diff --git a/crates/pecos-phir-json/tests/fixtures/empty_quantum_circuit.phir.json b/crates/pecos-phir-json/tests/fixtures/empty_quantum_circuit.phir.json new file mode 100644 index 000000000..11ac27ad0 --- /dev/null +++ b/crates/pecos-phir-json/tests/fixtures/empty_quantum_circuit.phir.json @@ -0,0 +1,15 @@ +{ + "format": "PHIR/JSON", + "version": "0.1.0", + "metadata": { + "source_program_type": [ + "PECOS.QuantumCircuit", + [ + "PECOS", + "fixture" + ] + ], + "num_qubits": 0 + }, + "ops": [] +} diff --git a/crates/pecos-phir-json/tests/quantum_declarations.rs b/crates/pecos-phir-json/tests/quantum_declarations.rs index 5ed4a1706..74bf17649 100644 --- a/crates/pecos-phir-json/tests/quantum_declarations.rs +++ b/crates/pecos-phir-json/tests/quantum_declarations.rs @@ -1,3 +1,4 @@ +use pecos_engines::{ClassicalEngine, byte_message::ByteMessage}; use pecos_phir::ops::Operation as PhirOperation; use pecos_phir_json::{ phir_json_to_module, @@ -61,6 +62,18 @@ fn declaration_verdicts() { false, "data_type", ), + ( + "boolean size", + json!({"data":"qvar_define","variable":"q","size":true}), + false, + "size", + ), + ( + "fractional size", + json!({"data":"qvar_define","variable":"q","size":2.0}), + false, + "size", + ), ( "negative size", json!({"data":"qvar_define","variable":"q","size":-1}), @@ -82,6 +95,7 @@ fn declaration_verdicts() { ] { let input = program(std::slice::from_ref(&declaration)); let mut interpreter = PhirClassicalInterpreter::new(); + let engine = PhirJsonEngine::from_json(&input); let verdicts = [ ( "converter", @@ -104,9 +118,7 @@ fn declaration_verdicts() { ), ( "engine", - PhirJsonEngine::from_json(&input) - .map(|_| ()) - .map_err(|e| e.to_string()), + engine.as_ref().map(|_| ()).map_err(ToString::to_string), ), ]; for (entry, result) in verdicts { @@ -125,6 +137,12 @@ fn declaration_verdicts() { if accepted { let expected = declaration["size"].as_u64().unwrap(); assert_eq!(interpreter.num_qubits(), usize::try_from(expected).unwrap()); + let engine = engine.unwrap(); + assert_eq!(engine.num_qubits(), usize::try_from(expected).unwrap()); + assert_eq!( + engine.processor.environment.resolve_qubit("q", 0).unwrap(), + 0 + ); let ast: PHIRProgram = serde_json::from_str(&input).unwrap(); assert!( matches!(&ast.ops[0], Operation::VariableDefinition { data_type, size: Some(size), .. } @@ -144,7 +162,7 @@ fn optional_type_preserves_declaration_order_ids() { let input = program(&[ json!({"data":"qvar_define","variable":"z","size":2}), q, - json!({"qop":"X","args":[["a",1],["z",1]]}), + json!({"qop":"CX","args":[["a",1],["z",1]]}), ]); let module = phir_json_to_module(&input).unwrap(); let gate = module.body.blocks[0] @@ -156,6 +174,21 @@ fn optional_type_preserves_declaration_order_ids() { gate.operands.iter().map(|v| v.id).collect::>(), [3, 1] ); + let mut engine = PhirJsonEngine::from_json(&input).unwrap(); + assert_eq!( + engine.num_qubits(), + 4, + "header must register both quantum declarations" + ); + assert_eq!( + engine.processor.environment.resolve_qubit("a", 1).unwrap(), + 3 + ); + assert_eq!( + engine.processor.environment.resolve_qubit("z", 1).unwrap(), + 1 + ); + assert_eq!(command_qubits(&engine.generate_commands().unwrap()), [3, 1]); let ast: PHIRProgram = serde_json::from_str(&input).unwrap(); let mut executor = BlockExecutor::new(); for op in &ast.ops[..2] { @@ -252,3 +285,83 @@ fn quantum_size_conversion_checks_platform_overflow() { ("q", usize::MAX) ); } + +fn command_qubits(message: &ByteMessage) -> Vec { + message + .quantum_ops() + .unwrap() + .iter() + .flat_map(|gate| gate.qubits.iter().map(|qubit| qubit.0)) + .collect() +} + +#[test] +fn processor_rejects_zero_size() { + let mut processor = OperationProcessor::new(); + let result = processor.handle_variable_definition("qvar_define", "qubits", "q", 0); + assert!( + result.is_err(), + "processor must reject zero size, got {result:?}" + ); + let message = result.unwrap_err().to_string(); + assert!( + message.contains("'q'") && message.contains("positive size"), + "{message}" + ); + assert_eq!(processor.environment.count_qubits(), 0); + assert!(!processor.environment.has_variable("q")); +} + +#[test] +fn declarations_execute_through_blocks_and_engine_commands() { + let input = program(&[ + json!({"data":"qvar_define","variable":"z","size":2}), + json!({"data":"qvar_define","variable":"a","size":2}), + json!({"qop":"CX","args":[["a",1],["z",1]]}), + ]); + let ast: PHIRProgram = serde_json::from_str(&input).unwrap(); + let mut executor = BlockExecutor::new(); + executor.execute_program(&ast.ops).unwrap(); + assert_eq!(executor.processor.environment.count_qubits(), 4); + assert_eq!(command_qubits(&executor.get_builder().build()), [3, 1]); + + let mut engine = PhirJsonEngine::from_json(&input).unwrap(); + // The processor is public; replacing it isolates execution from header registration. + engine.processor = OperationProcessor::new(); + let commands = engine + .generate_commands() + .expect("runtime declarations must register qubits before executing gates"); + assert_eq!(engine.num_qubits(), 4); + assert_eq!(command_qubits(&commands), [3, 1]); + + let mut engine = PhirJsonEngine::from_json(&input).unwrap(); + engine.processor = OperationProcessor::new(); + engine.processor.add_quantum_variable("z", 3).unwrap(); + let error = engine + .generate_commands() + .map(|_| ()) + .unwrap_err() + .to_string(); + assert!( + error.contains("Conflicting definition for variable 'z'"), + "{error}" + ); +} + +#[test] +fn empty_quantum_circuit_round_trip() { + // The Python producer test checks its entire output against this same fixture. + let input = include_str!("fixtures/empty_quantum_circuit.phir.json"); + let module = phir_json_to_module(input).unwrap(); + assert!(module.body.blocks[0].operations.is_empty()); + let ast: PHIRProgram = serde_json::from_str(input).unwrap(); + assert!(ast.ops.is_empty()); + let mut interpreter = PhirClassicalInterpreter::new(); + assert_eq!(interpreter.init(input, None).unwrap(), 0); + assert!(interpreter.execute_program().unwrap().is_empty()); + let mut engine = PhirJsonEngine::from_json(input).unwrap(); + assert_eq!(engine.num_qubits(), 0); + assert!(command_qubits(&engine.generate_commands().unwrap()).is_empty()); + let engine = PhirJsonEngine::from_program(ast).unwrap(); + assert_eq!(engine.num_qubits(), 0); +} diff --git a/crates/pecos-qasm/src/qasm_to_phir_json.rs b/crates/pecos-qasm/src/qasm_to_phir_json.rs index 900cc00bb..6fd4c3d03 100644 --- a/crates/pecos-qasm/src/qasm_to_phir_json.rs +++ b/crates/pecos-qasm/src/qasm_to_phir_json.rs @@ -60,6 +60,9 @@ pub fn program_to_phir_json(program: &Program) -> Result { // 1) Quantum register definitions for (name, qubit_ids) in &program.quantum_registers { + if qubit_ids.is_empty() { + continue; + } ops.push(json!({ "data": "qvar_define", "data_type": "qubits", @@ -74,6 +77,9 @@ pub fn program_to_phir_json(program: &Program) -> Result { // a signed register would need size + 1 <= N and could not represent 64 // bits at all). for (name, size) in &program.classical_registers { + if *size == 0 { + continue; + } let dtype = classical_register_dtype(*size)?; ops.push(json!({ "data": "cvar_define", @@ -91,8 +97,9 @@ pub fn program_to_phir_json(program: &Program) -> Result { // 4) Export all classical variables let cvar_names: Vec<&str> = program .classical_registers - .keys() - .map(String::as_str) + .iter() + .filter(|(_, size)| **size > 0) + .map(|(name, _)| name.as_str()) .collect(); if !cvar_names.is_empty() { ops.push(json!({ @@ -512,6 +519,19 @@ mod tests { assert!(phir["ops"].is_array()); } + #[test] + fn empty_registers_are_not_declared_or_exported() { + let mut program = Program::default(); + program.quantum_registers.insert("empty_q".into(), vec![]); + program.classical_registers.insert("empty_c".into(), 0); + assert_eq!(program_to_phir_json(&program).unwrap()["ops"], json!([])); + program.quantum_registers.insert("q".into(), vec![0]); + program.classical_registers.insert("c".into(), 1); + let phir = program_to_phir_json(&program).unwrap(); + assert_eq!(phir["ops"].as_array().unwrap().len(), 3); + assert_eq!(phir["ops"][2]["variables"], json!(["c"])); + } + #[test] fn register_definitions() { let phir = convert( diff --git a/exp/zlup/src/codegen/phir.rs b/exp/zlup/src/codegen/phir.rs index 1cd6190fa..a146c2a5d 100644 --- a/exp/zlup/src/codegen/phir.rs +++ b/exp/zlup/src/codegen/phir.rs @@ -719,7 +719,7 @@ impl PhirJsonCodegen { } // Add quantum variable definitions - for alloc in self.allocators.values() { + for alloc in self.allocators.values().filter(|alloc| alloc.capacity > 0) { phir.ops .push(PhirJsonOp::QvarDefine(PhirJsonQvarDefine::new( &alloc.name, @@ -773,7 +773,7 @@ impl PhirJsonCodegen { let mut phir = PhirJsonProgram::new().with_name(&fn_decl.name); // Add definitions - for alloc in self.allocators.values() { + for alloc in self.allocators.values().filter(|alloc| alloc.capacity > 0) { phir.ops .push(PhirJsonOp::QvarDefine(PhirJsonQvarDefine::new( &alloc.name, @@ -1549,6 +1549,22 @@ impl PhirJsonCodegen { mod tests { use super::*; + #[test] + fn empty_quantum_allocator_is_not_declared() { + let ast = crate::parse("pub fn main() -> unit { q := qalloc(0); return unit; }").unwrap(); + assert!(PhirJsonCodegen::new().compile(&ast).unwrap().ops.is_empty()); + let TopLevelDecl::Fn(function) = &ast.declarations[0] else { + panic!("expected main function"); + }; + assert!( + PhirJsonCodegen::new() + .compile_function(function) + .unwrap() + .ops + .is_empty() + ); + } + #[test] fn test_bell_state() { let source = r#" diff --git a/python/quantum-pecos/src/pecos/circuits/qc2phir.py b/python/quantum-pecos/src/pecos/circuits/qc2phir.py index 7c02e4448..ab750baf5 100644 --- a/python/quantum-pecos/src/pecos/circuits/qc2phir.py +++ b/python/quantum-pecos/src/pecos/circuits/qc2phir.py @@ -241,6 +241,8 @@ def ops_buffer_append( ops = prog["ops"] for sym, size in qc.metadata.get("qvar_spec", {}).items(): + if size == 0: + continue ops.append( { "data": "qvar_define", @@ -251,6 +253,8 @@ def ops_buffer_append( ) for sym, size in qc.metadata.get("cvar_spec", {}).items(): + if size == 0: + continue ops.append( { "data": "cvar_define", @@ -386,7 +390,7 @@ def ops_buffer_append( num_qubits = len(qid2qsym) prog["metadata"]["num_qubits"] = num_qubits - if "qvar_spec" not in qc.metadata: + if "qvar_spec" not in qc.metadata and num_qubits > 0: op = { "data": "qvar_define", "data_type": "qubits", diff --git a/python/quantum-pecos/src/pecos/reps/pyphir/pyphir.py b/python/quantum-pecos/src/pecos/reps/pyphir/pyphir.py index 535b35173..f5ca42662 100644 --- a/python/quantum-pecos/src/pecos/reps/pyphir/pyphir.py +++ b/python/quantum-pecos/src/pecos/reps/pyphir/pyphir.py @@ -305,15 +305,20 @@ def from_phir( msg = f"Do not know handle qvar type: {o['data_type']}" raise Exception(msg) + size = o.get("size") + if not isinstance(size, int) or isinstance(size, bool) or size <= 0: + msg = f"Quantum register '{o['variable']}' requires a positive integer size; got {size!r}" + raise ValueError(msg) + qubit_ids = [] - for _i in range(o["size"]): + for _i in range(size): qubit_ids.append(next_qvar_int) next_qvar_int += 1 data = d.QVarDefine( data_type=data_type, variable=o["variable"], - size=o["size"], + size=size, qubit_ids=qubit_ids, metadata=o.get("metadata"), ) diff --git a/python/quantum-pecos/tests/pecos/unit/test_quantum_declarations.py b/python/quantum-pecos/tests/pecos/unit/test_quantum_declarations.py index f31a27719..2a66a7a52 100644 --- a/python/quantum-pecos/tests/pecos/unit/test_quantum_declarations.py +++ b/python/quantum-pecos/tests/pecos/unit/test_quantum_declarations.py @@ -1,8 +1,14 @@ """Quantum declarations retain upstream schema validation and Python semantics.""" +import json +from pathlib import Path + +import pecos import pytest +from pecos.circuits.qc2phir import to_phir_dict from pecos.classical_interpreters.phir_classical_interpreter import PhirClassicalInterpreter from pecos.reps.pyphir import PyPHIR +from pecos.typing import PhirModel @pytest.mark.parametrize("explicit_type", [False, True]) @@ -36,7 +42,10 @@ def test_wrong_quantum_type(data_type: str | None) -> None: reader(program) -@pytest.mark.parametrize("fields", [{}, {"size": 0}, {"size": -1}, {"size": "2"}]) +@pytest.mark.parametrize( + "fields", + [{}, {"size": 0}, {"size": -1}, {"size": True}, {"size": False}, {"size": 2.0}, {"size": "2"}, {"size": None}], +) def test_quantum_size_schema_remains_enforced(fields: dict) -> None: """The full upstream schema still rejects missing, zero and malformed sizes.""" program = { @@ -44,6 +53,8 @@ def test_quantum_size_schema_remains_enforced(fields: dict) -> None: "version": "0.1.0", "ops": [{"data": "qvar_define", "variable": "q", **fields}], } + with pytest.raises(ValueError, match="Quantum register 'q' requires a positive integer size"): + PyPHIR.from_phir(program) with pytest.raises(ValueError, match="size"): PhirClassicalInterpreter().init(program) @@ -74,3 +85,28 @@ def test_classical_type_remains_required() -> None: } with pytest.raises(ValueError, match="data_type"): PhirClassicalInterpreter().init(program) + + +@pytest.mark.parametrize( + "metadata", + [{}, {"qvar_spec": {"q": 0}}, {"cvar_spec": {"c": 0}}, {"qvar_spec": {"q": 0}, "cvar_spec": {"c": 0}}], +) +def test_empty_circuit_round_trip(metadata: dict, monkeypatch: pytest.MonkeyPatch) -> None: + """The exact emitted document is also consumed by all four Rust entry points.""" + monkeypatch.setattr(pecos, "__version__", "fixture") + generated = to_phir_dict(pecos.QuantumCircuit(**metadata)) + fixture = ( + Path(__file__).resolve().parents[5] / "crates/pecos-phir-json/tests/fixtures/empty_quantum_circuit.phir.json" + ) + assert generated == json.loads(fixture.read_text()) + PhirModel.model_validate(generated) + assert PyPHIR.from_phir(generated).num_qubits == 0 + assert PhirClassicalInterpreter().init(generated) == 0 + + +def test_empty_registers_do_not_remove_nonempty_registers() -> None: + """Omission is limited to empty registers, including explicit classical sizes.""" + generated = to_phir_dict(pecos.QuantumCircuit(qvar_spec={"empty_q": 0, "q": 2}, cvar_spec={"empty_c": 0, "c": 2})) + assert [op["variable"] for op in generated["ops"]] == ["q", "c"] + PhirModel.model_validate(generated) + assert PhirClassicalInterpreter().init(generated) == 2