From ac46ea12ce4a9ddfae19ddce9feda83896338a37 Mon Sep 17 00:00:00 2001 From: Jake Lishman Date: Thu, 24 Oct 2024 23:44:49 +0100 Subject: [PATCH] Add iterator-access methods to `SparseObservable` This adds a new related class, `SparseObservable.Term` to be the item type of the iteration methods, and imbibes it with the simplest mathematical binary relations with `SparseObservable` as the other operand. For some reason, in my head, the methods to convert terms and observables to their corresponding Pauli basis are related to iteration? The `SparseObservable.pauli_bases` and `SparseObservable.Term.pauli_base` methods perform a similar bitwise trick to do this reduction as exemplified in the documentation, but return the other `quantum_info`-native classes `PauliList` and `Pauli`, respectively. These methods are expected to be used by the primitives. --- crates/accelerate/src/sparse_observable.rs | 436 +++++++++++++++++- .../quantum_info/test_sparse_observable.py | 300 +++++++++++- 2 files changed, 715 insertions(+), 21 deletions(-) diff --git a/crates/accelerate/src/sparse_observable.rs b/crates/accelerate/src/sparse_observable.rs index 14e386f3a2cb..5c15f26003f0 100644 --- a/crates/accelerate/src/sparse_observable.rs +++ b/crates/accelerate/src/sparse_observable.rs @@ -12,13 +12,14 @@ use std::collections::btree_map; +use ndarray::Array2; use num_complex::Complex64; use num_traits::Zero; use thiserror::Error; use numpy::{ - PyArray1, PyArrayDescr, PyArrayDescrMethods, PyArrayLike1, PyReadonlyArray1, PyReadonlyArray2, - PyUntypedArrayMethods, + npyffi, PyArray1, PyArray2, PyArrayDescr, PyArrayDescrMethods, PyArrayLike1, PyArrayMethods, + PyReadonlyArray1, PyReadonlyArray2, PyReadwriteArray, PyUntypedArrayMethods, }; use pyo3::exceptions::{PyTypeError, PyValueError, PyZeroDivisionError}; use pyo3::intern; @@ -30,6 +31,7 @@ use qiskit_circuit::imports::{ImportOnceCell, NUMPY_COPY_ONLY_IF_NEEDED}; use qiskit_circuit::slice::{PySequenceIndex, SequenceIndex}; static PAULI_TYPE: ImportOnceCell = ImportOnceCell::new("qiskit.quantum_info", "Pauli"); +static PAULI_LIST_TYPE: ImportOnceCell = ImportOnceCell::new("qiskit.quantum_info", "PauliList"); static SPARSE_PAULI_OP_TYPE: ImportOnceCell = ImportOnceCell::new("qiskit.quantum_info", "SparsePauliOp"); @@ -158,6 +160,16 @@ impl BitTerm { _ => Err(BitTermFromU8Error(value)), } } + + /// Is this term an operator or a projector to the X basis? + pub fn is_x_basis(&self) -> bool { + ((*self as u8) & (Self::X as u8)) != 0 + } + + /// Is this term an operator or a projector to the Z basis? + pub fn is_z_basis(&self) -> bool { + ((*self as u8) & (Self::Z as u8)) != 0 + } } static BIT_TERM_PY_ENUM: GILOnceCell> = GILOnceCell::new(); @@ -242,6 +254,24 @@ impl ToPyObject for BitTerm { self.into_py(py) } } +impl<'py> FromPyObject<'py> for BitTerm { + fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult { + let value = ob + .extract::() + .map_err(|_| match ob.get_type().repr() { + Ok(repr) => PyTypeError::new_err(format!("bad type for 'BitTerm': {}", repr)), + Err(err) => err, + })?; + let value_error = || { + PyValueError::new_err(format!( + "value {} is not a valid letter of the single-qubit alphabet for 'BitTerm'", + value + )) + }; + let value: u8 = value.try_into().map_err(|_| value_error())?; + value.try_into().map_err(|_| value_error()) + } +} /// The error type for a failed conversion into `BitTerm`. #[derive(Error, Debug)] @@ -312,6 +342,17 @@ impl From for PyErr { } } +#[derive(Error, Debug)] +pub enum ArithmeticError { + #[error("mismatched numbers of qubits: {left}, {right}")] + MismatchedQubits { left: u32, right: u32 }, +} +impl From for PyErr { + fn from(value: ArithmeticError) -> PyErr { + PyValueError::new_err(value.to_string()) + } +} + /// An observable over Pauli bases that stores its data in a qubit-sparse format. /// /// Mathematics @@ -537,6 +578,10 @@ impl From for PyErr { /// >>> obs.bit_terms[:] = obs.bit_terms[:] & 0b00_11 /// >>> assert obs == SparseObservable.from_list([("XZY", 1.5j), ("XZY", -0.5)]) /// +/// .. note:: +/// +/// The above reduction to the Pauli bases can also be achieved with :meth:`pauli_bases`. +/// /// .. _sparse-observable-canonical-order: /// /// Canonical ordering @@ -575,6 +620,19 @@ impl From for PyErr { /// computationally feasible to do this at scale. For example, on observable built from ``+`` /// and ``-`` components will not canonicalize to a single ``X`` term. /// +/// Indexing +/// -------- +/// +/// :class:`SparseObservable` behaves as `a Python sequence +/// `__ (the standard form, not the expanded +/// :class:`collections.abc.Sequence`). The observable can be indexed by integers, and iterated +/// through to yield individual terms. +/// +/// Each term appears as an instance a self-contained class. The individual terms are copied out of +/// the base observable; mutations to them will not affect the observable. +/// +/// .. autoclass:: qiskit.quantum_info::SparseObservable.Term +/// :members: /// /// Construction /// ============ @@ -603,6 +661,8 @@ impl From for PyErr { /// /// :meth:`from_sparse_pauli_op` Raise a :class:`.SparsePauliOp` into a :class:`SparseObservable`. /// +/// :meth:`from_terms` Sum explicit single :class:`Term` instances. +/// /// :meth:`from_raw_parts` Build the observable from :ref:`the raw data arrays /// `. /// ============================ ================================================================ @@ -689,7 +749,7 @@ impl From for PyErr { /// observable generate only a small number of duplications, and like-term detection has additional /// costs. If this does not fit your use cases, you can either periodically call :meth:`simplify`, /// or discuss further APIs with us for better building of observables. -#[pyclass(module = "qiskit.quantum_info")] +#[pyclass(module = "qiskit.quantum_info", sequence)] #[derive(Clone, Debug, PartialEq)] pub struct SparseObservable { /// The number of qubits the operator acts on. This is not inferable from any other shape or @@ -812,6 +872,7 @@ impl SparseObservable { let start = self.boundaries[i]; let end = self.boundaries[i + 1]; SparseTermView { + num_qubits: self.num_qubits, coeff: *coeff, bit_terms: &self.bit_terms[start..end], indices: &self.indices[start..end], @@ -895,6 +956,19 @@ impl SparseObservable { out } + /// Get an owned representation of a single sparse term. + pub fn term(&self, index: usize) -> SparseTerm { + debug_assert!(index < self.num_terms(), "index {index} out of bounds"); + let start = self.boundaries[index]; + let end = self.boundaries[index + 1]; + SparseTerm { + num_qubits: self.num_qubits, + coeff: self.coeffs[index], + bit_terms: (&self.bit_terms[start..end]).into(), + indices: (&self.indices[start..end]).into(), + } + } + /// Add the term implied by a dense string label onto this observable. pub fn add_dense_label>( &mut self, @@ -931,6 +1005,21 @@ impl SparseObservable { Ok(()) } + /// Add a single term to this operator. + pub fn add_term(&mut self, term: SparseTermView) -> Result<(), ArithmeticError> { + if self.num_qubits != term.num_qubits { + return Err(ArithmeticError::MismatchedQubits { + left: self.num_qubits, + right: term.num_qubits, + }); + } + self.coeffs.push(term.coeff); + self.bit_terms.extend_from_slice(term.bit_terms); + self.indices.extend_from_slice(term.indices); + self.boundaries.push(self.bit_terms.len()); + Ok(()) + } + /// Return a suitable Python error if two observables do not have equal numbers of qubits. fn check_equal_qubits(&self, other: &SparseObservable) -> PyResult<()> { if self.num_qubits != other.num_qubits { @@ -982,7 +1071,7 @@ impl SparseObservable { } return Self::py_from_label(&label).map_err(PyErr::from); } - if let Ok(observable) = data.downcast::() { + if let Ok(observable) = data.downcast_exact::() { check_num_qubits(data)?; return Ok(observable.borrow().clone()); } @@ -1000,6 +1089,12 @@ impl SparseObservable { }; return Self::py_from_sparse_list(vec, num_qubits).map_err(PyErr::from); } + if let Ok(term) = data.downcast_exact::() { + return Ok(term.borrow().to_observable()); + }; + if let Ok(observable) = Self::py_from_terms(data, num_qubits) { + return Ok(observable); + } Err(PyTypeError::new_err(format!( "unknown input format for 'SparseObservable': {}", data.get_type().repr()?, @@ -1082,6 +1177,14 @@ impl SparseObservable { .map(|obj| obj.clone_ref(py)) } + // The documentation for this is inlined into the class-level documentation of + // `SparseObservable`. + #[allow(non_snake_case)] + #[classattr] + fn Term(py: Python) -> Bound { + py.get_type_bound::() + } + /// Get the zero operator over the given number of qubits. /// /// The zero operator is the operator whose expectation value is zero for all quantum states. @@ -1144,6 +1247,22 @@ impl SparseObservable { self.boundaries.truncate(1); } + fn __len__(&self) -> usize { + self.num_terms() + } + + fn __getitem__(&self, py: Python, index: PySequenceIndex) -> PyResult> { + match index.with_len(self.num_terms())? { + SequenceIndex::Int(index) => Ok(self.term(index).into_py(py)), + indices => Ok(PyList::new_bound( + py, + indices.iter().map(|index| self.term(index).into_py(py)), + ) + .into_any() + .unbind()), + } + } + fn __repr__(&self) -> String { let num_terms = format!( "{} term{}", @@ -1159,19 +1278,8 @@ impl SparseObservable { "0.0".to_owned() } else { self.iter() - .map(|term| { - let coeff = format!("{}", term.coeff).replace('i', "j"); - let paulis = term - .indices - .iter() - .zip(term.bit_terms) - .rev() - .map(|(i, op)| format!("{}_{}", op.py_label(), i)) - .collect::>() - .join(" "); - format!("({})({})", coeff, paulis) - }) - .collect::>() + .map(SparseTermView::to_sparse_str) + .collect::>() .join(" + ") }; format!( @@ -1721,6 +1829,42 @@ impl SparseObservable { }) } + /// Construct a :class:`SparseObservable` out of individual terms. + /// + /// All the terms must have the same number of qubits. If supplied, the ``num_qubits`` argument + /// must match the terms. + /// + /// No simplification is done as part of the observable creation. + /// + /// Args: + /// obj (Iterable[Term]): Iterable of individual terms to build the observable from. + /// num_qubits (int | None): The number of qubits the observable should act on. This is + /// usually inferred from the input, but can be explicitly given to handle the case + /// of an empty iterable. + /// + /// Returns: + /// The corresponding observable. + #[staticmethod] + #[pyo3(signature = (obj, /, num_qubits=None), name="from_terms")] + fn py_from_terms(obj: &Bound, num_qubits: Option) -> PyResult { + let mut iter = obj.iter()?; + let mut obs = match num_qubits { + Some(num_qubits) => SparseObservable::zero(num_qubits), + None => { + let Some(first) = iter.next() else { + return Err(PyValueError::new_err( + "cannot construct an observable from an empty list without knowing `num_qubits`", + )); + }; + first?.downcast::()?.borrow().to_observable() + } + }; + for term in iter { + obs.add_term(term?.downcast::()?.borrow().view())?; + } + Ok(obs) + } + // SAFETY: this cannot invoke undefined behaviour if `check = true`, but if `check = false` then // the `bit_terms` must all be valid `BitTerm` representations. /// Construct a :class:`.SparseObservable` from raw Numpy arrays that match :ref:`the required @@ -2020,6 +2164,39 @@ impl SparseObservable { } out } + + /// Get a :class:`.PauliList` object that represents the measurement basis needed for each term + /// (in order) in this observable. + /// + /// For example, the projector ``0l+`` will return a Pauli ``ZXY``. The resulting + /// :class:`.Pauli` is dense, in the sense that explicit identities are stored. An identity in + /// the Pauli output does not require a concrete measurement. + /// + /// This will return an entry in the Pauli list for every term in the sum. + /// + /// Returns: + /// :class:`.PauliList`: the Pauli operator list representing the necessary measurement + /// bases. + #[pyo3(name = "pauli_bases")] + fn py_pauli_bases<'py>(&self, py: Python<'py>) -> PyResult> { + let mut x = Array2::from_elem([self.num_terms(), self.num_qubits as usize], false); + let mut z = Array2::from_elem([self.num_terms(), self.num_qubits as usize], false); + for (loc, term) in self.iter().enumerate() { + let mut x_row = x.row_mut(loc); + let mut z_row = z.row_mut(loc); + for (bit_term, index) in term.bit_terms.iter().zip(term.indices) { + x_row[*index as usize] = bit_term.is_x_basis(); + z_row[*index as usize] = bit_term.is_z_basis(); + } + } + PAULI_LIST_TYPE + .get_bound(py) + .getattr(intern!(py, "from_symplectic"))? + .call1(( + PyArray2::from_owned_array_bound(py, z), + PyArray2::from_owned_array_bound(py, x), + )) + } } impl ::std::ops::Add<&SparseObservable> for SparseObservable { @@ -2199,16 +2376,31 @@ impl ::std::ops::Neg for SparseObservable { } } -/// A view object onto a single term of a [SparseObservable]. +/// A view object onto a single term of a `SparseObservable`. /// -/// The lengths of [bit_terms] and [indices] are guaranteed to be created equal, but might be zero +/// The lengths of `bit_terms` and `indices` are guaranteed to be created equal, but might be zero /// (in the case that the term is proportional to the identity). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, PartialEq, Debug)] pub struct SparseTermView<'a> { + pub num_qubits: u32, pub coeff: Complex64, pub bit_terms: &'a [BitTerm], pub indices: &'a [u32], } +impl<'a> SparseTermView<'a> { + fn to_sparse_str(self) -> String { + let coeff = format!("{}", self.coeff).replace('i', "j"); + let paulis = self + .indices + .iter() + .zip(self.bit_terms) + .rev() + .map(|(i, op)| format!("{}_{}", op.py_label(), i)) + .collect::>() + .join(" "); + format!("({})({})", coeff, paulis) + } +} /// A mutable view object onto a single term of a [SparseObservable]. /// @@ -2217,6 +2409,7 @@ pub struct SparseTermView<'a> { /// this would allow data coherence to be broken. #[derive(Debug)] pub struct SparseTermViewMut<'a> { + pub num_qubits: u32, pub coeff: &'a mut Complex64, pub bit_terms: &'a mut [BitTerm], pub indices: &'a [u32], @@ -2227,6 +2420,7 @@ pub struct SparseTermViewMut<'a> { /// Created by [SparseObservable::iter_mut]. #[derive(Debug)] pub struct IterMut<'a> { + num_qubits: u32, coeffs: &'a mut [Complex64], bit_terms: &'a mut [BitTerm], indices: &'a [u32], @@ -2236,6 +2430,7 @@ pub struct IterMut<'a> { impl<'a> From<&'a mut SparseObservable> for IterMut<'a> { fn from(value: &mut SparseObservable) -> IterMut { IterMut { + num_qubits: value.num_qubits, coeffs: &mut value.coeffs, bit_terms: &mut value.bit_terms, indices: &value.indices, @@ -2269,6 +2464,7 @@ impl<'a> Iterator for IterMut<'a> { self.indices = rest_indices; Some(SparseTermViewMut { + num_qubits: self.num_qubits, coeff, bit_terms, indices, @@ -2472,6 +2668,190 @@ impl ArrayView { } } +/// A single term from a complete :class:`SparseObservable`. +/// +/// These are typically created by indexing into or iterating through a :class:`SparseObservable`. +#[pyclass(name = "Term", frozen, module = "qiskit.quantum_info")] +#[derive(Clone, Debug, PartialEq)] +pub struct SparseTerm { + /// Number of qubits the entire term applies to. + #[pyo3(get)] + num_qubits: u32, + /// The complex coefficient of the term. + #[pyo3(get)] + coeff: Complex64, + bit_terms: Box<[BitTerm]>, + indices: Box<[u32]>, +} +impl SparseTerm { + pub fn view(&self) -> SparseTermView { + SparseTermView { + num_qubits: self.num_qubits, + coeff: self.coeff, + bit_terms: &self.bit_terms, + indices: &self.indices, + } + } +} + +#[pymethods] +impl SparseTerm { + // Mark the Python class as being defined "within" the `SparseObservable` class namespace. + #[classattr] + #[pyo3(name = "__qualname__")] + fn type_qualname() -> &'static str { + "SparseObservable.Term" + } + + #[new] + #[pyo3(signature = (/, num_qubits, coeff, bit_terms, indices))] + fn py_new( + num_qubits: u32, + coeff: Complex64, + bit_terms: Vec, + indices: Vec, + ) -> PyResult { + if bit_terms.len() != indices.len() { + return Err(CoherenceError::MismatchedItemCount { + bit_terms: bit_terms.len(), + indices: indices.len(), + } + .into()); + } + let mut order = (0..bit_terms.len()).collect::>(); + order.sort_unstable_by_key(|a| indices[*a]); + let bit_terms = order.iter().map(|i| bit_terms[*i]).collect(); + let mut sorted_indices = Vec::::with_capacity(order.len()); + for i in order { + let index = indices[i]; + if sorted_indices + .last() + .map(|prev| *prev >= index) + .unwrap_or(false) + { + return Err(CoherenceError::UnsortedIndices.into()); + } + sorted_indices.push(index) + } + Ok(Self { + num_qubits, + coeff, + bit_terms, + indices: sorted_indices.into_boxed_slice(), + }) + } + + /// Convert this term to a complete :class:`SparseObservable`. + pub fn to_observable(&self) -> SparseObservable { + SparseObservable { + num_qubits: self.num_qubits, + coeffs: vec![self.coeff], + bit_terms: self.bit_terms.to_vec(), + indices: self.indices.to_vec(), + boundaries: vec![0, self.bit_terms.len()], + } + } + + fn __eq__(slf: Bound, other: Bound) -> bool { + if slf.is(&other) { + return true; + } + let Ok(other) = other.downcast_into::() else { + return false; + }; + slf.borrow().eq(&other.borrow()) + } + + fn __repr__(&self) -> String { + format!( + "<{} on {} qubit{}: {}>", + Self::type_qualname(), + self.num_qubits, + if self.num_qubits == 1 { "" } else { "s" }, + self.view().to_sparse_str(), + ) + } + + fn __getnewargs__(slf_: Bound, py: Python) -> Py { + let (num_qubits, coeff) = { + let slf_ = slf_.borrow(); + (slf_.num_qubits, slf_.coeff) + }; + ( + num_qubits, + coeff, + Self::get_bit_terms(slf_.clone()), + Self::get_indices(slf_), + ) + .into_py(py) + } + + /// Get a copy of this term. + #[pyo3(name = "copy")] + fn py_copy(&self) -> Self { + self.clone() + } + + /// Read-only view onto the individual single-qubit terms. + /// + /// The only valid values in the array are those with a corresponding + /// :class:`~SparseObservable.BitTerm`. + #[getter] + fn get_bit_terms(slf_: Bound) -> Bound> { + let bit_terms = &slf_.borrow().bit_terms; + let arr = ::ndarray::aview1(::bytemuck::cast_slice::<_, u8>(bit_terms)); + // SAFETY: in order to call this function, the lifetime of `self` must be managed by Python. + // We tie the lifetime of the array to `slf_`, and there are no public ways to modify the + // `Box<[BitTerm]>` allocation (including dropping or reallocating it) other than the entire + // object getting dropped, which Python will keep safe. + let out = unsafe { PyArray1::borrow_from_array_bound(&arr, slf_.into_any()) }; + make_array_readonly(out.readwrite()); + out + } + + /// Read-only view onto the indices of each non-identity single-qubit term. + /// + /// The indices will always be in sorted order. + #[getter] + fn get_indices(slf_: Bound) -> Bound> { + let indices = &slf_.borrow().indices; + let arr = ::ndarray::aview1(indices); + // SAFETY: in order to call this function, the lifetime of `self` must be managed by Python. + // We tie the lifetime of the array to `slf_`, and there are no public ways to modify the + // `Box<[u32]>` allocation (including dropping or reallocating it) other than the entire + // object getting dropped, which Python will keep safe. + let out = unsafe { PyArray1::borrow_from_array_bound(&arr, slf_.into_any()) }; + make_array_readonly(out.readwrite()); + out + } + + /// Get a :class:`.Pauli` object that represents the measurement basis needed for this term. + /// + /// For example, the projector ``0l+`` will return a Pauli ``ZXY``. The resulting + /// :class:`.Pauli` is dense, in the sense that explicit identities are stored. An identity in + /// the Pauli output does not require a concrete measurement. + /// + /// Returns: + /// :class:`.Pauli`: the Pauli operator representing the necessary measurement basis. + /// + /// See also: + /// :meth:`SparseObservable.pauli_bases` + /// A similar method for an entire observable at once. + #[pyo3(name = "pauli_base")] + fn py_pauli_base<'py>(&self, py: Python<'py>) -> PyResult> { + let mut x = vec![false; self.num_qubits as usize]; + let mut z = vec![false; self.num_qubits as usize]; + for (bit_term, index) in self.bit_terms.iter().zip(self.indices.iter()) { + x[*index as usize] = bit_term.is_x_basis(); + z[*index as usize] = bit_term.is_z_basis(); + } + PAULI_TYPE.get_bound(py).call1((( + PyArray1::from_vec_bound(py, z), + PyArray1::from_vec_bound(py, x), + ),)) + } +} + /// Use the Numpy Python API to convert a `PyArray` into a dynamically chosen `dtype`, copying only /// if required. fn cast_array_type<'py, T>( @@ -2532,6 +2912,22 @@ fn coerce_to_observable<'py>( } } +// This method might be added to rust-numpy in a future release, at which point we can use that. +// See https://github.com/PyO3/rust-numpy/pull/462. +/// Clear the `WRITEABLE` flag of the underlying Numpy array. +fn make_array_readonly(array: PyReadwriteArray) +where + T: ::numpy::Element, + D: ::ndarray::Dimension, +{ + // SAFETY: we're clearing the WRITEABLE flag from a pointer that we know is non-null. There + // can't be any existing read-write views onto the array because we consume the only possible + // extant `PyReadwriteArray` on entry. + unsafe { + (*array.as_array_ptr()).flags &= !npyffi::flags::NPY_ARRAY_WRITEABLE; + } +} + pub fn sparse_observable(m: &Bound) -> PyResult<()> { m.add_class::()?; Ok(()) diff --git a/test/python/quantum_info/test_sparse_observable.py b/test/python/quantum_info/test_sparse_observable.py index 551ea414998e..c8dbe2292f00 100644 --- a/test/python/quantum_info/test_sparse_observable.py +++ b/test/python/quantum_info/test_sparse_observable.py @@ -21,7 +21,7 @@ from qiskit.circuit import Parameter from qiskit.exceptions import QiskitError -from qiskit.quantum_info import SparseObservable, SparsePauliOp, Pauli +from qiskit.quantum_info import SparseObservable, SparsePauliOp, Pauli, PauliList from test import QiskitTestCase, combine # pylint: disable=wrong-import-order @@ -133,6 +133,17 @@ def test_default_constructor_copy(self): with self.assertRaisesRegex(ValueError, "explicitly given 'num_qubits'"): SparseObservable(base, num_qubits=base.num_qubits + 1) + def test_default_constructor_term(self): + expected = SparseObservable.from_list([("IIZXII+-", 2j)]) + self.assertEqual(SparseObservable(expected[0]), expected) + + def test_default_constructor_term_iterable(self): + expected = SparseObservable.from_list([("IIZXII+-", 2j), ("rlIIIIII", 0.5)]) + terms = [expected[0], expected[1]] + self.assertEqual(SparseObservable(list(terms)), expected) + self.assertEqual(SparseObservable(tuple(terms)), expected) + self.assertEqual(SparseObservable(term for term in terms), expected) + def test_default_constructor_failed_inference(self): with self.assertRaises(TypeError): # Mixed dense/sparse list. @@ -154,6 +165,13 @@ def test_num_terms(self): SparseObservable.from_list([("IIIXIZ", 1.0), ("YY+-II", 0.5j)]).num_terms, 2 ) + def test_len(self): + self.assertEqual(len(SparseObservable.zero(0)), 0) + self.assertEqual(len(SparseObservable.zero(10)), 0) + self.assertEqual(len(SparseObservable.identity(0)), 1) + self.assertEqual(len(SparseObservable.identity(1_000_000)), 1) + self.assertEqual(len(SparseObservable.from_list([("IIIXIZ", 1.0), ("YY+-II", 0.5j)])), 2) + def test_bit_term_enum(self): # These are very explicit tests that effectively just duplicate magic numbers, but the point # is that those magic numbers are required to be constant as their values are part of the @@ -566,6 +584,41 @@ def test_from_sparse_pauli_op_failures(self): with self.assertRaisesRegex(TypeError, "complex-typed coefficients"): SparseObservable.from_sparse_pauli_op(parametric) + def test_from_terms(self): + self.assertEqual(SparseObservable.from_terms([], num_qubits=5), SparseObservable.zero(5)) + self.assertEqual(SparseObservable.from_terms((), num_qubits=0), SparseObservable.zero(0)) + self.assertEqual( + SparseObservable.from_terms((None for _ in []), num_qubits=3), SparseObservable.zero(3) + ) + + expected = SparseObservable.from_sparse_list( + [ + ("XYZ", (4, 2, 1), 1j), + ("+-rl", (8, 5, 3, 2), 0.5), + ("01", (5, 0), 2.0), + ], + num_qubits=10, + ) + self.assertEqual(SparseObservable.from_terms(list(expected)), expected) + self.assertEqual(SparseObservable.from_terms(tuple(expected)), expected) + self.assertEqual(SparseObservable.from_terms(term for term in expected), expected) + self.assertEqual( + SparseObservable.from_terms( + (term for term in expected), num_qubits=expected.num_qubits + ), + expected, + ) + + def test_from_terms_failures(self): + with self.assertRaisesRegex(ValueError, "cannot construct.*without knowing `num_qubits`"): + SparseObservable.from_terms([]) + + left, right = SparseObservable("IIXYI")[0], SparseObservable("IIIIIIIIX")[0] + with self.assertRaisesRegex(ValueError, "mismatched numbers of qubits"): + SparseObservable.from_terms([left, right]) + with self.assertRaisesRegex(ValueError, "mismatched numbers of qubits"): + SparseObservable.from_terms([left], num_qubits=100) + def test_zero(self): zero_5 = SparseObservable.zero(5) self.assertEqual(zero_5.num_qubits, 5) @@ -1076,6 +1129,10 @@ def test_add_coercion(self): self.assertEqual(base + obs_label, expected) self.assertEqual(obs_label + base, expected) + expected = 3j * SparseObservable.from_label("IXYrlII0I") + self.assertEqual(base + expected[0], expected) + self.assertEqual(expected[0] + base, expected) + with self.assertRaises(TypeError): _ = base + {} with self.assertRaises(TypeError): @@ -1183,6 +1240,10 @@ def test_sub_coercion(self): self.assertEqual(base - obs_label, -expected) self.assertEqual(obs_label - base, expected) + expected = 3j * SparseObservable.from_label("IXYrlII0I") + self.assertEqual(base - expected[0], -expected) + self.assertEqual(expected[0] - base, expected) + with self.assertRaises(TypeError): _ = base - {} with self.assertRaises(TypeError): @@ -1533,3 +1594,240 @@ def test_clear(self, obs): num_qubits = obs.num_qubits obs.clear() self.assertEqual(obs, SparseObservable.zero(num_qubits)) + + def test_pauli_bases(self): + obs = SparseObservable.from_list( + [ + ("IIIII", 1.0), + ("IXYZI", 2.0), + ("+-II+", 1j), + ("rlrlr", -0.5), + ("01010", -0.25), + ("rlYII", 1.0), + ] + ) + expected = PauliList( + [ + Pauli("IIIII"), + Pauli("IXYZI"), + Pauli("XXIIX"), + Pauli("YYYYY"), + Pauli("ZZZZZ"), + Pauli("YYYII"), + ] + ) + self.assertEqual(obs.pauli_bases(), expected) + + def test_iteration(self): + self.assertEqual(list(SparseObservable.zero(5)), []) + self.assertEqual(tuple(SparseObservable.zero(0)), ()) + + obs = SparseObservable.from_sparse_list( + [ + ("Xrl", (4, 2, 1), 2j), + ("", (), 0.5), + ("01", (3, 0), -0.25), + ("+-", (2, 1), 1.0), + ("YZ", (4, 1), 1j), + ], + num_qubits=5, + ) + bit_term = SparseObservable.BitTerm + expected = [ + SparseObservable.Term(5, 2j, [bit_term.LEFT, bit_term.RIGHT, bit_term.X], [1, 2, 4]), + SparseObservable.Term(5, 0.5, [], []), + SparseObservable.Term(5, -0.25, [bit_term.ONE, bit_term.ZERO], [0, 3]), + SparseObservable.Term(5, 1.0, [bit_term.MINUS, bit_term.PLUS], [1, 2]), + SparseObservable.Term(5, 1j, [bit_term.Z, bit_term.Y], [1, 4]), + ] + self.assertEqual(list(obs), expected) + + def test_indexing(self): + obs = SparseObservable.from_sparse_list( + [ + ("Xrl", (4, 2, 1), 2j), + ("", (), 0.5), + ("01", (3, 0), -0.25), + ("+-", (2, 1), 1.0), + ("YZ", (4, 1), 1j), + ], + num_qubits=5, + ) + bit_term = SparseObservable.BitTerm + expected = [ + SparseObservable.Term(5, 2j, [bit_term.LEFT, bit_term.RIGHT, bit_term.X], [1, 2, 4]), + SparseObservable.Term(5, 0.5, [], []), + SparseObservable.Term(5, -0.25, [bit_term.ZERO, bit_term.ONE], [3, 0]), + SparseObservable.Term(5, 1.0, [bit_term.MINUS, bit_term.PLUS], [1, 2]), + SparseObservable.Term(5, 1j, [bit_term.Y, bit_term.Z], [4, 1]), + ] + self.assertEqual(obs[0], expected[0]) + self.assertEqual(obs[-2], expected[-2]) + self.assertEqual(obs[2:4], expected[2:4]) + self.assertEqual(obs[1::2], expected[1::2]) + self.assertEqual(obs[:], expected) + self.assertEqual(obs[-1:-4:-1], expected[-1:-4:-1]) + + @ddt.data( + SparseObservable.identity(0), + SparseObservable.identity(1_000), + SparseObservable.from_label("IIXIZI"), + SparseObservable.from_label("X"), + SparseObservable.from_list([("YIXZII", -0.25)]), + SparseObservable.from_list([("01rl+-", 0.25 + 0.5j)]), + ) + def test_term_repr(self, obs): + # The purpose of this is just to test that the `repr` doesn't crash, rather than asserting + # that it has any particular form. + term = obs[0] + self.assertIsInstance(repr(term), str) + self.assertIn("SparseObservable.Term", repr(term)) + + @ddt.data( + SparseObservable.identity(0), + 2j * SparseObservable.identity(1), + SparseObservable.identity(100), + SparseObservable.from_label("IIX+-rlYZ01IIIII"), + ) + def test_term_to_observable(self, obs): + self.assertEqual(obs[0].to_observable(), obs) + self.assertIsNot(obs[0].to_observable(), obs) + + def test_term_equality(self): + self.assertEqual( + SparseObservable.Term(5, 1.0, [], []), SparseObservable.Term(5, 1.0, [], []) + ) + self.assertNotEqual( + SparseObservable.Term(5, 1.0, [], []), SparseObservable.Term(8, 1.0, [], []) + ) + self.assertNotEqual( + SparseObservable.Term(5, 1.0, [], []), SparseObservable.Term(5, 1j, [], []) + ) + self.assertNotEqual( + SparseObservable.Term(5, 1.0, [], []), SparseObservable.Term(8, -1, [], []) + ) + + obs = SparseObservable.from_list( + [ + ("IIXIZ", 2j), + ("IIZIX", 2j), + ("++III", -1.5), + ("--III", -1.5), + ("IrIlI", 0.5), + ("IIrIl", 0.5), + ] + ) + self.assertEqual(obs[0], obs[0]) + self.assertEqual(obs[1], obs[1]) + self.assertNotEqual(obs[0], obs[1]) + self.assertEqual(obs[2], obs[2]) + self.assertEqual(obs[3], obs[3]) + self.assertNotEqual(obs[2], obs[3]) + self.assertEqual(obs[4], obs[4]) + self.assertEqual(obs[5], obs[5]) + self.assertNotEqual(obs[4], obs[5]) + + @ddt.data( + SparseObservable.identity(0), + 2j * SparseObservable.identity(1), + SparseObservable.identity(100), + SparseObservable.from_label("IIX+-rlYZ01IIIII"), + ) + def test_term_pickle(self, obs): + term = obs[0] + self.assertEqual(pickle.loads(pickle.dumps(term)), term) + self.assertEqual(copy.copy(term), term) + self.assertEqual(copy.deepcopy(term), term) + + def test_term_attributes(self): + term = SparseObservable.from_label("II+IIX0")[0] + self.assertEqual(term.num_qubits, 7) + self.assertEqual(term.coeff, 1.0) + np.testing.assert_equal( + term.bit_terms, + np.array( + [ + SparseObservable.BitTerm.ZERO, + SparseObservable.BitTerm.X, + SparseObservable.BitTerm.PLUS, + ], + dtype=np.uint8, + ), + ) + np.testing.assert_equal(term.indices, np.array([0, 1, 4], dtype=np.uintp)) + + term = SparseObservable.identity(10)[0] + self.assertEqual(term.num_qubits, 10) + self.assertEqual(term.coeff, 1.0) + self.assertEqual(list(term.bit_terms), []) + self.assertEqual(list(term.indices), []) + + term = SparseObservable.from_list([("IIrlZ", 0.5j)])[0] + self.assertEqual(term.num_qubits, 5) + self.assertEqual(term.coeff, 0.5j) + self.assertEqual( + list(term.bit_terms), + [ + SparseObservable.BitTerm.Z, + SparseObservable.BitTerm.LEFT, + SparseObservable.BitTerm.RIGHT, + ], + ) + self.assertEqual(list(term.indices), [0, 1, 2]) + + def test_term_new(self): + expected = SparseObservable.from_label("IIIX+1III")[0] + + self.assertEqual( + SparseObservable.Term( + 9, + 1.0, + [ + SparseObservable.BitTerm.ONE, + SparseObservable.BitTerm.PLUS, + SparseObservable.BitTerm.X, + ], + [3, 4, 5], + ), + expected, + ) + + # Constructor should allow being given unsorted inputs, and but them in the right order. + self.assertEqual( + SparseObservable.Term( + 9, + 1.0, + [ + SparseObservable.BitTerm.PLUS, + SparseObservable.BitTerm.X, + SparseObservable.BitTerm.ONE, + ], + [4, 5, 3], + ), + expected, + ) + self.assertEqual(list(expected.indices), [3, 4, 5]) + + with self.assertRaisesRegex(ValueError, "not term-wise increasing"): + SparseObservable.Term(2, 2j, [SparseObservable.BitTerm.RIGHT] * 2, [0, 0]) + + def test_term_pauli_base(self): + obs = SparseObservable.from_list( + [ + ("IIIII", 1.0), + ("IXYZI", 2.0), + ("+-II+", 1j), + ("rlrlr", -0.5), + ("01010", -0.25), + ("rlYII", 1.0), + ] + ) + expected = [ + Pauli("IIIII"), + Pauli("IXYZI"), + Pauli("XXIIX"), + Pauli("YYYYY"), + Pauli("ZZZZZ"), + Pauli("YYYII"), + ] + self.assertEqual([term.pauli_base() for term in obs], expected)