diff --git a/crates/pecos-core/src/errors.rs b/crates/pecos-core/src/errors.rs index 81382c207..073d12979 100644 --- a/crates/pecos-core/src/errors.rs +++ b/crates/pecos-core/src/errors.rs @@ -100,6 +100,10 @@ pub enum PecosError { #[error("Division by zero")] RuntimeDivisionByZero, + /// Undefined variable referenced at runtime + #[error("Variable '{name}' not found")] + RuntimeUndefinedVariable { name: String }, + /// Stack overflow #[error("Stack overflow")] RuntimeStackOverflow, diff --git a/crates/pecos-phir-json/src/lib.rs b/crates/pecos-phir-json/src/lib.rs index 85be1a827..54ec37d89 100644 --- a/crates/pecos-phir-json/src/lib.rs +++ b/crates/pecos-phir-json/src/lib.rs @@ -150,6 +150,31 @@ mod tests { use std::io::Write; use tempfile::tempdir; + #[cfg(feature = "v0_1")] + #[test] + fn test_oversized_signed_register_fails_fast_in_engine() { + // A signed size==64 register is i(S+1) = i65, which does not fit the + // 64-bit backing integer. The engine must reject it at construction + // (fail fast) rather than swallow the definition error and silently + // recreate the register as i32 on first assignment. Regression test for + // issue #345 (engine-path variable-definition error swallowing). + let program = r#"{ + "format": "PHIR/JSON", + "version": "0.1.0", + "ops": [ + {"data": "cvar_define", "data_type": "i64", "variable": "x", "size": 64}, + {"cop": "=", "returns": ["x"], "args": [9223372036854775807]}, + {"cop": "Result", "args": ["x"], "returns": ["out"]} + ] +}"#; + let err = PhirJsonEngine::from_json(program) + .expect_err("oversized signed register must fail fast at construction"); + assert!( + err.to_string().contains("does not fit"), + "unexpected error: {err}" + ); + } + #[cfg(feature = "v0_1")] #[test] #[allow(clippy::too_many_lines)] diff --git a/crates/pecos-phir-json/src/v0_1/ast.rs b/crates/pecos-phir-json/src/v0_1/ast.rs index 6c7d41d5a..26270396f 100644 --- a/crates/pecos-phir-json/src/v0_1/ast.rs +++ b/crates/pecos-phir-json/src/v0_1/ast.rs @@ -1,5 +1,5 @@ use serde::Deserialize; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::f64::consts::PI; /// Program structure for PHIR (PECOS High-level Intermediate Representation) @@ -12,6 +12,49 @@ pub struct PHIRProgram { pub ops: Vec, } +impl PHIRProgram { + /// Collect the names of all foreign functions invoked by `ffcall` classical + /// operations anywhere in the program, including inside `if`/`sequence` + /// blocks. Mirrors `PyPHIR.foreign_func_calls` and is used to fail fast when + /// a program calls a foreign function the supplied foreign object lacks. + #[must_use] + pub fn foreign_func_calls(&self) -> BTreeSet { + let mut names = BTreeSet::new(); + collect_foreign_func_calls(&self.ops, &mut names); + names + } +} + +/// Recursively gather `ffcall` function names from a slice of operations. +fn collect_foreign_func_calls(ops: &[Operation], names: &mut BTreeSet) { + for op in ops { + match op { + Operation::ClassicalOp { + cop, + function: Some(name), + .. + } if cop == "ffcall" => { + names.insert(name.clone()); + } + Operation::Block { + ops, + true_branch, + false_branch, + .. + } => { + collect_foreign_func_calls(ops, names); + if let Some(branch) = true_branch { + collect_foreign_func_calls(branch, names); + } + if let Some(branch) = false_branch { + collect_foreign_func_calls(branch, names); + } + } + _ => {} + } + } +} + /// Represents an operation in the PHIR program. /// /// Deserialized via a manual `Deserialize` impl that inspects which discriminating @@ -510,4 +553,30 @@ mod tests { panic!("Expected DataExport"); } } + + #[test] + fn test_foreign_func_calls_collects_nested_and_dedups() { + let json = r#"{ + "format": "PHIR/JSON", "version": "0.1.0", "metadata": {}, + "ops": [ + {"cop": "ffcall", "function": "foo", "args": [1], "returns": ["a"]}, + {"block": "if", "condition": {"cop": "==", "args": ["a", 1]}, + "true_branch": [ + {"cop": "ffcall", "function": "bar", "args": [], "returns": ["b"]} + ], + "false_branch": [ + {"cop": "ffcall", "function": "foo", "args": [2], "returns": ["c"]} + ]}, + {"block": "sequence", "ops": [ + {"cop": "ffcall", "function": "baz", "args": [], "returns": ["d"]} + ]} + ] + }"#; + let program: PHIRProgram = serde_json::from_str(json).expect("should parse"); + let expected: BTreeSet = ["bar", "baz", "foo"] + .iter() + .map(|s| (*s).to_string()) + .collect(); + assert_eq!(program.foreign_func_calls(), expected); + } } 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 d312f7cc4..214aaedd0 100644 --- a/crates/pecos-phir-json/src/v0_1/classical_interpreter.rs +++ b/crates/pecos-phir-json/src/v0_1/classical_interpreter.rs @@ -207,6 +207,12 @@ impl PhirClassicalInterpreter { Ok(()) } + /// Detach the foreign object (e.g. so the interpreter can be pickled + /// without it; workers re-attach their own copy via `init`). + pub fn clear_foreign_object(&mut self) { + self.foreign_object = None; + } + /// Get a reference to the program ops. #[must_use] pub fn program_ops(&self) -> &[Operation] { @@ -532,18 +538,14 @@ impl PhirClassicalInterpreter { /// The Environment's `BitValue` storage automatically masks to the declared /// bit width, so no manual masking is needed. fn assign_int_var(&mut self, name: &str, val: u64) -> Result<(), PecosError> { - if self.environment.has_variable(name) { - self.environment.set_raw(name, val)?; - } - Ok(()) + // Assigning a measurement result to an undeclared variable is an error + // (matching Python's `csym2id[cvar]` lookup), not a silent no-op. + self.environment.set_raw(name, val) } /// Assign a bit value to a specific bit of a classical variable. fn assign_int_bit(&mut self, name: &str, idx: usize, val: i64) -> Result<(), PecosError> { - if self.environment.has_variable(name) { - self.environment.set_bit(name, idx, (val & 1) != 0)?; - } - Ok(()) + self.environment.set_bit(name, idx, (val & 1) != 0) } /// Evaluate an expression using the current environment. @@ -591,16 +593,10 @@ impl PhirClassicalInterpreter { for (val, ret) in values.into_iter().zip(returns.iter()) { match ret { ArgItem::Simple(var) => { - if !self.environment.has_variable(var) { - 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, 31)?; - } self.environment.set_bit(var, *idx, (val & 1) != 0)?; } _ => { @@ -660,16 +656,10 @@ impl PhirClassicalInterpreter { if i < result.len() { match ret { ArgItem::Simple(var) => { - if !self.environment.has_variable(var) { - 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, 31)?; - } self.environment.set_bit(var, *idx, (result[i] & 1) != 0)?; } _ => { diff --git a/crates/pecos-phir-json/src/v0_1/engine.rs b/crates/pecos-phir-json/src/v0_1/engine.rs index 8a3e314f7..26c006f84 100644 --- a/crates/pecos-phir-json/src/v0_1/engine.rs +++ b/crates/pecos-phir-json/src/v0_1/engine.rs @@ -154,12 +154,12 @@ impl PhirJsonEngine { size, } = op { - let _ = processor.handle_variable_definition( + processor.handle_variable_definition( data, data_type, variable, infer_size(data_type, *size), - ); + )?; } } @@ -281,12 +281,12 @@ impl PhirJsonEngine { variable, size, } => { - let _ = self.processor.handle_variable_definition( + self.processor.handle_variable_definition( data, data_type, variable, infer_size(data_type, *size), - ); + )?; self.advance_cursor(); } Operation::QuantumOp { diff --git a/crates/pecos-phir-json/src/v0_1/environment.rs b/crates/pecos-phir-json/src/v0_1/environment.rs index 6818193ce..696054f9e 100644 --- a/crates/pecos-phir-json/src/v0_1/environment.rs +++ b/crates/pecos-phir-json/src/v0_1/environment.rs @@ -604,7 +604,9 @@ impl Environment { self.values[idx] = BitValue::from_u64(&info.data_type, info.size, value); Ok(()) } else { - Err(PecosError::Input(format!("Variable '{name}' not found"))) + Err(PecosError::RuntimeUndefinedVariable { + name: name.to_string(), + }) } } @@ -616,7 +618,9 @@ impl Environment { if let Some(&idx) = self.name_to_index.get(name) { Ok(&self.metadata[idx]) } else { - Err(PecosError::Input(format!("Variable '{name}' not found"))) + Err(PecosError::RuntimeUndefinedVariable { + name: name.to_string(), + }) } } @@ -634,9 +638,9 @@ impl Environment { if let Some(&idx) = self.name_to_index.get(var_name) { self.values[idx].get_bit(bit_index).map(BoolBit) } else { - Err(PecosError::Input(format!( - "Variable '{var_name}' not found" - ))) + Err(PecosError::RuntimeUndefinedVariable { + name: var_name.to_string(), + }) } } @@ -661,9 +665,9 @@ impl Environment { self.values[idx] = BitValue::from_u64(&info.data_type, info.size, updated.as_u64()); Ok(()) } else { - Err(PecosError::Input(format!( - "Variable '{var_name}' not found" - ))) + Err(PecosError::RuntimeUndefinedVariable { + name: var_name.to_string(), + }) } } diff --git a/crates/pecos-phir-json/src/v0_1/expression.rs b/crates/pecos-phir-json/src/v0_1/expression.rs index d212871b8..922ca4787 100644 --- a/crates/pecos-phir-json/src/v0_1/expression.rs +++ b/crates/pecos-phir-json/src/v0_1/expression.rs @@ -144,7 +144,7 @@ impl<'a> ExpressionEvaluator<'a> { ExprValue::from_bit_value(value) }); } - return Err(PecosError::Input(format!("Variable '{name}' not found"))); + return Err(PecosError::RuntimeUndefinedVariable { name: name.clone() }); } Expression::Operation { .. } => {} } @@ -221,17 +221,15 @@ impl<'a> ExpressionEvaluator<'a> { ExprValue::from_bit_value(value) }) } else { - Err(PecosError::Input(format!("Variable '{name}' not found"))) + Err(PecosError::RuntimeUndefinedVariable { name: name.clone() }) } } ArgItem::Indexed((name, idx)) => { - if let Ok(bit) = self.environment.get_bit(name, *idx) { - Ok(ExprValue::Boolean(bit.0)) - } else { - Err(PecosError::Input(format!( - "Failed to access bit {name}[{idx}]" - ))) - } + // Propagate the underlying error so an undefined variable surfaces + // as `RuntimeUndefinedVariable` (-> KeyError) rather than being + // flattened into a generic `Input` error. + let bit = self.environment.get_bit(name, *idx)?; + Ok(ExprValue::Boolean(bit.0)) } ArgItem::Integer(val) => Ok(ExprValue::signed(*val)), ArgItem::UInteger(val) => Ok(ExprValue::unsigned(*val)), @@ -414,10 +412,12 @@ impl<'a> ExpressionEvaluator<'a> { /// # Errors /// Returns an error if any bit access fails. pub fn get_bits(&self, name: &str, indices: &[usize]) -> Result, PecosError> { - let value = self - .environment - .get(name) - .ok_or_else(|| PecosError::Input(format!("Variable '{name}' not found")))?; + let value = + self.environment + .get(name) + .ok_or_else(|| PecosError::RuntimeUndefinedVariable { + name: name.to_string(), + })?; let value_u64 = value.as_u64(); indices .iter() diff --git a/crates/pecos-phir-json/src/v0_1/operations.rs b/crates/pecos-phir-json/src/v0_1/operations.rs index 54a09c7b8..ccfb2bdf2 100644 --- a/crates/pecos-phir-json/src/v0_1/operations.rs +++ b/crates/pecos-phir-json/src/v0_1/operations.rs @@ -146,6 +146,14 @@ pub struct OperationProcessor { /// `Measure` ops are queued (including from inside blocks) and consumed by /// `handle_measurements`. pending_measurements: Vec>, + /// Absolute count of measurement outcomes applied so far in the current + /// shot, accumulated across batches and reset per shot in `reset()`. Gives + /// the legacy `measurement_N` variables and the no-return `m` fallback a + /// stable absolute bit position even when a shot's measurements are split + /// across batches (`MAX_BATCH_SIZE` or a deferral). The primary + /// explicit-return path indexes `pending_measurements` batch-locally and is + /// unaffected. + measurements_applied: usize, } impl Default for OperationProcessor { @@ -162,6 +170,7 @@ impl Clone for OperationProcessor { foreign_object: self.foreign_object.as_ref().map(|fo| fo.clone_box()), current_op: self.current_op, pending_measurements: self.pending_measurements.clone(), + measurements_applied: self.measurements_applied, } // All data including mappings is now in the environment } @@ -176,6 +185,7 @@ impl OperationProcessor { foreign_object: None, current_op: 0, pending_measurements: Vec::new(), + measurements_applied: 0, } } @@ -277,6 +287,7 @@ impl OperationProcessor { self.environment.reset_values(); // Environment reset_values now also clears mappings self.pending_measurements.clear(); + self.measurements_applied = 0; // We deliberately don't clear variable definitions or foreign_object // so that we preserve the structure of the program while resetting state @@ -858,10 +869,28 @@ impl OperationProcessor { /// # Errors /// Returns an error if the variable already exists or cannot be added. pub fn add_quantum_variable(&mut self, variable: &str, size: usize) -> Result<(), PecosError> { - // Store in the environment (single source of truth) - self.environment - .add_variable(variable, DataType::Qubits, size)?; - log::debug!("Defined quantum variable {variable} of size {size}"); + // The engine pre-defines variables from the program header and then + // re-encounters the same definitions during execution, so an IDENTICAL + // re-definition must be a no-op (mirrors `add_classical_variable`). A + // CONFLICTING re-definition (different type or size) is a genuine + // definition error and must propagate. + if self.environment.has_variable(variable) { + let info = self.environment.get_variable_info(variable)?; + if info.data_type != DataType::Qubits || info.size != size { + return Err(PecosError::Input(format!( + "Conflicting definition for variable '{variable}': existing {:?} size {}, \ + new qubits size {size}", + info.data_type, info.size + ))); + } + log::debug!( + "Quantum variable '{variable}' already exists in environment, skipping creation" + ); + } else { + self.environment + .add_variable(variable, DataType::Qubits, size)?; + log::debug!("Defined quantum variable {variable} of size {size}"); + } Ok(()) } @@ -1669,7 +1698,12 @@ impl OperationProcessor { /// in the integer variable (e.g., "m") in the environment. /// /// The environment is the single source of truth for all variables. - fn store_measurement_result(&mut self, var_name: &str, var_idx: usize, outcome: u32) { + fn store_measurement_result( + &mut self, + var_name: &str, + var_idx: usize, + outcome: u32, + ) -> Result<(), PecosError> { log::debug!("PHIR: Storing measurement result {var_name}[{var_idx}] = {outcome}"); // Step 1: Ensure the main variable exists in the environment with appropriate size @@ -1679,26 +1713,28 @@ impl OperationProcessor { // 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::U64, var_size) - { - Ok(()) => log::debug!("Created variable {var_name} with size {var_size}"), - Err(e) => log::warn!( - "Could not create variable: {var_name}. Will try to update anyway: {e}" - ), - } + self.environment + .add_variable(var_name, DataType::U64, var_size)?; + log::debug!("Created variable {var_name} with size {var_size}"); } - // Step 2: Update the specific bit directly using the environment's bit setting functionality - let bit_value = u64::from(outcome != 0); - if let Err(e) = self.environment.set_bit(var_name, var_idx, bit_value) { - log::warn!("Could not set bit {var_name}[{var_idx}] = {bit_value}. Error: {e}"); - } else { - log::debug!("Set bit {var_name}[{var_idx}] = {bit_value} in environment"); + // Step 2: Update the specific bit directly. An out-of-range write is a + // hard error -- fail fast rather than silently dropping the outcome. + // `Environment::set_bit` only bounds-checks the backing integer width + // and then re-masks to the declared width, which would silently drop a + // bit between the declared size and the backing width -- so check the + // declared size here. + let declared_size = self.environment.get_variable_info(var_name)?.size; + if var_idx >= declared_size { + return Err(PecosError::Input(format!( + "Measurement write {var_name}[{var_idx}] is out of range for a register of \ + declared size {declared_size}" + ))); } + let bit_value = u64::from(outcome != 0); + self.environment.set_bit(var_name, var_idx, bit_value)?; + log::debug!("Set bit {var_name}[{var_idx}] = {bit_value} in environment"); + Ok(()) } /// Handle incoming measurements from quantum operations and store results @@ -1718,45 +1754,41 @@ impl OperationProcessor { log::debug!("PHIR: Handling {} measurement results", measurements.len()); for (index, outcome) in measurements.iter().enumerate() { - let result_id = u32::try_from(index).unwrap_or(u32::MAX); - log::debug!("PHIR: Received measurement index={index}, outcome={outcome}"); - - // Create the standard measurement variable name (e.g., "measurement_0") + // Absolute position across batches, for the legacy `measurement_N` + // variable and the no-return `m` fallback. The explicit-return path + // below stays batch-local (it indexes this batch's + // `pending_measurements`). + let abs_index = self.measurements_applied + index; + let result_id = u32::try_from(abs_index).unwrap_or(u32::MAX); + log::debug!("PHIR: Received measurement index={abs_index}, outcome={outcome}"); + + // Store in the standard measurement variable name (e.g., "measurement_0") let prefixed_name = format!("{MEASUREMENT_PREFIX}{result_id}"); - - // Store in the standard measurement variable - // Create the variable if it doesn't exist - if !self.environment.has_variable(&prefixed_name) - && let Err(e) = self - .environment - .add_variable(&prefixed_name, DataType::I32, 31) - { - log::warn!("Could not create measurement variable: {prefixed_name}. Error: {e}"); - } - - // Set the measurement value - if let Err(e) = self.environment.set(&prefixed_name, u64::from(*outcome)) { - log::warn!("Could not set measurement variable {prefixed_name}. Error: {e}"); - } else { - log::debug!("Stored measurement result: {prefixed_name} = {outcome}"); + if !self.environment.has_variable(&prefixed_name) { + self.environment + .add_variable(&prefixed_name, DataType::I32, 31)?; } + self.environment.set(&prefixed_name, u64::from(*outcome))?; + log::debug!("Stored measurement result: {prefixed_name} = {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); + 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); + self.store_measurement_result("m", abs_index, *outcome)?; log::debug!( - "PHIR: Auto-mapped measurement result {index} to m[{index}] = {outcome}" + "PHIR: Auto-mapped measurement result {abs_index} to m[{abs_index}] = {outcome}" ); } } + self.measurements_applied += measurements.len(); + // Log mappings for debugging purposes // The environment automatically manages and uses these mappings // when generating results, so no additional processing is needed @@ -2002,6 +2034,97 @@ mod tests { use super::*; use crate::v0_1::ast::{ArgItem, Expression}; + // Issue #345 finding 2: an out-of-range measurement return write must fail + // fast, not log-and-continue (silently dropping the outcome). + #[test] + fn test_out_of_range_measurement_return_fails_fast() { + let mut processor = OperationProcessor::new(); + processor.add_classical_variable("m", "u64", 4).unwrap(); + // Measure one qubit whose return register targets bit 70 -- beyond the + // 64-bit backing width, so the write cannot succeed. + processor + .record_measurement_returns(1, &[("m".to_string(), 70)]) + .unwrap(); + let err = processor + .handle_measurements(&[1], &[]) + .expect_err("out-of-range measurement write must fail fast"); + let msg = err.to_string(); + assert!( + msg.contains("70") || msg.to_lowercase().contains("bound") || msg.contains("fit"), + "expected an out-of-range error, got: {msg}" + ); + } + + // A write between the declared size and the backing integer width must also + // fail fast -- Environment::set_bit alone would accept it and then mask the + // bit away when re-normalizing to the declared width (silent drop). + #[test] + fn test_measurement_write_beyond_declared_size_fails_fast() { + let mut processor = OperationProcessor::new(); + processor.add_classical_variable("m", "u64", 4).unwrap(); + // Bit 4 on a size-4 register: within the 64-bit backing width, but + // beyond the declared size. + processor + .record_measurement_returns(1, &[("m".to_string(), 4)]) + .unwrap(); + let err = processor + .handle_measurements(&[1], &[]) + .expect_err("write beyond the declared register size must fail fast"); + assert!( + err.to_string().contains("declared size 4"), + "expected a declared-size error, got: {err}" + ); + } + + // A conflicting re-definition (same name, different type or size) is a + // genuine definition error; only an identical re-definition is a no-op. + #[test] + fn test_conflicting_quantum_variable_definition_fails() { + let mut processor = OperationProcessor::new(); + processor.add_quantum_variable("q", 2).unwrap(); + processor + .add_quantum_variable("q", 2) + .expect("identical re-definition is a no-op"); + let err = processor + .add_quantum_variable("q", 3) + .expect_err("conflicting size must be rejected"); + assert!(err.to_string().contains("Conflicting definition")); + + processor.add_classical_variable("c", "u32", 4).unwrap(); + let err = processor + .add_quantum_variable("c", 4) + .expect_err("classical/quantum name collision must be rejected"); + assert!(err.to_string().contains("Conflicting definition")); + } + + // Issue #345 finding 3: no-return measurements split across batches must not + // alias onto m[0..] -- the fallback position is absolute across batches, not + // batch-local. + #[test] + fn test_measurement_index_is_absolute_across_batches() { + let mut processor = OperationProcessor::new(); + processor.add_classical_variable("m", "u64", 8).unwrap(); + + // Batch 1: two no-return measures -> m[0]=1, m[1]=0. + processor.record_measurement_returns(2, &[]).unwrap(); + processor.handle_measurements(&[1, 0], &[]).unwrap(); + processor.clear_pending_measurements(); + + // Batch 2: two more. The batch-local index restarts at 0, but the + // absolute position must continue at m[2], m[3] rather than overwrite + // m[0..]. Old (batch-local) behaviour gave m = 0b0011 = 3. + processor.record_measurement_returns(2, &[]).unwrap(); + processor.handle_measurements(&[1, 1], &[]).unwrap(); + + let m = processor + .evaluate_expression(&Expression::Variable("m".to_string())) + .unwrap(); + assert_eq!( + m, 0b1101, + "no-return measurements across batches must not alias m[0..]" + ); + } + #[test] fn test_evaluate_expression() { let mut processor = OperationProcessor::new(); diff --git a/crates/pecos-phir-json/tests/operations/angle_units.rs b/crates/pecos-phir-json/tests/angle_units.rs similarity index 100% rename from crates/pecos-phir-json/tests/operations/angle_units.rs rename to crates/pecos-phir-json/tests/angle_units.rs diff --git a/crates/pecos-phir-json/tests/expressions/environment_tests.rs b/crates/pecos-phir-json/tests/environment_tests.rs similarity index 72% rename from crates/pecos-phir-json/tests/expressions/environment_tests.rs rename to crates/pecos-phir-json/tests/environment_tests.rs index 6b6c989c5..4f5185069 100644 --- a/crates/pecos-phir-json/tests/expressions/environment_tests.rs +++ b/crates/pecos-phir-json/tests/environment_tests.rs @@ -1,6 +1,6 @@ // No need to import PecosError for these tests use pecos_phir_json::v0_1::ast::{ArgItem, Expression}; -use pecos_phir_json::v0_1::environment::{DataType, Environment}; +use pecos_phir_json::v0_1::environment::{BitValue, DataType, Environment}; use pecos_phir_json::v0_1::expression::ExpressionEvaluator; #[test] @@ -20,19 +20,18 @@ fn test_variable_environment() { env.set("i32_var", 12345).unwrap(); // Verify values - assert_eq!(env.get("i8_var").map(|v| v.as_i64()), Some(100)); - assert_eq!(env.get("u8_var").map(|v| v.as_u64()), Some(200)); - assert_eq!(env.get("i32_var").map(|v| v.as_i64()), Some(12345)); + assert_eq!(env.get("i8_var").map(BitValue::as_i64), Some(100)); + assert_eq!(env.get("u8_var").map(BitValue::as_u64), Some(200)); + assert_eq!(env.get("i32_var").map(BitValue::as_i64), Some(12345)); // Test type constraints - env.set("i8_var", 130).unwrap(); // Should wrap around due to i8 constraints - assert_eq!( - env.get("i8_var").map(|v| v.as_u64()), - Some(0xFFFF_FFFF_FFFF_FF82) - ); // -126 as u64 + env.set("i8_var", 130).unwrap(); // 130 wraps into the i8 signed range + // Read a signed register through `as_i64` (the signed view); `as_u64` + // deliberately returns the raw register-width bits, not a sign-extension. + assert_eq!(env.get("i8_var").map(BitValue::as_i64), Some(-126)); env.set("u8_var", 300).unwrap(); // Should be masked to 44 (300 % 256) - assert_eq!(env.get("u8_var").map(|v| v.as_u64()), Some(44)); + assert_eq!(env.get("u8_var").map(BitValue::as_u64), Some(44)); // Test bit operations env.add_variable("bits", DataType::U8, 8).unwrap(); @@ -42,7 +41,7 @@ fn test_variable_environment() { env.set_bit("bits", 2, 1).unwrap(); // Set bit 2 env.set_bit("bits", 4, 1).unwrap(); // Set bit 4 - assert_eq!(env.get("bits").map(|v| v.as_u64()), Some(0b0001_0101)); // Binary 21 + assert_eq!(env.get("bits").map(BitValue::as_u64), Some(0b0001_0101)); // Binary 21 // Test getting individual bits assert!(env.get_bit("bits", 0).unwrap().0); @@ -51,10 +50,10 @@ fn test_variable_environment() { // Test reset_values env.reset_values(); - assert_eq!(env.get("i8_var").map(|v| v.as_u64()), Some(0)); - assert_eq!(env.get("u8_var").map(|v| v.as_u64()), Some(0)); - assert_eq!(env.get("i32_var").map(|v| v.as_u64()), Some(0)); - assert_eq!(env.get("bits").map(|v| v.as_u64()), Some(0)); + assert_eq!(env.get("i8_var").map(BitValue::as_u64), Some(0)); + assert_eq!(env.get("u8_var").map(BitValue::as_u64), Some(0)); + assert_eq!(env.get("i32_var").map(BitValue::as_u64), Some(0)); + assert_eq!(env.get("bits").map(BitValue::as_u64), Some(0)); // Make sure variables still exist after reset assert!(env.has_variable("i8_var")); @@ -79,10 +78,10 @@ fn test_expression_evaluation() { // Test basic expression types let expr_int = Expression::Integer(42); - assert_eq!(evaluator.eval_expr(&expr_int).unwrap(), 42); + assert_eq!(evaluator.eval_expr(&expr_int).unwrap().as_i64(), 42); let expr_var = Expression::Variable("a".to_string()); - assert_eq!(evaluator.eval_expr(&expr_var).unwrap(), 10); + assert_eq!(evaluator.eval_expr(&expr_var).unwrap().as_i64(), 10); // Test arithmetic operations let expr_add = Expression::Operation { @@ -92,7 +91,7 @@ fn test_expression_evaluation() { ArgItem::Simple("b".to_string()), ], }; - assert_eq!(evaluator.eval_expr(&expr_add).unwrap(), 15); + assert_eq!(evaluator.eval_expr(&expr_add).unwrap().as_i64(), 15); let expr_sub = Expression::Operation { cop: "-".to_string(), @@ -101,7 +100,7 @@ fn test_expression_evaluation() { ArgItem::Simple("b".to_string()), ], }; - assert_eq!(evaluator.eval_expr(&expr_sub).unwrap(), 5); + assert_eq!(evaluator.eval_expr(&expr_sub).unwrap().as_i64(), 5); let expr_mul = Expression::Operation { cop: "*".to_string(), @@ -110,7 +109,7 @@ fn test_expression_evaluation() { ArgItem::Simple("b".to_string()), ], }; - assert_eq!(evaluator.eval_expr(&expr_mul).unwrap(), 50); + assert_eq!(evaluator.eval_expr(&expr_mul).unwrap().as_i64(), 50); let expr_div = Expression::Operation { cop: "/".to_string(), @@ -119,7 +118,7 @@ fn test_expression_evaluation() { ArgItem::Simple("b".to_string()), ], }; - assert_eq!(evaluator.eval_expr(&expr_div).unwrap(), 2); + assert_eq!(evaluator.eval_expr(&expr_div).unwrap().as_i64(), 2); // Test bit operations let bitwise_and = Expression::Operation { @@ -129,7 +128,7 @@ fn test_expression_evaluation() { ArgItem::Simple("b".to_string()), ], }; - assert_eq!(evaluator.eval_expr(&bitwise_and).unwrap(), 0); // 10 & 5 = 0 + assert_eq!(evaluator.eval_expr(&bitwise_and).unwrap().as_i64(), 0); // 10 & 5 = 0 let bitwise_or = Expression::Operation { cop: "|".to_string(), @@ -138,7 +137,7 @@ fn test_expression_evaluation() { ArgItem::Simple("b".to_string()), ], }; - assert_eq!(evaluator.eval_expr(&bitwise_or).unwrap(), 15); // 10 | 5 = 15 + assert_eq!(evaluator.eval_expr(&bitwise_or).unwrap().as_i64(), 15); // 10 | 5 = 15 let xor_expr = Expression::Operation { cop: "^".to_string(), @@ -147,7 +146,7 @@ fn test_expression_evaluation() { ArgItem::Simple("b".to_string()), ], }; - assert_eq!(evaluator.eval_expr(&xor_expr).unwrap(), 15); // 10 ^ 5 = 15 + assert_eq!(evaluator.eval_expr(&xor_expr).unwrap().as_i64(), 15); // 10 ^ 5 = 15 // Test nested expressions let nested_expr = Expression::Operation { @@ -163,7 +162,7 @@ fn test_expression_evaluation() { ArgItem::Simple("c".to_string()), ], }; - assert_eq!(evaluator.eval_expr(&nested_expr).unwrap(), 30); // (10 + 5) * 2 = 30 + assert_eq!(evaluator.eval_expr(&nested_expr).unwrap().as_i64(), 30); // (10 + 5) * 2 = 30 // Test complex nested expression let complex_expr = Expression::Operation { @@ -182,5 +181,5 @@ fn test_expression_evaluation() { })), ], }; - assert_eq!(evaluator.eval_expr(&complex_expr).unwrap(), 15); // (10 * 2) - (5 / 1) = 20 - 5 = 15 + assert_eq!(evaluator.eval_expr(&complex_expr).unwrap().as_i64(), 15); // (10 * 2) - (5 / 1) = 20 - 5 = 15 } diff --git a/crates/pecos-phir-json/tests/expressions/expression_tests.rs b/crates/pecos-phir-json/tests/expression_tests.rs similarity index 88% rename from crates/pecos-phir-json/tests/expressions/expression_tests.rs rename to crates/pecos-phir-json/tests/expression_tests.rs index 2d34d8e52..f43767171 100644 --- a/crates/pecos-phir-json/tests/expressions/expression_tests.rs +++ b/crates/pecos-phir-json/tests/expression_tests.rs @@ -3,7 +3,7 @@ mod common; #[cfg(test)] mod tests { use pecos_core::errors::PecosError; - use pecos_engines::{Engine, ShotVec, shot_results::Data}; + use pecos_engines::{Engine, ShotVec}; use pecos_phir_json::v0_1::ast::PHIRProgram; use pecos_phir_json::v0_1::engine::PhirJsonEngine; @@ -65,10 +65,11 @@ mod tests { // Verify the result - we expect output = (10 * 5) - (10 + 5) = 50 - 15 = 35 let shot = &results.shots[0]; if shot.data.contains_key("output") { - // Accept either I32(35) or U32(35) as valid results + // Register results are now stored as a BitVec; compare the numeric value. let value = shot.data.get("output").unwrap(); - assert!( - matches!(value, &Data::I32(35) | &Data::U32(35)), + assert_eq!( + value.as_u32(), + Some(35), "Expected output value to be 35, got {value:?}" ); } else { @@ -142,37 +143,37 @@ mod tests { // Verify the results if available if shot.data.contains_key("less_than_result") { - // Accept either I32(1) or U32(1) as valid results let value = shot.data.get("less_than_result").unwrap(); - assert!( - matches!(value, &Data::I32(1) | &Data::U32(1)), + assert_eq!( + value.as_u32(), + Some(1), "Expected less_than_result to be 1, got {value:?}" ); } if shot.data.contains_key("equal_result") { - // Accept either I32(1) or U32(1) as valid results let value = shot.data.get("equal_result").unwrap(); - assert!( - matches!(value, &Data::I32(1) | &Data::U32(1)), + assert_eq!( + value.as_u32(), + Some(1), "Expected equal_result to be 1, got {value:?}" ); } if shot.data.contains_key("greater_than_result") { - // Accept either I32(1) or U32(1) as valid results let value = shot.data.get("greater_than_result").unwrap(); - assert!( - matches!(value, &Data::I32(1) | &Data::U32(1)), + assert_eq!( + value.as_u32(), + Some(1), "Expected greater_than_result to be 1, got {value:?}" ); } if shot.data.contains_key("combined_result") { - // Accept either I32(1) or U32(1) as valid results let value = shot.data.get("combined_result").unwrap(); - assert!( - matches!(value, &Data::I32(1) | &Data::U32(1)), + assert_eq!( + value.as_u32(), + Some(1), "Expected combined_result to be 1, got {value:?}" ); } @@ -244,37 +245,37 @@ mod tests { // Verify individual results if they exist if shot.data.contains_key("bit_and_result") { - // Accept either I32(1) or U32(1) as valid results let value = shot.data.get("bit_and_result").unwrap(); - assert!( - matches!(value, &Data::I32(1) | &Data::U32(1)), + assert_eq!( + value.as_u32(), + Some(1), "Expected bit_and_result to be 1, got {value:?}" ); } if shot.data.contains_key("bit_or_result") { - // Accept either I32(7) or U32(7) as valid results let value = shot.data.get("bit_or_result").unwrap(); - assert!( - matches!(value, &Data::I32(7) | &Data::U32(7)), + assert_eq!( + value.as_u32(), + Some(7), "Expected bit_or_result to be 7, got {value:?}" ); } if shot.data.contains_key("bit_xor_result") { - // Accept either I32(6) or U32(6) as valid results let value = shot.data.get("bit_xor_result").unwrap(); - assert!( - matches!(value, &Data::I32(6) | &Data::U32(6)), + assert_eq!( + value.as_u32(), + Some(6), "Expected bit_xor_result to be 6, got {value:?}" ); } if shot.data.contains_key("bit_shift_result") { - // Accept either I32(12) or U32(12) as valid results let value = shot.data.get("bit_shift_result").unwrap(); - assert!( - matches!(value, &Data::I32(12) | &Data::U32(12)), + assert_eq!( + value.as_u32(), + Some(12), "Expected bit_shift_result to be 12, got {value:?}" ); } @@ -350,10 +351,10 @@ mod tests { // Verify the expected result - we expect output = (5 * 10) + (15 - 5) = 50 + 10 = 60 if shot.data.contains_key("output") { - // Accept either I32(60) or U32(60) as valid results let value = shot.data.get("output").unwrap(); - assert!( - matches!(value, &Data::I32(60) | &Data::U32(60)), + assert_eq!( + value.as_u32(), + Some(60), "Expected output to be 60, got {value:?}" ); } @@ -426,37 +427,37 @@ mod tests { // Verify individual results if they exist // Initial value is 5 (binary 101), so bits 0 and 2 are 1, bit 1 is 0 if shot.data.contains_key("bit0_result") { - // Accept either I32(1) or U32(1) as valid results let value = shot.data.get("bit0_result").unwrap(); - assert!( - matches!(value, &Data::I32(1) | &Data::U32(1)), + assert_eq!( + value.as_u32(), + Some(1), "Expected bit0_result to be 1, got {value:?}" ); } if shot.data.contains_key("bit1_result") { - // Accept either I32(0) or U32(0) as valid results let value = shot.data.get("bit1_result").unwrap(); - assert!( - matches!(value, &Data::I32(0) | &Data::U32(0)), + assert_eq!( + value.as_u32(), + Some(0), "Expected bit1_result to be 0, got {value:?}" ); } if shot.data.contains_key("bit2_result") { - // Accept either I32(1) or U32(1) as valid results let value = shot.data.get("bit2_result").unwrap(); - assert!( - matches!(value, &Data::I32(1) | &Data::U32(1)), + assert_eq!( + value.as_u32(), + Some(1), "Expected bit2_result to be 1, got {value:?}" ); } if shot.data.contains_key("value_result") { - // Accept either I32(5) or U32(5) as valid results let value = shot.data.get("value_result").unwrap(); - assert!( - matches!(value, &Data::I32(5) | &Data::U32(5)), + assert_eq!( + value.as_u32(), + Some(5), "Expected value_result to be 5, got {value:?}" ); } diff --git a/crates/pecos-phir-json/tests/execution/iterative_blocks.rs b/crates/pecos-phir-json/tests/iterative_blocks.rs similarity index 93% rename from crates/pecos-phir-json/tests/execution/iterative_blocks.rs rename to crates/pecos-phir-json/tests/iterative_blocks.rs index 982f797ad..f4c7009ba 100644 --- a/crates/pecos-phir-json/tests/execution/iterative_blocks.rs +++ b/crates/pecos-phir-json/tests/iterative_blocks.rs @@ -153,11 +153,15 @@ fn test_nested_blocks_iterative() -> Result<(), PecosError> { // 2. z should be 100 (from true branch since y > 10) let env = executor.get_environment(); - let y_value = env.get("y").map(|v| v.as_i64()); + let y_value = env + .get("y") + .map(pecos_phir_json::v0_1::environment::BitValue::as_i64); println!("y value: {y_value:?}"); assert_eq!(y_value, Some(15)); - let z_value = env.get("z").map(|v| v.as_i64()); + let z_value = env + .get("z") + .map(pecos_phir_json::v0_1::environment::BitValue::as_i64); println!("z value: {z_value:?}"); assert_eq!(z_value, Some(100)); @@ -218,7 +222,11 @@ fn test_operation_buffering() -> Result<(), PecosError> { // Verify the final state let env = executor.get_environment(); - assert_eq!(env.get("m").map(|v| v.as_i64()), Some(42)); + assert_eq!( + env.get("m") + .map(pecos_phir_json::v0_1::environment::BitValue::as_i64), + Some(42) + ); Ok(()) } @@ -268,8 +276,16 @@ fn test_iterator_interface() -> Result<(), PecosError> { // Verify the values were set let env = executor.get_environment(); - assert_eq!(env.get("x").map(|v| v.as_i64()), Some(10)); - assert_eq!(env.get("y").map(|v| v.as_i64()), Some(20)); + assert_eq!( + env.get("x") + .map(pecos_phir_json::v0_1::environment::BitValue::as_i64), + Some(10) + ); + assert_eq!( + env.get("y") + .map(pecos_phir_json::v0_1::environment::BitValue::as_i64), + Some(20) + ); Ok(()) } diff --git a/crates/pecos-phir-json/tests/formats/json_fixtures.rs b/crates/pecos-phir-json/tests/json_fixtures.rs similarity index 83% rename from crates/pecos-phir-json/tests/formats/json_fixtures.rs rename to crates/pecos-phir-json/tests/json_fixtures.rs index 1bfd9a355..c09aef171 100644 --- a/crates/pecos-phir-json/tests/formats/json_fixtures.rs +++ b/crates/pecos-phir-json/tests/json_fixtures.rs @@ -2,8 +2,8 @@ Test loading and processing PHIR-JSON fixtures */ -use pecos_phir_json::phir_json_to_module; use pecos_core::errors::PecosError; +use pecos_phir_json::phir_json_to_module; use std::fs; #[test] @@ -16,7 +16,10 @@ fn test_bell_state_fixture() -> Result<(), PecosError> { let module = phir_json_to_module(&bell_json)?; // Verify the module name and structure - assert_eq!(module.name, "bell_state_circuit", "Module should be named 'bell_state_circuit'"); + assert_eq!( + module.name, "bell_state_circuit", + "Module should be named 'bell_state_circuit'" + ); assert!(!module.body.blocks.is_empty(), "Module should have blocks"); // Verify it has the expected operations @@ -28,14 +31,13 @@ fn test_bell_state_fixture() -> Result<(), PecosError> { let mut measure_count = 0; for op in operations { - match &op.operation { - pecos_phir::ops::Operation::Quantum(q) => match q { + if let pecos_phir::ops::Operation::Quantum(q) = &op.operation { + match q { pecos_phir::ops::QuantumOp::H => h_count += 1, pecos_phir::ops::QuantumOp::CX => cx_count += 1, pecos_phir::ops::QuantumOp::Measure => measure_count += 1, _ => {} - }, - _ => {} + } } } @@ -47,7 +49,7 @@ fn test_bell_state_fixture() -> Result<(), PecosError> { } #[test] -fn test_all_json_fixtures() -> Result<(), PecosError> { +fn test_all_json_fixtures() { // Test that all .json files in fixtures directory can be parsed let fixtures_dir = "tests/fixtures"; @@ -57,8 +59,8 @@ fn test_all_json_fixtures() -> Result<(), PecosError> { let path = entry.path(); if path.extension().and_then(|s| s.to_str()) == Some("json") { - let json_content = fs::read_to_string(&path) - .expect(&format!("Failed to read {:?}", path)); + let json_content = + fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read {path:?}")); // Try to parse each JSON file let result = phir_json_to_module(&json_content); @@ -71,6 +73,4 @@ fn test_all_json_fixtures() -> Result<(), PecosError> { } } } - - Ok(()) -} \ No newline at end of file +} diff --git a/crates/pecos-phir-json/tests/formats/json_to_phir_converter.rs b/crates/pecos-phir-json/tests/json_to_phir_converter.rs similarity index 78% rename from crates/pecos-phir-json/tests/formats/json_to_phir_converter.rs rename to crates/pecos-phir-json/tests/json_to_phir_converter.rs index ce43194c8..d409bbefd 100644 --- a/crates/pecos-phir-json/tests/formats/json_to_phir_converter.rs +++ b/crates/pecos-phir-json/tests/json_to_phir_converter.rs @@ -1,11 +1,11 @@ /*! Test the improved PHIR-JSON to PHIR converter functionality -This test was converted from examples/test_improved_converter.rs +This test was converted from `examples/test_improved_converter.rs` */ -use pecos_phir_json::phir_json_to_module; use pecos_core::errors::PecosError; +use pecos_phir_json::phir_json_to_module; #[test] fn test_converter_bell_state_ssa_flow() -> Result<(), PecosError> { @@ -27,12 +27,18 @@ fn test_converter_bell_state_ssa_flow() -> Result<(), PecosError> { let module = phir_json_to_module(bell_json)?; // Verify the module structure - assert!(!module.body.blocks.is_empty(), "Module should have at least one block"); + assert!( + !module.body.blocks.is_empty(), + "Module should have at least one block" + ); let operations = &module.body.blocks[0].operations; // The converter should generate additional operations for bit combining // Original has 7 ops, but converter adds bitwise operations for measurements - assert!(operations.len() > 7, "Converter should add bit-combining operations"); + assert!( + operations.len() > 7, + "Converter should add bit-combining operations" + ); // Count measurement operations and verify they have proper SSA values let mut measure_count = 0; @@ -48,20 +54,21 @@ fn test_converter_bell_state_ssa_flow() -> Result<(), PecosError> { assert_eq!(op.operands.len(), 1, "Measure should have one operand"); assert_eq!(op.results.len(), 1, "Measure should have one result"); } - pecos_phir::ops::Operation::Classical(classical_op) => { - match classical_op { - pecos_phir::ops::ClassicalOp::Bitcast => has_bitcast = true, - pecos_phir::ops::ClassicalOp::Shl(_) => has_shift = true, - pecos_phir::ops::ClassicalOp::Or => has_or = true, - _ => {} - } - } + pecos_phir::ops::Operation::Classical(classical_op) => match classical_op { + pecos_phir::ops::ClassicalOp::Bitcast => has_bitcast = true, + pecos_phir::ops::ClassicalOp::Shl(_) => has_shift = true, + pecos_phir::ops::ClassicalOp::Or => has_or = true, + _ => {} + }, _ => {} } } assert_eq!(measure_count, 2, "Should have 2 measurement operations"); - assert!(has_bitcast, "Should have bitcast operations for type conversion"); + assert!( + has_bitcast, + "Should have bitcast operations for type conversion" + ); assert!(has_shift, "Should have shift operation for bit positioning"); assert!(has_or, "Should have OR operation for bit combining"); @@ -93,15 +100,24 @@ fn test_converter_single_qubit_circuit() -> Result<(), PecosError> { assert!(operations.len() >= 5, "Should have at least 5 operations"); // Verify we have the expected quantum operations - let quantum_ops: Vec<_> = operations.iter() + let quantum_ops: Vec<_> = operations + .iter() .filter_map(|op| match &op.operation { pecos_phir::ops::Operation::Quantum(q) => Some(q), - _ => None + _ => None, }) .collect(); - assert!(quantum_ops.iter().any(|op| matches!(op, pecos_phir::ops::QuantumOp::H))); - assert!(quantum_ops.iter().any(|op| matches!(op, pecos_phir::ops::QuantumOp::Measure))); + assert!( + quantum_ops + .iter() + .any(|op| matches!(op, pecos_phir::ops::QuantumOp::H)) + ); + assert!( + quantum_ops + .iter() + .any(|op| matches!(op, pecos_phir::ops::QuantumOp::Measure)) + ); Ok(()) } @@ -116,4 +132,4 @@ fn test_converter_invalid_json() { let result = phir_json_to_module(invalid_json); assert!(result.is_err(), "Should fail on invalid JSON structure"); -} \ No newline at end of file +} diff --git a/crates/pecos-phir-json/tests/operations/machine_operations.rs b/crates/pecos-phir-json/tests/machine_operations.rs similarity index 97% rename from crates/pecos-phir-json/tests/operations/machine_operations.rs rename to crates/pecos-phir-json/tests/machine_operations.rs index 76da971be..0fd52615d 100644 --- a/crates/pecos-phir-json/tests/operations/machine_operations.rs +++ b/crates/pecos-phir-json/tests/machine_operations.rs @@ -3,7 +3,7 @@ mod common; #[cfg(test)] mod tests { use pecos_core::errors::PecosError; - use pecos_engines::{Engine, ShotVec, shot_results::Data}; + use pecos_engines::{Engine, ShotVec}; use pecos_phir_json::v0_1::ast::PHIRProgram; use pecos_phir_json::v0_1::engine::PhirJsonEngine; use pecos_phir_json::v0_1::operations::{MachineOperationResult, OperationProcessor}; @@ -130,8 +130,8 @@ mod tests { // Look in the shot map for string-based values if shot.data.contains_key("x") { assert_eq!( - shot.data.get("x").unwrap(), - &Data::U32(1), + shot.data.get("x").unwrap().as_u32(), + Some(1), "Expected output value to be 1, got {}", shot.data.get("x").unwrap() ); @@ -139,8 +139,8 @@ mod tests { // Check if source variable was exposed directly else if shot.data.contains_key("var") { assert_eq!( - shot.data.get("var").unwrap(), - &Data::U32(1), + shot.data.get("var").unwrap().as_u32(), + Some(1), "Expected var value to be 1, got {}", shot.data.get("var").unwrap() ); diff --git a/crates/pecos-phir-json/tests/operations/meta_instructions.rs b/crates/pecos-phir-json/tests/meta_instructions.rs similarity index 100% rename from crates/pecos-phir-json/tests/operations/meta_instructions.rs rename to crates/pecos-phir-json/tests/meta_instructions.rs diff --git a/crates/pecos-phir-json/tests/operations/quantum_operations.rs b/crates/pecos-phir-json/tests/quantum_operations.rs similarity index 96% rename from crates/pecos-phir-json/tests/operations/quantum_operations.rs rename to crates/pecos-phir-json/tests/quantum_operations.rs index e1778aa07..13a0e9069 100644 --- a/crates/pecos-phir-json/tests/operations/quantum_operations.rs +++ b/crates/pecos-phir-json/tests/quantum_operations.rs @@ -3,7 +3,6 @@ mod common; #[cfg(test)] mod tests { use pecos_core::errors::PecosError; - use pecos_engines::shot_results::Data; // Import helpers from common module @@ -59,8 +58,9 @@ mod tests { let shot = &results.shots[0]; if shot.data.contains_key("output") { let data_value = shot.data.get("output").unwrap(); + let value = data_value.as_u32(); assert!( - *data_value == Data::U32(0) || *data_value == Data::U32(1), + value == Some(0) || value == Some(1), "Expected measurement value to be 0 or 1, got {data_value}" ); } else { @@ -126,8 +126,9 @@ mod tests { let shot = &results.shots[0]; if shot.data.contains_key("output") { let data_value = shot.data.get("output").unwrap(); + let value = data_value.as_u32(); assert!( - *data_value == Data::U32(0) || *data_value == Data::U32(3), + value == Some(0) || value == Some(3), "Expected Bell state measurement value to be 0 or 3, got {data_value}" ); } else { @@ -192,8 +193,9 @@ mod tests { let shot = &results.shots[0]; if shot.data.contains_key("output") { let data_value = shot.data.get("output").unwrap(); + let value = data_value.as_u32(); assert!( - *data_value == Data::U32(0) || *data_value == Data::U32(1), + value == Some(0) || value == Some(1), "Expected measurement value to be 0 or 1, got {data_value}" ); } else { @@ -345,11 +347,9 @@ mod tests { if shot.data.contains_key("output") { // The value can be either 0 or 1 depending on the implementation let value = shot.data.get("output").unwrap(); + let numeric = value.as_u32(); assert!( - matches!( - value, - &Data::I32(0) | &Data::U32(0) | &Data::I32(1) | &Data::U32(1) - ), + numeric == Some(0) || numeric == Some(1), "Expected control flow output value to be 0 or 1, got {value:?}" ); } else { diff --git a/crates/pecos-phir-json/tests/wasm/wasm_direct_test.rs b/crates/pecos-phir-json/tests/wasm_direct_test.rs similarity index 100% rename from crates/pecos-phir-json/tests/wasm/wasm_direct_test.rs rename to crates/pecos-phir-json/tests/wasm_direct_test.rs diff --git a/crates/pecos-phir-json/tests/wasm/wasm_ffcall_test.rs b/crates/pecos-phir-json/tests/wasm_ffcall_test.rs similarity index 100% rename from crates/pecos-phir-json/tests/wasm/wasm_ffcall_test.rs rename to crates/pecos-phir-json/tests/wasm_ffcall_test.rs diff --git a/crates/pecos-phir-json/tests/wasm/wasm_foreign_object_test.rs b/crates/pecos-phir-json/tests/wasm_foreign_object_test.rs similarity index 100% rename from crates/pecos-phir-json/tests/wasm/wasm_foreign_object_test.rs rename to crates/pecos-phir-json/tests/wasm_foreign_object_test.rs diff --git a/crates/pecos-phir-json/tests/wasm/wasm_integration_tests.rs b/crates/pecos-phir-json/tests/wasm_integration_tests.rs similarity index 100% rename from crates/pecos-phir-json/tests/wasm/wasm_integration_tests.rs rename to crates/pecos-phir-json/tests/wasm_integration_tests.rs diff --git a/python/pecos-rslib/src/phir_classical_interpreter.rs b/python/pecos-rslib/src/phir_classical_interpreter.rs index 5f1059571..7e0fc7201 100644 --- a/python/pecos-rslib/src/phir_classical_interpreter.rs +++ b/python/pecos-rslib/src/phir_classical_interpreter.rs @@ -25,7 +25,7 @@ use pecos_wasm::ForeignObject; use pyo3::prelude::*; use pyo3::types::{PyDict, PyList, PyTuple}; use std::any::Any; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, Mutex}; // ── Python ForeignObject bridge ────────────────────────────────────── @@ -175,23 +175,50 @@ impl PyPhirClassicalInterpreter { program: &Bound<'_, PyAny>, foreign_obj: Option<&Bound<'_, PyAny>>, ) -> PyResult { - // Convert program to JSON string - let json_str = if let Ok(s) = program.extract::() { - s + // Resolve the program to a Python dict (for optional schema validation) + // and a JSON string (for the Rust core), mirroring the input types the + // Python `PhirClassicalInterpreter` accepts: a PHIR/JSON string, a dict, + // or a `PhirConvertible` exposing `to_phir_dict()`. + let json_mod = py.import("json")?; + let (program_dict, json_str) = if let Ok(s) = program.extract::() { + let dict = json_mod.call_method1("loads", (s.as_str(),))?; + (dict, s) } else if program.is_instance_of::() { - let json_mod = py.import("json")?; - json_mod + let s = json_mod .call_method1("dumps", (program,))? - .extract::()? + .extract::()?; + (program.clone(), s) + } else if program.hasattr("to_phir_dict")? { + let dict = program.call_method0("to_phir_dict")?; + let s = json_mod + .call_method1("dumps", (&dict,))? + .extract::()?; + (dict, s) } else { - // Try to_phir_dict() for PhirConvertible objects - let phir_dict = program.call_method0("to_phir_dict")?; - let json_mod = py.import("json")?; - json_mod - .call_method1("dumps", (&phir_dict,))? - .extract::()? + return Err(pyo3::exceptions::PyTypeError::new_err(format!( + "RustPhirClassicalInterpreter cannot accept a '{}' program; pass PHIR as a \ + dict or JSON string, or use the Python interpreter (cinterp=\"python\").", + program.get_type().name()?, + ))); }; + // Honor `phir_validate` (matching the Python interpreter): schema-validate + // the PHIR dict against the pydantic `PhirModel` before running. The + // schema lives in `quantum-pecos` (`pecos.typing`), which `pecos_rslib` + // does not depend on -- fail with an actionable message on standalone + // installs rather than a bare ModuleNotFoundError. + if self.phir_validate { + let typing = py.import("pecos.typing").map_err(|e| { + pyo3::exceptions::PyImportError::new_err(format!( + "phir_validate=True requires the 'quantum-pecos' package (pecos.typing) \ + for PHIR schema validation; install it or set phir_validate=False: {e}" + )) + })?; + typing + .getattr("PhirModel")? + .call_method1("model_validate", (&program_dict,))?; + } + let rust_foreign = foreign_obj.map(|fo| { let py_fo = PyForeignObject { obj: fo.clone().unbind(), @@ -199,6 +226,47 @@ impl PyPhirClassicalInterpreter { Box::new(py_fo) as Box }); + // Fast-fail (matching the intent of the Python interpreter's `check_ffc`) + // when the program invokes foreign functions that are not available -- + // either because the supplied foreign object lacks them, or because no + // foreign object was supplied at all. + { + let program_ast: PHIRProgram = serde_json::from_str(&json_str).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("Failed to parse: {e}")) + })?; + let ffcalls = program_ast.foreign_func_calls(); + if !ffcalls.is_empty() { + let provided: BTreeSet = match foreign_obj { + Some(fo) => fo + .call_method0("get_funcs")? + .extract::>()? + .into_iter() + .collect(), + None => BTreeSet::new(), + }; + let missing = ffcalls + .iter() + .filter(|name| !provided.contains(name.as_str())) + .map(|name| format!("'{name}'")) + .collect::>(); + if !missing.is_empty() { + let missing_set = format!("{{{}}}", missing.join(", ")); + let msg = if foreign_obj.is_some() { + format!( + "The following foreign function calls are listed in the program but \ + not supported by the supplied foreign object: {missing_set}" + ) + } else { + format!( + "The program lists foreign function calls but no foreign object was \ + supplied: {missing_set}" + ) + }; + return Err(pyo3::exceptions::PyException::new_err(msg)); + } + } + } + self.program_json = Some(json_str.clone()); let mut inner = self @@ -325,7 +393,7 @@ impl PyPhirClassicalInterpreter { inner .receive_results(&results) - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}"))) + .map_err(|e| pecos_error_to_pyerr(&e)) } /// Extract measurement bits, optionally filtering private variables. @@ -447,6 +515,27 @@ impl PyPhirClassicalInterpreter { // foreign object was passed via init() and is held in Rust. None } + + /// Allow `foreign_obj = None` (protocol compatibility): detaches the + /// foreign object held in Rust, e.g. before pickling for multiprocessing + /// (`run_multisim` clears it and workers re-attach via `init`). Attaching + /// a foreign object must go through `init()`, so any other value is an + /// error. + #[setter] + fn set_foreign_obj(&mut self, value: Option<&Bound<'_, PyAny>>) -> PyResult<()> { + match value { + None => { + let mut inner = self.inner.lock().map_err(|e| { + pyo3::exceptions::PyRuntimeError::new_err(format!("Lock error: {e}")) + })?; + inner.clear_foreign_object(); + Ok(()) + } + Some(_) => Err(pyo3::exceptions::PyTypeError::new_err( + "foreign_obj can only be set to None (detach); pass a foreign object via init()", + )), + } + } } /// Wrapper for the `program` attribute. @@ -594,6 +683,19 @@ impl PyPhirExecuteIter { } } +/// Map a `PecosError` from the interpreter to the closest Python exception type, +/// so the Rust interpreter's runtime errors match the Python interpreter's (e.g. +/// a division by zero raises `ZeroDivisionError`, and an undefined reference +/// raises `KeyError`, rather than a generic `RuntimeError`). +fn pecos_error_to_pyerr(err: &PecosError) -> PyErr { + let msg = err.to_string(); + match err { + PecosError::RuntimeDivisionByZero => pyo3::exceptions::PyZeroDivisionError::new_err(msg), + PecosError::RuntimeUndefinedVariable { .. } => pyo3::exceptions::PyKeyError::new_err(msg), + _ => pyo3::exceptions::PyRuntimeError::new_err(msg), + } +} + impl PyPhirExecuteIter { fn get_ops_slice<'a>(ops: &'a [Operation], stack_entry: &'a OpsRef) -> &'a [Operation] { match stack_entry { @@ -651,7 +753,7 @@ impl PyPhirExecuteIter { } => { let yielded = interp .make_qop(qop, angles, args, returns, metadata) - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))?; + .map_err(|e| pecos_error_to_pyerr(&e))?; let is_measure = matches!(qop.as_str(), "measure Z" | "Measure" | "Measure +Z"); self.buffer.push(YieldedOp::QOp(yielded)); @@ -668,7 +770,7 @@ impl PyPhirExecuteIter { } => { let yielded = interp .make_mop(mop, args, duration, metadata) - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))?; + .map_err(|e| pecos_error_to_pyerr(&e))?; self.buffer.push(YieldedOp::MOp(yielded)); } @@ -681,7 +783,7 @@ impl PyPhirExecuteIter { } => { interp .handle_cop(cop, args, returns, function) - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")))?; + .map_err(|e| pecos_error_to_pyerr(&e))?; } Operation::Block { @@ -699,9 +801,9 @@ impl PyPhirExecuteIter { let cond = condition.as_ref().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err("If block missing condition") })?; - let cond_val = interp.eval_expr(cond).map_err(|e| { - pyo3::exceptions::PyRuntimeError::new_err(format!("{e}")) - })?; + let cond_val = interp + .eval_expr(cond) + .map_err(|e| pecos_error_to_pyerr(&e))?; if cond_val != 0 { if let Some(tb) = true_branch { self.stack.push((0, OpsRef::Owned(tb.clone()))); diff --git a/python/pecos-rslib/tests/test_phir_wasm_integration.py b/python/pecos-rslib/tests/test_phir_wasm_integration.py index 9aa877ffe..19207077d 100644 --- a/python/pecos-rslib/tests/test_phir_wasm_integration.py +++ b/python/pecos-rslib/tests/test_phir_wasm_integration.py @@ -53,19 +53,19 @@ def test_phir_wasm_basic_ffcall() -> None: "data": "cvar_define", "data_type": "i32", "variable": "a", - "size": 32, + "size": 31, }, { "data": "cvar_define", "data_type": "i32", "variable": "b", - "size": 32, + "size": 31, }, { "data": "cvar_define", "data_type": "i32", "variable": "result", - "size": 32, + "size": 31, }, # Set a = 10 {"cop": "=", "args": [10], "returns": ["a"]}, @@ -137,19 +137,19 @@ def test_phir_wasm_conditional_ffcall() -> None: "data": "cvar_define", "data_type": "i32", "variable": "x", - "size": 32, + "size": 31, }, { "data": "cvar_define", "data_type": "i32", "variable": "result", - "size": 32, + "size": 31, }, { "data": "cvar_define", "data_type": "i32", "variable": "condition", - "size": 32, + "size": 31, }, # Set x = 5 {"cop": "=", "args": [5], "returns": ["x"]}, @@ -232,7 +232,7 @@ def test_phir_wasm_with_quantum_ops() -> None: "data": "cvar_define", "data_type": "i32", "variable": "check", - "size": 32, + "size": 31, }, # Measure qubit (initially |0>) {"qop": "Measure", "args": [["q", 0]], "returns": [["m", 0]]}, diff --git a/python/quantum-pecos/src/pecos/engines/hybrid_engine.py b/python/quantum-pecos/src/pecos/engines/hybrid_engine.py index 1eeda8670..eaeec56dd 100644 --- a/python/quantum-pecos/src/pecos/engines/hybrid_engine.py +++ b/python/quantum-pecos/src/pecos/engines/hybrid_engine.py @@ -36,16 +36,16 @@ def _make_classical_interpreter( """Create a classical interpreter from a string specifier. Args: - spec: One of "python", "rust", or None (defaults to "python"). + spec: One of "python", "rust", or None (defaults to "rust"). Returns: A classical interpreter instance. """ - if spec == "rust": + if spec is None or spec == "rust": from pecos_rslib import RustPhirClassicalInterpreter # noqa: PLC0415 return RustPhirClassicalInterpreter() - if spec is None or spec == "python": + if spec == "python": return PhirClassicalInterpreter() msg = f"Unknown classical interpreter: {spec!r}. Use 'python' or 'rust'." raise ValueError(msg) @@ -80,6 +80,17 @@ class HybridEngine: Note: Parameters of the quantum simulator are provided as extra keyword arguments passed down to ``QuantumSimulator`` as the dictionary ``**params``. + + Note: + The default classical interpreter is now the Rust-backed + ``RustPhirClassicalInterpreter`` (previously the pure-Python + ``PhirClassicalInterpreter``). For valid PHIR programs supplied as a + dict, JSON string, or ``to_phir_dict()``-convertible object it is + behaviorally equivalent and differentially validated against the + Python interpreter. Known limitation: ``PyPHIR`` program objects are + not accepted by the Rust interpreter (clear ``TypeError``). To restore + the previous behavior, construct the engine with + ``HybridEngine(cinterp="python")``. """ def __init__( @@ -96,8 +107,8 @@ def __init__( Args: cinterp: Classical interpreter for executing classical operations. Can be a ClassicalInterpreterProtocol instance, or a string: - "python" for PhirClassicalInterpreter (default), "rust" for - the Rust-backed RustPhirClassicalInterpreter. + "rust" for the Rust-backed RustPhirClassicalInterpreter (default), + "python" for the pure-Python PhirClassicalInterpreter. qsim: Quantum simulator for executing quantum operations. Can be a QuantumSimulator instance or a string specifying the simulator type. Defaults to QuantumSimulator if 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 314cb5af9..21e13be12 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 @@ -25,7 +25,7 @@ import pytest from pecos.classical_interpreters.phir_classical_interpreter import PhirClassicalInterpreter from pecos.engines.hybrid_engine import HybridEngine -from pecos_rslib import RustPhirClassicalInterpreter +from pecos_rslib import RustPhirClassicalInterpreter, qasm_to_phir_json_py if TYPE_CHECKING: from pecos.protocols import ForeignObjectProtocol @@ -96,29 +96,65 @@ def test_phir_integration_files(filename: str) -> None: WAT_DIR = Path(__file__).parent.parent / "integration" / "wat" -def test_wasm_spec_example() -> None: - """Test spec_example.phir.json with WASM foreign object.""" +@pytest.mark.parametrize("filename", ["spec_example.phir.json", "example1.phir.json"]) +def test_wasm_parity(filename: str) -> None: + """PHIR programs with WASM foreign calls must produce identical results.""" from pecos import WasmForeignObject - phir = json.loads((PHIR_DIR / "spec_example.phir.json").read_text()) - math_wat = WAT_DIR / "math.wat" - - py_i = PhirClassicalInterpreter() - py_r = HybridEngine(cinterp=py_i).run( + phir = json.loads((PHIR_DIR / filename).read_text()) + py_r, rs_r = run_both( phir, - foreign_object=WasmForeignObject(math_wat), shots=20, seed=42, + foreign_object=WasmForeignObject(WAT_DIR / "math.wat"), ) + assert py_r == rs_r - rs_i = RustPhirClassicalInterpreter() - rs_r = HybridEngine(cinterp=rs_i).run( + +@pytest.mark.parametrize("interp", ["python", "rust"]) +def test_run_multisim_with_wasm_foreign_object(interp: str) -> None: + """``run_multisim`` with a ``to_dict``-capable foreign object works on both interpreters. + + The multiprocessing helper detaches the interpreter's foreign object + (``cinterp.foreign_obj = None``) before pickling; the Rust interpreter used + to expose ``foreign_obj`` as a getter-only attribute, crashing here. + """ + from pecos import WasmForeignObject + + phir = json.loads((PHIR_DIR / "spec_example.phir.json").read_text()) + results = HybridEngine(cinterp=interp).run_multisim( phir, - foreign_object=WasmForeignObject(math_wat), - shots=20, + foreign_object=WasmForeignObject(WAT_DIR / "math.wat"), + shots=4, seed=42, + pool_size=2, ) + total = sum(len(v) for v in results.values()) + assert total >= 4, f"expected results from all shots, got {results}" + + +# ── Real-generator differential: QASM → PHIR ───────────────────────── + +QASM_VALIDATION_DIR = ( + Path(__file__).resolve().parents[5] / "crates" / "pecos-qasm" / "tests" / "fixtures" / "qasm_validation" +) + +@pytest.mark.parametrize( + "qasm_file", + sorted(QASM_VALIDATION_DIR.glob("*.qasm")), + ids=lambda p: p.stem, +) +def test_qasm_generated_phir_parity(qasm_file: Path) -> None: + """Real generated PHIR must be interpreted identically by both interpreters. + + Converts each QASM fixture to PHIR through the real Rust ``qasm_to_phir_json`` + generator (conditionals, classical registers, ...) and runs the result + through both interpreters -- a differential over actual generator output, + complementing the synthetic fuzz. + """ + phir = qasm_to_phir_json_py(qasm_file.read_text()) + py_r, rs_r = run_both(phir, shots=10, seed=42) assert py_r == rs_r @@ -349,6 +385,33 @@ def test_return_int_types() -> None: FUZZ_COPS = ["+", "-", "*", "&", "|", "^", ">>", "<<", "/", "%", "==", "!=", "<", ">", "<=", ">="] +def _random_expr(rng: random.Random, vars_info: list, depth: int): + """Generate a random (possibly nested) classical expression. + + Nests binary ops and the unary ``~`` so the interpreters' expression + recursion is exercised, not just flat one-level ops. Division/modulo use a + nonzero literal divisor and shifts a bounded literal amount, so no generated + program divides by zero or shifts wildly (both interpreters would agree on + those, but they'd raise/short-circuit and stop the differential). + """ + if depth <= 0 or rng.random() < 0.4: + # Leaf: a variable reference or a small literal. + if rng.random() < 0.6: + return rng.choice(vars_info)[0] + return rng.randint(0, 15) + if rng.random() < 0.2: + return {"cop": "~", "args": [_random_expr(rng, vars_info, depth - 1)]} + cop = rng.choice(FUZZ_COPS) + lhs = _random_expr(rng, vars_info, depth - 1) + if cop in ("/", "%"): + rhs = rng.randint(1, 15) + elif cop in (">>", "<<"): + rhs = rng.randint(0, 15) + else: + rhs = _random_expr(rng, vars_info, depth - 1) + return {"cop": cop, "args": [lhs, rhs]} + + def _make_random_classical_program(rng: random.Random) -> dict: """Generate a random classical-only PHIR program.""" nvars = rng.randint(2, 5) @@ -380,14 +443,8 @@ def _make_random_classical_program(rng: random.Random) -> dict: val = max(-(2**63), min(2**63 - 1, val)) ops.append({"cop": "=", "returns": [tname], "args": [val]}) elif op_kind < 0.6: - src = rng.choice(vars_info) - cop = rng.choice(FUZZ_COPS) - rhs = rng.randint(0, 15) - if cop in ("/", "%"): - rhs = max(rhs, 1) - if cop in (">>", "<<"): - rhs = rhs % 16 - ops.append({"cop": "=", "returns": [tname], "args": [{"cop": cop, "args": [src[0], rhs]}]}) + expr = _random_expr(rng, vars_info, depth=rng.randint(1, 3)) + ops.append({"cop": "=", "returns": [tname], "args": [expr]}) elif op_kind < 0.8: bi = rng.randint(0, min(tsize - 1, 7)) bv = rng.randint(0, 1) @@ -457,7 +514,7 @@ def _make_random_quantum_program(rng: random.Random) -> dict: def test_fuzz_classical_programs() -> None: """Fuzz test: random classical programs must produce identical results.""" rng = random.Random(2026) - for i in range(200): + for i in range(2000): phir = _make_random_classical_program(rng) py_r, rs_r = run_both(phir, seed=i, qsim="stabilizer") assert py_r == rs_r, f"Classical fuzz program {i} produced different results" @@ -468,13 +525,13 @@ def test_fuzz_quantum_programs() -> None: rng = random.Random(2026) identical = 0 - for i in range(200): + for i in range(1000): phir = _make_random_quantum_program(rng) py_r, rs_r = run_both(phir, shots=20, seed=i + 10000) assert py_r == rs_r, f"Quantum fuzz program {i} produced different results" identical += 1 - assert identical == 200 + assert identical == 1000 # ── Targeted classical edge cases ────────────────────────────────── @@ -720,6 +777,97 @@ def test_division_by_zero(cop: str) -> None: run_both(phir, qsim="stabilizer") +@pytest.mark.parametrize("interp", ["python", "rust"]) +@pytest.mark.parametrize("cop", ["/", "%"]) +def test_division_by_zero_raises_zero_division_error(interp: str, cop: str) -> None: + """Division/modulo by zero raises the exact ``ZeroDivisionError`` in both interpreters. + + ``run_both`` cannot assert this because it raises in the Python interpreter + before the Rust one runs, so each interpreter is exercised directly here. + """ + phir = _make_classical_program( + [("a", "i64", 32), ("r", "i64", 32)], + [ + {"cop": "=", "returns": ["a"], "args": [42]}, + {"cop": "=", "returns": ["r"], "args": [{"cop": cop, "args": ["a", 0]}]}, + ], + ) + with pytest.raises(ZeroDivisionError): + HybridEngine(cinterp=interp, qsim="stabilizer").run(phir, shots=1, seed=42) + + +@pytest.mark.parametrize("interp", ["python", "rust"]) +@pytest.mark.parametrize( + "read_expr", + [ + {"cop": "+", "args": ["missing", 1]}, # whole-variable read + {"cop": "+", "args": [["missing", 0], 1]}, # indexed bit read + ], + ids=["whole", "indexed"], +) +def test_undefined_variable_raises_key_error(interp: str, read_expr: dict) -> None: + """Reading an undefined classical variable (whole or indexed) raises ``KeyError`` in both.""" + phir = _make_classical_program( + [("x", "u32", 4)], + [{"cop": "=", "returns": ["x"], "args": [read_expr]}], + ) + with pytest.raises(KeyError): + HybridEngine(cinterp=interp, qsim="stabilizer").run(phir, shots=1, seed=42) + + +@pytest.mark.parametrize("interp", ["python", "rust"]) +@pytest.mark.parametrize( + "ops", + [ + [{"cop": "=", "returns": ["undeclared"], "args": [7]}], # whole-variable assignment + [{"cop": "=", "returns": [["undeclared", 0]], "args": [1]}], # indexed assignment + ], + ids=["assign-simple", "assign-indexed"], +) +def test_assign_to_undeclared_variable_raises_key_error(interp: str, ops: list) -> None: + """Assigning to an undeclared classical variable raises ``KeyError`` in both interpreters.""" + phir = {"format": "PHIR/JSON", "version": "0.1.0", "ops": ops} + with pytest.raises(KeyError): + HybridEngine(cinterp=interp).run(phir, shots=1, seed=42) + + +@pytest.mark.parametrize("interp", ["python", "rust"]) +def test_measure_to_undeclared_variable_raises_key_error(interp: str) -> None: + """Measuring into an undeclared classical variable raises ``KeyError`` in both interpreters.""" + phir = { + "format": "PHIR/JSON", + "version": "0.1.0", + "ops": [ + {"data": "qvar_define", "data_type": "qubits", "variable": "q", "size": 1}, + {"qop": "Measure", "args": [["q", 0]], "returns": [["undeclared", 0]]}, + ], + } + with pytest.raises(KeyError): + HybridEngine(cinterp=interp, qsim="stabilizer").run(phir, shots=1, seed=42) + + +@pytest.mark.parametrize("interp", ["python", "rust"]) +def test_ffcall_return_to_undeclared_variable_raises_key_error(interp: str) -> None: + """An ffcall returning into an undeclared classical variable raises ``KeyError`` in both.""" + + class _ForeignObj: + def init(self) -> None: ... + def shot_reinit(self) -> None: ... + def get_funcs(self) -> list: + return ["f"] + + def exec(self, _func: str, _args: list) -> list: + return [1] + + phir = { + "format": "PHIR/JSON", + "version": "0.1.0", + "ops": [{"cop": "ffcall", "function": "f", "args": [], "returns": ["undeclared"]}], + } + with pytest.raises(KeyError): + HybridEngine(cinterp=interp).run(phir, foreign_object=_ForeignObj(), shots=1, seed=42) + + def test_signed_division_min_by_neg_one() -> None: """i64::MIN / -1 wraps to i64::MIN in both interpreters.