From 93b15fecbb3d3d2b037a5010a8163e884dbb0f8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Fri, 16 Oct 2026 16:03:37 +0800 Subject: [PATCH 01/16] fix(circuit): preserve CircuitGate qubit storage order --- .../tests/gates/test_circuit_gate.py | 60 ++++++ .../src/circuit/circuit_to_matrix.rs | 5 +- .../src/circuit/circuit_to_matrix_test.rs | 70 +++++++ .../src/circuit/gate/circuit_gate.rs | 26 ++- .../src/circuit/symbolic_matrix/gate.rs | 5 +- .../src/circuit/symbolic_matrix/gate_test.rs | 196 ++++++++++++++++++ 6 files changed, 358 insertions(+), 4 deletions(-) diff --git a/crates/binding-python/tests/gates/test_circuit_gate.py b/crates/binding-python/tests/gates/test_circuit_gate.py index a18d8416..4248f17e 100644 --- a/crates/binding-python/tests/gates/test_circuit_gate.py +++ b/crates/binding-python/tests/gates/test_circuit_gate.py @@ -21,11 +21,71 @@ """ import pytest +import numpy as np from cqlib.circuit import Circuit, Parameter from cqlib.circuit.gates import CircuitGate +class TestCircuitGateStorageOrder: + @pytest.mark.parametrize("order", [[7, 2], [1, 0], [2, 7], [0, 1]]) + @pytest.mark.parametrize("targets", [[0, 1], [1, 0]]) + def test_matrix_uses_internal_storage_order(self, order, targets): + inner = Circuit(order) + inner.x(order[0]) + outer = Circuit(2) + outer.append_circuit_gate(inner.to_gate("StoredOrderX"), targets) + expected = Circuit(2) + expected.x(targets[0]) + for output_order in (None, [1, 0]): + reference = expected.to_matrix(output_order) + np.testing.assert_allclose( + outer.to_matrix(output_order), reference, rtol=0, atol=1e-10 + ) + np.testing.assert_allclose( + outer.to_symbolic_matrix(output_order).evaluate(), + reference, + rtol=0, + atol=1e-10, + ) + np.testing.assert_allclose( + outer.decompose().to_matrix(output_order), reference, rtol=0, atol=1e-10 + ) + assert outer.to_matrix()[1 << targets[0], 0] == 1 + + @pytest.mark.parametrize("middle_order", [None, [0, 1], [5, 1]]) + @pytest.mark.parametrize("inverse", [False, True]) + def test_nested_parameterized_matrix_and_inverse(self, middle_order, inverse): + theta = Parameter("theta") + body = Circuit([7, 2]) + body.rx(7, theta) + body.cx(7, 2) + if middle_order is not None: + gate = body.to_gate("Inner") + body = Circuit(middle_order) + body.append_circuit_gate(gate, middle_order, [theta]) + outer = Circuit(2) + outer.append_circuit_gate(body.to_gate("Body"), [0, 1], [Parameter("x")]) + if inverse: + outer = outer.inverse() + symbolic = outer.to_symbolic_matrix() + for angle in (0.37, -0.61): + expected = Circuit(2) + expected.rx(0, angle) + expected.cx(0, 1) + if inverse: + expected = expected.inverse() + reference = expected.to_matrix() + bound = outer.assign_parameters({"x": angle}) + np.testing.assert_allclose(bound.to_matrix(), reference, rtol=0, atol=1e-10) + np.testing.assert_allclose( + symbolic.evaluate({"x": angle}), reference, rtol=0, atol=1e-10 + ) + np.testing.assert_allclose( + bound.decompose().to_matrix(), reference, rtol=0, atol=1e-10 + ) + + class TestCircuitGateCreation: """Test CircuitGate creation from circuits""" diff --git a/crates/cqlib-core/src/circuit/circuit_to_matrix.rs b/crates/cqlib-core/src/circuit/circuit_to_matrix.rs index 9dc5a5d4..ead74ef5 100644 --- a/crates/cqlib-core/src/circuit/circuit_to_matrix.rs +++ b/crates/cqlib-core/src/circuit/circuit_to_matrix.rs @@ -213,7 +213,10 @@ pub fn circuit_to_matrix( .circuit .assign_parameters(&Some(bindings)) .map_err(|_| CircuitError::SymbolicParameterError)?; - let sub_matrix = circuit_to_matrix(&sub_circuit, None)?; + // Gate arguments map positionally to the inner storage order, + // which can differ from the default numeric matrix order. + let order: Vec = sub_circuit.qubits().iter().map(|q| q.index()).collect(); + let sub_matrix = circuit_to_matrix(&sub_circuit, Some(&order))?; apply_gate_to_matrix(&mut matrix, &sub_matrix, &bits)?; } Instruction::ClassicalControl(_) => { diff --git a/crates/cqlib-core/src/circuit/circuit_to_matrix_test.rs b/crates/cqlib-core/src/circuit/circuit_to_matrix_test.rs index 86754c4d..7fb12895 100644 --- a/crates/cqlib-core/src/circuit/circuit_to_matrix_test.rs +++ b/crates/cqlib-core/src/circuit/circuit_to_matrix_test.rs @@ -419,6 +419,76 @@ fn test_unitary_gate_two_qubit() { assert!((matrix[[1, 2]] - c(1.0, 0.0)).norm() < 1e-10); } +#[test] +fn test_circuit_gate_storage_order_mapping() { + for order in [[7, 2], [1, 0], [2, 7], [0, 1]] { + let mut inner = Circuit::from_qubits(order.map(Qubit::new).to_vec()).unwrap(); + inner.x(Qubit::new(order[0])).unwrap(); + let gate = inner.to_gate("StoredOrderX").unwrap(); + for targets in [[0, 1], [1, 0]] { + let mut circuit = Circuit::new(2); + circuit + .append(gate.clone(), targets.map(Qubit::new), [], None) + .unwrap(); + let mut expected = Circuit::new(2); + expected.x(Qubit::new(targets[0])).unwrap(); + let matrix = circuit_to_matrix(&circuit, None).unwrap(); + let expected = circuit_to_matrix(&expected, None).unwrap(); + assert_matrix_approx_eq(&matrix, &expected, 1e-10); + assert_eq!(matrix[[1 << targets[0], 0]], c(1.0, 0.0)); + assert_matrix_approx_eq( + &matrix, + &circuit_to_matrix(&circuit.decompose().unwrap(), None).unwrap(), + 1e-10, + ); + } + } +} + +#[test] +fn test_circuit_gate_storage_order_three_qubit_permutations() { + let permutations = [ + [0, 1, 2], + [0, 2, 1], + [1, 0, 2], + [1, 2, 0], + [2, 0, 1], + [2, 1, 0], + ]; + let inner_ids = [2, 7, 11]; + let outer_ids = [4, 9, 15]; + for permutation in permutations { + let order = permutation.map(|i| Qubit::new(inner_ids[i])); + let mut inner = Circuit::from_qubits(order.to_vec()).unwrap(); + inner.x(order[0]).unwrap(); + inner.ry(order[1], 0.37).unwrap(); + inner.cx(order[0], order[2]).unwrap(); + let gate = inner.to_gate("Permuted").unwrap(); + for target_permutation in permutations { + let targets = target_permutation.map(|i| Qubit::new(outer_ids[i])); + let mut circuit = Circuit::from_qubits(outer_ids.map(Qubit::new).to_vec()).unwrap(); + circuit.append(gate.clone(), targets, [], None).unwrap(); + let mut expected = Circuit::from_qubits(outer_ids.map(Qubit::new).to_vec()).unwrap(); + expected.x(targets[0]).unwrap(); + expected.ry(targets[1], 0.37).unwrap(); + expected.cx(targets[0], targets[2]).unwrap(); + for output_order in [None, Some([15, 4, 9].as_slice())] { + let matrix = circuit_to_matrix(&circuit, output_order).unwrap(); + assert_matrix_approx_eq( + &matrix, + &circuit_to_matrix(&expected, output_order).unwrap(), + 1e-10, + ); + assert_matrix_approx_eq( + &matrix, + &circuit_to_matrix(&circuit.decompose().unwrap(), output_order).unwrap(), + 1e-10, + ); + } + } + } +} + #[test] fn test_circuit_gate_bell_state() { // Create a Bell state preparation circuit as a gate diff --git a/crates/cqlib-core/src/circuit/gate/circuit_gate.rs b/crates/cqlib-core/src/circuit/gate/circuit_gate.rs index 7cbeca3d..4e935338 100644 --- a/crates/cqlib-core/src/circuit/gate/circuit_gate.rs +++ b/crates/cqlib-core/src/circuit/gate/circuit_gate.rs @@ -46,6 +46,7 @@ pub struct FrozenCircuit { pub(crate) circuit: Circuit, used_symbols: Arc>, symbolic_matrix_cache: Arc>>, + storage_order_symbolic_matrix_cache: Arc>>, } impl FrozenCircuit { @@ -67,6 +68,7 @@ impl FrozenCircuit { circuit, used_symbols, symbolic_matrix_cache: Arc::new(OnceLock::new()), + storage_order_symbolic_matrix_cache: Arc::new(OnceLock::new()), } } @@ -109,6 +111,23 @@ impl FrozenCircuit { let _ = self.symbolic_matrix_cache.set(matrix.clone()); Ok(self.symbolic_matrix_cache.get().cloned().unwrap_or(matrix)) } + + /// CircuitGate's positional basis is distinct from the numeric-sorted + /// default used by the public FrozenCircuit matrix and UnitaryGate. + fn symbolic_matrix_in_storage_order(&self) -> Result, CircuitError> { + if let Some(matrix) = self.storage_order_symbolic_matrix_cache.get() { + return Ok(matrix.clone()); + } + + let order: Vec = self.circuit.qubits().iter().map(|q| q.index()).collect(); + let matrix = Arc::new(circuit_to_symbolic_matrix(&self.circuit, Some(&order))?); + let _ = self.storage_order_symbolic_matrix_cache.set(matrix.clone()); + Ok(self + .storage_order_symbolic_matrix_cache + .get() + .cloned() + .unwrap_or(matrix)) + } } impl PartialEq for FrozenCircuit { @@ -350,9 +369,12 @@ impl CircuitGate { self.circuit.clone() } - /// Returns the cached symbolic matrix for this gate's frozen circuit. + /// Returns the cached symbolic matrix in this gate's positional basis. + /// + /// The first qubit in the inner circuit's storage order is the + /// least-significant matrix bit, matching the first application argument. pub fn symbolic_matrix(&self) -> Result, CircuitError> { - self.circuit.symbolic_matrix() + self.circuit.symbolic_matrix_in_storage_order() } /// Returns the name of this circuit gate. diff --git a/crates/cqlib-core/src/circuit/symbolic_matrix/gate.rs b/crates/cqlib-core/src/circuit/symbolic_matrix/gate.rs index 3cfb3983..84ff71b3 100644 --- a/crates/cqlib-core/src/circuit/symbolic_matrix/gate.rs +++ b/crates/cqlib-core/src/circuit/symbolic_matrix/gate.rs @@ -546,7 +546,10 @@ pub fn circuit_to_symbolic_matrix( .circuit() .assign_parameters(&Some(bindings)) .map_err(|_| CircuitError::SymbolicParameterError)?; - let sub_matrix = circuit_to_matrix(&sub_circuit, None)?; + // Match CircuitGate argument positions to the inner storage order. + let order: Vec = + sub_circuit.qubits().iter().map(|q| q.index()).collect(); + let sub_matrix = circuit_to_matrix(&sub_circuit, Some(&order))?; push_numeric_gate( &mut matrix, &mut numeric_run, diff --git a/crates/cqlib-core/src/circuit/symbolic_matrix/gate_test.rs b/crates/cqlib-core/src/circuit/symbolic_matrix/gate_test.rs index 7d5d88ca..b0dd392b 100644 --- a/crates/cqlib-core/src/circuit/symbolic_matrix/gate_test.rs +++ b/crates/cqlib-core/src/circuit/symbolic_matrix/gate_test.rs @@ -795,6 +795,202 @@ fn test_substitution_cross_dependency() { assert_matrix_approx_eq(&evaluated, expected.as_ref(), 1e-10); } +#[test] +fn test_circuit_gate_storage_order_constant_symbolic_matrix() { + for order in [[7, 2], [1, 0], [2, 7], [0, 1]] { + let order = order.map(Qubit::new); + let mut inner = Circuit::from_qubits(order.to_vec()).unwrap(); + inner.x(order[0]).unwrap(); + let gate = inner.to_gate("StoredOrderX").unwrap(); + for targets in [[4, 9], [9, 4]] { + let targets = targets.map(Qubit::new); + let mut circuit = Circuit::from_qubits(vec![Qubit::new(9), Qubit::new(4)]).unwrap(); + circuit.append(gate.clone(), targets, [], None).unwrap(); + let mut expected = Circuit::from_qubits(vec![Qubit::new(9), Qubit::new(4)]).unwrap(); + expected.x(targets[0]).unwrap(); + for output_order in [None, Some([9, 4].as_slice())] { + let symbolic = circuit_to_symbolic_matrix(&circuit, output_order).unwrap(); + let evaluated = evaluate_symbolic_matrix(&symbolic, &None).unwrap(); + assert_matrix_approx_eq( + &evaluated, + &circuit_to_matrix(&expected, output_order).unwrap(), + 1e-10, + ); + } + } + } +} + +#[test] +fn test_circuit_gate_storage_order_nested_parameterized_inverse() { + for middle_order in [None, Some([0, 1]), Some([5, 1])] { + let mut body = Circuit::from_qubits(vec![Qubit::new(7), Qubit::new(2)]).unwrap(); + body.rx(Qubit::new(7), Parameter::symbol("theta")).unwrap(); + body.cx(Qubit::new(7), Qubit::new(2)).unwrap(); + if let Some(order) = middle_order { + let gate = body.to_gate("Inner").unwrap(); + body = Circuit::from_qubits(order.map(Qubit::new).to_vec()).unwrap(); + body.append( + gate, + order.map(Qubit::new), + [ParameterValue::from(Parameter::symbol("theta"))], + None, + ) + .unwrap(); + } + let mut outer = Circuit::new(2); + outer + .append( + body.to_gate("Body").unwrap(), + [Qubit::new(0), Qubit::new(1)], + [ParameterValue::from(Parameter::symbol("x"))], + None, + ) + .unwrap(); + for inverse in [false, true] { + let circuit = if inverse { + outer.inverse().unwrap() + } else { + outer.clone() + }; + let symbolic = circuit_to_symbolic_matrix(&circuit, None).unwrap(); + for angle in [0.37, -0.61] { + let bindings = Some(HashMap::from([("x", angle)])); + let bound = circuit.assign_parameters(&bindings).unwrap(); + let mut expected = Circuit::new(2); + expected.rx(Qubit::new(0), angle).unwrap(); + expected.cx(Qubit::new(0), Qubit::new(1)).unwrap(); + if inverse { + expected = expected.inverse().unwrap(); + } + let expected_matrix = circuit_to_matrix(&expected, None).unwrap(); + assert_matrix_approx_eq( + &evaluate_symbolic_matrix(&symbolic, &bindings).unwrap(), + &expected_matrix, + 1e-10, + ); + assert_matrix_approx_eq( + &circuit_to_matrix(&bound, None).unwrap(), + &expected_matrix, + 1e-10, + ); + assert_matrix_approx_eq( + &circuit_to_matrix(&bound.decompose().unwrap(), None).unwrap(), + &expected_matrix, + 1e-10, + ); + } + } + } +} + +#[test] +fn test_circuit_gate_storage_order_cache_isolation() { + use crate::circuit::gate::CircuitGate; + + let mut inner = Circuit::from_qubits(vec![Qubit::new(7), Qubit::new(2)]).unwrap(); + inner.rx(Qubit::new(7), Parameter::symbol("theta")).unwrap(); + inner.cx(Qubit::new(7), Qubit::new(2)).unwrap(); + let frozen = FrozenCircuit::new(inner.clone()); + let gate = CircuitGate::new("Cached", frozen.clone()).unwrap(); + // Materializing either cache first must not determine the other's basis. + for gate_first in [false, true] { + let frozen = if gate_first { + FrozenCircuit::new(inner.clone()) + } else { + frozen.clone() + }; + let gate = if gate_first { + CircuitGate::new("Cached", frozen.clone()).unwrap() + } else { + gate.clone() + }; + if gate_first { + gate.symbolic_matrix().unwrap(); + } else { + frozen.symbolic_matrix().unwrap(); + } + let gate_matrix = gate.symbolic_matrix().unwrap(); + assert!(Arc::ptr_eq( + &gate_matrix, + &gate.clone().symbolic_matrix().unwrap() + )); + assert_eq!(frozen, FrozenCircuit::new(inner.clone())); + let bindings = Some(HashMap::from([("theta", 0.37)])); + let assigned = inner.assign_parameters(&bindings).unwrap(); + assert_matrix_approx_eq( + &evaluate_symbolic_matrix(&gate_matrix, &bindings).unwrap(), + &circuit_to_matrix(&assigned, Some(&[7, 2])).unwrap(), + 1e-10, + ); + let default_matrix = frozen.symbolic_matrix().unwrap(); + assert!(Arc::ptr_eq( + &default_matrix, + &frozen.symbolic_matrix().unwrap() + )); + assert_matrix_approx_eq( + &evaluate_symbolic_matrix(&default_matrix, &bindings).unwrap(), + &circuit_to_matrix(&assigned, None).unwrap(), + 1e-10, + ); + // Circuit-backed UnitaryGate keeps its existing numeric-sorted basis. + let unitary = UnitaryGate::new("Frozen", 2, 1) + .with_circuit(Arc::new(frozen)) + .unwrap(); + let mut unitary_circuit = Circuit::new(2); + unitary_circuit + .append( + Instruction::UnitaryGate(Box::new(unitary)), + [Qubit::new(0), Qubit::new(1)], + [ParameterValue::from(Parameter::symbol("x"))], + None, + ) + .unwrap(); + let mut unitary_expected = Circuit::new(2); + unitary_expected.rx(Qubit::new(1), 0.37).unwrap(); + unitary_expected.cx(Qubit::new(1), Qubit::new(0)).unwrap(); + let bindings = Some(HashMap::from([("x", 0.37)])); + let expected = circuit_to_matrix(&unitary_expected, None).unwrap(); + assert_matrix_approx_eq( + &evaluate_symbolic_matrix( + &circuit_to_symbolic_matrix(&unitary_circuit, None).unwrap(), + &bindings, + ) + .unwrap(), + &expected, + 1e-10, + ); + assert_matrix_approx_eq( + &circuit_to_matrix(&unitary_circuit.assign_parameters(&bindings).unwrap(), None) + .unwrap(), + &expected, + 1e-10, + ); + let mut circuit = Circuit::new(2); + let mut expected = Circuit::new(2); + for (targets, scale) in [([0, 1], 1.0), ([1, 0], 2.0)] { + circuit + .append( + Instruction::CircuitGate(Box::new(gate.clone())), + targets.map(Qubit::new), + [ParameterValue::from(Parameter::symbol("x") * scale)], + None, + ) + .unwrap(); + expected.rx(Qubit::new(targets[0]), 0.37 * scale).unwrap(); + expected + .cx(Qubit::new(targets[0]), Qubit::new(targets[1])) + .unwrap(); + } + let symbolic = circuit_to_symbolic_matrix(&circuit, None).unwrap(); + assert_matrix_approx_eq( + &evaluate_symbolic_matrix(&symbolic, &bindings).unwrap(), + &circuit_to_matrix(&expected, None).unwrap(), + 1e-10, + ); + } +} + #[test] fn test_frozen_circuit_symbolic_matrix_cache_reuses_arc() { let mut inner = Circuit::new(1); From 8870d3303ccc726987a1191ed7e5558a95ef9979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Fri, 16 Oct 2026 16:09:47 +0800 Subject: [PATCH 02/16] fix(python): preserve NumPy complex Hamiltonian coefficients --- crates/binding-python/src/qis/hamiltonian.rs | 17 ++- .../tests/qis/test_hamiltonian.py | 103 ++++++++++++++++++ 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/crates/binding-python/src/qis/hamiltonian.rs b/crates/binding-python/src/qis/hamiltonian.rs index afae3ee4..39d21b75 100644 --- a/crates/binding-python/src/qis/hamiltonian.rs +++ b/crates/binding-python/src/qis/hamiltonian.rs @@ -76,16 +76,6 @@ impl fmt::Display for PyHamiltonian { /// - complex -> complex number /// - tuple (real, imag) -> complex number fn extract_complex(value: &Bound<'_, PyAny>) -> PyResult { - // Try to extract as float first - if let Ok(val) = value.extract::() { - return Ok(Complex64::new(val, 0.0)); - } - // Try to extract as Python complex - if let Ok(py_complex) = value.cast::() { - return Ok(Complex64::new(py_complex.real(), py_complex.imag())); - } - - // Try to extract as tuple (real, imag) if let Ok(tuple) = value.cast::() && tuple.len() == 2 { @@ -94,6 +84,13 @@ fn extract_complex(value: &Bound<'_, PyAny>) -> PyResult { return Ok(Complex64::new(real, imag)); } + // Use the complex protocol before real conversion: NumPy complex scalars + // permit float extraction, but discard the imaginary part with a warning. + // Propagate conversion errors instead of falling back to a lossy float cast. + if let Some(coefficient) = super::fermion::scalar_operand(value)? { + return Ok(coefficient); + } + Err(PyValueError::new_err( "Coefficient must be a float, int, complex, or tuple (real, imag)", )) diff --git a/crates/binding-python/tests/qis/test_hamiltonian.py b/crates/binding-python/tests/qis/test_hamiltonian.py index 1abcf710..e57894ea 100644 --- a/crates/binding-python/tests/qis/test_hamiltonian.py +++ b/crates/binding-python/tests/qis/test_hamiltonian.py @@ -12,10 +12,113 @@ import pytest import math +import warnings import numpy as np from cqlib.qis import Hamiltonian, PauliString, Phase, TrotterMode +@pytest.fixture(params=["from_list", "add_term", "scale"]) +def hamiltonian_with_coefficient(request): + def make(pauli, coefficient): + if request.param == "from_list": + return Hamiltonian.from_list([(pauli, coefficient)]) + if request.param == "add_term": + h = Hamiltonian(1) + assert h.add_term(pauli, coefficient) is None + else: + h = Hamiltonian.from_pauli(pauli) + assert h.scale(coefficient) is None + return h + + return make + + +@pytest.mark.parametrize("scalar_type", [complex, np.complex128, np.complex64]) +@pytest.mark.parametrize( + "label, coefficient", [("iX", -1j), ("X", 1.25 - 2.5j), ("X", 2 + 0j)] +) +def test_hamiltonian_complex_scalar_coefficients( + hamiltonian_with_coefficient, scalar_type, label, coefficient +): + # In particular, (-i) * (iX) must be X rather than the zero operator. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + h = hamiltonian_with_coefficient( + PauliString.from_str(label), scalar_type(coefficient) + ) + assert h.terms[0][1] == coefficient + assert not caught + phase = 1j if label == "iX" else 1 + expected = np.array([[0, coefficient * phase], [coefficient * phase, 0]]) + np.testing.assert_array_equal(h.to_matrix(), expected) + + +@pytest.mark.parametrize( + "coefficient, expected", + [ + (2, 2), + (2.5, 2.5), + (np.int64(2), 2), + (np.float32(2.5), 2.5), + (np.float64(2.5), 2.5), + ((1.25, -2.5), 1.25 - 2.5j), + ((np.float32(1.25), np.float64(-2.5)), 1.25 - 2.5j), + ], +) +def test_hamiltonian_real_and_tuple_coefficients( + hamiltonian_with_coefficient, coefficient, expected +): + with warnings.catch_warnings(): + warnings.simplefilter("error") + h = hamiltonian_with_coefficient(PauliString.from_str("X"), coefficient) + assert h.terms[0][1] == expected + np.testing.assert_array_equal(h.to_matrix(), [[0, expected], [expected, 0]]) + + +@pytest.mark.parametrize("coefficient", ["1+2j", object(), (), (1,), (1, 2, 3)]) +def test_hamiltonian_rejects_invalid_coefficient( + hamiltonian_with_coefficient, coefficient +): + with pytest.raises(ValueError, match="Coefficient must be"): + hamiltonian_with_coefficient(PauliString.from_str("X"), coefficient) + + +class _BrokenComplexCoefficient: + def __complex__(self): + raise RuntimeError("complex conversion failed") + + def __float__(self): + return 0.0 # A failed complex conversion must not fall back to this. + + +def test_hamiltonian_propagates_complex_conversion_error(hamiltonian_with_coefficient): + with pytest.raises(RuntimeError, match="complex conversion failed"): + hamiltonian_with_coefficient( + PauliString.from_str("X"), _BrokenComplexCoefficient() + ) + + +@pytest.mark.parametrize("method", ["add_term", "scale"]) +@pytest.mark.parametrize( + "coefficient, error", + [ + (object(), ValueError), + (("invalid", 1), TypeError), + (_BrokenComplexCoefficient(), RuntimeError), + ], +) +def test_hamiltonian_coefficient_error_preserves_state(method, coefficient, error): + h = Hamiltonian.from_list([(PauliString.from_str("Z"), 2)]) + before = h.copy() + with pytest.raises(error): + if method == "add_term": + h.add_term(PauliString.from_str("X"), coefficient) + else: + h.scale(coefficient) + assert h == before + np.testing.assert_array_equal(h.to_matrix(), [[2, 0], [0, -2]]) + + def test_checked_sparse_algebra_and_legacy_compatibility(): x = Hamiltonian.from_terms(1, [(PauliString.from_str("X"), 1)]) z = Hamiltonian.from_pauli(PauliString.from_str("Z")) From b0116c591ad861fc1736d100028aedccb99b64b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Fri, 16 Oct 2026 17:06:31 +0800 Subject: [PATCH 03/16] fix(core): substitute parameters simultaneously and inside polynomials --- crates/binding-c/src/circuit/parameter.rs | 3 +- crates/binding-c/tests/parameter_test.rs | 57 ++++ .../cqlib/circuit/parameter.pyi | 5 +- .../binding-python/src/circuit/parameter.rs | 3 +- .../tests/parameter/test_substitution.py | 107 +++++++ crates/cqlib-core/src/circuit/parameter.rs | 113 +++++++- .../cqlib-core/src/circuit/parameter_test.rs | 262 ++++++++++++++++++ .../src/compile/knowledge/matcher_test.rs | 50 ++++ 8 files changed, 582 insertions(+), 18 deletions(-) create mode 100644 crates/binding-python/tests/parameter/test_substitution.py diff --git a/crates/binding-c/src/circuit/parameter.rs b/crates/binding-c/src/circuit/parameter.rs index 2f9dfa0e..8ae00dfd 100644 --- a/crates/binding-c/src/circuit/parameter.rs +++ b/crates/binding-c/src/circuit/parameter.rs @@ -445,7 +445,8 @@ pub extern "C" fn param_replace( box_param(unsafe { (*a).inner.replace(symbol, replacement) }) } -/// Substitute several symbols in one call and simplify the result. +/// Simultaneously substitute symbols and simplify the result. Replacement +/// expressions are not substituted again; the original parameter is unchanged. /// `names` and `values` are parallel arrays of length `len`. Returns a new /// parameter, or NULL on NULL input, non-UTF-8 names or NULL elements. #[unsafe(no_mangle)] diff --git a/crates/binding-c/tests/parameter_test.rs b/crates/binding-c/tests/parameter_test.rs index 7afb1e84..87dc059d 100644 --- a/crates/binding-c/tests/parameter_test.rs +++ b/crates/binding-c/tests/parameter_test.rs @@ -22,6 +22,63 @@ fn eval(ptr: *const CParameter, bindings: &str) -> f64 { param_evaluate(ptr, cstr(bindings).as_ptr()) } +#[test] +fn param_substitution_swaps_symbols_simultaneously() { + let x = param_symbol(cstr("x").as_ptr()); + let y = param_symbol(cstr("y").as_ptr()); + let expression = param_parse(cstr("x + 2*y").as_ptr()); + let name_x = cstr("x"); + let name_y = cstr("y"); + for (names, values) in [ + ( + [name_x.as_ptr(), name_y.as_ptr()], + [y.cast_const(), x.cast_const()], + ), + ( + [name_y.as_ptr(), name_x.as_ptr()], + [x.cast_const(), y.cast_const()], + ), + ] { + for _ in 0..100 { + let result = param_substitute_many(expression, names.as_ptr(), values.as_ptr(), 2); + assert!(!result.is_null()); + let value = eval(result, "x:1,y:3"); + param_free(result); + assert_eq!(value, 5.0); + } + } + assert_eq!(eval(expression, "x:1,y:3"), 7.0); + for ptr in [x, y, expression] { + param_free(ptr); + } +} + +#[test] +fn param_substitution_reaches_polynomial_symbols() { + let x = param_symbol(cstr("x").as_ptr()); + let y = param_symbol(cstr("y").as_ptr()); + let square = param_mul(x, x); + let sum = param_add(square, x); + let one = param_from_double(1.0); + let expression = param_add(sum, one); + let name = cstr("x"); + let replaced = param_replace(expression, name.as_ptr(), y); + let substituted = param_substitute_many( + expression, + [name.as_ptr()].as_ptr(), + [y.cast_const()].as_ptr(), + 1, + ); + for result in [replaced, substituted] { + assert!(!result.is_null()); + assert_eq!(eval(result, "y:2"), 7.0); + param_free(result); + } + for ptr in [x, y, square, sum, one, expression] { + param_free(ptr); + } +} + #[test] fn param_construction_and_constants() { let theta = param_symbol(cstr("theta").as_ptr()); diff --git a/crates/binding-python/cqlib/circuit/parameter.pyi b/crates/binding-python/cqlib/circuit/parameter.pyi index 3e67a27d..fd7a8be4 100644 --- a/crates/binding-python/cqlib/circuit/parameter.pyi +++ b/crates/binding-python/cqlib/circuit/parameter.pyi @@ -123,7 +123,10 @@ class Parameter: ... def substitute(self, bindings: dict[str, Parameter]) -> Parameter: - """Substitute multiple symbols and simplify the resulting expression.""" + """Substitute simultaneously and simplify, without revisiting replacements. + + The original parameter is unchanged. + """ ... def provably_equal(self, other: Parameter, tolerance: float = 1e-12) -> bool: diff --git a/crates/binding-python/src/circuit/parameter.rs b/crates/binding-python/src/circuit/parameter.rs index dae61327..7f11c551 100644 --- a/crates/binding-python/src/circuit/parameter.rs +++ b/crates/binding-python/src/circuit/parameter.rs @@ -228,7 +228,8 @@ impl PyParameter { self.inner.as_symbol() } - /// Substitutes multiple symbols and simplifies the resulting expression. + /// Simultaneously substitutes symbols and simplifies the resulting expression. + /// Inserted expressions are not substituted again; the original is unchanged. fn substitute(&self, bindings: HashMap) -> Self { let bindings = bindings .into_iter() diff --git a/crates/binding-python/tests/parameter/test_substitution.py b/crates/binding-python/tests/parameter/test_substitution.py new file mode 100644 index 00000000..cb650b98 --- /dev/null +++ b/crates/binding-python/tests/parameter/test_substitution.py @@ -0,0 +1,107 @@ +# This code is part of Cqlib. +# +# (C) Copyright China Telecom Quantum Group 2026 +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +import numpy as np +import pytest + +from cqlib import Circuit, Parameter + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_substitute_swaps_symbols_using_the_same_dictionary(reverse): + x, y = Parameter("x"), Parameter("y") + original = x + 2 * y + bindings = dict(reversed([("x", y), ("y", x)])) if reverse else {"x": y, "y": x} + + for _ in range(100): + result = original.substitute(bindings) + assert set(result.symbols) == {"x", "y"} + assert result.evaluate({"x": 1.0, "y": 3.0}) == 5.0 + + assert original.evaluate({"x": 1.0, "y": 3.0}) == 7.0 + assert bindings["x"] == y + assert bindings["y"] == x + + +@pytest.mark.parametrize( + "case", ["chain", "self", "cycle", "multiple_keys", "independent"] +) +def test_substitute_does_not_process_inserted_expressions(case): + x, y, z = (Parameter(name) for name in ("x", "y", "z")) + original, bindings, expected = { + "chain": (x, {"x": y + 1, "y": Parameter(2)}, 11.0), + "self": (x, {"x": x + 1}, 3.0), + "cycle": (x + 2 * y + 3 * z, {"x": y, "y": z, "z": x}, 56.0), + "multiple_keys": (x, {"x": y + z, "y": Parameter(1), "z": Parameter(2)}, 30.0), + "independent": (x + y, {"x": z + 1, "y": Parameter(2)}, 23.0), + }[case] + assert ( + original.substitute(bindings).evaluate({"x": 2.0, "y": 10.0, "z": 20.0}) + == expected + ) + + +@pytest.mark.parametrize("method", ["replace", "substitute"]) +@pytest.mark.parametrize("kind", ["polynomial", "function_polynomial", "nested"]) +def test_substitution_reaches_polynomials_and_nested_functions(method, kind): + x, y = Parameter("x"), Parameter("y") + base = x.sin() + original = { + "polynomial": x * x + x + 1, + "function_polynomial": base * base + 2 * base + 1, + "nested": ((x * x + x + 1) / (x + 2)).cos() + x ** (y + 1), + }[kind] + result = ( + original.replace("x", y) + if method == "replace" + else original.substitute({"x": y}) + ) + assert set(result.symbols) == {"y"} + assert result.evaluate({"y": 0.5}) == pytest.approx( + original.evaluate({"x": 0.5, "y": 0.5}), abs=1e-12 + ) + + +def test_substitute_preserves_unbound_symbols_constants_and_empty_bindings(): + x, y = Parameter("x"), Parameter("y") + original = x + y + for bindings in ({}, {"missing": Parameter(9)}): + result = original.substitute(bindings) + assert set(result.symbols) == {"x", "y"} + assert result.evaluate({"x": 2.0, "y": 3.0}) == 5.0 + partial = original.substitute({"x": Parameter(2)}) + assert partial.symbols == ["y"] + assert partial.evaluate({"y": 3.0}) == 5.0 + assert Parameter(0.25).substitute({"x": y}).evaluate() == 0.25 + + +@pytest.mark.parametrize( + "coefficient", [1e-16, np.nextafter(0.0, 1.0), 1.0 + np.finfo(float).eps] +) +def test_substitution_preserves_affine_coefficients(coefficient): + x, y = Parameter("x"), Parameter("y") + original = x * float(coefficient) + for result in (original.replace("x", y), original.substitute({"x": y})): + assert result.evaluate({"y": 1.0}) == coefficient + + +def test_numeric_circuit_assignment_remains_equivalent(): + x, y = Parameter("x"), Parameter("y") + circuit = Circuit(1) + circuit.rx(0, x + 2 * y) + expected = Circuit(1) + expected.rx(0, 7.0) + for bound in ( + circuit.assign_parameters({"x": 1.0, "y": 3.0}), + circuit.assign_parameters({"x": 1.0}).assign_parameters({"y": 3.0}), + ): + np.testing.assert_allclose(bound.to_matrix(), expected.to_matrix(), atol=1e-12) diff --git a/crates/cqlib-core/src/circuit/parameter.rs b/crates/cqlib-core/src/circuit/parameter.rs index cc497cc9..18bfe78e 100644 --- a/crates/cqlib-core/src/circuit/parameter.rs +++ b/crates/cqlib-core/src/circuit/parameter.rs @@ -42,6 +42,92 @@ use std::ops::{Add, Div, Mul, Neg, Sub}; use std::str::FromStr; use symb_anafis::{Expr, Simplify, parse, symb, visitor::ExprView}; +// The engine's map() treats polynomials as opaque and visits rebuilt nodes. +// Walk the public view instead: it exposes polynomial terms, and replacement +// expressions are returned immediately without being traversed themselves. +fn substitute_parameter_expr<'a>(expr: &Expr, lookup: &impl Fn(&str) -> Option<&'a Expr>) -> Expr { + match expr.view() { + ExprView::Number(_) => expr.clone(), + ExprView::Symbol(name) => lookup(&name).cloned().unwrap_or_else(|| expr.clone()), + ExprView::Sum(children) | ExprView::Product(children) => { + let mapped: Vec<_> = children + .iter() + .map(|child| substitute_parameter_expr(child, lookup)) + .collect(); + if children.iter().zip(&mapped).all(|(a, b)| a.as_ref() == b) { + return expr.clone(); + } + + // Smart sum/product constructors can prune tiny coefficients. + // Construct a template of distinct function calls first, then use + // map() only on that template, whose children have no polynomials. + // Inserted expressions never pass through the smart constructors. + let slots = (0..mapped.len()) + .map(|i| Expr::func("__cqlib_substitution_slot", Expr::number(i as f64))) + .collect(); + let template = if matches!(expr.view(), ExprView::Sum(_)) { + Expr::sum(slots) + } else { + Expr::product(slots) + }; + let slots = match template.view() { + ExprView::Sum(slots) | ExprView::Product(slots) => slots.into_owned(), + _ => return mapped.into_iter().next().unwrap_or(template), + }; + // Match in template order so reconstruction preserves the original + // child order even if construction sorted the placeholder nodes. + let replacements: HashMap<_, _> = slots + .into_iter() + .map(|slot| (*slot).clone()) + .zip(mapped) + .collect(); + template.map(|node| { + replacements + .get(node) + .cloned() + .unwrap_or_else(|| node.clone()) + }) + } + ExprView::Div(lhs, rhs) => { + let a = substitute_parameter_expr(lhs, lookup); + let b = substitute_parameter_expr(rhs, lookup); + if &a == lhs && &b == rhs { + expr.clone() + } else { + div_parameter_expr(a, b) + } + } + ExprView::Pow(base, exponent) => { + let a = substitute_parameter_expr(base, lookup); + let b = substitute_parameter_expr(exponent, lookup); + if &a == base && &b == exponent { + expr.clone() + } else { + Expr::pow_static(a, b) + } + } + ExprView::Function { name, args } => { + let mapped: Vec<_> = args + .iter() + .map(|arg| substitute_parameter_expr(arg, lookup)) + .collect(); + if args.iter().zip(&mapped).all(|(a, b)| a.as_ref() == b) { + expr.clone() + } else { + Expr::func_multi(name, mapped) + } + } + ExprView::Derivative { inner, var, order } => { + let mapped = substitute_parameter_expr(inner, lookup); + if &mapped == inner { + expr.clone() + } else { + Expr::derivative(mapped, var, order) + } + } + } +} + // Integral coefficients in the exactly representable integer range cannot // trigger the engine's near-zero/near-one pruning. Ordinary sums of symbols // therefore avoid coefficient collection and expression rebuilding entirely. @@ -774,6 +860,7 @@ impl Parameter { /// Substitutes every occurrence of `symbol` in this expression with /// `param`, returning the resulting expression. + /// The inserted expression is not visited again; the original is unchanged. /// /// # Examples /// @@ -788,7 +875,9 @@ impl Parameter { pub fn replace(&self, symbol: &str, param: impl Into) -> Self { let p: Self = param.into(); Self { - expr: self.expr.substitute(symbol, &p.expr), + expr: substitute_parameter_expr(&self.expr, &|name| { + (name == symbol).then_some(&p.expr) + }), } } @@ -815,12 +904,13 @@ impl Parameter { .map(|_| symbol) } - /// Substitutes every bound symbol in this expression and simplifies when possible. + /// Simultaneously substitutes bound symbols and simplifies when possible. + /// + /// Replacement expressions are inserted as-is, without applying other + /// bindings to them. The result is independent of binding iteration order, + /// and the original expression is unchanged. pub fn substitute_many(&self, bindings: &HashMap) -> Self { - let mut substituted = self.clone(); - for (symbol, value) in bindings { - substituted = substituted.replace(symbol, value.clone()); - } + let substituted = self.substitute_many_simultaneous(bindings); substituted.simplify().unwrap_or(substituted) } @@ -835,15 +925,8 @@ impl Parameter { if bindings.is_empty() { return self.clone(); } - let replacements = bindings - .iter() - .map(|(symbol, replacement)| (Self::symbol(symbol).expr, &replacement.expr)) - .collect::>(); - Self::new(self.expr.map(|node| { - replacements - .iter() - .find(|(symbol, _)| node == symbol) - .map_or_else(|| node.clone(), |(_, replacement)| (*replacement).clone()) + Self::new(substitute_parameter_expr(&self.expr, &|name| { + bindings.get(name).map(|replacement| &replacement.expr) })) } diff --git a/crates/cqlib-core/src/circuit/parameter_test.rs b/crates/cqlib-core/src/circuit/parameter_test.rs index e26af669..b427a713 100644 --- a/crates/cqlib-core/src/circuit/parameter_test.rs +++ b/crates/cqlib-core/src/circuit/parameter_test.rs @@ -1319,3 +1319,265 @@ fn simultaneous_substitution_does_not_revisit_replacement_expressions() { assert_eq!(substituted.get_symbols(), HashSet::from(["b".to_string()])); } + +#[test] +fn substitution_swaps_symbols_in_either_binding_order() { + let x = Parameter::symbol("x"); + let y = Parameter::symbol("y"); + let expression: Parameter = x.clone() + y.clone() * 2.0; + let values = Some(HashMap::from([("x", 1.0), ("y", 3.0)])); + for pairs in [ + [("x", y.clone()), ("y", x.clone())], + [("y", x.clone()), ("x", y.clone())], + ] { + let bindings = pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect(); + for result in [ + expression.substitute_many(&bindings), + expression.substitute_many_simultaneous(&bindings), + ] { + assert_eq!(result.evaluate(&values).unwrap(), 5.0); + assert_eq!( + result.get_symbols(), + HashSet::from(["x".into(), "y".into()]) + ); + } + } + assert_eq!(expression.evaluate(&values).unwrap(), 7.0); +} + +#[test] +fn substitution_inserts_replacements_without_cascading() { + let x = Parameter::symbol("x"); + let y = Parameter::symbol("y"); + let z = Parameter::symbol("z"); + let values = Some(HashMap::from([("x", 2.0), ("y", 10.0), ("z", 20.0)])); + let cases = [ + ( + x.clone(), + vec![("x", y.clone() + 1.0), ("y", Parameter::from(2.0))], + 11.0, + ), + (x.clone(), vec![("x", x.clone() + 1.0)], 3.0), + ( + x.clone(), + vec![ + ("x", y.clone() + z.clone()), + ("y", Parameter::from(1.0)), + ("z", Parameter::from(2.0)), + ], + 30.0, + ), + ( + x.clone() + y.clone() * 2.0 + z.clone() * 3.0, + vec![("x", y), ("y", z), ("z", x)], + 56.0, + ), + ]; + for (expression, pairs, expected) in cases { + let bindings = pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect(); + for result in [ + expression.substitute_many(&bindings), + expression.substitute_many_simultaneous(&bindings), + ] { + assert_eq!(result.evaluate(&values).unwrap(), expected); + } + } +} + +#[test] +fn substitution_preserves_unbound_symbols_constants_and_noops() { + let expression = Parameter::symbol("x") + Parameter::symbol("y"); + let values = Some(HashMap::from([("x", 2.0), ("y", 3.0), ("z", 4.0)])); + for bindings in [ + HashMap::new(), + HashMap::from([("missing".into(), Parameter::symbol("z"))]), + ] { + assert_eq!( + expression + .substitute_many(&bindings) + .evaluate(&values) + .unwrap(), + 5.0 + ); + assert_eq!( + expression.substitute_many_simultaneous(&bindings), + expression + ); + } + let bindings = HashMap::from([ + ("x".into(), Parameter::symbol("z") + 1.0), + ("y".into(), Parameter::from(2.0)), + ]); + assert_eq!( + expression + .substitute_many(&bindings) + .evaluate(&values) + .unwrap(), + 7.0 + ); + assert_eq!( + Parameter::from(0.25).substitute_many(&bindings), + Parameter::from(0.25) + ); + assert_eq!( + expression.replace("missing", Parameter::from(0.0)), + expression + ); +} + +#[test] +fn substitution_reaches_polynomial_bases_and_nested_operations() { + let x = Parameter::symbol("x"); + let y = Parameter::symbol("y"); + let values = Some(HashMap::from([("y", 0.5)])); + let natural_polynomial: Parameter = x.clone() * x.clone() + x.clone() + 1.0; + let base = x.sin(); + let function_polynomial: Parameter = base.clone() * base.clone() + base * 2.0 + 1.0; + let nested = (natural_polynomial.clone() / (x.clone() + Parameter::from(2.0))).cos() + + x.clone().pow(y.clone() + 1.0); + let bindings = HashMap::from([("x".into(), y.clone())]); + for expression in [natural_polynomial, function_polynomial, nested] { + let expected = expression + .evaluate(&Some(HashMap::from([("x", 0.5), ("y", 0.5)]))) + .unwrap(); + for result in [ + expression.replace("x", y.clone()), + expression.substitute_many(&bindings), + expression.substitute_many_simultaneous(&bindings), + ] { + assert_eq!(result.get_symbols(), HashSet::from(["y".into()])); + assert!((result.evaluate(&values).unwrap() - expected).abs() < 1e-12); + } + } +} + +#[test] +fn substitution_does_not_revisit_rebuilt_division_or_power() { + let x = Parameter::symbol("x"); + let y = Parameter::symbol("y"); + let bindings = HashMap::from([ + ("x".into(), Parameter::symbol("z")), + ("y".into(), Parameter::from(1.0)), + ("z".into(), Parameter::from(2.0)), + ]); + for expression in [x.clone() / y.clone(), x.pow(y)] { + for result in [ + expression.substitute_many(&bindings), + expression.substitute_many_simultaneous(&bindings), + ] { + assert_eq!(result.get_symbols(), HashSet::from(["z".into()])); + assert_eq!( + result + .evaluate(&Some(HashMap::from([("z", 10.0)]))) + .unwrap(), + 10.0 + ); + } + } +} + +#[test] +fn substitution_preserves_formal_derivative_metadata() { + use symb_anafis::{Expr, visitor::ExprView}; + let expression = Parameter::new(Expr::derivative( + Parameter::symbol("x").sin().into_expr(), + "x", + 2, + )); + let bindings = HashMap::from([("x".into(), Parameter::symbol("y"))]); + for result in [ + expression.replace("x", Parameter::symbol("y")), + expression.substitute_many_simultaneous(&bindings), + ] { + let ExprView::Derivative { inner, var, order } = result.as_expr().view() else { + panic!("expected a formal derivative") + }; + assert_eq!(var, "x"); + assert_eq!(order, 2); + assert_eq!(inner.variables(), HashSet::from(["y".into()])); + } +} + +#[test] +fn substitution_preserves_small_and_near_unit_affine_coefficients() { + let x = Parameter::symbol("x"); + let y = Parameter::symbol("y"); + let bindings = HashMap::from([("x".into(), y.clone())]); + for coefficient in [ + 1e-16, + f64::from_bits(1), + 1.0 + f64::EPSILON, + 1.0 - f64::EPSILON, + ] { + let expression = x.clone() * coefficient; + for result in [ + expression.replace("x", y.clone()), + expression.substitute_many(&bindings), + expression.substitute_many_simultaneous(&bindings), + ] { + assert_eq!( + result.evaluate(&Some(HashMap::from([("y", 1.0)]))).unwrap(), + coefficient + ); + } + let cancelled = expression.clone() - expression; + assert_eq!( + cancelled + .substitute_many(&bindings) + .evaluate(&None) + .unwrap(), + 0.0 + ); + } +} + +#[test] +fn substitution_agrees_with_independent_numeric_composition() { + use rand::{Rng, SeedableRng, rngs::StdRng}; + fn expression(rng: &mut StdRng, depth: u32) -> Parameter { + if depth == 0 { + return Parameter::symbol(["x", "y", "z"][rng.random_range(0..3)]); + } + let lhs = expression(rng, depth - 1); + match rng.random_range(0..5) { + 0 => lhs + expression(rng, depth - 1), + 1 => lhs * expression(rng, depth - 1), + 2 => lhs.sin(), + 3 => lhs.pow(2.0), + _ => lhs / (expression(rng, depth - 1).pow(2.0) + 1.0), + } + } + let mut rng = StdRng::seed_from_u64(0xB03); + for _ in 0..64 { + let original = expression(&mut rng, 3); + let bindings = ["x", "y", "z"] + .into_iter() + .map(|name| (name.to_string(), expression(&mut rng, 1))) + .collect::>(); + let values = HashMap::from([ + ("x", rng.random_range(-0.8..0.8)), + ("y", rng.random_range(-0.8..0.8)), + ("z", rng.random_range(-0.8..0.8)), + ]); + let composed = bindings + .iter() + .map(|(name, replacement)| { + ( + name.as_str(), + replacement.evaluate(&Some(values.clone())).unwrap(), + ) + }) + .collect(); + let expected = original.evaluate(&Some(composed)).unwrap(); + for result in [ + original.substitute_many(&bindings), + original.substitute_many_simultaneous(&bindings), + ] { + let actual = result.evaluate(&Some(values.clone())).unwrap(); + assert!( + (actual - expected).abs() <= 1e-10 * expected.abs().max(1.0), + "{original}: actual={actual}, expected={expected}" + ); + } + } +} diff --git a/crates/cqlib-core/src/compile/knowledge/matcher_test.rs b/crates/cqlib-core/src/compile/knowledge/matcher_test.rs index ead1817e..ee37c6da 100644 --- a/crates/cqlib-core/src/compile/knowledge/matcher_test.rs +++ b/crates/cqlib-core/src/compile/knowledge/matcher_test.rs @@ -319,6 +319,56 @@ fn instantiate_target_rejects_unbound_rewrite_references() { ); } +#[test] +fn instantiate_target_substitutes_overlapping_symbol_names_simultaneously() { + let a = Parameter::symbol("a"); + let b = Parameter::symbol("b"); + let rule = Rule::new( + "overlapping_parameters", + vec![ + RuleItem::standard( + StandardGate::RZ, + &[0], + vec![ParameterValue::Param(a.clone())], + ), + RuleItem::standard( + StandardGate::RZ, + &[0], + vec![ParameterValue::Param(b.clone())], + ), + ], + vec![RuleItem::standard( + StandardGate::RZ, + &[0], + vec![ParameterValue::Param(a.clone() + b.clone() * 2.0)], + )], + ); + let instruction = Instruction::Standard(StandardGate::RZ); + let qubits = [Qubit::new(0)]; + let first = [b]; + let second = [a]; + let operations = [ + ConcreteOperationView::new(&instruction, &qubits, &first), + ConcreteOperationView::new(&instruction, &qubits, &second), + ]; + let bindings = rule_matches_operations(&rule, &operations) + .unwrap() + .unwrap(); + let replacements = instantiate_target(&rule.target, &bindings).unwrap(); + let ParameterValue::Param(result) = &replacements[0].params[0] else { + panic!("expected a symbolic parameter"); + }; + assert_eq!( + result + .evaluate(&Some(std::collections::HashMap::from([ + ("a", 1.0), + ("b", 3.0) + ]))) + .unwrap(), + 5.0 + ); +} + #[test] fn rule_matches_operations_returns_none_for_non_adjacent_policy_cases() { let rule = Rule::new( From 12e36b58be6b2e8e00c774a5fe86fc88aa6477ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Fri, 16 Oct 2026 17:11:33 +0800 Subject: [PATCH 04/16] fix(circuit): accumulate sub-circuit global phase in decompose --- .../tests/circuit/test_decompose.py | 109 ++++++++ crates/cqlib-core/src/circuit/circuit_impl.rs | 22 +- crates/cqlib-core/src/circuit/circuit_test.rs | 253 ++++++++++++++++++ 3 files changed, 382 insertions(+), 2 deletions(-) diff --git a/crates/binding-python/tests/circuit/test_decompose.py b/crates/binding-python/tests/circuit/test_decompose.py index 668edb6f..7833ecd8 100644 --- a/crates/binding-python/tests/circuit/test_decompose.py +++ b/crates/binding-python/tests/circuit/test_decompose.py @@ -13,6 +13,115 @@ """Decomposition tests for the Python circuit API.""" from cqlib import Circuit, Parameter +from cqlib.qis import DensityMatrix, Statevector +import numpy as np +import pytest + + +@pytest.mark.parametrize( + "width,has_operations", [(0, False), (1, False), (1, True), (2, True)] +) +@pytest.mark.parametrize("repetitions", [0, 1, 2]) +def test_decompose_preserves_global_phase_of_each_definition( + width, has_operations, repetitions +): + inner = Circuit(width) + inner.set_global_phase(0.37) + if has_operations: + inner.x(0) + if width == 2: + inner.cx(0, 1) + gate = inner.to_gate("Phased") + outer = Circuit(2) + outer.set_global_phase(0.12) + expected = Circuit(2) + expected.set_global_phase(0.12 + repetitions * 0.37) + for _ in range(repetitions): + outer.append_circuit_gate(gate, [1, 0][:width]) + if has_operations: + expected.x(1) + if width == 2: + expected.cx(1, 0) + flat = outer.decompose() + assert flat.global_phase.evaluate() == pytest.approx(0.12 + repetitions * 0.37) + for candidate in (outer, flat, flat.decompose()): + np.testing.assert_allclose( + candidate.to_matrix(), expected.to_matrix(), rtol=0, atol=1e-10 + ) + assert len(flat) == len(expected) + assert outer.global_phase.evaluate() == 0.12 + reference_state = expected.to_matrix()[:, 0] + np.testing.assert_allclose( + Statevector.from_circuit(outer).data, reference_state, rtol=0, atol=1e-10 + ) + np.testing.assert_allclose( + DensityMatrix.from_circuit(outer).data, + np.outer(reference_state, reference_state.conj()), + rtol=0, + atol=1e-10, + ) + + +@pytest.mark.parametrize("inverse", [False, True]) +def test_decompose_preserves_nested_symbolic_global_phase(inverse): + theta = Parameter("theta") + inner = Circuit([7, 2]) + inner.set_global_phase(theta / 2) + inner.rx(7, theta) + inner.cx(7, 2) + middle = Circuit([5, 1]) + middle.set_global_phase(theta.sin()) + middle.append_circuit_gate(inner.to_gate("Inner"), [5, 1], [theta + 1]) + outer = Circuit(2) + x = Parameter("x") + outer.set_global_phase(x / 3) + outer.append_circuit_gate(middle.to_gate("Middle"), [1, 0], [2 * x]) + if inverse: + outer = outer.inverse() + flat = outer.decompose() + symbolic = flat.to_symbolic_matrix() + for value in (0.37, -0.61): + phase = value / 3 + np.sin(2 * value) + (2 * value + 1) / 2 + expected = Circuit(2) + expected.set_global_phase(phase) + expected.rx(1, 2 * value + 1) + expected.cx(1, 0) + if inverse: + expected = expected.inverse() + phase = -phase + bindings = {"x": value} + reference = expected.to_matrix() + assert flat.global_phase.evaluate(bindings) == pytest.approx(phase) + for candidate in ( + flat.assign_parameters(bindings), + outer.assign_parameters(bindings).decompose(), + outer.assign_parameters(bindings), + ): + np.testing.assert_allclose( + candidate.to_matrix(), reference, rtol=0, atol=1e-10 + ) + np.testing.assert_allclose( + symbolic.evaluate(bindings), reference, rtol=0, atol=1e-10 + ) + + +def test_decompose_keeps_symbols_used_only_by_global_phase(): + inner = Circuit(1) + inner.set_global_phase(Parameter("theta")) + outer = Circuit(1) + outer.set_global_phase(Parameter("phi")) + outer.append_circuit_gate(inner.to_gate("PhaseOnly"), [0], [2 * Parameter("x")]) + flat = outer.decompose() + assert len(flat) == 0 + assert set(flat.used_symbols) == {"phi", "x"} + bindings = {"phi": 0.12, "x": 0.37} + assert flat.global_phase.evaluate(bindings) == pytest.approx(0.86) + np.testing.assert_allclose( + flat.assign_parameters(bindings).to_matrix(), + np.exp(0.86j) * np.eye(2), + rtol=0, + atol=1e-10, + ) def test_decompose_keeps_standard_gates_unchanged(): diff --git a/crates/cqlib-core/src/circuit/circuit_impl.rs b/crates/cqlib-core/src/circuit/circuit_impl.rs index 3cfffaf6..d670328f 100644 --- a/crates/cqlib-core/src/circuit/circuit_impl.rs +++ b/crates/cqlib-core/src/circuit/circuit_impl.rs @@ -2255,6 +2255,8 @@ impl Circuit { /// the result is `Rx(beta+1)`. /// 2. **Qubit Mapping**: Virtual qubits in the sub-circuit definition are mapped to the /// physical qubits in the parent circuit. + /// 3. **Global Phase**: Each sub-circuit's phase is substituted using its call arguments + /// and accumulated into the flattened circuit, including empty definitions. /// /// # Returns /// @@ -2263,8 +2265,9 @@ impl Circuit { /// /// # Errors /// - /// Returns [`CircuitError::UnresolvedParameter`] if a symbolic parameter in a sub-circuit - /// or control flow body cannot be evaluated to a concrete value. + /// Returns an error if a fully resolved parameter or accumulated global phase is invalid, + /// or if the circuit contains unsupported classical control. Unbound symbolic parameters + /// and phases are preserved. pub fn decompose(&self) -> Result { let mut new_circuit = Circuit::from_qubits(self.qubits()).unwrap(); new_circuit.classical_vars = self.classical_vars.clone(); @@ -2367,6 +2370,21 @@ impl Circuit { } } + // Phase is circuit metadata, so it must be accumulated even when + // the definition has no operations. Each gate application adds it once. + if cg.circuit.circuit.global_phase_param() != &CircuitParam::Fixed(0.0) { + let phase = + Self::apply_param_map(cg.circuit.circuit.global_phase(), &next_param_map); + if phase.get_symbols().is_empty() { + phase.evaluate(&None)?; + } + let phase = target_circuit.global_phase() + phase; + if phase.get_symbols().is_empty() { + phase.evaluate(&None)?; + } + target_circuit.set_global_phase(phase); + } + // Qubit Map: Inner Qubit -> Outer Qubit // op.qubits are the qubits in 'context_circuit' that the gate acts on. // We need to map them through 'qubit_map' to get 'target_circuit' qubits. diff --git a/crates/cqlib-core/src/circuit/circuit_test.rs b/crates/cqlib-core/src/circuit/circuit_test.rs index d8f476c8..373ef8dc 100644 --- a/crates/cqlib-core/src/circuit/circuit_test.rs +++ b/crates/cqlib-core/src/circuit/circuit_test.rs @@ -1447,6 +1447,259 @@ fn test_decompose() { } } +#[test] +fn test_decompose_global_phase_constant_and_empty_definitions() { + use crate::circuit::circuit_to_matrix; + use crate::circuit::test_utils::assert_matrix_approx_eq; + + for (width, has_operations) in [(0, false), (1, false), (1, true), (2, true)] { + let mut inner = Circuit::new(width); + inner.set_global_phase(Parameter::from(0.37)); + if has_operations { + inner.x(Qubit::new(0)).unwrap(); + if width == 2 { + inner.cx(Qubit::new(0), Qubit::new(1)).unwrap(); + } + } + let gate = inner.to_gate("Phased").unwrap(); + for repetitions in 0..=2 { + let mut outer = Circuit::new(2); + outer.set_global_phase(Parameter::from(0.12)); + let mut expected = Circuit::new(2); + expected.set_global_phase(Parameter::from(0.12 + repetitions as f64 * 0.37)); + for _ in 0..repetitions { + let targets = [Qubit::new(1), Qubit::new(0)]; + outer + .append(gate.clone(), targets[..width].to_vec(), [], None) + .unwrap(); + if has_operations { + expected.x(Qubit::new(1)).unwrap(); + if width == 2 { + expected.cx(Qubit::new(1), Qubit::new(0)).unwrap(); + } + } + } + let flat = outer.decompose().unwrap(); + assert!( + (flat.global_phase().evaluate(&None).unwrap() - (0.12 + repetitions as f64 * 0.37)) + .abs() + < 1e-12 + ); + let expected_matrix = circuit_to_matrix(&expected, None).unwrap(); + assert_matrix_approx_eq( + &circuit_to_matrix(&outer, None).unwrap(), + &expected_matrix, + 1e-10, + ); + assert_matrix_approx_eq( + &circuit_to_matrix(&flat, None).unwrap(), + &expected_matrix, + 1e-10, + ); + assert_matrix_approx_eq( + &circuit_to_matrix(&flat.decompose().unwrap(), None).unwrap(), + &expected_matrix, + 1e-10, + ); + assert_eq!(flat.operations().len(), expected.operations().len()); + assert_eq!(outer.global_phase().evaluate(&None).unwrap(), 0.12); + } + } +} + +#[test] +fn test_decompose_global_phase_only_symbols_and_explicit_signature() { + use crate::circuit::circuit_to_matrix; + use crate::circuit::test_utils::assert_matrix_approx_eq; + use std::collections::HashMap; + + let mut inner = Circuit::new(1); + inner.set_global_phase(Parameter::symbol("theta") / 2.0); + let gate = CircuitGate::with_signature( + "PhaseOnly", + FrozenCircuit::new(inner), + ["unused".to_string(), "theta".to_string()], + ) + .unwrap(); + let mut outer = Circuit::new(1); + outer.set_global_phase(Parameter::symbol("phi") / 3.0); + outer + .circuit_gate( + gate, + vec![Qubit::new(0)], + [ + ParameterValue::Fixed(0.7), + ParameterValue::from(Parameter::symbol("x") * 2.0), + ], + ) + .unwrap(); + let flat = outer.decompose().unwrap(); + assert!(flat.operations().is_empty()); + assert_eq!( + flat.used_symbols().iter().cloned().collect::>(), + HashSet::from(["phi".to_string(), "x".to_string()]) + ); + for (x, phi) in [(0.37, -0.23), (-0.61, 0.44)] { + let bindings = Some(HashMap::from([("x", x), ("phi", phi)])); + let mut expected = Circuit::new(1); + expected.set_global_phase(Parameter::from(phi / 3.0 + x)); + let expected_matrix = circuit_to_matrix(&expected, None).unwrap(); + assert_matrix_approx_eq( + &circuit_to_matrix(&flat.assign_parameters(&bindings).unwrap(), None).unwrap(), + &expected_matrix, + 1e-10, + ); + assert_matrix_approx_eq( + &circuit_to_matrix( + &outer + .assign_parameters(&bindings) + .unwrap() + .decompose() + .unwrap(), + None, + ) + .unwrap(), + &expected_matrix, + 1e-10, + ); + } +} + +#[test] +fn test_decompose_global_phase_nested_symbolic_and_inverse() { + use crate::circuit::circuit_to_matrix; + use crate::circuit::symbolic_matrix::{circuit_to_symbolic_matrix, evaluate_symbolic_matrix}; + use crate::circuit::test_utils::assert_matrix_approx_eq; + use std::collections::HashMap; + + let a = Parameter::symbol("a"); + let b = Parameter::symbol("b"); + let mut inner = Circuit::from_qubits(vec![Qubit::new(7), Qubit::new(2)]).unwrap(); + inner.set_global_phase(a.clone() + b.clone() * 2.0); + inner.rx(Qubit::new(7), a.clone() - b.clone()).unwrap(); + inner.cx(Qubit::new(7), Qubit::new(2)).unwrap(); + let inner_gate = CircuitGate::with_signature( + "Inner", + FrozenCircuit::new(inner), + ["a".to_string(), "b".to_string()], + ) + .unwrap(); + let mut middle = Circuit::from_qubits(vec![Qubit::new(5), Qubit::new(1)]).unwrap(); + middle.set_global_phase(a.clone() / 2.0); + middle + .circuit_gate( + inner_gate, + vec![Qubit::new(5), Qubit::new(1)], + [ParameterValue::from(b), ParameterValue::from(a)], + ) + .unwrap(); + let middle_gate = CircuitGate::with_signature( + "Middle", + FrozenCircuit::new(middle), + ["a".to_string(), "b".to_string()], + ) + .unwrap(); + let x = Parameter::symbol("x"); + let mut outer = Circuit::new(2); + outer.set_global_phase(x.clone() / 3.0); + outer + .circuit_gate( + middle_gate, + vec![Qubit::new(1), Qubit::new(0)], + [ + ParameterValue::from(x + 1.0), + ParameterValue::from(Parameter::symbol("y")), + ], + ) + .unwrap(); + + for inverse in [false, true] { + let circuit = if inverse { + outer.inverse().unwrap() + } else { + outer.clone() + }; + let flat = circuit.decompose().unwrap(); + assert_eq!( + flat.used_symbols().iter().cloned().collect::>(), + HashSet::from(["x".to_string(), "y".to_string()]) + ); + let symbolic = circuit_to_symbolic_matrix(&flat, None).unwrap(); + for (x, y) in [(0.37, -0.23), (-0.61, 0.44)] { + let bindings = Some(HashMap::from([("x", x), ("y", y)])); + let mut expected = Circuit::new(2); + let expected_phase = x / 3.0 + (x + 1.0) / 2.0 + y + 2.0 * (x + 1.0); + expected.set_global_phase(Parameter::from(expected_phase)); + expected.rx(Qubit::new(1), y - (x + 1.0)).unwrap(); + expected.cx(Qubit::new(1), Qubit::new(0)).unwrap(); + if inverse { + expected = expected.inverse().unwrap(); + } + let expected_matrix = circuit_to_matrix(&expected, None).unwrap(); + assert!( + (flat.global_phase().evaluate(&bindings).unwrap() + - if inverse { + -expected_phase + } else { + expected_phase + }) + .abs() + < 1e-12 + ); + for candidate in [ + flat.assign_parameters(&bindings).unwrap(), + circuit + .assign_parameters(&bindings) + .unwrap() + .decompose() + .unwrap(), + circuit.assign_parameters(&bindings).unwrap(), + ] { + assert_matrix_approx_eq( + &circuit_to_matrix(&candidate, None).unwrap(), + &expected_matrix, + 1e-10, + ); + } + assert_matrix_approx_eq( + &evaluate_symbolic_matrix(&symbolic, &bindings).unwrap(), + &expected_matrix, + 1e-10, + ); + } + } +} + +#[test] +fn test_decompose_global_phase_rejects_invalid_constants() { + let mut inner = Circuit::new(1); + inner.set_global_phase(Parameter::symbol("theta").ln()); + let mut outer = Circuit::new(1); + outer + .append( + inner.to_gate("LogPhase").unwrap(), + [Qubit::new(0)], + [ParameterValue::Fixed(-1.0)], + None, + ) + .unwrap(); + assert!(outer.decompose().is_err()); + + let mut inner = Circuit::new(1); + inner.set_global_phase(Parameter::from(1e308)); + let mut outer = Circuit::new(1); + outer.set_global_phase(Parameter::from(1e308)); + outer + .append( + inner.to_gate("LargePhase").unwrap(), + [Qubit::new(0)], + [], + None, + ) + .unwrap(); + assert!(outer.decompose().is_err()); +} + #[test] fn test_decompose_nested() { let mut l1 = Circuit::new(1); From cda0a1878566858e64b04c72f13abf5dd3f60519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Sat, 17 Oct 2026 12:40:14 +0800 Subject: [PATCH 05/16] fix(qis): make GPhase matrices and simulation consistent Return scalar numeric and symbolic matrices for zero-qubit GPhase gates so controlled matrices match their declared qubit counts. Dispatch controlled gates only after recognizing supported specializations, and route zero-control GPhase through the ordinary phase execution path in the three numerical simulators. Return an unsupported-operation error for controlled GPhase in StabilizerState without indexing a nonexistent target. Add Rust and Python regressions for matrix metadata, symbolic parameters, controlled evolution, noise behavior, zero controls, and stabilizer errors. --- .../binding-python/tests/gates/test_gphase.py | 89 +++++++ .../tests/gates/test_standard.py | 8 +- .../tests/qis/test_controlled_gphase.py | 226 ++++++++++++++++++ .../tests/qis/test_stabilizer.py | 16 ++ .../src/circuit/circuit_to_matrix_test.rs | 68 ++++++ .../src/circuit/gate/gate_matrix.rs | 13 +- .../src/circuit/gate/gate_matrix_test.rs | 41 ++++ .../src/circuit/symbolic_matrix/gate.rs | 5 +- .../src/circuit/symbolic_matrix/gate_test.rs | 111 +++++++++ .../src/qis/state/density_matrix.rs | 47 ++-- .../src/qis/state/density_matrix_noise.rs | 48 ++-- .../qis/state/density_matrix_noise_test.rs | 166 +++++++++++++ .../src/qis/state/density_matrix_test.rs | 120 ++++++++++ crates/cqlib-core/src/qis/state/stabilizer.rs | 22 +- .../src/qis/state/stabilizer_test.rs | 55 +++++ .../cqlib-core/src/qis/state/statevector.rs | 61 ++--- .../src/qis/state/statevector_test.rs | 114 +++++++++ 17 files changed, 1092 insertions(+), 118 deletions(-) create mode 100644 crates/binding-python/tests/gates/test_gphase.py create mode 100644 crates/binding-python/tests/qis/test_controlled_gphase.py diff --git a/crates/binding-python/tests/gates/test_gphase.py b/crates/binding-python/tests/gates/test_gphase.py new file mode 100644 index 00000000..2e9e8df8 --- /dev/null +++ b/crates/binding-python/tests/gates/test_gphase.py @@ -0,0 +1,89 @@ +# This code is part of Cqlib. +# +# (C) Copyright China Telecom Quantum Group 2026 +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Zero-qubit phase and controlled-phase matrix contracts.""" + +import numpy as np +import pytest + +from cqlib import Circuit, Parameter +from cqlib.circuit.gates import GPhase + + +@pytest.mark.parametrize("theta", [0.0, 0.37, -0.51, np.pi]) +@pytest.mark.parametrize("controls", [None, 0, 1, 2]) +def test_gphase_matrix_metadata_and_inverse(theta, controls): + base = GPhase(theta) + gate = base if controls is None else base.control(controls) + count = controls or 0 + assert gate.num_qubits == count + assert gate.num_ctrl_qubits == count + assert gate.num_params == 1 + expected = np.eye(1 << count, dtype=complex) + expected[-1, -1] = np.exp(1j * theta) + actual = gate.matrix() + assert actual.shape == expected.shape + np.testing.assert_allclose(actual, expected, rtol=0, atol=1e-12) + inverse = gate.inverse().matrix() + assert inverse.shape == expected.shape + np.testing.assert_allclose(inverse, expected.conj().T, rtol=0, atol=1e-12) + + +@pytest.mark.parametrize("num_qubits", [0, 1, 3]) +@pytest.mark.parametrize("symbolic", [False, True]) +def test_gphase_markers_accumulate_in_circuit_matrices(num_qubits, symbolic): + circuit = Circuit(num_qubits) + if num_qubits: + circuit.h(0) + baseline = circuit.to_matrix() + circuit.append_gate(GPhase(-0.21), []) + circuit.append_gate(GPhase(Parameter("theta") if symbolic else 0.37), []) + circuit.append_gate(GPhase(0.12), []) + circuit.set_global_phase(0.17) + expected = np.exp(1j * (-0.21 + 0.37 + 0.12 + 0.17)) * baseline + if not symbolic: + actual = circuit.to_matrix() + assert actual.shape == expected.shape + np.testing.assert_allclose(actual, expected, rtol=0, atol=1e-12) + expression = circuit.to_symbolic_matrix() + assert expression.shape == expected.shape + np.testing.assert_allclose( + expression.evaluate({"theta": 0.37}), expected, rtol=0, atol=1e-12 + ) + + +@pytest.mark.parametrize("controls", [[], [2], [2, 0]]) +@pytest.mark.parametrize("order", [[0, 1, 2], [2, 0, 1]]) +@pytest.mark.parametrize("symbolic", [False, True]) +def test_gphase_controlled_circuit_matrices(controls, order, symbolic): + circuit = Circuit(3) + circuit.h(1) + theta = Parameter("theta") if symbolic else 0.37 + circuit.append_mc_gate(GPhase(theta).control(len(controls)), controls) + spectator_bit = order.index(1) + mask = sum(1 << order.index(control) for control in controls) + expected = np.zeros((8, 8), dtype=complex) + for row in range(8): + for col in range(8): + if (row ^ col) & ~(1 << spectator_bit) == 0: + sign = -1 if row & col & (1 << spectator_bit) else 1 + phase = np.exp(0.37j) if row & mask == mask else 1 + expected[row, col] = phase * sign / np.sqrt(2) + if not symbolic: + actual = circuit.to_matrix(order) + assert actual.shape == (8, 8) + np.testing.assert_allclose(actual, expected, rtol=0, atol=1e-12) + expression = circuit.to_symbolic_matrix(order) + assert expression.shape == (8, 8) + np.testing.assert_allclose( + expression.evaluate({"theta": 0.37}), expected, rtol=0, atol=1e-12 + ) diff --git a/crates/binding-python/tests/gates/test_standard.py b/crates/binding-python/tests/gates/test_standard.py index 4c7770fe..a0d64e27 100644 --- a/crates/binding-python/tests/gates/test_standard.py +++ b/crates/binding-python/tests/gates/test_standard.py @@ -396,12 +396,12 @@ def test_phase_gate(self): assert np.allclose(mat, expected), "Phase gate matrix mismatch" def test_global_phase_gate(self): - """GPhase(θ) = e^(iθ) I (2x2)""" + """The zero-qubit GPhase matrix contains one scalar phase.""" theta = np.pi / 4 mat = GPhase(theta).matrix() - # GPhase is 1-qubit gate with 1 parameter, matrix is 2x2 - expected = np.exp(1j * theta) * np.eye(2) - assert np.allclose(mat, expected), "GPhase matrix mismatch" + expected = np.array([[np.exp(1j * theta)]]) + assert mat.shape == (1, 1) + np.testing.assert_allclose(mat, expected, rtol=0, atol=1e-12) def test_u_gate_general(self): """U gate is general single-qubit gate""" diff --git a/crates/binding-python/tests/qis/test_controlled_gphase.py b/crates/binding-python/tests/qis/test_controlled_gphase.py new file mode 100644 index 00000000..2b29f446 --- /dev/null +++ b/crates/binding-python/tests/qis/test_controlled_gphase.py @@ -0,0 +1,226 @@ +# This code is part of Cqlib. +# +# (C) Copyright China Telecom Quantum Group 2026 +# +# Licensed under the Apache License, Version 2.0. See LICENSE.txt or +# http://www.apache.org/licenses/LICENSE-2.0. + +"""Controlled zero-target phase evolution in the numerical simulators.""" + +import numpy as np +import pytest + +from cqlib import Circuit, Parameter +from cqlib.circuit import StandardGate +from cqlib.circuit.gates import GPhase +from cqlib.device import NoiseModel, SingleQubitNoise +from cqlib.qis import DensityMatrix, DensityMatrixNoise, Statevector + + +SIMULATORS = [Statevector, DensityMatrix, DensityMatrixNoise] + + +def assert_state(state, amplitudes): + if isinstance(state, Statevector): + actual, expected = state.data, amplitudes + else: + actual = state.state if isinstance(state, DensityMatrixNoise) else state.data + expected = np.outer(amplitudes, amplitudes.conj()) + np.testing.assert_allclose(actual, expected, rtol=0, atol=1e-12) + + +@pytest.mark.parametrize("simulator", SIMULATORS) +@pytest.mark.parametrize("num_qubits", [0, 1, 3]) +def test_controlled_gphase_preserves_uncontrolled_phase(simulator, num_qubits): + circuit = Circuit(num_qubits) + expected = np.zeros(1 << num_qubits, dtype=complex) + expected[0] = 1 + if num_qubits: + circuit.h(0) + expected[:2] = 1 / np.sqrt(2) + circuit.append_gate(GPhase(0.37), []) + circuit.set_global_phase(-0.21) + expected *= np.exp(0.16j) + assert_state(simulator.from_circuit(circuit), expected) + + +@pytest.mark.parametrize("simulator", SIMULATORS) +@pytest.mark.parametrize("theta", [0.0, 0.37, -0.51, np.pi]) +@pytest.mark.parametrize("initial", ["zero", "one", "plus"]) +@pytest.mark.parametrize("incremental", [False, True]) +def test_controlled_gphase_single_qubit(simulator, theta, initial, incremental): + circuit = Circuit(1) + expected = np.array([1, 0], dtype=complex) + if initial == "one": + circuit.x(0) + expected = np.array([0, 1], dtype=complex) + elif initial == "plus": + circuit.h(0) + expected = np.ones(2, dtype=complex) / np.sqrt(2) + if incremental: + state = simulator.from_circuit(circuit) + circuit = Circuit(1) + circuit.append_mc_gate(GPhase(theta).control(1), [0]) + expected[1] *= np.exp(1j * theta) + if incremental: + state.apply_circuit(circuit) + else: + state = simulator.from_circuit(circuit) + assert_state(state, expected) + + +@pytest.mark.parametrize("simulator", SIMULATORS) +@pytest.mark.parametrize("labels", [[0, 1, 2], [12, 5, 31]]) +@pytest.mark.parametrize("positions", [[2], [2, 0], [0, 2]]) +def test_controlled_gphase_labels_spectators_and_inverse(simulator, labels, positions): + circuit = Circuit(labels) + for qubit in labels: + circuit.h(qubit) + circuit.s(labels[1]) + controls = [labels[position] for position in positions] + phase = Circuit(labels) + gate = GPhase(0.37).control(len(controls)) + phase.append_mc_gate(gate, controls) + incremental = simulator.from_circuit(circuit) + incremental.apply_circuit(phase) + circuit.append_mc_gate(gate, controls) + direct = simulator.from_circuit(circuit) + indices = np.arange(8) + baseline = np.where(indices & 2, 1j, 1) / np.sqrt(8) + mask = sum(1 << position for position in positions) + expected = baseline * np.where(indices & mask == mask, np.exp(0.37j), 1) + assert_state(direct, expected) + assert_state(incremental, expected) + inverse = Circuit(labels) + inverse.append_mc_gate(gate.inverse(), controls) + incremental.apply_circuit(inverse) + assert_state(incremental, baseline) + + +@pytest.mark.parametrize("simulator", SIMULATORS) +def test_controlled_gphase_parameter_resolution(simulator): + circuit = Circuit(1) + circuit.h(0) + circuit.append_mc_gate(GPhase(Parameter("theta")).control(1), [0]) + with pytest.raises(ValueError, match="(?i)symbolic"): + simulator.from_circuit(circuit) + state = simulator(1) + with pytest.raises(ValueError, match="(?i)symbolic"): + state.apply_circuit(circuit) + bound = circuit.assign_parameters({"theta": -0.51}) + expected = np.array([1, np.exp(-0.51j)]) / np.sqrt(2) + assert_state(simulator.from_circuit(bound), expected) + + +@pytest.mark.parametrize("empty_model", [False, True]) +def test_controlled_gphase_noisy_simulator_without_errors(empty_model): + circuit = Circuit(1) + circuit.h(0) + circuit.append_mc_gate(GPhase(0.37).control(1), [0]) + model = NoiseModel() if empty_model else None + state = DensityMatrixNoise.from_circuit(circuit, model) + assert_state(state, np.array([1, np.exp(0.37j)]) / np.sqrt(2)) + + +@pytest.mark.parametrize("incremental", [False, True]) +def test_controlled_gphase_noise_follows_unitary_once_with_original_labels(incremental): + labels = [12, 5] + circuit = Circuit(labels) + circuit.x(12) + circuit.h(5) + model = NoiseModel() + probability = 0.23 + model.add_single_qubit_error( + StandardGate.GPhase, 5, SingleQubitNoise.bit_flip(probability) + ) + model.add_single_qubit_error( + StandardGate.GPhase, 12, SingleQubitNoise.bit_flip(1.0) + ) + if incremental: + state = DensityMatrixNoise.from_circuit(circuit, model) + circuit = Circuit(labels) + circuit.append_mc_gate(GPhase(0.37).control(1), [5]) + if incremental: + state.apply_circuit(circuit) + else: + state = DensityMatrixNoise.from_circuit(circuit, model) + amplitudes = np.array([0, 1, 0, np.exp(0.37j)]) / np.sqrt(2) + rho = np.outer(amplitudes, amplitudes.conj()) + flipped = np.arange(4) ^ 2 + expected = (1 - probability) * rho + probability * rho[np.ix_(flipped, flipped)] + np.testing.assert_allclose(state.state, expected, rtol=0, atol=1e-12) + + +@pytest.mark.parametrize("simulator", SIMULATORS) +@pytest.mark.parametrize("num_qubits", [0, 1, 3]) +@pytest.mark.parametrize("theta", [0.0, 0.37, -0.51, np.pi]) +@pytest.mark.parametrize("incremental", [False, True]) +def test_zero_control_gphase_evolution(simulator, num_qubits, theta, incremental): + preparation = Circuit(num_qubits) + expected = np.zeros(1 << num_qubits, dtype=complex) + expected[0] = 1 + if num_qubits: + preparation.h(0) + expected[:2] = 1 / np.sqrt(2) + circuit = Circuit(num_qubits) if incremental else preparation + circuit.append_mc_gate(GPhase(theta).control(0), []) + circuit.append_mc_gate(GPhase(-0.21).control(0), []) + circuit.set_global_phase(0.11) + expected *= np.exp(1j * (theta - 0.10)) + if incremental: + state = simulator.from_circuit(preparation) + state.apply_circuit(circuit) + else: + state = simulator.from_circuit(circuit) + assert_state(state, expected) + inverse = Circuit(num_qubits) + inverse.append_mc_gate(GPhase(theta - 0.10).control(0).inverse(), []) + state.apply_circuit(inverse) + assert_state(state, expected * np.exp(-1j * (theta - 0.10))) + + +@pytest.mark.parametrize("simulator", SIMULATORS) +def test_zero_control_gphase_parameter_resolution(simulator): + circuit = Circuit([12, 5]) + circuit.h(5) + circuit.append_mc_gate(GPhase(Parameter("theta")).control(0), []) + with pytest.raises(ValueError, match="(?i)symbolic"): + simulator.from_circuit(circuit) + with pytest.raises(ValueError, match="(?i)symbolic"): + simulator(2).apply_circuit(circuit) + bound = circuit.assign_parameters({"theta": -0.51}) + expected = np.array([1, 0, 1, 0], dtype=complex) * np.exp(-0.51j) / np.sqrt(2) + assert_state(simulator.from_circuit(bound), expected) + + +@pytest.mark.parametrize("empty_model", [False, True]) +@pytest.mark.parametrize("incremental", [False, True]) +def test_zero_control_gphase_noise_matches_plain_gate(empty_model, incremental): + model = NoiseModel() + if not empty_model: + model.add_single_qubit_error( + StandardGate.GPhase, 12, SingleQubitNoise.bit_flip(1.0) + ) + model.add_single_qubit_error( + StandardGate.H, 5, SingleQubitNoise.depolarizing(0.23) + ) + preparation = Circuit([12, 5]) + preparation.x(12) + preparation.h(5) + results = [] + for wrapped in [False, True]: + circuit = Circuit([12, 5]) + if not incremental: + circuit.x(12) + circuit.h(5) + if wrapped: + circuit.append_mc_gate(GPhase(0.37).control(0), []) + else: + circuit.append_gate(GPhase(0.37), []) + if incremental: + state = DensityMatrixNoise.from_circuit(preparation, model) + state.apply_circuit(circuit) + else: + state = DensityMatrixNoise.from_circuit(circuit, model) + results.append(state.state) + np.testing.assert_allclose(results[0], results[1], rtol=0, atol=1e-12) diff --git a/crates/binding-python/tests/qis/test_stabilizer.py b/crates/binding-python/tests/qis/test_stabilizer.py index 8114759d..7aa02ba5 100644 --- a/crates/binding-python/tests/qis/test_stabilizer.py +++ b/crates/binding-python/tests/qis/test_stabilizer.py @@ -272,3 +272,19 @@ def test_two_qubit_gates_reject_duplicate_qubits(self): with pytest.raises(ValueError): state.apply_swap(1, 1) + + +@pytest.mark.parametrize("theta", [0.0, 0.37, math.pi / 2]) +@pytest.mark.parametrize("entrypoint", ["from_circuit", "apply_circuit", "run_circuit"]) +def test_controlled_gphase_returns_normal_unsupported_error(theta, entrypoint): + from cqlib.circuit.gates import GPhase + + circuit = Circuit([12]) + circuit.append_mc_gate(GPhase(theta).control(1), [12]) + execute = ( + StabilizerState(1).apply_circuit + if entrypoint == "apply_circuit" + else getattr(StabilizerState, entrypoint) + ) + with pytest.raises(ValueError, match="Controlled-GPhase.*not supported"): + execute(circuit) diff --git a/crates/cqlib-core/src/circuit/circuit_to_matrix_test.rs b/crates/cqlib-core/src/circuit/circuit_to_matrix_test.rs index 7fb12895..5f223d51 100644 --- a/crates/cqlib-core/src/circuit/circuit_to_matrix_test.rs +++ b/crates/cqlib-core/src/circuit/circuit_to_matrix_test.rs @@ -936,6 +936,74 @@ fn test_global_phase_is_included_for_empty_circuit() { assert_matrix_approx_eq(&matrix, &expected, 1e-10); } +#[test] +fn test_gphase_markers_accumulate_with_circuit_global_phase() { + for num_qubits in [0, 1, 3] { + for theta in [0.0, 0.37, -0.51, PI] { + let mut circuit = Circuit::new(num_qubits); + if num_qubits > 0 { + circuit.h(Qubit::new(0)).unwrap(); + } + let baseline = circuit_to_matrix(&circuit, None).unwrap(); + for phase in [theta, -0.21] { + circuit + .append( + Instruction::Standard(StandardGate::GPhase), + [] as [Qubit; 0], + [phase.into()], + None, + ) + .unwrap(); + } + circuit.set_global_phase(Parameter::from(0.17)); + let expected = baseline * Complex64::from_polar(1.0, theta - 0.21 + 0.17); + let actual = circuit_to_matrix(&circuit, None).unwrap(); + assert_matrix_approx_eq(&actual, &expected, 1e-12); + } + } +} + +#[test] +fn test_gphase_controlled_embedding_with_spectator_and_custom_order() { + for controls in [vec![], vec![2usize], vec![2, 0]] { + for theta in [0.0, 0.37, -0.51, PI] { + let mut circuit = Circuit::new(3); + circuit.h(Qubit::new(1)).unwrap(); + circuit + .multi_control( + StandardGate::GPhase, + controls.iter().map(|&q| Qubit::new(q as u32)), + [] as [Qubit; 0], + [theta.into()], + ) + .unwrap(); + for order in [[0, 1, 2], [2, 0, 1]] { + let spectator_bit = order.iter().position(|&q| q == 1).unwrap(); + let mask = controls.iter().fold(0, |mask, control| { + mask | (1 << order.iter().position(|q| q == control).unwrap()) + }); + let mut expected = Array2::zeros((8, 8)); + for row in 0..8 { + for col in 0..8 { + if (row ^ col) & !(1 << spectator_bit) == 0 { + let sign = if row & col & (1 << spectator_bit) != 0 { + -1.0 + } else { + 1.0 + }; + let phase = if row & mask == mask { theta } else { 0.0 }; + expected[[row, col]] = + Complex64::from_polar(1.0, phase) * sign / SQRT_2; + } + } + } + let actual = circuit_to_matrix(&circuit, Some(&order)).unwrap(); + assert_matrix_approx_eq(&actual, &expected, 1e-12); + } + } + } +} + #[test] fn test_global_phase_is_included_with_gate() { let mut circuit = Circuit::new(1); diff --git a/crates/cqlib-core/src/circuit/gate/gate_matrix.rs b/crates/cqlib-core/src/circuit/gate/gate_matrix.rs index f4792fc8..5477911f 100644 --- a/crates/cqlib-core/src/circuit/gate/gate_matrix.rs +++ b/crates/cqlib-core/src/circuit/gate/gate_matrix.rs @@ -581,17 +581,14 @@ pub fn phase_gate(lambda: f64) -> Array2> { array![[ONE, ZERO], [ZERO, exp_i_lambda]] } -/// Returns a global phase gate. +/// Returns the 1×1 matrix of a zero-qubit global phase gate. /// -/// This applies a global phase factor to the quantum state, physically equivalent to identity -/// but useful for mathematical consistency in simulations. +/// When embedded in a circuit, this scalar multiplies every state amplitude +/// by the same phase factor without requiring a target qubit. /// /// Defined as: /// ```text -/// [ -/// [exp(i*theta), 0 ], -/// [ 0, exp(i*theta)] -/// ] +/// [[exp(i*theta)]] /// ``` /// /// # Arguments @@ -601,7 +598,7 @@ pub fn phase_gate(lambda: f64) -> Array2> { pub fn global_phase_gate(theta: f64) -> Array2> { let (sin_val, cos_val) = theta.sin_cos(); let exp_i_theta = Complex::new(cos_val, sin_val); - array![[exp_i_theta, ZERO], [ZERO, exp_i_theta]] + array![[exp_i_theta]] } /// Returns the general single-qubit unitary gate (U3). diff --git a/crates/cqlib-core/src/circuit/gate/gate_matrix_test.rs b/crates/cqlib-core/src/circuit/gate/gate_matrix_test.rs index a6958d55..9444296e 100644 --- a/crates/cqlib-core/src/circuit/gate/gate_matrix_test.rs +++ b/crates/cqlib-core/src/circuit/gate/gate_matrix_test.rs @@ -11,6 +11,8 @@ // that they have been altered from the originals. use super::*; +use crate::circuit::Parameter; +use crate::circuit::gate::{MCGate, StandardGate}; use crate::circuit::test_utils::assert_is_unitary; use std::f64::consts::PI; @@ -27,6 +29,45 @@ fn assert_matrix_approx_eq(a: &Array2>, b: &Array2>) { } } +#[test] +fn test_gphase_scalar_matrix() { + for theta in [0.0, 0.37, -0.51, PI] { + let matrix = global_phase_gate(theta); + assert_eq!(matrix.dim(), (1, 1)); + assert!((matrix[[0, 0]] - Complex::from_polar(1.0, theta)).norm() < 1e-12); + assert_is_unitary(&matrix, 1e-12); + } +} + +#[test] +fn test_gphase_controlled_matrix_matches_metadata_and_inverse() { + assert_eq!(StandardGate::GPhase.num_qubits(), 0); + assert_eq!(StandardGate::GPhase.num_ctrl_qubits(), 0); + assert_eq!(StandardGate::GPhase.num_params(), 1); + for theta in [0.0, 0.37, -0.51, PI] { + for controls in 0..=2 { + let gate = MCGate::new(controls, StandardGate::GPhase); + assert_eq!(gate.num_qubits(), controls as usize); + let dim = 1 << gate.num_qubits(); + let matrix = gate.matrix(&[theta]).unwrap(); + let mut expected = Array2::eye(dim); + expected[[dim - 1, dim - 1]] = Complex::from_polar(1.0, theta); + crate::circuit::test_utils::assert_matrix_approx_eq(&matrix, &expected, 1e-12); + assert_is_unitary(&matrix, 1e-12); + + let (inverse, params) = gate.inverse(&[Parameter::from(theta)]).unwrap(); + let inverse_theta = params[0].evaluate(&None).unwrap(); + let inverse_matrix = inverse.matrix(&[inverse_theta]).unwrap(); + let expected_inverse = expected.t().mapv(|value| value.conj()); + crate::circuit::test_utils::assert_matrix_approx_eq( + &inverse_matrix, + &expected_inverse, + 1e-12, + ); + } + } +} + #[test] fn test_fixed_gates_unitary() { // Batch verify unitarity for all fixed gates diff --git a/crates/cqlib-core/src/circuit/symbolic_matrix/gate.rs b/crates/cqlib-core/src/circuit/symbolic_matrix/gate.rs index 84ff71b3..ee8adb3c 100644 --- a/crates/cqlib-core/src/circuit/symbolic_matrix/gate.rs +++ b/crates/cqlib-core/src/circuit/symbolic_matrix/gate.rs @@ -155,10 +155,7 @@ pub fn standard_gate_symbolic_matrix( [o.clone(), z.clone()], [z, SymbolicComplex::exp_i(params[0].clone())] ], - StandardGate::GPhase => { - let phase = SymbolicComplex::exp_i(params[0].clone()); - ndarray::array![[phase.clone(), z.clone()], [z, phase]] - } + StandardGate::GPhase => ndarray::array![[SymbolicComplex::exp_i(params[0].clone())]], StandardGate::RXX => { let c = cos_half(¶ms[0]); let s = neg_i_times(sin_half(¶ms[0])); diff --git a/crates/cqlib-core/src/circuit/symbolic_matrix/gate_test.rs b/crates/cqlib-core/src/circuit/symbolic_matrix/gate_test.rs index b0dd392b..69bc8f1a 100644 --- a/crates/cqlib-core/src/circuit/symbolic_matrix/gate_test.rs +++ b/crates/cqlib-core/src/circuit/symbolic_matrix/gate_test.rs @@ -36,6 +36,117 @@ fn assert_standard_gate_matches(gate: StandardGate, params: &[f64]) { assert_matrix_approx_eq(&evaluated, expected.as_ref(), 1e-10); } +#[test] +fn test_gphase_symbolic_gate_and_controls_match_scalar_formula() { + let theta = Parameter::symbol("theta"); + for controls in 0..=2 { + let base = + standard_gate_symbolic_matrix(StandardGate::GPhase, std::slice::from_ref(&theta)) + .unwrap(); + assert_eq!(base.dim(), (1, 1)); + let matrix = control_matrix(&base, controls); + let dim = 1 << controls; + assert_eq!(matrix.dim(), (dim, dim)); + for angle in [0.0, 0.37, -0.51, PI] { + let bindings = HashMap::from([("theta", angle)]); + let actual = evaluate_symbolic_matrix(&matrix, &Some(bindings)).unwrap(); + let mut expected = Array2::eye(dim); + expected[[dim - 1, dim - 1]] = Complex64::from_polar(1.0, angle); + assert_matrix_approx_eq(&actual, &expected, 1e-12); + } + } +} + +#[test] +fn test_gphase_symbolic_markers_accumulate_across_numeric_runs() { + for num_qubits in [0, 1, 3] { + let mut circuit = Circuit::new(num_qubits); + if num_qubits > 0 { + circuit.h(Qubit::new(0)).unwrap(); + } + for phase in [Parameter::from(-0.21), Parameter::symbol("theta")] { + circuit + .append( + Instruction::Standard(StandardGate::GPhase), + [] as [Qubit; 0], + [phase.into()], + None, + ) + .unwrap(); + } + if num_qubits > 0 { + circuit + .rz(Qubit::new(0), Parameter::symbol("alpha")) + .unwrap(); + } + circuit + .append( + Instruction::Standard(StandardGate::GPhase), + [] as [Qubit; 0], + [0.12.into()], + None, + ) + .unwrap(); + circuit.set_global_phase(Parameter::from(0.17)); + let symbolic = circuit_to_symbolic_matrix(&circuit, None).unwrap(); + let mut baseline = Circuit::new(num_qubits); + if num_qubits > 0 { + baseline.h(Qubit::new(0)).unwrap(); + baseline.rz(Qubit::new(0), Parameter::from(0.23)).unwrap(); + } + let baseline = circuit_to_matrix(&baseline, None).unwrap(); + for theta in [0.0, 0.37, -0.51, PI] { + let bindings = HashMap::from([("theta", theta), ("alpha", 0.23)]); + let actual = evaluate_symbolic_matrix(&symbolic, &Some(bindings)).unwrap(); + let expected = &baseline * Complex64::from_polar(1.0, theta - 0.21 + 0.12 + 0.17); + assert_matrix_approx_eq(&actual, &expected, 1e-12); + } + } +} + +#[test] +fn test_gphase_symbolic_controlled_circuit_with_constant_and_symbolic_params() { + for controls in [vec![], vec![2usize], vec![2, 0]] { + for phase in [Parameter::from(0.37), Parameter::symbol("theta")] { + let mut circuit = Circuit::new(3); + circuit.h(Qubit::new(1)).unwrap(); + circuit + .multi_control( + StandardGate::GPhase, + controls.iter().map(|&q| Qubit::new(q as u32)), + [] as [Qubit; 0], + [phase.into()], + ) + .unwrap(); + for order in [[0, 1, 2], [2, 0, 1]] { + let spectator_bit = order.iter().position(|&q| q == 1).unwrap(); + let mask = controls.iter().fold(0, |mask, control| { + mask | (1 << order.iter().position(|q| q == control).unwrap()) + }); + let symbolic = circuit_to_symbolic_matrix(&circuit, Some(&order)).unwrap(); + let bindings = HashMap::from([("theta", 0.37)]); + let actual = evaluate_symbolic_matrix(&symbolic, &Some(bindings)).unwrap(); + let mut expected = Array2::zeros((8, 8)); + for row in 0..8 { + for col in 0..8 { + if (row ^ col) & !(1 << spectator_bit) == 0 { + let sign = if row & col & (1 << spectator_bit) != 0 { + -1.0 + } else { + 1.0 + }; + let angle = if row & mask == mask { 0.37 } else { 0.0 }; + expected[[row, col]] = + Complex64::from_polar(1.0, angle) * sign / std::f64::consts::SQRT_2; + } + } + } + assert_matrix_approx_eq(&actual, &expected, 1e-12); + } + } + } +} + #[test] fn test_fixed_standard_gate_matches_numeric_matrix() { let symbolic = standard_gate_symbolic_matrix(StandardGate::H, &[]).unwrap(); diff --git a/crates/cqlib-core/src/qis/state/density_matrix.rs b/crates/cqlib-core/src/qis/state/density_matrix.rs index fbec87a1..35280102 100644 --- a/crates/cqlib-core/src/qis/state/density_matrix.rs +++ b/crates/cqlib-core/src/qis/state/density_matrix.rs @@ -464,30 +464,31 @@ impl DensityMatrix { let num_controls = mc_gate.num_ctrl_qubits(); let base_gate = mc_gate.base_gate(); - if num_controls == 1 { - let control = qubit_indices[0]; - let target = qubit_indices[1]; - match base_gate { - StandardGate::X => dm.apply_cx(control, target)?, - StandardGate::Y => dm.apply_cy(control, target)?, - StandardGate::Z => dm.apply_cz(control, target)?, - StandardGate::RX => dm.apply_crx(control, target, params[0])?, - StandardGate::RY => dm.apply_cry(control, target, params[0])?, - StandardGate::RZ => dm.apply_crz(control, target, params[0])?, - _ => { - let matrix = mc_gate.matrix(¶ms).map_err(|_| { - QisError::CircuitError(CircuitError::NoMatrixRepresentation) - })?; - dm.apply_unitary_gate(&qubit_indices, &matrix)?; - } + match (num_controls, *base_gate) { + (0, StandardGate::GPhase) => { + dm.apply_standard_gate(*base_gate, &qubit_indices, ¶ms)? + } + (1, StandardGate::X) => dm.apply_cx(qubit_indices[0], qubit_indices[1])?, + (1, StandardGate::Y) => dm.apply_cy(qubit_indices[0], qubit_indices[1])?, + (1, StandardGate::Z) => dm.apply_cz(qubit_indices[0], qubit_indices[1])?, + (1, StandardGate::RX) => { + dm.apply_crx(qubit_indices[0], qubit_indices[1], params[0])? + } + (1, StandardGate::RY) => { + dm.apply_cry(qubit_indices[0], qubit_indices[1], params[0])? + } + (1, StandardGate::RZ) => { + dm.apply_crz(qubit_indices[0], qubit_indices[1], params[0])? + } + (2, StandardGate::X) => { + dm.apply_ccx(qubit_indices[0], qubit_indices[1], qubit_indices[2])? + } + _ => { + let matrix = mc_gate.matrix(¶ms).map_err(|_| { + QisError::CircuitError(CircuitError::NoMatrixRepresentation) + })?; + dm.apply_unitary_gate(&qubit_indices, &matrix)?; } - } else if num_controls == 2 && *base_gate == StandardGate::X { - dm.apply_ccx(qubit_indices[0], qubit_indices[1], qubit_indices[2])?; - } else { - let matrix = mc_gate.matrix(¶ms).map_err(|_| { - QisError::CircuitError(CircuitError::NoMatrixRepresentation) - })?; - dm.apply_unitary_gate(&qubit_indices, &matrix)?; } } Instruction::UnitaryGate(u_gate) => { diff --git a/crates/cqlib-core/src/qis/state/density_matrix_noise.rs b/crates/cqlib-core/src/qis/state/density_matrix_noise.rs index d76fe5c0..49cd133e 100644 --- a/crates/cqlib-core/src/qis/state/density_matrix_noise.rs +++ b/crates/cqlib-core/src/qis/state/density_matrix_noise.rs @@ -261,36 +261,26 @@ impl DensityMatrixNoise { let num_controls = mc_gate.num_ctrl_qubits(); let base_gate = mc_gate.base_gate(); - if num_controls == 1 { - let c = qs[0]; - let t = qs[1]; - match base_gate { - StandardGate::X => sim.apply_cx(c, t)?, - StandardGate::Y => sim.apply_cy(c, t)?, - StandardGate::Z => sim.apply_cz(c, t)?, - StandardGate::RX => sim.apply_crx(c, t, params[0])?, - StandardGate::RY => sim.apply_cry(c, t, params[0])?, - StandardGate::RZ => sim.apply_crz(c, t, params[0])?, - _ => { - let matrix = mc_gate.matrix(¶ms).map_err(|_| { - QisError::CircuitError( - crate::circuit::CircuitError::NoMatrixRepresentation, - ) - })?; - sim.apply_unitary_gate(&qs, &matrix)?; - sim.apply_noise(*base_gate, &qs)?; - } + match (num_controls, *base_gate) { + (0, StandardGate::GPhase) => { + sim.apply_standard_gate_noise(*base_gate, &qs, ¶ms)? + } + (1, StandardGate::X) => sim.apply_cx(qs[0], qs[1])?, + (1, StandardGate::Y) => sim.apply_cy(qs[0], qs[1])?, + (1, StandardGate::Z) => sim.apply_cz(qs[0], qs[1])?, + (1, StandardGate::RX) => sim.apply_crx(qs[0], qs[1], params[0])?, + (1, StandardGate::RY) => sim.apply_cry(qs[0], qs[1], params[0])?, + (1, StandardGate::RZ) => sim.apply_crz(qs[0], qs[1], params[0])?, + (2, StandardGate::X) => sim.apply_ccx(qs[0], qs[1], qs[2])?, + _ => { + let matrix = mc_gate.matrix(¶ms).map_err(|_| { + QisError::CircuitError( + crate::circuit::CircuitError::NoMatrixRepresentation, + ) + })?; + sim.apply_unitary_gate(&qs, &matrix)?; + sim.apply_noise(*base_gate, &qs)?; } - } else if num_controls == 2 && *base_gate == StandardGate::X { - sim.apply_ccx(qs[0], qs[1], qs[2])?; - } else { - let matrix = mc_gate.matrix(¶ms).map_err(|_| { - QisError::CircuitError( - crate::circuit::CircuitError::NoMatrixRepresentation, - ) - })?; - sim.apply_unitary_gate(&qs, &matrix)?; - sim.apply_noise(*base_gate, &qs)?; } } Instruction::UnitaryGate(u_gate) => { diff --git a/crates/cqlib-core/src/qis/state/density_matrix_noise_test.rs b/crates/cqlib-core/src/qis/state/density_matrix_noise_test.rs index 82775269..cb801f01 100644 --- a/crates/cqlib-core/src/qis/state/density_matrix_noise_test.rs +++ b/crates/cqlib-core/src/qis/state/density_matrix_noise_test.rs @@ -1578,3 +1578,169 @@ fn identity_noise_respects_labels_and_repetition() { .is_err() ); } + +#[test] +fn controlled_gphase_circuit_evolution() { + for ids in [[0, 1, 2], [12, 5, 31]] { + let qubits = ids.map(Qubit::new); + for controls in [ + vec![qubits[2]], + vec![qubits[2], qubits[0]], + vec![qubits[0], qubits[2]], + ] { + for theta in [0.0, 0.37, -0.51, PI] { + let mut preparation = Circuit::from_qubits(qubits.to_vec()).unwrap(); + for qubit in qubits { + preparation.h(qubit).unwrap(); + } + preparation.s(qubits[1]).unwrap(); + let mut phase = Circuit::from_qubits(qubits.to_vec()).unwrap(); + phase + .multi_control( + StandardGate::GPhase, + controls.clone(), + Vec::::new(), + [theta.into()], + ) + .unwrap(); + let mut circuit = preparation.clone(); + circuit + .multi_control( + StandardGate::GPhase, + controls.clone(), + Vec::::new(), + [theta.into()], + ) + .unwrap(); + let mask = controls.iter().fold(0usize, |mask, q| { + mask | (1 << qubits.iter().position(|candidate| candidate == q).unwrap()) + }); + let expected: Vec = (0..8) + .map(|index| { + let amplitude = if index & 2 == 0 { + Complex64::new(1.0, 0.0) + } else { + Complex64::new(0.0, 1.0) + } / 8.0_f64.sqrt(); + if index & mask == mask { + amplitude * Complex64::from_polar(1.0, theta) + } else { + amplitude + } + }) + .collect(); + let expected_data = expected + .iter() + .flat_map(|a| expected.iter().map(move |b| a * b.conj())) + .collect::>(); + let actual = DensityMatrixNoise::from_circuit(&circuit, None).unwrap(); + let mut incremental = DensityMatrixNoise::new(3, None); + incremental.apply_circuit(&preparation).unwrap(); + incremental.apply_circuit(&phase).unwrap(); + for values in [actual.state.data(), incremental.state.data()] { + for (actual, expected) in values.iter().zip(&expected_data) { + assert!( + (actual - expected).norm() < 1e-12, + "{actual:?} != {expected:?}" + ); + } + } + let mut inverse = Circuit::from_qubits(qubits.to_vec()).unwrap(); + inverse + .multi_control( + StandardGate::GPhase, + controls.clone(), + Vec::::new(), + [(-theta).into()], + ) + .unwrap(); + incremental.apply_circuit(&inverse).unwrap(); + let baseline = DensityMatrixNoise::from_circuit(&preparation, None).unwrap(); + for (actual, expected) in incremental.state.data().iter().zip(baseline.state.data()) + { + assert!((actual - expected).norm() < 1e-12); + } + } + } + } +} + +#[test] +fn controlled_gphase_noise_applies_after_unitary_once() { + let qubits = [Qubit::new(12), Qubit::new(5)]; + let mut circuit = Circuit::from_qubits(qubits.to_vec()).unwrap(); + circuit.x(qubits[0]).unwrap(); + circuit.h(qubits[1]).unwrap(); + circuit + .multi_control( + StandardGate::GPhase, + [qubits[1]], + Vec::::new(), + [0.37.into()], + ) + .unwrap(); + let mut model = NoiseModel::new(); + model + .add_single_qubit_error( + StandardGate::GPhase, + qubits[1], + SingleQubitNoise::BitFlip(0.23), + ) + .unwrap(); + model + .add_single_qubit_error( + StandardGate::GPhase, + qubits[0], + SingleQubitNoise::BitFlip(1.0), + ) + .unwrap(); + let sim = DensityMatrixNoise::from_circuit(&circuit, Some(model)).unwrap(); + let amplitudes = [ + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0) / 2.0_f64.sqrt(), + Complex64::new(0.0, 0.0), + Complex64::from_polar(1.0 / 2.0_f64.sqrt(), 0.37), + ]; + for row in 0..4 { + for col in 0..4 { + let expected = 0.77 * amplitudes[row] * amplitudes[col].conj() + + 0.23 * amplitudes[row ^ 2] * amplitudes[col ^ 2].conj(); + assert!((sim.state.data()[row * 4 + col] - expected).norm() < 1e-12); + } + } +} + +#[test] +fn zero_control_gphase_circuit_evolution() { + use crate::circuit::gate::{Instruction, MCGate}; + for num_qubits in [0, 1, 3] { + for theta in [0.0, 0.37, -0.51, PI] { + let mut circuit = Circuit::new(num_qubits); + if num_qubits > 0 { + circuit.h(Qubit::new(0)).unwrap(); + } + circuit + .append( + Instruction::McGate(Box::new(MCGate::new(0, StandardGate::GPhase))), + Vec::::new(), + [theta.into()], + None, + ) + .unwrap(); + let actual = DensityMatrixNoise::from_circuit(&circuit, None).unwrap(); + let mut amplitudes = vec![Complex64::new(0.0, 0.0); 1 << num_qubits]; + amplitudes[0] = Complex64::from_polar(1.0, theta); + if num_qubits > 0 { + amplitudes[0] /= 2.0_f64.sqrt(); + amplitudes[1] = amplitudes[0]; + } + let expected = amplitudes + .iter() + .flat_map(|a| amplitudes.iter().map(move |b| a * b.conj())) + .collect::>(); + for (actual, expected) in actual.state.data().iter().zip(&expected) { + assert!((actual - expected).norm() < 1e-12); + } + } + } +} diff --git a/crates/cqlib-core/src/qis/state/density_matrix_test.rs b/crates/cqlib-core/src/qis/state/density_matrix_test.rs index 91595c4b..9192b901 100644 --- a/crates/cqlib-core/src/qis/state/density_matrix_test.rs +++ b/crates/cqlib-core/src/qis/state/density_matrix_test.rs @@ -1240,3 +1240,123 @@ fn hermiticity_rejects_nonfinite_entries() { } } } + +#[test] +fn controlled_gphase_circuit_evolution() { + for ids in [[0, 1, 2], [12, 5, 31]] { + let qubits = ids.map(Qubit::new); + for controls in [ + vec![qubits[2]], + vec![qubits[2], qubits[0]], + vec![qubits[0], qubits[2]], + ] { + for theta in [0.0, 0.37, -0.51, PI] { + let mut preparation = Circuit::from_qubits(qubits.to_vec()).unwrap(); + for qubit in qubits { + preparation.h(qubit).unwrap(); + } + preparation.s(qubits[1]).unwrap(); + let mut phase = Circuit::from_qubits(qubits.to_vec()).unwrap(); + phase + .multi_control( + StandardGate::GPhase, + controls.clone(), + Vec::::new(), + [theta.into()], + ) + .unwrap(); + let mut circuit = preparation.clone(); + circuit + .multi_control( + StandardGate::GPhase, + controls.clone(), + Vec::::new(), + [theta.into()], + ) + .unwrap(); + let mask = controls.iter().fold(0usize, |mask, q| { + mask | (1 << qubits.iter().position(|candidate| candidate == q).unwrap()) + }); + let expected: Vec = (0..8) + .map(|index| { + let amplitude = if index & 2 == 0 { + Complex64::new(1.0, 0.0) + } else { + Complex64::new(0.0, 1.0) + } / 8.0_f64.sqrt(); + if index & mask == mask { + amplitude * Complex64::from_polar(1.0, theta) + } else { + amplitude + } + }) + .collect(); + let expected_data = expected + .iter() + .flat_map(|a| expected.iter().map(move |b| a * b.conj())) + .collect::>(); + let actual = DensityMatrix::from_circuit(&circuit).unwrap(); + let mut incremental = DensityMatrix::new(3); + incremental.apply_circuit(&preparation).unwrap(); + incremental.apply_circuit(&phase).unwrap(); + for values in [actual.data(), incremental.data()] { + for (actual, expected) in values.iter().zip(&expected_data) { + assert!( + (actual - expected).norm() < 1e-12, + "{actual:?} != {expected:?}" + ); + } + } + let mut inverse = Circuit::from_qubits(qubits.to_vec()).unwrap(); + inverse + .multi_control( + StandardGate::GPhase, + controls.clone(), + Vec::::new(), + [(-theta).into()], + ) + .unwrap(); + incremental.apply_circuit(&inverse).unwrap(); + let baseline = DensityMatrix::from_circuit(&preparation).unwrap(); + for (actual, expected) in incremental.data().iter().zip(baseline.data()) { + assert!((actual - expected).norm() < 1e-12); + } + } + } + } +} + +#[test] +fn zero_control_gphase_circuit_evolution() { + use crate::circuit::gate::{Instruction, MCGate}; + for num_qubits in [0, 1, 3] { + for theta in [0.0, 0.37, -0.51, PI] { + let mut circuit = Circuit::new(num_qubits); + if num_qubits > 0 { + circuit.h(Qubit::new(0)).unwrap(); + } + circuit + .append( + Instruction::McGate(Box::new(MCGate::new(0, StandardGate::GPhase))), + Vec::::new(), + [theta.into()], + None, + ) + .unwrap(); + let actual = DensityMatrix::from_circuit(&circuit).unwrap(); + let mut amplitudes = vec![Complex64::new(0.0, 0.0); 1 << num_qubits]; + amplitudes[0] = Complex64::from_polar(1.0, theta); + if num_qubits > 0 { + amplitudes[0] /= 2.0_f64.sqrt(); + amplitudes[1] = amplitudes[0]; + } + let expected = amplitudes + .iter() + .flat_map(|a| amplitudes.iter().map(move |b| a * b.conj())) + .collect::>(); + for (actual, expected) in actual.data().iter().zip(&expected) { + assert!((actual - expected).norm() < 1e-12); + } + } + } +} diff --git a/crates/cqlib-core/src/qis/state/stabilizer.rs b/crates/cqlib-core/src/qis/state/stabilizer.rs index c62ec501..99d4640b 100644 --- a/crates/cqlib-core/src/qis/state/stabilizer.rs +++ b/crates/cqlib-core/src/qis/state/stabilizer.rs @@ -1662,20 +1662,16 @@ impl StabilizerState { Instruction::Standard(gate) => { state.apply_clifford_gate(*gate, &qubit_indices, ¶ms)?; } - Instruction::McGate(mc) if mc.num_ctrl_qubits() == 1 => { - let ctrl = qubit_indices[0]; - let tgt = qubit_indices[1]; - match mc.base_gate() { - StandardGate::X => state.apply_cx(ctrl, tgt)?, - StandardGate::Y => state.apply_cy(ctrl, tgt)?, - StandardGate::Z => state.apply_cz(ctrl, tgt)?, - g => { - return Err(QisError::UnsupportedOperation(format!( - "Controlled-{g} is not a Clifford gate" - ))); - } + Instruction::McGate(mc) if mc.num_ctrl_qubits() == 1 => match mc.base_gate() { + StandardGate::X => state.apply_cx(qubit_indices[0], qubit_indices[1])?, + StandardGate::Y => state.apply_cy(qubit_indices[0], qubit_indices[1])?, + StandardGate::Z => state.apply_cz(qubit_indices[0], qubit_indices[1])?, + g => { + return Err(QisError::UnsupportedOperation(format!( + "Controlled-{g} is not supported in stabilizer simulation" + ))); } - } + }, Instruction::McGate(mc) => { return Err(QisError::UnsupportedOperation(format!( "{}-controlled gates are not supported in stabilizer simulation", diff --git a/crates/cqlib-core/src/qis/state/stabilizer_test.rs b/crates/cqlib-core/src/qis/state/stabilizer_test.rs index 2633a1d2..8876a098 100644 --- a/crates/cqlib-core/src/qis/state/stabilizer_test.rs +++ b/crates/cqlib-core/src/qis/state/stabilizer_test.rs @@ -1434,3 +1434,58 @@ fn test_apply_circuit_rejects_classical_control_flow() { let error = StabilizerState::run_circuit(&c).unwrap_err(); assert!(matches!(error, QisError::UnsupportedOperation(_))); } + +#[test] +fn controlled_gphase_returns_unsupported_without_panicking() { + use crate::circuit::{Circuit, StandardGate}; + for theta in [0.0, 0.37, std::f64::consts::FRAC_PI_2] { + let mut circuit = Circuit::from_qubits(vec![Qubit::new(12)]).unwrap(); + circuit + .multi_control( + StandardGate::GPhase, + [Qubit::new(12)], + Vec::::new(), + [theta.into()], + ) + .unwrap(); + let mut state = StabilizerState::new(1); + for result in [ + StabilizerState::from_circuit(&circuit).map(|_| ()), + state.apply_circuit(&circuit), + StabilizerState::run_circuit(&circuit).map(|_| ()), + ] { + assert!( + matches!(result, Err(QisError::UnsupportedOperation(ref message)) + if message.contains("Controlled-GPhase") && message.contains("not supported")), + "unexpected result: {result:?}" + ); + } + } +} + +#[test] +fn controlled_paulis_match_standard_stabilizer_execution() { + use crate::circuit::{Circuit, StandardGate}; + for gate in [StandardGate::X, StandardGate::Y, StandardGate::Z] { + let mut circuit = Circuit::new(2); + circuit.h(Qubit::new(0)).unwrap(); + circuit.h(Qubit::new(1)).unwrap(); + let mut expected = StabilizerState::from_circuit(&circuit).unwrap(); + match gate { + StandardGate::X => expected.apply_cx(0, 1).unwrap(), + StandardGate::Y => expected.apply_cy(0, 1).unwrap(), + StandardGate::Z => expected.apply_cz(0, 1).unwrap(), + _ => unreachable!(), + } + circuit + .multi_control(gate, [Qubit::new(0)], [Qubit::new(1)], []) + .unwrap(); + for actual in [ + StabilizerState::from_circuit(&circuit).unwrap(), + StabilizerState::run_circuit(&circuit).unwrap().state, + ] { + assert_eq!(&*actual.tableau, &*expected.tableau); + assert_eq!(actual.phases, expected.phases); + } + } +} diff --git a/crates/cqlib-core/src/qis/state/statevector.rs b/crates/cqlib-core/src/qis/state/statevector.rs index 9b4f85da..21539d5e 100644 --- a/crates/cqlib-core/src/qis/state/statevector.rs +++ b/crates/cqlib-core/src/qis/state/statevector.rs @@ -576,44 +576,31 @@ impl Statevector { let num_controls = mc_gate.num_ctrl_qubits(); let base_gate = mc_gate.base_gate(); - if num_controls == 1 { - // Single-controlled gate - let control = qubit_indices[0]; - let target = qubit_indices[1]; - match base_gate { - StandardGate::X => sv.apply_cx(control, target)?, - StandardGate::Y => sv.apply_cy(control, target)?, - StandardGate::Z => sv.apply_cz(control, target)?, - StandardGate::RX => { - let theta = params[0]; - sv.apply_crx(control, target, theta)?; - } - StandardGate::RY => { - let theta = params[0]; - sv.apply_cry(control, target, theta)?; - } - StandardGate::RZ => { - let theta = params[0]; - sv.apply_crz(control, target, theta)?; - } - _ => { - let matrix = mc_gate.matrix(¶ms).map_err(|_| { - QisError::CircuitError(CircuitError::NoMatrixRepresentation) - })?; - sv.apply_unitary_gate(&qubit_indices, &matrix)?; - } + match (num_controls, *base_gate) { + (0, StandardGate::GPhase) => { + sv.apply_standard_gate(*base_gate, &qubit_indices, ¶ms)? + } + (1, StandardGate::X) => sv.apply_cx(qubit_indices[0], qubit_indices[1])?, + (1, StandardGate::Y) => sv.apply_cy(qubit_indices[0], qubit_indices[1])?, + (1, StandardGate::Z) => sv.apply_cz(qubit_indices[0], qubit_indices[1])?, + (1, StandardGate::RX) => { + sv.apply_crx(qubit_indices[0], qubit_indices[1], params[0])? + } + (1, StandardGate::RY) => { + sv.apply_cry(qubit_indices[0], qubit_indices[1], params[0])? + } + (1, StandardGate::RZ) => { + sv.apply_crz(qubit_indices[0], qubit_indices[1], params[0])? + } + (2, StandardGate::X) => { + sv.apply_ccx(qubit_indices[0], qubit_indices[1], qubit_indices[2])? + } + _ => { + let matrix = mc_gate.matrix(¶ms).map_err(|_| { + QisError::CircuitError(CircuitError::NoMatrixRepresentation) + })?; + sv.apply_unitary_gate(&qubit_indices, &matrix)?; } - } else if num_controls == 2 && *base_gate == StandardGate::X { - // Toffoli gate - let c0 = qubit_indices[0]; - let c1 = qubit_indices[1]; - let target = qubit_indices[2]; - sv.apply_ccx(c0, c1, target)?; - } else { - let matrix = mc_gate.matrix(¶ms).map_err(|_| { - QisError::CircuitError(CircuitError::NoMatrixRepresentation) - })?; - sv.apply_unitary_gate(&qubit_indices, &matrix)?; } } Instruction::UnitaryGate(u_gate) => { diff --git a/crates/cqlib-core/src/qis/state/statevector_test.rs b/crates/cqlib-core/src/qis/state/statevector_test.rs index 28f9d8a3..83fc75d1 100644 --- a/crates/cqlib-core/src/qis/state/statevector_test.rs +++ b/crates/cqlib-core/src/qis/state/statevector_test.rs @@ -2281,3 +2281,117 @@ fn test_apply_pauli_rotation_rejects_imaginary_global_phase() { pauli.phase = Phase::I; assert!(state.apply_pauli_rotation(&pauli, 0.2).is_err()); } + +#[test] +fn controlled_gphase_circuit_evolution() { + for ids in [[0, 1, 2], [12, 5, 31]] { + let qubits = ids.map(Qubit::new); + for controls in [ + vec![qubits[2]], + vec![qubits[2], qubits[0]], + vec![qubits[0], qubits[2]], + ] { + for theta in [0.0, 0.37, -0.51, PI] { + let mut preparation = Circuit::from_qubits(qubits.to_vec()).unwrap(); + for qubit in qubits { + preparation.h(qubit).unwrap(); + } + preparation.s(qubits[1]).unwrap(); + let mut phase = Circuit::from_qubits(qubits.to_vec()).unwrap(); + phase + .multi_control( + StandardGate::GPhase, + controls.clone(), + Vec::::new(), + [theta.into()], + ) + .unwrap(); + let mut circuit = preparation.clone(); + circuit + .multi_control( + StandardGate::GPhase, + controls.clone(), + Vec::::new(), + [theta.into()], + ) + .unwrap(); + let mask = controls.iter().fold(0usize, |mask, q| { + mask | (1 << qubits.iter().position(|candidate| candidate == q).unwrap()) + }); + let expected: Vec = (0..8) + .map(|index| { + let amplitude = if index & 2 == 0 { + Complex64::new(1.0, 0.0) + } else { + Complex64::new(0.0, 1.0) + } / 8.0_f64.sqrt(); + if index & mask == mask { + amplitude * Complex64::from_polar(1.0, theta) + } else { + amplitude + } + }) + .collect(); + let expected_data = expected.clone(); + let actual = Statevector::from_circuit(&circuit).unwrap(); + let mut incremental = Statevector::new(3); + incremental.apply_circuit(&preparation).unwrap(); + incremental.apply_circuit(&phase).unwrap(); + for values in [actual.data(), incremental.data()] { + for (actual, expected) in values.iter().zip(&expected_data) { + assert!( + (actual - expected).norm() < 1e-12, + "{actual:?} != {expected:?}" + ); + } + } + let mut inverse = Circuit::from_qubits(qubits.to_vec()).unwrap(); + inverse + .multi_control( + StandardGate::GPhase, + controls.clone(), + Vec::::new(), + [(-theta).into()], + ) + .unwrap(); + incremental.apply_circuit(&inverse).unwrap(); + let baseline = Statevector::from_circuit(&preparation).unwrap(); + for (actual, expected) in incremental.data().iter().zip(baseline.data()) { + assert!((actual - expected).norm() < 1e-12); + } + } + } + } +} + +#[test] +fn zero_control_gphase_circuit_evolution() { + use crate::circuit::gate::{Instruction, MCGate}; + for num_qubits in [0, 1, 3] { + for theta in [0.0, 0.37, -0.51, PI] { + let mut circuit = Circuit::new(num_qubits); + if num_qubits > 0 { + circuit.h(Qubit::new(0)).unwrap(); + } + circuit + .append( + Instruction::McGate(Box::new(MCGate::new(0, StandardGate::GPhase))), + Vec::::new(), + [theta.into()], + None, + ) + .unwrap(); + let actual = Statevector::from_circuit(&circuit).unwrap(); + let mut amplitudes = vec![Complex64::new(0.0, 0.0); 1 << num_qubits]; + amplitudes[0] = Complex64::from_polar(1.0, theta); + if num_qubits > 0 { + amplitudes[0] /= 2.0_f64.sqrt(); + amplitudes[1] = amplitudes[0]; + } + let expected = amplitudes; + for (actual, expected) in actual.data().iter().zip(&expected) { + assert!((actual - expected).norm() < 1e-12); + } + } + } +} From 458e51aef3da737f176686b3d6d167d0ab428898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Sat, 17 Oct 2026 12:41:41 +0800 Subject: [PATCH 06/16] fix(qasm3): preserve global phase in nested gate definitions Emit each defining circuit's global phase inside CircuitGate and circuit-backed UnitaryGate definitions so round trips preserve the full unitary, including phase. Add Rust and Python regressions for fixed and symbolic phases, nested and repeated calls, local parameters, and explicit GPhase operations alongside phase metadata. --- .../tests/ir/test_qasm3_gphase.py | 85 +++++++++++++++++++ crates/cqlib-core/src/ir/qasm3/dump.rs | 2 + crates/cqlib-core/src/ir/qasm3/dump_test.rs | 50 +++++++++++ 3 files changed, 137 insertions(+) create mode 100644 crates/binding-python/tests/ir/test_qasm3_gphase.py diff --git a/crates/binding-python/tests/ir/test_qasm3_gphase.py b/crates/binding-python/tests/ir/test_qasm3_gphase.py new file mode 100644 index 00000000..dca06a10 --- /dev/null +++ b/crates/binding-python/tests/ir/test_qasm3_gphase.py @@ -0,0 +1,85 @@ +# This code is part of Cqlib. +# +# (C) Copyright China Telecom Quantum Group 2026 +# +# Licensed under the Apache License, Version 2.0. See LICENSE.txt or +# http://www.apache.org/licenses/LICENSE-2.0. + +"""Global phase must survive serialization of reusable gate definitions.""" + +import numpy as np +import pytest + +from cqlib import Circuit, Parameter +from cqlib.circuit import UnitaryGate +from cqlib.circuit.gates import GPhase +from cqlib.ir import qasm3 +from cqlib.qis import Statevector + + +def assert_round_trip(circuit, expected, bindings=None): + output = qasm3.dumps(circuit) + loaded = qasm3.loads(output) + original = circuit.assign_parameters(bindings) if bindings else circuit + loaded = loaded.assign_parameters(bindings) if bindings else loaded + for candidate in [original, loaded]: + np.testing.assert_allclose(candidate.to_matrix(), expected, rtol=0, atol=1e-12) + np.testing.assert_allclose( + Statevector.from_circuit(candidate).data, + expected[:, 0], + rtol=0, + atol=1e-12, + ) + return output + + +@pytest.mark.parametrize("symbolic", [False, True]) +@pytest.mark.parametrize("explicit_phase", [False, True]) +@pytest.mark.parametrize("nested", [False, True]) +def test_nested_gate_global_phase_round_trip(symbolic, explicit_phase, nested): + theta = Parameter("theta") if symbolic else 0.37 + inner = Circuit([12]) + inner.h(12) + inner.set_global_phase(theta) + if explicit_phase: + inner.append_gate(GPhase(-0.21), []) + gate = inner.to_gate("fragment") + if nested: + middle = Circuit([5]) + middle.set_global_phase(0.11) + middle.append_circuit_gate(gate, [5], [theta] if symbolic else []) + gate = middle.to_gate("middle") + outer = Circuit(1) + outer.set_global_phase(-0.07) + outer.append_circuit_gate(gate, [0], [Parameter("outer_theta")] if symbolic else []) + phase = 0.37 - 0.07 + (0.11 if nested else 0) - (0.21 if explicit_phase else 0) + expected = np.exp(1j * phase) * np.array([[1, 1], [1, -1]]) / np.sqrt(2) + assert_round_trip(outer, expected, {"outer_theta": 0.37} if symbolic else None) + + +def test_phase_only_parameter_and_repeated_gate_calls(): + inner = Circuit(1) + inner.h(0) + inner.set_global_phase(Parameter("theta")) + gate = inner.to_gate("fragment") + outer = Circuit(1) + outer.append_circuit_gate(gate, [0], [0.37]) + outer.append_circuit_gate(gate, [0], [-0.51]) + output = assert_round_trip(outer, np.exp(-0.14j) * np.eye(2)) + assert output.count("gphase(theta);") == 1 + + +def test_unitary_gate_definition_global_phase_round_trip(): + inner = Circuit(1) + inner.h(0) + inner.set_global_phase(0.37) + inner.append_gate(GPhase(-0.21), []) + expected = np.exp(0.16j) * np.array([[1, 1], [1, -1]]) / np.sqrt(2) + gate = ( + UnitaryGate("phase_h", 1) + .with_matrix(expected) + .with_circuit(inner.to_gate("definition").circuit) + ) + outer = Circuit(1) + outer.append_unitary_gate(gate, [0]) + assert_round_trip(outer, expected) diff --git a/crates/cqlib-core/src/ir/qasm3/dump.rs b/crates/cqlib-core/src/ir/qasm3/dump.rs index 914669cf..7aa5371b 100644 --- a/crates/cqlib-core/src/ir/qasm3/dump.rs +++ b/crates/cqlib-core/src/ir/qasm3/dump.rs @@ -516,6 +516,7 @@ fn dump_gate_definition(gate: &CircuitGate, output: &mut String) -> Result<(), Q for (index, qubit) in gate.circuit.circuit.qubits().iter().copied().enumerate() { qubit_map.insert(qubit, format!("q{index}")); } + dump_global_phase(&gate.circuit.circuit, output, &HashMap::new())?; dump_operations( &gate.circuit.circuit, gate.circuit.circuit.operations(), @@ -552,6 +553,7 @@ fn dump_unitary_gate_definition( for (index, qubit) in frozen.circuit.qubits().iter().copied().enumerate() { qubit_map.insert(qubit, format!("q{index}")); } + dump_global_phase(&frozen.circuit, output, &HashMap::new())?; dump_operations( &frozen.circuit, frozen.circuit.operations(), diff --git a/crates/cqlib-core/src/ir/qasm3/dump_test.rs b/crates/cqlib-core/src/ir/qasm3/dump_test.rs index 9b93c967..ddd548d5 100644 --- a/crates/cqlib-core/src/ir/qasm3/dump_test.rs +++ b/crates/cqlib-core/src/ir/qasm3/dump_test.rs @@ -862,3 +862,53 @@ fn export_preserves_indexed_constants_and_symbol_substitution() { "(angle0)" ); } + +#[test] +fn nested_gate_global_phase_round_trip() { + let circuit = qasm3_loads( + r#" + OPENQASM 3; + include "stdgates.inc"; + gate fragment(theta) q { gphase(theta); h q; } + gate middle(alpha) q { gphase(0.11); fragment(alpha) q; } + qubit q; + middle(0.37) q; + middle(-0.51) q; + "#, + ) + .unwrap(); + let output = dumps(&circuit).unwrap(); + let loaded = qasm3_loads(&output).unwrap(); + let expected = num_complex::Complex64::from_polar(1.0, 0.08); + for ((row, col), actual) in loaded.to_matrix(None).unwrap().indexed_iter() { + let reference = if row == col { + expected + } else { + num_complex::Complex64::new(0.0, 0.0) + }; + assert!((*actual - reference).norm() < 1e-12, "{output}"); + } + assert!(output.contains("gphase(theta);"), "{output}"); + assert!(output.contains("gphase(0.11);"), "{output}"); +} + +#[test] +fn unitary_definition_global_phase_round_trip() { + use crate::circuit::gate::UnitaryGate; + let mut inner = Circuit::new(1); + inner.h(Qubit::new(0)).unwrap(); + inner.set_global_phase(0.37.into()); + let expected = inner.to_matrix(None).unwrap(); + let gate = UnitaryGate::new("phase_h", 1, 0) + .with_matrix(expected.clone()) + .unwrap() + .with_circuit(Arc::new(FrozenCircuit::new(inner))) + .unwrap(); + let mut circuit = Circuit::new(1); + circuit.unitary(gate, vec![Qubit::new(0)]).unwrap(); + let output = dumps(&circuit).unwrap(); + let loaded = qasm3_loads(&output).unwrap(); + for (actual, expected) in loaded.to_matrix(None).unwrap().iter().zip(expected.iter()) { + assert!((actual - expected).norm() < 1e-12, "{output}"); + } +} From 015572bc045f8c13ab8bca7a46124235dca08156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Sat, 17 Oct 2026 18:40:42 +0800 Subject: [PATCH 07/16] fix(qis): reject duplicate density matrix targets before mutation Validate distinct targets in the shared two-qubit and multi-qubit checks before running gate kernels, clearing Kraus output, or applying noise. Reuse the existing invalid-parameter error and include the duplicate index in the diagnostic. Add Rust regressions for gate, custom-matrix, Kraus, and noisy paths that verify rejection leaves the full density matrix unchanged, including middle-qubit targets. Validation: 134 density-matrix-related Rust tests passed. The preceding full workspace run passed 4080 tests; the production fix is unchanged since that run. --- .../src/qis/state/density_matrix.rs | 16 +++- .../qis/state/density_matrix_noise_test.rs | 31 +++++++ .../src/qis/state/density_matrix_test.rs | 92 +++++++++++++++++++ 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/crates/cqlib-core/src/qis/state/density_matrix.rs b/crates/cqlib-core/src/qis/state/density_matrix.rs index 35280102..904e9f9a 100644 --- a/crates/cqlib-core/src/qis/state/density_matrix.rs +++ b/crates/cqlib-core/src/qis/state/density_matrix.rs @@ -151,7 +151,7 @@ impl DensityMatrix { Ok(()) } - /// Validates that two qubit indices are within bounds. + /// Validates that two qubit indices are within bounds and distinct. #[inline] fn validate_two_qubits(&self, q0: usize, q1: usize) -> Result<(), QisError> { if self.num_qubits == 0 { @@ -171,10 +171,15 @@ impl DensityMatrix { max: self.num_qubits.saturating_sub(1), }); } + if q0 == q1 { + return Err(QisError::InvalidParameterValue(format!( + "Duplicate qubit index {q0} in gate operation" + ))); + } Ok(()) } - /// Validates that all qubit indices in a slice are within bounds. + /// Validates that all qubit indices in a slice are within bounds and distinct. #[inline] fn validate_qubits(&self, qubits: &[usize]) -> Result<(), QisError> { if self.num_qubits == 0 { @@ -190,6 +195,13 @@ impl DensityMatrix { }); } } + for (i, &q) in qubits.iter().enumerate() { + if qubits[..i].contains(&q) { + return Err(QisError::InvalidParameterValue(format!( + "Duplicate qubit index {q} in gate operation" + ))); + } + } Ok(()) } diff --git a/crates/cqlib-core/src/qis/state/density_matrix_noise_test.rs b/crates/cqlib-core/src/qis/state/density_matrix_noise_test.rs index cb801f01..75e89fc2 100644 --- a/crates/cqlib-core/src/qis/state/density_matrix_noise_test.rs +++ b/crates/cqlib-core/src/qis/state/density_matrix_noise_test.rs @@ -19,6 +19,37 @@ use ndarray::array; use num_complex::Complex64; use std::f64::consts::PI; +#[test] +fn duplicate_targets_leave_noisy_density_matrix_unchanged() { + let mut model = NoiseModel::new(); + model + .add_two_qubit_error( + StandardGate::CX, + Qubit::new(0), + Qubit::new(2), + TwoQubitNoise::CorrelatedPauli { + op_q0: Pauli::X, + op_q1: Pauli::X, + p: 0.2, + }, + ) + .unwrap(); + let mut initial = DensityMatrixNoise::new(3, Some(model)); + initial.apply_h(0).unwrap(); + initial.apply_s(0).unwrap(); + for gate in [StandardGate::CX, StandardGate::CZ] { + for q in 0..3 { + let mut sim = initial.clone(); + assert!(matches!( + sim.apply_standard_gate_noise(gate, &[q, q], &[]), + Err(QisError::InvalidParameterValue(msg)) + if msg.contains(&format!("Duplicate qubit index {q} ")) + )); + assert_eq!(sim.state.data(), initial.state.data()); + } + } +} + #[test] fn test_bit_flip_noise() { let mut noise_model = NoiseModel::new(); diff --git a/crates/cqlib-core/src/qis/state/density_matrix_test.rs b/crates/cqlib-core/src/qis/state/density_matrix_test.rs index 9192b901..95e89ce8 100644 --- a/crates/cqlib-core/src/qis/state/density_matrix_test.rs +++ b/crates/cqlib-core/src/qis/state/density_matrix_test.rs @@ -408,6 +408,98 @@ fn test_zeros_and_add_assign() { assert_relative_eq!(dm1.probabilities()[1], 0.5); } +#[test] +fn duplicate_two_qubit_targets_leave_density_matrix_unchanged() { + let mut initial = DensityMatrix::new(3); + initial.apply_h(0).unwrap(); + initial.apply_ry(1, 0.37).unwrap(); + initial.apply_s(0).unwrap(); + for gate in [ + StandardGate::CX, + StandardGate::CZ, + StandardGate::CY, + StandardGate::SWAP, + StandardGate::RXX, + StandardGate::RYY, + StandardGate::RZZ, + StandardGate::RZX, + StandardGate::CRX, + StandardGate::CRY, + StandardGate::CRZ, + StandardGate::FSIM, + ] { + for q in 0..3 { + let mut dm = initial.clone(); + let result = dm.apply_standard_gate(gate, &[q, q], &vec![0.37; gate.num_params()]); + assert!(matches!(result, Err(QisError::InvalidParameterValue(msg)) + if msg.contains(&format!("Duplicate qubit index {q} ")))); + assert_eq!(dm.data(), initial.data(), "{gate:?} on [{q}, {q}]"); + } + } + for targets in [[0, 0, 2], [0, 2, 0], [2, 0, 0]] { + let mut dm = initial.clone(); + assert!(matches!( + dm.apply_ccx(targets[0], targets[1], targets[2]), + Err(QisError::InvalidParameterValue(msg)) if msg.contains("Duplicate qubit index 0 ") + )); + assert_eq!(dm.data(), initial.data()); + } +} + +#[test] +fn duplicate_custom_gate_targets_leave_density_matrix_unchanged() { + let mut dm = DensityMatrix::new(3); + dm.apply_h(0).unwrap(); + dm.apply_s(0).unwrap(); + let before = dm.data().to_vec(); + let matrix = StandardGate::CX.matrix(&[]).unwrap(); + let matrix = std::array::from_fn(|r| std::array::from_fn(|c| matrix[[r, c]])); + for q in 0..3 { + assert!(matches!( + dm.apply_two_qubit_gate(q, q, matrix), + Err(QisError::InvalidParameterValue(msg)) + if msg.contains(&format!("Duplicate qubit index {q} ")) + )); + assert_eq!(dm.data(), before); + } +} + +#[test] +fn duplicate_kraus_targets_leave_density_matrix_unchanged() { + let amplitudes: Vec<_> = (1..=8) + .map(|i| Complex64::new(i as f64 / 204.0_f64.sqrt(), 0.0)) + .collect(); + let initial = DensityMatrix::from_state(3, amplitudes).unwrap(); + for (targets, duplicate) in [ + (&[0, 0][..], 0), + (&[1, 1][..], 1), + (&[2, 2][..], 2), + (&[0, 2, 0][..], 0), + (&[0, 0, 2][..], 0), + (&[2, 0, 0][..], 0), + ] { + let dim = 1 << targets.len(); + let norm: f64 = (1..=dim).map(|i| (i * i) as f64).sum(); + let operator = (0..dim) + .flat_map(|r| { + (0..dim).map(move |c| { + Complex64::new( + if r == c { 1.0 } else { 0.0 } - 2.0 * ((r + 1) * (c + 1)) as f64 / norm, + 0.0, + ) + }) + }) + .collect(); + let mut dm = initial.clone(); + assert!(matches!( + dm.apply_kraus(&[operator], targets), + Err(QisError::InvalidParameterValue(msg)) + if msg.contains(&format!("Duplicate qubit index {duplicate} ")) + )); + assert_eq!(dm.data(), initial.data()); + } +} + #[test] fn test_single_qubit_gates() { let mut dm = DensityMatrix::new(1); From 6be1c8cad02918911e3772981130d1fc167d272e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Sat, 17 Oct 2026 19:01:50 +0800 Subject: [PATCH 08/16] test(bindings): cover duplicate density matrix targets in Python and C Verify Python raises ValueError and the C ABI returns -8 for duplicate targets, with the complete density matrix unchanged. Compare valid ordered-target results against independently constructed full-matrix expectations. Include the standalone C host test in staged SDKs and register static and shared CTest suites. Update the SDK documentation to describe the additional coverage. Validation: 103 Python regressions passed; development and release SDK workflows each passed all 6 CTests. Workspace Clippy and formatting checks passed. --- crates/binding-c/tests/qis_test.rs | 55 +++++++ .../tests/test_density_matrix_targets.c | 62 ++++++++ .../tests/qis/test_density_matrix_targets.py | 141 ++++++++++++++++++ tools/c-sdk/CMakeLists.txt | 2 +- tools/c-sdk/README.md | 6 +- tools/ci.py | 6 +- 6 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 crates/binding-c/tests/test_density_matrix_targets.c create mode 100644 crates/binding-python/tests/qis/test_density_matrix_targets.py diff --git a/crates/binding-c/tests/qis_test.rs b/crates/binding-c/tests/qis_test.rs index 05e2174d..ddc13132 100644 --- a/crates/binding-c/tests/qis_test.rs +++ b/crates/binding-c/tests/qis_test.rs @@ -165,6 +165,61 @@ fn test_density_matrix_partial_trace() { density_matrix_free(dm); } +#[test] +fn density_matrix_duplicate_gate_targets_preserve_state() { + let dm = density_matrix_new(2); + assert!(!dm.is_null()); + assert_eq!(density_matrix_apply_h(dm, 0), 0); + let mut before = vec![num_complex::Complex64::default(); 16]; + assert_eq!(density_matrix_data(dm, before.as_mut_ptr(), 16), 0); + for q in [0, 1] { + assert_eq!(density_matrix_apply_cx(dm, q, q), -8); + assert_eq!(density_matrix_apply_cz(dm, q, q), -8); + let mut after = before.clone(); + assert_eq!(density_matrix_data(dm, after.as_mut_ptr(), 16), 0); + assert_eq!(after, before); + } + assert_eq!(density_matrix_apply_cx(dm, 0, 1), 0); + density_matrix_free(dm); +} + +#[test] +fn density_matrix_duplicate_kraus_targets_preserve_state() { + let state: Vec<_> = (1..=4) + .map(|i| num_complex::Complex64::new(i as f64 / 30.0_f64.sqrt(), 0.0)) + .collect(); + let dm = density_matrix_from_state(2, state.as_ptr(), state.len()); + assert!(!dm.is_null()); + let vector = [4.0, -1.0, 2.0, 3.0]; + let operator: Vec<_> = (0..4) + .flat_map(|r| { + (0..4).map(move |c| { + num_complex::Complex64::new( + if r == c { 1.0 } else { 0.0 } - 2.0 * vector[r] * vector[c] / 30.0, + 0.0, + ) + }) + }) + .collect(); + let mut before = vec![num_complex::Complex64::default(); 16]; + assert_eq!(density_matrix_data(dm, before.as_mut_ptr(), 16), 0); + for q in [0, 1] { + let targets = [q, q]; + assert_eq!( + density_matrix_apply_kraus(dm, operator.as_ptr(), 1, 4, targets.as_ptr(), 2), + -8 + ); + let mut after = before.clone(); + assert_eq!(density_matrix_data(dm, after.as_mut_ptr(), 16), 0); + assert_eq!(after, before); + } + assert_eq!( + density_matrix_apply_kraus(dm, operator.as_ptr(), 1, 4, [0, 1].as_ptr(), 2), + 0 + ); + density_matrix_free(dm); +} + #[test] fn test_stabilizer_new_and_clifford() { let ss = stabilizer_new(2); diff --git a/crates/binding-c/tests/test_density_matrix_targets.c b/crates/binding-c/tests/test_density_matrix_targets.c new file mode 100644 index 00000000..342c8401 --- /dev/null +++ b/crates/binding-c/tests/test_density_matrix_targets.c @@ -0,0 +1,62 @@ +// This code is part of Cqlib. +// +// (C) Copyright China Telecom Quantum Group 2026 +// +// Licensed under the Apache License, Version 2.0. See LICENSE.txt or +// http://www.apache.org/licenses/LICENSE-2.0. + +#include +#include +#include +#include + +#include "cqlib_c.h" + +int main(void) { + Complex64 amplitudes[4]; + Complex64 operator[16]; + const double vector[4] = {4.0, -1.0, 2.0, 3.0}; + for (int i = 0; i < 4; ++i) { + amplitudes[i] = (Complex64){(i + 1) / sqrt(30.0), 0.0}; + for (int j = 0; j < 4; ++j) { + operator[4 * i + j] = + (Complex64){(i == j ? 1.0 : 0.0) - 2.0 * vector[i] * vector[j] / 30.0, 0.0}; + } + } + CDensityMatrix* dm = density_matrix_from_state(2, amplitudes, 4); + assert(dm != NULL); + Complex64 before[16], after[16]; + assert(density_matrix_data(dm, before, 16) == 0); + for (uint32_t q = 0; q < 2; ++q) { + const uint32_t targets[2] = {q, q}; + /* Check Kraus first: a panic must fail the host-process test. */ + assert(density_matrix_apply_kraus(dm, operator, 1, 4, targets, 2) == -8); + assert(density_matrix_data(dm, after, 16) == 0); + assert(memcmp(before, after, sizeof(before)) == 0); + assert(density_matrix_apply_cx(dm, q, q) == -8); + assert(density_matrix_apply_cz(dm, q, q) == -8); + assert(density_matrix_data(dm, after, 16) == 0); + assert(memcmp(before, after, sizeof(before)) == 0); + } + /* A valid channel must still work after the rejected operations. */ + const uint32_t targets[2] = {0, 1}; + assert(density_matrix_apply_kraus(dm, operator, 1, 4, targets, 2) == 0); + assert(density_matrix_data(dm, after, 16) == 0); + double evolved[4] = {0}; + for (int i = 0; i < 4; ++i) { + for (int j = 0; j < 4; ++j) { + evolved[i] += operator[4 * i + j].re * amplitudes[((j & 1) << 1) | (j >> 1)].re; + } + } + for (int i = 0; i < 4; ++i) { + for (int j = 0; j < 4; ++j) { + const int row = ((i & 1) << 1) | (i >> 1); + const int col = ((j & 1) << 1) | (j >> 1); + assert(fabs(after[4 * row + col].re - evolved[i] * evolved[j]) < 1e-12); + assert(fabs(after[4 * row + col].im) < 1e-12); + } + } + density_matrix_free(dm); + puts("density matrix target validation passed"); + return 0; +} diff --git a/crates/binding-python/tests/qis/test_density_matrix_targets.py b/crates/binding-python/tests/qis/test_density_matrix_targets.py new file mode 100644 index 00000000..061f03fc --- /dev/null +++ b/crates/binding-python/tests/qis/test_density_matrix_targets.py @@ -0,0 +1,141 @@ +# This code is part of Cqlib. +# +# (C) Copyright China Telecom Quantum Group 2026 +# +# Licensed under the Apache License, Version 2.0. See LICENSE.txt or +# http://www.apache.org/licenses/LICENSE-2.0. + +"""Target validation must precede any density matrix mutation.""" + +import numpy as np +import pytest + +from cqlib.qis import DensityMatrix, DensityMatrixNoise + + +def initial_state(): + amplitudes = np.arange(1, 9) + 1j * np.arange(8, 0, -1) + return amplitudes / np.linalg.norm(amplitudes) + + +def householder(dim): + vector = np.arange(1, dim + 1, dtype=float) + return (np.eye(dim) - 2 * np.outer(vector, vector) / (vector @ vector)).astype( + complex + ) + + +def state_data(state): + return state.state if isinstance(state, DensityMatrixNoise) else state.data + + +@pytest.mark.parametrize("simulator", [DensityMatrix, DensityMatrixNoise]) +@pytest.mark.parametrize("target", [0, 1, 2]) +@pytest.mark.parametrize( + "method,params", + [ + ("apply_cx", []), + ("apply_cz", []), + ("apply_cy", []), + ("apply_swap", []), + ("apply_rxx", [0.37]), + ("apply_ryy", [0.37]), + ("apply_rzz", [0.37]), + ("apply_rzx", [0.37]), + ("apply_crx", [0.37]), + ("apply_cry", [0.37]), + ("apply_crz", [0.37]), + ("apply_fsim", [0.37, -0.21]), + ], +) +def test_duplicate_two_qubit_targets(simulator, target, method, params): + state = simulator(3) + state.apply_h(0) + state.apply_s(0) + before = state_data(state).copy() + with pytest.raises(ValueError, match=rf"(?i)duplicate qubit index {target}\b"): + getattr(state, method)(target, target, *params) + np.testing.assert_array_equal(state_data(state), before) + + +@pytest.mark.parametrize("simulator", [DensityMatrix, DensityMatrixNoise]) +@pytest.mark.parametrize("targets", [(0, 0, 2), (0, 2, 0), (2, 0, 0)]) +def test_duplicate_ccx_targets(simulator, targets): + state = simulator(3) + state.apply_h(0) + before = state_data(state).copy() + with pytest.raises(ValueError, match=r"(?i)duplicate qubit index 0\b"): + state.apply_ccx(*targets) + np.testing.assert_array_equal(state_data(state), before) + + +@pytest.mark.parametrize("target", [0, 1, 2]) +def test_duplicate_custom_gate_targets(target): + state = DensityMatrix.from_state(3, initial_state()) + before = state.data.copy() + with pytest.raises(ValueError, match=rf"(?i)duplicate qubit index {target}\b"): + state.apply_double_qubits_gate(target, target, householder(4)) + np.testing.assert_array_equal(state.data, before) + + +@pytest.mark.parametrize( + "targets,duplicate", + [ + ([0, 0], 0), + ([1, 1], 1), + ([2, 2], 2), + ([0, 2, 0], 0), + ([0, 0, 2], 0), + ([2, 0, 0], 0), + ], +) +def test_duplicate_kraus_targets(targets, duplicate): + state = DensityMatrix.from_state(3, initial_state()) + before = state.data.copy() + with pytest.raises(ValueError, match=rf"(?i)duplicate qubit index {duplicate}\b"): + state.apply_kraus(targets, [householder(1 << len(targets)).ravel()]) + np.testing.assert_array_equal(state.data, before) + + +def embed_operator(operator, targets, num_qubits): + """Build the full operator independently, preserving target argument order.""" + dim = 1 << num_qubits + result = np.zeros((dim, dim), dtype=complex) + mask = sum(1 << target for target in targets) + for column in range(dim): + local_column = 0 + for target in targets: + local_column = (local_column << 1) | ((column >> target) & 1) + for local_row in range(1 << len(targets)): + row = column & ~mask + for offset, target in enumerate(reversed(targets)): + row |= ((local_row >> offset) & 1) << target + result[row, column] = operator[local_row, local_column] + return result + + +@pytest.mark.parametrize("targets", [[0, 1], [1, 0], [0, 2], [2, 0]]) +@pytest.mark.parametrize("method", ["custom", "kraus", "unitary", "mixed_channel"]) +def test_distinct_targets_preserve_matrix_order(targets, method): + amplitudes = initial_state() + state = DensityMatrix.from_state(3, amplitudes) + operator = householder(4) + full_operator = embed_operator(operator, targets, 3) + evolved = full_operator @ amplitudes + expected = np.outer(evolved, evolved.conj()) + if method == "custom": + state.apply_double_qubits_gate(*targets, operator) + elif method == "unitary": + state.apply_unitary_gate(targets, operator) + elif method == "kraus": + state.apply_kraus(targets, [operator.ravel()]) + else: + state.apply_kraus( + targets, + [ + np.sqrt(0.7) * operator.ravel(), + np.sqrt(0.3) * np.eye(4, dtype=complex).ravel(), + ], + ) + expected = 0.7 * expected + 0.3 * np.outer(amplitudes, amplitudes.conj()) + np.testing.assert_allclose(state.data, expected, rtol=0, atol=1e-12) diff --git a/tools/c-sdk/CMakeLists.txt b/tools/c-sdk/CMakeLists.txt index 4fc74dd4..3461149d 100644 --- a/tools/c-sdk/CMakeLists.txt +++ b/tools/c-sdk/CMakeLists.txt @@ -28,7 +28,7 @@ else() endif() foreach(kind static shared) - foreach(suite circuit qis_algebra) + foreach(suite circuit qis_algebra density_matrix_targets) set(test_target test_${suite}_${kind}) add_executable(${test_target} test_${suite}.c) set_target_properties(${test_target} PROPERTIES C_STANDARD 11 C_STANDARD_REQUIRED YES) diff --git a/tools/c-sdk/README.md b/tools/c-sdk/README.md index ae465515..64fbe60a 100644 --- a/tools/c-sdk/README.md +++ b/tools/c-sdk/README.md @@ -56,8 +56,10 @@ directory is needed by SDK consumers. Circuit construction, parameters, fermionic algebra, Jordan-Wigner mapping and checked sparse Pauli algebra are documented in the generated header and -illustrated in `tests/test_circuit.c` and `tests/test_qis_algebra.c`. Both suites -run against static and shared libraries. New checked algebra APIs return error +illustrated in `tests/test_circuit.c` and `tests/test_qis_algebra.c`. +`tests/test_density_matrix_targets.c` checks rejection of duplicate targets and +preservation of the density matrix. All three suites run against static and +shared libraries. New checked algebra APIs return error codes and deliver owned results through output pointers; failed calls leave outputs unchanged. Initialize object output slots to NULL and free results with their corresponding functions. diff --git a/tools/ci.py b/tools/ci.py index ad2257ce..fc5cafea 100644 --- a/tools/ci.py +++ b/tools/ci.py @@ -105,7 +105,11 @@ def build_and_test(platform=None): shutil.copy2(ROOT / "LICENSE.txt", sdk) shutil.copy2(ROOT / "tools/c-sdk/README.md", sdk / "README.md") (sdk / "tests").mkdir() - for test in ("test_circuit.c", "test_qis_algebra.c"): + for test in ( + "test_circuit.c", + "test_qis_algebra.c", + "test_density_matrix_targets.c", + ): shutil.copy2(ROOT / "crates/binding-c/tests" / test, sdk / "tests") shutil.copy2(ROOT / "tools/c-sdk/CMakeLists.txt", sdk / "tests") if sys.platform == "darwin": From 3717b4d3dd41e227b5ecff602ac4dd89d58d8810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Sat, 17 Oct 2026 22:34:14 +0800 Subject: [PATCH 09/16] fix(circuit): bind CircuitGate matrix parameters and normalize qubit order Share matrix evaluation between instruction and full-circuit paths, preserving global phase and gate-local argument ordering. Validate parameter arity and finite values without mutating frozen definitions. Add Rust, Python, and C regression coverage for parameter binding, sparse and permuted qubits, nested and inverse gates, and commutation classification. --- crates/binding-c/tests/test_circuit.c | 87 ++++++++++++ .../tests/compile/test_commutation.py | 41 ++++++ .../tests/gates/test_circuit_gate.py | 132 +++++++++++++++++- .../src/circuit/circuit_to_matrix.rs | 25 +--- .../src/circuit/gate/circuit_gate.rs | 114 +++++++++++++++ .../src/circuit/gate/instruction.rs | 15 +- .../src/circuit/gate/instruction_test.rs | 51 ++++++- crates/cqlib-core/src/circuit/operation.rs | 3 + .../compile/commutation/commutation_test.rs | 105 ++++++++++++++ .../src/compile/commutation/matrix.rs | 2 +- 10 files changed, 545 insertions(+), 30 deletions(-) diff --git a/crates/binding-c/tests/test_circuit.c b/crates/binding-c/tests/test_circuit.c index f1f33944..ba48098f 100644 --- a/crates/binding-c/tests/test_circuit.c +++ b/crates/binding-c/tests/test_circuit.c @@ -165,11 +165,98 @@ static void test_extended_gates(void) { circuit_free(circuit); } +static void test_circuit_gate_operation_matrix(void) { + const uint32_t orders[][2] = {{0, 1}, {1, 0}, {7, 2}}; + const uint32_t targets[] = {0, 1}; + for (size_t order = 0; order < 3; ++order) { + for (int parameterized = 0; parameterized < 2; ++parameterized) { + CCircuit* body = circuit_new(0); + assert(body != NULL); + assert(circuit_add_qubits(body, orders[order], 2) == 0); + /* Build on dense ids, then map into the explicit storage order. */ + CCircuit* definition = circuit_new(2); + assert(definition != NULL); + CParameter* theta = param_parse("theta"); + assert(theta != NULL); + if (parameterized) { + assert(circuit_rx_param(definition, 0, theta) == 0); + } else { + assert(circuit_x(definition, 0) == 0); + } + assert(circuit_compose(body, definition, orders[order], 2) == 0); + circuit_free(definition); + assert(circuit_set_global_phase(body, 0.13) == 0); + CCircuitGate* gate = circuit_to_gate(body, "first_gate"); + assert(gate != NULL); + for (size_t application = 0; application < 2; ++application) { + double angle = application == 0 ? 0.37 : -0.61; + CParameter* argument = param_from_double(angle); + const CParameter* arguments[] = {argument}; + assert(argument != NULL); + CCircuit* circuit = circuit_new(2); + assert(circuit != NULL); + assert(circuit_circuit_gate(circuit, gate, targets, 2, + parameterized ? arguments : NULL, + parameterized ? 1 : 0) == 0); + /* CParameter arguments are stored as parameter-table indices. + * Resolve them in the owning circuit before extracting the raw + * DAG Operation, which has no access to that parameter table. + * The gate definition still contains theta; matrix() must bind it. */ + CCircuit* resolved = circuit_assign_params(circuit, ""); + assert(resolved != NULL && circuit_num_parameters(resolved) == 0); + circuit_free(circuit); + circuit = resolved; + CCircuitDag* dag = circuit_dag_from_circuit(circuit); + assert(dag != NULL && circuit_dag_op_nodes_len(dag) == 1); + uint32_t node; + assert(circuit_dag_op_nodes(dag, &node, 1) == 0); + COperation* operation = circuit_dag_operation(dag, node); + assert(operation != NULL); + size_t rows = 0, cols = 0; + assert(operation_matrix_len(operation) == 16); + assert(operation_matrix_dims(operation, &rows, &cols) == 0); + assert(rows == 4 && cols == 4); + double local[32], full[32]; + assert(operation_matrix(operation, local, 32) == 16); + assert(circuit_to_matrix(circuit, NULL, 0, full, 32) == 16); + for (size_t row = 0; row < 4; ++row) { + for (size_t col = 0; col < 4; ++col) { + for (int local_basis = 0; local_basis < 2; ++local_basis) { + size_t flip = local_basis ? 2 : 1; + double re = 0, im = 0; + if (parameterized) { + if (row == col) re = cos(angle / 2); + if (row == (col ^ flip)) im = -sin(angle / 2); + } else if (row == (col ^ flip)) { + re = 1; + } + double expected_re = re * cos(0.13) - im * sin(0.13); + double expected_im = re * sin(0.13) + im * cos(0.13); + const double* matrix = local_basis ? local : full; + size_t index = 2 * (row * 4 + col); + assert(fabs(matrix[index] - expected_re) < 1e-12); + assert(fabs(matrix[index + 1] - expected_im) < 1e-12); + } + } + } + operation_free(operation); + circuit_dag_free(dag); + circuit_free(circuit); + param_free(argument); + } + circuit_gate_free(gate); + param_free(theta); + circuit_free(body); + } + } +} + int main(void) { test_basic_circuit(); test_errors(); test_symbolic_parameters(); test_extended_gates(); + test_circuit_gate_operation_matrix(); printf("binding-c circuit tests passed\n"); return 0; } diff --git a/crates/binding-python/tests/compile/test_commutation.py b/crates/binding-python/tests/compile/test_commutation.py index 4eb5692c..4696ad8e 100644 --- a/crates/binding-python/tests/compile/test_commutation.py +++ b/crates/binding-python/tests/compile/test_commutation.py @@ -17,6 +17,7 @@ import pytest from cqlib.circuit import ( + Circuit, Instruction, Parameter, Qubit, @@ -64,6 +65,46 @@ def test_builtin_checker_proves_exact_and_global_phase_commutation(): assert "Parameter(" in repr(global_phase) +@pytest.mark.parametrize("order", [[0, 1], [1, 0], [7, 2]]) +@pytest.mark.parametrize("parameterized", [False, True]) +def test_circuit_gate_matrix_commutation_uses_positional_targets(order, parameterized): + body = Circuit(order) + body.rx(order[0], Parameter("theta") if parameterized else 0.37) + circuit = Circuit(2) + circuit.append_circuit_gate( + body.to_gate("first_rx"), [0, 1], [0.37] if parameterized else [] + ) + matrix_checker = CommutationChecker.with_config( + CommutationConfig(enable_rule_oracle=False, enable_matrix_fallback=True) + ) + for target in (0, 1): + z = operation(StandardGate.Z, [target]) + expected = None if target == 0 else Commutation.exact() + for lhs, rhs in ((circuit[0], z), (z, circuit[0])): + assert check_commutation(lhs, rhs) == expected + assert matrix_checker.check(lhs, rhs) == expected + + +def test_circuit_gate_matrix_commutation_preserves_global_phase_proof(): + body = Circuit([7, 2]) + body.x(7) + circuit = Circuit(2) + circuit.append_circuit_gate(body.to_gate("first_x"), [0, 1]) + z = operation(StandardGate.Z, [0]) + for lhs, rhs in ((circuit[0], z), (z, circuit[0])): + proof = check_commutation(lhs, rhs) + assert proof is not None and not proof.is_exact() + assert math.isclose(abs(proof.phase.evaluate()), math.pi, abs_tol=1e-12) + + +def test_circuit_gate_unresolved_matrix_does_not_produce_a_proof(): + body = Circuit(2) + body.rx(0, Parameter("theta")) + circuit = Circuit(2) + circuit.append_circuit_gate(body.to_gate("symbolic"), [0, 1], [Parameter("x")]) + assert check_commutation(circuit[0], operation(StandardGate.Z, [0])) is None + + def test_symbolic_parameters_are_preserved_for_algebraic_proofs(): lhs = operation(StandardGate.RZ(Parameter("a")), [0]) rhs = operation(StandardGate.RZ(Parameter("b")), [0]) diff --git a/crates/binding-python/tests/gates/test_circuit_gate.py b/crates/binding-python/tests/gates/test_circuit_gate.py index 4248f17e..969ef952 100644 --- a/crates/binding-python/tests/gates/test_circuit_gate.py +++ b/crates/binding-python/tests/gates/test_circuit_gate.py @@ -20,13 +20,143 @@ - Parametric CircuitGate creation and usage """ -import pytest +from itertools import permutations + import numpy as np +import pytest from cqlib.circuit import Circuit, Parameter from cqlib.circuit.gates import CircuitGate +def _embed_operation_matrix(matrix, targets, width): + """Independent basis-index embedding: first gate argument is the MSB.""" + result = np.zeros((2**width, 2**width), dtype=complex) + for column in range(2**width): + local_column = 0 + for target in targets: + local_column = 2 * local_column + ((column >> target) & 1) + for local_row in range(2 ** len(targets)): + row = column + for position, target in enumerate(targets): + bit = (local_row >> (len(targets) - 1 - position)) & 1 + row = (row & ~(1 << target)) | (bit << target) + result[row, column] = matrix[local_row, local_column] + return result + + +class TestCircuitGateOperationMatrix: + @staticmethod + def assert_operations(circuit, expected): + # Cover snapshots returned by both collection and indexing APIs. + for operation in (circuit.operations[0], circuit[0]): + np.testing.assert_allclose(operation.matrix(), expected, rtol=0, atol=1e-12) + + @pytest.mark.parametrize("order", [[0, 1], [1, 0], [7, 2]]) + def test_positional_matrix(self, order): + body = Circuit(order) + body.x(order[0]) + circuit = Circuit(2) + circuit.append_circuit_gate(body.to_gate("first_x"), [0, 1]) + self.assert_operations(circuit, np.kron([[0, 1], [1, 0]], np.eye(2))) + + def test_bound_parameter_and_repeated_application(self): + body = Circuit([7]) + body.rx(7, Parameter("theta")) + gate = body.to_gate("rx") + for angle in (0.37, -0.61, 0.37): + circuit = Circuit(1) + circuit.append_circuit_gate(gate, [0], [angle]) + c, s = np.cos(angle / 2), -1j * np.sin(angle / 2) + self.assert_operations(circuit, np.array([[c, s], [s, c]])) + assert gate.used_symbols == ["theta"] + + @pytest.mark.parametrize("order", list(permutations([7, 2, 5]))) + @pytest.mark.parametrize("targets", [[3, 0, 2], [2, 3, 0]]) + def test_three_qubit_permutations_and_spectator(self, order, targets): + body = Circuit(list(order)) + body.h(order[0]) + body.rz(order[1], 0.29) + body.cx(order[0], order[2]) + body.set_global_phase(0.17) + expected = np.zeros((8, 8), dtype=complex) + for column in range(8): + first, middle, last = column >> 2, (column >> 1) & 1, column & 1 + for output_first in (0, 1): + row = (output_first << 2) | (middle << 1) | (last ^ output_first) + expected[row, column] = ( + (-1) ** (first * output_first) + / np.sqrt(2) + * np.exp(1j * (0.17 + (2 * middle - 1) * 0.29 / 2)) + ) + circuit = Circuit(4) + circuit.append_circuit_gate(body.to_gate("three"), targets) + self.assert_operations(circuit, expected) + full = _embed_operation_matrix(expected, targets, 4) + np.testing.assert_allclose(circuit.to_matrix(), full, rtol=0, atol=1e-12) + np.testing.assert_allclose( + circuit.to_symbolic_matrix().evaluate(), full, rtol=0, atol=1e-12 + ) + np.testing.assert_allclose( + circuit.decompose().to_matrix(), full, rtol=0, atol=1e-12 + ) + + @pytest.mark.parametrize("nested", [False, True]) + @pytest.mark.parametrize("inverse", [False, True]) + def test_signature_phase_and_inverse(self, nested, inverse): + theta, phi = Parameter("theta"), Parameter("phi") + body = Circuit([7, 2]) + body.rx(7, theta) + body.rz(2, phi) + body.cx(7, 2) + body.set_global_phase(theta + phi / 2) + gate = CircuitGate.with_signature( + "declared", body.to_gate("definition").circuit, ["unused", "phi", "theta"] + ) + arguments = [12.0, -0.21, 0.37] + if nested: + middle = Circuit([5, 1]) + middle.append_circuit_gate(gate, [5, 1], [0, phi, theta]) + gate = CircuitGate.with_signature( + "nested", middle.to_gate("middle").circuit, ["unused", "phi", "theta"] + ) + circuit = Circuit(2) + circuit.append_circuit_gate(gate, [1, 0], arguments) + if inverse: + circuit = circuit.inverse() + c, s = np.cos(0.37 / 2), -1j * np.sin(0.37 / 2) + rx = np.array([[c, s], [s, c]]) + rz = np.diag(np.exp(1j * np.array([0.21, -0.21]) / 2)) + cx = np.eye(4)[[0, 1, 3, 2]] + expected = np.exp(1j * (0.37 - 0.21 / 2)) * cx @ np.kron(rx, rz) + if inverse: + expected = expected.conj().T + self.assert_operations(circuit, expected) + np.testing.assert_allclose(circuit.to_matrix(), expected, rtol=0, atol=1e-12) + np.testing.assert_allclose( + circuit.to_symbolic_matrix().evaluate(), expected, rtol=0, atol=1e-12 + ) + + def test_zero_qubit_phase(self): + body = Circuit(0) + body.set_global_phase(Parameter("theta")) + circuit = Circuit(0) + circuit.append_circuit_gate(body.to_gate("phase"), [], [0.37]) + expected = np.array([[np.exp(0.37j)]]) + self.assert_operations(circuit, expected) + np.testing.assert_allclose(circuit.to_matrix(), expected, rtol=0, atol=1e-12) + + def test_unresolved_arguments_keep_existing_error_behavior(self): + body = Circuit(1) + body.rx(0, Parameter("theta")) + circuit = Circuit(1) + circuit.append_circuit_gate(body.to_gate("unresolved"), [0], [Parameter("x")]) + with pytest.raises(ValueError): + circuit.operations[0].matrix() + with pytest.raises(ValueError): + circuit[0].matrix() + + class TestCircuitGateStorageOrder: @pytest.mark.parametrize("order", [[7, 2], [1, 0], [2, 7], [0, 1]]) @pytest.mark.parametrize("targets", [[0, 1], [1, 0]]) diff --git a/crates/cqlib-core/src/circuit/circuit_to_matrix.rs b/crates/cqlib-core/src/circuit/circuit_to_matrix.rs index ead74ef5..f72835fb 100644 --- a/crates/cqlib-core/src/circuit/circuit_to_matrix.rs +++ b/crates/cqlib-core/src/circuit/circuit_to_matrix.rs @@ -198,26 +198,11 @@ pub fn circuit_to_matrix( } } Instruction::CircuitGate(circuit_gate) => { - let symbols = circuit_gate.signature_params(); - let expected = symbols.len(); - let actual = params.len(); - if actual != expected { - return Err(CircuitError::ParameterCountMismatch { expected, actual }); - } - let mut bindings = HashMap::new(); - for (sym, val) in symbols.iter().zip(params.iter()) { - bindings.insert(sym.as_str(), *val); - } - let sub_circuit = circuit_gate - .circuit - .circuit - .assign_parameters(&Some(bindings)) - .map_err(|_| CircuitError::SymbolicParameterError)?; - // Gate arguments map positionally to the inner storage order, - // which can differ from the default numeric matrix order. - let order: Vec = sub_circuit.qubits().iter().map(|q| q.index()).collect(); - let sub_matrix = circuit_to_matrix(&sub_circuit, Some(&order))?; - apply_gate_to_matrix(&mut matrix, &sub_matrix, &bits)?; + let gate_matrix = circuit_gate.matrix_for_params(params.as_slice())?; + // Gate-local matrices put the first positional argument at MSB; + // the full circuit uses an LSB-first basis, as for standard gates. + let reversed_bits: SmallVec<[usize; 3]> = bits.iter().copied().rev().collect(); + apply_gate_to_matrix(&mut matrix, &gate_matrix, &reversed_bits)?; } Instruction::ClassicalControl(_) => { return Err(CircuitError::InvalidOperation( diff --git a/crates/cqlib-core/src/circuit/gate/circuit_gate.rs b/crates/cqlib-core/src/circuit/gate/circuit_gate.rs index 4e935338..aa6e4421 100644 --- a/crates/cqlib-core/src/circuit/gate/circuit_gate.rs +++ b/crates/cqlib-core/src/circuit/gate/circuit_gate.rs @@ -17,9 +17,13 @@ //! This enables hierarchical circuit construction and custom composite gates. use crate::circuit::circuit_impl::Circuit; +use crate::circuit::circuit_to_matrix; use crate::circuit::error::CircuitError; use crate::circuit::symbolic_matrix::{SymbolicMatrix, circuit_to_symbolic_matrix}; use indexmap::IndexSet; +use ndarray::Array2; +use num_complex::Complex64; +use std::collections::HashMap; use std::sync::{Arc, OnceLock}; /// An immutable, frozen circuit for use in gate definitions. @@ -350,6 +354,40 @@ impl CircuitGate { self.num_params } + /// Resolves a numeric gate-local matrix, with the first positional + /// argument as the most-significant bit, like a standard gate matrix. + /// This differs from `symbolic_matrix`'s storage-order, little-endian basis. + pub(crate) fn matrix_for_params( + &self, + params: &[f64], + ) -> Result, CircuitError> { + if params.len() != self.signature_params.len() { + return Err(CircuitError::ParameterCountMismatch { + expected: self.signature_params.len(), + actual: params.len(), + }); + } + for (index, &value) in params.iter().enumerate() { + if !value.is_finite() { + return Err(CircuitError::InvalidParameterValue(index, value)); + } + } + let bindings: HashMap<&str, f64> = self + .signature_params + .iter() + .map(String::as_str) + .zip(params.iter().copied()) + .collect(); + let assigned = self + .circuit + .circuit + .assign_parameters(&Some(bindings)) + .map_err(|_| CircuitError::SymbolicParameterError)?; + // circuit_to_matrix takes LSB-first ordering; gate arguments are MSB-first. + let order: Vec = assigned.qubits().iter().rev().map(|q| q.index()).collect(); + circuit_to_matrix(&assigned, Some(&order)) + } + /// Returns a clone of the internal frozen circuit. /// /// # Examples @@ -434,6 +472,82 @@ mod tests { use super::*; use crate::circuit::{Circuit, Parameter, Qubit}; + #[test] + fn numeric_matrix_binds_declared_signature_and_global_phase_without_mutation() { + let mut circuit = Circuit::from_qubits(vec![Qubit::new(7)]).unwrap(); + let theta = Parameter::symbol("theta"); + let phi = Parameter::symbol("phi"); + circuit.rx(Qubit::new(7), theta.clone()).unwrap(); + circuit.rz(Qubit::new(7), phi.clone()).unwrap(); + circuit.set_global_phase(theta + phi / Parameter::from(2.0)); + let gate = CircuitGate::with_signature( + "declared", + FrozenCircuit::new(circuit), + ["unused".to_string(), "phi".to_string(), "theta".to_string()], + ) + .unwrap(); + let original = gate.clone(); + for (theta, phi) in [(0.37_f64, -0.21_f64), (-0.61, 0.42), (0.37, -0.21)] { + let matrix = gate.matrix_for_params(&[12.0, phi, theta]).unwrap(); + for row in 0..2 { + let phase = theta + phi / 2.0 + if row == 0 { -phi / 2.0 } else { phi / 2.0 }; + for col in 0..2 { + let rx = if row == col { + Complex64::new((theta / 2.0).cos(), 0.0) + } else { + Complex64::new(0.0, -(theta / 2.0).sin()) + }; + assert!( + (matrix[[row, col]] - Complex64::from_polar(1.0, phase) * rx).norm() + < 1e-12 + ); + } + } + assert_eq!(gate, original); + assert_eq!(gate.used_symbols().len(), 2); + } + } + + #[test] + fn numeric_matrix_rejects_invalid_arguments_and_nonunitary_definitions() { + let gate = CircuitGate::with_signature( + "unused", + FrozenCircuit::new(Circuit::new(1)), + ["unused".to_string()], + ) + .unwrap(); + for params in [vec![], vec![0.1, 0.2]] { + assert!(matches!( + gate.matrix_for_params(¶ms), + Err(CircuitError::ParameterCountMismatch { expected: 1, .. }) + )); + } + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert!(matches!( + gate.matrix_for_params(&[value]), + Err(CircuitError::InvalidParameterValue(0, _)) + )); + } + for directive in [ + crate::circuit::Directive::Measure, + crate::circuit::Directive::Reset, + ] { + let mut body = Circuit::new(1); + body.append( + crate::circuit::Instruction::Directive(directive), + [Qubit::new(0)], + [], + None, + ) + .unwrap(); + let gate = CircuitGate::new("nonunitary", FrozenCircuit::new(body)).unwrap(); + assert!(matches!( + gate.matrix_for_params(&[]), + Err(CircuitError::NoMatrixRepresentation) + )); + } + } + #[test] fn signature_params_are_distinct_from_used_symbols() { let mut circuit = Circuit::new(1); diff --git a/crates/cqlib-core/src/circuit/gate/instruction.rs b/crates/cqlib-core/src/circuit/gate/instruction.rs index 31b9513c..6d0f6b98 100644 --- a/crates/cqlib-core/src/circuit/gate/instruction.rs +++ b/crates/cqlib-core/src/circuit/gate/instruction.rs @@ -26,7 +26,7 @@ use crate::circuit::gate::circuit_gate::{CircuitGate, FrozenCircuit}; use crate::circuit::gate::directive::Directive; use crate::circuit::gate::standard_gate::StandardGate; use crate::circuit::gate::{ClassicalDataOp, MCGate, UnitaryGate, gate_matrix}; -use crate::circuit::{ClassicalControlOp, ClassicalValue, Parameter, circuit_to_matrix}; +use crate::circuit::{ClassicalControlOp, ClassicalValue, Parameter}; use alloc::borrow::Cow; use ndarray::Array2; use num_complex::Complex64; @@ -321,20 +321,21 @@ impl Instruction { /// # Arguments /// /// * `params` - A slice of floating-point parameters (e.g., rotation angles). + /// CircuitGate values bind positionally to its declared signature. Its + /// first qubit argument is the most-significant bit of the local matrix, + /// matching standard gates rather than the full circuit's LSB-first basis. /// /// # Returns /// - /// - `Some(Cow)`: The unitary matrix for `Standard` and `Extended` gates. - /// - `None`: If the instruction is non-unitary (e.g., `Measure`, `Barrier`, `Reset`, - /// or classical control). + /// - `Some(Cow)`: A numeric matrix for a supported gate instruction. + /// - `None`: If parameters are invalid or unresolved, or the instruction has + /// no matrix (e.g., `Measure`, `Barrier`, `Reset`, or classical control). pub fn matrix(&self, params: &[f64]) -> Option>> { match self { Instruction::Standard(g) => g.matrix(params).ok(), Instruction::McGate(g) => g.matrix(params).ok(), Instruction::UnitaryGate(g) => g.matrix_for_params(params).ok(), - Instruction::CircuitGate(g) => circuit_to_matrix(&g.circuit.circuit, None) - .ok() - .map(Cow::Owned), + Instruction::CircuitGate(g) => g.matrix_for_params(params).ok().map(Cow::Owned), Instruction::Directive(_) => None, Instruction::ClassicalData(_) => None, Instruction::Delay => None, diff --git a/crates/cqlib-core/src/circuit/gate/instruction_test.rs b/crates/cqlib-core/src/circuit/gate/instruction_test.rs index 33f33779..93f83765 100644 --- a/crates/cqlib-core/src/circuit/gate/instruction_test.rs +++ b/crates/cqlib-core/src/circuit/gate/instruction_test.rs @@ -13,9 +13,58 @@ use super::*; use crate::circuit::{ - Circuit, CircuitId, ClassicalExpr, ClassicalType, ClassicalValue, ClassicalVar, + Circuit, CircuitId, ClassicalExpr, ClassicalType, ClassicalValue, ClassicalVar, Qubit, }; +#[test] +fn circuit_gate_operation_matrix_respects_positional_qubits() { + for order in [[0, 1], [1, 0], [7, 2]] { + let mut body = Circuit::from_qubits(order.map(Qubit::new).to_vec()).unwrap(); + body.x(Qubit::new(order[0])).unwrap(); + let instruction = body.to_gate("first_x").unwrap(); + let matrix = instruction.matrix(&[]).unwrap(); + // First argument is the MSB of a gate-local matrix: X tensor I. + for row in 0..4 { + for col in 0..4 { + let expected = if row == (col ^ 2) { 1.0 } else { 0.0 }; + assert_eq!(matrix[[row, col]], Complex64::new(expected, 0.0)); + } + } + } +} + +#[test] +fn circuit_gate_operation_matrix_binds_numeric_arguments() { + let mut body = Circuit::from_qubits(vec![Qubit::new(7)]).unwrap(); + body.rx(Qubit::new(7), Parameter::symbol("theta")).unwrap(); + let instruction = body.to_gate("rx").unwrap(); + for angle in [0.37_f64, -0.61] { + let matrix = instruction.matrix(&[angle]).unwrap(); + let mut operation = crate::circuit::Operation { + instruction: instruction.clone(), + qubits: smallvec::smallvec![Qubit::new(7)], + params: smallvec::smallvec![crate::circuit::CircuitParam::Fixed(angle)], + label: None, + }; + assert_eq!(operation.matrix().unwrap().as_ref(), matrix.as_ref()); + operation.params[0] = crate::circuit::CircuitParam::Index(0); + assert!(matches!( + operation.matrix(), + Err(crate::circuit::CircuitError::SymbolicParameterError) + )); + for row in 0..2 { + for col in 0..2 { + let expected = if row == col { + Complex64::new((angle / 2.0).cos(), 0.0) + } else { + Complex64::new(0.0, -(angle / 2.0).sin()) + }; + assert!((matrix[[row, col]] - expected).norm() < 1e-12); + } + } + } +} + #[test] fn standard_gate_query_distinguishes_instruction_kinds() { assert_eq!( diff --git a/crates/cqlib-core/src/circuit/operation.rs b/crates/cqlib-core/src/circuit/operation.rs index 4bfafecd..5a35afa0 100644 --- a/crates/cqlib-core/src/circuit/operation.rs +++ b/crates/cqlib-core/src/circuit/operation.rs @@ -102,6 +102,9 @@ impl Operation { /// # Returns /// /// * `Ok(Cow)` - The unitary matrix. It may be borrowed (static) or owned (computed). + /// CircuitGate matrices bind their declared positional parameters and put + /// the first qubit argument at the most-significant local basis bit, like + /// standard gates. Full circuit matrices instead use an LSB-first basis. /// * `Err(CircuitError)` - If the operation is non-unitary or contains an /// unresolved symbolic parameter. /// diff --git a/crates/cqlib-core/src/compile/commutation/commutation_test.rs b/crates/cqlib-core/src/compile/commutation/commutation_test.rs index 15deebd2..d3418683 100644 --- a/crates/cqlib-core/src/compile/commutation/commutation_test.rs +++ b/crates/cqlib-core/src/compile/commutation/commutation_test.rs @@ -80,6 +80,111 @@ fn assert_pi_phase(result: CommutationResult) { assert!((phase.evaluate(&None).unwrap() - PI).abs() < 1e-10); } +#[test] +fn circuit_gate_operation_matrix_does_not_certify_false_commutation() { + let mut body = Circuit::new(2); + body.rx(Qubit::new(0), Parameter::from(0.37)).unwrap(); + let instruction = body.to_gate("rx_first").unwrap(); + let qubits = [Qubit::new(0), Qubit::new(1)]; + let checker = CommutationChecker::with_config(CommutationConfig { + enable_rule_oracle: false, + ..CommutationConfig::default() + }); + assert!( + checker + .check( + &instruction, + &qubits, + &[], + &Instruction::Standard(StandardGate::Z), + &[qubits[0]], + &[], + ) + .is_none() + ); +} + +#[test] +fn circuit_gate_matrix_fallback_respects_targets_parameters_and_phase() { + for order in [[0, 1], [1, 0], [7, 2]] { + for parameterized in [false, true] { + let mut body = Circuit::from_qubits(order.map(Qubit::new).to_vec()).unwrap(); + body.rx( + Qubit::new(order[0]), + if parameterized { + Parameter::symbol("theta") + } else { + Parameter::from(0.37) + }, + ) + .unwrap(); + let instruction = body.to_gate("rx_first").unwrap(); + let params = if parameterized { + vec![Parameter::from(0.37)] + } else { + vec![] + }; + let qubits = [Qubit::new(5), Qubit::new(1)]; + for checker in [ + CommutationChecker::builtin(), + CommutationChecker::with_config(CommutationConfig { + enable_rule_oracle: false, + ..CommutationConfig::default() + }), + ] { + for (position, &qubit) in qubits.iter().enumerate() { + let z = Instruction::Standard(StandardGate::Z); + for result in [ + checker.check(&instruction, &qubits, ¶ms, &z, &[qubit], &[]), + checker.check(&z, &[qubit], &[], &instruction, &qubits, ¶ms), + ] { + if position == 0 { + assert!(result.is_none()); + } else { + assert_exact(result); + } + } + } + } + if parameterized { + assert!( + super::matrix::matrix_commutation( + &instruction, + &qubits, + &[Parameter::symbol("unresolved")], + &Instruction::Standard(StandardGate::Z), + &[qubits[0]], + &[], + 4, + ) + .is_none() + ); + } + } + } + let mut body = Circuit::new(2); + body.x(Qubit::new(0)).unwrap(); + let x = body.to_gate("x_first").unwrap(); + let z = Instruction::Standard(StandardGate::Z); + let checker = CommutationChecker::builtin(); + assert_pi_phase(checker.check( + &x, + &[Qubit::new(0), Qubit::new(1)], + &[], + &z, + &[Qubit::new(0)], + &[], + )); + assert_pi_phase(checker.check( + &z, + &[Qubit::new(0)], + &[], + &x, + &[Qubit::new(0), Qubit::new(1)], + &[], + )); +} + #[test] fn identity_commutes_exactly() { let checker = CommutationChecker::builtin(); diff --git a/crates/cqlib-core/src/compile/commutation/matrix.rs b/crates/cqlib-core/src/compile/commutation/matrix.rs index 448d896e..c50bd939 100644 --- a/crates/cqlib-core/src/compile/commutation/matrix.rs +++ b/crates/cqlib-core/src/compile/commutation/matrix.rs @@ -101,7 +101,7 @@ pub fn matrix_commutation( /// /// `gate_positions` maps each gate-local qubit to its position in the sorted /// combined support used by [`matrix_commutation`]. Bit extraction follows the -/// existing circuit matrix convention: earlier qubits correspond to more +/// gate-local matrix convention: earlier qubits correspond to more /// significant bits in the local basis index. fn expand_unitary( matrix: Cow<'_, Array2>, From 930c4924c932163864241bf5ffb605b8a5995c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Sat, 17 Oct 2026 22:49:09 +0800 Subject: [PATCH 10/16] fix(circuit): preserve circuit-backed unitary port order --- .../src/circuit/gate/circuit_gate.rs | 18 +- .../src/circuit/gate/unitary_gate.rs | 13 +- .../src/circuit/gate/unitary_gate_test.rs | 251 ++++++++++++++++++ .../compile/transform/decompose/definition.rs | 4 +- crates/cqlib-core/src/ir/qasm2/dump.rs | 2 +- crates/cqlib-core/src/ir/qasm3/dump.rs | 2 +- 6 files changed, 285 insertions(+), 5 deletions(-) diff --git a/crates/cqlib-core/src/circuit/gate/circuit_gate.rs b/crates/cqlib-core/src/circuit/gate/circuit_gate.rs index aa6e4421..5eaf87a9 100644 --- a/crates/cqlib-core/src/circuit/gate/circuit_gate.rs +++ b/crates/cqlib-core/src/circuit/gate/circuit_gate.rs @@ -16,6 +16,7 @@ //! quantum circuits to be used as reusable gate components within other circuits. //! This enables hierarchical circuit construction and custom composite gates. +use crate::circuit::Qubit; use crate::circuit::circuit_impl::Circuit; use crate::circuit::circuit_to_matrix; use crate::circuit::error::CircuitError; @@ -101,6 +102,16 @@ impl FrozenCircuit { &self.used_symbols } + /// The default matrix order, from least to most significant bit. + /// + /// Circuit-backed UnitaryGate definitions map this order positionally to + /// the caller's qubits. CircuitGate instead uses circuit storage order. + pub(crate) fn qubits_in_numeric_order(&self) -> Vec { + let mut qubits = self.circuit.qubits(); + qubits.sort_unstable(); + qubits + } + /// Returns the cached symbolic matrix for this frozen circuit. /// /// The matrix is computed in the circuit's default qubit order on the @@ -111,7 +122,12 @@ impl FrozenCircuit { return Ok(matrix.clone()); } - let matrix = Arc::new(circuit_to_symbolic_matrix(&self.circuit, None)?); + let order: Vec = self + .qubits_in_numeric_order() + .iter() + .map(|q| q.index()) + .collect(); + let matrix = Arc::new(circuit_to_symbolic_matrix(&self.circuit, Some(&order))?); let _ = self.symbolic_matrix_cache.set(matrix.clone()); Ok(self.symbolic_matrix_cache.get().cloned().unwrap_or(matrix)) } diff --git a/crates/cqlib-core/src/circuit/gate/unitary_gate.rs b/crates/cqlib-core/src/circuit/gate/unitary_gate.rs index 9e574e15..2fec804f 100644 --- a/crates/cqlib-core/src/circuit/gate/unitary_gate.rs +++ b/crates/cqlib-core/src/circuit/gate/unitary_gate.rs @@ -323,7 +323,12 @@ impl UnitaryGate { let assigned = inner .assign_parameters(&Some(bindings)) .map_err(|_| CircuitError::SymbolicParameterError)?; - return circuit_to_matrix(&assigned, None).map(Cow::Owned); + let order: Vec = circuit + .qubits_in_numeric_order() + .iter() + .map(|q| q.index()) + .collect(); + return circuit_to_matrix(&assigned, Some(&order)).map(Cow::Owned); } Err(CircuitError::NoMatrixRepresentation) @@ -407,6 +412,12 @@ impl UnitaryGate { /// /// This allows the gate to be defined by its circuit decomposition, /// which is useful for inverse operations and optimization. + /// The internal qubits are mapped in ascending numeric-ID order to the + /// caller's qubits, independently of their storage order in the circuit. + /// When no explicit matrix is attached, the first such qubit is the least + /// significant bit of the derived matrix. Explicit matrices retain their + /// MSB-first argument convention; if both representations are supplied, + /// they must describe the same operation under their respective conventions. /// /// # Arguments /// diff --git a/crates/cqlib-core/src/circuit/gate/unitary_gate_test.rs b/crates/cqlib-core/src/circuit/gate/unitary_gate_test.rs index 87e83d6a..8c197ab2 100644 --- a/crates/cqlib-core/src/circuit/gate/unitary_gate_test.rs +++ b/crates/cqlib-core/src/circuit/gate/unitary_gate_test.rs @@ -16,7 +16,9 @@ use crate::circuit::gate::StandardGate; use crate::circuit::symbolic_matrix::{ SymbolicComplex, standard_gate_symbolic_matrix, symbolic_eye, }; +use crate::circuit::test_utils::assert_matrix_approx_eq; use crate::circuit::{Circuit, Parameter, Qubit}; +use crate::compile::transform::decompose::expand_definitions; use ndarray::array; use num_complex::Complex64; @@ -646,3 +648,252 @@ fn test_custom_unitary_in_composite_circuit() { } } } + +/// Check complete operators, including spectator qubits and nested CircuitGate +/// definitions, against an independently constructed positional reference. +fn assert_circuit_definition_order(transform: impl Fn(&Circuit) -> Circuit) { + let mut inner = Circuit::from_qubits(vec![Qubit::new(2), Qubit::new(0)]).unwrap(); + inner.x(Qubit::new(2)).unwrap(); + let gate = UnitaryGate::new("asymmetric_x", 2, 0) + .with_circuit(Arc::new(FrozenCircuit::new(inner))) + .unwrap(); + let mut circuit = Circuit::new(2); + circuit + .unitary(gate, vec![Qubit::new(0), Qubit::new(1)]) + .unwrap(); + let mut expected = Circuit::new(2); + expected.x(Qubit::new(1)).unwrap(); + let expected = circuit_to_matrix(&expected, None).unwrap(); + assert_matrix_approx_eq( + &circuit_to_matrix(&circuit, None).unwrap(), + &expected, + 1e-12, + ); + assert_matrix_approx_eq( + &circuit_to_matrix(&transform(&circuit), None).unwrap(), + &expected, + 1e-12, + ); + + let mut leaf = Circuit::from_qubits(vec![Qubit::new(4), Qubit::new(1)]).unwrap(); + leaf.h(Qubit::new(4)).unwrap(); + leaf.cx(Qubit::new(4), Qubit::new(1)).unwrap(); + let leaf = leaf.to_gate("storage_order_leaf").unwrap(); + + for order in [[9, 2, 5], [5, 9, 2], [2, 5, 9]] { + let mut inner = Circuit::from_qubits(order.map(Qubit::new).to_vec()).unwrap(); + inner.rx(Qubit::new(9), 0.37).unwrap(); + inner.ry(Qubit::new(2), -0.51).unwrap(); + inner.cx(Qubit::new(5), Qubit::new(9)).unwrap(); + inner.cx(Qubit::new(9), Qubit::new(2)).unwrap(); + inner.rz(Qubit::new(5), 0.29).unwrap(); + inner + .append(leaf.clone(), [Qubit::new(9), Qubit::new(5)], [], None) + .unwrap(); + // Explicit matrices use MSB-first qargs. Reverse the definition's + // numeric order when also supplying an equivalent matrix representation. + let explicit_matrix = circuit_to_matrix(&inner, Some(&[9, 5, 2])).unwrap(); + let gate = UnitaryGate::new("numeric_order_unitary", 3, 0) + .with_circuit(Arc::new(FrozenCircuit::new(inner))) + .unwrap(); + for with_matrix in [false, true] { + let gate = if with_matrix { + gate.clone().with_matrix(explicit_matrix.clone()).unwrap() + } else { + gate.clone() + }; + for args in [ + [0, 2, 3], + [0, 3, 2], + [2, 0, 3], + [2, 3, 0], + [3, 0, 2], + [3, 2, 0], + ] { + let [a, b, c] = args.map(Qubit::new); + let mut circuit = Circuit::new(4); + circuit.unitary(gate.clone(), vec![a, b, c]).unwrap(); + let mut expected = Circuit::new(4); + expected.rx(c, 0.37).unwrap(); + expected.ry(a, -0.51).unwrap(); + expected.cx(b, c).unwrap(); + expected.cx(c, a).unwrap(); + expected.rz(b, 0.29).unwrap(); + expected.h(c).unwrap(); + expected.cx(c, b).unwrap(); + let expected = circuit_to_matrix(&expected, None).unwrap(); + assert_matrix_approx_eq( + &circuit_to_matrix(&circuit, None).unwrap(), + &expected, + 1e-12, + ); + assert_matrix_approx_eq( + &circuit_to_matrix(&transform(&circuit), None).unwrap(), + &expected, + 1e-12, + ); + } + } + } +} + +#[test] +fn circuit_definition_order_survives_expansion() { + assert_circuit_definition_order(|circuit| expand_definitions(circuit).unwrap()); +} + +#[test] +fn circuit_definition_order_survives_qasm2() { + assert_circuit_definition_order(|circuit| { + crate::ir::qasm2::load::loads(&crate::ir::qasm2::dump::dumps(circuit).unwrap()).unwrap() + }); +} + +#[test] +fn circuit_definition_order_survives_qasm3() { + assert_circuit_definition_order(|circuit| { + crate::ir::qasm3::load::loads(&crate::ir::qasm3::dump::dumps(circuit).unwrap()).unwrap() + }); +} + +#[test] +fn circuit_definition_order_survives_compilation() { + use crate::compile::resource::ResourcePolicy; + use crate::compile::{CompileConfig, CompileMode, CompileTarget, compile}; + for mode in [CompileMode::Normal, CompileMode::Enhanced] { + assert_circuit_definition_order(|circuit| { + compile( + circuit, + CompileConfig { + mode, + target: CompileTarget::Logical, + resource_policy: ResourcePolicy::default(), + }, + ) + .unwrap() + .circuit + }); + } +} + +#[test] +fn circuit_definition_order_preserves_device_layout_and_basis() { + use crate::compile::resource::ResourcePolicy; + use crate::compile::{CompileConfig, CompileMode, CompileTarget, DeviceCompileTarget, compile}; + use crate::device::{Device, Layout}; + + let mut inner = Circuit::from_qubits(vec![Qubit::new(2), Qubit::new(0)]).unwrap(); + inner.x(Qubit::new(2)).unwrap(); + inner.cx(Qubit::new(2), Qubit::new(0)).unwrap(); + let gate = UnitaryGate::new("device_unitary", 2, 0) + .with_circuit(Arc::new(FrozenCircuit::new(inner))) + .unwrap(); + let mut circuit = Circuit::new(2); + circuit + .unitary(gate, vec![Qubit::new(0), Qubit::new(1)]) + .unwrap(); + let device = Device::line("unitary-ports", 2) + .unwrap() + .with_native_gates(vec![ + StandardGate::X.into(), + StandardGate::H.into(), + StandardGate::CX.into(), + ]) + .unwrap(); + let layout = Layout::from_pairs(&[(0, 1), (1, 0)], 2).unwrap(); + // Internal 2 -> argument 1 -> physical 0. The CX direction is native on + // the device even though its logical arguments are in reverse order. + let mut expected = Circuit::new(2); + expected.x(Qubit::new(0)).unwrap(); + expected.cx(Qubit::new(0), Qubit::new(1)).unwrap(); + for mode in [CompileMode::Normal, CompileMode::Enhanced] { + let result = compile( + &circuit, + CompileConfig { + mode, + target: CompileTarget::Device(DeviceCompileTarget { + device: device.clone(), + initial_layout: Some(layout.clone()), + seed: Some(47), + }), + resource_policy: ResourcePolicy::default(), + }, + ) + .unwrap(); + device.validate_circuit(&result.circuit).unwrap(); + let metadata = result.device_metadata.unwrap(); + assert_eq!(metadata.initial_layout, layout); + assert_eq!(metadata.final_layout, layout); + assert_matrix_approx_eq( + &circuit_to_matrix(&result.circuit, None).unwrap(), + &circuit_to_matrix(&expected, None).unwrap(), + 1e-9, + ); + } +} + +#[test] +fn circuit_definition_order_preserves_nested_symbolic_parameters_and_phase() { + use crate::circuit::symbolic_matrix::{circuit_to_symbolic_matrix, evaluate_symbolic_matrix}; + let mut inner = Circuit::from_qubits(vec![Qubit::new(2), Qubit::new(0)]).unwrap(); + inner.rx(Qubit::new(2), Parameter::symbol("theta")).unwrap(); + inner.cx(Qubit::new(2), Qubit::new(0)).unwrap(); + inner.set_global_phase(Parameter::symbol("theta") * 0.25); + let gate = UnitaryGate::new("symbolic_unitary", 2, 1) + .with_circuit(Arc::new(FrozenCircuit::new(inner))) + .unwrap(); + let mut middle = Circuit::from_qubits(vec![Qubit::new(7), Qubit::new(3)]).unwrap(); + middle + .unitary_with_params( + gate, + vec![Qubit::new(3), Qubit::new(7)], + [Parameter::symbol("alpha").into()], + ) + .unwrap(); + let wrapper = middle.to_gate("storage_order_wrapper").unwrap(); + let mut circuit = Circuit::new(3); + circuit + .append( + wrapper, + [Qubit::new(2), Qubit::new(0)], + [crate::circuit::ParameterValue::Param( + Parameter::symbol("t") + 0.1_f64, + )], + None, + ) + .unwrap(); + let mut expected = Circuit::new(3); + expected + .rx(Qubit::new(2), Parameter::symbol("t") + 0.1) + .unwrap(); + expected.cx(Qubit::new(2), Qubit::new(0)).unwrap(); + expected.set_global_phase((Parameter::symbol("t") + 0.1) * 0.25); + + // Build the nested symbolic matrix before expansion, exercising numeric-order + // UnitaryGate and storage-order CircuitGate caches in the same expression. + let symbolic = circuit_to_symbolic_matrix(&circuit, None).unwrap(); + let expanded = expand_definitions(&circuit).unwrap(); + let expanded_symbolic = circuit_to_symbolic_matrix(&expanded, None).unwrap(); + for value in [0.0, 0.37, -0.51] { + let bindings = Some(HashMap::from([("t", value)])); + let expected = + circuit_to_matrix(&expected.assign_parameters(&bindings).unwrap(), None).unwrap(); + assert_matrix_approx_eq( + &evaluate_symbolic_matrix(&symbolic, &bindings).unwrap(), + &expected, + 1e-12, + ); + assert_matrix_approx_eq( + &evaluate_symbolic_matrix(&expanded_symbolic, &bindings).unwrap(), + &expected, + 1e-12, + ); + let bound = circuit.assign_parameters(&bindings).unwrap(); + assert_matrix_approx_eq(&circuit_to_matrix(&bound, None).unwrap(), &expected, 1e-12); + assert_matrix_approx_eq( + &circuit_to_matrix(&expand_definitions(&bound).unwrap(), None).unwrap(), + &expected, + 1e-12, + ); + } +} diff --git a/crates/cqlib-core/src/compile/transform/decompose/definition.rs b/crates/cqlib-core/src/compile/transform/decompose/definition.rs index 429e5f34..12d8e386 100644 --- a/crates/cqlib-core/src/compile/transform/decompose/definition.rs +++ b/crates/cqlib-core/src/compile/transform/decompose/definition.rs @@ -172,6 +172,7 @@ impl<'a> DefinitionExpander<'a> { self.expand_definition( gate.name(), definition.circuit(), + &definition.circuit().qubits(), gate.num_qubits(), gate.num_params(), operation, @@ -189,6 +190,7 @@ impl<'a> DefinitionExpander<'a> { self.expand_definition( gate.label(), definition.circuit(), + &definition.qubits_in_numeric_order(), gate.num_qubits() as usize, gate.num_params() as usize, operation, @@ -240,6 +242,7 @@ impl<'a> DefinitionExpander<'a> { &mut self, name: &str, definition: &Circuit, + definition_qubits: &[Qubit], expected_qubits: usize, expected_params: usize, operation: &Operation, @@ -289,7 +292,6 @@ impl<'a> DefinitionExpander<'a> { next_symbol_bindings.insert(symbol.clone(), value); } - let definition_qubits = definition.qubits(); if definition_qubits.len() != expected_qubits { return Err(CompilerError::InvariantViolation(format!( "definition '{name}' signature has {expected_qubits} qubits but backing circuit has {} qubits", diff --git a/crates/cqlib-core/src/ir/qasm2/dump.rs b/crates/cqlib-core/src/ir/qasm2/dump.rs index 03af805a..a80aa55d 100644 --- a/crates/cqlib-core/src/ir/qasm2/dump.rs +++ b/crates/cqlib-core/src/ir/qasm2/dump.rs @@ -596,7 +596,7 @@ fn dump_unitary_gate_definition( // Body context let mut body_qubit_map = HashMap::new(); let inner_circuit = &frozen_circuit.circuit; - for (i, q) in inner_circuit.qubits().iter().enumerate() { + for (i, q) in frozen_circuit.qubits_in_numeric_order().iter().enumerate() { body_qubit_map.insert(*q, format!("q{}", i)); } diff --git a/crates/cqlib-core/src/ir/qasm3/dump.rs b/crates/cqlib-core/src/ir/qasm3/dump.rs index 7aa5371b..a6bd1313 100644 --- a/crates/cqlib-core/src/ir/qasm3/dump.rs +++ b/crates/cqlib-core/src/ir/qasm3/dump.rs @@ -550,7 +550,7 @@ fn dump_unitary_gate_definition( .collect(); writeln!(output, "gate {label} {} {{", qubits.join(","))?; let mut qubit_map = HashMap::new(); - for (index, qubit) in frozen.circuit.qubits().iter().copied().enumerate() { + for (index, qubit) in frozen.qubits_in_numeric_order().into_iter().enumerate() { qubit_map.insert(qubit, format!("q{index}")); } dump_global_phase(&frozen.circuit, output, &HashMap::new())?; From 916651dca8fe09d3d92a529e28b29d95c6e58b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Sat, 17 Oct 2026 23:00:20 +0800 Subject: [PATCH 11/16] fix(compile): bind definition arguments by gate signature --- .../compile/transform/decompose/definition.rs | 234 +++++++++++++++++- .../cqlib-core/src/compile/workflow_test.rs | 75 ++++++ 2 files changed, 299 insertions(+), 10 deletions(-) diff --git a/crates/cqlib-core/src/compile/transform/decompose/definition.rs b/crates/cqlib-core/src/compile/transform/decompose/definition.rs index 12d8e386..97fd9859 100644 --- a/crates/cqlib-core/src/compile/transform/decompose/definition.rs +++ b/crates/cqlib-core/src/compile/transform/decompose/definition.rs @@ -26,6 +26,7 @@ use crate::compile::transform::analysis::WorkflowCircuitAnalysis; use crate::compile::transform::rebuild::{CircuitRebuildContext, ClassicalRemap}; use crate::compile::transform::transformer::{PassApplicability, WorkflowPass}; use crate::compile::transform::{CircuitAnalysis, TransformOutcome, Transformer}; +use indexmap::IndexSet; use smallvec::{SmallVec, smallvec}; use std::collections::{HashMap, HashSet}; @@ -173,6 +174,7 @@ impl<'a> DefinitionExpander<'a> { gate.name(), definition.circuit(), &definition.circuit().qubits(), + gate.signature_params(), gate.num_qubits(), gate.num_params(), operation, @@ -187,10 +189,13 @@ impl<'a> DefinitionExpander<'a> { Instruction::UnitaryGate(gate) => { if let Some(definition) = gate.circuit().as_ref() { self.changed = true; + // Circuit-backed unitaries use the cached live-symbol order, + // matching UnitaryGate::matrix_for_params. self.expand_definition( gate.label(), definition.circuit(), &definition.qubits_in_numeric_order(), + definition.used_symbols(), gate.num_qubits() as usize, gate.num_params() as usize, operation, @@ -243,6 +248,7 @@ impl<'a> DefinitionExpander<'a> { name: &str, definition: &Circuit, definition_qubits: &[Qubit], + signature_params: &IndexSet, expected_qubits: usize, expected_params: usize, operation: &Operation, @@ -272,11 +278,10 @@ impl<'a> DefinitionExpander<'a> { ))); } - let definition_symbols = definition.symbols(); - if definition_symbols.len() != expected_params { + if signature_params.len() != expected_params { return Err(CompilerError::InvariantViolation(format!( - "definition '{name}' signature has {expected_params} parameters but backing circuit has {} symbols", - definition_symbols.len() + "definition '{name}' expects {expected_params} parameters but its signature has {} parameters", + signature_params.len() ))); } @@ -287,8 +292,10 @@ impl<'a> DefinitionExpander<'a> { // nested definition must receive a fresh binding table instead of a // merged parent table. Merging would make equal symbol names in nested // definitions accidentally capture outer bindings. - let mut next_symbol_bindings = HashMap::with_capacity(definition_symbols.len()); - for (symbol, value) in definition_symbols.iter().zip(resolved_params) { + // Only the call signature defines positional bindings. Live dependencies + // may omit unused formals, and the circuit registry may retain stale symbols. + let mut next_symbol_bindings = HashMap::with_capacity(signature_params.len()); + for (symbol, value) in signature_params.iter().zip(resolved_params) { next_symbol_bindings.insert(symbol.clone(), value); } @@ -601,8 +608,9 @@ fn fresh_temp_symbol(index: usize, symbol: &str, occupied: &mut HashSet) #[cfg(test)] mod tests { use super::*; - use crate::circuit::ParameterValue; use crate::circuit::gate::{FrozenCircuit, UnitaryGate}; + use crate::circuit::test_utils::assert_matrix_approx_eq; + use crate::circuit::{CircuitGate, ParameterValue, circuit_to_matrix}; use crate::circuit::{ClassicalDataOp, ClassicalExpr}; use crate::compile::transform::TransformerTestExt; use ndarray::array; @@ -708,6 +716,192 @@ mod tests { assert_eq!(rz_param.evaluate(&Some(bindings)).unwrap(), 6.0); } + #[test] + fn expands_circuit_gate_using_declared_parameter_signature() { + let q0 = Qubit::new(0); + let mut expected = Circuit::new(1); + expected.rx(q0, -0.2).unwrap(); + expected.rz(q0, 0.7).unwrap(); + expected.set_global_phase(Parameter::from(-1.6)); + let expected_matrix = circuit_to_matrix(&expected, None).unwrap(); + + for stale_registry in [false, true] { + for unused_formal in [false, true] { + let mut definition = Circuit::new(1); + if stale_registry { + definition.add_parameter(Parameter::symbol("stale")); + } + definition.rx(q0, Parameter::symbol("theta")).unwrap(); + definition.rz(q0, Parameter::symbol("phi")).unwrap(); + definition + .set_global_phase(Parameter::symbol("theta") - Parameter::symbol("phi") * 2.0); + + let mut signature = vec!["phi".to_string(), "theta".to_string()]; + let mut args = vec![ParameterValue::from(0.7), ParameterValue::from(-0.2)]; + if unused_formal { + signature.insert(0, "unused".to_string()); + args.insert(0, ParameterValue::from(11.0)); + } + let gate = CircuitGate::with_signature( + "declared", + FrozenCircuit::new(definition), + signature, + ) + .unwrap(); + let mut circuit = Circuit::new(1); + circuit.circuit_gate(gate, vec![q0], args).unwrap(); + + let expanded = expand_definitions(&circuit).unwrap(); + assert_eq!(expanded.operations().len(), 2); + for actual in [&circuit, &circuit.decompose().unwrap(), &expanded] { + assert_matrix_approx_eq( + &circuit_to_matrix(actual, None).unwrap(), + &expected_matrix, + 1e-12, + ); + } + } + } + } + + #[test] + fn expands_inferred_signatures_without_removed_operation_symbols() { + let q0 = Qubit::new(0); + let mut definition = Circuit::new(1); + definition.rx(q0, Parameter::symbol("removed")).unwrap(); + definition.remove_operation(0).unwrap(); + definition.rx(q0, Parameter::symbol("theta")).unwrap(); + definition.rz(q0, Parameter::symbol("phi")).unwrap(); + let circuit_gate = definition.clone().to_gate("inferred").unwrap(); + let unitary_gate = UnitaryGate::new("inferred_unitary", 1, 2) + .with_circuit(Arc::new(FrozenCircuit::new(definition))) + .unwrap(); + + let mut expected = Circuit::new(1); + expected.rx(q0, 0.7).unwrap(); + expected.rz(q0, -0.2).unwrap(); + let expected_matrix = circuit_to_matrix(&expected, None).unwrap(); + for gate in [ + circuit_gate, + Instruction::UnitaryGate(Box::new(unitary_gate)), + ] { + let mut circuit = Circuit::new(1); + circuit + .append( + gate, + [q0], + [ParameterValue::from(0.7), ParameterValue::from(-0.2)], + None, + ) + .unwrap(); + let expanded = expand_definitions(&circuit).unwrap(); + for actual in [&circuit, &expanded] { + assert_matrix_approx_eq( + &circuit_to_matrix(actual, None).unwrap(), + &expected_matrix, + 1e-12, + ); + } + } + } + + #[test] + fn expands_phase_only_definition_with_unused_formal() { + let mut definition = Circuit::new(1); + definition.set_global_phase(Parameter::symbol("phase")); + let gate = CircuitGate::with_signature( + "phase_only", + FrozenCircuit::new(definition), + ["unused".to_string(), "phase".to_string()], + ) + .unwrap(); + let mut circuit = Circuit::new(1); + circuit + .append( + Instruction::CircuitGate(Box::new(gate)), + [Qubit::new(0)], + [ParameterValue::from(11.0), ParameterValue::from(0.7)], + None, + ) + .unwrap(); + let expanded = expand_definitions(&circuit).unwrap(); + assert!(expanded.operations().is_empty()); + assert!((expanded.global_phase().evaluate(&None).unwrap() - 0.7).abs() < 1e-12); + assert_matrix_approx_eq( + &circuit_to_matrix(&expanded, None).unwrap(), + &circuit_to_matrix(&circuit, None).unwrap(), + 1e-12, + ); + } + + #[test] + fn expands_nested_signatures_with_simultaneous_symbolic_arguments() { + let q0 = Qubit::new(0); + let theta = Parameter::symbol("theta"); + let phi = Parameter::symbol("phi"); + let mut leaf = Circuit::new(1); + leaf.rx(q0, theta.clone()).unwrap(); + leaf.rz(q0, phi.clone()).unwrap(); + leaf.set_global_phase(theta.clone() + phi.clone() * 2.0); + let leaf_gate = CircuitGate::with_signature( + "leaf", + FrozenCircuit::new(leaf), + ["unused".to_string(), "phi".to_string(), "theta".to_string()], + ) + .unwrap(); + + let mut middle = Circuit::new(1); + middle + .append( + Instruction::CircuitGate(Box::new(leaf_gate)), + [q0], + [ + ParameterValue::from(11.0), + ParameterValue::from(theta.clone() + 1.0), + ParameterValue::from(phi.clone() - 0.5), + ], + None, + ) + .unwrap(); + let middle_gate = CircuitGate::with_signature( + "middle", + FrozenCircuit::new(middle), + ["phi".to_string(), "theta".to_string()], + ) + .unwrap(); + let mut circuit = Circuit::new(1); + circuit + .append( + Instruction::CircuitGate(Box::new(middle_gate)), + [q0], + [ + ParameterValue::from(theta.clone()), + ParameterValue::from(phi.clone()), + ], + None, + ) + .unwrap(); + + let mut expected = Circuit::new(1); + expected.rx(q0, theta.clone() - 0.5).unwrap(); + expected.rz(q0, phi.clone() + 1.0).unwrap(); + expected.set_global_phase(theta - 0.5 + (phi + 1.0) * 2.0); + let expanded = expand_definitions(&circuit).unwrap(); + for (theta, phi) in [(0.2, 0.8), (-0.4, 0.1)] { + let bindings = Some(HashMap::from([("theta", theta), ("phi", phi)])); + let expected_matrix = + circuit_to_matrix(&expected.assign_parameters(&bindings).unwrap(), None).unwrap(); + for actual in [&circuit, &expanded] { + let bound = actual.assign_parameters(&bindings).unwrap(); + assert_matrix_approx_eq( + &circuit_to_matrix(&bound, None).unwrap(), + &expected_matrix, + 1e-12, + ); + } + } + } + #[test] fn uses_simultaneous_parameter_substitution() { let mut inner = Circuit::new(1); @@ -896,15 +1090,27 @@ mod tests { #[test] fn expands_definitions_in_control_flow_body_with_body_local_phase() { let mut inner = Circuit::new(1); - inner.set_global_phase(Parameter::from(0.5)); + inner.set_global_phase(Parameter::symbol("phase")); inner.x(Qubit::new(0)).unwrap(); - let gate = inner.to_gate("phase_x").unwrap(); + let gate = CircuitGate::with_signature( + "phase_x", + FrozenCircuit::new(inner), + ["unused".to_string(), "phase".to_string()], + ) + .unwrap(); let mut circuit = Circuit::new(2); circuit .if_else( ClassicalExpr::bool_literal(true), - |body| body.append(gate, [Qubit::new(1)], [], None), + |body| { + body.append( + Instruction::CircuitGate(Box::new(gate)), + [Qubit::new(1)], + [ParameterValue::from(11.0), ParameterValue::from(0.5)], + None, + ) + }, |_body| Ok(()), ) .unwrap(); @@ -922,6 +1128,14 @@ mod tests { then_body[0].instruction, Instruction::Standard(StandardGate::GPhase) )); + assert!( + (op_param(&expanded, &then_body[0].params[0]) + .evaluate(&None) + .unwrap() + - 0.5) + .abs() + < 1e-12 + ); assert!(matches!( then_body[1].instruction, Instruction::Standard(StandardGate::X) diff --git a/crates/cqlib-core/src/compile/workflow_test.rs b/crates/cqlib-core/src/compile/workflow_test.rs index 689dd5b6..7a87dcd7 100644 --- a/crates/cqlib-core/src/compile/workflow_test.rs +++ b/crates/cqlib-core/src/compile/workflow_test.rs @@ -1365,6 +1365,81 @@ fn workflow_expands_circuit_gate_definitions_before_optimization() { assert!(!contains_high_level_gate(&result.circuit)); } +#[test] +fn workflow_preserves_qasm_gate_signatures_in_both_modes() { + let declarations = r#" + gate reordered(phi, theta) a, b { + rx(theta) a; + rz(phi) b; + cx a, b; + } + gate declared(unused, phi, theta) a, b { + reordered(phi, theta) a, b; + } + "#; + let mut expected = Circuit::new(2); + expected.rx(Qubit::new(1), -0.2).unwrap(); + expected.rz(Qubit::new(0), 0.7).unwrap(); + expected.cx(Qubit::new(1), Qubit::new(0)).unwrap(); + let expected_matrix = crate::circuit::circuit_to_matrix(&expected, None).unwrap(); + let basis = vec![ + Instruction::Standard(StandardGate::RX), + Instruction::Standard(StandardGate::RY), + Instruction::Standard(StandardGate::CZ), + ]; + let device = Device::bidirectional_line("declared-signatures", 2) + .unwrap() + .with_native_gates(basis.clone()) + .unwrap(); + + for call in ["reordered(0.7, -0.2)", "declared(11.0, 0.7, -0.2)"] { + let qasm2 = format!( + "OPENQASM 2.0; include \"qelib1.inc\"; {declarations} qreg q[2]; {call} q[1], q[0];" + ); + let qasm3 = format!( + "OPENQASM 3.0; include \"stdgates.inc\"; {declarations} qubit[2] q; {call} q[1], q[0];" + ); + for circuit in [ + crate::ir::qasm2_loads(&qasm2).unwrap(), + crate::ir::qasm3_loads(&qasm3).unwrap(), + ] { + for mode in [CompileMode::Normal, CompileMode::Enhanced] { + for target in [ + CompileTarget::Logical, + CompileTarget::Basis(basis.clone()), + CompileTarget::Device(DeviceCompileTarget { + device: device.clone(), + initial_layout: Some(Layout::from_pairs(&[(0, 0), (1, 1)], 2).unwrap()), + seed: Some(47), + }), + ] { + let device_target = matches!(target, CompileTarget::Device(_)); + let result = compile( + &circuit, + CompileConfig { + mode, + target, + resource_policy: ResourcePolicy::default(), + }, + ) + .unwrap(); + + assert!(result.step_changed("decompose.definitions")); + assert!(!contains_high_level_gate(&result.circuit)); + crate::circuit::test_utils::assert_matrix_approx_eq( + &crate::circuit::circuit_to_matrix(&result.circuit, None).unwrap(), + &expected_matrix, + 1e-9, + ); + if device_target { + device.validate_circuit(&result.circuit).unwrap(); + } + } + } + } + } +} + #[test] fn definition_expansion_invalidates_analysis_before_unitary_decomposition() { let q0 = Qubit::new(0); From 8f3cb1c26532fe43bfe26b92ac0afafcd12ea8be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Sat, 17 Oct 2026 23:09:54 +0800 Subject: [PATCH 12/16] fix(circuit): normalize circuit-backed unitary instruction matrices Generate MSB-first matrices at the instruction boundary while preserving the public definition matrix convention. Cover commutation, positional qubit order, parameter binding, global phase, and explicit matrix precedence. --- .../src/circuit/gate/instruction.rs | 10 +- .../src/circuit/gate/instruction_test.rs | 121 ++++++++++++++++++ .../src/circuit/gate/unitary_gate.rs | 29 ++++- crates/cqlib-core/src/circuit/operation.rs | 6 +- .../compile/commutation/commutation_test.rs | 104 +++++++++++++++ 5 files changed, 262 insertions(+), 8 deletions(-) diff --git a/crates/cqlib-core/src/circuit/gate/instruction.rs b/crates/cqlib-core/src/circuit/gate/instruction.rs index 6d0f6b98..7047f713 100644 --- a/crates/cqlib-core/src/circuit/gate/instruction.rs +++ b/crates/cqlib-core/src/circuit/gate/instruction.rs @@ -318,12 +318,14 @@ impl Instruction { /// Computes the unitary matrix representation of the instruction. /// + /// The first qubit argument is the most-significant bit of the local + /// matrix for every supported gate, including circuit-backed UnitaryGate + /// definitions. Full circuit matrices instead use an LSB-first basis. + /// /// # Arguments /// /// * `params` - A slice of floating-point parameters (e.g., rotation angles). - /// CircuitGate values bind positionally to its declared signature. Its - /// first qubit argument is the most-significant bit of the local matrix, - /// matching standard gates rather than the full circuit's LSB-first basis. + /// CircuitGate values bind positionally to its declared signature. /// /// # Returns /// @@ -334,7 +336,7 @@ impl Instruction { match self { Instruction::Standard(g) => g.matrix(params).ok(), Instruction::McGate(g) => g.matrix(params).ok(), - Instruction::UnitaryGate(g) => g.matrix_for_params(params).ok(), + Instruction::UnitaryGate(g) => g.matrix_for_params_in_gate_order(params).ok(), Instruction::CircuitGate(g) => g.matrix_for_params(params).ok().map(Cow::Owned), Instruction::Directive(_) => None, Instruction::ClassicalData(_) => None, diff --git a/crates/cqlib-core/src/circuit/gate/instruction_test.rs b/crates/cqlib-core/src/circuit/gate/instruction_test.rs index 93f83765..426cf7b1 100644 --- a/crates/cqlib-core/src/circuit/gate/instruction_test.rs +++ b/crates/cqlib-core/src/circuit/gate/instruction_test.rs @@ -16,6 +16,127 @@ use crate::circuit::{ Circuit, CircuitId, ClassicalExpr, ClassicalType, ClassicalValue, ClassicalVar, Qubit, }; +#[test] +fn circuit_backed_unitary_instruction_matrix_uses_msb_first_qubits() { + use crate::circuit::circuit_to_matrix; + use crate::circuit::test_utils::assert_matrix_approx_eq; + + for order in [vec![], vec![7], vec![0, 1], vec![1, 0], vec![9, 2, 7]] { + let mut numeric_order = order.clone(); + numeric_order.sort_unstable(); + for parameterized in [false, true] { + let mut body = + Circuit::from_qubits(order.iter().copied().map(Qubit::new).collect()).unwrap(); + if let Some(&first) = numeric_order.first() { + body.rx( + Qubit::new(first), + if parameterized { + Parameter::symbol("theta") + } else { + Parameter::from(0.7) + }, + ) + .unwrap(); + if numeric_order.len() > 1 { + body.cx( + Qubit::new(first), + Qubit::new(*numeric_order.last().unwrap()), + ) + .unwrap(); + } + } + body.set_global_phase(if parameterized { + Parameter::symbol("theta") + } else { + Parameter::from(0.7) + }); + let gate = UnitaryGate::new("positional", order.len() as u16, u16::from(parameterized)) + .with_circuit(Arc::new(FrozenCircuit::new(body.clone()))) + .unwrap(); + let params = if parameterized { vec![0.7] } else { vec![] }; + let bindings = Some(std::collections::HashMap::from([("theta", 0.7)])); + let bound = body.assign_parameters(&bindings).unwrap(); + let lsb_order: Vec = numeric_order.iter().map(|&q| q as usize).collect(); + // The definition API keeps its existing numeric-ID, LSB-first basis. + assert_matrix_approx_eq( + gate.matrix_for_params(¶ms).unwrap().as_ref(), + &circuit_to_matrix(&bound, Some(&lsb_order)).unwrap(), + 1e-12, + ); + + let instruction = Instruction::UnitaryGate(Box::new(gate)); + let matrix = instruction.matrix(¶ms).unwrap(); + let qubits: Vec = [11, 3, 8][..order.len()] + .iter() + .copied() + .map(Qubit::new) + .collect(); + let mut expected = Circuit::from_qubits(qubits.clone()).unwrap(); + if let Some(&first) = qubits.first() { + expected.rx(first, Parameter::from(0.7)).unwrap(); + if qubits.len() > 1 { + expected.cx(first, *qubits.last().unwrap()).unwrap(); + } + } + expected.set_global_phase(Parameter::from(0.7)); + let msb_order: Vec = qubits.iter().rev().map(|q| q.index()).collect(); + assert_matrix_approx_eq( + matrix.as_ref(), + &circuit_to_matrix(&expected, Some(&msb_order)).unwrap(), + 1e-12, + ); + let operation = crate::circuit::Operation { + instruction: instruction.clone(), + qubits: qubits.into_iter().collect(), + params: params + .into_iter() + .map(crate::circuit::CircuitParam::Fixed) + .collect(), + label: None, + }; + assert_matrix_approx_eq(operation.matrix().unwrap().as_ref(), matrix.as_ref(), 1e-12); + if parameterized { + for invalid in [vec![], vec![f64::NAN], vec![f64::INFINITY], vec![0.7, 0.2]] { + assert!(instruction.matrix(&invalid).is_none()); + } + } + } + } +} + +#[test] +fn unitary_instruction_matrix_keeps_explicit_matrix_order_and_precedence() { + use crate::circuit::symbolic_matrix::standard_gate_symbolic_matrix; + + let mut body = Circuit::from_qubits(vec![Qubit::new(7), Qubit::new(2)]).unwrap(); + body.cx(Qubit::new(2), Qubit::new(7)).unwrap(); + let frozen = Arc::new(FrozenCircuit::new(body)); + let numeric = StandardGate::CX.matrix(&[]).unwrap(); + for with_circuit in [false, true] { + for symbolic in [false, true] { + let gate = UnitaryGate::new("explicit_cx", 2, 0); + let gate = if symbolic { + gate.with_symbolic_matrix( + std::iter::empty::(), + standard_gate_symbolic_matrix(StandardGate::CX, &[]).unwrap(), + ) + } else { + gate.with_matrix(numeric.clone().into_owned()) + } + .unwrap(); + let gate = if with_circuit { + gate.with_circuit(frozen.clone()).unwrap() + } else { + gate + }; + let instruction = Instruction::UnitaryGate(Box::new(gate)); + let matrix = instruction.matrix(&[]).unwrap(); + assert_eq!(matrix.as_ref(), numeric.as_ref()); + assert_eq!(matches!(matrix, Cow::Borrowed(_)), !symbolic); + } + } +} + #[test] fn circuit_gate_operation_matrix_respects_positional_qubits() { for order in [[0, 1], [1, 0], [7, 2]] { diff --git a/crates/cqlib-core/src/circuit/gate/unitary_gate.rs b/crates/cqlib-core/src/circuit/gate/unitary_gate.rs index 2fec804f..35e104c6 100644 --- a/crates/cqlib-core/src/circuit/gate/unitary_gate.rs +++ b/crates/cqlib-core/src/circuit/gate/unitary_gate.rs @@ -286,9 +286,31 @@ impl UnitaryGate { } /// Resolves this gate into a concrete matrix for the supplied parameter values. + /// + /// Explicit matrices use MSB-first argument order. A circuit-only definition + /// retains its numeric-ID, LSB-first basis for compatibility with circuit + /// matrix construction. Use [`Instruction::matrix`](super::Instruction::matrix) + /// for a uniform MSB-first gate-local matrix. pub fn matrix_for_params( &self, params: &[f64], + ) -> Result>>, CircuitError> { + self.resolve_matrix_for_params(params, false) + } + + /// Resolves a gate-local matrix with the first argument at MSB, regardless + /// of whether the definition supplies a matrix or a circuit. + pub(crate) fn matrix_for_params_in_gate_order( + &self, + params: &[f64], + ) -> Result>>, CircuitError> { + self.resolve_matrix_for_params(params, true) + } + + fn resolve_matrix_for_params( + &self, + params: &[f64], + circuit_msb_first: bool, ) -> Result>>, CircuitError> { self.validate_param_values(params)?; @@ -323,11 +345,16 @@ impl UnitaryGate { let assigned = inner .assign_parameters(&Some(bindings)) .map_err(|_| CircuitError::SymbolicParameterError)?; - let order: Vec = circuit + let mut order: Vec = circuit .qubits_in_numeric_order() .iter() .map(|q| q.index()) .collect(); + if circuit_msb_first { + // circuit_to_matrix takes an LSB-first order. Generate the + // instruction basis directly instead of permuting a dense matrix. + order.reverse(); + } return circuit_to_matrix(&assigned, Some(&order)).map(Cow::Owned); } diff --git a/crates/cqlib-core/src/circuit/operation.rs b/crates/cqlib-core/src/circuit/operation.rs index 5a35afa0..1d4b9ad7 100644 --- a/crates/cqlib-core/src/circuit/operation.rs +++ b/crates/cqlib-core/src/circuit/operation.rs @@ -102,9 +102,9 @@ impl Operation { /// # Returns /// /// * `Ok(Cow)` - The unitary matrix. It may be borrowed (static) or owned (computed). - /// CircuitGate matrices bind their declared positional parameters and put - /// the first qubit argument at the most-significant local basis bit, like - /// standard gates. Full circuit matrices instead use an LSB-first basis. + /// All gate matrices put the first qubit argument at the most-significant + /// local basis bit, including circuit-backed UnitaryGate and CircuitGate + /// definitions. Full circuit matrices instead use an LSB-first basis. /// * `Err(CircuitError)` - If the operation is non-unitary or contains an /// unresolved symbolic parameter. /// diff --git a/crates/cqlib-core/src/compile/commutation/commutation_test.rs b/crates/cqlib-core/src/compile/commutation/commutation_test.rs index d3418683..4cea713b 100644 --- a/crates/cqlib-core/src/compile/commutation/commutation_test.rs +++ b/crates/cqlib-core/src/compile/commutation/commutation_test.rs @@ -80,6 +80,110 @@ fn assert_pi_phase(result: CommutationResult) { assert!((phase.evaluate(&None).unwrap() - PI).abs() < 1e-10); } +#[test] +fn circuit_backed_unitary_matrix_fallback_respects_targets_and_parameters() { + for order in [vec![0, 1], vec![1, 0], vec![7, 2], vec![9, 2, 7]] { + let mut numeric_order = order.clone(); + numeric_order.sort_unstable(); + let qubits: Vec = [5, 1, 8][..order.len()] + .iter() + .copied() + .map(Qubit::new) + .collect(); + for active in 0..order.len() { + for parameterized in [false, true] { + let mut body = + Circuit::from_qubits(order.iter().copied().map(Qubit::new).collect()).unwrap(); + body.rx( + Qubit::new(numeric_order[active]), + if parameterized { + Parameter::symbol("theta") + } else { + Parameter::from(0.7) + }, + ) + .unwrap(); + body.set_global_phase(Parameter::from(0.19)); + let instruction = Instruction::UnitaryGate(Box::new( + UnitaryGate::new("rx_one_port", order.len() as u16, u16::from(parameterized)) + .with_circuit(std::sync::Arc::new(FrozenCircuit::new(body))) + .unwrap(), + )); + let params = if parameterized { + vec![Parameter::from(0.7)] + } else { + vec![] + }; + let z = Instruction::Standard(StandardGate::Z); + for checker in [ + CommutationChecker::builtin(), + CommutationChecker::with_config(CommutationConfig { + enable_rule_oracle: false, + ..CommutationConfig::default() + }), + ] { + for (position, &qubit) in qubits.iter().enumerate() { + for result in [ + checker.check(&instruction, &qubits, ¶ms, &z, &[qubit], &[]), + checker.check(&z, &[qubit], &[], &instruction, &qubits, ¶ms), + ] { + if position == active { + assert!(result.is_none(), "order={order:?}, active={active}"); + } else { + assert_exact(result); + } + } + } + } + if parameterized { + assert!( + super::matrix::matrix_commutation( + &instruction, + &qubits, + &[Parameter::symbol("unresolved")], + &z, + &[qubits[active]], + &[], + 4, + ) + .is_none() + ); + } + assert!( + super::matrix::matrix_commutation( + &instruction, + &qubits, + ¶ms, + &z, + &[qubits[active]], + &[], + order.len() - 1, + ) + .is_none() + ); + } + } + } +} + +#[test] +fn circuit_backed_unitary_matrix_fallback_preserves_global_phase_proof() { + let mut body = Circuit::new(2); + body.x(Qubit::new(0)).unwrap(); + body.set_global_phase(Parameter::from(0.19)); + let instruction = Instruction::UnitaryGate(Box::new( + UnitaryGate::new("x_first", 2, 0) + .with_circuit(std::sync::Arc::new(FrozenCircuit::new(body))) + .unwrap(), + )); + let qubits = [Qubit::new(5), Qubit::new(1)]; + let z = Instruction::Standard(StandardGate::Z); + let checker = CommutationChecker::builtin(); + assert_pi_phase(checker.check(&instruction, &qubits, &[], &z, &[qubits[0]], &[])); + assert_pi_phase(checker.check(&z, &[qubits[0]], &[], &instruction, &qubits, &[])); + assert_exact(checker.check(&instruction, &qubits, &[], &z, &[qubits[1]], &[])); +} + #[test] fn circuit_gate_operation_matrix_does_not_certify_false_commutation() { let mut body = Circuit::new(2); From 26d2c18003c177ff8290370bec9993136df4a899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Sun, 18 Oct 2026 09:15:58 +0800 Subject: [PATCH 13/16] fix(qis): resolve readout noise IDs from storage positions --- .../binding-c/src/qis/density_matrix_noise.rs | 4 + crates/binding-c/tests/qis_noise_test.rs | 40 ++++- .../cqlib/qis/state/density_matrix_noise.pyi | 11 +- .../src/qis/state/density_matrix_noise.rs | 11 +- .../tests/qis/test_density_matrix_noise.py | 29 ++++ .../src/qis/state/density_matrix_noise.rs | 60 +++++--- .../qis/state/density_matrix_noise_test.rs | 140 ++++++++++++++++++ docs/api/cn/c/3_qis/3_density_matrix_noise.md | 2 +- .../cn/python/3_qis/3_density_matrix_noise.md | 2 +- .../cn/rust/3_qis/3_density_matrix_noise.md | 2 +- docs/api/en/c/3_qis/3_density_matrix_noise.md | 2 +- .../en/python/3_qis/3_density_matrix_noise.md | 2 +- .../en/rust/3_qis/3_density_matrix_noise.md | 2 +- 13 files changed, 277 insertions(+), 30 deletions(-) diff --git a/crates/binding-c/src/qis/density_matrix_noise.rs b/crates/binding-c/src/qis/density_matrix_noise.rs index c84d7c08..85d7be0b 100644 --- a/crates/binding-c/src/qis/density_matrix_noise.rs +++ b/crates/binding-c/src/qis/density_matrix_noise.rs @@ -682,6 +682,10 @@ pub extern "C" fn density_matrix_noise_probabilities_with_readout_len( /// Copy the probability distribution including readout errors configured in /// the noise model for `qubits` into `buffer`. Returns 0 on success. /// +/// `qubits` contains storage positions. For a circuit-bound state, noise is +/// looked up using the circuit ID at each position; unbound states use identity +/// IDs. The full output distribution retains storage order. +/// /// `qubits` may be NULL when `qubit_len` is 0, in which case no readout /// error is applied and the result matches `density_matrix_noise_probabilities`. #[unsafe(no_mangle)] diff --git a/crates/binding-c/tests/qis_noise_test.rs b/crates/binding-c/tests/qis_noise_test.rs index 989e236e..21f69a3b 100644 --- a/crates/binding-c/tests/qis_noise_test.rs +++ b/crates/binding-c/tests/qis_noise_test.rs @@ -2,7 +2,9 @@ #![allow(dead_code)] -use binding_c::circuit::{circuit_cx, circuit_free, circuit_h, circuit_new, circuit_x}; +use binding_c::circuit::{ + circuit_cx, circuit_free, circuit_from_qubits, circuit_h, circuit_new, circuit_x, +}; use binding_c::cqlib_string_free; use binding_c::device::{ NOISE_BIT_FLIP, noise_model_add_readout, noise_model_add_single_qubit, noise_model_free, @@ -216,6 +218,42 @@ fn test_readout_error_difference() { noise_model_free(nm); } +#[test] +fn test_readout_uses_circuit_ids_at_storage_positions() { + for ids in [[0, 1], [1, 0], [2, 0], [9, 2]] { + let circuit = circuit_from_qubits(ids.as_ptr(), ids.len()); + assert!(!circuit.is_null()); + let model = noise_model_new(); + assert_eq!(noise_model_add_readout(model, ids[0], 0.0, 1.0), 0); + let sim = density_matrix_noise_from_circuit(circuit, model); + assert!(!sim.is_null()); + assert_eq!(density_matrix_noise_probabilities_with_readout_len(sim), 4); + for (positions, expected) in [ + (&[0][..], [0.0, 1.0, 0.0, 0.0]), + (&[1][..], [1.0, 0.0, 0.0, 0.0]), + (&[1, 0][..], [0.0, 1.0, 0.0, 0.0]), + (&[][..], [1.0, 0.0, 0.0, 0.0]), + ] { + let mut actual = [0.0; 4]; + assert_eq!( + density_matrix_noise_probabilities_with_readout( + sim, + actual.as_mut_ptr(), + actual.len(), + positions.as_ptr(), + positions.len() + ), + 0 + ); + assert_eq!(actual, expected); + } + assert_eq!(read_probs(sim, 4), vec![1.0, 0.0, 0.0, 0.0]); + density_matrix_noise_free(sim); + noise_model_free(model); + circuit_free(circuit); + } +} + #[test] fn test_multi_qubit_readout() { // Mirrors core `test_multi_qubit_readout_error`. diff --git a/crates/binding-python/cqlib/qis/state/density_matrix_noise.pyi b/crates/binding-python/cqlib/qis/state/density_matrix_noise.pyi index f0fadb76..67706b5f 100644 --- a/crates/binding-python/cqlib/qis/state/density_matrix_noise.pyi +++ b/crates/binding-python/cqlib/qis/state/density_matrix_noise.pyi @@ -121,10 +121,17 @@ class DensityMatrixNoise: """Computes measurement probabilities with readout error modeling. Args: - qubits: Indices of qubits to measure + qubits: Storage positions on which to apply readout errors. For a + circuit-bound state, noise is looked up using the circuit ID at + each position; unbound states use identity IDs. Returns: - A vector of probabilities for all 2^n computational basis states. + A vector of probabilities for all 2^num_qubits computational basis + states in storage order, independent of the selection order or size. + An empty selection applies no readout errors. + + Raises: + IndexError: A position is out of range, even without a noise model. """ ... diff --git a/crates/binding-python/src/qis/state/density_matrix_noise.rs b/crates/binding-python/src/qis/state/density_matrix_noise.rs index 8916923b..a70358d3 100644 --- a/crates/binding-python/src/qis/state/density_matrix_noise.rs +++ b/crates/binding-python/src/qis/state/density_matrix_noise.rs @@ -170,10 +170,17 @@ impl PyDensityMatrixNoise { /// Computes measurement probabilities with readout error modeling. /// /// Args: - /// qubits: Indices of qubits to measure + /// qubits: Storage positions on which to apply readout errors. For a + /// circuit-bound state, noise is looked up using the circuit ID at + /// each position; unbound states use identity IDs. /// /// Returns: - /// A vector of probabilities for all 2^n computational basis states. + /// A vector of probabilities for all 2^num_qubits computational basis + /// states in storage order, independent of the selection order or size. + /// An empty selection applies no readout errors. + /// + /// Raises: + /// IndexError: A position is out of range, even without a noise model. fn probabilities_with_readout(&self, py: Python<'_>, qubits: Vec) -> PyResult> { py.detach(|| self.inner.probabilities_with_readout(&qubits)) .map_err(qis_error_to_py_err) diff --git a/crates/binding-python/tests/qis/test_density_matrix_noise.py b/crates/binding-python/tests/qis/test_density_matrix_noise.py index 22184776..21687db0 100644 --- a/crates/binding-python/tests/qis/test_density_matrix_noise.py +++ b/crates/binding-python/tests/qis/test_density_matrix_noise.py @@ -101,6 +101,35 @@ def test_probabilities_with_readout(self): assert len(probs) == 4 assert abs(sum(probs) - 1.0) < 1e-10 + @pytest.mark.parametrize("ids", [[0, 1], [1, 0], [2, 0], [9, 2]]) + @pytest.mark.parametrize("bind_with_apply", [False, True]) + def test_readout_uses_circuit_ids_at_storage_positions(self, ids, bind_with_apply): + circuit = Circuit(ids) + receipt = circuit.measure(ids[0]) + model = NoiseModel() + model.add_readout_error(ids[0], ReadoutError(0.0, 1.0)) + if bind_with_apply: + sim = DensityMatrixNoise(2, model) + sim.apply_circuit(circuit) + else: + sim = DensityMatrixNoise.from_circuit(circuit, model) + + assert sim.probabilities_with_readout([0]) == [0.0, 1.0, 0.0, 0.0] + assert sim.probabilities_with_readout([1]) == [1.0, 0.0, 0.0, 0.0] + assert sim.probabilities_with_readout([1, 0]) == [0.0, 1.0, 0.0, 0.0] + assert sim.probabilities_with_readout([]) == sim.probabilities() + assert sim.probabilities() == [1.0, 0.0, 0.0, 0.0] + marginal = { + o.to_bitstring(1): p for o, p in sim.probs_with_readout(receipt).items() + } + assert marginal == {"1": 1.0} + + @pytest.mark.parametrize("model", [None, NoiseModel()]) + def test_readout_rejects_out_of_range_positions_without_errors(self, model): + sim = DensityMatrixNoise.from_circuit(Circuit([9, 2]), model) + with pytest.raises(IndexError): + sim.probabilities_with_readout([9]) + class TestDensityMatrixNoiseSingleQubitGates: """Test single-qubit gate operations with noise.""" diff --git a/crates/cqlib-core/src/qis/state/density_matrix_noise.rs b/crates/cqlib-core/src/qis/state/density_matrix_noise.rs index 49cd133e..6aff31b0 100644 --- a/crates/cqlib-core/src/qis/state/density_matrix_noise.rs +++ b/crates/cqlib-core/src/qis/state/density_matrix_noise.rs @@ -422,25 +422,28 @@ impl DensityMatrixNoise { .collect() } + /// Resolves a storage position to its noise-model qubit ID. + /// Callers validate positions before looking up noise. + fn noise_qubit(&self, position: usize) -> Qubit { + self.state + .qubit_map + .as_ref() + .and_then(|map| { + map.iter() + .find_map(|(&qubit, &index)| (index == position).then_some(qubit)) + }) + .unwrap_or_else(|| Qubit::new(position as u32)) + } + /// Applies noise channels associated with a gate operation. /// /// Looks up the noise model for errors associated with `gate` on `qubits`, /// converts them to Kraus operators, and applies them to the state. /// Supports single-qubit, two-qubit, and three-qubit gates. fn apply_noise(&mut self, gate: StandardGate, qubits: &[usize]) -> Result<(), QisError> { - let labelled_qubit = |position: usize| { - self.state - .qubit_map - .as_ref() - .and_then(|map| { - map.iter() - .find_map(|(&qubit, &index)| (index == position).then_some(qubit)) - }) - .unwrap_or_else(|| Qubit::new(position as u32)) - }; if let Some(noise_model) = &self.noise_model { if qubits.len() == 1 { - let q0 = labelled_qubit(qubits[0]); + let q0 = self.noise_qubit(qubits[0]); let key = OperationKey::new_single(gate, q0); if let Some(errors) = noise_model.get_single_qubit_errors(&key) { for error in errors { @@ -450,8 +453,8 @@ impl DensityMatrixNoise { } } } else if qubits.len() == 2 { - let q0 = labelled_qubit(qubits[0]); - let q1 = labelled_qubit(qubits[1]); + let q0 = self.noise_qubit(qubits[0]); + let q1 = self.noise_qubit(qubits[1]); if let Ok(key) = OperationKey::new_double(gate, q0, q1) && let Some(errors) = noise_model.get_two_qubit_errors(&key) { @@ -462,9 +465,9 @@ impl DensityMatrixNoise { } } } else if qubits.len() == 3 { - let q0 = labelled_qubit(qubits[0]); - let q1 = labelled_qubit(qubits[1]); - let q2 = labelled_qubit(qubits[2]); + let q0 = self.noise_qubit(qubits[0]); + let q1 = self.noise_qubit(qubits[1]); + let q2 = self.noise_qubit(qubits[2]); if let Ok(_key) = OperationKey::new_triple(gate, q0, q1, q2) { // Current noise model struct doesn't have `get_three_qubit_errors`. // Ready for future extension without silently failing or panicking. @@ -1032,12 +1035,21 @@ impl DensityMatrixNoise { /// /// # Arguments /// - /// * `qubits` - Indices of qubits to measure. + /// * `qubits` - Storage positions on which to apply readout errors, not circuit + /// qubit IDs. For a circuit-bound state, each position selects the noise + /// configured for `circuit.qubits()[position]`. Unbound states use identity IDs. /// /// # Returns /// /// A vector of probabilities for all 2^n computational basis states, where n - /// is the total number of qubits in the simulator. + /// is the total number of qubits in the simulator. The array retains storage + /// order regardless of the order or number of selected positions. An empty + /// selection returns the probabilities before readout noise. + /// + /// # Errors + /// + /// Returns [`QisError::IndexOutOfBounds`] for a position outside `0..num_qubits`, + /// including when no noise model is configured. /// /// # Examples /// @@ -1053,10 +1065,20 @@ impl DensityMatrixNoise { /// // For Bell state |Φ+⟩, P(|00⟩) ≈ 0.5 and P(|11⟩) ≈ 0.5 /// ``` pub fn probabilities_with_readout(&self, qubits: &[usize]) -> Result, QisError> { + for &position in qubits { + if position >= self.state.num_qubits { + return Err(QisError::IndexOutOfBounds { + index: position, + max: self.state.num_qubits.saturating_sub(1), + }); + } + } let mut probs = self.probabilities(); self.apply_readout_noise( &mut probs, - qubits.iter().map(|&q| (Qubit::new(q as u32), q)), + qubits + .iter() + .map(|&position| (self.noise_qubit(position), position)), )?; Ok(probs) } diff --git a/crates/cqlib-core/src/qis/state/density_matrix_noise_test.rs b/crates/cqlib-core/src/qis/state/density_matrix_noise_test.rs index 75e89fc2..3c8a055d 100644 --- a/crates/cqlib-core/src/qis/state/density_matrix_noise_test.rs +++ b/crates/cqlib-core/src/qis/state/density_matrix_noise_test.rs @@ -1207,6 +1207,146 @@ fn test_native_gates_amplitude_damping() { assert!(probs[0] > 0.0); } +#[test] +fn test_readout_uses_circuit_ids_at_selected_storage_positions() { + use crate::device::Outcome; + + for ids in [[0, 1, 2], [2, 0, 1], [2, 5, 9], [9, 2, 5]] { + for basis in 0..8 { + for (noise_position, &noise_id) in ids.iter().enumerate() { + let mut circuit = + Circuit::from_qubits(ids.iter().copied().map(Qubit::new).collect()).unwrap(); + for (position, &id) in ids.iter().enumerate() { + if basis & (1 << position) != 0 { + circuit.x(Qubit::new(id)).unwrap(); + } + } + let receipt = circuit.measure(Qubit::new(noise_id)).unwrap(); + let mut model = NoiseModel::new(); + model + .add_readout_error( + Qubit::new(noise_id), + ReadoutError { + p_0_given_1: 0.2, + p_1_given_0: 0.7, + }, + ) + .unwrap(); + let sim = DensityMatrixNoise::from_circuit(&circuit, Some(model)).unwrap(); + let original = sim.state.data().to_vec(); + let bit = (basis >> noise_position) & 1; + let flip_probability = if bit == 0 { 0.7 } else { 0.2 }; + + for selection in 0..8 { + let positions: Vec<_> = (0..3) + .rev() + .filter(|&position| selection & (1 << position) != 0) + .collect(); + let mut expected = [0.0; 8]; + expected[basis] = 1.0; + if selection & (1 << noise_position) != 0 { + expected[basis] = 1.0 - flip_probability; + expected[basis ^ (1 << noise_position)] = flip_probability; + } + let actual = sim.probabilities_with_readout(&positions).unwrap(); + assert_eq!(actual.len(), expected.len()); + for (actual, expected) in actual.iter().zip(&expected) { + assert!( + (actual - expected).abs() < 1e-12, + "IDs={ids:?}, basis={basis}, noise_position={noise_position}, positions={positions:?}: {actual} != {expected}" + ); + } + } + + let marginal = sim.probs_with_readout(&receipt).unwrap(); + let p_one = if bit == 0 { 0.7 } else { 0.8 }; + assert!((marginal[&Outcome::from_bitstring("1").unwrap()] - p_one).abs() < 1e-12); + assert_eq!(sim.state.data(), original.as_slice()); + } + } + } +} + +#[test] +fn test_readout_combines_mapped_errors_without_reordering_full_probabilities() { + use crate::device::Outcome; + + let mut circuit = + Circuit::from_qubits(vec![Qubit::new(9), Qubit::new(2), Qubit::new(5)]).unwrap(); + circuit.x(Qubit::new(9)).unwrap(); + let receipt = circuit + .measure_bits([Qubit::new(2), Qubit::new(9)]) + .unwrap(); + let mut model = NoiseModel::new(); + for (id, p_0_given_1, p_1_given_0) in [(9, 0.1, 0.2), (2, 0.3, 0.4)] { + model + .add_readout_error( + Qubit::new(id), + ReadoutError { + p_0_given_1, + p_1_given_0, + }, + ) + .unwrap(); + } + let sim = DensityMatrixNoise::from_circuit(&circuit, Some(model)).unwrap(); + let expected = [0.06, 0.54, 0.04, 0.36, 0.0, 0.0, 0.0, 0.0]; + for positions in [&[0, 1][..], &[1, 0], &[2, 1, 0]] { + let actual = sim.probabilities_with_readout(positions).unwrap(); + assert_eq!(actual.len(), expected.len()); + for (actual, expected) in actual.iter().zip(expected) { + assert!((actual - expected).abs() < 1e-12); + } + } + let marginal = sim.probs_with_readout(&receipt).unwrap(); + for (bits, probability) in [("00", 0.06), ("10", 0.54), ("01", 0.04), ("11", 0.36)] { + assert!((marginal[&Outcome::from_bitstring(bits).unwrap()] - probability).abs() < 1e-12); + } + assert_eq!( + sim.probabilities(), + vec![0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + ); +} + +#[test] +fn test_readout_validates_positions_independently_of_noise_model() { + let mut model = NoiseModel::new(); + model + .add_readout_error( + Qubit::new(9), + ReadoutError { + p_0_given_1: 0.0, + p_1_given_0: 1.0, + }, + ) + .unwrap(); + for noise_model in [None, Some(NoiseModel::new()), Some(model)] { + for bound in [false, true] { + let mut sim = DensityMatrixNoise::new(2, noise_model.clone()); + if bound { + let circuit = Circuit::from_qubits(vec![Qubit::new(9), Qubit::new(2)]).unwrap(); + sim.apply_circuit(&circuit).unwrap(); + } + for index in [2, 9, usize::MAX] { + assert!(matches!( + sim.probabilities_with_readout(&[0, index]), + Err(QisError::IndexOutOfBounds { index: actual, max: 1 }) if actual == index + )); + } + assert_eq!( + sim.probabilities_with_readout(&[]).unwrap(), + sim.probabilities() + ); + } + let sim = DensityMatrixNoise::new(0, noise_model); + assert_eq!(sim.probabilities_with_readout(&[]).unwrap(), vec![1.0]); + assert!(matches!( + sim.probabilities_with_readout(&[0]), + Err(QisError::IndexOutOfBounds { index: 0, max: 0 }) + )); + } +} + #[test] fn test_apply_readout_noise_out_of_bounds() { let mut sim = DensityMatrixNoise::new(2, Some(NoiseModel::new())); diff --git a/docs/api/cn/c/3_qis/3_density_matrix_noise.md b/docs/api/cn/c/3_qis/3_density_matrix_noise.md index 843d85fc..1f5ade40 100644 --- a/docs/api/cn/c/3_qis/3_density_matrix_noise.md +++ b/docs/api/cn/c/3_qis/3_density_matrix_noise.md @@ -250,7 +250,7 @@ int32_t density_matrix_noise_probabilities_with_readout(const struct CDensityMat - `ptr` (`const CDensityMatrixNoise*`):噪声模拟器句柄。 - `buffer` (`double*`):输出缓冲。 - `len` (`uintptr_t`):缓冲长度,必须等于 `density_matrix_noise_probabilities_with_readout_len`。 -- `qubits` (`const uint32_t*`):施加读出误差的比特数组;`qubit_len` 为 0 时可为 NULL,此时结果与 `density_matrix_noise_probabilities` 一致。 +- `qubits` (`const uint32_t*`):施加读出误差的存储位置数组;绑定线路后按位置对应的线路比特 ID 查询噪声模型,未绑定时 ID 与位置相同。输出始终为按存储位序排列的完整概率数组。`qubit_len` 为 0 时可为 NULL,此时结果与 `density_matrix_noise_probabilities` 一致。 - `qubit_len` (`uintptr_t`):比特数量。 返回:`0` 成功;`-1` NULL 指针;`-2` 比特越界;`-8` `len` 与实际数量不符。 diff --git a/docs/api/cn/python/3_qis/3_density_matrix_noise.md b/docs/api/cn/python/3_qis/3_density_matrix_noise.md index 07cd7924..331ac71c 100644 --- a/docs/api/cn/python/3_qis/3_density_matrix_noise.md +++ b/docs/api/cn/python/3_qis/3_density_matrix_noise.md @@ -171,7 +171,7 @@ assert sim.state[0, 0] != 999.0 **概率** - `probabilities() -> list[float]`:返回不含读出噪声的各基态概率,长度为 2^num_qubits。不修改状态。 -- `probabilities_with_readout(qubits) -> list[float]`:返回在指定比特上施加读出噪声后的概率分布,长度为 2^n,n 为 `qubits` 长度。 +- `probabilities_with_readout(qubits) -> list[float]`:`qubits` 为施加读出误差的存储位置;绑定线路后,按这些位置对应的线路比特 ID 查询噪声模型,未绑定时 ID 与位置相同。返回完整的 `2^num_qubits` 项概率数组,保持存储位序,不随选择的顺序或数量改变;空选择不施加读出误差。位置越界时抛出 `IndexError`,即使未配置噪声模型。 - `qubits` (`list[int]`):参与读出噪声建模的比特下标列表。下标越界时抛出 `IndexError`。 **测量与采样** diff --git a/docs/api/cn/rust/3_qis/3_density_matrix_noise.md b/docs/api/cn/rust/3_qis/3_density_matrix_noise.md index 47cc8b4b..bfab1bf8 100644 --- a/docs/api/cn/rust/3_qis/3_density_matrix_noise.md +++ b/docs/api/cn/rust/3_qis/3_density_matrix_noise.md @@ -87,7 +87,7 @@ pub struct DensityMatrixNoise { 模拟器把「量子态的噪声」与「读出的噪声」分开表达:前者作用于密度矩阵本身,后者只改变报告出来的概率分布。 - `fn probabilities(&self) -> Vec`:返回密度矩阵对角元,即各计算基态的测量概率。包含已经施加的门噪声,不含读出噪声。 -- `fn probabilities_with_readout(&self, qubits: &[usize]) -> Result, QisError>`:在 `probabilities` 的基础上,对 `qubits` 中列出的比特施加读出噪声。 +- `fn probabilities_with_readout(&self, qubits: &[usize]) -> Result, QisError>`:`qubits` 为施加读出误差的存储位置;绑定线路后,按这些位置对应的线路比特 ID 查询噪声模型,未绑定时 ID 与位置相同。返回完整的 `2^num_qubits` 项概率数组,保持存储位序;空选择不施加读出误差。位置越界时返回 `QisError::IndexOutOfBounds`,即使未配置噪声模型。 - `fn probs(&self, measurement: &Measurement) -> Result, QisError>`:返回 `Measurement` 选定的边缘概率分布,不含读出噪声。 - `fn probs_with_readout(&self, measurement: &Measurement) -> Result, QisError>`:在 `probs` 的基础上,对 `measurement.qubits()` 施加读出噪声。 diff --git a/docs/api/en/c/3_qis/3_density_matrix_noise.md b/docs/api/en/c/3_qis/3_density_matrix_noise.md index b542c22e..f6b24cc2 100644 --- a/docs/api/en/c/3_qis/3_density_matrix_noise.md +++ b/docs/api/en/c/3_qis/3_density_matrix_noise.md @@ -250,7 +250,7 @@ Takes the ideal probability distribution and applies the readout errors register - `ptr` (`const CDensityMatrixNoise*`): noisy-simulator handle. - `buffer` (`double*`): output buffer. - `len` (`uintptr_t`): buffer length; must equal `density_matrix_noise_probabilities_with_readout_len`. -- `qubits` (`const uint32_t*`): array of qubits to apply readout errors to; may be NULL when `qubit_len` is 0, in which case the result matches `density_matrix_noise_probabilities`. +- `qubits` (`const uint32_t*`): storage positions on which to apply readout errors. Circuit-bound states look up noise using the circuit ID at each position; unbound states use identity IDs. The full output distribution retains storage order. May be NULL when `qubit_len` is 0, in which case the result matches `density_matrix_noise_probabilities`. - `qubit_len` (`uintptr_t`): number of qubits. Returns: `0` on success; `-1` NULL pointers; `-2` out-of-bounds qubit; `-8` when `len` does not match the actual count. diff --git a/docs/api/en/python/3_qis/3_density_matrix_noise.md b/docs/api/en/python/3_qis/3_density_matrix_noise.md index 1a318e58..0de4d8f6 100644 --- a/docs/api/en/python/3_qis/3_density_matrix_noise.md +++ b/docs/api/en/python/3_qis/3_density_matrix_noise.md @@ -171,7 +171,7 @@ All gate application and state rewriting methods modify the current state in pla **Probabilities** - `probabilities() -> list[float]`: return the probability of each basis state without readout noise, with length 2^num_qubits. Does not modify the state. -- `probabilities_with_readout(qubits) -> list[float]`: return the probability distribution after readout noise is applied on the given qubits, with length 2^n, where n is the length of `qubits`. +- `probabilities_with_readout(qubits) -> list[float]`: `qubits` contains storage positions on which to apply readout errors. Circuit-bound states look up noise using the circuit ID at each position; unbound states use identity IDs. Returns all `2^num_qubits` probabilities in storage order, independent of the selection order or size; an empty selection applies no readout errors. Out-of-range positions raise `IndexError` even without a noise model. - `qubits` (`list[int]`): the list of qubit indices taking part in readout noise modeling. An `IndexError` is raised when an index is out of range. **Measurement and sampling** diff --git a/docs/api/en/rust/3_qis/3_density_matrix_noise.md b/docs/api/en/rust/3_qis/3_density_matrix_noise.md index fbf788ba..a00c7174 100644 --- a/docs/api/en/rust/3_qis/3_density_matrix_noise.md +++ b/docs/api/en/rust/3_qis/3_density_matrix_noise.md @@ -87,7 +87,7 @@ Methods: The simulator expresses "noise on the quantum state" and "noise on readout" separately: the former acts on the density matrix itself, while the latter only changes the reported probability distribution. - `fn probabilities(&self) -> Vec`: return the diagonal entries of the density matrix, that is the measurement probability of each computational basis state. Includes gate noise already applied and excludes readout noise. -- `fn probabilities_with_readout(&self, qubits: &[usize]) -> Result, QisError>`: based on `probabilities`, apply readout noise to the qubits listed in `qubits`. +- `fn probabilities_with_readout(&self, qubits: &[usize]) -> Result, QisError>`: `qubits` contains storage positions on which to apply readout errors. Circuit-bound states look up noise using the circuit ID at each position; unbound states use identity IDs. Returns all `2^num_qubits` probabilities in storage order; an empty selection applies no readout errors. Out-of-range positions return `QisError::IndexOutOfBounds` even without a noise model. - `fn probs(&self, measurement: &Measurement) -> Result, QisError>`: return the marginal probability distribution selected by `Measurement`, excluding readout noise. - `fn probs_with_readout(&self, measurement: &Measurement) -> Result, QisError>`: based on `probs`, apply readout noise to `measurement.qubits()`. From 8d093e511d7209f01da70e0033b330976de101a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Sun, 18 Oct 2026 09:27:43 +0800 Subject: [PATCH 14/16] fix(circuit): avoid double-counting inherent controls in symbolic MCGate matrices --- .../src/circuit/symbolic_matrix/gate.rs | 4 +- .../src/circuit/symbolic_matrix/gate_test.rs | 78 ++++++++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/crates/cqlib-core/src/circuit/symbolic_matrix/gate.rs b/crates/cqlib-core/src/circuit/symbolic_matrix/gate.rs index ee8adb3c..27530d0e 100644 --- a/crates/cqlib-core/src/circuit/symbolic_matrix/gate.rs +++ b/crates/cqlib-core/src/circuit/symbolic_matrix/gate.rs @@ -427,7 +427,9 @@ pub fn circuit_to_symbolic_matrix( flush_numeric_run(&mut matrix, &mut numeric_run, &full_bits)?; let matrix = matrix.get_or_insert_with(|| symbolic_eye(dim)); let base = standard_gate_symbolic_matrix(*mc_gate.base_gate(), ¶ms)?; - let gate_matrix = control_matrix(&base, mc_gate.num_ctrl_qubits()); + // The base matrix already includes any inherent controls. + let added_controls = mc_gate.num_qubits() - mc_gate.base_gate().num_qubits(); + let gate_matrix = control_matrix(&base, added_controls); apply_gate_to_matrix(matrix, &gate_matrix, &reversed_bits)?; } } diff --git a/crates/cqlib-core/src/circuit/symbolic_matrix/gate_test.rs b/crates/cqlib-core/src/circuit/symbolic_matrix/gate_test.rs index 69bc8f1a..82fb2379 100644 --- a/crates/cqlib-core/src/circuit/symbolic_matrix/gate_test.rs +++ b/crates/cqlib-core/src/circuit/symbolic_matrix/gate_test.rs @@ -11,7 +11,7 @@ // that they have been altered from the originals. use super::*; -use crate::circuit::gate::{FrozenCircuit, UnitaryGate}; +use crate::circuit::gate::{FrozenCircuit, MCGate, UnitaryGate}; use crate::circuit::symbolic_matrix::gate::{ apply_gate_to_matrix, apply_standard_gate_to_matrix, circuit_to_symbolic_matrix, standard_gate_symbolic_matrix, @@ -1482,6 +1482,82 @@ fn test_mcgate_parametric_symbolic_matrix_matches_numeric() { assert_matrix_approx_eq(&evaluated, &expected, 1e-10); } +#[test] +fn test_mcgate_zero_added_controls_preserves_symbolic_controlled_rotations() { + for gate in [StandardGate::CRX, StandardGate::CRY, StandardGate::CRZ] { + let mut circuit = Circuit::new(2); + circuit + .append( + Instruction::McGate(Box::new(MCGate::new(0, gate))), + [Qubit::new(0), Qubit::new(1)], + [ParameterValue::from(Parameter::symbol("theta"))], + None, + ) + .unwrap(); + let symbolic = circuit_to_symbolic_matrix(&circuit, Some(&[1, 0])).unwrap(); + assert_eq!(symbolic.dim(), (4, 4)); + for theta in [0.0, 0.37, -0.51, PI] { + let bindings = HashMap::from([("theta", theta)]); + let actual = evaluate_symbolic_matrix(&symbolic, &Some(bindings)).unwrap(); + let expected = gate.matrix(&[theta]).unwrap(); + assert_matrix_approx_eq(&actual, expected.as_ref(), 1e-12); + } + } +} + +#[test] +fn test_mcgate_inherent_controls_symbolic_matrix_matches_bound_numeric() { + for gate in [StandardGate::CRX, StandardGate::CRY, StandardGate::CRZ] { + for added_controls in 0..=2 { + let mc_gate = MCGate::new(added_controls, gate); + assert_eq!(mc_gate.num_ctrl_qubits(), added_controls as usize + 1); + let num_qubits = mc_gate.num_qubits() + 1; + let qubit_ids = [9usize, 2, 17, 5, 30][..num_qubits].to_vec(); + let qubits: Vec = qubit_ids.iter().map(|&id| Qubit::new(id as u32)).collect(); + // Leave a spectator between gate operands in the circuit's stored order. + let operands: Vec = qubits + .iter() + .enumerate() + .filter_map(|(index, &qubit)| (index != 1).then_some(qubit)) + .collect(); + for reverse in [false, true] { + let mut operands = operands.clone(); + if reverse { + operands.reverse(); + } + let mut circuit = Circuit::from_qubits(qubits.clone()).unwrap(); + circuit.h(operands[0]).unwrap(); + circuit.x(qubits[1]).unwrap(); + circuit + .append( + Instruction::McGate(Box::new(mc_gate.clone())), + operands.clone(), + [ParameterValue::from(Parameter::symbol("theta"))], + None, + ) + .unwrap(); + circuit.rz(*operands.last().unwrap(), 0.23).unwrap(); + for order in [None, Some(qubit_ids.as_slice())] { + let symbolic = circuit_to_symbolic_matrix(&circuit, order).unwrap(); + let dim = 1 << num_qubits; + assert_eq!(symbolic.dim(), (dim, dim)); + for theta in [0.0, 0.37, -0.51, PI] { + let bindings = HashMap::from([("theta", theta)]); + let actual = + evaluate_symbolic_matrix(&symbolic, &Some(bindings.clone())).unwrap(); + let bound = circuit.assign_parameters(&Some(bindings)).unwrap(); + let expected = circuit_to_matrix(&bound, order).unwrap(); + assert_matrix_approx_eq(&actual, &expected, 1e-12); + let constant_symbolic = circuit_to_symbolic_matrix(&bound, order).unwrap(); + let constant = evaluate_symbolic_matrix(&constant_symbolic, &None).unwrap(); + assert_matrix_approx_eq(&constant, &expected, 1e-12); + } + } + } + } + } +} + #[test] fn test_numeric_path_matches_symbolic_path_for_no_param_gates() { // H, CX, X2P — all non-parametric gates should take the numeric fast path From 14d68c8a8afdae45066cba340649bd6fc9dabed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Sun, 18 Oct 2026 09:35:56 +0800 Subject: [PATCH 15/16] fix(visualization): align Pauli-vector labels with PauliString --- .../cqlib/visualization/__init__.py | 7 +- .../tests/visualization/test_visualization.py | 29 +++++++ .../src/visualization/state/math.rs | 12 +-- .../src/visualization/state/math_test.rs | 81 +++++++++++++++++- .../src/visualization/state/plot.rs | 5 ++ .../src/visualization/state/plot_test.rs | 48 +++++++++++ .../api/cn/c/5_visualization/3_state_plots.md | 2 + .../python/5_visualization/3_state_plots.md | 2 + .../cn/rust/5_visualization/3_state_plots.md | 2 + .../api/en/c/5_visualization/3_state_plots.md | 2 + .../python/5_visualization/3_state_plots.md | 2 + .../en/rust/5_visualization/3_state_plots.md | 2 + .../5_visualization/7_state_visualization.md | 4 +- .../assets/asymmetric_paulivec.png | Bin 38558 -> 38600 bytes .../assets/asymmetric_paulivec_report.png | Bin 27768 -> 27815 bytes .../asymmetric_paulivec_reverse_bits.png | Bin 42343 -> 42301 bytes .../5_visualization/7_state_visualization.md | 4 +- .../assets/asymmetric_paulivec.png | Bin 38558 -> 38600 bytes .../assets/asymmetric_paulivec_report.png | Bin 27768 -> 27815 bytes .../asymmetric_paulivec_reverse_bits.png | Bin 42343 -> 42301 bytes 20 files changed, 193 insertions(+), 9 deletions(-) diff --git a/crates/binding-python/cqlib/visualization/__init__.py b/crates/binding-python/cqlib/visualization/__init__.py index aef02055..f7c2bdd0 100644 --- a/crates/binding-python/cqlib/visualization/__init__.py +++ b/crates/binding-python/cqlib/visualization/__init__.py @@ -196,7 +196,12 @@ def plot_state_paulivec( figsize=None, output_path=None, ): - """Render Pauli-basis expectation values as SVG.""" + """Render Pauli-basis expectation values as SVG. + + Labels follow PauliString by default: the rightmost character acts on + qubit 0, so IX measures X on qubit 0 of a two-qubit state. Setting + reverse_bits=True reverses each label without changing its coefficient. + """ svg = _visualization_module.plot_state_paulivec( state, title=title, diff --git a/crates/binding-python/tests/visualization/test_visualization.py b/crates/binding-python/tests/visualization/test_visualization.py index 42b1098d..331cf9d5 100644 --- a/crates/binding-python/tests/visualization/test_visualization.py +++ b/crates/binding-python/tests/visualization/test_visualization.py @@ -23,6 +23,7 @@ import subprocess import tempfile import zlib +from xml.etree import ElementTree import pytest import resvg_py @@ -641,6 +642,34 @@ def test_state_visualization_accepts_density_matrix(): assert vis.plot_state_paulivec(sv) == vis.plot_state_paulivec(dm) +@pytest.mark.parametrize("state_type", [Statevector, DensityMatrix]) +@pytest.mark.parametrize("reverse_bits", [False, True]) +def test_paulivec_labels_identify_the_correct_qubit(state_type, reverse_bits): + """The displayed IX bar measures q0 unless display bits are reversed.""" + state = state_type(2) + state.apply_h(0) + svg = vis.plot_state_paulivec(state, reverse_bits=reverse_bits) + elements = list(ElementTree.fromstring(svg)) + bars = { + element.text: elements[index - 1].attrib + for index, element in enumerate(elements) + if element.tag.endswith("text") + and element.text in {"II", "IX", "XI", "IZ", "ZI"} + } + one_y = float(bars["II"]["y"]) + one_height = float(bars["II"]["height"]) + assert one_height > 1.0 + for label, expected in [("IX", 1), ("XI", 0), ("IZ", 0), ("ZI", 1)]: + displayed = label[::-1] if reverse_bits else label + y = float(bars[displayed]["y"]) + height = float(bars[displayed]["height"]) + if expected: + assert (y, height) == (one_y, one_height) + else: + assert height == 1.0 + assert y == pytest.approx(one_y + one_height) + + def test_state_visualization_validates_python_inputs(): """Invalid Python inputs should fail with clear ValueError messages.""" with pytest.raises(ValueError, match="exactly 3"): diff --git a/crates/cqlib-core/src/visualization/state/math.rs b/crates/cqlib-core/src/visualization/state/math.rs index 490fb7a8..c65890da 100644 --- a/crates/cqlib-core/src/visualization/state/math.rs +++ b/crates/cqlib-core/src/visualization/state/math.rs @@ -84,6 +84,8 @@ pub fn local_bloch_vectors( /// The returned labels enumerate tensor products over `I`, `X`, `Y`, and `Z`. /// Values are expectation coefficients `Tr(rho P)` in the same qubit indexing convention /// used by the statevector basis: qubit `q` corresponds to bit `1 << q`. +/// Labels follow [`crate::qis::pauli::PauliString`]: the highest-index qubit is on +/// the left by default. `reverse_bits` reverses the displayed characters only. pub(crate) fn pauli_coefficients( num_qubits: usize, rho: &[Complex64], @@ -97,6 +99,11 @@ pub(crate) fn pauli_coefficients( ))); } + let display_qubits: Vec = if reverse_bits { + (0..num_qubits).collect() + } else { + (0..num_qubits).rev().collect() + }; let mut out = Vec::with_capacity(total); for idx in 0..total { let mut code = idx; @@ -111,11 +118,6 @@ pub(crate) fn pauli_coefficients( ops.push((qubit, op)); code /= 4; } - let display_qubits: Vec = if reverse_bits { - (0..num_qubits).rev().collect() - } else { - (0..num_qubits).collect() - }; let label = display_qubits .iter() .map(|qubit| match ops[*qubit].1 { diff --git a/crates/cqlib-core/src/visualization/state/math_test.rs b/crates/cqlib-core/src/visualization/state/math_test.rs index 318a584b..1b14e9ad 100644 --- a/crates/cqlib-core/src/visualization/state/math_test.rs +++ b/crates/cqlib-core/src/visualization/state/math_test.rs @@ -14,7 +14,8 @@ use super::math::pauli_coefficients; use super::*; -use crate::qis::Statevector; +use crate::qis::pauli::PauliString; +use crate::qis::{DensityMatrix, Statevector}; use num_complex::Complex64; fn plus_state() -> Statevector { @@ -86,3 +87,81 @@ fn paulivec_contains_expected_plus_y() { .any(|(label, value)| label == "Y" && (*value - 1.0).abs() < 1e-10) ); } + +#[test] +fn paulivec_labels_identify_the_correct_qubit() { + // q0 = |+>, q1 = |0>: IX measures q0, XI measures q1. + let state = Statevector::from_state( + 2, + vec![ + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + ], + ) + .unwrap(); + let (_, rho) = state_to_density_matrix(&state).unwrap(); + for reverse_bits in [false, true] { + let coeffs = pauli_coefficients(2, &rho, reverse_bits).unwrap(); + for (label, expected) in [("IX", 1.0), ("XI", 0.0), ("IZ", 0.0), ("ZI", 1.0)] { + let displayed = if reverse_bits { + label.chars().rev().collect::() + } else { + label.to_string() + }; + let value = coeffs.iter().find(|(s, _)| *s == displayed).unwrap().1; + assert!((value - expected).abs() < 1e-10, "{displayed}: {value}"); + } + } +} + +fn assert_paulivec_matches_observables( + state: &impl StateVisualizationSource, + expectation: impl Fn(&PauliString) -> f64, +) { + let (num_qubits, rho) = state_to_density_matrix(state).unwrap(); + for reverse_bits in [false, true] { + let coeffs = pauli_coefficients(num_qubits, &rho, reverse_bits).unwrap(); + assert_eq!(coeffs.len(), 4usize.pow(num_qubits as u32)); + for (label, value) in coeffs { + let semantic_label = if reverse_bits { + label.chars().rev().collect::() + } else { + label.clone() + }; + let pauli: PauliString = semantic_label.parse().unwrap(); + let expected = expectation(&pauli); + assert!( + (value - expected).abs() < 1e-10, + "{num_qubits} qubits, reverse_bits={reverse_bits}, {label}: {value} != {expected}" + ); + } + } +} + +#[test] +fn paulivec_coefficients_match_observables_for_pure_and_mixed_states() { + for num_qubits in 1..=3 { + let dim = 1 << num_qubits; + // Asymmetric complex amplitudes exercise X, Y, Z and negative coefficients. + let mut amplitudes: Vec<_> = (0..dim) + .map(|i| Complex64::new(((i + 1) as f64 * 0.71).sin(), ((i + 2) as f64 * 0.43).cos())) + .collect(); + let norm = amplitudes.iter().map(|a| a.norm_sqr()).sum::().sqrt(); + for amplitude in &mut amplitudes { + *amplitude /= norm; + } + let pure = Statevector::from_state(num_qubits, amplitudes).unwrap(); + assert_paulivec_matches_observables(&pure, |p| pure.expectation(p).unwrap()); + + // A convex mixture with |11...1><11...1| is a genuine mixed state. + let (_, mut rho) = state_to_density_matrix(&pure).unwrap(); + for value in &mut rho { + *value *= 0.7; + } + rho[dim * dim - 1] += Complex64::new(0.3, 0.0); + let mixed = DensityMatrix::from_density_matrix_state(num_qubits, rho).unwrap(); + assert_paulivec_matches_observables(&mixed, |p| mixed.expectation(p).unwrap()); + } +} diff --git a/crates/cqlib-core/src/visualization/state/plot.rs b/crates/cqlib-core/src/visualization/state/plot.rs index 2243f3ed..cadd93d4 100644 --- a/crates/cqlib-core/src/visualization/state/plot.rs +++ b/crates/cqlib-core/src/visualization/state/plot.rs @@ -195,6 +195,11 @@ pub fn plot_state_city( /// Plot Pauli-basis expectation values as an SVG bar chart. /// +/// By default, labels follow [`crate::qis::pauli::PauliString`]: the rightmost +/// character acts on qubit 0. For example, `IX` measures X on qubit 0 of a +/// two-qubit state. Setting `options.reverse_bits` reverses the characters in +/// each label without changing the state or the corresponding expectation value. +/// /// # Arguments /// /// * `state` - Input core state object accepted by state visualization routines. diff --git a/crates/cqlib-core/src/visualization/state/plot_test.rs b/crates/cqlib-core/src/visualization/state/plot_test.rs index 256ebb04..25e286a6 100644 --- a/crates/cqlib-core/src/visualization/state/plot_test.rs +++ b/crates/cqlib-core/src/visualization/state/plot_test.rs @@ -172,6 +172,54 @@ fn paulivec_matches_reference_image() { assert_state_visual_match(&svg, "paulivec_plus.png"); } +#[test] +fn paulivec_svg_labels_match_asymmetric_state() { + let state = Statevector::from_state( + 2, + vec![ + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + ], + ) + .unwrap(); + let density = DensityMatrix::from_state(2, state.data().to_vec()).unwrap(); + for reverse_bits in [false, true] { + let options = StatePlotOptions { + reverse_bits, + ..StatePlotOptions::default() + }; + for svg in [ + plot_state_paulivec(&state, &options).unwrap(), + plot_state_paulivec(&density, &options).unwrap(), + ] { + let bar = |label: &str| { + let label_start = svg.find(&format!(">{label}")).unwrap(); + let bar_start = svg[..label_start].rfind(" 1.0); + for (label, expected) in [("IX", 1.0), ("XI", 0.0), ("IZ", 0.0), ("ZI", 1.0)] { + let displayed = if reverse_bits { + label.chars().rev().collect::() + } else { + label.to_string() + }; + let (y, height) = bar(&displayed); + if expected == 1.0 { + assert_eq!((y, height), (one_y, one_height), "{displayed}"); + } else { + assert_eq!(height, 1.0, "{displayed}"); + assert!((y - one_y - one_height).abs() < 1e-3, "{displayed}"); + } + } + } + } +} + #[test] fn density_matrix_uses_same_state_plot_api() { let statevector = plus_state(); diff --git a/docs/api/cn/c/5_visualization/3_state_plots.md b/docs/api/cn/c/5_visualization/3_state_plots.md index 2cef5286..61851474 100644 --- a/docs/api/cn/c/5_visualization/3_state_plots.md +++ b/docs/api/cn/c/5_visualization/3_state_plots.md @@ -76,6 +76,8 @@ char *plot_state_city_density_matrix(const struct CDensityMatrix *state, 把泡利基期望值绘制为柱状图。标签为 `I`、`X`、`Y`、`Z` 的张量积,共 `4^n` 条,其中 `n` 为量子比特数;非负柱与负值柱使用不同颜色。柱数不超过 `64` 时才绘制旋转的横轴标签。 +默认标签遵循 `PauliString`:最高位在左,qubit 0 在最右侧。例如,两比特态的 `IX` 表示 qubit 0 上的 X。启用 `reverse_bits` 仅反转每个标签中的字符顺序,对应的期望值不变。 + ```c char *plot_state_paulivec(const struct CStatevector *state, const struct CStatePlotOptions *options); diff --git a/docs/api/cn/python/5_visualization/3_state_plots.md b/docs/api/cn/python/5_visualization/3_state_plots.md index 2bff0705..d563bd04 100644 --- a/docs/api/cn/python/5_visualization/3_state_plots.md +++ b/docs/api/cn/python/5_visualization/3_state_plots.md @@ -120,6 +120,8 @@ assert plot_state_paulivec(state) == plot_state_paulivec(density) 计算量子态在 Pauli 基下的期望值,并渲染成柱状图。 +默认标签遵循 `PauliString`:最高位在左,qubit 0 在最右侧。例如,两比特态的 `IX` 表示 qubit 0 上的 X。启用 `reverse_bits` 仅反转每个标签中的字符顺序,对应的期望值不变。 + 参数: - `state` (`QuantumState`):待渲染的量子态。 diff --git a/docs/api/cn/rust/5_visualization/3_state_plots.md b/docs/api/cn/rust/5_visualization/3_state_plots.md index 1f35cca1..533209bf 100644 --- a/docs/api/cn/rust/5_visualization/3_state_plots.md +++ b/docs/api/cn/rust/5_visualization/3_state_plots.md @@ -118,6 +118,8 @@ assert!(svg.contains("Re[rho]")); 把泡利基期望值绘制为柱状图。标签为 `I`、`X`、`Y`、`Z` 的张量积,共 `4^n` 条,其中 `n` 为量子比特数。非负柱与负值柱使用不同颜色。 +默认标签遵循 `PauliString`:最高位在左,qubit 0 在最右侧。例如,两比特态的 `IX` 表示 qubit 0 上的 X。启用 `reverse_bits` 仅反转每个标签中的字符顺序,对应的期望值不变。 + 参数: - `state` (`&S`,`S: StateVisualizationSource + ?Sized`):输入态。 diff --git a/docs/api/en/c/5_visualization/3_state_plots.md b/docs/api/en/c/5_visualization/3_state_plots.md index 8757ef43..fe296975 100644 --- a/docs/api/en/c/5_visualization/3_state_plots.md +++ b/docs/api/en/c/5_visualization/3_state_plots.md @@ -76,6 +76,8 @@ Failure scenarios: same as [plot_bloch_multivector](#plot_bloch_multivectorstate Plots Pauli-basis expectation values as a bar chart. Labels are tensor products of `I`, `X`, `Y`, `Z`, for `4^n` bars in total where `n` is the qubit count; non-negative and negative bars use different colors. Rotated x-axis labels are drawn only when the bar count does not exceed `64`. +By default, labels follow `PauliString`: the highest-index qubit is on the left and qubit 0 is on the right. For example, `IX` measures X on qubit 0 of a two-qubit state. Enabling `reverse_bits` reverses the characters in each label without changing the corresponding expectation value. + ```c char *plot_state_paulivec(const struct CStatevector *state, const struct CStatePlotOptions *options); diff --git a/docs/api/en/python/5_visualization/3_state_plots.md b/docs/api/en/python/5_visualization/3_state_plots.md index 806f9aed..ccfcbf9c 100644 --- a/docs/api/en/python/5_visualization/3_state_plots.md +++ b/docs/api/en/python/5_visualization/3_state_plots.md @@ -120,6 +120,8 @@ Raises: Compute the expectation values of the quantum state in the Pauli basis and render them as a bar chart. +By default, labels follow `PauliString`: the highest-index qubit is on the left and qubit 0 is on the right. For example, `IX` measures X on qubit 0 of a two-qubit state. Enabling `reverse_bits` reverses the characters in each label without changing the corresponding expectation value. + Parameters: - `state` (`QuantumState`): the quantum state to render. diff --git a/docs/api/en/rust/5_visualization/3_state_plots.md b/docs/api/en/rust/5_visualization/3_state_plots.md index 6181fb22..b199bc6d 100644 --- a/docs/api/en/rust/5_visualization/3_state_plots.md +++ b/docs/api/en/rust/5_visualization/3_state_plots.md @@ -118,6 +118,8 @@ assert!(svg.contains("Re[rho]")); Draw Pauli basis expectation values as a bar chart. The labels are tensor products of `I`, `X`, `Y` and `Z`, `4^n` of them in total, where `n` is the number of qubits. Non-negative bars and negative bars use different colors. +By default, labels follow `PauliString`: the highest-index qubit is on the left and qubit 0 is on the right. For example, `IX` measures X on qubit 0 of a two-qubit state. Enabling `reverse_bits` reverses the characters in each label without changing the corresponding expectation value. + Parameters: - `state` (`&S`, `S: StateVisualizationSource + ?Sized`): the input state. diff --git a/docs/documentation/cn/1_cqlib/5_visualization/7_state_visualization.md b/docs/documentation/cn/1_cqlib/5_visualization/7_state_visualization.md index 539b52ea..7d8c5452 100644 --- a/docs/documentation/cn/1_cqlib/5_visualization/7_state_visualization.md +++ b/docs/documentation/cn/1_cqlib/5_visualization/7_state_visualization.md @@ -242,7 +242,9 @@ plot_state_paulivec( ![Asymmetric Pauli vector with reversed bits](assets/asymmetric_paulivec_reverse_bits.png) -在多比特状态图中,先确认 basis label 和 Pauli string 的显示顺序,再解释相干项、对角权重或 Pauli 期望值。 +默认 Pauli vector 标签遵循 `PauliString`:最高位在左,qubit 0 在最右侧。因此,`IX` 表示 qubit 0 上的 X,`XI` 表示 qubit 1 上的 X。上面的对角态在默认图中给出 `IZ = 0.6`、`ZI = -0.6`。设置 `reverse_bits=True` 时,每个标签中的字符顺序反转,对应的系数不变。 + +早期版本的多比特 Pauli 标签方向与此相反,现已修正。此前用 `reverse_bits=True` 对齐公共 `PauliString` 约定的代码,现在应使用默认设置。单比特图不受影响。 --- diff --git a/docs/documentation/cn/1_cqlib/5_visualization/assets/asymmetric_paulivec.png b/docs/documentation/cn/1_cqlib/5_visualization/assets/asymmetric_paulivec.png index 59102fa948549335d7a6b0ad5571bb99147a3923..8675c36bd7f8a4803f7470402700da734fe8a820 100644 GIT binary patch delta 5220 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 delta 5242 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 diff --git a/docs/documentation/en/1_cqlib/5_visualization/7_state_visualization.md b/docs/documentation/en/1_cqlib/5_visualization/7_state_visualization.md index 6122d0a6..5307b0ba 100644 --- a/docs/documentation/en/1_cqlib/5_visualization/7_state_visualization.md +++ b/docs/documentation/en/1_cqlib/5_visualization/7_state_visualization.md @@ -242,7 +242,9 @@ Pauli vector after reversing the display order: ![Asymmetric Pauli vector with reversed bits](assets/asymmetric_paulivec_reverse_bits.png) -In multi-qubit state figures, confirm the display order of the basis labels and Pauli strings before interpreting coherence terms, diagonal weights or Pauli expectation values. +By default, Pauli-vector labels follow `PauliString`: the highest-index qubit is on the left and qubit 0 is on the right. Thus `IX` measures X on qubit 0 and `XI` measures X on qubit 1. For the diagonal state above, the default plot gives `IZ = 0.6` and `ZI = -0.6`. With `reverse_bits=True`, each label's characters are reversed while its coefficient stays the same. + +Earlier versions displayed multi-qubit Pauli labels in the opposite direction. This has been corrected; code that used `reverse_bits=True` to match the public `PauliString` convention should now use the default. Single-qubit plots are unaffected. --- diff --git a/docs/documentation/en/1_cqlib/5_visualization/assets/asymmetric_paulivec.png b/docs/documentation/en/1_cqlib/5_visualization/assets/asymmetric_paulivec.png index 59102fa948549335d7a6b0ad5571bb99147a3923..8675c36bd7f8a4803f7470402700da734fe8a820 100644 GIT binary patch delta 5220 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 delta 5242 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 From db7fcc49fb2f47119618112cfddd755c43ff8495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Sun, 18 Oct 2026 09:53:34 +0800 Subject: [PATCH 16/16] chore(release): prepare beta6 versions and compatibility notes --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- crates/binding-c/Cargo.toml | 2 +- crates/binding-python/Cargo.toml | 4 ++-- crates/cqlib-core/README.md | 4 ++-- crates/cqlib/Cargo.toml | 2 +- docs/releasing.md | 25 ++++++++++++++++++++++--- 7 files changed, 33 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a23d7d0f..438edc76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,7 +165,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "binding-c" -version = "0.1.0-beta.5" +version = "0.1.0-beta.6" dependencies = [ "bitvec", "cbindgen", @@ -178,7 +178,7 @@ dependencies = [ [[package]] name = "binding-python" -version = "2.0.0-beta5" +version = "2.0.0-beta6" dependencies = [ "chrono", "cqlib-core", @@ -465,14 +465,14 @@ dependencies = [ [[package]] name = "cqlib" -version = "0.1.0-beta.5" +version = "0.1.0-beta.6" dependencies = [ "cqlib-core", ] [[package]] name = "cqlib-core" -version = "0.1.0-beta.5" +version = "0.1.0-beta.6" dependencies = [ "approx", "bitvec", diff --git a/Cargo.toml b/Cargo.toml index 12d91b1d..32a64e5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.1.0-beta.5" +version = "0.1.0-beta.6" edition = "2024" rust-version = "1.89" license = "Apache-2.0" diff --git a/crates/binding-c/Cargo.toml b/crates/binding-c/Cargo.toml index 216f3683..df8ae605 100644 --- a/crates/binding-c/Cargo.toml +++ b/crates/binding-c/Cargo.toml @@ -9,7 +9,7 @@ license.workspace = true crate-type = ["rlib", "staticlib", "cdylib"] [dependencies] -cqlib-core = { path = "../cqlib-core", version = "0.1.0-beta.5" } +cqlib-core = { path = "../cqlib-core", version = "0.1.0-beta.6" } libc = "0.2" bitvec.workspace = true ndarray.workspace = true diff --git a/crates/binding-python/Cargo.toml b/crates/binding-python/Cargo.toml index 942962ac..926ce4ec 100644 --- a/crates/binding-python/Cargo.toml +++ b/crates/binding-python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "binding-python" -version = "2.0.0-beta5" +version = "2.0.0-beta6" edition.workspace = true rust-version.workspace = true license-file.workspace = true @@ -14,7 +14,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] pyo3 = { version = "0.29", features = ["abi3-py311", "num-complex", "chrono"] } chrono = "0.4" -cqlib-core = { path = "../cqlib-core", version = '0.1.0-beta.5' } +cqlib-core = { path = "../cqlib-core", version = '0.1.0-beta.6' } num-complex.workspace = true numpy.workspace = true rustworkx-core.workspace = true diff --git a/crates/cqlib-core/README.md b/crates/cqlib-core/README.md index 696a7225..5549ab8e 100644 --- a/crates/cqlib-core/README.md +++ b/crates/cqlib-core/README.md @@ -14,13 +14,13 @@ Add the crate to your project: ```toml [dependencies] -cqlib-core = "0.1.0-beta.5" +cqlib-core = "0.1.0-beta.6" ``` Or use Cargo: ```shell -cargo add cqlib-core@0.1.0-beta.5 +cargo add cqlib-core@0.1.0-beta.6 ``` ## Quick start diff --git a/crates/cqlib/Cargo.toml b/crates/cqlib/Cargo.toml index 650a1265..fbcc267d 100644 --- a/crates/cqlib/Cargo.toml +++ b/crates/cqlib/Cargo.toml @@ -26,4 +26,4 @@ categories = [ [dependencies] -cqlib-core = { path = "../cqlib-core", version = "0.1.0-beta.5" } +cqlib-core = { path = "../cqlib-core", version = "0.1.0-beta.6" } diff --git a/docs/releasing.md b/docs/releasing.md index 2d2fa6a7..d828ba1a 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -39,9 +39,9 @@ Tags use the Python version to identify a release batch: - Stable releases: `vX.Y.Z`. - Prereleases: `vX.Y.Z-alpha.N`, `vX.Y.Z-beta.N`, or `vX.Y.Z-rc.N`, with N starting at 1. -- Current tag: `v2.0.0-beta.5`. -- Current Python source version: `2.0.0-beta5`, normalized to `2.0.0b5` for PEP 440 and wheel filenames. -- Current Rust/C version: `0.1.0-beta.5`; both inherit the workspace version. +- Planned release tag: `v2.0.0-beta.6` (create manually after validation). +- Current Python source version: `2.0.0-beta6`, normalized to `2.0.0b6` for PEP 440 and wheel filenames. +- Current Rust/C version: `0.1.0-beta.6`; both inherit the workspace version. Python and Rust/C versions evolve independently and do not need matching numbers. The Cargo.toml files at a given tag record the version mapping for that @@ -50,6 +50,25 @@ CI checks that the tag is equivalent to the Python version, core dependency versions are consistent across bindings, the minimum Python version is 3.11, and `abi3-py311` is enabled. Maintainers must update versions before tagging. +## Beta 6 Compatibility Notes + +Beta 6 fixes composite-gate parameter binding and qubit ordering, nested global +phase preservation, symbolic parameter substitution, density-matrix target +validation, readout-noise qubit lookup, NumPy complex Hamiltonian coefficients, +and Pauli-vector labels. Callers upgrading from beta 5 should account for: + +- `GPhase` matrices now have shape 1×1, consistent with a zero-qubit gate. +- Gate-local instruction and operation matrices put the first qubit argument + at the most-significant bit, including circuit-backed gates. Full circuit + matrices retain their least-significant-bit-first basis. +- Parameter substitution is simultaneous: replacement expressions are not + substituted again by other entries in the same binding map. +- Pauli-vector labels put qubit 0 at the right by default, matching `PauliString`. +- Readout-noise selections use storage positions; circuit-bound states resolve + the configured noise using the circuit qubit ID at each selected position. +- Density-matrix gate operations reject duplicate targets before changing the + state. + ## Platforms and Artifacts | Platform | Runner / Build Environment |