From cb6f450bbb80262bbf0366586d5ad4b1f05f8090 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Fri, 6 Mar 2026 22:55:32 +0000 Subject: [PATCH 001/117] docstring --- .../time_evolution/builder/trotter.py | 102 ++++++++++++++- .../time_evolution/builder/trotter_error.py | 79 ++++++++++-- .../qdk_chemistry/utils/pauli_commutation.py | 120 ++++++++++++++++++ 3 files changed, 281 insertions(+), 20 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index 64cfda2c0..506062738 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -138,17 +138,27 @@ def _run_impl(self, qubit_hamiltonian: QubitHamiltonian, time: float) -> TimeEvo TimeEvolutionUnitary: The time evolution unitary built by the Trotter decomposition. """ - if self._settings.get("order") == 1: - return self._first_order_trotter(qubit_hamiltonian, time) - raise NotImplementedError("Only first-order Trotter decomposition is currently supported.") + order = self._settings.get("order") + if order in {1, 2} or (order > 2 and order % 2 == 0): + return self._trotter(qubit_hamiltonian, time) + raise NotImplementedError("Non-positive and higher odd orders are not supported.") - def _first_order_trotter(self, qubit_hamiltonian: QubitHamiltonian, time: float) -> TimeEvolutionUnitary: + def _trotter(self, qubit_hamiltonian: QubitHamiltonian, time: float) -> TimeEvolutionUnitary: r"""Construct the time evolution unitary using first-order Trotter decomposition. The First Order Trotter method approximates the time evolution operator :math:`e^{-iHt}` by decomposing the Hamiltonian H into a sum of terms and using the product formula: :math:`e^{-iHt} \approx \left[\prod_i e^{-iH_i t/n}\right]^n`, where n is the number of divisions. + The Second Order Trotter method approximates the time evolution operator :math:`e^{-iHt}` + by decomposing the Hamiltonian H into a sum of terms and using the product formula: + :math:`e^{-iHt} \approx \left[\prod_i e^{-iH_{i=1}^{L-1} t/2n}e^{-iH_L t/n}\prod_i + e^{-iH_{i=L-1}^{1} t/2n}\right]^n`, where n is the number of divisions. + + Higher order Trotter methods are constructed using the recursive Suzuki method, which builds order 2k formulas + as: :math:`S_{2k}(t) = S_{2k-2}(u_k t)^2 S_{2k-2}((1-4u_k) t) S_{2k-2}(u_k t)^2`, + where :math:`u_k = 1/(4-4^{1/(2k-1)})`. + Args: qubit_hamiltonian: The qubit Hamiltonian to be used in the construction. time: The total evolution time. @@ -157,10 +167,11 @@ def _first_order_trotter(self, qubit_hamiltonian: QubitHamiltonian, time: float) TimeEvolutionUnitary: The time evolution unitary built by the Trotter decomposition. """ + weight_threshold = self._settings.get("weight_threshold") + num_divisions = self._resolve_num_divisions(qubit_hamiltonian, time) delta = time / num_divisions - weight_threshold = self._settings.get("weight_threshold") terms = self._decompose_trotter_step(qubit_hamiltonian, time=delta, atol=weight_threshold) @@ -190,6 +201,7 @@ def _resolve_num_divisions(self, qubit_hamiltonian: QubitHamiltonian, time: floa order = self._settings.get("order") weight_threshold = self._settings.get("weight_threshold") + error_bound = self._settings.get("error_bound") if error_bound == "commutator": auto = trotter_steps_commutator( @@ -199,6 +211,7 @@ def _resolve_num_divisions(self, qubit_hamiltonian: QubitHamiltonian, time: floa order=order, weight_threshold=weight_threshold, ) + else: auto = trotter_steps_naive( hamiltonian=qubit_hamiltonian, @@ -217,6 +230,7 @@ def _decompose_trotter_step( Args: qubit_hamiltonian: The qubit Hamiltonian to be decomposed. time: The evolution time for the single step. + atol: Absolute tolerance for filtering small coefficients. Returns: @@ -228,11 +242,87 @@ def _decompose_trotter_step( if not qubit_hamiltonian.is_hermitian(tolerance=atol): raise ValueError("Non-Hermitian Hamiltonian: coefficients have nonzero imaginary parts.") - for label, coeff in qubit_hamiltonian.get_real_coefficients(tolerance=atol): + order = self._settings.get("order") + + if order == 1: + for label, coeff in qubit_hamiltonian.get_real_coefficients(tolerance=atol): + mapping = self._pauli_label_to_map(label) + angle = coeff * time + terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) + # order = 2 or order = 2k with k>1 + else: + coeffs = list(qubit_hamiltonian.get_real_coefficients(tolerance=atol)) + # If there are no coefficients (e.g., empty Hamiltonian or all filtered by atol), + # there is nothing to decompose; return the empty list of terms. + if not coeffs: + return terms + # \prod_{i=1}^{L-1} e^{-iH_i t/(2n)} + for label, coeff in coeffs[:-1]: + mapping = self._pauli_label_to_map(label) + angle = coeff * time / 2 + terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) + # e^{-iH_L t/n} + label, coeff = coeffs[-1] mapping = self._pauli_label_to_map(label) angle = coeff * time terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) + # \prod_{i=L-1}^1 e^{-iH_i t/(2n)} + for label, coeff in reversed(coeffs[:-1]): + mapping = self._pauli_label_to_map(label) + angle = coeff * time / 2 + terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) + + # Construct order 2k formula bottom up dynamic-programming style + if order > 2 and order % 2 == 0: + step_terms = terms.copy() + for k in range(2, int(order / 2) + 1): + u_k = 1 / (4 - 4 ** (1 / (2 * k - 1))) + new_terms = [] + + # S_{2k-2}(u_k t)^2 = S_{2k-2}(u_k t) S_{2k-2}(u_k t) + for _ in range(2): + for term in step_terms: + new_terms.append( + ExponentiatedPauliTerm( + pauli_term=term.pauli_term, + angle=term.angle * u_k, + ) + ) + # S_{2k-2}((1-4u_k) t) + for term in step_terms: + new_terms.append( + ExponentiatedPauliTerm( + pauli_term=term.pauli_term, + angle=term.angle * (1 - 4 * u_k), + ) + ) + + # S_{2k-2}(u_k t)^2 = S_{2k-2}(u_k t) S_{2k-2}(u_k t) + for _ in range(2): + for term in step_terms: + new_terms.append( + ExponentiatedPauliTerm( + pauli_term=term.pauli_term, + angle=term.angle * u_k, + ) + ) + + step_terms = new_terms + terms = step_terms + + # Merge adjacent terms with the same pauli_term by summing angles. + merged_terms: list[ExponentiatedPauliTerm] = [] + for term in terms: + if merged_terms and merged_terms[-1].pauli_term == term.pauli_term: + last = merged_terms[-1] + merged_terms[-1] = ExponentiatedPauliTerm( + pauli_term=last.pauli_term, + angle=last.angle + term.angle, + ) + else: + merged_terms.append(term) + terms = merged_terms return terms def name(self) -> str: diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter_error.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter_error.py index ca96884ad..dbb74a846 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter_error.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter_error.py @@ -30,7 +30,11 @@ import math from typing import TYPE_CHECKING -from qdk_chemistry.utils.pauli_commutation import commutator_bound_first_order +from qdk_chemistry.utils.pauli_commutation import ( + commutator_bound_first_order, + commutator_bound_higher_order, + commutator_bound_second_order, +) if TYPE_CHECKING: from qdk_chemistry.data import QubitHamiltonian @@ -55,10 +59,11 @@ def trotter_steps_naive( .. math:: - N = \left\lceil \frac{(\sum_j |\alpha_j|)^2 \, t^2}{\epsilon} \right\rceil + N = \left\lceil \frac{2(\sum_j |\alpha_j|)^{1 + 1/p} \, t^{1+1/p}} + {\epsilon^{1/p}} \right\rceil where :math:`\sum_j |\alpha_j|` is the 1-norm of the Hamiltonian - coefficients. + coefficients, :math:`p` is the order of the Trotter-Suzuki product formula. Args: hamiltonian: The qubit Hamiltonian to simulate. @@ -72,19 +77,36 @@ def trotter_steps_naive( Raises: ValueError: If ``target_accuracy`` is not positive. - NotImplementedError: If *order* is not yet supported. + NotImplementedError: If *order* is not supported. """ if target_accuracy <= 0: raise ValueError(f"target_accuracy must be positive, got {target_accuracy}.") - if order != 1: + if order not in {1, 2} and not (order > 2 and order % 2 == 0): raise NotImplementedError( f"Trotter step estimation for order {order} is not yet implemented. " - "Only first-order (order=1) is currently supported." + "Non-positive and higher odd orders are not supported." ) real_terms = hamiltonian.get_real_coefficients(tolerance=weight_threshold) one_norm = sum(abs(coeff) for _, coeff in real_terms) - return max(1, math.ceil(one_norm**2 * time**2 / target_accuracy)) + if order == 1: + return max(1, math.ceil(((2 * one_norm**2) * (1 / 2.0) * time**2) / target_accuracy)) + if order == 2: + return max( + 1, + math.ceil( + ((2**2 * one_norm**3) ** (1 / 2) * (1 / 12.0 ** (1 / 2)) * abs(time) ** (1 + 1 / 2)) + / target_accuracy ** (1 / 2) + ), + ) + return max( + 1, + math.ceil( + (2**order * one_norm ** (order + 1)) ** (1 / order) + * abs(time) ** (1 + 1 / order) + / (target_accuracy ** (1 / order)) + ), + ) def trotter_steps_commutator( @@ -101,9 +123,20 @@ def trotter_steps_commutator( .. math:: - N = \left\lceil \frac{t^2}{2\epsilon} + N_1 = \left\lceil \frac{\alpha_1 t^2}{2\epsilon}\right\rceil + + \alpha_1 = \sum_{j j,l > j} \lVert [\alpha_l P_l,\, [\alpha_k P_k,\, \alpha_j P_j] \rVert + + \frac{1}{2} \sum_{k > j} \lVert [\alpha_j P_j,\, [\alpha_j P_j,\, \alpha_k P_k] \rVert + + N_p = \left\lceil \frac{t^{1+1/p}\alpha_p^{1/p}}{\epsilon^{1/p}} \right\rceil + + \alpha_p = \sum_{j_1,\ldots,j_{p+1}} \lVert [\alpha_{j_1} P_{j_1},\, [\ldots [\alpha_{j_p} P_{j_p}, + \alpha_{j_{p+1}}P_{j_{p+1}}]\ldots]\rVert For Pauli strings the commutator norm is :math:`2|\alpha_j||\alpha_k|` when the pair anticommutes and 0 when it commutes. This bound is never @@ -122,15 +155,33 @@ def trotter_steps_commutator( Raises: ValueError: If ``target_accuracy`` is not positive. - NotImplementedError: If *order* is not yet supported. + NotImplementedError: If *order* is not supported. """ if target_accuracy <= 0: raise ValueError(f"target_accuracy must be positive, got {target_accuracy}.") - if order != 1: + if order not in {1, 2} and not (order > 2 and order % 2 == 0): raise NotImplementedError( f"Trotter step estimation for order {order} is not yet implemented. " - "Only first-order (order=1) is currently supported." + "Non-positive and higher odd orders are not supported." ) - comm_bound = commutator_bound_first_order(hamiltonian, weight_threshold=weight_threshold) - return max(1, math.ceil(comm_bound * time**2 / (2.0 * target_accuracy))) + if order == 1: + comm_bound = commutator_bound_first_order(hamiltonian, weight_threshold=weight_threshold) + return max(1, math.ceil(comm_bound * 1 / 2.0 * time**2 / (target_accuracy))) + if order == 2: + comm_bound = commutator_bound_second_order(hamiltonian, weight_threshold=weight_threshold) + + return max( + 1, + math.ceil( + comm_bound ** (1 / 2) * (1 / 12.0) ** (1 / 2) * abs(time) ** (1 + 1 / 2) / (target_accuracy) ** (1 / 2) + ), + ) + + comm_bound = commutator_bound_higher_order(hamiltonian, order=order, weight_threshold=weight_threshold) + return max( + 1, + math.ceil( + (comm_bound / (order + 1)) ** (1 / order) * abs(time) ** (1 + 1 / order) / (target_accuracy) ** (1 / order) + ), + ) diff --git a/python/src/qdk_chemistry/utils/pauli_commutation.py b/python/src/qdk_chemistry/utils/pauli_commutation.py index 3576852ec..6d9fa0565 100644 --- a/python/src/qdk_chemistry/utils/pauli_commutation.py +++ b/python/src/qdk_chemistry/utils/pauli_commutation.py @@ -25,8 +25,12 @@ from __future__ import annotations +import itertools +import math from typing import TYPE_CHECKING +from qdk_chemistry.data import PauliTermAccumulator + if TYPE_CHECKING: from collections.abc import Callable @@ -34,14 +38,30 @@ __all__: list[str] = [ "commutator_bound_first_order", + "commutator_bound_higher_order", + "commutator_bound_second_order", "do_pauli_labels_commute", "do_pauli_labels_qw_commute", "do_pauli_maps_commute", "do_pauli_maps_qw_commute", + "does_nested_commutator_vanish", "get_commutation_checker", ] +def _label_to_sparse_word(label: str) -> list[tuple[int, int]]: + # """Convert a Pauli string label to a ``SparsePauliWord``.""" + return [(i, 1 if c == "X" else 2 if c == "Y" else 3) for i, c in enumerate(label) if c != "I"] + + +def _sparse_word_to_label(word: list[tuple[int, int]], n_qubits: int) -> str: + # """Convert a ``SparsePauliWord`` back to a Pauli string label.""" + chars = ["I"] * n_qubits + for q, p in word: + chars[q] = "X" if p == 1 else "Y" if p == 2 else "Z" + return "".join(chars) + + def do_pauli_labels_commute(label_a: str, label_b: str) -> bool: r"""Check whether two Pauli strings commute. @@ -186,6 +206,41 @@ def get_commutation_checker( raise ValueError(f"Unknown commutation_type {commutation_type!r}; expected 'general' or 'qubit_wise'.") +def does_nested_commutator_vanish(*labels: str) -> bool: + """Check whether a nested commutator of Pauli labels vanishes. + + Args: + labels: A sequence of Pauli labels representing the nested commutator. + + Returns: + ``True`` if the nested commutator vanishes, ``False`` otherwise. + + Raises: + ValueError: If fewer than two Pauli labels are provided. + + """ + if len(labels) < 2: + raise ValueError("At least two Pauli labels are required for a commutator.") + + # Base case: [P_a, P_b] vanishes iff the two strings commute. + if len(labels) == 2: + return do_pauli_labels_commute(labels[0], labels[1]) + + # Recursive case: [P_1, [P_2, ..., P_n]] + # 1. Inner nested commutator vanishes => whole expression vanishes. + if does_nested_commutator_vanish(*labels[1:]): + return True + + # 2. Compute the product P_2 P_3 … P_n via sparse-word multiplication + # (proportional to the inner commutator when it is non-zero) and + # check whether P_1 commutes with it. + word = _label_to_sparse_word(labels[1]) + for lbl in labels[2:]: + _, word = PauliTermAccumulator.multiply_uncached(word, _label_to_sparse_word(lbl)) + inner_product = _sparse_word_to_label(word, len(labels[0])) + return do_pauli_labels_commute(labels[0], inner_product) + + def commutator_bound_first_order( hamiltonian: QubitHamiltonian, weight_threshold: float = 1e-12, @@ -230,3 +285,68 @@ def commutator_bound_first_order( if not do_pauli_labels_commute(pauli_labels[j], pauli_labels[k]): total += 2.0 * abs(coefficients[j]) * abs(coefficients[k]) return total + + +def commutator_bound_second_order( + hamiltonian: QubitHamiltonian, + weight_threshold: float = 1e-12, +) -> float: + r"""Compute the commutator bound term multiplying :math:`t^{3} / (12N)` in Proposition 10 in Childs et. al (2021). + + Args: + hamiltonian: The qubit Hamiltonian for which to compute the bound. + weight_threshold: Absolute threshold for filtering small Hamiltonian coefficients. + + Returns: + The commutator bound term multiplying :math:`t^{3} / (12N)`. + + """ + real_terms = hamiltonian.get_real_coefficients(tolerance=weight_threshold) + pauli_labels = [label for label, _ in real_terms] + coefficients = [coeff for _, coeff in real_terms] + + total_term1 = 0.0 + n = len(pauli_labels) + for i in range(n): + for j in range(i + 1, n): + for k in range(i + 1, n): + if not does_nested_commutator_vanish(pauli_labels[k], pauli_labels[j], pauli_labels[i]): + total_term1 += 2.0**2 * abs(coefficients[i]) * abs(coefficients[j]) * abs(coefficients[k]) + + total_term2 = 0.0 + for i in range(n): + for j in range(i + 1, n): + if not does_nested_commutator_vanish(pauli_labels[i], pauli_labels[i], pauli_labels[j]): + total_term2 += 2.0**2 * abs(coefficients[i]) ** 2 * abs(coefficients[j]) + + return total_term1 + 0.5 * total_term2 + + +def commutator_bound_higher_order( + hamiltonian: QubitHamiltonian, + order: int, + weight_threshold: float = 1e-12, +) -> float: + r"""Compute the commutator bound term :math:`\alpha` for arbitrary-order Trotter errors. + + Args: + hamiltonian: The qubit Hamiltonian for which to compute the bound. + order: The order of the Trotter decomposition. + weight_threshold: Absolute threshold for filtering small Hamiltonian coefficients. + + Returns: + The commutator bound term :math:`\alpha` multiplying :math:`t^{order+1}` in Theorem 6 of Childs et. al (2021). + + """ + real_terms = hamiltonian.get_real_coefficients(tolerance=weight_threshold) + pauli_labels = [label for label, _ in real_terms] + coefficients = [coeff for _, coeff in real_terms] + abs_coeffs = [abs(c) for c in coefficients] + + n = len(pauli_labels) + total = 0.0 + for idx_tuple in itertools.product(range(n), repeat=order + 1): + labels = [pauli_labels[i] for i in idx_tuple] + if not does_nested_commutator_vanish(*labels): + total += (2.0**order) * math.prod(abs_coeffs[i] for i in idx_tuple) + return total From d020a7884c7ae6bcced6ce11640c3ae9e6abfcb7 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 11 Mar 2026 15:56:18 +0000 Subject: [PATCH 002/117] started implementing --- python/pyproject.toml | 3 +- .../time_evolution/builder/trotter.py | 325 +++++++- .../time_evolution/builder/trotter_error.py | 11 +- .../time_evolution/circuit_mapper/__init__.py | 15 + .../time_evolution/circuit_mapper/base.py | 49 ++ .../circuit_mapper/pauli_sequence_mapper.py | 121 +++ .../measure_simulation/__init__.py | 10 + .../time_evolution/measure_simulation/base.py | 246 ++++++ .../measure_simulation/evolve_and_measure.py | 112 +++ .../containers/pauli_product_formula.py | 31 + .../qdk_chemistry/utils/pauli_commutation.py | 26 +- .../qdk_chemistry/utils/qsharp/PauliExp.qs | 77 ++ .../qdk_chemistry/utils/qsharp/__init__.py | 2 + python/tests/test_evolve_and_measure.py | 121 +++ ..._evolution_circuit_mapper_noncontrolled.py | 57 ++ python/tests/test_time_evolution_trotter.py | 706 +++++++++++++++++- python/tests/test_trotter_error.py | 146 +++- 17 files changed, 1985 insertions(+), 73 deletions(-) create mode 100644 python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/__init__.py create mode 100644 python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/base.py create mode 100644 python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/pauli_sequence_mapper.py create mode 100644 python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/__init__.py create mode 100644 python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py create mode 100644 python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py create mode 100644 python/src/qdk_chemistry/utils/qsharp/PauliExp.qs create mode 100644 python/tests/test_evolve_and_measure.py create mode 100644 python/tests/test_time_evolution_circuit_mapper_noncontrolled.py diff --git a/python/pyproject.toml b/python/pyproject.toml index fb1a49749..5f7d98e51 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -39,7 +39,8 @@ dependencies = [ "pydantic-settings>=2.9.1", "qdk[jupyter]>=1.26.0", "pybind11-stubgen>=2.5.1", - "h5py>=3.0.0" + "h5py>=3.0.0", + "paulimer" ] description = "Quantum Development Kit - Chemistry Library" license = { file = "LICENSE.txt" } diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index 506062738..67741c143 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -4,6 +4,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See LICENSE.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import cmath from qdk_chemistry.algorithms.time_evolution.builder.base import TimeEvolutionBuilder from qdk_chemistry.algorithms.time_evolution.builder.trotter_error import ( @@ -57,6 +58,12 @@ def __init__(self): self._set_default( "weight_threshold", "float", 1e-12, "The absolute threshold for filtering small coefficients." ) + self._set_default( + "optimize_term_ordering", + "bool", + False, + "Whether to group commuting terms and execute them in parallel.", + ) class Trotter(TimeEvolutionBuilder): @@ -70,6 +77,7 @@ def __init__( num_divisions: int = 0, error_bound: str = "commutator", weight_threshold: float = 1e-12, + optimize_term_ordering: bool = False, ): r"""Initialize Trotter builder with specified Trotter decomposition settings. @@ -117,6 +125,7 @@ def __init__( (default, tighter) or ``"naive"``. weight_threshold: Absolute threshold for filtering small Hamiltonian coefficients. Defaults to 1e-12. + optimize_term_ordering: Whether to group commuting terms and execute them in parallel. Defaults to False. """ super().__init__() @@ -126,6 +135,7 @@ def __init__( self._settings.set("num_divisions", num_divisions) self._settings.set("error_bound", error_bound) self._settings.set("weight_threshold", weight_threshold) + self._settings.set("optimize_term_ordering", optimize_term_ordering) def _run_impl(self, qubit_hamiltonian: QubitHamiltonian, time: float) -> TimeEvolutionUnitary: """Construct the time evolution unitary using Trotter decomposition. @@ -173,7 +183,11 @@ def _trotter(self, qubit_hamiltonian: QubitHamiltonian, time: float) -> TimeEvol delta = time / num_divisions - terms = self._decompose_trotter_step(qubit_hamiltonian, time=delta, atol=weight_threshold) + optimize_term_ordering = self._settings.get("optimize_term_ordering") + + terms = self._decompose_trotter_step( + qubit_hamiltonian, time=delta, atol=weight_threshold, optimize_term_ordering=optimize_term_ordering + ) num_qubits = qubit_hamiltonian.num_qubits @@ -223,7 +237,12 @@ def _resolve_num_divisions(self, qubit_hamiltonian: QubitHamiltonian, time: floa return max(manual, auto) def _decompose_trotter_step( - self, qubit_hamiltonian: QubitHamiltonian, time: float, *, atol: float = 1e-12 + self, + qubit_hamiltonian: QubitHamiltonian, + time: float, + *, + atol: float = 1e-12, + optimize_term_ordering: bool = False, ) -> list[ExponentiatedPauliTerm]: """Decompose a single Trotter step into exponentiated Pauli terms. @@ -232,6 +251,8 @@ def _decompose_trotter_step( time: The evolution time for the single step. atol: Absolute tolerance for filtering small coefficients. + optimize_term_ordering: Whether to group commuting terms together + and further subgroup into parallelizable layers. Returns: A list of ``ExponentiatedPauliTerm`` representing the decomposed terms. @@ -242,36 +263,55 @@ def _decompose_trotter_step( if not qubit_hamiltonian.is_hermitian(tolerance=atol): raise ValueError("Non-Hermitian Hamiltonian: coefficients have nonzero imaginary parts.") + coeffs = list(qubit_hamiltonian.get_real_coefficients(tolerance=atol)) + # If there are no coefficients (e.g., empty Hamiltonian or all filtered by atol), + # there is nothing to decompose; return the empty list of terms. + if not coeffs: + return terms + order = self._settings.get("order") + grouped_hamiltonians = self._group_terms(qubit_hamiltonian, optimize_term_ordering=optimize_term_ordering) if order == 1: - for label, coeff in qubit_hamiltonian.get_real_coefficients(tolerance=atol): - mapping = self._pauli_label_to_map(label) - angle = coeff * time - terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) + for group in grouped_hamiltonians: + for subgroup in group: + terms.extend( + self._exponentiate_commuting( + subgroup, + num_qubits=qubit_hamiltonian.num_qubits, + time=time, + atol=atol, + ) + ) + # order = 2 or order = 2k with k>1 else: - coeffs = list(qubit_hamiltonian.get_real_coefficients(tolerance=atol)) - # If there are no coefficients (e.g., empty Hamiltonian or all filtered by atol), - # there is nothing to decompose; return the empty list of terms. - if not coeffs: - return terms - # \prod_{i=1}^{L-1} e^{-iH_i t/(2n)} - for label, coeff in coeffs[:-1]: - mapping = self._pauli_label_to_map(label) - angle = coeff * time / 2 - terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) - # e^{-iH_L t/n} - label, coeff = coeffs[-1] - mapping = self._pauli_label_to_map(label) - angle = coeff * time - terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) + # \prod e^{-iH_i t/(2n)} for all Hamiltonian terms groups except the last one + # which is executed in the middle as e^{-iH_i t/n} + terms_without_last_group = [] + for group in grouped_hamiltonians[:-1]: + for subgroup in group: + terms_without_last_group.extend( + self._exponentiate_commuting( + subgroup, + num_qubits=qubit_hamiltonian.num_qubits, + time=time / 2, + atol=atol, + ) + ) - # \prod_{i=L-1}^1 e^{-iH_i t/(2n)} - for label, coeff in reversed(coeffs[:-1]): - mapping = self._pauli_label_to_map(label) - angle = coeff * time / 2 - terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) + terms.extend(terms_without_last_group) + # e^{-iH_i t/n} for all terms in the last group + for subgroup in grouped_hamiltonians[-1]: + terms.extend( + self._exponentiate_commuting( + subgroup, + num_qubits=qubit_hamiltonian.num_qubits, + time=time, + atol=atol, + ) + ) + terms.extend(terms_without_last_group[::-1]) # reverse all but the last group and append # Construct order 2k formula bottom up dynamic-programming style if order > 2 and order % 2 == 0: @@ -323,6 +363,239 @@ def _decompose_trotter_step( else: merged_terms.append(term) terms = merged_terms + + return terms + + def _group_terms( + self, + qubit_hamiltonian: QubitHamiltonian, + *, + optimize_term_ordering: bool = True, + ) -> list[list[QubitHamiltonian]]: + """Group Hamiltonian terms into commuting and concurrent sets. + + When *optimize_term_ordering* is ``True``: + 1. Partition using :meth:`QubitHamiltonian.group_commuting`. + 2. Within each group, merge terms with identical Pauli strings + by summing their coefficients. + 3. Within each group, split terms into parallelizable layers + (terms whose Pauli supports are disjoint). + 4. Move the group with the most multi-qubit terms to the end. + + When ``False``, each Pauli string is returned as its own + single-term group with no reordering. + + Args: + qubit_hamiltonian: The qubit Hamiltonian to group. + optimize_term_ordering: Whether to group commuting terms together. + + Returns: + A list of groups, where each group is a list of + ``QubitHamiltonian`` sub-groups (parallelizable layers). + + """ + if not optimize_term_ordering: + return [ + [ + QubitHamiltonian( + pauli_strings=[label], + coefficients=[coeff], + encoding=qubit_hamiltonian.encoding, + ) + ] + for label, coeff in zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=False) + ] + + # Sort terms so that Pauli strings acting on more qubits appear first. + num_non_identity = [sum(c != "I" for c in ps) for ps in qubit_hamiltonian.pauli_strings] + sorted_indices = sorted(range(len(num_non_identity)), key=lambda i: num_non_identity[i], reverse=True) + qubit_hamiltonian = QubitHamiltonian( + pauli_strings=[qubit_hamiltonian.pauli_strings[i] for i in sorted_indices], + coefficients=[qubit_hamiltonian.coefficients[i] for i in sorted_indices], + encoding=qubit_hamiltonian.encoding, + ) + + sub_hamiltonians = qubit_hamiltonian.group_commuting(qubit_wise=False) + + result: list[list[QubitHamiltonian]] = [] + for sub_h in sub_hamiltonians: + # Merge terms with identical Pauli strings. + merged: dict[str, complex] = {} + for label, coeff in zip(sub_h.pauli_strings, sub_h.coefficients, strict=False): + merged[label] = merged.get(label, 0.0) + coeff + labels = list(merged.keys()) + coeffs = list(merged.values()) + + # Split into parallelizable layers (disjoint qubit supports). + # Each layer becomes its own sub-group so that every sub-group + # passed to encoding_clifford_of contains only independent generators. + pauli_maps = [self._pauli_label_to_map(label) for label in labels] + layers_indices: list[list[int]] = [] + layers_occupied: list[set[int]] = [] + for i, pm in enumerate(pauli_maps): + qubits = set(pm.keys()) + placed = False + for layer_i, layer_occ in enumerate(layers_occupied): + if qubits.isdisjoint(layer_occ): + layers_indices[layer_i].append(i) + placed = True + break + if not placed: + layers_indices.append([i]) + layers_occupied.append(set(qubits)) + + outer_group: list[QubitHamiltonian] = [] + for layer in layers_indices: + outer_group.append( + QubitHamiltonian( + pauli_strings=[labels[i] for i in layer], + coefficients=[coeffs[i] for i in layer], + encoding=sub_h.encoding, + ) + ) + result.append(outer_group) + + # Move the group with the most multi-qubit terms to the end. + def _multi_qubit_count(group: list[QubitHamiltonian]) -> int: + return sum(1 for sub_h in group for label in sub_h.pauli_strings if sum(c != "I" for c in label) > 1) + + max_idx = max(range(len(result)), key=lambda i: _multi_qubit_count(result[i])) + result.append(result.pop(max_idx)) + + return result + + def _exponentiate_commuting( + self, + group: QubitHamiltonian, + num_qubits: int, + time: float, + *, + atol: float = 1e-12, + ) -> list[ExponentiatedPauliTerm]: + r"""Exponentiate commuting Pauli groups via Clifford diagonalization. + + For each commuting group produced by :meth:`_group_terms`, computes + the encoding Clifford *C* that simultaneously diagonalizes every + Pauli string in that group (:math:`C\,P_j\,C^\dagger = D_j`, where + :math:`D_j` is a product of *Z* and *I* operators only), then + constructs the sandwich decomposition: + + .. math:: + + \prod_j e^{-i\,\theta_j\,P_j} + \;=\; + C^\dagger + \!\left(\prod_j e^{-i\,\theta_j\,D_j}\right) + C + + Applied to a quantum state :math:`|\psi\rangle`: + + 1. Apply *C* (rotate into the diagonal basis). + 2. Apply :math:`\prod_j e^{-i\,\theta_j\,D_j}` (diagonal rotations). + 3. Apply :math:`C^\dagger` (undo the basis change). + + The returned list encodes the full sequence as + :class:`ExponentiatedPauliTerm` entries: for every commuting group + the Clifford *C* (decomposed into Pauli rotations), then the + diagonal :math:`e^{-i\theta_j D_j}` terms, then :math:`C^\dagger` + (the reversed rotations with negated angles). + + Args: + group: The group of commuting Hamiltonian terms to exponentiate. + num_qubits: The number of qubits in the system. + time: The evolution time used to compute rotation angles + (:math:`\theta_j = c_j \cdot t`). + atol: Absolute tolerance for filtering small coefficients. + + Returns: + A flat list of :class:`ExponentiatedPauliTerm` representing, for + each commuting group, the sequence + :math:`C \;\prod_j e^{-i\theta_j D_j}\; C^\dagger`. + + """ + from paulimer import DensePauli, SparsePauli, encoding_clifford_of # noqa: PLC0415 + + terms: list[ExponentiatedPauliTerm] = [] + + # Single-term groups don't need Clifford diagonalization. + if len(group.pauli_strings) == 1: + for label, coeff in group.get_real_coefficients(tolerance=atol): + mapping = self._pauli_label_to_map(label) + angle = coeff * time + terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) + return terms + + sparse_paulis = [SparsePauli(ps) for ps in group.pauli_strings] + clifford = encoding_clifford_of(sparse_paulis, num_qubits) + + if clifford.is_identity: + # All terms are already diagonal (Z/I only); no basis change needed. + for label, coeff in group.get_real_coefficients(tolerance=atol): + mapping = self._pauli_label_to_map(label) + angle = coeff * time + terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) + return terms + + # Decompose the Clifford C into Pauli rotations. + clifford_terms = self._clifford_to_terms(clifford, num_qubits, atol) + + # C: rotate into the diagonal basis. + terms.extend(clifford_terms) + + # Build the diagonal exponentiated terms. + # C diagonalizes each P_j: D_j = C P_j C† (Z/I only). + for label, coeff in group.get_real_coefficients(tolerance=atol): + diag_pauli = clifford.image_of(DensePauli(label)) + mapping = self._pauli_label_to_map(diag_pauli.characters) + angle = coeff * time * diag_pauli.phase.real + terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) + + # C†: undo the basis change (reversed order, negated angles). + for ct in reversed(clifford_terms): + terms.append(ExponentiatedPauliTerm(pauli_term=ct.pauli_term, angle=-ct.angle)) + + return terms + + @staticmethod + def _clifford_to_terms(clifford, num_qubits, atol) -> list[ExponentiatedPauliTerm]: + r"""Convert an encoding Clifford into Pauli strings with phases. + + Extracts the 2n generator images of the Clifford via + :meth:`~paulimer.CliffordUnitary.image_z` and + :meth:`~paulimer.CliffordUnitary.image_x`, returning each as + an :class:`ExponentiatedPauliTerm` with angle derived from the + image phase. + + Args: + clifford: The encoding Clifford object returned by + :func:`~paulimer.encoding_clifford_of`. + num_qubits: Number of qubits. + atol: Absolute tolerance for filtering small angles (e.g., from numerical imprecision). + + Returns: + A list of :class:`ExponentiatedPauliTerm` entries. + + """ + if clifford.is_identity: + return [] + + terms: list[ExponentiatedPauliTerm] = [] + for q in range(num_qubits): + for image in (clifford.image_z(q), clifford.image_x(q)): + chars = image.characters + # Skip pure-identity images. + if all(c == "I" for c in chars): + continue + # phase is +1, -1, +i, or -i. + # Map to an angle: phase = exp(i * phi) => angle = phi / 2 + phi = cmath.phase(image.phase) # 0, pi, pi/2, -pi/2 + angle = phi / 2 + # Skip no-op rotations (phase == +1 => angle == 0). + if abs(angle) < atol: + continue + mapping = Trotter._pauli_label_to_map(chars) + terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) + return terms def name(self) -> str: diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter_error.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter_error.py index dbb74a846..ed9fb1325 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter_error.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter_error.py @@ -90,14 +90,11 @@ def trotter_steps_naive( real_terms = hamiltonian.get_real_coefficients(tolerance=weight_threshold) one_norm = sum(abs(coeff) for _, coeff in real_terms) if order == 1: - return max(1, math.ceil(((2 * one_norm**2) * (1 / 2.0) * time**2) / target_accuracy)) + return max(1, math.ceil(((2 * one_norm**2) * time**2) / target_accuracy)) if order == 2: return max( 1, - math.ceil( - ((2**2 * one_norm**3) ** (1 / 2) * (1 / 12.0 ** (1 / 2)) * abs(time) ** (1 + 1 / 2)) - / target_accuracy ** (1 / 2) - ), + math.ceil(((2**2 * one_norm**3) ** (1 / 2) * abs(time) ** (1 + 1 / 2)) / target_accuracy ** (1 / 2)), ) return max( 1, @@ -181,7 +178,5 @@ def trotter_steps_commutator( comm_bound = commutator_bound_higher_order(hamiltonian, order=order, weight_threshold=weight_threshold) return max( 1, - math.ceil( - (comm_bound / (order + 1)) ** (1 / order) * abs(time) ** (1 + 1 / order) / (target_accuracy) ** (1 / order) - ), + math.ceil(comm_bound ** (1 / order) * abs(time) ** (1 + 1 / order) / (target_accuracy) ** (1 / order)), ) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/__init__.py b/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/__init__.py new file mode 100644 index 000000000..59343771f --- /dev/null +++ b/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/__init__.py @@ -0,0 +1,15 @@ +"""QDK/Chemistry time evolution circuit mapper module.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from .base import EvolutionCircuitMapperFactory +from .pauli_sequence_mapper import PauliSequenceMapper, PauliSequenceMapperSettings + +__all__ = [ + "EvolutionCircuitMapperFactory", + "PauliSequenceMapper", + "PauliSequenceMapperSettings", +] diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/base.py b/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/base.py new file mode 100644 index 000000000..70d299ad0 --- /dev/null +++ b/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/base.py @@ -0,0 +1,49 @@ +"""QDK/Chemistry time evolution unitary circuit mapper abstractions.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from abc import abstractmethod + +from qdk_chemistry.algorithms.base import Algorithm, AlgorithmFactory +from qdk_chemistry.data import Circuit +from qdk_chemistry.data.time_evolution.base import TimeEvolutionUnitary + +__all__: list[str] = ["EvolutionCircuitMapper", "EvolutionCircuitMapperFactory"] + + +class EvolutionCircuitMapper(Algorithm): + """Base class for time evolution circuit mapper in QDK/Chemistry algorithms.""" + + def __init__(self): + """Initialize the EvolutionCircuitMapper.""" + super().__init__() + + @abstractmethod + def _run_impl(self, evolution: TimeEvolutionUnitary, *args, **kwargs) -> Circuit: + """Construct a Circuit representing the unitary for the given TimeEvolutionUnitary. + + Args: + evolution: The time evolution unitary. + *args: Positional arguments, where the first argument is expected to be the + time evolution unitary. + **kwargs: Additional keyword arguments for concrete implementation. + + Returns: + Circuit: A Circuit representing the unitary for the given TimeEvolutionUnitary. + + """ + + +class EvolutionCircuitMapperFactory(AlgorithmFactory): + """Factory class for creating EvolutionCircuitMapper instances.""" + + def algorithm_type_name(self) -> str: + """Return evolution_circuit_mapper as the algorithm type name.""" + return "evolution_circuit_mapper" + + def default_algorithm_name(self) -> str: + """Return pauli_sequence as the default algorithm name.""" + return "pauli_sequence" diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/pauli_sequence_mapper.py b/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/pauli_sequence_mapper.py new file mode 100644 index 000000000..8942b8475 --- /dev/null +++ b/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/pauli_sequence_mapper.py @@ -0,0 +1,121 @@ +"""QDK/Chemistry sequence structure evolution circuit mapper.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from qdk import qsharp + +from qdk_chemistry.data import Settings +from qdk_chemistry.data.circuit import Circuit +from qdk_chemistry.data.time_evolution.base import TimeEvolutionUnitary +from qdk_chemistry.data.time_evolution.containers.pauli_product_formula import ( + PauliProductFormulaContainer, +) +from qdk_chemistry.utils.qsharp import QSHARP_UTILS + +from .base import EvolutionCircuitMapper + +__all__: list[str] = ["PauliSequenceMapper", "PauliSequenceMapperSettings"] + + +class PauliSequenceMapperSettings(Settings): + """Settings for PauliSequenceMapper.""" + + def __init__(self): + """Initialize PauliSequenceMapperSettings with default values.""" + super().__init__() + + +class PauliSequenceMapper(EvolutionCircuitMapper): + r"""Evolution circuit mapper using Pauli product formula term sequences. + + Given a time-evolution operator expressed as a Pauli product formula + :math:`U(t) \approx \left[ U_{\mathrm{step}}(t / r) \right]^{r}`, this mapper constructs + a :math:`U(t)` using the following pattern: + + 1. Each Pauli operator :math:`P_j` is basis-rotated into the :math:`Z` basis. + 2. Qubits involved in :math:`P_j` are entangled into a sequence using CNOT gates. + 3. A :math:`R_z` rotation implements + :math:`e^{-i\,\theta_j\,P_j} \;\rightarrow\; R_z(2 \theta_j)`. + 4. The basis rotations and entangling operations are uncomputed. + + Notes: + * Requires a ``PauliProductFormulaContainer`` for the time evolution unitary. + + """ + + def __init__(self): + """Initialize the PauliSequenceMapper.""" + super().__init__() + self._settings = PauliSequenceMapperSettings() + + def name(self) -> str: + """Return the algorithm name.""" + return "pauli_sequence" + + def type_name(self) -> str: + """Return evolution_circuit_mapper as the algorithm type name.""" + return "evolution_circuit_mapper" + + def _run_impl(self, evolution: TimeEvolutionUnitary) -> Circuit: + r"""Construct a quantum circuit implementing the time evolution unitary. + + Args: + evolution: The time evolution unitary containing the Hamiltonian + and evolution parameters. + + Returns: + Circuit: A quantum circuit implementing the unitary :math:`U` + where :math:`U` is the time evolution operator :math:`\exp(-i H t)`. + + Raises: + ValueError: If the time evolution unitary container type is not supported. + + """ + unitary_container = evolution.get_container() + if not isinstance(unitary_container, PauliProductFormulaContainer): + raise ValueError( + f"The {evolution.get_container_type()} container type is not supported. " + "PauliSequenceMapper only supports PauliProductFormula container for time evolution unitary." + ) + + pauli_terms: list[list[qsharp.Pauli]] = [] + angles: list[float] = [] + for term in unitary_container.step_terms: + base_terms = [qsharp.Pauli.I] * unitary_container.num_qubits + for index, pauli in term.pauli_term.items(): + base_terms[index] = getattr(qsharp.Pauli, pauli) + pauli_terms.append(base_terms.copy()) + angles.append(term.angle) + + flattened_pauli_terms: list[list[qsharp.Pauli]] = [] + flattened_angles: list[float] = [] + for _ in range(unitary_container.step_reps): + flattened_pauli_terms.extend(pauli_terms) + flattened_angles.extend(angles) + + evo_params = { + "pauliExponents": flattened_pauli_terms, + "pauliCoefficients": flattened_angles, + "repetitions": 1, + } + + target_indices = list(range(unitary_container.num_qubits)) + + qsc = qsharp.circuit( + QSHARP_UTILS.PauliExp.MakeRepPauliExpCircuit, + evo_params, + target_indices, + ) + + qir = qsharp.compile( + QSHARP_UTILS.PauliExp.MakeRepPauliExpCircuit, + evo_params, + target_indices, + ) + + evolution_op = QSHARP_UTILS.PauliExp.MakeRepPauliExpOp(evo_params) + + return Circuit(qsharp=qsc, qir=qir, qsharp_op=evolution_op) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/__init__.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/__init__.py new file mode 100644 index 000000000..eab2c9b4f --- /dev/null +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/__init__.py @@ -0,0 +1,10 @@ +"""QDK/Chemistry evolve-and-measure algorithms module.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +from .base import MeasureSimulationFactory +from .evolve_and_measure import EvolveAndMeasure + +__all__: list[str] = ["EvolveAndMeasure", "MeasureSimulationFactory"] diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py new file mode 100644 index 000000000..14edce80f --- /dev/null +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py @@ -0,0 +1,246 @@ +"""QDK/Chemistry evolve-and-measure abstractions and utilities.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import re +from abc import abstractmethod + +from qdk import qsharp + +from qdk_chemistry.algorithms.base import Algorithm, AlgorithmFactory +from qdk_chemistry.algorithms.circuit_executor.base import CircuitExecutor +from qdk_chemistry.algorithms.time_evolution.builder.base import TimeEvolutionBuilder +from qdk_chemistry.algorithms.time_evolution.circuit_mapper.base import EvolutionCircuitMapper +from qdk_chemistry.data import ( + Circuit, + EnergyExpectationResult, + MeasurementData, + QuantumErrorProfile, + QubitHamiltonian, + Settings, + TimeEvolutionUnitary, +) +from qdk_chemistry.utils.qsharp import QSHARP_UTILS + +__all__: list[str] = ["MeasureSimulation", "MeasureSimulationFactory", "MeasureSimulationSettings"] + +_QASM_QUBIT_DECLARATION_PATTERN = re.compile(r"\bqubit\s*\[(\d+)\]") +_QIR_REQUIRED_NUM_QUBITS_PATTERN = re.compile(r'"required_num_qubits"="(\d+)"') + + +class MeasureSimulationSettings(Settings): + """Settings for evolve-and-measure simulation.""" + + def __init__(self): + """Initialize defaults for evolve-and-measure simulation.""" + super().__init__() + + +class MeasureSimulation(Algorithm): + """Abstract base class for Hamiltonian evolution and observable measurement.""" + + def __init__(self): + """Initialize the evolve-and-measure simulation settings.""" + super().__init__() + self._settings = MeasureSimulationSettings() + + def type_name(self) -> str: + """Return the algorithm type name as measure_simulation.""" + return "measure_simulation" + + @abstractmethod + def _run_impl( + self, + qubit_hamiltonians: list[QubitHamiltonian], + times: list[float], + observables: list[QubitHamiltonian], + *, + state_prep: Circuit | None = None, + evolution_builder: TimeEvolutionBuilder, + circuit_mapper: EvolutionCircuitMapper, + shots: int = 1000, + circuit_executor: CircuitExecutor, + noise: QuantumErrorProfile | None = None, + basis_gates: list[str] | None = None, + ) -> list[MeasurementData]: + """Run evolve-and-measure simulation. + + Args: + qubit_hamiltonians: List of Hamiltonians used to build time evolution. + times: List of times to evolve under the Hamiltonians. + observables: List of observable Hamiltonians to measure after evolution. + state_prep: Optional circuit that prepares the initial state before time evolution. + evolution_builder: Time-evolution builder. + circuit_mapper: Mapper for time-evolution unitary to circuit. + shots: Number of shots to use for measurement. + circuit_executor: Circuit executor backend. + noise: Optional noise profile. + basis_gates: Optional list of basis gates to transpile the circuit into before execution. + + Returns: + A list of ``MeasurementData`` objects. + + """ + + def _create_time_evolution( + self, + qubit_hamiltonian: QubitHamiltonian, + time: float, + evolution_builder: TimeEvolutionBuilder, + ) -> TimeEvolutionUnitary: + """Create the time-evolution unitary for current settings.""" + return evolution_builder.run(qubit_hamiltonian, time) + + def _map_time_evolution_to_circuit( + self, + evolution: TimeEvolutionUnitary, + circuit_mapper: EvolutionCircuitMapper, + ) -> Circuit: + """Map a time-evolution unitary into an executable circuit.""" + return circuit_mapper.run(evolution) + + def _prepend_state_prep_circuit(self, state_prep: Circuit, circuit: Circuit) -> Circuit: + state_prep_op = getattr(state_prep, "_qsharp_op", None) + circuit_op = getattr(circuit, "_qsharp_op", None) + if state_prep_op is None or circuit_op is None: + raise RuntimeError("State-preparation circuit composition requires Q# operations on both circuits.") + + if state_prep.encoding is not None and circuit.encoding is not None and state_prep.encoding != circuit.encoding: + raise ValueError( + "State-preparation circuit and evolution circuit use different encodings " + f"('{state_prep.encoding}' and '{circuit.encoding}')." + ) + + num_qubits = None + for representation in (state_prep.qir, circuit.qir, state_prep.qasm, circuit.qasm): + if representation is None: + continue + + match = _QIR_REQUIRED_NUM_QUBITS_PATTERN.search(str(representation)) + if match: + candidate = int(match.group(1)) + else: + qasm_match = _QASM_QUBIT_DECLARATION_PATTERN.search(str(representation)) + if not qasm_match: + continue + candidate = int(qasm_match.group(1)) + + if num_qubits is None: + num_qubits = candidate + elif num_qubits != candidate: + raise ValueError( + "State-preparation circuit and evolution circuit must act on the same number of qubits " + f"(received {num_qubits} and {candidate})." + ) + + if num_qubits is None: + raise RuntimeError("Unable to infer the number of qubits needed to compose the Q# circuits.") + + target_indices = list(range(num_qubits)) + combined_encoding = circuit.encoding if circuit.encoding is not None else state_prep.encoding + qsharp_circuit = qsharp.circuit( + QSHARP_UTILS.CircuitComposition.MakeSequentialCircuit, + state_prep_op, + circuit_op, + target_indices, + ) + qir = qsharp.compile( + QSHARP_UTILS.CircuitComposition.MakeSequentialCircuit, + state_prep_op, + circuit_op, + target_indices, + ) + + return Circuit( + qsharp=qsharp_circuit, + qir=qir, + qsharp_op=QSHARP_UTILS.CircuitComposition.MakeSequentialOp(state_prep_op, circuit_op), + encoding=combined_encoding, + ) + + @staticmethod + def _transpile_to_basis_gates(circuit: Circuit, basis_gates: list[str]) -> Circuit: + """Transpile a Circuit to a target basis gate set using the qdk-chemistry transpiler. + + Args: + circuit: The circuit to transpile. + basis_gates: Target basis gates (e.g. ``["cx", "rz", "h", "x"]``). + + Returns: + A new ``Circuit`` restricted to the requested basis gates. + + """ + from qiskit import qasm3, transpile # noqa: PLC0415 + from qiskit.transpiler import PassManager # noqa: PLC0415 + + from qdk_chemistry.plugins.qiskit._interop.transpiler import ( # noqa: PLC0415 + MergeZBasisRotations, + RemoveZBasisOnZeroState, + SubstituteCliffordRz, + ) + + qc = circuit.get_qiskit_circuit() + qc = transpile(qc, basis_gates=basis_gates, optimization_level=3) + + pm = PassManager( + [ + MergeZBasisRotations(), + SubstituteCliffordRz(), + RemoveZBasisOnZeroState(), + ] + ) + qc = pm.run(qc) + + return Circuit(qasm=qasm3.dumps(qc), encoding=circuit.encoding) + + def _measure_observable( + self, + circuit: Circuit, + observable: QubitHamiltonian, + circuit_executor: CircuitExecutor, + shots: int = 1000, + noise: QuantumErrorProfile | None = None, + ) -> tuple[EnergyExpectationResult, MeasurementData]: + """Measure a qubit observable on the provided circuit state.""" + grouped_observables = observable.group_commuting(qubit_wise=True) + if not grouped_observables: + raise ValueError("Observable has no measurable terms after grouping.") + + if shots < len(grouped_observables): + raise ValueError( + f"Total shots {shots} is less than the number of grouped observables {len(grouped_observables)}." + ) + + shots_per_group = shots // len(grouped_observables) + if shots_per_group <= 0: + raise ValueError("shots per measurement group must be positive.") + + if getattr(circuit_executor, "type_name", lambda: None)() != "energy_estimator": + raise TypeError("Only an EnergyEstimator is supported as circuit_executor.") + + energy_result, measurement_data = circuit_executor.run( + circuit, + grouped_observables, + total_shots=shots, + noise_model=noise, + ) + return energy_result, measurement_data + + +class MeasureSimulationFactory(AlgorithmFactory): + """Factory class for creating evolve-and-measure algorithm instances.""" + + def __init__(self): + """Initialize the MeasureSimulationFactory.""" + super().__init__() + + def algorithm_type_name(self) -> str: + """Return the algorithm type name as measure_simulation.""" + return "measure_simulation" + + def default_algorithm_name(self) -> str: + """Return evolve_and_measure as the default algorithm name.""" + return "evolve_and_measure" diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py new file mode 100644 index 000000000..74171f5cc --- /dev/null +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py @@ -0,0 +1,112 @@ +"""Hamiltonian evolution + observable measurement implementation.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from qdk_chemistry.algorithms.circuit_executor.base import CircuitExecutor +from qdk_chemistry.algorithms.time_evolution.builder.base import TimeEvolutionBuilder +from qdk_chemistry.algorithms.time_evolution.circuit_mapper.base import EvolutionCircuitMapper +from qdk_chemistry.data import ( + Circuit, + MeasurementData, + QuantumErrorProfile, + QubitHamiltonian, + TimeEvolutionUnitary, +) +from qdk_chemistry.utils import Logger + +from .base import MeasureSimulation, MeasureSimulationSettings + +__all__: list[str] = ["EvolveAndMeasure", "EvolveAndMeasureSettings"] + + +class EvolveAndMeasureSettings(MeasureSimulationSettings): + """Settings for the EvolveAndMeasure algorithm.""" + + def __init__(self): + """Initialize the settings for EvolveAndMeasure.""" + super().__init__() + + +class EvolveAndMeasure(MeasureSimulation): + """Evolve under a Hamiltonian and measure a target observable.""" + + def __init__(self): + """Initialize EvolveAndMeasure with the given settings.""" + Logger.trace_entering() + super().__init__() + self._settings = EvolveAndMeasureSettings() + + def _run_impl( + self, + qubit_hamiltonians: list[QubitHamiltonian], + times: list[float], + observables: list[QubitHamiltonian], + *, + state_prep: Circuit | None = None, + evolution_builder: TimeEvolutionBuilder, + circuit_mapper: EvolutionCircuitMapper, + shots: int = 1000, + circuit_executor: CircuitExecutor, + noise: QuantumErrorProfile | None = None, + basis_gates: list[str] | None = None, + ) -> list[MeasurementData]: + """Run evolve-and-measure simulation. + + Args: + qubit_hamiltonians: List of Hamiltonians used to build time evolution. + times: List of times to evolve under the Hamiltonians. + observables: List of observable Hamiltonians to measure after evolution. + state_prep: Optional circuit that prepares the initial state before time evolution. + evolution_builder: Time-evolution builder. + circuit_mapper: Mapper for time-evolution unitary to circuit. + shots: Number of shots to use for measurement. + circuit_executor: Circuit executor backend. + noise: Optional noise profile. + basis_gates: Optional list of basis gates to transpile the circuit into before execution. + + Returns: + A list of ``MeasurementData`` objects. + + """ + evolution = self._create_time_evolution(qubit_hamiltonians[0], times[0], evolution_builder) + + for i in range(1, len(qubit_hamiltonians)): + qubit_hamiltonian = qubit_hamiltonians[i] + time = times[i] + delta_t = time - times[i - 1] + + evolution = TimeEvolutionUnitary( + evolution.get_container().combine( + self._create_time_evolution(qubit_hamiltonian, delta_t, evolution_builder).get_container(), + evolution_builder.settings().get("weight_threshold"), + ) + ) + + evolution_circuit = self._map_time_evolution_to_circuit(evolution, circuit_mapper) + + if state_prep is not None: + evolution_circuit = self._prepend_state_prep_circuit(state_prep, evolution_circuit) + + # Transpile to basis gates + if basis_gates is not None: + evolution_circuit = self._transpile_to_basis_gates(evolution_circuit, basis_gates) + + measurements = [] + for observable in observables: + measurements.append( + self._measure_observable( + circuit=evolution_circuit, + shots=shots, + observable=observable, + circuit_executor=circuit_executor, + noise=noise, + ) + ) + return measurements + + def name(self) -> str: + """Return the algorithm name used for registry.""" + return "evolve_and_measure" diff --git a/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py b/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py index 14724584b..73116c38a 100644 --- a/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py +++ b/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py @@ -125,6 +125,37 @@ def reorder_terms(self, permutation: list[int]) -> "PauliProductFormulaContainer num_qubits=self._num_qubits, ) + def combine(self, other_container: "PauliProductFormulaContainer", atol=1e-12) -> "PauliProductFormulaContainer": + """Combine two Trotter evolutions, merging commuting terms. + + Args: + self_container: The first ``PauliProductFormulaContainer``. + other_container: The second ``PauliProductFormulaContainer`` appended + after *self_container*. + atol: Absolute tolerance for filtering small coefficients. + + Returns: + A single ``PauliProductFormulaContainer`` representing the combined + evolution. + + """ + self_terms = list(self.step_terms) * self.step_reps + other_terms = list(other_container.step_terms) * other_container.step_reps + num_qubits = max(self.num_qubits, other_container.num_qubits) + + combined = self_terms + other_terms + merged: list[ExponentiatedPauliTerm] = [] + for term in combined: + if merged and merged[-1].pauli_term == term.pauli_term: + new_angle = merged[-1].angle + term.angle + if abs(new_angle) > atol: + merged[-1] = ExponentiatedPauliTerm(pauli_term=term.pauli_term, angle=new_angle) + else: + merged.pop() + else: + merged.append(term) + return PauliProductFormulaContainer(step_terms=merged, step_reps=1, num_qubits=num_qubits) + def to_json(self) -> dict[str, Any]: """Convert the PauliProductFormulaContainer to a dictionary for JSON serialization. diff --git a/python/src/qdk_chemistry/utils/pauli_commutation.py b/python/src/qdk_chemistry/utils/pauli_commutation.py index 6d9fa0565..345135a44 100644 --- a/python/src/qdk_chemistry/utils/pauli_commutation.py +++ b/python/src/qdk_chemistry/utils/pauli_commutation.py @@ -50,16 +50,24 @@ def _label_to_sparse_word(label: str) -> list[tuple[int, int]]: - # """Convert a Pauli string label to a ``SparsePauliWord``.""" - return [(i, 1 if c == "X" else 2 if c == "Y" else 3) for i, c in enumerate(label) if c != "I"] + # Convert a Pauli string label to a ``SparsePauliWord``. + word = [] + # q_n...q_0 + for i, c in enumerate(reversed(label)): + if c in {"X", "Y", "Z"}: + word.append((i, 1 if c == "X" else 2 if c == "Y" else 3)) + elif c != "I": + raise ValueError(f"Invalid character {c!r} in Pauli label; expected 'I', 'X', 'Y', or 'Z'.") + return word def _sparse_word_to_label(word: list[tuple[int, int]], n_qubits: int) -> str: - # """Convert a ``SparsePauliWord`` back to a Pauli string label.""" + # Convert a ``SparsePauliWord`` back to a Pauli string label. chars = ["I"] * n_qubits for q, p in word: chars[q] = "X" if p == 1 else "Y" if p == 2 else "Z" - return "".join(chars) + # q_n...q_0 + return "".join(reversed(chars)) def do_pauli_labels_commute(label_a: str, label_b: str) -> bool: @@ -222,6 +230,10 @@ def does_nested_commutator_vanish(*labels: str) -> bool: if len(labels) < 2: raise ValueError("At least two Pauli labels are required for a commutator.") + pauli_string_length = len(labels[0]) + if any(len(lbl) != pauli_string_length for lbl in labels): + raise ValueError("All Pauli labels must have the same length.") + # Base case: [P_a, P_b] vanishes iff the two strings commute. if len(labels) == 2: return do_pauli_labels_commute(labels[0], labels[1]) @@ -237,7 +249,7 @@ def does_nested_commutator_vanish(*labels: str) -> bool: word = _label_to_sparse_word(labels[1]) for lbl in labels[2:]: _, word = PauliTermAccumulator.multiply_uncached(word, _label_to_sparse_word(lbl)) - inner_product = _sparse_word_to_label(word, len(labels[0])) + inner_product = _sparse_word_to_label(word, pauli_string_length) return do_pauli_labels_commute(labels[0], inner_product) @@ -291,14 +303,14 @@ def commutator_bound_second_order( hamiltonian: QubitHamiltonian, weight_threshold: float = 1e-12, ) -> float: - r"""Compute the commutator bound term multiplying :math:`t^{3} / (12N)` in Proposition 10 in Childs et. al (2021). + r"""Compute the commutator bound term multiplying :math:`t^{3} / 12` in Proposition 10 in Childs et. al (2021). Args: hamiltonian: The qubit Hamiltonian for which to compute the bound. weight_threshold: Absolute threshold for filtering small Hamiltonian coefficients. Returns: - The commutator bound term multiplying :math:`t^{3} / (12N)`. + The commutator bound term multiplying :math:`t^{3} / 12`. """ real_terms = hamiltonian.get_real_coefficients(tolerance=weight_threshold) diff --git a/python/src/qdk_chemistry/utils/qsharp/PauliExp.qs b/python/src/qdk_chemistry/utils/qsharp/PauliExp.qs new file mode 100644 index 000000000..ae968f19f --- /dev/null +++ b/python/src/qdk_chemistry/utils/qsharp/PauliExp.qs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for +// license information. + +namespace QDKChemistry.Utils.PauliExp { + + import Std.Arrays.Subarray; + + /// Performs Time Evolution for a set of Pauli exponentials. + /// # Parameters + /// - `pauliExponents`: An array of arrays of Pauli operators representing the Pauli terms. + /// - `pauliCoefficients`: An array of doubles representing the coefficients for each Pauli term. + /// - `systems`: An array of qubits representing the system on which the operation acts. + /// # Returns + /// - `Unit`: The operation prepares the time evolution on the allocated qubits. + operation PauliExp( + pauliExponents : Pauli[][], + pauliCoefficients : Double[], + systems : Qubit[] + ) : Unit { + for idx in 0..Length(pauliExponents) - 1 { + let paulis = pauliExponents[idx]; + let coeff = pauliCoefficients[idx]; + Exp(paulis, -coeff, systems); + } + } + + + /// Performs repeated Time Evolution for a set of Pauli exponentials. + /// # Parameters + /// - `pauliExponents`: An array of arrays of Pauli operators representing the Pauli terms. + /// - `pauliCoefficients`: An array of doubles representing the coefficients for each Pauli term. + /// - `repetitions`: The number of times to repeat the evolution. + struct RepPauliExpParams { + pauliExponents : Pauli[][], + pauliCoefficients : Double[], + repetitions : Int, + } + + /// Performs repeated Time Evolution for a set of Pauli exponentials. + /// # Parameters + /// - `params`: A `RepPauliExpParams` struct containing the parameters for the operation. + /// - `systems`: An array of qubits representing the system on which the operation acts. + /// # Returns + /// - `Unit`: The operation prepares the repeated time evolution on the allocated qubits. + operation RepPauliExp( + params : RepPauliExpParams, + systems : Qubit[], + ) : Unit { + for i in 1..params.repetitions { + PauliExp(params.pauliExponents, params.pauliCoefficients, systems); + } + } + + /// A helper operation to create a circuit for repeated Time Evolution for a set of Pauli exponentials. + /// # Parameters + /// - `params`: A `RepPauliExpParams` struct containing the parameters for the operation. + /// - `system`: An array of integers representing the indices of the system qubits. + /// # Returns + /// - `Unit`: The operation prepares the repeated time evolution on the allocated qubits. + operation MakeRepPauliExpCircuit( + params : RepPauliExpParams, + system : Int[], + ) : Unit { + use qs = Qubit[Length(system)]; + RepPauliExp(params, Subarray(system, qs)); + } + + /// A helper function to create a callable for repeated Time Evolution for a set of Pauli exponentials. + /// # Parameters + /// - `params`: A `RepPauliExpParams` struct containing the parameters for the operation. + /// # Returns + /// - `Qubit[] => Unit`: A callable that takes an array of system qubits, and prepares the repeated time evolution on the allocated qubits. + function MakeRepPauliExpOp(params : RepPauliExpParams) : Qubit[] => Unit { + RepPauliExp(params, _) + } +} diff --git a/python/src/qdk_chemistry/utils/qsharp/__init__.py b/python/src/qdk_chemistry/utils/qsharp/__init__.py index c63b87b47..5968dcbf6 100644 --- a/python/src/qdk_chemistry/utils/qsharp/__init__.py +++ b/python/src/qdk_chemistry/utils/qsharp/__init__.py @@ -13,8 +13,10 @@ _QS_FILES = [ Path(__file__).parent / "StatePreparation.qs", + Path(__file__).parent / "CircuitComposition.qs", Path(__file__).parent / "IterativePhaseEstimation.qs", Path(__file__).parent / "ControlledPauliExp.qs", + Path(__file__).parent / "PauliExp.qs", ] diff --git a/python/tests/test_evolve_and_measure.py b/python/tests/test_evolve_and_measure.py new file mode 100644 index 000000000..23f3791f9 --- /dev/null +++ b/python/tests/test_evolve_and_measure.py @@ -0,0 +1,121 @@ +"""Tests for EvolveAndMeasure state-preparation composition.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np +import pytest + +import qdk_chemistry.algorithms.time_evolution.measure_simulation.base as measure_base +from qdk_chemistry.algorithms import create +from qdk_chemistry.algorithms.time_evolution.builder.trotter import Trotter +from qdk_chemistry.algorithms.time_evolution.circuit_mapper import PauliSequenceMapper +from qdk_chemistry.algorithms.time_evolution.measure_simulation import EvolveAndMeasure +from qdk_chemistry.data import Circuit, QubitHamiltonian + + +def test_prepend_state_prep_circuit_composes_qsharp_operations(monkeypatch: pytest.MonkeyPatch) -> None: + """The helper should compose state preparation and evolution through Q# operations.""" + algo = EvolveAndMeasure() + state_prep_op = object() + evolution_op = object() + + monkeypatch.setattr( + measure_base, + "QSHARP_UTILS", + SimpleNamespace( + CircuitComposition=SimpleNamespace( + MakeSequentialCircuit="sequential-circuit", + MakeSequentialOp=lambda first, second: ("sequential-op", first, second), + ) + ), + ) + monkeypatch.setattr( + measure_base.qsharp, + "circuit", + lambda *args: ("qsharp-circuit", args), + ) + monkeypatch.setattr( + measure_base.qsharp, + "compile", + lambda *args: ("qir", args), + ) + + state_prep = Circuit( + qir='attributes #0 = { "required_num_qubits"="1" }', + qsharp_op=state_prep_op, + encoding="jordan-wigner", + ) + evolution = Circuit( + qir='attributes #0 = { "required_num_qubits"="1" }', + qsharp_op=evolution_op, + encoding="jordan-wigner", + ) + + combined = algo._prepend_state_prep_circuit(state_prep, evolution) + + assert combined.qsharp == ( + "qsharp-circuit", + ("sequential-circuit", state_prep_op, evolution_op, [0]), + ) + assert combined.qir == ( + "qir", + ("sequential-circuit", state_prep_op, evolution_op, [0]), + ) + assert combined._qsharp_op == ("sequential-op", state_prep_op, evolution_op) + assert combined.encoding == "jordan-wigner" + + +def test_prepend_state_prep_circuit_rejects_mismatched_qubit_counts() -> None: + """The helper should reject incompatible circuit widths before composing.""" + algo = EvolveAndMeasure() + state_prep = Circuit(qir='attributes #0 = { "required_num_qubits"="1" }', qsharp_op=lambda _: None) + evolution = Circuit(qir='attributes #0 = { "required_num_qubits"="2" }', qsharp_op=lambda _: None) + + with pytest.raises(ValueError, match="same number of qubits"): + algo._prepend_state_prep_circuit(state_prep, evolution) + + +def test_prepend_state_prep_circuit_requires_qsharp_operations() -> None: + """The helper should fail fast when either circuit lacks a Q# operation handle.""" + algo = EvolveAndMeasure() + state_prep = Circuit(qasm="OPENQASM 3.0;\nqubit[1] q;\nh q[0];\n") + evolution = Circuit(qasm="OPENQASM 3.0;\nqubit[1] q;\nx q[0];\n") + + with pytest.raises(RuntimeError, match="requires Q# operations"): + algo._prepend_state_prep_circuit(state_prep, evolution) + + +def test_evolve_and_measure_eigenvalue_remains_constant() -> None: + """Run an example EvolveAndMeasure workflow.""" + hamiltonian_p = QubitHamiltonian(["ZZ"], np.array([1.0])) + hamiltonian_m = QubitHamiltonian(["ZZ"], np.array([-1.0])) + + steps = 100 + hamiltonians = [hamiltonian_p, hamiltonian_m] * (steps // 2) + time_steps = [float(t + 1) for t in range(steps)] + observable = QubitHamiltonian(["ZZ"], np.array([1.0])) + + evolution_builder = Trotter(num_divisions=1, order=1, optimize_term_ordering=True) + algo = EvolveAndMeasure() + mapper = PauliSequenceMapper() + circuit_executor = create("energy_estimator", "qiskit_aer_simulator") + + measurements = algo.run( + hamiltonians, + times=time_steps, + observables=[observable], + evolution_builder=evolution_builder, + circuit_mapper=mapper, + circuit_executor=circuit_executor, + shots=1024, + ) + + for measurement in measurements: + assert measurement[0].energy_expectation_value == pytest.approx(1.0, abs=0.2) diff --git a/python/tests/test_time_evolution_circuit_mapper_noncontrolled.py b/python/tests/test_time_evolution_circuit_mapper_noncontrolled.py new file mode 100644 index 000000000..3f135dfcd --- /dev/null +++ b/python/tests/test_time_evolution_circuit_mapper_noncontrolled.py @@ -0,0 +1,57 @@ +"""Tests for the non-controlled PauliSequenceMapper in QDK/Chemistry.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json + +import pytest +import qsharp + +from qdk_chemistry.algorithms.time_evolution.circuit_mapper.pauli_sequence_mapper import PauliSequenceMapper +from qdk_chemistry.data.circuit import Circuit +from qdk_chemistry.data.time_evolution.base import TimeEvolutionUnitary +from qdk_chemistry.data.time_evolution.containers.pauli_product_formula import ( + ExponentiatedPauliTerm, + PauliProductFormulaContainer, +) + + +@pytest.fixture +def simple_unitary() -> TimeEvolutionUnitary: + """Create a simple TimeEvolutionUnitary for testing.""" + container = PauliProductFormulaContainer( + step_terms=[ + ExponentiatedPauliTerm(pauli_term={0: "X"}, angle=0.5), + ExponentiatedPauliTerm(pauli_term={1: "Z"}, angle=0.25), + ], + step_reps=2, + num_qubits=2, + ) + return TimeEvolutionUnitary(container=container) + + +class TestPauliSequenceMapperNonControlled: + """Tests for the non-controlled PauliSequenceMapper class.""" + + def test_name_and_type_name(self): + """Test mapper identity methods.""" + mapper = PauliSequenceMapper() + + assert mapper.name() == "pauli_sequence" + assert mapper.type_name() == "evolution_circuit_mapper" + + def test_run_builds_regular_unitary_circuit(self, simple_unitary): + """Test run() builds a regular (non-controlled) unitary circuit.""" + mapper = PauliSequenceMapper() + + circuit = mapper.run(simple_unitary) + + assert isinstance(circuit, Circuit) + assert isinstance(circuit.get_qsharp_circuit(), qsharp._native.Circuit) + + qsc_json = json.loads(circuit.get_qsharp_circuit().json()) + num_qubits = len(qsc_json["qubits"]) + assert num_qubits == 2 diff --git a/python/tests/test_time_evolution_trotter.py b/python/tests/test_time_evolution_trotter.py index 7b744962d..82ddd92a9 100644 --- a/python/tests/test_time_evolution_trotter.py +++ b/python/tests/test_time_evolution_trotter.py @@ -15,6 +15,11 @@ ExponentiatedPauliTerm, PauliProductFormulaContainer, ) +from qdk_chemistry.utils.pauli_commutation import ( + commutator_bound_first_order, + commutator_bound_higher_order, + commutator_bound_second_order, +) from .reference_tolerances import float_comparison_absolute_tolerance, float_comparison_relative_tolerance @@ -90,6 +95,32 @@ def test_multiple_trotter_steps(self): rtol=float_comparison_relative_tolerance, ) + def test_single_step_merge_identical_terms(self): + """Test construction of time evolution unitary with a single Trotter step.""" + pauli_strings = ["XII", "IXI", "XII"] + coefficients = [1.0, 1.0, 1.0] + + # Scramble the order of the terms to ensure grouping works + perm = np.random.default_rng().permutation(len(pauli_strings)) + pauli_strings = [pauli_strings[i] for i in perm] + coefficients = [coefficients[i] for i in perm] + + hamiltonian = QubitHamiltonian(pauli_strings=pauli_strings, coefficients=coefficients) + builder = Trotter(num_divisions=1, optimize_term_ordering=True) + unitary = builder.run(hamiltonian, time=1) + + assert isinstance(unitary, TimeEvolutionUnitary) + container = unitary.get_container() + + assert isinstance(container, PauliProductFormulaContainer) + assert container.num_qubits == 3 + assert container.step_reps == 1 + # After merging identical Pauli strings, 2 unique terms remain (XII with + # coeff=2 and IXI with coeff=1). Because optimize_term_ordering groups + # them into one commuting set, the Clifford sandwich adds basis-change + # rotations (C and C†) around the diagonal terms. + assert len(container.step_terms) == 6 + def test_basic_decomposition(self): """Test basic decomposition of a qubit Hamiltonian.""" builder = Trotter() @@ -128,10 +159,10 @@ def test_rejects_non_hermitian(self): def test_not_implemented_order(self): """Test that unsupported Trotter orders raise NotImplementedError.""" - builder = Trotter(order=2) + builder = Trotter(order=3) hamiltonian = QubitHamiltonian(pauli_strings=["X"], coefficients=[1.0]) - with pytest.raises(NotImplementedError, match="Only first-order Trotter decomposition is currently supported."): + with pytest.raises(NotImplementedError, match="Non-positive and higher odd orders are not supported."): builder.run(hamiltonian, time=1.0) def test_trotter_x_z_example(self): @@ -168,7 +199,365 @@ def _pauli_matrix(label: str) -> np.ndarray: assert container.step_terms[0].angle == t # angle for X term assert container.step_terms[1].angle == t # angle for Z term # Compare: the error should scale as O(t^2) for first-order Trotter - assert np.allclose(u_trot, u_exact, atol=t**2) + commutator_error_bound = t**2 / 2 * commutator_bound_first_order(hamiltonian) + naive_error_bound = 2 * t**2 * sum(abs(coeff) for _, coeff in hamiltonian.get_real_coefficients()) ** 2 + error_actual = np.linalg.norm(u_trot - u_exact, ord=2) + assert error_actual <= commutator_error_bound + assert error_actual <= naive_error_bound + + # Second-order Trotter tests. + def test_single_step_construction_second_order(self): + """Test construction of time evolution unitary with a single Trotter step.""" + hamiltonian = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 0.5]) + builder = Trotter(num_divisions=1, order=2) + unitary = builder.run(hamiltonian, time=0.2) + + assert isinstance(unitary, TimeEvolutionUnitary) + container = unitary.get_container() + + assert isinstance(container, PauliProductFormulaContainer) + assert container.num_qubits == 1 + assert container.step_reps == 1 + assert len(container.step_terms) == 3 + + def test_multiple_trotter_steps_second_order(self): + """Test construction of time evolution unitary with multiple Trotter steps.""" + hamiltonian = QubitHamiltonian( + pauli_strings=["XI", "ZZ"], + coefficients=[2.0, 3.0], + ) + + builder = Trotter(num_divisions=4, order=2) + unitary = builder.run(hamiltonian, time=0.2) + + container = unitary.get_container() + + # dt = 0.2 / 4 = 0.05 + assert container.step_reps == 4 + assert np.isclose( + container.step_terms[0].angle, + 0.05, # angle = dt/2 * coeff = 0.025 * 2.0 + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[1].angle, + 0.15, # angle = dt * coeff = 0.05 * 3.0 + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[2].angle, + 0.05, # angle = dt/2 * coeff = 0.025 * 2.0 + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + + def test_basic_decomposition_second_order(self): + """Test basic decomposition of a qubit Hamiltonian.""" + builder = Trotter(order=2) + hamiltonian = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[3.0, 0.5]) + + terms = builder._decompose_trotter_step(hamiltonian, time=2.0) + + assert len(terms) == 3 + + assert terms[0] == ExponentiatedPauliTerm(pauli_term={0: "X"}, angle=3.0) + assert terms[1] == ExponentiatedPauliTerm(pauli_term={0: "Z"}, angle=1.0) + assert terms[2] == ExponentiatedPauliTerm(pauli_term={0: "X"}, angle=3.0) + + def test_filters_small_coefficients_second_order(self): + """Test that terms with small coefficients are filtered out.""" + builder = Trotter(order=2) + hamiltonian = QubitHamiltonian( + pauli_strings=["X", "Z"], + coefficients=[1e-15, 1.0], + ) + + terms = builder._decompose_trotter_step(hamiltonian, time=1.0, atol=1e-12) + + assert len(terms) == 1 + assert terms[0].pauli_term == {0: "Z"} + + def test_trotter_x_z_example_second_order(self): + """Correctness check for second-order Trotter decomposition.""" + # Hamiltonian H = X + Z + hamiltonian = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) + + builder = Trotter(num_divisions=1, order=2) + t = 0.1 + unitary = builder.run(hamiltonian, time=t) + container = unitary.get_container() + + def _pauli_matrix(label: str) -> np.ndarray: + """Helper to get Pauli matrix from label.""" + if label == "X": + return np.array([[0, 1], [1, 0]], dtype=complex) + if label == "Z": + return np.array([[1, 0], [0, -1]], dtype=complex) + raise ValueError(f"Unsupported Pauli label: {label}") + + # Build Trotter unitary matrix + u_trot = np.eye(2, dtype=complex) + for term in container.step_terms: + pauli_label = next(iter(term.pauli_term.values())) + pauli_matrix = _pauli_matrix(pauli_label) + u_trot = scipy.linalg.expm(-1j * term.angle * pauli_matrix) @ u_trot + + # Exact unitary + hamiltonian_matrix = _pauli_matrix("X") + _pauli_matrix("Z") + u_exact = scipy.linalg.expm(-1j * t * hamiltonian_matrix) + + assert container.step_terms[0].pauli_term == {0: "X"} + assert container.step_terms[1].pauli_term == {0: "Z"} + assert container.step_terms[2].pauli_term == {0: "X"} + assert container.step_terms[0].angle == t / 2 # angle for X term + assert container.step_terms[1].angle == t # angle for Z term + assert container.step_terms[2].angle == t / 2 # angle for X term + # Compare: the error should scale as O(t^3) for second-order Trotter + commutator_error_bound = t**3 / 12 * commutator_bound_second_order(hamiltonian) + naive_error_bound = 2**2 * t**3 * sum(abs(coeff) for _, coeff in hamiltonian.get_real_coefficients()) ** 3 + error_actual = np.linalg.norm(u_trot - u_exact, ord=2) + assert error_actual <= commutator_error_bound + assert error_actual <= naive_error_bound + + # Higher-order Trotter tests. + def test_single_step_construction_higher_order(self): + """Test construction of time evolution unitary with a single Trotter step.""" + hamiltonian = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 0.5]) + builder = Trotter(num_divisions=1, order=4) + unitary = builder.run(hamiltonian, time=0.2) + + assert isinstance(unitary, TimeEvolutionUnitary) + container = unitary.get_container() + + assert isinstance(container, PauliProductFormulaContainer) + assert container.num_qubits == 1 + assert container.step_reps == 1 + assert len(container.step_terms) == 11 + + def test_multiple_trotter_steps_fourth_order(self): + """Test construction of time evolution unitary with multiple Trotter steps.""" + hamiltonian = QubitHamiltonian( + pauli_strings=["XI", "ZZ"], + coefficients=[1.0, 1.0], + ) + + builder = Trotter(num_divisions=4, order=4) + unitary = builder.run(hamiltonian, time=0.2) + + container = unitary.get_container() + + assert container.step_reps == 4 + assert len(container.step_terms) == 11 + dt = 0.2 / container.step_reps + u_k = 1 / (4 - 4 ** (1 / 3)) + + # See Childs et. al. 2021 + assert np.isclose( + container.step_terms[0].angle, + u_k / 2 * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[1].angle, + u_k * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[2].angle, + u_k * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[3].angle, + u_k * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + + assert np.isclose( + container.step_terms[4].angle, + (1 - 3 * u_k) / 2 * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[5].angle, + (1 - 4 * u_k) * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[6].angle, + (1 - 3 * u_k) / 2 * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[7].angle, + u_k * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + + assert np.isclose( + container.step_terms[8].angle, + u_k * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[9].angle, + u_k * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[10].angle, + u_k / 2 * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + + def test_multiple_trotter_steps_sixth_order(self): + """Test construction of time evolution unitary with multiple Trotter steps.""" + hamiltonian = QubitHamiltonian( + pauli_strings=["XI", "ZZ"], + coefficients=[1.0, 1.0], + ) + + builder = Trotter(num_divisions=4, order=6) + unitary = builder.run(hamiltonian, time=0.2) + + container = unitary.get_container() + + assert container.step_reps == 4 + assert len(container.step_terms) == 51 + + dt = 0.2 / container.step_reps + + angles = [ + 0.07731617143363592, + 0.15463234286727184, + 0.15463234286727184, + 0.15463234286727184, + -0.04541560043427140, + -0.24546354373581464, + -0.04541560043427140, + 0.15463234286727184, + 0.15463234286727184, + 0.15463234286727184, + 0.15463234286727184, + 0.15463234286727184, + 0.15463234286727184, + 0.15463234286727184, + -0.04541560043427140, + -0.24546354373581464, + -0.04541560043427140, + 0.15463234286727184, + 0.15463234286727184, + 0.15463234286727184, + -0.024703128403719937, + -0.20403859967471172, + -0.20403859967471172, + -0.20403859967471172, + 0.059926244045521965, + 0.32389108776575565, + 0.059926244045521965, + -0.20403859967471172, + -0.20403859967471172, + -0.20403859967471172, + -0.024703128403719937, + 0.15463234286727184, + 0.15463234286727184, + 0.15463234286727184, + -0.04541560043427140, + -0.24546354373581464, + -0.04541560043427140, + 0.15463234286727184, + 0.15463234286727184, + 0.15463234286727184, + 0.15463234286727184, + 0.15463234286727184, + 0.15463234286727184, + 0.15463234286727184, + -0.04541560043427140, + -0.24546354373581464, + -0.04541560043427140, + 0.15463234286727184, + 0.15463234286727184, + 0.15463234286727184, + 0.07731617143363592, + ] + + # See Childs et. al. 2021 + assert len(angles) == len(container.step_terms) + for term, expected_angle in zip(container.step_terms, angles, strict=False): + assert np.isclose( + term.angle, + expected_angle * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + + def test_filters_small_coefficients_higher_order(self): + """Test that terms with small coefficients are filtered out.""" + builder = Trotter(order=4) + hamiltonian = QubitHamiltonian( + pauli_strings=["X", "Z"], + coefficients=[1e-15, 1.0], + ) + + terms = builder._decompose_trotter_step(hamiltonian, time=1.0, atol=1e-12) + + assert len(terms) == 1 + assert terms[0].pauli_term == {0: "Z"} + + def test_trotter_x_z_example_higher_order(self): + """Correctness check for fourth-order Trotter decomposition.""" + # Hamiltonian H = X + Z + hamiltonian_coefficient = 0.5 + hamiltonian = QubitHamiltonian( + pauli_strings=["X", "Z"], coefficients=[hamiltonian_coefficient, hamiltonian_coefficient] + ) + + builder = Trotter(num_divisions=1, order=4) + t = 0.1 + unitary = builder.run(hamiltonian, time=t) + container = unitary.get_container() + + def _pauli_matrix(label: str) -> np.ndarray: + """Helper to get Pauli matrix from label.""" + if label == "X": + return np.array([[0, 1], [1, 0]], dtype=complex) + if label == "Z": + return np.array([[1, 0], [0, -1]], dtype=complex) + raise ValueError(f"Unsupported Pauli label: {label}") + + # Build Trotter unitary matrix + u_trot = np.eye(2, dtype=complex) + for term in container.step_terms: + pauli_label = next(iter(term.pauli_term.values())) + pauli_matrix = _pauli_matrix(pauli_label) + u_trot = scipy.linalg.expm(-1j * term.angle * pauli_matrix) @ u_trot + + # Exact unitary + hamiltonian_matrix = np.zeros((2, 2), dtype=complex) + for pauli_string, coeff in zip(hamiltonian.pauli_strings, hamiltonian.coefficients, strict=False): + hamiltonian_matrix += coeff * _pauli_matrix(pauli_string) + u_exact = scipy.linalg.expm(-1j * t * hamiltonian_matrix) + + comm_bound = commutator_bound_higher_order(hamiltonian=hamiltonian, order=4) + commutator_error_bound = t**5 * comm_bound + naive_error_bound = t**5 * 2**4 * sum(abs(coeff) for _, coeff in hamiltonian.get_real_coefficients()) ** 5 + error_actual = np.linalg.norm(u_trot - u_exact, ord=2) + assert error_actual <= commutator_error_bound + assert error_actual <= naive_error_bound + assert commutator_error_bound <= naive_error_bound class TestTrotterAccuracyAware: @@ -191,8 +580,8 @@ def test_target_accuracy_naive_bound(self): builder = Trotter(target_accuracy=0.01, error_bound="naive") unitary = builder.run(hamiltonian, time=1.0) container = unitary.get_container() - # one_norm = 2, N = ceil(4 / 0.01) = 400 - assert container.step_reps == 400 + # one_norm = 2, N = ceil(2 * 4 / 0.01) = 800 + assert container.step_reps == 800 def test_commutator_tighter_than_naive(self): """Test that commutator bound gives fewer steps than naive bound.""" @@ -268,3 +657,310 @@ def test_invalid_error_bound_raises(self): """Test that an invalid error_bound raises an exception via Settings constraint.""" with pytest.raises(ValueError, match="allowed options"): Trotter(error_bound="invalid") + + # Second-order Trotter tests. + + def test_target_accuracy_commutator_bound_second_order(self): + """Test that target_accuracy with commutator bound computes correct step count.""" + # H = X + Z, X and Z anticommute -> commutator bound = 6 + # For t = 1 and eps = 0.01, N = ceil(sqrt(6 / 12) / sqrt(eps)) = 8. + hamiltonian = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) + builder = Trotter(target_accuracy=0.01, order=2) + unitary = builder.run(hamiltonian, time=1.0) + container = unitary.get_container() + assert container.step_reps == 8 + + def test_target_accuracy_naive_bound_second_order(self): + """Test that target_accuracy with naive bound computes correct step count.""" + hamiltonian = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) + builder = Trotter(target_accuracy=0.01, error_bound="naive", order=2) + unitary = builder.run(hamiltonian, time=1.0) + container = unitary.get_container() + # one_norm = 2, naive second-order bound for eps=0.01 and t=1.0 gives N = 57 + assert container.step_reps == 57 + + def test_commutator_tighter_than_naive_second_order(self): + """Test that commutator bound gives fewer steps than naive bound.""" + hamiltonian = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) + eps = 0.01 + time = 1.0 + builder_comm = Trotter(target_accuracy=eps, error_bound="commutator", order=2) + builder_naive = Trotter(target_accuracy=eps, error_bound="naive", order=2) + n_comm = builder_comm.run(hamiltonian, time=time).get_container().step_reps + n_naive = builder_naive.run(hamiltonian, time=time).get_container().step_reps + assert n_comm <= n_naive + + def test_commuting_hamiltonian_needs_one_step_second_order(self): + """Test that a fully commuting Hamiltonian needs only 1 Trotter step.""" + # XI and IX commute -> commutator bound = 0 -> N = 1 + hamiltonian = QubitHamiltonian(pauli_strings=["XI", "IX"], coefficients=[1.0, 1.0]) + builder = Trotter(target_accuracy=0.01, order=2) + unitary = builder.run(hamiltonian, time=1.0) + container = unitary.get_container() + assert container.step_reps == 1 + + def test_manual_steps_as_lower_bound_second_order(self): + """Test that manual num_divisions acts as a lower bound.""" + hamiltonian = QubitHamiltonian(pauli_strings=["XI", "IX"], coefficients=[1.0, 1.0]) + builder = Trotter(num_divisions=10, target_accuracy=0.01, order=2) + unitary = builder.run(hamiltonian, time=1.0) + container = unitary.get_container() + # Commutator bound gives 1, but manual gives 10 -> max(1, 10) = 10 + assert container.step_reps == 10 + + def test_no_target_accuracy_backward_compatible_second_order(self): + """Test that the builder is backward compatible when target_accuracy is disabled (0.0).""" + hamiltonian = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) + builder = Trotter(num_divisions=3, order=2) + unitary = builder.run(hamiltonian, time=0.5) + container = unitary.get_container() + assert container.step_reps == 3 + + def test_angle_scaling_with_auto_steps_second_order(self): + """Test that angles are correctly scaled when steps are auto-computed.""" + hamiltonian = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[3.0, 1.0]) + builder = Trotter(target_accuracy=0.01, order=2) + t = 1.0 + unitary = builder.run(hamiltonian, time=t) + container = unitary.get_container() + n = container.step_reps + dt = t / n + # First term angle = coeff * dt/2 = 1.5 * dt + assert np.isclose( + container.step_terms[0].angle, + 1.5 * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + # Second term angle = coeff * dt = 1.0 * dt + assert np.isclose( + container.step_terms[1].angle, + 1.0 * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + + # Third term angle = coeff * dt/2 = 1.5 * dt + assert np.isclose( + container.step_terms[2].angle, + 1.5 * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + + def test_zero_target_accuracy_means_disabled_second_order(self): + """Test that target_accuracy=0.0 (default) disables auto step computation.""" + hamiltonian = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) + builder = Trotter(target_accuracy=0.0, num_divisions=3, order=2) + unitary = builder.run(hamiltonian, time=1.0) + container = unitary.get_container() + # target_accuracy=0.0 means disabled, so num_divisions=3 is used directly + assert container.step_reps == 3 + + # Higher-order Trotter tests. + def test_target_accuracy_commutator_bound_higher_order(self): + """Test that target_accuracy with commutator bound computes correct step count.""" + hamiltonian = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) + builder = Trotter(target_accuracy=0.01, order=6) + unitary = builder.run(hamiltonian, time=1.0) + container = unitary.get_container() + assert container.step_reps == 7 + + def test_commutator_tighter_than_naive_higher_order(self): + """Test that commutator bound gives fewer steps than naive bound.""" + hamiltonian = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) + eps = 0.01 + time = 1.0 + builder_comm = Trotter(target_accuracy=eps, error_bound="commutator", order=4) + builder_naive = Trotter(target_accuracy=eps, error_bound="naive", order=4) + n_comm = builder_comm.run(hamiltonian, time=time).get_container().step_reps + n_naive = builder_naive.run(hamiltonian, time=time).get_container().step_reps + assert n_comm <= n_naive + + def test_commuting_hamiltonian_needs_one_step_higher_order(self): + """Test that a fully commuting Hamiltonian needs only 1 Trotter step.""" + # XI and IX commute -> commutator bound = 0 -> N = 1 + hamiltonian = QubitHamiltonian(pauli_strings=["XI", "IX"], coefficients=[1.0, 1.0]) + builder = Trotter(target_accuracy=0.01, order=6) + unitary = builder.run(hamiltonian, time=1.0) + container = unitary.get_container() + assert container.step_reps == 1 + + def test_angle_scaling_with_auto_steps_higher_order(self): + """Test construction of time evolution unitary with multiple Trotter steps.""" + hamiltonian = QubitHamiltonian( + pauli_strings=["XI", "ZZ"], + coefficients=[1.0, 1.0], + ) + + builder = Trotter(target_accuracy=0.01, order=4) + + t = 1.0 + unitary = builder.run(hamiltonian, time=t) + container = unitary.get_container() + + n = container.step_reps + dt = t / n + + u_k = 1 / (4 - 4 ** (1 / 3)) + + # See Childs et. al. 2021 + assert np.isclose( + container.step_terms[0].angle, + u_k / 2 * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[1].angle, + u_k * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[2].angle, + u_k * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[3].angle, + u_k * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + + assert np.isclose( + container.step_terms[4].angle, + (1 - 3 * u_k) / 2 * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[5].angle, + (1 - 4 * u_k) * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[6].angle, + (1 - 3 * u_k) / 2 * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[7].angle, + u_k * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + + assert np.isclose( + container.step_terms[8].angle, + u_k * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[9].angle, + u_k * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + assert np.isclose( + container.step_terms[10].angle, + u_k / 2 * dt, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) + + +class TestOptimizeTermOrdering: + """Tests for the optimize_term_ordering method.""" + + def test_optimize_term_ordering_does_nothing_when_false(self): + """Test that optimize_term_ordering does nothing when optimize_term_ordering is False.""" + pauli_strings = ["ZZII", "XIII", "IZZI", "IXII", "IIZZ", "IIXI", "ZIIZ", "IIIX"] + coefficients = [1.0] * 8 + hamiltonian = QubitHamiltonian( + pauli_strings=pauli_strings, + coefficients=coefficients, + ) + builder = Trotter(num_divisions=1, order=1, optimize_term_ordering=False) + t = 1.0 + terms = builder.run(hamiltonian, time=t).get_container().step_terms + + for term in terms: + assert term.pauli_term == builder._pauli_label_to_map(pauli_strings[terms.index(term)]) + assert term.angle == coefficients[terms.index(term)] * t + + def test_optimize_term_ordering_groups_when_true(self): + """Test that optimize_term_ordering groups commuting terms into parallelizable layers.""" + pauli_strings = ["ZZII", "XIII", "IZZI", "IXII", "IIZZ", "IIXI", "ZIIZ", "IIIX"] + coefficients = [1.0] * 8 + hamiltonian = QubitHamiltonian( + pauli_strings=pauli_strings, + coefficients=coefficients, + ) + builder = Trotter(num_divisions=1, order=1, optimize_term_ordering=True) + t = 1.0 + terms = builder.run(hamiltonian, time=t).get_container().step_terms + + # Convert terms back to label strings for grouping + def term_to_label(term): + num_qubits = hamiltonian.num_qubits + chars = ["I"] * num_qubits + for qubit_idx, pauli_op in term.pauli_term.items(): + chars[num_qubits - 1 - qubit_idx] = pauli_op + return "".join(chars) + + term_labels = [term_to_label(t) for t in terms] + + # After Clifford diagonalization, the ZZ layers produce 2 terms each + # (image_of transforms the original labels), and the X group + # {XIII, IXII, IIXI, IIIX} produces a Clifford sandwich of 12 + # single-qubit Z terms (C + diagonal + C†). + pauli_zz_layer_1 = {"ZZZZ", "IZIZ"} + pauli_zz_layer_2 = {"ZIZZ", "IZZZ"} + pauli_x_size = 12 + + assert len(terms) == pauli_x_size + len(pauli_zz_layer_1) + len(pauli_zz_layer_2) + + # Identify contiguous blocks: ZZ layers are 2-term blocks of multi-qubit + # Z strings; the X group sandwich is a 12-term block of single-qubit Z terms. + remaining = list(term_labels) + matched_groups = [] + while remaining: + first = remaining[0] + if first in pauli_zz_layer_1: + block = set(remaining[: len(pauli_zz_layer_1)]) + assert block == pauli_zz_layer_1, f"Expected {pauli_zz_layer_1}, got {block}" + matched_groups.append(("zz", block)) + remaining = remaining[len(pauli_zz_layer_1) :] + elif first in pauli_zz_layer_2: + block = set(remaining[: len(pauli_zz_layer_2)]) + assert block == pauli_zz_layer_2, f"Expected {pauli_zz_layer_2}, got {block}" + matched_groups.append(("zz", block)) + remaining = remaining[len(pauli_zz_layer_2) :] + else: + # X group Clifford sandwich: 12 contiguous single-qubit Z terms + x_block = remaining[:pauli_x_size] + assert len(x_block) == pauli_x_size, f"X block too short: {len(x_block)}" + assert all(sum(c != "I" for c in lbl) <= 1 for lbl in x_block), ( + f"X group sandwich should contain only single-qubit terms, got: {x_block}" + ) + matched_groups.append(("x", set(x_block))) + remaining = remaining[pauli_x_size:] + + assert len(matched_groups) == 3 + + # Verify all three group types are present + tags = [tag for tag, _ in matched_groups] + assert tags.count("zz") == 2 + assert tags.count("x") == 1 + + # The two ZZ layers must be adjacent (they come from the same commuting group). + zz_indices = [i for i, (tag, _) in enumerate(matched_groups) if tag == "zz"] + assert abs(zz_indices[0] - zz_indices[1]) == 1, ( + "The two ZZ layers must be adjacent, but got group ordering: " + str(tags) + ) diff --git a/python/tests/test_trotter_error.py b/python/tests/test_trotter_error.py index 795879298..f8423ec4d 100644 --- a/python/tests/test_trotter_error.py +++ b/python/tests/test_trotter_error.py @@ -21,45 +21,79 @@ class TestTrotterStepsNaive: def test_basic(self): """Test basic naive bound computation.""" - # one_norm = 2, N = ceil((2^2 * 1^2) / 0.1) = ceil(40) = 40 + # one_norm = 2, N = ceil((2^2 * 2 * 1^2) / 0.1) = ceil(80) = 80 h = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) - assert trotter_steps_naive(h, 1.0, 0.1) == 40 + assert trotter_steps_naive(h, 1.0, 0.1, order=1) == 80 def test_minimum_one(self): """Test that result is at least 1.""" # Very large epsilon should still give 1 h = QubitHamiltonian(pauli_strings=["X"], coefficients=[1.0]) - assert trotter_steps_naive(h, 0.001, 1e6) == 1 + assert trotter_steps_naive(h, 0.001, 1e6, order=1) == 1 def test_small_accuracy(self): """Test with very small accuracy.""" - # one_norm = 1, N = ceil((1^2 * 1^2) / 0.001) = ceil(1000) = 1000 + # one_norm = 1, N = ceil((1^2 * 2 * 1^2) / 0.001) = ceil(2000) = 2000 h = QubitHamiltonian(pauli_strings=["X"], coefficients=[1.0]) - assert trotter_steps_naive(h, 1.0, 0.001) == 1000 + assert trotter_steps_naive(h, 1.0, 0.001, order=1) == 2000 def test_large_time(self): """Test with large evolution time.""" - # one_norm = 1, N = ceil((1^2 * 10^2) / 1.0) = ceil(100) = 100 + # one_norm = 1, N = ceil((1^2 * 2 * 10^2) / 1.0) = ceil(200) = 200 h = QubitHamiltonian(pauli_strings=["X"], coefficients=[1.0]) - assert trotter_steps_naive(h, 10.0, 1.0) == 100 + assert trotter_steps_naive(h, 10.0, 1.0, order=1) == 200 def test_zero_accuracy_raises(self): """Test that zero accuracy raises ValueError.""" h = QubitHamiltonian(pauli_strings=["X"], coefficients=[1.0]) with pytest.raises(ValueError, match="positive"): - trotter_steps_naive(h, 1.0, 0.0) + trotter_steps_naive(h, 1.0, 0.0, order=1) def test_negative_accuracy_raises(self): """Test that negative accuracy raises ValueError.""" h = QubitHamiltonian(pauli_strings=["X"], coefficients=[1.0]) with pytest.raises(ValueError, match="positive"): - trotter_steps_naive(h, 1.0, -0.1) + trotter_steps_naive(h, 1.0, -0.1, order=1) - def test_order_2_raises(self): - """Test that order > 1 raises NotImplementedError.""" + # Second-order Trotter tests. + def test_basic_second_order(self): + """Test basic second-order naive bound computation.""" h = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) - with pytest.raises(NotImplementedError, match="order 2"): - trotter_steps_naive(h, 1.0, 0.1, order=2) + assert trotter_steps_naive(h, 1.0, 0.1, order=2) == 18 + + def test_small_accuracy_second_order(self): + """Test with very small accuracy.""" + h = QubitHamiltonian(pauli_strings=["X"], coefficients=[1.0]) + assert trotter_steps_naive(h, 1.0, 0.0001, order=2) == 200 + + def test_large_time_second_order(self): + """Test with large evolution time.""" + # one_norm = 1, N = ceil((2 * 1^1.5 * 10^1.5) / 1.0^0.5) = 64 + h = QubitHamiltonian(pauli_strings=["X"], coefficients=[1.0]) + assert trotter_steps_naive(h, 10.0, 1.0, order=2) == 64 + + # Higher-order Trotter tests. + + def test_order_3_raises(self): + """Test that odd order > 2 raises NotImplementedError.""" + h = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) + with pytest.raises(NotImplementedError, match="order 3"): + trotter_steps_naive(h, 1.0, 0.1, order=3) + + def test_basic_higher_order(self): + """Test basic higher-order naive bound computation.""" + h = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) + assert trotter_steps_naive(h, 1.0, 0.1, order=6) == 7 + + def test_small_accuracy_higher_order(self): + """Test with very small accuracy.""" + h = QubitHamiltonian(pauli_strings=["X"], coefficients=[1.0]) + assert trotter_steps_naive(h, 1.0, 0.0001, order=6) == 10 + + def test_large_time_higher_order(self): + """Test with large evolution time.""" + h = QubitHamiltonian(pauli_strings=["X"], coefficients=[1.0]) + assert trotter_steps_naive(h, 10.0, 1.0, order=6) == 30 class TestTrotterStepsCommutator: @@ -70,45 +104,105 @@ def test_anticommuting_pair(self): # X and Z anticommute: commutator bound = 2 # N = ceil(2 * 1^2 / (2 * 0.1)) = ceil(10) = 10 h = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) - assert trotter_steps_commutator(h, 1.0, 0.1) == 10 + assert trotter_steps_commutator(h, 1.0, 0.1, order=1) == 10 def test_all_commuting(self): """Test with all commuting terms.""" # XI and IX commute: commutator bound = 0, N = 1 h = QubitHamiltonian(pauli_strings=["XI", "IX"], coefficients=[1.0, 1.0]) - assert trotter_steps_commutator(h, 1.0, 0.1) == 1 + assert trotter_steps_commutator(h, 1.0, 0.1, order=1) == 1 def test_tighter_than_naive(self): """Test that commutator bound is never looser than naive.""" h = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) eps = 0.01 time = 1.0 - n_naive = trotter_steps_naive(h, time, eps) - n_comm = trotter_steps_commutator(h, time, eps) + n_naive = trotter_steps_naive(h, time, eps, order=1) + n_comm = trotter_steps_commutator(h, time, eps, order=1) assert n_comm <= n_naive def test_minimum_one(self): """Test that result is at least 1 for commuting Hamiltonian.""" h = QubitHamiltonian(pauli_strings=["XI", "IX"], coefficients=[1.0, 1.0]) - assert trotter_steps_commutator(h, 1.0, 100.0) == 1 + assert trotter_steps_commutator(h, 1.0, 100.0, order=1) == 1 def test_time_scaling(self): """Test that step count scales with t^2.""" h = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) - n1 = trotter_steps_commutator(h, 1.0, 0.1) - n2 = trotter_steps_commutator(h, 2.0, 0.1) + n1 = trotter_steps_commutator(h, 1.0, 0.1, order=1) + n2 = trotter_steps_commutator(h, 2.0, 0.1, order=1) # n2 should be approximately 4 * n1 (t^2 scaling) assert n2 == math.ceil(4 * (2.0 / 0.2)) # 40 - assert n2 >= 4 * n1 - 1 # Allow for ceiling effects + assert abs(n2 - 4 * n1) <= 1 # Allow for ceiling effects def test_zero_accuracy_raises(self): """Test that zero accuracy raises ValueError.""" h = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) with pytest.raises(ValueError, match="positive"): - trotter_steps_commutator(h, 1.0, 0.0) + trotter_steps_commutator(h, 1.0, 0.0, order=1) + + # Second-order Trotter tests. + + def test_all_commuting_second_order(self): + """Test with all commuting terms.""" + # XI and IX commute: commutator bound = 0, N = 1 + h = QubitHamiltonian(pauli_strings=["XI", "IX"], coefficients=[1.0, 1.0]) + assert trotter_steps_commutator(h, 1.0, 0.1, order=2) == 1 + + def test_tighter_than_naive_second_order(self): + """Test that commutator bound is never looser than naive.""" + h = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) + eps = 0.01 + time = 1.0 + n_naive = trotter_steps_naive(h, time, eps, order=2) + n_comm = trotter_steps_commutator(h, time, eps, order=2) + assert n_comm <= n_naive + + def test_minimum_one_second_order(self): + """Test that result is at least 1 for commuting Hamiltonian.""" + h = QubitHamiltonian(pauli_strings=["XI", "IX"], coefficients=[1.0, 1.0]) + assert trotter_steps_commutator(h, 1.0, 100.0, order=2) == 1 + + def test_time_scaling_second_order(self): + """Test that step count scales with t^(3/2).""" + h = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) + n1 = trotter_steps_commutator(h, 1.0, 0.1, order=2) + n2 = trotter_steps_commutator(h, 2.0, 0.1, order=2) + # n2 should be approximately 2**1.5 * n1 (t^1.5 scaling) + assert (n1 * math.ceil(2**1.5)) // n2 == 1 # Allow for ceiling effects + + # Higher-order Trotter tests. + + def test_all_commuting_higher_order(self): + """Test with all commuting terms.""" + # XI and IX commute: commutator bound = 0, N = 1 + h = QubitHamiltonian(pauli_strings=["XI", "IX"], coefficients=[1.0, 1.0]) + assert trotter_steps_commutator(h, 1.0, 0.1, order=4) == 1 + + def test_tighter_than_naive_higher_order(self): + """Test that commutator bound is never looser than naive.""" + h = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) + eps = 0.01 + time = 1.0 + n_naive = trotter_steps_naive(h, time, eps, order=4) + n_comm = trotter_steps_commutator(h, time, eps, order=4) + assert n_comm <= n_naive + + def test_minimum_one_higher_order(self): + """Test that result is at least 1 for commuting Hamiltonian.""" + h = QubitHamiltonian(pauli_strings=["XI", "IX"], coefficients=[1.0, 1.0]) + assert trotter_steps_commutator(h, 1.0, 100.0, order=4) == 1 + + def test_time_scaling_higher_order(self): + """Test that step count scales with t^(1+1/4).""" + h = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) + n1 = trotter_steps_commutator(h, 1.0, 0.1, order=4) + n2 = trotter_steps_commutator(h, 2.0, 0.1, order=4) + # n2 should be approximately 2**1.25 * n1 (t^1.25 scaling) + assert (n1 * math.ceil(2**1.25)) // n2 == 1 # Allow for ceiling effects - def test_order_2_raises(self): - """Test that order > 1 raises NotImplementedError.""" + def test_order_3_raises(self): + """Test that order > 2 raises NotImplementedError.""" h = QubitHamiltonian(pauli_strings=["X", "Z"], coefficients=[1.0, 1.0]) - with pytest.raises(NotImplementedError, match="order 2"): - trotter_steps_commutator(h, 1.0, 0.1, order=2) + with pytest.raises(NotImplementedError, match="order 3"): + trotter_steps_commutator(h, 1.0, 0.1, order=3) From a31336ff84efa04c6888f9d07af393a5001ede57 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Mon, 30 Mar 2026 16:59:37 +0000 Subject: [PATCH 003/117] Trotter grouping terms by parallelizable layers, fixes pauli strings generating -I --- .../qdk_chemistry/algorithms/time_evolution/builder/trotter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index e708224c7..e2dfcefda 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -456,6 +456,7 @@ def _group_terms( for layer_i, layer_occ in enumerate(layers_occupied): if qubits.isdisjoint(layer_occ): layers_indices[layer_i].append(i) + layer_occ.update(qubits) placed = True break if not placed: From 6df5b2feb1863401e0d95b2e8773e1a352f7f929 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Mon, 30 Mar 2026 17:47:54 +0000 Subject: [PATCH 004/117] added missing files --- .../time_evolution/measure_simulation/base.py | 10 +++--- .../measure_simulation/evolve_and_measure.py | 4 +++ .../utils/qsharp/CircuitComposition.qs | 31 +++++++++++++++++++ python/tests/test_evolve_and_measure.py | 4 ++- 4 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py index 14edce80f..bba1360b6 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py @@ -12,6 +12,7 @@ from qdk_chemistry.algorithms.base import Algorithm, AlgorithmFactory from qdk_chemistry.algorithms.circuit_executor.base import CircuitExecutor +from qdk_chemistry.algorithms.energy_estimator.energy_estimator import EnergyEstimator from qdk_chemistry.algorithms.time_evolution.builder.base import TimeEvolutionBuilder from qdk_chemistry.algorithms.time_evolution.circuit_mapper.base import EvolutionCircuitMapper from qdk_chemistry.data import ( @@ -63,6 +64,7 @@ def _run_impl( circuit_mapper: EvolutionCircuitMapper, shots: int = 1000, circuit_executor: CircuitExecutor, + energy_estimator: EnergyEstimator, noise: QuantumErrorProfile | None = None, basis_gates: list[str] | None = None, ) -> list[MeasurementData]: @@ -77,6 +79,7 @@ def _run_impl( circuit_mapper: Mapper for time-evolution unitary to circuit. shots: Number of shots to use for measurement. circuit_executor: Circuit executor backend. + energy_estimator: Energy estimator algorithm. noise: Optional noise profile. basis_gates: Optional list of basis gates to transpile the circuit into before execution. @@ -201,6 +204,7 @@ def _measure_observable( circuit: Circuit, observable: QubitHamiltonian, circuit_executor: CircuitExecutor, + energy_estimator: EnergyEstimator, shots: int = 1000, noise: QuantumErrorProfile | None = None, ) -> tuple[EnergyExpectationResult, MeasurementData]: @@ -218,12 +222,10 @@ def _measure_observable( if shots_per_group <= 0: raise ValueError("shots per measurement group must be positive.") - if getattr(circuit_executor, "type_name", lambda: None)() != "energy_estimator": - raise TypeError("Only an EnergyEstimator is supported as circuit_executor.") - - energy_result, measurement_data = circuit_executor.run( + energy_result, measurement_data = energy_estimator.run( circuit, grouped_observables, + circuit_executor, total_shots=shots, noise_model=noise, ) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py index 74171f5cc..fbf103105 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py @@ -6,6 +6,7 @@ # -------------------------------------------------------------------------------------------- from qdk_chemistry.algorithms.circuit_executor.base import CircuitExecutor +from qdk_chemistry.algorithms.energy_estimator.energy_estimator import EnergyEstimator from qdk_chemistry.algorithms.time_evolution.builder.base import TimeEvolutionBuilder from qdk_chemistry.algorithms.time_evolution.circuit_mapper.base import EvolutionCircuitMapper from qdk_chemistry.data import ( @@ -50,6 +51,7 @@ def _run_impl( circuit_mapper: EvolutionCircuitMapper, shots: int = 1000, circuit_executor: CircuitExecutor, + energy_estimator: EnergyEstimator, noise: QuantumErrorProfile | None = None, basis_gates: list[str] | None = None, ) -> list[MeasurementData]: @@ -64,6 +66,7 @@ def _run_impl( circuit_mapper: Mapper for time-evolution unitary to circuit. shots: Number of shots to use for measurement. circuit_executor: Circuit executor backend. + energy_estimator: Energy estimator algorithm. noise: Optional noise profile. basis_gates: Optional list of basis gates to transpile the circuit into before execution. @@ -102,6 +105,7 @@ def _run_impl( shots=shots, observable=observable, circuit_executor=circuit_executor, + energy_estimator=energy_estimator, noise=noise, ) ) diff --git a/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs b/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs new file mode 100644 index 000000000..efdf732c0 --- /dev/null +++ b/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for +// license information. + +namespace QDKChemistry.Utils.CircuitComposition { + + /// Applies two operations sequentially on the same system register. + operation ApplySequential( + first : Qubit[] => Unit, + second : Qubit[] => Unit, + systems : Qubit[] + ) : Unit { + first(systems); + second(systems); + } + + /// Returns a composed operation that applies ``first`` and then ``second``. + function MakeSequentialOp(first : Qubit[] => Unit, second : Qubit[] => Unit) : Qubit[] => Unit { + ApplySequential(first, second, _) + } + + /// Creates a circuit for sequentially applying two operations on the same target qubits. + operation MakeSequentialCircuit( + first : Qubit[] => Unit, + second : Qubit[] => Unit, + targets : Int[] + ) : Unit { + use qs = Qubit[Length(targets)]; + ApplySequential(first, second, qs); + } +} \ No newline at end of file diff --git a/python/tests/test_evolve_and_measure.py b/python/tests/test_evolve_and_measure.py index 23f3791f9..0c808093f 100644 --- a/python/tests/test_evolve_and_measure.py +++ b/python/tests/test_evolve_and_measure.py @@ -105,7 +105,8 @@ def test_evolve_and_measure_eigenvalue_remains_constant() -> None: evolution_builder = Trotter(num_divisions=1, order=1, optimize_term_ordering=True) algo = EvolveAndMeasure() mapper = PauliSequenceMapper() - circuit_executor = create("energy_estimator", "qiskit_aer_simulator") + energy_estimator = create("energy_estimator", "qdk") + circuit_executor = create("circuit_executor", "qdk_full_state_simulator") measurements = algo.run( hamiltonians, @@ -114,6 +115,7 @@ def test_evolve_and_measure_eigenvalue_remains_constant() -> None: evolution_builder=evolution_builder, circuit_mapper=mapper, circuit_executor=circuit_executor, + energy_estimator=energy_estimator, shots=1024, ) From 476bc5f74ba7aa30671b283cd8ad462750d02247 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Mon, 30 Mar 2026 18:05:48 +0000 Subject: [PATCH 005/117] pre-commit --- python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs b/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs index efdf732c0..7f5cdebff 100644 --- a/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs +++ b/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs @@ -28,4 +28,4 @@ namespace QDKChemistry.Utils.CircuitComposition { use qs = Qubit[Length(targets)]; ApplySequential(first, second, qs); } -} \ No newline at end of file +} From 55a048886a4ef4938fd0470334b4b8d9e172d364 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Mon, 30 Mar 2026 19:38:11 +0000 Subject: [PATCH 006/117] implememnting exponentiate_commuting() naively for now --- .../time_evolution/builder/trotter.py | 123 ++---------------- 1 file changed, 8 insertions(+), 115 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index e2dfcefda..ebce88189 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -17,7 +17,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See LICENSE.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import cmath from qdk_chemistry.algorithms.time_evolution.builder.base import TimeEvolutionBuilder from qdk_chemistry.algorithms.time_evolution.builder.trotter_error import ( @@ -296,7 +295,6 @@ def _decompose_trotter_step( terms.extend( self._exponentiate_commuting( subgroup, - num_qubits=qubit_hamiltonian.num_qubits, time=time, atol=atol, ) @@ -312,7 +310,6 @@ def _decompose_trotter_step( terms_without_last_group.extend( self._exponentiate_commuting( subgroup, - num_qubits=qubit_hamiltonian.num_qubits, time=time / 2, atol=atol, ) @@ -324,7 +321,6 @@ def _decompose_trotter_step( terms.extend( self._exponentiate_commuting( subgroup, - num_qubits=qubit_hamiltonian.num_qubits, time=time, atol=atol, ) @@ -486,135 +482,32 @@ def _multi_qubit_count(group: list[QubitHamiltonian]) -> int: def _exponentiate_commuting( self, group: QubitHamiltonian, - num_qubits: int, time: float, *, atol: float = 1e-12, ) -> list[ExponentiatedPauliTerm]: - r"""Exponentiate commuting Pauli groups via Clifford diagonalization. + r"""Exponentiate a group of commuting Pauli terms. - For each commuting group produced by :meth:`_group_terms`, computes - the encoding Clifford *C* that simultaneously diagonalizes every - Pauli string in that group (:math:`C\,P_j\,C^\dagger = D_j`, where - :math:`D_j` is a product of *Z* and *I* operators only), then - constructs the sandwich decomposition: - - .. math:: - - \prod_j e^{-i\,\theta_j\,P_j} - \;=\; - C^\dagger - \!\left(\prod_j e^{-i\,\theta_j\,D_j}\right) - C - - Applied to a quantum state :math:`|\psi\rangle`: - - 1. Apply *C* (rotate into the diagonal basis). - 2. Apply :math:`\prod_j e^{-i\,\theta_j\,D_j}` (diagonal rotations). - 3. Apply :math:`C^\dagger` (undo the basis change). - - The returned list encodes the full sequence as - :class:`ExponentiatedPauliTerm` entries: for every commuting group - the Clifford *C* (decomposed into Pauli rotations), then the - diagonal :math:`e^{-i\theta_j D_j}` terms, then :math:`C^\dagger` - (the reversed rotations with negated angles). + Each term :math:`P_j` with coefficient :math:`c_j` is converted to + the rotation :math:`e^{-i\,c_j\,t\,P_j}`. Because all terms in the + group commute and :meth:`_group_terms` ensures they have disjoint + qubit supports, the rotations can be applied in any order. Args: group: The group of commuting Hamiltonian terms to exponentiate. - num_qubits: The number of qubits in the system. time: The evolution time used to compute rotation angles (:math:`\theta_j = c_j \cdot t`). atol: Absolute tolerance for filtering small coefficients. Returns: - A flat list of :class:`ExponentiatedPauliTerm` representing, for - each commuting group, the sequence - :math:`C \;\prod_j e^{-i\theta_j D_j}\; C^\dagger`. + A flat list of :class:`ExponentiatedPauliTerm`. """ - from paulimer import DensePauli, SparsePauli, encoding_clifford_of # noqa: PLC0415 - terms: list[ExponentiatedPauliTerm] = [] - - # Single-term groups don't need Clifford diagonalization. - if len(group.pauli_strings) == 1: - for label, coeff in group.get_real_coefficients(tolerance=atol): - mapping = self._pauli_label_to_map(label) - angle = coeff * time - terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) - return terms - - sparse_paulis = [SparsePauli(ps) for ps in group.pauli_strings] - clifford = encoding_clifford_of(sparse_paulis, num_qubits) - - if clifford.is_identity: - # All terms are already diagonal (Z/I only); no basis change needed. - for label, coeff in group.get_real_coefficients(tolerance=atol): - mapping = self._pauli_label_to_map(label) - angle = coeff * time - terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) - return terms - - # Decompose the Clifford C into Pauli rotations. - clifford_terms = self._clifford_to_terms(clifford, num_qubits, atol) - - # C: rotate into the diagonal basis. - terms.extend(clifford_terms) - - # Build the diagonal exponentiated terms. - # C diagonalizes each P_j: D_j = C P_j C† (Z/I only). for label, coeff in group.get_real_coefficients(tolerance=atol): - diag_pauli = clifford.image_of(DensePauli(label)) - mapping = self._pauli_label_to_map(diag_pauli.characters) - angle = coeff * time * diag_pauli.phase.real + mapping = self._pauli_label_to_map(label) + angle = coeff * time terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) - - # C†: undo the basis change (reversed order, negated angles). - for ct in reversed(clifford_terms): - terms.append(ExponentiatedPauliTerm(pauli_term=ct.pauli_term, angle=-ct.angle)) - - return terms - - @staticmethod - def _clifford_to_terms(clifford, num_qubits, atol) -> list[ExponentiatedPauliTerm]: - r"""Convert an encoding Clifford into Pauli strings with phases. - - Extracts the 2n generator images of the Clifford via - :meth:`~paulimer.CliffordUnitary.image_z` and - :meth:`~paulimer.CliffordUnitary.image_x`, returning each as - an :class:`ExponentiatedPauliTerm` with angle derived from the - image phase. - - Args: - clifford: The encoding Clifford object returned by - :func:`~paulimer.encoding_clifford_of`. - num_qubits: Number of qubits. - atol: Absolute tolerance for filtering small angles (e.g., from numerical imprecision). - - Returns: - A list of :class:`ExponentiatedPauliTerm` entries. - - """ - if clifford.is_identity: - return [] - - terms: list[ExponentiatedPauliTerm] = [] - for q in range(num_qubits): - for image in (clifford.image_z(q), clifford.image_x(q)): - chars = image.characters - # Skip pure-identity images. - if all(c == "I" for c in chars): - continue - # phase is +1, -1, +i, or -i. - # Map to an angle: phase = exp(i * phi) => angle = phi / 2 - phi = cmath.phase(image.phase) # 0, pi, pi/2, -pi/2 - angle = phi / 2 - # Skip no-op rotations (phase == +1 => angle == 0). - if abs(angle) < atol: - continue - mapping = Trotter._pauli_label_to_map(chars) - terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) - return terms def name(self) -> str: From b55ca4accd306fbfbe2a5be6d617770fd29e78d6 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Mon, 30 Mar 2026 20:37:51 +0000 Subject: [PATCH 007/117] added qubit-wise diagonalization when exponentiating disjoint commuting pauli strings --- .../time_evolution/builder/trotter.py | 67 +++++++++++++------ python/tests/test_time_evolution_trotter.py | 11 ++- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index ebce88189..d90d2b2c9 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -18,6 +18,8 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import numpy as np + from qdk_chemistry.algorithms.time_evolution.builder.base import TimeEvolutionBuilder from qdk_chemistry.algorithms.time_evolution.builder.trotter_error import ( trotter_steps_commutator, @@ -293,7 +295,7 @@ def _decompose_trotter_step( for group in grouped_hamiltonians: for subgroup in group: terms.extend( - self._exponentiate_commuting( + self._exponentiate_disjoint_commuting( subgroup, time=time, atol=atol, @@ -308,7 +310,7 @@ def _decompose_trotter_step( for group in grouped_hamiltonians[:-1]: for subgroup in group: terms_without_last_group.extend( - self._exponentiate_commuting( + self._exponentiate_disjoint_commuting( subgroup, time=time / 2, atol=atol, @@ -319,7 +321,7 @@ def _decompose_trotter_step( # e^{-iH_i t/n} for all terms in the last group for subgroup in grouped_hamiltonians[-1]: terms.extend( - self._exponentiate_commuting( + self._exponentiate_disjoint_commuting( subgroup, time=time, atol=atol, @@ -479,36 +481,57 @@ def _multi_qubit_count(group: list[QubitHamiltonian]) -> int: return result - def _exponentiate_commuting( + def _exponentiate_disjoint_commuting( self, group: QubitHamiltonian, time: float, *, atol: float = 1e-12, ) -> list[ExponentiatedPauliTerm]: - r"""Exponentiate a group of commuting Pauli terms. + r"""Exponentiate commuting Pauli terms via qubit-wise diagonalization. - Each term :math:`P_j` with coefficient :math:`c_j` is converted to - the rotation :math:`e^{-i\,c_j\,t\,P_j}`. Because all terms in the - group commute and :meth:`_group_terms` ensures they have disjoint - qubit supports, the rotations can be applied in any order. + Each non-diagonal character is rotated into *Z*: - Args: - group: The group of commuting Hamiltonian terms to exponentiate. - time: The evolution time used to compute rotation angles - (:math:`\theta_j = c_j \cdot t`). - atol: Absolute tolerance for filtering small coefficients. + * **X** → :math:`e^{-i(\pi/4)Y}` maps :math:`X \to Z`. + * **Y** → :math:`e^{-i(\pi/4)X}` maps :math:`Y \to -Z`. - Returns: - A flat list of :class:`ExponentiatedPauliTerm`. + The output is ``[basis_change, diag_rotations, basis_change†]``. """ - terms: list[ExponentiatedPauliTerm] = [] - for label, coeff in group.get_real_coefficients(tolerance=atol): - mapping = self._pauli_label_to_map(label) - angle = coeff * time - terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) - return terms + raw = [(lbl, c, self._pauli_label_to_map(lbl)) for lbl, c in group.get_real_coefficients(tolerance=atol)] + + # Single-term group — emit directly (Q# Exp handles X/Y/Z natively). + if len(raw) == 1: + _, c, m = raw[0] + return [ExponentiatedPauliTerm(pauli_term=m, angle=c * time)] + + # Map each non-diagonal qubit to its basis-change axis. + basis: dict[int, str] = {} + for _, _, m in raw: + for q, ch in m.items(): + if ch in ("X", "Y") and q not in basis: + basis[q] = "Y" if ch == "X" else "X" + + # Already diagonal — emit raw terms. + if not basis: + return [ExponentiatedPauliTerm(pauli_term=m, angle=c * time) for _, c, m in raw] + + # Basis-change layer (sorted for determinism). + bc = [ExponentiatedPauliTerm(pauli_term={q: basis[q]}, angle=np.pi / 4) for q in sorted(basis)] + + # Diagonal rotations: replace X/Y → Z, flip sign per Y. + diag: list[ExponentiatedPauliTerm] = [] + for _, coeff, m in raw: + sign = (-1) ** sum(ch == "Y" for ch in m.values()) + diag.append( + ExponentiatedPauliTerm( + pauli_term={q: "Z" if ch in ("X", "Y") else ch for q, ch in m.items()}, + angle=coeff * time * sign, + ) + ) + + # Sandwich: C · diag · C† + return bc + diag + [ExponentiatedPauliTerm(pauli_term=t.pauli_term, angle=-t.angle) for t in reversed(bc)] def name(self) -> str: """Return the name of the time evolution unitary builder.""" diff --git a/python/tests/test_time_evolution_trotter.py b/python/tests/test_time_evolution_trotter.py index a2496e494..4a5faaccf 100644 --- a/python/tests/test_time_evolution_trotter.py +++ b/python/tests/test_time_evolution_trotter.py @@ -903,12 +903,11 @@ def term_to_label(term): term_labels = [term_to_label(t) for t in terms] - # After Clifford diagonalization, the ZZ layers produce 2 terms each - # (image_of transforms the original labels), and the X group - # {XIII, IXII, IIXI, IIIX} produces a Clifford sandwich of 12 - # single-qubit Z terms (C + diagonal + C†). - pauli_zz_layer_1 = {"ZZZZ", "IZIZ"} - pauli_zz_layer_2 = {"ZIZZ", "IZZZ"} + # With qubit-wise diagonalization, ZZ terms are already diagonal and + # stay as-is. The X group {XIII, IXII, IIXI, IIIX} gets a sandwich: + # 4 basis-change + 4 diagonal + 4 basis-change† = 12 single-qubit terms. + pauli_zz_layer_1 = {"ZZII", "IIZZ"} + pauli_zz_layer_2 = {"IZZI", "ZIIZ"} pauli_x_size = 12 assert len(terms) == pauli_x_size + len(pauli_zz_layer_1) + len(pauli_zz_layer_2) From ffe5be02fed05daba59a379f7523ecdfeffd0657 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Mon, 30 Mar 2026 21:05:33 +0000 Subject: [PATCH 008/117] Revert "added qubit-wise diagonalization when exponentiating disjoint commuting pauli strings" This reverts commit b55ca4accd306fbfbe2a5be6d617770fd29e78d6. --- .../time_evolution/builder/trotter.py | 67 ++++++------------- python/tests/test_time_evolution_trotter.py | 11 +-- 2 files changed, 28 insertions(+), 50 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index d90d2b2c9..ebce88189 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -18,8 +18,6 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import numpy as np - from qdk_chemistry.algorithms.time_evolution.builder.base import TimeEvolutionBuilder from qdk_chemistry.algorithms.time_evolution.builder.trotter_error import ( trotter_steps_commutator, @@ -295,7 +293,7 @@ def _decompose_trotter_step( for group in grouped_hamiltonians: for subgroup in group: terms.extend( - self._exponentiate_disjoint_commuting( + self._exponentiate_commuting( subgroup, time=time, atol=atol, @@ -310,7 +308,7 @@ def _decompose_trotter_step( for group in grouped_hamiltonians[:-1]: for subgroup in group: terms_without_last_group.extend( - self._exponentiate_disjoint_commuting( + self._exponentiate_commuting( subgroup, time=time / 2, atol=atol, @@ -321,7 +319,7 @@ def _decompose_trotter_step( # e^{-iH_i t/n} for all terms in the last group for subgroup in grouped_hamiltonians[-1]: terms.extend( - self._exponentiate_disjoint_commuting( + self._exponentiate_commuting( subgroup, time=time, atol=atol, @@ -481,57 +479,36 @@ def _multi_qubit_count(group: list[QubitHamiltonian]) -> int: return result - def _exponentiate_disjoint_commuting( + def _exponentiate_commuting( self, group: QubitHamiltonian, time: float, *, atol: float = 1e-12, ) -> list[ExponentiatedPauliTerm]: - r"""Exponentiate commuting Pauli terms via qubit-wise diagonalization. + r"""Exponentiate a group of commuting Pauli terms. - Each non-diagonal character is rotated into *Z*: + Each term :math:`P_j` with coefficient :math:`c_j` is converted to + the rotation :math:`e^{-i\,c_j\,t\,P_j}`. Because all terms in the + group commute and :meth:`_group_terms` ensures they have disjoint + qubit supports, the rotations can be applied in any order. - * **X** → :math:`e^{-i(\pi/4)Y}` maps :math:`X \to Z`. - * **Y** → :math:`e^{-i(\pi/4)X}` maps :math:`Y \to -Z`. + Args: + group: The group of commuting Hamiltonian terms to exponentiate. + time: The evolution time used to compute rotation angles + (:math:`\theta_j = c_j \cdot t`). + atol: Absolute tolerance for filtering small coefficients. - The output is ``[basis_change, diag_rotations, basis_change†]``. + Returns: + A flat list of :class:`ExponentiatedPauliTerm`. """ - raw = [(lbl, c, self._pauli_label_to_map(lbl)) for lbl, c in group.get_real_coefficients(tolerance=atol)] - - # Single-term group — emit directly (Q# Exp handles X/Y/Z natively). - if len(raw) == 1: - _, c, m = raw[0] - return [ExponentiatedPauliTerm(pauli_term=m, angle=c * time)] - - # Map each non-diagonal qubit to its basis-change axis. - basis: dict[int, str] = {} - for _, _, m in raw: - for q, ch in m.items(): - if ch in ("X", "Y") and q not in basis: - basis[q] = "Y" if ch == "X" else "X" - - # Already diagonal — emit raw terms. - if not basis: - return [ExponentiatedPauliTerm(pauli_term=m, angle=c * time) for _, c, m in raw] - - # Basis-change layer (sorted for determinism). - bc = [ExponentiatedPauliTerm(pauli_term={q: basis[q]}, angle=np.pi / 4) for q in sorted(basis)] - - # Diagonal rotations: replace X/Y → Z, flip sign per Y. - diag: list[ExponentiatedPauliTerm] = [] - for _, coeff, m in raw: - sign = (-1) ** sum(ch == "Y" for ch in m.values()) - diag.append( - ExponentiatedPauliTerm( - pauli_term={q: "Z" if ch in ("X", "Y") else ch for q, ch in m.items()}, - angle=coeff * time * sign, - ) - ) - - # Sandwich: C · diag · C† - return bc + diag + [ExponentiatedPauliTerm(pauli_term=t.pauli_term, angle=-t.angle) for t in reversed(bc)] + terms: list[ExponentiatedPauliTerm] = [] + for label, coeff in group.get_real_coefficients(tolerance=atol): + mapping = self._pauli_label_to_map(label) + angle = coeff * time + terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) + return terms def name(self) -> str: """Return the name of the time evolution unitary builder.""" diff --git a/python/tests/test_time_evolution_trotter.py b/python/tests/test_time_evolution_trotter.py index 4a5faaccf..a2496e494 100644 --- a/python/tests/test_time_evolution_trotter.py +++ b/python/tests/test_time_evolution_trotter.py @@ -903,11 +903,12 @@ def term_to_label(term): term_labels = [term_to_label(t) for t in terms] - # With qubit-wise diagonalization, ZZ terms are already diagonal and - # stay as-is. The X group {XIII, IXII, IIXI, IIIX} gets a sandwich: - # 4 basis-change + 4 diagonal + 4 basis-change† = 12 single-qubit terms. - pauli_zz_layer_1 = {"ZZII", "IIZZ"} - pauli_zz_layer_2 = {"IZZI", "ZIIZ"} + # After Clifford diagonalization, the ZZ layers produce 2 terms each + # (image_of transforms the original labels), and the X group + # {XIII, IXII, IIXI, IIIX} produces a Clifford sandwich of 12 + # single-qubit Z terms (C + diagonal + C†). + pauli_zz_layer_1 = {"ZZZZ", "IZIZ"} + pauli_zz_layer_2 = {"ZIZZ", "IZZZ"} pauli_x_size = 12 assert len(terms) == pauli_x_size + len(pauli_zz_layer_1) + len(pauli_zz_layer_2) From ec87ae357f38895e1ad2e8a9ea9fc3401e8791d2 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Mon, 30 Mar 2026 21:12:37 +0000 Subject: [PATCH 009/117] fixed tests --- python/tests/test_time_evolution_trotter.py | 61 +++------------------ 1 file changed, 9 insertions(+), 52 deletions(-) diff --git a/python/tests/test_time_evolution_trotter.py b/python/tests/test_time_evolution_trotter.py index a2496e494..09321a781 100644 --- a/python/tests/test_time_evolution_trotter.py +++ b/python/tests/test_time_evolution_trotter.py @@ -125,10 +125,8 @@ def test_single_step_merge_identical_terms(self): assert container.num_qubits == 3 assert container.step_reps == 1 # After merging identical Pauli strings, 2 unique terms remain (XII with - # coeff=2 and IXI with coeff=1). Because optimize_term_ordering groups - # them into one commuting set, the Clifford sandwich adds basis-change - # rotations (C and C†) around the diagonal terms. - assert len(container.step_terms) == 6 + # coeff=2 and IXI with coeff=1). Raw Pauli terms are emitted directly. + assert len(container.step_terms) == 2 def test_basic_decomposition(self): """Test basic decomposition of a qubit Hamiltonian.""" @@ -903,51 +901,10 @@ def term_to_label(term): term_labels = [term_to_label(t) for t in terms] - # After Clifford diagonalization, the ZZ layers produce 2 terms each - # (image_of transforms the original labels), and the X group - # {XIII, IXII, IIXI, IIIX} produces a Clifford sandwich of 12 - # single-qubit Z terms (C + diagonal + C†). - pauli_zz_layer_1 = {"ZZZZ", "IZIZ"} - pauli_zz_layer_2 = {"ZIZZ", "IZZZ"} - pauli_x_size = 12 - - assert len(terms) == pauli_x_size + len(pauli_zz_layer_1) + len(pauli_zz_layer_2) - - # Identify contiguous blocks: ZZ layers are 2-term blocks of multi-qubit - # Z strings; the X group sandwich is a 12-term block of single-qubit Z terms. - remaining = list(term_labels) - matched_groups = [] - while remaining: - first = remaining[0] - if first in pauli_zz_layer_1: - block = set(remaining[: len(pauli_zz_layer_1)]) - assert block == pauli_zz_layer_1, f"Expected {pauli_zz_layer_1}, got {block}" - matched_groups.append(("zz", block)) - remaining = remaining[len(pauli_zz_layer_1) :] - elif first in pauli_zz_layer_2: - block = set(remaining[: len(pauli_zz_layer_2)]) - assert block == pauli_zz_layer_2, f"Expected {pauli_zz_layer_2}, got {block}" - matched_groups.append(("zz", block)) - remaining = remaining[len(pauli_zz_layer_2) :] - else: - # X group Clifford sandwich: 12 contiguous single-qubit Z terms - x_block = remaining[:pauli_x_size] - assert len(x_block) == pauli_x_size, f"X block too short: {len(x_block)}" - assert all(sum(c != "I" for c in lbl) <= 1 for lbl in x_block), ( - f"X group sandwich should contain only single-qubit terms, got: {x_block}" - ) - matched_groups.append(("x", set(x_block))) - remaining = remaining[pauli_x_size:] - - assert len(matched_groups) == 3 - - # Verify all three group types are present - tags = [tag for tag, _ in matched_groups] - assert tags.count("zz") == 2 - assert tags.count("x") == 1 - - # The two ZZ layers must be adjacent (they come from the same commuting group). - zz_indices = [i for i, (tag, _) in enumerate(matched_groups) if tag == "zz"] - assert abs(zz_indices[0] - zz_indices[1]) == 1, ( - "The two ZZ layers must be adjacent, but got group ordering: " + str(tags) - ) + # Raw Pauli terms are emitted directly (no Clifford sandwich). + # ZZ terms stay as their original labels; X terms stay as single-qubit X. + pauli_zz_labels = {"ZZII", "IIZZ", "IZZI", "ZIIZ"} + pauli_x_labels = {"XIII", "IXII", "IIXI", "IIIX"} + + assert len(terms) == len(pauli_zz_labels) + len(pauli_x_labels) + assert set(term_labels) == pauli_zz_labels | pauli_x_labels From bdb435b1dba73663f47ae5e6f6498e70bc744f8b Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Mon, 30 Mar 2026 22:15:17 +0000 Subject: [PATCH 010/117] updated the evolve_and_measure interface to be compatible with the new energy_estimator --- .../time_evolution/measure_simulation/base.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py index bba1360b6..4e487061b 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py @@ -209,22 +209,9 @@ def _measure_observable( noise: QuantumErrorProfile | None = None, ) -> tuple[EnergyExpectationResult, MeasurementData]: """Measure a qubit observable on the provided circuit state.""" - grouped_observables = observable.group_commuting(qubit_wise=True) - if not grouped_observables: - raise ValueError("Observable has no measurable terms after grouping.") - - if shots < len(grouped_observables): - raise ValueError( - f"Total shots {shots} is less than the number of grouped observables {len(grouped_observables)}." - ) - - shots_per_group = shots // len(grouped_observables) - if shots_per_group <= 0: - raise ValueError("shots per measurement group must be positive.") - energy_result, measurement_data = energy_estimator.run( circuit, - grouped_observables, + observable, circuit_executor, total_shots=shots, noise_model=noise, From 14495b910a40fe150a2e2f0d286a0025d5ba8f17 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Mon, 30 Mar 2026 23:43:11 +0000 Subject: [PATCH 011/117] fixing docstring --- .../data/time_evolution/containers/pauli_product_formula.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py b/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py index e8c6e7af6..7b3d23560 100644 --- a/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py +++ b/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py @@ -131,7 +131,7 @@ def combine(self, other_container: "PauliProductFormulaContainer", atol=1e-12) - Args: self_container: The first ``PauliProductFormulaContainer``. other_container: The second ``PauliProductFormulaContainer`` appended - after *self_container*. + after *self_container*. atol: Absolute tolerance for filtering small coefficients. Returns: From 3a24e8983c035173a0f03ba2c3d27cf5383f952a Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Tue, 31 Mar 2026 15:25:15 +0000 Subject: [PATCH 012/117] fixing pre-commit --- .../data/time_evolution/containers/pauli_product_formula.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py b/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py index 7b3d23560..24109260b 100644 --- a/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py +++ b/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py @@ -129,9 +129,7 @@ def combine(self, other_container: "PauliProductFormulaContainer", atol=1e-12) - """Combine two Trotter evolutions, merging commuting terms. Args: - self_container: The first ``PauliProductFormulaContainer``. - other_container: The second ``PauliProductFormulaContainer`` appended - after *self_container*. + other_container: The second ``PauliProductFormulaContainer`` appended after *self_container*. atol: Absolute tolerance for filtering small coefficients. Returns: From 0439675162b71dac7edd06171c06c558c891b455 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Tue, 31 Mar 2026 20:57:17 +0000 Subject: [PATCH 013/117] added example notebook --- .../time_evolution/measure_simulation/evolve_and_measure.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py index fbf103105..6807a75e2 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py @@ -73,7 +73,13 @@ def _run_impl( Returns: A list of ``MeasurementData`` objects. + Raises: + ValueError: If ``qubit_hamiltonians`` is empty. + """ + if not qubit_hamiltonians: + raise ValueError("qubit_hamiltonians must not be empty.") + evolution = self._create_time_evolution(qubit_hamiltonians[0], times[0], evolution_builder) for i in range(1, len(qubit_hamiltonians)): From 522ba7eeb990c5fc7abf56652482e58c6ea1f90f Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Tue, 31 Mar 2026 21:20:29 +0000 Subject: [PATCH 014/117] added example notebook --- examples/time_evolve_and_measure.ipynb | 323 +++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 examples/time_evolve_and_measure.ipynb diff --git a/examples/time_evolve_and_measure.ipynb b/examples/time_evolve_and_measure.ipynb new file mode 100644 index 000000000..a3daafffe --- /dev/null +++ b/examples/time_evolve_and_measure.ipynb @@ -0,0 +1,323 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "694e0a42", + "metadata": {}, + "source": [ + "# Measure time-evolved expectation values in `qdk-chemistry`\n", + "\n", + "## This notebook demonstrates the `EvolveAndMeasure` algorithm for Hamiltonian time evolution and observable measurement using `qdk-chemistry`. It shows how to:\n", + "\n", + "1. Define a qubit Hamiltonian with time-dependent parameters\n", + "2. Build a Trotter evolution circuit\n", + "3. Run the simulation **without noise** using the QDK full-state simulator\n", + "4. Run the simulation **with noise** using both the QDK and Qiskit Aer backends\n", + "5. Optionally transpile to a target basis gate set before execution" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d7052dfd", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from qdk_chemistry.algorithms import create\n", + "from qdk_chemistry.algorithms.time_evolution.trotter import Trotter\n", + "from qdk_chemistry.algorithms.time_evolution.circuit_mapper import PauliSequenceMapper\n", + "from qdk_chemistry.algorithms.time_evolution.measure_simulation import EvolveAndMeasure\n", + "from qdk_chemistry.data import LatticeGraph, QuantumErrorProfile, QubitHamiltonian\n", + "from qdk_chemistry.utils.model_hamiltonians import create_ising_hamiltonian\n", + "\n", + "# Reduce logging output for demo\n", + "from qdk_chemistry.utils import Logger\n", + "Logger.set_global_level(Logger.LogLevel.off)" + ] + }, + { + "cell_type": "markdown", + "id": "974e8334", + "metadata": {}, + "source": [ + "We define two qubit Hamiltonians representing alternating time-evolution steps (e.g., a driven or Floquet-like protocol). The observable is `ZZ`, measured after the full evolution sequence." + ] + }, + { + "cell_type": "markdown", + "id": "30741065", + "metadata": {}, + "source": [ + "The lists `hamiltonians` and `time_steps` define the discretized time-dependet Hamiltonian $H(t)$ as:\n", + "\n", + "$H(t_i) = H_i$, where\n", + "\n", + "$H_i \\in $ `hamiltonians` = $\\left[H_1,\\dots,H_n\\right]$,\n", + "\n", + "$t_i \\in $`time_steps` = $\\left[t_1,\\dots,t_n\\right]$" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "c9bdd8b5", + "metadata": {}, + "outputs": [], + "source": [ + "lattice = LatticeGraph.chain(2)\n", + "\n", + "hamiltonian_p = create_ising_hamiltonian(lattice, j=1.0, h=0.5)\n", + "hamiltonian_m = create_ising_hamiltonian(lattice, j=1.0, h=-0.5)\n", + "\n", + "steps = 20\n", + "hamiltonians = [hamiltonian_p, hamiltonian_m] * (steps // 2)\n", + "time_steps = [float((t + 1) / 10) for t in range(steps)]\n", + "\n", + "observable = QubitHamiltonian([\"ZZ\"], np.array([1.0]))" + ] + }, + { + "cell_type": "markdown", + "id": "57064b4c", + "metadata": {}, + "source": [ + "## Configure the algorithm components\n", + "\n", + "Set up the Trotter builder, circuit mapper, energy estimator, and the `EvolveAndMeasure` algorithm." + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "3ef0a94f", + "metadata": {}, + "outputs": [], + "source": [ + "evolution_builder = Trotter(num_divisions=1, order=1, optimize_term_ordering=True)\n", + "mapper = PauliSequenceMapper()\n", + "energy_estimator = create(\"energy_estimator\", \"qdk\")\n", + "algo = EvolveAndMeasure()" + ] + }, + { + "cell_type": "markdown", + "id": "2ca695ec", + "metadata": {}, + "source": [ + "## Noiseless simulation (QDK full-state simulator)\n", + "\n", + "Run the evolution and measure `⟨ZZ⟩` without any noise using the QDK full-state simulator." + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "3a876d78", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Noiseless ⟨ZZ⟩: 0.990234\n" + ] + } + ], + "source": [ + "circuit_executor_qdk = create(\"circuit_executor\", \"qdk_full_state_simulator\")\n", + "\n", + "measurements_noiseless = algo.run(\n", + " hamiltonians,\n", + " times=time_steps,\n", + " observables=[observable],\n", + " evolution_builder=evolution_builder,\n", + " circuit_mapper=mapper,\n", + " circuit_executor=circuit_executor_qdk,\n", + " energy_estimator=energy_estimator,\n", + " shots=1024,\n", + ")\n", + "\n", + "zz_noiseless = measurements_noiseless[0][0].energy_expectation_value\n", + "print(f\"Noiseless ⟨ZZ⟩: {zz_noiseless:.6f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "6e70e127", + "metadata": {}, + "source": [ + "## Define a noise profile\n", + "\n", + "`QuantumErrorProfile` provides a backend-agnostic way to specify depolarizing noise on individual gates. This profile can be used with both the QDK and Qiskit Aer simulators.\n", + "\n", + "> **Note:** The QDK full-state simulator applies noise at the *native gate level* of the compiled QIR. If the circuit uses high-level Pauli exponentials (e.g. via `PauliSequenceMapper`), those are executed natively without decomposition into `cx`/`rz` gates — so noise on `cx` won't apply. To see noise with the QDK simulator, either use `basis_gates` to force decomposition, or define noise on the native gates (`rzz`, `rx`, etc.)." + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "56c5fc06", + "metadata": {}, + "outputs": [], + "source": [ + "qdk_error_profile = QuantumErrorProfile(\n", + " name=\"demo_noise\",\n", + " description=\"Light depolarizing noise on common gates\",\n", + " errors={\n", + " \"cx\": {\"type\": \"depolarizing_error\", \"rate\": 0.01, \"num_qubits\": 2},\n", + " \"cz\": {\"type\": \"depolarizing_error\", \"rate\": 0.01, \"num_qubits\": 2},\n", + " \"rzz\": {\"type\": \"depolarizing_error\", \"rate\": 0.01, \"num_qubits\": 2},\n", + " \"h\": {\"type\": \"depolarizing_error\", \"rate\": 0.001, \"num_qubits\": 1},\n", + " \"rz\": {\"type\": \"depolarizing_error\", \"rate\": 0.001, \"num_qubits\": 1},\n", + " \"rx\": {\"type\": \"depolarizing_error\", \"rate\": 0.001, \"num_qubits\": 1},\n", + " \"s\": {\"type\": \"depolarizing_error\", \"rate\": 0.001, \"num_qubits\": 1},\n", + " \"sdg\": {\"type\": \"depolarizing_error\", \"rate\": 0.001, \"num_qubits\": 1},\n", + " },\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "c27af18e", + "metadata": {}, + "source": [ + "## Noisy simulation — QDK full-state simulator\n", + "\n", + "Because `PauliSequenceMapper` produces native Pauli-exponential operations, and our noise profile includes `rzz` (the native two-qubit gate), the QDK simulator **will** apply noise to those operations." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "0742066a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Noisy QDK ⟨ZZ⟩: 0.769531\n" + ] + } + ], + "source": [ + "measurements_noisy_qdk = algo.run(\n", + " hamiltonians,\n", + " times=time_steps,\n", + " observables=[observable],\n", + " evolution_builder=evolution_builder,\n", + " circuit_mapper=mapper,\n", + " circuit_executor=circuit_executor_qdk,\n", + " energy_estimator=energy_estimator,\n", + " shots=1024,\n", + " noise=qdk_error_profile,\n", + ")\n", + "\n", + "zz_noisy_qdk = measurements_noisy_qdk[0][0].energy_expectation_value\n", + "print(f\"Noisy QDK ⟨ZZ⟩: {zz_noisy_qdk:.6f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "e0518676", + "metadata": {}, + "source": [ + "## Noisy simulation — Qiskit Aer simulator\n", + "\n", + "The Qiskit Aer backend transpiles the circuit to primitive gates (`cx`, `rz`, `h`, …) before execution, so noise on those gates fires naturally." + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "cafb9c98", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Noisy Aer ⟨ZZ⟩: 0.710938\n" + ] + } + ], + "source": [ + "circuit_executor_aer = create(\"circuit_executor\", \"qiskit_aer_simulator\")\n", + "\n", + "measurements_noisy_aer = algo.run(\n", + " hamiltonians,\n", + " times=time_steps,\n", + " observables=[observable],\n", + " evolution_builder=evolution_builder,\n", + " circuit_mapper=mapper,\n", + " circuit_executor=circuit_executor_aer,\n", + " energy_estimator=energy_estimator,\n", + " shots=1024,\n", + " noise=qdk_error_profile,\n", + ")\n", + "\n", + "zz_noisy_aer = measurements_noisy_aer[0][0].energy_expectation_value\n", + "print(f\"Noisy Aer ⟨ZZ⟩: {zz_noisy_aer:.6f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "fdc8f4be", + "metadata": {}, + "source": [ + "## Compare results\n", + "\n", + "Side-by-side comparison of the noiseless and noisy expectation values." + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "8d7b777c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Simulator ⟨ZZ⟩\n", + "────────────────────────────────────\n", + "QDK (noiseless) 0.990234\n", + "QDK (noisy) 0.769531\n", + "Aer (noisy) 0.710938\n" + ] + } + ], + "source": [ + "print(\"Simulator ⟨ZZ⟩\")\n", + "print(\"─\" * 36)\n", + "print(f\"QDK (noiseless) {zz_noiseless: .6f}\")\n", + "print(f\"QDK (noisy) {zz_noisy_qdk: .6f}\")\n", + "print(f\"Aer (noisy) {zz_noisy_aer: .6f}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "qdk_chemistry_venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 60c4fdacc018b9f88ae897fd2788fa313beaf5b3 Mon Sep 17 00:00:00 2001 From: v-agamshayit Date: Tue, 31 Mar 2026 14:23:04 -0700 Subject: [PATCH 015/117] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- python/pyproject.toml | 2 +- .../time_evolution/builder/trotter.py | 2 +- .../circuit_mapper/pauli_sequence_mapper.py | 12 +++--------- .../containers/pauli_product_formula.py | 17 +++++++++++++---- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index b69e69184..4e5a53b14 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "qdk[jupyter]>=1.26.0", "pybind11-stubgen>=2.5.1", "h5py>=3.0.0", - "paulimer" + "paulimer>=0.1.0" ] description = "Quantum Development Kit - Chemistry Library" license = { file = "LICENSE.txt" } diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index ebce88189..2faf84758 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -417,7 +417,7 @@ def _group_terms( encoding=qubit_hamiltonian.encoding, ) ] - for label, coeff in zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=False) + for label, coeff in zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=True) ] # Sort terms so that Pauli strings acting on more qubits appear first. diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/pauli_sequence_mapper.py b/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/pauli_sequence_mapper.py index 8942b8475..f2056e0f5 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/pauli_sequence_mapper.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/pauli_sequence_mapper.py @@ -90,16 +90,10 @@ def _run_impl(self, evolution: TimeEvolutionUnitary) -> Circuit: pauli_terms.append(base_terms.copy()) angles.append(term.angle) - flattened_pauli_terms: list[list[qsharp.Pauli]] = [] - flattened_angles: list[float] = [] - for _ in range(unitary_container.step_reps): - flattened_pauli_terms.extend(pauli_terms) - flattened_angles.extend(angles) - evo_params = { - "pauliExponents": flattened_pauli_terms, - "pauliCoefficients": flattened_angles, - "repetitions": 1, + "pauliExponents": pauli_terms, + "pauliCoefficients": angles, + "repetitions": unitary_container.step_reps, } target_indices = list(range(unitary_container.num_qubits)) diff --git a/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py b/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py index 24109260b..abd793dde 100644 --- a/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py +++ b/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py @@ -126,15 +126,24 @@ def reorder_terms(self, permutation: list[int]) -> "PauliProductFormulaContainer ) def combine(self, other_container: "PauliProductFormulaContainer", atol=1e-12) -> "PauliProductFormulaContainer": - """Combine two Trotter evolutions, merging commuting terms. + """Combine two Trotter evolutions, merging adjacent identical Pauli terms. + + The terms from ``self`` (repeated ``step_reps`` times) are followed by the + terms from ``other_container`` (also repeated according to its + ``step_reps``). When two consecutive terms act with the same Pauli operator + string (i.e., have identical ``pauli_term`` dictionaries), their rotation + angles are summed into a single ``ExponentiatedPauliTerm``. If the summed + angle has magnitude less than ``atol``, the resulting term is removed. Args: - other_container: The second ``PauliProductFormulaContainer`` appended after *self_container*. - atol: Absolute tolerance for filtering small coefficients. + other_container: The second ``PauliProductFormulaContainer`` appended + after this container. + atol: Absolute tolerance used when deciding whether a merged term with + a small rotation angle should be dropped. Returns: A single ``PauliProductFormulaContainer`` representing the combined - evolution. + evolution with adjacent identical terms fused. """ self_terms = list(self.step_terms) * self.step_reps From a8d57c8c06295225009532781b9468992cc24a17 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Tue, 31 Mar 2026 21:46:34 +0000 Subject: [PATCH 016/117] combining Pauli product formula containers w/o expanding step_reps --- .../time_evolution/builder/trotter.py | 2 +- .../time_evolution/measure_simulation/base.py | 4 +- .../measure_simulation/evolve_and_measure.py | 11 +++- .../containers/pauli_product_formula.py | 26 ++++---- python/tests/test_time_evolution_container.py | 64 +++++++++++++++++++ 5 files changed, 89 insertions(+), 18 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index 2faf84758..c7d6b46b3 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -435,7 +435,7 @@ def _group_terms( for sub_h in sub_hamiltonians: # Merge terms with identical Pauli strings. merged: dict[str, complex] = {} - for label, coeff in zip(sub_h.pauli_strings, sub_h.coefficients, strict=False): + for label, coeff in zip(sub_h.pauli_strings, sub_h.coefficients, strict=True): merged[label] = merged.get(label, 0.0) + coeff labels = list(merged.keys()) coeffs = list(merged.values()) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py index 4e487061b..e1b96e78c 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py @@ -67,7 +67,7 @@ def _run_impl( energy_estimator: EnergyEstimator, noise: QuantumErrorProfile | None = None, basis_gates: list[str] | None = None, - ) -> list[MeasurementData]: + ) -> list[tuple[EnergyExpectationResult, MeasurementData]]: """Run evolve-and-measure simulation. Args: @@ -84,7 +84,7 @@ def _run_impl( basis_gates: Optional list of basis gates to transpile the circuit into before execution. Returns: - A list of ``MeasurementData`` objects. + A list of tuples containing ``EnergyExpectationResult`` and ``MeasurementData`` objects. """ diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py index 6807a75e2..c9523531b 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py @@ -11,6 +11,7 @@ from qdk_chemistry.algorithms.time_evolution.circuit_mapper.base import EvolutionCircuitMapper from qdk_chemistry.data import ( Circuit, + EnergyExpectationResult, MeasurementData, QuantumErrorProfile, QubitHamiltonian, @@ -54,12 +55,12 @@ def _run_impl( energy_estimator: EnergyEstimator, noise: QuantumErrorProfile | None = None, basis_gates: list[str] | None = None, - ) -> list[MeasurementData]: + ) -> list[tuple[EnergyExpectationResult, MeasurementData]]: """Run evolve-and-measure simulation. Args: qubit_hamiltonians: List of Hamiltonians used to build time evolution. - times: List of times to evolve under the Hamiltonians. + times: Monotonically-increasing list of times to evolve under the Hamiltonians. observables: List of observable Hamiltonians to measure after evolution. state_prep: Optional circuit that prepares the initial state before time evolution. evolution_builder: Time-evolution builder. @@ -71,7 +72,7 @@ def _run_impl( basis_gates: Optional list of basis gates to transpile the circuit into before execution. Returns: - A list of ``MeasurementData`` objects. + A list of tuples containing ``EnergyExpectationResult`` and ``MeasurementData`` objects. Raises: ValueError: If ``qubit_hamiltonians`` is empty. @@ -79,6 +80,10 @@ def _run_impl( """ if not qubit_hamiltonians: raise ValueError("qubit_hamiltonians must not be empty.") + if not times: + raise ValueError("times must not be empty.") + if len(qubit_hamiltonians) != len(times): + raise ValueError("qubit_hamiltonians and times must have the same length.") evolution = self._create_time_evolution(qubit_hamiltonians[0], times[0], evolution_builder) diff --git a/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py b/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py index abd793dde..05282643d 100644 --- a/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py +++ b/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py @@ -146,21 +146,23 @@ def combine(self, other_container: "PauliProductFormulaContainer", atol=1e-12) - evolution with adjacent identical terms fused. """ - self_terms = list(self.step_terms) * self.step_reps - other_terms = list(other_container.step_terms) * other_container.step_reps num_qubits = max(self.num_qubits, other_container.num_qubits) - combined = self_terms + other_terms merged: list[ExponentiatedPauliTerm] = [] - for term in combined: - if merged and merged[-1].pauli_term == term.pauli_term: - new_angle = merged[-1].angle + term.angle - if abs(new_angle) > atol: - merged[-1] = ExponentiatedPauliTerm(pauli_term=term.pauli_term, angle=new_angle) - else: - merged.pop() - else: - merged.append(term) + for step_terms, step_reps in ( + (self.step_terms, self.step_reps), + (other_container.step_terms, other_container.step_reps), + ): + for _ in range(step_reps): + for term in step_terms: + if merged and merged[-1].pauli_term == term.pauli_term: + new_angle = merged[-1].angle + term.angle + if abs(new_angle) > atol: + merged[-1] = ExponentiatedPauliTerm(pauli_term=term.pauli_term, angle=new_angle) + else: + merged.pop() + else: + merged.append(term) return PauliProductFormulaContainer(step_terms=merged, step_reps=1, num_qubits=num_qubits) def to_json(self) -> dict[str, Any]: diff --git a/python/tests/test_time_evolution_container.py b/python/tests/test_time_evolution_container.py index 6089e7ce9..1b197b221 100644 --- a/python/tests/test_time_evolution_container.py +++ b/python/tests/test_time_evolution_container.py @@ -128,6 +128,70 @@ def test_to_hdf5_roundtrip(self, container, tmp_path): assert restored.step_reps == container.step_reps assert len(restored.step_terms) == len(container.step_terms) + def test_combine_no_adjacent_identical(self): + """Test combine when no adjacent terms share the same Pauli string.""" + a = PauliProductFormulaContainer( + step_terms=[ + ExponentiatedPauliTerm(pauli_term={0: "X"}, angle=0.1), + ExponentiatedPauliTerm(pauli_term={1: "Z"}, angle=0.2), + ], + step_reps=2, + num_qubits=2, + ) + b = PauliProductFormulaContainer( + step_terms=[ + ExponentiatedPauliTerm(pauli_term={0: "Y"}, angle=0.3), + ExponentiatedPauliTerm(pauli_term={0: "X"}, angle=0.4), + ], + step_reps=2, + num_qubits=2, + ) + result = a.combine(b) + + # a expanded: [X, Z, X, Z], b expanded: [Y, X, Y, X] + # No adjacent duplicates anywhere, so all 8 terms survive. + assert result.step_reps == 1 + assert len(result.step_terms) == 8 + expected_angles = [0.1, 0.2, 0.1, 0.2, 0.3, 0.4, 0.3, 0.4] + for term, expected in zip(result.step_terms, expected_angles, strict=True): + assert np.isclose(term.angle, expected, atol=1e-14) + + def test_combine_with_adjacent_identical(self): + """Test combine where adjacent identical Pauli terms get merged.""" + a = PauliProductFormulaContainer( + step_terms=[ + ExponentiatedPauliTerm(pauli_term={0: "Y"}, angle=1.5), + ExponentiatedPauliTerm(pauli_term={0: "X"}, angle=0.5), + ], + step_reps=2, + num_qubits=1, + ) + b = PauliProductFormulaContainer( + step_terms=[ + ExponentiatedPauliTerm(pauli_term={0: "X"}, angle=0.7), + ExponentiatedPauliTerm(pauli_term={0: "Z"}, angle=1.5), + ], + step_reps=1, + num_qubits=1, + ) + result = a.combine(b) + + # a expanded: [Y(1.5), X(0.5), Y(1.5)], b expanded: [X(0.7), Z(1.5)] + # All three are adjacent identical → merged into one term with angle 1.7 + assert result.step_reps == 1 + assert len(result.step_terms) == 5 + + assert result.step_terms[0].pauli_term == {0: "Y"} + assert np.isclose(result.step_terms[0].angle, 1.5, atol=1e-14) + assert result.step_terms[1].pauli_term == {0: "X"} + assert np.isclose(result.step_terms[1].angle, 0.5, atol=1e-14) + assert result.step_terms[2].pauli_term == {0: "Y"} + assert np.isclose(result.step_terms[2].angle, 1.5, atol=1e-14) + assert result.step_terms[3].pauli_term == {0: "X"} + assert np.isclose(result.step_terms[3].angle, 1.2, atol=1e-14) + assert result.step_terms[4].pauli_term == {0: "Z"} + assert np.isclose(result.step_terms[4].angle, 1.5, atol=1e-14) + def test_summary(self, container): """Test the summary generation of the container.""" summary = container.get_summary() From a2d643286e00469c902a9635cf6ba08992b8d9f7 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Tue, 31 Mar 2026 22:21:31 +0000 Subject: [PATCH 017/117] copilot comments --- examples/time_evolve_and_measure.ipynb | 62 ++++--------------- python/pyproject.toml | 3 +- .../time_evolution/builder/trotter.py | 4 +- .../measure_simulation/evolve_and_measure.py | 6 ++ ..._evolution_circuit_mapper_noncontrolled.py | 38 ++++++++++++ python/tests/test_time_evolution_container.py | 4 +- python/tests/test_time_evolution_trotter.py | 4 +- 7 files changed, 64 insertions(+), 57 deletions(-) diff --git a/examples/time_evolve_and_measure.ipynb b/examples/time_evolve_and_measure.ipynb index a3daafffe..a9e62cc71 100644 --- a/examples/time_evolve_and_measure.ipynb +++ b/examples/time_evolve_and_measure.ipynb @@ -26,7 +26,7 @@ "import numpy as np\n", "\n", "from qdk_chemistry.algorithms import create\n", - "from qdk_chemistry.algorithms.time_evolution.trotter import Trotter\n", + "from qdk_chemistry.algorithms.time_evolution.builder.trotter import Trotter\n", "from qdk_chemistry.algorithms.time_evolution.circuit_mapper import PauliSequenceMapper\n", "from qdk_chemistry.algorithms.time_evolution.measure_simulation import EvolveAndMeasure\n", "from qdk_chemistry.data import LatticeGraph, QuantumErrorProfile, QubitHamiltonian\n", @@ -50,7 +50,7 @@ "id": "30741065", "metadata": {}, "source": [ - "The lists `hamiltonians` and `time_steps` define the discretized time-dependet Hamiltonian $H(t)$ as:\n", + "The lists `hamiltonians` and `time_steps` define the discretized time-dependent Hamiltonian $H(t)$ as:\n", "\n", "$H(t_i) = H_i$, where\n", "\n", @@ -61,7 +61,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": null, "id": "c9bdd8b5", "metadata": {}, "outputs": [], @@ -90,7 +90,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": null, "id": "3ef0a94f", "metadata": {}, "outputs": [], @@ -113,18 +113,10 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": null, "id": "3a876d78", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Noiseless ⟨ZZ⟩: 0.990234\n" - ] - } - ], + "outputs": [], "source": [ "circuit_executor_qdk = create(\"circuit_executor\", \"qdk_full_state_simulator\")\n", "\n", @@ -157,7 +149,7 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": null, "id": "56c5fc06", "metadata": {}, "outputs": [], @@ -190,18 +182,10 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": null, "id": "0742066a", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Noisy QDK ⟨ZZ⟩: 0.769531\n" - ] - } - ], + "outputs": [], "source": [ "measurements_noisy_qdk = algo.run(\n", " hamiltonians,\n", @@ -231,18 +215,10 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": null, "id": "cafb9c98", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Noisy Aer ⟨ZZ⟩: 0.710938\n" - ] - } - ], + "outputs": [], "source": [ "circuit_executor_aer = create(\"circuit_executor\", \"qiskit_aer_simulator\")\n", "\n", @@ -274,22 +250,10 @@ }, { "cell_type": "code", - "execution_count": 32, + "execution_count": null, "id": "8d7b777c", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Simulator ⟨ZZ⟩\n", - "────────────────────────────────────\n", - "QDK (noiseless) 0.990234\n", - "QDK (noisy) 0.769531\n", - "Aer (noisy) 0.710938\n" - ] - } - ], + "outputs": [], "source": [ "print(\"Simulator ⟨ZZ⟩\")\n", "print(\"─\" * 36)\n", diff --git a/python/pyproject.toml b/python/pyproject.toml index 4e5a53b14..676796ed2 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -36,8 +36,7 @@ dependencies = [ "ruamel-yaml>=0.18.10", "qdk[jupyter]>=1.26.0", "pybind11-stubgen>=2.5.1", - "h5py>=3.0.0", - "paulimer>=0.1.0" + "h5py>=3.0.0" ] description = "Quantum Development Kit - Chemistry Library" license = { file = "LICENSE.txt" } diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index c7d6b46b3..668810334 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -441,8 +441,8 @@ def _group_terms( coeffs = list(merged.values()) # Split into parallelizable layers (disjoint qubit supports). - # Each layer becomes its own sub-group so that every sub-group - # passed to encoding_clifford_of contains only independent generators. + # Each layer becomes its own sub-group consisting of terms whose + # supports are mutually disjoint, allowing them to be applied in parallel. pauli_maps = [self._pauli_label_to_map(label) for label in labels] layers_indices: list[list[int]] = [] layers_occupied: list[set[int]] = [] diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py index c9523531b..862c66104 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py @@ -84,6 +84,12 @@ def _run_impl( raise ValueError("times must not be empty.") if len(qubit_hamiltonians) != len(times): raise ValueError("qubit_hamiltonians and times must have the same length.") + if times != sorted(times): + raise ValueError("times must be monotonically increasing.") + for hamiltonian_1 in qubit_hamiltonians + observables: + for hamiltonian_2 in qubit_hamiltonians + observables: + if hamiltonian_1.num_qubits != hamiltonian_2.num_qubits: + raise ValueError("All Hamiltonians and observables must have the same number of qubits.") evolution = self._create_time_evolution(qubit_hamiltonians[0], times[0], evolution_builder) diff --git a/python/tests/test_time_evolution_circuit_mapper_noncontrolled.py b/python/tests/test_time_evolution_circuit_mapper_noncontrolled.py index 3f135dfcd..4fa2579e4 100644 --- a/python/tests/test_time_evolution_circuit_mapper_noncontrolled.py +++ b/python/tests/test_time_evolution_circuit_mapper_noncontrolled.py @@ -7,8 +7,10 @@ import json +import numpy as np import pytest import qsharp +import scipy from qdk_chemistry.algorithms.time_evolution.circuit_mapper.pauli_sequence_mapper import PauliSequenceMapper from qdk_chemistry.data.circuit import Circuit @@ -17,6 +19,12 @@ ExponentiatedPauliTerm, PauliProductFormulaContainer, ) +from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT + +from .reference_tolerances import float_comparison_absolute_tolerance, float_comparison_relative_tolerance + +if QDK_CHEMISTRY_HAS_QISKIT: + from qiskit.quantum_info import Operator @pytest.fixture @@ -55,3 +63,33 @@ def test_run_builds_regular_unitary_circuit(self, simple_unitary): qsc_json = json.loads(circuit.get_qsharp_circuit().json()) num_qubits = len(qsc_json["qubits"]) assert num_qubits == 2 + + @pytest.mark.skipif(not QDK_CHEMISTRY_HAS_QISKIT, reason="Qiskit not available.") + def test_unitary_circuit_matrix(self, simple_unitary): + """Test that the constructed unitary circuit has the expected matrix.""" + mapper = PauliSequenceMapper() + circuit = mapper.run(simple_unitary) + + container = simple_unitary.get_container() + angle_x = container.step_terms[0].angle + angle_z = container.step_terms[1].angle + + pauli_x = np.array([[0, 1], [1, 0]], dtype=complex) + pauli_z = np.array([[1, 0], [0, -1]], dtype=complex) + identity = np.eye(2, dtype=complex) + x_0 = np.kron(identity, pauli_x) + z_1 = np.kron(pauli_z, identity) + + # U = [exp(-i*angle_z*Z1) @ exp(-i*angle_x*X0)]^step_reps + u_step = scipy.linalg.expm(-1j * angle_z * z_1) @ scipy.linalg.expm(-1j * angle_x * x_0) + expected_matrix = np.linalg.matrix_power(u_step, container.step_reps) + + qc = circuit.get_qiskit_circuit() + actual_matrix = Operator(qc).data + + assert np.allclose( + actual_matrix, + expected_matrix, + atol=float_comparison_absolute_tolerance, + rtol=float_comparison_relative_tolerance, + ) diff --git a/python/tests/test_time_evolution_container.py b/python/tests/test_time_evolution_container.py index 1b197b221..0e15d8480 100644 --- a/python/tests/test_time_evolution_container.py +++ b/python/tests/test_time_evolution_container.py @@ -176,8 +176,8 @@ def test_combine_with_adjacent_identical(self): ) result = a.combine(b) - # a expanded: [Y(1.5), X(0.5), Y(1.5)], b expanded: [X(0.7), Z(1.5)] - # All three are adjacent identical → merged into one term with angle 1.7 + # a expanded: [Y(1.5), X(0.5), Y(1.5), X(0.5)], b expanded: [X(0.7), Z(1.5)] + # Only the two adjacent X terms at the boundary are merged into X(1.2) assert result.step_reps == 1 assert len(result.step_terms) == 5 diff --git a/python/tests/test_time_evolution_trotter.py b/python/tests/test_time_evolution_trotter.py index 09321a781..a7fefcfae 100644 --- a/python/tests/test_time_evolution_trotter.py +++ b/python/tests/test_time_evolution_trotter.py @@ -109,8 +109,8 @@ def test_single_step_merge_identical_terms(self): pauli_strings = ["XII", "IXI", "XII"] coefficients = [1.0, 1.0, 1.0] - # Scramble the order of the terms to ensure grouping works - perm = np.random.default_rng().permutation(len(pauli_strings)) + # Scramble the order of the terms to ensure grouping works, using a fixed seed + perm = np.random.default_rng(seed=0).permutation(len(pauli_strings)) pauli_strings = [pauli_strings[i] for i in perm] coefficients = [coefficients[i] for i in perm] From 7e2c8bd7e2eb52a85ca8e80511ba395c46915768 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Tue, 31 Mar 2026 22:23:53 +0000 Subject: [PATCH 018/117] pre-commit --- python/tests/test_time_evolution_circuit_mapper_noncontrolled.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/tests/test_time_evolution_circuit_mapper_noncontrolled.py b/python/tests/test_time_evolution_circuit_mapper_noncontrolled.py index 4fa2579e4..b65d753ed 100644 --- a/python/tests/test_time_evolution_circuit_mapper_noncontrolled.py +++ b/python/tests/test_time_evolution_circuit_mapper_noncontrolled.py @@ -80,7 +80,6 @@ def test_unitary_circuit_matrix(self, simple_unitary): x_0 = np.kron(identity, pauli_x) z_1 = np.kron(pauli_z, identity) - # U = [exp(-i*angle_z*Z1) @ exp(-i*angle_x*X0)]^step_reps u_step = scipy.linalg.expm(-1j * angle_z * z_1) @ scipy.linalg.expm(-1j * angle_x * x_0) expected_matrix = np.linalg.matrix_power(u_step, container.step_reps) From 3e2ce511d42c7cb6829143f3551b2a3fa8a63976 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Tue, 31 Mar 2026 23:05:09 +0000 Subject: [PATCH 019/117] more copilot comments --- python/src/qdk_chemistry/algorithms/registry.py | 6 ++++++ .../measure_simulation/evolve_and_measure.py | 1 - .../time_evolution/containers/pauli_product_formula.py | 9 +++++++-- .../src/qdk_chemistry/utils/qsharp/CircuitComposition.qs | 4 +++- python/tests/test_time_evolution_trotter.py | 6 +++--- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/registry.py b/python/src/qdk_chemistry/algorithms/registry.py index 84e072711..a610b129b 100644 --- a/python/src/qdk_chemistry/algorithms/registry.py +++ b/python/src/qdk_chemistry/algorithms/registry.py @@ -511,11 +511,17 @@ def _register_python_factories(): from qdk_chemistry.algorithms.qubit_mapper import QubitMapperFactory # noqa: PLC0415 from qdk_chemistry.algorithms.state_preparation import StatePreparationFactory # noqa: PLC0415 from qdk_chemistry.algorithms.time_evolution.builder import TimeEvolutionBuilderFactory # noqa: PLC0415 + from qdk_chemistry.algorithms.time_evolution.circuit_mapper import ( # noqa: PLC0415 + EvolutionCircuitMapperFactory, + ) from qdk_chemistry.algorithms.time_evolution.controlled_circuit_mapper import ( # noqa: PLC0415 ControlledEvolutionCircuitMapperFactory, ) + from qdk_chemistry.algorithms.time_evolution.measure_simulation import MeasureSimulationFactory # noqa: PLC0415 register_factory(EnergyEstimatorFactory()) + register_factory(EvolutionCircuitMapperFactory()) + register_factory(MeasureSimulationFactory()) register_factory(StatePreparationFactory()) register_factory(QubitMapperFactory()) register_factory(QubitHamiltonianSolverFactory()) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py index 862c66104..5e4c1403a 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py @@ -101,7 +101,6 @@ def _run_impl( evolution = TimeEvolutionUnitary( evolution.get_container().combine( self._create_time_evolution(qubit_hamiltonian, delta_t, evolution_builder).get_container(), - evolution_builder.settings().get("weight_threshold"), ) ) diff --git a/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py b/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py index 05282643d..f6850ef1f 100644 --- a/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py +++ b/python/src/qdk_chemistry/data/time_evolution/containers/pauli_product_formula.py @@ -146,7 +146,12 @@ def combine(self, other_container: "PauliProductFormulaContainer", atol=1e-12) - evolution with adjacent identical terms fused. """ - num_qubits = max(self.num_qubits, other_container.num_qubits) + if self.num_qubits != other_container.num_qubits: + raise ValueError( + f"Cannot combine PauliProductFormulaContainer instances with different " + f"num_qubits (self.num_qubits={self.num_qubits}, " + f"other_container.num_qubits={other_container.num_qubits})." + ) merged: list[ExponentiatedPauliTerm] = [] for step_terms, step_reps in ( @@ -163,7 +168,7 @@ def combine(self, other_container: "PauliProductFormulaContainer", atol=1e-12) - merged.pop() else: merged.append(term) - return PauliProductFormulaContainer(step_terms=merged, step_reps=1, num_qubits=num_qubits) + return PauliProductFormulaContainer(step_terms=merged, step_reps=1, num_qubits=self.num_qubits) def to_json(self) -> dict[str, Any]: """Convert the PauliProductFormulaContainer to a dictionary for JSON serialization. diff --git a/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs b/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs index 7f5cdebff..fb572f550 100644 --- a/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs +++ b/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs @@ -4,6 +4,8 @@ namespace QDKChemistry.Utils.CircuitComposition { + import Std.Arrays.Subarray; + /// Applies two operations sequentially on the same system register. operation ApplySequential( first : Qubit[] => Unit, @@ -26,6 +28,6 @@ namespace QDKChemistry.Utils.CircuitComposition { targets : Int[] ) : Unit { use qs = Qubit[Length(targets)]; - ApplySequential(first, second, qs); + ApplySequential(first, second, Subarray(targets, qs)); } } diff --git a/python/tests/test_time_evolution_trotter.py b/python/tests/test_time_evolution_trotter.py index a7fefcfae..e2446f51b 100644 --- a/python/tests/test_time_evolution_trotter.py +++ b/python/tests/test_time_evolution_trotter.py @@ -875,9 +875,9 @@ def test_optimize_term_ordering_does_nothing_when_false(self): t = 1.0 terms = builder.run(hamiltonian, time=t).get_container().step_terms - for term in terms: - assert term.pauli_term == builder._pauli_label_to_map(pauli_strings[terms.index(term)]) - assert term.angle == coefficients[terms.index(term)] * t + for idx, term in enumerate(terms): + assert term.pauli_term == builder._pauli_label_to_map(pauli_strings[idx]) + assert term.angle == coefficients[idx] * t def test_optimize_term_ordering_groups_when_true(self): """Test that optimize_term_ordering groups commuting terms into parallelizable layers.""" From 11972949f9cedfda4db2df8a5520b0c1808d2cc7 Mon Sep 17 00:00:00 2001 From: v-agamshayit Date: Tue, 31 Mar 2026 16:22:48 -0700 Subject: [PATCH 020/117] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../time_evolution/measure_simulation/base.py | 23 ++++++++++++------- .../measure_simulation/evolve_and_measure.py | 12 ++++++---- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py index e1b96e78c..9d5e0a19c 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py @@ -176,14 +176,21 @@ def _transpile_to_basis_gates(circuit: Circuit, basis_gates: list[str]) -> Circu A new ``Circuit`` restricted to the requested basis gates. """ - from qiskit import qasm3, transpile # noqa: PLC0415 - from qiskit.transpiler import PassManager # noqa: PLC0415 - - from qdk_chemistry.plugins.qiskit._interop.transpiler import ( # noqa: PLC0415 - MergeZBasisRotations, - RemoveZBasisOnZeroState, - SubstituteCliffordRz, - ) + try: + from qiskit import qasm3, transpile # noqa: PLC0415 + from qiskit.transpiler import PassManager # noqa: PLC0415 + + from qdk_chemistry.plugins.qiskit._interop.transpiler import ( # noqa: PLC0415 + MergeZBasisRotations, + RemoveZBasisOnZeroState, + SubstituteCliffordRz, + ) + except ImportError as exc: # noqa: PLC0415 + raise RuntimeError( + "Qiskit is required to transpile circuits to the requested basis_gates, " + "but it is not installed. Please install the 'qiskit' package to use " + "the basis_gates option." + ) from exc qc = circuit.get_qiskit_circuit() qc = transpile(qc, basis_gates=basis_gates, optimization_level=3) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py index 5e4c1403a..e44d0c381 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py @@ -86,11 +86,15 @@ def _run_impl( raise ValueError("qubit_hamiltonians and times must have the same length.") if times != sorted(times): raise ValueError("times must be monotonically increasing.") - for hamiltonian_1 in qubit_hamiltonians + observables: - for hamiltonian_2 in qubit_hamiltonians + observables: - if hamiltonian_1.num_qubits != hamiltonian_2.num_qubits: - raise ValueError("All Hamiltonians and observables must have the same number of qubits.") + # Ensure all Hamiltonians and observables have the same number of qubits. + reference_num_qubits = qubit_hamiltonians[0].num_qubits + for hamiltonian in qubit_hamiltonians[1:]: + if hamiltonian.num_qubits != reference_num_qubits: + raise ValueError("All Hamiltonians and observables must have the same number of qubits.") + for observable in observables: + if observable.num_qubits != reference_num_qubits: + raise ValueError("All Hamiltonians and observables must have the same number of qubits.") evolution = self._create_time_evolution(qubit_hamiltonians[0], times[0], evolution_builder) for i in range(1, len(qubit_hamiltonians)): From 6d86368e8bad4620ed6b2c258702b7e3b0d65b28 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Tue, 31 Mar 2026 23:27:58 +0000 Subject: [PATCH 021/117] ran pre-commit --- .../algorithms/time_evolution/measure_simulation/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py index 9d5e0a19c..e75dec82e 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py @@ -185,7 +185,7 @@ def _transpile_to_basis_gates(circuit: Circuit, basis_gates: list[str]) -> Circu RemoveZBasisOnZeroState, SubstituteCliffordRz, ) - except ImportError as exc: # noqa: PLC0415 + except ImportError as exc: raise RuntimeError( "Qiskit is required to transpile circuits to the requested basis_gates, " "but it is not installed. Please install the 'qiskit' package to use " From b199a79143ed1166abe4de7bd147bfa300e29be4 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 1 Apr 2026 16:48:20 +0000 Subject: [PATCH 022/117] added new algorithms to registry --- examples/time_evolve_and_measure.ipynb | 9 ++++----- python/src/qdk_chemistry/algorithms/registry.py | 10 ++++++++-- .../time_evolution/measure_simulation/base.py | 4 ++-- .../measure_simulation/evolve_and_measure.py | 4 ++-- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/examples/time_evolve_and_measure.ipynb b/examples/time_evolve_and_measure.ipynb index a9e62cc71..22ff51dbf 100644 --- a/examples/time_evolve_and_measure.ipynb +++ b/examples/time_evolve_and_measure.ipynb @@ -28,7 +28,6 @@ "from qdk_chemistry.algorithms import create\n", "from qdk_chemistry.algorithms.time_evolution.builder.trotter import Trotter\n", "from qdk_chemistry.algorithms.time_evolution.circuit_mapper import PauliSequenceMapper\n", - "from qdk_chemistry.algorithms.time_evolution.measure_simulation import EvolveAndMeasure\n", "from qdk_chemistry.data import LatticeGraph, QuantumErrorProfile, QubitHamiltonian\n", "from qdk_chemistry.utils.model_hamiltonians import create_ising_hamiltonian\n", "\n", @@ -98,7 +97,7 @@ "evolution_builder = Trotter(num_divisions=1, order=1, optimize_term_ordering=True)\n", "mapper = PauliSequenceMapper()\n", "energy_estimator = create(\"energy_estimator\", \"qdk\")\n", - "algo = EvolveAndMeasure()" + "measure_simulation = create(\"measure_simulation\", \"classical_sampling\")" ] }, { @@ -120,7 +119,7 @@ "source": [ "circuit_executor_qdk = create(\"circuit_executor\", \"qdk_full_state_simulator\")\n", "\n", - "measurements_noiseless = algo.run(\n", + "measurements_noiseless = measure_simulation.run(\n", " hamiltonians,\n", " times=time_steps,\n", " observables=[observable],\n", @@ -187,7 +186,7 @@ "metadata": {}, "outputs": [], "source": [ - "measurements_noisy_qdk = algo.run(\n", + "measurements_noisy_qdk = measure_simulation.run(\n", " hamiltonians,\n", " times=time_steps,\n", " observables=[observable],\n", @@ -222,7 +221,7 @@ "source": [ "circuit_executor_aer = create(\"circuit_executor\", \"qiskit_aer_simulator\")\n", "\n", - "measurements_noisy_aer = algo.run(\n", + "measurements_noisy_aer = measure_simulation.run(\n", " hamiltonians,\n", " times=time_steps,\n", " observables=[observable],\n", diff --git a/python/src/qdk_chemistry/algorithms/registry.py b/python/src/qdk_chemistry/algorithms/registry.py index a610b129b..079cfae20 100644 --- a/python/src/qdk_chemistry/algorithms/registry.py +++ b/python/src/qdk_chemistry/algorithms/registry.py @@ -600,9 +600,13 @@ def _register_python_algorithms(): from qdk_chemistry.algorithms.time_evolution.builder.trotter import ( # noqa: PLC0415 Trotter, ) + from qdk_chemistry.algorithms.time_evolution.circuit_mapper import ( # noqa: PLC0415 + PauliSequenceMapper as EvolutionPauliSequenceMapper, + ) from qdk_chemistry.algorithms.time_evolution.controlled_circuit_mapper import ( # noqa: PLC0415 - PauliSequenceMapper, + PauliSequenceMapper as ControlledPauliSequenceMapper, ) + from qdk_chemistry.algorithms.time_evolution.measure_simulation import EvolveAndMeasure # noqa: PLC0415 register(lambda: QdkEnergyEstimator()) register(lambda: SparseIsometryGF2XStatePreparation()) @@ -612,7 +616,9 @@ def _register_python_algorithms(): register(lambda: Trotter()) register(lambda: QDrift()) register(lambda: PartiallyRandomized()) - register(lambda: PauliSequenceMapper()) + register(lambda: EvolutionPauliSequenceMapper()) + register(lambda: ControlledPauliSequenceMapper()) + register(lambda: EvolveAndMeasure()) register(lambda: QdkFullStateSimulator()) register(lambda: QdkSparseStateSimulator()) register(lambda: IterativePhaseEstimation()) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py index e75dec82e..1f64cbc2c 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py @@ -238,5 +238,5 @@ def algorithm_type_name(self) -> str: return "measure_simulation" def default_algorithm_name(self) -> str: - """Return evolve_and_measure as the default algorithm name.""" - return "evolve_and_measure" + """Return classical sampling as the default algorithm name.""" + return "classical_sampling" diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py index e44d0c381..87e33f58d 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py @@ -132,5 +132,5 @@ def _run_impl( return measurements def name(self) -> str: - """Return the algorithm name used for registry.""" - return "evolve_and_measure" + """Return ``classical_sampling`` as the algorithm type name.""" + return "classical_sampling" From 6033504101c09c0b58e7085b338a1c8e0e3785df Mon Sep 17 00:00:00 2001 From: v-agamshayit Date: Wed, 1 Apr 2026 12:30:34 -0700 Subject: [PATCH 023/117] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../measure_simulation/evolve_and_measure.py | 2 +- .../utils/qsharp/CircuitComposition.qs | 25 +++++++++++++++++-- .../qdk_chemistry/utils/qsharp/PauliExp.qs | 17 ++++++++++++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py index 87e33f58d..ae48b9ad3 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py @@ -132,5 +132,5 @@ def _run_impl( return measurements def name(self) -> str: - """Return ``classical_sampling`` as the algorithm type name.""" + """Return ``classical_sampling`` as the algorithm name.""" return "classical_sampling" diff --git a/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs b/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs index fb572f550..7c21bddf1 100644 --- a/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs +++ b/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs @@ -21,13 +21,34 @@ namespace QDKChemistry.Utils.CircuitComposition { ApplySequential(first, second, _) } + /// Returns the maximum element of the given array of integers. + function MaxInt(values : Int[]) : Int { + // Caller is responsible for not passing an empty array. + mutable max = values[0]; + for idx in 1 .. Length(values) - 1 { + let value = values[idx]; + if (value > max) { + set max = value; + } + } + return max; + } + /// Creates a circuit for sequentially applying two operations on the same target qubits. operation MakeSequentialCircuit( first : Qubit[] => Unit, second : Qubit[] => Unit, targets : Int[] ) : Unit { - use qs = Qubit[Length(targets)]; - ApplySequential(first, second, Subarray(targets, qs)); + if (Length(targets) == 0) { + // No target indices: allocate an empty register and do nothing. + use qs = Qubit[0]; + ApplySequential(first, second, qs); + } else { + // Allocate enough qubits so that all indices in 'targets' are valid. + let maxTarget = MaxInt(targets); + use qs = Qubit[1 + maxTarget]; + ApplySequential(first, second, Subarray(targets, qs)); + } } } diff --git a/python/src/qdk_chemistry/utils/qsharp/PauliExp.qs b/python/src/qdk_chemistry/utils/qsharp/PauliExp.qs index ae968f19f..c9ef598f6 100644 --- a/python/src/qdk_chemistry/utils/qsharp/PauliExp.qs +++ b/python/src/qdk_chemistry/utils/qsharp/PauliExp.qs @@ -62,7 +62,22 @@ namespace QDKChemistry.Utils.PauliExp { params : RepPauliExpParams, system : Int[], ) : Unit { - use qs = Qubit[Length(system)]; + // If no system indices are provided, there is nothing to do. + if Length(system) == 0 { + return (); + } + + // Determine the maximum index in the system array to size the qubit register safely. + mutable maxIndex = system[0]; + for idx in 1..Length(system) - 1 { + let current = system[idx]; + if current > maxIndex { + set maxIndex = current; + } + } + + // Allocate enough qubits so that all indices in `system` are valid. + use qs = Qubit[maxIndex + 1]; RepPauliExp(params, Subarray(system, qs)); } From f592b199e557a2a86bef0aff52715fd53a9baa4e Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 1 Apr 2026 19:55:08 +0000 Subject: [PATCH 024/117] more copilot comments, added exact exponentiation to example for reference --- examples/time_evolve_and_measure.ipynb | 60 ++++++++++++++++--- .../time_evolution/builder/trotter.py | 10 ++-- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/examples/time_evolve_and_measure.ipynb b/examples/time_evolve_and_measure.ipynb index 22ff51dbf..acc48625d 100644 --- a/examples/time_evolve_and_measure.ipynb +++ b/examples/time_evolve_and_measure.ipynb @@ -79,12 +79,55 @@ }, { "cell_type": "markdown", - "id": "57064b4c", + "id": "d1affab1", "metadata": {}, "source": [ - "## Configure the algorithm components\n", + "## Exact evolution via matrix exponential\n", + "\n", + "Convert the Hamiltonians to sparse matrices and compute the exact time evolution $|\\psi(t)\\rangle = \\prod_i e^{-i H_i \\Delta t_i} |\\psi_0\\rangle$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a58bb629", + "metadata": {}, + "outputs": [], + "source": [ + "from scipy.sparse.linalg import expm_multiply\n", + "\n", + "# Convert Hamiltonians to sparse matrices\n", + "H_p = hamiltonian_p.to_matrix(sparse=True)\n", + "H_m = hamiltonian_m.to_matrix(sparse=True)\n", + "\n", + "num_qubits = H_p.shape[0].bit_length() - 1\n", + "\n", + "# Initial state |00...0⟩\n", + "psi = np.zeros(2**num_qubits, dtype=complex)\n", + "psi[0] = 1.0\n", "\n", - "Set up the Trotter builder, circuit mapper, energy estimator, and the `EvolveAndMeasure` algorithm." + "# Time-evolve: apply exp(-i H_i dt_i) for each step\n", + "dt_list = [time_steps[0]] + [time_steps[i] - time_steps[i - 1] for i in range(1, len(time_steps))]\n", + "\n", + "for ham, dt in zip(hamiltonians, dt_list):\n", + " H_sparse = H_p if ham is hamiltonian_p else H_m\n", + " psi = expm_multiply(-1j * H_sparse * dt, psi)\n", + "\n", + "# Compute exact ⟨ZZ⟩\n", + "Z = np.array([1, -1], dtype=complex)\n", + "ZZ_diag = np.kron(Z, Z)\n", + "zz_exact = np.real(np.conj(psi) @ (ZZ_diag * psi))\n", + "\n", + "print(f\"Exact ⟨ZZ⟩: {zz_exact:.6f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "57064b4c", + "metadata": {}, + "source": [ + "## Noiseless simulation (QDK full-state simulator)\n", + "Set up the Trotter builder, circuit mapper, energy estimator, and the `measure_simulation` algorithm." ] }, { @@ -94,7 +137,7 @@ "metadata": {}, "outputs": [], "source": [ - "evolution_builder = Trotter(num_divisions=1, order=1, optimize_term_ordering=True)\n", + "evolution_builder = Trotter(num_divisions=2, order=1, optimize_term_ordering=True)\n", "mapper = PauliSequenceMapper()\n", "energy_estimator = create(\"energy_estimator\", \"qdk\")\n", "measure_simulation = create(\"measure_simulation\", \"classical_sampling\")" @@ -105,8 +148,6 @@ "id": "2ca695ec", "metadata": {}, "source": [ - "## Noiseless simulation (QDK full-state simulator)\n", - "\n", "Run the evolution and measure `⟨ZZ⟩` without any noise using the QDK full-state simulator." ] }, @@ -127,7 +168,7 @@ " circuit_mapper=mapper,\n", " circuit_executor=circuit_executor_qdk,\n", " energy_estimator=energy_estimator,\n", - " shots=1024,\n", + " shots=10000,\n", ")\n", "\n", "zz_noiseless = measurements_noiseless[0][0].energy_expectation_value\n", @@ -194,7 +235,7 @@ " circuit_mapper=mapper,\n", " circuit_executor=circuit_executor_qdk,\n", " energy_estimator=energy_estimator,\n", - " shots=1024,\n", + " shots=10000,\n", " noise=qdk_error_profile,\n", ")\n", "\n", @@ -229,7 +270,7 @@ " circuit_mapper=mapper,\n", " circuit_executor=circuit_executor_aer,\n", " energy_estimator=energy_estimator,\n", - " shots=1024,\n", + " shots=10000,\n", " noise=qdk_error_profile,\n", ")\n", "\n", @@ -256,6 +297,7 @@ "source": [ "print(\"Simulator ⟨ZZ⟩\")\n", "print(\"─\" * 36)\n", + "print(f\"Exact {zz_exact: .6f}\")\n", "print(f\"QDK (noiseless) {zz_noiseless: .6f}\")\n", "print(f\"QDK (noisy) {zz_noisy_qdk: .6f}\")\n", "print(f\"Aer (noisy) {zz_noisy_aer: .6f}\")" diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index 668810334..3cdc9f21e 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -18,6 +18,8 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import numpy as np + from qdk_chemistry.algorithms.time_evolution.builder.base import TimeEvolutionBuilder from qdk_chemistry.algorithms.time_evolution.builder.trotter_error import ( trotter_steps_commutator, @@ -424,8 +426,8 @@ def _group_terms( num_non_identity = [sum(c != "I" for c in ps) for ps in qubit_hamiltonian.pauli_strings] sorted_indices = sorted(range(len(num_non_identity)), key=lambda i: num_non_identity[i], reverse=True) qubit_hamiltonian = QubitHamiltonian( - pauli_strings=[qubit_hamiltonian.pauli_strings[i] for i in sorted_indices], - coefficients=[qubit_hamiltonian.coefficients[i] for i in sorted_indices], + pauli_strings=np.asarray([qubit_hamiltonian.pauli_strings[i] for i in sorted_indices]), + coefficients=np.asarray([qubit_hamiltonian.coefficients[i] for i in sorted_indices]), encoding=qubit_hamiltonian.encoding, ) @@ -463,8 +465,8 @@ def _group_terms( for layer in layers_indices: outer_group.append( QubitHamiltonian( - pauli_strings=[labels[i] for i in layer], - coefficients=[coeffs[i] for i in layer], + pauli_strings=np.asarray([labels[i] for i in layer]), + coefficients=np.asarray([coeffs[i] for i in layer]), encoding=sub_h.encoding, ) ) From 18b5f08824520833f05b232c5235f9dc9ef8ba69 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 1 Apr 2026 21:08:25 +0000 Subject: [PATCH 025/117] fixed test --- .../algorithms/time_evolution/builder/trotter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index 3cdc9f21e..2dbf38b84 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -426,7 +426,7 @@ def _group_terms( num_non_identity = [sum(c != "I" for c in ps) for ps in qubit_hamiltonian.pauli_strings] sorted_indices = sorted(range(len(num_non_identity)), key=lambda i: num_non_identity[i], reverse=True) qubit_hamiltonian = QubitHamiltonian( - pauli_strings=np.asarray([qubit_hamiltonian.pauli_strings[i] for i in sorted_indices]), + pauli_strings=[qubit_hamiltonian.pauli_strings[i] for i in sorted_indices], coefficients=np.asarray([qubit_hamiltonian.coefficients[i] for i in sorted_indices]), encoding=qubit_hamiltonian.encoding, ) @@ -465,7 +465,7 @@ def _group_terms( for layer in layers_indices: outer_group.append( QubitHamiltonian( - pauli_strings=np.asarray([labels[i] for i in layer]), + pauli_strings=[labels[i] for i in layer], coefficients=np.asarray([coeffs[i] for i in layer]), encoding=sub_h.encoding, ) From 4b77439cdb26aa1ab0457e518222bb77ba869459 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 1 Apr 2026 21:24:12 +0000 Subject: [PATCH 026/117] added Trotter docstring --- .../qdk_chemistry/algorithms/time_evolution/builder/trotter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index 2dbf38b84..f1e1dcd5d 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -47,6 +47,7 @@ def __init__(self): num_divisions: Explicit number of divisions within a Trotter step (0 means automatic). error_bound: Strategy for computing the Trotter error bound ("commutator" or "naive"). weight_threshold: The absolute threshold for filtering small coefficients. + optimize_term_ordering: Whether to group commuting terms and execute them in parallel. """ super().__init__() From cb00a69db3cc1521dfc5f840dc03400f07c39c7b Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Tue, 7 Apr 2026 23:02:37 +0000 Subject: [PATCH 027/117] added ability to transpile to IBM fake backends --- .../plugins/qiskit/circuit_executor.py | 62 ++++++++++++++----- .../test_interop_qiskit_circuit_executor.py | 9 +++ 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py index b9f466a4f..3a7bb2dcd 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py +++ b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py @@ -10,7 +10,11 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +from __future__ import annotations + +import qiskit_ibm_runtime.fake_provider from qiskit import transpile +from qiskit.providers.exceptions import QiskitBackendNotFoundError from qiskit_aer import AerSimulator from qiskit_aer.noise import NoiseModel @@ -70,6 +74,7 @@ def _run_impl( circuit: Circuit, shots: int, noise: QuantumErrorProfile | None = None, + device_backend_name: str | None = None, ) -> CircuitExecutorData: """Execute the given quantum circuit using the Qiskit Aer Simulator. @@ -85,25 +90,54 @@ def _run_impl( Logger.trace_entering() meas_circuit = circuit.get_qiskit_circuit() Logger.debug("Qiskit QuantumCircuit loaded.") - noise_model = get_noise_model_from_profile(noise) if noise else None - backend = AerSimulator( - method=self._settings.get("method"), - seed_simulator=self._settings.get("seed"), - noise_model=noise_model, - ) - if noise_model: + if noise is not None and device_backend_name is not None: + raise ValueError("Cannot specify both a noise model and a device backend. Please choose one or the other.") + + opt_level = self._settings.get("transpile_optimization_level") + + if device_backend_name is not None: + provider = qiskit_ibm_runtime.fake_provider.FakeProviderForBackendV2() + try: + device_backend = provider.backend(device_backend_name) + except QiskitBackendNotFoundError: + available = [b.name for b in provider.backends()] + raise ValueError( + f"Unknown device backend '{device_backend_name}'. " + f"Available backends: {available}" + ) from None + + backend = AerSimulator.from_backend(device_backend) + backend.set_options( + method=self._settings.get("method"), + seed_simulator=self._settings.get("seed"), + ) + transpiled_circuit = transpile( meas_circuit, - basis_gates=noise_model.basis_gates, - optimization_level=self._settings.get("transpile_optimization_level"), + backend=device_backend, + optimization_level=opt_level, ) + else: - # Use qiskit_aer NoiseModel() default basis gates if no noise model is provided - transpiled_circuit = transpile( - meas_circuit, - basis_gates=NoiseModel().basis_gates, - optimization_level=self._settings.get("transpile_optimization_level"), + noise_model = get_noise_model_from_profile(noise) if noise else None + backend = AerSimulator( + method=self._settings.get("method"), + seed_simulator=self._settings.get("seed"), + noise_model=noise_model, ) + if noise_model: + transpiled_circuit = transpile( + meas_circuit, + basis_gates=noise_model.basis_gates, + optimization_level=opt_level, + ) + else: + # Use qiskit_aer NoiseModel() default basis gates if no noise model is provided + transpiled_circuit = transpile( + meas_circuit, + basis_gates=NoiseModel().basis_gates, + optimization_level=opt_level, + ) raw_results = backend.run(transpiled_circuit, shots=shots).result() counts = raw_results.get_counts() Logger.debug(f"Measurement results obtained: {counts}") diff --git a/python/tests/test_interop_qiskit_circuit_executor.py b/python/tests/test_interop_qiskit_circuit_executor.py index 55102288d..dc4d69428 100644 --- a/python/tests/test_interop_qiskit_circuit_executor.py +++ b/python/tests/test_interop_qiskit_circuit_executor.py @@ -88,3 +88,12 @@ def test_circuit_executor_with_error_profile( assert counts.get("00", 0) > 0 assert counts.get("11", 0) > 0 assert counts.get("01", 0) + counts.get("10", 0) > 0 # Expect some errors to occur + + def test_circuit_executor_with_device_backend(self, test_circuit_2: Circuit): + """Test the Qiskit Aer circuit executor with a device backend name string.""" + executor = QiskitAerSimulator() + result = executor.run(test_circuit_2, shots=100, device_backend_name="fake_manila") + counts = result.bitstring_counts + # Circuit applies X on q[0], so only "01" (little-endian) should appear + assert counts.get("01", 0) > 0 + assert result.total_shots == 100 From 78b2b4b25e7904735790f4d55b73f69cc387a014 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Tue, 7 Apr 2026 23:09:32 +0000 Subject: [PATCH 028/117] pre-commit --- python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py index 3a7bb2dcd..bc7447e99 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py +++ b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py @@ -82,6 +82,7 @@ def _run_impl( circuit: The quantum circuit to execute. shots: The number of shots to execute the circuit. noise: Optional noise profile to apply during execution. + device_backend_name: Optional name of a fake device backend to use for noise modeling. Returns: CircuitExecutorData: Object containing the results of the circuit execution. @@ -102,8 +103,7 @@ def _run_impl( except QiskitBackendNotFoundError: available = [b.name for b in provider.backends()] raise ValueError( - f"Unknown device backend '{device_backend_name}'. " - f"Available backends: {available}" + f"Unknown device backend '{device_backend_name}'. Available backends: {available}" ) from None backend = AerSimulator.from_backend(device_backend) From 52739423a30c7c39303aeee63abfebb91672bc04 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Tue, 7 Apr 2026 21:49:28 +0000 Subject: [PATCH 029/117] added clifford factorization from Rz passes Added device_backend_name to energy_estimator.py --- python/src/qdk_chemistry/_core/__init__.pyi | 1 - .../src/qdk_chemistry/_core/_algorithms.pyi | 1 - python/src/qdk_chemistry/_core/constants.pyi | 1 - python/src/qdk_chemistry/_core/data.pyi | 1 - python/src/qdk_chemistry/_core/utils.pyi | 1 - .../energy_estimator/energy_estimator.py | 2 + .../algorithms/energy_estimator/qdk.py | 18 +- .../iterative_phase_estimation.py | 2 +- .../src/qdk_chemistry/algorithms/registry.pyi | 461 +++++++++++++++++- .../time_evolution/builder/trotter.py | 94 ++++ .../time_evolution/measure_simulation/base.py | 100 ++-- .../measure_simulation/evolve_and_measure.py | 84 +++- .../plugins/qiskit/_interop/transpiler.py | 220 ++++++++- .../plugins/qiskit/circuit_executor.py | 89 +++- python/tests/test_evolve_and_measure.py | 80 +-- .../tests/test_interop_qiskit_transpiler.py | 222 ++++++++- 16 files changed, 1249 insertions(+), 128 deletions(-) delete mode 100644 python/src/qdk_chemistry/_core/__init__.pyi delete mode 100644 python/src/qdk_chemistry/_core/_algorithms.pyi delete mode 100644 python/src/qdk_chemistry/_core/constants.pyi delete mode 100644 python/src/qdk_chemistry/_core/data.pyi delete mode 100644 python/src/qdk_chemistry/_core/utils.pyi diff --git a/python/src/qdk_chemistry/_core/__init__.pyi b/python/src/qdk_chemistry/_core/__init__.pyi deleted file mode 100644 index dcb38eb2c..000000000 --- a/python/src/qdk_chemistry/_core/__init__.pyi +++ /dev/null @@ -1 +0,0 @@ -# This file is a placeholder and will be replaced with generated stubs on first import diff --git a/python/src/qdk_chemistry/_core/_algorithms.pyi b/python/src/qdk_chemistry/_core/_algorithms.pyi deleted file mode 100644 index dcb38eb2c..000000000 --- a/python/src/qdk_chemistry/_core/_algorithms.pyi +++ /dev/null @@ -1 +0,0 @@ -# This file is a placeholder and will be replaced with generated stubs on first import diff --git a/python/src/qdk_chemistry/_core/constants.pyi b/python/src/qdk_chemistry/_core/constants.pyi deleted file mode 100644 index dcb38eb2c..000000000 --- a/python/src/qdk_chemistry/_core/constants.pyi +++ /dev/null @@ -1 +0,0 @@ -# This file is a placeholder and will be replaced with generated stubs on first import diff --git a/python/src/qdk_chemistry/_core/data.pyi b/python/src/qdk_chemistry/_core/data.pyi deleted file mode 100644 index dcb38eb2c..000000000 --- a/python/src/qdk_chemistry/_core/data.pyi +++ /dev/null @@ -1 +0,0 @@ -# This file is a placeholder and will be replaced with generated stubs on first import diff --git a/python/src/qdk_chemistry/_core/utils.pyi b/python/src/qdk_chemistry/_core/utils.pyi deleted file mode 100644 index dcb38eb2c..000000000 --- a/python/src/qdk_chemistry/_core/utils.pyi +++ /dev/null @@ -1 +0,0 @@ -# This file is a placeholder and will be replaced with generated stubs on first import diff --git a/python/src/qdk_chemistry/algorithms/energy_estimator/energy_estimator.py b/python/src/qdk_chemistry/algorithms/energy_estimator/energy_estimator.py index 36ff2bc23..f1f300387 100644 --- a/python/src/qdk_chemistry/algorithms/energy_estimator/energy_estimator.py +++ b/python/src/qdk_chemistry/algorithms/energy_estimator/energy_estimator.py @@ -39,6 +39,7 @@ def _run_impl( circuit_executor: CircuitExecutor, total_shots: int, noise_model: QuantumErrorProfile | None = None, + device_backend_name: str | None = None, ) -> tuple[EnergyExpectationResult, MeasurementData]: """Estimate the expectation value and variance of the Hamiltonian. @@ -48,6 +49,7 @@ def _run_impl( circuit_executor: An instance of ``CircuitExecutor`` to run quantum circuits. total_shots: Total number of shots to allocate across the observable terms. noise_model: Optional noise model to simulate noise in the quantum circuit. + device_backend_name: Optional device backend name string to pass to the circuit executor. Returns: tuple[EnergyExpectationResult, MeasurementData]: Tuple containing: diff --git a/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py b/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py index 6f089284c..15c889ff4 100644 --- a/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py +++ b/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py @@ -189,6 +189,7 @@ def _run_impl( circuit_executor: CircuitExecutor, total_shots: int, noise_model: QuantumErrorProfile | None = None, + device_backend_name: str | None = None, ) -> tuple[EnergyExpectationResult, MeasurementData]: """Estimate the expectation value and variance of the Hamiltonian. @@ -214,6 +215,11 @@ def _run_impl( # This function definition is not required it is present to add type hints and docstrings # for the derived classes specialized run() method. Logger.trace_entering() + if device_backend_name is not None and circuit_executor.name() != "qiskit_aer_simulator": + raise ValueError( + f"device_backend_name is only supported with 'qiskit_aer_simulator', " + f"but circuit_executor is '{circuit_executor.name()}'." + ) qubit_hamiltonians = qubit_hamiltonian.group_commuting(qubit_wise=True) num_observables = len(qubit_hamiltonians) if total_shots < num_observables: @@ -238,6 +244,7 @@ def _run_impl( circuit_executor=circuit_executor, shots_list=shots_list, noise_model=noise_model, + device_backend_name=device_backend_name, ) return self._compute_energy_expectation_from_bitstrings( @@ -315,6 +322,7 @@ def _run_measurement_circuits_and_get_bitstring_counts( circuit_executor: CircuitExecutor, shots_list: list[int], noise_model: QuantumErrorProfile | None = None, + device_backend_name: str | None = None, ) -> list[dict[str, int]]: """Run the measurement circuits and return the bitstring counts. @@ -323,6 +331,7 @@ def _run_measurement_circuits_and_get_bitstring_counts( circuit_executor: An instance of CircuitExecutor to run the circuits. shots_list: A list of shots allocated for each measurement circuit. noise_model: Optional noise model to simulate noise in the quantum circuit. + device_backend_name: Optional device backend name string to pass to the circuit executor. Returns: A list of dictionaries containing the bitstring counts for each measurement circuit. @@ -330,10 +339,13 @@ def _run_measurement_circuits_and_get_bitstring_counts( """ all_bitstring_counts: list[dict[str, int]] = [] for circuit, shots in zip(measurement_circuits, shots_list, strict=True): + run_kwargs: dict = {"noise": noise_model} + if device_backend_name is not None: + run_kwargs["device_backend_name"] = device_backend_name result = circuit_executor.run( circuit, shots=shots, - noise=noise_model, + **run_kwargs, ) all_bitstring_counts.append(result.bitstring_counts if result and result.bitstring_counts else {}) return all_bitstring_counts @@ -345,6 +357,7 @@ def _get_measurement_data( circuit_executor: CircuitExecutor, shots_list: list[int], noise_model: QuantumErrorProfile | None = None, + device_backend_name: str | None = None, ) -> MeasurementData: """Get ``MeasurementData`` from running measurement circuits. @@ -354,13 +367,14 @@ def _get_measurement_data( circuit_executor: An instance of ``CircuitExecutor`` to run the circuits. shots_list: A list of shots allocated for each measurement circuit. noise_model: Optional noise model to simulate noise in the quantum circuit. + device_backend_name: Optional device backend name string to pass to the circuit executor. Returns: MeasurementData: Measurement counts paired with their corresponding ``QubitHamiltonian`` objects. """ counts = self._run_measurement_circuits_and_get_bitstring_counts( - measurement_circuits, circuit_executor, shots_list, noise_model + measurement_circuits, circuit_executor, shots_list, noise_model, device_backend_name ) return MeasurementData( bitstring_counts=counts, diff --git a/python/src/qdk_chemistry/algorithms/phase_estimation/iterative_phase_estimation.py b/python/src/qdk_chemistry/algorithms/phase_estimation/iterative_phase_estimation.py index 9e035c334..e6c5cbd96 100644 --- a/python/src/qdk_chemistry/algorithms/phase_estimation/iterative_phase_estimation.py +++ b/python/src/qdk_chemistry/algorithms/phase_estimation/iterative_phase_estimation.py @@ -194,7 +194,7 @@ def create_iteration_circuit( state_preparation, ctrl_evol_circuit, phase_correction, num_system_qubits ) - if state_preparation.get_qiskit_circuit() and ctrl_evol_circuit.get_qiskit_circuit(): + if state_preparation.get_qiskit_circuit() is not None and ctrl_evol_circuit.get_qiskit_circuit() is not None: return self._create_circuit_from_qiskit(state_preparation, ctrl_evol_circuit, phase_correction) raise RuntimeError( diff --git a/python/src/qdk_chemistry/algorithms/registry.pyi b/python/src/qdk_chemistry/algorithms/registry.pyi index dcb38eb2c..f14832b03 100644 --- a/python/src/qdk_chemistry/algorithms/registry.pyi +++ b/python/src/qdk_chemistry/algorithms/registry.pyi @@ -1 +1,460 @@ -# This file is a placeholder and will be replaced with generated stubs on first import +"""Type stubs for registry.create() with all algorithm overloads.""" + +from typing import Literal, overload, Union +from .base import Algorithm + +import qdk_chemistry.algorithms.active_space_selector +import qdk_chemistry.algorithms.circuit_executor.qdk +import qdk_chemistry.algorithms.dynamical_correlation_calculator +import qdk_chemistry.algorithms.energy_estimator.qdk +import qdk_chemistry.algorithms.hamiltonian_constructor +import qdk_chemistry.algorithms.multi_configuration_calculator +import qdk_chemistry.algorithms.orbital_localizer +import qdk_chemistry.algorithms.phase_estimation.iterative_phase_estimation +import qdk_chemistry.algorithms.projected_multi_configuration_calculator +import qdk_chemistry.algorithms.qubit_hamiltonian_solver +import qdk_chemistry.algorithms.qubit_mapper.qdk_qubit_mapper +import qdk_chemistry.algorithms.scf_solver +import qdk_chemistry.algorithms.stability_checker +import qdk_chemistry.algorithms.state_preparation.sparse_isometry +import qdk_chemistry.algorithms.time_evolution.builder.partially_randomized +import qdk_chemistry.algorithms.time_evolution.builder.qdrift +import qdk_chemistry.algorithms.time_evolution.builder.trotter +import qdk_chemistry.algorithms.time_evolution.circuit_mapper.pauli_sequence_mapper +import qdk_chemistry.algorithms.time_evolution.controlled_circuit_mapper.pauli_sequence_mapper +import qdk_chemistry.algorithms.time_evolution.measure_simulation.evolve_and_measure +import qdk_chemistry.plugins.openfermion.qubit_mapper +import qdk_chemistry.plugins.pyscf.active_space_avas +import qdk_chemistry.plugins.pyscf.coupled_cluster +import qdk_chemistry.plugins.pyscf.localization +import qdk_chemistry.plugins.pyscf.mcscf +import qdk_chemistry.plugins.pyscf.scf_solver +import qdk_chemistry.plugins.pyscf.stability +import qdk_chemistry.plugins.qiskit.circuit_executor +import qdk_chemistry.plugins.qiskit.qubit_mapper +import qdk_chemistry.plugins.qiskit.regular_isometry +import qdk_chemistry.plugins.qiskit.standard_phase_estimation + +@overload +def create( + algorithm_type: Literal['active_space_selector'], + algorithm_name: Literal['pyscf_avas'] | None = None, + ao_labels: list[str] = [], + canonicalize: bool = False, + openshell_option: unknown = 2, +) -> qdk_chemistry.plugins.pyscf.active_space_avas.PyscfAVAS: ... + +@overload +def create( + algorithm_type: Literal['active_space_selector'], + algorithm_name: Literal['qdk_occupation'] | None = None, + occupation_threshold: float = 0.1, +) -> qdk_chemistry.algorithms.active_space_selector.QdkOccupationActiveSpaceSelector: ... + +@overload +def create( + algorithm_type: Literal['active_space_selector'], + algorithm_name: Literal['qdk_autocas_eos'] | None = None, + diff_threshold: float = 0.1, + entropy_threshold: float = 0.14, + normalize_entropies: bool = True, +) -> qdk_chemistry.algorithms.active_space_selector.QdkAutocasEosActiveSpaceSelector: ... + +@overload +def create( + algorithm_type: Literal['active_space_selector'], + algorithm_name: Literal['qdk_autocas'] | None = None, + entropy_threshold: float = 0.14, + min_plateau_size: unknown = 10, + normalize_entropies: bool = True, + num_bins: unknown = 100, +) -> qdk_chemistry.algorithms.active_space_selector.QdkAutocasActiveSpaceSelector: ... + +@overload +def create( + algorithm_type: Literal['active_space_selector'], + algorithm_name: Literal['qdk_valence'] | None = None, + num_active_electrons: unknown = -1, + num_active_orbitals: unknown = -1, +) -> qdk_chemistry.algorithms.active_space_selector.QdkValenceActiveSpaceSelector: ... + +@overload +def create( + algorithm_type: Literal['hamiltonian_constructor'], + algorithm_name: Literal['qdk_cholesky'] | None = None, + cholesky_tolerance: float = 1e-08, + eri_threshold: float = 1e-12, + scf_type: str = "auto", + store_cholesky_vectors: bool = False, +) -> qdk_chemistry.algorithms.hamiltonian_constructor.QdkCholeskyHamiltonianConstructor: ... + +@overload +def create( + algorithm_type: Literal['hamiltonian_constructor'], + algorithm_name: Literal['qdk'] | None = None, + eri_method: str = "direct", + scf_type: str = "auto", +) -> qdk_chemistry.algorithms.hamiltonian_constructor.QdkHamiltonianConstructor: ... + +@overload +def create( + algorithm_type: Literal['orbital_localizer'], + algorithm_name: Literal['pyscf_multi'] | None = None, + method: str = "pipek-mezey", + occupation_threshold: float = 1e-10, + population_method: str = "mulliken", +) -> qdk_chemistry.plugins.pyscf.localization.PyscfLocalizer: ... + +@overload +def create( + algorithm_type: Literal['orbital_localizer'], + algorithm_name: Literal['qdk_vvhv'] | None = None, + max_iterations: unknown = 10000, + minimal_basis: str = "sto-3g", + small_rotation_tolerance: float = 1e-12, + tolerance: float = 1e-06, + weighted_orthogonalization: bool = True, +) -> qdk_chemistry.algorithms.orbital_localizer.QdkVVHVLocalizer: ... + +@overload +def create( + algorithm_type: Literal['orbital_localizer'], + algorithm_name: Literal['qdk_mp2_natural_orbitals'] | None = None, +) -> qdk_chemistry.algorithms.orbital_localizer.QdkMP2NaturalOrbitalLocalizer: ... + +@overload +def create( + algorithm_type: Literal['orbital_localizer'], + algorithm_name: Literal['qdk_pipek_mezey'] | None = None, + max_iterations: unknown = 10000, + small_rotation_tolerance: float = 1e-12, + tolerance: float = 1e-06, +) -> qdk_chemistry.algorithms.orbital_localizer.QdkPipekMezeyLocalizer: ... + +@overload +def create( + algorithm_type: Literal['multi_configuration_calculator'], + algorithm_name: Literal['macis_asci'] | None = None, + calculate_mutual_information: bool = False, + calculate_one_rdm: bool = False, + calculate_single_orbital_entropies: bool = False, + calculate_two_orbital_entropies: bool = False, + calculate_two_rdm: bool = False, + ci_residual_tolerance: float = 1e-06, + constraint_level: unknown = 2, + core_selection_strategy: str = "percentage", + core_selection_threshold: float = 0.95, + grow_factor: float = 8.0, + grow_with_rot: bool = False, + growth_backoff_rate: float = 0.5, + growth_recovery_rate: float = 1.1, + h_el_tol: float = 1e-08, + just_singles: bool = False, + max_refine_iter: unknown = 6, + max_solver_iterations: unknown = 200, + min_grow_factor: float = 1.01, + ncdets_max: unknown = 100, + ntdets_max: unknown = 100000, + ntdets_min: unknown = 100, + nxtval_bcount_inc: unknown = 10, + nxtval_bcount_thresh: unknown = 1000, + pair_size_max: unknown = 500000000, + pt2_bigcon_thresh: unknown = 250, + pt2_constraint_refine_force: unknown = 0, + pt2_max_constraint_level: unknown = 5, + pt2_min_constraint_level: unknown = 0, + pt2_precompute_eps: bool = False, + pt2_precompute_idx: bool = False, + pt2_print_progress: bool = False, + pt2_prune: bool = False, + pt2_reserve_count: unknown = 70000000, + pt2_tol: float = 1e-16, + refine_energy_tol: float = 1e-06, + rot_size_start: unknown = 1000, + rv_prune_tol: float = 1e-08, +) -> qdk_chemistry.algorithms.multi_configuration_calculator.QdkMacisAsci: ... + +@overload +def create( + algorithm_type: Literal['multi_configuration_calculator'], + algorithm_name: Literal['macis_cas'] | None = None, + calculate_mutual_information: bool = False, + calculate_one_rdm: bool = False, + calculate_single_orbital_entropies: bool = False, + calculate_two_orbital_entropies: bool = False, + calculate_two_rdm: bool = False, + ci_residual_tolerance: float = 1e-06, + max_solver_iterations: unknown = 200, +) -> qdk_chemistry.algorithms.multi_configuration_calculator.QdkMacisCas: ... + +@overload +def create( + algorithm_type: Literal['multi_configuration_scf'], + algorithm_name: Literal['pyscf'] | None = None, + max_cycle_macro: unknown = 50, + verbose: unknown = 0, +) -> qdk_chemistry.plugins.pyscf.mcscf.PyscfMcscfCalculator: ... + +@overload +def create( + algorithm_type: Literal['projected_multi_configuration_calculator'], + algorithm_name: Literal['macis_pmc'] | None = None, + H_thresh: float = 1e-16, + calculate_mutual_information: bool = False, + calculate_one_rdm: bool = False, + calculate_single_orbital_entropies: bool = False, + calculate_two_orbital_entropies: bool = False, + calculate_two_rdm: bool = False, + ci_residual_tolerance: float = 1e-06, + davidson_max_m: unknown = 200, + davidson_res_tol: float = 1e-08, + h_el_tol: float = 1e-08, + iterative_solver_dimension_cutoff: unknown = 100, + max_solver_iterations: unknown = 200, +) -> qdk_chemistry.algorithms.projected_multi_configuration_calculator.QdkMacisPmc: ... + +@overload +def create( + algorithm_type: Literal['dynamical_correlation_calculator'], + algorithm_name: Literal['pyscf_coupled_cluster'] | None = None, + async_io: bool = True, + compute_bra: bool = False, + conv_tol: float = 1e-07, + conv_tol_normt: float = 1e-05, + diis_space: unknown = 6, + diis_start_cycle: unknown = 0, + direct: bool = False, + incore_complete: bool = True, + max_cycle: unknown = 50, + store_amplitudes: bool = False, +) -> qdk_chemistry.plugins.pyscf.coupled_cluster.PyscfCoupledClusterCalculator: ... + +@overload +def create( + algorithm_type: Literal['dynamical_correlation_calculator'], + algorithm_name: Literal['qdk_mp2_calculator'] | None = None, +) -> qdk_chemistry.algorithms.dynamical_correlation_calculator.DynamicalCorrelationCalculator: ... + +@overload +def create( + algorithm_type: Literal['scf_solver'], + algorithm_name: Literal['pyscf'] | None = None, + convergence_threshold: float = 1e-07, + max_iterations: unknown = 50, + method: str = "hf", + scf_type: str = "auto", + xc_grid: unknown = 3, +) -> qdk_chemistry.plugins.pyscf.scf_solver.PyscfScfSolver: ... + +@overload +def create( + algorithm_type: Literal['scf_solver'], + algorithm_name: Literal['qdk'] | None = None, + convergence_threshold: float = 1e-07, + enable_gdm: bool = True, + energy_thresh_diis_switch: float = 0.001, + eri_method: str = "direct", + eri_threshold: float = -1.0, + eri_use_atomics: bool = False, + fock_reset_steps: unknown = 1073741824, + gdm_bfgs_history_size_limit: unknown = 50, + gdm_max_diis_iteration: unknown = 50, + level_shift: float = -1.0, + max_iterations: unknown = 50, + method: str = "hf", + nthreads: unknown = -1, + scf_type: str = "auto", + shell_pair_threshold: float = 1e-12, +) -> qdk_chemistry.algorithms.scf_solver.QdkScfSolver: ... + +@overload +def create( + algorithm_type: Literal['stability_checker'], + algorithm_name: Literal['pyscf'] | None = None, + davidson_tolerance: float = 1e-08, + external: bool = True, + internal: bool = True, + method: str = "hf", + nroots: unknown = 3, + pyscf_verbose: unknown = 4, + stability_tolerance: float = -0.0001, + with_symmetry: bool = False, + xc_grid: unknown = 3, +) -> qdk_chemistry.plugins.pyscf.stability.PyscfStabilityChecker: ... + +@overload +def create( + algorithm_type: Literal['stability_checker'], + algorithm_name: Literal['qdk'] | None = None, + davidson_tolerance: float = 1e-08, + external: bool = False, + internal: bool = True, + max_subspace: unknown = 80, + method: str = "hf", + stability_tolerance: float = -0.0001, +) -> qdk_chemistry.algorithms.stability_checker.QdkStabilityChecker: ... + +@overload +def create( + algorithm_type: Literal['energy_estimator'], + algorithm_name: Literal['qdk'] | None = None, +) -> qdk_chemistry.algorithms.energy_estimator.qdk.QdkEnergyEstimator: ... + +@overload +def create( + algorithm_type: Literal['evolution_circuit_mapper'], + algorithm_name: Literal['pauli_sequence'] | None = None, +) -> qdk_chemistry.algorithms.time_evolution.circuit_mapper.pauli_sequence_mapper.PauliSequenceMapper: ... + +@overload +def create( + algorithm_type: Literal['measure_simulation'], + algorithm_name: Literal['classical_sampling'] | None = None, +) -> qdk_chemistry.algorithms.time_evolution.measure_simulation.evolve_and_measure.EvolveAndMeasure: ... + +@overload +def create( + algorithm_type: Literal['state_prep'], + algorithm_name: Literal['sparse_isometry_gf2x'] | None = None, + basis_gates: list[str] = ['x', 'y', 'z', 'cx', 'cz', 'id', 'h', 's', 'sdg', 'rz'], + dense_preparation_method: str = "qdk", + transpile: bool = True, + transpile_optimization_level: unknown = 0, +) -> qdk_chemistry.algorithms.state_preparation.sparse_isometry.SparseIsometryGF2XStatePreparation: ... + +@overload +def create( + algorithm_type: Literal['state_prep'], + algorithm_name: Literal['qiskit_regular_isometry'] | None = None, + basis_gates: list[str] = ['x', 'y', 'z', 'cx', 'cz', 'id', 'h', 's', 'sdg', 'rz'], + transpile: bool = True, + transpile_optimization_level: unknown = 0, +) -> qdk_chemistry.plugins.qiskit.regular_isometry.RegularIsometryStatePreparation: ... + +@overload +def create( + algorithm_type: Literal['qubit_mapper'], + algorithm_name: Literal['qdk'] | None = None, + encoding: str = "jordan-wigner", + integral_threshold: float = 1e-12, + threshold: float = 1e-12, +) -> qdk_chemistry.algorithms.qubit_mapper.qdk_qubit_mapper.QdkQubitMapper: ... + +@overload +def create( + algorithm_type: Literal['qubit_mapper'], + algorithm_name: Literal['qiskit'] | None = None, + encoding: str = "jordan-wigner", +) -> qdk_chemistry.plugins.qiskit.qubit_mapper.QiskitQubitMapper: ... + +@overload +def create( + algorithm_type: Literal['qubit_mapper'], + algorithm_name: Literal['openfermion'] | None = None, + encoding: str = "jordan-wigner", +) -> qdk_chemistry.plugins.openfermion.qubit_mapper.OpenFermionQubitMapper: ... + +@overload +def create( + algorithm_type: Literal['qubit_hamiltonian_solver'], + algorithm_name: Literal['qdk_dense_matrix_solver'] | None = None, +) -> qdk_chemistry.algorithms.qubit_hamiltonian_solver.DenseMatrixSolver: ... + +@overload +def create( + algorithm_type: Literal['qubit_hamiltonian_solver'], + algorithm_name: Literal['qdk_sparse_matrix_solver'] | None = None, + max_m: unknown = 20, + tol: float = 1e-08, +) -> qdk_chemistry.algorithms.qubit_hamiltonian_solver.SparseMatrixSolver: ... + +@overload +def create( + algorithm_type: Literal['time_evolution_builder'], + algorithm_name: Literal['trotter'] | None = None, + error_bound: str = "commutator", + num_divisions: unknown = 0, + optimize_term_ordering: bool = False, + order: unknown = 1, + target_accuracy: float = 0.0, + weight_threshold: float = 1e-12, +) -> qdk_chemistry.algorithms.time_evolution.builder.trotter.Trotter: ... + +@overload +def create( + algorithm_type: Literal['time_evolution_builder'], + algorithm_name: Literal['qdrift'] | None = None, + commutation_type: str = "general", + merge_duplicate_terms: bool = True, + num_samples: unknown = 100, + seed: unknown = -1, +) -> qdk_chemistry.algorithms.time_evolution.builder.qdrift.QDrift: ... + +@overload +def create( + algorithm_type: Literal['time_evolution_builder'], + algorithm_name: Literal['partially_randomized'] | None = None, + commutation_type: str = "general", + merge_duplicate_terms: bool = True, + num_random_samples: unknown = 100, + seed: unknown = -1, + tolerance: float = 1e-12, + trotter_order: unknown = 2, + weight_threshold: float = -1.0, +) -> qdk_chemistry.algorithms.time_evolution.builder.partially_randomized.PartiallyRandomized: ... + +@overload +def create( + algorithm_type: Literal['controlled_evolution_circuit_mapper'], + algorithm_name: Literal['pauli_sequence'] | None = None, + power: unknown = 1, +) -> qdk_chemistry.algorithms.time_evolution.controlled_circuit_mapper.pauli_sequence_mapper.PauliSequenceMapper: ... + +@overload +def create( + algorithm_type: Literal['circuit_executor'], + algorithm_name: Literal['qdk_full_state_simulator'] | None = None, + seed: unknown = 42, + type: str = "cpu", +) -> qdk_chemistry.algorithms.circuit_executor.qdk.QdkFullStateSimulator: ... + +@overload +def create( + algorithm_type: Literal['circuit_executor'], + algorithm_name: Literal['qdk_sparse_state_simulator'] | None = None, + seed: unknown = 42, +) -> qdk_chemistry.algorithms.circuit_executor.qdk.QdkSparseStateSimulator: ... + +@overload +def create( + algorithm_type: Literal['circuit_executor'], + algorithm_name: Literal['qiskit_aer_simulator'] | None = None, + method: str = "statevector", + seed: unknown = 42, + transpile_optimization_level: unknown = 0, +) -> qdk_chemistry.plugins.qiskit.circuit_executor.QiskitAerSimulator: ... + +@overload +def create( + algorithm_type: Literal['phase_estimation'], + algorithm_name: Literal['iterative'] | None = None, + evolution_time: float = 0.0, + num_bits: unknown = -1, + shots_per_bit: unknown = 3, +) -> qdk_chemistry.algorithms.phase_estimation.iterative_phase_estimation.IterativePhaseEstimation: ... + +@overload +def create( + algorithm_type: Literal['phase_estimation'], + algorithm_name: Literal['qiskit_standard'] | None = None, + evolution_time: float = 0.0, + num_bits: unknown = -1, + qft_do_swaps: bool = True, + shots: unknown = 3, +) -> qdk_chemistry.plugins.qiskit.standard_phase_estimation.QiskitStandardPhaseEstimation: ... + +def create( + algorithm_type: str, + algorithm_name: str | None = None, + **kwargs, +) -> Union[Algorithm | qdk_chemistry.algorithms.active_space_selector.QdkAutocasActiveSpaceSelector | qdk_chemistry.algorithms.active_space_selector.QdkAutocasEosActiveSpaceSelector | qdk_chemistry.algorithms.active_space_selector.QdkOccupationActiveSpaceSelector | qdk_chemistry.algorithms.active_space_selector.QdkValenceActiveSpaceSelector | qdk_chemistry.algorithms.circuit_executor.qdk.QdkFullStateSimulator | qdk_chemistry.algorithms.circuit_executor.qdk.QdkSparseStateSimulator | qdk_chemistry.algorithms.dynamical_correlation_calculator.DynamicalCorrelationCalculator | qdk_chemistry.algorithms.energy_estimator.qdk.QdkEnergyEstimator | qdk_chemistry.algorithms.hamiltonian_constructor.QdkCholeskyHamiltonianConstructor | qdk_chemistry.algorithms.hamiltonian_constructor.QdkHamiltonianConstructor | qdk_chemistry.algorithms.multi_configuration_calculator.QdkMacisAsci | qdk_chemistry.algorithms.multi_configuration_calculator.QdkMacisCas | qdk_chemistry.algorithms.orbital_localizer.QdkMP2NaturalOrbitalLocalizer | qdk_chemistry.algorithms.orbital_localizer.QdkPipekMezeyLocalizer | qdk_chemistry.algorithms.orbital_localizer.QdkVVHVLocalizer | qdk_chemistry.algorithms.phase_estimation.iterative_phase_estimation.IterativePhaseEstimation | qdk_chemistry.algorithms.projected_multi_configuration_calculator.QdkMacisPmc | qdk_chemistry.algorithms.qubit_hamiltonian_solver.DenseMatrixSolver | qdk_chemistry.algorithms.qubit_hamiltonian_solver.SparseMatrixSolver | qdk_chemistry.algorithms.qubit_mapper.qdk_qubit_mapper.QdkQubitMapper | qdk_chemistry.algorithms.scf_solver.QdkScfSolver | qdk_chemistry.algorithms.stability_checker.QdkStabilityChecker | qdk_chemistry.algorithms.state_preparation.sparse_isometry.SparseIsometryGF2XStatePreparation | qdk_chemistry.algorithms.time_evolution.builder.partially_randomized.PartiallyRandomized | qdk_chemistry.algorithms.time_evolution.builder.qdrift.QDrift | qdk_chemistry.algorithms.time_evolution.builder.trotter.Trotter | qdk_chemistry.algorithms.time_evolution.circuit_mapper.pauli_sequence_mapper.PauliSequenceMapper | qdk_chemistry.algorithms.time_evolution.controlled_circuit_mapper.pauli_sequence_mapper.PauliSequenceMapper | qdk_chemistry.algorithms.time_evolution.measure_simulation.evolve_and_measure.EvolveAndMeasure | qdk_chemistry.plugins.openfermion.qubit_mapper.OpenFermionQubitMapper | qdk_chemistry.plugins.pyscf.active_space_avas.PyscfAVAS | qdk_chemistry.plugins.pyscf.coupled_cluster.PyscfCoupledClusterCalculator | qdk_chemistry.plugins.pyscf.localization.PyscfLocalizer | qdk_chemistry.plugins.pyscf.mcscf.PyscfMcscfCalculator | qdk_chemistry.plugins.pyscf.scf_solver.PyscfScfSolver | qdk_chemistry.plugins.pyscf.stability.PyscfStabilityChecker | qdk_chemistry.plugins.qiskit.circuit_executor.QiskitAerSimulator | qdk_chemistry.plugins.qiskit.qubit_mapper.QiskitQubitMapper | qdk_chemistry.plugins.qiskit.regular_isometry.RegularIsometryStatePreparation | qdk_chemistry.plugins.qiskit.standard_phase_estimation.QiskitStandardPhaseEstimation]: ... \ No newline at end of file diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index f1e1dcd5d..7db37cd5c 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -18,8 +18,15 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +from __future__ import annotations + +from typing import TYPE_CHECKING + import numpy as np +if TYPE_CHECKING: + from qiskit.circuit import QuantumCircuit + from qdk_chemistry.algorithms.time_evolution.builder.base import TimeEvolutionBuilder from qdk_chemistry.algorithms.time_evolution.builder.trotter_error import ( trotter_steps_commutator, @@ -520,3 +527,90 @@ def name(self) -> str: def type_name(self) -> str: """Return time_evolution_builder as the algorithm type name.""" return "time_evolution_builder" + + +def encoding_clifford_circuit( + qubit_hamiltonians: list[QubitHamiltonian], +) -> QuantumCircuit: + """Build a Clifford circuit from the encoding Clifford of Hamiltonian Pauli generators. + + Collects all non-identity Pauli strings from the given Hamiltonians, + computes the encoding Clifford via ``paulimer.encoding_clifford_of``, + and synthesises it into a Qiskit :class:`QuantumCircuit` of elementary + Clifford gates (H, S, CX). + + The Pauli strings across all Hamiltonians must be mutually commuting + (i.e. they form a valid stabilizer group), otherwise ``encoding_clifford_of`` + will raise an error. + + Args: + qubit_hamiltonians: Hamiltonians whose Pauli strings serve as + generators for the encoding Clifford. + + Returns: + A :class:`~qiskit.circuit.QuantumCircuit` implementing the encoding + Clifford in terms of H, S, and CX gates. + + Raises: + ValueError: If no Hamiltonians are provided or qubit counts are inconsistent. + + """ + from paulimer import DensePauli # noqa: PLC0415 + from paulimer import encoding_clifford_of as _encoding_clifford_of # noqa: PLC0415 + + if not qubit_hamiltonians: + raise ValueError("At least one QubitHamiltonian is required.") + + num_qubits = qubit_hamiltonians[0].num_qubits + + # Collect unique non-identity Pauli generators from all Hamiltonians. + seen: set[str] = set() + generators: list[DensePauli] = [] + for qh in qubit_hamiltonians: + if qh.num_qubits != num_qubits: + raise ValueError("All QubitHamiltonians must have the same number of qubits.") + for label in qh.pauli_strings: + if label not in seen and any(c != "I" for c in label): + seen.add(label) + generators.append(DensePauli(label)) + + clifford_unitary = _encoding_clifford_of(generators, num_qubits) + + return _clifford_unitary_to_circuit(clifford_unitary) + + +def _clifford_unitary_to_circuit(clifford_unitary) -> QuantumCircuit: + """Convert a paulimer ``CliffordUnitary`` to a Qiskit ``QuantumCircuit``. + + Builds a Qiskit :class:`~qiskit.quantum_info.Clifford` from the + stabilizer tableau and synthesises it into elementary gates. + + Args: + clifford_unitary: A ``paulimer.CliffordUnitary`` instance. + + Returns: + A :class:`~qiskit.circuit.QuantumCircuit` of H, S, and CX gates. + + """ + from qiskit.quantum_info import Clifford # noqa: PLC0415 + + n = clifford_unitary.qubit_count + tableau = np.zeros((2 * n, 2 * n + 1), dtype=bool) + + for i in range(n): + img_x = clifford_unitary.image_x(i) + img_z = clifford_unitary.image_z(i) + + for j in range(n): + cx = img_x.characters[j] + tableau[i, j] = cx in ("X", "Y") + tableau[i, j + n] = cx in ("Z", "Y") + + cz = img_z.characters[j] + tableau[i + n, j] = cz in ("X", "Y") + tableau[i + n, j + n] = cz in ("Z", "Y") + + tableau[i, 2 * n] = img_x.phase.real < 0 + tableau[i + n, 2 * n] = img_z.phase.real < 0 + + return Clifford(tableau).to_circuit() diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py index 1f64cbc2c..a35d514ca 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py @@ -5,11 +5,8 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import re from abc import abstractmethod -from qdk import qsharp - from qdk_chemistry.algorithms.base import Algorithm, AlgorithmFactory from qdk_chemistry.algorithms.circuit_executor.base import CircuitExecutor from qdk_chemistry.algorithms.energy_estimator.energy_estimator import EnergyEstimator @@ -24,13 +21,11 @@ Settings, TimeEvolutionUnitary, ) +from qdk_chemistry.data.circuit import QsharpFactoryData from qdk_chemistry.utils.qsharp import QSHARP_UTILS __all__: list[str] = ["MeasureSimulation", "MeasureSimulationFactory", "MeasureSimulationSettings"] -_QASM_QUBIT_DECLARATION_PATTERN = re.compile(r"\bqubit\s*\[(\d+)\]") -_QIR_REQUIRED_NUM_QUBITS_PATTERN = re.compile(r'"required_num_qubits"="(\d+)"') - class MeasureSimulationSettings(Settings): """Settings for evolve-and-measure simulation.""" @@ -66,6 +61,7 @@ def _run_impl( circuit_executor: CircuitExecutor, energy_estimator: EnergyEstimator, noise: QuantumErrorProfile | None = None, + device_backend_name: str | None = None, basis_gates: list[str] | None = None, ) -> list[tuple[EnergyExpectationResult, MeasurementData]]: """Run evolve-and-measure simulation. @@ -81,6 +77,7 @@ def _run_impl( circuit_executor: Circuit executor backend. energy_estimator: Energy estimator algorithm. noise: Optional noise profile. + device_backend_name: Optional device backend name. basis_gates: Optional list of basis gates to transpile the circuit into before execution. Returns: @@ -105,7 +102,7 @@ def _map_time_evolution_to_circuit( """Map a time-evolution unitary into an executable circuit.""" return circuit_mapper.run(evolution) - def _prepend_state_prep_circuit(self, state_prep: Circuit, circuit: Circuit) -> Circuit: + def _prepend_state_prep_circuit(self, state_prep: Circuit, circuit: Circuit, num_qubits: int) -> Circuit: state_prep_op = getattr(state_prep, "_qsharp_op", None) circuit_op = getattr(circuit, "_qsharp_op", None) if state_prep_op is None or circuit_op is None: @@ -117,49 +114,19 @@ def _prepend_state_prep_circuit(self, state_prep: Circuit, circuit: Circuit) -> f"('{state_prep.encoding}' and '{circuit.encoding}')." ) - num_qubits = None - for representation in (state_prep.qir, circuit.qir, state_prep.qasm, circuit.qasm): - if representation is None: - continue - - match = _QIR_REQUIRED_NUM_QUBITS_PATTERN.search(str(representation)) - if match: - candidate = int(match.group(1)) - else: - qasm_match = _QASM_QUBIT_DECLARATION_PATTERN.search(str(representation)) - if not qasm_match: - continue - candidate = int(qasm_match.group(1)) - - if num_qubits is None: - num_qubits = candidate - elif num_qubits != candidate: - raise ValueError( - "State-preparation circuit and evolution circuit must act on the same number of qubits " - f"(received {num_qubits} and {candidate})." - ) - - if num_qubits is None: - raise RuntimeError("Unable to infer the number of qubits needed to compose the Q# circuits.") - target_indices = list(range(num_qubits)) combined_encoding = circuit.encoding if circuit.encoding is not None else state_prep.encoding - qsharp_circuit = qsharp.circuit( - QSHARP_UTILS.CircuitComposition.MakeSequentialCircuit, - state_prep_op, - circuit_op, - target_indices, - ) - qir = qsharp.compile( - QSHARP_UTILS.CircuitComposition.MakeSequentialCircuit, - state_prep_op, - circuit_op, - target_indices, - ) + sequential_parameters = { + "first": state_prep_op, + "second": circuit_op, + "targets": target_indices, + } return Circuit( - qsharp=qsharp_circuit, - qir=qir, + qsharp_factory=QsharpFactoryData( + program=QSHARP_UTILS.CircuitComposition.MakeSequentialCircuit, + parameter=sequential_parameters, + ), qsharp_op=QSHARP_UTILS.CircuitComposition.MakeSequentialOp(state_prep_op, circuit_op), encoding=combined_encoding, ) @@ -181,6 +148,8 @@ def _transpile_to_basis_gates(circuit: Circuit, basis_gates: list[str]) -> Circu from qiskit.transpiler import PassManager # noqa: PLC0415 from qdk_chemistry.plugins.qiskit._interop.transpiler import ( # noqa: PLC0415 + FactorCliffordFromRz, + FactorPauliFromRotation, MergeZBasisRotations, RemoveZBasisOnZeroState, SubstituteCliffordRz, @@ -193,17 +162,46 @@ def _transpile_to_basis_gates(circuit: Circuit, basis_gates: list[str]) -> Circu ) from exc qc = circuit.get_qiskit_circuit() - qc = transpile(qc, basis_gates=basis_gates, optimization_level=3) pm = PassManager( [ - MergeZBasisRotations(), - SubstituteCliffordRz(), - RemoveZBasisOnZeroState(), + FactorCliffordFromRz(), + FactorPauliFromRotation(), ] ) qc = pm.run(qc) + qc = transpile(qc, basis_gates=basis_gates, optimization_level=0) + + if ( + "x" in basis_gates + and "y" in basis_gates + and "z" in basis_gates + and "h" in basis_gates + and "s" in basis_gates + and "sdg" in basis_gates + and "rz" in basis_gates + ): + # If the basis gates include all Clifford gates, we substitute rz gates with Clifford gates wherever possible + pm = PassManager( + [ + # RemoveZBasisOnZeroState(), + MergeZBasisRotations(), + SubstituteCliffordRz(), + ] + ) + qc = pm.run(qc) + else: + pm = PassManager( + [ + MergeZBasisRotations(), + RemoveZBasisOnZeroState(), + ] + ) + qc = pm.run(qc) + + qc = transpile(qc, basis_gates=basis_gates, optimization_level=3) + return Circuit(qasm=qasm3.dumps(qc), encoding=circuit.encoding) def _measure_observable( @@ -214,6 +212,7 @@ def _measure_observable( energy_estimator: EnergyEstimator, shots: int = 1000, noise: QuantumErrorProfile | None = None, + device_backend_name: str | None = None, ) -> tuple[EnergyExpectationResult, MeasurementData]: """Measure a qubit observable on the provided circuit state.""" energy_result, measurement_data = energy_estimator.run( @@ -222,6 +221,7 @@ def _measure_observable( circuit_executor, total_shots=shots, noise_model=noise, + device_backend_name=device_backend_name, ) return energy_result, measurement_data diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py index ae48b9ad3..0cca1f5cc 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py @@ -35,6 +35,8 @@ def __init__(self): class EvolveAndMeasure(MeasureSimulation): """Evolve under a Hamiltonian and measure a target observable.""" + _evolution_circuit: Circuit | None = None + def __init__(self): """Initialize EvolveAndMeasure with the given settings.""" Logger.trace_entering() @@ -54,6 +56,7 @@ def _run_impl( circuit_executor: CircuitExecutor, energy_estimator: EnergyEstimator, noise: QuantumErrorProfile | None = None, + device_backend_name: str | None = None, basis_gates: list[str] | None = None, ) -> list[tuple[EnergyExpectationResult, MeasurementData]]: """Run evolve-and-measure simulation. @@ -95,42 +98,79 @@ def _run_impl( for observable in observables: if observable.num_qubits != reference_num_qubits: raise ValueError("All Hamiltonians and observables must have the same number of qubits.") - evolution = self._create_time_evolution(qubit_hamiltonians[0], times[0], evolution_builder) - - for i in range(1, len(qubit_hamiltonians)): - qubit_hamiltonian = qubit_hamiltonians[i] - time = times[i] - delta_t = time - times[i - 1] - - evolution = TimeEvolutionUnitary( - evolution.get_container().combine( - self._create_time_evolution(qubit_hamiltonian, delta_t, evolution_builder).get_container(), - ) - ) - - evolution_circuit = self._map_time_evolution_to_circuit(evolution, circuit_mapper) - - if state_prep is not None: - evolution_circuit = self._prepend_state_prep_circuit(state_prep, evolution_circuit) - - # Transpile to basis gates - if basis_gates is not None: - evolution_circuit = self._transpile_to_basis_gates(evolution_circuit, basis_gates) + self._evolution_circuit = self._build_evolution_circuit( + qubit_hamiltonians=qubit_hamiltonians, + times=times, + evolution_builder=evolution_builder, + circuit_mapper=circuit_mapper, + state_prep=state_prep, + basis_gates=basis_gates, + ) measurements = [] for observable in observables: measurements.append( self._measure_observable( - circuit=evolution_circuit, + circuit=self._evolution_circuit, shots=shots, observable=observable, circuit_executor=circuit_executor, energy_estimator=energy_estimator, noise=noise, + device_backend_name=device_backend_name, ) ) return measurements + def _build_evolution_circuit( + self, + qubit_hamiltonians: list[QubitHamiltonian], + times: list[float], + evolution_builder: TimeEvolutionBuilder, + circuit_mapper: EvolutionCircuitMapper, + *, + state_prep: Circuit | None = None, + basis_gates: list[str] | None = None, + ) -> Circuit: + """Construct the combined evolution circuit. + + Args: + qubit_hamiltonians: List of Hamiltonians used to build time evolution. + times: Monotonically-increasing list of times to evolve under the Hamiltonians. + evolution_builder: Time-evolution builder. + circuit_mapper: Mapper for time-evolution unitary to circuit. + state_prep: Optional circuit that prepares the initial state before time evolution. + basis_gates: Optional list of basis gates to transpile the circuit into. + + Returns: + The combined evolution circuit. + + """ + evolution = self._create_time_evolution(qubit_hamiltonians[0], times[0], evolution_builder) + + for i in range(1, len(qubit_hamiltonians)): + delta_t = times[i] - times[i - 1] + evolution = TimeEvolutionUnitary( + evolution.get_container().combine( + self._create_time_evolution(qubit_hamiltonians[i], delta_t, evolution_builder).get_container(), + ) + ) + + circuit = self._map_time_evolution_to_circuit(evolution, circuit_mapper) + + if state_prep is not None: + circuit = self._prepend_state_prep_circuit(state_prep, circuit, qubit_hamiltonians[0].num_qubits) + + if basis_gates is not None: + circuit = self._transpile_to_basis_gates(circuit, basis_gates) + + self._evolution_circuit = circuit + return circuit + + def get_circuit(self) -> Circuit | None: + """Get the evolution circuit used in the simulation.""" + return self._evolution_circuit + def name(self) -> str: """Return ``classical_sampling`` as the algorithm name.""" return "classical_sampling" diff --git a/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py b/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py index c0951420c..b63eae8e2 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py +++ b/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py @@ -12,7 +12,20 @@ import numpy as np from qiskit.circuit import ParameterExpression -from qiskit.circuit.library import IGate, SdgGate, SGate, ZGate +from qiskit.circuit.library import ( + IGate, + RXGate, + RXXGate, + RYGate, + RYYGate, + RZGate, + RZZGate, + SdgGate, + SGate, + XGate, + YGate, + ZGate, +) from qiskit.dagcircuit import DAGCircuit from qiskit.transpiler.basepasses import TransformationPass from qiskit.transpiler.passes.optimization import Optimize1qGatesDecomposition @@ -22,6 +35,8 @@ from qdk_chemistry.utils import Logger __all__ = [ + "FactorCliffordFromRz", + "FactorPauliFromRotation", "MergeZBasisRotations", "RemoveZBasisOnZeroState", "SubstituteCliffordRz", @@ -295,6 +310,209 @@ def settings(self) -> Settings: return self._settings +# Clifford angles indexed by their multiples of π/2 (mod 4). +_CLIFFORD_TABLE: list[tuple[float, type]] = [ + (0.0, IGate), # 0 · π/2 + (np.pi / 2, SGate), # 1 · π/2 + (np.pi, ZGate), # 2 · π/2 + (3 * np.pi / 2, SdgGate), # 3 · π/2 (≡ −π/2 mod 2π) +] + + +class FactorCliffordFromRz(TransformationPass): + r"""Factor out the nearest Clifford rotation from an arbitrary Rz gate. + + For every ``Rz(θ)`` in the circuit this pass finds the Clifford angle + ``θ_c ∈ {0, π/2, π, 3π/2}`` that is closest to ``θ (mod 2π)`` and + replaces the single gate with the two-gate sequence:: + + Clifford(θ_c) · Rz(θ − θ_c) + + When ``θ`` is already an exact Clifford angle (within *tolerance*) the + residual ``Rz`` vanishes and only the Clifford gate is emitted. + Parameterized ``Rz`` gates are left untouched. + + This is useful as a pre-processing step: by pulling out the Clifford + component the remaining ``Rz`` rotations are bounded to ``|θ_rem| ≤ π/4``, + which can improve subsequent synthesis or error-mitigation passes. + + Example: + ``Rz(1.6)`` is closest to ``S = Rz(π/2 ≈ 1.5708)``, so it becomes + ``S · Rz(0.0292)``. + + """ + + def __init__(self, tolerance: float = float(np.finfo(np.float64).eps)): + """Initialize the FactorCliffordFromRz transformation pass. + + Args: + tolerance: Angle comparison tolerance used to decide whether + the residual rotation is negligible (i.e. the original gate + is already Clifford). Default is ``np.finfo(np.float64).eps``. + + """ + Logger.trace_entering() + super().__init__() + self._tolerance = tolerance + + def run(self, dag: DAGCircuit) -> DAGCircuit: + """Run the pass on the given ``DAGCircuit``. + + Args: + dag: The input ``DAGCircuit`` to transform. + + Returns: + The transformed ``DAGCircuit`` with factored Clifford rotations. + + """ + Logger.trace_entering() + Logger.debug("Running FactorCliffordFromRz pass.") + + for node in dag.op_nodes(): + if node.op.name != "rz": + continue + + angle = node.op.params[0] + if isinstance(angle, ParameterExpression): + continue + + # Normalise to [0, 2π) + angle_mod = float(angle) % (2 * np.pi) + + # Find the nearest Clifford angle + best_cliff_angle, best_gate_cls = min( + _CLIFFORD_TABLE, + key=lambda entry: min(abs(angle_mod - entry[0]), 2 * np.pi - abs(angle_mod - entry[0])), + ) + + remainder = angle_mod - best_cliff_angle + # Wrap remainder into (−π, π] + remainder = (remainder + np.pi) % (2 * np.pi) - np.pi + + if abs(remainder) <= self._tolerance: + # Exact Clifford — replace with the single Clifford gate + dag.substitute_node(node, best_gate_cls(), inplace=True) + else: + # Factor: Clifford · Rz(remainder) + replacement = DAGCircuit() + replacement.add_qubits(node.qargs) + if not isinstance(best_gate_cls(), IGate): + replacement.apply_operation_back(best_gate_cls(), qargs=node.qargs) + replacement.apply_operation_back(RZGate(remainder), qargs=node.qargs) + dag.substitute_node_with_dag(node, replacement) + + return dag + + +# Mapping from rotation gate name to (single-qubit Pauli class, rotation gate class). +_ROTATION_GATE_TABLE: dict[str, tuple[type, type]] = { + "rx": (XGate, RXGate), + "ry": (YGate, RYGate), + "rz": (ZGate, RZGate), + "rxx": (XGate, RXXGate), + "ryy": (YGate, RYYGate), + "rzz": (ZGate, RZZGate), +} + + +class FactorPauliFromRotation(TransformationPass): + r"""Factor out multiples of π from 1- and 2-qubit Pauli rotation gates. + + Every rotation gate :math:`R_P(\theta) = \exp(-i \theta / 2\; P)` satisfies: + + * :math:`R_P(\pi) = -i P` — equivalent to applying the Pauli operator (up to + global phase). + * :math:`R_P(2\pi) = -I` — identity (up to global phase). + + This pass finds the nearest integer multiple :math:`k` of :math:`\pi` in the + gate angle and decomposes:: + + R_P(θ) → P^(k mod 2) · R_P(θ − kπ) + + where the Pauli part is emitted only when *k* is odd. When the residual + vanishes (the angle was already an exact multiple of :math:`\pi`) only the + Pauli gates (or nothing) are emitted. + + Supported gates: ``rx``, ``ry``, ``rz``, ``rxx``, ``ryy``, ``rzz``. + Parameterized gates are left untouched. + + For 2-qubit gates such as :math:`R_{XX}(\pi)` the Pauli contribution is + the tensor product :math:`X \otimes X`, so the pass emits the + corresponding single-qubit Pauli on each qubit independently. + + Example: + ``Rzz(π + 0.1)`` → ``Z · Z · Rzz(0.1)`` (Z on each qubit, then small + residual rotation). + + """ + + def __init__(self, tolerance: float = float(np.finfo(np.float64).eps)): + """Initialize the FactorPauliFromRotation transformation pass. + + Args: + tolerance: Angle comparison tolerance used to decide whether + the residual rotation is negligible. Default is + ``np.finfo(np.float64).eps``. + + """ + Logger.trace_entering() + super().__init__() + self._tolerance = tolerance + + def run(self, dag: DAGCircuit) -> DAGCircuit: + """Run the pass on the given ``DAGCircuit``. + + Args: + dag: The input ``DAGCircuit`` to transform. + + Returns: + The transformed ``DAGCircuit`` with factored Pauli rotations. + + """ + Logger.trace_entering() + Logger.debug("Running FactorPauliFromRotation pass.") + + for node in dag.op_nodes(): + if node.op.name not in _ROTATION_GATE_TABLE: + continue + + angle = node.op.params[0] + if isinstance(angle, ParameterExpression): + continue + + # Normalise to [0, 2π) + angle_f = float(angle) % (2 * np.pi) + pauli_cls, rot_cls = _ROTATION_GATE_TABLE[node.op.name] + + # Nearest integer multiple of π + k = round(angle_f / np.pi) + remainder = angle_f - k * np.pi + + need_pauli = (k % 2) != 0 + need_residual = abs(remainder) > self._tolerance + + if not need_pauli and not need_residual: + # Even multiple of π → identity; remove the gate + dag.remove_op_node(node) + continue + + if not need_pauli and need_residual: + # Even multiple of π + small residual → just the reduced rotation + dag.substitute_node(node, rot_cls(remainder), inplace=True) + continue + + # Odd multiple of π → emit Pauli gate(s), possibly followed by residual + replacement = DAGCircuit() + replacement.add_qubits(node.qargs) + for qubit in node.qargs: + replacement.apply_operation_back(pauli_cls(), qargs=[qubit]) + if need_residual: + replacement.apply_operation_back(rot_cls(remainder), qargs=node.qargs) + dag.substitute_node_with_dag(node, replacement) + + return dag + + class RemoveZBasisOnZeroState(TransformationPass): r"""Transformation pass to remove Z-basis operations on qubits that are in the :math:`\lvert 0 \rangle` state. diff --git a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py index b9f466a4f..8521c2414 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py +++ b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py @@ -10,7 +10,12 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +from __future__ import annotations + +import qiskit_ibm_runtime.fake_provider as _fake_provider from qiskit import transpile +from qiskit.circuit import ClassicalRegister +from qiskit.result import marginal_counts from qiskit_aer import AerSimulator from qiskit_aer.noise import NoiseModel @@ -70,6 +75,7 @@ def _run_impl( circuit: Circuit, shots: int, noise: QuantumErrorProfile | None = None, + device_backend_name: str | None = None, ) -> CircuitExecutorData: """Execute the given quantum circuit using the Qiskit Aer Simulator. @@ -77,6 +83,7 @@ def _run_impl( circuit: The quantum circuit to execute. shots: The number of shots to execute the circuit. noise: Optional noise profile to apply during execution. + device_backend_name: Optional name of a fake device backend to use for noise modeling. Returns: CircuitExecutorData: Object containing the results of the circuit execution. @@ -85,27 +92,79 @@ def _run_impl( Logger.trace_entering() meas_circuit = circuit.get_qiskit_circuit() Logger.debug("Qiskit QuantumCircuit loaded.") - noise_model = get_noise_model_from_profile(noise) if noise else None - backend = AerSimulator( - method=self._settings.get("method"), - seed_simulator=self._settings.get("seed"), - noise_model=noise_model, - ) - if noise_model: + if noise is not None and device_backend_name is not None: + raise ValueError("Cannot specify both a noise model and a device backend. Please choose one or the other.") + + opt_level = self._settings.get("transpile_optimization_level") + + if device_backend_name is not None: + # Directly look up the backend class to avoid instantiating all + # fake backends (which triggers unrelated warnings). + # Try both "FakeManila" and "FakeManilaV2" since naming is inconsistent. + class_name = "".join(part.capitalize() for part in device_backend_name.split("_")) + backend_class = getattr(_fake_provider, class_name, None) or getattr( + _fake_provider, class_name + "V2", None + ) + if backend_class is None: + raise ValueError( + f"Unknown device backend '{device_backend_name}'. " + f"Expected a class '{class_name}' or '{class_name}V2' in qiskit_ibm_runtime.fake_provider." + ) + device_backend = backend_class() + + # If circuit has no measurements, add them for all logical qubits + if meas_circuit.num_clbits == 0: + meas_circuit = meas_circuit.copy() + creg = ClassicalRegister(meas_circuit.num_qubits) + meas_circuit.add_register(creg) + meas_circuit.measure(range(meas_circuit.num_qubits), creg) + + n_clbits = meas_circuit.num_clbits + + backend = AerSimulator.from_backend(device_backend) + backend.set_options( + method=self._settings.get("method"), + seed_simulator=self._settings.get("seed"), + ) + transpiled_circuit = transpile( meas_circuit, - basis_gates=noise_model.basis_gates, - optimization_level=self._settings.get("transpile_optimization_level"), + backend=device_backend, + optimization_level=opt_level, ) + else: - # Use qiskit_aer NoiseModel() default basis gates if no noise model is provided - transpiled_circuit = transpile( - meas_circuit, - basis_gates=NoiseModel().basis_gates, - optimization_level=self._settings.get("transpile_optimization_level"), + # If circuit has no measurements, add them for all qubits + if meas_circuit.num_clbits == 0: + meas_circuit = meas_circuit.copy() + creg = ClassicalRegister(meas_circuit.num_qubits) + meas_circuit.add_register(creg) + meas_circuit.measure(range(meas_circuit.num_qubits), creg) + + noise_model = get_noise_model_from_profile(noise) if noise else None + backend = AerSimulator( + method=self._settings.get("method"), + seed_simulator=self._settings.get("seed"), + noise_model=noise_model, ) + if noise_model: + transpiled_circuit = transpile( + meas_circuit, + basis_gates=noise_model.basis_gates, + optimization_level=opt_level, + ) + else: + # Use qiskit_aer NoiseModel() default basis gates if no noise model is provided + transpiled_circuit = transpile( + meas_circuit, + basis_gates=NoiseModel().basis_gates, + optimization_level=opt_level, + ) raw_results = backend.run(transpiled_circuit, shots=shots).result() - counts = raw_results.get_counts() + if device_backend_name is not None: + counts = marginal_counts(raw_results, indices=list(range(n_clbits))).get_counts() + else: + counts = raw_results.get_counts() Logger.debug(f"Measurement results obtained: {counts}") return CircuitExecutorData( bitstring_counts=counts, diff --git a/python/tests/test_evolve_and_measure.py b/python/tests/test_evolve_and_measure.py index 0c808093f..8970b7b9f 100644 --- a/python/tests/test_evolve_and_measure.py +++ b/python/tests/test_evolve_and_measure.py @@ -20,6 +20,15 @@ from qdk_chemistry.data import Circuit, QubitHamiltonian +def _has_qiskit_aer() -> bool: + try: + from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT_AER + + return QDK_CHEMISTRY_HAS_QISKIT_AER + except ImportError: + return False + + def test_prepend_state_prep_circuit_composes_qsharp_operations(monkeypatch: pytest.MonkeyPatch) -> None: """The helper should compose state preparation and evolution through Q# operations.""" algo = EvolveAndMeasure() @@ -36,16 +45,6 @@ def test_prepend_state_prep_circuit_composes_qsharp_operations(monkeypatch: pyte ) ), ) - monkeypatch.setattr( - measure_base.qsharp, - "circuit", - lambda *args: ("qsharp-circuit", args), - ) - monkeypatch.setattr( - measure_base.qsharp, - "compile", - lambda *args: ("qir", args), - ) state_prep = Circuit( qir='attributes #0 = { "required_num_qubits"="1" }', @@ -58,30 +57,19 @@ def test_prepend_state_prep_circuit_composes_qsharp_operations(monkeypatch: pyte encoding="jordan-wigner", ) - combined = algo._prepend_state_prep_circuit(state_prep, evolution) + combined = algo._prepend_state_prep_circuit(state_prep, evolution, num_qubits=1) - assert combined.qsharp == ( - "qsharp-circuit", - ("sequential-circuit", state_prep_op, evolution_op, [0]), - ) - assert combined.qir == ( - "qir", - ("sequential-circuit", state_prep_op, evolution_op, [0]), - ) + assert combined._qsharp_factory is not None + assert combined._qsharp_factory.program == "sequential-circuit" + assert combined._qsharp_factory.parameter == { + "first": state_prep_op, + "second": evolution_op, + "targets": [0], + } assert combined._qsharp_op == ("sequential-op", state_prep_op, evolution_op) assert combined.encoding == "jordan-wigner" -def test_prepend_state_prep_circuit_rejects_mismatched_qubit_counts() -> None: - """The helper should reject incompatible circuit widths before composing.""" - algo = EvolveAndMeasure() - state_prep = Circuit(qir='attributes #0 = { "required_num_qubits"="1" }', qsharp_op=lambda _: None) - evolution = Circuit(qir='attributes #0 = { "required_num_qubits"="2" }', qsharp_op=lambda _: None) - - with pytest.raises(ValueError, match="same number of qubits"): - algo._prepend_state_prep_circuit(state_prep, evolution) - - def test_prepend_state_prep_circuit_requires_qsharp_operations() -> None: """The helper should fail fast when either circuit lacks a Q# operation handle.""" algo = EvolveAndMeasure() @@ -89,7 +77,7 @@ def test_prepend_state_prep_circuit_requires_qsharp_operations() -> None: evolution = Circuit(qasm="OPENQASM 3.0;\nqubit[1] q;\nx q[0];\n") with pytest.raises(RuntimeError, match="requires Q# operations"): - algo._prepend_state_prep_circuit(state_prep, evolution) + algo._prepend_state_prep_circuit(state_prep, evolution, num_qubits=1) def test_evolve_and_measure_eigenvalue_remains_constant() -> None: @@ -121,3 +109,35 @@ def test_evolve_and_measure_eigenvalue_remains_constant() -> None: for measurement in measurements: assert measurement[0].energy_expectation_value == pytest.approx(1.0, abs=0.2) + + +@pytest.mark.skipif( + not _has_qiskit_aer(), + reason="Qiskit Aer not available", +) +def test_evolve_and_measure_with_device_backend() -> None: + """Run EvolveAndMeasure with a device_backend_name string.""" + hamiltonian = QubitHamiltonian(["ZZ"], np.array([1.0])) + observable = QubitHamiltonian(["ZZ"], np.array([1.0])) + + evolution_builder = Trotter(num_divisions=1, order=1, optimize_term_ordering=True) + algo = EvolveAndMeasure() + mapper = PauliSequenceMapper() + energy_estimator = create("energy_estimator", "qdk") + circuit_executor = create("circuit_executor", "qiskit_aer_simulator") + + measurements = algo.run( + [hamiltonian], + times=[1.0], + observables=[observable], + evolution_builder=evolution_builder, + circuit_mapper=mapper, + circuit_executor=circuit_executor, + energy_estimator=energy_estimator, + shots=1024, + device_backend_name="fake_manila", + ) + + for measurement in measurements: + # With device noise the expectation value should still be close to 1.0 + assert measurement[0].energy_expectation_value == pytest.approx(1.0, abs=0.5) diff --git a/python/tests/test_interop_qiskit_transpiler.py b/python/tests/test_interop_qiskit_transpiler.py index df16db77f..70f6cb2ea 100644 --- a/python/tests/test_interop_qiskit_transpiler.py +++ b/python/tests/test_interop_qiskit_transpiler.py @@ -13,10 +13,12 @@ if QDK_CHEMISTRY_HAS_QISKIT: from qiskit import QuantumCircuit from qiskit.circuit import Parameter - from qiskit.circuit.library import IGate, SdgGate, SGate, ZGate + from qiskit.circuit.library import IGate, SdgGate, SGate, XGate, YGate, ZGate from qiskit.transpiler import PassManager from qdk_chemistry.plugins.qiskit._interop.transpiler import ( + FactorCliffordFromRz, + FactorPauliFromRotation, MergeZBasisRotations, RemoveZBasisOnZeroState, SubstituteCliffordRz, @@ -28,11 +30,15 @@ IGate = object SdgGate = object SGate = object + XGate = object + YGate = object ZGate = object PassManager = object MergeZBasisRotations = object RemoveZBasisOnZeroState = object SubstituteCliffordRz = object + FactorCliffordFromRz = object + FactorPauliFromRotation = object pytestmark = pytest.mark.skipif(not QDK_CHEMISTRY_HAS_QISKIT, reason="Qiskit not available") @@ -137,3 +143,217 @@ def test_remove_z_basis_on_zero_state_preserves_after_x(): assert "s" in result.count_ops() assert "rz" not in result.count_ops() + + +# --------------------------------------------------------------------------- +# FactorCliffordFromRz tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("angle", "expected_clifford"), + [ + (0, IGate), + (np.pi / 2, SGate), + (np.pi, ZGate), + (-np.pi / 2, SdgGate), + ], +) +def test_factor_clifford_exact_angle(angle, expected_clifford): + """Exact Clifford angles should produce a single Clifford gate with no residual Rz.""" + qc = QuantumCircuit(1) + qc.rz(angle, 0) + + result = _run_pass(FactorCliffordFromRz(), qc) + + ops = [instr.operation for instr in result.data] + assert len(ops) == 1 + assert isinstance(ops[0], expected_clifford) + + +def test_factor_clifford_near_s(): + """An angle near π/2 should produce S followed by a small residual Rz.""" + angle = np.pi / 2 + 0.1 + qc = QuantumCircuit(1) + qc.rz(angle, 0) + + result = _run_pass(FactorCliffordFromRz(), qc) + + ops = [instr.operation for instr in result.data] + assert len(ops) == 2 + assert isinstance(ops[0], SGate) + assert ops[1].name == "rz" + assert np.isclose(ops[1].params[0], 0.1, atol=1e-10) + + +def test_factor_clifford_near_zero(): + """An angle near 0 should produce only the residual Rz (identity is dropped).""" + angle = 0.05 + qc = QuantumCircuit(1) + qc.rz(angle, 0) + + result = _run_pass(FactorCliffordFromRz(), qc) + + ops = [instr.operation for instr in result.data] + assert len(ops) == 1 + assert ops[0].name == "rz" + assert np.isclose(ops[0].params[0], 0.05, atol=1e-10) + + +def test_factor_clifford_negative_angle(): + """A negative angle near -π/2 should factor out Sdg.""" + angle = -np.pi / 2 - 0.2 + qc = QuantumCircuit(1) + qc.rz(angle, 0) + + result = _run_pass(FactorCliffordFromRz(), qc) + + ops = [instr.operation for instr in result.data] + assert len(ops) == 2 + assert isinstance(ops[0], SdgGate) + assert ops[0].name == "sdg" + assert ops[1].name == "rz" + assert np.isclose(ops[1].params[0], -0.2, atol=1e-10) + + +def test_factor_clifford_parameterized_untouched(): + """Parameterized Rz gates should be left untouched.""" + theta = Parameter("θ") + qc = QuantumCircuit(1) + qc.rz(theta, 0) + + result = _run_pass(FactorCliffordFromRz(), qc) + + ops = [instr.operation for instr in result.data] + assert len(ops) == 1 + assert ops[0].name == "rz" + + +# --------------------------------------------------------------------------- +# FactorPauliFromRotation tests +# --------------------------------------------------------------------------- + + +def test_factor_pauli_rz_exact_pi(): + """Rz(π) should become a single Z gate.""" + qc = QuantumCircuit(1) + qc.rz(np.pi, 0) + + result = _run_pass(FactorPauliFromRotation(), qc) + + ops = [instr.operation for instr in result.data] + assert len(ops) == 1 + assert isinstance(ops[0], ZGate) + + +def test_factor_pauli_rz_exact_2pi(): + """Rz(2π) is identity — the gate should be removed.""" + qc = QuantumCircuit(1) + qc.rz(2 * np.pi, 0) + + result = _run_pass(FactorPauliFromRotation(), qc) + + assert result.size() == 0 + + +def test_factor_pauli_rz_near_pi(): + """Rz(π + 0.1) should become Z followed by Rz(0.1).""" + qc = QuantumCircuit(1) + qc.rz(np.pi + 0.1, 0) + + result = _run_pass(FactorPauliFromRotation(), qc) + + ops = [instr.operation for instr in result.data] + assert len(ops) == 2 + assert isinstance(ops[0], ZGate) + assert ops[1].name == "rz" + assert np.isclose(ops[1].params[0], 0.1, atol=1e-10) + + +def test_factor_pauli_rz_small_angle(): + """Rz(0.05) is near 0·π, so just the reduced Rz(0.05) should remain.""" + qc = QuantumCircuit(1) + qc.rz(0.05, 0) + + result = _run_pass(FactorPauliFromRotation(), qc) + + ops = [instr.operation for instr in result.data] + assert len(ops) == 1 + assert ops[0].name == "rz" + assert np.isclose(ops[0].params[0], 0.05, atol=1e-10) + + +def test_factor_pauli_rx_exact_pi(): + """Rx(π) should become a single X gate.""" + qc = QuantumCircuit(1) + qc.rx(np.pi, 0) + + result = _run_pass(FactorPauliFromRotation(), qc) + + ops = [instr.operation for instr in result.data] + assert len(ops) == 1 + assert isinstance(ops[0], XGate) + + +def test_factor_pauli_ry_near_pi(): + """Ry(π - 0.2) should become Y followed by Ry(-0.2).""" + qc = QuantumCircuit(1) + qc.ry(np.pi - 0.2, 0) + + result = _run_pass(FactorPauliFromRotation(), qc) + + ops = [instr.operation for instr in result.data] + assert len(ops) == 2 + assert isinstance(ops[0], YGate) + assert ops[1].name == "ry" + assert np.isclose(ops[1].params[0], -0.2, atol=1e-10) + + +def test_factor_pauli_rzz_exact_pi(): + """Rzz(π) should become Z on each qubit.""" + qc = QuantumCircuit(2) + qc.rzz(np.pi, 0, 1) + + result = _run_pass(FactorPauliFromRotation(), qc) + + ops = [instr.operation for instr in result.data] + assert len(ops) == 2 + assert all(isinstance(op, ZGate) for op in ops) + + +def test_factor_pauli_rxx_near_pi(): + """Rxx(π + 0.1) should become X·X followed by Rxx(0.1).""" + qc = QuantumCircuit(2) + qc.rxx(np.pi + 0.1, 0, 1) + + result = _run_pass(FactorPauliFromRotation(), qc) + + ops = [instr.operation for instr in result.data] + assert len(ops) == 3 + assert isinstance(ops[0], XGate) + assert isinstance(ops[1], XGate) + assert ops[2].name == "rxx" + assert np.isclose(ops[2].params[0], 0.1, atol=1e-10) + + +def test_factor_pauli_ryy_exact_2pi(): + """Ryy(2π) is identity — the gate should be removed.""" + qc = QuantumCircuit(2) + qc.ryy(2 * np.pi, 0, 1) + + result = _run_pass(FactorPauliFromRotation(), qc) + + assert result.size() == 0 + + +def test_factor_pauli_parameterized_untouched(): + """Parameterized rotation gates should be left untouched.""" + theta = Parameter("θ") + qc = QuantumCircuit(2) + qc.rzz(theta, 0, 1) + + result = _run_pass(FactorPauliFromRotation(), qc) + + ops = [instr.operation for instr in result.data] + assert len(ops) == 1 + assert ops[0].name == "rzz" From 570487607f289a57c2f6134252c349dd4eaa05cb Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 8 Apr 2026 16:55:18 +0000 Subject: [PATCH 030/117] added check to see if IBM runtime package is installed --- python/src/qdk_chemistry/plugins/qiskit/__init__.py | 6 +++++- python/tests/test_interop_qiskit_circuit_executor.py | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/python/src/qdk_chemistry/plugins/qiskit/__init__.py b/python/src/qdk_chemistry/plugins/qiskit/__init__.py index 134fb8d03..e9934f610 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/__init__.py +++ b/python/src/qdk_chemistry/plugins/qiskit/__init__.py @@ -17,13 +17,15 @@ QDK_CHEMISTRY_HAS_QISKIT = False QDK_CHEMISTRY_HAS_QISKIT_NATURE = False QDK_CHEMISTRY_HAS_QISKIT_AER = False +QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME = False def load(): """Load the Qiskit related plugins into QDK/Chemistry.""" Logger.trace_entering() global _loaded # noqa: PLW0603 - global QDK_CHEMISTRY_HAS_QISKIT, QDK_CHEMISTRY_HAS_QISKIT_NATURE, QDK_CHEMISTRY_HAS_QISKIT_AER # noqa: PLW0603 + global QDK_CHEMISTRY_HAS_QISKIT, QDK_CHEMISTRY_HAS_QISKIT_NATURE # noqa: PLW0603 + global QDK_CHEMISTRY_HAS_QISKIT_AER, QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME # noqa: PLW0603 if _loaded: return _loaded = True @@ -36,6 +38,8 @@ def load(): if importlib.util.find_spec("qiskit_aer") is not None: QDK_CHEMISTRY_HAS_QISKIT_AER = True qiskit_aer_load() + if importlib.util.find_spec("qiskit_ibm_runtime") is not None: + QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME = True def qiskit_load(): diff --git a/python/tests/test_interop_qiskit_circuit_executor.py b/python/tests/test_interop_qiskit_circuit_executor.py index dc4d69428..5b415930d 100644 --- a/python/tests/test_interop_qiskit_circuit_executor.py +++ b/python/tests/test_interop_qiskit_circuit_executor.py @@ -8,7 +8,7 @@ import pytest from qdk_chemistry.data import Circuit, QuantumErrorProfile -from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT_AER +from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT_AER, QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME if QDK_CHEMISTRY_HAS_QISKIT_AER: from qdk_chemistry.plugins.qiskit.circuit_executor import QiskitAerSimulator @@ -89,6 +89,7 @@ def test_circuit_executor_with_error_profile( assert counts.get("11", 0) > 0 assert counts.get("01", 0) + counts.get("10", 0) > 0 # Expect some errors to occur + @pytest.mark.skipif(not QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME, reason="qiskit_ibm_runtime not installed") def test_circuit_executor_with_device_backend(self, test_circuit_2: Circuit): """Test the Qiskit Aer circuit executor with a device backend name string.""" executor = QiskitAerSimulator() From c35c5956be2900c1b7d22e988e8aefc8b30c7a1c Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 8 Apr 2026 17:30:43 +0000 Subject: [PATCH 031/117] added IBM runtime dependency to qiskit-extras --- python/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/python/pyproject.toml b/python/pyproject.toml index 9f0268dc5..756e48b93 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -86,6 +86,7 @@ plugins = [ qiskit-extras = [ "qiskit[qasm3-import]>=2.2.0", "qiskit-aer", + "qiskit-ibm-runtime", "qiskit-nature", "pylatexenc==2.10" ] From e8ec10855d564abc4bde16af8a39750f84ce6255 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 8 Apr 2026 18:23:42 +0000 Subject: [PATCH 032/117] added ibm runtime to sphinx imports --- docs/source/conf.py | 1 + python/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 9c8f1500a..07630f2e0 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -111,6 +111,7 @@ "qiskit", "qiskit_nature", "qiskit_aer", + "qiskit_ibm_runtime", "openfermion", "cirq", "cirq_core", diff --git a/python/pyproject.toml b/python/pyproject.toml index 756e48b93..efae39853 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -86,7 +86,7 @@ plugins = [ qiskit-extras = [ "qiskit[qasm3-import]>=2.2.0", "qiskit-aer", - "qiskit-ibm-runtime", + "qiskit-ibm-runtime>=0.46.1", "qiskit-nature", "pylatexenc==2.10" ] From 090549ead042c98fbb002d68fafdd9dddc6d52c7 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 8 Apr 2026 20:08:12 +0000 Subject: [PATCH 033/117] addressed copilot comments --- .../qdk_chemistry/plugins/qiskit/__init__.py | 22 ++++++++++++++++--- .../plugins/qiskit/circuit_executor.py | 17 +++++++++++--- .../test_interop_qiskit_circuit_executor.py | 14 ++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/python/src/qdk_chemistry/plugins/qiskit/__init__.py b/python/src/qdk_chemistry/plugins/qiskit/__init__.py index e9934f610..d49084f72 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/__init__.py +++ b/python/src/qdk_chemistry/plugins/qiskit/__init__.py @@ -29,18 +29,25 @@ def load(): if _loaded: return _loaded = True + if importlib.util.find_spec("qiskit") is not None: QDK_CHEMISTRY_HAS_QISKIT = True - qiskit_load() if importlib.util.find_spec("qiskit_nature") is not None: QDK_CHEMISTRY_HAS_QISKIT_NATURE = True - qiskit_nature_load() if importlib.util.find_spec("qiskit_aer") is not None: QDK_CHEMISTRY_HAS_QISKIT_AER = True - qiskit_aer_load() if importlib.util.find_spec("qiskit_ibm_runtime") is not None: QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME = True + if QDK_CHEMISTRY_HAS_QISKIT: + qiskit_load() + if QDK_CHEMISTRY_HAS_QISKIT_NATURE: + qiskit_nature_load() + if QDK_CHEMISTRY_HAS_QISKIT_AER: + qiskit_aer_load() + if QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: + qiskit_ibm_runtime_load() + def qiskit_load(): """Load the Qiskit plugins into QDK/Chemistry.""" @@ -71,6 +78,15 @@ def qiskit_nature_load(): Logger.debug(f"Qiskit Nature plugin loaded: [{QiskitQubitMapper().type_name()}: {QiskitQubitMapper().name()}].") +def qiskit_ibm_runtime_load(): + """Load the Qiskit IBM Runtime fake provider into QDK/Chemistry.""" + Logger.trace_entering() + + import qiskit_ibm_runtime.fake_provider # noqa: PLC0415 + + Logger.debug("Qiskit IBM Runtime fake provider loaded.") + + def qiskit_aer_load(): """Load the Qiskit Aer plugin into QDK/Chemistry.""" Logger.trace_entering() diff --git a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py index bc7447e99..c70b57fae 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py +++ b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py @@ -12,13 +12,17 @@ from __future__ import annotations -import qiskit_ibm_runtime.fake_provider from qiskit import transpile from qiskit.providers.exceptions import QiskitBackendNotFoundError from qiskit_aer import AerSimulator from qiskit_aer.noise import NoiseModel from qdk_chemistry.algorithms.circuit_executor.base import CircuitExecutor +from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME + +if QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: + import qiskit_ibm_runtime.fake_provider + from qdk_chemistry.data import ( Circuit, CircuitExecutorData, @@ -97,13 +101,20 @@ def _run_impl( opt_level = self._settings.get("transpile_optimization_level") if device_backend_name is not None: + if not QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: + raise ImportError( + "qiskit_ibm_runtime is required for device backend simulation. " + "Install it with: pip install qiskit-ibm-runtime" + ) + provider = qiskit_ibm_runtime.fake_provider.FakeProviderForBackendV2() try: device_backend = provider.backend(device_backend_name) except QiskitBackendNotFoundError: - available = [b.name for b in provider.backends()] + available = sorted(b.name for b in provider.backends()) + available_backends = ", ".join(available) raise ValueError( - f"Unknown device backend '{device_backend_name}'. Available backends: {available}" + f"Unknown device backend '{device_backend_name}'. Available backends: {available_backends}" ) from None backend = AerSimulator.from_backend(device_backend) diff --git a/python/tests/test_interop_qiskit_circuit_executor.py b/python/tests/test_interop_qiskit_circuit_executor.py index 5b415930d..123df784f 100644 --- a/python/tests/test_interop_qiskit_circuit_executor.py +++ b/python/tests/test_interop_qiskit_circuit_executor.py @@ -98,3 +98,17 @@ def test_circuit_executor_with_device_backend(self, test_circuit_2: Circuit): # Circuit applies X on q[0], so only "01" (little-endian) should appear assert counts.get("01", 0) > 0 assert result.total_shots == 100 + + @pytest.mark.skipif(not QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME, reason="qiskit_ibm_runtime not installed") + def test_noise_and_device_backend_raises(self, test_circuit_1: Circuit, simple_error_profile: QuantumErrorProfile): + """Test that specifying both noise and device_backend_name raises ValueError.""" + executor = QiskitAerSimulator() + with pytest.raises(ValueError, match="Cannot specify both a noise model and a device backend"): + executor.run(test_circuit_1, shots=10, noise=simple_error_profile, device_backend_name="fake_manila") + + @pytest.mark.skipif(not QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME, reason="qiskit_ibm_runtime not installed") + def test_invalid_device_backend_name_raises(self, test_circuit_1: Circuit): + """Test that an invalid device_backend_name raises ValueError with a helpful message.""" + executor = QiskitAerSimulator() + with pytest.raises(ValueError, match="Unknown device backend 'not_a_real_backend'"): + executor.run(test_circuit_1, shots=10, device_backend_name="not_a_real_backend") From 2083ac5cc302a66650ef85b3c72a39b7ffb2a0b2 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 8 Apr 2026 20:08:12 +0000 Subject: [PATCH 034/117] added transpilation passes to circuit_executor --- .../qdk_chemistry/plugins/qiskit/__init__.py | 22 ++++++++-- .../plugins/qiskit/circuit_executor.py | 40 +++++++++++++++++-- .../test_interop_qiskit_circuit_executor.py | 14 +++++++ 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/python/src/qdk_chemistry/plugins/qiskit/__init__.py b/python/src/qdk_chemistry/plugins/qiskit/__init__.py index e9934f610..d49084f72 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/__init__.py +++ b/python/src/qdk_chemistry/plugins/qiskit/__init__.py @@ -29,18 +29,25 @@ def load(): if _loaded: return _loaded = True + if importlib.util.find_spec("qiskit") is not None: QDK_CHEMISTRY_HAS_QISKIT = True - qiskit_load() if importlib.util.find_spec("qiskit_nature") is not None: QDK_CHEMISTRY_HAS_QISKIT_NATURE = True - qiskit_nature_load() if importlib.util.find_spec("qiskit_aer") is not None: QDK_CHEMISTRY_HAS_QISKIT_AER = True - qiskit_aer_load() if importlib.util.find_spec("qiskit_ibm_runtime") is not None: QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME = True + if QDK_CHEMISTRY_HAS_QISKIT: + qiskit_load() + if QDK_CHEMISTRY_HAS_QISKIT_NATURE: + qiskit_nature_load() + if QDK_CHEMISTRY_HAS_QISKIT_AER: + qiskit_aer_load() + if QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: + qiskit_ibm_runtime_load() + def qiskit_load(): """Load the Qiskit plugins into QDK/Chemistry.""" @@ -71,6 +78,15 @@ def qiskit_nature_load(): Logger.debug(f"Qiskit Nature plugin loaded: [{QiskitQubitMapper().type_name()}: {QiskitQubitMapper().name()}].") +def qiskit_ibm_runtime_load(): + """Load the Qiskit IBM Runtime fake provider into QDK/Chemistry.""" + Logger.trace_entering() + + import qiskit_ibm_runtime.fake_provider # noqa: PLC0415 + + Logger.debug("Qiskit IBM Runtime fake provider loaded.") + + def qiskit_aer_load(): """Load the Qiskit Aer plugin into QDK/Chemistry.""" Logger.trace_entering() diff --git a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py index bc7447e99..f69a2968b 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py +++ b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py @@ -12,13 +12,18 @@ from __future__ import annotations -import qiskit_ibm_runtime.fake_provider from qiskit import transpile from qiskit.providers.exceptions import QiskitBackendNotFoundError +from qiskit.transpiler import PassManager from qiskit_aer import AerSimulator from qiskit_aer.noise import NoiseModel from qdk_chemistry.algorithms.circuit_executor.base import CircuitExecutor +from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME + +if QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: + import qiskit_ibm_runtime.fake_provider + from qdk_chemistry.data import ( Circuit, CircuitExecutorData, @@ -28,6 +33,12 @@ from qdk_chemistry.plugins.qiskit._interop.noise_model import ( get_noise_model_from_profile, ) +from qdk_chemistry.plugins.qiskit._interop.transpiler import ( + FactorCliffordFromRz, + FactorPauliFromRotation, + MergeZBasisRotations, + SubstituteCliffordRz, +) from qdk_chemistry.utils import Logger __all__: list[str] = ["QiskitAerSimulator", "QiskitAerSimulatorSettings"] @@ -91,19 +102,34 @@ def _run_impl( Logger.trace_entering() meas_circuit = circuit.get_qiskit_circuit() Logger.debug("Qiskit QuantumCircuit loaded.") + if noise is not None and device_backend_name is not None: raise ValueError("Cannot specify both a noise model and a device backend. Please choose one or the other.") opt_level = self._settings.get("transpile_optimization_level") + meas_circuit = PassManager( + [ + FactorCliffordFromRz(), + FactorPauliFromRotation(), + ] + ).run(meas_circuit) + if device_backend_name is not None: + if not QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: + raise ImportError( + "qiskit_ibm_runtime is required for device backend simulation. " + "Install it with: pip install qiskit-ibm-runtime" + ) + provider = qiskit_ibm_runtime.fake_provider.FakeProviderForBackendV2() try: device_backend = provider.backend(device_backend_name) except QiskitBackendNotFoundError: - available = [b.name for b in provider.backends()] + available = sorted(b.name for b in provider.backends()) + available_backends = ", ".join(available) raise ValueError( - f"Unknown device backend '{device_backend_name}'. Available backends: {available}" + f"Unknown device backend '{device_backend_name}'. Available backends: {available_backends}" ) from None backend = AerSimulator.from_backend(device_backend) @@ -138,6 +164,14 @@ def _run_impl( basis_gates=NoiseModel().basis_gates, optimization_level=opt_level, ) + + transpiled_circuit = PassManager( + [ + MergeZBasisRotations(), + SubstituteCliffordRz(), + ] + ).run(transpiled_circuit) + raw_results = backend.run(transpiled_circuit, shots=shots).result() counts = raw_results.get_counts() Logger.debug(f"Measurement results obtained: {counts}") diff --git a/python/tests/test_interop_qiskit_circuit_executor.py b/python/tests/test_interop_qiskit_circuit_executor.py index 5b415930d..123df784f 100644 --- a/python/tests/test_interop_qiskit_circuit_executor.py +++ b/python/tests/test_interop_qiskit_circuit_executor.py @@ -98,3 +98,17 @@ def test_circuit_executor_with_device_backend(self, test_circuit_2: Circuit): # Circuit applies X on q[0], so only "01" (little-endian) should appear assert counts.get("01", 0) > 0 assert result.total_shots == 100 + + @pytest.mark.skipif(not QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME, reason="qiskit_ibm_runtime not installed") + def test_noise_and_device_backend_raises(self, test_circuit_1: Circuit, simple_error_profile: QuantumErrorProfile): + """Test that specifying both noise and device_backend_name raises ValueError.""" + executor = QiskitAerSimulator() + with pytest.raises(ValueError, match="Cannot specify both a noise model and a device backend"): + executor.run(test_circuit_1, shots=10, noise=simple_error_profile, device_backend_name="fake_manila") + + @pytest.mark.skipif(not QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME, reason="qiskit_ibm_runtime not installed") + def test_invalid_device_backend_name_raises(self, test_circuit_1: Circuit): + """Test that an invalid device_backend_name raises ValueError with a helpful message.""" + executor = QiskitAerSimulator() + with pytest.raises(ValueError, match="Unknown device backend 'not_a_real_backend'"): + executor.run(test_circuit_1, shots=10, device_backend_name="not_a_real_backend") From 7b81d0c4704702a9f69f1c1ebee54c7c5311e5ab Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 8 Apr 2026 21:44:15 +0000 Subject: [PATCH 035/117] more copilot comments --- .../src/qdk_chemistry/algorithms/circuit_executor/base.py | 2 ++ python/src/qdk_chemistry/plugins/qiskit/__init__.py | 5 ++++- .../src/qdk_chemistry/plugins/qiskit/circuit_executor.py | 8 ++++---- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/circuit_executor/base.py b/python/src/qdk_chemistry/algorithms/circuit_executor/base.py index b6adeefbe..cfc425e77 100644 --- a/python/src/qdk_chemistry/algorithms/circuit_executor/base.py +++ b/python/src/qdk_chemistry/algorithms/circuit_executor/base.py @@ -36,6 +36,7 @@ def _run_impl( circuit: Circuit, shots: int, noise: QuantumErrorProfile | None = None, + device_backend_name: str | None = None, ) -> CircuitExecutorData: """Execute the given quantum circuit and get measurement results. @@ -43,6 +44,7 @@ def _run_impl( circuit: The circuit that prepares the initial state. shots: The number of shots to execute the circuit. noise: Optional noise profile to apply during execution. + device_backend_name: Optional name of an IBM device backend to transpile to. Returns: CircuitExecutorData: Object containing the results of the circuit execution. diff --git a/python/src/qdk_chemistry/plugins/qiskit/__init__.py b/python/src/qdk_chemistry/plugins/qiskit/__init__.py index d49084f72..355494645 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/__init__.py +++ b/python/src/qdk_chemistry/plugins/qiskit/__init__.py @@ -36,7 +36,10 @@ def load(): QDK_CHEMISTRY_HAS_QISKIT_NATURE = True if importlib.util.find_spec("qiskit_aer") is not None: QDK_CHEMISTRY_HAS_QISKIT_AER = True - if importlib.util.find_spec("qiskit_ibm_runtime") is not None: + if ( + importlib.util.find_spec("qiskit_ibm_runtime") is not None + and importlib.util.find_spec("qiskit_ibm_runtime.fake_provider") is not None + ): QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME = True if QDK_CHEMISTRY_HAS_QISKIT: diff --git a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py index f69a2968b..4043273cb 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py +++ b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py @@ -18,10 +18,10 @@ from qiskit_aer import AerSimulator from qiskit_aer.noise import NoiseModel +import qdk_chemistry.plugins.qiskit as qiskit_plugin from qdk_chemistry.algorithms.circuit_executor.base import CircuitExecutor -from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME -if QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: +if qiskit_plugin.QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: import qiskit_ibm_runtime.fake_provider from qdk_chemistry.data import ( @@ -116,9 +116,9 @@ def _run_impl( ).run(meas_circuit) if device_backend_name is not None: - if not QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: + if not qiskit_plugin.QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: raise ImportError( - "qiskit_ibm_runtime is required for device backend simulation. " + "The fake_provider module from qiskit_ibm_runtime is required for device backend simulation. " "Install it with: pip install qiskit-ibm-runtime" ) From 57131a7d465938fa3db6b5ffe77a95987c4bdebb Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 8 Apr 2026 21:48:21 +0000 Subject: [PATCH 036/117] updated files --- python/src/qdk_chemistry/plugins/qiskit/__init__.py | 4 ---- .../qdk_chemistry/plugins/qiskit/circuit_executor.py | 12 ------------ 2 files changed, 16 deletions(-) diff --git a/python/src/qdk_chemistry/plugins/qiskit/__init__.py b/python/src/qdk_chemistry/plugins/qiskit/__init__.py index 9daa2e0a0..355494645 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/__init__.py +++ b/python/src/qdk_chemistry/plugins/qiskit/__init__.py @@ -36,14 +36,10 @@ def load(): QDK_CHEMISTRY_HAS_QISKIT_NATURE = True if importlib.util.find_spec("qiskit_aer") is not None: QDK_CHEMISTRY_HAS_QISKIT_AER = True -<<<<<<< HEAD if ( importlib.util.find_spec("qiskit_ibm_runtime") is not None and importlib.util.find_spec("qiskit_ibm_runtime.fake_provider") is not None ): -======= - if importlib.util.find_spec("qiskit_ibm_runtime") is not None: ->>>>>>> origin/feature/agamshayit/transpile_to_backend QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME = True if QDK_CHEMISTRY_HAS_QISKIT: diff --git a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py index 5267b6d43..4043273cb 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py +++ b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py @@ -20,14 +20,8 @@ import qdk_chemistry.plugins.qiskit as qiskit_plugin from qdk_chemistry.algorithms.circuit_executor.base import CircuitExecutor -<<<<<<< HEAD if qiskit_plugin.QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: -======= -from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME - -if QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: ->>>>>>> origin/feature/agamshayit/transpile_to_backend import qiskit_ibm_runtime.fake_provider from qdk_chemistry.data import ( @@ -122,15 +116,9 @@ def _run_impl( ).run(meas_circuit) if device_backend_name is not None: -<<<<<<< HEAD if not qiskit_plugin.QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: raise ImportError( "The fake_provider module from qiskit_ibm_runtime is required for device backend simulation. " -======= - if not QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: - raise ImportError( - "qiskit_ibm_runtime is required for device backend simulation. " ->>>>>>> origin/feature/agamshayit/transpile_to_backend "Install it with: pip install qiskit-ibm-runtime" ) From 3e2d8c5265a62c87a11fca8a327ec417b172ced8 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 8 Apr 2026 21:54:43 +0000 Subject: [PATCH 037/117] reverted unrelated changes to circuit_executor --- .../plugins/qiskit/circuit_executor.py | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py index 4043273cb..a3fa5922b 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py +++ b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py @@ -14,7 +14,6 @@ from qiskit import transpile from qiskit.providers.exceptions import QiskitBackendNotFoundError -from qiskit.transpiler import PassManager from qiskit_aer import AerSimulator from qiskit_aer.noise import NoiseModel @@ -33,12 +32,6 @@ from qdk_chemistry.plugins.qiskit._interop.noise_model import ( get_noise_model_from_profile, ) -from qdk_chemistry.plugins.qiskit._interop.transpiler import ( - FactorCliffordFromRz, - FactorPauliFromRotation, - MergeZBasisRotations, - SubstituteCliffordRz, -) from qdk_chemistry.utils import Logger __all__: list[str] = ["QiskitAerSimulator", "QiskitAerSimulatorSettings"] @@ -108,13 +101,6 @@ def _run_impl( opt_level = self._settings.get("transpile_optimization_level") - meas_circuit = PassManager( - [ - FactorCliffordFromRz(), - FactorPauliFromRotation(), - ] - ).run(meas_circuit) - if device_backend_name is not None: if not qiskit_plugin.QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME: raise ImportError( @@ -165,13 +151,6 @@ def _run_impl( optimization_level=opt_level, ) - transpiled_circuit = PassManager( - [ - MergeZBasisRotations(), - SubstituteCliffordRz(), - ] - ).run(transpiled_circuit) - raw_results = backend.run(transpiled_circuit, shots=shots).result() counts = raw_results.get_counts() Logger.debug(f"Measurement results obtained: {counts}") From 2202c12abe5d37008ae5fb39ce1b0097698baec0 Mon Sep 17 00:00:00 2001 From: v-agamshayit Date: Wed, 8 Apr 2026 15:08:11 -0700 Subject: [PATCH 038/117] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- python/src/qdk_chemistry/algorithms/circuit_executor/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/src/qdk_chemistry/algorithms/circuit_executor/base.py b/python/src/qdk_chemistry/algorithms/circuit_executor/base.py index cfc425e77..b43f1b50f 100644 --- a/python/src/qdk_chemistry/algorithms/circuit_executor/base.py +++ b/python/src/qdk_chemistry/algorithms/circuit_executor/base.py @@ -44,7 +44,9 @@ def _run_impl( circuit: The circuit that prepares the initial state. shots: The number of shots to execute the circuit. noise: Optional noise profile to apply during execution. - device_backend_name: Optional name of an IBM device backend to transpile to. + device_backend_name: Optional name of an IBM fake device backend + from ``qiskit_ibm_runtime.fake_provider`` to transpile to + (for example, ``fake_manila``). Requires ``qiskit-ibm-runtime``. Returns: CircuitExecutorData: Object containing the results of the circuit execution. From 3b7cee3871a6942c7a9076dc7d234e19489ab526 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Thu, 9 Apr 2026 19:48:31 +0000 Subject: [PATCH 039/117] restored registry.pyi --- .../src/qdk_chemistry/algorithms/registry.pyi | 461 +----------------- 1 file changed, 1 insertion(+), 460 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/registry.pyi b/python/src/qdk_chemistry/algorithms/registry.pyi index 0f0a4ef8a..dcb38eb2c 100644 --- a/python/src/qdk_chemistry/algorithms/registry.pyi +++ b/python/src/qdk_chemistry/algorithms/registry.pyi @@ -1,460 +1 @@ -"""Type stubs for registry.create() with all algorithm overloads.""" - -from typing import Literal, overload, Union -from .base import Algorithm - -import qdk_chemistry.algorithms.active_space_selector -import qdk_chemistry.algorithms.circuit_executor.qdk -import qdk_chemistry.algorithms.dynamical_correlation_calculator -import qdk_chemistry.algorithms.energy_estimator.qdk -import qdk_chemistry.algorithms.hamiltonian_constructor -import qdk_chemistry.algorithms.multi_configuration_calculator -import qdk_chemistry.algorithms.orbital_localizer -import qdk_chemistry.algorithms.phase_estimation.iterative_phase_estimation -import qdk_chemistry.algorithms.projected_multi_configuration_calculator -import qdk_chemistry.algorithms.qubit_hamiltonian_solver -import qdk_chemistry.algorithms.qubit_mapper.qdk_qubit_mapper -import qdk_chemistry.algorithms.scf_solver -import qdk_chemistry.algorithms.stability_checker -import qdk_chemistry.algorithms.state_preparation.sparse_isometry -import qdk_chemistry.algorithms.time_evolution.builder.partially_randomized -import qdk_chemistry.algorithms.time_evolution.builder.qdrift -import qdk_chemistry.algorithms.time_evolution.builder.trotter -import qdk_chemistry.algorithms.time_evolution.circuit_mapper.pauli_sequence_mapper -import qdk_chemistry.algorithms.time_evolution.controlled_circuit_mapper.pauli_sequence_mapper -import qdk_chemistry.algorithms.time_evolution.measure_simulation.evolve_and_measure -import qdk_chemistry.plugins.openfermion.qubit_mapper -import qdk_chemistry.plugins.pyscf.active_space_avas -import qdk_chemistry.plugins.pyscf.coupled_cluster -import qdk_chemistry.plugins.pyscf.localization -import qdk_chemistry.plugins.pyscf.mcscf -import qdk_chemistry.plugins.pyscf.scf_solver -import qdk_chemistry.plugins.pyscf.stability -import qdk_chemistry.plugins.qiskit.circuit_executor -import qdk_chemistry.plugins.qiskit.qubit_mapper -import qdk_chemistry.plugins.qiskit.regular_isometry -import qdk_chemistry.plugins.qiskit.standard_phase_estimation - -@overload -def create( - algorithm_type: Literal['active_space_selector'], - algorithm_name: Literal['pyscf_avas'] | None = None, - ao_labels: list[str] = [], - canonicalize: bool = False, - openshell_option: unknown = 2, -) -> qdk_chemistry.plugins.pyscf.active_space_avas.PyscfAVAS: ... - -@overload -def create( - algorithm_type: Literal['active_space_selector'], - algorithm_name: Literal['qdk_occupation'] | None = None, - occupation_threshold: float = 0.1, -) -> qdk_chemistry.algorithms.active_space_selector.QdkOccupationActiveSpaceSelector: ... - -@overload -def create( - algorithm_type: Literal['active_space_selector'], - algorithm_name: Literal['qdk_autocas_eos'] | None = None, - diff_threshold: float = 0.1, - entropy_threshold: float = 0.14, - normalize_entropies: bool = True, -) -> qdk_chemistry.algorithms.active_space_selector.QdkAutocasEosActiveSpaceSelector: ... - -@overload -def create( - algorithm_type: Literal['active_space_selector'], - algorithm_name: Literal['qdk_autocas'] | None = None, - entropy_threshold: float = 0.14, - min_plateau_size: unknown = 10, - normalize_entropies: bool = True, - num_bins: unknown = 100, -) -> qdk_chemistry.algorithms.active_space_selector.QdkAutocasActiveSpaceSelector: ... - -@overload -def create( - algorithm_type: Literal['active_space_selector'], - algorithm_name: Literal['qdk_valence'] | None = None, - num_active_electrons: unknown = -1, - num_active_orbitals: unknown = -1, -) -> qdk_chemistry.algorithms.active_space_selector.QdkValenceActiveSpaceSelector: ... - -@overload -def create( - algorithm_type: Literal['hamiltonian_constructor'], - algorithm_name: Literal['qdk_cholesky'] | None = None, - cholesky_tolerance: float = 1e-08, - eri_threshold: float = 1e-12, - scf_type: str = "auto", - store_cholesky_vectors: bool = False, -) -> qdk_chemistry.algorithms.hamiltonian_constructor.QdkCholeskyHamiltonianConstructor: ... - -@overload -def create( - algorithm_type: Literal['hamiltonian_constructor'], - algorithm_name: Literal['qdk'] | None = None, - eri_method: str = "direct", - scf_type: str = "auto", -) -> qdk_chemistry.algorithms.hamiltonian_constructor.QdkHamiltonianConstructor: ... - -@overload -def create( - algorithm_type: Literal['orbital_localizer'], - algorithm_name: Literal['pyscf_multi'] | None = None, - method: str = "pipek-mezey", - occupation_threshold: float = 1e-10, - population_method: str = "mulliken", -) -> qdk_chemistry.plugins.pyscf.localization.PyscfLocalizer: ... - -@overload -def create( - algorithm_type: Literal['orbital_localizer'], - algorithm_name: Literal['qdk_vvhv'] | None = None, - max_iterations: unknown = 10000, - minimal_basis: str = "sto-3g", - small_rotation_tolerance: float = 1e-12, - tolerance: float = 1e-06, - weighted_orthogonalization: bool = True, -) -> qdk_chemistry.algorithms.orbital_localizer.QdkVVHVLocalizer: ... - -@overload -def create( - algorithm_type: Literal['orbital_localizer'], - algorithm_name: Literal['qdk_mp2_natural_orbitals'] | None = None, -) -> qdk_chemistry.algorithms.orbital_localizer.QdkMP2NaturalOrbitalLocalizer: ... - -@overload -def create( - algorithm_type: Literal['orbital_localizer'], - algorithm_name: Literal['qdk_pipek_mezey'] | None = None, - max_iterations: unknown = 10000, - small_rotation_tolerance: float = 1e-12, - tolerance: float = 1e-06, -) -> qdk_chemistry.algorithms.orbital_localizer.QdkPipekMezeyLocalizer: ... - -@overload -def create( - algorithm_type: Literal['multi_configuration_calculator'], - algorithm_name: Literal['macis_asci'] | None = None, - calculate_mutual_information: bool = False, - calculate_one_rdm: bool = False, - calculate_single_orbital_entropies: bool = False, - calculate_two_orbital_entropies: bool = False, - calculate_two_rdm: bool = False, - ci_residual_tolerance: float = 1e-06, - constraint_level: unknown = 2, - core_selection_strategy: str = "percentage", - core_selection_threshold: float = 0.95, - grow_factor: float = 8.0, - grow_with_rot: bool = False, - growth_backoff_rate: float = 0.5, - growth_recovery_rate: float = 1.1, - h_el_tol: float = 1e-08, - just_singles: bool = False, - max_refine_iter: unknown = 6, - max_solver_iterations: unknown = 200, - min_grow_factor: float = 1.01, - ncdets_max: unknown = 100, - ntdets_max: unknown = 100000, - ntdets_min: unknown = 100, - nxtval_bcount_inc: unknown = 10, - nxtval_bcount_thresh: unknown = 1000, - pair_size_max: unknown = 500000000, - pt2_bigcon_thresh: unknown = 250, - pt2_constraint_refine_force: unknown = 0, - pt2_max_constraint_level: unknown = 5, - pt2_min_constraint_level: unknown = 0, - pt2_precompute_eps: bool = False, - pt2_precompute_idx: bool = False, - pt2_print_progress: bool = False, - pt2_prune: bool = False, - pt2_reserve_count: unknown = 70000000, - pt2_tol: float = 1e-16, - refine_energy_tol: float = 1e-06, - rot_size_start: unknown = 1000, - rv_prune_tol: float = 1e-08, -) -> qdk_chemistry.algorithms.multi_configuration_calculator.QdkMacisAsci: ... - -@overload -def create( - algorithm_type: Literal['multi_configuration_calculator'], - algorithm_name: Literal['macis_cas'] | None = None, - calculate_mutual_information: bool = False, - calculate_one_rdm: bool = False, - calculate_single_orbital_entropies: bool = False, - calculate_two_orbital_entropies: bool = False, - calculate_two_rdm: bool = False, - ci_residual_tolerance: float = 1e-06, - max_solver_iterations: unknown = 200, -) -> qdk_chemistry.algorithms.multi_configuration_calculator.QdkMacisCas: ... - -@overload -def create( - algorithm_type: Literal['multi_configuration_scf'], - algorithm_name: Literal['pyscf'] | None = None, - max_cycle_macro: unknown = 50, - verbose: unknown = 0, -) -> qdk_chemistry.plugins.pyscf.mcscf.PyscfMcscfCalculator: ... - -@overload -def create( - algorithm_type: Literal['projected_multi_configuration_calculator'], - algorithm_name: Literal['macis_pmc'] | None = None, - H_thresh: float = 1e-16, - calculate_mutual_information: bool = False, - calculate_one_rdm: bool = False, - calculate_single_orbital_entropies: bool = False, - calculate_two_orbital_entropies: bool = False, - calculate_two_rdm: bool = False, - ci_residual_tolerance: float = 1e-06, - davidson_max_m: unknown = 200, - davidson_res_tol: float = 1e-08, - h_el_tol: float = 1e-08, - iterative_solver_dimension_cutoff: unknown = 100, - max_solver_iterations: unknown = 200, -) -> qdk_chemistry.algorithms.projected_multi_configuration_calculator.QdkMacisPmc: ... - -@overload -def create( - algorithm_type: Literal['dynamical_correlation_calculator'], - algorithm_name: Literal['pyscf_coupled_cluster'] | None = None, - async_io: bool = True, - compute_bra: bool = False, - conv_tol: float = 1e-07, - conv_tol_normt: float = 1e-05, - diis_space: unknown = 6, - diis_start_cycle: unknown = 0, - direct: bool = False, - incore_complete: bool = True, - max_cycle: unknown = 50, - store_amplitudes: bool = False, -) -> qdk_chemistry.plugins.pyscf.coupled_cluster.PyscfCoupledClusterCalculator: ... - -@overload -def create( - algorithm_type: Literal['dynamical_correlation_calculator'], - algorithm_name: Literal['qdk_mp2_calculator'] | None = None, -) -> qdk_chemistry.algorithms.dynamical_correlation_calculator.DynamicalCorrelationCalculator: ... - -@overload -def create( - algorithm_type: Literal['scf_solver'], - algorithm_name: Literal['pyscf'] | None = None, - convergence_threshold: float = 1e-07, - max_iterations: unknown = 50, - method: str = "hf", - scf_type: str = "auto", - xc_grid: unknown = 3, -) -> qdk_chemistry.plugins.pyscf.scf_solver.PyscfScfSolver: ... - -@overload -def create( - algorithm_type: Literal['scf_solver'], - algorithm_name: Literal['qdk'] | None = None, - convergence_threshold: float = 1e-07, - enable_gdm: bool = True, - energy_thresh_diis_switch: float = 0.001, - eri_method: str = "direct", - eri_threshold: float = -1.0, - eri_use_atomics: bool = False, - fock_reset_steps: unknown = 1073741824, - gdm_bfgs_history_size_limit: unknown = 50, - gdm_max_diis_iteration: unknown = 50, - level_shift: float = -1.0, - max_iterations: unknown = 50, - method: str = "hf", - nthreads: unknown = -1, - scf_type: str = "auto", - shell_pair_threshold: float = 1e-12, -) -> qdk_chemistry.algorithms.scf_solver.QdkScfSolver: ... - -@overload -def create( - algorithm_type: Literal['stability_checker'], - algorithm_name: Literal['pyscf'] | None = None, - davidson_tolerance: float = 1e-08, - external: bool = True, - internal: bool = True, - method: str = "hf", - nroots: unknown = 3, - pyscf_verbose: unknown = 4, - stability_tolerance: float = -0.0001, - with_symmetry: bool = False, - xc_grid: unknown = 3, -) -> qdk_chemistry.plugins.pyscf.stability.PyscfStabilityChecker: ... - -@overload -def create( - algorithm_type: Literal['stability_checker'], - algorithm_name: Literal['qdk'] | None = None, - davidson_tolerance: float = 1e-08, - external: bool = False, - internal: bool = True, - max_subspace: unknown = 80, - method: str = "hf", - stability_tolerance: float = -0.0001, -) -> qdk_chemistry.algorithms.stability_checker.QdkStabilityChecker: ... - -@overload -def create( - algorithm_type: Literal['energy_estimator'], - algorithm_name: Literal['qdk'] | None = None, -) -> qdk_chemistry.algorithms.energy_estimator.qdk.QdkEnergyEstimator: ... - -@overload -def create( - algorithm_type: Literal['evolution_circuit_mapper'], - algorithm_name: Literal['pauli_sequence'] | None = None, -) -> qdk_chemistry.algorithms.time_evolution.circuit_mapper.pauli_sequence_mapper.PauliSequenceMapper: ... - -@overload -def create( - algorithm_type: Literal['measure_simulation'], - algorithm_name: Literal['classical_sampling'] | None = None, -) -> qdk_chemistry.algorithms.time_evolution.measure_simulation.evolve_and_measure.EvolveAndMeasure: ... - -@overload -def create( - algorithm_type: Literal['state_prep'], - algorithm_name: Literal['sparse_isometry_gf2x'] | None = None, - basis_gates: list[str] = ['x', 'y', 'z', 'cx', 'cz', 'id', 'h', 's', 'sdg', 'rz'], - dense_preparation_method: str = "qdk", - transpile: bool = True, - transpile_optimization_level: unknown = 0, -) -> qdk_chemistry.algorithms.state_preparation.sparse_isometry.SparseIsometryGF2XStatePreparation: ... - -@overload -def create( - algorithm_type: Literal['state_prep'], - algorithm_name: Literal['qiskit_regular_isometry'] | None = None, - basis_gates: list[str] = ['x', 'y', 'z', 'cx', 'cz', 'id', 'h', 's', 'sdg', 'rz'], - transpile: bool = True, - transpile_optimization_level: unknown = 0, -) -> qdk_chemistry.plugins.qiskit.regular_isometry.RegularIsometryStatePreparation: ... - -@overload -def create( - algorithm_type: Literal['qubit_mapper'], - algorithm_name: Literal['qdk'] | None = None, - encoding: str = "jordan-wigner", - integral_threshold: float = 1e-12, - threshold: float = 1e-12, -) -> qdk_chemistry.algorithms.qubit_mapper.qdk_qubit_mapper.QdkQubitMapper: ... - -@overload -def create( - algorithm_type: Literal['qubit_mapper'], - algorithm_name: Literal['qiskit'] | None = None, - encoding: str = "jordan-wigner", -) -> qdk_chemistry.plugins.qiskit.qubit_mapper.QiskitQubitMapper: ... - -@overload -def create( - algorithm_type: Literal['qubit_mapper'], - algorithm_name: Literal['openfermion'] | None = None, - encoding: str = "jordan-wigner", -) -> qdk_chemistry.plugins.openfermion.qubit_mapper.OpenFermionQubitMapper: ... - -@overload -def create( - algorithm_type: Literal['qubit_hamiltonian_solver'], - algorithm_name: Literal['qdk_dense_matrix_solver'] | None = None, -) -> qdk_chemistry.algorithms.qubit_hamiltonian_solver.DenseMatrixSolver: ... - -@overload -def create( - algorithm_type: Literal['qubit_hamiltonian_solver'], - algorithm_name: Literal['qdk_sparse_matrix_solver'] | None = None, - max_m: unknown = 20, - tol: float = 1e-08, -) -> qdk_chemistry.algorithms.qubit_hamiltonian_solver.SparseMatrixSolver: ... - -@overload -def create( - algorithm_type: Literal['time_evolution_builder'], - algorithm_name: Literal['trotter'] | None = None, - error_bound: str = "commutator", - num_divisions: unknown = 0, - optimize_term_ordering: bool = False, - order: unknown = 1, - target_accuracy: float = 0.0, - weight_threshold: float = 1e-12, -) -> qdk_chemistry.algorithms.time_evolution.builder.trotter.Trotter: ... - -@overload -def create( - algorithm_type: Literal['time_evolution_builder'], - algorithm_name: Literal['qdrift'] | None = None, - commutation_type: str = "general", - merge_duplicate_terms: bool = True, - num_samples: unknown = 100, - seed: unknown = -1, -) -> qdk_chemistry.algorithms.time_evolution.builder.qdrift.QDrift: ... - -@overload -def create( - algorithm_type: Literal['time_evolution_builder'], - algorithm_name: Literal['partially_randomized'] | None = None, - commutation_type: str = "general", - merge_duplicate_terms: bool = True, - num_random_samples: unknown = 100, - seed: unknown = -1, - tolerance: float = 1e-12, - trotter_order: unknown = 2, - weight_threshold: float = -1.0, -) -> qdk_chemistry.algorithms.time_evolution.builder.partially_randomized.PartiallyRandomized: ... - -@overload -def create( - algorithm_type: Literal['controlled_evolution_circuit_mapper'], - algorithm_name: Literal['pauli_sequence'] | None = None, - power: unknown = 1, -) -> qdk_chemistry.algorithms.time_evolution.controlled_circuit_mapper.pauli_sequence_mapper.PauliSequenceMapper: ... - -@overload -def create( - algorithm_type: Literal['circuit_executor'], - algorithm_name: Literal['qdk_full_state_simulator'] | None = None, - seed: unknown = 42, - type: str = "cpu", -) -> qdk_chemistry.algorithms.circuit_executor.qdk.QdkFullStateSimulator: ... - -@overload -def create( - algorithm_type: Literal['circuit_executor'], - algorithm_name: Literal['qdk_sparse_state_simulator'] | None = None, - seed: unknown = 42, -) -> qdk_chemistry.algorithms.circuit_executor.qdk.QdkSparseStateSimulator: ... - -@overload -def create( - algorithm_type: Literal['circuit_executor'], - algorithm_name: Literal['qiskit_aer_simulator'] | None = None, - method: str = "statevector", - seed: unknown = 42, - transpile_optimization_level: unknown = 0, -) -> qdk_chemistry.plugins.qiskit.circuit_executor.QiskitAerSimulator: ... - -@overload -def create( - algorithm_type: Literal['phase_estimation'], - algorithm_name: Literal['iterative'] | None = None, - evolution_time: float = 0.0, - num_bits: unknown = -1, - shots_per_bit: unknown = 3, -) -> qdk_chemistry.algorithms.phase_estimation.iterative_phase_estimation.IterativePhaseEstimation: ... - -@overload -def create( - algorithm_type: Literal['phase_estimation'], - algorithm_name: Literal['qiskit_standard'] | None = None, - evolution_time: float = 0.0, - num_bits: unknown = -1, - qft_do_swaps: bool = True, - shots: unknown = 3, -) -> qdk_chemistry.plugins.qiskit.standard_phase_estimation.QiskitStandardPhaseEstimation: ... - -def create( - algorithm_type: str, - algorithm_name: str | None = None, - **kwargs, -) -> Union[Algorithm | qdk_chemistry.algorithms.active_space_selector.QdkAutocasActiveSpaceSelector | qdk_chemistry.algorithms.active_space_selector.QdkAutocasEosActiveSpaceSelector | qdk_chemistry.algorithms.active_space_selector.QdkOccupationActiveSpaceSelector | qdk_chemistry.algorithms.active_space_selector.QdkValenceActiveSpaceSelector | qdk_chemistry.algorithms.circuit_executor.qdk.QdkFullStateSimulator | qdk_chemistry.algorithms.circuit_executor.qdk.QdkSparseStateSimulator | qdk_chemistry.algorithms.dynamical_correlation_calculator.DynamicalCorrelationCalculator | qdk_chemistry.algorithms.energy_estimator.qdk.QdkEnergyEstimator | qdk_chemistry.algorithms.hamiltonian_constructor.QdkCholeskyHamiltonianConstructor | qdk_chemistry.algorithms.hamiltonian_constructor.QdkHamiltonianConstructor | qdk_chemistry.algorithms.multi_configuration_calculator.QdkMacisAsci | qdk_chemistry.algorithms.multi_configuration_calculator.QdkMacisCas | qdk_chemistry.algorithms.orbital_localizer.QdkMP2NaturalOrbitalLocalizer | qdk_chemistry.algorithms.orbital_localizer.QdkPipekMezeyLocalizer | qdk_chemistry.algorithms.orbital_localizer.QdkVVHVLocalizer | qdk_chemistry.algorithms.phase_estimation.iterative_phase_estimation.IterativePhaseEstimation | qdk_chemistry.algorithms.projected_multi_configuration_calculator.QdkMacisPmc | qdk_chemistry.algorithms.qubit_hamiltonian_solver.DenseMatrixSolver | qdk_chemistry.algorithms.qubit_hamiltonian_solver.SparseMatrixSolver | qdk_chemistry.algorithms.qubit_mapper.qdk_qubit_mapper.QdkQubitMapper | qdk_chemistry.algorithms.scf_solver.QdkScfSolver | qdk_chemistry.algorithms.stability_checker.QdkStabilityChecker | qdk_chemistry.algorithms.state_preparation.sparse_isometry.SparseIsometryGF2XStatePreparation | qdk_chemistry.algorithms.time_evolution.builder.partially_randomized.PartiallyRandomized | qdk_chemistry.algorithms.time_evolution.builder.qdrift.QDrift | qdk_chemistry.algorithms.time_evolution.builder.trotter.Trotter | qdk_chemistry.algorithms.time_evolution.circuit_mapper.pauli_sequence_mapper.PauliSequenceMapper | qdk_chemistry.algorithms.time_evolution.controlled_circuit_mapper.pauli_sequence_mapper.PauliSequenceMapper | qdk_chemistry.algorithms.time_evolution.measure_simulation.evolve_and_measure.EvolveAndMeasure | qdk_chemistry.plugins.openfermion.qubit_mapper.OpenFermionQubitMapper | qdk_chemistry.plugins.pyscf.active_space_avas.PyscfAVAS | qdk_chemistry.plugins.pyscf.coupled_cluster.PyscfCoupledClusterCalculator | qdk_chemistry.plugins.pyscf.localization.PyscfLocalizer | qdk_chemistry.plugins.pyscf.mcscf.PyscfMcscfCalculator | qdk_chemistry.plugins.pyscf.scf_solver.PyscfScfSolver | qdk_chemistry.plugins.pyscf.stability.PyscfStabilityChecker | qdk_chemistry.plugins.qiskit.circuit_executor.QiskitAerSimulator | qdk_chemistry.plugins.qiskit.qubit_mapper.QiskitQubitMapper | qdk_chemistry.plugins.qiskit.regular_isometry.RegularIsometryStatePreparation | qdk_chemistry.plugins.qiskit.standard_phase_estimation.QiskitStandardPhaseEstimation]: ... +# This file is a placeholder and will be replaced with generated stubs on first import From be7bd0859ed1f03af23bbbb4eed338a05ce63928 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Thu, 9 Apr 2026 19:58:55 +0000 Subject: [PATCH 040/117] copilot comments --- .../algorithms/energy_estimator/qdk.py | 11 +++ .../time_evolution/builder/trotter.py | 92 ------------------- .../plugins/qiskit/circuit_executor.py | 1 - python/tests/test_evolve_and_measure.py | 6 +- 4 files changed, 14 insertions(+), 96 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py b/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py index f9fef00a2..ec2b6e6c5 100644 --- a/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py +++ b/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py @@ -225,6 +225,17 @@ def _run_impl( f"device_backend_name is only supported with 'qiskit_aer_simulator', " f"but circuit_executor is '{circuit_executor.name()}'." ) + if pre_transpilation_passes is not None and circuit_executor.name() != "qiskit_aer_simulator": + raise ValueError( + f"pre_transpilation_passes is only supported with 'qiskit_aer_simulator', " + f"but circuit_executor is '{circuit_executor.name()}'." + ) + if post_transpilation_passes is not None and circuit_executor.name() != "qiskit_aer_simulator": + raise ValueError( + f"post_transpilation_passes is only supported with 'qiskit_aer_simulator', " + f"but circuit_executor is '{circuit_executor.name()}'." + ) + qubit_hamiltonians = qubit_hamiltonian.group_commuting(qubit_wise=True) num_observables = len(qubit_hamiltonians) if total_shots < num_observables: diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index 7db37cd5c..9804ceff0 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -20,13 +20,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING - import numpy as np -if TYPE_CHECKING: - from qiskit.circuit import QuantumCircuit - from qdk_chemistry.algorithms.time_evolution.builder.base import TimeEvolutionBuilder from qdk_chemistry.algorithms.time_evolution.builder.trotter_error import ( trotter_steps_commutator, @@ -527,90 +522,3 @@ def name(self) -> str: def type_name(self) -> str: """Return time_evolution_builder as the algorithm type name.""" return "time_evolution_builder" - - -def encoding_clifford_circuit( - qubit_hamiltonians: list[QubitHamiltonian], -) -> QuantumCircuit: - """Build a Clifford circuit from the encoding Clifford of Hamiltonian Pauli generators. - - Collects all non-identity Pauli strings from the given Hamiltonians, - computes the encoding Clifford via ``paulimer.encoding_clifford_of``, - and synthesises it into a Qiskit :class:`QuantumCircuit` of elementary - Clifford gates (H, S, CX). - - The Pauli strings across all Hamiltonians must be mutually commuting - (i.e. they form a valid stabilizer group), otherwise ``encoding_clifford_of`` - will raise an error. - - Args: - qubit_hamiltonians: Hamiltonians whose Pauli strings serve as - generators for the encoding Clifford. - - Returns: - A :class:`~qiskit.circuit.QuantumCircuit` implementing the encoding - Clifford in terms of H, S, and CX gates. - - Raises: - ValueError: If no Hamiltonians are provided or qubit counts are inconsistent. - - """ - from paulimer import DensePauli # noqa: PLC0415 - from paulimer import encoding_clifford_of as _encoding_clifford_of # noqa: PLC0415 - - if not qubit_hamiltonians: - raise ValueError("At least one QubitHamiltonian is required.") - - num_qubits = qubit_hamiltonians[0].num_qubits - - # Collect unique non-identity Pauli generators from all Hamiltonians. - seen: set[str] = set() - generators: list[DensePauli] = [] - for qh in qubit_hamiltonians: - if qh.num_qubits != num_qubits: - raise ValueError("All QubitHamiltonians must have the same number of qubits.") - for label in qh.pauli_strings: - if label not in seen and any(c != "I" for c in label): - seen.add(label) - generators.append(DensePauli(label)) - - clifford_unitary = _encoding_clifford_of(generators, num_qubits) - - return _clifford_unitary_to_circuit(clifford_unitary) - - -def _clifford_unitary_to_circuit(clifford_unitary) -> QuantumCircuit: - """Convert a paulimer ``CliffordUnitary`` to a Qiskit ``QuantumCircuit``. - - Builds a Qiskit :class:`~qiskit.quantum_info.Clifford` from the - stabilizer tableau and synthesises it into elementary gates. - - Args: - clifford_unitary: A ``paulimer.CliffordUnitary`` instance. - - Returns: - A :class:`~qiskit.circuit.QuantumCircuit` of H, S, and CX gates. - - """ - from qiskit.quantum_info import Clifford # noqa: PLC0415 - - n = clifford_unitary.qubit_count - tableau = np.zeros((2 * n, 2 * n + 1), dtype=bool) - - for i in range(n): - img_x = clifford_unitary.image_x(i) - img_z = clifford_unitary.image_z(i) - - for j in range(n): - cx = img_x.characters[j] - tableau[i, j] = cx in ("X", "Y") - tableau[i, j + n] = cx in ("Z", "Y") - - cz = img_z.characters[j] - tableau[i + n, j] = cz in ("X", "Y") - tableau[i + n, j + n] = cz in ("Z", "Y") - - tableau[i, 2 * n] = img_x.phase.real < 0 - tableau[i + n, 2 * n] = img_z.phase.real < 0 - - return Clifford(tableau).to_circuit() diff --git a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py index 4d7c377de..91be814e2 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py +++ b/python/src/qdk_chemistry/plugins/qiskit/circuit_executor.py @@ -34,7 +34,6 @@ from qdk_chemistry.plugins.qiskit._interop.noise_model import ( get_noise_model_from_profile, ) -from qdk_chemistry.plugins.qiskit._interop.transpiler import * # noqa: F403 from qdk_chemistry.utils import Logger __all__: list[str] = ["QiskitAerSimulator", "QiskitAerSimulatorSettings"] diff --git a/python/tests/test_evolve_and_measure.py b/python/tests/test_evolve_and_measure.py index 93e5e74d4..0c76b8fa9 100644 --- a/python/tests/test_evolve_and_measure.py +++ b/python/tests/test_evolve_and_measure.py @@ -18,7 +18,7 @@ from qdk_chemistry.algorithms.time_evolution.circuit_mapper import PauliSequenceMapper from qdk_chemistry.algorithms.time_evolution.measure_simulation import EvolveAndMeasure from qdk_chemistry.data import Circuit, QubitHamiltonian -from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT_AER +from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT_AER, QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME def test_prepend_state_prep_circuit_composes_qsharp_operations(monkeypatch: pytest.MonkeyPatch) -> None: @@ -104,8 +104,8 @@ def test_evolve_and_measure_eigenvalue_remains_constant() -> None: @pytest.mark.skipif( - not QDK_CHEMISTRY_HAS_QISKIT_AER, - reason="Qiskit Aer not available", + not QDK_CHEMISTRY_HAS_QISKIT_AER or not QDK_CHEMISTRY_HAS_QISKIT_IBM_RUNTIME, + reason="Qiskit Aer or IBM Runtime not available", ) def test_evolve_and_measure_with_device_backend() -> None: """Run EvolveAndMeasure with a device_backend_name string.""" From fea782a6de52e5b31a36eb240f794a22dbcd633f Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Thu, 9 Apr 2026 21:15:02 +0000 Subject: [PATCH 041/117] fixing doc build --- .../plugins/qiskit/_interop/transpiler.py | 191 ++++++------------ .../tests/test_interop_qiskit_transpiler.py | 36 ++-- 2 files changed, 80 insertions(+), 147 deletions(-) diff --git a/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py b/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py index 46dce78ee..556e82f49 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py +++ b/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py @@ -38,8 +38,7 @@ "MergeZBasisRotations", "RemoveZBasisOnZeroState", "SubstituteCliffordRz", - "SubstitutePauli1QRotation", - "SubstitutePauli2QRotation", + "SubstitutePauliRotation", ] @@ -310,25 +309,20 @@ def settings(self) -> Settings: return self._settings -class _SubstitutePauliRotationSettings(Settings): - r"""Base settings configuration for SubstitutePauli\* passes. +class SubstitutePauliRotationSettings(Settings): + """Settings configuration for SubstitutePauliRotation. - Settings: - equivalent_gate_set (vector): Substitutions to allow (always includes ``"id"``). - tolerance (double, default=float(np.finfo(np.float64).eps)): Float comparison tolerance. + SubstitutePauliRotation-specific settings: + equivalent_gate_set (vector, default=["id", "x", "y", "z"]): Equivalent gate set to use. + tolerance (double, default=float(np.finfo(np.float64).eps)): Float comparison tolerance to use. """ - def __init__(self, pauli_gate_names: list[str]): - """Initialize _SubstitutePauliRotationSettings. - - Args: - pauli_gate_names: The Pauli gate names (e.g. ``["x", "y", "z"]``). - - """ + def __init__(self): + """Initialize SubstitutePauliRotationSettings.""" Logger.trace_entering() super().__init__() - self._set_default("equivalent_gate_set", "vector", ["id", *pauli_gate_names]) + self._set_default("equivalent_gate_set", "vector", ["id", "x", "y", "z"]) self._set_default("tolerance", "double", float(np.finfo(np.float64).eps)) def set(self, key: str, value): @@ -360,103 +354,87 @@ def update(self, settings_dict: dict): super().update(settings_dict) -class SubstitutePauli1QRotationSettings(_SubstitutePauliRotationSettings): - """Settings configuration for SubstitutePauli1QRotation. - - SubstitutePauli1QRotation-specific settings: - equivalent_gate_set (vector, default=["id", "x", "y", "z"]): Equivalent gate set to use. - tolerance (double, default=float(np.finfo(np.float64).eps)): Float comparison tolerance to use. - - """ - - def __init__(self): - """Initialize SubstitutePauli1QRotationSettings.""" - Logger.trace_entering() - super().__init__(["x", "y", "z"]) - - -class SubstitutePauli2QRotationSettings(_SubstitutePauliRotationSettings): - """Settings configuration for SubstitutePauli2QRotation. - - SubstitutePauli2QRotation-specific settings: - equivalent_gate_set (vector, default=["id", "x", "y", "z"]): Equivalent gate set to use. - tolerance (double, default=float(np.finfo(np.float64).eps)): Float comparison tolerance to use. - - """ +class SubstitutePauliRotation(TransformationPass): + r"""Substitute Pauli rotation gates at integer multiples of π. - def __init__(self): - """Initialize SubstitutePauli2QRotationSettings.""" - Logger.trace_entering() - super().__init__(["x", "y", "z"]) + Handles both 1-qubit (Rx, Ry, Rz) and 2-qubit (Rxx, Ryy, Rzz) rotations: - -class _SubstitutePauliRotation(TransformationPass): - r"""Base pass that replaces rotation gates at integer multiples of π. - - For a rotation gate :math:`R_P(\theta)`: - - * Even multiples of π (0, 2π, …) → identity (gate removed). - * Odd multiples of π (π, 3π, …) → the corresponding Pauli gate(s). + * :math:`R_P(k\pi)` where *k* is even → removed (identity up to global phase). + * :math:`R_P(k\pi)` where *k* is odd → the corresponding Pauli gate(s). For 2-qubit rotations the Pauli is applied independently to each qubit. """ def __init__( self, - rotation_gates: dict[str, tuple[type, type]], - settings: _SubstitutePauliRotationSettings, equivalent_gate_set: list[str] | None = None, tolerance: float = float(np.finfo(np.float64).eps), ): - """Initialize the pass. + """Initialize SubstitutePauliRotation. Args: - rotation_gates: Gate-name → (Pauli class, Rotation class) mapping this pass handles. - settings: Settings instance for this pass. - equivalent_gate_set: List of gates to allow substitution with, or None for defaults. + equivalent_gate_set: List of gates to allow substitution with, or None for + defaults (``["id", "x", "y", "z"]``). tolerance: Angle comparison tolerance. """ Logger.trace_entering() super().__init__() - self._rotation_gates = rotation_gates - self._settings = settings + self._rotation_gates = { + "rx": (XGate, RXGate), + "ry": (YGate, RYGate), + "rz": (ZGate, RZGate), + "rxx": (XGate, RXXGate), + "ryy": (YGate, RYYGate), + "rzz": (ZGate, RZZGate), + } + self._settings = SubstitutePauliRotationSettings() if equivalent_gate_set is not None: if not isinstance(equivalent_gate_set, list): raise TypeError("equivalent_gate_set must be a list of gate names or None") self._settings.set("equivalent_gate_set", equivalent_gate_set) self._settings.set("tolerance", tolerance) - def run(self, dag: DAGCircuit) -> DAGCircuit: - """Run the pass on the given ``DAGCircuit``. + def substitute_pauli_rotations( + self, + dag: DAGCircuit, + rotation_gates: dict[str, tuple[type, type]], + equivalent_gate_set: list[str], + tolerance: float, + ) -> DAGCircuit: + r"""Replace rotation gates at integer multiples of π with Pauli gates or identity. + + For a rotation gate :math:`R_P(\theta)`: + + * Even multiples of π (0, 2π, …) → identity (gate removed). + * Odd multiples of π (π, 3π, …) → the corresponding Pauli gate(s). + + For 2-qubit rotations the Pauli is applied independently to each qubit. Args: dag: The input ``DAGCircuit`` to transform. + rotation_gates: Gate-name → (Pauli class, Rotation class) mapping. + equivalent_gate_set: List of gate names allowed for substitution. + tolerance: Angle comparison tolerance. Returns: The transformed ``DAGCircuit`` with Pauli substitutions. """ - Logger.trace_entering() - Logger.debug(f"Running {type(self).__name__} pass.") - - equivalent_gate_set = self._settings.get("equivalent_gate_set") - tolerance = self._settings.get("tolerance") - if "id" not in equivalent_gate_set: raise ValueError("Gate 'id' is missing in equivalent_gate_set.") if len(equivalent_gate_set) != len(set(equivalent_gate_set)): raise ValueError(f"Gates in equivalent_gate_set ({equivalent_gate_set}) are not unique.") for node in dag.op_nodes(): - if node.op.name not in self._rotation_gates: + if node.op.name not in rotation_gates: continue angle = node.op.params[0] if isinstance(angle, ParameterExpression): continue - pauli_cls, _ = self._rotation_gates[node.op.name] + pauli_cls, _ = rotation_gates[node.op.name] pauli_name = pauli_cls().name # e.g. "x", "y", "z" k = round(float(angle) / np.pi) @@ -479,77 +457,34 @@ def run(self, dag: DAGCircuit) -> DAGCircuit: return dag - def settings(self) -> Settings: - """Get the settings for this pass. - - Returns: - The settings object associated with this pass. - - """ - Logger.trace_entering() - return self._settings - - -class SubstitutePauli1QRotation(_SubstitutePauliRotation): - r"""Substitute 1-qubit Pauli rotation gates (Rx, Ry, Rz) at integer multiples of π. - - For each gate in {Rx, Ry, Rz}: - - * :math:`R_P(k\pi)` where *k* is even → removed (identity up to global phase). - * :math:`R_P(k\pi)` where *k* is odd → the corresponding Pauli gate (X, Y, or Z). - """ - - def __init__( - self, - equivalent_gate_set: list[str] | None = None, - tolerance: float = float(np.finfo(np.float64).eps), - ): - """Initialize SubstitutePauli1QRotation. + def run(self, dag: DAGCircuit) -> DAGCircuit: + """Run the pass on the given ``DAGCircuit``. Args: - equivalent_gate_set: List of gates to allow substitution with, or None for - defaults (``["id", "x", "y", "z"]``). - tolerance: Angle comparison tolerance. + dag: The input ``DAGCircuit`` to transform. + + Returns: + The transformed ``DAGCircuit`` with Pauli substitutions. """ Logger.trace_entering() - super().__init__( - {"rx": (XGate, RXGate), "ry": (YGate, RYGate), "rz": (ZGate, RZGate)}, - SubstitutePauli1QRotationSettings(), - equivalent_gate_set, - tolerance, + Logger.debug("Running SubstitutePauliRotation pass.") + return self.substitute_pauli_rotations( + dag, + self._rotation_gates, + self._settings.get("equivalent_gate_set"), + self._settings.get("tolerance"), ) + def settings(self) -> Settings: + """Get the settings for this pass. -class SubstitutePauli2QRotation(_SubstitutePauliRotation): - r"""Substitute 2-qubit Pauli rotation gates (Rxx, Ryy, Rzz) at integer multiples of π. - - For each gate in {Rxx, Ryy, Rzz}: - - * :math:`R_{PP}(k\pi)` where *k* is even → removed (identity up to global phase). - * :math:`R_{PP}(k\pi)` where *k* is odd → the Pauli applied to each qubit (e.g. X⊗X). - """ - - def __init__( - self, - equivalent_gate_set: list[str] | None = None, - tolerance: float = float(np.finfo(np.float64).eps), - ): - """Initialize SubstitutePauli2QRotation. - - Args: - equivalent_gate_set: List of gates to allow substitution with, or None for - defaults (``["id", "x", "y", "z"]``). - tolerance: Angle comparison tolerance. + Returns: + The settings object associated with this pass. """ Logger.trace_entering() - super().__init__( - {"rxx": (XGate, RXXGate), "ryy": (YGate, RYYGate), "rzz": (ZGate, RZZGate)}, - SubstitutePauli2QRotationSettings(), - equivalent_gate_set, - tolerance, - ) + return self._settings class RemoveZBasisOnZeroState(TransformationPass): diff --git a/python/tests/test_interop_qiskit_transpiler.py b/python/tests/test_interop_qiskit_transpiler.py index b7c49e939..8170b25dd 100644 --- a/python/tests/test_interop_qiskit_transpiler.py +++ b/python/tests/test_interop_qiskit_transpiler.py @@ -20,8 +20,7 @@ MergeZBasisRotations, RemoveZBasisOnZeroState, SubstituteCliffordRz, - SubstitutePauli1QRotation, - SubstitutePauli2QRotation, + SubstitutePauliRotation, ) else: # Define placeholders for type checking when Qiskit is not available @@ -37,8 +36,7 @@ MergeZBasisRotations = object RemoveZBasisOnZeroState = object SubstituteCliffordRz = object - SubstitutePauli1QRotation = object - SubstitutePauli2QRotation = object + SubstitutePauliRotation = object pytestmark = pytest.mark.skipif(not QDK_CHEMISTRY_HAS_QISKIT, reason="Qiskit not available") @@ -147,7 +145,7 @@ def test_remove_z_basis_on_zero_state_preserves_after_x(): # --------------------------------------------------------------------------- -# SubstitutePauli1QRotation tests +# SubstitutePauliRotation tests — 1-qubit gates # --------------------------------------------------------------------------- @@ -166,7 +164,7 @@ def test_substitute_pauli_1q_odd_pi(gate_method, angle, expected_gate): qc = QuantumCircuit(1) getattr(qc, gate_method)(angle, 0) - result = _run_pass(SubstitutePauli1QRotation(), qc) + result = _run_pass(SubstitutePauliRotation(), qc) ops = [instr.operation for instr in result.data] assert len(ops) == 1 @@ -186,7 +184,7 @@ def test_substitute_pauli_1q_even_pi(gate_method, angle): qc = QuantumCircuit(1) getattr(qc, gate_method)(angle, 0) - result = _run_pass(SubstitutePauli1QRotation(), qc) + result = _run_pass(SubstitutePauliRotation(), qc) assert result.size() == 0 @@ -204,7 +202,7 @@ def test_substitute_pauli_1q_non_pi_unchanged(gate_method, angle): qc = QuantumCircuit(1) getattr(qc, gate_method)(angle, 0) - result = _run_pass(SubstitutePauli1QRotation(), qc) + result = _run_pass(SubstitutePauliRotation(), qc) ops = [instr.operation for instr in result.data] assert len(ops) == 1 @@ -217,7 +215,7 @@ def test_substitute_pauli_1q_parameterized_untouched(): qc = QuantumCircuit(1) qc.rz(theta, 0) - result = _run_pass(SubstitutePauli1QRotation(), qc) + result = _run_pass(SubstitutePauliRotation(), qc) ops = [instr.operation for instr in result.data] assert len(ops) == 1 @@ -232,7 +230,7 @@ def test_substitute_pauli_1q_mixed_circuit(): qc.rz(np.pi, 0) # odd → Z qc.rx(0.5, 0) # not a multiple → kept - result = _run_pass(SubstitutePauli1QRotation(), qc) + result = _run_pass(SubstitutePauliRotation(), qc) ops = [instr.operation for instr in result.data] assert len(ops) == 3 @@ -248,7 +246,7 @@ def test_substitute_pauli_1q_selective_gate_set(): qc.rz(np.pi, 0) # would become Z # Only allow X substitution, not Z - result = _run_pass(SubstitutePauli1QRotation(equivalent_gate_set=["id", "x"]), qc) + result = _run_pass(SubstitutePauliRotation(equivalent_gate_set=["id", "x"]), qc) ops = [instr.operation for instr in result.data] assert len(ops) == 2 @@ -258,15 +256,15 @@ def test_substitute_pauli_1q_selective_gate_set(): def test_substitute_pauli_1q_settings(): """Test settings initialization and 'id' auto-inclusion.""" - p = SubstitutePauli1QRotation(equivalent_gate_set=["x"]) + p = SubstitutePauliRotation(equivalent_gate_set=["x"]) assert "id" in p.settings().get("equivalent_gate_set") with pytest.raises(TypeError): - SubstitutePauli1QRotation(equivalent_gate_set="x") + SubstitutePauliRotation(equivalent_gate_set="x") # --------------------------------------------------------------------------- -# SubstitutePauli2QRotation tests +# SubstitutePauliRotation tests — 2-qubit gates # --------------------------------------------------------------------------- @@ -284,7 +282,7 @@ def test_substitute_pauli_2q_odd_pi(gate_method, angle, expected_gate): qc = QuantumCircuit(2) getattr(qc, gate_method)(angle, 0, 1) - result = _run_pass(SubstitutePauli2QRotation(), qc) + result = _run_pass(SubstitutePauliRotation(), qc) ops = [instr.operation for instr in result.data] assert len(ops) == 2 @@ -304,7 +302,7 @@ def test_substitute_pauli_2q_even_pi(gate_method, angle): qc = QuantumCircuit(2) getattr(qc, gate_method)(angle, 0, 1) - result = _run_pass(SubstitutePauli2QRotation(), qc) + result = _run_pass(SubstitutePauliRotation(), qc) assert result.size() == 0 @@ -322,7 +320,7 @@ def test_substitute_pauli_2q_non_pi_unchanged(gate_method, angle): qc = QuantumCircuit(2) getattr(qc, gate_method)(angle, 0, 1) - result = _run_pass(SubstitutePauli2QRotation(), qc) + result = _run_pass(SubstitutePauliRotation(), qc) ops = [instr.operation for instr in result.data] assert len(ops) == 1 @@ -335,7 +333,7 @@ def test_substitute_pauli_2q_parameterized_untouched(): qc = QuantumCircuit(2) qc.rzz(theta, 0, 1) - result = _run_pass(SubstitutePauli2QRotation(), qc) + result = _run_pass(SubstitutePauliRotation(), qc) ops = [instr.operation for instr in result.data] assert len(ops) == 1 @@ -349,7 +347,7 @@ def test_substitute_pauli_2q_mixed_circuit(): qc.ryy(2 * np.pi, 0, 1) # even → removed qc.rzz(3 * np.pi, 0, 1) # odd → Z⊗Z - result = _run_pass(SubstitutePauli2QRotation(), qc) + result = _run_pass(SubstitutePauliRotation(), qc) ops = [instr.operation for instr in result.data] names = [op.name for op in ops] From d5829f2fda8e5ee36c3bdd58e0908b80ce729db1 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Thu, 9 Apr 2026 21:16:38 +0000 Subject: [PATCH 042/117] copilot comment --- .../time_evolution/measure_simulation/evolve_and_measure.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py index 0ab064ad1..22a83959a 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/evolve_and_measure.py @@ -171,5 +171,5 @@ def get_circuit(self) -> Circuit | None: return self._evolution_circuit def name(self) -> str: - """Return ``classical_sampling`` as the algorithm name.""" - return "classical_sampling" + """Return ``evolve_and_measure`` as the algorithm name.""" + return "evolve_and_measure" From abf650931b8416097047808653668dd92ba039c6 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Thu, 9 Apr 2026 21:36:07 +0000 Subject: [PATCH 043/117] more copilot comments --- examples/time_evolve_and_measure.ipynb | 2 +- .../algorithms/circuit_executor/base.py | 16 ++++++++++------ .../time_evolution/measure_simulation/base.py | 4 ++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/examples/time_evolve_and_measure.ipynb b/examples/time_evolve_and_measure.ipynb index acc48625d..15e68442a 100644 --- a/examples/time_evolve_and_measure.ipynb +++ b/examples/time_evolve_and_measure.ipynb @@ -140,7 +140,7 @@ "evolution_builder = Trotter(num_divisions=2, order=1, optimize_term_ordering=True)\n", "mapper = PauliSequenceMapper()\n", "energy_estimator = create(\"energy_estimator\", \"qdk\")\n", - "measure_simulation = create(\"measure_simulation\", \"classical_sampling\")" + "measure_simulation = create(\"measure_simulation\", \"evolve_and_measure\")" ] }, { diff --git a/python/src/qdk_chemistry/algorithms/circuit_executor/base.py b/python/src/qdk_chemistry/algorithms/circuit_executor/base.py index 08cb1b157..cfc8f575f 100644 --- a/python/src/qdk_chemistry/algorithms/circuit_executor/base.py +++ b/python/src/qdk_chemistry/algorithms/circuit_executor/base.py @@ -36,9 +36,7 @@ def _run_impl( circuit: Circuit, shots: int, noise: QuantumErrorProfile | None = None, - device_backend_name: str | None = None, - pre_transpilation_passes: list[str] | None = None, - post_transpilation_passes: list[str] | None = None, + **kwargs, ) -> CircuitExecutorData: """Execute the given quantum circuit and get measurement results. @@ -46,9 +44,15 @@ def _run_impl( circuit: The circuit that prepares the initial state. shots: The number of shots to execute the circuit. noise: Optional noise profile to apply during execution. - device_backend_name: Optional name of a device backend to use for noise modeling. - pre_transpilation_passes: Optional list of pass names to apply before transpilation. - post_transpilation_passes: Optional list of pass names to apply after transpilation. + **kwargs: Backend-specific keyword arguments. The ``qiskit_aer_simulator`` + backend accepts: + + * **device_backend_name** (*str | None*) - Name of a fake device backend + to use for noise modeling. + * **pre_transpilation_passes** (*list[str] | None*) - Pass names to apply + before transpilation. + * **post_transpilation_passes** (*list[str] | None*) - Pass names to apply + after transpilation. Returns: CircuitExecutorData: Object containing the results of the circuit execution. diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py index 8fb9f509c..971d573c0 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/measure_simulation/base.py @@ -171,5 +171,5 @@ def algorithm_type_name(self) -> str: return "measure_simulation" def default_algorithm_name(self) -> str: - """Return classical sampling as the default algorithm name.""" - return "classical_sampling" + """Return evolve_and_measure as the default algorithm name.""" + return "evolve_and_measure" From 787961db52b9dbee4177855b246021516a420842 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Thu, 9 Apr 2026 22:40:25 +0000 Subject: [PATCH 044/117] fixed docstring --- python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py b/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py index 556e82f49..4ff640f8a 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py +++ b/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py @@ -398,7 +398,7 @@ def __init__( def substitute_pauli_rotations( self, dag: DAGCircuit, - rotation_gates: dict[str, tuple[type, type]], + rotation_gates: dict[str, tuple], equivalent_gate_set: list[str], tolerance: float, ) -> DAGCircuit: From 53c037bb61db99b4568feb0221d6e9f94c081904 Mon Sep 17 00:00:00 2001 From: v-agamshayit Date: Thu, 9 Apr 2026 15:48:59 -0700 Subject: [PATCH 045/117] Update python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs b/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs index 7c21bddf1..ad616a173 100644 --- a/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs +++ b/python/src/qdk_chemistry/utils/qsharp/CircuitComposition.qs @@ -41,9 +41,8 @@ namespace QDKChemistry.Utils.CircuitComposition { targets : Int[] ) : Unit { if (Length(targets) == 0) { - // No target indices: allocate an empty register and do nothing. - use qs = Qubit[0]; - ApplySequential(first, second, qs); + // No target indices: do nothing. + return (); } else { // Allocate enough qubits so that all indices in 'targets' are valid. let maxTarget = MaxInt(targets); From 63e41cd716c85b2aa3eac7150a86366feef9c01c Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Fri, 24 Apr 2026 17:11:09 +0000 Subject: [PATCH 046/117] added T gate transpilation pass --- .../plugins/qiskit/_interop/transpiler.py | 103 ++++++++++-------- 1 file changed, 57 insertions(+), 46 deletions(-) diff --git a/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py b/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py index 4ff640f8a..f559ae53d 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py +++ b/python/src/qdk_chemistry/plugins/qiskit/_interop/transpiler.py @@ -22,6 +22,8 @@ RZZGate, SdgGate, SGate, + TdgGate, + TGate, XGate, YGate, ZGate, @@ -146,7 +148,7 @@ class SubstituteCliffordRzSettings(Settings): """Settings configuration for SubstituteCliffordRz. SubstituteCliffordRz-specific settings: - equivalent_gate_set (vector, default=["id", "s", "sdg", "z"]): Equivalent gate set to use. + equivalent_gate_set (vector, default=["id", "t", "s", "sdg", "tdg", "z"]): Equivalent gate set to use. tolerance (double, default=float(np.finfo(np.float64).eps)): Float comparison tolerance to use. """ @@ -155,7 +157,7 @@ def __init__(self): """Initialize SubstituteCliffordRzSettings.""" Logger.trace_entering() super().__init__() - self._set_default("equivalent_gate_set", "vector", ["id", "s", "sdg", "z"]) + self._set_default("equivalent_gate_set", "vector", ["id", "t", "s", "z", "sdg", "tdg"]) self._set_default("tolerance", "double", float(np.finfo(np.float64).eps)) def set(self, key: str, value): @@ -190,37 +192,53 @@ def update(self, settings_dict: dict): class SubstituteCliffordRz(TransformationPass): - """Transformation pass to substitute Rz(θ) gates with equivalent Clifford gates for special angles. + """Transformation pass to substitute Rz(θ) gates with equivalent Clifford+T gates for special angles. - This pass replaces Rz(θ) gates with one of the following Clifford gates: + This pass replaces Rz(θ) gates with one of the following gates: * Identity (Id) + * T gate (T) * Phase gate (S) - * Inverse Phase gate (Sdg) * Pauli-Z (Z) + * Inverse Phase gate (Sdg) + * T-dagger gate (Tdg) Substitution rules: +--------------------+--------------------------+ - | Rz angle (θ) | Equivalent Clifford gate | + | Rz angle (θ) | Equivalent gate | +====================+==========================+ | 0 | Id | +--------------------+--------------------------+ + | π/4 | T | + +--------------------+--------------------------+ | π/2 | S | +--------------------+--------------------------+ | π | Z | +--------------------+--------------------------+ | -π/2 or 3π/2 | Sdg | +--------------------+--------------------------+ + | -π/4 or 7π/4 | Tdg | + +--------------------+--------------------------+ Note: * Only substitutes gates whose angle is non-parameterized and matches - one of the above special Clifford phases within the specified tolerance. + one of the above special phases within the specified tolerance. * Leaves parameterized Rz gates untouched to preserve symbolic expressions. * Ignores gates not in the user-specified ``equivalent_gate_set`` """ + # Map from mod-8 factor (angle = factor * π/4) to (gate_name, gate_class) + _FACTOR_TO_GATE: tuple[tuple[int, str, type], ...] = ( + (0, "id", IGate), + (1, "t", TGate), + (2, "s", SGate), + (4, "z", ZGate), + (6, "sdg", SdgGate), + (7, "tdg", TdgGate), + ) + def __init__( self, equivalent_gate_set: list[str] | None = None, @@ -230,7 +248,7 @@ def __init__( Args: equivalent_gate_set (list[str] | None): List of gates to substitute rz with special - angles. Default is None, which means ['id', 's', 'sdg', 'z']. + angles. Default is None, which means ['id', 't', 's', 'z', 'sdg', 'tdg']. tolerance (float): Angle comparison tolerance. Default is np.finfo(np.float64).eps. """ @@ -242,6 +260,19 @@ def __init__( raise TypeError("equivalent_gate_set must be a list of gate names or None") self._settings.set("equivalent_gate_set", equivalent_gate_set) self._settings.set("tolerance", tolerance) + self._build_lookup() + + def _build_lookup(self): + """Precompute the mod-8 lookup table from current settings.""" + gate_set = set(self._settings.get("equivalent_gate_set")) + tolerance = self._settings.get("tolerance") + # Precompute (mod8_target, gate_instance) pairs for enabled gates + self._lookup: list[tuple[float, object]] = [] + for mod8_val, name, cls in self._FACTOR_TO_GATE: + if name in gate_set: + self._lookup.append((float(mod8_val), cls())) + self._tolerance = tolerance + self._inv_quarter_pi = 4.0 / np.pi # precompute reciprocal def run(self, dag: DAGCircuit) -> DAGCircuit: """Run the pass on the given ``DAGCircuit``. @@ -254,48 +285,28 @@ def run(self, dag: DAGCircuit) -> DAGCircuit: """ Logger.trace_entering() - equivalent_gate_set = self._settings.get("equivalent_gate_set") - tolerance = self._settings.get("tolerance") + tolerance = self._tolerance + lookup = self._lookup + inv_quarter_pi = self._inv_quarter_pi - if "id" not in equivalent_gate_set: - raise ValueError("Gate 'id' is missing in equivalent_gate_set.") - if len(equivalent_gate_set) != len(set(equivalent_gate_set)): - raise ValueError(f"Gates in equivalent_gate_set ({equivalent_gate_set}) are not unique.") + for node in dag.op_nodes(): + if node.op.name != "rz": + continue + + angle = node.op.params[0] - Logger.debug("SubstituteCliffordRz pass: simplification logic needs careful review.") + # Skip parameterized rotations + if isinstance(angle, ParameterExpression): + continue - for node in dag.op_nodes(): - if node.op.name == "rz": - angle = node.op.params[0] - - # Skip parameterized rotations - if isinstance(angle, ParameterExpression): - Logger.debug("Skipping parameterized Rz.") - continue - - factor = 2 * angle / np.pi - mod4_factor = np.mod(factor, 4) - Logger.debug(f"Rz({angle:.4f}) = {factor:.4f} * π/2 (mod 4 = {mod4_factor:.2f})") - - replacement_gate = None - if np.isclose(mod4_factor, 0, atol=tolerance) and "id" in equivalent_gate_set: - Logger.debug(f"Substituting Rz({angle:.4f}) with Id.") - replacement_gate = IGate() - elif np.isclose(mod4_factor, 1, atol=tolerance) and "s" in equivalent_gate_set: - Logger.debug(f"Substituting Rz({angle:.4f}) with S.") - replacement_gate = SGate() - elif np.isclose(mod4_factor, 2, atol=tolerance) and "z" in equivalent_gate_set: - Logger.debug(f"Substituting Rz({angle:.4f}) with Z.") - replacement_gate = ZGate() - elif np.isclose(mod4_factor, 3, atol=tolerance) and "sdg" in equivalent_gate_set: - Logger.debug(f"Substituting Rz({angle:.4f}) with Sdg.") - replacement_gate = SdgGate() - - if replacement_gate: - dag.substitute_node(node, replacement_gate, inplace=True) - else: - Logger.debug(f"Keeping original Rz({angle:.4f}).") + # Compute mod-8 factor: angle = factor * (π/4) + factor = angle * inv_quarter_pi + mod8 = factor % 8.0 + for target, gate in lookup: + if abs(mod8 - target) <= tolerance or abs(mod8 - target - 8.0) <= tolerance: + dag.substitute_node(node, gate, inplace=True) + break return dag def settings(self) -> Settings: From 0465b25445288a44b983d268102edd4be21c716c Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 4 May 2026 11:44:33 -0700 Subject: [PATCH 047/117] Add lattice edge coloring and subsequent Trotter optimizations --- .../qdk/chemistry/data/lattice_graph.hpp | 63 +++- cpp/src/qdk/chemistry/data/lattice_graph.cpp | 201 +++++++++++- cpp/tests/test_lattice_graph.cpp | 63 ++++ .../user/comprehensive/algorithms/index.rst | 20 ++ .../algorithms/time_evolution_builder.rst | 15 + docs/source/user/comprehensive/data/index.rst | 12 + .../user/comprehensive/data/lattice_graph.rst | 10 + .../user/comprehensive/model_hamiltonians.rst | 16 + docs/source/user/features.rst | 4 +- docs/suzuki_recursion_analysis.tex | 270 ++++++++++++++++ python/src/pybind11/data/lattice_graph.cpp | 24 ++ .../algorithms/energy_estimator/qdk.py | 21 +- .../src/qdk_chemistry/algorithms/registry.py | 10 + .../algorithms/term_grouper/__init__.py | 34 ++ .../algorithms/term_grouper/base.py | 69 ++++ .../algorithms/term_grouper/commuting.py | 123 +++++++ .../algorithms/term_grouper/identity.py | 48 +++ .../time_evolution/builder/trotter.py | 302 +++++++++++++----- python/src/qdk_chemistry/data/__init__.py | 7 + .../qdk_chemistry/data/qubit_hamiltonian.py | 65 ++-- .../src/qdk_chemistry/data/term_partition.py | 249 +++++++++++++++ .../qdk_chemistry/utils/model_hamiltonians.py | 114 ++++++- python/tests/test_encoding_metadata.py | 20 +- python/tests/test_qubit_hamiltonian.py | 37 ++- python/tests/test_term_partition.py | 294 +++++++++++++++++ python/tests/test_time_evolution_trotter.py | 45 ++- 26 files changed, 1988 insertions(+), 148 deletions(-) create mode 100644 docs/suzuki_recursion_analysis.tex create mode 100644 python/src/qdk_chemistry/algorithms/term_grouper/__init__.py create mode 100644 python/src/qdk_chemistry/algorithms/term_grouper/base.py create mode 100644 python/src/qdk_chemistry/algorithms/term_grouper/commuting.py create mode 100644 python/src/qdk_chemistry/algorithms/term_grouper/identity.py create mode 100644 python/src/qdk_chemistry/data/term_partition.py create mode 100644 python/tests/test_term_partition.py diff --git a/cpp/include/qdk/chemistry/data/lattice_graph.hpp b/cpp/include/qdk/chemistry/data/lattice_graph.hpp index fe2f3823f..d5185914b 100644 --- a/cpp/include/qdk/chemistry/data/lattice_graph.hpp +++ b/cpp/include/qdk/chemistry/data/lattice_graph.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,54 @@ namespace qdk::chemistry::data { +/** + * @brief Edge coloring as a map from ordered (i, j) (with i < j) to a + * non-negative integer color label. + * + * Two edges sharing the same color have disjoint vertex sets and may be + * exponentiated in parallel by Trotter-style decompositions. + */ +using EdgeColoring = + std::map, int>; + +// ---- Free coloring functions ------------------------------------------------ +// These compute edge colorings for known lattice topologies. They are +// called by the factory methods to pre-populate the coloring at +// construction time, and can also be called directly by users who need +// a coloring for a topology not covered by the built-in factories. + +/** + * @brief Greedy randomised edge coloring of an arbitrary graph. + * + * Shuffles the edge order and assigns each edge the lowest colour not + * incident to either endpoint. Repeats for ``trials`` shuffles (with + * deterministic PRNG seeded by ``seed``) and returns the result with + * the fewest colours. + * + * @param adj Sparse adjacency matrix of the graph. + * @param seed Random seed. Default: 0. + * @param trials Number of random-order trials. Default: 1. + */ +EdgeColoring greedy_edge_coloring(const Eigen::SparseMatrix& adj, + int seed = 0, int trials = 1); + +/** + * @brief Deterministic optimal edge coloring for a chain (path / ring). + */ +EdgeColoring chain_coloring(std::int64_t n, bool periodic); + +/** + * @brief Deterministic optimal edge coloring for a square lattice. + */ +EdgeColoring square_coloring(std::int64_t nx, std::int64_t ny, + bool periodic_x, bool periodic_y); + +/** + * @brief Deterministic optimal 3-coloring for a honeycomb lattice. + */ +EdgeColoring honeycomb_coloring(std::int64_t nx, std::int64_t ny, + bool periodic_x, bool periodic_y); + /** * @brief Weighted graph representing a lattice connectivity structure. * @@ -317,6 +366,14 @@ class LatticeGraph : public DataClass { bool periodic_x = false, bool periodic_y = false, double t = 1.0); + /** + * @brief Edge coloring stored at construction time, if any. + * + * Factory methods for recognised topologies pre-populate this field. + * Returns ``std::nullopt`` for lattices constructed without a coloring. + */ + const std::optional& edge_coloring() const; + /** * @brief Get the data type name for this class. * @return "lattice_graph" @@ -395,8 +452,10 @@ class LatticeGraph : public DataClass { * make_bidirectional(). * * @param adjacency Sparse square adjacency matrix (moved in). + * @param coloring Optional edge coloring (moved in). */ - explicit LatticeGraph(Eigen::SparseMatrix adjacency); + explicit LatticeGraph(Eigen::SparseMatrix adjacency, + std::optional coloring = std::nullopt); /** @brief Check if a sparse matrix is symmetric within a numerical tolerance. */ @@ -410,6 +469,8 @@ class LatticeGraph : public DataClass { /// Flag indicating whether the adjacency matrix is symmetric (undirected /// graph) bool _is_symmetric; + /// Edge coloring, populated at construction for recognised topologies. + std::optional _edge_coloring; }; static_assert(DataClassCompliant, diff --git a/cpp/src/qdk/chemistry/data/lattice_graph.cpp b/cpp/src/qdk/chemistry/data/lattice_graph.cpp index f22dee8e7..7960fcd00 100644 --- a/cpp/src/qdk/chemistry/data/lattice_graph.cpp +++ b/cpp/src/qdk/chemistry/data/lattice_graph.cpp @@ -3,11 +3,16 @@ // license information. #include +#include #include #include +#include #include +#include #include #include +#include +#include #include #include #include @@ -60,10 +65,12 @@ LatticeGraph::LatticeGraph( _is_symmetric = _check_symmetry(adjacency_); } -LatticeGraph::LatticeGraph(Eigen::SparseMatrix adjacency) +LatticeGraph::LatticeGraph(Eigen::SparseMatrix adjacency, + std::optional coloring) : _num_sites(static_cast(adjacency.rows())), adjacency_(std::move(adjacency)), - _is_symmetric(_check_symmetry(adjacency_)) {} + _is_symmetric(_check_symmetry(adjacency_)), + _edge_coloring(std::move(coloring)) {} LatticeGraph LatticeGraph::from_dense_matrix( const Eigen::MatrixXd& adjacency_matrix) { @@ -152,7 +159,8 @@ LatticeGraph LatticeGraph::chain(std::uint64_t n, bool periodic, double t) { Eigen::SparseMatrix adj(N, N); adj.setFromTriplets(triplets.begin(), triplets.end()); adj.makeCompressed(); - return LatticeGraph(std::move(adj)); + return LatticeGraph(std::move(adj), + chain_coloring(static_cast(N), periodic)); } LatticeGraph LatticeGraph::square(std::uint64_t nx, std::uint64_t ny, @@ -199,7 +207,8 @@ LatticeGraph LatticeGraph::square(std::uint64_t nx, std::uint64_t ny, Eigen::SparseMatrix adj(N, N); adj.setFromTriplets(triplets.begin(), triplets.end()); adj.makeCompressed(); - return LatticeGraph(std::move(adj)); + return LatticeGraph(std::move(adj), + square_coloring(Nx, Ny, periodic_x, periodic_y)); } LatticeGraph LatticeGraph::triangular(std::uint64_t nx, std::uint64_t ny, @@ -257,7 +266,7 @@ LatticeGraph LatticeGraph::triangular(std::uint64_t nx, std::uint64_t ny, Eigen::SparseMatrix adj(N, N); adj.setFromTriplets(triplets.begin(), triplets.end()); adj.makeCompressed(); - return LatticeGraph(std::move(adj)); + return LatticeGraph(std::move(adj), greedy_edge_coloring(adj, 0, 32)); } LatticeGraph LatticeGraph::honeycomb(std::uint64_t nx, std::uint64_t ny, @@ -309,7 +318,8 @@ LatticeGraph LatticeGraph::honeycomb(std::uint64_t nx, std::uint64_t ny, Eigen::SparseMatrix adj(N, N); adj.setFromTriplets(triplets.begin(), triplets.end()); adj.makeCompressed(); - return LatticeGraph(std::move(adj)); + return LatticeGraph(std::move(adj), + honeycomb_coloring(Nx, Ny, periodic_x, periodic_y)); } LatticeGraph LatticeGraph::kagome(std::uint64_t nx, std::uint64_t ny, @@ -381,7 +391,184 @@ LatticeGraph LatticeGraph::kagome(std::uint64_t nx, std::uint64_t ny, Eigen::SparseMatrix adj(N, N); adj.setFromTriplets(triplets.begin(), triplets.end()); adj.makeCompressed(); - return LatticeGraph(std::move(adj)); + return LatticeGraph(std::move(adj), greedy_edge_coloring(adj, 0, 32)); +} + +namespace detail { + +// Collect every undirected edge (i, j) with i < j from the adjacency matrix. +std::vector> undirected_edges( + const Eigen::SparseMatrix& adj) { + std::vector> edges; + for (int k = 0; k < adj.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(adj, k); it; ++it) { + if (it.row() < it.col() && it.value() != 0.0) { + edges.emplace_back(static_cast(it.row()), + static_cast(it.col())); + } + } + } + return edges; +} + +} // namespace detail + +// Greedy edge coloring: place each edge in the lowest-index color whose +// vertices do not already touch that color. Optionally retry with shuffled +// edge orders and keep the result with fewest colors. +EdgeColoring greedy_edge_coloring(const Eigen::SparseMatrix& adj, + int seed, int trials) { + auto edges_in = detail::undirected_edges(adj); + if (edges_in.empty() || trials < 1) { + return {}; + } + + EdgeColoring best; + int best_count = std::numeric_limits::max(); + std::mt19937 rng(static_cast(seed)); + + std::vector order(edges_in.size()); + std::iota(order.begin(), order.end(), 0); + + for (int trial = 0; trial < trials; ++trial) { + if (trial > 0) { + std::shuffle(order.begin(), order.end(), rng); + } + + EdgeColoring coloring; + // For each vertex, the set of colors already incident to it. + std::map> vertex_colors; + int max_color = -1; + + for (std::size_t pos : order) { + const auto& edge = edges_in[pos]; + const auto& used_i = vertex_colors[edge.first]; + const auto& used_j = vertex_colors[edge.second]; + int chosen = 0; + while (used_i.count(chosen) != 0 || used_j.count(chosen) != 0) { + ++chosen; + } + coloring[edge] = chosen; + vertex_colors[edge.first].insert(chosen); + vertex_colors[edge.second].insert(chosen); + if (chosen > max_color) max_color = chosen; + } + + int distinct = max_color + 1; + if (distinct < best_count) { + best_count = distinct; + best = std::move(coloring); + } + } + return best; +} + +// Deterministic two-coloring of an open chain: edge (i, i+1) gets color i % 2. +// For periodic chains, even N keeps two colors, odd N requires a third for +// the wrap edge to satisfy the no-incident-same-color constraint. +EdgeColoring chain_coloring(std::int64_t n, bool periodic) { + EdgeColoring out; + for (std::int64_t i = 0; i + 1 < n; ++i) { + out[{static_cast(i), static_cast(i + 1)}] = + static_cast(i % 2); + } + if (periodic && n > 2) { + int wrap_color = (n % 2 == 0) ? 1 : 2; // last edge color is (n-2)%2 + out[{0, static_cast(n - 1)}] = wrap_color; + } + return out; +} + +// Deterministic edge coloring for the square lattice. Horizontal and vertical +// edges live on disjoint axes; each axis can be 2-colored by alternating. +// With periodic boundaries, an odd extent on that axis forces a third color +// on its wrap edges. Total colors: 2 (open) up to 4 (both axes odd-periodic). +EdgeColoring square_coloring(std::int64_t Nx, std::int64_t Ny, + bool periodic_x, bool periodic_y) { + EdgeColoring out; + auto idx = [Nx](std::int64_t x, std::int64_t y) { + return static_cast(y * Nx + x); + }; + auto put = [&out](std::uint64_t a, std::uint64_t b, int c) { + auto edge = std::minmax(a, b); + out[{edge.first, edge.second}] = c; + }; + + // Horizontal edges use colors {0, 1}; vertical edges use {2, 3}. When a + // periodic dimension has odd extent the wrap edge needs its own color + // (4 for x-wrap parity-conflict, 5 for y-wrap parity-conflict). + for (std::int64_t y = 0; y < Ny; ++y) { + for (std::int64_t x = 0; x + 1 < Nx; ++x) { + put(idx(x, y), idx(x + 1, y), static_cast(x % 2)); + } + if (periodic_x && Nx > 2) { + int wrap_color = (Nx % 2 == 0) ? 1 : 4; + put(idx(Nx - 1, y), idx(0, y), wrap_color); + } + } + for (std::int64_t x = 0; x < Nx; ++x) { + for (std::int64_t y = 0; y + 1 < Ny; ++y) { + put(idx(x, y), idx(x, y + 1), 2 + static_cast(y % 2)); + } + if (periodic_y && Ny > 2) { + int wrap_color = (Ny % 2 == 0) ? 3 : 5; + put(idx(x, Ny - 1), idx(x, 0), wrap_color); + } + } + + // Compact the color labels so the result is in 0..(distinct-1). + std::map remap; + for (const auto& [edge, c] : out) { + remap.emplace(c, static_cast(remap.size())); + } + for (auto& [edge, c] : out) { + c = remap.at(c); + } + return out; +} + +// Deterministic 3-coloring for honeycomb lattice. The honeycomb has max +// degree 3 with three structurally distinct bond types: intra-cell (A–B), +// horizontal inter-cell (B–A right), vertical inter-cell (B–A up). +// Each bond type gets its own color, which is valid because no vertex +// is incident to two bonds of the same type. +EdgeColoring honeycomb_coloring(std::int64_t Nx, std::int64_t Ny, + bool periodic_x, bool periodic_y) { + EdgeColoring out; + auto idxA = [Nx](std::int64_t x, std::int64_t y) -> std::uint64_t { + return static_cast(2 * (y * Nx + x)); + }; + auto idxB = [Nx](std::int64_t x, std::int64_t y) -> std::uint64_t { + return static_cast(2 * (y * Nx + x) + 1); + }; + auto put = [&out](std::uint64_t a, std::uint64_t b, int c) { + auto edge = std::minmax(a, b); + out[{edge.first, edge.second}] = c; + }; + + for (std::int64_t y = 0; y < Ny; ++y) { + for (std::int64_t x = 0; x < Nx; ++x) { + // Intra-cell: color 0 + put(idxA(x, y), idxB(x, y), 0); + // Horizontal inter-cell: color 1 + if (x + 1 < Nx) { + put(idxB(x, y), idxA(x + 1, y), 1); + } else if (periodic_x) { + put(idxB(x, y), idxA(0, y), 1); + } + // Vertical inter-cell: color 2 + if (y + 1 < Ny) { + put(idxB(x, y), idxA(x, y + 1), 2); + } else if (periodic_y) { + put(idxB(x, y), idxA(x, 0), 2); + } + } + } + return out; +} + +const std::optional& LatticeGraph::edge_coloring() const { + return _edge_coloring; } bool LatticeGraph::_check_symmetry(const Eigen::SparseMatrix& mat) { diff --git a/cpp/tests/test_lattice_graph.cpp b/cpp/tests/test_lattice_graph.cpp index a2a75ece1..a3f46f345 100644 --- a/cpp/tests/test_lattice_graph.cpp +++ b/cpp/tests/test_lattice_graph.cpp @@ -500,3 +500,66 @@ TEST_F(LatticeGraphTest, KagomeConstructor) { kg_pxy.adjacency_matrix().isApprox(expected_pxy.adjacency_matrix())); } } + +// Coloring helper: confirm no two same-color edges share a vertex. +static void check_valid_edge_coloring( + const EdgeColoring& coloring) { + std::map> incident; + for (const auto& [edge, color] : coloring) { + auto [a, b] = edge; + EXPECT_EQ(incident[a].count(color), 0u) + << "vertex " << a << " has two edges of color " << color; + EXPECT_EQ(incident[b].count(color), 0u) + << "vertex " << b << " has two edges of color " << color; + incident[a].insert(color); + incident[b].insert(color); + } +} + +TEST_F(LatticeGraphTest, ColorCount) { + auto chain_open = LatticeGraph::chain(5, false); + ASSERT_TRUE(chain_open.edge_coloring().has_value()); + std::set chain_open_colors; + for (const auto& [e, c] : *chain_open.edge_coloring()) + chain_open_colors.insert(c); + EXPECT_GT(chain_open_colors.size(), 0u); + + auto chain_periodic_even = LatticeGraph::chain(6, true); + ASSERT_TRUE(chain_periodic_even.edge_coloring().has_value()); + + auto hc = LatticeGraph::honeycomb(3, 3, true, true); + ASSERT_TRUE(hc.edge_coloring().has_value()); + // Honeycomb uses exactly 3 colors. + std::set hc_colors; + for (const auto& [e, c] : *hc.edge_coloring()) hc_colors.insert(c); + EXPECT_EQ(hc_colors.size(), 3u); +} + +TEST_F(LatticeGraphTest, EdgeColoringIsValid) { + // For every factory-built lattice, the coloring must be present and valid. + std::vector graphs; + graphs.emplace_back(LatticeGraph::chain(8, true)); + graphs.emplace_back(LatticeGraph::square(4, 4, true, true)); + graphs.emplace_back(LatticeGraph::triangular(4, 4, true, true)); + graphs.emplace_back(LatticeGraph::honeycomb(3, 3, true, true)); + graphs.emplace_back(LatticeGraph::kagome(2, 3, true, true)); + + for (const auto& g : graphs) { + ASSERT_TRUE(g.edge_coloring().has_value()); + check_valid_edge_coloring(*g.edge_coloring()); + } + + // Custom adjacency: no coloring by default. + using Edge = std::pair; + std::map custom_edges = { + {{0, 1}, 1.0}, {{1, 2}, 1.0}, {{2, 3}, 1.0}, {{3, 0}, 1.0}}; + LatticeGraph custom(custom_edges, 4); + EXPECT_FALSE(custom.edge_coloring().has_value()); +} + +TEST_F(LatticeGraphTest, EdgeColoringIsImmutable) { + auto sq = LatticeGraph::square(4, 4, true, true); + const auto& first = sq.edge_coloring(); + const auto& second = sq.edge_coloring(); + EXPECT_EQ(&first, &second); +} diff --git a/docs/source/user/comprehensive/algorithms/index.rst b/docs/source/user/comprehensive/algorithms/index.rst index 672f9682c..f2bab1354 100644 --- a/docs/source/user/comprehensive/algorithms/index.rst +++ b/docs/source/user/comprehensive/algorithms/index.rst @@ -86,6 +86,26 @@ The following table summarizes the available algorithm classes in QDK/Chemistry - Circuit → CircuitExecutorData +.. _algorithms-term-grouper: + +Term grouper +------------ + +The ``term_grouper`` algorithm type partitions the Pauli terms of a :class:`~qdk_chemistry.data.QubitHamiltonian` into algorithm-relevant subsets and stores the result on :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition`. +A grouper consumes a ``QubitHamiltonian`` and returns a *new* ``QubitHamiltonian`` whose ``term_partition`` field is populated; the input is not mutated. + +Strategies include full commutation grouping, qubit-wise commutation grouping, and trivial (identity) grouping. +Use ``registry.available("term_grouper")`` to list implementations. + +Example:: + + from qdk_chemistry.algorithms import registry + + grouper = registry.create("term_grouper", "qubit_wise_commuting") + grouped = grouper.run(qubit_hamiltonian) + grouped.term_partition # FlatPartition(strategy="qubit_wise_commuting", ...) + + Discovering implementations --------------------------- diff --git a/docs/source/user/comprehensive/algorithms/time_evolution_builder.rst b/docs/source/user/comprehensive/algorithms/time_evolution_builder.rst index 86b0ddd7f..0fb76d251 100644 --- a/docs/source/user/comprehensive/algorithms/time_evolution_builder.rst +++ b/docs/source/user/comprehensive/algorithms/time_evolution_builder.rst @@ -146,6 +146,21 @@ When both ``num_divisions`` and ``target_accuracy`` are specified, the builder u - Coefficient threshold below which Pauli terms are discarded. Default is 1e-12. +Consuming term partitions +------------------------- + +When the input :class:`~qdk_chemistry.data.QubitHamiltonian` carries a populated :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition`, the Trotter builder consumes it directly: + +* :class:`~qdk_chemistry.data.LayeredPartition` (group → layer → index) is used as-is — the outer level controls the Strang/Suzuki splitting and each inner layer becomes one parallelisable sub-step. +* :class:`~qdk_chemistry.data.FlatPartition` (group → index) is interpreted as a layered partition with one layer per group. + +In both cases groups are sorted by ascending layer count so that the smallest groups sit on the outside of the Strang/Suzuki splitting, which maximises merging at recursion boundaries. +This typically reduces the number of distinct exponentials per Trotter step and the saving compounds through the recursion at higher orders. + +When ``term_partition is None`` each Pauli term is exponentiated as its own group. +Pre-populate the partition using the :ref:`term_grouper algorithm ` or one of the :ref:`spin model Hamiltonian builders ` to enable group-aware scheduling. + + Related classes --------------- diff --git a/docs/source/user/comprehensive/data/index.rst b/docs/source/user/comprehensive/data/index.rst index e5cbd16c4..f46ae07a4 100644 --- a/docs/source/user/comprehensive/data/index.rst +++ b/docs/source/user/comprehensive/data/index.rst @@ -72,3 +72,15 @@ The following table summarizes the available data classes in QDK/Chemistry and t * - :doc:`Circuit ` - Quantum circuit (OpenQASM, Q#, QIR, Qiskit) - :doc:`StatePreparation <../algorithms/state_preparation>`, User input + +QubitHamiltonian and term partitions +------------------------------------ + +A :class:`~qdk_chemistry.data.QubitHamiltonian` carries an optional :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` field describing how its Pauli terms are organised into algorithm-relevant subsets. +The partition is index-based — it stores indices into :attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings` rather than nested ``QubitHamiltonian`` objects — so it serialises cheaply alongside the Hamiltonian. + +A :class:`~qdk_chemistry.data.TermPartition` can represent single-level groupings (see :class:`~qdk_chemistry.data.FlatPartition`) or hierarchical group-and-layer structures (see :class:`~qdk_chemistry.data.LayeredPartition`). +The partition is *optional* metadata — ``term_partition is None`` means the partition has not been computed for this Hamiltonian. +Transformations that change term ordering or qubit support (for example :meth:`~qdk_chemistry.data.QubitHamiltonian.to_interleaved`) reset the partition to ``None`` on the new instance. + +Algorithms that consume a partition treat its presence as an explicit signal to exploit it — for example, the :doc:`Trotter time-evolution builder <../algorithms/time_evolution_builder>` reads ``term_partition`` and uses it for schedule-level Suzuki recursion and reduction. diff --git a/docs/source/user/comprehensive/data/lattice_graph.rst b/docs/source/user/comprehensive/data/lattice_graph.rst index fa1391587..f6119ba15 100644 --- a/docs/source/user/comprehensive/data/lattice_graph.rst +++ b/docs/source/user/comprehensive/data/lattice_graph.rst @@ -306,6 +306,16 @@ For detailed information about serialization in QDK/Chemistry, see the :doc:`Ser :start-after: # start-cell-serialization :end-before: # end-cell-serialization +Edge coloring +------------- + +The ``edge_coloring`` property returns an optional ``dict[tuple[int, int], int]`` that assigns a color index to each undirected edge such that edges sharing a vertex receive distinct colors. +Factory methods for recognised topologies (chain, square, honeycomb) pre-populate this with a deterministic optimal coloring; triangular and kagome lattices use a greedy heuristic. +Custom lattices built from raw adjacency matrices have ``edge_coloring`` set to ``None`` — callers can compute and supply their own coloring. + +This coloring is the topological ingredient that powers geometry-aware Trotter scheduling: edges of the same color have disjoint qubit supports, so their Pauli exponentials can be applied in parallel inside one Trotter step. +The :doc:`spin model Hamiltonian builders <../model_hamiltonians>` consume the coloring automatically when ``include_term_groups=True`` and store the result on :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition`. + Related classes --------------- diff --git a/docs/source/user/comprehensive/model_hamiltonians.rst b/docs/source/user/comprehensive/model_hamiltonians.rst index 0c1015d75..28e529318 100644 --- a/docs/source/user/comprehensive/model_hamiltonians.rst +++ b/docs/source/user/comprehensive/model_hamiltonians.rst @@ -271,6 +271,22 @@ The transverse-field Ising model is a special case of the Heisenberg model with :start-after: # start-cell-create-ising :end-before: # end-cell-create-ising +.. _model-term-partition: + +Geometry-aware term grouping +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Both :func:`~qdk_chemistry.utils.model_hamiltonians.create_heisenberg_hamiltonian` and :func:`~qdk_chemistry.utils.model_hamiltonians.create_ising_hamiltonian` accept an ``include_term_groups`` flag (default ``True``). +When enabled, the builder consults the lattice's edge coloring and stores the resulting group-and-layer structure on :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` as a :class:`~qdk_chemistry.data.LayeredPartition` with ``strategy="geometry_coloring"``: + +* each *group* corresponds to one interaction type (``XX``, ``YY``, ``ZZ``) or one external-field direction (``X``, ``Y``, ``Z``); +* each *layer* within a coupling group is a set of edges of the same color, which by construction have disjoint qubit supports and can be applied in parallel. + +Downstream consumers — most importantly the :doc:`Trotter time-evolution builder ` — read ``term_partition`` automatically and use it to schedule fewer sequential exponentials per Trotter step. +No manual geometry boilerplate is required at the call site. + +Pass ``include_term_groups=False`` to skip this step and obtain a Hamiltonian with ``term_partition is None`` (useful for benchmarking or when a different partition is desired). + Parameter flexibility --------------------- diff --git a/docs/source/user/features.rst b/docs/source/user/features.rst index daa5bff06..bd6664e60 100644 --- a/docs/source/user/features.rst +++ b/docs/source/user/features.rst @@ -98,7 +98,9 @@ This generally involves the following steps: The target operator (e.g., the electronic Hamiltonian) is decomposed into a sum of measurable components, often expressed in terms of Pauli operators. This decomposition facilitates efficient measurement on quantum hardware. Starting from a qubit-mapped Hamiltonian, this task generally involves grouping Pauli terms into sets of mutually commuting operators that can be measured simultaneously. - QDK/Chemistry provides utilities for Pauli grouping by qubit-wise commutativity via the :class:`~qdk_chemistry.data.QubitHamiltonian.group_commuting` method. + QDK/Chemistry provides utilities for Pauli grouping by qubit-wise commutativity via the ``term_grouper`` algorithm + (e.g., ``registry.create("term_grouper", "qubit_wise_commuting")``), which attaches a + :class:`~qdk_chemistry.data.FlatPartition` to a :class:`~qdk_chemistry.data.QubitHamiltonian` for downstream consumers. 2. **Circuit Execution and Measurement**: Given the state preparation circuit and the decomposed operator, quantum circuits are executed on quantum hardware or simulators to obtain measurement outcomes. 3. **Classical Post-Processing**: diff --git a/docs/suzuki_recursion_analysis.tex b/docs/suzuki_recursion_analysis.tex new file mode 100644 index 000000000..31611703f --- /dev/null +++ b/docs/suzuki_recursion_analysis.tex @@ -0,0 +1,270 @@ +\documentclass[11pt]{article} +\usepackage[margin=1in]{geometry} +\usepackage{amsmath,amssymb,amsthm} +\usepackage{hyperref} +\usepackage{booktabs} +\usepackage{listings} +\usepackage{xcolor} + +\lstset{ + language=Python, + basicstyle=\ttfamily\small, + keywordstyle=\color{blue}, + commentstyle=\color{gray}, + stringstyle=\color{red!70!black}, + showstringspaces=false, + frame=single, + breaklines=true, +} + +\newtheorem{theorem}{Theorem} +\newtheorem{lemma}[theorem]{Lemma} + +\title{Analysis of the Suzuki Recursion Formula\\in the QDK Trotter Implementation} +\author{QDK-Chemistry} +\date{\today} + +\begin{document} +\maketitle + +\section{Background: Suzuki's Fractal Decomposition} + +Consider a Hamiltonian $H = \sum_{j=1}^{L} H_j$. The goal of Trotter--Suzuki +product formulas is to approximate the time-evolution operator $e^{-iHt}$ by a +product of exponentials of the individual terms $H_j$. + +\subsection{Second-order formula (Strang splitting)} + +The second-order symmetric (Strang) splitting is: +\begin{equation}\label{eq:S2} + S_2(t) = e^{-iH_1 t/2}\, e^{-iH_2 t/2}\, \cdots\, e^{-iH_{L-1} t/2}\, + e^{-iH_L t}\, + e^{-iH_{L-1} t/2}\, \cdots\, e^{-iH_1 t/2}. +\end{equation} +This satisfies +\begin{equation} + \bigl\lVert e^{-iHt} - S_2(t) \bigr\rVert = \mathcal{O}(t^3). +\end{equation} + +\subsection{Higher-order formulas via recursion} + +Suzuki~\cite{Suzuki1990,Suzuki1991} showed that higher-order approximations can +be constructed recursively. Given an approximation $S_{2k}(t)$ of order $2k$ +(i.e., error $\mathcal{O}(t^{2k+1})$), one obtains an approximation of order +$2k+2$ via: +\begin{equation}\label{eq:recursion} + S_{2k+2}(t) + = \bigl[S_{2k}(p_k\, t)\bigr]^2\; + S_{2k}\!\bigl((1 - 4p_k)\, t\bigr)\; + \bigl[S_{2k}(p_k\, t)\bigr]^2, +\end{equation} +where the parameter $p_k$ is chosen to cancel the leading-order error: +\begin{equation}\label{eq:pk} + \boxed{p_k = \frac{1}{4 - 4^{1/(2k+1)}}}. +\end{equation} + +\begin{theorem}[Suzuki 1991]\label{thm:suzuki} +If $S_{2k}(t) = e^{-iHt} + \mathcal{O}(t^{2k+1})$, then +$S_{2k+2}(t)$ defined by~\eqref{eq:recursion} with $p_k$ from~\eqref{eq:pk} +satisfies $S_{2k+2}(t) = e^{-iHt} + \mathcal{O}(t^{2k+3})$. +\end{theorem} + +\section{Constructing the Fourth-Order Formula} + +To build the fourth-order formula $S_4$ from the second-order formula $S_2$, we +apply one level of Suzuki recursion with $k = 1$ (promoting order $2 \cdot 1 = 2$ +to order $2 \cdot 2 = 4$): +\begin{equation}\label{eq:S4} + S_4(t) = \bigl[S_2(p_1\, t)\bigr]^2\; + S_2\!\bigl((1-4p_1)\, t\bigr)\; + \bigl[S_2(p_1\, t)\bigr]^2, +\end{equation} +with +\begin{equation}\label{eq:p1} + p_1 = \frac{1}{4 - 4^{1/(2 \cdot 1 + 1)}} = \frac{1}{4 - 4^{1/3}}. +\end{equation} + +\noindent +Numerically, $p_1 = (4 - 4^{1/3})^{-1} \approx 0.41449$. + +\section{The Bug in the QDK Implementation} + +The QDK's \texttt{suzuki\_recursion} function in +\texttt{source/pip/qsharp/applications/magnets/trotter/trotter.py} +implements the recursion as follows: + +\begin{lstlisting} +def suzuki_recursion(trotter: TrotterStep) -> TrotterStep: + # ... + p = 1 / (4 - 4 ** (1 / (2 * trotter.order + 1))) + # ... +\end{lstlisting} + +When called on a Strang splitting (\texttt{trotter.order = 2}), this computes: +\begin{equation}\label{eq:qdk_p} + p_{\text{QDK}} = \frac{1}{4 - 4^{1/(2 \cdot 2 + 1)}} = \frac{1}{4 - 4^{1/5}} + \approx 0.37307. +\end{equation} + +\subsection{What went wrong} + +The \texttt{trotter.order} attribute equals $2$ (the order of the input +formula). In Suzuki's notation, the input is $S_{2k}$ with $2k = 2$, so $k = +1$. The correct parameter is: +\begin{equation} + p_k = p_1 = \frac{1}{4 - 4^{1/(2 \cdot 1 + 1)}} = \frac{1}{4 - 4^{1/3}}. +\end{equation} + +The code substitutes \texttt{trotter.order} (which is $2k = 2$) directly in +place of $k$: +\begin{equation} + p_{\text{QDK}} = \frac{1}{4 - 4^{1/(2 \cdot (2k) + 1)}} = \frac{1}{4 - 4^{1/(4k+1)}}. +\end{equation} + +For $k=1$ this gives $p_{\text{QDK}} = 1/(4-4^{1/5})$ instead of the correct +$p_1 = 1/(4-4^{1/3})$. + +\subsection{Correct fix} + +The fix is to use $k = \texttt{trotter.order} / 2$: +\begin{lstlisting} +k = trotter.order // 2 # order=2 -> k=1 +p = 1 / (4 - 4 ** (1 / (2 * k + 1))) +\end{lstlisting} + +\noindent +Or equivalently, use \texttt{trotter.order - 1} as the exponent denominator: +\begin{lstlisting} +p = 1 / (4 - 4 ** (1 / (trotter.order + 1))) +\end{lstlisting} + +\noindent +since $2k + 1 = \texttt{trotter.order} + 1$ when $\texttt{trotter.order} = 2k$. + +\section{Impact: Loss of Convergence Order} + +With the wrong $p$, the leading-order error term in $S_{2k}$ is \emph{not} +cancelled by the recursion. The resulting formula still converges, but only at +the original order $2k$ rather than the intended $2k+2$. + +\subsection{Numerical verification} + +We verify this with a 2-qubit Ising Hamiltonian +$H = Z_0 Z_1 + 0.5\,(X_0 + X_1)$, computing $e^{-iHt}$ with $t = 1$ using +$N$ Trotter steps. The error is +$\varepsilon(N) = \lVert U_{\text{Trotter}} - U_{\text{exact}} \rVert$. + +\begin{table}[h] +\centering +\begin{tabular}{rcccc} +\toprule +$N$ & $\varepsilon_{\text{standard}}$ & order & $\varepsilon_{\text{QDK}}$ & order \\ +\midrule + 4 & $5.95 \times 10^{-5}$ & --- & $2.12 \times 10^{-3}$ & --- \\ + 8 & $3.70 \times 10^{-6}$ & 4.01 & $5.34 \times 10^{-4}$ & 1.99 \\ +16 & $2.31 \times 10^{-7}$ & 4.00 & $1.34 \times 10^{-4}$ & 2.00 \\ +32 & $1.44 \times 10^{-8}$ & 4.00 & $3.34 \times 10^{-5}$ & 2.00 \\ +64 & $9.02 \times 10^{-10}$& 4.00 & $8.35 \times 10^{-6}$ & 2.00 \\ +\bottomrule +\end{tabular} +\caption{Convergence order $= \log_2(\varepsilon_N / \varepsilon_{2N})$. The +standard formula achieves 4th-order convergence; the QDK formula achieves only +2nd-order.} +\label{tab:convergence} +\end{table} + +\noindent +Table~\ref{tab:convergence} confirms that the standard formula converges as +$\mathcal{O}(N^{-4})$ (4th~order), while the QDK formula converges as +$\mathcal{O}(N^{-2})$ (2nd~order). The Suzuki recursion with the wrong $p$ +does not achieve the intended order promotion. + +\section{Verification of the qdk-chemistry Implementation} + +We verify that the qdk-chemistry Trotter implementation achieves the correct +convergence order by running the full pipeline end-to-end: +\texttt{create\_ising\_hamiltonian} $\to$ \texttt{Trotter.run()} $\to$ +\texttt{to\_circuit()} $\to$ Cirq unitary extraction $\to$ comparison with +$e^{-iHt}$. + +\subsection{2-qubit Ising chain} + +$H = Z_0 Z_1 + 0.5\,X_0 + 0.5\,X_1$, $t = 1$. + +\begin{table}[h] +\centering +\begin{tabular}{rcccc} +\toprule +$N$ & $\varepsilon$ (order~2) & conv.\ order & $\varepsilon$ (order~4) & conv.\ order \\ +\midrule + 2 & $1.01 \times 10^{-1}$ & --- & $9.74 \times 10^{-4}$ & --- \\ + 4 & $2.44 \times 10^{-2}$ & 2.04 & $5.95 \times 10^{-5}$ & 4.03 \\ + 8 & $6.06 \times 10^{-3}$ & 2.01 & $3.70 \times 10^{-6}$ & 4.01 \\ + 16 & $1.51 \times 10^{-3}$ & 2.00 & $2.31 \times 10^{-7}$ & 4.00 \\ + 32 & $3.78 \times 10^{-4}$ & 2.00 & $1.44 \times 10^{-8}$ & 4.00 \\ + 64 & $9.45 \times 10^{-5}$ & 2.00 & $9.02 \times 10^{-10}$ & 4.00 \\ +128 & $2.36 \times 10^{-5}$ & 2.00 & $5.64 \times 10^{-11}$ & 4.00 \\ +\bottomrule +\end{tabular} +\caption{qdk-chemistry convergence for the 2-qubit Ising chain. +Both 2nd- and 4th-order formulas achieve their expected convergence rates.} +\end{table} + +\subsection{4-qubit Ising lattice ($2\times 2$)} + +$H = \sum_{\langle i,j\rangle} Z_i Z_j + 0.5 \sum_i X_i$ on a $2 \times 2$ +square lattice (8 Pauli terms), $t = 1$. + +\begin{table}[h] +\centering +\begin{tabular}{rcccc} +\toprule +$N$ & $\varepsilon$ (order~2) & conv.\ order & $\varepsilon$ (order~4) & conv.\ order \\ +\midrule + 2 & $4.38 \times 10^{-1}$ & --- & $9.23 \times 10^{-3}$ & --- \\ + 4 & $1.03 \times 10^{-1}$ & 2.08 & $5.65 \times 10^{-4}$ & 4.03 \\ + 8 & $2.55 \times 10^{-2}$ & 2.02 & $3.54 \times 10^{-5}$ & 4.00 \\ +16 & $6.35 \times 10^{-3}$ & 2.00 & $2.22 \times 10^{-6}$ & 4.00 \\ +32 & $1.59 \times 10^{-3}$ & 2.00 & $1.39 \times 10^{-7}$ & 4.00 \\ +64 & $3.97 \times 10^{-4}$ & 2.00 & $8.67 \times 10^{-9}$ & 4.00 \\ +\bottomrule +\end{tabular} +\caption{qdk-chemistry convergence for the $2\times 2$ Ising lattice +(with \texttt{optimize\_term\_ordering=True}).} +\end{table} + +\noindent +All tests confirm that the qdk-chemistry implementation achieves the correct +convergence order for both 2nd- and 4th-order Trotter formulas. + +\section{Summary} + +\begin{itemize} + \item The Suzuki recursion \eqref{eq:recursion} promotes order $2k$ to + $2k+2$ using $p_k = 1/(4 - 4^{1/(2k+1)})$, where $k$ indexes the + \emph{input} formula order as $2k$. + \item The QDK code uses \texttt{trotter.order} (which equals $2k$, not $k$) + directly in the formula, computing + $p = 1/(4 - 4^{1/(2 \cdot 2k + 1)}) = 1/(4 - 4^{1/(4k+1)})$. + \item For the $S_2 \to S_4$ promotion ($k=1$), this gives + $1/(4-4^{1/5}) \approx 0.373$ instead of the correct + $1/(4-4^{1/3}) \approx 0.414$. + \item Numerical experiments confirm the resulting formula is only 2nd-order + accurate, not 4th-order as intended. +\end{itemize} + +\begin{thebibliography}{9} +\bibitem{Suzuki1990} + M.~Suzuki, + ``Fractal decomposition of exponential operators with applications to + many-body theories and Monte Carlo simulations,'' + \textit{Phys.\ Lett.\ A}, vol.~146, pp.~319--323, 1990. + +\bibitem{Suzuki1991} + M.~Suzuki, + ``General theory of fractal path integrals with applications to many-body + theories and statistical physics,'' + \textit{J.\ Math.\ Phys.}, vol.~32, pp.~400--407, 1991. +\end{thebibliography} + +\end{document} diff --git a/python/src/pybind11/data/lattice_graph.cpp b/python/src/pybind11/data/lattice_graph.cpp index cc68b530c..746376cd4 100644 --- a/python/src/pybind11/data/lattice_graph.cpp +++ b/python/src/pybind11/data/lattice_graph.cpp @@ -216,6 +216,30 @@ Check whether two sites are connected by an edge. )", py::arg("i"), py::arg("j")); + lattice_graph.def_property_readonly( + "edge_coloring", + [](const LatticeGraph &self) -> std::optional { + const auto &coloring = self.edge_coloring(); + if (!coloring.has_value()) { + return std::nullopt; + } + py::dict out; + for (const auto &[edge, color] : *coloring) { + out[py::make_tuple(edge.first, edge.second)] = color; + } + return out; + }, + R"( +Edge coloring stored at construction time, or ``None``. + +Factory methods for recognised topologies pre-populate this field. +Returns ``None`` for lattices constructed without a coloring. + +Returns: + dict[tuple[int, int], int] | None: Mapping of canonical edges (``i < j``) + to non-negative color labels, or ``None``. +)"); + // Static factory methods lattice_graph.def_static("chain", &LatticeGraph::chain, R"( Create a one-dimensional chain lattice. diff --git a/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py b/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py index b1b7ff4d7..d0b954ad6 100644 --- a/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py +++ b/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py @@ -211,7 +211,26 @@ def _run_impl( """ Logger.trace_entering() circuit_executor = self._create_nested("circuit_executor") - qubit_hamiltonians = qubit_hamiltonian.group_commuting(qubit_wise=True) + + from qdk_chemistry.algorithms import registry # noqa: PLC0415 + + grouper = registry.create("term_grouper", "qubit_wise_commuting") + grouped = grouper.run(qubit_hamiltonian) + partition = grouped.term_partition + labels = grouped.pauli_strings + coeffs = grouped.coefficients + encoding = grouped.encoding + fermion_mode_order = grouped.fermion_mode_order + + qubit_hamiltonians = [ + QubitHamiltonian( + pauli_strings=[labels[i] for i in group], + coefficients=np.asarray([coeffs[i] for i in group]), + encoding=encoding, + fermion_mode_order=fermion_mode_order, + ) + for group in partition.groups + ] num_observables = len(qubit_hamiltonians) if total_shots < num_observables: raise ValueError( diff --git a/python/src/qdk_chemistry/algorithms/registry.py b/python/src/qdk_chemistry/algorithms/registry.py index 84e072711..93dd0fc59 100644 --- a/python/src/qdk_chemistry/algorithms/registry.py +++ b/python/src/qdk_chemistry/algorithms/registry.py @@ -510,6 +510,7 @@ def _register_python_factories(): from qdk_chemistry.algorithms.qubit_hamiltonian_solver import QubitHamiltonianSolverFactory # noqa: PLC0415 from qdk_chemistry.algorithms.qubit_mapper import QubitMapperFactory # noqa: PLC0415 from qdk_chemistry.algorithms.state_preparation import StatePreparationFactory # noqa: PLC0415 + from qdk_chemistry.algorithms.term_grouper import TermGrouperFactory # noqa: PLC0415 from qdk_chemistry.algorithms.time_evolution.builder import TimeEvolutionBuilderFactory # noqa: PLC0415 from qdk_chemistry.algorithms.time_evolution.controlled_circuit_mapper import ( # noqa: PLC0415 ControlledEvolutionCircuitMapperFactory, @@ -517,6 +518,7 @@ def _register_python_factories(): register_factory(EnergyEstimatorFactory()) register_factory(StatePreparationFactory()) + register_factory(TermGrouperFactory()) register_factory(QubitMapperFactory()) register_factory(QubitHamiltonianSolverFactory()) register_factory(TimeEvolutionBuilderFactory()) @@ -585,6 +587,11 @@ def _register_python_algorithms(): from qdk_chemistry.algorithms.qubit_hamiltonian_solver import DenseMatrixSolver, SparseMatrixSolver # noqa: PLC0415 from qdk_chemistry.algorithms.qubit_mapper import QdkQubitMapper # noqa: PLC0415 from qdk_chemistry.algorithms.state_preparation import SparseIsometryGF2XStatePreparation # noqa: PLC0415 + from qdk_chemistry.algorithms.term_grouper import ( # noqa: PLC0415 + FullCommutingTermGrouper, + IdentityTermGrouper, + QubitWiseCommutingTermGrouper, + ) from qdk_chemistry.algorithms.time_evolution.builder.partially_randomized import ( # noqa: PLC0415 PartiallyRandomized, ) @@ -603,6 +610,9 @@ def _register_python_algorithms(): register(lambda: DenseMatrixSolver()) register(lambda: SparseMatrixSolver()) register(lambda: QdkQubitMapper()) + register(lambda: FullCommutingTermGrouper()) + register(lambda: QubitWiseCommutingTermGrouper()) + register(lambda: IdentityTermGrouper()) register(lambda: Trotter()) register(lambda: QDrift()) register(lambda: PartiallyRandomized()) diff --git a/python/src/qdk_chemistry/algorithms/term_grouper/__init__.py b/python/src/qdk_chemistry/algorithms/term_grouper/__init__.py new file mode 100644 index 000000000..138aeaf95 --- /dev/null +++ b/python/src/qdk_chemistry/algorithms/term_grouper/__init__.py @@ -0,0 +1,34 @@ +"""Term-grouper algorithms for :class:`~qdk_chemistry.data.QubitHamiltonian`. + +A *term grouper* takes a :class:`~qdk_chemistry.data.QubitHamiltonian` and +returns a new one with a populated +:attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` that downstream +algorithms can exploit. + +Example: + >>> from qdk_chemistry.algorithms import registry + >>> grouper = registry.create("term_grouper", "qubit_wise_commuting") + >>> grouped = grouper.run(my_hamiltonian) + >>> grouped.term_partition # FlatPartition with strategy="qubit_wise_commuting" + +""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from qdk_chemistry.algorithms.term_grouper.base import TermGrouper, TermGrouperFactory +from qdk_chemistry.algorithms.term_grouper.commuting import ( + FullCommutingTermGrouper, + QubitWiseCommutingTermGrouper, +) +from qdk_chemistry.algorithms.term_grouper.identity import IdentityTermGrouper + +__all__ = [ + "FullCommutingTermGrouper", + "IdentityTermGrouper", + "QubitWiseCommutingTermGrouper", + "TermGrouper", + "TermGrouperFactory", +] diff --git a/python/src/qdk_chemistry/algorithms/term_grouper/base.py b/python/src/qdk_chemistry/algorithms/term_grouper/base.py new file mode 100644 index 000000000..c389b6116 --- /dev/null +++ b/python/src/qdk_chemistry/algorithms/term_grouper/base.py @@ -0,0 +1,69 @@ +"""Abstract base class and factory for term-grouper algorithms.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from abc import abstractmethod + +from qdk_chemistry.algorithms.base import Algorithm, AlgorithmFactory +from qdk_chemistry.data import QubitHamiltonian, Settings + +__all__ = ["TermGrouper", "TermGrouperFactory"] + + +class TermGrouperSettings(Settings): + """Settings for term-grouper algorithms.""" + + def __init__(self): + """Initialise default term-grouper settings (currently empty).""" + super().__init__() + + +class TermGrouper(Algorithm): + """Abstract base class for algorithms that partition Hamiltonian terms. + + A ``TermGrouper`` consumes a :class:`~qdk_chemistry.data.QubitHamiltonian` + and returns a *new* ``QubitHamiltonian`` whose + :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` is populated + with the grouping computed by the strategy. + + Subclasses implement ``_run_impl``, which must return a new + ``QubitHamiltonian`` (the input must not be mutated). + + """ + + def __init__(self): + """Initialise the term grouper with default settings.""" + super().__init__() + self._settings = TermGrouperSettings() + + def type_name(self) -> str: + """Return ``term_grouper`` as the algorithm type name.""" + return "term_grouper" + + @abstractmethod + def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: + """Compute a term partition and return a new ``QubitHamiltonian`` carrying it. + + Args: + qubit_hamiltonian: Hamiltonian whose Pauli terms should be partitioned. + + Returns: + QubitHamiltonian: A copy of the input with + :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` populated. + + """ + + +class TermGrouperFactory(AlgorithmFactory): + """Factory for :class:`TermGrouper` instances.""" + + def algorithm_type_name(self) -> str: + """Return ``term_grouper`` as the algorithm type name.""" + return "term_grouper" + + def default_algorithm_name(self) -> str: + """Return ``commuting`` as the default term-grouper algorithm.""" + return "commuting" diff --git a/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py b/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py new file mode 100644 index 000000000..cb8c469b6 --- /dev/null +++ b/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py @@ -0,0 +1,123 @@ +"""Commutation-based term groupers (full and qubit-wise).""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from collections.abc import Callable + +from qdk_chemistry.algorithms.term_grouper.base import TermGrouper +from qdk_chemistry.data import FlatPartition, QubitHamiltonian +from qdk_chemistry.utils.pauli_commutation import do_pauli_labels_commute, do_pauli_labels_qw_commute + +__all__ = ["FullCommutingTermGrouper", "QubitWiseCommutingTermGrouper"] + + +def _greedy_color_partition( + pauli_strings: list[str], + commutes: Callable[[str, str], bool], +) -> tuple[tuple[int, ...], ...]: + """Partition Pauli labels into commuting groups via greedy graph coloring. + + Conceptually builds the non-commutation graph (an edge between every pair + of labels that do *not* commute) and colors it greedily, assigning each + label the lowest color not used by any non-commuting neighbour. Labels + sharing a color form a commuting group. + + Args: + pauli_strings: Pauli labels to partition. + commutes: Predicate returning ``True`` when two labels commute. + + Returns: + Tuple of groups; each group is a tuple of indices into ``pauli_strings``. + + """ + groups: list[list[int]] = [] + group_labels: list[list[str]] = [] + + for i, pauli_str in enumerate(pauli_strings): + placed = False + for group, labels in zip(groups, group_labels, strict=True): + if all(commutes(pauli_str, existing) for existing in labels): + group.append(i) + labels.append(pauli_str) + placed = True + break + if not placed: + groups.append([i]) + group_labels.append([pauli_str]) + + return tuple(tuple(g) for g in groups) + + +class FullCommutingTermGrouper(TermGrouper): + """Group terms by full Pauli commutation (``[P_i, P_j] = 0``). + + The resulting :class:`~qdk_chemistry.data.FlatPartition` stores a + :class:`~qdk_chemistry.data.QubitHamiltonian` partition where every pair of + terms in the same group commutes globally. Useful for Trotter-style + decompositions, which can exponentiate a commuting block as a single + ordered product without splitting error. + + """ + + def name(self) -> str: + """Return ``commuting`` as the algorithm name.""" + return "commuting" + + def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: + """Return a copy of ``qubit_hamiltonian`` with a full-commutation partition. + + Args: + qubit_hamiltonian: Hamiltonian to partition. + + Returns: + QubitHamiltonian: New instance carrying a :class:`~qdk_chemistry.data.FlatPartition` with strategy ``"commuting"``. + + """ + groups = _greedy_color_partition(qubit_hamiltonian.pauli_strings, do_pauli_labels_commute) + partition = FlatPartition(strategy="commuting", groups=groups) + return QubitHamiltonian( + pauli_strings=list(qubit_hamiltonian.pauli_strings), + coefficients=qubit_hamiltonian.coefficients.copy(), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + term_partition=partition, + ) + + +class QubitWiseCommutingTermGrouper(TermGrouper): + """Group terms by qubit-wise commutation. + + Two labels qubit-wise commute when, on every qubit position, the two + single-qubit Paulis individually commute (i.e. one is identity or both are + equal). All members of a group can be measured in a single basis, which + is the property exploited by :class:`~qdk_chemistry.algorithms.QdkEnergyEstimator` + for measurement-cost reduction. + + """ + + def name(self) -> str: + """Return ``qubit_wise_commuting`` as the algorithm name.""" + return "qubit_wise_commuting" + + def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: + """Return a copy of ``qubit_hamiltonian`` with a qubit-wise commutation partition. + + Args: + qubit_hamiltonian: Hamiltonian to partition. + + Returns: + QubitHamiltonian: New instance carrying a :class:`~qdk_chemistry.data.FlatPartition` with strategy ``"qubit_wise_commuting"``. + + """ + groups = _greedy_color_partition(qubit_hamiltonian.pauli_strings, do_pauli_labels_qw_commute) + partition = FlatPartition(strategy="qubit_wise_commuting", groups=groups) + return QubitHamiltonian( + pauli_strings=list(qubit_hamiltonian.pauli_strings), + coefficients=qubit_hamiltonian.coefficients.copy(), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + term_partition=partition, + ) diff --git a/python/src/qdk_chemistry/algorithms/term_grouper/identity.py b/python/src/qdk_chemistry/algorithms/term_grouper/identity.py new file mode 100644 index 000000000..ba93df8d6 --- /dev/null +++ b/python/src/qdk_chemistry/algorithms/term_grouper/identity.py @@ -0,0 +1,48 @@ +"""Identity term grouper: each Pauli term is its own group.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from qdk_chemistry.algorithms.term_grouper.base import TermGrouper +from qdk_chemistry.data import FlatPartition, QubitHamiltonian + +__all__ = ["IdentityTermGrouper"] + + +class IdentityTermGrouper(TermGrouper): + """Trivial grouper — every term is placed in its own single-element group. + + Useful to clear an existing :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` + while still passing through the standard ``term_grouper`` interface, or to + disable downstream group-aware optimisation in a controlled way. + + """ + + def name(self) -> str: + """Return ``identity`` as the algorithm name.""" + return "identity" + + def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: + """Return a copy of ``qubit_hamiltonian`` with one group per term. + + Args: + qubit_hamiltonian: Hamiltonian to wrap. + + Returns: + QubitHamiltonian: A new instance with a :class:`FlatPartition`. + + """ + n = len(qubit_hamiltonian.pauli_strings) + partition = FlatPartition( + strategy="identity", + groups=tuple((i,) for i in range(n)), + ) + return QubitHamiltonian( + pauli_strings=list(qubit_hamiltonian.pauli_strings), + coefficients=qubit_hamiltonian.coefficients.copy(), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + term_partition=partition, + ) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index 40839bb26..e394b5874 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -18,12 +18,23 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +from __future__ import annotations + +import numpy as np + from qdk_chemistry.algorithms.time_evolution.builder.base import TimeEvolutionBuilder from qdk_chemistry.algorithms.time_evolution.builder.trotter_error import ( trotter_steps_commutator, trotter_steps_naive, ) -from qdk_chemistry.data import QubitHamiltonian, Settings, TimeEvolutionUnitary +from qdk_chemistry.data import ( + FlatPartition, + LayeredPartition, + QubitHamiltonian, + Settings, + TermPartition, + TimeEvolutionUnitary, +) from qdk_chemistry.data.time_evolution.containers.pauli_product_formula import ( ExponentiatedPauliTerm, PauliProductFormulaContainer, @@ -116,21 +127,17 @@ def __init__( * ``"naive"``: uses the triangle-inequality bound. :math:`N = \lceil (\sum_j|\alpha_j|)^{2}t^{2}/\epsilon \rceil` + When the input :class:`~qdk_chemistry.data.QubitHamiltonian` carries a populated + :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition`, the builder consumes it + directly for schedule-level grouping. When no partition is present, each Pauli term + is exponentiated as its own group. + Args: - order: The order of the Trotter decomposition (currently only - first order is supported). Defaults to 1. - target_accuracy: Target accuracy for automatic step computation. - Must be positive to enable automatic computation. - Use 0.0 (default) to disable. - num_divisions: Explicit number of divisions within a Trotter - step. When both *num_divisions* and *target_accuracy* - are given the larger value is used. Use 0 (default) for - automatic determination. - error_bound: Strategy for computing the Trotter error bound - when *target_accuracy* is set. Either ``"commutator"`` - (default, tighter) or ``"naive"``. - weight_threshold: Absolute threshold for filtering small - Hamiltonian coefficients. Defaults to 1e-12. + order: The order of the Trotter decomposition (currently only first order is supported). Defaults to 1. + target_accuracy: Target accuracy for automatic step computation. Must be positive to enable automatic computation. Use 0.0 (default) to disable. + num_divisions: Explicit number of divisions within a Trotter step. When both *num_divisions* and *target_accuracy* are given the larger value is used. Use 0 (default) for automatic determination. + error_bound: Strategy for computing the Trotter error bound when *target_accuracy* is set. Either ``"commutator"`` (default, tighter) or ``"naive"``. + weight_threshold: Absolute threshold for filtering small Hamiltonian coefficients. Defaults to 1e-12. """ super().__init__() @@ -187,7 +194,9 @@ def _trotter(self, qubit_hamiltonian: QubitHamiltonian, time: float) -> TimeEvol delta = time / num_divisions - terms = self._decompose_trotter_step(qubit_hamiltonian, time=delta, atol=weight_threshold) + terms = self._decompose_trotter_step( + qubit_hamiltonian, time=delta, atol=weight_threshold + ) num_qubits = qubit_hamiltonian.num_qubits @@ -237,7 +246,11 @@ def _resolve_num_divisions(self, qubit_hamiltonian: QubitHamiltonian, time: floa return max(manual, auto) def _decompose_trotter_step( - self, qubit_hamiltonian: QubitHamiltonian, time: float, *, atol: float = 1e-12 + self, + qubit_hamiltonian: QubitHamiltonian, + time: float, + *, + atol: float = 1e-12, ) -> list[ExponentiatedPauliTerm]: """Decompose a single Trotter step into exponentiated Pauli terms. @@ -247,7 +260,6 @@ def _decompose_trotter_step( Args: qubit_hamiltonian: The qubit Hamiltonian to be decomposed. time: The evolution time for the single step. - atol: Absolute tolerance for filtering small coefficients. Returns: @@ -259,8 +271,6 @@ def _decompose_trotter_step( if not qubit_hamiltonian.is_hermitian(tolerance=atol): raise ValueError("Non-Hermitian Hamiltonian: coefficients have nonzero imaginary parts.") - order = self._settings.get("order") - coeffs = list(qubit_hamiltonian.get_real_coefficients(tolerance=atol)) # If there are no coefficients (e.g., empty Hamiltonian or all filtered by atol), # there is nothing to decompose; return the empty list of terms. @@ -268,80 +278,200 @@ def _decompose_trotter_step( Logger.warn("No coefficients above the tolerance; returning empty term list.") return terms + order = self._settings.get("order") + grouped_hamiltonians = self._group_terms(qubit_hamiltonian) + if order == 1: - for label, coeff in coeffs: - mapping = self._pauli_label_to_map(label) - angle = coeff * time - terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) + for group in grouped_hamiltonians: + for subgroup in group: + terms.extend( + self._exponentiate_commuting( + subgroup, + time=time, + atol=atol, + ) + ) + # order = 2 or order = 2k with k>1 else: - # \prod_{i=1}^{L-1} e^{-iH_i t/(2n)} - for label, coeff in coeffs[:-1]: - mapping = self._pauli_label_to_map(label) - angle = coeff * time / 2 - terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) - # e^{-iH_L t/n} - label, coeff = coeffs[-1] - mapping = self._pauli_label_to_map(label) - angle = coeff * time - terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) - - # \prod_{i=L-1}^1 e^{-iH_i t/(2n)} - for label, coeff in reversed(coeffs[:-1]): - mapping = self._pauli_label_to_map(label) - angle = coeff * time / 2 - terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) - - # Construct order 2k formula bottom up dynamic-programming style - if order > 2: - step_terms = terms.copy() + # Build an abstract schedule of (time_fraction, group_index) entries. + # The Strang splitting puts group 0..L-2 at half-time on the outside + # and group L-1 at full-time in the middle: + # S2(t) = [t/2 * G0, ..., t/2 * G_{L-2}, t * G_{L-1}, t/2 * G_{L-2}, ..., t/2 * G0] + n_groups = len(grouped_hamiltonians) + schedule: list[tuple[float, int]] = [] + for g in range(n_groups - 1): + schedule.append((0.5, g)) + schedule.append((1.0, n_groups - 1)) + for g in range(n_groups - 2, -1, -1): + schedule.append((0.5, g)) + + # Apply Suzuki recursion at the schedule level for order > 2 + if order > 2 and order % 2 == 0: for k in range(2, int(order / 2) + 1): u_k = 1 / (4 - 4 ** (1 / (2 * k - 1))) - new_terms = [] - - # S_{2k-2}(u_k t)^2 = S_{2k-2}(u_k t) S_{2k-2}(u_k t) + new_schedule: list[tuple[float, int]] = [] + # S_{2k}(t) = S_{2k-2}(u_k t)^2 S_{2k-2}((1-4u_k) t) S_{2k-2}(u_k t)^2 for _ in range(2): - for term in step_terms: - new_terms.append( - ExponentiatedPauliTerm( - pauli_term=term.pauli_term, - angle=term.angle * u_k, - ) - ) - # S_{2k-2}((1-4u_k) t) - for term in step_terms: - new_terms.append( - ExponentiatedPauliTerm( - pauli_term=term.pauli_term, - angle=term.angle * (1 - 4 * u_k), - ) - ) - - # S_{2k-2}(u_k t)^2 = S_{2k-2}(u_k t) S_{2k-2}(u_k t) + for frac, g in schedule: + new_schedule.append((frac * u_k, g)) + for frac, g in schedule: + new_schedule.append((frac * (1 - 4 * u_k), g)) for _ in range(2): - for term in step_terms: - new_terms.append( - ExponentiatedPauliTerm( - pauli_term=term.pauli_term, - angle=term.angle * u_k, - ) - ) - - step_terms = new_terms - terms = step_terms - - # Merge adjacent terms with the same pauli_term by summing angles. - merged_terms: list[ExponentiatedPauliTerm] = [] - for term in terms: - if merged_terms and merged_terms[-1].pauli_term == term.pauli_term: - last = merged_terms[-1] - merged_terms[-1] = ExponentiatedPauliTerm( - pauli_term=last.pauli_term, - angle=last.angle + term.angle, - ) + for frac, g in schedule: + new_schedule.append((frac * u_k, g)) + schedule = new_schedule + + # Reduce the schedule: merge consecutive entries with the same group index + reduced: list[tuple[float, int]] = [] + for frac, g in schedule: + if reduced and reduced[-1][1] == g: + reduced[-1] = (reduced[-1][0] + frac, g) else: - merged_terms.append(term) - terms = merged_terms + reduced.append((frac, g)) + schedule = reduced + + # Expand the schedule into exponentiated Pauli terms + for frac, g in schedule: + for subgroup in grouped_hamiltonians[g]: + terms.extend( + self._exponentiate_commuting( + subgroup, + time=time * frac, + atol=atol, + ) + ) + + return terms + + def _group_terms( + self, + qubit_hamiltonian: QubitHamiltonian, + ) -> list[list[QubitHamiltonian]]: + """Group Hamiltonian terms for Trotter decomposition. + + When the Hamiltonian carries a populated + :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition`, it is + consumed directly. Both :class:`~qdk_chemistry.data.LayeredPartition` + and :class:`~qdk_chemistry.data.FlatPartition` are accepted. + + When no partition is present, each Pauli term is treated as its own + single-term group with no reordering. + + Args: + qubit_hamiltonian: The qubit Hamiltonian to group. + + Returns: + A list of groups, where each group is a list of + ``QubitHamiltonian`` sub-groups (parallelisable layers). + + """ + partition = qubit_hamiltonian.term_partition + if partition is not None: + Logger.info( + f"Trotter: consuming QubitHamiltonian.term_partition " + f"(strategy={partition.strategy!r}, num_groups={partition.num_groups})." + ) + return self._groups_from_partition(qubit_hamiltonian, partition) + + Logger.info( + "Trotter: no term_partition present; " + "treating each Pauli term as its own group." + ) + return [ + [ + QubitHamiltonian( + pauli_strings=[label], + coefficients=[coeff], + encoding=qubit_hamiltonian.encoding, + ) + ] + for label, coeff in zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=True) + ] + + def _groups_from_partition( + self, + qubit_hamiltonian: QubitHamiltonian, + partition: TermPartition, + ) -> list[list[QubitHamiltonian]]: + """Materialise a :class:`TermPartition` into Trotter sub-groups. + + Both :class:`~qdk_chemistry.data.LayeredPartition` and + :class:`~qdk_chemistry.data.FlatPartition` are supported. A flat + partition's groups are treated as single layers. Groups are sorted by + ascending layer count so that the smallest groups sit on the outside + of the Strang/Suzuki splitting and merge at boundaries. + + Args: + qubit_hamiltonian: Source Hamiltonian whose Pauli terms the partition indexes into. + partition: Index-based partition carried on ``qubit_hamiltonian``. + + Returns: + List of groups; each group is a list of layer ``QubitHamiltonian`` objects. + + """ + labels = qubit_hamiltonian.pauli_strings + coeffs = qubit_hamiltonian.coefficients + encoding = qubit_hamiltonian.encoding + + def _make(indices: tuple[int, ...]) -> QubitHamiltonian: + return QubitHamiltonian( + pauli_strings=[labels[i] for i in indices], + coefficients=np.asarray([coeffs[i] for i in indices]), + encoding=encoding, + ) + + # Normalise to (group → tuple of layers of indices) + if isinstance(partition, LayeredPartition): + layered_groups = partition.groups + elif isinstance(partition, FlatPartition): + layered_groups = tuple((g,) for g in partition.groups) + else: + raise TypeError( + f"Unsupported TermPartition subtype: {type(partition).__name__}. " + "Expected FlatPartition or LayeredPartition." + ) + + groups: list[list[QubitHamiltonian]] = [ + [_make(layer) for layer in group_layers if layer] for group_layers in layered_groups + ] + # Drop empty groups (no layers / all layers empty). + groups = [g for g in groups if g] + + # Sort groups by ascending layer count so the smallest sits on the + # outside of the Strang/Suzuki splitting (maximises boundary merging). + groups.sort(key=len) + return groups + + def _exponentiate_commuting( + self, + group: QubitHamiltonian, + time: float, + *, + atol: float = 1e-12, + ) -> list[ExponentiatedPauliTerm]: + r"""Exponentiate a group of commuting Pauli terms. + + Each term :math:`P_j` with coefficient :math:`c_j` is converted to + the rotation :math:`e^{-i\,c_j\,t\,P_j}`. Because all terms in the + group commute and :meth:`_group_terms` ensures they have disjoint + qubit supports, the rotations can be applied in any order. + + Args: + group: The group of commuting Hamiltonian terms to exponentiate. + time: The evolution time used to compute rotation angles + (:math:`\theta_j = c_j \cdot t`). + atol: Absolute tolerance for filtering small coefficients. + + Returns: + A flat list of :class:`ExponentiatedPauliTerm`. + + """ + terms: list[ExponentiatedPauliTerm] = [] + for label, coeff in group.get_real_coefficients(tolerance=atol): + mapping = self._pauli_label_to_map(label) + angle = coeff * time + terms.append(ExponentiatedPauliTerm(pauli_term=mapping, angle=angle)) return terms def name(self) -> str: diff --git a/python/src/qdk_chemistry/data/__init__.py b/python/src/qdk_chemistry/data/__init__.py index deb1e7801..8594ce32b 100644 --- a/python/src/qdk_chemistry/data/__init__.py +++ b/python/src/qdk_chemistry/data/__init__.py @@ -44,6 +44,8 @@ - :class:`StabilityResult`: Result of stability analysis for electronic structure calculations. - :class:`Structure`: Molecular structure and geometry information. - :class:`Symmetries`: Physical symmetries of an electronic state for symmetry-exploiting algorithms. +- :class:`TermPartition`: Index-based partition of Hamiltonian terms. + See :class:`FlatPartition` and :class:`LayeredPartition`. - :class:`TimeEvolutionUnitary`: Time evolution unitary. - :class:`TimeEvolutionUnitaryContainer`: Abstract base class for different time evolution unitary representation. - :class:`Wavefunction`: Electronic wavefunction data and coefficients. @@ -114,6 +116,7 @@ from qdk_chemistry.data.qpe_result import QpeResult from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian from qdk_chemistry.data.symmetries import Symmetries +from qdk_chemistry.data.term_partition import FlatPartition, LayeredPartition, TermPartition from qdk_chemistry.data.time_evolution.base import TimeEvolutionUnitary from qdk_chemistry.data.time_evolution.containers.base import TimeEvolutionUnitaryContainer from qdk_chemistry.data.time_evolution.containers.pauli_product_formula import PauliProductFormulaContainer @@ -145,10 +148,12 @@ "EncodingMismatchError", "EnergyExpectationResult", "FermionModeOrder", + "FlatPartition", "Hamiltonian", "HamiltonianContainer", "HamiltonianType", "LatticeGraph", + "LayeredPartition", "MP2Container", "MeasurementData", "ModelOrbitals", @@ -176,6 +181,7 @@ "StabilityResult", "Structure", "Symmetries", + "TermPartition", "TimeEvolutionUnitary", "TimeEvolutionUnitaryContainer", "Wavefunction", @@ -184,3 +190,4 @@ "get_current_ciaaw_version", "validate_encoding_compatibility", ] + diff --git a/python/src/qdk_chemistry/data/qubit_hamiltonian.py b/python/src/qdk_chemistry/data/qubit_hamiltonian.py index 99a79924e..02b6e4913 100644 --- a/python/src/qdk_chemistry/data/qubit_hamiltonian.py +++ b/python/src/qdk_chemistry/data/qubit_hamiltonian.py @@ -18,6 +18,7 @@ import numpy as np from qdk_chemistry.data.base import DataClass +from qdk_chemistry.data.term_partition import TermPartition from qdk_chemistry.utils.pauli_matrix import pauli_to_dense_matrix, pauli_to_sparse_matrix if TYPE_CHECKING: @@ -26,7 +27,6 @@ from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.utils import Logger -from qdk_chemistry.utils.pauli_commutation import do_pauli_labels_commute, do_pauli_labels_qw_commute __all__: list[str] = [] @@ -42,6 +42,11 @@ class QubitHamiltonian(DataClass): fermion_mode_order (FermionModeOrder | None): The fermion mode ordering convention used when mapping fermionic modes to qubits (``"blocked"`` or ``"interleaved"``). If None, the ordering is unspecified or not applicable. + term_partition (TermPartition | None): Optional index-based partition of + :attr:`pauli_strings` into algorithm-relevant groups (and, for layered + partitions, into parallelisable layers within each group). Set by + geometry-aware constructors and by ``term_grouper`` algorithms; reset + to ``None`` by transformations that change the term ordering. """ @@ -57,6 +62,7 @@ def __init__( coefficients: np.ndarray, encoding: str | None = None, fermion_mode_order: FermionModeOrder | str | None = None, + term_partition: TermPartition | None = None, ) -> None: """Initialize a QubitHamiltonian. @@ -65,6 +71,7 @@ def __init__( coefficients (numpy.ndarray): Array of coefficients corresponding to each Pauli string. encoding (str | None): Fermion-to-qubit encoding (e.g., ``"jordan-wigner"``). Default ``None``. fermion_mode_order (FermionModeOrder | str | None): Mode ordering (``"blocked"``/``"interleaved"``). + term_partition (TermPartition | None): Optional ``TermPartition`` carrying group/layer metadata. Raises: ValueError: If the number of Pauli strings and coefficients don't match, @@ -81,6 +88,7 @@ def __init__( self.fermion_mode_order: FermionModeOrder | None = ( FermionModeOrder(fermion_mode_order) if fermion_mode_order is not None else None ) + self.term_partition: TermPartition | None = term_partition # Validate Pauli strings _validate_pauli_strings(pauli_strings) @@ -265,42 +273,6 @@ def to_interleaved(self, n_spatial: int) -> QubitHamiltonian: fermion_mode_order=FermionModeOrder.INTERLEAVED, ) - def group_commuting(self, qubit_wise: bool = True) -> list[QubitHamiltonian]: - """Group the qubit Hamiltonian into commuting subsets. - - Args: - qubit_wise (bool): Whether to use qubit-wise commuting grouping. Default is True. - - Returns: - list[QubitHamiltonian]: A list of ``QubitHamiltonian`` representing the grouped Hamiltonian. - - """ - Logger.trace_entering() - commutes = do_pauli_labels_qw_commute if qubit_wise else do_pauli_labels_commute - - # Each group is a list of (pauli_string, coefficient) - groups: list[list[tuple[str, complex]]] = [] - - for pauli_str, coeff in zip(self.pauli_strings, self.coefficients, strict=True): - placed = False - for group in groups: - if all(commutes(pauli_str, existing_str) for existing_str, _ in group): - group.append((pauli_str, coeff)) - placed = True - break - if not placed: - groups.append([(pauli_str, coeff)]) - - return [ - QubitHamiltonian( - pauli_strings=[p for p, _ in group], - coefficients=np.array([c for _, c in group]), - encoding=self.encoding, - fermion_mode_order=self.fermion_mode_order, - ) - for group in groups - ] - # DataClass interface implementation def get_summary(self) -> str: """Get a human-readable summary of the qubit Hamiltonian. @@ -339,6 +311,8 @@ def to_json(self) -> dict[str, Any]: data["encoding"] = self.encoding if self.fermion_mode_order is not None: data["fermion_mode_order"] = str(self.fermion_mode_order) + if self.term_partition is not None: + data["term_partition"] = self.term_partition.to_json() return self._add_json_version(data) def to_hdf5(self, group: h5py.Group) -> None: @@ -355,6 +329,10 @@ def to_hdf5(self, group: h5py.Group) -> None: group.attrs["encoding"] = self.encoding if self.fermion_mode_order is not None: group.attrs["fermion_mode_order"] = str(self.fermion_mode_order) + if self.term_partition is not None: + import json # noqa: PLC0415 + + group.attrs["term_partition"] = json.dumps(self.term_partition.to_json()) @classmethod def from_json(cls, json_data: dict[str, Any]) -> QubitHamiltonian: @@ -378,11 +356,14 @@ def from_json(cls, json_data: dict[str, Any]) -> QubitHamiltonian: else: # Fallback for legacy format (simple list of real numbers) coefficients = np.array(coeff_data) + partition_data = json_data.get("term_partition") + term_partition = TermPartition.from_json(partition_data) if partition_data is not None else None return cls( pauli_strings=json_data["pauli_strings"], coefficients=coefficients, encoding=json_data.get("encoding"), fermion_mode_order=json_data.get("fermion_mode_order"), + term_partition=term_partition, ) @classmethod @@ -409,11 +390,21 @@ def from_hdf5(cls, group: h5py.Group) -> QubitHamiltonian: fermion_mode_order = group.attrs.get("fermion_mode_order") if fermion_mode_order is not None and isinstance(fermion_mode_order, bytes): fermion_mode_order = fermion_mode_order.decode("utf-8") + partition_attr = group.attrs.get("term_partition") + if partition_attr is not None: + import json # noqa: PLC0415 + + if isinstance(partition_attr, bytes): + partition_attr = partition_attr.decode("utf-8") + term_partition = TermPartition.from_json(json.loads(partition_attr)) + else: + term_partition = None return cls( pauli_strings=pauli_strings, coefficients=coefficients, encoding=encoding, fermion_mode_order=fermion_mode_order, + term_partition=term_partition, ) diff --git a/python/src/qdk_chemistry/data/term_partition.py b/python/src/qdk_chemistry/data/term_partition.py new file mode 100644 index 000000000..eb655c374 --- /dev/null +++ b/python/src/qdk_chemistry/data/term_partition.py @@ -0,0 +1,249 @@ +"""Term-partition metadata for :class:`~qdk_chemistry.data.QubitHamiltonian`. + +A :class:`TermPartition` records how the Pauli terms of a +:class:`~qdk_chemistry.data.QubitHamiltonian` are organised into algorithm- +relevant subsets. Concrete subclasses include :class:`FlatPartition` +(single-level groups) and :class:`LayeredPartition` (group → layer +hierarchy). + +The partition stores **indices** into +:attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings`, not nested +``QubitHamiltonian`` objects, so that it serialises trivially and remains +small. + +Lifecycle +--------- + +* The partition is *optional* metadata. ``term_partition is None`` means the + partition has not been computed for this Hamiltonian. +* Transformations that change the term ordering or qubit support + (for example :meth:`~qdk_chemistry.data.QubitHamiltonian.to_interleaved`) + must reset the partition to ``None`` on the new Hamiltonian. +* Algorithms that consume a partition should treat its presence as an explicit + signal to exploit it (for example, by applying schedule-level Suzuki + recursion or grouping measurements by basis). + +""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from __future__ import annotations + +from typing import Any + +from qdk_chemistry.data.base import DataClass + +__all__ = ["FlatPartition", "LayeredPartition", "TermPartition"] + + +class TermPartition(DataClass): + """Base class for index-based partitions of Hamiltonian terms. + + Use :class:`FlatPartition` for single-level partitions or + :class:`LayeredPartition` for hierarchical (group → layer) partitions. + + The ``strategy`` field is a free-form label identifying how the partition + was produced (for example ``"geometry_coloring"``, ``"commuting"``, + ``"qubit_wise_commuting"``). + + """ + + _data_type_name = "term_partition" + _serialization_version = "0.1.0" + + def __init__(self, *, strategy: str) -> None: + """Initialize the term partition. + + Args: + strategy: Label identifying how the partition was produced. + + """ + self.strategy = strategy + + @property + def num_groups(self) -> int: + """Return the number of top-level groups in the partition.""" + raise NotImplementedError + + def all_indices(self) -> list[int]: + """Return every term index referenced by the partition, in order.""" + raise NotImplementedError + + def get_summary(self) -> str: + """Return a summary of the partition.""" + return f"TermPartition(strategy={self.strategy!r}, num_groups={self.num_groups})" + + def to_json(self) -> dict[str, Any]: + """Convert this partition to a JSON-serialisable dictionary.""" + raise NotImplementedError + + def to_hdf5(self, group) -> None: + """Save this partition to an HDF5 group.""" + import json as _json # noqa: PLC0415 + + group.attrs["term_partition"] = _json.dumps(self.to_json()) + + @staticmethod + def from_json(data: dict[str, Any]) -> TermPartition: + """Reconstruct a :class:`TermPartition` from :meth:`to_json` output. + + Args: + data: Dict produced by :meth:`to_json` of either :class:`FlatPartition` or :class:`LayeredPartition`. + + Returns: + The reconstructed partition. + + Raises: + ValueError: If ``data["kind"]`` is not a recognised partition kind. + + """ + kind = data.get("kind") + if kind == "flat": + return FlatPartition(strategy=data["strategy"], groups=tuple(tuple(g) for g in data["groups"])) + if kind == "layered": + return LayeredPartition( + strategy=data["strategy"], + groups=tuple(tuple(tuple(layer) for layer in group) for group in data["groups"]), + ) + raise ValueError(f"Unknown TermPartition kind: {kind!r}. Expected 'flat' or 'layered'.") + + @classmethod + def from_hdf5(cls, group) -> TermPartition: + """Load a :class:`TermPartition` from an HDF5 group.""" + import json as _json # noqa: PLC0415 + + data = _json.loads(group.attrs["term_partition"]) + return cls.from_json(data) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, TermPartition): + return NotImplemented + return type(self) is type(other) and self.strategy == other.strategy + + def __hash__(self) -> int: + return hash((type(self).__name__, self.strategy)) + + +class FlatPartition(TermPartition): + """Single-level partition: each group is a list of term indices. + + Suitable for algorithms that only care about which terms belong together + (for example, qubit-wise commuting groups for measurement basis selection). + + The ``groups`` field is a tuple of groups; each group is a tuple of term + indices into :attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings`. + + Raises: + TypeError: If ``groups`` is not a sequence of sequences of integers. + + """ + + def __init__(self, *, strategy: str, groups: tuple[tuple[int, ...], ...]) -> None: + """Initialize a flat partition. + + Args: + strategy: Label identifying how the partition was produced. + groups: Tuple of groups; each group is a tuple of term indices. + + """ + super().__init__(strategy=strategy) + self.groups: tuple[tuple[int, ...], ...] = tuple(tuple(int(i) for i in group) for group in groups) + # Freeze after all attributes are set. + DataClass.__init__(self) + + @property + def num_groups(self) -> int: + """Return the number of groups.""" + return len(self.groups) + + def all_indices(self) -> list[int]: + """Return every term index referenced by the partition, in order.""" + return [i for group in self.groups for i in group] + + def get_summary(self) -> str: + """Return a summary of the flat partition.""" + return f"FlatPartition(strategy={self.strategy!r}, num_groups={self.num_groups})" + + def to_json(self) -> dict[str, Any]: + """Return a JSON-serialisable dict of this :class:`FlatPartition`.""" + return { + "kind": "flat", + "strategy": self.strategy, + "groups": [list(group) for group in self.groups], + } + + def __eq__(self, other: object) -> bool: + if not isinstance(other, FlatPartition): + return NotImplemented + return self.strategy == other.strategy and self.groups == other.groups + + def __hash__(self) -> int: + return hash(("FlatPartition", self.strategy, self.groups)) + + +class LayeredPartition(TermPartition): + """Two-level partition: each group is a sequence of parallelisable layers. + + Suitable for Trotter-style decompositions where the outer level controls + Strang/Suzuki splitting order and the inner level groups operators with + disjoint qubit supports that can be applied in parallel. + + The ``groups`` field is a nested tuple ``(group, layer, term_index)``: + outer = groups, middle = layers within a group, inner = term indices into + :attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings`. + + Raises: + TypeError: If ``groups`` is not the expected nested-sequence shape. + + """ + + def __init__(self, *, strategy: str, groups: tuple[tuple[tuple[int, ...], ...], ...]) -> None: + """Initialize a layered partition. + + Args: + strategy: Label identifying how the partition was produced. + groups: Nested tuple ``(group, layer, term_index)``. + + """ + super().__init__(strategy=strategy) + self.groups: tuple[tuple[tuple[int, ...], ...], ...] = tuple( + tuple(tuple(int(i) for i in layer) for layer in group) for group in groups + ) + # Freeze after all attributes are set. + DataClass.__init__(self) + + @property + def num_groups(self) -> int: + """Return the number of top-level groups.""" + return len(self.groups) + + def num_layers(self, group_index: int) -> int: + """Return the number of parallelisable layers in ``group_index``.""" + return len(self.groups[group_index]) + + def all_indices(self) -> list[int]: + """Return every term index referenced by the partition, in order.""" + return [i for group in self.groups for layer in group for i in layer] + + def get_summary(self) -> str: + """Return a summary of the layered partition.""" + return f"LayeredPartition(strategy={self.strategy!r}, num_groups={self.num_groups})" + + def to_json(self) -> dict[str, Any]: + """Return a JSON-serialisable dict of this :class:`LayeredPartition`.""" + return { + "kind": "layered", + "strategy": self.strategy, + "groups": [[list(layer) for layer in group] for group in self.groups], + } + + def __eq__(self, other: object) -> bool: + if not isinstance(other, LayeredPartition): + return NotImplemented + return self.strategy == other.strategy and self.groups == other.groups + + def __hash__(self) -> int: + return hash(("LayeredPartition", self.strategy, self.groups)) diff --git a/python/src/qdk_chemistry/utils/model_hamiltonians.py b/python/src/qdk_chemistry/utils/model_hamiltonians.py index fac2f63eb..823485bf5 100644 --- a/python/src/qdk_chemistry/utils/model_hamiltonians.py +++ b/python/src/qdk_chemistry/utils/model_hamiltonians.py @@ -17,7 +17,8 @@ to_pair_param, to_site_param, ) -from qdk_chemistry.data import LatticeGraph, PauliOperator, QubitHamiltonian +from qdk_chemistry.data import LatticeGraph, LayeredPartition, PauliOperator, QubitHamiltonian + __all__ = [ "create_heisenberg_hamiltonian", @@ -40,6 +41,100 @@ def _pauli_expr_to_qubit_hamiltonian(expr, num_qubits: int) -> QubitHamiltonian: return QubitHamiltonian(pauli_strings, coefficients) +def _build_geometry_grouped_hamiltonian( + graph: LatticeGraph, + *, + couplings: list[tuple[str, np.ndarray | float]], + fields: list[tuple[str, np.ndarray | float]], + coloring: dict[tuple[int, int], int] | None = None, +) -> QubitHamiltonian: + r"""Assemble a Heisenberg-like Hamiltonian with a populated ``term_partition``. + + Builds the Pauli-string list grouped first by single-body field direction + (one *Trotter group* per direction, each containing a single layer because + field terms have disjoint support), then by two-body coupling type (one + Trotter group per ``XX``/``YY``/``ZZ`` block, each split into layers by + edge color). Term indices in + :attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings` align with the + indices stored in the returned :class:`LayeredPartition`. + + Args: + graph: Lattice graph defining connectivity. + couplings: ``[(label, value), ...]`` for two-body terms (e.g. ``[(\"XX\", jx)]``). + fields: ``[(char, value), ...]`` for single-body terms (e.g. ``[(\"X\", hx)]``). + coloring: Optional pre-computed edge coloring as ``{(i, j): color}`` with ``i < j``. When ``None``, ``graph.edge_coloring`` is read. + + Returns: + QubitHamiltonian: The assembled Hamiltonian carrying a ``LayeredPartition`` + with ``strategy=\"geometry_coloring\"``. + + """ + n = graph.num_sites + adj = graph.adjacency_matrix() + + if coloring is None: + coloring = graph.edge_coloring + if coloring is None: + raise ValueError( + "No edge coloring available on the lattice graph. " + "Use a factory method that provides one, or pass an explicit coloring." + ) + + pauli_strings: list[str] = [] + coefficients: list[complex] = [] + groups_layers: list[tuple[tuple[int, ...], ...]] = [] + + # Field groups: one group per direction, single layer each. + for pauli_char, field in fields: + field_vec = to_site_param(field, graph, "field") + layer_indices: list[int] = [] + for i in range(n): + if field_vec[i] == 0.0: + continue + ps = ["I"] * n + ps[i] = pauli_char + pauli_strings.append("".join(ps[::-1])) + coefficients.append(complex(field_vec[i])) + layer_indices.append(len(pauli_strings) - 1) + if layer_indices: + groups_layers.append((tuple(layer_indices),)) + + # Coupling groups: one group per (XX/YY/ZZ) block; layers given by edge colors. + for pauli_label, coupling in couplings: + coupling_mat = to_pair_param(coupling, graph, "coupling") + color_to_indices: dict[int, list[int]] = {} + for (i, j), c in coloring.items(): + edge_weight = adj[i, j] + if edge_weight == 0.0: + continue + coeff_val = coupling_mat[i, j] * edge_weight + if coeff_val == 0.0: + continue + ps = ["I"] * n + ps[i] = pauli_label[0] + ps[j] = pauli_label[1] + pauli_strings.append("".join(ps[::-1])) + coefficients.append(complex(coeff_val)) + color_to_indices.setdefault(c, []).append(len(pauli_strings) - 1) + if color_to_indices: + layers = tuple(tuple(color_to_indices[c]) for c in sorted(color_to_indices)) + groups_layers.append(layers) + + if not pauli_strings: + # Empty Hamiltonian: emit a single all-identity term with zero coefficient + # so the resulting QubitHamiltonian remains constructible. + pauli_strings = ["I" * n] + coefficients = [0.0 + 0.0j] + groups_layers = [((0,),)] + + partition = LayeredPartition(strategy="geometry_coloring", groups=tuple(groups_layers)) + return QubitHamiltonian( + pauli_strings=pauli_strings, + coefficients=np.array(coefficients), + term_partition=partition, + ) + + def create_heisenberg_hamiltonian( graph: LatticeGraph, jx: np.ndarray | float, @@ -48,6 +143,8 @@ def create_heisenberg_hamiltonian( hx: np.ndarray | float = 0.0, hy: np.ndarray | float = 0.0, hz: np.ndarray | float = 0.0, + *, + include_term_groups: bool = True, ) -> QubitHamiltonian: r"""Create the anisotropic Heisenberg model Hamiltonian on a lattice. @@ -76,14 +173,22 @@ def create_heisenberg_hamiltonian( hx: External magnetic field in the x direction. Scalar or length-n array. Defaults to 0. hy: External magnetic field in the y direction. Defaults to 0. hz: External magnetic field in the z direction. Defaults to 0. + include_term_groups: When ``True`` (default), attach a geometry-coloring term partition to the result. Returns: - QubitHamiltonian: The Heisenberg model as a qubit Hamiltonian. + QubitHamiltonian: The Heisenberg model as a qubit Hamiltonian; carries a ``LayeredPartition`` when grouped. """ if not graph.is_symmetric: raise ValueError("Lattice graph must be symmetric for a valid Hamiltonian.") + if include_term_groups: + return _build_geometry_grouped_hamiltonian( + graph, + couplings=[("XX", jx), ("YY", jy), ("ZZ", jz)], + fields=[("X", hx), ("Y", hy), ("Z", hz)], + ) + n = graph.num_sites adj = graph.adjacency_matrix() @@ -125,6 +230,8 @@ def create_ising_hamiltonian( graph: LatticeGraph, j: np.ndarray | float, h: np.ndarray | float = 0.0, + *, + include_term_groups: bool = True, ) -> QubitHamiltonian: r"""Create the Ising model Hamiltonian on a lattice. @@ -139,9 +246,10 @@ def create_ising_hamiltonian( graph: Lattice graph defining the connectivity. j: Coupling constant for ZZ interactions. Scalar or ``(n, n)`` array. h: Transverse field strength (x direction). Scalar or length-n array. Defaults to 0. + include_term_groups: When ``True`` (default), attach a geometry-coloring term partition to the result. Returns: QubitHamiltonian: The Ising model as a qubit Hamiltonian. """ - return create_heisenberg_hamiltonian(graph, jx=0.0, jy=0.0, jz=j, hx=h) + return create_heisenberg_hamiltonian(graph, jx=0.0, jy=0.0, jz=j, hx=h, include_term_groups=include_term_groups) diff --git a/python/tests/test_encoding_metadata.py b/python/tests/test_encoding_metadata.py index e32020dea..3a3768d3c 100644 --- a/python/tests/test_encoding_metadata.py +++ b/python/tests/test_encoding_metadata.py @@ -9,7 +9,7 @@ import numpy as np import pytest -from qdk_chemistry.algorithms import create +from qdk_chemistry.algorithms import create, registry from qdk_chemistry.data import Circuit, EncodingMismatchError, QubitHamiltonian, validate_encoding_compatibility from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT, QDK_CHEMISTRY_HAS_QISKIT_NATURE @@ -17,6 +17,22 @@ from .test_helpers import create_test_hamiltonian +def _group_commuting(qh: QubitHamiltonian, *, qubit_wise: bool = True) -> list[QubitHamiltonian]: + """Materialise commuting groups via the term_grouper algorithm.""" + strategy = "qubit_wise_commuting" if qubit_wise else "commuting" + grouped = registry.create("term_grouper", strategy).run(qh) + partition = grouped.term_partition + return [ + QubitHamiltonian( + pauli_strings=[grouped.pauli_strings[i] for i in group], + coefficients=np.asarray([grouped.coefficients[i] for i in group]), + encoding=grouped.encoding, + fermion_mode_order=grouped.fermion_mode_order, + ) + for group in partition.groups + ] + + def test_circuit_encoding_metadata(): """Test that Circuit properly stores and retrieves encoding metadata.""" # Test with encoding specified @@ -238,7 +254,7 @@ def test_group_commuting_preserves_encoding(): ham = QubitHamiltonian(pauli_strings, coefficients, encoding="jordan-wigner") # Group into commuting subsets - grouped = ham.group_commuting(qubit_wise=True) + grouped = _group_commuting(ham, qubit_wise=True) # Each group should preserve the encoding for group in grouped: diff --git a/python/tests/test_qubit_hamiltonian.py b/python/tests/test_qubit_hamiltonian.py index 0f462d8c7..41b3a02c6 100644 --- a/python/tests/test_qubit_hamiltonian.py +++ b/python/tests/test_qubit_hamiltonian.py @@ -13,12 +13,33 @@ import pytest import scipy.sparse +from qdk_chemistry.algorithms import registry from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian from .reference_tolerances import float_comparison_absolute_tolerance, float_comparison_relative_tolerance +def _group_commuting(qh: QubitHamiltonian, *, qubit_wise: bool = True) -> list[QubitHamiltonian]: + """Materialise commuting groups via the term_grouper algorithm. + + Replacement for the removed ``QubitHamiltonian.group_commuting`` method + used by the legacy tests below. + """ + strategy = "qubit_wise_commuting" if qubit_wise else "commuting" + grouped = registry.create("term_grouper", strategy).run(qh) + partition = grouped.term_partition + return [ + QubitHamiltonian( + pauli_strings=[grouped.pauli_strings[i] for i in group], + coefficients=np.asarray([grouped.coefficients[i] for i in group]), + encoding=grouped.encoding, + fermion_mode_order=grouped.fermion_mode_order, + ) + for group in partition.groups + ] + + def _pauli_matrix(label): """Return Pauli matrix from a Pauli label.""" mat = np.eye(1, dtype=complex) @@ -69,7 +90,7 @@ def test_initialization_invalid_pauli(self): def test_group_commuting(self): """Test group_commuting.""" qubit_hamiltonian = QubitHamiltonian(["XX", "YY", "ZZ", "XY"], [1.0, 0.5, -0.5, 0.2]) - grouped = qubit_hamiltonian.group_commuting(qubit_wise=False) + grouped = _group_commuting(qubit_hamiltonian, qubit_wise=False) assert len(grouped) == 2 # Verify coefficients are preserved @@ -86,7 +107,7 @@ def test_group_commuting(self): def test_group_commuting_qubitwise(self): """Test group_commuting without qubit-wise commuting.""" qubit_hamiltonian = QubitHamiltonian(["XX", "YY", "ZZ", "XY"], [1.0, 0.5, -0.5, 0.2]) - grouped = qubit_hamiltonian.group_commuting(qubit_wise=True) + grouped = _group_commuting(qubit_hamiltonian, qubit_wise=True) assert len(grouped) == 4 # Qubit-wise commuting returns four groups # Check that all original Pauli strings are present across all groups @@ -100,7 +121,7 @@ def test_group_commuting_all_commute(self): """Test that fully commuting operators go into one group.""" # ZI, IZ, ZZ all commute with each other qh = QubitHamiltonian(["ZI", "IZ", "ZZ"], np.array([1.0, -0.5, 0.3])) - grouped = qh.group_commuting(qubit_wise=False) + grouped = _group_commuting(qh, qubit_wise=False) assert len(grouped) == 1 assert len(grouped[0].pauli_strings) == 3 @@ -108,13 +129,13 @@ def test_group_commuting_none_commute(self): """Test that non-commuting operators each get their own group.""" # X and Z anticommute; Y and X anticommute; Y and Z anticommute qh = QubitHamiltonian(["X", "Z", "Y"], np.array([1.0, -0.5, 0.3])) - grouped = qh.group_commuting(qubit_wise=False) + grouped = _group_commuting(qh, qubit_wise=False) assert len(grouped) == 3 def test_group_commuting_single_term(self): """Test group_commuting with a single term.""" qh = QubitHamiltonian(["ZZ"], np.array([1.0])) - grouped = qh.group_commuting(qubit_wise=False) + grouped = _group_commuting(qh, qubit_wise=False) assert len(grouped) == 1 assert grouped[0].pauli_strings == ["ZZ"] @@ -125,7 +146,7 @@ def test_group_commuting_reconstruct_matrix(self): np.array([-0.8, 0.17, -0.17, 0.12, 0.04, 0.04]), ) # General commuting: all diagonal terms commute, XX and YY commute with each other and with diag terms - grouped = qh.group_commuting(qubit_wise=False) + grouped = _group_commuting(qh, qubit_wise=False) total_terms = sum(len(g.pauli_strings) for g in grouped) assert total_terms == 6 # Verify ground state energy via eigenvalues @@ -144,7 +165,7 @@ def test_group_commuting_qw_reconstruct_matrix(self): coeffs = np.array([0.5, 0.3, 0.2, -0.1, 0.4, -0.25, 0.15]) qh = QubitHamiltonian(labels, coeffs) original_mat = qh.to_matrix() - groups = qh.group_commuting(qubit_wise=True) + groups = _group_commuting(qh, qubit_wise=True) reconstructed = np.zeros_like(original_mat) for g in groups: reconstructed += g.to_matrix() @@ -537,7 +558,7 @@ def test_group_commuting_preserves(self): np.array([1.0, 0.5, -0.5]), fermion_mode_order=FermionModeOrder.BLOCKED, ) - for group in qh.group_commuting(qubit_wise=True): + for group in _group_commuting(qh, qubit_wise=True): assert group.fermion_mode_order == FermionModeOrder.BLOCKED def test_to_interleaved_sets_order(self): diff --git a/python/tests/test_term_partition.py b/python/tests/test_term_partition.py new file mode 100644 index 000000000..cacfac4e3 --- /dev/null +++ b/python/tests/test_term_partition.py @@ -0,0 +1,294 @@ +"""Tests for the ``TermPartition`` infrastructure and ``term_grouper`` algorithms.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from __future__ import annotations + +import numpy as np +import pytest + +from qdk_chemistry.algorithms import registry +from qdk_chemistry.data import ( + FlatPartition, + LatticeGraph, + LayeredPartition, + QubitHamiltonian, + TermPartition, +) +from qdk_chemistry.utils.model_hamiltonians import ( + create_heisenberg_hamiltonian, + create_ising_hamiltonian, +) + +# --------------------------------------------------------------------------- +# TermPartition data classes +# --------------------------------------------------------------------------- + + +class TestFlatPartition: + def test_construction_normalises_to_tuples_of_ints(self): + p = FlatPartition(strategy="commuting", groups=[[0, 1, 2], [3, 4]]) + assert isinstance(p.groups, tuple) + assert all(isinstance(g, tuple) for g in p.groups) + assert all(isinstance(i, int) for g in p.groups for i in g) + assert p.groups == ((0, 1, 2), (3, 4)) + + def test_num_groups(self): + p = FlatPartition(strategy="x", groups=[[0], [1, 2], [3]]) + assert p.num_groups == 3 + + def test_all_indices(self): + p = FlatPartition(strategy="x", groups=[[2, 1], [0]]) + assert p.all_indices() == [2, 1, 0] + + def test_is_subclass_of_term_partition(self): + assert issubclass(FlatPartition, TermPartition) + + +class TestLayeredPartition: + def test_construction(self): + p = LayeredPartition( + strategy="geometry_coloring", + groups=[[[0, 1], [2, 3]], [[4]]], + ) + assert p.groups == (((0, 1), (2, 3)), ((4,),)) + + def test_num_groups_and_layers(self): + p = LayeredPartition( + strategy="x", + groups=[[[0]], [[1], [2]], [[3], [4], [5]]], + ) + assert p.num_groups == 3 + assert p.num_layers(0) == 1 + assert p.num_layers(1) == 2 + assert p.num_layers(2) == 3 + + def test_all_indices_flattens_in_order(self): + p = LayeredPartition(strategy="x", groups=[[[0, 1], [2]], [[3, 4]]]) + assert p.all_indices() == [0, 1, 2, 3, 4] + + +# --------------------------------------------------------------------------- +# QubitHamiltonian.term_partition property +# --------------------------------------------------------------------------- + + +class TestQubitHamiltonianTermPartition: + def test_default_is_none(self): + qh = QubitHamiltonian(["XX", "ZZ"], np.array([0.1, 0.2])) + assert qh.term_partition is None + + def test_round_trip_flat(self): + partition = FlatPartition(strategy="commuting", groups=[[0], [1]]) + qh = QubitHamiltonian(["XX", "ZZ"], np.array([0.1, 0.2]), term_partition=partition) + assert qh.term_partition is partition + + def test_round_trip_layered(self): + partition = LayeredPartition(strategy="geometry_coloring", groups=[[[0, 1]]]) + qh = QubitHamiltonian(["XX", "ZZ"], np.array([0.1, 0.2]), term_partition=partition) + assert qh.term_partition is partition + + def test_to_interleaved_resets_partition(self): + partition = FlatPartition(strategy="commuting", groups=[[0, 1, 2, 3]]) + qh = QubitHamiltonian( + ["XXII", "YYII", "IIZZ", "IIXX"], + np.array([0.1, 0.2, 0.3, 0.4]), + term_partition=partition, + ) + out = qh.to_interleaved(n_spatial=2) + assert out.term_partition is None + + +# --------------------------------------------------------------------------- +# term_grouper algorithm registry integration +# --------------------------------------------------------------------------- + + +class TestTermGrouperRegistry: + def test_available_strategies(self): + names = registry.available("term_grouper") + assert set(names) == {"commuting", "qubit_wise_commuting", "identity"} + + def test_default_strategy_is_commuting(self): + grouper = registry.create("term_grouper") + assert grouper.name() == "commuting" + + @pytest.mark.parametrize("strategy", ["commuting", "qubit_wise_commuting", "identity"]) + def test_returns_new_hamiltonian_with_partition(self, strategy): + qh = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([1.0, 2.0, 3.0])) + grouper = registry.create("term_grouper", strategy) + out = grouper.run(qh) + assert out is not qh + assert isinstance(out.term_partition, FlatPartition) + assert out.term_partition.strategy == strategy + + def test_partition_indices_cover_all_terms_exactly_once(self): + qh = QubitHamiltonian( + ["XIII", "IXII", "IIXI", "IIIX", "ZIII", "IZII"], + np.array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]), + ) + for strategy in ("commuting", "qubit_wise_commuting", "identity"): + grouper = registry.create("term_grouper", strategy) + out = grouper.run(qh) + indices = out.term_partition.all_indices() + assert sorted(indices) == list(range(len(qh.pauli_strings))) + + def test_identity_strategy_one_term_per_group(self): + qh = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([1.0, 2.0, 3.0])) + out = registry.create("term_grouper", "identity").run(qh) + assert out.term_partition.num_groups == len(qh.pauli_strings) + assert all(len(g) == 1 for g in out.term_partition.groups) + + def test_commuting_groups_globally_commute(self): + # XX and YY commute (XY * YX = -ZZ * -ZZ = ZZ^2 = I; and YX * XY = ZZ), + # ZZ commutes with both. So all three should land in the same group. + qh = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([1.0, 1.0, 1.0])) + out = registry.create("term_grouper", "commuting").run(qh) + assert out.term_partition.num_groups == 1 + + def test_qwc_separates_paulis_that_only_globally_commute(self): + # XX and YY are NOT qubit-wise commuting, even though they globally commute. + qh = QubitHamiltonian(["XX", "YY"], np.array([1.0, 1.0])) + out = registry.create("term_grouper", "qubit_wise_commuting").run(qh) + assert out.term_partition.num_groups == 2 + + +# --------------------------------------------------------------------------- +# LatticeGraph.edge_coloring overlay +# --------------------------------------------------------------------------- + + +class TestLatticeEdgeColoring: + def test_chain_two_colors(self): + lat = LatticeGraph.chain(4, periodic=True) + coloring = lat.edge_coloring + assert coloring is not None + assert len(set(coloring.values())) == 2 + + def test_returns_dict_or_none(self): + lat = LatticeGraph.chain(3, periodic=False) + coloring = lat.edge_coloring + assert isinstance(coloring, dict) + + +# --------------------------------------------------------------------------- +# create_*_hamiltonian populates term_partition +# --------------------------------------------------------------------------- + + +class TestModelHamiltonianTermPartition: + def test_heisenberg_populates_layered_partition(self): + lat = LatticeGraph.chain(4, periodic=True) + ham = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0) + assert isinstance(ham.term_partition, LayeredPartition) + assert ham.term_partition.strategy == "geometry_coloring" + # Indices reach every term exactly once. + assert sorted(ham.term_partition.all_indices()) == list(range(len(ham.pauli_strings))) + + def test_ising_populates_layered_partition(self): + lat = LatticeGraph.chain(4, periodic=True) + ham = create_ising_hamiltonian(lat, j=1.0, h=0.5) + assert isinstance(ham.term_partition, LayeredPartition) + assert ham.term_partition.strategy == "geometry_coloring" + + def test_include_term_groups_false_disables_partition(self): + lat = LatticeGraph.chain(4, periodic=True) + ham = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0, include_term_groups=False) + assert ham.term_partition is None + + +# --------------------------------------------------------------------------- +# Trotter consumes term_partition +# --------------------------------------------------------------------------- + + +class TestTrotterConsumesTermPartition: + def test_trotter_runs_with_partitioned_hamiltonian(self): + lat = LatticeGraph.chain(4, periodic=True) + ham = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0) + trotter = registry.create("time_evolution_builder", "trotter") + trotter.settings().update({"order": 2}) + unitary = trotter.run(ham, time=0.5) + assert unitary is not None + + def test_trotter_runs_without_partition(self): + # Falls back to treating each term as its own group. + ham = QubitHamiltonian(["XXII", "IXXI", "IIXX", "ZIII"], np.array([1.0, 1.0, 1.0, 0.5])) + assert ham.term_partition is None + trotter = registry.create("time_evolution_builder", "trotter") + unitary = trotter.run(ham, time=0.5) + assert unitary is not None + + def test_partition_produces_smaller_or_equal_step_count_at_order_2(self): + # With group sorting + schedule reduction, populating the partition + # should never produce more groups than the ungrouped fallback. + lat = LatticeGraph.chain(4, periodic=True) + with_groups = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0, include_term_groups=True) + without_groups = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0, include_term_groups=False) + assert with_groups.term_partition is not None + assert without_groups.term_partition is None + + def test_trotter_runs_with_flat_partition(self): + # Take a partitioned Hamiltonian and overwrite term_partition with a + # FlatPartition (via the term_grouper algorithm), then drive Trotter. + lat = LatticeGraph.chain(4, periodic=True) + ham = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0) + flat = registry.create("term_grouper", "commuting").run(ham) + assert isinstance(flat.term_partition, FlatPartition) + + trotter = registry.create("time_evolution_builder", "trotter") + trotter.settings().update({"order": 2}) + unitary = trotter.run(flat, time=0.5) + assert unitary is not None + + +# --------------------------------------------------------------------------- +# QubitHamiltonian round-trips term_partition through JSON / HDF5 +# --------------------------------------------------------------------------- + + +class TestTermPartitionSerialisation: + def test_flat_partition_to_json_round_trip(self): + partition = FlatPartition(strategy="commuting", groups=[[0, 2], [1]]) + data = partition.to_json() + assert data["kind"] == "flat" + restored = TermPartition.from_json(data) + assert isinstance(restored, FlatPartition) + assert restored == partition + + def test_layered_partition_to_json_round_trip(self): + partition = LayeredPartition(strategy="geometry_coloring", groups=[[[0, 1], [2]], [[3]]]) + data = partition.to_json() + assert data["kind"] == "layered" + restored = TermPartition.from_json(data) + assert isinstance(restored, LayeredPartition) + assert restored == partition + + def test_qubit_hamiltonian_json_round_trip_preserves_partition(self): + partition = FlatPartition(strategy="commuting", groups=[[0, 1], [2]]) + ham = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([0.1, 0.2, 0.3]), term_partition=partition) + restored = QubitHamiltonian.from_json(ham.to_json()) + assert isinstance(restored.term_partition, FlatPartition) + assert restored.term_partition == partition + + def test_qubit_hamiltonian_json_round_trip_with_no_partition(self): + ham = QubitHamiltonian(["XX", "ZZ"], np.array([0.1, 0.2])) + restored = QubitHamiltonian.from_json(ham.to_json()) + assert restored.term_partition is None + + def test_qubit_hamiltonian_hdf5_round_trip_preserves_partition(self, tmp_path): + h5py = pytest.importorskip("h5py") + partition = LayeredPartition(strategy="geometry_coloring", groups=[[[0], [1]], [[2]]]) + ham = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([0.1, 0.2, 0.3]), term_partition=partition) + + path = tmp_path / "ham.h5" + with h5py.File(path, "w") as f: + ham.to_hdf5(f) + with h5py.File(path, "r") as f: + restored = QubitHamiltonian.from_hdf5(f) + + assert isinstance(restored.term_partition, LayeredPartition) + assert restored.term_partition == partition diff --git a/python/tests/test_time_evolution_trotter.py b/python/tests/test_time_evolution_trotter.py index eed5913f8..2afd575e3 100644 --- a/python/tests/test_time_evolution_trotter.py +++ b/python/tests/test_time_evolution_trotter.py @@ -104,6 +104,24 @@ def test_multiple_trotter_steps(self): rtol=float_comparison_relative_tolerance, ) + def test_single_step_no_merge_without_partition(self): + """Test that without term_partition, duplicate terms are not merged.""" + pauli_strings = ["XII", "IXI", "XII"] + coefficients = [1.0, 1.0, 1.0] + + hamiltonian = QubitHamiltonian(pauli_strings=pauli_strings, coefficients=coefficients) + builder = Trotter(num_divisions=1) + unitary = builder.run(hamiltonian, time=1) + + assert isinstance(unitary, TimeEvolutionUnitary) + container = unitary.get_container() + + assert isinstance(container, PauliProductFormulaContainer) + assert container.num_qubits == 3 + assert container.step_reps == 1 + # Without a partition, each Pauli term is its own group — no merging. + assert len(container.step_terms) == 3 + def test_basic_decomposition(self): """Test basic decomposition of a qubit Hamiltonian.""" builder = Trotter() @@ -483,8 +501,11 @@ def test_filters_small_coefficients_higher_order(self): terms = builder._decompose_trotter_step(hamiltonian, time=1.0, atol=1e-12) - assert len(terms) == 1 - assert terms[0].pauli_term == {0: "Z"} + # All terms should be Z only (X filtered out). + # The 4th-order Suzuki schedule produces multiple entries for the single group, + # each with a different time fraction. The total angle should sum to coeff * time. + assert all(t.pauli_term == {0: "Z"} for t in terms) + assert abs(sum(t.angle for t in terms) - 1.0) < 1e-12 def test_trotter_x_z_example_higher_order(self): """Correctness check for fourth-order Trotter decomposition.""" @@ -834,3 +855,23 @@ def test_angle_scaling_with_auto_steps_higher_order(self): atol=float_comparison_absolute_tolerance, rtol=float_comparison_relative_tolerance, ) + + +class TestNoPartitionFallback: + """Tests for Trotter behavior when no term_partition is present.""" + + def test_no_partition_treats_each_term_individually(self): + """Test that without term_partition, each Pauli term is its own group.""" + pauli_strings = ["ZZII", "XIII", "IZZI", "IXII", "IIZZ", "IIXI", "ZIIZ", "IIIX"] + coefficients = [1.0] * 8 + hamiltonian = QubitHamiltonian( + pauli_strings=pauli_strings, + coefficients=coefficients, + ) + builder = Trotter(num_divisions=1, order=1) + t = 1.0 + terms = builder.run(hamiltonian, time=t).get_container().step_terms + + for idx, term in enumerate(terms): + assert term.pauli_term == builder._pauli_label_to_map(pauli_strings[idx]) + assert term.angle == coefficients[idx] * t From 7285298b2e3ec2d1eb297a9cd7af8496335950e2 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Tue, 5 May 2026 11:53:34 -0700 Subject: [PATCH 048/117] Cleanup + PR comments --- .../qdk/chemistry/data/lattice_graph.hpp | 41 ++- cpp/src/qdk/chemistry/data/lattice_graph.cpp | 89 +++++- cpp/tests/test_lattice_graph.cpp | 69 ++++- .../algorithms/time_evolution_builder.rst | 20 ++ docs/source/user/comprehensive/data/index.rst | 24 +- docs/suzuki_recursion_analysis.tex | 270 ------------------ python/src/pybind11/data/lattice_graph.cpp | 33 ++- .../algorithms/energy_estimator/qdk.py | 83 ++++-- .../algorithms/term_grouper/commuting.py | 45 +-- .../time_evolution/builder/trotter.py | 30 +- python/src/qdk_chemistry/data/__init__.py | 1 - .../qdk_chemistry/data/qubit_hamiltonian.py | 5 +- .../src/qdk_chemistry/data/term_partition.py | 21 +- .../qdk_chemistry/utils/model_hamiltonians.py | 33 ++- python/tests/test_encoding_metadata.py | 19 +- python/tests/test_energy_estimator.py | 4 +- python/tests/test_helpers.py | 29 ++ python/tests/test_qubit_hamiltonian.py | 22 +- python/tests/test_term_partition.py | 14 +- python/tests/test_time_evolution_trotter.py | 4 +- 20 files changed, 446 insertions(+), 410 deletions(-) delete mode 100644 docs/suzuki_recursion_analysis.tex diff --git a/cpp/include/qdk/chemistry/data/lattice_graph.hpp b/cpp/include/qdk/chemistry/data/lattice_graph.hpp index d5185914b..1150eed22 100644 --- a/cpp/include/qdk/chemistry/data/lattice_graph.hpp +++ b/cpp/include/qdk/chemistry/data/lattice_graph.hpp @@ -24,8 +24,7 @@ namespace qdk::chemistry::data { * @brief Edge coloring as a map from ordered (i, j) (with i < j) to a * non-negative integer color label. * - * Two edges sharing the same color have disjoint vertex sets and may be - * exponentiated in parallel by Trotter-style decompositions. + * Two edges sharing the same color have disjoint vertex sets. */ using EdgeColoring = std::map, int>; @@ -47,27 +46,56 @@ using EdgeColoring = * @param adj Sparse adjacency matrix of the graph. * @param seed Random seed. Default: 0. * @param trials Number of random-order trials. Default: 1. + * @return Edge coloring with the fewest distinct colours found. */ EdgeColoring greedy_edge_coloring(const Eigen::SparseMatrix& adj, int seed = 0, int trials = 1); /** * @brief Deterministic optimal edge coloring for a chain (path / ring). + * + * @param n Number of sites in the chain. + * @param periodic Whether the chain wraps around (ring topology). + * @return Edge coloring using 2 colours (open or even-periodic) or 3 + * colours (odd-periodic). */ EdgeColoring chain_coloring(std::int64_t n, bool periodic); /** * @brief Deterministic optimal edge coloring for a square lattice. + * + * @param nx Number of sites along x. + * @param ny Number of sites along y. + * @param periodic_x Whether periodic boundary conditions are applied along x. + * @param periodic_y Whether periodic boundary conditions are applied along y. + * @return Edge coloring using 2–4 colours depending on periodicity and parity. */ EdgeColoring square_coloring(std::int64_t nx, std::int64_t ny, bool periodic_x, bool periodic_y); /** * @brief Deterministic optimal 3-coloring for a honeycomb lattice. + * + * @param nx Number of unit cells along x. + * @param ny Number of unit cells along y. + * @param periodic_x Whether periodic boundary conditions are applied along x. + * @param periodic_y Whether periodic boundary conditions are applied along y. + * @return Edge coloring using exactly 3 colours (one per bond type). */ EdgeColoring honeycomb_coloring(std::int64_t nx, std::int64_t ny, bool periodic_x, bool periodic_y); +/** + * @brief Trivial edge coloring where every edge receives a unique color. + * + * Useful as a fallback when no topology-aware coloring is available. + * + * @param adj Sparse adjacency matrix of the graph. + * @return Edge coloring mapping each undirected edge to a distinct colour + * label 0, 1, 2, … in iteration order. + */ +EdgeColoring trivial_edge_coloring(const Eigen::SparseMatrix& adj); + /** * @brief Weighted graph representing a lattice connectivity structure. * @@ -272,11 +300,13 @@ class LatticeGraph : public DataClass { * @param periodic_y If true, apply periodic boundary conditions along y. * Requires ny >= 2. Default: false. * @param t Uniform hopping weight. Default: 1.0. + * @param coloring_seed PRNG seed for greedy edge coloring. Default: 0. * @throws std::invalid_argument If nx or ny is 0. */ static LatticeGraph triangular(std::uint64_t nx, std::uint64_t ny, bool periodic_x = false, - bool periodic_y = false, double t = 1.0); + bool periodic_y = false, double t = 1.0, + int coloring_seed = 0); /** * @brief Create a two-dimensional honeycomb lattice. @@ -360,17 +390,20 @@ class LatticeGraph : public DataClass { * @param periodic_y If true, apply periodic boundary conditions along y. * Requires ny >= 2. Default: false. * @param t Uniform hopping weight. Default: 1.0. + * @param coloring_seed PRNG seed for greedy edge coloring. Default: 0. * @throws std::invalid_argument If nx or ny is 0. */ static LatticeGraph kagome(std::uint64_t nx, std::uint64_t ny, bool periodic_x = false, bool periodic_y = false, - double t = 1.0); + double t = 1.0, int coloring_seed = 0); /** * @brief Edge coloring stored at construction time, if any. * * Factory methods for recognised topologies pre-populate this field. * Returns ``std::nullopt`` for lattices constructed without a coloring. + * + * @return Reference to the optional edge coloring. */ const std::optional& edge_coloring() const; diff --git a/cpp/src/qdk/chemistry/data/lattice_graph.cpp b/cpp/src/qdk/chemistry/data/lattice_graph.cpp index 7960fcd00..251accbab 100644 --- a/cpp/src/qdk/chemistry/data/lattice_graph.cpp +++ b/cpp/src/qdk/chemistry/data/lattice_graph.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -70,7 +71,33 @@ LatticeGraph::LatticeGraph(Eigen::SparseMatrix adjacency, : _num_sites(static_cast(adjacency.rows())), adjacency_(std::move(adjacency)), _is_symmetric(_check_symmetry(adjacency_)), - _edge_coloring(std::move(coloring)) {} + _edge_coloring(std::move(coloring)) { +#ifndef NDEBUG + if (_edge_coloring.has_value()) { + // Verify every edge in the coloring exists in the adjacency matrix. + for (const auto& [edge, color] : *_edge_coloring) { + assert(edge.first < _num_sites && edge.second < _num_sites && + "coloring edge vertex out of range"); + assert(edge.first < edge.second && "coloring edge not canonical (i < j)"); + assert(adjacency_.coeff(static_cast(edge.first), + static_cast(edge.second)) != 0.0 && + "coloring contains edge not in adjacency matrix"); + } + // Verify every upper-triangular edge in adjacency appears in coloring. + for (int k = 0; k < adjacency_.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(adjacency_, k); it; + ++it) { + if (it.row() < it.col() && it.value() != 0.0) { + auto key = std::make_pair(static_cast(it.row()), + static_cast(it.col())); + assert(_edge_coloring->count(key) != 0 && + "adjacency edge missing from coloring"); + } + } + } + } +#endif +} LatticeGraph LatticeGraph::from_dense_matrix( const Eigen::MatrixXd& adjacency_matrix) { @@ -213,7 +240,7 @@ LatticeGraph LatticeGraph::square(std::uint64_t nx, std::uint64_t ny, LatticeGraph LatticeGraph::triangular(std::uint64_t nx, std::uint64_t ny, bool periodic_x, bool periodic_y, - double t) { + double t, int coloring_seed) { if (nx == 0 || ny == 0) { throw std::invalid_argument("triangular: nx and ny must be > 0."); } @@ -266,7 +293,10 @@ LatticeGraph LatticeGraph::triangular(std::uint64_t nx, std::uint64_t ny, Eigen::SparseMatrix adj(N, N); adj.setFromTriplets(triplets.begin(), triplets.end()); adj.makeCompressed(); - return LatticeGraph(std::move(adj), greedy_edge_coloring(adj, 0, 32)); + // No known deterministic coloring for triangular lattices with arbitrary + // periodic boundaries; use greedy with multiple trials instead. + return LatticeGraph(std::move(adj), + greedy_edge_coloring(adj, coloring_seed, 32)); } LatticeGraph LatticeGraph::honeycomb(std::uint64_t nx, std::uint64_t ny, @@ -323,7 +353,8 @@ LatticeGraph LatticeGraph::honeycomb(std::uint64_t nx, std::uint64_t ny, } LatticeGraph LatticeGraph::kagome(std::uint64_t nx, std::uint64_t ny, - bool periodic_x, bool periodic_y, double t) { + bool periodic_x, bool periodic_y, double t, + int coloring_seed) { if (nx == 0 || ny == 0) { throw std::invalid_argument("kagome: nx and ny must be > 0."); } @@ -391,7 +422,8 @@ LatticeGraph LatticeGraph::kagome(std::uint64_t nx, std::uint64_t ny, Eigen::SparseMatrix adj(N, N); adj.setFromTriplets(triplets.begin(), triplets.end()); adj.makeCompressed(); - return LatticeGraph(std::move(adj), greedy_edge_coloring(adj, 0, 32)); + return LatticeGraph(std::move(adj), + greedy_edge_coloring(adj, coloring_seed, 32)); } namespace detail { @@ -400,6 +432,7 @@ namespace detail { std::vector> undirected_edges( const Eigen::SparseMatrix& adj) { std::vector> edges; + edges.reserve(static_cast(adj.nonZeros()) / 2); for (int k = 0; k < adj.outerSize(); ++k) { for (Eigen::SparseMatrix::InnerIterator it(adj, k); it; ++it) { if (it.row() < it.col() && it.value() != 0.0) { @@ -423,6 +456,16 @@ EdgeColoring greedy_edge_coloring(const Eigen::SparseMatrix& adj, return {}; } + // Compute max degree to bound the colour count. + auto num_vertices = static_cast(adj.rows()); + std::vector degree(num_vertices, 0); + for (const auto& [u, v] : edges_in) { + ++degree[u]; + ++degree[v]; + } + int max_degree = *std::max_element(degree.begin(), degree.end()); + int max_colors = 2 * max_degree; // upper bound: 2*Δ - 1 rounded up + EdgeColoring best; int best_count = std::numeric_limits::max(); std::mt19937 rng(static_cast(seed)); @@ -436,21 +479,23 @@ EdgeColoring greedy_edge_coloring(const Eigen::SparseMatrix& adj, } EdgeColoring coloring; - // For each vertex, the set of colors already incident to it. - std::map> vertex_colors; + // For each vertex, a bitset of colours already incident to it. + std::vector> vertex_used( + num_vertices, std::vector(max_colors, false)); int max_color = -1; for (std::size_t pos : order) { const auto& edge = edges_in[pos]; - const auto& used_i = vertex_colors[edge.first]; - const auto& used_j = vertex_colors[edge.second]; + const auto& used_i = vertex_used[edge.first]; + const auto& used_j = vertex_used[edge.second]; int chosen = 0; - while (used_i.count(chosen) != 0 || used_j.count(chosen) != 0) { + while (chosen < max_colors && + (used_i[chosen] || used_j[chosen])) { ++chosen; } coloring[edge] = chosen; - vertex_colors[edge.first].insert(chosen); - vertex_colors[edge.second].insert(chosen); + vertex_used[edge.first][chosen] = true; + vertex_used[edge.second][chosen] = true; if (chosen > max_color) max_color = chosen; } @@ -470,7 +515,7 @@ EdgeColoring chain_coloring(std::int64_t n, bool periodic) { EdgeColoring out; for (std::int64_t i = 0; i + 1 < n; ++i) { out[{static_cast(i), static_cast(i + 1)}] = - static_cast(i % 2); + i % 2; } if (periodic && n > 2) { int wrap_color = (n % 2 == 0) ? 1 : 2; // last edge color is (n-2)%2 @@ -499,7 +544,7 @@ EdgeColoring square_coloring(std::int64_t Nx, std::int64_t Ny, // (4 for x-wrap parity-conflict, 5 for y-wrap parity-conflict). for (std::int64_t y = 0; y < Ny; ++y) { for (std::int64_t x = 0; x + 1 < Nx; ++x) { - put(idx(x, y), idx(x + 1, y), static_cast(x % 2)); + put(idx(x, y), idx(x + 1, y), x % 2); } if (periodic_x && Nx > 2) { int wrap_color = (Nx % 2 == 0) ? 1 : 4; @@ -508,7 +553,7 @@ EdgeColoring square_coloring(std::int64_t Nx, std::int64_t Ny, } for (std::int64_t x = 0; x < Nx; ++x) { for (std::int64_t y = 0; y + 1 < Ny; ++y) { - put(idx(x, y), idx(x, y + 1), 2 + static_cast(y % 2)); + put(idx(x, y), idx(x, y + 1), 2 + y % 2); } if (periodic_y && Ny > 2) { int wrap_color = (Ny % 2 == 0) ? 3 : 5; @@ -567,6 +612,20 @@ EdgeColoring honeycomb_coloring(std::int64_t Nx, std::int64_t Ny, return out; } +EdgeColoring trivial_edge_coloring(const Eigen::SparseMatrix& adj) { + EdgeColoring out; + int color = 0; + for (int k = 0; k < adj.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(adj, k); it; ++it) { + if (it.row() < it.col() && it.value() != 0.0) { + out[{static_cast(it.row()), + static_cast(it.col())}] = color++; + } + } + } + return out; +} + const std::optional& LatticeGraph::edge_coloring() const { return _edge_coloring; } diff --git a/cpp/tests/test_lattice_graph.cpp b/cpp/tests/test_lattice_graph.cpp index a3f46f345..baaeb1dfa 100644 --- a/cpp/tests/test_lattice_graph.cpp +++ b/cpp/tests/test_lattice_graph.cpp @@ -522,10 +522,24 @@ TEST_F(LatticeGraphTest, ColorCount) { std::set chain_open_colors; for (const auto& [e, c] : *chain_open.edge_coloring()) chain_open_colors.insert(c); - EXPECT_GT(chain_open_colors.size(), 0u); + // Open chain uses exactly 2 colors (alternating) + EXPECT_EQ(chain_open_colors.size(), 2u); auto chain_periodic_even = LatticeGraph::chain(6, true); ASSERT_TRUE(chain_periodic_even.edge_coloring().has_value()); + std::set chain_even_colors; + for (const auto& [e, c] : *chain_periodic_even.edge_coloring()) + chain_even_colors.insert(c); + // Even periodic chain uses exactly 2 colors + EXPECT_EQ(chain_even_colors.size(), 2u); + + // Odd periodic chain needs 3 colors + auto chain_periodic_odd = LatticeGraph::chain(5, true); + ASSERT_TRUE(chain_periodic_odd.edge_coloring().has_value()); + std::set chain_odd_colors; + for (const auto& [e, c] : *chain_periodic_odd.edge_coloring()) + chain_odd_colors.insert(c); + EXPECT_EQ(chain_odd_colors.size(), 3u); auto hc = LatticeGraph::honeycomb(3, 3, true, true); ASSERT_TRUE(hc.edge_coloring().has_value()); @@ -563,3 +577,56 @@ TEST_F(LatticeGraphTest, EdgeColoringIsImmutable) { const auto& second = sq.edge_coloring(); EXPECT_EQ(&first, &second); } + +TEST_F(LatticeGraphTest, TrivialEdgeColoring) { + // Build a small graph and check trivial coloring assigns unique colors. + auto chain = LatticeGraph::chain(5); + const auto& adj = chain.sparse_adjacency_matrix(); + auto coloring = trivial_edge_coloring(adj); + + // 4 edges in a 5-site open chain + EXPECT_EQ(coloring.size(), 4u); + + // Each edge should have a distinct color 0..3 + std::set colors; + for (const auto& [edge, c] : coloring) { + colors.insert(c); + } + EXPECT_EQ(colors.size(), 4u); + EXPECT_EQ(*colors.begin(), 0); + EXPECT_EQ(*colors.rbegin(), 3); + + // Also valid as an edge coloring (trivially, since all colors differ) + check_valid_edge_coloring(coloring); +} + +TEST_F(LatticeGraphTest, TrivialEdgeColoringEmpty) { + // Single-site graph has no edges → empty coloring + auto single = LatticeGraph::chain(1); + auto coloring = trivial_edge_coloring(single.sparse_adjacency_matrix()); + EXPECT_TRUE(coloring.empty()); +} + +TEST_F(LatticeGraphTest, ColoringSeedDeterministic) { + // Same seed → same coloring. + auto tri_a = LatticeGraph::triangular(3, 3, true, true, 1.0, 42); + auto tri_b = LatticeGraph::triangular(3, 3, true, true, 1.0, 42); + ASSERT_TRUE(tri_a.edge_coloring().has_value()); + ASSERT_TRUE(tri_b.edge_coloring().has_value()); + EXPECT_EQ(*tri_a.edge_coloring(), *tri_b.edge_coloring()); + + // Different seed may produce a different coloring (or same, but at + // least both must be valid). + auto tri_c = LatticeGraph::triangular(3, 3, true, true, 1.0, 99); + ASSERT_TRUE(tri_c.edge_coloring().has_value()); + check_valid_edge_coloring(*tri_c.edge_coloring()); +} + +TEST_F(LatticeGraphTest, KagomeColoringSeed) { + auto kg_a = LatticeGraph::kagome(2, 2, true, true, 1.0, 7); + auto kg_b = LatticeGraph::kagome(2, 2, true, true, 1.0, 7); + ASSERT_TRUE(kg_a.edge_coloring().has_value()); + ASSERT_TRUE(kg_b.edge_coloring().has_value()); + EXPECT_EQ(*kg_a.edge_coloring(), *kg_b.edge_coloring()); + check_valid_edge_coloring(*kg_a.edge_coloring()); +} diff --git a/docs/source/user/comprehensive/algorithms/time_evolution_builder.rst b/docs/source/user/comprehensive/algorithms/time_evolution_builder.rst index 0fb76d251..517f5a704 100644 --- a/docs/source/user/comprehensive/algorithms/time_evolution_builder.rst +++ b/docs/source/user/comprehensive/algorithms/time_evolution_builder.rst @@ -160,6 +160,26 @@ This typically reduces the number of distinct exponentials per Trotter step and When ``term_partition is None`` each Pauli term is exponentiated as its own group. Pre-populate the partition using the :ref:`term_grouper algorithm ` or one of the :ref:`spin model Hamiltonian builders ` to enable group-aware scheduling. +For lattice models, the edge coloring stored on :class:`~qdk_chemistry.data.LatticeGraph` feeds directly into the partition: edges of the same color have disjoint qubit supports and form a single parallelisable layer. +The :ref:`model Hamiltonian builders ` consume this coloring automatically, so no manual geometry handling is required. + +Example:: + + from qdk_chemistry.data import LatticeGraph + from qdk_chemistry.utils.model_hamiltonians import create_ising_hamiltonian + from qdk_chemistry.algorithms import registry + + graph = LatticeGraph.square(4, 4, periodic_x=True, periodic_y=True) + hamiltonian = create_ising_hamiltonian(graph, j=1.0, h=0.5) + + # The Hamiltonian already carries a LayeredPartition from the edge coloring. + print(hamiltonian.term_partition) + + # The Trotter builder consumes it automatically. + trotter = registry.create("time_evolution_builder", "trotter") + trotter.settings().set("order", 2) + evolution = trotter.run(hamiltonian, time=1.0) + Related classes --------------- diff --git a/docs/source/user/comprehensive/data/index.rst b/docs/source/user/comprehensive/data/index.rst index f46ae07a4..94270438f 100644 --- a/docs/source/user/comprehensive/data/index.rst +++ b/docs/source/user/comprehensive/data/index.rst @@ -77,10 +77,30 @@ QubitHamiltonian and term partitions ------------------------------------ A :class:`~qdk_chemistry.data.QubitHamiltonian` carries an optional :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` field describing how its Pauli terms are organised into algorithm-relevant subsets. -The partition is index-based — it stores indices into :attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings` rather than nested ``QubitHamiltonian`` objects — so it serialises cheaply alongside the Hamiltonian. +The partition is index-based — it stores indices into :attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings` — so it serialises cheaply alongside the Hamiltonian. -A :class:`~qdk_chemistry.data.TermPartition` can represent single-level groupings (see :class:`~qdk_chemistry.data.FlatPartition`) or hierarchical group-and-layer structures (see :class:`~qdk_chemistry.data.LayeredPartition`). The partition is *optional* metadata — ``term_partition is None`` means the partition has not been computed for this Hamiltonian. Transformations that change term ordering or qubit support (for example :meth:`~qdk_chemistry.data.QubitHamiltonian.to_interleaved`) reset the partition to ``None`` on the new instance. Algorithms that consume a partition treat its presence as an explicit signal to exploit it — for example, the :doc:`Trotter time-evolution builder <../algorithms/time_evolution_builder>` reads ``term_partition`` and uses it for schedule-level Suzuki recursion and reduction. + +FlatPartition +~~~~~~~~~~~~~ + +:class:`~qdk_chemistry.data.FlatPartition` stores a single-level grouping: each group is a tuple of term indices. +It is suitable for algorithms that only need to know which terms belong together, such as qubit-wise commuting measurement grouping in :class:`~qdk_chemistry.algorithms.QdkEnergyEstimator`. + +The ``groups`` field is a tuple of tuples: ``((idx0, idx1, ...), (idx2, ...), ...)``. +Each inner tuple lists the indices of terms in :attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings` that belong to that group. + +LayeredPartition +~~~~~~~~~~~~~~~~ + +:class:`~qdk_chemistry.data.LayeredPartition` stores a two-level hierarchy: groups contain parallelisable layers, and each layer contains term indices. +It is suitable for Trotter-style decompositions where the outer level controls Strang/Suzuki splitting order and each inner layer groups operators with disjoint qubit supports that can be applied simultaneously. + +The ``groups`` field is a nested tuple: ``(((idx0, idx1), (idx2,)), ...)``. +The outer level is groups, the middle level is layers within a group, and the innermost level is term indices. + +Both classes carry a ``strategy`` label (e.g. ``"geometry_coloring"``, ``"qubit_wise_commuting"``) identifying how the partition was produced. +They serialise as part of :class:`~qdk_chemistry.data.QubitHamiltonian` in both JSON and HDF5 formats. diff --git a/docs/suzuki_recursion_analysis.tex b/docs/suzuki_recursion_analysis.tex deleted file mode 100644 index 31611703f..000000000 --- a/docs/suzuki_recursion_analysis.tex +++ /dev/null @@ -1,270 +0,0 @@ -\documentclass[11pt]{article} -\usepackage[margin=1in]{geometry} -\usepackage{amsmath,amssymb,amsthm} -\usepackage{hyperref} -\usepackage{booktabs} -\usepackage{listings} -\usepackage{xcolor} - -\lstset{ - language=Python, - basicstyle=\ttfamily\small, - keywordstyle=\color{blue}, - commentstyle=\color{gray}, - stringstyle=\color{red!70!black}, - showstringspaces=false, - frame=single, - breaklines=true, -} - -\newtheorem{theorem}{Theorem} -\newtheorem{lemma}[theorem]{Lemma} - -\title{Analysis of the Suzuki Recursion Formula\\in the QDK Trotter Implementation} -\author{QDK-Chemistry} -\date{\today} - -\begin{document} -\maketitle - -\section{Background: Suzuki's Fractal Decomposition} - -Consider a Hamiltonian $H = \sum_{j=1}^{L} H_j$. The goal of Trotter--Suzuki -product formulas is to approximate the time-evolution operator $e^{-iHt}$ by a -product of exponentials of the individual terms $H_j$. - -\subsection{Second-order formula (Strang splitting)} - -The second-order symmetric (Strang) splitting is: -\begin{equation}\label{eq:S2} - S_2(t) = e^{-iH_1 t/2}\, e^{-iH_2 t/2}\, \cdots\, e^{-iH_{L-1} t/2}\, - e^{-iH_L t}\, - e^{-iH_{L-1} t/2}\, \cdots\, e^{-iH_1 t/2}. -\end{equation} -This satisfies -\begin{equation} - \bigl\lVert e^{-iHt} - S_2(t) \bigr\rVert = \mathcal{O}(t^3). -\end{equation} - -\subsection{Higher-order formulas via recursion} - -Suzuki~\cite{Suzuki1990,Suzuki1991} showed that higher-order approximations can -be constructed recursively. Given an approximation $S_{2k}(t)$ of order $2k$ -(i.e., error $\mathcal{O}(t^{2k+1})$), one obtains an approximation of order -$2k+2$ via: -\begin{equation}\label{eq:recursion} - S_{2k+2}(t) - = \bigl[S_{2k}(p_k\, t)\bigr]^2\; - S_{2k}\!\bigl((1 - 4p_k)\, t\bigr)\; - \bigl[S_{2k}(p_k\, t)\bigr]^2, -\end{equation} -where the parameter $p_k$ is chosen to cancel the leading-order error: -\begin{equation}\label{eq:pk} - \boxed{p_k = \frac{1}{4 - 4^{1/(2k+1)}}}. -\end{equation} - -\begin{theorem}[Suzuki 1991]\label{thm:suzuki} -If $S_{2k}(t) = e^{-iHt} + \mathcal{O}(t^{2k+1})$, then -$S_{2k+2}(t)$ defined by~\eqref{eq:recursion} with $p_k$ from~\eqref{eq:pk} -satisfies $S_{2k+2}(t) = e^{-iHt} + \mathcal{O}(t^{2k+3})$. -\end{theorem} - -\section{Constructing the Fourth-Order Formula} - -To build the fourth-order formula $S_4$ from the second-order formula $S_2$, we -apply one level of Suzuki recursion with $k = 1$ (promoting order $2 \cdot 1 = 2$ -to order $2 \cdot 2 = 4$): -\begin{equation}\label{eq:S4} - S_4(t) = \bigl[S_2(p_1\, t)\bigr]^2\; - S_2\!\bigl((1-4p_1)\, t\bigr)\; - \bigl[S_2(p_1\, t)\bigr]^2, -\end{equation} -with -\begin{equation}\label{eq:p1} - p_1 = \frac{1}{4 - 4^{1/(2 \cdot 1 + 1)}} = \frac{1}{4 - 4^{1/3}}. -\end{equation} - -\noindent -Numerically, $p_1 = (4 - 4^{1/3})^{-1} \approx 0.41449$. - -\section{The Bug in the QDK Implementation} - -The QDK's \texttt{suzuki\_recursion} function in -\texttt{source/pip/qsharp/applications/magnets/trotter/trotter.py} -implements the recursion as follows: - -\begin{lstlisting} -def suzuki_recursion(trotter: TrotterStep) -> TrotterStep: - # ... - p = 1 / (4 - 4 ** (1 / (2 * trotter.order + 1))) - # ... -\end{lstlisting} - -When called on a Strang splitting (\texttt{trotter.order = 2}), this computes: -\begin{equation}\label{eq:qdk_p} - p_{\text{QDK}} = \frac{1}{4 - 4^{1/(2 \cdot 2 + 1)}} = \frac{1}{4 - 4^{1/5}} - \approx 0.37307. -\end{equation} - -\subsection{What went wrong} - -The \texttt{trotter.order} attribute equals $2$ (the order of the input -formula). In Suzuki's notation, the input is $S_{2k}$ with $2k = 2$, so $k = -1$. The correct parameter is: -\begin{equation} - p_k = p_1 = \frac{1}{4 - 4^{1/(2 \cdot 1 + 1)}} = \frac{1}{4 - 4^{1/3}}. -\end{equation} - -The code substitutes \texttt{trotter.order} (which is $2k = 2$) directly in -place of $k$: -\begin{equation} - p_{\text{QDK}} = \frac{1}{4 - 4^{1/(2 \cdot (2k) + 1)}} = \frac{1}{4 - 4^{1/(4k+1)}}. -\end{equation} - -For $k=1$ this gives $p_{\text{QDK}} = 1/(4-4^{1/5})$ instead of the correct -$p_1 = 1/(4-4^{1/3})$. - -\subsection{Correct fix} - -The fix is to use $k = \texttt{trotter.order} / 2$: -\begin{lstlisting} -k = trotter.order // 2 # order=2 -> k=1 -p = 1 / (4 - 4 ** (1 / (2 * k + 1))) -\end{lstlisting} - -\noindent -Or equivalently, use \texttt{trotter.order - 1} as the exponent denominator: -\begin{lstlisting} -p = 1 / (4 - 4 ** (1 / (trotter.order + 1))) -\end{lstlisting} - -\noindent -since $2k + 1 = \texttt{trotter.order} + 1$ when $\texttt{trotter.order} = 2k$. - -\section{Impact: Loss of Convergence Order} - -With the wrong $p$, the leading-order error term in $S_{2k}$ is \emph{not} -cancelled by the recursion. The resulting formula still converges, but only at -the original order $2k$ rather than the intended $2k+2$. - -\subsection{Numerical verification} - -We verify this with a 2-qubit Ising Hamiltonian -$H = Z_0 Z_1 + 0.5\,(X_0 + X_1)$, computing $e^{-iHt}$ with $t = 1$ using -$N$ Trotter steps. The error is -$\varepsilon(N) = \lVert U_{\text{Trotter}} - U_{\text{exact}} \rVert$. - -\begin{table}[h] -\centering -\begin{tabular}{rcccc} -\toprule -$N$ & $\varepsilon_{\text{standard}}$ & order & $\varepsilon_{\text{QDK}}$ & order \\ -\midrule - 4 & $5.95 \times 10^{-5}$ & --- & $2.12 \times 10^{-3}$ & --- \\ - 8 & $3.70 \times 10^{-6}$ & 4.01 & $5.34 \times 10^{-4}$ & 1.99 \\ -16 & $2.31 \times 10^{-7}$ & 4.00 & $1.34 \times 10^{-4}$ & 2.00 \\ -32 & $1.44 \times 10^{-8}$ & 4.00 & $3.34 \times 10^{-5}$ & 2.00 \\ -64 & $9.02 \times 10^{-10}$& 4.00 & $8.35 \times 10^{-6}$ & 2.00 \\ -\bottomrule -\end{tabular} -\caption{Convergence order $= \log_2(\varepsilon_N / \varepsilon_{2N})$. The -standard formula achieves 4th-order convergence; the QDK formula achieves only -2nd-order.} -\label{tab:convergence} -\end{table} - -\noindent -Table~\ref{tab:convergence} confirms that the standard formula converges as -$\mathcal{O}(N^{-4})$ (4th~order), while the QDK formula converges as -$\mathcal{O}(N^{-2})$ (2nd~order). The Suzuki recursion with the wrong $p$ -does not achieve the intended order promotion. - -\section{Verification of the qdk-chemistry Implementation} - -We verify that the qdk-chemistry Trotter implementation achieves the correct -convergence order by running the full pipeline end-to-end: -\texttt{create\_ising\_hamiltonian} $\to$ \texttt{Trotter.run()} $\to$ -\texttt{to\_circuit()} $\to$ Cirq unitary extraction $\to$ comparison with -$e^{-iHt}$. - -\subsection{2-qubit Ising chain} - -$H = Z_0 Z_1 + 0.5\,X_0 + 0.5\,X_1$, $t = 1$. - -\begin{table}[h] -\centering -\begin{tabular}{rcccc} -\toprule -$N$ & $\varepsilon$ (order~2) & conv.\ order & $\varepsilon$ (order~4) & conv.\ order \\ -\midrule - 2 & $1.01 \times 10^{-1}$ & --- & $9.74 \times 10^{-4}$ & --- \\ - 4 & $2.44 \times 10^{-2}$ & 2.04 & $5.95 \times 10^{-5}$ & 4.03 \\ - 8 & $6.06 \times 10^{-3}$ & 2.01 & $3.70 \times 10^{-6}$ & 4.01 \\ - 16 & $1.51 \times 10^{-3}$ & 2.00 & $2.31 \times 10^{-7}$ & 4.00 \\ - 32 & $3.78 \times 10^{-4}$ & 2.00 & $1.44 \times 10^{-8}$ & 4.00 \\ - 64 & $9.45 \times 10^{-5}$ & 2.00 & $9.02 \times 10^{-10}$ & 4.00 \\ -128 & $2.36 \times 10^{-5}$ & 2.00 & $5.64 \times 10^{-11}$ & 4.00 \\ -\bottomrule -\end{tabular} -\caption{qdk-chemistry convergence for the 2-qubit Ising chain. -Both 2nd- and 4th-order formulas achieve their expected convergence rates.} -\end{table} - -\subsection{4-qubit Ising lattice ($2\times 2$)} - -$H = \sum_{\langle i,j\rangle} Z_i Z_j + 0.5 \sum_i X_i$ on a $2 \times 2$ -square lattice (8 Pauli terms), $t = 1$. - -\begin{table}[h] -\centering -\begin{tabular}{rcccc} -\toprule -$N$ & $\varepsilon$ (order~2) & conv.\ order & $\varepsilon$ (order~4) & conv.\ order \\ -\midrule - 2 & $4.38 \times 10^{-1}$ & --- & $9.23 \times 10^{-3}$ & --- \\ - 4 & $1.03 \times 10^{-1}$ & 2.08 & $5.65 \times 10^{-4}$ & 4.03 \\ - 8 & $2.55 \times 10^{-2}$ & 2.02 & $3.54 \times 10^{-5}$ & 4.00 \\ -16 & $6.35 \times 10^{-3}$ & 2.00 & $2.22 \times 10^{-6}$ & 4.00 \\ -32 & $1.59 \times 10^{-3}$ & 2.00 & $1.39 \times 10^{-7}$ & 4.00 \\ -64 & $3.97 \times 10^{-4}$ & 2.00 & $8.67 \times 10^{-9}$ & 4.00 \\ -\bottomrule -\end{tabular} -\caption{qdk-chemistry convergence for the $2\times 2$ Ising lattice -(with \texttt{optimize\_term\_ordering=True}).} -\end{table} - -\noindent -All tests confirm that the qdk-chemistry implementation achieves the correct -convergence order for both 2nd- and 4th-order Trotter formulas. - -\section{Summary} - -\begin{itemize} - \item The Suzuki recursion \eqref{eq:recursion} promotes order $2k$ to - $2k+2$ using $p_k = 1/(4 - 4^{1/(2k+1)})$, where $k$ indexes the - \emph{input} formula order as $2k$. - \item The QDK code uses \texttt{trotter.order} (which equals $2k$, not $k$) - directly in the formula, computing - $p = 1/(4 - 4^{1/(2 \cdot 2k + 1)}) = 1/(4 - 4^{1/(4k+1)})$. - \item For the $S_2 \to S_4$ promotion ($k=1$), this gives - $1/(4-4^{1/5}) \approx 0.373$ instead of the correct - $1/(4-4^{1/3}) \approx 0.414$. - \item Numerical experiments confirm the resulting formula is only 2nd-order - accurate, not 4th-order as intended. -\end{itemize} - -\begin{thebibliography}{9} -\bibitem{Suzuki1990} - M.~Suzuki, - ``Fractal decomposition of exponential operators with applications to - many-body theories and Monte Carlo simulations,'' - \textit{Phys.\ Lett.\ A}, vol.~146, pp.~319--323, 1990. - -\bibitem{Suzuki1991} - M.~Suzuki, - ``General theory of fractal path integrals with applications to many-body - theories and statistical physics,'' - \textit{J.\ Math.\ Phys.}, vol.~32, pp.~400--407, 1991. -\end{thebibliography} - -\end{document} diff --git a/python/src/pybind11/data/lattice_graph.cpp b/python/src/pybind11/data/lattice_graph.cpp index 746376cd4..d955125e3 100644 --- a/python/src/pybind11/data/lattice_graph.cpp +++ b/python/src/pybind11/data/lattice_graph.cpp @@ -59,6 +59,31 @@ void bind_lattice_graph(pybind11::module &m) { using qdk::chemistry::python::utils::bind_getter_as_property; + // Module-level free function: trivial_edge_coloring + m.def( + "trivial_edge_coloring", + [](const Eigen::SparseMatrix &adj) -> py::dict { + auto coloring = trivial_edge_coloring(adj); + py::dict out; + for (const auto &[edge, color] : coloring) { + out[py::make_tuple(edge.first, edge.second)] = color; + } + return out; + }, + R"( +Trivial edge coloring where every edge receives a unique color. + +Useful as a fallback when no topology-aware coloring is available. + +Args: + adj (scipy.sparse matrix): Sparse adjacency matrix of the graph. + +Returns: + dict[tuple[int, int], int]: Mapping of canonical edges to distinct + color labels 0, 1, 2, ... in iteration order. +)", + py::arg("adj")); + py::class_ lattice_graph( m, "LatticeGraph", R"( Lattice graph defining the connectivity and geometry of a model Hamiltonian. @@ -338,6 +363,7 @@ With periodic boundary conditions (using the 3x3 example above): periodic_y (bool, optional): If True, apply periodic boundary conditions along y. Requires ny >= 2. Defaults to False. t (float, optional): Hopping weight for all edges. Defaults to 1.0. + coloring_seed (int, optional): PRNG seed for greedy edge coloring. Defaults to 0. Returns: LatticeGraph: Triangular lattice with nx * ny sites. @@ -347,7 +373,8 @@ With periodic boundary conditions (using the 3x3 example above): )", py::arg("nx"), py::arg("ny"), py::arg("periodic_x") = false, - py::arg("periodic_y") = false, py::arg("t") = 1.0); + py::arg("periodic_y") = false, py::arg("t") = 1.0, + py::arg("coloring_seed") = 0); lattice_graph.def_static("honeycomb", &LatticeGraph::honeycomb, R"( Create a two-dimensional honeycomb lattice. @@ -432,6 +459,7 @@ With periodic boundary conditions (using the 3x2 example above): periodic_y (bool, optional): If True, apply periodic boundary conditions along y. Requires ny >= 2. Defaults to False. t (float, optional): Hopping weight for all edges. Defaults to 1.0. + coloring_seed (int, optional): PRNG seed for greedy edge coloring. Defaults to 0. Returns: LatticeGraph: Kagome lattice with 3 * nx * ny sites. @@ -441,7 +469,8 @@ With periodic boundary conditions (using the 3x2 example above): )", py::arg("nx"), py::arg("ny"), py::arg("periodic_x") = false, - py::arg("periodic_y") = false, py::arg("t") = 1.0); + py::arg("periodic_y") = false, py::arg("t") = 1.0, + py::arg("coloring_seed") = 0); lattice_graph.def("__repr__", [](const LatticeGraph &self) { return " list[QubitHamiltonian]: + """Resolve the Hamiltonian into qubit-wise commuting measurement groups. + + If ``qubit_hamiltonian`` carries a + :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` whose + strategy is ``"qubit_wise_commuting"``, the partition is consumed + directly. Otherwise each Pauli term is measured individually. + + Args: + qubit_hamiltonian: The Hamiltonian to partition for measurement. + + Returns: + A list of ``QubitHamiltonian`` objects, one per measurement group. + + Raises: + ValueError: If a ``term_partition`` is present but uses a strategy + incompatible with qubit-wise commuting measurement (only + ``"qubit_wise_commuting"`` is currently supported). + + """ + partition = qubit_hamiltonian.term_partition + if partition is not None: + from qdk_chemistry.data.term_partition import FlatPartition # noqa: PLC0415 + + if partition.strategy != "qubit_wise_commuting": + raise ValueError( + f"QdkEnergyEstimator requires qubit-wise commuting measurement groups, " + f"but the Hamiltonian carries a term_partition with " + f"strategy={partition.strategy!r}. Either pre-group with the " + f"'qubit_wise_commuting' term_grouper, or remove the partition " + f"to measure each term individually." + ) + if not isinstance(partition, FlatPartition): + raise TypeError( + f"QdkEnergyEstimator expects a FlatPartition for measurement grouping, " + f"got {type(partition).__name__}." + ) + Logger.info( + f"EnergyEstimator: consuming term_partition " + f"(strategy={partition.strategy!r}, num_groups={partition.num_groups})." + ) + return [ + QubitHamiltonian( + pauli_strings=[qubit_hamiltonian.pauli_strings[i] for i in group], + coefficients=np.asarray([qubit_hamiltonian.coefficients[i] for i in group]), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + ) + for group in partition.groups + ] + + Logger.info("EnergyEstimator: no compatible term_partition; measuring each term individually.") + return [ + QubitHamiltonian( + pauli_strings=[label], + coefficients=np.asarray([coeff]), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + ) + for label, coeff in zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=True) + ] + @staticmethod def _create_measurement_circuits(circuit: Circuit, grouped_hamiltonians: list[QubitHamiltonian]) -> list[Circuit]: """Create measurement circuits for each QubitHamiltonian. diff --git a/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py b/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py index cb8c469b6..d409d08d7 100644 --- a/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py +++ b/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py @@ -14,16 +14,15 @@ __all__ = ["FullCommutingTermGrouper", "QubitWiseCommutingTermGrouper"] -def _greedy_color_partition( +def _greedy_commutation_grouping( pauli_strings: list[str], commutes: Callable[[str, str], bool], ) -> tuple[tuple[int, ...], ...]: """Partition Pauli labels into commuting groups via greedy graph coloring. - Conceptually builds the non-commutation graph (an edge between every pair - of labels that do *not* commute) and colors it greedily, assigning each - label the lowest color not used by any non-commuting neighbour. Labels - sharing a color form a commuting group. + Builds a non-commutation adjacency structure and greedily assigns each + label the lowest-index group in which it commutes with all existing + members. Args: pauli_strings: Pauli labels to partition. @@ -33,20 +32,34 @@ def _greedy_color_partition( Tuple of groups; each group is a tuple of indices into ``pauli_strings``. """ + n = len(pauli_strings) + if n == 0: + return () + + # Pre-compute which pairs do NOT commute for O(1) lookup during grouping. + # Store as a list of sets: non_commuting[i] = {j, ...} for j < i. + non_commuting: list[set[int]] = [set() for _ in range(n)] + for i in range(1, n): + for j in range(i): + if not commutes(pauli_strings[i], pauli_strings[j]): + non_commuting[i].add(j) + non_commuting[j].add(i) + groups: list[list[int]] = [] - group_labels: list[list[str]] = [] + group_members: list[set[int]] = [] - for i, pauli_str in enumerate(pauli_strings): + for i in range(n): placed = False - for group, labels in zip(groups, group_labels, strict=True): - if all(commutes(pauli_str, existing) for existing in labels): - group.append(i) - labels.append(pauli_str) + conflicts_i = non_commuting[i] + for g_idx, members in enumerate(group_members): + if not conflicts_i & members: + groups[g_idx].append(i) + members.add(i) placed = True break if not placed: groups.append([i]) - group_labels.append([pauli_str]) + group_members.append({i}) return tuple(tuple(g) for g in groups) @@ -73,10 +86,10 @@ def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: qubit_hamiltonian: Hamiltonian to partition. Returns: - QubitHamiltonian: New instance carrying a :class:`~qdk_chemistry.data.FlatPartition` with strategy ``"commuting"``. + QubitHamiltonian: New instance with a ``FlatPartition`` (strategy ``"commuting"``). """ - groups = _greedy_color_partition(qubit_hamiltonian.pauli_strings, do_pauli_labels_commute) + groups = _greedy_commutation_grouping(qubit_hamiltonian.pauli_strings, do_pauli_labels_commute) partition = FlatPartition(strategy="commuting", groups=groups) return QubitHamiltonian( pauli_strings=list(qubit_hamiltonian.pauli_strings), @@ -109,10 +122,10 @@ def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: qubit_hamiltonian: Hamiltonian to partition. Returns: - QubitHamiltonian: New instance carrying a :class:`~qdk_chemistry.data.FlatPartition` with strategy ``"qubit_wise_commuting"``. + QubitHamiltonian: New instance with a ``FlatPartition`` (strategy ``"qubit_wise_commuting"``). """ - groups = _greedy_color_partition(qubit_hamiltonian.pauli_strings, do_pauli_labels_qw_commute) + groups = _greedy_commutation_grouping(qubit_hamiltonian.pauli_strings, do_pauli_labels_qw_commute) partition = FlatPartition(strategy="qubit_wise_commuting", groups=groups) return QubitHamiltonian( pauli_strings=list(qubit_hamiltonian.pauli_strings), diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index e394b5874..dc13d72f6 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -133,11 +133,11 @@ def __init__( is exponentiated as its own group. Args: - order: The order of the Trotter decomposition (currently only first order is supported). Defaults to 1. - target_accuracy: Target accuracy for automatic step computation. Must be positive to enable automatic computation. Use 0.0 (default) to disable. - num_divisions: Explicit number of divisions within a Trotter step. When both *num_divisions* and *target_accuracy* are given the larger value is used. Use 0 (default) for automatic determination. - error_bound: Strategy for computing the Trotter error bound when *target_accuracy* is set. Either ``"commutator"`` (default, tighter) or ``"naive"``. - weight_threshold: Absolute threshold for filtering small Hamiltonian coefficients. Defaults to 1e-12. + order: Trotter decomposition order (1, 2, or any positive even integer). Defaults to 1. + target_accuracy: Target accuracy for auto step computation. Use 0.0 (default) to disable. + num_divisions: Divisions per Trotter step. Max of this and auto value is used. Defaults to 0. + error_bound: Error bound strategy: ``"commutator"`` (default) or ``"naive"``. + weight_threshold: Threshold for filtering small coefficients. Defaults to 1e-12. """ super().__init__() @@ -194,9 +194,7 @@ def _trotter(self, qubit_hamiltonian: QubitHamiltonian, time: float) -> TimeEvol delta = time / num_divisions - terms = self._decompose_trotter_step( - qubit_hamiltonian, time=delta, atol=weight_threshold - ) + terms = self._decompose_trotter_step(qubit_hamiltonian, time=delta, atol=weight_threshold) num_qubits = qubit_hamiltonian.num_qubits @@ -281,6 +279,10 @@ def _decompose_trotter_step( order = self._settings.get("order") grouped_hamiltonians = self._group_terms(qubit_hamiltonian) + if not grouped_hamiltonians: + Logger.warn("Term partition produced no groups; returning empty term list.") + return terms + if order == 1: for group in grouped_hamiltonians: for subgroup in group: @@ -374,16 +376,14 @@ def _group_terms( ) return self._groups_from_partition(qubit_hamiltonian, partition) - Logger.info( - "Trotter: no term_partition present; " - "treating each Pauli term as its own group." - ) + Logger.info("Trotter: no term_partition present; treating each Pauli term as its own group.") return [ [ QubitHamiltonian( pauli_strings=[label], coefficients=[coeff], encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, ) ] for label, coeff in zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=True) @@ -413,12 +413,14 @@ def _groups_from_partition( labels = qubit_hamiltonian.pauli_strings coeffs = qubit_hamiltonian.coefficients encoding = qubit_hamiltonian.encoding + fmo = qubit_hamiltonian.fermion_mode_order def _make(indices: tuple[int, ...]) -> QubitHamiltonian: return QubitHamiltonian( pauli_strings=[labels[i] for i in indices], coefficients=np.asarray([coeffs[i] for i in indices]), encoding=encoding, + fermion_mode_order=fmo, ) # Normalise to (group → tuple of layers of indices) @@ -454,8 +456,8 @@ def _exponentiate_commuting( Each term :math:`P_j` with coefficient :math:`c_j` is converted to the rotation :math:`e^{-i\,c_j\,t\,P_j}`. Because all terms in the - group commute and :meth:`_group_terms` ensures they have disjoint - qubit supports, the rotations can be applied in any order. + group commute, the product of rotations equals the exponential of + the sum regardless of ordering. Args: group: The group of commuting Hamiltonian terms to exponentiate. diff --git a/python/src/qdk_chemistry/data/__init__.py b/python/src/qdk_chemistry/data/__init__.py index 8594ce32b..dbc2254dc 100644 --- a/python/src/qdk_chemistry/data/__init__.py +++ b/python/src/qdk_chemistry/data/__init__.py @@ -190,4 +190,3 @@ "get_current_ciaaw_version", "validate_encoding_compatibility", ] - diff --git a/python/src/qdk_chemistry/data/qubit_hamiltonian.py b/python/src/qdk_chemistry/data/qubit_hamiltonian.py index 02b6e4913..df7c9482d 100644 --- a/python/src/qdk_chemistry/data/qubit_hamiltonian.py +++ b/python/src/qdk_chemistry/data/qubit_hamiltonian.py @@ -12,6 +12,7 @@ from __future__ import annotations +import json import re from typing import TYPE_CHECKING, Any @@ -330,8 +331,6 @@ def to_hdf5(self, group: h5py.Group) -> None: if self.fermion_mode_order is not None: group.attrs["fermion_mode_order"] = str(self.fermion_mode_order) if self.term_partition is not None: - import json # noqa: PLC0415 - group.attrs["term_partition"] = json.dumps(self.term_partition.to_json()) @classmethod @@ -392,8 +391,6 @@ def from_hdf5(cls, group: h5py.Group) -> QubitHamiltonian: fermion_mode_order = fermion_mode_order.decode("utf-8") partition_attr = group.attrs.get("term_partition") if partition_attr is not None: - import json # noqa: PLC0415 - if isinstance(partition_attr, bytes): partition_attr = partition_attr.decode("utf-8") term_partition = TermPartition.from_json(json.loads(partition_attr)) diff --git a/python/src/qdk_chemistry/data/term_partition.py b/python/src/qdk_chemistry/data/term_partition.py index eb655c374..ca9f61d4f 100644 --- a/python/src/qdk_chemistry/data/term_partition.py +++ b/python/src/qdk_chemistry/data/term_partition.py @@ -7,9 +7,8 @@ hierarchy). The partition stores **indices** into -:attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings`, not nested -``QubitHamiltonian`` objects, so that it serialises trivially and remains -small. +:attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings` so that it +serialises trivially and remains small. Lifecycle --------- @@ -32,6 +31,7 @@ from __future__ import annotations +import json as _json from typing import Any from qdk_chemistry.data.base import DataClass @@ -82,8 +82,6 @@ def to_json(self) -> dict[str, Any]: def to_hdf5(self, group) -> None: """Save this partition to an HDF5 group.""" - import json as _json # noqa: PLC0415 - group.attrs["term_partition"] = _json.dumps(self.to_json()) @staticmethod @@ -113,17 +111,20 @@ def from_json(data: dict[str, Any]) -> TermPartition: @classmethod def from_hdf5(cls, group) -> TermPartition: """Load a :class:`TermPartition` from an HDF5 group.""" - import json as _json # noqa: PLC0415 - - data = _json.loads(group.attrs["term_partition"]) + raw = group.attrs["term_partition"] + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + data = _json.loads(raw) return cls.from_json(data) def __eq__(self, other: object) -> bool: + """Check equality by type and strategy.""" if not isinstance(other, TermPartition): return NotImplemented return type(self) is type(other) and self.strategy == other.strategy def __hash__(self) -> int: + """Return hash.""" return hash((type(self).__name__, self.strategy)) @@ -176,11 +177,13 @@ def to_json(self) -> dict[str, Any]: } def __eq__(self, other: object) -> bool: + """Check equality by strategy and groups.""" if not isinstance(other, FlatPartition): return NotImplemented return self.strategy == other.strategy and self.groups == other.groups def __hash__(self) -> int: + """Return hash.""" return hash(("FlatPartition", self.strategy, self.groups)) @@ -241,9 +244,11 @@ def to_json(self) -> dict[str, Any]: } def __eq__(self, other: object) -> bool: + """Check equality by strategy and groups.""" if not isinstance(other, LayeredPartition): return NotImplemented return self.strategy == other.strategy and self.groups == other.groups def __hash__(self) -> int: + """Return hash.""" return hash(("LayeredPartition", self.strategy, self.groups)) diff --git a/python/src/qdk_chemistry/utils/model_hamiltonians.py b/python/src/qdk_chemistry/utils/model_hamiltonians.py index 823485bf5..7400488c6 100644 --- a/python/src/qdk_chemistry/utils/model_hamiltonians.py +++ b/python/src/qdk_chemistry/utils/model_hamiltonians.py @@ -18,7 +18,7 @@ to_site_param, ) from qdk_chemistry.data import LatticeGraph, LayeredPartition, PauliOperator, QubitHamiltonian - +from qdk_chemistry.utils import Logger __all__ = [ "create_heisenberg_hamiltonian", @@ -50,11 +50,18 @@ def _build_geometry_grouped_hamiltonian( ) -> QubitHamiltonian: r"""Assemble a Heisenberg-like Hamiltonian with a populated ``term_partition``. - Builds the Pauli-string list grouped first by single-body field direction - (one *Trotter group* per direction, each containing a single layer because - field terms have disjoint support), then by two-body coupling type (one - Trotter group per ``XX``/``YY``/``ZZ`` block, each split into layers by - edge color). Term indices in + This helper exists separately from the ungrouped construction path + because it builds the Pauli-string list in a specific order dictated + by the lattice edge coloring, then records that order as a + :class:`~qdk_chemistry.data.LayeredPartition`. The ungrouped path + constructs terms from the adjacency matrix directly without regard + to color structure. + + Groups are organised first by single-body field direction (one group + per direction, each containing a single layer because field terms + have disjoint support), then by two-body coupling type (one group + per ``XX``/``YY``/``ZZ`` block, each split into layers by edge + color). Term indices in :attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings` align with the indices stored in the returned :class:`LayeredPartition`. @@ -62,7 +69,7 @@ def _build_geometry_grouped_hamiltonian( graph: Lattice graph defining connectivity. couplings: ``[(label, value), ...]`` for two-body terms (e.g. ``[(\"XX\", jx)]``). fields: ``[(char, value), ...]`` for single-body terms (e.g. ``[(\"X\", hx)]``). - coloring: Optional pre-computed edge coloring as ``{(i, j): color}`` with ``i < j``. When ``None``, ``graph.edge_coloring`` is read. + coloring: Optional edge coloring ``{(i, j): color}`` (``i < j``). Reads ``graph.edge_coloring`` when ``None``. Returns: QubitHamiltonian: The assembled Hamiltonian carrying a ``LayeredPartition`` @@ -183,10 +190,14 @@ def create_heisenberg_hamiltonian( raise ValueError("Lattice graph must be symmetric for a valid Hamiltonian.") if include_term_groups: - return _build_geometry_grouped_hamiltonian( - graph, - couplings=[("XX", jx), ("YY", jy), ("ZZ", jz)], - fields=[("X", hx), ("Y", hy), ("Z", hz)], + if graph.edge_coloring is not None: + return _build_geometry_grouped_hamiltonian( + graph, + couplings=[("XX", jx), ("YY", jy), ("ZZ", jz)], + fields=[("X", hx), ("Y", hy), ("Z", hz)], + ) + Logger.info( + "No edge coloring on lattice graph; falling back to ungrouped Hamiltonian construction." ) n = graph.num_sites diff --git a/python/tests/test_encoding_metadata.py b/python/tests/test_encoding_metadata.py index 3a3768d3c..f9c7036ac 100644 --- a/python/tests/test_encoding_metadata.py +++ b/python/tests/test_encoding_metadata.py @@ -9,28 +9,13 @@ import numpy as np import pytest -from qdk_chemistry.algorithms import create, registry +from qdk_chemistry.algorithms import create from qdk_chemistry.data import Circuit, EncodingMismatchError, QubitHamiltonian, validate_encoding_compatibility from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT, QDK_CHEMISTRY_HAS_QISKIT_NATURE from .test_helpers import create_test_hamiltonian - - -def _group_commuting(qh: QubitHamiltonian, *, qubit_wise: bool = True) -> list[QubitHamiltonian]: - """Materialise commuting groups via the term_grouper algorithm.""" - strategy = "qubit_wise_commuting" if qubit_wise else "commuting" - grouped = registry.create("term_grouper", strategy).run(qh) - partition = grouped.term_partition - return [ - QubitHamiltonian( - pauli_strings=[grouped.pauli_strings[i] for i in group], - coefficients=np.asarray([grouped.coefficients[i] for i in group]), - encoding=grouped.encoding, - fermion_mode_order=grouped.fermion_mode_order, - ) - for group in partition.groups - ] +from .test_helpers import group_commuting as _group_commuting def test_circuit_encoding_metadata(): diff --git a/python/tests/test_energy_estimator.py b/python/tests/test_energy_estimator.py index e57d2fde0..e5cab6ab4 100644 --- a/python/tests/test_energy_estimator.py +++ b/python/tests/test_energy_estimator.py @@ -12,7 +12,7 @@ import numpy as np import pytest -from qdk_chemistry.algorithms import create +from qdk_chemistry.algorithms import create, registry from qdk_chemistry.algorithms.energy_estimator.qdk import ( QdkEnergyEstimator, _append_measurement_to_circuit, @@ -287,6 +287,8 @@ def test_estimator_run_4e4o(executor_name, wavefunction_4e4o, ref_energy_4e4o): test_hamiltonian = QubitHamiltonian( ["IIIIIZII", "IXXIIXXI", "IIIIIIZI"], np.array([0.76388709, 0.1022262, 1.03502496]) ) + # Pre-group by QWC so the estimator uses grouped measurement. + test_hamiltonian = registry.create("term_grouper", "qubit_wise_commuting").run(test_hamiltonian) estimator = QdkEnergyEstimator() estimator.settings().set( "circuit_executor", diff --git a/python/tests/test_helpers.py b/python/tests/test_helpers.py index ec786ee54..fef349543 100644 --- a/python/tests/test_helpers.py +++ b/python/tests/test_helpers.py @@ -7,6 +7,7 @@ import numpy as np +from qdk_chemistry.algorithms import registry from qdk_chemistry.data import ( Ansatz, BasisSet, @@ -16,6 +17,7 @@ Hamiltonian, Orbitals, OrbitalType, + QubitHamiltonian, Shell, Structure, Wavefunction, @@ -247,3 +249,30 @@ def create_test_ansatz(num_orbitals: int = 2): wavefunction = Wavefunction(container) return Ansatz(hamiltonian, wavefunction) + + +def group_commuting(qh: QubitHamiltonian, *, qubit_wise: bool = True) -> list[QubitHamiltonian]: + """Materialise commuting groups via the term_grouper algorithm. + + Test helper replacing the removed ``QubitHamiltonian.group_commuting`` method. + + Args: + qh: QubitHamiltonian to partition. + qubit_wise: Use qubit-wise commutation (default) or full commutation. + + Returns: + List of ``QubitHamiltonian`` objects, one per commuting group. + + """ + strategy = "qubit_wise_commuting" if qubit_wise else "commuting" + grouped = registry.create("term_grouper", strategy).run(qh) + partition = grouped.term_partition + return [ + QubitHamiltonian( + pauli_strings=[grouped.pauli_strings[i] for i in group], + coefficients=np.asarray([grouped.coefficients[i] for i in group]), + encoding=grouped.encoding, + fermion_mode_order=grouped.fermion_mode_order, + ) + for group in partition.groups + ] diff --git a/python/tests/test_qubit_hamiltonian.py b/python/tests/test_qubit_hamiltonian.py index 41b3a02c6..1a5480bd4 100644 --- a/python/tests/test_qubit_hamiltonian.py +++ b/python/tests/test_qubit_hamiltonian.py @@ -13,31 +13,11 @@ import pytest import scipy.sparse -from qdk_chemistry.algorithms import registry from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian from .reference_tolerances import float_comparison_absolute_tolerance, float_comparison_relative_tolerance - - -def _group_commuting(qh: QubitHamiltonian, *, qubit_wise: bool = True) -> list[QubitHamiltonian]: - """Materialise commuting groups via the term_grouper algorithm. - - Replacement for the removed ``QubitHamiltonian.group_commuting`` method - used by the legacy tests below. - """ - strategy = "qubit_wise_commuting" if qubit_wise else "commuting" - grouped = registry.create("term_grouper", strategy).run(qh) - partition = grouped.term_partition - return [ - QubitHamiltonian( - pauli_strings=[grouped.pauli_strings[i] for i in group], - coefficients=np.asarray([grouped.coefficients[i] for i in group]), - encoding=grouped.encoding, - fermion_mode_order=grouped.fermion_mode_order, - ) - for group in partition.groups - ] +from .test_helpers import group_commuting as _group_commuting def _pauli_matrix(label): diff --git a/python/tests/test_term_partition.py b/python/tests/test_term_partition.py index cacfac4e3..1122e7774 100644 --- a/python/tests/test_term_partition.py +++ b/python/tests/test_term_partition.py @@ -110,7 +110,7 @@ def test_to_interleaved_resets_partition(self): class TestTermGrouperRegistry: def test_available_strategies(self): names = registry.available("term_grouper") - assert set(names) == {"commuting", "qubit_wise_commuting", "identity"} + assert {"commuting", "qubit_wise_commuting", "identity"} <= set(names) def test_default_strategy_is_commuting(self): grouper = registry.create("term_grouper") @@ -224,13 +224,23 @@ def test_trotter_runs_without_partition(self): def test_partition_produces_smaller_or_equal_step_count_at_order_2(self): # With group sorting + schedule reduction, populating the partition - # should never produce more groups than the ungrouped fallback. + # should never produce more step terms than the ungrouped fallback. lat = LatticeGraph.chain(4, periodic=True) with_groups = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0, include_term_groups=True) without_groups = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0, include_term_groups=False) assert with_groups.term_partition is not None assert without_groups.term_partition is None + trotter = registry.create("time_evolution_builder", "trotter") + trotter.settings().update({"order": 2, "num_divisions": 1}) + grouped_steps = len(trotter.run(with_groups, time=1.0).get_container().step_terms) + + trotter2 = registry.create("time_evolution_builder", "trotter") + trotter2.settings().update({"order": 2, "num_divisions": 1}) + ungrouped_steps = len(trotter2.run(without_groups, time=1.0).get_container().step_terms) + + assert grouped_steps <= ungrouped_steps + def test_trotter_runs_with_flat_partition(self): # Take a partitioned Hamiltonian and overwrite term_partition with a # FlatPartition (via the term_grouper algorithm), then drive Trotter. diff --git a/python/tests/test_time_evolution_trotter.py b/python/tests/test_time_evolution_trotter.py index 2afd575e3..a319e7ed7 100644 --- a/python/tests/test_time_evolution_trotter.py +++ b/python/tests/test_time_evolution_trotter.py @@ -502,8 +502,8 @@ def test_filters_small_coefficients_higher_order(self): terms = builder._decompose_trotter_step(hamiltonian, time=1.0, atol=1e-12) # All terms should be Z only (X filtered out). - # The 4th-order Suzuki schedule produces multiple entries for the single group, - # each with a different time fraction. The total angle should sum to coeff * time. + # After Suzuki recursion and schedule reduction, the total rotation + # angle across all Z terms must equal coeff * time = 1.0. assert all(t.pauli_term == {0: "Z"} for t in terms) assert abs(sum(t.angle for t in terms) - 1.0) < 1e-12 From 79570d6ac82cd6e75ab9a342fd34012471ee6cc3 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Tue, 5 May 2026 12:02:25 -0700 Subject: [PATCH 049/117] Cleanup --- .../algorithms/time_evolution_builder.rst | 39 +++++++- .../algorithms/energy_estimator/qdk.py | 28 ++---- python/tests/test_encoding_metadata.py | 13 +-- python/tests/test_helpers.py | 29 ------ python/tests/test_qubit_hamiltonian.py | 98 +++++++++++-------- 5 files changed, 104 insertions(+), 103 deletions(-) diff --git a/docs/source/user/comprehensive/algorithms/time_evolution_builder.rst b/docs/source/user/comprehensive/algorithms/time_evolution_builder.rst index 517f5a704..e9e756f01 100644 --- a/docs/source/user/comprehensive/algorithms/time_evolution_builder.rst +++ b/docs/source/user/comprehensive/algorithms/time_evolution_builder.rst @@ -169,16 +169,47 @@ Example:: from qdk_chemistry.utils.model_hamiltonians import create_ising_hamiltonian from qdk_chemistry.algorithms import registry - graph = LatticeGraph.square(4, 4, periodic_x=True, periodic_y=True) + # 4-site open Ising chain: H = Σ Z_i Z_{i+1} + 0.5 Σ X_i + graph = LatticeGraph.chain(4, periodic=False) hamiltonian = create_ising_hamiltonian(graph, j=1.0, h=0.5) - # The Hamiltonian already carries a LayeredPartition from the edge coloring. + # The edge coloring partitions ZZ terms into two layers by color: + # group 0 (field): 1 layer → [X₀, X₁, X₂, X₃] + # group 1 (ZZ): 2 layers → [{Z₀Z₁, Z₂Z₃}, {Z₁Z₂}] print(hamiltonian.term_partition) + # LayeredPartition(strategy='geometry_coloring', num_groups=2) - # The Trotter builder consumes it automatically. + # Second-order Trotter with 1 division produces the Strang splitting: + # S₂(t) = e^{fields·t/2} e^{ZZ_layer0·t} e^{ZZ_layer1·t} + # e^{fields·t/2} + # where same-layer ZZ terms (e.g. Z₀Z₁ and Z₂Z₃) have disjoint + # qubit support and are exponentiated independently within one step. trotter = registry.create("time_evolution_builder", "trotter") - trotter.settings().set("order", 2) + trotter.settings().update({"order": 2, "num_divisions": 1}) evolution = trotter.run(hamiltonian, time=1.0) + container = evolution.get_container() + + # The grouped schedule uses 11 exponentiated terms per step, + # vs. 13 for the ungrouped fallback — a 15% reduction that + # compounds at higher Suzuki orders. + print(f"{len(container.step_terms)} terms per Trotter step") + for term in container.step_terms: + label = ['I'] * 4 + for q, p in term.pauli_term.items(): + label[q] = p + print(f" exp(-i * {term.angle:+.4f} * {''.join(reversed(label))})") + # Output: + # exp(-i * +0.2500 * IIIX) + # exp(-i * +0.2500 * IIXI) + # exp(-i * +0.2500 * IXII) + # exp(-i * +0.2500 * XIII) + # exp(-i * +1.0000 * IIZZ) + # exp(-i * +1.0000 * ZZII) + # exp(-i * +1.0000 * IZZI) + # exp(-i * +0.2500 * IIIX) + # exp(-i * +0.2500 * IIXI) + # exp(-i * +0.2500 * IXII) + # exp(-i * +0.2500 * XIII) Related classes diff --git a/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py b/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py index 857740855..dffac5f85 100644 --- a/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py +++ b/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py @@ -244,12 +244,17 @@ def _run_impl( @staticmethod def _resolve_measurement_groups(qubit_hamiltonian: QubitHamiltonian) -> list[QubitHamiltonian]: - """Resolve the Hamiltonian into qubit-wise commuting measurement groups. + """Resolve the Hamiltonian into simultaneously-measurable groups. If ``qubit_hamiltonian`` carries a - :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` whose - strategy is ``"qubit_wise_commuting"``, the partition is consumed - directly. Otherwise each Pauli term is measured individually. + :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition`, the + partition is consumed and each group becomes one measurement circuit. + The groups must be compatible with the measurement backend — at + present, each group must be qubit-wise commuting so that a single + Pauli basis suffices. + + When no partition is present, each Pauli term is measured + individually. Args: qubit_hamiltonian: The Hamiltonian to partition for measurement. @@ -257,24 +262,11 @@ def _resolve_measurement_groups(qubit_hamiltonian: QubitHamiltonian) -> list[Qub Returns: A list of ``QubitHamiltonian`` objects, one per measurement group. - Raises: - ValueError: If a ``term_partition`` is present but uses a strategy - incompatible with qubit-wise commuting measurement (only - ``"qubit_wise_commuting"`` is currently supported). - """ partition = qubit_hamiltonian.term_partition if partition is not None: from qdk_chemistry.data.term_partition import FlatPartition # noqa: PLC0415 - if partition.strategy != "qubit_wise_commuting": - raise ValueError( - f"QdkEnergyEstimator requires qubit-wise commuting measurement groups, " - f"but the Hamiltonian carries a term_partition with " - f"strategy={partition.strategy!r}. Either pre-group with the " - f"'qubit_wise_commuting' term_grouper, or remove the partition " - f"to measure each term individually." - ) if not isinstance(partition, FlatPartition): raise TypeError( f"QdkEnergyEstimator expects a FlatPartition for measurement grouping, " @@ -294,7 +286,7 @@ def _resolve_measurement_groups(qubit_hamiltonian: QubitHamiltonian) -> list[Qub for group in partition.groups ] - Logger.info("EnergyEstimator: no compatible term_partition; measuring each term individually.") + Logger.info("EnergyEstimator: no term_partition; measuring each term individually.") return [ QubitHamiltonian( pauli_strings=[label], diff --git a/python/tests/test_encoding_metadata.py b/python/tests/test_encoding_metadata.py index f9c7036ac..486e7082c 100644 --- a/python/tests/test_encoding_metadata.py +++ b/python/tests/test_encoding_metadata.py @@ -9,13 +9,12 @@ import numpy as np import pytest -from qdk_chemistry.algorithms import create +from qdk_chemistry.algorithms import create, registry from qdk_chemistry.data import Circuit, EncodingMismatchError, QubitHamiltonian, validate_encoding_compatibility from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT, QDK_CHEMISTRY_HAS_QISKIT_NATURE from .test_helpers import create_test_hamiltonian -from .test_helpers import group_commuting as _group_commuting def test_circuit_encoding_metadata(): @@ -233,17 +232,13 @@ def test_qdk_qubit_mapper_injects_encoding(): def test_group_commuting_preserves_encoding(): - """Test that group_commuting preserves the encoding metadata.""" + """Test that term_grouper preserves the encoding metadata.""" pauli_strings = ["II", "ZI", "IZ", "ZZ", "XX", "YY"] coefficients = np.array([1.0, 0.5, 0.5, 0.25, 0.3, 0.3]) ham = QubitHamiltonian(pauli_strings, coefficients, encoding="jordan-wigner") - # Group into commuting subsets - grouped = _group_commuting(ham, qubit_wise=True) - - # Each group should preserve the encoding - for group in grouped: - assert group.encoding == "jordan-wigner" + grouped = registry.create("term_grouper", "qubit_wise_commuting").run(ham) + assert grouped.encoding == "jordan-wigner" def test_end_to_end_workflow_compatible_encodings(wavefunction_4e4o): diff --git a/python/tests/test_helpers.py b/python/tests/test_helpers.py index fef349543..ec786ee54 100644 --- a/python/tests/test_helpers.py +++ b/python/tests/test_helpers.py @@ -7,7 +7,6 @@ import numpy as np -from qdk_chemistry.algorithms import registry from qdk_chemistry.data import ( Ansatz, BasisSet, @@ -17,7 +16,6 @@ Hamiltonian, Orbitals, OrbitalType, - QubitHamiltonian, Shell, Structure, Wavefunction, @@ -249,30 +247,3 @@ def create_test_ansatz(num_orbitals: int = 2): wavefunction = Wavefunction(container) return Ansatz(hamiltonian, wavefunction) - - -def group_commuting(qh: QubitHamiltonian, *, qubit_wise: bool = True) -> list[QubitHamiltonian]: - """Materialise commuting groups via the term_grouper algorithm. - - Test helper replacing the removed ``QubitHamiltonian.group_commuting`` method. - - Args: - qh: QubitHamiltonian to partition. - qubit_wise: Use qubit-wise commutation (default) or full commutation. - - Returns: - List of ``QubitHamiltonian`` objects, one per commuting group. - - """ - strategy = "qubit_wise_commuting" if qubit_wise else "commuting" - grouped = registry.create("term_grouper", strategy).run(qh) - partition = grouped.term_partition - return [ - QubitHamiltonian( - pauli_strings=[grouped.pauli_strings[i] for i in group], - coefficients=np.asarray([grouped.coefficients[i] for i in group]), - encoding=grouped.encoding, - fermion_mode_order=grouped.fermion_mode_order, - ) - for group in partition.groups - ] diff --git a/python/tests/test_qubit_hamiltonian.py b/python/tests/test_qubit_hamiltonian.py index 1a5480bd4..95e652c0f 100644 --- a/python/tests/test_qubit_hamiltonian.py +++ b/python/tests/test_qubit_hamiltonian.py @@ -13,11 +13,12 @@ import pytest import scipy.sparse +from qdk_chemistry.algorithms import registry from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian +from qdk_chemistry.data.term_partition import FlatPartition from .reference_tolerances import float_comparison_absolute_tolerance, float_comparison_relative_tolerance -from .test_helpers import group_commuting as _group_commuting def _pauli_matrix(label): @@ -68,74 +69,79 @@ def test_initialization_invalid_pauli(self): QubitHamiltonian(pauli_strings=[], coefficients=[]) def test_group_commuting(self): - """Test group_commuting.""" + """Test full-commuting term grouper produces correct groups.""" qubit_hamiltonian = QubitHamiltonian(["XX", "YY", "ZZ", "XY"], [1.0, 0.5, -0.5, 0.2]) - grouped = _group_commuting(qubit_hamiltonian, qubit_wise=False) - assert len(grouped) == 2 + grouped = registry.create("term_grouper", "commuting").run(qubit_hamiltonian) + partition = grouped.term_partition + assert isinstance(partition, FlatPartition) + assert partition.num_groups == 2 # Verify coefficients are preserved - coeff_map = dict(zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=True)) - for group in grouped: - for pauli_str, coeff in zip(group.pauli_strings, group.coefficients, strict=True): + for group_indices in partition.groups: + for idx in group_indices: assert np.isclose( - coeff, - coeff_map[pauli_str], + grouped.coefficients[idx], + qubit_hamiltonian.coefficients[idx], atol=float_comparison_absolute_tolerance, rtol=float_comparison_relative_tolerance, ) def test_group_commuting_qubitwise(self): - """Test group_commuting without qubit-wise commuting.""" + """Test qubit-wise commuting grouper.""" qubit_hamiltonian = QubitHamiltonian(["XX", "YY", "ZZ", "XY"], [1.0, 0.5, -0.5, 0.2]) - grouped = _group_commuting(qubit_hamiltonian, qubit_wise=True) - assert len(grouped) == 4 # Qubit-wise commuting returns four groups - - # Check that all original Pauli strings are present across all groups - all_grouped_strings = [] - for group in grouped: - assert len(group.pauli_strings) == 1 # Each group should contain only one Pauli string - all_grouped_strings.extend(group.pauli_strings) - assert set(all_grouped_strings) == {"XX", "YY", "ZZ", "XY"} + grouped = registry.create("term_grouper", "qubit_wise_commuting").run(qubit_hamiltonian) + partition = grouped.term_partition + assert isinstance(partition, FlatPartition) + assert partition.num_groups == 4 + + # Each group should contain exactly one term + for group_indices in partition.groups: + assert len(group_indices) == 1 + # All original terms are present + all_indices = sorted(partition.all_indices()) + assert all_indices == list(range(4)) def test_group_commuting_all_commute(self): """Test that fully commuting operators go into one group.""" - # ZI, IZ, ZZ all commute with each other qh = QubitHamiltonian(["ZI", "IZ", "ZZ"], np.array([1.0, -0.5, 0.3])) - grouped = _group_commuting(qh, qubit_wise=False) - assert len(grouped) == 1 - assert len(grouped[0].pauli_strings) == 3 + grouped = registry.create("term_grouper", "commuting").run(qh) + assert grouped.term_partition.num_groups == 1 + assert len(grouped.term_partition.groups[0]) == 3 def test_group_commuting_none_commute(self): """Test that non-commuting operators each get their own group.""" - # X and Z anticommute; Y and X anticommute; Y and Z anticommute qh = QubitHamiltonian(["X", "Z", "Y"], np.array([1.0, -0.5, 0.3])) - grouped = _group_commuting(qh, qubit_wise=False) - assert len(grouped) == 3 + grouped = registry.create("term_grouper", "commuting").run(qh) + assert grouped.term_partition.num_groups == 3 def test_group_commuting_single_term(self): - """Test group_commuting with a single term.""" + """Test grouping with a single term.""" qh = QubitHamiltonian(["ZZ"], np.array([1.0])) - grouped = _group_commuting(qh, qubit_wise=False) - assert len(grouped) == 1 - assert grouped[0].pauli_strings == ["ZZ"] + grouped = registry.create("term_grouper", "commuting").run(qh) + assert grouped.term_partition.num_groups == 1 + assert grouped.pauli_strings == ["ZZ"] def test_group_commuting_reconstruct_matrix(self): - """Test group_commuting with matrix verification.""" + """Test that grouped terms reconstruct the same matrix.""" qh = QubitHamiltonian( ["II", "IZ", "ZI", "ZZ", "XX", "YY"], np.array([-0.8, 0.17, -0.17, 0.12, 0.04, 0.04]), ) - # General commuting: all diagonal terms commute, XX and YY commute with each other and with diag terms - grouped = _group_commuting(qh, qubit_wise=False) - total_terms = sum(len(g.pauli_strings) for g in grouped) + grouped = registry.create("term_grouper", "commuting").run(qh) + partition = grouped.term_partition + total_terms = sum(len(g) for g in partition.groups) assert total_terms == 6 - # Verify ground state energy via eigenvalues + mat = qh.to_matrix() gs_energy = np.min(np.linalg.eigvalsh(mat)) # Reconstruct from groups and check same ground state energy full_mat = np.zeros_like(mat) - for g in grouped: - full_mat += g.to_matrix() + for group_indices in partition.groups: + sub = QubitHamiltonian( + [grouped.pauli_strings[i] for i in group_indices], + np.array([grouped.coefficients[i] for i in group_indices]), + ) + full_mat += sub.to_matrix() gs_energy_grouped = np.min(np.linalg.eigvalsh(full_mat)) assert np.isclose(gs_energy, gs_energy_grouped, atol=float_comparison_absolute_tolerance) @@ -145,10 +151,16 @@ def test_group_commuting_qw_reconstruct_matrix(self): coeffs = np.array([0.5, 0.3, 0.2, -0.1, 0.4, -0.25, 0.15]) qh = QubitHamiltonian(labels, coeffs) original_mat = qh.to_matrix() - groups = _group_commuting(qh, qubit_wise=True) + + grouped = registry.create("term_grouper", "qubit_wise_commuting").run(qh) + partition = grouped.term_partition reconstructed = np.zeros_like(original_mat) - for g in groups: - reconstructed += g.to_matrix() + for group_indices in partition.groups: + sub = QubitHamiltonian( + [grouped.pauli_strings[i] for i in group_indices], + np.array([grouped.coefficients[i] for i in group_indices]), + ) + reconstructed += sub.to_matrix() assert np.allclose( reconstructed, original_mat, @@ -532,14 +544,14 @@ def test_hdf5_roundtrip_none(self, tmp_path): assert restored.fermion_mode_order is None def test_group_commuting_preserves(self): - """group_commuting preserves fermion_mode_order.""" + """term_grouper preserves fermion_mode_order.""" qh = QubitHamiltonian( ["XX", "YY", "ZZ"], np.array([1.0, 0.5, -0.5]), fermion_mode_order=FermionModeOrder.BLOCKED, ) - for group in _group_commuting(qh, qubit_wise=True): - assert group.fermion_mode_order == FermionModeOrder.BLOCKED + grouped = registry.create("term_grouper", "qubit_wise_commuting").run(qh) + assert grouped.fermion_mode_order == FermionModeOrder.BLOCKED def test_to_interleaved_sets_order(self): """to_interleaved sets fermion_mode_order to INTERLEAVED.""" From aeaa90f3ca062a7424968cd873c587196cb5bbf1 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Tue, 5 May 2026 12:39:35 -0700 Subject: [PATCH 050/117] Add NetworkX plugin --- python/pyproject.toml | 5 +- python/src/qdk_chemistry/__init__.py | 4 + .../algorithms/term_grouper/commuting.py | 15 +-- .../plugins/networkx/__init__.py | 59 +++++++++ .../plugins/networkx/term_grouper.py | 125 ++++++++++++++++++ 5 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 python/src/qdk_chemistry/plugins/networkx/__init__.py create mode 100644 python/src/qdk_chemistry/plugins/networkx/term_grouper.py diff --git a/python/pyproject.toml b/python/pyproject.toml index d9b37a47a..6bb7334a9 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -47,7 +47,7 @@ requires-python = ">=3.10" [project.optional-dependencies] all = [ - "qdk-chemistry[coverage,dev,docs,jupyter,plugins,qiskit-extras,openfermion-extras]" + "qdk-chemistry[coverage,dev,docs,jupyter,plugins,qiskit-extras,openfermion-extras,networkx-extras]" ] coverage = [ "coverage", @@ -92,6 +92,9 @@ qiskit-extras = [ openfermion-extras = [ "openfermion>=1.0.0" ] +networkx-extras = [ + "networkx>=3.0" +] [project.urls] Documentation = "https://microsoft.github.io/qdk-chemistry" diff --git a/python/src/qdk_chemistry/__init__.py b/python/src/qdk_chemistry/__init__.py index 7d4e7a5ae..926abe581 100644 --- a/python/src/qdk_chemistry/__init__.py +++ b/python/src/qdk_chemistry/__init__.py @@ -126,6 +126,10 @@ def _import_plugins() -> None: import qdk_chemistry.plugins.openfermion as openfermion_plugin # noqa: PLC0415 openfermion_plugin.load() + with contextlib.suppress(ImportError): + import qdk_chemistry.plugins.networkx as networkx_plugin # noqa: PLC0415 + + networkx_plugin.load() def _is_placeholder_stub(stub_file: Path) -> bool: diff --git a/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py b/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py index d409d08d7..f6ea7e989 100644 --- a/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py +++ b/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py @@ -5,6 +5,8 @@ # Licensed under the MIT License. See LICENSE.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +from __future__ import annotations + from collections.abc import Callable from qdk_chemistry.algorithms.term_grouper.base import TermGrouper @@ -14,15 +16,14 @@ __all__ = ["FullCommutingTermGrouper", "QubitWiseCommutingTermGrouper"] -def _greedy_commutation_grouping( +def _color_non_commutation_graph( pauli_strings: list[str], commutes: Callable[[str, str], bool], ) -> tuple[tuple[int, ...], ...]: """Partition Pauli labels into commuting groups via greedy graph coloring. - Builds a non-commutation adjacency structure and greedily assigns each - label the lowest-index group in which it commutes with all existing - members. + Builds the non-commutation graph and greedily assigns each label the + lowest-index group in which it commutes with all existing members. Args: pauli_strings: Pauli labels to partition. @@ -36,8 +37,6 @@ def _greedy_commutation_grouping( if n == 0: return () - # Pre-compute which pairs do NOT commute for O(1) lookup during grouping. - # Store as a list of sets: non_commuting[i] = {j, ...} for j < i. non_commuting: list[set[int]] = [set() for _ in range(n)] for i in range(1, n): for j in range(i): @@ -89,7 +88,7 @@ def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: QubitHamiltonian: New instance with a ``FlatPartition`` (strategy ``"commuting"``). """ - groups = _greedy_commutation_grouping(qubit_hamiltonian.pauli_strings, do_pauli_labels_commute) + groups = _color_non_commutation_graph(qubit_hamiltonian.pauli_strings, do_pauli_labels_commute) partition = FlatPartition(strategy="commuting", groups=groups) return QubitHamiltonian( pauli_strings=list(qubit_hamiltonian.pauli_strings), @@ -125,7 +124,7 @@ def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: QubitHamiltonian: New instance with a ``FlatPartition`` (strategy ``"qubit_wise_commuting"``). """ - groups = _greedy_commutation_grouping(qubit_hamiltonian.pauli_strings, do_pauli_labels_qw_commute) + groups = _color_non_commutation_graph(qubit_hamiltonian.pauli_strings, do_pauli_labels_qw_commute) partition = FlatPartition(strategy="qubit_wise_commuting", groups=groups) return QubitHamiltonian( pauli_strings=list(qubit_hamiltonian.pauli_strings), diff --git a/python/src/qdk_chemistry/plugins/networkx/__init__.py b/python/src/qdk_chemistry/plugins/networkx/__init__.py new file mode 100644 index 000000000..3d492e300 --- /dev/null +++ b/python/src/qdk_chemistry/plugins/networkx/__init__.py @@ -0,0 +1,59 @@ +"""NetworkX plugin for QDK/Chemistry. + +Provides improved graph-coloring-based term groupers using networkx's +DSATUR (saturation-largest-first) strategy, which typically produces +fewer groups than the built-in greedy first-fit algorithm. + +When loaded, this plugin registers two additional ``term_grouper`` +algorithms: + +- ``"nx_commuting"`` — full Pauli commutation grouping via DSATUR +- ``"nx_qubit_wise_commuting"`` — qubit-wise commutation grouping via DSATUR + +These are drop-in alternatives to the built-in ``"commuting"`` and +``"qubit_wise_commuting"`` groupers. + +""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import importlib.util + +from qdk_chemistry.utils import Logger + +_loaded = False +QDK_CHEMISTRY_HAS_NETWORKX = False + + +def load(): + """Load the NetworkX plugin into QDK/Chemistry.""" + Logger.trace_entering() + global _loaded, QDK_CHEMISTRY_HAS_NETWORKX # noqa: PLW0603 + if _loaded: + return + _loaded = True + + if importlib.util.find_spec("networkx") is not None: + QDK_CHEMISTRY_HAS_NETWORKX = True + _register_algorithms() + + +def _register_algorithms(): + """Register NetworkX-backed term grouper algorithms.""" + Logger.trace_entering() + from qdk_chemistry.algorithms import register # noqa: PLC0415 + from qdk_chemistry.plugins.networkx.term_grouper import ( # noqa: PLC0415 + NxFullCommutingTermGrouper, + NxQubitWiseCommutingTermGrouper, + ) + + register(lambda: NxFullCommutingTermGrouper()) + register(lambda: NxQubitWiseCommutingTermGrouper()) + Logger.debug( + f"NetworkX plugin loaded: " + f"[{NxFullCommutingTermGrouper().type_name()}: {NxFullCommutingTermGrouper().name()}], " + f"[{NxQubitWiseCommutingTermGrouper().type_name()}: {NxQubitWiseCommutingTermGrouper().name()}]." + ) diff --git a/python/src/qdk_chemistry/plugins/networkx/term_grouper.py b/python/src/qdk_chemistry/plugins/networkx/term_grouper.py new file mode 100644 index 000000000..8e69f189f --- /dev/null +++ b/python/src/qdk_chemistry/plugins/networkx/term_grouper.py @@ -0,0 +1,125 @@ +"""NetworkX-backed term groupers using DSATUR graph coloring.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from __future__ import annotations + +from collections.abc import Callable + +import networkx as nx + +from qdk_chemistry.algorithms.term_grouper.base import TermGrouper +from qdk_chemistry.data import FlatPartition, QubitHamiltonian +from qdk_chemistry.utils.pauli_commutation import do_pauli_labels_commute, do_pauli_labels_qw_commute + +__all__ = ["NxFullCommutingTermGrouper", "NxQubitWiseCommutingTermGrouper"] + + +def _dsatur_commutation_grouping( + pauli_strings: list[str], + commutes: Callable[[str, str], bool], +) -> tuple[tuple[int, ...], ...]: + """Partition Pauli labels using networkx DSATUR graph coloring. + + Builds the non-commutation graph (vertices = Pauli terms, edges between + non-commuting pairs) and colors it using the saturation-largest-first + (DSATUR) strategy, which greedily colors the vertex with the highest + saturation degree (most distinct colors among neighbours). + + Args: + pauli_strings: Pauli labels to partition. + commutes: Predicate returning ``True`` when two labels commute. + + Returns: + Tuple of groups; each group is a tuple of indices into ``pauli_strings``. + + """ + n = len(pauli_strings) + if n == 0: + return () + + g = nx.Graph() + g.add_nodes_from(range(n)) + for i in range(1, n): + for j in range(i): + if not commutes(pauli_strings[i], pauli_strings[j]): + g.add_edge(i, j) + + coloring = nx.coloring.greedy_color(g, strategy="saturation_largest_first") + + color_to_indices: dict[int, list[int]] = {} + for node, color in sorted(coloring.items()): + color_to_indices.setdefault(color, []).append(node) + + return tuple(tuple(indices) for indices in color_to_indices.values()) + + +class NxFullCommutingTermGrouper(TermGrouper): + """Group terms by full Pauli commutation using networkx DSATUR. + + Uses the saturation-largest-first (DSATUR) graph coloring strategy + from networkx, which typically produces fewer groups than the built-in + greedy first-fit algorithm. + + """ + + def name(self) -> str: + """Return ``nx_commuting`` as the algorithm name.""" + return "nx_commuting" + + def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: + """Return a copy of ``qubit_hamiltonian`` with a full-commutation DSATUR partition. + + Args: + qubit_hamiltonian: Hamiltonian to partition. + + Returns: + QubitHamiltonian: New instance with a ``FlatPartition`` (strategy ``"nx_commuting"``). + + """ + groups = _dsatur_commutation_grouping(qubit_hamiltonian.pauli_strings, do_pauli_labels_commute) + partition = FlatPartition(strategy="nx_commuting", groups=groups) + return QubitHamiltonian( + pauli_strings=list(qubit_hamiltonian.pauli_strings), + coefficients=qubit_hamiltonian.coefficients.copy(), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + term_partition=partition, + ) + + +class NxQubitWiseCommutingTermGrouper(TermGrouper): + """Group terms by qubit-wise commutation using networkx DSATUR. + + Uses the saturation-largest-first (DSATUR) graph coloring strategy + from networkx, which typically produces fewer groups than the built-in + greedy first-fit algorithm. + + """ + + def name(self) -> str: + """Return ``nx_qubit_wise_commuting`` as the algorithm name.""" + return "nx_qubit_wise_commuting" + + def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: + """Return a copy with a qubit-wise commutation DSATUR partition. + + Args: + qubit_hamiltonian: Hamiltonian to partition. + + Returns: + QubitHamiltonian: New instance with a ``FlatPartition`` (strategy ``"nx_qubit_wise_commuting"``). + + """ + groups = _dsatur_commutation_grouping(qubit_hamiltonian.pauli_strings, do_pauli_labels_qw_commute) + partition = FlatPartition(strategy="nx_qubit_wise_commuting", groups=groups) + return QubitHamiltonian( + pauli_strings=list(qubit_hamiltonian.pauli_strings), + coefficients=qubit_hamiltonian.coefficients.copy(), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + term_partition=partition, + ) From e9c70d63f87c0f2c2e5ea8260a89c2238c29dc6e Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Tue, 5 May 2026 12:57:01 -0700 Subject: [PATCH 051/117] Add NetworkX plugin --- cpp/include/qdk/chemistry/data/lattice_graph.hpp | 7 +++---- cpp/src/qdk/chemistry/data/lattice_graph.cpp | 7 +++---- cpp/tests/test_lattice_graph.cpp | 3 +-- python/src/qdk_chemistry/utils/model_hamiltonians.py | 4 +--- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/lattice_graph.hpp b/cpp/include/qdk/chemistry/data/lattice_graph.hpp index 1150eed22..b7d814375 100644 --- a/cpp/include/qdk/chemistry/data/lattice_graph.hpp +++ b/cpp/include/qdk/chemistry/data/lattice_graph.hpp @@ -26,8 +26,7 @@ namespace qdk::chemistry::data { * * Two edges sharing the same color have disjoint vertex sets. */ -using EdgeColoring = - std::map, int>; +using EdgeColoring = std::map, int>; // ---- Free coloring functions ------------------------------------------------ // These compute edge colorings for known lattice topologies. They are @@ -70,8 +69,8 @@ EdgeColoring chain_coloring(std::int64_t n, bool periodic); * @param periodic_y Whether periodic boundary conditions are applied along y. * @return Edge coloring using 2–4 colours depending on periodicity and parity. */ -EdgeColoring square_coloring(std::int64_t nx, std::int64_t ny, - bool periodic_x, bool periodic_y); +EdgeColoring square_coloring(std::int64_t nx, std::int64_t ny, bool periodic_x, + bool periodic_y); /** * @brief Deterministic optimal 3-coloring for a honeycomb lattice. diff --git a/cpp/src/qdk/chemistry/data/lattice_graph.cpp b/cpp/src/qdk/chemistry/data/lattice_graph.cpp index 251accbab..77bd80730 100644 --- a/cpp/src/qdk/chemistry/data/lattice_graph.cpp +++ b/cpp/src/qdk/chemistry/data/lattice_graph.cpp @@ -489,8 +489,7 @@ EdgeColoring greedy_edge_coloring(const Eigen::SparseMatrix& adj, const auto& used_i = vertex_used[edge.first]; const auto& used_j = vertex_used[edge.second]; int chosen = 0; - while (chosen < max_colors && - (used_i[chosen] || used_j[chosen])) { + while (chosen < max_colors && (used_i[chosen] || used_j[chosen])) { ++chosen; } coloring[edge] = chosen; @@ -528,8 +527,8 @@ EdgeColoring chain_coloring(std::int64_t n, bool periodic) { // edges live on disjoint axes; each axis can be 2-colored by alternating. // With periodic boundaries, an odd extent on that axis forces a third color // on its wrap edges. Total colors: 2 (open) up to 4 (both axes odd-periodic). -EdgeColoring square_coloring(std::int64_t Nx, std::int64_t Ny, - bool periodic_x, bool periodic_y) { +EdgeColoring square_coloring(std::int64_t Nx, std::int64_t Ny, bool periodic_x, + bool periodic_y) { EdgeColoring out; auto idx = [Nx](std::int64_t x, std::int64_t y) { return static_cast(y * Nx + x); diff --git a/cpp/tests/test_lattice_graph.cpp b/cpp/tests/test_lattice_graph.cpp index baaeb1dfa..7a69031a0 100644 --- a/cpp/tests/test_lattice_graph.cpp +++ b/cpp/tests/test_lattice_graph.cpp @@ -502,8 +502,7 @@ TEST_F(LatticeGraphTest, KagomeConstructor) { } // Coloring helper: confirm no two same-color edges share a vertex. -static void check_valid_edge_coloring( - const EdgeColoring& coloring) { +static void check_valid_edge_coloring(const EdgeColoring& coloring) { std::map> incident; for (const auto& [edge, color] : coloring) { auto [a, b] = edge; diff --git a/python/src/qdk_chemistry/utils/model_hamiltonians.py b/python/src/qdk_chemistry/utils/model_hamiltonians.py index 7400488c6..0575970aa 100644 --- a/python/src/qdk_chemistry/utils/model_hamiltonians.py +++ b/python/src/qdk_chemistry/utils/model_hamiltonians.py @@ -196,9 +196,7 @@ def create_heisenberg_hamiltonian( couplings=[("XX", jx), ("YY", jy), ("ZZ", jz)], fields=[("X", hx), ("Y", hy), ("Z", hz)], ) - Logger.info( - "No edge coloring on lattice graph; falling back to ungrouped Hamiltonian construction." - ) + Logger.info("No edge coloring on lattice graph; falling back to ungrouped Hamiltonian construction.") n = graph.num_sites adj = graph.adjacency_matrix() From 51d3a2abd01e94458575758b053bf9a776488926 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Tue, 5 May 2026 13:57:08 -0700 Subject: [PATCH 052/117] Linting --- .../src/qdk_chemistry/algorithms/term_grouper/commuting.py | 5 ++++- python/src/qdk_chemistry/plugins/networkx/term_grouper.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py b/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py index f6ea7e989..a9a3ba640 100644 --- a/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py +++ b/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py @@ -7,12 +7,15 @@ from __future__ import annotations -from collections.abc import Callable +from typing import TYPE_CHECKING from qdk_chemistry.algorithms.term_grouper.base import TermGrouper from qdk_chemistry.data import FlatPartition, QubitHamiltonian from qdk_chemistry.utils.pauli_commutation import do_pauli_labels_commute, do_pauli_labels_qw_commute +if TYPE_CHECKING: + from collections.abc import Callable + __all__ = ["FullCommutingTermGrouper", "QubitWiseCommutingTermGrouper"] diff --git a/python/src/qdk_chemistry/plugins/networkx/term_grouper.py b/python/src/qdk_chemistry/plugins/networkx/term_grouper.py index 8e69f189f..59451677c 100644 --- a/python/src/qdk_chemistry/plugins/networkx/term_grouper.py +++ b/python/src/qdk_chemistry/plugins/networkx/term_grouper.py @@ -7,7 +7,7 @@ from __future__ import annotations -from collections.abc import Callable +from typing import TYPE_CHECKING import networkx as nx @@ -15,6 +15,9 @@ from qdk_chemistry.data import FlatPartition, QubitHamiltonian from qdk_chemistry.utils.pauli_commutation import do_pauli_labels_commute, do_pauli_labels_qw_commute +if TYPE_CHECKING: + from collections.abc import Callable + __all__ = ["NxFullCommutingTermGrouper", "NxQubitWiseCommutingTermGrouper"] From dafa4180088036b63d253b42f6b6b0e70d532f35 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Tue, 5 May 2026 22:58:23 +0000 Subject: [PATCH 053/117] merged Hamiltonian grouping infrastructure --- .../qdk/chemistry/data/lattice_graph.hpp | 99 +++- cpp/src/qdk/chemistry/data/lattice_graph.cpp | 263 ++++++++- cpp/tests/test_lattice_graph.cpp | 129 +++++ .../hamiltonian_unitary_builder.rst | 66 +++ .../user/comprehensive/algorithms/index.rst | 20 + docs/source/user/comprehensive/data/index.rst | 32 ++ .../user/comprehensive/data/lattice_graph.rst | 10 + .../user/comprehensive/model_hamiltonians.rst | 16 + docs/source/user/features.rst | 4 +- python/pyproject.toml | 5 +- python/src/pybind11/data/lattice_graph.cpp | 57 +- python/src/qdk_chemistry/__init__.py | 4 + .../algorithms/energy_estimator/qdk.py | 57 +- .../time_evolution/trotter.py | 316 +++++------ .../src/qdk_chemistry/algorithms/registry.py | 10 + .../src/qdk_chemistry/algorithms/registry.pyi | 514 +++++++++++++++++- .../algorithms/term_grouper/__init__.py | 34 ++ .../algorithms/term_grouper/base.py | 69 +++ .../algorithms/term_grouper/commuting.py | 138 +++++ .../algorithms/term_grouper/identity.py | 48 ++ python/src/qdk_chemistry/data/__init__.py | 8 + python/src/qdk_chemistry/data/base.py | 2 +- .../qdk_chemistry/data/qubit_hamiltonian.py | 62 +-- .../src/qdk_chemistry/data/term_partition.py | 254 +++++++++ .../plugins/networkx/__init__.py | 59 ++ .../plugins/networkx/term_grouper.py | 128 +++++ .../qdk_chemistry/utils/model_hamiltonians.py | 123 ++++- python/tests/test_encoding_metadata.py | 12 +- python/tests/test_energy_estimator.py | 4 +- python/tests/test_qubit_hamiltonian.py | 97 ++-- python/tests/test_term_partition.py | 313 +++++++++++ python/tests/test_time_evolution_trotter.py | 62 +-- 32 files changed, 2687 insertions(+), 328 deletions(-) create mode 100644 python/src/qdk_chemistry/algorithms/term_grouper/__init__.py create mode 100644 python/src/qdk_chemistry/algorithms/term_grouper/base.py create mode 100644 python/src/qdk_chemistry/algorithms/term_grouper/commuting.py create mode 100644 python/src/qdk_chemistry/algorithms/term_grouper/identity.py create mode 100644 python/src/qdk_chemistry/data/term_partition.py create mode 100644 python/src/qdk_chemistry/plugins/networkx/__init__.py create mode 100644 python/src/qdk_chemistry/plugins/networkx/term_grouper.py create mode 100644 python/tests/test_term_partition.py diff --git a/cpp/include/qdk/chemistry/data/lattice_graph.hpp b/cpp/include/qdk/chemistry/data/lattice_graph.hpp index fe2f3823f..b7d814375 100644 --- a/cpp/include/qdk/chemistry/data/lattice_graph.hpp +++ b/cpp/include/qdk/chemistry/data/lattice_graph.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,81 @@ namespace qdk::chemistry::data { +/** + * @brief Edge coloring as a map from ordered (i, j) (with i < j) to a + * non-negative integer color label. + * + * Two edges sharing the same color have disjoint vertex sets. + */ +using EdgeColoring = std::map, int>; + +// ---- Free coloring functions ------------------------------------------------ +// These compute edge colorings for known lattice topologies. They are +// called by the factory methods to pre-populate the coloring at +// construction time, and can also be called directly by users who need +// a coloring for a topology not covered by the built-in factories. + +/** + * @brief Greedy randomised edge coloring of an arbitrary graph. + * + * Shuffles the edge order and assigns each edge the lowest colour not + * incident to either endpoint. Repeats for ``trials`` shuffles (with + * deterministic PRNG seeded by ``seed``) and returns the result with + * the fewest colours. + * + * @param adj Sparse adjacency matrix of the graph. + * @param seed Random seed. Default: 0. + * @param trials Number of random-order trials. Default: 1. + * @return Edge coloring with the fewest distinct colours found. + */ +EdgeColoring greedy_edge_coloring(const Eigen::SparseMatrix& adj, + int seed = 0, int trials = 1); + +/** + * @brief Deterministic optimal edge coloring for a chain (path / ring). + * + * @param n Number of sites in the chain. + * @param periodic Whether the chain wraps around (ring topology). + * @return Edge coloring using 2 colours (open or even-periodic) or 3 + * colours (odd-periodic). + */ +EdgeColoring chain_coloring(std::int64_t n, bool periodic); + +/** + * @brief Deterministic optimal edge coloring for a square lattice. + * + * @param nx Number of sites along x. + * @param ny Number of sites along y. + * @param periodic_x Whether periodic boundary conditions are applied along x. + * @param periodic_y Whether periodic boundary conditions are applied along y. + * @return Edge coloring using 2–4 colours depending on periodicity and parity. + */ +EdgeColoring square_coloring(std::int64_t nx, std::int64_t ny, bool periodic_x, + bool periodic_y); + +/** + * @brief Deterministic optimal 3-coloring for a honeycomb lattice. + * + * @param nx Number of unit cells along x. + * @param ny Number of unit cells along y. + * @param periodic_x Whether periodic boundary conditions are applied along x. + * @param periodic_y Whether periodic boundary conditions are applied along y. + * @return Edge coloring using exactly 3 colours (one per bond type). + */ +EdgeColoring honeycomb_coloring(std::int64_t nx, std::int64_t ny, + bool periodic_x, bool periodic_y); + +/** + * @brief Trivial edge coloring where every edge receives a unique color. + * + * Useful as a fallback when no topology-aware coloring is available. + * + * @param adj Sparse adjacency matrix of the graph. + * @return Edge coloring mapping each undirected edge to a distinct colour + * label 0, 1, 2, … in iteration order. + */ +EdgeColoring trivial_edge_coloring(const Eigen::SparseMatrix& adj); + /** * @brief Weighted graph representing a lattice connectivity structure. * @@ -223,11 +299,13 @@ class LatticeGraph : public DataClass { * @param periodic_y If true, apply periodic boundary conditions along y. * Requires ny >= 2. Default: false. * @param t Uniform hopping weight. Default: 1.0. + * @param coloring_seed PRNG seed for greedy edge coloring. Default: 0. * @throws std::invalid_argument If nx or ny is 0. */ static LatticeGraph triangular(std::uint64_t nx, std::uint64_t ny, bool periodic_x = false, - bool periodic_y = false, double t = 1.0); + bool periodic_y = false, double t = 1.0, + int coloring_seed = 0); /** * @brief Create a two-dimensional honeycomb lattice. @@ -311,11 +389,22 @@ class LatticeGraph : public DataClass { * @param periodic_y If true, apply periodic boundary conditions along y. * Requires ny >= 2. Default: false. * @param t Uniform hopping weight. Default: 1.0. + * @param coloring_seed PRNG seed for greedy edge coloring. Default: 0. * @throws std::invalid_argument If nx or ny is 0. */ static LatticeGraph kagome(std::uint64_t nx, std::uint64_t ny, bool periodic_x = false, bool periodic_y = false, - double t = 1.0); + double t = 1.0, int coloring_seed = 0); + + /** + * @brief Edge coloring stored at construction time, if any. + * + * Factory methods for recognised topologies pre-populate this field. + * Returns ``std::nullopt`` for lattices constructed without a coloring. + * + * @return Reference to the optional edge coloring. + */ + const std::optional& edge_coloring() const; /** * @brief Get the data type name for this class. @@ -395,8 +484,10 @@ class LatticeGraph : public DataClass { * make_bidirectional(). * * @param adjacency Sparse square adjacency matrix (moved in). + * @param coloring Optional edge coloring (moved in). */ - explicit LatticeGraph(Eigen::SparseMatrix adjacency); + explicit LatticeGraph(Eigen::SparseMatrix adjacency, + std::optional coloring = std::nullopt); /** @brief Check if a sparse matrix is symmetric within a numerical tolerance. */ @@ -410,6 +501,8 @@ class LatticeGraph : public DataClass { /// Flag indicating whether the adjacency matrix is symmetric (undirected /// graph) bool _is_symmetric; + /// Edge coloring, populated at construction for recognised topologies. + std::optional _edge_coloring; }; static_assert(DataClassCompliant, diff --git a/cpp/src/qdk/chemistry/data/lattice_graph.cpp b/cpp/src/qdk/chemistry/data/lattice_graph.cpp index f22dee8e7..77bd80730 100644 --- a/cpp/src/qdk/chemistry/data/lattice_graph.cpp +++ b/cpp/src/qdk/chemistry/data/lattice_graph.cpp @@ -3,11 +3,17 @@ // license information. #include +#include +#include #include #include +#include #include +#include #include #include +#include +#include #include #include #include @@ -60,10 +66,38 @@ LatticeGraph::LatticeGraph( _is_symmetric = _check_symmetry(adjacency_); } -LatticeGraph::LatticeGraph(Eigen::SparseMatrix adjacency) +LatticeGraph::LatticeGraph(Eigen::SparseMatrix adjacency, + std::optional coloring) : _num_sites(static_cast(adjacency.rows())), adjacency_(std::move(adjacency)), - _is_symmetric(_check_symmetry(adjacency_)) {} + _is_symmetric(_check_symmetry(adjacency_)), + _edge_coloring(std::move(coloring)) { +#ifndef NDEBUG + if (_edge_coloring.has_value()) { + // Verify every edge in the coloring exists in the adjacency matrix. + for (const auto& [edge, color] : *_edge_coloring) { + assert(edge.first < _num_sites && edge.second < _num_sites && + "coloring edge vertex out of range"); + assert(edge.first < edge.second && "coloring edge not canonical (i < j)"); + assert(adjacency_.coeff(static_cast(edge.first), + static_cast(edge.second)) != 0.0 && + "coloring contains edge not in adjacency matrix"); + } + // Verify every upper-triangular edge in adjacency appears in coloring. + for (int k = 0; k < adjacency_.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(adjacency_, k); it; + ++it) { + if (it.row() < it.col() && it.value() != 0.0) { + auto key = std::make_pair(static_cast(it.row()), + static_cast(it.col())); + assert(_edge_coloring->count(key) != 0 && + "adjacency edge missing from coloring"); + } + } + } + } +#endif +} LatticeGraph LatticeGraph::from_dense_matrix( const Eigen::MatrixXd& adjacency_matrix) { @@ -152,7 +186,8 @@ LatticeGraph LatticeGraph::chain(std::uint64_t n, bool periodic, double t) { Eigen::SparseMatrix adj(N, N); adj.setFromTriplets(triplets.begin(), triplets.end()); adj.makeCompressed(); - return LatticeGraph(std::move(adj)); + return LatticeGraph(std::move(adj), + chain_coloring(static_cast(N), periodic)); } LatticeGraph LatticeGraph::square(std::uint64_t nx, std::uint64_t ny, @@ -199,12 +234,13 @@ LatticeGraph LatticeGraph::square(std::uint64_t nx, std::uint64_t ny, Eigen::SparseMatrix adj(N, N); adj.setFromTriplets(triplets.begin(), triplets.end()); adj.makeCompressed(); - return LatticeGraph(std::move(adj)); + return LatticeGraph(std::move(adj), + square_coloring(Nx, Ny, periodic_x, periodic_y)); } LatticeGraph LatticeGraph::triangular(std::uint64_t nx, std::uint64_t ny, bool periodic_x, bool periodic_y, - double t) { + double t, int coloring_seed) { if (nx == 0 || ny == 0) { throw std::invalid_argument("triangular: nx and ny must be > 0."); } @@ -257,7 +293,10 @@ LatticeGraph LatticeGraph::triangular(std::uint64_t nx, std::uint64_t ny, Eigen::SparseMatrix adj(N, N); adj.setFromTriplets(triplets.begin(), triplets.end()); adj.makeCompressed(); - return LatticeGraph(std::move(adj)); + // No known deterministic coloring for triangular lattices with arbitrary + // periodic boundaries; use greedy with multiple trials instead. + return LatticeGraph(std::move(adj), + greedy_edge_coloring(adj, coloring_seed, 32)); } LatticeGraph LatticeGraph::honeycomb(std::uint64_t nx, std::uint64_t ny, @@ -309,11 +348,13 @@ LatticeGraph LatticeGraph::honeycomb(std::uint64_t nx, std::uint64_t ny, Eigen::SparseMatrix adj(N, N); adj.setFromTriplets(triplets.begin(), triplets.end()); adj.makeCompressed(); - return LatticeGraph(std::move(adj)); + return LatticeGraph(std::move(adj), + honeycomb_coloring(Nx, Ny, periodic_x, periodic_y)); } LatticeGraph LatticeGraph::kagome(std::uint64_t nx, std::uint64_t ny, - bool periodic_x, bool periodic_y, double t) { + bool periodic_x, bool periodic_y, double t, + int coloring_seed) { if (nx == 0 || ny == 0) { throw std::invalid_argument("kagome: nx and ny must be > 0."); } @@ -381,7 +422,211 @@ LatticeGraph LatticeGraph::kagome(std::uint64_t nx, std::uint64_t ny, Eigen::SparseMatrix adj(N, N); adj.setFromTriplets(triplets.begin(), triplets.end()); adj.makeCompressed(); - return LatticeGraph(std::move(adj)); + return LatticeGraph(std::move(adj), + greedy_edge_coloring(adj, coloring_seed, 32)); +} + +namespace detail { + +// Collect every undirected edge (i, j) with i < j from the adjacency matrix. +std::vector> undirected_edges( + const Eigen::SparseMatrix& adj) { + std::vector> edges; + edges.reserve(static_cast(adj.nonZeros()) / 2); + for (int k = 0; k < adj.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(adj, k); it; ++it) { + if (it.row() < it.col() && it.value() != 0.0) { + edges.emplace_back(static_cast(it.row()), + static_cast(it.col())); + } + } + } + return edges; +} + +} // namespace detail + +// Greedy edge coloring: place each edge in the lowest-index color whose +// vertices do not already touch that color. Optionally retry with shuffled +// edge orders and keep the result with fewest colors. +EdgeColoring greedy_edge_coloring(const Eigen::SparseMatrix& adj, + int seed, int trials) { + auto edges_in = detail::undirected_edges(adj); + if (edges_in.empty() || trials < 1) { + return {}; + } + + // Compute max degree to bound the colour count. + auto num_vertices = static_cast(adj.rows()); + std::vector degree(num_vertices, 0); + for (const auto& [u, v] : edges_in) { + ++degree[u]; + ++degree[v]; + } + int max_degree = *std::max_element(degree.begin(), degree.end()); + int max_colors = 2 * max_degree; // upper bound: 2*Δ - 1 rounded up + + EdgeColoring best; + int best_count = std::numeric_limits::max(); + std::mt19937 rng(static_cast(seed)); + + std::vector order(edges_in.size()); + std::iota(order.begin(), order.end(), 0); + + for (int trial = 0; trial < trials; ++trial) { + if (trial > 0) { + std::shuffle(order.begin(), order.end(), rng); + } + + EdgeColoring coloring; + // For each vertex, a bitset of colours already incident to it. + std::vector> vertex_used( + num_vertices, std::vector(max_colors, false)); + int max_color = -1; + + for (std::size_t pos : order) { + const auto& edge = edges_in[pos]; + const auto& used_i = vertex_used[edge.first]; + const auto& used_j = vertex_used[edge.second]; + int chosen = 0; + while (chosen < max_colors && (used_i[chosen] || used_j[chosen])) { + ++chosen; + } + coloring[edge] = chosen; + vertex_used[edge.first][chosen] = true; + vertex_used[edge.second][chosen] = true; + if (chosen > max_color) max_color = chosen; + } + + int distinct = max_color + 1; + if (distinct < best_count) { + best_count = distinct; + best = std::move(coloring); + } + } + return best; +} + +// Deterministic two-coloring of an open chain: edge (i, i+1) gets color i % 2. +// For periodic chains, even N keeps two colors, odd N requires a third for +// the wrap edge to satisfy the no-incident-same-color constraint. +EdgeColoring chain_coloring(std::int64_t n, bool periodic) { + EdgeColoring out; + for (std::int64_t i = 0; i + 1 < n; ++i) { + out[{static_cast(i), static_cast(i + 1)}] = + i % 2; + } + if (periodic && n > 2) { + int wrap_color = (n % 2 == 0) ? 1 : 2; // last edge color is (n-2)%2 + out[{0, static_cast(n - 1)}] = wrap_color; + } + return out; +} + +// Deterministic edge coloring for the square lattice. Horizontal and vertical +// edges live on disjoint axes; each axis can be 2-colored by alternating. +// With periodic boundaries, an odd extent on that axis forces a third color +// on its wrap edges. Total colors: 2 (open) up to 4 (both axes odd-periodic). +EdgeColoring square_coloring(std::int64_t Nx, std::int64_t Ny, bool periodic_x, + bool periodic_y) { + EdgeColoring out; + auto idx = [Nx](std::int64_t x, std::int64_t y) { + return static_cast(y * Nx + x); + }; + auto put = [&out](std::uint64_t a, std::uint64_t b, int c) { + auto edge = std::minmax(a, b); + out[{edge.first, edge.second}] = c; + }; + + // Horizontal edges use colors {0, 1}; vertical edges use {2, 3}. When a + // periodic dimension has odd extent the wrap edge needs its own color + // (4 for x-wrap parity-conflict, 5 for y-wrap parity-conflict). + for (std::int64_t y = 0; y < Ny; ++y) { + for (std::int64_t x = 0; x + 1 < Nx; ++x) { + put(idx(x, y), idx(x + 1, y), x % 2); + } + if (periodic_x && Nx > 2) { + int wrap_color = (Nx % 2 == 0) ? 1 : 4; + put(idx(Nx - 1, y), idx(0, y), wrap_color); + } + } + for (std::int64_t x = 0; x < Nx; ++x) { + for (std::int64_t y = 0; y + 1 < Ny; ++y) { + put(idx(x, y), idx(x, y + 1), 2 + y % 2); + } + if (periodic_y && Ny > 2) { + int wrap_color = (Ny % 2 == 0) ? 3 : 5; + put(idx(x, Ny - 1), idx(x, 0), wrap_color); + } + } + + // Compact the color labels so the result is in 0..(distinct-1). + std::map remap; + for (const auto& [edge, c] : out) { + remap.emplace(c, static_cast(remap.size())); + } + for (auto& [edge, c] : out) { + c = remap.at(c); + } + return out; +} + +// Deterministic 3-coloring for honeycomb lattice. The honeycomb has max +// degree 3 with three structurally distinct bond types: intra-cell (A–B), +// horizontal inter-cell (B–A right), vertical inter-cell (B–A up). +// Each bond type gets its own color, which is valid because no vertex +// is incident to two bonds of the same type. +EdgeColoring honeycomb_coloring(std::int64_t Nx, std::int64_t Ny, + bool periodic_x, bool periodic_y) { + EdgeColoring out; + auto idxA = [Nx](std::int64_t x, std::int64_t y) -> std::uint64_t { + return static_cast(2 * (y * Nx + x)); + }; + auto idxB = [Nx](std::int64_t x, std::int64_t y) -> std::uint64_t { + return static_cast(2 * (y * Nx + x) + 1); + }; + auto put = [&out](std::uint64_t a, std::uint64_t b, int c) { + auto edge = std::minmax(a, b); + out[{edge.first, edge.second}] = c; + }; + + for (std::int64_t y = 0; y < Ny; ++y) { + for (std::int64_t x = 0; x < Nx; ++x) { + // Intra-cell: color 0 + put(idxA(x, y), idxB(x, y), 0); + // Horizontal inter-cell: color 1 + if (x + 1 < Nx) { + put(idxB(x, y), idxA(x + 1, y), 1); + } else if (periodic_x) { + put(idxB(x, y), idxA(0, y), 1); + } + // Vertical inter-cell: color 2 + if (y + 1 < Ny) { + put(idxB(x, y), idxA(x, y + 1), 2); + } else if (periodic_y) { + put(idxB(x, y), idxA(x, 0), 2); + } + } + } + return out; +} + +EdgeColoring trivial_edge_coloring(const Eigen::SparseMatrix& adj) { + EdgeColoring out; + int color = 0; + for (int k = 0; k < adj.outerSize(); ++k) { + for (Eigen::SparseMatrix::InnerIterator it(adj, k); it; ++it) { + if (it.row() < it.col() && it.value() != 0.0) { + out[{static_cast(it.row()), + static_cast(it.col())}] = color++; + } + } + } + return out; +} + +const std::optional& LatticeGraph::edge_coloring() const { + return _edge_coloring; } bool LatticeGraph::_check_symmetry(const Eigen::SparseMatrix& mat) { diff --git a/cpp/tests/test_lattice_graph.cpp b/cpp/tests/test_lattice_graph.cpp index a2a75ece1..7a69031a0 100644 --- a/cpp/tests/test_lattice_graph.cpp +++ b/cpp/tests/test_lattice_graph.cpp @@ -500,3 +500,132 @@ TEST_F(LatticeGraphTest, KagomeConstructor) { kg_pxy.adjacency_matrix().isApprox(expected_pxy.adjacency_matrix())); } } + +// Coloring helper: confirm no two same-color edges share a vertex. +static void check_valid_edge_coloring(const EdgeColoring& coloring) { + std::map> incident; + for (const auto& [edge, color] : coloring) { + auto [a, b] = edge; + EXPECT_EQ(incident[a].count(color), 0u) + << "vertex " << a << " has two edges of color " << color; + EXPECT_EQ(incident[b].count(color), 0u) + << "vertex " << b << " has two edges of color " << color; + incident[a].insert(color); + incident[b].insert(color); + } +} + +TEST_F(LatticeGraphTest, ColorCount) { + auto chain_open = LatticeGraph::chain(5, false); + ASSERT_TRUE(chain_open.edge_coloring().has_value()); + std::set chain_open_colors; + for (const auto& [e, c] : *chain_open.edge_coloring()) + chain_open_colors.insert(c); + // Open chain uses exactly 2 colors (alternating) + EXPECT_EQ(chain_open_colors.size(), 2u); + + auto chain_periodic_even = LatticeGraph::chain(6, true); + ASSERT_TRUE(chain_periodic_even.edge_coloring().has_value()); + std::set chain_even_colors; + for (const auto& [e, c] : *chain_periodic_even.edge_coloring()) + chain_even_colors.insert(c); + // Even periodic chain uses exactly 2 colors + EXPECT_EQ(chain_even_colors.size(), 2u); + + // Odd periodic chain needs 3 colors + auto chain_periodic_odd = LatticeGraph::chain(5, true); + ASSERT_TRUE(chain_periodic_odd.edge_coloring().has_value()); + std::set chain_odd_colors; + for (const auto& [e, c] : *chain_periodic_odd.edge_coloring()) + chain_odd_colors.insert(c); + EXPECT_EQ(chain_odd_colors.size(), 3u); + + auto hc = LatticeGraph::honeycomb(3, 3, true, true); + ASSERT_TRUE(hc.edge_coloring().has_value()); + // Honeycomb uses exactly 3 colors. + std::set hc_colors; + for (const auto& [e, c] : *hc.edge_coloring()) hc_colors.insert(c); + EXPECT_EQ(hc_colors.size(), 3u); +} + +TEST_F(LatticeGraphTest, EdgeColoringIsValid) { + // For every factory-built lattice, the coloring must be present and valid. + std::vector graphs; + graphs.emplace_back(LatticeGraph::chain(8, true)); + graphs.emplace_back(LatticeGraph::square(4, 4, true, true)); + graphs.emplace_back(LatticeGraph::triangular(4, 4, true, true)); + graphs.emplace_back(LatticeGraph::honeycomb(3, 3, true, true)); + graphs.emplace_back(LatticeGraph::kagome(2, 3, true, true)); + + for (const auto& g : graphs) { + ASSERT_TRUE(g.edge_coloring().has_value()); + check_valid_edge_coloring(*g.edge_coloring()); + } + + // Custom adjacency: no coloring by default. + using Edge = std::pair; + std::map custom_edges = { + {{0, 1}, 1.0}, {{1, 2}, 1.0}, {{2, 3}, 1.0}, {{3, 0}, 1.0}}; + LatticeGraph custom(custom_edges, 4); + EXPECT_FALSE(custom.edge_coloring().has_value()); +} + +TEST_F(LatticeGraphTest, EdgeColoringIsImmutable) { + auto sq = LatticeGraph::square(4, 4, true, true); + const auto& first = sq.edge_coloring(); + const auto& second = sq.edge_coloring(); + EXPECT_EQ(&first, &second); +} + +TEST_F(LatticeGraphTest, TrivialEdgeColoring) { + // Build a small graph and check trivial coloring assigns unique colors. + auto chain = LatticeGraph::chain(5); + const auto& adj = chain.sparse_adjacency_matrix(); + auto coloring = trivial_edge_coloring(adj); + + // 4 edges in a 5-site open chain + EXPECT_EQ(coloring.size(), 4u); + + // Each edge should have a distinct color 0..3 + std::set colors; + for (const auto& [edge, c] : coloring) { + colors.insert(c); + } + EXPECT_EQ(colors.size(), 4u); + EXPECT_EQ(*colors.begin(), 0); + EXPECT_EQ(*colors.rbegin(), 3); + + // Also valid as an edge coloring (trivially, since all colors differ) + check_valid_edge_coloring(coloring); +} + +TEST_F(LatticeGraphTest, TrivialEdgeColoringEmpty) { + // Single-site graph has no edges → empty coloring + auto single = LatticeGraph::chain(1); + auto coloring = trivial_edge_coloring(single.sparse_adjacency_matrix()); + EXPECT_TRUE(coloring.empty()); +} + +TEST_F(LatticeGraphTest, ColoringSeedDeterministic) { + // Same seed → same coloring. + auto tri_a = LatticeGraph::triangular(3, 3, true, true, 1.0, 42); + auto tri_b = LatticeGraph::triangular(3, 3, true, true, 1.0, 42); + ASSERT_TRUE(tri_a.edge_coloring().has_value()); + ASSERT_TRUE(tri_b.edge_coloring().has_value()); + EXPECT_EQ(*tri_a.edge_coloring(), *tri_b.edge_coloring()); + + // Different seed may produce a different coloring (or same, but at + // least both must be valid). + auto tri_c = LatticeGraph::triangular(3, 3, true, true, 1.0, 99); + ASSERT_TRUE(tri_c.edge_coloring().has_value()); + check_valid_edge_coloring(*tri_c.edge_coloring()); +} + +TEST_F(LatticeGraphTest, KagomeColoringSeed) { + auto kg_a = LatticeGraph::kagome(2, 2, true, true, 1.0, 7); + auto kg_b = LatticeGraph::kagome(2, 2, true, true, 1.0, 7); + ASSERT_TRUE(kg_a.edge_coloring().has_value()); + ASSERT_TRUE(kg_b.edge_coloring().has_value()); + EXPECT_EQ(*kg_a.edge_coloring(), *kg_b.edge_coloring()); + check_valid_edge_coloring(*kg_a.edge_coloring()); +} diff --git a/docs/source/user/comprehensive/algorithms/hamiltonian_unitary_builder.rst b/docs/source/user/comprehensive/algorithms/hamiltonian_unitary_builder.rst index 5861ff450..2222a3a9a 100644 --- a/docs/source/user/comprehensive/algorithms/hamiltonian_unitary_builder.rst +++ b/docs/source/user/comprehensive/algorithms/hamiltonian_unitary_builder.rst @@ -143,6 +143,72 @@ When both ``num_divisions`` and ``target_accuracy`` are specified, the builder u - Coefficient threshold below which Pauli terms are discarded. Default is 1e-12. +Consuming term partitions +------------------------- + +When the input :class:`~qdk_chemistry.data.QubitHamiltonian` carries a populated :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition`, the Trotter builder consumes it directly: + +* :class:`~qdk_chemistry.data.LayeredPartition` (group → layer → index) is used as-is — the outer level controls the Strang/Suzuki splitting and each inner layer becomes one parallelisable sub-step. +* :class:`~qdk_chemistry.data.FlatPartition` (group → index) is interpreted as a layered partition with one layer per group. + +In both cases groups are sorted by ascending layer count so that the smallest groups sit on the outside of the Strang/Suzuki splitting, which maximises merging at recursion boundaries. +This typically reduces the number of distinct exponentials per Trotter step and the saving compounds through the recursion at higher orders. + +When ``term_partition is None`` each Pauli term is exponentiated as its own group. +Pre-populate the partition using the :ref:`term_grouper algorithm ` or one of the :ref:`spin model Hamiltonian builders ` to enable group-aware scheduling. + +For lattice models, the edge coloring stored on :class:`~qdk_chemistry.data.LatticeGraph` feeds directly into the partition: edges of the same color have disjoint qubit supports and form a single parallelisable layer. +The :ref:`model Hamiltonian builders ` consume this coloring automatically, so no manual geometry handling is required. + +Example:: + + from qdk_chemistry.data import LatticeGraph + from qdk_chemistry.utils.model_hamiltonians import create_ising_hamiltonian + from qdk_chemistry.algorithms import registry + + # 4-site open Ising chain: H = Σ Z_i Z_{i+1} + 0.5 Σ X_i + graph = LatticeGraph.chain(4, periodic=False) + hamiltonian = create_ising_hamiltonian(graph, j=1.0, h=0.5) + + # The edge coloring partitions ZZ terms into two layers by color: + # group 0 (field): 1 layer → [X₀, X₁, X₂, X₃] + # group 1 (ZZ): 2 layers → [{Z₀Z₁, Z₂Z₃}, {Z₁Z₂}] + print(hamiltonian.term_partition) + # LayeredPartition(strategy='geometry_coloring', num_groups=2) + + # Second-order Trotter with 1 division produces the Strang splitting: + # S₂(t) = e^{fields·t/2} e^{ZZ_layer0·t} e^{ZZ_layer1·t} + # e^{fields·t/2} + # where same-layer ZZ terms (e.g. Z₀Z₁ and Z₂Z₃) have disjoint + # qubit support and are exponentiated independently within one step. + trotter = registry.create("time_evolution_builder", "trotter") + trotter.settings().update({"order": 2, "num_divisions": 1}) + evolution = trotter.run(hamiltonian, time=1.0) + container = evolution.get_container() + + # The grouped schedule uses 11 exponentiated terms per step, + # vs. 13 for the ungrouped fallback — a 15% reduction that + # compounds at higher Suzuki orders. + print(f"{len(container.step_terms)} terms per Trotter step") + for term in container.step_terms: + label = ['I'] * 4 + for q, p in term.pauli_term.items(): + label[q] = p + print(f" exp(-i * {term.angle:+.4f} * {''.join(reversed(label))})") + # Output: + # exp(-i * +0.2500 * IIIX) + # exp(-i * +0.2500 * IIXI) + # exp(-i * +0.2500 * IXII) + # exp(-i * +0.2500 * XIII) + # exp(-i * +1.0000 * IIZZ) + # exp(-i * +1.0000 * ZZII) + # exp(-i * +1.0000 * IZZI) + # exp(-i * +0.2500 * IIIX) + # exp(-i * +0.2500 * IIXI) + # exp(-i * +0.2500 * IXII) + # exp(-i * +0.2500 * XIII) + + Related classes --------------- diff --git a/docs/source/user/comprehensive/algorithms/index.rst b/docs/source/user/comprehensive/algorithms/index.rst index 6eb2ee9ea..7f7c78f3e 100644 --- a/docs/source/user/comprehensive/algorithms/index.rst +++ b/docs/source/user/comprehensive/algorithms/index.rst @@ -86,6 +86,26 @@ The following table summarizes the available algorithm classes in QDK/Chemistry - Circuit → CircuitExecutorData +.. _algorithms-term-grouper: + +Term grouper +------------ + +The ``term_grouper`` algorithm type partitions the Pauli terms of a :class:`~qdk_chemistry.data.QubitHamiltonian` into algorithm-relevant subsets and stores the result on :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition`. +A grouper consumes a ``QubitHamiltonian`` and returns a *new* ``QubitHamiltonian`` whose ``term_partition`` field is populated; the input is not mutated. + +Strategies include full commutation grouping, qubit-wise commutation grouping, and trivial (identity) grouping. +Use ``registry.available("term_grouper")`` to list implementations. + +Example:: + + from qdk_chemistry.algorithms import registry + + grouper = registry.create("term_grouper", "qubit_wise_commuting") + grouped = grouper.run(qubit_hamiltonian) + grouped.term_partition # FlatPartition(strategy="qubit_wise_commuting", ...) + + Discovering implementations --------------------------- diff --git a/docs/source/user/comprehensive/data/index.rst b/docs/source/user/comprehensive/data/index.rst index e5cbd16c4..94270438f 100644 --- a/docs/source/user/comprehensive/data/index.rst +++ b/docs/source/user/comprehensive/data/index.rst @@ -72,3 +72,35 @@ The following table summarizes the available data classes in QDK/Chemistry and t * - :doc:`Circuit ` - Quantum circuit (OpenQASM, Q#, QIR, Qiskit) - :doc:`StatePreparation <../algorithms/state_preparation>`, User input + +QubitHamiltonian and term partitions +------------------------------------ + +A :class:`~qdk_chemistry.data.QubitHamiltonian` carries an optional :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` field describing how its Pauli terms are organised into algorithm-relevant subsets. +The partition is index-based — it stores indices into :attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings` — so it serialises cheaply alongside the Hamiltonian. + +The partition is *optional* metadata — ``term_partition is None`` means the partition has not been computed for this Hamiltonian. +Transformations that change term ordering or qubit support (for example :meth:`~qdk_chemistry.data.QubitHamiltonian.to_interleaved`) reset the partition to ``None`` on the new instance. + +Algorithms that consume a partition treat its presence as an explicit signal to exploit it — for example, the :doc:`Trotter time-evolution builder <../algorithms/time_evolution_builder>` reads ``term_partition`` and uses it for schedule-level Suzuki recursion and reduction. + +FlatPartition +~~~~~~~~~~~~~ + +:class:`~qdk_chemistry.data.FlatPartition` stores a single-level grouping: each group is a tuple of term indices. +It is suitable for algorithms that only need to know which terms belong together, such as qubit-wise commuting measurement grouping in :class:`~qdk_chemistry.algorithms.QdkEnergyEstimator`. + +The ``groups`` field is a tuple of tuples: ``((idx0, idx1, ...), (idx2, ...), ...)``. +Each inner tuple lists the indices of terms in :attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings` that belong to that group. + +LayeredPartition +~~~~~~~~~~~~~~~~ + +:class:`~qdk_chemistry.data.LayeredPartition` stores a two-level hierarchy: groups contain parallelisable layers, and each layer contains term indices. +It is suitable for Trotter-style decompositions where the outer level controls Strang/Suzuki splitting order and each inner layer groups operators with disjoint qubit supports that can be applied simultaneously. + +The ``groups`` field is a nested tuple: ``(((idx0, idx1), (idx2,)), ...)``. +The outer level is groups, the middle level is layers within a group, and the innermost level is term indices. + +Both classes carry a ``strategy`` label (e.g. ``"geometry_coloring"``, ``"qubit_wise_commuting"``) identifying how the partition was produced. +They serialise as part of :class:`~qdk_chemistry.data.QubitHamiltonian` in both JSON and HDF5 formats. diff --git a/docs/source/user/comprehensive/data/lattice_graph.rst b/docs/source/user/comprehensive/data/lattice_graph.rst index fa1391587..f6119ba15 100644 --- a/docs/source/user/comprehensive/data/lattice_graph.rst +++ b/docs/source/user/comprehensive/data/lattice_graph.rst @@ -306,6 +306,16 @@ For detailed information about serialization in QDK/Chemistry, see the :doc:`Ser :start-after: # start-cell-serialization :end-before: # end-cell-serialization +Edge coloring +------------- + +The ``edge_coloring`` property returns an optional ``dict[tuple[int, int], int]`` that assigns a color index to each undirected edge such that edges sharing a vertex receive distinct colors. +Factory methods for recognised topologies (chain, square, honeycomb) pre-populate this with a deterministic optimal coloring; triangular and kagome lattices use a greedy heuristic. +Custom lattices built from raw adjacency matrices have ``edge_coloring`` set to ``None`` — callers can compute and supply their own coloring. + +This coloring is the topological ingredient that powers geometry-aware Trotter scheduling: edges of the same color have disjoint qubit supports, so their Pauli exponentials can be applied in parallel inside one Trotter step. +The :doc:`spin model Hamiltonian builders <../model_hamiltonians>` consume the coloring automatically when ``include_term_groups=True`` and store the result on :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition`. + Related classes --------------- diff --git a/docs/source/user/comprehensive/model_hamiltonians.rst b/docs/source/user/comprehensive/model_hamiltonians.rst index 0c1015d75..28e529318 100644 --- a/docs/source/user/comprehensive/model_hamiltonians.rst +++ b/docs/source/user/comprehensive/model_hamiltonians.rst @@ -271,6 +271,22 @@ The transverse-field Ising model is a special case of the Heisenberg model with :start-after: # start-cell-create-ising :end-before: # end-cell-create-ising +.. _model-term-partition: + +Geometry-aware term grouping +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Both :func:`~qdk_chemistry.utils.model_hamiltonians.create_heisenberg_hamiltonian` and :func:`~qdk_chemistry.utils.model_hamiltonians.create_ising_hamiltonian` accept an ``include_term_groups`` flag (default ``True``). +When enabled, the builder consults the lattice's edge coloring and stores the resulting group-and-layer structure on :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` as a :class:`~qdk_chemistry.data.LayeredPartition` with ``strategy="geometry_coloring"``: + +* each *group* corresponds to one interaction type (``XX``, ``YY``, ``ZZ``) or one external-field direction (``X``, ``Y``, ``Z``); +* each *layer* within a coupling group is a set of edges of the same color, which by construction have disjoint qubit supports and can be applied in parallel. + +Downstream consumers — most importantly the :doc:`Trotter time-evolution builder ` — read ``term_partition`` automatically and use it to schedule fewer sequential exponentials per Trotter step. +No manual geometry boilerplate is required at the call site. + +Pass ``include_term_groups=False`` to skip this step and obtain a Hamiltonian with ``term_partition is None`` (useful for benchmarking or when a different partition is desired). + Parameter flexibility --------------------- diff --git a/docs/source/user/features.rst b/docs/source/user/features.rst index 7486b07b6..a08730ccb 100644 --- a/docs/source/user/features.rst +++ b/docs/source/user/features.rst @@ -98,7 +98,9 @@ This generally involves the following steps: The target operator (e.g., the electronic Hamiltonian) is decomposed into a sum of measurable components, often expressed in terms of Pauli operators. This decomposition facilitates efficient measurement on quantum hardware. Starting from a qubit-mapped Hamiltonian, this task generally involves grouping Pauli terms into sets of mutually commuting operators that can be measured simultaneously. - QDK/Chemistry provides utilities for Pauli grouping by qubit-wise commutativity via the :class:`~qdk_chemistry.data.QubitHamiltonian.group_commuting` method. + QDK/Chemistry provides utilities for Pauli grouping by qubit-wise commutativity via the ``term_grouper`` algorithm + (e.g., ``registry.create("term_grouper", "qubit_wise_commuting")``), which attaches a + :class:`~qdk_chemistry.data.FlatPartition` to a :class:`~qdk_chemistry.data.QubitHamiltonian` for downstream consumers. 2. **Circuit Execution and Measurement**: Given the state preparation circuit and the decomposed operator, quantum circuits are executed on quantum hardware or simulators to obtain measurement outcomes. 3. **Classical Post-Processing**: diff --git a/python/pyproject.toml b/python/pyproject.toml index 98612d323..4dec787cc 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -47,7 +47,7 @@ requires-python = ">=3.10" [project.optional-dependencies] all = [ - "qdk-chemistry[coverage,dev,docs,jupyter,plugins,qiskit-extras,openfermion-extras]" + "qdk-chemistry[coverage,dev,docs,jupyter,plugins,qiskit-extras,openfermion-extras,networkx-extras]" ] coverage = [ "coverage", @@ -93,6 +93,9 @@ qiskit-extras = [ openfermion-extras = [ "openfermion>=1.0.0" ] +networkx-extras = [ + "networkx>=3.0" +] [project.urls] Documentation = "https://microsoft.github.io/qdk-chemistry" diff --git a/python/src/pybind11/data/lattice_graph.cpp b/python/src/pybind11/data/lattice_graph.cpp index cc68b530c..d955125e3 100644 --- a/python/src/pybind11/data/lattice_graph.cpp +++ b/python/src/pybind11/data/lattice_graph.cpp @@ -59,6 +59,31 @@ void bind_lattice_graph(pybind11::module &m) { using qdk::chemistry::python::utils::bind_getter_as_property; + // Module-level free function: trivial_edge_coloring + m.def( + "trivial_edge_coloring", + [](const Eigen::SparseMatrix &adj) -> py::dict { + auto coloring = trivial_edge_coloring(adj); + py::dict out; + for (const auto &[edge, color] : coloring) { + out[py::make_tuple(edge.first, edge.second)] = color; + } + return out; + }, + R"( +Trivial edge coloring where every edge receives a unique color. + +Useful as a fallback when no topology-aware coloring is available. + +Args: + adj (scipy.sparse matrix): Sparse adjacency matrix of the graph. + +Returns: + dict[tuple[int, int], int]: Mapping of canonical edges to distinct + color labels 0, 1, 2, ... in iteration order. +)", + py::arg("adj")); + py::class_ lattice_graph( m, "LatticeGraph", R"( Lattice graph defining the connectivity and geometry of a model Hamiltonian. @@ -216,6 +241,30 @@ Check whether two sites are connected by an edge. )", py::arg("i"), py::arg("j")); + lattice_graph.def_property_readonly( + "edge_coloring", + [](const LatticeGraph &self) -> std::optional { + const auto &coloring = self.edge_coloring(); + if (!coloring.has_value()) { + return std::nullopt; + } + py::dict out; + for (const auto &[edge, color] : *coloring) { + out[py::make_tuple(edge.first, edge.second)] = color; + } + return out; + }, + R"( +Edge coloring stored at construction time, or ``None``. + +Factory methods for recognised topologies pre-populate this field. +Returns ``None`` for lattices constructed without a coloring. + +Returns: + dict[tuple[int, int], int] | None: Mapping of canonical edges (``i < j``) + to non-negative color labels, or ``None``. +)"); + // Static factory methods lattice_graph.def_static("chain", &LatticeGraph::chain, R"( Create a one-dimensional chain lattice. @@ -314,6 +363,7 @@ With periodic boundary conditions (using the 3x3 example above): periodic_y (bool, optional): If True, apply periodic boundary conditions along y. Requires ny >= 2. Defaults to False. t (float, optional): Hopping weight for all edges. Defaults to 1.0. + coloring_seed (int, optional): PRNG seed for greedy edge coloring. Defaults to 0. Returns: LatticeGraph: Triangular lattice with nx * ny sites. @@ -323,7 +373,8 @@ With periodic boundary conditions (using the 3x3 example above): )", py::arg("nx"), py::arg("ny"), py::arg("periodic_x") = false, - py::arg("periodic_y") = false, py::arg("t") = 1.0); + py::arg("periodic_y") = false, py::arg("t") = 1.0, + py::arg("coloring_seed") = 0); lattice_graph.def_static("honeycomb", &LatticeGraph::honeycomb, R"( Create a two-dimensional honeycomb lattice. @@ -408,6 +459,7 @@ With periodic boundary conditions (using the 3x2 example above): periodic_y (bool, optional): If True, apply periodic boundary conditions along y. Requires ny >= 2. Defaults to False. t (float, optional): Hopping weight for all edges. Defaults to 1.0. + coloring_seed (int, optional): PRNG seed for greedy edge coloring. Defaults to 0. Returns: LatticeGraph: Kagome lattice with 3 * nx * ny sites. @@ -417,7 +469,8 @@ With periodic boundary conditions (using the 3x2 example above): )", py::arg("nx"), py::arg("ny"), py::arg("periodic_x") = false, - py::arg("periodic_y") = false, py::arg("t") = 1.0); + py::arg("periodic_y") = false, py::arg("t") = 1.0, + py::arg("coloring_seed") = 0); lattice_graph.def("__repr__", [](const LatticeGraph &self) { return " list[QubitHamiltonian]: + """Resolve the Hamiltonian into simultaneously-measurable groups. + + If ``qubit_hamiltonian`` carries a + :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition`, the + partition is consumed and each group becomes one measurement circuit. + The groups must be compatible with the measurement backend — at + present, each group must be qubit-wise commuting so that a single + Pauli basis suffices. + + When no partition is present, each Pauli term is measured + individually. + + Args: + qubit_hamiltonian: The Hamiltonian to partition for measurement. + + Returns: + A list of ``QubitHamiltonian`` objects, one per measurement group. + + """ + partition = qubit_hamiltonian.term_partition + if partition is not None: + from qdk_chemistry.data.term_partition import FlatPartition # noqa: PLC0415 + + if not isinstance(partition, FlatPartition): + raise TypeError( + f"QdkEnergyEstimator expects a FlatPartition for measurement grouping, " + f"got {type(partition).__name__}." + ) + Logger.info( + f"EnergyEstimator: consuming term_partition " + f"(strategy={partition.strategy!r}, num_groups={partition.num_groups})." + ) + return [ + QubitHamiltonian( + pauli_strings=[qubit_hamiltonian.pauli_strings[i] for i in group], + coefficients=np.asarray([qubit_hamiltonian.coefficients[i] for i in group]), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + ) + for group in partition.groups + ] + + Logger.info("EnergyEstimator: no term_partition; measuring each term individually.") + return [ + QubitHamiltonian( + pauli_strings=[label], + coefficients=np.asarray([coeff]), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + ) + for label, coeff in zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=True) + ] + @staticmethod def _create_measurement_circuits(circuit: Circuit, grouped_hamiltonians: list[QubitHamiltonian]) -> list[Circuit]: """Create measurement circuits for each QubitHamiltonian. diff --git a/python/src/qdk_chemistry/algorithms/hamiltonian_unitary_builder/time_evolution/trotter.py b/python/src/qdk_chemistry/algorithms/hamiltonian_unitary_builder/time_evolution/trotter.py index 649b02068..58d80d008 100644 --- a/python/src/qdk_chemistry/algorithms/hamiltonian_unitary_builder/time_evolution/trotter.py +++ b/python/src/qdk_chemistry/algorithms/hamiltonian_unitary_builder/time_evolution/trotter.py @@ -27,7 +27,13 @@ trotter_steps_commutator, trotter_steps_naive, ) -from qdk_chemistry.data import QubitHamiltonian, UnitaryRepresentation +from qdk_chemistry.data import ( + FlatPartition, + LayeredPartition, + QubitHamiltonian, + TermPartition, + UnitaryRepresentation, +) from qdk_chemistry.data.unitary_representation.containers.pauli_product_formula import ( ExponentiatedPauliTerm, PauliProductFormulaContainer, @@ -131,22 +137,18 @@ def __init__( * ``"naive"``: uses the triangle-inequality bound. :math:`N = \lceil (\sum_j|\alpha_j|)^{2}t^{2}/\epsilon \rceil` + When the input :class:`~qdk_chemistry.data.QubitHamiltonian` carries a populated + :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition`, the builder consumes it + directly for schedule-level grouping. When no partition is present, each Pauli term + is exponentiated as its own group. + Args: - order: The order of the Trotter decomposition (currently only - first order is supported). Defaults to 1. + order: Trotter decomposition order (1, 2, or any positive even integer). Defaults to 1. time: The evolution time. Defaults to 0.0. - target_accuracy: Target accuracy for automatic step computation. - Must be positive to enable automatic computation. - Use 0.0 (default) to disable. - num_divisions: Explicit number of divisions within a Trotter - step. When both *num_divisions* and *target_accuracy* - are given the larger value is used. Use 0 (default) for - automatic determination. - error_bound: Strategy for computing the Trotter error bound - when *target_accuracy* is set. Either ``"commutator"`` - (default, tighter) or ``"naive"``. - weight_threshold: Absolute threshold for filtering small - Hamiltonian coefficients. Defaults to 1e-12. + target_accuracy: Target accuracy for auto step computation. Use 0.0 (default) to disable. + num_divisions: Divisions per Trotter step. Max of this and auto value is used. Defaults to 0. + error_bound: Error bound strategy: ``"commutator"`` (default) or ``"naive"``. + weight_threshold: Threshold for filtering small coefficients. Defaults to 1e-12. optimize_term_ordering: Whether to group commuting terms and execute them in parallel. Defaults to False. power: The power to raise the unitary to. Defaults to 1. power_strategy: Strategy for U^power: ``"rescale"`` scales @@ -216,10 +218,10 @@ def _trotter( delta = time / num_divisions optimize_term_ordering = self._settings.get("optimize_term_ordering") + if optimize_term_ordering: + Logger.warn("optimize_term_ordering is deprecated; use QubitHamiltonian.term_partition instead.") - terms = self._decompose_trotter_step( - qubit_hamiltonian, time=delta, atol=weight_threshold, optimize_term_ordering=optimize_term_ordering - ) + terms = self._decompose_trotter_step(qubit_hamiltonian, time=delta, atol=weight_threshold) num_qubits = qubit_hamiltonian.num_qubits @@ -274,7 +276,6 @@ def _decompose_trotter_step( time: float, *, atol: float = 1e-12, - optimize_term_ordering: bool = False, ) -> list[ExponentiatedPauliTerm]: """Decompose a single Trotter step into exponentiated Pauli terms. @@ -284,10 +285,7 @@ def _decompose_trotter_step( Args: qubit_hamiltonian: The qubit Hamiltonian to be decomposed. time: The evolution time for the single step. - atol: Absolute tolerance for filtering small coefficients. - optimize_term_ordering: Whether to group commuting terms together - and further subgroup into parallelizable layers. Returns: A list of ``ExponentiatedPauliTerm`` representing the decomposed terms. @@ -306,7 +304,11 @@ def _decompose_trotter_step( return terms order = self._settings.get("order") - grouped_hamiltonians = self._group_terms(qubit_hamiltonian, optimize_term_ordering=optimize_term_ordering) + grouped_hamiltonians = self._group_terms(qubit_hamiltonian) + + if not grouped_hamiltonians: + Logger.warn("Term partition produced no groups; returning empty term list.") + return terms if order == 1: for group in grouped_hamiltonians: @@ -321,182 +323,154 @@ def _decompose_trotter_step( # order = 2 or order = 2k with k>1 else: - # \prod e^{-iH_i t/(2n)} for all Hamiltonian terms groups except the last one - # which is executed in the middle as e^{-iH_i t/n} - terms_without_last_group = [] - for group in grouped_hamiltonians[:-1]: - for subgroup in group: - terms_without_last_group.extend( - self._exponentiate_commuting( - subgroup, - time=time / 2, - atol=atol, - ) - ) - - terms.extend(terms_without_last_group) - # e^{-iH_i t/n} for all terms in the last group - for subgroup in grouped_hamiltonians[-1]: - terms.extend( - self._exponentiate_commuting( - subgroup, - time=time, - atol=atol, - ) - ) - terms.extend(terms_without_last_group[::-1]) # reverse all but the last group and append - - # Construct order 2k formula bottom up dynamic-programming style + # Build an abstract schedule of (time_fraction, group_index) entries. + # The Strang splitting puts group 0..L-2 at half-time on the outside + # and group L-1 at full-time in the middle: + # S2(t) = [t/2 * G0, ..., t/2 * G_{L-2}, t * G_{L-1}, t/2 * G_{L-2}, ..., t/2 * G0] + n_groups = len(grouped_hamiltonians) + schedule: list[tuple[float, int]] = [] + for g in range(n_groups - 1): + schedule.append((0.5, g)) + schedule.append((1.0, n_groups - 1)) + for g in range(n_groups - 2, -1, -1): + schedule.append((0.5, g)) + + # Apply Suzuki recursion at the schedule level for order > 2 if order > 2 and order % 2 == 0: - step_terms = terms.copy() for k in range(2, int(order / 2) + 1): u_k = 1 / (4 - 4 ** (1 / (2 * k - 1))) - new_terms = [] - - # S_{2k-2}(u_k t)^2 = S_{2k-2}(u_k t) S_{2k-2}(u_k t) + new_schedule: list[tuple[float, int]] = [] + # S_{2k}(t) = S_{2k-2}(u_k t)^2 S_{2k-2}((1-4u_k) t) S_{2k-2}(u_k t)^2 for _ in range(2): - for term in step_terms: - new_terms.append( - ExponentiatedPauliTerm( - pauli_term=term.pauli_term, - angle=term.angle * u_k, - ) - ) - # S_{2k-2}((1-4u_k) t) - for term in step_terms: - new_terms.append( - ExponentiatedPauliTerm( - pauli_term=term.pauli_term, - angle=term.angle * (1 - 4 * u_k), - ) - ) - - # S_{2k-2}(u_k t)^2 = S_{2k-2}(u_k t) S_{2k-2}(u_k t) + for frac, g in schedule: + new_schedule.append((frac * u_k, g)) + for frac, g in schedule: + new_schedule.append((frac * (1 - 4 * u_k), g)) for _ in range(2): - for term in step_terms: - new_terms.append( - ExponentiatedPauliTerm( - pauli_term=term.pauli_term, - angle=term.angle * u_k, - ) - ) - - step_terms = new_terms - terms = step_terms - - # Merge adjacent terms with the same pauli_term by summing angles. - merged_terms: list[ExponentiatedPauliTerm] = [] - for term in terms: - if merged_terms and merged_terms[-1].pauli_term == term.pauli_term: - last = merged_terms[-1] - merged_terms[-1] = ExponentiatedPauliTerm( - pauli_term=last.pauli_term, - angle=last.angle + term.angle, - ) + for frac, g in schedule: + new_schedule.append((frac * u_k, g)) + schedule = new_schedule + + # Reduce the schedule: merge consecutive entries with the same group index + reduced: list[tuple[float, int]] = [] + for frac, g in schedule: + if reduced and reduced[-1][1] == g: + reduced[-1] = (reduced[-1][0] + frac, g) else: - merged_terms.append(term) - terms = merged_terms + reduced.append((frac, g)) + schedule = reduced + + # Expand the schedule into exponentiated Pauli terms + for frac, g in schedule: + for subgroup in grouped_hamiltonians[g]: + terms.extend( + self._exponentiate_commuting( + subgroup, + time=time * frac, + atol=atol, + ) + ) return terms def _group_terms( self, qubit_hamiltonian: QubitHamiltonian, - *, - optimize_term_ordering: bool = True, ) -> list[list[QubitHamiltonian]]: - """Group Hamiltonian terms into commuting and concurrent sets. + """Group Hamiltonian terms for Trotter decomposition. - When *optimize_term_ordering* is ``True``: - 1. Partition using :meth:`QubitHamiltonian.group_commuting`. - 2. Within each group, merge terms with identical Pauli strings - by summing their coefficients. - 3. Within each group, split terms into parallelizable layers - (terms whose Pauli supports are disjoint). - 4. Move the group with the most multi-qubit terms to the end. + When the Hamiltonian carries a populated + :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition`, it is + consumed directly. Both :class:`~qdk_chemistry.data.LayeredPartition` + and :class:`~qdk_chemistry.data.FlatPartition` are accepted. - When ``False``, each Pauli string is returned as its own + When no partition is present, each Pauli term is treated as its own single-term group with no reordering. Args: qubit_hamiltonian: The qubit Hamiltonian to group. - optimize_term_ordering: Whether to group commuting terms together. Returns: A list of groups, where each group is a list of - ``QubitHamiltonian`` sub-groups (parallelizable layers). + ``QubitHamiltonian`` sub-groups (parallelisable layers). """ - if not optimize_term_ordering: - return [ - [ - QubitHamiltonian( - pauli_strings=[label], - coefficients=[coeff], - encoding=qubit_hamiltonian.encoding, - ) - ] - for label, coeff in zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=True) + partition = qubit_hamiltonian.term_partition + if partition is not None: + Logger.info( + f"Trotter: consuming QubitHamiltonian.term_partition " + f"(strategy={partition.strategy!r}, num_groups={partition.num_groups})." + ) + return self._groups_from_partition(qubit_hamiltonian, partition) + + Logger.info("Trotter: no term_partition present; treating each Pauli term as its own group.") + return [ + [ + QubitHamiltonian( + pauli_strings=[label], + coefficients=[coeff], + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + ) ] + for label, coeff in zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=True) + ] - # Sort terms so that Pauli strings acting on more qubits appear first. - num_non_identity = [sum(c != "I" for c in ps) for ps in qubit_hamiltonian.pauli_strings] - sorted_indices = sorted(range(len(num_non_identity)), key=lambda i: num_non_identity[i], reverse=True) - qubit_hamiltonian = QubitHamiltonian( - pauli_strings=[qubit_hamiltonian.pauli_strings[i] for i in sorted_indices], - coefficients=np.asarray([qubit_hamiltonian.coefficients[i] for i in sorted_indices]), - encoding=qubit_hamiltonian.encoding, - ) + def _groups_from_partition( + self, + qubit_hamiltonian: QubitHamiltonian, + partition: TermPartition, + ) -> list[list[QubitHamiltonian]]: + """Materialise a :class:`TermPartition` into Trotter sub-groups. - sub_hamiltonians = qubit_hamiltonian.group_commuting(qubit_wise=False) - - result: list[list[QubitHamiltonian]] = [] - for sub_h in sub_hamiltonians: - # Merge terms with identical Pauli strings. - merged: dict[str, complex] = {} - for label, coeff in zip(sub_h.pauli_strings, sub_h.coefficients, strict=True): - merged[label] = merged.get(label, 0.0) + coeff - labels = list(merged.keys()) - coeffs = list(merged.values()) - - # Split into parallelizable layers (disjoint qubit supports). - # Each layer becomes its own sub-group consisting of terms whose - # supports are mutually disjoint, allowing them to be applied in parallel. - pauli_maps = [self._pauli_label_to_map(label) for label in labels] - layers_indices: list[list[int]] = [] - layers_occupied: list[set[int]] = [] - for i, pm in enumerate(pauli_maps): - qubits = set(pm.keys()) - placed = False - for layer_i, layer_occ in enumerate(layers_occupied): - if qubits.isdisjoint(layer_occ): - layers_indices[layer_i].append(i) - layer_occ.update(qubits) - placed = True - break - if not placed: - layers_indices.append([i]) - layers_occupied.append(set(qubits)) - - outer_group: list[QubitHamiltonian] = [] - for layer in layers_indices: - outer_group.append( - QubitHamiltonian( - pauli_strings=[labels[i] for i in layer], - coefficients=np.asarray([coeffs[i] for i in layer]), - encoding=sub_h.encoding, - ) - ) - result.append(outer_group) + Both :class:`~qdk_chemistry.data.LayeredPartition` and + :class:`~qdk_chemistry.data.FlatPartition` are supported. A flat + partition's groups are treated as single layers. Groups are sorted by + ascending layer count so that the smallest groups sit on the outside + of the Strang/Suzuki splitting and merge at boundaries. + + Args: + qubit_hamiltonian: Source Hamiltonian whose Pauli terms the partition indexes into. + partition: Index-based partition carried on ``qubit_hamiltonian``. + + Returns: + List of groups; each group is a list of layer ``QubitHamiltonian`` objects. - # Move the group with the most multi-qubit terms to the end. - def _multi_qubit_count(group: list[QubitHamiltonian]) -> int: - return sum(1 for sub_h in group for label in sub_h.pauli_strings if sum(c != "I" for c in label) > 1) + """ + labels = qubit_hamiltonian.pauli_strings + coeffs = qubit_hamiltonian.coefficients + encoding = qubit_hamiltonian.encoding + fmo = qubit_hamiltonian.fermion_mode_order + + def _make(indices: tuple[int, ...]) -> QubitHamiltonian: + return QubitHamiltonian( + pauli_strings=[labels[i] for i in indices], + coefficients=np.asarray([coeffs[i] for i in indices]), + encoding=encoding, + fermion_mode_order=fmo, + ) + + # Normalise to (group → tuple of layers of indices) + if isinstance(partition, LayeredPartition): + layered_groups = partition.groups + elif isinstance(partition, FlatPartition): + layered_groups = tuple((g,) for g in partition.groups) + else: + raise TypeError( + f"Unsupported TermPartition subtype: {type(partition).__name__}. " + "Expected FlatPartition or LayeredPartition." + ) - max_idx = max(range(len(result)), key=lambda i: _multi_qubit_count(result[i])) - result.append(result.pop(max_idx)) + groups: list[list[QubitHamiltonian]] = [ + [_make(layer) for layer in group_layers if layer] for group_layers in layered_groups + ] + # Drop empty groups (no layers / all layers empty). + groups = [g for g in groups if g] - return result + # Sort groups by ascending layer count so the smallest sits on the + # outside of the Strang/Suzuki splitting (maximises boundary merging). + groups.sort(key=len) + return groups def _exponentiate_commuting( self, @@ -509,8 +483,8 @@ def _exponentiate_commuting( Each term :math:`P_j` with coefficient :math:`c_j` is converted to the rotation :math:`e^{-i\,c_j\,t\,P_j}`. Because all terms in the - group commute and :meth:`_group_terms` ensures they have disjoint - qubit supports, the rotations can be applied in any order. + group commute, the product of rotations equals the exponential of + the sum regardless of ordering. Args: group: The group of commuting Hamiltonian terms to exponentiate. diff --git a/python/src/qdk_chemistry/algorithms/registry.py b/python/src/qdk_chemistry/algorithms/registry.py index 30e08c118..e15553f47 100644 --- a/python/src/qdk_chemistry/algorithms/registry.py +++ b/python/src/qdk_chemistry/algorithms/registry.py @@ -512,6 +512,7 @@ def _register_python_factories(): from qdk_chemistry.algorithms.qubit_hamiltonian_solver import QubitHamiltonianSolverFactory # noqa: PLC0415 from qdk_chemistry.algorithms.qubit_mapper import QubitMapperFactory # noqa: PLC0415 from qdk_chemistry.algorithms.state_preparation import StatePreparationFactory # noqa: PLC0415 + from qdk_chemistry.algorithms.term_grouper import TermGrouperFactory # noqa: PLC0415 from qdk_chemistry.algorithms.time_evolution.circuit_mapper import ( # noqa: PLC0415 EvolutionCircuitMapperFactory, ) @@ -521,6 +522,7 @@ def _register_python_factories(): register_factory(EvolutionCircuitMapperFactory()) register_factory(MeasureSimulationFactory()) register_factory(StatePreparationFactory()) + register_factory(TermGrouperFactory()) register_factory(QubitMapperFactory()) register_factory(QubitHamiltonianSolverFactory()) register_factory(HamiltonianUnitaryBuilderFactory()) @@ -595,6 +597,11 @@ def _register_python_algorithms(): from qdk_chemistry.algorithms.qubit_hamiltonian_solver import DenseMatrixSolver, SparseMatrixSolver # noqa: PLC0415 from qdk_chemistry.algorithms.qubit_mapper import QdkQubitMapper # noqa: PLC0415 from qdk_chemistry.algorithms.state_preparation import SparseIsometryGF2XStatePreparation # noqa: PLC0415 + from qdk_chemistry.algorithms.term_grouper import ( # noqa: PLC0415 + FullCommutingTermGrouper, + IdentityTermGrouper, + QubitWiseCommutingTermGrouper, + ) from qdk_chemistry.algorithms.time_evolution.circuit_mapper import ( # noqa: PLC0415 PauliSequenceMapper as EvolutionPauliSequenceMapper, ) @@ -605,6 +612,9 @@ def _register_python_algorithms(): register(lambda: DenseMatrixSolver()) register(lambda: SparseMatrixSolver()) register(lambda: QdkQubitMapper()) + register(lambda: FullCommutingTermGrouper()) + register(lambda: QubitWiseCommutingTermGrouper()) + register(lambda: IdentityTermGrouper()) register(lambda: Trotter()) register(lambda: QDrift()) register(lambda: PartiallyRandomized()) diff --git a/python/src/qdk_chemistry/algorithms/registry.pyi b/python/src/qdk_chemistry/algorithms/registry.pyi index dcb38eb2c..8854a4c94 100644 --- a/python/src/qdk_chemistry/algorithms/registry.pyi +++ b/python/src/qdk_chemistry/algorithms/registry.pyi @@ -1 +1,513 @@ -# This file is a placeholder and will be replaced with generated stubs on first import +"""Type stubs for registry.create() with all algorithm overloads.""" + +from typing import Literal, overload, Union +from .base import Algorithm + +import qdk_chemistry.algorithms.active_space_selector +import qdk_chemistry.algorithms.circuit_executor.qdk +import qdk_chemistry.algorithms.controlled_circuit_mapper.pauli_sequence_mapper +import qdk_chemistry.algorithms.dynamical_correlation_calculator +import qdk_chemistry.algorithms.energy_estimator.qdk +import qdk_chemistry.algorithms.hamiltonian_constructor +import qdk_chemistry.algorithms.hamiltonian_unitary_builder.time_evolution.partially_randomized +import qdk_chemistry.algorithms.hamiltonian_unitary_builder.time_evolution.qdrift +import qdk_chemistry.algorithms.hamiltonian_unitary_builder.time_evolution.trotter +import qdk_chemistry.algorithms.multi_configuration_calculator +import qdk_chemistry.algorithms.orbital_localizer +import qdk_chemistry.algorithms.phase_estimation.iterative_phase_estimation +import qdk_chemistry.algorithms.projected_multi_configuration_calculator +import qdk_chemistry.algorithms.qubit_hamiltonian_solver +import qdk_chemistry.algorithms.qubit_mapper.qdk_qubit_mapper +import qdk_chemistry.algorithms.scf_solver +import qdk_chemistry.algorithms.stability_checker +import qdk_chemistry.algorithms.state_preparation.sparse_isometry +import qdk_chemistry.algorithms.term_grouper.commuting +import qdk_chemistry.algorithms.term_grouper.identity +import qdk_chemistry.algorithms.time_evolution.circuit_mapper.pauli_sequence_mapper +import qdk_chemistry.algorithms.time_evolution.measure_simulation.evolve_and_measure +import qdk_chemistry.plugins.networkx.term_grouper +import qdk_chemistry.plugins.openfermion.qubit_mapper +import qdk_chemistry.plugins.pyscf.active_space_avas +import qdk_chemistry.plugins.pyscf.coupled_cluster +import qdk_chemistry.plugins.pyscf.localization +import qdk_chemistry.plugins.pyscf.mcscf +import qdk_chemistry.plugins.pyscf.scf_solver +import qdk_chemistry.plugins.pyscf.stability +import qdk_chemistry.plugins.qiskit.circuit_executor +import qdk_chemistry.plugins.qiskit.qubit_mapper +import qdk_chemistry.plugins.qiskit.regular_isometry +import qdk_chemistry.plugins.qiskit.standard_phase_estimation + +@overload +def create( + algorithm_type: Literal['active_space_selector'], + algorithm_name: Literal['pyscf_avas'] | None = None, + ao_labels: list[str] = [], + canonicalize: bool = False, + openshell_option: unknown = 2, + threshold: float = 0.2, +) -> qdk_chemistry.plugins.pyscf.active_space_avas.PyscfAVAS: ... + +@overload +def create( + algorithm_type: Literal['active_space_selector'], + algorithm_name: Literal['qdk_occupation'] | None = None, + occupation_threshold: float = 0.1, +) -> qdk_chemistry.algorithms.active_space_selector.QdkOccupationActiveSpaceSelector: ... + +@overload +def create( + algorithm_type: Literal['active_space_selector'], + algorithm_name: Literal['qdk_autocas_eos'] | None = None, + diff_threshold: float = 0.1, + entropy_threshold: float = 0.14, + normalize_entropies: bool = True, +) -> qdk_chemistry.algorithms.active_space_selector.QdkAutocasEosActiveSpaceSelector: ... + +@overload +def create( + algorithm_type: Literal['active_space_selector'], + algorithm_name: Literal['qdk_autocas'] | None = None, + entropy_threshold: float = 0.14, + min_plateau_size: unknown = 10, + normalize_entropies: bool = True, + num_bins: unknown = 100, +) -> qdk_chemistry.algorithms.active_space_selector.QdkAutocasActiveSpaceSelector: ... + +@overload +def create( + algorithm_type: Literal['active_space_selector'], + algorithm_name: Literal['qdk_valence'] | None = None, + num_active_electrons: unknown = -1, + num_active_orbitals: unknown = -1, +) -> qdk_chemistry.algorithms.active_space_selector.QdkValenceActiveSpaceSelector: ... + +@overload +def create( + algorithm_type: Literal['hamiltonian_constructor'], + algorithm_name: Literal['qdk_cholesky'] | None = None, + cholesky_tolerance: float = 1e-08, + eri_threshold: float = 1e-12, + scf_type: str = "auto", + store_ao_cholesky_vectors: bool = False, +) -> qdk_chemistry.algorithms.hamiltonian_constructor.QdkCholeskyHamiltonianConstructor: ... + +@overload +def create( + algorithm_type: Literal['hamiltonian_constructor'], + algorithm_name: Literal['qdk'] | None = None, + eri_method: str = "direct", + scf_type: str = "auto", +) -> qdk_chemistry.algorithms.hamiltonian_constructor.QdkHamiltonianConstructor: ... + +@overload +def create( + algorithm_type: Literal['orbital_localizer'], + algorithm_name: Literal['pyscf_multi'] | None = None, + method: str = "pipek-mezey", + occupation_threshold: float = 1e-10, + population_method: str = "mulliken", +) -> qdk_chemistry.plugins.pyscf.localization.PyscfLocalizer: ... + +@overload +def create( + algorithm_type: Literal['orbital_localizer'], + algorithm_name: Literal['qdk_vvhv'] | None = None, + max_iterations: unknown = 10000, + minimal_basis: str = "sto-3g", + small_rotation_tolerance: float = 1e-12, + tolerance: float = 1e-06, + weighted_orthogonalization: bool = True, +) -> qdk_chemistry.algorithms.orbital_localizer.QdkVVHVLocalizer: ... + +@overload +def create( + algorithm_type: Literal['orbital_localizer'], + algorithm_name: Literal['qdk_mp2_natural_orbitals'] | None = None, +) -> qdk_chemistry.algorithms.orbital_localizer.QdkMP2NaturalOrbitalLocalizer: ... + +@overload +def create( + algorithm_type: Literal['orbital_localizer'], + algorithm_name: Literal['qdk_pipek_mezey'] | None = None, + max_iterations: unknown = 10000, + small_rotation_tolerance: float = 1e-12, + tolerance: float = 1e-06, +) -> qdk_chemistry.algorithms.orbital_localizer.QdkPipekMezeyLocalizer: ... + +@overload +def create( + algorithm_type: Literal['multi_configuration_calculator'], + algorithm_name: Literal['macis_asci'] | None = None, + calculate_mutual_information: bool = False, + calculate_one_rdm: bool = False, + calculate_single_orbital_entropies: bool = False, + calculate_two_orbital_entropies: bool = False, + calculate_two_rdm: bool = False, + ci_matel_tol: float = 2.220446049250313e-16, + ci_residual_tolerance: float = 1e-06, + constraint_level: unknown = 2, + core_selection_strategy: str = "percentage", + core_selection_threshold: float = 0.95, + grow_factor: float = 8.0, + grow_with_rot: bool = False, + growth_backoff_rate: float = 0.5, + growth_recovery_rate: float = 1.1, + iterative_solver_dimension_cutoff: unknown = 2000, + just_singles: bool = False, + max_refine_iter: unknown = 6, + max_solver_iterations: unknown = 200, + min_grow_factor: float = 1.01, + ncdets_max: unknown = 100, + ntdets_max: unknown = 100000, + ntdets_min: unknown = 100, + nxtval_bcount_inc: unknown = 10, + nxtval_bcount_thresh: unknown = 1000, + pair_size_max: unknown = 500000000, + pt2_bigcon_thresh: unknown = 250, + pt2_constraint_refine_force: unknown = 0, + pt2_max_constraint_level: unknown = 5, + pt2_min_constraint_level: unknown = 0, + pt2_precompute_eps: bool = False, + pt2_precompute_idx: bool = False, + pt2_print_progress: bool = False, + pt2_prune: bool = False, + pt2_reserve_count: unknown = 70000000, + pt2_tol: float = 1e-16, + refine_energy_tol: float = 1e-06, + rot_size_start: unknown = 1000, + rv_prune_tol: float = 1e-08, + search_matel_tol: float = 1e-08, +) -> qdk_chemistry.algorithms.multi_configuration_calculator.QdkMacisAsci: ... + +@overload +def create( + algorithm_type: Literal['multi_configuration_calculator'], + algorithm_name: Literal['macis_cas'] | None = None, + calculate_mutual_information: bool = False, + calculate_one_rdm: bool = False, + calculate_single_orbital_entropies: bool = False, + calculate_two_orbital_entropies: bool = False, + calculate_two_rdm: bool = False, + ci_matel_tol: float = 2.220446049250313e-16, + ci_residual_tolerance: float = 1e-06, + iterative_solver_dimension_cutoff: unknown = 2000, + max_solver_iterations: unknown = 200, +) -> qdk_chemistry.algorithms.multi_configuration_calculator.QdkMacisCas: ... + +@overload +def create( + algorithm_type: Literal['multi_configuration_scf'], + algorithm_name: Literal['pyscf'] | None = None, + max_cycle_macro: unknown = 50, + multi_configuration_calculator: AlgorithmRef = AlgorithmRef(type='multi_configuration_calculator', name='macis_cas'), + verbose: unknown = 0, +) -> qdk_chemistry.plugins.pyscf.mcscf.PyscfMcscfCalculator: ... + +@overload +def create( + algorithm_type: Literal['projected_multi_configuration_calculator'], + algorithm_name: Literal['macis_pmc'] | None = None, + calculate_mutual_information: bool = False, + calculate_one_rdm: bool = False, + calculate_single_orbital_entropies: bool = False, + calculate_two_orbital_entropies: bool = False, + calculate_two_rdm: bool = False, + ci_matel_tol: float = 2.220446049250313e-16, + ci_residual_tolerance: float = 1e-06, + iterative_solver_dimension_cutoff: unknown = 2000, + max_solver_iterations: unknown = 200, +) -> qdk_chemistry.algorithms.projected_multi_configuration_calculator.QdkMacisPmc: ... + +@overload +def create( + algorithm_type: Literal['dynamical_correlation_calculator'], + algorithm_name: Literal['pyscf_coupled_cluster'] | None = None, + async_io: bool = True, + compute_bra: bool = False, + conv_tol: float = 1e-07, + conv_tol_normt: float = 1e-05, + diis_space: unknown = 6, + diis_start_cycle: unknown = 0, + direct: bool = False, + incore_complete: bool = True, + max_cycle: unknown = 50, + store_amplitudes: bool = False, +) -> qdk_chemistry.plugins.pyscf.coupled_cluster.PyscfCoupledClusterCalculator: ... + +@overload +def create( + algorithm_type: Literal['dynamical_correlation_calculator'], + algorithm_name: Literal['qdk_mp2_calculator'] | None = None, +) -> qdk_chemistry.algorithms.dynamical_correlation_calculator.DynamicalCorrelationCalculator: ... + +@overload +def create( + algorithm_type: Literal['scf_solver'], + algorithm_name: Literal['pyscf'] | None = None, + convergence_threshold: float = 1e-07, + max_iterations: unknown = 50, + method: str = "hf", + scf_type: str = "auto", + xc_grid: unknown = 3, +) -> qdk_chemistry.plugins.pyscf.scf_solver.PyscfScfSolver: ... + +@overload +def create( + algorithm_type: Literal['scf_solver'], + algorithm_name: Literal['qdk'] | None = None, + convergence_threshold: float = 1e-07, + enable_gdm: bool = True, + energy_thresh_diis_switch: float = 0.001, + eri_method: str = "direct", + eri_threshold: float = -1.0, + eri_use_atomics: bool = False, + fock_reset_steps: unknown = 1073741824, + gdm_bfgs_history_size_limit: unknown = 50, + gdm_max_diis_iteration: unknown = 50, + level_shift: float = -1.0, + max_iterations: unknown = 50, + method: str = "hf", + nthreads: unknown = -1, + scf_type: str = "auto", + shell_pair_threshold: float = 1e-12, +) -> qdk_chemistry.algorithms.scf_solver.QdkScfSolver: ... + +@overload +def create( + algorithm_type: Literal['stability_checker'], + algorithm_name: Literal['pyscf'] | None = None, + davidson_tolerance: float = 1e-08, + external: bool = True, + internal: bool = True, + method: str = "hf", + nroots: unknown = 3, + pyscf_verbose: unknown = 4, + stability_tolerance: float = -0.0001, + with_symmetry: bool = False, + xc_grid: unknown = 3, +) -> qdk_chemistry.plugins.pyscf.stability.PyscfStabilityChecker: ... + +@overload +def create( + algorithm_type: Literal['stability_checker'], + algorithm_name: Literal['qdk'] | None = None, + davidson_tolerance: float = 1e-08, + external: bool = False, + internal: bool = True, + max_subspace: unknown = 80, + method: str = "hf", + stability_tolerance: float = -0.0001, +) -> qdk_chemistry.algorithms.stability_checker.QdkStabilityChecker: ... + +@overload +def create( + algorithm_type: Literal['energy_estimator'], + algorithm_name: Literal['qdk'] | None = None, + circuit_executor: AlgorithmRef = AlgorithmRef(type='circuit_executor', name='qdk_sparse_state_simulator'), +) -> qdk_chemistry.algorithms.energy_estimator.qdk.QdkEnergyEstimator: ... + +@overload +def create( + algorithm_type: Literal['evolution_circuit_mapper'], + algorithm_name: Literal['pauli_sequence'] | None = None, +) -> qdk_chemistry.algorithms.time_evolution.circuit_mapper.pauli_sequence_mapper.PauliSequenceMapper: ... + +@overload +def create( + algorithm_type: Literal['measure_simulation'], + algorithm_name: Literal['evolve_and_measure'] | None = None, + circuit_executor: AlgorithmRef = AlgorithmRef(type='circuit_executor', name='qdk_sparse_state_simulator'), + circuit_mapper: AlgorithmRef = AlgorithmRef(type='evolution_circuit_mapper', name='pauli_sequence'), + energy_estimator: AlgorithmRef = AlgorithmRef(type='energy_estimator', name='qdk'), + evolution_builder: AlgorithmRef = AlgorithmRef(type='hamiltonian_unitary_builder', name='trotter'), +) -> qdk_chemistry.algorithms.time_evolution.measure_simulation.evolve_and_measure.EvolveAndMeasure: ... + +@overload +def create( + algorithm_type: Literal['state_prep'], + algorithm_name: Literal['sparse_isometry_gf2x'] | None = None, + basis_gates: list[str] = ['x', 'y', 'z', 'cx', 'cz', 'id', 'h', 's', 'sdg', 'rz'], + dense_preparation_method: str = "qdk", + transpile: bool = True, + transpile_optimization_level: unknown = 0, +) -> qdk_chemistry.algorithms.state_preparation.sparse_isometry.SparseIsometryGF2XStatePreparation: ... + +@overload +def create( + algorithm_type: Literal['state_prep'], + algorithm_name: Literal['qiskit_regular_isometry'] | None = None, + basis_gates: list[str] = ['x', 'y', 'z', 'cx', 'cz', 'id', 'h', 's', 'sdg', 'rz'], + transpile: bool = True, + transpile_optimization_level: unknown = 0, +) -> qdk_chemistry.plugins.qiskit.regular_isometry.RegularIsometryStatePreparation: ... + +@overload +def create( + algorithm_type: Literal['term_grouper'], + algorithm_name: Literal['commuting'] | None = None, +) -> qdk_chemistry.algorithms.term_grouper.commuting.FullCommutingTermGrouper: ... + +@overload +def create( + algorithm_type: Literal['term_grouper'], + algorithm_name: Literal['qubit_wise_commuting'] | None = None, +) -> qdk_chemistry.algorithms.term_grouper.commuting.QubitWiseCommutingTermGrouper: ... + +@overload +def create( + algorithm_type: Literal['term_grouper'], + algorithm_name: Literal['identity'] | None = None, +) -> qdk_chemistry.algorithms.term_grouper.identity.IdentityTermGrouper: ... + +@overload +def create( + algorithm_type: Literal['term_grouper'], + algorithm_name: Literal['nx_commuting'] | None = None, +) -> qdk_chemistry.plugins.networkx.term_grouper.NxFullCommutingTermGrouper: ... + +@overload +def create( + algorithm_type: Literal['term_grouper'], + algorithm_name: Literal['nx_qubit_wise_commuting'] | None = None, +) -> qdk_chemistry.plugins.networkx.term_grouper.NxQubitWiseCommutingTermGrouper: ... + +@overload +def create( + algorithm_type: Literal['qubit_mapper'], + algorithm_name: Literal['qdk'] | None = None, + encoding: str = "jordan-wigner", + integral_threshold: float = 1e-12, + threshold: float = 1e-12, +) -> qdk_chemistry.algorithms.qubit_mapper.qdk_qubit_mapper.QdkQubitMapper: ... + +@overload +def create( + algorithm_type: Literal['qubit_mapper'], + algorithm_name: Literal['qiskit'] | None = None, + encoding: str = "jordan-wigner", +) -> qdk_chemistry.plugins.qiskit.qubit_mapper.QiskitQubitMapper: ... + +@overload +def create( + algorithm_type: Literal['qubit_mapper'], + algorithm_name: Literal['openfermion'] | None = None, + encoding: str = "jordan-wigner", +) -> qdk_chemistry.plugins.openfermion.qubit_mapper.OpenFermionQubitMapper: ... + +@overload +def create( + algorithm_type: Literal['qubit_hamiltonian_solver'], + algorithm_name: Literal['qdk_dense_matrix_solver'] | None = None, +) -> qdk_chemistry.algorithms.qubit_hamiltonian_solver.DenseMatrixSolver: ... + +@overload +def create( + algorithm_type: Literal['qubit_hamiltonian_solver'], + algorithm_name: Literal['qdk_sparse_matrix_solver'] | None = None, + max_m: unknown = 20, + tol: float = 1e-08, +) -> qdk_chemistry.algorithms.qubit_hamiltonian_solver.SparseMatrixSolver: ... + +@overload +def create( + algorithm_type: Literal['hamiltonian_unitary_builder'], + algorithm_name: Literal['trotter'] | None = None, + error_bound: str = "commutator", + num_divisions: unknown = 0, + optimize_term_ordering: bool = False, + order: unknown = 1, + power: unknown = 1, + power_strategy: str = "repeat", + target_accuracy: float = 0.0, + time: float = 0.0, + weight_threshold: float = 1e-12, +) -> qdk_chemistry.algorithms.hamiltonian_unitary_builder.time_evolution.trotter.Trotter: ... + +@overload +def create( + algorithm_type: Literal['hamiltonian_unitary_builder'], + algorithm_name: Literal['qdrift'] | None = None, + commutation_type: str = "general", + merge_duplicate_terms: bool = True, + num_samples: unknown = 100, + power: unknown = 1, + power_strategy: str = "repeat", + seed: unknown = -1, + time: float = 0.0, +) -> qdk_chemistry.algorithms.hamiltonian_unitary_builder.time_evolution.qdrift.QDrift: ... + +@overload +def create( + algorithm_type: Literal['hamiltonian_unitary_builder'], + algorithm_name: Literal['partially_randomized'] | None = None, + commutation_type: str = "general", + merge_duplicate_terms: bool = True, + num_random_samples: unknown = 100, + power: unknown = 1, + power_strategy: str = "repeat", + seed: unknown = -1, + time: float = 0.0, + tolerance: float = 1e-12, + trotter_order: unknown = 2, + weight_threshold: float = -1.0, +) -> qdk_chemistry.algorithms.hamiltonian_unitary_builder.time_evolution.partially_randomized.PartiallyRandomized: ... + +@overload +def create( + algorithm_type: Literal['controlled_circuit_mapper'], + algorithm_name: Literal['pauli_sequence'] | None = None, +) -> qdk_chemistry.algorithms.controlled_circuit_mapper.pauli_sequence_mapper.PauliSequenceMapper: ... + +@overload +def create( + algorithm_type: Literal['circuit_executor'], + algorithm_name: Literal['qdk_full_state_simulator'] | None = None, + seed: unknown = 42, + type: str = "cpu", +) -> qdk_chemistry.algorithms.circuit_executor.qdk.QdkFullStateSimulator: ... + +@overload +def create( + algorithm_type: Literal['circuit_executor'], + algorithm_name: Literal['qdk_sparse_state_simulator'] | None = None, + seed: unknown = 42, +) -> qdk_chemistry.algorithms.circuit_executor.qdk.QdkSparseStateSimulator: ... + +@overload +def create( + algorithm_type: Literal['circuit_executor'], + algorithm_name: Literal['qiskit_aer_simulator'] | None = None, + method: str = "statevector", + seed: unknown = 42, + transpile_optimization_level: unknown = 0, +) -> qdk_chemistry.plugins.qiskit.circuit_executor.QiskitAerSimulator: ... + +@overload +def create( + algorithm_type: Literal['phase_estimation'], + algorithm_name: Literal['iterative'] | None = None, + circuit_executor: AlgorithmRef = AlgorithmRef(type='circuit_executor', name='qdk_sparse_state_simulator'), + circuit_mapper: AlgorithmRef = AlgorithmRef(type='controlled_circuit_mapper', name='pauli_sequence'), + num_bits: unknown = -1, + shots_per_bit: unknown = 3, + unitary_builder: AlgorithmRef = AlgorithmRef(type='hamiltonian_unitary_builder', name='trotter'), +) -> qdk_chemistry.algorithms.phase_estimation.iterative_phase_estimation.IterativePhaseEstimation: ... + +@overload +def create( + algorithm_type: Literal['phase_estimation'], + algorithm_name: Literal['qiskit_standard'] | None = None, + circuit_executor: AlgorithmRef = AlgorithmRef(type='circuit_executor', name='qdk_sparse_state_simulator'), + circuit_mapper: AlgorithmRef = AlgorithmRef(type='controlled_circuit_mapper', name='pauli_sequence'), + num_bits: unknown = -1, + qft_do_swaps: bool = True, + shots: unknown = 3, + unitary_builder: AlgorithmRef = AlgorithmRef(type='hamiltonian_unitary_builder', name='trotter'), +) -> qdk_chemistry.plugins.qiskit.standard_phase_estimation.QiskitStandardPhaseEstimation: ... + +def create( + algorithm_type: str, + algorithm_name: str | None = None, + **kwargs, +) -> Union[Algorithm | qdk_chemistry.algorithms.active_space_selector.QdkAutocasActiveSpaceSelector | qdk_chemistry.algorithms.active_space_selector.QdkAutocasEosActiveSpaceSelector | qdk_chemistry.algorithms.active_space_selector.QdkOccupationActiveSpaceSelector | qdk_chemistry.algorithms.active_space_selector.QdkValenceActiveSpaceSelector | qdk_chemistry.algorithms.circuit_executor.qdk.QdkFullStateSimulator | qdk_chemistry.algorithms.circuit_executor.qdk.QdkSparseStateSimulator | qdk_chemistry.algorithms.controlled_circuit_mapper.pauli_sequence_mapper.PauliSequenceMapper | qdk_chemistry.algorithms.dynamical_correlation_calculator.DynamicalCorrelationCalculator | qdk_chemistry.algorithms.energy_estimator.qdk.QdkEnergyEstimator | qdk_chemistry.algorithms.hamiltonian_constructor.QdkCholeskyHamiltonianConstructor | qdk_chemistry.algorithms.hamiltonian_constructor.QdkHamiltonianConstructor | qdk_chemistry.algorithms.hamiltonian_unitary_builder.time_evolution.partially_randomized.PartiallyRandomized | qdk_chemistry.algorithms.hamiltonian_unitary_builder.time_evolution.qdrift.QDrift | qdk_chemistry.algorithms.hamiltonian_unitary_builder.time_evolution.trotter.Trotter | qdk_chemistry.algorithms.multi_configuration_calculator.QdkMacisAsci | qdk_chemistry.algorithms.multi_configuration_calculator.QdkMacisCas | qdk_chemistry.algorithms.orbital_localizer.QdkMP2NaturalOrbitalLocalizer | qdk_chemistry.algorithms.orbital_localizer.QdkPipekMezeyLocalizer | qdk_chemistry.algorithms.orbital_localizer.QdkVVHVLocalizer | qdk_chemistry.algorithms.phase_estimation.iterative_phase_estimation.IterativePhaseEstimation | qdk_chemistry.algorithms.projected_multi_configuration_calculator.QdkMacisPmc | qdk_chemistry.algorithms.qubit_hamiltonian_solver.DenseMatrixSolver | qdk_chemistry.algorithms.qubit_hamiltonian_solver.SparseMatrixSolver | qdk_chemistry.algorithms.qubit_mapper.qdk_qubit_mapper.QdkQubitMapper | qdk_chemistry.algorithms.scf_solver.QdkScfSolver | qdk_chemistry.algorithms.stability_checker.QdkStabilityChecker | qdk_chemistry.algorithms.state_preparation.sparse_isometry.SparseIsometryGF2XStatePreparation | qdk_chemistry.algorithms.term_grouper.commuting.FullCommutingTermGrouper | qdk_chemistry.algorithms.term_grouper.commuting.QubitWiseCommutingTermGrouper | qdk_chemistry.algorithms.term_grouper.identity.IdentityTermGrouper | qdk_chemistry.algorithms.time_evolution.circuit_mapper.pauli_sequence_mapper.PauliSequenceMapper | qdk_chemistry.algorithms.time_evolution.measure_simulation.evolve_and_measure.EvolveAndMeasure | qdk_chemistry.plugins.networkx.term_grouper.NxFullCommutingTermGrouper | qdk_chemistry.plugins.networkx.term_grouper.NxQubitWiseCommutingTermGrouper | qdk_chemistry.plugins.openfermion.qubit_mapper.OpenFermionQubitMapper | qdk_chemistry.plugins.pyscf.active_space_avas.PyscfAVAS | qdk_chemistry.plugins.pyscf.coupled_cluster.PyscfCoupledClusterCalculator | qdk_chemistry.plugins.pyscf.localization.PyscfLocalizer | qdk_chemistry.plugins.pyscf.mcscf.PyscfMcscfCalculator | qdk_chemistry.plugins.pyscf.scf_solver.PyscfScfSolver | qdk_chemistry.plugins.pyscf.stability.PyscfStabilityChecker | qdk_chemistry.plugins.qiskit.circuit_executor.QiskitAerSimulator | qdk_chemistry.plugins.qiskit.qubit_mapper.QiskitQubitMapper | qdk_chemistry.plugins.qiskit.regular_isometry.RegularIsometryStatePreparation | qdk_chemistry.plugins.qiskit.standard_phase_estimation.QiskitStandardPhaseEstimation]: ... diff --git a/python/src/qdk_chemistry/algorithms/term_grouper/__init__.py b/python/src/qdk_chemistry/algorithms/term_grouper/__init__.py new file mode 100644 index 000000000..138aeaf95 --- /dev/null +++ b/python/src/qdk_chemistry/algorithms/term_grouper/__init__.py @@ -0,0 +1,34 @@ +"""Term-grouper algorithms for :class:`~qdk_chemistry.data.QubitHamiltonian`. + +A *term grouper* takes a :class:`~qdk_chemistry.data.QubitHamiltonian` and +returns a new one with a populated +:attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` that downstream +algorithms can exploit. + +Example: + >>> from qdk_chemistry.algorithms import registry + >>> grouper = registry.create("term_grouper", "qubit_wise_commuting") + >>> grouped = grouper.run(my_hamiltonian) + >>> grouped.term_partition # FlatPartition with strategy="qubit_wise_commuting" + +""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from qdk_chemistry.algorithms.term_grouper.base import TermGrouper, TermGrouperFactory +from qdk_chemistry.algorithms.term_grouper.commuting import ( + FullCommutingTermGrouper, + QubitWiseCommutingTermGrouper, +) +from qdk_chemistry.algorithms.term_grouper.identity import IdentityTermGrouper + +__all__ = [ + "FullCommutingTermGrouper", + "IdentityTermGrouper", + "QubitWiseCommutingTermGrouper", + "TermGrouper", + "TermGrouperFactory", +] diff --git a/python/src/qdk_chemistry/algorithms/term_grouper/base.py b/python/src/qdk_chemistry/algorithms/term_grouper/base.py new file mode 100644 index 000000000..c389b6116 --- /dev/null +++ b/python/src/qdk_chemistry/algorithms/term_grouper/base.py @@ -0,0 +1,69 @@ +"""Abstract base class and factory for term-grouper algorithms.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from abc import abstractmethod + +from qdk_chemistry.algorithms.base import Algorithm, AlgorithmFactory +from qdk_chemistry.data import QubitHamiltonian, Settings + +__all__ = ["TermGrouper", "TermGrouperFactory"] + + +class TermGrouperSettings(Settings): + """Settings for term-grouper algorithms.""" + + def __init__(self): + """Initialise default term-grouper settings (currently empty).""" + super().__init__() + + +class TermGrouper(Algorithm): + """Abstract base class for algorithms that partition Hamiltonian terms. + + A ``TermGrouper`` consumes a :class:`~qdk_chemistry.data.QubitHamiltonian` + and returns a *new* ``QubitHamiltonian`` whose + :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` is populated + with the grouping computed by the strategy. + + Subclasses implement ``_run_impl``, which must return a new + ``QubitHamiltonian`` (the input must not be mutated). + + """ + + def __init__(self): + """Initialise the term grouper with default settings.""" + super().__init__() + self._settings = TermGrouperSettings() + + def type_name(self) -> str: + """Return ``term_grouper`` as the algorithm type name.""" + return "term_grouper" + + @abstractmethod + def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: + """Compute a term partition and return a new ``QubitHamiltonian`` carrying it. + + Args: + qubit_hamiltonian: Hamiltonian whose Pauli terms should be partitioned. + + Returns: + QubitHamiltonian: A copy of the input with + :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` populated. + + """ + + +class TermGrouperFactory(AlgorithmFactory): + """Factory for :class:`TermGrouper` instances.""" + + def algorithm_type_name(self) -> str: + """Return ``term_grouper`` as the algorithm type name.""" + return "term_grouper" + + def default_algorithm_name(self) -> str: + """Return ``commuting`` as the default term-grouper algorithm.""" + return "commuting" diff --git a/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py b/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py new file mode 100644 index 000000000..a9a3ba640 --- /dev/null +++ b/python/src/qdk_chemistry/algorithms/term_grouper/commuting.py @@ -0,0 +1,138 @@ +"""Commutation-based term groupers (full and qubit-wise).""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qdk_chemistry.algorithms.term_grouper.base import TermGrouper +from qdk_chemistry.data import FlatPartition, QubitHamiltonian +from qdk_chemistry.utils.pauli_commutation import do_pauli_labels_commute, do_pauli_labels_qw_commute + +if TYPE_CHECKING: + from collections.abc import Callable + +__all__ = ["FullCommutingTermGrouper", "QubitWiseCommutingTermGrouper"] + + +def _color_non_commutation_graph( + pauli_strings: list[str], + commutes: Callable[[str, str], bool], +) -> tuple[tuple[int, ...], ...]: + """Partition Pauli labels into commuting groups via greedy graph coloring. + + Builds the non-commutation graph and greedily assigns each label the + lowest-index group in which it commutes with all existing members. + + Args: + pauli_strings: Pauli labels to partition. + commutes: Predicate returning ``True`` when two labels commute. + + Returns: + Tuple of groups; each group is a tuple of indices into ``pauli_strings``. + + """ + n = len(pauli_strings) + if n == 0: + return () + + non_commuting: list[set[int]] = [set() for _ in range(n)] + for i in range(1, n): + for j in range(i): + if not commutes(pauli_strings[i], pauli_strings[j]): + non_commuting[i].add(j) + non_commuting[j].add(i) + + groups: list[list[int]] = [] + group_members: list[set[int]] = [] + + for i in range(n): + placed = False + conflicts_i = non_commuting[i] + for g_idx, members in enumerate(group_members): + if not conflicts_i & members: + groups[g_idx].append(i) + members.add(i) + placed = True + break + if not placed: + groups.append([i]) + group_members.append({i}) + + return tuple(tuple(g) for g in groups) + + +class FullCommutingTermGrouper(TermGrouper): + """Group terms by full Pauli commutation (``[P_i, P_j] = 0``). + + The resulting :class:`~qdk_chemistry.data.FlatPartition` stores a + :class:`~qdk_chemistry.data.QubitHamiltonian` partition where every pair of + terms in the same group commutes globally. Useful for Trotter-style + decompositions, which can exponentiate a commuting block as a single + ordered product without splitting error. + + """ + + def name(self) -> str: + """Return ``commuting`` as the algorithm name.""" + return "commuting" + + def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: + """Return a copy of ``qubit_hamiltonian`` with a full-commutation partition. + + Args: + qubit_hamiltonian: Hamiltonian to partition. + + Returns: + QubitHamiltonian: New instance with a ``FlatPartition`` (strategy ``"commuting"``). + + """ + groups = _color_non_commutation_graph(qubit_hamiltonian.pauli_strings, do_pauli_labels_commute) + partition = FlatPartition(strategy="commuting", groups=groups) + return QubitHamiltonian( + pauli_strings=list(qubit_hamiltonian.pauli_strings), + coefficients=qubit_hamiltonian.coefficients.copy(), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + term_partition=partition, + ) + + +class QubitWiseCommutingTermGrouper(TermGrouper): + """Group terms by qubit-wise commutation. + + Two labels qubit-wise commute when, on every qubit position, the two + single-qubit Paulis individually commute (i.e. one is identity or both are + equal). All members of a group can be measured in a single basis, which + is the property exploited by :class:`~qdk_chemistry.algorithms.QdkEnergyEstimator` + for measurement-cost reduction. + + """ + + def name(self) -> str: + """Return ``qubit_wise_commuting`` as the algorithm name.""" + return "qubit_wise_commuting" + + def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: + """Return a copy of ``qubit_hamiltonian`` with a qubit-wise commutation partition. + + Args: + qubit_hamiltonian: Hamiltonian to partition. + + Returns: + QubitHamiltonian: New instance with a ``FlatPartition`` (strategy ``"qubit_wise_commuting"``). + + """ + groups = _color_non_commutation_graph(qubit_hamiltonian.pauli_strings, do_pauli_labels_qw_commute) + partition = FlatPartition(strategy="qubit_wise_commuting", groups=groups) + return QubitHamiltonian( + pauli_strings=list(qubit_hamiltonian.pauli_strings), + coefficients=qubit_hamiltonian.coefficients.copy(), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + term_partition=partition, + ) diff --git a/python/src/qdk_chemistry/algorithms/term_grouper/identity.py b/python/src/qdk_chemistry/algorithms/term_grouper/identity.py new file mode 100644 index 000000000..ba93df8d6 --- /dev/null +++ b/python/src/qdk_chemistry/algorithms/term_grouper/identity.py @@ -0,0 +1,48 @@ +"""Identity term grouper: each Pauli term is its own group.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from qdk_chemistry.algorithms.term_grouper.base import TermGrouper +from qdk_chemistry.data import FlatPartition, QubitHamiltonian + +__all__ = ["IdentityTermGrouper"] + + +class IdentityTermGrouper(TermGrouper): + """Trivial grouper — every term is placed in its own single-element group. + + Useful to clear an existing :attr:`~qdk_chemistry.data.QubitHamiltonian.term_partition` + while still passing through the standard ``term_grouper`` interface, or to + disable downstream group-aware optimisation in a controlled way. + + """ + + def name(self) -> str: + """Return ``identity`` as the algorithm name.""" + return "identity" + + def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: + """Return a copy of ``qubit_hamiltonian`` with one group per term. + + Args: + qubit_hamiltonian: Hamiltonian to wrap. + + Returns: + QubitHamiltonian: A new instance with a :class:`FlatPartition`. + + """ + n = len(qubit_hamiltonian.pauli_strings) + partition = FlatPartition( + strategy="identity", + groups=tuple((i,) for i in range(n)), + ) + return QubitHamiltonian( + pauli_strings=list(qubit_hamiltonian.pauli_strings), + coefficients=qubit_hamiltonian.coefficients.copy(), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + term_partition=partition, + ) diff --git a/python/src/qdk_chemistry/data/__init__.py b/python/src/qdk_chemistry/data/__init__.py index daa19a52f..83e806034 100644 --- a/python/src/qdk_chemistry/data/__init__.py +++ b/python/src/qdk_chemistry/data/__init__.py @@ -44,6 +44,8 @@ - :class:`StabilityResult`: Result of stability analysis for electronic structure calculations. - :class:`Structure`: Molecular structure and geometry information. - :class:`Symmetries`: Physical symmetries of an electronic state for symmetry-exploiting algorithms. +- :class:`TermPartition`: Index-based partition of Hamiltonian terms. + See :class:`FlatPartition` and :class:`LayeredPartition`. - :class:`UnitaryRepresentation`: Unitary representation. - :class:`UnitaryContainer`: Abstract base class for different unitary representations. - :class:`Wavefunction`: Electronic wavefunction data and coefficients. @@ -115,6 +117,7 @@ from qdk_chemistry.data.qpe_result import QpeResult from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian from qdk_chemistry.data.symmetries import Symmetries +from qdk_chemistry.data.term_partition import FlatPartition, LayeredPartition, TermPartition from qdk_chemistry.data.unitary_representation.base import UnitaryRepresentation from qdk_chemistry.data.unitary_representation.containers.base import UnitaryContainer from qdk_chemistry.data.unitary_representation.containers.pauli_product_formula import PauliProductFormulaContainer @@ -145,10 +148,14 @@ "EncodingMismatchError", "EnergyExpectationResult", "FermionModeOrder", + "FlatPartition", + "FlatPartition", "Hamiltonian", "HamiltonianContainer", "HamiltonianType", "LatticeGraph", + "LayeredPartition", + "LayeredPartition", "MP2Container", "MeasurementData", "ModelOrbitals", @@ -176,6 +183,7 @@ "StabilityResult", "Structure", "Symmetries", + "TermPartition", "UnitaryContainer", "UnitaryRepresentation", "Wavefunction", diff --git a/python/src/qdk_chemistry/data/base.py b/python/src/qdk_chemistry/data/base.py index 86f038616..c9150bb5e 100644 --- a/python/src/qdk_chemistry/data/base.py +++ b/python/src/qdk_chemistry/data/base.py @@ -151,7 +151,7 @@ def __getattr__(self, name: str) -> Any: if attr is not None: return attr # Forward to parent class for other attributes - return super().__getattr__(name) + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") def __setattr__(self, name: str, value: Any) -> None: """Prevent attribute modification after initialization. diff --git a/python/src/qdk_chemistry/data/qubit_hamiltonian.py b/python/src/qdk_chemistry/data/qubit_hamiltonian.py index 99a79924e..df7c9482d 100644 --- a/python/src/qdk_chemistry/data/qubit_hamiltonian.py +++ b/python/src/qdk_chemistry/data/qubit_hamiltonian.py @@ -12,12 +12,14 @@ from __future__ import annotations +import json import re from typing import TYPE_CHECKING, Any import numpy as np from qdk_chemistry.data.base import DataClass +from qdk_chemistry.data.term_partition import TermPartition from qdk_chemistry.utils.pauli_matrix import pauli_to_dense_matrix, pauli_to_sparse_matrix if TYPE_CHECKING: @@ -26,7 +28,6 @@ from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.utils import Logger -from qdk_chemistry.utils.pauli_commutation import do_pauli_labels_commute, do_pauli_labels_qw_commute __all__: list[str] = [] @@ -42,6 +43,11 @@ class QubitHamiltonian(DataClass): fermion_mode_order (FermionModeOrder | None): The fermion mode ordering convention used when mapping fermionic modes to qubits (``"blocked"`` or ``"interleaved"``). If None, the ordering is unspecified or not applicable. + term_partition (TermPartition | None): Optional index-based partition of + :attr:`pauli_strings` into algorithm-relevant groups (and, for layered + partitions, into parallelisable layers within each group). Set by + geometry-aware constructors and by ``term_grouper`` algorithms; reset + to ``None`` by transformations that change the term ordering. """ @@ -57,6 +63,7 @@ def __init__( coefficients: np.ndarray, encoding: str | None = None, fermion_mode_order: FermionModeOrder | str | None = None, + term_partition: TermPartition | None = None, ) -> None: """Initialize a QubitHamiltonian. @@ -65,6 +72,7 @@ def __init__( coefficients (numpy.ndarray): Array of coefficients corresponding to each Pauli string. encoding (str | None): Fermion-to-qubit encoding (e.g., ``"jordan-wigner"``). Default ``None``. fermion_mode_order (FermionModeOrder | str | None): Mode ordering (``"blocked"``/``"interleaved"``). + term_partition (TermPartition | None): Optional ``TermPartition`` carrying group/layer metadata. Raises: ValueError: If the number of Pauli strings and coefficients don't match, @@ -81,6 +89,7 @@ def __init__( self.fermion_mode_order: FermionModeOrder | None = ( FermionModeOrder(fermion_mode_order) if fermion_mode_order is not None else None ) + self.term_partition: TermPartition | None = term_partition # Validate Pauli strings _validate_pauli_strings(pauli_strings) @@ -265,42 +274,6 @@ def to_interleaved(self, n_spatial: int) -> QubitHamiltonian: fermion_mode_order=FermionModeOrder.INTERLEAVED, ) - def group_commuting(self, qubit_wise: bool = True) -> list[QubitHamiltonian]: - """Group the qubit Hamiltonian into commuting subsets. - - Args: - qubit_wise (bool): Whether to use qubit-wise commuting grouping. Default is True. - - Returns: - list[QubitHamiltonian]: A list of ``QubitHamiltonian`` representing the grouped Hamiltonian. - - """ - Logger.trace_entering() - commutes = do_pauli_labels_qw_commute if qubit_wise else do_pauli_labels_commute - - # Each group is a list of (pauli_string, coefficient) - groups: list[list[tuple[str, complex]]] = [] - - for pauli_str, coeff in zip(self.pauli_strings, self.coefficients, strict=True): - placed = False - for group in groups: - if all(commutes(pauli_str, existing_str) for existing_str, _ in group): - group.append((pauli_str, coeff)) - placed = True - break - if not placed: - groups.append([(pauli_str, coeff)]) - - return [ - QubitHamiltonian( - pauli_strings=[p for p, _ in group], - coefficients=np.array([c for _, c in group]), - encoding=self.encoding, - fermion_mode_order=self.fermion_mode_order, - ) - for group in groups - ] - # DataClass interface implementation def get_summary(self) -> str: """Get a human-readable summary of the qubit Hamiltonian. @@ -339,6 +312,8 @@ def to_json(self) -> dict[str, Any]: data["encoding"] = self.encoding if self.fermion_mode_order is not None: data["fermion_mode_order"] = str(self.fermion_mode_order) + if self.term_partition is not None: + data["term_partition"] = self.term_partition.to_json() return self._add_json_version(data) def to_hdf5(self, group: h5py.Group) -> None: @@ -355,6 +330,8 @@ def to_hdf5(self, group: h5py.Group) -> None: group.attrs["encoding"] = self.encoding if self.fermion_mode_order is not None: group.attrs["fermion_mode_order"] = str(self.fermion_mode_order) + if self.term_partition is not None: + group.attrs["term_partition"] = json.dumps(self.term_partition.to_json()) @classmethod def from_json(cls, json_data: dict[str, Any]) -> QubitHamiltonian: @@ -378,11 +355,14 @@ def from_json(cls, json_data: dict[str, Any]) -> QubitHamiltonian: else: # Fallback for legacy format (simple list of real numbers) coefficients = np.array(coeff_data) + partition_data = json_data.get("term_partition") + term_partition = TermPartition.from_json(partition_data) if partition_data is not None else None return cls( pauli_strings=json_data["pauli_strings"], coefficients=coefficients, encoding=json_data.get("encoding"), fermion_mode_order=json_data.get("fermion_mode_order"), + term_partition=term_partition, ) @classmethod @@ -409,11 +389,19 @@ def from_hdf5(cls, group: h5py.Group) -> QubitHamiltonian: fermion_mode_order = group.attrs.get("fermion_mode_order") if fermion_mode_order is not None and isinstance(fermion_mode_order, bytes): fermion_mode_order = fermion_mode_order.decode("utf-8") + partition_attr = group.attrs.get("term_partition") + if partition_attr is not None: + if isinstance(partition_attr, bytes): + partition_attr = partition_attr.decode("utf-8") + term_partition = TermPartition.from_json(json.loads(partition_attr)) + else: + term_partition = None return cls( pauli_strings=pauli_strings, coefficients=coefficients, encoding=encoding, fermion_mode_order=fermion_mode_order, + term_partition=term_partition, ) diff --git a/python/src/qdk_chemistry/data/term_partition.py b/python/src/qdk_chemistry/data/term_partition.py new file mode 100644 index 000000000..ca9f61d4f --- /dev/null +++ b/python/src/qdk_chemistry/data/term_partition.py @@ -0,0 +1,254 @@ +"""Term-partition metadata for :class:`~qdk_chemistry.data.QubitHamiltonian`. + +A :class:`TermPartition` records how the Pauli terms of a +:class:`~qdk_chemistry.data.QubitHamiltonian` are organised into algorithm- +relevant subsets. Concrete subclasses include :class:`FlatPartition` +(single-level groups) and :class:`LayeredPartition` (group → layer +hierarchy). + +The partition stores **indices** into +:attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings` so that it +serialises trivially and remains small. + +Lifecycle +--------- + +* The partition is *optional* metadata. ``term_partition is None`` means the + partition has not been computed for this Hamiltonian. +* Transformations that change the term ordering or qubit support + (for example :meth:`~qdk_chemistry.data.QubitHamiltonian.to_interleaved`) + must reset the partition to ``None`` on the new Hamiltonian. +* Algorithms that consume a partition should treat its presence as an explicit + signal to exploit it (for example, by applying schedule-level Suzuki + recursion or grouping measurements by basis). + +""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from __future__ import annotations + +import json as _json +from typing import Any + +from qdk_chemistry.data.base import DataClass + +__all__ = ["FlatPartition", "LayeredPartition", "TermPartition"] + + +class TermPartition(DataClass): + """Base class for index-based partitions of Hamiltonian terms. + + Use :class:`FlatPartition` for single-level partitions or + :class:`LayeredPartition` for hierarchical (group → layer) partitions. + + The ``strategy`` field is a free-form label identifying how the partition + was produced (for example ``"geometry_coloring"``, ``"commuting"``, + ``"qubit_wise_commuting"``). + + """ + + _data_type_name = "term_partition" + _serialization_version = "0.1.0" + + def __init__(self, *, strategy: str) -> None: + """Initialize the term partition. + + Args: + strategy: Label identifying how the partition was produced. + + """ + self.strategy = strategy + + @property + def num_groups(self) -> int: + """Return the number of top-level groups in the partition.""" + raise NotImplementedError + + def all_indices(self) -> list[int]: + """Return every term index referenced by the partition, in order.""" + raise NotImplementedError + + def get_summary(self) -> str: + """Return a summary of the partition.""" + return f"TermPartition(strategy={self.strategy!r}, num_groups={self.num_groups})" + + def to_json(self) -> dict[str, Any]: + """Convert this partition to a JSON-serialisable dictionary.""" + raise NotImplementedError + + def to_hdf5(self, group) -> None: + """Save this partition to an HDF5 group.""" + group.attrs["term_partition"] = _json.dumps(self.to_json()) + + @staticmethod + def from_json(data: dict[str, Any]) -> TermPartition: + """Reconstruct a :class:`TermPartition` from :meth:`to_json` output. + + Args: + data: Dict produced by :meth:`to_json` of either :class:`FlatPartition` or :class:`LayeredPartition`. + + Returns: + The reconstructed partition. + + Raises: + ValueError: If ``data["kind"]`` is not a recognised partition kind. + + """ + kind = data.get("kind") + if kind == "flat": + return FlatPartition(strategy=data["strategy"], groups=tuple(tuple(g) for g in data["groups"])) + if kind == "layered": + return LayeredPartition( + strategy=data["strategy"], + groups=tuple(tuple(tuple(layer) for layer in group) for group in data["groups"]), + ) + raise ValueError(f"Unknown TermPartition kind: {kind!r}. Expected 'flat' or 'layered'.") + + @classmethod + def from_hdf5(cls, group) -> TermPartition: + """Load a :class:`TermPartition` from an HDF5 group.""" + raw = group.attrs["term_partition"] + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + data = _json.loads(raw) + return cls.from_json(data) + + def __eq__(self, other: object) -> bool: + """Check equality by type and strategy.""" + if not isinstance(other, TermPartition): + return NotImplemented + return type(self) is type(other) and self.strategy == other.strategy + + def __hash__(self) -> int: + """Return hash.""" + return hash((type(self).__name__, self.strategy)) + + +class FlatPartition(TermPartition): + """Single-level partition: each group is a list of term indices. + + Suitable for algorithms that only care about which terms belong together + (for example, qubit-wise commuting groups for measurement basis selection). + + The ``groups`` field is a tuple of groups; each group is a tuple of term + indices into :attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings`. + + Raises: + TypeError: If ``groups`` is not a sequence of sequences of integers. + + """ + + def __init__(self, *, strategy: str, groups: tuple[tuple[int, ...], ...]) -> None: + """Initialize a flat partition. + + Args: + strategy: Label identifying how the partition was produced. + groups: Tuple of groups; each group is a tuple of term indices. + + """ + super().__init__(strategy=strategy) + self.groups: tuple[tuple[int, ...], ...] = tuple(tuple(int(i) for i in group) for group in groups) + # Freeze after all attributes are set. + DataClass.__init__(self) + + @property + def num_groups(self) -> int: + """Return the number of groups.""" + return len(self.groups) + + def all_indices(self) -> list[int]: + """Return every term index referenced by the partition, in order.""" + return [i for group in self.groups for i in group] + + def get_summary(self) -> str: + """Return a summary of the flat partition.""" + return f"FlatPartition(strategy={self.strategy!r}, num_groups={self.num_groups})" + + def to_json(self) -> dict[str, Any]: + """Return a JSON-serialisable dict of this :class:`FlatPartition`.""" + return { + "kind": "flat", + "strategy": self.strategy, + "groups": [list(group) for group in self.groups], + } + + def __eq__(self, other: object) -> bool: + """Check equality by strategy and groups.""" + if not isinstance(other, FlatPartition): + return NotImplemented + return self.strategy == other.strategy and self.groups == other.groups + + def __hash__(self) -> int: + """Return hash.""" + return hash(("FlatPartition", self.strategy, self.groups)) + + +class LayeredPartition(TermPartition): + """Two-level partition: each group is a sequence of parallelisable layers. + + Suitable for Trotter-style decompositions where the outer level controls + Strang/Suzuki splitting order and the inner level groups operators with + disjoint qubit supports that can be applied in parallel. + + The ``groups`` field is a nested tuple ``(group, layer, term_index)``: + outer = groups, middle = layers within a group, inner = term indices into + :attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings`. + + Raises: + TypeError: If ``groups`` is not the expected nested-sequence shape. + + """ + + def __init__(self, *, strategy: str, groups: tuple[tuple[tuple[int, ...], ...], ...]) -> None: + """Initialize a layered partition. + + Args: + strategy: Label identifying how the partition was produced. + groups: Nested tuple ``(group, layer, term_index)``. + + """ + super().__init__(strategy=strategy) + self.groups: tuple[tuple[tuple[int, ...], ...], ...] = tuple( + tuple(tuple(int(i) for i in layer) for layer in group) for group in groups + ) + # Freeze after all attributes are set. + DataClass.__init__(self) + + @property + def num_groups(self) -> int: + """Return the number of top-level groups.""" + return len(self.groups) + + def num_layers(self, group_index: int) -> int: + """Return the number of parallelisable layers in ``group_index``.""" + return len(self.groups[group_index]) + + def all_indices(self) -> list[int]: + """Return every term index referenced by the partition, in order.""" + return [i for group in self.groups for layer in group for i in layer] + + def get_summary(self) -> str: + """Return a summary of the layered partition.""" + return f"LayeredPartition(strategy={self.strategy!r}, num_groups={self.num_groups})" + + def to_json(self) -> dict[str, Any]: + """Return a JSON-serialisable dict of this :class:`LayeredPartition`.""" + return { + "kind": "layered", + "strategy": self.strategy, + "groups": [[list(layer) for layer in group] for group in self.groups], + } + + def __eq__(self, other: object) -> bool: + """Check equality by strategy and groups.""" + if not isinstance(other, LayeredPartition): + return NotImplemented + return self.strategy == other.strategy and self.groups == other.groups + + def __hash__(self) -> int: + """Return hash.""" + return hash(("LayeredPartition", self.strategy, self.groups)) diff --git a/python/src/qdk_chemistry/plugins/networkx/__init__.py b/python/src/qdk_chemistry/plugins/networkx/__init__.py new file mode 100644 index 000000000..3d492e300 --- /dev/null +++ b/python/src/qdk_chemistry/plugins/networkx/__init__.py @@ -0,0 +1,59 @@ +"""NetworkX plugin for QDK/Chemistry. + +Provides improved graph-coloring-based term groupers using networkx's +DSATUR (saturation-largest-first) strategy, which typically produces +fewer groups than the built-in greedy first-fit algorithm. + +When loaded, this plugin registers two additional ``term_grouper`` +algorithms: + +- ``"nx_commuting"`` — full Pauli commutation grouping via DSATUR +- ``"nx_qubit_wise_commuting"`` — qubit-wise commutation grouping via DSATUR + +These are drop-in alternatives to the built-in ``"commuting"`` and +``"qubit_wise_commuting"`` groupers. + +""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import importlib.util + +from qdk_chemistry.utils import Logger + +_loaded = False +QDK_CHEMISTRY_HAS_NETWORKX = False + + +def load(): + """Load the NetworkX plugin into QDK/Chemistry.""" + Logger.trace_entering() + global _loaded, QDK_CHEMISTRY_HAS_NETWORKX # noqa: PLW0603 + if _loaded: + return + _loaded = True + + if importlib.util.find_spec("networkx") is not None: + QDK_CHEMISTRY_HAS_NETWORKX = True + _register_algorithms() + + +def _register_algorithms(): + """Register NetworkX-backed term grouper algorithms.""" + Logger.trace_entering() + from qdk_chemistry.algorithms import register # noqa: PLC0415 + from qdk_chemistry.plugins.networkx.term_grouper import ( # noqa: PLC0415 + NxFullCommutingTermGrouper, + NxQubitWiseCommutingTermGrouper, + ) + + register(lambda: NxFullCommutingTermGrouper()) + register(lambda: NxQubitWiseCommutingTermGrouper()) + Logger.debug( + f"NetworkX plugin loaded: " + f"[{NxFullCommutingTermGrouper().type_name()}: {NxFullCommutingTermGrouper().name()}], " + f"[{NxQubitWiseCommutingTermGrouper().type_name()}: {NxQubitWiseCommutingTermGrouper().name()}]." + ) diff --git a/python/src/qdk_chemistry/plugins/networkx/term_grouper.py b/python/src/qdk_chemistry/plugins/networkx/term_grouper.py new file mode 100644 index 000000000..59451677c --- /dev/null +++ b/python/src/qdk_chemistry/plugins/networkx/term_grouper.py @@ -0,0 +1,128 @@ +"""NetworkX-backed term groupers using DSATUR graph coloring.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import networkx as nx + +from qdk_chemistry.algorithms.term_grouper.base import TermGrouper +from qdk_chemistry.data import FlatPartition, QubitHamiltonian +from qdk_chemistry.utils.pauli_commutation import do_pauli_labels_commute, do_pauli_labels_qw_commute + +if TYPE_CHECKING: + from collections.abc import Callable + +__all__ = ["NxFullCommutingTermGrouper", "NxQubitWiseCommutingTermGrouper"] + + +def _dsatur_commutation_grouping( + pauli_strings: list[str], + commutes: Callable[[str, str], bool], +) -> tuple[tuple[int, ...], ...]: + """Partition Pauli labels using networkx DSATUR graph coloring. + + Builds the non-commutation graph (vertices = Pauli terms, edges between + non-commuting pairs) and colors it using the saturation-largest-first + (DSATUR) strategy, which greedily colors the vertex with the highest + saturation degree (most distinct colors among neighbours). + + Args: + pauli_strings: Pauli labels to partition. + commutes: Predicate returning ``True`` when two labels commute. + + Returns: + Tuple of groups; each group is a tuple of indices into ``pauli_strings``. + + """ + n = len(pauli_strings) + if n == 0: + return () + + g = nx.Graph() + g.add_nodes_from(range(n)) + for i in range(1, n): + for j in range(i): + if not commutes(pauli_strings[i], pauli_strings[j]): + g.add_edge(i, j) + + coloring = nx.coloring.greedy_color(g, strategy="saturation_largest_first") + + color_to_indices: dict[int, list[int]] = {} + for node, color in sorted(coloring.items()): + color_to_indices.setdefault(color, []).append(node) + + return tuple(tuple(indices) for indices in color_to_indices.values()) + + +class NxFullCommutingTermGrouper(TermGrouper): + """Group terms by full Pauli commutation using networkx DSATUR. + + Uses the saturation-largest-first (DSATUR) graph coloring strategy + from networkx, which typically produces fewer groups than the built-in + greedy first-fit algorithm. + + """ + + def name(self) -> str: + """Return ``nx_commuting`` as the algorithm name.""" + return "nx_commuting" + + def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: + """Return a copy of ``qubit_hamiltonian`` with a full-commutation DSATUR partition. + + Args: + qubit_hamiltonian: Hamiltonian to partition. + + Returns: + QubitHamiltonian: New instance with a ``FlatPartition`` (strategy ``"nx_commuting"``). + + """ + groups = _dsatur_commutation_grouping(qubit_hamiltonian.pauli_strings, do_pauli_labels_commute) + partition = FlatPartition(strategy="nx_commuting", groups=groups) + return QubitHamiltonian( + pauli_strings=list(qubit_hamiltonian.pauli_strings), + coefficients=qubit_hamiltonian.coefficients.copy(), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + term_partition=partition, + ) + + +class NxQubitWiseCommutingTermGrouper(TermGrouper): + """Group terms by qubit-wise commutation using networkx DSATUR. + + Uses the saturation-largest-first (DSATUR) graph coloring strategy + from networkx, which typically produces fewer groups than the built-in + greedy first-fit algorithm. + + """ + + def name(self) -> str: + """Return ``nx_qubit_wise_commuting`` as the algorithm name.""" + return "nx_qubit_wise_commuting" + + def _run_impl(self, qubit_hamiltonian: QubitHamiltonian) -> QubitHamiltonian: + """Return a copy with a qubit-wise commutation DSATUR partition. + + Args: + qubit_hamiltonian: Hamiltonian to partition. + + Returns: + QubitHamiltonian: New instance with a ``FlatPartition`` (strategy ``"nx_qubit_wise_commuting"``). + + """ + groups = _dsatur_commutation_grouping(qubit_hamiltonian.pauli_strings, do_pauli_labels_qw_commute) + partition = FlatPartition(strategy="nx_qubit_wise_commuting", groups=groups) + return QubitHamiltonian( + pauli_strings=list(qubit_hamiltonian.pauli_strings), + coefficients=qubit_hamiltonian.coefficients.copy(), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + term_partition=partition, + ) diff --git a/python/src/qdk_chemistry/utils/model_hamiltonians.py b/python/src/qdk_chemistry/utils/model_hamiltonians.py index fac2f63eb..6b9b8819f 100644 --- a/python/src/qdk_chemistry/utils/model_hamiltonians.py +++ b/python/src/qdk_chemistry/utils/model_hamiltonians.py @@ -17,7 +17,8 @@ to_pair_param, to_site_param, ) -from qdk_chemistry.data import LatticeGraph, PauliOperator, QubitHamiltonian +from qdk_chemistry.data import LatticeGraph, LayeredPartition, PauliOperator, QubitHamiltonian +from qdk_chemistry.utils import Logger __all__ = [ "create_heisenberg_hamiltonian", @@ -40,6 +41,107 @@ def _pauli_expr_to_qubit_hamiltonian(expr, num_qubits: int) -> QubitHamiltonian: return QubitHamiltonian(pauli_strings, coefficients) +def _build_geometry_grouped_hamiltonian( + graph: LatticeGraph, + *, + couplings: list[tuple[str, np.ndarray | float]], + fields: list[tuple[str, np.ndarray | float]], + coloring: dict[tuple[int, int], int] | None = None, +) -> QubitHamiltonian: + r"""Assemble a Heisenberg-like Hamiltonian with a populated ``term_partition``. + + This helper exists separately from the ungrouped construction path + because it builds the Pauli-string list in a specific order dictated + by the lattice edge coloring, then records that order as a + :class:`~qdk_chemistry.data.LayeredPartition`. The ungrouped path + constructs terms from the adjacency matrix directly without regard + to color structure. + + Groups are organised first by single-body field direction (one group + per direction, each containing a single layer because field terms + have disjoint support), then by two-body coupling type (one group + per ``XX``/``YY``/``ZZ`` block, each split into layers by edge + color). Term indices in + :attr:`~qdk_chemistry.data.QubitHamiltonian.pauli_strings` align with the + indices stored in the returned :class:`LayeredPartition`. + + Args: + graph: Lattice graph defining connectivity. + couplings: ``[(label, value), ...]`` for two-body terms (e.g. ``[(\"XX\", jx)]``). + fields: ``[(char, value), ...]`` for single-body terms (e.g. ``[(\"X\", hx)]``). + coloring: Optional edge coloring ``{(i, j): color}`` (``i < j``). Reads ``graph.edge_coloring`` when ``None``. + + Returns: + QubitHamiltonian: The assembled Hamiltonian carrying a ``LayeredPartition`` + with ``strategy=\"geometry_coloring\"``. + + """ + n = graph.num_sites + adj = graph.adjacency_matrix() + + if coloring is None: + coloring = getattr(graph, "edge_coloring", None) + if coloring is None: + raise ValueError( + "No edge coloring available on the lattice graph. " + "Use a factory method that provides one, or pass an explicit coloring." + ) + + pauli_strings: list[str] = [] + coefficients: list[complex] = [] + groups_layers: list[tuple[tuple[int, ...], ...]] = [] + + # Field groups: one group per direction, single layer each. + for pauli_char, field in fields: + field_vec = to_site_param(field, graph, "field") + layer_indices: list[int] = [] + for i in range(n): + if field_vec[i] == 0.0: + continue + ps = ["I"] * n + ps[i] = pauli_char + pauli_strings.append("".join(ps[::-1])) + coefficients.append(complex(field_vec[i])) + layer_indices.append(len(pauli_strings) - 1) + if layer_indices: + groups_layers.append((tuple(layer_indices),)) + + # Coupling groups: one group per (XX/YY/ZZ) block; layers given by edge colors. + for pauli_label, coupling in couplings: + coupling_mat = to_pair_param(coupling, graph, "coupling") + color_to_indices: dict[int, list[int]] = {} + for (i, j), c in coloring.items(): + edge_weight = adj[i, j] + if edge_weight == 0.0: + continue + coeff_val = coupling_mat[i, j] * edge_weight + if coeff_val == 0.0: + continue + ps = ["I"] * n + ps[i] = pauli_label[0] + ps[j] = pauli_label[1] + pauli_strings.append("".join(ps[::-1])) + coefficients.append(complex(coeff_val)) + color_to_indices.setdefault(c, []).append(len(pauli_strings) - 1) + if color_to_indices: + layers = tuple(tuple(color_to_indices[c]) for c in sorted(color_to_indices)) + groups_layers.append(layers) + + if not pauli_strings: + # Empty Hamiltonian: emit a single all-identity term with zero coefficient + # so the resulting QubitHamiltonian remains constructible. + pauli_strings = ["I" * n] + coefficients = [0.0 + 0.0j] + groups_layers = [((0,),)] + + partition = LayeredPartition(strategy="geometry_coloring", groups=tuple(groups_layers)) + return QubitHamiltonian( + pauli_strings=pauli_strings, + coefficients=np.array(coefficients), + term_partition=partition, + ) + + def create_heisenberg_hamiltonian( graph: LatticeGraph, jx: np.ndarray | float, @@ -48,6 +150,8 @@ def create_heisenberg_hamiltonian( hx: np.ndarray | float = 0.0, hy: np.ndarray | float = 0.0, hz: np.ndarray | float = 0.0, + *, + include_term_groups: bool = True, ) -> QubitHamiltonian: r"""Create the anisotropic Heisenberg model Hamiltonian on a lattice. @@ -76,14 +180,24 @@ def create_heisenberg_hamiltonian( hx: External magnetic field in the x direction. Scalar or length-n array. Defaults to 0. hy: External magnetic field in the y direction. Defaults to 0. hz: External magnetic field in the z direction. Defaults to 0. + include_term_groups: When ``True`` (default), attach a geometry-coloring term partition to the result. Returns: - QubitHamiltonian: The Heisenberg model as a qubit Hamiltonian. + QubitHamiltonian: The Heisenberg model as a qubit Hamiltonian; carries a ``LayeredPartition`` when grouped. """ if not graph.is_symmetric: raise ValueError("Lattice graph must be symmetric for a valid Hamiltonian.") + if include_term_groups: + if getattr(graph, "edge_coloring", None) is not None: + return _build_geometry_grouped_hamiltonian( + graph, + couplings=[("XX", jx), ("YY", jy), ("ZZ", jz)], + fields=[("X", hx), ("Y", hy), ("Z", hz)], + ) + Logger.info("No edge coloring on lattice graph; falling back to ungrouped Hamiltonian construction.") + n = graph.num_sites adj = graph.adjacency_matrix() @@ -125,6 +239,8 @@ def create_ising_hamiltonian( graph: LatticeGraph, j: np.ndarray | float, h: np.ndarray | float = 0.0, + *, + include_term_groups: bool = True, ) -> QubitHamiltonian: r"""Create the Ising model Hamiltonian on a lattice. @@ -139,9 +255,10 @@ def create_ising_hamiltonian( graph: Lattice graph defining the connectivity. j: Coupling constant for ZZ interactions. Scalar or ``(n, n)`` array. h: Transverse field strength (x direction). Scalar or length-n array. Defaults to 0. + include_term_groups: When ``True`` (default), attach a geometry-coloring term partition to the result. Returns: QubitHamiltonian: The Ising model as a qubit Hamiltonian. """ - return create_heisenberg_hamiltonian(graph, jx=0.0, jy=0.0, jz=j, hx=h) + return create_heisenberg_hamiltonian(graph, jx=0.0, jy=0.0, jz=j, hx=h, include_term_groups=include_term_groups) diff --git a/python/tests/test_encoding_metadata.py b/python/tests/test_encoding_metadata.py index e32020dea..486e7082c 100644 --- a/python/tests/test_encoding_metadata.py +++ b/python/tests/test_encoding_metadata.py @@ -9,7 +9,7 @@ import numpy as np import pytest -from qdk_chemistry.algorithms import create +from qdk_chemistry.algorithms import create, registry from qdk_chemistry.data import Circuit, EncodingMismatchError, QubitHamiltonian, validate_encoding_compatibility from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT, QDK_CHEMISTRY_HAS_QISKIT_NATURE @@ -232,17 +232,13 @@ def test_qdk_qubit_mapper_injects_encoding(): def test_group_commuting_preserves_encoding(): - """Test that group_commuting preserves the encoding metadata.""" + """Test that term_grouper preserves the encoding metadata.""" pauli_strings = ["II", "ZI", "IZ", "ZZ", "XX", "YY"] coefficients = np.array([1.0, 0.5, 0.5, 0.25, 0.3, 0.3]) ham = QubitHamiltonian(pauli_strings, coefficients, encoding="jordan-wigner") - # Group into commuting subsets - grouped = ham.group_commuting(qubit_wise=True) - - # Each group should preserve the encoding - for group in grouped: - assert group.encoding == "jordan-wigner" + grouped = registry.create("term_grouper", "qubit_wise_commuting").run(ham) + assert grouped.encoding == "jordan-wigner" def test_end_to_end_workflow_compatible_encodings(wavefunction_4e4o): diff --git a/python/tests/test_energy_estimator.py b/python/tests/test_energy_estimator.py index e57d2fde0..e5cab6ab4 100644 --- a/python/tests/test_energy_estimator.py +++ b/python/tests/test_energy_estimator.py @@ -12,7 +12,7 @@ import numpy as np import pytest -from qdk_chemistry.algorithms import create +from qdk_chemistry.algorithms import create, registry from qdk_chemistry.algorithms.energy_estimator.qdk import ( QdkEnergyEstimator, _append_measurement_to_circuit, @@ -287,6 +287,8 @@ def test_estimator_run_4e4o(executor_name, wavefunction_4e4o, ref_energy_4e4o): test_hamiltonian = QubitHamiltonian( ["IIIIIZII", "IXXIIXXI", "IIIIIIZI"], np.array([0.76388709, 0.1022262, 1.03502496]) ) + # Pre-group by QWC so the estimator uses grouped measurement. + test_hamiltonian = registry.create("term_grouper", "qubit_wise_commuting").run(test_hamiltonian) estimator = QdkEnergyEstimator() estimator.settings().set( "circuit_executor", diff --git a/python/tests/test_qubit_hamiltonian.py b/python/tests/test_qubit_hamiltonian.py index 0f462d8c7..95e652c0f 100644 --- a/python/tests/test_qubit_hamiltonian.py +++ b/python/tests/test_qubit_hamiltonian.py @@ -13,8 +13,10 @@ import pytest import scipy.sparse +from qdk_chemistry.algorithms import registry from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian +from qdk_chemistry.data.term_partition import FlatPartition from .reference_tolerances import float_comparison_absolute_tolerance, float_comparison_relative_tolerance @@ -67,74 +69,79 @@ def test_initialization_invalid_pauli(self): QubitHamiltonian(pauli_strings=[], coefficients=[]) def test_group_commuting(self): - """Test group_commuting.""" + """Test full-commuting term grouper produces correct groups.""" qubit_hamiltonian = QubitHamiltonian(["XX", "YY", "ZZ", "XY"], [1.0, 0.5, -0.5, 0.2]) - grouped = qubit_hamiltonian.group_commuting(qubit_wise=False) - assert len(grouped) == 2 + grouped = registry.create("term_grouper", "commuting").run(qubit_hamiltonian) + partition = grouped.term_partition + assert isinstance(partition, FlatPartition) + assert partition.num_groups == 2 # Verify coefficients are preserved - coeff_map = dict(zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=True)) - for group in grouped: - for pauli_str, coeff in zip(group.pauli_strings, group.coefficients, strict=True): + for group_indices in partition.groups: + for idx in group_indices: assert np.isclose( - coeff, - coeff_map[pauli_str], + grouped.coefficients[idx], + qubit_hamiltonian.coefficients[idx], atol=float_comparison_absolute_tolerance, rtol=float_comparison_relative_tolerance, ) def test_group_commuting_qubitwise(self): - """Test group_commuting without qubit-wise commuting.""" + """Test qubit-wise commuting grouper.""" qubit_hamiltonian = QubitHamiltonian(["XX", "YY", "ZZ", "XY"], [1.0, 0.5, -0.5, 0.2]) - grouped = qubit_hamiltonian.group_commuting(qubit_wise=True) - assert len(grouped) == 4 # Qubit-wise commuting returns four groups - - # Check that all original Pauli strings are present across all groups - all_grouped_strings = [] - for group in grouped: - assert len(group.pauli_strings) == 1 # Each group should contain only one Pauli string - all_grouped_strings.extend(group.pauli_strings) - assert set(all_grouped_strings) == {"XX", "YY", "ZZ", "XY"} + grouped = registry.create("term_grouper", "qubit_wise_commuting").run(qubit_hamiltonian) + partition = grouped.term_partition + assert isinstance(partition, FlatPartition) + assert partition.num_groups == 4 + + # Each group should contain exactly one term + for group_indices in partition.groups: + assert len(group_indices) == 1 + # All original terms are present + all_indices = sorted(partition.all_indices()) + assert all_indices == list(range(4)) def test_group_commuting_all_commute(self): """Test that fully commuting operators go into one group.""" - # ZI, IZ, ZZ all commute with each other qh = QubitHamiltonian(["ZI", "IZ", "ZZ"], np.array([1.0, -0.5, 0.3])) - grouped = qh.group_commuting(qubit_wise=False) - assert len(grouped) == 1 - assert len(grouped[0].pauli_strings) == 3 + grouped = registry.create("term_grouper", "commuting").run(qh) + assert grouped.term_partition.num_groups == 1 + assert len(grouped.term_partition.groups[0]) == 3 def test_group_commuting_none_commute(self): """Test that non-commuting operators each get their own group.""" - # X and Z anticommute; Y and X anticommute; Y and Z anticommute qh = QubitHamiltonian(["X", "Z", "Y"], np.array([1.0, -0.5, 0.3])) - grouped = qh.group_commuting(qubit_wise=False) - assert len(grouped) == 3 + grouped = registry.create("term_grouper", "commuting").run(qh) + assert grouped.term_partition.num_groups == 3 def test_group_commuting_single_term(self): - """Test group_commuting with a single term.""" + """Test grouping with a single term.""" qh = QubitHamiltonian(["ZZ"], np.array([1.0])) - grouped = qh.group_commuting(qubit_wise=False) - assert len(grouped) == 1 - assert grouped[0].pauli_strings == ["ZZ"] + grouped = registry.create("term_grouper", "commuting").run(qh) + assert grouped.term_partition.num_groups == 1 + assert grouped.pauli_strings == ["ZZ"] def test_group_commuting_reconstruct_matrix(self): - """Test group_commuting with matrix verification.""" + """Test that grouped terms reconstruct the same matrix.""" qh = QubitHamiltonian( ["II", "IZ", "ZI", "ZZ", "XX", "YY"], np.array([-0.8, 0.17, -0.17, 0.12, 0.04, 0.04]), ) - # General commuting: all diagonal terms commute, XX and YY commute with each other and with diag terms - grouped = qh.group_commuting(qubit_wise=False) - total_terms = sum(len(g.pauli_strings) for g in grouped) + grouped = registry.create("term_grouper", "commuting").run(qh) + partition = grouped.term_partition + total_terms = sum(len(g) for g in partition.groups) assert total_terms == 6 - # Verify ground state energy via eigenvalues + mat = qh.to_matrix() gs_energy = np.min(np.linalg.eigvalsh(mat)) # Reconstruct from groups and check same ground state energy full_mat = np.zeros_like(mat) - for g in grouped: - full_mat += g.to_matrix() + for group_indices in partition.groups: + sub = QubitHamiltonian( + [grouped.pauli_strings[i] for i in group_indices], + np.array([grouped.coefficients[i] for i in group_indices]), + ) + full_mat += sub.to_matrix() gs_energy_grouped = np.min(np.linalg.eigvalsh(full_mat)) assert np.isclose(gs_energy, gs_energy_grouped, atol=float_comparison_absolute_tolerance) @@ -144,10 +151,16 @@ def test_group_commuting_qw_reconstruct_matrix(self): coeffs = np.array([0.5, 0.3, 0.2, -0.1, 0.4, -0.25, 0.15]) qh = QubitHamiltonian(labels, coeffs) original_mat = qh.to_matrix() - groups = qh.group_commuting(qubit_wise=True) + + grouped = registry.create("term_grouper", "qubit_wise_commuting").run(qh) + partition = grouped.term_partition reconstructed = np.zeros_like(original_mat) - for g in groups: - reconstructed += g.to_matrix() + for group_indices in partition.groups: + sub = QubitHamiltonian( + [grouped.pauli_strings[i] for i in group_indices], + np.array([grouped.coefficients[i] for i in group_indices]), + ) + reconstructed += sub.to_matrix() assert np.allclose( reconstructed, original_mat, @@ -531,14 +544,14 @@ def test_hdf5_roundtrip_none(self, tmp_path): assert restored.fermion_mode_order is None def test_group_commuting_preserves(self): - """group_commuting preserves fermion_mode_order.""" + """term_grouper preserves fermion_mode_order.""" qh = QubitHamiltonian( ["XX", "YY", "ZZ"], np.array([1.0, 0.5, -0.5]), fermion_mode_order=FermionModeOrder.BLOCKED, ) - for group in qh.group_commuting(qubit_wise=True): - assert group.fermion_mode_order == FermionModeOrder.BLOCKED + grouped = registry.create("term_grouper", "qubit_wise_commuting").run(qh) + assert grouped.fermion_mode_order == FermionModeOrder.BLOCKED def test_to_interleaved_sets_order(self): """to_interleaved sets fermion_mode_order to INTERLEAVED.""" diff --git a/python/tests/test_term_partition.py b/python/tests/test_term_partition.py new file mode 100644 index 000000000..ff816a579 --- /dev/null +++ b/python/tests/test_term_partition.py @@ -0,0 +1,313 @@ +"""Tests for the ``TermPartition`` infrastructure and ``term_grouper`` algorithms.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from __future__ import annotations + +import numpy as np +import pytest + +from qdk_chemistry.algorithms import registry +from qdk_chemistry.data import ( + FlatPartition, + LatticeGraph, + LayeredPartition, + QubitHamiltonian, + TermPartition, +) +from qdk_chemistry.utils.model_hamiltonians import ( + create_heisenberg_hamiltonian, + create_ising_hamiltonian, +) + +# --------------------------------------------------------------------------- +# TermPartition data classes +# --------------------------------------------------------------------------- + + +class TestFlatPartition: + def test_construction_normalises_to_tuples_of_ints(self): + p = FlatPartition(strategy="commuting", groups=[[0, 1, 2], [3, 4]]) + assert isinstance(p.groups, tuple) + assert all(isinstance(g, tuple) for g in p.groups) + assert all(isinstance(i, int) for g in p.groups for i in g) + assert p.groups == ((0, 1, 2), (3, 4)) + + def test_num_groups(self): + p = FlatPartition(strategy="x", groups=[[0], [1, 2], [3]]) + assert p.num_groups == 3 + + def test_all_indices(self): + p = FlatPartition(strategy="x", groups=[[2, 1], [0]]) + assert p.all_indices() == [2, 1, 0] + + def test_is_subclass_of_term_partition(self): + assert issubclass(FlatPartition, TermPartition) + + +class TestLayeredPartition: + def test_construction(self): + p = LayeredPartition( + strategy="geometry_coloring", + groups=[[[0, 1], [2, 3]], [[4]]], + ) + assert p.groups == (((0, 1), (2, 3)), ((4,),)) + + def test_num_groups_and_layers(self): + p = LayeredPartition( + strategy="x", + groups=[[[0]], [[1], [2]], [[3], [4], [5]]], + ) + assert p.num_groups == 3 + assert p.num_layers(0) == 1 + assert p.num_layers(1) == 2 + assert p.num_layers(2) == 3 + + def test_all_indices_flattens_in_order(self): + p = LayeredPartition(strategy="x", groups=[[[0, 1], [2]], [[3, 4]]]) + assert p.all_indices() == [0, 1, 2, 3, 4] + + +# --------------------------------------------------------------------------- +# QubitHamiltonian.term_partition property +# --------------------------------------------------------------------------- + + +class TestQubitHamiltonianTermPartition: + def test_default_is_none(self): + qh = QubitHamiltonian(["XX", "ZZ"], np.array([0.1, 0.2])) + assert qh.term_partition is None + + def test_round_trip_flat(self): + partition = FlatPartition(strategy="commuting", groups=[[0], [1]]) + qh = QubitHamiltonian(["XX", "ZZ"], np.array([0.1, 0.2]), term_partition=partition) + assert qh.term_partition is partition + + def test_round_trip_layered(self): + partition = LayeredPartition(strategy="geometry_coloring", groups=[[[0, 1]]]) + qh = QubitHamiltonian(["XX", "ZZ"], np.array([0.1, 0.2]), term_partition=partition) + assert qh.term_partition is partition + + def test_to_interleaved_resets_partition(self): + partition = FlatPartition(strategy="commuting", groups=[[0, 1, 2, 3]]) + qh = QubitHamiltonian( + ["XXII", "YYII", "IIZZ", "IIXX"], + np.array([0.1, 0.2, 0.3, 0.4]), + term_partition=partition, + ) + out = qh.to_interleaved(n_spatial=2) + assert out.term_partition is None + + +# --------------------------------------------------------------------------- +# term_grouper algorithm registry integration +# --------------------------------------------------------------------------- + + +class TestTermGrouperRegistry: + def test_available_strategies(self): + names = registry.available("term_grouper") + assert {"commuting", "qubit_wise_commuting", "identity"} <= set(names) + + def test_default_strategy_is_commuting(self): + grouper = registry.create("term_grouper") + assert grouper.name() == "commuting" + + @pytest.mark.parametrize("strategy", ["commuting", "qubit_wise_commuting", "identity"]) + def test_returns_new_hamiltonian_with_partition(self, strategy): + qh = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([1.0, 2.0, 3.0])) + grouper = registry.create("term_grouper", strategy) + out = grouper.run(qh) + assert out is not qh + assert isinstance(out.term_partition, FlatPartition) + assert out.term_partition.strategy == strategy + + def test_partition_indices_cover_all_terms_exactly_once(self): + qh = QubitHamiltonian( + ["XIII", "IXII", "IIXI", "IIIX", "ZIII", "IZII"], + np.array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]), + ) + for strategy in ("commuting", "qubit_wise_commuting", "identity"): + grouper = registry.create("term_grouper", strategy) + out = grouper.run(qh) + indices = out.term_partition.all_indices() + assert sorted(indices) == list(range(len(qh.pauli_strings))) + + def test_identity_strategy_one_term_per_group(self): + qh = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([1.0, 2.0, 3.0])) + out = registry.create("term_grouper", "identity").run(qh) + assert out.term_partition.num_groups == len(qh.pauli_strings) + assert all(len(g) == 1 for g in out.term_partition.groups) + + def test_commuting_groups_globally_commute(self): + # XX and YY commute (XY * YX = -ZZ * -ZZ = ZZ^2 = I; and YX * XY = ZZ), + # ZZ commutes with both. So all three should land in the same group. + qh = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([1.0, 1.0, 1.0])) + out = registry.create("term_grouper", "commuting").run(qh) + assert out.term_partition.num_groups == 1 + + def test_qwc_separates_paulis_that_only_globally_commute(self): + # XX and YY are NOT qubit-wise commuting, even though they globally commute. + qh = QubitHamiltonian(["XX", "YY"], np.array([1.0, 1.0])) + out = registry.create("term_grouper", "qubit_wise_commuting").run(qh) + assert out.term_partition.num_groups == 2 + + +# --------------------------------------------------------------------------- +# LatticeGraph.edge_coloring overlay +# --------------------------------------------------------------------------- + + +_has_edge_coloring = hasattr(LatticeGraph.chain(2, periodic=False), "edge_coloring") + + +@pytest.mark.skipif(not _has_edge_coloring, reason="C++ edge_coloring not available in this build") +class TestLatticeEdgeColoring: + def test_chain_two_colors(self): + lat = LatticeGraph.chain(4, periodic=True) + coloring = lat.edge_coloring + assert coloring is not None + assert len(set(coloring.values())) == 2 + + def test_returns_dict_or_none(self): + lat = LatticeGraph.chain(3, periodic=False) + coloring = lat.edge_coloring + assert isinstance(coloring, dict) + + +# --------------------------------------------------------------------------- +# create_*_hamiltonian populates term_partition +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not _has_edge_coloring, reason="C++ edge_coloring not available in this build") +class TestModelHamiltonianTermPartition: + def test_heisenberg_populates_layered_partition(self): + lat = LatticeGraph.chain(4, periodic=True) + ham = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0) + assert isinstance(ham.term_partition, LayeredPartition) + assert ham.term_partition.strategy == "geometry_coloring" + # Indices reach every term exactly once. + assert sorted(ham.term_partition.all_indices()) == list(range(len(ham.pauli_strings))) + + def test_ising_populates_layered_partition(self): + lat = LatticeGraph.chain(4, periodic=True) + ham = create_ising_hamiltonian(lat, j=1.0, h=0.5) + assert isinstance(ham.term_partition, LayeredPartition) + assert ham.term_partition.strategy == "geometry_coloring" + + def test_include_term_groups_false_disables_partition(self): + lat = LatticeGraph.chain(4, periodic=True) + ham = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0, include_term_groups=False) + assert ham.term_partition is None + + +# --------------------------------------------------------------------------- +# Trotter consumes term_partition +# --------------------------------------------------------------------------- + + +class TestTrotterConsumesTermPartition: + @pytest.mark.skipif(not _has_edge_coloring, reason="C++ edge_coloring not available in this build") + def test_trotter_runs_with_partitioned_hamiltonian(self): + lat = LatticeGraph.chain(4, periodic=True) + ham = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0) + trotter = registry.create("hamiltonian_unitary_builder", "trotter") + trotter.settings().update({"order": 2, "time": 0.5}) + unitary = trotter.run(ham) + assert unitary is not None + + def test_trotter_runs_without_partition(self): + # Falls back to treating each term as its own group. + ham = QubitHamiltonian(["XXII", "IXXI", "IIXX", "ZIII"], np.array([1.0, 1.0, 1.0, 0.5])) + assert ham.term_partition is None + trotter = registry.create("hamiltonian_unitary_builder", "trotter") + trotter.settings().update({"time": 0.5}) + unitary = trotter.run(ham) + assert unitary is not None + + @pytest.mark.skipif(not _has_edge_coloring, reason="C++ edge_coloring not available in this build") + def test_partition_produces_smaller_or_equal_step_count_at_order_2(self): + # With group sorting + schedule reduction, populating the partition + # should never produce more step terms than the ungrouped fallback. + lat = LatticeGraph.chain(4, periodic=True) + with_groups = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0, include_term_groups=True) + without_groups = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0, include_term_groups=False) + assert with_groups.term_partition is not None + assert without_groups.term_partition is None + + trotter = registry.create("hamiltonian_unitary_builder", "trotter") + trotter.settings().update({"order": 2, "num_divisions": 1, "time": 1.0}) + grouped_steps = len(trotter.run(with_groups).get_container().step_terms) + + trotter2 = registry.create("hamiltonian_unitary_builder", "trotter") + trotter2.settings().update({"order": 2, "num_divisions": 1, "time": 1.0}) + ungrouped_steps = len(trotter2.run(without_groups).get_container().step_terms) + + assert grouped_steps <= ungrouped_steps + + @pytest.mark.skipif(not _has_edge_coloring, reason="C++ edge_coloring not available in this build") + def test_trotter_runs_with_flat_partition(self): + # Take a partitioned Hamiltonian and overwrite term_partition with a + # FlatPartition (via the term_grouper algorithm), then drive Trotter. + lat = LatticeGraph.chain(4, periodic=True) + ham = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0) + flat = registry.create("term_grouper", "commuting").run(ham) + assert isinstance(flat.term_partition, FlatPartition) + + trotter = registry.create("hamiltonian_unitary_builder", "trotter") + trotter.settings().update({"order": 2, "time": 0.5}) + unitary = trotter.run(flat) + assert unitary is not None + + +# --------------------------------------------------------------------------- +# QubitHamiltonian round-trips term_partition through JSON / HDF5 +# --------------------------------------------------------------------------- + + +class TestTermPartitionSerialisation: + def test_flat_partition_to_json_round_trip(self): + partition = FlatPartition(strategy="commuting", groups=[[0, 2], [1]]) + data = partition.to_json() + assert data["kind"] == "flat" + restored = TermPartition.from_json(data) + assert isinstance(restored, FlatPartition) + assert restored == partition + + def test_layered_partition_to_json_round_trip(self): + partition = LayeredPartition(strategy="geometry_coloring", groups=[[[0, 1], [2]], [[3]]]) + data = partition.to_json() + assert data["kind"] == "layered" + restored = TermPartition.from_json(data) + assert isinstance(restored, LayeredPartition) + assert restored == partition + + def test_qubit_hamiltonian_json_round_trip_preserves_partition(self): + partition = FlatPartition(strategy="commuting", groups=[[0, 1], [2]]) + ham = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([0.1, 0.2, 0.3]), term_partition=partition) + restored = QubitHamiltonian.from_json(ham.to_json()) + assert isinstance(restored.term_partition, FlatPartition) + assert restored.term_partition == partition + + def test_qubit_hamiltonian_json_round_trip_with_no_partition(self): + ham = QubitHamiltonian(["XX", "ZZ"], np.array([0.1, 0.2])) + restored = QubitHamiltonian.from_json(ham.to_json()) + assert restored.term_partition is None + + def test_qubit_hamiltonian_hdf5_round_trip_preserves_partition(self, tmp_path): + h5py = pytest.importorskip("h5py") + partition = LayeredPartition(strategy="geometry_coloring", groups=[[[0], [1]], [[2]]]) + ham = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([0.1, 0.2, 0.3]), term_partition=partition) + + path = tmp_path / "ham.h5" + with h5py.File(path, "w") as f: + ham.to_hdf5(f) + with h5py.File(path, "r") as f: + restored = QubitHamiltonian.from_hdf5(f) + + assert isinstance(restored.term_partition, LayeredPartition) + assert restored.term_partition == partition diff --git a/python/tests/test_time_evolution_trotter.py b/python/tests/test_time_evolution_trotter.py index 22a922fa0..4ba122d6c 100644 --- a/python/tests/test_time_evolution_trotter.py +++ b/python/tests/test_time_evolution_trotter.py @@ -104,18 +104,13 @@ def test_multiple_trotter_steps(self): rtol=float_comparison_relative_tolerance, ) - def test_single_step_merge_identical_terms(self): - """Test construction of time evolution unitary with a single Trotter step.""" + def test_single_step_no_merge_without_partition(self): + """Test that without term_partition, duplicate terms are not merged.""" pauli_strings = ["XII", "IXI", "XII"] coefficients = [1.0, 1.0, 1.0] - # Scramble the order of the terms to ensure grouping works, using a fixed seed - perm = np.random.default_rng(seed=0).permutation(len(pauli_strings)) - pauli_strings = [pauli_strings[i] for i in perm] - coefficients = [coefficients[i] for i in perm] - hamiltonian = QubitHamiltonian(pauli_strings=pauli_strings, coefficients=coefficients) - builder = Trotter(num_divisions=1, time=1, optimize_term_ordering=True) + builder = Trotter(num_divisions=1, time=1) unitary = builder.run(hamiltonian) assert isinstance(unitary, UnitaryRepresentation) @@ -124,9 +119,8 @@ def test_single_step_merge_identical_terms(self): assert isinstance(container, PauliProductFormulaContainer) assert container.num_qubits == 3 assert container.step_reps == 1 - # After merging identical Pauli strings, 2 unique terms remain (XII with - # coeff=2 and IXI with coeff=1). Raw Pauli terms are emitted directly. - assert len(container.step_terms) == 2 + # Without a partition, each Pauli term is its own group — no merging. + assert len(container.step_terms) == 3 def test_basic_decomposition(self): """Test basic decomposition of a qubit Hamiltonian.""" @@ -507,8 +501,11 @@ def test_filters_small_coefficients_higher_order(self): terms = builder._decompose_trotter_step(hamiltonian, time=1.0, atol=1e-12) - assert len(terms) == 1 - assert terms[0].pauli_term == {0: "Z"} + # All terms should be Z only (X filtered out). + # After Suzuki recursion and schedule reduction, the total rotation + # angle across all Z terms must equal coeff * time = 1.0. + assert all(t.pauli_term == {0: "Z"} for t in terms) + assert abs(sum(t.angle for t in terms) - 1.0) < 1e-12 def test_trotter_x_z_example_higher_order(self): """Correctness check for fourth-order Trotter decomposition.""" @@ -860,50 +857,21 @@ def test_angle_scaling_with_auto_steps_higher_order(self): ) -class TestOptimizeTermOrdering: - """Tests for the optimize_term_ordering method.""" +class TestNoPartitionFallback: + """Tests for Trotter behavior when no term_partition is present.""" - def test_optimize_term_ordering_does_nothing_when_false(self): - """Test that optimize_term_ordering does nothing when optimize_term_ordering is False.""" + def test_no_partition_treats_each_term_individually(self): + """Test that without term_partition, each Pauli term is its own group.""" pauli_strings = ["ZZII", "XIII", "IZZI", "IXII", "IIZZ", "IIXI", "ZIIZ", "IIIX"] coefficients = [1.0] * 8 hamiltonian = QubitHamiltonian( pauli_strings=pauli_strings, coefficients=coefficients, ) - builder = Trotter(num_divisions=1, order=1, time=1.0, optimize_term_ordering=False) + builder = Trotter(num_divisions=1, order=1, time=1.0) t = 1.0 terms = builder.run(hamiltonian).get_container().step_terms for idx, term in enumerate(terms): assert term.pauli_term == builder._pauli_label_to_map(pauli_strings[idx]) assert term.angle == coefficients[idx] * t - - def test_optimize_term_ordering_groups_when_true(self): - """Test that optimize_term_ordering groups commuting terms into parallelizable layers.""" - pauli_strings = ["ZZII", "XIII", "IZZI", "IXII", "IIZZ", "IIXI", "ZIIZ", "IIIX"] - coefficients = [1.0] * 8 - hamiltonian = QubitHamiltonian( - pauli_strings=pauli_strings, - coefficients=coefficients, - ) - builder = Trotter(num_divisions=1, order=1, optimize_term_ordering=True) - terms = builder.run(hamiltonian).get_container().step_terms - - # Convert terms back to label strings for grouping - def term_to_label(term): - num_qubits = hamiltonian.num_qubits - chars = ["I"] * num_qubits - for qubit_idx, pauli_op in term.pauli_term.items(): - chars[num_qubits - 1 - qubit_idx] = pauli_op - return "".join(chars) - - term_labels = [term_to_label(t) for t in terms] - - # Raw Pauli terms are emitted directly (no Clifford sandwich). - # ZZ terms stay as their original labels; X terms stay as single-qubit X. - pauli_zz_labels = {"ZZII", "IIZZ", "IZZI", "ZIIZ"} - pauli_x_labels = {"XIII", "IXII", "IIXI", "IIIX"} - - assert len(terms) == len(pauli_zz_labels) + len(pauli_x_labels) - assert set(term_labels) == pauli_zz_labels | pauli_x_labels From 3c5200d5b37fff4520b36a783883cbe09a978a7f Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Tue, 5 May 2026 16:38:20 -0700 Subject: [PATCH 054/117] Review comments --- cpp/src/qdk/chemistry/data/lattice_graph.cpp | 65 ++++++++++++++++++- .../algorithms/energy_estimator/qdk.py | 31 ++++----- .../qdk_chemistry/data/qubit_hamiltonian.py | 12 ++++ 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/cpp/src/qdk/chemistry/data/lattice_graph.cpp b/cpp/src/qdk/chemistry/data/lattice_graph.cpp index 77bd80730..45616d0c8 100644 --- a/cpp/src/qdk/chemistry/data/lattice_graph.cpp +++ b/cpp/src/qdk/chemistry/data/lattice_graph.cpp @@ -678,6 +678,16 @@ nlohmann::json LatticeGraph::to_json() const { j["num_sites"] = _num_sites; j["is_symmetric"] = _is_symmetric; j["adjacency_sparse"] = edges; + + if (_edge_coloring.has_value()) { + nlohmann::json coloring_json = nlohmann::json::array(); + for (const auto& [edge, color] : *_edge_coloring) { + coloring_json.push_back( + {edge.first, edge.second, color}); + } + j["edge_coloring"] = coloring_json; + } + return j; } @@ -731,6 +741,24 @@ void LatticeGraph::to_hdf5(H5::Group& group) const { H5::DataSet dataset = group.createDataSet( "adjacency_sparse", H5::PredType::NATIVE_DOUBLE, dataspace); dataset.write(buffer.data(), H5::PredType::NATIVE_DOUBLE); + + // Serialize edge coloring as Nx3 dataset: [i, j, color] + if (_edge_coloring.has_value()) { + auto nc = static_cast(_edge_coloring->size()); + hsize_t cdims[2] = {nc, 3}; + H5::DataSpace cspace(2, cdims); + std::vector cbuf(nc * 3); + hsize_t ci = 0; + for (const auto& [edge, color] : *_edge_coloring) { + cbuf[ci * 3 + 0] = static_cast(edge.first); + cbuf[ci * 3 + 1] = static_cast(edge.second); + cbuf[ci * 3 + 2] = static_cast(color); + ++ci; + } + H5::DataSet cds = group.createDataSet( + "edge_coloring", H5::PredType::NATIVE_DOUBLE, cspace); + cds.write(cbuf.data(), H5::PredType::NATIVE_DOUBLE); + } } catch (const H5::Exception& e) { throw std::runtime_error("HDF5 error in LatticeGraph::to_hdf5: " + std::string(e.getCDetailMsg())); @@ -810,7 +838,20 @@ LatticeGraph LatticeGraph::from_json(const nlohmann::json& j) { static_cast(n)); sparse.setFromTriplets(triplets.begin(), triplets.end()); sparse.makeCompressed(); - return LatticeGraph(std::move(sparse)); + + std::optional coloring; + if (j.contains("edge_coloring")) { + EdgeColoring c; + for (const auto& entry : j["edge_coloring"]) { + auto i = entry[0].get(); + auto k = entry[1].get(); + auto color = entry[2].get(); + c[{i, k}] = color; + } + coloring = std::move(c); + } + + return LatticeGraph(std::move(sparse), std::move(coloring)); } LatticeGraph LatticeGraph::from_hdf5_file(const std::string& filename) { @@ -879,7 +920,27 @@ LatticeGraph LatticeGraph::from_hdf5(H5::Group& group) { static_cast(n)); sparse.setFromTriplets(triplets.begin(), triplets.end()); sparse.makeCompressed(); - return LatticeGraph(std::move(sparse)); + + std::optional coloring; + if (group.nameExists("edge_coloring")) { + H5::DataSet cds = group.openDataSet("edge_coloring"); + H5::DataSpace cspace = cds.getSpace(); + hsize_t cdims[2]; + cspace.getSimpleExtentDims(cdims); + auto nc = cdims[0]; + std::vector cbuf(nc * 3); + cds.read(cbuf.data(), H5::PredType::NATIVE_DOUBLE); + EdgeColoring c; + for (hsize_t ci = 0; ci < nc; ++ci) { + auto ei = static_cast(cbuf[ci * 3 + 0]); + auto ej = static_cast(cbuf[ci * 3 + 1]); + auto color = static_cast(cbuf[ci * 3 + 2]); + c[{ei, ej}] = color; + } + coloring = std::move(c); + } + + return LatticeGraph(std::move(sparse), std::move(coloring)); } } // namespace qdk::chemistry::data diff --git a/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py b/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py index dffac5f85..42d61b217 100644 --- a/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py +++ b/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py @@ -267,24 +267,25 @@ def _resolve_measurement_groups(qubit_hamiltonian: QubitHamiltonian) -> list[Qub if partition is not None: from qdk_chemistry.data.term_partition import FlatPartition # noqa: PLC0415 - if not isinstance(partition, FlatPartition): - raise TypeError( - f"QdkEnergyEstimator expects a FlatPartition for measurement grouping, " - f"got {type(partition).__name__}." + if isinstance(partition, FlatPartition): + Logger.info( + f"EnergyEstimator: consuming term_partition " + f"(strategy={partition.strategy!r}, num_groups={partition.num_groups})." ) + return [ + QubitHamiltonian( + pauli_strings=[qubit_hamiltonian.pauli_strings[i] for i in group], + coefficients=np.asarray([qubit_hamiltonian.coefficients[i] for i in group]), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + ) + for group in partition.groups + ] + Logger.info( - f"EnergyEstimator: consuming term_partition " - f"(strategy={partition.strategy!r}, num_groups={partition.num_groups})." + f"EnergyEstimator: ignoring unsupported partition type " + f"{type(partition).__name__}; measuring each term individually." ) - return [ - QubitHamiltonian( - pauli_strings=[qubit_hamiltonian.pauli_strings[i] for i in group], - coefficients=np.asarray([qubit_hamiltonian.coefficients[i] for i in group]), - encoding=qubit_hamiltonian.encoding, - fermion_mode_order=qubit_hamiltonian.fermion_mode_order, - ) - for group in partition.groups - ] Logger.info("EnergyEstimator: no term_partition; measuring each term individually.") return [ diff --git a/python/src/qdk_chemistry/data/qubit_hamiltonian.py b/python/src/qdk_chemistry/data/qubit_hamiltonian.py index df7c9482d..45d77d91f 100644 --- a/python/src/qdk_chemistry/data/qubit_hamiltonian.py +++ b/python/src/qdk_chemistry/data/qubit_hamiltonian.py @@ -94,6 +94,18 @@ def __init__( # Validate Pauli strings _validate_pauli_strings(pauli_strings) + # Validate partition coverage + if term_partition is not None: + indices = sorted(term_partition.all_indices()) + expected = list(range(len(pauli_strings))) + if indices != expected: + missing = set(expected) - set(indices) + duped = {i for i in indices if indices.count(i) > 1} + raise ValueError( + f"term_partition does not cover all {len(pauli_strings)} terms exactly once. " + f"Missing: {missing or 'none'}, duplicated: {duped or 'none'}." + ) + # Make instance immutable after construction (handled by base class) super().__init__() From dcd52e573dd9a0c0ad186b28f333916e5982f4ab Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Tue, 5 May 2026 16:46:55 -0700 Subject: [PATCH 055/117] Queit the logger --- python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py | 6 +++--- .../algorithms/time_evolution/builder/trotter.py | 4 ++-- python/src/qdk_chemistry/utils/model_hamiltonians.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py b/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py index 42d61b217..4212cc89e 100644 --- a/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py +++ b/python/src/qdk_chemistry/algorithms/energy_estimator/qdk.py @@ -268,7 +268,7 @@ def _resolve_measurement_groups(qubit_hamiltonian: QubitHamiltonian) -> list[Qub from qdk_chemistry.data.term_partition import FlatPartition # noqa: PLC0415 if isinstance(partition, FlatPartition): - Logger.info( + Logger.debug( f"EnergyEstimator: consuming term_partition " f"(strategy={partition.strategy!r}, num_groups={partition.num_groups})." ) @@ -282,12 +282,12 @@ def _resolve_measurement_groups(qubit_hamiltonian: QubitHamiltonian) -> list[Qub for group in partition.groups ] - Logger.info( + Logger.debug( f"EnergyEstimator: ignoring unsupported partition type " f"{type(partition).__name__}; measuring each term individually." ) - Logger.info("EnergyEstimator: no term_partition; measuring each term individually.") + Logger.debug("EnergyEstimator: no term_partition; measuring each term individually.") return [ QubitHamiltonian( pauli_strings=[label], diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index dc13d72f6..85ae987e1 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -370,13 +370,13 @@ def _group_terms( """ partition = qubit_hamiltonian.term_partition if partition is not None: - Logger.info( + Logger.debug( f"Trotter: consuming QubitHamiltonian.term_partition " f"(strategy={partition.strategy!r}, num_groups={partition.num_groups})." ) return self._groups_from_partition(qubit_hamiltonian, partition) - Logger.info("Trotter: no term_partition present; treating each Pauli term as its own group.") + Logger.debug("Trotter: no term_partition present; treating each Pauli term as its own group.") return [ [ QubitHamiltonian( diff --git a/python/src/qdk_chemistry/utils/model_hamiltonians.py b/python/src/qdk_chemistry/utils/model_hamiltonians.py index 0575970aa..3b6bfaccb 100644 --- a/python/src/qdk_chemistry/utils/model_hamiltonians.py +++ b/python/src/qdk_chemistry/utils/model_hamiltonians.py @@ -196,7 +196,7 @@ def create_heisenberg_hamiltonian( couplings=[("XX", jx), ("YY", jy), ("ZZ", jz)], fields=[("X", hx), ("Y", hy), ("Z", hz)], ) - Logger.info("No edge coloring on lattice graph; falling back to ungrouped Hamiltonian construction.") + Logger.debug("No edge coloring on lattice graph; falling back to ungrouped Hamiltonian construction.") n = graph.num_sites adj = graph.adjacency_matrix() From 721b740a663126b6fd4c284c049e728eb6d97fb8 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Wed, 6 May 2026 15:35:34 +0000 Subject: [PATCH 056/117] Review comments --- docs/source/changelog.rst | 7 +++++++ .../algorithms/time_evolution/builder/trotter.py | 6 ++---- python/src/qdk_chemistry/utils/model_hamiltonians.py | 3 +-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 28931182b..5c763573f 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -9,6 +9,13 @@ Version 2.0.0 Work in progress +Breaking changes +----------------- + +- ``QdkEnergyEstimator`` no longer auto-groups terms by qubit-wise commutativity. + Pre-group with ``create("term_grouper", "qubit_wise_commuting")`` to restore + the previous behavior. + Version 1.1.0 ============= diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py index 85ae987e1..f091b7238 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/builder/trotter.py @@ -269,10 +269,8 @@ def _decompose_trotter_step( if not qubit_hamiltonian.is_hermitian(tolerance=atol): raise ValueError("Non-Hermitian Hamiltonian: coefficients have nonzero imaginary parts.") - coeffs = list(qubit_hamiltonian.get_real_coefficients(tolerance=atol)) - # If there are no coefficients (e.g., empty Hamiltonian or all filtered by atol), - # there is nothing to decompose; return the empty list of terms. - if not coeffs: + # If all coefficients are below the tolerance, there is nothing to decompose. + if not any(abs(complex(c).real) > atol for c in qubit_hamiltonian.coefficients): Logger.warn("No coefficients above the tolerance; returning empty term list.") return terms diff --git a/python/src/qdk_chemistry/utils/model_hamiltonians.py b/python/src/qdk_chemistry/utils/model_hamiltonians.py index 3b6bfaccb..1665a45b5 100644 --- a/python/src/qdk_chemistry/utils/model_hamiltonians.py +++ b/python/src/qdk_chemistry/utils/model_hamiltonians.py @@ -77,7 +77,6 @@ def _build_geometry_grouped_hamiltonian( """ n = graph.num_sites - adj = graph.adjacency_matrix() if coloring is None: coloring = graph.edge_coloring @@ -111,7 +110,7 @@ def _build_geometry_grouped_hamiltonian( coupling_mat = to_pair_param(coupling, graph, "coupling") color_to_indices: dict[int, list[int]] = {} for (i, j), c in coloring.items(): - edge_weight = adj[i, j] + edge_weight = graph.weight(i, j) if edge_weight == 0.0: continue coeff_val = coupling_mat[i, j] * edge_weight From 70acd4ec6f8eaaf5d67c980878bc35ea4d29f548 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 6 May 2026 17:01:59 +0000 Subject: [PATCH 057/117] fixed docstring build --- docs/source/user/comprehensive/data/index.rst | 2 +- docs/source/user/comprehensive/model_hamiltonians.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/user/comprehensive/data/index.rst b/docs/source/user/comprehensive/data/index.rst index 94270438f..d0400a0fc 100644 --- a/docs/source/user/comprehensive/data/index.rst +++ b/docs/source/user/comprehensive/data/index.rst @@ -82,7 +82,7 @@ The partition is index-based — it stores indices into :attr:`~qdk_chemistry.da The partition is *optional* metadata — ``term_partition is None`` means the partition has not been computed for this Hamiltonian. Transformations that change term ordering or qubit support (for example :meth:`~qdk_chemistry.data.QubitHamiltonian.to_interleaved`) reset the partition to ``None`` on the new instance. -Algorithms that consume a partition treat its presence as an explicit signal to exploit it — for example, the :doc:`Trotter time-evolution builder <../algorithms/time_evolution_builder>` reads ``term_partition`` and uses it for schedule-level Suzuki recursion and reduction. +Algorithms that consume a partition treat its presence as an explicit signal to exploit it — for example, the :doc:`Trotter time-evolution builder <../algorithms/hamiltonian_unitary_builder>` reads ``term_partition`` and uses it for schedule-level Suzuki recursion and reduction. FlatPartition ~~~~~~~~~~~~~ diff --git a/docs/source/user/comprehensive/model_hamiltonians.rst b/docs/source/user/comprehensive/model_hamiltonians.rst index 28e529318..cbe71d741 100644 --- a/docs/source/user/comprehensive/model_hamiltonians.rst +++ b/docs/source/user/comprehensive/model_hamiltonians.rst @@ -282,7 +282,7 @@ When enabled, the builder consults the lattice's edge coloring and stores the re * each *group* corresponds to one interaction type (``XX``, ``YY``, ``ZZ``) or one external-field direction (``X``, ``Y``, ``Z``); * each *layer* within a coupling group is a set of edges of the same color, which by construction have disjoint qubit supports and can be applied in parallel. -Downstream consumers — most importantly the :doc:`Trotter time-evolution builder ` — read ``term_partition`` automatically and use it to schedule fewer sequential exponentials per Trotter step. +Downstream consumers — most importantly the :doc:`Trotter time-evolution builder ` — read ``term_partition`` automatically and use it to schedule fewer sequential exponentials per Trotter step. No manual geometry boilerplate is required at the call site. Pass ``include_term_groups=False`` to skip this step and obtain a Hamiltonian with ``term_partition is None`` (useful for benchmarking or when a different partition is desired). From 1e22acedae7c38e67fd6569ce56651d3a276df87 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Wed, 6 May 2026 18:54:39 +0000 Subject: [PATCH 058/117] Fix tests --- python/tests/test_term_partition.py | 61 ++++++++++++++++----- python/tests/test_time_evolution_trotter.py | 8 +-- 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/python/tests/test_term_partition.py b/python/tests/test_term_partition.py index 1122e7774..558d3068c 100644 --- a/python/tests/test_term_partition.py +++ b/python/tests/test_term_partition.py @@ -30,6 +30,7 @@ class TestFlatPartition: def test_construction_normalises_to_tuples_of_ints(self): + """Construction normalises to tuples of ints.""" p = FlatPartition(strategy="commuting", groups=[[0, 1, 2], [3, 4]]) assert isinstance(p.groups, tuple) assert all(isinstance(g, tuple) for g in p.groups) @@ -37,19 +38,23 @@ def test_construction_normalises_to_tuples_of_ints(self): assert p.groups == ((0, 1, 2), (3, 4)) def test_num_groups(self): + """Num groups.""" p = FlatPartition(strategy="x", groups=[[0], [1, 2], [3]]) assert p.num_groups == 3 def test_all_indices(self): + """All indices.""" p = FlatPartition(strategy="x", groups=[[2, 1], [0]]) assert p.all_indices() == [2, 1, 0] def test_is_subclass_of_term_partition(self): + """Is subclass of term partition.""" assert issubclass(FlatPartition, TermPartition) class TestLayeredPartition: def test_construction(self): + """Construction.""" p = LayeredPartition( strategy="geometry_coloring", groups=[[[0, 1], [2, 3]], [[4]]], @@ -57,6 +62,7 @@ def test_construction(self): assert p.groups == (((0, 1), (2, 3)), ((4,),)) def test_num_groups_and_layers(self): + """Num groups and layers.""" p = LayeredPartition( strategy="x", groups=[[[0]], [[1], [2]], [[3], [4], [5]]], @@ -67,6 +73,7 @@ def test_num_groups_and_layers(self): assert p.num_layers(2) == 3 def test_all_indices_flattens_in_order(self): + """All indices flattens in order.""" p = LayeredPartition(strategy="x", groups=[[[0, 1], [2]], [[3, 4]]]) assert p.all_indices() == [0, 1, 2, 3, 4] @@ -78,20 +85,24 @@ def test_all_indices_flattens_in_order(self): class TestQubitHamiltonianTermPartition: def test_default_is_none(self): + """Default is none.""" qh = QubitHamiltonian(["XX", "ZZ"], np.array([0.1, 0.2])) assert qh.term_partition is None def test_round_trip_flat(self): + """Round trip flat.""" partition = FlatPartition(strategy="commuting", groups=[[0], [1]]) qh = QubitHamiltonian(["XX", "ZZ"], np.array([0.1, 0.2]), term_partition=partition) assert qh.term_partition is partition def test_round_trip_layered(self): + """Round trip layered.""" partition = LayeredPartition(strategy="geometry_coloring", groups=[[[0, 1]]]) qh = QubitHamiltonian(["XX", "ZZ"], np.array([0.1, 0.2]), term_partition=partition) assert qh.term_partition is partition def test_to_interleaved_resets_partition(self): + """To interleaved resets partition.""" partition = FlatPartition(strategy="commuting", groups=[[0, 1, 2, 3]]) qh = QubitHamiltonian( ["XXII", "YYII", "IIZZ", "IIXX"], @@ -109,15 +120,18 @@ def test_to_interleaved_resets_partition(self): class TestTermGrouperRegistry: def test_available_strategies(self): + """Available strategies.""" names = registry.available("term_grouper") assert {"commuting", "qubit_wise_commuting", "identity"} <= set(names) def test_default_strategy_is_commuting(self): + """Default strategy is commuting.""" grouper = registry.create("term_grouper") assert grouper.name() == "commuting" @pytest.mark.parametrize("strategy", ["commuting", "qubit_wise_commuting", "identity"]) def test_returns_new_hamiltonian_with_partition(self, strategy): + """Returns new hamiltonian with partition.""" qh = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([1.0, 2.0, 3.0])) grouper = registry.create("term_grouper", strategy) out = grouper.run(qh) @@ -126,6 +140,7 @@ def test_returns_new_hamiltonian_with_partition(self, strategy): assert out.term_partition.strategy == strategy def test_partition_indices_cover_all_terms_exactly_once(self): + """Partition indices cover all terms exactly once.""" qh = QubitHamiltonian( ["XIII", "IXII", "IIXI", "IIIX", "ZIII", "IZII"], np.array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]), @@ -137,6 +152,7 @@ def test_partition_indices_cover_all_terms_exactly_once(self): assert sorted(indices) == list(range(len(qh.pauli_strings))) def test_identity_strategy_one_term_per_group(self): + """Identity strategy one term per group.""" qh = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([1.0, 2.0, 3.0])) out = registry.create("term_grouper", "identity").run(qh) assert out.term_partition.num_groups == len(qh.pauli_strings) @@ -145,12 +161,14 @@ def test_identity_strategy_one_term_per_group(self): def test_commuting_groups_globally_commute(self): # XX and YY commute (XY * YX = -ZZ * -ZZ = ZZ^2 = I; and YX * XY = ZZ), # ZZ commutes with both. So all three should land in the same group. + """Commuting groups globally commute.""" qh = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([1.0, 1.0, 1.0])) out = registry.create("term_grouper", "commuting").run(qh) assert out.term_partition.num_groups == 1 def test_qwc_separates_paulis_that_only_globally_commute(self): # XX and YY are NOT qubit-wise commuting, even though they globally commute. + """Qwc separates paulis that only globally commute.""" qh = QubitHamiltonian(["XX", "YY"], np.array([1.0, 1.0])) out = registry.create("term_grouper", "qubit_wise_commuting").run(qh) assert out.term_partition.num_groups == 2 @@ -163,12 +181,14 @@ def test_qwc_separates_paulis_that_only_globally_commute(self): class TestLatticeEdgeColoring: def test_chain_two_colors(self): + """Chain two colors.""" lat = LatticeGraph.chain(4, periodic=True) coloring = lat.edge_coloring assert coloring is not None assert len(set(coloring.values())) == 2 def test_returns_dict_or_none(self): + """Returns dict or none.""" lat = LatticeGraph.chain(3, periodic=False) coloring = lat.edge_coloring assert isinstance(coloring, dict) @@ -181,6 +201,7 @@ def test_returns_dict_or_none(self): class TestModelHamiltonianTermPartition: def test_heisenberg_populates_layered_partition(self): + """Heisenberg populates layered partition.""" lat = LatticeGraph.chain(4, periodic=True) ham = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0) assert isinstance(ham.term_partition, LayeredPartition) @@ -189,12 +210,14 @@ def test_heisenberg_populates_layered_partition(self): assert sorted(ham.term_partition.all_indices()) == list(range(len(ham.pauli_strings))) def test_ising_populates_layered_partition(self): + """Ising populates layered partition.""" lat = LatticeGraph.chain(4, periodic=True) ham = create_ising_hamiltonian(lat, j=1.0, h=0.5) assert isinstance(ham.term_partition, LayeredPartition) assert ham.term_partition.strategy == "geometry_coloring" def test_include_term_groups_false_disables_partition(self): + """Include term groups false disables partition.""" lat = LatticeGraph.chain(4, periodic=True) ham = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0, include_term_groups=False) assert ham.term_partition is None @@ -207,51 +230,56 @@ def test_include_term_groups_false_disables_partition(self): class TestTrotterConsumesTermPartition: def test_trotter_runs_with_partitioned_hamiltonian(self): + """Trotter runs with partitioned hamiltonian.""" lat = LatticeGraph.chain(4, periodic=True) ham = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0) - trotter = registry.create("time_evolution_builder", "trotter") - trotter.settings().update({"order": 2}) - unitary = trotter.run(ham, time=0.5) + trotter = registry.create("hamiltonian_unitary_builder", "trotter") + trotter.settings().update({"order": 2, "time": 0.5}) + unitary = trotter.run(ham) assert unitary is not None def test_trotter_runs_without_partition(self): # Falls back to treating each term as its own group. + """Trotter runs without partition.""" ham = QubitHamiltonian(["XXII", "IXXI", "IIXX", "ZIII"], np.array([1.0, 1.0, 1.0, 0.5])) assert ham.term_partition is None - trotter = registry.create("time_evolution_builder", "trotter") - unitary = trotter.run(ham, time=0.5) + trotter = registry.create("hamiltonian_unitary_builder", "trotter") + trotter.settings().update({"time": 0.5}) + unitary = trotter.run(ham) assert unitary is not None def test_partition_produces_smaller_or_equal_step_count_at_order_2(self): # With group sorting + schedule reduction, populating the partition # should never produce more step terms than the ungrouped fallback. + """Partition produces smaller or equal step count at order 2.""" lat = LatticeGraph.chain(4, periodic=True) with_groups = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0, include_term_groups=True) without_groups = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0, include_term_groups=False) assert with_groups.term_partition is not None assert without_groups.term_partition is None - trotter = registry.create("time_evolution_builder", "trotter") - trotter.settings().update({"order": 2, "num_divisions": 1}) - grouped_steps = len(trotter.run(with_groups, time=1.0).get_container().step_terms) + trotter = registry.create("hamiltonian_unitary_builder", "trotter") + trotter.settings().update({"order": 2, "num_divisions": 1, "time": 1.0}) + grouped_steps = len(trotter.run(with_groups).get_container().step_terms) - trotter2 = registry.create("time_evolution_builder", "trotter") - trotter2.settings().update({"order": 2, "num_divisions": 1}) - ungrouped_steps = len(trotter2.run(without_groups, time=1.0).get_container().step_terms) + trotter2 = registry.create("hamiltonian_unitary_builder", "trotter") + trotter2.settings().update({"order": 2, "num_divisions": 1, "time": 1.0}) + ungrouped_steps = len(trotter2.run(without_groups).get_container().step_terms) assert grouped_steps <= ungrouped_steps def test_trotter_runs_with_flat_partition(self): # Take a partitioned Hamiltonian and overwrite term_partition with a # FlatPartition (via the term_grouper algorithm), then drive Trotter. + """Trotter runs with flat partition.""" lat = LatticeGraph.chain(4, periodic=True) ham = create_heisenberg_hamiltonian(lat, jx=1.0, jy=1.0, jz=1.0) flat = registry.create("term_grouper", "commuting").run(ham) assert isinstance(flat.term_partition, FlatPartition) - trotter = registry.create("time_evolution_builder", "trotter") - trotter.settings().update({"order": 2}) - unitary = trotter.run(flat, time=0.5) + trotter = registry.create("hamiltonian_unitary_builder", "trotter") + trotter.settings().update({"order": 2, "time": 0.5}) + unitary = trotter.run(flat) assert unitary is not None @@ -262,6 +290,7 @@ def test_trotter_runs_with_flat_partition(self): class TestTermPartitionSerialisation: def test_flat_partition_to_json_round_trip(self): + """Flat partition to json round trip.""" partition = FlatPartition(strategy="commuting", groups=[[0, 2], [1]]) data = partition.to_json() assert data["kind"] == "flat" @@ -270,6 +299,7 @@ def test_flat_partition_to_json_round_trip(self): assert restored == partition def test_layered_partition_to_json_round_trip(self): + """Layered partition to json round trip.""" partition = LayeredPartition(strategy="geometry_coloring", groups=[[[0, 1], [2]], [[3]]]) data = partition.to_json() assert data["kind"] == "layered" @@ -278,6 +308,7 @@ def test_layered_partition_to_json_round_trip(self): assert restored == partition def test_qubit_hamiltonian_json_round_trip_preserves_partition(self): + """Qubit hamiltonian json round trip preserves partition.""" partition = FlatPartition(strategy="commuting", groups=[[0, 1], [2]]) ham = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([0.1, 0.2, 0.3]), term_partition=partition) restored = QubitHamiltonian.from_json(ham.to_json()) @@ -285,11 +316,13 @@ def test_qubit_hamiltonian_json_round_trip_preserves_partition(self): assert restored.term_partition == partition def test_qubit_hamiltonian_json_round_trip_with_no_partition(self): + """Qubit hamiltonian json round trip with no partition.""" ham = QubitHamiltonian(["XX", "ZZ"], np.array([0.1, 0.2])) restored = QubitHamiltonian.from_json(ham.to_json()) assert restored.term_partition is None def test_qubit_hamiltonian_hdf5_round_trip_preserves_partition(self, tmp_path): + """Qubit hamiltonian hdf5 round trip preserves partition.""" h5py = pytest.importorskip("h5py") partition = LayeredPartition(strategy="geometry_coloring", groups=[[[0], [1]], [[2]]]) ham = QubitHamiltonian(["XX", "YY", "ZZ"], np.array([0.1, 0.2, 0.3]), term_partition=partition) diff --git a/python/tests/test_time_evolution_trotter.py b/python/tests/test_time_evolution_trotter.py index 331bc8789..7422b374b 100644 --- a/python/tests/test_time_evolution_trotter.py +++ b/python/tests/test_time_evolution_trotter.py @@ -110,8 +110,8 @@ def test_single_step_no_merge_without_partition(self): coefficients = [1.0, 1.0, 1.0] hamiltonian = QubitHamiltonian(pauli_strings=pauli_strings, coefficients=coefficients) - builder = Trotter(num_divisions=1) - unitary = builder.run(hamiltonian, time=1) + builder = Trotter(num_divisions=1, time=1.0) + unitary = builder.run(hamiltonian) assert isinstance(unitary, UnitaryRepresentation) container = unitary.get_container() @@ -868,9 +868,9 @@ def test_no_partition_treats_each_term_individually(self): pauli_strings=pauli_strings, coefficients=coefficients, ) - builder = Trotter(num_divisions=1, order=1) t = 1.0 - terms = builder.run(hamiltonian, time=t).get_container().step_terms + builder = Trotter(num_divisions=1, order=1, time=t) + terms = builder.run(hamiltonian).get_container().step_terms for idx, term in enumerate(terms): assert term.pauli_term == builder._pauli_label_to_map(pauli_strings[idx]) From 866b0a075b2c1e04fb7d2b1ad1fabb5e98f48c56 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Wed, 6 May 2026 18:57:46 +0000 Subject: [PATCH 059/117] Linting --- cpp/src/qdk/chemistry/data/lattice_graph.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cpp/src/qdk/chemistry/data/lattice_graph.cpp b/cpp/src/qdk/chemistry/data/lattice_graph.cpp index 45616d0c8..172652795 100644 --- a/cpp/src/qdk/chemistry/data/lattice_graph.cpp +++ b/cpp/src/qdk/chemistry/data/lattice_graph.cpp @@ -682,8 +682,7 @@ nlohmann::json LatticeGraph::to_json() const { if (_edge_coloring.has_value()) { nlohmann::json coloring_json = nlohmann::json::array(); for (const auto& [edge, color] : *_edge_coloring) { - coloring_json.push_back( - {edge.first, edge.second, color}); + coloring_json.push_back({edge.first, edge.second, color}); } j["edge_coloring"] = coloring_json; } From 5f074f826684a203b87f9d0b87d1d84ba47eb646 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 6 May 2026 21:45:22 +0000 Subject: [PATCH 060/117] ran pre commit --- python/src/qdk_chemistry/algorithms/circuit_executor/qdk.py | 2 -- python/tests/test_time_evolution_trotter.py | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/circuit_executor/qdk.py b/python/src/qdk_chemistry/algorithms/circuit_executor/qdk.py index dfc411a9d..ece12f666 100644 --- a/python/src/qdk_chemistry/algorithms/circuit_executor/qdk.py +++ b/python/src/qdk_chemistry/algorithms/circuit_executor/qdk.py @@ -88,7 +88,6 @@ def _run_impl( circuit: Circuit, shots: int, noise: QuantumErrorProfile | None = None, - **kwargs, ) -> CircuitExecutorData: """Execute the given quantum circuit using the QDK Full State Simulator. @@ -147,7 +146,6 @@ def _run_impl( circuit: Circuit, shots: int, noise: QuantumErrorProfile | None = None, - **kwargs, ) -> CircuitExecutorData: """Execute the given quantum circuit using the QDK Sparse State Simulator. diff --git a/python/tests/test_time_evolution_trotter.py b/python/tests/test_time_evolution_trotter.py index 34f9150a8..5988f8ae2 100644 --- a/python/tests/test_time_evolution_trotter.py +++ b/python/tests/test_time_evolution_trotter.py @@ -915,7 +915,7 @@ def term_to_label(term): # With first-order Trotter, grouped terms should appear contiguously: # all ZZ terms first (group 0), then all X terms (group 1), or vice versa. # Check that the groups are contiguous (not interleaved). - zz_indices = [i for i, l in enumerate(term_labels) if l in pauli_zz_labels] - x_indices = [i for i, l in enumerate(term_labels) if l in pauli_x_labels] + zz_indices = [i for i, label in enumerate(term_labels) if label in pauli_zz_labels] + x_indices = [i for i, label in enumerate(term_labels) if label in pauli_x_labels] assert zz_indices == list(range(zz_indices[0], zz_indices[0] + len(zz_indices))) assert x_indices == list(range(x_indices[0], x_indices[0] + len(x_indices))) From a47a8307c9d0ca57d935c34413fa68f2c76a6be8 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Wed, 6 May 2026 15:50:29 -0700 Subject: [PATCH 061/117] Fix docs --- docs/source/user/comprehensive/data/index.rst | 2 +- docs/source/user/comprehensive/model_hamiltonians.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/user/comprehensive/data/index.rst b/docs/source/user/comprehensive/data/index.rst index 94270438f..d0400a0fc 100644 --- a/docs/source/user/comprehensive/data/index.rst +++ b/docs/source/user/comprehensive/data/index.rst @@ -82,7 +82,7 @@ The partition is index-based — it stores indices into :attr:`~qdk_chemistry.da The partition is *optional* metadata — ``term_partition is None`` means the partition has not been computed for this Hamiltonian. Transformations that change term ordering or qubit support (for example :meth:`~qdk_chemistry.data.QubitHamiltonian.to_interleaved`) reset the partition to ``None`` on the new instance. -Algorithms that consume a partition treat its presence as an explicit signal to exploit it — for example, the :doc:`Trotter time-evolution builder <../algorithms/time_evolution_builder>` reads ``term_partition`` and uses it for schedule-level Suzuki recursion and reduction. +Algorithms that consume a partition treat its presence as an explicit signal to exploit it — for example, the :doc:`Trotter time-evolution builder <../algorithms/hamiltonian_unitary_builder>` reads ``term_partition`` and uses it for schedule-level Suzuki recursion and reduction. FlatPartition ~~~~~~~~~~~~~ diff --git a/docs/source/user/comprehensive/model_hamiltonians.rst b/docs/source/user/comprehensive/model_hamiltonians.rst index 28e529318..cbe71d741 100644 --- a/docs/source/user/comprehensive/model_hamiltonians.rst +++ b/docs/source/user/comprehensive/model_hamiltonians.rst @@ -282,7 +282,7 @@ When enabled, the builder consults the lattice's edge coloring and stores the re * each *group* corresponds to one interaction type (``XX``, ``YY``, ``ZZ``) or one external-field direction (``X``, ``Y``, ``Z``); * each *layer* within a coupling group is a set of edges of the same color, which by construction have disjoint qubit supports and can be applied in parallel. -Downstream consumers — most importantly the :doc:`Trotter time-evolution builder ` — read ``term_partition`` automatically and use it to schedule fewer sequential exponentials per Trotter step. +Downstream consumers — most importantly the :doc:`Trotter time-evolution builder ` — read ``term_partition`` automatically and use it to schedule fewer sequential exponentials per Trotter step. No manual geometry boilerplate is required at the call site. Pass ``include_term_groups=False`` to skip this step and obtain a Hamiltonian with ``term_partition is None`` (useful for benchmarking or when a different partition is desired). From 03d507978ce11962b18aa38141b35278a15f9fb0 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Wed, 6 May 2026 23:26:39 +0000 Subject: [PATCH 062/117] restored kwargs to handle device_backend_name in circuit_executor --- python/src/qdk_chemistry/algorithms/circuit_executor/qdk.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/src/qdk_chemistry/algorithms/circuit_executor/qdk.py b/python/src/qdk_chemistry/algorithms/circuit_executor/qdk.py index ece12f666..e31378339 100644 --- a/python/src/qdk_chemistry/algorithms/circuit_executor/qdk.py +++ b/python/src/qdk_chemistry/algorithms/circuit_executor/qdk.py @@ -88,6 +88,7 @@ def _run_impl( circuit: Circuit, shots: int, noise: QuantumErrorProfile | None = None, + **_kwargs, ) -> CircuitExecutorData: """Execute the given quantum circuit using the QDK Full State Simulator. @@ -95,6 +96,7 @@ def _run_impl( circuit: The quantum circuit to execute. shots: The number of shots to execute the circuit. noise: Optional noise profile to apply during execution. + **_kwargs: Additional keyword arguments (ignored by this backend). Returns: CircuitExecutorData: Object containing the results of the circuit execution. @@ -146,6 +148,7 @@ def _run_impl( circuit: Circuit, shots: int, noise: QuantumErrorProfile | None = None, + **_kwargs, ) -> CircuitExecutorData: """Execute the given quantum circuit using the QDK Sparse State Simulator. @@ -153,6 +156,7 @@ def _run_impl( circuit: The quantum circuit to execute. shots: The number of shots to execute the circuit. noise: Optional noise profile to apply during execution. + **_kwargs: Additional keyword arguments (ignored by this backend). Returns: CircuitExecutorData: Object containing the results of the circuit execution. From ab3217e7a27281362445e5f9440d1fef56223dac Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Thu, 7 May 2026 17:56:43 +0000 Subject: [PATCH 063/117] fixed pauli sequence mapper --- .../circuit_mapper/pauli_sequence_mapper.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/pauli_sequence_mapper.py b/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/pauli_sequence_mapper.py index 9a7a0336b..d790cc396 100644 --- a/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/pauli_sequence_mapper.py +++ b/python/src/qdk_chemistry/algorithms/time_evolution/circuit_mapper/pauli_sequence_mapper.py @@ -8,7 +8,7 @@ from qdk import qsharp from qdk_chemistry.data import Settings -from qdk_chemistry.data.circuit import Circuit +from qdk_chemistry.data.circuit import Circuit, QsharpFactoryData from qdk_chemistry.data.unitary_representation.base import UnitaryRepresentation from qdk_chemistry.data.unitary_representation.containers.pauli_product_formula import ( PauliProductFormulaContainer, @@ -112,4 +112,9 @@ def _run_impl(self, evolution: UnitaryRepresentation) -> Circuit: evolution_op = QSHARP_UTILS.PauliExp.MakeRepPauliExpOp(evo_params) - return Circuit(qsharp=qsc, qir=qir, qsharp_op=evolution_op) + factory = QsharpFactoryData( + program=QSHARP_UTILS.PauliExp.MakeRepPauliExpCircuit, + parameter={"evo_params": evo_params, "target_indices": target_indices}, + ) + + return Circuit(qsharp=qsc, qir=qir, qsharp_op=evolution_op, qsharp_factory=factory) From 5d5c5396eec7666b8df726e95fef6bd4009b9045 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 9 May 2026 01:42:59 +0000 Subject: [PATCH 064/117] =?UTF-8?q?feat:=20Majorana-first=20f2q=20refactor?= =?UTF-8?q?=20=E2=80=94=20MajoranaMapping=20data=20class=20+=20C++=20engin?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce MajoranaMapping as an immutable DataClass storing the 2N-entry Majorana-to-Pauli table for any standard f2q encoding (JW, BK, parity). The encoding is now pure data, validated via Clifford algebra at construction. New C++ mapping engine (majorana_map_engine) replaces the per-encoding Python loops with compile-time Majorana decomposition coefficients and a single encoding-agnostic O(N^4) C++ loop. QubitMapper._run_impl signature: (Hamiltonian, MajoranaMapping, Symmetries?) QdkQubitMapper simplified from 599 to 155 lines. Plugin mappers dispatch on mapping.name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chemistry/data/majorana_map_engine.hpp | 73 +++ .../qdk/chemistry/data/majorana_mapping.hpp | 150 +++++ cpp/src/qdk/chemistry/data/CMakeLists.txt | 2 + .../chemistry/data/majorana_map_engine.cpp | 285 ++++++++++ .../qdk/chemistry/data/majorana_mapping.cpp | 388 +++++++++++++ python/CMakeLists.txt | 1 + python/src/pybind11/data/majorana_mapping.cpp | 416 ++++++++++++++ python/src/pybind11/module.cpp | 3 + .../qubit_mapper/qdk_qubit_mapper.py | 535 ++---------------- .../algorithms/qubit_mapper/qubit_mapper.py | 34 +- python/src/qdk_chemistry/data/__init__.py | 3 + .../qdk_chemistry/data/majorana_mapping.py | 276 +++++++++ python/src/qdk_chemistry/plugins/__init__.py | 6 +- .../plugins/openfermion/qubit_mapper.py | 144 ++--- .../plugins/qiskit/qubit_mapper.py | 75 +-- python/tests/conftest.py | 13 +- python/tests/test_encoding_metadata.py | 52 +- python/tests/test_majorana_mapping.py | 365 ++++++++++++ python/tests/test_qdk_qubit_mapper.py | 196 ++----- .../tests/test_sample_workflow_openfermion.py | 9 +- 20 files changed, 2216 insertions(+), 810 deletions(-) create mode 100644 cpp/include/qdk/chemistry/data/majorana_map_engine.hpp create mode 100644 cpp/include/qdk/chemistry/data/majorana_mapping.hpp create mode 100644 cpp/src/qdk/chemistry/data/majorana_map_engine.cpp create mode 100644 cpp/src/qdk/chemistry/data/majorana_mapping.cpp create mode 100644 python/src/pybind11/data/majorana_mapping.cpp create mode 100644 python/src/qdk_chemistry/data/majorana_mapping.py create mode 100644 python/tests/test_majorana_mapping.py diff --git a/cpp/include/qdk/chemistry/data/majorana_map_engine.hpp b/cpp/include/qdk/chemistry/data/majorana_map_engine.hpp new file mode 100644 index 000000000..0a75b5859 --- /dev/null +++ b/cpp/include/qdk/chemistry/data/majorana_map_engine.hpp @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for +// license information. + +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace qdk::chemistry::data { + +/** + * @brief Result of a Majorana-loop fermion-to-qubit mapping. + * + * Contains Pauli strings in little-endian format (qubit 0 = rightmost char) + * and their corresponding complex coefficients. + */ +struct MajoranaMapResult { + /// Pauli strings in little-endian format. + std::vector pauli_strings; + /// Complex coefficients (same length as pauli_strings). + std::vector> coefficients; +}; + +/** + * @brief Map a fermionic Hamiltonian to qubit Pauli terms using Majorana loops. + * + * This is the encoding-agnostic mapping engine. It decomposes each fermionic + * operator (a†_p a_q, a†_p a†_r a_s a_q) into Majorana products using + * compile-time coefficients, looks up each Majorana γ_k in the mapping table, + * and multiplies/accumulates the resulting Pauli strings. + * + * The second-quantized Hamiltonian in chemist notation is: + * H = E_core + * + Σ_{pq,σ} h_pq a†_{pσ} a_{qσ} + * + (1/2) Σ_{pqrs,σ,τ} (pq|rs) a†_{pσ} a†_{rτ} a_{sτ} a_{qσ} + * + * Using the identity a†_p a†_r a_s a_q = E_pq·E_rs - δ_{qr}·E_ps, + * where E_pq = a†_p a_q, and each E_pq is decomposed as: + * E_pq = (1/4) Σ_{a,b} c[a][b] · γ_{2p+a} · γ_{2q+b} + * with c[a][b] = (-i)^a · (i)^b. + * + * @param mapping The Majorana-to-Pauli mapping (encoding). + * @param core_energy The core (nuclear repulsion + frozen core) energy. + * @param h1_alpha One-body integrals for alpha spin (n_spatial × n_spatial). + * @param h1_beta One-body integrals for beta spin (n_spatial × n_spatial). + * @param eri_aaaa Flattened two-body integrals (αα|αα) in chemist notation. + * @param eri_aabb Flattened two-body integrals (αα|ββ) in chemist notation. + * @param eri_bbbb Flattened two-body integrals (ββ|ββ) in chemist notation. + * @param n_spatial Number of spatial orbitals. + * @param is_restricted Whether h1_alpha == h1_beta (spin-free case). + * @param threshold Pauli terms with |coeff| < threshold are dropped. + * @param integral_threshold Integrals with |value| < this are skipped. + * @return MajoranaMapResult with Pauli strings (little-endian) and coefficients. + */ +MajoranaMapResult majorana_map_hamiltonian( + const MajoranaMapping& mapping, + double core_energy, + const double* h1_alpha, + const double* h1_beta, + const double* eri_aaaa, + const double* eri_aabb, + const double* eri_bbbb, + std::size_t n_spatial, + bool is_restricted, + double threshold, + double integral_threshold); + +} // namespace qdk::chemistry::data diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp new file mode 100644 index 000000000..3bce5991a --- /dev/null +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for +// license information. + +#pragma once +#include +#include +#include +#include + +namespace qdk::chemistry::data { + +/** + * @brief Immutable data class mapping 2N Majorana operators to Pauli strings. + * + * A MajoranaMapping stores a table of 2N SparsePauliWord entries, one per + * Majorana operator γ_k (k = 0, ..., 2N-1), where N is the number of + * fermionic modes (spin-orbitals). Each entry is a single Pauli string + * representing φ(γ_k) under the chosen fermion-to-qubit encoding. + * + * The mapping is validated at construction time by checking the Clifford + * algebra anticommutation relations: {γ_i, γ_j} = 2δ_{ij} · I. This + * guarantees that the mapping defines a valid encoding. + * + * Pauli string convention: The SparsePauliWord entries are stored in the + * same little-endian convention used by QubitHamiltonian (qubit 0 has the + * smallest index in the sparse representation). + * + * Factory methods construct the standard encodings (Jordan-Wigner, + * Bravyi-Kitaev, parity) from a mode count. Custom encodings can be + * constructed directly by providing the table. + * + * @see PauliTermAccumulator for the accumulation engine that uses this + * mapping. + */ +class MajoranaMapping { + public: + /** + * @brief Construct a MajoranaMapping from a Majorana-to-Pauli table. + * + * @param table Vector of 2N SparsePauliWord entries (one per Majorana + * operator γ_0, γ_1, ..., γ_{2N-1}). + * @param name Optional human-readable label for the encoding (e.g., + * "jordan_wigner"). Stored but not used for dispatch. + * @throws std::invalid_argument If table size is odd, empty, or the + * Clifford algebra validation fails. + */ + explicit MajoranaMapping(std::vector table, + std::string name = ""); + + /** + * @brief Number of fermionic modes (spin-orbitals). + * @return N where the table has 2N entries. + */ + std::size_t num_modes() const { return table_.size() / 2; } + + /** + * @brief Number of qubits required by this encoding. + * @return max qubit index + 1 across all table entries, or 0 if all + * entries are identity. + */ + std::size_t num_qubits() const { return num_qubits_; } + + /** + * @brief Look up the Pauli string for Majorana operator γ_k. + * @param k Majorana index (0 ≤ k < 2N). + * @return const reference to the SparsePauliWord for γ_k. + * @throws std::out_of_range if k ≥ 2N. + */ + const SparsePauliWord& operator()(std::size_t k) const; + + /** + * @brief Access the full Majorana-to-Pauli table. + * @return const reference to the vector of SparsePauliWords. + */ + const std::vector& table() const { return table_; } + + /** + * @brief Human-readable name of the encoding. + * @return The name string (may be empty for custom encodings). + */ + const std::string& name() const { return name_; } + + /** + * @brief Validate the Clifford algebra anticommutation relations. + * + * Checks that {γ_i, γ_j} = φ(γ_i)·φ(γ_j) + φ(γ_j)·φ(γ_i) = 2δ_{ij}·I + * for all pairs (i, j). This is called automatically at construction. + * + * @throws std::invalid_argument if any anticommutator check fails. + */ + void validate() const; + + // --- Factory methods for standard encodings --- + + /** + * @brief Construct a Jordan-Wigner encoding. + * + * γ_{2j} = Z_{j-1} ⊗ ... ⊗ Z_0 ⊗ X_j + * γ_{2j+1} = Z_{j-1} ⊗ ... ⊗ Z_0 ⊗ Y_j + * + * @param num_modes Number of fermionic modes (spin-orbitals). + * The encoding uses num_modes qubits. + * @return MajoranaMapping with name "jordan_wigner". + */ + static MajoranaMapping jordan_wigner(std::size_t num_modes); + + /** + * @brief Construct a Bravyi-Kitaev encoding. + * + * γ_{2j} = X_{U(j)} ⊗ X_j ⊗ Z_{P(j)} + * γ_{2j+1} = X_{U(j)} ⊗ Y_j ⊗ Z_{R(j)} + * + * where U(j), P(j), R(j) are the update, parity, and remainder sets + * derived from the BK binary tree. + * + * @param num_modes Number of fermionic modes (spin-orbitals). + * The encoding uses num_modes qubits. + * @return MajoranaMapping with name "bravyi_kitaev". + */ + static MajoranaMapping bravyi_kitaev(std::size_t num_modes); + + /** + * @brief Construct a parity encoding. + * + * γ_{2j} = X_{n-1} ⊗ ... ⊗ X_{j+1} ⊗ Y_j ⊗ Z_{j-1} ⊗ ... ⊗ Z_0 + * γ_{2j+1} = X_{n-1} ⊗ ... ⊗ X_{j+1} ⊗ X_j ⊗ Z_{j-1} ⊗ ... ⊗ Z_0 + * + * @param num_modes Number of fermionic modes (spin-orbitals). + * The encoding uses num_modes qubits. + * @return MajoranaMapping with name "parity". + */ + static MajoranaMapping parity(std::size_t num_modes); + + private: + /// Majorana-to-Pauli table: table_[k] = φ(γ_k). + std::vector table_; + + /// Human-readable encoding name. + std::string name_; + + /// Cached qubit count (max qubit index + 1). + std::size_t num_qubits_; + + /// Compute num_qubits_ from the table. + static std::size_t compute_num_qubits( + const std::vector& table); +}; + +} // namespace qdk::chemistry::data diff --git a/cpp/src/qdk/chemistry/data/CMakeLists.txt b/cpp/src/qdk/chemistry/data/CMakeLists.txt index 5d04df8d4..054b54e86 100644 --- a/cpp/src/qdk/chemistry/data/CMakeLists.txt +++ b/cpp/src/qdk/chemistry/data/CMakeLists.txt @@ -11,6 +11,8 @@ target_sources(chemistry PRIVATE hamiltonian_containers/canonical_four_center.cpp hamiltonian_containers/cholesky.cpp hamiltonian_containers/sparse.cpp + majorana_map_engine.cpp + majorana_mapping.cpp orbitals.cpp pauli_operator.cpp settings.cpp diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp new file mode 100644 index 000000000..2bea92715 --- /dev/null +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -0,0 +1,285 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for +// license information. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace qdk::chemistry::data { + +namespace { + +// Compile-time Majorana decomposition coefficients for a†_p a_q: +// a†_p a_q = (1/4) Σ_{a,b} c[a][b] · γ_{2p+a} · γ_{2q+b} +// c[a][b] = (-i)^a · (i)^b +// c[0][0] = 1, c[0][1] = i, c[1][0] = -i, c[1][1] = 1 +constexpr std::complex kC00{1.0, 0.0}; +constexpr std::complex kC01{0.0, 1.0}; +constexpr std::complex kC10{0.0, -1.0}; +constexpr std::complex kC11{1.0, 0.0}; + +// All four coefficients in indexable form: kC[a][b] +constexpr std::complex kC[2][2] = {{kC00, kC01}, {kC10, kC11}}; + +// Quarter factor applied to each one-body Majorana product +constexpr double kQuarter = 0.25; + +// 1/16 factor for two-body (product of two E operators, each with 1/4) +constexpr double kSixteenth = 0.0625; + +/// Convert a SparsePauliWord to a dense little-endian string. +/// (qubit 0 = rightmost character) +std::string sparse_to_le_string(const SparsePauliWord& word, + std::size_t num_qubits) { + std::string result(num_qubits, 'I'); + for (const auto& [qubit, op_type] : word) { + if (qubit < num_qubits) { + switch (op_type) { + case 1: + result[qubit] = 'X'; + break; + case 2: + result[qubit] = 'Y'; + break; + case 3: + result[qubit] = 'Z'; + break; + default: + break; + } + } + } + // Reverse to get little-endian (qubit 0 = rightmost) + std::reverse(result.begin(), result.end()); + return result; +} + +} // namespace + +MajoranaMapResult majorana_map_hamiltonian( + const MajoranaMapping& mapping, double core_energy, + const double* h1_alpha, const double* h1_beta, const double* eri_aaaa, + const double* eri_aabb, const double* eri_bbbb, std::size_t n_spatial, + bool is_restricted, double threshold, double integral_threshold) { + const std::size_t n_modes = 2 * n_spatial; // spin-orbitals + PauliTermAccumulator acc; + + // Helper: compute spin-orbital mode index from spatial + spin + // Blocked ordering: mode(p, α) = p, mode(p, β) = p + n_spatial + auto mode_alpha = [](std::size_t p) -> std::size_t { return p; }; + auto mode_beta = [n_spatial](std::size_t p) -> std::size_t { + return p + n_spatial; + }; + + // Helper: accumulate one-body E_pq = a†_p a_q for specific mode pair + // E_pq = (1/4) Σ_{a,b} c[a][b] · γ_{2p+a} · γ_{2q+b} + auto accumulate_epq = [&](std::size_t mode_p, std::size_t mode_q, + double h_pq) { + for (int a = 0; a < 2; ++a) { + for (int b = 0; b < 2; ++b) { + std::complex coeff = + h_pq * kQuarter * kC[a][b]; + acc.accumulate_product(mapping(2 * mode_p + a), + mapping(2 * mode_q + b), coeff); + } + } + }; + + // ─── Core energy ────────────────────────────────────────────────── + if (std::abs(core_energy) > integral_threshold) { + acc.accumulate({}, std::complex(core_energy, 0.0)); + } + + // ─── One-body terms ─────────────────────────────────────────────── + // H_1 = Σ_{p,q,σ} h_pq a†_{p,σ} a_{q,σ} + for (std::size_t p = 0; p < n_spatial; ++p) { + for (std::size_t q = 0; q < n_spatial; ++q) { + double h_pq_a = h1_alpha[p * n_spatial + q]; + if (std::abs(h_pq_a) > integral_threshold) { + accumulate_epq(mode_alpha(p), mode_alpha(q), h_pq_a); + } + + double h_pq_b = is_restricted ? h_pq_a : h1_beta[p * n_spatial + q]; + if (std::abs(h_pq_b) > integral_threshold) { + accumulate_epq(mode_beta(p), mode_beta(q), h_pq_b); + } + } + } + + // ─── Two-body terms ─────────────────────────────────────────────── + // H_2 = (1/2) Σ_{pqrs,σ,τ} (pq|rs) a†_{p,σ} a†_{r,τ} a_{s,τ} a_{q,σ} + // = (1/2) Σ_{pqrs,σ,τ} (pq|rs) [E_{pσ,qσ} E_{rτ,sτ} - δ_{qσ,rτ} E_{pσ,sτ}] + // + // For same-spin (σ==τ), δ_{qσ,rτ} = δ_{q,r} and E_{pσ,sτ} = E_{pσ,sσ} + // For cross-spin (σ≠τ), δ_{qσ,rτ} = 0 (different spin) + + // Helper: accumulate E_pσ E_rτ product with two-body coefficient + // E_pq · E_rs = (1/16) Σ_{a,b,c,d} c[a][b] c[c][d] γ_{2p+a} γ_{2q+b} γ_{2r+c} γ_{2s+d} + auto accumulate_two_body_product = [&](std::size_t mode_p, + std::size_t mode_q, + std::size_t mode_r, + std::size_t mode_s, double eri) { + // Factor: 0.5 * eri / 16 = eri * kSixteenth * 0.5 + double half_eri = 0.5 * eri; + for (int a = 0; a < 2; ++a) { + for (int b = 0; b < 2; ++b) { + std::complex c_ab = kC[a][b]; + const auto& w_pa = mapping(2 * mode_p + a); + const auto& w_qb = mapping(2 * mode_q + b); + for (int c = 0; c < 2; ++c) { + for (int d = 0; d < 2; ++d) { + std::complex coeff = + half_eri * kSixteenth * c_ab * kC[c][d]; + const auto& w_rc = mapping(2 * mode_r + c); + const auto& w_sd = mapping(2 * mode_s + d); + + // Need to compute (w_pa · w_qb) · (w_rc · w_sd) + // Use the accumulator's multiply + accumulate + auto [phase1, prod1] = + PauliTermAccumulator::multiply_uncached(w_pa, w_qb); + auto [phase2, prod2] = + PauliTermAccumulator::multiply_uncached(w_rc, w_sd); + acc.accumulate_product(prod1, prod2, + coeff * phase1 * phase2); + } + } + } + } + }; + + auto idx4 = [n_spatial](std::size_t p, std::size_t q, std::size_t r, + std::size_t s) -> std::size_t { + return ((p * n_spatial + q) * n_spatial + r) * n_spatial + s; + }; + + // Spin-free case: use spatial ERIs with both spin channels + if (is_restricted) { + for (std::size_t p = 0; p < n_spatial; ++p) { + for (std::size_t q = 0; q < n_spatial; ++q) { + for (std::size_t r = 0; r < n_spatial; ++r) { + for (std::size_t s = 0; s < n_spatial; ++s) { + double eri = eri_aaaa[idx4(p, q, r, s)]; + if (std::abs(eri) < integral_threshold) continue; + + // αα channel + accumulate_two_body_product(mode_alpha(p), mode_alpha(q), + mode_alpha(r), mode_alpha(s), eri); + // ββ channel (same ERI for restricted) + accumulate_two_body_product(mode_beta(p), mode_beta(q), + mode_beta(r), mode_beta(s), eri); + // αβ channel + accumulate_two_body_product(mode_alpha(p), mode_alpha(q), + mode_beta(r), mode_beta(s), eri); + // βα channel + accumulate_two_body_product(mode_beta(p), mode_beta(q), + mode_alpha(r), mode_alpha(s), eri); + + // δ corrections for same-spin channels + if (q == r) { + // -0.5 * eri * E_{p,α,s,α} + accumulate_epq(mode_alpha(p), mode_alpha(s), -0.5 * eri); + // -0.5 * eri * E_{p,β,s,β} + accumulate_epq(mode_beta(p), mode_beta(s), -0.5 * eri); + } + // No δ correction for cross-spin channels (σ ≠ τ) + } + } + } + } + } else { + // Unrestricted: explicit spin-channel ERIs + + // aaaa channel + for (std::size_t p = 0; p < n_spatial; ++p) { + for (std::size_t q = 0; q < n_spatial; ++q) { + for (std::size_t r = 0; r < n_spatial; ++r) { + for (std::size_t s = 0; s < n_spatial; ++s) { + double eri = eri_aaaa[idx4(p, q, r, s)]; + if (std::abs(eri) < integral_threshold) continue; + + accumulate_two_body_product(mode_alpha(p), mode_alpha(q), + mode_alpha(r), mode_alpha(s), eri); + if (q == r) { + accumulate_epq(mode_alpha(p), mode_alpha(s), -0.5 * eri); + } + } + } + } + } + + // bbbb channel + for (std::size_t p = 0; p < n_spatial; ++p) { + for (std::size_t q = 0; q < n_spatial; ++q) { + for (std::size_t r = 0; r < n_spatial; ++r) { + for (std::size_t s = 0; s < n_spatial; ++s) { + double eri = eri_bbbb[idx4(p, q, r, s)]; + if (std::abs(eri) < integral_threshold) continue; + + accumulate_two_body_product(mode_beta(p), mode_beta(q), + mode_beta(r), mode_beta(s), eri); + if (q == r) { + accumulate_epq(mode_beta(p), mode_beta(s), -0.5 * eri); + } + } + } + } + } + + // aabb channel (alpha-beta) + for (std::size_t p = 0; p < n_spatial; ++p) { + for (std::size_t q = 0; q < n_spatial; ++q) { + for (std::size_t r = 0; r < n_spatial; ++r) { + for (std::size_t s = 0; s < n_spatial; ++s) { + double eri = eri_aabb[idx4(p, q, r, s)]; + if (std::abs(eri) < integral_threshold) continue; + + accumulate_two_body_product(mode_alpha(p), mode_alpha(q), + mode_beta(r), mode_beta(s), eri); + // No δ correction (different spin) + } + } + } + } + + // bbaa channel (beta-alpha) — uses same eri_aabb + for (std::size_t p = 0; p < n_spatial; ++p) { + for (std::size_t q = 0; q < n_spatial; ++q) { + for (std::size_t r = 0; r < n_spatial; ++r) { + for (std::size_t s = 0; s < n_spatial; ++s) { + double eri = eri_aabb[idx4(p, q, r, s)]; + if (std::abs(eri) < integral_threshold) continue; + + accumulate_two_body_product(mode_beta(p), mode_beta(q), + mode_alpha(r), mode_alpha(s), eri); + // No δ correction (different spin) + } + } + } + } + } + + // ─── Extract results ────────────────────────────────────────────── + auto terms = acc.get_terms(threshold); + std::size_t num_qubits = mapping.num_qubits(); + + MajoranaMapResult result; + result.pauli_strings.reserve(terms.size()); + result.coefficients.reserve(terms.size()); + + for (auto& [coeff, word] : terms) { + result.pauli_strings.push_back(sparse_to_le_string(word, num_qubits)); + result.coefficients.push_back(coeff); + } + + return result; +} + +} // namespace qdk::chemistry::data diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp new file mode 100644 index 000000000..91a7229ea --- /dev/null +++ b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp @@ -0,0 +1,388 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for +// license information. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace qdk::chemistry::data { + +namespace { + +// Re-use the Pauli algebra from pauli_operator.cpp (it's in the detail +// namespace of this translation unit's sibling, but we can call the +// PauliTermAccumulator static helper which is public). + +/// Multiply two SparsePauliWords via the public uncached static method. +std::pair, SparsePauliWord> multiply_words( + const SparsePauliWord& a, const SparsePauliWord& b) { + return PauliTermAccumulator::multiply_uncached(a, b); +} + +// ── BK index-set computation ────────────────────────────────────────── + +/// Smallest power of 2 ≥ n. +std::size_t next_power_of_two(std::size_t n) { + if (n == 0) return 1; + std::size_t p = 1; + while (p < n) p <<= 1; + return p; +} + +/// Parity set P(j) for BK encoding. +/// P(j) contains qubit indices whose cumulative parity encodes the +/// occupation of all orbitals with index < j. +std::vector bk_parity_set(std::uint64_t j, std::size_t n) { + // n must be a power of 2 + if (n <= 1) return {}; + std::size_t half = n / 2; + if (j < half) { + return bk_parity_set(j, half); + } + auto sub = bk_parity_set(j - half, half); + std::vector result; + result.reserve(sub.size() + 1); + result.push_back(static_cast(half - 1)); + for (auto idx : sub) { + result.push_back(idx + static_cast(half)); + } + return result; +} + +/// Update (ancestor) set U(j) for BK encoding. +/// U(j) contains qubit indices whose stored parity must be flipped when +/// orbital j is occupied. +std::vector bk_update_set(std::uint64_t j, std::size_t n) { + if (n <= 1) return {}; + std::size_t half = n / 2; + if (j < half) { + auto sub = bk_update_set(j, half); + sub.push_back(static_cast(n - 1)); + return sub; + } + auto sub = bk_update_set(j - half, half); + std::vector result; + result.reserve(sub.size()); + for (auto idx : sub) { + result.push_back(idx + static_cast(half)); + } + return result; +} + +/// Flip (children) set F(j) for BK encoding. +std::vector bk_flip_set(std::uint64_t j, std::size_t n) { + if (n <= 1) return {}; + std::size_t half = n / 2; + if (j < half) { + return bk_flip_set(j, half); + } + if (j < static_cast(n - 1)) { + auto sub = bk_flip_set(j - half, half); + std::vector result; + result.reserve(sub.size()); + for (auto idx : sub) { + result.push_back(idx + static_cast(half)); + } + return result; + } + // j == n-1 + auto sub = bk_flip_set(j - half, half); + std::vector result; + result.reserve(sub.size() + 1); + result.push_back(static_cast(half - 1)); + for (auto idx : sub) { + result.push_back(idx + static_cast(half)); + } + return result; +} + +/// Remainder set R(j) = P(j) \ F(j) for BK encoding. +std::vector bk_remainder_set(std::uint64_t j, std::size_t n) { + auto parity = bk_parity_set(j, n); + auto flip = bk_flip_set(j, n); + + // Convert flip to a set for O(1) lookup + std::vector in_flip(n, false); + for (auto idx : flip) { + if (idx < n) in_flip[idx] = true; + } + + std::vector result; + for (auto idx : parity) { + if (idx >= n || !in_flip[idx]) { + result.push_back(idx); + } + } + return result; +} + +/// Build a SparsePauliWord from qubit assignments. The word is sorted by +/// qubit index (required invariant for SparsePauliWord). +SparsePauliWord build_sorted_word( + std::vector> entries) { + std::sort(entries.begin(), entries.end()); + return entries; +} + +// Pauli operator type constants (matching pauli_operator.hpp convention) +constexpr std::uint8_t OP_X = 1; +constexpr std::uint8_t OP_Y = 2; +constexpr std::uint8_t OP_Z = 3; + +} // anonymous namespace + +// ── MajoranaMapping implementation ─────────────────────────────────── + +MajoranaMapping::MajoranaMapping(std::vector table, + std::string name) + : table_(std::move(table)), + name_(std::move(name)), + num_qubits_(compute_num_qubits(table_)) { + if (table_.empty()) { + throw std::invalid_argument( + "MajoranaMapping table must not be empty"); + } + if (table_.size() % 2 != 0) { + throw std::invalid_argument( + "MajoranaMapping table must have an even number of entries " + "(2 per fermionic mode), got " + + std::to_string(table_.size())); + } + validate(); +} + +const SparsePauliWord& MajoranaMapping::operator()(std::size_t k) const { + if (k >= table_.size()) { + throw std::out_of_range( + "Majorana index " + std::to_string(k) + + " out of range [0, " + std::to_string(table_.size()) + ")"); + } + return table_[k]; +} + +void MajoranaMapping::validate() const { + const std::size_t n = table_.size(); + constexpr double tol = 1e-12; + + for (std::size_t i = 0; i < n; ++i) { + for (std::size_t j = i; j < n; ++j) { + // Compute φ(γ_i)·φ(γ_j) + φ(γ_j)·φ(γ_i) and check = 2δ_{ij}·I + auto [phase_ij, word_ij] = multiply_words(table_[i], table_[j]); + auto [phase_ji, word_ji] = multiply_words(table_[j], table_[i]); + + // The anticommutator should be a scalar (identity word = empty) + // For i == j: result should be 2·I (phase = 2, word = empty) + // For i != j: result should be 0 (both terms cancel) + + if (i == j) { + // γ_i² = I, so φ(γ_i)·φ(γ_i) should give phase·I with phase + // being ±1 (since Pauli strings square to ±I). The sum should be 2. + // Since word_ij == word_ji and phase_ij == phase_ji (same product), + // we need phase_ij to be 1 and word_ij to be identity. + if (!word_ij.empty()) { + std::ostringstream msg; + msg << "Clifford algebra validation failed: γ_" << i + << " squared is not proportional to identity"; + throw std::invalid_argument(msg.str()); + } + // phase_ij should be +1 (Pauli string squares to +I only for I, XX, + // YY, ZZ on a single qubit gives -I for Y but the overall product + // should be +I for a valid Majorana operator) + if (std::abs(phase_ij - std::complex(1.0, 0.0)) > tol) { + std::ostringstream msg; + msg << "Clifford algebra validation failed: γ_" << i + << " squared gives phase " << phase_ij << " (expected +1)"; + throw std::invalid_argument(msg.str()); + } + } else { + // γ_i·γ_j + γ_j·γ_i = 0, so the two products must cancel. + // If words are different, both must have zero coefficient. + // If words are the same, phases must sum to zero. + if (word_ij == word_ji) { + auto sum = phase_ij + phase_ji; + if (std::abs(sum) > tol) { + std::ostringstream msg; + msg << "Clifford algebra validation failed: {γ_" << i << ", γ_" + << j << "} != 0 (anticommutator phase sum = " << sum << ")"; + throw std::invalid_argument(msg.str()); + } + } else { + // Different resulting words — each must be separately zero, + // which means either the mapping is invalid or the words cancel + // in a sum expression. For valid Majorana mappings from Clifford + // algebra homomorphisms, the products always yield the same word + // (possibly with different phase). If words differ, that's an error. + if (std::abs(phase_ij) > tol || std::abs(phase_ji) > tol) { + std::ostringstream msg; + msg << "Clifford algebra validation failed: {γ_" << i << ", γ_" + << j + << "} produces non-cancelling terms with different Pauli words"; + throw std::invalid_argument(msg.str()); + } + } + } + } + } +} + +std::size_t MajoranaMapping::compute_num_qubits( + const std::vector& table) { + std::uint64_t max_idx = 0; + bool has_any = false; + for (const auto& word : table) { + for (const auto& [qubit, _] : word) { + if (!has_any || qubit >= max_idx) { + max_idx = qubit; + has_any = true; + } + } + } + return has_any ? static_cast(max_idx + 1) : 0; +} + +// ── Factory: Jordan-Wigner ─────────────────────────────────────────── + +MajoranaMapping MajoranaMapping::jordan_wigner(std::size_t num_modes) { + if (num_modes == 0) { + throw std::invalid_argument( + "jordan_wigner requires num_modes > 0"); + } + + std::vector table; + table.reserve(2 * num_modes); + + for (std::size_t j = 0; j < num_modes; ++j) { + // γ_{2j} = Z_{j-1} ... Z_0 ⊗ X_j + std::vector> even_entries; + for (std::size_t k = 0; k < j; ++k) { + even_entries.emplace_back(static_cast(k), OP_Z); + } + even_entries.emplace_back(static_cast(j), OP_X); + table.push_back(build_sorted_word(std::move(even_entries))); + + // γ_{2j+1} = Z_{j-1} ... Z_0 ⊗ Y_j + std::vector> odd_entries; + for (std::size_t k = 0; k < j; ++k) { + odd_entries.emplace_back(static_cast(k), OP_Z); + } + odd_entries.emplace_back(static_cast(j), OP_Y); + table.push_back(build_sorted_word(std::move(odd_entries))); + } + + return MajoranaMapping(std::move(table), "jordan-wigner"); +} + +// ── Factory: Bravyi-Kitaev ─────────────────────────────────────────── + +MajoranaMapping MajoranaMapping::bravyi_kitaev(std::size_t num_modes) { + if (num_modes == 0) { + throw std::invalid_argument( + "bravyi_kitaev requires num_modes > 0"); + } + + // BK index sets are defined on a binary tree of size 2^ceil(log2(n)) + std::size_t tree_size = next_power_of_two(num_modes); + + std::vector table; + table.reserve(2 * num_modes); + + for (std::size_t j = 0; j < num_modes; ++j) { + auto parity = bk_parity_set(static_cast(j), tree_size); + auto update = bk_update_set(static_cast(j), tree_size); + auto remainder = + bk_remainder_set(static_cast(j), tree_size); + + // Filter out indices ≥ num_modes (virtual tree nodes beyond actual qubits) + auto filter = [num_modes](std::vector& v) { + v.erase(std::remove_if(v.begin(), v.end(), + [num_modes](std::uint64_t idx) { + return idx >= num_modes; + }), + v.end()); + }; + filter(parity); + filter(update); + filter(remainder); + + // γ_{2j} = X_{U(j)} ⊗ X_j ⊗ Z_{P(j)} + { + std::vector> entries; + entries.emplace_back(static_cast(j), OP_X); + for (auto q : parity) { + entries.emplace_back(q, OP_Z); + } + for (auto q : update) { + entries.emplace_back(q, OP_X); + } + table.push_back(build_sorted_word(std::move(entries))); + } + + // γ_{2j+1} = X_{U(j)} ⊗ Y_j ⊗ Z_{R(j)} + { + std::vector> entries; + entries.emplace_back(static_cast(j), OP_Y); + for (auto q : remainder) { + entries.emplace_back(q, OP_Z); + } + for (auto q : update) { + entries.emplace_back(q, OP_X); + } + table.push_back(build_sorted_word(std::move(entries))); + } + } + + return MajoranaMapping(std::move(table), "bravyi-kitaev"); +} + +// ── Factory: Parity ────────────────────────────────────────────────── + +MajoranaMapping MajoranaMapping::parity(std::size_t num_modes) { + if (num_modes == 0) { + throw std::invalid_argument("parity requires num_modes > 0"); + } + + std::vector table; + table.reserve(2 * num_modes); + + for (std::size_t j = 0; j < num_modes; ++j) { + // γ_{2j}: X_j, X_{j+1} (if j < n-1), Z on {j-1, j-3, j-5, ...} + { + std::vector> entries; + // Z on alternating qubits below j (step -2 from j-1) + for (int64_t k = static_cast(j) - 1; k >= 0; k -= 2) { + entries.emplace_back(static_cast(k), OP_Z); + } + entries.emplace_back(static_cast(j), OP_X); + if (j < num_modes - 1) { + entries.emplace_back(static_cast(j + 1), OP_X); + } + table.push_back(build_sorted_word(std::move(entries))); + } + + // γ_{2j+1}: Y_j, X_{j+1} (if j < n-1), Z on {j-2, j-4, j-6, ...} + { + std::vector> entries; + // Z on alternating qubits below j (step -2 from j-2) + for (int64_t k = static_cast(j) - 2; k >= 0; k -= 2) { + entries.emplace_back(static_cast(k), OP_Z); + } + entries.emplace_back(static_cast(j), OP_Y); + if (j < num_modes - 1) { + entries.emplace_back(static_cast(j + 1), OP_X); + } + table.push_back(build_sorted_word(std::move(entries))); + } + } + + return MajoranaMapping(std::move(table), "parity"); +} + +} // namespace qdk::chemistry::data diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index c0a3d5ddd..da72ff6a7 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -129,6 +129,7 @@ pybind11_add_module(_core src/pybind11/data/ansatz.cpp src/pybind11/data/stability_result.cpp src/pybind11/data/pauli_operator.cpp + src/pybind11/data/majorana_mapping.cpp src/pybind11/data/serialization.cpp src/pybind11/data/lattice_graph.cpp src/pybind11/algorithms/localizer.cpp diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp new file mode 100644 index 000000000..49da9659c --- /dev/null +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -0,0 +1,416 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for +// license information. + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace py = pybind11; + +namespace { + +/// Map dense little-endian Pauli char to op_type (0=I, 1=X, 2=Y, 3=Z). +std::uint8_t char_to_op(char c) { + switch (c) { + case 'I': + case 'i': + return 0; + case 'X': + case 'x': + return 1; + case 'Y': + case 'y': + return 2; + case 'Z': + case 'z': + return 3; + default: + throw std::invalid_argument( + std::string("Invalid Pauli character '") + c + + "' — expected I, X, Y, or Z"); + } +} + +/// Convert a dense little-endian Pauli string (qubit 0 = rightmost char) +/// to a SparsePauliWord (sorted by qubit index, identities omitted). +qdk::chemistry::data::SparsePauliWord dense_le_to_sparse( + const std::string& s) { + qdk::chemistry::data::SparsePauliWord word; + // Little-endian: qubit 0 is the rightmost character (index len-1). + std::size_t len = s.size(); + for (std::size_t i = 0; i < len; ++i) { + std::size_t qubit = len - 1 - i; // rightmost char = qubit 0 + std::uint8_t op = char_to_op(s[i]); + if (op != 0) { + word.emplace_back(static_cast(qubit), op); + } + } + // Sort by qubit index (required invariant) + std::sort(word.begin(), word.end()); + return word; +} + +/// Convert a SparsePauliWord to a dense little-endian string. +std::string sparse_to_dense_le( + const qdk::chemistry::data::SparsePauliWord& word, + std::size_t num_qubits) { + // Build big-endian first (qubit 0 at index 0), then reverse + std::string result(num_qubits, 'I'); + for (const auto& [qubit, op_type] : word) { + if (qubit < num_qubits) { + switch (op_type) { + case 1: + result[qubit] = 'X'; + break; + case 2: + result[qubit] = 'Y'; + break; + case 3: + result[qubit] = 'Z'; + break; + default: + break; + } + } + } + // Reverse to get little-endian (qubit 0 = rightmost) + std::reverse(result.begin(), result.end()); + return result; +} + +} // namespace + +void bind_majorana_mapping(pybind11::module& data) { + using namespace qdk::chemistry::data; + + py::class_ mapping(data, "MajoranaMapping", R"( +Immutable data class mapping 2N Majorana operators to Pauli strings. + +A MajoranaMapping stores a table of 2N entries, one per Majorana operator +γ_k (k = 0, ..., 2N-1), where N is the number of fermionic modes +(spin-orbitals). Each entry is a single Pauli string representing φ(γ_k) +under the chosen fermion-to-qubit encoding. + +The mapping is validated at construction time by checking the Clifford +algebra anticommutation relations: {γ_i, γ_j} = 2δ_{ij} · I. + +Example: + >>> from qdk_chemistry._core.data import MajoranaMapping + >>> jw = MajoranaMapping.jordan_wigner(4) + >>> jw.num_modes + 4 + >>> jw.num_qubits + 4 +)"); + + // Constructor from list of dense little-endian Pauli strings + mapping.def( + py::init([](py::list table_list, const std::string& name) { + Py_ssize_t n = PyList_GET_SIZE(table_list.ptr()); + if (n == 0) { + throw py::value_error("MajoranaMapping table must not be empty"); + } + if (n % 2 != 0) { + throw py::value_error( + "MajoranaMapping table must have an even number of entries " + "(2 per fermionic mode), got " + + std::to_string(n)); + } + + // Validate all strings have the same length and contain only IXYZ + Py_ssize_t expected_len = -1; + std::vector table; + table.reserve(static_cast(n)); + + for (Py_ssize_t i = 0; i < n; ++i) { + PyObject* item = PyList_GET_ITEM(table_list.ptr(), i); + if (!PyUnicode_Check(item)) { + throw py::value_error( + "MajoranaMapping table entries must be strings, got " + + std::string(Py_TYPE(item)->tp_name) + " at index " + + std::to_string(i)); + } + Py_ssize_t str_len; + const char* str_data = PyUnicode_AsUTF8AndSize(item, &str_len); + if (!str_data) { + throw py::value_error( + "Failed to decode string at index " + std::to_string(i)); + } + + if (expected_len < 0) { + expected_len = str_len; + } else if (str_len != expected_len) { + throw py::value_error( + "All Pauli strings must have the same length. Entry 0 has " + "length " + + std::to_string(expected_len) + " but entry " + + std::to_string(i) + " has length " + + std::to_string(str_len)); + } + + std::string s(str_data, static_cast(str_len)); + // char_to_op validates each character + table.push_back(dense_le_to_sparse(s)); + } + + // C++ constructor validates Clifford algebra + try { + return MajoranaMapping(std::move(table), name); + } catch (const std::invalid_argument& e) { + throw py::value_error(e.what()); + } + }), + py::arg("table"), py::arg("name") = "", + R"( +Construct a MajoranaMapping from a list of dense Pauli-string labels. + +Each string uses the same little-endian convention as QubitHamiltonian: +qubit 0 is the rightmost character. The list must have 2N entries (one +per Majorana operator), where N is the number of fermionic modes. + +The Clifford algebra {γ_i, γ_j} = 2δ_{ij}·I is validated at construction. + +Args: + table: List of 2N Pauli strings (e.g., ["IX", "IY", "XZ", "YZ"]). + name: Optional human-readable label for the encoding. + +Raises: + ValueError: If the table is invalid (wrong size, bad characters, or + Clifford algebra violation). +)"); + + // Constructor from list of SparsePauliWord (for advanced use) + mapping.def( + py::init([](const std::vector& table, + const std::string& name) { + try { + return MajoranaMapping(table, name); + } catch (const std::invalid_argument& e) { + throw py::value_error(e.what()); + } + }), + py::arg("table"), py::arg("name") = "", + R"( +Construct a MajoranaMapping from a list of sparse Pauli words. + +Each sparse word is a list of (qubit_index, op_type) tuples. This is the +advanced constructor for users who want to avoid string parsing. + +Args: + table: List of 2N sparse Pauli words. + name: Optional human-readable label for the encoding. + +Raises: + ValueError: If the table is invalid. +)"); + + // Properties + mapping.def_property_readonly( + "num_modes", + [](const MajoranaMapping& self) { return self.num_modes(); }, + "Number of fermionic modes (spin-orbitals)."); + + mapping.def_property_readonly( + "num_qubits", + [](const MajoranaMapping& self) { return self.num_qubits(); }, + "Number of qubits required by this encoding."); + + mapping.def_property_readonly( + "name", [](const MajoranaMapping& self) { return self.name(); }, + "Human-readable name of the encoding."); + + // table property: return as list of dense little-endian strings + mapping.def_property_readonly( + "table", + [](const MajoranaMapping& self) -> py::tuple { + std::size_t nq = self.num_qubits(); + py::tuple result(self.table().size()); + for (std::size_t i = 0; i < self.table().size(); ++i) { + result[i] = py::cast(sparse_to_dense_le(self.table()[i], nq)); + } + return result; + }, + "Tuple of dense little-endian Pauli strings (qubit 0 = rightmost)."); + + // sparse_table property: return as list of SparsePauliWord + mapping.def_property_readonly( + "sparse_table", + [](const MajoranaMapping& self) { return self.table(); }, + "List of sparse Pauli words [(qubit_idx, op_type), ...]."); + + // __call__ for γ_k lookup + mapping.def( + "__call__", + [](const MajoranaMapping& self, std::size_t k) -> SparsePauliWord { + try { + return self(k); + } catch (const std::out_of_range& e) { + throw py::index_error(e.what()); + } + }, + py::arg("k"), + R"( +Look up the sparse Pauli word for Majorana operator γ_k. + +Args: + k: Majorana index (0 ≤ k < 2N). + +Returns: + Sparse Pauli word as list of (qubit_index, op_type) tuples. + +Raises: + IndexError: If k is out of range. +)"); + + // validate() + mapping.def( + "validate", + [](const MajoranaMapping& self) { + try { + self.validate(); + } catch (const std::invalid_argument& e) { + throw py::value_error(e.what()); + } + }, + "Validate the Clifford algebra anticommutation relations."); + + // __repr__ + mapping.def("__repr__", [](const MajoranaMapping& self) -> std::string { + std::string repr = "MajoranaMapping("; + if (!self.name().empty()) { + repr += "'" + self.name() + "', "; + } + repr += "num_modes=" + std::to_string(self.num_modes()) + + ", num_qubits=" + std::to_string(self.num_qubits()) + ")"; + return repr; + }); + + // Factory methods + mapping.def_static( + "jordan_wigner", + [](std::size_t num_modes) { + try { + return MajoranaMapping::jordan_wigner(num_modes); + } catch (const std::invalid_argument& e) { + throw py::value_error(e.what()); + } + }, + py::arg("num_modes"), + R"( +Construct a Jordan-Wigner encoding. + +Args: + num_modes: Number of fermionic modes (spin-orbitals). + +Returns: + MajoranaMapping with name "jordan_wigner". +)"); + + mapping.def_static( + "bravyi_kitaev", + [](std::size_t num_modes) { + try { + return MajoranaMapping::bravyi_kitaev(num_modes); + } catch (const std::invalid_argument& e) { + throw py::value_error(e.what()); + } + }, + py::arg("num_modes"), + R"( +Construct a Bravyi-Kitaev encoding. + +Args: + num_modes: Number of fermionic modes (spin-orbitals). + +Returns: + MajoranaMapping with name "bravyi_kitaev". +)"); + + mapping.def_static( + "parity", + [](std::size_t num_modes) { + try { + return MajoranaMapping::parity(num_modes); + } catch (const std::invalid_argument& e) { + throw py::value_error(e.what()); + } + }, + py::arg("num_modes"), + R"( +Construct a parity encoding. + +Args: + num_modes: Number of fermionic modes (spin-orbitals). + +Returns: + MajoranaMapping with name "parity". +)"); + + // ─── majorana_map_hamiltonian free function ──────────────────────── + data.def( + "majorana_map_hamiltonian", + [](const MajoranaMapping& mapping, double core_energy, + py::array_t + h1_alpha, + py::array_t + h1_beta, + py::array_t + eri_aaaa, + py::array_t + eri_aabb, + py::array_t + eri_bbbb, + std::size_t n_spatial, bool is_restricted, double threshold, + double integral_threshold) -> py::tuple { + auto result = majorana_map_hamiltonian( + mapping, core_energy, h1_alpha.data(), h1_beta.data(), + eri_aaaa.data(), eri_aabb.data(), eri_bbbb.data(), n_spatial, + is_restricted, threshold, integral_threshold); + + // Convert to Python: (list[str], list[complex]) + py::list py_strings; + py::list py_coeffs; + for (std::size_t i = 0; i < result.pauli_strings.size(); ++i) { + py_strings.append(result.pauli_strings[i]); + py_coeffs.append(result.coefficients[i]); + } + return py::make_tuple(py_strings, py_coeffs); + }, + py::arg("mapping"), py::arg("core_energy"), py::arg("h1_alpha"), + py::arg("h1_beta"), py::arg("eri_aaaa"), py::arg("eri_aabb"), + py::arg("eri_bbbb"), py::arg("n_spatial"), py::arg("is_restricted"), + py::arg("threshold"), py::arg("integral_threshold"), + R"( +Map a fermionic Hamiltonian to qubit Pauli terms using Majorana loops. + +This is the encoding-agnostic C++ mapping engine. It takes integrals +and a MajoranaMapping, and returns (pauli_strings, coefficients). + +Args: + mapping: The MajoranaMapping encoding. + core_energy: Core energy (nuclear repulsion + frozen core). + h1_alpha: One-body integrals for alpha spin (n_spatial × n_spatial, flattened). + h1_beta: One-body integrals for beta spin (n_spatial × n_spatial, flattened). + eri_aaaa: Two-body integrals (αα|αα) (n_spatial⁴, flattened). + eri_aabb: Two-body integrals (αα|ββ) (n_spatial⁴, flattened). + eri_bbbb: Two-body integrals (ββ|ββ) (n_spatial⁴, flattened). + n_spatial: Number of spatial orbitals. + is_restricted: Whether the system is spin-free (h1_alpha == h1_beta). + threshold: Drop Pauli terms with |coeff| < threshold. + integral_threshold: Skip integrals with |value| < this. + +Returns: + Tuple of (list[str], list[complex]) with Pauli strings and coefficients. +)"); +} diff --git a/python/src/pybind11/module.cpp b/python/src/pybind11/module.cpp index 23ba9ddfe..dce604bc3 100644 --- a/python/src/pybind11/module.cpp +++ b/python/src/pybind11/module.cpp @@ -33,6 +33,7 @@ void bind_configuration(py::module& m); void bind_qdk_chemistry_config(py::module& m); void bind_pauli_operator(py::module& m); void bind_valence_space(py::module& m); +void bind_majorana_mapping(py::module& m); void bind_orbital_rotation(py::module& m); void bind_dynamical_correlation_calculator(py::module& m); void bind_logger(py::module& m); @@ -69,6 +70,8 @@ PYBIND11_MODULE(_core, m) { bind_ansatz(data); bind_stability_result(data); bind_pauli_operator(data); + bind_majorana_mapping( + data); // Depends on SparsePauliWord from pauli_operator bind_serialization(data); bind_localizer(algorithms); diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index 99280c510..3344630a5 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -1,7 +1,9 @@ -"""QDK native qubit mapper using an optimized expression layer. +"""QDK native qubit mapper using Majorana-level C++ building blocks. This module provides the QdkQubitMapper class for transforming electronic structure -Hamiltonians to qubit Hamiltonians using various fermion-to-qubit encodings. +Hamiltonians to qubit Hamiltonians. The encoding is specified by a +:class:`~qdk_chemistry.data.MajoranaMapping` passed to :meth:`run`, making the +mapper encoding-agnostic. """ # -------------------------------------------------------------------------------------------- @@ -15,182 +17,23 @@ import numpy as np +from qdk_chemistry._core.data import majorana_map_hamiltonian from qdk_chemistry.algorithms.qubit_mapper.qubit_mapper import QubitMapper, QubitMapperSettings -from qdk_chemistry.data import PauliTermAccumulator from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian from qdk_chemistry.utils import Logger if TYPE_CHECKING: from qdk_chemistry.data import Hamiltonian, Symmetries - -# Type alias for sparse Pauli word: list of (qubit_index, op_type) -# op_type: 1=X, 2=Y, 3=Z (identity is implicit/omitted) -SparsePauliWord = list[tuple[int, int]] - -# Pauli operator type constants -_X = 1 -_Y = 2 -_Z = 3 + from qdk_chemistry.data.majorana_mapping import MajoranaMapping __all__ = ["QdkQubitMapper", "QdkQubitMapperSettings"] -# ============================================================================= -# Bravyi-Kitaev Binary Tree Index Sets -# ============================================================================= -# The following functions compute the qubit index sets for Bravyi-Kitaev encoding -# as defined in the original paper: -# -# Seeley, Richard, and Love. "The Bravyi-Kitaev transformation for quantum -# computation of electronic structure." J. Chem. Phys. 137, 224109 (2012). -# https://doi.org/10.1063/1.4768229 -# -# The BK encoding maps fermionic operators to qubit operators using a binary -# tree structure where: -# - Even-indexed qubits store occupation information -# - Odd-indexed qubits store partial parity sums -# -# Each ladder operator requires three index sets derived from the tree structure: -# - P(j): "parity set" - qubits encoding parity of orbitals < j -# - U(j): "update set" - ancestor qubits whose parity includes orbital j -# - F(j): "flip set" - subset of P(j) for the imaginary component -# - R(j): "remainder set" = P(j) \ F(j) - used for Z operators in Y component -# ============================================================================= - - -def _bk_compute_parity_indices(j: int, n: int) -> frozenset[int]: - """Compute qubit indices encoding cumulative parity for orbital j. - - In the Bravyi-Kitaev binary tree, the parity set P(j) contains indices - of qubits that together encode the parity of all orbitals with index < j. - This is used to construct the Z-string in the real component of ladder operators. - - Reference: Seeley et al., J. Chem. Phys. 137, 224109 (2012), Eq. (17). - - Args: - j: Spin-orbital index. - n: Size of the binary superset (must be a power of 2). - - Returns: - Frozenset of qubit indices in the parity set. - - Raises: - ValueError: If n is not a power of 2. - - """ - if n == 1: - return frozenset() # Base case: single orbital has no parity dependencies - if n <= 0 or (n & (n - 1)) != 0: - raise ValueError(f"n must be a power of 2, got {n}") - half = n // 2 - if j < half: - return _bk_compute_parity_indices(j, half) - # Right half: recurse with offset, then add n/2-1 - return frozenset(i + half for i in _bk_compute_parity_indices(j - half, half)) | frozenset({half - 1}) - - -def _bk_compute_ancestor_indices(j: int, n: int) -> frozenset[int]: - """Compute qubit indices that are ancestors of orbital j in the binary tree. - - The update set U(j) contains indices of qubits whose stored parity value - must be flipped when orbital j is occupied. These correspond to ancestor - nodes in the binary tree representation. - - Reference: Seeley et al., J. Chem. Phys. 137, 224109 (2012), Eq. (18). - - Args: - j: Spin-orbital index. - n: Size of the binary superset (must be a power of 2). - - Returns: - Frozenset of qubit indices in the update (ancestor) set. - - Raises: - ValueError: If n is not a power of 2. - - """ - if n == 1: - return frozenset() # Base case: single orbital has no ancestors - if n <= 0 or (n & (n - 1)) != 0: - raise ValueError(f"n must be a power of 2, got {n}") - half = n // 2 - if j < half: - # Left half: include n-1 and recurse - return frozenset({n - 1}) | _bk_compute_ancestor_indices(j, half) - # Right half: recurse with offset - return frozenset(i + half for i in _bk_compute_ancestor_indices(j - half, half)) - - -def _bk_compute_children_indices(j: int, n: int) -> frozenset[int]: - """Compute qubit indices for the imaginary component parity subset. - - The flip set F(j) is used to partition the parity set when constructing - the Y-component of BK ladder operators. It identifies which parity qubits - contribute to the imaginary vs real components. - - Reference: Seeley et al., J. Chem. Phys. 137, 224109 (2012), Eq. (19). - - Args: - j: Spin-orbital index. - n: Size of the binary superset (must be a power of 2). - - Returns: - Frozenset of qubit indices in the flip (children) set. - - Raises: - ValueError: If n is not a power of 2. - - """ - if n == 1: - return frozenset() # Base case: single orbital has no children - if n <= 0 or (n & (n - 1)) != 0: - raise ValueError(f"n must be a power of 2, got {n}") - half = n // 2 - if j < half: - # Left half: recurse - return _bk_compute_children_indices(j, half) - if j < n - 1: - # Right half but not last: recurse with offset - return frozenset(i + half for i in _bk_compute_children_indices(j - half, half)) - # Last element (j == n-1): recurse with offset and add n/2-1 - return frozenset(i + half for i in _bk_compute_children_indices(j - half, half)) | frozenset({half - 1}) - - -def _bk_compute_z_indices_for_y_component(j: int, n: int) -> frozenset[int]: - r"""Compute qubit indices for Z operators in the Y-component of ladder operators. - - The remainder set R(j) = P(j) \\ F(j) determines which qubits receive Z gates - in the imaginary (Y) component of BK ladder operators. This set difference - partitions the parity information between real and imaginary components. - - Reference: Seeley et al., J. Chem. Phys. 137, 224109 (2012), Section II.B. - - Args: - j: Spin-orbital index. - n: Size of the binary superset (must be a power of 2). - - Returns: - Frozenset of qubit indices for Z operators in Y-component. - - Raises: - ValueError: If n is not a power of 2. - - """ - parity = _bk_compute_parity_indices(j, n) - flip = _bk_compute_children_indices(j, n) - return parity - flip # Set difference, not symmetric difference - class QdkQubitMapperSettings(QubitMapperSettings): """Settings configuration for a QdkQubitMapper. - Inherits base settings from :class:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapperSettings`. - - Available encodings: - - ``"jordan-wigner"`` (default) - - ``"bravyi-kitaev"`` - - Additional settings: + Settings: threshold (double, default=1e-12): Threshold for pruning small Pauli coefficients. integral_threshold (double, default=1e-12): Threshold for filtering small integrals. @@ -199,7 +42,7 @@ class QdkQubitMapperSettings(QubitMapperSettings): def __init__(self) -> None: """Initialize QdkQubitMapperSettings.""" Logger.trace_entering() - super().__init__(valid_encodings=["jordan-wigner", "bravyi-kitaev"]) + super().__init__() self._set_default( "threshold", "double", @@ -215,51 +58,41 @@ def __init__(self) -> None: class QdkQubitMapper(QubitMapper): - """QDK native qubit mapper using PauliTermAccumulator. - - This mapper transforms a fermionic Hamiltonian to a qubit Hamiltonian using - configurable fermion-to-qubit encodings. + """QDK native qubit mapper using Majorana-level C++ engine. - Available encodings: - - ``"jordan-wigner"`` (default) - - ``"bravyi-kitaev"`` + This mapper transforms a fermionic Hamiltonian to a qubit Hamiltonian. + The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` + passed to :meth:`run`. Any valid MajoranaMapping works — built-in + (Jordan-Wigner, Bravyi-Kitaev, parity) or custom. The mapper uses canonical blocked spin-orbital ordering internally: qubits 0..N-1 for alpha spin, qubits N..2N-1 for beta spin (where N is the number of spatial orbitals). Use ``QubitHamiltonian.to_interleaved()`` for alternative qubit orderings. - Attributes: - encoding (str): The fermion-to-qubit encoding type. Default: "jordan-wigner". - threshold (float): Threshold for pruning small Pauli coefficients. Default: 1e-12. - integral_threshold (float): Threshold for filtering small integrals. Default: 1e-12. - Examples: - >>> from qdk_chemistry.algorithms import QdkQubitMapper - >>> mapper = QdkQubitMapper() - >>> mapper.settings().set("encoding", "jordan-wigner") - >>> mapper.settings().set("threshold", 1e-10) - >>> qubit_hamiltonian = mapper.run(hamiltonian) + >>> from qdk_chemistry.algorithms import create + >>> from qdk_chemistry.data import MajoranaMapping + >>> mapper = create("qubit_mapper") + >>> mapping = MajoranaMapping.jordan_wigner(num_modes=n_spin_orbitals) + >>> qh = mapper.run(hamiltonian, mapping) """ def __init__( self, - encoding: str = "jordan-wigner", threshold: float = 1e-12, integral_threshold: float = 1e-12, ) -> None: """Initialize the QdkQubitMapper with default settings. Args: - encoding: Fermion-to-qubit encoding type. Default: "jordan-wigner". threshold: Threshold for pruning small Pauli coefficients. Default: 1e-12. integral_threshold: Threshold for filtering small integrals. Default: 1e-12. """ super().__init__() self._settings = QdkQubitMapperSettings() - self._settings.set("encoding", encoding) self._settings.set("threshold", threshold) self._settings.set("integral_threshold", integral_threshold) @@ -267,333 +100,53 @@ def name(self) -> str: """Return the algorithm name.""" return "qdk" - def _run_impl(self, hamiltonian: Hamiltonian, symmetries: Symmetries | None = None) -> QubitHamiltonian: # noqa: ARG002 + def _run_impl( + self, + hamiltonian: Hamiltonian, + mapping: MajoranaMapping, + symmetries: Symmetries | None = None, # noqa: ARG002 + ) -> QubitHamiltonian: """Transform a fermionic Hamiltonian to a qubit Hamiltonian. Args: hamiltonian: The fermionic Hamiltonian with one-body and two-body integrals. + mapping: The Majorana-to-Pauli encoding. symmetries: Optional symmetry information. Not used by this implementation. Returns: QubitHamiltonian: The qubit Hamiltonian with Pauli strings and coefficients. - Raises: - ValueError: If the mapping type is not supported. - RuntimeError: If the Hamiltonian does not have required integrals. - """ Logger.trace_entering() - encoding = str(self.settings().get("encoding")) threshold = float(self.settings().get("threshold")) integral_threshold = float(self.settings().get("integral_threshold")) - if encoding == "jordan-wigner": - return self._jordan_wigner_transform(hamiltonian, threshold, integral_threshold) - if encoding == "bravyi-kitaev": - return self._bravyi_kitaev_transform(hamiltonian, threshold, integral_threshold) - - raise ValueError(f"Unsupported encoding: '{encoding}'.") - - def _jordan_wigner_transform( - self, hamiltonian: Hamiltonian, threshold: float, integral_threshold: float - ) -> QubitHamiltonian: - """Perform Jordan-Wigner transformation. - - Uses blocked spin-orbital ordering: alpha orbitals first, then beta orbitals. - Spin-orbital index p = spatial_orbital for alpha, p = spatial_orbital + n_spatial for beta. - - Args: - hamiltonian: The fermionic Hamiltonian. - threshold: Threshold for pruning small coefficients. - integral_threshold: Threshold for discarding small integrals. - - Returns: - QubitHamiltonian: The transformed qubit Hamiltonian. - - """ - Logger.trace_entering() - - h1_alpha, _ = hamiltonian.get_one_body_integrals() - n_spin_orbitals = 2 * h1_alpha.shape[0] - - # Use C++ to compute all N² excitation terms in one call - Logger.debug("Computing all JW excitation terms in C++...") - all_excitation_terms = PauliTermAccumulator.compute_all_jw_excitation_terms(n_spin_orbitals) - - return self._transform_with_excitation_terms_dict( - hamiltonian, threshold, integral_threshold, n_spin_orbitals, all_excitation_terms, "jordan-wigner" - ) - - def _bravyi_kitaev_transform( - self, hamiltonian: Hamiltonian, threshold: float, integral_threshold: float - ) -> QubitHamiltonian: - r"""Perform Bravyi-Kitaev transformation. - - Implements the fermion-to-qubit encoding from Seeley, Richard, and Love, - "The Bravyi-Kitaev transformation for quantum computation of electronic - structure," J. Chem. Phys. 137, 224109 (2012). - - Uses blocked spin-orbital ordering: alpha orbitals first, then beta orbitals. - The Bravyi-Kitaev encoding uses a binary tree structure where even-indexed - qubits store occupation and odd-indexed qubits store partial parity sums, - achieving O(log n) operator weight compared to O(n) for Jordan-Wigner. - - Args: - hamiltonian: The fermionic Hamiltonian. - threshold: Threshold for pruning small coefficients. - integral_threshold: Threshold for discarding small integrals. - - Returns: - QubitHamiltonian: The transformed qubit Hamiltonian. - - """ - Logger.trace_entering() - - h1_alpha, _ = hamiltonian.get_one_body_integrals() - n_spin_orbitals = 2 * h1_alpha.shape[0] - - # Binary superset size (next power of 2) - bin_sup = 1 - while n_spin_orbitals > 2**bin_sup: - bin_sup += 1 - n_binary = 2**bin_sup - - # Precompute BK index sets for all orbitals (as dict[int, list[int]] for C++) - update_sets: dict[int, list[int]] = {} - parity_sets: dict[int, list[int]] = {} - remainder_sets: dict[int, list[int]] = {} - - for j in range(n_spin_orbitals): - update_sets[j] = sorted(i for i in _bk_compute_ancestor_indices(j, n_binary) if i < n_spin_orbitals) - parity_sets[j] = sorted(i for i in _bk_compute_parity_indices(j, n_binary) if i < n_spin_orbitals) - remainder_sets[j] = sorted( - i for i in _bk_compute_z_indices_for_y_component(j, n_binary) if i < n_spin_orbitals - ) - - # Use C++ to compute all N² excitation terms in one call - Logger.debug("Computing all BK excitation terms in C++...") - all_excitation_terms = PauliTermAccumulator.compute_all_bk_excitation_terms( - n_spin_orbitals, parity_sets, update_sets, remainder_sets - ) - - return self._transform_with_excitation_terms_dict( - hamiltonian, threshold, integral_threshold, n_spin_orbitals, all_excitation_terms, "bravyi-kitaev" - ) - - def _transform_with_excitation_terms_dict( - self, - hamiltonian: Hamiltonian, - threshold: float, - integral_threshold: float, - n_spin_orbitals: int, - excitation_terms_dict: dict[tuple[int, int], list[tuple[complex, SparsePauliWord]]], - encoding: str, - ) -> QubitHamiltonian: - """Transform Hamiltonian to qubit representation using precomputed excitation terms. - - This is the shared infrastructure for all fermion-to-qubit encodings. - It handles integral extraction, excitation operator construction, - spin-summed operators, one-body and two-body terms, and output processing. - - The second-quantized Hamiltonian in chemist notation is: - H = sum_{pq,sigma} h_pq a†_{p,sigma} a_{q,sigma} - + 1/2 sum_{pqrs,sigma,tau} (pq|rs) a†_{p,sigma} a†_{r,tau} a_{s,tau} a_{q,sigma} - - Args: - hamiltonian: The fermionic Hamiltonian. - threshold: Threshold for pruning small coefficients. - integral_threshold: Threshold for discarding small integrals. - n_spin_orbitals: Total number of spin orbitals. - excitation_terms_dict: Pre-computed dictionary mapping (p, q) to - E_pq = a†_p a_q terms in sparse format. - encoding: The fermion-to-qubit encoding used (e.g., "jordan-wigner", "bravyi-kitaev"). - - Returns: - QubitHamiltonian: The transformed qubit Hamiltonian. - - """ - Logger.trace_entering() - h1_alpha, h1_beta = hamiltonian.get_one_body_integrals() h2_aaaa, h2_aabb, h2_bbbb = hamiltonian.get_two_body_integrals() - n_spatial = h1_alpha.shape[0] + is_restricted = hamiltonian.get_orbitals().is_restricted() + + # Single C++ call: Majorana-loop engine builds all Pauli terms + pauli_strings, coefficients = majorana_map_hamiltonian( + mapping.core, + 0.0, # core energy not included (QDK convention) + h1_alpha.flatten(), + h1_beta.flatten(), + h2_aaaa.flatten(), + h2_aabb.flatten(), + h2_bbbb.flatten(), + n_spatial, + is_restricted, + threshold, + integral_threshold, + ) - # Reshape two-body integrals to 4D tensors for direct indexing (zero-copy view) - eri_aaaa = h2_aaaa.reshape((n_spatial, n_spatial, n_spatial, n_spatial)) - eri_aabb = h2_aabb.reshape((n_spatial, n_spatial, n_spatial, n_spatial)) - eri_bbbb = h2_bbbb.reshape((n_spatial, n_spatial, n_spatial, n_spatial)) - - # Use C++ PauliTermAccumulator for efficient term accumulation - accumulator = PauliTermAccumulator() - - # Eagerly precompute spin-summed excitation terms: E_pq = E_pq_alpha + E_pq_beta - # (indexed by spatial orbitals p, q) - Logger.debug("Pre-computing spin-summed excitation terms...") - spin_summed_terms: dict[tuple[int, int], list[tuple[complex, SparsePauliWord]]] = {} - for p in range(n_spatial): - for q in range(n_spatial): - # Get alpha and beta excitation terms (inline index computation) - alpha_terms = excitation_terms_dict[(p, q)] - beta_terms = excitation_terms_dict[(p + n_spatial, q + n_spatial)] - # Merge terms with same sparse word - combined: dict[tuple[tuple[int, int], ...], complex] = {} - for coeff, word in alpha_terms: - key_tuple = tuple(word) - combined[key_tuple] = combined.get(key_tuple, 0) + coeff - for coeff, word in beta_terms: - key_tuple = tuple(word) - combined[key_tuple] = combined.get(key_tuple, 0) + coeff - # Filter using machine epsilon for efficiency - spin_summed_terms[(p, q)] = [ - (coeff, list(word_tuple)) - for word_tuple, coeff in combined.items() - if abs(coeff) > np.finfo(np.float64).eps - ] - - Logger.debug("Building one-body terms...") - is_spin_free = hamiltonian.get_orbitals().is_restricted() - - if is_spin_free: - for p in range(n_spatial): - for q in range(n_spatial): - h_pq = float(h1_alpha[p, q]) - if abs(h_pq) > integral_threshold: - for coeff, word in spin_summed_terms[(p, q)]: - accumulator.accumulate(word, complex(h_pq) * coeff) - else: - # General case: handle alpha and beta separately - for p in range(n_spatial): - for q in range(n_spatial): - h_pq_alpha = float(h1_alpha[p, q]) - h_pq_beta = float(h1_beta[p, q]) - - if abs(h_pq_alpha) > integral_threshold: - for coeff, word in excitation_terms_dict[(p, q)]: - accumulator.accumulate(word, complex(h_pq_alpha) * coeff) - - if abs(h_pq_beta) > integral_threshold: - for coeff, word in excitation_terms_dict[(p + n_spatial, q + n_spatial)]: - accumulator.accumulate(word, complex(h_pq_beta) * coeff) - - Logger.debug("Building two-body terms...") - - if is_spin_free: - # Spin-free case: use spin-summed factorization - for p in range(n_spatial): - for q in range(n_spatial): - for r in range(n_spatial): - for s in range(n_spatial): - eri = float(eri_aaaa[p, q, r, s]) - if abs(eri) > integral_threshold: - e_pq_terms = spin_summed_terms[(p, q)] - e_rs_terms = spin_summed_terms[(r, s)] - scale = complex(0.5 * eri) - for c1, w1 in e_pq_terms: - for c2, w2 in e_rs_terms: - accumulator.accumulate_product(w1, w2, scale * c1 * c2) - - if q == r: - for coeff, word in spin_summed_terms[(p, s)]: - accumulator.accumulate(word, complex(-0.5 * eri) * coeff) - else: - # Spin-polarized case: explicit channel blocks - - # aaaa channel (same-spin alpha-alpha) - Logger.debug("Processing aaaa channel...") - for p in range(n_spatial): - for q in range(n_spatial): - for r in range(n_spatial): - if p == r: - continue - for s in range(n_spatial): - if q == s: - continue - eri = float(eri_aaaa[p, q, r, s]) - if abs(eri) > integral_threshold: - e_pq_terms = excitation_terms_dict[(p, q)] - e_rs_terms = excitation_terms_dict[(r, s)] - scale = complex(0.5 * eri) - for c1, w1 in e_pq_terms: - for c2, w2 in e_rs_terms: - accumulator.accumulate_product(w1, w2, scale * c1 * c2) - - if q == r: - for coeff, word in excitation_terms_dict[(p, s)]: - accumulator.accumulate(word, complex(-0.5 * eri) * coeff) - - # bbbb channel (same-spin beta-beta) - Logger.debug("Processing bbbb channel...") - for p in range(n_spatial): - for q in range(n_spatial): - for r in range(n_spatial): - if p == r: - continue - for s in range(n_spatial): - if q == s: - continue - eri = float(eri_bbbb[p, q, r, s]) - if abs(eri) > integral_threshold: - e_pq_terms = excitation_terms_dict[(p + n_spatial, q + n_spatial)] - e_rs_terms = excitation_terms_dict[(r + n_spatial, s + n_spatial)] - scale = complex(0.5 * eri) - for c1, w1 in e_pq_terms: - for c2, w2 in e_rs_terms: - accumulator.accumulate_product(w1, w2, scale * c1 * c2) - - if q == r: - for coeff, word in excitation_terms_dict[(p + n_spatial, s + n_spatial)]: - accumulator.accumulate(word, complex(-0.5 * eri) * coeff) - - # aabb channel (mixed alpha-beta) - Logger.debug("Processing aabb channel...") - for p in range(n_spatial): - for q in range(n_spatial): - for r in range(n_spatial): - for s in range(n_spatial): - eri = float(eri_aabb[p, q, r, s]) - if abs(eri) > integral_threshold: - e_pq_terms = excitation_terms_dict[(p, q)] - e_rs_terms = excitation_terms_dict[(r + n_spatial, s + n_spatial)] - scale = complex(0.5 * eri) - for c1, w1 in e_pq_terms: - for c2, w2 in e_rs_terms: - accumulator.accumulate_product(w1, w2, scale * c1 * c2) - - # bbaa channel (mixed beta-alpha) - Logger.debug("Processing bbaa channel...") - for p in range(n_spatial): - for q in range(n_spatial): - for r in range(n_spatial): - for s in range(n_spatial): - eri = float(eri_aabb[p, q, r, s]) - if abs(eri) > integral_threshold: - e_pq_terms = excitation_terms_dict[(p + n_spatial, q + n_spatial)] - e_rs_terms = excitation_terms_dict[(r, s)] - scale = complex(0.5 * eri) - for c1, w1 in e_pq_terms: - for c2, w2 in e_rs_terms: - accumulator.accumulate_product(w1, w2, scale * c1 * c2) - - Logger.debug("Finalizing Pauli terms...") - - # Get terms from C++ accumulator as canonical strings (only place we use strings) - canonical_terms = accumulator.get_terms_as_strings(n_spin_orbitals, threshold) - - pauli_strings = [] - coefficients = [] - - for coeff, pauli_str in canonical_terms: - # Convert to Qiskit-style little-endian ordering - pauli_strings.append(pauli_str[::-1]) - coefficients.append(coeff) - - Logger.debug(f"Generated {len(pauli_strings)} Pauli terms for {n_spin_orbitals} qubits") + Logger.debug(f"Generated {len(pauli_strings)} Pauli terms for {2 * n_spatial} qubits") return QubitHamiltonian( - pauli_strings=pauli_strings, + pauli_strings=list(pauli_strings), coefficients=np.array(coefficients, dtype=complex), - encoding=encoding, + encoding=mapping.name, fermion_mode_order=FermionModeOrder.BLOCKED, ) diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index b9e7a3ff4..12260c54c 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: # Only needed for type annotations; avoid importing into module namespace from qdk_chemistry.data import Hamiltonian, QubitHamiltonian, Symmetries + from qdk_chemistry.data.majorana_mapping import MajoranaMapping __all__: list[str] = [] @@ -22,28 +23,15 @@ class QubitMapperSettings(Settings): """Base settings for all QubitMapper implementations. - Common settings: - encoding (string, default="jordan-wigner"): Fermion-to-qubit encoding strategy. + Settings are variant-specific (thresholds, etc.). The encoding is + determined by the :class:`~qdk_chemistry.data.MajoranaMapping` passed + to :meth:`~QubitMapper.run`. """ - def __init__(self, valid_encodings: list[str] | None = None) -> None: - """Initialize QubitMapperSettings. - - Args: - valid_encodings: Allowed encoding values. Default: ``["jordan-wigner"]``. - - """ + def __init__(self) -> None: + """Initialize QubitMapperSettings.""" super().__init__() - if valid_encodings is None: - valid_encodings = ["jordan-wigner"] - self._set_default( - "encoding", - "string", - "jordan-wigner", - "Fermion-to-qubit encoding strategy", - valid_encodings, - ) class QubitMapper(Algorithm): @@ -58,11 +46,17 @@ def type_name(self) -> str: return "qubit_mapper" @abstractmethod - def _run_impl(self, hamiltonian: Hamiltonian, symmetries: Symmetries | None = None) -> QubitHamiltonian: - """Construct a QubitHamiltonian from a Hamiltonian using the mapping specified. + def _run_impl( + self, + hamiltonian: Hamiltonian, + mapping: MajoranaMapping, + symmetries: Symmetries | None = None, + ) -> QubitHamiltonian: + """Construct a QubitHamiltonian from a Hamiltonian using the given mapping. Args: hamiltonian: The fermionic Hamiltonian. + mapping: The Majorana-to-Pauli encoding to use. symmetries: Optional symmetry information. Required by symmetry-exploiting algorithms. Returns: diff --git a/python/src/qdk_chemistry/data/__init__.py b/python/src/qdk_chemistry/data/__init__.py index 506c1ed2f..23e23b26e 100644 --- a/python/src/qdk_chemistry/data/__init__.py +++ b/python/src/qdk_chemistry/data/__init__.py @@ -25,6 +25,7 @@ - :class:`HamiltonianContainer`: Abstract base class for different Hamiltonian storage formats. - :class:`HamiltonianType`: Enumeration of Hamiltonian types (Hermitian, NonHermitian). - :class:`LatticeGraph`: Lattice graph defining the connectivity and geometry of a model Hamiltonian. +- :class:`MajoranaMapping`: Majorana-to-Pauli mapping data class for fermion-to-qubit encodings. - :class:`MeasurementData`: Measurement bitstring data and metadata for QubitHamiltonian objects. - :class:`SparseHamiltonianContainer`: Container for lattice model Hamiltonians with sparse internal storage. - :class:`ModelOrbitals`: Simple orbital representation for model systems without full basis set information. @@ -113,6 +114,7 @@ from qdk_chemistry.data.encoding_validation import EncodingMismatchError, validate_encoding_compatibility from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.data.estimator_data import EnergyExpectationResult, MeasurementData +from qdk_chemistry.data.majorana_mapping import MajoranaMapping from qdk_chemistry.data.noise_models import QuantumErrorProfile from qdk_chemistry.data.qpe_result import QpeResult from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian @@ -155,6 +157,7 @@ "LatticeGraph", "LayeredPartition", "MP2Container", + "MajoranaMapping", "MeasurementData", "ModelOrbitals", "OrbitalType", diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py new file mode 100644 index 000000000..e8b5e9fa9 --- /dev/null +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -0,0 +1,276 @@ +"""Majorana-to-Pauli mapping data class for fermion-to-qubit encodings. + +This module provides the :class:`MajoranaMapping` data class, which stores +the mapping of 2N Majorana operators to Pauli strings for a given f2q encoding. +Standard encodings (Jordan-Wigner, Bravyi-Kitaev, parity) are available via +class-method factories; custom encodings can be constructed from a Pauli table. + +""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from qdk_chemistry._core.data import MajoranaMapping as _CoreMajoranaMapping +from qdk_chemistry.data.base import DataClass + +if TYPE_CHECKING: + import h5py + +__all__: list[str] = [] + + +class MajoranaMapping(DataClass): + """Immutable data class mapping 2N Majorana operators to Pauli strings. + + A ``MajoranaMapping`` stores a table of 2N entries, one per Majorana operator + γ_k (k = 0, ..., 2N-1), where N is the number of fermionic modes (spin-orbitals). + Each entry is a single Pauli string representing φ(γ_k) under the chosen + fermion-to-qubit encoding. + + The mapping is validated at construction time by checking the Clifford algebra + anticommutation relations: {γ_i, γ_j} = 2δ_{ij} · I. This guarantees that the + mapping defines a valid encoding. + + Pauli strings use the same **little-endian** convention as + :class:`~qdk_chemistry.data.QubitHamiltonian`: qubit 0 is the rightmost character. + + Attributes: + table (tuple[str, ...]): Tuple of 2N dense Pauli strings in little-endian format. + num_modes (int): Number of fermionic modes (spin-orbitals), N. + num_qubits (int): Number of qubits required by this encoding. + name (str): Human-readable name of the encoding (may be empty for custom mappings). + + Examples: + Built-in encodings: + + >>> jw = MajoranaMapping.jordan_wigner(num_modes=4) + >>> jw.num_modes + 4 + >>> jw.num_qubits + 4 + + Custom encoding from Pauli string labels: + + >>> custom = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"], name="my-jw") + >>> custom.table + ('IX', 'IY', 'XZ', 'YZ') + + """ + + _data_type_name = "majorana_mapping" + _serialization_version = "0.1.0" + + def __init__( + self, + table: list[str] | tuple[str, ...], + name: str = "", + ) -> None: + """Initialize a MajoranaMapping from a list of dense Pauli-string labels. + + Args: + table (list[str] | tuple[str, ...]): 2N Pauli strings in little-endian format (qubit 0 = rightmost char). + name (str): Optional human-readable label for the encoding. Default ``""``. + + Raises: + ValueError: If table size is odd, empty, strings differ in length, contain non-IXYZ characters, or the Clifford algebra validation fails. + + """ + # Build C++ core object (validates Clifford algebra) + self._core = _CoreMajoranaMapping(list(table), name) + + # Cache immutable tuple from the core + self._table = self._core.table + self._name = self._core.name + self._num_modes = self._core.num_modes + self._num_qubits = self._core.num_qubits + + # Mark immutable + super().__init__() + + @property + def table(self) -> tuple[str, ...]: + """Tuple of 2N dense Pauli strings in little-endian format (qubit 0 = rightmost char).""" + return self._table + + @property + def num_modes(self) -> int: + """Number of fermionic modes (spin-orbitals).""" + return self._num_modes + + @property + def num_qubits(self) -> int: + """Number of qubits required by this encoding.""" + return self._num_qubits + + @property + def name(self) -> str: + """Human-readable name of the encoding (may be empty for custom mappings).""" + return self._name + + @property + def core(self) -> _CoreMajoranaMapping: + """Access the underlying C++ MajoranaMapping object.""" + return self._core + + @classmethod + def jordan_wigner(cls, num_modes: int) -> MajoranaMapping: + """Construct a Jordan-Wigner encoding. + + Args: + num_modes (int): Number of fermionic modes (spin-orbitals). + + Returns: + MajoranaMapping: Mapping with name ``"jordan_wigner"``. + + """ + core = _CoreMajoranaMapping.jordan_wigner(num_modes) + return cls._from_core(core) + + @classmethod + def bravyi_kitaev(cls, num_modes: int) -> MajoranaMapping: + """Construct a Bravyi-Kitaev encoding. + + Args: + num_modes (int): Number of fermionic modes (spin-orbitals). + + Returns: + MajoranaMapping: Mapping with name ``"bravyi_kitaev"``. + + """ + core = _CoreMajoranaMapping.bravyi_kitaev(num_modes) + return cls._from_core(core) + + @classmethod + def parity(cls, num_modes: int) -> MajoranaMapping: + """Construct a parity encoding. + + Args: + num_modes (int): Number of fermionic modes (spin-orbitals). + + Returns: + MajoranaMapping: Mapping with name ``"parity"``. + + """ + core = _CoreMajoranaMapping.parity(num_modes) + return cls._from_core(core) + + @classmethod + def from_mode_pairs( + cls, + pairs: list[tuple[str, str]], + name: str = "", + ) -> MajoranaMapping: + """Construct from (γ_even, γ_odd) mode pairs. + + Args: + pairs (list[tuple[str, str]]): List of (γ_{2k}, γ_{2k+1}) Pauli string pairs, one per mode. + name (str): Optional human-readable label. Default ``""``. + + Returns: + MajoranaMapping: The constructed mapping. + + Raises: + ValueError: If pairs are invalid or Clifford algebra validation fails. + + """ + table: list[str] = [] + for even, odd in pairs: + table.append(even) + table.append(odd) + return cls(table=table, name=name) + + @classmethod + def _from_core(cls, core: _CoreMajoranaMapping) -> MajoranaMapping: + """Construct from an already-validated C++ core object.""" + # Re-use the table from the core (already validated, so construction is fast) + return cls(table=list(core.table), name=core.name) + + def get_summary(self) -> str: + """Get a human-readable summary of the mapping. + + Returns: + str: Summary string. + + """ + lines = [] + label = f"MajoranaMapping '{self._name}'" if self._name else "MajoranaMapping (unnamed)" + lines.append(label) + lines.append(f" Modes: {self._num_modes}, Qubits: {self._num_qubits}") + for k, pauli_str in enumerate(self._table): + lines.append(f" γ_{k} → {pauli_str}") + return "\n".join(lines) + + def to_json(self) -> dict[str, Any]: + """Serialize to a JSON-compatible dictionary. + + Returns: + dict[str, Any]: Dictionary representation. + + """ + data: dict[str, Any] = { + "table": list(self._table), + "name": self._name, + } + return self._add_json_version(data) + + @classmethod + def from_json(cls, json_data: dict[str, Any]) -> MajoranaMapping: + """Deserialize from a JSON dictionary. + + Args: + json_data (dict[str, Any]): Dictionary produced by :meth:`to_json`. + + Returns: + MajoranaMapping: The deserialized mapping. + + """ + cls._validate_json_version("0.1.0", json_data) + return cls( + table=json_data["table"], + name=json_data.get("name", ""), + ) + + def to_hdf5(self, group: h5py.Group) -> None: + """Write to an HDF5 group. + + Args: + group (h5py.Group): HDF5 group to write to. + + """ + self._add_hdf5_version(group) + group.attrs["name"] = self._name + group.attrs["num_modes"] = self._num_modes + # Store table as array of strings + import numpy as np + + group.create_dataset("table", data=np.array(list(self._table), dtype="S")) + + @classmethod + def from_hdf5(cls, group: h5py.Group) -> MajoranaMapping: + """Load from an HDF5 group. + + Args: + group (h5py.Group): HDF5 group to read from. + + Returns: + MajoranaMapping: The deserialized mapping. + + """ + cls._validate_hdf5_version("0.1.0", group) + table = [s.decode("utf-8") if isinstance(s, bytes) else s for s in group["table"][()]] + name = group.attrs.get("name", "") + if isinstance(name, bytes): + name = name.decode("utf-8") + return cls(table=table, name=name) + + def __repr__(self) -> str: + """Return a repr string.""" + label = f"'{self._name}', " if self._name else "" + return f"MajoranaMapping({label}num_modes={self._num_modes}, num_qubits={self._num_qubits})" diff --git a/python/src/qdk_chemistry/plugins/__init__.py b/python/src/qdk_chemistry/plugins/__init__.py index 824c3198f..802ca354b 100644 --- a/python/src/qdk_chemistry/plugins/__init__.py +++ b/python/src/qdk_chemistry/plugins/__init__.py @@ -57,8 +57,10 @@ >>> # Import the OpenFermion plugin to register its implementations >>> import qdk_chemistry.plugins.openfermion # noqa: F401 >>> # Create an OpenFermion qubit mapper through the registry - >>> mapper = create("qubit_mapper", "openfermion", encoding="jordan-wigner") - >>> qubit_hamiltonian = mapper.run(hamiltonian) + >>> from qdk_chemistry.data import MajoranaMapping + >>> mapper = create("qubit_mapper", "openfermion") + >>> mapping = MajoranaMapping.jordan_wigner(num_modes=n_spin_orbitals) + >>> qubit_hamiltonian = mapper.run(hamiltonian, mapping) Notes: Plugin modules may have additional dependencies beyond core QDK/Chemistry. diff --git a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py index 898aec89b..fc852bbe9 100644 --- a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py @@ -1,8 +1,8 @@ """OpenFermion-based qubit mappers to map electronic structure Hamiltonians to qubit Hamiltonians. This module provides an OpenFermionQubitMapper class to convert Hamiltonians to QubitHamiltonians -using different mapping strategies ("jordan-wigner", "bravyi-kitaev", -"symmetry-conserving-bravyi-kitaev", and "bravyi-kitaev-tree"). +using different mapping strategies. The encoding is determined by the MajoranaMapping passed +to :meth:`run`. """ # -------------------------------------------------------------------------------------------- @@ -20,7 +20,6 @@ from qdk_chemistry.algorithms.qubit_mapper import QubitMapper, QubitMapperSettings from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.plugins.openfermion.conversion import ( - hamiltonian_to_fermion_operator, hamiltonian_to_interaction_operator, qubit_operator_to_qubit_hamiltonian, ) @@ -28,85 +27,81 @@ if TYPE_CHECKING: from qdk_chemistry.data import Hamiltonian, QubitHamiltonian, Symmetries + from qdk_chemistry.data.majorana_mapping import MajoranaMapping __all__ = ["OpenFermionQubitMapper", "OpenFermionQubitMapperSettings"] -_VALID_ENCODINGS = [ - "jordan-wigner", - "bravyi-kitaev", - "symmetry-conserving-bravyi-kitaev", - "bravyi-kitaev-tree", -] +_STANDARD_TRANSFORMS: dict[str, object] = { + "jordan-wigner": of.transforms.jordan_wigner, + "bravyi-kitaev": of.transforms.bravyi_kitaev, + "bravyi-kitaev-tree": of.transforms.bravyi_kitaev_tree, +} class OpenFermionQubitMapperSettings(QubitMapperSettings): - """Settings configuration for an OpenFermionQubitMapper. - - Inherits ``encoding`` from :class:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapperSettings`. - - Available encodings: - - ``"jordan-wigner"`` (default) - - ``"bravyi-kitaev"`` - - ``"symmetry-conserving-bravyi-kitaev"`` (requires :class:`~qdk_chemistry.data.Symmetries`) - - ``"bravyi-kitaev-tree"`` - - """ + """Settings configuration for an OpenFermionQubitMapper.""" def __init__(self): """Initialize OpenFermionQubitMapperSettings.""" Logger.trace_entering() - super().__init__(valid_encodings=_VALID_ENCODINGS) + super().__init__() class OpenFermionQubitMapper(QubitMapper): """Map an electronic structure Hamiltonian to a QubitHamiltonian using OpenFermion. - Available encodings: - - ``"jordan-wigner"`` (default) - - ``"bravyi-kitaev"`` - - ``"symmetry-conserving-bravyi-kitaev"`` (requires :class:`~qdk_chemistry.data.Symmetries`) - - ``"bravyi-kitaev-tree"`` + The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` + passed to :meth:`run`. The plugin uses ``mapping.name`` to select the + corresponding OpenFermion transform. Custom (unnamed) mappings are not + supported — use the QDK variant instead. - """ - - def __init__(self, encoding: str = "jordan-wigner"): - """Initialize OpenFermionQubitMapper with a specific mapping strategy. + Supported ``mapping.name`` values: + - ``"jordan_wigner"`` + - ``"bravyi_kitaev"`` + - ``"bravyi_kitaev_tree"`` - Args: - encoding: Qubit mapping strategy. See *Available encodings* above. + """ - """ + def __init__(self): + """Initialize OpenFermionQubitMapper.""" Logger.trace_entering() super().__init__() self._settings = OpenFermionQubitMapperSettings() - self._settings.set("encoding", encoding) - def _run_impl(self, hamiltonian: Hamiltonian, symmetries: Symmetries | None = None) -> QubitHamiltonian: + def _run_impl( + self, + hamiltonian: Hamiltonian, + mapping: MajoranaMapping, + symmetries: Symmetries | None = None, # noqa: ARG002 + ) -> QubitHamiltonian: """Construct a QubitHamiltonian from a Hamiltonian using the selected mapping strategy. Args: hamiltonian: The fermionic Hamiltonian. + mapping: The Majorana-to-Pauli encoding. Only built-in encodings are supported. symmetries: Symmetry information. Required for SCBK encoding. Returns: QubitHamiltonian: An instance of the QubitHamiltonian. + Raises: + NotImplementedError: If ``mapping.name`` is not a supported OpenFermion encoding. + """ Logger.trace_entering() - encoding = self._settings.get("encoding") - if encoding not in _VALID_ENCODINGS: - raise ValueError( - f"Encoding '{encoding}' is unknown for OpenFermionQubitMapper.\nPlease use one of: {_VALID_ENCODINGS}" + encoding_name = mapping.name + + if encoding_name not in _STANDARD_TRANSFORMS: + raise NotImplementedError( + f"OpenFermion plugin does not support MajoranaMapping with name {encoding_name!r}. " + f"Supported names: {sorted(_STANDARD_TRANSFORMS.keys())}. " + f"Use the QDK variant for custom mappings." ) - Logger.debug(f"Mapping Hamiltonian with OpenFermion encoding: {encoding}") + Logger.debug(f"Mapping Hamiltonian with OpenFermion encoding: {encoding_name}") - if encoding == "symmetry-conserving-bravyi-kitaev": - qubit_op = self._map_scbk(hamiltonian, symmetries) - fermion_mode_order = FermionModeOrder.INTERLEAVED - else: - qubit_op = self._map_standard(hamiltonian, encoding) - fermion_mode_order = FermionModeOrder.BLOCKED + qubit_op = self._map_standard(hamiltonian, encoding_name) + fermion_mode_order = FermionModeOrder.BLOCKED qubit_op.compress() @@ -119,7 +114,7 @@ def _run_impl(self, hamiltonian: Hamiltonian, symmetries: Symmetries | None = No return qubit_operator_to_qubit_hamiltonian( qubit_op, - encoding=encoding, + encoding=encoding_name, fermion_mode_order=fermion_mode_order, ) @@ -131,66 +126,17 @@ def _map_standard(self, hamiltonian: Hamiltonian, encoding: str) -> of.QubitOper Args: hamiltonian: The fermionic Hamiltonian. - encoding: One of ``"jordan-wigner"``, ``"bravyi-kitaev"``, - or ``"bravyi-kitaev-tree"``. + encoding: One of ``"jordan_wigner"``, ``"bravyi_kitaev"``, + or ``"bravyi_kitaev_tree"``. Returns: openfermion.QubitOperator: The mapped qubit operator. """ fermion_op = _build_blocked_fermion_operator(hamiltonian) - - transform_map = { - "jordan-wigner": of.transforms.jordan_wigner, - "bravyi-kitaev": of.transforms.bravyi_kitaev, - "bravyi-kitaev-tree": of.transforms.bravyi_kitaev_tree, - } - transform = transform_map[encoding] + transform = _STANDARD_TRANSFORMS[encoding] return transform(fermion_op) - def _map_scbk(self, hamiltonian: Hamiltonian, symmetries: Symmetries | None) -> of.QubitOperator: - """Apply symmetry-conserving Bravyi-Kitaev transformation. - - This transform reduces the qubit count by 2 by exploiting particle number - and spin symmetry. The number of active electrons is read from the - ``symmetries`` parameter. - - Args: - hamiltonian: The fermionic Hamiltonian. - symmetries: Symmetry information providing the active electron count. - - Returns: - openfermion.QubitOperator: The mapped qubit operator. - - Raises: - ValueError: If ``symmetries`` is not provided. - - """ - if symmetries is None: - raise ValueError( - "The symmetry-conserving Bravyi-Kitaev encoding requires a Symmetries " - "object specifying the number of active electrons.\n" - "Example:\n" - " from qdk_chemistry.data import Symmetries\n" - " symmetries = Symmetries(n_alpha=1, n_beta=1)\n" - " qubit_hamiltonian = mapper.run(hamiltonian, symmetries)" - ) - - fermion_op = hamiltonian_to_fermion_operator(hamiltonian) - n_active_electrons = symmetries.n_particles - - # Number of spin-orbitals - h1_alpha, _ = hamiltonian.get_one_body_integrals() - n_spinorbitals = 2 * h1_alpha.shape[0] - - Logger.debug(f"SCBK: n_spinorbitals={n_spinorbitals}, n_active_electrons={n_active_electrons}") - - return of.transforms.symmetry_conserving_bravyi_kitaev( - fermion_op, - n_spinorbitals, - n_active_electrons, - ) - def name(self) -> str: """Return the algorithm name ``openfermion``.""" Logger.trace_entering() diff --git a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py index 3ef97d95f..82283b9b6 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py @@ -26,74 +26,77 @@ if TYPE_CHECKING: from qdk_chemistry.data import Symmetries + from qdk_chemistry.data.majorana_mapping import MajoranaMapping __all__ = ["QiskitQubitMapper", "QiskitQubitMapperSettings"] +_SUPPORTED_ENCODINGS: dict[str, type] = { + "jordan-wigner": JordanWignerMapper, + "bravyi-kitaev": BravyiKitaevMapper, + "parity": ParityMapper, +} -class QiskitQubitMapperSettings(QubitMapperSettings): - """Settings configuration for a QiskitQubitMapper. - - Inherits ``encoding`` from :class:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapperSettings`. - - Available encodings: - - ``"jordan-wigner"`` (default) - - ``"bravyi-kitaev"`` - - ``"parity"`` - """ +class QiskitQubitMapperSettings(QubitMapperSettings): + """Settings configuration for a QiskitQubitMapper.""" def __init__(self): """Initialize QiskitQubitMapperSettings.""" Logger.trace_entering() - super().__init__(valid_encodings=["jordan-wigner", "bravyi-kitaev", "parity"]) + super().__init__() class QiskitQubitMapper(QubitMapper): """Map an electronic structure Hamiltonian to a QubitHamiltonian using Qiskit. - Available encodings: - - ``"jordan-wigner"`` (default) - - ``"bravyi-kitaev"`` + The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` + passed to :meth:`run`. The plugin uses ``mapping.name`` to select the + corresponding Qiskit Nature mapper. Custom (unnamed) mappings are not + supported — use the QDK variant instead. + + Supported ``mapping.name`` values: + - ``"jordan_wigner"`` + - ``"bravyi_kitaev"`` - ``"parity"`` """ - QubitMappers: ClassVar = { - "bravyi-kitaev": BravyiKitaevMapper, - "jordan-wigner": JordanWignerMapper, - "parity": ParityMapper, - } + QubitMappers: ClassVar = _SUPPORTED_ENCODINGS - def __init__(self, encoding: str = "jordan-wigner"): - """Initialize QiskitQubitMapper with a specific mapping strategy. - - Args: - encoding (str): Qubit mapping strategy to use ("jordan-wigner", "bravyi-kitaev", or "parity"). - Default: "jordan-wigner". - - """ + def __init__(self): + """Initialize QiskitQubitMapper.""" Logger.trace_entering() super().__init__() self._settings = QiskitQubitMapperSettings() - self._settings.set("encoding", encoding) - def _run_impl(self, hamiltonian: Hamiltonian, symmetries: Symmetries | None = None) -> QubitHamiltonian: # noqa: ARG002 + def _run_impl( + self, + hamiltonian: Hamiltonian, + mapping: MajoranaMapping, + symmetries: Symmetries | None = None, # noqa: ARG002 + ) -> QubitHamiltonian: """Construct a QubitHamiltonian from a Hamiltonian using the selected mapping strategy. Args: hamiltonian: The fermionic Hamiltonian. + mapping: The Majorana-to-Pauli encoding. Only built-in encodings are supported. symmetries: Optional symmetry information. Not used by this implementation. Returns: QubitHamiltonian: An instance of the QubitHamiltonian. + Raises: + NotImplementedError: If ``mapping.name`` is not a supported Qiskit encoding. + """ Logger.trace_entering() - encoding = self._settings.get("encoding") - if encoding not in self.QubitMappers: - raise ValueError( - f"Encoding {encoding} is unknown for QiskitQubitMapper.\n" - f"Please use one of the following options: {self.QubitMappers.keys()}" + encoding_name = mapping.name + + if encoding_name not in _SUPPORTED_ENCODINGS: + raise NotImplementedError( + f"Qiskit plugin does not support MajoranaMapping with name {encoding_name!r}. " + f"Supported names: {sorted(_SUPPORTED_ENCODINGS.keys())}. " + f"Use the QDK variant for custom mappings." ) (h1_a, _) = hamiltonian.get_one_body_integrals() @@ -103,12 +106,12 @@ def _run_impl(self, hamiltonian: Hamiltonian, symmetries: Symmetries | None = No h1_a=h1_a, h2_aa=h2_aa.reshape(num_orbs, num_orbs, num_orbs, num_orbs) ) fermionic_op = electronic_hamiltonian.second_q_op() - qubit_mapper = self.QubitMappers[encoding]() + qubit_mapper = _SUPPORTED_ENCODINGS[encoding_name]() qubit_op = qubit_mapper.map(fermionic_op) return QubitHamiltonian( pauli_strings=qubit_op.paulis.to_labels(), coefficients=qubit_op.coeffs, - encoding=encoding, + encoding=encoding_name, fermion_mode_order=FermionModeOrder.BLOCKED, ) diff --git a/python/tests/conftest.py b/python/tests/conftest.py index 910845905..bdcad83f6 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -24,6 +24,7 @@ CasWavefunctionContainer, Configuration, Hamiltonian, + MajoranaMapping, Orbitals, QuantumErrorProfile, Wavefunction, @@ -110,17 +111,21 @@ def test_data_files_path(): @pytest.fixture def hamiltonian_4e4o(test_data_files_path): """Fixture to create the Qubit Hamiltonian for 4e4o ethylene 2det problem.""" - mapper = create("qubit_mapper", "qdk", encoding="jordan-wigner") + mapper = create("qubit_mapper", "qdk") classical_hamiltonian = Hamiltonian.from_json_file(test_data_files_path / "ethylene_4e4o_2det.hamiltonian.json") - return mapper.run(classical_hamiltonian) + n_spatial = classical_hamiltonian.get_one_body_integrals()[0].shape[0] + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * n_spatial) + return mapper.run(classical_hamiltonian, mapping) @pytest.fixture def hamiltonian_10e6o(test_data_files_path): """Fixture to create the Qubit Hamiltonian for 10e6o f2 problem.""" - mapper = create("qubit_mapper", "qdk", encoding="jordan-wigner") + mapper = create("qubit_mapper", "qdk") classical_hamiltonian = Hamiltonian.from_json_file(test_data_files_path / "f2_10e6o.hamiltonian.json") - return mapper.run(classical_hamiltonian) + n_spatial = classical_hamiltonian.get_one_body_integrals()[0].shape[0] + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * n_spatial) + return mapper.run(classical_hamiltonian, mapping) @pytest.fixture diff --git a/python/tests/test_encoding_metadata.py b/python/tests/test_encoding_metadata.py index 486e7082c..a72cdd72e 100644 --- a/python/tests/test_encoding_metadata.py +++ b/python/tests/test_encoding_metadata.py @@ -10,7 +10,13 @@ import pytest from qdk_chemistry.algorithms import create, registry -from qdk_chemistry.data import Circuit, EncodingMismatchError, QubitHamiltonian, validate_encoding_compatibility +from qdk_chemistry.data import ( + Circuit, + EncodingMismatchError, + MajoranaMapping, + QubitHamiltonian, + validate_encoding_compatibility, +) from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT, QDK_CHEMISTRY_HAS_QISKIT_NATURE @@ -199,35 +205,37 @@ def test_qiskit_state_preparation_injects_jordan_wigner_encoding(wavefunction_4e def test_qiskit_qubit_mapper_injects_encoding(): """Test that QubitMapper injects the correct encoding.""" hamiltonian = create_test_hamiltonian(2) + n_modes = 2 * 2 # 2 spatial orbitals → 4 spin-orbitals # Test Jordan-Wigner - mapper_jw = create("qubit_mapper", "qiskit", encoding="jordan-wigner") - qubit_ham_jw = mapper_jw.run(hamiltonian) + mapper_jw = create("qubit_mapper", "qiskit") + qubit_ham_jw = mapper_jw.run(hamiltonian, MajoranaMapping.jordan_wigner(n_modes)) assert qubit_ham_jw.encoding == "jordan-wigner" # Test Bravyi-Kitaev - mapper_bk = create("qubit_mapper", "qiskit", encoding="bravyi-kitaev") - qubit_ham_bk = mapper_bk.run(hamiltonian) + mapper_bk = create("qubit_mapper", "qiskit") + qubit_ham_bk = mapper_bk.run(hamiltonian, MajoranaMapping.bravyi_kitaev(n_modes)) assert qubit_ham_bk.encoding == "bravyi-kitaev" # Test Parity - mapper_parity = create("qubit_mapper", "qiskit", encoding="parity") - qubit_ham_parity = mapper_parity.run(hamiltonian) + mapper_parity = create("qubit_mapper", "qiskit") + qubit_ham_parity = mapper_parity.run(hamiltonian, MajoranaMapping.parity(n_modes)) assert qubit_ham_parity.encoding == "parity" def test_qdk_qubit_mapper_injects_encoding(): """Test that QDK QubitMapper injects the correct encoding.""" hamiltonian = create_test_hamiltonian(2) + n_modes = 2 * 2 # Test Jordan-Wigner - mapper_jw = create("qubit_mapper", "qdk", encoding="jordan-wigner") - qubit_ham_jw = mapper_jw.run(hamiltonian) + mapper_jw = create("qubit_mapper", "qdk") + qubit_ham_jw = mapper_jw.run(hamiltonian, MajoranaMapping.jordan_wigner(n_modes)) assert qubit_ham_jw.encoding == "jordan-wigner" # Test Bravyi-Kitaev - mapper_bk = create("qubit_mapper", "qdk", encoding="bravyi-kitaev") - qubit_ham_bk = mapper_bk.run(hamiltonian) + mapper_bk = create("qubit_mapper", "qdk") + qubit_ham_bk = mapper_bk.run(hamiltonian, MajoranaMapping.bravyi_kitaev(n_modes)) assert qubit_ham_bk.encoding == "bravyi-kitaev" @@ -246,8 +254,9 @@ def test_end_to_end_workflow_compatible_encodings(wavefunction_4e4o): hamiltonian = create_test_hamiltonian(2) # Create QubitHamiltonian with Jordan-Wigner encoding - mapper = create("qubit_mapper", "qdk", encoding="jordan-wigner") - qubit_ham = mapper.run(hamiltonian) + mapper = create("qubit_mapper", "qdk") + mapping = MajoranaMapping.jordan_wigner(num_modes=4) + qubit_ham = mapper.run(hamiltonian, mapping) # Create Circuit with state preparation (should be Jordan-Wigner) prep = create("state_prep", "sparse_isometry_gf2x") @@ -262,8 +271,9 @@ def test_end_to_end_workflow_incompatible_encodings(wavefunction_4e4o): hamiltonian = create_test_hamiltonian(2) # Create QubitHamiltonian with Bravyi-Kitaev encoding - mapper = create("qubit_mapper", "qdk", encoding="bravyi-kitaev") - qubit_ham = mapper.run(hamiltonian) + mapper = create("qubit_mapper", "qdk") + mapping = MajoranaMapping.bravyi_kitaev(num_modes=4) + qubit_ham = mapper.run(hamiltonian, mapping) # Create Circuit with state preparation (should be Jordan-Wigner) prep = create("state_prep", "sparse_isometry_gf2x") @@ -302,9 +312,11 @@ def test_qubit_hamiltonian_summary_includes_encoding(): def test_qdk_qubit_mapper_sets_fermion_mode_order(): """QDK native qubit mapper sets fermion_mode_order to BLOCKED.""" hamiltonian = create_test_hamiltonian(2) + n_modes = 4 # 2 spatial → 4 spin-orbitals - for encoding in ("jordan-wigner", "bravyi-kitaev"): - qh = create("qubit_mapper", "qdk", encoding=encoding).run(hamiltonian) + for factory in (MajoranaMapping.jordan_wigner, MajoranaMapping.bravyi_kitaev): + mapping = factory(n_modes) + qh = create("qubit_mapper", "qdk").run(hamiltonian, mapping) assert qh.fermion_mode_order == FermionModeOrder.BLOCKED @@ -312,7 +324,9 @@ def test_qdk_qubit_mapper_sets_fermion_mode_order(): def test_qiskit_qubit_mapper_sets_fermion_mode_order(): """Qiskit qubit mapper sets fermion_mode_order to BLOCKED.""" hamiltonian = create_test_hamiltonian(2) + n_modes = 4 - for encoding in ("jordan-wigner", "bravyi-kitaev", "parity"): - qh = create("qubit_mapper", "qiskit", encoding=encoding).run(hamiltonian) + for factory in (MajoranaMapping.jordan_wigner, MajoranaMapping.bravyi_kitaev, MajoranaMapping.parity): + mapping = factory(n_modes) + qh = create("qubit_mapper", "qiskit").run(hamiltonian, mapping) assert qh.fermion_mode_order == FermionModeOrder.BLOCKED diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py new file mode 100644 index 000000000..bbb7f4f68 --- /dev/null +++ b/python/tests/test_majorana_mapping.py @@ -0,0 +1,365 @@ +"""Tests for MajoranaMapping data class. + +Tests cover: +- Factory construction (JW, BK, parity) for various sizes +- Clifford algebra anticommutation validation +- Custom mapping construction +- from_mode_pairs construction +- Immutability enforcement +- Serialization round-trips (JSON, HDF5, file) +- Invalid input rejection +- Reference output comparison for small systems +""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import tempfile +from pathlib import Path + +import h5py +import numpy as np +import pytest + +from qdk_chemistry._core.data import PauliTermAccumulator +from qdk_chemistry.data import MajoranaMapping + + +# ─── Helpers ───────────────────────────────────────────────────────────── + + +def verify_clifford_algebra(mapping: MajoranaMapping) -> None: + """Verify {γ_i, γ_j} = 2δ_{ij}·I for all pairs.""" + n = 2 * mapping.num_modes + for i in range(n): + wi = mapping.core(i) + for j in range(i, n): + wj = mapping.core(j) + phase_ij, word_ij = PauliTermAccumulator.multiply_uncached(wi, wj) + phase_ji, word_ji = PauliTermAccumulator.multiply_uncached(wj, wi) + + if i == j: + assert word_ij == [], f"γ_{i}² is not identity: word={word_ij}" + assert abs(phase_ij - 1.0) < 1e-12, f"γ_{i}² phase={phase_ij}, expected 1" + else: + assert word_ij == word_ji, f"γ_{i}·γ_{j} and γ_{j}·γ_{i} produce different words" + assert abs(phase_ij + phase_ji) < 1e-12, ( + f"{{γ_{i}, γ_{j}}} != 0: phases {phase_ij} + {phase_ji} = {phase_ij + phase_ji}" + ) + + +# ─── Factory Tests ─────────────────────────────────────────────────────── + + +class TestJordanWigner: + """Tests for the Jordan-Wigner factory.""" + + @pytest.mark.parametrize("n_modes", [1, 2, 4, 6, 8]) + def test_clifford_algebra(self, n_modes: int) -> None: + """JW tables satisfy Clifford anticommutation for various sizes.""" + jw = MajoranaMapping.jordan_wigner(num_modes=n_modes) + verify_clifford_algebra(jw) + + def test_properties(self) -> None: + """JW factory sets correct properties.""" + jw = MajoranaMapping.jordan_wigner(num_modes=4) + assert jw.num_modes == 4 + assert jw.num_qubits == 4 + assert jw.name == "jordan-wigner" + assert len(jw.table) == 8 + + def test_reference_n2(self) -> None: + """JW n=2 matches hand-computed reference (little-endian).""" + jw = MajoranaMapping.jordan_wigner(num_modes=2) + # γ_0 = X_0 → "IX" (qubit 0 rightmost = X, qubit 1 = I) + # γ_1 = Y_0 → "IY" + # γ_2 = Z_0 X_1 → "XZ" (qubit 0 = Z, qubit 1 = X) + # γ_3 = Z_0 Y_1 → "YZ" + assert jw.table == ("IX", "IY", "XZ", "YZ") + + def test_reference_n4(self) -> None: + """JW n=4 spot check: γ_6 = Z_0 Z_1 Z_2 X_3.""" + jw = MajoranaMapping.jordan_wigner(num_modes=4) + assert jw.table[6] == "XZZZ" # X_3 Z_2 Z_1 Z_0 in little-endian + assert jw.table[7] == "YZZZ" # Y_3 Z_2 Z_1 Z_0 + + +class TestBravyiKitaev: + """Tests for the Bravyi-Kitaev factory.""" + + @pytest.mark.parametrize("n_modes", [1, 2, 4, 6, 8]) + def test_clifford_algebra(self, n_modes: int) -> None: + """BK tables satisfy Clifford anticommutation for various sizes.""" + bk = MajoranaMapping.bravyi_kitaev(num_modes=n_modes) + verify_clifford_algebra(bk) + + def test_properties(self) -> None: + """BK factory sets correct properties.""" + bk = MajoranaMapping.bravyi_kitaev(num_modes=4) + assert bk.num_modes == 4 + assert bk.num_qubits == 4 + assert bk.name == "bravyi-kitaev" + + @pytest.mark.parametrize("n_modes", [3, 5, 7]) + def test_non_power_of_two(self, n_modes: int) -> None: + """BK works for non-power-of-2 mode counts.""" + bk = MajoranaMapping.bravyi_kitaev(num_modes=n_modes) + assert bk.num_modes == n_modes + verify_clifford_algebra(bk) + + +class TestParity: + """Tests for the parity encoding factory.""" + + @pytest.mark.parametrize("n_modes", [1, 2, 4, 6, 8]) + def test_clifford_algebra(self, n_modes: int) -> None: + """Parity tables satisfy Clifford anticommutation for various sizes.""" + par = MajoranaMapping.parity(num_modes=n_modes) + verify_clifford_algebra(par) + + def test_properties(self) -> None: + """Parity factory sets correct properties.""" + par = MajoranaMapping.parity(num_modes=4) + assert par.num_modes == 4 + assert par.num_qubits == 4 + assert par.name == "parity" + + def test_reference_n2(self) -> None: + """Parity n=2 matches CNOT-derived reference.""" + par = MajoranaMapping.parity(num_modes=2) + # Derived via CNOT(0,1) conjugation of JW: + # γ_0 = X_0 X_1 → "XX" + # γ_1 = Y_0 X_1 → "XY" (little-endian: qubit 0=Y, qubit 1=X) + # γ_2 = Z_0 X_1 → "XZ" + # γ_3 = Y_1 → "YI" + assert par.table == ("XX", "XY", "XZ", "YI") + + def test_reference_n4(self) -> None: + """Parity n=4 matches CNOT-derived reference.""" + par = MajoranaMapping.parity(num_modes=4) + expected = ("IIXX", "IIXY", "IXXZ", "IXYI", "XXZI", "XYIZ", "XZIZ", "YIZI") + assert par.table == expected + + +# ─── Custom Mapping Tests ──────────────────────────────────────────────── + + +class TestCustomMapping: + """Tests for custom MajoranaMapping construction.""" + + def test_custom_from_table(self) -> None: + """Custom mapping from table list.""" + custom = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"], name="my-jw") + assert custom.num_modes == 2 + assert custom.num_qubits == 2 + assert custom.name == "my-jw" + assert custom.table == ("IX", "IY", "XZ", "YZ") + + def test_custom_unnamed(self) -> None: + """Custom mapping without name.""" + custom = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"]) + assert custom.name == "" + + def test_from_mode_pairs(self) -> None: + """from_mode_pairs produces same result as direct table.""" + direct = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"], name="test") + pairs = MajoranaMapping.from_mode_pairs( + pairs=[("IX", "IY"), ("XZ", "YZ")], name="test" + ) + assert direct.table == pairs.table + + def test_from_mode_pairs_equivalence(self) -> None: + """from_mode_pairs matches JW factory for n=2.""" + jw = MajoranaMapping.jordan_wigner(num_modes=2) + pairs = MajoranaMapping.from_mode_pairs( + pairs=[("IX", "IY"), ("XZ", "YZ")], name="jordan-wigner" + ) + assert jw.table == pairs.table + + +# ─── Validation Tests ──────────────────────────────────────────────────── + + +class TestValidation: + """Tests for input validation.""" + + def test_empty_table(self) -> None: + """Empty table raises ValueError.""" + with pytest.raises(ValueError, match="must not be empty"): + MajoranaMapping(table=[]) + + def test_odd_length_table(self) -> None: + """Odd-length table raises ValueError.""" + with pytest.raises(ValueError, match="even number"): + MajoranaMapping(table=["IX", "IY", "XZ"]) + + def test_invalid_characters(self) -> None: + """Non-IXYZ characters raise ValueError.""" + with pytest.raises(ValueError, match="Invalid Pauli character"): + MajoranaMapping(table=["IX", "IA", "XZ", "YZ"]) + + def test_inconsistent_lengths(self) -> None: + """Strings of different lengths raise ValueError.""" + with pytest.raises(ValueError, match="same length"): + MajoranaMapping(table=["IX", "IYZ", "XZ", "YZ"]) + + def test_clifford_violation(self) -> None: + """Table violating Clifford algebra raises ValueError.""" + # γ_0 = γ_1 = IX → they commute (shouldn't) + with pytest.raises(ValueError, match="Clifford"): + MajoranaMapping(table=["IX", "IX", "XZ", "YZ"]) + + def test_zero_modes(self) -> None: + """Zero modes raises ValueError in factories.""" + with pytest.raises(ValueError): + MajoranaMapping.jordan_wigner(num_modes=0) + with pytest.raises(ValueError): + MajoranaMapping.bravyi_kitaev(num_modes=0) + with pytest.raises(ValueError): + MajoranaMapping.parity(num_modes=0) + + +# ─── Immutability Tests ───────────────────────────────────────────────── + + +class TestImmutability: + """Tests for DataClass immutability.""" + + def test_cannot_set_table(self) -> None: + """Setting table raises AttributeError.""" + jw = MajoranaMapping.jordan_wigner(num_modes=2) + with pytest.raises(AttributeError, match="Cannot modify"): + jw.table = ("foo",) # type: ignore[misc] + + def test_cannot_set_name(self) -> None: + """Setting name raises AttributeError.""" + jw = MajoranaMapping.jordan_wigner(num_modes=2) + with pytest.raises(AttributeError, match="Cannot modify"): + jw.name = "bar" # type: ignore[misc] + + def test_cannot_delete_attribute(self) -> None: + """Deleting attribute raises AttributeError.""" + jw = MajoranaMapping.jordan_wigner(num_modes=2) + with pytest.raises(AttributeError, match="Cannot delete"): + del jw.table # type: ignore[misc] + + +# ─── Serialization Tests ──────────────────────────────────────────────── + + +class TestSerialization: + """Tests for JSON and HDF5 serialization.""" + + def test_json_round_trip(self) -> None: + """JSON serialize and deserialize.""" + jw = MajoranaMapping.jordan_wigner(num_modes=4) + data = jw.to_json() + loaded = MajoranaMapping.from_json(data) + assert loaded.table == jw.table + assert loaded.name == jw.name + assert loaded.num_modes == jw.num_modes + + def test_json_has_version(self) -> None: + """JSON output includes version field.""" + jw = MajoranaMapping.jordan_wigner(num_modes=2) + data = jw.to_json() + assert "version" in data + assert data["version"] == "0.1.0" + + def test_hdf5_round_trip(self) -> None: + """HDF5 serialize and deserialize.""" + bk = MajoranaMapping.bravyi_kitaev(num_modes=4) + with tempfile.NamedTemporaryFile(suffix=".h5") as f: + with h5py.File(f.name, "w") as hf: + bk.to_hdf5(hf) + with h5py.File(f.name, "r") as hf: + loaded = MajoranaMapping.from_hdf5(hf) + assert loaded.table == bk.table + assert loaded.name == bk.name + + def test_json_file_round_trip(self) -> None: + """JSON file serialize and deserialize.""" + par = MajoranaMapping.parity(num_modes=4) + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "test.majorana_mapping.json" + par.to_json_file(str(path)) + loaded = MajoranaMapping.from_json_file(str(path)) + assert loaded.table == par.table + assert loaded.name == par.name + + def test_hdf5_file_round_trip(self) -> None: + """HDF5 file serialize and deserialize.""" + jw = MajoranaMapping.jordan_wigner(num_modes=4) + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "test.majorana_mapping.h5" + jw.to_hdf5_file(str(path)) + loaded = MajoranaMapping.from_hdf5_file(str(path)) + assert loaded.table == jw.table + + def test_custom_serialization(self) -> None: + """Custom mapping with name survives serialization.""" + custom = MajoranaMapping( + table=["IX", "IY", "XZ", "YZ"], name="my-custom" + ) + data = custom.to_json() + loaded = MajoranaMapping.from_json(data) + assert loaded.table == custom.table + assert loaded.name == "my-custom" + + +# ─── Summary/Repr Tests ───────────────────────────────────────────────── + + +class TestDisplay: + """Tests for summary and repr.""" + + def test_repr_with_name(self) -> None: + """repr includes name when present.""" + jw = MajoranaMapping.jordan_wigner(num_modes=2) + r = repr(jw) + assert "jordan-wigner" in r + assert "num_modes=2" in r + + def test_repr_without_name(self) -> None: + """repr works for unnamed custom mappings.""" + custom = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"]) + r = repr(custom) + assert "num_modes=2" in r + + def test_get_summary(self) -> None: + """get_summary includes mapping details.""" + jw = MajoranaMapping.jordan_wigner(num_modes=2) + s = jw.get_summary() + assert "jordan-wigner" in s + assert "Modes: 2" in s + assert "γ_0" in s + assert "IX" in s + + +# ─── Cross-encoding consistency ────────────────────────────────────────── + + +class TestCrossEncodingConsistency: + """Tests ensuring all encodings produce valid mappings with same structure.""" + + @pytest.mark.parametrize("n_modes", [2, 4, 6]) + def test_all_encodings_same_qubit_count(self, n_modes: int) -> None: + """JW, BK, and parity all use num_modes qubits.""" + jw = MajoranaMapping.jordan_wigner(num_modes=n_modes) + bk = MajoranaMapping.bravyi_kitaev(num_modes=n_modes) + par = MajoranaMapping.parity(num_modes=n_modes) + assert jw.num_qubits == n_modes + assert bk.num_qubits == n_modes + assert par.num_qubits == n_modes + + @pytest.mark.parametrize("n_modes", [2, 4, 6]) + def test_all_encodings_table_length(self, n_modes: int) -> None: + """All encodings have 2N table entries.""" + jw = MajoranaMapping.jordan_wigner(num_modes=n_modes) + bk = MajoranaMapping.bravyi_kitaev(num_modes=n_modes) + par = MajoranaMapping.parity(num_modes=n_modes) + assert len(jw.table) == 2 * n_modes + assert len(bk.table) == 2 * n_modes + assert len(par.table) == 2 * n_modes diff --git a/python/tests/test_qdk_qubit_mapper.py b/python/tests/test_qdk_qubit_mapper.py index 6fb977074..9060389b8 100644 --- a/python/tests/test_qdk_qubit_mapper.py +++ b/python/tests/test_qdk_qubit_mapper.py @@ -11,13 +11,7 @@ import pytest from qdk_chemistry.algorithms import QubitMapper, available, create -from qdk_chemistry.algorithms.qubit_mapper.qdk_qubit_mapper import ( - _bk_compute_ancestor_indices, - _bk_compute_children_indices, - _bk_compute_parity_indices, - _bk_compute_z_indices_for_y_component, -) -from qdk_chemistry.data import CanonicalFourCenterHamiltonianContainer, Hamiltonian, QubitHamiltonian +from qdk_chemistry.data import CanonicalFourCenterHamiltonianContainer, Hamiltonian, MajoranaMapping, QubitHamiltonian from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT_NATURE from .test_helpers import create_test_hamiltonian, create_test_orbitals @@ -40,91 +34,6 @@ def _make_hamiltonian( return Hamiltonian(CanonicalFourCenterHamiltonianContainer(one_body, two_body, orbitals, core_energy, fock)) -class TestBravyiKitaevSets: - """Tests for Bravyi-Kitaev set computation functions. - - The second argument n must be a power of 2. - """ - - def test_ancestor_indices_4_qubits(self) -> None: - """Test ancestor indices for 4 qubit system (n=4 is already power of 2).""" - # U(0) = {1, 3} - qubit 0's occupation affects qubits 1 and 3 - assert _bk_compute_ancestor_indices(0, 4) == frozenset({1, 3}) - # U(1) = {3} - assert _bk_compute_ancestor_indices(1, 4) == frozenset({3}) - # U(2) = {3} - assert _bk_compute_ancestor_indices(2, 4) == frozenset({3}) - # U(3) = {} - qubit 3 is the root, no ancestors - assert _bk_compute_ancestor_indices(3, 4) == frozenset() - - def test_ancestor_indices_8_qubits(self) -> None: - """Test ancestor indices for 8 qubit system.""" - assert _bk_compute_ancestor_indices(0, 8) == frozenset({1, 3, 7}) - assert _bk_compute_ancestor_indices(4, 8) == frozenset({5, 7}) - assert _bk_compute_ancestor_indices(7, 8) == frozenset() - - def test_parity_set_4_qubits(self) -> None: - """Test parity set for 4 qubit system. - - P(j) follows the recursive binary tree structure. - """ - assert _bk_compute_parity_indices(0, 4) == frozenset() - assert _bk_compute_parity_indices(1, 4) == frozenset({0}) - assert _bk_compute_parity_indices(2, 4) == frozenset({1}) - assert _bk_compute_parity_indices(3, 4) == frozenset({1, 2}) - - def test_parity_set_8_qubits(self) -> None: - """Test parity set for 8 qubit system.""" - assert _bk_compute_parity_indices(0, 8) == frozenset() - assert _bk_compute_parity_indices(4, 8) == frozenset({3}) - assert _bk_compute_parity_indices(5, 8) == frozenset({3, 4}) - assert _bk_compute_parity_indices(6, 8) == frozenset({3, 5}) - assert _bk_compute_parity_indices(7, 8) == frozenset({3, 5, 6}) - - def test_children_indices_4_qubits(self) -> None: - """Test children indices for 4 qubit system.""" - assert _bk_compute_children_indices(0, 4) == frozenset() - assert _bk_compute_children_indices(1, 4) == frozenset({0}) - assert _bk_compute_children_indices(2, 4) == frozenset() - assert _bk_compute_children_indices(3, 4) == frozenset({1, 2}) - - def test_z_indices_for_y_component_4_qubits(self) -> None: - """Test Z indices for Y component R(j) = P(j) - F(j) for 4 qubit system.""" - # R(j) = P(j) - F(j) (set difference) - assert _bk_compute_z_indices_for_y_component(0, 4) == frozenset() # {} - {} = {} - assert _bk_compute_z_indices_for_y_component(1, 4) == frozenset() # {0} - {0} = {} - assert _bk_compute_z_indices_for_y_component(2, 4) == frozenset({1}) # {1} - {} = {1} - assert _bk_compute_z_indices_for_y_component(3, 4) == frozenset() # {1,2} - {1,2} = {} - - def test_z_indices_for_y_component_8_qubits(self) -> None: - """Test Z indices for Y component for 8 qubit system.""" - assert _bk_compute_z_indices_for_y_component(4, 8) == frozenset({3}) # {3} - {} = {3} - assert _bk_compute_z_indices_for_y_component(5, 8) == frozenset({3}) # {3,4} - {4} = {3} - assert _bk_compute_z_indices_for_y_component(6, 8) == frozenset({3, 5}) # {3,5} - {} = {3,5} - assert _bk_compute_z_indices_for_y_component(7, 8) == frozenset() # {3,5,6} - {3,5,6} = {} - - def test_invalid_n_raises_value_error(self) -> None: - """Test that non-power-of-2 values for n raise ValueError.""" - # Test various non-power-of-2 values - invalid_values = [0, 3, 5, 6, 7, 9, 10, 12, 15] - for n in invalid_values: - with pytest.raises(ValueError, match="n must be a power of 2"): - _bk_compute_parity_indices(0, n) - with pytest.raises(ValueError, match="n must be a power of 2"): - _bk_compute_ancestor_indices(0, n) - with pytest.raises(ValueError, match="n must be a power of 2"): - _bk_compute_children_indices(0, n) - with pytest.raises(ValueError, match="n must be a power of 2"): - _bk_compute_z_indices_for_y_component(0, n) - - def test_n_equals_1_returns_empty_set(self) -> None: - """Test that n=1 (valid power of 2) returns empty frozenset.""" - assert _bk_compute_parity_indices(0, 1) == frozenset() - assert _bk_compute_ancestor_indices(0, 1) == frozenset() - assert _bk_compute_children_indices(0, 1) == frozenset() - assert _bk_compute_z_indices_for_y_component(0, 1) == frozenset() - - class TestQdkQubitMapper: """Tests for QdkQubitMapper.""" @@ -139,7 +48,6 @@ def test_instantiation(self) -> None: def test_default_settings(self) -> None: """Test default settings values.""" mapper = create("qubit_mapper", "qdk") - assert mapper.settings().get("encoding") == "jordan-wigner" assert mapper.settings().get("threshold") == 1e-12 def test_custom_threshold(self) -> None: @@ -147,18 +55,13 @@ def test_custom_threshold(self) -> None: mapper = create("qubit_mapper", "qdk", threshold=1e-10) assert mapper.settings().get("threshold") == 1e-10 - def test_invalid_encoding_raises(self) -> None: - """Test that invalid encoding raises ValueError.""" - mapper = create("qubit_mapper", "qdk") - with pytest.raises(ValueError, match="out of allowed options"): - mapper.settings().set("encoding", "invalid_type") - def test_simple_hamiltonian(self) -> None: """Test mapping a simple diagonal Hamiltonian.""" mapper = create("qubit_mapper", "qdk") hamiltonian = create_test_hamiltonian(2) + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * 2) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) assert isinstance(result, QubitHamiltonian) assert result.num_qubits == 4 @@ -176,8 +79,9 @@ def test_number_operator(self) -> None: two_body = np.zeros(1) orbitals = create_test_orbitals(n_orbitals) hamiltonian = _make_hamiltonian(one_body, two_body, orbitals) + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * n_orbitals) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) pauli_dict = dict(zip(result.pauli_strings, result.coefficients, strict=True)) assert result.num_qubits == 2 @@ -195,8 +99,9 @@ def test_core_energy_not_included(self) -> None: orbitals = create_test_orbitals(n_orbitals) core_energy = 5.0 hamiltonian = _make_hamiltonian(one_body, two_body, orbitals, core_energy=core_energy) + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * n_orbitals) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) pauli_dict = dict(zip(result.pauli_strings, result.coefficients, strict=True)) assert "II" in pauli_dict @@ -206,8 +111,9 @@ def test_threshold_pruning(self) -> None: """Test that small coefficients are pruned.""" mapper = create("qubit_mapper", "qdk", threshold=0.1) hamiltonian = create_test_hamiltonian(2) + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * 2) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) for coeff in result.coefficients: assert abs(coeff) >= 0.1 @@ -216,8 +122,9 @@ def test_pauli_strings_format(self) -> None: """Test Pauli string format.""" mapper = create("qubit_mapper", "qdk") hamiltonian = create_test_hamiltonian(2) + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * 2) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) for ps in result.pauli_strings: assert isinstance(ps, str) @@ -242,8 +149,9 @@ def test_pauli_string_ordering_convention(self) -> None: two_body = np.zeros(1) orbitals = create_test_orbitals(n_orbitals) hamiltonian = _make_hamiltonian(one_body, two_body, orbitals) + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * n_orbitals) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) pauli_dict = dict(zip(result.pauli_strings, result.coefficients, strict=True)) # Verify ordering: ZI should have Z on qubit 0 (alpha), I on qubit 1 (beta) @@ -277,8 +185,9 @@ def test_hopping_adjacent_orbitals(self) -> None: two_body = np.zeros(n_orbitals**4) orbitals = create_test_orbitals(n_orbitals) hamiltonian = _make_hamiltonian(one_body, two_body, orbitals) + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * n_orbitals) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) pauli_dict = dict(zip(result.pauli_strings, result.coefficients, strict=True)) # Expected hopping terms (no Z-string for adjacent orbitals) @@ -321,8 +230,9 @@ def test_hopping_non_adjacent_orbitals_z_string(self) -> None: two_body = np.zeros(n_orbitals**4) orbitals = create_test_orbitals(n_orbitals) hamiltonian = _make_hamiltonian(one_body, two_body, orbitals) + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * n_orbitals) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) pauli_dict = dict(zip(result.pauli_strings, result.coefficients, strict=True)) # Expected hopping terms WITH Z-string @@ -364,8 +274,9 @@ def test_pure_one_body_hamiltonian(self) -> None: two_body = np.zeros(n_orbitals**4) orbitals = create_test_orbitals(n_orbitals) hamiltonian = _make_hamiltonian(one_body, two_body, orbitals) + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * n_orbitals) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) pauli_dict = dict(zip(result.pauli_strings, result.coefficients, strict=True)) # Expected: only identity and single-Z terms (number operators) @@ -416,13 +327,13 @@ def test_pure_two_body_hamiltonian(self) -> None: two_body[0] = 2.0 # (00|00) = U = 2 orbitals = create_test_orbitals(n_orbitals) hamiltonian = _make_hamiltonian(one_body, two_body, orbitals) + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * n_orbitals) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) pauli_dict = dict(zip(result.pauli_strings, result.coefficients, strict=True)) # Expected: n_a n_b interaction expected = { - "II": 0.5, "ZI": -0.5, "IZ": -0.5, "ZZ": 0.5, @@ -461,7 +372,8 @@ def test_mixed_one_and_two_body(self) -> None: orbitals = create_test_orbitals(n_orbitals) hamiltonian = _make_hamiltonian(one_body, two_body, orbitals) - result = mapper.run(hamiltonian) + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * n_orbitals) + result = mapper.run(hamiltonian, mapping) pauli_dict = dict(zip(result.pauli_strings, result.coefficients, strict=True)) expected = { @@ -493,7 +405,8 @@ def test_threshold_boundary(self) -> None: orbitals = create_test_orbitals(n_orbitals) hamiltonian = _make_hamiltonian(one_body, two_body, orbitals) - result = mapper.run(hamiltonian) + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * n_orbitals) + result = mapper.run(hamiltonian, mapping) # All returned coefficients should be >= threshold for coeff in result.coefficients: @@ -523,7 +436,8 @@ def test_four_orbital_z_string(self) -> None: orbitals = create_test_orbitals(n_orbitals) hamiltonian = _make_hamiltonian(one_body, two_body, orbitals) - result = mapper.run(hamiltonian) + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * n_orbitals) + result = mapper.run(hamiltonian, mapping) pauli_dict = dict(zip(result.pauli_strings, result.coefficients, strict=True)) # Expected hopping terms with double Z-string @@ -549,10 +463,10 @@ def test_ethylene_4e4o(self, test_data_path: Path) -> None: hamiltonian = Hamiltonian.from_json_file(test_data_path / "ethylene_4e4o_2det.hamiltonian.json") mapper = create("qubit_mapper", "qdk") - result = mapper.run(hamiltonian) - h1_alpha, _ = hamiltonian.get_one_body_integrals() expected_qubits = 2 * h1_alpha.shape[0] + mapping = MajoranaMapping.jordan_wigner(num_modes=expected_qubits) + result = mapper.run(hamiltonian, mapping) assert result.num_qubits == expected_qubits assert len(result.pauli_strings) > 0 @@ -564,10 +478,10 @@ def test_f2_10e6o(self, test_data_path: Path) -> None: hamiltonian = Hamiltonian.from_json_file(test_data_path / "f2_10e6o.hamiltonian.json") mapper = create("qubit_mapper", "qdk") - result = mapper.run(hamiltonian) - h1_alpha, _ = hamiltonian.get_one_body_integrals() expected_qubits = 2 * h1_alpha.shape[0] + mapping = MajoranaMapping.jordan_wigner(num_modes=expected_qubits) + result = mapper.run(hamiltonian, mapping) assert result.num_qubits == expected_qubits assert len(result.pauli_strings) > 0 @@ -589,8 +503,10 @@ def test_vs_qiskit(self, test_data_path: Path) -> None: FermionicOp.atol = threshold SparsePauliOp.atol = threshold - qdk_result = create("qubit_mapper", "qdk", threshold=threshold).run(hamiltonian) - qiskit_result = create("qubit_mapper", "qiskit", encoding="jordan-wigner").run(hamiltonian) + h1_alpha, _ = hamiltonian.get_one_body_integrals() + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * h1_alpha.shape[0]) + qdk_result = create("qubit_mapper", "qdk", threshold=threshold).run(hamiltonian, mapping) + qiskit_result = create("qubit_mapper", "qiskit").run(hamiltonian, mapping) finally: FermionicOp.atol = original_fermionic_atol SparsePauliOp.atol = original_sparse_atol @@ -611,16 +527,17 @@ class TestBravyiKitaevMapper: """Tests for Bravyi-Kitaev mapping.""" def test_bk_instantiation(self) -> None: - """Test BK encoding is valid via factory.""" - mapper = create("qubit_mapper", "qdk", encoding="bravyi-kitaev") - assert mapper.settings().get("encoding") == "bravyi-kitaev" + """Test BK mapper can be created via factory.""" + mapper = create("qubit_mapper", "qdk") + assert isinstance(mapper, QubitMapper) def test_bk_simple_hamiltonian(self) -> None: """Test BK mapping of simple Hamiltonian.""" - mapper = create("qubit_mapper", "qdk", encoding="bravyi-kitaev") + mapper = create("qubit_mapper", "qdk") hamiltonian = create_test_hamiltonian(2) + mapping = MajoranaMapping.bravyi_kitaev(num_modes=2 * 2) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) assert isinstance(result, QubitHamiltonian) assert result.num_qubits == 4 @@ -639,7 +556,7 @@ def test_bk_number_operator(self) -> None: - n_1 (beta, j=1): F(1)={0}, so n_1 = 0.5*(I - Z_0*Z_1) Total with h_00=1: H = n_0 + n_1 = I - 0.5*Z_0 - 0.5*Z_0*Z_1 """ - mapper_bk = create("qubit_mapper", "qdk", encoding="bravyi-kitaev") + mapper_bk = create("qubit_mapper", "qdk") # h_00 = 1 gives H = n_0_alpha + n_0_beta n_orbitals = 1 @@ -647,8 +564,9 @@ def test_bk_number_operator(self) -> None: two_body = np.zeros(1) orbitals = create_test_orbitals(n_orbitals) hamiltonian = _make_hamiltonian(one_body, two_body, orbitals) + mapping = MajoranaMapping.bravyi_kitaev(num_modes=2 * n_orbitals) - result_bk = mapper_bk.run(hamiltonian) + result_bk = mapper_bk.run(hamiltonian, mapping) assert result_bk.num_qubits == 2 bk_dict = dict(zip(result_bk.pauli_strings, result_bk.coefficients, strict=True)) @@ -661,7 +579,7 @@ def test_bk_number_operator(self) -> None: def test_bk_core_energy_not_included(self) -> None: """Test that core energy is not included in BK QubitHamiltonian.""" - mapper = create("qubit_mapper", "qdk", encoding="bravyi-kitaev") + mapper = create("qubit_mapper", "qdk") n_orbitals = 1 one_body = np.array([[1.0]]) # Non-zero integral to generate Pauli terms @@ -669,8 +587,9 @@ def test_bk_core_energy_not_included(self) -> None: orbitals = create_test_orbitals(n_orbitals) core_energy = 5.0 hamiltonian = _make_hamiltonian(one_body, two_body, orbitals, core_energy=core_energy) + mapping = MajoranaMapping.bravyi_kitaev(num_modes=2 * n_orbitals) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) pauli_dict = dict(zip(result.pauli_strings, result.coefficients, strict=True)) assert "II" in pauli_dict @@ -690,15 +609,16 @@ def test_bk_hopping_adjacent_orbitals(self) -> None: For h_01 = h_10 = 1 with 2 orbitals (4 qubits, blocked ordering): We verify the BK result differs from JW but preserves hermiticity. """ - mapper = create("qubit_mapper", "qdk", encoding="bravyi-kitaev") + mapper = create("qubit_mapper", "qdk") n_orbitals = 2 one_body = np.array([[0.0, 1.0], [1.0, 0.0]]) # h_01 = h_10 = 1 two_body = np.zeros(n_orbitals**4) orbitals = create_test_orbitals(n_orbitals) hamiltonian = _make_hamiltonian(one_body, two_body, orbitals) + mapping = MajoranaMapping.bravyi_kitaev(num_modes=2 * n_orbitals) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) pauli_dict = dict(zip(result.pauli_strings, result.coefficients, strict=True)) # BK should have different structure than JW @@ -730,15 +650,16 @@ def test_bk_pure_one_body_diagonal(self) -> None: H = h_00*(n_0 + n_2) + h_11*(n_1 + n_3) = 1*(n_0 + n_2) + 2*(n_1 + n_3) """ - mapper = create("qubit_mapper", "qdk", encoding="bravyi-kitaev") + mapper = create("qubit_mapper", "qdk") n_orbitals = 2 one_body = np.array([[1.0, 0.0], [0.0, 2.0]]) two_body = np.zeros(n_orbitals**4) orbitals = create_test_orbitals(n_orbitals) hamiltonian = _make_hamiltonian(one_body, two_body, orbitals) + mapping = MajoranaMapping.bravyi_kitaev(num_modes=2 * n_orbitals) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) pauli_dict = dict(zip(result.pauli_strings, result.coefficients, strict=True)) # Expected terms from BK number operators: @@ -762,7 +683,7 @@ def test_bk_two_body_on_site(self) -> None: The two-body term n_0a n_0b should produce ZZ interactions in BK, but with different structure than JW due to BK encoding. """ - mapper = create("qubit_mapper", "qdk", encoding="bravyi-kitaev") + mapper = create("qubit_mapper", "qdk") n_orbitals = 1 one_body = np.zeros((n_orbitals, n_orbitals)) @@ -770,8 +691,9 @@ def test_bk_two_body_on_site(self) -> None: two_body[0] = 2.0 # U = 2 orbitals = create_test_orbitals(n_orbitals) hamiltonian = _make_hamiltonian(one_body, two_body, orbitals) + mapping = MajoranaMapping.bravyi_kitaev(num_modes=2 * n_orbitals) - result = mapper.run(hamiltonian) + result = mapper.run(hamiltonian, mapping) pauli_dict = dict(zip(result.pauli_strings, result.coefficients, strict=True)) # For 1 spatial orbital (2 qubits), BK n_0a n_0b: @@ -808,8 +730,10 @@ def test_bk_vs_qiskit(self, test_data_path: Path) -> None: FermionicOp.atol = threshold SparsePauliOp.atol = threshold - qdk_result = create("qubit_mapper", "qdk", encoding="bravyi-kitaev", threshold=threshold).run(hamiltonian) - qiskit_result = create("qubit_mapper", "qiskit", encoding="bravyi-kitaev").run(hamiltonian) + h1_alpha, _ = hamiltonian.get_one_body_integrals() + mapping = MajoranaMapping.bravyi_kitaev(num_modes=2 * h1_alpha.shape[0]) + qdk_result = create("qubit_mapper", "qdk", threshold=threshold).run(hamiltonian, mapping) + qiskit_result = create("qubit_mapper", "qiskit").run(hamiltonian, mapping) finally: FermionicOp.atol = original_fermionic_atol SparsePauliOp.atol = original_sparse_atol diff --git a/python/tests/test_sample_workflow_openfermion.py b/python/tests/test_sample_workflow_openfermion.py index 6d22003b8..ec31b96dd 100644 --- a/python/tests/test_sample_workflow_openfermion.py +++ b/python/tests/test_sample_workflow_openfermion.py @@ -20,7 +20,7 @@ from qdk_chemistry.algorithms import create from qdk_chemistry.constants import ANGSTROM_TO_BOHR -from qdk_chemistry.data import Structure +from qdk_chemistry.data import MajoranaMapping, Structure from .reference_tolerances import float_comparison_absolute_tolerance, scf_energy_tolerance from .test_sample_workflow_utils import ( @@ -79,8 +79,11 @@ def test_openfermion_molecular_hamiltonian_jordan_wigner(): # Obtain qubit Hamiltonian assuming block ordering - spin up first then spin down # Note if printed directly, the Pauli operators will not match with OpenFermion output - qubit_mapper = create("qubit_mapper", "qdk", encoding="jordan-wigner") - qubit_hamiltonian = qubit_mapper.run(active_hamiltonian) + + n_spatial = active_hamiltonian.get_one_body_integrals()[0].shape[0] + qubit_mapper = create("qubit_mapper", "qdk") + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * n_spatial) + qubit_hamiltonian = qubit_mapper.run(active_hamiltonian, mapping) # Obtain the ground state energy by diagonalizing the qubit Hamiltonian matrix jordan_wigner_matrix = qubit_hamiltonian.to_matrix() From ca7355e71d2d61a5600fcae33042cb4468be3754 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 9 May 2026 01:53:19 +0000 Subject: [PATCH 065/117] perf: precompute Majorana pair products + spin-summed E operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache γ_i·γ_j products in a 2N×2N lookup table to avoid redundant multiply_uncached calls in the O(N^4) two-body loop. For restricted systems, precompute spin-summed E_pq operators (α+β) to eliminate the 4x spin-channel repetition. Benchmarks (JW, restricted, vs old Python-loop approach): n=4: 14.5ms → 3.6ms (4.0x faster) n=8: 367ms → 137ms (2.7x faster) n=10: 1128ms → 532ms (2.1x faster) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chemistry/data/majorana_map_engine.cpp | 102 +++++++++++------- 1 file changed, 66 insertions(+), 36 deletions(-) diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index 2bea92715..2c8c4be4f 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -117,36 +117,40 @@ MajoranaMapResult majorana_map_hamiltonian( // ─── Two-body terms ─────────────────────────────────────────────── // H_2 = (1/2) Σ_{pqrs,σ,τ} (pq|rs) a†_{p,σ} a†_{r,τ} a_{s,τ} a_{q,σ} // = (1/2) Σ_{pqrs,σ,τ} (pq|rs) [E_{pσ,qσ} E_{rτ,sτ} - δ_{qσ,rτ} E_{pσ,sτ}] - // - // For same-spin (σ==τ), δ_{qσ,rτ} = δ_{q,r} and E_{pσ,sτ} = E_{pσ,sσ} - // For cross-spin (σ≠τ), δ_{qσ,rτ} = 0 (different spin) - // Helper: accumulate E_pσ E_rτ product with two-body coefficient - // E_pq · E_rs = (1/16) Σ_{a,b,c,d} c[a][b] c[c][d] γ_{2p+a} γ_{2q+b} γ_{2r+c} γ_{2s+d} + // Precompute all Majorana pair products: prod_cache[2*m+a][2*m'+b] = (phase, word) + // This avoids redundant multiply_uncached calls in the O(N^4) loop. + struct MajPairProduct { + std::complex phase; + SparsePauliWord word; + }; + std::vector> pair_cache( + 2 * n_modes, std::vector(2 * n_modes)); + for (std::size_t i = 0; i < 2 * n_modes; ++i) { + for (std::size_t j = 0; j < 2 * n_modes; ++j) { + auto [ph, w] = PauliTermAccumulator::multiply_uncached( + mapping(i), mapping(j)); + pair_cache[i][j] = {ph, std::move(w)}; + } + } + + // Helper: accumulate E_pσ E_rτ product using precomputed pair products auto accumulate_two_body_product = [&](std::size_t mode_p, std::size_t mode_q, std::size_t mode_r, std::size_t mode_s, double eri) { - // Factor: 0.5 * eri / 16 = eri * kSixteenth * 0.5 double half_eri = 0.5 * eri; for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { std::complex c_ab = kC[a][b]; - const auto& w_pa = mapping(2 * mode_p + a); - const auto& w_qb = mapping(2 * mode_q + b); + const auto& [phase1, prod1] = + pair_cache[2 * mode_p + a][2 * mode_q + b]; for (int c = 0; c < 2; ++c) { for (int d = 0; d < 2; ++d) { std::complex coeff = half_eri * kSixteenth * c_ab * kC[c][d]; - const auto& w_rc = mapping(2 * mode_r + c); - const auto& w_sd = mapping(2 * mode_s + d); - - // Need to compute (w_pa · w_qb) · (w_rc · w_sd) - // Use the accumulator's multiply + accumulate - auto [phase1, prod1] = - PauliTermAccumulator::multiply_uncached(w_pa, w_qb); - auto [phase2, prod2] = - PauliTermAccumulator::multiply_uncached(w_rc, w_sd); + const auto& [phase2, prod2] = + pair_cache[2 * mode_r + c][2 * mode_s + d]; acc.accumulate_product(prod1, prod2, coeff * phase1 * phase2); } @@ -160,36 +164,62 @@ MajoranaMapResult majorana_map_hamiltonian( return ((p * n_spatial + q) * n_spatial + r) * n_spatial + s; }; - // Spin-free case: use spatial ERIs with both spin channels + // Spin-free case: use spin-summed E operators for 4x fewer products if (is_restricted) { + // Precompute spin-summed E_pq: for each spatial (p,q), accumulate + // the 4 Majorana pair products from both α and β into a single set. + // E^σ_pq = (1/4) Σ_{a,b} c[a][b] * pair_cache[2*mode+a][2*mode'+b] + // E_pq = E^α_pq + E^β_pq + struct SpinSummedE { + // Up to 8 terms (4 per spin, some may combine) + std::vector, SparsePauliWord>> terms; + }; + std::vector ss_e(n_spatial * n_spatial); + + for (std::size_t p = 0; p < n_spatial; ++p) { + for (std::size_t q = 0; q < n_spatial; ++q) { + auto& sse = ss_e[p * n_spatial + q]; + // Collect terms from both spins + for (int spin = 0; spin < 2; ++spin) { + std::size_t mp = (spin == 0) ? mode_alpha(p) : mode_beta(p); + std::size_t mq = (spin == 0) ? mode_alpha(q) : mode_beta(q); + for (int a = 0; a < 2; ++a) { + for (int b = 0; b < 2; ++b) { + const auto& [phase, word] = + pair_cache[2 * mp + a][2 * mq + b]; + sse.terms.emplace_back(kQuarter * kC[a][b] * phase, word); + } + } + } + } + } + + // Two-body: (1/2) Σ_{pqrs} (pq|rs) [E_pq · E_rs - δ_{qr} E_ps] for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t q = 0; q < n_spatial; ++q) { + const auto& e_pq = ss_e[p * n_spatial + q]; for (std::size_t r = 0; r < n_spatial; ++r) { for (std::size_t s = 0; s < n_spatial; ++s) { double eri = eri_aaaa[idx4(p, q, r, s)]; if (std::abs(eri) < integral_threshold) continue; - // αα channel - accumulate_two_body_product(mode_alpha(p), mode_alpha(q), - mode_alpha(r), mode_alpha(s), eri); - // ββ channel (same ERI for restricted) - accumulate_two_body_product(mode_beta(p), mode_beta(q), - mode_beta(r), mode_beta(s), eri); - // αβ channel - accumulate_two_body_product(mode_alpha(p), mode_alpha(q), - mode_beta(r), mode_beta(s), eri); - // βα channel - accumulate_two_body_product(mode_beta(p), mode_beta(q), - mode_alpha(r), mode_alpha(s), eri); + double half_eri = 0.5 * eri; + const auto& e_rs = ss_e[r * n_spatial + s]; + + // E_pq · E_rs product + for (const auto& [c1, w1] : e_pq.terms) { + for (const auto& [c2, w2] : e_rs.terms) { + acc.accumulate_product(w1, w2, half_eri * c1 * c2); + } + } - // δ corrections for same-spin channels + // δ_{qr} correction if (q == r) { - // -0.5 * eri * E_{p,α,s,α} - accumulate_epq(mode_alpha(p), mode_alpha(s), -0.5 * eri); - // -0.5 * eri * E_{p,β,s,β} - accumulate_epq(mode_beta(p), mode_beta(s), -0.5 * eri); + const auto& e_ps = ss_e[p * n_spatial + s]; + for (const auto& [c, w] : e_ps.terms) { + acc.accumulate(w, -half_eri * c); + } } - // No δ correction for cross-spin channels (σ ≠ τ) } } } From 44775a18fc53a08455b6b9ca9185297c96d8d2ee Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sun, 10 May 2026 19:53:58 +0000 Subject: [PATCH 066/117] perf: bypass LRU cache + per-spin pair cache (~6x vs old code) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two optimizations from code review (GPT-5.5 + Claude Opus 4.7): 1. Bypass PauliTermAccumulator's LRU multiplication cache in the O(N^4) inner loop. The cache has near-zero hit rate (keys are unique across (p,q,r,s,a,b,c,d) tuples) so the hash+lookup+LRU-surgery was pure overhead. Now calls multiply_uncached + accumulate directly. 2. Split the Majorana pair cache into two per-spin blocks (α and β), stored as flat vectors. Eliminates ~50% of precomputed entries that were never read (cross-spin pairs are never needed) and removes the vector> indirection. End-to-end benchmark vs old code (JW, restricted): n=12: 2.91s → 0.30s (9.7x) n=20: 43.0s → 5.9s (7.3x) n=30: 348s → 58s (6.0x) n=40: 1444s → 251s (5.8x) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chemistry/data/majorana_map_engine.cpp | 100 +++++++++++++----- 1 file changed, 75 insertions(+), 25 deletions(-) diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index 2c8c4be4f..531685763 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -118,41 +118,87 @@ MajoranaMapResult majorana_map_hamiltonian( // H_2 = (1/2) Σ_{pqrs,σ,τ} (pq|rs) a†_{p,σ} a†_{r,τ} a_{s,τ} a_{q,σ} // = (1/2) Σ_{pqrs,σ,τ} (pq|rs) [E_{pσ,qσ} E_{rτ,sτ} - δ_{qσ,rτ} E_{pσ,sτ}] - // Precompute all Majorana pair products: prod_cache[2*m+a][2*m'+b] = (phase, word) - // This avoids redundant multiply_uncached calls in the O(N^4) loop. + // Precompute Majorana pair products for SAME-SPIN blocks only. + // Majorana indices for α: [0, 2*n_spatial), for β: [2*n_spatial, 4*n_spatial). + // Downstream only ever pairs indices within the same spin block. + // Two flat caches (α and β), each (2*n_spatial)² entries. struct MajPairProduct { std::complex phase; SparsePauliWord word; }; - std::vector> pair_cache( - 2 * n_modes, std::vector(2 * n_modes)); - for (std::size_t i = 0; i < 2 * n_modes; ++i) { - for (std::size_t j = 0; j < 2 * n_modes; ++j) { - auto [ph, w] = PauliTermAccumulator::multiply_uncached( + const std::size_t maj_per_spin = 2 * n_spatial; // Majorana indices per spin + std::vector pair_cache_alpha(maj_per_spin * maj_per_spin); + std::vector pair_cache_beta(maj_per_spin * maj_per_spin); + + for (std::size_t i = 0; i < maj_per_spin; ++i) { + for (std::size_t j = 0; j < maj_per_spin; ++j) { + // α block: Majorana indices i, j (for modes 0..n_spatial-1) + auto [ph_a, w_a] = PauliTermAccumulator::multiply_uncached( mapping(i), mapping(j)); - pair_cache[i][j] = {ph, std::move(w)}; + pair_cache_alpha[i * maj_per_spin + j] = {ph_a, std::move(w_a)}; + + // β block: Majorana indices i+2*n_spatial, j+2*n_spatial + auto [ph_b, w_b] = PauliTermAccumulator::multiply_uncached( + mapping(i + maj_per_spin), mapping(j + maj_per_spin)); + pair_cache_beta[i * maj_per_spin + j] = {ph_b, std::move(w_b)}; } } + // Accessor lambdas: given a mode index m and Majorana sub-index a (0 or 1), + // return the pair product for two Majorana operators of the same spin. + // For α: mode m ∈ [0, n_spatial), Majorana index = 2*m+a + // For β: mode m ∈ [n_spatial, 2*n_spatial), Majorana index = 2*(m-n_spatial)+a + auto alpha_pair = [&](std::size_t local_i, std::size_t local_j) + -> const MajPairProduct& { + return pair_cache_alpha[local_i * maj_per_spin + local_j]; + }; + auto beta_pair = [&](std::size_t local_i, std::size_t local_j) + -> const MajPairProduct& { + return pair_cache_beta[local_i * maj_per_spin + local_j]; + }; + + // Helper: multiply two SparsePauliWords and accumulate, bypassing the + // LRU cache (the cache has near-zero hit rate in the O(N^4) loop since + // keys are unique across (p,q,r,s,a,b,c,d) tuples). + auto accumulate_product_uncached = + [&](const SparsePauliWord& w1, const SparsePauliWord& w2, + std::complex scale) { + auto [phase, word] = + PauliTermAccumulator::multiply_uncached(w1, w2); + acc.accumulate(word, scale * phase); + }; + // Helper: accumulate E_pσ E_rτ product using precomputed pair products auto accumulate_two_body_product = [&](std::size_t mode_p, std::size_t mode_q, std::size_t mode_r, std::size_t mode_s, double eri) { + // Determine which pair cache to use for each E operator + bool pq_is_alpha = mode_p < n_spatial; + bool rs_is_alpha = mode_r < n_spatial; + auto& cache_pq = pq_is_alpha ? pair_cache_alpha : pair_cache_beta; + auto& cache_rs = rs_is_alpha ? pair_cache_alpha : pair_cache_beta; + std::size_t pq_base_p = pq_is_alpha ? mode_p : (mode_p - n_spatial); + std::size_t pq_base_q = pq_is_alpha ? mode_q : (mode_q - n_spatial); + std::size_t rs_base_r = rs_is_alpha ? mode_r : (mode_r - n_spatial); + std::size_t rs_base_s = rs_is_alpha ? mode_s : (mode_s - n_spatial); + double half_eri = 0.5 * eri; for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { std::complex c_ab = kC[a][b]; const auto& [phase1, prod1] = - pair_cache[2 * mode_p + a][2 * mode_q + b]; + cache_pq[(2 * pq_base_p + a) * maj_per_spin + + (2 * pq_base_q + b)]; for (int c = 0; c < 2; ++c) { for (int d = 0; d < 2; ++d) { std::complex coeff = half_eri * kSixteenth * c_ab * kC[c][d]; const auto& [phase2, prod2] = - pair_cache[2 * mode_r + c][2 * mode_s + d]; - acc.accumulate_product(prod1, prod2, - coeff * phase1 * phase2); + cache_rs[(2 * rs_base_r + c) * maj_per_spin + + (2 * rs_base_s + d)]; + accumulate_product_uncached(prod1, prod2, + coeff * phase1 * phase2); } } } @@ -168,7 +214,7 @@ MajoranaMapResult majorana_map_hamiltonian( if (is_restricted) { // Precompute spin-summed E_pq: for each spatial (p,q), accumulate // the 4 Majorana pair products from both α and β into a single set. - // E^σ_pq = (1/4) Σ_{a,b} c[a][b] * pair_cache[2*mode+a][2*mode'+b] + // E^σ_pq = (1/4) Σ_{a,b} c[a][b] * pair_cache_σ[2*p+a, 2*q+b] // E_pq = E^α_pq + E^β_pq struct SpinSummedE { // Up to 8 terms (4 per spin, some may combine) @@ -179,16 +225,20 @@ MajoranaMapResult majorana_map_hamiltonian( for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t q = 0; q < n_spatial; ++q) { auto& sse = ss_e[p * n_spatial + q]; - // Collect terms from both spins - for (int spin = 0; spin < 2; ++spin) { - std::size_t mp = (spin == 0) ? mode_alpha(p) : mode_beta(p); - std::size_t mq = (spin == 0) ? mode_alpha(q) : mode_beta(q); - for (int a = 0; a < 2; ++a) { - for (int b = 0; b < 2; ++b) { - const auto& [phase, word] = - pair_cache[2 * mp + a][2 * mq + b]; - sse.terms.emplace_back(kQuarter * kC[a][b] * phase, word); - } + // α terms: local Majorana indices 2*p+a, 2*q+b + for (int a = 0; a < 2; ++a) { + for (int b = 0; b < 2; ++b) { + const auto& [phase, word] = + alpha_pair(2 * p + a, 2 * q + b); + sse.terms.emplace_back(kQuarter * kC[a][b] * phase, word); + } + } + // β terms: same local indices but from β cache + for (int a = 0; a < 2; ++a) { + for (int b = 0; b < 2; ++b) { + const auto& [phase, word] = + beta_pair(2 * p + a, 2 * q + b); + sse.terms.emplace_back(kQuarter * kC[a][b] * phase, word); } } } @@ -206,10 +256,10 @@ MajoranaMapResult majorana_map_hamiltonian( double half_eri = 0.5 * eri; const auto& e_rs = ss_e[r * n_spatial + s]; - // E_pq · E_rs product + // E_pq · E_rs product — bypass LRU cache for (const auto& [c1, w1] : e_pq.terms) { for (const auto& [c2, w2] : e_rs.terms) { - acc.accumulate_product(w1, w2, half_eri * c1 * c2); + accumulate_product_uncached(w1, w2, half_eri * c1 * c2); } } From 7518cb3bf56507ce032737732698fa27048cbcd2 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sun, 10 May 2026 20:29:42 +0000 Subject: [PATCH 067/117] perf: avoid array copies + skip re-validation in factory methods Fix #4: Replace .flatten() (always copies) with .ravel() via np.ascontiguousarray for integral arrays crossing the pybind11 boundary. For restricted Hamiltonians, reuse the same aaaa array for unused aabb/bbbb channels instead of copying. Fix #5: Factory classmethods (jordan_wigner, bravyi_kitaev, parity) now pass the pre-validated C++ core directly to __init__ via a private _core kwarg, skipping string re-parsing and Clifford re-validation. Construction time ~2x faster for large mode counts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../qubit_mapper/qdk_qubit_mapper.py | 20 ++++++++++---- .../qdk_chemistry/data/majorana_mapping.py | 26 ++++++++++++------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index 3344630a5..bcc9654c9 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -127,15 +127,25 @@ def _run_impl( n_spatial = h1_alpha.shape[0] is_restricted = hamiltonian.get_orbitals().is_restricted() + # Use ravel() instead of flatten() to avoid copying contiguous arrays. + # For restricted Hamiltonians the containers share the same two-body + # vector across aaaa/aabb/bbbb, so pass the same array to avoid + # materializing unused copies. + h1_a_flat = np.ascontiguousarray(h1_alpha).ravel() + h1_b_flat = h1_a_flat if is_restricted else np.ascontiguousarray(h1_beta).ravel() + h2_aaaa_flat = np.ascontiguousarray(h2_aaaa).ravel() + h2_aabb_flat = h2_aaaa_flat if is_restricted else np.ascontiguousarray(h2_aabb).ravel() + h2_bbbb_flat = h2_aaaa_flat if is_restricted else np.ascontiguousarray(h2_bbbb).ravel() + # Single C++ call: Majorana-loop engine builds all Pauli terms pauli_strings, coefficients = majorana_map_hamiltonian( mapping.core, 0.0, # core energy not included (QDK convention) - h1_alpha.flatten(), - h1_beta.flatten(), - h2_aaaa.flatten(), - h2_aabb.flatten(), - h2_bbbb.flatten(), + h1_a_flat, + h1_b_flat, + h2_aaaa_flat, + h2_aabb_flat, + h2_bbbb_flat, n_spatial, is_restricted, threshold, diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index e8b5e9fa9..7c53c0704 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -71,6 +71,8 @@ def __init__( self, table: list[str] | tuple[str, ...], name: str = "", + *, + _core: _CoreMajoranaMapping | None = None, ) -> None: """Initialize a MajoranaMapping from a list of dense Pauli-string labels. @@ -82,10 +84,15 @@ def __init__( ValueError: If table size is odd, empty, strings differ in length, contain non-IXYZ characters, or the Clifford algebra validation fails. """ - # Build C++ core object (validates Clifford algebra) - self._core = _CoreMajoranaMapping(list(table), name) - - # Cache immutable tuple from the core + if _core is not None: + # Fast path: accept a pre-validated C++ core object directly + # (used by factory classmethods to skip re-parsing and re-validation) + self._core = _core + else: + # Build C++ core object (validates Clifford algebra) + self._core = _CoreMajoranaMapping(list(table), name) + + # Cache immutable properties from the core self._table = self._core.table self._name = self._core.name self._num_modes = self._core.num_modes @@ -131,7 +138,7 @@ def jordan_wigner(cls, num_modes: int) -> MajoranaMapping: """ core = _CoreMajoranaMapping.jordan_wigner(num_modes) - return cls._from_core(core) + return cls(table=[], _core=core) @classmethod def bravyi_kitaev(cls, num_modes: int) -> MajoranaMapping: @@ -141,11 +148,11 @@ def bravyi_kitaev(cls, num_modes: int) -> MajoranaMapping: num_modes (int): Number of fermionic modes (spin-orbitals). Returns: - MajoranaMapping: Mapping with name ``"bravyi_kitaev"``. + MajoranaMapping: Mapping with name ``"bravyi-kitaev"``. """ core = _CoreMajoranaMapping.bravyi_kitaev(num_modes) - return cls._from_core(core) + return cls(table=[], _core=core) @classmethod def parity(cls, num_modes: int) -> MajoranaMapping: @@ -159,7 +166,7 @@ def parity(cls, num_modes: int) -> MajoranaMapping: """ core = _CoreMajoranaMapping.parity(num_modes) - return cls._from_core(core) + return cls(table=[], _core=core) @classmethod def from_mode_pairs( @@ -189,8 +196,7 @@ def from_mode_pairs( @classmethod def _from_core(cls, core: _CoreMajoranaMapping) -> MajoranaMapping: """Construct from an already-validated C++ core object.""" - # Re-use the table from the core (already validated, so construction is fast) - return cls(table=list(core.table), name=core.name) + return cls(table=[], _core=core) def get_summary(self) -> str: """Get a human-readable summary of the mapping. From b5c62132eae140869d997fbaa3e5dcd6484f9cea Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sun, 10 May 2026 21:34:03 +0000 Subject: [PATCH 068/117] perf: ERI symmetry exploitation + fold delta into one-body integrals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1: Exploit (pq|rs) = (qp|rs) = (pq|sr) = (qp|sr) symmetry in the restricted two-body loop by precomputing symmetrized operators S_pq = E_pq + E_qp (3-4 merged terms vs 8 raw). Iterate p≤q, r≤s only, giving ~12x fewer multiply_uncached calls in the inner loop. Also fold the two-body δ_{qr} correction into the one-body integrals: h'_ps = h_ps - (1/2)·Σ_q (pq|qs), eliminating the separate O(N³) accumulation loop entirely. End-to-end vs old code (JW, restricted): n=12: 2.91s → 0.06s (49x) n=20: 43.0s → 0.85s (51x) n=30: 348s → 6.7s (52x) n=40: 1444s → 31.6s (46x) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chemistry/data/majorana_map_engine.cpp | 130 +++++++++++++----- 1 file changed, 97 insertions(+), 33 deletions(-) diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index 531685763..f70cefcba 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -100,14 +100,43 @@ MajoranaMapResult majorana_map_hamiltonian( // ─── One-body terms ─────────────────────────────────────────────── // H_1 = Σ_{p,q,σ} h_pq a†_{p,σ} a_{q,σ} + // + // For restricted systems, also fold in the two-body δ correction: + // -1/2 Σ_{p,q,s} (pq|qs) · E_ps = Σ_{p,s} h'_ps · E_ps + // where h'_ps = -1/2 Σ_q (pq|qs) + // This avoids a separate O(N³) δ loop. + + // Compute effective one-body integrals (original + δ correction) + std::vector h1_eff_alpha(n_spatial * n_spatial); + std::vector h1_eff_beta(n_spatial * n_spatial); + for (std::size_t p = 0; p < n_spatial; ++p) { + for (std::size_t s = 0; s < n_spatial; ++s) { + double h_a = h1_alpha[p * n_spatial + s]; + double h_b = is_restricted ? h_a : h1_beta[p * n_spatial + s]; + + if (is_restricted) { + // δ correction: h'_ps = -1/2 Σ_q (pq|qs) + double delta_corr = 0.0; + for (std::size_t q = 0; q < n_spatial; ++q) { + delta_corr += eri_aaaa[idx4(p, q, q, s)]; + } + h_a -= 0.5 * delta_corr; + h_b = h_a; // restricted: same correction for both spins + } + + h1_eff_alpha[p * n_spatial + s] = h_a; + h1_eff_beta[p * n_spatial + s] = h_b; + } + } + for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t q = 0; q < n_spatial; ++q) { - double h_pq_a = h1_alpha[p * n_spatial + q]; + double h_pq_a = h1_eff_alpha[p * n_spatial + q]; if (std::abs(h_pq_a) > integral_threshold) { accumulate_epq(mode_alpha(p), mode_alpha(q), h_pq_a); } - double h_pq_b = is_restricted ? h_pq_a : h1_beta[p * n_spatial + q]; + double h_pq_b = h1_eff_beta[p * n_spatial + q]; if (std::abs(h_pq_b) > integral_threshold) { accumulate_epq(mode_beta(p), mode_beta(q), h_pq_b); } @@ -210,14 +239,12 @@ MajoranaMapResult majorana_map_hamiltonian( return ((p * n_spatial + q) * n_spatial + r) * n_spatial + s; }; - // Spin-free case: use spin-summed E operators for 4x fewer products + // Spin-free case: exploit (pq|rs) = (qp|rs) = (pq|sr) = (qp|sr) symmetry + // by using symmetrized operators S_pq = E_pq + E_qp, which merge from + // 8 terms down to 3-4 terms. Iterate p≤q, r≤s: ~12x fewer multiply calls. if (is_restricted) { - // Precompute spin-summed E_pq: for each spatial (p,q), accumulate - // the 4 Majorana pair products from both α and β into a single set. - // E^σ_pq = (1/4) Σ_{a,b} c[a][b] * pair_cache_σ[2*p+a, 2*q+b] - // E_pq = E^α_pq + E^β_pq + // Precompute spin-summed E_pq for all (p,q): struct SpinSummedE { - // Up to 8 terms (4 per spin, some may combine) std::vector, SparsePauliWord>> terms; }; std::vector ss_e(n_spatial * n_spatial); @@ -225,55 +252,92 @@ MajoranaMapResult majorana_map_hamiltonian( for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t q = 0; q < n_spatial; ++q) { auto& sse = ss_e[p * n_spatial + q]; - // α terms: local Majorana indices 2*p+a, 2*q+b for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { - const auto& [phase, word] = + const auto& [phase_a, word_a] = alpha_pair(2 * p + a, 2 * q + b); - sse.terms.emplace_back(kQuarter * kC[a][b] * phase, word); + sse.terms.emplace_back(kQuarter * kC[a][b] * phase_a, word_a); + const auto& [phase_b, word_b] = + beta_pair(2 * p + a, 2 * q + b); + sse.terms.emplace_back(kQuarter * kC[a][b] * phase_b, word_b); } } - // β terms: same local indices but from β cache - for (int a = 0; a < 2; ++a) { - for (int b = 0; b < 2; ++b) { - const auto& [phase, word] = - beta_pair(2 * p + a, 2 * q + b); - sse.terms.emplace_back(kQuarter * kC[a][b] * phase, word); + } + } + + // Precompute symmetrized S_pq = E_pq + E_qp (merged) for p ≤ q. + // S_pp = E_pp (just merged), S_pq = E_pq + E_qp for p < q. + // Merging combines terms with the same Pauli word, reducing from 8-16 + // terms down to 3-4, which is the key inner-loop speedup. + struct SymmetrizedE { + std::vector, SparsePauliWord>> terms; + }; + std::size_t sym_size = n_spatial * (n_spatial + 1) / 2; + std::vector sym_e(sym_size); + + auto sym_idx = [](std::size_t p, std::size_t q) -> std::size_t { + // Upper-triangular index for p ≤ q + return p * (p + 1) / 2 + q; // wrong — need canonical form + }; + // Use flat index: for p ≤ q, index = p*n - p*(p+1)/2 + q + // Actually simpler: use a 2D lookup but only fill p ≤ q + // Let's use p*n_spatial + q mapped to the sym_e array via a helper + std::vector sym_map(n_spatial * n_spatial); + { + std::size_t idx = 0; + for (std::size_t p = 0; p < n_spatial; ++p) { + for (std::size_t q = p; q < n_spatial; ++q) { + sym_map[p * n_spatial + q] = idx; + if (p != q) sym_map[q * n_spatial + p] = idx; + + // Merge E_pq + E_qp terms + std::unordered_map, + SparsePauliWordHash> + merged; + for (const auto& [c, w] : ss_e[p * n_spatial + q].terms) { + merged[w] += c; + } + if (p != q) { + for (const auto& [c, w] : ss_e[q * n_spatial + p].terms) { + merged[w] += c; + } + } + auto& se = sym_e[idx]; + for (auto& [w, c] : merged) { + if (std::abs(c) > 1e-15) { + se.terms.emplace_back(c, std::move(w)); + } } + ++idx; } } } - // Two-body: (1/2) Σ_{pqrs} (pq|rs) [E_pq · E_rs - δ_{qr} E_ps] + // Two-body product term: Σ_{p≤q, r≤s} (pq|rs) S_pq · S_rs + // This is equivalent to Σ_{pqrs} (pq|rs) E_pq · E_rs due to + // the bra/ket symmetry (pq|rs) = (qp|rs) = (pq|sr) = (qp|sr). for (std::size_t p = 0; p < n_spatial; ++p) { - for (std::size_t q = 0; q < n_spatial; ++q) { - const auto& e_pq = ss_e[p * n_spatial + q]; + for (std::size_t q = p; q < n_spatial; ++q) { + const auto& s_pq = sym_e[sym_map[p * n_spatial + q]]; for (std::size_t r = 0; r < n_spatial; ++r) { - for (std::size_t s = 0; s < n_spatial; ++s) { + for (std::size_t s = r; s < n_spatial; ++s) { double eri = eri_aaaa[idx4(p, q, r, s)]; if (std::abs(eri) < integral_threshold) continue; double half_eri = 0.5 * eri; - const auto& e_rs = ss_e[r * n_spatial + s]; + const auto& s_rs = sym_e[sym_map[r * n_spatial + s]]; - // E_pq · E_rs product — bypass LRU cache - for (const auto& [c1, w1] : e_pq.terms) { - for (const auto& [c2, w2] : e_rs.terms) { + for (const auto& [c1, w1] : s_pq.terms) { + for (const auto& [c2, w2] : s_rs.terms) { accumulate_product_uncached(w1, w2, half_eri * c1 * c2); } } - - // δ_{qr} correction - if (q == r) { - const auto& e_ps = ss_e[p * n_spatial + s]; - for (const auto& [c, w] : e_ps.terms) { - acc.accumulate(w, -half_eri * c); - } - } } } } } + + // (δ correction is folded into the one-body integrals above) } else { // Unrestricted: explicit spin-channel ERIs From e423ce02fae9c6c3342c192a399f66c5cf3e9294 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sun, 10 May 2026 22:19:16 +0000 Subject: [PATCH 069/117] perf: bitpacked Pauli representation for O(1) hash/multiply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace SparsePauliWord (vector>) with a bitpacked symplectic representation (x_bits, z_bits as vector) inside the mapping engine. This gives: - O(1) hashing (hash the uint64s directly vs iterating the vector) - O(num_words) multiplication via XOR + popcount (vs O(weight) merge) - O(1) equality comparison (memcmp-like vs element-wise) - Scales to arbitrary qubit counts (1 uint64 per 64 qubits) Phase formula uses per-qubit Levi-Civita structure vectorized over 64-bit words with popcount for cyclic/anti-cyclic pair detection. The packed representation is used only within the engine; SparsePauliWord remains the public API type. Conversion happens at input (mapping table) and output (final term extraction). End-to-end vs old code (JW, restricted): n=20: 43.0s → 0.61s (70x) n=30: 348s → 4.7s (74x) n=40: 1444s → 19.0s (76x) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chemistry/data/majorana_map_engine.cpp | 408 +++++++++++------- 1 file changed, 260 insertions(+), 148 deletions(-) diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index f70cefcba..894d4da18 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -35,6 +35,174 @@ constexpr double kQuarter = 0.25; // 1/16 factor for two-body (product of two E operators, each with 1/4) constexpr double kSixteenth = 0.0625; +// ─── Bitpacked Pauli representation ──────────────────────────────── +// +// Represents a Pauli string as a pair of bitmasks (x_bits, z_bits) +// using the symplectic encoding: +// I: x=0, z=0 X: x=1, z=0 Z: x=0, z=1 Y: x=1, z=1 +// +// Each uint64_t covers 64 qubits. A vector of uint64_t scales to +// arbitrary qubit counts for FTQC applications. +// +// Multiplication is O(num_words) via XOR + popcount for phase: +// (x1,z1) * (x2,z2) = phase * (x1^x2, z1^z2) +// where phase = i^{2 * popcount(x1 & z2)} * (-1)^{popcount(z1 & x2 & ~(x1&z2))} +// (simplified: see multiply implementation below) + +struct PackedPauliWord { + std::vector x; // X-component bitmask + std::vector z; // Z-component bitmask + + bool operator==(const PackedPauliWord& other) const = default; +}; + +struct PackedPauliWordHash { + std::size_t operator()(const PackedPauliWord& w) const noexcept { + std::size_t seed = w.x.size(); + for (auto v : w.x) seed ^= v * 0x9e3779b97f4a7c15ULL + (seed << 6) + (seed >> 2); + for (auto v : w.z) seed ^= v * 0x517cc1b727220a95ULL + (seed << 6) + (seed >> 2); + return seed; + } +}; + +/// Convert SparsePauliWord to packed form. +PackedPauliWord sparse_to_packed(const SparsePauliWord& word, + std::size_t num_words) { + PackedPauliWord pw; + pw.x.resize(num_words, 0); + pw.z.resize(num_words, 0); + for (const auto& [qubit, op] : word) { + std::size_t wi = qubit / 64; + std::uint64_t bit = std::uint64_t(1) << (qubit % 64); + if (wi < num_words) { + if (op == 1 || op == 2) pw.x[wi] |= bit; // X or Y + if (op == 2 || op == 3) pw.z[wi] |= bit; // Y or Z + } + } + return pw; +} + +/// Convert packed form back to SparsePauliWord. +SparsePauliWord packed_to_sparse(const PackedPauliWord& pw) { + SparsePauliWord word; + for (std::size_t wi = 0; wi < pw.x.size(); ++wi) { + std::uint64_t x = pw.x[wi]; + std::uint64_t z = pw.z[wi]; + std::uint64_t active = x | z; + while (active) { + int bit = __builtin_ctzll(active); + std::uint64_t mask = std::uint64_t(1) << bit; + std::uint64_t qubit = wi * 64 + bit; + bool has_x = (x & mask) != 0; + bool has_z = (z & mask) != 0; + std::uint8_t op = has_x ? (has_z ? 2 : 1) : 3; // Y:2, X:1, Z:3 + word.emplace_back(qubit, op); + active &= ~mask; + } + } + return word; +} + +/// Popcount for a vector of uint64_t words. +inline int vec_popcount(const std::vector& v) { + int count = 0; + for (auto w : v) count += __builtin_popcountll(w); + return count; +} + +/// Multiply two PackedPauliWords: result = pw1 * pw2. +/// Returns (phase, product) where phase is ±1 or ±i. +/// +/// Using the symplectic formula: +/// P1 * P2 = i^{phase_exp} * P_result +/// P_result.x = P1.x ^ P2.x, P_result.z = P1.z ^ P2.z +/// phase_exp = 2*popcount(P1.x & P2.z) - 2*popcount(P1.z & P2.x) (mod 4) +/// but we need the FULL Pauli algebra phase, which is: +/// For each qubit: op1 * op2 gives a phase from the multiplication table. +/// +/// Efficient formula (from Dehaene & De Moor 2003): +/// phase_exp = Σ_j f(P1_j, P2_j) mod 4 +/// where f(a,b) for symplectic vectors encodes the single-qubit phase. +/// +/// We use the direct computation: for each qubit where both operators are +/// non-identity, look up the phase from {X,Y,Z} × {X,Y,Z}. +/// +/// Actually, the most efficient approach uses the identity: +/// phase = i^{2·popcount(x1&z2)} · (-1)^{popcount(z1&x2)} +/// · (-1)^{popcount(x1&x2&z1&z2)} +/// (this follows from the qubit-by-qubit Pauli multiplication table) +std::pair, PackedPauliWord> multiply_packed( + const PackedPauliWord& p1, const PackedPauliWord& p2) { + std::size_t nw = p1.x.size(); + PackedPauliWord result; + result.x.resize(nw); + result.z.resize(nw); + + // Per-qubit phase from Pauli multiplication table, vectorized over 64 bits. + // Cyclic pairs (XY, YZ, ZX) contribute +1; anti-cyclic (YX, ZY, XZ) give -1. + int phase_exp = 0; + for (std::size_t i = 0; i < nw; ++i) { + std::uint64_t x1 = p1.x[i], z1 = p1.z[i]; + std::uint64_t x2 = p2.x[i], z2 = p2.z[i]; + + result.x[i] = x1 ^ x2; + result.z[i] = z1 ^ z2; + + std::uint64_t nx1 = ~x1, nz1 = ~z1, nx2 = ~x2, nz2 = ~z2; + std::uint64_t cyc = (x1 & nz1 & x2 & z2) | // XY + (x1 & z1 & nx2 & z2) | // YZ + (nx1 & z1 & x2 & nz2); // ZX + std::uint64_t anti = (x1 & z1 & x2 & nz2) | // YX + (nx1 & z1 & x2 & z2) | // ZY + (x1 & nz1 & nx2 & z2); // XZ + phase_exp += __builtin_popcountll(cyc); + phase_exp -= __builtin_popcountll(anti); + } + + static constexpr std::complex powers_of_i[4] = { + {1, 0}, {0, 1}, {-1, 0}, {0, -1}}; + int mod = ((phase_exp % 4) + 4) % 4; + return {powers_of_i[mod], result}; +} + +/// Bitpacked term accumulator: maps PackedPauliWord → complex coefficient. +class PackedAccumulator { + public: + void accumulate(const PackedPauliWord& word, std::complex coeff) { + auto it = terms_.find(word); + if (it != terms_.end()) { + it->second += coeff; + } else { + terms_[word] = coeff; + } + } + + void accumulate_product(const PackedPauliWord& w1, + const PackedPauliWord& w2, + std::complex scale) { + auto [phase, word] = multiply_packed(w1, w2); + accumulate(word, scale * phase); + } + + /// Extract terms above threshold, converting back to SparsePauliWord. + std::vector, SparsePauliWord>> get_terms( + double threshold) const { + std::vector, SparsePauliWord>> result; + result.reserve(terms_.size()); + for (const auto& [pw, coeff] : terms_) { + if (std::abs(coeff) >= threshold) { + result.emplace_back(coeff, packed_to_sparse(pw)); + } + } + return result; + } + + private: + std::unordered_map, + PackedPauliWordHash> + terms_; +}; + /// Convert a SparsePauliWord to a dense little-endian string. /// (qubit 0 = rightmost character) std::string sparse_to_le_string(const SparsePauliWord& word, @@ -70,60 +238,63 @@ MajoranaMapResult majorana_map_hamiltonian( const double* eri_aabb, const double* eri_bbbb, std::size_t n_spatial, bool is_restricted, double threshold, double integral_threshold) { const std::size_t n_modes = 2 * n_spatial; // spin-orbitals - PauliTermAccumulator acc; + const std::size_t num_qubits = mapping.num_qubits(); + const std::size_t num_words = (num_qubits + 63) / 64; + + // Use bitpacked accumulator for O(1) hash/compare/multiply + PackedAccumulator acc; + + // Convert all Majorana table entries to packed form + std::vector packed_mapping(2 * n_modes); + for (std::size_t k = 0; k < 2 * n_modes; ++k) { + packed_mapping[k] = sparse_to_packed(mapping(k), num_words); + } - // Helper: compute spin-orbital mode index from spatial + spin - // Blocked ordering: mode(p, α) = p, mode(p, β) = p + n_spatial auto mode_alpha = [](std::size_t p) -> std::size_t { return p; }; auto mode_beta = [n_spatial](std::size_t p) -> std::size_t { return p + n_spatial; }; - // Helper: accumulate one-body E_pq = a†_p a_q for specific mode pair - // E_pq = (1/4) Σ_{a,b} c[a][b] · γ_{2p+a} · γ_{2q+b} + // Helper: accumulate one-body E_pq for a mode pair, using packed types auto accumulate_epq = [&](std::size_t mode_p, std::size_t mode_q, double h_pq) { for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { - std::complex coeff = - h_pq * kQuarter * kC[a][b]; - acc.accumulate_product(mapping(2 * mode_p + a), - mapping(2 * mode_q + b), coeff); + auto [phase, word] = multiply_packed( + packed_mapping[2 * mode_p + a], packed_mapping[2 * mode_q + b]); + acc.accumulate(word, h_pq * kQuarter * kC[a][b] * phase); } } }; // ─── Core energy ────────────────────────────────────────────────── if (std::abs(core_energy) > integral_threshold) { - acc.accumulate({}, std::complex(core_energy, 0.0)); + PackedPauliWord identity; + identity.x.resize(num_words, 0); + identity.z.resize(num_words, 0); + acc.accumulate(identity, std::complex(core_energy, 0.0)); } - // ─── One-body terms ─────────────────────────────────────────────── - // H_1 = Σ_{p,q,σ} h_pq a†_{p,σ} a_{q,σ} - // - // For restricted systems, also fold in the two-body δ correction: - // -1/2 Σ_{p,q,s} (pq|qs) · E_ps = Σ_{p,s} h'_ps · E_ps - // where h'_ps = -1/2 Σ_q (pq|qs) - // This avoids a separate O(N³) δ loop. + auto idx4 = [n_spatial](std::size_t p, std::size_t q, std::size_t r, + std::size_t s) -> std::size_t { + return ((p * n_spatial + q) * n_spatial + r) * n_spatial + s; + }; - // Compute effective one-body integrals (original + δ correction) + // ─── One-body terms (with δ correction folded in for restricted) ── std::vector h1_eff_alpha(n_spatial * n_spatial); std::vector h1_eff_beta(n_spatial * n_spatial); for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t s = 0; s < n_spatial; ++s) { double h_a = h1_alpha[p * n_spatial + s]; double h_b = is_restricted ? h_a : h1_beta[p * n_spatial + s]; - if (is_restricted) { - // δ correction: h'_ps = -1/2 Σ_q (pq|qs) double delta_corr = 0.0; for (std::size_t q = 0; q < n_spatial; ++q) { delta_corr += eri_aaaa[idx4(p, q, q, s)]; } h_a -= 0.5 * delta_corr; - h_b = h_a; // restricted: same correction for both spins + h_b = h_a; } - h1_eff_alpha[p * n_spatial + s] = h_a; h1_eff_beta[p * n_spatial + s] = h_b; } @@ -135,7 +306,6 @@ MajoranaMapResult majorana_map_hamiltonian( if (std::abs(h_pq_a) > integral_threshold) { accumulate_epq(mode_alpha(p), mode_alpha(q), h_pq_a); } - double h_pq_b = h1_eff_beta[p * n_spatial + q]; if (std::abs(h_pq_b) > integral_threshold) { accumulate_epq(mode_beta(p), mode_beta(q), h_pq_b); @@ -144,108 +314,42 @@ MajoranaMapResult majorana_map_hamiltonian( } // ─── Two-body terms ─────────────────────────────────────────────── - // H_2 = (1/2) Σ_{pqrs,σ,τ} (pq|rs) a†_{p,σ} a†_{r,τ} a_{s,τ} a_{q,σ} - // = (1/2) Σ_{pqrs,σ,τ} (pq|rs) [E_{pσ,qσ} E_{rτ,sτ} - δ_{qσ,rτ} E_{pσ,sτ}] - - // Precompute Majorana pair products for SAME-SPIN blocks only. - // Majorana indices for α: [0, 2*n_spatial), for β: [2*n_spatial, 4*n_spatial). - // Downstream only ever pairs indices within the same spin block. - // Two flat caches (α and β), each (2*n_spatial)² entries. - struct MajPairProduct { + + // Precompute Majorana pair products in packed form (same-spin only). + struct PackedPairProduct { std::complex phase; - SparsePauliWord word; + PackedPauliWord word; }; - const std::size_t maj_per_spin = 2 * n_spatial; // Majorana indices per spin - std::vector pair_cache_alpha(maj_per_spin * maj_per_spin); - std::vector pair_cache_beta(maj_per_spin * maj_per_spin); + const std::size_t maj_per_spin = 2 * n_spatial; + std::vector ppair_alpha(maj_per_spin * maj_per_spin); + std::vector ppair_beta(maj_per_spin * maj_per_spin); for (std::size_t i = 0; i < maj_per_spin; ++i) { for (std::size_t j = 0; j < maj_per_spin; ++j) { - // α block: Majorana indices i, j (for modes 0..n_spatial-1) - auto [ph_a, w_a] = PauliTermAccumulator::multiply_uncached( - mapping(i), mapping(j)); - pair_cache_alpha[i * maj_per_spin + j] = {ph_a, std::move(w_a)}; - - // β block: Majorana indices i+2*n_spatial, j+2*n_spatial - auto [ph_b, w_b] = PauliTermAccumulator::multiply_uncached( - mapping(i + maj_per_spin), mapping(j + maj_per_spin)); - pair_cache_beta[i * maj_per_spin + j] = {ph_b, std::move(w_b)}; + auto [ph_a, w_a] = multiply_packed( + packed_mapping[i], packed_mapping[j]); + ppair_alpha[i * maj_per_spin + j] = {ph_a, std::move(w_a)}; + + auto [ph_b, w_b] = multiply_packed( + packed_mapping[i + maj_per_spin], + packed_mapping[j + maj_per_spin]); + ppair_beta[i * maj_per_spin + j] = {ph_b, std::move(w_b)}; } } - // Accessor lambdas: given a mode index m and Majorana sub-index a (0 or 1), - // return the pair product for two Majorana operators of the same spin. - // For α: mode m ∈ [0, n_spatial), Majorana index = 2*m+a - // For β: mode m ∈ [n_spatial, 2*n_spatial), Majorana index = 2*(m-n_spatial)+a - auto alpha_pair = [&](std::size_t local_i, std::size_t local_j) - -> const MajPairProduct& { - return pair_cache_alpha[local_i * maj_per_spin + local_j]; - }; - auto beta_pair = [&](std::size_t local_i, std::size_t local_j) - -> const MajPairProduct& { - return pair_cache_beta[local_i * maj_per_spin + local_j]; - }; - - // Helper: multiply two SparsePauliWords and accumulate, bypassing the - // LRU cache (the cache has near-zero hit rate in the O(N^4) loop since - // keys are unique across (p,q,r,s,a,b,c,d) tuples). - auto accumulate_product_uncached = - [&](const SparsePauliWord& w1, const SparsePauliWord& w2, - std::complex scale) { - auto [phase, word] = - PauliTermAccumulator::multiply_uncached(w1, w2); - acc.accumulate(word, scale * phase); - }; - - // Helper: accumulate E_pσ E_rτ product using precomputed pair products - auto accumulate_two_body_product = [&](std::size_t mode_p, - std::size_t mode_q, - std::size_t mode_r, - std::size_t mode_s, double eri) { - // Determine which pair cache to use for each E operator - bool pq_is_alpha = mode_p < n_spatial; - bool rs_is_alpha = mode_r < n_spatial; - auto& cache_pq = pq_is_alpha ? pair_cache_alpha : pair_cache_beta; - auto& cache_rs = rs_is_alpha ? pair_cache_alpha : pair_cache_beta; - std::size_t pq_base_p = pq_is_alpha ? mode_p : (mode_p - n_spatial); - std::size_t pq_base_q = pq_is_alpha ? mode_q : (mode_q - n_spatial); - std::size_t rs_base_r = rs_is_alpha ? mode_r : (mode_r - n_spatial); - std::size_t rs_base_s = rs_is_alpha ? mode_s : (mode_s - n_spatial); - - double half_eri = 0.5 * eri; - for (int a = 0; a < 2; ++a) { - for (int b = 0; b < 2; ++b) { - std::complex c_ab = kC[a][b]; - const auto& [phase1, prod1] = - cache_pq[(2 * pq_base_p + a) * maj_per_spin + - (2 * pq_base_q + b)]; - for (int c = 0; c < 2; ++c) { - for (int d = 0; d < 2; ++d) { - std::complex coeff = - half_eri * kSixteenth * c_ab * kC[c][d]; - const auto& [phase2, prod2] = - cache_rs[(2 * rs_base_r + c) * maj_per_spin + - (2 * rs_base_s + d)]; - accumulate_product_uncached(prod1, prod2, - coeff * phase1 * phase2); - } - } - } - } + auto alpha_pair = [&](std::size_t i, std::size_t j) + -> const PackedPairProduct& { + return ppair_alpha[i * maj_per_spin + j]; }; - - auto idx4 = [n_spatial](std::size_t p, std::size_t q, std::size_t r, - std::size_t s) -> std::size_t { - return ((p * n_spatial + q) * n_spatial + r) * n_spatial + s; + auto beta_pair = [&](std::size_t i, std::size_t j) + -> const PackedPairProduct& { + return ppair_beta[i * maj_per_spin + j]; }; - // Spin-free case: exploit (pq|rs) = (qp|rs) = (pq|sr) = (qp|sr) symmetry - // by using symmetrized operators S_pq = E_pq + E_qp, which merge from - // 8 terms down to 3-4 terms. Iterate p≤q, r≤s: ~12x fewer multiply calls. if (is_restricted) { // Precompute spin-summed E_pq for all (p,q): struct SpinSummedE { - std::vector, SparsePauliWord>> terms; + std::vector, PackedPauliWord>> terms; }; std::vector ss_e(n_spatial * n_spatial); @@ -254,11 +358,9 @@ MajoranaMapResult majorana_map_hamiltonian( auto& sse = ss_e[p * n_spatial + q]; for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { - const auto& [phase_a, word_a] = - alpha_pair(2 * p + a, 2 * q + b); + const auto& [phase_a, word_a] = alpha_pair(2*p+a, 2*q+b); sse.terms.emplace_back(kQuarter * kC[a][b] * phase_a, word_a); - const auto& [phase_b, word_b] = - beta_pair(2 * p + a, 2 * q + b); + const auto& [phase_b, word_b] = beta_pair(2*p+a, 2*q+b); sse.terms.emplace_back(kQuarter * kC[a][b] * phase_b, word_b); } } @@ -266,34 +368,21 @@ MajoranaMapResult majorana_map_hamiltonian( } // Precompute symmetrized S_pq = E_pq + E_qp (merged) for p ≤ q. - // S_pp = E_pp (just merged), S_pq = E_pq + E_qp for p < q. - // Merging combines terms with the same Pauli word, reducing from 8-16 - // terms down to 3-4, which is the key inner-loop speedup. struct SymmetrizedE { - std::vector, SparsePauliWord>> terms; + std::vector, PackedPauliWord>> terms; }; - std::size_t sym_size = n_spatial * (n_spatial + 1) / 2; - std::vector sym_e(sym_size); - - auto sym_idx = [](std::size_t p, std::size_t q) -> std::size_t { - // Upper-triangular index for p ≤ q - return p * (p + 1) / 2 + q; // wrong — need canonical form - }; - // Use flat index: for p ≤ q, index = p*n - p*(p+1)/2 + q - // Actually simpler: use a 2D lookup but only fill p ≤ q - // Let's use p*n_spatial + q mapped to the sym_e array via a helper + std::vector sym_e; std::vector sym_map(n_spatial * n_spatial); { std::size_t idx = 0; + sym_e.resize(n_spatial * (n_spatial + 1) / 2); for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t q = p; q < n_spatial; ++q) { sym_map[p * n_spatial + q] = idx; if (p != q) sym_map[q * n_spatial + p] = idx; - // Merge E_pq + E_qp terms - std::unordered_map, - SparsePauliWordHash> - merged; + std::unordered_map, + PackedPauliWordHash> merged; for (const auto& [c, w] : ss_e[p * n_spatial + q].terms) { merged[w] += c; } @@ -313,9 +402,7 @@ MajoranaMapResult majorana_map_hamiltonian( } } - // Two-body product term: Σ_{p≤q, r≤s} (pq|rs) S_pq · S_rs - // This is equivalent to Σ_{pqrs} (pq|rs) E_pq · E_rs due to - // the bra/ket symmetry (pq|rs) = (qp|rs) = (pq|sr) = (qp|sr). + // Two-body product: Σ_{p≤q, r≤s} (pq|rs) S_pq · S_rs for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t q = p; q < n_spatial; ++q) { const auto& s_pq = sym_e[sym_map[p * n_spatial + q]]; @@ -329,7 +416,7 @@ MajoranaMapResult majorana_map_hamiltonian( for (const auto& [c1, w1] : s_pq.terms) { for (const auto& [c2, w2] : s_rs.terms) { - accumulate_product_uncached(w1, w2, half_eri * c1 * c2); + acc.accumulate_product(w1, w2, half_eri * c1 * c2); } } } @@ -339,7 +426,38 @@ MajoranaMapResult majorana_map_hamiltonian( // (δ correction is folded into the one-body integrals above) } else { - // Unrestricted: explicit spin-channel ERIs + // Unrestricted: explicit spin-channel ERIs using packed types + + auto accumulate_two_body_product = [&](std::size_t mode_p, + std::size_t mode_q, + std::size_t mode_r, + std::size_t mode_s, double eri) { + bool pq_is_alpha = mode_p < n_spatial; + bool rs_is_alpha = mode_r < n_spatial; + auto& cache_pq = pq_is_alpha ? ppair_alpha : ppair_beta; + auto& cache_rs = rs_is_alpha ? ppair_alpha : ppair_beta; + std::size_t bp = pq_is_alpha ? mode_p : (mode_p - n_spatial); + std::size_t bq = pq_is_alpha ? mode_q : (mode_q - n_spatial); + std::size_t br = rs_is_alpha ? mode_r : (mode_r - n_spatial); + std::size_t bs = rs_is_alpha ? mode_s : (mode_s - n_spatial); + + double half_eri = 0.5 * eri; + for (int a = 0; a < 2; ++a) { + for (int b = 0; b < 2; ++b) { + const auto& [ph1, w1] = + cache_pq[(2*bp+a) * maj_per_spin + (2*bq+b)]; + for (int c = 0; c < 2; ++c) { + for (int d = 0; d < 2; ++d) { + const auto& [ph2, w2] = + cache_rs[(2*br+c) * maj_per_spin + (2*bs+d)]; + acc.accumulate_product( + w1, w2, + half_eri * kSixteenth * kC[a][b] * kC[c][d] * ph1 * ph2); + } + } + } + } + }; // aaaa channel for (std::size_t p = 0; p < n_spatial; ++p) { @@ -348,7 +466,6 @@ MajoranaMapResult majorana_map_hamiltonian( for (std::size_t s = 0; s < n_spatial; ++s) { double eri = eri_aaaa[idx4(p, q, r, s)]; if (std::abs(eri) < integral_threshold) continue; - accumulate_two_body_product(mode_alpha(p), mode_alpha(q), mode_alpha(r), mode_alpha(s), eri); if (q == r) { @@ -366,7 +483,6 @@ MajoranaMapResult majorana_map_hamiltonian( for (std::size_t s = 0; s < n_spatial; ++s) { double eri = eri_bbbb[idx4(p, q, r, s)]; if (std::abs(eri) < integral_threshold) continue; - accumulate_two_body_product(mode_beta(p), mode_beta(q), mode_beta(r), mode_beta(s), eri); if (q == r) { @@ -377,33 +493,29 @@ MajoranaMapResult majorana_map_hamiltonian( } } - // aabb channel (alpha-beta) + // aabb channel for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t q = 0; q < n_spatial; ++q) { for (std::size_t r = 0; r < n_spatial; ++r) { for (std::size_t s = 0; s < n_spatial; ++s) { double eri = eri_aabb[idx4(p, q, r, s)]; if (std::abs(eri) < integral_threshold) continue; - accumulate_two_body_product(mode_alpha(p), mode_alpha(q), mode_beta(r), mode_beta(s), eri); - // No δ correction (different spin) } } } } - // bbaa channel (beta-alpha) — uses same eri_aabb + // bbaa channel for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t q = 0; q < n_spatial; ++q) { for (std::size_t r = 0; r < n_spatial; ++r) { for (std::size_t s = 0; s < n_spatial; ++s) { double eri = eri_aabb[idx4(p, q, r, s)]; if (std::abs(eri) < integral_threshold) continue; - accumulate_two_body_product(mode_beta(p), mode_beta(q), mode_alpha(r), mode_alpha(s), eri); - // No δ correction (different spin) } } } @@ -412,14 +524,14 @@ MajoranaMapResult majorana_map_hamiltonian( // ─── Extract results ────────────────────────────────────────────── auto terms = acc.get_terms(threshold); - std::size_t num_qubits = mapping.num_qubits(); MajoranaMapResult result; result.pauli_strings.reserve(terms.size()); result.coefficients.reserve(terms.size()); for (auto& [coeff, word] : terms) { - result.pauli_strings.push_back(sparse_to_le_string(word, num_qubits)); + result.pauli_strings.push_back( + sparse_to_le_string(word, num_qubits)); result.coefficients.push_back(coeff); } From 642c216219e96e4389e545dc14a4a099562638b2 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sun, 10 May 2026 22:27:15 +0000 Subject: [PATCH 070/117] perf: template PackedPauliWord on num_words with std::array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace vector with std::array for zero heap allocation per Pauli word. Runtime dispatch on NW ∈ {1,2,3,4} covers up to 256 qubits. Beyond that, throws with a clear message. This eliminates ~2 heap allocations per Pauli word (x + z vectors) in the inner loop, improving cache locality and reducing allocator pressure. MACIS-style fixed-size bitset pattern. End-to-end vs old code (JW, restricted): n=20: 43.0s → 0.29s (148x) n=30: 348s → 2.2s (158x) n=40: 1444s → 11.3s (128x) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chemistry/data/majorana_map_engine.cpp | 202 +++++++++--------- 1 file changed, 105 insertions(+), 97 deletions(-) diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index 894d4da18..d32cb011a 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -41,51 +41,50 @@ constexpr double kSixteenth = 0.0625; // using the symplectic encoding: // I: x=0, z=0 X: x=1, z=0 Z: x=0, z=1 Y: x=1, z=1 // -// Each uint64_t covers 64 qubits. A vector of uint64_t scales to -// arbitrary qubit counts for FTQC applications. -// -// Multiplication is O(num_words) via XOR + popcount for phase: -// (x1,z1) * (x2,z2) = phase * (x1^x2, z1^z2) -// where phase = i^{2 * popcount(x1 & z2)} * (-1)^{popcount(z1 & x2 & ~(x1&z2))} -// (simplified: see multiply implementation below) +// Each uint64_t covers 64 qubits. Templated on NW (number of uint64 +// words) to keep everything on the stack. The engine dispatches to +// NW ∈ {1, 2, 3, 4} at runtime (covers up to 256 qubits). +template struct PackedPauliWord { - std::vector x; // X-component bitmask - std::vector z; // Z-component bitmask + std::array x{}; + std::array z{}; bool operator==(const PackedPauliWord& other) const = default; }; +template struct PackedPauliWordHash { - std::size_t operator()(const PackedPauliWord& w) const noexcept { - std::size_t seed = w.x.size(); - for (auto v : w.x) seed ^= v * 0x9e3779b97f4a7c15ULL + (seed << 6) + (seed >> 2); - for (auto v : w.z) seed ^= v * 0x517cc1b727220a95ULL + (seed << 6) + (seed >> 2); + std::size_t operator()(const PackedPauliWord& w) const noexcept { + std::size_t seed = NW; + for (std::size_t i = 0; i < NW; ++i) { + seed ^= w.x[i] * 0x9e3779b97f4a7c15ULL + (seed << 6) + (seed >> 2); + } + for (std::size_t i = 0; i < NW; ++i) { + seed ^= w.z[i] * 0x517cc1b727220a95ULL + (seed << 6) + (seed >> 2); + } return seed; } }; -/// Convert SparsePauliWord to packed form. -PackedPauliWord sparse_to_packed(const SparsePauliWord& word, - std::size_t num_words) { - PackedPauliWord pw; - pw.x.resize(num_words, 0); - pw.z.resize(num_words, 0); +template +PackedPauliWord sparse_to_packed(const SparsePauliWord& word) { + PackedPauliWord pw{}; for (const auto& [qubit, op] : word) { std::size_t wi = qubit / 64; std::uint64_t bit = std::uint64_t(1) << (qubit % 64); - if (wi < num_words) { - if (op == 1 || op == 2) pw.x[wi] |= bit; // X or Y - if (op == 2 || op == 3) pw.z[wi] |= bit; // Y or Z + if (wi < NW) { + if (op == 1 || op == 2) pw.x[wi] |= bit; + if (op == 2 || op == 3) pw.z[wi] |= bit; } } return pw; } -/// Convert packed form back to SparsePauliWord. -SparsePauliWord packed_to_sparse(const PackedPauliWord& pw) { +template +SparsePauliWord packed_to_sparse(const PackedPauliWord& pw) { SparsePauliWord word; - for (std::size_t wi = 0; wi < pw.x.size(); ++wi) { + for (std::size_t wi = 0; wi < NW; ++wi) { std::uint64_t x = pw.x[wi]; std::uint64_t z = pw.z[wi]; std::uint64_t active = x | z; @@ -95,7 +94,7 @@ SparsePauliWord packed_to_sparse(const PackedPauliWord& pw) { std::uint64_t qubit = wi * 64 + bit; bool has_x = (x & mask) != 0; bool has_z = (z & mask) != 0; - std::uint8_t op = has_x ? (has_z ? 2 : 1) : 3; // Y:2, X:1, Z:3 + std::uint8_t op = has_x ? (has_z ? 2 : 1) : 3; word.emplace_back(qubit, op); active &= ~mask; } @@ -103,72 +102,36 @@ SparsePauliWord packed_to_sparse(const PackedPauliWord& pw) { return word; } -/// Popcount for a vector of uint64_t words. -inline int vec_popcount(const std::vector& v) { - int count = 0; - for (auto w : v) count += __builtin_popcountll(w); - return count; -} - -/// Multiply two PackedPauliWords: result = pw1 * pw2. -/// Returns (phase, product) where phase is ±1 or ±i. -/// -/// Using the symplectic formula: -/// P1 * P2 = i^{phase_exp} * P_result -/// P_result.x = P1.x ^ P2.x, P_result.z = P1.z ^ P2.z -/// phase_exp = 2*popcount(P1.x & P2.z) - 2*popcount(P1.z & P2.x) (mod 4) -/// but we need the FULL Pauli algebra phase, which is: -/// For each qubit: op1 * op2 gives a phase from the multiplication table. -/// -/// Efficient formula (from Dehaene & De Moor 2003): -/// phase_exp = Σ_j f(P1_j, P2_j) mod 4 -/// where f(a,b) for symplectic vectors encodes the single-qubit phase. -/// -/// We use the direct computation: for each qubit where both operators are -/// non-identity, look up the phase from {X,Y,Z} × {X,Y,Z}. -/// -/// Actually, the most efficient approach uses the identity: -/// phase = i^{2·popcount(x1&z2)} · (-1)^{popcount(z1&x2)} -/// · (-1)^{popcount(x1&x2&z1&z2)} -/// (this follows from the qubit-by-qubit Pauli multiplication table) -std::pair, PackedPauliWord> multiply_packed( - const PackedPauliWord& p1, const PackedPauliWord& p2) { - std::size_t nw = p1.x.size(); - PackedPauliWord result; - result.x.resize(nw); - result.z.resize(nw); - - // Per-qubit phase from Pauli multiplication table, vectorized over 64 bits. - // Cyclic pairs (XY, YZ, ZX) contribute +1; anti-cyclic (YX, ZY, XZ) give -1. +template +std::pair, PackedPauliWord> multiply_packed( + const PackedPauliWord& p1, const PackedPauliWord& p2) { + PackedPauliWord result; int phase_exp = 0; - for (std::size_t i = 0; i < nw; ++i) { + for (std::size_t i = 0; i < NW; ++i) { std::uint64_t x1 = p1.x[i], z1 = p1.z[i]; std::uint64_t x2 = p2.x[i], z2 = p2.z[i]; - result.x[i] = x1 ^ x2; result.z[i] = z1 ^ z2; - std::uint64_t nx1 = ~x1, nz1 = ~z1, nx2 = ~x2, nz2 = ~z2; - std::uint64_t cyc = (x1 & nz1 & x2 & z2) | // XY - (x1 & z1 & nx2 & z2) | // YZ - (nx1 & z1 & x2 & nz2); // ZX - std::uint64_t anti = (x1 & z1 & x2 & nz2) | // YX - (nx1 & z1 & x2 & z2) | // ZY - (x1 & nz1 & nx2 & z2); // XZ + std::uint64_t cyc = (x1 & nz1 & x2 & z2) | + (x1 & z1 & nx2 & z2) | + (nx1 & z1 & x2 & nz2); + std::uint64_t anti = (x1 & z1 & x2 & nz2) | + (nx1 & z1 & x2 & z2) | + (x1 & nz1 & nx2 & z2); phase_exp += __builtin_popcountll(cyc); phase_exp -= __builtin_popcountll(anti); } - static constexpr std::complex powers_of_i[4] = { {1, 0}, {0, 1}, {-1, 0}, {0, -1}}; - int mod = ((phase_exp % 4) + 4) % 4; - return {powers_of_i[mod], result}; + return {powers_of_i[((phase_exp % 4) + 4) % 4], result}; } -/// Bitpacked term accumulator: maps PackedPauliWord → complex coefficient. +template class PackedAccumulator { public: - void accumulate(const PackedPauliWord& word, std::complex coeff) { + void accumulate(const PackedPauliWord& word, + std::complex coeff) { auto it = terms_.find(word); if (it != terms_.end()) { it->second += coeff; @@ -177,14 +140,13 @@ class PackedAccumulator { } } - void accumulate_product(const PackedPauliWord& w1, - const PackedPauliWord& w2, + void accumulate_product(const PackedPauliWord& w1, + const PackedPauliWord& w2, std::complex scale) { auto [phase, word] = multiply_packed(w1, w2); accumulate(word, scale * phase); } - /// Extract terms above threshold, converting back to SparsePauliWord. std::vector, SparsePauliWord>> get_terms( double threshold) const { std::vector, SparsePauliWord>> result; @@ -198,8 +160,8 @@ class PackedAccumulator { } private: - std::unordered_map, - PackedPauliWordHash> + std::unordered_map, std::complex, + PackedPauliWordHash> terms_; }; @@ -232,22 +194,25 @@ std::string sparse_to_le_string(const SparsePauliWord& word, } // namespace -MajoranaMapResult majorana_map_hamiltonian( +namespace { + +/// Templated engine implementation, parameterized on NW (number of uint64 +/// words per Pauli bitmask). NW is selected at runtime by the dispatcher. +template +MajoranaMapResult majorana_map_impl( const MajoranaMapping& mapping, double core_energy, const double* h1_alpha, const double* h1_beta, const double* eri_aaaa, const double* eri_aabb, const double* eri_bbbb, std::size_t n_spatial, bool is_restricted, double threshold, double integral_threshold) { - const std::size_t n_modes = 2 * n_spatial; // spin-orbitals + const std::size_t n_modes = 2 * n_spatial; const std::size_t num_qubits = mapping.num_qubits(); - const std::size_t num_words = (num_qubits + 63) / 64; - // Use bitpacked accumulator for O(1) hash/compare/multiply - PackedAccumulator acc; + PackedAccumulator acc; // Convert all Majorana table entries to packed form - std::vector packed_mapping(2 * n_modes); + std::vector> packed_mapping(2 * n_modes); for (std::size_t k = 0; k < 2 * n_modes; ++k) { - packed_mapping[k] = sparse_to_packed(mapping(k), num_words); + packed_mapping[k] = sparse_to_packed(mapping(k)); } auto mode_alpha = [](std::size_t p) -> std::size_t { return p; }; @@ -269,9 +234,7 @@ MajoranaMapResult majorana_map_hamiltonian( // ─── Core energy ────────────────────────────────────────────────── if (std::abs(core_energy) > integral_threshold) { - PackedPauliWord identity; - identity.x.resize(num_words, 0); - identity.z.resize(num_words, 0); + PackedPauliWord identity{}; acc.accumulate(identity, std::complex(core_energy, 0.0)); } @@ -318,7 +281,7 @@ MajoranaMapResult majorana_map_hamiltonian( // Precompute Majorana pair products in packed form (same-spin only). struct PackedPairProduct { std::complex phase; - PackedPauliWord word; + PackedPauliWord word; }; const std::size_t maj_per_spin = 2 * n_spatial; std::vector ppair_alpha(maj_per_spin * maj_per_spin); @@ -349,7 +312,7 @@ MajoranaMapResult majorana_map_hamiltonian( if (is_restricted) { // Precompute spin-summed E_pq for all (p,q): struct SpinSummedE { - std::vector, PackedPauliWord>> terms; + std::vector, PackedPauliWord>> terms; }; std::vector ss_e(n_spatial * n_spatial); @@ -369,7 +332,7 @@ MajoranaMapResult majorana_map_hamiltonian( // Precompute symmetrized S_pq = E_pq + E_qp (merged) for p ≤ q. struct SymmetrizedE { - std::vector, PackedPauliWord>> terms; + std::vector, PackedPauliWord>> terms; }; std::vector sym_e; std::vector sym_map(n_spatial * n_spatial); @@ -381,8 +344,8 @@ MajoranaMapResult majorana_map_hamiltonian( sym_map[p * n_spatial + q] = idx; if (p != q) sym_map[q * n_spatial + p] = idx; - std::unordered_map, - PackedPauliWordHash> merged; + std::unordered_map, std::complex, + PackedPauliWordHash> merged; for (const auto& [c, w] : ss_e[p * n_spatial + q].terms) { merged[w] += c; } @@ -538,4 +501,49 @@ MajoranaMapResult majorana_map_hamiltonian( return result; } +} // anonymous namespace + +MajoranaMapResult majorana_map_hamiltonian( + const MajoranaMapping& mapping, double core_energy, + const double* h1_alpha, const double* h1_beta, const double* eri_aaaa, + const double* eri_aabb, const double* eri_bbbb, std::size_t n_spatial, + bool is_restricted, double threshold, double integral_threshold) { + const std::size_t num_qubits = mapping.num_qubits(); + const std::size_t num_words = (num_qubits + 63) / 64; + + // Dispatch to the appropriate template instantiation. + // Each instantiation uses std::array (stack-allocated, + // no heap overhead per Pauli word). + switch (num_words) { + case 0: + case 1: + return majorana_map_impl<1>(mapping, core_energy, h1_alpha, h1_beta, + eri_aaaa, eri_aabb, eri_bbbb, n_spatial, + is_restricted, threshold, + integral_threshold); + case 2: + return majorana_map_impl<2>(mapping, core_energy, h1_alpha, h1_beta, + eri_aaaa, eri_aabb, eri_bbbb, n_spatial, + is_restricted, threshold, + integral_threshold); + case 3: + return majorana_map_impl<3>(mapping, core_energy, h1_alpha, h1_beta, + eri_aaaa, eri_aabb, eri_bbbb, n_spatial, + is_restricted, threshold, + integral_threshold); + case 4: + return majorana_map_impl<4>(mapping, core_energy, h1_alpha, h1_beta, + eri_aaaa, eri_aabb, eri_bbbb, n_spatial, + is_restricted, threshold, + integral_threshold); + default: + throw std::invalid_argument( + "majorana_map_hamiltonian: num_qubits=" + + std::to_string(num_qubits) + + " requires " + std::to_string(num_words) + + " uint64 words, but max supported is 4 (256 qubits). " + "Contact the developers to extend the template dispatch."); + } +} + } // namespace qdk::chemistry::data From 31f596e68338f607214cc5525eb21987a12e5037 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sun, 10 May 2026 22:51:04 +0000 Subject: [PATCH 071/117] perf: phase-as-int, single-probe accumulate, 8-fold ERI, direct output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group A (hot-path micro-opts): - multiply_packed returns phase index (int) instead of complex; callers use branchless apply_phase() (real/imag swap) instead of complex multiply. Eliminates 6-op FP sequence per inner-loop call. - PackedAccumulator::accumulate uses terms_[word] += coeff (single hash probe) instead of find() + operator[] (double probe on miss). - Phase normalization: phase_exp & 3 instead of ((% 4) + 4) % 4 (one AND vs two idivs). Group B (algorithmic — ~2x): - Full 8-fold ERI symmetry in restricted path. Previous code used 4-fold (p≤q, r≤s); now adds (pq)≤(rs) exchange symmetry, halving the dominant O(N^4) inner-product work. Group C (output): - Direct packed→string conversion (packed_to_le_string) skips the intermediate SparsePauliWord allocation in get_terms. End-to-end vs old code (JW, restricted): n=20: 43.0s → 0.21s (209x) n=30: 348s → 1.9s (181x) n=40: 1444s → 8.6s (168x) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chemistry/data/majorana_map_engine.cpp | 133 +++++++++++++----- 1 file changed, 97 insertions(+), 36 deletions(-) diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index d32cb011a..d1e13e60a 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -103,7 +103,7 @@ SparsePauliWord packed_to_sparse(const PackedPauliWord& pw) { } template -std::pair, PackedPauliWord> multiply_packed( +std::pair> multiply_packed( const PackedPauliWord& p1, const PackedPauliWord& p2) { PackedPauliWord result; int phase_exp = 0; @@ -122,9 +122,27 @@ std::pair, PackedPauliWord> multiply_packed( phase_exp += __builtin_popcountll(cyc); phase_exp -= __builtin_popcountll(anti); } - static constexpr std::complex powers_of_i[4] = { - {1, 0}, {0, 1}, {-1, 0}, {0, -1}}; - return {powers_of_i[((phase_exp % 4) + 4) % 4], result}; + // Return phase index (0..3) instead of complex — callers apply phase + // using branchless real/imag swap, avoiding complex multiply. + return {phase_exp & 3, result}; +} + +/// Apply a phase index (0=+1, 1=+i, 2=-1, 3=-i) to a complex scale factor. +/// Returns the scaled value without a full complex multiply. +inline std::complex apply_phase(int phase_idx, + std::complex scale) { + switch (phase_idx) { + case 0: + return scale; + case 1: + return {-scale.imag(), scale.real()}; + case 2: + return {-scale.real(), -scale.imag()}; + case 3: + return {scale.imag(), -scale.real()}; + default: + __builtin_unreachable(); + } } template @@ -132,28 +150,25 @@ class PackedAccumulator { public: void accumulate(const PackedPauliWord& word, std::complex coeff) { - auto it = terms_.find(word); - if (it != terms_.end()) { - it->second += coeff; - } else { - terms_[word] = coeff; - } + // Single hash-map probe: operator[] default-constructs (0,0) on miss. + terms_[word] += coeff; } void accumulate_product(const PackedPauliWord& w1, const PackedPauliWord& w2, std::complex scale) { - auto [phase, word] = multiply_packed(w1, w2); - accumulate(word, scale * phase); + auto [phase_idx, word] = multiply_packed(w1, w2); + terms_[word] += apply_phase(phase_idx, scale); } - std::vector, SparsePauliWord>> get_terms( - double threshold) const { - std::vector, SparsePauliWord>> result; + /// Extract terms above threshold as (coefficient, little-endian string) pairs. + std::vector, std::string>> get_terms_as_strings( + double threshold, std::size_t num_qubits) const { + std::vector, std::string>> result; result.reserve(terms_.size()); for (const auto& [pw, coeff] : terms_) { if (std::abs(coeff) >= threshold) { - result.emplace_back(coeff, packed_to_sparse(pw)); + result.emplace_back(coeff, packed_to_le_string(pw, num_qubits)); } } return result; @@ -165,6 +180,32 @@ class PackedAccumulator { terms_; }; +/// Convert a PackedPauliWord directly to a dense little-endian string, +/// skipping the intermediate SparsePauliWord allocation. +template +std::string packed_to_le_string(const PackedPauliWord& pw, + std::size_t num_qubits) { + std::string result(num_qubits, 'I'); + for (std::size_t wi = 0; wi < NW; ++wi) { + std::uint64_t x = pw.x[wi]; + std::uint64_t z = pw.z[wi]; + std::uint64_t active = x | z; + while (active) { + int bit = __builtin_ctzll(active); + std::uint64_t mask = std::uint64_t(1) << bit; + std::size_t qubit = wi * 64 + bit; + if (qubit < num_qubits) { + bool has_x = (x & mask) != 0; + bool has_z = (z & mask) != 0; + // Little-endian: qubit 0 = rightmost + result[num_qubits - 1 - qubit] = has_x ? (has_z ? 'Y' : 'X') : 'Z'; + } + active &= ~mask; + } + } + return result; +} + /// Convert a SparsePauliWord to a dense little-endian string. /// (qubit 0 = rightmost character) std::string sparse_to_le_string(const SparsePauliWord& word, @@ -225,9 +266,9 @@ MajoranaMapResult majorana_map_impl( double h_pq) { for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { - auto [phase, word] = multiply_packed( + auto [ph, word] = multiply_packed( packed_mapping[2 * mode_p + a], packed_mapping[2 * mode_q + b]); - acc.accumulate(word, h_pq * kQuarter * kC[a][b] * phase); + acc.accumulate(word, apply_phase(ph, h_pq * kQuarter * kC[a][b])); } } }; @@ -280,7 +321,7 @@ MajoranaMapResult majorana_map_impl( // Precompute Majorana pair products in packed form (same-spin only). struct PackedPairProduct { - std::complex phase; + int phase; // phase index (0..3): 0=+1, 1=+i, 2=-1, 3=-i PackedPauliWord word; }; const std::size_t maj_per_spin = 2 * n_spatial; @@ -322,9 +363,9 @@ MajoranaMapResult majorana_map_impl( for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { const auto& [phase_a, word_a] = alpha_pair(2*p+a, 2*q+b); - sse.terms.emplace_back(kQuarter * kC[a][b] * phase_a, word_a); + sse.terms.emplace_back(apply_phase(phase_a, kQuarter * kC[a][b]), word_a); const auto& [phase_b, word_b] = beta_pair(2*p+a, 2*q+b); - sse.terms.emplace_back(kQuarter * kC[a][b] * phase_b, word_b); + sse.terms.emplace_back(apply_phase(phase_b, kQuarter * kC[a][b]), word_b); } } } @@ -365,21 +406,41 @@ MajoranaMapResult majorana_map_impl( } } - // Two-body product: Σ_{p≤q, r≤s} (pq|rs) S_pq · S_rs + // Two-body product: exploit full 8-fold ERI symmetry. + // Already using p≤q, r≤s (4-fold). Now add (pq)≤(rs) exchange: + // (pq|rs) = (rs|pq), so S_pq·S_rs + S_rs·S_pq covers both. + // For pq_idx < rs_idx: accumulate both products with scale 2×½·eri. + // For pq_idx == rs_idx: accumulate one product with scale ½·eri. for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t q = p; q < n_spatial; ++q) { - const auto& s_pq = sym_e[sym_map[p * n_spatial + q]]; + std::size_t pq_idx = sym_map[p * n_spatial + q]; + const auto& s_pq = sym_e[pq_idx]; for (std::size_t r = 0; r < n_spatial; ++r) { for (std::size_t s = r; s < n_spatial; ++s) { + std::size_t rs_idx = sym_map[r * n_spatial + s]; + if (pq_idx > rs_idx) continue; // skip; (rs,pq) handles this + double eri = eri_aaaa[idx4(p, q, r, s)]; if (std::abs(eri) < integral_threshold) continue; - double half_eri = 0.5 * eri; - const auto& s_rs = sym_e[sym_map[r * n_spatial + s]]; + const auto& s_rs = sym_e[rs_idx]; - for (const auto& [c1, w1] : s_pq.terms) { - for (const auto& [c2, w2] : s_rs.terms) { - acc.accumulate_product(w1, w2, half_eri * c1 * c2); + if (pq_idx == rs_idx) { + // Diagonal: S_pq · S_pq (single product) + double half_eri = 0.5 * eri; + for (const auto& [c1, w1] : s_pq.terms) { + for (const auto& [c2, w2] : s_rs.terms) { + acc.accumulate_product(w1, w2, half_eri * c1 * c2); + } + } + } else { + // Off-diagonal: S_pq · S_rs + S_rs · S_pq (both products) + double half_eri = 0.5 * eri; + for (const auto& [c1, w1] : s_pq.terms) { + for (const auto& [c2, w2] : s_rs.terms) { + acc.accumulate_product(w1, w2, half_eri * c1 * c2); + acc.accumulate_product(w2, w1, half_eri * c2 * c1); + } } } } @@ -413,9 +474,10 @@ MajoranaMapResult majorana_map_impl( for (int d = 0; d < 2; ++d) { const auto& [ph2, w2] = cache_rs[(2*br+c) * maj_per_spin + (2*bs+d)]; - acc.accumulate_product( - w1, w2, - half_eri * kSixteenth * kC[a][b] * kC[c][d] * ph1 * ph2); + std::complex scale = + apply_phase((ph1 + ph2) & 3, + half_eri * kSixteenth * kC[a][b] * kC[c][d]); + acc.accumulate_product(w1, w2, scale); } } } @@ -485,16 +547,15 @@ MajoranaMapResult majorana_map_impl( } } - // ─── Extract results ────────────────────────────────────────────── - auto terms = acc.get_terms(threshold); + // ─── Extract results directly as strings ──────────────────────── + auto terms = acc.get_terms_as_strings(threshold, num_qubits); MajoranaMapResult result; result.pauli_strings.reserve(terms.size()); result.coefficients.reserve(terms.size()); - for (auto& [coeff, word] : terms) { - result.pauli_strings.push_back( - sparse_to_le_string(word, num_qubits)); + for (auto& [coeff, str] : terms) { + result.pauli_strings.push_back(std::move(str)); result.coefficients.push_back(coeff); } From 3c09e3f880875c4db84479682ad309f155678dca Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sun, 10 May 2026 23:33:15 +0000 Subject: [PATCH 072/117] fix: docstring naming, mapping size validation, test coverage Documentation fixes: - All docstrings now use hyphenated encoding names (jordan-wigner, bravyi-kitaev) matching the actual factory output. Previously some docstrings showed underscore names which would confuse users. - Replace Unicode gamma/multiply characters with ASCII equivalents to satisfy ruff RUF001/RUF003 rules. Robustness: - QdkQubitMapper validates mapping.num_modes == 2*n_spatial before calling the C++ engine. Raises ValueError with clear message for mismatched sizes. Test coverage (173 tests total): - Mapping size validation: oversized and undersized mappings rejected - Custom mapping end-to-end: unnamed MajoranaMapping(table=[...]) produces bit-exact match with factory output through QdkQubitMapper - Parity encoding end-to-end: eigenvalue consistency with JW - Plugin name dispatch: QDK accepts unnamed, Qiskit/OF reject unnamed - Qiskit plugin tests updated for new signature - OpenFermion plugin tests updated for new signature (SCBK removed) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../qdk/chemistry/data/majorana_mapping.hpp | 6 +- python/src/pybind11/data/majorana_mapping.cpp | 4 +- .../qubit_mapper/qdk_qubit_mapper.py | 8 + .../qdk_chemistry/data/majorana_mapping.py | 20 +- .../plugins/openfermion/qubit_mapper.py | 10 +- .../plugins/qiskit/qubit_mapper.py | 4 +- python/tests/test_encoding_metadata.py | 21 ++ .../test_interop_openfermion_qubit_mapper.py | 144 ++++-------- .../tests/test_interop_qiskit_qubit_mapper.py | 16 +- python/tests/test_majorana_mapping.py | 56 ++--- python/tests/test_qdk_qubit_mapper.py | 219 +++++++++++++++++- 11 files changed, 342 insertions(+), 166 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index 3bce5991a..ac78be47c 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -41,7 +41,7 @@ class MajoranaMapping { * @param table Vector of 2N SparsePauliWord entries (one per Majorana * operator γ_0, γ_1, ..., γ_{2N-1}). * @param name Optional human-readable label for the encoding (e.g., - * "jordan_wigner"). Stored but not used for dispatch. + * "jordan-wigner"). Stored but not used for dispatch. * @throws std::invalid_argument If table size is odd, empty, or the * Clifford algebra validation fails. */ @@ -101,7 +101,7 @@ class MajoranaMapping { * * @param num_modes Number of fermionic modes (spin-orbitals). * The encoding uses num_modes qubits. - * @return MajoranaMapping with name "jordan_wigner". + * @return MajoranaMapping with name "jordan-wigner". */ static MajoranaMapping jordan_wigner(std::size_t num_modes); @@ -116,7 +116,7 @@ class MajoranaMapping { * * @param num_modes Number of fermionic modes (spin-orbitals). * The encoding uses num_modes qubits. - * @return MajoranaMapping with name "bravyi_kitaev". + * @return MajoranaMapping with name "bravyi-kitaev". */ static MajoranaMapping bravyi_kitaev(std::size_t num_modes); diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index 49da9659c..9485ffdd0 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -314,7 +314,7 @@ Construct a Jordan-Wigner encoding. num_modes: Number of fermionic modes (spin-orbitals). Returns: - MajoranaMapping with name "jordan_wigner". + MajoranaMapping with name "jordan-wigner". )"); mapping.def_static( @@ -334,7 +334,7 @@ Construct a Bravyi-Kitaev encoding. num_modes: Number of fermionic modes (spin-orbitals). Returns: - MajoranaMapping with name "bravyi_kitaev". + MajoranaMapping with name "bravyi-kitaev". )"); mapping.def_static( diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index bcc9654c9..96fa77413 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -125,8 +125,16 @@ def _run_impl( h1_alpha, h1_beta = hamiltonian.get_one_body_integrals() h2_aaaa, h2_aabb, h2_bbbb = hamiltonian.get_two_body_integrals() n_spatial = h1_alpha.shape[0] + n_spin_orbitals = 2 * n_spatial is_restricted = hamiltonian.get_orbitals().is_restricted() + if mapping.num_modes != n_spin_orbitals: + raise ValueError( + f"MajoranaMapping has {mapping.num_modes} modes but the Hamiltonian has " + f"{n_spin_orbitals} spin-orbitals (2 x {n_spatial} spatial orbitals). " + f"Use MajoranaMapping.jordan_wigner(num_modes={n_spin_orbitals}) or equivalent." + ) + # Use ravel() instead of flatten() to avoid copying contiguous arrays. # For restricted Hamiltonians the containers share the same two-body # vector across aaaa/aabb/bbbb, so pass the same array to avoid diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index 7c53c0704..794ded085 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -14,9 +14,10 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING, Any +import numpy as np + from qdk_chemistry._core.data import MajoranaMapping as _CoreMajoranaMapping from qdk_chemistry.data.base import DataClass @@ -30,12 +31,12 @@ class MajoranaMapping(DataClass): """Immutable data class mapping 2N Majorana operators to Pauli strings. A ``MajoranaMapping`` stores a table of 2N entries, one per Majorana operator - γ_k (k = 0, ..., 2N-1), where N is the number of fermionic modes (spin-orbitals). - Each entry is a single Pauli string representing φ(γ_k) under the chosen + gamma_k (k = 0, ..., 2N-1), where N is the number of fermionic modes (spin-orbitals). + Each entry is a single Pauli string representing φ(gamma_k) under the chosen fermion-to-qubit encoding. The mapping is validated at construction time by checking the Clifford algebra - anticommutation relations: {γ_i, γ_j} = 2δ_{ij} · I. This guarantees that the + anticommutation relations: {gamma_i, gamma_j} = 2δ_{ij} · I. This guarantees that the mapping defines a valid encoding. Pauli strings use the same **little-endian** convention as @@ -81,7 +82,7 @@ def __init__( name (str): Optional human-readable label for the encoding. Default ``""``. Raises: - ValueError: If table size is odd, empty, strings differ in length, contain non-IXYZ characters, or the Clifford algebra validation fails. + ValueError: If the table is invalid (wrong size, bad characters, or Clifford algebra violation). """ if _core is not None: @@ -134,7 +135,7 @@ def jordan_wigner(cls, num_modes: int) -> MajoranaMapping: num_modes (int): Number of fermionic modes (spin-orbitals). Returns: - MajoranaMapping: Mapping with name ``"jordan_wigner"``. + MajoranaMapping: Mapping with name ``"jordan-wigner"``. """ core = _CoreMajoranaMapping.jordan_wigner(num_modes) @@ -174,10 +175,10 @@ def from_mode_pairs( pairs: list[tuple[str, str]], name: str = "", ) -> MajoranaMapping: - """Construct from (γ_even, γ_odd) mode pairs. + """Construct from (gamma_even, gamma_odd) mode pairs. Args: - pairs (list[tuple[str, str]]): List of (γ_{2k}, γ_{2k+1}) Pauli string pairs, one per mode. + pairs (list[tuple[str, str]]): List of (gamma_{2k}, gamma_{2k+1}) Pauli string pairs, one per mode. name (str): Optional human-readable label. Default ``""``. Returns: @@ -210,7 +211,7 @@ def get_summary(self) -> str: lines.append(label) lines.append(f" Modes: {self._num_modes}, Qubits: {self._num_qubits}") for k, pauli_str in enumerate(self._table): - lines.append(f" γ_{k} → {pauli_str}") + lines.append(f" gamma_{k} → {pauli_str}") return "\n".join(lines) def to_json(self) -> dict[str, Any]: @@ -254,7 +255,6 @@ def to_hdf5(self, group: h5py.Group) -> None: group.attrs["name"] = self._name group.attrs["num_modes"] = self._num_modes # Store table as array of strings - import numpy as np group.create_dataset("table", data=np.array(list(self._table), dtype="S")) diff --git a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py index fc852bbe9..4b5c26eb0 100644 --- a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py @@ -56,9 +56,9 @@ class OpenFermionQubitMapper(QubitMapper): supported — use the QDK variant instead. Supported ``mapping.name`` values: - - ``"jordan_wigner"`` - - ``"bravyi_kitaev"`` - - ``"bravyi_kitaev_tree"`` + - ``"jordan-wigner"`` + - ``"bravyi-kitaev"`` + - ``"bravyi-kitaev-tree"`` """ @@ -126,8 +126,8 @@ def _map_standard(self, hamiltonian: Hamiltonian, encoding: str) -> of.QubitOper Args: hamiltonian: The fermionic Hamiltonian. - encoding: One of ``"jordan_wigner"``, ``"bravyi_kitaev"``, - or ``"bravyi_kitaev_tree"``. + encoding: One of ``"jordan-wigner"``, ``"bravyi-kitaev"``, + or ``"bravyi-kitaev-tree"``. Returns: openfermion.QubitOperator: The mapped qubit operator. diff --git a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py index 82283b9b6..e0ea4d27b 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py @@ -55,8 +55,8 @@ class QiskitQubitMapper(QubitMapper): supported — use the QDK variant instead. Supported ``mapping.name`` values: - - ``"jordan_wigner"`` - - ``"bravyi_kitaev"`` + - ``"jordan-wigner"`` + - ``"bravyi-kitaev"`` - ``"parity"`` """ diff --git a/python/tests/test_encoding_metadata.py b/python/tests/test_encoding_metadata.py index a72cdd72e..7cd26a4af 100644 --- a/python/tests/test_encoding_metadata.py +++ b/python/tests/test_encoding_metadata.py @@ -330,3 +330,24 @@ def test_qiskit_qubit_mapper_sets_fermion_mode_order(): mapping = factory(n_modes) qh = create("qubit_mapper", "qiskit").run(hamiltonian, mapping) assert qh.fermion_mode_order == FermionModeOrder.BLOCKED + + +def test_unnamed_mapping_dispatch(): + """QDK mapper accepts unnamed custom mappings; Qiskit mapper rejects them.""" + hamiltonian = create_test_hamiltonian(2) + n_modes = 2 * 2 + + jw = MajoranaMapping.jordan_wigner(num_modes=n_modes) + unnamed = MajoranaMapping(table=list(jw.table), name="") + assert unnamed.name == "" + + # QDK mapper works fine with unnamed mapping + qdk_mapper = create("qubit_mapper", "qdk") + result = qdk_mapper.run(hamiltonian, unnamed) + assert isinstance(result, QubitHamiltonian) + + # Qiskit mapper rejects unnamed mapping + if QDK_CHEMISTRY_HAS_QISKIT_NATURE: + qiskit_mapper = create("qubit_mapper", "qiskit") + with pytest.raises(NotImplementedError): + qiskit_mapper.run(hamiltonian, unnamed) diff --git a/python/tests/test_interop_openfermion_qubit_mapper.py b/python/tests/test_interop_openfermion_qubit_mapper.py index f86101f49..4410e39e6 100644 --- a/python/tests/test_interop_openfermion_qubit_mapper.py +++ b/python/tests/test_interop_openfermion_qubit_mapper.py @@ -16,7 +16,7 @@ from .reference_tolerances import ( float_comparison_absolute_tolerance, ) -from .test_helpers import create_nontrivial_test_hamiltonian, create_test_ansatz, create_test_wavefunction +from .test_helpers import create_nontrivial_test_hamiltonian OPENFERMION_AVAILABLE = importlib.util.find_spec("openfermion") is not None @@ -24,7 +24,7 @@ import openfermion as of from qdk_chemistry.algorithms import QubitMapper, available, create - from qdk_chemistry.data import Symmetries + from qdk_chemistry.data import MajoranaMapping, Symmetries from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.plugins.openfermion.conversion import ( hamiltonian_to_fermion_operator, @@ -33,12 +33,22 @@ qubit_operator_to_qubit_hamiltonian, ) + _ENCODING_TO_MAPPING = { + "jordan-wigner": MajoranaMapping.jordan_wigner, + "bravyi-kitaev": MajoranaMapping.bravyi_kitaev, + } + if TYPE_CHECKING: - from qdk_chemistry.data import QubitHamiltonian + from qdk_chemistry.data import Hamiltonian, QubitHamiltonian pytestmark = pytest.mark.skipif(not OPENFERMION_AVAILABLE, reason="OpenFermion not available") +def _num_spin_orbitals(hamiltonian: Hamiltonian) -> int: + """Return the number of spin-orbitals in *hamiltonian*.""" + return 2 * hamiltonian.get_one_body_integrals()[0].shape[0] + + def _assert_pauli_ops_equal(actual: QubitHamiltonian, expected: QubitHamiltonian) -> None: """Assert two QubitHamiltonians have identical Pauli terms and coefficients.""" assert actual.equiv(expected, atol=float_comparison_absolute_tolerance), ( @@ -61,7 +71,6 @@ def test_openfermion_mapper_create(): mapper = create("qubit_mapper", "openfermion") assert isinstance(mapper, QubitMapper) assert mapper.name() == "openfermion" - assert mapper.settings().get("encoding") == "jordan-wigner" # ------------------------------------------------------------------------------------- @@ -73,9 +82,11 @@ def test_openfermion_mapper_create(): def test_openfermion_matches_qdk_native(encoding): """OpenFermion and QDK native mappers produce identical Pauli terms.""" hamiltonian = create_nontrivial_test_hamiltonian() + n = _num_spin_orbitals(hamiltonian) + mapping = _ENCODING_TO_MAPPING[encoding](n) - of_qh = create("qubit_mapper", "openfermion", encoding=encoding).run(hamiltonian) - qdk_qh = create("qubit_mapper", "qdk", encoding=encoding).run(hamiltonian) + of_qh = create("qubit_mapper", "openfermion").run(hamiltonian, mapping) + qdk_qh = create("qubit_mapper", "qdk").run(hamiltonian, mapping) _assert_pauli_ops_equal(of_qh, qdk_qh) @@ -88,8 +99,10 @@ def test_openfermion_bk_tree_encoding(): and removing core energy. """ hamiltonian = create_nontrivial_test_hamiltonian() + n = _num_spin_orbitals(hamiltonian) + mapping = MajoranaMapping(table=list(MajoranaMapping.jordan_wigner(n).table), name="bravyi-kitaev-tree") - qh = create("qubit_mapper", "openfermion", encoding="bravyi-kitaev-tree").run(hamiltonian) + qh = create("qubit_mapper", "openfermion").run(hamiltonian, mapping) # Build reference independently: blocked InteractionOperator → FermionOp → BK-tree iop = hamiltonian_to_interaction_operator(hamiltonian) @@ -116,106 +129,35 @@ def test_openfermion_bk_tree_encoding(): # ------------------------------------------------------------------------------------- -# Non-standard encodings (SCBK) +# Error handling # ------------------------------------------------------------------------------------- -def test_openfermion_scbk_encoding(): - """SCBK encoding produces a QubitHamiltonian with correct Pauli terms.""" - hamiltonian = create_nontrivial_test_hamiltonian() - - mapper = create( - "qubit_mapper", - "openfermion", - encoding="symmetry-conserving-bravyi-kitaev", - ) - symmetries = Symmetries(n_alpha=1, n_beta=1) - qh = mapper.run(hamiltonian, symmetries) - - # Reference: apply SCBK directly - fop = hamiltonian_to_fermion_operator(hamiltonian) - ref_qop = of.transforms.symmetry_conserving_bravyi_kitaev(fop, 4, 2) - ref_qop.compress() - - # Remove core energy from reference (same convention as plugin) - core_energy = hamiltonian.get_core_energy() - if abs(core_energy) > 1e-15: - ref_qop -= core_energy * of.QubitOperator(()) - ref_qop.compress() - - ref_qh = qubit_operator_to_qubit_hamiltonian(ref_qop, encoding="symmetry-conserving-bravyi-kitaev") - - _assert_pauli_ops_equal(qh, ref_qh) - - -def test_openfermion_scbk_requires_symmetries(): - """SCBK encoding raises ValueError when symmetries are not provided.""" - hamiltonian = create_nontrivial_test_hamiltonian() - - mapper = create( - "qubit_mapper", - "openfermion", - encoding="symmetry-conserving-bravyi-kitaev", - ) - with pytest.raises(ValueError, match="Symmetries"): - mapper.run(hamiltonian) - - -def test_openfermion_scbk_from_wavefunction(): - """SCBK encoding works with Symmetries constructed from a Wavefunction.""" +def test_openfermion_unsupported_mapping_raises(): + """A mapping with an unsupported name raises NotImplementedError.""" hamiltonian = create_nontrivial_test_hamiltonian() + n = _num_spin_orbitals(hamiltonian) + mapping = MajoranaMapping(table=list(MajoranaMapping.jordan_wigner(n).table), name="invalid-encoding") - wfn = create_test_wavefunction(num_orbitals=2) - symmetries = Symmetries.from_wavefunction(wfn) - - mapper = create( - "qubit_mapper", - "openfermion", - encoding="symmetry-conserving-bravyi-kitaev", - ) - qh = mapper.run(hamiltonian, symmetries) - - # Should produce a valid QubitHamiltonian with reduced qubit count - assert qh.num_qubits == 2 # 4 spin-orbitals - 2 from SCBK - - -def test_openfermion_scbk_from_ansatz(): - """SCBK encoding works with Symmetries constructed from an Ansatz.""" - hamiltonian = create_nontrivial_test_hamiltonian() - - ansatz = create_test_ansatz(num_orbitals=2) - symmetries = Symmetries.from_ansatz(ansatz) - - mapper = create( - "qubit_mapper", - "openfermion", - encoding="symmetry-conserving-bravyi-kitaev", - ) - qh = mapper.run(hamiltonian, symmetries) - - # Should produce a valid QubitHamiltonian with reduced qubit count - assert qh.num_qubits == 2 # 4 spin-orbitals - 2 from SCBK + mapper = create("qubit_mapper", "openfermion") + with pytest.raises(NotImplementedError, match="invalid-encoding"): + mapper.run(hamiltonian, mapping) -def test_openfermion_scbk_symmetries_ignored_for_jw(): - """Symmetries parameter is accepted but ignored for non-SCBK encodings.""" +def test_symmetries_ignored_for_standard_encodings(): + """Symmetries parameter is accepted but ignored for standard encodings.""" hamiltonian = create_nontrivial_test_hamiltonian() + n = _num_spin_orbitals(hamiltonian) + mapping = MajoranaMapping.jordan_wigner(n) symmetries = Symmetries(n_alpha=1, n_beta=1) # Should work without error - symmetries is simply ignored - qh_with = create("qubit_mapper", "openfermion", encoding="jordan-wigner").run(hamiltonian, symmetries) - qh_without = create("qubit_mapper", "openfermion", encoding="jordan-wigner").run(hamiltonian) + qh_with = create("qubit_mapper", "openfermion").run(hamiltonian, mapping, symmetries) + qh_without = create("qubit_mapper", "openfermion").run(hamiltonian, mapping) _assert_pauli_ops_equal(qh_with, qh_without) -def test_openfermion_invalid_encoding(): - """An invalid encoding raises ValueError.""" - mapper = create("qubit_mapper", "openfermion") - with pytest.raises(ValueError, match="out of allowed options"): - mapper.settings().set("encoding", "invalid-encoding") - - # ------------------------------------------------------------------------------------- # Conversion utilities: Hamiltonian → OpenFermion # ------------------------------------------------------------------------------------- @@ -330,16 +272,10 @@ def test_full_pipeline_round_trip(): def test_openfermion_standard_sets_blocked_order(encoding): """Standard OpenFermion encodings set fermion_mode_order to BLOCKED.""" hamiltonian = create_nontrivial_test_hamiltonian() - qh = create("qubit_mapper", "openfermion", encoding=encoding).run(hamiltonian) + n = _num_spin_orbitals(hamiltonian) + if encoding in _ENCODING_TO_MAPPING: + mapping = _ENCODING_TO_MAPPING[encoding](n) + else: + mapping = MajoranaMapping(table=list(MajoranaMapping.jordan_wigner(n).table), name=encoding) + qh = create("qubit_mapper", "openfermion").run(hamiltonian, mapping) assert qh.fermion_mode_order == FermionModeOrder.BLOCKED - - -def test_openfermion_scbk_sets_interleaved_order(): - """SCBK encoding sets fermion_mode_order to INTERLEAVED.""" - hamiltonian = create_nontrivial_test_hamiltonian() - symmetries = Symmetries(n_alpha=1, n_beta=1) - qh = create("qubit_mapper", "openfermion", encoding="symmetry-conserving-bravyi-kitaev").run( - hamiltonian, - symmetries, - ) - assert qh.fermion_mode_order == FermionModeOrder.INTERLEAVED diff --git a/python/tests/test_interop_qiskit_qubit_mapper.py b/python/tests/test_interop_qiskit_qubit_mapper.py index 5d89e7399..6fa72578c 100644 --- a/python/tests/test_interop_qiskit_qubit_mapper.py +++ b/python/tests/test_interop_qiskit_qubit_mapper.py @@ -18,7 +18,7 @@ if QDK_CHEMISTRY_HAS_QISKIT_NATURE: from qdk_chemistry.algorithms import QubitMapper, available, create - from qdk_chemistry.data import Hamiltonian, QubitHamiltonian + from qdk_chemistry.data import Hamiltonian, MajoranaMapping, QubitHamiltonian pytestmark = pytest.mark.skipif(not QDK_CHEMISTRY_HAS_QISKIT_NATURE, reason="Qiskit Nature not available") @@ -27,15 +27,19 @@ def test_qiskit_qubit_mappers(encoding) -> None: """Basic test for mapping a Hamiltonian to a Qubit Hamiltonian using Qiskit.""" assert "qiskit" in available("qubit_mapper") - qubit_mapper = create("qubit_mapper", "qiskit", encoding="jordan-wigner") + qubit_mapper = create("qubit_mapper", "qiskit") assert isinstance(qubit_mapper, QubitMapper) - assert qubit_mapper.settings().get("encoding") == "jordan-wigner" - qubit_mapper.settings().set("encoding", encoding) - assert qubit_mapper.settings().get("encoding") == encoding hamiltonian = create_test_hamiltonian(2) assert isinstance(hamiltonian, Hamiltonian) - qubit_hamiltonian = qubit_mapper.run(hamiltonian) + n_modes = 2 * 2 # 2 spatial orbitals → 4 spin-orbitals + factory = { + "jordan-wigner": MajoranaMapping.jordan_wigner, + "bravyi-kitaev": MajoranaMapping.bravyi_kitaev, + "parity": MajoranaMapping.parity, + }[encoding] + mapping = factory(n_modes) + qubit_hamiltonian = qubit_mapper.run(hamiltonian, mapping) assert isinstance(qubit_hamiltonian, QubitHamiltonian) assert qubit_hamiltonian.num_qubits == 4 assert isinstance(qubit_hamiltonian.pauli_strings, list) diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index bbb7f4f68..13932fb0c 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -20,18 +20,16 @@ from pathlib import Path import h5py -import numpy as np import pytest from qdk_chemistry._core.data import PauliTermAccumulator from qdk_chemistry.data import MajoranaMapping - # ─── Helpers ───────────────────────────────────────────────────────────── def verify_clifford_algebra(mapping: MajoranaMapping) -> None: - """Verify {γ_i, γ_j} = 2δ_{ij}·I for all pairs.""" + """Verify {gamma_i, gamma_j} = 2δ_{ij}·I for all pairs.""" n = 2 * mapping.num_modes for i in range(n): wi = mapping.core(i) @@ -41,12 +39,12 @@ def verify_clifford_algebra(mapping: MajoranaMapping) -> None: phase_ji, word_ji = PauliTermAccumulator.multiply_uncached(wj, wi) if i == j: - assert word_ij == [], f"γ_{i}² is not identity: word={word_ij}" - assert abs(phase_ij - 1.0) < 1e-12, f"γ_{i}² phase={phase_ij}, expected 1" + assert word_ij == [], f"gamma_{i}² is not identity: word={word_ij}" + assert abs(phase_ij - 1.0) < 1e-12, f"gamma_{i}² phase={phase_ij}, expected 1" else: - assert word_ij == word_ji, f"γ_{i}·γ_{j} and γ_{j}·γ_{i} produce different words" + assert word_ij == word_ji, f"gamma_{i}·gamma_{j} and gamma_{j}·gamma_{i} produce different words" assert abs(phase_ij + phase_ji) < 1e-12, ( - f"{{γ_{i}, γ_{j}}} != 0: phases {phase_ij} + {phase_ji} = {phase_ij + phase_ji}" + f"{{gamma_{i}, gamma_{j}}} != 0: phases {phase_ij} + {phase_ji} = {phase_ij + phase_ji}" ) @@ -73,14 +71,14 @@ def test_properties(self) -> None: def test_reference_n2(self) -> None: """JW n=2 matches hand-computed reference (little-endian).""" jw = MajoranaMapping.jordan_wigner(num_modes=2) - # γ_0 = X_0 → "IX" (qubit 0 rightmost = X, qubit 1 = I) - # γ_1 = Y_0 → "IY" - # γ_2 = Z_0 X_1 → "XZ" (qubit 0 = Z, qubit 1 = X) - # γ_3 = Z_0 Y_1 → "YZ" + # gamma_0 = X_0 → "IX" (qubit 0 rightmost = X, qubit 1 = I) + # gamma_1 = Y_0 → "IY" + # gamma_2 = Z_0 X_1 → "XZ" (qubit 0 = Z, qubit 1 = X) + # gamma_3 = Z_0 Y_1 → "YZ" assert jw.table == ("IX", "IY", "XZ", "YZ") def test_reference_n4(self) -> None: - """JW n=4 spot check: γ_6 = Z_0 Z_1 Z_2 X_3.""" + """JW n=4 spot check: gamma_6 = Z_0 Z_1 Z_2 X_3.""" jw = MajoranaMapping.jordan_wigner(num_modes=4) assert jw.table[6] == "XZZZ" # X_3 Z_2 Z_1 Z_0 in little-endian assert jw.table[7] == "YZZZ" # Y_3 Z_2 Z_1 Z_0 @@ -130,10 +128,10 @@ def test_reference_n2(self) -> None: """Parity n=2 matches CNOT-derived reference.""" par = MajoranaMapping.parity(num_modes=2) # Derived via CNOT(0,1) conjugation of JW: - # γ_0 = X_0 X_1 → "XX" - # γ_1 = Y_0 X_1 → "XY" (little-endian: qubit 0=Y, qubit 1=X) - # γ_2 = Z_0 X_1 → "XZ" - # γ_3 = Y_1 → "YI" + # gamma_0 = X_0 X_1 → "XX" + # gamma_1 = Y_0 X_1 → "XY" (little-endian: qubit 0=Y, qubit 1=X) + # gamma_2 = Z_0 X_1 → "XZ" + # gamma_3 = Y_1 → "YI" assert par.table == ("XX", "XY", "XZ", "YI") def test_reference_n4(self) -> None: @@ -165,17 +163,13 @@ def test_custom_unnamed(self) -> None: def test_from_mode_pairs(self) -> None: """from_mode_pairs produces same result as direct table.""" direct = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"], name="test") - pairs = MajoranaMapping.from_mode_pairs( - pairs=[("IX", "IY"), ("XZ", "YZ")], name="test" - ) + pairs = MajoranaMapping.from_mode_pairs(pairs=[("IX", "IY"), ("XZ", "YZ")], name="test") assert direct.table == pairs.table def test_from_mode_pairs_equivalence(self) -> None: """from_mode_pairs matches JW factory for n=2.""" jw = MajoranaMapping.jordan_wigner(num_modes=2) - pairs = MajoranaMapping.from_mode_pairs( - pairs=[("IX", "IY"), ("XZ", "YZ")], name="jordan-wigner" - ) + pairs = MajoranaMapping.from_mode_pairs(pairs=[("IX", "IY"), ("XZ", "YZ")], name="jordan-wigner") assert jw.table == pairs.table @@ -207,17 +201,17 @@ def test_inconsistent_lengths(self) -> None: def test_clifford_violation(self) -> None: """Table violating Clifford algebra raises ValueError.""" - # γ_0 = γ_1 = IX → they commute (shouldn't) + # gamma_0 = gamma_1 = IX → they commute (shouldn't) with pytest.raises(ValueError, match="Clifford"): MajoranaMapping(table=["IX", "IX", "XZ", "YZ"]) def test_zero_modes(self) -> None: """Zero modes raises ValueError in factories.""" - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="num_modes"): MajoranaMapping.jordan_wigner(num_modes=0) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="num_modes"): MajoranaMapping.bravyi_kitaev(num_modes=0) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="num_modes"): MajoranaMapping.parity(num_modes=0) @@ -300,9 +294,7 @@ def test_hdf5_file_round_trip(self) -> None: def test_custom_serialization(self) -> None: """Custom mapping with name survives serialization.""" - custom = MajoranaMapping( - table=["IX", "IY", "XZ", "YZ"], name="my-custom" - ) + custom = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"], name="my-custom") data = custom.to_json() loaded = MajoranaMapping.from_json(data) assert loaded.table == custom.table @@ -316,14 +308,14 @@ class TestDisplay: """Tests for summary and repr.""" def test_repr_with_name(self) -> None: - """repr includes name when present.""" + """Repr includes name when present.""" jw = MajoranaMapping.jordan_wigner(num_modes=2) r = repr(jw) assert "jordan-wigner" in r assert "num_modes=2" in r def test_repr_without_name(self) -> None: - """repr works for unnamed custom mappings.""" + """Repr works for unnamed custom mappings.""" custom = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"]) r = repr(custom) assert "num_modes=2" in r @@ -334,7 +326,7 @@ def test_get_summary(self) -> None: s = jw.get_summary() assert "jordan-wigner" in s assert "Modes: 2" in s - assert "γ_0" in s + assert "gamma_0" in s assert "IX" in s diff --git a/python/tests/test_qdk_qubit_mapper.py b/python/tests/test_qdk_qubit_mapper.py index 9060389b8..97495fe1a 100644 --- a/python/tests/test_qdk_qubit_mapper.py +++ b/python/tests/test_qdk_qubit_mapper.py @@ -11,10 +11,21 @@ import pytest from qdk_chemistry.algorithms import QubitMapper, available, create -from qdk_chemistry.data import CanonicalFourCenterHamiltonianContainer, Hamiltonian, MajoranaMapping, QubitHamiltonian +from qdk_chemistry.data import ( + CanonicalFourCenterHamiltonianContainer, + Hamiltonian, + MajoranaMapping, + Orbitals, + QubitHamiltonian, +) from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT_NATURE -from .test_helpers import create_test_hamiltonian, create_test_orbitals +from .test_helpers import ( + create_nontrivial_test_hamiltonian, + create_test_basis_set, + create_test_hamiltonian, + create_test_orbitals, +) @pytest.fixture @@ -454,6 +465,210 @@ def test_four_orbital_z_string(self) -> None: f"Coefficient mismatch for {pauli_str}: got {pauli_dict[pauli_str].real}, expected {expected_coeff}" ) + def test_mapping_size_mismatch_raises(self) -> None: + """Test that mismatched mapping size raises ValueError.""" + hamiltonian = create_test_hamiltonian(2) # 2 spatial → 4 spin-orbitals + mapper = create("qubit_mapper", "qdk") + + # Oversized mapping (6 modes for 4-spin-orbital Hamiltonian) + mapping_over = MajoranaMapping.jordan_wigner(num_modes=6) + with pytest.raises(ValueError, match="modes"): + mapper.run(hamiltonian, mapping_over) + + # Undersized mapping (2 modes for 4-spin-orbital Hamiltonian) + mapping_under = MajoranaMapping.jordan_wigner(num_modes=2) + with pytest.raises(ValueError, match="modes"): + mapper.run(hamiltonian, mapping_under) + + def test_custom_mapping_end_to_end(self) -> None: + """Test that a custom MajoranaMapping from JW table matches JW factory output bit-exactly.""" + mapper = create("qubit_mapper", "qdk") + hamiltonian = create_test_hamiltonian(2) + n_modes = 2 * 2 + + jw = MajoranaMapping.jordan_wigner(num_modes=n_modes) + custom = MajoranaMapping(table=list(jw.table), name="") + + result_jw = mapper.run(hamiltonian, jw) + result_custom = mapper.run(hamiltonian, custom) + + assert result_jw.pauli_strings == result_custom.pauli_strings + assert np.array_equal(result_jw.coefficients, result_custom.coefficients) + + def test_parity_end_to_end(self) -> None: + """Test parity mapping produces correct qubits, is Hermitian, and matches JW eigenvalues.""" + hamiltonian = create_nontrivial_test_hamiltonian(2) + n_modes = 2 * 2 + + mapper = create("qubit_mapper", "qdk") + mapping_parity = MajoranaMapping.parity(num_modes=n_modes) + result_parity = mapper.run(hamiltonian, mapping_parity) + + # Correct number of qubits + assert result_parity.num_qubits == n_modes + + # All coefficients must be real (Hermitian Hamiltonian) + for coeff in result_parity.coefficients: + assert np.isclose(coeff.imag, 0.0, atol=1e-10), f"Non-real coefficient: {coeff}" + + # Eigenvalues must match JW + mapping_jw = MajoranaMapping.jordan_wigner(num_modes=n_modes) + result_jw = mapper.run(hamiltonian, mapping_jw) + + def _to_matrix(qh: QubitHamiltonian) -> np.ndarray: + n = qh.num_qubits + dim = 2**n + mat = np.zeros((dim, dim), dtype=complex) + pauli_mats = { + "I": np.eye(2, dtype=complex), + "X": np.array([[0, 1], [1, 0]], dtype=complex), + "Y": np.array([[0, -1j], [1j, 0]], dtype=complex), + "Z": np.array([[1, 0], [0, -1]], dtype=complex), + } + for ps, c in zip(qh.pauli_strings, qh.coefficients, strict=True): + term = np.array([[1.0]], dtype=complex) + for ch in ps: + term = np.kron(term, pauli_mats[ch]) + mat += c * term + return mat + + eigs_jw = np.sort(np.linalg.eigvalsh(_to_matrix(result_jw))) + eigs_parity = np.sort(np.linalg.eigvalsh(_to_matrix(result_parity))) + np.testing.assert_allclose(eigs_parity, eigs_jw, atol=1e-10) + + +class TestUnrestrictedHamiltonians: + """Tests for unrestricted (UHF) Hamiltonian mapping.""" + + @staticmethod + def _make_unrestricted_hamiltonian(n: int, seed: int = 42): + """Create an unrestricted Hamiltonian with distinct alpha/beta integrals.""" + rng = np.random.default_rng(seed) + coeffs_alpha = np.eye(n) + coeffs_beta = np.eye(n) + rng.standard_normal((n, n)) * 0.1 + basis_set = create_test_basis_set(n, "test-uhf") + orbitals = Orbitals(coeffs_alpha, coeffs_beta, None, None, None, basis_set) + + # Symmetric one-body matrices (different for alpha/beta) + raw_a = rng.standard_normal((n, n)) * 0.3 + h1_alpha = (raw_a + raw_a.T) / 2 + np.diag(np.linspace(1.0, -0.5, n)) + raw_b = rng.standard_normal((n, n)) * 0.3 + h1_beta = (raw_b + raw_b.T) / 2 + np.diag(np.linspace(0.8, -0.3, n)) + + # Two-body integrals with 8-fold symmetry (different per channel) + def make_symmetric_eri(n, rng): + h2 = np.zeros((n, n, n, n)) + seen = set() + for p in range(n): + for q in range(n): + for r in range(n): + for s in range(n): + perms = frozenset({ + (p, q, r, s), (q, p, r, s), (p, q, s, r), (q, p, s, r), + (r, s, p, q), (s, r, p, q), (r, s, q, p), (s, r, q, p), + }) + canon = min(perms) + if canon in seen: + continue + seen.add(canon) + val = rng.standard_normal() * 0.2 + for a, b, c, d in perms: + h2[a, b, c, d] = val + return h2.ravel() + + h2_aaaa = make_symmetric_eri(n, rng) + h2_aabb = make_symmetric_eri(n, rng) + h2_bbbb = make_symmetric_eri(n, rng) + + fock_a = np.eye(0) + fock_b = np.eye(0) + return Hamiltonian( + CanonicalFourCenterHamiltonianContainer( + h1_alpha, h1_beta, h2_aaaa, h2_aabb, h2_bbbb, + orbitals, 0.5, fock_a, fock_b, + ) + ) + + def test_unrestricted_hamiltonian_is_detected(self) -> None: + """Verify the test helper creates an unrestricted Hamiltonian.""" + h = self._make_unrestricted_hamiltonian(2) + assert not h.get_orbitals().is_restricted() + h1_a, h1_b = h.get_one_body_integrals() + assert not np.array_equal(h1_a, h1_b) + + def test_unrestricted_jw_produces_hermitian(self) -> None: + """UHF JW mapping produces a Hermitian qubit Hamiltonian.""" + h = self._make_unrestricted_hamiltonian(2) + mapping = MajoranaMapping.jordan_wigner(num_modes=4) + mapper = create("qubit_mapper", "qdk") + qh = mapper.run(h, mapping) + + assert qh.num_qubits == 4 + for c in qh.coefficients: + assert abs(c.imag) < 1e-12, f"Non-real coefficient: {c}" + + def test_unrestricted_eigenvalues_match_across_encodings(self) -> None: + """UHF eigenvalues are consistent across JW, BK, and parity.""" + h = self._make_unrestricted_hamiltonian(2) + n_modes = 4 + + eigenvalues = {} + for enc in ["jordan_wigner", "bravyi_kitaev", "parity"]: + mapping = getattr(MajoranaMapping, enc)(num_modes=n_modes) + mapper = create("qubit_mapper", "qdk") + qh = mapper.run(h, mapping) + eigenvalues[enc] = np.sort(np.linalg.eigvalsh(qh.to_matrix())) + + np.testing.assert_allclose( + eigenvalues["jordan_wigner"], eigenvalues["bravyi_kitaev"], atol=1e-10 + ) + np.testing.assert_allclose( + eigenvalues["jordan_wigner"], eigenvalues["parity"], atol=1e-10 + ) + + def test_unrestricted_equals_restricted_when_channels_match(self) -> None: + """When alpha == beta integrals, UHF path should match restricted path.""" + # Create a restricted Hamiltonian + h_res = create_nontrivial_test_hamiltonian(2) + h1_a, _ = h_res.get_one_body_integrals() + h2_aaaa, _, _ = h_res.get_two_body_integrals() + n = h1_a.shape[0] + + # Create an "unrestricted" Hamiltonian with alpha == beta + coeffs_alpha = np.eye(n) + coeffs_beta = np.eye(n) + np.random.default_rng(99).standard_normal((n, n)) * 0.01 + basis_set = create_test_basis_set(n, "test-uhf-eq") + orbitals = Orbitals(coeffs_alpha, coeffs_beta, None, None, None, basis_set) + + h_unres = Hamiltonian( + CanonicalFourCenterHamiltonianContainer( + h1_a, h1_a, h2_aaaa, h2_aaaa, h2_aaaa, + orbitals, h_res.get_core_energy(), np.eye(0), np.eye(0), + ) + ) + assert not h_unres.get_orbitals().is_restricted() + + mapping = MajoranaMapping.jordan_wigner(num_modes=2 * n) + qh_res = create("qubit_mapper", "qdk").run(h_res, mapping) + qh_unres = create("qubit_mapper", "qdk").run(h_unres, mapping) + + # Eigenvalues should match + e_res = np.sort(np.linalg.eigvalsh(qh_res.to_matrix())) + e_unres = np.sort(np.linalg.eigvalsh(qh_unres.to_matrix())) + np.testing.assert_allclose(e_unres, e_res, atol=1e-10) + + def test_unrestricted_3_orbitals(self) -> None: + """UHF mapping works for a larger unrestricted system.""" + h = self._make_unrestricted_hamiltonian(3) + mapping = MajoranaMapping.jordan_wigner(num_modes=6) + mapper = create("qubit_mapper", "qdk") + qh = mapper.run(h, mapping) + + assert qh.num_qubits == 6 + assert len(qh.pauli_strings) > 0 + for c in qh.coefficients: + assert abs(c.imag) < 1e-12 + class TestQdkQubitMapperRealHamiltonians: """Tests with real molecular Hamiltonians.""" From 8cc3c23edf887eeb95174e34f50e7815f824eb99 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 11 May 2026 00:18:41 +0000 Subject: [PATCH 073/117] feat: UHF support in Qiskit plugin + cross-validated UHF tests Qiskit plugin now passes separate alpha/beta one-body and two-body integrals to ElectronicEnergy.from_raw_integrals for unrestricted Hamiltonians (h1_b, h2_bb, h2_ba). OpenFermion plugin already handled UHF via hamiltonian_to_interaction_operator. UHF tests cross-validate QDK engine output against both plugins: - test_qiskit_unrestricted_jw_matches_qdk: coefficient-exact match - test_openfermion_unrestricted_jw_matches_qdk: coefficient-exact match Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../plugins/qiskit/qubit_mapper.py | 22 +++-- .../test_interop_openfermion_qubit_mapper.py | 87 ++++++++++++++++++- .../tests/test_interop_qiskit_qubit_mapper.py | 83 +++++++++++++++++- 3 files changed, 182 insertions(+), 10 deletions(-) diff --git a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py index e0ea4d27b..297977560 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py @@ -99,12 +99,24 @@ def _run_impl( f"Use the QDK variant for custom mappings." ) - (h1_a, _) = hamiltonian.get_one_body_integrals() - (h2_aa, _, _) = hamiltonian.get_two_body_integrals() + h1_a, h1_b = hamiltonian.get_one_body_integrals() + h2_aa, h2_ab, h2_bb = hamiltonian.get_two_body_integrals() num_orbs = len(hamiltonian.get_orbitals().get_active_space_indices()[0]) - electronic_hamiltonian = ElectronicEnergy.from_raw_integrals( - h1_a=h1_a, h2_aa=h2_aa.reshape(num_orbs, num_orbs, num_orbs, num_orbs) - ) + is_restricted = hamiltonian.get_orbitals().is_restricted() + + if is_restricted: + electronic_hamiltonian = ElectronicEnergy.from_raw_integrals( + h1_a=h1_a, h2_aa=h2_aa.reshape(num_orbs, num_orbs, num_orbs, num_orbs) + ) + else: + electronic_hamiltonian = ElectronicEnergy.from_raw_integrals( + h1_a=h1_a, + h2_aa=h2_aa.reshape(num_orbs, num_orbs, num_orbs, num_orbs), + h1_b=h1_b, + h2_bb=h2_bb.reshape(num_orbs, num_orbs, num_orbs, num_orbs), + h2_ba=h2_ab.reshape(num_orbs, num_orbs, num_orbs, num_orbs), + ) + fermionic_op = electronic_hamiltonian.second_q_op() qubit_mapper = _SUPPORTED_ENCODINGS[encoding_name]() qubit_op = qubit_mapper.map(fermionic_op) diff --git a/python/tests/test_interop_openfermion_qubit_mapper.py b/python/tests/test_interop_openfermion_qubit_mapper.py index 4410e39e6..9120e136e 100644 --- a/python/tests/test_interop_openfermion_qubit_mapper.py +++ b/python/tests/test_interop_openfermion_qubit_mapper.py @@ -16,7 +16,7 @@ from .reference_tolerances import ( float_comparison_absolute_tolerance, ) -from .test_helpers import create_nontrivial_test_hamiltonian +from .test_helpers import create_nontrivial_test_hamiltonian, create_test_basis_set OPENFERMION_AVAILABLE = importlib.util.find_spec("openfermion") is not None @@ -24,7 +24,13 @@ import openfermion as of from qdk_chemistry.algorithms import QubitMapper, available, create - from qdk_chemistry.data import MajoranaMapping, Symmetries + from qdk_chemistry.data import ( + CanonicalFourCenterHamiltonianContainer, + Hamiltonian, + MajoranaMapping, + Orbitals, + Symmetries, + ) from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.plugins.openfermion.conversion import ( hamiltonian_to_fermion_operator, @@ -39,7 +45,7 @@ } if TYPE_CHECKING: - from qdk_chemistry.data import Hamiltonian, QubitHamiltonian + from qdk_chemistry.data import QubitHamiltonian pytestmark = pytest.mark.skipif(not OPENFERMION_AVAILABLE, reason="OpenFermion not available") @@ -279,3 +285,78 @@ def test_openfermion_standard_sets_blocked_order(encoding): mapping = MajoranaMapping(table=list(MajoranaMapping.jordan_wigner(n).table), name=encoding) qh = create("qubit_mapper", "openfermion").run(hamiltonian, mapping) assert qh.fermion_mode_order == FermionModeOrder.BLOCKED + + +@pytest.mark.skipif(not OPENFERMION_AVAILABLE, reason="OpenFermion not available") +def test_openfermion_unrestricted_jw_matches_qdk(): + """OpenFermion plugin produces same UHF JW result as QDK engine.""" + n = 2 + rng = np.random.default_rng(77) + coeffs_a = np.eye(n) + coeffs_b = np.eye(n) + rng.standard_normal((n, n)) * 0.1 + basis = create_test_basis_set(n, "uhf-of-test") + orbitals = Orbitals(coeffs_a, coeffs_b, None, None, None, basis) + + raw_a = rng.standard_normal((n, n)) * 0.3 + h1_a = (raw_a + raw_a.T) / 2 + np.diag([1.0, -0.5]) + raw_b = rng.standard_normal((n, n)) * 0.3 + h1_b = (raw_b + raw_b.T) / 2 + np.diag([0.8, -0.3]) + + def sym_eri(n, rng): + h2 = np.zeros((n, n, n, n)) + seen = set() + for p in range(n): + for q in range(n): + for r in range(n): + for s in range(n): + perms = frozenset( + { + (p, q, r, s), + (q, p, r, s), + (p, q, s, r), + (q, p, s, r), + (r, s, p, q), + (s, r, p, q), + (r, s, q, p), + (s, r, q, p), + } + ) + c = min(perms) + if c in seen: + continue + seen.add(c) + v = rng.standard_normal() * 0.2 + for a, b, c2, d in perms: + h2[a, b, c2, d] = v + return h2 + + eri_aa = sym_eri(n, rng) + eri_ab = sym_eri(n, rng) + eri_bb = sym_eri(n, rng) + + hamiltonian = Hamiltonian( + CanonicalFourCenterHamiltonianContainer( + h1_a, + h1_b, + eri_aa.ravel(), + eri_ab.ravel(), + eri_bb.ravel(), + orbitals, + 0.0, + np.eye(0), + np.eye(0), + ) + ) + assert not hamiltonian.get_orbitals().is_restricted() + + mapping = MajoranaMapping.jordan_wigner(num_modes=4) + qh_qdk = create("qubit_mapper", "qdk").run(hamiltonian, mapping) + qh_of = create("qubit_mapper", "openfermion").run(hamiltonian, mapping) + + # OpenFermion folds core_energy into identity; QDK doesn't. + # Core energy is 0.0 here, so no adjustment needed. + d_qdk = dict(zip(qh_qdk.pauli_strings, qh_qdk.coefficients, strict=False)) + d_of = dict(zip(qh_of.pauli_strings, qh_of.coefficients, strict=False)) + all_keys = set(d_qdk) | set(d_of) + max_diff = max(abs(d_qdk.get(k, 0) - d_of.get(k, 0)) for k in all_keys) + assert max_diff < 1e-10, f"UHF JW max coefficient diff: {max_diff}" diff --git a/python/tests/test_interop_qiskit_qubit_mapper.py b/python/tests/test_interop_qiskit_qubit_mapper.py index 6fa72578c..55a0ad69f 100644 --- a/python/tests/test_interop_qiskit_qubit_mapper.py +++ b/python/tests/test_interop_qiskit_qubit_mapper.py @@ -14,11 +14,17 @@ float_comparison_absolute_tolerance, float_comparison_relative_tolerance, ) -from .test_helpers import create_test_hamiltonian +from .test_helpers import create_test_basis_set, create_test_hamiltonian if QDK_CHEMISTRY_HAS_QISKIT_NATURE: from qdk_chemistry.algorithms import QubitMapper, available, create - from qdk_chemistry.data import Hamiltonian, MajoranaMapping, QubitHamiltonian + from qdk_chemistry.data import ( + CanonicalFourCenterHamiltonianContainer, + Hamiltonian, + MajoranaMapping, + Orbitals, + QubitHamiltonian, + ) pytestmark = pytest.mark.skipif(not QDK_CHEMISTRY_HAS_QISKIT_NATURE, reason="Qiskit Nature not available") @@ -58,3 +64,76 @@ def test_qiskit_qubit_mappers(encoding) -> None: rtol=float_comparison_relative_tolerance, atol=float_comparison_absolute_tolerance, ) + + +def test_qiskit_unrestricted_jw_matches_qdk(): + """Qiskit plugin produces same UHF JW result as QDK engine.""" + n = 2 + rng = np.random.default_rng(77) + coeffs_a = np.eye(n) + coeffs_b = np.eye(n) + rng.standard_normal((n, n)) * 0.1 + basis = create_test_basis_set(n, "uhf-qiskit-test") + orbitals = Orbitals(coeffs_a, coeffs_b, None, None, None, basis) + + raw_a = rng.standard_normal((n, n)) * 0.3 + h1_a = (raw_a + raw_a.T) / 2 + np.diag([1.0, -0.5]) + raw_b = rng.standard_normal((n, n)) * 0.3 + h1_b = (raw_b + raw_b.T) / 2 + np.diag([0.8, -0.3]) + + def sym_eri(n, rng): + h2 = np.zeros((n, n, n, n)) + seen = set() + for p in range(n): + for q in range(n): + for r in range(n): + for s in range(n): + perms = frozenset( + { + (p, q, r, s), + (q, p, r, s), + (p, q, s, r), + (q, p, s, r), + (r, s, p, q), + (s, r, p, q), + (r, s, q, p), + (s, r, q, p), + } + ) + c = min(perms) + if c in seen: + continue + seen.add(c) + v = rng.standard_normal() * 0.2 + for a, b, c2, d in perms: + h2[a, b, c2, d] = v + return h2 + + eri_aa = sym_eri(n, rng) + eri_ab = sym_eri(n, rng) + eri_bb = sym_eri(n, rng) + + hamiltonian = Hamiltonian( + CanonicalFourCenterHamiltonianContainer( + h1_a, + h1_b, + eri_aa.ravel(), + eri_ab.ravel(), + eri_bb.ravel(), + orbitals, + 0.0, + np.eye(0), + np.eye(0), + ) + ) + assert not hamiltonian.get_orbitals().is_restricted() + + mapping = MajoranaMapping.jordan_wigner(num_modes=4) + qh_qdk = create("qubit_mapper", "qdk").run(hamiltonian, mapping) + qh_qis = create("qubit_mapper", "qiskit").run(hamiltonian, mapping) + + # Coefficient-exact comparison + d_qdk = dict(zip(qh_qdk.pauli_strings, qh_qdk.coefficients, strict=False)) + d_qis = dict(zip(qh_qis.pauli_strings, qh_qis.coefficients, strict=False)) + all_keys = set(d_qdk) | set(d_qis) + max_diff = max(abs(d_qdk.get(k, 0) - d_qis.get(k, 0)) for k in all_keys) + assert max_diff < 1e-10, f"UHF JW max coefficient diff: {max_diff}" From 7dde723b066fda6a86ff17dea5ae86c0cfc8d2a0 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 11 May 2026 00:21:03 +0000 Subject: [PATCH 074/117] docs: document UHF support in all qubit mapper variants Update class and method docstrings for QdkQubitMapper, QiskitQubitMapper, and OpenFermionQubitMapper to reflect that both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. Add usage examples to plugin docstrings. Remove stale SCBK reference from OpenFermion docstring. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../qubit_mapper/qdk_qubit_mapper.py | 6 +++++- .../plugins/openfermion/qubit_mapper.py | 19 ++++++++++++++++--- .../plugins/qiskit/qubit_mapper.py | 19 +++++++++++++++++-- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index 96fa77413..20d6bc9c6 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -62,9 +62,13 @@ class QdkQubitMapper(QubitMapper): This mapper transforms a fermionic Hamiltonian to a qubit Hamiltonian. The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` - passed to :meth:`run`. Any valid MajoranaMapping works — built-in + passed to :meth:`run`. Any valid MajoranaMapping works -- built-in (Jordan-Wigner, Bravyi-Kitaev, parity) or custom. + Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. + For unrestricted systems, the engine handles all four spin-channel ERI + blocks (aa, ab, ba, bb) independently. + The mapper uses canonical blocked spin-orbital ordering internally: qubits 0..N-1 for alpha spin, qubits N..2N-1 for beta spin (where N is the number of spatial orbitals). Use ``QubitHamiltonian.to_interleaved()`` diff --git a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py index 4b5c26eb0..fd5e76b86 100644 --- a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py @@ -53,13 +53,24 @@ class OpenFermionQubitMapper(QubitMapper): The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` passed to :meth:`run`. The plugin uses ``mapping.name`` to select the corresponding OpenFermion transform. Custom (unnamed) mappings are not - supported — use the QDK variant instead. + supported -- use the QDK variant instead. + + Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. + For unrestricted systems, separate alpha/beta spin channels are handled + via ``hamiltonian_to_interaction_operator``. Supported ``mapping.name`` values: - ``"jordan-wigner"`` - ``"bravyi-kitaev"`` - ``"bravyi-kitaev-tree"`` + Examples: + >>> from qdk_chemistry.algorithms import create + >>> from qdk_chemistry.data import MajoranaMapping + >>> mapper = create("qubit_mapper", "openfermion") + >>> mapping = MajoranaMapping.jordan_wigner(num_modes=n_spin_orbitals) + >>> qh = mapper.run(hamiltonian, mapping) + """ def __init__(self): @@ -76,10 +87,12 @@ def _run_impl( ) -> QubitHamiltonian: """Construct a QubitHamiltonian from a Hamiltonian using the selected mapping strategy. + Supports both restricted and unrestricted (UHF) Hamiltonians. + Args: - hamiltonian: The fermionic Hamiltonian. + hamiltonian: The fermionic Hamiltonian (restricted or unrestricted). mapping: The Majorana-to-Pauli encoding. Only built-in encodings are supported. - symmetries: Symmetry information. Required for SCBK encoding. + symmetries: Optional symmetry information. Not used by this implementation. Returns: QubitHamiltonian: An instance of the QubitHamiltonian. diff --git a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py index 297977560..330bc9b1f 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py @@ -52,13 +52,24 @@ class QiskitQubitMapper(QubitMapper): The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` passed to :meth:`run`. The plugin uses ``mapping.name`` to select the corresponding Qiskit Nature mapper. Custom (unnamed) mappings are not - supported — use the QDK variant instead. + supported -- use the QDK variant instead. + + Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. + For unrestricted systems, separate alpha and beta one-body and two-body + integrals are forwarded to Qiskit Nature's ``ElectronicEnergy``. Supported ``mapping.name`` values: - ``"jordan-wigner"`` - ``"bravyi-kitaev"`` - ``"parity"`` + Examples: + >>> from qdk_chemistry.algorithms import create + >>> from qdk_chemistry.data import MajoranaMapping + >>> mapper = create("qubit_mapper", "qiskit") + >>> mapping = MajoranaMapping.jordan_wigner(num_modes=n_spin_orbitals) + >>> qh = mapper.run(hamiltonian, mapping) + """ QubitMappers: ClassVar = _SUPPORTED_ENCODINGS @@ -77,8 +88,12 @@ def _run_impl( ) -> QubitHamiltonian: """Construct a QubitHamiltonian from a Hamiltonian using the selected mapping strategy. + Supports both restricted and unrestricted (UHF) Hamiltonians. For + unrestricted systems, separate alpha/beta integrals are passed to + Qiskit Nature's ``ElectronicEnergy.from_raw_integrals``. + Args: - hamiltonian: The fermionic Hamiltonian. + hamiltonian: The fermionic Hamiltonian (restricted or unrestricted). mapping: The Majorana-to-Pauli encoding. Only built-in encodings are supported. symmetries: Optional symmetry information. Not used by this implementation. From 5a1e5e21422043a3469913a8655f3cd1afa7a8c9 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 11 May 2026 00:26:19 +0000 Subject: [PATCH 075/117] docs: add MajoranaMapping page, update qubit mapper docs for new API New documentation: - docs/source/user/comprehensive/data/majorana_mapping.rst: full data class page covering built-in encodings, custom encodings, Clifford validation, conventions, and serialization. - Added to data index toctree and quick reference table. Updated documentation: - qubit_mapper.rst: MajoranaMapping added as input requirement, encoding setting removed from all implementation tables, Parity added to QDK supported encodings, UHF support noted, SCBK removed from OpenFermion, custom encoding capability documented. - qubit_mapper.py example script: updated to new API (MajoranaMapping as run() argument instead of encoding setting), SCBK example removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_static/examples/python/qubit_mapper.py | 52 +++----- .../comprehensive/algorithms/qubit_mapper.rst | 71 +++++------ docs/source/user/comprehensive/data/index.rst | 4 + .../comprehensive/data/majorana_mapping.rst | 115 ++++++++++++++++++ 4 files changed, 166 insertions(+), 76 deletions(-) create mode 100644 docs/source/user/comprehensive/data/majorana_mapping.rst diff --git a/docs/source/_static/examples/python/qubit_mapper.py b/docs/source/_static/examples/python/qubit_mapper.py index 8163bc6d4..9efe059e5 100644 --- a/docs/source/_static/examples/python/qubit_mapper.py +++ b/docs/source/_static/examples/python/qubit_mapper.py @@ -8,16 +8,18 @@ ################################################################################ # start-cell-create from qdk_chemistry.algorithms import create +from qdk_chemistry.data import MajoranaMapping # Create a QubitMapper instance -qubit_mapper = create("qubit_mapper", "qiskit") +qubit_mapper = create("qubit_mapper") # end-cell-create ################################################################################ ################################################################################ # start-cell-configure -# Configure the encoding strategy -qubit_mapper.settings().set("encoding", "jordan-wigner") +# Optional: configure numerical thresholds +qubit_mapper.settings().set("threshold", 1e-12) +qubit_mapper.settings().set("integral_threshold", 1e-12) # end-cell-configure ################################################################################ @@ -50,8 +52,15 @@ hamiltonian_constructor = create("hamiltonian_constructor") hamiltonian = hamiltonian_constructor.run(active_orbitals) +# Determine the number of spin-orbitals +n_spatial = hamiltonian.get_one_body_integrals()[0].shape[0] +n_spin_orbitals = 2 * n_spatial + +# Choose an encoding +mapping = MajoranaMapping.jordan_wigner(num_modes=n_spin_orbitals) + # Map the fermionic Hamiltonian to a qubit Hamiltonian -qubit_hamiltonian = qubit_mapper.run(hamiltonian) +qubit_hamiltonian = qubit_mapper.run(hamiltonian, mapping) print(f"Qubit Hamiltonian has {qubit_hamiltonian.num_qubits} qubits") # end-cell-run ################################################################################ @@ -68,45 +77,20 @@ ################################################################################ # start-cell-qdk-mapper from qdk_chemistry.algorithms import create as create_algorithm +from qdk_chemistry.data import MajoranaMapping # Create a native QDK QubitMapper instance qdk_mapper = create_algorithm("qubit_mapper", "qdk") -# Configure the encoding (jordan-wigner or bravyi-kitaev) -qdk_mapper.settings().set("encoding", "jordan-wigner") - # Optional: configure thresholds for numerical precision qdk_mapper.settings().set("threshold", 1e-12) qdk_mapper.settings().set("integral_threshold", 1e-12) +# Choose an encoding (Jordan-Wigner, Bravyi-Kitaev, or Parity) +mapping = MajoranaMapping.jordan_wigner(num_modes=n_spin_orbitals) + # Map the fermionic Hamiltonian to a qubit Hamiltonian -qdk_qubit_hamiltonian = qdk_mapper.run(hamiltonian) +qdk_qubit_hamiltonian = qdk_mapper.run(hamiltonian, mapping) print(f"QDK mapper produced {len(qdk_qubit_hamiltonian.pauli_strings)} Pauli terms") # end-cell-qdk-mapper ################################################################################ - -################################################################################ -# start-cell-scbk-mapper -from qdk_chemistry.data import Symmetries - -# Create an OpenFermion mapper with SCBK encoding -scbk_mapper = create_algorithm( - "qubit_mapper", "openfermion", encoding="symmetry-conserving-bravyi-kitaev" -) - -# Provide symmetries: the SCBK encoding needs active electron counts -# Option 1: construct directly -symmetries = Symmetries(n_alpha=2, n_beta=2) -scbk_hamiltonian = scbk_mapper.run(hamiltonian, symmetries) - -# Option 2: derive from a wavefunction -symmetries = Symmetries.from_wavefunction(active_wfn) -scbk_hamiltonian = scbk_mapper.run(hamiltonian, symmetries) - -# SCBK reduces the qubit count by 2 compared to JW or BK -print( - f"SCBK mapper: {scbk_hamiltonian.num_qubits} qubits " - f"(vs {qdk_qubit_hamiltonian.num_qubits} for JW)" -) -# end-cell-scbk-mapper -################################################################################ diff --git a/docs/source/user/comprehensive/algorithms/qubit_mapper.rst b/docs/source/user/comprehensive/algorithms/qubit_mapper.rst index 109caa4c5..5897a3592 100644 --- a/docs/source/user/comprehensive/algorithms/qubit_mapper.rst +++ b/docs/source/user/comprehensive/algorithms/qubit_mapper.rst @@ -60,12 +60,13 @@ Using the QubitMapper This algorithm is currently available only in the Python API. This section demonstrates how to create, configure, and run a qubit mapping. -The ``run`` method returns a :class:`~qdk_chemistry.data.QubitHamiltonian` object containing the Pauli-string representation. +The ``run`` method requires a :class:`~qdk_chemistry.data.MajoranaMapping` as its second argument, which specifies the fermion-to-qubit encoding to use. +It returns a :class:`~qdk_chemistry.data.QubitHamiltonian` object containing the Pauli-string representation. Input requirements ~~~~~~~~~~~~~~~~~~ -The :class:`~qdk_chemistry.algorithms.QubitMapper` requires the following input: +The :class:`~qdk_chemistry.algorithms.QubitMapper` requires the following inputs: Hamiltonian A :doc:`Hamiltonian <../data/hamiltonian>` instance containing the fermionic one- and @@ -75,6 +76,12 @@ Hamiltonian The Hamiltonian defines the fermionic operators that will be transformed into qubit (Pauli) operators using the selected encoding strategy. +MajoranaMapping + A :doc:`MajoranaMapping <../data/majorana_mapping>` instance specifying the + fermion-to-qubit encoding. Built-in factory methods are available for standard + encodings (e.g., ``MajoranaMapping.jordan_wigner(num_modes=n)``), or a custom + encoding can be constructed from a Pauli-string table. + .. note:: Different encoding strategies produce mathematically equivalent qubit Hamiltonians @@ -135,11 +142,15 @@ QDK Native QDK/Chemistry qubit mapping implementation built on the :doc:`PauliOperator <../data/pauli_operator>` expression layer. This implementation provides high-performance fermion-to-qubit transformations without external dependencies. -Supported encodings: :ref:`Jordan-Wigner `, :ref:`Bravyi-Kitaev ` +Supported encodings: :ref:`Jordan-Wigner `, :ref:`Bravyi-Kitaev `, :ref:`Parity ` The native mapper uses blocked spin-orbital ordering internally (alpha orbitals first, then beta orbitals). Use ``QubitHamiltonian.to_interleaved()`` for alternative qubit orderings if needed. +Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. + +Custom encodings can be defined by constructing a :class:`~qdk_chemistry.data.MajoranaMapping` from a Pauli-string table. + .. rubric:: Settings .. list-table:: @@ -149,9 +160,6 @@ Use ``QubitHamiltonian.to_interleaved()`` for alternative qubit orderings if nee * - Setting - Type - Description - * - ``encoding`` - - string - - Fermion-to-qubit encoding (``jordan-wigner``, ``bravyi-kitaev``). Default: ``jordan-wigner`` * - ``threshold`` - double - Threshold for pruning small Pauli coefficients. Default: ``1e-12`` @@ -179,18 +187,15 @@ Qubit mapping implementation integrated through the Qiskit plugin. Supported encodings: :ref:`Jordan-Wigner `, :ref:`Bravyi-Kitaev `, :ref:`Parity ` -.. rubric:: Settings +The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` passed to ``run()``. -.. list-table:: - :header-rows: 1 - :widths: 25 25 50 +Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. - * - Setting - - Type - - Description - * - ``encoding`` - - string - - Qubit mapping strategy (``jordan-wigner``, ``bravyi-kitaev``, ``parity``) +.. rubric:: Settings + +This implementation has no configurable settings. The encoding strategy is +determined entirely by the :class:`~qdk_chemistry.data.MajoranaMapping` provided +to ``run()``. .. _openfermion-qubit-mapper: @@ -201,42 +206,24 @@ OpenFermion Qubit mapping implementation integrated through the OpenFermion plugin. -Supported encodings: :ref:`Jordan-Wigner `, :ref:`Bravyi-Kitaev `, :ref:`Symmetry-conserving Bravyi-Kitaev `, :ref:`Bravyi-Kitaev tree ` +Supported encodings: :ref:`Jordan-Wigner `, :ref:`Bravyi-Kitaev `, :ref:`Bravyi-Kitaev tree ` -.. rubric:: Settings - -.. list-table:: - :header-rows: 1 - :widths: 25 25 50 - - * - Setting - - Type - - Description - * - ``encoding`` - - string - - Fermion-to-qubit encoding (``jordan-wigner``, ``bravyi-kitaev``, ``symmetry-conserving-bravyi-kitaev``, ``bravyi-kitaev-tree``) +The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` passed to ``run()``. -.. note:: - - The ``symmetry-conserving-bravyi-kitaev`` encoding requires a - :class:`~qdk_chemistry.data.Symmetries` object passed as the second argument - to ``run()``. This object specifies the number of active alpha and beta - electrons so that the transform can exploit particle-number and spin-parity - symmetries to reduce the qubit count by 2. - See :doc:`Symmetries <../data/symmetries>` for full details and factory methods. +Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. - .. tab:: Python API +.. rubric:: Settings - .. literalinclude:: ../../../_static/examples/python/qubit_mapper.py - :language: python - :start-after: # start-cell-scbk-mapper - :end-before: # end-cell-scbk-mapper +This implementation has no configurable settings. The encoding strategy is +determined entirely by the :class:`~qdk_chemistry.data.MajoranaMapping` provided +to ``run()``. Related classes --------------- - :doc:`Hamiltonian <../data/hamiltonian>`: Input Hamiltonian for mapping +- :doc:`MajoranaMapping <../data/majorana_mapping>`: Fermion-to-qubit encoding passed to ``run()`` - :class:`~qdk_chemistry.data.QubitHamiltonian`: Output qubit operator representation - :doc:`Symmetries <../data/symmetries>`: Physical symmetries (e.g., conserved quantum numbers) for symmetry-exploiting algorithms diff --git a/docs/source/user/comprehensive/data/index.rst b/docs/source/user/comprehensive/data/index.rst index d0400a0fc..b1cd57d05 100644 --- a/docs/source/user/comprehensive/data/index.rst +++ b/docs/source/user/comprehensive/data/index.rst @@ -19,6 +19,7 @@ Each of the links below leads to a detailed description of the data class, inclu hamiltonian orbitals lattice_graph + majorana_mapping pauli_operator structure symmetries @@ -63,6 +64,9 @@ The following table summarizes the available data classes in QDK/Chemistry and t * - :doc:`LatticeGraph ` - Lattice topology for model Hamiltonians - Factory methods, User input + * - :doc:`MajoranaMapping ` + - Fermion-to-qubit encoding (Majorana-to-Pauli table) + - Factory methods, User input * - :doc:`PauliOperator ` - Pauli operator expressions with arithmetic - User construction diff --git a/docs/source/user/comprehensive/data/majorana_mapping.rst b/docs/source/user/comprehensive/data/majorana_mapping.rst new file mode 100644 index 000000000..7b6b6c3ce --- /dev/null +++ b/docs/source/user/comprehensive/data/majorana_mapping.rst @@ -0,0 +1,115 @@ +MajoranaMapping +=============== + +The :class:`~qdk_chemistry.data.MajoranaMapping` class in QDK/Chemistry is an immutable data class that defines a fermion-to-qubit encoding by mapping Majorana operators to Pauli strings. +As a core :doc:`data class <../design/index>`, it follows QDK/Chemistry's immutable data pattern. + +Overview +-------- + +Fermion-to-qubit mappings transform fermionic creation and annihilation operators into qubit (Pauli) operators. +Every such mapping can be expressed as a table of Majorana-to-Pauli-string correspondences. +The :class:`~qdk_chemistry.data.MajoranaMapping` class encapsulates this table, making the :doc:`QubitMapper <../algorithms/qubit_mapper>` algorithm encoding-agnostic: the mapper receives the encoding as data rather than selecting it internally. + +Convention +~~~~~~~~~~ + +``num_modes`` + The number of fermionic modes (spin-orbitals) in the system. + +Pauli strings use little-endian qubit ordering, consistent with the rest of QDK/Chemistry's :doc:`PauliOperator ` layer. + +Built-in encodings +------------------ + +Factory methods construct standard encodings for a given number of modes. +Each returns a :class:`~qdk_chemistry.data.MajoranaMapping` with the appropriate Pauli-string table and a descriptive ``name``. + +Jordan-Wigner +~~~~~~~~~~~~~ + +.. code-block:: python + + from qdk_chemistry.data import MajoranaMapping + + mapping = MajoranaMapping.jordan_wigner(num_modes=12) + +Encodes each fermionic mode in a single qubit. +See :ref:`encoding-jordan-wigner` for a description of the encoding. + +Bravyi-Kitaev +~~~~~~~~~~~~~ + +.. code-block:: python + + mapping = MajoranaMapping.bravyi_kitaev(num_modes=12) + +Uses a binary-tree structure to reduce average Pauli-string weight. +See :ref:`encoding-bravyi-kitaev` for a description of the encoding. + +Parity +~~~~~~ + +.. code-block:: python + + mapping = MajoranaMapping.parity(num_modes=12) + +Encodes cumulative electron-number parities. +See :ref:`encoding-parity` for a description of the encoding. + +Custom encodings +---------------- + +A custom encoding can be defined by providing a Pauli-string table directly: + +.. code-block:: python + + from qdk_chemistry.data import MajoranaMapping + + # Provide a list of Pauli strings, one per Majorana operator + mapping = MajoranaMapping(table=[...], name="my-custom-encoding") + +Alternatively, construct from mode pairs: + +.. code-block:: python + + mapping = MajoranaMapping.from_mode_pairs(...) + +Validation +---------- + +At construction, the :class:`~qdk_chemistry.data.MajoranaMapping` validates that the provided table satisfies the Clifford algebra anti-commutation relations required for a valid fermion-to-qubit mapping. +Invalid tables raise an error immediately, preventing silent correctness issues downstream. + +Serialization +------------- + +:class:`~qdk_chemistry.data.MajoranaMapping` supports the same :doc:`serialization ` formats as other QDK/Chemistry data classes: + +.. code-block:: python + + from qdk_chemistry.data import MajoranaMapping + + mapping = MajoranaMapping.jordan_wigner(num_modes=12) + + # JSON round-trip + json_str = mapping.to_json() + restored = MajoranaMapping.from_json(json_str) + + # HDF5 round-trip + mapping.to_hdf5("mapping.h5") + restored = MajoranaMapping.from_hdf5("mapping.h5") + + +Related classes +--------------- + +- :doc:`Hamiltonian `: Fermionic Hamiltonian transformed by the mapping +- :class:`~qdk_chemistry.data.QubitHamiltonian`: Output of qubit mapping using this encoding +- :doc:`PauliOperator `: Pauli operator expressions used in the mapping table + +Further reading +--------------- + +- :doc:`QubitMapper <../algorithms/qubit_mapper>`: The algorithm that consumes a ``MajoranaMapping`` to perform fermion-to-qubit transformations +- :doc:`Design principles <../design/index>`: Data class design principles in QDK/Chemistry From 8eae1ced832804e5707baea564c5a94d65f80929 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 11 May 2026 01:23:14 +0000 Subject: [PATCH 076/117] feat: per-entry phases in MajoranaMapping + restore SCBK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional `phases` vector to MajoranaMapping storing per-entry sign factors (+1 or -1). This enables encodings where Majorana operators carry negative signs (e.g., future Verstraete-Cirac support). Standard encodings (JW/BK/parity) have all-positive phases; the engine fast-paths when all_phases_positive() is true — zero overhead for existing code. SCBK (symmetry-conserving Bravyi-Kitaev) is restored in the OpenFermion plugin as a name-dispatched special case. SCBK operators don't satisfy the Clifford algebra on the full Hilbert space (they act on a tapered subspace), so they cannot be expressed as a MajoranaMapping. The plugin dispatches on mapping.name == "symmetry-conserving-bravyi-kitaev" and calls OpenFermion's transform directly. Performance: no regression — 161-213x faster than old code at n=20-40. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../qdk/chemistry/data/majorana_mapping.hpp | 48 +++++++++-- .../chemistry/data/majorana_map_engine.cpp | 52 ++++++++--- .../qdk/chemistry/data/majorana_mapping.cpp | 47 +++++++--- python/src/pybind11/data/majorana_mapping.cpp | 28 ++++-- .../qdk_chemistry/data/majorana_mapping.py | 17 +++- .../plugins/openfermion/qubit_mapper.py | 60 +++++++++++-- .../test_interop_openfermion_qubit_mapper.py | 86 ++++++++++++++++++- 7 files changed, 288 insertions(+), 50 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index ac78be47c..15dcd8fcf 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -39,14 +39,19 @@ class MajoranaMapping { * @brief Construct a MajoranaMapping from a Majorana-to-Pauli table. * * @param table Vector of 2N SparsePauliWord entries (one per Majorana - * operator γ_0, γ_1, ..., γ_{2N-1}). + * operator gamma_0, gamma_1, ..., gamma_{2N-1}). * @param name Optional human-readable label for the encoding (e.g., * "jordan-wigner"). Stored but not used for dispatch. - * @throws std::invalid_argument If table size is odd, empty, or the - * Clifford algebra validation fails. + * @param phases Optional vector of 2N sign factors (+1 or -1) such that + * gamma_k = phases[k] * table[k]. If empty, all phases are + * assumed +1. Encodings like SCBK produce negative phases. + * @throws std::invalid_argument If table size is odd, empty, phases size + * doesn't match table, phases contain values other than +1/-1, + * or the Clifford algebra validation fails. */ explicit MajoranaMapping(std::vector table, - std::string name = ""); + std::string name = "", + std::vector phases = {}); /** * @brief Number of fermionic modes (spin-orbitals). @@ -62,19 +67,38 @@ class MajoranaMapping { std::size_t num_qubits() const { return num_qubits_; } /** - * @brief Look up the Pauli string for Majorana operator γ_k. - * @param k Majorana index (0 ≤ k < 2N). - * @return const reference to the SparsePauliWord for γ_k. - * @throws std::out_of_range if k ≥ 2N. + * @brief Look up the Pauli string for Majorana operator gamma_k. + * @param k Majorana index (0 <= k < 2N). + * @return const reference to the SparsePauliWord for gamma_k. + * @throws std::out_of_range if k >= 2N. */ const SparsePauliWord& operator()(std::size_t k) const; + /** + * @brief Get the sign factor for Majorana operator gamma_k. + * @param k Majorana index (0 <= k < 2N). + * @return +1 or -1. + */ + std::int8_t phase(std::size_t k) const; + + /** + * @brief Check if all phases are +1 (fast path for standard encodings). + * @return true if no entry has a negative phase. + */ + bool all_phases_positive() const { return all_positive_; } + /** * @brief Access the full Majorana-to-Pauli table. * @return const reference to the vector of SparsePauliWords. */ const std::vector& table() const { return table_; } + /** + * @brief Access the phases vector. + * @return const reference to the vector of sign factors. + */ + const std::vector& phases() const { return phases_; } + /** * @brief Human-readable name of the encoding. * @return The name string (may be empty for custom encodings). @@ -133,9 +157,15 @@ class MajoranaMapping { static MajoranaMapping parity(std::size_t num_modes); private: - /// Majorana-to-Pauli table: table_[k] = φ(γ_k). + /// Majorana-to-Pauli table: table_[k] = unsigned Pauli string for gamma_k. std::vector table_; + /// Per-entry sign factors: gamma_k = phases_[k] * table_[k]. + std::vector phases_; + + /// True if all phases are +1 (enables fast-path in engine). + bool all_positive_; + /// Human-readable encoding name. std::string name_; diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index d1e13e60a..12c026928 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -250,11 +250,14 @@ MajoranaMapResult majorana_map_impl( PackedAccumulator acc; - // Convert all Majorana table entries to packed form + // Convert all Majorana table entries to packed form + cache phases std::vector> packed_mapping(2 * n_modes); + std::vector maj_phases(2 * n_modes); for (std::size_t k = 0; k < 2 * n_modes; ++k) { packed_mapping[k] = sparse_to_packed(mapping(k)); + maj_phases[k] = mapping.phase(k); } + const bool has_neg_phases = !mapping.all_phases_positive(); auto mode_alpha = [](std::size_t p) -> std::size_t { return p; }; auto mode_beta = [n_spatial](std::size_t p) -> std::size_t { @@ -262,13 +265,21 @@ MajoranaMapResult majorana_map_impl( }; // Helper: accumulate one-body E_pq for a mode pair, using packed types + // E_pq = (1/4) * sum_{a,b} c[a][b] * phase(2p+a) * phase(2q+b) * P(2p+a) * P(2q+b) auto accumulate_epq = [&](std::size_t mode_p, std::size_t mode_q, double h_pq) { for (int a = 0; a < 2; ++a) { + std::size_t idx_pa = 2 * mode_p + a; for (int b = 0; b < 2; ++b) { + std::size_t idx_qb = 2 * mode_q + b; auto [ph, word] = multiply_packed( - packed_mapping[2 * mode_p + a], packed_mapping[2 * mode_q + b]); - acc.accumulate(word, apply_phase(ph, h_pq * kQuarter * kC[a][b])); + packed_mapping[idx_pa], packed_mapping[idx_qb]); + double sign = has_neg_phases + ? static_cast(maj_phases[idx_pa]) * + maj_phases[idx_qb] + : 1.0; + acc.accumulate(word, + apply_phase(ph, sign * h_pq * kQuarter * kC[a][b])); } } }; @@ -320,8 +331,11 @@ MajoranaMapResult majorana_map_impl( // ─── Two-body terms ─────────────────────────────────────────────── // Precompute Majorana pair products in packed form (same-spin only). + // Each entry stores the Pauli multiply phase index AND the combined + // Majorana sign factor (phases[i] * phases[j]). struct PackedPairProduct { - int phase; // phase index (0..3): 0=+1, 1=+i, 2=-1, 3=-i + int phase; // Pauli multiply phase index (0..3) + std::int8_t sign; // maj_phases[i] * maj_phases[j] (±1) PackedPauliWord word; }; const std::size_t maj_per_spin = 2 * n_spatial; @@ -330,14 +344,23 @@ MajoranaMapResult majorana_map_impl( for (std::size_t i = 0; i < maj_per_spin; ++i) { for (std::size_t j = 0; j < maj_per_spin; ++j) { + // alpha block: Majorana indices i, j auto [ph_a, w_a] = multiply_packed( packed_mapping[i], packed_mapping[j]); - ppair_alpha[i * maj_per_spin + j] = {ph_a, std::move(w_a)}; + std::int8_t sign_a = has_neg_phases + ? maj_phases[i] * maj_phases[j] + : static_cast(1); + ppair_alpha[i * maj_per_spin + j] = {ph_a, sign_a, std::move(w_a)}; + // beta block: Majorana indices i+2*n_spatial, j+2*n_spatial auto [ph_b, w_b] = multiply_packed( packed_mapping[i + maj_per_spin], packed_mapping[j + maj_per_spin]); - ppair_beta[i * maj_per_spin + j] = {ph_b, std::move(w_b)}; + std::int8_t sign_b = + has_neg_phases + ? maj_phases[i + maj_per_spin] * maj_phases[j + maj_per_spin] + : static_cast(1); + ppair_beta[i * maj_per_spin + j] = {ph_b, sign_b, std::move(w_b)}; } } @@ -362,10 +385,12 @@ MajoranaMapResult majorana_map_impl( auto& sse = ss_e[p * n_spatial + q]; for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { - const auto& [phase_a, word_a] = alpha_pair(2*p+a, 2*q+b); - sse.terms.emplace_back(apply_phase(phase_a, kQuarter * kC[a][b]), word_a); - const auto& [phase_b, word_b] = beta_pair(2*p+a, 2*q+b); - sse.terms.emplace_back(apply_phase(phase_b, kQuarter * kC[a][b]), word_b); + const auto& [phase_a, sign_a, word_a] = alpha_pair(2*p+a, 2*q+b); + sse.terms.emplace_back( + apply_phase(phase_a, static_cast(sign_a) * kQuarter * kC[a][b]), word_a); + const auto& [phase_b, sign_b, word_b] = beta_pair(2*p+a, 2*q+b); + sse.terms.emplace_back( + apply_phase(phase_b, static_cast(sign_b) * kQuarter * kC[a][b]), word_b); } } } @@ -468,15 +493,16 @@ MajoranaMapResult majorana_map_impl( double half_eri = 0.5 * eri; for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { - const auto& [ph1, w1] = + const auto& [ph1, s1, w1] = cache_pq[(2*bp+a) * maj_per_spin + (2*bq+b)]; for (int c = 0; c < 2; ++c) { for (int d = 0; d < 2; ++d) { - const auto& [ph2, w2] = + const auto& [ph2, s2, w2] = cache_rs[(2*br+c) * maj_per_spin + (2*bs+d)]; + double sign = static_cast(s1) * s2; std::complex scale = apply_phase((ph1 + ph2) & 3, - half_eri * kSixteenth * kC[a][b] * kC[c][d]); + sign * half_eri * kSixteenth * kC[a][b] * kC[c][d]); acc.accumulate_product(w1, w2, scale); } } diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp index 91a7229ea..1c6acb828 100644 --- a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp @@ -142,8 +142,11 @@ constexpr std::uint8_t OP_Z = 3; // ── MajoranaMapping implementation ─────────────────────────────────── MajoranaMapping::MajoranaMapping(std::vector table, - std::string name) + std::string name, + std::vector phases) : table_(std::move(table)), + phases_(std::move(phases)), + all_positive_(true), name_(std::move(name)), num_qubits_(compute_num_qubits(table_)) { if (table_.empty()) { @@ -156,6 +159,23 @@ MajoranaMapping::MajoranaMapping(std::vector table, "(2 per fermionic mode), got " + std::to_string(table_.size())); } + // Default phases to all +1 if not provided + if (phases_.empty()) { + phases_.assign(table_.size(), 1); + } else if (phases_.size() != table_.size()) { + throw std::invalid_argument( + "phases size (" + std::to_string(phases_.size()) + + ") must match table size (" + std::to_string(table_.size()) + ")"); + } + // Validate phase values and compute all_positive flag + for (std::size_t k = 0; k < phases_.size(); ++k) { + if (phases_[k] != 1 && phases_[k] != -1) { + throw std::invalid_argument( + "phases[" + std::to_string(k) + "] = " + + std::to_string(phases_[k]) + "; must be +1 or -1"); + } + if (phases_[k] != 1) all_positive_ = false; + } validate(); } @@ -168,28 +188,35 @@ const SparsePauliWord& MajoranaMapping::operator()(std::size_t k) const { return table_[k]; } +std::int8_t MajoranaMapping::phase(std::size_t k) const { + if (k >= phases_.size()) { + throw std::out_of_range( + "Majorana index " + std::to_string(k) + + " out of range [0, " + std::to_string(phases_.size()) + ")"); + } + return phases_[k]; +} + void MajoranaMapping::validate() const { const std::size_t n = table_.size(); constexpr double tol = 1e-12; for (std::size_t i = 0; i < n; ++i) { for (std::size_t j = i; j < n; ++j) { - // Compute φ(γ_i)·φ(γ_j) + φ(γ_j)·φ(γ_i) and check = 2δ_{ij}·I + // gamma_k = phases_[k] * table_[k], so: + // gamma_i * gamma_j = phases_[i]*phases_[j] * table_[i]*table_[j] auto [phase_ij, word_ij] = multiply_words(table_[i], table_[j]); auto [phase_ji, word_ji] = multiply_words(table_[j], table_[i]); - // The anticommutator should be a scalar (identity word = empty) - // For i == j: result should be 2·I (phase = 2, word = empty) - // For i != j: result should be 0 (both terms cancel) + // Include the sign factors + double sign_ij = static_cast(phases_[i]) * phases_[j]; if (i == j) { - // γ_i² = I, so φ(γ_i)·φ(γ_i) should give phase·I with phase - // being ±1 (since Pauli strings square to ±I). The sum should be 2. - // Since word_ij == word_ji and phase_ij == phase_ji (same product), - // we need phase_ij to be 1 and word_ij to be identity. + // gamma_i^2 = phases_[i]^2 * P_i^2 = 1 * P_i^2 + // P_i^2 must be I with phase +1 if (!word_ij.empty()) { std::ostringstream msg; - msg << "Clifford algebra validation failed: γ_" << i + msg << "Clifford algebra validation failed: gamma_" << i << " squared is not proportional to identity"; throw std::invalid_argument(msg.str()); } diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index 9485ffdd0..aa81915fa 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -114,9 +114,10 @@ algebra anticommutation relations: {γ_i, γ_j} = 2δ_{ij} · I. 4 )"); - // Constructor from list of dense little-endian Pauli strings + // Constructor from list of dense little-endian Pauli strings + optional phases mapping.def( - py::init([](py::list table_list, const std::string& name) { + py::init([](py::list table_list, const std::string& name, + std::vector phases) { Py_ssize_t n = PyList_GET_SIZE(table_list.ptr()); if (n == 0) { throw py::value_error("MajoranaMapping table must not be empty"); @@ -128,7 +129,6 @@ algebra anticommutation relations: {γ_i, γ_j} = 2δ_{ij} · I. std::to_string(n)); } - // Validate all strings have the same length and contain only IXYZ Py_ssize_t expected_len = -1; std::vector table; table.reserve(static_cast(n)); @@ -160,18 +160,17 @@ algebra anticommutation relations: {γ_i, γ_j} = 2δ_{ij} · I. } std::string s(str_data, static_cast(str_len)); - // char_to_op validates each character table.push_back(dense_le_to_sparse(s)); } - // C++ constructor validates Clifford algebra try { - return MajoranaMapping(std::move(table), name); + return MajoranaMapping(std::move(table), name, std::move(phases)); } catch (const std::invalid_argument& e) { throw py::value_error(e.what()); } }), py::arg("table"), py::arg("name") = "", + py::arg("phases") = std::vector{}, R"( Construct a MajoranaMapping from a list of dense Pauli-string labels. @@ -249,6 +248,23 @@ advanced constructor for users who want to avoid string parsing. [](const MajoranaMapping& self) { return self.table(); }, "List of sparse Pauli words [(qubit_idx, op_type), ...]."); + // phases property + mapping.def_property_readonly( + "phases", + [](const MajoranaMapping& self) -> py::tuple { + py::tuple result(self.phases().size()); + for (std::size_t i = 0; i < self.phases().size(); ++i) { + result[i] = py::cast(static_cast(self.phases()[i])); + } + return result; + }, + "Tuple of per-entry sign factors (+1 or -1)."); + + mapping.def_property_readonly( + "all_phases_positive", + [](const MajoranaMapping& self) { return self.all_phases_positive(); }, + "True if all phases are +1 (standard encodings)."); + // __call__ for γ_k lookup mapping.def( "__call__", diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index 794ded085..80a2a399f 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -72,6 +72,7 @@ def __init__( self, table: list[str] | tuple[str, ...], name: str = "", + phases: list[int] | tuple[int, ...] | None = None, *, _core: _CoreMajoranaMapping | None = None, ) -> None: @@ -80,24 +81,23 @@ def __init__( Args: table (list[str] | tuple[str, ...]): 2N Pauli strings in little-endian format (qubit 0 = rightmost char). name (str): Optional human-readable label for the encoding. Default ``""``. + phases (list[int] | None): Optional 2N sign factors (+1 or -1) per Majorana operator. Default all +1. Raises: ValueError: If the table is invalid (wrong size, bad characters, or Clifford algebra violation). """ if _core is not None: - # Fast path: accept a pre-validated C++ core object directly - # (used by factory classmethods to skip re-parsing and re-validation) self._core = _core else: - # Build C++ core object (validates Clifford algebra) - self._core = _CoreMajoranaMapping(list(table), name) + self._core = _CoreMajoranaMapping(list(table), name, list(phases) if phases else []) # Cache immutable properties from the core self._table = self._core.table self._name = self._core.name self._num_modes = self._core.num_modes self._num_qubits = self._core.num_qubits + self._phases = self._core.phases # Mark immutable super().__init__() @@ -122,6 +122,11 @@ def name(self) -> str: """Human-readable name of the encoding (may be empty for custom mappings).""" return self._name + @property + def phases(self) -> tuple[int, ...]: + """Tuple of per-entry sign factors (+1 or -1). All +1 for standard encodings.""" + return self._phases + @property def core(self) -> _CoreMajoranaMapping: """Access the underlying C++ MajoranaMapping object.""" @@ -225,6 +230,9 @@ def to_json(self) -> dict[str, Any]: "table": list(self._table), "name": self._name, } + # Only include phases if any are non-default (-1) + if any(p != 1 for p in self._phases): + data["phases"] = list(self._phases) return self._add_json_version(data) @classmethod @@ -242,6 +250,7 @@ def from_json(cls, json_data: dict[str, Any]) -> MajoranaMapping: return cls( table=json_data["table"], name=json_data.get("name", ""), + phases=json_data.get("phases"), ) def to_hdf5(self, group: h5py.Group) -> None: diff --git a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py index fd5e76b86..a4e9ff098 100644 --- a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py @@ -20,6 +20,7 @@ from qdk_chemistry.algorithms.qubit_mapper import QubitMapper, QubitMapperSettings from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.plugins.openfermion.conversion import ( + hamiltonian_to_fermion_operator, hamiltonian_to_interaction_operator, qubit_operator_to_qubit_hamiltonian, ) @@ -63,6 +64,7 @@ class OpenFermionQubitMapper(QubitMapper): - ``"jordan-wigner"`` - ``"bravyi-kitaev"`` - ``"bravyi-kitaev-tree"`` + - ``"symmetry-conserving-bravyi-kitaev"`` (requires :class:`~qdk_chemistry.data.Symmetries`) Examples: >>> from qdk_chemistry.algorithms import create @@ -83,7 +85,7 @@ def _run_impl( self, hamiltonian: Hamiltonian, mapping: MajoranaMapping, - symmetries: Symmetries | None = None, # noqa: ARG002 + symmetries: Symmetries | None = None, ) -> QubitHamiltonian: """Construct a QubitHamiltonian from a Hamiltonian using the selected mapping strategy. @@ -92,7 +94,7 @@ def _run_impl( Args: hamiltonian: The fermionic Hamiltonian (restricted or unrestricted). mapping: The Majorana-to-Pauli encoding. Only built-in encodings are supported. - symmetries: Optional symmetry information. Not used by this implementation. + symmetries: Symmetry information. Required for ``"symmetry-conserving-bravyi-kitaev"`` encoding. Returns: QubitHamiltonian: An instance of the QubitHamiltonian. @@ -104,18 +106,21 @@ def _run_impl( Logger.trace_entering() encoding_name = mapping.name - if encoding_name not in _STANDARD_TRANSFORMS: + if encoding_name == "symmetry-conserving-bravyi-kitaev": + qubit_op = self._map_scbk(hamiltonian, symmetries) + fermion_mode_order = FermionModeOrder.INTERLEAVED + elif encoding_name in _STANDARD_TRANSFORMS: + qubit_op = self._map_standard(hamiltonian, encoding_name) + fermion_mode_order = FermionModeOrder.BLOCKED + else: raise NotImplementedError( f"OpenFermion plugin does not support MajoranaMapping with name {encoding_name!r}. " - f"Supported names: {sorted(_STANDARD_TRANSFORMS.keys())}. " + f"Supported names: {sorted([*_STANDARD_TRANSFORMS.keys(), 'symmetry-conserving-bravyi-kitaev'])}. " f"Use the QDK variant for custom mappings." ) Logger.debug(f"Mapping Hamiltonian with OpenFermion encoding: {encoding_name}") - qubit_op = self._map_standard(hamiltonian, encoding_name) - fermion_mode_order = FermionModeOrder.BLOCKED - qubit_op.compress() # OpenFermion folds core_energy into the identity Pauli term. @@ -150,6 +155,47 @@ def _map_standard(self, hamiltonian: Hamiltonian, encoding: str) -> of.QubitOper transform = _STANDARD_TRANSFORMS[encoding] return transform(fermion_op) + def _map_scbk(self, hamiltonian: Hamiltonian, symmetries: Symmetries | None) -> of.QubitOperator: + """Apply symmetry-conserving Bravyi-Kitaev transformation. + + This transform reduces the qubit count by 2 by exploiting particle number + and spin symmetry. Uses interleaved spin-orbital ordering (OpenFermion native). + + Args: + hamiltonian: The fermionic Hamiltonian. + symmetries: Symmetry information providing the active electron count. + + Returns: + openfermion.QubitOperator: The mapped qubit operator. + + Raises: + ValueError: If ``symmetries`` is not provided. + + """ + if symmetries is None: + raise ValueError( + "The symmetry-conserving Bravyi-Kitaev encoding requires a Symmetries " + "object specifying the number of active electrons.\n" + "Example:\n" + " from qdk_chemistry.data import Symmetries\n" + " symmetries = Symmetries(n_alpha=1, n_beta=1)\n" + " qubit_hamiltonian = mapper.run(hamiltonian, mapping, symmetries)" + ) + + fermion_op = hamiltonian_to_fermion_operator(hamiltonian) + n_active_electrons = symmetries.n_particles + + h1_alpha, _ = hamiltonian.get_one_body_integrals() + n_spinorbitals = 2 * h1_alpha.shape[0] + + Logger.debug(f"SCBK: n_spinorbitals={n_spinorbitals}, n_active_electrons={n_active_electrons}") + + return of.transforms.symmetry_conserving_bravyi_kitaev( + fermion_op, + n_spinorbitals, + n_active_electrons, + ) + def name(self) -> str: """Return the algorithm name ``openfermion``.""" Logger.trace_entering() diff --git a/python/tests/test_interop_openfermion_qubit_mapper.py b/python/tests/test_interop_openfermion_qubit_mapper.py index 9120e136e..c706cd974 100644 --- a/python/tests/test_interop_openfermion_qubit_mapper.py +++ b/python/tests/test_interop_openfermion_qubit_mapper.py @@ -16,7 +16,10 @@ from .reference_tolerances import ( float_comparison_absolute_tolerance, ) -from .test_helpers import create_nontrivial_test_hamiltonian, create_test_basis_set +from .test_helpers import ( + create_nontrivial_test_hamiltonian, + create_test_basis_set, +) OPENFERMION_AVAILABLE = importlib.util.find_spec("openfermion") is not None @@ -134,6 +137,87 @@ def test_openfermion_bk_tree_encoding(): _assert_pauli_ops_equal(qh, ref_qh) +# ------------------------------------------------------------------------------------- +# Symmetry-conserving Bravyi-Kitaev (SCBK) +# ------------------------------------------------------------------------------------- + + +def _make_scbk_mapping(hamiltonian: Hamiltonian) -> MajoranaMapping: + """Build a MajoranaMapping with name ``"symmetry-conserving-bravyi-kitaev"``. + + SCBK uses name-dispatch, not the Majorana table. Use a JW table as placeholder. + """ + n_spin = _num_spin_orbitals(hamiltonian) + jw = MajoranaMapping.jordan_wigner(num_modes=n_spin) + return MajoranaMapping(table=list(jw.table), name="symmetry-conserving-bravyi-kitaev") + + +def test_openfermion_scbk_produces_qubit_hamiltonian(): + """SCBK encoding produces a valid QubitHamiltonian with reduced qubit count.""" + hamiltonian = create_nontrivial_test_hamiltonian() + mapping = _make_scbk_mapping(hamiltonian) + symmetries = Symmetries(n_alpha=1, n_beta=1) + + qh = create("qubit_mapper", "openfermion").run(hamiltonian, mapping, symmetries) + + assert qh is not None + assert len(qh.pauli_strings) > 0 + # SCBK reduces qubit count by 2 relative to the number of spin-orbitals + n_spin = _num_spin_orbitals(hamiltonian) + assert qh.num_qubits == n_spin - 2 + + +def test_openfermion_scbk_sets_interleaved_order(): + """SCBK encoding sets fermion_mode_order to INTERLEAVED.""" + hamiltonian = create_nontrivial_test_hamiltonian() + mapping = _make_scbk_mapping(hamiltonian) + symmetries = Symmetries(n_alpha=1, n_beta=1) + + qh = create("qubit_mapper", "openfermion").run(hamiltonian, mapping, symmetries) + + assert qh.fermion_mode_order == FermionModeOrder.INTERLEAVED + + +def test_openfermion_scbk_matches_direct_openfermion(): + """SCBK encoding matches direct use of openfermion.transforms.symmetry_conserving_bravyi_kitaev.""" + hamiltonian = create_nontrivial_test_hamiltonian() + mapping = _make_scbk_mapping(hamiltonian) + symmetries = Symmetries(n_alpha=1, n_beta=1) + + qh = create("qubit_mapper", "openfermion").run(hamiltonian, mapping, symmetries) + + # Build reference directly via OpenFermion + fop = hamiltonian_to_fermion_operator(hamiltonian) + h1_alpha, _ = hamiltonian.get_one_body_integrals() + n_spinorbitals = 2 * h1_alpha.shape[0] + n_active_electrons = symmetries.n_particles + + ref_qop = of.transforms.symmetry_conserving_bravyi_kitaev(fop, n_spinorbitals, n_active_electrons) + ref_qop.compress() + + # Remove core energy (same as plugin does) + core_energy = hamiltonian.get_core_energy() + if abs(core_energy) > 1e-15: + ref_qop -= core_energy * of.QubitOperator(()) + ref_qop.compress() + + ref_qh = qubit_operator_to_qubit_hamiltonian( + ref_qop, encoding="symmetry-conserving-bravyi-kitaev", fermion_mode_order=FermionModeOrder.INTERLEAVED + ) + + _assert_pauli_ops_equal(qh, ref_qh) + + +def test_openfermion_scbk_requires_symmetries(): + """SCBK encoding raises ValueError when symmetries is not provided.""" + hamiltonian = create_nontrivial_test_hamiltonian() + mapping = _make_scbk_mapping(hamiltonian) + + mapper = create("qubit_mapper", "openfermion") + with pytest.raises(ValueError, match="symmetry-conserving Bravyi-Kitaev"): + mapper.run(hamiltonian, mapping) + + # ------------------------------------------------------------------------------------- # Error handling # ------------------------------------------------------------------------------------- From 8c4813fe369e6c045adb35a171530ad8ce1749ae Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 11 May 2026 12:09:51 -0700 Subject: [PATCH 077/117] feat: tapering infrastructure for symmetry-conserving encodings Add TaperingSpecification as generic Z2 symmetry tapering infrastructure. MajoranaMapping factories for symmetry-conserving Bravyi-Kitaev and parity two-qubit reduction bundle tapering specs so that mapper.run() produces reduced QubitHamiltonians in one step. - TaperingSpecification data class with SCBK and parity factories - MajoranaMapping.symmetry_conserving_bravyi_kitaev() factory - MajoranaMapping.parity(n, symmetries) for two-qubit reduction - MajoranaMapping.num_qubits now reflects post-taper count - QDK mapper applies tapering after engine call - QubitHamiltonian carries tapering metadata + serialization - Removed symmetries param from all mapper _run_impl() signatures - Removed SCBK name-dispatch from OpenFermion plugin Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../comprehensive/algorithms/qubit_mapper.rst | 4 +- .../user/comprehensive/data/symmetries.rst | 5 +- .../qubit_mapper/qdk_qubit_mapper.py | 26 +- .../algorithms/qubit_mapper/qubit_mapper.py | 4 +- python/src/qdk_chemistry/data/__init__.py | 2 + .../qdk_chemistry/data/majorana_mapping.py | 108 +++++++- .../qdk_chemistry/data/qubit_hamiltonian.py | 22 ++ python/src/qdk_chemistry/data/tapering.py | 246 ++++++++++++++++++ .../plugins/openfermion/qubit_mapper.py | 73 ++---- .../plugins/qiskit/qubit_mapper.py | 3 - python/src/qdk_chemistry/utils/tapering.py | 213 +++++++++++++++ .../test_interop_openfermion_qubit_mapper.py | 140 +++++----- python/tests/test_majorana_mapping.py | 105 ++++++++ python/tests/test_qdk_qubit_mapper.py | 118 ++++++++- python/tests/test_tapering.py | 108 ++++++++ 15 files changed, 1022 insertions(+), 155 deletions(-) create mode 100644 python/src/qdk_chemistry/data/tapering.py create mode 100644 python/src/qdk_chemistry/utils/tapering.py create mode 100644 python/tests/test_tapering.py diff --git a/docs/source/user/comprehensive/algorithms/qubit_mapper.rst b/docs/source/user/comprehensive/algorithms/qubit_mapper.rst index 5897a3592..5af393a33 100644 --- a/docs/source/user/comprehensive/algorithms/qubit_mapper.rst +++ b/docs/source/user/comprehensive/algorithms/qubit_mapper.rst @@ -45,7 +45,7 @@ Parity :cite:`Seeley2012` .. _encoding-scbk: Symmetry-conserving Bravyi-Kitaev :cite:`Bravyi2017tapering` - Exploits particle-number and spin-parity symmetries to reduce the qubit count by 2. Requires a :class:`~qdk_chemistry.data.Symmetries` object. + Exploits particle-number and spin-parity symmetries to reduce the qubit count by 2. Use :meth:`~qdk_chemistry.data.MajoranaMapping.symmetry_conserving_bravyi_kitaev` with a :class:`~qdk_chemistry.data.Symmetries` object. .. _encoding-bk-tree: @@ -225,7 +225,7 @@ Related classes - :doc:`Hamiltonian <../data/hamiltonian>`: Input Hamiltonian for mapping - :doc:`MajoranaMapping <../data/majorana_mapping>`: Fermion-to-qubit encoding passed to ``run()`` - :class:`~qdk_chemistry.data.QubitHamiltonian`: Output qubit operator representation -- :doc:`Symmetries <../data/symmetries>`: Physical symmetries (e.g., conserved quantum numbers) for symmetry-exploiting algorithms +- :doc:`Symmetries <../data/symmetries>`: Physical symmetries (e.g., conserved quantum numbers) for :meth:`~qdk_chemistry.data.MajoranaMapping.symmetry_conserving_bravyi_kitaev` Further reading --------------- diff --git a/docs/source/user/comprehensive/data/symmetries.rst b/docs/source/user/comprehensive/data/symmetries.rst index eb93c8fc4..8ad4f37a3 100644 --- a/docs/source/user/comprehensive/data/symmetries.rst +++ b/docs/source/user/comprehensive/data/symmetries.rst @@ -8,7 +8,7 @@ Overview -------- Many quantum algorithms can reduce circuit depth, qubit count, or classical post-processing cost when the symmetries of the target quantum state are known in advance. -The :class:`~qdk_chemistry.data.Symmetries` class encapsulates this symmetry information so that it can be passed to any algorithm that exploits it, such as the :ref:`symmetry-conserving Bravyi-Kitaev ` qubit mapping. +The :class:`~qdk_chemistry.data.Symmetries` class encapsulates this symmetry information so that it can be passed to encoding factories like :meth:`~qdk_chemistry.data.MajoranaMapping.symmetry_conserving_bravyi_kitaev` for :ref:`symmetry-conserving Bravyi-Kitaev ` qubit tapering. Conserved quantum numbers @@ -98,5 +98,6 @@ Related classes Further reading --------------- -- :doc:`QubitMapper <../algorithms/qubit_mapper>`: Fermion-to-qubit mapping algorithms that consume ``Symmetries`` +- :doc:`QubitMapper <../algorithms/qubit_mapper>`: Fermion-to-qubit mapping algorithms +- :meth:`~qdk_chemistry.data.MajoranaMapping.symmetry_conserving_bravyi_kitaev`: Encoding factory that consumes ``Symmetries`` - :doc:`Design principles <../design/index>`: Data class design principles in QDK/Chemistry diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index 20d6bc9c6..1a74b333f 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -24,7 +24,7 @@ from qdk_chemistry.utils import Logger if TYPE_CHECKING: - from qdk_chemistry.data import Hamiltonian, Symmetries + from qdk_chemistry.data import Hamiltonian from qdk_chemistry.data.majorana_mapping import MajoranaMapping __all__ = ["QdkQubitMapper", "QdkQubitMapperSettings"] @@ -108,14 +108,12 @@ def _run_impl( self, hamiltonian: Hamiltonian, mapping: MajoranaMapping, - symmetries: Symmetries | None = None, # noqa: ARG002 ) -> QubitHamiltonian: """Transform a fermionic Hamiltonian to a qubit Hamiltonian. Args: hamiltonian: The fermionic Hamiltonian with one-body and two-body integrals. mapping: The Majorana-to-Pauli encoding. - symmetries: Optional symmetry information. Not used by this implementation. Returns: QubitHamiltonian: The qubit Hamiltonian with Pauli strings and coefficients. @@ -166,9 +164,27 @@ def _run_impl( Logger.debug(f"Generated {len(pauli_strings)} Pauli terms for {2 * n_spatial} qubits") - return QubitHamiltonian( + qh = QubitHamiltonian( pauli_strings=list(pauli_strings), coefficients=np.array(coefficients, dtype=complex), - encoding=mapping.name, + encoding=mapping.base_encoding, fermion_mode_order=FermionModeOrder.BLOCKED, ) + + # Apply post-mapping tapering if specified (e.g. SCBK) + if mapping.tapering is not None: + from qdk_chemistry.utils.tapering import taper_qubits # noqa: PLC0415 + + qh = taper_qubits(qh, mapping.tapering.qubit_indices, mapping.tapering.eigenvalues) + qh = QubitHamiltonian( + pauli_strings=qh.pauli_strings, + coefficients=qh.coefficients, + encoding=mapping.name, + fermion_mode_order=qh.fermion_mode_order, + tapering=mapping.tapering, + ) + Logger.debug( + f"Tapered {mapping.tapering.num_tapered} qubits → {qh.num_qubits} qubits" + ) + + return qh diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 12260c54c..827aa847e 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -14,7 +14,7 @@ from qdk_chemistry.data import Settings if TYPE_CHECKING: # Only needed for type annotations; avoid importing into module namespace - from qdk_chemistry.data import Hamiltonian, QubitHamiltonian, Symmetries + from qdk_chemistry.data import Hamiltonian, QubitHamiltonian from qdk_chemistry.data.majorana_mapping import MajoranaMapping __all__: list[str] = [] @@ -50,14 +50,12 @@ def _run_impl( self, hamiltonian: Hamiltonian, mapping: MajoranaMapping, - symmetries: Symmetries | None = None, ) -> QubitHamiltonian: """Construct a QubitHamiltonian from a Hamiltonian using the given mapping. Args: hamiltonian: The fermionic Hamiltonian. mapping: The Majorana-to-Pauli encoding to use. - symmetries: Optional symmetry information. Required by symmetry-exploiting algorithms. Returns: QubitHamiltonian: An instance of the QubitHamiltonian. diff --git a/python/src/qdk_chemistry/data/__init__.py b/python/src/qdk_chemistry/data/__init__.py index 23e23b26e..881fb2662 100644 --- a/python/src/qdk_chemistry/data/__init__.py +++ b/python/src/qdk_chemistry/data/__init__.py @@ -119,6 +119,7 @@ from qdk_chemistry.data.qpe_result import QpeResult from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian from qdk_chemistry.data.symmetries import Symmetries +from qdk_chemistry.data.tapering import TaperingSpecification from qdk_chemistry.data.term_partition import FlatPartition, LayeredPartition, TermPartition from qdk_chemistry.data.unitary_representation.base import UnitaryRepresentation from qdk_chemistry.data.unitary_representation.containers.base import UnitaryContainer @@ -184,6 +185,7 @@ "StabilityResult", "Structure", "Symmetries", + "TaperingSpecification", "TermPartition", "UnitaryContainer", "UnitaryRepresentation", diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index 80a2a399f..fb0f11c4c 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -20,10 +20,13 @@ from qdk_chemistry._core.data import MajoranaMapping as _CoreMajoranaMapping from qdk_chemistry.data.base import DataClass +from qdk_chemistry.data.tapering import TaperingSpecification if TYPE_CHECKING: import h5py + from qdk_chemistry.data import Symmetries + __all__: list[str] = [] @@ -73,6 +76,7 @@ def __init__( table: list[str] | tuple[str, ...], name: str = "", phases: list[int] | tuple[int, ...] | None = None, + tapering: TaperingSpecification | None = None, *, _core: _CoreMajoranaMapping | None = None, ) -> None: @@ -82,6 +86,7 @@ def __init__( table (list[str] | tuple[str, ...]): 2N Pauli strings in little-endian format (qubit 0 = rightmost char). name (str): Optional human-readable label for the encoding. Default ``""``. phases (list[int] | None): Optional 2N sign factors (+1 or -1) per Majorana operator. Default all +1. + tapering (TaperingSpecification | None): Optional post-mapping tapering specification. Raises: ValueError: If the table is invalid (wrong size, bad characters, or Clifford algebra violation). @@ -94,10 +99,12 @@ def __init__( # Cache immutable properties from the core self._table = self._core.table - self._name = self._core.name + # Allow Python-level name override (e.g. SCBK wraps a BK core) + self._name = name if name else self._core.name self._num_modes = self._core.num_modes self._num_qubits = self._core.num_qubits self._phases = self._core.phases + self._tapering = tapering # Mark immutable super().__init__() @@ -114,7 +121,15 @@ def num_modes(self) -> int: @property def num_qubits(self) -> int: - """Number of qubits required by this encoding.""" + """Effective number of qubits after any tapering. + + For untapered encodings this equals the number of qubits in the Pauli + table. For tapering-based encodings (e.g. symmetry-conserving + Bravyi-Kitaev, parity with two-qubit reduction) this reflects the + reduced qubit count that downstream consumers will see. + """ + if self._tapering is not None: + return self._num_qubits - self._tapering.num_tapered return self._num_qubits @property @@ -127,6 +142,23 @@ def phases(self) -> tuple[int, ...]: """Tuple of per-entry sign factors (+1 or -1). All +1 for standard encodings.""" return self._phases + @property + def tapering(self) -> TaperingSpecification | None: + """Post-mapping tapering specification, or None for untapered encodings.""" + return self._tapering + + @property + def base_encoding(self) -> str: + """The base encoding name used for the Majorana-to-Pauli table. + + For standard encodings this equals :attr:`name`. For tapering-based + encodings like symmetry-conserving Bravyi-Kitaev, this returns the + underlying encoding (e.g. ``"bravyi-kitaev"``) while :attr:`name` + returns the final encoding label + (e.g. ``"symmetry-conserving-bravyi-kitaev"``). + """ + return self._core.name + @property def core(self) -> _CoreMajoranaMapping: """Access the underlying C++ MajoranaMapping object.""" @@ -161,19 +193,80 @@ def bravyi_kitaev(cls, num_modes: int) -> MajoranaMapping: return cls(table=[], _core=core) @classmethod - def parity(cls, num_modes: int) -> MajoranaMapping: - """Construct a parity encoding. + def parity( + cls, + num_modes: int, + symmetries: Symmetries | None = None, + ) -> MajoranaMapping: + """Construct a parity encoding, optionally with two-qubit reduction. + + When ``symmetries`` is provided, the mapping includes a + :class:`~qdk_chemistry.data.TaperingSpecification` that tapers the two + Z₂ symmetry qubits (total electron-number parity and alpha-spin + parity), reducing the qubit count by 2. This is the same two-qubit + reduction used by Qiskit Nature's ``ParityMapper(num_particles=...)``. Args: num_modes (int): Number of fermionic modes (spin-orbitals). + symmetries (Symmetries | None): If provided, enables two-qubit reduction for the target symmetry sector. Returns: - MajoranaMapping: Mapping with name ``"parity"``. + MajoranaMapping: Mapping with name ``"parity"`` (untapered) or ``"parity-2q-reduced"`` (tapered). """ core = _CoreMajoranaMapping.parity(num_modes) + if symmetries is not None: + tapering = TaperingSpecification.parity_two_qubit_reduction(num_modes, symmetries) + return cls(table=[], name="parity-2q-reduced", tapering=tapering, _core=core) return cls(table=[], _core=core) + @classmethod + def symmetry_conserving_bravyi_kitaev( + cls, + num_modes: int, + symmetries: Symmetries, + ) -> MajoranaMapping: + """Construct a symmetry-conserving Bravyi-Kitaev (SCBK) encoding. + + Combines the standard Bravyi-Kitaev mapping with a + :class:`~qdk_chemistry.data.TaperingSpecification` that removes the two Z₂ + symmetry qubits (total electron-number parity and alpha-spin parity), + reducing the qubit count by 2. + + When passed to :meth:`~qdk_chemistry.algorithms.QubitMapper.run`, the + mapper applies the BK mapping first, then tapers the symmetry qubits + automatically. + + Args: + num_modes (int): Number of fermionic modes (spin-orbitals). Must be even and >= 4. + symmetries (Symmetries): Electron counts for the target symmetry sector. + + Returns: + MajoranaMapping: BK mapping with SCBK tapering, name ``"symmetry-conserving-bravyi-kitaev"``. + + Raises: + ValueError: If num_modes < 4 or odd, or electron counts are invalid. + + Examples: + >>> from qdk_chemistry.data import MajoranaMapping, Symmetries + >>> mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + >>> mapping.name + 'symmetry-conserving-bravyi-kitaev' + >>> mapping.base_encoding + 'bravyi-kitaev' + >>> mapping.tapering.num_tapered + 2 + + """ + tapering = TaperingSpecification.symmetry_conserving_bravyi_kitaev(num_modes, symmetries) + core = _CoreMajoranaMapping.bravyi_kitaev(num_modes) + return cls( + table=[], + name="symmetry-conserving-bravyi-kitaev", + tapering=tapering, + _core=core, + ) + @classmethod def from_mode_pairs( cls, @@ -233,6 +326,8 @@ def to_json(self) -> dict[str, Any]: # Only include phases if any are non-default (-1) if any(p != 1 for p in self._phases): data["phases"] = list(self._phases) + if self._tapering is not None: + data["tapering"] = self._tapering.to_json() return self._add_json_version(data) @classmethod @@ -247,10 +342,13 @@ def from_json(cls, json_data: dict[str, Any]) -> MajoranaMapping: """ cls._validate_json_version("0.1.0", json_data) + tapering_data = json_data.get("tapering") + tapering = TaperingSpecification.from_json(tapering_data) if tapering_data else None return cls( table=json_data["table"], name=json_data.get("name", ""), phases=json_data.get("phases"), + tapering=tapering, ) def to_hdf5(self, group: h5py.Group) -> None: diff --git a/python/src/qdk_chemistry/data/qubit_hamiltonian.py b/python/src/qdk_chemistry/data/qubit_hamiltonian.py index 45d77d91f..18efc3588 100644 --- a/python/src/qdk_chemistry/data/qubit_hamiltonian.py +++ b/python/src/qdk_chemistry/data/qubit_hamiltonian.py @@ -27,6 +27,7 @@ import scipy from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder +from qdk_chemistry.data.tapering import TaperingSpecification from qdk_chemistry.utils import Logger __all__: list[str] = [] @@ -48,6 +49,9 @@ class QubitHamiltonian(DataClass): partitions, into parallelisable layers within each group). Set by geometry-aware constructors and by ``term_grouper`` algorithms; reset to ``None`` by transformations that change the term ordering. + tapering (TaperingSpecification | None): If this Hamiltonian was produced by a + tapering-based encoding (e.g. SCBK), records the applied tapering + for downstream consumers. ``None`` for untapered encodings. """ @@ -64,6 +68,7 @@ def __init__( encoding: str | None = None, fermion_mode_order: FermionModeOrder | str | None = None, term_partition: TermPartition | None = None, + tapering: TaperingSpecification | None = None, ) -> None: """Initialize a QubitHamiltonian. @@ -73,6 +78,7 @@ def __init__( encoding (str | None): Fermion-to-qubit encoding (e.g., ``"jordan-wigner"``). Default ``None``. fermion_mode_order (FermionModeOrder | str | None): Mode ordering (``"blocked"``/``"interleaved"``). term_partition (TermPartition | None): Optional ``TermPartition`` carrying group/layer metadata. + tapering (TaperingSpecification | None): Applied tapering metadata, or None if untapered. Raises: ValueError: If the number of Pauli strings and coefficients don't match, @@ -90,6 +96,7 @@ def __init__( FermionModeOrder(fermion_mode_order) if fermion_mode_order is not None else None ) self.term_partition: TermPartition | None = term_partition + self.tapering: TaperingSpecification | None = tapering # Validate Pauli strings _validate_pauli_strings(pauli_strings) @@ -326,6 +333,8 @@ def to_json(self) -> dict[str, Any]: data["fermion_mode_order"] = str(self.fermion_mode_order) if self.term_partition is not None: data["term_partition"] = self.term_partition.to_json() + if self.tapering is not None: + data["tapering"] = self.tapering.to_json() return self._add_json_version(data) def to_hdf5(self, group: h5py.Group) -> None: @@ -344,6 +353,8 @@ def to_hdf5(self, group: h5py.Group) -> None: group.attrs["fermion_mode_order"] = str(self.fermion_mode_order) if self.term_partition is not None: group.attrs["term_partition"] = json.dumps(self.term_partition.to_json()) + if self.tapering is not None: + group.attrs["tapering"] = json.dumps(self.tapering.to_json()) @classmethod def from_json(cls, json_data: dict[str, Any]) -> QubitHamiltonian: @@ -369,12 +380,15 @@ def from_json(cls, json_data: dict[str, Any]) -> QubitHamiltonian: coefficients = np.array(coeff_data) partition_data = json_data.get("term_partition") term_partition = TermPartition.from_json(partition_data) if partition_data is not None else None + tapering_data = json_data.get("tapering") + tapering = TaperingSpecification.from_json(tapering_data) if tapering_data is not None else None return cls( pauli_strings=json_data["pauli_strings"], coefficients=coefficients, encoding=json_data.get("encoding"), fermion_mode_order=json_data.get("fermion_mode_order"), term_partition=term_partition, + tapering=tapering, ) @classmethod @@ -408,12 +422,20 @@ def from_hdf5(cls, group: h5py.Group) -> QubitHamiltonian: term_partition = TermPartition.from_json(json.loads(partition_attr)) else: term_partition = None + tapering_attr = group.attrs.get("tapering") + if tapering_attr is not None: + if isinstance(tapering_attr, bytes): + tapering_attr = tapering_attr.decode("utf-8") + tapering = TaperingSpecification.from_json(json.loads(tapering_attr)) + else: + tapering = None return cls( pauli_strings=pauli_strings, coefficients=coefficients, encoding=encoding, fermion_mode_order=fermion_mode_order, term_partition=term_partition, + tapering=tapering, ) diff --git a/python/src/qdk_chemistry/data/tapering.py b/python/src/qdk_chemistry/data/tapering.py new file mode 100644 index 000000000..855e6064a --- /dev/null +++ b/python/src/qdk_chemistry/data/tapering.py @@ -0,0 +1,246 @@ +"""Tapering specification for symmetry-conserving encodings. + +A :class:`TaperingSpecification` describes which qubits to taper and the eigenvalue +to assign to each, enabling post-mapping qubit reduction for encodings like +symmetry-conserving Bravyi-Kitaev (SCBK). +""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from qdk_chemistry.data import Symmetries + +__all__: list[str] = [] + + +class TaperingSpecification: + """Immutable specification for post-mapping qubit tapering. + + Describes which qubits are constrained by symmetry and the Z eigenvalue + (+1 or -1) to substitute for each. When attached to a + :class:`~qdk_chemistry.data.MajoranaMapping`, the mapper applies this + tapering automatically after the Majorana-to-Pauli mapping. + + Attributes: + qubit_indices (tuple[int, ...]): Qubit indices to taper (0-indexed, pre-taper numbering). + eigenvalues (tuple[int, ...]): Corresponding Z eigenvalues (+1 or -1). + source_num_qubits (int): Number of qubits in the pre-taper register. + source_encoding (str): Encoding of the pre-taper mapping (e.g. ``"bravyi-kitaev"``). + + """ + + __slots__ = ("_eigenvalues", "_qubit_indices", "_source_encoding", "_source_num_qubits") + + def __init__( + self, + qubit_indices: tuple[int, ...] | list[int], + eigenvalues: tuple[int, ...] | list[int], + source_num_qubits: int, + source_encoding: str = "", + ) -> None: + """Initialize a TaperingSpecification. + + Args: + qubit_indices (tuple[int, ...] | list[int]): Qubit indices to taper (0-indexed). + eigenvalues (tuple[int, ...] | list[int]): Corresponding Z eigenvalues (+1 or -1). + source_num_qubits (int): Number of qubits in the pre-taper register. + source_encoding (str): Encoding name of the pre-taper mapping. + + Raises: + ValueError: If lengths don't match, indices out of range, duplicates, or invalid eigenvalues. + + """ + qi = tuple(qubit_indices) + ev = tuple(eigenvalues) + if len(qi) != len(ev): + raise ValueError(f"qubit_indices length ({len(qi)}) must match eigenvalues length ({len(ev)})") + if len(set(qi)) != len(qi): + raise ValueError("qubit_indices must not contain duplicates") + for q in qi: + if q < 0 or q >= source_num_qubits: + raise ValueError(f"Qubit index {q} out of range [0, {source_num_qubits})") + for v in ev: + if v not in (1, -1): + raise ValueError(f"Eigenvalue must be +1 or -1, got {v}") + + object.__setattr__(self, "_qubit_indices", qi) + object.__setattr__(self, "_eigenvalues", ev) + object.__setattr__(self, "_source_num_qubits", source_num_qubits) + object.__setattr__(self, "_source_encoding", source_encoding) + + def __setattr__(self, _name: str, _value: object) -> None: + raise AttributeError("TaperingSpecification is immutable") + + def __delattr__(self, _name: str) -> None: + raise AttributeError("TaperingSpecification is immutable") + + @property + def qubit_indices(self) -> tuple[int, ...]: + """Qubit indices to taper (0-indexed, pre-taper numbering).""" + return self._qubit_indices + + @property + def eigenvalues(self) -> tuple[int, ...]: + """Z eigenvalues (+1 or -1) for each tapered qubit.""" + return self._eigenvalues + + @property + def source_num_qubits(self) -> int: + """Number of qubits in the pre-taper register.""" + return self._source_num_qubits + + @property + def source_encoding(self) -> str: + """Encoding name of the pre-taper mapping.""" + return self._source_encoding + + @property + def num_tapered(self) -> int: + """Number of qubits removed by this tapering.""" + return len(self._qubit_indices) + + @classmethod + def symmetry_conserving_bravyi_kitaev(cls, num_modes: int, symmetries: Symmetries) -> TaperingSpecification: + """Create a tapering specification for symmetry-conserving Bravyi-Kitaev. + + Tapers the two Z₂ symmetry qubits of the Bravyi-Kitaev encoding: + total electron-number parity (qubit n-1) and alpha-spin parity + (qubit n/2-1), reducing the qubit count by 2. + + Args: + num_modes (int): Number of spin-orbitals (= number of Bravyi-Kitaev qubits). + symmetries (Symmetries): Electron counts for the target symmetry sector. + + Returns: + TaperingSpecification: Tapering specification for symmetry-conserving Bravyi-Kitaev. + + Raises: + ValueError: If num_modes < 4 or odd, or electron counts exceed available orbitals. + + """ + n = num_modes + if n < 4 or n % 2 != 0: + raise ValueError(f"Symmetry-conserving Bravyi-Kitaev requires an even num_modes >= 4, got {n}") + if symmetries.n_alpha < 0 or symmetries.n_beta < 0: + raise ValueError("n_alpha and n_beta must be non-negative") + if symmetries.n_alpha > n // 2: + raise ValueError(f"n_alpha ({symmetries.n_alpha}) exceeds spatial orbitals ({n // 2})") + if symmetries.n_beta > n // 2: + raise ValueError(f"n_beta ({symmetries.n_beta}) exceeds spatial orbitals ({n // 2})") + + ev_total = 1 if (symmetries.n_alpha + symmetries.n_beta) % 2 == 0 else -1 + ev_alpha = 1 if symmetries.n_alpha % 2 == 0 else -1 + + q_alpha = n // 2 - 1 + q_total = n - 1 + + return cls( + qubit_indices=(q_alpha, q_total), + eigenvalues=(ev_alpha, ev_total), + source_num_qubits=n, + source_encoding="bravyi-kitaev", + ) + + @classmethod + def parity_two_qubit_reduction(cls, num_modes: int, symmetries: Symmetries) -> TaperingSpecification: + """Create a tapering specification for parity encoding two-qubit reduction. + + Tapers the same two Z₂ symmetry qubits as the symmetry-conserving + Bravyi-Kitaev encoding: total electron-number parity (qubit n-1) and + alpha-spin parity (qubit n/2-1). + + Args: + num_modes (int): Number of spin-orbitals (= number of parity qubits). + symmetries (Symmetries): Electron counts for the target symmetry sector. + + Returns: + TaperingSpecification: Tapering specification for parity two-qubit reduction. + + Raises: + ValueError: If num_modes < 4 or odd, or electron counts exceed available orbitals. + + """ + n = num_modes + if n < 4 or n % 2 != 0: + raise ValueError(f"Parity two-qubit reduction requires an even num_modes >= 4, got {n}") + if symmetries.n_alpha < 0 or symmetries.n_beta < 0: + raise ValueError("n_alpha and n_beta must be non-negative") + if symmetries.n_alpha > n // 2: + raise ValueError(f"n_alpha ({symmetries.n_alpha}) exceeds spatial orbitals ({n // 2})") + if symmetries.n_beta > n // 2: + raise ValueError(f"n_beta ({symmetries.n_beta}) exceeds spatial orbitals ({n // 2})") + + ev_total = 1 if (symmetries.n_alpha + symmetries.n_beta) % 2 == 0 else -1 + ev_alpha = 1 if symmetries.n_alpha % 2 == 0 else -1 + + q_alpha = n // 2 - 1 + q_total = n - 1 + + return cls( + qubit_indices=(q_alpha, q_total), + eigenvalues=(ev_alpha, ev_total), + source_num_qubits=n, + source_encoding="parity", + ) + + def to_json(self) -> dict[str, Any]: + """Serialize to a JSON-compatible dictionary. + + Returns: + dict[str, Any]: Dictionary representation. + + """ + return { + "qubit_indices": list(self._qubit_indices), + "eigenvalues": list(self._eigenvalues), + "source_num_qubits": self._source_num_qubits, + "source_encoding": self._source_encoding, + } + + @classmethod + def from_json(cls, data: dict[str, Any]) -> TaperingSpecification: + """Deserialize from a JSON dictionary. + + Args: + data (dict[str, Any]): Dictionary produced by :meth:`to_json`. + + Returns: + TaperingSpecification: The deserialized tapering specification. + + """ + return cls( + qubit_indices=tuple(data["qubit_indices"]), + eigenvalues=tuple(data["eigenvalues"]), + source_num_qubits=data["source_num_qubits"], + source_encoding=data.get("source_encoding", ""), + ) + + def __repr__(self) -> str: + """Return a repr string.""" + return ( + f"TaperingSpecification(qubit_indices={self._qubit_indices}, " + f"eigenvalues={self._eigenvalues}, " + f"source_num_qubits={self._source_num_qubits})" + ) + + def __eq__(self, other: object) -> bool: + """Check equality.""" + if not isinstance(other, TaperingSpecification): + return NotImplemented + return ( + self._qubit_indices == other._qubit_indices + and self._eigenvalues == other._eigenvalues + and self._source_num_qubits == other._source_num_qubits + and self._source_encoding == other._source_encoding + ) + + def __hash__(self) -> int: + """Return hash.""" + return hash((self._qubit_indices, self._eigenvalues, self._source_num_qubits, self._source_encoding)) diff --git a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py index a4e9ff098..abbc1e2c7 100644 --- a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py @@ -20,19 +20,20 @@ from qdk_chemistry.algorithms.qubit_mapper import QubitMapper, QubitMapperSettings from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.plugins.openfermion.conversion import ( - hamiltonian_to_fermion_operator, hamiltonian_to_interaction_operator, qubit_operator_to_qubit_hamiltonian, ) from qdk_chemistry.utils import Logger if TYPE_CHECKING: - from qdk_chemistry.data import Hamiltonian, QubitHamiltonian, Symmetries + from collections.abc import Callable + + from qdk_chemistry.data import Hamiltonian, QubitHamiltonian from qdk_chemistry.data.majorana_mapping import MajoranaMapping __all__ = ["OpenFermionQubitMapper", "OpenFermionQubitMapperSettings"] -_STANDARD_TRANSFORMS: dict[str, object] = { +_STANDARD_TRANSFORMS: dict[str, Callable[..., of.QubitOperator]] = { "jordan-wigner": of.transforms.jordan_wigner, "bravyi-kitaev": of.transforms.bravyi_kitaev, "bravyi-kitaev-tree": of.transforms.bravyi_kitaev_tree, @@ -53,8 +54,9 @@ class OpenFermionQubitMapper(QubitMapper): The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` passed to :meth:`run`. The plugin uses ``mapping.name`` to select the - corresponding OpenFermion transform. Custom (unnamed) mappings are not - supported -- use the QDK variant instead. + corresponding OpenFermion transform. Custom (unnamed) mappings and + tapering-based encodings (symmetry-conserving Bravyi-Kitaev, parity + two-qubit reduction) are not supported — use the QDK mapper instead. Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. For unrestricted systems, separate alpha/beta spin channels are handled @@ -64,7 +66,6 @@ class OpenFermionQubitMapper(QubitMapper): - ``"jordan-wigner"`` - ``"bravyi-kitaev"`` - ``"bravyi-kitaev-tree"`` - - ``"symmetry-conserving-bravyi-kitaev"`` (requires :class:`~qdk_chemistry.data.Symmetries`) Examples: >>> from qdk_chemistry.algorithms import create @@ -85,7 +86,6 @@ def _run_impl( self, hamiltonian: Hamiltonian, mapping: MajoranaMapping, - symmetries: Symmetries | None = None, ) -> QubitHamiltonian: """Construct a QubitHamiltonian from a Hamiltonian using the selected mapping strategy. @@ -94,7 +94,6 @@ def _run_impl( Args: hamiltonian: The fermionic Hamiltonian (restricted or unrestricted). mapping: The Majorana-to-Pauli encoding. Only built-in encodings are supported. - symmetries: Symmetry information. Required for ``"symmetry-conserving-bravyi-kitaev"`` encoding. Returns: QubitHamiltonian: An instance of the QubitHamiltonian. @@ -106,19 +105,18 @@ def _run_impl( Logger.trace_entering() encoding_name = mapping.name - if encoding_name == "symmetry-conserving-bravyi-kitaev": - qubit_op = self._map_scbk(hamiltonian, symmetries) - fermion_mode_order = FermionModeOrder.INTERLEAVED - elif encoding_name in _STANDARD_TRANSFORMS: - qubit_op = self._map_standard(hamiltonian, encoding_name) - fermion_mode_order = FermionModeOrder.BLOCKED - else: + if encoding_name not in _STANDARD_TRANSFORMS: raise NotImplementedError( f"OpenFermion plugin does not support MajoranaMapping with name {encoding_name!r}. " - f"Supported names: {sorted([*_STANDARD_TRANSFORMS.keys(), 'symmetry-conserving-bravyi-kitaev'])}. " - f"Use the QDK variant for custom mappings." + f"Supported names: {sorted(_STANDARD_TRANSFORMS.keys())}. " + f"For tapering-based encodings, use the QDK mapper with " + f"MajoranaMapping.symmetry_conserving_bravyi_kitaev() or " + f"MajoranaMapping.parity(n, symmetries)." ) + qubit_op = self._map_standard(hamiltonian, encoding_name) + fermion_mode_order = FermionModeOrder.BLOCKED + Logger.debug(f"Mapping Hamiltonian with OpenFermion encoding: {encoding_name}") qubit_op.compress() @@ -155,47 +153,6 @@ def _map_standard(self, hamiltonian: Hamiltonian, encoding: str) -> of.QubitOper transform = _STANDARD_TRANSFORMS[encoding] return transform(fermion_op) - def _map_scbk(self, hamiltonian: Hamiltonian, symmetries: Symmetries | None) -> of.QubitOperator: - """Apply symmetry-conserving Bravyi-Kitaev transformation. - - This transform reduces the qubit count by 2 by exploiting particle number - and spin symmetry. Uses interleaved spin-orbital ordering (OpenFermion native). - - Args: - hamiltonian: The fermionic Hamiltonian. - symmetries: Symmetry information providing the active electron count. - - Returns: - openfermion.QubitOperator: The mapped qubit operator. - - Raises: - ValueError: If ``symmetries`` is not provided. - - """ - if symmetries is None: - raise ValueError( - "The symmetry-conserving Bravyi-Kitaev encoding requires a Symmetries " - "object specifying the number of active electrons.\n" - "Example:\n" - " from qdk_chemistry.data import Symmetries\n" - " symmetries = Symmetries(n_alpha=1, n_beta=1)\n" - " qubit_hamiltonian = mapper.run(hamiltonian, mapping, symmetries)" - ) - - fermion_op = hamiltonian_to_fermion_operator(hamiltonian) - n_active_electrons = symmetries.n_particles - - h1_alpha, _ = hamiltonian.get_one_body_integrals() - n_spinorbitals = 2 * h1_alpha.shape[0] - - Logger.debug(f"SCBK: n_spinorbitals={n_spinorbitals}, n_active_electrons={n_active_electrons}") - - return of.transforms.symmetry_conserving_bravyi_kitaev( - fermion_op, - n_spinorbitals, - n_active_electrons, - ) - def name(self) -> str: """Return the algorithm name ``openfermion``.""" Logger.trace_entering() diff --git a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py index 330bc9b1f..1faf7a23e 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py @@ -25,7 +25,6 @@ from qdk_chemistry.utils import Logger if TYPE_CHECKING: - from qdk_chemistry.data import Symmetries from qdk_chemistry.data.majorana_mapping import MajoranaMapping __all__ = ["QiskitQubitMapper", "QiskitQubitMapperSettings"] @@ -84,7 +83,6 @@ def _run_impl( self, hamiltonian: Hamiltonian, mapping: MajoranaMapping, - symmetries: Symmetries | None = None, # noqa: ARG002 ) -> QubitHamiltonian: """Construct a QubitHamiltonian from a Hamiltonian using the selected mapping strategy. @@ -95,7 +93,6 @@ def _run_impl( Args: hamiltonian: The fermionic Hamiltonian (restricted or unrestricted). mapping: The Majorana-to-Pauli encoding. Only built-in encodings are supported. - symmetries: Optional symmetry information. Not used by this implementation. Returns: QubitHamiltonian: An instance of the QubitHamiltonian. diff --git a/python/src/qdk_chemistry/utils/tapering.py b/python/src/qdk_chemistry/utils/tapering.py new file mode 100644 index 000000000..bbb14c485 --- /dev/null +++ b/python/src/qdk_chemistry/utils/tapering.py @@ -0,0 +1,213 @@ +"""Internal qubit tapering utilities for symmetry-conserving encodings. + +Provides functions that post-process a :class:`~qdk_chemistry.data.QubitHamiltonian` +by projecting out qubits whose Z₂ eigenvalues are fixed by symmetry, reducing the +qubit count without loss of information within the symmetry sector. + +These are internal utilities used by the QDK mapper when a +:class:`~qdk_chemistry.data.MajoranaMapping` carries a +:class:`~qdk_chemistry.data.TaperingSpecification`. Users should prefer the +one-step API:: + + from qdk_chemistry.algorithms import create + from qdk_chemistry.data import MajoranaMapping, Symmetries + + mapper = create("qubit_mapper") + mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev( + num_modes=n_spin_orbitals, symmetries=Symmetries(n_alpha=2, n_beta=2) + ) + qh = mapper.run(hamiltonian, mapping) # already tapered +""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from collections.abc import Sequence + +if TYPE_CHECKING: + from qdk_chemistry.data import QubitHamiltonian, Symmetries + +__all__ = ["taper_qubits", "taper_to_scbk"] + + +def taper_qubits( + qubit_hamiltonian: QubitHamiltonian, + qubit_indices: Sequence[int], + eigenvalues: Sequence[int], +) -> QubitHamiltonian: + """Remove qubits with known Z eigenvalues from a qubit Hamiltonian. + + For each specified qubit, every Pauli term that has Z on that qubit gets + its coefficient multiplied by the eigenvalue, and the Z is replaced with I. + Terms with X or Y on a tapered qubit are dropped (they connect different + symmetry sectors). After replacement, the tapered qubit positions are + removed from all strings and the remaining qubits are renumbered. + + This implements the qubit tapering step of symmetry-conserving encodings + such as SCBK (arXiv:1701.08213). + + Args: + qubit_hamiltonian (QubitHamiltonian): The qubit Hamiltonian to taper. + qubit_indices (Sequence[int]): Qubit indices to taper (0-indexed). + eigenvalues (Sequence[int]): Corresponding Z eigenvalues (+1 or -1) for each qubit. + + Returns: + QubitHamiltonian: A new QubitHamiltonian with the specified qubits removed. + + Raises: + ValueError: If lengths don't match, indices are out of range, contain duplicates, or eigenvalues are not ±1. + + """ + from qdk_chemistry.data import QubitHamiltonian # noqa: PLC0415 + + qubit_indices = list(qubit_indices) + eigenvalues = list(eigenvalues) + + if len(qubit_indices) != len(eigenvalues): + raise ValueError( + f"qubit_indices length ({len(qubit_indices)}) must match eigenvalues length ({len(eigenvalues)})" + ) + if len(set(qubit_indices)) != len(qubit_indices): + raise ValueError("qubit_indices must not contain duplicates") + + nq = qubit_hamiltonian.num_qubits + for q in qubit_indices: + if q < 0 or q >= nq: + raise ValueError(f"Qubit index {q} out of range [0, {nq})") + for ev in eigenvalues: + if ev not in (1, -1): + raise ValueError(f"Eigenvalue must be +1 or -1, got {ev}") + + # String positions to remove (little-endian: qubit q is at position nq-1-q) + positions_to_remove = sorted([nq - 1 - q for q in qubit_indices]) + eigenvalue_map = dict(zip(qubit_indices, eigenvalues, strict=True)) + + new_strings: list[str] = [] + new_coeffs: list[complex] = [] + + for pauli_str, coeff in zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=True): + skip = False + adjusted_coeff = complex(coeff) + + for q, ev in eigenvalue_map.items(): + pos = nq - 1 - q + char = pauli_str[pos] + if char == "Z": + adjusted_coeff *= ev + elif char in ("X", "Y"): + skip = True + break + # 'I' → no change + + if skip: + continue + + # Remove the tapered qubit positions from the string + chars = [c for i, c in enumerate(pauli_str) if i not in positions_to_remove] + new_str = "".join(chars) + + new_strings.append(new_str) + new_coeffs.append(adjusted_coeff) + + if not new_strings: + raise ValueError("All Pauli terms were eliminated by tapering") + + # Merge duplicate Pauli strings + merged: dict[str, complex] = {} + for s, c in zip(new_strings, new_coeffs, strict=True): + merged[s] = merged.get(s, 0.0) + c + + # Filter near-zero terms + final_strings = [] + final_coeffs = [] + for s, c in merged.items(): + if abs(c) > np.finfo(np.float64).eps: + final_strings.append(s) + final_coeffs.append(c) + + if not final_strings: + raise ValueError("All Pauli terms cancelled after tapering") + + return QubitHamiltonian( + pauli_strings=final_strings, + coefficients=np.array(final_coeffs), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + ) + + +def taper_to_scbk( + qubit_hamiltonian: QubitHamiltonian, + symmetries: Symmetries, +) -> QubitHamiltonian: + """Apply symmetry-conserving Bravyi-Kitaev tapering to a BK-encoded Hamiltonian. + + Tapers the two Z₂ symmetry qubits of the Bravyi-Kitaev encoding — total + electron-number parity (qubit n-1) and alpha-spin parity (qubit n/2-1) — + reducing the qubit count by 2. The eigenvalues are determined by the + electron counts following the convention of arXiv:1701.08213. + + The input must be a BK-encoded QubitHamiltonian with an even number of + qubits (= 2 * n_spatial). + + Args: + qubit_hamiltonian (QubitHamiltonian): A Bravyi-Kitaev encoded qubit Hamiltonian. + symmetries (Symmetries): Symmetry information providing ``n_alpha`` and ``n_beta`` electron counts. + + Returns: + QubitHamiltonian: Tapered Hamiltonian with 2 fewer qubits and encoding ``"symmetry-conserving-bravyi-kitaev"``. + + Raises: + ValueError: If encoding is not ``"bravyi-kitaev"``, or qubit count is odd or < 4. + + """ + from qdk_chemistry.data import QubitHamiltonian # noqa: PLC0415 + from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder # noqa: PLC0415 + + if qubit_hamiltonian.encoding != "bravyi-kitaev": + raise ValueError( + f"taper_to_scbk requires a Bravyi-Kitaev encoded QubitHamiltonian " + f"(encoding='bravyi-kitaev'), got encoding={qubit_hamiltonian.encoding!r}. " + f"Use MajoranaMapping.bravyi_kitaev() to produce a BK-encoded Hamiltonian first." + ) + + if ( + qubit_hamiltonian.fermion_mode_order is not None + and qubit_hamiltonian.fermion_mode_order != FermionModeOrder.BLOCKED + ): + raise ValueError( + f"taper_to_scbk requires blocked spin-orbital ordering " + f"(fermion_mode_order='blocked'), got {qubit_hamiltonian.fermion_mode_order!r}. " + f"The QDK and OpenFermion BK mappers produce blocked ordering by default." + ) + + n = qubit_hamiltonian.num_qubits + if n < 4 or n % 2 != 0: + raise ValueError(f"SCBK tapering requires an even qubit count >= 4, got {n}") + + # In blocked BK ordering [α₀,α₁,…,αₙ₋₁,β₀,β₁,…,βₙ₋₁]: + # qubit n/2-1 stores alpha electron number parity + # qubit n-1 stores total electron number parity + ev_total = 1 if (symmetries.n_alpha + symmetries.n_beta) % 2 == 0 else -1 + ev_alpha = 1 if symmetries.n_alpha % 2 == 0 else -1 + + q_total = n - 1 + q_alpha = n // 2 - 1 + + result = taper_qubits(qubit_hamiltonian, [q_alpha, q_total], [ev_alpha, ev_total]) + return QubitHamiltonian( + pauli_strings=result.pauli_strings, + coefficients=result.coefficients, + encoding="symmetry-conserving-bravyi-kitaev", + fermion_mode_order=result.fermion_mode_order, + term_partition=result.term_partition, + ) diff --git a/python/tests/test_interop_openfermion_qubit_mapper.py b/python/tests/test_interop_openfermion_qubit_mapper.py index c706cd974..715ae13ce 100644 --- a/python/tests/test_interop_openfermion_qubit_mapper.py +++ b/python/tests/test_interop_openfermion_qubit_mapper.py @@ -138,86 +138,114 @@ def test_openfermion_bk_tree_encoding(): # ------------------------------------------------------------------------------------- -# Symmetry-conserving Bravyi-Kitaev (SCBK) +# Symmetry-conserving Bravyi-Kitaev via one-step MajoranaMapping API # ------------------------------------------------------------------------------------- -def _make_scbk_mapping(hamiltonian: Hamiltonian) -> MajoranaMapping: - """Build a MajoranaMapping with name ``"symmetry-conserving-bravyi-kitaev"``. - - SCBK uses name-dispatch, not the Majorana table. Use a JW table as placeholder. - """ - n_spin = _num_spin_orbitals(hamiltonian) - jw = MajoranaMapping.jordan_wigner(num_modes=n_spin) - return MajoranaMapping(table=list(jw.table), name="symmetry-conserving-bravyi-kitaev") - - -def test_openfermion_scbk_produces_qubit_hamiltonian(): - """SCBK encoding produces a valid QubitHamiltonian with reduced qubit count.""" +def test_scbk_one_step_produces_reduced_hamiltonian(): + """One-step symmetry-conserving BK mapping produces a QubitHamiltonian with 2 fewer qubits.""" hamiltonian = create_nontrivial_test_hamiltonian() - mapping = _make_scbk_mapping(hamiltonian) - symmetries = Symmetries(n_alpha=1, n_beta=1) + n_spin = _num_spin_orbitals(hamiltonian) + mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev(n_spin, Symmetries(n_alpha=1, n_beta=1)) - qh = create("qubit_mapper", "openfermion").run(hamiltonian, mapping, symmetries) + qh = create("qubit_mapper", "qdk").run(hamiltonian, mapping) assert qh is not None assert len(qh.pauli_strings) > 0 - # SCBK reduces qubit count by 2 relative to the number of spin-orbitals - n_spin = _num_spin_orbitals(hamiltonian) assert qh.num_qubits == n_spin - 2 -def test_openfermion_scbk_sets_interleaved_order(): - """SCBK encoding sets fermion_mode_order to INTERLEAVED.""" - hamiltonian = create_nontrivial_test_hamiltonian() - mapping = _make_scbk_mapping(hamiltonian) - symmetries = Symmetries(n_alpha=1, n_beta=1) - - qh = create("qubit_mapper", "openfermion").run(hamiltonian, mapping, symmetries) - - assert qh.fermion_mode_order == FermionModeOrder.INTERLEAVED - - -def test_openfermion_scbk_matches_direct_openfermion(): - """SCBK encoding matches direct use of openfermion.transforms.symmetry_conserving_bravyi_kitaev.""" +def test_scbk_one_step_eigenvalues_match_openfermion(): + """One-step symmetry-conserving BK eigenvalues match OpenFermion SCBK (closed-shell).""" hamiltonian = create_nontrivial_test_hamiltonian() - mapping = _make_scbk_mapping(hamiltonian) + n_spin = _num_spin_orbitals(hamiltonian) symmetries = Symmetries(n_alpha=1, n_beta=1) + core_energy = hamiltonian.get_core_energy() - qh = create("qubit_mapper", "openfermion").run(hamiltonian, mapping, symmetries) + # QDK one-step path + mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev(n_spin, symmetries) + qh_scbk = create("qubit_mapper", "qdk").run(hamiltonian, mapping) - # Build reference directly via OpenFermion + # OpenFermion reference fop = hamiltonian_to_fermion_operator(hamiltonian) - h1_alpha, _ = hamiltonian.get_one_body_integrals() - n_spinorbitals = 2 * h1_alpha.shape[0] - n_active_electrons = symmetries.n_particles - - ref_qop = of.transforms.symmetry_conserving_bravyi_kitaev(fop, n_spinorbitals, n_active_electrons) + ref_qop = of.transforms.symmetry_conserving_bravyi_kitaev(fop, n_spin, symmetries.n_particles) ref_qop.compress() - - # Remove core energy (same as plugin does) - core_energy = hamiltonian.get_core_energy() if abs(core_energy) > 1e-15: ref_qop -= core_energy * of.QubitOperator(()) ref_qop.compress() - ref_qh = qubit_operator_to_qubit_hamiltonian( - ref_qop, encoding="symmetry-conserving-bravyi-kitaev", fermion_mode_order=FermionModeOrder.INTERLEAVED + qdk_qop = qubit_hamiltonian_to_qubit_operator(qh_scbk) + qdk_full = qdk_qop + core_energy * of.QubitOperator(()) + nq = max(qh_scbk.num_qubits, 1) + + ref_mat = of.linalg.get_sparse_operator(ref_qop + core_energy * of.QubitOperator(()), n_qubits=nq).toarray() + qdk_mat = of.linalg.get_sparse_operator(qdk_full, n_qubits=nq).toarray() + + np.testing.assert_allclose( + sorted(np.linalg.eigvalsh(qdk_mat)), + sorted(np.linalg.eigvalsh(ref_mat)), + atol=float_comparison_absolute_tolerance, ) - _assert_pauli_ops_equal(qh, ref_qh) + +def test_scbk_one_step_sets_encoding(): + """One-step symmetry-conserving BK sets encoding correctly.""" + hamiltonian = create_nontrivial_test_hamiltonian() + n_spin = _num_spin_orbitals(hamiltonian) + mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev(n_spin, Symmetries(n_alpha=1, n_beta=1)) + + qh = create("qubit_mapper", "qdk").run(hamiltonian, mapping) + assert qh.encoding == "symmetry-conserving-bravyi-kitaev" -def test_openfermion_scbk_requires_symmetries(): - """SCBK encoding raises ValueError when symmetries is not provided.""" +def test_scbk_name_dispatch_removed(): + """Passing a symmetry-conserving-BK-named mapping to the OF plugin raises NotImplementedError.""" hamiltonian = create_nontrivial_test_hamiltonian() - mapping = _make_scbk_mapping(hamiltonian) + n_spin = _num_spin_orbitals(hamiltonian) + jw = MajoranaMapping.jordan_wigner(n_spin) + mapping = MajoranaMapping(table=list(jw.table), name="symmetry-conserving-bravyi-kitaev") mapper = create("qubit_mapper", "openfermion") - with pytest.raises(ValueError, match="symmetry-conserving Bravyi-Kitaev"): + with pytest.raises(NotImplementedError, match="tapering-based"): mapper.run(hamiltonian, mapping) +@pytest.mark.parametrize( + ("n_alpha", "n_beta"), + [(2, 1), (1, 2), (2, 0), (0, 2)], + ids=["open-2-1", "open-1-2", "open-2-0", "open-0-2"], +) +def test_scbk_open_shell_eigenvalues_subset_of_bk(n_alpha, n_beta): + """Symmetry-conserving BK eigenvalues for open-shell are a subset of full BK eigenvalues. + + OpenFermion's symmetry-conserving BK uses interleaved ordering while QDK uses + blocked, so we can't directly compare open-shell spectra. Instead we verify + that every tapered eigenvalue appears in the full (untapered) BK spectrum. + """ + hamiltonian = create_nontrivial_test_hamiltonian() + n_spin = _num_spin_orbitals(hamiltonian) + symmetries = Symmetries(n_alpha=n_alpha, n_beta=n_beta) + core_energy = hamiltonian.get_core_energy() + + # Full BK spectrum + bk_mapping = MajoranaMapping.bravyi_kitaev(n_spin) + qh_bk = create("qubit_mapper", "qdk").run(hamiltonian, bk_mapping) + bk_mat = qh_bk.to_matrix() + core_energy * np.eye(2**qh_bk.num_qubits) + bk_evals = sorted(np.linalg.eigvalsh(bk_mat)) + + # Symmetry-conserving BK spectrum (one-step) + scbk_mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev(n_spin, symmetries) + qh_scbk = create("qubit_mapper", "qdk").run(hamiltonian, scbk_mapping) + scbk_mat = qh_scbk.to_matrix() + core_energy * np.eye(2**qh_scbk.num_qubits) + scbk_evals = sorted(np.linalg.eigvalsh(scbk_mat)) + + for scbk_ev in scbk_evals: + diffs = [abs(scbk_ev - bk_ev) for bk_ev in bk_evals] + assert min(diffs) < float_comparison_absolute_tolerance, ( + f"Tapered eigenvalue {scbk_ev:.6f} not found in full BK spectrum" + ) + + # ------------------------------------------------------------------------------------- # Error handling # ------------------------------------------------------------------------------------- @@ -234,20 +262,6 @@ def test_openfermion_unsupported_mapping_raises(): mapper.run(hamiltonian, mapping) -def test_symmetries_ignored_for_standard_encodings(): - """Symmetries parameter is accepted but ignored for standard encodings.""" - hamiltonian = create_nontrivial_test_hamiltonian() - n = _num_spin_orbitals(hamiltonian) - mapping = MajoranaMapping.jordan_wigner(n) - symmetries = Symmetries(n_alpha=1, n_beta=1) - - # Should work without error - symmetries is simply ignored - qh_with = create("qubit_mapper", "openfermion").run(hamiltonian, mapping, symmetries) - qh_without = create("qubit_mapper", "openfermion").run(hamiltonian, mapping) - - _assert_pauli_ops_equal(qh_with, qh_without) - - # ------------------------------------------------------------------------------------- # Conversion utilities: Hamiltonian → OpenFermion # ------------------------------------------------------------------------------------- diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index 13932fb0c..060d7547b 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -355,3 +355,108 @@ def test_all_encodings_table_length(self, n_modes: int) -> None: assert len(jw.table) == 2 * n_modes assert len(bk.table) == 2 * n_modes assert len(par.table) == 2 * n_modes + + +# ─── SCBK (symmetry-conserving Bravyi-Kitaev) ──────────────────────────── + + +class TestSymmetryConservingBravyiKitaev: + """Tests for the SCBK factory method and TaperingSpecification integration.""" + + def test_scbk_factory_creates_bk_table(self) -> None: + """SCBK factory uses a standard BK Majorana table underneath.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + bk = MajoranaMapping.bravyi_kitaev(8) + assert scbk.table == bk.table + + def test_scbk_name_and_base_encoding(self) -> None: + """SCBK has the right name and base_encoding properties.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + assert scbk.name == "symmetry-conserving-bravyi-kitaev" + assert scbk.base_encoding == "bravyi-kitaev" + + def test_scbk_has_tapering(self) -> None: + """SCBK mapping has a TaperingSpecification with 2 tapered qubits.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + assert scbk.tapering is not None + assert scbk.tapering.num_tapered == 2 + assert scbk.tapering.source_num_qubits == 8 + assert scbk.tapering.source_encoding == "bravyi-kitaev" + + def test_scbk_eigenvalues_depend_on_alpha_beta(self) -> None: + """Different (n_alpha, n_beta) produce different eigenvalues.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + scbk_11 = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(1, 1)) + scbk_20 = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 0)) + assert scbk_11.tapering.eigenvalues != scbk_20.tapering.eigenvalues + + def test_scbk_num_qubits_is_posttaper_via_property(self) -> None: + """MajoranaMapping.num_qubits reflects the reduced qubit count.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + assert scbk.num_qubits == 6 + + def test_scbk_json_roundtrip(self) -> None: + """SCBK mapping with tapering survives JSON serialization.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + data = scbk.to_json() + loaded = MajoranaMapping.from_json(data) + assert loaded.name == scbk.name + assert loaded.table == scbk.table + assert loaded.tapering is not None + assert loaded.tapering.qubit_indices == scbk.tapering.qubit_indices + assert loaded.tapering.eigenvalues == scbk.tapering.eigenvalues + + def test_scbk_odd_modes_raises(self) -> None: + """SCBK with odd num_modes raises ValueError.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + with pytest.raises(ValueError, match="even"): + MajoranaMapping.symmetry_conserving_bravyi_kitaev(7, Symmetries(1, 1)) + + def test_scbk_too_few_modes_raises(self) -> None: + """SCBK with num_modes < 4 raises ValueError.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + with pytest.raises(ValueError, match="4"): + MajoranaMapping.symmetry_conserving_bravyi_kitaev(2, Symmetries(1, 0)) + + def test_standard_mappings_have_no_tapering(self) -> None: + """JW, BK, parity (without symmetries) have tapering=None.""" + assert MajoranaMapping.jordan_wigner(4).tapering is None + assert MajoranaMapping.bravyi_kitaev(4).tapering is None + assert MajoranaMapping.parity(4).tapering is None + + def test_scbk_num_qubits_is_posttaper(self) -> None: + """MajoranaMapping.num_qubits reflects the post-taper qubit count.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + assert scbk.num_qubits == 6 # 8 - 2 tapered + + def test_parity_with_symmetries_has_tapering(self) -> None: + """Parity with symmetries bundles a TaperingSpecification.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + par = MajoranaMapping.parity(8, Symmetries(2, 2)) + assert par.tapering is not None + assert par.tapering.num_tapered == 2 + assert par.name == "parity-2q-reduced" + assert par.base_encoding == "parity" + assert par.num_qubits == 6 # 8 - 2 + + def test_parity_without_symmetries_no_tapering(self) -> None: + """Parity without symmetries has no tapering.""" + par = MajoranaMapping.parity(8) + assert par.tapering is None + assert par.num_qubits == 8 diff --git a/python/tests/test_qdk_qubit_mapper.py b/python/tests/test_qdk_qubit_mapper.py index 97495fe1a..b8023becb 100644 --- a/python/tests/test_qdk_qubit_mapper.py +++ b/python/tests/test_qdk_qubit_mapper.py @@ -563,10 +563,18 @@ def make_symmetric_eri(n, rng): for q in range(n): for r in range(n): for s in range(n): - perms = frozenset({ - (p, q, r, s), (q, p, r, s), (p, q, s, r), (q, p, s, r), - (r, s, p, q), (s, r, p, q), (r, s, q, p), (s, r, q, p), - }) + perms = frozenset( + { + (p, q, r, s), + (q, p, r, s), + (p, q, s, r), + (q, p, s, r), + (r, s, p, q), + (s, r, p, q), + (r, s, q, p), + (s, r, q, p), + } + ) canon = min(perms) if canon in seen: continue @@ -584,8 +592,15 @@ def make_symmetric_eri(n, rng): fock_b = np.eye(0) return Hamiltonian( CanonicalFourCenterHamiltonianContainer( - h1_alpha, h1_beta, h2_aaaa, h2_aabb, h2_bbbb, - orbitals, 0.5, fock_a, fock_b, + h1_alpha, + h1_beta, + h2_aaaa, + h2_aabb, + h2_bbbb, + orbitals, + 0.5, + fock_a, + fock_b, ) ) @@ -619,12 +634,8 @@ def test_unrestricted_eigenvalues_match_across_encodings(self) -> None: qh = mapper.run(h, mapping) eigenvalues[enc] = np.sort(np.linalg.eigvalsh(qh.to_matrix())) - np.testing.assert_allclose( - eigenvalues["jordan_wigner"], eigenvalues["bravyi_kitaev"], atol=1e-10 - ) - np.testing.assert_allclose( - eigenvalues["jordan_wigner"], eigenvalues["parity"], atol=1e-10 - ) + np.testing.assert_allclose(eigenvalues["jordan_wigner"], eigenvalues["bravyi_kitaev"], atol=1e-10) + np.testing.assert_allclose(eigenvalues["jordan_wigner"], eigenvalues["parity"], atol=1e-10) def test_unrestricted_equals_restricted_when_channels_match(self) -> None: """When alpha == beta integrals, UHF path should match restricted path.""" @@ -642,8 +653,15 @@ def test_unrestricted_equals_restricted_when_channels_match(self) -> None: h_unres = Hamiltonian( CanonicalFourCenterHamiltonianContainer( - h1_a, h1_a, h2_aaaa, h2_aaaa, h2_aaaa, - orbitals, h_res.get_core_energy(), np.eye(0), np.eye(0), + h1_a, + h1_a, + h2_aaaa, + h2_aaaa, + h2_aaaa, + orbitals, + h_res.get_core_energy(), + np.eye(0), + np.eye(0), ) ) assert not h_unres.get_orbitals().is_restricted() @@ -965,3 +983,75 @@ def test_bk_vs_qiskit(self, test_data_path: Path) -> None: assert np.isclose(qdk_dict[pauli_str], qiskit_coeff, rtol=1e-10, atol=1e-14), ( f"Mismatch for {pauli_str}: QDK={qdk_dict[pauli_str]}, Qiskit={qiskit_coeff}" ) + + +# ─── SCBK one-step API ─────────────────────────────────────────────────── + + +class TestScbkOneStep: + """Tests for the one-step SCBK API via MajoranaMapping.symmetry_conserving_bravyi_kitaev.""" + + def test_scbk_produces_reduced_qubit_count(self) -> None: + """One-step SCBK mapping produces n-2 qubits.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + hamiltonian = create_nontrivial_test_hamiltonian() + n = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] + mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev(n, Symmetries(1, 1)) + + qh = create("qubit_mapper", "qdk").run(hamiltonian, mapping) + assert qh.num_qubits == n - 2 + + def test_scbk_sets_encoding_metadata(self) -> None: + """One-step SCBK sets encoding to 'symmetry-conserving-bravyi-kitaev'.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + hamiltonian = create_nontrivial_test_hamiltonian() + n = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] + mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev(n, Symmetries(1, 1)) + + qh = create("qubit_mapper", "qdk").run(hamiltonian, mapping) + assert qh.encoding == "symmetry-conserving-bravyi-kitaev" + + def test_scbk_carries_tapering_metadata(self) -> None: + """One-step SCBK output has tapering metadata.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + hamiltonian = create_nontrivial_test_hamiltonian() + n = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] + mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev(n, Symmetries(1, 1)) + + qh = create("qubit_mapper", "qdk").run(hamiltonian, mapping) + assert qh.tapering is not None + assert qh.tapering.source_num_qubits == n + assert qh.tapering.num_tapered == 2 + + def test_scbk_matches_two_step(self) -> None: + """One-step symmetry-conserving BK matches explicit BK + internal taper.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + from qdk_chemistry.utils.tapering import taper_to_scbk # noqa: PLC0415 + + hamiltonian = create_nontrivial_test_hamiltonian() + n = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] + sym = Symmetries(1, 1) + + # One-step + scbk_mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev(n, sym) + qh_one_step = create("qubit_mapper", "qdk").run(hamiltonian, scbk_mapping) + + # Two-step + bk_mapping = MajoranaMapping.bravyi_kitaev(n) + qh_bk = create("qubit_mapper", "qdk").run(hamiltonian, bk_mapping) + qh_two_step = taper_to_scbk(qh_bk, sym) + + assert qh_one_step.equiv(qh_two_step) + + def test_standard_mappings_no_tapering(self) -> None: + """Standard mappings produce QubitHamiltonian with tapering=None.""" + hamiltonian = create_nontrivial_test_hamiltonian() + n = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] + + for factory in [MajoranaMapping.jordan_wigner, MajoranaMapping.bravyi_kitaev, MajoranaMapping.parity]: + mapping = factory(n) + qh = create("qubit_mapper", "qdk").run(hamiltonian, mapping) + assert qh.tapering is None diff --git a/python/tests/test_tapering.py b/python/tests/test_tapering.py new file mode 100644 index 000000000..824e1f17e --- /dev/null +++ b/python/tests/test_tapering.py @@ -0,0 +1,108 @@ +"""Tests for qubit tapering utilities.""" + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from __future__ import annotations + +import numpy as np +import pytest + +from qdk_chemistry.data import QubitHamiltonian +from qdk_chemistry.utils.tapering import taper_qubits + +# ------------------------------------------------------------------------------------- +# taper_qubits — core tests +# ------------------------------------------------------------------------------------- + + +class TestTaperQubits: + """Tests for the taper_qubits free function.""" + + def test_single_qubit_z_eigenvalue_positive(self) -> None: + """Tapering a Z on a qubit with eigenvalue +1 leaves coefficient unchanged.""" + qh = QubitHamiltonian(pauli_strings=["ZI", "IZ"], coefficients=np.array([1.0, 2.0])) + result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) + assert result.num_qubits == 1 + assert "Z" in result.pauli_strings or "I" in result.pauli_strings + + def test_single_qubit_z_eigenvalue_negative(self) -> None: + """Tapering a Z on a qubit with eigenvalue -1 flips the coefficient sign.""" + qh = QubitHamiltonian(pauli_strings=["ZI"], coefficients=np.array([1.0])) + result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[-1]) + assert result.num_qubits == 1 + assert np.isclose(result.coefficients[0], -1.0) + + def test_x_on_tapered_qubit_drops_term(self) -> None: + """Terms with X on a tapered qubit are removed.""" + qh = QubitHamiltonian(pauli_strings=["XI", "IZ"], coefficients=np.array([1.0, 2.0])) + result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) + assert result.num_qubits == 1 + assert len(result.pauli_strings) == 1 + + def test_y_on_tapered_qubit_drops_term(self) -> None: + """Terms with Y on a tapered qubit are removed.""" + qh = QubitHamiltonian(pauli_strings=["YI", "IZ"], coefficients=np.array([1.0, 2.0])) + result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) + assert result.num_qubits == 1 + + def test_identity_on_tapered_qubit_preserved(self) -> None: + """Terms with I on a tapered qubit are kept as-is.""" + qh = QubitHamiltonian(pauli_strings=["IZ", "II"], coefficients=np.array([1.0, 2.0])) + result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) + assert result.num_qubits == 1 + assert len(result.pauli_strings) == 2 + + def test_two_qubits_tapered(self) -> None: + """Tapering two qubits reduces qubit count by 2.""" + qh = QubitHamiltonian(pauli_strings=["IIII", "ZIZI", "IZIZ"], coefficients=np.array([1.0, 0.5, 0.3])) + result = taper_qubits(qh, qubit_indices=[0, 3], eigenvalues=[1, 1]) + assert result.num_qubits == 2 + + def test_duplicate_terms_merged(self) -> None: + """After tapering, identical Pauli strings are merged.""" + qh = QubitHamiltonian(pauli_strings=["ZI", "II"], coefficients=np.array([1.0, 2.0])) + result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) + # ZI → I (coeff 1.0) and II → I (coeff 2.0) should merge to I (coeff 3.0) + assert len(result.pauli_strings) == 1 + assert np.isclose(result.coefficients[0], 3.0) + + def test_encoding_preserved(self) -> None: + """Encoding metadata is preserved through tapering.""" + qh = QubitHamiltonian(pauli_strings=["ZI", "IZ"], coefficients=np.array([1.0, 2.0]), encoding="bravyi-kitaev") + result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) + assert result.encoding == "bravyi-kitaev" + + def test_mismatched_lengths_raises(self) -> None: + """ValueError when qubit_indices and eigenvalues have different lengths.""" + qh = QubitHamiltonian(pauli_strings=["ZI"], coefficients=np.array([1.0])) + with pytest.raises(ValueError, match="length"): + taper_qubits(qh, qubit_indices=[0, 1], eigenvalues=[1]) + + def test_out_of_range_index_raises(self) -> None: + """ValueError for qubit index out of range.""" + qh = QubitHamiltonian(pauli_strings=["ZI"], coefficients=np.array([1.0])) + with pytest.raises(ValueError, match="out of range"): + taper_qubits(qh, qubit_indices=[5], eigenvalues=[1]) + + def test_invalid_eigenvalue_raises(self) -> None: + """ValueError for eigenvalue that is not ±1.""" + qh = QubitHamiltonian(pauli_strings=["ZI"], coefficients=np.array([1.0])) + with pytest.raises(ValueError, match="must be"): + taper_qubits(qh, qubit_indices=[0], eigenvalues=[0]) + + def test_duplicate_indices_raises(self) -> None: + """ValueError for duplicate qubit indices.""" + qh = QubitHamiltonian(pauli_strings=["ZII"], coefficients=np.array([1.0])) + with pytest.raises(ValueError, match="duplicate"): + taper_qubits(qh, qubit_indices=[0, 0], eigenvalues=[1, -1]) + + def test_all_terms_eliminated_raises(self) -> None: + """ValueError when all terms are eliminated by tapering.""" + qh = QubitHamiltonian(pauli_strings=["XI"], coefficients=np.array([1.0])) + with pytest.raises(ValueError, match="eliminated"): + taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) + + From 474ce2a59647f1f6fe65fab1626be23f083102ea Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Tue, 12 May 2026 23:24:53 +0000 Subject: [PATCH 078/117] fixed tests and pre-commit --- .../chemistry/data/majorana_map_engine.hpp | 20 +- .../chemistry/data/majorana_map_engine.cpp | 173 +++++++++--------- .../qdk/chemistry/data/majorana_mapping.cpp | 44 ++--- .../_static/examples/python/circuit_mapper.py | 9 +- .../python/hamiltonian_unitary_builder.py | 9 +- .../examples/python/phase_estimation.py | 9 +- .../_static/examples/python/quickstart.py | 9 +- .../examples/python/release_notes_v1_1.py | 12 +- .../molecular_hamiltonian_jordan_wigner.py | 7 +- .../qiskit/iqpe_no_trotter.py | 8 +- .../interoperability/qiskit/iqpe_trotter.py | 8 +- python/src/pybind11/data/majorana_mapping.cpp | 51 +++--- .../qubit_mapper/qdk_qubit_mapper.py | 4 +- .../src/qdk_chemistry/algorithms/registry.py | 12 ++ python/src/qdk_chemistry/data/tapering.py | 5 + python/tests/test_tapering.py | 2 - 16 files changed, 203 insertions(+), 179 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_map_engine.hpp b/cpp/include/qdk/chemistry/data/majorana_map_engine.hpp index 0a75b5859..89dd48c7d 100644 --- a/cpp/include/qdk/chemistry/data/majorana_map_engine.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_map_engine.hpp @@ -3,8 +3,8 @@ // license information. #pragma once -#include #include +#include #include #include #include @@ -55,19 +55,13 @@ struct MajoranaMapResult { * @param is_restricted Whether h1_alpha == h1_beta (spin-free case). * @param threshold Pauli terms with |coeff| < threshold are dropped. * @param integral_threshold Integrals with |value| < this are skipped. - * @return MajoranaMapResult with Pauli strings (little-endian) and coefficients. + * @return MajoranaMapResult with Pauli strings (little-endian) and + * coefficients. */ MajoranaMapResult majorana_map_hamiltonian( - const MajoranaMapping& mapping, - double core_energy, - const double* h1_alpha, - const double* h1_beta, - const double* eri_aaaa, - const double* eri_aabb, - const double* eri_bbbb, - std::size_t n_spatial, - bool is_restricted, - double threshold, - double integral_threshold); + const MajoranaMapping& mapping, double core_energy, const double* h1_alpha, + const double* h1_beta, const double* eri_aaaa, const double* eri_aabb, + const double* eri_bbbb, std::size_t n_spatial, bool is_restricted, + double threshold, double integral_threshold); } // namespace qdk::chemistry::data diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index 12c026928..e95d47f1e 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -113,12 +113,10 @@ std::pair> multiply_packed( result.x[i] = x1 ^ x2; result.z[i] = z1 ^ z2; std::uint64_t nx1 = ~x1, nz1 = ~z1, nx2 = ~x2, nz2 = ~z2; - std::uint64_t cyc = (x1 & nz1 & x2 & z2) | - (x1 & z1 & nx2 & z2) | - (nx1 & z1 & x2 & nz2); - std::uint64_t anti = (x1 & z1 & x2 & nz2) | - (nx1 & z1 & x2 & z2) | - (x1 & nz1 & nx2 & z2); + std::uint64_t cyc = + (x1 & nz1 & x2 & z2) | (x1 & z1 & nx2 & z2) | (nx1 & z1 & x2 & nz2); + std::uint64_t anti = + (x1 & z1 & x2 & nz2) | (nx1 & z1 & x2 & z2) | (x1 & nz1 & nx2 & z2); phase_exp += __builtin_popcountll(cyc); phase_exp -= __builtin_popcountll(anti); } @@ -148,8 +146,7 @@ inline std::complex apply_phase(int phase_idx, template class PackedAccumulator { public: - void accumulate(const PackedPauliWord& word, - std::complex coeff) { + void accumulate(const PackedPauliWord& word, std::complex coeff) { // Single hash-map probe: operator[] default-constructs (0,0) on miss. terms_[word] += coeff; } @@ -161,9 +158,10 @@ class PackedAccumulator { terms_[word] += apply_phase(phase_idx, scale); } - /// Extract terms above threshold as (coefficient, little-endian string) pairs. - std::vector, std::string>> get_terms_as_strings( - double threshold, std::size_t num_qubits) const { + /// Extract terms above threshold as (coefficient, little-endian string) + /// pairs. + std::vector, std::string>> + get_terms_as_strings(double threshold, std::size_t num_qubits) const { std::vector, std::string>> result; result.reserve(terms_.size()); for (const auto& [pw, coeff] : terms_) { @@ -241,10 +239,10 @@ namespace { /// words per Pauli bitmask). NW is selected at runtime by the dispatcher. template MajoranaMapResult majorana_map_impl( - const MajoranaMapping& mapping, double core_energy, - const double* h1_alpha, const double* h1_beta, const double* eri_aaaa, - const double* eri_aabb, const double* eri_bbbb, std::size_t n_spatial, - bool is_restricted, double threshold, double integral_threshold) { + const MajoranaMapping& mapping, double core_energy, const double* h1_alpha, + const double* h1_beta, const double* eri_aaaa, const double* eri_aabb, + const double* eri_bbbb, std::size_t n_spatial, bool is_restricted, + double threshold, double integral_threshold) { const std::size_t n_modes = 2 * n_spatial; const std::size_t num_qubits = mapping.num_qubits(); @@ -265,19 +263,19 @@ MajoranaMapResult majorana_map_impl( }; // Helper: accumulate one-body E_pq for a mode pair, using packed types - // E_pq = (1/4) * sum_{a,b} c[a][b] * phase(2p+a) * phase(2q+b) * P(2p+a) * P(2q+b) + // E_pq = (1/4) * sum_{a,b} c[a][b] * phase(2p+a) * phase(2q+b) * P(2p+a) * + // P(2q+b) auto accumulate_epq = [&](std::size_t mode_p, std::size_t mode_q, double h_pq) { for (int a = 0; a < 2; ++a) { std::size_t idx_pa = 2 * mode_p + a; for (int b = 0; b < 2; ++b) { std::size_t idx_qb = 2 * mode_q + b; - auto [ph, word] = multiply_packed( - packed_mapping[idx_pa], packed_mapping[idx_qb]); - double sign = has_neg_phases - ? static_cast(maj_phases[idx_pa]) * - maj_phases[idx_qb] - : 1.0; + auto [ph, word] = + multiply_packed(packed_mapping[idx_pa], packed_mapping[idx_qb]); + double sign = has_neg_phases ? static_cast(maj_phases[idx_pa]) * + maj_phases[idx_qb] + : 1.0; acc.accumulate(word, apply_phase(ph, sign * h_pq * kQuarter * kC[a][b])); } @@ -291,7 +289,7 @@ MajoranaMapResult majorana_map_impl( } auto idx4 = [n_spatial](std::size_t p, std::size_t q, std::size_t r, - std::size_t s) -> std::size_t { + std::size_t s) -> std::size_t { return ((p * n_spatial + q) * n_spatial + r) * n_spatial + s; }; @@ -334,7 +332,7 @@ MajoranaMapResult majorana_map_impl( // Each entry stores the Pauli multiply phase index AND the combined // Majorana sign factor (phases[i] * phases[j]). struct PackedPairProduct { - int phase; // Pauli multiply phase index (0..3) + int phase; // Pauli multiply phase index (0..3) std::int8_t sign; // maj_phases[i] * maj_phases[j] (±1) PackedPauliWord word; }; @@ -345,31 +343,27 @@ MajoranaMapResult majorana_map_impl( for (std::size_t i = 0; i < maj_per_spin; ++i) { for (std::size_t j = 0; j < maj_per_spin; ++j) { // alpha block: Majorana indices i, j - auto [ph_a, w_a] = multiply_packed( - packed_mapping[i], packed_mapping[j]); - std::int8_t sign_a = has_neg_phases - ? maj_phases[i] * maj_phases[j] - : static_cast(1); + auto [ph_a, w_a] = multiply_packed(packed_mapping[i], packed_mapping[j]); + std::int8_t sign_a = has_neg_phases ? maj_phases[i] * maj_phases[j] + : static_cast(1); ppair_alpha[i * maj_per_spin + j] = {ph_a, sign_a, std::move(w_a)}; // beta block: Majorana indices i+2*n_spatial, j+2*n_spatial - auto [ph_b, w_b] = multiply_packed( - packed_mapping[i + maj_per_spin], - packed_mapping[j + maj_per_spin]); - std::int8_t sign_b = - has_neg_phases - ? maj_phases[i + maj_per_spin] * maj_phases[j + maj_per_spin] - : static_cast(1); + auto [ph_b, w_b] = multiply_packed(packed_mapping[i + maj_per_spin], + packed_mapping[j + maj_per_spin]); + std::int8_t sign_b = has_neg_phases ? maj_phases[i + maj_per_spin] * + maj_phases[j + maj_per_spin] + : static_cast(1); ppair_beta[i * maj_per_spin + j] = {ph_b, sign_b, std::move(w_b)}; } } - auto alpha_pair = [&](std::size_t i, std::size_t j) - -> const PackedPairProduct& { + auto alpha_pair = [&](std::size_t i, + std::size_t j) -> const PackedPairProduct& { return ppair_alpha[i * maj_per_spin + j]; }; - auto beta_pair = [&](std::size_t i, std::size_t j) - -> const PackedPairProduct& { + auto beta_pair = [&](std::size_t i, + std::size_t j) -> const PackedPairProduct& { return ppair_beta[i * maj_per_spin + j]; }; @@ -385,12 +379,18 @@ MajoranaMapResult majorana_map_impl( auto& sse = ss_e[p * n_spatial + q]; for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { - const auto& [phase_a, sign_a, word_a] = alpha_pair(2*p+a, 2*q+b); + const auto& [phase_a, sign_a, word_a] = + alpha_pair(2 * p + a, 2 * q + b); sse.terms.emplace_back( - apply_phase(phase_a, static_cast(sign_a) * kQuarter * kC[a][b]), word_a); - const auto& [phase_b, sign_b, word_b] = beta_pair(2*p+a, 2*q+b); + apply_phase(phase_a, + static_cast(sign_a) * kQuarter * kC[a][b]), + word_a); + const auto& [phase_b, sign_b, word_b] = + beta_pair(2 * p + a, 2 * q + b); sse.terms.emplace_back( - apply_phase(phase_b, static_cast(sign_b) * kQuarter * kC[a][b]), word_b); + apply_phase(phase_b, + static_cast(sign_b) * kQuarter * kC[a][b]), + word_b); } } } @@ -411,7 +411,8 @@ MajoranaMapResult majorana_map_impl( if (p != q) sym_map[q * n_spatial + p] = idx; std::unordered_map, std::complex, - PackedPauliWordHash> merged; + PackedPauliWordHash> + merged; for (const auto& [c, w] : ss_e[p * n_spatial + q].terms) { merged[w] += c; } @@ -477,38 +478,37 @@ MajoranaMapResult majorana_map_impl( } else { // Unrestricted: explicit spin-channel ERIs using packed types - auto accumulate_two_body_product = [&](std::size_t mode_p, - std::size_t mode_q, - std::size_t mode_r, - std::size_t mode_s, double eri) { - bool pq_is_alpha = mode_p < n_spatial; - bool rs_is_alpha = mode_r < n_spatial; - auto& cache_pq = pq_is_alpha ? ppair_alpha : ppair_beta; - auto& cache_rs = rs_is_alpha ? ppair_alpha : ppair_beta; - std::size_t bp = pq_is_alpha ? mode_p : (mode_p - n_spatial); - std::size_t bq = pq_is_alpha ? mode_q : (mode_q - n_spatial); - std::size_t br = rs_is_alpha ? mode_r : (mode_r - n_spatial); - std::size_t bs = rs_is_alpha ? mode_s : (mode_s - n_spatial); - - double half_eri = 0.5 * eri; - for (int a = 0; a < 2; ++a) { - for (int b = 0; b < 2; ++b) { - const auto& [ph1, s1, w1] = - cache_pq[(2*bp+a) * maj_per_spin + (2*bq+b)]; - for (int c = 0; c < 2; ++c) { - for (int d = 0; d < 2; ++d) { - const auto& [ph2, s2, w2] = - cache_rs[(2*br+c) * maj_per_spin + (2*bs+d)]; - double sign = static_cast(s1) * s2; - std::complex scale = - apply_phase((ph1 + ph2) & 3, - sign * half_eri * kSixteenth * kC[a][b] * kC[c][d]); - acc.accumulate_product(w1, w2, scale); + auto accumulate_two_body_product = + [&](std::size_t mode_p, std::size_t mode_q, std::size_t mode_r, + std::size_t mode_s, double eri) { + bool pq_is_alpha = mode_p < n_spatial; + bool rs_is_alpha = mode_r < n_spatial; + auto& cache_pq = pq_is_alpha ? ppair_alpha : ppair_beta; + auto& cache_rs = rs_is_alpha ? ppair_alpha : ppair_beta; + std::size_t bp = pq_is_alpha ? mode_p : (mode_p - n_spatial); + std::size_t bq = pq_is_alpha ? mode_q : (mode_q - n_spatial); + std::size_t br = rs_is_alpha ? mode_r : (mode_r - n_spatial); + std::size_t bs = rs_is_alpha ? mode_s : (mode_s - n_spatial); + + double half_eri = 0.5 * eri; + for (int a = 0; a < 2; ++a) { + for (int b = 0; b < 2; ++b) { + const auto& [ph1, s1, w1] = + cache_pq[(2 * bp + a) * maj_per_spin + (2 * bq + b)]; + for (int c = 0; c < 2; ++c) { + for (int d = 0; d < 2; ++d) { + const auto& [ph2, s2, w2] = + cache_rs[(2 * br + c) * maj_per_spin + (2 * bs + d)]; + double sign = static_cast(s1) * s2; + std::complex scale = apply_phase( + (ph1 + ph2) & 3, + sign * half_eri * kSixteenth * kC[a][b] * kC[c][d]); + acc.accumulate_product(w1, w2, scale); + } + } } } - } - } - }; + }; // aaaa channel for (std::size_t p = 0; p < n_spatial; ++p) { @@ -591,10 +591,10 @@ MajoranaMapResult majorana_map_impl( } // anonymous namespace MajoranaMapResult majorana_map_hamiltonian( - const MajoranaMapping& mapping, double core_energy, - const double* h1_alpha, const double* h1_beta, const double* eri_aaaa, - const double* eri_aabb, const double* eri_bbbb, std::size_t n_spatial, - bool is_restricted, double threshold, double integral_threshold) { + const MajoranaMapping& mapping, double core_energy, const double* h1_alpha, + const double* h1_beta, const double* eri_aaaa, const double* eri_aabb, + const double* eri_bbbb, std::size_t n_spatial, bool is_restricted, + double threshold, double integral_threshold) { const std::size_t num_qubits = mapping.num_qubits(); const std::size_t num_words = (num_qubits + 63) / 64; @@ -606,27 +606,22 @@ MajoranaMapResult majorana_map_hamiltonian( case 1: return majorana_map_impl<1>(mapping, core_energy, h1_alpha, h1_beta, eri_aaaa, eri_aabb, eri_bbbb, n_spatial, - is_restricted, threshold, - integral_threshold); + is_restricted, threshold, integral_threshold); case 2: return majorana_map_impl<2>(mapping, core_energy, h1_alpha, h1_beta, eri_aaaa, eri_aabb, eri_bbbb, n_spatial, - is_restricted, threshold, - integral_threshold); + is_restricted, threshold, integral_threshold); case 3: return majorana_map_impl<3>(mapping, core_energy, h1_alpha, h1_beta, eri_aaaa, eri_aabb, eri_bbbb, n_spatial, - is_restricted, threshold, - integral_threshold); + is_restricted, threshold, integral_threshold); case 4: return majorana_map_impl<4>(mapping, core_energy, h1_alpha, h1_beta, eri_aaaa, eri_aabb, eri_bbbb, n_spatial, - is_restricted, threshold, - integral_threshold); + is_restricted, threshold, integral_threshold); default: throw std::invalid_argument( - "majorana_map_hamiltonian: num_qubits=" + - std::to_string(num_qubits) + + "majorana_map_hamiltonian: num_qubits=" + std::to_string(num_qubits) + " requires " + std::to_string(num_words) + " uint64 words, but max supported is 4 (256 qubits). " "Contact the developers to extend the template dispatch."); diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp index 1c6acb828..3ac416aec 100644 --- a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp @@ -150,8 +150,7 @@ MajoranaMapping::MajoranaMapping(std::vector table, name_(std::move(name)), num_qubits_(compute_num_qubits(table_)) { if (table_.empty()) { - throw std::invalid_argument( - "MajoranaMapping table must not be empty"); + throw std::invalid_argument("MajoranaMapping table must not be empty"); } if (table_.size() % 2 != 0) { throw std::invalid_argument( @@ -170,9 +169,9 @@ MajoranaMapping::MajoranaMapping(std::vector table, // Validate phase values and compute all_positive flag for (std::size_t k = 0; k < phases_.size(); ++k) { if (phases_[k] != 1 && phases_[k] != -1) { - throw std::invalid_argument( - "phases[" + std::to_string(k) + "] = " + - std::to_string(phases_[k]) + "; must be +1 or -1"); + throw std::invalid_argument("phases[" + std::to_string(k) + + "] = " + std::to_string(phases_[k]) + + "; must be +1 or -1"); } if (phases_[k] != 1) all_positive_ = false; } @@ -181,18 +180,18 @@ MajoranaMapping::MajoranaMapping(std::vector table, const SparsePauliWord& MajoranaMapping::operator()(std::size_t k) const { if (k >= table_.size()) { - throw std::out_of_range( - "Majorana index " + std::to_string(k) + - " out of range [0, " + std::to_string(table_.size()) + ")"); + throw std::out_of_range("Majorana index " + std::to_string(k) + + " out of range [0, " + + std::to_string(table_.size()) + ")"); } return table_[k]; } std::int8_t MajoranaMapping::phase(std::size_t k) const { if (k >= phases_.size()) { - throw std::out_of_range( - "Majorana index " + std::to_string(k) + - " out of range [0, " + std::to_string(phases_.size()) + ")"); + throw std::out_of_range("Majorana index " + std::to_string(k) + + " out of range [0, " + + std::to_string(phases_.size()) + ")"); } return phases_[k]; } @@ -237,8 +236,8 @@ void MajoranaMapping::validate() const { auto sum = phase_ij + phase_ji; if (std::abs(sum) > tol) { std::ostringstream msg; - msg << "Clifford algebra validation failed: {γ_" << i << ", γ_" - << j << "} != 0 (anticommutator phase sum = " << sum << ")"; + msg << "Clifford algebra validation failed: {γ_" << i << ", γ_" << j + << "} != 0 (anticommutator phase sum = " << sum << ")"; throw std::invalid_argument(msg.str()); } } else { @@ -249,8 +248,7 @@ void MajoranaMapping::validate() const { // (possibly with different phase). If words differ, that's an error. if (std::abs(phase_ij) > tol || std::abs(phase_ji) > tol) { std::ostringstream msg; - msg << "Clifford algebra validation failed: {γ_" << i << ", γ_" - << j + msg << "Clifford algebra validation failed: {γ_" << i << ", γ_" << j << "} produces non-cancelling terms with different Pauli words"; throw std::invalid_argument(msg.str()); } @@ -279,8 +277,7 @@ std::size_t MajoranaMapping::compute_num_qubits( MajoranaMapping MajoranaMapping::jordan_wigner(std::size_t num_modes) { if (num_modes == 0) { - throw std::invalid_argument( - "jordan_wigner requires num_modes > 0"); + throw std::invalid_argument("jordan_wigner requires num_modes > 0"); } std::vector table; @@ -311,8 +308,7 @@ MajoranaMapping MajoranaMapping::jordan_wigner(std::size_t num_modes) { MajoranaMapping MajoranaMapping::bravyi_kitaev(std::size_t num_modes) { if (num_modes == 0) { - throw std::invalid_argument( - "bravyi_kitaev requires num_modes > 0"); + throw std::invalid_argument("bravyi_kitaev requires num_modes > 0"); } // BK index sets are defined on a binary tree of size 2^ceil(log2(n)) @@ -324,15 +320,13 @@ MajoranaMapping MajoranaMapping::bravyi_kitaev(std::size_t num_modes) { for (std::size_t j = 0; j < num_modes; ++j) { auto parity = bk_parity_set(static_cast(j), tree_size); auto update = bk_update_set(static_cast(j), tree_size); - auto remainder = - bk_remainder_set(static_cast(j), tree_size); + auto remainder = bk_remainder_set(static_cast(j), tree_size); // Filter out indices ≥ num_modes (virtual tree nodes beyond actual qubits) auto filter = [num_modes](std::vector& v) { - v.erase(std::remove_if(v.begin(), v.end(), - [num_modes](std::uint64_t idx) { - return idx >= num_modes; - }), + v.erase(std::remove_if( + v.begin(), v.end(), + [num_modes](std::uint64_t idx) { return idx >= num_modes; }), v.end()); }; filter(parity); diff --git a/docs/source/_static/examples/python/circuit_mapper.py b/docs/source/_static/examples/python/circuit_mapper.py index ec12359b5..65ce791d6 100644 --- a/docs/source/_static/examples/python/circuit_mapper.py +++ b/docs/source/_static/examples/python/circuit_mapper.py @@ -34,8 +34,13 @@ # 3. Hamiltonian and qubit mapping hamiltonian_constructor = create("hamiltonian_constructor") hamiltonian = hamiltonian_constructor.run(wfn_scf.get_orbitals()) -qubit_mapper = create("qubit_mapper", encoding="jordan-wigner") -qubit_ham = qubit_mapper.run(hamiltonian) +from qdk_chemistry.data.majorana_mapping import MajoranaMapping + +n_spin_orbitals = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] +qubit_mapper = create("qubit_mapper") +qubit_ham = qubit_mapper.run( + hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals) +) # 4. Build time evolution unitary trotter = create("hamiltonian_unitary_builder", "trotter", order=2, time=0.1) diff --git a/docs/source/_static/examples/python/hamiltonian_unitary_builder.py b/docs/source/_static/examples/python/hamiltonian_unitary_builder.py index 8d8db72b4..b5fcfb0c6 100644 --- a/docs/source/_static/examples/python/hamiltonian_unitary_builder.py +++ b/docs/source/_static/examples/python/hamiltonian_unitary_builder.py @@ -68,8 +68,13 @@ hamiltonian = hamiltonian_constructor.run(wfn_scf.get_orbitals()) # 4. Qubit mapping -qubit_mapper = create("qubit_mapper", encoding="jordan-wigner") -qubit_ham = qubit_mapper.run(hamiltonian) +from qdk_chemistry.data.majorana_mapping import MajoranaMapping + +n_spin_orbitals = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] +qubit_mapper = create("qubit_mapper") +qubit_ham = qubit_mapper.run( + hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals) +) # 5. Build time evolution unitary trotter = create("hamiltonian_unitary_builder", "trotter", order=2, time=0.1) diff --git a/docs/source/_static/examples/python/phase_estimation.py b/docs/source/_static/examples/python/phase_estimation.py index ce44385de..da069bbdc 100644 --- a/docs/source/_static/examples/python/phase_estimation.py +++ b/docs/source/_static/examples/python/phase_estimation.py @@ -62,8 +62,13 @@ E_cas, wfn_cas = cas_solver.run(hamiltonian, 1, 1) # 5. Qubit mapping -qubit_mapper = create("qubit_mapper", encoding="jordan-wigner") -qubit_ham = qubit_mapper.run(hamiltonian) +from qdk_chemistry.data.majorana_mapping import MajoranaMapping + +n_spin_orbitals = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] +qubit_mapper = create("qubit_mapper") +qubit_ham = qubit_mapper.run( + hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals) +) # 6. State preparation state_prep = create("state_prep", "sparse_isometry_gf2x") diff --git a/docs/source/_static/examples/python/quickstart.py b/docs/source/_static/examples/python/quickstart.py index 2956c0c63..ac2693f48 100644 --- a/docs/source/_static/examples/python/quickstart.py +++ b/docs/source/_static/examples/python/quickstart.py @@ -103,8 +103,13 @@ ################################################################################ # start-cell-qubit-hamiltonian # Prepare qubit Hamiltonian -qubit_mapper = create("qubit_mapper", algorithm_name="qdk", encoding="jordan-wigner") -qubit_hamiltonian = qubit_mapper.run(hamiltonian) +from qdk_chemistry.data.majorana_mapping import MajoranaMapping + +n_spin_orbitals = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] +qubit_mapper = create("qubit_mapper", algorithm_name="qdk") +qubit_hamiltonian = qubit_mapper.run( + hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals) +) # Print the number of Pauli strings in the full Hamiltonian print( diff --git a/docs/source/_static/examples/python/release_notes_v1_1.py b/docs/source/_static/examples/python/release_notes_v1_1.py index 0e30f2b45..78b4f9a82 100644 --- a/docs/source/_static/examples/python/release_notes_v1_1.py +++ b/docs/source/_static/examples/python/release_notes_v1_1.py @@ -34,7 +34,10 @@ _hamiltonian = _ham_constructor.run(_orbitals) _qdk_mapper = create("qubit_mapper", "qdk") -_qubit_hamiltonian = _qdk_mapper.run(_hamiltonian) +_n_spin_orbitals = 2 * _hamiltonian.get_one_body_integrals()[0].shape[0] +from qdk_chemistry.data.majorana_mapping import MajoranaMapping as _MM + +_qubit_hamiltonian = _qdk_mapper.run(_hamiltonian, _MM.jordan_wigner(_n_spin_orbitals)) # =========================================================================== # Model Hamiltonians — fermionic @@ -248,16 +251,17 @@ ############################################################################ # start-cell-openfermion from qdk_chemistry.data import Symmetries + from qdk_chemistry.data.majorana_mapping import MajoranaMapping - mapper = create("qubit_mapper", "openfermion", encoding="jordan-wigner") - qh = mapper.run(hamiltonian) + n_spin_orbitals = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] + mapper = create("qubit_mapper", "openfermion") + qh = mapper.run(hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals)) # Symmetry-conserving Bravyi-Kitaev (reduces qubit count by 2) sym = Symmetries(n_alpha=1, n_beta=1) mapper = create( "qubit_mapper", "openfermion", - encoding="symmetry-conserving-bravyi-kitaev", ) qh = mapper.run(hamiltonian, sym) # end-cell-openfermion diff --git a/examples/interoperability/openFermion/molecular_hamiltonian_jordan_wigner.py b/examples/interoperability/openFermion/molecular_hamiltonian_jordan_wigner.py index 9766f376a..ae534ff16 100644 --- a/examples/interoperability/openFermion/molecular_hamiltonian_jordan_wigner.py +++ b/examples/interoperability/openFermion/molecular_hamiltonian_jordan_wigner.py @@ -21,6 +21,7 @@ from qdk_chemistry.algorithms import create from qdk_chemistry.constants import ANGSTROM_TO_BOHR from qdk_chemistry.data import Structure +from qdk_chemistry.data.majorana_mapping import MajoranaMapping from qdk_chemistry.utils import Logger # OpenFermion must be installed to run this example. @@ -74,8 +75,10 @@ ######################################################################################## # 3. QDK → OpenFermion: map to qubits via the plugin, then exact-diagonalise ######################################################################################## -mapper = create("qubit_mapper", "openfermion", encoding="jordan-wigner") -qdk_qubit_ham = mapper.run(active_hamiltonian) +mapper = create("qubit_mapper", "openfermion") +n_spin_orbitals = 2 * active_hamiltonian.get_one_body_integrals()[0].shape[0] +mapping = MajoranaMapping.jordan_wigner(n_spin_orbitals) +qdk_qubit_ham = mapper.run(active_hamiltonian, mapping) Logger.info("=== QDK → OpenFermion (Jordan-Wigner) ===") Logger.info(f" Pauli terms: {len(qdk_qubit_ham.pauli_strings)}") diff --git a/examples/interoperability/qiskit/iqpe_no_trotter.py b/examples/interoperability/qiskit/iqpe_no_trotter.py index c35d60d06..9913e09b1 100644 --- a/examples/interoperability/qiskit/iqpe_no_trotter.py +++ b/examples/interoperability/qiskit/iqpe_no_trotter.py @@ -34,6 +34,7 @@ from qdk_chemistry.algorithms import create from qdk_chemistry.data import QpeResult, Structure +from qdk_chemistry.data.majorana_mapping import MajoranaMapping from qdk_chemistry.utils import Logger from qdk_chemistry.utils.phase import ( iterative_phase_feedback_update, @@ -215,8 +216,11 @@ def run_iterative_exact_qpe( ######################################################################################## # 3. Preparing the qubit Hamiltonian and sparse-isometry trial state ######################################################################################## -qubit_mapper = create("qubit_mapper", "qiskit", encoding="jordan-wigner") -qubit_hamiltonian = qubit_mapper.run(active_hamiltonian) +qubit_mapper = create("qubit_mapper", "qiskit") +n_spin_orbitals = 2 * active_hamiltonian.get_one_body_integrals()[0].shape[0] +qubit_hamiltonian = qubit_mapper.run( + active_hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals) +) qubit_pauli_op = SparsePauliOp( qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients ) diff --git a/examples/interoperability/qiskit/iqpe_trotter.py b/examples/interoperability/qiskit/iqpe_trotter.py index 68ea5f8a7..46e613984 100644 --- a/examples/interoperability/qiskit/iqpe_trotter.py +++ b/examples/interoperability/qiskit/iqpe_trotter.py @@ -26,6 +26,7 @@ create, ) from qdk_chemistry.data import AlgorithmRef, Circuit, Structure +from qdk_chemistry.data.majorana_mapping import MajoranaMapping from qdk_chemistry.utils import Logger Logger.set_global_level("info") @@ -80,8 +81,11 @@ ######################################################################################## # 3. Preparing the qubit Hamiltonian and sparse-isometry trial state ######################################################################################## -qubit_mapper = create("qubit_mapper", "qiskit", encoding="jordan-wigner") -qubit_hamiltonian = qubit_mapper.run(active_hamiltonian) +qubit_mapper = create("qubit_mapper", "qiskit") +n_spin_orbitals = 2 * active_hamiltonian.get_one_body_integrals()[0].shape[0] +qubit_hamiltonian = qubit_mapper.run( + active_hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals) +) qubit_pauli_op = SparsePauliOp( qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index aa81915fa..789a6ef78 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -36,16 +36,14 @@ std::uint8_t char_to_op(char c) { case 'z': return 3; default: - throw std::invalid_argument( - std::string("Invalid Pauli character '") + c + - "' — expected I, X, Y, or Z"); + throw std::invalid_argument(std::string("Invalid Pauli character '") + c + + "' — expected I, X, Y, or Z"); } } /// Convert a dense little-endian Pauli string (qubit 0 = rightmost char) /// to a SparsePauliWord (sorted by qubit index, identities omitted). -qdk::chemistry::data::SparsePauliWord dense_le_to_sparse( - const std::string& s) { +qdk::chemistry::data::SparsePauliWord dense_le_to_sparse(const std::string& s) { qdk::chemistry::data::SparsePauliWord word; // Little-endian: qubit 0 is the rightmost character (index len-1). std::size_t len = s.size(); @@ -63,8 +61,7 @@ qdk::chemistry::data::SparsePauliWord dense_le_to_sparse( /// Convert a SparsePauliWord to a dense little-endian string. std::string sparse_to_dense_le( - const qdk::chemistry::data::SparsePauliWord& word, - std::size_t num_qubits) { + const qdk::chemistry::data::SparsePauliWord& word, std::size_t num_qubits) { // Build big-endian first (qubit 0 at index 0), then reverse std::string result(num_qubits, 'I'); for (const auto& [qubit, op_type] : word) { @@ -114,7 +111,8 @@ algebra anticommutation relations: {γ_i, γ_j} = 2δ_{ij} · I. 4 )"); - // Constructor from list of dense little-endian Pauli strings + optional phases + // Constructor from list of dense little-endian Pauli strings + optional + // phases mapping.def( py::init([](py::list table_list, const std::string& name, std::vector phases) { @@ -144,8 +142,8 @@ algebra anticommutation relations: {γ_i, γ_j} = 2δ_{ij} · I. Py_ssize_t str_len; const char* str_data = PyUnicode_AsUTF8AndSize(item, &str_len); if (!str_data) { - throw py::value_error( - "Failed to decode string at index " + std::to_string(i)); + throw py::value_error("Failed to decode string at index " + + std::to_string(i)); } if (expected_len < 0) { @@ -155,8 +153,7 @@ algebra anticommutation relations: {γ_i, γ_j} = 2δ_{ij} · I. "All Pauli strings must have the same length. Entry 0 has " "length " + std::to_string(expected_len) + " but entry " + - std::to_string(i) + " has length " + - std::to_string(str_len)); + std::to_string(i) + " has length " + std::to_string(str_len)); } std::string s(str_data, static_cast(str_len)); @@ -190,17 +187,16 @@ The Clifford algebra {γ_i, γ_j} = 2δ_{ij}·I is validated at construction. )"); // Constructor from list of SparsePauliWord (for advanced use) - mapping.def( - py::init([](const std::vector& table, - const std::string& name) { - try { - return MajoranaMapping(table, name); - } catch (const std::invalid_argument& e) { - throw py::value_error(e.what()); - } - }), - py::arg("table"), py::arg("name") = "", - R"( + mapping.def(py::init([](const std::vector& table, + const std::string& name) { + try { + return MajoranaMapping(table, name); + } catch (const std::invalid_argument& e) { + throw py::value_error(e.what()); + } + }), + py::arg("table"), py::arg("name") = "", + R"( Construct a MajoranaMapping from a list of sparse Pauli words. Each sparse word is a list of (qubit_index, op_type) tuples. This is the @@ -216,8 +212,7 @@ advanced constructor for users who want to avoid string parsing. // Properties mapping.def_property_readonly( - "num_modes", - [](const MajoranaMapping& self) { return self.num_modes(); }, + "num_modes", [](const MajoranaMapping& self) { return self.num_modes(); }, "Number of fermionic modes (spin-orbitals)."); mapping.def_property_readonly( @@ -244,8 +239,7 @@ advanced constructor for users who want to avoid string parsing. // sparse_table property: return as list of SparsePauliWord mapping.def_property_readonly( - "sparse_table", - [](const MajoranaMapping& self) { return self.table(); }, + "sparse_table", [](const MajoranaMapping& self) { return self.table(); }, "List of sparse Pauli words [(qubit_idx, op_type), ...]."); // phases property @@ -379,8 +373,7 @@ Construct a parity encoding. [](const MajoranaMapping& mapping, double core_energy, py::array_t h1_alpha, - py::array_t - h1_beta, + py::array_t h1_beta, py::array_t eri_aaaa, py::array_t diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index 1a74b333f..3d3e39c10 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -183,8 +183,6 @@ def _run_impl( fermion_mode_order=qh.fermion_mode_order, tapering=mapping.tapering, ) - Logger.debug( - f"Tapered {mapping.tapering.num_tapered} qubits → {qh.num_qubits} qubits" - ) + Logger.debug(f"Tapered {mapping.tapering.num_tapered} qubits → {qh.num_qubits} qubits") return qh diff --git a/python/src/qdk_chemistry/algorithms/registry.py b/python/src/qdk_chemistry/algorithms/registry.py index 3132422de..e15553f47 100644 --- a/python/src/qdk_chemistry/algorithms/registry.py +++ b/python/src/qdk_chemistry/algorithms/registry.py @@ -513,8 +513,14 @@ def _register_python_factories(): from qdk_chemistry.algorithms.qubit_mapper import QubitMapperFactory # noqa: PLC0415 from qdk_chemistry.algorithms.state_preparation import StatePreparationFactory # noqa: PLC0415 from qdk_chemistry.algorithms.term_grouper import TermGrouperFactory # noqa: PLC0415 + from qdk_chemistry.algorithms.time_evolution.circuit_mapper import ( # noqa: PLC0415 + EvolutionCircuitMapperFactory, + ) + from qdk_chemistry.algorithms.time_evolution.measure_simulation import MeasureSimulationFactory # noqa: PLC0415 register_factory(EnergyEstimatorFactory()) + register_factory(EvolutionCircuitMapperFactory()) + register_factory(MeasureSimulationFactory()) register_factory(StatePreparationFactory()) register_factory(TermGrouperFactory()) register_factory(QubitMapperFactory()) @@ -596,6 +602,10 @@ def _register_python_algorithms(): IdentityTermGrouper, QubitWiseCommutingTermGrouper, ) + from qdk_chemistry.algorithms.time_evolution.circuit_mapper import ( # noqa: PLC0415 + PauliSequenceMapper as EvolutionPauliSequenceMapper, + ) + from qdk_chemistry.algorithms.time_evolution.measure_simulation import EvolveAndMeasure # noqa: PLC0415 register(lambda: QdkEnergyEstimator()) register(lambda: SparseIsometryGF2XStatePreparation()) @@ -608,7 +618,9 @@ def _register_python_algorithms(): register(lambda: Trotter()) register(lambda: QDrift()) register(lambda: PartiallyRandomized()) + register(lambda: EvolutionPauliSequenceMapper()) register(lambda: PauliSequenceMapper()) + register(lambda: EvolveAndMeasure()) register(lambda: QdkFullStateSimulator()) register(lambda: QdkSparseStateSimulator()) register(lambda: IterativePhaseEstimation()) diff --git a/python/src/qdk_chemistry/data/tapering.py b/python/src/qdk_chemistry/data/tapering.py index 855e6064a..c3097435a 100644 --- a/python/src/qdk_chemistry/data/tapering.py +++ b/python/src/qdk_chemistry/data/tapering.py @@ -38,6 +38,11 @@ class TaperingSpecification: __slots__ = ("_eigenvalues", "_qubit_indices", "_source_encoding", "_source_num_qubits") + _qubit_indices: tuple[int, ...] + _eigenvalues: tuple[int, ...] + _source_num_qubits: int + _source_encoding: str + def __init__( self, qubit_indices: tuple[int, ...] | list[int], diff --git a/python/tests/test_tapering.py b/python/tests/test_tapering.py index 824e1f17e..64ca35734 100644 --- a/python/tests/test_tapering.py +++ b/python/tests/test_tapering.py @@ -104,5 +104,3 @@ def test_all_terms_eliminated_raises(self) -> None: qh = QubitHamiltonian(pauli_strings=["XI"], coefficients=np.array([1.0])) with pytest.raises(ValueError, match="eliminated"): taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) - - From 9d887d511950ef7e582fcd9f66940812a946ef35 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Wed, 13 May 2026 20:02:35 +0000 Subject: [PATCH 079/117] PR comment --- cpp/src/qdk/chemistry/data/majorana_map_engine.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index e95d47f1e..caac32fc2 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -596,13 +596,18 @@ MajoranaMapResult majorana_map_hamiltonian( const double* eri_bbbb, std::size_t n_spatial, bool is_restricted, double threshold, double integral_threshold) { const std::size_t num_qubits = mapping.num_qubits(); + if (num_qubits == 0) { + throw std::invalid_argument( + "majorana_map_hamiltonian: mapping has zero qubits; the encoding " + "must produce at least one qubit. This usually indicates an " + "uninitialized or malformed MajoranaMapping."); + } const std::size_t num_words = (num_qubits + 63) / 64; // Dispatch to the appropriate template instantiation. // Each instantiation uses std::array (stack-allocated, // no heap overhead per Pauli word). switch (num_words) { - case 0: case 1: return majorana_map_impl<1>(mapping, core_energy, h1_alpha, h1_beta, eri_aaaa, eri_aabb, eri_bbbb, n_spatial, From 2352b34fc772cbd3e98b5eca35ae63f40d643bbe Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 18 May 2026 20:04:24 +0000 Subject: [PATCH 080/117] feat: add bilinear primitive to MajoranaMapping Generalize MajoranaMapping from "table of per-Majorana Pauli images" to "fermion-to-qubit encoding," exposing the bilinear i*gamma_j*gamma_k as the unified primitive that works in every encoding (including future redundant encodings such as Bravyi-Kitaev superfast, Verstraete-Cirac, and Derby-Klassen, where individual Majoranas have no Pauli image). C++ (data/majorana_mapping.{hpp,cpp}): - New bilinear(j, k) -> (complex, SparsePauliWord), computed via PauliTermAccumulator::multiply_uncached with the i * phase_j * phase_k prefactor. Throws out_of_range / invalid_argument. - New majorana(k) alias for operator()(k). - New introspection hooks reserved for redundant encodings: is_majorana_atomic() (true), stabilizers() (empty), parity_sector() (0). - Expanded Doxygen with references (Chen-Xu-Boettcher 2023; Jiang-Kalev-Mruczkiewicz-Neven 2020). Pybind11 + Python wrapper: - Bind bilinear, majorana, is_majorana_atomic, stabilizers, parity_sector. Map invalid_argument -> ValueError, out_of_range -> IndexError. - Document that for tapered encodings (e.g. SCBK) returned Pauli strings are in the pre-taper basis (len(table[0])), with tapering applied downstream by the qubit mapper. Docs: - New "Bilinears as the unified primitive" section in the MajoranaMapping user doc; BdG / MZM clarifications; references. Tests (TestBilinear, TestEncodingMetadata): - Parametrized over JW / BK / parity x n_modes in {2,3,4,6}; verify bilinear(j,k) == i*gamma_j*gamma_k, antisymmetry, (i*gamma_j*gamma_k)^2 = I, commutation / anticommutation by shared-index count, Hermitian (real +/-1 coefficient). - Hand-computed JW(n=2) values, error cases, and SCBK pre-taper length check. All additions are backward-compatible; no public API was removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../qdk/chemistry/data/majorana_mapping.hpp | 141 +++++++++- .../qdk/chemistry/data/majorana_mapping.cpp | 26 ++ .../comprehensive/data/majorana_mapping.rst | 40 ++- python/src/pybind11/data/majorana_mapping.cpp | 150 +++++++++- .../qdk_chemistry/data/majorana_mapping.py | 160 ++++++++++- python/tests/test_majorana_mapping.py | 257 ++++++++++++++++++ 6 files changed, 742 insertions(+), 32 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index 15dcd8fcf..eabf207c0 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -3,24 +3,40 @@ // license information. #pragma once +#include #include #include #include +#include #include namespace qdk::chemistry::data { /** - * @brief Immutable data class mapping 2N Majorana operators to Pauli strings. + * @brief Immutable data class describing a fermion-to-qubit encoding. * - * A MajoranaMapping stores a table of 2N SparsePauliWord entries, one per - * Majorana operator γ_k (k = 0, ..., 2N-1), where N is the number of - * fermionic modes (spin-orbitals). Each entry is a single Pauli string - * representing φ(γ_k) under the chosen fermion-to-qubit encoding. + * The most general primitive of any fermion-to-qubit encoding is the + * **bilinear** i·γ_j·γ_k: the parity-even subalgebra of the Majorana + * Clifford algebra is generated by these bilinears in every encoding, + * and they always have an explicit Pauli image, even in redundant + * (stabilizer-code) encodings such as Bravyi-Kitaev superfast where + * individual Majoranas do not. See ``bilinear()``. + * + * For **Majorana-atomic** encodings (Jordan-Wigner, Bravyi-Kitaev tree, + * parity, and their tapered variants) individual Majorana operators γ_k + * additionally have a Pauli image, and the encoding can be specified + * compactly as a 2N-entry table. The current constructors and factory + * methods of this class all produce Majorana-atomic encodings; the + * stored ``table`` is the per-Majorana Pauli string. ``majorana()`` + * exposes those entries directly. The bilinear i·γ_j·γ_k is computed on + * demand from ``i · phases_[j] · phases_[k] · table_[j] · table_[k]``. + * + * Future redundant encodings would not have a per-Majorana table; the + * bilinear API is the unified primitive that covers both regimes. * * The mapping is validated at construction time by checking the Clifford * algebra anticommutation relations: {γ_i, γ_j} = 2δ_{ij} · I. This - * guarantees that the mapping defines a valid encoding. + * guarantees that the mapping defines a valid Majorana-atomic encoding. * * Pauli string convention: The SparsePauliWord entries are stored in the * same little-endian convention used by QubitHamiltonian (qubit 0 has the @@ -30,6 +46,15 @@ namespace qdk::chemistry::data { * Bravyi-Kitaev, parity) from a mode count. Custom encodings can be * constructed directly by providing the table. * + * References: + * - Chen, Xu, Boettcher, "Equivalence between fermion-to-qubit mappings + * in two spatial dimensions" (2023): every f2q mapping is a + * homomorphism from the parity-even Majorana Clifford algebra into + * the Pauli group, modulo stabilizers. + * - Jiang, Kalev, Mruczkiewicz, Neven, "Optimal fermion-to-qubit + * mapping via ternary trees..." (2020) and the Majorana loop + * stabilizer code literature: redundant encodings as stabilizer codes. + * * @see PauliTermAccumulator for the accumulation engine that uses this * mapping. */ @@ -68,12 +93,108 @@ class MajoranaMapping { /** * @brief Look up the Pauli string for Majorana operator gamma_k. + * + * Returns the unsigned Pauli word table_[k]; the full Majorana operator + * is gamma_k = phases_[k] * table_[k]. Available only for + * Majorana-atomic encodings (every encoding constructible today is + * Majorana-atomic; see ``is_majorana_atomic()``). + * * @param k Majorana index (0 <= k < 2N). * @return const reference to the SparsePauliWord for gamma_k. * @throws std::out_of_range if k >= 2N. */ const SparsePauliWord& operator()(std::size_t k) const; + /** + * @brief Named alias for ``operator()(k)``. + * + * Equivalent to ``(*this)(k)``. Provided so call sites that consume + * the bilinear-first interface can read consistently + * (``mapping.majorana(k)`` and ``mapping.bilinear(j, k)``). + * + * @param k Majorana index (0 <= k < 2N). + * @return const reference to the SparsePauliWord for gamma_k. + * @throws std::out_of_range if k >= 2N. + */ + const SparsePauliWord& majorana(std::size_t k) const { return (*this)(k); } + + /** + * @brief Pauli image of the bilinear i·γ_j·γ_k. + * + * Returns ``(coeff, word)`` such that the Pauli operator + * ``coeff · word`` equals ``i · γ_j · γ_k`` in the encoded qubit + * representation. Defined for all distinct ``j != k`` in + * ``[0, 2 * num_modes())``. + * + * For Majorana-atomic encodings this is computed as + * ``i · phases_[j] · phases_[k] · table_[j] * table_[k]`` using the + * Pauli algebra. The returned coefficient is always real (±1) for the + * encodings produced by the factory methods, since ``i·γ_j·γ_k`` is + * Hermitian; the return type is ``std::complex`` for + * generality and consistency with + * ``PauliTermAccumulator::multiply_uncached``. + * + * Bilinears commute with the total fermion parity and with all + * stabilizers of the codespace, so they are the most general primitive + * usable across Majorana-atomic and redundant encodings alike. + * + * For tapered encodings (e.g. SCBK, parity-2q-reduced) this returns + * the **pre-taper** (source) Pauli string, with qubit indices in + * ``[0, num_qubits())``. Apply the :class:`TaperingSpecification` + * separately if you need post-taper coordinates. + * + * @param j First Majorana index (0 <= j < 2N). + * @param k Second Majorana index (0 <= k < 2N), must differ from j. + * @return pair of (complex coefficient, sparse Pauli word). + * @throws std::out_of_range if j or k is >= 2N. + * @throws std::invalid_argument if j == k. + */ + std::pair, SparsePauliWord> bilinear( + std::size_t j, std::size_t k) const; + + /** + * @brief Whether the encoding exposes a Pauli image for individual + * Majoranas. + * + * ``true`` for every encoding currently constructible (the constructor + * requires a 2N-entry table). Future redundant encodings (Bravyi- + * Kitaev superfast, Verstraete-Cirac, Derby-Klassen, …) would return + * ``false``; for those, ``majorana()`` would not be defined and only + * ``bilinear()`` would be available. + * + * @return ``true`` for Majorana-atomic encodings. + */ + bool is_majorana_atomic() const { return true; } + + /** + * @brief Stabilizer generators of the codespace. + * + * Empty for Majorana-atomic encodings (no codespace constraint: the + * full 2^num_qubits qubit space carries both fermion-parity sectors). + * Reserved for redundant encodings whose codespace is a fixed-parity + * sector stabilized by ``num_qubits - num_modes`` Pauli operators. + * + * @return const reference to the stabilizer list (empty for current + * encodings). + */ + const std::vector& stabilizers() const { + return stabilizers_; + } + + /** + * @brief Parity sector of the encoded codespace. + * + * - ``0``: unrestricted; the full Fock space spans both even and odd + * parity (Majorana-atomic encodings). + * - ``+1``: even-parity codespace (typical for redundant encodings on + * electron-number-conserving Hamiltonians). + * - ``-1``: odd-parity codespace (e.g. odd electron count by + * construction). + * + * Always ``0`` for current encodings. + */ + int parity_sector() const { return parity_sector_; } + /** * @brief Get the sign factor for Majorana operator gamma_k. * @param k Majorana index (0 <= k < 2N). @@ -172,6 +293,14 @@ class MajoranaMapping { /// Cached qubit count (max qubit index + 1). std::size_t num_qubits_; + /// Stabilizer generators of the codespace. Empty for Majorana-atomic + /// encodings; reserved for redundant encodings. + std::vector stabilizers_{}; + + /// Parity sector of the codespace: 0 (unrestricted), +1 (even), or -1 + /// (odd). Always 0 for current encodings. + int parity_sector_{0}; + /// Compute num_qubits_ from the table. static std::size_t compute_num_qubits( const std::vector& table); diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp index 3ac416aec..27d2f7c4c 100644 --- a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp @@ -196,6 +196,32 @@ std::int8_t MajoranaMapping::phase(std::size_t k) const { return phases_[k]; } +std::pair, SparsePauliWord> MajoranaMapping::bilinear( + std::size_t j, std::size_t k) const { + const std::size_t n = table_.size(); + if (j >= n || k >= n) { + throw std::out_of_range( + "MajoranaMapping::bilinear index out of range: requested (" + + std::to_string(j) + ", " + std::to_string(k) + "), valid range [0, " + + std::to_string(n) + ")"); + } + if (j == k) { + throw std::invalid_argument( + "MajoranaMapping::bilinear is undefined for j == k (got " + + std::to_string(j) + + "); the bilinear i*gamma_j*gamma_k requires distinct indices."); + } + // i * gamma_j * gamma_k + // = i * (phases_[j] * table_[j]) * (phases_[k] * table_[k]) + // = i * phases_[j] * phases_[k] * (table_[j] * table_[k]) + auto [pauli_phase, word] = + PauliTermAccumulator::multiply_uncached(table_[j], table_[k]); + std::complex coeff = std::complex(0.0, 1.0) * pauli_phase * + static_cast(phases_[j]) * + static_cast(phases_[k]); + return {coeff, std::move(word)}; +} + void MajoranaMapping::validate() const { const std::size_t n = table_.size(); constexpr double tol = 1e-12; diff --git a/docs/source/user/comprehensive/data/majorana_mapping.rst b/docs/source/user/comprehensive/data/majorana_mapping.rst index 7b6b6c3ce..44e20fcc5 100644 --- a/docs/source/user/comprehensive/data/majorana_mapping.rst +++ b/docs/source/user/comprehensive/data/majorana_mapping.rst @@ -1,15 +1,35 @@ MajoranaMapping =============== -The :class:`~qdk_chemistry.data.MajoranaMapping` class in QDK/Chemistry is an immutable data class that defines a fermion-to-qubit encoding by mapping Majorana operators to Pauli strings. +The :class:`~qdk_chemistry.data.MajoranaMapping` class in QDK/Chemistry is an immutable data class that defines a fermion-to-qubit encoding. +It exposes the **bilinear** :math:`i\,\gamma_j\,\gamma_k` as the unified primitive available across every encoding, and (for Majorana-atomic encodings) individual Majorana operators :math:`\gamma_k` as an additional capability. As a core :doc:`data class <../design/index>`, it follows QDK/Chemistry's immutable data pattern. Overview -------- Fermion-to-qubit mappings transform fermionic creation and annihilation operators into qubit (Pauli) operators. -Every such mapping can be expressed as a table of Majorana-to-Pauli-string correspondences. -The :class:`~qdk_chemistry.data.MajoranaMapping` class encapsulates this table, making the :doc:`QubitMapper <../algorithms/qubit_mapper>` algorithm encoding-agnostic: the mapper receives the encoding as data rather than selecting it internally. +The :class:`~qdk_chemistry.data.MajoranaMapping` class encapsulates such an encoding as data, making the :doc:`QubitMapper <../algorithms/qubit_mapper>` algorithm encoding-agnostic: the mapper receives the encoding as data rather than selecting it internally. + +Bilinears as the unified primitive +---------------------------------- + +Across every fermion-to-qubit encoding, the most general primitive that admits a Pauli-string image is the **bilinear** :math:`i\,\gamma_j\,\gamma_k`. +This is not a stylistic choice: bilinears generate the parity-even subalgebra of the Majorana Clifford algebra in *every* encoding, so any parity-conserving operator (Hamiltonian terms, BdG anomalous terms, density-density couplings) decomposes into ordered bilinear products. +Quartics and higher-degree even monomials are products of bilinears. + +By contrast, individual Majorana operators :math:`\gamma_k` only have a Pauli image in **Majorana-atomic** encodings. +For *redundant* encodings — Bravyi-Kitaev superfast, Verstraete-Cirac, Derby-Klassen compact, Setia-Whitfield, and the broader Majorana loop stabilizer code family — :math:`m > n` qubits represent :math:`n` modes, and a single :math:`\gamma_k` anticommutes with the total parity stabilizer; it has no representation in the codespace at all. + +The :class:`~qdk_chemistry.data.MajoranaMapping` API therefore exposes: + +- :py:meth:`~qdk_chemistry.data.MajoranaMapping.bilinear` — the unified primitive available on every encoding. +- :py:meth:`~qdk_chemistry.data.MajoranaMapping.majorana` — the additional capability that Majorana-atomic encodings provide; gated by :py:attr:`~qdk_chemistry.data.MajoranaMapping.is_majorana_atomic`. +- :py:attr:`~qdk_chemistry.data.MajoranaMapping.stabilizers` and :py:attr:`~qdk_chemistry.data.MajoranaMapping.parity_sector` — codespace metadata; empty / ``0`` for the Majorana-atomic encodings shipped today, populated for redundant encodings when those land. + +The current factory methods (Jordan-Wigner, Bravyi-Kitaev, parity, SCBK) are all Majorana-atomic, so both APIs are available and produce consistent results: :py:meth:`~qdk_chemistry.data.MajoranaMapping.bilinear` is computed on demand from the stored Majorana table. + +A common point of confusion: BdG (Bogoliubov-de Gennes) Hamiltonians contain anomalous terms like :math:`a_i^\dagger a_j^\dagger`. Despite breaking :math:`U(1)` particle number, these are parity-even bilinears in :math:`\gamma`'s and are representable on **any** backend, redundant or not. The cases that *require* a Majorana-atomic backend are single-Majorana observables (e.g. MZM measurements), state preparation by acting with a single :math:`a_j^\dagger` on the vacuum, and Bogoliubov quasiparticle operators viewed as observables. Convention ~~~~~~~~~~ @@ -18,6 +38,7 @@ Convention The number of fermionic modes (spin-orbitals) in the system. Pauli strings use little-endian qubit ordering, consistent with the rest of QDK/Chemistry's :doc:`PauliOperator ` layer. +:py:meth:`~qdk_chemistry.data.MajoranaMapping.majorana` and :py:meth:`~qdk_chemistry.data.MajoranaMapping.bilinear` return Pauli strings in the encoding's native (pre-taper) qubit basis, i.e. of length ``len(mapping.table[0])``; any tapering specification is applied downstream by the qubit mapper. Built-in encodings ------------------ @@ -34,6 +55,13 @@ Jordan-Wigner mapping = MajoranaMapping.jordan_wigner(num_modes=12) + # Bilinear (works on every encoding): + coeff, pauli_str = mapping.bilinear(0, 1) + + # Single Majorana (Majorana-atomic encodings only): + if mapping.is_majorana_atomic: + gamma_0 = mapping.majorana(0) + Encodes each fermionic mode in a single qubit. See :ref:`encoding-jordan-wigner` for a description of the encoding. @@ -60,7 +88,7 @@ See :ref:`encoding-parity` for a description of the encoding. Custom encodings ---------------- -A custom encoding can be defined by providing a Pauli-string table directly: +A custom Majorana-atomic encoding can be defined by providing a Pauli-string table directly: .. code-block:: python @@ -78,7 +106,7 @@ Alternatively, construct from mode pairs: Validation ---------- -At construction, the :class:`~qdk_chemistry.data.MajoranaMapping` validates that the provided table satisfies the Clifford algebra anti-commutation relations required for a valid fermion-to-qubit mapping. +At construction, the :class:`~qdk_chemistry.data.MajoranaMapping` validates that the provided table satisfies the Clifford algebra anti-commutation relations required for a valid Majorana-atomic fermion-to-qubit mapping. Invalid tables raise an error immediately, preventing silent correctness issues downstream. Serialization @@ -113,3 +141,5 @@ Further reading - :doc:`QubitMapper <../algorithms/qubit_mapper>`: The algorithm that consumes a ``MajoranaMapping`` to perform fermion-to-qubit transformations - :doc:`Design principles <../design/index>`: Data class design principles in QDK/Chemistry +- Chen, Xu, Boettcher, "Equivalence between fermion-to-qubit mappings in two spatial dimensions" — unified treatment of every fermion-to-qubit mapping as a homomorphism from the parity-even Majorana Clifford algebra into the Pauli group, modulo stabilizers. +- Jiang, Kalev, Mruczkiewicz, Neven, "Optimal fermion-to-qubit mapping via ternary trees" and the related Majorana loop stabilizer code literature — redundant encodings as stabilizer codes. diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index 789a6ef78..6b3402344 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -92,15 +92,23 @@ void bind_majorana_mapping(pybind11::module& data) { using namespace qdk::chemistry::data; py::class_ mapping(data, "MajoranaMapping", R"( -Immutable data class mapping 2N Majorana operators to Pauli strings. +Immutable data class describing a fermion-to-qubit encoding. -A MajoranaMapping stores a table of 2N entries, one per Majorana operator -γ_k (k = 0, ..., 2N-1), where N is the number of fermionic modes -(spin-orbitals). Each entry is a single Pauli string representing φ(γ_k) -under the chosen fermion-to-qubit encoding. +The unified primitive across all fermion-to-qubit encodings is the +**bilinear** ``i·γ_j·γ_k``: the parity-even subalgebra of the Majorana +Clifford algebra is generated by these bilinears in every encoding, +even in redundant encodings (e.g. Bravyi-Kitaev superfast, +Verstraete-Cirac, Derby-Klassen) where individual Majoranas have no +Pauli image. See :py:meth:`bilinear`. -The mapping is validated at construction time by checking the Clifford -algebra anticommutation relations: {γ_i, γ_j} = 2δ_{ij} · I. +For **Majorana-atomic** encodings (Jordan-Wigner, Bravyi-Kitaev tree, +parity, and their tapered variants) individual Majorana operators γ_k +additionally have a Pauli image, accessible via :py:meth:`majorana` +and the underlying ``table``. Every encoding currently constructible +through this class is Majorana-atomic; see :py:attr:`is_majorana_atomic`. + +The Clifford algebra ``{γ_i, γ_j} = 2δ_{ij}·I`` is validated at +construction. Example: >>> from qdk_chemistry._core.data import MajoranaMapping @@ -109,6 +117,7 @@ algebra anticommutation relations: {γ_i, γ_j} = 2δ_{ij} · I. 4 >>> jw.num_qubits 4 + >>> coeff, word = jw.bilinear(0, 1) )"); // Constructor from list of dense little-endian Pauli strings + optional @@ -259,6 +268,51 @@ advanced constructor for users who want to avoid string parsing. [](const MajoranaMapping& self) { return self.all_phases_positive(); }, "True if all phases are +1 (standard encodings)."); + mapping.def_property_readonly( + "is_majorana_atomic", + [](const MajoranaMapping& self) { return self.is_majorana_atomic(); }, + R"( +True if individual Majorana operators γ_k have a Pauli image under +this encoding (i.e. :py:meth:`majorana` is callable). Always True for +encodings constructible through this class today; future redundant +encodings will return False, in which case only :py:meth:`bilinear` +is available. +)"); + + // stabilizers property: list of dense little-endian Pauli strings + mapping.def_property_readonly( + "stabilizers", + [](const MajoranaMapping& self) -> py::tuple { + const auto& s = self.stabilizers(); + std::size_t nq = self.num_qubits(); + py::tuple result(s.size()); + for (std::size_t i = 0; i < s.size(); ++i) { + result[i] = py::cast(sparse_to_dense_le(s[i], nq)); + } + return result; + }, + R"( +Stabilizer generators of the codespace, as a tuple of dense +little-endian Pauli strings. Empty for Majorana-atomic encodings (no +codespace constraint). Reserved for redundant encodings whose +codespace is a fixed-parity sector stabilized by +``num_qubits - num_modes`` Pauli operators. +)"); + + mapping.def_property_readonly( + "parity_sector", + [](const MajoranaMapping& self) { return self.parity_sector(); }, + R"( +Parity sector of the codespace: + +- ``0``: unrestricted; the full Fock space spans both parity sectors + (Majorana-atomic encodings). +- ``+1``: even-parity codespace. +- ``-1``: odd-parity codespace. + +Always ``0`` for current encodings. +)"); + // __call__ for γ_k lookup mapping.def( "__call__", @@ -283,6 +337,88 @@ Look up the sparse Pauli word for Majorana operator γ_k. IndexError: If k is out of range. )"); + // majorana(k) — named alias for __call__, returning a dense LE string + mapping.def( + "majorana", + [](const MajoranaMapping& self, std::size_t k) -> std::string { + try { + return sparse_to_dense_le(self.majorana(k), self.num_qubits()); + } catch (const std::out_of_range& e) { + throw py::index_error(e.what()); + } + }, + py::arg("k"), + R"( +Pauli image of the Majorana operator γ_k as a dense little-endian +Pauli string. + +Available only for :py:attr:`is_majorana_atomic` encodings. The +returned string uses the encoding's native (pre-taper) qubit count +``len(table[0])``; tapering, if any, is applied by downstream +mappers and is not reflected here. + +Args: + k: Majorana index (0 ≤ k < 2N). + +Returns: + Dense little-endian Pauli string (qubit 0 = rightmost char) of + length ``len(table[0])``. + +Raises: + IndexError: If k is out of range. +)"); + + // bilinear(j, k) — Pauli image of i·γ_j·γ_k as (coefficient, dense LE string) + mapping.def( + "bilinear", + [](const MajoranaMapping& self, std::size_t j, + std::size_t k) -> py::tuple { + try { + auto [coeff, word] = self.bilinear(j, k); + std::string dense = sparse_to_dense_le(word, self.num_qubits()); + return py::make_tuple(py::cast(coeff), py::cast(dense)); + } catch (const std::invalid_argument& e) { + throw py::value_error(e.what()); + } catch (const std::out_of_range& e) { + throw py::index_error(e.what()); + } + }, + py::arg("j"), py::arg("k"), + R"( +Pauli image of the bilinear ``i·γ_j·γ_k``. + +Returns ``(coeff, pauli_str)`` such that the Pauli operator +``coeff * pauli_str`` equals ``i·γ_j·γ_k`` in the encoded qubit +representation. Defined for all distinct ``j != k`` in +``[0, 2 * num_modes)``. + +For the encodings produced by the factory methods the returned +coefficient is always real (±1), since ``i·γ_j·γ_k`` is Hermitian for +``j != k``. The return type is :class:`complex` for generality and +consistency with :py:meth:`PauliTermAccumulator.multiply_uncached`. + +Bilinears commute with the total fermion parity and with all +stabilizers of the codespace, so they are the most general primitive +usable across Majorana-atomic and redundant encodings alike. + +The returned Pauli string uses the encoding's native (pre-taper) +qubit count ``len(table[0])``; tapering, if any, is applied by +downstream mappers and is not reflected here. + +Args: + j: First Majorana index (0 ≤ j < 2N). + k: Second Majorana index (0 ≤ k < 2N), must differ from j. + +Returns: + Tuple ``(coeff, pauli_str)`` where ``coeff`` is a Python complex + and ``pauli_str`` is the dense little-endian Pauli string (qubit + 0 = rightmost char) of length ``len(table[0])``. + +Raises: + IndexError: If j or k is out of range. + ValueError: If j == k. +)"); + // validate() mapping.def( "validate", diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index fb0f11c4c..eaad66f28 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -1,9 +1,28 @@ """Majorana-to-Pauli mapping data class for fermion-to-qubit encodings. -This module provides the :class:`MajoranaMapping` data class, which stores -the mapping of 2N Majorana operators to Pauli strings for a given f2q encoding. -Standard encodings (Jordan-Wigner, Bravyi-Kitaev, parity) are available via -class-method factories; custom encodings can be constructed from a Pauli table. +This module provides the :class:`MajoranaMapping` data class, which describes +a fermion-to-qubit encoding. The unified primitive across all f2q encodings +is the **bilinear** ``i·gamma_j·gamma_k`` (see :py:meth:`MajoranaMapping.bilinear`): +the parity-even subalgebra of the Majorana Clifford algebra is generated by +these bilinears in every encoding, including redundant encodings (e.g. +Bravyi-Kitaev superfast, Verstraete-Cirac, Derby-Klassen) where individual +Majoranas have no Pauli image. + +Standard **Majorana-atomic** encodings (Jordan-Wigner, Bravyi-Kitaev tree, +parity, and their tapered variants) additionally provide a Pauli image for +each individual Majorana operator gamma_k via the ``table`` and +:py:meth:`MajoranaMapping.majorana` interfaces. The factory class methods +on :class:`MajoranaMapping` produce these Majorana-atomic encodings; custom +Majorana-atomic encodings can be constructed from a Pauli table. + +References: + * Chen, Xu, Boettcher, "Equivalence between fermion-to-qubit mappings in + two spatial dimensions" (2023) — every f2q mapping as a homomorphism + from the parity-even Majorana Clifford algebra into the Pauli group, + modulo stabilizers. + * Jiang, Kalev, Mruczkiewicz, Neven, "Optimal fermion-to-qubit mapping + via ternary trees..." (2020), and the Majorana loop stabilizer code + literature. """ @@ -31,25 +50,40 @@ class MajoranaMapping(DataClass): - """Immutable data class mapping 2N Majorana operators to Pauli strings. + """Immutable data class describing a fermion-to-qubit encoding. - A ``MajoranaMapping`` stores a table of 2N entries, one per Majorana operator - gamma_k (k = 0, ..., 2N-1), where N is the number of fermionic modes (spin-orbitals). - Each entry is a single Pauli string representing φ(gamma_k) under the chosen - fermion-to-qubit encoding. + The unified primitive across all f2q encodings is the **bilinear** + ``i·gamma_j·gamma_k``, accessible via :py:meth:`bilinear`. Bilinears commute with + the total fermion parity and with all stabilizers of the codespace, so + they always have a Pauli image — including in redundant encodings where + individual Majoranas do not. - The mapping is validated at construction time by checking the Clifford algebra - anticommutation relations: {gamma_i, gamma_j} = 2δ_{ij} · I. This guarantees that the - mapping defines a valid encoding. + For **Majorana-atomic** encodings (Jordan-Wigner, Bravyi-Kitaev tree, + parity, and their tapered variants) individual Majoranas gamma_k additionally + have a Pauli image accessible via :py:meth:`majorana` and the + ``table`` attribute. Every encoding currently constructible through + this class is Majorana-atomic; see :py:attr:`is_majorana_atomic`. + + A ``MajoranaMapping`` constructed from a 2N-entry table stores those + entries as the per-Majorana Pauli image; the bilinear ``i·gamma_j·gamma_k`` is + computed on demand from the table. + + The mapping is validated at construction time by checking the Clifford + algebra anticommutation relations: ``{gamma_i, gamma_j} = 2δ_{ij} · I``. + This guarantees that the mapping defines a valid Majorana-atomic encoding. Pauli strings use the same **little-endian** convention as - :class:`~qdk_chemistry.data.QubitHamiltonian`: qubit 0 is the rightmost character. + :class:`~qdk_chemistry.data.QubitHamiltonian`: qubit 0 is the rightmost + character. Attributes: table (tuple[str, ...]): Tuple of 2N dense Pauli strings in little-endian format. num_modes (int): Number of fermionic modes (spin-orbitals), N. - num_qubits (int): Number of qubits required by this encoding. + num_qubits (int): Effective number of qubits after any tapering. name (str): Human-readable name of the encoding (may be empty for custom mappings). + is_majorana_atomic (bool): True if individual gamma_k have a Pauli image (always True today). + stabilizers (tuple[str, ...]): Codespace stabilizer generators (empty for current encodings). + parity_sector (int): Codespace parity: 0 (unrestricted), +1 (even), -1 (odd). Always 0 today. Examples: Built-in encodings: @@ -66,6 +100,10 @@ class MajoranaMapping(DataClass): >>> custom.table ('IX', 'IY', 'XZ', 'YZ') + Bilinear lookup: + + >>> coeff, pauli = jw.bilinear(0, 1) + """ _data_type_name = "majorana_mapping" @@ -164,6 +202,100 @@ def core(self) -> _CoreMajoranaMapping: """Access the underlying C++ MajoranaMapping object.""" return self._core + @property + def is_majorana_atomic(self) -> bool: + """True if individual Majorana operators gamma_k have a Pauli image. + + Always ``True`` for encodings constructible through this class today + — they are all Majorana-atomic and expose :py:meth:`majorana`. + Future redundant encodings (e.g. Bravyi-Kitaev superfast, + Verstraete-Cirac, Derby-Klassen) would return ``False``; for those, + only :py:meth:`bilinear` is available. + """ + return bool(self._core.is_majorana_atomic) + + @property + def stabilizers(self) -> tuple[str, ...]: + """Stabilizer generators of the codespace. + + Empty for Majorana-atomic encodings (no codespace constraint: the + full qubit Hilbert space spanned by the Pauli table carries both + fermion-parity sectors). Reserved for redundant encodings, whose + codespace is a fixed-parity sector stabilized by + ``num_qubits - num_modes`` Pauli operators. + """ + return tuple(self._core.stabilizers) + + @property + def parity_sector(self) -> int: + """Parity sector of the codespace. + + - ``0``: unrestricted; the full Fock space spans both parity sectors + (Majorana-atomic encodings). + - ``+1``: even-parity codespace. + - ``-1``: odd-parity codespace. + + Always ``0`` for current encodings. + """ + return int(self._core.parity_sector) + + def majorana(self, k: int) -> str: + """Return the Pauli image of the Majorana operator gamma_k. + + Available only for :py:attr:`is_majorana_atomic` encodings (always + true for current encodings). For tapered encodings the returned + Pauli string is in the encoding's native (pre-taper) qubit basis, + i.e. of length ``len(self.table[0])``; tapering, if any, is + applied downstream by the qubit mapper. + + Args: + k (int): Majorana index (0 ≤ k < 2 * num_modes). + + Returns: + str: Dense little-endian Pauli string (qubit 0 = rightmost char). + + Raises: + IndexError: If k is out of range. + + """ + return self._core.majorana(k) + + def bilinear(self, j: int, k: int) -> tuple[complex, str]: + """Return the Pauli image of the bilinear ``i·gamma_j·gamma_k``. + + Returns ``(coeff, pauli_str)`` such that the Pauli operator + ``coeff * pauli_str`` equals ``i·gamma_j·gamma_k`` in the encoded qubit + representation. Defined for all distinct ``j != k`` in + ``[0, 2 * num_modes)``. + + For the Majorana-atomic encodings produced by the factory methods + the returned coefficient is always real (±1), since ``i·gamma_j·gamma_k`` + is Hermitian for ``j != k``. The return type is :class:`complex` + for generality. + + Bilinears commute with the total fermion parity and with all + stabilizers of the codespace, so they are the most general + primitive usable across Majorana-atomic and redundant encodings. + + For tapered encodings the returned Pauli string is in the + encoding's native (pre-taper) qubit basis, i.e. of length + ``len(self.table[0])``; tapering, if any, is applied downstream + by the qubit mapper. + + Args: + j (int): First Majorana index (0 ≤ j < 2 * num_modes). + k (int): Second Majorana index (0 ≤ k < 2 * num_modes), must differ from j. + + Returns: + tuple[complex, str]: ``(coeff, pauli_str)`` in dense little-endian form. + + Raises: + IndexError: If j or k is out of range. + ValueError: If j == k. + + """ + return self._core.bilinear(j, k) + @classmethod def jordan_wigner(cls, num_modes: int) -> MajoranaMapping: """Construct a Jordan-Wigner encoding. diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index 060d7547b..c9c0f8ebe 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -460,3 +460,260 @@ def test_parity_without_symmetries_no_tapering(self) -> None: par = MajoranaMapping.parity(8) assert par.tapering is None assert par.num_qubits == 8 + + +# ─── Bilinear primitive ────────────────────────────────────────────────── + + +def _dense_to_sparse(s: str) -> list[tuple[int, str]]: + """Convert a dense little-endian Pauli string to a sparse word list. + + The dense representation is ``s[0]`` = highest-index qubit, ``s[-1]`` = + qubit 0. The sparse representation is a list of ``(qubit_index, gate)`` + pairs sorted by ``qubit_index``, omitting identities. + """ + n = len(s) + return [(n - 1 - i, c) for i, c in enumerate(s) if c != "I"] + + +def _sparse_to_dense(word: list[tuple[int, str]], n_qubits: int) -> str: + """Inverse of ``_dense_to_sparse``.""" + chars = ["I"] * n_qubits + for q, g in word: + chars[q] = g + return "".join(reversed(chars)) + + +# Single-qubit Pauli multiplication table: (a, b) -> (phase, c) where a*b = phase * c. +_PAULI_MULT = { + ("I", "I"): (1 + 0j, "I"), + ("I", "X"): (1 + 0j, "X"), + ("I", "Y"): (1 + 0j, "Y"), + ("I", "Z"): (1 + 0j, "Z"), + ("X", "I"): (1 + 0j, "X"), + ("X", "X"): (1 + 0j, "I"), + ("X", "Y"): (1j, "Z"), + ("X", "Z"): (-1j, "Y"), + ("Y", "I"): (1 + 0j, "Y"), + ("Y", "X"): (-1j, "Z"), + ("Y", "Y"): (1 + 0j, "I"), + ("Y", "Z"): (1j, "X"), + ("Z", "I"): (1 + 0j, "Z"), + ("Z", "X"): (1j, "Y"), + ("Z", "Y"): (-1j, "X"), + ("Z", "Z"): (1 + 0j, "I"), +} + + +def _multiply_dense(a: str, b: str) -> tuple[complex, str]: + """Multiply two dense little-endian Pauli strings of equal length.""" + assert len(a) == len(b) + phase: complex = 1 + 0j + chars: list[str] = [] + for ca, cb in zip(a, b, strict=True): + p, c = _PAULI_MULT[(ca, cb)] + phase *= p + chars.append(c) + return phase, "".join(chars) + + +_FACTORIES = [ + ("jordan_wigner", lambda n: MajoranaMapping.jordan_wigner(num_modes=n)), + ("bravyi_kitaev", lambda n: MajoranaMapping.bravyi_kitaev(num_modes=n)), + ("parity", lambda n: MajoranaMapping.parity(num_modes=n)), +] + + +class TestBilinear: + """Tests for the unified bilinear primitive ``i·gamma_j·gamma_k``. + + Bilinears are the most general primitive across fermion-to-qubit + encodings: Majorana-atomic encodings expose individual gamma_k as well, but + redundant encodings (e.g. Bravyi-Kitaev superfast) only admit + parity-even products. These tests verify the algebraic invariants that + every backend must satisfy. + """ + + @pytest.mark.parametrize(("name", "factory"), _FACTORIES) + @pytest.mark.parametrize("n_modes", [2, 4, 6]) + def test_matches_majorana_product(self, name: str, factory, n_modes: int) -> None: + """``bilinear(j, k)`` equals ``i · majorana(j) · majorana(k)`` exactly.""" + del name + m = factory(n_modes) + n = 2 * n_modes + for j in range(n): + for k in range(n): + if j == k: + continue + expected_phase, expected_word = _multiply_dense(m.majorana(j), m.majorana(k)) + expected_coeff = 1j * expected_phase + bcoeff, bword = m.bilinear(j, k) + assert bword == expected_word, f"({j},{k}): word {bword} != {expected_word}" + assert abs(bcoeff - expected_coeff) < 1e-12, f"({j},{k}): coeff {bcoeff} != {expected_coeff}" + + @pytest.mark.parametrize(("name", "factory"), _FACTORIES) + @pytest.mark.parametrize("n_modes", [2, 4]) + def test_antisymmetry(self, name: str, factory, n_modes: int) -> None: + """``bilinear(k, j) == -bilinear(j, k)`` for all distinct j, k.""" + del name + m = factory(n_modes) + n = 2 * n_modes + for j in range(n): + for k in range(j + 1, n): + cjk, wjk = m.bilinear(j, k) + ckj, wkj = m.bilinear(k, j) + assert wjk == wkj + assert abs(cjk + ckj) < 1e-12, f"({j},{k}): {cjk} + {ckj} != 0" + + @pytest.mark.parametrize(("name", "factory"), _FACTORIES) + @pytest.mark.parametrize("n_modes", [2, 4]) + def test_squares_to_identity(self, name: str, factory, n_modes: int) -> None: + """``(i·gamma_j·gamma_k)² == I`` for all distinct j, k.""" + del name + m = factory(n_modes) + n = 2 * n_modes + identity = "I" * len(m.table[0]) + for j in range(n): + for k in range(n): + if j == k: + continue + coeff, word = m.bilinear(j, k) + phase, prod = _multiply_dense(word, word) + assert prod == identity, f"({j},{k}): word² = {prod}, not identity" + assert abs((coeff * coeff) * phase - 1.0) < 1e-12, ( + f"({j},{k}): bilinear² coeff = {coeff * coeff * phase}" + ) + + @pytest.mark.parametrize(("name", "factory"), _FACTORIES) + @pytest.mark.parametrize("n_modes", [3, 4]) + def test_commutation_relations(self, name: str, factory, n_modes: int) -> None: + """Disjoint pairs commute; pairs sharing exactly one index anticommute.""" + del name + m = factory(n_modes) + n = 2 * n_modes + pairs = [(j, k) for j in range(n) for k in range(j + 1, n)] + for j1, k1 in pairs: + c1, w1 = m.bilinear(j1, k1) + for j2, k2 in pairs: + shared = len({j1, k1} & {j2, k2}) + if shared == 2: + continue + c2, w2 = m.bilinear(j2, k2) + p_ab, w_ab = _multiply_dense(w1, w2) + p_ba, w_ba = _multiply_dense(w2, w1) + assert w_ab == w_ba + ab = c1 * c2 * p_ab + ba = c2 * c1 * p_ba + if shared == 0: + assert abs(ab - ba) < 1e-12, f"disjoint ({j1},{k1})·({j2},{k2}) does not commute" + else: + assert abs(ab + ba) < 1e-12, f"single-shared ({j1},{k1})·({j2},{k2}) does not anticommute" + + @pytest.mark.parametrize(("name", "factory"), _FACTORIES) + @pytest.mark.parametrize("n_modes", [2, 4]) + def test_hermitian_real_coefficient(self, name: str, factory, n_modes: int) -> None: + """Bilinears are Hermitian, so the coefficient is real for current encodings.""" + del name + m = factory(n_modes) + n = 2 * n_modes + for j in range(n): + for k in range(n): + if j == k: + continue + coeff, _ = m.bilinear(j, k) + assert abs(coeff.imag) < 1e-12, f"({j},{k}): coeff {coeff} is not real" + assert abs(abs(coeff.real) - 1.0) < 1e-12, f"({j},{k}): |coeff| = {abs(coeff.real)}, expected 1" + + def test_jw_n2_known_values(self) -> None: + """JW(num_modes=2): gamma_0=X_0, gamma_1=Y_0, gamma_2=Z_0 X_1, gamma_3=Z_0 Y_1.""" + m = MajoranaMapping.jordan_wigner(num_modes=2) + assert m.bilinear(0, 1) == (-1 + 0j, "IZ") + assert m.bilinear(1, 0) == (1 + 0j, "IZ") + assert m.bilinear(0, 2) == (1 + 0j, "XY") + assert m.bilinear(2, 3) == (-1 + 0j, "ZI") + + def test_raises_on_equal_indices(self) -> None: + """``bilinear(j, j)`` is undefined and must raise ValueError.""" + m = MajoranaMapping.jordan_wigner(num_modes=2) + with pytest.raises(ValueError, match="distinct"): + m.bilinear(0, 0) + with pytest.raises(ValueError, match="distinct"): + m.bilinear(3, 3) + + def test_raises_on_out_of_range(self) -> None: + """Out-of-range indices raise IndexError.""" + m = MajoranaMapping.jordan_wigner(num_modes=2) + with pytest.raises(IndexError): + m.bilinear(0, 4) + with pytest.raises(IndexError): + m.bilinear(99, 0) + + def test_majorana_consistent_with_call(self) -> None: + """``majorana(k)`` and ``__call__(k)`` describe the same Pauli operator.""" + m = MajoranaMapping.jordan_wigner(num_modes=3) + n_q = len(m.table[0]) + for k in range(2 * m.num_modes): + sparse = m.core(k) + dense = m.majorana(k) + assert len(dense) == n_q + non_identity = sum(1 for c in dense if c != "I") + assert non_identity == len(sparse) + + def test_majorana_out_of_range(self) -> None: + """``majorana(k)`` raises IndexError on out-of-range k.""" + m = MajoranaMapping.jordan_wigner(num_modes=2) + with pytest.raises(IndexError): + m.majorana(4) + with pytest.raises(IndexError): + m.majorana(99) + + +class TestEncodingMetadata: + """Tests for the new metadata properties on Majorana-atomic encodings.""" + + @pytest.mark.parametrize(("name", "factory"), _FACTORIES) + @pytest.mark.parametrize("n_modes", [2, 4]) + def test_is_majorana_atomic(self, name: str, factory, n_modes: int) -> None: + """All current encodings are Majorana-atomic.""" + del name + m = factory(n_modes) + assert m.is_majorana_atomic is True + + @pytest.mark.parametrize(("name", "factory"), _FACTORIES) + @pytest.mark.parametrize("n_modes", [2, 4]) + def test_stabilizers_empty(self, name: str, factory, n_modes: int) -> None: + """Majorana-atomic encodings have no codespace stabilizers.""" + del name + m = factory(n_modes) + assert m.stabilizers == () + + @pytest.mark.parametrize(("name", "factory"), _FACTORIES) + @pytest.mark.parametrize("n_modes", [2, 4]) + def test_parity_sector_unrestricted(self, name: str, factory, n_modes: int) -> None: + """Majorana-atomic encodings span both parity sectors (sector 0).""" + del name + m = factory(n_modes) + assert m.parity_sector == 0 + + def test_pauli_string_length_untapered(self) -> None: + """For untapered encodings, bilinear/majorana strings have length ``num_qubits``.""" + m = MajoranaMapping.jordan_wigner(num_modes=4) + assert len(m.table[0]) == m.num_qubits == 4 + assert len(m.majorana(0)) == 4 + _, w = m.bilinear(0, 1) + assert len(w) == 4 + + def test_pauli_string_length_with_tapering(self) -> None: + """For tapered SCBK, bilinear/majorana operate in the pre-taper basis.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + assert len(scbk.table[0]) == 8 + assert scbk.num_qubits == 6 + for j in range(2 * scbk.num_modes): + assert len(scbk.majorana(j)) == 8 + for k in range(2 * scbk.num_modes): + if j == k: + continue + _, w = scbk.bilinear(j, k) + assert len(w) == 8 From 8ef09d4c8a015e128b12def885817fb26fb0f667 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Tue, 26 May 2026 19:01:55 +0000 Subject: [PATCH 081/117] feat: Majorana-first F2Q mapping with BK-tree, fixes, and dispatch docs - Add BK-tree factory (MajoranaMapping.bravyi_kitaev_tree) and fix SCBK to use BK-tree as base encoding (works for non-power-of-2 modes) - Fix parity encoding to match standard convention (Qiskit-compatible) - Fix UHF BBAA ERI index ordering (eri_aabb[r,s,p,q] not [p,q,r,s]) - Fix HDF5 round-trip for phases and tapering metadata - Propagate tapering through QubitHamiltonian arithmetic and interleave - Handle tapering in QubitMapper.run() base class for all backends - Document F2Q dispatch contract: table-driven (QDK) vs name-dispatched (OpenFermion, Qiskit) backends, with inline comments and RST docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../qdk/chemistry/data/majorana_mapping.hpp | 21 ++ .../chemistry/data/majorana_map_engine.cpp | 2 +- .../qdk/chemistry/data/majorana_mapping.cpp | 111 ++++++++-- .../comprehensive/algorithms/qubit_mapper.rst | 61 +++++- .../comprehensive/data/majorana_mapping.rst | 4 +- python/src/pybind11/data/majorana_mapping.cpp | 26 +++ .../qubit_mapper/qdk_qubit_mapper.py | 37 ++-- .../algorithms/qubit_mapper/qubit_mapper.py | 115 +++++++++- .../qdk_chemistry/data/majorana_mapping.py | 95 +++++++-- .../qdk_chemistry/data/qubit_hamiltonian.py | 19 +- .../plugins/openfermion/qubit_mapper.py | 60 ++++-- .../plugins/qiskit/qubit_mapper.py | 59 ++++-- python/src/qdk_chemistry/utils/tapering.py | 15 +- .../test_interop_openfermion_qubit_mapper.py | 32 ++- python/tests/test_majorana_mapping.py | 25 ++- python/tests/test_qdk_qubit_mapper.py | 200 +++++++++++++++++- python/tests/test_qubit_hamiltonian.py | 74 +++++++ 17 files changed, 832 insertions(+), 124 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index eabf207c0..0cbbe9a75 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -265,6 +265,27 @@ class MajoranaMapping { */ static MajoranaMapping bravyi_kitaev(std::size_t num_modes); + /** + * @brief Construct a balanced binary tree Bravyi-Kitaev encoding. + * + * Implementation from arXiv:1701.07072 (Havlíček et al.). Unlike the + * Fenwick-tree BK (``bravyi_kitaev()``), the balanced tree variant has + * guaranteed Z₂ symmetries at qubit positions (n/2-1) and (n-1) for + * any even n, making it the required base encoding for + * symmetry-conserving BK (SCBK) tapering. + * + * γ_{2j} = X_{U(j)} ⊗ X_j ⊗ Z_{P(j)} + * γ_{2j+1} = X_{U(j)} ⊗ Y_j ⊗ Z_{C(j)} + * + * where U(j), P(j) = C(j) ∪ F(j), C(j) are the update, parity, + * and remainder sets derived from the balanced binary tree. + * + * @param num_modes Number of fermionic modes (spin-orbitals). + * The encoding uses num_modes qubits. + * @return MajoranaMapping with name "bravyi-kitaev-tree". + */ + static MajoranaMapping bravyi_kitaev_tree(std::size_t num_modes); + /** * @brief Construct a parity encoding. * diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index caac32fc2..c3484be07 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -563,7 +563,7 @@ MajoranaMapResult majorana_map_impl( for (std::size_t q = 0; q < n_spatial; ++q) { for (std::size_t r = 0; r < n_spatial; ++r) { for (std::size_t s = 0; s < n_spatial; ++s) { - double eri = eri_aabb[idx4(p, q, r, s)]; + double eri = eri_aabb[idx4(r, s, p, q)]; if (std::abs(eri) < integral_threshold) continue; accumulate_two_body_product(mode_beta(p), mode_beta(q), mode_alpha(r), mode_alpha(s), eri); diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp index 27d2f7c4c..d2b93f4b7 100644 --- a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp @@ -389,6 +389,95 @@ MajoranaMapping MajoranaMapping::bravyi_kitaev(std::size_t num_modes) { return MajoranaMapping(std::move(table), "bravyi-kitaev"); } +// ── Factory: Bravyi-Kitaev tree ────────────────────────────────────── + +MajoranaMapping MajoranaMapping::bravyi_kitaev_tree(std::size_t num_modes) { + if (num_modes == 0) { + throw std::invalid_argument("bravyi_kitaev_tree requires num_modes > 0"); + } + + // Build the balanced binary tree (Algorithm 1 from arXiv:1701.07072). + // parent[j] = parent index of node j (-1 for root). + // children[j] = list of child indices of node j. + std::vector parent(num_modes, -1); + std::vector> children(num_modes); + + // Recursive tree builder: range [left, right), pivot = midpoint. + // Left half goes under pivot; right half goes under parent_idx. + auto build_tree = [&](auto& self, std::size_t left, std::size_t right, + std::size_t parent_idx) -> void { + if (left >= right) return; + std::size_t pivot = (left + right) >> 1; + parent[pivot] = static_cast(parent_idx); + children[parent_idx].push_back(pivot); + self(self, left, pivot, pivot); // left subtree under pivot + self(self, pivot + 1, right, parent_idx); // right subtree under parent + }; + + // Root is node (num_modes - 1). Build tree on [0, num_modes - 1). + if (num_modes > 1) { + build_tree(build_tree, 0, num_modes - 1, num_modes - 1); + } + + std::vector table; + table.reserve(2 * num_modes); + + for (std::size_t j = 0; j < num_modes; ++j) { + // U(j) = ancestors of j (walk up parent chain, excluding j itself) + std::vector update; + for (int64_t p = parent[j]; p >= 0; p = parent[static_cast(p)]) { + update.push_back(static_cast(p)); + } + + // F(j) = children of j + // C(j) = for each ancestor of j, children of that ancestor with index < j + std::vector child_set; + for (auto c : children[j]) { + child_set.push_back(static_cast(c)); + } + std::vector remainder; + for (int64_t p = parent[j]; p >= 0; p = parent[static_cast(p)]) { + for (auto c : children[static_cast(p)]) { + if (c < j) { + remainder.push_back(static_cast(c)); + } + } + } + + // P(j) = C(j) ∪ F(j) + std::vector parity_set = remainder; + parity_set.insert(parity_set.end(), child_set.begin(), child_set.end()); + + // γ_{2j} = X_j · Z_{P(j)} · X_{U(j)} + { + std::vector> entries; + entries.emplace_back(static_cast(j), OP_X); + for (auto q : parity_set) { + entries.emplace_back(q, OP_Z); + } + for (auto q : update) { + entries.emplace_back(q, OP_X); + } + table.push_back(build_sorted_word(std::move(entries))); + } + + // γ_{2j+1} = Y_j · Z_{C(j)} · X_{U(j)} + { + std::vector> entries; + entries.emplace_back(static_cast(j), OP_Y); + for (auto q : remainder) { + entries.emplace_back(q, OP_Z); + } + for (auto q : update) { + entries.emplace_back(q, OP_X); + } + table.push_back(build_sorted_word(std::move(entries))); + } + } + + return MajoranaMapping(std::move(table), "bravyi-kitaev-tree"); +} + // ── Factory: Parity ────────────────────────────────────────────────── MajoranaMapping MajoranaMapping::parity(std::size_t num_modes) { @@ -400,30 +489,24 @@ MajoranaMapping MajoranaMapping::parity(std::size_t num_modes) { table.reserve(2 * num_modes); for (std::size_t j = 0; j < num_modes; ++j) { - // γ_{2j}: X_j, X_{j+1} (if j < n-1), Z on {j-1, j-3, j-5, ...} + // γ_{2j} = Z_{j-1} (if j>0) · X_j · X_{j+1} · ... · X_{n-1} { std::vector> entries; - // Z on alternating qubits below j (step -2 from j-1) - for (int64_t k = static_cast(j) - 1; k >= 0; k -= 2) { - entries.emplace_back(static_cast(k), OP_Z); + if (j > 0) { + entries.emplace_back(static_cast(j - 1), OP_Z); } - entries.emplace_back(static_cast(j), OP_X); - if (j < num_modes - 1) { - entries.emplace_back(static_cast(j + 1), OP_X); + for (std::size_t k = j; k < num_modes; ++k) { + entries.emplace_back(static_cast(k), OP_X); } table.push_back(build_sorted_word(std::move(entries))); } - // γ_{2j+1}: Y_j, X_{j+1} (if j < n-1), Z on {j-2, j-4, j-6, ...} + // γ_{2j+1} = Y_j · X_{j+1} · ... · X_{n-1} { std::vector> entries; - // Z on alternating qubits below j (step -2 from j-2) - for (int64_t k = static_cast(j) - 2; k >= 0; k -= 2) { - entries.emplace_back(static_cast(k), OP_Z); - } entries.emplace_back(static_cast(j), OP_Y); - if (j < num_modes - 1) { - entries.emplace_back(static_cast(j + 1), OP_X); + for (std::size_t k = j + 1; k < num_modes; ++k) { + entries.emplace_back(static_cast(k), OP_X); } table.push_back(build_sorted_word(std::move(entries))); } diff --git a/docs/source/user/comprehensive/algorithms/qubit_mapper.rst b/docs/source/user/comprehensive/algorithms/qubit_mapper.rst index 5af393a33..29785d740 100644 --- a/docs/source/user/comprehensive/algorithms/qubit_mapper.rst +++ b/docs/source/user/comprehensive/algorithms/qubit_mapper.rst @@ -132,6 +132,47 @@ You can discover available implementations programmatically: :start-after: # start-cell-list-implementations :end-before: # end-cell-list-implementations +.. _backend-dispatch-contract: + +Backend dispatch contract +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Implementations fall into two categories that use the +:class:`~qdk_chemistry.data.MajoranaMapping` argument differently: + +**Table-driven backends** (QDK native) + Read the Pauli-string table from the ``MajoranaMapping`` directly and + pass it to the C++ mapping engine. Any valid table works, including + custom encodings that have no standard name. + +**Name-dispatched backends** (OpenFermion, Qiskit) + **Ignore the Pauli table entirely.** They read + :attr:`~qdk_chemistry.data.MajoranaMapping.base_encoding` — a string + like ``"jordan-wigner"`` or ``"bravyi-kitaev-tree"`` — and use it to + look up the corresponding transform in their own library. The qubit + operator is then built from scratch using the third-party library's own + fermion-to-qubit pipeline. + +This distinction has practical consequences: + +- **Custom mappings** (user-defined Pauli tables) work with the QDK + backend but **cannot** be used with name-dispatched backends, which + have no way to interpret an arbitrary table. + +- **Consistency is assumed, not verified.** Factory-produced mappings + (e.g. ``MajoranaMapping.jordan_wigner()``) guarantee that the Pauli + table and the ``base_encoding`` name describe the same encoding. + Cross-backend eigenvalue tests in the test suite verify this for every + supported factory × backend combination. However, if a + ``MajoranaMapping`` is manually constructed with a table that does not + match its name, a name-dispatched backend will silently use the wrong + transform. + +- **Tapering** is handled in the base class + :meth:`~qdk_chemistry.algorithms.QubitMapper.run`, so all backends + automatically support tapered encodings (SCBK, parity two-qubit + reduction). Backends only ever see the untapered base encoding. + .. _qdk-qubit-mapper: QDK @@ -140,9 +181,11 @@ QDK .. rubric:: Factory name: ``"qdk"`` Native QDK/Chemistry qubit mapping implementation built on the :doc:`PauliOperator <../data/pauli_operator>` expression layer. -This implementation provides high-performance fermion-to-qubit transformations without external dependencies. +This is a **table-driven** backend: it reads the Pauli-string table from the :class:`~qdk_chemistry.data.MajoranaMapping` and passes it directly to the C++ mapping engine. +Any valid ``MajoranaMapping`` works — factory-produced or custom user-defined tables. +The mapping's ``name`` and ``base_encoding`` are used only for metadata on the output, not for dispatch. -Supported encodings: :ref:`Jordan-Wigner `, :ref:`Bravyi-Kitaev `, :ref:`Parity ` +Supported encodings: :ref:`Jordan-Wigner `, :ref:`Bravyi-Kitaev `, :ref:`Bravyi-Kitaev tree `, :ref:`Parity `, :ref:`SCBK `, and any custom encoding The native mapper uses blocked spin-orbital ordering internally (alpha orbitals first, then beta orbitals). Use ``QubitHamiltonian.to_interleaved()`` for alternative qubit orderings if needed. @@ -184,18 +227,15 @@ Qiskit .. rubric:: Factory name: ``"qiskit"`` Qubit mapping implementation integrated through the Qiskit plugin. +This is a **name-dispatched** backend: it reads ``mapping.base_encoding`` to select a Qiskit Nature mapper class and **ignores the Pauli table** (see :ref:`backend-dispatch-contract`). -Supported encodings: :ref:`Jordan-Wigner `, :ref:`Bravyi-Kitaev `, :ref:`Parity ` - -The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` passed to ``run()``. +Supported base encodings: :ref:`Jordan-Wigner `, :ref:`Bravyi-Kitaev `, :ref:`Parity ` Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. .. rubric:: Settings -This implementation has no configurable settings. The encoding strategy is -determined entirely by the :class:`~qdk_chemistry.data.MajoranaMapping` provided -to ``run()``. +This implementation has no configurable settings. .. _openfermion-qubit-mapper: @@ -205,10 +245,9 @@ OpenFermion .. rubric:: Factory name: ``"openfermion"`` Qubit mapping implementation integrated through the OpenFermion plugin. +This is a **name-dispatched** backend: it reads ``mapping.base_encoding`` to select an OpenFermion transform function and **ignores the Pauli table** (see :ref:`backend-dispatch-contract`). -Supported encodings: :ref:`Jordan-Wigner `, :ref:`Bravyi-Kitaev `, :ref:`Bravyi-Kitaev tree ` - -The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` passed to ``run()``. +Supported base encodings: :ref:`Jordan-Wigner `, :ref:`Bravyi-Kitaev `, :ref:`Bravyi-Kitaev tree ` Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. diff --git a/docs/source/user/comprehensive/data/majorana_mapping.rst b/docs/source/user/comprehensive/data/majorana_mapping.rst index 44e20fcc5..947add228 100644 --- a/docs/source/user/comprehensive/data/majorana_mapping.rst +++ b/docs/source/user/comprehensive/data/majorana_mapping.rst @@ -125,8 +125,8 @@ Serialization restored = MajoranaMapping.from_json(json_str) # HDF5 round-trip - mapping.to_hdf5("mapping.h5") - restored = MajoranaMapping.from_hdf5("mapping.h5") + mapping.to_hdf5_file("mapping.h5") + restored = MajoranaMapping.from_hdf5_file("mapping.h5") Related classes diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index 6b3402344..8326b5432 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -483,6 +483,32 @@ Construct a Bravyi-Kitaev encoding. MajoranaMapping with name "bravyi-kitaev". )"); + mapping.def_static( + "bravyi_kitaev_tree", + [](std::size_t num_modes) { + try { + return MajoranaMapping::bravyi_kitaev_tree(num_modes); + } catch (const std::invalid_argument& e) { + throw py::value_error(e.what()); + } + }, + py::arg("num_modes"), + R"( +Construct a balanced binary tree Bravyi-Kitaev encoding. + +Implementation from arXiv:1701.07072 (Havlíček et al.). Unlike the +Fenwick-tree BK (``bravyi_kitaev()``), the balanced tree variant has +guaranteed Z₂ symmetries at qubit positions (n/2-1) and (n-1) for any +even n, making it the required base encoding for symmetry-conserving +BK (SCBK) tapering. + +Args: + num_modes: Number of fermionic modes (spin-orbitals). + +Returns: + MajoranaMapping with name "bravyi-kitaev-tree". +)"); + mapping.def_static( "parity", [](std::size_t num_modes) { diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index 3d3e39c10..4787e0409 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -60,10 +60,13 @@ def __init__(self) -> None: class QdkQubitMapper(QubitMapper): """QDK native qubit mapper using Majorana-level C++ engine. - This mapper transforms a fermionic Hamiltonian to a qubit Hamiltonian. - The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` - passed to :meth:`run`. Any valid MajoranaMapping works -- built-in - (Jordan-Wigner, Bravyi-Kitaev, parity) or custom. + This is a **table-driven** backend: it reads the Pauli-string table + from the :class:`~qdk_chemistry.data.MajoranaMapping` and passes it + directly to the C++ ``majorana_map_hamiltonian`` engine. Any valid + ``MajoranaMapping`` works — built-in (Jordan-Wigner, Bravyi-Kitaev, + parity, BK-tree, SCBK) or custom user-defined tables. The mapping's + ``name`` and ``base_encoding`` are used only for metadata on the + output :class:`~qdk_chemistry.data.QubitHamiltonian`, not for dispatch. Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. For unrestricted systems, the engine handles all four spin-channel ERI @@ -109,11 +112,15 @@ def _run_impl( hamiltonian: Hamiltonian, mapping: MajoranaMapping, ) -> QubitHamiltonian: - """Transform a fermionic Hamiltonian to a qubit Hamiltonian. + """Transform a fermionic Hamiltonian to a qubit Hamiltonian (table-driven). + + This backend reads ``mapping.core`` (the C++ MajoranaMapping) and + uses the Pauli-string table directly. The ``base_encoding`` name + is used only for metadata on the output, not for dispatch. Args: hamiltonian: The fermionic Hamiltonian with one-body and two-body integrals. - mapping: The Majorana-to-Pauli encoding. + mapping: The Majorana-to-Pauli encoding (table is consumed directly by the C++ engine). Returns: QubitHamiltonian: The qubit Hamiltonian with Pauli strings and coefficients. @@ -164,25 +171,9 @@ def _run_impl( Logger.debug(f"Generated {len(pauli_strings)} Pauli terms for {2 * n_spatial} qubits") - qh = QubitHamiltonian( + return QubitHamiltonian( pauli_strings=list(pauli_strings), coefficients=np.array(coefficients, dtype=complex), encoding=mapping.base_encoding, fermion_mode_order=FermionModeOrder.BLOCKED, ) - - # Apply post-mapping tapering if specified (e.g. SCBK) - if mapping.tapering is not None: - from qdk_chemistry.utils.tapering import taper_qubits # noqa: PLC0415 - - qh = taper_qubits(qh, mapping.tapering.qubit_indices, mapping.tapering.eigenvalues) - qh = QubitHamiltonian( - pauli_strings=qh.pauli_strings, - coefficients=qh.coefficients, - encoding=mapping.name, - fermion_mode_order=qh.fermion_mode_order, - tapering=mapping.tapering, - ) - Logger.debug(f"Tapered {mapping.tapering.num_tapered} qubits → {qh.num_qubits} qubits") - - return qh diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 827aa847e..a66c367d3 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -12,6 +12,7 @@ from qdk_chemistry.algorithms.base import Algorithm, AlgorithmFactory from qdk_chemistry.data import Settings +from qdk_chemistry.utils import Logger if TYPE_CHECKING: # Only needed for type annotations; avoid importing into module namespace from qdk_chemistry.data import Hamiltonian, QubitHamiltonian @@ -35,7 +36,56 @@ def __init__(self) -> None: class QubitMapper(Algorithm): - """Abstract base class for mapping a Hamiltonian to a QubitHamiltonian.""" + """Abstract base class for mapping a Hamiltonian to a QubitHamiltonian. + + .. rubric:: Backend dispatch contract + + There are two fundamentally different kinds of ``QubitMapper`` backend, + and they use the :class:`~qdk_chemistry.data.MajoranaMapping` argument + in different ways: + + **Table-driven backends** (e.g. :class:`QdkQubitMapper`) + Read ``mapping.table`` — the actual 2N Pauli strings that define + the Majorana-to-qubit encoding — and use them directly in the + mapping engine. Any valid table works, including custom encodings + that have no name. + + **Name-dispatched backends** (e.g. ``OpenFermionQubitMapper``, + ``QiskitQubitMapper``) + **Ignore** ``mapping.table`` entirely. Instead they read + ``mapping.base_encoding`` (a string like ``"jordan-wigner"`` or + ``"bravyi-kitaev-tree"``) and use it to look up the corresponding + transform function in their own library. The backend then builds + a qubit operator from scratch using its own independent + fermion-to-qubit pipeline. + + This means: + + * The ``MajoranaMapping`` serves only as an **encoding selector** + for name-dispatched backends — its Pauli table is not consulted. + * Consistency between the table and the name is **not verified at + runtime**. If a ``MajoranaMapping`` is constructed with a table + that does not match its ``base_encoding`` name (e.g. a BK table + labelled ``"jordan-wigner"``), a name-dispatched backend will + silently use the wrong transform. + * Factory-produced mappings (``MajoranaMapping.jordan_wigner()``, + ``.bravyi_kitaev()``, etc.) are guaranteed to be consistent. + Cross-backend eigenvalue tests in the test suite verify this for + every supported factory x backend combination. + * Custom or manually constructed mappings with non-standard names + cannot be used with name-dispatched backends. + + .. rubric:: Tapering + + Subclasses implement :meth:`_run_impl` for the base (untapered) mapping. + Tapering is handled automatically by :meth:`run`: if the + :class:`~qdk_chemistry.data.MajoranaMapping` carries a + :attr:`~qdk_chemistry.data.MajoranaMapping.tapering` specification, + ``run()`` strips it before calling ``_run_impl()`` and applies it to + the result afterward. This means ``_run_impl()`` only ever sees a + base encoding and never needs to handle tapering itself. + + """ def __init__(self): """Initialize the QubitMapper.""" @@ -45,6 +95,51 @@ def type_name(self) -> str: """Return ``qubit_mapper`` as the algorithm type name.""" return "qubit_mapper" + def run( + self, + hamiltonian: Hamiltonian, + mapping: MajoranaMapping, + ) -> QubitHamiltonian: + """Map a fermionic Hamiltonian to a qubit Hamiltonian. + + If *mapping* carries tapering metadata, the tapering is stripped + before delegating to :meth:`_run_impl` and reapplied afterward. + This lets every backend (QDK native, OpenFermion, Qiskit, …) + support tapered encodings without duplicating tapering logic. + + Args: + hamiltonian: The fermionic Hamiltonian. + mapping: The Majorana-to-Pauli encoding (may include tapering). + + Returns: + QubitHamiltonian with encoding and tapering metadata set. + + """ + self._settings.lock() + + tapering = mapping.tapering + if tapering is not None: + base_mapping = mapping.without_tapering() + qh = self._run_impl(hamiltonian, base_mapping) + else: + qh = self._run_impl(hamiltonian, mapping) + + if tapering is not None: + from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian # noqa: PLC0415 + from qdk_chemistry.utils.tapering import taper_qubits # noqa: PLC0415 + + qh = taper_qubits(qh, tapering.qubit_indices, tapering.eigenvalues) + qh = QubitHamiltonian( + pauli_strings=qh.pauli_strings, + coefficients=qh.coefficients, + encoding=mapping.name, + fermion_mode_order=qh.fermion_mode_order, + tapering=tapering, + ) + Logger.debug(f"Tapered {tapering.num_tapered} qubits → {qh.num_qubits} qubits") + + return qh + @abstractmethod def _run_impl( self, @@ -53,9 +148,25 @@ def _run_impl( ) -> QubitHamiltonian: """Construct a QubitHamiltonian from a Hamiltonian using the given mapping. + Implementations receive a **base** (untapered) mapping only. + Tapering is handled by :meth:`run`. + + .. important:: + + **Table-driven** backends (e.g. :class:`QdkQubitMapper`) should + read ``mapping.table`` and ``mapping.core`` to perform the + transformation. + + **Name-dispatched** backends (e.g. ``OpenFermionQubitMapper``) + should read ``mapping.base_encoding`` to select a third-party + transform function. These backends do **not** use + ``mapping.table`` — they rebuild the qubit operator from scratch + using the third-party library's own pipeline. See the class + docstring for the full dispatch contract and its implications. + Args: hamiltonian: The fermionic Hamiltonian. - mapping: The Majorana-to-Pauli encoding to use. + mapping: The Majorana-to-Pauli encoding (always untapered). Returns: QubitHamiltonian: An instance of the QubitHamiltonian. diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index eaad66f28..2ee3c7212 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -185,15 +185,44 @@ def tapering(self) -> TaperingSpecification | None: """Post-mapping tapering specification, or None for untapered encodings.""" return self._tapering + def without_tapering(self) -> MajoranaMapping: + """Return a copy of this mapping with tapering stripped. + + The returned mapping has the same Majorana table and phases but + ``tapering=None`` and ``name`` set to :attr:`base_encoding`. + This is used by :class:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapper` + to separate the base encoding from post-mapping tapering. + + Returns: + A new :class:`MajoranaMapping` with no tapering. + + """ + return MajoranaMapping(table=[], _core=self._core) + @property def base_encoding(self) -> str: """The base encoding name used for the Majorana-to-Pauli table. - For standard encodings this equals :attr:`name`. For tapering-based + For standard encodings this equals :attr:`name`. For tapering-based encodings like symmetry-conserving Bravyi-Kitaev, this returns the - underlying encoding (e.g. ``"bravyi-kitaev"``) while :attr:`name` - returns the final encoding label + underlying encoding (e.g. ``"bravyi-kitaev-tree"``) while + :attr:`name` returns the final encoding label (e.g. ``"symmetry-conserving-bravyi-kitaev"``). + + .. important:: + + **Plugin dispatch key.** Name-dispatched + :class:`~qdk_chemistry.algorithms.QubitMapper` backends + (OpenFermion, Qiskit) use this property — not :attr:`name` — to + select the third-party transform function. Those backends + ignore :attr:`table` entirely and rebuild the qubit operator + from scratch. Consistency between this name and the table + contents is guaranteed for factory-produced mappings and + verified by cross-backend tests, but is **not** checked at + runtime. See + :class:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapper` + for the full dispatch contract. + """ return self._core.name @@ -324,6 +353,26 @@ def bravyi_kitaev(cls, num_modes: int) -> MajoranaMapping: core = _CoreMajoranaMapping.bravyi_kitaev(num_modes) return cls(table=[], _core=core) + @classmethod + def bravyi_kitaev_tree(cls, num_modes: int) -> MajoranaMapping: + """Construct a balanced binary tree Bravyi-Kitaev encoding. + + Implementation from arXiv:1701.07072 (Havlíček et al.). Unlike the + Fenwick-tree variant (:meth:`bravyi_kitaev`), the balanced tree has + guaranteed Z₂ symmetries at qubit positions ``(n/2-1)`` and ``(n-1)`` + for any even ``n``, making it the required base encoding for + :meth:`symmetry_conserving_bravyi_kitaev` tapering. + + Args: + num_modes (int): Number of fermionic modes (spin-orbitals). + + Returns: + MajoranaMapping: Mapping with name ``"bravyi-kitaev-tree"``. + + """ + core = _CoreMajoranaMapping.bravyi_kitaev_tree(num_modes) + return cls(table=[], _core=core) + @classmethod def parity( cls, @@ -360,21 +409,22 @@ def symmetry_conserving_bravyi_kitaev( ) -> MajoranaMapping: """Construct a symmetry-conserving Bravyi-Kitaev (SCBK) encoding. - Combines the standard Bravyi-Kitaev mapping with a + Combines the balanced binary tree BK mapping + (:meth:`bravyi_kitaev_tree`) with a :class:`~qdk_chemistry.data.TaperingSpecification` that removes the two Z₂ symmetry qubits (total electron-number parity and alpha-spin parity), reducing the qubit count by 2. When passed to :meth:`~qdk_chemistry.algorithms.QubitMapper.run`, the - mapper applies the BK mapping first, then tapers the symmetry qubits - automatically. + mapper applies the BK-tree mapping first, then tapers the symmetry + qubits automatically. Args: num_modes (int): Number of fermionic modes (spin-orbitals). Must be even and >= 4. symmetries (Symmetries): Electron counts for the target symmetry sector. Returns: - MajoranaMapping: BK mapping with SCBK tapering, name ``"symmetry-conserving-bravyi-kitaev"``. + MajoranaMapping: BK-tree mapping with SCBK tapering, name ``"symmetry-conserving-bravyi-kitaev"``. Raises: ValueError: If num_modes < 4 or odd, or electron counts are invalid. @@ -385,13 +435,13 @@ def symmetry_conserving_bravyi_kitaev( >>> mapping.name 'symmetry-conserving-bravyi-kitaev' >>> mapping.base_encoding - 'bravyi-kitaev' + 'bravyi-kitaev-tree' >>> mapping.tapering.num_tapered 2 """ tapering = TaperingSpecification.symmetry_conserving_bravyi_kitaev(num_modes, symmetries) - core = _CoreMajoranaMapping.bravyi_kitaev(num_modes) + core = _CoreMajoranaMapping.bravyi_kitaev_tree(num_modes) return cls( table=[], name="symmetry-conserving-bravyi-kitaev", @@ -493,9 +543,15 @@ def to_hdf5(self, group: h5py.Group) -> None: self._add_hdf5_version(group) group.attrs["name"] = self._name group.attrs["num_modes"] = self._num_modes - # Store table as array of strings - group.create_dataset("table", data=np.array(list(self._table), dtype="S")) + if any(p != 1 for p in self._phases): + group.create_dataset("phases", data=np.array(self._phases, dtype=np.int8)) + if self._tapering is not None: + tg = group.create_group("tapering") + tg.create_dataset("qubit_indices", data=np.array(self._tapering.qubit_indices, dtype=np.int64)) + tg.create_dataset("eigenvalues", data=np.array(self._tapering.eigenvalues, dtype=np.int8)) + tg.attrs["source_num_qubits"] = self._tapering.source_num_qubits + tg.attrs["source_encoding"] = self._tapering.source_encoding @classmethod def from_hdf5(cls, group: h5py.Group) -> MajoranaMapping: @@ -513,7 +569,22 @@ def from_hdf5(cls, group: h5py.Group) -> MajoranaMapping: name = group.attrs.get("name", "") if isinstance(name, bytes): name = name.decode("utf-8") - return cls(table=table, name=name) + phases = None + if "phases" in group: + phases = [int(p) for p in group["phases"][()]] + tapering = None + if "tapering" in group: + tg = group["tapering"] + src_enc = tg.attrs.get("source_encoding", "") + if isinstance(src_enc, bytes): + src_enc = src_enc.decode("utf-8") + tapering = TaperingSpecification( + qubit_indices=tuple(int(x) for x in tg["qubit_indices"][()]), + eigenvalues=tuple(int(x) for x in tg["eigenvalues"][()]), + source_num_qubits=int(tg.attrs["source_num_qubits"]), + source_encoding=src_enc, + ) + return cls(table=table, name=name, phases=phases, tapering=tapering) def __repr__(self) -> str: """Return a repr string.""" diff --git a/python/src/qdk_chemistry/data/qubit_hamiltonian.py b/python/src/qdk_chemistry/data/qubit_hamiltonian.py index 53cfe32af..ff5fa8178 100644 --- a/python/src/qdk_chemistry/data/qubit_hamiltonian.py +++ b/python/src/qdk_chemistry/data/qubit_hamiltonian.py @@ -240,11 +240,11 @@ def is_hermitian(self, tolerance: float = 1e-12) -> bool: def __add__(self, other: QubitHamiltonian) -> QubitHamiltonian: """Return the sum of two qubit Hamiltonians. - Pauli strings and coefficients are concatenated. The ``encoding`` - and ``fermion_mode_order`` metadata must match between operands - (or both be ``None``); a mismatch raises ``ValueError``. If both - operands carry a :attr:`term_partition` of the same concrete type, - the partitions are merged (with the right-hand operand's indices + Pauli strings and coefficients are concatenated. The ``encoding``, + ``fermion_mode_order``, and ``tapering`` metadata must match between + operands (or both be ``None``); a mismatch raises ``ValueError``. + If both operands carry a :attr:`term_partition` of the same concrete + type, the partitions are merged (with the right-hand operand's indices offset). Otherwise the result has no partition. Args: @@ -255,7 +255,7 @@ def __add__(self, other: QubitHamiltonian) -> QubitHamiltonian: Raises: TypeError: If *other* is not a ``QubitHamiltonian``. - ValueError: If the two Hamiltonians have different qubit counts, encodings, or fermion mode orders. + ValueError: If the two Hamiltonians have different qubit counts, encodings, fermion mode orders, or tapering. """ if not isinstance(other, QubitHamiltonian): @@ -271,6 +271,10 @@ def __add__(self, other: QubitHamiltonian) -> QubitHamiltonian: f"Cannot add Hamiltonians with different fermion_mode_order: " f"{self.fermion_mode_order!r} vs {other.fermion_mode_order!r}." ) + if self.tapering != other.tapering: + raise ValueError( + f"Cannot add Hamiltonians with different tapering: {self.tapering!r} vs {other.tapering!r}." + ) pauli_strings = list(self.pauli_strings) + list(other.pauli_strings) coefficients = np.concatenate([self.coefficients, other.coefficients]) @@ -285,6 +289,7 @@ def __add__(self, other: QubitHamiltonian) -> QubitHamiltonian: encoding=self.encoding, fermion_mode_order=self.fermion_mode_order, term_partition=partition, + tapering=self.tapering, ) def __mul__(self, scalar) -> QubitHamiltonian: @@ -307,6 +312,7 @@ def __mul__(self, scalar) -> QubitHamiltonian: encoding=self.encoding, fermion_mode_order=self.fermion_mode_order, term_partition=self.term_partition, + tapering=self.tapering, ) def __rmul__(self, scalar: float) -> QubitHamiltonian: @@ -397,6 +403,7 @@ def to_interleaved(self, n_spatial: int) -> QubitHamiltonian: coefficients=self.coefficients.copy(), encoding=self.encoding, fermion_mode_order=FermionModeOrder.INTERLEAVED, + tapering=self.tapering, ) # DataClass interface implementation diff --git a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py index abbc1e2c7..f58d0da88 100644 --- a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py @@ -52,20 +52,36 @@ def __init__(self): class OpenFermionQubitMapper(QubitMapper): """Map an electronic structure Hamiltonian to a QubitHamiltonian using OpenFermion. - The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` - passed to :meth:`run`. The plugin uses ``mapping.name`` to select the - corresponding OpenFermion transform. Custom (unnamed) mappings and - tapering-based encodings (symmetry-conserving Bravyi-Kitaev, parity - two-qubit reduction) are not supported — use the QDK mapper instead. + This is a **name-dispatched** backend: it reads + ``mapping.base_encoding`` to select the corresponding OpenFermion + transform function and **ignores** ``mapping.table`` entirely. The + qubit operator is built from scratch using OpenFermion's own + fermion-to-qubit pipeline. + + .. warning:: + + Because this backend dispatches on the encoding *name* rather than + the Pauli table, it relies on the assumption that the mapping's + ``base_encoding`` string is consistent with its table contents. + This invariant is guaranteed for factory-produced mappings + (``MajoranaMapping.jordan_wigner()``, ``.bravyi_kitaev()``, etc.) + and is verified by cross-backend eigenvalue tests in the test + suite. Manually constructed mappings with mismatched names will + produce silently incorrect results. + + Tapering-based encodings (e.g. symmetry-conserving Bravyi-Kitaev) are + supported — the base class + :meth:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapper.run` strips + tapering before calling ``_run_impl()`` and reapplies it afterward. Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. For unrestricted systems, separate alpha/beta spin channels are handled via ``hamiltonian_to_interaction_operator``. - Supported ``mapping.name`` values: - - ``"jordan-wigner"`` - - ``"bravyi-kitaev"`` - - ``"bravyi-kitaev-tree"`` + Supported base encodings: + - ``"jordan-wigner"`` → :func:`openfermion.transforms.jordan_wigner` + - ``"bravyi-kitaev"`` → :func:`openfermion.transforms.bravyi_kitaev` + - ``"bravyi-kitaev-tree"`` → :func:`openfermion.transforms.bravyi_kitaev_tree` Examples: >>> from qdk_chemistry.algorithms import create @@ -87,31 +103,37 @@ def _run_impl( hamiltonian: Hamiltonian, mapping: MajoranaMapping, ) -> QubitHamiltonian: - """Construct a QubitHamiltonian from a Hamiltonian using the selected mapping strategy. + """Build a qubit Hamiltonian via OpenFermion (name-dispatched). - Supports both restricted and unrestricted (UHF) Hamiltonians. + Reads ``mapping.base_encoding`` to select an OpenFermion transform + function. ``mapping.table`` is **not used** — the qubit operator + is rebuilt entirely by OpenFermion's own pipeline. Args: hamiltonian: The fermionic Hamiltonian (restricted or unrestricted). - mapping: The Majorana-to-Pauli encoding. Only built-in encodings are supported. + mapping: Encoding selector — only ``base_encoding`` is read. Returns: QubitHamiltonian: An instance of the QubitHamiltonian. Raises: - NotImplementedError: If ``mapping.name`` is not a supported OpenFermion encoding. + NotImplementedError: If ``mapping.base_encoding`` is not a supported OpenFermion encoding. """ Logger.trace_entering() - encoding_name = mapping.name + + # --- Name dispatch (see QubitMapper class docstring) --- + # This backend does NOT use mapping.table. It reads the encoding + # name and selects the corresponding OpenFermion transform function, + # which rebuilds the qubit operator from scratch. Consistency + # between the name and the table is only guaranteed for + # factory-produced MajoranaMapping objects. + encoding_name = mapping.base_encoding if encoding_name not in _STANDARD_TRANSFORMS: raise NotImplementedError( - f"OpenFermion plugin does not support MajoranaMapping with name {encoding_name!r}. " - f"Supported names: {sorted(_STANDARD_TRANSFORMS.keys())}. " - f"For tapering-based encodings, use the QDK mapper with " - f"MajoranaMapping.symmetry_conserving_bravyi_kitaev() or " - f"MajoranaMapping.parity(n, symmetries)." + f"OpenFermion plugin does not support base encoding {encoding_name!r}. " + f"Supported encodings: {sorted(_STANDARD_TRANSFORMS.keys())}." ) qubit_op = self._map_standard(hamiltonian, encoding_name) diff --git a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py index 1faf7a23e..6198e5180 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py @@ -48,19 +48,36 @@ def __init__(self): class QiskitQubitMapper(QubitMapper): """Map an electronic structure Hamiltonian to a QubitHamiltonian using Qiskit. - The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` - passed to :meth:`run`. The plugin uses ``mapping.name`` to select the - corresponding Qiskit Nature mapper. Custom (unnamed) mappings are not - supported -- use the QDK variant instead. + This is a **name-dispatched** backend: it reads + ``mapping.base_encoding`` to select the corresponding Qiskit Nature + mapper class and **ignores** ``mapping.table`` entirely. The qubit + operator is built from scratch using Qiskit Nature's own + fermion-to-qubit pipeline. + + .. warning:: + + Because this backend dispatches on the encoding *name* rather than + the Pauli table, it relies on the assumption that the mapping's + ``base_encoding`` string is consistent with its table contents. + This invariant is guaranteed for factory-produced mappings + (``MajoranaMapping.jordan_wigner()``, ``.bravyi_kitaev()``, etc.) + and is verified by cross-backend eigenvalue tests in the test + suite. Manually constructed mappings with mismatched names will + produce silently incorrect results. + + Tapering-based encodings (e.g. parity two-qubit reduction) are + supported — the base class + :meth:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapper.run` strips + tapering before calling ``_run_impl()`` and reapplies it afterward. Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. For unrestricted systems, separate alpha and beta one-body and two-body integrals are forwarded to Qiskit Nature's ``ElectronicEnergy``. - Supported ``mapping.name`` values: - - ``"jordan-wigner"`` - - ``"bravyi-kitaev"`` - - ``"parity"`` + Supported base encodings: + - ``"jordan-wigner"`` → :class:`qiskit_nature.second_q.mappers.JordanWignerMapper` + - ``"bravyi-kitaev"`` → :class:`qiskit_nature.second_q.mappers.BravyiKitaevMapper` + - ``"parity"`` → :class:`qiskit_nature.second_q.mappers.ParityMapper` Examples: >>> from qdk_chemistry.algorithms import create @@ -84,31 +101,37 @@ def _run_impl( hamiltonian: Hamiltonian, mapping: MajoranaMapping, ) -> QubitHamiltonian: - """Construct a QubitHamiltonian from a Hamiltonian using the selected mapping strategy. + """Build a qubit Hamiltonian via Qiskit Nature (name-dispatched). - Supports both restricted and unrestricted (UHF) Hamiltonians. For - unrestricted systems, separate alpha/beta integrals are passed to - Qiskit Nature's ``ElectronicEnergy.from_raw_integrals``. + Reads ``mapping.base_encoding`` to select a Qiskit Nature mapper + class. ``mapping.table`` is **not used** — the qubit operator + is rebuilt entirely by Qiskit's own pipeline. Args: hamiltonian: The fermionic Hamiltonian (restricted or unrestricted). - mapping: The Majorana-to-Pauli encoding. Only built-in encodings are supported. + mapping: Encoding selector — only ``base_encoding`` is read. Returns: QubitHamiltonian: An instance of the QubitHamiltonian. Raises: - NotImplementedError: If ``mapping.name`` is not a supported Qiskit encoding. + NotImplementedError: If ``mapping.base_encoding`` is not a supported Qiskit encoding. """ Logger.trace_entering() - encoding_name = mapping.name + + # --- Name dispatch (see QubitMapper class docstring) --- + # This backend does NOT use mapping.table. It reads the encoding + # name and selects the corresponding Qiskit Nature mapper class, + # which rebuilds the qubit operator from scratch. Consistency + # between the name and the table is only guaranteed for + # factory-produced MajoranaMapping objects. + encoding_name = mapping.base_encoding if encoding_name not in _SUPPORTED_ENCODINGS: raise NotImplementedError( - f"Qiskit plugin does not support MajoranaMapping with name {encoding_name!r}. " - f"Supported names: {sorted(_SUPPORTED_ENCODINGS.keys())}. " - f"Use the QDK variant for custom mappings." + f"Qiskit plugin does not support base encoding {encoding_name!r}. " + f"Supported encodings: {sorted(_SUPPORTED_ENCODINGS.keys())}." ) h1_a, h1_b = hamiltonian.get_one_body_integrals() diff --git a/python/src/qdk_chemistry/utils/tapering.py b/python/src/qdk_chemistry/utils/tapering.py index bbb14c485..18f901654 100644 --- a/python/src/qdk_chemistry/utils/tapering.py +++ b/python/src/qdk_chemistry/utils/tapering.py @@ -156,28 +156,29 @@ def taper_to_scbk( reducing the qubit count by 2. The eigenvalues are determined by the electron counts following the convention of arXiv:1701.08213. - The input must be a BK-encoded QubitHamiltonian with an even number of - qubits (= 2 * n_spatial). + The input must be a BK-encoded (Fenwick or BK-tree) QubitHamiltonian with + an even number of qubits (= 2 * n_spatial). Args: - qubit_hamiltonian (QubitHamiltonian): A Bravyi-Kitaev encoded qubit Hamiltonian. + qubit_hamiltonian (QubitHamiltonian): A Bravyi-Kitaev or BK-tree encoded qubit Hamiltonian. symmetries (Symmetries): Symmetry information providing ``n_alpha`` and ``n_beta`` electron counts. Returns: QubitHamiltonian: Tapered Hamiltonian with 2 fewer qubits and encoding ``"symmetry-conserving-bravyi-kitaev"``. Raises: - ValueError: If encoding is not ``"bravyi-kitaev"``, or qubit count is odd or < 4. + ValueError: If encoding is not ``"bravyi-kitaev"`` or ``"bravyi-kitaev-tree"``, or qubit count is odd or < 4. """ from qdk_chemistry.data import QubitHamiltonian # noqa: PLC0415 from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder # noqa: PLC0415 - if qubit_hamiltonian.encoding != "bravyi-kitaev": + _BK_ENCODINGS = {"bravyi-kitaev", "bravyi-kitaev-tree"} + if qubit_hamiltonian.encoding not in _BK_ENCODINGS: raise ValueError( f"taper_to_scbk requires a Bravyi-Kitaev encoded QubitHamiltonian " - f"(encoding='bravyi-kitaev'), got encoding={qubit_hamiltonian.encoding!r}. " - f"Use MajoranaMapping.bravyi_kitaev() to produce a BK-encoded Hamiltonian first." + f"(encoding in {_BK_ENCODINGS}), got encoding={qubit_hamiltonian.encoding!r}. " + f"Use MajoranaMapping.bravyi_kitaev_tree() to produce a BK-encoded Hamiltonian first." ) if ( diff --git a/python/tests/test_interop_openfermion_qubit_mapper.py b/python/tests/test_interop_openfermion_qubit_mapper.py index 715ae13ce..a001fb9ae 100644 --- a/python/tests/test_interop_openfermion_qubit_mapper.py +++ b/python/tests/test_interop_openfermion_qubit_mapper.py @@ -199,14 +199,14 @@ def test_scbk_one_step_sets_encoding(): def test_scbk_name_dispatch_removed(): - """Passing a symmetry-conserving-BK-named mapping to the OF plugin raises NotImplementedError.""" + """Passing a mapping with an unsupported base encoding to the OF plugin raises NotImplementedError.""" hamiltonian = create_nontrivial_test_hamiltonian() n_spin = _num_spin_orbitals(hamiltonian) jw = MajoranaMapping.jordan_wigner(n_spin) mapping = MajoranaMapping(table=list(jw.table), name="symmetry-conserving-bravyi-kitaev") mapper = create("qubit_mapper", "openfermion") - with pytest.raises(NotImplementedError, match="tapering-based"): + with pytest.raises(NotImplementedError, match="does not support base encoding"): mapper.run(hamiltonian, mapping) @@ -458,3 +458,31 @@ def sym_eri(n, rng): all_keys = set(d_qdk) | set(d_of) max_diff = max(abs(d_qdk.get(k, 0) - d_of.get(k, 0)) for k in all_keys) assert max_diff < 1e-10, f"UHF JW max coefficient diff: {max_diff}" + + +@pytest.mark.skipif(not OPENFERMION_AVAILABLE, reason="OpenFermion not installed") +def test_scbk_cross_backend(): + """SCBK through QDK and OpenFermion backends should produce matching eigenvalues.""" + hamiltonian = create_nontrivial_test_hamiltonian() + n_spin = _num_spin_orbitals(hamiltonian) + symmetries = Symmetries(n_alpha=1, n_beta=1) + mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev(n_spin, symmetries) + + qh_qdk = create("qubit_mapper", "qdk").run(hamiltonian, mapping) + qh_of = create("qubit_mapper", "openfermion").run(hamiltonian, mapping) + + assert qh_qdk.encoding == "symmetry-conserving-bravyi-kitaev" + assert qh_of.encoding == "symmetry-conserving-bravyi-kitaev" + assert qh_qdk.tapering is not None + assert qh_of.tapering is not None + assert qh_qdk.num_qubits == n_spin - 2 + assert qh_of.num_qubits == n_spin - 2 + + # Compare eigenvalues + from qdk_chemistry.utils.pauli_matrix import pauli_to_sparse_matrix # noqa: PLC0415 + + mat_qdk = pauli_to_sparse_matrix(list(qh_qdk.pauli_strings), qh_qdk.coefficients).toarray() + mat_of = pauli_to_sparse_matrix(list(qh_of.pauli_strings), qh_of.coefficients).toarray() + eigs_qdk = sorted(np.linalg.eigvalsh(mat_qdk)) + eigs_of = sorted(np.linalg.eigvalsh(mat_of)) + np.testing.assert_allclose(eigs_qdk, eigs_of, atol=1e-10) diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index c9c0f8ebe..11d8918a3 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -135,9 +135,9 @@ def test_reference_n2(self) -> None: assert par.table == ("XX", "XY", "XZ", "YI") def test_reference_n4(self) -> None: - """Parity n=4 matches CNOT-derived reference.""" + """Parity n=4 matches standard-convention reference.""" par = MajoranaMapping.parity(num_modes=4) - expected = ("IIXX", "IIXY", "IXXZ", "IXYI", "XXZI", "XYIZ", "XZIZ", "YIZI") + expected = ("XXXX", "XXXY", "XXXZ", "XXYI", "XXZI", "XYII", "XZII", "YIII") assert par.table == expected @@ -300,6 +300,25 @@ def test_custom_serialization(self) -> None: assert loaded.table == custom.table assert loaded.name == "my-custom" + def test_hdf5_round_trip_with_tapering(self) -> None: + """HDF5 round-trip preserves phases and tapering for SCBK mappings.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + with tempfile.NamedTemporaryFile(suffix=".h5") as f: + with h5py.File(f.name, "w") as hf: + scbk.to_hdf5(hf) + with h5py.File(f.name, "r") as hf: + loaded = MajoranaMapping.from_hdf5(hf) + assert loaded.table == scbk.table + assert loaded.name == scbk.name + assert loaded.phases == scbk.phases + assert loaded.tapering is not None + assert loaded.tapering.qubit_indices == scbk.tapering.qubit_indices + assert loaded.tapering.eigenvalues == scbk.tapering.eigenvalues + assert loaded.tapering.source_num_qubits == scbk.tapering.source_num_qubits + assert loaded.tapering.source_encoding == scbk.tapering.source_encoding + # ─── Summary/Repr Tests ───────────────────────────────────────────────── @@ -377,7 +396,7 @@ def test_scbk_name_and_base_encoding(self) -> None: scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) assert scbk.name == "symmetry-conserving-bravyi-kitaev" - assert scbk.base_encoding == "bravyi-kitaev" + assert scbk.base_encoding == "bravyi-kitaev-tree" def test_scbk_has_tapering(self) -> None: """SCBK mapping has a TaperingSpecification with 2 tapered qubits.""" diff --git a/python/tests/test_qdk_qubit_mapper.py b/python/tests/test_qdk_qubit_mapper.py index b8023becb..33c95566c 100644 --- a/python/tests/test_qdk_qubit_mapper.py +++ b/python/tests/test_qdk_qubit_mapper.py @@ -536,6 +536,32 @@ def _to_matrix(qh: QubitHamiltonian) -> np.ndarray: eigs_parity = np.sort(np.linalg.eigvalsh(_to_matrix(result_parity))) np.testing.assert_allclose(eigs_parity, eigs_jw, atol=1e-10) + def test_parity_number_operator_structure(self) -> None: + """Parity encoding gives n_j = (I - Z_{j-1}·Z_j)/2, matching standard convention.""" + from qdk_chemistry.utils.pauli_matrix import pauli_to_sparse_matrix # noqa: PLC0415 + + n = 6 + mapping = MajoranaMapping.parity(n) + I_n = np.eye(2**n) + + for j in range(n): + g0 = pauli_to_sparse_matrix([mapping.table[2 * j]], np.array([mapping.phases[2 * j]])).toarray() + g1 = pauli_to_sparse_matrix([mapping.table[2 * j + 1]], np.array([mapping.phases[2 * j + 1]])).toarray() + nj = (I_n + 1j * g0 @ g1) / 2 + + # Build expected Z structure: Z_0 for j=0, Z_{j-1}·Z_j for j≥1 + z_op = np.eye(2**n, dtype=complex) + for idx in range(2**n): + if j == 0: + if (idx >> 0) & 1: + z_op[idx, idx] = -1.0 + else: + parity = ((idx >> (j - 1)) & 1) ^ ((idx >> j) & 1) + if parity: + z_op[idx, idx] = -1.0 + expected_nj = (I_n - z_op) / 2 + np.testing.assert_allclose(nj, expected_nj, atol=1e-12, err_msg=f"n_{j} structure mismatch") + class TestUnrestrictedHamiltonians: """Tests for unrestricted (UHF) Hamiltonian mapping.""" @@ -687,6 +713,68 @@ def test_unrestricted_3_orbitals(self) -> None: for c in qh.coefficients: assert abs(c.imag) < 1e-12 + def test_unrestricted_asymmetric_aabb(self) -> None: + """UHF with genuinely asymmetric AABB integrals (eri_aabb[p,q,r,s] ≠ eri_aabb[r,s,p,q]).""" + n = 2 + rng = np.random.default_rng(123) + coeffs_alpha = np.eye(n) + rng.standard_normal((n, n)) * 0.1 + coeffs_beta = np.eye(n) + rng.standard_normal((n, n)) * 0.1 + basis_set = create_test_basis_set(n, "test-asym-aabb") + orbitals = Orbitals(coeffs_alpha, coeffs_beta, None, None, None, basis_set) + + h1_alpha = np.array([[1.0, 0.2], [0.2, 0.8]]) + h1_beta = np.array([[0.9, 0.1], [0.1, 1.1]]) + + # AAAA and BBBB with 8-fold symmetry + def sym_eri(n, rng): + h2 = np.zeros((n, n, n, n)) + for p in range(n): + for q in range(n): + for r in range(n): + for s in range(n): + if h2[p, q, r, s] == 0: + v = rng.standard_normal() * 0.2 + for a, b, c, d in {(p, q, r, s), (q, p, r, s), (p, q, s, r), (q, p, s, r), + (r, s, p, q), (s, r, p, q), (r, s, q, p), (s, r, q, p)}: + h2[a, b, c, d] = v + return h2.ravel() + + h2_aaaa = sym_eri(n, rng) + h2_bbbb = sym_eri(n, rng) + + # AABB with only 4-fold symmetry: (pq|rs) = (qp|rs) = (pq|sr) = (qp|sr) + # but NOT (pq|rs) = (rs|pq) since alpha ≠ beta + h2_aabb_4d = np.zeros((n, n, n, n)) + for p in range(n): + for q in range(n): + for r in range(n): + for s in range(n): + if h2_aabb_4d[p, q, r, s] == 0: + v = rng.standard_normal() * 0.3 + for a, b, c, d in {(p, q, r, s), (q, p, r, s), (p, q, s, r), (q, p, s, r)}: + h2_aabb_4d[a, b, c, d] = v + h2_aabb = h2_aabb_4d.ravel() + + fock_a, fock_b = np.eye(0), np.eye(0) + h = Hamiltonian(CanonicalFourCenterHamiltonianContainer( + h1_alpha, h1_beta, h2_aaaa, h2_aabb, h2_bbbb, orbitals, 0.0, fock_a, fock_b, + )) + + n_modes = 2 * n + mapper = create("qubit_mapper", "qdk") + + # All encodings should produce the same eigenvalues + eigs = {} + for enc in ["jordan_wigner", "bravyi_kitaev", "parity"]: + mapping = getattr(MajoranaMapping, enc)(num_modes=n_modes) + qh = mapper.run(h, mapping) + eigs[enc] = np.sort(np.real(np.linalg.eigvalsh(qh.to_matrix()))) + for c in qh.coefficients: + assert abs(c.imag) < 1e-12, f"Non-real coeff in {enc}: {c}" + + np.testing.assert_allclose(eigs["jordan_wigner"], eigs["bravyi_kitaev"], atol=1e-10) + np.testing.assert_allclose(eigs["jordan_wigner"], eigs["parity"], atol=1e-10) + class TestQdkQubitMapperRealHamiltonians: """Tests with real molecular Hamiltonians.""" @@ -1027,7 +1115,7 @@ def test_scbk_carries_tapering_metadata(self) -> None: assert qh.tapering.num_tapered == 2 def test_scbk_matches_two_step(self) -> None: - """One-step symmetry-conserving BK matches explicit BK + internal taper.""" + """One-step symmetry-conserving BK matches explicit BK-tree + internal taper.""" from qdk_chemistry.data import Symmetries # noqa: PLC0415 from qdk_chemistry.utils.tapering import taper_to_scbk # noqa: PLC0415 @@ -1039,13 +1127,57 @@ def test_scbk_matches_two_step(self) -> None: scbk_mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev(n, sym) qh_one_step = create("qubit_mapper", "qdk").run(hamiltonian, scbk_mapping) - # Two-step - bk_mapping = MajoranaMapping.bravyi_kitaev(n) - qh_bk = create("qubit_mapper", "qdk").run(hamiltonian, bk_mapping) + # Two-step: use BK-tree (the base encoding for SCBK) + bk_tree_mapping = MajoranaMapping.bravyi_kitaev_tree(n) + qh_bk = create("qubit_mapper", "qdk").run(hamiltonian, bk_tree_mapping) qh_two_step = taper_to_scbk(qh_bk, sym) assert qh_one_step.equiv(qh_two_step) + def test_scbk_non_power_of_two(self) -> None: + """SCBK eigenvalues match exact sector eigenvalues for non-power-of-2 mode counts.""" + from itertools import combinations # noqa: PLC0415 + + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + hamiltonian = create_nontrivial_test_hamiltonian(3) + n_spatial = hamiltonian.get_one_body_integrals()[0].shape[0] + n = 2 * n_spatial + n_alpha, n_beta = 2, 1 + sym = Symmetries(n_alpha, n_beta) + + mapper = create("qubit_mapper", "qdk") + + # SCBK Hamiltonian + scbk_mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev(n, sym) + qh_scbk = mapper.run(hamiltonian, scbk_mapping) + eigs_scbk = np.sort(np.real(np.linalg.eigvalsh(qh_scbk.to_matrix()))) + + # Exact: project JW Hamiltonian onto all matching-parity sectors + jw_mapping = MajoranaMapping.jordan_wigner(n) + H_jw = mapper.run(hamiltonian, jw_mapping).to_matrix() + all_eigs: list[float] = [] + for na in range(n_spatial + 1): + for nb in range(n_spatial + 1): + if (-1) ** (na + nb) != (-1) ** (n_alpha + n_beta): + continue + if (-1) ** na != (-1) ** n_alpha: + continue + states = [] + for ac in combinations(range(n_spatial), na): + for bc in combinations(range(n_spatial), nb): + s = sum(1 << o for o in ac) | sum(1 << (o + n_spatial) for o in bc) + states.append(s) + if states: + proj = np.zeros((2**n, len(states))) + for i, s in enumerate(states): + proj[s, i] = 1.0 + all_eigs.extend(np.linalg.eigvalsh(proj.T @ H_jw @ proj)) + eigs_exact = np.sort(all_eigs) + + assert len(eigs_scbk) == len(eigs_exact) + np.testing.assert_allclose(eigs_scbk, eigs_exact, atol=1e-10) + def test_standard_mappings_no_tapering(self) -> None: """Standard mappings produce QubitHamiltonian with tapering=None.""" hamiltonian = create_nontrivial_test_hamiltonian() @@ -1055,3 +1187,63 @@ def test_standard_mappings_no_tapering(self) -> None: mapping = factory(n) qh = create("qubit_mapper", "qdk").run(hamiltonian, mapping) assert qh.tapering is None + + +# ─── BK-tree factory ───────────────────────────────────────────────────── + + +class TestBravyiKitaevTreeMapper: + """Tests for the BK-tree (balanced binary tree) Bravyi-Kitaev variant.""" + + def test_bk_tree_instantiation(self) -> None: + """BK-tree mapping can be created for various mode counts.""" + for n in (4, 6, 8, 10, 12): + mapping = MajoranaMapping.bravyi_kitaev_tree(n) + assert mapping.name == "bravyi-kitaev-tree" + assert mapping.num_qubits == n + + def test_bk_tree_clifford_algebra(self) -> None: + """BK-tree Majorana operators satisfy {γ_i, γ_j} = 2δ_{ij}.""" + from qdk_chemistry.utils.pauli_matrix import pauli_to_sparse_matrix # noqa: PLC0415 + + for n in (4, 6, 8): + mapping = MajoranaMapping.bravyi_kitaev_tree(n) + gammas = [ + pauli_to_sparse_matrix([mapping.table[k]], np.array([mapping.phases[k]])).toarray() + for k in range(2 * n) + ] + for i in range(2 * n): + for j in range(i, 2 * n): + anticomm = gammas[i] @ gammas[j] + gammas[j] @ gammas[i] + expected = 2.0 * np.eye(2**n) if i == j else np.zeros((2**n, 2**n)) + np.testing.assert_allclose(anticomm, expected, atol=1e-12, err_msg=f"n={n}, i={i}, j={j}") + + def test_bk_tree_eigenvalues_match_jw(self) -> None: + """BK-tree encoded Hamiltonian has same eigenvalues as JW.""" + hamiltonian = create_nontrivial_test_hamiltonian(3) + n = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] + mapper = create("qubit_mapper", "qdk") + + qh_jw = mapper.run(hamiltonian, MajoranaMapping.jordan_wigner(n)) + qh_bkt = mapper.run(hamiltonian, MajoranaMapping.bravyi_kitaev_tree(n)) + + eigs_jw = np.sort(np.real(np.linalg.eigvalsh(qh_jw.to_matrix()))) + eigs_bkt = np.sort(np.real(np.linalg.eigvalsh(qh_bkt.to_matrix()))) + np.testing.assert_allclose(eigs_bkt, eigs_jw, atol=1e-10) + + def test_bk_tree_z2_symmetries(self) -> None: + """BK-tree Z_{n-1} and Z_{n/2-1} commute with the Hamiltonian (Z₂ symmetries).""" + hamiltonian = create_nontrivial_test_hamiltonian(3) + n = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] + mapper = create("qubit_mapper", "qdk") + + H = mapper.run(hamiltonian, MajoranaMapping.bravyi_kitaev_tree(n)).to_matrix() + + # Build Z_{n-1} and Z_{n/2-1} operators + for q in (n // 2 - 1, n - 1): + Z_q = np.eye(2**n, dtype=complex) + for i in range(2**n): + if (i >> q) & 1: + Z_q[i, i] = -1.0 + commutator = H @ Z_q - Z_q @ H + np.testing.assert_allclose(commutator, 0.0, atol=1e-12, err_msg=f"[H, Z_{q}] ≠ 0") diff --git a/python/tests/test_qubit_hamiltonian.py b/python/tests/test_qubit_hamiltonian.py index 223536591..31777890a 100644 --- a/python/tests/test_qubit_hamiltonian.py +++ b/python/tests/test_qubit_hamiltonian.py @@ -665,3 +665,77 @@ def test_rmul_equals_mul(self): """H * scalar and scalar * H should give the same result.""" h = QubitHamiltonian(["XI"], np.array([3.0])) np.testing.assert_allclose((h * 2.0).coefficients, (2.0 * h).coefficients) + + +class TestTaperingPropagation: + """Verify tapering metadata survives arithmetic and reordering.""" + + @pytest.fixture() + def tapering(self): + """Create a sample tapering specification.""" + from qdk_chemistry.data.tapering import TaperingSpecification + + return TaperingSpecification( + qubit_indices=(3, 1), + eigenvalues=(1, -1), + source_num_qubits=4, + source_encoding="bravyi-kitaev-tree", + ) + + @pytest.fixture() + def tapered_h(self, tapering): + """Create a QubitHamiltonian with tapering metadata.""" + return QubitHamiltonian( + ["XI", "IZ"], + np.array([1.0, 0.5]), + encoding="symmetry-conserving-bravyi-kitaev", + tapering=tapering, + ) + + def test_add_preserves_tapering(self, tapered_h, tapering): + """H1 + H2 with matching tapering should preserve it.""" + h2 = QubitHamiltonian( + ["XX"], + np.array([0.3]), + encoding="symmetry-conserving-bravyi-kitaev", + tapering=tapering, + ) + result = tapered_h + h2 + assert result.tapering == tapering + + def test_add_mismatched_tapering_raises(self, tapered_h): + """H1 + H2 with different tapering should raise ValueError.""" + from qdk_chemistry.data.tapering import TaperingSpecification + + other_tapering = TaperingSpecification( + qubit_indices=(3, 1), + eigenvalues=(1, 1), + source_num_qubits=4, + source_encoding="bravyi-kitaev-tree", + ) + h2 = QubitHamiltonian( + ["XX"], + np.array([0.3]), + encoding="symmetry-conserving-bravyi-kitaev", + tapering=other_tapering, + ) + with pytest.raises(ValueError, match="tapering"): + tapered_h + h2 + + def test_mul_preserves_tapering(self, tapered_h, tapering): + """Scalar * H should preserve tapering metadata.""" + result = 2.0 * tapered_h + assert result.tapering == tapering + result2 = tapered_h * 3.0 + assert result2.tapering == tapering + + def test_to_interleaved_preserves_tapering(self, tapering): + """to_interleaved should preserve tapering metadata.""" + h = QubitHamiltonian( + ["XIZI", "IZIX"], + np.array([1.0, 0.5]), + encoding="symmetry-conserving-bravyi-kitaev", + tapering=tapering, + ) + result = h.to_interleaved(n_spatial=2) + assert result.tapering == tapering From 7ce18f39fc55aaa845a09e10c52eb3baefb8ae33 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Tue, 26 May 2026 22:34:29 +0000 Subject: [PATCH 082/117] fix: tapering utilities, Qiskit UHF h2_ba, and repr polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M2: Use 1e-12 threshold (not eps) for near-zero filtering in taper_qubits - M6: Return zero-coefficient identity on full cancellation instead of ValueError - M1: taper_to_scbk sets TaperingSpecification on output QubitHamiltonian - M5: Transpose h2_ba for Qiskit UHF — (aa|bb) → (bb|aa) per Qiskit convention - L2: MajoranaMapping repr shows tapered=N when tapering is present - Add tests for zero-operator return and taper_to_scbk tapering metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../qdk_chemistry/data/majorana_mapping.py | 3 +- .../plugins/qiskit/qubit_mapper.py | 6 +- python/src/qdk_chemistry/utils/tapering.py | 38 ++++++++--- python/tests/test_tapering.py | 66 +++++++++++++++++-- 4 files changed, 97 insertions(+), 16 deletions(-) diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index 2ee3c7212..0f1594785 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -589,4 +589,5 @@ def from_hdf5(cls, group: h5py.Group) -> MajoranaMapping: def __repr__(self) -> str: """Return a repr string.""" label = f"'{self._name}', " if self._name else "" - return f"MajoranaMapping({label}num_modes={self._num_modes}, num_qubits={self._num_qubits})" + tapered = f", tapered={self._tapering.num_tapered}" if self._tapering is not None else "" + return f"MajoranaMapping({label}num_modes={self._num_modes}, num_qubits={self._num_qubits}{tapered})" diff --git a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py index 6198e5180..a1d744725 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py @@ -144,12 +144,16 @@ def _run_impl( h1_a=h1_a, h2_aa=h2_aa.reshape(num_orbs, num_orbs, num_orbs, num_orbs) ) else: + # h2_ab is eri_aabb in chemist notation: (aa|bb). + # Qiskit's h2_ba parameter expects (bb|aa) = eri_aabb transposed. + # By Coulomb symmetry (pq|rs)=(rs|pq), this is eri_aabb[r,s,p,q]. + h2_ab_4d = h2_ab.reshape(num_orbs, num_orbs, num_orbs, num_orbs) electronic_hamiltonian = ElectronicEnergy.from_raw_integrals( h1_a=h1_a, h2_aa=h2_aa.reshape(num_orbs, num_orbs, num_orbs, num_orbs), h1_b=h1_b, h2_bb=h2_bb.reshape(num_orbs, num_orbs, num_orbs, num_orbs), - h2_ba=h2_ab.reshape(num_orbs, num_orbs, num_orbs, num_orbs), + h2_ba=h2_ab_4d.transpose(2, 3, 0, 1), ) fermionic_op = electronic_hamiltonian.second_q_op() diff --git a/python/src/qdk_chemistry/utils/tapering.py b/python/src/qdk_chemistry/utils/tapering.py index 18f901654..9115dcd8d 100644 --- a/python/src/qdk_chemistry/utils/tapering.py +++ b/python/src/qdk_chemistry/utils/tapering.py @@ -61,7 +61,7 @@ def taper_qubits( eigenvalues (Sequence[int]): Corresponding Z eigenvalues (+1 or -1) for each qubit. Returns: - QubitHamiltonian: A new QubitHamiltonian with the specified qubits removed. + QubitHamiltonian: Tapered Hamiltonian with fewer qubits. Zero-coefficient identity if all terms cancel. Raises: ValueError: If lengths don't match, indices are out of range, contain duplicates, or eigenvalues are not ±1. @@ -118,24 +118,36 @@ def taper_qubits( new_strings.append(new_str) new_coeffs.append(adjusted_coeff) + new_nq = nq - len(qubit_indices) + if not new_strings: - raise ValueError("All Pauli terms were eliminated by tapering") + # All terms had X/Y on tapered qubits — return zero operator + identity_str = "I" * new_nq + return QubitHamiltonian( + pauli_strings=[identity_str], + coefficients=np.array([0.0]), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + ) # Merge duplicate Pauli strings merged: dict[str, complex] = {} for s, c in zip(new_strings, new_coeffs, strict=True): merged[s] = merged.get(s, 0.0) + c - # Filter near-zero terms + # Filter near-zero terms (use 1e-12, consistent with mapper thresholds) final_strings = [] final_coeffs = [] for s, c in merged.items(): - if abs(c) > np.finfo(np.float64).eps: + if abs(c) > 1e-12: final_strings.append(s) final_coeffs.append(c) if not final_strings: - raise ValueError("All Pauli terms cancelled after tapering") + # All terms cancelled after merging — return zero operator + identity_str = "I" * new_nq + final_strings = [identity_str] + final_coeffs = [0.0] return QubitHamiltonian( pauli_strings=final_strings, @@ -172,12 +184,13 @@ def taper_to_scbk( """ from qdk_chemistry.data import QubitHamiltonian # noqa: PLC0415 from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder # noqa: PLC0415 + from qdk_chemistry.data.tapering import TaperingSpecification # noqa: PLC0415 - _BK_ENCODINGS = {"bravyi-kitaev", "bravyi-kitaev-tree"} - if qubit_hamiltonian.encoding not in _BK_ENCODINGS: + bk_encodings = {"bravyi-kitaev", "bravyi-kitaev-tree"} + if qubit_hamiltonian.encoding not in bk_encodings: raise ValueError( f"taper_to_scbk requires a Bravyi-Kitaev encoded QubitHamiltonian " - f"(encoding in {_BK_ENCODINGS}), got encoding={qubit_hamiltonian.encoding!r}. " + f"(encoding in {bk_encodings}), got encoding={qubit_hamiltonian.encoding!r}. " f"Use MajoranaMapping.bravyi_kitaev_tree() to produce a BK-encoded Hamiltonian first." ) @@ -205,10 +218,19 @@ def taper_to_scbk( q_alpha = n // 2 - 1 result = taper_qubits(qubit_hamiltonian, [q_alpha, q_total], [ev_alpha, ev_total]) + + tapering = TaperingSpecification( + qubit_indices=(q_alpha, q_total), + eigenvalues=(ev_alpha, ev_total), + source_num_qubits=n, + source_encoding=qubit_hamiltonian.encoding or "bravyi-kitaev-tree", + ) + return QubitHamiltonian( pauli_strings=result.pauli_strings, coefficients=result.coefficients, encoding="symmetry-conserving-bravyi-kitaev", fermion_mode_order=result.fermion_mode_order, term_partition=result.term_partition, + tapering=tapering, ) diff --git a/python/tests/test_tapering.py b/python/tests/test_tapering.py index 64ca35734..fab897c80 100644 --- a/python/tests/test_tapering.py +++ b/python/tests/test_tapering.py @@ -10,8 +10,9 @@ import numpy as np import pytest -from qdk_chemistry.data import QubitHamiltonian -from qdk_chemistry.utils.tapering import taper_qubits +from qdk_chemistry.data import QubitHamiltonian, Symmetries +from qdk_chemistry.data.tapering import TaperingSpecification +from qdk_chemistry.utils.tapering import taper_qubits, taper_to_scbk # ------------------------------------------------------------------------------------- # taper_qubits — core tests @@ -99,8 +100,61 @@ def test_duplicate_indices_raises(self) -> None: with pytest.raises(ValueError, match="duplicate"): taper_qubits(qh, qubit_indices=[0, 0], eigenvalues=[1, -1]) - def test_all_terms_eliminated_raises(self) -> None: - """ValueError when all terms are eliminated by tapering.""" + def test_all_terms_eliminated_returns_zero(self) -> None: + """When all terms are eliminated, return a zero-coefficient identity operator.""" qh = QubitHamiltonian(pauli_strings=["XI"], coefficients=np.array([1.0])) - with pytest.raises(ValueError, match="eliminated"): - taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) + result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) + assert result.num_qubits == 1 + assert len(result.pauli_strings) == 1 + assert result.pauli_strings[0] == "I" + assert np.isclose(result.coefficients[0], 0.0) + + def test_full_cancellation_returns_zero(self) -> None: + """When terms cancel to zero after merging, return a zero-coefficient identity.""" + # Z with +1 eigenvalue → +1.0·I, and I → -1.0·I → merge to 0 + qh = QubitHamiltonian(pauli_strings=["ZI", "II"], coefficients=np.array([1.0, -1.0])) + result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) + assert result.num_qubits == 1 + assert len(result.pauli_strings) == 1 + assert result.pauli_strings[0] == "I" + assert np.isclose(result.coefficients[0], 0.0) + + +# ------------------------------------------------------------------------------------- +# taper_to_scbk — two-step SCBK tests +# ------------------------------------------------------------------------------------- + + +class TestTaperToScbk: + """Tests for the taper_to_scbk two-step function.""" + + def test_output_has_tapering_attribute(self) -> None: + """taper_to_scbk sets tapering metadata on the output QubitHamiltonian.""" + qh = QubitHamiltonian( + pauli_strings=["IIII", "ZIZI", "IZIZ", "ZZII"], + coefficients=np.array([1.0, 0.5, 0.3, 0.2]), + encoding="bravyi-kitaev-tree", + ) + symmetries = Symmetries(n_alpha=1, n_beta=1) + result = taper_to_scbk(qh, symmetries) + + assert result.tapering is not None + assert isinstance(result.tapering, TaperingSpecification) + assert result.tapering.qubit_indices == (1, 3) + assert result.tapering.source_num_qubits == 4 + assert result.tapering.source_encoding == "bravyi-kitaev-tree" + assert result.encoding == "symmetry-conserving-bravyi-kitaev" + + def test_output_tapering_eigenvalues(self) -> None: + """Tapering eigenvalues match the symmetry sector.""" + qh = QubitHamiltonian( + pauli_strings=["IIII", "ZIII"], + coefficients=np.array([1.0, 0.5]), + encoding="bravyi-kitaev", + ) + symmetries = Symmetries(n_alpha=1, n_beta=0) + result = taper_to_scbk(qh, symmetries) + + # n_alpha=1 (odd) → ev_alpha=-1, n_total=1 (odd) → ev_total=-1 + assert result.tapering is not None + assert result.tapering.eigenvalues == (-1, -1) From b2a742f402ed527dfaedc7395984fc8b525e8cc5 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Wed, 27 May 2026 13:03:45 -0700 Subject: [PATCH 083/117] REview feedback --- .../comprehensive/algorithms/qubit_mapper.rst | 9 +- .../qubit_mapper/qdk_qubit_mapper.py | 18 ++-- .../algorithms/qubit_mapper/qubit_mapper.py | 83 +++++++++++-------- .../plugins/openfermion/qubit_mapper.py | 18 ++-- .../plugins/qiskit/qubit_mapper.py | 18 ++-- 5 files changed, 84 insertions(+), 62 deletions(-) diff --git a/docs/source/user/comprehensive/algorithms/qubit_mapper.rst b/docs/source/user/comprehensive/algorithms/qubit_mapper.rst index 29785d740..d9146238f 100644 --- a/docs/source/user/comprehensive/algorithms/qubit_mapper.rst +++ b/docs/source/user/comprehensive/algorithms/qubit_mapper.rst @@ -168,10 +168,11 @@ This distinction has practical consequences: match its name, a name-dispatched backend will silently use the wrong transform. -- **Tapering** is handled in the base class - :meth:`~qdk_chemistry.algorithms.QubitMapper.run`, so all backends - automatically support tapered encodings (SCBK, parity two-qubit - reduction). Backends only ever see the untapered base encoding. +- **Tapering** is each backend's responsibility. The base class provides + a ``_taper_result()`` helper that strips tapering from the mapping, + performs the base transform, and reapplies tapering to the output. All + shipped backends use this helper, but third-party backends are free to + handle tapering however they choose. .. _qdk-qubit-mapper: diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index 4787e0409..cb733848e 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -118,6 +118,10 @@ def _run_impl( uses the Pauli-string table directly. The ``base_encoding`` name is used only for metadata on the output, not for dispatch. + If *mapping* carries tapering metadata, the base encoding is + extracted first, mapped, and tapering is applied to the result + via :meth:`~QubitMapper._taper_result`. + Args: hamiltonian: The fermionic Hamiltonian with one-body and two-body integrals. mapping: The Majorana-to-Pauli encoding (table is consumed directly by the C++ engine). @@ -128,6 +132,9 @@ def _run_impl( """ Logger.trace_entering() + # Strip tapering — the C++ engine maps the base encoding only + base_mapping = mapping.without_tapering() if mapping.tapering else mapping + threshold = float(self.settings().get("threshold")) integral_threshold = float(self.settings().get("integral_threshold")) @@ -137,9 +144,9 @@ def _run_impl( n_spin_orbitals = 2 * n_spatial is_restricted = hamiltonian.get_orbitals().is_restricted() - if mapping.num_modes != n_spin_orbitals: + if base_mapping.num_modes != n_spin_orbitals: raise ValueError( - f"MajoranaMapping has {mapping.num_modes} modes but the Hamiltonian has " + f"MajoranaMapping has {base_mapping.num_modes} modes but the Hamiltonian has " f"{n_spin_orbitals} spin-orbitals (2 x {n_spatial} spatial orbitals). " f"Use MajoranaMapping.jordan_wigner(num_modes={n_spin_orbitals}) or equivalent." ) @@ -156,7 +163,7 @@ def _run_impl( # Single C++ call: Majorana-loop engine builds all Pauli terms pauli_strings, coefficients = majorana_map_hamiltonian( - mapping.core, + base_mapping.core, 0.0, # core energy not included (QDK convention) h1_a_flat, h1_b_flat, @@ -171,9 +178,10 @@ def _run_impl( Logger.debug(f"Generated {len(pauli_strings)} Pauli terms for {2 * n_spatial} qubits") - return QubitHamiltonian( + qh = QubitHamiltonian( pauli_strings=list(pauli_strings), coefficients=np.array(coefficients, dtype=complex), - encoding=mapping.base_encoding, + encoding=base_mapping.base_encoding, fermion_mode_order=FermionModeOrder.BLOCKED, ) + return self._taper_result(qh, mapping) diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index a66c367d3..287434fee 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -77,13 +77,12 @@ class QubitMapper(Algorithm): .. rubric:: Tapering - Subclasses implement :meth:`_run_impl` for the base (untapered) mapping. - Tapering is handled automatically by :meth:`run`: if the - :class:`~qdk_chemistry.data.MajoranaMapping` carries a - :attr:`~qdk_chemistry.data.MajoranaMapping.tapering` specification, - ``run()`` strips it before calling ``_run_impl()`` and applies it to - the result afterward. This means ``_run_impl()`` only ever sees a - base encoding and never needs to handle tapering itself. + Each backend is responsible for handling tapering in its own + ``_run_impl()``. The static helper :meth:`_taper_result` provides + the common taper-then-relabel logic so backends don't have to + reimplement it, but backends are free to handle tapering however they + choose. All shipped backends (QDK, OpenFermion, Qiskit) use the + helper. """ @@ -102,10 +101,8 @@ def run( ) -> QubitHamiltonian: """Map a fermionic Hamiltonian to a qubit Hamiltonian. - If *mapping* carries tapering metadata, the tapering is stripped - before delegating to :meth:`_run_impl` and reapplied afterward. - This lets every backend (QDK native, OpenFermion, Qiskit, …) - support tapered encodings without duplicating tapering logic. + Delegates entirely to :meth:`_run_impl`. Each backend is + responsible for handling tapering (if ``mapping.tapering`` is set). Args: hamiltonian: The fermionic Hamiltonian. @@ -116,29 +113,42 @@ def run( """ self._settings.lock() + return self._run_impl(hamiltonian, mapping) + @staticmethod + def _taper_result(qh: QubitHamiltonian, mapping: MajoranaMapping) -> QubitHamiltonian: + """Apply post-mapping tapering if the mapping specifies it. + + Convenience helper for backends. If ``mapping.tapering`` is + ``None``, returns *qh* unchanged. Otherwise, applies + :func:`~qdk_chemistry.utils.tapering.taper_qubits` and relabels + the result with the mapping's final encoding name. + + Args: + qh: The untapered qubit Hamiltonian from the base mapping. + mapping: The original mapping (with tapering metadata). + + Returns: + QubitHamiltonian: Tapered and relabelled, or *qh* unchanged. + + """ tapering = mapping.tapering - if tapering is not None: - base_mapping = mapping.without_tapering() - qh = self._run_impl(hamiltonian, base_mapping) - else: - qh = self._run_impl(hamiltonian, mapping) - - if tapering is not None: - from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian # noqa: PLC0415 - from qdk_chemistry.utils.tapering import taper_qubits # noqa: PLC0415 - - qh = taper_qubits(qh, tapering.qubit_indices, tapering.eigenvalues) - qh = QubitHamiltonian( - pauli_strings=qh.pauli_strings, - coefficients=qh.coefficients, - encoding=mapping.name, - fermion_mode_order=qh.fermion_mode_order, - tapering=tapering, - ) - Logger.debug(f"Tapered {tapering.num_tapered} qubits → {qh.num_qubits} qubits") - - return qh + if tapering is None: + return qh + + from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian as QH # noqa: PLC0415 + from qdk_chemistry.utils.tapering import taper_qubits # noqa: PLC0415 + + tapered = taper_qubits(qh, tapering.qubit_indices, tapering.eigenvalues) + result = QH( + pauli_strings=tapered.pauli_strings, + coefficients=tapered.coefficients, + encoding=mapping.name, + fermion_mode_order=tapered.fermion_mode_order, + tapering=tapering, + ) + Logger.debug(f"Tapered {tapering.num_tapered} qubits → {result.num_qubits} qubits") + return result @abstractmethod def _run_impl( @@ -148,8 +158,11 @@ def _run_impl( ) -> QubitHamiltonian: """Construct a QubitHamiltonian from a Hamiltonian using the given mapping. - Implementations receive a **base** (untapered) mapping only. - Tapering is handled by :meth:`run`. + Implementations receive the **full** mapping, which may include + tapering. Each backend is responsible for handling tapering — + typically by stripping it via ``mapping.without_tapering()``, + performing the base mapping, and calling :meth:`_taper_result` + to apply tapering to the output. .. important:: @@ -166,7 +179,7 @@ def _run_impl( Args: hamiltonian: The fermionic Hamiltonian. - mapping: The Majorana-to-Pauli encoding (always untapered). + mapping: The Majorana-to-Pauli encoding (may include tapering). Returns: QubitHamiltonian: An instance of the QubitHamiltonian. diff --git a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py index f58d0da88..0a04cf54c 100644 --- a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py @@ -70,9 +70,9 @@ class OpenFermionQubitMapper(QubitMapper): produce silently incorrect results. Tapering-based encodings (e.g. symmetry-conserving Bravyi-Kitaev) are - supported — the base class - :meth:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapper.run` strips - tapering before calling ``_run_impl()`` and reapplies it afterward. + supported — each backend handles tapering in its own ``_run_impl()`` + via the :meth:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapper._taper_result` + helper. Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. For unrestricted systems, separate alpha/beta spin channels are handled @@ -109,6 +109,10 @@ def _run_impl( function. ``mapping.table`` is **not used** — the qubit operator is rebuilt entirely by OpenFermion's own pipeline. + If *mapping* carries tapering metadata, the base encoding is + extracted first, mapped, and tapering is applied to the result + via :meth:`~QubitMapper._taper_result`. + Args: hamiltonian: The fermionic Hamiltonian (restricted or unrestricted). mapping: Encoding selector — only ``base_encoding`` is read. @@ -123,11 +127,6 @@ def _run_impl( Logger.trace_entering() # --- Name dispatch (see QubitMapper class docstring) --- - # This backend does NOT use mapping.table. It reads the encoding - # name and selects the corresponding OpenFermion transform function, - # which rebuilds the qubit operator from scratch. Consistency - # between the name and the table is only guaranteed for - # factory-produced MajoranaMapping objects. encoding_name = mapping.base_encoding if encoding_name not in _STANDARD_TRANSFORMS: @@ -150,11 +149,12 @@ def _run_impl( qubit_op -= core_energy * of.QubitOperator(()) qubit_op.compress() - return qubit_operator_to_qubit_hamiltonian( + qh = qubit_operator_to_qubit_hamiltonian( qubit_op, encoding=encoding_name, fermion_mode_order=fermion_mode_order, ) + return self._taper_result(qh, mapping) def _map_standard(self, hamiltonian: Hamiltonian, encoding: str) -> of.QubitOperator: """Apply a standard fermion-to-qubit transform (JW, BK, or BK-tree). diff --git a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py index a1d744725..8363efb4b 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py @@ -66,9 +66,9 @@ class QiskitQubitMapper(QubitMapper): produce silently incorrect results. Tapering-based encodings (e.g. parity two-qubit reduction) are - supported — the base class - :meth:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapper.run` strips - tapering before calling ``_run_impl()`` and reapplies it afterward. + supported — each backend handles tapering in its own ``_run_impl()`` + via the :meth:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapper._taper_result` + helper. Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. For unrestricted systems, separate alpha and beta one-body and two-body @@ -107,6 +107,10 @@ def _run_impl( class. ``mapping.table`` is **not used** — the qubit operator is rebuilt entirely by Qiskit's own pipeline. + If *mapping* carries tapering metadata, the base encoding is + extracted first, mapped, and tapering is applied to the result + via :meth:`~QubitMapper._taper_result`. + Args: hamiltonian: The fermionic Hamiltonian (restricted or unrestricted). mapping: Encoding selector — only ``base_encoding`` is read. @@ -121,11 +125,6 @@ def _run_impl( Logger.trace_entering() # --- Name dispatch (see QubitMapper class docstring) --- - # This backend does NOT use mapping.table. It reads the encoding - # name and selects the corresponding Qiskit Nature mapper class, - # which rebuilds the qubit operator from scratch. Consistency - # between the name and the table is only guaranteed for - # factory-produced MajoranaMapping objects. encoding_name = mapping.base_encoding if encoding_name not in _SUPPORTED_ENCODINGS: @@ -159,12 +158,13 @@ def _run_impl( fermionic_op = electronic_hamiltonian.second_q_op() qubit_mapper = _SUPPORTED_ENCODINGS[encoding_name]() qubit_op = qubit_mapper.map(fermionic_op) - return QubitHamiltonian( + qh = QubitHamiltonian( pauli_strings=qubit_op.paulis.to_labels(), coefficients=qubit_op.coeffs, encoding=encoding_name, fermion_mode_order=FermionModeOrder.BLOCKED, ) + return self._taper_result(qh, mapping) def name(self) -> str: """Return the algorithm name ``qiskit``.""" From 095b180cc6feab7cb7c5c54fa23f6f5e0f443400 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Wed, 27 May 2026 14:11:04 -0700 Subject: [PATCH 084/117] Minor --- .../src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 287434fee..5a60380ff 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -136,11 +136,11 @@ def _taper_result(qh: QubitHamiltonian, mapping: MajoranaMapping) -> QubitHamilt if tapering is None: return qh - from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian as QH # noqa: PLC0415 + from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian # noqa: PLC0415 from qdk_chemistry.utils.tapering import taper_qubits # noqa: PLC0415 tapered = taper_qubits(qh, tapering.qubit_indices, tapering.eigenvalues) - result = QH( + result = QubitHamiltonian( pauli_strings=tapered.pauli_strings, coefficients=tapered.coefficients, encoding=mapping.name, From a50a57b3381551cc6e7b7b25dd2dfe1e33078948 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Fri, 29 May 2026 22:49:01 +0000 Subject: [PATCH 085/117] Drop reserved stabilizers/parity_sector scaffolding from MajoranaMapping These fields were always empty for the Majorana-atomic encodings the class produces and were never read by the mapping engine, validation, or any consumer. Codespace metadata, if ever needed for redundant encodings, is derivable from the encoding and belongs to the consuming algorithm. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../qdk/chemistry/data/majorana_mapping.hpp | 37 ------------------- .../comprehensive/data/majorana_mapping.rst | 1 - python/src/pybind11/data/majorana_mapping.cpp | 34 ----------------- .../qdk_chemistry/data/majorana_mapping.py | 27 -------------- python/tests/test_majorana_mapping.py | 16 -------- 5 files changed, 115 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index 0cbbe9a75..360065337 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -166,35 +166,6 @@ class MajoranaMapping { */ bool is_majorana_atomic() const { return true; } - /** - * @brief Stabilizer generators of the codespace. - * - * Empty for Majorana-atomic encodings (no codespace constraint: the - * full 2^num_qubits qubit space carries both fermion-parity sectors). - * Reserved for redundant encodings whose codespace is a fixed-parity - * sector stabilized by ``num_qubits - num_modes`` Pauli operators. - * - * @return const reference to the stabilizer list (empty for current - * encodings). - */ - const std::vector& stabilizers() const { - return stabilizers_; - } - - /** - * @brief Parity sector of the encoded codespace. - * - * - ``0``: unrestricted; the full Fock space spans both even and odd - * parity (Majorana-atomic encodings). - * - ``+1``: even-parity codespace (typical for redundant encodings on - * electron-number-conserving Hamiltonians). - * - ``-1``: odd-parity codespace (e.g. odd electron count by - * construction). - * - * Always ``0`` for current encodings. - */ - int parity_sector() const { return parity_sector_; } - /** * @brief Get the sign factor for Majorana operator gamma_k. * @param k Majorana index (0 <= k < 2N). @@ -314,14 +285,6 @@ class MajoranaMapping { /// Cached qubit count (max qubit index + 1). std::size_t num_qubits_; - /// Stabilizer generators of the codespace. Empty for Majorana-atomic - /// encodings; reserved for redundant encodings. - std::vector stabilizers_{}; - - /// Parity sector of the codespace: 0 (unrestricted), +1 (even), or -1 - /// (odd). Always 0 for current encodings. - int parity_sector_{0}; - /// Compute num_qubits_ from the table. static std::size_t compute_num_qubits( const std::vector& table); diff --git a/docs/source/user/comprehensive/data/majorana_mapping.rst b/docs/source/user/comprehensive/data/majorana_mapping.rst index 947add228..bab9498d5 100644 --- a/docs/source/user/comprehensive/data/majorana_mapping.rst +++ b/docs/source/user/comprehensive/data/majorana_mapping.rst @@ -25,7 +25,6 @@ The :class:`~qdk_chemistry.data.MajoranaMapping` API therefore exposes: - :py:meth:`~qdk_chemistry.data.MajoranaMapping.bilinear` — the unified primitive available on every encoding. - :py:meth:`~qdk_chemistry.data.MajoranaMapping.majorana` — the additional capability that Majorana-atomic encodings provide; gated by :py:attr:`~qdk_chemistry.data.MajoranaMapping.is_majorana_atomic`. -- :py:attr:`~qdk_chemistry.data.MajoranaMapping.stabilizers` and :py:attr:`~qdk_chemistry.data.MajoranaMapping.parity_sector` — codespace metadata; empty / ``0`` for the Majorana-atomic encodings shipped today, populated for redundant encodings when those land. The current factory methods (Jordan-Wigner, Bravyi-Kitaev, parity, SCBK) are all Majorana-atomic, so both APIs are available and produce consistent results: :py:meth:`~qdk_chemistry.data.MajoranaMapping.bilinear` is computed on demand from the stored Majorana table. diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index 8326b5432..aa30e4934 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -277,40 +277,6 @@ this encoding (i.e. :py:meth:`majorana` is callable). Always True for encodings constructible through this class today; future redundant encodings will return False, in which case only :py:meth:`bilinear` is available. -)"); - - // stabilizers property: list of dense little-endian Pauli strings - mapping.def_property_readonly( - "stabilizers", - [](const MajoranaMapping& self) -> py::tuple { - const auto& s = self.stabilizers(); - std::size_t nq = self.num_qubits(); - py::tuple result(s.size()); - for (std::size_t i = 0; i < s.size(); ++i) { - result[i] = py::cast(sparse_to_dense_le(s[i], nq)); - } - return result; - }, - R"( -Stabilizer generators of the codespace, as a tuple of dense -little-endian Pauli strings. Empty for Majorana-atomic encodings (no -codespace constraint). Reserved for redundant encodings whose -codespace is a fixed-parity sector stabilized by -``num_qubits - num_modes`` Pauli operators. -)"); - - mapping.def_property_readonly( - "parity_sector", - [](const MajoranaMapping& self) { return self.parity_sector(); }, - R"( -Parity sector of the codespace: - -- ``0``: unrestricted; the full Fock space spans both parity sectors - (Majorana-atomic encodings). -- ``+1``: even-parity codespace. -- ``-1``: odd-parity codespace. - -Always ``0`` for current encodings. )"); // __call__ for γ_k lookup diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index 0f1594785..a7b162473 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -82,8 +82,6 @@ class MajoranaMapping(DataClass): num_qubits (int): Effective number of qubits after any tapering. name (str): Human-readable name of the encoding (may be empty for custom mappings). is_majorana_atomic (bool): True if individual gamma_k have a Pauli image (always True today). - stabilizers (tuple[str, ...]): Codespace stabilizer generators (empty for current encodings). - parity_sector (int): Codespace parity: 0 (unrestricted), +1 (even), -1 (odd). Always 0 today. Examples: Built-in encodings: @@ -243,31 +241,6 @@ def is_majorana_atomic(self) -> bool: """ return bool(self._core.is_majorana_atomic) - @property - def stabilizers(self) -> tuple[str, ...]: - """Stabilizer generators of the codespace. - - Empty for Majorana-atomic encodings (no codespace constraint: the - full qubit Hilbert space spanned by the Pauli table carries both - fermion-parity sectors). Reserved for redundant encodings, whose - codespace is a fixed-parity sector stabilized by - ``num_qubits - num_modes`` Pauli operators. - """ - return tuple(self._core.stabilizers) - - @property - def parity_sector(self) -> int: - """Parity sector of the codespace. - - - ``0``: unrestricted; the full Fock space spans both parity sectors - (Majorana-atomic encodings). - - ``+1``: even-parity codespace. - - ``-1``: odd-parity codespace. - - Always ``0`` for current encodings. - """ - return int(self._core.parity_sector) - def majorana(self, k: int) -> str: """Return the Pauli image of the Majorana operator gamma_k. diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index 11d8918a3..d152979fb 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -698,22 +698,6 @@ def test_is_majorana_atomic(self, name: str, factory, n_modes: int) -> None: m = factory(n_modes) assert m.is_majorana_atomic is True - @pytest.mark.parametrize(("name", "factory"), _FACTORIES) - @pytest.mark.parametrize("n_modes", [2, 4]) - def test_stabilizers_empty(self, name: str, factory, n_modes: int) -> None: - """Majorana-atomic encodings have no codespace stabilizers.""" - del name - m = factory(n_modes) - assert m.stabilizers == () - - @pytest.mark.parametrize(("name", "factory"), _FACTORIES) - @pytest.mark.parametrize("n_modes", [2, 4]) - def test_parity_sector_unrestricted(self, name: str, factory, n_modes: int) -> None: - """Majorana-atomic encodings span both parity sectors (sector 0).""" - del name - m = factory(n_modes) - assert m.parity_sector == 0 - def test_pauli_string_length_untapered(self) -> None: """For untapered encodings, bilinear/majorana strings have length ``num_qubits``.""" m = MajoranaMapping.jordan_wigner(num_modes=4) From 08b546a2e43dbf56025418e6ea8a76e18e9b985e Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Fri, 29 May 2026 23:30:41 +0000 Subject: [PATCH 086/117] =?UTF-8?q?refactor:=20simplify=20MajoranaMapping?= =?UTF-8?q?=20=E2=80=94=20drop=20phases,=20validation,=20strings=20from=20?= =?UTF-8?q?C++?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove phases (dead code — no factory produced negative phases), Clifford validation, and dense-string handling from the C++ and pybind layers. C++/pybind boundary now uses SparsePauliWord exclusively; Python owns dense little-endian string conversion via _dense_le_to_sparse / _sparse_to_dense_le helpers. Public Python API unchanged (still returns strings). Consolidate majorana_map_engine.hpp into majorana_mapping.hpp. Rename is_restricted → is_spin_free in the engine (documents the actual semantic). Note the spin-free-only limitation on majorana_map_hamiltonian. Minimize docs: strip speculative encoding references and validation language from docstrings and RST. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chemistry/data/majorana_map_engine.hpp | 67 --- .../qdk/chemistry/data/majorana_mapping.hpp | 284 +++--------- .../chemistry/data/majorana_map_engine.cpp | 143 ++---- .../qdk/chemistry/data/majorana_mapping.cpp | 113 +---- .../comprehensive/data/majorana_mapping.rst | 23 +- python/src/pybind11/data/majorana_mapping.cpp | 420 ++---------------- .../qubit_mapper/qdk_qubit_mapper.py | 24 +- .../qdk_chemistry/data/majorana_mapping.py | 200 ++++----- python/tests/test_majorana_mapping.py | 9 +- python/tests/test_qdk_qubit_mapper.py | 6 +- 10 files changed, 231 insertions(+), 1058 deletions(-) delete mode 100644 cpp/include/qdk/chemistry/data/majorana_map_engine.hpp diff --git a/cpp/include/qdk/chemistry/data/majorana_map_engine.hpp b/cpp/include/qdk/chemistry/data/majorana_map_engine.hpp deleted file mode 100644 index 89dd48c7d..000000000 --- a/cpp/include/qdk/chemistry/data/majorana_map_engine.hpp +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE.txt in the project root for -// license information. - -#pragma once -#include -#include -#include -#include -#include -#include -#include - -namespace qdk::chemistry::data { - -/** - * @brief Result of a Majorana-loop fermion-to-qubit mapping. - * - * Contains Pauli strings in little-endian format (qubit 0 = rightmost char) - * and their corresponding complex coefficients. - */ -struct MajoranaMapResult { - /// Pauli strings in little-endian format. - std::vector pauli_strings; - /// Complex coefficients (same length as pauli_strings). - std::vector> coefficients; -}; - -/** - * @brief Map a fermionic Hamiltonian to qubit Pauli terms using Majorana loops. - * - * This is the encoding-agnostic mapping engine. It decomposes each fermionic - * operator (a†_p a_q, a†_p a†_r a_s a_q) into Majorana products using - * compile-time coefficients, looks up each Majorana γ_k in the mapping table, - * and multiplies/accumulates the resulting Pauli strings. - * - * The second-quantized Hamiltonian in chemist notation is: - * H = E_core - * + Σ_{pq,σ} h_pq a†_{pσ} a_{qσ} - * + (1/2) Σ_{pqrs,σ,τ} (pq|rs) a†_{pσ} a†_{rτ} a_{sτ} a_{qσ} - * - * Using the identity a†_p a†_r a_s a_q = E_pq·E_rs - δ_{qr}·E_ps, - * where E_pq = a†_p a_q, and each E_pq is decomposed as: - * E_pq = (1/4) Σ_{a,b} c[a][b] · γ_{2p+a} · γ_{2q+b} - * with c[a][b] = (-i)^a · (i)^b. - * - * @param mapping The Majorana-to-Pauli mapping (encoding). - * @param core_energy The core (nuclear repulsion + frozen core) energy. - * @param h1_alpha One-body integrals for alpha spin (n_spatial × n_spatial). - * @param h1_beta One-body integrals for beta spin (n_spatial × n_spatial). - * @param eri_aaaa Flattened two-body integrals (αα|αα) in chemist notation. - * @param eri_aabb Flattened two-body integrals (αα|ββ) in chemist notation. - * @param eri_bbbb Flattened two-body integrals (ββ|ββ) in chemist notation. - * @param n_spatial Number of spatial orbitals. - * @param is_restricted Whether h1_alpha == h1_beta (spin-free case). - * @param threshold Pauli terms with |coeff| < threshold are dropped. - * @param integral_threshold Integrals with |value| < this are skipped. - * @return MajoranaMapResult with Pauli strings (little-endian) and - * coefficients. - */ -MajoranaMapResult majorana_map_hamiltonian( - const MajoranaMapping& mapping, double core_energy, const double* h1_alpha, - const double* h1_beta, const double* eri_aaaa, const double* eri_aabb, - const double* eri_bbbb, std::size_t n_spatial, bool is_restricted, - double threshold, double integral_threshold); - -} // namespace qdk::chemistry::data diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index 360065337..294bae093 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -15,279 +15,127 @@ namespace qdk::chemistry::data { /** * @brief Immutable data class describing a fermion-to-qubit encoding. * - * The most general primitive of any fermion-to-qubit encoding is the - * **bilinear** i·γ_j·γ_k: the parity-even subalgebra of the Majorana - * Clifford algebra is generated by these bilinears in every encoding, - * and they always have an explicit Pauli image, even in redundant - * (stabilizer-code) encodings such as Bravyi-Kitaev superfast where - * individual Majoranas do not. See ``bilinear()``. + * Stores a 2N-entry table mapping each Majorana operator gamma_k to a Pauli + * word. The bilinear i*gamma_j*gamma_k is the unified primitive and is + * computed on demand from the table. * - * For **Majorana-atomic** encodings (Jordan-Wigner, Bravyi-Kitaev tree, - * parity, and their tapered variants) individual Majorana operators γ_k - * additionally have a Pauli image, and the encoding can be specified - * compactly as a 2N-entry table. The current constructors and factory - * methods of this class all produce Majorana-atomic encodings; the - * stored ``table`` is the per-Majorana Pauli string. ``majorana()`` - * exposes those entries directly. The bilinear i·γ_j·γ_k is computed on - * demand from ``i · phases_[j] · phases_[k] · table_[j] · table_[k]``. - * - * Future redundant encodings would not have a per-Majorana table; the - * bilinear API is the unified primitive that covers both regimes. - * - * The mapping is validated at construction time by checking the Clifford - * algebra anticommutation relations: {γ_i, γ_j} = 2δ_{ij} · I. This - * guarantees that the mapping defines a valid Majorana-atomic encoding. - * - * Pauli string convention: The SparsePauliWord entries are stored in the - * same little-endian convention used by QubitHamiltonian (qubit 0 has the - * smallest index in the sparse representation). - * - * Factory methods construct the standard encodings (Jordan-Wigner, - * Bravyi-Kitaev, parity) from a mode count. Custom encodings can be - * constructed directly by providing the table. - * - * References: - * - Chen, Xu, Boettcher, "Equivalence between fermion-to-qubit mappings - * in two spatial dimensions" (2023): every f2q mapping is a - * homomorphism from the parity-even Majorana Clifford algebra into - * the Pauli group, modulo stabilizers. - * - Jiang, Kalev, Mruczkiewicz, Neven, "Optimal fermion-to-qubit - * mapping via ternary trees..." (2020) and the Majorana loop - * stabilizer code literature: redundant encodings as stabilizer codes. - * - * @see PauliTermAccumulator for the accumulation engine that uses this - * mapping. + * Pauli words use the little-endian convention of QubitHamiltonian (qubit 0 + * has the smallest sparse index). */ class MajoranaMapping { public: /** - * @brief Construct a MajoranaMapping from a Majorana-to-Pauli table. + * @brief Construct from a 2N-entry Majorana-to-Pauli table. * - * @param table Vector of 2N SparsePauliWord entries (one per Majorana - * operator gamma_0, gamma_1, ..., gamma_{2N-1}). - * @param name Optional human-readable label for the encoding (e.g., - * "jordan-wigner"). Stored but not used for dispatch. - * @param phases Optional vector of 2N sign factors (+1 or -1) such that - * gamma_k = phases[k] * table[k]. If empty, all phases are - * assumed +1. Encodings like SCBK produce negative phases. - * @throws std::invalid_argument If table size is odd, empty, phases size - * doesn't match table, phases contain values other than +1/-1, - * or the Clifford algebra validation fails. + * @param table 2N SparsePauliWord entries (gamma_0, ..., gamma_{2N-1}). + * @param name Optional encoding label. Stored but not used for dispatch. + * @throws std::invalid_argument If the table is empty or its size is odd. */ explicit MajoranaMapping(std::vector table, - std::string name = "", - std::vector phases = {}); + std::string name = ""); - /** - * @brief Number of fermionic modes (spin-orbitals). - * @return N where the table has 2N entries. - */ + /// Number of fermionic modes (the table has 2N entries). std::size_t num_modes() const { return table_.size() / 2; } - /** - * @brief Number of qubits required by this encoding. - * @return max qubit index + 1 across all table entries, or 0 if all - * entries are identity. - */ + /// Number of qubits (max qubit index + 1, or 0 if all entries are identity). std::size_t num_qubits() const { return num_qubits_; } /** - * @brief Look up the Pauli string for Majorana operator gamma_k. - * - * Returns the unsigned Pauli word table_[k]; the full Majorana operator - * is gamma_k = phases_[k] * table_[k]. Available only for - * Majorana-atomic encodings (every encoding constructible today is - * Majorana-atomic; see ``is_majorana_atomic()``). - * - * @param k Majorana index (0 <= k < 2N). - * @return const reference to the SparsePauliWord for gamma_k. + * @brief Pauli word for Majorana operator gamma_k. * @throws std::out_of_range if k >= 2N. */ const SparsePauliWord& operator()(std::size_t k) const; - /** - * @brief Named alias for ``operator()(k)``. - * - * Equivalent to ``(*this)(k)``. Provided so call sites that consume - * the bilinear-first interface can read consistently - * (``mapping.majorana(k)`` and ``mapping.bilinear(j, k)``). - * - * @param k Majorana index (0 <= k < 2N). - * @return const reference to the SparsePauliWord for gamma_k. - * @throws std::out_of_range if k >= 2N. - */ + /// Named alias for operator()(k). const SparsePauliWord& majorana(std::size_t k) const { return (*this)(k); } /** - * @brief Pauli image of the bilinear i·γ_j·γ_k. - * - * Returns ``(coeff, word)`` such that the Pauli operator - * ``coeff · word`` equals ``i · γ_j · γ_k`` in the encoded qubit - * representation. Defined for all distinct ``j != k`` in - * ``[0, 2 * num_modes())``. - * - * For Majorana-atomic encodings this is computed as - * ``i · phases_[j] · phases_[k] · table_[j] * table_[k]`` using the - * Pauli algebra. The returned coefficient is always real (±1) for the - * encodings produced by the factory methods, since ``i·γ_j·γ_k`` is - * Hermitian; the return type is ``std::complex`` for - * generality and consistency with - * ``PauliTermAccumulator::multiply_uncached``. + * @brief Pauli image of the bilinear i*gamma_j*gamma_k. * - * Bilinears commute with the total fermion parity and with all - * stabilizers of the codespace, so they are the most general primitive - * usable across Majorana-atomic and redundant encodings alike. + * Returns (coeff, word) such that coeff*word equals i*gamma_j*gamma_k in the + * encoded representation. The coefficient is real (+/-1) since the bilinear + * is Hermitian; the return type is complex for consistency with + * PauliTermAccumulator::multiply_uncached. * - * For tapered encodings (e.g. SCBK, parity-2q-reduced) this returns - * the **pre-taper** (source) Pauli string, with qubit indices in - * ``[0, num_qubits())``. Apply the :class:`TaperingSpecification` - * separately if you need post-taper coordinates. - * - * @param j First Majorana index (0 <= j < 2N). - * @param k Second Majorana index (0 <= k < 2N), must differ from j. - * @return pair of (complex coefficient, sparse Pauli word). - * @throws std::out_of_range if j or k is >= 2N. + * @throws std::out_of_range if j or k >= 2N. * @throws std::invalid_argument if j == k. */ std::pair, SparsePauliWord> bilinear( std::size_t j, std::size_t k) const; - /** - * @brief Whether the encoding exposes a Pauli image for individual - * Majoranas. - * - * ``true`` for every encoding currently constructible (the constructor - * requires a 2N-entry table). Future redundant encodings (Bravyi- - * Kitaev superfast, Verstraete-Cirac, Derby-Klassen, …) would return - * ``false``; for those, ``majorana()`` would not be defined and only - * ``bilinear()`` would be available. - * - * @return ``true`` for Majorana-atomic encodings. - */ + /// Whether individual Majoranas have a Pauli image (true for the table form). bool is_majorana_atomic() const { return true; } - /** - * @brief Get the sign factor for Majorana operator gamma_k. - * @param k Majorana index (0 <= k < 2N). - * @return +1 or -1. - */ - std::int8_t phase(std::size_t k) const; - - /** - * @brief Check if all phases are +1 (fast path for standard encodings). - * @return true if no entry has a negative phase. - */ - bool all_phases_positive() const { return all_positive_; } - - /** - * @brief Access the full Majorana-to-Pauli table. - * @return const reference to the vector of SparsePauliWords. - */ + /// The full Majorana-to-Pauli table. const std::vector& table() const { return table_; } - /** - * @brief Access the phases vector. - * @return const reference to the vector of sign factors. - */ - const std::vector& phases() const { return phases_; } - - /** - * @brief Human-readable name of the encoding. - * @return The name string (may be empty for custom encodings). - */ + /// Encoding name (may be empty for custom encodings). const std::string& name() const { return name_; } - /** - * @brief Validate the Clifford algebra anticommutation relations. - * - * Checks that {γ_i, γ_j} = φ(γ_i)·φ(γ_j) + φ(γ_j)·φ(γ_i) = 2δ_{ij}·I - * for all pairs (i, j). This is called automatically at construction. - * - * @throws std::invalid_argument if any anticommutator check fails. - */ - void validate() const; - // --- Factory methods for standard encodings --- - /** - * @brief Construct a Jordan-Wigner encoding. - * - * γ_{2j} = Z_{j-1} ⊗ ... ⊗ Z_0 ⊗ X_j - * γ_{2j+1} = Z_{j-1} ⊗ ... ⊗ Z_0 ⊗ Y_j - * - * @param num_modes Number of fermionic modes (spin-orbitals). - * The encoding uses num_modes qubits. - * @return MajoranaMapping with name "jordan-wigner". - */ + /// Jordan-Wigner encoding on num_modes qubits. static MajoranaMapping jordan_wigner(std::size_t num_modes); - /** - * @brief Construct a Bravyi-Kitaev encoding. - * - * γ_{2j} = X_{U(j)} ⊗ X_j ⊗ Z_{P(j)} - * γ_{2j+1} = X_{U(j)} ⊗ Y_j ⊗ Z_{R(j)} - * - * where U(j), P(j), R(j) are the update, parity, and remainder sets - * derived from the BK binary tree. - * - * @param num_modes Number of fermionic modes (spin-orbitals). - * The encoding uses num_modes qubits. - * @return MajoranaMapping with name "bravyi-kitaev". - */ + /// Bravyi-Kitaev (Fenwick-tree) encoding on num_modes qubits. static MajoranaMapping bravyi_kitaev(std::size_t num_modes); - /** - * @brief Construct a balanced binary tree Bravyi-Kitaev encoding. - * - * Implementation from arXiv:1701.07072 (Havlíček et al.). Unlike the - * Fenwick-tree BK (``bravyi_kitaev()``), the balanced tree variant has - * guaranteed Z₂ symmetries at qubit positions (n/2-1) and (n-1) for - * any even n, making it the required base encoding for - * symmetry-conserving BK (SCBK) tapering. - * - * γ_{2j} = X_{U(j)} ⊗ X_j ⊗ Z_{P(j)} - * γ_{2j+1} = X_{U(j)} ⊗ Y_j ⊗ Z_{C(j)} - * - * where U(j), P(j) = C(j) ∪ F(j), C(j) are the update, parity, - * and remainder sets derived from the balanced binary tree. - * - * @param num_modes Number of fermionic modes (spin-orbitals). - * The encoding uses num_modes qubits. - * @return MajoranaMapping with name "bravyi-kitaev-tree". - */ + /// Balanced binary-tree Bravyi-Kitaev encoding (arXiv:1701.07072). static MajoranaMapping bravyi_kitaev_tree(std::size_t num_modes); - /** - * @brief Construct a parity encoding. - * - * γ_{2j} = X_{n-1} ⊗ ... ⊗ X_{j+1} ⊗ Y_j ⊗ Z_{j-1} ⊗ ... ⊗ Z_0 - * γ_{2j+1} = X_{n-1} ⊗ ... ⊗ X_{j+1} ⊗ X_j ⊗ Z_{j-1} ⊗ ... ⊗ Z_0 - * - * @param num_modes Number of fermionic modes (spin-orbitals). - * The encoding uses num_modes qubits. - * @return MajoranaMapping with name "parity". - */ + /// Parity encoding on num_modes qubits. static MajoranaMapping parity(std::size_t num_modes); private: - /// Majorana-to-Pauli table: table_[k] = unsigned Pauli string for gamma_k. + /// Majorana-to-Pauli table: table_[k] is the Pauli word for gamma_k. std::vector table_; - /// Per-entry sign factors: gamma_k = phases_[k] * table_[k]. - std::vector phases_; - - /// True if all phases are +1 (enables fast-path in engine). - bool all_positive_; - /// Human-readable encoding name. std::string name_; /// Cached qubit count (max qubit index + 1). std::size_t num_qubits_; - /// Compute num_qubits_ from the table. static std::size_t compute_num_qubits( const std::vector& table); }; +/** + * @brief Result of a Majorana-loop fermion-to-qubit mapping. + * + * Parallel arrays of Pauli words and their complex coefficients. + */ +struct MajoranaMapResult { + std::vector words; + std::vector> coefficients; +}; + +/** + * @brief Map a fermionic Hamiltonian to qubit Pauli terms via Majorana loops. + * + * Decomposes each fermionic operator into Majorana products, looks up each + * gamma_k in the mapping, and accumulates the resulting Pauli words. + * + * @note Limitation: only valid for spin-free Hamiltonians (spin-independent + * one- and two-body integrals, h1_alpha == h1_beta). + * + * @param mapping The Majorana-to-Pauli encoding. + * @param core_energy Core (nuclear repulsion + frozen core) energy. + * @param h1_alpha One-body integrals, alpha spin (n_spatial x n_spatial). + * @param h1_beta One-body integrals, beta spin (n_spatial x n_spatial). + * @param eri_aaaa Flattened two-body integrals (aa|aa), chemist notation. + * @param eri_aabb Flattened two-body integrals (aa|bb), chemist notation. + * @param eri_bbbb Flattened two-body integrals (bb|bb), chemist notation. + * @param n_spatial Number of spatial orbitals. + * @param is_spin_free Whether the integrals are spin-independent. + * @param threshold Pauli terms with |coeff| < threshold are dropped. + * @param integral_threshold Integrals with |value| < this are skipped. + * @return MajoranaMapResult with Pauli words and coefficients. + */ +MajoranaMapResult majorana_map_hamiltonian( + const MajoranaMapping& mapping, double core_energy, const double* h1_alpha, + const double* h1_beta, const double* eri_aaaa, const double* eri_aabb, + const double* eri_bbbb, std::size_t n_spatial, bool is_spin_free, + double threshold, double integral_threshold); + } // namespace qdk::chemistry::data diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index c3484be07..cfad801d8 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -7,7 +7,6 @@ #include #include #include -#include #include #include #include @@ -158,15 +157,14 @@ class PackedAccumulator { terms_[word] += apply_phase(phase_idx, scale); } - /// Extract terms above threshold as (coefficient, little-endian string) - /// pairs. - std::vector, std::string>> - get_terms_as_strings(double threshold, std::size_t num_qubits) const { - std::vector, std::string>> result; + /// Extract terms above threshold as (coefficient, SparsePauliWord) pairs. + std::vector, SparsePauliWord>> get_terms( + double threshold) const { + std::vector, SparsePauliWord>> result; result.reserve(terms_.size()); for (const auto& [pw, coeff] : terms_) { if (std::abs(coeff) >= threshold) { - result.emplace_back(coeff, packed_to_le_string(pw, num_qubits)); + result.emplace_back(coeff, packed_to_sparse(pw)); } } return result; @@ -178,59 +176,6 @@ class PackedAccumulator { terms_; }; -/// Convert a PackedPauliWord directly to a dense little-endian string, -/// skipping the intermediate SparsePauliWord allocation. -template -std::string packed_to_le_string(const PackedPauliWord& pw, - std::size_t num_qubits) { - std::string result(num_qubits, 'I'); - for (std::size_t wi = 0; wi < NW; ++wi) { - std::uint64_t x = pw.x[wi]; - std::uint64_t z = pw.z[wi]; - std::uint64_t active = x | z; - while (active) { - int bit = __builtin_ctzll(active); - std::uint64_t mask = std::uint64_t(1) << bit; - std::size_t qubit = wi * 64 + bit; - if (qubit < num_qubits) { - bool has_x = (x & mask) != 0; - bool has_z = (z & mask) != 0; - // Little-endian: qubit 0 = rightmost - result[num_qubits - 1 - qubit] = has_x ? (has_z ? 'Y' : 'X') : 'Z'; - } - active &= ~mask; - } - } - return result; -} - -/// Convert a SparsePauliWord to a dense little-endian string. -/// (qubit 0 = rightmost character) -std::string sparse_to_le_string(const SparsePauliWord& word, - std::size_t num_qubits) { - std::string result(num_qubits, 'I'); - for (const auto& [qubit, op_type] : word) { - if (qubit < num_qubits) { - switch (op_type) { - case 1: - result[qubit] = 'X'; - break; - case 2: - result[qubit] = 'Y'; - break; - case 3: - result[qubit] = 'Z'; - break; - default: - break; - } - } - } - // Reverse to get little-endian (qubit 0 = rightmost) - std::reverse(result.begin(), result.end()); - return result; -} - } // namespace namespace { @@ -241,21 +186,17 @@ template MajoranaMapResult majorana_map_impl( const MajoranaMapping& mapping, double core_energy, const double* h1_alpha, const double* h1_beta, const double* eri_aaaa, const double* eri_aabb, - const double* eri_bbbb, std::size_t n_spatial, bool is_restricted, + const double* eri_bbbb, std::size_t n_spatial, bool is_spin_free, double threshold, double integral_threshold) { const std::size_t n_modes = 2 * n_spatial; - const std::size_t num_qubits = mapping.num_qubits(); PackedAccumulator acc; - // Convert all Majorana table entries to packed form + cache phases + // Convert all Majorana table entries to packed form. std::vector> packed_mapping(2 * n_modes); - std::vector maj_phases(2 * n_modes); for (std::size_t k = 0; k < 2 * n_modes; ++k) { packed_mapping[k] = sparse_to_packed(mapping(k)); - maj_phases[k] = mapping.phase(k); } - const bool has_neg_phases = !mapping.all_phases_positive(); auto mode_alpha = [](std::size_t p) -> std::size_t { return p; }; auto mode_beta = [n_spatial](std::size_t p) -> std::size_t { @@ -263,8 +204,7 @@ MajoranaMapResult majorana_map_impl( }; // Helper: accumulate one-body E_pq for a mode pair, using packed types - // E_pq = (1/4) * sum_{a,b} c[a][b] * phase(2p+a) * phase(2q+b) * P(2p+a) * - // P(2q+b) + // E_pq = (1/4) * sum_{a,b} c[a][b] * P(2p+a) * P(2q+b) auto accumulate_epq = [&](std::size_t mode_p, std::size_t mode_q, double h_pq) { for (int a = 0; a < 2; ++a) { @@ -273,11 +213,7 @@ MajoranaMapResult majorana_map_impl( std::size_t idx_qb = 2 * mode_q + b; auto [ph, word] = multiply_packed(packed_mapping[idx_pa], packed_mapping[idx_qb]); - double sign = has_neg_phases ? static_cast(maj_phases[idx_pa]) * - maj_phases[idx_qb] - : 1.0; - acc.accumulate(word, - apply_phase(ph, sign * h_pq * kQuarter * kC[a][b])); + acc.accumulate(word, apply_phase(ph, h_pq * kQuarter * kC[a][b])); } } }; @@ -299,8 +235,8 @@ MajoranaMapResult majorana_map_impl( for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t s = 0; s < n_spatial; ++s) { double h_a = h1_alpha[p * n_spatial + s]; - double h_b = is_restricted ? h_a : h1_beta[p * n_spatial + s]; - if (is_restricted) { + double h_b = is_spin_free ? h_a : h1_beta[p * n_spatial + s]; + if (is_spin_free) { double delta_corr = 0.0; for (std::size_t q = 0; q < n_spatial; ++q) { delta_corr += eri_aaaa[idx4(p, q, q, s)]; @@ -329,11 +265,8 @@ MajoranaMapResult majorana_map_impl( // ─── Two-body terms ─────────────────────────────────────────────── // Precompute Majorana pair products in packed form (same-spin only). - // Each entry stores the Pauli multiply phase index AND the combined - // Majorana sign factor (phases[i] * phases[j]). struct PackedPairProduct { - int phase; // Pauli multiply phase index (0..3) - std::int8_t sign; // maj_phases[i] * maj_phases[j] (±1) + int phase; // Pauli multiply phase index (0..3) PackedPauliWord word; }; const std::size_t maj_per_spin = 2 * n_spatial; @@ -344,17 +277,12 @@ MajoranaMapResult majorana_map_impl( for (std::size_t j = 0; j < maj_per_spin; ++j) { // alpha block: Majorana indices i, j auto [ph_a, w_a] = multiply_packed(packed_mapping[i], packed_mapping[j]); - std::int8_t sign_a = has_neg_phases ? maj_phases[i] * maj_phases[j] - : static_cast(1); - ppair_alpha[i * maj_per_spin + j] = {ph_a, sign_a, std::move(w_a)}; + ppair_alpha[i * maj_per_spin + j] = {ph_a, std::move(w_a)}; // beta block: Majorana indices i+2*n_spatial, j+2*n_spatial auto [ph_b, w_b] = multiply_packed(packed_mapping[i + maj_per_spin], packed_mapping[j + maj_per_spin]); - std::int8_t sign_b = has_neg_phases ? maj_phases[i + maj_per_spin] * - maj_phases[j + maj_per_spin] - : static_cast(1); - ppair_beta[i * maj_per_spin + j] = {ph_b, sign_b, std::move(w_b)}; + ppair_beta[i * maj_per_spin + j] = {ph_b, std::move(w_b)}; } } @@ -367,7 +295,7 @@ MajoranaMapResult majorana_map_impl( return ppair_beta[i * maj_per_spin + j]; }; - if (is_restricted) { + if (is_spin_free) { // Precompute spin-summed E_pq for all (p,q): struct SpinSummedE { std::vector, PackedPauliWord>> terms; @@ -379,18 +307,12 @@ MajoranaMapResult majorana_map_impl( auto& sse = ss_e[p * n_spatial + q]; for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { - const auto& [phase_a, sign_a, word_a] = - alpha_pair(2 * p + a, 2 * q + b); + const auto& [phase_a, word_a] = alpha_pair(2 * p + a, 2 * q + b); sse.terms.emplace_back( - apply_phase(phase_a, - static_cast(sign_a) * kQuarter * kC[a][b]), - word_a); - const auto& [phase_b, sign_b, word_b] = - beta_pair(2 * p + a, 2 * q + b); + apply_phase(phase_a, kQuarter * kC[a][b]), word_a); + const auto& [phase_b, word_b] = beta_pair(2 * p + a, 2 * q + b); sse.terms.emplace_back( - apply_phase(phase_b, - static_cast(sign_b) * kQuarter * kC[a][b]), - word_b); + apply_phase(phase_b, kQuarter * kC[a][b]), word_b); } } } @@ -493,16 +415,15 @@ MajoranaMapResult majorana_map_impl( double half_eri = 0.5 * eri; for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { - const auto& [ph1, s1, w1] = + const auto& [ph1, w1] = cache_pq[(2 * bp + a) * maj_per_spin + (2 * bq + b)]; for (int c = 0; c < 2; ++c) { for (int d = 0; d < 2; ++d) { - const auto& [ph2, s2, w2] = + const auto& [ph2, w2] = cache_rs[(2 * br + c) * maj_per_spin + (2 * bs + d)]; - double sign = static_cast(s1) * s2; std::complex scale = apply_phase( (ph1 + ph2) & 3, - sign * half_eri * kSixteenth * kC[a][b] * kC[c][d]); + half_eri * kSixteenth * kC[a][b] * kC[c][d]); acc.accumulate_product(w1, w2, scale); } } @@ -573,15 +494,15 @@ MajoranaMapResult majorana_map_impl( } } - // ─── Extract results directly as strings ──────────────────────── - auto terms = acc.get_terms_as_strings(threshold, num_qubits); + // ─── Extract results as sparse Pauli words ────────────────────── + auto terms = acc.get_terms(threshold); MajoranaMapResult result; - result.pauli_strings.reserve(terms.size()); + result.words.reserve(terms.size()); result.coefficients.reserve(terms.size()); - for (auto& [coeff, str] : terms) { - result.pauli_strings.push_back(std::move(str)); + for (auto& [coeff, word] : terms) { + result.words.push_back(std::move(word)); result.coefficients.push_back(coeff); } @@ -593,7 +514,7 @@ MajoranaMapResult majorana_map_impl( MajoranaMapResult majorana_map_hamiltonian( const MajoranaMapping& mapping, double core_energy, const double* h1_alpha, const double* h1_beta, const double* eri_aaaa, const double* eri_aabb, - const double* eri_bbbb, std::size_t n_spatial, bool is_restricted, + const double* eri_bbbb, std::size_t n_spatial, bool is_spin_free, double threshold, double integral_threshold) { const std::size_t num_qubits = mapping.num_qubits(); if (num_qubits == 0) { @@ -611,19 +532,19 @@ MajoranaMapResult majorana_map_hamiltonian( case 1: return majorana_map_impl<1>(mapping, core_energy, h1_alpha, h1_beta, eri_aaaa, eri_aabb, eri_bbbb, n_spatial, - is_restricted, threshold, integral_threshold); + is_spin_free, threshold, integral_threshold); case 2: return majorana_map_impl<2>(mapping, core_energy, h1_alpha, h1_beta, eri_aaaa, eri_aabb, eri_bbbb, n_spatial, - is_restricted, threshold, integral_threshold); + is_spin_free, threshold, integral_threshold); case 3: return majorana_map_impl<3>(mapping, core_energy, h1_alpha, h1_beta, eri_aaaa, eri_aabb, eri_bbbb, n_spatial, - is_restricted, threshold, integral_threshold); + is_spin_free, threshold, integral_threshold); case 4: return majorana_map_impl<4>(mapping, core_energy, h1_alpha, h1_beta, eri_aaaa, eri_aabb, eri_bbbb, n_spatial, - is_restricted, threshold, integral_threshold); + is_spin_free, threshold, integral_threshold); default: throw std::invalid_argument( "majorana_map_hamiltonian: num_qubits=" + std::to_string(num_qubits) + diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp index d2b93f4b7..8fa5bd66c 100644 --- a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include #include #include @@ -17,16 +16,6 @@ namespace qdk::chemistry::data { namespace { -// Re-use the Pauli algebra from pauli_operator.cpp (it's in the detail -// namespace of this translation unit's sibling, but we can call the -// PauliTermAccumulator static helper which is public). - -/// Multiply two SparsePauliWords via the public uncached static method. -std::pair, SparsePauliWord> multiply_words( - const SparsePauliWord& a, const SparsePauliWord& b) { - return PauliTermAccumulator::multiply_uncached(a, b); -} - // ── BK index-set computation ────────────────────────────────────────── /// Smallest power of 2 ≥ n. @@ -142,11 +131,8 @@ constexpr std::uint8_t OP_Z = 3; // ── MajoranaMapping implementation ─────────────────────────────────── MajoranaMapping::MajoranaMapping(std::vector table, - std::string name, - std::vector phases) + std::string name) : table_(std::move(table)), - phases_(std::move(phases)), - all_positive_(true), name_(std::move(name)), num_qubits_(compute_num_qubits(table_)) { if (table_.empty()) { @@ -158,24 +144,6 @@ MajoranaMapping::MajoranaMapping(std::vector table, "(2 per fermionic mode), got " + std::to_string(table_.size())); } - // Default phases to all +1 if not provided - if (phases_.empty()) { - phases_.assign(table_.size(), 1); - } else if (phases_.size() != table_.size()) { - throw std::invalid_argument( - "phases size (" + std::to_string(phases_.size()) + - ") must match table size (" + std::to_string(table_.size()) + ")"); - } - // Validate phase values and compute all_positive flag - for (std::size_t k = 0; k < phases_.size(); ++k) { - if (phases_[k] != 1 && phases_[k] != -1) { - throw std::invalid_argument("phases[" + std::to_string(k) + - "] = " + std::to_string(phases_[k]) + - "; must be +1 or -1"); - } - if (phases_[k] != 1) all_positive_ = false; - } - validate(); } const SparsePauliWord& MajoranaMapping::operator()(std::size_t k) const { @@ -187,15 +155,6 @@ const SparsePauliWord& MajoranaMapping::operator()(std::size_t k) const { return table_[k]; } -std::int8_t MajoranaMapping::phase(std::size_t k) const { - if (k >= phases_.size()) { - throw std::out_of_range("Majorana index " + std::to_string(k) + - " out of range [0, " + - std::to_string(phases_.size()) + ")"); - } - return phases_[k]; -} - std::pair, SparsePauliWord> MajoranaMapping::bilinear( std::size_t j, std::size_t k) const { const std::size_t n = table_.size(); @@ -211,79 +170,13 @@ std::pair, SparsePauliWord> MajoranaMapping::bilinear( std::to_string(j) + "); the bilinear i*gamma_j*gamma_k requires distinct indices."); } - // i * gamma_j * gamma_k - // = i * (phases_[j] * table_[j]) * (phases_[k] * table_[k]) - // = i * phases_[j] * phases_[k] * (table_[j] * table_[k]) + // i * gamma_j * gamma_k = i * (table_[j] * table_[k]) auto [pauli_phase, word] = PauliTermAccumulator::multiply_uncached(table_[j], table_[k]); - std::complex coeff = std::complex(0.0, 1.0) * pauli_phase * - static_cast(phases_[j]) * - static_cast(phases_[k]); + std::complex coeff = std::complex(0.0, 1.0) * pauli_phase; return {coeff, std::move(word)}; } -void MajoranaMapping::validate() const { - const std::size_t n = table_.size(); - constexpr double tol = 1e-12; - - for (std::size_t i = 0; i < n; ++i) { - for (std::size_t j = i; j < n; ++j) { - // gamma_k = phases_[k] * table_[k], so: - // gamma_i * gamma_j = phases_[i]*phases_[j] * table_[i]*table_[j] - auto [phase_ij, word_ij] = multiply_words(table_[i], table_[j]); - auto [phase_ji, word_ji] = multiply_words(table_[j], table_[i]); - - // Include the sign factors - double sign_ij = static_cast(phases_[i]) * phases_[j]; - - if (i == j) { - // gamma_i^2 = phases_[i]^2 * P_i^2 = 1 * P_i^2 - // P_i^2 must be I with phase +1 - if (!word_ij.empty()) { - std::ostringstream msg; - msg << "Clifford algebra validation failed: gamma_" << i - << " squared is not proportional to identity"; - throw std::invalid_argument(msg.str()); - } - // phase_ij should be +1 (Pauli string squares to +I only for I, XX, - // YY, ZZ on a single qubit gives -I for Y but the overall product - // should be +I for a valid Majorana operator) - if (std::abs(phase_ij - std::complex(1.0, 0.0)) > tol) { - std::ostringstream msg; - msg << "Clifford algebra validation failed: γ_" << i - << " squared gives phase " << phase_ij << " (expected +1)"; - throw std::invalid_argument(msg.str()); - } - } else { - // γ_i·γ_j + γ_j·γ_i = 0, so the two products must cancel. - // If words are different, both must have zero coefficient. - // If words are the same, phases must sum to zero. - if (word_ij == word_ji) { - auto sum = phase_ij + phase_ji; - if (std::abs(sum) > tol) { - std::ostringstream msg; - msg << "Clifford algebra validation failed: {γ_" << i << ", γ_" << j - << "} != 0 (anticommutator phase sum = " << sum << ")"; - throw std::invalid_argument(msg.str()); - } - } else { - // Different resulting words — each must be separately zero, - // which means either the mapping is invalid or the words cancel - // in a sum expression. For valid Majorana mappings from Clifford - // algebra homomorphisms, the products always yield the same word - // (possibly with different phase). If words differ, that's an error. - if (std::abs(phase_ij) > tol || std::abs(phase_ji) > tol) { - std::ostringstream msg; - msg << "Clifford algebra validation failed: {γ_" << i << ", γ_" << j - << "} produces non-cancelling terms with different Pauli words"; - throw std::invalid_argument(msg.str()); - } - } - } - } - } -} - std::size_t MajoranaMapping::compute_num_qubits( const std::vector& table) { std::uint64_t max_idx = 0; diff --git a/docs/source/user/comprehensive/data/majorana_mapping.rst b/docs/source/user/comprehensive/data/majorana_mapping.rst index bab9498d5..047884e5d 100644 --- a/docs/source/user/comprehensive/data/majorana_mapping.rst +++ b/docs/source/user/comprehensive/data/majorana_mapping.rst @@ -14,21 +14,14 @@ The :class:`~qdk_chemistry.data.MajoranaMapping` class encapsulates such an enco Bilinears as the unified primitive ---------------------------------- -Across every fermion-to-qubit encoding, the most general primitive that admits a Pauli-string image is the **bilinear** :math:`i\,\gamma_j\,\gamma_k`. -This is not a stylistic choice: bilinears generate the parity-even subalgebra of the Majorana Clifford algebra in *every* encoding, so any parity-conserving operator (Hamiltonian terms, BdG anomalous terms, density-density couplings) decomposes into ordered bilinear products. -Quartics and higher-degree even monomials are products of bilinears. - -By contrast, individual Majorana operators :math:`\gamma_k` only have a Pauli image in **Majorana-atomic** encodings. -For *redundant* encodings — Bravyi-Kitaev superfast, Verstraete-Cirac, Derby-Klassen compact, Setia-Whitfield, and the broader Majorana loop stabilizer code family — :math:`m > n` qubits represent :math:`n` modes, and a single :math:`\gamma_k` anticommutes with the total parity stabilizer; it has no representation in the codespace at all. +Across fermion-to-qubit encodings, the most general primitive that admits a Pauli-string image is the **bilinear** :math:`i\,\gamma_j\,\gamma_k`. +Bilinears generate the parity-even subalgebra of the Majorana Clifford algebra, so any parity-conserving operator decomposes into ordered bilinear products, and higher-degree even monomials are products of bilinears. +Individual Majorana operators :math:`\gamma_k` have a Pauli image only in **Majorana-atomic** encodings. The :class:`~qdk_chemistry.data.MajoranaMapping` API therefore exposes: - :py:meth:`~qdk_chemistry.data.MajoranaMapping.bilinear` — the unified primitive available on every encoding. -- :py:meth:`~qdk_chemistry.data.MajoranaMapping.majorana` — the additional capability that Majorana-atomic encodings provide; gated by :py:attr:`~qdk_chemistry.data.MajoranaMapping.is_majorana_atomic`. - -The current factory methods (Jordan-Wigner, Bravyi-Kitaev, parity, SCBK) are all Majorana-atomic, so both APIs are available and produce consistent results: :py:meth:`~qdk_chemistry.data.MajoranaMapping.bilinear` is computed on demand from the stored Majorana table. - -A common point of confusion: BdG (Bogoliubov-de Gennes) Hamiltonians contain anomalous terms like :math:`a_i^\dagger a_j^\dagger`. Despite breaking :math:`U(1)` particle number, these are parity-even bilinears in :math:`\gamma`'s and are representable on **any** backend, redundant or not. The cases that *require* a Majorana-atomic backend are single-Majorana observables (e.g. MZM measurements), state preparation by acting with a single :math:`a_j^\dagger` on the vacuum, and Bogoliubov quasiparticle operators viewed as observables. +- :py:meth:`~qdk_chemistry.data.MajoranaMapping.majorana` — the additional capability provided by Majorana-atomic encodings; gated by :py:attr:`~qdk_chemistry.data.MajoranaMapping.is_majorana_atomic`. Convention ~~~~~~~~~~ @@ -102,12 +95,6 @@ Alternatively, construct from mode pairs: mapping = MajoranaMapping.from_mode_pairs(...) -Validation ----------- - -At construction, the :class:`~qdk_chemistry.data.MajoranaMapping` validates that the provided table satisfies the Clifford algebra anti-commutation relations required for a valid Majorana-atomic fermion-to-qubit mapping. -Invalid tables raise an error immediately, preventing silent correctness issues downstream. - Serialization ------------- @@ -140,5 +127,3 @@ Further reading - :doc:`QubitMapper <../algorithms/qubit_mapper>`: The algorithm that consumes a ``MajoranaMapping`` to perform fermion-to-qubit transformations - :doc:`Design principles <../design/index>`: Data class design principles in QDK/Chemistry -- Chen, Xu, Boettcher, "Equivalence between fermion-to-qubit mappings in two spatial dimensions" — unified treatment of every fermion-to-qubit mapping as a homomorphism from the parity-even Majorana Clifford algebra into the Pauli group, modulo stabilizers. -- Jiang, Kalev, Mruczkiewicz, Neven, "Optimal fermion-to-qubit mapping via ternary trees" and the related Majorana loop stabilizer code literature — redundant encodings as stabilizer codes. diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index aa30e4934..e7ffeb77e 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -7,9 +7,7 @@ #include #include -#include #include -#include #include #include #include @@ -18,184 +16,18 @@ namespace py = pybind11; -namespace { - -/// Map dense little-endian Pauli char to op_type (0=I, 1=X, 2=Y, 3=Z). -std::uint8_t char_to_op(char c) { - switch (c) { - case 'I': - case 'i': - return 0; - case 'X': - case 'x': - return 1; - case 'Y': - case 'y': - return 2; - case 'Z': - case 'z': - return 3; - default: - throw std::invalid_argument(std::string("Invalid Pauli character '") + c + - "' — expected I, X, Y, or Z"); - } -} - -/// Convert a dense little-endian Pauli string (qubit 0 = rightmost char) -/// to a SparsePauliWord (sorted by qubit index, identities omitted). -qdk::chemistry::data::SparsePauliWord dense_le_to_sparse(const std::string& s) { - qdk::chemistry::data::SparsePauliWord word; - // Little-endian: qubit 0 is the rightmost character (index len-1). - std::size_t len = s.size(); - for (std::size_t i = 0; i < len; ++i) { - std::size_t qubit = len - 1 - i; // rightmost char = qubit 0 - std::uint8_t op = char_to_op(s[i]); - if (op != 0) { - word.emplace_back(static_cast(qubit), op); - } - } - // Sort by qubit index (required invariant) - std::sort(word.begin(), word.end()); - return word; -} - -/// Convert a SparsePauliWord to a dense little-endian string. -std::string sparse_to_dense_le( - const qdk::chemistry::data::SparsePauliWord& word, std::size_t num_qubits) { - // Build big-endian first (qubit 0 at index 0), then reverse - std::string result(num_qubits, 'I'); - for (const auto& [qubit, op_type] : word) { - if (qubit < num_qubits) { - switch (op_type) { - case 1: - result[qubit] = 'X'; - break; - case 2: - result[qubit] = 'Y'; - break; - case 3: - result[qubit] = 'Z'; - break; - default: - break; - } - } - } - // Reverse to get little-endian (qubit 0 = rightmost) - std::reverse(result.begin(), result.end()); - return result; -} - -} // namespace - void bind_majorana_mapping(pybind11::module& data) { using namespace qdk::chemistry::data; py::class_ mapping(data, "MajoranaMapping", R"( -Immutable data class describing a fermion-to-qubit encoding. - -The unified primitive across all fermion-to-qubit encodings is the -**bilinear** ``i·γ_j·γ_k``: the parity-even subalgebra of the Majorana -Clifford algebra is generated by these bilinears in every encoding, -even in redundant encodings (e.g. Bravyi-Kitaev superfast, -Verstraete-Cirac, Derby-Klassen) where individual Majoranas have no -Pauli image. See :py:meth:`bilinear`. - -For **Majorana-atomic** encodings (Jordan-Wigner, Bravyi-Kitaev tree, -parity, and their tapered variants) individual Majorana operators γ_k -additionally have a Pauli image, accessible via :py:meth:`majorana` -and the underlying ``table``. Every encoding currently constructible -through this class is Majorana-atomic; see :py:attr:`is_majorana_atomic`. - -The Clifford algebra ``{γ_i, γ_j} = 2δ_{ij}·I`` is validated at -construction. +Immutable fermion-to-qubit encoding. -Example: - >>> from qdk_chemistry._core.data import MajoranaMapping - >>> jw = MajoranaMapping.jordan_wigner(4) - >>> jw.num_modes - 4 - >>> jw.num_qubits - 4 - >>> coeff, word = jw.bilinear(0, 1) +Stores a 2N-entry table mapping each Majorana operator gamma_k to a sparse +Pauli word. The bilinear ``i*gamma_j*gamma_k`` is the unified primitive and +is computed on demand from the table. Sparse Pauli words use the little-endian +convention of QubitHamiltonian (qubit 0 has the smallest index). )"); - // Constructor from list of dense little-endian Pauli strings + optional - // phases - mapping.def( - py::init([](py::list table_list, const std::string& name, - std::vector phases) { - Py_ssize_t n = PyList_GET_SIZE(table_list.ptr()); - if (n == 0) { - throw py::value_error("MajoranaMapping table must not be empty"); - } - if (n % 2 != 0) { - throw py::value_error( - "MajoranaMapping table must have an even number of entries " - "(2 per fermionic mode), got " + - std::to_string(n)); - } - - Py_ssize_t expected_len = -1; - std::vector table; - table.reserve(static_cast(n)); - - for (Py_ssize_t i = 0; i < n; ++i) { - PyObject* item = PyList_GET_ITEM(table_list.ptr(), i); - if (!PyUnicode_Check(item)) { - throw py::value_error( - "MajoranaMapping table entries must be strings, got " + - std::string(Py_TYPE(item)->tp_name) + " at index " + - std::to_string(i)); - } - Py_ssize_t str_len; - const char* str_data = PyUnicode_AsUTF8AndSize(item, &str_len); - if (!str_data) { - throw py::value_error("Failed to decode string at index " + - std::to_string(i)); - } - - if (expected_len < 0) { - expected_len = str_len; - } else if (str_len != expected_len) { - throw py::value_error( - "All Pauli strings must have the same length. Entry 0 has " - "length " + - std::to_string(expected_len) + " but entry " + - std::to_string(i) + " has length " + std::to_string(str_len)); - } - - std::string s(str_data, static_cast(str_len)); - table.push_back(dense_le_to_sparse(s)); - } - - try { - return MajoranaMapping(std::move(table), name, std::move(phases)); - } catch (const std::invalid_argument& e) { - throw py::value_error(e.what()); - } - }), - py::arg("table"), py::arg("name") = "", - py::arg("phases") = std::vector{}, - R"( -Construct a MajoranaMapping from a list of dense Pauli-string labels. - -Each string uses the same little-endian convention as QubitHamiltonian: -qubit 0 is the rightmost character. The list must have 2N entries (one -per Majorana operator), where N is the number of fermionic modes. - -The Clifford algebra {γ_i, γ_j} = 2δ_{ij}·I is validated at construction. - -Args: - table: List of 2N Pauli strings (e.g., ["IX", "IY", "XZ", "YZ"]). - name: Optional human-readable label for the encoding. - -Raises: - ValueError: If the table is invalid (wrong size, bad characters, or - Clifford algebra violation). -)"); - - // Constructor from list of SparsePauliWord (for advanced use) mapping.def(py::init([](const std::vector& table, const std::string& name) { try { @@ -205,21 +37,8 @@ The Clifford algebra {γ_i, γ_j} = 2δ_{ij}·I is validated at construction. } }), py::arg("table"), py::arg("name") = "", - R"( -Construct a MajoranaMapping from a list of sparse Pauli words. + "Construct from a list of 2N sparse Pauli words."); -Each sparse word is a list of (qubit_index, op_type) tuples. This is the -advanced constructor for users who want to avoid string parsing. - -Args: - table: List of 2N sparse Pauli words. - name: Optional human-readable label for the encoding. - -Raises: - ValueError: If the table is invalid. -)"); - - // Properties mapping.def_property_readonly( "num_modes", [](const MajoranaMapping& self) { return self.num_modes(); }, "Number of fermionic modes (spin-orbitals)."); @@ -231,55 +50,17 @@ advanced constructor for users who want to avoid string parsing. mapping.def_property_readonly( "name", [](const MajoranaMapping& self) { return self.name(); }, - "Human-readable name of the encoding."); + "Encoding name (may be empty)."); - // table property: return as list of dense little-endian strings mapping.def_property_readonly( - "table", - [](const MajoranaMapping& self) -> py::tuple { - std::size_t nq = self.num_qubits(); - py::tuple result(self.table().size()); - for (std::size_t i = 0; i < self.table().size(); ++i) { - result[i] = py::cast(sparse_to_dense_le(self.table()[i], nq)); - } - return result; - }, - "Tuple of dense little-endian Pauli strings (qubit 0 = rightmost)."); - - // sparse_table property: return as list of SparsePauliWord - mapping.def_property_readonly( - "sparse_table", [](const MajoranaMapping& self) { return self.table(); }, - "List of sparse Pauli words [(qubit_idx, op_type), ...]."); - - // phases property - mapping.def_property_readonly( - "phases", - [](const MajoranaMapping& self) -> py::tuple { - py::tuple result(self.phases().size()); - for (std::size_t i = 0; i < self.phases().size(); ++i) { - result[i] = py::cast(static_cast(self.phases()[i])); - } - return result; - }, - "Tuple of per-entry sign factors (+1 or -1)."); - - mapping.def_property_readonly( - "all_phases_positive", - [](const MajoranaMapping& self) { return self.all_phases_positive(); }, - "True if all phases are +1 (standard encodings)."); + "table", [](const MajoranaMapping& self) { return self.table(); }, + "List of 2N sparse Pauli words [(qubit_idx, op_type), ...]."); mapping.def_property_readonly( "is_majorana_atomic", [](const MajoranaMapping& self) { return self.is_majorana_atomic(); }, - R"( -True if individual Majorana operators γ_k have a Pauli image under -this encoding (i.e. :py:meth:`majorana` is callable). Always True for -encodings constructible through this class today; future redundant -encodings will return False, in which case only :py:meth:`bilinear` -is available. -)"); + "True if individual Majorana operators have a Pauli image."); - // __call__ for γ_k lookup mapping.def( "__call__", [](const MajoranaMapping& self, std::size_t k) -> SparsePauliWord { @@ -289,60 +70,26 @@ is available. throw py::index_error(e.what()); } }, - py::arg("k"), - R"( -Look up the sparse Pauli word for Majorana operator γ_k. - -Args: - k: Majorana index (0 ≤ k < 2N). + py::arg("k"), "Sparse Pauli word for Majorana operator gamma_k."); -Returns: - Sparse Pauli word as list of (qubit_index, op_type) tuples. - -Raises: - IndexError: If k is out of range. -)"); - - // majorana(k) — named alias for __call__, returning a dense LE string mapping.def( "majorana", - [](const MajoranaMapping& self, std::size_t k) -> std::string { + [](const MajoranaMapping& self, std::size_t k) -> SparsePauliWord { try { - return sparse_to_dense_le(self.majorana(k), self.num_qubits()); + return self.majorana(k); } catch (const std::out_of_range& e) { throw py::index_error(e.what()); } }, - py::arg("k"), - R"( -Pauli image of the Majorana operator γ_k as a dense little-endian -Pauli string. - -Available only for :py:attr:`is_majorana_atomic` encodings. The -returned string uses the encoding's native (pre-taper) qubit count -``len(table[0])``; tapering, if any, is applied by downstream -mappers and is not reflected here. - -Args: - k: Majorana index (0 ≤ k < 2N). + py::arg("k"), "Sparse Pauli word for Majorana operator gamma_k."); -Returns: - Dense little-endian Pauli string (qubit 0 = rightmost char) of - length ``len(table[0])``. - -Raises: - IndexError: If k is out of range. -)"); - - // bilinear(j, k) — Pauli image of i·γ_j·γ_k as (coefficient, dense LE string) mapping.def( "bilinear", [](const MajoranaMapping& self, std::size_t j, std::size_t k) -> py::tuple { try { auto [coeff, word] = self.bilinear(j, k); - std::string dense = sparse_to_dense_le(word, self.num_qubits()); - return py::make_tuple(py::cast(coeff), py::cast(dense)); + return py::make_tuple(py::cast(coeff), py::cast(word)); } catch (const std::invalid_argument& e) { throw py::value_error(e.what()); } catch (const std::out_of_range& e) { @@ -350,54 +97,8 @@ mappers and is not reflected here. } }, py::arg("j"), py::arg("k"), - R"( -Pauli image of the bilinear ``i·γ_j·γ_k``. - -Returns ``(coeff, pauli_str)`` such that the Pauli operator -``coeff * pauli_str`` equals ``i·γ_j·γ_k`` in the encoded qubit -representation. Defined for all distinct ``j != k`` in -``[0, 2 * num_modes)``. - -For the encodings produced by the factory methods the returned -coefficient is always real (±1), since ``i·γ_j·γ_k`` is Hermitian for -``j != k``. The return type is :class:`complex` for generality and -consistency with :py:meth:`PauliTermAccumulator.multiply_uncached`. - -Bilinears commute with the total fermion parity and with all -stabilizers of the codespace, so they are the most general primitive -usable across Majorana-atomic and redundant encodings alike. - -The returned Pauli string uses the encoding's native (pre-taper) -qubit count ``len(table[0])``; tapering, if any, is applied by -downstream mappers and is not reflected here. - -Args: - j: First Majorana index (0 ≤ j < 2N). - k: Second Majorana index (0 ≤ k < 2N), must differ from j. - -Returns: - Tuple ``(coeff, pauli_str)`` where ``coeff`` is a Python complex - and ``pauli_str`` is the dense little-endian Pauli string (qubit - 0 = rightmost char) of length ``len(table[0])``. - -Raises: - IndexError: If j or k is out of range. - ValueError: If j == k. -)"); - - // validate() - mapping.def( - "validate", - [](const MajoranaMapping& self) { - try { - self.validate(); - } catch (const std::invalid_argument& e) { - throw py::value_error(e.what()); - } - }, - "Validate the Clifford algebra anticommutation relations."); + "Pauli image (coeff, word) of the bilinear i*gamma_j*gamma_k."); - // __repr__ mapping.def("__repr__", [](const MajoranaMapping& self) -> std::string { std::string repr = "MajoranaMapping("; if (!self.name().empty()) { @@ -408,7 +109,6 @@ downstream mappers and is not reflected here. return repr; }); - // Factory methods mapping.def_static( "jordan_wigner", [](std::size_t num_modes) { @@ -418,16 +118,7 @@ downstream mappers and is not reflected here. throw py::value_error(e.what()); } }, - py::arg("num_modes"), - R"( -Construct a Jordan-Wigner encoding. - -Args: - num_modes: Number of fermionic modes (spin-orbitals). - -Returns: - MajoranaMapping with name "jordan-wigner". -)"); + py::arg("num_modes"), "Construct a Jordan-Wigner encoding."); mapping.def_static( "bravyi_kitaev", @@ -438,16 +129,7 @@ Construct a Jordan-Wigner encoding. throw py::value_error(e.what()); } }, - py::arg("num_modes"), - R"( -Construct a Bravyi-Kitaev encoding. - -Args: - num_modes: Number of fermionic modes (spin-orbitals). - -Returns: - MajoranaMapping with name "bravyi-kitaev". -)"); + py::arg("num_modes"), "Construct a Bravyi-Kitaev encoding."); mapping.def_static( "bravyi_kitaev_tree", @@ -459,21 +141,7 @@ Construct a Bravyi-Kitaev encoding. } }, py::arg("num_modes"), - R"( -Construct a balanced binary tree Bravyi-Kitaev encoding. - -Implementation from arXiv:1701.07072 (Havlíček et al.). Unlike the -Fenwick-tree BK (``bravyi_kitaev()``), the balanced tree variant has -guaranteed Z₂ symmetries at qubit positions (n/2-1) and (n-1) for any -even n, making it the required base encoding for symmetry-conserving -BK (SCBK) tapering. - -Args: - num_modes: Number of fermionic modes (spin-orbitals). - -Returns: - MajoranaMapping with name "bravyi-kitaev-tree". -)"); + "Construct a balanced binary-tree Bravyi-Kitaev encoding."); mapping.def_static( "parity", @@ -484,18 +152,8 @@ BK (SCBK) tapering. throw py::value_error(e.what()); } }, - py::arg("num_modes"), - R"( -Construct a parity encoding. - -Args: - num_modes: Number of fermionic modes (spin-orbitals). + py::arg("num_modes"), "Construct a parity encoding."); -Returns: - MajoranaMapping with name "parity". -)"); - - // ─── majorana_map_hamiltonian free function ──────────────────────── data.def( "majorana_map_hamiltonian", [](const MajoranaMapping& mapping, double core_energy, @@ -508,46 +166,24 @@ Construct a parity encoding. eri_aabb, py::array_t eri_bbbb, - std::size_t n_spatial, bool is_restricted, double threshold, + std::size_t n_spatial, bool is_spin_free, double threshold, double integral_threshold) -> py::tuple { auto result = majorana_map_hamiltonian( mapping, core_energy, h1_alpha.data(), h1_beta.data(), eri_aaaa.data(), eri_aabb.data(), eri_bbbb.data(), n_spatial, - is_restricted, threshold, integral_threshold); - - // Convert to Python: (list[str], list[complex]) - py::list py_strings; - py::list py_coeffs; - for (std::size_t i = 0; i < result.pauli_strings.size(); ++i) { - py_strings.append(result.pauli_strings[i]); - py_coeffs.append(result.coefficients[i]); - } - return py::make_tuple(py_strings, py_coeffs); + is_spin_free, threshold, integral_threshold); + return py::make_tuple(py::cast(result.words), + py::cast(result.coefficients)); }, py::arg("mapping"), py::arg("core_energy"), py::arg("h1_alpha"), py::arg("h1_beta"), py::arg("eri_aaaa"), py::arg("eri_aabb"), - py::arg("eri_bbbb"), py::arg("n_spatial"), py::arg("is_restricted"), + py::arg("eri_bbbb"), py::arg("n_spatial"), py::arg("is_spin_free"), py::arg("threshold"), py::arg("integral_threshold"), R"( Map a fermionic Hamiltonian to qubit Pauli terms using Majorana loops. -This is the encoding-agnostic C++ mapping engine. It takes integrals -and a MajoranaMapping, and returns (pauli_strings, coefficients). - -Args: - mapping: The MajoranaMapping encoding. - core_energy: Core energy (nuclear repulsion + frozen core). - h1_alpha: One-body integrals for alpha spin (n_spatial × n_spatial, flattened). - h1_beta: One-body integrals for beta spin (n_spatial × n_spatial, flattened). - eri_aaaa: Two-body integrals (αα|αα) (n_spatial⁴, flattened). - eri_aabb: Two-body integrals (αα|ββ) (n_spatial⁴, flattened). - eri_bbbb: Two-body integrals (ββ|ββ) (n_spatial⁴, flattened). - n_spatial: Number of spatial orbitals. - is_restricted: Whether the system is spin-free (h1_alpha == h1_beta). - threshold: Drop Pauli terms with |coeff| < threshold. - integral_threshold: Skip integrals with |value| < this. - -Returns: - Tuple of (list[str], list[complex]) with Pauli strings and coefficients. +Limitation: only valid for spin-free Hamiltonians (spin-independent +integrals). Returns ``(words, coefficients)`` where ``words`` is a list of +sparse Pauli words. )"); } diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index cb733848e..8fbc92d79 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -20,6 +20,7 @@ from qdk_chemistry._core.data import majorana_map_hamiltonian from qdk_chemistry.algorithms.qubit_mapper.qubit_mapper import QubitMapper, QubitMapperSettings from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder +from qdk_chemistry.data.majorana_mapping import _sparse_to_dense_le from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian from qdk_chemistry.utils import Logger @@ -142,7 +143,8 @@ def _run_impl( h2_aaaa, h2_aabb, h2_bbbb = hamiltonian.get_two_body_integrals() n_spatial = h1_alpha.shape[0] n_spin_orbitals = 2 * n_spatial - is_restricted = hamiltonian.get_orbitals().is_restricted() + # Restricted orbitals imply spin-free integrals (h_alpha == h_beta). + is_spin_free = hamiltonian.get_orbitals().is_restricted() if base_mapping.num_modes != n_spin_orbitals: raise ValueError( @@ -152,17 +154,17 @@ def _run_impl( ) # Use ravel() instead of flatten() to avoid copying contiguous arrays. - # For restricted Hamiltonians the containers share the same two-body + # For spin-free Hamiltonians the containers share the same two-body # vector across aaaa/aabb/bbbb, so pass the same array to avoid # materializing unused copies. h1_a_flat = np.ascontiguousarray(h1_alpha).ravel() - h1_b_flat = h1_a_flat if is_restricted else np.ascontiguousarray(h1_beta).ravel() + h1_b_flat = h1_a_flat if is_spin_free else np.ascontiguousarray(h1_beta).ravel() h2_aaaa_flat = np.ascontiguousarray(h2_aaaa).ravel() - h2_aabb_flat = h2_aaaa_flat if is_restricted else np.ascontiguousarray(h2_aabb).ravel() - h2_bbbb_flat = h2_aaaa_flat if is_restricted else np.ascontiguousarray(h2_bbbb).ravel() + h2_aabb_flat = h2_aaaa_flat if is_spin_free else np.ascontiguousarray(h2_aabb).ravel() + h2_bbbb_flat = h2_aaaa_flat if is_spin_free else np.ascontiguousarray(h2_bbbb).ravel() - # Single C++ call: Majorana-loop engine builds all Pauli terms - pauli_strings, coefficients = majorana_map_hamiltonian( + # Single C++ call: Majorana-loop engine builds all Pauli terms as sparse words + words, coefficients = majorana_map_hamiltonian( base_mapping.core, 0.0, # core energy not included (QDK convention) h1_a_flat, @@ -171,15 +173,19 @@ def _run_impl( h2_aabb_flat, h2_bbbb_flat, n_spatial, - is_restricted, + is_spin_free, threshold, integral_threshold, ) + # Render sparse words into dense little-endian strings (Python owns string form) + n_qubits = base_mapping.num_qubits + pauli_strings = [_sparse_to_dense_le(word, n_qubits) for word in words] + Logger.debug(f"Generated {len(pauli_strings)} Pauli terms for {2 * n_spatial} qubits") qh = QubitHamiltonian( - pauli_strings=list(pauli_strings), + pauli_strings=pauli_strings, coefficients=np.array(coefficients, dtype=complex), encoding=base_mapping.base_encoding, fermion_mode_order=FermionModeOrder.BLOCKED, diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index a7b162473..d0fc87636 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -1,28 +1,13 @@ """Majorana-to-Pauli mapping data class for fermion-to-qubit encodings. -This module provides the :class:`MajoranaMapping` data class, which describes -a fermion-to-qubit encoding. The unified primitive across all f2q encodings -is the **bilinear** ``i·gamma_j·gamma_k`` (see :py:meth:`MajoranaMapping.bilinear`): -the parity-even subalgebra of the Majorana Clifford algebra is generated by -these bilinears in every encoding, including redundant encodings (e.g. -Bravyi-Kitaev superfast, Verstraete-Cirac, Derby-Klassen) where individual -Majoranas have no Pauli image. - -Standard **Majorana-atomic** encodings (Jordan-Wigner, Bravyi-Kitaev tree, -parity, and their tapered variants) additionally provide a Pauli image for -each individual Majorana operator gamma_k via the ``table`` and -:py:meth:`MajoranaMapping.majorana` interfaces. The factory class methods -on :class:`MajoranaMapping` produce these Majorana-atomic encodings; custom -Majorana-atomic encodings can be constructed from a Pauli table. - -References: - * Chen, Xu, Boettcher, "Equivalence between fermion-to-qubit mappings in - two spatial dimensions" (2023) — every f2q mapping as a homomorphism - from the parity-even Majorana Clifford algebra into the Pauli group, - modulo stabilizers. - * Jiang, Kalev, Mruczkiewicz, Neven, "Optimal fermion-to-qubit mapping - via ternary trees..." (2020), and the Majorana loop stabilizer code - literature. +This module provides the :class:`MajoranaMapping` data class, which describes a +fermion-to-qubit encoding. It stores a 2N-entry table mapping each Majorana +operator gamma_k to a Pauli word; the bilinear ``i*gamma_j*gamma_k`` is the +unified primitive and is computed on demand from the table. + +Pauli strings use the same little-endian convention as +:class:`~qdk_chemistry.data.QubitHamiltonian`: qubit 0 is the rightmost +character. """ @@ -48,29 +33,44 @@ __all__: list[str] = [] +# Sparse op_type codes used by the C++ layer: 1=X, 2=Y, 3=Z (0/identity omitted). +_CHAR_TO_OP = {"I": 0, "X": 1, "Y": 2, "Z": 3} +_OP_TO_CHAR = {1: "X", 2: "Y", 3: "Z"} + +SparsePauliWord = list[tuple[int, int]] -class MajoranaMapping(DataClass): - """Immutable data class describing a fermion-to-qubit encoding. - The unified primitive across all f2q encodings is the **bilinear** - ``i·gamma_j·gamma_k``, accessible via :py:meth:`bilinear`. Bilinears commute with - the total fermion parity and with all stabilizers of the codespace, so - they always have a Pauli image — including in redundant encodings where - individual Majoranas do not. +def _dense_le_to_sparse(label: str) -> SparsePauliWord: + """Convert a dense little-endian Pauli string to a sparse Pauli word.""" + n = len(label) + word: SparsePauliWord = [] + for i, char in enumerate(label): + try: + op = _CHAR_TO_OP[char.upper()] + except KeyError: + raise ValueError(f"Invalid Pauli character {char!r}; expected I, X, Y, or Z") from None + if op: + word.append((n - 1 - i, op)) + word.sort() + return word - For **Majorana-atomic** encodings (Jordan-Wigner, Bravyi-Kitaev tree, - parity, and their tapered variants) individual Majoranas gamma_k additionally - have a Pauli image accessible via :py:meth:`majorana` and the - ``table`` attribute. Every encoding currently constructible through - this class is Majorana-atomic; see :py:attr:`is_majorana_atomic`. - A ``MajoranaMapping`` constructed from a 2N-entry table stores those - entries as the per-Majorana Pauli image; the bilinear ``i·gamma_j·gamma_k`` is - computed on demand from the table. +def _sparse_to_dense_le(word: SparsePauliWord, num_qubits: int) -> str: + """Convert a sparse Pauli word to a dense little-endian string.""" + chars = ["I"] * num_qubits + for qubit, op in word: + if qubit < num_qubits: + chars[num_qubits - 1 - qubit] = _OP_TO_CHAR[op] + return "".join(chars) - The mapping is validated at construction time by checking the Clifford - algebra anticommutation relations: ``{gamma_i, gamma_j} = 2δ_{ij} · I``. - This guarantees that the mapping defines a valid Majorana-atomic encoding. + + +class MajoranaMapping(DataClass): + """Immutable data class describing a fermion-to-qubit encoding. + + Stores a 2N-entry table mapping each Majorana operator gamma_k to a Pauli + word. The bilinear ``i*gamma_j*gamma_k`` is the unified primitive and is + computed on demand from the table, accessible via :py:meth:`bilinear`. Pauli strings use the same **little-endian** convention as :class:`~qdk_chemistry.data.QubitHamiltonian`: qubit 0 is the rightmost @@ -81,7 +81,7 @@ class MajoranaMapping(DataClass): num_modes (int): Number of fermionic modes (spin-orbitals), N. num_qubits (int): Effective number of qubits after any tapering. name (str): Human-readable name of the encoding (may be empty for custom mappings). - is_majorana_atomic (bool): True if individual gamma_k have a Pauli image (always True today). + is_majorana_atomic (bool): True if individual gamma_k have a Pauli image. Examples: Built-in encodings: @@ -111,7 +111,6 @@ def __init__( self, table: list[str] | tuple[str, ...], name: str = "", - phases: list[int] | tuple[int, ...] | None = None, tapering: TaperingSpecification | None = None, *, _core: _CoreMajoranaMapping | None = None, @@ -121,25 +120,25 @@ def __init__( Args: table (list[str] | tuple[str, ...]): 2N Pauli strings in little-endian format (qubit 0 = rightmost char). name (str): Optional human-readable label for the encoding. Default ``""``. - phases (list[int] | None): Optional 2N sign factors (+1 or -1) per Majorana operator. Default all +1. tapering (TaperingSpecification | None): Optional post-mapping tapering specification. Raises: - ValueError: If the table is invalid (wrong size, bad characters, or Clifford algebra violation). + ValueError: If the table is invalid (empty, odd size, or bad characters). """ if _core is not None: self._core = _core else: - self._core = _CoreMajoranaMapping(list(table), name, list(phases) if phases else []) + if table and len({len(label) for label in table}) > 1: + raise ValueError("All Pauli strings must have the same length") + sparse_table = [_dense_le_to_sparse(label) for label in table] + self._core = _CoreMajoranaMapping(sparse_table, name) # Cache immutable properties from the core - self._table = self._core.table - # Allow Python-level name override (e.g. SCBK wraps a BK core) self._name = name if name else self._core.name self._num_modes = self._core.num_modes self._num_qubits = self._core.num_qubits - self._phases = self._core.phases + self._table = tuple(_sparse_to_dense_le(word, self._num_qubits) for word in self._core.table) self._tapering = tapering # Mark immutable @@ -173,11 +172,6 @@ def name(self) -> str: """Human-readable name of the encoding (may be empty for custom mappings).""" return self._name - @property - def phases(self) -> tuple[int, ...]: - """Tuple of per-entry sign factors (+1 or -1). All +1 for standard encodings.""" - return self._phases - @property def tapering(self) -> TaperingSpecification | None: """Post-mapping tapering specification, or None for untapered encodings.""" @@ -186,10 +180,10 @@ def tapering(self) -> TaperingSpecification | None: def without_tapering(self) -> MajoranaMapping: """Return a copy of this mapping with tapering stripped. - The returned mapping has the same Majorana table and phases but - ``tapering=None`` and ``name`` set to :attr:`base_encoding`. - This is used by :class:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapper` - to separate the base encoding from post-mapping tapering. + The returned mapping has the same Majorana table but ``tapering=None`` + and ``name`` set to :attr:`base_encoding`. Used by + :class:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapper` to separate + the base encoding from post-mapping tapering. Returns: A new :class:`MajoranaMapping` with no tapering. @@ -201,26 +195,13 @@ def without_tapering(self) -> MajoranaMapping: def base_encoding(self) -> str: """The base encoding name used for the Majorana-to-Pauli table. - For standard encodings this equals :attr:`name`. For tapering-based - encodings like symmetry-conserving Bravyi-Kitaev, this returns the - underlying encoding (e.g. ``"bravyi-kitaev-tree"``) while - :attr:`name` returns the final encoding label - (e.g. ``"symmetry-conserving-bravyi-kitaev"``). - - .. important:: - - **Plugin dispatch key.** Name-dispatched - :class:`~qdk_chemistry.algorithms.QubitMapper` backends - (OpenFermion, Qiskit) use this property — not :attr:`name` — to - select the third-party transform function. Those backends - ignore :attr:`table` entirely and rebuild the qubit operator - from scratch. Consistency between this name and the table - contents is guaranteed for factory-produced mappings and - verified by cross-backend tests, but is **not** checked at - runtime. See - :class:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapper` - for the full dispatch contract. + For standard encodings this equals :attr:`name`. For tapering-based + encodings it returns the underlying encoding (e.g. + ``"bravyi-kitaev-tree"``) while :attr:`name` returns the final label. + Name-dispatched :class:`~qdk_chemistry.algorithms.QubitMapper` backends + use this property to select the third-party transform; they ignore + :attr:`table` and rebuild the qubit operator from scratch. """ return self._core.name @@ -233,25 +214,20 @@ def core(self) -> _CoreMajoranaMapping: def is_majorana_atomic(self) -> bool: """True if individual Majorana operators gamma_k have a Pauli image. - Always ``True`` for encodings constructible through this class today - — they are all Majorana-atomic and expose :py:meth:`majorana`. - Future redundant encodings (e.g. Bravyi-Kitaev superfast, - Verstraete-Cirac, Derby-Klassen) would return ``False``; for those, - only :py:meth:`bilinear` is available. + When ``True``, :py:meth:`majorana` is available; otherwise only + :py:meth:`bilinear` is. """ return bool(self._core.is_majorana_atomic) def majorana(self, k: int) -> str: """Return the Pauli image of the Majorana operator gamma_k. - Available only for :py:attr:`is_majorana_atomic` encodings (always - true for current encodings). For tapered encodings the returned - Pauli string is in the encoding's native (pre-taper) qubit basis, - i.e. of length ``len(self.table[0])``; tapering, if any, is - applied downstream by the qubit mapper. + Available only for :py:attr:`is_majorana_atomic` encodings. For tapered + encodings the returned Pauli string is in the encoding's native + (pre-taper) qubit basis; tapering is applied downstream. Args: - k (int): Majorana index (0 ≤ k < 2 * num_modes). + k (int): Majorana index (0 <= k < 2 * num_modes). Returns: str: Dense little-endian Pauli string (qubit 0 = rightmost char). @@ -260,33 +236,23 @@ def majorana(self, k: int) -> str: IndexError: If k is out of range. """ - return self._core.majorana(k) + return _sparse_to_dense_le(self._core.majorana(k), self._num_qubits) def bilinear(self, j: int, k: int) -> tuple[complex, str]: - """Return the Pauli image of the bilinear ``i·gamma_j·gamma_k``. - - Returns ``(coeff, pauli_str)`` such that the Pauli operator - ``coeff * pauli_str`` equals ``i·gamma_j·gamma_k`` in the encoded qubit - representation. Defined for all distinct ``j != k`` in - ``[0, 2 * num_modes)``. - - For the Majorana-atomic encodings produced by the factory methods - the returned coefficient is always real (±1), since ``i·gamma_j·gamma_k`` - is Hermitian for ``j != k``. The return type is :class:`complex` - for generality. + """Return the Pauli image of the bilinear ``i*gamma_j*gamma_k``. - Bilinears commute with the total fermion parity and with all - stabilizers of the codespace, so they are the most general - primitive usable across Majorana-atomic and redundant encodings. + Returns ``(coeff, pauli_str)`` such that ``coeff * pauli_str`` equals + ``i*gamma_j*gamma_k`` in the encoded qubit representation. Defined for + all distinct ``j != k`` in ``[0, 2 * num_modes)``. The coefficient is + real (±1) for the factory encodings but typed :class:`complex` for + generality. - For tapered encodings the returned Pauli string is in the - encoding's native (pre-taper) qubit basis, i.e. of length - ``len(self.table[0])``; tapering, if any, is applied downstream - by the qubit mapper. + For tapered encodings the returned Pauli string is in the encoding's + native (pre-taper) qubit basis; tapering is applied downstream. Args: - j (int): First Majorana index (0 ≤ j < 2 * num_modes). - k (int): Second Majorana index (0 ≤ k < 2 * num_modes), must differ from j. + j (int): First Majorana index (0 <= j < 2 * num_modes). + k (int): Second Majorana index (0 <= k < 2 * num_modes), must differ from j. Returns: tuple[complex, str]: ``(coeff, pauli_str)`` in dense little-endian form. @@ -296,7 +262,8 @@ def bilinear(self, j: int, k: int) -> tuple[complex, str]: ValueError: If j == k. """ - return self._core.bilinear(j, k) + coeff, word = self._core.bilinear(j, k) + return coeff, _sparse_to_dense_le(word, self._num_qubits) @classmethod def jordan_wigner(cls, num_modes: int) -> MajoranaMapping: @@ -438,7 +405,7 @@ def from_mode_pairs( MajoranaMapping: The constructed mapping. Raises: - ValueError: If pairs are invalid or Clifford algebra validation fails. + ValueError: If the table is invalid (empty, odd size, or bad characters). """ table: list[str] = [] @@ -478,9 +445,6 @@ def to_json(self) -> dict[str, Any]: "table": list(self._table), "name": self._name, } - # Only include phases if any are non-default (-1) - if any(p != 1 for p in self._phases): - data["phases"] = list(self._phases) if self._tapering is not None: data["tapering"] = self._tapering.to_json() return self._add_json_version(data) @@ -502,7 +466,6 @@ def from_json(cls, json_data: dict[str, Any]) -> MajoranaMapping: return cls( table=json_data["table"], name=json_data.get("name", ""), - phases=json_data.get("phases"), tapering=tapering, ) @@ -517,8 +480,6 @@ def to_hdf5(self, group: h5py.Group) -> None: group.attrs["name"] = self._name group.attrs["num_modes"] = self._num_modes group.create_dataset("table", data=np.array(list(self._table), dtype="S")) - if any(p != 1 for p in self._phases): - group.create_dataset("phases", data=np.array(self._phases, dtype=np.int8)) if self._tapering is not None: tg = group.create_group("tapering") tg.create_dataset("qubit_indices", data=np.array(self._tapering.qubit_indices, dtype=np.int64)) @@ -542,9 +503,6 @@ def from_hdf5(cls, group: h5py.Group) -> MajoranaMapping: name = group.attrs.get("name", "") if isinstance(name, bytes): name = name.decode("utf-8") - phases = None - if "phases" in group: - phases = [int(p) for p in group["phases"][()]] tapering = None if "tapering" in group: tg = group["tapering"] @@ -557,7 +515,7 @@ def from_hdf5(cls, group: h5py.Group) -> MajoranaMapping: source_num_qubits=int(tg.attrs["source_num_qubits"]), source_encoding=src_enc, ) - return cls(table=table, name=name, phases=phases, tapering=tapering) + return cls(table=table, name=name, tapering=tapering) def __repr__(self) -> str: """Return a repr string.""" diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index d152979fb..0fba1f18e 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -199,12 +199,6 @@ def test_inconsistent_lengths(self) -> None: with pytest.raises(ValueError, match="same length"): MajoranaMapping(table=["IX", "IYZ", "XZ", "YZ"]) - def test_clifford_violation(self) -> None: - """Table violating Clifford algebra raises ValueError.""" - # gamma_0 = gamma_1 = IX → they commute (shouldn't) - with pytest.raises(ValueError, match="Clifford"): - MajoranaMapping(table=["IX", "IX", "XZ", "YZ"]) - def test_zero_modes(self) -> None: """Zero modes raises ValueError in factories.""" with pytest.raises(ValueError, match="num_modes"): @@ -301,7 +295,7 @@ def test_custom_serialization(self) -> None: assert loaded.name == "my-custom" def test_hdf5_round_trip_with_tapering(self) -> None: - """HDF5 round-trip preserves phases and tapering for SCBK mappings.""" + """HDF5 round-trip preserves tapering for SCBK mappings.""" from qdk_chemistry.data import Symmetries # noqa: PLC0415 scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) @@ -312,7 +306,6 @@ def test_hdf5_round_trip_with_tapering(self) -> None: loaded = MajoranaMapping.from_hdf5(hf) assert loaded.table == scbk.table assert loaded.name == scbk.name - assert loaded.phases == scbk.phases assert loaded.tapering is not None assert loaded.tapering.qubit_indices == scbk.tapering.qubit_indices assert loaded.tapering.eigenvalues == scbk.tapering.eigenvalues diff --git a/python/tests/test_qdk_qubit_mapper.py b/python/tests/test_qdk_qubit_mapper.py index 33c95566c..b13adead9 100644 --- a/python/tests/test_qdk_qubit_mapper.py +++ b/python/tests/test_qdk_qubit_mapper.py @@ -545,8 +545,8 @@ def test_parity_number_operator_structure(self) -> None: I_n = np.eye(2**n) for j in range(n): - g0 = pauli_to_sparse_matrix([mapping.table[2 * j]], np.array([mapping.phases[2 * j]])).toarray() - g1 = pauli_to_sparse_matrix([mapping.table[2 * j + 1]], np.array([mapping.phases[2 * j + 1]])).toarray() + g0 = pauli_to_sparse_matrix([mapping.table[2 * j]], np.array([1.0])).toarray() + g1 = pauli_to_sparse_matrix([mapping.table[2 * j + 1]], np.array([1.0])).toarray() nj = (I_n + 1j * g0 @ g1) / 2 # Build expected Z structure: Z_0 for j=0, Z_{j-1}·Z_j for j≥1 @@ -1209,7 +1209,7 @@ def test_bk_tree_clifford_algebra(self) -> None: for n in (4, 6, 8): mapping = MajoranaMapping.bravyi_kitaev_tree(n) gammas = [ - pauli_to_sparse_matrix([mapping.table[k]], np.array([mapping.phases[k]])).toarray() + pauli_to_sparse_matrix([mapping.table[k]], np.array([1.0])).toarray() for k in range(2 * n) ] for i in range(2 * n): From 1ec50c19d236b5b2caf76de2a596b164883cd037 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 30 May 2026 00:02:17 +0000 Subject: [PATCH 087/117] refactor: engine code quality + bilinear-only constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine: - Replace anonymous namespaces with namespace detail - Snake-case constants (excitation_coeff, inline 0.25/0.0625) - Portable C++20: std::popcount, std::countr_zero (no __builtin_) - Use utils::hash_combine instead of ad-hoc hash - Dispatch table covers NW 1..16 (up to 1024 qubits); error beyond - Merge αβ/βα cross-spin loops via Coulomb symmetry (pq|rs)=(rs|pq) Terminology: - Rename is_spin_free → spin_symmetric throughout (C++, pybind, Python) - Spin-free = Hamiltonian has no spin operators (always true for standard electronic H). Restricted/unrestricted = orbital basis choice. The flag controls the spin-summed fast path, which requires spin-symmetric integrals — a consequence of restricted orbitals. MajoranaMapping: - Add from_bilinears() factory for bilinear-only encodings (C++, pybind, Python). Supports encodings where individual Majoranas have no Pauli image; only bilinear(j,k) is available. - Minor doc updates for the two construction forms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../qdk/chemistry/data/majorana_mapping.hpp | 86 ++++++-- .../chemistry/data/majorana_map_engine.cpp | 208 ++++++++---------- .../qdk/chemistry/data/majorana_mapping.cpp | 95 +++++++- .../comprehensive/data/majorana_mapping.rst | 7 +- python/src/pybind11/data/majorana_mapping.cpp | 35 ++- .../qubit_mapper/qdk_qubit_mapper.py | 24 +- .../qdk_chemistry/data/majorana_mapping.py | 77 ++++++- 7 files changed, 364 insertions(+), 168 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index 294bae093..803c252be 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -15,9 +15,18 @@ namespace qdk::chemistry::data { /** * @brief Immutable data class describing a fermion-to-qubit encoding. * - * Stores a 2N-entry table mapping each Majorana operator gamma_k to a Pauli - * word. The bilinear i*gamma_j*gamma_k is the unified primitive and is - * computed on demand from the table. + * A MajoranaMapping encodes 2N Majorana operators into Pauli operators. + * Two construction forms are supported: + * + * - **Majorana-atomic** (the table constructor and all factory methods): + * stores a 2N-entry table mapping each individual gamma_k to a Pauli word. + * The bilinear i*gamma_j*gamma_k is computed on demand from the table via + * PauliTermAccumulator::multiply_uncached. + * + * - **Bilinear-only** (via from_bilinears): stores the bilinear images + * directly. Individual gamma_k have no Pauli image; only bilinear(j,k) + * is available. This form supports encodings where m > n qubits represent + * n modes and single Majoranas anticommute with codespace stabilizers. * * Pauli words use the little-endian convention of QubitHamiltonian (qubit 0 * has the smallest sparse index). @@ -25,7 +34,7 @@ namespace qdk::chemistry::data { class MajoranaMapping { public: /** - * @brief Construct from a 2N-entry Majorana-to-Pauli table. + * @brief Construct a Majorana-atomic mapping from a 2N-entry table. * * @param table 2N SparsePauliWord entries (gamma_0, ..., gamma_{2N-1}). * @param name Optional encoding label. Stored but not used for dispatch. @@ -34,8 +43,26 @@ class MajoranaMapping { explicit MajoranaMapping(std::vector table, std::string name = ""); - /// Number of fermionic modes (the table has 2N entries). - std::size_t num_modes() const { return table_.size() / 2; } + /** + * @brief Construct a bilinear-only mapping from pre-computed bilinears. + * + * The upper_triangle vector stores (coeff, word) for each pair (j, k) with + * j < k, in row-major order: (0,1), (0,2), ..., (0,M-1), (1,2), ..., + * (M-2,M-1) where M = 2*num_modes. Size must be M*(M-1)/2. + * + * @param num_modes Number of fermionic modes (N). + * @param upper_triangle Bilinear entries for all j < k. + * @param name Optional encoding label. + * @throws std::invalid_argument If sizes are inconsistent or num_modes == 0. + */ + static MajoranaMapping from_bilinears( + std::size_t num_modes, + std::vector, SparsePauliWord>> + upper_triangle, + std::string name = ""); + + /// Number of fermionic modes. + std::size_t num_modes() const { return num_modes_; } /// Number of qubits (max qubit index + 1, or 0 if all entries are identity). std::size_t num_qubits() const { return num_qubits_; } @@ -43,6 +70,7 @@ class MajoranaMapping { /** * @brief Pauli word for Majorana operator gamma_k. * @throws std::out_of_range if k >= 2N. + * @throws std::logic_error if the mapping is not Majorana-atomic. */ const SparsePauliWord& operator()(std::size_t k) const; @@ -53,9 +81,9 @@ class MajoranaMapping { * @brief Pauli image of the bilinear i*gamma_j*gamma_k. * * Returns (coeff, word) such that coeff*word equals i*gamma_j*gamma_k in the - * encoded representation. The coefficient is real (+/-1) since the bilinear - * is Hermitian; the return type is complex for consistency with - * PauliTermAccumulator::multiply_uncached. + * encoded representation. For Majorana-atomic mappings, the coefficient is + * real (+/-1); for bilinear-only mappings, it is whatever was provided at + * construction. * * @throws std::out_of_range if j or k >= 2N. * @throws std::invalid_argument if j == k. @@ -63,10 +91,10 @@ class MajoranaMapping { std::pair, SparsePauliWord> bilinear( std::size_t j, std::size_t k) const; - /// Whether individual Majoranas have a Pauli image (true for the table form). - bool is_majorana_atomic() const { return true; } + /// Whether individual Majoranas have a Pauli image. + bool is_majorana_atomic() const { return majorana_atomic_; } - /// The full Majorana-to-Pauli table. + /// The full Majorana-to-Pauli table (empty for bilinear-only mappings). const std::vector& table() const { return table_; } /// Encoding name (may be empty for custom encodings). @@ -87,15 +115,36 @@ class MajoranaMapping { static MajoranaMapping parity(std::size_t num_modes); private: - /// Majorana-to-Pauli table: table_[k] is the Pauli word for gamma_k. + /// Private constructor for bilinear-only mappings. + MajoranaMapping( + std::size_t num_modes, std::size_t num_qubits, + std::vector, SparsePauliWord>> bilinears, + std::string name); + + /// Majorana-to-Pauli table (empty for bilinear-only mappings). std::vector table_; + /// Pre-computed bilinear table, upper triangle row-major (empty for atomic). + std::vector, SparsePauliWord>> bilinears_; + /// Human-readable encoding name. std::string name_; + /// Number of fermionic modes. + std::size_t num_modes_; + /// Cached qubit count (max qubit index + 1). std::size_t num_qubits_; + /// True for table-constructed mappings, false for bilinear-only. + bool majorana_atomic_; + + /// Upper-triangle index: (j, k) with j < k, M = 2*num_modes. + std::size_t bilinear_index(std::size_t j, std::size_t k) const { + const std::size_t M = 2 * num_modes_; + return j * (2 * M - j - 1) / 2 + (k - j - 1); + } + static std::size_t compute_num_qubits( const std::vector& table); }; @@ -116,9 +165,6 @@ struct MajoranaMapResult { * Decomposes each fermionic operator into Majorana products, looks up each * gamma_k in the mapping, and accumulates the resulting Pauli words. * - * @note Limitation: only valid for spin-free Hamiltonians (spin-independent - * one- and two-body integrals, h1_alpha == h1_beta). - * * @param mapping The Majorana-to-Pauli encoding. * @param core_energy Core (nuclear repulsion + frozen core) energy. * @param h1_alpha One-body integrals, alpha spin (n_spatial x n_spatial). @@ -127,7 +173,11 @@ struct MajoranaMapResult { * @param eri_aabb Flattened two-body integrals (aa|bb), chemist notation. * @param eri_bbbb Flattened two-body integrals (bb|bb), chemist notation. * @param n_spatial Number of spatial orbitals. - * @param is_spin_free Whether the integrals are spin-independent. + * @param spin_symmetric If true, use the spin-summed fast path. This assumes + * identical integrals across all spin channels (h_alpha == h_beta, + * eri_aaaa == eri_bbbb == eri_aabb), as produced by restricted orbitals. + * For unrestricted orbital sets, pass false — the engine handles each + * spin channel independently. * @param threshold Pauli terms with |coeff| < threshold are dropped. * @param integral_threshold Integrals with |value| < this are skipped. * @return MajoranaMapResult with Pauli words and coefficients. @@ -135,7 +185,7 @@ struct MajoranaMapResult { MajoranaMapResult majorana_map_hamiltonian( const MajoranaMapping& mapping, double core_energy, const double* h1_alpha, const double* h1_beta, const double* eri_aaaa, const double* eri_aabb, - const double* eri_bbbb, std::size_t n_spatial, bool is_spin_free, + const double* eri_bbbb, std::size_t n_spatial, bool spin_symmetric, double threshold, double integral_threshold); } // namespace qdk::chemistry::data diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index cfad801d8..d3e568f89 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -2,37 +2,30 @@ // Licensed under the MIT License. See LICENSE.txt in the project root for // license information. -#include +#include +#include #include #include #include #include #include #include +#include +#include #include +#include #include namespace qdk::chemistry::data { - -namespace { - -// Compile-time Majorana decomposition coefficients for a†_p a_q: -// a†_p a_q = (1/4) Σ_{a,b} c[a][b] · γ_{2p+a} · γ_{2q+b} -// c[a][b] = (-i)^a · (i)^b -// c[0][0] = 1, c[0][1] = i, c[1][0] = -i, c[1][1] = 1 -constexpr std::complex kC00{1.0, 0.0}; -constexpr std::complex kC01{0.0, 1.0}; -constexpr std::complex kC10{0.0, -1.0}; -constexpr std::complex kC11{1.0, 0.0}; - -// All four coefficients in indexable form: kC[a][b] -constexpr std::complex kC[2][2] = {{kC00, kC01}, {kC10, kC11}}; - -// Quarter factor applied to each one-body Majorana product -constexpr double kQuarter = 0.25; - -// 1/16 factor for two-body (product of two E operators, each with 1/4) -constexpr double kSixteenth = 0.0625; +namespace detail { + +// Majorana decomposition coefficients for the excitation operator a†_p a_q: +// a†_p a_q = (1/4) Σ_{a,b} coeff[a][b] · γ_{2p+a} · γ_{2q+b} +// coeff[a][b] = (-i)^a · (i)^b +constexpr std::complex excitation_coeff[2][2] = { + {{1.0, 0.0}, {0.0, 1.0}}, + {{0.0, -1.0}, {1.0, 0.0}}, +}; // ─── Bitpacked Pauli representation ──────────────────────────────── // @@ -42,7 +35,7 @@ constexpr double kSixteenth = 0.0625; // // Each uint64_t covers 64 qubits. Templated on NW (number of uint64 // words) to keep everything on the stack. The engine dispatches to -// NW ∈ {1, 2, 3, 4} at runtime (covers up to 256 qubits). +// NW in {1, ..., 16} at runtime (covers up to 1024 qubits). template struct PackedPauliWord { @@ -57,10 +50,10 @@ struct PackedPauliWordHash { std::size_t operator()(const PackedPauliWord& w) const noexcept { std::size_t seed = NW; for (std::size_t i = 0; i < NW; ++i) { - seed ^= w.x[i] * 0x9e3779b97f4a7c15ULL + (seed << 6) + (seed >> 2); + seed = utils::hash_combine(seed, w.x[i]); } for (std::size_t i = 0; i < NW; ++i) { - seed ^= w.z[i] * 0x517cc1b727220a95ULL + (seed << 6) + (seed >> 2); + seed = utils::hash_combine(seed, w.z[i]); } return seed; } @@ -88,7 +81,7 @@ SparsePauliWord packed_to_sparse(const PackedPauliWord& pw) { std::uint64_t z = pw.z[wi]; std::uint64_t active = x | z; while (active) { - int bit = __builtin_ctzll(active); + int bit = std::countr_zero(active); std::uint64_t mask = std::uint64_t(1) << bit; std::uint64_t qubit = wi * 64 + bit; bool has_x = (x & mask) != 0; @@ -116,19 +109,16 @@ std::pair> multiply_packed( (x1 & nz1 & x2 & z2) | (x1 & z1 & nx2 & z2) | (nx1 & z1 & x2 & nz2); std::uint64_t anti = (x1 & z1 & x2 & nz2) | (nx1 & z1 & x2 & z2) | (x1 & nz1 & nx2 & z2); - phase_exp += __builtin_popcountll(cyc); - phase_exp -= __builtin_popcountll(anti); + phase_exp += std::popcount(cyc); + phase_exp -= std::popcount(anti); } - // Return phase index (0..3) instead of complex — callers apply phase - // using branchless real/imag swap, avoiding complex multiply. return {phase_exp & 3, result}; } /// Apply a phase index (0=+1, 1=+i, 2=-1, 3=-i) to a complex scale factor. -/// Returns the scaled value without a full complex multiply. inline std::complex apply_phase(int phase_idx, std::complex scale) { - switch (phase_idx) { + switch (phase_idx & 3) { case 0: return scale; case 1: @@ -137,16 +127,14 @@ inline std::complex apply_phase(int phase_idx, return {-scale.real(), -scale.imag()}; case 3: return {scale.imag(), -scale.real()}; - default: - __builtin_unreachable(); } + return scale; // unreachable } template class PackedAccumulator { public: void accumulate(const PackedPauliWord& word, std::complex coeff) { - // Single hash-map probe: operator[] default-constructs (0,0) on miss. terms_[word] += coeff; } @@ -157,7 +145,6 @@ class PackedAccumulator { terms_[word] += apply_phase(phase_idx, scale); } - /// Extract terms above threshold as (coefficient, SparsePauliWord) pairs. std::vector, SparsePauliWord>> get_terms( double threshold) const { std::vector, SparsePauliWord>> result; @@ -176,17 +163,35 @@ class PackedAccumulator { terms_; }; -} // namespace - -namespace { +// ─── Engine implementation ───────────────────────────────────────── +// +// The standard non-relativistic electronic Hamiltonian is spin-free: it +// contains no spin operators. The Majorana decomposition of excitation +// operators E_pq = a†_p a_q into bilinear products of γ's works for +// any spin-free Hamiltonian regardless of the orbital basis. +// +// The spin_symmetric flag selects one of two evaluation strategies: +// +// spin_symmetric = true (restricted orbitals) +// All spin channels share the same integrals (h_alpha == h_beta, +// eri_aaaa == eri_bbbb == eri_aabb). The engine precomputes +// spin-summed E_pq = E^α_pq + E^β_pq and exploits 8-fold ERI +// symmetry, roughly halving the two-body work. The δ_{qr} +// two-body contraction is folded into the one-body integrals. +// +// spin_symmetric = false (unrestricted orbitals) +// Each spin channel (αα, ββ, αβ, βα) is handled independently +// with its own integrals. This is needed for unrestricted orbitals, +// where h^α ≠ h^β and the ERI channels differ — even though the +// underlying Hamiltonian is still spin-free. The αβ and βα +// cross-spin channels are related by Coulomb symmetry (pq|rs) = +// (rs|pq) and are handled in a single merged loop. -/// Templated engine implementation, parameterized on NW (number of uint64 -/// words per Pauli bitmask). NW is selected at runtime by the dispatcher. template MajoranaMapResult majorana_map_impl( const MajoranaMapping& mapping, double core_energy, const double* h1_alpha, const double* h1_beta, const double* eri_aaaa, const double* eri_aabb, - const double* eri_bbbb, std::size_t n_spatial, bool is_spin_free, + const double* eri_bbbb, std::size_t n_spatial, bool spin_symmetric, double threshold, double integral_threshold) { const std::size_t n_modes = 2 * n_spatial; @@ -203,8 +208,7 @@ MajoranaMapResult majorana_map_impl( return p + n_spatial; }; - // Helper: accumulate one-body E_pq for a mode pair, using packed types - // E_pq = (1/4) * sum_{a,b} c[a][b] * P(2p+a) * P(2q+b) + // E_pq = (1/4) Σ_{a,b} coeff[a][b] · P(2p+a) · P(2q+b) auto accumulate_epq = [&](std::size_t mode_p, std::size_t mode_q, double h_pq) { for (int a = 0; a < 2; ++a) { @@ -213,7 +217,8 @@ MajoranaMapResult majorana_map_impl( std::size_t idx_qb = 2 * mode_q + b; auto [ph, word] = multiply_packed(packed_mapping[idx_pa], packed_mapping[idx_qb]); - acc.accumulate(word, apply_phase(ph, h_pq * kQuarter * kC[a][b])); + acc.accumulate(word, + apply_phase(ph, h_pq * 0.25 * excitation_coeff[a][b])); } } }; @@ -229,14 +234,15 @@ MajoranaMapResult majorana_map_impl( return ((p * n_spatial + q) * n_spatial + r) * n_spatial + s; }; - // ─── One-body terms (with δ correction folded in for restricted) ── + // ─── One-body terms ────────────────────────────────────────────── std::vector h1_eff_alpha(n_spatial * n_spatial); std::vector h1_eff_beta(n_spatial * n_spatial); for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t s = 0; s < n_spatial; ++s) { double h_a = h1_alpha[p * n_spatial + s]; - double h_b = is_spin_free ? h_a : h1_beta[p * n_spatial + s]; - if (is_spin_free) { + double h_b = spin_symmetric ? h_a : h1_beta[p * n_spatial + s]; + if (spin_symmetric) { + // Fold δ_{qr} contraction into the one-body integrals. double delta_corr = 0.0; for (std::size_t q = 0; q < n_spatial; ++q) { delta_corr += eri_aaaa[idx4(p, q, q, s)]; @@ -266,7 +272,7 @@ MajoranaMapResult majorana_map_impl( // Precompute Majorana pair products in packed form (same-spin only). struct PackedPairProduct { - int phase; // Pauli multiply phase index (0..3) + int phase; PackedPauliWord word; }; const std::size_t maj_per_spin = 2 * n_spatial; @@ -275,11 +281,9 @@ MajoranaMapResult majorana_map_impl( for (std::size_t i = 0; i < maj_per_spin; ++i) { for (std::size_t j = 0; j < maj_per_spin; ++j) { - // alpha block: Majorana indices i, j auto [ph_a, w_a] = multiply_packed(packed_mapping[i], packed_mapping[j]); ppair_alpha[i * maj_per_spin + j] = {ph_a, std::move(w_a)}; - // beta block: Majorana indices i+2*n_spatial, j+2*n_spatial auto [ph_b, w_b] = multiply_packed(packed_mapping[i + maj_per_spin], packed_mapping[j + maj_per_spin]); ppair_beta[i * maj_per_spin + j] = {ph_b, std::move(w_b)}; @@ -295,7 +299,7 @@ MajoranaMapResult majorana_map_impl( return ppair_beta[i * maj_per_spin + j]; }; - if (is_spin_free) { + if (spin_symmetric) { // Precompute spin-summed E_pq for all (p,q): struct SpinSummedE { std::vector, PackedPauliWord>> terms; @@ -309,16 +313,16 @@ MajoranaMapResult majorana_map_impl( for (int b = 0; b < 2; ++b) { const auto& [phase_a, word_a] = alpha_pair(2 * p + a, 2 * q + b); sse.terms.emplace_back( - apply_phase(phase_a, kQuarter * kC[a][b]), word_a); + apply_phase(phase_a, 0.25 * excitation_coeff[a][b]), word_a); const auto& [phase_b, word_b] = beta_pair(2 * p + a, 2 * q + b); sse.terms.emplace_back( - apply_phase(phase_b, kQuarter * kC[a][b]), word_b); + apply_phase(phase_b, 0.25 * excitation_coeff[a][b]), word_b); } } } } - // Precompute symmetrized S_pq = E_pq + E_qp (merged) for p ≤ q. + // Precompute symmetrized S_pq = E_pq + E_qp (merged) for p <= q. struct SymmetrizedE { std::vector, PackedPauliWord>> terms; }; @@ -354,11 +358,7 @@ MajoranaMapResult majorana_map_impl( } } - // Two-body product: exploit full 8-fold ERI symmetry. - // Already using p≤q, r≤s (4-fold). Now add (pq)≤(rs) exchange: - // (pq|rs) = (rs|pq), so S_pq·S_rs + S_rs·S_pq covers both. - // For pq_idx < rs_idx: accumulate both products with scale 2×½·eri. - // For pq_idx == rs_idx: accumulate one product with scale ½·eri. + // Two-body: exploit full 8-fold ERI symmetry via (pq)<=(rs) exchange. for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t q = p; q < n_spatial; ++q) { std::size_t pq_idx = sym_map[p * n_spatial + q]; @@ -366,7 +366,7 @@ MajoranaMapResult majorana_map_impl( for (std::size_t r = 0; r < n_spatial; ++r) { for (std::size_t s = r; s < n_spatial; ++s) { std::size_t rs_idx = sym_map[r * n_spatial + s]; - if (pq_idx > rs_idx) continue; // skip; (rs,pq) handles this + if (pq_idx > rs_idx) continue; double eri = eri_aaaa[idx4(p, q, r, s)]; if (std::abs(eri) < integral_threshold) continue; @@ -374,7 +374,6 @@ MajoranaMapResult majorana_map_impl( const auto& s_rs = sym_e[rs_idx]; if (pq_idx == rs_idx) { - // Diagonal: S_pq · S_pq (single product) double half_eri = 0.5 * eri; for (const auto& [c1, w1] : s_pq.terms) { for (const auto& [c2, w2] : s_rs.terms) { @@ -382,7 +381,6 @@ MajoranaMapResult majorana_map_impl( } } } else { - // Off-diagonal: S_pq · S_rs + S_rs · S_pq (both products) double half_eri = 0.5 * eri; for (const auto& [c1, w1] : s_pq.terms) { for (const auto& [c2, w2] : s_rs.terms) { @@ -396,9 +394,8 @@ MajoranaMapResult majorana_map_impl( } } - // (δ correction is folded into the one-body integrals above) } else { - // Unrestricted: explicit spin-channel ERIs using packed types + // Channel-separated path for unrestricted orbitals. auto accumulate_two_body_product = [&](std::size_t mode_p, std::size_t mode_q, std::size_t mode_r, @@ -423,7 +420,8 @@ MajoranaMapResult majorana_map_impl( cache_rs[(2 * br + c) * maj_per_spin + (2 * bs + d)]; std::complex scale = apply_phase( (ph1 + ph2) & 3, - half_eri * kSixteenth * kC[a][b] * kC[c][d]); + half_eri * 0.0625 * excitation_coeff[a][b] * + excitation_coeff[c][d]); acc.accumulate_product(w1, w2, scale); } } @@ -431,7 +429,7 @@ MajoranaMapResult majorana_map_impl( } }; - // aaaa channel + // αα channel for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t q = 0; q < n_spatial; ++q) { for (std::size_t r = 0; r < n_spatial; ++r) { @@ -448,7 +446,7 @@ MajoranaMapResult majorana_map_impl( } } - // bbbb channel + // ββ channel for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t q = 0; q < n_spatial; ++q) { for (std::size_t r = 0; r < n_spatial; ++r) { @@ -465,7 +463,7 @@ MajoranaMapResult majorana_map_impl( } } - // aabb channel + // αβ + βα cross-spin channels, related by Coulomb symmetry (pq|rs)=(rs|pq) for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t q = 0; q < n_spatial; ++q) { for (std::size_t r = 0; r < n_spatial; ++r) { @@ -474,20 +472,8 @@ MajoranaMapResult majorana_map_impl( if (std::abs(eri) < integral_threshold) continue; accumulate_two_body_product(mode_alpha(p), mode_alpha(q), mode_beta(r), mode_beta(s), eri); - } - } - } - } - - // bbaa channel - for (std::size_t p = 0; p < n_spatial; ++p) { - for (std::size_t q = 0; q < n_spatial; ++q) { - for (std::size_t r = 0; r < n_spatial; ++r) { - for (std::size_t s = 0; s < n_spatial; ++s) { - double eri = eri_aabb[idx4(r, s, p, q)]; - if (std::abs(eri) < integral_threshold) continue; - accumulate_two_body_product(mode_beta(p), mode_beta(q), - mode_alpha(r), mode_alpha(s), eri); + accumulate_two_body_product(mode_beta(r), mode_beta(s), + mode_alpha(p), mode_alpha(q), eri); } } } @@ -509,49 +495,47 @@ MajoranaMapResult majorana_map_impl( return result; } -} // anonymous namespace +// Dispatch table: function pointer per NW, covering 1..16 (up to 1024 qubits). +using DispatchFn = MajoranaMapResult (*)( + const MajoranaMapping&, double, const double*, const double*, + const double*, const double*, const double*, std::size_t, bool, + double, double); + +template +constexpr std::array make_dispatch_table( + std::index_sequence) { + return {{&majorana_map_impl...}}; +} + +constexpr std::size_t max_nw = 16; + +} // namespace detail MajoranaMapResult majorana_map_hamiltonian( const MajoranaMapping& mapping, double core_energy, const double* h1_alpha, const double* h1_beta, const double* eri_aaaa, const double* eri_aabb, - const double* eri_bbbb, std::size_t n_spatial, bool is_spin_free, + const double* eri_bbbb, std::size_t n_spatial, bool spin_symmetric, double threshold, double integral_threshold) { const std::size_t num_qubits = mapping.num_qubits(); if (num_qubits == 0) { throw std::invalid_argument( "majorana_map_hamiltonian: mapping has zero qubits; the encoding " - "must produce at least one qubit. This usually indicates an " - "uninitialized or malformed MajoranaMapping."); + "must produce at least one qubit."); } const std::size_t num_words = (num_qubits + 63) / 64; - // Dispatch to the appropriate template instantiation. - // Each instantiation uses std::array (stack-allocated, - // no heap overhead per Pauli word). - switch (num_words) { - case 1: - return majorana_map_impl<1>(mapping, core_energy, h1_alpha, h1_beta, - eri_aaaa, eri_aabb, eri_bbbb, n_spatial, - is_spin_free, threshold, integral_threshold); - case 2: - return majorana_map_impl<2>(mapping, core_energy, h1_alpha, h1_beta, - eri_aaaa, eri_aabb, eri_bbbb, n_spatial, - is_spin_free, threshold, integral_threshold); - case 3: - return majorana_map_impl<3>(mapping, core_energy, h1_alpha, h1_beta, - eri_aaaa, eri_aabb, eri_bbbb, n_spatial, - is_spin_free, threshold, integral_threshold); - case 4: - return majorana_map_impl<4>(mapping, core_energy, h1_alpha, h1_beta, - eri_aaaa, eri_aabb, eri_bbbb, n_spatial, - is_spin_free, threshold, integral_threshold); - default: - throw std::invalid_argument( - "majorana_map_hamiltonian: num_qubits=" + std::to_string(num_qubits) + - " requires " + std::to_string(num_words) + - " uint64 words, but max supported is 4 (256 qubits). " - "Contact the developers to extend the template dispatch."); + if (num_words > detail::max_nw) { + throw std::invalid_argument( + "majorana_map_hamiltonian: num_qubits=" + std::to_string(num_qubits) + + " exceeds the maximum of " + + std::to_string(detail::max_nw * 64) + " qubits."); } + + static const auto table = + detail::make_dispatch_table(std::make_index_sequence{}); + return table[num_words - 1](mapping, core_energy, h1_alpha, h1_beta, + eri_aaaa, eri_aabb, eri_bbbb, n_spatial, + spin_symmetric, threshold, integral_threshold); } } // namespace qdk::chemistry::data diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp index 8fa5bd66c..6b70a9c0a 100644 --- a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp @@ -14,7 +14,7 @@ namespace qdk::chemistry::data { -namespace { +namespace detail { // ── BK index-set computation ────────────────────────────────────────── @@ -126,7 +126,17 @@ constexpr std::uint8_t OP_X = 1; constexpr std::uint8_t OP_Y = 2; constexpr std::uint8_t OP_Z = 3; -} // anonymous namespace +} // namespace detail + +using detail::bk_flip_set; +using detail::bk_parity_set; +using detail::bk_remainder_set; +using detail::bk_update_set; +using detail::build_sorted_word; +using detail::next_power_of_two; +using detail::OP_X; +using detail::OP_Y; +using detail::OP_Z; // ── MajoranaMapping implementation ─────────────────────────────────── @@ -134,7 +144,9 @@ MajoranaMapping::MajoranaMapping(std::vector table, std::string name) : table_(std::move(table)), name_(std::move(name)), - num_qubits_(compute_num_qubits(table_)) { + num_modes_(table_.size() / 2), + num_qubits_(compute_num_qubits(table_)), + majorana_atomic_(true) { if (table_.empty()) { throw std::invalid_argument("MajoranaMapping table must not be empty"); } @@ -146,7 +158,57 @@ MajoranaMapping::MajoranaMapping(std::vector table, } } +MajoranaMapping::MajoranaMapping( + std::size_t num_modes, std::size_t num_qubits, + std::vector, SparsePauliWord>> bilinears, + std::string name) + : bilinears_(std::move(bilinears)), + name_(std::move(name)), + num_modes_(num_modes), + num_qubits_(num_qubits), + majorana_atomic_(false) {} + +MajoranaMapping MajoranaMapping::from_bilinears( + std::size_t num_modes, + std::vector, SparsePauliWord>> + upper_triangle, + std::string name) { + if (num_modes == 0) { + throw std::invalid_argument( + "MajoranaMapping::from_bilinears requires num_modes > 0"); + } + const std::size_t M = 2 * num_modes; + const std::size_t expected = M * (M - 1) / 2; + if (upper_triangle.size() != expected) { + throw std::invalid_argument( + "MajoranaMapping::from_bilinears: expected " + + std::to_string(expected) + " upper-triangle entries for " + + std::to_string(num_modes) + " modes, got " + + std::to_string(upper_triangle.size())); + } + // Compute num_qubits from the bilinear words + std::uint64_t max_idx = 0; + bool has_any = false; + for (const auto& [_, word] : upper_triangle) { + for (const auto& [qubit, op] : word) { + (void)op; + if (!has_any || qubit >= max_idx) { + max_idx = qubit; + has_any = true; + } + } + } + auto nq = has_any ? static_cast(max_idx + 1) : 0; + return MajoranaMapping(num_modes, nq, std::move(upper_triangle), + std::move(name)); +} + const SparsePauliWord& MajoranaMapping::operator()(std::size_t k) const { + if (!majorana_atomic_) { + throw std::logic_error( + "MajoranaMapping::majorana(k) is not available for bilinear-only " + "mappings; use bilinear(j, k) instead."); + } if (k >= table_.size()) { throw std::out_of_range("Majorana index " + std::to_string(k) + " out of range [0, " + @@ -157,12 +219,12 @@ const SparsePauliWord& MajoranaMapping::operator()(std::size_t k) const { std::pair, SparsePauliWord> MajoranaMapping::bilinear( std::size_t j, std::size_t k) const { - const std::size_t n = table_.size(); - if (j >= n || k >= n) { + const std::size_t M = 2 * num_modes_; + if (j >= M || k >= M) { throw std::out_of_range( "MajoranaMapping::bilinear index out of range: requested (" + std::to_string(j) + ", " + std::to_string(k) + "), valid range [0, " + - std::to_string(n) + ")"); + std::to_string(M) + ")"); } if (j == k) { throw std::invalid_argument( @@ -170,11 +232,22 @@ std::pair, SparsePauliWord> MajoranaMapping::bilinear( std::to_string(j) + "); the bilinear i*gamma_j*gamma_k requires distinct indices."); } - // i * gamma_j * gamma_k = i * (table_[j] * table_[k]) - auto [pauli_phase, word] = - PauliTermAccumulator::multiply_uncached(table_[j], table_[k]); - std::complex coeff = std::complex(0.0, 1.0) * pauli_phase; - return {coeff, std::move(word)}; + + if (majorana_atomic_) { + auto [pauli_phase, word] = + PauliTermAccumulator::multiply_uncached(table_[j], table_[k]); + std::complex coeff = std::complex(0.0, 1.0) * pauli_phase; + return {coeff, std::move(word)}; + } + + // Bilinear-only: look up from stored upper triangle + if (j < k) { + const auto& entry = bilinears_[bilinear_index(j, k)]; + return {entry.first, entry.second}; + } + // Antisymmetry: i*gamma_k*gamma_j = -(i*gamma_j*gamma_k) + const auto& entry = bilinears_[bilinear_index(k, j)]; + return {-entry.first, entry.second}; } std::size_t MajoranaMapping::compute_num_qubits( diff --git a/docs/source/user/comprehensive/data/majorana_mapping.rst b/docs/source/user/comprehensive/data/majorana_mapping.rst index 047884e5d..e85016bae 100644 --- a/docs/source/user/comprehensive/data/majorana_mapping.rst +++ b/docs/source/user/comprehensive/data/majorana_mapping.rst @@ -18,11 +18,16 @@ Across fermion-to-qubit encodings, the most general primitive that admits a Paul Bilinears generate the parity-even subalgebra of the Majorana Clifford algebra, so any parity-conserving operator decomposes into ordered bilinear products, and higher-degree even monomials are products of bilinears. Individual Majorana operators :math:`\gamma_k` have a Pauli image only in **Majorana-atomic** encodings. -The :class:`~qdk_chemistry.data.MajoranaMapping` API therefore exposes: +In **bilinear-only** encodings (where :math:`m > n` qubits represent :math:`n` modes), single Majoranas anticommute with codespace stabilizers and have no representation in the physical subspace; only the bilinears are observable. + +The :class:`~qdk_chemistry.data.MajoranaMapping` supports both forms: - :py:meth:`~qdk_chemistry.data.MajoranaMapping.bilinear` — the unified primitive available on every encoding. - :py:meth:`~qdk_chemistry.data.MajoranaMapping.majorana` — the additional capability provided by Majorana-atomic encodings; gated by :py:attr:`~qdk_chemistry.data.MajoranaMapping.is_majorana_atomic`. +Majorana-atomic mappings are constructed from a Pauli-string table (the constructor or factory methods). +Bilinear-only mappings are constructed via :py:meth:`~qdk_chemistry.data.MajoranaMapping.from_bilinears`. + Convention ~~~~~~~~~~ diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index e7ffeb77e..749c9e85c 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -68,6 +68,8 @@ convention of QubitHamiltonian (qubit 0 has the smallest index). return self(k); } catch (const std::out_of_range& e) { throw py::index_error(e.what()); + } catch (const std::logic_error& e) { + throw py::value_error(e.what()); } }, py::arg("k"), "Sparse Pauli word for Majorana operator gamma_k."); @@ -79,6 +81,8 @@ convention of QubitHamiltonian (qubit 0 has the smallest index). return self.majorana(k); } catch (const std::out_of_range& e) { throw py::index_error(e.what()); + } catch (const std::logic_error& e) { + throw py::value_error(e.what()); } }, py::arg("k"), "Sparse Pauli word for Majorana operator gamma_k."); @@ -154,6 +158,22 @@ convention of QubitHamiltonian (qubit 0 has the smallest index). }, py::arg("num_modes"), "Construct a parity encoding."); + mapping.def_static( + "from_bilinears", + [](std::size_t num_modes, + const std::vector, SparsePauliWord>>& + upper_triangle, + const std::string& name) { + try { + return MajoranaMapping::from_bilinears(num_modes, upper_triangle, + name); + } catch (const std::invalid_argument& e) { + throw py::value_error(e.what()); + } + }, + py::arg("num_modes"), py::arg("upper_triangle"), py::arg("name") = "", + "Construct a bilinear-only mapping from pre-computed bilinears."); + data.def( "majorana_map_hamiltonian", [](const MajoranaMapping& mapping, double core_energy, @@ -166,24 +186,27 @@ convention of QubitHamiltonian (qubit 0 has the smallest index). eri_aabb, py::array_t eri_bbbb, - std::size_t n_spatial, bool is_spin_free, double threshold, + std::size_t n_spatial, bool spin_symmetric, double threshold, double integral_threshold) -> py::tuple { auto result = majorana_map_hamiltonian( mapping, core_energy, h1_alpha.data(), h1_beta.data(), eri_aaaa.data(), eri_aabb.data(), eri_bbbb.data(), n_spatial, - is_spin_free, threshold, integral_threshold); + spin_symmetric, threshold, integral_threshold); return py::make_tuple(py::cast(result.words), py::cast(result.coefficients)); }, py::arg("mapping"), py::arg("core_energy"), py::arg("h1_alpha"), py::arg("h1_beta"), py::arg("eri_aaaa"), py::arg("eri_aabb"), - py::arg("eri_bbbb"), py::arg("n_spatial"), py::arg("is_spin_free"), + py::arg("eri_bbbb"), py::arg("n_spatial"), py::arg("spin_symmetric"), py::arg("threshold"), py::arg("integral_threshold"), R"( Map a fermionic Hamiltonian to qubit Pauli terms using Majorana loops. -Limitation: only valid for spin-free Hamiltonians (spin-independent -integrals). Returns ``(words, coefficients)`` where ``words`` is a list of -sparse Pauli words. +When ``spin_symmetric`` is true, uses a spin-summed fast path that assumes +identical integrals across spin channels (restricted orbitals). When false, +handles each spin channel independently (unrestricted orbitals). + +Returns ``(words, coefficients)`` where ``words`` is a list of sparse +Pauli words. )"); } diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index 8fbc92d79..196ae3c79 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -69,9 +69,9 @@ class QdkQubitMapper(QubitMapper): ``name`` and ``base_encoding`` are used only for metadata on the output :class:`~qdk_chemistry.data.QubitHamiltonian`, not for dispatch. - Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. - For unrestricted systems, the engine handles all four spin-channel ERI - blocks (aa, ab, ba, bb) independently. + Both restricted (RHF) and unrestricted (UHF) orbital sets are supported. + For restricted orbitals the engine uses a spin-summed fast path; for + unrestricted orbitals it handles all spin-channel ERI blocks independently. The mapper uses canonical blocked spin-orbital ordering internally: qubits 0..N-1 for alpha spin, qubits N..2N-1 for beta spin (where N is the @@ -143,8 +143,9 @@ def _run_impl( h2_aaaa, h2_aabb, h2_bbbb = hamiltonian.get_two_body_integrals() n_spatial = h1_alpha.shape[0] n_spin_orbitals = 2 * n_spatial - # Restricted orbitals imply spin-free integrals (h_alpha == h_beta). - is_spin_free = hamiltonian.get_orbitals().is_restricted() + # Restricted orbitals produce spin-symmetric integrals, enabling the + # spin-summed fast path in the engine. + spin_symmetric = hamiltonian.get_orbitals().is_restricted() if base_mapping.num_modes != n_spin_orbitals: raise ValueError( @@ -154,14 +155,13 @@ def _run_impl( ) # Use ravel() instead of flatten() to avoid copying contiguous arrays. - # For spin-free Hamiltonians the containers share the same two-body - # vector across aaaa/aabb/bbbb, so pass the same array to avoid - # materializing unused copies. + # For spin-symmetric integrals (restricted orbitals) the containers + # share the same arrays across spin channels. h1_a_flat = np.ascontiguousarray(h1_alpha).ravel() - h1_b_flat = h1_a_flat if is_spin_free else np.ascontiguousarray(h1_beta).ravel() + h1_b_flat = h1_a_flat if spin_symmetric else np.ascontiguousarray(h1_beta).ravel() h2_aaaa_flat = np.ascontiguousarray(h2_aaaa).ravel() - h2_aabb_flat = h2_aaaa_flat if is_spin_free else np.ascontiguousarray(h2_aabb).ravel() - h2_bbbb_flat = h2_aaaa_flat if is_spin_free else np.ascontiguousarray(h2_bbbb).ravel() + h2_aabb_flat = h2_aaaa_flat if spin_symmetric else np.ascontiguousarray(h2_aabb).ravel() + h2_bbbb_flat = h2_aaaa_flat if spin_symmetric else np.ascontiguousarray(h2_bbbb).ravel() # Single C++ call: Majorana-loop engine builds all Pauli terms as sparse words words, coefficients = majorana_map_hamiltonian( @@ -173,7 +173,7 @@ def _run_impl( h2_aabb_flat, h2_bbbb_flat, n_spatial, - is_spin_free, + spin_symmetric, threshold, integral_threshold, ) diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index d0fc87636..02314ffc7 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -68,16 +68,24 @@ def _sparse_to_dense_le(word: SparsePauliWord, num_qubits: int) -> str: class MajoranaMapping(DataClass): """Immutable data class describing a fermion-to-qubit encoding. - Stores a 2N-entry table mapping each Majorana operator gamma_k to a Pauli - word. The bilinear ``i*gamma_j*gamma_k`` is the unified primitive and is - computed on demand from the table, accessible via :py:meth:`bilinear`. + Two construction forms are supported: + + - **Majorana-atomic** (the table constructor and all factory methods): + stores a 2N-entry table mapping each individual gamma_k to a Pauli word. + The bilinear ``i*gamma_j*gamma_k`` is computed on demand from the table. + + - **Bilinear-only** (via :py:meth:`from_bilinears`): stores the bilinear + images directly. Individual gamma_k have no Pauli image; only + :py:meth:`bilinear` is available. This form supports encodings where + m > n qubits represent n modes and single Majoranas anticommute with + codespace stabilizers. Pauli strings use the same **little-endian** convention as :class:`~qdk_chemistry.data.QubitHamiltonian`: qubit 0 is the rightmost character. Attributes: - table (tuple[str, ...]): Tuple of 2N dense Pauli strings in little-endian format. + table (tuple[str, ...]): Tuple of 2N dense Pauli strings (empty for bilinear-only mappings). num_modes (int): Number of fermionic modes (spin-orbitals), N. num_qubits (int): Effective number of qubits after any tapering. name (str): Human-readable name of the encoding (may be empty for custom mappings). @@ -146,7 +154,7 @@ def __init__( @property def table(self) -> tuple[str, ...]: - """Tuple of 2N dense Pauli strings in little-endian format (qubit 0 = rightmost char).""" + """Tuple of 2N dense Pauli strings (empty for bilinear-only mappings).""" return self._table @property @@ -222,9 +230,10 @@ def is_majorana_atomic(self) -> bool: def majorana(self, k: int) -> str: """Return the Pauli image of the Majorana operator gamma_k. - Available only for :py:attr:`is_majorana_atomic` encodings. For tapered - encodings the returned Pauli string is in the encoding's native - (pre-taper) qubit basis; tapering is applied downstream. + Available only for :py:attr:`is_majorana_atomic` encodings; raises + :class:`ValueError` for bilinear-only mappings. For tapered encodings + the returned Pauli string is in the encoding's native (pre-taper) + qubit basis; tapering is applied downstream. Args: k (int): Majorana index (0 <= k < 2 * num_modes). @@ -234,6 +243,7 @@ def majorana(self, k: int) -> str: Raises: IndexError: If k is out of range. + ValueError: If the mapping is not Majorana-atomic. """ return _sparse_to_dense_le(self._core.majorana(k), self._num_qubits) @@ -414,6 +424,57 @@ def from_mode_pairs( table.append(odd) return cls(table=table, name=name) + @classmethod + def from_bilinears( + cls, + num_modes: int, + bilinears: dict[tuple[int, int], tuple[complex, str]], + name: str = "", + ) -> MajoranaMapping: + """Construct a bilinear-only mapping from pre-computed bilinears. + + For encodings where individual Majorana operators have no Pauli image, + this constructor accepts the bilinear images directly. + :py:meth:`bilinear` is available; :py:meth:`majorana` will raise. + + Args: + num_modes (int): Number of fermionic modes (spin-orbitals). Must be > 0. + bilinears (dict[tuple[int, int], tuple[complex, str]]): Mapping from ``(j, k)`` with ``j < k`` to ``(coeff, pauli_str)``, one entry per distinct ordered pair of Majorana indices. + name (str): Optional human-readable label. Default ``""``. + + Returns: + MajoranaMapping: A bilinear-only mapping. + + Raises: + ValueError: If sizes are inconsistent or num_modes is zero. + + """ + M = 2 * num_modes + expected = M * (M - 1) // 2 + if len(bilinears) != expected: + raise ValueError( + f"Expected {expected} upper-triangle bilinear entries for " + f"{num_modes} modes, got {len(bilinears)}" + ) + # Build upper-triangle flat list in row-major order + upper_triangle: list[tuple[complex, SparsePauliWord]] = [] + for j in range(M): + for k in range(j + 1, M): + if (j, k) not in bilinears: + raise ValueError(f"Missing bilinear entry for ({j}, {k})") + coeff, label = bilinears[(j, k)] + upper_triangle.append((complex(coeff), _dense_le_to_sparse(label))) + core = _CoreMajoranaMapping.from_bilinears(num_modes, upper_triangle, name) + result = cls.__new__(cls) + result._core = core + result._name = name if name else core.name + result._num_modes = core.num_modes + result._num_qubits = core.num_qubits + result._table = () + result._tapering = None + DataClass.__init__(result) + return result + @classmethod def _from_core(cls, core: _CoreMajoranaMapping) -> MajoranaMapping: """Construct from an already-validated C++ core object.""" From f011b3aeecb1814f38333eec59c303df23427f84 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 30 May 2026 00:09:19 +0000 Subject: [PATCH 088/117] =?UTF-8?q?refactor:=20constructor=20=E2=86=92=20f?= =?UTF-8?q?rom=5Ftable=20/=20from=5Fbilinears=20factories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the public MajoranaMapping constructor with from_table() for symmetry with from_bilinears(). Both are named static factories; the actual constructors are private. Add unit tests for from_bilinears: basic construction, bilinear lookup matches table-derived values, antisymmetry, and majorana() raises for bilinear-only mappings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../qdk/chemistry/data/majorana_mapping.hpp | 8 ++- .../qdk/chemistry/data/majorana_mapping.cpp | 30 +++++---- python/src/pybind11/data/majorana_mapping.cpp | 22 ++++--- .../qdk_chemistry/data/majorana_mapping.py | 2 +- python/tests/test_majorana_mapping.py | 63 +++++++++++++++++++ 5 files changed, 101 insertions(+), 24 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index 803c252be..8258f29cb 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -40,8 +40,8 @@ class MajoranaMapping { * @param name Optional encoding label. Stored but not used for dispatch. * @throws std::invalid_argument If the table is empty or its size is odd. */ - explicit MajoranaMapping(std::vector table, - std::string name = ""); + static MajoranaMapping from_table(std::vector table, + std::string name = ""); /** * @brief Construct a bilinear-only mapping from pre-computed bilinears. @@ -115,6 +115,10 @@ class MajoranaMapping { static MajoranaMapping parity(std::size_t num_modes); private: + /// Private constructor for table-based (Majorana-atomic) mappings. + MajoranaMapping(std::vector table, std::string name, + std::size_t num_modes, std::size_t num_qubits); + /// Private constructor for bilinear-only mappings. MajoranaMapping( std::size_t num_modes, std::size_t num_qubits, diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp index 6b70a9c0a..1389a2db2 100644 --- a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp @@ -141,21 +141,29 @@ using detail::OP_Z; // ── MajoranaMapping implementation ─────────────────────────────────── MajoranaMapping::MajoranaMapping(std::vector table, - std::string name) + std::string name, std::size_t num_modes, + std::size_t num_qubits) : table_(std::move(table)), name_(std::move(name)), - num_modes_(table_.size() / 2), - num_qubits_(compute_num_qubits(table_)), - majorana_atomic_(true) { - if (table_.empty()) { + num_modes_(num_modes), + num_qubits_(num_qubits), + majorana_atomic_(true) {} + +MajoranaMapping MajoranaMapping::from_table(std::vector table, + std::string name) { + if (table.empty()) { throw std::invalid_argument("MajoranaMapping table must not be empty"); } - if (table_.size() % 2 != 0) { + if (table.size() % 2 != 0) { throw std::invalid_argument( "MajoranaMapping table must have an even number of entries " "(2 per fermionic mode), got " + - std::to_string(table_.size())); + std::to_string(table.size())); } + auto num_modes = table.size() / 2; + auto num_qubits = compute_num_qubits(table); + return MajoranaMapping(std::move(table), std::move(name), num_modes, + num_qubits); } MajoranaMapping::MajoranaMapping( @@ -293,7 +301,7 @@ MajoranaMapping MajoranaMapping::jordan_wigner(std::size_t num_modes) { table.push_back(build_sorted_word(std::move(odd_entries))); } - return MajoranaMapping(std::move(table), "jordan-wigner"); + return MajoranaMapping::from_table(std::move(table), "jordan-wigner"); } // ── Factory: Bravyi-Kitaev ─────────────────────────────────────────── @@ -352,7 +360,7 @@ MajoranaMapping MajoranaMapping::bravyi_kitaev(std::size_t num_modes) { } } - return MajoranaMapping(std::move(table), "bravyi-kitaev"); + return MajoranaMapping::from_table(std::move(table), "bravyi-kitaev"); } // ── Factory: Bravyi-Kitaev tree ────────────────────────────────────── @@ -441,7 +449,7 @@ MajoranaMapping MajoranaMapping::bravyi_kitaev_tree(std::size_t num_modes) { } } - return MajoranaMapping(std::move(table), "bravyi-kitaev-tree"); + return MajoranaMapping::from_table(std::move(table), "bravyi-kitaev-tree"); } // ── Factory: Parity ────────────────────────────────────────────────── @@ -478,7 +486,7 @@ MajoranaMapping MajoranaMapping::parity(std::size_t num_modes) { } } - return MajoranaMapping(std::move(table), "parity"); + return MajoranaMapping::from_table(std::move(table), "parity"); } } // namespace qdk::chemistry::data diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index 749c9e85c..f440516c0 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -28,16 +28,18 @@ is computed on demand from the table. Sparse Pauli words use the little-endian convention of QubitHamiltonian (qubit 0 has the smallest index). )"); - mapping.def(py::init([](const std::vector& table, - const std::string& name) { - try { - return MajoranaMapping(table, name); - } catch (const std::invalid_argument& e) { - throw py::value_error(e.what()); - } - }), - py::arg("table"), py::arg("name") = "", - "Construct from a list of 2N sparse Pauli words."); + mapping.def_static( + "from_table", + [](const std::vector& table, const std::string& name) { + try { + return MajoranaMapping::from_table(table, name); + } catch (const std::invalid_argument& e) { + throw py::value_error(e.what()); + } + }, + py::arg("table"), py::arg("name") = "", + "Construct a Majorana-atomic mapping from a list of 2N sparse Pauli " + "words."); mapping.def_property_readonly( "num_modes", [](const MajoranaMapping& self) { return self.num_modes(); }, diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index 02314ffc7..72f1c44fc 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -140,7 +140,7 @@ def __init__( if table and len({len(label) for label in table}) > 1: raise ValueError("All Pauli strings must have the same length") sparse_table = [_dense_le_to_sparse(label) for label in table] - self._core = _CoreMajoranaMapping(sparse_table, name) + self._core = _CoreMajoranaMapping.from_table(sparse_table, name) # Cache immutable properties from the core self._name = name if name else self._core.name diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index 0fba1f18e..aa41a55eb 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -173,6 +173,69 @@ def test_from_mode_pairs_equivalence(self) -> None: assert jw.table == pairs.table +class TestFromBilinears: + """Tests for the from_bilinears construction.""" + + def test_bilinear_only_basic(self) -> None: + """Bilinear-only mapping stores and retrieves bilinears correctly.""" + jw = MajoranaMapping.jordan_wigner(num_modes=2) + bilinears: dict[tuple[int, int], tuple[complex, str]] = {} + for j in range(4): + for k in range(j + 1, 4): + coeff, pauli = jw.bilinear(j, k) + bilinears[(j, k)] = (coeff, pauli) + + bl = MajoranaMapping.from_bilinears(num_modes=2, bilinears=bilinears, name="test-bl") + assert bl.num_modes == 2 + assert bl.name == "test-bl" + assert bl.is_majorana_atomic is False + assert bl.table == () + + def test_bilinear_lookup_matches_table(self) -> None: + """Bilinear-only mapping reproduces the same bilinears as the table form.""" + jw = MajoranaMapping.jordan_wigner(num_modes=3) + bilinears: dict[tuple[int, int], tuple[complex, str]] = {} + for j in range(6): + for k in range(j + 1, 6): + coeff, pauli = jw.bilinear(j, k) + bilinears[(j, k)] = (coeff, pauli) + + bl = MajoranaMapping.from_bilinears(num_modes=3, bilinears=bilinears) + for j in range(6): + for k in range(j + 1, 6): + c_bl, p_bl = bl.bilinear(j, k) + c_jw, p_jw = jw.bilinear(j, k) + assert p_bl == p_jw, f"bilinear({j},{k}): {p_bl} != {p_jw}" + assert abs(c_bl - c_jw) < 1e-12, f"bilinear({j},{k}) coeff: {c_bl} != {c_jw}" + + def test_bilinear_antisymmetry(self) -> None: + """bilinear(k,j) = -bilinear(j,k) for bilinear-only mappings.""" + jw = MajoranaMapping.jordan_wigner(num_modes=2) + bilinears: dict[tuple[int, int], tuple[complex, str]] = {} + for j in range(4): + for k in range(j + 1, 4): + bilinears[(j, k)] = jw.bilinear(j, k) + + bl = MajoranaMapping.from_bilinears(num_modes=2, bilinears=bilinears) + for j in range(4): + for k in range(j + 1, 4): + c_fwd, p_fwd = bl.bilinear(j, k) + c_rev, p_rev = bl.bilinear(k, j) + assert p_fwd == p_rev + assert abs(c_fwd + c_rev) < 1e-12 + + def test_majorana_raises_for_bilinear_only(self) -> None: + """majorana(k) raises ValueError for bilinear-only mappings.""" + jw = MajoranaMapping.jordan_wigner(num_modes=2) + bilinears: dict[tuple[int, int], tuple[complex, str]] = {} + for j in range(4): + for k in range(j + 1, 4): + bilinears[(j, k)] = jw.bilinear(j, k) + bl = MajoranaMapping.from_bilinears(num_modes=2, bilinears=bilinears) + with pytest.raises(ValueError, match="bilinear-only"): + bl.majorana(0) + + # ─── Validation Tests ──────────────────────────────────────────────────── From d7653ee3163b221ce5ed7d50cab5d3824f2ad809 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 30 May 2026 00:16:02 +0000 Subject: [PATCH 089/117] refactor: split factories into separate file, fix docs examples Extract JW/BK/BK-tree/parity factory methods and BK index-set helpers into majorana_mapping_factories.cpp (313 lines). Core class stays in majorana_mapping.cpp (150 lines, down from 492). Also rename OP_X/OP_Y/OP_Z to op_x/op_y/op_z (snake_case) in the factories file. Fix all docs examples to use hamiltonian.get_orbitals(). get_num_molecular_orbitals() instead of the fragile hamiltonian.get_one_body_integrals()[0].shape[0] pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cpp/src/qdk/chemistry/data/CMakeLists.txt | 1 + .../qdk/chemistry/data/majorana_mapping.cpp | 342 ------------------ .../data/majorana_mapping_factories.cpp | 313 ++++++++++++++++ .../_static/examples/python/circuit_mapper.py | 2 +- .../python/hamiltonian_unitary_builder.py | 2 +- .../examples/python/phase_estimation.py | 2 +- .../_static/examples/python/qubit_mapper.py | 2 +- .../_static/examples/python/quickstart.py | 2 +- .../molecular_hamiltonian_jordan_wigner.py | 2 +- .../qiskit/iqpe_no_trotter.py | 2 +- .../interoperability/qiskit/iqpe_trotter.py | 2 +- 11 files changed, 322 insertions(+), 350 deletions(-) create mode 100644 cpp/src/qdk/chemistry/data/majorana_mapping_factories.cpp diff --git a/cpp/src/qdk/chemistry/data/CMakeLists.txt b/cpp/src/qdk/chemistry/data/CMakeLists.txt index 054b54e86..3ed703e37 100644 --- a/cpp/src/qdk/chemistry/data/CMakeLists.txt +++ b/cpp/src/qdk/chemistry/data/CMakeLists.txt @@ -13,6 +13,7 @@ target_sources(chemistry PRIVATE hamiltonian_containers/sparse.cpp majorana_map_engine.cpp majorana_mapping.cpp + majorana_mapping_factories.cpp orbitals.cpp pauli_operator.cpp settings.cpp diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp index 1389a2db2..2e4074b3e 100644 --- a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp @@ -2,8 +2,6 @@ // Licensed under the MIT License. See LICENSE.txt in the project root for // license information. -#include -#include #include #include #include @@ -14,130 +12,6 @@ namespace qdk::chemistry::data { -namespace detail { - -// ── BK index-set computation ────────────────────────────────────────── - -/// Smallest power of 2 ≥ n. -std::size_t next_power_of_two(std::size_t n) { - if (n == 0) return 1; - std::size_t p = 1; - while (p < n) p <<= 1; - return p; -} - -/// Parity set P(j) for BK encoding. -/// P(j) contains qubit indices whose cumulative parity encodes the -/// occupation of all orbitals with index < j. -std::vector bk_parity_set(std::uint64_t j, std::size_t n) { - // n must be a power of 2 - if (n <= 1) return {}; - std::size_t half = n / 2; - if (j < half) { - return bk_parity_set(j, half); - } - auto sub = bk_parity_set(j - half, half); - std::vector result; - result.reserve(sub.size() + 1); - result.push_back(static_cast(half - 1)); - for (auto idx : sub) { - result.push_back(idx + static_cast(half)); - } - return result; -} - -/// Update (ancestor) set U(j) for BK encoding. -/// U(j) contains qubit indices whose stored parity must be flipped when -/// orbital j is occupied. -std::vector bk_update_set(std::uint64_t j, std::size_t n) { - if (n <= 1) return {}; - std::size_t half = n / 2; - if (j < half) { - auto sub = bk_update_set(j, half); - sub.push_back(static_cast(n - 1)); - return sub; - } - auto sub = bk_update_set(j - half, half); - std::vector result; - result.reserve(sub.size()); - for (auto idx : sub) { - result.push_back(idx + static_cast(half)); - } - return result; -} - -/// Flip (children) set F(j) for BK encoding. -std::vector bk_flip_set(std::uint64_t j, std::size_t n) { - if (n <= 1) return {}; - std::size_t half = n / 2; - if (j < half) { - return bk_flip_set(j, half); - } - if (j < static_cast(n - 1)) { - auto sub = bk_flip_set(j - half, half); - std::vector result; - result.reserve(sub.size()); - for (auto idx : sub) { - result.push_back(idx + static_cast(half)); - } - return result; - } - // j == n-1 - auto sub = bk_flip_set(j - half, half); - std::vector result; - result.reserve(sub.size() + 1); - result.push_back(static_cast(half - 1)); - for (auto idx : sub) { - result.push_back(idx + static_cast(half)); - } - return result; -} - -/// Remainder set R(j) = P(j) \ F(j) for BK encoding. -std::vector bk_remainder_set(std::uint64_t j, std::size_t n) { - auto parity = bk_parity_set(j, n); - auto flip = bk_flip_set(j, n); - - // Convert flip to a set for O(1) lookup - std::vector in_flip(n, false); - for (auto idx : flip) { - if (idx < n) in_flip[idx] = true; - } - - std::vector result; - for (auto idx : parity) { - if (idx >= n || !in_flip[idx]) { - result.push_back(idx); - } - } - return result; -} - -/// Build a SparsePauliWord from qubit assignments. The word is sorted by -/// qubit index (required invariant for SparsePauliWord). -SparsePauliWord build_sorted_word( - std::vector> entries) { - std::sort(entries.begin(), entries.end()); - return entries; -} - -// Pauli operator type constants (matching pauli_operator.hpp convention) -constexpr std::uint8_t OP_X = 1; -constexpr std::uint8_t OP_Y = 2; -constexpr std::uint8_t OP_Z = 3; - -} // namespace detail - -using detail::bk_flip_set; -using detail::bk_parity_set; -using detail::bk_remainder_set; -using detail::bk_update_set; -using detail::build_sorted_word; -using detail::next_power_of_two; -using detail::OP_X; -using detail::OP_Y; -using detail::OP_Z; - // ── MajoranaMapping implementation ─────────────────────────────────── MajoranaMapping::MajoranaMapping(std::vector table, @@ -273,220 +147,4 @@ std::size_t MajoranaMapping::compute_num_qubits( return has_any ? static_cast(max_idx + 1) : 0; } -// ── Factory: Jordan-Wigner ─────────────────────────────────────────── - -MajoranaMapping MajoranaMapping::jordan_wigner(std::size_t num_modes) { - if (num_modes == 0) { - throw std::invalid_argument("jordan_wigner requires num_modes > 0"); - } - - std::vector table; - table.reserve(2 * num_modes); - - for (std::size_t j = 0; j < num_modes; ++j) { - // γ_{2j} = Z_{j-1} ... Z_0 ⊗ X_j - std::vector> even_entries; - for (std::size_t k = 0; k < j; ++k) { - even_entries.emplace_back(static_cast(k), OP_Z); - } - even_entries.emplace_back(static_cast(j), OP_X); - table.push_back(build_sorted_word(std::move(even_entries))); - - // γ_{2j+1} = Z_{j-1} ... Z_0 ⊗ Y_j - std::vector> odd_entries; - for (std::size_t k = 0; k < j; ++k) { - odd_entries.emplace_back(static_cast(k), OP_Z); - } - odd_entries.emplace_back(static_cast(j), OP_Y); - table.push_back(build_sorted_word(std::move(odd_entries))); - } - - return MajoranaMapping::from_table(std::move(table), "jordan-wigner"); -} - -// ── Factory: Bravyi-Kitaev ─────────────────────────────────────────── - -MajoranaMapping MajoranaMapping::bravyi_kitaev(std::size_t num_modes) { - if (num_modes == 0) { - throw std::invalid_argument("bravyi_kitaev requires num_modes > 0"); - } - - // BK index sets are defined on a binary tree of size 2^ceil(log2(n)) - std::size_t tree_size = next_power_of_two(num_modes); - - std::vector table; - table.reserve(2 * num_modes); - - for (std::size_t j = 0; j < num_modes; ++j) { - auto parity = bk_parity_set(static_cast(j), tree_size); - auto update = bk_update_set(static_cast(j), tree_size); - auto remainder = bk_remainder_set(static_cast(j), tree_size); - - // Filter out indices ≥ num_modes (virtual tree nodes beyond actual qubits) - auto filter = [num_modes](std::vector& v) { - v.erase(std::remove_if( - v.begin(), v.end(), - [num_modes](std::uint64_t idx) { return idx >= num_modes; }), - v.end()); - }; - filter(parity); - filter(update); - filter(remainder); - - // γ_{2j} = X_{U(j)} ⊗ X_j ⊗ Z_{P(j)} - { - std::vector> entries; - entries.emplace_back(static_cast(j), OP_X); - for (auto q : parity) { - entries.emplace_back(q, OP_Z); - } - for (auto q : update) { - entries.emplace_back(q, OP_X); - } - table.push_back(build_sorted_word(std::move(entries))); - } - - // γ_{2j+1} = X_{U(j)} ⊗ Y_j ⊗ Z_{R(j)} - { - std::vector> entries; - entries.emplace_back(static_cast(j), OP_Y); - for (auto q : remainder) { - entries.emplace_back(q, OP_Z); - } - for (auto q : update) { - entries.emplace_back(q, OP_X); - } - table.push_back(build_sorted_word(std::move(entries))); - } - } - - return MajoranaMapping::from_table(std::move(table), "bravyi-kitaev"); -} - -// ── Factory: Bravyi-Kitaev tree ────────────────────────────────────── - -MajoranaMapping MajoranaMapping::bravyi_kitaev_tree(std::size_t num_modes) { - if (num_modes == 0) { - throw std::invalid_argument("bravyi_kitaev_tree requires num_modes > 0"); - } - - // Build the balanced binary tree (Algorithm 1 from arXiv:1701.07072). - // parent[j] = parent index of node j (-1 for root). - // children[j] = list of child indices of node j. - std::vector parent(num_modes, -1); - std::vector> children(num_modes); - - // Recursive tree builder: range [left, right), pivot = midpoint. - // Left half goes under pivot; right half goes under parent_idx. - auto build_tree = [&](auto& self, std::size_t left, std::size_t right, - std::size_t parent_idx) -> void { - if (left >= right) return; - std::size_t pivot = (left + right) >> 1; - parent[pivot] = static_cast(parent_idx); - children[parent_idx].push_back(pivot); - self(self, left, pivot, pivot); // left subtree under pivot - self(self, pivot + 1, right, parent_idx); // right subtree under parent - }; - - // Root is node (num_modes - 1). Build tree on [0, num_modes - 1). - if (num_modes > 1) { - build_tree(build_tree, 0, num_modes - 1, num_modes - 1); - } - - std::vector table; - table.reserve(2 * num_modes); - - for (std::size_t j = 0; j < num_modes; ++j) { - // U(j) = ancestors of j (walk up parent chain, excluding j itself) - std::vector update; - for (int64_t p = parent[j]; p >= 0; p = parent[static_cast(p)]) { - update.push_back(static_cast(p)); - } - - // F(j) = children of j - // C(j) = for each ancestor of j, children of that ancestor with index < j - std::vector child_set; - for (auto c : children[j]) { - child_set.push_back(static_cast(c)); - } - std::vector remainder; - for (int64_t p = parent[j]; p >= 0; p = parent[static_cast(p)]) { - for (auto c : children[static_cast(p)]) { - if (c < j) { - remainder.push_back(static_cast(c)); - } - } - } - - // P(j) = C(j) ∪ F(j) - std::vector parity_set = remainder; - parity_set.insert(parity_set.end(), child_set.begin(), child_set.end()); - - // γ_{2j} = X_j · Z_{P(j)} · X_{U(j)} - { - std::vector> entries; - entries.emplace_back(static_cast(j), OP_X); - for (auto q : parity_set) { - entries.emplace_back(q, OP_Z); - } - for (auto q : update) { - entries.emplace_back(q, OP_X); - } - table.push_back(build_sorted_word(std::move(entries))); - } - - // γ_{2j+1} = Y_j · Z_{C(j)} · X_{U(j)} - { - std::vector> entries; - entries.emplace_back(static_cast(j), OP_Y); - for (auto q : remainder) { - entries.emplace_back(q, OP_Z); - } - for (auto q : update) { - entries.emplace_back(q, OP_X); - } - table.push_back(build_sorted_word(std::move(entries))); - } - } - - return MajoranaMapping::from_table(std::move(table), "bravyi-kitaev-tree"); -} - -// ── Factory: Parity ────────────────────────────────────────────────── - -MajoranaMapping MajoranaMapping::parity(std::size_t num_modes) { - if (num_modes == 0) { - throw std::invalid_argument("parity requires num_modes > 0"); - } - - std::vector table; - table.reserve(2 * num_modes); - - for (std::size_t j = 0; j < num_modes; ++j) { - // γ_{2j} = Z_{j-1} (if j>0) · X_j · X_{j+1} · ... · X_{n-1} - { - std::vector> entries; - if (j > 0) { - entries.emplace_back(static_cast(j - 1), OP_Z); - } - for (std::size_t k = j; k < num_modes; ++k) { - entries.emplace_back(static_cast(k), OP_X); - } - table.push_back(build_sorted_word(std::move(entries))); - } - - // γ_{2j+1} = Y_j · X_{j+1} · ... · X_{n-1} - { - std::vector> entries; - entries.emplace_back(static_cast(j), OP_Y); - for (std::size_t k = j + 1; k < num_modes; ++k) { - entries.emplace_back(static_cast(k), OP_X); - } - table.push_back(build_sorted_word(std::move(entries))); - } - } - - return MajoranaMapping::from_table(std::move(table), "parity"); -} - } // namespace qdk::chemistry::data diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping_factories.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping_factories.cpp new file mode 100644 index 000000000..5fc97ee79 --- /dev/null +++ b/cpp/src/qdk/chemistry/data/majorana_mapping_factories.cpp @@ -0,0 +1,313 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for +// license information. + +#include +#include +#include +#include +#include +#include +#include + +namespace qdk::chemistry::data { +namespace detail { + +std::size_t next_power_of_two(std::size_t n) { + if (n == 0) return 1; + std::size_t p = 1; + while (p < n) p <<= 1; + return p; +} + +std::vector bk_parity_set(std::uint64_t j, std::size_t n) { + if (n <= 1) return {}; + std::size_t half = n / 2; + if (j < half) { + return bk_parity_set(j, half); + } + auto sub = bk_parity_set(j - half, half); + std::vector result; + result.reserve(sub.size() + 1); + result.push_back(static_cast(half - 1)); + for (auto idx : sub) { + result.push_back(idx + static_cast(half)); + } + return result; +} + +std::vector bk_update_set(std::uint64_t j, std::size_t n) { + if (n <= 1) return {}; + std::size_t half = n / 2; + if (j < half) { + auto sub = bk_update_set(j, half); + sub.push_back(static_cast(n - 1)); + return sub; + } + auto sub = bk_update_set(j - half, half); + std::vector result; + result.reserve(sub.size()); + for (auto idx : sub) { + result.push_back(idx + static_cast(half)); + } + return result; +} + +std::vector bk_flip_set(std::uint64_t j, std::size_t n) { + if (n <= 1) return {}; + std::size_t half = n / 2; + if (j < half) { + return bk_flip_set(j, half); + } + if (j < static_cast(n - 1)) { + auto sub = bk_flip_set(j - half, half); + std::vector result; + result.reserve(sub.size()); + for (auto idx : sub) { + result.push_back(idx + static_cast(half)); + } + return result; + } + auto sub = bk_flip_set(j - half, half); + std::vector result; + result.reserve(sub.size() + 1); + result.push_back(static_cast(half - 1)); + for (auto idx : sub) { + result.push_back(idx + static_cast(half)); + } + return result; +} + +std::vector bk_remainder_set(std::uint64_t j, std::size_t n) { + auto parity = bk_parity_set(j, n); + auto flip = bk_flip_set(j, n); + + std::vector in_flip(n, false); + for (auto idx : flip) { + if (idx < n) in_flip[idx] = true; + } + + std::vector result; + for (auto idx : parity) { + if (idx >= n || !in_flip[idx]) { + result.push_back(idx); + } + } + return result; +} + +SparsePauliWord build_sorted_word( + std::vector> entries) { + std::sort(entries.begin(), entries.end()); + return entries; +} + +constexpr std::uint8_t op_x = 1; +constexpr std::uint8_t op_y = 2; +constexpr std::uint8_t op_z = 3; + +} // namespace detail + +// ── Factory: Jordan-Wigner ─────────────────────────────────────────── + +MajoranaMapping MajoranaMapping::jordan_wigner(std::size_t num_modes) { + using namespace detail; + if (num_modes == 0) { + throw std::invalid_argument("jordan_wigner requires num_modes > 0"); + } + + std::vector table; + table.reserve(2 * num_modes); + + for (std::size_t j = 0; j < num_modes; ++j) { + std::vector> even_entries; + for (std::size_t k = 0; k < j; ++k) { + even_entries.emplace_back(static_cast(k), op_z); + } + even_entries.emplace_back(static_cast(j), op_x); + table.push_back(build_sorted_word(std::move(even_entries))); + + std::vector> odd_entries; + for (std::size_t k = 0; k < j; ++k) { + odd_entries.emplace_back(static_cast(k), op_z); + } + odd_entries.emplace_back(static_cast(j), op_y); + table.push_back(build_sorted_word(std::move(odd_entries))); + } + + return MajoranaMapping::from_table(std::move(table), "jordan-wigner"); +} + +// ── Factory: Bravyi-Kitaev ─────────────────────────────────────────── + +MajoranaMapping MajoranaMapping::bravyi_kitaev(std::size_t num_modes) { + using namespace detail; + if (num_modes == 0) { + throw std::invalid_argument("bravyi_kitaev requires num_modes > 0"); + } + + std::size_t tree_size = next_power_of_two(num_modes); + + std::vector table; + table.reserve(2 * num_modes); + + for (std::size_t j = 0; j < num_modes; ++j) { + auto parity = bk_parity_set(static_cast(j), tree_size); + auto update = bk_update_set(static_cast(j), tree_size); + auto remainder = bk_remainder_set(static_cast(j), tree_size); + + auto filter = [num_modes](std::vector& v) { + v.erase(std::remove_if( + v.begin(), v.end(), + [num_modes](std::uint64_t idx) { return idx >= num_modes; }), + v.end()); + }; + filter(parity); + filter(update); + filter(remainder); + + { + std::vector> entries; + entries.emplace_back(static_cast(j), op_x); + for (auto q : parity) { + entries.emplace_back(q, op_z); + } + for (auto q : update) { + entries.emplace_back(q, op_x); + } + table.push_back(build_sorted_word(std::move(entries))); + } + + { + std::vector> entries; + entries.emplace_back(static_cast(j), op_y); + for (auto q : remainder) { + entries.emplace_back(q, op_z); + } + for (auto q : update) { + entries.emplace_back(q, op_x); + } + table.push_back(build_sorted_word(std::move(entries))); + } + } + + return MajoranaMapping::from_table(std::move(table), "bravyi-kitaev"); +} + +// ── Factory: Bravyi-Kitaev tree ────────────────────────────────────── + +MajoranaMapping MajoranaMapping::bravyi_kitaev_tree(std::size_t num_modes) { + using namespace detail; + if (num_modes == 0) { + throw std::invalid_argument("bravyi_kitaev_tree requires num_modes > 0"); + } + + std::vector parent(num_modes, -1); + std::vector> children(num_modes); + + auto build_tree = [&](auto& self, std::size_t left, std::size_t right, + std::size_t parent_idx) -> void { + if (left >= right) return; + std::size_t pivot = (left + right) >> 1; + parent[pivot] = static_cast(parent_idx); + children[parent_idx].push_back(pivot); + self(self, left, pivot, pivot); + self(self, pivot + 1, right, parent_idx); + }; + + if (num_modes > 1) { + build_tree(build_tree, 0, num_modes - 1, num_modes - 1); + } + + std::vector table; + table.reserve(2 * num_modes); + + for (std::size_t j = 0; j < num_modes; ++j) { + std::vector update; + for (int64_t p = parent[j]; p >= 0; + p = parent[static_cast(p)]) { + update.push_back(static_cast(p)); + } + + std::vector child_set; + for (auto c : children[j]) { + child_set.push_back(static_cast(c)); + } + std::vector remainder; + for (int64_t p = parent[j]; p >= 0; + p = parent[static_cast(p)]) { + for (auto c : children[static_cast(p)]) { + if (c < j) { + remainder.push_back(static_cast(c)); + } + } + } + + std::vector parity_set = remainder; + parity_set.insert(parity_set.end(), child_set.begin(), child_set.end()); + + { + std::vector> entries; + entries.emplace_back(static_cast(j), op_x); + for (auto q : parity_set) { + entries.emplace_back(q, op_z); + } + for (auto q : update) { + entries.emplace_back(q, op_x); + } + table.push_back(build_sorted_word(std::move(entries))); + } + + { + std::vector> entries; + entries.emplace_back(static_cast(j), op_y); + for (auto q : remainder) { + entries.emplace_back(q, op_z); + } + for (auto q : update) { + entries.emplace_back(q, op_x); + } + table.push_back(build_sorted_word(std::move(entries))); + } + } + + return MajoranaMapping::from_table(std::move(table), "bravyi-kitaev-tree"); +} + +// ── Factory: Parity ────────────────────────────────────────────────── + +MajoranaMapping MajoranaMapping::parity(std::size_t num_modes) { + using namespace detail; + if (num_modes == 0) { + throw std::invalid_argument("parity requires num_modes > 0"); + } + + std::vector table; + table.reserve(2 * num_modes); + + for (std::size_t j = 0; j < num_modes; ++j) { + { + std::vector> entries; + if (j > 0) { + entries.emplace_back(static_cast(j - 1), op_z); + } + for (std::size_t k = j; k < num_modes; ++k) { + entries.emplace_back(static_cast(k), op_x); + } + table.push_back(build_sorted_word(std::move(entries))); + } + + { + std::vector> entries; + entries.emplace_back(static_cast(j), op_y); + for (std::size_t k = j + 1; k < num_modes; ++k) { + entries.emplace_back(static_cast(k), op_x); + } + table.push_back(build_sorted_word(std::move(entries))); + } + } + + return MajoranaMapping::from_table(std::move(table), "parity"); +} + +} // namespace qdk::chemistry::data diff --git a/docs/source/_static/examples/python/circuit_mapper.py b/docs/source/_static/examples/python/circuit_mapper.py index 65ce791d6..3e9fb4695 100644 --- a/docs/source/_static/examples/python/circuit_mapper.py +++ b/docs/source/_static/examples/python/circuit_mapper.py @@ -36,7 +36,7 @@ hamiltonian = hamiltonian_constructor.run(wfn_scf.get_orbitals()) from qdk_chemistry.data.majorana_mapping import MajoranaMapping -n_spin_orbitals = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] +n_spin_orbitals = 2 * hamiltonian.get_orbitals().get_num_molecular_orbitals() qubit_mapper = create("qubit_mapper") qubit_ham = qubit_mapper.run( hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals) diff --git a/docs/source/_static/examples/python/hamiltonian_unitary_builder.py b/docs/source/_static/examples/python/hamiltonian_unitary_builder.py index b5fcfb0c6..812939815 100644 --- a/docs/source/_static/examples/python/hamiltonian_unitary_builder.py +++ b/docs/source/_static/examples/python/hamiltonian_unitary_builder.py @@ -70,7 +70,7 @@ # 4. Qubit mapping from qdk_chemistry.data.majorana_mapping import MajoranaMapping -n_spin_orbitals = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] +n_spin_orbitals = 2 * hamiltonian.get_orbitals().get_num_molecular_orbitals() qubit_mapper = create("qubit_mapper") qubit_ham = qubit_mapper.run( hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals) diff --git a/docs/source/_static/examples/python/phase_estimation.py b/docs/source/_static/examples/python/phase_estimation.py index da069bbdc..8e25d3d3c 100644 --- a/docs/source/_static/examples/python/phase_estimation.py +++ b/docs/source/_static/examples/python/phase_estimation.py @@ -64,7 +64,7 @@ # 5. Qubit mapping from qdk_chemistry.data.majorana_mapping import MajoranaMapping -n_spin_orbitals = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] +n_spin_orbitals = 2 * hamiltonian.get_orbitals().get_num_molecular_orbitals() qubit_mapper = create("qubit_mapper") qubit_ham = qubit_mapper.run( hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals) diff --git a/docs/source/_static/examples/python/qubit_mapper.py b/docs/source/_static/examples/python/qubit_mapper.py index 9efe059e5..bad0ff966 100644 --- a/docs/source/_static/examples/python/qubit_mapper.py +++ b/docs/source/_static/examples/python/qubit_mapper.py @@ -53,7 +53,7 @@ hamiltonian = hamiltonian_constructor.run(active_orbitals) # Determine the number of spin-orbitals -n_spatial = hamiltonian.get_one_body_integrals()[0].shape[0] +n_spatial = hamiltonian.get_orbitals().get_num_molecular_orbitals() n_spin_orbitals = 2 * n_spatial # Choose an encoding diff --git a/docs/source/_static/examples/python/quickstart.py b/docs/source/_static/examples/python/quickstart.py index ac2693f48..5b90e2148 100644 --- a/docs/source/_static/examples/python/quickstart.py +++ b/docs/source/_static/examples/python/quickstart.py @@ -105,7 +105,7 @@ # Prepare qubit Hamiltonian from qdk_chemistry.data.majorana_mapping import MajoranaMapping -n_spin_orbitals = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] +n_spin_orbitals = 2 * hamiltonian.get_orbitals().get_num_molecular_orbitals() qubit_mapper = create("qubit_mapper", algorithm_name="qdk") qubit_hamiltonian = qubit_mapper.run( hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals) diff --git a/examples/interoperability/openFermion/molecular_hamiltonian_jordan_wigner.py b/examples/interoperability/openFermion/molecular_hamiltonian_jordan_wigner.py index ae534ff16..431195dcb 100644 --- a/examples/interoperability/openFermion/molecular_hamiltonian_jordan_wigner.py +++ b/examples/interoperability/openFermion/molecular_hamiltonian_jordan_wigner.py @@ -76,7 +76,7 @@ # 3. QDK → OpenFermion: map to qubits via the plugin, then exact-diagonalise ######################################################################################## mapper = create("qubit_mapper", "openfermion") -n_spin_orbitals = 2 * active_hamiltonian.get_one_body_integrals()[0].shape[0] +n_spin_orbitals = 2 * active_hamiltonian.get_orbitals().get_num_molecular_orbitals() mapping = MajoranaMapping.jordan_wigner(n_spin_orbitals) qdk_qubit_ham = mapper.run(active_hamiltonian, mapping) diff --git a/examples/interoperability/qiskit/iqpe_no_trotter.py b/examples/interoperability/qiskit/iqpe_no_trotter.py index 9913e09b1..d2d9e2c64 100644 --- a/examples/interoperability/qiskit/iqpe_no_trotter.py +++ b/examples/interoperability/qiskit/iqpe_no_trotter.py @@ -217,7 +217,7 @@ def run_iterative_exact_qpe( # 3. Preparing the qubit Hamiltonian and sparse-isometry trial state ######################################################################################## qubit_mapper = create("qubit_mapper", "qiskit") -n_spin_orbitals = 2 * active_hamiltonian.get_one_body_integrals()[0].shape[0] +n_spin_orbitals = 2 * active_hamiltonian.get_orbitals().get_num_molecular_orbitals() qubit_hamiltonian = qubit_mapper.run( active_hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals) ) diff --git a/examples/interoperability/qiskit/iqpe_trotter.py b/examples/interoperability/qiskit/iqpe_trotter.py index 46e613984..9bc892edd 100644 --- a/examples/interoperability/qiskit/iqpe_trotter.py +++ b/examples/interoperability/qiskit/iqpe_trotter.py @@ -82,7 +82,7 @@ # 3. Preparing the qubit Hamiltonian and sparse-isometry trial state ######################################################################################## qubit_mapper = create("qubit_mapper", "qiskit") -n_spin_orbitals = 2 * active_hamiltonian.get_one_body_integrals()[0].shape[0] +n_spin_orbitals = 2 * active_hamiltonian.get_orbitals().get_num_molecular_orbitals() qubit_hamiltonian = qubit_mapper.run( active_hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals) ) From b4eff27f10d702c97a1881d46f5828879e828a4a Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 30 May 2026 00:22:18 +0000 Subject: [PATCH 090/117] fix: use public import path for MajoranaMapping in examples from qdk_chemistry.data import MajoranaMapping (not the private module path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/source/_static/examples/python/circuit_mapper.py | 2 +- .../_static/examples/python/hamiltonian_unitary_builder.py | 2 +- docs/source/_static/examples/python/phase_estimation.py | 2 +- docs/source/_static/examples/python/quickstart.py | 2 +- .../openFermion/molecular_hamiltonian_jordan_wigner.py | 2 +- examples/interoperability/qiskit/iqpe_no_trotter.py | 2 +- examples/interoperability/qiskit/iqpe_trotter.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/source/_static/examples/python/circuit_mapper.py b/docs/source/_static/examples/python/circuit_mapper.py index 3e9fb4695..6e0495640 100644 --- a/docs/source/_static/examples/python/circuit_mapper.py +++ b/docs/source/_static/examples/python/circuit_mapper.py @@ -34,7 +34,7 @@ # 3. Hamiltonian and qubit mapping hamiltonian_constructor = create("hamiltonian_constructor") hamiltonian = hamiltonian_constructor.run(wfn_scf.get_orbitals()) -from qdk_chemistry.data.majorana_mapping import MajoranaMapping +from qdk_chemistry.data import MajoranaMapping n_spin_orbitals = 2 * hamiltonian.get_orbitals().get_num_molecular_orbitals() qubit_mapper = create("qubit_mapper") diff --git a/docs/source/_static/examples/python/hamiltonian_unitary_builder.py b/docs/source/_static/examples/python/hamiltonian_unitary_builder.py index 812939815..d283b655d 100644 --- a/docs/source/_static/examples/python/hamiltonian_unitary_builder.py +++ b/docs/source/_static/examples/python/hamiltonian_unitary_builder.py @@ -68,7 +68,7 @@ hamiltonian = hamiltonian_constructor.run(wfn_scf.get_orbitals()) # 4. Qubit mapping -from qdk_chemistry.data.majorana_mapping import MajoranaMapping +from qdk_chemistry.data import MajoranaMapping n_spin_orbitals = 2 * hamiltonian.get_orbitals().get_num_molecular_orbitals() qubit_mapper = create("qubit_mapper") diff --git a/docs/source/_static/examples/python/phase_estimation.py b/docs/source/_static/examples/python/phase_estimation.py index 8e25d3d3c..1ffe65ab6 100644 --- a/docs/source/_static/examples/python/phase_estimation.py +++ b/docs/source/_static/examples/python/phase_estimation.py @@ -62,7 +62,7 @@ E_cas, wfn_cas = cas_solver.run(hamiltonian, 1, 1) # 5. Qubit mapping -from qdk_chemistry.data.majorana_mapping import MajoranaMapping +from qdk_chemistry.data import MajoranaMapping n_spin_orbitals = 2 * hamiltonian.get_orbitals().get_num_molecular_orbitals() qubit_mapper = create("qubit_mapper") diff --git a/docs/source/_static/examples/python/quickstart.py b/docs/source/_static/examples/python/quickstart.py index 5b90e2148..94d9399fa 100644 --- a/docs/source/_static/examples/python/quickstart.py +++ b/docs/source/_static/examples/python/quickstart.py @@ -103,7 +103,7 @@ ################################################################################ # start-cell-qubit-hamiltonian # Prepare qubit Hamiltonian -from qdk_chemistry.data.majorana_mapping import MajoranaMapping +from qdk_chemistry.data import MajoranaMapping n_spin_orbitals = 2 * hamiltonian.get_orbitals().get_num_molecular_orbitals() qubit_mapper = create("qubit_mapper", algorithm_name="qdk") diff --git a/examples/interoperability/openFermion/molecular_hamiltonian_jordan_wigner.py b/examples/interoperability/openFermion/molecular_hamiltonian_jordan_wigner.py index 431195dcb..f26c17d16 100644 --- a/examples/interoperability/openFermion/molecular_hamiltonian_jordan_wigner.py +++ b/examples/interoperability/openFermion/molecular_hamiltonian_jordan_wigner.py @@ -21,7 +21,7 @@ from qdk_chemistry.algorithms import create from qdk_chemistry.constants import ANGSTROM_TO_BOHR from qdk_chemistry.data import Structure -from qdk_chemistry.data.majorana_mapping import MajoranaMapping +from qdk_chemistry.data import MajoranaMapping from qdk_chemistry.utils import Logger # OpenFermion must be installed to run this example. diff --git a/examples/interoperability/qiskit/iqpe_no_trotter.py b/examples/interoperability/qiskit/iqpe_no_trotter.py index d2d9e2c64..a1200cd2e 100644 --- a/examples/interoperability/qiskit/iqpe_no_trotter.py +++ b/examples/interoperability/qiskit/iqpe_no_trotter.py @@ -34,7 +34,7 @@ from qdk_chemistry.algorithms import create from qdk_chemistry.data import QpeResult, Structure -from qdk_chemistry.data.majorana_mapping import MajoranaMapping +from qdk_chemistry.data import MajoranaMapping from qdk_chemistry.utils import Logger from qdk_chemistry.utils.phase import ( iterative_phase_feedback_update, diff --git a/examples/interoperability/qiskit/iqpe_trotter.py b/examples/interoperability/qiskit/iqpe_trotter.py index 9bc892edd..1e3be6112 100644 --- a/examples/interoperability/qiskit/iqpe_trotter.py +++ b/examples/interoperability/qiskit/iqpe_trotter.py @@ -26,7 +26,7 @@ create, ) from qdk_chemistry.data import AlgorithmRef, Circuit, Structure -from qdk_chemistry.data.majorana_mapping import MajoranaMapping +from qdk_chemistry.data import MajoranaMapping from qdk_chemistry.utils import Logger Logger.set_global_level("info") From 9306cbb8b45994a835168f997759d09bc30854ac Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 30 May 2026 00:23:47 +0000 Subject: [PATCH 091/117] revert: restore original QdkQubitMapper class docstring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../algorithms/qubit_mapper/qdk_qubit_mapper.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index 196ae3c79..0701e01fe 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -69,9 +69,9 @@ class QdkQubitMapper(QubitMapper): ``name`` and ``base_encoding`` are used only for metadata on the output :class:`~qdk_chemistry.data.QubitHamiltonian`, not for dispatch. - Both restricted (RHF) and unrestricted (UHF) orbital sets are supported. - For restricted orbitals the engine uses a spin-summed fast path; for - unrestricted orbitals it handles all spin-channel ERI blocks independently. + Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. + For unrestricted systems, the engine handles all four spin-channel ERI + blocks (aa, ab, ba, bb) independently. The mapper uses canonical blocked spin-orbital ordering internally: qubits 0..N-1 for alpha spin, qubits N..2N-1 for beta spin (where N is the From 849c7a0fe44601f6a4ad5cb1084c08651452b501 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 30 May 2026 00:29:53 +0000 Subject: [PATCH 092/117] docs: trim obvious comments from MajoranaMapping docstrings Remove 'tapering is applied downstream', 'passed to Algorithm.run', 'used by QubitMapper' boilerplate from method/property docstrings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../comprehensive/data/majorana_mapping.rst | 2 +- .../qdk_chemistry/data/majorana_mapping.py | 22 ++----------------- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/docs/source/user/comprehensive/data/majorana_mapping.rst b/docs/source/user/comprehensive/data/majorana_mapping.rst index e85016bae..9b8afd49c 100644 --- a/docs/source/user/comprehensive/data/majorana_mapping.rst +++ b/docs/source/user/comprehensive/data/majorana_mapping.rst @@ -35,7 +35,7 @@ Convention The number of fermionic modes (spin-orbitals) in the system. Pauli strings use little-endian qubit ordering, consistent with the rest of QDK/Chemistry's :doc:`PauliOperator ` layer. -:py:meth:`~qdk_chemistry.data.MajoranaMapping.majorana` and :py:meth:`~qdk_chemistry.data.MajoranaMapping.bilinear` return Pauli strings in the encoding's native (pre-taper) qubit basis, i.e. of length ``len(mapping.table[0])``; any tapering specification is applied downstream by the qubit mapper. +:py:meth:`~qdk_chemistry.data.MajoranaMapping.majorana` and :py:meth:`~qdk_chemistry.data.MajoranaMapping.bilinear` return Pauli strings in the encoding's native (pre-taper) qubit basis. Built-in encodings ------------------ diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index 72f1c44fc..33a2d329b 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -188,11 +188,6 @@ def tapering(self) -> TaperingSpecification | None: def without_tapering(self) -> MajoranaMapping: """Return a copy of this mapping with tapering stripped. - The returned mapping has the same Majorana table but ``tapering=None`` - and ``name`` set to :attr:`base_encoding`. Used by - :class:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapper` to separate - the base encoding from post-mapping tapering. - Returns: A new :class:`MajoranaMapping` with no tapering. @@ -201,15 +196,11 @@ def without_tapering(self) -> MajoranaMapping: @property def base_encoding(self) -> str: - """The base encoding name used for the Majorana-to-Pauli table. + """The base encoding name for the Majorana-to-Pauli table. For standard encodings this equals :attr:`name`. For tapering-based encodings it returns the underlying encoding (e.g. ``"bravyi-kitaev-tree"``) while :attr:`name` returns the final label. - - Name-dispatched :class:`~qdk_chemistry.algorithms.QubitMapper` backends - use this property to select the third-party transform; they ignore - :attr:`table` and rebuild the qubit operator from scratch. """ return self._core.name @@ -231,9 +222,7 @@ def majorana(self, k: int) -> str: """Return the Pauli image of the Majorana operator gamma_k. Available only for :py:attr:`is_majorana_atomic` encodings; raises - :class:`ValueError` for bilinear-only mappings. For tapered encodings - the returned Pauli string is in the encoding's native (pre-taper) - qubit basis; tapering is applied downstream. + :class:`ValueError` for bilinear-only mappings. Args: k (int): Majorana index (0 <= k < 2 * num_modes). @@ -257,9 +246,6 @@ def bilinear(self, j: int, k: int) -> tuple[complex, str]: real (±1) for the factory encodings but typed :class:`complex` for generality. - For tapered encodings the returned Pauli string is in the encoding's - native (pre-taper) qubit basis; tapering is applied downstream. - Args: j (int): First Majorana index (0 <= j < 2 * num_modes). k (int): Second Majorana index (0 <= k < 2 * num_modes), must differ from j. @@ -365,10 +351,6 @@ def symmetry_conserving_bravyi_kitaev( symmetry qubits (total electron-number parity and alpha-spin parity), reducing the qubit count by 2. - When passed to :meth:`~qdk_chemistry.algorithms.QubitMapper.run`, the - mapper applies the BK-tree mapping first, then tapers the symmetry - qubits automatically. - Args: num_modes (int): Number of fermionic modes (spin-orbitals). Must be even and >= 4. symmetries (Symmetries): Electron counts for the target symmetry sector. From 7cb69813c5cfcdb24110ff51cda7c2d402b71635 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 30 May 2026 00:36:31 +0000 Subject: [PATCH 093/117] docs: strip obvious comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cpp/include/qdk/chemistry/data/majorana_mapping.hpp | 2 +- cpp/src/qdk/chemistry/data/majorana_map_engine.cpp | 1 - .../algorithms/qubit_mapper/qdk_qubit_mapper.py | 9 +-------- python/src/qdk_chemistry/data/majorana_mapping.py | 2 -- 4 files changed, 2 insertions(+), 12 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index 8258f29cb..28e20487d 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -37,7 +37,7 @@ class MajoranaMapping { * @brief Construct a Majorana-atomic mapping from a 2N-entry table. * * @param table 2N SparsePauliWord entries (gamma_0, ..., gamma_{2N-1}). - * @param name Optional encoding label. Stored but not used for dispatch. + * @param name Optional encoding label. * @throws std::invalid_argument If the table is empty or its size is odd. */ static MajoranaMapping from_table(std::vector table, diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index d3e568f89..6b5d8a7b5 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -197,7 +197,6 @@ MajoranaMapResult majorana_map_impl( PackedAccumulator acc; - // Convert all Majorana table entries to packed form. std::vector> packed_mapping(2 * n_modes); for (std::size_t k = 0; k < 2 * n_modes; ++k) { packed_mapping[k] = sparse_to_packed(mapping(k)); diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index 0701e01fe..17656b96d 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -143,8 +143,6 @@ def _run_impl( h2_aaaa, h2_aabb, h2_bbbb = hamiltonian.get_two_body_integrals() n_spatial = h1_alpha.shape[0] n_spin_orbitals = 2 * n_spatial - # Restricted orbitals produce spin-symmetric integrals, enabling the - # spin-summed fast path in the engine. spin_symmetric = hamiltonian.get_orbitals().is_restricted() if base_mapping.num_modes != n_spin_orbitals: @@ -154,19 +152,15 @@ def _run_impl( f"Use MajoranaMapping.jordan_wigner(num_modes={n_spin_orbitals}) or equivalent." ) - # Use ravel() instead of flatten() to avoid copying contiguous arrays. - # For spin-symmetric integrals (restricted orbitals) the containers - # share the same arrays across spin channels. h1_a_flat = np.ascontiguousarray(h1_alpha).ravel() h1_b_flat = h1_a_flat if spin_symmetric else np.ascontiguousarray(h1_beta).ravel() h2_aaaa_flat = np.ascontiguousarray(h2_aaaa).ravel() h2_aabb_flat = h2_aaaa_flat if spin_symmetric else np.ascontiguousarray(h2_aabb).ravel() h2_bbbb_flat = h2_aaaa_flat if spin_symmetric else np.ascontiguousarray(h2_bbbb).ravel() - # Single C++ call: Majorana-loop engine builds all Pauli terms as sparse words words, coefficients = majorana_map_hamiltonian( base_mapping.core, - 0.0, # core energy not included (QDK convention) + 0.0, h1_a_flat, h1_b_flat, h2_aaaa_flat, @@ -178,7 +172,6 @@ def _run_impl( integral_threshold, ) - # Render sparse words into dense little-endian strings (Python owns string form) n_qubits = base_mapping.num_qubits pauli_strings = [_sparse_to_dense_le(word, n_qubits) for word in words] diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index 33a2d329b..5006f8c55 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -142,14 +142,12 @@ def __init__( sparse_table = [_dense_le_to_sparse(label) for label in table] self._core = _CoreMajoranaMapping.from_table(sparse_table, name) - # Cache immutable properties from the core self._name = name if name else self._core.name self._num_modes = self._core.num_modes self._num_qubits = self._core.num_qubits self._table = tuple(_sparse_to_dense_le(word, self._num_qubits) for word in self._core.table) self._tapering = tapering - # Mark immutable super().__init__() @property From 27d0337908e2152dea9c37fa85731a511c8103f6 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 30 May 2026 00:45:55 +0000 Subject: [PATCH 094/117] =?UTF-8?q?docs:=20drop=20'immutable'=20=E2=80=94?= =?UTF-8?q?=20implied=20by=20data=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cpp/include/qdk/chemistry/data/majorana_mapping.hpp | 2 +- docs/source/user/comprehensive/data/majorana_mapping.rst | 4 ++-- python/src/pybind11/data/majorana_mapping.cpp | 2 +- python/src/qdk_chemistry/data/majorana_mapping.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index 28e20487d..9a02c51fd 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -13,7 +13,7 @@ namespace qdk::chemistry::data { /** - * @brief Immutable data class describing a fermion-to-qubit encoding. + * @brief Data class describing a fermion-to-qubit encoding. * * A MajoranaMapping encodes 2N Majorana operators into Pauli operators. * Two construction forms are supported: diff --git a/docs/source/user/comprehensive/data/majorana_mapping.rst b/docs/source/user/comprehensive/data/majorana_mapping.rst index 9b8afd49c..a4fb83964 100644 --- a/docs/source/user/comprehensive/data/majorana_mapping.rst +++ b/docs/source/user/comprehensive/data/majorana_mapping.rst @@ -1,9 +1,9 @@ MajoranaMapping =============== -The :class:`~qdk_chemistry.data.MajoranaMapping` class in QDK/Chemistry is an immutable data class that defines a fermion-to-qubit encoding. +The :class:`~qdk_chemistry.data.MajoranaMapping` class defines a fermion-to-qubit encoding. It exposes the **bilinear** :math:`i\,\gamma_j\,\gamma_k` as the unified primitive available across every encoding, and (for Majorana-atomic encodings) individual Majorana operators :math:`\gamma_k` as an additional capability. -As a core :doc:`data class <../design/index>`, it follows QDK/Chemistry's immutable data pattern. +As a core :doc:`data class <../design/index>`, it follows QDK/Chemistry's data class pattern. Overview -------- diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index f440516c0..624ff3389 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -20,7 +20,7 @@ void bind_majorana_mapping(pybind11::module& data) { using namespace qdk::chemistry::data; py::class_ mapping(data, "MajoranaMapping", R"( -Immutable fermion-to-qubit encoding. +Fermion-to-qubit encoding. Stores a 2N-entry table mapping each Majorana operator gamma_k to a sparse Pauli word. The bilinear ``i*gamma_j*gamma_k`` is the unified primitive and diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index 5006f8c55..f4ecbafd8 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -66,7 +66,7 @@ def _sparse_to_dense_le(word: SparsePauliWord, num_qubits: int) -> str: class MajoranaMapping(DataClass): - """Immutable data class describing a fermion-to-qubit encoding. + """Data class describing a fermion-to-qubit encoding. Two construction forms are supported: From a9ea9eb0e2b3ed090223dce9a39870f63b28a076 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 30 May 2026 00:52:15 +0000 Subject: [PATCH 095/117] refactor: engine uses bilinear() as its access primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build pair product caches from mapping.bilinear() instead of mapping(k) + manual packed multiply. This makes the engine work with both Majorana-atomic and bilinear-only mappings. Pair products store complex coefficients (γ_j·γ_k = -i·bilinear(j,k)) rather than integer phase indices. accumulate_epq now uses the same pair caches as the two-body loops. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chemistry/data/majorana_map_engine.cpp | 113 +++++++++--------- 1 file changed, 58 insertions(+), 55 deletions(-) diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index 6b5d8a7b5..43116280e 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -197,27 +197,66 @@ MajoranaMapResult majorana_map_impl( PackedAccumulator acc; - std::vector> packed_mapping(2 * n_modes); - for (std::size_t k = 0; k < 2 * n_modes; ++k) { - packed_mapping[k] = sparse_to_packed(mapping(k)); - } + // ─── Pair product cache ─────────────────────────────────────────── + // + // Precompute γ_j · γ_k = (-i) · bilinear(j, k) for each within-spin-block + // pair. Using bilinear() as the access primitive makes this work for both + // Majorana-atomic and bilinear-only mappings. + + struct PackedPairProduct { + std::complex coeff; + PackedPauliWord word; + }; + const std::size_t maj_per_spin = 2 * n_spatial; + const std::size_t alpha_offset = 0; + const std::size_t beta_offset = maj_per_spin; + + auto build_pair_cache = + [&](std::size_t global_offset) -> std::vector { + std::vector cache(maj_per_spin * maj_per_spin); + for (std::size_t i = 0; i < maj_per_spin; ++i) { + cache[i * maj_per_spin + i] = {{1.0, 0.0}, PackedPauliWord{}}; + for (std::size_t j = 0; j < maj_per_spin; ++j) { + if (i == j) continue; + auto [bl_coeff, bl_word] = + mapping.bilinear(i + global_offset, j + global_offset); + cache[i * maj_per_spin + j] = { + std::complex(0.0, -1.0) * bl_coeff, + sparse_to_packed(bl_word)}; + } + } + return cache; + }; + + auto ppair_alpha = build_pair_cache(alpha_offset); + auto ppair_beta = build_pair_cache(beta_offset); + + auto alpha_pair = [&](std::size_t i, + std::size_t j) -> const PackedPairProduct& { + return ppair_alpha[i * maj_per_spin + j]; + }; + auto beta_pair = [&](std::size_t i, + std::size_t j) -> const PackedPairProduct& { + return ppair_beta[i * maj_per_spin + j]; + }; auto mode_alpha = [](std::size_t p) -> std::size_t { return p; }; auto mode_beta = [n_spatial](std::size_t p) -> std::size_t { return p + n_spatial; }; - // E_pq = (1/4) Σ_{a,b} coeff[a][b] · P(2p+a) · P(2q+b) auto accumulate_epq = [&](std::size_t mode_p, std::size_t mode_q, double h_pq) { + bool is_alpha = mode_p < n_spatial; + auto& cache = is_alpha ? ppair_alpha : ppair_beta; + std::size_t bp = is_alpha ? mode_p : (mode_p - n_spatial); + std::size_t bq = is_alpha ? mode_q : (mode_q - n_spatial); for (int a = 0; a < 2; ++a) { - std::size_t idx_pa = 2 * mode_p + a; for (int b = 0; b < 2; ++b) { - std::size_t idx_qb = 2 * mode_q + b; - auto [ph, word] = - multiply_packed(packed_mapping[idx_pa], packed_mapping[idx_qb]); + const auto& [coeff, word] = + cache[(2 * bp + a) * maj_per_spin + (2 * bq + b)]; acc.accumulate(word, - apply_phase(ph, h_pq * 0.25 * excitation_coeff[a][b])); + coeff * h_pq * 0.25 * excitation_coeff[a][b]); } } }; @@ -241,7 +280,6 @@ MajoranaMapResult majorana_map_impl( double h_a = h1_alpha[p * n_spatial + s]; double h_b = spin_symmetric ? h_a : h1_beta[p * n_spatial + s]; if (spin_symmetric) { - // Fold δ_{qr} contraction into the one-body integrals. double delta_corr = 0.0; for (std::size_t q = 0; q < n_spatial; ++q) { delta_corr += eri_aaaa[idx4(p, q, q, s)]; @@ -269,37 +307,7 @@ MajoranaMapResult majorana_map_impl( // ─── Two-body terms ─────────────────────────────────────────────── - // Precompute Majorana pair products in packed form (same-spin only). - struct PackedPairProduct { - int phase; - PackedPauliWord word; - }; - const std::size_t maj_per_spin = 2 * n_spatial; - std::vector ppair_alpha(maj_per_spin * maj_per_spin); - std::vector ppair_beta(maj_per_spin * maj_per_spin); - - for (std::size_t i = 0; i < maj_per_spin; ++i) { - for (std::size_t j = 0; j < maj_per_spin; ++j) { - auto [ph_a, w_a] = multiply_packed(packed_mapping[i], packed_mapping[j]); - ppair_alpha[i * maj_per_spin + j] = {ph_a, std::move(w_a)}; - - auto [ph_b, w_b] = multiply_packed(packed_mapping[i + maj_per_spin], - packed_mapping[j + maj_per_spin]); - ppair_beta[i * maj_per_spin + j] = {ph_b, std::move(w_b)}; - } - } - - auto alpha_pair = [&](std::size_t i, - std::size_t j) -> const PackedPairProduct& { - return ppair_alpha[i * maj_per_spin + j]; - }; - auto beta_pair = [&](std::size_t i, - std::size_t j) -> const PackedPairProduct& { - return ppair_beta[i * maj_per_spin + j]; - }; - if (spin_symmetric) { - // Precompute spin-summed E_pq for all (p,q): struct SpinSummedE { std::vector, PackedPauliWord>> terms; }; @@ -310,18 +318,17 @@ MajoranaMapResult majorana_map_impl( auto& sse = ss_e[p * n_spatial + q]; for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { - const auto& [phase_a, word_a] = alpha_pair(2 * p + a, 2 * q + b); + const auto& [coeff_a, word_a] = alpha_pair(2 * p + a, 2 * q + b); sse.terms.emplace_back( - apply_phase(phase_a, 0.25 * excitation_coeff[a][b]), word_a); - const auto& [phase_b, word_b] = beta_pair(2 * p + a, 2 * q + b); + coeff_a * 0.25 * excitation_coeff[a][b], word_a); + const auto& [coeff_b, word_b] = beta_pair(2 * p + a, 2 * q + b); sse.terms.emplace_back( - apply_phase(phase_b, 0.25 * excitation_coeff[a][b]), word_b); + coeff_b * 0.25 * excitation_coeff[a][b], word_b); } } } } - // Precompute symmetrized S_pq = E_pq + E_qp (merged) for p <= q. struct SymmetrizedE { std::vector, PackedPauliWord>> terms; }; @@ -357,7 +364,6 @@ MajoranaMapResult majorana_map_impl( } } - // Two-body: exploit full 8-fold ERI symmetry via (pq)<=(rs) exchange. for (std::size_t p = 0; p < n_spatial; ++p) { for (std::size_t q = p; q < n_spatial; ++q) { std::size_t pq_idx = sym_map[p * n_spatial + q]; @@ -394,8 +400,6 @@ MajoranaMapResult majorana_map_impl( } } else { - // Channel-separated path for unrestricted orbitals. - auto accumulate_two_body_product = [&](std::size_t mode_p, std::size_t mode_q, std::size_t mode_r, std::size_t mode_s, double eri) { @@ -411,16 +415,15 @@ MajoranaMapResult majorana_map_impl( double half_eri = 0.5 * eri; for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { - const auto& [ph1, w1] = + const auto& [coeff1, w1] = cache_pq[(2 * bp + a) * maj_per_spin + (2 * bq + b)]; for (int c = 0; c < 2; ++c) { for (int d = 0; d < 2; ++d) { - const auto& [ph2, w2] = + const auto& [coeff2, w2] = cache_rs[(2 * br + c) * maj_per_spin + (2 * bs + d)]; - std::complex scale = apply_phase( - (ph1 + ph2) & 3, - half_eri * 0.0625 * excitation_coeff[a][b] * - excitation_coeff[c][d]); + std::complex scale = + coeff1 * coeff2 * half_eri * 0.0625 * + excitation_coeff[a][b] * excitation_coeff[c][d]; acc.accumulate_product(w1, w2, scale); } } From 49b3626d20079573a030f1cf83149c4552376bf2 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 30 May 2026 02:29:29 +0000 Subject: [PATCH 096/117] perf: cache bilinears at construction time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit from_table() precomputes all M*(M-1)/2 bilinears from the Majorana table at construction. bilinear() is now always an O(1) lookup regardless of construction form — no dispatch, no on-demand Pauli multiply. Unified to a single private constructor; majorana_atomic_ derived from whether the table is populated. bilinear() returns a const reference to the cached word. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../qdk/chemistry/data/majorana_mapping.hpp | 19 ++----- .../qdk/chemistry/data/majorana_mapping.cpp | 57 +++++++++---------- 2 files changed, 32 insertions(+), 44 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index 9a02c51fd..2635250d2 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -80,15 +80,13 @@ class MajoranaMapping { /** * @brief Pauli image of the bilinear i*gamma_j*gamma_k. * - * Returns (coeff, word) such that coeff*word equals i*gamma_j*gamma_k in the - * encoded representation. For Majorana-atomic mappings, the coefficient is - * real (+/-1); for bilinear-only mappings, it is whatever was provided at - * construction. + * O(1) lookup. For Majorana-atomic mappings the coefficient is real (+/-1); + * for bilinear-only mappings it is whatever was provided at construction. * * @throws std::out_of_range if j or k >= 2N. * @throws std::invalid_argument if j == k. */ - std::pair, SparsePauliWord> bilinear( + std::pair, const SparsePauliWord&> bilinear( std::size_t j, std::size_t k) const; /// Whether individual Majoranas have a Pauli image. @@ -115,20 +113,15 @@ class MajoranaMapping { static MajoranaMapping parity(std::size_t num_modes); private: - /// Private constructor for table-based (Majorana-atomic) mappings. - MajoranaMapping(std::vector table, std::string name, - std::size_t num_modes, std::size_t num_qubits); - - /// Private constructor for bilinear-only mappings. MajoranaMapping( - std::size_t num_modes, std::size_t num_qubits, + std::vector table, std::vector, SparsePauliWord>> bilinears, - std::string name); + std::string name, std::size_t num_modes, std::size_t num_qubits); /// Majorana-to-Pauli table (empty for bilinear-only mappings). std::vector table_; - /// Pre-computed bilinear table, upper triangle row-major (empty for atomic). + /// Cached bilinear table, upper triangle row-major. Always populated. std::vector, SparsePauliWord>> bilinears_; /// Human-readable encoding name. diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp index 2e4074b3e..31a6f0b8e 100644 --- a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp @@ -14,14 +14,16 @@ namespace qdk::chemistry::data { // ── MajoranaMapping implementation ─────────────────────────────────── -MajoranaMapping::MajoranaMapping(std::vector table, - std::string name, std::size_t num_modes, - std::size_t num_qubits) +MajoranaMapping::MajoranaMapping( + std::vector table, + std::vector, SparsePauliWord>> bilinears, + std::string name, std::size_t num_modes, std::size_t num_qubits) : table_(std::move(table)), + bilinears_(std::move(bilinears)), name_(std::move(name)), num_modes_(num_modes), num_qubits_(num_qubits), - majorana_atomic_(true) {} + majorana_atomic_(!table_.empty()) {} MajoranaMapping MajoranaMapping::from_table(std::vector table, std::string name) { @@ -36,19 +38,23 @@ MajoranaMapping MajoranaMapping::from_table(std::vector table, } auto num_modes = table.size() / 2; auto num_qubits = compute_num_qubits(table); - return MajoranaMapping(std::move(table), std::move(name), num_modes, - num_qubits); -} -MajoranaMapping::MajoranaMapping( - std::size_t num_modes, std::size_t num_qubits, - std::vector, SparsePauliWord>> bilinears, - std::string name) - : bilinears_(std::move(bilinears)), - name_(std::move(name)), - num_modes_(num_modes), - num_qubits_(num_qubits), - majorana_atomic_(false) {} + // Precompute all bilinears from the table. + const std::size_t M = table.size(); + std::vector, SparsePauliWord>> bilinears; + bilinears.reserve(M * (M - 1) / 2); + for (std::size_t j = 0; j < M; ++j) { + for (std::size_t k = j + 1; k < M; ++k) { + auto [phase, word] = + PauliTermAccumulator::multiply_uncached(table[j], table[k]); + bilinears.emplace_back(std::complex(0.0, 1.0) * phase, + std::move(word)); + } + } + + return MajoranaMapping(std::move(table), std::move(bilinears), + std::move(name), num_modes, num_qubits); +} MajoranaMapping MajoranaMapping::from_bilinears( std::size_t num_modes, @@ -68,7 +74,6 @@ MajoranaMapping MajoranaMapping::from_bilinears( std::to_string(num_modes) + " modes, got " + std::to_string(upper_triangle.size())); } - // Compute num_qubits from the bilinear words std::uint64_t max_idx = 0; bool has_any = false; for (const auto& [_, word] : upper_triangle) { @@ -81,8 +86,8 @@ MajoranaMapping MajoranaMapping::from_bilinears( } } auto nq = has_any ? static_cast(max_idx + 1) : 0; - return MajoranaMapping(num_modes, nq, std::move(upper_triangle), - std::move(name)); + return MajoranaMapping({}, std::move(upper_triangle), std::move(name), + num_modes, nq); } const SparsePauliWord& MajoranaMapping::operator()(std::size_t k) const { @@ -99,8 +104,8 @@ const SparsePauliWord& MajoranaMapping::operator()(std::size_t k) const { return table_[k]; } -std::pair, SparsePauliWord> MajoranaMapping::bilinear( - std::size_t j, std::size_t k) const { +std::pair, const SparsePauliWord&> +MajoranaMapping::bilinear(std::size_t j, std::size_t k) const { const std::size_t M = 2 * num_modes_; if (j >= M || k >= M) { throw std::out_of_range( @@ -114,20 +119,10 @@ std::pair, SparsePauliWord> MajoranaMapping::bilinear( std::to_string(j) + "); the bilinear i*gamma_j*gamma_k requires distinct indices."); } - - if (majorana_atomic_) { - auto [pauli_phase, word] = - PauliTermAccumulator::multiply_uncached(table_[j], table_[k]); - std::complex coeff = std::complex(0.0, 1.0) * pauli_phase; - return {coeff, std::move(word)}; - } - - // Bilinear-only: look up from stored upper triangle if (j < k) { const auto& entry = bilinears_[bilinear_index(j, k)]; return {entry.first, entry.second}; } - // Antisymmetry: i*gamma_k*gamma_j = -(i*gamma_j*gamma_k) const auto& entry = bilinears_[bilinear_index(k, j)]; return {-entry.first, entry.second}; } From 02f48f310089023f32ccd8ec0cc24ec3db27baed Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 30 May 2026 02:37:25 +0000 Subject: [PATCH 097/117] test: comprehensive coverage for new MajoranaMapping features from_bilinears: add num_qubits, wrong count, missing entry, out-of-range, equal-indices error tests (5 new). Sparse/dense conversion helpers: roundtrip identity, single Pauli, JW table, invalid character, case insensitivity (5 new). Serialization backward compat: JSON and HDF5 files with old 'phases' key/dataset deserialize without error (2 new). Bilinear caching: verify cached bilinear(j,k) matches manual Pauli multiply of table entries for all factory encodings (1 new, parametrized across 3 encodings). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/tests/test_majorana_mapping.py | 146 +++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index aa41a55eb..c93ab460f 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -5,8 +5,11 @@ - Clifford algebra anticommutation validation - Custom mapping construction - from_mode_pairs construction +- from_bilinears construction (bilinear-only mappings) +- Bilinear caching correctness +- Sparse/dense Pauli string conversion helpers - Immutability enforcement -- Serialization round-trips (JSON, HDF5, file) +- Serialization round-trips (JSON, HDF5, file) and backward compatibility - Invalid input rejection - Reference output comparison for small systems """ @@ -235,6 +238,58 @@ def test_majorana_raises_for_bilinear_only(self) -> None: with pytest.raises(ValueError, match="bilinear-only"): bl.majorana(0) + def test_num_qubits(self) -> None: + """num_qubits is derived from the bilinear Pauli words.""" + jw = MajoranaMapping.jordan_wigner(num_modes=4) + bilinears: dict[tuple[int, int], tuple[complex, str]] = {} + for j in range(8): + for k in range(j + 1, 8): + bilinears[(j, k)] = jw.bilinear(j, k) + bl = MajoranaMapping.from_bilinears(num_modes=4, bilinears=bilinears) + assert bl.num_qubits == 4 + + def test_wrong_count_raises(self) -> None: + """from_bilinears raises ValueError if entry count doesn't match num_modes.""" + with pytest.raises(ValueError, match="upper-triangle"): + MajoranaMapping.from_bilinears(num_modes=2, bilinears={(0, 1): (1.0, "IZ")}) + + def test_missing_entry_raises(self) -> None: + """from_bilinears raises ValueError if a required (j,k) pair is missing.""" + with pytest.raises(ValueError): + MajoranaMapping.from_bilinears( + num_modes=2, + bilinears={ + (0, 1): (1.0, "IZ"), + (0, 2): (1.0, "XZ"), + (0, 3): (1.0, "YZ"), + (1, 2): (1.0, "XI"), + # missing (1, 3) + (2, 3): (1.0, "ZI"), + }, + ) + + def test_bilinear_out_of_range_raises(self) -> None: + """bilinear() raises IndexError for out-of-range indices on bilinear-only mapping.""" + jw = MajoranaMapping.jordan_wigner(num_modes=2) + bilinears: dict[tuple[int, int], tuple[complex, str]] = {} + for j in range(4): + for k in range(j + 1, 4): + bilinears[(j, k)] = jw.bilinear(j, k) + bl = MajoranaMapping.from_bilinears(num_modes=2, bilinears=bilinears) + with pytest.raises(IndexError): + bl.bilinear(0, 99) + + def test_bilinear_equal_indices_raises(self) -> None: + """bilinear(j, j) raises ValueError on bilinear-only mapping.""" + jw = MajoranaMapping.jordan_wigner(num_modes=2) + bilinears: dict[tuple[int, int], tuple[complex, str]] = {} + for j in range(4): + for k in range(j + 1, 4): + bilinears[(j, k)] = jw.bilinear(j, k) + bl = MajoranaMapping.from_bilinears(num_modes=2, bilinears=bilinears) + with pytest.raises(ValueError): + bl.bilinear(1, 1) + # ─── Validation Tests ──────────────────────────────────────────────────── @@ -776,3 +831,92 @@ def test_pauli_string_length_with_tapering(self) -> None: continue _, w = scbk.bilinear(j, k) assert len(w) == 8 + + +# ─── Sparse/dense conversion helpers ──────────────────────────────────── + + +class TestSparseConversion: + """Tests for _dense_le_to_sparse and _sparse_to_dense_le.""" + + def test_roundtrip_identity(self) -> None: + """All-identity string round-trips to empty sparse word.""" + from qdk_chemistry.data.majorana_mapping import _dense_le_to_sparse, _sparse_to_dense_le + + assert _dense_le_to_sparse("IIII") == [] + assert _sparse_to_dense_le([], 4) == "IIII" + + def test_roundtrip_single_pauli(self) -> None: + """Single non-identity Pauli round-trips correctly.""" + from qdk_chemistry.data.majorana_mapping import _dense_le_to_sparse, _sparse_to_dense_le + + for label in ("IIIX", "IIYI", "ZIII", "IXII"): + sparse = _dense_le_to_sparse(label) + assert _sparse_to_dense_le(sparse, 4) == label + + def test_roundtrip_jw_table(self) -> None: + """JW table entries survive sparse→dense round-trip.""" + from qdk_chemistry.data.majorana_mapping import _dense_le_to_sparse, _sparse_to_dense_le + + jw = MajoranaMapping.jordan_wigner(num_modes=4) + for entry in jw.table: + sparse = _dense_le_to_sparse(entry) + assert _sparse_to_dense_le(sparse, len(entry)) == entry + + def test_invalid_character_raises(self) -> None: + """Invalid Pauli character raises ValueError.""" + from qdk_chemistry.data.majorana_mapping import _dense_le_to_sparse + + with pytest.raises(ValueError, match="Invalid Pauli character"): + _dense_le_to_sparse("IXAZ") + + def test_case_insensitive(self) -> None: + """Lowercase Pauli characters are accepted.""" + from qdk_chemistry.data.majorana_mapping import _dense_le_to_sparse + + assert _dense_le_to_sparse("ix") == _dense_le_to_sparse("IX") + + +class TestSerializationBackwardCompat: + """Backward compatibility for serialized data that may contain phases.""" + + def test_json_with_phases_key_ignored(self) -> None: + """Old JSON with a 'phases' key deserializes without error.""" + jw = MajoranaMapping.jordan_wigner(num_modes=2) + data = jw.to_json() + data["phases"] = [1, 1, 1, 1] + loaded = MajoranaMapping.from_json(data) + assert loaded.table == jw.table + + def test_hdf5_with_phases_dataset_ignored(self) -> None: + """Old HDF5 with a 'phases' dataset deserializes without error.""" + import numpy as np + + jw = MajoranaMapping.jordan_wigner(num_modes=2) + with tempfile.NamedTemporaryFile(suffix=".h5") as f: + with h5py.File(f.name, "w") as hf: + jw.to_hdf5(hf) + hf.create_dataset("phases", data=np.array([1, 1, 1, 1], dtype=np.int8)) + with h5py.File(f.name, "r") as hf: + loaded = MajoranaMapping.from_hdf5(hf) + assert loaded.table == jw.table + + +class TestBilinearCaching: + """Verify that bilinear results are cached and consistent.""" + + @pytest.mark.parametrize(("name", "factory"), _FACTORIES) + def test_cached_bilinear_matches_manual_multiply(self, name: str, factory) -> None: + """Cached bilinear(j,k) matches manual Pauli multiply of table entries.""" + del name + m = factory(4) + for j in range(2 * m.num_modes): + for k in range(j + 1, 2 * m.num_modes): + coeff, word = m.bilinear(j, k) + phase, product = PauliTermAccumulator.multiply_uncached(m.core(j), m.core(k)) + from qdk_chemistry.data.majorana_mapping import _sparse_to_dense_le + + expected_word = _sparse_to_dense_le(product, m.num_qubits) + expected_coeff = 1j * phase + assert word == expected_word, f"bilinear({j},{k}) word mismatch" + assert abs(coeff - expected_coeff) < 1e-12, f"bilinear({j},{k}) coeff mismatch" From d5bb9a434475f5632c2a179fca0d403f6cd0106e Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 30 May 2026 02:38:53 +0000 Subject: [PATCH 098/117] test: drop backward compat tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/tests/test_majorana_mapping.py | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index c93ab460f..b3da74a68 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -9,7 +9,7 @@ - Bilinear caching correctness - Sparse/dense Pauli string conversion helpers - Immutability enforcement -- Serialization round-trips (JSON, HDF5, file) and backward compatibility +- Serialization round-trips (JSON, HDF5, file) - Invalid input rejection - Reference output comparison for small systems """ @@ -877,31 +877,6 @@ def test_case_insensitive(self) -> None: assert _dense_le_to_sparse("ix") == _dense_le_to_sparse("IX") -class TestSerializationBackwardCompat: - """Backward compatibility for serialized data that may contain phases.""" - - def test_json_with_phases_key_ignored(self) -> None: - """Old JSON with a 'phases' key deserializes without error.""" - jw = MajoranaMapping.jordan_wigner(num_modes=2) - data = jw.to_json() - data["phases"] = [1, 1, 1, 1] - loaded = MajoranaMapping.from_json(data) - assert loaded.table == jw.table - - def test_hdf5_with_phases_dataset_ignored(self) -> None: - """Old HDF5 with a 'phases' dataset deserializes without error.""" - import numpy as np - - jw = MajoranaMapping.jordan_wigner(num_modes=2) - with tempfile.NamedTemporaryFile(suffix=".h5") as f: - with h5py.File(f.name, "w") as hf: - jw.to_hdf5(hf) - hf.create_dataset("phases", data=np.array([1, 1, 1, 1], dtype=np.int8)) - with h5py.File(f.name, "r") as hf: - loaded = MajoranaMapping.from_hdf5(hf) - assert loaded.table == jw.table - - class TestBilinearCaching: """Verify that bilinear results are cached and consistent.""" From 24a14b1711b7da791d5d63c493a6be540393208c Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 30 May 2026 02:46:46 +0000 Subject: [PATCH 099/117] fix: address PR review comments - Remove validate_encoding_compatibility and EncodingMismatchError entirely - Move taper_qubits from utils/tapering.py to data/tapering.py - Trim MajoranaMapping class docstring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../algorithms/qubit_mapper/qubit_mapper.py | 4 +- python/src/qdk_chemistry/data/__init__.py | 3 - .../qdk_chemistry/data/encoding_validation.py | 74 ---- .../qdk_chemistry/data/majorana_mapping.py | 42 +-- python/src/qdk_chemistry/data/tapering.py | 111 +++++- python/src/qdk_chemistry/utils/tapering.py | 236 ------------ python/tests/test_encoding_metadata.py | 353 ------------------ 7 files changed, 116 insertions(+), 707 deletions(-) delete mode 100644 python/src/qdk_chemistry/data/encoding_validation.py delete mode 100644 python/src/qdk_chemistry/utils/tapering.py delete mode 100644 python/tests/test_encoding_metadata.py diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 5a60380ff..916371c89 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -121,7 +121,7 @@ def _taper_result(qh: QubitHamiltonian, mapping: MajoranaMapping) -> QubitHamilt Convenience helper for backends. If ``mapping.tapering`` is ``None``, returns *qh* unchanged. Otherwise, applies - :func:`~qdk_chemistry.utils.tapering.taper_qubits` and relabels + :func:`~qdk_chemistry.data.tapering.taper_qubits` and relabels the result with the mapping's final encoding name. Args: @@ -137,7 +137,7 @@ def _taper_result(qh: QubitHamiltonian, mapping: MajoranaMapping) -> QubitHamilt return qh from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian # noqa: PLC0415 - from qdk_chemistry.utils.tapering import taper_qubits # noqa: PLC0415 + from qdk_chemistry.data.tapering import taper_qubits # noqa: PLC0415 tapered = taper_qubits(qh, tapering.qubit_indices, tapering.eigenvalues) result = QubitHamiltonian( diff --git a/python/src/qdk_chemistry/data/__init__.py b/python/src/qdk_chemistry/data/__init__.py index 4d8019390..49b080adb 100644 --- a/python/src/qdk_chemistry/data/__init__.py +++ b/python/src/qdk_chemistry/data/__init__.py @@ -111,7 +111,6 @@ from qdk_chemistry.data.circuit import Circuit from qdk_chemistry.data.circuit_executor_data import CircuitExecutorData from qdk_chemistry.data.controlled_unitary import ControlledUnitary -from qdk_chemistry.data.encoding_validation import EncodingMismatchError, validate_encoding_compatibility from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.data.estimator_data import EnergyExpectationResult, MeasurementData from qdk_chemistry.data.majorana_mapping import MajoranaMapping @@ -154,7 +153,6 @@ "DrivenQubitHamiltonian", "ElectronicStructureSettings", "Element", - "EncodingMismatchError", "EnergyExpectationResult", "FermionModeOrder", "FlatPartition", @@ -201,5 +199,4 @@ "WavefunctionContainer", "WavefunctionType", "get_current_ciaaw_version", - "validate_encoding_compatibility", ] diff --git a/python/src/qdk_chemistry/data/encoding_validation.py b/python/src/qdk_chemistry/data/encoding_validation.py deleted file mode 100644 index 5c0ee9ad9..000000000 --- a/python/src/qdk_chemistry/data/encoding_validation.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Utilities for validating fermion-to-qubit encoding compatibility. - -This module provides functions to validate that Circuit and QubitHamiltonian -instances use compatible fermion-to-qubit encodings. -""" - -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See LICENSE.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from qdk_chemistry.data.circuit import Circuit - from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian - -__all__ = ["EncodingMismatchError", "validate_encoding_compatibility"] - - -class EncodingMismatchError(ValueError): - """Exception raised when Circuit and QubitHamiltonian have incompatible encodings.""" - - -def validate_encoding_compatibility(circuit: Circuit, hamiltonian: QubitHamiltonian) -> None: - """Validate that a Circuit and QubitHamiltonian use compatible encodings. - - This function checks that both the circuit and Hamiltonian have matching encodings. - Both must have their encoding specified (not None), and the encodings must match. - - Args: - circuit: The quantum circuit with encoding metadata. - hamiltonian: The qubit Hamiltonian with encoding metadata. - - Raises: - EncodingMismatchError: If the circuit or Hamiltonian encoding is None, or if - the encodings don't match. - - Examples: - >>> circuit = Circuit(qasm="...", encoding="jordan-wigner") - >>> hamiltonian = QubitHamiltonian(..., encoding="jordan-wigner") - >>> validate_encoding_compatibility(circuit, hamiltonian) # OK - >>> hamiltonian_bk = QubitHamiltonian(..., encoding="bravyi-kitaev") - >>> validate_encoding_compatibility(circuit, hamiltonian_bk) # Raises EncodingMismatchError - >>> circuit_none = Circuit(qasm="...", encoding=None) - >>> validate_encoding_compatibility(circuit_none, hamiltonian) # Raises EncodingMismatchError - - """ - circuit_encoding = circuit.encoding - hamiltonian_encoding = hamiltonian.encoding - - # Require that both encodings are specified - if circuit_encoding is None: - raise EncodingMismatchError( - "Circuit encoding is not specified. All circuits must have an encoding metadata " - "to ensure compatibility with qubit Hamiltonians." - ) - - if hamiltonian_encoding is None: - raise EncodingMismatchError( - "QubitHamiltonian encoding is not specified. All qubit Hamiltonians must have an " - "encoding metadata to ensure compatibility with circuits." - ) - - # Both encodings are specified - they must match - if circuit_encoding != hamiltonian_encoding: - raise EncodingMismatchError( - f"Encoding mismatch detected: Circuit uses '{circuit_encoding}' encoding, " - f"but QubitHamiltonian uses '{hamiltonian_encoding}' encoding. " - f"These encodings are incompatible and will lead to incorrect results. " - f"Please ensure both the circuit and Hamiltonian use the same fermion-to-qubit encoding." - ) diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index f4ecbafd8..f09bf4b65 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -66,48 +66,14 @@ def _sparse_to_dense_le(word: SparsePauliWord, num_qubits: int) -> str: class MajoranaMapping(DataClass): - """Data class describing a fermion-to-qubit encoding. + """Fermion-to-qubit encoding. - Two construction forms are supported: - - - **Majorana-atomic** (the table constructor and all factory methods): - stores a 2N-entry table mapping each individual gamma_k to a Pauli word. - The bilinear ``i*gamma_j*gamma_k`` is computed on demand from the table. - - - **Bilinear-only** (via :py:meth:`from_bilinears`): stores the bilinear - images directly. Individual gamma_k have no Pauli image; only - :py:meth:`bilinear` is available. This form supports encodings where - m > n qubits represent n modes and single Majoranas anticommute with - codespace stabilizers. - - Pauli strings use the same **little-endian** convention as - :class:`~qdk_chemistry.data.QubitHamiltonian`: qubit 0 is the rightmost - character. - - Attributes: - table (tuple[str, ...]): Tuple of 2N dense Pauli strings (empty for bilinear-only mappings). - num_modes (int): Number of fermionic modes (spin-orbitals), N. - num_qubits (int): Effective number of qubits after any tapering. - name (str): Human-readable name of the encoding (may be empty for custom mappings). - is_majorana_atomic (bool): True if individual gamma_k have a Pauli image. + Majorana-atomic mappings store a 2N-entry Pauli table for individual + gamma_k; bilinear-only mappings (via :py:meth:`from_bilinears`) store + the bilinear images directly. :py:meth:`bilinear` is available on both. Examples: - Built-in encodings: - >>> jw = MajoranaMapping.jordan_wigner(num_modes=4) - >>> jw.num_modes - 4 - >>> jw.num_qubits - 4 - - Custom encoding from Pauli string labels: - - >>> custom = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"], name="my-jw") - >>> custom.table - ('IX', 'IY', 'XZ', 'YZ') - - Bilinear lookup: - >>> coeff, pauli = jw.bilinear(0, 1) """ diff --git a/python/src/qdk_chemistry/data/tapering.py b/python/src/qdk_chemistry/data/tapering.py index c3097435a..27fe71a1c 100644 --- a/python/src/qdk_chemistry/data/tapering.py +++ b/python/src/qdk_chemistry/data/tapering.py @@ -15,7 +15,9 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from qdk_chemistry.data import Symmetries + from collections.abc import Sequence + + from qdk_chemistry.data import QubitHamiltonian, Symmetries __all__: list[str] = [] @@ -249,3 +251,110 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: """Return hash.""" return hash((self._qubit_indices, self._eigenvalues, self._source_num_qubits, self._source_encoding)) + + +def taper_qubits( + qubit_hamiltonian: QubitHamiltonian, + qubit_indices: Sequence[int], + eigenvalues: Sequence[int], +) -> QubitHamiltonian: + """Remove qubits with known Z eigenvalues from a qubit Hamiltonian. + + For each specified qubit, every Pauli term that has Z on that qubit gets + its coefficient multiplied by the eigenvalue, and the Z is replaced with I. + Terms with X or Y on a tapered qubit are dropped. After replacement, the + tapered qubit positions are removed and the remaining qubits are renumbered. + + Args: + qubit_hamiltonian (QubitHamiltonian): The qubit Hamiltonian to taper. + qubit_indices (Sequence[int]): Qubit indices to taper (0-indexed). + eigenvalues (Sequence[int]): Corresponding Z eigenvalues (+1 or -1) for each qubit. + + Returns: + QubitHamiltonian: Tapered Hamiltonian with fewer qubits. + + Raises: + ValueError: If lengths don't match, indices are out of range, contain duplicates, or eigenvalues are not +/-1. + + """ + from qdk_chemistry.data import QubitHamiltonian # noqa: PLC0415 + + import numpy as np # noqa: PLC0415 + + qubit_indices = list(qubit_indices) + eigenvalues = list(eigenvalues) + + if len(qubit_indices) != len(eigenvalues): + raise ValueError( + f"qubit_indices length ({len(qubit_indices)}) must match eigenvalues length ({len(eigenvalues)})" + ) + if len(set(qubit_indices)) != len(qubit_indices): + raise ValueError("qubit_indices must not contain duplicates") + + nq = qubit_hamiltonian.num_qubits + for q in qubit_indices: + if q < 0 or q >= nq: + raise ValueError(f"Qubit index {q} out of range [0, {nq})") + for ev in eigenvalues: + if ev not in (1, -1): + raise ValueError(f"Eigenvalue must be +1 or -1, got {ev}") + + positions_to_remove = sorted([nq - 1 - q for q in qubit_indices]) + eigenvalue_map = dict(zip(qubit_indices, eigenvalues, strict=True)) + + new_strings: list[str] = [] + new_coeffs: list[complex] = [] + + for pauli_str, coeff in zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=True): + skip = False + adjusted_coeff = complex(coeff) + + for q, ev in eigenvalue_map.items(): + pos = nq - 1 - q + char = pauli_str[pos] + if char == "Z": + adjusted_coeff *= ev + elif char in ("X", "Y"): + skip = True + break + + if skip: + continue + + chars = [c for i, c in enumerate(pauli_str) if i not in positions_to_remove] + new_strings.append("".join(chars)) + new_coeffs.append(adjusted_coeff) + + new_nq = nq - len(qubit_indices) + + if not new_strings: + identity_str = "I" * new_nq + return QubitHamiltonian( + pauli_strings=[identity_str], + coefficients=np.array([0.0]), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + ) + + merged: dict[str, complex] = {} + for s, c in zip(new_strings, new_coeffs, strict=True): + merged[s] = merged.get(s, 0.0) + c + + final_strings = [] + final_coeffs = [] + for s, c in merged.items(): + if abs(c) > 1e-12: + final_strings.append(s) + final_coeffs.append(c) + + if not final_strings: + identity_str = "I" * new_nq + final_strings = [identity_str] + final_coeffs = [0.0] + + return QubitHamiltonian( + pauli_strings=final_strings, + coefficients=np.array(final_coeffs), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + ) diff --git a/python/src/qdk_chemistry/utils/tapering.py b/python/src/qdk_chemistry/utils/tapering.py deleted file mode 100644 index 9115dcd8d..000000000 --- a/python/src/qdk_chemistry/utils/tapering.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Internal qubit tapering utilities for symmetry-conserving encodings. - -Provides functions that post-process a :class:`~qdk_chemistry.data.QubitHamiltonian` -by projecting out qubits whose Z₂ eigenvalues are fixed by symmetry, reducing the -qubit count without loss of information within the symmetry sector. - -These are internal utilities used by the QDK mapper when a -:class:`~qdk_chemistry.data.MajoranaMapping` carries a -:class:`~qdk_chemistry.data.TaperingSpecification`. Users should prefer the -one-step API:: - - from qdk_chemistry.algorithms import create - from qdk_chemistry.data import MajoranaMapping, Symmetries - - mapper = create("qubit_mapper") - mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev( - num_modes=n_spin_orbitals, symmetries=Symmetries(n_alpha=2, n_beta=2) - ) - qh = mapper.run(hamiltonian, mapping) # already tapered -""" - -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See LICENSE.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import numpy as np - -if TYPE_CHECKING: - from collections.abc import Sequence - -if TYPE_CHECKING: - from qdk_chemistry.data import QubitHamiltonian, Symmetries - -__all__ = ["taper_qubits", "taper_to_scbk"] - - -def taper_qubits( - qubit_hamiltonian: QubitHamiltonian, - qubit_indices: Sequence[int], - eigenvalues: Sequence[int], -) -> QubitHamiltonian: - """Remove qubits with known Z eigenvalues from a qubit Hamiltonian. - - For each specified qubit, every Pauli term that has Z on that qubit gets - its coefficient multiplied by the eigenvalue, and the Z is replaced with I. - Terms with X or Y on a tapered qubit are dropped (they connect different - symmetry sectors). After replacement, the tapered qubit positions are - removed from all strings and the remaining qubits are renumbered. - - This implements the qubit tapering step of symmetry-conserving encodings - such as SCBK (arXiv:1701.08213). - - Args: - qubit_hamiltonian (QubitHamiltonian): The qubit Hamiltonian to taper. - qubit_indices (Sequence[int]): Qubit indices to taper (0-indexed). - eigenvalues (Sequence[int]): Corresponding Z eigenvalues (+1 or -1) for each qubit. - - Returns: - QubitHamiltonian: Tapered Hamiltonian with fewer qubits. Zero-coefficient identity if all terms cancel. - - Raises: - ValueError: If lengths don't match, indices are out of range, contain duplicates, or eigenvalues are not ±1. - - """ - from qdk_chemistry.data import QubitHamiltonian # noqa: PLC0415 - - qubit_indices = list(qubit_indices) - eigenvalues = list(eigenvalues) - - if len(qubit_indices) != len(eigenvalues): - raise ValueError( - f"qubit_indices length ({len(qubit_indices)}) must match eigenvalues length ({len(eigenvalues)})" - ) - if len(set(qubit_indices)) != len(qubit_indices): - raise ValueError("qubit_indices must not contain duplicates") - - nq = qubit_hamiltonian.num_qubits - for q in qubit_indices: - if q < 0 or q >= nq: - raise ValueError(f"Qubit index {q} out of range [0, {nq})") - for ev in eigenvalues: - if ev not in (1, -1): - raise ValueError(f"Eigenvalue must be +1 or -1, got {ev}") - - # String positions to remove (little-endian: qubit q is at position nq-1-q) - positions_to_remove = sorted([nq - 1 - q for q in qubit_indices]) - eigenvalue_map = dict(zip(qubit_indices, eigenvalues, strict=True)) - - new_strings: list[str] = [] - new_coeffs: list[complex] = [] - - for pauli_str, coeff in zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=True): - skip = False - adjusted_coeff = complex(coeff) - - for q, ev in eigenvalue_map.items(): - pos = nq - 1 - q - char = pauli_str[pos] - if char == "Z": - adjusted_coeff *= ev - elif char in ("X", "Y"): - skip = True - break - # 'I' → no change - - if skip: - continue - - # Remove the tapered qubit positions from the string - chars = [c for i, c in enumerate(pauli_str) if i not in positions_to_remove] - new_str = "".join(chars) - - new_strings.append(new_str) - new_coeffs.append(adjusted_coeff) - - new_nq = nq - len(qubit_indices) - - if not new_strings: - # All terms had X/Y on tapered qubits — return zero operator - identity_str = "I" * new_nq - return QubitHamiltonian( - pauli_strings=[identity_str], - coefficients=np.array([0.0]), - encoding=qubit_hamiltonian.encoding, - fermion_mode_order=qubit_hamiltonian.fermion_mode_order, - ) - - # Merge duplicate Pauli strings - merged: dict[str, complex] = {} - for s, c in zip(new_strings, new_coeffs, strict=True): - merged[s] = merged.get(s, 0.0) + c - - # Filter near-zero terms (use 1e-12, consistent with mapper thresholds) - final_strings = [] - final_coeffs = [] - for s, c in merged.items(): - if abs(c) > 1e-12: - final_strings.append(s) - final_coeffs.append(c) - - if not final_strings: - # All terms cancelled after merging — return zero operator - identity_str = "I" * new_nq - final_strings = [identity_str] - final_coeffs = [0.0] - - return QubitHamiltonian( - pauli_strings=final_strings, - coefficients=np.array(final_coeffs), - encoding=qubit_hamiltonian.encoding, - fermion_mode_order=qubit_hamiltonian.fermion_mode_order, - ) - - -def taper_to_scbk( - qubit_hamiltonian: QubitHamiltonian, - symmetries: Symmetries, -) -> QubitHamiltonian: - """Apply symmetry-conserving Bravyi-Kitaev tapering to a BK-encoded Hamiltonian. - - Tapers the two Z₂ symmetry qubits of the Bravyi-Kitaev encoding — total - electron-number parity (qubit n-1) and alpha-spin parity (qubit n/2-1) — - reducing the qubit count by 2. The eigenvalues are determined by the - electron counts following the convention of arXiv:1701.08213. - - The input must be a BK-encoded (Fenwick or BK-tree) QubitHamiltonian with - an even number of qubits (= 2 * n_spatial). - - Args: - qubit_hamiltonian (QubitHamiltonian): A Bravyi-Kitaev or BK-tree encoded qubit Hamiltonian. - symmetries (Symmetries): Symmetry information providing ``n_alpha`` and ``n_beta`` electron counts. - - Returns: - QubitHamiltonian: Tapered Hamiltonian with 2 fewer qubits and encoding ``"symmetry-conserving-bravyi-kitaev"``. - - Raises: - ValueError: If encoding is not ``"bravyi-kitaev"`` or ``"bravyi-kitaev-tree"``, or qubit count is odd or < 4. - - """ - from qdk_chemistry.data import QubitHamiltonian # noqa: PLC0415 - from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder # noqa: PLC0415 - from qdk_chemistry.data.tapering import TaperingSpecification # noqa: PLC0415 - - bk_encodings = {"bravyi-kitaev", "bravyi-kitaev-tree"} - if qubit_hamiltonian.encoding not in bk_encodings: - raise ValueError( - f"taper_to_scbk requires a Bravyi-Kitaev encoded QubitHamiltonian " - f"(encoding in {bk_encodings}), got encoding={qubit_hamiltonian.encoding!r}. " - f"Use MajoranaMapping.bravyi_kitaev_tree() to produce a BK-encoded Hamiltonian first." - ) - - if ( - qubit_hamiltonian.fermion_mode_order is not None - and qubit_hamiltonian.fermion_mode_order != FermionModeOrder.BLOCKED - ): - raise ValueError( - f"taper_to_scbk requires blocked spin-orbital ordering " - f"(fermion_mode_order='blocked'), got {qubit_hamiltonian.fermion_mode_order!r}. " - f"The QDK and OpenFermion BK mappers produce blocked ordering by default." - ) - - n = qubit_hamiltonian.num_qubits - if n < 4 or n % 2 != 0: - raise ValueError(f"SCBK tapering requires an even qubit count >= 4, got {n}") - - # In blocked BK ordering [α₀,α₁,…,αₙ₋₁,β₀,β₁,…,βₙ₋₁]: - # qubit n/2-1 stores alpha electron number parity - # qubit n-1 stores total electron number parity - ev_total = 1 if (symmetries.n_alpha + symmetries.n_beta) % 2 == 0 else -1 - ev_alpha = 1 if symmetries.n_alpha % 2 == 0 else -1 - - q_total = n - 1 - q_alpha = n // 2 - 1 - - result = taper_qubits(qubit_hamiltonian, [q_alpha, q_total], [ev_alpha, ev_total]) - - tapering = TaperingSpecification( - qubit_indices=(q_alpha, q_total), - eigenvalues=(ev_alpha, ev_total), - source_num_qubits=n, - source_encoding=qubit_hamiltonian.encoding or "bravyi-kitaev-tree", - ) - - return QubitHamiltonian( - pauli_strings=result.pauli_strings, - coefficients=result.coefficients, - encoding="symmetry-conserving-bravyi-kitaev", - fermion_mode_order=result.fermion_mode_order, - term_partition=result.term_partition, - tapering=tapering, - ) diff --git a/python/tests/test_encoding_metadata.py b/python/tests/test_encoding_metadata.py deleted file mode 100644 index 7cd26a4af..000000000 --- a/python/tests/test_encoding_metadata.py +++ /dev/null @@ -1,353 +0,0 @@ -"""Tests for encoding metadata and validation in Circuit and QubitHamiltonian.""" - -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See LICENSE.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -import h5py -import numpy as np -import pytest - -from qdk_chemistry.algorithms import create, registry -from qdk_chemistry.data import ( - Circuit, - EncodingMismatchError, - MajoranaMapping, - QubitHamiltonian, - validate_encoding_compatibility, -) -from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder -from qdk_chemistry.plugins.qiskit import QDK_CHEMISTRY_HAS_QISKIT, QDK_CHEMISTRY_HAS_QISKIT_NATURE - -from .test_helpers import create_test_hamiltonian - - -def test_circuit_encoding_metadata(): - """Test that Circuit properly stores and retrieves encoding metadata.""" - # Test with encoding specified - circuit_jw = Circuit(qasm="OPENQASM 3.0; qubit[2] q;", encoding="jordan-wigner") - assert circuit_jw.encoding == "jordan-wigner" - - # Test with no encoding specified - circuit_none = Circuit(qasm="OPENQASM 3.0; qubit[2] q;", encoding=None) - assert circuit_none.encoding is None - - # Test different encodings - circuit_bk = Circuit(qasm="OPENQASM 3.0; qubit[2] q;", encoding="bravyi-kitaev") - assert circuit_bk.encoding == "bravyi-kitaev" - - circuit_parity = Circuit(qasm="OPENQASM 3.0; qubit[2] q;", encoding="parity") - assert circuit_parity.encoding == "parity" - - -def test_qubit_hamiltonian_encoding_metadata(): - """Test that QubitHamiltonian properly stores and retrieves encoding metadata.""" - pauli_strings = ["II", "ZI", "IZ", "ZZ"] - coefficients = np.array([1.0, 0.5, 0.5, 0.25]) - - # Test with encoding specified - ham_jw = QubitHamiltonian(pauli_strings, coefficients, encoding="jordan-wigner") - assert ham_jw.encoding == "jordan-wigner" - - # Test with no encoding specified - ham_none = QubitHamiltonian(pauli_strings, coefficients, encoding=None) - assert ham_none.encoding is None - - # Test different encodings - ham_bk = QubitHamiltonian(pauli_strings, coefficients, encoding="bravyi-kitaev") - assert ham_bk.encoding == "bravyi-kitaev" - - ham_parity = QubitHamiltonian(pauli_strings, coefficients, encoding="parity") - assert ham_parity.encoding == "parity" - - -def test_circuit_encoding_serialization_json(): - """Test that Circuit encoding is preserved through JSON serialization.""" - circuit = Circuit(qasm="OPENQASM 3.0; qubit[2] q;", encoding="jordan-wigner") - - # Serialize to JSON - json_data = circuit.to_json() - assert "encoding" in json_data - assert json_data["encoding"] == "jordan-wigner" - - # Deserialize from JSON - circuit_restored = Circuit.from_json(json_data) - assert circuit_restored.encoding == "jordan-wigner" - assert circuit_restored.qasm == circuit.qasm - - -def test_circuit_encoding_serialization_hdf5(tmp_path): - """Test that Circuit encoding is preserved through HDF5 serialization.""" - circuit = Circuit(qasm="OPENQASM 3.0; qubit[2] q;", encoding="jordan-wigner") - - # Save to HDF5 - hdf5_path = tmp_path / "circuit.h5" - with h5py.File(hdf5_path, "w") as f: - circuit.to_hdf5(f) - - # Load from HDF5 - with h5py.File(hdf5_path, "r") as f: - circuit_restored = Circuit.from_hdf5(f) - - assert circuit_restored.encoding == "jordan-wigner" - assert circuit_restored.qasm == circuit.qasm - - -def test_qubit_hamiltonian_encoding_serialization_json(): - """Test that QubitHamiltonian encoding is preserved through JSON serialization.""" - pauli_strings = ["II", "ZI", "IZ"] - coefficients = np.array([1.0, 0.5, 0.5]) - ham = QubitHamiltonian(pauli_strings, coefficients, encoding="jordan-wigner") - - # Serialize to JSON - json_data = ham.to_json() - assert "encoding" in json_data - assert json_data["encoding"] == "jordan-wigner" - - # Deserialize from JSON - ham_restored = QubitHamiltonian.from_json(json_data) - assert ham_restored.encoding == "jordan-wigner" - assert ham_restored.pauli_strings == pauli_strings - assert np.array_equal(ham_restored.coefficients, coefficients) - - -def test_qubit_hamiltonian_encoding_serialization_hdf5(tmp_path): - """Test that QubitHamiltonian encoding is preserved through HDF5 serialization.""" - pauli_strings = ["II", "ZI", "IZ"] - coefficients = np.array([1.0, 0.5, 0.5]) - ham = QubitHamiltonian(pauli_strings, coefficients, encoding="jordan-wigner") - - # Save to HDF5 - hdf5_path = tmp_path / "hamiltonian.h5" - with h5py.File(hdf5_path, "w") as f: - ham.to_hdf5(f) - - # Load from HDF5 - with h5py.File(hdf5_path, "r") as f: - ham_restored = QubitHamiltonian.from_hdf5(f) - - assert ham_restored.encoding == "jordan-wigner" - assert ham_restored.pauli_strings == pauli_strings - assert np.array_equal(ham_restored.coefficients, coefficients) - - -def test_validate_encoding_compatibility_matching(): - """Test that validation passes when encodings match.""" - circuit = Circuit(qasm="OPENQASM 3.0; qubit[2] q;", encoding="jordan-wigner") - pauli_strings = ["II", "ZI"] - coefficients = np.array([1.0, 0.5]) - hamiltonian = QubitHamiltonian(pauli_strings, coefficients, encoding="jordan-wigner") - - # Should not raise an error - validate_encoding_compatibility(circuit, hamiltonian) - - -def test_validate_encoding_compatibility_none(): - """Test that validation fails when encodings are None.""" - circuit_none = Circuit(qasm="OPENQASM 3.0; qubit[2] q;", encoding=None) - circuit_jw = Circuit(qasm="OPENQASM 3.0; qubit[2] q;", encoding="jordan-wigner") - pauli_strings = ["II", "ZI"] - coefficients = np.array([1.0, 0.5]) - ham_none = QubitHamiltonian(pauli_strings, coefficients, encoding=None) - ham_jw = QubitHamiltonian(pauli_strings, coefficients, encoding="jordan-wigner") - - # Circuit with None encoding should raise error - with pytest.raises(EncodingMismatchError) as exc_info: - validate_encoding_compatibility(circuit_none, ham_jw) - assert "Circuit encoding is not specified" in str(exc_info.value) - - # Hamiltonian with None encoding should raise error - with pytest.raises(EncodingMismatchError) as exc_info: - validate_encoding_compatibility(circuit_jw, ham_none) - assert "QubitHamiltonian encoding is not specified" in str(exc_info.value) - - # Both None should raise error (circuit is checked first) - with pytest.raises(EncodingMismatchError) as exc_info: - validate_encoding_compatibility(circuit_none, ham_none) - assert "Circuit encoding is not specified" in str(exc_info.value) - - -def test_validate_encoding_compatibility_mismatch(): - """Test that validation fails when encodings don't match.""" - circuit = Circuit(qasm="OPENQASM 3.0; qubit[2] q;", encoding="jordan-wigner") - pauli_strings = ["II", "ZI"] - coefficients = np.array([1.0, 0.5]) - hamiltonian = QubitHamiltonian(pauli_strings, coefficients, encoding="bravyi-kitaev") - - # Should raise EncodingMismatchError - with pytest.raises(EncodingMismatchError) as exc_info: - validate_encoding_compatibility(circuit, hamiltonian) - - error_msg = str(exc_info.value) - assert "jordan-wigner" in error_msg - assert "bravyi-kitaev" in error_msg - assert "incompatible" in error_msg.lower() - - -def test_state_preparation_injects_jordan_wigner_encoding(wavefunction_4e4o): - """Test that StatePreparation algorithms inject Jordan-Wigner encoding.""" - # Test sparse_isometry_gf2x - prep_gf2x = create("state_prep", "sparse_isometry_gf2x") - circuit_gf2x = prep_gf2x.run(wavefunction_4e4o) - assert circuit_gf2x.encoding == "jordan-wigner" - - -@pytest.mark.skipif(not QDK_CHEMISTRY_HAS_QISKIT, reason="Qiskit not available") -def test_qiskit_state_preparation_injects_jordan_wigner_encoding(wavefunction_4e4o): - # Test qiskit_regular_isometry - prep_regular = create("state_prep", "qiskit_regular_isometry") - circuit_regular = prep_regular.run(wavefunction_4e4o) - assert circuit_regular.encoding == "jordan-wigner" - - -@pytest.mark.skipif(not QDK_CHEMISTRY_HAS_QISKIT_NATURE, reason="Qiskit Nature not available") -def test_qiskit_qubit_mapper_injects_encoding(): - """Test that QubitMapper injects the correct encoding.""" - hamiltonian = create_test_hamiltonian(2) - n_modes = 2 * 2 # 2 spatial orbitals → 4 spin-orbitals - - # Test Jordan-Wigner - mapper_jw = create("qubit_mapper", "qiskit") - qubit_ham_jw = mapper_jw.run(hamiltonian, MajoranaMapping.jordan_wigner(n_modes)) - assert qubit_ham_jw.encoding == "jordan-wigner" - - # Test Bravyi-Kitaev - mapper_bk = create("qubit_mapper", "qiskit") - qubit_ham_bk = mapper_bk.run(hamiltonian, MajoranaMapping.bravyi_kitaev(n_modes)) - assert qubit_ham_bk.encoding == "bravyi-kitaev" - - # Test Parity - mapper_parity = create("qubit_mapper", "qiskit") - qubit_ham_parity = mapper_parity.run(hamiltonian, MajoranaMapping.parity(n_modes)) - assert qubit_ham_parity.encoding == "parity" - - -def test_qdk_qubit_mapper_injects_encoding(): - """Test that QDK QubitMapper injects the correct encoding.""" - hamiltonian = create_test_hamiltonian(2) - n_modes = 2 * 2 - - # Test Jordan-Wigner - mapper_jw = create("qubit_mapper", "qdk") - qubit_ham_jw = mapper_jw.run(hamiltonian, MajoranaMapping.jordan_wigner(n_modes)) - assert qubit_ham_jw.encoding == "jordan-wigner" - - # Test Bravyi-Kitaev - mapper_bk = create("qubit_mapper", "qdk") - qubit_ham_bk = mapper_bk.run(hamiltonian, MajoranaMapping.bravyi_kitaev(n_modes)) - assert qubit_ham_bk.encoding == "bravyi-kitaev" - - -def test_group_commuting_preserves_encoding(): - """Test that term_grouper preserves the encoding metadata.""" - pauli_strings = ["II", "ZI", "IZ", "ZZ", "XX", "YY"] - coefficients = np.array([1.0, 0.5, 0.5, 0.25, 0.3, 0.3]) - ham = QubitHamiltonian(pauli_strings, coefficients, encoding="jordan-wigner") - - grouped = registry.create("term_grouper", "qubit_wise_commuting").run(ham) - assert grouped.encoding == "jordan-wigner" - - -def test_end_to_end_workflow_compatible_encodings(wavefunction_4e4o): - """Test end-to-end workflow with compatible encodings (both Jordan-Wigner).""" - hamiltonian = create_test_hamiltonian(2) - - # Create QubitHamiltonian with Jordan-Wigner encoding - mapper = create("qubit_mapper", "qdk") - mapping = MajoranaMapping.jordan_wigner(num_modes=4) - qubit_ham = mapper.run(hamiltonian, mapping) - - # Create Circuit with state preparation (should be Jordan-Wigner) - prep = create("state_prep", "sparse_isometry_gf2x") - circuit = prep.run(wavefunction_4e4o) - - # Validation should pass - validate_encoding_compatibility(circuit, qubit_ham) - - -def test_end_to_end_workflow_incompatible_encodings(wavefunction_4e4o): - """Test end-to-end workflow with incompatible encodings.""" - hamiltonian = create_test_hamiltonian(2) - - # Create QubitHamiltonian with Bravyi-Kitaev encoding - mapper = create("qubit_mapper", "qdk") - mapping = MajoranaMapping.bravyi_kitaev(num_modes=4) - qubit_ham = mapper.run(hamiltonian, mapping) - - # Create Circuit with state preparation (should be Jordan-Wigner) - prep = create("state_prep", "sparse_isometry_gf2x") - circuit = prep.run(wavefunction_4e4o) - - # Validation should fail - with pytest.raises(EncodingMismatchError): - validate_encoding_compatibility(circuit, qubit_ham) - - -def test_circuit_summary_includes_encoding(): - """Test that Circuit.get_summary() includes encoding information.""" - circuit = Circuit(qasm="OPENQASM 3.0; qubit[2] q;", encoding="jordan-wigner") - summary = circuit.get_summary() - - assert "jordan-wigner" in summary - assert "Encoding" in summary - - -def test_qubit_hamiltonian_summary_includes_encoding(): - """Test that QubitHamiltonian.get_summary() includes encoding information.""" - pauli_strings = ["II", "ZI"] - coefficients = np.array([1.0, 0.5]) - ham = QubitHamiltonian(pauli_strings, coefficients, encoding="jordan-wigner") - summary = ham.get_summary() - - assert "jordan-wigner" in summary - assert "Encoding" in summary - - -# ------------------------------------------------------------------------------------- -# Fermion mode order metadata -# ------------------------------------------------------------------------------------- - - -def test_qdk_qubit_mapper_sets_fermion_mode_order(): - """QDK native qubit mapper sets fermion_mode_order to BLOCKED.""" - hamiltonian = create_test_hamiltonian(2) - n_modes = 4 # 2 spatial → 4 spin-orbitals - - for factory in (MajoranaMapping.jordan_wigner, MajoranaMapping.bravyi_kitaev): - mapping = factory(n_modes) - qh = create("qubit_mapper", "qdk").run(hamiltonian, mapping) - assert qh.fermion_mode_order == FermionModeOrder.BLOCKED - - -@pytest.mark.skipif(not QDK_CHEMISTRY_HAS_QISKIT_NATURE, reason="Qiskit Nature not available") -def test_qiskit_qubit_mapper_sets_fermion_mode_order(): - """Qiskit qubit mapper sets fermion_mode_order to BLOCKED.""" - hamiltonian = create_test_hamiltonian(2) - n_modes = 4 - - for factory in (MajoranaMapping.jordan_wigner, MajoranaMapping.bravyi_kitaev, MajoranaMapping.parity): - mapping = factory(n_modes) - qh = create("qubit_mapper", "qiskit").run(hamiltonian, mapping) - assert qh.fermion_mode_order == FermionModeOrder.BLOCKED - - -def test_unnamed_mapping_dispatch(): - """QDK mapper accepts unnamed custom mappings; Qiskit mapper rejects them.""" - hamiltonian = create_test_hamiltonian(2) - n_modes = 2 * 2 - - jw = MajoranaMapping.jordan_wigner(num_modes=n_modes) - unnamed = MajoranaMapping(table=list(jw.table), name="") - assert unnamed.name == "" - - # QDK mapper works fine with unnamed mapping - qdk_mapper = create("qubit_mapper", "qdk") - result = qdk_mapper.run(hamiltonian, unnamed) - assert isinstance(result, QubitHamiltonian) - - # Qiskit mapper rejects unnamed mapping - if QDK_CHEMISTRY_HAS_QISKIT_NATURE: - qiskit_mapper = create("qubit_mapper", "qiskit") - with pytest.raises(NotImplementedError): - qiskit_mapper.run(hamiltonian, unnamed) From 564577f14b441bf69952a5a4560d7852223f2ce2 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sat, 30 May 2026 03:02:18 +0000 Subject: [PATCH 100/117] review: trim tests, docs, and docstrings per multi-agent review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests (90 → 43): - Drop trivial getter/metadata/immutability/display/cross-encoding tests - Drop redundant serialization, bilinear antisymmetry/hermitian/caching tests - Drop stale test_majorana_consistent_with_call and sparse conversion micro-tests - Fix stale test_scbk_factory_creates_bk_table → compares against bk_tree - Add test_without_tapering Docs: - Fix pybind class docstring (was missing bilinear-only form) - Fix RST 'stabilizers' reference (removed feature term) - Trim C++ class doxygen, pybind table property docstring - Trim verbose factory docstrings (BK-tree, parity, SCBK) - Trim num_qubits docstring - Drop BK superfast reference from test class docstring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../qdk/chemistry/data/majorana_mapping.hpp | 18 +- .../comprehensive/data/majorana_mapping.rst | 2 +- python/src/pybind11/data/majorana_mapping.cpp | 9 +- .../qdk_chemistry/data/majorana_mapping.py | 42 +-- python/tests/test_majorana_mapping.py | 346 +----------------- 5 files changed, 30 insertions(+), 387 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index 2635250d2..e467b2556 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -15,21 +15,9 @@ namespace qdk::chemistry::data { /** * @brief Data class describing a fermion-to-qubit encoding. * - * A MajoranaMapping encodes 2N Majorana operators into Pauli operators. - * Two construction forms are supported: - * - * - **Majorana-atomic** (the table constructor and all factory methods): - * stores a 2N-entry table mapping each individual gamma_k to a Pauli word. - * The bilinear i*gamma_j*gamma_k is computed on demand from the table via - * PauliTermAccumulator::multiply_uncached. - * - * - **Bilinear-only** (via from_bilinears): stores the bilinear images - * directly. Individual gamma_k have no Pauli image; only bilinear(j,k) - * is available. This form supports encodings where m > n qubits represent - * n modes and single Majoranas anticommute with codespace stabilizers. - * - * Pauli words use the little-endian convention of QubitHamiltonian (qubit 0 - * has the smallest sparse index). + * Majorana-atomic mappings store a 2N-entry Pauli table for individual + * gamma_k; bilinear-only mappings (via from_bilinears) store the bilinear + * images directly. bilinear(j, k) is available on both forms. */ class MajoranaMapping { public: diff --git a/docs/source/user/comprehensive/data/majorana_mapping.rst b/docs/source/user/comprehensive/data/majorana_mapping.rst index a4fb83964..d4b248882 100644 --- a/docs/source/user/comprehensive/data/majorana_mapping.rst +++ b/docs/source/user/comprehensive/data/majorana_mapping.rst @@ -18,7 +18,7 @@ Across fermion-to-qubit encodings, the most general primitive that admits a Paul Bilinears generate the parity-even subalgebra of the Majorana Clifford algebra, so any parity-conserving operator decomposes into ordered bilinear products, and higher-degree even monomials are products of bilinears. Individual Majorana operators :math:`\gamma_k` have a Pauli image only in **Majorana-atomic** encodings. -In **bilinear-only** encodings (where :math:`m > n` qubits represent :math:`n` modes), single Majoranas anticommute with codespace stabilizers and have no representation in the physical subspace; only the bilinears are observable. +In **bilinear-only** encodings (where :math:`m > n` qubits represent :math:`n` modes), single Majoranas have no representation in the physical subspace; only the bilinears are observable. The :class:`~qdk_chemistry.data.MajoranaMapping` supports both forms: diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index 624ff3389..85b7b48fc 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -22,10 +22,9 @@ void bind_majorana_mapping(pybind11::module& data) { py::class_ mapping(data, "MajoranaMapping", R"( Fermion-to-qubit encoding. -Stores a 2N-entry table mapping each Majorana operator gamma_k to a sparse -Pauli word. The bilinear ``i*gamma_j*gamma_k`` is the unified primitive and -is computed on demand from the table. Sparse Pauli words use the little-endian -convention of QubitHamiltonian (qubit 0 has the smallest index). +Majorana-atomic mappings store a 2N-entry Pauli table for individual gamma_k; +bilinear-only mappings (via from_bilinears) store the bilinear images directly. +bilinear(j, k) is available on both forms. )"); mapping.def_static( @@ -56,7 +55,7 @@ convention of QubitHamiltonian (qubit 0 has the smallest index). mapping.def_property_readonly( "table", [](const MajoranaMapping& self) { return self.table(); }, - "List of 2N sparse Pauli words [(qubit_idx, op_type), ...]."); + "List of 2N sparse Pauli words (empty for bilinear-only mappings)."); mapping.def_property_readonly( "is_majorana_atomic", diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index f09bf4b65..ec48d9135 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -128,13 +128,7 @@ def num_modes(self) -> int: @property def num_qubits(self) -> int: - """Effective number of qubits after any tapering. - - For untapered encodings this equals the number of qubits in the Pauli - table. For tapering-based encodings (e.g. symmetry-conserving - Bravyi-Kitaev, parity with two-qubit reduction) this reflects the - reduced qubit count that downstream consumers will see. - """ + """Number of qubits, accounting for any tapering.""" if self._tapering is not None: return self._num_qubits - self._tapering.num_tapered return self._num_qubits @@ -257,12 +251,6 @@ def bravyi_kitaev(cls, num_modes: int) -> MajoranaMapping: def bravyi_kitaev_tree(cls, num_modes: int) -> MajoranaMapping: """Construct a balanced binary tree Bravyi-Kitaev encoding. - Implementation from arXiv:1701.07072 (Havlíček et al.). Unlike the - Fenwick-tree variant (:meth:`bravyi_kitaev`), the balanced tree has - guaranteed Z₂ symmetries at qubit positions ``(n/2-1)`` and ``(n-1)`` - for any even ``n``, making it the required base encoding for - :meth:`symmetry_conserving_bravyi_kitaev` tapering. - Args: num_modes (int): Number of fermionic modes (spin-orbitals). @@ -281,18 +269,12 @@ def parity( ) -> MajoranaMapping: """Construct a parity encoding, optionally with two-qubit reduction. - When ``symmetries`` is provided, the mapping includes a - :class:`~qdk_chemistry.data.TaperingSpecification` that tapers the two - Z₂ symmetry qubits (total electron-number parity and alpha-spin - parity), reducing the qubit count by 2. This is the same two-qubit - reduction used by Qiskit Nature's ``ParityMapper(num_particles=...)``. - Args: num_modes (int): Number of fermionic modes (spin-orbitals). - symmetries (Symmetries | None): If provided, enables two-qubit reduction for the target symmetry sector. + symmetries (Symmetries | None): If provided, enables two-qubit reduction. Returns: - MajoranaMapping: Mapping with name ``"parity"`` (untapered) or ``"parity-2q-reduced"`` (tapered). + MajoranaMapping: Mapping with name ``"parity"`` or ``"parity-2q-reduced"``. """ core = _CoreMajoranaMapping.parity(num_modes) @@ -309,32 +291,16 @@ def symmetry_conserving_bravyi_kitaev( ) -> MajoranaMapping: """Construct a symmetry-conserving Bravyi-Kitaev (SCBK) encoding. - Combines the balanced binary tree BK mapping - (:meth:`bravyi_kitaev_tree`) with a - :class:`~qdk_chemistry.data.TaperingSpecification` that removes the two Z₂ - symmetry qubits (total electron-number parity and alpha-spin parity), - reducing the qubit count by 2. - Args: num_modes (int): Number of fermionic modes (spin-orbitals). Must be even and >= 4. symmetries (Symmetries): Electron counts for the target symmetry sector. Returns: - MajoranaMapping: BK-tree mapping with SCBK tapering, name ``"symmetry-conserving-bravyi-kitaev"``. + MajoranaMapping: BK-tree mapping with SCBK tapering. Raises: ValueError: If num_modes < 4 or odd, or electron counts are invalid. - Examples: - >>> from qdk_chemistry.data import MajoranaMapping, Symmetries - >>> mapping = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) - >>> mapping.name - 'symmetry-conserving-bravyi-kitaev' - >>> mapping.base_encoding - 'bravyi-kitaev-tree' - >>> mapping.tapering.num_tapered - 2 - """ tapering = TaperingSpecification.symmetry_conserving_bravyi_kitaev(num_modes, symmetries) core = _CoreMajoranaMapping.bravyi_kitaev_tree(num_modes) diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index b3da74a68..361451eed 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -63,14 +63,6 @@ def test_clifford_algebra(self, n_modes: int) -> None: jw = MajoranaMapping.jordan_wigner(num_modes=n_modes) verify_clifford_algebra(jw) - def test_properties(self) -> None: - """JW factory sets correct properties.""" - jw = MajoranaMapping.jordan_wigner(num_modes=4) - assert jw.num_modes == 4 - assert jw.num_qubits == 4 - assert jw.name == "jordan-wigner" - assert len(jw.table) == 8 - def test_reference_n2(self) -> None: """JW n=2 matches hand-computed reference (little-endian).""" jw = MajoranaMapping.jordan_wigner(num_modes=2) @@ -96,13 +88,6 @@ def test_clifford_algebra(self, n_modes: int) -> None: bk = MajoranaMapping.bravyi_kitaev(num_modes=n_modes) verify_clifford_algebra(bk) - def test_properties(self) -> None: - """BK factory sets correct properties.""" - bk = MajoranaMapping.bravyi_kitaev(num_modes=4) - assert bk.num_modes == 4 - assert bk.num_qubits == 4 - assert bk.name == "bravyi-kitaev" - @pytest.mark.parametrize("n_modes", [3, 5, 7]) def test_non_power_of_two(self, n_modes: int) -> None: """BK works for non-power-of-2 mode counts.""" @@ -120,13 +105,6 @@ def test_clifford_algebra(self, n_modes: int) -> None: par = MajoranaMapping.parity(num_modes=n_modes) verify_clifford_algebra(par) - def test_properties(self) -> None: - """Parity factory sets correct properties.""" - par = MajoranaMapping.parity(num_modes=4) - assert par.num_modes == 4 - assert par.num_qubits == 4 - assert par.name == "parity" - def test_reference_n2(self) -> None: """Parity n=2 matches CNOT-derived reference.""" par = MajoranaMapping.parity(num_modes=2) @@ -158,42 +136,16 @@ def test_custom_from_table(self) -> None: assert custom.name == "my-jw" assert custom.table == ("IX", "IY", "XZ", "YZ") - def test_custom_unnamed(self) -> None: - """Custom mapping without name.""" - custom = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"]) - assert custom.name == "" - def test_from_mode_pairs(self) -> None: """from_mode_pairs produces same result as direct table.""" direct = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"], name="test") pairs = MajoranaMapping.from_mode_pairs(pairs=[("IX", "IY"), ("XZ", "YZ")], name="test") assert direct.table == pairs.table - def test_from_mode_pairs_equivalence(self) -> None: - """from_mode_pairs matches JW factory for n=2.""" - jw = MajoranaMapping.jordan_wigner(num_modes=2) - pairs = MajoranaMapping.from_mode_pairs(pairs=[("IX", "IY"), ("XZ", "YZ")], name="jordan-wigner") - assert jw.table == pairs.table - class TestFromBilinears: """Tests for the from_bilinears construction.""" - def test_bilinear_only_basic(self) -> None: - """Bilinear-only mapping stores and retrieves bilinears correctly.""" - jw = MajoranaMapping.jordan_wigner(num_modes=2) - bilinears: dict[tuple[int, int], tuple[complex, str]] = {} - for j in range(4): - for k in range(j + 1, 4): - coeff, pauli = jw.bilinear(j, k) - bilinears[(j, k)] = (coeff, pauli) - - bl = MajoranaMapping.from_bilinears(num_modes=2, bilinears=bilinears, name="test-bl") - assert bl.num_modes == 2 - assert bl.name == "test-bl" - assert bl.is_majorana_atomic is False - assert bl.table == () - def test_bilinear_lookup_matches_table(self) -> None: """Bilinear-only mapping reproduces the same bilinears as the table form.""" jw = MajoranaMapping.jordan_wigner(num_modes=3) @@ -268,28 +220,6 @@ def test_missing_entry_raises(self) -> None: }, ) - def test_bilinear_out_of_range_raises(self) -> None: - """bilinear() raises IndexError for out-of-range indices on bilinear-only mapping.""" - jw = MajoranaMapping.jordan_wigner(num_modes=2) - bilinears: dict[tuple[int, int], tuple[complex, str]] = {} - for j in range(4): - for k in range(j + 1, 4): - bilinears[(j, k)] = jw.bilinear(j, k) - bl = MajoranaMapping.from_bilinears(num_modes=2, bilinears=bilinears) - with pytest.raises(IndexError): - bl.bilinear(0, 99) - - def test_bilinear_equal_indices_raises(self) -> None: - """bilinear(j, j) raises ValueError on bilinear-only mapping.""" - jw = MajoranaMapping.jordan_wigner(num_modes=2) - bilinears: dict[tuple[int, int], tuple[complex, str]] = {} - for j in range(4): - for k in range(j + 1, 4): - bilinears[(j, k)] = jw.bilinear(j, k) - bl = MajoranaMapping.from_bilinears(num_modes=2, bilinears=bilinears) - with pytest.raises(ValueError): - bl.bilinear(1, 1) - # ─── Validation Tests ──────────────────────────────────────────────────── @@ -330,28 +260,6 @@ def test_zero_modes(self) -> None: # ─── Immutability Tests ───────────────────────────────────────────────── -class TestImmutability: - """Tests for DataClass immutability.""" - - def test_cannot_set_table(self) -> None: - """Setting table raises AttributeError.""" - jw = MajoranaMapping.jordan_wigner(num_modes=2) - with pytest.raises(AttributeError, match="Cannot modify"): - jw.table = ("foo",) # type: ignore[misc] - - def test_cannot_set_name(self) -> None: - """Setting name raises AttributeError.""" - jw = MajoranaMapping.jordan_wigner(num_modes=2) - with pytest.raises(AttributeError, match="Cannot modify"): - jw.name = "bar" # type: ignore[misc] - - def test_cannot_delete_attribute(self) -> None: - """Deleting attribute raises AttributeError.""" - jw = MajoranaMapping.jordan_wigner(num_modes=2) - with pytest.raises(AttributeError, match="Cannot delete"): - del jw.table # type: ignore[misc] - - # ─── Serialization Tests ──────────────────────────────────────────────── @@ -367,51 +275,6 @@ def test_json_round_trip(self) -> None: assert loaded.name == jw.name assert loaded.num_modes == jw.num_modes - def test_json_has_version(self) -> None: - """JSON output includes version field.""" - jw = MajoranaMapping.jordan_wigner(num_modes=2) - data = jw.to_json() - assert "version" in data - assert data["version"] == "0.1.0" - - def test_hdf5_round_trip(self) -> None: - """HDF5 serialize and deserialize.""" - bk = MajoranaMapping.bravyi_kitaev(num_modes=4) - with tempfile.NamedTemporaryFile(suffix=".h5") as f: - with h5py.File(f.name, "w") as hf: - bk.to_hdf5(hf) - with h5py.File(f.name, "r") as hf: - loaded = MajoranaMapping.from_hdf5(hf) - assert loaded.table == bk.table - assert loaded.name == bk.name - - def test_json_file_round_trip(self) -> None: - """JSON file serialize and deserialize.""" - par = MajoranaMapping.parity(num_modes=4) - with tempfile.TemporaryDirectory() as td: - path = Path(td) / "test.majorana_mapping.json" - par.to_json_file(str(path)) - loaded = MajoranaMapping.from_json_file(str(path)) - assert loaded.table == par.table - assert loaded.name == par.name - - def test_hdf5_file_round_trip(self) -> None: - """HDF5 file serialize and deserialize.""" - jw = MajoranaMapping.jordan_wigner(num_modes=4) - with tempfile.TemporaryDirectory() as td: - path = Path(td) / "test.majorana_mapping.h5" - jw.to_hdf5_file(str(path)) - loaded = MajoranaMapping.from_hdf5_file(str(path)) - assert loaded.table == jw.table - - def test_custom_serialization(self) -> None: - """Custom mapping with name survives serialization.""" - custom = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"], name="my-custom") - data = custom.to_json() - loaded = MajoranaMapping.from_json(data) - assert loaded.table == custom.table - assert loaded.name == "my-custom" - def test_hdf5_round_trip_with_tapering(self) -> None: """HDF5 round-trip preserves tapering for SCBK mappings.""" from qdk_chemistry.data import Symmetries # noqa: PLC0415 @@ -427,64 +290,6 @@ def test_hdf5_round_trip_with_tapering(self) -> None: assert loaded.tapering is not None assert loaded.tapering.qubit_indices == scbk.tapering.qubit_indices assert loaded.tapering.eigenvalues == scbk.tapering.eigenvalues - assert loaded.tapering.source_num_qubits == scbk.tapering.source_num_qubits - assert loaded.tapering.source_encoding == scbk.tapering.source_encoding - - -# ─── Summary/Repr Tests ───────────────────────────────────────────────── - - -class TestDisplay: - """Tests for summary and repr.""" - - def test_repr_with_name(self) -> None: - """Repr includes name when present.""" - jw = MajoranaMapping.jordan_wigner(num_modes=2) - r = repr(jw) - assert "jordan-wigner" in r - assert "num_modes=2" in r - - def test_repr_without_name(self) -> None: - """Repr works for unnamed custom mappings.""" - custom = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"]) - r = repr(custom) - assert "num_modes=2" in r - - def test_get_summary(self) -> None: - """get_summary includes mapping details.""" - jw = MajoranaMapping.jordan_wigner(num_modes=2) - s = jw.get_summary() - assert "jordan-wigner" in s - assert "Modes: 2" in s - assert "gamma_0" in s - assert "IX" in s - - -# ─── Cross-encoding consistency ────────────────────────────────────────── - - -class TestCrossEncodingConsistency: - """Tests ensuring all encodings produce valid mappings with same structure.""" - - @pytest.mark.parametrize("n_modes", [2, 4, 6]) - def test_all_encodings_same_qubit_count(self, n_modes: int) -> None: - """JW, BK, and parity all use num_modes qubits.""" - jw = MajoranaMapping.jordan_wigner(num_modes=n_modes) - bk = MajoranaMapping.bravyi_kitaev(num_modes=n_modes) - par = MajoranaMapping.parity(num_modes=n_modes) - assert jw.num_qubits == n_modes - assert bk.num_qubits == n_modes - assert par.num_qubits == n_modes - - @pytest.mark.parametrize("n_modes", [2, 4, 6]) - def test_all_encodings_table_length(self, n_modes: int) -> None: - """All encodings have 2N table entries.""" - jw = MajoranaMapping.jordan_wigner(num_modes=n_modes) - bk = MajoranaMapping.bravyi_kitaev(num_modes=n_modes) - par = MajoranaMapping.parity(num_modes=n_modes) - assert len(jw.table) == 2 * n_modes - assert len(bk.table) == 2 * n_modes - assert len(par.table) == 2 * n_modes # ─── SCBK (symmetry-conserving Bravyi-Kitaev) ──────────────────────────── @@ -493,13 +298,13 @@ def test_all_encodings_table_length(self, n_modes: int) -> None: class TestSymmetryConservingBravyiKitaev: """Tests for the SCBK factory method and TaperingSpecification integration.""" - def test_scbk_factory_creates_bk_table(self) -> None: - """SCBK factory uses a standard BK Majorana table underneath.""" + def test_scbk_factory_creates_bk_tree_table(self) -> None: + """SCBK uses the BK-tree table as its base encoding.""" from qdk_chemistry.data import Symmetries # noqa: PLC0415 scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) - bk = MajoranaMapping.bravyi_kitaev(8) - assert scbk.table == bk.table + bk_tree = MajoranaMapping.bravyi_kitaev_tree(8) + assert scbk.table == bk_tree.table def test_scbk_name_and_base_encoding(self) -> None: """SCBK has the right name and base_encoding properties.""" @@ -561,19 +366,6 @@ def test_scbk_too_few_modes_raises(self) -> None: with pytest.raises(ValueError, match="4"): MajoranaMapping.symmetry_conserving_bravyi_kitaev(2, Symmetries(1, 0)) - def test_standard_mappings_have_no_tapering(self) -> None: - """JW, BK, parity (without symmetries) have tapering=None.""" - assert MajoranaMapping.jordan_wigner(4).tapering is None - assert MajoranaMapping.bravyi_kitaev(4).tapering is None - assert MajoranaMapping.parity(4).tapering is None - - def test_scbk_num_qubits_is_posttaper(self) -> None: - """MajoranaMapping.num_qubits reflects the post-taper qubit count.""" - from qdk_chemistry.data import Symmetries # noqa: PLC0415 - - scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) - assert scbk.num_qubits == 6 # 8 - 2 tapered - def test_parity_with_symmetries_has_tapering(self) -> None: """Parity with symmetries bundles a TaperingSpecification.""" from qdk_chemistry.data import Symmetries # noqa: PLC0415 @@ -583,13 +375,18 @@ def test_parity_with_symmetries_has_tapering(self) -> None: assert par.tapering.num_tapered == 2 assert par.name == "parity-2q-reduced" assert par.base_encoding == "parity" - assert par.num_qubits == 6 # 8 - 2 + assert par.num_qubits == 6 + + def test_without_tapering(self) -> None: + """without_tapering strips tapering but preserves the base table.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 - def test_parity_without_symmetries_no_tapering(self) -> None: - """Parity without symmetries has no tapering.""" - par = MajoranaMapping.parity(8) - assert par.tapering is None - assert par.num_qubits == 8 + scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + base = scbk.without_tapering() + bk_tree = MajoranaMapping.bravyi_kitaev_tree(8) + assert base.tapering is None + assert base.table == bk_tree.table + assert base.num_qubits == 8 # ─── Bilinear primitive ────────────────────────────────────────────────── @@ -655,14 +452,7 @@ def _multiply_dense(a: str, b: str) -> tuple[complex, str]: class TestBilinear: - """Tests for the unified bilinear primitive ``i·gamma_j·gamma_k``. - - Bilinears are the most general primitive across fermion-to-qubit - encodings: Majorana-atomic encodings expose individual gamma_k as well, but - redundant encodings (e.g. Bravyi-Kitaev superfast) only admit - parity-even products. These tests verify the algebraic invariants that - every backend must satisfy. - """ + """Tests for the bilinear primitive ``i·gamma_j·gamma_k``.""" @pytest.mark.parametrize(("name", "factory"), _FACTORIES) @pytest.mark.parametrize("n_modes", [2, 4, 6]) @@ -681,20 +471,6 @@ def test_matches_majorana_product(self, name: str, factory, n_modes: int) -> Non assert bword == expected_word, f"({j},{k}): word {bword} != {expected_word}" assert abs(bcoeff - expected_coeff) < 1e-12, f"({j},{k}): coeff {bcoeff} != {expected_coeff}" - @pytest.mark.parametrize(("name", "factory"), _FACTORIES) - @pytest.mark.parametrize("n_modes", [2, 4]) - def test_antisymmetry(self, name: str, factory, n_modes: int) -> None: - """``bilinear(k, j) == -bilinear(j, k)`` for all distinct j, k.""" - del name - m = factory(n_modes) - n = 2 * n_modes - for j in range(n): - for k in range(j + 1, n): - cjk, wjk = m.bilinear(j, k) - ckj, wkj = m.bilinear(k, j) - assert wjk == wkj - assert abs(cjk + ckj) < 1e-12, f"({j},{k}): {cjk} + {ckj} != 0" - @pytest.mark.parametrize(("name", "factory"), _FACTORIES) @pytest.mark.parametrize("n_modes", [2, 4]) def test_squares_to_identity(self, name: str, factory, n_modes: int) -> None: @@ -767,27 +543,12 @@ def test_raises_on_equal_indices(self) -> None: m = MajoranaMapping.jordan_wigner(num_modes=2) with pytest.raises(ValueError, match="distinct"): m.bilinear(0, 0) - with pytest.raises(ValueError, match="distinct"): - m.bilinear(3, 3) def test_raises_on_out_of_range(self) -> None: """Out-of-range indices raise IndexError.""" m = MajoranaMapping.jordan_wigner(num_modes=2) with pytest.raises(IndexError): m.bilinear(0, 4) - with pytest.raises(IndexError): - m.bilinear(99, 0) - - def test_majorana_consistent_with_call(self) -> None: - """``majorana(k)`` and ``__call__(k)`` describe the same Pauli operator.""" - m = MajoranaMapping.jordan_wigner(num_modes=3) - n_q = len(m.table[0]) - for k in range(2 * m.num_modes): - sparse = m.core(k) - dense = m.majorana(k) - assert len(dense) == n_q - non_identity = sum(1 for c in dense if c != "I") - assert non_identity == len(sparse) def test_majorana_out_of_range(self) -> None: """``majorana(k)`` raises IndexError on out-of-range k.""" @@ -798,8 +559,9 @@ def test_majorana_out_of_range(self) -> None: m.majorana(99) + class TestEncodingMetadata: - """Tests for the new metadata properties on Majorana-atomic encodings.""" + """Tests for encoding metadata properties.""" @pytest.mark.parametrize(("name", "factory"), _FACTORIES) @pytest.mark.parametrize("n_modes", [2, 4]) @@ -809,14 +571,6 @@ def test_is_majorana_atomic(self, name: str, factory, n_modes: int) -> None: m = factory(n_modes) assert m.is_majorana_atomic is True - def test_pauli_string_length_untapered(self) -> None: - """For untapered encodings, bilinear/majorana strings have length ``num_qubits``.""" - m = MajoranaMapping.jordan_wigner(num_modes=4) - assert len(m.table[0]) == m.num_qubits == 4 - assert len(m.majorana(0)) == 4 - _, w = m.bilinear(0, 1) - assert len(w) == 4 - def test_pauli_string_length_with_tapering(self) -> None: """For tapered SCBK, bilinear/majorana operate in the pre-taper basis.""" from qdk_chemistry.data import Symmetries # noqa: PLC0415 @@ -831,67 +585,3 @@ def test_pauli_string_length_with_tapering(self) -> None: continue _, w = scbk.bilinear(j, k) assert len(w) == 8 - - -# ─── Sparse/dense conversion helpers ──────────────────────────────────── - - -class TestSparseConversion: - """Tests for _dense_le_to_sparse and _sparse_to_dense_le.""" - - def test_roundtrip_identity(self) -> None: - """All-identity string round-trips to empty sparse word.""" - from qdk_chemistry.data.majorana_mapping import _dense_le_to_sparse, _sparse_to_dense_le - - assert _dense_le_to_sparse("IIII") == [] - assert _sparse_to_dense_le([], 4) == "IIII" - - def test_roundtrip_single_pauli(self) -> None: - """Single non-identity Pauli round-trips correctly.""" - from qdk_chemistry.data.majorana_mapping import _dense_le_to_sparse, _sparse_to_dense_le - - for label in ("IIIX", "IIYI", "ZIII", "IXII"): - sparse = _dense_le_to_sparse(label) - assert _sparse_to_dense_le(sparse, 4) == label - - def test_roundtrip_jw_table(self) -> None: - """JW table entries survive sparse→dense round-trip.""" - from qdk_chemistry.data.majorana_mapping import _dense_le_to_sparse, _sparse_to_dense_le - - jw = MajoranaMapping.jordan_wigner(num_modes=4) - for entry in jw.table: - sparse = _dense_le_to_sparse(entry) - assert _sparse_to_dense_le(sparse, len(entry)) == entry - - def test_invalid_character_raises(self) -> None: - """Invalid Pauli character raises ValueError.""" - from qdk_chemistry.data.majorana_mapping import _dense_le_to_sparse - - with pytest.raises(ValueError, match="Invalid Pauli character"): - _dense_le_to_sparse("IXAZ") - - def test_case_insensitive(self) -> None: - """Lowercase Pauli characters are accepted.""" - from qdk_chemistry.data.majorana_mapping import _dense_le_to_sparse - - assert _dense_le_to_sparse("ix") == _dense_le_to_sparse("IX") - - -class TestBilinearCaching: - """Verify that bilinear results are cached and consistent.""" - - @pytest.mark.parametrize(("name", "factory"), _FACTORIES) - def test_cached_bilinear_matches_manual_multiply(self, name: str, factory) -> None: - """Cached bilinear(j,k) matches manual Pauli multiply of table entries.""" - del name - m = factory(4) - for j in range(2 * m.num_modes): - for k in range(j + 1, 2 * m.num_modes): - coeff, word = m.bilinear(j, k) - phase, product = PauliTermAccumulator.multiply_uncached(m.core(j), m.core(k)) - from qdk_chemistry.data.majorana_mapping import _sparse_to_dense_le - - expected_word = _sparse_to_dense_le(product, m.num_qubits) - expected_coeff = 1j * phase - assert word == expected_word, f"bilinear({j},{k}) word mismatch" - assert abs(coeff - expected_coeff) < 1e-12, f"bilinear({j},{k}) coeff mismatch" From 01a6240771fad39185fb8e4650b09af2777322bc Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Sun, 31 May 2026 01:59:50 +0000 Subject: [PATCH 101/117] added taper_to_scbk implementation, addressed multi-agent comments, cleaned pre-commit --- .../chemistry/data/majorana_map_engine.cpp | 38 ++++++++------- .../qdk/chemistry/data/majorana_mapping.cpp | 10 ++-- .../_static/examples/python/qubit_mapper.py | 8 ++-- .../qdk_chemistry/data/majorana_mapping.py | 20 ++------ .../qdk_chemistry/data/qubit_hamiltonian.py | 2 +- python/src/qdk_chemistry/data/tapering.py | 47 +++++++++++++++++-- python/tests/test_majorana_mapping.py | 6 +-- python/tests/test_qdk_qubit_mapper.py | 45 ++++++++++++------ python/tests/test_qubit_hamiltonian.py | 8 ++-- python/tests/test_tapering.py | 42 ++++++++++++++++- 10 files changed, 154 insertions(+), 72 deletions(-) diff --git a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp index 43116280e..04fedf45d 100644 --- a/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_map_engine.cpp @@ -112,6 +112,8 @@ std::pair> multiply_packed( phase_exp += std::popcount(cyc); phase_exp -= std::popcount(anti); } + // Pauli multiplication phases cycle mod 4: + // 0: +1, 1: +i, 2: −1, 3: −i. return {phase_exp & 3, result}; } @@ -255,8 +257,7 @@ MajoranaMapResult majorana_map_impl( for (int b = 0; b < 2; ++b) { const auto& [coeff, word] = cache[(2 * bp + a) * maj_per_spin + (2 * bq + b)]; - acc.accumulate(word, - coeff * h_pq * 0.25 * excitation_coeff[a][b]); + acc.accumulate(word, coeff * h_pq * 0.25 * excitation_coeff[a][b]); } } }; @@ -319,11 +320,11 @@ MajoranaMapResult majorana_map_impl( for (int a = 0; a < 2; ++a) { for (int b = 0; b < 2; ++b) { const auto& [coeff_a, word_a] = alpha_pair(2 * p + a, 2 * q + b); - sse.terms.emplace_back( - coeff_a * 0.25 * excitation_coeff[a][b], word_a); + sse.terms.emplace_back(coeff_a * 0.25 * excitation_coeff[a][b], + word_a); const auto& [coeff_b, word_b] = beta_pair(2 * p + a, 2 * q + b); - sse.terms.emplace_back( - coeff_b * 0.25 * excitation_coeff[a][b], word_b); + sse.terms.emplace_back(coeff_b * 0.25 * excitation_coeff[a][b], + word_b); } } } @@ -421,9 +422,9 @@ MajoranaMapResult majorana_map_impl( for (int d = 0; d < 2; ++d) { const auto& [coeff2, w2] = cache_rs[(2 * br + c) * maj_per_spin + (2 * bs + d)]; - std::complex scale = - coeff1 * coeff2 * half_eri * 0.0625 * - excitation_coeff[a][b] * excitation_coeff[c][d]; + std::complex scale = coeff1 * coeff2 * half_eri * + 0.0625 * excitation_coeff[a][b] * + excitation_coeff[c][d]; acc.accumulate_product(w1, w2, scale); } } @@ -498,10 +499,11 @@ MajoranaMapResult majorana_map_impl( } // Dispatch table: function pointer per NW, covering 1..16 (up to 1024 qubits). -using DispatchFn = MajoranaMapResult (*)( - const MajoranaMapping&, double, const double*, const double*, - const double*, const double*, const double*, std::size_t, bool, - double, double); +using DispatchFn = MajoranaMapResult (*)(const MajoranaMapping&, double, + const double*, const double*, + const double*, const double*, + const double*, std::size_t, bool, + double, double); template constexpr std::array make_dispatch_table( @@ -529,15 +531,15 @@ MajoranaMapResult majorana_map_hamiltonian( if (num_words > detail::max_nw) { throw std::invalid_argument( "majorana_map_hamiltonian: num_qubits=" + std::to_string(num_qubits) + - " exceeds the maximum of " + - std::to_string(detail::max_nw * 64) + " qubits."); + " exceeds the maximum of " + std::to_string(detail::max_nw * 64) + + " qubits."); } static const auto table = detail::make_dispatch_table(std::make_index_sequence{}); - return table[num_words - 1](mapping, core_energy, h1_alpha, h1_beta, - eri_aaaa, eri_aabb, eri_bbbb, n_spatial, - spin_symmetric, threshold, integral_threshold); + return table[num_words - 1](mapping, core_energy, h1_alpha, h1_beta, eri_aaaa, + eri_aabb, eri_bbbb, n_spatial, spin_symmetric, + threshold, integral_threshold); } } // namespace qdk::chemistry::data diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp index 31a6f0b8e..7b8ee9bc3 100644 --- a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp @@ -68,11 +68,11 @@ MajoranaMapping MajoranaMapping::from_bilinears( const std::size_t M = 2 * num_modes; const std::size_t expected = M * (M - 1) / 2; if (upper_triangle.size() != expected) { - throw std::invalid_argument( - "MajoranaMapping::from_bilinears: expected " + - std::to_string(expected) + " upper-triangle entries for " + - std::to_string(num_modes) + " modes, got " + - std::to_string(upper_triangle.size())); + throw std::invalid_argument("MajoranaMapping::from_bilinears: expected " + + std::to_string(expected) + + " upper-triangle entries for " + + std::to_string(num_modes) + " modes, got " + + std::to_string(upper_triangle.size())); } std::uint64_t max_idx = 0; bool has_any = false; diff --git a/docs/source/_static/examples/python/qubit_mapper.py b/docs/source/_static/examples/python/qubit_mapper.py index bad0ff966..f2ace3146 100644 --- a/docs/source/_static/examples/python/qubit_mapper.py +++ b/docs/source/_static/examples/python/qubit_mapper.py @@ -39,11 +39,12 @@ ) # Select an active space +num_active_orbitals = 6 active_space_selector = create( "active_space_selector", algorithm_name="qdk_valence", num_active_electrons=4, - num_active_orbitals=6, + num_active_orbitals=num_active_orbitals, ) active_wfn = active_space_selector.run(wfn_hf) active_orbitals = active_wfn.get_orbitals() @@ -52,9 +53,8 @@ hamiltonian_constructor = create("hamiltonian_constructor") hamiltonian = hamiltonian_constructor.run(active_orbitals) -# Determine the number of spin-orbitals -n_spatial = hamiltonian.get_orbitals().get_num_molecular_orbitals() -n_spin_orbitals = 2 * n_spatial +# Determine the number of spin-orbitals in the active space +n_spin_orbitals = 2 * num_active_orbitals # Choose an encoding mapping = MajoranaMapping.jordan_wigner(num_modes=n_spin_orbitals) diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index ec48d9135..8c03bf49b 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -64,7 +64,6 @@ def _sparse_to_dense_le(word: SparsePauliWord, num_qubits: int) -> str: return "".join(chars) - class MajoranaMapping(DataClass): """Fermion-to-qubit encoding. @@ -150,7 +149,7 @@ def without_tapering(self) -> MajoranaMapping: A new :class:`MajoranaMapping` with no tapering. """ - return MajoranaMapping(table=[], _core=self._core) + return self._from_core(self._core) @property def base_encoding(self) -> str: @@ -351,7 +350,7 @@ def from_bilinears( Args: num_modes (int): Number of fermionic modes (spin-orbitals). Must be > 0. - bilinears (dict[tuple[int, int], tuple[complex, str]]): Mapping from ``(j, k)`` with ``j < k`` to ``(coeff, pauli_str)``, one entry per distinct ordered pair of Majorana indices. + bilinears (dict[tuple[int, int], tuple[complex, str]]): Upper-triangle bilinear images. name (str): Optional human-readable label. Default ``""``. Returns: @@ -361,12 +360,11 @@ def from_bilinears( ValueError: If sizes are inconsistent or num_modes is zero. """ - M = 2 * num_modes + M = 2 * num_modes # noqa: N806 expected = M * (M - 1) // 2 if len(bilinears) != expected: raise ValueError( - f"Expected {expected} upper-triangle bilinear entries for " - f"{num_modes} modes, got {len(bilinears)}" + f"Expected {expected} upper-triangle bilinear entries for {num_modes} modes, got {len(bilinears)}" ) # Build upper-triangle flat list in row-major order upper_triangle: list[tuple[complex, SparsePauliWord]] = [] @@ -377,15 +375,7 @@ def from_bilinears( coeff, label = bilinears[(j, k)] upper_triangle.append((complex(coeff), _dense_le_to_sparse(label))) core = _CoreMajoranaMapping.from_bilinears(num_modes, upper_triangle, name) - result = cls.__new__(cls) - result._core = core - result._name = name if name else core.name - result._num_modes = core.num_modes - result._num_qubits = core.num_qubits - result._table = () - result._tapering = None - DataClass.__init__(result) - return result + return cls(table=[], name=name, _core=core) @classmethod def _from_core(cls, core: _CoreMajoranaMapping) -> MajoranaMapping: diff --git a/python/src/qdk_chemistry/data/qubit_hamiltonian.py b/python/src/qdk_chemistry/data/qubit_hamiltonian.py index ff5fa8178..1372ea05f 100644 --- a/python/src/qdk_chemistry/data/qubit_hamiltonian.py +++ b/python/src/qdk_chemistry/data/qubit_hamiltonian.py @@ -255,7 +255,7 @@ def __add__(self, other: QubitHamiltonian) -> QubitHamiltonian: Raises: TypeError: If *other* is not a ``QubitHamiltonian``. - ValueError: If the two Hamiltonians have different qubit counts, encodings, fermion mode orders, or tapering. + ValueError: If the two Hamiltonians have different qubit counts, encodings, or modes. """ if not isinstance(other, QubitHamiltonian): diff --git a/python/src/qdk_chemistry/data/tapering.py b/python/src/qdk_chemistry/data/tapering.py index 27fe71a1c..1450c87f1 100644 --- a/python/src/qdk_chemistry/data/tapering.py +++ b/python/src/qdk_chemistry/data/tapering.py @@ -114,7 +114,9 @@ def num_tapered(self) -> int: return len(self._qubit_indices) @classmethod - def symmetry_conserving_bravyi_kitaev(cls, num_modes: int, symmetries: Symmetries) -> TaperingSpecification: + def symmetry_conserving_bravyi_kitaev( + cls, num_modes: int, symmetries: Symmetries, source_encoding: str = "bravyi-kitaev" + ) -> TaperingSpecification: """Create a tapering specification for symmetry-conserving Bravyi-Kitaev. Tapers the two Z₂ symmetry qubits of the Bravyi-Kitaev encoding: @@ -124,6 +126,7 @@ def symmetry_conserving_bravyi_kitaev(cls, num_modes: int, symmetries: Symmetrie Args: num_modes (int): Number of spin-orbitals (= number of Bravyi-Kitaev qubits). symmetries (Symmetries): Electron counts for the target symmetry sector. + source_encoding (str): Encoding label for the pre-taper mapping. Default ``"bravyi-kitaev"``. Returns: TaperingSpecification: Tapering specification for symmetry-conserving Bravyi-Kitaev. @@ -152,7 +155,7 @@ def symmetry_conserving_bravyi_kitaev(cls, num_modes: int, symmetries: Symmetrie qubit_indices=(q_alpha, q_total), eigenvalues=(ev_alpha, ev_total), source_num_qubits=n, - source_encoding="bravyi-kitaev", + source_encoding=source_encoding, ) @classmethod @@ -277,10 +280,10 @@ def taper_qubits( ValueError: If lengths don't match, indices are out of range, contain duplicates, or eigenvalues are not +/-1. """ - from qdk_chemistry.data import QubitHamiltonian # noqa: PLC0415 - import numpy as np # noqa: PLC0415 + from qdk_chemistry.data import QubitHamiltonian # noqa: PLC0415 + qubit_indices = list(qubit_indices) eigenvalues = list(eigenvalues) @@ -358,3 +361,39 @@ def taper_qubits( encoding=qubit_hamiltonian.encoding, fermion_mode_order=qubit_hamiltonian.fermion_mode_order, ) + + +def taper_to_scbk( + qubit_hamiltonian: QubitHamiltonian, + symmetries: Symmetries, +) -> QubitHamiltonian: + """Apply symmetry-conserving Bravyi-Kitaev tapering to a qubit Hamiltonian. + + Computes the SCBK tapering specification from symmetries, applies + :func:`taper_qubits` to remove the two Z_2 symmetry qubits, and returns the + reduced Hamiltonian with encoding set to ``"symmetry-conserving-bravyi-kitaev"`` + and tapering metadata attached. + + Args: + qubit_hamiltonian (QubitHamiltonian): A qubit Hamiltonian produced by a Bravyi-Kitaev mapping. + symmetries (Symmetries): Electron counts (n_alpha, n_beta) for the target symmetry sector. + + Returns: + QubitHamiltonian: Tapered Hamiltonian with two fewer qubits, SCBK encoding label, and tapering metadata. + + """ + from qdk_chemistry.data import QubitHamiltonian # noqa: PLC0415 + + tapering = TaperingSpecification.symmetry_conserving_bravyi_kitaev( + num_modes=qubit_hamiltonian.num_qubits, + symmetries=symmetries, + source_encoding=qubit_hamiltonian.encoding or "bravyi-kitaev", + ) + tapered = taper_qubits(qubit_hamiltonian, tapering.qubit_indices, tapering.eigenvalues) + return QubitHamiltonian( + pauli_strings=tapered.pauli_strings, + coefficients=tapered.coefficients, + encoding="symmetry-conserving-bravyi-kitaev", + fermion_mode_order=tapered.fermion_mode_order, + tapering=tapering, + ) diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index 361451eed..f3233654c 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -20,7 +20,6 @@ # -------------------------------------------------------------------------------------------- import tempfile -from pathlib import Path import h5py import pytest @@ -207,7 +206,7 @@ def test_wrong_count_raises(self) -> None: def test_missing_entry_raises(self) -> None: """from_bilinears raises ValueError if a required (j,k) pair is missing.""" - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Missing bilinear"): MajoranaMapping.from_bilinears( num_modes=2, bilinears={ @@ -215,7 +214,7 @@ def test_missing_entry_raises(self) -> None: (0, 2): (1.0, "XZ"), (0, 3): (1.0, "YZ"), (1, 2): (1.0, "XI"), - # missing (1, 3) + # (1, 3) intentionally omitted (2, 3): (1.0, "ZI"), }, ) @@ -559,7 +558,6 @@ def test_majorana_out_of_range(self) -> None: m.majorana(99) - class TestEncodingMetadata: """Tests for encoding metadata properties.""" diff --git a/python/tests/test_qdk_qubit_mapper.py b/python/tests/test_qdk_qubit_mapper.py index b13adead9..f9c1be302 100644 --- a/python/tests/test_qdk_qubit_mapper.py +++ b/python/tests/test_qdk_qubit_mapper.py @@ -542,7 +542,7 @@ def test_parity_number_operator_structure(self) -> None: n = 6 mapping = MajoranaMapping.parity(n) - I_n = np.eye(2**n) + I_n = np.eye(2**n) # noqa: N806 for j in range(n): g0 = pauli_to_sparse_matrix([mapping.table[2 * j]], np.array([1.0])).toarray() @@ -734,8 +734,16 @@ def sym_eri(n, rng): for s in range(n): if h2[p, q, r, s] == 0: v = rng.standard_normal() * 0.2 - for a, b, c, d in {(p, q, r, s), (q, p, r, s), (p, q, s, r), (q, p, s, r), - (r, s, p, q), (s, r, p, q), (r, s, q, p), (s, r, q, p)}: + for a, b, c, d in { + (p, q, r, s), + (q, p, r, s), + (p, q, s, r), + (q, p, s, r), + (r, s, p, q), + (s, r, p, q), + (r, s, q, p), + (s, r, q, p), + }: h2[a, b, c, d] = v return h2.ravel() @@ -756,9 +764,19 @@ def sym_eri(n, rng): h2_aabb = h2_aabb_4d.ravel() fock_a, fock_b = np.eye(0), np.eye(0) - h = Hamiltonian(CanonicalFourCenterHamiltonianContainer( - h1_alpha, h1_beta, h2_aaaa, h2_aabb, h2_bbbb, orbitals, 0.0, fock_a, fock_b, - )) + h = Hamiltonian( + CanonicalFourCenterHamiltonianContainer( + h1_alpha, + h1_beta, + h2_aaaa, + h2_aabb, + h2_bbbb, + orbitals, + 0.0, + fock_a, + fock_b, + ) + ) n_modes = 2 * n mapper = create("qubit_mapper", "qdk") @@ -1117,7 +1135,7 @@ def test_scbk_carries_tapering_metadata(self) -> None: def test_scbk_matches_two_step(self) -> None: """One-step symmetry-conserving BK matches explicit BK-tree + internal taper.""" from qdk_chemistry.data import Symmetries # noqa: PLC0415 - from qdk_chemistry.utils.tapering import taper_to_scbk # noqa: PLC0415 + from qdk_chemistry.data.tapering import taper_to_scbk # noqa: PLC0415 hamiltonian = create_nontrivial_test_hamiltonian() n = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] @@ -1155,7 +1173,7 @@ def test_scbk_non_power_of_two(self) -> None: # Exact: project JW Hamiltonian onto all matching-parity sectors jw_mapping = MajoranaMapping.jordan_wigner(n) - H_jw = mapper.run(hamiltonian, jw_mapping).to_matrix() + H_jw = mapper.run(hamiltonian, jw_mapping).to_matrix() # noqa: N806 all_eigs: list[float] = [] for na in range(n_spatial + 1): for nb in range(n_spatial + 1): @@ -1203,15 +1221,12 @@ def test_bk_tree_instantiation(self) -> None: assert mapping.num_qubits == n def test_bk_tree_clifford_algebra(self) -> None: - """BK-tree Majorana operators satisfy {γ_i, γ_j} = 2δ_{ij}.""" + """BK-tree Majorana operators satisfy anticommutation.""" from qdk_chemistry.utils.pauli_matrix import pauli_to_sparse_matrix # noqa: PLC0415 for n in (4, 6, 8): mapping = MajoranaMapping.bravyi_kitaev_tree(n) - gammas = [ - pauli_to_sparse_matrix([mapping.table[k]], np.array([1.0])).toarray() - for k in range(2 * n) - ] + gammas = [pauli_to_sparse_matrix([mapping.table[k]], np.array([1.0])).toarray() for k in range(2 * n)] for i in range(2 * n): for j in range(i, 2 * n): anticomm = gammas[i] @ gammas[j] + gammas[j] @ gammas[i] @@ -1237,11 +1252,11 @@ def test_bk_tree_z2_symmetries(self) -> None: n = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] mapper = create("qubit_mapper", "qdk") - H = mapper.run(hamiltonian, MajoranaMapping.bravyi_kitaev_tree(n)).to_matrix() + H = mapper.run(hamiltonian, MajoranaMapping.bravyi_kitaev_tree(n)).to_matrix() # noqa: N806 # Build Z_{n-1} and Z_{n/2-1} operators for q in (n // 2 - 1, n - 1): - Z_q = np.eye(2**n, dtype=complex) + Z_q = np.eye(2**n, dtype=complex) # noqa: N806 for i in range(2**n): if (i >> q) & 1: Z_q[i, i] = -1.0 diff --git a/python/tests/test_qubit_hamiltonian.py b/python/tests/test_qubit_hamiltonian.py index 31777890a..0f1dde01e 100644 --- a/python/tests/test_qubit_hamiltonian.py +++ b/python/tests/test_qubit_hamiltonian.py @@ -670,10 +670,10 @@ def test_rmul_equals_mul(self): class TestTaperingPropagation: """Verify tapering metadata survives arithmetic and reordering.""" - @pytest.fixture() + @pytest.fixture def tapering(self): """Create a sample tapering specification.""" - from qdk_chemistry.data.tapering import TaperingSpecification + from qdk_chemistry.data.tapering import TaperingSpecification # noqa: PLC0415 return TaperingSpecification( qubit_indices=(3, 1), @@ -682,7 +682,7 @@ def tapering(self): source_encoding="bravyi-kitaev-tree", ) - @pytest.fixture() + @pytest.fixture def tapered_h(self, tapering): """Create a QubitHamiltonian with tapering metadata.""" return QubitHamiltonian( @@ -705,7 +705,7 @@ def test_add_preserves_tapering(self, tapered_h, tapering): def test_add_mismatched_tapering_raises(self, tapered_h): """H1 + H2 with different tapering should raise ValueError.""" - from qdk_chemistry.data.tapering import TaperingSpecification + from qdk_chemistry.data.tapering import TaperingSpecification # noqa: PLC0415 other_tapering = TaperingSpecification( qubit_indices=(3, 1), diff --git a/python/tests/test_tapering.py b/python/tests/test_tapering.py index fab897c80..f4fc83064 100644 --- a/python/tests/test_tapering.py +++ b/python/tests/test_tapering.py @@ -11,8 +11,12 @@ import pytest from qdk_chemistry.data import QubitHamiltonian, Symmetries -from qdk_chemistry.data.tapering import TaperingSpecification -from qdk_chemistry.utils.tapering import taper_qubits, taper_to_scbk +from qdk_chemistry.data.tapering import TaperingSpecification, taper_qubits, taper_to_scbk + +from .reference_tolerances import ( + float_comparison_absolute_tolerance, + orthonormality_error_tolerance, +) # ------------------------------------------------------------------------------------- # taper_qubits — core tests @@ -158,3 +162,37 @@ def test_output_tapering_eigenvalues(self) -> None: # n_alpha=1 (odd) → ev_alpha=-1, n_total=1 (odd) → ev_total=-1 assert result.tapering is not None assert result.tapering.eigenvalues == (-1, -1) + + def test_tapered_eigenvalues_subset_of_full(self) -> None: + """Tapered Hamiltonian eigenvalues are a subset of the full BK eigenvalues.""" + base_strings = ["IIII", "IIIZ", "IIZZ", "IZII", "ZZZI"] + base_coeffs = [2.0, -0.5, -0.5, -0.5, -0.5] + + qh_clean = QubitHamiltonian( + pauli_strings=base_strings, + coefficients=np.array(base_coeffs), + encoding="bravyi-kitaev", + ) + + qh_with_xy = QubitHamiltonian( + pauli_strings=[*base_strings, "IIXI", "IIYI", "XIIX"], + coefficients=np.array([*base_coeffs, 0.3, 0.3, 0.2]), + encoding="bravyi-kitaev", + ) + + symmetries = Symmetries(n_alpha=1, n_beta=1) + result_clean = taper_to_scbk(qh_clean, symmetries) + result_with_xy = taper_to_scbk(qh_with_xy, symmetries) + + # X/Y terms on tapered qubits must be dropped — results should be identical + assert result_with_xy.num_qubits == 2 + assert result_clean.pauli_strings == result_with_xy.pauli_strings + np.testing.assert_allclose( + result_clean.coefficients, result_with_xy.coefficients, atol=float_comparison_absolute_tolerance + ) + + # Also verify eigenvalue subset for the symmetry-preserving Hamiltonian + full_eigs = np.linalg.eigvalsh(qh_clean.to_matrix()) + tapered_eigs = np.linalg.eigvalsh(result_clean.to_matrix()) + for e in tapered_eigs: + assert np.any(np.isclose(full_eigs, e, atol=orthonormality_error_tolerance)) From fc5945e778f8aacabdd78b59671f5a5ddaa906d9 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Sun, 31 May 2026 03:12:36 +0000 Subject: [PATCH 102/117] fixed raises test regex --- python/src/qdk_chemistry/data/majorana_mapping.py | 12 ++++++------ python/tests/test_majorana_mapping.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py index 8c03bf49b..b58ab1e9e 100644 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ b/python/src/qdk_chemistry/data/majorana_mapping.py @@ -149,7 +149,7 @@ def without_tapering(self) -> MajoranaMapping: A new :class:`MajoranaMapping` with no tapering. """ - return self._from_core(self._core) + return MajoranaMapping(table=[], _core=self._core) @property def base_encoding(self) -> str: @@ -350,7 +350,7 @@ def from_bilinears( Args: num_modes (int): Number of fermionic modes (spin-orbitals). Must be > 0. - bilinears (dict[tuple[int, int], tuple[complex, str]]): Upper-triangle bilinear images. + bilinears (dict[tuple[int, int], tuple[complex, str]]): Bilinear images ``{(j, k): (coeff, pauli_str)}``. name (str): Optional human-readable label. Default ``""``. Returns: @@ -360,16 +360,16 @@ def from_bilinears( ValueError: If sizes are inconsistent or num_modes is zero. """ - M = 2 * num_modes # noqa: N806 - expected = M * (M - 1) // 2 + num_majoranas = 2 * num_modes + expected = num_majoranas * (num_majoranas - 1) // 2 if len(bilinears) != expected: raise ValueError( f"Expected {expected} upper-triangle bilinear entries for {num_modes} modes, got {len(bilinears)}" ) # Build upper-triangle flat list in row-major order upper_triangle: list[tuple[complex, SparsePauliWord]] = [] - for j in range(M): - for k in range(j + 1, M): + for j in range(num_majoranas): + for k in range(j + 1, num_majoranas): if (j, k) not in bilinears: raise ValueError(f"Missing bilinear entry for ({j}, {k})") coeff, label = bilinears[(j, k)] diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index f3233654c..4afc2b0e2 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -206,7 +206,7 @@ def test_wrong_count_raises(self) -> None: def test_missing_entry_raises(self) -> None: """from_bilinears raises ValueError if a required (j,k) pair is missing.""" - with pytest.raises(ValueError, match="Missing bilinear"): + with pytest.raises(ValueError, match="upper-triangle bilinear entries"): MajoranaMapping.from_bilinears( num_modes=2, bilinears={ From 7a76a38e50a5529f8a688c9b02f3ba3983633433 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 03:50:36 +0000 Subject: [PATCH 103/117] fix: consolidate two-line RST definition list term in QubitMapper docstring --- .../src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 916371c89..6a6d13c4b 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -50,8 +50,7 @@ class QubitMapper(Algorithm): mapping engine. Any valid table works, including custom encodings that have no name. - **Name-dispatched backends** (e.g. ``OpenFermionQubitMapper``, - ``QiskitQubitMapper``) + **Name-dispatched backends** (e.g. ``OpenFermionQubitMapper``, ``QiskitQubitMapper``) **Ignore** ``mapping.table`` entirely. Instead they read ``mapping.base_encoding`` (a string like ``"jordan-wigner"`` or ``"bravyi-kitaev-tree"``) and use it to look up the corresponding From 944d7d596ea58a2e83a7bb8d013c1214cc8dafa1 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Sun, 31 May 2026 04:50:42 +0000 Subject: [PATCH 104/117] fixing docstring --- docs/source/conf.py | 4 ++ .../qubit_mapper/qdk_qubit_mapper.py | 2 +- .../algorithms/qubit_mapper/qubit_mapper.py | 62 +++++++++---------- python/src/qdk_chemistry/data/__init__.py | 2 - .../plugins/openfermion/qubit_mapper.py | 10 +-- .../plugins/qiskit/qubit_mapper.py | 2 +- 6 files changed, 39 insertions(+), 43 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 92222cab2..a4d4614b6 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -203,6 +203,10 @@ (r"py:class", r"cirq.*"), (r"py:class", r"qdk_chemistry._core.data.DataClass"), (r"py:class", r"qdk_chemistry._core\.data\.PauliOperatorExpression"), + (r"py:class", r"qdk_chemistry._core\.data\.MajoranaMapping"), + (r"py:class", r"_CoreMajoranaMapping"), + (r"py:class", r"^TaperingSpecification$"), + (r"py:class", r"qdk_chemistry\.data\.tapering\.TaperingSpecification"), (r"py:class", r"qdk::chemistry::data::SumPauliOperatorExpression"), (r"py:class", r"qdk::chemistry::algorithms::HamiltonianConstructor"), (r"py:class", r"^SumPauliOperatorExpression$"), diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index 17656b96d..0e8972a64 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -2,7 +2,7 @@ This module provides the QdkQubitMapper class for transforming electronic structure Hamiltonians to qubit Hamiltonians. The encoding is specified by a -:class:`~qdk_chemistry.data.MajoranaMapping` passed to :meth:`run`, making the +:class:`~qdk_chemistry.data.MajoranaMapping` passed to ``run()``, making the mapper encoding-agnostic. """ diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 916371c89..2dc291e59 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -26,7 +26,7 @@ class QubitMapperSettings(Settings): Settings are variant-specific (thresholds, etc.). The encoding is determined by the :class:`~qdk_chemistry.data.MajoranaMapping` passed - to :meth:`~QubitMapper.run`. + to :meth:`~qdk_chemistry.algorithms.QubitMapper.run`. """ @@ -44,41 +44,35 @@ class QubitMapper(Algorithm): and they use the :class:`~qdk_chemistry.data.MajoranaMapping` argument in different ways: - **Table-driven backends** (e.g. :class:`QdkQubitMapper`) - Read ``mapping.table`` — the actual 2N Pauli strings that define - the Majorana-to-qubit encoding — and use them directly in the - mapping engine. Any valid table works, including custom encodings - that have no name. - - **Name-dispatched backends** (e.g. ``OpenFermionQubitMapper``, - ``QiskitQubitMapper``) - **Ignore** ``mapping.table`` entirely. Instead they read - ``mapping.base_encoding`` (a string like ``"jordan-wigner"`` or - ``"bravyi-kitaev-tree"``) and use it to look up the corresponding - transform function in their own library. The backend then builds - a qubit operator from scratch using its own independent - fermion-to-qubit pipeline. - - This means: - - * The ``MajoranaMapping`` serves only as an **encoding selector** - for name-dispatched backends — its Pauli table is not consulted. - * Consistency between the table and the name is **not verified at - runtime**. If a ``MajoranaMapping`` is constructed with a table - that does not match its ``base_encoding`` name (e.g. a BK table - labelled ``"jordan-wigner"``), a name-dispatched backend will - silently use the wrong transform. - * Factory-produced mappings (``MajoranaMapping.jordan_wigner()``, - ``.bravyi_kitaev()``, etc.) are guaranteed to be consistent. - Cross-backend eigenvalue tests in the test suite verify this for - every supported factory x backend combination. - * Custom or manually constructed mappings with non-standard names - cannot be used with name-dispatched backends. + * **Table-driven backends** (e.g. :class:`QdkQubitMapper`) read + ``mapping.table`` — the actual 2N Pauli strings that define the + Majorana-to-qubit encoding — and use them directly in the mapping + engine. Any valid table works, including custom encodings that + have no name. + * **Name-dispatched backends** (e.g. ``OpenFermionQubitMapper``, + ``QiskitQubitMapper``) **ignore** ``mapping.table`` entirely. + Instead they read ``mapping.base_encoding`` (a string like + ``"jordan-wigner"`` or ``"bravyi-kitaev-tree"``) and use it to + look up the corresponding transform function in their own library. + The backend then builds a qubit operator from scratch using its + own independent fermion-to-qubit pipeline. + + For name-dispatched backends, the ``MajoranaMapping`` serves only as an + encoding selector — its Pauli table is not consulted. Consistency + between the table and the name is not verified at runtime. If a + ``MajoranaMapping`` is constructed with a table that does not match its + ``base_encoding`` name, a name-dispatched backend will silently use the + wrong transform. Factory-produced mappings + (``MajoranaMapping.jordan_wigner()``, ``.bravyi_kitaev()``, etc.) are + guaranteed to be consistent. Cross-backend eigenvalue tests in the + test suite verify this for every supported factory x backend combination. + Custom or manually constructed mappings with non-standard names cannot + be used with name-dispatched backends. .. rubric:: Tapering Each backend is responsible for handling tapering in its own - ``_run_impl()``. The static helper :meth:`_taper_result` provides + ``_run_impl()``. The static helper ``_taper_result`` provides the common taper-then-relabel logic so backends don't have to reimplement it, but backends are free to handle tapering however they choose. All shipped backends (QDK, OpenFermion, Qiskit) use the @@ -101,7 +95,7 @@ def run( ) -> QubitHamiltonian: """Map a fermionic Hamiltonian to a qubit Hamiltonian. - Delegates entirely to :meth:`_run_impl`. Each backend is + Delegates entirely to ``_run_impl``. Each backend is responsible for handling tapering (if ``mapping.tapering`` is set). Args: @@ -161,7 +155,7 @@ def _run_impl( Implementations receive the **full** mapping, which may include tapering. Each backend is responsible for handling tapering — typically by stripping it via ``mapping.without_tapering()``, - performing the base mapping, and calling :meth:`_taper_result` + performing the base mapping, and calling ``_taper_result`` to apply tapering to the output. .. important:: diff --git a/python/src/qdk_chemistry/data/__init__.py b/python/src/qdk_chemistry/data/__init__.py index 49b080adb..f40aa974c 100644 --- a/python/src/qdk_chemistry/data/__init__.py +++ b/python/src/qdk_chemistry/data/__init__.py @@ -118,7 +118,6 @@ from qdk_chemistry.data.qpe_result import QpeResult from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian from qdk_chemistry.data.symmetries import Symmetries -from qdk_chemistry.data.tapering import TaperingSpecification from qdk_chemistry.data.term_partition import FlatPartition, LayeredPartition, TermPartition from qdk_chemistry.data.time_dependent_qubit_hamiltonian.base import TimeDependentQubitHamiltonian from qdk_chemistry.data.time_dependent_qubit_hamiltonian.containers.base import TimeDependentQubitHamiltonianContainer @@ -189,7 +188,6 @@ "StabilityResult", "Structure", "Symmetries", - "TaperingSpecification", "TermPartition", "TimeDependentQubitHamiltonian", "TimeDependentQubitHamiltonianContainer", diff --git a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py index 0a04cf54c..21ff7269b 100644 --- a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py @@ -2,7 +2,7 @@ This module provides an OpenFermionQubitMapper class to convert Hamiltonians to QubitHamiltonians using different mapping strategies. The encoding is determined by the MajoranaMapping passed -to :meth:`run`. +to ``run()``. """ # -------------------------------------------------------------------------------------------- @@ -71,7 +71,7 @@ class OpenFermionQubitMapper(QubitMapper): Tapering-based encodings (e.g. symmetry-conserving Bravyi-Kitaev) are supported — each backend handles tapering in its own ``_run_impl()`` - via the :meth:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapper._taper_result` + via the ``QubitMapper._taper_result()`` helper. Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. @@ -79,9 +79,9 @@ class OpenFermionQubitMapper(QubitMapper): via ``hamiltonian_to_interaction_operator``. Supported base encodings: - - ``"jordan-wigner"`` → :func:`openfermion.transforms.jordan_wigner` - - ``"bravyi-kitaev"`` → :func:`openfermion.transforms.bravyi_kitaev` - - ``"bravyi-kitaev-tree"`` → :func:`openfermion.transforms.bravyi_kitaev_tree` + - ``"jordan-wigner"`` → ``openfermion.transforms.jordan_wigner`` + - ``"bravyi-kitaev"`` → ``openfermion.transforms.bravyi_kitaev`` + - ``"bravyi-kitaev-tree"`` → ``openfermion.transforms.bravyi_kitaev_tree`` Examples: >>> from qdk_chemistry.algorithms import create diff --git a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py index 8363efb4b..eb1d998f0 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py @@ -67,7 +67,7 @@ class QiskitQubitMapper(QubitMapper): Tapering-based encodings (e.g. parity two-qubit reduction) are supported — each backend handles tapering in its own ``_run_impl()`` - via the :meth:`~qdk_chemistry.algorithms.qubit_mapper.QubitMapper._taper_result` + via the ``QubitMapper._taper_result()`` helper. Both restricted (RHF) and unrestricted (UHF) Hamiltonians are supported. From bccaffa1cd89f488ddfa8921bd81b86cf4ad8ae9 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Sun, 31 May 2026 05:03:38 +0000 Subject: [PATCH 105/117] fixed pre commit --- .../algorithms/qubit_mapper/qubit_mapper.py | 130 +++++++++--------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 60e3e9b2d..954ec62fc 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -38,71 +38,71 @@ def __init__(self) -> None: class QubitMapper(Algorithm): """Abstract base class for mapping a Hamiltonian to a QubitHamiltonian. - .. rubric:: Backend dispatch contract - - There are two fundamentally different kinds of ``QubitMapper`` backend, - and they use the :class:`~qdk_chemistry.data.MajoranaMapping` argument - in different ways: - - * **Table-driven backends** (e.g. :class:`QdkQubitMapper`) read - ``mapping.table`` — the actual 2N Pauli strings that define the - Majorana-to-qubit encoding — and use them directly in the mapping - engine. Any valid table works, including custom encodings that - have no name. - * **Name-dispatched backends** (e.g. ``OpenFermionQubitMapper``, - ``QiskitQubitMapper``) **ignore** ``mapping.table`` entirely. - Instead they read ``mapping.base_encoding`` (a string like - ``"jordan-wigner"`` or ``"bravyi-kitaev-tree"``) and use it to - look up the corresponding transform function in their own library. - The backend then builds a qubit operator from scratch using its - own independent fermion-to-qubit pipeline. - -<<<<<<< HEAD - For name-dispatched backends, the ``MajoranaMapping`` serves only as an - encoding selector — its Pauli table is not consulted. Consistency - between the table and the name is not verified at runtime. If a - ``MajoranaMapping`` is constructed with a table that does not match its - ``base_encoding`` name, a name-dispatched backend will silently use the - wrong transform. Factory-produced mappings - (``MajoranaMapping.jordan_wigner()``, ``.bravyi_kitaev()``, etc.) are - guaranteed to be consistent. Cross-backend eigenvalue tests in the - test suite verify this for every supported factory x backend combination. - Custom or manually constructed mappings with non-standard names cannot - be used with name-dispatched backends. -======= - **Name-dispatched backends** (e.g. ``OpenFermionQubitMapper``, ``QiskitQubitMapper``) - **Ignore** ``mapping.table`` entirely. Instead they read - ``mapping.base_encoding`` (a string like ``"jordan-wigner"`` or - ``"bravyi-kitaev-tree"``) and use it to look up the corresponding - transform function in their own library. The backend then builds - a qubit operator from scratch using its own independent - fermion-to-qubit pipeline. - - This means: - - * The ``MajoranaMapping`` serves only as an **encoding selector** - for name-dispatched backends — its Pauli table is not consulted. - * Consistency between the table and the name is **not verified at - runtime**. If a ``MajoranaMapping`` is constructed with a table - that does not match its ``base_encoding`` name (e.g. a BK table - labelled ``"jordan-wigner"``), a name-dispatched backend will - silently use the wrong transform. - * Factory-produced mappings (``MajoranaMapping.jordan_wigner()``, - ``.bravyi_kitaev()``, etc.) are guaranteed to be consistent. - Cross-backend eigenvalue tests in the test suite verify this for - every supported factory x backend combination. - * Custom or manually constructed mappings with non-standard names - cannot be used with name-dispatched backends. ->>>>>>> origin/session/majorana-qubit-operators - - .. rubric:: Tapering - - Each backend is responsible for handling tapering in its own - ``_run_impl()``. The static helper ``_taper_result`` provides - the common taper-then-relabel logic so backends don't have to - reimplement it, but backends are free to handle tapering however they - choose. All shipped backends (QDK, OpenFermion, Qiskit) use the - helper. + .. rubric:: Backend dispatch contract + + There are two fundamentally different kinds of ``QubitMapper`` backend, + and they use the :class:`~qdk_chemistry.data.MajoranaMapping` argument + in different ways: + + * **Table-driven backends** (e.g. :class:`QdkQubitMapper`) read + ``mapping.table`` — the actual 2N Pauli strings that define the + Majorana-to-qubit encoding — and use them directly in the mapping + engine. Any valid table works, including custom encodings that + have no name. + * **Name-dispatched backends** (e.g. ``OpenFermionQubitMapper``, + ``QiskitQubitMapper``) **ignore** ``mapping.table`` entirely. + Instead they read ``mapping.base_encoding`` (a string like + ``"jordan-wigner"`` or ``"bravyi-kitaev-tree"``) and use it to + look up the corresponding transform function in their own library. + The backend then builds a qubit operator from scratch using its + own independent fermion-to-qubit pipeline. + + <<<<<<< HEAD + For name-dispatched backends, the ``MajoranaMapping`` serves only as an + encoding selector — its Pauli table is not consulted. Consistency + between the table and the name is not verified at runtime. If a + ``MajoranaMapping`` is constructed with a table that does not match its + ``base_encoding`` name, a name-dispatched backend will silently use the + wrong transform. Factory-produced mappings + (``MajoranaMapping.jordan_wigner()``, ``.bravyi_kitaev()``, etc.) are + guaranteed to be consistent. Cross-backend eigenvalue tests in the + test suite verify this for every supported factory x backend combination. + Custom or manually constructed mappings with non-standard names cannot + be used with name-dispatched backends. + ======= + **Name-dispatched backends** (e.g. ``OpenFermionQubitMapper``, ``QiskitQubitMapper``) + **Ignore** ``mapping.table`` entirely. Instead they read + ``mapping.base_encoding`` (a string like ``"jordan-wigner"`` or + ``"bravyi-kitaev-tree"``) and use it to look up the corresponding + transform function in their own library. The backend then builds + a qubit operator from scratch using its own independent + fermion-to-qubit pipeline. + + This means: + + * The ``MajoranaMapping`` serves only as an **encoding selector** + for name-dispatched backends — its Pauli table is not consulted. + * Consistency between the table and the name is **not verified at + runtime**. If a ``MajoranaMapping`` is constructed with a table + that does not match its ``base_encoding`` name (e.g. a BK table + labelled ``"jordan-wigner"``), a name-dispatched backend will + silently use the wrong transform. + * Factory-produced mappings (``MajoranaMapping.jordan_wigner()``, + ``.bravyi_kitaev()``, etc.) are guaranteed to be consistent. + Cross-backend eigenvalue tests in the test suite verify this for + every supported factory x backend combination. + * Custom or manually constructed mappings with non-standard names + cannot be used with name-dispatched backends. + >>>>>>> origin/session/majorana-qubit-operators + + .. rubric:: Tapering + + Each backend is responsible for handling tapering in its own + ``_run_impl()``. The static helper ``_taper_result`` provides + the common taper-then-relabel logic so backends don't have to + reimplement it, but backends are free to handle tapering however they + choose. All shipped backends (QDK, OpenFermion, Qiskit) use the + helper. """ From 7d2abd6fd1ba9aeedcd61b4e07b76e4bcbc1d0f7 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Sun, 31 May 2026 05:10:06 +0000 Subject: [PATCH 106/117] fix: resolve merge conflict markers in qubit_mapper.py --- .../algorithms/qubit_mapper/qubit_mapper.py | 30 ++----------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 954ec62fc..490b2c605 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -38,7 +38,7 @@ def __init__(self) -> None: class QubitMapper(Algorithm): """Abstract base class for mapping a Hamiltonian to a QubitHamiltonian. - .. rubric:: Backend dispatch contract + .. rubric:: Backend dispatch contract There are two fundamentally different kinds of ``QubitMapper`` backend, and they use the :class:`~qdk_chemistry.data.MajoranaMapping` argument @@ -57,7 +57,8 @@ class QubitMapper(Algorithm): The backend then builds a qubit operator from scratch using its own independent fermion-to-qubit pipeline. - <<<<<<< HEAD + .. rubric:: Name-dispatched backends + For name-dispatched backends, the ``MajoranaMapping`` serves only as an encoding selector — its Pauli table is not consulted. Consistency between the table and the name is not verified at runtime. If a @@ -69,31 +70,6 @@ class QubitMapper(Algorithm): test suite verify this for every supported factory x backend combination. Custom or manually constructed mappings with non-standard names cannot be used with name-dispatched backends. - ======= - **Name-dispatched backends** (e.g. ``OpenFermionQubitMapper``, ``QiskitQubitMapper``) - **Ignore** ``mapping.table`` entirely. Instead they read - ``mapping.base_encoding`` (a string like ``"jordan-wigner"`` or - ``"bravyi-kitaev-tree"``) and use it to look up the corresponding - transform function in their own library. The backend then builds - a qubit operator from scratch using its own independent - fermion-to-qubit pipeline. - - This means: - - * The ``MajoranaMapping`` serves only as an **encoding selector** - for name-dispatched backends — its Pauli table is not consulted. - * Consistency between the table and the name is **not verified at - runtime**. If a ``MajoranaMapping`` is constructed with a table - that does not match its ``base_encoding`` name (e.g. a BK table - labelled ``"jordan-wigner"``), a name-dispatched backend will - silently use the wrong transform. - * Factory-produced mappings (``MajoranaMapping.jordan_wigner()``, - ``.bravyi_kitaev()``, etc.) are guaranteed to be consistent. - Cross-backend eigenvalue tests in the test suite verify this for - every supported factory x backend combination. - * Custom or manually constructed mappings with non-standard names - cannot be used with name-dispatched backends. - >>>>>>> origin/session/majorana-qubit-operators .. rubric:: Tapering From a2a50bab171e1ecb1d2c7460074b7fd428d931f5 Mon Sep 17 00:00:00 2001 From: Agam Shayit Date: Sun, 31 May 2026 05:16:22 +0000 Subject: [PATCH 107/117] fixing docstring --- .../algorithms/qubit_mapper/qubit_mapper.py | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 490b2c605..1fb96481e 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -40,45 +40,45 @@ class QubitMapper(Algorithm): .. rubric:: Backend dispatch contract - There are two fundamentally different kinds of ``QubitMapper`` backend, - and they use the :class:`~qdk_chemistry.data.MajoranaMapping` argument - in different ways: - - * **Table-driven backends** (e.g. :class:`QdkQubitMapper`) read - ``mapping.table`` — the actual 2N Pauli strings that define the - Majorana-to-qubit encoding — and use them directly in the mapping - engine. Any valid table works, including custom encodings that - have no name. - * **Name-dispatched backends** (e.g. ``OpenFermionQubitMapper``, - ``QiskitQubitMapper``) **ignore** ``mapping.table`` entirely. - Instead they read ``mapping.base_encoding`` (a string like - ``"jordan-wigner"`` or ``"bravyi-kitaev-tree"``) and use it to - look up the corresponding transform function in their own library. - The backend then builds a qubit operator from scratch using its - own independent fermion-to-qubit pipeline. + There are two fundamentally different kinds of ``QubitMapper`` backend, + and they use the :class:`~qdk_chemistry.data.MajoranaMapping` argument + in different ways: + + * **Table-driven backends** (e.g. :class:`QdkQubitMapper`) read + ``mapping.table`` — the actual 2N Pauli strings that define the + Majorana-to-qubit encoding — and use them directly in the mapping + engine. Any valid table works, including custom encodings that + have no name. + * **Name-dispatched backends** (e.g. ``OpenFermionQubitMapper``, + ``QiskitQubitMapper``) **ignore** ``mapping.table`` entirely. + Instead they read ``mapping.base_encoding`` (a string like + ``"jordan-wigner"`` or ``"bravyi-kitaev-tree"``) and use it to + look up the corresponding transform function in their own library. + The backend then builds a qubit operator from scratch using its + own independent fermion-to-qubit pipeline. .. rubric:: Name-dispatched backends - For name-dispatched backends, the ``MajoranaMapping`` serves only as an - encoding selector — its Pauli table is not consulted. Consistency - between the table and the name is not verified at runtime. If a - ``MajoranaMapping`` is constructed with a table that does not match its - ``base_encoding`` name, a name-dispatched backend will silently use the - wrong transform. Factory-produced mappings - (``MajoranaMapping.jordan_wigner()``, ``.bravyi_kitaev()``, etc.) are - guaranteed to be consistent. Cross-backend eigenvalue tests in the - test suite verify this for every supported factory x backend combination. - Custom or manually constructed mappings with non-standard names cannot - be used with name-dispatched backends. - - .. rubric:: Tapering - - Each backend is responsible for handling tapering in its own - ``_run_impl()``. The static helper ``_taper_result`` provides - the common taper-then-relabel logic so backends don't have to - reimplement it, but backends are free to handle tapering however they - choose. All shipped backends (QDK, OpenFermion, Qiskit) use the - helper. + For name-dispatched backends, the ``MajoranaMapping`` serves only as an + encoding selector — its Pauli table is not consulted. Consistency + between the table and the name is not verified at runtime. If a + ``MajoranaMapping`` is constructed with a table that does not match its + ``base_encoding`` name, a name-dispatched backend will silently use the + wrong transform. Factory-produced mappings + (``MajoranaMapping.jordan_wigner()``, ``.bravyi_kitaev()``, etc.) are + guaranteed to be consistent. Cross-backend eigenvalue tests in the + test suite verify this for every supported factory x backend combination. + Custom or manually constructed mappings with non-standard names cannot + be used with name-dispatched backends. + + .. rubric:: Tapering + + Each backend is responsible for handling tapering in its own + ``_run_impl()``. The static helper ``_taper_result`` provides + the common taper-then-relabel logic so backends don't have to + reimplement it, but backends are free to handle tapering however they + choose. All shipped backends (QDK, OpenFermion, Qiskit) use the + helper. """ From 84a24dc01ffc486a5ce57d35c781205a267e8a25 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Sun, 31 May 2026 19:52:27 -0700 Subject: [PATCH 108/117] PR comments --- .../qdk/chemistry/data/majorana_mapping.hpp | 90 +++- .../qdk/chemistry/data/pauli_operator.hpp | 21 + cpp/include/qdk/chemistry/data/tapering.hpp | 75 +++ cpp/src/qdk/chemistry/data/CMakeLists.txt | 1 + .../qdk/chemistry/data/majorana_mapping.cpp | 139 ++++- .../data/majorana_mapping_factories.cpp | 23 + cpp/src/qdk/chemistry/data/pauli_operator.cpp | 43 ++ cpp/src/qdk/chemistry/data/tapering.cpp | 197 +++++++ cpp/tests/test_majorana_mapping.cpp | 94 ++++ .../comprehensive/algorithms/qubit_mapper.rst | 4 +- .../comprehensive/data/majorana_mapping.rst | 16 +- python/src/pybind11/data/majorana_mapping.cpp | 346 +++++++------ python/src/pybind11/data/pauli_operator.cpp | 8 + .../qubit_mapper/qdk_qubit_mapper.py | 14 +- .../algorithms/qubit_mapper/qubit_mapper.py | 99 +++- python/src/qdk_chemistry/data/__init__.py | 4 +- .../qdk_chemistry/data/majorana_mapping.py | 487 ------------------ .../qdk_chemistry/data/qubit_hamiltonian.py | 2 +- python/src/qdk_chemistry/data/tapering.py | 399 -------------- .../plugins/openfermion/qubit_mapper.py | 3 +- .../plugins/qiskit/qubit_mapper.py | 2 +- .../test_interop_openfermion_qubit_mapper.py | 8 +- python/tests/test_majorana_mapping.py | 204 +++----- python/tests/test_qdk_qubit_mapper.py | 7 +- python/tests/test_qubit_hamiltonian.py | 8 +- python/tests/test_tapering.py | 198 ------- 26 files changed, 1080 insertions(+), 1412 deletions(-) create mode 100644 cpp/include/qdk/chemistry/data/tapering.hpp create mode 100644 cpp/src/qdk/chemistry/data/tapering.cpp create mode 100644 cpp/tests/test_majorana_mapping.cpp delete mode 100644 python/src/qdk_chemistry/data/majorana_mapping.py delete mode 100644 python/src/qdk_chemistry/data/tapering.py delete mode 100644 python/tests/test_tapering.py diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index e467b2556..7c4559b6f 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -3,9 +3,15 @@ // license information. #pragma once +#include + #include #include +#include +#include +#include #include +#include #include #include #include @@ -19,7 +25,7 @@ namespace qdk::chemistry::data { * gamma_k; bilinear-only mappings (via from_bilinears) store the bilinear * images directly. bilinear(j, k) is available on both forms. */ -class MajoranaMapping { +class MajoranaMapping : public DataClass { public: /** * @brief Construct a Majorana-atomic mapping from a 2N-entry table. @@ -52,7 +58,7 @@ class MajoranaMapping { /// Number of fermionic modes. std::size_t num_modes() const { return num_modes_; } - /// Number of qubits (max qubit index + 1, or 0 if all entries are identity). + /// Number of qubits in the encoding table. std::size_t num_qubits() const { return num_qubits_; } /** @@ -86,6 +92,63 @@ class MajoranaMapping { /// Encoding name (may be empty for custom encodings). const std::string& name() const { return name_; } + /// Underlying table encoding name used by name-dispatched plugin backends. + const std::string& base_encoding() const { return base_encoding_; } + + /// Optional post-mapping tapering specification. + const std::optional& tapering() const { + return tapering_; + } + + /// Return a copy with tapering removed and the base encoding name restored. + MajoranaMapping without_tapering() const; + + /// @brief Get the data type name for serialization. + std::string get_data_type_name() const override { return "majorana_mapping"; } + + /// @brief Get a human-readable summary of the mapping. + std::string get_summary() const override; + + /** + * @brief Save to file in the specified format. + * @param filename Path to the output file. + * @param type Format type ("json", "hdf5", or "h5"). + */ + void to_file(const std::string& filename, + const std::string& type) const override; + + /// @brief Serialize to JSON. + nlohmann::json to_json() const override; + + /// @brief Deserialize from JSON. + static MajoranaMapping from_json(const nlohmann::json& data); + + /// @brief Save to a JSON file. + void to_json_file(const std::string& filename) const override; + + /// @brief Load from a JSON file. + static MajoranaMapping from_json_file(const std::string& filename); + + /// @brief Save to an HDF5 group. + void to_hdf5(H5::Group& group) const override; + + /// @brief Load from an HDF5 group. + static MajoranaMapping from_hdf5(H5::Group& group); + + /// @brief Save to an HDF5 file. + void to_hdf5_file(const std::string& filename) const override; + + /// @brief Load from an HDF5 file. + static MajoranaMapping from_hdf5_file(const std::string& filename); + + /** + * @brief Load from file in the specified format. + * @param filename Path to the input file. + * @param type Format type ("json", "hdf5", or "h5"). + */ + static MajoranaMapping from_file(const std::string& filename, + const std::string& type); + // --- Factory methods for standard encodings --- /// Jordan-Wigner encoding on num_modes qubits. @@ -94,17 +157,27 @@ class MajoranaMapping { /// Bravyi-Kitaev (Fenwick-tree) encoding on num_modes qubits. static MajoranaMapping bravyi_kitaev(std::size_t num_modes); - /// Balanced binary-tree Bravyi-Kitaev encoding (arXiv:1701.07072). + /// Balanced binary-tree Bravyi-Kitaev encoding. static MajoranaMapping bravyi_kitaev_tree(std::size_t num_modes); /// Parity encoding on num_modes qubits. static MajoranaMapping parity(std::size_t num_modes); + /// Parity encoding with two-qubit reduction metadata. + static MajoranaMapping parity(std::size_t num_modes, std::size_t n_alpha, + std::size_t n_beta); + + /// Symmetry-conserving Bravyi-Kitaev encoding with tapering metadata. + static MajoranaMapping symmetry_conserving_bravyi_kitaev( + std::size_t num_modes, std::size_t n_alpha, std::size_t n_beta); + private: MajoranaMapping( std::vector table, std::vector, SparsePauliWord>> bilinears, - std::string name, std::size_t num_modes, std::size_t num_qubits); + std::string name, std::size_t num_modes, std::size_t num_qubits, + std::string base_encoding, + std::optional tapering = std::nullopt); /// Majorana-to-Pauli table (empty for bilinear-only mappings). std::vector table_; @@ -115,6 +188,9 @@ class MajoranaMapping { /// Human-readable encoding name. std::string name_; + /// Base encoding name associated with the pre-taper Pauli table. + std::string base_encoding_; + /// Number of fermionic modes. std::size_t num_modes_; @@ -124,6 +200,9 @@ class MajoranaMapping { /// True for table-constructed mappings, false for bilinear-only. bool majorana_atomic_; + /// Optional tapering metadata for post-mapping qubit reduction. + std::optional tapering_; + /// Upper-triangle index: (j, k) with j < k, M = 2*num_modes. std::size_t bilinear_index(std::size_t j, std::size_t k) const { const std::size_t M = 2 * num_modes_; @@ -132,6 +211,9 @@ class MajoranaMapping { static std::size_t compute_num_qubits( const std::vector& table); + + /// Serialization schema version. + static constexpr const char* SERIALIZATION_VERSION = "0.1.0"; }; /** diff --git a/cpp/include/qdk/chemistry/data/pauli_operator.hpp b/cpp/include/qdk/chemistry/data/pauli_operator.hpp index aa5c5173a..9fcee929d 100644 --- a/cpp/include/qdk/chemistry/data/pauli_operator.hpp +++ b/cpp/include/qdk/chemistry/data/pauli_operator.hpp @@ -34,6 +34,27 @@ namespace qdk::chemistry::data { */ using SparsePauliWord = std::vector>; +/** + * @brief Convert a sparse Pauli word to a QubitHamiltonian label. + * + * The returned label uses the QubitHamiltonian convention: qubit 0 is the + * rightmost character. + */ +std::string sparse_pauli_word_to_label(const SparsePauliWord& word, + std::uint64_t num_qubits); + +/** + * @brief Convert a QubitHamiltonian label to a sparse Pauli word. + * + * The input label uses the QubitHamiltonian convention: qubit 0 is the + * rightmost character. Identity characters are omitted from the output. + * + * @param label Dense Pauli-string label (e.g. "XIZZ"). + * @return Sparse Pauli word sorted by qubit index. + * @throws std::invalid_argument If the label contains invalid characters. + */ +SparsePauliWord label_to_sparse_pauli_word(const std::string& label); + /** * @brief Hash function for SparsePauliWord. * diff --git a/cpp/include/qdk/chemistry/data/tapering.hpp b/cpp/include/qdk/chemistry/data/tapering.hpp new file mode 100644 index 000000000..eafdaac6c --- /dev/null +++ b/cpp/include/qdk/chemistry/data/tapering.hpp @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for +// license information. + +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace qdk::chemistry::data { + +class TaperingSpecification : public DataClass { + public: + TaperingSpecification(std::vector qubit_indices, + std::vector eigenvalues); + + const std::vector& qubit_indices() const { + return qubit_indices_; + } + + const std::vector& eigenvalues() const { return eigenvalues_; } + + std::size_t num_tapered() const { return qubit_indices_.size(); } + + static TaperingSpecification symmetry_conserving_bravyi_kitaev( + std::size_t num_modes, std::size_t n_alpha, std::size_t n_beta); + + static TaperingSpecification parity_two_qubit_reduction(std::size_t num_modes, + std::size_t n_alpha, + std::size_t n_beta); + + std::string get_data_type_name() const override { + return "tapering_specification"; + } + + std::string get_summary() const override; + + void to_file(const std::string& filename, + const std::string& type) const override; + + nlohmann::json to_json() const override; + + static TaperingSpecification from_json(const nlohmann::json& data); + + void to_json_file(const std::string& filename) const override; + + static TaperingSpecification from_json_file(const std::string& filename); + + void to_hdf5(H5::Group& group) const override; + + static TaperingSpecification from_hdf5(H5::Group& group); + + void to_hdf5_file(const std::string& filename) const override; + + static TaperingSpecification from_hdf5_file(const std::string& filename); + + static TaperingSpecification from_file(const std::string& filename, + const std::string& type); + + bool operator==(const TaperingSpecification& other) const; + + private: + std::vector qubit_indices_; + std::vector eigenvalues_; + + /// Serialization schema version. + static constexpr const char* SERIALIZATION_VERSION = "0.1.0"; +}; + +} // namespace qdk::chemistry::data diff --git a/cpp/src/qdk/chemistry/data/CMakeLists.txt b/cpp/src/qdk/chemistry/data/CMakeLists.txt index 3ed703e37..aa2a0a42a 100644 --- a/cpp/src/qdk/chemistry/data/CMakeLists.txt +++ b/cpp/src/qdk/chemistry/data/CMakeLists.txt @@ -19,6 +19,7 @@ target_sources(chemistry PRIVATE settings.cpp stability_result.cpp structure.cpp + tapering.cpp wavefunction.cpp wavefunction_containers/cas.cpp wavefunction_containers/cc.cpp diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp index 7b8ee9bc3..eb489c960 100644 --- a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp @@ -4,10 +4,15 @@ #include #include +#include +#include #include #include +#include +#include #include #include +#include #include namespace qdk::chemistry::data { @@ -17,13 +22,25 @@ namespace qdk::chemistry::data { MajoranaMapping::MajoranaMapping( std::vector table, std::vector, SparsePauliWord>> bilinears, - std::string name, std::size_t num_modes, std::size_t num_qubits) + std::string name, std::size_t num_modes, std::size_t num_qubits, + std::string base_encoding, std::optional tapering) : table_(std::move(table)), bilinears_(std::move(bilinears)), name_(std::move(name)), + base_encoding_(std::move(base_encoding)), num_modes_(num_modes), num_qubits_(num_qubits), - majorana_atomic_(!table_.empty()) {} + majorana_atomic_(!table_.empty()), + tapering_(std::move(tapering)) { + if (base_encoding_.empty()) { + base_encoding_ = name_; + } + if (tapering_ && tapering_->num_tapered() > num_qubits_) { + throw std::invalid_argument( + "MajoranaMapping tapering removes more qubits than the base mapping " + "contains"); + } +} MajoranaMapping MajoranaMapping::from_table(std::vector table, std::string name) { @@ -52,8 +69,10 @@ MajoranaMapping MajoranaMapping::from_table(std::vector table, } } + auto base_encoding = name; return MajoranaMapping(std::move(table), std::move(bilinears), - std::move(name), num_modes, num_qubits); + std::move(name), num_modes, num_qubits, + std::move(base_encoding)); } MajoranaMapping MajoranaMapping::from_bilinears( @@ -86,8 +105,9 @@ MajoranaMapping MajoranaMapping::from_bilinears( } } auto nq = has_any ? static_cast(max_idx + 1) : 0; + auto base_encoding = name; return MajoranaMapping({}, std::move(upper_triangle), std::move(name), - num_modes, nq); + num_modes, nq, std::move(base_encoding)); } const SparsePauliWord& MajoranaMapping::operator()(std::size_t k) const { @@ -127,6 +147,117 @@ MajoranaMapping::bilinear(std::size_t j, std::size_t k) const { return {-entry.first, entry.second}; } +MajoranaMapping MajoranaMapping::without_tapering() const { + return MajoranaMapping(table_, bilinears_, base_encoding_, num_modes_, + num_qubits_, base_encoding_); +} + +std::string MajoranaMapping::get_summary() const { + std::ostringstream ss; + ss << "MajoranaMapping"; + if (!name_.empty()) { + ss << " '" << name_ << "'"; + } + ss << "\n Modes: " << num_modes_ << "\n Qubits: " << num_qubits_; + if (tapering_) { + ss << "\n Tapered qubits: " << tapering_->num_tapered(); + } + return ss.str(); +} + +nlohmann::json MajoranaMapping::to_json() const { + nlohmann::json data{{"version", SERIALIZATION_VERSION}, + {"table", table_}, + {"name", name_}, + {"base_encoding", base_encoding_}}; + if (tapering_) { + data["tapering"] = tapering_->to_json(); + } + return data; +} + +void MajoranaMapping::to_file(const std::string& filename, + const std::string& type) const { + if (type == "json") { + to_json_file(filename); + } else if (type == "hdf5" || type == "h5") { + to_hdf5_file(filename); + } else { + throw std::invalid_argument("Unsupported format type: " + type); + } +} + +MajoranaMapping MajoranaMapping::from_json(const nlohmann::json& data) { + auto table = data.at("table").get>(); + std::string name = data.value("name", ""); + std::string base_encoding = data.value("base_encoding", name); + std::optional tapering = std::nullopt; + if (data.contains("tapering") && !data.at("tapering").is_null()) { + tapering = TaperingSpecification::from_json(data.at("tapering")); + } + + auto mapping = MajoranaMapping::from_table(std::move(table), base_encoding); + if (name == base_encoding && !tapering) { + return mapping; + } + return MajoranaMapping(mapping.table_, mapping.bilinears_, std::move(name), + mapping.num_modes_, mapping.num_qubits_, + std::move(base_encoding), std::move(tapering)); +} + +void MajoranaMapping::to_json_file(const std::string& filename) const { + std::ofstream file(filename); + if (!file) { + throw std::runtime_error("Unable to open file for writing: " + filename); + } + file << to_json().dump(2); +} + +MajoranaMapping MajoranaMapping::from_json_file(const std::string& filename) { + std::ifstream file(filename); + if (!file) { + throw std::runtime_error("Unable to open file for reading: " + filename); + } + nlohmann::json data; + file >> data; + return from_json(data); +} + +void MajoranaMapping::to_hdf5(H5::Group& group) const { + const std::string json = to_json().dump(); + group.createAttribute("json", H5::StrType(0, H5T_VARIABLE), H5::DataSpace()) + .write(H5::StrType(0, H5T_VARIABLE), json); +} + +MajoranaMapping MajoranaMapping::from_hdf5(H5::Group& group) { + std::string json; + group.openAttribute("json").read(H5::StrType(0, H5T_VARIABLE), json); + return from_json(nlohmann::json::parse(json)); +} + +void MajoranaMapping::to_hdf5_file(const std::string& filename) const { + H5::H5File file(filename, H5F_ACC_TRUNC); + H5::Group root = file.openGroup("/"); + to_hdf5(root); +} + +MajoranaMapping MajoranaMapping::from_hdf5_file(const std::string& filename) { + H5::H5File file(filename, H5F_ACC_RDONLY); + H5::Group root = file.openGroup("/"); + return from_hdf5(root); +} + +MajoranaMapping MajoranaMapping::from_file(const std::string& filename, + const std::string& type) { + if (type == "json") { + return from_json_file(filename); + } + if (type == "hdf5" || type == "h5") { + return from_hdf5_file(filename); + } + throw std::invalid_argument("Unsupported format type: " + type); +} + std::size_t MajoranaMapping::compute_num_qubits( const std::vector& table) { std::uint64_t max_idx = 0; diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping_factories.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping_factories.cpp index 5fc97ee79..0d3bcef22 100644 --- a/cpp/src/qdk/chemistry/data/majorana_mapping_factories.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_mapping_factories.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -310,4 +311,26 @@ MajoranaMapping MajoranaMapping::parity(std::size_t num_modes) { return MajoranaMapping::from_table(std::move(table), "parity"); } +MajoranaMapping MajoranaMapping::parity(std::size_t num_modes, + std::size_t n_alpha, + std::size_t n_beta) { + auto base = MajoranaMapping::parity(num_modes); + auto tapering = TaperingSpecification::parity_two_qubit_reduction( + num_modes, n_alpha, n_beta); + return MajoranaMapping(base.table_, base.bilinears_, "parity-2q-reduced", + base.num_modes_, base.num_qubits_, "parity", + std::move(tapering)); +} + +MajoranaMapping MajoranaMapping::symmetry_conserving_bravyi_kitaev( + std::size_t num_modes, std::size_t n_alpha, std::size_t n_beta) { + auto base = MajoranaMapping::bravyi_kitaev_tree(num_modes); + auto tapering = TaperingSpecification::symmetry_conserving_bravyi_kitaev( + num_modes, n_alpha, n_beta); + return MajoranaMapping(base.table_, base.bilinears_, + "symmetry-conserving-bravyi-kitaev", base.num_modes_, + base.num_qubits_, "bravyi-kitaev-tree", + std::move(tapering)); +} + } // namespace qdk::chemistry::data diff --git a/cpp/src/qdk/chemistry/data/pauli_operator.cpp b/cpp/src/qdk/chemistry/data/pauli_operator.cpp index 72f39d394..8e6188f6c 100644 --- a/cpp/src/qdk/chemistry/data/pauli_operator.cpp +++ b/cpp/src/qdk/chemistry/data/pauli_operator.cpp @@ -14,6 +14,49 @@ namespace qdk::chemistry::data { +std::string sparse_pauli_word_to_label(const SparsePauliWord& word, + std::uint64_t num_qubits) { + std::string label(num_qubits, 'I'); + for (const auto& [qubit, op_type] : word) { + if (qubit >= num_qubits) { + throw std::invalid_argument( + "Sparse Pauli word references qubit " + std::to_string(qubit) + + " outside the requested register size " + std::to_string(num_qubits)); + } + label[num_qubits - 1 - qubit] = PauliOperator(op_type, qubit).to_char(); + } + return label; +} + +SparsePauliWord label_to_sparse_pauli_word(const std::string& label) { + SparsePauliWord word; + auto n = static_cast(label.size()); + for (std::uint64_t i = 0; i < n; ++i) { + char c = label[i]; + std::uint8_t op; + switch (c) { + case 'I': + continue; + case 'X': + op = 1; + break; + case 'Y': + op = 2; + break; + case 'Z': + op = 3; + break; + default: + throw std::invalid_argument("Invalid Pauli character '" + + std::string(1, c) + + "'; expected I, X, Y, or Z"); + } + word.emplace_back(n - 1 - i, op); + } + std::sort(word.begin(), word.end()); + return word; +} + namespace detail { /** diff --git a/cpp/src/qdk/chemistry/data/tapering.cpp b/cpp/src/qdk/chemistry/data/tapering.cpp new file mode 100644 index 000000000..b21677307 --- /dev/null +++ b/cpp/src/qdk/chemistry/data/tapering.cpp @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for +// license information. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace qdk::chemistry::data { + +TaperingSpecification::TaperingSpecification( + std::vector qubit_indices, std::vector eigenvalues) + : qubit_indices_(std::move(qubit_indices)), + eigenvalues_(std::move(eigenvalues)) { + if (qubit_indices_.size() != eigenvalues_.size()) { + throw std::invalid_argument( + "qubit_indices length must match eigenvalues length"); + } + + auto sorted = qubit_indices_; + std::sort(sorted.begin(), sorted.end()); + if (std::adjacent_find(sorted.begin(), sorted.end()) != sorted.end()) { + throw std::invalid_argument("qubit_indices must not contain duplicates"); + } + + for (auto value : eigenvalues_) { + if (value != 1 && value != -1) { + throw std::invalid_argument("Eigenvalue must be +1 or -1, got " + + std::to_string(value)); + } + } +} + +TaperingSpecification TaperingSpecification::symmetry_conserving_bravyi_kitaev( + std::size_t num_modes, std::size_t n_alpha, std::size_t n_beta) { + if (num_modes < 4 || num_modes % 2 != 0) { + throw std::invalid_argument( + "Symmetry-conserving Bravyi-Kitaev requires an even num_modes >= 4, " + "got " + + std::to_string(num_modes)); + } + if (n_alpha > num_modes / 2) { + throw std::invalid_argument("n_alpha (" + std::to_string(n_alpha) + + ") exceeds spatial orbitals (" + + std::to_string(num_modes / 2) + ")"); + } + if (n_beta > num_modes / 2) { + throw std::invalid_argument("n_beta (" + std::to_string(n_beta) + + ") exceeds spatial orbitals (" + + std::to_string(num_modes / 2) + ")"); + } + + int ev_total = ((n_alpha + n_beta) % 2 == 0) ? 1 : -1; + int ev_alpha = (n_alpha % 2 == 0) ? 1 : -1; + + return TaperingSpecification({num_modes / 2 - 1, num_modes - 1}, + {ev_alpha, ev_total}); +} + +TaperingSpecification TaperingSpecification::parity_two_qubit_reduction( + std::size_t num_modes, std::size_t n_alpha, std::size_t n_beta) { + return symmetry_conserving_bravyi_kitaev(num_modes, n_alpha, n_beta); +} + +std::string TaperingSpecification::get_summary() const { + std::ostringstream ss; + ss << "TaperingSpecification\n" + << " Tapered qubits: " << qubit_indices_.size(); + return ss.str(); +} + +bool TaperingSpecification::operator==( + const TaperingSpecification& other) const { + return qubit_indices_ == other.qubit_indices_ && + eigenvalues_ == other.eigenvalues_; +} + +nlohmann::json TaperingSpecification::to_json() const { + return nlohmann::json{{"version", SERIALIZATION_VERSION}, + {"qubit_indices", qubit_indices_}, + {"eigenvalues", eigenvalues_}}; +} + +TaperingSpecification TaperingSpecification::from_json( + const nlohmann::json& data) { + if (!data.contains("qubit_indices") || !data.contains("eigenvalues")) { + throw std::runtime_error( + "TaperingSpecification JSON missing required field"); + } + return TaperingSpecification( + data.at("qubit_indices").get>(), + data.at("eigenvalues").get>()); +} + +void TaperingSpecification::to_json_file(const std::string& filename) const { + std::ofstream file(filename); + if (!file) { + throw std::runtime_error("Unable to open file for writing: " + filename); + } + file << to_json().dump(2); +} + +TaperingSpecification TaperingSpecification::from_json_file( + const std::string& filename) { + std::ifstream file(filename); + if (!file) { + throw std::runtime_error("Unable to open file for reading: " + filename); + } + nlohmann::json data; + file >> data; + return from_json(data); +} + +void TaperingSpecification::to_hdf5(H5::Group& group) const { + hsize_t index_dims[] = {qubit_indices_.size()}; + H5::DataSpace index_space(1, index_dims); + std::vector qubits(qubit_indices_.begin(), + qubit_indices_.end()); + group.createDataSet("qubit_indices", H5::PredType::NATIVE_ULLONG, index_space) + .write(qubits.data(), H5::PredType::NATIVE_ULLONG); + + hsize_t eigen_dims[] = {eigenvalues_.size()}; + H5::DataSpace eigen_space(1, eigen_dims); + group.createDataSet("eigenvalues", H5::PredType::NATIVE_INT, eigen_space) + .write(eigenvalues_.data(), H5::PredType::NATIVE_INT); + + group + .createAttribute("version", H5::StrType(0, H5T_VARIABLE), H5::DataSpace()) + .write(H5::StrType(0, H5T_VARIABLE), std::string(SERIALIZATION_VERSION)); +} + +TaperingSpecification TaperingSpecification::from_hdf5(H5::Group& group) { + auto qubit_dataset = group.openDataSet("qubit_indices"); + auto qubit_space = qubit_dataset.getSpace(); + hsize_t qubit_dims[1] = {0}; + qubit_space.getSimpleExtentDims(qubit_dims); + std::vector qubit_buffer(qubit_dims[0]); + qubit_dataset.read(qubit_buffer.data(), H5::PredType::NATIVE_ULLONG); + std::vector qubit_indices(qubit_buffer.begin(), + qubit_buffer.end()); + + auto eigen_dataset = group.openDataSet("eigenvalues"); + auto eigen_space = eigen_dataset.getSpace(); + hsize_t eigen_dims[1] = {0}; + eigen_space.getSimpleExtentDims(eigen_dims); + std::vector eigenvalues(eigen_dims[0]); + eigen_dataset.read(eigenvalues.data(), H5::PredType::NATIVE_INT); + + return TaperingSpecification(std::move(qubit_indices), + std::move(eigenvalues)); +} + +void TaperingSpecification::to_hdf5_file(const std::string& filename) const { + H5::H5File file(filename, H5F_ACC_TRUNC); + H5::Group root = file.openGroup("/"); + to_hdf5(root); +} + +TaperingSpecification TaperingSpecification::from_hdf5_file( + const std::string& filename) { + H5::H5File file(filename, H5F_ACC_RDONLY); + H5::Group root = file.openGroup("/"); + return from_hdf5(root); +} + +void TaperingSpecification::to_file(const std::string& filename, + const std::string& type) const { + if (type == "json") { + to_json_file(filename); + } else if (type == "hdf5" || type == "h5") { + to_hdf5_file(filename); + } else { + throw std::invalid_argument("Unsupported format type: " + type); + } +} + +TaperingSpecification TaperingSpecification::from_file( + const std::string& filename, const std::string& type) { + if (type == "json") { + return from_json_file(filename); + } + if (type == "hdf5" || type == "h5") { + return from_hdf5_file(filename); + } + throw std::invalid_argument("Unsupported format type: " + type); +} + +} // namespace qdk::chemistry::data diff --git a/cpp/tests/test_majorana_mapping.cpp b/cpp/tests/test_majorana_mapping.cpp new file mode 100644 index 000000000..65badda04 --- /dev/null +++ b/cpp/tests/test_majorana_mapping.cpp @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE.txt in the project root for +// license information. + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace qdk::chemistry::data; + +namespace { + +std::string sparse_to_dense_le(const SparsePauliWord& word, + std::size_t num_qubits) { + std::string label(num_qubits, 'I'); + for (const auto& [qubit, op] : word) { + char pauli = 'I'; + switch (op) { + case 1: + pauli = 'X'; + break; + case 2: + pauli = 'Y'; + break; + case 3: + pauli = 'Z'; + break; + default: + pauli = 'I'; + break; + } + label[num_qubits - 1 - qubit] = pauli; + } + return label; +} + +std::unordered_map> collect_terms( + const MajoranaMapResult& result, std::size_t num_qubits) { + std::unordered_map> terms; + for (std::size_t i = 0; i < result.words.size(); ++i) { + terms[sparse_to_dense_le(result.words[i], num_qubits)] += + result.coefficients[i]; + } + return terms; +} + +void expect_real_term( + const std::unordered_map>& terms, + const std::string& label, double expected) { + auto it = terms.find(label); + ASSERT_NE(it, terms.end()) << "Missing term " << label; + EXPECT_NEAR(it->second.real(), expected, 1e-12); + EXPECT_NEAR(it->second.imag(), 0.0, 1e-12); +} + +} // namespace + +TEST(MajoranaMapEngineTest, MapsOneBodyUnrestrictedJordanWignerHamiltonian) { + auto mapping = MajoranaMapping::jordan_wigner(2); + const double h1_alpha[1] = {1.0}; + const double h1_beta[1] = {2.0}; + const double eri_zero[1] = {0.0}; + + auto result = majorana_map_hamiltonian( + mapping, 0.5, h1_alpha, h1_beta, eri_zero, eri_zero, eri_zero, + /*n_spatial=*/1, /*spin_symmetric=*/false, /*threshold=*/1e-12, + /*integral_threshold=*/1e-12); + + auto terms = collect_terms(result, mapping.num_qubits()); + ASSERT_EQ(terms.size(), 3); + expect_real_term(terms, "II", 2.0); + expect_real_term(terms, "IZ", -0.5); + expect_real_term(terms, "ZI", -1.0); +} + +TEST(MajoranaMapEngineTest, RejectsZeroQubitMappings) { + std::vector, SparsePauliWord>> bilinears = { + {{1.0, 0.0}, {}}}; + auto mapping = MajoranaMapping::from_bilinears(1, std::move(bilinears)); + const double zero[1] = {0.0}; + + EXPECT_THROW( + majorana_map_hamiltonian(mapping, 0.0, zero, zero, zero, zero, zero, + /*n_spatial=*/1, /*spin_symmetric=*/false, + /*threshold=*/1e-12, + /*integral_threshold=*/1e-12), + std::invalid_argument); +} diff --git a/docs/source/user/comprehensive/algorithms/qubit_mapper.rst b/docs/source/user/comprehensive/algorithms/qubit_mapper.rst index d9146238f..11c9c65d1 100644 --- a/docs/source/user/comprehensive/algorithms/qubit_mapper.rst +++ b/docs/source/user/comprehensive/algorithms/qubit_mapper.rst @@ -134,8 +134,8 @@ You can discover available implementations programmatically: .. _backend-dispatch-contract: -Backend dispatch contract -~~~~~~~~~~~~~~~~~~~~~~~~~ +Details for extending implementations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Implementations fall into two categories that use the :class:`~qdk_chemistry.data.MajoranaMapping` argument differently: diff --git a/docs/source/user/comprehensive/data/majorana_mapping.rst b/docs/source/user/comprehensive/data/majorana_mapping.rst index d4b248882..a3871abc6 100644 --- a/docs/source/user/comprehensive/data/majorana_mapping.rst +++ b/docs/source/user/comprehensive/data/majorana_mapping.rst @@ -3,7 +3,7 @@ MajoranaMapping The :class:`~qdk_chemistry.data.MajoranaMapping` class defines a fermion-to-qubit encoding. It exposes the **bilinear** :math:`i\,\gamma_j\,\gamma_k` as the unified primitive available across every encoding, and (for Majorana-atomic encodings) individual Majorana operators :math:`\gamma_k` as an additional capability. -As a core :doc:`data class <../design/index>`, it follows QDK/Chemistry's data class pattern. +It follows QDK/Chemistry's data-container conventions for immutable fermion-to-qubit encoding data. Overview -------- @@ -16,6 +16,7 @@ Bilinears as the unified primitive Across fermion-to-qubit encodings, the most general primitive that admits a Pauli-string image is the **bilinear** :math:`i\,\gamma_j\,\gamma_k`. Bilinears generate the parity-even subalgebra of the Majorana Clifford algebra, so any parity-conserving operator decomposes into ordered bilinear products, and higher-degree even monomials are products of bilinears. +This Majorana-operator viewpoint follows the fermion-to-qubit encoding formalism described by `Bravyi and Kitaev `_. Individual Majorana operators :math:`\gamma_k` have a Pauli image only in **Majorana-atomic** encodings. In **bilinear-only** encodings (where :math:`m > n` qubits represent :math:`n` modes), single Majoranas have no representation in the physical subspace; only the bilinears are observable. @@ -85,20 +86,15 @@ See :ref:`encoding-parity` for a description of the encoding. Custom encodings ---------------- -A custom Majorana-atomic encoding can be defined by providing a Pauli-string table directly: +A custom Majorana-atomic encoding can be defined by providing a sparse Pauli-word table directly: .. code-block:: python from qdk_chemistry.data import MajoranaMapping - # Provide a list of Pauli strings, one per Majorana operator - mapping = MajoranaMapping(table=[...], name="my-custom-encoding") - -Alternatively, construct from mode pairs: - -.. code-block:: python - - mapping = MajoranaMapping.from_mode_pairs(...) + # Provide one sparse Pauli word per Majorana operator. + # Entries are (qubit_index, operator_code), with X=1, Y=2, Z=3. + mapping = MajoranaMapping.from_table([...], name="my-custom-encoding") Serialization ------------- diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index 85b7b48fc..119872dae 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -7,19 +7,60 @@ #include #include -#include +#include #include #include +#include #include #include +#include #include +#include "path_utils.hpp" + namespace py = pybind11; void bind_majorana_mapping(pybind11::module& data) { using namespace qdk::chemistry::data; - py::class_ mapping(data, "MajoranaMapping", R"( + py::class_ tapering( + data, "TaperingSpecification", R"( +Immutable specification for post-mapping qubit tapering. +)"); + + tapering + .def(py::init, std::vector>(), + py::arg("qubit_indices"), py::arg("eigenvalues")) + .def_property_readonly("qubit_indices", + &TaperingSpecification::qubit_indices) + .def_property_readonly("eigenvalues", &TaperingSpecification::eigenvalues) + .def_property_readonly("num_tapered", &TaperingSpecification::num_tapered) + .def_static( + "symmetry_conserving_bravyi_kitaev", + [](std::size_t num_modes, const py::object& symmetries) { + auto n_alpha = symmetries.attr("n_alpha").cast(); + auto n_beta = symmetries.attr("n_beta").cast(); + return TaperingSpecification::symmetry_conserving_bravyi_kitaev( + num_modes, n_alpha, n_beta); + }, + py::arg("num_modes"), py::arg("symmetries")) + .def_static( + "parity_two_qubit_reduction", + [](std::size_t num_modes, const py::object& symmetries) { + auto n_alpha = symmetries.attr("n_alpha").cast(); + auto n_beta = symmetries.attr("n_beta").cast(); + return TaperingSpecification::parity_two_qubit_reduction( + num_modes, n_alpha, n_beta); + }, + py::arg("num_modes"), py::arg("symmetries")) + .def("__eq__", &TaperingSpecification::operator==, py::arg("other")) + .def("__hash__", [](const TaperingSpecification& self) { + return py::hash( + py::make_tuple(self.qubit_indices(), self.eigenvalues())); + }); + + py::class_ mapping( + data, "MajoranaMapping", R"( Fermion-to-qubit encoding. Majorana-atomic mappings store a 2N-entry Pauli table for individual gamma_k; @@ -27,153 +68,166 @@ bilinear-only mappings (via from_bilinears) store the bilinear images directly. bilinear(j, k) is available on both forms. )"); - mapping.def_static( - "from_table", - [](const std::vector& table, const std::string& name) { - try { - return MajoranaMapping::from_table(table, name); - } catch (const std::invalid_argument& e) { - throw py::value_error(e.what()); + mapping + .def_static("from_table", &MajoranaMapping::from_table, py::arg("table"), + py::arg("name") = "", + "Construct a Majorana-atomic mapping from sparse Pauli " + "words.") + .def_static("from_bilinears", &MajoranaMapping::from_bilinears, + py::arg("num_modes"), py::arg("bilinears"), + py::arg("name") = "", + "Construct a bilinear-only mapping from pre-computed " + "bilinears.") + .def_property_readonly("num_modes", &MajoranaMapping::num_modes) + .def_property_readonly("num_qubits", &MajoranaMapping::num_qubits) + .def_property_readonly("name", &MajoranaMapping::name) + .def_property_readonly("base_encoding", &MajoranaMapping::base_encoding) + .def_property_readonly("table", &MajoranaMapping::table) + .def_property_readonly("tapering", + [](const MajoranaMapping& self) + -> std::optional { + return self.tapering(); + }) + .def_property_readonly("is_majorana_atomic", + &MajoranaMapping::is_majorana_atomic) + .def( + "__call__", + [](const MajoranaMapping& self, + std::size_t k) -> const SparsePauliWord& { + try { + return self.majorana(k); + } catch (const std::out_of_range& e) { + throw py::index_error(e.what()); + } catch (const std::logic_error& e) { + throw py::value_error(e.what()); + } + }, + py::arg("k"), py::return_value_policy::reference_internal) + .def( + "majorana", + [](const MajoranaMapping& self, + std::size_t k) -> const SparsePauliWord& { + try { + return self.majorana(k); + } catch (const std::out_of_range& e) { + throw py::index_error(e.what()); + } catch (const std::logic_error& e) { + throw py::value_error(e.what()); + } + }, + py::arg("k"), py::return_value_policy::reference_internal) + .def( + "bilinear", + [](const MajoranaMapping& self, std::size_t j, std::size_t k) { + auto [coeff, word] = self.bilinear(j, k); + return py::make_tuple(coeff, word); + }, + py::arg("j"), py::arg("k")) + .def("without_tapering", &MajoranaMapping::without_tapering) + .def("__repr__", [](const MajoranaMapping& self) { + std::string repr = "MajoranaMapping("; + if (!self.name().empty()) { + repr += "'" + self.name() + "', "; } - }, - py::arg("table"), py::arg("name") = "", - "Construct a Majorana-atomic mapping from a list of 2N sparse Pauli " - "words."); - - mapping.def_property_readonly( - "num_modes", [](const MajoranaMapping& self) { return self.num_modes(); }, - "Number of fermionic modes (spin-orbitals)."); - - mapping.def_property_readonly( - "num_qubits", - [](const MajoranaMapping& self) { return self.num_qubits(); }, - "Number of qubits required by this encoding."); - - mapping.def_property_readonly( - "name", [](const MajoranaMapping& self) { return self.name(); }, - "Encoding name (may be empty)."); - - mapping.def_property_readonly( - "table", [](const MajoranaMapping& self) { return self.table(); }, - "List of 2N sparse Pauli words (empty for bilinear-only mappings)."); + repr += "num_modes=" + std::to_string(self.num_modes()) + + ", num_qubits=" + std::to_string(self.num_qubits()) + ")"; + return repr; + }); - mapping.def_property_readonly( - "is_majorana_atomic", - [](const MajoranaMapping& self) { return self.is_majorana_atomic(); }, - "True if individual Majorana operators have a Pauli image."); + mapping + .def_static("jordan_wigner", &MajoranaMapping::jordan_wigner, + py::arg("num_modes"), "Construct a Jordan-Wigner encoding.") + .def_static("bravyi_kitaev", &MajoranaMapping::bravyi_kitaev, + py::arg("num_modes"), "Construct a Bravyi-Kitaev encoding.") + .def_static("bravyi_kitaev_tree", &MajoranaMapping::bravyi_kitaev_tree, + py::arg("num_modes"), + "Construct a balanced binary-tree Bravyi-Kitaev encoding.") + .def_static( + "parity", + [](std::size_t num_modes) { + return MajoranaMapping::parity(num_modes); + }, + py::arg("num_modes"), "Construct a parity encoding.") + .def_static( + "parity", + [](std::size_t num_modes, const py::object& symmetries) { + auto n_alpha = symmetries.attr("n_alpha").cast(); + auto n_beta = symmetries.attr("n_beta").cast(); + return MajoranaMapping::parity(num_modes, n_alpha, n_beta); + }, + py::arg("num_modes"), py::arg("symmetries"), + "Construct a parity encoding with two-qubit reduction metadata.") + .def_static( + "symmetry_conserving_bravyi_kitaev", + [](std::size_t num_modes, const py::object& symmetries) { + auto n_alpha = symmetries.attr("n_alpha").cast(); + auto n_beta = symmetries.attr("n_beta").cast(); + return MajoranaMapping::symmetry_conserving_bravyi_kitaev( + num_modes, n_alpha, n_beta); + }, + py::arg("num_modes"), py::arg("symmetries"), + "Construct a symmetry-conserving Bravyi-Kitaev encoding."); - mapping.def( - "__call__", - [](const MajoranaMapping& self, std::size_t k) -> SparsePauliWord { - try { - return self(k); - } catch (const std::out_of_range& e) { - throw py::index_error(e.what()); - } catch (const std::logic_error& e) { - throw py::value_error(e.what()); - } - }, - py::arg("k"), "Sparse Pauli word for Majorana operator gamma_k."); - - mapping.def( - "majorana", - [](const MajoranaMapping& self, std::size_t k) -> SparsePauliWord { - try { - return self.majorana(k); - } catch (const std::out_of_range& e) { - throw py::index_error(e.what()); - } catch (const std::logic_error& e) { - throw py::value_error(e.what()); - } - }, - py::arg("k"), "Sparse Pauli word for Majorana operator gamma_k."); - - mapping.def( - "bilinear", - [](const MajoranaMapping& self, std::size_t j, - std::size_t k) -> py::tuple { - try { - auto [coeff, word] = self.bilinear(j, k); - return py::make_tuple(py::cast(coeff), py::cast(word)); - } catch (const std::invalid_argument& e) { - throw py::value_error(e.what()); - } catch (const std::out_of_range& e) { - throw py::index_error(e.what()); - } - }, - py::arg("j"), py::arg("k"), - "Pauli image (coeff, word) of the bilinear i*gamma_j*gamma_k."); - - mapping.def("__repr__", [](const MajoranaMapping& self) -> std::string { - std::string repr = "MajoranaMapping("; - if (!self.name().empty()) { - repr += "'" + self.name() + "', "; - } - repr += "num_modes=" + std::to_string(self.num_modes()) + - ", num_qubits=" + std::to_string(self.num_qubits()) + ")"; - return repr; - }); - - mapping.def_static( - "jordan_wigner", - [](std::size_t num_modes) { - try { - return MajoranaMapping::jordan_wigner(num_modes); - } catch (const std::invalid_argument& e) { - throw py::value_error(e.what()); - } - }, - py::arg("num_modes"), "Construct a Jordan-Wigner encoding."); - - mapping.def_static( - "bravyi_kitaev", - [](std::size_t num_modes) { - try { - return MajoranaMapping::bravyi_kitaev(num_modes); - } catch (const std::invalid_argument& e) { - throw py::value_error(e.what()); - } - }, - py::arg("num_modes"), "Construct a Bravyi-Kitaev encoding."); - - mapping.def_static( - "bravyi_kitaev_tree", - [](std::size_t num_modes) { - try { - return MajoranaMapping::bravyi_kitaev_tree(num_modes); - } catch (const std::invalid_argument& e) { - throw py::value_error(e.what()); - } - }, - py::arg("num_modes"), - "Construct a balanced binary-tree Bravyi-Kitaev encoding."); - - mapping.def_static( - "parity", - [](std::size_t num_modes) { - try { - return MajoranaMapping::parity(num_modes); - } catch (const std::invalid_argument& e) { - throw py::value_error(e.what()); - } - }, - py::arg("num_modes"), "Construct a parity encoding."); - - mapping.def_static( - "from_bilinears", - [](std::size_t num_modes, - const std::vector, SparsePauliWord>>& - upper_triangle, - const std::string& name) { - try { - return MajoranaMapping::from_bilinears(num_modes, upper_triangle, - name); - } catch (const std::invalid_argument& e) { - throw py::value_error(e.what()); - } - }, - py::arg("num_modes"), py::arg("upper_triangle"), py::arg("name") = "", - "Construct a bilinear-only mapping from pre-computed bilinears."); + mapping + .def( + "to_json", + [](const MajoranaMapping& self) { + py::module_ json = py::module_::import("json"); + return json.attr("loads")(self.to_json().dump()); + }, + "Serialize to a JSON-compatible dictionary.") + .def_static( + "from_json", + [](const py::object& json_data) { + py::module_ json = py::module_::import("json"); + return MajoranaMapping::from_json(nlohmann::json::parse( + json.attr("dumps")(json_data).cast())); + }, + py::arg("json_data")) + .def( + "to_hdf5", + [](const MajoranaMapping& self, const py::object& group) { + group.attr("attrs").attr("__setitem__")("json", + self.to_json().dump()); + }, + py::arg("group")) + .def_static( + "from_hdf5", + [](const py::object& group) { + auto json = group.attr("attrs") + .attr("__getitem__")("json") + .cast(); + return MajoranaMapping::from_json(nlohmann::json::parse(json)); + }, + py::arg("group")) + .def( + "to_json_file", + [](const MajoranaMapping& self, const py::object& filename) { + self.to_json_file( + qdk::chemistry::python::utils::to_string_path(filename)); + }, + py::arg("filename")) + .def_static( + "from_json_file", + [](const py::object& filename) { + return MajoranaMapping::from_json_file( + qdk::chemistry::python::utils::to_string_path(filename)); + }, + py::arg("filename")) + .def( + "to_hdf5_file", + [](const MajoranaMapping& self, const py::object& filename) { + self.to_hdf5_file( + qdk::chemistry::python::utils::to_string_path(filename)); + }, + py::arg("filename")) + .def_static( + "from_hdf5_file", + [](const py::object& filename) { + return MajoranaMapping::from_hdf5_file( + qdk::chemistry::python::utils::to_string_path(filename)); + }, + py::arg("filename")); data.def( "majorana_map_hamiltonian", diff --git a/python/src/pybind11/data/pauli_operator.cpp b/python/src/pybind11/data/pauli_operator.cpp index ddc88c7ab..53fbac6ed 100644 --- a/python/src/pybind11/data/pauli_operator.cpp +++ b/python/src/pybind11/data/pauli_operator.cpp @@ -13,6 +13,14 @@ namespace py = pybind11; void bind_pauli_operator(pybind11::module& data) { using namespace qdk::chemistry::data; + data.def("sparse_pauli_word_to_label", &sparse_pauli_word_to_label, + py::arg("word"), py::arg("num_qubits"), + "Convert a sparse Pauli word to a QubitHamiltonian Pauli label."); + + data.def("label_to_sparse_pauli_word", &label_to_sparse_pauli_word, + py::arg("label"), + "Convert a QubitHamiltonian Pauli label to a sparse Pauli word."); + // Base class PauliOperatorExpression py::class_> pauli_expr(data, "PauliOperatorExpression", R"( diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index 0e8972a64..0b80346ed 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -17,16 +17,14 @@ import numpy as np -from qdk_chemistry._core.data import majorana_map_hamiltonian +from qdk_chemistry._core.data import majorana_map_hamiltonian, sparse_pauli_word_to_label from qdk_chemistry.algorithms.qubit_mapper.qubit_mapper import QubitMapper, QubitMapperSettings from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder -from qdk_chemistry.data.majorana_mapping import _sparse_to_dense_le from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian from qdk_chemistry.utils import Logger if TYPE_CHECKING: - from qdk_chemistry.data import Hamiltonian - from qdk_chemistry.data.majorana_mapping import MajoranaMapping + from qdk_chemistry.data import Hamiltonian, MajoranaMapping __all__ = ["QdkQubitMapper", "QdkQubitMapperSettings"] @@ -115,8 +113,8 @@ def _run_impl( ) -> QubitHamiltonian: """Transform a fermionic Hamiltonian to a qubit Hamiltonian (table-driven). - This backend reads ``mapping.core`` (the C++ MajoranaMapping) and - uses the Pauli-string table directly. The ``base_encoding`` name + This backend passes the C++ ``MajoranaMapping`` directly to the + native mapper. The ``base_encoding`` name is used only for metadata on the output, not for dispatch. If *mapping* carries tapering metadata, the base encoding is @@ -159,7 +157,7 @@ def _run_impl( h2_bbbb_flat = h2_aaaa_flat if spin_symmetric else np.ascontiguousarray(h2_bbbb).ravel() words, coefficients = majorana_map_hamiltonian( - base_mapping.core, + base_mapping, 0.0, h1_a_flat, h1_b_flat, @@ -173,7 +171,7 @@ def _run_impl( ) n_qubits = base_mapping.num_qubits - pauli_strings = [_sparse_to_dense_le(word, n_qubits) for word in words] + pauli_strings = [sparse_pauli_word_to_label(word, n_qubits) for word in words] Logger.debug(f"Generated {len(pauli_strings)} Pauli terms for {2 * n_spatial} qubits") diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 1fb96481e..7f555941d 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -15,12 +15,99 @@ from qdk_chemistry.utils import Logger if TYPE_CHECKING: # Only needed for type annotations; avoid importing into module namespace - from qdk_chemistry.data import Hamiltonian, QubitHamiltonian - from qdk_chemistry.data.majorana_mapping import MajoranaMapping + from collections.abc import Sequence + + from qdk_chemistry.data import Hamiltonian, MajoranaMapping, QubitHamiltonian __all__: list[str] = [] +def _taper_qubits( + qubit_hamiltonian: QubitHamiltonian, + qubit_indices: Sequence[int], + eigenvalues: Sequence[int], +) -> QubitHamiltonian: + import numpy as np # noqa: PLC0415 + + from qdk_chemistry.data import QubitHamiltonian # noqa: PLC0415 + + qubit_indices = list(qubit_indices) + eigenvalues = list(eigenvalues) + + if len(qubit_indices) != len(eigenvalues): + raise ValueError( + f"qubit_indices length ({len(qubit_indices)}) must match eigenvalues length ({len(eigenvalues)})" + ) + if len(set(qubit_indices)) != len(qubit_indices): + raise ValueError("qubit_indices must not contain duplicates") + + nq = qubit_hamiltonian.num_qubits + for q in qubit_indices: + if q < 0 or q >= nq: + raise ValueError(f"Qubit index {q} out of range [0, {nq})") + for ev in eigenvalues: + if ev not in (1, -1): + raise ValueError(f"Eigenvalue must be +1 or -1, got {ev}") + + positions_to_remove = sorted([nq - 1 - q for q in qubit_indices]) + eigenvalue_map = dict(zip(qubit_indices, eigenvalues, strict=True)) + + new_strings: list[str] = [] + new_coeffs: list[complex] = [] + + for pauli_str, coeff in zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=True): + skip = False + adjusted_coeff = complex(coeff) + + for q, ev in eigenvalue_map.items(): + pos = nq - 1 - q + char = pauli_str[pos] + if char == "Z": + adjusted_coeff *= ev + elif char in ("X", "Y"): + skip = True + break + + if skip: + continue + + chars = [c for i, c in enumerate(pauli_str) if i not in positions_to_remove] + new_strings.append("".join(chars)) + new_coeffs.append(adjusted_coeff) + + new_nq = nq - len(qubit_indices) + + if not new_strings: + return QubitHamiltonian( + pauli_strings=["I" * new_nq], + coefficients=np.array([0.0]), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + ) + + merged: dict[str, complex] = {} + for s, c in zip(new_strings, new_coeffs, strict=True): + merged[s] = merged.get(s, 0.0) + c + + final_strings = [] + final_coeffs = [] + for s, c in merged.items(): + if abs(c) > 1e-12: + final_strings.append(s) + final_coeffs.append(c) + + if not final_strings: + final_strings = ["I" * new_nq] + final_coeffs = [0.0] + + return QubitHamiltonian( + pauli_strings=final_strings, + coefficients=np.array(final_coeffs), + encoding=qubit_hamiltonian.encoding, + fermion_mode_order=qubit_hamiltonian.fermion_mode_order, + ) + + class QubitMapperSettings(Settings): """Base settings for all QubitMapper implementations. @@ -117,7 +204,6 @@ def _taper_result(qh: QubitHamiltonian, mapping: MajoranaMapping) -> QubitHamilt Convenience helper for backends. If ``mapping.tapering`` is ``None``, returns *qh* unchanged. Otherwise, applies - :func:`~qdk_chemistry.data.tapering.taper_qubits` and relabels the result with the mapping's final encoding name. Args: @@ -133,9 +219,8 @@ def _taper_result(qh: QubitHamiltonian, mapping: MajoranaMapping) -> QubitHamilt return qh from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian # noqa: PLC0415 - from qdk_chemistry.data.tapering import taper_qubits # noqa: PLC0415 - tapered = taper_qubits(qh, tapering.qubit_indices, tapering.eigenvalues) + tapered = _taper_qubits(qh, tapering.qubit_indices, tapering.eigenvalues) result = QubitHamiltonian( pauli_strings=tapered.pauli_strings, coefficients=tapered.coefficients, @@ -163,8 +248,8 @@ def _run_impl( .. important:: **Table-driven** backends (e.g. :class:`QdkQubitMapper`) should - read ``mapping.table`` and ``mapping.core`` to perform the - transformation. + read ``mapping.table`` and pass the mapping to the native engine to + perform the transformation. **Name-dispatched** backends (e.g. ``OpenFermionQubitMapper``) should read ``mapping.base_encoding`` to select a third-party diff --git a/python/src/qdk_chemistry/data/__init__.py b/python/src/qdk_chemistry/data/__init__.py index f40aa974c..6becdf5a7 100644 --- a/python/src/qdk_chemistry/data/__init__.py +++ b/python/src/qdk_chemistry/data/__init__.py @@ -84,6 +84,7 @@ HamiltonianContainer, HamiltonianType, LatticeGraph, + MajoranaMapping, ModelOrbitals, MP2Container, Orbitals, @@ -102,6 +103,7 @@ SpinChannel, StabilityResult, Structure, + TaperingSpecification, Wavefunction, WavefunctionContainer, WavefunctionType, @@ -113,7 +115,6 @@ from qdk_chemistry.data.controlled_unitary import ControlledUnitary from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder from qdk_chemistry.data.estimator_data import EnergyExpectationResult, MeasurementData -from qdk_chemistry.data.majorana_mapping import MajoranaMapping from qdk_chemistry.data.noise_models import QuantumErrorProfile from qdk_chemistry.data.qpe_result import QpeResult from qdk_chemistry.data.qubit_hamiltonian import QubitHamiltonian @@ -188,6 +189,7 @@ "StabilityResult", "Structure", "Symmetries", + "TaperingSpecification", "TermPartition", "TimeDependentQubitHamiltonian", "TimeDependentQubitHamiltonianContainer", diff --git a/python/src/qdk_chemistry/data/majorana_mapping.py b/python/src/qdk_chemistry/data/majorana_mapping.py deleted file mode 100644 index b58ab1e9e..000000000 --- a/python/src/qdk_chemistry/data/majorana_mapping.py +++ /dev/null @@ -1,487 +0,0 @@ -"""Majorana-to-Pauli mapping data class for fermion-to-qubit encodings. - -This module provides the :class:`MajoranaMapping` data class, which describes a -fermion-to-qubit encoding. It stores a 2N-entry table mapping each Majorana -operator gamma_k to a Pauli word; the bilinear ``i*gamma_j*gamma_k`` is the -unified primitive and is computed on demand from the table. - -Pauli strings use the same little-endian convention as -:class:`~qdk_chemistry.data.QubitHamiltonian`: qubit 0 is the rightmost -character. - -""" - -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See LICENSE.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -import numpy as np - -from qdk_chemistry._core.data import MajoranaMapping as _CoreMajoranaMapping -from qdk_chemistry.data.base import DataClass -from qdk_chemistry.data.tapering import TaperingSpecification - -if TYPE_CHECKING: - import h5py - - from qdk_chemistry.data import Symmetries - -__all__: list[str] = [] - -# Sparse op_type codes used by the C++ layer: 1=X, 2=Y, 3=Z (0/identity omitted). -_CHAR_TO_OP = {"I": 0, "X": 1, "Y": 2, "Z": 3} -_OP_TO_CHAR = {1: "X", 2: "Y", 3: "Z"} - -SparsePauliWord = list[tuple[int, int]] - - -def _dense_le_to_sparse(label: str) -> SparsePauliWord: - """Convert a dense little-endian Pauli string to a sparse Pauli word.""" - n = len(label) - word: SparsePauliWord = [] - for i, char in enumerate(label): - try: - op = _CHAR_TO_OP[char.upper()] - except KeyError: - raise ValueError(f"Invalid Pauli character {char!r}; expected I, X, Y, or Z") from None - if op: - word.append((n - 1 - i, op)) - word.sort() - return word - - -def _sparse_to_dense_le(word: SparsePauliWord, num_qubits: int) -> str: - """Convert a sparse Pauli word to a dense little-endian string.""" - chars = ["I"] * num_qubits - for qubit, op in word: - if qubit < num_qubits: - chars[num_qubits - 1 - qubit] = _OP_TO_CHAR[op] - return "".join(chars) - - -class MajoranaMapping(DataClass): - """Fermion-to-qubit encoding. - - Majorana-atomic mappings store a 2N-entry Pauli table for individual - gamma_k; bilinear-only mappings (via :py:meth:`from_bilinears`) store - the bilinear images directly. :py:meth:`bilinear` is available on both. - - Examples: - >>> jw = MajoranaMapping.jordan_wigner(num_modes=4) - >>> coeff, pauli = jw.bilinear(0, 1) - - """ - - _data_type_name = "majorana_mapping" - _serialization_version = "0.1.0" - - def __init__( - self, - table: list[str] | tuple[str, ...], - name: str = "", - tapering: TaperingSpecification | None = None, - *, - _core: _CoreMajoranaMapping | None = None, - ) -> None: - """Initialize a MajoranaMapping from a list of dense Pauli-string labels. - - Args: - table (list[str] | tuple[str, ...]): 2N Pauli strings in little-endian format (qubit 0 = rightmost char). - name (str): Optional human-readable label for the encoding. Default ``""``. - tapering (TaperingSpecification | None): Optional post-mapping tapering specification. - - Raises: - ValueError: If the table is invalid (empty, odd size, or bad characters). - - """ - if _core is not None: - self._core = _core - else: - if table and len({len(label) for label in table}) > 1: - raise ValueError("All Pauli strings must have the same length") - sparse_table = [_dense_le_to_sparse(label) for label in table] - self._core = _CoreMajoranaMapping.from_table(sparse_table, name) - - self._name = name if name else self._core.name - self._num_modes = self._core.num_modes - self._num_qubits = self._core.num_qubits - self._table = tuple(_sparse_to_dense_le(word, self._num_qubits) for word in self._core.table) - self._tapering = tapering - - super().__init__() - - @property - def table(self) -> tuple[str, ...]: - """Tuple of 2N dense Pauli strings (empty for bilinear-only mappings).""" - return self._table - - @property - def num_modes(self) -> int: - """Number of fermionic modes (spin-orbitals).""" - return self._num_modes - - @property - def num_qubits(self) -> int: - """Number of qubits, accounting for any tapering.""" - if self._tapering is not None: - return self._num_qubits - self._tapering.num_tapered - return self._num_qubits - - @property - def name(self) -> str: - """Human-readable name of the encoding (may be empty for custom mappings).""" - return self._name - - @property - def tapering(self) -> TaperingSpecification | None: - """Post-mapping tapering specification, or None for untapered encodings.""" - return self._tapering - - def without_tapering(self) -> MajoranaMapping: - """Return a copy of this mapping with tapering stripped. - - Returns: - A new :class:`MajoranaMapping` with no tapering. - - """ - return MajoranaMapping(table=[], _core=self._core) - - @property - def base_encoding(self) -> str: - """The base encoding name for the Majorana-to-Pauli table. - - For standard encodings this equals :attr:`name`. For tapering-based - encodings it returns the underlying encoding (e.g. - ``"bravyi-kitaev-tree"``) while :attr:`name` returns the final label. - """ - return self._core.name - - @property - def core(self) -> _CoreMajoranaMapping: - """Access the underlying C++ MajoranaMapping object.""" - return self._core - - @property - def is_majorana_atomic(self) -> bool: - """True if individual Majorana operators gamma_k have a Pauli image. - - When ``True``, :py:meth:`majorana` is available; otherwise only - :py:meth:`bilinear` is. - """ - return bool(self._core.is_majorana_atomic) - - def majorana(self, k: int) -> str: - """Return the Pauli image of the Majorana operator gamma_k. - - Available only for :py:attr:`is_majorana_atomic` encodings; raises - :class:`ValueError` for bilinear-only mappings. - - Args: - k (int): Majorana index (0 <= k < 2 * num_modes). - - Returns: - str: Dense little-endian Pauli string (qubit 0 = rightmost char). - - Raises: - IndexError: If k is out of range. - ValueError: If the mapping is not Majorana-atomic. - - """ - return _sparse_to_dense_le(self._core.majorana(k), self._num_qubits) - - def bilinear(self, j: int, k: int) -> tuple[complex, str]: - """Return the Pauli image of the bilinear ``i*gamma_j*gamma_k``. - - Returns ``(coeff, pauli_str)`` such that ``coeff * pauli_str`` equals - ``i*gamma_j*gamma_k`` in the encoded qubit representation. Defined for - all distinct ``j != k`` in ``[0, 2 * num_modes)``. The coefficient is - real (±1) for the factory encodings but typed :class:`complex` for - generality. - - Args: - j (int): First Majorana index (0 <= j < 2 * num_modes). - k (int): Second Majorana index (0 <= k < 2 * num_modes), must differ from j. - - Returns: - tuple[complex, str]: ``(coeff, pauli_str)`` in dense little-endian form. - - Raises: - IndexError: If j or k is out of range. - ValueError: If j == k. - - """ - coeff, word = self._core.bilinear(j, k) - return coeff, _sparse_to_dense_le(word, self._num_qubits) - - @classmethod - def jordan_wigner(cls, num_modes: int) -> MajoranaMapping: - """Construct a Jordan-Wigner encoding. - - Args: - num_modes (int): Number of fermionic modes (spin-orbitals). - - Returns: - MajoranaMapping: Mapping with name ``"jordan-wigner"``. - - """ - core = _CoreMajoranaMapping.jordan_wigner(num_modes) - return cls(table=[], _core=core) - - @classmethod - def bravyi_kitaev(cls, num_modes: int) -> MajoranaMapping: - """Construct a Bravyi-Kitaev encoding. - - Args: - num_modes (int): Number of fermionic modes (spin-orbitals). - - Returns: - MajoranaMapping: Mapping with name ``"bravyi-kitaev"``. - - """ - core = _CoreMajoranaMapping.bravyi_kitaev(num_modes) - return cls(table=[], _core=core) - - @classmethod - def bravyi_kitaev_tree(cls, num_modes: int) -> MajoranaMapping: - """Construct a balanced binary tree Bravyi-Kitaev encoding. - - Args: - num_modes (int): Number of fermionic modes (spin-orbitals). - - Returns: - MajoranaMapping: Mapping with name ``"bravyi-kitaev-tree"``. - - """ - core = _CoreMajoranaMapping.bravyi_kitaev_tree(num_modes) - return cls(table=[], _core=core) - - @classmethod - def parity( - cls, - num_modes: int, - symmetries: Symmetries | None = None, - ) -> MajoranaMapping: - """Construct a parity encoding, optionally with two-qubit reduction. - - Args: - num_modes (int): Number of fermionic modes (spin-orbitals). - symmetries (Symmetries | None): If provided, enables two-qubit reduction. - - Returns: - MajoranaMapping: Mapping with name ``"parity"`` or ``"parity-2q-reduced"``. - - """ - core = _CoreMajoranaMapping.parity(num_modes) - if symmetries is not None: - tapering = TaperingSpecification.parity_two_qubit_reduction(num_modes, symmetries) - return cls(table=[], name="parity-2q-reduced", tapering=tapering, _core=core) - return cls(table=[], _core=core) - - @classmethod - def symmetry_conserving_bravyi_kitaev( - cls, - num_modes: int, - symmetries: Symmetries, - ) -> MajoranaMapping: - """Construct a symmetry-conserving Bravyi-Kitaev (SCBK) encoding. - - Args: - num_modes (int): Number of fermionic modes (spin-orbitals). Must be even and >= 4. - symmetries (Symmetries): Electron counts for the target symmetry sector. - - Returns: - MajoranaMapping: BK-tree mapping with SCBK tapering. - - Raises: - ValueError: If num_modes < 4 or odd, or electron counts are invalid. - - """ - tapering = TaperingSpecification.symmetry_conserving_bravyi_kitaev(num_modes, symmetries) - core = _CoreMajoranaMapping.bravyi_kitaev_tree(num_modes) - return cls( - table=[], - name="symmetry-conserving-bravyi-kitaev", - tapering=tapering, - _core=core, - ) - - @classmethod - def from_mode_pairs( - cls, - pairs: list[tuple[str, str]], - name: str = "", - ) -> MajoranaMapping: - """Construct from (gamma_even, gamma_odd) mode pairs. - - Args: - pairs (list[tuple[str, str]]): List of (gamma_{2k}, gamma_{2k+1}) Pauli string pairs, one per mode. - name (str): Optional human-readable label. Default ``""``. - - Returns: - MajoranaMapping: The constructed mapping. - - Raises: - ValueError: If the table is invalid (empty, odd size, or bad characters). - - """ - table: list[str] = [] - for even, odd in pairs: - table.append(even) - table.append(odd) - return cls(table=table, name=name) - - @classmethod - def from_bilinears( - cls, - num_modes: int, - bilinears: dict[tuple[int, int], tuple[complex, str]], - name: str = "", - ) -> MajoranaMapping: - """Construct a bilinear-only mapping from pre-computed bilinears. - - For encodings where individual Majorana operators have no Pauli image, - this constructor accepts the bilinear images directly. - :py:meth:`bilinear` is available; :py:meth:`majorana` will raise. - - Args: - num_modes (int): Number of fermionic modes (spin-orbitals). Must be > 0. - bilinears (dict[tuple[int, int], tuple[complex, str]]): Bilinear images ``{(j, k): (coeff, pauli_str)}``. - name (str): Optional human-readable label. Default ``""``. - - Returns: - MajoranaMapping: A bilinear-only mapping. - - Raises: - ValueError: If sizes are inconsistent or num_modes is zero. - - """ - num_majoranas = 2 * num_modes - expected = num_majoranas * (num_majoranas - 1) // 2 - if len(bilinears) != expected: - raise ValueError( - f"Expected {expected} upper-triangle bilinear entries for {num_modes} modes, got {len(bilinears)}" - ) - # Build upper-triangle flat list in row-major order - upper_triangle: list[tuple[complex, SparsePauliWord]] = [] - for j in range(num_majoranas): - for k in range(j + 1, num_majoranas): - if (j, k) not in bilinears: - raise ValueError(f"Missing bilinear entry for ({j}, {k})") - coeff, label = bilinears[(j, k)] - upper_triangle.append((complex(coeff), _dense_le_to_sparse(label))) - core = _CoreMajoranaMapping.from_bilinears(num_modes, upper_triangle, name) - return cls(table=[], name=name, _core=core) - - @classmethod - def _from_core(cls, core: _CoreMajoranaMapping) -> MajoranaMapping: - """Construct from an already-validated C++ core object.""" - return cls(table=[], _core=core) - - def get_summary(self) -> str: - """Get a human-readable summary of the mapping. - - Returns: - str: Summary string. - - """ - lines = [] - label = f"MajoranaMapping '{self._name}'" if self._name else "MajoranaMapping (unnamed)" - lines.append(label) - lines.append(f" Modes: {self._num_modes}, Qubits: {self._num_qubits}") - for k, pauli_str in enumerate(self._table): - lines.append(f" gamma_{k} → {pauli_str}") - return "\n".join(lines) - - def to_json(self) -> dict[str, Any]: - """Serialize to a JSON-compatible dictionary. - - Returns: - dict[str, Any]: Dictionary representation. - - """ - data: dict[str, Any] = { - "table": list(self._table), - "name": self._name, - } - if self._tapering is not None: - data["tapering"] = self._tapering.to_json() - return self._add_json_version(data) - - @classmethod - def from_json(cls, json_data: dict[str, Any]) -> MajoranaMapping: - """Deserialize from a JSON dictionary. - - Args: - json_data (dict[str, Any]): Dictionary produced by :meth:`to_json`. - - Returns: - MajoranaMapping: The deserialized mapping. - - """ - cls._validate_json_version("0.1.0", json_data) - tapering_data = json_data.get("tapering") - tapering = TaperingSpecification.from_json(tapering_data) if tapering_data else None - return cls( - table=json_data["table"], - name=json_data.get("name", ""), - tapering=tapering, - ) - - def to_hdf5(self, group: h5py.Group) -> None: - """Write to an HDF5 group. - - Args: - group (h5py.Group): HDF5 group to write to. - - """ - self._add_hdf5_version(group) - group.attrs["name"] = self._name - group.attrs["num_modes"] = self._num_modes - group.create_dataset("table", data=np.array(list(self._table), dtype="S")) - if self._tapering is not None: - tg = group.create_group("tapering") - tg.create_dataset("qubit_indices", data=np.array(self._tapering.qubit_indices, dtype=np.int64)) - tg.create_dataset("eigenvalues", data=np.array(self._tapering.eigenvalues, dtype=np.int8)) - tg.attrs["source_num_qubits"] = self._tapering.source_num_qubits - tg.attrs["source_encoding"] = self._tapering.source_encoding - - @classmethod - def from_hdf5(cls, group: h5py.Group) -> MajoranaMapping: - """Load from an HDF5 group. - - Args: - group (h5py.Group): HDF5 group to read from. - - Returns: - MajoranaMapping: The deserialized mapping. - - """ - cls._validate_hdf5_version("0.1.0", group) - table = [s.decode("utf-8") if isinstance(s, bytes) else s for s in group["table"][()]] - name = group.attrs.get("name", "") - if isinstance(name, bytes): - name = name.decode("utf-8") - tapering = None - if "tapering" in group: - tg = group["tapering"] - src_enc = tg.attrs.get("source_encoding", "") - if isinstance(src_enc, bytes): - src_enc = src_enc.decode("utf-8") - tapering = TaperingSpecification( - qubit_indices=tuple(int(x) for x in tg["qubit_indices"][()]), - eigenvalues=tuple(int(x) for x in tg["eigenvalues"][()]), - source_num_qubits=int(tg.attrs["source_num_qubits"]), - source_encoding=src_enc, - ) - return cls(table=table, name=name, tapering=tapering) - - def __repr__(self) -> str: - """Return a repr string.""" - label = f"'{self._name}', " if self._name else "" - tapered = f", tapered={self._tapering.num_tapered}" if self._tapering is not None else "" - return f"MajoranaMapping({label}num_modes={self._num_modes}, num_qubits={self._num_qubits}{tapered})" diff --git a/python/src/qdk_chemistry/data/qubit_hamiltonian.py b/python/src/qdk_chemistry/data/qubit_hamiltonian.py index 1372ea05f..957a448f4 100644 --- a/python/src/qdk_chemistry/data/qubit_hamiltonian.py +++ b/python/src/qdk_chemistry/data/qubit_hamiltonian.py @@ -26,8 +26,8 @@ import h5py import scipy +from qdk_chemistry._core.data import TaperingSpecification from qdk_chemistry.data.enums.fermion_mode_order import FermionModeOrder -from qdk_chemistry.data.tapering import TaperingSpecification from qdk_chemistry.utils import Logger __all__: list[str] = [] diff --git a/python/src/qdk_chemistry/data/tapering.py b/python/src/qdk_chemistry/data/tapering.py deleted file mode 100644 index 1450c87f1..000000000 --- a/python/src/qdk_chemistry/data/tapering.py +++ /dev/null @@ -1,399 +0,0 @@ -"""Tapering specification for symmetry-conserving encodings. - -A :class:`TaperingSpecification` describes which qubits to taper and the eigenvalue -to assign to each, enabling post-mapping qubit reduction for encodings like -symmetry-conserving Bravyi-Kitaev (SCBK). -""" - -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See LICENSE.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from collections.abc import Sequence - - from qdk_chemistry.data import QubitHamiltonian, Symmetries - -__all__: list[str] = [] - - -class TaperingSpecification: - """Immutable specification for post-mapping qubit tapering. - - Describes which qubits are constrained by symmetry and the Z eigenvalue - (+1 or -1) to substitute for each. When attached to a - :class:`~qdk_chemistry.data.MajoranaMapping`, the mapper applies this - tapering automatically after the Majorana-to-Pauli mapping. - - Attributes: - qubit_indices (tuple[int, ...]): Qubit indices to taper (0-indexed, pre-taper numbering). - eigenvalues (tuple[int, ...]): Corresponding Z eigenvalues (+1 or -1). - source_num_qubits (int): Number of qubits in the pre-taper register. - source_encoding (str): Encoding of the pre-taper mapping (e.g. ``"bravyi-kitaev"``). - - """ - - __slots__ = ("_eigenvalues", "_qubit_indices", "_source_encoding", "_source_num_qubits") - - _qubit_indices: tuple[int, ...] - _eigenvalues: tuple[int, ...] - _source_num_qubits: int - _source_encoding: str - - def __init__( - self, - qubit_indices: tuple[int, ...] | list[int], - eigenvalues: tuple[int, ...] | list[int], - source_num_qubits: int, - source_encoding: str = "", - ) -> None: - """Initialize a TaperingSpecification. - - Args: - qubit_indices (tuple[int, ...] | list[int]): Qubit indices to taper (0-indexed). - eigenvalues (tuple[int, ...] | list[int]): Corresponding Z eigenvalues (+1 or -1). - source_num_qubits (int): Number of qubits in the pre-taper register. - source_encoding (str): Encoding name of the pre-taper mapping. - - Raises: - ValueError: If lengths don't match, indices out of range, duplicates, or invalid eigenvalues. - - """ - qi = tuple(qubit_indices) - ev = tuple(eigenvalues) - if len(qi) != len(ev): - raise ValueError(f"qubit_indices length ({len(qi)}) must match eigenvalues length ({len(ev)})") - if len(set(qi)) != len(qi): - raise ValueError("qubit_indices must not contain duplicates") - for q in qi: - if q < 0 or q >= source_num_qubits: - raise ValueError(f"Qubit index {q} out of range [0, {source_num_qubits})") - for v in ev: - if v not in (1, -1): - raise ValueError(f"Eigenvalue must be +1 or -1, got {v}") - - object.__setattr__(self, "_qubit_indices", qi) - object.__setattr__(self, "_eigenvalues", ev) - object.__setattr__(self, "_source_num_qubits", source_num_qubits) - object.__setattr__(self, "_source_encoding", source_encoding) - - def __setattr__(self, _name: str, _value: object) -> None: - raise AttributeError("TaperingSpecification is immutable") - - def __delattr__(self, _name: str) -> None: - raise AttributeError("TaperingSpecification is immutable") - - @property - def qubit_indices(self) -> tuple[int, ...]: - """Qubit indices to taper (0-indexed, pre-taper numbering).""" - return self._qubit_indices - - @property - def eigenvalues(self) -> tuple[int, ...]: - """Z eigenvalues (+1 or -1) for each tapered qubit.""" - return self._eigenvalues - - @property - def source_num_qubits(self) -> int: - """Number of qubits in the pre-taper register.""" - return self._source_num_qubits - - @property - def source_encoding(self) -> str: - """Encoding name of the pre-taper mapping.""" - return self._source_encoding - - @property - def num_tapered(self) -> int: - """Number of qubits removed by this tapering.""" - return len(self._qubit_indices) - - @classmethod - def symmetry_conserving_bravyi_kitaev( - cls, num_modes: int, symmetries: Symmetries, source_encoding: str = "bravyi-kitaev" - ) -> TaperingSpecification: - """Create a tapering specification for symmetry-conserving Bravyi-Kitaev. - - Tapers the two Z₂ symmetry qubits of the Bravyi-Kitaev encoding: - total electron-number parity (qubit n-1) and alpha-spin parity - (qubit n/2-1), reducing the qubit count by 2. - - Args: - num_modes (int): Number of spin-orbitals (= number of Bravyi-Kitaev qubits). - symmetries (Symmetries): Electron counts for the target symmetry sector. - source_encoding (str): Encoding label for the pre-taper mapping. Default ``"bravyi-kitaev"``. - - Returns: - TaperingSpecification: Tapering specification for symmetry-conserving Bravyi-Kitaev. - - Raises: - ValueError: If num_modes < 4 or odd, or electron counts exceed available orbitals. - - """ - n = num_modes - if n < 4 or n % 2 != 0: - raise ValueError(f"Symmetry-conserving Bravyi-Kitaev requires an even num_modes >= 4, got {n}") - if symmetries.n_alpha < 0 or symmetries.n_beta < 0: - raise ValueError("n_alpha and n_beta must be non-negative") - if symmetries.n_alpha > n // 2: - raise ValueError(f"n_alpha ({symmetries.n_alpha}) exceeds spatial orbitals ({n // 2})") - if symmetries.n_beta > n // 2: - raise ValueError(f"n_beta ({symmetries.n_beta}) exceeds spatial orbitals ({n // 2})") - - ev_total = 1 if (symmetries.n_alpha + symmetries.n_beta) % 2 == 0 else -1 - ev_alpha = 1 if symmetries.n_alpha % 2 == 0 else -1 - - q_alpha = n // 2 - 1 - q_total = n - 1 - - return cls( - qubit_indices=(q_alpha, q_total), - eigenvalues=(ev_alpha, ev_total), - source_num_qubits=n, - source_encoding=source_encoding, - ) - - @classmethod - def parity_two_qubit_reduction(cls, num_modes: int, symmetries: Symmetries) -> TaperingSpecification: - """Create a tapering specification for parity encoding two-qubit reduction. - - Tapers the same two Z₂ symmetry qubits as the symmetry-conserving - Bravyi-Kitaev encoding: total electron-number parity (qubit n-1) and - alpha-spin parity (qubit n/2-1). - - Args: - num_modes (int): Number of spin-orbitals (= number of parity qubits). - symmetries (Symmetries): Electron counts for the target symmetry sector. - - Returns: - TaperingSpecification: Tapering specification for parity two-qubit reduction. - - Raises: - ValueError: If num_modes < 4 or odd, or electron counts exceed available orbitals. - - """ - n = num_modes - if n < 4 or n % 2 != 0: - raise ValueError(f"Parity two-qubit reduction requires an even num_modes >= 4, got {n}") - if symmetries.n_alpha < 0 or symmetries.n_beta < 0: - raise ValueError("n_alpha and n_beta must be non-negative") - if symmetries.n_alpha > n // 2: - raise ValueError(f"n_alpha ({symmetries.n_alpha}) exceeds spatial orbitals ({n // 2})") - if symmetries.n_beta > n // 2: - raise ValueError(f"n_beta ({symmetries.n_beta}) exceeds spatial orbitals ({n // 2})") - - ev_total = 1 if (symmetries.n_alpha + symmetries.n_beta) % 2 == 0 else -1 - ev_alpha = 1 if symmetries.n_alpha % 2 == 0 else -1 - - q_alpha = n // 2 - 1 - q_total = n - 1 - - return cls( - qubit_indices=(q_alpha, q_total), - eigenvalues=(ev_alpha, ev_total), - source_num_qubits=n, - source_encoding="parity", - ) - - def to_json(self) -> dict[str, Any]: - """Serialize to a JSON-compatible dictionary. - - Returns: - dict[str, Any]: Dictionary representation. - - """ - return { - "qubit_indices": list(self._qubit_indices), - "eigenvalues": list(self._eigenvalues), - "source_num_qubits": self._source_num_qubits, - "source_encoding": self._source_encoding, - } - - @classmethod - def from_json(cls, data: dict[str, Any]) -> TaperingSpecification: - """Deserialize from a JSON dictionary. - - Args: - data (dict[str, Any]): Dictionary produced by :meth:`to_json`. - - Returns: - TaperingSpecification: The deserialized tapering specification. - - """ - return cls( - qubit_indices=tuple(data["qubit_indices"]), - eigenvalues=tuple(data["eigenvalues"]), - source_num_qubits=data["source_num_qubits"], - source_encoding=data.get("source_encoding", ""), - ) - - def __repr__(self) -> str: - """Return a repr string.""" - return ( - f"TaperingSpecification(qubit_indices={self._qubit_indices}, " - f"eigenvalues={self._eigenvalues}, " - f"source_num_qubits={self._source_num_qubits})" - ) - - def __eq__(self, other: object) -> bool: - """Check equality.""" - if not isinstance(other, TaperingSpecification): - return NotImplemented - return ( - self._qubit_indices == other._qubit_indices - and self._eigenvalues == other._eigenvalues - and self._source_num_qubits == other._source_num_qubits - and self._source_encoding == other._source_encoding - ) - - def __hash__(self) -> int: - """Return hash.""" - return hash((self._qubit_indices, self._eigenvalues, self._source_num_qubits, self._source_encoding)) - - -def taper_qubits( - qubit_hamiltonian: QubitHamiltonian, - qubit_indices: Sequence[int], - eigenvalues: Sequence[int], -) -> QubitHamiltonian: - """Remove qubits with known Z eigenvalues from a qubit Hamiltonian. - - For each specified qubit, every Pauli term that has Z on that qubit gets - its coefficient multiplied by the eigenvalue, and the Z is replaced with I. - Terms with X or Y on a tapered qubit are dropped. After replacement, the - tapered qubit positions are removed and the remaining qubits are renumbered. - - Args: - qubit_hamiltonian (QubitHamiltonian): The qubit Hamiltonian to taper. - qubit_indices (Sequence[int]): Qubit indices to taper (0-indexed). - eigenvalues (Sequence[int]): Corresponding Z eigenvalues (+1 or -1) for each qubit. - - Returns: - QubitHamiltonian: Tapered Hamiltonian with fewer qubits. - - Raises: - ValueError: If lengths don't match, indices are out of range, contain duplicates, or eigenvalues are not +/-1. - - """ - import numpy as np # noqa: PLC0415 - - from qdk_chemistry.data import QubitHamiltonian # noqa: PLC0415 - - qubit_indices = list(qubit_indices) - eigenvalues = list(eigenvalues) - - if len(qubit_indices) != len(eigenvalues): - raise ValueError( - f"qubit_indices length ({len(qubit_indices)}) must match eigenvalues length ({len(eigenvalues)})" - ) - if len(set(qubit_indices)) != len(qubit_indices): - raise ValueError("qubit_indices must not contain duplicates") - - nq = qubit_hamiltonian.num_qubits - for q in qubit_indices: - if q < 0 or q >= nq: - raise ValueError(f"Qubit index {q} out of range [0, {nq})") - for ev in eigenvalues: - if ev not in (1, -1): - raise ValueError(f"Eigenvalue must be +1 or -1, got {ev}") - - positions_to_remove = sorted([nq - 1 - q for q in qubit_indices]) - eigenvalue_map = dict(zip(qubit_indices, eigenvalues, strict=True)) - - new_strings: list[str] = [] - new_coeffs: list[complex] = [] - - for pauli_str, coeff in zip(qubit_hamiltonian.pauli_strings, qubit_hamiltonian.coefficients, strict=True): - skip = False - adjusted_coeff = complex(coeff) - - for q, ev in eigenvalue_map.items(): - pos = nq - 1 - q - char = pauli_str[pos] - if char == "Z": - adjusted_coeff *= ev - elif char in ("X", "Y"): - skip = True - break - - if skip: - continue - - chars = [c for i, c in enumerate(pauli_str) if i not in positions_to_remove] - new_strings.append("".join(chars)) - new_coeffs.append(adjusted_coeff) - - new_nq = nq - len(qubit_indices) - - if not new_strings: - identity_str = "I" * new_nq - return QubitHamiltonian( - pauli_strings=[identity_str], - coefficients=np.array([0.0]), - encoding=qubit_hamiltonian.encoding, - fermion_mode_order=qubit_hamiltonian.fermion_mode_order, - ) - - merged: dict[str, complex] = {} - for s, c in zip(new_strings, new_coeffs, strict=True): - merged[s] = merged.get(s, 0.0) + c - - final_strings = [] - final_coeffs = [] - for s, c in merged.items(): - if abs(c) > 1e-12: - final_strings.append(s) - final_coeffs.append(c) - - if not final_strings: - identity_str = "I" * new_nq - final_strings = [identity_str] - final_coeffs = [0.0] - - return QubitHamiltonian( - pauli_strings=final_strings, - coefficients=np.array(final_coeffs), - encoding=qubit_hamiltonian.encoding, - fermion_mode_order=qubit_hamiltonian.fermion_mode_order, - ) - - -def taper_to_scbk( - qubit_hamiltonian: QubitHamiltonian, - symmetries: Symmetries, -) -> QubitHamiltonian: - """Apply symmetry-conserving Bravyi-Kitaev tapering to a qubit Hamiltonian. - - Computes the SCBK tapering specification from symmetries, applies - :func:`taper_qubits` to remove the two Z_2 symmetry qubits, and returns the - reduced Hamiltonian with encoding set to ``"symmetry-conserving-bravyi-kitaev"`` - and tapering metadata attached. - - Args: - qubit_hamiltonian (QubitHamiltonian): A qubit Hamiltonian produced by a Bravyi-Kitaev mapping. - symmetries (Symmetries): Electron counts (n_alpha, n_beta) for the target symmetry sector. - - Returns: - QubitHamiltonian: Tapered Hamiltonian with two fewer qubits, SCBK encoding label, and tapering metadata. - - """ - from qdk_chemistry.data import QubitHamiltonian # noqa: PLC0415 - - tapering = TaperingSpecification.symmetry_conserving_bravyi_kitaev( - num_modes=qubit_hamiltonian.num_qubits, - symmetries=symmetries, - source_encoding=qubit_hamiltonian.encoding or "bravyi-kitaev", - ) - tapered = taper_qubits(qubit_hamiltonian, tapering.qubit_indices, tapering.eigenvalues) - return QubitHamiltonian( - pauli_strings=tapered.pauli_strings, - coefficients=tapered.coefficients, - encoding="symmetry-conserving-bravyi-kitaev", - fermion_mode_order=tapered.fermion_mode_order, - tapering=tapering, - ) diff --git a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py index 21ff7269b..ef7abce21 100644 --- a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py @@ -28,8 +28,7 @@ if TYPE_CHECKING: from collections.abc import Callable - from qdk_chemistry.data import Hamiltonian, QubitHamiltonian - from qdk_chemistry.data.majorana_mapping import MajoranaMapping + from qdk_chemistry.data import Hamiltonian, MajoranaMapping, QubitHamiltonian __all__ = ["OpenFermionQubitMapper", "OpenFermionQubitMapperSettings"] diff --git a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py index eb1d998f0..3fedf4a13 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py @@ -25,7 +25,7 @@ from qdk_chemistry.utils import Logger if TYPE_CHECKING: - from qdk_chemistry.data.majorana_mapping import MajoranaMapping + from qdk_chemistry.data import MajoranaMapping __all__ = ["QiskitQubitMapper", "QiskitQubitMapperSettings"] diff --git a/python/tests/test_interop_openfermion_qubit_mapper.py b/python/tests/test_interop_openfermion_qubit_mapper.py index a001fb9ae..cf58886d6 100644 --- a/python/tests/test_interop_openfermion_qubit_mapper.py +++ b/python/tests/test_interop_openfermion_qubit_mapper.py @@ -109,7 +109,7 @@ def test_openfermion_bk_tree_encoding(): """ hamiltonian = create_nontrivial_test_hamiltonian() n = _num_spin_orbitals(hamiltonian) - mapping = MajoranaMapping(table=list(MajoranaMapping.jordan_wigner(n).table), name="bravyi-kitaev-tree") + mapping = MajoranaMapping.from_table(list(MajoranaMapping.jordan_wigner(n).table), name="bravyi-kitaev-tree") qh = create("qubit_mapper", "openfermion").run(hamiltonian, mapping) @@ -203,7 +203,7 @@ def test_scbk_name_dispatch_removed(): hamiltonian = create_nontrivial_test_hamiltonian() n_spin = _num_spin_orbitals(hamiltonian) jw = MajoranaMapping.jordan_wigner(n_spin) - mapping = MajoranaMapping(table=list(jw.table), name="symmetry-conserving-bravyi-kitaev") + mapping = MajoranaMapping.from_table(list(jw.table), name="symmetry-conserving-bravyi-kitaev") mapper = create("qubit_mapper", "openfermion") with pytest.raises(NotImplementedError, match="does not support base encoding"): @@ -255,7 +255,7 @@ def test_openfermion_unsupported_mapping_raises(): """A mapping with an unsupported name raises NotImplementedError.""" hamiltonian = create_nontrivial_test_hamiltonian() n = _num_spin_orbitals(hamiltonian) - mapping = MajoranaMapping(table=list(MajoranaMapping.jordan_wigner(n).table), name="invalid-encoding") + mapping = MajoranaMapping.from_table(list(MajoranaMapping.jordan_wigner(n).table), name="invalid-encoding") mapper = create("qubit_mapper", "openfermion") with pytest.raises(NotImplementedError, match="invalid-encoding"): @@ -380,7 +380,7 @@ def test_openfermion_standard_sets_blocked_order(encoding): if encoding in _ENCODING_TO_MAPPING: mapping = _ENCODING_TO_MAPPING[encoding](n) else: - mapping = MajoranaMapping(table=list(MajoranaMapping.jordan_wigner(n).table), name=encoding) + mapping = MajoranaMapping.from_table(list(MajoranaMapping.jordan_wigner(n).table), name=encoding) qh = create("qubit_mapper", "openfermion").run(hamiltonian, mapping) assert qh.fermion_mode_order == FermionModeOrder.BLOCKED diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index 4afc2b0e2..ecbb2c674 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -4,7 +4,6 @@ - Factory construction (JW, BK, parity) for various sizes - Clifford algebra anticommutation validation - Custom mapping construction -- from_mode_pairs construction - from_bilinears construction (bilinear-only mappings) - Bilinear caching correctness - Sparse/dense Pauli string conversion helpers @@ -24,19 +23,43 @@ import h5py import pytest -from qdk_chemistry._core.data import PauliTermAccumulator +from qdk_chemistry._core.data import ( + PauliTermAccumulator, + label_to_sparse_pauli_word, + sparse_pauli_word_to_label, +) from qdk_chemistry.data import MajoranaMapping # ─── Helpers ───────────────────────────────────────────────────────────── +def label_to_word(label: str) -> list[tuple[int, int]]: + """Convert a QubitHamiltonian label to a sparse Pauli word.""" + return label_to_sparse_pauli_word(label) + + +def word_to_label(word: list[tuple[int, int]], num_qubits: int) -> str: + """Convert a sparse Pauli word to a QubitHamiltonian label.""" + return sparse_pauli_word_to_label(word, num_qubits) + + +def table_labels(mapping: MajoranaMapping) -> tuple[str, ...]: + """Return dense labels for a mapping's sparse table.""" + return tuple(word_to_label(word, mapping.num_qubits) for word in mapping.table) + + +def bilinear_entries(mapping: MajoranaMapping) -> list[tuple[complex, list[tuple[int, int]]]]: + """Return upper-triangle bilinear entries in C++ row-major order.""" + return [mapping.bilinear(j, k) for j in range(2 * mapping.num_modes) for k in range(j + 1, 2 * mapping.num_modes)] + + def verify_clifford_algebra(mapping: MajoranaMapping) -> None: """Verify {gamma_i, gamma_j} = 2δ_{ij}·I for all pairs.""" n = 2 * mapping.num_modes for i in range(n): - wi = mapping.core(i) + wi = mapping.majorana(i) for j in range(i, n): - wj = mapping.core(j) + wj = mapping.majorana(j) phase_ij, word_ij = PauliTermAccumulator.multiply_uncached(wi, wj) phase_ji, word_ji = PauliTermAccumulator.multiply_uncached(wj, wi) @@ -69,13 +92,14 @@ def test_reference_n2(self) -> None: # gamma_1 = Y_0 → "IY" # gamma_2 = Z_0 X_1 → "XZ" (qubit 0 = Z, qubit 1 = X) # gamma_3 = Z_0 Y_1 → "YZ" - assert jw.table == ("IX", "IY", "XZ", "YZ") + assert table_labels(jw) == ("IX", "IY", "XZ", "YZ") def test_reference_n4(self) -> None: """JW n=4 spot check: gamma_6 = Z_0 Z_1 Z_2 X_3.""" jw = MajoranaMapping.jordan_wigner(num_modes=4) - assert jw.table[6] == "XZZZ" # X_3 Z_2 Z_1 Z_0 in little-endian - assert jw.table[7] == "YZZZ" # Y_3 Z_2 Z_1 Z_0 + labels = table_labels(jw) + assert labels[6] == "XZZZ" # X_3 Z_2 Z_1 Z_0 in little-endian + assert labels[7] == "YZZZ" # Y_3 Z_2 Z_1 Z_0 class TestBravyiKitaev: @@ -112,13 +136,13 @@ def test_reference_n2(self) -> None: # gamma_1 = Y_0 X_1 → "XY" (little-endian: qubit 0=Y, qubit 1=X) # gamma_2 = Z_0 X_1 → "XZ" # gamma_3 = Y_1 → "YI" - assert par.table == ("XX", "XY", "XZ", "YI") + assert table_labels(par) == ("XX", "XY", "XZ", "YI") def test_reference_n4(self) -> None: """Parity n=4 matches standard-convention reference.""" par = MajoranaMapping.parity(num_modes=4) expected = ("XXXX", "XXXY", "XXXZ", "XXYI", "XXZI", "XYII", "XZII", "YIII") - assert par.table == expected + assert table_labels(par) == expected # ─── Custom Mapping Tests ──────────────────────────────────────────────── @@ -129,17 +153,11 @@ class TestCustomMapping: def test_custom_from_table(self) -> None: """Custom mapping from table list.""" - custom = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"], name="my-jw") + custom = MajoranaMapping.from_table([label_to_word(s) for s in ("IX", "IY", "XZ", "YZ")], name="my-jw") assert custom.num_modes == 2 assert custom.num_qubits == 2 assert custom.name == "my-jw" - assert custom.table == ("IX", "IY", "XZ", "YZ") - - def test_from_mode_pairs(self) -> None: - """from_mode_pairs produces same result as direct table.""" - direct = MajoranaMapping(table=["IX", "IY", "XZ", "YZ"], name="test") - pairs = MajoranaMapping.from_mode_pairs(pairs=[("IX", "IY"), ("XZ", "YZ")], name="test") - assert direct.table == pairs.table + assert table_labels(custom) == ("IX", "IY", "XZ", "YZ") class TestFromBilinears: @@ -148,13 +166,7 @@ class TestFromBilinears: def test_bilinear_lookup_matches_table(self) -> None: """Bilinear-only mapping reproduces the same bilinears as the table form.""" jw = MajoranaMapping.jordan_wigner(num_modes=3) - bilinears: dict[tuple[int, int], tuple[complex, str]] = {} - for j in range(6): - for k in range(j + 1, 6): - coeff, pauli = jw.bilinear(j, k) - bilinears[(j, k)] = (coeff, pauli) - - bl = MajoranaMapping.from_bilinears(num_modes=3, bilinears=bilinears) + bl = MajoranaMapping.from_bilinears(num_modes=3, bilinears=bilinear_entries(jw)) for j in range(6): for k in range(j + 1, 6): c_bl, p_bl = bl.bilinear(j, k) @@ -165,12 +177,7 @@ def test_bilinear_lookup_matches_table(self) -> None: def test_bilinear_antisymmetry(self) -> None: """bilinear(k,j) = -bilinear(j,k) for bilinear-only mappings.""" jw = MajoranaMapping.jordan_wigner(num_modes=2) - bilinears: dict[tuple[int, int], tuple[complex, str]] = {} - for j in range(4): - for k in range(j + 1, 4): - bilinears[(j, k)] = jw.bilinear(j, k) - - bl = MajoranaMapping.from_bilinears(num_modes=2, bilinears=bilinears) + bl = MajoranaMapping.from_bilinears(num_modes=2, bilinears=bilinear_entries(jw)) for j in range(4): for k in range(j + 1, 4): c_fwd, p_fwd = bl.bilinear(j, k) @@ -181,42 +188,34 @@ def test_bilinear_antisymmetry(self) -> None: def test_majorana_raises_for_bilinear_only(self) -> None: """majorana(k) raises ValueError for bilinear-only mappings.""" jw = MajoranaMapping.jordan_wigner(num_modes=2) - bilinears: dict[tuple[int, int], tuple[complex, str]] = {} - for j in range(4): - for k in range(j + 1, 4): - bilinears[(j, k)] = jw.bilinear(j, k) - bl = MajoranaMapping.from_bilinears(num_modes=2, bilinears=bilinears) + bl = MajoranaMapping.from_bilinears(num_modes=2, bilinears=bilinear_entries(jw)) with pytest.raises(ValueError, match="bilinear-only"): bl.majorana(0) def test_num_qubits(self) -> None: """num_qubits is derived from the bilinear Pauli words.""" jw = MajoranaMapping.jordan_wigner(num_modes=4) - bilinears: dict[tuple[int, int], tuple[complex, str]] = {} - for j in range(8): - for k in range(j + 1, 8): - bilinears[(j, k)] = jw.bilinear(j, k) - bl = MajoranaMapping.from_bilinears(num_modes=4, bilinears=bilinears) + bl = MajoranaMapping.from_bilinears(num_modes=4, bilinears=bilinear_entries(jw)) assert bl.num_qubits == 4 def test_wrong_count_raises(self) -> None: """from_bilinears raises ValueError if entry count doesn't match num_modes.""" with pytest.raises(ValueError, match="upper-triangle"): - MajoranaMapping.from_bilinears(num_modes=2, bilinears={(0, 1): (1.0, "IZ")}) + MajoranaMapping.from_bilinears(num_modes=2, bilinears=[(1.0, label_to_word("IZ"))]) def test_missing_entry_raises(self) -> None: """from_bilinears raises ValueError if a required (j,k) pair is missing.""" - with pytest.raises(ValueError, match="upper-triangle bilinear entries"): + with pytest.raises(ValueError, match="upper-triangle entries"): MajoranaMapping.from_bilinears( num_modes=2, - bilinears={ - (0, 1): (1.0, "IZ"), - (0, 2): (1.0, "XZ"), - (0, 3): (1.0, "YZ"), - (1, 2): (1.0, "XI"), - # (1, 3) intentionally omitted - (2, 3): (1.0, "ZI"), - }, + bilinears=[ + (1.0, label_to_word("IZ")), + (1.0, label_to_word("XZ")), + (1.0, label_to_word("YZ")), + (1.0, label_to_word("XI")), + # One entry intentionally omitted. + (1.0, label_to_word("ZI")), + ], ) @@ -229,22 +228,17 @@ class TestValidation: def test_empty_table(self) -> None: """Empty table raises ValueError.""" with pytest.raises(ValueError, match="must not be empty"): - MajoranaMapping(table=[]) + MajoranaMapping.from_table([]) def test_odd_length_table(self) -> None: """Odd-length table raises ValueError.""" with pytest.raises(ValueError, match="even number"): - MajoranaMapping(table=["IX", "IY", "XZ"]) + MajoranaMapping.from_table([label_to_word(s) for s in ("IX", "IY", "XZ")]) def test_invalid_characters(self) -> None: """Non-IXYZ characters raise ValueError.""" with pytest.raises(ValueError, match="Invalid Pauli character"): - MajoranaMapping(table=["IX", "IA", "XZ", "YZ"]) - - def test_inconsistent_lengths(self) -> None: - """Strings of different lengths raise ValueError.""" - with pytest.raises(ValueError, match="same length"): - MajoranaMapping(table=["IX", "IYZ", "XZ", "YZ"]) + label_to_word("IA") def test_zero_modes(self) -> None: """Zero modes raises ValueError in factories.""" @@ -320,8 +314,6 @@ def test_scbk_has_tapering(self) -> None: scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) assert scbk.tapering is not None assert scbk.tapering.num_tapered == 2 - assert scbk.tapering.source_num_qubits == 8 - assert scbk.tapering.source_encoding == "bravyi-kitaev" def test_scbk_eigenvalues_depend_on_alpha_beta(self) -> None: """Different (n_alpha, n_beta) produce different eigenvalues.""" @@ -331,12 +323,12 @@ def test_scbk_eigenvalues_depend_on_alpha_beta(self) -> None: scbk_20 = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 0)) assert scbk_11.tapering.eigenvalues != scbk_20.tapering.eigenvalues - def test_scbk_num_qubits_is_posttaper_via_property(self) -> None: - """MajoranaMapping.num_qubits reflects the reduced qubit count.""" + def test_scbk_num_qubits_is_base_register_size(self) -> None: + """MajoranaMapping.num_qubits reflects the base encoding register.""" from qdk_chemistry.data import Symmetries # noqa: PLC0415 scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) - assert scbk.num_qubits == 6 + assert scbk.num_qubits == 8 def test_scbk_json_roundtrip(self) -> None: """SCBK mapping with tapering survives JSON serialization.""" @@ -374,7 +366,7 @@ def test_parity_with_symmetries_has_tapering(self) -> None: assert par.tapering.num_tapered == 2 assert par.name == "parity-2q-reduced" assert par.base_encoding == "parity" - assert par.num_qubits == 6 + assert par.num_qubits == 8 def test_without_tapering(self) -> None: """without_tapering strips tapering but preserves the base table.""" @@ -391,56 +383,9 @@ def test_without_tapering(self) -> None: # ─── Bilinear primitive ────────────────────────────────────────────────── -def _dense_to_sparse(s: str) -> list[tuple[int, str]]: - """Convert a dense little-endian Pauli string to a sparse word list. - - The dense representation is ``s[0]`` = highest-index qubit, ``s[-1]`` = - qubit 0. The sparse representation is a list of ``(qubit_index, gate)`` - pairs sorted by ``qubit_index``, omitting identities. - """ - n = len(s) - return [(n - 1 - i, c) for i, c in enumerate(s) if c != "I"] - - -def _sparse_to_dense(word: list[tuple[int, str]], n_qubits: int) -> str: - """Inverse of ``_dense_to_sparse``.""" - chars = ["I"] * n_qubits - for q, g in word: - chars[q] = g - return "".join(reversed(chars)) - - -# Single-qubit Pauli multiplication table: (a, b) -> (phase, c) where a*b = phase * c. -_PAULI_MULT = { - ("I", "I"): (1 + 0j, "I"), - ("I", "X"): (1 + 0j, "X"), - ("I", "Y"): (1 + 0j, "Y"), - ("I", "Z"): (1 + 0j, "Z"), - ("X", "I"): (1 + 0j, "X"), - ("X", "X"): (1 + 0j, "I"), - ("X", "Y"): (1j, "Z"), - ("X", "Z"): (-1j, "Y"), - ("Y", "I"): (1 + 0j, "Y"), - ("Y", "X"): (-1j, "Z"), - ("Y", "Y"): (1 + 0j, "I"), - ("Y", "Z"): (1j, "X"), - ("Z", "I"): (1 + 0j, "Z"), - ("Z", "X"): (1j, "Y"), - ("Z", "Y"): (-1j, "X"), - ("Z", "Z"): (1 + 0j, "I"), -} - - -def _multiply_dense(a: str, b: str) -> tuple[complex, str]: - """Multiply two dense little-endian Pauli strings of equal length.""" - assert len(a) == len(b) - phase: complex = 1 + 0j - chars: list[str] = [] - for ca, cb in zip(a, b, strict=True): - p, c = _PAULI_MULT[(ca, cb)] - phase *= p - chars.append(c) - return phase, "".join(chars) +def _multiply_words(a: list[tuple[int, int]], b: list[tuple[int, int]]) -> tuple[complex, list[tuple[int, int]]]: + """Multiply two sparse Pauli words.""" + return PauliTermAccumulator.multiply_uncached(a, b) _FACTORIES = [ @@ -464,7 +409,7 @@ def test_matches_majorana_product(self, name: str, factory, n_modes: int) -> Non for k in range(n): if j == k: continue - expected_phase, expected_word = _multiply_dense(m.majorana(j), m.majorana(k)) + expected_phase, expected_word = _multiply_words(m.majorana(j), m.majorana(k)) expected_coeff = 1j * expected_phase bcoeff, bword = m.bilinear(j, k) assert bword == expected_word, f"({j},{k}): word {bword} != {expected_word}" @@ -477,14 +422,13 @@ def test_squares_to_identity(self, name: str, factory, n_modes: int) -> None: del name m = factory(n_modes) n = 2 * n_modes - identity = "I" * len(m.table[0]) for j in range(n): for k in range(n): if j == k: continue coeff, word = m.bilinear(j, k) - phase, prod = _multiply_dense(word, word) - assert prod == identity, f"({j},{k}): word² = {prod}, not identity" + phase, prod = _multiply_words(word, word) + assert prod == [], f"({j},{k}): word² = {prod}, not identity" assert abs((coeff * coeff) * phase - 1.0) < 1e-12, ( f"({j},{k}): bilinear² coeff = {coeff * coeff * phase}" ) @@ -504,8 +448,8 @@ def test_commutation_relations(self, name: str, factory, n_modes: int) -> None: if shared == 2: continue c2, w2 = m.bilinear(j2, k2) - p_ab, w_ab = _multiply_dense(w1, w2) - p_ba, w_ba = _multiply_dense(w2, w1) + p_ab, w_ab = _multiply_words(w1, w2) + p_ba, w_ba = _multiply_words(w2, w1) assert w_ab == w_ba ab = c1 * c2 * p_ab ba = c2 * c1 * p_ba @@ -532,10 +476,15 @@ def test_hermitian_real_coefficient(self, name: str, factory, n_modes: int) -> N def test_jw_n2_known_values(self) -> None: """JW(num_modes=2): gamma_0=X_0, gamma_1=Y_0, gamma_2=Z_0 X_1, gamma_3=Z_0 Y_1.""" m = MajoranaMapping.jordan_wigner(num_modes=2) - assert m.bilinear(0, 1) == (-1 + 0j, "IZ") - assert m.bilinear(1, 0) == (1 + 0j, "IZ") - assert m.bilinear(0, 2) == (1 + 0j, "XY") - assert m.bilinear(2, 3) == (-1 + 0j, "ZI") + for (j, k), expected in { + (0, 1): (-1 + 0j, "IZ"), + (1, 0): (1 + 0j, "IZ"), + (0, 2): (1 + 0j, "XY"), + (2, 3): (-1 + 0j, "ZI"), + }.items(): + coeff, word = m.bilinear(j, k) + assert coeff == expected[0] + assert word_to_label(word, m.num_qubits) == expected[1] def test_raises_on_equal_indices(self) -> None: """``bilinear(j, j)`` is undefined and must raise ValueError.""" @@ -574,12 +523,11 @@ def test_pauli_string_length_with_tapering(self) -> None: from qdk_chemistry.data import Symmetries # noqa: PLC0415 scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) - assert len(scbk.table[0]) == 8 - assert scbk.num_qubits == 6 + assert scbk.num_qubits == 8 for j in range(2 * scbk.num_modes): - assert len(scbk.majorana(j)) == 8 + assert len(word_to_label(scbk.majorana(j), scbk.num_qubits)) == 8 for k in range(2 * scbk.num_modes): if j == k: continue _, w = scbk.bilinear(j, k) - assert len(w) == 8 + assert len(word_to_label(w, scbk.num_qubits)) == 8 diff --git a/python/tests/test_qdk_qubit_mapper.py b/python/tests/test_qdk_qubit_mapper.py index f9c1be302..0c857f03b 100644 --- a/python/tests/test_qdk_qubit_mapper.py +++ b/python/tests/test_qdk_qubit_mapper.py @@ -487,7 +487,7 @@ def test_custom_mapping_end_to_end(self) -> None: n_modes = 2 * 2 jw = MajoranaMapping.jordan_wigner(num_modes=n_modes) - custom = MajoranaMapping(table=list(jw.table), name="") + custom = MajoranaMapping.from_table(list(jw.table), name="") result_jw = mapper.run(hamiltonian, jw) result_custom = mapper.run(hamiltonian, custom) @@ -1129,13 +1129,12 @@ def test_scbk_carries_tapering_metadata(self) -> None: qh = create("qubit_mapper", "qdk").run(hamiltonian, mapping) assert qh.tapering is not None - assert qh.tapering.source_num_qubits == n assert qh.tapering.num_tapered == 2 def test_scbk_matches_two_step(self) -> None: """One-step symmetry-conserving BK matches explicit BK-tree + internal taper.""" + from qdk_chemistry.algorithms.qubit_mapper.qubit_mapper import QubitMapper # noqa: PLC0415 from qdk_chemistry.data import Symmetries # noqa: PLC0415 - from qdk_chemistry.data.tapering import taper_to_scbk # noqa: PLC0415 hamiltonian = create_nontrivial_test_hamiltonian() n = 2 * hamiltonian.get_one_body_integrals()[0].shape[0] @@ -1148,7 +1147,7 @@ def test_scbk_matches_two_step(self) -> None: # Two-step: use BK-tree (the base encoding for SCBK) bk_tree_mapping = MajoranaMapping.bravyi_kitaev_tree(n) qh_bk = create("qubit_mapper", "qdk").run(hamiltonian, bk_tree_mapping) - qh_two_step = taper_to_scbk(qh_bk, sym) + qh_two_step = QubitMapper._taper_result(qh_bk, scbk_mapping) assert qh_one_step.equiv(qh_two_step) diff --git a/python/tests/test_qubit_hamiltonian.py b/python/tests/test_qubit_hamiltonian.py index 0f1dde01e..4816cada5 100644 --- a/python/tests/test_qubit_hamiltonian.py +++ b/python/tests/test_qubit_hamiltonian.py @@ -673,13 +673,11 @@ class TestTaperingPropagation: @pytest.fixture def tapering(self): """Create a sample tapering specification.""" - from qdk_chemistry.data.tapering import TaperingSpecification # noqa: PLC0415 + from qdk_chemistry.data import TaperingSpecification # noqa: PLC0415 return TaperingSpecification( qubit_indices=(3, 1), eigenvalues=(1, -1), - source_num_qubits=4, - source_encoding="bravyi-kitaev-tree", ) @pytest.fixture @@ -705,13 +703,11 @@ def test_add_preserves_tapering(self, tapered_h, tapering): def test_add_mismatched_tapering_raises(self, tapered_h): """H1 + H2 with different tapering should raise ValueError.""" - from qdk_chemistry.data.tapering import TaperingSpecification # noqa: PLC0415 + from qdk_chemistry.data import TaperingSpecification # noqa: PLC0415 other_tapering = TaperingSpecification( qubit_indices=(3, 1), eigenvalues=(1, 1), - source_num_qubits=4, - source_encoding="bravyi-kitaev-tree", ) h2 = QubitHamiltonian( ["XX"], diff --git a/python/tests/test_tapering.py b/python/tests/test_tapering.py deleted file mode 100644 index f4fc83064..000000000 --- a/python/tests/test_tapering.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Tests for qubit tapering utilities.""" - -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See LICENSE.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -from __future__ import annotations - -import numpy as np -import pytest - -from qdk_chemistry.data import QubitHamiltonian, Symmetries -from qdk_chemistry.data.tapering import TaperingSpecification, taper_qubits, taper_to_scbk - -from .reference_tolerances import ( - float_comparison_absolute_tolerance, - orthonormality_error_tolerance, -) - -# ------------------------------------------------------------------------------------- -# taper_qubits — core tests -# ------------------------------------------------------------------------------------- - - -class TestTaperQubits: - """Tests for the taper_qubits free function.""" - - def test_single_qubit_z_eigenvalue_positive(self) -> None: - """Tapering a Z on a qubit with eigenvalue +1 leaves coefficient unchanged.""" - qh = QubitHamiltonian(pauli_strings=["ZI", "IZ"], coefficients=np.array([1.0, 2.0])) - result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) - assert result.num_qubits == 1 - assert "Z" in result.pauli_strings or "I" in result.pauli_strings - - def test_single_qubit_z_eigenvalue_negative(self) -> None: - """Tapering a Z on a qubit with eigenvalue -1 flips the coefficient sign.""" - qh = QubitHamiltonian(pauli_strings=["ZI"], coefficients=np.array([1.0])) - result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[-1]) - assert result.num_qubits == 1 - assert np.isclose(result.coefficients[0], -1.0) - - def test_x_on_tapered_qubit_drops_term(self) -> None: - """Terms with X on a tapered qubit are removed.""" - qh = QubitHamiltonian(pauli_strings=["XI", "IZ"], coefficients=np.array([1.0, 2.0])) - result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) - assert result.num_qubits == 1 - assert len(result.pauli_strings) == 1 - - def test_y_on_tapered_qubit_drops_term(self) -> None: - """Terms with Y on a tapered qubit are removed.""" - qh = QubitHamiltonian(pauli_strings=["YI", "IZ"], coefficients=np.array([1.0, 2.0])) - result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) - assert result.num_qubits == 1 - - def test_identity_on_tapered_qubit_preserved(self) -> None: - """Terms with I on a tapered qubit are kept as-is.""" - qh = QubitHamiltonian(pauli_strings=["IZ", "II"], coefficients=np.array([1.0, 2.0])) - result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) - assert result.num_qubits == 1 - assert len(result.pauli_strings) == 2 - - def test_two_qubits_tapered(self) -> None: - """Tapering two qubits reduces qubit count by 2.""" - qh = QubitHamiltonian(pauli_strings=["IIII", "ZIZI", "IZIZ"], coefficients=np.array([1.0, 0.5, 0.3])) - result = taper_qubits(qh, qubit_indices=[0, 3], eigenvalues=[1, 1]) - assert result.num_qubits == 2 - - def test_duplicate_terms_merged(self) -> None: - """After tapering, identical Pauli strings are merged.""" - qh = QubitHamiltonian(pauli_strings=["ZI", "II"], coefficients=np.array([1.0, 2.0])) - result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) - # ZI → I (coeff 1.0) and II → I (coeff 2.0) should merge to I (coeff 3.0) - assert len(result.pauli_strings) == 1 - assert np.isclose(result.coefficients[0], 3.0) - - def test_encoding_preserved(self) -> None: - """Encoding metadata is preserved through tapering.""" - qh = QubitHamiltonian(pauli_strings=["ZI", "IZ"], coefficients=np.array([1.0, 2.0]), encoding="bravyi-kitaev") - result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) - assert result.encoding == "bravyi-kitaev" - - def test_mismatched_lengths_raises(self) -> None: - """ValueError when qubit_indices and eigenvalues have different lengths.""" - qh = QubitHamiltonian(pauli_strings=["ZI"], coefficients=np.array([1.0])) - with pytest.raises(ValueError, match="length"): - taper_qubits(qh, qubit_indices=[0, 1], eigenvalues=[1]) - - def test_out_of_range_index_raises(self) -> None: - """ValueError for qubit index out of range.""" - qh = QubitHamiltonian(pauli_strings=["ZI"], coefficients=np.array([1.0])) - with pytest.raises(ValueError, match="out of range"): - taper_qubits(qh, qubit_indices=[5], eigenvalues=[1]) - - def test_invalid_eigenvalue_raises(self) -> None: - """ValueError for eigenvalue that is not ±1.""" - qh = QubitHamiltonian(pauli_strings=["ZI"], coefficients=np.array([1.0])) - with pytest.raises(ValueError, match="must be"): - taper_qubits(qh, qubit_indices=[0], eigenvalues=[0]) - - def test_duplicate_indices_raises(self) -> None: - """ValueError for duplicate qubit indices.""" - qh = QubitHamiltonian(pauli_strings=["ZII"], coefficients=np.array([1.0])) - with pytest.raises(ValueError, match="duplicate"): - taper_qubits(qh, qubit_indices=[0, 0], eigenvalues=[1, -1]) - - def test_all_terms_eliminated_returns_zero(self) -> None: - """When all terms are eliminated, return a zero-coefficient identity operator.""" - qh = QubitHamiltonian(pauli_strings=["XI"], coefficients=np.array([1.0])) - result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) - assert result.num_qubits == 1 - assert len(result.pauli_strings) == 1 - assert result.pauli_strings[0] == "I" - assert np.isclose(result.coefficients[0], 0.0) - - def test_full_cancellation_returns_zero(self) -> None: - """When terms cancel to zero after merging, return a zero-coefficient identity.""" - # Z with +1 eigenvalue → +1.0·I, and I → -1.0·I → merge to 0 - qh = QubitHamiltonian(pauli_strings=["ZI", "II"], coefficients=np.array([1.0, -1.0])) - result = taper_qubits(qh, qubit_indices=[1], eigenvalues=[1]) - assert result.num_qubits == 1 - assert len(result.pauli_strings) == 1 - assert result.pauli_strings[0] == "I" - assert np.isclose(result.coefficients[0], 0.0) - - -# ------------------------------------------------------------------------------------- -# taper_to_scbk — two-step SCBK tests -# ------------------------------------------------------------------------------------- - - -class TestTaperToScbk: - """Tests for the taper_to_scbk two-step function.""" - - def test_output_has_tapering_attribute(self) -> None: - """taper_to_scbk sets tapering metadata on the output QubitHamiltonian.""" - qh = QubitHamiltonian( - pauli_strings=["IIII", "ZIZI", "IZIZ", "ZZII"], - coefficients=np.array([1.0, 0.5, 0.3, 0.2]), - encoding="bravyi-kitaev-tree", - ) - symmetries = Symmetries(n_alpha=1, n_beta=1) - result = taper_to_scbk(qh, symmetries) - - assert result.tapering is not None - assert isinstance(result.tapering, TaperingSpecification) - assert result.tapering.qubit_indices == (1, 3) - assert result.tapering.source_num_qubits == 4 - assert result.tapering.source_encoding == "bravyi-kitaev-tree" - assert result.encoding == "symmetry-conserving-bravyi-kitaev" - - def test_output_tapering_eigenvalues(self) -> None: - """Tapering eigenvalues match the symmetry sector.""" - qh = QubitHamiltonian( - pauli_strings=["IIII", "ZIII"], - coefficients=np.array([1.0, 0.5]), - encoding="bravyi-kitaev", - ) - symmetries = Symmetries(n_alpha=1, n_beta=0) - result = taper_to_scbk(qh, symmetries) - - # n_alpha=1 (odd) → ev_alpha=-1, n_total=1 (odd) → ev_total=-1 - assert result.tapering is not None - assert result.tapering.eigenvalues == (-1, -1) - - def test_tapered_eigenvalues_subset_of_full(self) -> None: - """Tapered Hamiltonian eigenvalues are a subset of the full BK eigenvalues.""" - base_strings = ["IIII", "IIIZ", "IIZZ", "IZII", "ZZZI"] - base_coeffs = [2.0, -0.5, -0.5, -0.5, -0.5] - - qh_clean = QubitHamiltonian( - pauli_strings=base_strings, - coefficients=np.array(base_coeffs), - encoding="bravyi-kitaev", - ) - - qh_with_xy = QubitHamiltonian( - pauli_strings=[*base_strings, "IIXI", "IIYI", "XIIX"], - coefficients=np.array([*base_coeffs, 0.3, 0.3, 0.2]), - encoding="bravyi-kitaev", - ) - - symmetries = Symmetries(n_alpha=1, n_beta=1) - result_clean = taper_to_scbk(qh_clean, symmetries) - result_with_xy = taper_to_scbk(qh_with_xy, symmetries) - - # X/Y terms on tapered qubits must be dropped — results should be identical - assert result_with_xy.num_qubits == 2 - assert result_clean.pauli_strings == result_with_xy.pauli_strings - np.testing.assert_allclose( - result_clean.coefficients, result_with_xy.coefficients, atol=float_comparison_absolute_tolerance - ) - - # Also verify eigenvalue subset for the symmetry-preserving Hamiltonian - full_eigs = np.linalg.eigvalsh(qh_clean.to_matrix()) - tapered_eigs = np.linalg.eigvalsh(result_clean.to_matrix()) - for e in tapered_eigs: - assert np.any(np.isclose(full_eigs, e, atol=orthonormality_error_tolerance)) From ca0b6a2e0d5b7b5bb16f1cc303f1301d74af403e Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 1 Jun 2026 03:08:30 +0000 Subject: [PATCH 109/117] docs: simplify dispatch jargon, update notebook APIs, add tapering tests - Replace 'name-dispatched' terminology with 'third-party backends' throughout docs and docstrings for clarity (qubit_mapper.py, qubit_mapper.rst, openfermion/qubit_mapper.py, qiskit/qubit_mapper.py, majorana_mapping.hpp) - Rewrite majorana_mapping.rst bilinears section with clearer language and a prominent reference to Bravyi & Kitaev (2002) - Update 3 example notebooks (extended_hubbard, qpe_stretched_n2, state_prep_energy) from old encoding= API to new MajoranaMapping API - Add TaperingSpecification serialization roundtrip tests (JSON, HDF5) - Rename RST anchor from backend-dispatch-contract to extending-implementations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../qdk/chemistry/data/majorana_mapping.hpp | 2 +- .../comprehensive/algorithms/qubit_mapper.rst | 30 +++---- .../comprehensive/data/majorana_mapping.rst | 17 ++-- examples/extended_hubbard.ipynb | 63 ++++++------- examples/qpe_stretched_n2.ipynb | 11 ++- examples/state_prep_energy.ipynb | 23 ++--- .../algorithms/qubit_mapper/qubit_mapper.py | 90 +++++++++---------- .../plugins/openfermion/qubit_mapper.py | 22 ++--- .../plugins/qiskit/qubit_mapper.py | 24 ++--- python/tests/test_majorana_mapping.py | 60 +++++++++++++ 10 files changed, 206 insertions(+), 136 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index 7c4559b6f..8ce1fe75f 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -92,7 +92,7 @@ class MajoranaMapping : public DataClass { /// Encoding name (may be empty for custom encodings). const std::string& name() const { return name_; } - /// Underlying table encoding name used by name-dispatched plugin backends. + /// Encoding name used by third-party plugin backends to select their own transform. const std::string& base_encoding() const { return base_encoding_; } /// Optional post-mapping tapering specification. diff --git a/docs/source/user/comprehensive/algorithms/qubit_mapper.rst b/docs/source/user/comprehensive/algorithms/qubit_mapper.rst index 11c9c65d1..110a65792 100644 --- a/docs/source/user/comprehensive/algorithms/qubit_mapper.rst +++ b/docs/source/user/comprehensive/algorithms/qubit_mapper.rst @@ -132,12 +132,12 @@ You can discover available implementations programmatically: :start-after: # start-cell-list-implementations :end-before: # end-cell-list-implementations -.. _backend-dispatch-contract: +.. _extending-implementations: Details for extending implementations ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Implementations fall into two categories that use the +Implementations fall into two groups. They use the :class:`~qdk_chemistry.data.MajoranaMapping` argument differently: **Table-driven backends** (QDK native) @@ -145,27 +145,27 @@ Implementations fall into two categories that use the pass it to the C++ mapping engine. Any valid table works, including custom encodings that have no standard name. -**Name-dispatched backends** (OpenFermion, Qiskit) - **Ignore the Pauli table entirely.** They read +**Third-party backends** (OpenFermion, Qiskit) + **Ignore the Pauli table.** They read :attr:`~qdk_chemistry.data.MajoranaMapping.base_encoding` — a string - like ``"jordan-wigner"`` or ``"bravyi-kitaev-tree"`` — and use it to - look up the corresponding transform in their own library. The qubit - operator is then built from scratch using the third-party library's own - fermion-to-qubit pipeline. + like ``"jordan-wigner"`` or ``"bravyi-kitaev-tree"`` — and pass it to + their own library to select the matching transform. The qubit + operator is then built from scratch using the third-party library's + own fermion-to-qubit code. This distinction has practical consequences: - **Custom mappings** (user-defined Pauli tables) work with the QDK - backend but **cannot** be used with name-dispatched backends, which - have no way to interpret an arbitrary table. + backend but **cannot** be used with third-party backends, which have + no way to interpret an arbitrary table. - **Consistency is assumed, not verified.** Factory-produced mappings (e.g. ``MajoranaMapping.jordan_wigner()``) guarantee that the Pauli table and the ``base_encoding`` name describe the same encoding. Cross-backend eigenvalue tests in the test suite verify this for every supported factory × backend combination. However, if a - ``MajoranaMapping`` is manually constructed with a table that does not - match its name, a name-dispatched backend will silently use the wrong + ``MajoranaMapping`` is manually built with a table that does not + match its name, a third-party backend will silently use the wrong transform. - **Tapering** is each backend's responsibility. The base class provides @@ -184,7 +184,7 @@ QDK Native QDK/Chemistry qubit mapping implementation built on the :doc:`PauliOperator <../data/pauli_operator>` expression layer. This is a **table-driven** backend: it reads the Pauli-string table from the :class:`~qdk_chemistry.data.MajoranaMapping` and passes it directly to the C++ mapping engine. Any valid ``MajoranaMapping`` works — factory-produced or custom user-defined tables. -The mapping's ``name`` and ``base_encoding`` are used only for metadata on the output, not for dispatch. +The mapping's ``name`` and ``base_encoding`` are used only for metadata on the output, not to select a transform. Supported encodings: :ref:`Jordan-Wigner `, :ref:`Bravyi-Kitaev `, :ref:`Bravyi-Kitaev tree `, :ref:`Parity `, :ref:`SCBK `, and any custom encoding @@ -228,7 +228,7 @@ Qiskit .. rubric:: Factory name: ``"qiskit"`` Qubit mapping implementation integrated through the Qiskit plugin. -This is a **name-dispatched** backend: it reads ``mapping.base_encoding`` to select a Qiskit Nature mapper class and **ignores the Pauli table** (see :ref:`backend-dispatch-contract`). +This is a **third-party** backend: it reads ``mapping.base_encoding`` to select a Qiskit Nature mapper class and **ignores the Pauli table** (see :ref:`extending-implementations`). Supported base encodings: :ref:`Jordan-Wigner `, :ref:`Bravyi-Kitaev `, :ref:`Parity ` @@ -246,7 +246,7 @@ OpenFermion .. rubric:: Factory name: ``"openfermion"`` Qubit mapping implementation integrated through the OpenFermion plugin. -This is a **name-dispatched** backend: it reads ``mapping.base_encoding`` to select an OpenFermion transform function and **ignores the Pauli table** (see :ref:`backend-dispatch-contract`). +This is a **third-party** backend: it reads ``mapping.base_encoding`` to select an OpenFermion transform function and **ignores the Pauli table** (see :ref:`extending-implementations`). Supported base encodings: :ref:`Jordan-Wigner `, :ref:`Bravyi-Kitaev `, :ref:`Bravyi-Kitaev tree ` diff --git a/docs/source/user/comprehensive/data/majorana_mapping.rst b/docs/source/user/comprehensive/data/majorana_mapping.rst index a3871abc6..028c17f7b 100644 --- a/docs/source/user/comprehensive/data/majorana_mapping.rst +++ b/docs/source/user/comprehensive/data/majorana_mapping.rst @@ -14,12 +14,19 @@ The :class:`~qdk_chemistry.data.MajoranaMapping` class encapsulates such an enco Bilinears as the unified primitive ---------------------------------- -Across fermion-to-qubit encodings, the most general primitive that admits a Pauli-string image is the **bilinear** :math:`i\,\gamma_j\,\gamma_k`. -Bilinears generate the parity-even subalgebra of the Majorana Clifford algebra, so any parity-conserving operator decomposes into ordered bilinear products, and higher-degree even monomials are products of bilinears. -This Majorana-operator viewpoint follows the fermion-to-qubit encoding formalism described by `Bravyi and Kitaev `_. +Every fermion-to-qubit encoding can express the **bilinear** product :math:`i\,\gamma_j\,\gamma_k` as a Pauli string. +This makes the bilinear the most general building block that all encodings share. -Individual Majorana operators :math:`\gamma_k` have a Pauli image only in **Majorana-atomic** encodings. -In **bilinear-only** encodings (where :math:`m > n` qubits represent :math:`n` modes), single Majoranas have no representation in the physical subspace; only the bilinears are observable. +Why bilinears? Physical (parity-conserving) fermionic operators can always be +written as products of bilinears, so any Hamiltonian can be mapped through +bilinears alone — even if the encoding does not assign a Pauli image to +individual Majorana operators :math:`\gamma_k`. + +For the formal foundations, see `Bravyi and Kitaev (2002) `_, +which develops the Majorana-operator perspective on fermion-to-qubit encodings. + +Individual Majorana operators :math:`\gamma_k` have a Pauli image only in **Majorana-atomic** encodings (e.g. Jordan-Wigner, Bravyi-Kitaev, Parity). +In **bilinear-only** encodings — where the number of qubits exceeds the number of fermionic modes — single Majoranas have no representation in the physical subspace; only the bilinears are observable. The :class:`~qdk_chemistry.data.MajoranaMapping` supports both forms: diff --git a/examples/extended_hubbard.ipynb b/examples/extended_hubbard.ipynb index b0c952cde..474ff2f36 100644 --- a/examples/extended_hubbard.ipynb +++ b/examples/extended_hubbard.ipynb @@ -9,10 +9,10 @@ "\n", "## Background: cyclobutadiene and the automerization problem\n", "\n", - "Cyclobutadiene (C₄H₄) is the smallest cyclic molecule with 4 π-electrons, making it the prototypical **antiaromatic** system.\n", - "Unlike benzene (6 π-electrons, aromatic, stable), cyclobutadiene is highly reactive and has never been isolated at room temperature.\n", + "Cyclobutadiene (C\u2084H\u2084) is the smallest cyclic molecule with 4 \u03c0-electrons, making it the prototypical **antiaromatic** system.\n", + "Unlike benzene (6 \u03c0-electrons, aromatic, stable), cyclobutadiene is highly reactive and has never been isolated at room temperature.\n", "\n", - "Hückel's rule predicts that cyclic molecules with 4*n* π-electrons (here *n* = 1) are destabilized by electron delocalization\n", + "H\u00fcckel's rule predicts that cyclic molecules with 4*n* \u03c0-electrons (here *n* = 1) are destabilized by electron delocalization\n", "rather than stabilized.\n", "This is directly observable in cyclobutadiene's geometry: the molecule distorts from a symmetric square ($D_{4h}$) to a\n", "rectangle ($D_{2h}$), alternating between two equivalent rectangular structures through a process called\n", @@ -22,7 +22,7 @@ "\n", "The square geometry is the **transition state** of this reaction.\n", "At this geometry, two frontier orbitals become degenerate (equal in energy), meaning a single Slater determinant (i.e., a mean-field approach like Hartree-Fock) cannot correctly describe the electronic structure.\n", - "This makes square cyclobutadiene a compact, well-understood test case for **strongly correlated** quantum chemistry methods — including quantum algorithms.\n", + "This makes square cyclobutadiene a compact, well-understood test case for **strongly correlated** quantum chemistry methods \u2014 including quantum algorithms.\n", "\n", "This notebook demonstrates a complete workflow from defining the chemical problem, through deriving a model Hamiltonian, to estimating the ground-state energy using iterative Quantum Phase Estimation (iQPE).\n", "The companion notebook [`qpe_stretched_n2.ipynb`](qpe_stretched_n2.ipynb) walks through a similar workflow using the full *ab initio* Hamiltonian; this notebook focuses on the complementary approach of mapping the problem onto a simpler **model Hamiltonian**.\n", @@ -66,17 +66,17 @@ "That approach is general and accurate, but it comes at a cost: the number of qubits and quantum gates grows with the size of the molecular orbital basis set, which can make even modest molecules expensive to simulate.\n", "\n", "An alternative is to **map the essential physics onto a simpler model Hamiltonian** that captures the key interactions with far fewer degrees of freedom.\n", - "For cyclobutadiene's automerization, the relevant physics lives in the 4 carbon π-orbitals. Rather than treating all electrons and orbitals, we can describe the system with a 4-site lattice model.\n", + "For cyclobutadiene's automerization, the relevant physics lives in the 4 carbon \u03c0-orbitals. Rather than treating all electrons and orbitals, we can describe the system with a 4-site lattice model.\n", "\n", - "The [Extended Hubbard model](https://en.wikipedia.org/wiki/Hubbard_model) is a natural choice for conjugated π-systems.\n", + "The [Extended Hubbard model](https://en.wikipedia.org/wiki/Hubbard_model) is a natural choice for conjugated \u03c0-systems.\n", "It retains three physically motivated parameters:\n", "- **$t$ (hopping)**: the energy for an electron to hop between neighboring sites (analogous to bond strength)\n", "- **$U$ (on-site repulsion)**: the energy penalty when two electrons occupy the same site (governs correlation strength)\n", - "- **$V$ (nearest-neighbor repulsion)**: the Coulomb repulsion between electrons on adjacent sites (important in π-systems where charge fluctuations extend beyond a single atom)\n", + "- **$V$ (nearest-neighbor repulsion)**: the Coulomb repulsion between electrons on adjacent sites (important in \u03c0-systems where charge fluctuations extend beyond a single atom)\n", "\n", "This reduction from a full molecular Hamiltonian (many orbitals, many integrals) to 3 effective parameters has two practical benefits:\n", "\n", - "1. **Fewer qubits**: 4 sites × 2 spins = 8 qubits, compared to potentially dozens for the full orbital basis\n", + "1. **Fewer qubits**: 4 sites \u00d7 2 spins = 8 qubits, compared to potentially dozens for the full orbital basis\n", "2. **Physical insight**: the ratio $U/t$ directly tells you how strongly correlated the system is: large $U/t$ means electrons strongly avoid each other, which is exactly the regime where classical mean-field methods fail and quantum algorithms can provide an advantage\n", "\n", "The key question is: *can we derive these parameters from first principles rather than fitting them by hand?*\n", @@ -91,8 +91,8 @@ "## Deriving the model from the molecule\n", "\n", "The first step is to load the molecular structure.\n", - "Cyclobutadiene in its square ($D_{4h}$) geometry has all four C–C bonds equal in length — this is the transition state of the automerization reaction.\n", - "The 4 hydrogen atoms complete the valence of each carbon but play no role in the π-system.\n", + "Cyclobutadiene in its square ($D_{4h}$) geometry has all four C\u2013C bonds equal in length \u2014 this is the transition state of the automerization reaction.\n", + "The 4 hydrogen atoms complete the valence of each carbon but play no role in the \u03c0-system.\n", "\n", "We load the structure from an [XYZ-format](https://en.wikipedia.org/wiki/XYZ_file_format) file and visualize it to confirm the square geometry." ] @@ -120,14 +120,14 @@ "id": "4faa20ba", "metadata": {}, "source": [ - "## Identifying the π-orbitals\n", + "## Identifying the \u03c0-orbitals\n", "\n", "In planar organic molecules like cyclobutadiene, the electron orbitals separate into two types:\n", - "- **σ-orbitals** lie in the molecular plane and form the strong C–C and C–H single bonds that define the molecular skeleton\n", - "- **π-orbitals** extend above and below the plane, formed by the overlap of carbon $p_z$ atomic orbitals (perpendicular to the ring)\n", + "- **\u03c3-orbitals** lie in the molecular plane and form the strong C\u2013C and C\u2013H single bonds that define the molecular skeleton\n", + "- **\u03c0-orbitals** extend above and below the plane, formed by the overlap of carbon $p_z$ atomic orbitals (perpendicular to the ring)\n", "\n", - "The chemistry of interest (bond alternation, antiaromaticity, and strong correlation) lives entirely in the π-system.\n", - "Our goal is to isolate these 4 π-orbitals (one per carbon) from the full set of molecular orbitals, so we can build a 4-site model Hamiltonian.\n", + "The chemistry of interest (bond alternation, antiaromaticity, and strong correlation) lives entirely in the \u03c0-system.\n", + "Our goal is to isolate these 4 \u03c0-orbitals (one per carbon) from the full set of molecular orbitals, so we can build a 4-site model Hamiltonian.\n", "\n", "First, we run a Hartree-Fock (HF) self-consistent field calculation to obtain a set of molecular orbitals.\n", "We use the minimal STO-3G basis set, which is sufficient for extracting the model parameters." @@ -184,9 +184,9 @@ "id": "0e10e07c", "metadata": {}, "source": [ - "To select the π-orbitals automatically, we use the Automated Valence Active Space (AVAS) method.\n", + "To select the \u03c0-orbitals automatically, we use the Automated Valence Active Space (AVAS) method.\n", "AVAS works by projecting the molecular orbitals onto a target set of atomic orbitals (here, the carbon $2p_z$ orbitals) and selecting those with the largest overlap.\n", - "This gives us a CAS(4,4) active space: **4 electrons** in **4 orbitals**, corresponding to the 4 π-electrons distributed across 4 carbon sites." + "This gives us a CAS(4,4) active space: **4 electrons** in **4 orbitals**, corresponding to the 4 \u03c0-electrons distributed across 4 carbon sites." ] }, { @@ -198,7 +198,7 @@ "source": [ "import qdk_chemistry.plugins.pyscf # Enable PySCF plugin # noqa: F401\n", "\n", - "# Use AVAS to select the π-orbitals by projecting onto the carbon 2pz atomic orbitals\n", + "# Use AVAS to select the \u03c0-orbitals by projecting onto the carbon 2pz atomic orbitals\n", "avas_selector = create(\"active_space_selector\", \"pyscf_avas\", ao_labels=[\"C 2pz\"])\n", "avas_wfn = avas_selector.run(wfn_hf)\n", "indices, _ = avas_wfn.get_orbitals().get_active_space_indices()\n", @@ -213,9 +213,9 @@ "metadata": {}, "source": [ "We can verify the selection by visualizing the orbitals.\n", - "For a 4-site cyclic π-system, we expect the familiar nodal pattern from Hückel theory: one orbital with no nodes, two orbitals with one node each, and one orbital with two nodes.\n", + "For a 4-site cyclic \u03c0-system, we expect the familiar nodal pattern from H\u00fcckel theory: one orbital with no nodes, two orbitals with one node each, and one orbital with two nodes.\n", "In the square ($D_{4h}$) geometry, the two single-node orbitals are related by the $C_4$ rotational symmetry of the ring, making them symmetry-equivalent.\n", - "With 4 π-electrons to distribute, exactly 2 electrons must be shared between these two equivalent orbitals. No single assignment is preferred by symmetry, which is the root cause of the strong correlation in this system." + "With 4 \u03c0-electrons to distribute, exactly 2 electrons must be shared between these two equivalent orbitals. No single assignment is preferred by symmetry, which is the root cause of the strong correlation in this system." ] }, { @@ -249,7 +249,7 @@ "The canonical orbitals above are delocalized over the entire ring, which is the natural output of a Hartree-Fock calculation.\n", "However, the Extended Hubbard model describes electrons hopping between **localized sites**, so we need one orbital per carbon atom.\n", "\n", - "We apply a Pipek-Mezey localization to transform the 4 delocalized π-orbitals into 4 site-localized orbitals, each centered on a single carbon.\n", + "We apply a Pipek-Mezey localization to transform the 4 delocalized \u03c0-orbitals into 4 site-localized orbitals, each centered on a single carbon.\n", "These localized orbitals will serve as the \"sites\" in our model Hamiltonian." ] }, @@ -286,13 +286,13 @@ "source": [ "## Extracting Extended Hubbard parameters from the molecule\n", "\n", - "With localized π-orbitals in hand, we can now read off the Extended Hubbard parameters directly from the *ab initio* Hamiltonian integrals.\n", + "With localized \u03c0-orbitals in hand, we can now read off the Extended Hubbard parameters directly from the *ab initio* Hamiltonian integrals.\n", "\n", "The full Extended Hubbard Hamiltonian is:\n", "\n", "$$\\hat{H}_\\text{Ext. Hubbard} = \\varepsilon \\sum_i \\hat{n}_i - t\\sum_{\\langle i,j \\rangle} (\\hat{a}_i^\\dagger \\hat{a}_j + \\text{h.c.}) + U \\sum_i \\hat{n}_{i\\uparrow} \\hat{n}_{i\\downarrow} + \\frac{1}{2}\\sum_{\\langle i,j \\rangle} V_{ij}\\, (\\hat{n}_i - z_i)(\\hat{n}_j - z_j)$$\n", "\n", - "where $\\hat{n}_i = \\hat{n}_{i\\uparrow} + \\hat{n}_{i\\downarrow}$ is the electron count on site $i$, and $z_i = 1$ is the effective nuclear charge (one π-electron per carbon). For a review of this model and the closely related Pariser-Parr-Pople (PPP) Hamiltonian, see [Fabian, Glaser and Solomon, *Digital Discovery*, 2026, **5**, 482–496](https://doi.org/10.1039/D5DD00445D).\n", + "where $\\hat{n}_i = \\hat{n}_{i\\uparrow} + \\hat{n}_{i\\downarrow}$ is the electron count on site $i$, and $z_i = 1$ is the effective nuclear charge (one \u03c0-electron per carbon). For a review of this model and the closely related Pariser-Parr-Pople (PPP) Hamiltonian, see [Fabian, Glaser and Solomon, *Digital Discovery*, 2026, **5**, 482\u2013496](https://doi.org/10.1039/D5DD00445D).\n", "\n", "We extract the parameters as follows:\n", "- **$t$** (hopping): the largest off-diagonal element of the one-body integral matrix for each orbital identifies its nearest neighbor, the average of these gives $t$\n", @@ -349,7 +349,7 @@ "source": [ "### Building the lattice Hamiltonian\n", "\n", - "Cyclobutadiene's π-system has a **ring topology**: each carbon is bonded to two neighbors, forming a cyclic 4-site chain.\n", + "Cyclobutadiene's \u03c0-system has a **ring topology**: each carbon is bonded to two neighbors, forming a cyclic 4-site chain.\n", "We represent this as a periodic lattice and compute the remaining parameter, the nearest-neighbor repulsion $V$, using the **Ohno potential** that transitions smoothly between the on-site $U$ at zero distance and the classical $1/R$ Coulomb interaction at large separations.\n", "\n", "The `create_ppp_hamiltonian` function assembles the full Extended Hubbard Hamiltonian from these ingredients." @@ -386,7 +386,7 @@ "h1 = hamiltonian.get_one_body_integrals()[0] # one-body integral matrix\n", "Vij = V[0, 1]\n", "\n", - "print(f\"Extended Hubbard model: {lattice.num_sites} sites (ring), {n_active_alpha}α + {n_active_beta}β electrons\")\n", + "print(f\"Extended Hubbard model: {lattice.num_sites} sites (ring), {n_active_alpha}\u03b1 + {n_active_beta}\u03b2 electrons\")\n", "print(f\"One-body integrals:\\n{np.round(h1, 4)}\")\n", "print(f\"Two-body integrals (U): {U:.4f}\")\n", "print(f\"Two-body integrals (V): {Vij:.4f}\")\n", @@ -442,7 +442,7 @@ "## Preparing a trial state for quantum phase estimation\n", "\n", "Quantum phase estimation requires an initial **trial state** that has significant overlap with the true ground state.\n", - "We construct a sparse trial state from the dominant configurations of the exact wavefunction — this is physically motivated by the determinant structure we observed above. For this particular problem, there are two configurations with large magnitude and four more with significant magnitude, leading to a total of 6 configurations which comprise the majority of overlap with the exact state.\n", + "We construct a sparse trial state from the dominant configurations of the exact wavefunction \u2014 this is physically motivated by the determinant structure we observed above. For this particular problem, there are two configurations with large magnitude and four more with significant magnitude, leading to a total of 6 configurations which comprise the majority of overlap with the exact state.\n", "\n", "For a detailed discussion of trial-state preparation and the trade-offs involved, see the companion notebook [`qpe_stretched_n2.ipynb`](qpe_stretched_n2.ipynb)." ] @@ -519,7 +519,7 @@ "We now use iterative Quantum Phase Estimation (iQPE) to estimate the ground-state energy of the Extended Hubbard model on a quantum simulator.\n", "For details on how iQPE works (ancilla qubit, controlled-$U^{2^k}$ applications, phase feedback), see [`qpe_stretched_n2.ipynb`](qpe_stretched_n2.ipynb).\n", "\n", - "The key advantage of running QPE on the model Hamiltonian rather than the full molecular Hamiltonian is **circuit depth**: with only 8 qubits (4 sites × 2 spins), the circuits are significantly shallower and more amenable to near-term fault-tolerant hardware." + "The key advantage of running QPE on the model Hamiltonian rather than the full molecular Hamiltonian is **circuit depth**: with only 8 qubits (4 sites \u00d7 2 spins), the circuits are significantly shallower and more amenable to near-term fault-tolerant hardware." ] }, { @@ -537,9 +537,12 @@ "metadata": {}, "outputs": [], "source": [ + "from qdk_chemistry.data import MajoranaMapping\n", + "\n", "# Prepare the qubit-mapped Hamiltonian\n", - "qubit_mapper = create(\"qubit_mapper\", algorithm_name=\"qdk\", encoding=\"jordan-wigner\")\n", - "qubit_hamiltonian = qubit_mapper.run(hamiltonian)\n", + "n_spin_orbitals = 2 * n_sites\n", + "qubit_mapper = create(\"qubit_mapper\", \"qdk\")\n", + "qubit_hamiltonian = qubit_mapper.run(hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals))\n", "print(\"Qubit Hamiltonian:\\n\", qubit_hamiltonian.get_summary())" ] }, @@ -792,4 +795,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/examples/qpe_stretched_n2.ipynb b/examples/qpe_stretched_n2.ipynb index e7fc4ed32..1295a890c 100644 --- a/examples/qpe_stretched_n2.ipynb +++ b/examples/qpe_stretched_n2.ipynb @@ -56,7 +56,7 @@ "source": [ "from qdk_chemistry.data import Structure\n", "\n", - "# Stretched N2 structure at 1.270025 Å bond length\n", + "# Stretched N2 structure at 1.270025 \u00c5 bond length\n", "structure = Structure.from_xyz_file(Path(\"data/stretched_n2.structure.xyz\"))" ] }, @@ -401,9 +401,12 @@ "metadata": {}, "outputs": [], "source": [ + "from qdk_chemistry.data import MajoranaMapping\n", + "\n", "# Prepare the qubit-mapped Hamiltonian\n", - "qubit_mapper = create(\"qubit_mapper\", algorithm_name=\"qiskit\", encoding=\"jordan-wigner\")\n", - "qubit_hamiltonian = qubit_mapper.run(active_hamiltonian)\n", + "n_spin_orbitals = 2 * active_hamiltonian.get_orbitals().get_num_molecular_orbitals()\n", + "qubit_mapper = create(\"qubit_mapper\", \"qiskit\")\n", + "qubit_hamiltonian = qubit_mapper.run(active_hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals))\n", "print(\"Qubit Hamiltonian:\\n\", qubit_hamiltonian.get_summary())" ] }, @@ -664,4 +667,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/examples/state_prep_energy.ipynb b/examples/state_prep_energy.ipynb index 2b75d9120..4791265d1 100644 --- a/examples/state_prep_energy.ipynb +++ b/examples/state_prep_energy.ipynb @@ -20,8 +20,8 @@ "\n", "---\n", "\n", - "In many molecular systems—such as bond dissociation or transition-metal complexes—a single electronic configuration cannot describe the true electronic structure.\n", - "These multi-configurational systems exhibit strong electron correlation that challenges mean-field and single-determinant methods like [Hartree–Fock](https://en.wikipedia.org/wiki/Hartree%E2%80%93Fock_method) or standard [coupled cluster theory](https://en.wikipedia.org/wiki/Coupled_cluster).\n", + "In many molecular systems\u2014such as bond dissociation or transition-metal complexes\u2014a single electronic configuration cannot describe the true electronic structure.\n", + "These multi-configurational systems exhibit strong electron correlation that challenges mean-field and single-determinant methods like [Hartree\u2013Fock](https://en.wikipedia.org/wiki/Hartree%E2%80%93Fock_method) or standard [coupled cluster theory](https://en.wikipedia.org/wiki/Coupled_cluster).\n", "\n", "While classical multi-configurational approaches can capture these effects, their computational cost grows exponentially with system size.\n", "Quantum computers offer a complementary route: they can represent superpositions of many configurations natively and solve these problems with polynomial scaling.\n", @@ -258,7 +258,7 @@ "id": "c73ad541", "metadata": {}, "source": [ - "Reducing the wavefunction to these determinants allows us to optimize the computational requirements for loading the quantum computer with a state that has high overlap with the true wavefunction—an important metric for quantum algorithms like QPE.\n", + "Reducing the wavefunction to these determinants allows us to optimize the computational requirements for loading the quantum computer with a state that has high overlap with the true wavefunction\u2014an important metric for quantum algorithms like QPE.\n", "However, this reduction of the wavefunction also changes our description of the quantum system, particularly its energy.\n", "Therefore, for the purposes of benchmarking, we need to recalculate the energy of the truncated wavefunction classically to provide a reference for evaluating the accuracy of the quantum calculation.\n", "This cell shows how to recalculate this energy." @@ -290,7 +290,7 @@ "\n", "One possibility for loading the multi-configuration wavefunction onto a quantum computer is to use general state preparation approaches such as the [isometry method](https://arxiv.org/abs/1501.06911), as offered in software such as [Qiskit](https://qiskit.org/documentation/stubs/qiskit.circuit.library.Isometry.html).\n", "While this is a very powerful general-purpose approach, it can be resource intensive, requiring very deep circuits even for modest-sized wavefunctions due to its exponential scaling in the number of qubits.\n", - "This approach also requires numerous fine rotations—operations that can be challenging for near-term fault-tolerant quantum hardware.\n", + "This approach also requires numerous fine rotations\u2014operations that can be challenging for near-term fault-tolerant quantum hardware.\n", "This cell demonstrates how to use the isometry method to generate a quantum circuit for preparing the multi-configuration wavefunction on a quantum computer.\n", "\n", "**Note**: the generated circuits are so deep that you will need to adjust the \"zoom\" selection in the visualization window to see the detailed operations." @@ -325,7 +325,7 @@ "source": [ "### Loading the wavefunction using optimized state preparation methods\n", "\n", - "As the cell above illustrates, the general isometry method for state preparation can be very resource intensive—requiring thousands of fine rotations for this benzene diradical example.\n", + "As the cell above illustrates, the general isometry method for state preparation can be very resource intensive\u2014requiring thousands of fine rotations for this benzene diradical example.\n", "However, we can optimize this process by taking advantage of the sparse multi-configuration wavefunction structure, generating much more efficient quantum circuits for state preparation.\n", "The cell below demonstrates how the `qdk-chemistry` library can be used for optimized wavefunction loading, producing a circuit that is orders of magnitude more efficient than the general isometry method.\n", "\n", @@ -360,7 +360,7 @@ "id": "6635d626", "metadata": {}, "source": [ - "Rather than requiring thousands of fine rotations, this optimized approach requires only a single fine rotation for the two-determinant benzene diradical wavefunction—demonstrating the power of chemistry-informed optimizations for quantum state preparation.\n", + "Rather than requiring thousands of fine rotations, this optimized approach requires only a single fine rotation for the two-determinant benzene diradical wavefunction\u2014demonstrating the power of chemistry-informed optimizations for quantum state preparation.\n", "\n", "Close inspection of the generated circuit shows that it has also reduced our qubit count: several of the qubits have been converted to classical bits, which can be post-processed after measurement.\n", "We will revisit these classical bits in the next section on energy measurement." @@ -385,9 +385,12 @@ "metadata": {}, "outputs": [], "source": [ + "from qdk_chemistry.data import MajoranaMapping\n", + "\n", "# Prepare qubit Hamiltonian\n", - "qubit_mapper = create(\"qubit_mapper\", algorithm_name=\"qiskit\", encoding=\"jordan-wigner\")\n", - "qubit_hamiltonian = qubit_mapper.run(hamiltonian)" + "n_spin_orbitals = 2 * hamiltonian.get_orbitals().get_num_molecular_orbitals()\n", + "qubit_mapper = create(\"qubit_mapper\", \"qiskit\")\n", + "qubit_hamiltonian = qubit_mapper.run(hamiltonian, MajoranaMapping.jordan_wigner(n_spin_orbitals))" ] }, { @@ -431,7 +434,7 @@ "energy_mean = energy_results.energy_expectation_value + hamiltonian.get_core_energy()\n", "energy_stddev = np.sqrt(energy_results.energy_variance)\n", "print(\n", - " f\"Estimated energy from quantum circuit: {energy_mean:.3f} ± {energy_stddev:.3f} Hartree\"\n", + " f\"Estimated energy from quantum circuit: {energy_mean:.3f} \u00b1 {energy_stddev:.3f} Hartree\"\n", ")\n", "\n", "# Print comparison with reference energy\n", @@ -460,4 +463,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 7f555941d..32855ab65 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -125,47 +125,45 @@ def __init__(self) -> None: class QubitMapper(Algorithm): """Abstract base class for mapping a Hamiltonian to a QubitHamiltonian. - .. rubric:: Backend dispatch contract + .. rubric:: How backends use the MajoranaMapping - There are two fundamentally different kinds of ``QubitMapper`` backend, - and they use the :class:`~qdk_chemistry.data.MajoranaMapping` argument - in different ways: + ``QubitMapper`` backends fall into two groups, and they use the + :class:`~qdk_chemistry.data.MajoranaMapping` argument differently: * **Table-driven backends** (e.g. :class:`QdkQubitMapper`) read - ``mapping.table`` — the actual 2N Pauli strings that define the - Majorana-to-qubit encoding — and use them directly in the mapping - engine. Any valid table works, including custom encodings that - have no name. - * **Name-dispatched backends** (e.g. ``OpenFermionQubitMapper``, - ``QiskitQubitMapper``) **ignore** ``mapping.table`` entirely. - Instead they read ``mapping.base_encoding`` (a string like - ``"jordan-wigner"`` or ``"bravyi-kitaev-tree"``) and use it to - look up the corresponding transform function in their own library. - The backend then builds a qubit operator from scratch using its - own independent fermion-to-qubit pipeline. - - .. rubric:: Name-dispatched backends - - For name-dispatched backends, the ``MajoranaMapping`` serves only as an - encoding selector — its Pauli table is not consulted. Consistency - between the table and the name is not verified at runtime. If a - ``MajoranaMapping`` is constructed with a table that does not match its - ``base_encoding`` name, a name-dispatched backend will silently use the - wrong transform. Factory-produced mappings - (``MajoranaMapping.jordan_wigner()``, ``.bravyi_kitaev()``, etc.) are - guaranteed to be consistent. Cross-backend eigenvalue tests in the - test suite verify this for every supported factory x backend combination. - Custom or manually constructed mappings with non-standard names cannot - be used with name-dispatched backends. + ``mapping.table`` — the Pauli strings that define the encoding — + and feed them directly to the mapping engine. Any valid table + works, including custom encodings with no standard name. + + * **Third-party backends** (e.g. ``OpenFermionQubitMapper``, + ``QiskitQubitMapper``) **ignore** ``mapping.table``. Instead + they read ``mapping.base_encoding`` (a string like + ``"jordan-wigner"``) and pass it to their own library to select + the matching transform. The qubit operator is then built entirely + by the third-party library's own pipeline. + + .. rubric:: Third-party backends + + Because third-party backends choose their transform by encoding + *name*, the Pauli table in the ``MajoranaMapping`` is not used. + Consistency between the table and the name is not checked at runtime. + If a ``MajoranaMapping`` is manually built with a table that does not + match its ``base_encoding`` name, a third-party backend will silently + use the wrong transform. + + Factory-produced mappings (``MajoranaMapping.jordan_wigner()``, + ``.bravyi_kitaev()``, etc.) always keep the table and name in sync. + Cross-backend eigenvalue tests in the test suite verify this for every + supported factory × backend combination. Custom or manually built + mappings with non-standard names cannot be used with third-party + backends. .. rubric:: Tapering - Each backend is responsible for handling tapering in its own - ``_run_impl()``. The static helper ``_taper_result`` provides - the common taper-then-relabel logic so backends don't have to - reimplement it, but backends are free to handle tapering however they - choose. All shipped backends (QDK, OpenFermion, Qiskit) use the - helper. + Each backend handles tapering in its own ``_run_impl()``. The + static helper ``_taper_result`` provides shared taper-then-relabel + logic so backends don't have to reimplement it. All shipped + backends (QDK, OpenFermion, Qiskit) use this helper. """ @@ -240,23 +238,19 @@ def _run_impl( """Construct a QubitHamiltonian from a Hamiltonian using the given mapping. Implementations receive the **full** mapping, which may include - tapering. Each backend is responsible for handling tapering — - typically by stripping it via ``mapping.without_tapering()``, - performing the base mapping, and calling ``_taper_result`` - to apply tapering to the output. + tapering. Each backend handles tapering — typically by stripping + it via ``mapping.without_tapering()``, performing the base mapping, + and calling ``_taper_result`` to apply tapering to the output. .. important:: - **Table-driven** backends (e.g. :class:`QdkQubitMapper`) should - read ``mapping.table`` and pass the mapping to the native engine to - perform the transformation. + **Table-driven** backends (e.g. :class:`QdkQubitMapper`) read + ``mapping.table`` and pass the Pauli strings to the native engine. - **Name-dispatched** backends (e.g. ``OpenFermionQubitMapper``) - should read ``mapping.base_encoding`` to select a third-party - transform function. These backends do **not** use - ``mapping.table`` — they rebuild the qubit operator from scratch - using the third-party library's own pipeline. See the class - docstring for the full dispatch contract and its implications. + **Third-party** backends (e.g. ``OpenFermionQubitMapper``) + read ``mapping.base_encoding`` to choose a transform function + from their own library. They do **not** use ``mapping.table``. + See the class docstring for details and caveats. Args: hamiltonian: The fermionic Hamiltonian. diff --git a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py index ef7abce21..db8f8cf49 100644 --- a/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/openfermion/qubit_mapper.py @@ -51,21 +51,21 @@ def __init__(self): class OpenFermionQubitMapper(QubitMapper): """Map an electronic structure Hamiltonian to a QubitHamiltonian using OpenFermion. - This is a **name-dispatched** backend: it reads + This is a **third-party** backend: it reads ``mapping.base_encoding`` to select the corresponding OpenFermion - transform function and **ignores** ``mapping.table`` entirely. The - qubit operator is built from scratch using OpenFermion's own + transform function and **ignores** ``mapping.table``. The qubit + operator is built from scratch using OpenFermion's own fermion-to-qubit pipeline. .. warning:: - Because this backend dispatches on the encoding *name* rather than - the Pauli table, it relies on the assumption that the mapping's - ``base_encoding`` string is consistent with its table contents. - This invariant is guaranteed for factory-produced mappings + Because this backend chooses its transform by encoding *name* + rather than from the Pauli table, it relies on the mapping's + ``base_encoding`` string being consistent with its table. + This is guaranteed for factory-produced mappings (``MajoranaMapping.jordan_wigner()``, ``.bravyi_kitaev()``, etc.) and is verified by cross-backend eigenvalue tests in the test - suite. Manually constructed mappings with mismatched names will + suite. Manually built mappings with mismatched names will produce silently incorrect results. Tapering-based encodings (e.g. symmetry-conserving Bravyi-Kitaev) are @@ -102,11 +102,11 @@ def _run_impl( hamiltonian: Hamiltonian, mapping: MajoranaMapping, ) -> QubitHamiltonian: - """Build a qubit Hamiltonian via OpenFermion (name-dispatched). + """Build a qubit Hamiltonian via OpenFermion. Reads ``mapping.base_encoding`` to select an OpenFermion transform function. ``mapping.table`` is **not used** — the qubit operator - is rebuilt entirely by OpenFermion's own pipeline. + is built entirely by OpenFermion's own pipeline. If *mapping* carries tapering metadata, the base encoding is extracted first, mapped, and tapering is applied to the result @@ -125,7 +125,7 @@ def _run_impl( """ Logger.trace_entering() - # --- Name dispatch (see QubitMapper class docstring) --- + # --- Select transform by encoding name (see QubitMapper class docstring) --- encoding_name = mapping.base_encoding if encoding_name not in _STANDARD_TRANSFORMS: diff --git a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py index 3fedf4a13..4c68fbace 100644 --- a/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py +++ b/python/src/qdk_chemistry/plugins/qiskit/qubit_mapper.py @@ -48,21 +48,21 @@ def __init__(self): class QiskitQubitMapper(QubitMapper): """Map an electronic structure Hamiltonian to a QubitHamiltonian using Qiskit. - This is a **name-dispatched** backend: it reads + This is a **third-party** backend: it reads ``mapping.base_encoding`` to select the corresponding Qiskit Nature - mapper class and **ignores** ``mapping.table`` entirely. The qubit - operator is built from scratch using Qiskit Nature's own - fermion-to-qubit pipeline. + mapper class and **ignores** ``mapping.table``. The qubit operator + is built from scratch using Qiskit Nature's own fermion-to-qubit + pipeline. .. warning:: - Because this backend dispatches on the encoding *name* rather than - the Pauli table, it relies on the assumption that the mapping's - ``base_encoding`` string is consistent with its table contents. - This invariant is guaranteed for factory-produced mappings + Because this backend chooses its transform by encoding *name* + rather than from the Pauli table, it relies on the mapping's + ``base_encoding`` string being consistent with its table. + This is guaranteed for factory-produced mappings (``MajoranaMapping.jordan_wigner()``, ``.bravyi_kitaev()``, etc.) and is verified by cross-backend eigenvalue tests in the test - suite. Manually constructed mappings with mismatched names will + suite. Manually built mappings with mismatched names will produce silently incorrect results. Tapering-based encodings (e.g. parity two-qubit reduction) are @@ -101,11 +101,11 @@ def _run_impl( hamiltonian: Hamiltonian, mapping: MajoranaMapping, ) -> QubitHamiltonian: - """Build a qubit Hamiltonian via Qiskit Nature (name-dispatched). + """Build a qubit Hamiltonian via Qiskit Nature. Reads ``mapping.base_encoding`` to select a Qiskit Nature mapper class. ``mapping.table`` is **not used** — the qubit operator - is rebuilt entirely by Qiskit's own pipeline. + is built entirely by Qiskit's own pipeline. If *mapping* carries tapering metadata, the base encoding is extracted first, mapped, and tapering is applied to the result @@ -124,7 +124,7 @@ def _run_impl( """ Logger.trace_entering() - # --- Name dispatch (see QubitMapper class docstring) --- + # --- Select transform by encoding name (see QubitMapper class docstring) --- encoding_name = mapping.base_encoding if encoding_name not in _SUPPORTED_ENCODINGS: diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index ecbb2c674..4a4e0f81e 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -531,3 +531,63 @@ def test_pauli_string_length_with_tapering(self) -> None: continue _, w = scbk.bilinear(j, k) assert len(word_to_label(w, scbk.num_qubits)) == 8 + + +# ─── TaperingSpecification Serialization ──────────────────────────────── + + +class TestTaperingSpecificationSerialization: + """Serialization round-trip tests for TaperingSpecification.""" + + def test_json_roundtrip_via_mapping(self) -> None: + """TaperingSpecification survives a JSON round-trip through MajoranaMapping.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + data = scbk.to_json() + loaded = MajoranaMapping.from_json(data) + assert loaded.tapering is not None + assert loaded.tapering.qubit_indices == scbk.tapering.qubit_indices + assert loaded.tapering.eigenvalues == scbk.tapering.eigenvalues + assert loaded.tapering.num_tapered == scbk.tapering.num_tapered + + def test_hdf5_roundtrip_via_mapping(self) -> None: + """TaperingSpecification survives an HDF5 round-trip through MajoranaMapping.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + scbk = MajoranaMapping.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + with tempfile.NamedTemporaryFile(suffix=".h5") as f: + with h5py.File(f.name, "w") as hf: + scbk.to_hdf5(hf) + with h5py.File(f.name, "r") as hf: + loaded = MajoranaMapping.from_hdf5(hf) + assert loaded.tapering is not None + assert loaded.tapering.qubit_indices == scbk.tapering.qubit_indices + assert loaded.tapering.eigenvalues == scbk.tapering.eigenvalues + + def test_json_contains_tapering_fields(self) -> None: + """TaperingSpecification.to_json() produces the expected structure.""" + import json # noqa: PLC0415 + + from qdk_chemistry.data import Symmetries, TaperingSpecification # noqa: PLC0415 + + tap = TaperingSpecification.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + data = json.loads(tap.to_json()) + assert "qubit_indices" in data + assert "eigenvalues" in data + reconstructed = TaperingSpecification( + qubit_indices=list(data["qubit_indices"]), + eigenvalues=list(data["eigenvalues"]), + ) + assert reconstructed == tap + + def test_parity_tapering_json_roundtrip_via_mapping(self) -> None: + """Parity two-qubit reduction tapering survives a JSON round-trip.""" + from qdk_chemistry.data import Symmetries # noqa: PLC0415 + + par = MajoranaMapping.parity(8, Symmetries(2, 2)) + data = par.to_json() + loaded = MajoranaMapping.from_json(data) + assert loaded.tapering is not None + assert loaded.tapering.qubit_indices == par.tapering.qubit_indices + assert loaded.tapering.eigenvalues == par.tapering.eigenvalues From 3e3748ce0013e87a6e7f683734529c5d9f9551b4 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 1 Jun 2026 03:28:16 +0000 Subject: [PATCH 110/117] test: expand C++ engine tests, add TaperingSpecification HDF5 roundtrip, clear stale notebook - Add 5 new C++ tests for majorana_map_engine: BK/parity identity coefficient invariance, spin-symmetric vs unrestricted equivalence, two-body integral contribution, and multi-word (>64 qubit) dispatch - Add standalone TaperingSpecification HDF5 roundtrip test via to_hdf5_file() with h5py read-back verification - Clear stale rendered output in factory_list.ipynb that still showed the removed 'encoding' setting for qubit_mapper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cpp/tests/test_majorana_mapping.cpp | 122 +++ examples/factory_list.ipynb | 1428 +------------------------ python/tests/test_majorana_mapping.py | 16 + 3 files changed, 145 insertions(+), 1421 deletions(-) diff --git a/cpp/tests/test_majorana_mapping.cpp b/cpp/tests/test_majorana_mapping.cpp index 65badda04..f180439e6 100644 --- a/cpp/tests/test_majorana_mapping.cpp +++ b/cpp/tests/test_majorana_mapping.cpp @@ -79,6 +79,128 @@ TEST(MajoranaMapEngineTest, MapsOneBodyUnrestrictedJordanWignerHamiltonian) { expect_real_term(terms, "ZI", -1.0); } +TEST(MajoranaMapEngineTest, BravyiKitaevProducesCorrectIdentityCoefficient) { + auto jw = MajoranaMapping::jordan_wigner(4); + auto bk = MajoranaMapping::bravyi_kitaev(4); + // 2 spatial orbitals, diagonal one-body, no two-body. + const double h1_alpha[4] = {1.0, 0.0, 0.0, 0.5}; + const double h1_beta[4] = {2.0, 0.0, 0.0, 1.5}; + const double eri_zero[16] = {}; + + auto jw_result = majorana_map_hamiltonian( + jw, 0.0, h1_alpha, h1_beta, eri_zero, eri_zero, eri_zero, + /*n_spatial=*/2, /*spin_symmetric=*/false, /*threshold=*/1e-12, + /*integral_threshold=*/1e-12); + auto bk_result = majorana_map_hamiltonian( + bk, 0.0, h1_alpha, h1_beta, eri_zero, eri_zero, eri_zero, + /*n_spatial=*/2, /*spin_symmetric=*/false, /*threshold=*/1e-12, + /*integral_threshold=*/1e-12); + + auto jw_terms = collect_terms(jw_result, jw.num_qubits()); + auto bk_terms = collect_terms(bk_result, bk.num_qubits()); + + // Identity coefficient = sum(h_diag)/2 is encoding-independent. + expect_real_term(jw_terms, "IIII", 2.5); + expect_real_term(bk_terms, "IIII", 2.5); +} + +TEST(MajoranaMapEngineTest, ParityProducesCorrectIdentityCoefficient) { + auto jw = MajoranaMapping::jordan_wigner(4); + auto par = MajoranaMapping::parity(4); + const double h1_alpha[4] = {1.0, 0.0, 0.0, 0.5}; + const double h1_beta[4] = {2.0, 0.0, 0.0, 1.5}; + const double eri_zero[16] = {}; + + auto jw_result = majorana_map_hamiltonian( + jw, 0.0, h1_alpha, h1_beta, eri_zero, eri_zero, eri_zero, + /*n_spatial=*/2, /*spin_symmetric=*/false, /*threshold=*/1e-12, + /*integral_threshold=*/1e-12); + auto par_result = majorana_map_hamiltonian( + par, 0.0, h1_alpha, h1_beta, eri_zero, eri_zero, eri_zero, + /*n_spatial=*/2, /*spin_symmetric=*/false, /*threshold=*/1e-12, + /*integral_threshold=*/1e-12); + + auto jw_terms = collect_terms(jw_result, jw.num_qubits()); + auto par_terms = collect_terms(par_result, par.num_qubits()); + + expect_real_term(jw_terms, "IIII", 2.5); + expect_real_term(par_terms, "IIII", 2.5); +} + +TEST(MajoranaMapEngineTest, SpinSymmetricMatchesUnrestricted) { + auto mapping = MajoranaMapping::jordan_wigner(4); + const double h1[4] = {1.0, 0.3, 0.3, 0.5}; + // (00|00)=0.6, (11|11)=0.4, (00|11)=(11|00)=0.1 + const double eri[16] = {0.6, 0.0, 0.0, 0.1, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.1, 0.0, 0.0, 0.4}; + + auto restricted = majorana_map_hamiltonian( + mapping, 0.5, h1, h1, eri, eri, eri, + /*n_spatial=*/2, /*spin_symmetric=*/true, /*threshold=*/1e-12, + /*integral_threshold=*/1e-12); + auto unrestricted = majorana_map_hamiltonian( + mapping, 0.5, h1, h1, eri, eri, eri, + /*n_spatial=*/2, /*spin_symmetric=*/false, /*threshold=*/1e-12, + /*integral_threshold=*/1e-12); + + auto r_terms = collect_terms(restricted, mapping.num_qubits()); + auto u_terms = collect_terms(unrestricted, mapping.num_qubits()); + + EXPECT_EQ(r_terms.size(), u_terms.size()); + for (const auto& [label, coeff] : r_terms) { + auto it = u_terms.find(label); + ASSERT_NE(it, u_terms.end()) << "Missing term " << label; + EXPECT_NEAR(coeff.real(), it->second.real(), 1e-10) + << "Real mismatch at " << label; + EXPECT_NEAR(coeff.imag(), it->second.imag(), 1e-10) + << "Imag mismatch at " << label; + } +} + +TEST(MajoranaMapEngineTest, TwoBodyIntegralsProduceAdditionalTerms) { + auto mapping = MajoranaMapping::jordan_wigner(2); + const double h1_alpha[1] = {1.0}; + const double h1_beta[1] = {2.0}; + const double eri_zero[1] = {0.0}; + const double eri_nonzero[1] = {0.8}; + + auto one_body_only = majorana_map_hamiltonian( + mapping, 0.0, h1_alpha, h1_beta, eri_zero, eri_zero, eri_zero, + /*n_spatial=*/1, /*spin_symmetric=*/false, /*threshold=*/1e-12, + /*integral_threshold=*/1e-12); + auto with_two_body = majorana_map_hamiltonian( + mapping, 0.0, h1_alpha, h1_beta, eri_nonzero, eri_nonzero, eri_nonzero, + /*n_spatial=*/1, /*spin_symmetric=*/false, /*threshold=*/1e-12, + /*integral_threshold=*/1e-12); + + auto ob_terms = collect_terms(one_body_only, mapping.num_qubits()); + auto tb_terms = collect_terms(with_two_body, mapping.num_qubits()); + + // Two-body integrals should produce additional or modified terms. + EXPECT_NE(ob_terms, tb_terms); + // Identity coefficient should differ due to two-body contributions. + EXPECT_NE(ob_terms.at("II").real(), tb_terms.at("II").real()); +} + +TEST(MajoranaMapEngineTest, MultiWordDispatchDoesNotCrash) { + // 33 spatial orbitals → 66 spin-orbitals (modes) → 66 qubits → NW=2, + // exercising multi-word packed-Pauli dispatch. + constexpr std::size_t n_spatial = 33; + auto mapping = MajoranaMapping::jordan_wigner(2 * n_spatial); + std::vector h1(n_spatial * n_spatial, 0.0); + h1[0] = 1.0; // single non-zero diagonal element + std::vector eri(n_spatial * n_spatial * n_spatial * n_spatial, 0.0); + + auto result = majorana_map_hamiltonian( + mapping, 0.5, h1.data(), h1.data(), eri.data(), eri.data(), eri.data(), + n_spatial, /*spin_symmetric=*/true, /*threshold=*/1e-12, + /*integral_threshold=*/1e-12); + + auto terms = collect_terms(result, mapping.num_qubits()); + // Should have at least the identity term. + EXPECT_GE(terms.size(), 1u); +} + TEST(MajoranaMapEngineTest, RejectsZeroQubitMappings) { std::vector, SparsePauliWord>> bilinears = { {{1.0, 0.0}, {}}}; diff --git a/examples/factory_list.ipynb b/examples/factory_list.ipynb index d9f05b7b5..7df50aa14 100644 --- a/examples/factory_list.ipynb +++ b/examples/factory_list.ipynb @@ -24,19 +24,10 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "64a8ae5d", "metadata": {}, - "outputs": [ - { - "data": { - "application/javascript": "// Copyright (c) Microsoft Corporation.\n// Licensed under the MIT License.\n\n// This file provides CodeMirror syntax highlighting for Q# magic cells\n// in classic Jupyter Notebooks. It does nothing in other (Jupyter Notebook 7,\n// VS Code, Azure Notebooks, etc.) environments.\n\n// Detect the prerequisites and do nothing if they don't exist.\nif (window.require && window.CodeMirror && window.Jupyter) {\n // The simple mode plugin for CodeMirror is not loaded by default, so require it.\n window.require([\"codemirror/addon/mode/simple\"], function defineMode() {\n let rules = [\n {\n token: \"comment\",\n regex: /(\\/\\/).*/,\n beginWord: false,\n },\n {\n token: \"string\",\n regex: String.raw`^\\\"(?:[^\\\"\\\\]|\\\\[\\s\\S])*(?:\\\"|$)`,\n beginWord: false,\n },\n {\n token: \"keyword\",\n regex: String.raw`(namespace|open|as|operation|function|body|adjoint|newtype|controlled|internal)\\b`,\n beginWord: true,\n },\n {\n token: \"keyword\",\n regex: String.raw`(if|elif|else|repeat|until|fixup|for|in|return|fail|within|apply)\\b`,\n beginWord: true,\n },\n {\n token: \"keyword\",\n regex: String.raw`(Adjoint|Controlled|Adj|Ctl|is|self|auto|distribute|invert|intrinsic)\\b`,\n beginWord: true,\n },\n {\n token: \"keyword\",\n regex: String.raw`(let|set|use|borrow|mutable)\\b`,\n beginWord: true,\n },\n {\n token: \"operatorKeyword\",\n regex: String.raw`(not|and|or)\\b|(w/)`,\n beginWord: true,\n },\n {\n token: \"operatorKeyword\",\n regex: String.raw`(=)|(!)|(<)|(>)|(\\+)|(-)|(\\*)|(/)|(\\^)|(%)|(\\|)|(&&&)|(~~~)|(\\.\\.\\.)|(\\.\\.)|(\\?)`,\n beginWord: false,\n },\n {\n token: \"meta\",\n regex: String.raw`(Int|BigInt|Double|Bool|Qubit|Pauli|Result|Range|String|Unit)\\b`,\n beginWord: true,\n },\n {\n token: \"atom\",\n regex: String.raw`(true|false|Pauli(I|X|Y|Z)|One|Zero)\\b`,\n beginWord: true,\n },\n ];\n let simpleRules = [];\n for (let rule of rules) {\n simpleRules.push({\n token: rule.token,\n regex: new RegExp(rule.regex, \"g\"),\n sol: rule.beginWord,\n });\n if (rule.beginWord) {\n // Need an additional rule due to the fact that CodeMirror simple mode doesn't work with ^ token\n simpleRules.push({\n token: rule.token,\n regex: new RegExp(String.raw`\\W` + rule.regex, \"g\"),\n sol: false,\n });\n }\n }\n\n // Register the mode defined above with CodeMirror\n window.CodeMirror.defineSimpleMode(\"qsharp\", { start: simpleRules });\n window.CodeMirror.defineMIME(\"text/x-qsharp\", \"qsharp\");\n\n // Tell Jupyter to associate %%qsharp magic cells with the qsharp mode\n window.Jupyter.CodeCell.options_default.highlight_modes[\"qsharp\"] = {\n reg: [/^%%qsharp/],\n };\n\n // Force re-highlighting of all cells the first time this code runs\n for (const cell of window.Jupyter.notebook.get_cells()) {\n cell.auto_highlight();\n }\n });\n}\n", - "text/plain": [] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Import the optional plugins to expose third-party methods\n", "import qdk_chemistry.plugins.pyscf\n", @@ -58,7 +49,7 @@ " display: flex;\n", " align-items: center;\n", " \">\n", - " ⚠️\n", + " \u26a0\ufe0f\n", "
\n", " {html.escape(title)}
\n", " {html.escape(message)}\n", @@ -90,39 +81,10 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "c3718593", "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "
\n", - " ⚠️\n", - "
\n", - " Documentation Warning
\n", - " No docstring for dynamical_correlation_calculator:qdk_mp2_calculator\n", - "
\n", - "
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Generate the list of available factories, methods, and settings\n", "import inspect\n", @@ -155,1386 +117,10 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "97de5394", "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "
FactoryMethodDescriptionSettingDefault
active_space_selectorpyscf_avasPySCF-based Active Space Selector for quantum chemistry calculations.ao_labels[]
active_space_selectorpyscf_avasPySCF-based Active Space Selector for quantum chemistry calculations.canonicalizeFalse
active_space_selectorpyscf_avasPySCF-based Active Space Selector for quantum chemistry calculations.openshell_option2
active_space_selectorpyscf_avasPySCF-based Active Space Selector for quantum chemistry calculations.threshold0.2
active_space_selectorqdk_occupationQDK occupation-based active space selector.occupation_threshold0.1
active_space_selectorqdk_autocas_eosQDK entropy-based active space selector.diff_threshold0.1
active_space_selectorqdk_autocas_eosQDK entropy-based active space selector.entropy_threshold0.14
active_space_selectorqdk_autocas_eosQDK entropy-based active space selector.normalize_entropiesTrue
active_space_selectorqdk_autocasQDK Automated Complete Active Space (autoCAS) selector.entropy_threshold0.14
active_space_selectorqdk_autocasQDK Automated Complete Active Space (autoCAS) selector.min_plateau_size10
active_space_selectorqdk_autocasQDK Automated Complete Active Space (autoCAS) selector.normalize_entropiesTrue
active_space_selectorqdk_autocasQDK Automated Complete Active Space (autoCAS) selector.num_bins100
active_space_selectorqdk_valenceQDK valence-based active space selector.num_active_electrons-1
active_space_selectorqdk_valenceQDK valence-based active space selector.num_active_orbitals-1
hamiltonian_constructorqdk_choleskyQDK implementation of the Cholesky Hamiltonian constructor.cholesky_tolerance1e-08
hamiltonian_constructorqdk_choleskyQDK implementation of the Cholesky Hamiltonian constructor.eri_threshold1e-12
hamiltonian_constructorqdk_choleskyQDK implementation of the Cholesky Hamiltonian constructor.scf_typeauto
hamiltonian_constructorqdk_choleskyQDK implementation of the Cholesky Hamiltonian constructor.store_ao_cholesky_vectorsFalse
hamiltonian_constructorqdkQDK implementation of the Hamiltonian constructor.eri_methoddirect
hamiltonian_constructorqdkQDK implementation of the Hamiltonian constructor.scf_typeauto
orbital_localizerpyscf_multiPySCF-based orbital localizer for quantum chemistry calculations.methodpipek-mezey
orbital_localizerpyscf_multiPySCF-based orbital localizer for quantum chemistry calculations.occupation_threshold1e-10
orbital_localizerpyscf_multiPySCF-based orbital localizer for quantum chemistry calculations.population_methodmulliken
orbital_localizerqdk_vvhvQDK Valence Virtual - Hard Virtual (VV-HV) orbital localizer.max_iterations10000
orbital_localizerqdk_vvhvQDK Valence Virtual - Hard Virtual (VV-HV) orbital localizer.minimal_basissto-3g
orbital_localizerqdk_vvhvQDK Valence Virtual - Hard Virtual (VV-HV) orbital localizer.small_rotation_tolerance1e-12
orbital_localizerqdk_vvhvQDK Valence Virtual - Hard Virtual (VV-HV) orbital localizer.tolerance1e-06
orbital_localizerqdk_vvhvQDK Valence Virtual - Hard Virtual (VV-HV) orbital localizer.weighted_orthogonalizationTrue
orbital_localizerqdk_pipek_mezeyQDK Pipek-Mezey orbital localizer.max_iterations10000
orbital_localizerqdk_pipek_mezeyQDK Pipek-Mezey orbital localizer.small_rotation_tolerance1e-12
orbital_localizerqdk_pipek_mezeyQDK Pipek-Mezey orbital localizer.tolerance1e-06
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.calculate_mutual_informationFalse
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.calculate_one_rdmFalse
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.calculate_single_orbital_entropiesFalse
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.calculate_two_orbital_entropiesFalse
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.calculate_two_rdmFalse
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.ci_matel_tol2.220446049250313e-16
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.ci_residual_tolerance1e-06
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.constraint_level2
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.core_selection_strategypercentage
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.core_selection_threshold0.95
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.grow_factor8.0
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.grow_with_rotFalse
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.growth_backoff_rate0.5
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.growth_recovery_rate1.1
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.iterative_solver_dimension_cutoff2000
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.just_singlesFalse
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.max_refine_iter6
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.max_solver_iterations200
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.min_grow_factor1.01
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.ncdets_max100
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.ntdets_max100000
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.ntdets_min100
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.nxtval_bcount_inc10
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.nxtval_bcount_thresh1000
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.pair_size_max500000000
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.pt2_bigcon_thresh250
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.pt2_constraint_refine_force0
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.pt2_max_constraint_level5
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.pt2_min_constraint_level0
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.pt2_precompute_epsFalse
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.pt2_precompute_idxFalse
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.pt2_print_progressFalse
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.pt2_pruneFalse
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.pt2_reserve_count70000000
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.pt2_tol1e-16
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.refine_energy_tol1e-06
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.rot_size_start1000
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.rv_prune_tol1e-08
multi_configuration_calculatormacis_asciQDK MACIS-based Adaptive Sampling Configuration Interaction (ASCI) calculator.search_matel_tol1e-08
multi_configuration_calculatormacis_casQDK MACIS-based Complete Active Space (CAS) calculator.calculate_mutual_informationFalse
multi_configuration_calculatormacis_casQDK MACIS-based Complete Active Space (CAS) calculator.calculate_one_rdmFalse
multi_configuration_calculatormacis_casQDK MACIS-based Complete Active Space (CAS) calculator.calculate_single_orbital_entropiesFalse
multi_configuration_calculatormacis_casQDK MACIS-based Complete Active Space (CAS) calculator.calculate_two_orbital_entropiesFalse
multi_configuration_calculatormacis_casQDK MACIS-based Complete Active Space (CAS) calculator.calculate_two_rdmFalse
multi_configuration_calculatormacis_casQDK MACIS-based Complete Active Space (CAS) calculator.ci_matel_tol2.220446049250313e-16
multi_configuration_calculatormacis_casQDK MACIS-based Complete Active Space (CAS) calculator.ci_residual_tolerance1e-06
multi_configuration_calculatormacis_casQDK MACIS-based Complete Active Space (CAS) calculator.iterative_solver_dimension_cutoff2000
multi_configuration_calculatormacis_casQDK MACIS-based Complete Active Space (CAS) calculator.max_solver_iterations200
multi_configuration_scfpyscfPySCF-based MCSCF calculator for quantum chemistry calculations.max_cycle_macro50
multi_configuration_scfpyscfPySCF-based MCSCF calculator for quantum chemistry calculations.multi_configuration_calculatorAlgorithmRef(type='multi_configuration_calculator', name='macis_cas')
multi_configuration_scfpyscfPySCF-based MCSCF calculator for quantum chemistry calculations.verbose0
projected_multi_configuration_calculatormacis_pmcQDK MACIS-based Projected Multi-Configuration (PMC) calculator.calculate_mutual_informationFalse
projected_multi_configuration_calculatormacis_pmcQDK MACIS-based Projected Multi-Configuration (PMC) calculator.calculate_one_rdmFalse
projected_multi_configuration_calculatormacis_pmcQDK MACIS-based Projected Multi-Configuration (PMC) calculator.calculate_single_orbital_entropiesFalse
projected_multi_configuration_calculatormacis_pmcQDK MACIS-based Projected Multi-Configuration (PMC) calculator.calculate_two_orbital_entropiesFalse
projected_multi_configuration_calculatormacis_pmcQDK MACIS-based Projected Multi-Configuration (PMC) calculator.calculate_two_rdmFalse
projected_multi_configuration_calculatormacis_pmcQDK MACIS-based Projected Multi-Configuration (PMC) calculator.ci_matel_tol2.220446049250313e-16
projected_multi_configuration_calculatormacis_pmcQDK MACIS-based Projected Multi-Configuration (PMC) calculator.ci_residual_tolerance1e-06
projected_multi_configuration_calculatormacis_pmcQDK MACIS-based Projected Multi-Configuration (PMC) calculator.iterative_solver_dimension_cutoff2000
projected_multi_configuration_calculatormacis_pmcQDK MACIS-based Projected Multi-Configuration (PMC) calculator.max_solver_iterations200
dynamical_correlation_calculatorpyscf_coupled_clusterPySCF-based Coupled Cluster calculator for quantum chemistry calculations.async_ioTrue
dynamical_correlation_calculatorpyscf_coupled_clusterPySCF-based Coupled Cluster calculator for quantum chemistry calculations.compute_braFalse
dynamical_correlation_calculatorpyscf_coupled_clusterPySCF-based Coupled Cluster calculator for quantum chemistry calculations.conv_tol1e-07
dynamical_correlation_calculatorpyscf_coupled_clusterPySCF-based Coupled Cluster calculator for quantum chemistry calculations.conv_tol_normt1e-05
dynamical_correlation_calculatorpyscf_coupled_clusterPySCF-based Coupled Cluster calculator for quantum chemistry calculations.diis_space6
dynamical_correlation_calculatorpyscf_coupled_clusterPySCF-based Coupled Cluster calculator for quantum chemistry calculations.diis_start_cycle0
dynamical_correlation_calculatorpyscf_coupled_clusterPySCF-based Coupled Cluster calculator for quantum chemistry calculations.directFalse
dynamical_correlation_calculatorpyscf_coupled_clusterPySCF-based Coupled Cluster calculator for quantum chemistry calculations.incore_completeTrue
dynamical_correlation_calculatorpyscf_coupled_clusterPySCF-based Coupled Cluster calculator for quantum chemistry calculations.max_cycle50
dynamical_correlation_calculatorpyscf_coupled_clusterPySCF-based Coupled Cluster calculator for quantum chemistry calculations.store_amplitudesFalse
scf_solverpyscfPySCF-based Self-Consistent Field (SCF) solver for quantum chemistry calculations.convergence_threshold1e-07
scf_solverpyscfPySCF-based Self-Consistent Field (SCF) solver for quantum chemistry calculations.max_iterations50
scf_solverpyscfPySCF-based Self-Consistent Field (SCF) solver for quantum chemistry calculations.methodhf
scf_solverpyscfPySCF-based Self-Consistent Field (SCF) solver for quantum chemistry calculations.scf_typeauto
scf_solverpyscfPySCF-based Self-Consistent Field (SCF) solver for quantum chemistry calculations.xc_grid3
scf_solverqdkQDK implementation of the SCF solver.convergence_threshold1e-07
scf_solverqdkQDK implementation of the SCF solver.enable_gdmTrue
scf_solverqdkQDK implementation of the SCF solver.energy_thresh_diis_switch0.001
scf_solverqdkQDK implementation of the SCF solver.eri_methoddirect
scf_solverqdkQDK implementation of the SCF solver.eri_threshold-1.0
scf_solverqdkQDK implementation of the SCF solver.eri_use_atomicsFalse
scf_solverqdkQDK implementation of the SCF solver.fock_reset_steps1073741824
scf_solverqdkQDK implementation of the SCF solver.gdm_bfgs_history_size_limit50
scf_solverqdkQDK implementation of the SCF solver.gdm_max_diis_iteration50
scf_solverqdkQDK implementation of the SCF solver.level_shift-1.0
scf_solverqdkQDK implementation of the SCF solver.max_iterations50
scf_solverqdkQDK implementation of the SCF solver.methodhf
scf_solverqdkQDK implementation of the SCF solver.nthreads-1
scf_solverqdkQDK implementation of the SCF solver.scf_typeauto
scf_solverqdkQDK implementation of the SCF solver.shell_pair_threshold1e-12
stability_checkerpyscfPySCF-based stability checker for quantum chemistry wavefunctions.davidson_tolerance1e-08
stability_checkerpyscfPySCF-based stability checker for quantum chemistry wavefunctions.externalTrue
stability_checkerpyscfPySCF-based stability checker for quantum chemistry wavefunctions.internalTrue
stability_checkerpyscfPySCF-based stability checker for quantum chemistry wavefunctions.methodhf
stability_checkerpyscfPySCF-based stability checker for quantum chemistry wavefunctions.nroots3
stability_checkerpyscfPySCF-based stability checker for quantum chemistry wavefunctions.pyscf_verbose4
stability_checkerpyscfPySCF-based stability checker for quantum chemistry wavefunctions.stability_tolerance-0.0001
stability_checkerpyscfPySCF-based stability checker for quantum chemistry wavefunctions.with_symmetryFalse
stability_checkerpyscfPySCF-based stability checker for quantum chemistry wavefunctions.xc_grid3
stability_checkerqdkQDK implementation of the stability checker.davidson_tolerance1e-08
stability_checkerqdkQDK implementation of the stability checker.externalFalse
stability_checkerqdkQDK implementation of the stability checker.internalTrue
stability_checkerqdkQDK implementation of the stability checker.max_subspace80
stability_checkerqdkQDK implementation of the stability checker.methodhf
stability_checkerqdkQDK implementation of the stability checker.stability_tolerance-0.0001
energy_estimatorqdkQDK implementation of the EnergyEstimator.circuit_executorAlgorithmRef(type='circuit_executor', name='qdk_sparse_state_simulator')
state_prepsparse_isometry_gf2xState preparation using sparse isometry with enhanced GF2+X elimination.basis_gates['x', 'y', 'z', 'cx', 'cz', 'id', 'h', 's', 'sdg', 'rz']
state_prepsparse_isometry_gf2xState preparation using sparse isometry with enhanced GF2+X elimination.dense_preparation_methodqdk
state_prepsparse_isometry_gf2xState preparation using sparse isometry with enhanced GF2+X elimination.transpileTrue
state_prepsparse_isometry_gf2xState preparation using sparse isometry with enhanced GF2+X elimination.transpile_optimization_level0
state_prepqiskit_regular_isometryState preparation using a regular isometry approach.basis_gates['x', 'y', 'z', 'cx', 'cz', 'id', 'h', 's', 'sdg', 'rz']
state_prepqiskit_regular_isometryState preparation using a regular isometry approach.transpileTrue
state_prepqiskit_regular_isometryState preparation using a regular isometry approach.transpile_optimization_level0
qubit_mapperqdkQDK native qubit mapper using PauliTermAccumulator.encodingjordan-wigner
qubit_mapperqdkQDK native qubit mapper using PauliTermAccumulator.integral_threshold1e-12
qubit_mapperqdkQDK native qubit mapper using PauliTermAccumulator.threshold1e-12
qubit_mapperqiskitMap an electronic structure Hamiltonian to a QubitHamiltonian using Qiskit.encodingjordan-wigner
qubit_mapperopenfermionMap an electronic structure Hamiltonian to a QubitHamiltonian using OpenFermion.encodingjordan-wigner
qubit_hamiltonian_solverqdk_sparse_matrix_solverQubit Hamiltonian solver using sparse matrix methods.max_m20
qubit_hamiltonian_solverqdk_sparse_matrix_solverQubit Hamiltonian solver using sparse matrix methods.tol1e-08
hamiltonian_unitary_buildertrotterTrotter decomposition builder.error_boundcommutator
hamiltonian_unitary_buildertrotterTrotter decomposition builder.num_divisions0
hamiltonian_unitary_buildertrotterTrotter decomposition builder.order1
hamiltonian_unitary_buildertrotterTrotter decomposition builder.power1
hamiltonian_unitary_buildertrotterTrotter decomposition builder.power_strategyrepeat
hamiltonian_unitary_buildertrotterTrotter decomposition builder.target_accuracy0.0
hamiltonian_unitary_buildertrotterTrotter decomposition builder.time0.0
hamiltonian_unitary_buildertrotterTrotter decomposition builder.weight_threshold1e-12
hamiltonian_unitary_builderqdriftqDRIFT randomized product formula builder.commutation_typegeneral
hamiltonian_unitary_builderqdriftqDRIFT randomized product formula builder.merge_duplicate_termsTrue
hamiltonian_unitary_builderqdriftqDRIFT randomized product formula builder.num_samples100
hamiltonian_unitary_builderqdriftqDRIFT randomized product formula builder.power1
hamiltonian_unitary_builderqdriftqDRIFT randomized product formula builder.power_strategyrepeat
hamiltonian_unitary_builderqdriftqDRIFT randomized product formula builder.seed-1
hamiltonian_unitary_builderqdriftqDRIFT randomized product formula builder.time0.0
hamiltonian_unitary_builderpartially_randomizedPartially randomized product formula builder.commutation_typegeneral
hamiltonian_unitary_builderpartially_randomizedPartially randomized product formula builder.merge_duplicate_termsTrue
hamiltonian_unitary_builderpartially_randomizedPartially randomized product formula builder.num_random_samples100
hamiltonian_unitary_builderpartially_randomizedPartially randomized product formula builder.power1
hamiltonian_unitary_builderpartially_randomizedPartially randomized product formula builder.power_strategyrepeat
hamiltonian_unitary_builderpartially_randomizedPartially randomized product formula builder.seed-1
hamiltonian_unitary_builderpartially_randomizedPartially randomized product formula builder.time0.0
hamiltonian_unitary_builderpartially_randomizedPartially randomized product formula builder.tolerance1e-12
hamiltonian_unitary_builderpartially_randomizedPartially randomized product formula builder.trotter_order2
hamiltonian_unitary_builderpartially_randomizedPartially randomized product formula builder.weight_threshold-1.0
circuit_executorqdk_full_state_simulatorQDK Full State Simulator circuit executor implementation.seed42
circuit_executorqdk_full_state_simulatorQDK Full State Simulator circuit executor implementation.typecpu
circuit_executorqdk_sparse_state_simulatorQDK Sparse State Simulator circuit executor implementation.seed42
circuit_executorqiskit_aer_simulatorQiskit Aer Simulator circuit executor implementation.methodstatevector
circuit_executorqiskit_aer_simulatorQiskit Aer Simulator circuit executor implementation.seed42
circuit_executorqiskit_aer_simulatorQiskit Aer Simulator circuit executor implementation.transpile_optimization_level0
phase_estimationiterativeIterative Phase Estimation algorithm implementation.circuit_executorAlgorithmRef(type='circuit_executor', name='qdk_sparse_state_simulator')
phase_estimationiterativeIterative Phase Estimation algorithm implementation.circuit_mapperAlgorithmRef(type='controlled_circuit_mapper', name='pauli_sequence')
phase_estimationiterativeIterative Phase Estimation algorithm implementation.num_bits-1
phase_estimationiterativeIterative Phase Estimation algorithm implementation.shots_per_bit3
phase_estimationiterativeIterative Phase Estimation algorithm implementation.unitary_builderAlgorithmRef(type='hamiltonian_unitary_builder', name='trotter')
phase_estimationqiskit_standardStandard QFT-based (non-iterative) phase estimation.circuit_executorAlgorithmRef(type='circuit_executor', name='qdk_sparse_state_simulator')
phase_estimationqiskit_standardStandard QFT-based (non-iterative) phase estimation.circuit_mapperAlgorithmRef(type='controlled_circuit_mapper', name='pauli_sequence')
phase_estimationqiskit_standardStandard QFT-based (non-iterative) phase estimation.num_bits-1
phase_estimationqiskit_standardStandard QFT-based (non-iterative) phase estimation.qft_do_swapsTrue
phase_estimationqiskit_standardStandard QFT-based (non-iterative) phase estimation.shots3
phase_estimationqiskit_standardStandard QFT-based (non-iterative) phase estimation.unitary_builderAlgorithmRef(type='hamiltonian_unitary_builder', name='trotter')
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# Summarize the results\n", "\n", diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index 4a4e0f81e..7428251e2 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -581,6 +581,22 @@ def test_json_contains_tapering_fields(self) -> None: ) assert reconstructed == tap + def test_hdf5_standalone_roundtrip(self) -> None: + """TaperingSpecification survives a standalone HDF5 round-trip.""" + from qdk_chemistry.data import Symmetries, TaperingSpecification # noqa: PLC0415 + + tap = TaperingSpecification.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + with tempfile.NamedTemporaryFile(suffix=".h5") as f: + tap.to_hdf5_file(f.name) + with h5py.File(f.name, "r") as hf: + assert "qubit_indices" in hf + assert "eigenvalues" in hf + reconstructed = TaperingSpecification( + qubit_indices=[int(x) for x in hf["qubit_indices"][:]], + eigenvalues=[int(x) for x in hf["eigenvalues"][:]], + ) + assert reconstructed == tap + def test_parity_tapering_json_roundtrip_via_mapping(self) -> None: """Parity two-qubit reduction tapering survives a JSON round-trip.""" from qdk_chemistry.data import Symmetries # noqa: PLC0415 From ab2d70c8f0d1b6e2831b02b05f941e97330f2c75 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 1 Jun 2026 15:30:23 +0000 Subject: [PATCH 111/117] docs: add Doxygen documentation to MajoranaMapping and TaperingSpecification headers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../qdk/chemistry/data/majorana_mapping.hpp | 88 +++++++++++++++++-- cpp/include/qdk/chemistry/data/tapering.hpp | 63 +++++++++++++ 2 files changed, 144 insertions(+), 7 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index 8ce1fe75f..16fcce769 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -100,7 +100,16 @@ class MajoranaMapping : public DataClass { return tapering_; } - /// Return a copy with tapering removed and the base encoding name restored. + /** + * @brief Return a copy with tapering removed and the base encoding name + * restored. + * + * The returned mapping has the same Pauli table and bilinears as the + * original, but ``tapering()`` is ``std::nullopt`` and ``name()`` equals + * ``base_encoding()``. + * + * @return An untapered copy of this mapping. + */ MajoranaMapping without_tapering() const; /// @brief Get the data type name for serialization. @@ -151,23 +160,86 @@ class MajoranaMapping : public DataClass { // --- Factory methods for standard encodings --- - /// Jordan-Wigner encoding on num_modes qubits. + /** + * @brief Jordan-Wigner encoding. + * + * Maps fermionic modes to qubits using the Jordan-Wigner transform. + * Each mode maps to one qubit; the Pauli-Z string encodes parity. + * + * @param num_modes Number of fermionic modes (= number of qubits). + * @return MajoranaMapping with name ``"jordan-wigner"``. + * @throws std::invalid_argument If num_modes == 0. + */ static MajoranaMapping jordan_wigner(std::size_t num_modes); - /// Bravyi-Kitaev (Fenwick-tree) encoding on num_modes qubits. + /** + * @brief Bravyi-Kitaev (Fenwick-tree) encoding. + * + * Uses the Fenwick-tree construction to balance parity and occupation + * information across qubits. + * + * @param num_modes Number of fermionic modes (= number of qubits). + * @return MajoranaMapping with name ``"bravyi-kitaev"``. + * @throws std::invalid_argument If num_modes == 0. + */ static MajoranaMapping bravyi_kitaev(std::size_t num_modes); - /// Balanced binary-tree Bravyi-Kitaev encoding. + /** + * @brief Balanced binary-tree Bravyi-Kitaev encoding. + * + * Recursive balanced binary-tree construction. Produces shorter + * Pauli strings than the Fenwick variant for non-power-of-two mode + * counts. + * + * @param num_modes Number of fermionic modes (= number of qubits). + * @return MajoranaMapping with name ``"bravyi-kitaev-tree"``. + * @throws std::invalid_argument If num_modes == 0. + */ static MajoranaMapping bravyi_kitaev_tree(std::size_t num_modes); - /// Parity encoding on num_modes qubits. + /** + * @brief Parity encoding. + * + * Each qubit stores the parity (cumulative occupation) up to its + * corresponding mode, rather than the occupation itself. + * + * @param num_modes Number of fermionic modes (= number of qubits). + * @return MajoranaMapping with name ``"parity"``. + * @throws std::invalid_argument If num_modes == 0. + */ static MajoranaMapping parity(std::size_t num_modes); - /// Parity encoding with two-qubit reduction metadata. + /** + * @brief Parity encoding with two-qubit reduction. + * + * Attaches TaperingSpecification metadata for post-mapping removal + * of two symmetry qubits (alpha-parity and total-parity). + * + * @param num_modes Number of fermionic modes. + * @param n_alpha Number of alpha electrons. + * @param n_beta Number of beta electrons. + * @return MajoranaMapping with name ``"parity-2q-reduced"`` and tapering. + * @throws std::invalid_argument If num_modes is odd, < 4, or electron + * counts exceed spatial orbitals. + */ static MajoranaMapping parity(std::size_t num_modes, std::size_t n_alpha, std::size_t n_beta); - /// Symmetry-conserving Bravyi-Kitaev encoding with tapering metadata. + /** + * @brief Symmetry-conserving Bravyi-Kitaev (SCBK) encoding. + * + * Combines a balanced BK-tree base mapping with tapering metadata + * that removes two symmetry qubits. Post-mapping tapering is applied + * by the qubit mapper backends. + * + * @param num_modes Number of fermionic modes. + * @param n_alpha Number of alpha electrons. + * @param n_beta Number of beta electrons. + * @return MajoranaMapping with name ``"symmetry-conserving-bravyi-kitaev"`` + * and tapering. + * @throws std::invalid_argument If num_modes is odd, < 4, or electron + * counts exceed spatial orbitals. + */ static MajoranaMapping symmetry_conserving_bravyi_kitaev( std::size_t num_modes, std::size_t n_alpha, std::size_t n_beta); @@ -222,7 +294,9 @@ class MajoranaMapping : public DataClass { * Parallel arrays of Pauli words and their complex coefficients. */ struct MajoranaMapResult { + /// Pauli words (one per non-zero term). std::vector words; + /// Complex coefficients (parallel to ``words``). std::vector> coefficients; }; diff --git a/cpp/include/qdk/chemistry/data/tapering.hpp b/cpp/include/qdk/chemistry/data/tapering.hpp index eafdaac6c..1ddde9096 100644 --- a/cpp/include/qdk/chemistry/data/tapering.hpp +++ b/cpp/include/qdk/chemistry/data/tapering.hpp @@ -14,54 +14,117 @@ namespace qdk::chemistry::data { +/** + * @brief Specification for post-mapping qubit tapering. + * + * Records which qubits to remove after a fermion-to-qubit mapping and the + * symmetry eigenvalue (+1 or −1) to substitute for each. Used by + * MajoranaMapping to carry tapering metadata through the mapping pipeline. + */ class TaperingSpecification : public DataClass { public: + /** + * @brief Construct a tapering specification. + * + * @param qubit_indices Indices of qubits to taper (remove). + * @param eigenvalues Symmetry eigenvalue (+1 or −1) for each qubit. + * @throws std::invalid_argument If sizes differ, indices contain duplicates, + * or any eigenvalue is not +1 or −1. + */ TaperingSpecification(std::vector qubit_indices, std::vector eigenvalues); + /// @brief Indices of qubits to taper (remove). const std::vector& qubit_indices() const { return qubit_indices_; } + /// @brief Symmetry eigenvalue (+1 or −1) for each tapered qubit. const std::vector& eigenvalues() const { return eigenvalues_; } + /// @brief Number of qubits removed by tapering. std::size_t num_tapered() const { return qubit_indices_.size(); } + /** + * @brief Tapering for the symmetry-conserving Bravyi-Kitaev encoding. + * + * Removes the two qubits that encode the alpha and total particle-number + * parities in a balanced binary-tree (BK-tree) mapping. + * + * @param num_modes Total number of spin-orbitals (must be even, ≥ 4). + * @param n_alpha Number of alpha electrons. + * @param n_beta Number of beta electrons. + * @throws std::invalid_argument If num_modes is odd, < 4, or electron + * counts exceed spatial orbitals. + */ static TaperingSpecification symmetry_conserving_bravyi_kitaev( std::size_t num_modes, std::size_t n_alpha, std::size_t n_beta); + /** + * @brief Tapering for the parity encoding with two-qubit reduction. + * + * Removes the same two symmetry qubits as + * symmetry_conserving_bravyi_kitaev(). + * + * @param num_modes Total number of spin-orbitals (must be even, ≥ 4). + * @param n_alpha Number of alpha electrons. + * @param n_beta Number of beta electrons. + * @throws std::invalid_argument If num_modes is odd, < 4, or electron + * counts exceed spatial orbitals. + */ static TaperingSpecification parity_two_qubit_reduction(std::size_t num_modes, std::size_t n_alpha, std::size_t n_beta); + /// @brief Get the data type name for serialization. std::string get_data_type_name() const override { return "tapering_specification"; } + /// @brief Get a human-readable summary of the tapering specification. std::string get_summary() const override; + /** + * @brief Save to file in the specified format. + * @param filename Path to the output file. + * @param type Format type ("json", "hdf5", or "h5"). + */ void to_file(const std::string& filename, const std::string& type) const override; + /// @brief Serialize to JSON. nlohmann::json to_json() const override; + /// @brief Deserialize from JSON. static TaperingSpecification from_json(const nlohmann::json& data); + /// @brief Save to a JSON file. void to_json_file(const std::string& filename) const override; + /// @brief Load from a JSON file. static TaperingSpecification from_json_file(const std::string& filename); + /// @brief Save to an HDF5 group. void to_hdf5(H5::Group& group) const override; + /// @brief Load from an HDF5 group. static TaperingSpecification from_hdf5(H5::Group& group); + /// @brief Save to an HDF5 file. void to_hdf5_file(const std::string& filename) const override; + /// @brief Load from an HDF5 file. static TaperingSpecification from_hdf5_file(const std::string& filename); + /** + * @brief Load from file in the specified format. + * @param filename Path to the input file. + * @param type Format type ("json", "hdf5", or "h5"). + */ static TaperingSpecification from_file(const std::string& filename, const std::string& type); + /// @brief Value equality (compares qubit indices and eigenvalues). bool operator==(const TaperingSpecification& other) const; private: From 9ef3f5ccc404299416c3fd2a0b580f649283a02c Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 1 Jun 2026 17:00:32 +0000 Subject: [PATCH 112/117] fix: convert MajoranaMapping table entries to Pauli strings in tests mapping.table returns sparse (qubit, pauli_id) tuples, not Pauli label strings. Add _table_entry_to_pauli_string helper and fix the qubit ordering convention in test_parity_number_operator_structure to use big-endian (Pauli label index 0 = MSB). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/tests/test_qdk_qubit_mapper.py | 33 ++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/python/tests/test_qdk_qubit_mapper.py b/python/tests/test_qdk_qubit_mapper.py index 0c857f03b..ff551e5c6 100644 --- a/python/tests/test_qdk_qubit_mapper.py +++ b/python/tests/test_qdk_qubit_mapper.py @@ -27,6 +27,16 @@ create_test_orbitals, ) +_PAULI_ID_TO_CHAR = {0: "I", 1: "X", 2: "Y", 3: "Z"} + + +def _table_entry_to_pauli_string(entry: list[tuple[int, int]], num_qubits: int) -> str: + """Convert a sparse MajoranaMapping table entry to a dense Pauli label string.""" + chars = ["I"] * num_qubits + for qubit, pauli_id in entry: + chars[qubit] = _PAULI_ID_TO_CHAR[pauli_id] + return "".join(chars) + @pytest.fixture def test_data_path() -> Path: @@ -545,18 +555,23 @@ def test_parity_number_operator_structure(self) -> None: I_n = np.eye(2**n) # noqa: N806 for j in range(n): - g0 = pauli_to_sparse_matrix([mapping.table[2 * j]], np.array([1.0])).toarray() - g1 = pauli_to_sparse_matrix([mapping.table[2 * j + 1]], np.array([1.0])).toarray() + ps0 = _table_entry_to_pauli_string(mapping.table[2 * j], mapping.num_qubits) + ps1 = _table_entry_to_pauli_string(mapping.table[2 * j + 1], mapping.num_qubits) + g0 = pauli_to_sparse_matrix([ps0], np.array([1.0])).toarray() + g1 = pauli_to_sparse_matrix([ps1], np.array([1.0])).toarray() nj = (I_n + 1j * g0 @ g1) / 2 - # Build expected Z structure: Z_0 for j=0, Z_{j-1}·Z_j for j≥1 + # Build expected Z structure, using big-endian qubit convention + # (Pauli label index 0 = MSB = bit n-1). Qubit q in the + # Pauli string maps to bit (n - 1 - q) in the state index. + q = n - 1 - j # bit position for qubit j z_op = np.eye(2**n, dtype=complex) for idx in range(2**n): if j == 0: - if (idx >> 0) & 1: + if (idx >> q) & 1: z_op[idx, idx] = -1.0 else: - parity = ((idx >> (j - 1)) & 1) ^ ((idx >> j) & 1) + parity = ((idx >> q) & 1) ^ ((idx >> (q + 1)) & 1) if parity: z_op[idx, idx] = -1.0 expected_nj = (I_n - z_op) / 2 @@ -1225,7 +1240,13 @@ def test_bk_tree_clifford_algebra(self) -> None: for n in (4, 6, 8): mapping = MajoranaMapping.bravyi_kitaev_tree(n) - gammas = [pauli_to_sparse_matrix([mapping.table[k]], np.array([1.0])).toarray() for k in range(2 * n)] + gammas = [ + pauli_to_sparse_matrix( + [_table_entry_to_pauli_string(mapping.table[k], mapping.num_qubits)], + np.array([1.0]), + ).toarray() + for k in range(2 * n) + ] for i in range(2 * n): for j in range(i, 2 * n): anticomm = gammas[i] @ gammas[j] + gammas[j] @ gammas[i] From ae4e8d65644b53ffeace5eac237a8ae00fd37363 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 1 Jun 2026 17:36:40 +0000 Subject: [PATCH 113/117] =?UTF-8?q?fix:=20replace=20ambiguous=20=C3=97=20w?= =?UTF-8?q?ith=20x=20in=20docstring=20(RUF002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 32855ab65..93912db1a 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -154,7 +154,7 @@ class QubitMapper(Algorithm): Factory-produced mappings (``MajoranaMapping.jordan_wigner()``, ``.bravyi_kitaev()``, etc.) always keep the table and name in sync. Cross-backend eigenvalue tests in the test suite verify this for every - supported factory × backend combination. Custom or manually built + supported factory x backend combination. Custom or manually built mappings with non-standard names cannot be used with third-party backends. From 27ad94b44616b7513b2b78a3c08ae34e24ab9b9d Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 1 Jun 2026 13:21:01 -0700 Subject: [PATCH 114/117] PR comments + linting --- .../qdk/chemistry/data/majorana_mapping.hpp | 3 +- .../qdk/chemistry/data/majorana_mapping.cpp | 31 ++++ examples/extended_hubbard.ipynb | 2 +- examples/qpe_stretched_n2.ipynb | 2 +- examples/state_prep_energy.ipynb | 2 +- python/src/pybind11/data/majorana_mapping.cpp | 59 ++++++- .../qubit_mapper/qdk_qubit_mapper.py | 2 +- .../algorithms/qubit_mapper/qubit_mapper.py | 4 + .../test_interop_openfermion_qubit_mapper.py | 2 +- python/tests/test_majorana_mapping.py | 147 +++++++++++++++++- 10 files changed, 237 insertions(+), 17 deletions(-) diff --git a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp index 16fcce769..a07b52a2d 100644 --- a/cpp/include/qdk/chemistry/data/majorana_mapping.hpp +++ b/cpp/include/qdk/chemistry/data/majorana_mapping.hpp @@ -92,7 +92,8 @@ class MajoranaMapping : public DataClass { /// Encoding name (may be empty for custom encodings). const std::string& name() const { return name_; } - /// Encoding name used by third-party plugin backends to select their own transform. + /// Encoding name used by third-party plugin backends to select their own + /// transform. const std::string& base_encoding() const { return base_encoding_; } /// Optional post-mapping tapering specification. diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp index eb489c960..27ed37bf5 100644 --- a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp @@ -173,6 +173,17 @@ nlohmann::json MajoranaMapping::to_json() const { if (tapering_) { data["tapering"] = tapering_->to_json(); } + // Bilinear-only mappings: persist the bilinear entries so the mapping can + // round-trip even when the Majorana table is empty. + if (!majorana_atomic_) { + data["num_modes"] = num_modes_; + nlohmann::json bl_array = nlohmann::json::array(); + for (const auto& [coeff, word] : bilinears_) { + bl_array.push_back( + {{"real", coeff.real()}, {"imag", coeff.imag()}, {"word", word}}); + } + data["bilinears"] = bl_array; + } return data; } @@ -196,6 +207,26 @@ MajoranaMapping MajoranaMapping::from_json(const nlohmann::json& data) { tapering = TaperingSpecification::from_json(data.at("tapering")); } + // Bilinear-only mapping: table is empty, bilinears stored explicitly. + if (table.empty() && data.contains("bilinears")) { + auto num_modes = data.at("num_modes").get(); + std::vector, SparsePauliWord>> bilinears; + for (const auto& entry : data.at("bilinears")) { + double real = entry.at("real").get(); + double imag = entry.at("imag").get(); + auto word = entry.at("word").get(); + bilinears.emplace_back(std::complex(real, imag), std::move(word)); + } + auto mapping = MajoranaMapping::from_bilinears( + num_modes, std::move(bilinears), base_encoding); + if (name == base_encoding && !tapering) { + return mapping; + } + return MajoranaMapping(mapping.table_, mapping.bilinears_, std::move(name), + mapping.num_modes_, mapping.num_qubits_, + std::move(base_encoding), std::move(tapering)); + } + auto mapping = MajoranaMapping::from_table(std::move(table), base_encoding); if (name == base_encoding && !tapering) { return mapping; diff --git a/examples/extended_hubbard.ipynb b/examples/extended_hubbard.ipynb index 474ff2f36..a42d8f9c7 100644 --- a/examples/extended_hubbard.ipynb +++ b/examples/extended_hubbard.ipynb @@ -795,4 +795,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/examples/qpe_stretched_n2.ipynb b/examples/qpe_stretched_n2.ipynb index 1295a890c..530d0efb1 100644 --- a/examples/qpe_stretched_n2.ipynb +++ b/examples/qpe_stretched_n2.ipynb @@ -667,4 +667,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/examples/state_prep_energy.ipynb b/examples/state_prep_energy.ipynb index 4791265d1..b76526fde 100644 --- a/examples/state_prep_energy.ipynb +++ b/examples/state_prep_energy.ipynb @@ -463,4 +463,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index 119872dae..60acefda7 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -54,10 +54,61 @@ Immutable specification for post-mapping qubit tapering. }, py::arg("num_modes"), py::arg("symmetries")) .def("__eq__", &TaperingSpecification::operator==, py::arg("other")) - .def("__hash__", [](const TaperingSpecification& self) { - return py::hash( - py::make_tuple(self.qubit_indices(), self.eigenvalues())); - }); + .def("__hash__", + [](const TaperingSpecification& self) { + return py::hash(py::make_tuple( + py::tuple(py::cast(self.qubit_indices())), + py::tuple(py::cast(self.eigenvalues())))); + }) + .def( + "to_json", + [](const TaperingSpecification& self) { + py::module_ json = py::module_::import("json"); + return json.attr("loads")(self.to_json().dump()); + }, + "Serialize to a JSON-compatible dictionary.") + .def_static( + "from_json", + [](const py::object& json_data) { + py::module_ json = py::module_::import("json"); + return TaperingSpecification::from_json(nlohmann::json::parse( + json.attr("dumps")(json_data).cast())); + }, + py::arg("json_data"), + "Deserialize from a JSON-compatible dictionary.") + .def( + "to_hdf5", + [](const TaperingSpecification& self, const py::object& group) { + group.attr("attrs").attr("__setitem__")("json", + self.to_json().dump()); + }, + py::arg("group"), + "Serialize to an HDF5 group.") + .def_static( + "from_hdf5", + [](const py::object& group) { + auto json = group.attr("attrs") + .attr("__getitem__")("json") + .cast(); + return TaperingSpecification::from_json( + nlohmann::json::parse(json)); + }, + py::arg("group"), + "Deserialize from an HDF5 group.") + .def( + "to_json_file", + [](const TaperingSpecification& self, const py::object& filename) { + self.to_json_file( + qdk::chemistry::python::utils::to_string_path(filename)); + }, + py::arg("filename")) + .def_static( + "from_json_file", + [](const py::object& filename) { + return TaperingSpecification::from_json_file( + qdk::chemistry::python::utils::to_string_path(filename)); + }, + py::arg("filename")); py::class_ mapping( data, "MajoranaMapping", R"( diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py index 0b80346ed..183730b02 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qdk_qubit_mapper.py @@ -173,7 +173,7 @@ def _run_impl( n_qubits = base_mapping.num_qubits pauli_strings = [sparse_pauli_word_to_label(word, n_qubits) for word in words] - Logger.debug(f"Generated {len(pauli_strings)} Pauli terms for {2 * n_spatial} qubits") + Logger.debug(f"Generated {len(pauli_strings)} Pauli terms for {n_qubits} qubits") qh = QubitHamiltonian( pauli_strings=pauli_strings, diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 93912db1a..598339eb3 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -42,6 +42,10 @@ def _taper_qubits( raise ValueError("qubit_indices must not contain duplicates") nq = qubit_hamiltonian.num_qubits + if len(qubit_indices) >= nq: + raise ValueError( + f"Cannot taper all {nq} qubits — at least one qubit must remain" + ) for q in qubit_indices: if q < 0 or q >= nq: raise ValueError(f"Qubit index {q} out of range [0, {nq})") diff --git a/python/tests/test_interop_openfermion_qubit_mapper.py b/python/tests/test_interop_openfermion_qubit_mapper.py index cf58886d6..2ae2cb97e 100644 --- a/python/tests/test_interop_openfermion_qubit_mapper.py +++ b/python/tests/test_interop_openfermion_qubit_mapper.py @@ -109,7 +109,7 @@ def test_openfermion_bk_tree_encoding(): """ hamiltonian = create_nontrivial_test_hamiltonian() n = _num_spin_orbitals(hamiltonian) - mapping = MajoranaMapping.from_table(list(MajoranaMapping.jordan_wigner(n).table), name="bravyi-kitaev-tree") + mapping = MajoranaMapping.bravyi_kitaev_tree(n) qh = create("qubit_mapper", "openfermion").run(hamiltonian, mapping) diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index 7428251e2..559d71053 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -21,6 +21,7 @@ import tempfile import h5py +import numpy as np import pytest from qdk_chemistry._core.data import ( @@ -567,18 +568,13 @@ def test_hdf5_roundtrip_via_mapping(self) -> None: def test_json_contains_tapering_fields(self) -> None: """TaperingSpecification.to_json() produces the expected structure.""" - import json # noqa: PLC0415 - from qdk_chemistry.data import Symmetries, TaperingSpecification # noqa: PLC0415 tap = TaperingSpecification.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) - data = json.loads(tap.to_json()) + data = tap.to_json() assert "qubit_indices" in data assert "eigenvalues" in data - reconstructed = TaperingSpecification( - qubit_indices=list(data["qubit_indices"]), - eigenvalues=list(data["eigenvalues"]), - ) + reconstructed = TaperingSpecification.from_json(data) assert reconstructed == tap def test_hdf5_standalone_roundtrip(self) -> None: @@ -607,3 +603,140 @@ def test_parity_tapering_json_roundtrip_via_mapping(self) -> None: assert loaded.tapering is not None assert loaded.tapering.qubit_indices == par.tapering.qubit_indices assert loaded.tapering.eigenvalues == par.tapering.eigenvalues + + def test_tapering_standalone_json_roundtrip(self) -> None: + """TaperingSpecification round-trips through its own to_json/from_json.""" + from qdk_chemistry.data import Symmetries, TaperingSpecification # noqa: PLC0415 + + tap = TaperingSpecification.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + data = tap.to_json() + loaded = TaperingSpecification.from_json(data) + assert loaded == tap + assert loaded.qubit_indices == tap.qubit_indices + assert loaded.eigenvalues == tap.eigenvalues + + def test_tapering_standalone_hdf5_roundtrip(self) -> None: + """TaperingSpecification round-trips through its own to_hdf5/from_hdf5.""" + from qdk_chemistry.data import Symmetries, TaperingSpecification # noqa: PLC0415 + + tap = TaperingSpecification.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + with tempfile.NamedTemporaryFile(suffix=".h5") as f: + with h5py.File(f.name, "w") as hf: + tap.to_hdf5(hf) + with h5py.File(f.name, "r") as hf: + loaded = TaperingSpecification.from_hdf5(hf) + assert loaded == tap + + def test_tapering_hash(self) -> None: + """TaperingSpecification is hashable and works in sets/dicts.""" + from qdk_chemistry.data import Symmetries, TaperingSpecification # noqa: PLC0415 + + tap1 = TaperingSpecification.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + tap2 = TaperingSpecification.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + assert hash(tap1) == hash(tap2) + assert len({tap1, tap2}) == 1 + + +# ─── Bilinear-only Mapping Serialization ──────────────────────────────── + + +class TestBilinearOnlySerialization: + """Serialization round-trip tests for bilinear-only MajoranaMappings.""" + + @staticmethod + def _make_bilinear_mapping(num_modes: int = 4) -> MajoranaMapping: + """Create a bilinear-only mapping from a JW mapping's bilinears.""" + jw = MajoranaMapping.jordan_wigner(num_modes) + bilinears = [] + m = 2 * num_modes + for j in range(m): + for k in range(j + 1, m): + coeff, word = jw.bilinear(j, k) + bilinears.append((coeff, list(word))) + return MajoranaMapping.from_bilinears(num_modes, bilinears, name="test-bilinear") + + def test_json_roundtrip(self) -> None: + """Bilinear-only mapping survives JSON round-trip.""" + mapping = self._make_bilinear_mapping() + data = mapping.to_json() + loaded = MajoranaMapping.from_json(data) + assert not loaded.is_majorana_atomic + assert loaded.num_modes == mapping.num_modes + assert loaded.num_qubits == mapping.num_qubits + assert loaded.name == mapping.name + for j in range(2 * mapping.num_modes): + for k in range(j + 1, 2 * mapping.num_modes): + c_orig, w_orig = mapping.bilinear(j, k) + c_load, w_load = loaded.bilinear(j, k) + assert abs(c_orig - c_load) < 1e-14 + assert list(w_orig) == list(w_load) + + def test_hdf5_roundtrip(self) -> None: + """Bilinear-only mapping survives HDF5 round-trip.""" + mapping = self._make_bilinear_mapping() + with tempfile.NamedTemporaryFile(suffix=".h5") as f: + with h5py.File(f.name, "w") as hf: + mapping.to_hdf5(hf) + with h5py.File(f.name, "r") as hf: + loaded = MajoranaMapping.from_hdf5(hf) + assert not loaded.is_majorana_atomic + assert loaded.num_modes == mapping.num_modes + assert loaded.num_qubits == mapping.num_qubits + + def test_json_contains_bilinear_fields(self) -> None: + """Bilinear-only mapping JSON includes the bilinear and num_modes fields.""" + mapping = self._make_bilinear_mapping() + data = mapping.to_json() + assert data["table"] == [] + assert "bilinears" in data + assert "num_modes" in data + assert data["num_modes"] == mapping.num_modes + + +# ─── Tapered QubitHamiltonian Serialization ───────────────────────────── + + +class TestTaperedQubitHamiltonianSerialization: + """Round-trip tests for QubitHamiltonian with tapering metadata.""" + + @staticmethod + def _make_tapered_qh(): + """Create a QubitHamiltonian with tapering metadata.""" + from qdk_chemistry.data import QubitHamiltonian, Symmetries, TaperingSpecification # noqa: PLC0415 + + tap = TaperingSpecification.symmetry_conserving_bravyi_kitaev(8, Symmetries(2, 2)) + return QubitHamiltonian( + pauli_strings=["IIIIII", "ZZXXII", "XXYYZZ"], + coefficients=np.array([0.5, 0.25, -0.1]), + encoding="symmetry-conserving-bravyi-kitaev", + tapering=tap, + ) + + def test_json_roundtrip(self) -> None: + """Tapered QubitHamiltonian survives JSON round-trip.""" + import json # noqa: PLC0415 + + qh = self._make_tapered_qh() + data = qh.to_json() + assert "tapering" in data + assert isinstance(data["tapering"], dict) + loaded = type(qh).from_json(data) + assert loaded.tapering is not None + assert loaded.tapering.qubit_indices == qh.tapering.qubit_indices + assert loaded.tapering.eigenvalues == qh.tapering.eigenvalues + assert loaded.pauli_strings == qh.pauli_strings + + def test_hdf5_roundtrip(self) -> None: + """Tapered QubitHamiltonian survives HDF5 round-trip.""" + import numpy as np # noqa: PLC0415 + + qh = self._make_tapered_qh() + with tempfile.NamedTemporaryFile(suffix=".h5") as f: + with h5py.File(f.name, "w") as hf: + qh.to_hdf5(hf) + with h5py.File(f.name, "r") as hf: + loaded = type(qh).from_hdf5(hf) + assert loaded.tapering is not None + assert loaded.tapering.qubit_indices == qh.tapering.qubit_indices + assert loaded.tapering.eigenvalues == qh.tapering.eigenvalues + assert np.allclose(loaded.coefficients, qh.coefficients) From 7585c7b82ee6cb165dfd0afd882bdee16647c7a7 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 1 Jun 2026 20:21:37 +0000 Subject: [PATCH 115/117] Linting --- python/src/pybind11/data/majorana_mapping.cpp | 12 +++++------- .../algorithms/qubit_mapper/qubit_mapper.py | 4 +--- python/tests/test_majorana_mapping.py | 2 -- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index 60acefda7..95c20403e 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -56,9 +56,9 @@ Immutable specification for post-mapping qubit tapering. .def("__eq__", &TaperingSpecification::operator==, py::arg("other")) .def("__hash__", [](const TaperingSpecification& self) { - return py::hash(py::make_tuple( - py::tuple(py::cast(self.qubit_indices())), - py::tuple(py::cast(self.eigenvalues())))); + return py::hash( + py::make_tuple(py::tuple(py::cast(self.qubit_indices())), + py::tuple(py::cast(self.eigenvalues())))); }) .def( "to_json", @@ -82,8 +82,7 @@ Immutable specification for post-mapping qubit tapering. group.attr("attrs").attr("__setitem__")("json", self.to_json().dump()); }, - py::arg("group"), - "Serialize to an HDF5 group.") + py::arg("group"), "Serialize to an HDF5 group.") .def_static( "from_hdf5", [](const py::object& group) { @@ -93,8 +92,7 @@ Immutable specification for post-mapping qubit tapering. return TaperingSpecification::from_json( nlohmann::json::parse(json)); }, - py::arg("group"), - "Deserialize from an HDF5 group.") + py::arg("group"), "Deserialize from an HDF5 group.") .def( "to_json_file", [](const TaperingSpecification& self, const py::object& filename) { diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 598339eb3..3904b78d2 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -43,9 +43,7 @@ def _taper_qubits( nq = qubit_hamiltonian.num_qubits if len(qubit_indices) >= nq: - raise ValueError( - f"Cannot taper all {nq} qubits — at least one qubit must remain" - ) + raise ValueError(f"Cannot taper all {nq} qubits — at least one qubit must remain") for q in qubit_indices: if q < 0 or q >= nq: raise ValueError(f"Qubit index {q} out of range [0, {nq})") diff --git a/python/tests/test_majorana_mapping.py b/python/tests/test_majorana_mapping.py index 559d71053..6df7985bd 100644 --- a/python/tests/test_majorana_mapping.py +++ b/python/tests/test_majorana_mapping.py @@ -714,8 +714,6 @@ def _make_tapered_qh(): def test_json_roundtrip(self) -> None: """Tapered QubitHamiltonian survives JSON round-trip.""" - import json # noqa: PLC0415 - qh = self._make_tapered_qh() data = qh.to_json() assert "tapering" in data From 2b7edad557c9d1a37f63a5aaccfaedf97d0bdfd1 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 1 Jun 2026 21:35:08 +0000 Subject: [PATCH 116/117] Review fix --- python/src/pybind11/data/majorana_mapping.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/python/src/pybind11/data/majorana_mapping.cpp b/python/src/pybind11/data/majorana_mapping.cpp index 95c20403e..bf5db94a9 100644 --- a/python/src/pybind11/data/majorana_mapping.cpp +++ b/python/src/pybind11/data/majorana_mapping.cpp @@ -106,6 +106,20 @@ Immutable specification for post-mapping qubit tapering. return TaperingSpecification::from_json_file( qdk::chemistry::python::utils::to_string_path(filename)); }, + py::arg("filename")) + .def( + "to_hdf5_file", + [](const TaperingSpecification& self, const py::object& filename) { + self.to_hdf5_file( + qdk::chemistry::python::utils::to_string_path(filename)); + }, + py::arg("filename")) + .def_static( + "from_hdf5_file", + [](const py::object& filename) { + return TaperingSpecification::from_hdf5_file( + qdk::chemistry::python::utils::to_string_path(filename)); + }, py::arg("filename")); py::class_ mapping( From 134364f896a52558581fbc915c1181c168086d03 Mon Sep 17 00:00:00 2001 From: David Williams-Young Date: Mon, 1 Jun 2026 15:02:47 -0700 Subject: [PATCH 117/117] PR comments --- cpp/src/qdk/chemistry/data/majorana_mapping.cpp | 6 +++--- .../user/comprehensive/algorithms/qubit_mapper.rst | 10 ++++++---- .../algorithms/qubit_mapper/qubit_mapper.py | 1 + 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp index 27ed37bf5..69608bd59 100644 --- a/cpp/src/qdk/chemistry/data/majorana_mapping.cpp +++ b/cpp/src/qdk/chemistry/data/majorana_mapping.cpp @@ -35,10 +35,10 @@ MajoranaMapping::MajoranaMapping( if (base_encoding_.empty()) { base_encoding_ = name_; } - if (tapering_ && tapering_->num_tapered() > num_qubits_) { + if (tapering_ && tapering_->num_tapered() >= num_qubits_) { throw std::invalid_argument( - "MajoranaMapping tapering removes more qubits than the base mapping " - "contains"); + "MajoranaMapping tapering removes all (or more) qubits than the base " + "mapping contains — at least one qubit must remain"); } } diff --git a/docs/source/user/comprehensive/algorithms/qubit_mapper.rst b/docs/source/user/comprehensive/algorithms/qubit_mapper.rst index 110a65792..a25b930cd 100644 --- a/docs/source/user/comprehensive/algorithms/qubit_mapper.rst +++ b/docs/source/user/comprehensive/algorithms/qubit_mapper.rst @@ -169,10 +169,12 @@ This distinction has practical consequences: transform. - **Tapering** is each backend's responsibility. The base class provides - a ``_taper_result()`` helper that strips tapering from the mapping, - performs the base transform, and reapplies tapering to the output. All - shipped backends use this helper, but third-party backends are free to - handle tapering however they choose. + a ``_taper_result()`` helper that applies tapering and qubit relabeling + to an *already mapped* ``QubitHamiltonian``. Backends must first run + the base transform (typically using ``mapping.without_tapering()``) and + then call ``_taper_result()`` on the output. All shipped backends use + this helper, but third-party backends are free to handle tapering + however they choose. .. _qdk-qubit-mapper: diff --git a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py index 3904b78d2..8b85ca79e 100644 --- a/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py +++ b/python/src/qdk_chemistry/algorithms/qubit_mapper/qubit_mapper.py @@ -78,6 +78,7 @@ def _taper_qubits( new_coeffs.append(adjusted_coeff) new_nq = nq - len(qubit_indices) + assert new_nq >= 1, "Tapering all qubits should have been rejected above" if not new_strings: return QubitHamiltonian(