diff --git a/Cargo.lock b/Cargo.lock index 2398038d6..ae11295ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -405,28 +405,6 @@ dependencies = [ "awint_macro_internals", ] -[[package]] -name = "aws-lc-rs" -version = "1.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "az" version = "1.3.0" @@ -1355,9 +1333,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -1772,12 +1750,6 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "duplicate" version = "2.0.1" @@ -1996,12 +1968,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "fsevent-sys" version = "4.1.0" @@ -2187,11 +2153,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -3371,12 +3335,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lsp-types" version = "0.94.1" @@ -4178,6 +4136,7 @@ dependencies = [ "flate2", "log", "reqwest", + "rustls", "serde", "sevenz-rust", "sha2 0.11.0", @@ -5558,62 +5517,6 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash 2.1.2", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash 2.1.2", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quizx" version = "0.3.0" @@ -6053,7 +5956,6 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -6218,8 +6120,8 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ - "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -6244,7 +6146,6 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ - "web-time", "zeroize", ] @@ -6281,7 +6182,6 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -8632,15 +8532,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -8674,30 +8565,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.2.1" @@ -8719,12 +8593,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -8737,12 +8605,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -8755,24 +8617,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -8785,12 +8635,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -8803,12 +8647,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -8821,12 +8659,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -8839,12 +8671,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index e659998e4..6ef46fc2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,9 +125,18 @@ petgraph = "0.8" rustworkx-core = "0.18" # --- Networking & archive handling --- +# Use rustls WITHOUT its default aws-lc-rs crypto provider: aws-lc-sys ships +# hand-written assembly whose .eh_frame rustc's bundled rust-lld mis-links +# ("invalid CIE reference"), breaking build-script links on some toolchains. +# We install the `ring` provider at runtime instead (see pecos-build download). reqwest = { version = "0.13", default-features = false, features = [ "blocking", - "rustls", + "rustls-no-provider", +] } +rustls = { version = "0.23", default-features = false, features = [ + "ring", + "std", + "tls12", ] } flate2 = "1" sha2 = "0.11" diff --git a/Justfile b/Justfile index 855f0abbf..6ad900494 100644 --- a/Justfile +++ b/Justfile @@ -89,6 +89,32 @@ doctor: _msvc-bootstrap fi echo "" + echo "Rust:" + if command -v rustup >/dev/null 2>&1; then + # CI runs the latest floating stable (we deliberately do not pin the + # toolchain), so a stale local stable can lint/build differently than CI. + # Advisory only -- never a hard failure. + RUST_HOST=$(rustc -vV 2>/dev/null | sed -n 's/^host: //p') + RUST_VER=$(rustc +stable --version 2>/dev/null | awk '{print $2}') || RUST_VER="" + # Only the host's stable line -- a machine may carry stable for several + # targets; head -1 guards if the host triple can't be determined. + RUST_CHECK=$(rustup check 2>/dev/null | grep -iE "^stable-${RUST_HOST:-}" | head -1 || true) + if [ -z "$RUST_VER" ]; then + echo " [--] stable: could not determine version (run: rustup toolchain install stable)" + elif [ -z "$RUST_CHECK" ]; then + echo " [--] stable: $RUST_VER (could not check latest -- offline?)" + elif echo "$RUST_CHECK" | grep -qi "update available"; then + LATEST=$(echo "$RUST_CHECK" | sed -E 's/.*-> ([0-9.]+).*/\1/') + echo " [--] stable: $RUST_VER installed, but ${LATEST:-a newer release} is out" + echo " CI runs the latest stable -- run 'rustup update stable' to match it." + else + ok "stable" "$RUST_VER (latest)" + fi + else + fail "rustup" "not found (see https://rustup.rs)" + fi + echo "" + echo "Python:" if command -v uv >/dev/null 2>&1; then ok "uv" "$(uv --version)" diff --git a/crates/pecos-build/Cargo.toml b/crates/pecos-build/Cargo.toml index 2164401fa..373444511 100644 --- a/crates/pecos-build/Cargo.toml +++ b/crates/pecos-build/Cargo.toml @@ -18,8 +18,11 @@ default = [] # Directory management dirs.workspace = true -# Downloading +# Downloading. reqwest uses rustls with no built-in provider; we install the +# ring crypto provider at runtime (see download.rs) to avoid aws-lc-sys, whose +# assembly rustc's bundled rust-lld mis-links. reqwest.workspace = true +rustls.workspace = true # Checksums sha2.workspace = true diff --git a/crates/pecos-build/src/cmake/installer.rs b/crates/pecos-build/src/cmake/installer.rs index 1dff74061..2bbbb77ec 100644 --- a/crates/pecos-build/src/cmake/installer.rs +++ b/crates/pecos-build/src/cmake/installer.rs @@ -194,6 +194,7 @@ fn download(url: &str, dest: &PathBuf) -> Result<()> { print!("Downloading cmake... "); io::Write::flush(&mut io::stdout())?; + crate::download::ensure_crypto_provider(); let response = reqwest::blocking::get(url).map_err(|e| Error::Http(e.to_string()))?; let total_size = response.content_length().unwrap_or(0); diff --git a/crates/pecos-build/src/cuda/installer.rs b/crates/pecos-build/src/cuda/installer.rs index 0e4bf90b1..03b09a04c 100644 --- a/crates/pecos-build/src/cuda/installer.rs +++ b/crates/pecos-build/src/cuda/installer.rs @@ -150,6 +150,7 @@ fn download_cuda(url: &str, dest: &Path) -> Result<()> { print!("Downloading CUDA Toolkit... "); io::stdout().flush()?; + crate::download::ensure_crypto_provider(); let response = reqwest::blocking::get(url).map_err(|e| Error::Http(e.to_string()))?; if !response.status().is_success() { diff --git a/crates/pecos-build/src/cuquantum/installer.rs b/crates/pecos-build/src/cuquantum/installer.rs index a08a0cc3a..dab665bf0 100644 --- a/crates/pecos-build/src/cuquantum/installer.rs +++ b/crates/pecos-build/src/cuquantum/installer.rs @@ -206,6 +206,7 @@ fn download_cuquantum(url: &str, dest: &Path) -> Result<()> { print!("Downloading cuQuantum SDK... "); io::stdout().flush()?; + crate::download::ensure_crypto_provider(); let response = reqwest::blocking::get(url).map_err(|e| Error::Http(e.to_string()))?; if !response.status().is_success() { diff --git a/crates/pecos-build/src/cutensor.rs b/crates/pecos-build/src/cutensor.rs index 28f938777..92046448f 100644 --- a/crates/pecos-build/src/cutensor.rs +++ b/crates/pecos-build/src/cutensor.rs @@ -195,6 +195,7 @@ fn get_download_info() -> Result<(String, String)> { /// Download a file from a URL, streaming to disk fn download(url: &str, dest: &Path) -> Result<()> { + crate::download::ensure_crypto_provider(); let mut response = reqwest::blocking::get(url).map_err(|e| Error::Http(e.to_string()))?; if !response.status().is_success() { diff --git a/crates/pecos-build/src/download.rs b/crates/pecos-build/src/download.rs index 69e84953e..4db5ddaad 100644 --- a/crates/pecos-build/src/download.rs +++ b/crates/pecos-build/src/download.rs @@ -3,6 +3,23 @@ use crate::errors::{Error, Result}; use crate::home::get_cache_dir; use std::fs; +use std::sync::Once; + +/// Install the process-wide rustls crypto provider (ring) exactly once. +/// +/// reqwest is built with `rustls-no-provider`, so it uses whichever +/// `CryptoProvider` is installed as the process default; building a client +/// without one panics. We use ring rather than the rustls default (aws-lc-rs) +/// because aws-lc-sys's assembly mis-links under rustc's bundled rust-lld. +/// Every function that builds a reqwest client must call this first. +pub fn ensure_crypto_provider() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + // Ignore the error: a provider may already be installed (e.g. by the + // host application), in which case ours is simply not needed. + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} /// Download info with URL and expected SHA256 pub struct DownloadInfo { @@ -49,6 +66,7 @@ pub fn download_cached(info: &DownloadInfo) -> Result> { // Download fresh with timeout and retry logic log::info!("Downloading {} (will be cached)", info.name); + ensure_crypto_provider(); let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_mins(5)) .connect_timeout(std::time::Duration::from_secs(30)) diff --git a/crates/pecos-build/src/llvm.rs b/crates/pecos-build/src/llvm.rs index 8f6ed03cd..cbaa5e5bd 100644 --- a/crates/pecos-build/src/llvm.rs +++ b/crates/pecos-build/src/llvm.rs @@ -81,6 +81,88 @@ pub fn is_required_llvm_version(version: &str) -> bool { .is_some_and(|rest| rest.starts_with('.')) } +/// An LLVM tool resolved via a bare `PATH` lookup, with the version it reports. +/// +/// Used to warn when a wrong-version tool (e.g. a system LLVM 14 `llvm-as`) +/// would be picked up at runtime instead of LLVM 21.1 — external tools such as +/// Selene resolve `llvm-as` straight from `PATH`, so a stale system LLVM ahead +/// of the PECOS one silently produces wrong results. +#[derive(Debug, Clone)] +pub struct PathToolReport { + /// Tool name, e.g. `"llvm-as"`. + pub tool: String, + /// Location resolved from `PATH`. + pub path: PathBuf, + /// Version string reported by ` --version`, if one could be parsed. + pub version: Option, + /// Whether the reported version satisfies the required LLVM version. + pub is_required: bool, +} + +/// Resolve an executable via `PATH`, mirroring how a bare `Command::new(name)` +/// (and external tools like Selene) find it. +#[must_use] +pub fn which_on_path(exe_name: &str) -> Option { + let exe = if cfg!(windows) { + format!("{exe_name}.exe") + } else { + exe_name.to_string() + }; + let paths = std::env::var_os("PATH")?; + std::env::split_paths(&paths) + .map(|dir| dir.join(&exe)) + .find(|candidate| candidate.is_file()) +} + +/// Parse an LLVM `--version` output into a bare `X.Y.Z` version string. +/// +/// Handles both `llvm-config --version` (prints `21.1.8`) and tools like +/// `llvm-as --version` (whose output contains `LLVM version 21.1.8`) by +/// returning the first dotted numeric token (at least `major.minor`). +#[must_use] +pub fn parse_llvm_version_output(output: &str) -> Option { + output + .split_whitespace() + .find(|token| { + let mut parts = token.split('.'); + token.starts_with(|c: char| c.is_ascii_digit()) + && parts.clone().count() >= 2 + && parts.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) + }) + .map(str::to_string) +} + +/// Run ` --version` and parse the LLVM version it reports. +#[must_use] +pub fn get_tool_llvm_version(tool_path: &Path) -> Option { + let output = Command::new(tool_path).arg("--version").output().ok()?; + if !output.status.success() { + return None; + } + parse_llvm_version_output(&String::from_utf8_lossy(&output.stdout)) +} + +/// Inspect the LLVM tools a bare `PATH` lookup resolves, reporting each one's +/// version and whether it is the required LLVM. A tool not found on `PATH` is +/// omitted from the result. +#[must_use] +pub fn inspect_path_llvm_tools(tools: &[&str]) -> Vec { + tools + .iter() + .filter_map(|&tool| { + let path = which_on_path(tool)?; + let version = get_tool_llvm_version(&path); + let is_required = version.as_deref().is_some_and(is_required_llvm_version); + Some(PathToolReport { + tool: tool.to_string(), + path, + version, + is_required, + }) + }) + .collect() +} + /// Find a compatible LLVM installation on the system. /// /// This function searches for LLVM in the following priority order: @@ -583,6 +665,36 @@ mod tests { assert!(!is_required_llvm_version("22.0.0")); } + #[test] + fn parse_llvm_version_handles_config_and_tool_output() { + // `llvm-config --version` prints the bare version. + assert_eq!( + parse_llvm_version_output("21.1.8\n").as_deref(), + Some("21.1.8") + ); + // `llvm-as --version` prints a block; the first dotted numeric token is + // the LLVM version, not the target triple or CPU line. + let llvm_as = " LLVM (http://llvm.org/):\n LLVM version 14.0.0\n \ + Optimized build.\n Default target: x86_64-pc-linux-gnu\n"; + assert_eq!( + parse_llvm_version_output(llvm_as).as_deref(), + Some("14.0.0") + ); + assert_eq!(parse_llvm_version_output("no version here"), None); + } + + #[test] + fn path_tool_report_flags_wrong_version() { + // Version parsing + is_required together decide whether a PATH tool is + // acceptable; a system LLVM 14 must be flagged, 21.1.x accepted. + assert!(!is_required_llvm_version( + &parse_llvm_version_output("LLVM version 14.0.0").unwrap() + )); + assert!(is_required_llvm_version( + &parse_llvm_version_output("LLVM version 21.1.8").unwrap() + )); + } + #[test] fn normalize_path_string_strips_windows_verbatim_drive_prefix() { assert_eq!( diff --git a/crates/pecos-build/src/llvm/installer.rs b/crates/pecos-build/src/llvm/installer.rs index 3c4fcf64b..5600ac2d8 100644 --- a/crates/pecos-build/src/llvm/installer.rs +++ b/crates/pecos-build/src/llvm/installer.rs @@ -383,6 +383,7 @@ fn download_file(url: &str, dest: &Path, label: &str) -> Result<()> { print!("Downloading {label}... "); io::Write::flush(&mut io::stdout())?; + crate::download::ensure_crypto_provider(); let response = reqwest::blocking::get(url).map_err(|e| Error::Http(e.to_string()))?; if !response.status().is_success() { return Err(Error::Http(format!( diff --git a/crates/pecos-cli/src/cli/llvm_cmd.rs b/crates/pecos-cli/src/cli/llvm_cmd.rs index 310f555e1..aad7fa1eb 100644 --- a/crates/pecos-cli/src/cli/llvm_cmd.rs +++ b/crates/pecos-cli/src/cli/llvm_cmd.rs @@ -49,6 +49,7 @@ fn run_check(quiet: bool) { // Validate configuration validation.print_warnings(); + warn_wrong_path_llvm_tools(); } // Exit with error if config is unhealthy (would cause build failures). @@ -71,11 +72,36 @@ fn run_check(quiet: bool) { } else { eprintln!("Install with: `{cmd} install llvm`"); } + warn_wrong_path_llvm_tools(); } std::process::exit(1); } } +/// Warn when LLVM tools resolved from `PATH` are the wrong version. Selene and +/// PECOS's own runtime `PATH` fallback resolve `llvm-as` this way, so a system +/// LLVM ahead of 21.1 silently breaks QIS execution even when +/// `.cargo/config.toml` is correct. +fn warn_wrong_path_llvm_tools() { + for report in pecos_build::llvm::inspect_path_llvm_tools(&["llvm-as", "llvm-config"]) { + let Some(version) = &report.version else { + continue; + }; + if !report.is_required { + eprintln!(); + eprintln!( + "Warning: '{}' on PATH is LLVM {version} ({}), not {REQUIRED_VERSION}", + report.tool, + report.path.display() + ); + eprintln!( + " External tools (Selene) resolve {} from PATH; put LLVM {REQUIRED_VERSION}'s bin directory ahead on PATH.", + report.tool + ); + } + } +} + fn run_ensure(managed: bool, no_configure: bool) -> Result<()> { let llvm_path = if managed { ensure_managed_llvm(no_configure)? diff --git a/crates/pecos-cli/src/main.rs b/crates/pecos-cli/src/main.rs index d2c252643..a0258f3dd 100644 --- a/crates/pecos-cli/src/main.rs +++ b/crates/pecos-cli/src/main.rs @@ -1006,6 +1006,37 @@ fn run_doctor() { )); } } + + // Warn when a wrong-version LLVM tool would be resolved from PATH. External + // tools (Selene's llvm-as) and PECOS's own runtime PATH fallback pick these + // up, so a system LLVM ahead of 21.1 silently breaks QIS execution. + for report in pecos_build::llvm::inspect_path_llvm_tools(&["llvm-as", "llvm-config"]) { + let label = format!("PATH {}", report.tool); + match report.version { + Some(ref v) if report.is_required => { + print_check(&label, true, &format!("{v} at {}", report.path.display())); + } + Some(ref v) => { + print_check( + &label, + false, + &format!("{v} at {} (need 21.1)", report.path.display()), + ); + problems.push(format!( + "{} on PATH is LLVM {v}, not 21.1 ({}). External tools like Selene resolve it from PATH; put LLVM 21.1's bin directory ahead on PATH.", + report.tool, + report.path.display() + )); + } + None => { + print_check( + &label, + false, + &format!("version unknown at {}", report.path.display()), + ); + } + } + } println!(); // --- Python / uv --- diff --git a/crates/pecos-core/src/bit.rs b/crates/pecos-core/src/bit.rs index f8842f602..8a9fbfdf3 100644 --- a/crates/pecos-core/src/bit.rs +++ b/crates/pecos-core/src/bit.rs @@ -811,10 +811,10 @@ mod tests { #[test] fn test_comparison_with_bool() { - assert!(Bit::ONE == true); - assert!(Bit::ZERO == false); - assert!(true == Bit::ONE); - assert!(false == Bit::ZERO); + assert_eq!(Bit::ONE, true); + assert_eq!(Bit::ZERO, false); + assert_eq!(true, Bit::ONE); + assert_eq!(false, Bit::ZERO); } #[test] diff --git a/crates/pecos-core/src/unitary_rep.rs b/crates/pecos-core/src/unitary_rep.rs index 04e4917e5..c0b812209 100644 --- a/crates/pecos-core/src/unitary_rep.rs +++ b/crates/pecos-core/src/unitary_rep.rs @@ -2279,11 +2279,8 @@ impl UnitaryRep { // For tensor products, compose all parts (they act on different qubits) let mut result = CliffordRep::identity(num_qubits); for part in parts { - if let Some(cliff) = part.to_clifford_rep(num_qubits) { - result = result.compose(&cliff); - } else { - return None; - } + let cliff = part.to_clifford_rep(num_qubits)?; + result = result.compose(&cliff); } Some(result) } @@ -2292,11 +2289,8 @@ impl UnitaryRep { // For composition, compose in order (parts are in application order) let mut result = CliffordRep::identity(num_qubits); for part in parts { - if let Some(cliff) = part.to_clifford_rep(num_qubits) { - result = cliff.compose(&result); - } else { - return None; - } + let cliff = part.to_clifford_rep(num_qubits)?; + result = cliff.compose(&result); } Some(result) } diff --git a/crates/pecos-engines/src/monte_carlo/builder.rs b/crates/pecos-engines/src/monte_carlo/builder.rs index e039c967d..b617d4d8c 100644 --- a/crates/pecos-engines/src/monte_carlo/builder.rs +++ b/crates/pecos-engines/src/monte_carlo/builder.rs @@ -431,7 +431,10 @@ mod tests { .build(); // Just verify that it was created without panic - assert!(engine.hybrid_engine_template.classical_engine.num_qubits() == 2); + assert_eq!( + engine.hybrid_engine_template.classical_engine.num_qubits(), + 2 + ); } #[test] @@ -444,7 +447,10 @@ mod tests { .build(); // Just verify that it was created without panic - assert!(engine.hybrid_engine_template.classical_engine.num_qubits() == 2); + assert_eq!( + engine.hybrid_engine_template.classical_engine.num_qubits(), + 2 + ); } #[test] @@ -481,7 +487,10 @@ mod tests { .build(); // Just verify that it was created without panic - assert!(engine.hybrid_engine_template.classical_engine.num_qubits() == 2); + assert_eq!( + engine.hybrid_engine_template.classical_engine.num_qubits(), + 2 + ); } #[test] @@ -493,7 +502,10 @@ mod tests { .build(); // Just verify that it was created without panic - assert!(engine.hybrid_engine_template.classical_engine.num_qubits() == 2); + assert_eq!( + engine.hybrid_engine_template.classical_engine.num_qubits(), + 2 + ); } #[test] @@ -511,6 +523,9 @@ mod tests { .build(); // Just verify that it was created without panic - assert!(engine.hybrid_engine_template.classical_engine.num_qubits() == 2); + assert_eq!( + engine.hybrid_engine_template.classical_engine.num_qubits(), + 2 + ); } } diff --git a/crates/pecos-llvm/build.rs b/crates/pecos-llvm/build.rs index 40337fad9..2f364bdf6 100644 --- a/crates/pecos-llvm/build.rs +++ b/crates/pecos-llvm/build.rs @@ -32,11 +32,30 @@ fn validate_llvm() { panic!("Invalid {LLVM_SYS_PREFIX_ENV}. See error message above."); } - // LLVM_SYS_PREFIX_ENV not set - print setup instructions - print_llvm_not_found_error_extended(); - panic!( - "LLVM {REQUIRED_VERSION} not configured. See error message above for setup instructions." - ); + // LLVM_SYS_PREFIX_ENV not set. Reaching here means llvm-sys already located + // a usable LLVM (via PATH llvm-config) without an explicit prefix. If a + // valid LLVM 21.1 exists, auto-configure .cargo/config.toml so the prefix is + // pinned for future builds and the runtime bin-path embed works, then use it + // for this build. Installing a missing LLVM stays with `pecos setup`. + if let Ok(path) = pecos_build::llvm::config::auto_configure_llvm(None) { + eprintln!( + "pecos-llvm: auto-configured .cargo/config.toml to use LLVM {REQUIRED_VERSION} at {}", + path.display() + ); + // SAFETY: build scripts run single-threaded; set the prefix so any + // downstream tooling in this build sees the resolved installation. + unsafe { + env::set_var( + LLVM_SYS_PREFIX_ENV, + pecos_build::llvm::path_to_env_string(&path), + ); + } + } else { + print_llvm_not_found_error_extended(); + panic!( + "LLVM {REQUIRED_VERSION} not configured. See error message above for setup instructions." + ); + } } fn print_llvm_not_found_error_extended() { diff --git a/crates/pecos-num/src/math.rs b/crates/pecos-num/src/math.rs index 251a06699..5a942e8e5 100644 --- a/crates/pecos-num/src/math.rs +++ b/crates/pecos-num/src/math.rs @@ -2166,7 +2166,7 @@ mod tests { #[test] fn test_exp_special_cases() { // Test special values - assert!(exp(f64::NEG_INFINITY) == 0.0); + assert_eq!(exp(f64::NEG_INFINITY).to_bits(), 0.0_f64.to_bits()); assert!(exp(f64::INFINITY).is_infinite()); assert!(exp(f64::NAN).is_nan()); } diff --git a/crates/pecos-phir-json/src/lib.rs b/crates/pecos-phir-json/src/lib.rs index 6a7514993..85be1a827 100644 --- a/crates/pecos-phir-json/src/lib.rs +++ b/crates/pecos-phir-json/src/lib.rs @@ -255,7 +255,7 @@ mod tests { phir_engine .processor .environment - .add_variable("result", v0_1::environment::DataType::I32, 32) + .add_variable("result", v0_1::environment::DataType::I32, 31) .ok(); phir_engine.processor.environment.set("result", 1).ok(); diff --git a/crates/pecos-phir-json/src/v0_1/ast.rs b/crates/pecos-phir-json/src/v0_1/ast.rs index 2a81b0aff..6c7d41d5a 100644 --- a/crates/pecos-phir-json/src/v0_1/ast.rs +++ b/crates/pecos-phir-json/src/v0_1/ast.rs @@ -361,8 +361,11 @@ pub const MEASUREMENT_PREFIX: &str = "measurement_"; /// Infer variable size from data type when not explicitly provided. /// -/// For types like "i32", "u64", extracts the bit width from the name. -/// For "qubits", returns 0 (size must be explicit). +/// For types like "i32", "u64", extracts the bit width from the name. A signed +/// type defaults to `N - 1` because a signed size-S register is an `i(S+1)` +/// integer, so the full backing type `iN` holds `N - 1` data bits plus a sign +/// bit. Unsigned types default to their full `N` bits. For "qubits", returns 0 +/// (size must be explicit). #[must_use] pub fn infer_size(data_type: &str, explicit_size: Option) -> usize { if let Some(s) = explicit_size { @@ -370,7 +373,12 @@ pub fn infer_size(data_type: &str, explicit_size: Option) -> usize { } // Try to extract bit width from type name (e.g., "i32" -> 32, "u64" -> 64) let digits: String = data_type.chars().filter(char::is_ascii_digit).collect(); - digits.parse().unwrap_or(0) + let width: usize = digits.parse().unwrap_or(0); + if width > 0 && data_type.starts_with('i') { + width - 1 + } else { + width + } } #[cfg(test)] diff --git a/crates/pecos-phir-json/src/v0_1/block_executor.rs b/crates/pecos-phir-json/src/v0_1/block_executor.rs index 3c98e619c..c5834a281 100644 --- a/crates/pecos-phir-json/src/v0_1/block_executor.rs +++ b/crates/pecos-phir-json/src/v0_1/block_executor.rs @@ -475,13 +475,13 @@ impl BlockExecutor { /// Process export mappings to determine values to return from simulations #[must_use] - pub fn process_export_mappings(&self) -> BTreeMap { + pub fn process_export_mappings(&self) -> BTreeMap { self.processor.process_export_mappings() } /// Get mapped results for output (alias for `process_export_mappings`) #[must_use] - pub fn get_mapped_results(&self) -> BTreeMap { + pub fn get_mapped_results(&self) -> BTreeMap { self.processor.process_export_mappings() } @@ -492,7 +492,7 @@ impl BlockExecutor { pub fn execute_program( &mut self, program: &[Operation], - ) -> Result, PecosError> { + ) -> Result, PecosError> { debug!("Executing PHIR program with {} operations", program.len()); // Reset state before execution @@ -528,7 +528,7 @@ mod tests { // Add variables for testing executor.add_quantum_variable("q", 2).unwrap(); - executor.add_classical_variable("c", "i32", 32).unwrap(); + executor.add_classical_variable("c", "i32", 31).unwrap(); // Execute a simple assignment operation let op = Operation::ClassicalOp { @@ -552,8 +552,8 @@ mod tests { let mut executor = BlockExecutor::new(); // Add variables for testing - executor.add_classical_variable("x", "i32", 32).unwrap(); - executor.add_classical_variable("y", "i32", 32).unwrap(); + executor.add_classical_variable("x", "i32", 31).unwrap(); + executor.add_classical_variable("y", "i32", 31).unwrap(); // Set initial values executor.get_environment_mut().set_raw("x", 10).unwrap(); @@ -607,8 +607,8 @@ mod tests { let mut executor = BlockExecutor::new(); // Add variables for testing - executor.add_classical_variable("a", "i32", 32).unwrap(); - executor.add_classical_variable("b", "i32", 32).unwrap(); + executor.add_classical_variable("a", "i32", 31).unwrap(); + executor.add_classical_variable("b", "i32", 31).unwrap(); // Create a sequence of operations let operations = vec![ @@ -718,8 +718,8 @@ mod tests { let mut executor = BlockExecutor::new(); // Add variables for testing - executor.add_classical_variable("x", "i32", 32).unwrap(); - executor.add_classical_variable("y", "i32", 32).unwrap(); + executor.add_classical_variable("x", "i32", 31).unwrap(); + executor.add_classical_variable("y", "i32", 31).unwrap(); // Set initial value executor.get_environment_mut().set_raw("x", 10).unwrap(); @@ -778,7 +778,7 @@ mod tests { // Add variables for testing executor.add_quantum_variable("q", 2).unwrap(); - executor.add_classical_variable("m", "i32", 32).unwrap(); + executor.add_classical_variable("m", "i32", 31).unwrap(); // Create measurement operations for testing let operations = vec![Operation::QuantumOp { @@ -808,8 +808,8 @@ mod tests { let mut executor = BlockExecutor::new(); // Add variables for testing - executor.add_classical_variable("a", "i32", 32).unwrap(); - executor.add_classical_variable("b", "i32", 32).unwrap(); + executor.add_classical_variable("a", "i32", 31).unwrap(); + executor.add_classical_variable("b", "i32", 31).unwrap(); // Set values executor.get_environment_mut().set_raw("a", 10).unwrap(); @@ -838,7 +838,7 @@ mod tests { data: "cvar_define".to_string(), data_type: "i32".to_string(), variable: "x".to_string(), - size: Some(32), + size: Some(31), }, Operation::ClassicalOp { cop: "=".to_string(), diff --git a/crates/pecos-phir-json/src/v0_1/block_iterative_executor.rs b/crates/pecos-phir-json/src/v0_1/block_iterative_executor.rs index 001b703ae..f9ec5308e 100644 --- a/crates/pecos-phir-json/src/v0_1/block_iterative_executor.rs +++ b/crates/pecos-phir-json/src/v0_1/block_iterative_executor.rs @@ -362,7 +362,7 @@ mod tests { let mut executor = BlockExecutor::new(); // Add a variable for testing - executor.add_classical_variable("x", "i32", 32).unwrap(); + executor.add_classical_variable("x", "i32", 31).unwrap(); // Create a sequence of operations let operations = vec![ @@ -401,8 +401,8 @@ mod tests { let mut executor = BlockExecutor::new(); // Add variables for testing - executor.add_classical_variable("x", "i32", 32).unwrap(); - executor.add_classical_variable("y", "i32", 32).unwrap(); + executor.add_classical_variable("x", "i32", 31).unwrap(); + executor.add_classical_variable("y", "i32", 31).unwrap(); // Set initial value executor.get_environment_mut().set_raw("x", 10).unwrap(); @@ -461,9 +461,9 @@ mod tests { let mut executor = BlockExecutor::new(); // Add variables for testing - executor.add_classical_variable("x", "i32", 32).unwrap(); - executor.add_classical_variable("y", "i32", 32).unwrap(); - executor.add_classical_variable("z", "i32", 32).unwrap(); + executor.add_classical_variable("x", "i32", 31).unwrap(); + executor.add_classical_variable("y", "i32", 31).unwrap(); + executor.add_classical_variable("z", "i32", 31).unwrap(); // Set initial values executor.get_environment_mut().set_raw("x", 10).unwrap(); @@ -544,7 +544,7 @@ mod tests { // Add variables for testing executor.add_quantum_variable("q", 2).unwrap(); - executor.add_classical_variable("m", "i32", 32).unwrap(); + executor.add_classical_variable("m", "i32", 31).unwrap(); // Create operations with measurements let operations = vec![ @@ -593,7 +593,7 @@ mod tests { let mut executor = BlockExecutor::new(); // Add a variable for testing - executor.add_classical_variable("x", "i32", 32).unwrap(); + executor.add_classical_variable("x", "i32", 31).unwrap(); // Create a sequence of operations let operations = vec![ diff --git a/crates/pecos-phir-json/src/v0_1/classical_interpreter.rs b/crates/pecos-phir-json/src/v0_1/classical_interpreter.rs index 8d0097f30..d312f7cc4 100644 --- a/crates/pecos-phir-json/src/v0_1/classical_interpreter.rs +++ b/crates/pecos-phir-json/src/v0_1/classical_interpreter.rs @@ -339,24 +339,26 @@ impl PhirClassicalInterpreter { ); } } else { - // Match Python: format(cval, '0{size}b') - // - // Python stores values in PECOS signed dtypes. When the value - // is negative (determined by the TYPE's sign interpretation), - // format() shows "-" prefix + magnitude. We use as_i64() which - // sign-extends from the register's bit width -- this matches - // Python behavior because Python stores the sign-extended value - // in the full-width dtype. - let signed_val = val.as_i64(); - let bits = if info.data_type.is_signed() && signed_val < 0 { - format!( - "-{:0>width$b}", - signed_val.unsigned_abs(), - width = info.size - ) + // A signed size-S register is an i(S+1) integer, so it + // prints S+1 bits (S data bits + a sign bit); an unsigned + // size-S register prints S bits. Register widths are + // validated to fit the backing type at definition time. + let width = if info.data_type.is_signed() { + info.size + 1 } else { - format!("{:0>width$b}", val.as_u64(), width = info.size) + info.size }; + // Render the raw two's-complement bit pattern rather than + // sign-and-magnitude (which prints a leading "-" for + // negative values). `as_u64()` already holds the value + // sign-extended, so reinterpreting it at `width` bits makes + // the sign bit print as a "1"/"0" like every other bit. + let mask = if width >= 64 { + u64::MAX + } else { + (1u64 << width) - 1 + }; + let bits = format!("{:0>width$b}", val.as_u64() & mask, width = width); result.insert(info.name.clone(), ResultValue::BitString(bits)); } } @@ -590,14 +592,14 @@ impl PhirClassicalInterpreter { match ret { ArgItem::Simple(var) => { if !self.environment.has_variable(var) { - self.environment.add_variable(var, DataType::I32, 32)?; + self.environment.add_variable(var, DataType::I32, 31)?; } #[allow(clippy::cast_sign_loss)] self.environment.set_raw(var, val as u64)?; } ArgItem::Indexed((var, idx)) => { if !self.environment.has_variable(var) { - self.environment.add_variable(var, DataType::I32, 32)?; + self.environment.add_variable(var, DataType::I32, 31)?; } self.environment.set_bit(var, *idx, (val & 1) != 0)?; } @@ -659,14 +661,14 @@ impl PhirClassicalInterpreter { match ret { ArgItem::Simple(var) => { if !self.environment.has_variable(var) { - self.environment.add_variable(var, DataType::I32, 32)?; + self.environment.add_variable(var, DataType::I32, 31)?; } #[allow(clippy::cast_sign_loss)] self.environment.set_raw(var, result[i] as u64)?; } ArgItem::Indexed((var, idx)) => { if !self.environment.has_variable(var) { - self.environment.add_variable(var, DataType::I32, 32)?; + self.environment.add_variable(var, DataType::I32, 31)?; } self.environment.set_bit(var, *idx, (result[i] & 1) != 0)?; } @@ -913,6 +915,75 @@ mod tests { } } + #[test] + fn test_negative_signed_register_is_twos_complement() { + // A signed size-S register is an i(S+1) integer. A full-width register + // (S = N-1) can hold any iN value and prints its N-bit two's-complement + // pattern -- the sign bit as "1"/"0", never a sign-and-magnitude "-...". + // A narrow signed register prints S+1 bits and can also go negative. + let mut interp = PhirClassicalInterpreter::new(); + interp.init(SIMPLE_PROGRAM, None).unwrap(); + + // Full-width i32 == i(31 + 1). + interp.add_cvar("w", DataType::I32, 31).unwrap(); + interp.add_cvar("n", DataType::I32, 31).unwrap(); + // Narrow signed register: i32 size 4 is an i5, range [-16, 15]. + interp.add_cvar("narrow", DataType::I32, 4).unwrap(); + + // -1 stored as its two's-complement pattern. + interp.environment.set("w", 0xFFFF_FFFF).unwrap(); + interp.environment.set("n", 5).unwrap(); + interp.environment.set("narrow", 0xFFFF_FFFF).unwrap(); + + let results = interp.results(false); + match results.get("w").unwrap() { + ResultValue::BitString(s) => { + assert_eq!(s, &"1".repeat(32)); + assert!(!s.contains('-')); + } + other => panic!("Expected BitString, got {other:?}"), + } + match results.get("n").unwrap() { + ResultValue::BitString(s) => assert_eq!(s, "00000000000000000000000000000101"), + other => panic!("Expected BitString, got {other:?}"), + } + match results.get("narrow").unwrap() { + ResultValue::BitString(s) => { + assert_eq!(s, "11111"); // i5: -1 -> five ones + assert_eq!(s.len(), 5); // size 4 + 1 sign bit + } + other => panic!("Expected BitString, got {other:?}"), + } + + // The integer view sees the correct signed values. + let ints = interp.results(true); + match ints.get("w").unwrap() { + ResultValue::Int(v, _) => assert_eq!(*v, -1), + other => panic!("Expected Int, got {other:?}"), + } + match ints.get("narrow").unwrap() { + ResultValue::Int(v, _) => assert_eq!(*v, -1), + other => panic!("Expected Int, got {other:?}"), + } + } + + #[test] + fn test_oversized_signed_register_errors() { + // A signed size-S register is an i(S+1) integer, so size + 1 must fit + // the backing width. i32 size 32 would need 33 bits -> fail fast. + let mut interp = PhirClassicalInterpreter::new(); + interp.init(SIMPLE_PROGRAM, None).unwrap(); + + let err = interp.add_cvar("bad", DataType::I32, 32).unwrap_err(); + assert!( + err.to_string() + .contains("does not fit its 32-bit backing type"), + "unexpected error: {err}" + ); + // Unsigned u32 size 32 is a valid u(32). + interp.add_cvar("ok", DataType::U32, 32).unwrap(); + } + #[test] fn test_result_bits_filters_private() { let mut interp = PhirClassicalInterpreter::new(); diff --git a/crates/pecos-phir-json/src/v0_1/engine.rs b/crates/pecos-phir-json/src/v0_1/engine.rs index afee58732..8a3e314f7 100644 --- a/crates/pecos-phir-json/src/v0_1/engine.rs +++ b/crates/pecos-phir-json/src/v0_1/engine.rs @@ -11,19 +11,51 @@ use std::any::Any; use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; +/// A frame of the execution stack: a slice of operations and the index of the +/// next one to process. Blocks (`if`/`sequence`/`qparallel`) push a new frame +/// so their child ops flow through the same measurement-boundary deferral as +/// top-level ops, with arbitrary nesting. +#[derive(Debug, Clone)] +struct ExecFrame { + ops: Vec, + idx: usize, +} + /// `PhirJsonEngine` processes PHIR programs and generates quantum operations #[derive(Debug)] pub struct PhirJsonEngine { /// The loaded PHIR program program: Option, - /// Current operation index being processed - current_op: usize, + /// Execution cursor: a stack of operation frames. The bottom frame is the + /// top-level program; blocks push nested frames. Persists across batch + /// yields so a batch can end mid-block and resume after measurements land. + exec_stack: Vec, /// Operation processor for handling different operation types pub processor: OperationProcessor, /// Builder for constructing `ByteMessages` message_builder: ByteMessageBuilder, } +impl PhirJsonEngine { + /// Build the initial execution stack from the loaded program (a single + /// bottom frame over the top-level ops), or empty if no program. + fn initial_stack(program: Option<&PHIRProgram>) -> Vec { + program.map_or_else(Vec::new, |p| { + vec![ExecFrame { + ops: p.ops.clone(), + idx: 0, + }] + }) + } + + /// Advance the top execution frame past the current op. + fn advance_cursor(&mut self) { + if let Some(frame) = self.exec_stack.last_mut() { + frame.idx += 1; + } + } +} + impl PhirJsonEngine { /// Sets a foreign object for executing foreign function calls pub fn set_foreign_object(&mut self, foreign_object: Box) { @@ -132,8 +164,8 @@ impl PhirJsonEngine { } Ok(Self { + exec_stack: Self::initial_stack(Some(&program)), program: Some(program), - current_op: 0, processor, message_builder: ByteMessageBuilder::new(), }) @@ -171,8 +203,8 @@ impl PhirJsonEngine { } Ok(Self { + exec_stack: Self::initial_stack(Some(&program)), program: Some(program), - current_op: 0, processor, message_builder: ByteMessageBuilder::new(), }) @@ -184,13 +216,10 @@ impl PhirJsonEngine { /// This no longer preserves and restores variable values during reset, as they /// should be recomputed during program execution. fn reset_state(&mut self) { - debug!( - "INTERNAL RESET: PhirJsonEngine reset, current_op={}", - self.current_op - ); + debug!("INTERNAL RESET: PhirJsonEngine reset"); - // Reset the operation index to start from the beginning - self.current_op = 0; + // Reset the execution cursor to the start of the program. + self.exec_stack = Self::initial_stack(self.program.as_ref()); // Log operations for debugging if needed if log::log_enabled!(log::Level::Debug) @@ -214,382 +243,153 @@ impl PhirJsonEngine { fn empty() -> Self { Self { program: None, - current_op: 0, + exec_stack: Vec::new(), processor: OperationProcessor::new(), message_builder: ByteMessageBuilder::new(), } } - #[allow(clippy::too_many_lines)] fn generate_commands_impl(&mut self) -> Result, PecosError> { - // Define a maximum batch size for better performance - // This helps avoid creating excessively large messages + // Maximum quantum ops per batch, to bound message size. const MAX_BATCH_SIZE: usize = 100; - debug!("generate_commands called, current_op: {}", self.current_op); - - debug!( - "Generating commands - thread {:?}, current_op: {}", - std::thread::current().id(), - self.current_op - ); - - // Get program reference and clone ops to avoid borrow issues - let prog = self.program.as_ref().ok_or_else(|| { - PecosError::Resource("Cannot generate commands: No PHIR program loaded".to_string()) - })?; - let ops = prog.ops.clone(); - - // If we've processed all ops, return None to signal completion - if self.current_op >= ops.len() { - debug!( - "End of program reached at op {}, no more commands to generate", - self.current_op - ); - - // With our updated HybridEngine and ControlEngine implementations, - // we can now consistently return None when there are no more commands, - // even for the first batch. - return Ok(None); - } - - debug!( - "Current operation to process: {} - {:?}", - self.current_op, ops[self.current_op] - ); - - // Reset and configure the reusable message builder for quantum operations + // Reset per-batch state. self.message_builder.reset(); let _ = self.message_builder.for_quantum_operations(); + self.processor.clear_pending_measurements(); let mut operation_count = 0; - while self.current_op < ops.len() && operation_count < MAX_BATCH_SIZE { - match &ops[self.current_op] { + loop { + // Drop exhausted frames (finished blocks / end of program). + while self.exec_stack.last().is_some_and(|f| f.idx >= f.ops.len()) { + self.exec_stack.pop(); + } + let Some(frame) = self.exec_stack.last() else { + // Nothing left to process. Flush any queued quantum ops. + if operation_count > 0 { + return Ok(Some(self.message_builder.build())); + } + debug!("Execution stack empty; shot complete"); + return Ok(None); + }; + let op = frame.ops[frame.idx].clone(); + + match &op { Operation::VariableDefinition { data, data_type, variable, size, } => { - debug!("Processing variable definition: {data} {data_type} {variable}"); let _ = self.processor.handle_variable_definition( data, data_type, variable, infer_size(data_type, *size), ); - self.current_op += 1; - return self.generate_commands_impl(); + self.advance_cursor(); } Operation::QuantumOp { qop, angles, args, - returns: _, - metadata: _, + returns, + .. } => { - debug!("Processing quantum operation: {qop}"); - - // Clone the operation parameters to avoid borrow issues - let qop_str = qop.clone(); - let args_clone = args.clone(); - let angles_clone = angles.clone(); - - // Process the quantum operation with angles in radians - match self.processor.process_quantum_op( - &qop_str, - angles_clone.as_ref(), - &args_clone, - ) { - Ok((gate_type, qubit_args, angle_args)) => { - // Add the gate to the builder - self.processor.add_quantum_operation_to_builder( - &mut self.message_builder, - &gate_type, - &qubit_args, - &angle_args, - )?; - - operation_count += 1; - debug!("Added quantum operation to builder"); - } - Err(e) => return Err(e), + let (gate_type, qubit_args, angle_args) = + self.processor + .process_quantum_op(qop, angles.as_ref(), args)?; + self.processor.add_quantum_operation_to_builder( + &mut self.message_builder, + &gate_type, + &qubit_args, + &angle_args, + )?; + if gate_type == "Measure" { + // Record the return registers so positional outcomes map + // back to them -- works for measurements queued from + // inside blocks too, which a top-level scan would miss. + // One slot per measured qubit keeps outcomes aligned. + self.processor + .record_measurement_returns(qubit_args.len(), returns)?; + } + operation_count += 1; + self.advance_cursor(); + if operation_count >= MAX_BATCH_SIZE { + debug!("Reached maximum batch size ({MAX_BATCH_SIZE}), returning batch"); + return Ok(Some(self.message_builder.build())); } } Operation::ClassicalOp { - cop, - args, - returns, - metadata: _, - function, + cop, args, returns, .. } => { - debug!("Processing classical operation: {cop}"); - - // Debug log specially for ffcall operations - if cop == "ffcall" { + // A classical op may read a register measured earlier in this + // batch; its result is not available until the batch runs. + // Yield the queued quantum ops first (leaving the cursor in + // place) so measurements land before this op executes. + if operation_count > 0 { debug!( - "Found ffcall operation: function={function:?}, args={args:?}, returns={returns:?}" + "Deferring classical op '{cop}' until batch measurements are applied" ); + return Ok(Some(self.message_builder.build())); } - - if self.processor.handle_classical_op( + // Pass the op itself so an ffcall can find its function name. + let ended = self.processor.handle_classical_op( cop, args, returns, - &ops, - self.current_op, - )? { - debug!("Finishing batch due to classical operation completion"); - self.current_op += 1; - - // Build and return the message - if operation_count > 0 { - debug!("Returning batch with {operation_count} operations"); - return Ok(Some(self.message_builder.build())); - } - - // Create an empty message if no operations were added - debug!("Returning empty batch after classical operation"); - return Ok(Some(ByteMessage::builder().build())); + std::slice::from_ref(&op), + 0, + )?; + self.advance_cursor(); + if ended { + return Ok(Some(self.message_builder.build())); } } Operation::Block { block, - ops, + ops: block_ops, condition, true_branch, false_branch, .. } => { - debug!("Processing block operation: {block}"); - match block.as_str() { "if" => { - // Process if/else block - if let Some(_cond) = condition - && let (Some(tb), fb) = (true_branch, false_branch) - { - // Get operations based on condition - let branch_ops = self.processor.process_conditional_block( - condition.as_ref().expect("checked Some above"), - tb, - fb.as_deref(), - )?; - - // Replace the current op with the branch operations - // This is a simplification - a more robust implementation would - // involve temporarily changing the ops list - for branch_op in &branch_ops { - match branch_op { - Operation::QuantumOp { - qop, angles, args, .. - } => { - // Process each quantum operation in the branch - let qop_str = qop.clone(); - let args_clone = args.clone(); - let angles_clone = angles.clone(); - - match self.processor.process_quantum_op( - &qop_str, - angles_clone.as_ref(), - &args_clone, - ) { - Ok((gate_type, qubit_args, angle_args)) => { - self.processor - .add_quantum_operation_to_builder( - &mut self.message_builder, - &gate_type, - &qubit_args, - &angle_args, - )?; - operation_count += 1; - } - Err(e) => return Err(e), - } - } - Operation::ClassicalOp { - cop, - args, - returns, - metadata: _, - function, - } => { - debug!( - "Processing classical operation in branch: {cop}" - ); - // Handle classical operations from conditional branches - if cop == "ffcall" { - debug!( - "Processing ffcall in branch: function={function:?}, args={args:?}, returns={returns:?}" - ); - } - // For ffcall operations from branches, we need to handle them specially - // because they have the function name directly in the operation - if cop == "ffcall" { - // Create a temporary operation list with just this operation - // This ensures handle_classical_op can find the function name - let temp_ops = vec![branch_op.clone()]; - if self.processor.handle_classical_op( - cop, args, returns, &temp_ops, - 0, // The operation is at index 0 in temp_ops - )? { - debug!( - "Classical ffcall operation in branch completed" - ); - } - } else { - // For other classical operations, use the original ops - if self.processor.handle_classical_op( - cop, - args, - returns, - &branch_ops, - 0, // Index within branch ops - )? { - debug!( - "Classical operation in branch completed" - ); - } - } - } - _ => { - // For other operation types, we'll handle them later - debug!( - "Skipping other operation type in branch: {branch_op:?}" - ); - } - } - } - } - } - "qparallel" => { - // Process qparallel block - let parallel_ops = self.processor.process_block(block, ops)?; - - for parallel_op in parallel_ops { - match parallel_op { - Operation::QuantumOp { - qop, angles, args, .. - } => { - // Process each quantum operation in the parallel block - let qop_str = qop.clone(); - let args_clone = args.clone(); - let angles_clone = angles.clone(); - - match self.processor.process_quantum_op( - &qop_str, - angles_clone.as_ref(), - &args_clone, - ) { - Ok((gate_type, qubit_args, angle_args)) => { - self.processor.add_quantum_operation_to_builder( - &mut self.message_builder, - &gate_type, - &qubit_args, - &angle_args, - )?; - operation_count += 1; - } - Err(e) => return Err(e), - } - } - _ => { - // For other operation types, we'll handle them later - debug!("Skipping non-quantum operation in qparallel block"); - } - } + // A conditional's condition may read a register + // measured earlier in this batch; defer until the + // batch's measurements are applied. + if operation_count > 0 { + debug!( + "Deferring conditional block until batch measurements are applied" + ); + return Ok(Some(self.message_builder.build())); } + let branch = match (condition, true_branch) { + (Some(cond), Some(tb)) => self + .processor + .process_conditional_block(cond, tb, false_branch.as_deref())?, + _ => Vec::new(), + }; + self.advance_cursor(); + self.exec_stack.push(ExecFrame { + ops: branch, + idx: 0, + }); } - "sequence" => { - // Process sequence block by recursively processing all operations - debug!("Processing sequence block"); - - // Process each operation in the sequence block - for op in ops { - match op { - Operation::QuantumOp { - qop, angles, args, .. - } => { - // Process each quantum operation - let qop_str = qop.clone(); - let args_clone = args.clone(); - let angles_clone = angles.clone(); - - match self.processor.process_quantum_op( - &qop_str, - angles_clone.as_ref(), - &args_clone, - ) { - Ok((gate_type, qubit_args, angle_args)) => { - self.processor.add_quantum_operation_to_builder( - &mut self.message_builder, - &gate_type, - &qubit_args, - &angle_args, - )?; - operation_count += 1; - debug!( - "Added quantum operation from sequence block to builder" - ); - } - Err(e) => return Err(e), - } - } - Operation::ClassicalOp { - cop, - args, - returns, - function: _, - metadata: _, - } => { - // Process classical operations in the sequence - if self.processor.handle_classical_op( - cop, - args, - returns, - ops, - self.current_op, - )? { - debug!( - "Processed classical operation from sequence block" - ); - operation_count += 1; - } - } - Operation::MachineOp { - mop, - args, - duration, - metadata, - } => { - // Process machine operations in the sequence - match self.processor.process_machine_op( - mop, - args.as_ref(), - duration.as_ref(), - metadata.as_ref(), - ) { - Ok(mop_result) => { - self.processor.add_machine_operation_to_builder( - &mut self.message_builder, - &mop_result, - )?; - operation_count += 1; - debug!( - "Added machine operation from sequence block to builder" - ); - } - Err(e) => return Err(e), - } - } - // We don't process nested blocks here to avoid excessive recursion - // If needed, we could add a recursion limit - _ => debug!("Skipping complex operation in sequence block"), - } - } + "sequence" | "qparallel" => { + // Flatten the block: its child ops run through the + // same cursor (and the same measurement deferral), + // with arbitrary nesting. + let expanded = self.processor.process_block(block, block_ops)?; + self.advance_cursor(); + self.exec_stack.push(ExecFrame { + ops: expanded, + idx: 0, + }); } - _ => { - return Err(PecosError::Input(format!("Unknown block type: {block}"))); + other => { + return Err(PecosError::Input(format!("Unknown block type: {other}"))); } } } @@ -599,70 +399,29 @@ impl PhirJsonEngine { duration, metadata, } => { - debug!("Processing machine operation: {mop}"); - - // Process the machine operation - match self.processor.process_machine_op( + let mop_result = self.processor.process_machine_op( mop, args.as_ref(), duration.as_ref(), metadata.as_ref(), - ) { - Ok(mop_result) => { - // Add the machine operation to the builder - self.processor.add_machine_operation_to_builder( - &mut self.message_builder, - &mop_result, - )?; - operation_count += 1; - debug!("Added machine operation to builder"); - } - Err(e) => return Err(e), - } - } - Operation::MetaInstruction { - meta, - args, - metadata: _, - } => { - debug!("Processing meta instruction: {meta}"); - - // Process meta instructions like barrier - match self.processor.process_meta_instruction(meta, args) { - Ok(meta_result) => { - // Add the meta instruction to the builder - self.processor.add_meta_instruction_to_builder( - &mut self.message_builder, - &meta_result, - )?; - operation_count += 1; - debug!("Added meta instruction to builder"); - } - Err(e) => return Err(e), - } + )?; + self.processor + .add_machine_operation_to_builder(&mut self.message_builder, &mop_result)?; + operation_count += 1; + self.advance_cursor(); } - Operation::Comment { comment } => { - debug!("Processing comment: {comment}"); - // Comments are ignored during execution + Operation::MetaInstruction { meta, args, .. } => { + let meta_result = self.processor.process_meta_instruction(meta, args)?; + self.processor + .add_meta_instruction_to_builder(&mut self.message_builder, &meta_result)?; + operation_count += 1; + self.advance_cursor(); } - Operation::DataExport { .. } => { - // Data exports are handled elsewhere + Operation::Comment { .. } | Operation::DataExport { .. } => { + self.advance_cursor(); } } - self.current_op += 1; - - // If we've reached the maximum batch size, break out of the loop - // This ensures we don't create excessively large messages - if operation_count >= MAX_BATCH_SIZE { - debug!("Reached maximum batch size ({MAX_BATCH_SIZE}), returning current batch"); - break; - } } - - debug!("PHIR-JSON engine generated {operation_count} operations for shot"); - - // Build and return the message - Ok(Some(self.message_builder.build())) } /// Gets the results in a specific format @@ -699,11 +458,8 @@ impl ControlEngine for PhirJsonEngine { type EngineOutput = ByteMessage; fn start(&mut self, _input: ()) -> Result, PecosError> { - debug!( - "PHIR: start() called with current_op={}, beginning new shot", - self.current_op - ); - self.current_op = 0; // Force reset here too + debug!("PHIR: start() called, beginning new shot"); + self.exec_stack = Self::initial_stack(self.program.as_ref()); self.processor.reset(); debug!("start() called, generating commands"); @@ -720,10 +476,7 @@ impl ControlEngine for PhirJsonEngine { &mut self, measurements: ByteMessage, ) -> Result, PecosError> { - debug!( - "continue_processing called with current_op={}", - self.current_op - ); + debug!("continue_processing called"); // Handle received measurements let measurement_results = measurements.outcomes()?; @@ -764,30 +517,8 @@ impl ControlEngine for PhirJsonEngine { Ok(EngineStage::NeedsProcessing(commands)) } else { debug!("No more commands, returning results"); - // Make sure to process any remaining Result operations - if self.current_op < self.program.as_ref().map_or(0, |prog| prog.ops.len()) { - let ops = self - .program - .as_ref() - .expect("program is set during execution") - .ops - .clone(); - if let Operation::ClassicalOp { - cop, args, returns, .. - } = &ops[self.current_op] - && cop == "Result" - { - debug!("Processing Result operation: {cop}"); - self.processor.handle_classical_op( - cop, - args, - returns, - &ops, - self.current_op, - )?; - } - } - + // The cursor re-processes any deferred classical ops after + // measurements are applied, so no end-of-program fallback is needed. let results = self.get_results()?; debug!("Completed processing, returning results"); Ok(EngineStage::Complete(results)) @@ -874,7 +605,7 @@ impl ClassicalEngine for PhirJsonEngine { if let Some(value) = self.processor.environment.get(&info.name) { exported_values .entry(info.name.clone()) - .or_insert(value.as_u32()); + .or_insert(value.as_u64()); } } } else { @@ -909,13 +640,21 @@ impl ClassicalEngine for PhirJsonEngine { ); for (key, value) in &exported_values { - // Use add_register with proper width from variable metadata + // Use add_register with proper width from variable metadata. A + // signed size-S register is an i(S+1) integer, so it renders S+1 + // bits (the extra sign bit); unsigned registers render S bits. let width = self .processor .environment .get_variable_info_opt(key) - .map_or(32, |info| info.size); - results.add_register(key, *value, width); + .map_or(32, |info| { + if info.data_type.is_signed() { + info.size + 1 + } else { + info.size + } + }); + results.add_register_u64(key, *value, width); log::debug!("PHIR: Adding mapped register {key} = {value} (width={width})"); } @@ -928,7 +667,12 @@ impl ClassicalEngine for PhirJsonEngine { for info in self.processor.environment.get_all_variables() { if let Some(value) = self.processor.environment.get(&info.name) { log::debug!("PHIR: Adding variable {} = {} to results", info.name, value); - results.add_register(&info.name, value.as_u32(), info.size); + let width = if info.data_type.is_signed() { + info.size + 1 + } else { + info.size + }; + results.add_register_u64(&info.name, value.as_u64(), width); } } @@ -942,7 +686,7 @@ impl ClassicalEngine for PhirJsonEngine { // Try to get the value from the environment if let Some(value) = self.processor.environment.get(source) { log::debug!("PHIR: Exporting {source} -> {dest} = {value}"); - results.data.insert(dest.clone(), Data::U32(value.as_u32())); + results.data.insert(dest.clone(), Data::U64(value.as_u64())); } else { // If not found in environment, try the exported_values directly // Try to get the value directly from environment if not already found @@ -950,7 +694,7 @@ impl ClassicalEngine for PhirJsonEngine { log::debug!( "PHIR: Exporting from environment {source} -> {dest} = {value}" ); - results.data.insert(dest.clone(), Data::U32(value.as_u32())); + results.data.insert(dest.clone(), Data::U64(value.as_u64())); } // Note: We no longer fall back to measurement_results as primary source } @@ -963,7 +707,7 @@ impl ClassicalEngine for PhirJsonEngine { log::debug!("PHIR: Adding all variables: {} = {}", info.name, value); results .data - .insert(info.name.clone(), Data::U32(value.as_u32())); + .insert(info.name.clone(), Data::U64(value.as_u64())); } } } @@ -1027,7 +771,7 @@ impl Clone for PhirJsonEngine { Self { program: Some(program.clone()), - current_op: self.current_op, // Preserve the current operation position + exec_stack: self.exec_stack.clone(), // Preserve execution position processor, // Use the fully cloned processor with preserved state message_builder: ByteMessageBuilder::new(), } diff --git a/crates/pecos-phir-json/src/v0_1/environment.rs b/crates/pecos-phir-json/src/v0_1/environment.rs index 874cfc82d..6818193ce 100644 --- a/crates/pecos-phir-json/src/v0_1/environment.rs +++ b/crates/pecos-phir-json/src/v0_1/environment.rs @@ -73,6 +73,22 @@ impl DataType { ) } + /// Checks if the data type is one of the fixed-width integer types. + #[must_use] + pub fn is_integer(&self) -> bool { + matches!( + self, + DataType::I8 + | DataType::I16 + | DataType::I32 + | DataType::I64 + | DataType::U8 + | DataType::U16 + | DataType::U32 + | DataType::U64 + ) + } + /// Returns the maximum value for this data type #[must_use] pub fn max_value(&self) -> u64 { @@ -261,9 +277,13 @@ impl BitValue { /// Create a new value from a raw u64 for the given data type and size. /// - /// Storage is at the full type width. The value is masked to `size` bits - /// for user-assigned values (matching PHIR semantics where `size` limits - /// the data bits the user can write, but the underlying register is N bits). + /// Storage is at the full type width, holding the register's value as a + /// two's-complement pattern. In PHIR a signed size-S register is an + /// `i(S+1)` integer -- S data bits plus a sign bit -- so the value wraps to + /// `S + 1` bits and is sign-extended into the high bits (so `as_i64` and + /// full-width bitwise ops see the correct sign). An unsigned size-S register + /// is a `u(S)`: it wraps to S bits with no sign bit. Register widths are + /// validated to fit the backing type when the variable is defined. #[must_use] pub fn from_u64(data_type: &DataType, size: usize, value: u64) -> Self { let raw_tw = data_type.bit_width(); @@ -271,17 +291,31 @@ impl BitValue { .unwrap_or(64) .max(1); let s = u16::try_from(size).unwrap_or(tw); - // Mask to `size` data bits before storing at type width. - // This ensures user-assigned values respect the declared register size, - // while the full type-width storage allows bitwise ops on all N bits. - let masked = if s < tw { - value & ((1u64 << s) - 1) + let signed = data_type.is_signed(); + // Effective width: S+1 for signed (sign bit), S for unsigned. Capped at + // the type width defensively (validation already guarantees it fits). + let width = if signed { + s.saturating_add(1).min(tw) } else { + s.min(tw) + }; + let stored = if width >= 64 { + // Full-width: the value already carries its own sign bit at bit 63. value + } else { + let mask = (1u64 << width) - 1; + let masked = value & mask; + if signed && (masked >> (width - 1)) & 1 == 1 { + // Sign-extend into the high bits; `BitUInt::new` truncates back + // to the type width, giving a full-width two's-complement value. + masked | !mask + } else { + masked + } }; Self { - inner: BitUInt::new(tw, masked), - signed: data_type.is_signed(), + inner: BitUInt::new(tw, stored), + signed, type_width: tw, } } @@ -298,12 +332,13 @@ impl BitValue { self.inner.clone() } - /// Get value as i64, interpreting sign via two's complement using the - /// TYPE's bit width (not the register size). + /// Get value as i64, interpreting sign via two's complement at the type + /// width. /// - /// This matches Python behavior where `i64(3)` is positive even if stored - /// in a 2-bit register, because the sign bit is at position 63 (i64 type - /// width), not at position 1 (register size). + /// `from_u64` already sign-extends a signed register's `i(S+1)` value into + /// the high bits, so reinterpreting at the type width recovers the correct + /// signed value (e.g. an `i64` size-4 register assigned -1 reads back as + /// -1, and assigned 15 reads back as 15). #[must_use] #[allow(clippy::cast_possible_wrap)] pub fn as_i64(&self) -> i64 { @@ -486,6 +521,31 @@ impl Environment { ))); } + // A signed size-S register is an `i(S+1)` integer (S data bits + a sign + // bit), so `S + 1` must fit the backing width N. An unsigned size-S + // register is a `u(S)` and needs `S <= N`. Fail fast otherwise. + if data_type.is_integer() { + let tw = data_type.bit_width(); + let needed = if data_type.is_signed() { + size + 1 + } else { + size + }; + if needed > tw { + let kind = if data_type.is_signed() { + "signed" + } else { + "unsigned" + }; + let limit = if data_type.is_signed() { tw - 1 } else { tw }; + return Err(PecosError::Input(format!( + "Register '{name}' declares {kind} type {data_type} with size {size}, \ + which does not fit its {tw}-bit backing type. A size-{size} {kind} \ + register needs {needed} bits. Use size <= {limit} or a wider integer type." + ))); + } + } + let index = self.values.len(); self.name_to_index.insert(name.to_string(), index); @@ -593,7 +653,12 @@ impl Environment { let bool_value = bit_value.into().0; if let Some(&idx) = self.name_to_index.get(var_name) { - self.values[idx] = self.values[idx].with_bit_set(bit_index, bool_value)?; + let updated = self.values[idx].with_bit_set(bit_index, bool_value)?; + // Re-normalize to the register's declared i(S+1)/u(S) width so that + // setting the sign bit -- or a bit above the declared width -- wraps + // and sign-extends the same way a whole-value assignment does. + let info = &self.metadata[idx]; + self.values[idx] = BitValue::from_u64(&info.data_type, info.size, updated.as_u64()); Ok(()) } else { Err(PecosError::Input(format!( @@ -770,7 +835,7 @@ mod tests { let mut env = Environment::new(); // Add variables - env.add_variable("x", DataType::I32, 32).unwrap(); + env.add_variable("x", DataType::I32, 31).unwrap(); env.add_variable("y", DataType::U8, 8).unwrap(); // Set values @@ -791,7 +856,7 @@ mod tests { let mut env = Environment::new(); // Add variables with different types - env.add_variable("i8_var", DataType::I8, 8).unwrap(); + env.add_variable("i8_var", DataType::I8, 7).unwrap(); env.add_variable("u8_var", DataType::U8, 8).unwrap(); // Test i8 constraints: 8-bit signed, raw bits stored as BitUInt(8) @@ -843,7 +908,7 @@ mod tests { let mut env = Environment::new(); // Add source variable - env.add_variable("source", DataType::I32, 32).unwrap(); + env.add_variable("source", DataType::I32, 31).unwrap(); env.set_raw("source", 42).unwrap(); // Copy to destination (creates new variable) diff --git a/crates/pecos-phir-json/src/v0_1/expression.rs b/crates/pecos-phir-json/src/v0_1/expression.rs index cc17f3503..d212871b8 100644 --- a/crates/pecos-phir-json/src/v0_1/expression.rs +++ b/crates/pecos-phir-json/src/v0_1/expression.rs @@ -442,7 +442,7 @@ mod tests { fn setup_environment() -> Environment { let mut env = Environment::new(); - env.add_variable("x", DataType::I32, 32).unwrap(); + env.add_variable("x", DataType::I32, 31).unwrap(); env.add_variable("y", DataType::U8, 8).unwrap(); env.add_variable("z", DataType::Bool, 1).unwrap(); env.set_raw("x", 42).unwrap(); @@ -539,7 +539,7 @@ mod tests { fn test_evaluation_at_64_bit_width() { // i32 variable, but arithmetic should happen at 64 bits let mut env = Environment::new(); - env.add_variable("a", DataType::I32, 32).unwrap(); + env.add_variable("a", DataType::I32, 31).unwrap(); env.set_raw("a", 1).unwrap(); let mut evaluator = ExpressionEvaluator::new(&env); diff --git a/crates/pecos-phir-json/src/v0_1/operations.rs b/crates/pecos-phir-json/src/v0_1/operations.rs index 5d63beaeb..54a09c7b8 100644 --- a/crates/pecos-phir-json/src/v0_1/operations.rs +++ b/crates/pecos-phir-json/src/v0_1/operations.rs @@ -139,6 +139,13 @@ pub struct OperationProcessor { pub foreign_object: Option>, /// Current operation index being processed current_op: usize, + /// Return register for each qubit measured in the current batch, in the + /// order the simulator emits outcomes -- one slot per measured qubit, so + /// `outcome[i]` maps to `pending_measurements[i]`. `None` marks a measured + /// qubit with no return register (handled by the fallback). Populated as + /// `Measure` ops are queued (including from inside blocks) and consumed by + /// `handle_measurements`. + pending_measurements: Vec>, } impl Default for OperationProcessor { @@ -154,6 +161,7 @@ impl Clone for OperationProcessor { environment: self.environment.clone(), foreign_object: self.foreign_object.as_ref().map(|fo| fo.clone_box()), current_op: self.current_op, + pending_measurements: self.pending_measurements.clone(), } // All data including mappings is now in the environment } @@ -167,9 +175,45 @@ impl OperationProcessor { environment: Environment::new(), foreign_object: None, current_op: 0, + pending_measurements: Vec::new(), } } + /// Record the return registers of a queued `Measure` op so + /// `handle_measurements` can map positional outcomes back to them. The + /// simulator emits one outcome per measured qubit, so we record one slot + /// per qubit to keep outcomes aligned even across a no-return measure. + /// + /// # Errors + /// Returns an error if a Measure has return registers that do not match its + /// measured qubits one-to-one (malformed PHIR). + pub fn record_measurement_returns( + &mut self, + qubit_count: usize, + returns: &[(String, usize)], + ) -> Result<(), PecosError> { + if returns.is_empty() { + // No-return measure: reserve a placeholder per qubit so a later + // measurement's outcomes stay positionally aligned. + self.pending_measurements + .extend((0..qubit_count).map(|_| None)); + } else if returns.len() == qubit_count { + self.pending_measurements + .extend(returns.iter().map(|r| Some(r.clone()))); + } else { + return Err(PecosError::Input(format!( + "Measure op measures {qubit_count} qubit(s) but has {} return register(s); they must match one-to-one", + returns.len() + ))); + } + Ok(()) + } + + /// Clear the queued-measurement record (called when a new batch begins). + pub fn clear_pending_measurements(&mut self) { + self.pending_measurements.clear(); + } + /// Get the variables of type "qubits" /// Returns a map of quantum variable names to their sizes /// This is a helper method that accesses the environment directly @@ -232,6 +276,7 @@ impl OperationProcessor { // Clear state but keep variable definitions self.environment.reset_values(); // Environment reset_values now also clears mappings + self.pending_measurements.clear(); // We deliberately don't clear variable definitions or foreign_object // so that we preserve the structure of the program while resetting state @@ -246,7 +291,7 @@ impl OperationProcessor { // Create the variable if it doesn't exist if !self.environment.has_variable(name) { // Add but allow failure if it already exists - match self.environment.add_variable(name, DataType::I32, 32) { + match self.environment.add_variable(name, DataType::I32, 31) { Ok(()) => log::debug!("Created new variable: {name} in environment"), Err(e) => log::warn!( "Could not create variable in environment: {name}. Will try to update anyway: {e}" @@ -839,14 +884,11 @@ impl OperationProcessor { if self.environment.has_variable(variable) { log::debug!("Variable '{variable}' already exists in environment, skipping creation"); } else { - match self.environment.add_variable(variable, dt, size) { - Ok(()) => log::debug!( - "Added classical variable {variable} of type {data_type} and size {size}" - ), - Err(e) => log::warn!( - "Could not add variable '{variable}' to environment: {e}. Will continue with existing variable." - ), - } + // Propagate definition errors (e.g. a register whose declared width + // does not fit its backing integer type) so they fail fast with a + // useful message rather than surfacing later as "variable not found". + self.environment.add_variable(variable, dt, size)?; + log::debug!("Added classical variable {variable} of type {data_type} and size {size}"); } Ok(()) @@ -1008,37 +1050,16 @@ impl OperationProcessor { )?; log::debug!("Set bit {var}[{idx}] = {bit_value} in environment"); } - - // Calculate the new value and update exported_values - // Get the current value from environment or use 0 if it doesn't exist - let current_value = self - .environment - .get(&var) - .map_or(0u32, super::environment::BitValue::as_u32); - - // Clear the bit and set it to the new value - let mask = !(1 << idx); - // bit_value is already masked with & 1, so it's guaranteed to be 0 or 1 - let bit_u32 = u32::try_from(bit_value).unwrap_or(0); - let new_value = (current_value & mask) | (bit_u32 << idx); - - // Make sure the composite variable is updated in the environment as well - match self.environment.set(&var, u64::from(new_value)) { - Ok(()) => { - log::debug!("Updated composite variable: {var} = {new_value}"); - } - Err(e) => { - log::warn!("Could not update composite variable: {var}. Error: {e}"); - } - } - log::debug!("Added bit-level value to environment: {var} = {new_value}"); + // `set_bit` above already updates the full 64-bit value in the + // environment; do not recompute the whole register here (a u32 + // recompute both truncated bits 32-63 and panicked on idx >= 32). } else { // For whole variable assignment, store in environment log::debug!("Storing assignment value {value} in variable {var}"); // Make sure variable exists in environment and update it if !self.environment.has_variable(&var) { - self.environment.add_variable(&var, DataType::I32, 32)?; + self.environment.add_variable(&var, DataType::I32, 31)?; } // Convert to u64 safely - we're working with raw bit patterns #[allow(clippy::cast_sign_loss)] @@ -1181,7 +1202,7 @@ impl OperationProcessor { .add_variable( var, DataType::I32, - 32, + 31, ); } let _ = self.environment.set( @@ -1207,7 +1228,7 @@ impl OperationProcessor { .add_variable( var, DataType::I32, - 32, + 31, ); } @@ -1281,7 +1302,7 @@ impl OperationProcessor { .add_variable( var, DataType::I32, - 32, + 31, ); } let _ = self.environment.set( @@ -1308,7 +1329,7 @@ impl OperationProcessor { .add_variable( var, DataType::I32, - 32, + 31, ); } @@ -1385,7 +1406,7 @@ impl OperationProcessor { // Make sure the variable exists if !self.environment.has_variable(var) { // Create if needed - self.environment.add_variable(var, DataType::I32, 32)?; + self.environment.add_variable(var, DataType::I32, 31)?; } // Set value in environment (single source of truth) @@ -1399,7 +1420,7 @@ impl OperationProcessor { // Make sure the variable exists if !self.environment.has_variable(var) { // Create if needed - self.environment.add_variable(var, DataType::I32, 32)?; + self.environment.add_variable(var, DataType::I32, 31)?; } // Set bit in environment (single source of truth) @@ -1621,7 +1642,10 @@ impl OperationProcessor { builder.z(&[qubit_args[0]]); } "Measure" => { - builder.mz(&[qubit_args[0]]); + // Measure every qubit in the op (one outcome per qubit, in + // order), not just the first, so multi-qubit Measure works and + // the outcomes align with the recorded return registers. + builder.mz(qubit_args); } "Init" => { // Initialize qubit to |0⟩ state using the Prep gate @@ -1650,13 +1674,16 @@ impl OperationProcessor { // Step 1: Ensure the main variable exists in the environment with appropriate size if !self.environment.has_variable(var_name) { - // Determine appropriate size (at least large enough to hold this bit) + // Determine appropriate size (at least large enough to hold this + // bit). Measurement registers are unsigned bit collections, so use + // an unsigned type -- `u64` is valid for any size up to 64, whereas + // a signed `i32` could not hold e.g. bit index 31 (size 32). let var_size = std::cmp::max(var_idx + 1, 32); // Create the variable match self .environment - .add_variable(var_name, DataType::I32, var_size) + .add_variable(var_name, DataType::U64, var_size) { Ok(()) => log::debug!("Created variable {var_name} with size {var_size}"), Err(e) => log::warn!( @@ -1686,7 +1713,7 @@ impl OperationProcessor { pub fn handle_measurements( &mut self, measurements: &[u32], - ops: &[Operation], + _ops: &[Operation], ) -> Result<(), PecosError> { log::debug!("PHIR: Handling {} measurement results", measurements.len()); @@ -1702,7 +1729,7 @@ impl OperationProcessor { if !self.environment.has_variable(&prefixed_name) && let Err(e) = self .environment - .add_variable(&prefixed_name, DataType::I32, 32) + .add_variable(&prefixed_name, DataType::I32, 31) { log::warn!("Could not create measurement variable: {prefixed_name}. Error: {e}"); } @@ -1714,38 +1741,18 @@ impl OperationProcessor { log::debug!("Stored measurement result: {prefixed_name} = {outcome}"); } - // Also map to specific variable based on the Measure operation - let mut found_mapping = false; - for op in ops { - if let Operation::QuantumOp { - qop, - args: _, - returns, - .. - } = op - && qop == "Measure" - && !returns.is_empty() - { - // Get the variable name and index from the returns field - let (var_name, var_idx) = &returns[0]; - - // Check if this is the right measurement result - if *var_idx == result_id as usize { - // Store the result in the specific bit of the variable - self.store_measurement_result(var_name, *var_idx, *outcome); - found_mapping = true; - } - } - } - - // If we didn't find a mapping in the operations, add a default mapping to variable "m" - // This helps with tests and interoperability, particularly Bell state tests - if !found_mapping && self.environment.has_variable("m") { - // Store in main "m" variable for test compatibility - let idx = result_id as usize; - self.store_measurement_result("m", idx, *outcome); + // Map the outcome to the return register recorded when this + // measurement was queued (queue-order positional). This works for + // measurements queued from inside blocks too, which a scan of the + // top-level ops would miss. + if let Some(Some((var_name, var_idx))) = self.pending_measurements.get(index).cloned() { + self.store_measurement_result(&var_name, var_idx, *outcome); + } else if self.environment.has_variable("m") { + // Fallback for a measured qubit with no recorded return register + // (e.g. Bell-state test programs that measure without returns). + self.store_measurement_result("m", index, *outcome); log::debug!( - "PHIR: Auto-mapped measurement result {result_id} to m[{idx}] = {outcome}" + "PHIR: Auto-mapped measurement result {index} to m[{index}] = {outcome}" ); } } @@ -1782,7 +1789,7 @@ impl OperationProcessor { /// /// This simplified implementation treats the environment as the single source of truth /// for retrieving variable values. - fn get_variable_value(&self, var_name: &str, index: Option) -> Result { + fn get_variable_value(&self, var_name: &str, index: Option) -> Result { log::debug!("Getting variable value for {var_name}[{index:?}]"); // Ensure the variable exists in the environment @@ -1798,7 +1805,7 @@ impl OperationProcessor { match self.environment.get_bit(var_name, idx) { Ok(bit_value) => { log::debug!("Found bit value in environment: {var_name}[{idx}] = {bit_value}"); - return Ok(u32::from(bit_value.0)); + return Ok(u64::from(bit_value.0)); } Err(_) => { // Fall back to extracting bit from full u64 value @@ -1806,8 +1813,7 @@ impl OperationProcessor { let raw = full_val.as_u64(); let bit_value = (raw >> idx) & 1; log::debug!("Extracted bit from variable: {var_name}[{idx}] = {bit_value}"); - #[allow(clippy::cast_possible_truncation)] - return Ok(bit_value as u32); + return Ok(bit_value); } } } @@ -1820,7 +1826,7 @@ impl OperationProcessor { // Handle whole variable access if let Some(val) = self.environment.get(var_name) { log::debug!("Got value from environment: {var_name} = {val}"); - return Ok(val.as_u32()); + return Ok(val.as_u64()); } // If we get here, the variable exists but has no value @@ -1859,60 +1865,49 @@ impl OperationProcessor { "Result mapping: {src_name}[{src_index:?}] -> {dst_name}[{dst_index:?}]" ); - // Store mapping in the environment - let _ = self.environment.add_mapping(&src_name, &dst_name); - - // Get the source value directly from the environment - // No special handling or fallbacks - environment is the single source of truth + // Get the source value from the environment (single source of truth). let value = self.get_variable_value(&src_name, src_index)?; log::debug!("Got value for {src_name}: {value}"); // Create destination variable if needed if !self.environment.has_variable(&dst_name) { - // Inherit type and size from source variable when possible - let (var_type, var_size) = self - .environment - .get_variable_info_opt(&src_name) - .map_or_else( - || { - if let Some(idx) = dst_index { - (DataType::I32, std::cmp::max(idx + 1, 32)) - } else { - (DataType::I32, 32) - } - }, - |info| (info.data_type.clone(), info.size), - ); - - // Create the variable, but don't fail if it already exists - if let Err(e) = self.environment.add_variable(&dst_name, var_type, var_size) { - log::warn!( - "Could not create variable: {dst_name}. Will try to update existing: {e}" - ); - } + let (var_type, var_size) = if let Some(idx) = dst_index { + // An indexed destination is an unsigned bit collection; + // it must be wide enough for `idx` regardless of the + // source's type/size (inheriting a small or signed + // source would drop the bit). + (DataType::U64, std::cmp::max(idx + 1, 32)) + } else { + // Whole-register copy: mirror the source, else default. + self.environment + .get_variable_info_opt(&src_name) + .map_or((DataType::U64, 32), |info| { + (info.data_type.clone(), info.size) + }) + }; + self.environment + .add_variable(&dst_name, var_type, var_size)?; } - // Store the value in the destination + // Store the value in the destination, failing loudly on error + // (e.g. a bit index that does not fit the backing type). if let Some(idx) = dst_index { - // Bit access - set specific bit in the variable - let bit_value = value & 1; - if let Err(e) = self - .environment - .set_bit(&dst_name, idx, u64::from(bit_value)) - { - log::warn!("Could not set bit {dst_name}[{idx}] = {bit_value}: {e}"); - } else { - log::debug!("Set bit {dst_name}[{idx}] = {bit_value}"); - } + self.environment.set_bit(&dst_name, idx, value & 1)?; + log::debug!("Set bit {dst_name}[{idx}] = {}", value & 1); } else { - // Whole variable assignment - if let Err(e) = self.environment.set(&dst_name, u64::from(value)) { - log::warn!("Could not set variable {dst_name} = {value}: {e}"); - } else { - log::debug!("Set variable {dst_name} = {value}"); - } + self.environment.set(&dst_name, value)?; + log::debug!("Set variable {dst_name} = {value}"); } + + // Export the destination register, which now holds the copied + // (whole-register `set`) or accumulated (indexed `set_bit`) + // value. Measurement ordering is handled by the engine (classical + // ops are deferred until batch measurements are applied), so the + // destination is correct at this point -- mapping it to itself is + // correct even when multiple Results (whole-register and/or + // indexed) target the same destination. + let _ = self.environment.add_mapping(&dst_name, &dst_name); } } @@ -1924,7 +1919,7 @@ impl OperationProcessor { /// This simplified method treats the environment as the single source of truth /// and provides a clean, simple approach to gathering exported values. #[must_use] - pub fn process_export_mappings(&self) -> BTreeMap { + pub fn process_export_mappings(&self) -> BTreeMap { let mut exported_values = BTreeMap::new(); log::debug!("Processing export mappings using environment as source of truth"); @@ -1951,7 +1946,7 @@ impl OperationProcessor { if self.environment.has_variable(source_register) { if let Some(value) = self.environment.get(source_register) { log::debug!("Using value from environment: {source_register} = {value}"); - exported_values.insert(export_name.clone(), value.as_u32()); + exported_values.insert(export_name.clone(), value.as_u64()); } else { log::debug!( "Variable {source_register} exists in environment but has no value" @@ -1987,7 +1982,7 @@ impl OperationProcessor { // Include any variable that has a value if let Some(val) = self.environment.get(&var_info.name) { log::debug!("Adding variable: {} = {}", var_info.name, val); - exported_values.insert(var_info.name.clone(), val.as_u32()); + exported_values.insert(var_info.name.clone(), val.as_u64()); } } } @@ -2014,7 +2009,7 @@ mod tests { // Add a test variable to the environment processor .environment - .add_variable("test_var", DataType::I32, 32) + .add_variable("test_var", DataType::I32, 31) .unwrap(); processor.environment.set("test_var", 42).unwrap(); diff --git a/crates/pecos-phir-json/tests/execution/iterative_blocks.rs b/crates/pecos-phir-json/tests/execution/iterative_blocks.rs index 8f43f1148..982f797ad 100644 --- a/crates/pecos-phir-json/tests/execution/iterative_blocks.rs +++ b/crates/pecos-phir-json/tests/execution/iterative_blocks.rs @@ -14,8 +14,8 @@ fn test_basic_iterative_execution() -> Result<(), PecosError> { // Add variables for testing executor.add_quantum_variable("q", 2)?; - executor.add_classical_variable("m", "i32", 32)?; - executor.add_classical_variable("result", "i32", 32)?; + executor.add_classical_variable("m", "i32", 31)?; + executor.add_classical_variable("result", "i32", 31)?; // Create a sequence of operations let operations = vec![ @@ -86,9 +86,9 @@ fn test_nested_blocks_iterative() -> Result<(), PecosError> { let mut executor = BlockExecutor::new(); // Add variables for testing - executor.add_classical_variable("x", "i32", 32)?; - executor.add_classical_variable("y", "i32", 32)?; - executor.add_classical_variable("z", "i32", 32)?; + executor.add_classical_variable("x", "i32", 31)?; + executor.add_classical_variable("y", "i32", 31)?; + executor.add_classical_variable("z", "i32", 31)?; // Set initial values executor.get_environment_mut().set("x", 10)?; @@ -172,7 +172,7 @@ fn test_operation_buffering() -> Result<(), PecosError> { // Add variables for testing executor.add_quantum_variable("q", 2)?; - executor.add_classical_variable("m", "i32", 32)?; + executor.add_classical_variable("m", "i32", 31)?; // Create operations with measurements let operations = vec![ @@ -230,8 +230,8 @@ fn test_iterator_interface() -> Result<(), PecosError> { let mut executor = BlockExecutor::new(); // Add variables for testing - executor.add_classical_variable("x", "i32", 32)?; - executor.add_classical_variable("y", "i32", 32)?; + executor.add_classical_variable("x", "i32", 31)?; + executor.add_classical_variable("y", "i32", 31)?; // Create a sequence of operations let operations = vec![ diff --git a/crates/pecos-phir-json/tests/expressions/environment_tests.rs b/crates/pecos-phir-json/tests/expressions/environment_tests.rs index bb5a5e67e..6b6c989c5 100644 --- a/crates/pecos-phir-json/tests/expressions/environment_tests.rs +++ b/crates/pecos-phir-json/tests/expressions/environment_tests.rs @@ -9,9 +9,9 @@ fn test_variable_environment() { let mut env = Environment::new(); // Add variables of different types - env.add_variable("i8_var", DataType::I8, 8).unwrap(); + env.add_variable("i8_var", DataType::I8, 7).unwrap(); env.add_variable("u8_var", DataType::U8, 8).unwrap(); - env.add_variable("i32_var", DataType::I32, 32).unwrap(); + env.add_variable("i32_var", DataType::I32, 31).unwrap(); env.add_variable("qubits", DataType::Qubits, 4).unwrap(); // Set values @@ -67,9 +67,9 @@ fn test_variable_environment() { fn test_expression_evaluation() { // Create an environment with test variables let mut env = Environment::new(); - env.add_variable("a", DataType::I32, 32).unwrap(); - env.add_variable("b", DataType::I32, 32).unwrap(); - env.add_variable("c", DataType::I32, 32).unwrap(); + env.add_variable("a", DataType::I32, 31).unwrap(); + env.add_variable("b", DataType::I32, 31).unwrap(); + env.add_variable("c", DataType::I32, 31).unwrap(); env.set("a", 10).unwrap(); env.set("b", 5).unwrap(); diff --git a/crates/pecos-phir-json/tests/expressions/expression_tests.rs b/crates/pecos-phir-json/tests/expressions/expression_tests.rs index 1ce538a51..2d34d8e52 100644 --- a/crates/pecos-phir-json/tests/expressions/expression_tests.rs +++ b/crates/pecos-phir-json/tests/expressions/expression_tests.rs @@ -18,11 +18,11 @@ mod tests { "num_qubits": 0 }, "ops": [ - {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "c", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "d", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "result", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "c", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "d", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "result", "size": 31}, {"cop": "=", "args": [10], "returns": ["a"]}, {"cop": "=", "args": [5], "returns": ["b"]}, {"cop": "=", "args": [{"cop": "+", "args": ["a", "b"]}], "returns": ["c"]}, @@ -90,12 +90,12 @@ mod tests { "num_qubits": 0 }, "ops": [ - {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "less_than", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "equal", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "greater_than", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "combined", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "less_than", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "equal", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "greater_than", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "combined", "size": 31}, {"cop": "=", "args": [10], "returns": ["a"]}, {"cop": "=", "args": [5], "returns": ["b"]}, {"cop": "=", "args": [{"cop": "<", "args": ["b", "a"]}], "returns": ["less_than"]}, @@ -192,12 +192,12 @@ mod tests { "num_qubits": 0 }, "ops": [ - {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "bit_and", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "bit_or", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "bit_xor", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "bit_shift", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "bit_and", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "bit_or", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "bit_xor", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "bit_shift", "size": 31}, {"cop": "=", "args": [3], "returns": ["a"]}, {"cop": "=", "args": [5], "returns": ["b"]}, {"cop": "=", "args": [{"cop": "&", "args": ["a", "b"]}], "returns": ["bit_and"]}, @@ -294,10 +294,10 @@ mod tests { "num_qubits": 0 }, "ops": [ - {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "c", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "result", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "c", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "result", "size": 31}, {"cop": "=", "args": [5], "returns": ["a"]}, {"cop": "=", "args": [10], "returns": ["b"]}, {"cop": "=", "args": [15], "returns": ["c"]}, @@ -373,11 +373,11 @@ mod tests { "num_qubits": 0 }, "ops": [ - {"data": "cvar_define", "data_type": "i32", "variable": "value", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "value", "size": 31}, {"data": "cvar_define", "data_type": "i32", "variable": "bit0", "size": 1}, {"data": "cvar_define", "data_type": "i32", "variable": "bit1", "size": 1}, {"data": "cvar_define", "data_type": "i32", "variable": "bit2", "size": 1}, - {"data": "cvar_define", "data_type": "i32", "variable": "result", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "result", "size": 31}, {"cop": "=", "args": [5], "returns": ["value"]}, {"cop": "=", "args": [{"cop": "&", "args": [{"cop": ">>", "args": ["value", 0]}, 1]}], "returns": ["bit0"]}, {"cop": "=", "args": [{"cop": "&", "args": [{"cop": ">>", "args": ["value", 1]}, 1]}], "returns": ["bit1"]}, diff --git a/crates/pecos-phir-json/tests/operations/machine_operations.rs b/crates/pecos-phir-json/tests/operations/machine_operations.rs index b1491c165..76da971be 100644 --- a/crates/pecos-phir-json/tests/operations/machine_operations.rs +++ b/crates/pecos-phir-json/tests/operations/machine_operations.rs @@ -80,7 +80,7 @@ mod tests { }, "ops": [ {"data": "qvar_define", "data_type": "qubits", "variable": "q", "size": 2}, - {"data": "cvar_define", "data_type": "i32", "variable": "var", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "var", "size": 31}, {"qop": "H", "args": [["q", 0]]}, {"mop": "Idle", "args": [["q", 0], ["q", 1]], "duration": [5.0, "ms"]}, {"mop": "Delay", "args": [["q", 0]], "duration": [2.0, "us"]}, @@ -167,7 +167,7 @@ mod tests { }, "ops": [ {"data": "qvar_define", "data_type": "qubits", "variable": "q", "size": 2}, - {"data": "cvar_define", "data_type": "i32", "variable": "result", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "result", "size": 31}, {"qop": "H", "args": [["q", 0]]}, {"mop": "Idle", "args": [["q", 0], ["q", 1]], "duration": [5.0, "ms"]}, {"mop": "Delay", "args": [["q", 0]], "duration": [2.0, "us"]}, diff --git a/crates/pecos-phir-json/tests/operations/meta_instructions.rs b/crates/pecos-phir-json/tests/operations/meta_instructions.rs index bc38e1542..6ce3f79fa 100644 --- a/crates/pecos-phir-json/tests/operations/meta_instructions.rs +++ b/crates/pecos-phir-json/tests/operations/meta_instructions.rs @@ -22,7 +22,7 @@ mod tests { }, "ops": [ {"data": "qvar_define", "data_type": "qubits", "variable": "q", "size": 2}, - {"data": "cvar_define", "data_type": "i32", "variable": "result", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "result", "size": 31}, {"data": "cvar_define", "data_type": "i32", "variable": "m", "size": 2}, {"qop": "H", "args": [["q", 0]]}, {"meta": "barrier", "args": [["q", 0], ["q", 1]]}, diff --git a/crates/pecos-phir-json/tests/operations/quantum_operations.rs b/crates/pecos-phir-json/tests/operations/quantum_operations.rs index b058ed68c..e1778aa07 100644 --- a/crates/pecos-phir-json/tests/operations/quantum_operations.rs +++ b/crates/pecos-phir-json/tests/operations/quantum_operations.rs @@ -298,7 +298,7 @@ mod tests { }, "ops": [ {"data": "qvar_define", "data_type": "qubits", "variable": "q", "size": 1}, - {"data": "cvar_define", "data_type": "i32", "variable": "condition", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "condition", "size": 31}, {"data": "cvar_define", "data_type": "i32", "variable": "m", "size": 1}, {"cop": "=", "args": [1], "returns": ["condition"]}, { diff --git a/crates/pecos-phir-json/tests/wasm/wasm_direct_test.rs b/crates/pecos-phir-json/tests/wasm/wasm_direct_test.rs index acece4ae1..32f55cce4 100644 --- a/crates/pecos-phir-json/tests/wasm/wasm_direct_test.rs +++ b/crates/pecos-phir-json/tests/wasm/wasm_direct_test.rs @@ -96,9 +96,9 @@ mod tests { "source_program_type": ["Test", ["PECOS", "0.5.dev1"]] }, "ops": [ - {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "sum", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "sum", "size": 31}, {"cop": "=", "args": [1], "returns": ["a"]}, {"cop": "=", "args": [10], "returns": ["b"]}, {"cop": "ffcall", "function": "add", "args": ["a", "b"], "returns": ["sum"]}, diff --git a/crates/pecos-phir-json/tests/wasm/wasm_integration_tests.rs b/crates/pecos-phir-json/tests/wasm/wasm_integration_tests.rs index d986df640..99734f982 100644 --- a/crates/pecos-phir-json/tests/wasm/wasm_integration_tests.rs +++ b/crates/pecos-phir-json/tests/wasm/wasm_integration_tests.rs @@ -59,7 +59,7 @@ mod tests { "source_program_type": ["Test", ["PECOS", "0.5.dev1"]] }, "ops": [ - {"data": "cvar_define", "data_type": "i32", "variable": "placeholder", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "placeholder", "size": 31}, {"cop": "=", "args": [0], "returns": ["placeholder"]}, {"cop": "Result", "args": ["placeholder"], "returns": ["output"]} ] @@ -152,10 +152,10 @@ mod tests { "source_program_type": ["Test", ["PECOS", "0.5.dev1"]] }, "ops": [ - {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "c", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "final_result", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "c", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "final_result", "size": 31}, {"cop": "=", "args": [3], "returns": ["a"]}, {"cop": "=", "args": [4], "returns": ["b"]}, {"cop": "ffcall", "function": "add", "args": ["a", "b"], "returns": ["c"]}, @@ -213,8 +213,8 @@ mod tests { "source_program_type": ["Test", ["PECOS", "0.5.dev1"]] }, "ops": [ - {"data": "cvar_define", "data_type": "i32", "variable": "condition", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "result", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "condition", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "result", "size": 31}, {"cop": "=", "args": [1], "returns": ["condition"]}, { "block": "if", diff --git a/crates/pecos-qasm/src/qasm_to_phir_json.rs b/crates/pecos-qasm/src/qasm_to_phir_json.rs index 297845b8b..0bc1e8066 100644 --- a/crates/pecos-qasm/src/qasm_to_phir_json.rs +++ b/crates/pecos-qasm/src/qasm_to_phir_json.rs @@ -29,11 +29,32 @@ pub fn qasm_to_phir_json(qasm_str: &str) -> Result { program_to_phir_json(&program) } +/// Unsigned PHIR integer type that holds `size` bits. +/// +/// Classical registers are unsigned bit collections, so an unsigned type (legal +/// for any `size <= N`, and lossless even at 64 bits where a signed +/// `i(size+1)` register has no backing) is the correct representation. The PHIR +/// spec only defines `u32` and `u64`, so those are the two choices. +/// +/// # Errors +/// +/// Returns an error if `size` exceeds the maximum 64-bit backing type. +fn classical_register_dtype(size: usize) -> Result<&'static str, String> { + match size { + 0..=32 => Ok("u32"), + 33..=64 => Ok("u64"), + _ => Err(format!( + "classical register size {size} exceeds the maximum 64-bit backing type" + )), + } +} + /// Convert a parsed QASM `Program` to a PHIR-JSON `serde_json::Value`. /// /// # Errors /// -/// Returns an error if the program contains unsupported operations. +/// Returns an error if the program contains unsupported operations or a +/// classical register is too large for a 64-bit backing type. pub fn program_to_phir_json(program: &Program) -> Result { let mut ops: Vec = Vec::new(); @@ -47,11 +68,16 @@ pub fn program_to_phir_json(program: &Program) -> Result { })); } - // 2) Classical register definitions -- all i64 + // 2) Classical register definitions. QASM classical registers are unsigned + // bit collections, so they map to the smallest unsigned type that holds + // `size` bits (`uN` is valid for any size up to the 64-bit backing width; + // a signed register would need size + 1 <= N and could not represent 64 + // bits at all). for (name, size) in &program.classical_registers { + let dtype = classical_register_dtype(*size)?; ops.push(json!({ "data": "cvar_define", - "data_type": "i64", + "data_type": dtype, "variable": name, "size": size })); @@ -421,6 +447,7 @@ mod tests { include "qelib1.inc"; qreg q[2]; creg m[3]; + creg big[64]; "#, ); let ops = get_ops(&phir); @@ -430,10 +457,23 @@ mod tests { assert_eq!(qvar["size"], 2); assert_eq!(qvar["data_type"], "qubits"); - let cvar = ops.iter().find(|o| o["data"] == "cvar_define").unwrap(); - assert_eq!(cvar["variable"], "m"); + // Classical registers are unsigned bit collections. The PHIR spec only + // defines u32/u64, so a <=32-bit register maps to u32. + let cvar = ops + .iter() + .find(|o| o["data"] == "cvar_define" && o["variable"] == "m") + .unwrap(); assert_eq!(cvar["size"], 3); - assert_eq!(cvar["data_type"], "i64"); + assert_eq!(cvar["data_type"], "u32"); + + // A 64-bit register maps to u64 -- a signed register could not hold 64 + // bits (i65 has no backing type). + let big = ops + .iter() + .find(|o| o["data"] == "cvar_define" && o["variable"] == "big") + .unwrap(); + assert_eq!(big["size"], 64); + assert_eq!(big["data_type"], "u64"); } #[test] 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 8f90260c1..c63e7f4a2 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 @@ -3127,7 +3127,7 @@ mod tests { // sample_statistics_with_rng uses auto-selection internally let mut rng = SmallRng::seed_from_u64(42); let stats = sampler.sample_statistics_with_rng(10000, &mut rng); - assert!(stats.total_shots == 10000); + assert_eq!(stats.total_shots, 10000); } #[test] @@ -3157,7 +3157,7 @@ mod tests { // sample_statistics_with_rng uses auto-selection internally let mut rng = SmallRng::seed_from_u64(42); let stats = sampler.sample_statistics_with_rng(10000, &mut rng); - assert!(stats.total_shots == 10000); + assert_eq!(stats.total_shots, 10000); } #[test] @@ -3364,6 +3364,6 @@ mod tests { let stats = sampler.sample_statistics(1000, 42); // Just verify it runs without panicking - assert!(stats.total_shots == 1000); + assert_eq!(stats.total_shots, 1000); } } diff --git a/crates/pecos-qis/build.rs b/crates/pecos-qis/build.rs index bec15897b..52aae1e24 100644 --- a/crates/pecos-qis/build.rs +++ b/crates/pecos-qis/build.rs @@ -64,11 +64,30 @@ fn validate_llvm() { panic!("Invalid {LLVM_SYS_PREFIX_ENV}. See error message above."); } - // LLVM_SYS_PREFIX_ENV not set - print setup instructions - print_llvm_not_found_error_extended(); - panic!( - "LLVM {REQUIRED_VERSION} not configured. See error message above for setup instructions." - ); + // LLVM_SYS_PREFIX_ENV not set. Reaching here means llvm-sys already located + // a usable LLVM (via PATH llvm-config) without an explicit prefix. If a + // valid LLVM 21.1 exists, auto-configure .cargo/config.toml so the prefix is + // pinned for future builds, and set it now so the PECOS_LLVM_BIN_PATH embed + // below resolves. Installing a missing LLVM stays with `pecos setup`. + if let Ok(path) = pecos_build::llvm::config::auto_configure_llvm(None) { + eprintln!( + "pecos-qis: auto-configured .cargo/config.toml to use LLVM {REQUIRED_VERSION} at {}", + path.display() + ); + // SAFETY: build scripts run single-threaded; set the prefix so the + // bin-path embed step (which reads this env var) picks it up. + unsafe { + env::set_var( + LLVM_SYS_PREFIX_ENV, + pecos_build::llvm::path_to_env_string(&path), + ); + } + } else { + print_llvm_not_found_error_extended(); + panic!( + "LLVM {REQUIRED_VERSION} not configured. See error message above for setup instructions." + ); + } } #[cfg(feature = "llvm")] diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index 5a135c1cd..8ef531589 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -719,7 +719,8 @@ 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_211_PREFIX` environment variable -/// 3. Fall back to PATH +/// 3. PECOS-managed install (`~/.pecos/deps/llvm-/bin`) +/// 4. Fall back to PATH fn find_llvm_tool(tool_name: &str) -> PathBuf { let tool_exe = if cfg!(windows) { format!("{tool_name}.exe") @@ -758,12 +759,96 @@ fn find_llvm_tool(tool_name: &str) -> PathBuf { } }) }) + .or_else(|| { + // PECOS's own managed install (`pecos install llvm` / `pecos setup`) + // lives at a deterministic location. Prefer it over a bare PATH + // lookup so a stale system LLVM on PATH cannot shadow it -- this is + // what lets PECOS work without the user configuring PATH or env vars. + let path = managed_llvm_tool(&tool_exe)?; + debug!( + "Using {tool_name} from PECOS-managed LLVM: {}", + path.display() + ); + Some(path) + }) .unwrap_or_else(|| { + // Neither the embedded, env-configured, nor managed LLVM was usable; + // fall back to PATH. Warn if that resolves to a non-21.1 tool: a + // stale system LLVM (e.g. llvm-as 14) cannot parse opaque-pointer IR + // and silently fails QIS loading rather than producing wrong data. + let path_tool = PathBuf::from(tool_name); + warn_if_wrong_llvm_version(&path_tool, tool_name); debug!("Using {tool_name} from PATH"); - PathBuf::from(tool_name) + path_tool }) } +/// Required LLVM version for PECOS. Kept in sync with `pecos-build`'s +/// `REQUIRED_VERSION`; duplicated here because `pecos-build` is only a +/// build-dependency and is not available at runtime. +const REQUIRED_LLVM_VERSION: &str = "21.1"; + +/// Resolve an LLVM tool from the PECOS-managed install location +/// (`~/.pecos/deps/llvm-/bin`), where `pecos install llvm` / `pecos +/// setup` place LLVM 21.1. Returns `None` if the tool is not present there. +fn managed_llvm_tool(tool_exe: &str) -> Option { + managed_llvm_tool_in(&home_dir()?, tool_exe) +} + +/// [`managed_llvm_tool`] with the home directory passed in explicitly. +fn managed_llvm_tool_in(home: &Path, tool_exe: &str) -> Option { + let path = home + .join(".pecos") + .join("deps") + .join(format!("llvm-{REQUIRED_LLVM_VERSION}")) + .join("bin") + .join(tool_exe); + path.exists().then_some(path) +} + +/// The user's home directory from the environment (`HOME` on Unix, +/// `USERPROFILE` on Windows), without pulling in an extra crate at runtime. +fn home_dir() -> Option { + let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + std::env::var_os(var) + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) +} + +/// Warn (once per call) if an LLVM tool resolved from `PATH` reports a version +/// other than the required 21.1 series. Best-effort: a tool that cannot be run +/// or whose version cannot be parsed is left alone. +fn warn_if_wrong_llvm_version(tool_path: &Path, tool_name: &str) { + let Ok(output) = Command::new(tool_path).arg("--version").output() else { + return; + }; + if !output.status.success() { + return; + } + let text = String::from_utf8_lossy(&output.stdout); + // The first dotted numeric token is the LLVM version (`llvm-config` prints + // it bare; `llvm-as` embeds it in a `LLVM version X.Y.Z` line). + let Some(version) = text.split_whitespace().find(|token| { + let mut parts = token.split('.'); + token.starts_with(|c: char| c.is_ascii_digit()) + && parts.clone().count() >= 2 + && parts.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) + }) else { + return; + }; + let matches_required = version == REQUIRED_LLVM_VERSION + || version + .strip_prefix(REQUIRED_LLVM_VERSION) + .is_some_and(|rest| rest.starts_with('.')); + if !matches_required { + warn!( + "'{tool_name}' resolved from PATH is LLVM {version}, but PECOS requires \ + {REQUIRED_LLVM_VERSION}. This can silently break QIS execution. Configure \ + LLVM 21.1 (`pecos llvm configure`) or put its bin directory ahead on PATH." + ); + } +} + // FFI function types for dynamic circuit coordination // These must be called via the dynamically loaded library to use the same statics diff --git a/crates/pecos-results/src/data.rs b/crates/pecos-results/src/data.rs index 13ac6f233..ed8c6647c 100644 --- a/crates/pecos-results/src/data.rs +++ b/crates/pecos-results/src/data.rs @@ -213,10 +213,7 @@ impl Data { Self::Vec(v) => { let mut result = Vec::with_capacity(v.len()); for item in v { - match item.as_u32() { - Some(val) => result.push(val), - None => return None, - } + result.push(item.as_u32()?); } Some(result) } diff --git a/crates/pecos-results/src/shot.rs b/crates/pecos-results/src/shot.rs index 705e287c4..461ea947e 100644 --- a/crates/pecos-results/src/shot.rs +++ b/crates/pecos-results/src/shot.rs @@ -38,16 +38,30 @@ impl Shot { /// * `value` - The register value as u32 /// * `width` - The bit width of the register pub fn add_register(&mut self, name: &str, value: u32, width: usize) { + self.add_register_u64(name, u64::from(value), width); + } + + /// Add a register from a 64-bit value, preserving bits 32-63. + /// + /// Use this for registers wider than 32 bits; [`Shot::add_register`] is a + /// convenience for the common case where the value fits in `u32`. + /// + /// # Parameters + /// + /// * `name` - The register name + /// * `value` - The register value as u64 + /// * `width` - The bit width of the register + pub fn add_register_u64(&mut self, name: &str, value: u64, width: usize) { // Create a BitVec with the specified width let mut bv = BitVec::::with_capacity(width); // Set bits from the value for i in 0..width { - if i < 32 { - // Only shift if within u32 bounds + if i < 64 { + // Only shift if within u64 bounds bv.push((value >> i) & 1 == 1); } else { - // For bits beyond u32, push zeros + // For bits beyond u64, push zeros bv.push(false); } } diff --git a/crates/pecos-results/src/shot_tests.rs b/crates/pecos-results/src/shot_tests.rs index bc874c445..12038a446 100644 --- a/crates/pecos-results/src/shot_tests.rs +++ b/crates/pecos-results/src/shot_tests.rs @@ -411,4 +411,25 @@ mod tests { assert!(!names.contains(&"_width_c".to_string())); assert!(!names.contains(&"_width_d".to_string())); } + + #[test] + fn test_add_register_u64_preserves_high_bits() { + // A 64-bit value with bits above 31 set must round-trip losslessly + // (regression: add_register(u32) zeroed bits 32-63). + let mut shot = Shot::default(); + // bit 63 and bit 40 set, plus low bits 0b101 + let value: u64 = (1 << 63) | (1 << 40) | 0b101; + shot.add_register_u64("c", value, 64); + + let bits = shot.register_to_binary_string("c").unwrap(); + assert_eq!(bits.len(), 64); + assert_eq!(&bits[0..1], "1"); // MSB = bit 63 + assert_eq!(bits.chars().nth(64 - 1 - 40).unwrap(), '1'); // bit 40 + assert_eq!(&bits[62..64], "01"); // low bits: ...0b101 -> ends "101" + assert_eq!(bits.matches('1').count(), 4); + + // The u32 convenience still works for values that fit. + shot.add_register("d", 5, 3); + assert_eq!(shot.register_to_binary_string("d"), Some("101".to_string())); + } } diff --git a/crates/pecos-simulators/src/gens.rs b/crates/pecos-simulators/src/gens.rs index 46808a15b..75e5f07a5 100644 --- a/crates/pecos-simulators/src/gens.rs +++ b/crates/pecos-simulators/src/gens.rs @@ -264,10 +264,10 @@ impl GensGeneric { signs_minus: S, signs_i: S, ) -> Self { - debug_assert!(col_x.len() == num_qubits); - debug_assert!(col_z.len() == num_qubits); - debug_assert!(row_x.len() == num_qubits); - debug_assert!(row_z.len() == num_qubits); + debug_assert_eq!(col_x.len(), num_qubits); + debug_assert_eq!(col_z.len(), num_qubits); + debug_assert_eq!(row_x.len(), num_qubits); + debug_assert_eq!(row_z.len(), num_qubits); Self { num_qubits, col_x, diff --git a/crates/pecos-simulators/src/stab_vec.rs b/crates/pecos-simulators/src/stab_vec.rs index 11ee8cbcb..6072975c6 100644 --- a/crates/pecos-simulators/src/stab_vec.rs +++ b/crates/pecos-simulators/src/stab_vec.rs @@ -2451,7 +2451,7 @@ mod tests { } crz.rz(Angle64::from_radians(0.3), &[QubitId(0)]); let results = crz.mz(&[QubitId(0)]); - assert!(results.len() == 1); + assert_eq!(results.len(), 1); } #[test] @@ -2464,7 +2464,7 @@ mod tests { } crz.rz(Angle64::from_radians(0.3), &[QubitId(0)]); let results = crz.mz(&[QubitId(0)]); - assert!(results.len() == 1); + assert_eq!(results.len(), 1); } #[test] diff --git a/crates/pecos/tests/sim_builder_tests.rs b/crates/pecos/tests/sim_builder_tests.rs index 913d9012d..38d92b8c3 100644 --- a/crates/pecos/tests/sim_builder_tests.rs +++ b/crates/pecos/tests/sim_builder_tests.rs @@ -181,7 +181,7 @@ fn test_sim_determinism() -> Result<(), PecosError> { // Results should be different (this is probabilistic but very likely) // We're checking if the measurements are completely identical, which is // extremely unlikely with different seeds over 100 shots - assert!(results1 != results3); + assert_ne!(results1, results3); Ok(()) } diff --git a/exp/pecos-zx/src/viz/html.rs b/exp/pecos-zx/src/viz/html.rs index fcdb58b48..22cd7ae84 100644 --- a/exp/pecos-zx/src/viz/html.rs +++ b/exp/pecos-zx/src/viz/html.rs @@ -777,7 +777,7 @@ fn write_script( /// Emit a JS palette object definition for the given palette. fn write_palette_js(html: &mut String, name: &str, p: &Palette) { // Capitalize first letter for the JS variable name - let var_name = format!("palette{}{}", &name[..1].to_uppercase(), &name[1..]); + let var_name = format!("palette{}{}", name[..1].to_uppercase(), &name[1..]); let _ = writeln!( html, "const {var_name} = {{\n\ diff --git a/exp/zlup/src/semantic.rs b/exp/zlup/src/semantic.rs index 4a26e55e3..db890ccbe 100644 --- a/exp/zlup/src/semantic.rs +++ b/exp/zlup/src/semantic.rs @@ -5387,22 +5387,16 @@ impl SemanticAnalyzer { 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; - } + let bits = self.type_bit_size(field_ty)?; + total += bits; } 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; - } + let bits = self.type_bit_size(elem_ty)?; + total += bits; } Some(total) } diff --git a/python/pecos-rslib-llvm/pecos_rslib_llvm/__init__.py b/python/pecos-rslib-llvm/pecos_rslib_llvm/__init__.py index ca325a77e..f4a36b482 100644 --- a/python/pecos-rslib-llvm/pecos_rslib_llvm/__init__.py +++ b/python/pecos-rslib-llvm/pecos_rslib_llvm/__init__.py @@ -46,9 +46,61 @@ def _add_windows_llvm_dll_directories() -> None: _add_dll_directory(candidate) +def _preload_unix_llvm_runtime() -> None: + """Preload ``libLLVM`` (global visibility) so the extension's ``NEEDED + libLLVM.so.21.1`` resolves -- a fallback only, run when the extension + cannot import on its own. + + A distributed wheel bundles ``libLLVM`` with an rpath (auditwheel on Linux) + and imports directly, so this never runs for it -- which matters, because + preloading a *second*, differently-named ``libLLVM`` alongside the bundled + copy leaves two sets of LLVM global state that crash the interpreter at + shutdown. But a source/editable build links ``libLLVM`` by soname and may + carry no usable rpath, so without help it fails ``ImportError: + libLLVM.so.21.1``. PECOS installs LLVM under ``~/.pecos/deps/llvm-21.1`` + (honouring ``PECOS_LLVM`` / ``LLVM_SYS_211_PREFIX``); load it from there. + Best-effort: if no candidate is found, the caller's retry re-raises the + original ImportError. + """ + if os.name == "nt": + return + + import ctypes + + # Soname the extension is linked against, plus nearby fallbacks. + lib_names = ("libLLVM.so.21.1", "libLLVM.so.21", "libLLVM.dylib", "libLLVM-21.dylib") + + lib_dirs: list[Path] = [] + for env_name in ("PECOS_LLVM", "LLVM_SYS_211_PREFIX"): + if raw_path := os.environ.get(env_name): + lib_dirs.append(Path(raw_path) / "lib") + lib_dirs.append(Path.home() / ".pecos" / "deps" / "llvm-21.1" / "lib") + + for lib_dir in lib_dirs: + for name in lib_names: + candidate = lib_dir / name + if not candidate.is_file(): + continue + try: + ctypes.CDLL(str(candidate), mode=ctypes.RTLD_GLOBAL) + except OSError: + continue + return + + +# Windows has no rpath, so a Windows source/editable build needs the managed +# LLVM ``bin`` on the DLL search path (search-path only -- never loads a second +# copy, so it is safe to run eagerly). Unix resolves ``libLLVM`` via the +# extension's own rpath (dev build) or a bundled copy (wheel); only if that +# fails do we fall back to preloading it below. _add_windows_llvm_dll_directories() -from . import pecos_rslib_llvm as _native # noqa: E402 +try: + from . import pecos_rslib_llvm as _native # noqa: E402 +except ImportError: + _preload_unix_llvm_runtime() + from . import pecos_rslib_llvm as _native # noqa: E402 + from .pecos_rslib_llvm import * # noqa: E402,F403 __doc__ = _native.__doc__ diff --git a/python/quantum-pecos/src/pecos/circuits/qc2phir.py b/python/quantum-pecos/src/pecos/circuits/qc2phir.py index 685d34f17..116b65b36 100644 --- a/python/quantum-pecos/src/pecos/circuits/qc2phir.py +++ b/python/quantum-pecos/src/pecos/circuits/qc2phir.py @@ -42,6 +42,22 @@ } +def _classical_register_dtype(size: int) -> str: + """Unsigned PHIR integer type that holds ``size`` bits. + + Classical registers are unsigned bit collections, so an unsigned type + (valid for any ``size <= N``, and lossless even at 64 bits where a signed + ``i(size+1)`` register has no backing) is the correct representation. The + PHIR spec only defines ``u32`` and ``u64``, so those are the two choices. + """ + if size <= 32: + return "u32" + if size <= 64: + return "u64" + msg = f"Classical register size {size} exceeds the maximum 64-bit backing type." + raise ValueError(msg) + + def conv_expr(expr: dict[str, Any]) -> dict[str, Any]: """Convert expression dictionary to PHIR format. @@ -238,7 +254,7 @@ def ops_buffer_append( ops.append( { "data": "cvar_define", - "data_type": "i32", + "data_type": _classical_register_dtype(size), "variable": sym, "size": size, }, diff --git a/python/quantum-pecos/src/pecos/classical_interpreters/phir_classical_interpreter.py b/python/quantum-pecos/src/pecos/classical_interpreters/phir_classical_interpreter.py index 93d0600fa..9d5b3fd55 100644 --- a/python/quantum-pecos/src/pecos/classical_interpreters/phir_classical_interpreter.py +++ b/python/quantum-pecos/src/pecos/classical_interpreters/phir_classical_interpreter.py @@ -44,6 +44,36 @@ def version2tuple(v: str) -> tuple[int, ...]: data_type_map_rev = {v: k for k, v in data_type_map.items()} +_MASK64 = (1 << 64) - 1 + + +def _to_i64(bits: int) -> int: + """Reinterpret the low 64 bits of ``bits`` as a signed two's-complement int.""" + bits &= _MASK64 + return bits - (1 << 64) if bits & (1 << 63) else bits + + +def _trunc_div(x: int, y: int) -> int: + """Integer division truncated toward zero (C/Rust semantics). + + Python's ``//`` floors, so ``-7 // 2 == -4``; hardware/C integer division + truncates toward zero, giving ``-3``. Matches the Rust interpreter's + ``wrapping_div``. + """ + q = abs(x) // abs(y) + return -q if (x < 0) != (y < 0) else q + + +def _trunc_mod(x: int, y: int) -> int: + """Integer remainder whose sign follows the dividend (C/Rust semantics). + + Consistent with :func:`_trunc_div`: ``x == _trunc_div(x, y) * y + _trunc_mod(x, y)``. + So ``-7 % 3 == -1`` (not Python's ``2``). Matches Rust's ``wrapping_rem``. + """ + r = abs(x) % abs(y) + return -r if x < 0 else r + + class PhirClassicalInterpreter: """An interpreter that takes in a PHIR program and runs the classical side of the program.""" @@ -149,6 +179,27 @@ def shot_reinit(self) -> None: """Run all code needed at the beginning of each shot, e.g., resetting state.""" self.initialize_cenv() + @staticmethod + def _check_register_width(variable: str, data_type: str, size: int, type_width: int) -> None: + """Ensure a register's declared width fits its backing integer type. + + A signed size-S register is an ``i(S+1)`` integer (S data bits + a sign + bit), so ``S + 1`` must fit the backing width N. An unsigned size-S + register is a ``u(S)`` and must satisfy ``S <= N``. + """ + needed = size + 1 if data_type in signed_data_types else size + if needed > type_width: + kind = "signed" if data_type in signed_data_types else "unsigned" + limit = type_width - 1 if data_type in signed_data_types else type_width + note = " (one bit is reserved for the sign)" if data_type in signed_data_types else "" + msg = ( + f"Register {variable!r} declares {kind} type {data_type!r} with size {size}, " + f"which does not fit its {type_width}-bit backing type{note}. " + f"A size-{size} {kind} register needs {needed} bits. " + f"Use size <= {limit} or a wider integer type." + ) + raise ValueError(msg) + def initialize_cenv(self) -> None: """Initialize the classical environment with program variables.""" self._reset_env() @@ -156,12 +207,15 @@ def initialize_cenv(self) -> None: for cvar in self.cvar_meta: cvar: pt.data.CVarDefine dtype = data_type_map[cvar.data_type] + self._check_register_width(cvar.variable, cvar.data_type, cvar.size, dtype.itemsize * 8) self.cenv.append(dtype(0)) self.cid2dtype.append(dtype) def add_cvar(self, cvar: str, dtype: type[Integer], size: int) -> None: """Adds a new classical variable to the interpreter.""" if cvar not in self.csym2id: + data_type = data_type_map_rev[dtype] + self._check_register_width(cvar, data_type, size, dtype.itemsize * 8) cid = len(self.csym2id) self.csym2id[cvar] = cid self.cenv.append(dtype(0)) @@ -169,7 +223,7 @@ def add_cvar(self, cvar: str, dtype: type[Integer], size: int) -> None: self.cvar_meta.append( pt.data.CVarDefine( size=size, - data_type=data_type_map_rev[dtype], + data_type=data_type, cvar_id=cid, variable=cvar, ), @@ -265,62 +319,133 @@ def get_bit(self, cvar: str, idx: int) -> int: def eval_expr( self, expr: int | str | list | pt.opt.COp, - ) -> int | Integer | None: - """Evaluates integer expressions. + ) -> int: + """Evaluate an integer expression at 64-bit backing precision. + + Mirrors the Rust interpreter: every operand is widened to 64 bits (signed + values sign-extended, unsigned zero-extended), arithmetic wraps at 64 + bits, and division/modulo/shift/comparison follow signedness (an + operation is signed only when BOTH operands are signed). The returned + value is the 64-bit result reinterpreted as a signed integer; the + assignment boundary (handle_cops/assign_int) clamps it to the + destination register's i(S+1)/u(S) width. + """ + bits, _signed, _is_bool = self._eval_bits(expr) + return _to_i64(bits) + + def _eval_bits(self, expr: int | str | list | pt.opt.COp) -> tuple[int, bool, bool]: + """Evaluate to a ``(64-bit bit pattern, is_signed, is_bool)`` triple. - All arithmetic is performed at Python int level (arbitrary precision), - matching the hardware model where evaluation happens at full register - width. The result is NOT wrapped in a PECOS dtype -- that happens at - the assignment boundary in handle_cops/assign_int. + ``is_bool`` mirrors Rust's ``ExprValue::Boolean`` (a bit access or a + ``&&``/``||`` result): unary ``~`` on a boolean is a logical NOT, not a + full-width bit flip. """ match expr: case int(): - return expr - + # Literals that fit i64 are signed (Rust `ArgItem::Integer`); + # larger values up to u64::MAX are unsigned (`ArgItem::UInteger`). + signed = -(1 << 63) <= expr < (1 << 63) + return expr & _MASK64, signed, False case str(): - return int(self.get_cval(expr)) + cid = self.csym2id[expr] + signed = self.cvar_meta[cid].data_type in signed_data_types + return int(self.get_cval(expr)) & _MASK64, signed, False case list(): - return int(self.get_bit(*expr)) + # A single-bit access is a boolean 0/1. + return int(self.get_bit(*expr)) & 1, False, True case pt.opt.COp(): sym = expr.name - args = expr.args - - if sym in {"~"}: # Unary ops - lhs = self.eval_expr(args[0]) - return ~int(lhs) - - # Binary operators - lhs, rhs = args - lhs = int(self.eval_expr(lhs)) - rhs = int(self.eval_expr(rhs)) - - # Map of operators to their functions - ops = { - "^": lambda x, y: x ^ y, - "+": lambda x, y: x + y, - "-": lambda x, y: x - y, - "|": lambda x, y: x | y, - "&": lambda x, y: x & y, - ">>": lambda x, y: x >> y, - "<<": lambda x, y: x << y, - "*": lambda x, y: x * y, - "/": lambda x, y: x // y, - "==": lambda x, y: x == y, - "!=": lambda x, y: x != y, - "<=": lambda x, y: x <= y, - ">=": lambda x, y: x >= y, - "<": lambda x, y: x < y, - ">": lambda x, y: x > y, - "%": lambda x, y: x % y, - } - - if sym in ops: - return ops[sym](lhs, rhs) - - msg = f"Unknown expression type: {sym}" + if sym == "~": # Unary NOT + (arg,) = expr.args + bits, signed, is_bool = self._eval_bits(arg) + if is_bool: + # Logical NOT of a boolean (matches Rust ~Boolean). + return int(not bits), False, True + # Bitwise NOT at eval width; keeps signedness. + return (~bits) & _MASK64, signed, False + lhs, rhs = expr.args + lbits, lsigned, _ = self._eval_bits(lhs) + rbits, rsigned, _ = self._eval_bits(rhs) + return self._eval_binop(sym, lbits, lsigned, rbits, rsigned) + case _: + msg = f"Unsupported expression: {expr!r}" raise ValueError(msg) + + @staticmethod + def _eval_binop(op: str, lbits: int, lsigned: bool, rbits: int, rsigned: bool) -> tuple[int, bool, bool]: + """Apply a binary op on 64-bit patterns, returning ``(bits, is_signed, is_bool)``. + + An operation is signed only when both operands are signed; division, + modulo, shift-right and ordering comparisons then use the signed + interpretation (matching the Rust interpreter). ``+ - * & | ^ << ==`` + depend only on the two's-complement bit pattern, so signedness only + propagates the result tag for a later operation. ``&&``/``||`` return a + boolean; the ordering/equality comparisons return an unsigned 0/1. + """ + result_signed = lsigned and rsigned + li = _to_i64(lbits) + ri = _to_i64(rbits) + + match op: + case "+": + return (lbits + rbits) & _MASK64, result_signed, False + case "-": + return (lbits - rbits) & _MASK64, result_signed, False + case "*": + return (lbits * rbits) & _MASK64, result_signed, False + case "&": + return lbits & rbits, result_signed, False + case "|": + return lbits | rbits, result_signed, False + case "^": + return lbits ^ rbits, result_signed, False + case "/": + if rbits == 0: + msg = "division by zero" + raise ZeroDivisionError(msg) + if result_signed: + return _trunc_div(li, ri) & _MASK64, True, False + return (lbits // rbits) & _MASK64, False, False + case "%": + if rbits == 0: + msg = "modulo by zero" + raise ZeroDivisionError(msg) + if result_signed: + return _trunc_mod(li, ri) & _MASK64, True, False + return (lbits % rbits) & _MASK64, False, False + case "<<": + if ri < 0: + msg = f"Negative shift amount: {ri}" + raise ValueError(msg) + shift = rbits & 0xFFFF + return ((lbits << shift) & _MASK64 if shift < 64 else 0), result_signed, False + case ">>": + if ri < 0: + msg = f"Negative shift amount: {ri}" + raise ValueError(msg) + if lsigned: # arithmetic shift (sign-extends), tagged by the LHS + return (li >> (ri % 64)) & _MASK64, True, False + shift = rbits & 0xFFFF + return (lbits >> shift if shift < 64 else 0), False, False + case "==": + return int(lbits == rbits), False, False + case "!=": + return int(lbits != rbits), False, False + case "<": + return int(li < ri if result_signed else lbits < rbits), False, False + case ">": + return int(li > ri if result_signed else lbits > rbits), False, False + case "<=": + return int(li <= ri if result_signed else lbits <= rbits), False, False + case ">=": + return int(li >= ri if result_signed else lbits >= rbits), False, False + case "&&": + return int(lbits != 0 and rbits != 0), False, True + case "||": + return int(lbits != 0 or rbits != 0), False, True case _: - return None + msg = f"Unknown expression type: {op}" + raise ValueError(msg) def assign_int(self, cvar: str | tuple | list, val: int) -> None: """Assign an integer value to a classical variable or specific bit. @@ -346,14 +471,20 @@ def assign_int(self, cvar: str | tuple | list, val: int) -> None: cval &= ~(one << i) cval |= (val & one) << i - # Mask to the register's declared size. - # This applies to all types -- `size` constrains how many data bits - # the user can assign. The underlying dtype provides the total storage. - size = self.cvar_meta[cid].size - type_width = dtype.itemsize * 8 - if size < type_width: - cval = dtype(int(cval) & ((1 << size) - 1)) - self.cenv[cid] = cval + # Wrap the value to the register's declared width. In PHIR a signed + # size-S register means an i(S+1) integer -- S data bits plus a sign + # bit -- so it wraps to S+1 bits (capped at the backing type width), + # and the sign bit is honored via two's complement. An unsigned size-S + # register is a u(S) and wraps to S bits with no sign bit. Sign-extend + # signed values before storing so later arithmetic and output see the + # correct sign. + meta = self.cvar_meta[cid] + signed = meta.data_type in signed_data_types + width = meta.size + 1 if signed else meta.size + raw = int(cval) & ((1 << width) - 1) + if signed and raw >> (width - 1): + raw -= 1 << width + self.cenv[cid] = dtype(raw) def handle_cops(self, op: pt.opt.COp) -> None: """Handle the processing of classical operations.""" @@ -424,9 +555,16 @@ def results(self, *, return_int: bool = True) -> dict: for csym, cid in self.csym2id.items(): cval = self.cenv[cid] if not return_int: - size = self.cvar_meta[cid].size - # Use native __format__() implementation from Rust scalars - cval = "{:0{width}b}".format(cval, width=size) + meta = self.cvar_meta[cid] + # A signed size-S register is an i(S+1) integer, so it prints + # S+1 bits (S data bits + a sign bit); an unsigned size-S + # register prints S bits. Render the raw two's-complement bit + # pattern rather than Python's sign-and-magnitude form (which + # prints a leading "-" for negatives), so the sign bit shows up + # as a "1"/"0" like every other bit. + width = meta.size + 1 if meta.data_type in signed_data_types else meta.size + raw = int(cval) & ((1 << width) - 1) + cval = format(raw, f"0{width}b") result[csym] = cval return result diff --git a/python/quantum-pecos/src/pecos/reps/pyphir/pyphir.py b/python/quantum-pecos/src/pecos/reps/pyphir/pyphir.py index a55d6bcc6..5fa370a88 100644 --- a/python/quantum-pecos/src/pecos/reps/pyphir/pyphir.py +++ b/python/quantum-pecos/src/pecos/reps/pyphir/pyphir.py @@ -275,9 +275,16 @@ def from_phir( if name == "cvar_define": data_type = o["data_type"] - size = int(data_type[1:]) + type_width = int(data_type[1:]) if "size" in o: size = o["size"] + elif data_type in signed_data_types: + # A signed size-S register is an i(S+1) integer, so the + # full backing type iN holds N-1 data bits plus a sign + # bit -- default a bare signed register to size N-1. + size = type_width - 1 + else: + size = type_width data = d.CVarDefine( data_type=data_type, variable=o["variable"], diff --git a/python/quantum-pecos/tests/pecos/integration/phir/example1.phir.json b/python/quantum-pecos/tests/pecos/integration/phir/example1.phir.json index a3d1250db..a91a416f6 100644 --- a/python/quantum-pecos/tests/pecos/integration/phir/example1.phir.json +++ b/python/quantum-pecos/tests/pecos/integration/phir/example1.phir.json @@ -11,7 +11,7 @@ {"data": "qvar_define", "data_type": "qubits", "variable": "w", "size": 2}, {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 12}, {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 20}, - {"data": "cvar_define", "data_type": "i32", "variable": "c", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "c", "size": 31}, {"data": "cvar_define", "data_type": "i32", "variable": "d", "size": 6}, {"data": "cvar_define", "data_type": "i32", "variable": "e", "size": 16}, {"data": "cvar_define", "data_type": "i32", "variable": "f", "size": 16}, diff --git a/python/quantum-pecos/tests/pecos/integration/phir/example1_no_wasm.phir.json b/python/quantum-pecos/tests/pecos/integration/phir/example1_no_wasm.phir.json index f999c751f..23c9bbd58 100644 --- a/python/quantum-pecos/tests/pecos/integration/phir/example1_no_wasm.phir.json +++ b/python/quantum-pecos/tests/pecos/integration/phir/example1_no_wasm.phir.json @@ -11,7 +11,7 @@ {"data": "qvar_define", "data_type": "qubits", "variable": "w", "size": 2}, {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 12}, {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 20}, - {"data": "cvar_define", "data_type": "i32", "variable": "c", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "c", "size": 31}, {"data": "cvar_define", "data_type": "i32", "variable": "d", "size": 6}, {"data": "cvar_define", "data_type": "i32", "variable": "e", "size": 16}, {"data": "cvar_define", "data_type": "i32", "variable": "f", "size": 16}, diff --git a/python/quantum-pecos/tests/pecos/integration/test_phir_64_bit.py b/python/quantum-pecos/tests/pecos/integration/test_phir_64_bit.py index cb58513c5..72d3b494f 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_phir_64_bit.py +++ b/python/quantum-pecos/tests/pecos/integration/test_phir_64_bit.py @@ -14,9 +14,9 @@ from pecos.engines.hybrid_engine import HybridEngine -def bin2int(result: list[str]) -> int: - """Convert binary string to integer.""" - return int(result[0], base=2) +def twos_complement_bits(value: int, width: int) -> str: + """Expected two's-complement bit string of ``value`` at ``width`` bits.""" + return format(value & ((1 << width) - 1), f"0{width}b") def test_setting_cvar() -> None: @@ -53,9 +53,15 @@ def test_setting_cvar() -> None: results = HybridEngine(qsim="stabilizer").run(program=phir, shots=5) results_dict = results - assert bin2int(results_dict["var_i32"]) == 2**31 - 1 - assert bin2int(results_dict["var_u32"]) == 2**32 - 1 - assert bin2int(results_dict["var_i64"]) == 2**63 - 1 - assert bin2int(results_dict["var_u64"]) == 2**64 - 1 - assert bin2int(results_dict["var_i32neg"]) == -(2**31) - assert bin2int(results_dict["var_i64neg"]) == -(2**63) + # Registers render as fixed-width two's-complement bit strings. Negative + # signed values show the sign bit as "1" -- never a "-" prefix. + expected = { + "var_i32": twos_complement_bits(2**31 - 1, 32), + "var_u32": twos_complement_bits(2**32 - 1, 32), + "var_i64": twos_complement_bits(2**63 - 1, 64), + "var_u64": twos_complement_bits(2**64 - 1, 64), + "var_i32neg": twos_complement_bits(-(2**31), 32), + "var_i64neg": twos_complement_bits(-(2**63), 64), + } + for name, bits in expected.items(): + assert all(shot == bits for shot in results_dict[name]), name diff --git a/python/quantum-pecos/tests/pecos/integration/test_phir_setting_cregs.py b/python/quantum-pecos/tests/pecos/integration/test_phir_setting_cregs.py index ec700aaeb..361577ac9 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_phir_setting_cregs.py +++ b/python/quantum-pecos/tests/pecos/integration/test_phir_setting_cregs.py @@ -120,3 +120,81 @@ def test_setting_mixed() -> None: assert results_dict["b"].count("011") == len(results_dict["b"]) assert results_dict["c"].count("010") == len(results_dict["c"]) assert results_dict["d"].count("100") == len(results_dict["d"]) + + +def test_negative_signed_register_is_twos_complement() -> None: + """A signed register that goes negative prints two's-complement, not "-". + + A size-31 i32 register is an i32 (i(31+1)), so 0 - 1 = -1, which prints as + the full 32-bit two's-complement string (all ones) -- never Python's + sign-and-magnitude "-...". + """ + phir = { + "format": "PHIR/JSON", + "version": "0.1.0", + "ops": [ + {"data": "cvar_define", "data_type": "i32", "variable": "w", "size": 31}, + {"data": "cvar_define", "data_type": "i64", "variable": "d", "size": 63}, + # w = d = 0 - 1 -> -1 -> all ones in two's complement + { + "cop": "=", + "returns": ["w", "d"], + "args": [ + {"cop": "-", "args": [0, 1]}, + {"cop": "-", "args": [0, 1]}, + ], + }, + ], + } + + results = HybridEngine(qsim="stabilizer").run(program=phir, shots=3) + + for bits in results["w"]: + assert bits == "1" * 32 + for bits in results["d"]: + assert bits == "1" * 64 + assert not any("-" in bits for bits in results["w"] + results["d"]) + + +def test_oversized_signed_register_errors() -> None: + """A signed register whose size+1 exceeds its backing width errors loudly.""" + import pytest + + phir = { + "format": "PHIR/JSON", + "version": "0.1.0", + "ops": [ + # i32 size-32 would need 33 bits (32 data + sign) -> does not fit i32. + {"data": "cvar_define", "data_type": "i32", "variable": "bad", "size": 32}, + ], + } + + with pytest.raises(ValueError, match=r"does not fit its 32-bit backing type"): + HybridEngine(qsim="stabilizer").run(program=phir, shots=1) + + +def test_signed_register_prints_sign_bit_width() -> None: + """A signed size-n register prints n+1 bits: n data bits plus a sign bit. + + A size-31 i32-backed register is non-negative (its data bits are masked to + 31 bits), so it prints 32 bits with a leading "0" sign bit. An unsigned + register has no sign bit and stays exactly `size` wide. + """ + phir = { + "format": "PHIR/JSON", + "version": "0.1.0", + "ops": [ + {"data": "cvar_define", "data_type": "i32", "variable": "s", "size": 31}, + {"data": "cvar_define", "data_type": "u32", "variable": "u", "size": 31}, + {"cop": "=", "returns": ["s", "u"], "args": [5, 5]}, + ], + } + + results = HybridEngine(qsim="stabilizer").run(program=phir, shots=3) + + for bits in results["s"]: + assert bits == "0" + "0" * 26 + "00101" # 32 bits: sign "0" + 31 data bits + assert len(bits) == 32 + for bits in results["u"]: + assert bits == "0" * 26 + "00101" # 31 bits, no sign bit + assert len(bits) == 31 diff --git a/python/quantum-pecos/tests/pecos/integration/test_run_phir_sim_registers.py b/python/quantum-pecos/tests/pecos/integration/test_run_phir_sim_registers.py new file mode 100644 index 000000000..950490c28 --- /dev/null +++ b/python/quantum-pecos/tests/pecos/integration/test_run_phir_sim_registers.py @@ -0,0 +1,317 @@ +# 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. + +"""Register-fidelity regression tests for the Rust ``run_phir_sim`` engine path. + +This path (``PhirJsonEngine``) is separate from the ``HybridEngine`` classical +interpreter. These tests lock in that classical registers export losslessly at +full 64-bit width and that ``Result`` operations use the destination register. +""" + +from __future__ import annotations + +import json + +import pytest +from pecos_rslib import run_phir_sim + + +def _run(ops: list[dict]) -> dict: + return run_phir_sim(json.dumps({"format": "PHIR/JSON", "version": "0.1.0", "ops": ops}), shots=1) + + +def _as_int(result: dict, name: str) -> int: + return int(result[name][0], 2) + + +def test_whole_register_result_preserves_high_bits() -> None: + """A whole-register Result of a >32-bit source is not truncated to u32.""" + value = (1 << 40) + 5 + res = _run( + [ + {"data": "cvar_define", "data_type": "u64", "variable": "m", "size": 64}, + {"cop": "=", "returns": ["m"], "args": [value]}, + {"cop": "Result", "args": ["m"], "returns": ["out"]}, + ], + ) + assert _as_int(res, "out") == value + + +def test_indexed_result_exports_destination() -> None: + """An indexed Result exports the accumulated destination, not the source value.""" + res = _run( + [ + {"data": "cvar_define", "data_type": "u64", "variable": "m", "size": 2}, + {"cop": "=", "returns": [["m", 0]], "args": [1]}, + {"cop": "Result", "args": [["m", 0]], "returns": [["dst", 40]]}, + ], + ) + # dst bit 40 set from m[0]; exporting the source m would wrongly give 1. + assert _as_int(res, "dst") == (1 << 40) + + +def test_multiple_indexed_results_accumulate() -> None: + """Multiple indexed Results into one register accumulate correctly.""" + res = _run( + [ + {"data": "cvar_define", "data_type": "u64", "variable": "m", "size": 2}, + {"cop": "=", "returns": [["m", 0]], "args": [1]}, + {"cop": "=", "returns": [["m", 1]], "args": [1]}, + {"cop": "Result", "args": [["m", 0]], "returns": [["c", 0]]}, + {"cop": "Result", "args": [["m", 1]], "returns": [["c", 1]]}, + ], + ) + assert _as_int(res, "c") == 0b11 + + +def test_out_of_range_result_bit_fails_fast() -> None: + """A Result bit index that exceeds the 64-bit backing type errors, not panics/drops.""" + with pytest.raises(RuntimeError, match=r"does not fit|backing type|out of range"): + _run( + [ + {"data": "cvar_define", "data_type": "u64", "variable": "m", "size": 2}, + {"cop": "=", "returns": [["m", 0]], "args": [1]}, + {"cop": "Result", "args": [["m", 0]], "returns": [["dst", 70]]}, + ], + ) + + +# ── Measurement ordering: classical ops must read post-measurement values ──── + + +def _qc(name: str, size: int) -> dict: + return {"data": "qvar_define", "data_type": "qubits", "variable": name, "size": size} + + +def _cd(name: str, size: int) -> dict: + return {"data": "cvar_define", "data_type": "u64", "variable": name, "size": size} + + +def test_measured_whole_register_result() -> None: + """A Result reading a register measured in the same batch sees the measurement.""" + res = _run( + [ + _qc("q", 1), + _cd("m", 1), + _cd("c", 1), + {"qop": "X", "args": [["q", 0]]}, # deterministic measurement -> 1 + {"qop": "Measure", "args": [["q", 0]], "returns": [["m", 0]]}, + {"cop": "Result", "args": ["m"], "returns": ["c"]}, + ], + ) + assert _as_int(res, "c") == 1 + + +def test_measured_indexed_result() -> None: + """An indexed Result of a just-measured bit places it correctly (ordering fix).""" + res = _run( + [ + _qc("q", 1), + _cd("m", 1), + _cd("c", 41), + {"qop": "X", "args": [["q", 0]]}, + {"qop": "Measure", "args": [["q", 0]], "returns": [["m", 0]]}, + {"cop": "Result", "args": [["m", 0]], "returns": [["c", 40]]}, + ], + ) + assert _as_int(res, "c") == (1 << 40) + + +def test_measured_assignment_reads_measurement() -> None: + """An assignment reading a just-measured register sees the measurement (ordering).""" + res = _run( + [ + _qc("q", 1), + _cd("m", 1), + _cd("rr", 8), + {"qop": "X", "args": [["q", 0]]}, + {"qop": "Measure", "args": [["q", 0]], "returns": [["m", 0]]}, + {"cop": "=", "args": [{"cop": "+", "args": ["m", 1]}], "returns": ["rr"]}, + ], + ) + assert _as_int(res, "rr") == 2 + + +def test_conditional_reads_measurement() -> None: + """An if-block condition reads the just-measured register (ordering fix).""" + res = _run( + [ + _qc("q", 2), + _cd("m", 1), + _cd("m2", 1), + {"qop": "X", "args": [["q", 0]]}, + {"qop": "Measure", "args": [["q", 0]], "returns": [["m", 0]]}, + { + "block": "if", + "condition": {"cop": "==", "args": [["m", 0], 1]}, + "true_branch": [{"qop": "X", "args": [["q", 1]]}], + }, + {"qop": "Measure", "args": [["q", 1]], "returns": [["m2", 0]]}, + {"cop": "Result", "args": ["m2"], "returns": ["out"]}, + ], + ) + assert _as_int(res, "out") == 1 + + +def test_mixed_whole_and_indexed_result_same_destination() -> None: + """Whole-register then indexed Result into the same destination both land.""" + res = _run( + [ + _qc("q", 1), + _cd("a", 8), + _cd("m", 1), + _cd("c", 8), + {"cop": "=", "args": [5], "returns": ["a"]}, + {"cop": "Result", "args": ["a"], "returns": ["c"]}, # c = 5 + {"qop": "X", "args": [["q", 0]]}, + {"qop": "Measure", "args": [["q", 0]], "returns": [["m", 0]]}, + {"cop": "Result", "args": [["m", 0]], "returns": [["c", 6]]}, # c |= 1<<6 + ], + ) + assert _as_int(res, "c") == (5 | (1 << 6)) + + +# ── Nested-block measurement ordering (execution-cursor rework) ────── + + +def test_measurement_inside_if_branch_is_mapped() -> None: + """A Measure emitted inside an if-branch maps to its return register.""" + res = _run( + [ + _qc("q", 2), + _cd("m", 1), + _cd("m2", 1), + {"qop": "X", "args": [["q", 0]]}, + {"qop": "Measure", "args": [["q", 0]], "returns": [["m", 0]]}, + { + "block": "if", + "condition": {"cop": "==", "args": [["m", 0], 1]}, + "true_branch": [ + {"qop": "X", "args": [["q", 1]]}, + {"qop": "Measure", "args": [["q", 1]], "returns": [["m2", 0]]}, + ], + }, + {"cop": "Result", "args": ["m2"], "returns": ["out"]}, + ], + ) + assert _as_int(res, "out") == 1 + + +def test_result_inside_sequence_reads_measurement() -> None: + """A Result inside a sequence block runs after the measurement lands.""" + res = _run( + [ + _qc("q", 1), + _cd("m", 1), + _cd("c", 1), + {"qop": "X", "args": [["q", 0]]}, + {"qop": "Measure", "args": [["q", 0]], "returns": [["m", 0]]}, + {"block": "sequence", "ops": [{"cop": "Result", "args": ["m"], "returns": ["c"]}]}, + ], + ) + assert _as_int(res, "c") == 1 + + +def test_branch_local_classical_reads_branch_measurement() -> None: + """A classical op inside a branch reads a measurement queued in the same branch.""" + res = _run( + [ + _qc("q", 2), + _cd("m", 1), + _cd("m2", 1), + _cd("cc", 8), + {"qop": "X", "args": [["q", 0]]}, + {"qop": "Measure", "args": [["q", 0]], "returns": [["m", 0]]}, + { + "block": "if", + "condition": {"cop": "==", "args": [["m", 0], 1]}, + "true_branch": [ + {"qop": "X", "args": [["q", 1]]}, + {"qop": "Measure", "args": [["q", 1]], "returns": [["m2", 0]]}, + {"cop": "=", "args": [{"cop": "+", "args": ["m2", 5]}], "returns": ["cc"]}, + ], + }, + {"cop": "Result", "args": ["cc"], "returns": ["out"]}, + ], + ) + assert _as_int(res, "out") == 6 + + +def test_nested_sequence_if_measure() -> None: + """A block nested inside a block (depth > 1) executes correctly.""" + res = _run( + [ + _qc("q", 2), + _cd("m", 1), + _cd("m2", 1), + {"qop": "X", "args": [["q", 0]]}, + {"qop": "Measure", "args": [["q", 0]], "returns": [["m", 0]]}, + { + "block": "sequence", + "ops": [ + { + "block": "if", + "condition": {"cop": "==", "args": [["m", 0], 1]}, + "true_branch": [ + {"qop": "X", "args": [["q", 1]]}, + {"qop": "Measure", "args": [["q", 1]], "returns": [["m2", 0]]}, + ], + }, + {"cop": "Result", "args": ["m2"], "returns": ["out"]}, + ], + }, + ], + ) + assert _as_int(res, "out") == 1 + + +def test_multi_qubit_measure() -> None: + """A multi-qubit Measure measures all qubits (not just the first).""" + res = _run( + [ + _qc("q", 2), + _cd("mm", 2), + {"qop": "X", "args": [["q", 0]]}, # q0 -> 1, q1 -> 0 + {"qop": "Measure", "args": [["q", 0], ["q", 1]], "returns": [["mm", 0], ["mm", 1]]}, + {"cop": "Result", "args": ["mm"], "returns": ["out"]}, + ], + ) + assert _as_int(res, "out") == 0b01 + + +def test_no_return_measure_keeps_later_outcomes_aligned() -> None: + """A no-return Measure reserves an outcome slot so later measures stay aligned. + + q0=1 (via X) is measured without a return; q1=0 is measured into m2. The + outcomes must map by measured-qubit order, so m2 must see q1's 0, not q0's 1. + """ + res = _run( + [ + _qc("q", 2), + _cd("m2", 1), + {"qop": "X", "args": [["q", 0]]}, # q0 -> 1, q1 -> 0 + {"qop": "Measure", "args": [["q", 0]]}, # no returns + {"qop": "Measure", "args": [["q", 1]], "returns": [["m2", 0]]}, + {"cop": "Result", "args": ["m2"], "returns": ["out"]}, + ], + ) + assert _as_int(res, "out") == 0 + + +def test_unknown_block_type_fails_fast() -> None: + """An unrecognized block type is a hard error, not a silent skip.""" + with pytest.raises(Exception, match="Unknown block"): + _run( + [ + _qc("q", 1), + {"block": "not_a_real_block", "ops": [{"qop": "X", "args": [["q", 0]]}]}, + ], + ) diff --git a/python/quantum-pecos/tests/pecos/regression/test_engines/test_r1xy_conditional_gates.py b/python/quantum-pecos/tests/pecos/regression/test_engines/test_r1xy_conditional_gates.py index f6318aea0..6eafaeb03 100644 --- a/python/quantum-pecos/tests/pecos/regression/test_engines/test_r1xy_conditional_gates.py +++ b/python/quantum-pecos/tests/pecos/regression/test_engines/test_r1xy_conditional_gates.py @@ -28,6 +28,16 @@ from pecos.engines.hybrid_engine import HybridEngine +def meas_bit(shots: list[str]) -> str: + """Return register bit 0 across shots as a string of "0"/"1" chars. + + The measurement writes to bit 0 of a signed ``i64`` size-1 register, whose + bit string is ``sign_bit + data_bit`` -- so bit 0 is the last character. + Joining them lets ``.count("0")`` tally the measured outcomes as before. + """ + return "".join(shot[-1] for shot in shots) + + def test_r1xy_angles_summing_to_4pi_cancel() -> None: """Test that two R1XY gates with angles summing to 4π cancel completely. @@ -57,8 +67,8 @@ def test_r1xy_angles_summing_to_4pi_cancel() -> None: engine = HybridEngine(qsim="state-vector") results = engine.run(phir, shots=100) - zeros = results["a"].count("0") - ones = results["a"].count("1") + zeros = meas_bit(results["a"]).count("0") + ones = meas_bit(results["a"]).count("1") assert ones == 0, f"Expected all measurements to be 0, but got {ones} ones" assert zeros == 100, f"Expected 100 zeros, but got {zeros}" @@ -85,7 +95,7 @@ def test_r1xy_theta_2pi_classified_as_identity() -> None: results = engine.run(phir, shots=100) # R1XY with theta=2π should be identity, leaving qubit in |0⟩ - zeros = results["a"].count("0") + zeros = meas_bit(results["a"]).count("0") assert zeros == 100, f"R1XY(2π, 0) should be identity, but got {100-zeros} non-zero results" @@ -112,8 +122,7 @@ def test_r1xy_theta_2_not_identity() -> None: # R1XY(2.0, 0) should rotate the qubit, not be identity # We expect a mix of 0s and 1s, not all 0s - results["a"].count("0") - ones = results["a"].count("1") + ones = meas_bit(results["a"]).count("1") # With a 2 radian rotation, we should NOT get all zeros assert ones > 0, "R1XY(2.0, 0) should not be identity - expected some 1s in results" @@ -149,7 +158,7 @@ def test_r1xy_angles_summing_to_2pi_return_to_identity() -> None: # R1XY(π, 0) + R1XY(π, 0) = rotation by 2π = identity (up to global phase) # Qubit should remain in |0⟩ - zeros = results["a"].count("0") + zeros = meas_bit(results["a"]).count("0") assert zeros == 100, f"Expected all measurements to be 0 (2π rotation = identity), but got {100-zeros} ones" @@ -179,7 +188,7 @@ def test_conditional_r1xy_with_false_condition() -> None: # Condition is false (t[0]=0, not 1), so R1XY should not execute # Qubit should remain in |0⟩ - zeros = results["a"].count("0") + zeros = meas_bit(results["a"]).count("0") assert zeros == 100, f"Expected all zeros (R1XY not executed), but got {100-zeros} ones" @@ -213,8 +222,8 @@ def test_r1xy_alternative_angles_summing_to_4pi() -> None: engine = HybridEngine(qsim="state-vector") results = engine.run(phir, shots=100) - zeros = results["a"].count("0") - ones = results["a"].count("1") + zeros = meas_bit(results["a"]).count("0") + ones = meas_bit(results["a"]).count("1") assert ones == 0, f"Expected all measurements to be 0 (4π cancellation), but got {ones} ones" assert zeros == 100, f"Expected 100 zeros, but got {zeros}" diff --git a/python/quantum-pecos/tests/pecos/unit/test_phir_classical_interpreter.py b/python/quantum-pecos/tests/pecos/unit/test_phir_classical_interpreter.py index 21f546e5e..a119816cb 100644 --- a/python/quantum-pecos/tests/pecos/unit/test_phir_classical_interpreter.py +++ b/python/quantum-pecos/tests/pecos/unit/test_phir_classical_interpreter.py @@ -102,9 +102,9 @@ def test_i64_lshift_with_python_int(self) -> None: "ops": [ { "data": "cvar_define", - "data_type": "i64", + "data_type": "i64", # i64 == i(63+1); full width is size 63 "variable": "a", - "size": 64, + "size": 63, }, {"cop": "=", "args": [1], "returns": ["a"]}, { @@ -132,9 +132,9 @@ def test_i64_rshift_with_python_int(self) -> None: "ops": [ { "data": "cvar_define", - "data_type": "i64", + "data_type": "i64", # i64 == i(63+1); full width is size 63 "variable": "a", - "size": 64, + "size": 63, }, {"cop": "=", "args": [16], "returns": ["a"]}, { @@ -162,9 +162,9 @@ def test_i64_bitwise_and_with_python_int(self) -> None: "ops": [ { "data": "cvar_define", - "data_type": "i64", + "data_type": "i64", # i64 == i(63+1); full width is size 63 "variable": "a", - "size": 64, + "size": 63, }, {"cop": "=", "args": [15], "returns": ["a"]}, { @@ -192,9 +192,9 @@ def test_i64_bitwise_or_with_python_int(self) -> None: "ops": [ { "data": "cvar_define", - "data_type": "i64", + "data_type": "i64", # i64 == i(63+1); full width is size 63 "variable": "a", - "size": 64, + "size": 63, }, {"cop": "=", "args": [8], "returns": ["a"]}, { @@ -222,9 +222,9 @@ def test_i64_bitwise_xor_with_python_int(self) -> None: "ops": [ { "data": "cvar_define", - "data_type": "i64", + "data_type": "i64", # i64 == i(63+1); full width is size 63 "variable": "a", - "size": 64, + "size": 63, }, {"cop": "=", "args": [15], "returns": ["a"]}, { diff --git a/python/quantum-pecos/tests/pecos/unit/test_qasm_to_phir_json.py b/python/quantum-pecos/tests/pecos/unit/test_qasm_to_phir_json.py index d4a07dcea..c32ee020c 100644 --- a/python/quantum-pecos/tests/pecos/unit/test_qasm_to_phir_json.py +++ b/python/quantum-pecos/tests/pecos/unit/test_qasm_to_phir_json.py @@ -67,8 +67,8 @@ def test_bell_state_validates() -> None: PHIRModel.model_validate(phir) -def test_all_cregs_are_i64() -> None: - """All classical registers should be emitted as i64.""" +def test_all_cregs_are_unsigned() -> None: + """Classical registers are unsigned bit collections: <=32 bits -> u32, else u64.""" qasm = """ OPENQASM 2.0; include "qelib1.inc"; @@ -76,12 +76,11 @@ def test_all_cregs_are_i64() -> None: creg a[1]; creg b[4]; creg c[32]; + creg d[33]; """ phir = qasm_to_phir_json_py(qasm) - cvars = [op for op in phir["ops"] if op.get("data") == "cvar_define"] - assert len(cvars) == 3 - for cvar in cvars: - assert cvar["data_type"] == "i64" + cvars = {op["variable"]: op["data_type"] for op in phir["ops"] if op.get("data") == "cvar_define"} + assert cvars == {"a": "u32", "b": "u32", "c": "u32", "d": "u64"} def test_conditional_produces_if_block() -> None: diff --git a/python/quantum-pecos/tests/pecos/unit/test_rust_python_parity.py b/python/quantum-pecos/tests/pecos/unit/test_rust_python_parity.py index d6671a8b5..314cb5af9 100644 --- a/python/quantum-pecos/tests/pecos/unit/test_rust_python_parity.py +++ b/python/quantum-pecos/tests/pecos/unit/test_rust_python_parity.py @@ -153,8 +153,8 @@ def test_classical_binary_ops(cop: str, a: int, b: int, expected: int) -> None: "format": "PHIR/JSON", "version": "0.1.0", "ops": [ - {"data": "cvar_define", "data_type": "i32", "variable": "x", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "r", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "x", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "r", "size": 31}, {"cop": "=", "returns": ["x"], "args": [a]}, {"cop": "=", "returns": ["r"], "args": [{"cop": cop, "args": ["x", b]}]}, ], @@ -170,18 +170,20 @@ def test_classical_binary_ops(cop: str, a: int, b: int, expected: int) -> None: @pytest.mark.parametrize( ("dtype", "size", "val"), [ - ("i8", 8, 127), - ("i8", 8, -128), + # A signed size-S register is an i(S+1) integer, so the full range of an + # iN backing type is declared with size N-1 (N-1 data bits + sign bit). + ("i8", 7, 127), + ("i8", 7, -128), ("u8", 8, 255), ("u8", 8, 0), - ("i16", 16, 32767), - ("i16", 16, -32768), + ("i16", 15, 32767), + ("i16", 15, -32768), ("u16", 16, 65535), - ("i32", 32, 2**31 - 1), - ("i32", 32, -(2**31)), + ("i32", 31, 2**31 - 1), + ("i32", 31, -(2**31)), ("u32", 32, 2**32 - 1), - ("i64", 64, 2**63 - 1), - ("i64", 64, -(2**63)), + ("i64", 63, 2**63 - 1), + ("i64", 63, -(2**63)), ("u64", 64, 2**64 - 1), ], ) @@ -226,9 +228,9 @@ def test_multi_assign_eval_order() -> None: "format": "PHIR/JSON", "version": "0.1.0", "ops": [ - {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 32}, - {"data": "cvar_define", "data_type": "i32", "variable": "c", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "b", "size": 31}, + {"data": "cvar_define", "data_type": "i32", "variable": "c", "size": 31}, { "cop": "=", "returns": ["a", "b", "c"], @@ -330,7 +332,7 @@ def test_return_int_types() -> None: "format": "PHIR/JSON", "version": "0.1.0", "ops": [ - {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 32}, + {"data": "cvar_define", "data_type": "i32", "variable": "a", "size": 31}, {"data": "cvar_define", "data_type": "u32", "variable": "b", "size": 32}, {"cop": "=", "returns": ["a"], "args": [42]}, {"cop": "=", "returns": ["b"], "args": [7]}, @@ -343,7 +345,8 @@ def test_return_int_types() -> None: # ── Fuzz testing ───────────────────────────────────────────────────── FUZZ_DTYPES = ["i32", "u32", "i64", "u64"] -FUZZ_COPS = ["+", "-", "*", "&", "|", "^", ">>", "<<", "==", "!=", "<", ">", "<=", ">="] +# Includes / and % so signed (negative) division and modulo are fuzzed for parity. +FUZZ_COPS = ["+", "-", "*", "&", "|", "^", ">>", "<<", "/", "%", "==", "!=", "<", ">", "<=", ">="] def _make_random_classical_program(rng: random.Random) -> dict: @@ -354,7 +357,10 @@ def _make_random_classical_program(rng: random.Random) -> dict: for i in range(nvars): dtype = rng.choice(FUZZ_DTYPES) tw = int(dtype[1:]) - size = rng.randint(1, tw) + # A signed size-S register is an i(S+1), so S+1 must fit the backing + # width: signed sizes top out at tw-1, unsigned at tw. + max_size = tw - 1 if dtype.startswith("i") else tw + size = rng.randint(1, max_size) name = f"v{i}" ops.append({"data": "cvar_define", "data_type": dtype, "variable": name, "size": size}) vars_info.append((name, dtype, size)) @@ -509,19 +515,21 @@ def _make_classical_program(var_defs: list[tuple[str, str, int]], ops: list[dict @pytest.mark.parametrize( ("size", "val", "expected"), [ - (2, 3, 3), # 3 fits in 2 bits -> 3 - (2, 5, 1), # 5 = 0b101, masked to 2 bits -> 1 - (2, 4, 0), # 4 = 0b100, masked to 2 bits -> 0 - (2, 7, 3), # 7 = 0b111, masked to 2 bits -> 3 - (3, 10, 2), # 10 = 0b1010, masked to 3 bits -> 2 - (4, 255, 15), # 255 = 0xFF, masked to 4 bits -> 15 - (1, 1, 1), # 1 fits in 1 bit -> 1 - (1, 2, 0), # 2 = 0b10, masked to 1 bit -> 0 - (1, 3, 1), # 3 = 0b11, masked to 1 bit -> 1 + # A signed size-S register is an i(S+1) integer: wrap to S+1 bits and + # sign-extend, so a value whose sign bit (bit S) is set is negative. + (2, 3, 3), # i3: 3 = 0b011, sign bit clear -> 3 + (2, 5, -3), # i3: 5 = 0b101, sign bit set -> 5 - 8 = -3 + (2, 4, -4), # i3: 4 = 0b100 -> 4 - 8 = -4 + (2, 7, -1), # i3: 7 = 0b111 -> 7 - 8 = -1 + (3, 10, -6), # i4: 10 = 0b1010 -> 10 - 16 = -6 + (4, 255, -1), # i5: 255 & 31 = 31 = 0b11111 -> -1 + (1, 1, 1), # i2: 1 = 0b01 -> 1 + (1, 2, -2), # i2: 2 = 0b10 -> 2 - 4 = -2 + (1, 3, -1), # i2: 3 = 0b11 -> 3 - 4 = -1 ], ) def test_signed_narrow_register_masking(size: int, val: int, expected: int) -> None: - """i64 with size < 64 should mask to size bits on assignment.""" + """A signed i64 size-S register is an i(S+1): assignment wraps and sign-extends.""" phir = _make_classical_program( [("v", "i64", size)], [{"cop": "=", "returns": ["v"], "args": [val]}], @@ -534,15 +542,15 @@ def test_signed_narrow_register_masking(size: int, val: int, expected: int) -> N @pytest.mark.parametrize( ("dtype", "size", "val", "expected"), [ - ("i32", 2, 5, 1), # i32 size=2, assign 5 -> mask to 2 bits = 1 - ("i32", 4, 255, 15), # i32 size=4, assign 255 -> mask to 4 bits = 15 - ("u32", 2, 5, 1), # u32 size=2, assign 5 -> mask to 2 bits = 1 - ("u64", 3, 10, 2), # u64 size=3, assign 10 -> mask to 3 bits = 2 - ("i64", 4, -1, 15), # -1 in twos complement, masked to 4 bits = 0b1111 = 15 + ("i32", 2, 5, -3), # i3: assign 5 -> 0b101 -> -3 + ("i32", 4, 255, -1), # i5: assign 255 -> 0b11111 -> -1 + ("u32", 2, 5, 1), # u2: assign 5 -> mask to 2 bits = 1 + ("u64", 3, 10, 2), # u3: assign 10 -> mask to 3 bits = 2 + ("i64", 4, -1, -1), # i5: -1 stays -1 (0b11111) ], ) def test_narrow_register_masking_all_types(dtype: str, size: int, val: int, expected: int) -> None: - """All integer types should mask to size bits on assignment.""" + """Signed types wrap to i(S+1); unsigned types mask to S bits on assignment.""" phir = _make_classical_program( [("v", dtype, size)], [{"cop": "=", "returns": ["v"], "args": [val]}], @@ -558,18 +566,18 @@ def test_narrow_register_masking_all_types(dtype: str, size: int, val: int, expe @pytest.mark.parametrize( ("size", "val", "expected"), [ - # ~val at full 64-bit width, then masked to size bits - (4, 5, 10), # ~5 = ...1111010, masked to 4 bits = 0b1010 = 10 - (4, 0, 15), # ~0 = all ones, masked to 4 bits = 15 - (4, 15, 0), # ~15 = ...10000, masked to 4 bits = 0 - (8, 0xAA, 0x55), # ~0xAA = 0x55...55, masked to 8 bits = 0x55 - (2, 0, 3), # ~0 -> all ones, masked to 2 bits = 3 - (1, 0, 1), # ~0 -> all ones, masked to 1 bit = 1 - (1, 1, 0), # ~1 -> ...1110, masked to 1 bit = 0 + # ~val at full width, then wrapped/sign-extended into i(S+1). + (4, 5, -6), # ~5 = -6, fits i5 -> -6 + (4, 0, -1), # ~0 = -1 -> i5 -> -1 + (4, 15, -16), # ~15 = -16 -> i5 -> -16 + (8, 0xAA, -171), # ~0xAA = -171 -> i9 -> -171 + (2, 0, -1), # ~0 = -1 -> i3 -> -1 + (1, 0, -1), # ~0 = -1 -> i2 -> -1 + (1, 1, -2), # ~1 = -2 -> i2 -> -2 ], ) def test_not_narrow_register(size: int, val: int, expected: int) -> None: - """~ evaluates at full width, result masked to register size.""" + """~ evaluates at full width, result wrapped/sign-extended into i(S+1).""" phir = _make_classical_program( [("v", "i64", size)], [ @@ -588,17 +596,17 @@ def test_not_narrow_register(size: int, val: int, expected: int) -> None: @pytest.mark.parametrize( ("size", "val", "shift", "expected"), [ - (4, 1, 10, 0), # 1 << 10 = 1024, masked to 4 bits = 0 - (4, 1, 3, 8), # 1 << 3 = 8, masked to 4 bits = 8 - (4, 1, 4, 0), # 1 << 4 = 16, masked to 4 bits = 0 - (8, 1, 7, 128), # 1 << 7 = 128, fits in 8 bits - (8, 1, 8, 0), # 1 << 8 = 256, masked to 8 bits = 0 - (2, 1, 1, 2), # 1 << 1 = 2, fits in 2 bits - (2, 1, 2, 0), # 1 << 2 = 4, masked to 2 bits = 0 + (4, 1, 10, 0), # 1 << 10 = 1024 -> i5 -> 0 + (4, 1, 3, 8), # 1 << 3 = 8 -> i5 -> 8 + (4, 1, 4, -16), # 1 << 4 = 16 = 0b10000 -> i5 sign bit -> -16 + (8, 1, 7, 128), # 1 << 7 = 128 -> i9 -> 128 + (8, 1, 8, -256), # 1 << 8 = 256 = sign bit of i9 -> -256 + (2, 1, 1, 2), # 1 << 1 = 2 -> i3 -> 2 + (2, 1, 2, -4), # 1 << 2 = 4 = sign bit of i3 -> -4 ], ) def test_left_shift_with_masking(size: int, val: int, shift: int, expected: int) -> None: - """Left shift evaluates at full width, result masked to register size.""" + """Left shift evaluates at full width, result wrapped/sign-extended into i(S+1).""" phir = _make_classical_program( [("v", "i64", size)], [ @@ -641,20 +649,20 @@ def test_right_shift_with_masking(size: int, val: int, shift: int, expected: int @pytest.mark.parametrize( ("size", "a_val", "b_val", "cop", "expected"), [ - # Subtraction underflow: 0 - 1 at full width is huge negative, masked to size bits - (3, 0, 1, "-", 7), # 0 - 1 = -1 = ...1111, masked to 3 bits = 7 - (4, 0, 1, "-", 15), # 0 - 1 = -1, masked to 4 bits = 15 - (8, 0, 1, "-", 255), # 0 - 1 = -1, masked to 8 bits = 255 - # Addition overflow - (4, 15, 1, "+", 0), # 15 + 1 = 16 = 0b10000, masked to 4 bits = 0 - (4, 15, 2, "+", 1), # 15 + 2 = 17 = 0b10001, masked to 4 bits = 1 - (3, 7, 1, "+", 0), # 7 + 1 = 8 = 0b1000, masked to 3 bits = 0 + # Subtraction underflow: 0 - 1 = -1 fits every i(S+1) -> -1 + (3, 0, 1, "-", -1), # i4: 0 - 1 = -1 + (4, 0, 1, "-", -1), # i5: 0 - 1 = -1 + (8, 0, 1, "-", -1), # i9: 0 - 1 = -1 + # Addition overflow past the sign bit wraps negative + (4, 15, 1, "+", -16), # i5: 15 + 1 = 16 = sign bit -> -16 + (4, 15, 2, "+", -15), # i5: 15 + 2 = 17 = 0b10001 -> -15 + (3, 7, 1, "+", -8), # i4: 7 + 1 = 8 = sign bit -> -8 # Multiplication overflow - (4, 4, 5, "*", 4), # 4 * 5 = 20 = 0b10100, masked to 4 bits = 4 + (4, 4, 5, "*", -12), # i5: 4 * 5 = 20 = 0b10100 -> -12 ], ) def test_expression_overflow_narrow(size: int, a_val: int, b_val: int, cop: str, expected: int) -> None: - """Arithmetic at full width, overflow masked to register size.""" + """Arithmetic at full width, overflow wrapped/sign-extended into i(S+1).""" phir = _make_classical_program( [("v", "i64", size)], [ @@ -731,12 +739,150 @@ def test_signed_division_min_by_neg_one() -> None: assert py_r == rs_r, f"Parity failure on i64::MIN / -1: py={py_r}, rs={rs_r}" +# ── Signed division / modulo with negative operands ───────────────── + + +@pytest.mark.parametrize( + ("a_val", "b_val", "cop", "expected"), + [ + # Truncate toward zero (C/Rust), NOT Python floor. Remainder sign + # follows the dividend. + (-7, 2, "/", -3), + (-7, 3, "%", -1), + (7, -2, "/", -3), + (7, -3, "%", 1), + (-7, -2, "/", 3), + (-8, 3, "%", -2), + (-1, 2, "/", 0), + (-10, 3, "/", -3), + (-10, 3, "%", -1), + ], +) +def test_signed_division_modulo_negative(a_val: int, b_val: int, cop: str, expected: int) -> None: + """Signed division/modulo truncate toward zero and match Rust for negatives.""" + phir = _make_classical_program( + [("a", "i64", 63), ("r", "i64", 63)], + [ + {"cop": "=", "returns": ["a"], "args": [a_val]}, + {"cop": "=", "returns": ["r"], "args": [{"cop": cop, "args": ["a", b_val]}]}, + ], + ) + py_r, rs_r = _run_classical(phir) + assert py_r == rs_r, f"Parity failure: py={py_r}, rs={rs_r}" + assert int(py_r["r"]) == expected, f"{a_val} {cop} {b_val}: expected {expected}, got {int(py_r['r'])}" + + +# ── Full-backing (64-bit) evaluation of intermediate overflow ──────── + + +def test_intermediate_overflow_matches_backing_width() -> None: + """Intermediate arithmetic wraps at 64 bits before a comparison (not arbitrary precision). + + ``(2**62 * 4)`` overflows 64 bits to 0, so ``== 0`` is true in both + interpreters -- Python must evaluate at the backing width, not arbitrary + precision. + """ + phir = _make_classical_program( + [("a", "i64", 63), ("r", "i64", 63)], + [ + {"cop": "=", "returns": ["a"], "args": [2**62]}, + {"cop": "=", "returns": ["r"], "args": [{"cop": "==", "args": [{"cop": "*", "args": ["a", 4]}, 0]}]}, + ], + ) + py_r, rs_r = _run_classical(phir) + assert py_r == rs_r + assert int(py_r["r"]) == 1, f"(2^62*4)==0 should be 1, got {int(py_r['r'])}" + + +def test_unsigned_large_value_comparison() -> None: + """A u64 value above 2**63 compares as a large positive (unsigned), matching Rust. + + This is the case a naive "wrap intermediates to i64" would get wrong: the + value must stay unsigned, so ``> 10`` is true. + """ + phir = _make_classical_program( + [("u", "u64", 64), ("r", "u64", 64)], + [ + {"cop": "=", "returns": ["u"], "args": [2**63 + 5]}, + {"cop": "=", "returns": ["r"], "args": [{"cop": ">", "args": ["u", 10]}]}, + ], + ) + py_r, rs_r = _run_classical(phir) + assert py_r == rs_r + assert int(py_r["r"]) == 1, f"u64(2^63+5) > 10 should be 1, got {int(py_r['r'])}" + + +@pytest.mark.parametrize( + ("cop", "b", "expected"), + [ + # A literal above i64::MAX is an unsigned operand (Rust ArgItem::UInteger), + # so the shift is logical and the comparison is unsigned. + (">>", 1, (2**64 - 1) >> 1), + ("<", 0, 0), + (">", 0, 1), + ], +) +def test_large_literal_is_unsigned(cop: str, b: int, expected: int) -> None: + """Literals in [2**63, 2**64) evaluate as unsigned, matching Rust.""" + phir = _make_classical_program( + [("r", "u64", 64)], + [{"cop": "=", "returns": ["r"], "args": [{"cop": cop, "args": [2**64 - 1, b]}]}], + ) + py_r, rs_r = _run_classical(phir) + assert py_r == rs_r, f"Parity failure: py={py_r}, rs={rs_r}" + assert int(py_r["r"]) == expected + + +@pytest.mark.parametrize("bit", [0, 1]) +def test_not_of_single_bit_is_boolean(bit: int) -> None: + """~ of a single bit is a boolean NOT (0<->1), not a full-width bit flip.""" + phir = _make_classical_program( + [("m", "u64", 2), ("r", "u64", 64)], + [ + {"cop": "=", "returns": [["m", 0]], "args": [bit]}, + {"cop": "=", "returns": ["r"], "args": [{"cop": "~", "args": [["m", 0]]}]}, + ], + ) + py_r, rs_r = _run_classical(phir) + assert py_r == rs_r, f"Parity failure: py={py_r}, rs={rs_r}" + assert int(py_r["r"]) == (0 if bit else 1) + + +@pytest.mark.parametrize("bit", [0, 1]) +def test_double_not_of_bit_roundtrips(bit: int) -> None: + """~~ of a bit is the bit again (boolean NOT is involutive), matching Rust.""" + phir = _make_classical_program( + [("m", "u64", 2), ("r", "u64", 64)], + [ + {"cop": "=", "returns": [["m", 0]], "args": [bit]}, + {"cop": "=", "returns": ["r"], "args": [{"cop": "~", "args": [{"cop": "~", "args": [["m", 0]]}]}]}, + ], + ) + py_r, rs_r = _run_classical(phir) + assert py_r == rs_r, f"Parity failure: py={py_r}, rs={rs_r}" + assert int(py_r["r"]) == bit + + +def test_not_of_bit_expression_is_bitwise() -> None: + """~ of an arithmetic expression on a bit is a bitwise NOT, not boolean.""" + phir = _make_classical_program( + [("m", "u64", 2), ("r", "u64", 64)], + [ + {"cop": "=", "returns": [["m", 0]], "args": [1]}, + {"cop": "=", "returns": ["r"], "args": [{"cop": "~", "args": [{"cop": "&", "args": [["m", 0], 5]}]}]}, + ], + ) + py_r, rs_r = _run_classical(phir) + assert py_r == rs_r, f"Parity failure: py={py_r}, rs={rs_r}" + assert int(py_r["r"]) == ((1 & 5) ^ (2**64 - 1)) # ~(1) at 64 bits + + # ── Nested expressions with narrow registers ──────────────────────── def test_nested_expression_full_width() -> None: - """Nested expressions should evaluate at full width before storing masked.""" - # (a | b) + c where a=3, b=12, c=1 -> (3|12)+1 = 16, masked to 4 bits = 0 + """Nested expressions should evaluate at full width before storing.""" + # (a | b) + c where a=3, b=12, c=1 -> (3|12)+1 = 16 = sign bit of i5 -> -16 phir = _make_classical_program( [("a", "i64", 4), ("b", "i64", 4), ("c", "i64", 4), ("r", "i64", 4)], [ @@ -760,7 +906,7 @@ def test_nested_expression_full_width() -> None: ) py_r, rs_r = _run_classical(phir) assert py_r == rs_r - assert int(py_r["r"]) == 0, f"(3|12)+1 masked to 4 bits: expected 0, got {int(py_r['r'])}" + assert int(py_r["r"]) == -16, f"(3|12)+1 in i5: expected -16, got {int(py_r['r'])}" def test_chained_not_narrow() -> None: @@ -782,7 +928,7 @@ def test_chained_not_narrow() -> None: def test_wide_to_narrow_assignment() -> None: - """Assigning a wide-register value to a narrow register should mask.""" + """Assigning a wide-register value to a narrow register wraps into i(S+1).""" phir = _make_classical_program( [("wide", "i64", 32), ("narrow", "i64", 4)], [ @@ -792,7 +938,7 @@ def test_wide_to_narrow_assignment() -> None: ) py_r, rs_r = _run_classical(phir) assert py_r == rs_r - assert int(py_r["narrow"]) == 15, f"255 into 4-bit register: expected 15, got {int(py_r['narrow'])}" + assert int(py_r["narrow"]) == -1, f"255 into i5 register: expected -1, got {int(py_r['narrow'])}" def test_narrow_to_wide_assignment() -> None: @@ -827,7 +973,7 @@ def test_cross_size_expression() -> None: def test_cross_size_expression_overflow() -> None: """Cross-size expression that overflows the destination register.""" - # narrow = 15 (4 bits), wide = 250 (32 bits), result = 265, masked to 8 bits = 9 + # narrow = 15 (i5 -> +15), wide = 250 (i33 -> +250), sum = 265; i9: 265 - 512 = -247 phir = _make_classical_program( [("narrow", "i64", 4), ("wide", "i64", 32), ("result", "i64", 8)], [ @@ -838,7 +984,7 @@ def test_cross_size_expression_overflow() -> None: ) py_r, rs_r = _run_classical(phir) assert py_r == rs_r - assert int(py_r["result"]) == 9, f"15 + 250 in 8-bit register: expected 9, got {int(py_r['result'])}" + assert int(py_r["result"]) == -247, f"15 + 250 in i9 register: expected -247, got {int(py_r['result'])}" # ── Bit-level operations on narrow registers ──────────────────────── @@ -926,11 +1072,12 @@ def test_bit_read_zero() -> None: ("|", 0b1010, 0b0101, 4, 0b1111), # 10 | 5 = 15 ("^", 0b1010, 0b1111, 4, 0b0101), # 10 ^ 15 = 5 ("&", 0xFF, 0x0F, 4, 0x0F), # 255 & 15, but stored in 4-bit = 15 - ("|", 0b1010, 0b0101, 2, 0b11), # 10 | 5 = 15, masked to 2 bits = 3 + # a=10 -> i3 +2, b=5 -> i3 -3; 2 | -3 = -1; stored i3 -> -1 + ("|", 0b1010, 0b0101, 2, -1), ], ) def test_bitwise_ops_narrow(cop: str, a: int, b: int, size: int, expected: int) -> None: - """Bitwise ops evaluate at full width, result masked to register size.""" + """Bitwise ops evaluate at full width on the signed operands, then wrap into i(S+1).""" phir = _make_classical_program( [("a", "i64", size), ("b", "i64", size), ("r", "i64", size)], [ @@ -973,9 +1120,8 @@ def test_all_ones_register(size: int) -> None: @pytest.mark.parametrize("size", [1, 2, 3, 4, 8, 16, 32, 63]) -def test_overflow_to_all_ones(size: int) -> None: - """0 - 1 in narrow register should give all ones (max value for that size).""" - max_val = (1 << size) - 1 +def test_overflow_to_negative_one(size: int) -> None: + """0 - 1 in a signed i(S+1) register is -1 (all S+1 bits set).""" phir = _make_classical_program( [("v", "i64", size)], [ @@ -985,13 +1131,14 @@ def test_overflow_to_all_ones(size: int) -> None: ) py_r, rs_r = _run_classical(phir) assert py_r == rs_r - assert int(py_r["v"]) == max_val, f"0-1 in size={size}: expected {max_val}, got {int(py_r['v'])}" + assert int(py_r["v"]) == -1, f"0-1 in size={size}: expected -1, got {int(py_r['v'])}" @pytest.mark.parametrize("size", [1, 2, 3, 4, 8, 16, 32, 63]) -def test_overflow_wraps_to_zero(size: int) -> None: - """Max value + 1 in narrow register should wrap to 0.""" - max_val = (1 << size) - 1 +def test_overflow_wraps_to_min(size: int) -> None: + """Max value + 1 in a signed i(S+1) register wraps to its minimum, -(2**S).""" + max_val = (1 << size) - 1 # i(S+1) max: bit S clear + expected = -(1 << size) # i(S+1) min phir = _make_classical_program( [("v", "i64", size)], [ @@ -1001,4 +1148,4 @@ def test_overflow_wraps_to_zero(size: int) -> None: ) py_r, rs_r = _run_classical(phir) assert py_r == rs_r - assert int(py_r["v"]) == 0, f"max+1 in size={size}: expected 0, got {int(py_r['v'])}" + assert int(py_r["v"]) == expected, f"max+1 in size={size}: expected {expected}, got {int(py_r['v'])}"