Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/ferrmion-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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."

Expand Down
2 changes: 1 addition & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion python/ferrmion/core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 41 additions & 5 deletions python/ferrmion/encode/ternary_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
120 changes: 119 additions & 1 deletion python/tests/test_majorana_encoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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])
25 changes: 25 additions & 0 deletions python/tests/test_ternary_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Loading
Loading