From c13d129eda8a94c84cc946442533995cd0d9dec6 Mon Sep 17 00:00:00 2001 From: Brad Lackey Date: Fri, 19 Jun 2026 14:40:42 -0700 Subject: [PATCH] Added (Majorana) fermions to basic operator types. --- .../magnets/utilities/__init__.py | 34 +- .../applications/magnets/utilities/fermion.py | 246 ++++++++++++++ .../magnets/utilities/majorana.py | 271 +++++++++++++++ .../applications/magnets/utilities/pauli.py | 54 ++- .../tests/applications/magnets/test_pauli.py | 317 +++++++++++++++++- 5 files changed, 915 insertions(+), 7 deletions(-) create mode 100644 source/qdk_package/qdk/applications/magnets/utilities/fermion.py create mode 100644 source/qdk_package/qdk/applications/magnets/utilities/majorana.py diff --git a/source/qdk_package/qdk/applications/magnets/utilities/__init__.py b/source/qdk_package/qdk/applications/magnets/utilities/__init__.py index b350f7da40..db7f4ecb4b 100644 --- a/source/qdk_package/qdk/applications/magnets/utilities/__init__.py +++ b/source/qdk_package/qdk/applications/magnets/utilities/__init__.py @@ -7,17 +7,49 @@ the magnets package, including hypergraph representations. """ +from .fermion import ( + Fermion, + FermionAnnihilation, + FermionCreation, + FermionString, + hopping_term, +) from .hypergraph import ( Hyperedge, Hypergraph, HypergraphEdgeColoring, ) -from .pauli import Pauli, PauliString, PauliX, PauliY, PauliZ +from .majorana import ( + edge_operator, + Majorana, + MajoranaDualFermion, + MajoranaFermion, + MajoranaString, + vertex_operator, +) +from .pauli import ( + Pauli, + PauliString, + PauliX, + PauliY, + PauliZ, +) __all__ = [ "Hyperedge", "Hypergraph", "HypergraphEdgeColoring", + "Fermion", + "FermionAnnihilation", + "FermionCreation", + "FermionString", + "hopping_term", + "Majorana", + "MajoranaDualFermion", + "MajoranaFermion", + "MajoranaString", + "edge_operator", + "vertex_operator", "Pauli", "PauliString", "PauliX", diff --git a/source/qdk_package/qdk/applications/magnets/utilities/fermion.py b/source/qdk_package/qdk/applications/magnets/utilities/fermion.py new file mode 100644 index 0000000000..57bb520d66 --- /dev/null +++ b/source/qdk_package/qdk/applications/magnets/utilities/fermion.py @@ -0,0 +1,246 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Fermionic operator representations for many-body systems.""" + +from collections.abc import Sequence + + +class Fermion: + """Single-mode fermionic operator tied to an explicit site index. + + ``Fermion`` stores a fermionic operator identifier and the site it acts on. + The identifier can be provided either as an integer code or a label: + + - ``10`` / ``"A^"`` / ``"CREATE"`` / ``"CREATION"`` + - ``11`` / ``"A"`` / ``"ANNIHILATE"`` / ``"ANNIHILATION"`` + + The annihilation operator ``A`` is treated as primitive, and the creation + operator ``A^`` denotes its Hermitian conjugate. + + Example: + + .. code-block:: python + >>> f = Fermion("A^", site=2) + >>> f.op + 10 + >>> f.site + 2 + """ + + _VALID_INTS = {10, 11} + _STR_TO_INT = { + "A^": 10, + "CREATE": 10, + "CREATION": 10, + "A": 11, + "ANNIHILATE": 11, + "ANNIHILATION": 11, + } + _INT_TO_LABEL = {10: "A^", 11: "A"} + + def __init__(self, value: int | str, site: int = 0) -> None: + """Initialize a fermionic operator. + + Args: + value: An integer 10-11 or a creation/annihilation label. + site: The index of the site this operator acts on. Defaults to 0. + + Raises: + ValueError: If ``value`` is not a valid integer/string fermion identifier. + """ + if isinstance(value, int): + if value not in self._VALID_INTS: + raise ValueError(f"Integer value must be 10 or 11, got {value}.") + self._op = value + elif isinstance(value, str): + key = value.upper() + if key not in self._STR_TO_INT: + raise ValueError( + "String value must be one of 'A^', 'CREATE', 'CREATION', " + f"'A', 'ANNIHILATE', 'ANNIHILATION', got '{value}'." + ) + self._op = self._STR_TO_INT[key] + else: + raise ValueError(f"Expected int or str, got {type(value).__name__}.") + self.site: int = site + + @property + def op(self) -> int: + """Integer encoding of this fermionic term.""" + return self._op + + def __str__(self) -> str: + return f"{self._INT_TO_LABEL[self._op]}({self.site})" + + def __repr__(self) -> str: + return f"Fermion('{self._INT_TO_LABEL[self._op]}', site={self.site})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Fermion): + return NotImplemented + return self._op == other._op and self.site == other.site + + def __hash__(self) -> int: + return hash((self._op, self.site)) + + +def FermionCreation(site: int) -> Fermion: + """Create a fermionic creation operator on the given site.""" + return Fermion("A^", site) + + +def FermionAnnihilation(site: int) -> Fermion: + """Create a fermionic annihilation operator on the given site.""" + return Fermion("A", site) + + +class FermionString: + """Ordered product of single-site ``Fermion`` terms with a coefficient. + + ``FermionString`` stores: + + - an ordered tuple of :class:`Fermion` objects (including each term's site), and + - a complex scalar coefficient. + + Construction options: + + - pass a sequence of :class:`Fermion` objects to ``FermionString(...)`` + - use :meth:`from_sites` to pair site indices with fermion labels or codes + + Example: + + .. code-block:: python + >>> fs = FermionString([FermionCreation(0), FermionAnnihilation(1)], coefficient=-1j) + >>> fs.sites + (0, 1) + >>> fs2 = FermionString.from_sites((0, 1), ["A^", "A"], coefficient=-1j) + >>> fs == fs2 + True + """ + + def __init__(self, fermions: Sequence[Fermion], coefficient: complex = 1.0) -> None: + """Initialize a FermionString from a sequence of Fermion operators. + + Args: + fermions: A sequence of :class:`Fermion` instances, each with its + own site index. + coefficient: Complex coefficient multiplying the fermion string. + + Raises: + TypeError: If any element is not a Fermion instance. + """ + for fermion in fermions: + if not isinstance(fermion, Fermion): + raise TypeError( + f"Expected Fermion instance, got {type(fermion).__name__}. " + "Use FermionString.from_sites() for int/str values." + ) + self._fermions: tuple[Fermion, ...] = tuple(fermions) + self._coefficient: complex = coefficient + + @classmethod + def from_sites( + cls, + sites: tuple[int, ...], + values: Sequence[int | str], + coefficient: complex = 1.0, + ) -> "FermionString": + """Create a FermionString from site indices and fermion labels. + + Args: + sites: Tuple of site indices. + values: Sequence of fermion identifiers (integers 10-11 or strings + like 'A^' and 'A'). + coefficient: Complex coefficient multiplying the fermion string. + + Returns: + A new FermionString instance. + + Raises: + ValueError: If sites and values have different lengths, or if + any value is not a valid fermion identifier. + """ + if len(sites) != len(values): + raise ValueError( + f"Length mismatch: {len(sites)} sites vs {len(values)} values." + ) + fermions = [Fermion(value, site) for site, value in zip(sites, values)] + return cls(fermions, coefficient=coefficient) + + @property + def sites(self) -> tuple[int, ...]: + """Tuple of site indices in the same order as the stored Fermion terms.""" + return tuple(fermion.site for fermion in self._fermions) + + @property + def coefficient(self) -> complex: + """Complex coefficient multiplying this fermion string.""" + return self._coefficient + + @property + def fermions(self) -> tuple[str, ...]: + """Tuple of canonical fermion labels in stored order.""" + return tuple(Fermion._INT_TO_LABEL[fermion.op] for fermion in self._fermions) + + def __iter__(self): + """Iterate over Fermion terms in stored order.""" + return iter(self._fermions) + + def __len__(self) -> int: + return len(self._fermions) + + def __getitem__(self, index: int) -> Fermion: + return self._fermions[index] + + def __mul__(self, scalar: complex) -> "FermionString": + """Scale the coefficient of this FermionString by a complex scalar.""" + return FermionString(self._fermions, coefficient=self._coefficient * scalar) + + def hermitian_conjugate(self) -> "FermionString": + """Return the Hermitian conjugate of this fermion string. + + Returns: + A new ``FermionString`` with reversed operator order, each + annihilation/creation operator swapped with its conjugate, and the + coefficient complex-conjugated. + """ + conjugated_ops = [ + Fermion(10 if fermion.op == 11 else 11, fermion.site) + for fermion in reversed(self._fermions) + ] + return FermionString(conjugated_ops, coefficient=self._coefficient.conjugate()) + + def __str__(self) -> str: + return f"{self._coefficient} * {''.join(map(str, self._fermions))}" + + def __repr__(self) -> str: + ops = list(self.fermions) + return ( + f"FermionString(sites={self.sites}, ops={ops}, " + f"coefficient={self._coefficient})" + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, FermionString): + return NotImplemented + return self._fermions == other._fermions and self._coefficient == other._coefficient + + def __hash__(self) -> int: + return hash((self._fermions, self._coefficient)) + + +def hopping_term(j: int, k: int) -> FermionString: + """Create the hopping term :math:`A^_j A_k`. + + Args: + j: Site index of the creation operator. + k: Site index of the annihilation operator. + + Returns: + A fermion string representing ``A^[j] * A[k]``. + + Note: + Setting ``j = k`` yields the number operator on that site. + """ + return FermionString([FermionCreation(j), FermionAnnihilation(k)]) diff --git a/source/qdk_package/qdk/applications/magnets/utilities/majorana.py b/source/qdk_package/qdk/applications/magnets/utilities/majorana.py new file mode 100644 index 0000000000..9a742a80d9 --- /dev/null +++ b/source/qdk_package/qdk/applications/magnets/utilities/majorana.py @@ -0,0 +1,271 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Majorana fermion representations for many-body systems.""" + +from collections.abc import Sequence + + +class Majorana: + """Single-mode Majorana operator tied to an explicit site index. + + ``Majorana`` stores a Majorana operator identifier and the site it acts on. + The identifier can be provided either as an integer code or a label: + + - ``12`` / ``"G"`` + - ``13`` / ``"G'"`` + + Example: + + .. code-block:: python + >>> g = Majorana("G'", site=2) + >>> g.op + 13 + >>> g.site + 2 + """ + + _VALID_INTS = {12, 13} + _STR_TO_INT = { + "G": 12, + "G'": 13, + } + _INT_TO_LABEL = {12: "G", 13: "G'"} + + def __init__(self, value: int | str, site: int = 0) -> None: + """Initialize a Majorana operator. + + Args: + value: An integer 12-13 or one of 'G', 'G''. + site: The index of the site this operator acts on. Defaults to 0. + + Raises: + ValueError: If ``value`` is not a valid integer/string Majorana identifier. + """ + if isinstance(value, int): + if value not in self._VALID_INTS: + raise ValueError(f"Integer value must be 12 or 13, got {value}.") + self._op = value + elif isinstance(value, str): + key = value.upper() + if key not in self._STR_TO_INT: + raise ValueError(f"String value must be one of 'G', \"G'\", got '{value}'.") + self._op = self._STR_TO_INT[key] + else: + raise ValueError(f"Expected int or str, got {type(value).__name__}.") + self.site: int = site + + @property + def op(self) -> int: + """Integer encoding of this Majorana term.""" + return self._op + + def __str__(self) -> str: + return f"{self._INT_TO_LABEL[self._op]}({self.site})" + + def __repr__(self) -> str: + return f"Majorana('{self._INT_TO_LABEL[self._op]}', site={self.site})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Majorana): + return NotImplemented + return self._op == other._op and self.site == other.site + + def __hash__(self) -> int: + return hash((self._op, self.site)) + + +def MajoranaFermion(site: int) -> Majorana: + """Create a Majorana fermion on the given site.""" + return Majorana("G", site) + + +def MajoranaDualFermion(site: int) -> Majorana: + """Create a dual Majorana fermion on the given site.""" + return Majorana("G'", site) + + +class MajoranaString: + """Ordered product of single-site ``Majorana`` terms with a coefficient. + + ``MajoranaString`` stores: + + - an ordered tuple of :class:`Majorana` objects (including each term's site), and + - a complex scalar coefficient. + + Construction options: + + - pass a sequence of :class:`Majorana` objects to ``MajoranaString(...)`` + - use :meth:`from_sites` to pair site indices with Majorana labels or codes + + Example: + + .. code-block:: python + >>> ms = MajoranaString([MajoranaFermion(0), MajoranaDualFermion(1)], coefficient=-1j) + >>> ms.sites + (0, 1) + >>> ms2 = MajoranaString.from_sites((0, 1), ["G", "G'"], coefficient=-1j) + >>> ms == ms2 + True + """ + + def __init__(self, majoranas: Sequence[Majorana], coefficient: complex = 1.0) -> None: + """Initialize a MajoranaString from a sequence of Majorana operators. + + Args: + majoranas: A sequence of :class:`Majorana` instances, each with its + own site index. + coefficient: Complex coefficient multiplying the Majorana string. + + Raises: + TypeError: If any element is not a Majorana instance. + """ + for majorana in majoranas: + if not isinstance(majorana, Majorana): + raise TypeError( + f"Expected Majorana instance, got {type(majorana).__name__}. " + "Use MajoranaString.from_sites() for int/str values." + ) + self._majoranas: tuple[Majorana, ...] = tuple(majoranas) + self._coefficient: complex = coefficient + + @classmethod + def from_sites( + cls, + sites: tuple[int, ...], + values: Sequence[int | str], + coefficient: complex = 1.0, + ) -> "MajoranaString": + """Create a MajoranaString from site indices and Majorana labels. + + Args: + sites: Tuple of site indices. + values: Sequence of Majorana identifiers (integers 12-13 or strings + like 'G' and "G'"). + coefficient: Complex coefficient multiplying the Majorana string. + + Returns: + A new MajoranaString instance. + + Raises: + ValueError: If sites and values have different lengths, or if + any value is not a valid Majorana identifier. + """ + if len(sites) != len(values): + raise ValueError( + f"Length mismatch: {len(sites)} sites vs {len(values)} values." + ) + majoranas = [Majorana(value, site) for site, value in zip(sites, values)] + return cls(majoranas, coefficient=coefficient) + + @property + def sites(self) -> tuple[int, ...]: + """Tuple of site indices in the same order as the stored Majorana terms.""" + return tuple(majorana.site for majorana in self._majoranas) + + @property + def coefficient(self) -> complex: + """Complex coefficient multiplying this Majorana string.""" + return self._coefficient + + @property + def majoranas(self) -> tuple[str, ...]: + """Tuple of canonical Majorana labels in stored order.""" + return tuple(Majorana._INT_TO_LABEL[majorana.op] for majorana in self._majoranas) + + def __iter__(self): + """Iterate over Majorana terms in stored order.""" + return iter(self._majoranas) + + def __len__(self) -> int: + return len(self._majoranas) + + def __getitem__(self, index: int) -> Majorana: + return self._majoranas[index] + + def __mul__(self, scalar: complex) -> "MajoranaString": + """Scale the coefficient of this MajoranaString by a complex scalar.""" + return MajoranaString(self._majoranas, coefficient=self._coefficient * scalar) + + def normalize(self) -> None: + """Normalize this Majorana string in place. + + The normalization performs two operations using adjacent swaps only: + + - reorder terms into ascending site order with the convention ``G[j] < G'[j]``, + - cancel adjacent equal Majorana terms using ``G[j]G[j] = I`` and + ``G'[j]G'[j] = I``. + + Every swap of adjacent distinct Majorana terms flips the sign of the + coefficient. + """ + + def order_key(majorana: Majorana) -> tuple[int, int]: + return (majorana.site, 0 if majorana.op == 12 else 1) + + majoranas = list(self._majoranas) + coefficient = self._coefficient + index = 0 + + while index < len(majoranas) - 1: + if majoranas[index] == majoranas[index + 1]: + del majoranas[index : index + 2] + index = max(index - 1, 0) + continue + + if order_key(majoranas[index]) > order_key(majoranas[index + 1]): + majoranas[index], majoranas[index + 1] = ( + majoranas[index + 1], + majoranas[index], + ) + coefficient *= -1 + index = max(index - 1, 0) + continue + + index += 1 + + self._majoranas = tuple(majoranas) + self._coefficient = coefficient + + def __str__(self) -> str: + return f"{self._coefficient} * {''.join(map(str, self._majoranas))}" + + def __repr__(self) -> str: + ops = list(self.majoranas) + return ( + f"MajoranaString(sites={self.sites}, ops={ops}, " + f"coefficient={self._coefficient})" + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, MajoranaString): + return NotImplemented + return self._majoranas == other._majoranas and self._coefficient == other._coefficient + + def __hash__(self) -> int: + return hash((self._majoranas, self._coefficient)) + + +def vertex_operator(j: int) -> MajoranaString: + """Create the vertex operator :math:`i G_j G'_j`. + + Args: + j: Site index of both Majorana operators. + + Returns: + A Majorana string representing ``i * G[j] * G'[j]``. + """ + return MajoranaString([MajoranaFermion(j), MajoranaDualFermion(j)]) * 1j + + +def edge_operator(j: int, k: int) -> MajoranaString: + """Create the edge operator :math:`i G_j G_k`. + + Args: + j: Site index of the first Majorana operator. + k: Site index of the second Majorana operator. + + Returns: + A Majorana string representing ``i * G[j] * G[k]``. + """ + return MajoranaString([MajoranaFermion(j), MajoranaFermion(k)]) * 1j diff --git a/source/qdk_package/qdk/applications/magnets/utilities/pauli.py b/source/qdk_package/qdk/applications/magnets/utilities/pauli.py index 8acee236fa..4453f588d7 100644 --- a/source/qdk_package/qdk/applications/magnets/utilities/pauli.py +++ b/source/qdk_package/qdk/applications/magnets/utilities/pauli.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Pauli operator representation for quantum spin systems.""" +"""Pauli operator representations for quantum spin systems.""" from collections.abc import Sequence @@ -234,6 +234,49 @@ def __mul__(self, scalar: complex) -> "PauliString": """Scale the coefficient of this PauliString by a complex scalar.""" return PauliString(self._paulis, coefficient=self._coefficient * scalar) + def normalize(self) -> None: + """Normalize this Pauli string in place. + + The normalization performs three steps: + + - reorder terms by ascending qubit index, + - combine repeated uses of the same qubit by multiplying adjacent Pauli terms, + - remove identity terms from the stored Pauli sequence. + """ + multiplication_table = { + (0, 0): (1, 0), + (0, 1): (1, 1), + (0, 2): (1, 2), + (0, 3): (1, 3), + (1, 0): (1, 1), + (1, 1): (1, 0), + (1, 2): (-1j, 3), + (1, 3): (1j, 2), + (2, 0): (1, 2), + (2, 1): (1j, 3), + (2, 2): (1, 0), + (2, 3): (-1j, 1), + (3, 0): (1, 3), + (3, 1): (-1j, 2), + (3, 2): (1j, 1), + (3, 3): (1, 0), + } + + sorted_paulis = sorted(self._paulis, key=lambda pauli: pauli.qubit) + normalized: list[Pauli] = [] + coefficient = self._coefficient + + for pauli in sorted_paulis: + if normalized and normalized[-1].qubit == pauli.qubit: + phase, op = multiplication_table[(normalized[-1].op, pauli.op)] + coefficient *= phase + normalized[-1] = Pauli(op, pauli.qubit) + else: + normalized.append(pauli) + + self._paulis = tuple(pauli for pauli in normalized if pauli.op != 0) + self._coefficient = coefficient + def __str__(self) -> str: labels = {0: "I", 1: "X", 2: "Z", 3: "Y"} s = "".join(map(str, self._paulis)) @@ -257,14 +300,17 @@ def cirq(self): """Return the corresponding Cirq ``PauliString``. Constructs a ``cirq.PauliString`` by applying each single-qubit - Pauli to its corresponding ``cirq.LineQubit``. + Pauli to its corresponding ``cirq.LineQubit`` after normalizing any + repeated qubit indices. Returns: A ``cirq.PauliString`` on ``cirq.LineQubit`` instances with ``self._coefficient`` as its coefficient. """ + normalized = PauliString(self._paulis, coefficient=self._coefficient) + normalized.normalize() _INT_TO_CIRQ = (cirq.I, cirq.X, cirq.Z, cirq.Y) return cirq.PauliString( - {cirq.LineQubit(p.qubit): _INT_TO_CIRQ[p.op] for p in self._paulis}, - coefficient=self._coefficient, + {cirq.LineQubit(p.qubit): _INT_TO_CIRQ[p.op] for p in normalized._paulis}, + coefficient=normalized._coefficient, ) diff --git a/source/qdk_package/tests/applications/magnets/test_pauli.py b/source/qdk_package/tests/applications/magnets/test_pauli.py index 306e6b4597..c5f8616bb5 100644 --- a/source/qdk_package/tests/applications/magnets/test_pauli.py +++ b/source/qdk_package/tests/applications/magnets/test_pauli.py @@ -1,13 +1,301 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -"""Unit tests for Pauli and PauliString utilities.""" +"""Unit tests for Pauli, Fermion, Majorana, and string utilities.""" import pytest cirq = pytest.importorskip("cirq") -from qdk.applications.magnets import Pauli, PauliString, PauliX, PauliY, PauliZ +from qdk.applications.magnets import ( + edge_operator, + Fermion, + FermionAnnihilation, + FermionCreation, + FermionString, + Majorana, + MajoranaDualFermion, + MajoranaFermion, + MajoranaString, + Pauli, + PauliString, + PauliX, + PauliY, + PauliZ, + hopping_term, + vertex_operator, +) + + +def test_majorana_init_from_int_and_string(): + """Test Majorana initialization from int and string labels.""" + majorana = Majorana(12, site=1) + majorana_alias = Majorana("g", site=2) + dual = Majorana(13, site=3) + dual_alias = Majorana("G'", site=4) + + assert majorana.op == 12 and majorana.site == 1 + assert majorana_alias.op == 12 and majorana_alias.site == 2 + assert dual.op == 13 and dual.site == 3 + assert dual_alias.op == 13 and dual_alias.site == 4 + + +@pytest.mark.parametrize("value", [11, 14, 42]) +def test_majorana_invalid_int_raises(value: int): + """Test invalid integer Majorana identifiers raise ValueError.""" + with pytest.raises(ValueError, match="Integer value must be 12 or 13"): + Majorana(value) + + +def test_majorana_invalid_string_raises(): + """Test invalid string Majorana identifiers raise ValueError.""" + with pytest.raises(ValueError, match="String value must be one of"): + Majorana("A") + + +def test_majorana_invalid_type_raises(): + """Test non-int/non-str Majorana identifiers raise ValueError.""" + with pytest.raises(ValueError, match="Expected int or str"): + Majorana(1.5) # type: ignore[arg-type] + + +def test_majorana_helpers_create_expected_operator(): + """Test MajoranaFermion/MajoranaDualFermion helper constructors.""" + assert MajoranaFermion(0) == Majorana("G", 0) + assert MajoranaDualFermion(1) == Majorana("G'", 1) + + +def test_majorana_string_representation_uses_g_and_g_prime_notation(): + """Test Majorana string forms use G and G' as the canonical labels.""" + assert str(Majorana(12, site=2)) == "G(2)" + assert repr(Majorana(13, site=3)) == "Majorana('G\'', site=3)" + + +def test_majorana_string_init_requires_majorana_instances(): + """Test MajoranaString initializer validates element types.""" + with pytest.raises(TypeError, match="Expected Majorana instance"): + MajoranaString([MajoranaFermion(0), "G'"]) # type: ignore[list-item] + + +def test_majorana_string_from_sites_accepts_string_and_int_values(): + """Test MajoranaString.from_sites accepts both string and int identifiers.""" + from_string = MajoranaString.from_sites((0, 1), ["G", "G'"], coefficient=-1j) + from_ints = MajoranaString.from_sites((0, 1), [12, 13], coefficient=-1j) + + assert from_string == from_ints + assert len(from_string) == 2 + assert from_string.sites == (0, 1) + assert from_string.majoranas == ("G", "G'") + + +def test_majorana_string_from_sites_length_mismatch_raises(): + """Test from_sites raises when site/value lengths differ.""" + with pytest.raises(ValueError, match="Length mismatch"): + MajoranaString.from_sites((0, 1), ["G"]) + + +def test_majorana_string_sequence_protocol_and_indexing(): + """Test MajoranaString iteration, len, and indexing behavior.""" + ms = MajoranaString([MajoranaFermion(0), MajoranaDualFermion(2)], coefficient=2.0) + + assert ms.sites == (0, 2) + assert len(ms) == 2 + assert ms[0] == MajoranaFermion(0) + assert list(ms) == [MajoranaFermion(0), MajoranaDualFermion(2)] + + +def test_majorana_string_equality_and_hash_include_coefficient(): + """Test equality/hash depend on Majorana terms and coefficient.""" + m1 = MajoranaString.from_sites((0, 1), ["G", "G'"], coefficient=1.0) + m2 = MajoranaString.from_sites((0, 1), ["G", "G'"], coefficient=1.0) + m3 = MajoranaString.from_sites((0, 1), ["G", "G'"], coefficient=-1.0) + + assert m1 == m2 + assert hash(m1) == hash(m2) + assert m1 != m3 + + +def test_majorana_string_mul_scales_coefficient_and_preserves_terms(): + """Test MajoranaString.__mul__ returns scaled coefficient with same operators.""" + ms = MajoranaString.from_sites((0, 2), ["G", "G'"], coefficient=2.0) + + scaled = ms * (-0.25j) + + assert scaled.sites == ms.sites + assert list(scaled) == list(ms) + assert scaled.coefficient == -0.5j + assert ms.coefficient == 2.0 + + +def test_majorana_string_normalize_reorders_with_sign_flip(): + """Test normalize uses swap parity and the G[j] < G'[j] convention.""" + ms = MajoranaString([MajoranaDualFermion(1), MajoranaFermion(1)], coefficient=2.0) + + ms.normalize() + + assert ms.sites == (1, 1) + assert ms.majoranas == ("G", "G'") + assert list(ms) == [MajoranaFermion(1), MajoranaDualFermion(1)] + assert ms.coefficient == -2.0 + + +def test_majorana_string_normalize_cancels_adjacent_equal_terms(): + """Test normalize removes adjacent equal Majorana pairs after reordering.""" + ms = MajoranaString( + [MajoranaFermion(1), MajoranaFermion(0), MajoranaFermion(0), MajoranaDualFermion(1)], + coefficient=2.0, + ) + + ms.normalize() + + assert ms.sites == (1, 1) + assert ms.majoranas == ("G", "G'") + assert list(ms) == [MajoranaFermion(1), MajoranaDualFermion(1)] + assert ms.coefficient == 2.0 + + +def test_vertex_operator_returns_imaginary_majorana_pair(): + """Test vertex_operator constructs i * G[j] * G'[j].""" + term = vertex_operator(3) + + assert term == MajoranaString.from_sites((3, 3), ["G", "G'"], coefficient=1j) + assert term.coefficient == 1j + assert term.sites == (3, 3) + + +def test_edge_operator_returns_imaginary_majorana_pair(): + """Test edge_operator constructs i * G[j] * G[k].""" + term = edge_operator(1, 4) + + assert term == MajoranaString.from_sites((1, 4), ["G", "G"], coefficient=1j) + assert term.coefficient == 1j + assert term.sites == (1, 4) + + +def test_fermion_init_from_int_and_string(): + """Test Fermion initialization from int and string labels.""" + creation = Fermion(10, site=1) + creation_alias = Fermion("A^", site=2) + annihilation = Fermion(11, site=3) + annihilation_alias = Fermion("a", site=4) + + assert creation.op == 10 and creation.site == 1 + assert creation_alias.op == 10 and creation_alias.site == 2 + assert annihilation.op == 11 and annihilation.site == 3 + assert annihilation_alias.op == 11 and annihilation_alias.site == 4 + + +@pytest.mark.parametrize("value", [9, 12, 42]) +def test_fermion_invalid_int_raises(value: int): + """Test invalid integer fermion identifiers raise ValueError.""" + with pytest.raises(ValueError, match="Integer value must be 10 or 11"): + Fermion(value) + + +def test_fermion_invalid_string_raises(): + """Test invalid string fermion identifiers raise ValueError.""" + with pytest.raises(ValueError, match="String value must be one of"): + Fermion("Z") + + +def test_fermion_invalid_type_raises(): + """Test non-int/non-str fermion identifiers raise ValueError.""" + with pytest.raises(ValueError, match="Expected int or str"): + Fermion(1.5) # type: ignore[arg-type] + + +def test_fermion_helpers_create_expected_operator(): + """Test FermionCreation/FermionAnnihilation helper constructors.""" + assert FermionCreation(0) == Fermion("A^", 0) + assert FermionAnnihilation(1) == Fermion("ANNIHILATION", 1) + + +def test_fermion_string_representation_uses_a_and_a_dagger_notation(): + """Test Fermion string forms use A and A^ as the canonical labels.""" + assert str(Fermion(10, site=2)) == "A^(2)" + assert repr(Fermion(11, site=3)) == "Fermion('A', site=3)" + + +def test_fermion_string_init_requires_fermion_instances(): + """Test FermionString initializer validates element types.""" + with pytest.raises(TypeError, match="Expected Fermion instance"): + FermionString([FermionCreation(0), "A"]) # type: ignore[list-item] + + +def test_fermion_string_from_sites_accepts_string_and_int_values(): + """Test FermionString.from_sites accepts both string and int identifiers.""" + from_string = FermionString.from_sites((0, 1), ["A^", "A"], coefficient=-1j) + from_ints = FermionString.from_sites((0, 1), [10, 11], coefficient=-1j) + + assert from_string == from_ints + assert len(from_string) == 2 + assert from_string.sites == (0, 1) + assert from_string.fermions == ("A^", "A") + + +def test_fermion_string_from_sites_length_mismatch_raises(): + """Test from_sites raises when site/value lengths differ.""" + with pytest.raises(ValueError, match="Length mismatch"): + FermionString.from_sites((0, 1), ["A^"]) + + +def test_fermion_string_sequence_protocol_and_indexing(): + """Test FermionString iteration, len, and indexing behavior.""" + fs = FermionString([FermionCreation(0), FermionAnnihilation(2)], coefficient=2.0) + + assert fs.sites == (0, 2) + assert len(fs) == 2 + assert fs[0] == FermionCreation(0) + assert list(fs) == [FermionCreation(0), FermionAnnihilation(2)] + + +def test_fermion_string_equality_and_hash_include_coefficient(): + """Test equality/hash depend on Fermion terms and coefficient.""" + f1 = FermionString.from_sites((0, 1), ["A^", "A"], coefficient=1.0) + f2 = FermionString.from_sites((0, 1), ["A^", "A"], coefficient=1.0) + f3 = FermionString.from_sites((0, 1), ["A^", "A"], coefficient=-1.0) + + assert f1 == f2 + assert hash(f1) == hash(f2) + assert f1 != f3 + + +def test_fermion_string_mul_scales_coefficient_and_preserves_terms(): + """Test FermionString.__mul__ returns scaled coefficient with same operators.""" + fs = FermionString.from_sites((0, 2), ["A^", "A"], coefficient=2.0) + + scaled = fs * (-0.25j) + + assert scaled.sites == fs.sites + assert list(scaled) == list(fs) + assert scaled.coefficient == -0.5j + assert fs.coefficient == 2.0 + + +def test_fermion_string_hermitian_conjugate_reverses_and_conjugates(): + """Test Hermitian conjugation reverses order, swaps ops, and conjugates coefficient.""" + fs = FermionString.from_sites((0, 2), ["A^", "A"], coefficient=1 + 2j) + + conjugated = fs.hermitian_conjugate() + + assert conjugated == FermionString.from_sites((2, 0), ["A^", "A"], coefficient=1 - 2j) + assert fs.coefficient == 1 + 2j + + +def test_hopping_term_returns_creation_then_annihilation_string(): + """Test hopping_term constructs A^[j] A[k] with unit coefficient.""" + term = hopping_term(1, 3) + + assert term == FermionString.from_sites((1, 3), ["A^", "A"]) + assert term.coefficient == 1.0 + + +def test_hopping_term_with_equal_indices_gives_number_operator(): + """Test hopping_term(j, j) gives the fermionic number operator form.""" + term = hopping_term(2, 2) + + assert term.sites == (2, 2) + assert term.fermions == ("A^", "A") def test_pauli_init_from_int_and_string(): @@ -113,6 +401,18 @@ def test_pauli_string_mul_scales_coefficient_and_preserves_terms(): assert ps.coefficient == 2.0 +def test_pauli_string_normalize_reorders_simplifies_and_removes_identity(): + """Test normalize sorts qubits, multiplies repeated qubits, and drops identities.""" + ps = PauliString([PauliX(2), PauliX(0), PauliZ(2), PauliX(0)], coefficient=2.0) + + ps.normalize() + + assert ps.qubits == (2,) + assert ps.paulis == "Y" + assert list(ps) == [PauliY(2)] + assert ps.coefficient == -2j + + def test_pauli_string_cirq_property_preserves_terms_and_coefficient(): """Test PauliString.cirq conversion with coefficient.""" ps = PauliString.from_qubits((0, 2), "XZ", coefficient=-0.5j) @@ -123,3 +423,16 @@ def test_pauli_string_cirq_property_preserves_terms_and_coefficient(): ) assert ps.cirq == expected + + +def test_pauli_string_cirq_property_normalizes_duplicate_qubits(): + """Test PauliString.cirq simplifies repeated qubits before conversion.""" + ps = PauliString([PauliX(2), PauliX(0), PauliZ(2), PauliX(0)], coefficient=2.0) + + expected = cirq.PauliString( + {cirq.LineQubit(2): cirq.Y}, + coefficient=-2j, + ) + + assert ps.cirq == expected + assert ps.qubits == (2, 0, 2, 0)