diff --git a/CHANGELOG.md b/CHANGELOG.md index f6713570..a6232b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.13.0] - 2026-08-06 + +### Added + +- `MajoranaEncoding.encode_majorana_product(majorana_indices, coeff)` encodes a + single product of Majorana operators, the Majorana counterpart of + `encode_fermion_product`. Majorana operators are hermitian, so the product is + described by its indices alone with no ladder signature. Operators are + multiplied in the order given, and indices outside `[0, 2 * n_modes)` raise + `ValueError`. Such a product is always a single Pauli term, so the result is + returned as a `(pauli_string, coefficient)` tuple rather than a + `QubitHamiltonian`. +- `TernaryTree.encode_majorana_product` exposes the same operation from a tree, + building the encoding on demand, so callers no longer reach into the private + `_encoding` attribute. +- `MajoranaEncoding.encode` now accepts a `MajoranaSparse` as well as a + `FermionHamiltonian`. The Majorana representation was already the internal + encoding path, so callers holding a `MajoranaSparse` (from + `FermionHamiltonian.to_majorana_sparse()`, or alongside `topphatt`) no longer + have to round-trip through a `FermionHamiltonian`. `TernaryTree.encode_naive` + accepts both types for the same reason. +- Encoding a `MajoranaSparse` whose Majorana indices exceed the encoding's + `2 * n_modes` operators now raises `ValueError` instead of panicking. + +### Changed + +- The `MajoranaEncoding.encode` parameter is renamed `fham` to `operator`, since + it is no longer restricted to a `FermionHamiltonian`. Positional calls are + unaffected; callers passing it by keyword need updating. + ## [0.12.0] - 2026-07-27 ### Changed diff --git a/Cargo.lock b/Cargo.lock index c5644334..d0494ae5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -270,7 +270,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "ferrmion" -version = "0.12.0" +version = "0.13.0" dependencies = [ "criterion", "ferrmion-core", @@ -286,7 +286,7 @@ dependencies = [ [[package]] name = "ferrmion-core" -version = "0.12.0" +version = "0.13.0" dependencies = [ "ahash", "argmin", diff --git a/Cargo.toml b/Cargo.toml index 35e82251..9e0a92e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [package] name = "ferrmion" -version = "0.12.0" +version = "0.13.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/crates/ferrmion-core/Cargo.toml b/crates/ferrmion-core/Cargo.toml index 82a0fcd1..67d2b539 100644 --- a/crates/ferrmion-core/Cargo.toml +++ b/crates/ferrmion-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ferrmion-core" -version = "0.12.0" +version = "0.13.0" edition = "2021" description = "Fast, easy and optimised fermion-qubit encodings." diff --git a/docs/source/conf.py b/docs/source/conf.py index 3c968c7f..9f1b1c21 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -18,7 +18,7 @@ project = 'ferrmion' copyright = '2025, Michael Williams de la Bastida' author = 'Michael Williams de la Bastida' -version = "0.12.0" +version = "0.13.0" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index 7e8f30a8..ffc5054e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: Implementation :: CPython", ] -version = "0.12.0" +version = "0.13.0" dependencies = [ "deap>=1.4.3", "rustworkx>=0.16.0", diff --git a/python/ferrmion/core.pyi b/python/ferrmion/core.pyi index 47b07744..10a39e34 100644 --- a/python/ferrmion/core.pyi +++ b/python/ferrmion/core.pyi @@ -119,7 +119,9 @@ class MajoranaEncoding: def symplectic_matrix(self) -> npt.NDArray[np.bool]: ... @property def vacuum_state(self) -> npt.NDArray[np.bool]: ... - def encode(self, fham: FermionHamiltonian) -> QubitHamiltonian: ... + def encode( + self, operator: FermionHamiltonian | MajoranaSparse + ) -> QubitHamiltonian: ... def encode_annealed( self, fham: FermionHamiltonian, @@ -163,6 +165,11 @@ class MajoranaEncoding: coeff: complex = 1.0, with_conjugate: bool = False, ) -> QubitHamiltonian: ... + def encode_majorana_product( + self, + majorana_indices: list[int], + coeff: complex = 1.0, + ) -> tuple[str, complex]: ... def batch_pauli_weights( self, fham: FermionHamiltonian, diff --git a/python/ferrmion/encode/ternary_tree.py b/python/ferrmion/encode/ternary_tree.py index 4923032c..e4d5e0a9 100644 --- a/python/ferrmion/encode/ternary_tree.py +++ b/python/ferrmion/encode/ternary_tree.py @@ -12,7 +12,12 @@ from numpy.typing import NDArray from ferrmion import core -from ferrmion.core import FermionHamiltonian, MajoranaEncoding, QubitHamiltonian +from ferrmion.core import ( + FermionHamiltonian, + MajoranaEncoding, + MajoranaSparse, + QubitHamiltonian, +) from .ternary_tree_node import TTNode, node_sorter @@ -161,18 +166,22 @@ def vacuum_state(self) -> NDArray[np.bool]: """The vacuum state of the encoding represented by this tree.""" return self._encoding.vacuum_state - def encode_naive(self, fham: FermionHamiltonian) -> QubitHamiltonian: - """Encode a fermionic Hamiltonian into a qubit Hamiltonian. + def encode_naive( + self, operator: FermionHamiltonian | MajoranaSparse + ) -> QubitHamiltonian: + """Encode a fermionic operator into a qubit Hamiltonian. Args: - fham (FermionHamiltonian): The fermionic Hamiltonian to encode. + operator (FermionHamiltonian | MajoranaSparse): The operator to encode. + A ``FermionHamiltonian`` is converted to its Majorana representation + first; passing a ``MajoranaSparse`` directly skips that conversion. Returns: QubitHamiltonian: The encoded qubit Hamiltonian. """ if not hasattr(self, "_encoding"): self.build_encoding() - return self._encoding.encode(fham) + return self._encoding.encode(operator) def encode_annealed( self, @@ -323,6 +332,33 @@ def hartree_fock_state( np.asarray(fermionic_hf_state, dtype=bool), mode_op_map ) + def encode_majorana_product( + self, + majorana_indices: list[int], + coeff: complex | float = 1.0, + ) -> tuple[str, complex]: + """Encode a single product of Majorana operators for this encoding. + + Majorana operators are hermitian, so the product is described by its + indices alone. Operators are multiplied in the order given. + + Args: + majorana_indices (list[int]): The index of each Majorana operator in + the product, each in ``[0, 2 * n_modes)``. + coeff (complex | float): The operator coefficient. + + Returns: + tuple[str, complex]: The Pauli string and its coefficient. + + Raises: + ValueError: If any index is negative or beyond ``2 * n_modes``. + """ + if not hasattr(self, "_encoding"): + self.build_encoding() + return self._encoding.encode_majorana_product( + list(majorana_indices), complex(coeff) + ) + def number_operator( self, mode: int, coeff: complex | float = 1.0 ) -> QubitHamiltonian: diff --git a/python/tests/test_majorana_encoding.py b/python/tests/test_majorana_encoding.py index 8483ccb1..81cb5acb 100644 --- a/python/tests/test_majorana_encoding.py +++ b/python/tests/test_majorana_encoding.py @@ -7,7 +7,12 @@ import pytest from hypothesis import given, strategies as st -from ferrmion.core import FermionHamiltonian, MajoranaEncoding, QubitHamiltonian +from ferrmion.core import ( + FermionHamiltonian, + MajoranaEncoding, + MajoranaSparse, + QubitHamiltonian, +) from ferrmion.encode import MaxNTO np.random.seed(1710) @@ -262,3 +267,116 @@ def test_fermion_hamiltonian_pickle_roundtrip(): assert rebuilt == fham assert rebuilt.n_modes == 4 assert rebuilt.constant_energy == 0.25 + + +def test_encode_accepts_majorana_sparse(jw_four): + fham = FermionHamiltonian(terms={"+-": np.eye(4)}) + msparse = fham.to_majorana_sparse() + assert isinstance(msparse, MajoranaSparse) + assert jw_four.encode(msparse) == jw_four.encode(fham) + + +@pytest.mark.parametrize("factory", FACTORIES) +def test_encode_majorana_sparse_matches_fermion_path(factory): + n_modes = 4 + encoding = factory(n_modes) + fham = FermionHamiltonian( + terms={"+-": np.random.rand(n_modes, n_modes)}, + ) + assert encoding.encode(fham.to_majorana_sparse()) == encoding.encode(fham) + + +def test_encode_majorana_sparse_preserves_constant(jw_four): + """The MajoranaSparse constant must reach the identity Pauli string.""" + terms = {"+-": np.eye(4)} + plain = FermionHamiltonian(terms=terms) + with_constant = FermionHamiltonian(terms=terms, constant_energy=0.75) + + msparse = with_constant.to_majorana_sparse() + assert msparse.constant == pytest.approx(0.75) + + qham = jw_four.encode(msparse) + assert qham == jw_four.encode(with_constant) + # The identity coefficient also collects the 1/2-per-mode from each a†a, so + # compare against the same Hamiltonian without a constant energy. + identity_shift = qham["IIII"] - jw_four.encode(plain.to_majorana_sparse())["IIII"] + assert identity_shift == pytest.approx(0.75) + + +def test_encode_majorana_sparse_rejects_out_of_range_indices(jw_four): + """Out-of-range Majorana indices must raise, not panic in a rayon worker.""" + six_mode = FermionHamiltonian(terms={"+-": np.eye(6)}) + msparse = six_mode.to_majorana_sparse() + assert max(max(term) for term in msparse.indices) >= 2 * jw_four.n_modes + with pytest.raises(ValueError) as excinfo: + jw_four.encode(msparse) + assert "modes" in str(excinfo.value) + + +def test_encode_rejects_unsupported_type(jw_four): + with pytest.raises(TypeError): + jw_four.encode("not an operator") + + +def test_encode_majorana_product_returns_pauli_coefficient_pair(jw_four): + """A Majorana product is always a single Pauli term, returned as a tuple.""" + result = jw_four.encode_majorana_product([0]) + assert isinstance(result, tuple) and len(result) == 2 + pauli, coeff = result + assert isinstance(pauli, str) + assert isinstance(coeff, complex) + + +def test_encode_majorana_product_jordan_wigner_convention(jw_four): + """Under JW the first two Majoranas are X and Y on qubit 0.""" + assert jw_four.encode_majorana_product([0]) == ("XIII", 1 + 0j) + assert jw_four.encode_majorana_product([1]) == ("YIII", 1 + 0j) + # X * Y = iZ + assert jw_four.encode_majorana_product([0, 1]) == ("ZIII", 1j) + + +def test_encode_majorana_product_anticommutes(jw_four): + """Swapping two distinct Majoranas flips the sign; squaring gives identity.""" + forward_pauli, forward_coeff = jw_four.encode_majorana_product([0, 1]) + reversed_pauli, reversed_coeff = jw_four.encode_majorana_product([1, 0]) + assert forward_pauli == reversed_pauli + assert reversed_coeff == pytest.approx(-forward_coeff) + + assert jw_four.encode_majorana_product([0, 0]) == ("IIII", 1 + 0j) + assert jw_four.encode_majorana_product([]) == ("IIII", 1 + 0j) + + +@pytest.mark.parametrize("factory", FACTORIES) +def test_encode_majorana_product_matches_number_operator(factory): + """n_i = 1/2 - (i/2) * y_2i * y_2i+1, independently of the encoding. + + Cross-checks encode_majorana_product against the number_operator path. + """ + n_modes = 4 + encoding = factory(n_modes) + identity = "I" * encoding.n_qubits + for mode in range(n_modes): + pauli, coeff = encoding.encode_majorana_product( + [2 * mode, 2 * mode + 1], 0.5j + ) + assert encoding.number_operator(mode).to_dict() == { + identity: 0.5 + 0j, + pauli: coeff, + } + + +def test_encode_majorana_product_scales_coefficient(jw_four): + base_pauli, base_coeff = jw_four.encode_majorana_product([0, 2]) + scaled_pauli, scaled_coeff = jw_four.encode_majorana_product([0, 2], 2.5 - 1j) + assert base_pauli == scaled_pauli + assert scaled_coeff == pytest.approx(base_coeff * (2.5 - 1j)) + + +@pytest.mark.parametrize("bad_index", [-1, 8, 100]) +def test_encode_majorana_product_rejects_out_of_range(jw_four, bad_index): + """Indices run over 2 * n_modes Majoranas, not n_modes.""" + assert jw_four.n_modes == 4 + # The last valid index is 7; nothing beyond it may reach the symplectic rows. + jw_four.encode_majorana_product([7]) + with pytest.raises(ValueError): + jw_four.encode_majorana_product([0, bad_index]) diff --git a/python/tests/test_ternary_tree.py b/python/tests/test_ternary_tree.py index bc74cf2d..72e311cd 100644 --- a/python/tests/test_ternary_tree.py +++ b/python/tests/test_ternary_tree.py @@ -739,3 +739,28 @@ def test_core_python_symplectics_from_flatpack_equal(flatpack): assert np.array_equal( tree_encoding.symplectic_matrix, direct_encoding.symplectic_matrix ) + + +def test_encode_majorana_product_delegates_to_encoding(): + """The helper matches the underlying encoding, without touching _encoding.""" + tree = JKMN(4) + encoding = tree.build_encoding() + for indices in ([0], [0, 1], [1, 0], [2, 5], [0, 1, 2, 3], []): + assert tree.encode_majorana_product(indices) == ( + encoding.encode_majorana_product(indices) + ) + + +def test_encode_majorana_product_builds_encoding_on_demand(): + """Calling the helper before build_encoding() must not raise.""" + tree = TernaryTree.from_flatpack(JW(4).flatpack()) + assert not hasattr(tree, "_encoding") + pauli, coeff = tree.encode_majorana_product([0, 1], 2.0) + assert len(pauli) == tree.n_qubits + assert coeff == pytest.approx(2j) + + +def test_encode_majorana_product_rejects_out_of_range(): + tree = JW(4) + with pytest.raises(ValueError): + tree.encode_majorana_product([8]) diff --git a/src/encoding.rs b/src/encoding.rs index 7f69a4ef..12c5e612 100644 --- a/src/encoding.rs +++ b/src/encoding.rs @@ -2,11 +2,14 @@ use crate::error::CoreError; use crate::hamiltonians::{PyFermionHamiltonian, PyQubitHamiltonian}; +use crate::operators::PyMajoranaSparse; use ferrmion_core::encode::majorana::{Encode, MajoranaEncoding, TryEncode}; use ferrmion_core::encode::maxnto::maxnto_symplectic_matrix; use ferrmion_core::encode::ternarytree::{TTFlatpack, TernaryTree}; use ferrmion_core::hamiltonians::QubitHamiltonian; -use ferrmion_core::operators::{FermionProduct, LadderOperator, SymplecticMatrix}; +use ferrmion_core::operators::{ + FermionProduct, LadderOperator, MajoranaProduct, MajoranaSparse, SymplecticMatrix, +}; use ferrmion_core::optimise::{anneal_enumerations, AnnealingParameters}; use ferrmion_core::states::{FockState, State, ZBasisEnsemble, ZBasisState}; use ndarray::{s, Array1, Array2, ArrayView1, ArrayView2}; @@ -22,6 +25,19 @@ use std::collections::HashSet; #[derive(Clone, Debug)] pub struct PyMajoranaEncoding(pub MajoranaEncoding); +/// The operator types accepted by [`PyMajoranaEncoding::encode`]. +/// +/// Python has no overload resolution and `#[pymethods]` cannot expose a generic +/// method, so the two accepted types are dispatched at runtime by trying each +/// variant's extraction in turn. +#[derive(FromPyObject)] +enum EncodeInput<'py> { + #[pyo3(annotation = "FermionHamiltonian")] + Fermion(PyRef<'py, PyFermionHamiltonian>), + #[pyo3(annotation = "MajoranaSparse")] + Majorana(PyRef<'py, PyMajoranaSparse>), +} + /// Build a [`MajoranaEncoding`] from the `[x|z]` numpy exchange layout. /// /// When `vacuum_state` is `None` the vacuum is determined automatically via @@ -119,6 +135,32 @@ impl PyMajoranaEncoding { } Ok(qham) } + + /// Check that every Majorana index in `hamiltonian` addresses a row of this + /// encoding's symplectic matrix. + /// + /// [`MajoranaSparse`] carries no mode count of its own, and the core encode + /// indexes `operators` unguarded, so an out-of-range index would panic inside + /// a rayon worker rather than raise. Terms are ordered lexicographically + /// rather than by magnitude, so the whole index set has to be scanned; the + /// cost is negligible beside the symplectic multiplies it guards. + fn check_majorana_indices(&self, hamiltonian: &MajoranaSparse) -> Result<(), CoreError> { + let n_operators = 2 * self.0.n_modes; + match hamiltonian + .indices + .iter() + .flat_map(|term| term.iter()) + .copied() + .max() + { + Some(max_index) if max_index as usize >= n_operators => Err(CoreError::Value(format!( + "MajoranaSparse has Majorana index {max_index} but encoding has {} modes \ + ({n_operators} Majorana operators).", + self.0.n_modes + ))), + _ => Ok(()), + } + } } #[pymethods] @@ -308,21 +350,42 @@ impl PyMajoranaEncoding { self.0.vacuum_state.state_bools().into_pyarray(py) } - /// Encode a fermionic Hamiltonian into a qubit Hamiltonian. + /// Encode a fermionic operator into a qubit Hamiltonian. + /// + /// Args: + /// operator: The operator to encode, either a ``FermionHamiltonian`` or a + /// ``MajoranaSparse``. A ``FermionHamiltonian`` is converted to its + /// Majorana representation first; passing a ``MajoranaSparse`` + /// directly skips that conversion. + /// + /// Returns: + /// The encoded ``QubitHamiltonian``. + /// + /// Raises: + /// ValueError: If the operator does not match the mode count of this encoding. fn encode( &self, py: Python<'_>, - fham: PyRef<'_, PyFermionHamiltonian>, + operator: EncodeInput<'_>, ) -> Result { - let fham_n_modes = fham.inner.n_modes(); - if fham_n_modes != 0 && fham_n_modes != self.0.n_modes { - return Err(CoreError::Value(format!( - "FermionHamiltonian has {fham_n_modes} modes but encoding has {} modes.", - self.0.n_modes - ))); - } - let hamiltonian = fham.inner.to_majorana_sparse(); - let qham = py.allow_threads(|| self.0.encode(&hamiltonian)); + let qham = match operator { + EncodeInput::Fermion(fham) => { + let fham_n_modes = fham.inner.n_modes(); + if fham_n_modes != 0 && fham_n_modes != self.0.n_modes { + return Err(CoreError::Value(format!( + "FermionHamiltonian has {fham_n_modes} modes but encoding has {} modes.", + self.0.n_modes + ))); + } + let hamiltonian = fham.inner.to_majorana_sparse(); + py.allow_threads(|| self.0.encode(&hamiltonian)) + } + EncodeInput::Majorana(msparse) => { + let hamiltonian: &MajoranaSparse = &msparse.0; + self.check_majorana_indices(hamiltonian)?; + py.allow_threads(|| self.0.encode(hamiltonian)) + } + }; Ok(PyQubitHamiltonian(qham)) } @@ -554,6 +617,54 @@ impl PyMajoranaEncoding { )?)) } + /// Encode a single product of Majorana operators. + /// + /// Majorana operators are hermitian, so unlike `encode_fermion_product` + /// there is no ladder signature — the product is fully described by its + /// Majorana indices. + /// + /// The operators are multiplied in the order given; reordering is not + /// applied, since the symplectic product already tracks the phase picked up + /// by each multiplication. + /// + /// Args: + /// `majorana_indices`: The index of each Majorana operator in the + /// product, each in ``[0, 2 * n_modes)``. + /// coeff: The operator coefficient. + /// + /// Returns: + /// Tuple of ``(pauli_string, coefficient)``. A Majorana product always + /// encodes to exactly one Pauli term, so no `QubitHamiltonian` is needed + /// to hold the result. + /// + /// Raises: + /// ValueError: If any index is negative or beyond ``2 * n_modes``. + #[pyo3(signature = (majorana_indices, coeff = Complex64::new(1.0, 0.0)))] + fn encode_majorana_product( + &self, + majorana_indices: Vec, + coeff: Complex64, + ) -> Result<(String, Complex64), CoreError> { + let n_operators = 2 * self.0.n_modes as i64; + if let Some(&bad) = majorana_indices + .iter() + .find(|&&i| i < 0 || i >= n_operators) + { + return Err(CoreError::Value(format!( + "Majorana index {bad} out of range for an encoding with {} modes \ + ({n_operators} Majorana operators).", + self.0.n_modes + ))); + } + let indices: Vec = majorana_indices.into_iter().map(|i| i as usize).collect(); + let mproduct = MajoranaProduct::new(indices, coeff); + // `Encode` inserts exactly one entry, so the map + // always yields a single term. + self.0.encode(mproduct).0.into_iter().next().ok_or_else(|| { + CoreError::Value("Majorana product encoded to no Pauli term.".to_string()) + }) + } + /// Compute plain and coefficient-weighted Pauli weights for a batch of /// mode permutations in a single parallelised call. /// diff --git a/uv.lock b/uv.lock index 9d366e8d..9eb4b330 100644 --- a/uv.lock +++ b/uv.lock @@ -629,7 +629,7 @@ wheels = [ [[package]] name = "ferrmion" -version = "0.12.0" +version = "0.13.0" source = { editable = "." } dependencies = [ { name = "deap" },