diff --git a/analysis/hamiltonian.py b/analysis/hamiltonian.py index 04a2e68..4806116 100644 --- a/analysis/hamiltonian.py +++ b/analysis/hamiltonian.py @@ -18,6 +18,7 @@ from qhat.analysis.config_types import GeneralConfiguration, HamiltonianConfiguration, value from qhat.common.bosons_binary import BosonicBinaryEncoding from qhat.common.MixedFermionBosonOperator import MixedFermionBosonOperator +from qhat.common.pauli_string import PauliString logger = logging.getLogger(__name__) @@ -38,62 +39,51 @@ def boson_to_qubit_operator(bosonic_operator, Nmax): # ------------------------------------------------------------------------------------------------- -# TODO: These are generally useful utilities. Where should they live? -def sparse_to_dense_pauli(sparse_pauli, num_qubits): - dense_pauli = ["I",] * num_qubits - for idx, op in sparse_pauli: - dense_pauli[idx] = op - return "".join(dense_pauli) -def dense_to_sparse_pauli(dense_pauli): - sparse_pauli = tuple() - for idx, op in enumerate(dense_pauli): - if op in ["X", "Y", "Z"]: - sparse_pauli = (*sparse_pauli, (idx,op)) - elif op != "I": - raise ValueError(f"Invalid character in dense pauli string: \"{op}\".") - return sparse_pauli - -# ------------------------------------------------------------------------------------------------- - class LinearCombinationOfPauliStrings: def __init__(self, **kwargs): - self._nq = None - self._format = None - self._data = None - self._nq = kwargs["num_qubits"] - for f in [ "dense", "sparse" ]: - if f in kwargs: - if self._format is not None: - raise ValueError( - "Too many formats provided to LinearCombinationOfPauliStrings.") - self._format = f - self._data = kwargs[f] - assert isinstance(self._data, dict) - if self._format is None: + formats = [fmt for fmt in ["dense", "sparse"] if fmt in kwargs] + if not formats: raise ValueError("No data provided to LinearCombinationOfPauliStrings.") + if len(formats) > 1: + raise ValueError("Too many formats provided to LinearCombinationOfPauliStrings.") + + input_format = formats[0] + input_data = kwargs[input_format] + if not isinstance(input_data, dict): + raise TypeError("Pauli string data must be provided as a dictionary.") + + identity = PauliString.from_sparse((), kwargs["num_qubits"]) + self._nq = identity.num_qubits + self._data = {} + for raw_pauli, coefficient in input_data.items(): + if input_format == "dense": + pauli = PauliString.from_dense(raw_pauli) + if pauli.num_qubits != self._nq: + raise ValueError( + f"Dense Pauli string {raw_pauli!r} has length " + f"{pauli.num_qubits}, expected {self._nq}." + ) + else: + pauli = PauliString.from_sparse(raw_pauli, self._nq) + self._data[pauli] = self._data.get(pauli, 0.0) + coefficient + def num_qubits(self): return self._nq + + def get_pauli_strings(self): + """Return a copy of the canonical PauliString-keyed coefficient dictionary.""" + return dict(self._data) + def get_dense_pauli_strings(self): - if self._format == "dense": - return self._data - elif self._format == "sparse": - return {sparse_to_dense_pauli(pauli, self._nq) : coef - for pauli, coef in self._data.items()} - else: - raise ValueError("Invalid data format \"{self._format}\".") + return {pauli.to_dense() : coef for pauli, coef in self._data.items()} + def get_sparse_pauli_strings(self): - if self._format == "dense": - return {dense_to_sparse_pauli(pauli) : coef for pauli, coef in self._data.items()} - elif self._format == "sparse": - return self._data - else: - raise ValueError("Invalid data format \"{self._format}\".") + return {pauli.to_sparse() : coef for pauli, coef in self._data.items()} + def energy_shift(self, shift): - all_identity = tuple() - if self._format == "dense": - all_identity = sparse_to_dense_pauli(all_identity, self._nq) - identity_coefficient = self._data.get(all_identity, 0.0) + shift - self._data[all_identity] = identity_coefficient + identity = PauliString.from_sparse((), self._nq) + identity_coefficient = self._data.get(identity, 0.0) + shift + self._data[identity] = identity_coefficient # ------------------------------------------------------------------------------------------------- @@ -142,6 +132,8 @@ def get_all_pauli_strings(self, return_as="tuples"): # -- If return_as == "strings": The Pauli string is encoded as a character string, where # each character is a Pauli matrix, explicitly including identity entries. For example, # assuming 6 qubits, "XIIZII". + # -- If return_as == "objects": Keys are immutable PauliString instances that can provide + # either representation on demand. # TODO: I'd prefer that the flag identify not the data structure but the concept: dense vs # sparse, rather than strings vs tuples. if return_as == "tuples": @@ -158,16 +150,18 @@ def get_all_pauli_strings(self, return_as="tuples"): raise TypeError( f"Unable to generate Pauli strings from object of type \"{type(self._H)}\".") elif return_as == "strings": + as_objects = self.get_all_pauli_strings(return_as="objects") + return {pauli.to_dense() : coef for pauli, coef in as_objects.items()} + elif return_as == "objects": if isinstance(self._H, LinearCombinationOfPauliStrings): - return self._H.get_dense_pauli_strings() - else: - as_tuples = self.get_all_pauli_strings(return_as="tuples") - Nq = self.num_qubits() - return {sparse_to_dense_pauli(pauli, Nq) : coef + return self._H.get_pauli_strings() + as_tuples = self.get_all_pauli_strings(return_as="tuples") + Nq = self.num_qubits() + return {PauliString.from_sparse(pauli, Nq) : coef for pauli, coef in as_tuples.items()} else: raise ValueError(" ".join([ - "The value of return_as must be \"tuples\" or \"strings\".", + "The value of return_as must be \"tuples\", \"strings\", or \"objects\".", f"Unable to return result as \"{return_as}\".", ])) def get_grouped_terms(self): diff --git a/analysis/tests/test_pauli_hamiltonian.py b/analysis/tests/test_pauli_hamiltonian.py index b4f563c..c116ed0 100644 --- a/analysis/tests/test_pauli_hamiltonian.py +++ b/analysis/tests/test_pauli_hamiltonian.py @@ -3,7 +3,6 @@ Tests cover: - LinearCombinationOfPauliStrings class functionality -- Utility functions (dense/sparse conversion) - File loading (dense and sparse formats) - Hermitian validation - Integration with Hamiltonian API @@ -22,10 +21,9 @@ from qhat.analysis.hamiltonian import ( Hamiltonian, LinearCombinationOfPauliStrings, - dense_to_sparse_pauli, load_pauli, - sparse_to_dense_pauli, ) +from qhat.common.pauli_string import PauliString # ================================================================================== @@ -39,63 +37,6 @@ def general_config(): return GeneralConfiguration(user_config) -# ================================================================================== -# Test: Utility functions -# ================================================================================== - -class TestUtilityFunctions: - """Test sparse_to_dense_pauli and dense_to_sparse_pauli conversions.""" - - def test_sparse_to_dense_identity(self): - """Test converting identity (empty sparse) to dense.""" - assert sparse_to_dense_pauli(tuple(), 4) == "IIII" - assert sparse_to_dense_pauli(tuple(), 1) == "I" - - def test_sparse_to_dense_single_op(self): - """Test converting single Pauli operator.""" - assert sparse_to_dense_pauli(((0, 'X'),), 4) == "XIII" - assert sparse_to_dense_pauli(((2, 'Z'),), 4) == "IIZI" - assert sparse_to_dense_pauli(((3, 'Y'),), 5) == "IIIYI" - - def test_sparse_to_dense_multi_op(self): - """Test converting multiple Pauli operators.""" - assert sparse_to_dense_pauli(((0, 'X'), (2, 'Z')), 4) == "XIZI" - assert sparse_to_dense_pauli(((1, 'Y'), (3, 'Z')), 5) == "IYIZI" - - def test_dense_to_sparse_identity(self): - """Test converting dense identity to sparse.""" - assert dense_to_sparse_pauli("IIII") == tuple() - assert dense_to_sparse_pauli("I") == tuple() - - def test_dense_to_sparse_single_op(self): - """Test converting single operator from dense.""" - assert dense_to_sparse_pauli("XIII") == ((0, 'X'),) - assert dense_to_sparse_pauli("IIZI") == ((2, 'Z'),) - - def test_dense_to_sparse_multi_op(self): - """Test converting multiple operators from dense.""" - assert dense_to_sparse_pauli("XIZI") == ((0, 'X'), (2, 'Z')) - assert dense_to_sparse_pauli("XYZ") == ((0, 'X'), (1, 'Y'), (2, 'Z')) - - def test_dense_to_sparse_invalid_char(self): - """Test that invalid characters raise ValueError.""" - with pytest.raises(ValueError, match="Invalid character"): - dense_to_sparse_pauli("XQZI") - - def test_round_trip_conversion(self): - """Test that converting back and forth preserves the Pauli string.""" - test_cases = [ - ("IIII", 4), - ("XIII", 4), - ("XYZI", 4), - ("XYZIXYZ", 7), - ] - for dense, nq in test_cases: - sparse = dense_to_sparse_pauli(dense) - dense_back = sparse_to_dense_pauli(sparse, nq) - assert dense == dense_back - - # ================================================================================== # Test: LinearCombinationOfPauliStrings class # ================================================================================== @@ -108,16 +49,18 @@ def test_dense_format_init(self): data = {"IIII": 1.0, "XIII": 0.5} lcps = LinearCombinationOfPauliStrings(num_qubits=4, dense=data) assert lcps.num_qubits() == 4 - assert lcps._format == "dense" assert len(lcps._data) == 2 + assert all(isinstance(pauli, PauliString) for pauli in lcps._data) + assert lcps._data[PauliString.from_dense("XIII")] == 0.5 def test_sparse_format_init(self): """Test initialization with sparse format.""" data = {tuple(): 1.0, ((0, 'X'),): 0.5} lcps = LinearCombinationOfPauliStrings(num_qubits=4, sparse=data) assert lcps.num_qubits() == 4 - assert lcps._format == "sparse" assert len(lcps._data) == 2 + assert all(isinstance(pauli, PauliString) for pauli in lcps._data) + assert lcps._data[PauliString.from_dense("XIII")] == 0.5 def test_no_data_raises_error(self): """Test that initialization without data raises ValueError.""" @@ -169,29 +112,42 @@ def test_get_dense_from_sparse(self): expected = {"IIII": 1.0, "XIII": 0.5, "IXZI": 0.3} assert dense_result == expected + def test_equivalent_sparse_keys_are_combined(self): + """Equivalent sparse orderings represent one Pauli string.""" + sparse_data = { + ((0, 'X'), (1, 'Y'), (2, 'Z')): 0.25, + ((2, 'Z'), (1, 'Y'), (0, 'X')): -0.15, + } + lcps = LinearCombinationOfPauliStrings(num_qubits=4, sparse=sparse_data) + + assert lcps.get_dense_pauli_strings() == {"XYZI": pytest.approx(0.10)} + def test_energy_shift_dense(self): """Test energy shift with dense format.""" data = {"IIII": 1.0, "XIII": 0.5} lcps = LinearCombinationOfPauliStrings(num_qubits=4, dense=data) lcps.energy_shift(2.5) - assert lcps._data["IIII"] == 3.5 - assert lcps._data["XIII"] == 0.5 + result = lcps.get_dense_pauli_strings() + assert result["IIII"] == 3.5 + assert result["XIII"] == 0.5 def test_energy_shift_sparse(self): """Test energy shift with sparse format.""" data = {tuple(): 1.0, ((0, 'X'),): 0.5} lcps = LinearCombinationOfPauliStrings(num_qubits=4, sparse=data) lcps.energy_shift(2.5) - assert lcps._data[tuple()] == 3.5 - assert lcps._data[((0, 'X'),)] == 0.5 + result = lcps.get_sparse_pauli_strings() + assert result[tuple()] == 3.5 + assert result[((0, 'X'),)] == 0.5 def test_energy_shift_adds_identity(self): """Test that energy shift adds identity term if not present.""" data = {"XIII": 0.5} lcps = LinearCombinationOfPauliStrings(num_qubits=4, dense=data) lcps.energy_shift(2.5) - assert lcps._data["IIII"] == 2.5 - assert lcps._data["XIII"] == 0.5 + result = lcps.get_dense_pauli_strings() + assert result["IIII"] == 2.5 + assert result["XIII"] == 0.5 # ================================================================================== @@ -506,6 +462,16 @@ def test_get_all_pauli_strings_strings(self, sample_hamiltonian): assert pauli_dict["IIII"] == 2.5 assert pauli_dict["XIII"] == 0.5 + def test_get_all_pauli_strings_objects(self, sample_hamiltonian): + """Test getting representation-independent PauliString keys.""" + H, _, _ = sample_hamiltonian + pauli_dict = H.get_all_pauli_strings(return_as="objects") + + assert len(pauli_dict) == 10 + assert all(isinstance(pauli, PauliString) for pauli in pauli_dict) + assert pauli_dict[PauliString.from_dense("IIII")] == 2.5 + assert pauli_dict[PauliString.from_sparse(((0, 'X'),), 4)] == 0.5 + def test_energy_bounds(self, sample_hamiltonian): """Test energy bounds computation.""" H, config_gen, config_ham = sample_hamiltonian @@ -677,10 +643,10 @@ def test_load_json_test_file(self, config): # Verify basic properties assert H.num_qubits() == 4 pauli_dict = H.get_all_pauli_strings(return_as="tuples") - assert len(pauli_dict) == 10 + assert len(pauli_dict) == 9 # Verify specific terms assert pauli_dict[tuple()] == 2.5 assert pauli_dict[((0, 'X'),)] == 0.5 assert pauli_dict[((0, 'X'), (1, 'X'))] == 1.2 - assert pauli_dict[((0, 'X'), (1, 'Y'), (2, 'Z'))] == 0.25 + assert pauli_dict[((0, 'X'), (1, 'Y'), (2, 'Z'))] == pytest.approx(0.10) diff --git a/common/README.md b/common/README.md index dc6e0d2..6fa2d99 100644 --- a/common/README.md +++ b/common/README.md @@ -5,6 +5,25 @@ be used directly by users. ## Pauli String and Time Evolution +### Representation-Independent Pauli Strings + +`pauli_string.py` provides an immutable, hashable `PauliString` value type. Construct a value from +either a dense string or a sparse mapping, then request the representation needed by the caller: + +```python +from qhat.common.pauli_string import PauliString + +dense = PauliString.from_dense("IIXYIZ") +sparse = PauliString.from_sparse({2: "X", 3: "Y", 5: "Z"}, num_qubits=6) + +assert dense == sparse +assert dense.to_dense() == "IIXYIZ" +assert dense.to_sparse() == ((2, "X"), (3, "Y"), (5, "Z")) +assert dense.to_sparse_dict() == {2: "X", 3: "Y", 5: "Z"} +``` + +Coefficients remain separate so `PauliString` values can be used as Hamiltonian dictionary keys. + ### Core Evolution Modules - **`pauli_string_evolution.py`**: Implements time evolution under a single Pauli string Hamiltonian: diff --git a/common/pauli_string.py b/common/pauli_string.py new file mode 100644 index 0000000..8ff6dd7 --- /dev/null +++ b/common/pauli_string.py @@ -0,0 +1,134 @@ +"""Representation-independent Pauli string value type.""" + +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from numbers import Integral +from typing import TypeAlias + + +SparsePauli: TypeAlias = tuple[tuple[int, str], ...] +SparsePauliInput: TypeAlias = Mapping[int, str] | Iterable[tuple[int, str]] + +_VALID_OPERATORS = frozenset("IXYZ") + + +def _validate_num_qubits(num_qubits: int) -> int: + if isinstance(num_qubits, bool) or not isinstance(num_qubits, Integral): + raise TypeError("num_qubits must be an integer.") + if num_qubits < 0: + raise ValueError("num_qubits must be non-negative.") + return int(num_qubits) + + +def _normalize_sparse(operators: SparsePauliInput, num_qubits: int) -> SparsePauli: + if isinstance(operators, Mapping): + items = operators.items() + elif isinstance(operators, (str, bytes)) or not isinstance(operators, Iterable): + raise TypeError("Sparse Pauli input must be a mapping or an iterable of pairs.") + else: + items = operators + + normalized = [] + seen_indices = set() + for item in items: + try: + index, operator = item + except (TypeError, ValueError) as exc: + raise TypeError( + "Each sparse Pauli operator must be an (index, operator) pair." + ) from exc + + if isinstance(index, bool) or not isinstance(index, Integral): + raise TypeError("Pauli operator indices must be integers.") + index = int(index) + if index < 0 or index >= num_qubits: + raise ValueError( + f"Pauli operator index {index} is outside the range " + f"[0, {num_qubits})." + ) + if index in seen_indices: + raise ValueError(f"Duplicate Pauli operator index: {index}.") + seen_indices.add(index) + + if not isinstance(operator, str) or len(operator) != 1: + raise TypeError("Pauli operators must be single-character strings.") + if operator not in _VALID_OPERATORS: + raise ValueError( + f"Invalid Pauli operator {operator!r}; expected one of I, X, Y, or Z." + ) + if operator != "I": + normalized.append((index, operator)) + + return tuple(sorted(normalized)) + + +@dataclass(frozen=True, slots=True) +class PauliString: + """An immutable Pauli string with dense and sparse representations. + + Coefficients are intentionally kept outside this class so instances can be + used as keys in Hamiltonian dictionaries. + """ + + num_qubits: int + _operators: SparsePauli + + def __post_init__(self) -> None: + num_qubits = _validate_num_qubits(self.num_qubits) + operators = _normalize_sparse(self._operators, num_qubits) + object.__setattr__(self, "num_qubits", num_qubits) + object.__setattr__(self, "_operators", operators) + + @classmethod + def from_dense(cls, value: str) -> "PauliString": + """Construct a Pauli string from a dense value such as ``"IXYZ"``.""" + if not isinstance(value, str): + raise TypeError("Dense Pauli input must be a string.") + invalid = next( + (operator for operator in value if operator not in _VALID_OPERATORS), + None, + ) + if invalid is not None: + raise ValueError( + f"Invalid Pauli operator {invalid!r}; expected one of I, X, Y, or Z." + ) + + operators = tuple( + (index, operator) + for index, operator in enumerate(value) + if operator != "I" + ) + return cls(num_qubits=len(value), _operators=operators) + + @classmethod + def from_sparse( + cls, + operators: SparsePauliInput, + num_qubits: int, + ) -> "PauliString": + """Construct from sparse ``(index, operator)`` pairs or a mapping.""" + return cls(num_qubits=num_qubits, _operators=operators) + + def to_dense(self) -> str: + """Return the full dense string, including identity operators.""" + dense = ["I"] * self.num_qubits + for index, operator in self._operators: + dense[index] = operator + return "".join(dense) + + def to_sparse(self) -> SparsePauli: + """Return the canonical sparse tuple used by existing QHAT APIs.""" + return self._operators + + def to_sparse_dict(self) -> dict[int, str]: + """Return the sparse dictionary representation described in issue #36.""" + return dict(self._operators) + + def __len__(self) -> int: + return self.num_qubits + + def __str__(self) -> str: + return self.to_dense() + + def __repr__(self) -> str: + return f"PauliString.from_dense({self.to_dense()!r})" diff --git a/common/tests/test_pauli_string.py b/common/tests/test_pauli_string.py new file mode 100644 index 0000000..22a45ce --- /dev/null +++ b/common/tests/test_pauli_string.py @@ -0,0 +1,135 @@ +"""Tests for the representation-independent PauliString value type.""" + +import pytest + +from qhat.common.pauli_string import PauliString + + +def test_construct_from_dense(): + pauli = PauliString.from_dense("IIXYIZ") + + assert pauli.num_qubits == 6 + assert len(pauli) == 6 + assert pauli.to_dense() == "IIXYIZ" + assert pauli.to_sparse() == ((2, "X"), (3, "Y"), (5, "Z")) + assert pauli.to_sparse_dict() == {2: "X", 3: "Y", 5: "Z"} + + +def test_dense_and_sparse_inputs_create_equal_values(): + dense = PauliString.from_dense("IIXYIZ") + sparse = PauliString.from_sparse( + {5: "Z", 2: "X", 3: "Y"}, + num_qubits=6, + ) + + assert dense == sparse + assert hash(dense) == hash(sparse) + + +def test_pauli_string_can_be_a_dictionary_key(): + pauli = PauliString.from_dense("XII") + hamiltonian = {pauli: 0.5} + + assert hamiltonian[PauliString.from_sparse(((0, "X"),), 3)] == 0.5 + + +def test_string_representations(): + pauli = PauliString.from_dense("IIXYIZ") + + assert str(pauli) == "IIXYIZ" + assert repr(pauli) == "PauliString.from_dense('IIXYIZ')" + + +@pytest.mark.parametrize( + "dense, sparse", + [ + ("IIII", ()), + ("I", ()), + ("XIII", ((0, "X"),)), + ("IIZI", ((2, "Z"),)), + ("XIZI", ((0, "X"), (2, "Z"))), + ("XYZ", ((0, "X"), (1, "Y"), (2, "Z"))), + ], +) +def test_dense_to_sparse_conversions(dense, sparse): + assert PauliString.from_dense(dense).to_sparse() == sparse + + +@pytest.mark.parametrize( + "sparse, num_qubits, dense", + [ + ((), 4, "IIII"), + ((), 1, "I"), + (((0, "X"),), 4, "XIII"), + (((2, "Z"),), 4, "IIZI"), + (((3, "Y"),), 5, "IIIYI"), + (((0, "X"), (2, "Z")), 4, "XIZI"), + (((1, "Y"), (3, "Z")), 5, "IYIZI"), + ], +) +def test_sparse_to_dense_conversions(sparse, num_qubits, dense): + assert PauliString.from_sparse(sparse, num_qubits).to_dense() == dense + + +@pytest.mark.parametrize("dense", ["IIII", "XIII", "XYZI", "XYZIXYZ"]) +def test_round_trip_conversion_preserves_dense_string(dense): + pauli = PauliString.from_dense(dense) + + assert PauliString.from_sparse(pauli.to_sparse(), len(pauli)).to_dense() == dense + + +def test_sparse_input_is_sorted_and_drops_identity_operators(): + pauli = PauliString.from_sparse( + ((3, "Z"), (0, "I"), (1, "X")), + num_qubits=4, + ) + + assert pauli.to_sparse() == ((1, "X"), (3, "Z")) + assert pauli.to_dense() == "IXIZ" + + +def test_identity_pauli_string(): + pauli = PauliString.from_sparse({}, num_qubits=4) + + assert pauli.to_dense() == "IIII" + assert pauli.to_sparse() == () + + +def test_zero_qubit_identity_pauli_string(): + dense = PauliString.from_dense("") + sparse = PauliString.from_sparse((), num_qubits=0) + + assert dense == sparse + assert dense.to_dense() == "" + assert dense.to_sparse() == () + + +@pytest.mark.parametrize("value", ["IXA", "xyz"]) +def test_invalid_dense_input(value): + with pytest.raises(ValueError): + PauliString.from_dense(value) + + +def test_invalid_dense_input_identifies_invalid_operator(): + with pytest.raises(ValueError, match="Invalid Pauli operator 'Q'"): + PauliString.from_dense("XQZI") + + +def test_num_qubits_cannot_be_negative(): + with pytest.raises(ValueError, match="non-negative"): + PauliString.from_sparse((), num_qubits=-1) + + +@pytest.mark.parametrize( + "operators, error, message", + [ + (((-1, "X"),), ValueError, "outside the range"), + (((3, "X"),), ValueError, "outside the range"), + (((0, "X"), (0, "Y")), ValueError, "Duplicate"), + (((0, "A"),), ValueError, "Invalid Pauli operator"), + (((0.5, "X"),), TypeError, "indices must be integers"), + ], +) +def test_invalid_sparse_input(operators, error, message): + with pytest.raises(error, match=message): + PauliString.from_sparse(operators, num_qubits=3)