From bb43dc0fec73ef158640d393ae3965cec6e63e30 Mon Sep 17 00:00:00 2001 From: Albert Lee Date: Sat, 20 Jun 2026 00:45:52 -0600 Subject: [PATCH 1/7] code: add representation-independent PauliString class --- common/pauli_string.py | 141 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 common/pauli_string.py diff --git a/common/pauli_string.py b/common/pauli_string.py new file mode 100644 index 0000000..4f19d25 --- /dev/null +++ b/common/pauli_string.py @@ -0,0 +1,141 @@ +"""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 positive.") + 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.") + if not value: + raise ValueError("A dense Pauli string cannot be empty.") + + 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.""" + num_qubits = _validate_num_qubits(num_qubits) + return cls( + num_qubits=num_qubits, + _operators=_normalize_sparse(operators, num_qubits), + ) + + 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})" From 91b724c4c8f276d683a13b8cf7df20baa8667032 Mon Sep 17 00:00:00 2001 From: Albert Lee Date: Sat, 20 Jun 2026 00:46:06 -0600 Subject: [PATCH 2/7] test: add PauliString conversion and validation tests --- common/tests/test_pauli_string.py | 77 +++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 common/tests/test_pauli_string.py diff --git a/common/tests/test_pauli_string.py b/common/tests/test_pauli_string.py new file mode 100644 index 0000000..dda36ae --- /dev/null +++ b/common/tests/test_pauli_string.py @@ -0,0 +1,77 @@ +"""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_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() == () + + +@pytest.mark.parametrize("value", ["", "IXA", "xyz"]) +def test_invalid_dense_input(value): + with pytest.raises(ValueError): + PauliString.from_dense(value) + + +@pytest.mark.parametrize("num_qubits", [0, -1]) +def test_num_qubits_must_be_positive(num_qubits): + with pytest.raises(ValueError, match="positive"): + PauliString.from_sparse((), num_qubits=num_qubits) + + +@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) From 81f236b7dbc281571b1bea27bc9ed6d96d890bc5 Mon Sep 17 00:00:00 2001 From: Albert Lee Date: Sat, 20 Jun 2026 10:56:42 -0600 Subject: [PATCH 3/7] fix: update validation for num_qubits and improve test coverage for PauliString --- common/pauli_string.py | 7 ++----- common/tests/test_pauli_string.py | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/common/pauli_string.py b/common/pauli_string.py index 4f19d25..f3276e2 100644 --- a/common/pauli_string.py +++ b/common/pauli_string.py @@ -15,8 +15,8 @@ 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 positive.") + if num_qubits < 0: + raise ValueError("num_qubits must be non-negative.") return int(num_qubits) @@ -84,9 +84,6 @@ 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.") - if not value: - raise ValueError("A dense Pauli string cannot be empty.") - invalid = next( (operator for operator in value if operator not in _VALID_OPERATORS), None, diff --git a/common/tests/test_pauli_string.py b/common/tests/test_pauli_string.py index dda36ae..45280dc 100644 --- a/common/tests/test_pauli_string.py +++ b/common/tests/test_pauli_string.py @@ -50,16 +50,24 @@ def test_identity_pauli_string(): assert pauli.to_sparse() == () -@pytest.mark.parametrize("value", ["", "IXA", "xyz"]) +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) -@pytest.mark.parametrize("num_qubits", [0, -1]) -def test_num_qubits_must_be_positive(num_qubits): - with pytest.raises(ValueError, match="positive"): - PauliString.from_sparse((), num_qubits=num_qubits) +def test_num_qubits_cannot_be_negative(): + with pytest.raises(ValueError, match="non-negative"): + PauliString.from_sparse((), num_qubits=-1) @pytest.mark.parametrize( From 6c8715968939486d69c4262423f942dec3451a4e Mon Sep 17 00:00:00 2001 From: Albert Lee Date: Sat, 20 Jun 2026 10:56:52 -0600 Subject: [PATCH 4/7] refactor: integrate PauliString class for sparse/dense conversion and enhance LinearCombinationOfPauliStrings initialization --- analysis/hamiltonian.py | 106 ++++++++++++----------- analysis/tests/test_pauli_hamiltonian.py | 46 +++++++--- 2 files changed, 93 insertions(+), 59 deletions(-) diff --git a/analysis/hamiltonian.py b/analysis/hamiltonian.py index 04a2e68..e16d51d 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,65 @@ 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) + """Compatibility wrapper for converting a sparse Pauli tuple to a string.""" + return PauliString.from_sparse(sparse_pauli, num_qubits).to_dense() + + 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 + """Compatibility wrapper for converting a Pauli string to a sparse tuple.""" + try: + return PauliString.from_dense(dense_pauli).to_sparse() + except ValueError as exc: + raise ValueError(f"Invalid character in dense pauli string: \"{dense_pauli}\".") from exc # ------------------------------------------------------------------------------------------------- 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 +146,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 +164,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..09170c3 100644 --- a/analysis/tests/test_pauli_hamiltonian.py +++ b/analysis/tests/test_pauli_hamiltonian.py @@ -26,6 +26,7 @@ load_pauli, sparse_to_dense_pauli, ) +from qhat.common.pauli_string import PauliString # ================================================================================== @@ -108,16 +109,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 +172,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 +522,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 +703,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) From 0fc57a6e2d2d39c57a23075be70d4ac0c5922d08 Mon Sep 17 00:00:00 2001 From: Albert Lee Date: Sun, 21 Jun 2026 12:41:39 -0600 Subject: [PATCH 5/7] refactor: simplify dense_to_sparse_pauli by removing exception handling --- analysis/hamiltonian.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/analysis/hamiltonian.py b/analysis/hamiltonian.py index e16d51d..1f0b0f5 100644 --- a/analysis/hamiltonian.py +++ b/analysis/hamiltonian.py @@ -46,10 +46,7 @@ def sparse_to_dense_pauli(sparse_pauli, num_qubits): def dense_to_sparse_pauli(dense_pauli): """Compatibility wrapper for converting a Pauli string to a sparse tuple.""" - try: - return PauliString.from_dense(dense_pauli).to_sparse() - except ValueError as exc: - raise ValueError(f"Invalid character in dense pauli string: \"{dense_pauli}\".") from exc + return PauliString.from_dense(dense_pauli).to_sparse() # ------------------------------------------------------------------------------------------------- From ed13897b165883ae0c168451e2c6471b0aa52548 Mon Sep 17 00:00:00 2001 From: Albert Lee Date: Sun, 21 Jun 2026 12:41:46 -0600 Subject: [PATCH 6/7] fix: update error message for invalid characters in dense_to_sparse_pauli test --- analysis/tests/test_pauli_hamiltonian.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/analysis/tests/test_pauli_hamiltonian.py b/analysis/tests/test_pauli_hamiltonian.py index 09170c3..a79be9f 100644 --- a/analysis/tests/test_pauli_hamiltonian.py +++ b/analysis/tests/test_pauli_hamiltonian.py @@ -80,7 +80,7 @@ def test_dense_to_sparse_multi_op(self): def test_dense_to_sparse_invalid_char(self): """Test that invalid characters raise ValueError.""" - with pytest.raises(ValueError, match="Invalid character"): + with pytest.raises(ValueError, match="Invalid Pauli operator 'Q'"): dense_to_sparse_pauli("XQZI") def test_round_trip_conversion(self): From b93faede981de69df33ab430bc4b942274aa6bd5 Mon Sep 17 00:00:00 2001 From: Albert Lee Date: Mon, 22 Jun 2026 07:19:02 -0600 Subject: [PATCH 7/7] Address PauliString review feedback --- analysis/README.md | 15 +++++++++++++++ common/README.md | 19 +++++++++++++++++++ common/pauli_string.py | 6 +----- common/tests/test_pauli_string.py | 7 +++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/analysis/README.md b/analysis/README.md index 90d08e5..680ae65 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -108,6 +108,21 @@ hamiltonian.load_second_quantization("file.npz", hamiltonian.load_second_quantization("data.h5") ``` +### Accessing Pauli Strings in Python + +For a `Hamiltonian` instance, request `PauliString` keys to work without committing to a dense or +sparse representation: + +```python +pauli_terms = physical_hamiltonian.get_all_pauli_strings(return_as="objects") + +for pauli, coefficient in pauli_terms.items(): + print(pauli.to_dense(), pauli.to_sparse_dict(), coefficient) +``` + +Use `return_as="strings"` for dense-string keys or `return_as="tuples"` for the existing sparse +tuple keys. The `objects` form is useful when a caller needs both representations. + ### Encoding as a Unitary Currently all of our applications involve encoding the Hamiltonian ($\hat{H}$) as a time-evolution 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 index f3276e2..8ff6dd7 100644 --- a/common/pauli_string.py +++ b/common/pauli_string.py @@ -107,11 +107,7 @@ def from_sparse( num_qubits: int, ) -> "PauliString": """Construct from sparse ``(index, operator)`` pairs or a mapping.""" - num_qubits = _validate_num_qubits(num_qubits) - return cls( - num_qubits=num_qubits, - _operators=_normalize_sparse(operators, num_qubits), - ) + return cls(num_qubits=num_qubits, _operators=operators) def to_dense(self) -> str: """Return the full dense string, including identity operators.""" diff --git a/common/tests/test_pauli_string.py b/common/tests/test_pauli_string.py index 45280dc..7294df9 100644 --- a/common/tests/test_pauli_string.py +++ b/common/tests/test_pauli_string.py @@ -33,6 +33,13 @@ def test_pauli_string_can_be_a_dictionary_key(): 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')" + + def test_sparse_input_is_sorted_and_drops_identity_operators(): pauli = PauliString.from_sparse( ((3, "Z"), (0, "I"), (1, "X")),