From 73a8047314bcfa91e9ccf5f6fdeea52c92f844f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Sat, 10 Oct 2026 14:48:12 +0800 Subject: [PATCH 1/6] feat(core): add fermionic algebra and Jordan-Wigner mapping --- .../circuit/ansatz/hamiltonian_evolution.rs | 22 +- crates/cqlib-core/src/qis/error.rs | 24 + crates/cqlib-core/src/qis/evolution.rs | 34 +- .../src/qis/fermion/mapping/jordan_wigner.rs | 106 +++ .../cqlib-core/src/qis/fermion/mapping/mod.rs | 37 + crates/cqlib-core/src/qis/fermion/mod.rs | 72 ++ crates/cqlib-core/src/qis/fermion/operator.rs | 431 +++++++++ crates/cqlib-core/src/qis/fermion/tests.rs | 850 ++++++++++++++++++ crates/cqlib-core/src/qis/hamiltonian.rs | 43 + crates/cqlib-core/src/qis/mod.rs | 5 + crates/cqlib-core/src/qis/observable.rs | 47 +- 11 files changed, 1606 insertions(+), 65 deletions(-) create mode 100644 crates/cqlib-core/src/qis/fermion/mapping/jordan_wigner.rs create mode 100644 crates/cqlib-core/src/qis/fermion/mapping/mod.rs create mode 100644 crates/cqlib-core/src/qis/fermion/mod.rs create mode 100644 crates/cqlib-core/src/qis/fermion/operator.rs create mode 100644 crates/cqlib-core/src/qis/fermion/tests.rs diff --git a/crates/cqlib-core/src/circuit/ansatz/hamiltonian_evolution.rs b/crates/cqlib-core/src/circuit/ansatz/hamiltonian_evolution.rs index e9096ac2..fb77d5cd 100644 --- a/crates/cqlib-core/src/circuit/ansatz/hamiltonian_evolution.rs +++ b/crates/cqlib-core/src/circuit/ansatz/hamiltonian_evolution.rs @@ -181,7 +181,7 @@ pub struct EvolutionInfo { /// /// # Requirements /// -/// - The Hamiltonian must be **Hermitian**: after calling `simplify()`, all +/// - The Hamiltonian must be **Hermitian**: after phase normalization and merging, all /// coefficients must have negligible imaginary part ($|\text{Im}(c_k)| < 10^{-10}$). /// - The Hamiltonian must be **non-empty**. /// - For [`EvolutionStrategy::Exact`], all terms must mutually commute. @@ -232,7 +232,7 @@ pub struct PauliEvolutionAnsatz { /// The simplified Hamiltonian (normalized, de-duplicated). /// /// **Invariant**: always in simplified form (phases absorbed, duplicates merged, - /// near-zero terms removed). Any future mutating API must re-run `simplify()` and + /// exact-zero terms removed). Any future mutating API must re-run normalization and /// re-validate Hermiticity before updating this field. hamiltonian: Hamiltonian, /// Cached result of `hamiltonian.all_terms_commute()`, computed once in `new()`. @@ -252,9 +252,9 @@ pub struct PauliEvolutionAnsatz { impl PauliEvolutionAnsatz { /// Creates a new ansatz from a Hamiltonian. /// - /// Internally calls [`Hamiltonian::simplify`] to normalize the Hamiltonian - /// (merge duplicate terms, absorb phases into coefficients, remove near-zero terms) - /// before any further processing. + /// Validates and normalizes the Hamiltonian (merges duplicate terms, absorbs + /// phases into coefficients and removes exact zeros). Small nonzero coefficients + /// are retained, matching the numeric Hamiltonian evolution APIs. /// /// The default strategy is [`EvolutionStrategy::Auto`] with `steps = 1`. /// @@ -264,8 +264,7 @@ impl PauliEvolutionAnsatz { /// simplification, or [`QisError::NotHermitian`] if any coefficient has a non-zero /// imaginary part after simplification. pub fn new(hamiltonian: Hamiltonian) -> Result { - let mut h = hamiltonian; - h.simplify(); + let h = hamiltonian.normalized_hermitian()?; if h.terms.is_empty() { return Err(QisError::InvalidParameterValue( @@ -274,15 +273,6 @@ impl PauliEvolutionAnsatz { )); } - // Hermitian check: all coefficients must be real after simplify(). - // simplify() absorbs any PauliString phase into the coefficient, so a - // term like (0.5) * (-iY) becomes (-0.5i) * Y — which fails this check. - for (_pauli, coeff) in &h.terms { - if coeff.im.abs() > 1e-10 { - return Err(QisError::NotHermitian); - } - } - // Cache commutativity once; reused by validate(), evolution_info(), build_circuit(). let all_terms_commute = h.all_terms_commute(); diff --git a/crates/cqlib-core/src/qis/error.rs b/crates/cqlib-core/src/qis/error.rs index 808e764c..6cbf3aff 100644 --- a/crates/cqlib-core/src/qis/error.rs +++ b/crates/cqlib-core/src/qis/error.rs @@ -11,6 +11,7 @@ // that they have been altered from the originals. use crate::circuit::CircuitError; +use num_complex::Complex64; use thiserror::Error; /// Comprehensive error type for the `qis` (Quantum Information Science) module. @@ -19,6 +20,21 @@ use thiserror::Error; /// density matrix simulation, observables, Hamiltonians, and Pauli operator manipulations. #[derive(Debug, Error)] pub enum QisError { + /// A fermionic mode lies outside the declared mode space. + #[error("Fermionic mode {mode} is out of bounds for {num_modes} modes")] + ModeOutOfBounds { mode: usize, num_modes: usize }, + + /// Fermionic operators must declare the same mode count. + #[error("Mode count mismatch: expected {expected}, got {actual}")] + ModeMismatch { expected: usize, actual: usize }, + + /// Operator coefficients and arithmetic results must be finite. + #[error("Operator coefficient must be finite, got {coefficient}")] + NonFiniteCoefficient { coefficient: Complex64 }, + + /// A fermionic occupation must be either zero or one. + #[error("Invalid occupation at mode {index}: expected 0 or 1, got {value}")] + InvalidOccupation { index: usize, value: u8 }, /// Errors originating from quantum circuit operations, such as unsupported gates /// or symbolic parameters. #[error(transparent)] @@ -90,6 +106,14 @@ pub enum QisError { NonCliffordGate(String), } +pub(crate) fn validate_coefficient(coefficient: Complex64) -> Result<(), QisError> { + if coefficient.re.is_finite() && coefficient.im.is_finite() { + Ok(()) + } else { + Err(QisError::NonFiniteCoefficient { coefficient }) + } +} + /// Error type for parsing PauliString from a string representation. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum PauliStringParseError { diff --git a/crates/cqlib-core/src/qis/evolution.rs b/crates/cqlib-core/src/qis/evolution.rs index e81dc3a9..dc4bde31 100644 --- a/crates/cqlib-core/src/qis/evolution.rs +++ b/crates/cqlib-core/src/qis/evolution.rs @@ -449,6 +449,16 @@ pub(crate) fn trotter_second_order_core( Ok(()) } +// Numeric evolution historically reports coefficient overflow as unsupported. +fn normalized_evolution(h: &Hamiltonian) -> Result { + h.normalized_hermitian().map_err(|error| match error { + nonfinite @ QisError::NonFiniteCoefficient { .. } => { + QisError::UnsupportedOperation(nonfinite.to_string()) + } + other => other, + }) +} + impl Hamiltonian { /// Converts the Hamiltonian to a Trotterized time evolution circuit. /// @@ -459,6 +469,8 @@ impl Hamiltonian { /// the Hamiltonian terms commute. For an auto-selecting method that uses the /// mathematically exact single-pass decomposition when all terms commute, see /// [`to_evolution_circuit`](Hamiltonian::to_evolution_circuit). + /// Nonempty sums that cancel to zero produce an identity circuit, retaining + /// the declared qubit count. An empty input sum is rejected. /// /// # Arguments /// @@ -521,15 +533,7 @@ impl Hamiltonian { )); } - let mut simplified_h = self.clone(); - simplified_h.simplify_with_tolerance(0.0); - - for (_, coeff) in &simplified_h.terms { - if coeff.im.abs() > 1e-10 { - return Err(QisError::NotHermitian); - } - } - + let simplified_h = normalized_evolution(self)?; let mut circuit = Circuit::new(simplified_h.num_qubits); let qubits: Vec = circuit.qubits(); let t = ParameterValue::Fixed(time); @@ -591,6 +595,8 @@ impl Hamiltonian { /// This mirrors the behavior of [`PauliEvolutionAnsatz`] with /// [`EvolutionStrategy::Auto`], closing the capability gap between the numeric /// and parametric APIs. + /// Nonempty sums that cancel to zero produce an identity circuit, retaining + /// the declared qubit count. An empty input sum is rejected. /// /// # Arguments /// @@ -641,15 +647,7 @@ impl Hamiltonian { )); } - let mut simplified_h = self.clone(); - simplified_h.simplify_with_tolerance(0.0); - - for (_, coeff) in &simplified_h.terms { - if coeff.im.abs() > 1e-10 { - return Err(QisError::NotHermitian); - } - } - + let simplified_h = normalized_evolution(self)?; let mut circuit = Circuit::new(simplified_h.num_qubits); let qubits: Vec = circuit.qubits(); diff --git a/crates/cqlib-core/src/qis/fermion/mapping/jordan_wigner.rs b/crates/cqlib-core/src/qis/fermion/mapping/jordan_wigner.rs new file mode 100644 index 00000000..db60aaf9 --- /dev/null +++ b/crates/cqlib-core/src/qis/fermion/mapping/jordan_wigner.rs @@ -0,0 +1,106 @@ +// 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. + +//! Jordan–Wigner mapping using sparse Pauli arithmetic. + +use super::{FermionToQubitMapper, OccupationStateMapper}; +use crate::qis::fermion::{FermionicLadder, FermionicOp}; +use crate::qis::{Hamiltonian, Pauli, PauliString, QisError}; +use num_complex::Complex64; +use std::collections::BTreeMap; + +/// Jordan–Wigner encoding with mode p mapped to qubit p. +/// +/// The Z chain covers modes `0..p`: creation is `Z_chain (X_p - iY_p)/2`, +/// annihilation is `Z_chain (X_p + iY_p)/2`. Qubit zero is the least-significant +/// basis bit, and the rightmost character of a displayed Pauli string. +/// Mapping never constructs a dense matrix or implicitly normal orders its input. +/// A product of k ladders can expand to 2^k terms before sparse merging. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct JordanWignerMapper; + +impl JordanWignerMapper { + /// Creates a stateless mapper. + pub fn new() -> Self { + Self + } +} + +impl FermionToQubitMapper for JordanWignerMapper { + fn num_qubits(&self, num_modes: usize) -> usize { + num_modes + } + + fn map_operator(&self, op: &FermionicOp) -> Result { + let n = self.num_qubits(op.num_modes()); + let mut result = Hamiltonian::new(n); + // Build only the components needed by this input, and reuse them across terms. + let mut components = BTreeMap::new(); + for (term, coefficient) in op.terms() { + let mut product = Hamiltonian::new(n); + // Merge the unit-weight product before applying the input coefficient, + // so representable small results are not lost in ladder intermediates. + product.add_term(PauliString::new(n), Complex64::new(1.0, 0.0))?; + for ladder in term { + let p = ladder.mode(); + let (x, y) = components.entry(p).or_insert_with(|| { + let mut x = PauliString::new(n); + let mut y = PauliString::new(n); + for j in 0..p { + x.set_pauli(j, Pauli::Z); + y.set_pauli(j, Pauli::Z); + } + x.set_pauli(p, Pauli::X); + y.set_pauli(p, Pauli::Y); + (x, y) + }); + let imaginary = match ladder { + FermionicLadder::Create(_) => -0.5, + FermionicLadder::Annihilate(_) => 0.5, + }; + let mut next = Hamiltonian::new(n); + for (pauli, c) in &product.terms { + next.add_term(pauli * &*x, *c * 0.5)?; + next.add_term(pauli * &*y, *c * Complex64::new(0.0, imaginary))?; + } + product = next.normalized()?; + if product.terms.is_empty() { + break; + } + } + result.terms.extend( + product + .terms + .into_iter() + .map(|(pauli, c)| (pauli, c * coefficient)), + ); + } + result.normalized() + } +} + +impl OccupationStateMapper for JordanWignerMapper { + fn map_occupation(&self, occupation: &[u8]) -> Result, QisError> { + occupation + .iter() + .enumerate() + .map(|(index, value)| match value { + 0 => Ok(false), + 1 => Ok(true), + value => Err(QisError::InvalidOccupation { + index, + value: *value, + }), + }) + .collect() + } +} diff --git a/crates/cqlib-core/src/qis/fermion/mapping/mod.rs b/crates/cqlib-core/src/qis/fermion/mapping/mod.rs new file mode 100644 index 00000000..b7fed5dd --- /dev/null +++ b/crates/cqlib-core/src/qis/fermion/mapping/mod.rs @@ -0,0 +1,37 @@ +// 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. + +//! Contracts for fermion-to-qubit operators and occupation-state encodings. + +pub mod jordan_wigner; +pub use jordan_wigner::JordanWignerMapper; + +use super::FermionicOp; +use crate::qis::{Hamiltonian, QisError}; + +/// Maps finite fermionic operators, including non-Hermitian operators, to Pauli sums. +/// Circuit synthesis and Hermiticity requirements belong to downstream consumers. +pub trait FermionToQubitMapper { + /// Maps an operator while preserving its declared mode space. + fn map_operator(&self, op: &FermionicOp) -> Result; + /// Returns the qubit count required to encode the given mode space. + fn num_qubits(&self, num_modes: usize) -> usize; +} + +/// Encodes occupations into computational-basis bits, where `bits[p]` addresses qp. +/// This is separate from operator mapping because some encodings do not map +/// occupations to individual computational basis states. +pub trait OccupationStateMapper { + /// Maps a vector of zero/one occupations; its length defines the mode count. + /// An empty vector encodes the zero-mode vacuum as an empty bit vector. + fn map_occupation(&self, occupation: &[u8]) -> Result, QisError>; +} diff --git a/crates/cqlib-core/src/qis/fermion/mod.rs b/crates/cqlib-core/src/qis/fermion/mod.rs new file mode 100644 index 00000000..82a47998 --- /dev/null +++ b/crates/cqlib-core/src/qis/fermion/mod.rs @@ -0,0 +1,72 @@ +// 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. + +//! Fermionic algebra, qubit mappings and occupation encodings. +//! +//! Modes are numbered from zero. Ordered products act right-to-left. The Fock +//! basis is `|n_{N-1} ... n_0> = (a†_0)^n_0 ... (a†_{N-1})^n_{N-1} |vac>`. +//! Qubit zero is the least-significant bit; state indices are `sum(n_p * 2^p)`. +//! Core does not assign spin or chemical-orbital meanings to these indices. +//! Zero and identity operators retain their declared mode count, including zero +//! modes. Circuit consumers can have stricter requirements: basis encoding needs +//! nonempty bits, and `PauliEvolutionAnsatz` needs a nonzero Hermitian operator. +//! Numeric evolution rejects empty input sums but returns identity evolution +//! when a nonempty sum cancels to zero. +//! +//! # Particle-number observable +//! ``` +//! use cqlib_core::qis::{FermionicOp, FermionToQubitMapper, JordanWignerMapper, +//! Observable, Statevector}; +//! let mapper = JordanWignerMapper::new(); +//! let number = mapper.map_operator(&FermionicOp::total_number(3))?; +//! let mut state = Statevector::new(3); +//! state.apply_x(0); +//! state.apply_x(2); +//! assert_eq!(number.expectation_statevector(&state)?, 2.0); +//! # Ok::<(), cqlib_core::qis::QisError>(()) +//! ``` +//! +//! # Parameterized excitation evolution +//! ``` +//! use cqlib_core::qis::{FermionicLadder::{Create, Annihilate}, FermionicOp, +//! FermionToQubitMapper, JordanWignerMapper}; +//! use cqlib_core::circuit::ansatz::{Ansatz, PauliEvolutionAnsatz}; +//! use num_complex::Complex64; +//! let t = FermionicOp::from_term(4, +//! [Create(2), Create(3), Annihilate(1), Annihilate(0)], 1.0.into())?; +//! let h = (&t - t.adjoint())?.scaled(Complex64::new(0.0, 1.0))?; +//! // e^(-i H theta) = e^(theta (T - T†)). +//! let mapped = JordanWignerMapper::new().map_operator(&h)?; +//! let circuit = PauliEvolutionAnsatz::new(mapped)?.build_circuit("excitation")?; +//! assert_eq!(circuit.num_qubits(), 4); +//! # Ok::<(), cqlib_core::qis::QisError>(()) +//! ``` +//! +//! # Occupation-state encoding +//! ``` +//! use cqlib_core::qis::{JordanWignerMapper, OccupationStateMapper, Statevector}; +//! use cqlib_core::circuit::ansatz::{Ansatz, BasisEncoding}; +//! let bits = JordanWignerMapper::new().map_occupation(&[1, 0, 0])?; +//! let circuit = BasisEncoding::new(bits).build_circuit("reference")?; +//! let state = Statevector::from_circuit(&circuit)?; +//! assert_eq!(state.probabilities()[1], 1.0); // displayed as |001> +//! # Ok::<(), cqlib_core::qis::QisError>(()) +//! ``` + +pub mod mapping; +pub mod operator; + +pub use mapping::{FermionToQubitMapper, JordanWignerMapper, OccupationStateMapper}; +pub use operator::{FermionicLadder, FermionicOp}; + +#[cfg(test)] +mod tests; diff --git a/crates/cqlib-core/src/qis/fermion/operator.rs b/crates/cqlib-core/src/qis/fermion/operator.rs new file mode 100644 index 00000000..0ac3915c --- /dev/null +++ b/crates/cqlib-core/src/qis/fermion/operator.rs @@ -0,0 +1,431 @@ +// 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. + +//! Structured fermionic operators and canonical anticommutation algebra. + +use crate::qis::QisError; +use crate::qis::error::validate_coefficient; +use num_complex::Complex64; +use std::collections::BTreeMap; +use std::ops::{Add, Mul, Neg, Sub}; + +/// A creation or annihilation operator on a zero-based fermionic mode. +/// +/// A sequence `[A, B]` denotes the mathematical product `A B`: `B` acts first. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum FermionicLadder { + /// Creates a particle in the given mode. + Create(usize), + /// Removes a particle from the given mode. + Annihilate(usize), +} + +impl FermionicLadder { + /// Returns the mode on which this operator acts. + pub fn mode(self) -> usize { + match self { + Self::Create(mode) | Self::Annihilate(mode) => mode, + } + } + + /// Exchanges creation and annihilation. + pub fn adjoint(self) -> Self { + match self { + Self::Create(mode) => Self::Annihilate(mode), + Self::Annihilate(mode) => Self::Create(mode), + } + } +} + +/// A finite complex linear combination of ordered fermionic products. +/// +/// The declared mode count is retained even for zero and identity operators. +/// Equal ordered products are merged automatically; only exact zero coefficients +/// are removed. Products are not automatically normal ordered. Use [`Self::chop`] +/// for explicit numerical truncation and [`Self::equiv`] for algebraic equivalence. +/// Structural equality also compares the declared mode count. +/// +/// Fallible arithmetic operators return `Result`, for example `(&a + &b)?`. +#[derive(Debug, Clone, PartialEq)] +pub struct FermionicOp { + num_modes: usize, + terms: BTreeMap, Complex64>, +} + +pub(crate) fn validate_tolerance(atol: f64) -> Result<(), QisError> { + if !atol.is_finite() || atol < 0.0 { + return Err(QisError::InvalidParameterValue( + "tolerance must be finite and nonnegative".into(), + )); + } + Ok(()) +} + +impl FermionicOp { + /// Creates the zero operator on the declared mode space, including zero modes. + pub fn new(num_modes: usize) -> Self { + Self { + num_modes, + terms: BTreeMap::new(), + } + } + + /// Creates the identity. An empty product is the identity, not the zero operator. + pub fn identity(num_modes: usize) -> Self { + let mut result = Self::new(num_modes); + result.terms.insert(Vec::new(), Complex64::new(1.0, 0.0)); + result + } + + /// Creates a weighted ordered product. Even zero-weight terms are validated. + pub fn from_term( + num_modes: usize, + term: impl IntoIterator, + coefficient: Complex64, + ) -> Result { + let mut result = Self::new(num_modes); + result.add_term(term, coefficient)?; + Ok(result) + } + + /// Creates a sum of products, merging duplicates without numerical truncation. + pub fn from_terms( + num_modes: usize, + terms: impl IntoIterator, Complex64)>, + ) -> Result { + let mut result = Self::new(num_modes); + for (term, coefficient) in terms { + result.add_term(term, coefficient)?; + } + Ok(result) + } + + /// Adds a weighted product atomically; an error leaves this operator unchanged. + pub fn add_term( + &mut self, + term: impl IntoIterator, + coefficient: Complex64, + ) -> Result<(), QisError> { + validate_coefficient(coefficient)?; + let term: Vec<_> = term.into_iter().collect(); + for ladder in &term { + let mode = ladder.mode(); + if mode >= self.num_modes { + return Err(QisError::ModeOutOfBounds { + mode, + num_modes: self.num_modes, + }); + } + } + let merged = self.terms.get(&term).copied().unwrap_or_default() + coefficient; + validate_coefficient(merged)?; + if merged == Complex64::new(0.0, 0.0) { + self.terms.remove(&term); + } else { + self.terms.insert(term, merged); + } + Ok(()) + } + + /// Creates a single creation operator. + pub fn creation(num_modes: usize, mode: usize) -> Result { + Self::from_term(num_modes, [FermionicLadder::Create(mode)], 1.0.into()) + } + + /// Creates a single annihilation operator. + pub fn annihilation(num_modes: usize, mode: usize) -> Result { + Self::from_term(num_modes, [FermionicLadder::Annihilate(mode)], 1.0.into()) + } + + /// Creates the number operator `a†_p a_p`. + pub fn number(num_modes: usize, mode: usize) -> Result { + Self::from_term( + num_modes, + [ + FermionicLadder::Create(mode), + FermionicLadder::Annihilate(mode), + ], + 1.0.into(), + ) + } + + /// Creates the total particle-number operator, or zero for zero modes. + pub fn total_number(num_modes: usize) -> Self { + let terms = (0..num_modes) + .map(|mode| { + ( + vec![ + FermionicLadder::Create(mode), + FermionicLadder::Annihilate(mode), + ], + 1.0.into(), + ) + }) + .collect(); + Self { num_modes, terms } + } + + /// Returns the declared mode count. + pub fn num_modes(&self) -> usize { + self.num_modes + } + + /// Returns the number of stored ordered products. + pub fn num_terms(&self) -> usize { + self.terms.len() + } + + /// Iterates over products and their coefficients in deterministic structural order. + pub fn terms(&self) -> impl ExactSizeIterator + '_ { + self.terms + .iter() + .map(|(term, coefficient)| (term.as_slice(), *coefficient)) + } + + /// Reports whether the stored sum is zero. Algebraic cancellations require normal ordering. + pub fn is_zero(&self) -> bool { + self.terms.is_empty() + } + + fn check_modes(&self, other: &Self) -> Result<(), QisError> { + if self.num_modes != other.num_modes { + return Err(QisError::ModeMismatch { + expected: self.num_modes, + actual: other.num_modes, + }); + } + Ok(()) + } + + /// Adds operators on the same mode space, without modifying either operand. + pub fn try_add(&self, other: &Self) -> Result { + self.check_modes(other)?; + let mut result = self.clone(); + for (term, coefficient) in other.terms() { + result.add_term(term.iter().copied(), coefficient)?; + } + Ok(result) + } + + /// Subtracts operators on the same mode space. + pub fn try_sub(&self, other: &Self) -> Result { + self.check_modes(other)?; + let mut result = self.clone(); + for (term, coefficient) in other.terms() { + result.add_term(term.iter().copied(), -coefficient)?; + } + Ok(result) + } + + /// Forms `self * other`; the right operand acts first. Does not normal order. + pub fn try_mul(&self, other: &Self) -> Result { + self.check_modes(other)?; + let mut result = Self::new(self.num_modes); + for (left, a) in self.terms() { + for (right, b) in other.terms() { + result.add_term(left.iter().chain(right).copied(), a * b)?; + } + } + Ok(result) + } + + /// Scales all coefficients. The factor is validated even for the zero operator. + pub fn scaled(&self, factor: Complex64) -> Result { + validate_coefficient(factor)?; + Self::from_terms( + self.num_modes, + self.terms + .iter() + .map(|(term, c)| (term.clone(), *c * factor)), + ) + } + + /// Takes the conjugate transpose, reversing each product without reordering it. + pub fn adjoint(&self) -> Self { + Self { + num_modes: self.num_modes, + terms: self + .terms + .iter() + .map(|(term, coefficient)| { + ( + term.iter().rev().map(|ladder| ladder.adjoint()).collect(), + coefficient.conj(), + ) + }) + .collect(), + } + } + + /// Expands using the canonical anticommutation relations. + /// + /// Creators precede annihilators; each group uses ascending mode indices. + /// This is an equivalent operator expansion, including contraction terms: + /// `a_p a†_p = I - a†_p a_p`. Small nonzero coefficients are retained. + /// An explicit work stack avoids recursion in long products. + /// Sorting a branch of k ladders takes O(k²) comparisons; contractions can + /// produce additional branches. + pub fn normal_order(&self) -> Result { + let mut result = Self::new(self.num_modes); + let mut pending: Vec<_> = self + .terms + .iter() + .map(|(term, c)| (term.clone(), *c, 0usize)) + .collect(); + while let Some((mut term, mut coefficient, mut index)) = pending.pop() { + let mut complete = true; + while index + 1 < term.len() { + let left = term[index]; + let right = term[index + 1]; + if left == right { + complete = false; + break; + } + if left > right { + // Swapping distinct fermionic ladders contributes a minus sign. + // Equal-mode annihilator/creator pairs also contribute a contraction. + if let (FermionicLadder::Annihilate(p), FermionicLadder::Create(q)) = + (left, right) + && p == q + { + let mut contracted = term.clone(); + contracted.drain(index..index + 2); + // The prefix is already sorted. Removing this pair only + // requires rechecking the new boundary with that prefix. + pending.push((contracted, coefficient, index.saturating_sub(1))); + } + term.swap(index, index + 1); + coefficient = -coefficient; + // Revisit the changed boundary, rather than scanning from zero. + index = index.saturating_sub(1); + } else { + index += 1; + } + } + if complete { + result.add_term(term, coefficient)?; + } + } + Ok(result) + } + + /// Explicitly removes coefficients whose complex magnitude is at most `atol`. + /// Invalid tolerances leave the operator unchanged. + pub fn chop(&mut self, atol: f64) -> Result<(), QisError> { + validate_tolerance(atol)?; + self.terms + .retain(|_, coefficient| coefficient.norm() > atol); + Ok(()) + } + + /// Compares operators algebraically after normal ordering their difference. + /// Different mode counts are an error; `atol` must be finite and nonnegative. + pub fn equiv(&self, other: &Self, atol: f64) -> Result { + validate_tolerance(atol)?; + let difference = self.try_sub(other)?.normal_order()?; + Ok(difference + .terms + .values() + .all(|coefficient| coefficient.norm() <= atol)) + } + + /// Checks Hermiticity algebraically, allowing non-normal-ordered inputs. + pub fn is_hermitian(&self, atol: f64) -> Result { + self.equiv(&self.adjoint(), atol) + } +} + +macro_rules! binary_operator { + ($trait:ident, $method:ident, $checked:ident) => { + impl $trait<&FermionicOp> for &FermionicOp { + type Output = Result; + fn $method(self, rhs: &FermionicOp) -> Self::Output { + self.$checked(rhs) + } + } + impl $trait for FermionicOp { + type Output = Result; + fn $method(self, rhs: FermionicOp) -> Self::Output { + self.$checked(&rhs) + } + } + impl $trait<&FermionicOp> for FermionicOp { + type Output = Result; + fn $method(self, rhs: &FermionicOp) -> Self::Output { + self.$checked(rhs) + } + } + impl $trait for &FermionicOp { + type Output = Result; + fn $method(self, rhs: FermionicOp) -> Self::Output { + self.$checked(&rhs) + } + } + }; +} + +binary_operator!(Add, add, try_add); +binary_operator!(Sub, sub, try_sub); +binary_operator!(Mul, mul, try_mul); + +macro_rules! scalar_operator { + ($scalar:ty) => { + impl Mul<$scalar> for &FermionicOp { + type Output = Result; + fn mul(self, rhs: $scalar) -> Self::Output { + self.scaled(rhs.into()) + } + } + impl Mul<$scalar> for FermionicOp { + type Output = Result; + fn mul(self, rhs: $scalar) -> Self::Output { + self.scaled(rhs.into()) + } + } + impl Mul<&FermionicOp> for $scalar { + type Output = Result; + fn mul(self, rhs: &FermionicOp) -> Self::Output { + rhs.scaled(self.into()) + } + } + impl Mul for $scalar { + type Output = Result; + fn mul(self, rhs: FermionicOp) -> Self::Output { + rhs.scaled(self.into()) + } + } + }; +} + +scalar_operator!(f64); +scalar_operator!(Complex64); + +impl Neg for &FermionicOp { + type Output = FermionicOp; + fn neg(self) -> Self::Output { + let mut result = self.clone(); + for coefficient in result.terms.values_mut() { + *coefficient = -*coefficient; + } + result + } +} + +impl Neg for FermionicOp { + type Output = Self; + fn neg(mut self) -> Self::Output { + for coefficient in self.terms.values_mut() { + *coefficient = -*coefficient; + } + self + } +} diff --git a/crates/cqlib-core/src/qis/fermion/tests.rs b/crates/cqlib-core/src/qis/fermion/tests.rs new file mode 100644 index 00000000..2d9494f2 --- /dev/null +++ b/crates/cqlib-core/src/qis/fermion/tests.rs @@ -0,0 +1,850 @@ +// 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. + +use super::*; +use crate::circuit::ansatz::{Ansatz, BasisEncoding, EvolutionStrategy, PauliEvolutionAnsatz}; +use crate::circuit::circuit_to_matrix; +use crate::qis::{ + DensityMatrix, Hamiltonian, Observable, PauliString, Phase, QisError, Statevector, TrotterMode, +}; +use FermionicLadder::{Annihilate, Create}; +use ndarray::Array2; +use num_complex::Complex64; +use std::collections::{BTreeMap, HashMap}; + +// Independent Fock-basis action: no Pauli matrices or mapping formulas are used. +fn fock_matrix(op: &FermionicOp) -> Array2 { + let dim = 1 << op.num_modes(); + let mut matrix = Array2::zeros((dim, dim)); + for (word, coefficient) in op.terms() { + for source in 0..dim { + let mut state = source; + let mut amplitude = coefficient; + for ladder in word.iter().rev() { + let p = ladder.mode(); + let occupied = state & (1 << p) != 0; + if matches!(ladder, Create(_)) == occupied { + amplitude = Complex64::new(0.0, 0.0); + break; + } + if (state & ((1 << p) - 1)).count_ones() % 2 == 1 { + amplitude = -amplitude; + } + state ^= 1 << p; + } + matrix[[state, source]] += amplitude; + } + } + matrix +} + +fn assert_matrix_close(left: &Array2, right: &Array2) { + assert_eq!(left.dim(), right.dim()); + for (a, b) in left.iter().zip(right) { + assert!((*a - *b).norm() < 1e-11, "{a} != {b}"); + } +} + +fn matrix_exp(matrix: &Array2, scale: f64) -> Array2 { + let mut term = Array2::::eye(matrix.nrows()); + let mut result = term.clone(); + for order in 1..=48 { + term = term.dot(matrix).mapv(|c| c * scale / order as f64); + result += &term; + } + result +} + +fn bound_evolution(h: Hamiltonian, time: f64) -> Array2 { + let circuit = PauliEvolutionAnsatz::new(h) + .unwrap() + .build_circuit("fermion") + .unwrap(); + let bindings = HashMap::from([("fermion_t", time)]); + let circuit = circuit.assign_parameters(&Some(bindings)).unwrap(); + circuit_to_matrix(&circuit, None).unwrap() +} + +fn pauli_terms(h: &Hamiltonian) -> BTreeMap { + assert!(h.terms.iter().all(|(p, _)| p.phase == Phase::Plus)); + h.terms.iter().map(|(p, c)| (p.to_string(), *c)).collect() +} + +#[test] +fn canonical_anticommutation_relations() { + for p in 0..4 { + for q in 0..4 { + let a = FermionicOp::annihilation(4, p).unwrap(); + let b = FermionicOp::annihilation(4, q).unwrap(); + let bd = b.adjoint(); + let anticommutator = (a.try_mul(&bd).unwrap() + bd.try_mul(&a).unwrap()).unwrap(); + let expected = if p == q { + FermionicOp::identity(4) + } else { + FermionicOp::new(4) + }; + assert_eq!(anticommutator.normal_order().unwrap(), expected); + let annihilators = (a.try_mul(&b).unwrap() + b.try_mul(&a).unwrap()).unwrap(); + assert!(annihilators.normal_order().unwrap().is_zero()); + let creators = + (a.adjoint().try_mul(&bd).unwrap() + bd.try_mul(&a.adjoint()).unwrap()).unwrap(); + assert!(creators.normal_order().unwrap().is_zero()); + } + } +} + +#[test] +fn contractions_and_normal_order_preserve_remaining_branches() { + let a = FermionicOp::annihilation(3, 0).unwrap(); + let ad = a.adjoint(); + let hole = (&a * &ad).unwrap(); + assert_eq!( + hole.normal_order().unwrap(), + (FermionicOp::identity(3) - FermionicOp::number(3, 0).unwrap()).unwrap() + ); + assert_eq!((&hole * &a).unwrap().normal_order().unwrap(), a); + assert_eq!((&ad * &hole).unwrap().normal_order().unwrap(), ad); + let n = FermionicOp::number(3, 0).unwrap(); + assert_eq!((&n * &n).unwrap().normal_order().unwrap(), n); + let input = FermionicOp::from_term( + 3, + [Annihilate(0), Create(0), Create(1), Annihilate(2)], + 1.0.into(), + ) + .unwrap(); + let expected = FermionicOp::from_terms( + 3, + [ + (vec![Create(1), Annihilate(2)], 1.0.into()), + ( + vec![Create(0), Create(1), Annihilate(0), Annihilate(2)], + 1.0.into(), + ), + ], + ) + .unwrap(); + assert_eq!(input.normal_order().unwrap(), expected); + assert_eq!(expected.normal_order().unwrap(), expected); +} + +#[test] +fn long_single_branch_normal_order_preserves_sign_and_nilpotency() { + for n in [399, 400, 799, 800] { + let word: Vec<_> = (0..n).rev().map(Create).collect(); + let input = FermionicOp::from_term(n, word.clone(), 1.0.into()).unwrap(); + let sign = if (n * (n - 1) / 2) % 2 == 0 { + 1.0 + } else { + -1.0 + }; + let expected = FermionicOp::from_term(n, (0..n).map(Create), sign.into()).unwrap(); + assert_eq!(input.normal_order().unwrap(), expected); + assert_eq!(expected.normal_order().unwrap(), expected); + + let mut repeated = word; + repeated.push(Create(n - 1)); + assert!( + FermionicOp::from_term(n, repeated, 1.0.into()) + .unwrap() + .normal_order() + .unwrap() + .is_zero() + ); + } +} + +#[test] +fn multiplication_keeps_order_and_adjoint_reverses_it() { + let a = + FermionicOp::from_term(3, [Create(2), Annihilate(0)], Complex64::new(2.0, 3.0)).unwrap(); + let expected = + FermionicOp::from_term(3, [Create(0), Annihilate(2)], Complex64::new(2.0, -3.0)).unwrap(); + assert_eq!(a.adjoint(), expected); + assert_eq!(a.adjoint().adjoint(), a); + let b = FermionicOp::creation(3, 1).unwrap(); + let product = (&a * &b).unwrap(); + assert_eq!( + product.terms().next().unwrap().0, + [Create(2), Annihilate(0), Create(1)] + ); + assert_eq!(product.adjoint(), (&b.adjoint() * &a.adjoint()).unwrap()); + assert_eq!(Create(1).adjoint(), Annihilate(1)); +} + +#[test] +fn exact_merging_explicit_chopping_and_structural_equality() { + let mut op = FermionicOp::from_terms( + 4, + [ + (vec![Create(1)], 0.5.into()), + (vec![Create(1)], (-0.5).into()), + (vec![Annihilate(3)], Complex64::new(1e-14, 0.0)), + ], + ) + .unwrap(); + assert_eq!(op.num_terms(), 1); + assert_eq!(op.num_modes(), 4); + op.chop(0.0).unwrap(); + assert_eq!(op.num_terms(), 1); + op.chop(1e-14).unwrap(); + assert_eq!(op, FermionicOp::new(4)); + let input = FermionicOp::from_term(2, [Create(1), Create(0)], 1.0.into()).unwrap(); + let ordered = FermionicOp::from_term(2, [Create(0), Create(1)], (-1.0).into()).unwrap(); + assert_ne!(input, ordered); + assert!(input.equiv(&ordered, 0.0).unwrap()); + assert_ne!(FermionicOp::new(1), FermionicOp::new(2)); +} + +#[test] +fn fallible_owned_and_borrowed_arithmetic() { + let a = FermionicOp::creation(2, 0).unwrap(); + let b = FermionicOp::annihilation(2, 1).unwrap(); + let sum = a.try_add(&b).unwrap(); + assert_eq!((&a + &b).unwrap(), sum); + assert_eq!((a.clone() + &b).unwrap(), sum); + assert_eq!((&a + b.clone()).unwrap(), sum); + assert_eq!((a.clone() + b.clone()).unwrap(), sum); + let difference = a.try_sub(&b).unwrap(); + assert_eq!((&a - &b).unwrap(), difference); + assert_eq!((a.clone() - &b).unwrap(), difference); + assert_eq!((&a - b.clone()).unwrap(), difference); + assert_eq!((a.clone() - b.clone()).unwrap(), difference); + let product = a.try_mul(&b).unwrap(); + assert_eq!((&a * &b).unwrap(), product); + assert_eq!((a.clone() * &b).unwrap(), product); + assert_eq!((&a * b.clone()).unwrap(), product); + assert_eq!((a.clone() * b.clone()).unwrap(), product); + for factor in [Complex64::new(2.0, 0.0), Complex64::new(0.0, 1.0)] { + let scaled = a.scaled(factor).unwrap(); + assert_eq!((&a * factor).unwrap(), scaled); + assert_eq!((a.clone() * factor).unwrap(), scaled); + assert_eq!((factor * &a).unwrap(), scaled); + assert_eq!((factor * a.clone()).unwrap(), scaled); + } + assert_eq!((&a * 2.0).unwrap(), (2.0 * &a).unwrap()); + assert_eq!((a.clone() * 2.0).unwrap(), (2.0 * a.clone()).unwrap()); + assert_eq!(-&a, -a.clone()); + assert!((&a + -&a).unwrap().is_zero()); +} + +#[test] +fn invalid_inputs_and_failed_mutation_do_not_corrupt_operators() { + assert!(matches!( + FermionicOp::creation(0, 0), + Err(QisError::ModeOutOfBounds { + mode: 0, + num_modes: 0 + }) + )); + assert!(FermionicOp::annihilation(2, 2).is_err()); + assert!(FermionicOp::number(1, usize::MAX).is_err()); + assert!(FermionicOp::from_term(1, [Create(2)], 0.0.into()).is_err()); + let a = FermionicOp::creation(1, 0).unwrap(); + let b = FermionicOp::new(2); + for result in [&a + &b, &a - &b, &a * &b] { + assert!(matches!( + result, + Err(QisError::ModeMismatch { + expected: 1, + actual: 2 + }) + )); + } + assert!(a.equiv(&b, 0.0).is_err()); + for c in [ + Complex64::new(f64::NAN, 0.0), + Complex64::new(0.0, f64::INFINITY), + ] { + assert!(matches!( + FermionicOp::from_term(1, [], c), + Err(QisError::NonFiniteCoefficient { .. }) + )); + assert!(FermionicOp::new(1).scaled(c).is_err()); + } + let mut large = FermionicOp::from_term(1, [Create(0)], f64::MAX.into()).unwrap(); + let saved = large.clone(); + assert!(large.add_term([Create(0)], f64::MAX.into()).is_err()); + assert_eq!(large, saved); + assert!(large.add_term([Create(1)], 1.0.into()).is_err()); + assert_eq!(large, saved); + assert!(large.scaled(2.0.into()).is_err()); + for tolerance in [-1.0, f64::NAN, f64::INFINITY] { + assert!(large.chop(tolerance).is_err()); + assert_eq!(large, saved); + assert!(large.is_hermitian(tolerance).is_err()); + assert!(large.equiv(&saved, tolerance).is_err()); + } +} + +#[test] +fn hermiticity_checks_use_fermionic_algebra() { + let hole = FermionicOp::from_term(2, [Annihilate(0), Create(0)], 1.0.into()).unwrap(); + assert!(hole.is_hermitian(0.0).unwrap()); + let t = FermionicOp::from_term(2, [Create(1), Annihilate(0)], 1.0.into()).unwrap(); + assert!(!t.is_hermitian(0.0).unwrap()); + let generator = (&t - t.adjoint()) + .unwrap() + .scaled(Complex64::new(0.0, 1.0)) + .unwrap(); + assert!(generator.is_hermitian(0.0).unwrap()); + assert!( + !FermionicOp::identity(0) + .scaled(Complex64::new(0.0, 1.0)) + .unwrap() + .is_hermitian(0.0) + .unwrap() + ); +} + +#[test] +fn ladder_number_hole_and_identity_mappings() { + let mapper = JordanWignerMapper::new(); + let creation = mapper + .map_operator(&FermionicOp::creation(4, 2).unwrap()) + .unwrap(); + assert_eq!( + pauli_terms(&creation), + BTreeMap::from([ + ("+IXZZ".into(), 0.5.into()), + ("+IYZZ".into(), Complex64::new(0.0, -0.5)), + ]) + ); + let annihilation = mapper + .map_operator(&FermionicOp::annihilation(4, 2).unwrap()) + .unwrap(); + assert_eq!( + pauli_terms(&annihilation), + BTreeMap::from([ + ("+IXZZ".into(), 0.5.into()), + ("+IYZZ".into(), Complex64::new(0.0, 0.5)), + ]) + ); + assert_eq!( + pauli_terms( + &mapper + .map_operator(&FermionicOp::number(4, 2).unwrap()) + .unwrap() + ), + BTreeMap::from([ + ("+IIII".into(), 0.5.into()), + ("+IZII".into(), (-0.5).into()), + ]) + ); + let hole = FermionicOp::from_term(4, [Annihilate(2), Create(2)], 1.0.into()).unwrap(); + assert_eq!( + pauli_terms(&mapper.map_operator(&hole).unwrap()), + BTreeMap::from([("+IIII".into(), 0.5.into()), ("+IZII".into(), 0.5.into()),]) + ); + assert_eq!( + pauli_terms(&mapper.map_operator(&FermionicOp::identity(4)).unwrap()), + BTreeMap::from([("+IIII".into(), 1.0.into())]) + ); +} + +#[test] +fn zero_scalar_and_wide_mode_spaces_are_preserved() { + let mapper = JordanWignerMapper::new(); + for n in [0, 1, 4, 129] { + let zero = mapper.map_operator(&FermionicOp::new(n)).unwrap(); + assert_eq!(zero.num_qubits, n); + assert!(zero.terms.is_empty()); + assert_eq!(mapper.num_qubits(n), n); + } + let scalar = FermionicOp::identity(0) + .scaled(Complex64::new(2.0, 3.0)) + .unwrap(); + assert_eq!( + mapper.map_operator(&scalar).unwrap().to_matrix().unwrap()[[0, 0]], + Complex64::new(2.0, 3.0) + ); + assert!(FermionicOp::total_number(0).is_zero()); + let wide = mapper + .map_operator(&FermionicOp::creation(129, 128).unwrap()) + .unwrap(); + assert_eq!(wide.num_qubits, 129); + assert_eq!(wide.terms.len(), 2); + assert!( + wide.terms + .iter() + .all(|(p, _)| (0..128).all(|j| p.get_pauli(j) == crate::qis::Pauli::Z)) + ); + let nilpotent = FermionicOp::from_term(4, [Create(0), Create(0)], 1.0.into()).unwrap(); + let mapped = mapper.map_operator(&nilpotent).unwrap(); + assert!(mapped.terms.is_empty()); + assert_eq!(mapped.num_qubits, 4); +} + +fn check_word(n: usize, word: Vec) { + let op = FermionicOp::from_term(n, word, 1.0.into()).unwrap(); + let normal = op.normal_order().unwrap(); + assert_eq!(normal.normal_order().unwrap(), normal); + let expected = fock_matrix(&op); + assert_matrix_close(&fock_matrix(&normal), &expected); + let mapper = JordanWignerMapper::new(); + assert_matrix_close( + &mapper.map_operator(&op).unwrap().to_matrix().unwrap(), + &expected, + ); + assert_matrix_close( + &mapper.map_operator(&normal).unwrap().to_matrix().unwrap(), + &expected, + ); +} + +#[test] +fn exhaustive_short_products_match_independent_fock_matrices() { + for n in 1usize..=3 { + for length in 0u32..=4 { + for mut encoded in 0..(2 * n).pow(length) { + let word = (0..length) + .map(|_| { + let symbol = encoded % (2 * n); + encoded /= 2 * n; + if symbol < n { + Create(symbol) + } else { + Annihilate(symbol - n) + } + }) + .collect(); + check_word(n, word); + } + } + } +} + +#[test] +fn fixed_seed_longer_products_match_independent_fock_matrices() { + let mut seed = 20261009u64; + for sample in 0..256 { + let word = (0..sample % 9) + .map(|_| { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let symbol = (seed >> 32) as usize % 8; + if symbol < 4 { + Create(symbol) + } else { + Annihilate(symbol - 4) + } + }) + .collect(); + check_word(4, word); + } +} + +#[test] +fn mapping_preserves_linear_combinations_products_and_adjoint() { + let mapper = JordanWignerMapper::new(); + let a = FermionicOp::from_terms( + 3, + [ + (vec![Annihilate(1), Create(2)], Complex64::new(1.0, 2.0)), + (vec![], Complex64::new(-2.0, 1.0)), + ], + ) + .unwrap(); + let b = FermionicOp::creation(3, 0).unwrap(); + let ma = mapper.map_operator(&a).unwrap().to_matrix().unwrap(); + let mb = mapper.map_operator(&b).unwrap().to_matrix().unwrap(); + assert_matrix_close(&ma, &fock_matrix(&a)); + assert_matrix_close( + &mapper + .map_operator(&(&a + &b).unwrap()) + .unwrap() + .to_matrix() + .unwrap(), + &(&ma + &mb), + ); + assert_matrix_close( + &mapper + .map_operator(&(&a * &b).unwrap()) + .unwrap() + .to_matrix() + .unwrap(), + &ma.dot(&mb), + ); + assert_matrix_close( + &mapper + .map_operator(&a.adjoint()) + .unwrap() + .to_matrix() + .unwrap(), + &ma.t().mapv(|c| c.conj()), + ); +} + +#[test] +fn occupation_encoding_prepares_basis_states_and_number_observables() { + let mapper = JordanWignerMapper::new(); + let operator_mapper: &dyn FermionToQubitMapper = &mapper; + let state_mapper: &dyn OccupationStateMapper = &mapper; + for n in 1..=4 { + let number = operator_mapper + .map_operator(&FermionicOp::total_number(n)) + .unwrap(); + for state in 0usize..1 << n { + let occupation: Vec<_> = (0..n).map(|p| ((state >> p) & 1) as u8).collect(); + let bits = state_mapper.map_occupation(&occupation).unwrap(); + let circuit = BasisEncoding::new(bits) + .build_circuit("occupation") + .unwrap(); + let sv = Statevector::from_circuit(&circuit).unwrap(); + assert_eq!(sv.probabilities()[state], 1.0); + assert_eq!( + number.expectation_statevector(&sv).unwrap(), + state.count_ones() as f64 + ); + assert_eq!(number.variance_statevector(&sv).unwrap(), 0.0); + let dm = DensityMatrix::from_circuit(&circuit).unwrap(); + assert_eq!( + number.expectation_density_matrix(&dm).unwrap(), + state.count_ones() as f64 + ); + for (p, occupation_value) in occupation.iter().enumerate() { + let np = operator_mapper + .map_operator(&FermionicOp::number(n, p).unwrap()) + .unwrap(); + assert_eq!( + np.expectation_statevector(&sv).unwrap(), + *occupation_value as f64 + ); + } + } + } + assert_eq!(mapper.map_occupation(&[]).unwrap(), Vec::::new()); + for invalid in [2, 255] { + assert!( + matches!(mapper.map_occupation(&[1, invalid]), Err(QisError::InvalidOccupation { index: 1, value }) if value == invalid) + ); + } +} + +#[test] +fn single_and_double_excitation_evolution_matches_fock_exponentials() { + let mapper = JordanWignerMapper::new(); + for (n, word) in [ + (3, vec![Create(2), Annihilate(0)]), + (4, vec![Create(2), Create(3), Annihilate(1), Annihilate(0)]), + ] { + let t = FermionicOp::from_term(n, word, 1.0.into()).unwrap(); + let k = (&t - t.adjoint()).unwrap(); + let h = k.scaled(Complex64::new(0.0, 1.0)).unwrap(); + let mapped = mapper.map_operator(&h).unwrap(); + assert!(mapped.all_terms_commute()); + let time = 0.37; + let expected = matrix_exp(&fock_matrix(&k), time); + assert_matrix_close(&bound_evolution(mapped.clone(), time), &expected); + let numeric = mapped + .to_evolution_circuit(time, 1, TrotterMode::FirstOrder) + .unwrap(); + assert_matrix_close(&circuit_to_matrix(&numeric, None).unwrap(), &expected); + let particle_number = fock_matrix(&FermionicOp::total_number(n)); + let generator = fock_matrix(&k); + assert_matrix_close( + &particle_number.dot(&generator), + &generator.dot(&particle_number), + ); + } +} + +#[test] +fn reversing_double_excitation_annihilators_changes_the_sign() { + let a = FermionicOp::from_term( + 4, + [Create(2), Create(3), Annihilate(1), Annihilate(0)], + 1.0.into(), + ) + .unwrap(); + let b = FermionicOp::from_term( + 4, + [Create(2), Create(3), Annihilate(0), Annihilate(1)], + 1.0.into(), + ) + .unwrap(); + assert!((&a + &b).unwrap().normal_order().unwrap().is_zero()); + assert_eq!(fock_matrix(&a)[[12, 3]], Complex64::new(1.0, 0.0)); + assert_matrix_close(&fock_matrix(&b), &fock_matrix(&a).mapv(|c| -c)); +} + +#[test] +fn small_coefficients_survive_mapping_and_both_evolution_apis() { + let mapper = JordanWignerMapper::new(); + let t = FermionicOp::creation(1, 0).unwrap(); + let k = (&t - t.adjoint()).unwrap(); + let tiny = k.scaled(Complex64::new(0.0, 1e-12)).unwrap(); + let mapped = mapper.map_operator(&tiny).unwrap(); + assert_eq!(mapped.terms.len(), 1); + assert_eq!(mapped.terms[0].1, Complex64::new(1e-12, 0.0)); + assert_eq!( + PauliEvolutionAnsatz::new(mapped.clone()) + .unwrap() + .evolution_info() + .num_terms, + 1 + ); + // Large time makes loss of a small coefficient observable in the matrix. + let numeric = mapped + .to_evolution_circuit(1e12, 1, TrotterMode::FirstOrder) + .unwrap(); + let expected = matrix_exp(&fock_matrix(&k), 1.0); + assert_matrix_close(&circuit_to_matrix(&numeric, None).unwrap(), &expected); + assert_matrix_close(&bound_evolution(mapped, 1e12), &expected); +} + +#[test] +fn identity_evolution_preserves_global_phase_and_nonhermitian_mapping_is_allowed() { + let mapper = JordanWignerMapper::new(); + let h = mapper + .map_operator(&FermionicOp::identity(2).scaled(0.3.into()).unwrap()) + .unwrap(); + let expected = Array2::::eye(4).mapv(|c| c * Complex64::from_polar(1.0, -0.15)); + assert_matrix_close(&bound_evolution(h, 0.5), &expected); + let creation = mapper + .map_operator(&FermionicOp::creation(2, 0).unwrap()) + .unwrap(); + assert!(matches!( + PauliEvolutionAnsatz::new(creation), + Err(QisError::NotHermitian) + )); + assert!(PauliEvolutionAnsatz::new(mapper.map_operator(&FermionicOp::new(2)).unwrap()).is_err()); +} + +#[test] +fn mapping_reports_coefficient_overflow_after_pauli_merging() { + let op = FermionicOp::from_terms( + 1, + [ + (vec![], f64::MAX.into()), + (vec![Create(0), Annihilate(0)], f64::MAX.into()), + (vec![Annihilate(0), Create(0)], f64::MAX.into()), + ], + ) + .unwrap(); + assert!(matches!( + JordanWignerMapper::new().map_operator(&op), + Err(QisError::NonFiniteCoefficient { .. }) + )); +} + +#[test] +fn mapping_applies_subnormal_weights_after_merging_the_unit_product() { + let tiny = f64::from_bits(2); + let half = f64::from_bits(1); + let mapper = JordanWignerMapper::new(); + for coefficient in [ + Complex64::new(tiny, 0.0), + Complex64::new(0.0, -tiny), + Complex64::new(tiny, -tiny), + ] { + for (word, z_sign) in [ + (vec![Create(0), Annihilate(0)], -1.0), + (vec![Annihilate(0), Create(0)], 1.0), + ( + vec![Create(0), Annihilate(0), Create(0), Annihilate(0)], + -1.0, + ), + ] { + let input = FermionicOp::from_term(1, word, coefficient).unwrap(); + let mapped = mapper.map_operator(&input).unwrap(); + let expected_half = Complex64::new( + if coefficient.re == 0.0 { 0.0 } else { half }, + if coefficient.im == 0.0 { 0.0 } else { -half }, + ); + // Compare coefficients exactly: a matrix tolerance would hide subnormals. + assert_eq!( + pauli_terms(&mapped), + BTreeMap::from([ + ("+I".into(), expected_half), + ("+Z".into(), expected_half * z_sign), + ]) + ); + } + let identity = FermionicOp::identity(1).scaled(coefficient).unwrap(); + assert_eq!( + pauli_terms(&mapper.map_operator(&identity).unwrap()), + BTreeMap::from([("+I".into(), coefficient)]) + ); + } +} + +#[test] +fn normal_order_and_arithmetic_report_coefficient_overflow() { + let op = FermionicOp::from_terms( + 1, + [ + (vec![Create(0)], f64::MAX.into()), + (vec![Create(0), Annihilate(0), Create(0)], f64::MAX.into()), + ], + ) + .unwrap(); + assert!(matches!( + op.normal_order(), + Err(QisError::NonFiniteCoefficient { .. }) + )); + let a = FermionicOp::from_term(1, [Create(0)], f64::MAX.into()).unwrap(); + assert!((&a + &a).is_err()); + let twice_identity = FermionicOp::identity(1).scaled(2.0.into()).unwrap(); + assert!((&a * twice_identity).is_err()); + let tiny = FermionicOp::creation(1, 0) + .unwrap() + .scaled(1e-310.into()) + .unwrap(); + let mut retained = tiny.clone(); + retained.chop(0.0).unwrap(); + assert_eq!(retained, tiny); + assert_eq!( + JordanWignerMapper::new() + .map_operator(&tiny) + .unwrap() + .terms + .len(), + 2 + ); +} + +#[test] +fn noncommuting_excitation_sum_uses_the_same_numeric_and_symbolic_product_formula() { + let first = FermionicOp::from_term(3, [Create(1), Annihilate(0)], 1.0.into()).unwrap(); + let second = FermionicOp::from_term(3, [Create(2), Annihilate(1)], 1.0.into()).unwrap(); + let t = (&first + &second).unwrap(); + let k = (&t - t.adjoint()).unwrap(); + let h = JordanWignerMapper::new() + .map_operator(&k.scaled(Complex64::new(0.0, 1.0)).unwrap()) + .unwrap(); + assert!(!h.all_terms_commute()); + assert!( + PauliEvolutionAnsatz::new(h.clone()) + .unwrap() + .with_strategy(EvolutionStrategy::Exact) + .build_circuit("exact") + .is_err() + ); + let time = 0.2; + let numeric = h + .to_evolution_circuit(time, 8, TrotterMode::SecondOrder) + .unwrap(); + let numeric = circuit_to_matrix(&numeric, None).unwrap(); + let circuit = PauliEvolutionAnsatz::new(h) + .unwrap() + .with_strategy(EvolutionStrategy::Trotter { + mode: TrotterMode::SecondOrder, + steps: 8, + }) + .build_circuit("sum") + .unwrap(); + let circuit = circuit + .assign_parameters(&Some(HashMap::from([("sum_t", time)]))) + .unwrap(); + assert_matrix_close(&numeric, &circuit_to_matrix(&circuit, None).unwrap()); + let expected = matrix_exp(&fock_matrix(&k), time); + assert!((&numeric - &expected).iter().all(|c| c.norm() < 1e-3)); +} + +#[test] +fn all_hamiltonian_consumers_validate_original_and_merged_inputs() { + let sv = Statevector::new(1); + let dm = DensityMatrix::new(1); + let x: PauliString = "X".parse().unwrap(); + let mut malformed = x.clone(); + malformed.z.clear(); + let invalid = [ + Hamiltonian { + num_qubits: 1, + terms: vec![(PauliString::new(2), 1.0.into())], + }, + Hamiltonian { + num_qubits: 1, + terms: vec![(malformed, 1.0.into())], + }, + Hamiltonian { + num_qubits: 1, + terms: vec![(x.clone(), f64::NAN.into())], + }, + Hamiltonian { + num_qubits: 1, + terms: vec![ + (x.clone(), f64::INFINITY.into()), + (x.clone(), f64::NEG_INFINITY.into()), + ], + }, + Hamiltonian { + num_qubits: 1, + terms: vec![(x.clone(), f64::MAX.into()), (x.clone(), f64::MAX.into())], + }, + ]; + for h in invalid { + assert!(h.expectation_statevector(&sv).is_err()); + assert!(h.expectation_density_matrix(&dm).is_err()); + assert!(h.expectation_probs(&[]).is_err()); + assert!(h.variance_statevector(&sv).is_err()); + assert!( + h.to_evolution_circuit(1.0, 1, TrotterMode::FirstOrder) + .is_err() + ); + assert!( + h.to_trotter_circuit(1.0, 1, TrotterMode::FirstOrder) + .is_err() + ); + assert!(PauliEvolutionAnsatz::new(h).is_err()); + } + let zero = Hamiltonian::from_list(vec![(x.clone(), 1.0.into()), (x, (-1.0).into())]).unwrap(); + assert_eq!(zero.expectation_statevector(&sv).unwrap(), 0.0); + assert_eq!(zero.expectation_probs(&[]).unwrap(), 0.0); + for mode in [ + TrotterMode::FirstOrder, + TrotterMode::SecondOrder, + TrotterMode::Randomized(7), + ] { + for circuit in [ + zero.to_evolution_circuit(1.0, 3, mode).unwrap(), + zero.to_trotter_circuit(1.0, 3, mode).unwrap(), + ] { + assert_eq!(circuit.num_qubits(), 1); + assert_eq!(circuit_to_matrix(&circuit, None).unwrap(), Array2::eye(2)); + } + } + assert!(PauliEvolutionAnsatz::new(zero).is_err()); +} + +#[test] +fn hamiltonian_consumers_preserve_coefficient_error_categories() { + let sv = Statevector::new(1); + let dm = DensityMatrix::new(1); + let x: PauliString = "X".parse().unwrap(); + for coefficients in [ + vec![f64::NAN], + vec![f64::INFINITY], + vec![f64::MAX, f64::MAX], + ] { + let h = Hamiltonian::from_list( + coefficients + .into_iter() + .map(|c| (x.clone(), c.into())) + .collect(), + ) + .unwrap(); + for result in [ + h.expectation_statevector(&sv), + h.expectation_density_matrix(&dm), + h.expectation_probs(&[]), + h.variance_statevector(&sv), + ] { + assert!(matches!(result, Err(QisError::InvalidParameterValue(_)))); + } + for result in [ + h.to_evolution_circuit(1.0, 1, TrotterMode::FirstOrder), + h.to_trotter_circuit(1.0, 1, TrotterMode::FirstOrder), + ] { + assert!(matches!(result, Err(QisError::UnsupportedOperation(_)))); + } + } +} diff --git a/crates/cqlib-core/src/qis/hamiltonian.rs b/crates/cqlib-core/src/qis/hamiltonian.rs index 015ecfbd..668b960e 100644 --- a/crates/cqlib-core/src/qis/hamiltonian.rs +++ b/crates/cqlib-core/src/qis/hamiltonian.rs @@ -17,6 +17,7 @@ //! with complex coefficients. It is primarily used for defining system energies, //! observables for expectation value calculations, and operators for time evolution. +use crate::qis::error::validate_coefficient; use crate::qis::pauli::PauliString; use crate::qis::{Phase, QisError}; use ndarray::Array2; @@ -51,6 +52,48 @@ impl fmt::Display for Hamiltonian { } impl Hamiltonian { + /// Returns a validated Pauli sum with phases absorbed and exact zeros removed. + /// Retains the declared qubit count, including for zero and scalar operators. + pub fn normalized(&self) -> Result { + for (pauli, coefficient) in &self.terms { + if pauli.num_qubits != self.num_qubits { + return Err(QisError::QubitMismatch { + expected: self.num_qubits, + actual: pauli.num_qubits, + }); + } + for actual in [pauli.x.len(), pauli.z.len()] { + if actual != self.num_qubits { + return Err(QisError::DimensionMismatch { + expected: self.num_qubits, + actual, + }); + } + } + validate_coefficient(*coefficient)?; + } + let mut normalized = self.clone(); + normalized.simplify_with_tolerance(0.0); + for (_, coefficient) in &normalized.terms { + validate_coefficient(*coefficient)?; + } + Ok(normalized) + } + + /// Shared contract for real-valued observables and Hamiltonian evolution. + /// Hermiticity is checked after merging, without pruning small coefficients. + pub fn normalized_hermitian(&self) -> Result { + let normalized = self.normalized()?; + if normalized + .terms + .iter() + .any(|(_, coefficient)| coefficient.im.abs() > 1e-10) + { + return Err(QisError::NotHermitian); + } + Ok(normalized) + } + /// Creates a new empty Hamiltonian. /// /// The resulting Hamiltonian represents the zero operator for the given diff --git a/crates/cqlib-core/src/qis/mod.rs b/crates/cqlib-core/src/qis/mod.rs index cf196fb3..45906459 100644 --- a/crates/cqlib-core/src/qis/mod.rs +++ b/crates/cqlib-core/src/qis/mod.rs @@ -24,6 +24,7 @@ //! - [`state`]: Quantum state representations (statevector, density matrix, stabilizer) //! - [`pauli`]: Pauli operators and Pauli strings with symplectic encoding //! - [`hamiltonian`]: Hamiltonian construction from Pauli strings +//! - [`fermion`]: Fermionic operator algebra and fermion-to-qubit mappings //! - [`observable`]: Trait for computing expectation values //! - [`entropy`]: Entropy measures and entanglement metrics //! - [`error`]: Error types for QIS operations @@ -79,6 +80,7 @@ pub mod entropy; pub mod error; pub mod evolution; +pub mod fermion; pub mod hamiltonian; pub mod metrics; pub mod observable; @@ -88,6 +90,9 @@ pub mod state; pub use error::{PauliStringParseError, QisError}; pub use evolution::{PauliEvolution, TrotterMode}; +pub use fermion::{ + FermionToQubitMapper, FermionicLadder, FermionicOp, JordanWignerMapper, OccupationStateMapper, +}; pub use hamiltonian::Hamiltonian; pub use observable::Observable; pub use pauli::{Pauli, PauliIter, PauliString, Phase}; diff --git a/crates/cqlib-core/src/qis/observable.rs b/crates/cqlib-core/src/qis/observable.rs index ac0e4958..ce1733e5 100644 --- a/crates/cqlib-core/src/qis/observable.rs +++ b/crates/cqlib-core/src/qis/observable.rs @@ -77,7 +77,8 @@ const VARIANCE_TOLERANCE: f64 = 1e-10; /// Methods may return [`QisError::QubitMismatch`] if the observable /// and the quantum state have incompatible qubit counts. /// Real expectation methods return [`QisError::NotHermitian`] for non-Hermitian -/// observables; Hamiltonian coefficients must also be finite. +/// observables. Nonfinite Hamiltonian coefficients, including merge overflow, +/// return [`QisError::InvalidParameterValue`]. pub trait Observable { /// Computes the expectation value for a statevector: $\langle\psi|O|\psi\rangle$. /// @@ -185,28 +186,14 @@ fn finalize_variance(variance: f64) -> Result { } } -// Validate the operator itself, including cancellations between repeated terms. -// Do not prune small coefficients while checking a real-valued API's contract. -fn ensure_hermitian_hamiltonian(h: &Hamiltonian) -> Result<(), QisError> { - let finite = |c: &Complex64| c.re.is_finite() && c.im.is_finite(); - if h.terms.iter().any(|(_, c)| !finite(c)) { - return Err(QisError::InvalidParameterValue( - "observable coefficients must be finite".into(), - )); - } - let mut merged = h.clone(); - merged.simplify_with_tolerance(0.0); - for (_, coeff) in &merged.terms { - if !finite(coeff) { - return Err(QisError::InvalidParameterValue( - "merged observable coefficients must be finite".into(), - )); - } - if coeff.im.abs() > VARIANCE_TOLERANCE { - return Err(QisError::NotHermitian); +// Keep the existing Observable error contract while sharing algebraic validation. +fn normalized_observable(h: &Hamiltonian) -> Result { + h.normalized_hermitian().map_err(|error| match error { + nonfinite @ QisError::NonFiniteCoefficient { .. } => { + QisError::InvalidParameterValue(nonfinite.to_string()) } - } - Ok(()) + other => other, + }) } impl Observable for Hamiltonian { @@ -218,11 +205,11 @@ impl Observable for Hamiltonian { }); } - ensure_hermitian_hamiltonian(self)?; + let normalized = normalized_observable(self)?; let mut expected_value = 0.0; // Iterate over each term in the Hamiltonian - for (pauli_str, coeff) in &self.terms { + for (pauli_str, coeff) in &normalized.terms { let x_mask = pauli_str.x_mask(); let z_mask = pauli_str.z_mask(); @@ -267,12 +254,12 @@ impl Observable for Hamiltonian { }); } - ensure_hermitian_hamiltonian(self)?; + let normalized = normalized_observable(self)?; let mut expected_value = 0.0; let n = self.num_qubits; let dim = 1 << n; // 2^n - for (pauli_str, coeff) in &self.terms { + for (pauli_str, coeff) in &normalized.terms { let x_mask = pauli_str.x_mask(); let z_mask = pauli_str.z_mask(); @@ -314,10 +301,10 @@ impl Observable for Hamiltonian { &self, measurements: &[(PauliString, HashMap)], ) -> Result { - ensure_hermitian_hamiltonian(self)?; + let normalized = normalized_observable(self)?; let mut expected_value = 0.0; - for (term_pauli, coeff) in &self.terms { + for (term_pauli, coeff) in &normalized.terms { let term_x_mask = term_pauli.x_mask(); let term_z_mask = term_pauli.z_mask(); let p_active = term_x_mask | term_z_mask; @@ -397,9 +384,7 @@ impl Observable for Hamiltonian { }); } - ensure_hermitian_hamiltonian(self)?; - let mut simplified = self.clone(); - simplified.simplify_with_tolerance(0.0); + let mut simplified = normalized_observable(self)?; for (_, coeff) in &mut simplified.terms { if coeff.im.abs() > VARIANCE_TOLERANCE { From a8b867a2933e7a6d910b4f1472e8135efd33130f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Mon, 12 Oct 2026 18:13:08 +0800 Subject: [PATCH 2/6] fix(core): preserve parameter coefficients and numeric phases Preserve nonzero affine coefficients, near-unit scales and cancellation residues in Parameter arithmetic and simplification. Divide coefficients directly to retain representable quotients with subnormal divisors. Evaluate constant expressions before approximate symbolic simplification and retain small numeric phase and rotation residues in canonicalization. Add regression tests for scaling, substitution, cancellation, division and canonicalization idempotence. --- .../cqlib-core/src/circuit/circuit_param.rs | 6 +- .../src/circuit/circuit_param_test.rs | 32 ++ crates/cqlib-core/src/circuit/parameter.rs | 306 ++++++++++++++++-- .../cqlib-core/src/circuit/parameter_test.rs | 107 ++++++ .../canonicalize/canonicalize_test.rs | 40 ++- .../transform/canonicalize/canonicalizer.rs | 9 - .../compile/transform/canonicalize/verify.rs | 17 - 7 files changed, 454 insertions(+), 63 deletions(-) diff --git a/crates/cqlib-core/src/circuit/circuit_param.rs b/crates/cqlib-core/src/circuit/circuit_param.rs index 15721c06..01a8e308 100644 --- a/crates/cqlib-core/src/circuit/circuit_param.rs +++ b/crates/cqlib-core/src/circuit/circuit_param.rs @@ -102,8 +102,10 @@ impl ParameterValue { /// /// Fixed values use floating-point multiplication. Symbolic values use /// expression multiplication and remain `Param`, even if the resulting - /// expression is constant (including multiplication by zero). This method - /// does not explicitly evaluate, simplify, or canonicalize expressions. + /// expression is constant (including multiplication by zero). In affine + /// expressions with unresolved symbols, coefficients are preserved without + /// tolerance pruning. + /// This method does not explicitly evaluate or canonicalize expressions. /// Unlike converting a `Parameter` with `Into`, it does not fold constants /// into `Fixed` values. /// diff --git a/crates/cqlib-core/src/circuit/circuit_param_test.rs b/crates/cqlib-core/src/circuit/circuit_param_test.rs index 45690540..8a99fe28 100644 --- a/crates/cqlib-core/src/circuit/circuit_param_test.rs +++ b/crates/cqlib-core/src/circuit/circuit_param_test.rs @@ -59,6 +59,38 @@ fn scaled_constant_expressions_remain_param() { } } +#[test] +fn small_symbolic_scales_survive_canonicalization_and_substitution() { + let input = ParameterValue::Param(Parameter::symbol("theta")); + for factor in [1e-16, -1e-16, 1e-12 / 512.0, f64::from_bits(1)] { + let ParameterValue::Param(scaled) = input.scaled(factor) else { + panic!("symbolic input must remain Param"); + }; + let canonical = scaled.canonicalized().unwrap(); + assert!(canonical.get_symbols().contains("theta")); + assert_eq!( + canonical + .evaluate(&Some(HashMap::from([("theta", 1.0)]))) + .unwrap(), + factor + ); + + let renamed = canonical.replace("theta", Parameter::symbol("time")); + let rescaled = ParameterValue::Param(renamed).scaled(-0.5); + let ParameterValue::Param(rescaled) = rescaled else { + unreachable!() + }; + let canonical = rescaled.canonicalized().unwrap(); + let expected = factor * -0.5; + assert_eq!( + canonical + .evaluate(&Some(HashMap::from([("time", 1e16)]))) + .unwrap(), + expected * 1e16 + ); + } +} + #[test] fn scaled_rejects_non_finite_factors_for_both_variants() { for input in [ diff --git a/crates/cqlib-core/src/circuit/parameter.rs b/crates/cqlib-core/src/circuit/parameter.rs index 10c0ef31..cc497cc9 100644 --- a/crates/cqlib-core/src/circuit/parameter.rs +++ b/crates/cqlib-core/src/circuit/parameter.rs @@ -40,7 +40,237 @@ use std::collections::{HashMap, HashSet}; use std::fmt; use std::ops::{Add, Div, Mul, Neg, Sub}; use std::str::FromStr; -use symb_anafis::{Expr, Simplify, parse, symb}; +use symb_anafis::{Expr, Simplify, parse, symb, visitor::ExprView}; + +// 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. +fn needs_affine_protection(expr: &Expr) -> bool { + match expr.view() { + ExprView::Number(value) => value.fract() != 0.0 || value.abs() > (1u64 << 53) as f64, + ExprView::Sum(children) | ExprView::Product(children) => { + children.iter().any(|child| needs_affine_protection(child)) + } + ExprView::Div(numerator, denominator) => { + needs_affine_protection(numerator) + || denominator.as_number().is_some_and(|value| { + value != 0.0 && needs_affine_protection(&Expr::number(1.0 / value)) + }) + } + _ => false, + } +} + +type AffineCoefficients = HashMap, f64>; + +// Coefficients are collected once per input, using hashed symbol keys. Building +// a replacement AST is deferred until the engine actually changes a coefficient. +fn affine_coefficients(inputs: &[(&Expr, f64)]) -> Option { + fn collect(expr: &Expr, scale: f64, terms: &mut AffineCoefficients) -> Option<()> { + match expr.view() { + ExprView::Number(value) => *terms.entry(None).or_default() += scale * value, + ExprView::Symbol(_) => *terms.entry(Some(expr.clone())).or_default() += scale, + ExprView::Sum(children) => { + for child in children.iter() { + collect(child, scale, terms)?; + } + } + ExprView::Product(children) => { + let mut scale = scale; + let mut symbolic = None; + for child in children.iter() { + if let Some(value) = child.as_number() { + scale *= value; + } else if symbolic.replace(child).is_some() { + return None; + } + } + if let Some(child) = symbolic { + collect(child, scale, terms)?; + } else { + *terms.entry(None).or_default() += scale; + } + } + ExprView::Div(numerator, denominator) => { + let divisor = denominator.as_number()?; + if divisor == 0.0 { + return None; + } + collect(numerator, scale / divisor, terms)?; + } + _ => return None, + } + Some(()) + } + + let mut terms = HashMap::new(); + for (expr, scale) in inputs { + collect(expr, *scale, &mut terms)?; + } + if terms.values().any(|coefficient| !coefficient.is_finite()) { + return None; + } + terms.retain(|_, coefficient| *coefficient != 0.0); + Some(terms) +} + +fn weighted_symbol(symbol: Expr, coefficient: f64) -> Expr { + if coefficient == 0.0 { + return Expr::number(0.0); + } + if coefficient == 1.0 { + return symbol; + } + let product = Expr::product(vec![Expr::number(coefficient), symbol.clone()]); + if let ExprView::Product(factors) = product.view() + && factors + .iter() + .any(|factor| factor.as_number() == Some(coefficient)) + { + return product; + } + // Public map() reconstructs products without approximate numeric pruning. + // If construction rounded a near-unit coefficient, insert its exact value + // into a non-unit product template after construction. + Expr::product(vec![Expr::number(2.0), symbol]).map(|factor| { + if factor.as_number() == Some(2.0) { + Expr::number(coefficient) + } else { + factor.clone() + } + }) +} + +fn affine_expr(terms: &AffineCoefficients) -> Expr { + // Construct a sum of distinct unit-weight symbols before inserting weights, + // preventing automatic polynomial conversion from deleting small terms. + let unit_sum = Expr::sum( + terms + .keys() + .map(|symbol| symbol.clone().unwrap_or_else(|| Expr::number(1.0))) + .collect(), + ); + unit_sum.map(|node| { + if node.as_number() == Some(1.0) { + Expr::number(terms.get(&None).copied().unwrap_or(1.0)) + } else if matches!(node.view(), ExprView::Symbol(_)) { + weighted_symbol( + node.clone(), + terms.get(&Some(node.clone())).copied().unwrap_or(1.0), + ) + } else { + node.clone() + } + }) +} + +fn preserve_affine(expected: Option, fallback: Expr) -> Expr { + match expected { + Some(expected) if expected.keys().all(Option::is_none) => { + Expr::number(expected.get(&None).copied().unwrap_or(0.0)) + } + Some(expected) if affine_coefficients(&[(&fallback, 1.0)]).as_ref() != Some(&expected) => { + affine_expr(&expected) + } + _ => fallback, + } +} + +fn add_parameter_expr(lhs: Expr, rhs: Expr) -> Expr { + if let (Some(a), Some(b)) = (lhs.as_number(), rhs.as_number()) { + return Expr::number(a + b); + } + if !needs_affine_protection(&lhs) && !needs_affine_protection(&rhs) { + return lhs + rhs; + } + let affine = affine_coefficients(&[(&lhs, 1.0), (&rhs, 1.0)]); + preserve_affine(affine, lhs + rhs) +} + +fn sub_parameter_expr(lhs: Expr, rhs: Expr) -> Expr { + if let (Some(a), Some(b)) = (lhs.as_number(), rhs.as_number()) { + return Expr::number(a - b); + } + if !needs_affine_protection(&lhs) && !needs_affine_protection(&rhs) { + return lhs - rhs; + } + let affine = affine_coefficients(&[(&lhs, 1.0), (&rhs, -1.0)]); + preserve_affine(affine, lhs - rhs) +} + +// The common rotation-angle case needs neither a coefficient map nor a second +// symbolic operation. Handle one weighted symbol directly, including rescaling. +fn scaled_symbol(expr: &Expr, scale: f64) -> Option { + let (symbol, coefficient) = match expr.view() { + ExprView::Symbol(_) => (expr.clone(), scale), + ExprView::Product(children) if children.len() == 2 => { + let (number, symbol) = if let Some(number) = children[0].as_number() { + (number, &children[1]) + } else { + (children[1].as_number()?, &children[0]) + }; + if !matches!(symbol.view(), ExprView::Symbol(_)) { + return None; + } + ((**symbol).clone(), number * scale) + } + _ => return None, + }; + coefficient + .is_finite() + .then(|| weighted_symbol(symbol, coefficient)) +} + +fn mul_parameter_expr(lhs: Expr, rhs: Expr) -> Expr { + if let (Some(a), Some(b)) = (lhs.as_number(), rhs.as_number()) { + return Expr::number(a * b); + } + let affine = if let Some(scale) = lhs.as_number() { + if let Some(expr) = scaled_symbol(&rhs, scale) { + return expr; + } + if !needs_affine_protection(&lhs) && !needs_affine_protection(&rhs) { + return lhs * rhs; + } + affine_coefficients(&[(&rhs, scale)]) + } else if let Some(scale) = rhs.as_number() { + if let Some(expr) = scaled_symbol(&lhs, scale) { + return expr; + } + if !needs_affine_protection(&lhs) && !needs_affine_protection(&rhs) { + return lhs * rhs; + } + affine_coefficients(&[(&lhs, scale)]) + } else { + None + }; + preserve_affine(affine, lhs * rhs) +} + +fn div_parameter_expr(lhs: Expr, rhs: Expr) -> Expr { + if let (Some(a), Some(b)) = (lhs.as_number(), rhs.as_number()) + && b != 0.0 + { + return Expr::number(a / b); + } + let affine = if let Some(divisor) = rhs.as_number().filter(|value| *value != 0.0) { + affine_coefficients(&[(&lhs, 1.0)]).and_then(|mut terms| { + // Divide coefficients directly: the reciprocal of a subnormal + // divisor can overflow even when the quotient is representable. + for coefficient in terms.values_mut() { + *coefficient /= divisor; + } + if terms.values().any(|coefficient| !coefficient.is_finite()) { + return None; + } + terms.retain(|_, coefficient| *coefficient != 0.0); + Some(terms) + }) + } else { + None + }; + preserve_affine(affine, lhs / rhs) +} /// A symbolic or numeric parameter used to represent gate angles and other /// continuous values in a quantum circuit. @@ -261,6 +491,11 @@ impl Parameter { /// /// Simplification is performed in a domain-safe mode to avoid invalid /// transformations (e.g. taking the log of a potentially negative number). + /// For affine sums, scalar products and division by numeric constants, + /// nonzero `f64` coefficients and cancellation results are preserved without + /// tolerance pruning. Numeric literals are retained exactly. Floating-point + /// rounding, overflow and underflow still apply. Parsing and general + /// nonlinear simplification use the symbolic engine's own numeric policy. /// /// # Examples /// @@ -273,8 +508,16 @@ impl Parameter { /// assert_eq!(simplified.to_string(), "θ"); /// ``` pub fn simplify(&self) -> Result { + if self.expr.as_number().is_some() { + return Ok(self.clone()); + } + let affine = needs_affine_protection(&self.expr) + .then(|| affine_coefficients(&[(&self.expr, 1.0)])) + .flatten(); let expr = Simplify::new().domain_safe(true).simplify(&self.expr)?; - Ok(Self { expr }) + Ok(Self { + expr: preserve_affine(affine, expr), + }) } /// Returns the numeric value of this parameter without symbolic @@ -292,10 +535,11 @@ impl Parameter { /// Returns the canonical storage form of this parameter expression. /// - /// The parameter is first simplified. If the simplified expression has no - /// free symbols, it is evaluated to a concrete finite value and rebuilt as - /// a numeric parameter. This also normalizes `-0.0` to `0.0`, giving circuit - /// operation storage a stable representation for equivalent constants. + /// A constant expression is evaluated before symbolic simplification. If + /// simplification eliminates all symbols, the result is also evaluated and + /// rebuilt as a finite numeric parameter. This also normalizes `-0.0` to + /// `0.0`, giving circuit operation storage a stable representation for + /// equivalent constants. /// /// Symbolic expressions are returned in their simplified form. This method /// does not intern symbolic parameters into any circuit; use @@ -308,6 +552,11 @@ impl Parameter { /// Returns [`ParameterError`] if simplification fails, a constant expression /// cannot be evaluated, or the evaluated constant is not finite. pub fn canonicalized(&self) -> Result { + // Evaluate constant expressions before any approximate symbolic rewrite. + if self.get_symbols().is_empty() { + let value = self.evaluate(&None)?; + return Ok(Parameter::from(if value == 0.0 { 0.0 } else { value })); + } let simplified = self.simplify()?; if simplified.get_symbols().is_empty() { @@ -812,7 +1061,8 @@ impl From for Parameter { /// type `$t` in both directions (e.g. `Parameter + f64` and `f64 + Parameter`). /// /// The implementation converts `$t` to `Parameter` via `Parameter::from`, extracts the -/// underlying `Expr`, and delegates to the corresponding `Expr` operator. +/// underlying `Expr`, and preserves affine coefficients before falling back to +/// the corresponding symbolic engine operator. macro_rules! impl_ops_for_type { ($t:ty) => { // Parameter + T @@ -822,8 +1072,8 @@ macro_rules! impl_ops_for_type { Self { // 1. `Parameter::from(rhs)` converts the primitive to `Parameter`. // 2. `.expr` unwraps the underlying `Expr` node. - // 3. `self.expr + ...` invokes `symb_anafis`'s `Add` impl. - expr: self.expr + Parameter::from(rhs).expr, + // 3. Preserve affine coefficients before general symbolic addition. + expr: add_parameter_expr(self.expr, Parameter::from(rhs).expr), } } } @@ -833,7 +1083,7 @@ macro_rules! impl_ops_for_type { type Output = Parameter; fn add(self, rhs: Parameter) -> Self::Output { Parameter { - expr: Parameter::from(self).expr + rhs.expr, + expr: add_parameter_expr(Parameter::from(self).expr, rhs.expr), } } } @@ -843,7 +1093,7 @@ macro_rules! impl_ops_for_type { type Output = Parameter; fn sub(self, rhs: $t) -> Self::Output { Self { - expr: self.expr - Parameter::from(rhs).expr, + expr: sub_parameter_expr(self.expr, Parameter::from(rhs).expr), } } } @@ -853,7 +1103,7 @@ macro_rules! impl_ops_for_type { type Output = Parameter; fn sub(self, rhs: Parameter) -> Self::Output { Parameter { - expr: Parameter::from(self).expr - rhs.expr, + expr: sub_parameter_expr(Parameter::from(self).expr, rhs.expr), } } } @@ -863,7 +1113,7 @@ macro_rules! impl_ops_for_type { type Output = Parameter; fn mul(self, rhs: $t) -> Self::Output { Self { - expr: self.expr * Parameter::from(rhs).expr, + expr: mul_parameter_expr(self.expr, Parameter::from(rhs).expr), } } } @@ -873,7 +1123,7 @@ macro_rules! impl_ops_for_type { type Output = Parameter; fn mul(self, rhs: Parameter) -> Self::Output { Parameter { - expr: Parameter::from(self).expr * rhs.expr, + expr: mul_parameter_expr(Parameter::from(self).expr, rhs.expr), } } } @@ -883,7 +1133,7 @@ macro_rules! impl_ops_for_type { type Output = Parameter; fn div(self, rhs: $t) -> Self::Output { Self { - expr: self.expr / Parameter::from(rhs).expr, + expr: div_parameter_expr(self.expr, Parameter::from(rhs).expr), } } } @@ -893,7 +1143,7 @@ macro_rules! impl_ops_for_type { type Output = Parameter; fn div(self, rhs: Parameter) -> Self::Output { Parameter { - expr: Parameter::from(self).expr / rhs.expr, + expr: div_parameter_expr(Parameter::from(self).expr, rhs.expr), } } } @@ -919,15 +1169,15 @@ impl_ops_for_type!(u32); /// Cloning `Expr` is inexpensive because `symb_anafis` uses reference-counted /// node sharing internally. macro_rules! impl_binary_op_ref { - ($($trait:ident, $method:ident),* $(,)?) => { + ($($trait:ident, $method:ident, $expr_method:ident);* $(;)?) => { $( // 1. Parameter op Parameter — both sides consumed. impl $trait for Parameter { type Output = Parameter; fn $method(self, rhs: Parameter) -> Self::Output { Parameter { - // Delegate directly to the underlying `Expr` operator. - expr: self.expr.$method(rhs.expr), + // Preserve affine coefficients before general symbolic arithmetic. + expr: $expr_method(self.expr, rhs.expr), } } } @@ -939,7 +1189,7 @@ macro_rules! impl_binary_op_ref { Parameter { // `Expr::clone` is cheap — the tree uses Arc-based // node sharing inside `symb_anafis`. - expr: self.expr.clone().$method(rhs.expr.clone()), + expr: $expr_method(self.expr.clone(), rhs.expr.clone()), } } } @@ -949,7 +1199,7 @@ macro_rules! impl_binary_op_ref { type Output = Parameter; fn $method(self, rhs: &'a Parameter) -> Self::Output { Parameter { - expr: self.expr.$method(rhs.expr.clone()), + expr: $expr_method(self.expr, rhs.expr.clone()), } } } @@ -959,7 +1209,7 @@ macro_rules! impl_binary_op_ref { type Output = Parameter; fn $method(self, rhs: Parameter) -> Self::Output { Parameter { - expr: self.expr.clone().$method(rhs.expr), + expr: $expr_method(self.expr.clone(), rhs.expr), } } } @@ -968,10 +1218,10 @@ macro_rules! impl_binary_op_ref { } impl_binary_op_ref! { - Add, add, - Sub, sub, - Mul, mul, - Div, div, + Add, add, add_parameter_expr; + Sub, sub, sub_parameter_expr; + Mul, mul, mul_parameter_expr; + Div, div, div_parameter_expr; } // Implement Neg for owned Parameter @@ -979,7 +1229,7 @@ impl Neg for Parameter { type Output = Parameter; fn neg(self) -> Self::Output { Parameter { - expr: self.expr.neg(), + expr: mul_parameter_expr(Expr::number(-1.0), self.expr), } } } @@ -989,7 +1239,7 @@ impl Neg for &Parameter { type Output = Parameter; fn neg(self) -> Self::Output { Parameter { - expr: self.expr.clone().neg(), + expr: mul_parameter_expr(Expr::number(-1.0), self.expr.clone()), } } } diff --git a/crates/cqlib-core/src/circuit/parameter_test.rs b/crates/cqlib-core/src/circuit/parameter_test.rs index 8fe576f2..e26af669 100644 --- a/crates/cqlib-core/src/circuit/parameter_test.rs +++ b/crates/cqlib-core/src/circuit/parameter_test.rs @@ -11,6 +11,8 @@ // that they have been altered from the originals. use super::Parameter; +use crate::circuit::circuit_impl::Circuit; +use crate::circuit::circuit_to_matrix::circuit_to_matrix; use std::collections::{HashMap, HashSet}; use std::f64::consts::{E, PI}; @@ -1136,6 +1138,111 @@ fn test_canonicalized_folds_constant_expression() { assert_eq!(canonical.evaluate(&None).unwrap(), 0.0); } +#[test] +fn affine_cancellation_preserves_small_constants_without_false_symbols() { + let t = Parameter::symbol("t"); + let u = Parameter::symbol("u"); + let q: Parameter = t * 1e-16 + u * 2e-16; + for constant in [3e-16, -3e-16, f64::from_bits(1), 0.0] { + let p = q.clone() + constant; + let result = (p - &q).canonicalized().unwrap(); + assert!(result.get_symbols().is_empty(), "{constant:?}: {result}"); + assert_eq!(result.evaluate(&None).unwrap(), constant); + assert_eq!( + result.simplify().unwrap().evaluate(&None).unwrap(), + constant + ); + assert_eq!(result.canonicalized().unwrap(), result); + assert_eq!( + (result.clone() + 0.0_f64).evaluate(&None).unwrap(), + constant + ); + if constant.abs() == 3e-16 { + let angle: Parameter = result * 1e16; + let mut circuit = Circuit::new(1); + circuit.rz(circuit.qubits()[0], angle).unwrap(); + assert!(circuit.symbols().is_empty()); + let matrix = circuit_to_matrix(&circuit, None).unwrap(); + let expected = num_complex::Complex64::from_polar(1.0, -constant * 1e16 / 2.0); + assert!((matrix[[0, 0]] - expected).norm() < 1e-14); + } + } +} + +#[test] +fn affine_scaling_preserves_near_unit_and_subnormal_weights() { + let t = Parameter::symbol("t"); + let bindings = Some(HashMap::from([("t", 1.0)])); + for coefficient in [ + f64::from_bits(1), + 1e-16, + -1e-16, + 0.375, + 1.0 - f64::EPSILON, + 1.0 + f64::EPSILON, + ] { + let weighted = t.clone() * coefficient; + let canonical = weighted.canonicalized().unwrap(); + assert!(canonical.get_symbols().contains("t")); + assert_eq!(canonical.evaluate(&bindings).unwrap(), coefficient); + let rescaled: Parameter = canonical * 2.0; + assert_eq!( + rescaled + .canonicalized() + .unwrap() + .evaluate(&bindings) + .unwrap(), + coefficient * 2.0 + ); + } +} + +#[test] +fn affine_constant_division_preserves_small_coefficients() { + let t = Parameter::symbol("t"); + let divisor = Parameter::from(1e16); + for (expr, expected) in [ + (t.clone() / 1e16, 1.0), + (&t / &divisor, 1.0), + ((t.clone() * 1e-16) / 2.0, 0.5), + ((t.clone() / 1e16) / 2.0, 0.5), + (t.clone() / -1e16, -1.0), + ] { + let canonical = expr.canonicalized().unwrap(); + assert!(canonical.get_symbols().contains("t")); + assert_eq!( + canonical + .evaluate(&Some(HashMap::from([("t", 1e16)]))) + .unwrap(), + expected + ); + } + let tiny = f64::from_bits(1); + assert_eq!((Parameter::from(tiny) / tiny).evaluate(&None).unwrap(), 1.0); + let quotient: Parameter = (t.clone() * tiny) / tiny; + assert_eq!( + quotient + .canonicalized() + .unwrap() + .evaluate(&Some(HashMap::from([("t", 1.0)]))) + .unwrap(), + 1.0 + ); + assert_eq!( + Parameter::from(tiny) + .canonicalized() + .unwrap() + .evaluate(&None) + .unwrap(), + tiny + ); + let cancelled: Parameter = t.clone() / 1e16 - t / 1e16; + assert_eq!( + cancelled.canonicalized().unwrap().evaluate(&None).unwrap(), + 0.0 + ); +} + #[test] fn test_canonicalized_normalizes_negative_zero() { let canonical = Parameter::from(-0.0).canonicalized().unwrap(); diff --git a/crates/cqlib-core/src/compile/transform/canonicalize/canonicalize_test.rs b/crates/cqlib-core/src/compile/transform/canonicalize/canonicalize_test.rs index 5488b9de..566e5fb1 100644 --- a/crates/cqlib-core/src/compile/transform/canonicalize/canonicalize_test.rs +++ b/crates/cqlib-core/src/compile/transform/canonicalize/canonicalize_test.rs @@ -11,11 +11,13 @@ // that they have been altered from the originals. use super::{CanonicalizeConfig, Canonicalizer, canonicalize_circuit}; +use crate::circuit::circuit_impl::Circuit; +use crate::circuit::circuit_to_matrix::circuit_to_matrix; use crate::circuit::gate::FrozenCircuit; use crate::circuit::{ - Circuit, CircuitError, CircuitGate, CircuitParam, ClassicalControlOp, ClassicalDataOp, - ClassicalExpr, ClassicalType, Directive, Instruction, MCGate, Parameter, ParameterValue, Qubit, - StandardGate, UnitaryGate, ValueInstruction, ValueOperation, circuit_to_matrix, + CircuitError, CircuitGate, CircuitParam, ClassicalControlOp, ClassicalDataOp, ClassicalExpr, + ClassicalType, Directive, Instruction, MCGate, Parameter, ParameterValue, Qubit, StandardGate, + UnitaryGate, ValueInstruction, ValueOperation, }; use crate::compile::test_utils::generated_small_matrix_circuit; use crate::compile::transform::{RewriteEdits, TransformOutcome, TransformerTestExt}; @@ -1043,19 +1045,43 @@ fn canonicalize_phase_fast_path_does_not_accept_negative_zero() { } #[test] -fn canonical_transform_normalizes_global_phase_accumulator_residue() { +fn canonical_transform_preserves_nonzero_global_phase_residue() { let mut circuit = Circuit::new(1); - circuit.set_global_phase(Parameter::from(-3.330_669_073_875_469_6e-16)); + let phase = Parameter::from(-3.330_669_073_875_469_6e-16); + circuit.set_global_phase(phase.clone()); circuit.h(Qubit::new(0)).unwrap(); let first = Canonicalizer::production().run(&circuit).unwrap(); - assert!(first.changed); - assert_eq!(first.circuit.global_phase(), Parameter::from(0.0)); + assert!(!first.changed); + assert_eq!(first.circuit.global_phase(), phase); let second = Canonicalizer::production().run(&first.circuit).unwrap(); assert!(!second.changed); } +#[test] +fn canonical_transform_preserves_small_nonzero_rotation_angles() { + let mut circuit = Circuit::new(1); + circuit.rz(Qubit::new(0), Parameter::from(3e-16)).unwrap(); + let result = Canonicalizer::production().run(&circuit).unwrap(); + assert_eq!(result.circuit.operations().len(), 1); + assert_eq!( + result + .circuit + .resolve_parameter(&result.circuit.operations()[0].params[0]) + .unwrap() + .evaluate(&None) + .unwrap(), + 3e-16 + ); + assert!( + !Canonicalizer::production() + .run(&result.circuit) + .unwrap() + .changed + ); +} + #[test] fn canonicalization_is_idempotent_for_mixed_production_input() { let mut circuit = Circuit::new(3); diff --git a/crates/cqlib-core/src/compile/transform/canonicalize/canonicalizer.rs b/crates/cqlib-core/src/compile/transform/canonicalize/canonicalizer.rs index df075031..5a0ee49c 100644 --- a/crates/cqlib-core/src/compile/transform/canonicalize/canonicalizer.rs +++ b/crates/cqlib-core/src/compile/transform/canonicalize/canonicalizer.rs @@ -267,15 +267,6 @@ impl<'a> CanonicalizeRound<'a> { fn run(mut self) -> Result<(Circuit, Vec>), CompilerError> { self.top_phase = self.canonicalize_parameter(self.top_phase.clone())?; - if !self.source.operations().is_empty() && !self.top_phase.is_canonical_positive_zero() { - // The previous operation loop normalized `current + 0` once for - // every source operation, including operations with no phase - // contribution. Preserve that representation for tiny numeric - // residues with one up-front normalization instead of repeating - // the same symbolic work for every operation. - self.top_phase = - self.canonicalize_parameter(self.top_phase.clone() + Parameter::from(0.0))?; - } let mut top_level = Vec::with_capacity(self.source.operations().len()); let mut provenance = Vec::with_capacity(self.source.operations().len()); diff --git a/crates/cqlib-core/src/compile/transform/canonicalize/verify.rs b/crates/cqlib-core/src/compile/transform/canonicalize/verify.rs index 337b3cf4..a15fdee9 100644 --- a/crates/cqlib-core/src/compile/transform/canonicalize/verify.rs +++ b/crates/cqlib-core/src/compile/transform/canonicalize/verify.rs @@ -72,23 +72,6 @@ pub fn verify_circuit(circuit: &Circuit, mode: VerifyMode<'_>) -> Result<(), Com verify_parameter_finite(&global_phase, "global phase")?; if matches!(mode, VerifyMode::Output { .. }) { verify_parameter_canonical(&global_phase, circuit.global_phase_param(), "global phase")?; - // A canonicalization round accumulates a zero phase contribution for - // every retained root operation. The expression engine can normalize - // a tiny numeric residue while constructing that sum even when the - // residue alone is already a canonical numeric parameter. Reject that - // representation here so the verified fast path is a true fixed point. - if !circuit.operations().is_empty() && !global_phase.is_canonical_positive_zero() { - let accumulated = (global_phase.clone() + Parameter::from(0.0)) - .canonicalized() - .map_err(|error| { - CompilerError::Circuit(crate::circuit::CircuitError::InvalidParameter(error)) - })?; - if accumulated != global_phase { - return Err(CompilerError::InvariantViolation( - "global phase is not in accumulator-normal form".to_string(), - )); - } - } } verify_operations(circuit, circuit.operations(), "root", mode)?; From 164525ec5e890906bb829cb4978a0074ae67e70a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Mon, 12 Oct 2026 18:17:15 +0800 Subject: [PATCH 3/6] feat(core): add validated fermionic excitation generators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Construct ordered, unit-weight i(T - T†) generators with index validation. Verify fermionic signs, particle-number conservation, Jordan-Wigner mapping and independent evolution blocks against Fock-space calculations. Project accepted imaginary roundoff residuals during Hermitian normalization while retaining every nonzero real coefficient. Add regression coverage for zero projection and small-coefficient symbolic evolution. --- .../circuit/ansatz/hamiltonian_evolution.rs | 7 +- .../ansatz/hamiltonian_evolution_test.rs | 116 +++++++- crates/cqlib-core/src/qis/fermion/operator.rs | 87 +++++- crates/cqlib-core/src/qis/fermion/tests.rs | 271 +++++++++++++++++- crates/cqlib-core/src/qis/hamiltonian.rs | 13 +- crates/cqlib-core/src/qis/hamiltonian_test.rs | 36 +++ 6 files changed, 522 insertions(+), 8 deletions(-) diff --git a/crates/cqlib-core/src/circuit/ansatz/hamiltonian_evolution.rs b/crates/cqlib-core/src/circuit/ansatz/hamiltonian_evolution.rs index fb77d5cd..27f2b4a8 100644 --- a/crates/cqlib-core/src/circuit/ansatz/hamiltonian_evolution.rs +++ b/crates/cqlib-core/src/circuit/ansatz/hamiltonian_evolution.rs @@ -254,15 +254,16 @@ impl PauliEvolutionAnsatz { /// /// Validates and normalizes the Hamiltonian (merges duplicate terms, absorbs /// phases into coefficients and removes exact zeros). Small nonzero coefficients - /// are retained, matching the numeric Hamiltonian evolution APIs. + /// are retained, matching the numeric Hamiltonian evolution APIs. Imaginary + /// roundoff residuals of magnitude at most `1e-10` are projected to zero. /// /// The default strategy is [`EvolutionStrategy::Auto`] with `steps = 1`. /// /// # Errors /// /// Returns [`QisError::InvalidParameterValue`] if the Hamiltonian is empty after - /// simplification, or [`QisError::NotHermitian`] if any coefficient has a non-zero - /// imaginary part after simplification. + /// real projection, or [`QisError::NotHermitian`] if any merged coefficient + /// has an imaginary part of magnitude greater than `1e-10`. pub fn new(hamiltonian: Hamiltonian) -> Result { let h = hamiltonian.normalized_hermitian()?; diff --git a/crates/cqlib-core/src/circuit/ansatz/hamiltonian_evolution_test.rs b/crates/cqlib-core/src/circuit/ansatz/hamiltonian_evolution_test.rs index 024615a4..1b09d36b 100644 --- a/crates/cqlib-core/src/circuit/ansatz/hamiltonian_evolution_test.rs +++ b/crates/cqlib-core/src/circuit/ansatz/hamiltonian_evolution_test.rs @@ -26,7 +26,7 @@ use ndarray::Array2; use num_complex::Complex64; use crate::circuit::ansatz::Ansatz; -use crate::circuit::circuit_to_matrix; +use crate::circuit::circuit_to_matrix::circuit_to_matrix; use crate::qis::evolution::TrotterMode; use crate::qis::hamiltonian::Hamiltonian; @@ -86,6 +86,120 @@ fn circuit_matrix_at_t( circuit_to_matrix(&bound, None).unwrap() } +#[test] +fn small_coefficients_survive_symbolic_evolution_and_trotter_scaling() { + for coefficient in [1e-16, 1e-12, -1e-16] { + for pauli in ["Z", "I", "X", "Y"] { + let h = Hamiltonian::from_list(vec![( + pauli.parse().unwrap(), + Complex64::new(coefficient, 0.0), + )]) + .unwrap(); + let time = 1.0 / coefficient; + let expected = matrix_exp_iht(&hamiltonian_matrix(&h), time); + for strategy in [ + EvolutionStrategy::Exact, + EvolutionStrategy::Auto { steps: 512 }, + EvolutionStrategy::Trotter { + mode: TrotterMode::FirstOrder, + steps: 512, + }, + EvolutionStrategy::Trotter { + mode: TrotterMode::SecondOrder, + steps: 512, + }, + EvolutionStrategy::Trotter { + mode: TrotterMode::Randomized(7), + steps: 512, + }, + ] { + let ansatz = PauliEvolutionAnsatz::new(h.clone()) + .unwrap() + .with_strategy(strategy.clone()); + let circuit = ansatz.build_circuit("small").unwrap(); + assert_eq!(circuit.symbols().len(), ansatz.num_parameters()); + assert!(circuit.symbols().contains("small_t")); + // An incomplete binding must preserve the time dependence too. + let partial = circuit.assign_parameters(&Some(HashMap::new())).unwrap(); + assert!(partial.symbols().contains("small_t")); + let bound = partial + .assign_parameters(&Some(HashMap::from([("small_t", time)]))) + .unwrap(); + assert!(frob_dist(&circuit_to_matrix(&bound, None).unwrap(), &expected) < 1e-10); + let actual = circuit_matrix_at_t(&ansatz, "small", "small_t", time); + assert!( + frob_dist(&actual, &expected) < 1e-10, + "{pauli}, coefficient={coefficient}, strategy={strategy:?}" + ); + } + } + } +} + +#[test] +fn imaginary_residuals_are_projected_before_ansatz_zero_check() { + for pauli in ["I", "Z"] { + let h = Hamiltonian::from_list(vec![(pauli.parse().unwrap(), Complex64::new(0.0, 1e-12))]) + .unwrap(); + assert!(matches!( + PauliEvolutionAnsatz::new(h), + Err(crate::qis::QisError::InvalidParameterValue(_)) + )); + } + + // Real parts cancel, leaving only an accepted imaginary roundoff residual. + let h = Hamiltonian::from_list(vec![ + ("Z".parse().unwrap(), Complex64::new(1.0, 1e-12)), + ("Z".parse().unwrap(), Complex64::new(-1.0, 0.0)), + ]) + .unwrap(); + assert!(PauliEvolutionAnsatz::new(h).is_err()); + + // Projection must retain even a very small nonzero real generator. + let h = + Hamiltonian::from_list(vec![("Z".parse().unwrap(), Complex64::new(1e-16, 1e-12))]).unwrap(); + let ansatz = PauliEvolutionAnsatz::new(h).unwrap(); + assert_eq!(ansatz.hamiltonian.terms[0].1, Complex64::new(1e-16, 0.0)); + let actual = circuit_matrix_at_t(&ansatz, "residual", "residual_t", 1e16); + let expected = matrix_exp_iht(&hamiltonian_matrix(&ansatz.hamiltonian), 1e16); + assert!(frob_dist(&actual, &expected) < 1e-12); +} + +#[test] +fn mapped_small_number_operator_retains_time_and_global_phase() { + use crate::qis::fermion::{FermionToQubitMapper, FermionicOp, JordanWignerMapper}; + + let op = FermionicOp::number(1, 0) + .unwrap() + .scaled(Complex64::new(1e-16, 0.0)) + .unwrap(); + let h = JordanWignerMapper::new().map_operator(&op).unwrap(); + let ansatz = PauliEvolutionAnsatz::new(h).unwrap(); + let actual = circuit_matrix_at_t(&ansatz, "number", "number_t", 1e16); + // exp(-i n): vacuum has phase 1 and the occupied state has phase exp(-i). + let mut expected = Array2::::eye(2); + expected[[1, 1]] = Complex64::new(0.0, -1.0).exp(); + assert!(frob_dist(&actual, &expected) < 1e-12); +} + +#[test] +fn small_noncommuting_auto_evolution_matches_numeric_trotter() { + let h = Hamiltonian::from_list(vec![ + ("X".parse().unwrap(), Complex64::new(1e-12, 0.0)), + ("Z".parse().unwrap(), Complex64::new(-1e-12, 0.0)), + ]) + .unwrap(); + let numeric = h + .to_trotter_circuit(1e12, 512, TrotterMode::FirstOrder) + .unwrap(); + let expected = circuit_to_matrix(&numeric, None).unwrap(); + let ansatz = PauliEvolutionAnsatz::new(h) + .unwrap() + .with_strategy(EvolutionStrategy::Auto { steps: 512 }); + let actual = circuit_matrix_at_t(&ansatz, "auto", "auto_t", 1e12); + assert!(frob_dist(&actual, &expected) < 1e-12); +} + #[test] fn test_new_simplifies_hamiltonian() { // Add two identical terms: should merge into one with double coefficient diff --git a/crates/cqlib-core/src/qis/fermion/operator.rs b/crates/cqlib-core/src/qis/fermion/operator.rs index 0ac3915c..4b234d96 100644 --- a/crates/cqlib-core/src/qis/fermion/operator.rs +++ b/crates/cqlib-core/src/qis/fermion/operator.rs @@ -15,7 +15,7 @@ use crate::qis::QisError; use crate::qis::error::validate_coefficient; use num_complex::Complex64; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::ops::{Add, Mul, Neg, Sub}; /// A creation or annihilation operator on a zero-based fermionic mode. @@ -174,6 +174,91 @@ impl FermionicOp { Self { num_modes, terms } } + /// Creates the unit-weight Hermitian excitation generator `G = i(T - T†)`. + /// + /// For `sources = [i_0, ..., i_{r-1}]` and `targets = [a_0, ..., a_{r-1}]`, + /// the ordered excitation is + /// `T = a†_{a_0} ... a†_{a_{r-1}} a_{i_{r-1}} ... a_{i_0}`. + /// The rightmost operator acts first. Thus `[i, j] -> [a, b]` means + /// `T = a†_a a†_b a_j a_i`. + /// + /// Input order is preserved, without implicit sorting or normal ordering. + /// An odd permutation of either list negates `G`; applying odd permutations + /// to both lists preserves it. Explicit [`Self::normal_order`] preserves + /// these fermionic signs. No factorial or other normalization is applied. + /// + /// Mapping `G` and evolving it with [`crate::circuit::ansatz::PauliEvolutionAnsatz`] + /// implements `exp(-i θ G) = exp(θ (T - T†))`. The evolution parameter belongs + /// to the ansatz, not this operator. The generator conserves total particle + /// number. Reference occupations and spin interpretations are caller choices. + /// + /// # Errors + /// + /// Returns [`QisError::InvalidParameterValue`] if the lists are empty, have + /// unequal lengths, contain repeated modes, or overlap. Returns + /// [`QisError::ModeOutOfBounds`] if an index is outside `0..num_modes`. + /// + /// # Example + /// ``` + /// use cqlib_core::qis::FermionicOp; + /// let single = FermionicOp::excitation_generator(4, &[0], &[2])?; + /// let double = FermionicOp::excitation_generator(4, &[0, 1], &[2, 3])?; + /// assert!(single.is_hermitian(0.0)?); + /// assert!(double.is_hermitian(0.0)?); + /// # Ok::<(), cqlib_core::qis::QisError>(()) + /// ``` + pub fn excitation_generator( + num_modes: usize, + sources: &[usize], + targets: &[usize], + ) -> Result { + if sources.len() != targets.len() { + return Err(QisError::InvalidParameterValue( + "excitation sources and targets must have equal lengths".into(), + )); + } + if sources.is_empty() { + return Err(QisError::InvalidParameterValue( + "excitation sources and targets must be nonempty".into(), + )); + } + + let mut source_modes = BTreeSet::new(); + let mut target_modes = BTreeSet::new(); + for (label, modes, seen) in [ + ("sources", sources, &mut source_modes), + ("targets", targets, &mut target_modes), + ] { + for &mode in modes { + if mode >= num_modes { + return Err(QisError::ModeOutOfBounds { mode, num_modes }); + } + if !seen.insert(mode) { + return Err(QisError::InvalidParameterValue(format!( + "excitation {label} contain repeated mode {mode}" + ))); + } + } + } + if let Some(mode) = source_modes.intersection(&target_modes).next() { + return Err(QisError::InvalidParameterValue(format!( + "excitation sources and targets overlap at mode {mode}" + ))); + } + + let term = targets.iter().copied().map(FermionicLadder::Create).chain( + sources + .iter() + .rev() + .copied() + .map(FermionicLadder::Annihilate), + ); + let excitation = Self::from_term(num_modes, term, 1.0.into())?; + excitation + .try_sub(&excitation.adjoint())? + .scaled(Complex64::new(0.0, 1.0)) + } + /// Returns the declared mode count. pub fn num_modes(&self) -> usize { self.num_modes diff --git a/crates/cqlib-core/src/qis/fermion/tests.rs b/crates/cqlib-core/src/qis/fermion/tests.rs index 2d9494f2..5a90d144 100644 --- a/crates/cqlib-core/src/qis/fermion/tests.rs +++ b/crates/cqlib-core/src/qis/fermion/tests.rs @@ -12,7 +12,7 @@ use super::*; use crate::circuit::ansatz::{Ansatz, BasisEncoding, EvolutionStrategy, PauliEvolutionAnsatz}; -use crate::circuit::circuit_to_matrix; +use crate::circuit::circuit_to_matrix::circuit_to_matrix; use crate::qis::{ DensityMatrix, Hamiltonian, Observable, PauliString, Phase, QisError, Statevector, TrotterMode, }; @@ -54,6 +54,275 @@ fn assert_matrix_close(left: &Array2, right: &Array2) { } } +// Independent occupation transitions for T: remove sources in input order, +// then insert targets in reverse order. This does not inspect FermionicOp words +// or use its algebra, normal ordering or JW mapping. +fn excitation_fock_matrix(n: usize, sources: &[usize], targets: &[usize]) -> Array2 { + let dim = 1usize << n; + let mut transition = Array2::::zeros((dim, dim)); + for initial in 0..dim { + let mut state = initial; + let mut sign = 1.0; + let mut allowed = true; + for (&mode, create) in sources + .iter() + .map(|p| (p, false)) + .chain(targets.iter().rev().map(|p| (p, true))) + { + if (state & (1 << mode) != 0) == create { + allowed = false; + break; + } + if (state & ((1 << mode) - 1)).count_ones() % 2 != 0 { + sign = -sign; + } + state ^= 1 << mode; + } + if allowed { + transition[[state, initial]] = Complex64::new(sign, 0.0); + } + } + (&transition - &transition.t().mapv(|c| c.conj())).mapv(|c| Complex64::new(0.0, 1.0) * c) +} + +#[test] +fn excitation_generator_validates_all_index_constraints() { + for (sources, targets) in [ + (vec![], vec![]), + (vec![], vec![1]), + (vec![0, 1], vec![2]), + (vec![0, 0], vec![2, 3]), + (vec![0, 1], vec![2, 2]), + (vec![0], vec![0]), + (vec![0, 1], vec![1, 2]), + ] { + assert!(matches!( + FermionicOp::excitation_generator(4, &sources, &targets), + Err(QisError::InvalidParameterValue(_)) + )); + } + for (n, sources, targets, invalid) in [ + (0, vec![0], vec![1], 0), + (4, vec![4], vec![1], 4), + (4, vec![0], vec![4], 4), + (4, vec![0], vec![usize::MAX], usize::MAX), + ] { + assert!(matches!( + FermionicOp::excitation_generator(n, &sources, &targets), + Err(QisError::ModeOutOfBounds { mode, num_modes }) + if mode == invalid && num_modes == n + )); + } +} + +#[test] +fn excitation_generator_preserves_input_order_and_permutation_signs() { + let g = FermionicOp::excitation_generator(5, &[1, 0], &[4, 2]).unwrap(); + let expected = FermionicOp::from_terms( + 5, + [ + ( + vec![Create(4), Create(2), Annihilate(0), Annihilate(1)], + Complex64::new(0.0, 1.0), + ), + ( + vec![Create(1), Create(0), Annihilate(2), Annihilate(4)], + Complex64::new(0.0, -1.0), + ), + ], + ) + .unwrap(); + assert_eq!(g, expected); + assert_eq!(g.num_modes(), 5); + assert_eq!(g.num_terms(), 2); + assert_eq!(g.adjoint(), g); + let ordered = g.normal_order().unwrap(); + assert_eq!( + FermionicOp::excitation_generator(5, &[0, 1], &[4, 2]) + .unwrap() + .normal_order() + .unwrap(), + -&ordered + ); + assert_eq!( + FermionicOp::excitation_generator(5, &[1, 0], &[2, 4]) + .unwrap() + .normal_order() + .unwrap(), + -&ordered + ); + assert_eq!( + FermionicOp::excitation_generator(5, &[0, 1], &[2, 4]) + .unwrap() + .normal_order() + .unwrap(), + ordered + ); + assert_eq!( + FermionicOp::excitation_generator(5, &[4, 2], &[1, 0]).unwrap(), + -&g + ); + + let triple = FermionicOp::excitation_generator(6, &[2, 0, 1], &[4, 5, 3]).unwrap(); + assert_matrix_close( + &fock_matrix(&triple), + &excitation_fock_matrix(6, &[2, 0, 1], &[4, 5, 3]), + ); +} + +#[test] +fn excitation_generator_single_and_double_have_expected_fock_signs() { + let single = FermionicOp::excitation_generator(3, &[0], &[2]).unwrap(); + let mut expected = Array2::::zeros((8, 8)); + let i = Complex64::new(0.0, 1.0); + expected[[4, 1]] = i; + expected[[1, 4]] = -i; + // A spectator in mode 1 flips the single-excitation sign. + expected[[6, 3]] = -i; + expected[[3, 6]] = i; + assert_eq!(fock_matrix(&single), expected); + + let double = FermionicOp::excitation_generator(4, &[0, 1], &[2, 3]).unwrap(); + let mut expected = Array2::::zeros((16, 16)); + expected[[12, 3]] = i; + expected[[3, 12]] = -i; + assert_eq!(fock_matrix(&double), expected); +} + +#[test] +fn excitation_generators_match_fock_action_and_conserve_particle_number() { + let mapper = JordanWignerMapper::new(); + let check = |n, sources: &[usize], targets: &[usize]| { + let op = FermionicOp::excitation_generator(n, sources, targets).unwrap(); + let expected = excitation_fock_matrix(n, sources, targets); + assert_eq!(fock_matrix(&op), expected); + assert_eq!(expected, expected.t().mapv(|c| c.conj())); + assert!(op.is_hermitian(0.0).unwrap()); + assert_eq!(fock_matrix(&op.normal_order().unwrap()), expected); + assert_matrix_close( + &mapper.map_operator(&op).unwrap().to_matrix().unwrap(), + &expected, + ); + for ((row, col), value) in expected.indexed_iter() { + if value.norm() != 0.0 { + assert_eq!(row.count_ones(), col.count_ones()); + } + } + let number = FermionicOp::total_number(n); + assert!( + number + .try_mul(&op) + .unwrap() + .try_sub(&op.try_mul(&number).unwrap()) + .unwrap() + .normal_order() + .unwrap() + .is_zero() + ); + }; + // Every ordered, disjoint single and double excitation for up to five modes. + for n in 2..=5 { + for source in 0..n { + for target in 0..n { + if source == target { + continue; + } + check(n, &[source], &[target]); + for source2 in 0..n { + if [source, target].contains(&source2) { + continue; + } + for target2 in 0..n { + if [source, target, source2].contains(&target2) { + continue; + } + check(n, &[source, source2], &[target, target2]); + } + } + } + } + } + check(6, &[2, 0, 1], &[4, 5, 3]); +} + +#[test] +fn excitation_generator_evolution_matches_independent_fock_exponential() { + let mapper = JordanWignerMapper::new(); + for (n, sources, targets) in [ + (3, vec![0], vec![2]), + (3, vec![2], vec![0]), + (4, vec![0, 1], vec![2, 3]), + (5, vec![1, 0], vec![4, 2]), + ] { + let op = FermionicOp::excitation_generator(n, &sources, &targets).unwrap(); + let mapped = mapper.map_operator(&op).unwrap(); + assert!(mapped.all_terms_commute()); + let k = + excitation_fock_matrix(n, &sources, &targets).mapv(|g| Complex64::new(0.0, -1.0) * g); + for time in [-0.37, 0.0, 0.61] { + assert_matrix_close( + &bound_evolution(mapped.clone(), time), + &matrix_exp(&k, time), + ); + } + } +} + +#[test] +fn composed_excitations_have_independent_parameters_and_correct_statevectors() { + let mapper = JordanWignerMapper::new(); + let occupation = mapper.map_occupation(&[1, 1, 0, 0]).unwrap(); + let mut circuit = BasisEncoding::new(occupation) + .build_circuit("reference") + .unwrap(); + for (sources, targets, name) in [ + (vec![0, 1], vec![2, 3], "theta_double"), + (vec![0], vec![2], "theta_single"), + ] { + let g = FermionicOp::excitation_generator(4, &sources, &targets).unwrap(); + let block = PauliEvolutionAnsatz::new(mapper.map_operator(&g).unwrap()) + .unwrap() + .with_time_param_name(name) + .with_strategy(EvolutionStrategy::Exact) + .build_circuit("excitation") + .unwrap(); + assert_eq!(block.symbols().len(), 1); + circuit.compose(&block, None).unwrap(); + } + assert_eq!(circuit.symbols().len(), 2); + assert!(circuit.symbols().contains("theta_double")); + assert!(circuit.symbols().contains("theta_single")); + let number = mapper.map_operator(&FermionicOp::total_number(4)).unwrap(); + for (double, single) in [ + (0.0_f64, 0.0_f64), + (0.37, 0.0), + (0.0, -0.21), + (0.37, -0.21), + (-0.43, 0.19), + ] { + // Bind in two stages to exercise independence and partial binding. + let partial = circuit + .assign_parameters(&Some(HashMap::from([("theta_double", double)]))) + .unwrap(); + assert_eq!(partial.symbols().len(), 1); + assert!(partial.symbols().contains("theta_single")); + let bound = partial + .assign_parameters(&Some(HashMap::from([("theta_single", single)]))) + .unwrap(); + assert!(bound.symbols().is_empty()); + let state = Statevector::from_circuit(&bound).unwrap(); + let mut expected = vec![Complex64::new(0.0, 0.0); 16]; + expected[3] = (double.cos() * single.cos()).into(); + expected[6] = (-double.cos() * single.sin()).into(); + expected[12] = (double.sin() * single.cos()).into(); + expected[9] = (-double.sin() * single.sin()).into(); + for (actual, expected) in state.data().iter().zip(expected) { + assert!((*actual - expected).norm() < 1e-11); + } + assert!((number.expectation_statevector(&state).unwrap() - 2.0).abs() < 1e-11); + } +} + fn matrix_exp(matrix: &Array2, scale: f64) -> Array2 { let mut term = Array2::::eye(matrix.nrows()); let mut result = term.clone(); diff --git a/crates/cqlib-core/src/qis/hamiltonian.rs b/crates/cqlib-core/src/qis/hamiltonian.rs index 668b960e..b3338d94 100644 --- a/crates/cqlib-core/src/qis/hamiltonian.rs +++ b/crates/cqlib-core/src/qis/hamiltonian.rs @@ -81,9 +81,12 @@ impl Hamiltonian { } /// Shared contract for real-valued observables and Hamiltonian evolution. - /// Hermiticity is checked after merging, without pruning small coefficients. + /// After merging and absorbing Pauli phases, imaginary parts with magnitude + /// greater than `1e-10` are rejected. Accepted imaginary roundoff residuals + /// are projected to zero, and terms with exactly zero real part are removed. + /// Every nonzero real coefficient is retained, however small. pub fn normalized_hermitian(&self) -> Result { - let normalized = self.normalized()?; + let mut normalized = self.normalized()?; if normalized .terms .iter() @@ -91,6 +94,12 @@ impl Hamiltonian { { return Err(QisError::NotHermitian); } + for (_, coefficient) in &mut normalized.terms { + coefficient.im = 0.0; + } + normalized + .terms + .retain(|(_, coefficient)| coefficient.re != 0.0); Ok(normalized) } diff --git a/crates/cqlib-core/src/qis/hamiltonian_test.rs b/crates/cqlib-core/src/qis/hamiltonian_test.rs index c3a0417f..3f153b63 100644 --- a/crates/cqlib-core/src/qis/hamiltonian_test.rs +++ b/crates/cqlib-core/src/qis/hamiltonian_test.rs @@ -15,6 +15,42 @@ use crate::qis::{PauliString, Phase, QisError}; use ndarray::{Array2, arr2}; use num_complex::Complex64; +#[test] +fn hermitian_normalization_projects_only_accepted_imaginary_residuals() { + let h = Hamiltonian::from_list(vec![ + ("I".parse().unwrap(), Complex64::new(0.0, 1e-12)), + ("Z".parse().unwrap(), Complex64::new(1e-16, -1e-10)), + ]) + .unwrap(); + let normalized = h.normalized_hermitian().unwrap(); + assert_eq!(normalized.num_qubits, 1); + assert_eq!( + normalized.terms, + vec![("Z".parse().unwrap(), Complex64::new(1e-16, 0.0))] + ); + assert_eq!(normalized.normalized_hermitian().unwrap(), normalized); + // General complex normalization must not apply the real projection. + assert_eq!(h.normalized().unwrap().terms.len(), 2); + + let mut invalid = h.clone(); + invalid.terms[0].1.im = 1.01e-10; + assert!(matches!( + invalid.normalized_hermitian(), + Err(QisError::NotHermitian) + )); + + // Validate the merged imaginary coefficient before removing zero real parts. + let merged_invalid = Hamiltonian::from_list(vec![ + ("Z".parse().unwrap(), Complex64::new(0.0, 0.75e-10)), + ("Z".parse().unwrap(), Complex64::new(0.0, 0.75e-10)), + ]) + .unwrap(); + assert!(matches!( + merged_invalid.normalized_hermitian(), + Err(QisError::NotHermitian) + )); +} + #[test] fn empty_hamiltonian_to_matrix_is_zero() { assert_eq!( From 0edf872e94801fff1c29601e9a1f17ac1c236a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Mon, 12 Oct 2026 19:32:47 +0800 Subject: [PATCH 4/6] feat(core): add checked sparse Pauli operator algebra --- crates/cqlib-core/README.md | 3 +- crates/cqlib-core/src/qis/fermion/mod.rs | 12 +- crates/cqlib-core/src/qis/hamiltonian.rs | 322 ++++++++++- crates/cqlib-core/src/qis/hamiltonian_test.rs | 507 ++++++++++++++++++ docs/api/cn/rust/3_qis/7_hamiltonian.md | 55 +- docs/api/en/rust/3_qis/7_hamiltonian.md | 55 +- 6 files changed, 918 insertions(+), 36 deletions(-) diff --git a/crates/cqlib-core/README.md b/crates/cqlib-core/README.md index 4a7e3911..6498e6b9 100644 --- a/crates/cqlib-core/README.md +++ b/crates/cqlib-core/README.md @@ -77,7 +77,8 @@ assert!(bound.parameters().is_empty()); - **Simulation** — Statevector, density-matrix, noisy density-matrix, and stabilizer simulation - **Quantum information** — Pauli operators, Hamiltonians, observables, - evolution, metrics, and entropy functions + evolution, fermionic algebra, excitation generators, Jordan–Wigner mapping, + metrics, and entropy functions - **Error mitigation** — Zero-noise extrapolation and virtual distillation - **Interchange formats** — OpenQASM 2, OpenQASM 3, and QCIS - **Visualization** — Text and SVG circuit rendering plus state and result plots diff --git a/crates/cqlib-core/src/qis/fermion/mod.rs b/crates/cqlib-core/src/qis/fermion/mod.rs index 82a47998..324fcdbe 100644 --- a/crates/cqlib-core/src/qis/fermion/mod.rs +++ b/crates/cqlib-core/src/qis/fermion/mod.rs @@ -37,13 +37,10 @@ //! //! # Parameterized excitation evolution //! ``` -//! use cqlib_core::qis::{FermionicLadder::{Create, Annihilate}, FermionicOp, +//! use cqlib_core::qis::{FermionicOp, //! FermionToQubitMapper, JordanWignerMapper}; //! use cqlib_core::circuit::ansatz::{Ansatz, PauliEvolutionAnsatz}; -//! use num_complex::Complex64; -//! let t = FermionicOp::from_term(4, -//! [Create(2), Create(3), Annihilate(1), Annihilate(0)], 1.0.into())?; -//! let h = (&t - t.adjoint())?.scaled(Complex64::new(0.0, 1.0))?; +//! let h = FermionicOp::excitation_generator(4, &[0, 1], &[2, 3])?; //! // e^(-i H theta) = e^(theta (T - T†)). //! let mapped = JordanWignerMapper::new().map_operator(&h)?; //! let circuit = PauliEvolutionAnsatz::new(mapped)?.build_circuit("excitation")?; @@ -51,6 +48,11 @@ //! # Ok::<(), cqlib_core::qis::QisError>(()) //! ``` //! +//! Each excitation can have an independent parameter: build one evolution +//! block per generator with a distinct `with_time_param_name`, then append +//! the blocks using [`crate::circuit::Circuit::compose`]. A single ansatz built +//! from a sum of generators instead has one shared time parameter. +//! //! # Occupation-state encoding //! ``` //! use cqlib_core::qis::{JordanWignerMapper, OccupationStateMapper, Statevector}; diff --git a/crates/cqlib-core/src/qis/hamiltonian.rs b/crates/cqlib-core/src/qis/hamiltonian.rs index b3338d94..91403594 100644 --- a/crates/cqlib-core/src/qis/hamiltonian.rs +++ b/crates/cqlib-core/src/qis/hamiltonian.rs @@ -13,9 +13,13 @@ //! Hamiltonian representation and operations. //! //! This module provides the [`Hamiltonian`] struct, which represents a quantum -//! observable as a linear combination of Pauli strings (tensor products of Pauli operators) +//! operator as a linear combination of Pauli strings (tensor products of Pauli operators) //! with complex coefficients. It is primarily used for defining system energies, //! observables for expectation value calculations, and operators for time evolution. +//! +//! Modified: added checked sparse algebra and validated dense conversion. +//! General algebra permits non-Hermitian operators. Real observables and unitary +//! evolution validate Hermiticity at their consumption boundaries. use crate::qis::error::validate_coefficient; use crate::qis::pauli::PauliString; @@ -30,6 +34,10 @@ use std::ops::Add; /// A `Hamiltonian` is essentially a sparse representation of a $2^N \times 2^N$ /// matrix, expressed as $H = \sum_k c_k P_k$, where $c_k$ is a complex coefficient /// and $P_k$ is an $N$-qubit Pauli string. +/// +/// Despite its name, this type also represents non-Hermitian operators. Checked +/// algebra returns phase-normalized, merged terms without approximate pruning. +/// [`PartialEq`] compares stored structure; use [`Self::equiv`] for algebraic comparison. #[derive(Debug, Clone, PartialEq)] pub struct Hamiltonian { /// The number of qubits this operator acts on. @@ -55,6 +63,10 @@ impl Hamiltonian { /// Returns a validated Pauli sum with phases absorbed and exact zeros removed. /// Retains the declared qubit count, including for zero and scalar operators. pub fn normalized(&self) -> Result { + Self::from_terms(self.num_qubits, self.terms.iter().cloned()) + } + + fn validate(&self) -> Result<(), QisError> { for (pauli, coefficient) in &self.terms { if pauli.num_qubits != self.num_qubits { return Err(QisError::QubitMismatch { @@ -72,12 +84,7 @@ impl Hamiltonian { } validate_coefficient(*coefficient)?; } - let mut normalized = self.clone(); - normalized.simplify_with_tolerance(0.0); - for (_, coefficient) in &normalized.terms { - validate_coefficient(*coefficient)?; - } - Ok(normalized) + Ok(()) } /// Shared contract for real-valued observables and Hamiltonian evolution. @@ -134,25 +141,49 @@ impl Hamiltonian { /// /// # Errors /// - /// Returns [`QisError::QubitMismatch`] when a term's qubit count differs - /// from [`Hamiltonian::num_qubits`]. + /// Validates term widths, bit-vector lengths and finite coefficients. Returns + /// [`QisError::UnsupportedOperation`] if the dense allocation size overflows + /// or its reservation fails. Matrix-entry overflow is also reported. pub fn to_matrix(&self) -> Result, QisError> { - for (pauli, _) in &self.terms { - if pauli.num_qubits != self.num_qubits { - return Err(QisError::QubitMismatch { - expected: self.num_qubits, - actual: pauli.num_qubits, - }); - } + let normalized = self.normalized()?; + let size_error = || { + QisError::UnsupportedOperation( + "dense Pauli matrix exceeds addressable allocation size".into(), + ) + }; + let shift = u32::try_from(self.num_qubits).map_err(|_| size_error())?; + let dim = 1usize.checked_shl(shift).ok_or_else(size_error)?; + let len = dim.checked_mul(dim).ok_or_else(size_error)?; + let bytes = len + .checked_mul(std::mem::size_of::()) + .ok_or_else(size_error)?; + if bytes > isize::MAX as usize { + return Err(size_error()); } - - let dim = 1usize << self.num_qubits; - let mut matrix = Array2::from_elem((dim, dim), Complex64::new(0.0, 0.0)); - for (pauli, coefficient) in &self.terms { - let term = pauli.to_matrix().mapv(|value| value * *coefficient); - matrix += &term; + let mut data = Vec::new(); + data.try_reserve_exact(len).map_err(|error| { + QisError::UnsupportedOperation(format!("cannot allocate dense Pauli matrix: {error}")) + })?; + data.resize(len, Complex64::new(0.0, 0.0)); + // P|column> flips X bits; Z parity is evaluated before those flips. + for (pauli, coefficient) in &normalized.terms { + let x = pauli.x_mask(); + let z = pauli.z_mask(); + let factor = *coefficient * pauli.y_phase(); + validate_coefficient(factor)?; + for column in 0..dim { + let sign = if (column & z).count_ones() & 1 == 0 { + 1.0 + } else { + -1.0 + }; + let entry = &mut data[(column ^ x) * dim + column]; + *entry += factor * sign; + validate_coefficient(*entry)?; + } } - Ok(matrix) + Array2::from_shape_vec((dim, dim), data) + .map_err(|error| QisError::UnsupportedOperation(error.to_string())) } /// Creates a Hamiltonian from a list of Pauli strings and their corresponding coefficients. @@ -179,6 +210,236 @@ impl From for Hamiltonian { } } +impl Hamiltonian { + /// The identity on the declared register; at zero qubits this is scalar one. + pub fn identity(num_qubits: usize) -> Self { + Self::from_pauli(PauliString::new(num_qubits)) + } + + /// Constructs a validated, phase-normalized sum, merging duplicate terms. + /// Only exact zeros are removed, and empty input retains `num_qubits`. + /// + /// Invalid widths, bit-vector lengths, nonfinite coefficients and intermediate + /// coefficient overflow return an error. No approximate truncation occurs. + pub fn from_terms( + num_qubits: usize, + terms: impl IntoIterator, + ) -> Result { + let mut result = Self { + num_qubits, + terms: terms.into_iter().collect(), + }; + result.validate()?; + result.merge_terms(0.0, true)?; + Ok(result) + } + + fn check_width(&self, other: &Self) -> Result<(), QisError> { + if self.num_qubits != other.num_qubits { + return Err(QisError::QubitMismatch { + expected: self.num_qubits, + actual: other.num_qubits, + }); + } + Ok(()) + } + + fn check_materialized_width(width: usize) -> Result<(), QisError> { + // BitSlice reserves metadata bits, so its length bound is below usize::MAX. + // Zero sums need no bit vectors and may retain larger declared widths. + if width > bitvec::slice::BitSlice::::MAX_BITS { + return Err(QisError::UnsupportedOperation( + "Pauli register width exceeds the BitVec representation limit".into(), + )); + } + Ok(()) + } + + /// Adds two operators on the same register, without modifying either input. + /// Unlike legacy `Add`, this validates inputs and merges the resulting terms. + pub fn try_add(&self, other: &Self) -> Result { + self.check_width(other)?; + let left = self.normalized()?; + let right = other.normalized()?; + Self::from_terms(self.num_qubits, left.terms.into_iter().chain(right.terms)) + } + + /// Subtracts two operators on the same register, retaining every nonzero term. + pub fn try_sub(&self, other: &Self) -> Result { + self.check_width(other)?; + let left = self.normalized()?; + let right = other.normalized()?; + Self::from_terms( + self.num_qubits, + left.terms + .into_iter() + .chain(right.terms.into_iter().map(|(p, c)| (p, -c))), + ) + } + + /// Forms the matrix product `self * other`; the right operand acts first. + /// Both inputs are validated even when either represents zero. Products may + /// be non-Hermitian. Expansion takes O(m k n) work before sorting/merging + /// and may generate m k terms, for term counts m, k and register width n. + /// + /// ``` + /// use cqlib_core::qis::{Hamiltonian, QisError}; + /// use num_complex::Complex64; + /// let x = Hamiltonian::from_pauli("X".parse()?); + /// let z = Hamiltonian::from_pauli("Z".parse()?); + /// let minus_i_y = Hamiltonian::from_terms(1, [ + /// ("Y".parse()?, Complex64::new(0.0, -1.0)), + /// ])?; + /// assert!(x.try_mul(&z)?.equiv(&minus_i_y, 0.0)?); + /// # Ok::<(), QisError>(()) + /// ``` + pub fn try_mul(&self, other: &Self) -> Result { + self.check_width(other)?; + let left = self.normalized()?; + let right = other.normalized()?; + Self::from_terms( + self.num_qubits, + left.terms + .iter() + .flat_map(|(p, a)| right.terms.iter().map(move |(q, b)| (p * q, *a * *b))), + ) + } + + /// Returns a scaled sum, checking the factor even for the zero operator. + pub fn scaled(&self, factor: Complex64) -> Result { + validate_coefficient(factor)?; + let normalized = self.normalized()?; + Self::from_terms( + self.num_qubits, + normalized.terms.into_iter().map(|(p, c)| (p, c * factor)), + ) + } + + /// Returns the conjugate transpose after absorbing Pauli phases. + /// Phase-free Pauli strings are Hermitian, so their coefficients are conjugated. + pub fn adjoint(&self) -> Result { + let mut result = self.normalized()?; + for (_, coefficient) in &mut result.terms { + *coefficient = coefficient.conj(); + } + Ok(result) + } + + fn validate_tolerance(atol: f64) -> Result<(), QisError> { + if !atol.is_finite() || atol < 0.0 { + return Err(QisError::InvalidParameterValue( + "Pauli coefficient tolerance must be finite and nonnegative".into(), + )); + } + Ok(()) + } + + /// Normalizes, then removes coefficients with complex magnitude at most `atol`. + /// On any validation or merging error, the stored operator is unchanged. + pub fn chop(&mut self, atol: f64) -> Result<(), QisError> { + Self::validate_tolerance(atol)?; + let mut result = self.normalized()?; + result.terms.retain(|(_, c)| c.norm() > atol); + *self = result; + Ok(()) + } + + /// Compares normalized coefficients of `self - other` against `atol`. + /// Width mismatch is an error. This coefficient tolerance is not a matrix + /// operator-norm bound, and differs from structural `PartialEq`. + pub fn equiv(&self, other: &Self, atol: f64) -> Result { + Self::validate_tolerance(atol)?; + Ok(self + .try_sub(other)? + .terms + .iter() + .all(|(_, c)| c.norm() <= atol)) + } + + /// Checks `self.equiv(self.adjoint(), atol)` as for `FermionicOp`. + /// On a normalized sum this bounds twice the imaginary coefficient magnitude. + /// It does not project coefficients to real values; `normalized_hermitian` + /// retains its separate consumer contract (imaginary tolerance `1e-10`). + pub fn is_hermitian(&self, atol: f64) -> Result { + Self::validate_tolerance(atol)?; + self.equiv(&self.adjoint()?, atol) + } + + /// Returns `self ⊗ other`: `other` occupies low qubits and `self` high qubits. + /// Register widths are added even for zero operators. No dense matrix is built. + /// A nonzero result with a width beyond BitVec's length limit returns an error. + /// + /// ``` + /// use cqlib_core::qis::{Hamiltonian, QisError}; + /// let x = Hamiltonian::from_pauli("X".parse()?); + /// let z = Hamiltonian::from_pauli("Z".parse()?); + /// assert!(x.tensor(&z)?.equiv(&Hamiltonian::from_pauli("XZ".parse()?), 0.0)?); + /// # Ok::<(), QisError>(()) + /// ``` + pub fn tensor(&self, other: &Self) -> Result { + let width = self + .num_qubits + .checked_add(other.num_qubits) + .ok_or_else(|| { + QisError::InvalidParameterValue("tensor register width overflow".into()) + })?; + let left = self.normalized()?; + let right = other.normalized()?; + if !left.terms.is_empty() && !right.terms.is_empty() { + Self::check_materialized_width(width)?; + } + Self::from_terms( + width, + left.terms.iter().flat_map(|(p, a)| { + right.terms.iter().map(move |(q, b)| { + let mut product = q.clone(); + product.x.extend_from_bitslice(&p.x); + product.z.extend_from_bitslice(&p.z); + product.num_qubits = width; + (product, *a * *b) + }) + }), + ) + } + + /// Relabels qubits using `mapping[source] = destination`, inserting identities + /// on unused target qubits. Requires exactly one distinct destination per + /// source qubit, each less than `target_width`, even for zero/identity input. + /// This is a qubit embedding, not a fermionic mode permutation or a trace. + /// If terms must be constructed, unrepresentable target widths return + /// [`QisError::UnsupportedOperation`] before BitVec allocation. + pub fn remap_qubits(&self, mapping: &[usize], target_width: usize) -> Result { + if mapping.len() != self.num_qubits { + return Err(QisError::InvalidParameterValue( + "qubit mapping length mismatch".into(), + )); + } + let mut destinations = std::collections::HashSet::with_capacity(mapping.len()); + for &destination in mapping { + if destination >= target_width || !destinations.insert(destination) { + return Err(QisError::InvalidParameterValue( + "qubit mapping must contain distinct, in-range destinations".into(), + )); + } + } + let normalized = self.normalized()?; + if !normalized.terms.is_empty() { + Self::check_materialized_width(target_width)?; + } + Self::from_terms( + target_width, + normalized.terms.into_iter().map(|(p, c)| { + let mut mapped = PauliString::new(target_width); + for (source, &destination) in mapping.iter().enumerate() { + mapped.x.set(destination, p.x[source]); + mapped.z.set(destination, p.z[source]); + } + (mapped, c) + }), + ) + } +} + impl TryFrom> for Hamiltonian { type Error = QisError; @@ -247,8 +508,14 @@ impl Hamiltonian { // Evolution must merge terms without pruning small, nonzero coefficients. pub(crate) fn simplify_with_tolerance(&mut self, tolerance: f64) { + // Legacy simplification retains its infallible API and numerical behavior. + self.merge_terms(tolerance, false) + .expect("unchecked merging cannot fail"); + } + + fn merge_terms(&mut self, tolerance: f64, checked: bool) -> Result<(), QisError> { if self.terms.is_empty() { - return; + return Ok(()); } // 1. Normalize the Phase of all PauliStrings to Plus (+1), and multiply the phase into the coefficient. @@ -265,6 +532,9 @@ impl Hamiltonian { // Multiply the phase into the coefficient let new_coeff = coeff * phase_val; + if checked { + validate_coefficient(new_coeff)?; + } simplified_terms.push((pure_op, new_coeff)); } @@ -289,6 +559,9 @@ impl Hamiltonian { if op.x == current_op.x && op.z == current_op.z { // Identical operator found, accumulate the coefficient current_coeff += coeff; + if checked { + validate_coefficient(current_coeff)?; + } } else { // New operator found, save the previous one if its coefficient is significant // Filter out terms with a coefficient near zero @@ -310,6 +583,7 @@ impl Hamiltonian { } self.terms = merged; + Ok(()) } /// Scales all terms in the Hamiltonian by a complex factor. diff --git a/crates/cqlib-core/src/qis/hamiltonian_test.rs b/crates/cqlib-core/src/qis/hamiltonian_test.rs index 3f153b63..ed4ebfa0 100644 --- a/crates/cqlib-core/src/qis/hamiltonian_test.rs +++ b/crates/cqlib-core/src/qis/hamiltonian_test.rs @@ -15,6 +15,513 @@ use crate::qis::{PauliString, Phase, QisError}; use ndarray::{Array2, arr2}; use num_complex::Complex64; +// Modified: independent matrix oracles and sparse-algebra boundary coverage. + +/// Test-only Kronecker product, independent of production Pauli operations. +fn matrix_tensor(a: &Array2, b: &Array2) -> Array2 { + Array2::from_shape_fn((a.nrows() * b.nrows(), a.ncols() * b.ncols()), |(r, c)| { + a[[r / b.nrows(), c / b.ncols()]] * b[[r % b.nrows(), c % b.ncols()]] + }) +} + +fn matrix_adjoint(a: &Array2) -> Array2 { + a.t().mapv(|c| c.conj()) +} + +/// Literal single-qubit matrices and explicit labels, not production masks/phases. +fn matrix_oracle(terms: &[(&str, Phase, Complex64)]) -> Array2 { + let width = terms[0].0.len(); + let mut sum = Array2::zeros((1 << width, 1 << width)); + let zero = Complex64::new(0.0, 0.0); + let one = Complex64::new(1.0, 0.0); + let i = Complex64::new(0.0, 1.0); + for &(label, phase, coefficient) in terms { + let mut product = arr2(&[[one]]); + for letter in label.chars() { + let local = match letter { + 'I' => arr2(&[[one, zero], [zero, one]]), + 'X' => arr2(&[[zero, one], [one, zero]]), + 'Y' => arr2(&[[zero, -i], [i, zero]]), + 'Z' => arr2(&[[one, zero], [zero, -one]]), + _ => panic!("invalid test label"), + }; + product = matrix_tensor(&product, &local); + } + let phase = match phase { + Phase::Plus => one, + Phase::Minus => -one, + Phase::I => i, + Phase::MinusI => -i, + }; + sum += &product.mapv(|c| c * phase * coefficient); + } + sum +} + +fn raw_sum(terms: &[(&str, Phase, Complex64)]) -> Hamiltonian { + Hamiltonian::from_list( + terms + .iter() + .map(|&(label, phase, coefficient)| { + let mut pauli: PauliString = label.parse().unwrap(); + pauli.phase = phase; + (pauli, coefficient) + }) + .collect(), + ) + .unwrap() +} + +fn assert_matrix_close(actual: &Array2, expected: &Array2) { + assert_eq!(actual.dim(), expected.dim()); + for (a, b) in actual.iter().zip(expected) { + assert!((*a - *b).norm() < 1e-12, "{actual:?} != {expected:?}"); + } +} + +#[test] +fn sparse_arithmetic_matches_independent_matrices() { + let a_terms = [ + ("XYZ", Phase::MinusI, Complex64::new(0.5, 0.25)), + ("XYZ", Phase::Plus, Complex64::new(-0.125, 0.75)), + ("YIX", Phase::Minus, Complex64::new(1.0, -0.5)), + ("III", Phase::I, Complex64::new(-0.25, 0.5)), + ]; + let b_terms = [ + ("ZZY", Phase::I, Complex64::new(0.75, -0.25)), + ("XYY", Phase::Plus, Complex64::new(-0.5, 0.5)), + ]; + let a = raw_sum(&a_terms); + let b = raw_sum(&b_terms); + let saved = (a.clone(), b.clone()); + let am = matrix_oracle(&a_terms); + let bm = matrix_oracle(&b_terms); + assert_matrix_close(&a.to_matrix().unwrap(), &am); + assert_matrix_close(&a.try_add(&b).unwrap().to_matrix().unwrap(), &(&am + &bm)); + assert_matrix_close(&a.try_sub(&b).unwrap().to_matrix().unwrap(), &(&am - &bm)); + assert_matrix_close(&a.try_mul(&b).unwrap().to_matrix().unwrap(), &am.dot(&bm)); + assert_matrix_close( + &a.adjoint().unwrap().to_matrix().unwrap(), + &matrix_adjoint(&am), + ); + let factor = Complex64::new(0.25, -0.5); + assert_matrix_close( + &a.scaled(factor).unwrap().to_matrix().unwrap(), + &am.mapv(|c| c * factor), + ); + assert_eq!((a, b), saved); +} + +#[test] +fn exhaustive_two_qubit_products_include_all_global_phases() { + for a in ['I', 'X', 'Y', 'Z'] { + for b in ['I', 'X', 'Y', 'Z'] { + for c in ['I', 'X', 'Y', 'Z'] { + for d in ['I', 'X', 'Y', 'Z'] { + for phase in [Phase::Plus, Phase::Minus, Phase::I, Phase::MinusI] { + let left_label = format!("{a}{b}"); + let right_label = format!("{c}{d}"); + let left = [(left_label.as_str(), phase, Complex64::new(0.5, 0.25))]; + let right = [( + right_label.as_str(), + Phase::MinusI, + Complex64::new(-0.25, 0.5), + )]; + let product = raw_sum(&left).try_mul(&raw_sum(&right)).unwrap(); + assert_matrix_close( + &product.to_matrix().unwrap(), + &matrix_oracle(&left).dot(&matrix_oracle(&right)), + ); + } + } + } + } + } +} + +#[test] +fn cancellations_and_adjoint_product_order_follow_operator_algebra() { + let x = Hamiltonian::from_pauli("X".parse().unwrap()); + let z = Hamiltonian::from_pauli("Z".parse().unwrap()); + let sum = x.try_add(&z).unwrap(); + assert_eq!( + sum.try_mul(&sum).unwrap(), + Hamiltonian::identity(1).scaled(2.0.into()).unwrap() + ); + let product = x.try_mul(&z).unwrap(); + assert_eq!( + product.terms, + vec![("Y".parse().unwrap(), Complex64::new(0.0, -1.0))] + ); + assert!(!product.is_hermitian(0.0).unwrap()); + assert_eq!( + product.adjoint().unwrap(), + z.adjoint().unwrap().try_mul(&x.adjoint().unwrap()).unwrap() + ); + let commutator = product.try_sub(&z.try_mul(&x).unwrap()).unwrap(); + assert_eq!(commutator.terms[0].1, Complex64::new(0.0, -2.0)); + assert!( + product + .try_add(&z.try_mul(&x).unwrap()) + .unwrap() + .terms + .is_empty() + ); +} + +#[test] +fn checked_construction_and_comparison_keep_legacy_structure_semantics() { + let p: PauliString = "X".parse().unwrap(); + let raw = + Hamiltonian::from_list(vec![(p.clone(), 0.25.into()), (p.clone(), 0.75.into())]).unwrap(); + let canonical = Hamiltonian::from_terms(1, raw.terms.clone()).unwrap(); + assert_eq!(canonical, Hamiltonian::from_pauli(p)); + assert_ne!(raw, canonical); + assert!(raw.equiv(&canonical, 0.0).unwrap()); + assert_eq!((raw.clone() + canonical.clone()).terms.len(), 3); + assert_eq!(raw.try_add(&canonical).unwrap().terms.len(), 1); + assert_eq!(Hamiltonian::from_list(vec![]).unwrap().num_qubits, 0); + assert_eq!(Hamiltonian::from_terms(7, []).unwrap(), Hamiltonian::new(7)); + assert_eq!( + canonical.normalized().unwrap().normalized().unwrap(), + canonical + ); + let reversed = Hamiltonian::from_list(raw.terms.into_iter().rev().collect()).unwrap(); + assert_eq!(reversed.normalized().unwrap(), canonical); +} + +#[test] +fn explicit_chopping_follows_merging_and_preserves_tiny_coefficients() { + let tiny = f64::from_bits(1); + let p: PauliString = "Y".parse().unwrap(); + let mut phased = p.clone(); + phased.phase = Phase::I; + let small = Hamiltonian::from_terms(1, [(phased, tiny.into())]).unwrap(); + assert_eq!(small.terms[0].1, Complex64::new(0.0, tiny)); + for result in [ + small.try_mul(&Hamiltonian::identity(1)).unwrap(), + small.scaled(1.0.into()).unwrap(), + small.try_add(&Hamiltonian::new(1)).unwrap(), + small.adjoint().unwrap().adjoint().unwrap(), + ] { + assert_eq!(result, small); + } + let mut h = Hamiltonian::from_list(vec![(p.clone(), 0.75.into()), (p, 0.75.into())]).unwrap(); + h.chop(1.0).unwrap(); + assert_eq!(h.terms[0].1, Complex64::new(1.5, 0.0)); + h.chop(1.5).unwrap(); + assert_eq!(h, Hamiltonian::new(1)); + let mut small = small; + small.chop(0.0).unwrap(); + assert_eq!(small.terms.len(), 1); + small.chop(tiny).unwrap(); + assert!(small.terms.is_empty()); + let mut complex = + Hamiltonian::from_terms(1, [("X".parse().unwrap(), Complex64::new(3.0, 4.0))]).unwrap(); + complex.chop(4.0).unwrap(); + assert_eq!(complex.terms.len(), 1); + complex.chop(5.0).unwrap(); + assert!(complex.terms.is_empty()); +} + +#[test] +fn hermiticity_uses_adjoint_difference_tolerance_without_projection() { + let h = + Hamiltonian::from_terms(1, [("Z".parse().unwrap(), Complex64::new(1.0, 0.125))]).unwrap(); + assert!(h.is_hermitian(0.25).unwrap()); + assert!(!h.is_hermitian(0.249).unwrap()); + assert_eq!(h.terms[0].1.im, 0.125); + let phased = raw_sum(&[("X", Phase::I, Complex64::new(0.0, -1.0))]); + assert!(phased.is_hermitian(0.0).unwrap()); + assert!(!h.equiv(&Hamiltonian::identity(1), 0.0).unwrap()); +} + +#[test] +fn tensor_and_permutation_embedding_match_independent_matrices() { + let at = [("YX", Phase::MinusI, Complex64::new(0.5, 0.25))]; + let bt = [ + ("Z", Phase::I, Complex64::new(-0.25, 1.0)), + ("X", Phase::Minus, 0.5.into()), + ]; + let a = raw_sum(&at); + let b = raw_sum(&bt); + assert_matrix_close( + &a.tensor(&b).unwrap().to_matrix().unwrap(), + &matrix_tensor(&matrix_oracle(&at), &matrix_oracle(&bt)), + ); + let swapped = a.remap_qubits(&[1, 0], 2).unwrap(); + assert_matrix_close( + &swapped.to_matrix().unwrap(), + &matrix_oracle(&[("XY", Phase::MinusI, Complex64::new(0.5, 0.25))]), + ); + let embedded = a.remap_qubits(&[3, 1], 4).unwrap(); + assert_matrix_close( + &embedded.to_matrix().unwrap(), + &matrix_oracle(&[("XIYI", Phase::MinusI, Complex64::new(0.5, 0.25))]), + ); + let scalar = Hamiltonian::identity(0) + .scaled(Complex64::new(2.0, -0.5)) + .unwrap(); + assert_eq!( + a.tensor(&scalar).unwrap(), + a.scaled(Complex64::new(2.0, -0.5)).unwrap() + ); + assert_eq!( + scalar.tensor(&a).unwrap(), + a.scaled(Complex64::new(2.0, -0.5)).unwrap() + ); +} + +#[test] +fn zero_and_scalar_operators_retain_declared_spaces() { + for width in [0, 1, 128] { + let zero = Hamiltonian::new(width); + let identity = Hamiltonian::identity(width); + assert_eq!(zero.try_mul(&identity).unwrap(), zero); + assert_eq!(identity.try_mul(&zero).unwrap(), zero); + assert_eq!(identity.try_sub(&identity).unwrap(), zero); + assert_eq!(identity.scaled(0.0.into()).unwrap(), zero); + assert_eq!( + zero.tensor(&Hamiltonian::new(2)).unwrap().num_qubits, + width + 2 + ); + let map: Vec<_> = (0..width).map(|q| q + 1).collect(); + assert_eq!( + zero.remap_qubits(&map, width + 2).unwrap(), + Hamiltonian::new(width + 2) + ); + assert_eq!( + identity.remap_qubits(&map, width + 2).unwrap(), + Hamiltonian::identity(width + 2) + ); + } + assert_eq!( + Hamiltonian::identity(0).to_matrix().unwrap(), + arr2(&[[Complex64::new(1.0, 0.0)]]) + ); +} + +#[test] +fn wide_register_arithmetic_never_needs_machine_word_masks() { + use crate::qis::Pauli; + for width in [65, 128, 1000] { + let mut p = PauliString::new(width); + p.set_pauli(width - 1, Pauli::Y); + p.set_pauli(0, Pauli::Z); + let a = Hamiltonian::from_terms(width, [(p.clone(), 0.5.into())]).unwrap(); + assert_eq!( + a.try_mul(&a).unwrap(), + Hamiltonian::identity(width).scaled(0.25.into()).unwrap() + ); + assert!(a.is_hermitian(0.0).unwrap()); + assert!( + a.try_add(&a) + .unwrap() + .equiv(&a.scaled(2.0.into()).unwrap(), 0.0) + .unwrap() + ); + let mapping: Vec<_> = (0..width).rev().collect(); + let reversed = a.remap_qubits(&mapping, width).unwrap(); + assert_eq!(reversed.terms[0].0.get_pauli(0), Pauli::Y); + assert_eq!(reversed.terms[0].0.get_pauli(width - 1), Pauli::Z); + assert_eq!(reversed.remap_qubits(&mapping, width).unwrap(), a); + let tensor = a + .tensor(&Hamiltonian::from_pauli("X".parse().unwrap())) + .unwrap(); + assert_eq!(tensor.num_qubits, width + 1); + assert_eq!(tensor.terms[0].0.get_pauli(0), Pauli::X); + assert_eq!(tensor.terms[0].0.get_pauli(width), Pauli::Y); + } +} + +#[test] +fn invalid_inputs_are_rejected_even_when_other_operand_is_zero() { + let mut short = PauliString::new(1); + short.x.clear(); + let mut long = PauliString::new(1); + long.z.push(false); + for terms in [ + vec![(PauliString::new(2), 1.0.into())], + vec![(short, 1.0.into())], + vec![(long, 1.0.into())], + vec![(PauliString::new(1), f64::NAN.into())], + vec![(PauliString::new(1), f64::INFINITY.into())], + vec![(PauliString::new(1), Complex64::new(0.0, f64::NEG_INFINITY))], + ] { + let h = Hamiltonian { + num_qubits: 1, + terms: terms.clone(), + }; + let zero = Hamiltonian::new(1); + assert!(Hamiltonian::from_terms(1, terms).is_err()); + assert!(h.try_add(&zero).is_err()); + assert!(h.try_sub(&zero).is_err()); + assert!(h.try_mul(&zero).is_err()); + assert!(zero.try_mul(&h).is_err()); + assert!(h.scaled(0.0.into()).is_err()); + assert!(h.adjoint().is_err()); + assert!(h.tensor(&zero).is_err()); + assert!(zero.tensor(&h).is_err()); + assert!(h.remap_qubits(&[0], 1).is_err()); + assert!(h.equiv(&h, 0.0).is_err()); + assert!(h.is_hermitian(0.0).is_err()); + assert!(h.to_matrix().is_err()); + let mut chopped = h.clone(); + assert!(chopped.chop(0.0).is_err()); + // Compare coefficient bits because NaN is not equal to itself. + assert_eq!(chopped.num_qubits, h.num_qubits); + assert_eq!(chopped.terms.len(), h.terms.len()); + for ((actual_p, actual_c), (expected_p, expected_c)) in chopped.terms.iter().zip(&h.terms) { + assert_eq!(actual_p, expected_p); + assert_eq!(actual_c.re.to_bits(), expected_c.re.to_bits()); + assert_eq!(actual_c.im.to_bits(), expected_c.im.to_bits()); + } + } + let a = Hamiltonian::new(1); + let b = Hamiltonian::new(2); + for result in [a.try_add(&b), a.try_sub(&b), a.try_mul(&b)] { + assert!(matches!(result, Err(QisError::QubitMismatch { .. }))); + } + assert!(a.equiv(&b, 0.0).is_err()); +} + +#[test] +fn invalid_tolerances_mappings_and_scalars_do_not_mutate_input() { + let h = Hamiltonian::identity(2); + for atol in [-1.0, f64::NAN, f64::INFINITY] { + let mut chopped = h.clone(); + assert!(chopped.chop(atol).is_err()); + assert_eq!(chopped, h); + assert!(h.equiv(&h, atol).is_err()); + assert!(h.is_hermitian(atol).is_err()); + } + for mapping in [&[0][..], &[0, 0], &[0, 2]] { + assert!(h.remap_qubits(mapping, 2).is_err()); + assert!(Hamiltonian::new(2).remap_qubits(mapping, 2).is_err()); + } + for factor in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert!(Hamiltonian::new(2).scaled(factor.into()).is_err()); + } + assert!( + Hamiltonian::new(usize::MAX) + .tensor(&Hamiltonian::new(1)) + .is_err() + ); +} + +#[test] +fn unrepresentable_embeddings_return_errors_without_panicking() { + let limit = bitvec::slice::BitSlice::::MAX_BITS; + for width in [limit + 1, usize::MAX] { + // Empty source mapping is valid, but scalar identity still requires a + // target Pauli string: reject its unrepresentable width before allocation. + let scalar = Hamiltonian::identity(0); + assert!(matches!( + scalar.remap_qubits(&[], width), + Err(QisError::UnsupportedOperation(_)) + )); + assert!(matches!( + Hamiltonian::identity(1).remap_qubits(&[width - 1], width), + Err(QisError::UnsupportedOperation(_)) + )); + // Zero sums do not allocate target bit vectors and retain declared width. + assert_eq!( + Hamiltonian::new(0).remap_qubits(&[], width).unwrap(), + Hamiltonian::new(width) + ); + assert_eq!( + Hamiltonian::new(width).tensor(&scalar).unwrap(), + Hamiltonian::new(width) + ); + } +} + +#[test] +fn intermediate_and_dense_coefficient_overflow_is_reported() { + let max = Hamiltonian::identity(1).scaled(f64::MAX.into()).unwrap(); + let two = Hamiltonian::identity(1).scaled(2.0.into()).unwrap(); + for result in [ + max.try_add(&max), + max.scaled(2.0.into()), + max.try_mul(&two), + max.tensor(&two), + max.try_sub(&max.scaled((-1.0).into()).unwrap()), + ] { + assert!(matches!(result, Err(QisError::NonFiniteCoefficient { .. }))); + } + // A later cancellation cannot hide an earlier nonfinite accumulation. + let raw = Hamiltonian::from_list( + [f64::MAX, f64::MAX, -f64::MAX] + .into_iter() + .map(|c| (PauliString::new(1), c.into())) + .collect(), + ) + .unwrap(); + assert!(matches!( + raw.normalized(), + Err(QisError::NonFiniteCoefficient { .. }) + )); + let mut chopped = raw.clone(); + assert!(chopped.chop(0.0).is_err()); + assert_eq!(chopped, raw); + let dense = Hamiltonian::from_terms( + 1, + [ + ("I".parse().unwrap(), f64::MAX.into()), + ("Z".parse().unwrap(), f64::MAX.into()), + ], + ) + .unwrap(); + assert!(matches!( + dense.to_matrix(), + Err(QisError::NonFiniteCoefficient { .. }) + )); +} + +#[test] +fn dense_conversion_checks_shift_element_and_byte_overflow_before_allocation() { + // On 64-bit: 64 overflows the shift, 32 the element count, 30 the byte range. + for width in [ + usize::BITS as usize, + usize::BITS as usize / 2, + (usize::BITS as usize - 4) / 2, + usize::MAX, + ] { + assert!(matches!( + Hamiltonian::new(width).to_matrix(), + Err(QisError::UnsupportedOperation(_)) + )); + } +} + +#[test] +fn mapped_fermionic_products_and_adjoint_use_public_pauli_algebra() { + use crate::qis::{FermionToQubitMapper, FermionicOp, JordanWignerMapper}; + let mapper = JordanWignerMapper::new(); + for p in 0..3 { + for q in 0..3 { + let a = FermionicOp::creation(3, p).unwrap(); + let b = FermionicOp::annihilation(3, q).unwrap(); + let mapped_a = mapper.map_operator(&a).unwrap(); + let mapped_b = mapper.map_operator(&b).unwrap(); + assert!( + mapped_a + .try_mul(&mapped_b) + .unwrap() + .equiv(&mapper.map_operator(&a.try_mul(&b).unwrap()).unwrap(), 0.0) + .unwrap() + ); + assert!( + mapped_a + .adjoint() + .unwrap() + .equiv(&mapper.map_operator(&a.adjoint()).unwrap(), 0.0) + .unwrap() + ); + } + } +} + #[test] fn hermitian_normalization_projects_only_accepted_imaginary_residuals() { let h = Hamiltonian::from_list(vec![ diff --git a/docs/api/cn/rust/3_qis/7_hamiltonian.md b/docs/api/cn/rust/3_qis/7_hamiltonian.md index 14757c31..ce86449d 100644 --- a/docs/api/cn/rust/3_qis/7_hamiltonian.md +++ b/docs/api/cn/rust/3_qis/7_hamiltonian.md @@ -3,9 +3,9 @@ `cqlib_core::qis::hamiltonian` `cqlib_core::qis::observable` -本页介绍由 Pauli 串与复系数构成的观测量 `Hamiltonian`,以及计算期望值的统一接口 `Observable` trait。 +本页介绍由 Pauli 串与复系数构成的算符 `Hamiltonian`,以及计算实数期望值的统一接口 `Observable` trait。 -`Hamiltonian` 是 $2^N \times 2^N$ 矩阵的稀疏表示,形式为 $H = \sum_k c_k P_k$,其中 $c_k$ 是复系数,$P_k$ 是 $N$ 比特 Pauli 串。同一个结构既用于物理解释中的哈密顿量,也用于任意需要求期望值的可观测量。 +`Hamiltonian` 是 $2^N \times 2^N$ 矩阵的稀疏表示,形式为 $H = \sum_k c_k P_k$,其中 $c_k$ 是复系数,$P_k$ 是 $N$ 比特 Pauli 串。该结构允许非厄米算符;实数期望值和酉演化入口会额外检查厄米性。 ## 导入 @@ -46,10 +46,59 @@ pub struct Hamiltonian { - `fn simplify(&mut self)`:化简项列表。该操作分两步:把每个 `PauliString` 的内部相位吸收进系数,使所有 Pauli 串归一到 `Phase::Plus`;再按 X、Z 分量向量分组,合并相同算子的系数。绝对值小于 $10^{-10}$ 的系数会被丢弃。 - `fn scale(&mut self, factor: Complex64)`:把所有项的系数乘以同一个复因子。 +### 安全 Pauli 和式代数 + +以下接口返回 `Result`,验证比特数、X/Z 位向量长度、有限系数及系数乘法/累加结果。返回的算符已吸收相位、按 Z 再 X 向量确定性排序并合并重复项,且只删除精确零;不采用 `simplify()` 的默认截断。 + +| 接口 | 语义 | +| --- | --- | +| `identity(num_qubits) -> Self` | 指定寄存器上的单位算符;零比特时为标量 1。 | +| `from_terms(num_qubits, terms) -> Result` | 从 `(PauliString, Complex64)` 的 `IntoIterator` 安全构造;空输入保留声明宽度。 | +| `try_add(&self, other: &Self)` / `try_sub(&self, other: &Self)` | 同宽度加减,返回 `Result`,不修改输入。 | +| `try_mul(&self, other: &Self) -> Result` | 矩阵乘积 $AB$,右操作数先作用;允许非厄米结果。 | +| `scaled(&self, factor: Complex64) -> Result` | 返回缩放结果;零算符也验证因子。 | +| `adjoint(&self) -> Result` | 相位归一化后共轭系数,得到 $A^\dagger$。 | +| `normalized(&self) -> Result` | 验证、吸收相位、合并同类项,仅删除精确零。 | +| `chop(&mut self, atol: f64) -> Result<(), QisError>` | 先归并,再删除复数模长 `≤ atol` 的项;失败时原值不变。 | +| `equiv(&self, other: &Self, atol: f64) -> Result` | 判断归一化后 $A-B$ 的每个系数模长是否 `≤ atol`。 | +| `is_hermitian(&self, atol: f64) -> Result` | 等价于与伴随的 `equiv`,不投影到实数。 | +| `tensor(&self, other: &Self) -> Result` | $A\otimes B$,B 占低比特,A 占高比特。 | +| `remap_qubits(&self, mapping: &[usize], target_width: usize) -> Result` | `mapping[source] = destination`;支持置换和嵌入,未使用位置补 I。 | + +所有容差必须有限且非负。`equiv` 使用逐项系数容差,不是矩阵算符范数界;宽度不同返回 `QubitMismatch`,不会隐式扩展寄存器。`is_hermitian` 的容差衡量 $A-A^\dagger$,归一化后等于虚部幅值的两倍,与 `FermionicOp` 一致。已有 `normalized_hermitian()` 使用独立消费者契约:允许虚部幅值不超过 `1e-10`,将其投影到实数,但保留任何非零实系数。 + +`remap_qubits` 要求映射长度等于源宽度,目标位置不重复且小于 `target_width`,包括零/单位算符。它不表示费米子模式置换或部分迹。零算符保留寄存器宽度,稀疏运算支持超过 64 个量子比特;乘法可展开成两侧项数的乘积,仍可能产生大量项。 + +若张量或嵌入结果需要构造 Pauli 项,宽度超过 BitVec 的可表示长度时返回 `UnsupportedOperation`,不会触发长度断言。零算符不需要位向量,可以保留更大的声明宽度。 + +```rust +use cqlib_core::qis::{Hamiltonian, QisError}; +use num_complex::Complex64; + +let x = Hamiltonian::from_pauli("X".parse()?); +let z = Hamiltonian::from_pauli("Z".parse()?); +let sum = x.try_add(&z)?; +assert!(sum.try_mul(&sum)?.equiv(&Hamiltonian::identity(1).scaled(2.0.into())?, 0.0)?); + +let xz = x.try_mul(&z)?; // XZ = -iY +let minus_i_y = Hamiltonian::from_terms(1, [("Y".parse()?, Complex64::new(0.0, -1.0))])?; +assert!(xz.equiv(&minus_i_y, 0.0)?); +assert!(!xz.is_hermitian(0.0)?); + +// [X,Z],以及 X ⊗ Z:最右字符对应比特 0。 +let commutator = xz.try_sub(&z.try_mul(&x)?)?; +assert!(commutator.equiv(&minus_i_y.scaled(2.0.into())?, 0.0)?); +assert!(x.tensor(&z)?.equiv(&Hamiltonian::from_pauli("XZ".parse()?), 0.0)?); +assert!(x.remap_qubits(&[2], 3)?.equiv(&Hamiltonian::from_pauli("XII".parse()?), 0.0)?); +# Ok::<(), QisError>(()) +``` + +旧 `Add`、`scale`、`simplify` 和构造器保留原契约;新调用优先使用安全接口。`PartialEq` 比较存储结构,项顺序、重复项或相位分布不同的等价算符可能不相等,应使用 `equiv`。 + ### 查询与转换 - `fn all_terms_commute(&self) -> bool`:判断所有 Pauli 项是否两两对易。为真时 $e^{-iHt}$ 可以按项分解为精确演化。 -- `fn to_matrix(&self) -> Result, QisError>`:返回 $2^N \times 2^N$ 稠密矩阵。任一项的比特数与 `num_qubits` 不一致时返回 `QisError::QubitMismatch`。空算量返回对应维度的零矩阵。 +- `fn to_matrix(&self) -> Result, QisError>`:验证并返回 $2^N \times 2^N$ 稠密矩阵。空算符返回对应维度的零矩阵,零比特输入返回 1×1 矩阵。分配前检查维度、元素数和字节数;大小溢出或内存预留失败返回 `UnsupportedOperation`。非法结构或非有限系数(包括矩阵元素累加溢出)返回相应错误。需要 $O(4^N)$ 内存,仅适用于小系统。 - `fn to_trotter_circuit(&self, time: f64, steps: usize, mode: TrotterMode) -> Result`:见 [Trotter 演化](8_evolution.md)。 - `fn to_evolution_circuit(&self, time: f64, steps: usize, mode: TrotterMode) -> Result`:见 [Trotter 演化](8_evolution.md)。 diff --git a/docs/api/en/rust/3_qis/7_hamiltonian.md b/docs/api/en/rust/3_qis/7_hamiltonian.md index 44318a14..89721379 100644 --- a/docs/api/en/rust/3_qis/7_hamiltonian.md +++ b/docs/api/en/rust/3_qis/7_hamiltonian.md @@ -3,9 +3,9 @@ `cqlib_core::qis::hamiltonian` `cqlib_core::qis::observable` -This page covers the observable `Hamiltonian`, formed by Pauli strings and complex coefficients, and the unified interface `Observable` trait for computing expectation values. +This page covers the operator `Hamiltonian`, formed by Pauli strings and complex coefficients, and the unified `Observable` trait for computing real expectation values. -`Hamiltonian` is a sparse representation of a $2^N \times 2^N$ matrix, of the form $H = \sum_k c_k P_k$, where $c_k$ is a complex coefficient and $P_k$ is an $N$-qubit Pauli string. The same structure serves both as the Hamiltonian of a physical interpretation and as an arbitrary observable whose expectation value is needed. +`Hamiltonian` is a sparse representation of a $2^N \times 2^N$ matrix, of the form $H = \sum_k c_k P_k$, where $c_k$ is a complex coefficient and $P_k$ is an $N$-qubit Pauli string. The type permits non-Hermitian operators; real expectation and unitary evolution methods additionally check Hermiticity. ## Import @@ -46,10 +46,59 @@ Fields: - `fn simplify(&mut self)`: simplify the term list. The operation has two steps: absorb the internal phase of each `PauliString` into the coefficient, normalizing all Pauli strings to `Phase::Plus`; then group by the X and Z component vectors and merge the coefficients of identical operators. Coefficients whose absolute value is smaller than $10^{-10}$ are discarded. - `fn scale(&mut self, factor: Complex64)`: multiply the coefficients of all terms by the same complex factor. +### Checked Pauli-sum algebra + +The fallible interfaces below validate register widths, X/Z bit-vector lengths, finite coefficients and coefficient products/accumulations. Returned operators have phases absorbed, terms sorted deterministically by Z then X vectors, duplicates merged and only exact zeros removed. They do not use the default pruning threshold of `simplify()`. + +| Interface | Meaning | +| --- | --- | +| `identity(num_qubits) -> Self` | Identity on the declared register; scalar one at zero qubits. | +| `from_terms(num_qubits, terms) -> Result` | Checked construction from an `IntoIterator` of `(PauliString, Complex64)`; empty input retains the declared width. | +| `try_add(&self, other: &Self)` / `try_sub(&self, other: &Self)` | Same-width addition/subtraction returning `Result`, without modifying inputs. | +| `try_mul(&self, other: &Self) -> Result` | Matrix product $AB$: the right operand acts first. Results may be non-Hermitian. | +| `scaled(&self, factor: Complex64) -> Result` | Returns a scaled operator; checks the factor even for zero input. | +| `adjoint(&self) -> Result` | Conjugates coefficients after phase normalization to obtain $A^\dagger$. | +| `normalized(&self) -> Result` | Validates, absorbs phases, merges terms and removes only exact zeros. | +| `chop(&mut self, atol: f64) -> Result<(), QisError>` | Merges first, then removes coefficients with complex magnitude `≤ atol`; leaves the original unchanged on failure. | +| `equiv(&self, other: &Self, atol: f64) -> Result` | Checks whether every normalized coefficient of $A-B$ has magnitude `≤ atol`. | +| `is_hermitian(&self, atol: f64) -> Result` | Equivalent to `equiv` against the adjoint; does not project coefficients to real values. | +| `tensor(&self, other: &Self) -> Result` | $A\otimes B$: B occupies low qubits, A high qubits. | +| `remap_qubits(&self, mapping: &[usize], target_width: usize) -> Result` | `mapping[source] = destination`; permutations and embeddings insert I at unused positions. | + +Tolerances must be finite and nonnegative. `equiv` uses a per-coefficient tolerance, not a matrix operator-norm bound. Different widths return `QubitMismatch`; registers are never implicitly expanded. `is_hermitian` measures $A-A^\dagger$, bounding twice the imaginary coefficient magnitude after normalization, as for `FermionicOp`. The existing `normalized_hermitian()` has a separate consumer contract: imaginary magnitudes up to `1e-10` are projected to real values, while every nonzero real coefficient is retained. + +`remap_qubits` requires a mapping of the source width with distinct destinations below `target_width`, including for zero/identity operators. It is not a fermionic mode permutation or a partial trace. Zero operators retain register widths. Sparse operations support more than 64 qubits; multiplication can still expand to the product of the input term counts. + +When a tensor product or embedding requires Pauli terms, widths beyond BitVec's representable length return `UnsupportedOperation` before its length assertion can panic. Zero operators require no bit vectors and can retain larger declared widths. + +```rust +use cqlib_core::qis::{Hamiltonian, QisError}; +use num_complex::Complex64; + +let x = Hamiltonian::from_pauli("X".parse()?); +let z = Hamiltonian::from_pauli("Z".parse()?); +let sum = x.try_add(&z)?; +assert!(sum.try_mul(&sum)?.equiv(&Hamiltonian::identity(1).scaled(2.0.into())?, 0.0)?); + +let xz = x.try_mul(&z)?; // XZ = -iY +let minus_i_y = Hamiltonian::from_terms(1, [("Y".parse()?, Complex64::new(0.0, -1.0))])?; +assert!(xz.equiv(&minus_i_y, 0.0)?); +assert!(!xz.is_hermitian(0.0)?); + +// [X,Z], and X ⊗ Z: the rightmost character is qubit zero. +let commutator = xz.try_sub(&z.try_mul(&x)?)?; +assert!(commutator.equiv(&minus_i_y.scaled(2.0.into())?, 0.0)?); +assert!(x.tensor(&z)?.equiv(&Hamiltonian::from_pauli("XZ".parse()?), 0.0)?); +assert!(x.remap_qubits(&[2], 3)?.equiv(&Hamiltonian::from_pauli("XII".parse()?), 0.0)?); +# Ok::<(), QisError>(()) +``` + +Legacy `Add`, `scale`, `simplify` and constructors retain their contracts; prefer the checked interfaces for new callers. `PartialEq` compares stored structure: operators with different term orders, duplicates or phase distributions may be algebraically equivalent but unequal. Use `equiv` for that comparison. + ### Queries and conversion - `fn all_terms_commute(&self) -> bool`: determine whether all Pauli terms commute pairwise. When true, $e^{-iHt}$ can be decomposed term by term into an exact evolution. -- `fn to_matrix(&self) -> Result, QisError>`: return a dense $2^N \times 2^N$ matrix. When the qubit count of any term is inconsistent with `num_qubits`, `QisError::QubitMismatch` is returned. An empty operator returns the zero matrix of the corresponding dimension. +- `fn to_matrix(&self) -> Result, QisError>`: validate and return a dense $2^N \times 2^N$ matrix. Empty operators produce the corresponding zero matrix; zero-qubit input produces a 1×1 matrix. Dimensions, element count and byte count are checked before allocation. Size overflow or reservation failure returns `UnsupportedOperation`; malformed terms and nonfinite coefficients (including matrix-entry accumulation overflow) return the corresponding errors. Requires $O(4^N)$ memory and is intended for small systems. - `fn to_trotter_circuit(&self, time: f64, steps: usize, mode: TrotterMode) -> Result`: see [Trotter Evolution](8_evolution.md). - `fn to_evolution_circuit(&self, time: f64, steps: usize, mode: TrotterMode) -> Result`: see [Trotter Evolution](8_evolution.md). From 1a31d4e2798435397d6769459f72c83c24a338b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Mon, 12 Oct 2026 19:42:19 +0800 Subject: [PATCH 5/6] refactor(core): move regression tests into owning modules Move the P1/P2 regression suites into their QIS, noise, and ZNE test modules. Preserve all assertions and split channel completeness from noisy simulation coverage. Remove Modified declarations only from files involved in this work. Validation: 3337 core tests and 259 doctests passed; 8 existing tests ignored. Clippy with warnings denied and diff checks passed. --- crates/cqlib-core/src/device/noise.rs | 32 ++ .../error_mitigation/zne_mitigation_test.rs | 27 ++ crates/cqlib-core/src/qis/entropy_test.rs | 102 ++++++ crates/cqlib-core/src/qis/evolution_test.rs | 24 ++ crates/cqlib-core/src/qis/hamiltonian.rs | 1 - crates/cqlib-core/src/qis/hamiltonian_test.rs | 2 - crates/cqlib-core/src/qis/observable_test.rs | 159 ++++++++++ .../qis/state/density_matrix_noise_test.rs | 119 +++++++ crates/cqlib-core/src/qis/state/state_test.rs | 64 +++- crates/cqlib-core/tests/p1_regressions.rs | 243 -------------- crates/cqlib-core/tests/p2_regressions.rs | 299 ------------------ 11 files changed, 524 insertions(+), 548 deletions(-) delete mode 100644 crates/cqlib-core/tests/p1_regressions.rs delete mode 100644 crates/cqlib-core/tests/p2_regressions.rs diff --git a/crates/cqlib-core/src/device/noise.rs b/crates/cqlib-core/src/device/noise.rs index 903367f4..7b861e45 100644 --- a/crates/cqlib-core/src/device/noise.rs +++ b/crates/cqlib-core/src/device/noise.rs @@ -558,4 +558,36 @@ mod tests { result => panic!("unexpected result: {result:?}"), } } + + #[test] + fn pauli_probability_boundary_has_complete_finite_kraus_operators() { + let noise = SingleQubitNoise::Pauli { + px: 0.3, + py: 0.6, + pz: 0.1, + }; + assert!(noise.is_valid()); + let ops = noise.to_kraus(); + let mut completeness = ndarray::Array2::::zeros((2, 2)); + for op in &ops { + assert!(op.iter().all(|v| v.re.is_finite() && v.im.is_finite())); + completeness += &op.t().mapv(|v| v.conj()).dot(op); + } + for i in 0..2 { + for j in 0..2 { + assert!( + (completeness[[i, j]] - Complex64::from(if i == j { 1.0 } else { 0.0 })).norm() + < 1e-12 + ); + } + } + assert!( + !SingleQubitNoise::Pauli { + px: 0.4, + py: 0.6, + pz: 0.1 + } + .is_valid() + ); + } } diff --git a/crates/cqlib-core/src/error_mitigation/zne_mitigation_test.rs b/crates/cqlib-core/src/error_mitigation/zne_mitigation_test.rs index 282cd262..4ded2ee0 100644 --- a/crates/cqlib-core/src/error_mitigation/zne_mitigation_test.rs +++ b/crates/cqlib-core/src/error_mitigation/zne_mitigation_test.rs @@ -347,3 +347,30 @@ fn test_extrapolate_api_exponential() { .unwrap(); assert!((extrapolated - a).abs() < 1e-10); } + +#[test] +fn folding_preserves_full_matrix_including_global_phase() { + use crate::circuit::Parameter; + for gates in [false, true] { + let mut c = Circuit::from_qubits(vec![Qubit::new(5)]).unwrap(); + c.set_global_phase(Parameter::from(0.37)); + if gates { + c.h(Qubit::new(5)).unwrap(); + c.t(Qubit::new(5)).unwrap(); + } + let expected = c.to_matrix(None).unwrap(); + let zne = ZNEMitigation::new(c, vec![0, 1, 2]); + let selected = [Instruction::Standard(StandardGate::H)]; + for selection in [None, Some(selected.as_slice()), Some([].as_slice())] { + for folded in zne.fold_circuits(selection).unwrap() { + let actual = folded.to_matrix(None).unwrap(); + assert!( + actual + .iter() + .zip(expected.iter()) + .all(|(a, b)| (*a - *b).norm() < 1e-12) + ); + } + } + } +} diff --git a/crates/cqlib-core/src/qis/entropy_test.rs b/crates/cqlib-core/src/qis/entropy_test.rs index 1c8e6f24..d6d1be9c 100644 --- a/crates/cqlib-core/src/qis/entropy_test.rs +++ b/crates/cqlib-core/src/qis/entropy_test.rs @@ -761,3 +761,105 @@ fn test_eof_concurrence_relationship() { assert!(c_sep.abs() < EPSILON); assert!(eof_sep.abs() < EPSILON); } + +#[test] +fn formation_entropy_of_rotated_maximally_entangled_state() { + let state = vec![ + Complex64::new(-0.5917562523120286, -0.14345273890626087), + Complex64::new(0.12739228627789673, 0.3361800930257551), + Complex64::new(-0.06600502193827436, 0.35339664207352467), + Complex64::new(-0.5571108451004554, 0.2457267928442915), + ]; + let dm = DensityMatrix::from_state(2, state).unwrap(); + let eof = entanglement_of_formation(&dm).unwrap(); + assert!( + eof.is_finite() && (0.0..=1.0).contains(&eof), + "entropy={eof}" + ); + // Square roots amplify O(epsilon) noise in zero eigenvalues to O(sqrt(epsilon)). + // Eigensolver backends differ in the sign of this noise on rank-one states. + let tolerance = 8.0 * f64::EPSILON.sqrt(); + assert!( + (eof - 1.0).abs() < tolerance, + "entropy={eof}, tolerance={tolerance}" + ); +} + +#[test] +fn renyi_extreme_orders_and_small_positive_eigenvalues() { + for n in 1..=3 { + let dm = DensityMatrix::maximally_mixed(n); + for alpha in [ + 0.1, + 1.0 - 1e-12, + 1.0, + 1.0 + 1e-12, + 2000.0, + f64::MAX, + f64::INFINITY, + ] { + assert!( + (renyi_entropy(&dm, alpha).unwrap() - n as f64).abs() < 1e-10, + "n={n}, alpha={alpha}" + ); + assert!(renyi_entropy(&DensityMatrix::new(n), alpha).unwrap().abs() < 1e-10); + } + } + let dm = DensityMatrix::from_density_matrix_state( + 1, + vec![(1.0 - 1e-14).into(), 0.0.into(), 0.0.into(), 1e-14.into()], + ) + .unwrap(); + assert!( + (renyi_entropy(&dm, 0.1).unwrap() - 0.06257881098503021).abs() < 1e-12, + "got {}", + renyi_entropy(&dm, 0.1).unwrap() + ); + assert!(renyi_entropy(&dm, f64::NAN).is_err()); +} + +#[test] +fn renyi_entropy_of_nondiagonal_pure_state_is_zero() { + let state = [1.0, 2.0, 3.0, 4.0] + .map(|v| Complex64::new(v / 30.0_f64.sqrt(), 0.0)) + .to_vec(); + let dm = DensityMatrix::from_state(2, state).unwrap(); + for alpha in [0.01, 0.1, 0.5, 1.0, 2.0, f64::INFINITY] { + let actual = renyi_entropy(&dm, alpha).unwrap(); + assert!(actual.abs() < 1e-10, "alpha={alpha}, entropy={actual}"); + } +} + +#[test] +fn renyi_entropy_preserves_rank_deficient_spectrum_under_unitary_rotation() { + // The first two columns of the four-dimensional Fourier matrix are + // orthonormal, so this state has spectrum [0.8, 0.2, 0, 0]. + let column = [ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 1.0), + Complex64::new(-1.0, 0.0), + Complex64::new(0.0, -1.0), + ]; + let mut rho = Vec::new(); + let mut diagonal = vec![Complex64::new(0.0, 0.0); 16]; + diagonal[0] = 0.8.into(); + diagonal[5] = 0.2.into(); + for i in 0..4 { + for j in 0..4 { + rho.push((Complex64::new(0.8, 0.0) + 0.2 * column[i] * column[j].conj()) / 4.0); + } + } + for data in [diagonal, rho] { + let dm = DensityMatrix::from_density_matrix_state(2, data).unwrap(); + for alpha in [0.01, 0.1, 0.5, 1.0, 2.0, f64::INFINITY] { + let expected = if alpha == 1.0 { + -0.8_f64 * 0.8_f64.log2() - 0.2_f64 * 0.2_f64.log2() + } else if alpha == f64::INFINITY { + -0.8_f64.log2() + } else { + (0.8_f64.powf(alpha) + 0.2_f64.powf(alpha)).log2() / (1.0 - alpha) + }; + assert!((renyi_entropy(&dm, alpha).unwrap() - expected).abs() < 1e-10); + } + } +} diff --git a/crates/cqlib-core/src/qis/evolution_test.rs b/crates/cqlib-core/src/qis/evolution_test.rs index 31312e9b..9ace77e2 100644 --- a/crates/cqlib-core/src/qis/evolution_test.rs +++ b/crates/cqlib-core/src/qis/evolution_test.rs @@ -732,3 +732,27 @@ fn test_evolution_circuit_exact_matrix_correctness() { assert_abs_diff_eq!(matrix[[0, 1]].re, 0.0, epsilon = 1e-12); assert_abs_diff_eq!(matrix[[1, 0]].re, 0.0, epsilon = 1e-12); } + +#[test] +fn evolution_retains_small_coefficients_before_time_scaling() { + for coefficients in [vec![1e-11], vec![2e-11, -1e-11], vec![1e-11, -1e-11]] { + let mut h = Hamiltonian::new(1); + for &c in &coefficients { + h.add_term("Z".parse().unwrap(), c.into()).unwrap(); + } + let angle = coefficients.iter().sum::() * 1e11; + for mode in [TrotterMode::FirstOrder, TrotterMode::SecondOrder] { + for circuit in [ + h.to_trotter_circuit(1e11, 3, mode).unwrap(), + h.to_evolution_circuit(1e11, 3, mode).unwrap(), + ] { + let m = circuit_to_matrix(&circuit, None).unwrap(); + assert!((m[[0, 0]] - Complex64::from_polar(1.0, -angle)).norm() < 1e-10); + assert!((m[[1, 1]] - Complex64::from_polar(1.0, angle)).norm() < 1e-10); + assert!(m[[0, 1]].norm() < 1e-10 && m[[1, 0]].norm() < 1e-10); + } + } + h.simplify(); + assert!(h.terms.is_empty()); + } +} diff --git a/crates/cqlib-core/src/qis/hamiltonian.rs b/crates/cqlib-core/src/qis/hamiltonian.rs index 91403594..f82d67e2 100644 --- a/crates/cqlib-core/src/qis/hamiltonian.rs +++ b/crates/cqlib-core/src/qis/hamiltonian.rs @@ -17,7 +17,6 @@ //! with complex coefficients. It is primarily used for defining system energies, //! observables for expectation value calculations, and operators for time evolution. //! -//! Modified: added checked sparse algebra and validated dense conversion. //! General algebra permits non-Hermitian operators. Real observables and unitary //! evolution validate Hermiticity at their consumption boundaries. diff --git a/crates/cqlib-core/src/qis/hamiltonian_test.rs b/crates/cqlib-core/src/qis/hamiltonian_test.rs index ed4ebfa0..be4c2b05 100644 --- a/crates/cqlib-core/src/qis/hamiltonian_test.rs +++ b/crates/cqlib-core/src/qis/hamiltonian_test.rs @@ -15,8 +15,6 @@ use crate::qis::{PauliString, Phase, QisError}; use ndarray::{Array2, arr2}; use num_complex::Complex64; -// Modified: independent matrix oracles and sparse-algebra boundary coverage. - /// Test-only Kronecker product, independent of production Pauli operations. fn matrix_tensor(a: &Array2, b: &Array2) -> Array2 { Array2::from_shape_fn((a.nrows() * b.nrows(), a.ncols() * b.ncols()), |(r, c)| { diff --git a/crates/cqlib-core/src/qis/observable_test.rs b/crates/cqlib-core/src/qis/observable_test.rs index 1fc05c34..4b5b4e03 100644 --- a/crates/cqlib-core/src/qis/observable_test.rs +++ b/crates/cqlib-core/src/qis/observable_test.rs @@ -390,3 +390,162 @@ fn test_empty_hamiltonian_variance_is_zero() { fn test_variance_tiny_negative_roundoff_clamps_to_zero() { assert_close(finalize_variance(-VARIANCE_TOLERANCE / 2.0).unwrap(), 0.0); } + +#[test] +fn nonhermitian_observables_are_rejected_even_at_zero_expectation() { + let sv = Statevector::new(1); + let dm = DensityMatrix::new(1); + let probs = HashMap::from([("0".to_string(), 1.0)]); + for label in ["iZ", "-iI", "iX"] { + let p: PauliString = label.parse().unwrap(); + let h = Hamiltonian::from_pauli(p.clone()); + let measurements = vec![(p.clone(), probs.clone())]; + for obs in [&p as &dyn Observable, &h as &dyn Observable] { + assert!(matches!( + obs.expectation_statevector(&sv), + Err(QisError::NotHermitian) + )); + assert!(matches!( + obs.expectation_density_matrix(&dm), + Err(QisError::NotHermitian) + )); + assert!(matches!( + obs.expectation_probs(&measurements), + Err(QisError::NotHermitian) + )); + } + assert!(matches!(p.expectation(&probs), Err(QisError::NotHermitian))); + } +} + +#[test] +fn hermiticity_is_checked_after_phase_and_duplicate_merging() { + let sv = Statevector::new(1); + let dm = DensityMatrix::new(1); + let probs = vec![( + "Z".parse().unwrap(), + HashMap::from([("0".to_string(), 1.0)]), + )]; + for terms in [ + vec![("iZ", Complex64::new(0.0, -1.0))], + vec![ + ("Z", Complex64::new(1.0, 2.0)), + ("Z", Complex64::new(0.0, -2.0)), + ], + vec![("Z", Complex64::new(1e-11, 0.0))], + ] { + let h = Hamiltonian::from_list( + terms + .iter() + .map(|(p, c)| (p.parse().unwrap(), *c)) + .collect(), + ) + .unwrap(); + let expected = if terms[0].1.re == 1e-11 { 1e-11 } else { 1.0 }; + for actual in [ + h.expectation_statevector(&sv).unwrap(), + h.expectation_density_matrix(&dm).unwrap(), + h.expectation_probs(&probs).unwrap(), + ] { + assert!((actual - expected).abs() < 1e-14); + } + } + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let h = + Hamiltonian::from_list(vec![("Z".parse().unwrap(), Complex64::new(bad, 0.0))]).unwrap(); + assert!(matches!( + h.expectation_statevector(&sv), + Err(QisError::InvalidParameterValue(_)) + )); + assert!(matches!( + h.expectation_density_matrix(&dm), + Err(QisError::InvalidParameterValue(_)) + )); + assert!(matches!( + h.expectation_probs(&probs), + Err(QisError::InvalidParameterValue(_)) + )); + } + let h = Hamiltonian::from_pauli("iZZ".parse().unwrap()); + assert!(matches!( + h.expectation_statevector(&sv), + Err(QisError::QubitMismatch { .. }) + )); +} + +#[test] +fn hermiticity_tolerance_and_merged_overflow() { + for (imaginary, valid) in [(0.5e-10, true), (2e-10, false)] { + let h = + Hamiltonian::from_list(vec![("Z".parse().unwrap(), Complex64::new(1.0, imaginary))]) + .unwrap(); + assert_eq!( + h.expectation_statevector(&Statevector::new(1)).is_ok(), + valid + ); + assert_eq!( + h.expectation_density_matrix(&DensityMatrix::new(1)).is_ok(), + valid + ); + } + let h = Hamiltonian::from_list(vec![ + ("Z".parse().unwrap(), Complex64::new(f64::MAX, 0.0)); + 2 + ]) + .unwrap(); + assert!(matches!( + h.expectation_statevector(&Statevector::new(1)), + Err(QisError::InvalidParameterValue(_)) + )); +} + +#[test] +fn variance_validates_coefficients_before_merging_and_retains_small_terms() { + let sv = Statevector::new(1); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + for coeff in [Complex64::new(bad, 0.0), Complex64::new(0.0, bad)] { + let h = Hamiltonian::from_list(vec![("Z".parse().unwrap(), coeff)]).unwrap(); + assert!(matches!( + h.variance_statevector(&sv), + Err(QisError::InvalidParameterValue(_)) + )); + } + } + let h = Hamiltonian::from_list(vec![ + ("Z".parse().unwrap(), Complex64::new(f64::MAX, 0.0)); + 2 + ]) + .unwrap(); + assert!(matches!( + h.variance_statevector(&sv), + Err(QisError::InvalidParameterValue(_)) + )); + for terms in [ + vec![("iX", Complex64::new(0.0, -1.0))], + vec![ + ("X", Complex64::new(1.0, 2.0)), + ("X", Complex64::new(0.0, -2.0)), + ], + ] { + let h = Hamiltonian::from_list( + terms + .into_iter() + .map(|(p, c)| (p.parse().unwrap(), c)) + .collect(), + ) + .unwrap(); + assert!((h.variance_statevector(&sv).unwrap() - 1.0).abs() < 1e-12); + } + let h = + Hamiltonian::from_list(vec![("X".parse().unwrap(), Complex64::new(1e-11, 0.0))]).unwrap(); + assert!((h.variance_statevector(&sv).unwrap() - 1e-22).abs() < 1e-34); + let invalid = Hamiltonian::from_pauli("iZ".parse().unwrap()); + assert!(matches!( + invalid.variance_statevector(&sv), + Err(QisError::NotHermitian) + )); + assert!(matches!( + invalid.variance_statevector(&Statevector::new(2)), + Err(QisError::QubitMismatch { .. }) + )); +} 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 9c0bbfa9..82775269 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 @@ -1459,3 +1459,122 @@ fn test_ccx_with_noise_model_does_not_panic() { "CCX should produce support on |111>, got {probs:?}" ); } + +#[test] +fn pauli_probability_boundary_preserves_simulation_trace() { + let noise = SingleQubitNoise::Pauli { + px: 0.3, + py: 0.6, + pz: 0.1, + }; + let mut model = NoiseModel::new(); + model + .add_single_qubit_error(StandardGate::X, Qubit::new(0), noise) + .unwrap(); + let mut sim = DensityMatrixNoise::new(1, Some(model)); + sim.apply_x(0).unwrap(); + assert!((sim.probabilities()[0] - 0.9).abs() < 1e-12); + assert!((sim.probabilities().iter().sum::() - 1.0).abs() < 1e-12); +} + +#[test] +fn gate_noise_uses_original_labels_and_operand_order() { + for ids in [[0, 1], [5, 12], [12, 5]] { + let qs = ids.map(Qubit::new); + let mut circuit = Circuit::from_qubits(qs.to_vec()).unwrap(); + circuit.x(qs[0]).unwrap(); + let mut model = NoiseModel::new(); + model + .add_single_qubit_error(StandardGate::X, qs[0], SingleQubitNoise::BitFlip(1.0)) + .unwrap(); + let mut sim = DensityMatrixNoise::from_circuit(&circuit, Some(model)).unwrap(); + assert!((sim.probabilities()[0] - 1.0).abs() < 1e-12); + sim.apply_x(0).unwrap(); + assert!((sim.probabilities()[0] - 1.0).abs() < 1e-12); + for reverse in [false, true] { + let mut circuit = Circuit::from_qubits(qs.to_vec()).unwrap(); + let [control, target] = if reverse { [qs[1], qs[0]] } else { qs }; + circuit.cx(control, target).unwrap(); + let mut model = NoiseModel::new(); + model + .add_two_qubit_error( + StandardGate::CX, + control, + target, + TwoQubitNoise::Independent { + q0_noise: SingleQubitNoise::BitFlip(1.0), + q1_noise: SingleQubitNoise::BitFlip(0.0), + }, + ) + .unwrap(); + let sim = DensityMatrixNoise::from_circuit(&circuit, Some(model)).unwrap(); + let expected = if reverse { 2 } else { 1 }; + assert!((sim.probabilities()[expected] - 1.0).abs() < 1e-12); + } + } +} + +#[test] +fn failed_noisy_circuit_preserves_label_binding() { + use crate::circuit::Parameter; + let q = Qubit::new(5); + let mut bad = Circuit::from_qubits(vec![q]).unwrap(); + bad.x(q).unwrap(); + bad.rz(q, Parameter::symbol("unbound")).unwrap(); + let mut model = NoiseModel::new(); + model + .add_single_qubit_error(StandardGate::X, q, SingleQubitNoise::BitFlip(1.0)) + .unwrap(); + let mut sim = DensityMatrixNoise::new(1, Some(model.clone())); + assert!(sim.apply_circuit(&bad).is_err()); + // Completed gates used label 5, but the failed application did not bind it. + assert!((sim.probabilities()[0] - 1.0).abs() < 1e-12); + sim.apply_circuit(&Circuit::new(1)).unwrap(); + + let mut good = Circuit::from_qubits(vec![q]).unwrap(); + good.x(q).unwrap(); + let mut bound = DensityMatrixNoise::from_circuit(&good, Some(model)).unwrap(); + assert!(bound.apply_circuit(&bad).is_err()); + bound.apply_x(0).unwrap(); + assert!((bound.probabilities()[0] - 1.0).abs() < 1e-12); + assert!(bound.apply_circuit(&Circuit::new(1)).is_err()); +} + +#[test] +fn identity_noise_respects_labels_and_repetition() { + for ids in [[0, 1], [12, 5]] { + for p in [0.0, 0.25, 1.0] { + for count in [1, 2, 3] { + let qs = ids.map(Qubit::new); + let mut c = Circuit::from_qubits(qs.to_vec()).unwrap(); + for _ in 0..count { + c.i(qs[1]).unwrap(); + } + let mut model = NoiseModel::new(); + model + .add_single_qubit_error(StandardGate::I, qs[1], SingleQubitNoise::BitFlip(p)) + .unwrap(); + let sim = DensityMatrixNoise::from_circuit(&c, Some(model)).unwrap(); + let flipped = (1.0 - (1.0 - 2.0 * p).powi(count)) / 2.0; + for (actual, expected) in + sim.probabilities() + .iter() + .zip([1.0 - flipped, 0.0, flipped, 0.0]) + { + assert!((actual - expected).abs() < 1e-12); + } + assert_eq!( + DensityMatrixNoise::from_circuit(&c, None) + .unwrap() + .probabilities(), + vec![1.0, 0.0, 0.0, 0.0] + ); + } + } + } + let mut sim = DensityMatrixNoise::new(1, None); + assert!( + sim.apply_standard_gate_noise(StandardGate::I, &[1], &[]) + .is_err() + ); +} diff --git a/crates/cqlib-core/src/qis/state/state_test.rs b/crates/cqlib-core/src/qis/state/state_test.rs index 86e40296..2c707bb5 100644 --- a/crates/cqlib-core/src/qis/state/state_test.rs +++ b/crates/cqlib-core/src/qis/state/state_test.rs @@ -1,5 +1,4 @@ // This code is part of Cqlib. -// Modified to support reproducible state sampling. // // (C) Copyright China Telecom Quantum Group 2026 // @@ -10,8 +9,6 @@ // 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. -// -// Modified to validate terminal measurements and clarify or correct reset behavior. //! Simulation consistency tests between Statevector and DensityMatrix @@ -875,3 +872,64 @@ fn full_measurement_uses_the_supplied_random_stream() { ); } } + +#[test] +fn checked_constructors_reject_dimensions_without_allocating() { + for n in [usize::BITS as usize - 4, usize::BITS as usize, usize::MAX] { + assert!(Statevector::try_new(n).is_err()); + assert!(Statevector::from_state(n, vec![]).is_err()); + } + for n in [ + (usize::BITS as usize - 4).div_ceil(2), + usize::BITS as usize, + usize::MAX, + ] { + assert!(DensityMatrix::try_new(n).is_err()); + assert!(DensityMatrixNoise::try_new(n, None).is_err()); + assert!(DensityMatrix::from_state(n, vec![]).is_err()); + assert!(DensityMatrix::from_density_matrix_state(n, vec![]).is_err()); + assert!(DensityMatrix::try_maximally_mixed(n).is_err()); + } + for n in [0, 1, 3] { + assert_eq!( + Statevector::try_new(n).unwrap().data(), + Statevector::new(n).data() + ); + assert_eq!( + DensityMatrix::try_new(n).unwrap().data(), + DensityMatrix::new(n).data() + ); + assert!( + (DensityMatrixNoise::try_new(n, None) + .unwrap() + .probabilities()[0] + - 1.0) + .abs() + < 1e-12 + ); + } +} + +#[test] +fn nonfinite_pure_states_are_rejected() { + use num_complex::Complex64; + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX] { + for amp in [Complex64::new(bad, 0.0), Complex64::new(0.0, bad)] { + let state = vec![amp, Complex64::new(0.0, 0.0)]; + assert!(matches!( + Statevector::from_state(1, state.clone()), + Err(QisError::NotNormalized) + )); + assert!(matches!( + DensityMatrix::from_state(1, state), + Err(QisError::NotNormalized) + )); + } + } + for n in [0, 1] { + let mut v = vec![Complex64::new(0.0, 0.0); 1 << n]; + v[0] = Complex64::new(0.0, 1.0); + assert!(Statevector::from_state(n, v.clone()).is_ok()); + assert!(DensityMatrix::from_state(n, v).is_ok()); + } +} diff --git a/crates/cqlib-core/tests/p1_regressions.rs b/crates/cqlib-core/tests/p1_regressions.rs deleted file mode 100644 index 252fc083..00000000 --- a/crates/cqlib-core/tests/p1_regressions.rs +++ /dev/null @@ -1,243 +0,0 @@ -// 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. - -//! Regression tests for numerical boundaries and labelled noisy simulation. -use cqlib_core::circuit::{Circuit, Qubit, StandardGate, circuit_to_matrix}; -use cqlib_core::device::{ - NoiseModel, - noise::{SingleQubitNoise, TwoQubitNoise}, -}; -use cqlib_core::qis::entropy::{entanglement_of_formation, renyi_entropy}; -use cqlib_core::qis::evolution::TrotterMode; -use cqlib_core::qis::{DensityMatrix, DensityMatrixNoise, Hamiltonian}; -use num_complex::Complex64; - -#[test] -fn pauli_probability_boundary_is_trace_preserving() { - let noise = SingleQubitNoise::Pauli { - px: 0.3, - py: 0.6, - pz: 0.1, - }; - assert!(noise.is_valid()); - let ops = noise.to_kraus(); - let mut completeness = ndarray::Array2::::zeros((2, 2)); - for op in &ops { - assert!(op.iter().all(|v| v.re.is_finite() && v.im.is_finite())); - completeness += &op.t().mapv(|v| v.conj()).dot(op); - } - for i in 0..2 { - for j in 0..2 { - assert!( - (completeness[[i, j]] - Complex64::from(if i == j { 1.0 } else { 0.0 })).norm() - < 1e-12 - ); - } - } - let mut model = NoiseModel::new(); - model - .add_single_qubit_error(StandardGate::X, Qubit::new(0), noise) - .unwrap(); - let mut sim = DensityMatrixNoise::new(1, Some(model)); - sim.apply_x(0).unwrap(); - assert!((sim.probabilities()[0] - 0.9).abs() < 1e-12); - assert!((sim.probabilities().iter().sum::() - 1.0).abs() < 1e-12); - assert!( - !SingleQubitNoise::Pauli { - px: 0.4, - py: 0.6, - pz: 0.1 - } - .is_valid() - ); -} - -#[test] -fn gate_noise_uses_original_labels_and_operand_order() { - for ids in [[0, 1], [5, 12], [12, 5]] { - let qs = ids.map(Qubit::new); - let mut circuit = Circuit::from_qubits(qs.to_vec()).unwrap(); - circuit.x(qs[0]).unwrap(); - let mut model = NoiseModel::new(); - model - .add_single_qubit_error(StandardGate::X, qs[0], SingleQubitNoise::BitFlip(1.0)) - .unwrap(); - let mut sim = DensityMatrixNoise::from_circuit(&circuit, Some(model)).unwrap(); - assert!((sim.probabilities()[0] - 1.0).abs() < 1e-12); - sim.apply_x(0).unwrap(); - assert!((sim.probabilities()[0] - 1.0).abs() < 1e-12); - for reverse in [false, true] { - let mut circuit = Circuit::from_qubits(qs.to_vec()).unwrap(); - let [control, target] = if reverse { [qs[1], qs[0]] } else { qs }; - circuit.cx(control, target).unwrap(); - let mut model = NoiseModel::new(); - model - .add_two_qubit_error( - StandardGate::CX, - control, - target, - TwoQubitNoise::Independent { - q0_noise: SingleQubitNoise::BitFlip(1.0), - q1_noise: SingleQubitNoise::BitFlip(0.0), - }, - ) - .unwrap(); - let sim = DensityMatrixNoise::from_circuit(&circuit, Some(model)).unwrap(); - let expected = if reverse { 2 } else { 1 }; - assert!((sim.probabilities()[expected] - 1.0).abs() < 1e-12); - } - } -} - -#[test] -fn formation_entropy_of_rotated_maximally_entangled_state() { - let state = vec![ - Complex64::new(-0.5917562523120286, -0.14345273890626087), - Complex64::new(0.12739228627789673, 0.3361800930257551), - Complex64::new(-0.06600502193827436, 0.35339664207352467), - Complex64::new(-0.5571108451004554, 0.2457267928442915), - ]; - let dm = DensityMatrix::from_state(2, state).unwrap(); - let eof = entanglement_of_formation(&dm).unwrap(); - assert!( - eof.is_finite() && (0.0..=1.0).contains(&eof), - "entropy={eof}" - ); - // Square roots amplify O(epsilon) noise in zero eigenvalues to O(sqrt(epsilon)). - // Eigensolver backends differ in the sign of this noise on rank-one states. - let tolerance = 8.0 * f64::EPSILON.sqrt(); - assert!( - (eof - 1.0).abs() < tolerance, - "entropy={eof}, tolerance={tolerance}" - ); -} - -#[test] -fn renyi_extreme_orders_and_small_positive_eigenvalues() { - for n in 1..=3 { - let dm = DensityMatrix::maximally_mixed(n); - for alpha in [ - 0.1, - 1.0 - 1e-12, - 1.0, - 1.0 + 1e-12, - 2000.0, - f64::MAX, - f64::INFINITY, - ] { - assert!( - (renyi_entropy(&dm, alpha).unwrap() - n as f64).abs() < 1e-10, - "n={n}, alpha={alpha}" - ); - assert!(renyi_entropy(&DensityMatrix::new(n), alpha).unwrap().abs() < 1e-10); - } - } - let dm = DensityMatrix::from_density_matrix_state( - 1, - vec![(1.0 - 1e-14).into(), 0.0.into(), 0.0.into(), 1e-14.into()], - ) - .unwrap(); - assert!( - (renyi_entropy(&dm, 0.1).unwrap() - 0.06257881098503021).abs() < 1e-12, - "got {}", - renyi_entropy(&dm, 0.1).unwrap() - ); - assert!(renyi_entropy(&dm, f64::NAN).is_err()); -} - -#[test] -fn evolution_retains_small_coefficients_before_time_scaling() { - for coefficients in [vec![1e-11], vec![2e-11, -1e-11], vec![1e-11, -1e-11]] { - let mut h = Hamiltonian::new(1); - for &c in &coefficients { - h.add_term("Z".parse().unwrap(), c.into()).unwrap(); - } - let angle = coefficients.iter().sum::() * 1e11; - for mode in [TrotterMode::FirstOrder, TrotterMode::SecondOrder] { - for circuit in [ - h.to_trotter_circuit(1e11, 3, mode).unwrap(), - h.to_evolution_circuit(1e11, 3, mode).unwrap(), - ] { - let m = circuit_to_matrix(&circuit, None).unwrap(); - assert!((m[[0, 0]] - Complex64::from_polar(1.0, -angle)).norm() < 1e-10); - assert!((m[[1, 1]] - Complex64::from_polar(1.0, angle)).norm() < 1e-10); - assert!(m[[0, 1]].norm() < 1e-10 && m[[1, 0]].norm() < 1e-10); - } - } - h.simplify(); - assert!(h.terms.is_empty()); - } -} - -#[test] -fn checked_constructors_reject_dimensions_without_allocating() { - use cqlib_core::qis::Statevector; - for n in [usize::BITS as usize - 4, usize::BITS as usize, usize::MAX] { - assert!(Statevector::try_new(n).is_err()); - assert!(Statevector::from_state(n, vec![]).is_err()); - } - for n in [ - (usize::BITS as usize - 4).div_ceil(2), - usize::BITS as usize, - usize::MAX, - ] { - assert!(DensityMatrix::try_new(n).is_err()); - assert!(DensityMatrixNoise::try_new(n, None).is_err()); - assert!(DensityMatrix::from_state(n, vec![]).is_err()); - assert!(DensityMatrix::from_density_matrix_state(n, vec![]).is_err()); - assert!(DensityMatrix::try_maximally_mixed(n).is_err()); - } - for n in [0, 1, 3] { - assert_eq!( - Statevector::try_new(n).unwrap().data(), - Statevector::new(n).data() - ); - assert_eq!( - DensityMatrix::try_new(n).unwrap().data(), - DensityMatrix::new(n).data() - ); - assert!( - (DensityMatrixNoise::try_new(n, None) - .unwrap() - .probabilities()[0] - - 1.0) - .abs() - < 1e-12 - ); - } -} - -#[test] -fn failed_noisy_circuit_preserves_label_binding() { - use cqlib_core::circuit::Parameter; - let q = Qubit::new(5); - let mut bad = Circuit::from_qubits(vec![q]).unwrap(); - bad.x(q).unwrap(); - bad.rz(q, Parameter::symbol("unbound")).unwrap(); - let mut model = NoiseModel::new(); - model - .add_single_qubit_error(StandardGate::X, q, SingleQubitNoise::BitFlip(1.0)) - .unwrap(); - let mut sim = DensityMatrixNoise::new(1, Some(model.clone())); - assert!(sim.apply_circuit(&bad).is_err()); - // Completed gates used label 5, but the failed application did not bind it. - assert!((sim.probabilities()[0] - 1.0).abs() < 1e-12); - sim.apply_circuit(&Circuit::new(1)).unwrap(); - - let mut good = Circuit::from_qubits(vec![q]).unwrap(); - good.x(q).unwrap(); - let mut bound = DensityMatrixNoise::from_circuit(&good, Some(model)).unwrap(); - assert!(bound.apply_circuit(&bad).is_err()); - bound.apply_x(0).unwrap(); - assert!((bound.probabilities()[0] - 1.0).abs() < 1e-12); - assert!(bound.apply_circuit(&Circuit::new(1)).is_err()); -} diff --git a/crates/cqlib-core/tests/p2_regressions.rs b/crates/cqlib-core/tests/p2_regressions.rs deleted file mode 100644 index ffa6faaa..00000000 --- a/crates/cqlib-core/tests/p2_regressions.rs +++ /dev/null @@ -1,299 +0,0 @@ -// 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. - -//! Regressions for noisy identity gates, observable validation and circuit phase. -use cqlib_core::circuit::{Circuit, Instruction, Parameter, Qubit, StandardGate}; -use cqlib_core::device::{NoiseModel, noise::SingleQubitNoise}; -use cqlib_core::error_mitigation::ZNEMitigation; -use cqlib_core::qis::entropy::renyi_entropy; -use cqlib_core::qis::error::QisError; -use cqlib_core::qis::{ - DensityMatrix, DensityMatrixNoise, Hamiltonian, Observable, PauliString, Statevector, -}; -use num_complex::Complex64 as C; -use std::collections::HashMap; - -#[test] -fn identity_noise_respects_labels_and_repetition() { - for ids in [[0, 1], [12, 5]] { - for p in [0.0, 0.25, 1.0] { - for count in [1, 2, 3] { - let qs = ids.map(Qubit::new); - let mut c = Circuit::from_qubits(qs.to_vec()).unwrap(); - for _ in 0..count { - c.i(qs[1]).unwrap(); - } - let mut model = NoiseModel::new(); - model - .add_single_qubit_error(StandardGate::I, qs[1], SingleQubitNoise::BitFlip(p)) - .unwrap(); - let sim = DensityMatrixNoise::from_circuit(&c, Some(model)).unwrap(); - let flipped = (1.0 - (1.0 - 2.0 * p).powi(count)) / 2.0; - for (actual, expected) in - sim.probabilities() - .iter() - .zip([1.0 - flipped, 0.0, flipped, 0.0]) - { - assert!((actual - expected).abs() < 1e-12); - } - assert_eq!( - DensityMatrixNoise::from_circuit(&c, None) - .unwrap() - .probabilities(), - vec![1.0, 0.0, 0.0, 0.0] - ); - } - } - } - let mut sim = DensityMatrixNoise::new(1, None); - assert!( - sim.apply_standard_gate_noise(StandardGate::I, &[1], &[]) - .is_err() - ); -} - -#[test] -fn nonfinite_pure_states_are_rejected() { - for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX] { - for amp in [C::new(bad, 0.0), C::new(0.0, bad)] { - let state = vec![amp, C::new(0.0, 0.0)]; - assert!(matches!( - Statevector::from_state(1, state.clone()), - Err(QisError::NotNormalized) - )); - assert!(matches!( - DensityMatrix::from_state(1, state), - Err(QisError::NotNormalized) - )); - } - } - for n in [0, 1] { - let mut v = vec![C::new(0.0, 0.0); 1 << n]; - v[0] = C::new(0.0, 1.0); - assert!(Statevector::from_state(n, v.clone()).is_ok()); - assert!(DensityMatrix::from_state(n, v).is_ok()); - } -} - -#[test] -fn nonhermitian_observables_are_rejected_even_at_zero_expectation() { - let sv = Statevector::new(1); - let dm = DensityMatrix::new(1); - let probs = HashMap::from([("0".to_string(), 1.0)]); - for label in ["iZ", "-iI", "iX"] { - let p: PauliString = label.parse().unwrap(); - let h = Hamiltonian::from_pauli(p.clone()); - let measurements = vec![(p.clone(), probs.clone())]; - for obs in [&p as &dyn Observable, &h as &dyn Observable] { - assert!(matches!( - obs.expectation_statevector(&sv), - Err(QisError::NotHermitian) - )); - assert!(matches!( - obs.expectation_density_matrix(&dm), - Err(QisError::NotHermitian) - )); - assert!(matches!( - obs.expectation_probs(&measurements), - Err(QisError::NotHermitian) - )); - } - assert!(matches!(p.expectation(&probs), Err(QisError::NotHermitian))); - } -} - -#[test] -fn hermiticity_is_checked_after_phase_and_duplicate_merging() { - let sv = Statevector::new(1); - let dm = DensityMatrix::new(1); - let probs = vec![( - "Z".parse().unwrap(), - HashMap::from([("0".to_string(), 1.0)]), - )]; - for terms in [ - vec![("iZ", C::new(0.0, -1.0))], - vec![("Z", C::new(1.0, 2.0)), ("Z", C::new(0.0, -2.0))], - vec![("Z", C::new(1e-11, 0.0))], - ] { - let h = Hamiltonian::from_list( - terms - .iter() - .map(|(p, c)| (p.parse().unwrap(), *c)) - .collect(), - ) - .unwrap(); - let expected = if terms[0].1.re == 1e-11 { 1e-11 } else { 1.0 }; - for actual in [ - h.expectation_statevector(&sv).unwrap(), - h.expectation_density_matrix(&dm).unwrap(), - h.expectation_probs(&probs).unwrap(), - ] { - assert!((actual - expected).abs() < 1e-14); - } - } - for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { - let h = Hamiltonian::from_list(vec![("Z".parse().unwrap(), C::new(bad, 0.0))]).unwrap(); - assert!(matches!( - h.expectation_statevector(&sv), - Err(QisError::InvalidParameterValue(_)) - )); - assert!(matches!( - h.expectation_density_matrix(&dm), - Err(QisError::InvalidParameterValue(_)) - )); - assert!(matches!( - h.expectation_probs(&probs), - Err(QisError::InvalidParameterValue(_)) - )); - } - let h = Hamiltonian::from_pauli("iZZ".parse().unwrap()); - assert!(matches!( - h.expectation_statevector(&sv), - Err(QisError::QubitMismatch { .. }) - )); -} - -#[test] -fn folding_preserves_full_matrix_including_global_phase() { - for gates in [false, true] { - let mut c = Circuit::from_qubits(vec![Qubit::new(5)]).unwrap(); - c.set_global_phase(Parameter::from(0.37)); - if gates { - c.h(Qubit::new(5)).unwrap(); - c.t(Qubit::new(5)).unwrap(); - } - let expected = c.to_matrix(None).unwrap(); - let zne = ZNEMitigation::new(c, vec![0, 1, 2]); - let selected = [Instruction::Standard(StandardGate::H)]; - for selection in [None, Some(selected.as_slice()), Some([].as_slice())] { - for folded in zne.fold_circuits(selection).unwrap() { - let actual = folded.to_matrix(None).unwrap(); - assert!( - actual - .iter() - .zip(expected.iter()) - .all(|(a, b)| (*a - *b).norm() < 1e-12) - ); - } - } - } -} - -#[test] -fn hermiticity_tolerance_and_merged_overflow() { - for (imaginary, valid) in [(0.5e-10, true), (2e-10, false)] { - let h = - Hamiltonian::from_list(vec![("Z".parse().unwrap(), C::new(1.0, imaginary))]).unwrap(); - assert_eq!( - h.expectation_statevector(&Statevector::new(1)).is_ok(), - valid - ); - assert_eq!( - h.expectation_density_matrix(&DensityMatrix::new(1)).is_ok(), - valid - ); - } - let h = Hamiltonian::from_list(vec![("Z".parse().unwrap(), C::new(f64::MAX, 0.0)); 2]).unwrap(); - assert!(matches!( - h.expectation_statevector(&Statevector::new(1)), - Err(QisError::InvalidParameterValue(_)) - )); -} - -#[test] -fn renyi_entropy_of_nondiagonal_pure_state_is_zero() { - let state = [1.0, 2.0, 3.0, 4.0] - .map(|v| C::new(v / 30.0_f64.sqrt(), 0.0)) - .to_vec(); - let dm = DensityMatrix::from_state(2, state).unwrap(); - for alpha in [0.01, 0.1, 0.5, 1.0, 2.0, f64::INFINITY] { - let actual = renyi_entropy(&dm, alpha).unwrap(); - assert!(actual.abs() < 1e-10, "alpha={alpha}, entropy={actual}"); - } -} - -#[test] -fn renyi_entropy_preserves_rank_deficient_spectrum_under_unitary_rotation() { - // The first two columns of the four-dimensional Fourier matrix are - // orthonormal, so this state has spectrum [0.8, 0.2, 0, 0]. - let column = [ - C::new(1.0, 0.0), - C::new(0.0, 1.0), - C::new(-1.0, 0.0), - C::new(0.0, -1.0), - ]; - let mut rho = Vec::new(); - let mut diagonal = vec![C::new(0.0, 0.0); 16]; - diagonal[0] = 0.8.into(); - diagonal[5] = 0.2.into(); - for i in 0..4 { - for j in 0..4 { - rho.push((C::new(0.8, 0.0) + 0.2 * column[i] * column[j].conj()) / 4.0); - } - } - for data in [diagonal, rho] { - let dm = DensityMatrix::from_density_matrix_state(2, data).unwrap(); - for alpha in [0.01, 0.1, 0.5, 1.0, 2.0, f64::INFINITY] { - let expected = if alpha == 1.0 { - -0.8_f64 * 0.8_f64.log2() - 0.2_f64 * 0.2_f64.log2() - } else if alpha == f64::INFINITY { - -0.8_f64.log2() - } else { - (0.8_f64.powf(alpha) + 0.2_f64.powf(alpha)).log2() / (1.0 - alpha) - }; - assert!((renyi_entropy(&dm, alpha).unwrap() - expected).abs() < 1e-10); - } - } -} - -#[test] -fn variance_validates_coefficients_before_merging_and_retains_small_terms() { - let sv = Statevector::new(1); - for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { - for coeff in [C::new(bad, 0.0), C::new(0.0, bad)] { - let h = Hamiltonian::from_list(vec![("Z".parse().unwrap(), coeff)]).unwrap(); - assert!(matches!( - h.variance_statevector(&sv), - Err(QisError::InvalidParameterValue(_)) - )); - } - } - let h = Hamiltonian::from_list(vec![("Z".parse().unwrap(), C::new(f64::MAX, 0.0)); 2]).unwrap(); - assert!(matches!( - h.variance_statevector(&sv), - Err(QisError::InvalidParameterValue(_)) - )); - for terms in [ - vec![("iX", C::new(0.0, -1.0))], - vec![("X", C::new(1.0, 2.0)), ("X", C::new(0.0, -2.0))], - ] { - let h = Hamiltonian::from_list( - terms - .into_iter() - .map(|(p, c)| (p.parse().unwrap(), c)) - .collect(), - ) - .unwrap(); - assert!((h.variance_statevector(&sv).unwrap() - 1.0).abs() < 1e-12); - } - let h = Hamiltonian::from_list(vec![("X".parse().unwrap(), C::new(1e-11, 0.0))]).unwrap(); - assert!((h.variance_statevector(&sv).unwrap() - 1e-22).abs() < 1e-34); - let invalid = Hamiltonian::from_pauli("iZ".parse().unwrap()); - assert!(matches!( - invalid.variance_statevector(&sv), - Err(QisError::NotHermitian) - )); - assert!(matches!( - invalid.variance_statevector(&Statevector::new(2)), - Err(QisError::QubitMismatch { .. }) - )); -} From 8e4cb79d0d67e04d73a52d41344099a359c6fe4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Mon, 12 Oct 2026 20:26:01 +0800 Subject: [PATCH 6/6] feat(python): expose fermionic and checked Pauli algebra Expose structured fermionic operators, excitation generators, Jordan-Wigner mapping and structural mapper protocols through the Python API. Add checked sparse Pauli algebra while preserving legacy Hamiltonian behavior. Align integer conversion, reflected arithmetic, runtime signatures and type declarations with Python conventions. Apply circuit global phase during statevector simulation and preserve nonzero phase residuals. Add numeric and algebraic regression tests and update Chinese and English API documentation. --- .../circuit/ansatz/hamiltonian_evolution.pyi | 7 +- crates/binding-python/cqlib/qis/__init__.py | 15 + crates/binding-python/cqlib/qis/__init__.pyi | 15 + crates/binding-python/cqlib/qis/fermion.py | 65 +++ crates/binding-python/cqlib/qis/fermion.pyi | 149 ++++++ .../binding-python/cqlib/qis/hamiltonian.py | 4 + .../binding-python/cqlib/qis/hamiltonian.pyi | 56 +- .../circuit/ansatz/hamiltonian_evolution.rs | 5 +- crates/binding-python/src/qis/fermion.rs | 506 ++++++++++++++++++ crates/binding-python/src/qis/hamiltonian.rs | 186 ++++++- crates/binding-python/src/qis/mod.rs | 6 +- .../tests/parameter/test_arithmetic.py | 22 + .../binding-python/tests/qis/test_fermion.py | 378 +++++++++++++ .../tests/qis/test_hamiltonian.py | 166 ++++++ .../cqlib-core/src/qis/state/statevector.rs | 13 +- .../src/qis/state/statevector_test.rs | 29 + docs/api/cn/python/3_qis/0_overview.md | 2 + docs/api/cn/python/3_qis/10_fermion.md | 72 +++ docs/api/cn/python/3_qis/7_hamiltonian.md | 36 ++ docs/api/en/python/3_qis/0_overview.md | 2 + docs/api/en/python/3_qis/10_fermion.md | 70 +++ docs/api/en/python/3_qis/7_hamiltonian.md | 36 ++ 22 files changed, 1821 insertions(+), 19 deletions(-) create mode 100644 crates/binding-python/cqlib/qis/fermion.py create mode 100644 crates/binding-python/cqlib/qis/fermion.pyi create mode 100644 crates/binding-python/src/qis/fermion.rs create mode 100644 crates/binding-python/tests/qis/test_fermion.py create mode 100644 docs/api/cn/python/3_qis/10_fermion.md create mode 100644 docs/api/en/python/3_qis/10_fermion.md diff --git a/crates/binding-python/cqlib/circuit/ansatz/hamiltonian_evolution.pyi b/crates/binding-python/cqlib/circuit/ansatz/hamiltonian_evolution.pyi index c68ff21c..a64c9cc4 100644 --- a/crates/binding-python/cqlib/circuit/ansatz/hamiltonian_evolution.pyi +++ b/crates/binding-python/cqlib/circuit/ansatz/hamiltonian_evolution.pyi @@ -331,9 +331,10 @@ class PauliEvolutionAnsatz: def __init__(self, hamiltonian: Hamiltonian) -> None: """Creates a new ``PauliEvolutionAnsatz``. - The Hamiltonian is automatically **simplified** before processing: - Pauli phases are absorbed into coefficients, duplicate terms are merged, - and near-zero terms are removed. The default strategy is + The Hamiltonian is normalized before processing: Pauli phases are absorbed, + duplicate terms are merged, and imaginary residuals of magnitude <= 1e-10 + are projected to zero. Larger imaginary parts are rejected; every nonzero + real coefficient is retained. The default strategy is :meth:`EvolutionStrategy.auto` with ``steps=1``. Args: diff --git a/crates/binding-python/cqlib/qis/__init__.py b/crates/binding-python/cqlib/qis/__init__.py index 37eff524..bcadae72 100644 --- a/crates/binding-python/cqlib/qis/__init__.py +++ b/crates/binding-python/cqlib/qis/__init__.py @@ -65,6 +65,15 @@ # Hamiltonian module from .hamiltonian import Hamiltonian as Hamiltonian +from . import fermion as fermion +from .fermion import ( + FermionicLadder as FermionicLadder, + FermionicOp as FermionicOp, + FermionToQubitMapper as FermionToQubitMapper, + OccupationStateMapper as OccupationStateMapper, + JordanWignerMapper as JordanWignerMapper, +) + # Evolution module from .evolution import TrotterMode as TrotterMode @@ -103,6 +112,12 @@ def num_qubits(self) -> int: ... "Pauli", "PauliString", "Hamiltonian", + "fermion", + "FermionicLadder", + "FermionicOp", + "FermionToQubitMapper", + "OccupationStateMapper", + "JordanWignerMapper", "TrotterMode", "state", "Statevector", diff --git a/crates/binding-python/cqlib/qis/__init__.pyi b/crates/binding-python/cqlib/qis/__init__.pyi index fb4dbe49..e3df9927 100644 --- a/crates/binding-python/cqlib/qis/__init__.pyi +++ b/crates/binding-python/cqlib/qis/__init__.pyi @@ -33,6 +33,15 @@ from .pauli import PauliString as PauliString # Hamiltonian module from .hamiltonian import Hamiltonian as Hamiltonian +from . import fermion as fermion +from .fermion import ( + FermionicLadder as FermionicLadder, + FermionicOp as FermionicOp, + FermionToQubitMapper as FermionToQubitMapper, + OccupationStateMapper as OccupationStateMapper, + JordanWignerMapper as JordanWignerMapper, +) + # Evolution module from .evolution import TrotterMode as TrotterMode @@ -83,6 +92,12 @@ __all__ = [ "Pauli", "PauliString", "Hamiltonian", + "fermion", + "FermionicLadder", + "FermionicOp", + "FermionToQubitMapper", + "OccupationStateMapper", + "JordanWignerMapper", "TrotterMode", "state", "Statevector", diff --git a/crates/binding-python/cqlib/qis/fermion.py b/crates/binding-python/cqlib/qis/fermion.py new file mode 100644 index 00000000..ffabc726 --- /dev/null +++ b/crates/binding-python/cqlib/qis/fermion.py @@ -0,0 +1,65 @@ +# 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. + +"""Structured fermionic algebra and fermion-to-qubit mappings. + +Modes are numbered from zero. A product A * B applies B first. Jordan-Wigner +maps mode p to qubit p, with a Z chain on modes below p; qubit 0 is the least +significant bit. Core imposes no chemical or spin-orbital ordering. + +The excitation generator is G = i(T - T†), where targets supply creators in +input order and sources supply annihilators in reverse input order. Evolving +the mapped generator implements exp(-i theta G) = exp(theta (T - T†)). +""" + +from __future__ import annotations + +from typing import Protocol, Sequence, runtime_checkable + +from .._native import qis as _qis_module +from .hamiltonian import Hamiltonian + +FermionicLadder = _qis_module.fermion.FermionicLadder +FermionicOp = _qis_module.fermion.FermionicOp +JordanWignerMapper = _qis_module.fermion.JordanWignerMapper + + +@runtime_checkable +class FermionToQubitMapper(Protocol): + """Structural contract for operator mappings, including non-Hermitian inputs. + + Implementations need not inherit this protocol. Runtime checks establish + member presence only; callers must still honor signatures and semantics. + """ + + def map_operator(self, op: FermionicOp) -> Hamiltonian: ... + def num_qubits(self, num_modes: int) -> int: ... + + +@runtime_checkable +class OccupationStateMapper(Protocol): + """Separate contract for computational-basis occupation encodings. + + occupation[p] denotes mode p and returned bits[p] addresses qubit p. + Not every operator mapping has a computational-basis occupation encoding. + """ + + def map_occupation(self, occupation: Sequence[int]) -> list[bool]: ... + + +__all__ = [ + "FermionicLadder", + "FermionicOp", + "FermionToQubitMapper", + "OccupationStateMapper", + "JordanWignerMapper", +] diff --git a/crates/binding-python/cqlib/qis/fermion.pyi b/crates/binding-python/cqlib/qis/fermion.pyi new file mode 100644 index 00000000..422893eb --- /dev/null +++ b/crates/binding-python/cqlib/qis/fermion.pyi @@ -0,0 +1,149 @@ +# 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. + +"""Typed interfaces for structured fermionic algebra and sparse mappings.""" + +from typing import ( + Iterable, + Literal, + Protocol, + Sequence, + TypeAlias, + final, + runtime_checkable, +) +from .hamiltonian import Hamiltonian + +_Coefficient: TypeAlias = complex | float | int + +@final +class FermionicLadder: + """Immutable ladder, constructed with create or annihilate, not directly.""" + @staticmethod + def create(mode: int) -> FermionicLadder: ... + @staticmethod + def annihilate(mode: int) -> FermionicLadder: ... + @property + def mode(self) -> int: ... + @property + def kind(self) -> Literal["create", "annihilate"]: ... + def adjoint(self) -> FermionicLadder: ... + def __eq__(self, other: object, /) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + +@final +class FermionicOp: + """Mutable complex sum of ordered products. B acts first in A * B. + + Identical ordered products merge exactly; no implicit normal ordering or + numerical truncation occurs. Mode counts are retained for zero and identity. + """ + + __hash__: None # type: ignore[assignment] + def __new__(cls, num_modes: int) -> FermionicOp: ... + @staticmethod + def identity(num_modes: int) -> FermionicOp: ... + @staticmethod + def from_term( + num_modes: int, + term: Iterable[FermionicLadder], + coefficient: _Coefficient = 1.0, + ) -> FermionicOp: ... + @staticmethod + def from_terms( + num_modes: int, + terms: Iterable[tuple[Iterable[FermionicLadder], _Coefficient]], + ) -> FermionicOp: ... + @staticmethod + def creation(num_modes: int, mode: int) -> FermionicOp: ... + @staticmethod + def annihilation(num_modes: int, mode: int) -> FermionicOp: ... + @staticmethod + def number(num_modes: int, mode: int) -> FermionicOp: ... + @staticmethod + def total_number(num_modes: int) -> FermionicOp: ... + @staticmethod + def excitation_generator( + num_modes: int, sources: Sequence[int], targets: Sequence[int] + ) -> FermionicOp: + """G = i(T - T†); targets in order, source annihilators in reverse order. + + Lists must be nonempty, equally sized, unique and disjoint. Input order + is preserved, with no implicit sorting or factorial normalization. + """ + ... + @property + def num_modes(self) -> int: ... + @property + def num_terms(self) -> int: ... + @property + def terms(self) -> list[tuple[tuple[FermionicLadder, ...], complex]]: + """Independent snapshot in deterministic structural order.""" + ... + def is_zero(self) -> bool: + """Whether the stored sum is empty; use equiv to test algebraic zero.""" + ... + def add_term( + self, term: Iterable[FermionicLadder], coefficient: _Coefficient = 1.0 + ) -> None: + """Modify in place; errors leave this object unchanged.""" + ... + def adjoint(self) -> FermionicOp: ... + def normal_order(self) -> FermionicOp: + """Return a new expansion: creators first, ascending modes in each group.""" + ... + def scaled(self, factor: _Coefficient) -> FermionicOp: ... + def chop(self, *, atol: float = 0.0) -> None: + """Remove terms with complex magnitude <= atol, in place.""" + ... + def equiv(self, other: FermionicOp, *, atol: float = 0.0) -> bool: ... + def is_hermitian(self, *, atol: float = 0.0) -> bool: ... + def __add__(self, other: FermionicOp, /) -> FermionicOp: ... + def __sub__(self, other: FermionicOp, /) -> FermionicOp: ... + def __mul__(self, other: FermionicOp | _Coefficient, /) -> FermionicOp: ... + def __rmul__(self, other: _Coefficient, /) -> FermionicOp: ... + def __neg__(self) -> FermionicOp: ... + def __eq__(self, other: object, /) -> bool: + """Structural equality; use equiv for algebraic equivalence.""" + ... + def copy(self) -> FermionicOp: ... + def __copy__(self) -> FermionicOp: ... + def __deepcopy__(self, memo: dict[int, object]) -> FermionicOp: ... + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + +@runtime_checkable +class FermionToQubitMapper(Protocol): + def map_operator(self, op: FermionicOp) -> Hamiltonian: ... + def num_qubits(self, num_modes: int) -> int: ... + +@runtime_checkable +class OccupationStateMapper(Protocol): + def map_occupation(self, occupation: Sequence[int]) -> list[bool]: ... + +@final +class JordanWignerMapper: + """Mode p maps to qubit p; Z chain uses modes below p. Qubit 0 is LSB.""" + def __init__(self) -> None: ... + def map_operator(self, op: FermionicOp) -> Hamiltonian: ... + def num_qubits(self, num_modes: int) -> int: ... + def map_occupation(self, occupation: Sequence[int]) -> list[bool]: ... + def __repr__(self) -> str: ... + +__all__ = [ + "FermionicLadder", + "FermionicOp", + "FermionToQubitMapper", + "OccupationStateMapper", + "JordanWignerMapper", +] diff --git a/crates/binding-python/cqlib/qis/hamiltonian.py b/crates/binding-python/cqlib/qis/hamiltonian.py index c5f6b95f..20a1a58d 100644 --- a/crates/binding-python/cqlib/qis/hamiltonian.py +++ b/crates/binding-python/cqlib/qis/hamiltonian.py @@ -12,8 +12,12 @@ """Hamiltonian observable wrapper.""" +from typing import TypeAlias + from .._native import qis as _qis_module +Coefficient: TypeAlias = float | int | complex | tuple[float, float] + Hamiltonian = _qis_module.Hamiltonian __all__ = ["Hamiltonian"] diff --git a/crates/binding-python/cqlib/qis/hamiltonian.pyi b/crates/binding-python/cqlib/qis/hamiltonian.pyi index 3a7ee341..61d43dd6 100644 --- a/crates/binding-python/cqlib/qis/hamiltonian.pyi +++ b/crates/binding-python/cqlib/qis/hamiltonian.pyi @@ -10,7 +10,7 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -from typing import List, Tuple, Union, final, Sequence, Dict +from typing import List, Tuple, Union, final, Sequence, Dict, Iterable import numpy as np @@ -180,6 +180,52 @@ class Hamiltonian: """Returns ``True`` if all Pauli terms mutually commute.""" ... + @staticmethod + def from_terms( + num_qubits: int, + terms: Iterable[tuple[PauliString, complex | float | int]], + ) -> Hamiltonian: + """Validate, absorb phases and merge duplicates, removing exact zeros only.""" + ... + + def normalized(self) -> Hamiltonian: + """Return a validated, merged copy without numerical truncation.""" + ... + + def normalized_hermitian(self) -> Hamiltonian: + """Normalize and project imaginary residuals <= 1e-10; reject larger ones.""" + ... + + def add(self, other: Hamiltonian) -> Hamiltonian: + """Return a validated, merged sum; legacy + and += retain concatenation.""" + ... + + def scaled(self, factor: complex | float | int) -> Hamiltonian: ... + def adjoint(self) -> Hamiltonian: ... + def chop(self, *, atol: float = 0.0) -> None: + """Normalize and truncate by complex magnitude, in place and atomically.""" + ... + + def equiv(self, other: Hamiltonian, *, atol: float = 0.0) -> bool: ... + def is_hermitian(self, *, atol: float = 0.0) -> bool: + """Compare with adjoint; coefficient criterion is 2*abs(imag) <= atol.""" + ... + + def tensor(self, other: Hamiltonian) -> Hamiltonian: + """Return self tensor other, placing other on low qubits.""" + ... + + def remap_qubits(self, mapping: Sequence[int], target_width: int) -> Hamiltonian: + """Embed mapping[source] = destination, with identity on unused targets.""" + ... + + def __sub__(self, other: Hamiltonian, /) -> Hamiltonian: ... + def __mul__(self, other: Hamiltonian | complex | float | int, /) -> Hamiltonian: + """Operator product AB (B acts first), or scalar multiplication.""" + ... + + def __rmul__(self, other: complex | float | int, /) -> Hamiltonian: ... + def __neg__(self) -> Hamiltonian: ... def to_matrix(self) -> np.ndarray: """Computes the dense matrix representation as a NumPy array. @@ -247,7 +293,7 @@ class Hamiltonian: """ ... - def __add__(self, other: "Hamiltonian") -> "Hamiltonian": + def __add__(self, other: "Hamiltonian", /) -> "Hamiltonian": """Adds two Hamiltonians together. Note: This performs a simple lazy concatenation of the term lists. @@ -273,7 +319,7 @@ class Hamiltonian: """ ... - def __iadd__(self, other: "Hamiltonian") -> "Hamiltonian": + def __iadd__(self, other: "Hamiltonian", /) -> "Hamiltonian": """In-place addition of another Hamiltonian. Args: @@ -287,7 +333,7 @@ class Hamiltonian: """ ... - def __eq__(self, other: object) -> bool: + def __eq__(self, other: object, /) -> bool: """Checks if two Hamiltonians are equal.""" ... @@ -365,3 +411,5 @@ class Hamiltonian: def __deepcopy__(self, memo: dict) -> "Hamiltonian": """Return an independent copy via copy.deepcopy().""" ... + +__all__ = ["Hamiltonian"] diff --git a/crates/binding-python/src/circuit/ansatz/hamiltonian_evolution.rs b/crates/binding-python/src/circuit/ansatz/hamiltonian_evolution.rs index ab7e3c6b..dc439f35 100644 --- a/crates/binding-python/src/circuit/ansatz/hamiltonian_evolution.rs +++ b/crates/binding-python/src/circuit/ansatz/hamiltonian_evolution.rs @@ -361,8 +361,9 @@ impl From for PauliEvolutionAnsatz { impl PyPauliEvolutionAnsatz { /// Creates a new ``PauliEvolutionAnsatz`` from a Hamiltonian. /// - /// The Hamiltonian is automatically **simplified** (phases absorbed, duplicate - /// terms merged, near-zero terms removed) before any further processing. + /// The Hamiltonian is normalized (phases absorbed and duplicate terms merged). + /// Imaginary residuals with magnitude <= 1e-10 are projected to zero; larger + /// ones are rejected. Every nonzero real coefficient is retained. /// /// Default strategy is :meth:`EvolutionStrategy.auto` with ``steps=1``. /// diff --git a/crates/binding-python/src/qis/fermion.rs b/crates/binding-python/src/qis/fermion.rs new file mode 100644 index 00000000..83a519cd --- /dev/null +++ b/crates/binding-python/src/qis/fermion.rs @@ -0,0 +1,506 @@ +// 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. + +//! Structured fermionic operators and sparse mappings; algebra stays in Core. + +use super::hamiltonian::PyHamiltonian; +use super::qis_error_to_py_err; +use cqlib_core::qis::{ + FermionToQubitMapper, FermionicLadder, FermionicOp, JordanWignerMapper, OccupationStateMapper, +}; +use num_complex::Complex64; +use pyo3::basic::CompareOp; +use pyo3::exceptions::{PyIndexError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::{PyComplex, PyList, PyTuple}; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +// Respect Python's integer protocol, including type-level method lookup and +// validation of __index__ return values, rather than invoking the method directly. +fn integer_index<'py>(value: &Bound<'py, PyAny>) -> PyResult> { + // SAFETY: value is a live Python object under the GIL. PyNumber_Index returns + // a new owned reference, or NULL with an exception; Bound handles both cases. + unsafe { Bound::from_owned_ptr_or_err(value.py(), pyo3::ffi::PyNumber_Index(value.as_ptr())) } +} + +pub(super) fn index(value: &Bound<'_, PyAny>, is_count: bool) -> PyResult { + let integer = integer_index(value)?; + if integer.rich_compare(0, CompareOp::Lt)?.is_truthy()? { + return Err(if is_count { + PyValueError::new_err("register size must be nonnegative") + } else { + PyIndexError::new_err("mode indices must be nonnegative") + }); + } + integer.extract() +} + +fn indices(values: &Bound<'_, PyAny>) -> PyResult> { + values.try_iter()?.map(|v| index(&v?, false)).collect() +} + +fn ladders(term: &Bound<'_, PyAny>) -> PyResult> { + term.try_iter()? + .map(|v| Ok(v?.extract::>()?.inner)) + .collect() +} + +/// An immutable creation or annihilation operator on a zero-based mode. +#[pyclass( + name = "FermionicLadder", + module = "cqlib.qis.fermion", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyFermionicLadder { + inner: FermionicLadder, +} + +#[pymethods] +impl PyFermionicLadder { + #[staticmethod] + /// Create an immutable creation ladder on a zero-based mode. + fn create(mode: &Bound<'_, PyAny>) -> PyResult { + Ok(Self { + inner: FermionicLadder::Create(index(mode, false)?), + }) + } + + #[staticmethod] + /// Create an immutable annihilation ladder on a zero-based mode. + fn annihilate(mode: &Bound<'_, PyAny>) -> PyResult { + Ok(Self { + inner: FermionicLadder::Annihilate(index(mode, false)?), + }) + } + + #[getter] + /// Zero-based mode index; the containing operator validates its range. + fn mode(&self) -> usize { + self.inner.mode() + } + + #[getter] + /// Ladder kind, either "create" or "annihilate". + fn kind(&self) -> &'static str { + match self.inner { + FermionicLadder::Create(_) => "create", + FermionicLadder::Annihilate(_) => "annihilate", + } + } + + /// Return a ladder on the same mode with creation and annihilation exchanged. + fn adjoint(&self) -> Self { + Self { + inner: self.inner.adjoint(), + } + } + + fn __eq__(&self, other: &Bound<'_, PyAny>) -> PyResult> { + let py = other.py(); + match other.extract::>() { + Ok(rhs) => Ok((self.inner == rhs.inner) + .into_pyobject(py)? + .to_owned() + .into_any() + .unbind()), + Err(_) => Ok(py.NotImplemented()), + } + } + + fn __hash__(&self) -> isize { + let mut hasher = DefaultHasher::new(); + self.inner.hash(&mut hasher); + let hash = hasher.finish() as isize; + if hash == -1 { -2 } else { hash } + } + + fn __repr__(&self) -> String { + format!("FermionicLadder.{}({})", self.kind(), self.mode()) + } +} + +/// A finite complex sum of ordered fermionic products on a declared mode space. +/// +/// In A * B, B acts first. Products are not automatically normal ordered. +/// Only exact zero coefficients are removed; use chop for explicit truncation. +#[pyclass( + name = "FermionicOp", + module = "cqlib.qis.fermion", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyFermionicOp { + inner: FermionicOp, +} + +impl From for PyFermionicOp { + fn from(inner: FermionicOp) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyFermionicOp { + /// Construct the zero operator; num_modes is retained even for zero modes. + #[new] + fn new(num_modes: &Bound<'_, PyAny>) -> PyResult { + Ok(FermionicOp::new(index(num_modes, true)?).into()) + } + + #[staticmethod] + /// Return the identity on the declared mode space, including zero modes. + fn identity(num_modes: &Bound<'_, PyAny>) -> PyResult { + Ok(FermionicOp::identity(index(num_modes, true)?).into()) + } + + /// An empty product is identity, whereas an empty sum is zero. + #[staticmethod] + #[pyo3(signature = (num_modes, term, coefficient=Complex64::new(1.0, 0.0)), text_signature = "(num_modes, term, coefficient=1.0)")] + fn from_term( + num_modes: &Bound<'_, PyAny>, + term: &Bound<'_, PyAny>, + coefficient: Complex64, + ) -> PyResult { + FermionicOp::from_term(index(num_modes, true)?, ladders(term)?, coefficient) + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + /// Accept an iterable of (ordered ladders, coefficient) pairs and merge duplicates. + #[staticmethod] + fn from_terms(num_modes: &Bound<'_, PyAny>, terms: &Bound<'_, PyAny>) -> PyResult { + let mut result = FermionicOp::new(index(num_modes, true)?); + for item in terms.try_iter()? { + let item = item?; + let pair = item.cast::()?; + if pair.len() != 2 { + return Err(PyValueError::new_err( + "each term must be a pair (ladders, coefficient)", + )); + } + result + .add_term(ladders(&pair.get_item(0)?)?, pair.get_item(1)?.extract()?) + .map_err(qis_error_to_py_err)?; + } + Ok(result.into()) + } + + #[staticmethod] + /// Return a creation operator; invalid mode indices raise IndexError. + fn creation(num_modes: &Bound<'_, PyAny>, mode: &Bound<'_, PyAny>) -> PyResult { + FermionicOp::creation(index(num_modes, true)?, index(mode, false)?) + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + #[staticmethod] + /// Return an annihilation operator; invalid mode indices raise IndexError. + fn annihilation(num_modes: &Bound<'_, PyAny>, mode: &Bound<'_, PyAny>) -> PyResult { + FermionicOp::annihilation(index(num_modes, true)?, index(mode, false)?) + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + #[staticmethod] + /// Return the mode number operator a†_p a_p. + fn number(num_modes: &Bound<'_, PyAny>, mode: &Bound<'_, PyAny>) -> PyResult { + FermionicOp::number(index(num_modes, true)?, index(mode, false)?) + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + #[staticmethod] + /// Return total particle number; zero modes yield the zero operator. + fn total_number(num_modes: &Bound<'_, PyAny>) -> PyResult { + Ok(FermionicOp::total_number(index(num_modes, true)?).into()) + } + + /// G = i(T - T†), with target creators followed by reversed source annihilators. + /// Input order is preserved; no spin-orbital ordering or factorial is imposed. + #[staticmethod] + fn excitation_generator( + num_modes: &Bound<'_, PyAny>, + sources: &Bound<'_, PyAny>, + targets: &Bound<'_, PyAny>, + ) -> PyResult { + FermionicOp::excitation_generator( + index(num_modes, true)?, + &indices(sources)?, + &indices(targets)?, + ) + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + #[getter] + /// Declared number of modes, retained even for zero and identity operators. + fn num_modes(&self) -> usize { + self.inner.num_modes() + } + + #[getter] + /// Number of stored ordered products, before algebraic normal ordering. + fn num_terms(&self) -> usize { + self.inner.num_terms() + } + + /// Return an independent snapshot in deterministic structural order. + #[getter] + fn terms<'py>(&self, py: Python<'py>) -> PyResult> { + let result = PyList::empty(py); + for (term, coefficient) in self.inner.terms() { + let term = PyTuple::new( + py, + term.iter() + .map(|&inner| Py::new(py, PyFermionicLadder { inner })) + .collect::>>()?, + )?; + result.append((term, coefficient))?; + } + Ok(result) + } + + /// Test whether storage is empty; use equiv for algebraic zero. + fn is_zero(&self) -> bool { + self.inner.is_zero() + } + + /// Add a term in place and return None; errors leave this operator unchanged. + #[pyo3(signature = (term, coefficient=Complex64::new(1.0, 0.0)), text_signature = "($self, term, coefficient=1.0)")] + fn add_term(&mut self, term: &Bound<'_, PyAny>, coefficient: Complex64) -> PyResult<()> { + self.inner + .add_term(ladders(term)?, coefficient) + .map_err(qis_error_to_py_err) + } + + /// Return a conjugate transpose, reversing products and exchanging ladder kinds. + fn adjoint(&self) -> Self { + self.inner.adjoint().into() + } + + /// Return a normal-ordered expansion, including contractions and fermionic signs. + /// Creators precede annihilators; each group uses ascending mode indices. + fn normal_order(&self) -> PyResult { + self.inner + .normal_order() + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + /// Return a scaled copy; the factor and resulting coefficients must be finite. + fn scaled(&self, factor: Complex64) -> PyResult { + self.inner + .scaled(factor) + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + /// Explicitly remove coefficients whose complex magnitude is at most atol, in place. + #[pyo3(signature = (*, atol=0.0))] + fn chop(&mut self, atol: f64) -> PyResult<()> { + self.inner.chop(atol).map_err(qis_error_to_py_err) + } + + /// Compare algebraically after normal ordering the difference. + #[pyo3(signature = (other, *, atol=0.0))] + fn equiv(&self, other: &Self, atol: f64) -> PyResult { + self.inner + .equiv(&other.inner, atol) + .map_err(qis_error_to_py_err) + } + + #[pyo3(signature = (*, atol=0.0))] + /// Compare algebraically with the adjoint; atol must be finite and nonnegative. + fn is_hermitian(&self, atol: f64) -> PyResult { + self.inner.is_hermitian(atol).map_err(qis_error_to_py_err) + } + + fn __add__(&self, other: &Bound<'_, PyAny>) -> PyResult> { + match other.extract::>() { + Ok(rhs) => Ok(Py::new( + other.py(), + Self::from( + self.inner + .try_add(&rhs.inner) + .map_err(qis_error_to_py_err)?, + ), + )? + .into_any()), + Err(_) => Ok(other.py().NotImplemented()), + } + } + + fn __sub__(&self, other: &Bound<'_, PyAny>) -> PyResult> { + match other.extract::>() { + Ok(rhs) => Ok(Py::new( + other.py(), + Self::from( + self.inner + .try_sub(&rhs.inner) + .map_err(qis_error_to_py_err)?, + ), + )? + .into_any()), + Err(_) => Ok(other.py().NotImplemented()), + } + } + + fn __mul__(&self, other: &Bound<'_, PyAny>) -> PyResult> { + let result = if let Ok(rhs) = other.extract::>() { + self.inner.try_mul(&rhs.inner) + } else if let Some(factor) = scalar_operand(other)? { + self.inner.scaled(factor) + } else { + return Ok(other.py().NotImplemented()); + }; + Ok(Py::new(other.py(), Self::from(result.map_err(qis_error_to_py_err)?))?.into_any()) + } + + fn __rmul__(&self, other: &Bound<'_, PyAny>) -> PyResult> { + match scalar_operand(other)? { + Some(factor) => Ok(Py::new(other.py(), self.scaled(factor)?)?.into_any()), + None => Ok(other.py().NotImplemented()), + } + } + + fn __neg__(&self) -> PyResult { + self.scaled(Complex64::new(-1.0, 0.0)) + } + + fn __eq__(&self, other: &Bound<'_, PyAny>) -> PyResult> { + match other.extract::>() { + Ok(rhs) => Ok((self.inner == rhs.inner) + .into_pyobject(other.py())? + .to_owned() + .into_any() + .unbind()), + Err(_) => Ok(other.py().NotImplemented()), + } + } + + /// Return an independent copy of this mutable operator. + fn copy(&self) -> Self { + self.clone() + } + fn __copy__(&self) -> Self { + self.copy() + } + fn __deepcopy__(&self, _memo: &Bound<'_, PyAny>) -> Self { + self.copy() + } + + fn __repr__(&self) -> String { + format!( + "FermionicOp(num_modes={}, num_terms={})", + self.num_modes(), + self.num_terms() + ) + } + + fn __str__(&self) -> String { + if self.is_zero() { + return "0".into(); + } + self.inner + .terms() + .map(|(term, coefficient)| { + let product = if term.is_empty() { + "I".into() + } else { + term.iter() + .map(|ladder| match ladder { + FermionicLadder::Create(mode) => format!("a†_{mode}"), + FermionicLadder::Annihilate(mode) => format!("a_{mode}"), + }) + .collect::>() + .join(" ") + }; + format!("({coefficient}) {product}") + }) + .collect::>() + .join(" + ") + } +} + +// Decline foreign types for Python's reflected arithmetic, but propagate numeric errors. +pub(super) fn scalar_operand(value: &Bound<'_, PyAny>) -> PyResult> { + if value.is_instance_of::() + || value.hasattr("__complex__")? + || value.hasattr("__float__")? + || value.hasattr("__index__")? + { + value.extract().map(Some) + } else { + Ok(None) + } +} + +/// Jordan-Wigner encoding: mode p maps to qubit p, with Z factors on modes below p. +#[pyclass(name = "JordanWignerMapper", module = "cqlib.qis.fermion", frozen)] +pub struct PyJordanWignerMapper; + +#[pymethods] +impl PyJordanWignerMapper { + #[new] + fn new() -> Self { + Self + } + + /// Map to a sparse Hamiltonian; non-Hermitian inputs are valid here. + fn map_operator(&self, op: &PyFermionicOp) -> PyResult { + JordanWignerMapper + .map_operator(&op.inner) + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + /// Return the register width; Jordan-Wigner uses one qubit per mode. + fn num_qubits(&self, num_modes: &Bound<'_, PyAny>) -> PyResult { + Ok(JordanWignerMapper.num_qubits(index(num_modes, true)?)) + } + + /// bits[p] addresses qubit p (qubit 0 is the least significant bit). + /// Accept only integer or boolean zero/one occupations. Empty input is vacuum. + fn map_occupation(&self, occupation: &Bound<'_, PyAny>) -> PyResult> { + let mut occupations = Vec::new(); + for value in occupation.try_iter()? { + let value = value?; + let integer = integer_index(&value)?; + let bit = if integer.rich_compare(0, CompareOp::Eq)?.is_truthy()? { + 0 + } else if integer.rich_compare(1, CompareOp::Eq)?.is_truthy()? { + 1 + } else { + return Err(PyValueError::new_err("occupations must be zero or one")); + }; + occupations.push(bit); + } + JordanWignerMapper + .map_occupation(&occupations) + .map_err(qis_error_to_py_err) + } + + fn __repr__(&self) -> &'static str { + "JordanWignerMapper()" + } +} + +pub fn register_fermion_module(parent: &Bound<'_, PyModule>) -> PyResult<()> { + let module = PyModule::new(parent.py(), "fermion")?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + parent.add_submodule(&module) +} diff --git a/crates/binding-python/src/qis/hamiltonian.rs b/crates/binding-python/src/qis/hamiltonian.rs index b81ca7f6..afae3ee4 100644 --- a/crates/binding-python/src/qis/hamiltonian.rs +++ b/crates/binding-python/src/qis/hamiltonian.rs @@ -188,6 +188,166 @@ impl PyHamiltonian { Ok(Self { inner }) } + /// Construct a validated Pauli sum, absorbing phases and merging duplicates. + /// Only exact zeros are removed. Empty input retains the declared width. + #[staticmethod] + fn from_terms(num_qubits: &Bound<'_, PyAny>, terms: &Bound<'_, PyAny>) -> PyResult { + let width = super::fermion::index(num_qubits, true)?; + let mut parsed = Vec::new(); + for item in terms.try_iter()? { + let item = item?; + let pair = item.cast::()?; + if pair.len() != 2 { + return Err(PyValueError::new_err( + "each term must be (PauliString, coefficient)", + )); + } + let pauli = pair + .get_item(0)? + .extract::>()? + .inner + .clone(); + let coefficient = pair.get_item(1)?.extract::()?; + parsed.push((pauli, coefficient)); + } + Hamiltonian::from_terms(width, parsed) + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + /// Return a validated sum with phases absorbed and duplicates merged. + /// Retain all nonzero coefficients; legacy simplify() also truncates small terms. + fn normalized(&self) -> PyResult { + self.inner + .normalized() + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + /// Normalize and project imaginary roundoff <= 1e-10 to zero. + /// Larger imaginary coefficients raise ValueError; all nonzero real terms remain. + fn normalized_hermitian(&self) -> PyResult { + self.inner + .normalized_hermitian() + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + /// Return a validated, merged sum without changing either operand. + /// Legacy + and += concatenate stored terms instead. + fn add(&self, other: &Self) -> PyResult { + self.inner + .try_add(&other.inner) + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + /// Return a validated scaled copy, retaining all nonzero coefficients. + /// Unlike legacy scale(), validate the factor and resulting coefficients. + fn scaled(&self, factor: Complex64) -> PyResult { + self.inner + .scaled(factor) + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + /// Return the conjugate transpose after absorbing Pauli phases. + fn adjoint(&self) -> PyResult { + self.inner + .adjoint() + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + /// Normalize and remove coefficients with complex magnitude <= atol, in place. + /// Return None; errors leave this Hamiltonian unchanged. + #[pyo3(signature = (*, atol=0.0))] + fn chop(&mut self, atol: f64) -> PyResult<()> { + self.inner.chop(atol).map_err(qis_error_to_py_err) + } + + /// Compare normalized coefficient differences, requiring equal register widths. + /// atol is a coefficient tolerance, not a matrix operator-norm bound. + #[pyo3(signature = (other, *, atol=0.0))] + fn equiv(&self, other: &Self, atol: f64) -> PyResult { + self.inner + .equiv(&other.inner, atol) + .map_err(qis_error_to_py_err) + } + + /// Compare with the adjoint; normalized coefficients satisfy 2*abs(imag) <= atol. + /// This check does not project imaginary parts to zero. + #[pyo3(signature = (*, atol=0.0))] + fn is_hermitian(&self, atol: f64) -> PyResult { + self.inner.is_hermitian(atol).map_err(qis_error_to_py_err) + } + + /// Return self tensor other: other occupies low qubits, self occupies high qubits. + fn tensor(&self, other: &Self) -> PyResult { + self.inner + .tensor(&other.inner) + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + /// Embed qubits using mapping[source] = destination; unused targets are identity. + /// Destinations must be unique and in range. This is not a fermionic permutation. + fn remap_qubits( + &self, + mapping: &Bound<'_, PyAny>, + target_width: &Bound<'_, PyAny>, + ) -> PyResult { + let destinations = mapping + .try_iter()? + .map(|v| super::fermion::index(&v?, false)) + .collect::>>()?; + self.inner + .remap_qubits(&destinations, super::fermion::index(target_width, true)?) + .map(Into::into) + .map_err(qis_error_to_py_err) + } + + /// Return a validated difference on the same register, without truncation. + fn __sub__(&self, other: &Bound<'_, PyAny>) -> PyResult> { + match other.extract::>() { + Ok(rhs) => Ok(Py::new( + other.py(), + Self::from( + self.inner + .try_sub(&rhs.inner) + .map_err(qis_error_to_py_err)?, + ), + )? + .into_any()), + Err(_) => Ok(other.py().NotImplemented()), + } + } + + /// Return AB (B acts first) or a scalar multiple; validate and merge the result. + fn __mul__(&self, other: &Bound<'_, PyAny>) -> PyResult> { + let result = if let Ok(rhs) = other.extract::>() { + self.inner.try_mul(&rhs.inner) + } else if let Some(factor) = super::fermion::scalar_operand(other)? { + self.inner.scaled(factor) + } else { + return Ok(other.py().NotImplemented()); + }; + Ok(Py::new(other.py(), Self::from(result.map_err(qis_error_to_py_err)?))?.into_any()) + } + + /// Return a validated scalar multiple. + fn __rmul__(&self, other: &Bound<'_, PyAny>) -> PyResult> { + match super::fermion::scalar_operand(other)? { + Some(factor) => Ok(Py::new(other.py(), self.scaled(factor)?)?.into_any()), + None => Ok(other.py().NotImplemented()), + } + } + + /// Return a validated negation without modifying this operator. + fn __neg__(&self) -> PyResult { + self.scaled(Complex64::new(-1.0, 0.0)) + } + /// Adds a new Pauli string term with a given coefficient to the Hamiltonian. /// /// Args: @@ -195,7 +355,7 @@ impl PyHamiltonian { /// coeff: The coefficient for this term. Can be float, int, complex, or tuple (real, imag). /// /// Returns: - /// self (for method chaining) + /// None. This method modifies the Hamiltonian in place. /// /// Raises: /// ValueError: If the number of qubits in the operator does not match the Hamiltonian. @@ -343,15 +503,18 @@ impl PyHamiltonian { /// >>> h2 = Hamiltonian(2) /// >>> h2.add_term(PauliString.from_str("XX"), 0.3) /// >>> h3 = h1 + h2 # Contains both ZZ and XX terms - fn __add__(&self, other: &PyHamiltonian) -> PyResult { - if self.inner.num_qubits != other.inner.num_qubits { + fn __add__(&self, other: &Bound<'_, PyAny>) -> PyResult> { + let Ok(rhs) = other.extract::>() else { + return Ok(other.py().NotImplemented()); + }; + if self.inner.num_qubits != rhs.inner.num_qubits { return Err(PyValueError::new_err(format!( "Cannot add Hamiltonians with different numbers of qubits: {} vs {}", - self.inner.num_qubits, other.inner.num_qubits + self.inner.num_qubits, rhs.inner.num_qubits ))); } - let result = self.inner.clone() + other.inner.clone(); - Ok(Self { inner: result }) + let result = self.inner.clone() + rhs.inner.clone(); + Ok(Py::new(other.py(), Self { inner: result })?.into_any()) } /// In-place addition of another Hamiltonian. @@ -369,8 +532,15 @@ impl PyHamiltonian { Ok(()) } - fn __eq__(&self, other: &PyHamiltonian) -> bool { - self.inner == other.inner + fn __eq__(&self, other: &Bound<'_, PyAny>) -> PyResult> { + match other.extract::>() { + Ok(rhs) => Ok((self.inner == rhs.inner) + .into_pyobject(other.py())? + .to_owned() + .into_any() + .unbind()), + Err(_) => Ok(other.py().NotImplemented()), + } } fn __str__(&self) -> String { diff --git a/crates/binding-python/src/qis/mod.rs b/crates/binding-python/src/qis/mod.rs index 305ef130..daba0b6c 100644 --- a/crates/binding-python/src/qis/mod.rs +++ b/crates/binding-python/src/qis/mod.rs @@ -18,6 +18,7 @@ use pyo3::prelude::*; pub mod entropy; pub mod evolution; +pub mod fermion; pub mod hamiltonian; pub mod metrics; pub mod pauli; @@ -27,7 +28,9 @@ pub mod state; /// Converts a QisError to the appropriate Python exception. pub(crate) fn qis_error_to_py_err(err: QisError) -> PyErr { match err { - QisError::IndexOutOfBounds { .. } => PyIndexError::new_err(err.to_string()), + QisError::IndexOutOfBounds { .. } | QisError::ModeOutOfBounds { .. } => { + PyIndexError::new_err(err.to_string()) + } _ => PyValueError::new_err(err.to_string()), } } @@ -46,6 +49,7 @@ pub fn register_qis_module(parent: &Bound<'_, PyModule>) -> PyResult<()> { qis_module.add_class::()?; qis_module.add_class::()?; qis_module.add_class::()?; + fermion::register_fermion_module(&qis_module)?; // Register state submodule state::register_state_module(&qis_module)?; diff --git a/crates/binding-python/tests/parameter/test_arithmetic.py b/crates/binding-python/tests/parameter/test_arithmetic.py index 94b6a89a..3a769c9c 100644 --- a/crates/binding-python/tests/parameter/test_arithmetic.py +++ b/crates/binding-python/tests/parameter/test_arithmetic.py @@ -26,6 +26,28 @@ from cqlib.circuit import Parameter +def test_affine_coefficients_and_cancellation_residues(): + import math + + theta = Parameter("theta") + for coefficient in (1e-15, math.nextafter(1.0, 2.0)): + scaled = (theta * coefficient).simplify() + assert scaled.evaluate({"theta": 1.0}) == coefficient + assert scaled.replace("theta", Parameter(1.0)).evaluate() == coefficient + coefficient = math.nextafter(1.0, 2.0) + residue = (theta * coefficient - theta).simplify() + assert residue.evaluate({"theta": 1.0}) == coefficient - 1.0 + assert (Parameter(1e-15) - Parameter(0.0)).simplify().evaluate() == 1e-15 + + +def test_subnormal_parameter_division_preserves_representable_quotient(): + smallest = float.fromhex("0x0.0000000000001p-1022") + theta = Parameter("theta") + scaled = theta * smallest + assert scaled.simplify().evaluate({"theta": 1.0}) == smallest + assert (scaled / smallest).simplify().evaluate({"theta": 1.0}) == 1.0 + + class TestAddition: """Tests for addition operation.""" diff --git a/crates/binding-python/tests/qis/test_fermion.py b/crates/binding-python/tests/qis/test_fermion.py new file mode 100644 index 00000000..125f38fd --- /dev/null +++ b/crates/binding-python/tests/qis/test_fermion.py @@ -0,0 +1,378 @@ +# 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. + +"""Fermionic Python contracts and independent Fock-space comparisons.""" + +import copy +import itertools + +import numpy as np +import pytest + +from cqlib.qis import ( + FermionicLadder as Ladder, + FermionicOp as Op, + FermionToQubitMapper, + Hamiltonian, + JordanWignerMapper, + OccupationStateMapper, + Statevector, +) +from cqlib.circuit.ansatz import BasisEncoding, EvolutionStrategy, PauliEvolutionAnsatz + + +def fock_ladder(num_modes, mode, create): + """Independent ladder action on little-endian occupation basis states.""" + matrix = np.zeros((1 << num_modes, 1 << num_modes), dtype=complex) + for column in range(1 << num_modes): + occupied = bool(column & (1 << mode)) + if occupied == create: + continue + lower = column & ((1 << mode) - 1) + sign = (-1) ** bin(lower).count("1") + matrix[column ^ (1 << mode), column] = sign + return matrix + + +def fock_matrix(op): + dim = 1 << op.num_modes + result = np.zeros((dim, dim), dtype=complex) + for term, coefficient in op.terms: + matrix = np.eye(dim, dtype=complex) + for ladder in term: + matrix = matrix @ fock_ladder( + op.num_modes, ladder.mode, ladder.kind == "create" + ) + result += coefficient * matrix + return result + + +def test_exports_and_ladder_contract(): + from cqlib.qis.fermion import FermionicOp + + assert FermionicOp is Op + assert Op.__module__ == "cqlib.qis.fermion" + create = Ladder.create(2) + assert create.mode == 2 and create.kind == "create" + assert create.adjoint() == Ladder.annihilate(2) + assert create.adjoint().adjoint() == create + assert len({create, Ladder.create(2)}) == 1 + assert create != 2 + assert repr(create) == "FermionicLadder.create(2)" + with pytest.raises(AttributeError): + create.mode = 1 + with pytest.raises(TypeError): + Ladder() + + +def test_construction_snapshots_and_exact_zero(): + create = Ladder.create(1) + op = Op.from_terms(3, iter([([create], 2), ((create,), -1), ((), 1j)])) + assert op.num_modes == 3 and op.num_terms == 2 + assert all(isinstance(c, complex) for _, c in op.terms) + snapshot = op.terms + assert all(isinstance(term, tuple) for term, _ in snapshot) + snapshot.clear() + assert op.num_terms == 2 + assert Op.from_terms(3, []).is_zero() + assert Op.from_term(3, []).equiv(Op.identity(3)) + tiny = Op.from_term(3, [create], 1e-15) + assert not tiny.is_zero() + assert not tiny.equiv(Op(3)) + assert tiny.chop(atol=1e-15) is None + assert tiny.is_zero() + with pytest.raises(AttributeError): + op.num_modes = 1 + with pytest.raises(TypeError): + hash(op) + + +def test_arithmetic_adjoint_and_normal_order(): + a = Op.annihilation(2, 0) + b = Op.annihilation(2, 1) + identity = Op.identity(2) + assert (a * a.adjoint() + a.adjoint() * a).equiv(identity) + assert (a * b + b * a).equiv(Op(2)) + assert (a * b.adjoint() + b.adjoint() * a).equiv(Op(2)) + contraction = a * a.adjoint() + assert contraction != contraction.normal_order() + assert contraction.equiv(identity - Op.number(2, 0)) + assert not (a * a).is_zero() # Raw product is stored until normal ordering. + assert (a * a).normal_order().is_zero() + assert (a * b).adjoint() == b.adjoint() * a.adjoint() + assert (2j * a) == a * 2j == a.scaled(2j) + assert -a == a * -1 + assert sum([a, b], start=Op(2)) == a + b + assert Op.total_number(2).is_hermitian() + alias = a + a += b + assert alias == Op.annihilation(2, 0) + assert a == alias + b + + +def test_copy_and_mutation_failure_atomicity(): + op = Op.number(2, 0) + for duplicate in (op.copy(), copy.copy(op), copy.deepcopy(op)): + assert duplicate == op and duplicate is not op + duplicate.add_term([], 2) + assert duplicate != op + before = op.copy() + for term, coefficient, error in ( + ([Ladder.create(2)], 0, IndexError), + (["bad"], 1, TypeError), + ([], complex(float("nan"), 0), ValueError), + ): + with pytest.raises(error): + op.add_term(term, coefficient) + assert op == before + assert "num_modes=2" in repr(op) + assert "a†_0 a_0" in str(op) + assert str(Op(0)) == "0" and "I" in str(Op.identity(0)) + + +@pytest.mark.parametrize("value", [-1, 2**100]) +def test_index_errors(value): + error = IndexError if value < 0 else OverflowError + with pytest.raises(error): + Ladder.create(value) + with pytest.raises(error): + Op.creation(2, value) + with pytest.raises(ValueError if value < 0 else OverflowError): + Op(value) + with pytest.raises(ValueError if value < 0 else OverflowError): + JordanWignerMapper().num_qubits(value) + + +@pytest.mark.parametrize("value", [1.2, "1", None]) +def test_type_errors(value): + with pytest.raises(TypeError): + Ladder.create(value) + with pytest.raises(TypeError): + Op(value) + if not isinstance(value, float): + with pytest.raises(TypeError): + Op.from_term(2, [], value) + + +@pytest.mark.parametrize("atol", [-1, float("nan"), float("inf")]) +def test_invalid_tolerances(atol): + op = Op.number(2, 0) + before = op.copy() + for operation in ( + lambda: op.chop(atol=atol), + lambda: op.equiv(before, atol=atol), + lambda: op.is_hermitian(atol=atol), + ): + with pytest.raises(ValueError): + operation() + assert op == before + + +def test_arithmetic_errors_and_reflected_dispatch(): + a = Op.number(2, 0) + b = Op.number(3, 0) + for operation in (lambda: a + b, lambda: a - b, lambda: a * b, lambda: a.equiv(b)): + with pytest.raises(ValueError): + operation() + for operation in (lambda: a + 1, lambda: a * "x", lambda: "x" * a): + with pytest.raises(TypeError): + operation() + assert a.__add__(object()) is NotImplemented + assert a.__sub__(object()) is NotImplemented + assert a.__mul__(object()) is NotImplemented + assert a.__rmul__(object()) is NotImplemented + assert a.__eq__(object()) is NotImplemented + + class Reflected: + def __rmul__(self, other): + return "reflected" + + assert a * Reflected() == "reflected" + for value in (float("nan"), float("inf"), complex(0, float("inf"))): + with pytest.raises(ValueError): + Op(2) * value + with pytest.raises(ValueError): + Op.from_term(2, [], value) + large = Op.from_term(2, [], 1e308) + before = large.copy() + with pytest.raises(ValueError): + large.add_term([], 1e308) + assert large == before + + +@pytest.mark.parametrize( + "sources,targets", [([], []), ([0], [1, 2]), ([0, 0], [1, 2]), ([0], [0])] +) +def test_excitation_validation(sources, targets): + with pytest.raises(ValueError): + Op.excitation_generator(4, sources, targets) + + +def test_mapping_against_independent_fock_products(): + mapper = JordanWignerMapper() + ladders = [ + factory(p) for factory in (Ladder.create, Ladder.annihilate) for p in range(2) + ] + # Exhaustive ordered products exercise JW phases, contractions and fermionic signs. + for length in range(4): + for term in itertools.product(ladders, repeat=length): + op = Op.from_term(2, term, 0.3 + 0.2j) + expected = fock_matrix(op) + np.testing.assert_allclose( + mapper.map_operator(op).to_matrix(), expected, atol=1e-15 + ) + np.testing.assert_allclose( + fock_matrix(op.normal_order()), expected, atol=1e-15 + ) + a = Op.annihilation(2, 0) + np.testing.assert_allclose( + mapper.map_operator(a.adjoint()).to_matrix(), fock_matrix(a).conj().T + ) + combo = Op.from_terms( + 2, [((), 1j), ([Ladder.create(1)], 0.7), ([Ladder.create(1)], 0.3)] + ) + np.testing.assert_allclose( + mapper.map_operator(combo).to_matrix(), fock_matrix(combo) + ) + for op in (Op.number(2, 1), Op.total_number(2)): + np.testing.assert_allclose(mapper.map_operator(op).to_matrix(), fock_matrix(op)) + + +def test_zero_identity_and_occupations(): + mapper = JordanWignerMapper() + for n in (0, 1, 3): + zero = mapper.map_operator(Op(n)) + assert ( + isinstance(zero, Hamiltonian) + and zero.num_qubits == n + and zero.num_terms == 0 + ) + np.testing.assert_allclose(zero.to_matrix(), np.zeros((1 << n, 1 << n))) + np.testing.assert_allclose( + mapper.map_operator(Op.identity(n)).to_matrix(), np.eye(1 << n) + ) + assert mapper.map_occupation([]) == [] + assert mapper.map_occupation([1, False, True]) == [True, False, True] + for value in (-1, 2, 256, 2**100): + with pytest.raises(ValueError): + mapper.map_occupation([value]) + with pytest.raises(TypeError): + mapper.map_occupation([1.0]) + circuit = BasisEncoding(mapper.map_occupation([1, 0, 1])).build_circuit("hf") + state = Statevector.from_circuit(circuit) + np.testing.assert_allclose(state.data, np.eye(8)[5]) + + +def test_integer_protocol_validation(): + class ValidIndex: + def __index__(self): + return 1 + + class InvalidIndex: + def __index__(self): + return 1.0 + + class InstanceOnlyIndex: + def __init__(self): + self.__index__ = lambda: 1 + + mapper = JordanWignerMapper() + assert Ladder.create(ValidIndex()) == Ladder.create(1) + assert mapper.map_occupation([ValidIndex()]) == [True] + assert mapper.num_qubits(ValidIndex()) == 1 + assert Op(np.int64(2)).num_modes == 2 + assert mapper.map_occupation(np.array([0, 1], dtype=np.int64)) == [False, True] + for value in (InvalidIndex(), InstanceOnlyIndex()): + for operation in ( + lambda: Ladder.create(value), + lambda: Op(value), + lambda: mapper.num_qubits(value), + lambda: mapper.map_occupation([value]), + ): + with pytest.raises(TypeError): + operation() + + +def test_protocols_and_python_extension(): + mapper = JordanWignerMapper() + assert isinstance(mapper, FermionToQubitMapper) + assert isinstance(mapper, OccupationStateMapper) + + class CustomMapper: + def map_operator(self, op): + return mapper.map_operator(op) + + def num_qubits(self, num_modes): + return num_modes + + custom = CustomMapper() + assert isinstance(custom, FermionToQubitMapper) + assert not isinstance(custom, OccupationStateMapper) + np.testing.assert_allclose( + custom.map_operator(Op.number(2, 0)).to_matrix(), fock_matrix(Op.number(2, 0)) + ) + + +def test_mapping_preserves_representable_subnormal_and_near_unit_coefficients(): + smallest = float.fromhex("0x0.0000000000001p-1022") + mapper = JordanWignerMapper() + scalar = Op.from_term(0, [], smallest) + assert scalar.normal_order().terms[0][1].real == smallest + assert mapper.map_operator(scalar).terms[0][1].real == smallest + creation = Op.creation(1, 0).scaled(smallest * 2) + assert all( + abs(coefficient) == smallest + for _, coefficient in mapper.map_operator(creation).terms + ) + assert mapper.map_operator(creation).to_matrix()[1, 0].real == smallest * 2 + almost_one = float(np.nextafter(1.0, 2.0)) + number = Op.number(1, 0).scaled(almost_one) + assert number.terms[0][1].real == almost_one + assert mapper.map_operator(number).to_matrix()[1, 1].real == almost_one + + +def test_excitation_evolution_with_independent_parameters(): + mapper = JordanWignerMapper() + generators = [ + Op.excitation_generator(4, [0, 1], [2, 3]), + Op.excitation_generator(4, [0], [2]), + ] + assert generators[0].is_hermitian() + assert Op.excitation_generator(4, [1, 0], [2, 3]).equiv(-generators[0]) + circuit = BasisEncoding(mapper.map_occupation([1, 1, 0, 0])).build_circuit("hf") + for name, generator in zip(("theta_double", "theta_single"), generators): + block = ( + PauliEvolutionAnsatz(mapper.map_operator(generator)) + .with_strategy(EvolutionStrategy.exact()) + .with_time_param_name(name) + .build_circuit("ignored") + ) + circuit.compose(block) + assert set(circuit.symbols) == {"theta_double", "theta_single"} + angles = {"theta_double": 0.23, "theta_single": -0.37} + partial = circuit.assign_parameters({"theta_double": angles["theta_double"]}) + assert partial.symbols == ["theta_single"] + bound = partial.assign_parameters({"theta_single": angles["theta_single"]}) + actual = Statevector.from_circuit(bound) + expected = np.eye(16, dtype=complex)[3] + for generator, theta in zip(generators, angles.values()): + matrix = fock_matrix(generator) + eigenvalues, eigenvectors = np.linalg.eigh(matrix) + expected = ( + (eigenvectors * np.exp(-1j * theta * eigenvalues)) + @ eigenvectors.conj().T + @ expected + ) + np.testing.assert_allclose(actual.data, expected, atol=1e-14) + assert mapper.map_operator(Op.total_number(4)).expectation_statevector( + actual + ) == pytest.approx(2) diff --git a/crates/binding-python/tests/qis/test_hamiltonian.py b/crates/binding-python/tests/qis/test_hamiltonian.py index 969870fb..1abcf710 100644 --- a/crates/binding-python/tests/qis/test_hamiltonian.py +++ b/crates/binding-python/tests/qis/test_hamiltonian.py @@ -16,6 +16,172 @@ from cqlib.qis import Hamiltonian, PauliString, Phase, TrotterMode +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")) + np.testing.assert_array_equal((x * z).to_matrix(), x.to_matrix() @ z.to_matrix()) + np.testing.assert_array_equal( + (x * z).adjoint().to_matrix(), (x.to_matrix() @ z.to_matrix()).conj().T + ) + assert (x - x).num_terms == 0 + assert (2j * x).equiv(x * 2j) + assert (-x).equiv(x.scaled(-1)) + assert x.add(x).equiv(x * 2) + assert x.add(x).num_terms == 1 + # Existing addition and mutation contracts remain compatible. + legacy = x + x + assert legacy.num_terms == 2 + legacy += x + assert legacy.num_terms == 3 + assert legacy.normalized().num_terms == 1 + assert legacy.add_term(PauliString.from_str("Z"), 1) is None + assert legacy.scale(2) is None + + +def test_checked_normalization_and_truncation(): + p = PauliString.from_str("X") + h = Hamiltonian.from_terms(1, [(p, 1e-15), (PauliString.from_str("+iX"), 2e-15)]) + assert h.num_terms == 1 + assert h.terms[0][1] == complex(1e-15, 2e-15) + assert h.normalized().terms == h.terms + assert not h.equiv(Hamiltonian(1)) + truncated = h.copy() + assert truncated.chop(atol=3e-15) is None + assert truncated.num_terms == 0 and truncated.num_qubits == 1 + legacy = h.copy() + legacy.simplify() + assert legacy.num_terms == 0 # Legacy behavior is deliberately retained. + assert Hamiltonian.from_terms(5, []).num_qubits == 5 + assert Hamiltonian.from_terms(0, []).to_matrix().shape == (1, 1) + + +def test_checked_tensor_and_qubit_embedding(): + x = Hamiltonian.from_pauli(PauliString.from_str("X")) + z = Hamiltonian.from_pauli(PauliString.from_str("Z")) + np.testing.assert_array_equal( + x.tensor(z).to_matrix(), np.kron(x.to_matrix(), z.to_matrix()) + ) + embedded = x.remap_qubits([2], target_width=3) + assert embedded.equiv(Hamiltonian.from_pauli(PauliString.from_str("XII"))) + assert x.tensor(Hamiltonian(2)).num_qubits == 3 + wide = x.remap_qubits([69], target_width=70) + assert wide.num_qubits == 70 and wide.num_terms == 1 + assert wide.equiv(wide.adjoint()) + for mapping, width in (([], 1), ([1], 1), ([0, 1], 2)): + with pytest.raises(ValueError): + x.remap_qubits(mapping, width) + with pytest.raises(ValueError): + x.tensor(z).remap_qubits([0, 0], 2) + with pytest.raises(IndexError): + x.remap_qubits([-1], 1) + with pytest.raises(TypeError): + x.remap_qubits([0.5], 1) + + +def test_checked_validation_and_failure_atomicity(): + x = Hamiltonian.from_pauli(PauliString.from_str("X")) + with pytest.raises(ValueError): + Hamiltonian.from_terms(2, [(PauliString.from_str("X"), 0)]) + with pytest.raises(ValueError): + Hamiltonian.from_terms(1, [(PauliString.from_str("X"), float("nan"))]) + with pytest.raises(TypeError): + Hamiltonian.from_terms(1, [(PauliString.from_str("X"), "invalid")]) + with pytest.raises(ValueError): + Hamiltonian.from_terms(-1, []) + for operation in ( + lambda: x.add(Hamiltonian(2)), + lambda: x - Hamiltonian(2), + lambda: x * Hamiltonian(2), + lambda: x.equiv(Hamiltonian(2)), + lambda: Hamiltonian(1).scaled(float("inf")), + ): + with pytest.raises(ValueError): + operation() + assert x.__mul__(object()) is NotImplemented + assert x.__sub__(object()) is NotImplemented + assert x.__rmul__(object()) is NotImplemented + assert x.__add__(object()) is NotImplemented + assert x.__eq__(object()) is NotImplemented + assert x != object() + for atol in (-1, float("nan"), float("inf")): + before = x.copy() + with pytest.raises(ValueError): + x.chop(atol=atol) + assert x == before + with pytest.raises(ValueError): + x.equiv(before, atol=atol) + with pytest.raises(ValueError): + x.is_hermitian(atol=atol) + overflow = x.scaled(1e308) + with pytest.raises(ValueError): + overflow.add(overflow) + with pytest.raises(ValueError): + overflow * 2 + invalid = x.copy() + invalid.scale(float("inf")) # Legacy scale retains its historical behavior. + before = invalid.copy() + with pytest.raises(ValueError): + invalid.chop() + assert invalid.num_qubits == before.num_qubits + assert invalid.num_terms == before.num_terms + for (_, actual), (_, expected) in zip(invalid.terms, before.terms): + assert actual.real == expected.real + assert math.isnan(actual.imag) and math.isnan(expected.imag) + + +def test_hermitian_projection_preserves_small_real_coefficients(): + h = Hamiltonian.from_terms(1, [(PauliString.from_str("X"), 1e-15 + 1e-11j)]) + assert not h.is_hermitian() + assert h.is_hermitian(atol=2e-11) + projected = h.normalized_hermitian() + assert projected.terms[0][1] == complex(1e-15) + assert h.terms[0][1].imag == 1e-11 # Projection returns a copy. + purely_imaginary = Hamiltonian.from_terms(1, [(PauliString.from_str("X"), 1e-11j)]) + assert purely_imaginary.normalized_hermitian().num_terms == 0 + with pytest.raises(ValueError): + Hamiltonian.from_terms( + 1, [(PauliString.from_str("X"), 2e-10j)] + ).normalized_hermitian() + + +def test_sparse_subnormal_and_near_unit_coefficients(): + smallest = float.fromhex("0x0.0000000000001p-1022") + scalar = Hamiltonian.from_terms(0, [(PauliString(0), smallest)]) + assert scalar.normalized().terms[0][1].real == smallest + assert scalar.scaled(2).terms[0][1].real == smallest * 2 + assert scalar.to_matrix()[0, 0].real == smallest + almost_one = float(np.nextafter(1.0, 2.0)) + x = Hamiltonian.from_pauli(PauliString.from_str("X")) + scaled = almost_one * x + assert scaled.terms[0][1].real == almost_one + assert scaled.adjoint().terms[0][1].real == almost_one + residue = scaled - x + assert residue.terms[0][1].real == almost_one - 1.0 + + +def test_small_symbolic_evolution_preserves_rotation_and_global_phase(): + from cqlib.circuit.ansatz import EvolutionStrategy, PauliEvolutionAnsatz + from cqlib.qis import Statevector + + small = 1e-12 + for label in ("X", "I"): + h = Hamiltonian.from_terms(1, [(PauliString.from_str(label), small)]) + circuit = ( + PauliEvolutionAnsatz(h) + .with_strategy(EvolutionStrategy.exact()) + .with_time_param_name("theta") + .build_circuit("small") + ) + with pytest.raises(ValueError): + Statevector.from_circuit( + circuit + ) # Identity-only symbolic phase must also be bound. + bound = circuit.assign_parameters({"theta": 1.0}) + state = Statevector.from_circuit(bound).data + index = 1 if label == "X" else 0 + assert state[index].imag == pytest.approx(-math.sin(small), rel=1e-14, abs=0) + + def test_hamiltonian_initialization(): h = Hamiltonian(2) assert h.num_qubits == 2 diff --git a/crates/cqlib-core/src/qis/state/statevector.rs b/crates/cqlib-core/src/qis/state/statevector.rs index 9b94a36a..9b4f85da 100644 --- a/crates/cqlib-core/src/qis/state/statevector.rs +++ b/crates/cqlib-core/src/qis/state/statevector.rs @@ -471,6 +471,7 @@ impl Statevector { /// Applies a quantum circuit to this statevector in-place. /// + /// The circuit's resolved global phase is applied, retaining nonzero residuals. /// Once bound to circuit qubit IDs, subsequent circuits must have the same /// IDs in the same order. The mapping is committed only on success; gates /// completed before an error are not rolled back. @@ -520,6 +521,15 @@ impl Statevector { // Decompose circuit to basic gates let circuit = circuit.decompose()?; super::validate_terminal_measurements(&circuit)?; + let global_phase = circuit + .global_phase() + .evaluate(&None) + .map_err(|_| QisError::CircuitError(CircuitError::SymbolicParameterError))?; + if !global_phase.is_finite() { + return Err(QisError::InvalidParameterValue( + "circuit global phase must be finite".into(), + )); + } let sv = self; let qubit_map = super::prepare_qubit_map(&circuit.qubits(), &sv.qubit_map)?; @@ -644,6 +654,7 @@ impl Statevector { } } + sv.apply_gphase(global_phase)?; sv.qubit_map = Some(qubit_map); Ok(()) } @@ -2688,7 +2699,7 @@ impl Statevector { /// # Arguments /// * `phi` - Phase angle pub fn apply_gphase(&mut self, phi: f64) -> Result<(), QisError> { - if phi.abs() < 1e-15 { + if phi == 0.0 { return Ok(()); } let phase = Complex64::from_polar(1.0, phi); diff --git a/crates/cqlib-core/src/qis/state/statevector_test.rs b/crates/cqlib-core/src/qis/state/statevector_test.rs index de277f7b..28f9d8a3 100644 --- a/crates/cqlib-core/src/qis/state/statevector_test.rs +++ b/crates/cqlib-core/src/qis/state/statevector_test.rs @@ -908,6 +908,35 @@ fn test_global_phase() { ); } +#[test] +fn circuit_evolution_applies_global_phase_including_small_residuals() { + use crate::circuit::Parameter; + for num_qubits in [0, 1] { + for angle in [0.3, 1e-12, 1e-20] { + let mut circuit = Circuit::new(num_qubits); + circuit.set_global_phase(Parameter::from(angle)); + let state = Statevector::from_circuit(&circuit).unwrap(); + let expected = Complex64::from_polar(1.0, angle); + assert_eq!(state.data[0], expected); + let mut incremental = Statevector::new(num_qubits); + incremental.apply_circuit(&circuit).unwrap(); + assert_eq!(incremental.data[0], expected); + } + } +} + +#[test] +fn circuit_evolution_rejects_unbound_global_phase_before_mutating() { + use crate::circuit::Parameter; + let mut circuit = Circuit::new(1); + circuit.x(Qubit::new(0)).unwrap(); + circuit.set_global_phase(Parameter::symbol("phi")); + let mut state = Statevector::new(1); + let before = state.data.clone(); + assert!(state.apply_circuit(&circuit).is_err()); + assert_eq!(&state.data[..], &before[..]); +} + #[test] fn test_relative_phase() { // S gate introduces relative phase diff --git a/docs/api/cn/python/3_qis/0_overview.md b/docs/api/cn/python/3_qis/0_overview.md index 9339a0b1..fb3f4aba 100644 --- a/docs/api/cn/python/3_qis/0_overview.md +++ b/docs/api/cn/python/3_qis/0_overview.md @@ -29,6 +29,8 @@ ### 观测量与时间演化 +[`FermionicOp` 与 `JordanWignerMapper`](10_fermion.md) 提供通用费米代数、激发生成元及费米到 Pauli 和的映射,可直接复用现有演化组件。 + `PauliString` 与 `Hamiltonian` 描述可观测量,两者都可以给出期望值;`Hamiltonian` 还可以通过 Trotter 分解转成演化线路,回到 `cqlib.circuit` 的线路空间中执行。 --- diff --git a/docs/api/cn/python/3_qis/10_fermion.md b/docs/api/cn/python/3_qis/10_fermion.md new file mode 100644 index 00000000..15670693 --- /dev/null +++ b/docs/api/cn/python/3_qis/10_fermion.md @@ -0,0 +1,72 @@ +# 费米算子与 Jordan–Wigner 映射 + +`cqlib.qis.fermion` 提供通用费米代数与稀疏映射。公共类型也可以从 `cqlib.qis` 导入,计算由 Rust Core 完成。 + +```python +from cqlib.qis import FermionicLadder, FermionicOp, JordanWignerMapper + +a = FermionicOp.annihilation(num_modes=4, mode=0) +identity = FermionicOp.identity(4) +assert (a * a.adjoint() + a.adjoint() * a).equiv(identity) + +term = [FermionicLadder.create(2), FermionicLadder.annihilate(0)] +op = FermionicOp.from_term(num_modes=4, term=term, coefficient=0.5j) +hamiltonian = JordanWignerMapper().map_operator(op) +``` + +## 算子与运算 + +`FermionicLadder.create(mode)` 和 `annihilate(mode)` 创建不可变单算子,提供只读 `mode`、`kind`、`adjoint()`、值比较和哈希。它自身不声明模态空间,越界检查发生在构造 `FermionicOp` 时。 + +`FermionicOp(num_modes)` 创建零算子;`identity(num_modes)` 创建恒等算子。`from_term(num_modes, term, coefficient=1.0)` 接受一个有序乘积;`from_terms(num_modes, terms)` 接受 `(term, coefficient)` 二元组的可迭代对象,合并重复项。空乘积是恒等项,空项集合是零算子,二者都保留模态数量,包括零模态。 + +| 接口 | 行为 | +| --- | --- | +| `creation` / `annihilation` / `number` | 参数为 `num_modes, mode`;构造产生、湮灭、模态数算子。 | +| `total_number(num_modes)` | 构造总粒子数算子。 | +| `A + B` / `A - B` / `-A` | 返回新算子;加减要求模态数量一致。 | +| `A * B` | 算子乘积 AB,B 先作用;不自动正规序。 | +| `c * A` / `A * c` / `A.scaled(c)` | 标量乘法,接受有限的整数、浮点数和复数。 | +| `adjoint()` | 共轭系数、反转乘积并交换产生与湮灭。 | +| `normal_order()` | 返回新展开:产生算子在前,每组模态编号升序,保留收缩项与符号。 | +| `add_term(term, coefficient=1.0)` | 原地添加项;失败后对象不变,返回 `None`。 | +| `chop(*, atol=0.0)` | 原地删除复模长不大于容差的项,返回 `None`。 | +| `equiv(other, *, atol=0.0)` | 正规序后判断代数等价,模态数量不一致报错。 | +| `is_hermitian(*, atol=0.0)` | 判断算子与其共轭转置是否代数等价。 | + +只读属性 `num_modes`、`num_terms` 描述存储;`terms` 返回独立的 `list[(tuple[FermionicLadder, ...], complex)]` 快照。`==` 比较存储结构,`is_zero()` 检查存储为空;数学等价与代数零使用 `equiv()` 判断。 + +默认只删除精确零,不隐式截断微小系数。容差必须有限且非负。`copy()`、`copy.copy()`、`copy.deepcopy()` 创建独立副本。算子可变且不可哈希;`A += B` 重新绑定结果,不修改 A 的别名。求和使用 `sum(operators, start=FermionicOp(num_modes))`。 + +## 激发生成元与演化 + +`excitation_generator(num_modes, sources, targets)` 构造 G = i(T − T†)。T 的产生算子按 targets 原顺序排列,湮灭算子按 sources 逆序排列。例如 `[0, 1] -> [2, 3]` 表示 T = a†₂ a†₃ a₁ a₀。列表必须非空、等长、各自无重复且互不重叠;不自动排序,也不引入阶乘归一化。 + +```python +from cqlib.circuit.ansatz import BasisEncoding, EvolutionStrategy, PauliEvolutionAnsatz + +mapper = JordanWignerMapper() +circuit = BasisEncoding(mapper.map_occupation([1, 1, 0, 0])).build_circuit("hf") +for name, sources, targets in [ + ("theta_double", [0, 1], [2, 3]), + ("theta_single", [0], [2]), +]: + generator = FermionicOp.excitation_generator(4, sources, targets) + block = (PauliEvolutionAnsatz(mapper.map_operator(generator)) + .with_strategy(EvolutionStrategy.exact()) + .with_time_param_name(name).build_circuit("excitation")) + circuit.compose(block) +bound = circuit.assign_parameters({"theta_double": 0.2, "theta_single": -0.3}) +``` + +每块演化为 exp(−iθG) = exp(θ(T − T†))。多个独立参数应分别构造、命名并组合演化块;把生成元相加后构造一块演化会共享一个参数。 + +## 映射与扩展 + +`JordanWignerMapper()` 提供 `map_operator(op) -> Hamiltonian`、`num_qubits(num_modes) -> int` 和 `map_occupation(occupation) -> list[bool]`。算子映射支持非厄米、恒等和零算子;线路与期望值消费者自行要求厄米性。 + +模态从 0 编号,模态 p 对应比特 p,Z 链覆盖小于 p 的编号,比特 0 是最低有效位。占据输入必须为整数 0/1 或布尔值;返回 `bits[p]` 对应比特 p,空输入返回空列表。Core 不规定 α/β 或化学轨道顺序。 + +`FermionToQubitMapper` 与 `OccupationStateMapper` 是独立的、可运行时检查的 `typing.Protocol`。开发者可以实现同签名的方法,不必继承协议;运行时检查只确认成员存在,不验证签名和计算正确性。Python 映射器由算法代码直接调用,不自动桥接到 Rust trait。 + +类型错误抛出 `TypeError`;负数或越界模态抛出 `IndexError`;负模态数量、空间不匹配、非有限系数、非法占据值或容差抛出 `ValueError`;模态整数超出 Rust 可表示范围抛出 `OverflowError`。 diff --git a/docs/api/cn/python/3_qis/7_hamiltonian.md b/docs/api/cn/python/3_qis/7_hamiltonian.md index 6cb11f52..fd09bb46 100644 --- a/docs/api/cn/python/3_qis/7_hamiltonian.md +++ b/docs/api/cn/python/3_qis/7_hamiltonian.md @@ -223,3 +223,39 @@ assert trotterized.num_qubits == 1 | `ValueError` | `expectation_probs` 无法从给定的测量基推出待求算符。 | 观测量计算的输入类型见 [Statevector](1_statevector.md) 与 [DensityMatrix](2_density_matrix.md);Trotter 模式的定义见 [Trotter 演化](8_evolution.md)。 + +## 经过校验的稀疏代数 + +`from_terms(num_qubits, terms)` 明确指定宽度,接受 `(PauliString, 整数/浮点数/复数)` 项。它校验尺寸与有限系数、吸收 Pauli 相位并合并重复项,只删除精确零;空输入保留指定宽度。 + +| 接口 | 行为 | +| --- | --- | +| `normalized()` | 返回校验与合并后的新对象,保留所有非零系数。 | +| `add(other)` | 同宽度的安全加法,返回合并后的新对象。 | +| `A - B`、`A * B`、`-A` | 安全减法、算子乘积及取负;AB 中 B 先作用。 | +| `c * A`、`A * c`、`scaled(c)` | 校验标量及结果系数,返回新对象。 | +| `adjoint()` | 返回共轭转置,包括 Pauli 相位。 | +| `chop(*, atol=0.0)` | 原地规范化,并删除复模长不大于容差的系数;失败后对象不变。 | +| `equiv(other, *, atol=0.0)` | 比较规范化后的系数差,宽度不一致报错;容差不是矩阵算子范数界。 | +| `is_hermitian(*, atol=0.0)` | 与共轭转置比较,即规范化系数满足 2·abs(imag) ≤ atol。 | +| `normalized_hermitian()` | 将模长不大于 1e-10 的虚部残差投影为零,更大虚部报错;保留任意非零实部。 | +| `tensor(other)` | 返回 self ⊗ other,other 占低编号比特。 | +| `remap_qubits(mapping, target_width)` | mapping[source] = destination;目标必须唯一且在范围内,未使用位置补恒等。 | + +这些接口支持非厄米算子,厄米性由演化与期望值消费者校验。所有容差必须有限且非负。比特嵌入是量子比特重标记,不是费米模态置换。 + +```python +from cqlib.qis import Hamiltonian, PauliString + +x = Hamiltonian.from_terms(1, [(PauliString.from_str("X"), 1.0)]) +z = Hamiltonian.from_terms(1, [(PauliString.from_str("Z"), 1.0)]) +assert (x * z).equiv(Hamiltonian.from_terms(1, [(PauliString.from_str("Y"), -1j)])) +assert x.add(x).equiv(2 * x) +assert x.remap_qubits([2], target_width=3).num_qubits == 3 +``` + +### 旧接口兼容 + +`from_list`、`add_term`、`scale`、`+`、`+=` 保留现有行为。其中 `+` 与 `+=` 拼接存储项;`scale` 原地缩放。`simplify()` 使用旧的近零截断策略,会删除微小系数。需要完整保留映射结果时使用 `normalized()`,需要明确截断时使用 `chop(atol=...)`。 + +`add_term()` 和 `scale()` 原地修改并返回 `None`,不支持链式调用。新代数运算遇到宽度不一致、非有限系数或中间系数溢出时抛出 `ValueError`。 diff --git a/docs/api/en/python/3_qis/0_overview.md b/docs/api/en/python/3_qis/0_overview.md index e92b10c1..2564dd96 100644 --- a/docs/api/en/python/3_qis/0_overview.md +++ b/docs/api/en/python/3_qis/0_overview.md @@ -29,6 +29,8 @@ All four simulators provide a `from_circuit()` static method, which simulates a ### Observables and time evolution +[`FermionicOp` and `JordanWignerMapper`](10_fermion.md) provide general fermionic algebra, excitation generators and mappings to Pauli sums for the existing evolution components. + `PauliString` and `Hamiltonian` describe observables, and both can give expectation values; `Hamiltonian` can also be converted into an evolution circuit through Trotter decomposition, returning to the circuit space of `cqlib.circuit` for execution. --- diff --git a/docs/api/en/python/3_qis/10_fermion.md b/docs/api/en/python/3_qis/10_fermion.md new file mode 100644 index 00000000..9044bf9e --- /dev/null +++ b/docs/api/en/python/3_qis/10_fermion.md @@ -0,0 +1,70 @@ +# Fermionic Operators and Jordan–Wigner Mapping + +`cqlib.qis.fermion` exposes general fermionic algebra and sparse mappings backed by Rust Core. Public types are also exported from `cqlib.qis`. + +```python +from cqlib.qis import FermionicLadder, FermionicOp, JordanWignerMapper + +a = FermionicOp.annihilation(num_modes=4, mode=0) +assert (a * a.adjoint() + a.adjoint() * a).equiv(FermionicOp.identity(4)) +term = [FermionicLadder.create(2), FermionicLadder.annihilate(0)] +op = FermionicOp.from_term(num_modes=4, term=term, coefficient=0.5j) +hamiltonian = JordanWignerMapper().map_operator(op) +``` + +## Operators and Arithmetic + +`FermionicLadder.create(mode)` and `annihilate(mode)` construct immutable ladders with read-only `mode` and `kind`, `adjoint()`, value equality and hashing. The operator containing a ladder validates its mode against the declared mode space. + +`FermionicOp(num_modes)` constructs zero; `identity(num_modes)` constructs identity. `from_term(num_modes, term, coefficient=1.0)` accepts an ordered product. `from_terms(num_modes, terms)` accepts an iterable of `(term, coefficient)` tuples and merges duplicate products. An empty product is identity; an empty sum is zero. Both retain their declared mode count, including zero modes. + +| Interface | Behavior | +| --- | --- | +| `creation` / `annihilation` / `number` | Arguments are `num_modes, mode`; construct creation, annihilation or mode-number operators. | +| `total_number(num_modes)` | Construct total particle number. | +| `A + B` / `A - B` / `-A` | Return new operators; addition and subtraction require matching mode counts. | +| `A * B` | Operator product AB: B acts first, without implicit normal ordering. | +| `c * A` / `A * c` / `A.scaled(c)` | Scale by a finite integer, float or complex coefficient. | +| `adjoint()` | Conjugate coefficients, reverse products and exchange ladder kinds. | +| `normal_order()` | Return a new expansion: creators first, ascending modes within each group, including contractions and signs. | +| `add_term(term, coefficient=1.0)` | Mutate atomically and return `None`. | +| `chop(*, atol=0.0)` | Remove coefficients with complex magnitude <= tolerance in place; return `None`. | +| `equiv(other, *, atol=0.0)` | Compare algebraically after normal ordering; mismatched mode counts raise an error. | +| `is_hermitian(*, atol=0.0)` | Compare algebraically with the adjoint. | + +Read-only `num_modes` and `num_terms` describe storage. `terms` returns an independent snapshot of `(tuple[FermionicLadder, ...], complex)` pairs. Equality compares stored structure; `is_zero()` tests empty storage. Use `equiv()` for algebraic equality or zero. + +Only exact zeros are removed by default. Tolerances must be finite and nonnegative. `copy()` and Python copy protocols create independent objects. Operators are mutable and unhashable. `A += B` rebinds the result without modifying aliases of A. Use `sum(operators, start=FermionicOp(num_modes))` to sum operators. + +## Excitation Generators and Evolution + +`excitation_generator(num_modes, sources, targets)` constructs G = i(T − T†), with target creators in input order followed by source annihilators in reverse input order. Thus `[0, 1] -> [2, 3]` means T = a†₂ a†₃ a₁ a₀. Lists must be nonempty, equally sized, individually unique and disjoint. No implicit sorting or factorial normalization is applied. + +```python +from cqlib.circuit.ansatz import BasisEncoding, EvolutionStrategy, PauliEvolutionAnsatz + +mapper = JordanWignerMapper() +circuit = BasisEncoding(mapper.map_occupation([1, 1, 0, 0])).build_circuit("hf") +for name, sources, targets in [ + ("theta_double", [0, 1], [2, 3]), + ("theta_single", [0], [2]), +]: + generator = FermionicOp.excitation_generator(4, sources, targets) + block = (PauliEvolutionAnsatz(mapper.map_operator(generator)) + .with_strategy(EvolutionStrategy.exact()) + .with_time_param_name(name).build_circuit("excitation")) + circuit.compose(block) +bound = circuit.assign_parameters({"theta_double": 0.2, "theta_single": -0.3}) +``` + +Each block implements exp(−iθG) = exp(θ(T − T†)). Build and name separate blocks for independent parameters; evolving a sum of generators uses one shared parameter. + +## Mappings and Extension + +`JordanWignerMapper()` provides `map_operator(op) -> Hamiltonian`, `num_qubits(num_modes) -> int` and `map_occupation(occupation) -> list[bool]`. Operator mapping accepts non-Hermitian, identity and zero operators; downstream evolution and expectation consumers impose Hermiticity requirements. + +Mode p maps to qubit p, with a Z chain on lower-numbered modes. Qubit 0 is the least significant bit. Occupations must be integer zero/one or booleans; returned `bits[p]` addresses qubit p. Empty input returns an empty list. Core specifies no chemical or alpha/beta orbital ordering. + +`FermionToQubitMapper` and `OccupationStateMapper` are independent runtime-checkable `typing.Protocol` interfaces. Custom implementations need matching methods without inheriting a binding class. Runtime checks establish member presence only, not signatures or correctness. Algorithms call Python mappers directly; no automatic Rust trait callback bridge is provided. + +Wrong types raise `TypeError`; negative or out-of-range modes raise `IndexError`. Negative mode counts, mismatched spaces, nonfinite coefficients, invalid occupations or tolerances raise `ValueError`. Mode integers outside the Rust integer range raise `OverflowError`. diff --git a/docs/api/en/python/3_qis/7_hamiltonian.md b/docs/api/en/python/3_qis/7_hamiltonian.md index 98adcc34..9286c66c 100644 --- a/docs/api/en/python/3_qis/7_hamiltonian.md +++ b/docs/api/en/python/3_qis/7_hamiltonian.md @@ -223,3 +223,39 @@ assert trotterized.num_qubits == 1 | `ValueError` | `expectation_probs` cannot derive the target operator from the given measurement bases. | The input types of observable computation are given in [Statevector](1_statevector.md) and [DensityMatrix](2_density_matrix.md); the definition of Trotter modes is given in [Trotter evolution](8_evolution.md). + +## Validated Sparse Algebra + +`from_terms(num_qubits, terms)` declares the width explicitly and accepts `(PauliString, int/float/complex)` pairs. It validates widths and finite coefficients, absorbs Pauli phases and merges duplicates, removing only exact zeros. Empty input retains the declared width. + +| Interface | Behavior | +| --- | --- | +| `normalized()` | Return a validated, merged copy retaining every nonzero coefficient. | +| `add(other)` | Return a validated, merged sum on the same register. | +| `A - B`, `A * B`, `-A` | Checked subtraction, operator multiplication and negation; B acts first in AB. | +| `c * A`, `A * c`, `scaled(c)` | Validate the factor and resulting coefficients; return a new object. | +| `adjoint()` | Return the conjugate transpose, accounting for Pauli phases. | +| `chop(*, atol=0.0)` | Normalize and truncate by complex magnitude in place; errors preserve the object. | +| `equiv(other, *, atol=0.0)` | Compare normalized coefficient differences; width mismatch raises an error. Tolerance is not a matrix operator-norm bound. | +| `is_hermitian(*, atol=0.0)` | Compare with the adjoint: normalized coefficients satisfy 2*abs(imag) <= atol. | +| `normalized_hermitian()` | Project imaginary residuals <= 1e-10 to zero; reject larger ones and retain all nonzero real parts. | +| `tensor(other)` | Return self tensor other; other occupies low qubits. | +| `remap_qubits(mapping, target_width)` | Embed mapping[source] = destination; destinations must be unique and in range. Unused targets are identity. | + +General algebra accepts non-Hermitian operators. Evolution and expectation consumers enforce Hermiticity. Tolerances must be finite and nonnegative. Qubit embedding is not a fermionic mode permutation. + +```python +from cqlib.qis import Hamiltonian, PauliString + +x = Hamiltonian.from_terms(1, [(PauliString.from_str("X"), 1.0)]) +z = Hamiltonian.from_terms(1, [(PauliString.from_str("Z"), 1.0)]) +assert (x * z).equiv(Hamiltonian.from_terms(1, [(PauliString.from_str("Y"), -1j)])) +assert x.add(x).equiv(2 * x) +assert x.remap_qubits([2], target_width=3).num_qubits == 3 +``` + +### Legacy Compatibility + +`from_list`, `add_term`, `scale`, `+` and `+=` keep their existing behavior. Addition concatenates stored terms; `scale` mutates in place. Legacy `simplify()` truncates near-zero coefficients. Use `normalized()` to retain small mapped coefficients, or `chop(atol=...)` for explicit truncation. + +`add_term()` and `scale()` return `None` and do not support chaining. New algebra operations raise `ValueError` on mismatched widths, nonfinite coefficients or intermediate coefficient overflow.