From 3041b760802dea23a91915f84999fe483e7baf09 Mon Sep 17 00:00:00 2001 From: Tianyi Hao Date: Wed, 25 Feb 2026 22:38:50 -0600 Subject: [PATCH 01/10] feat: reimplement flow matching using Gaussian elimination --- src/tqecd/flow.py | 6 +- src/tqecd/match.py | 4 +- src/tqecd/match_utils/__init__.py | 24 +-- src/tqecd/match_utils/cover.py | 257 +++++----------------------- src/tqecd/match_utils/cover_test.py | 54 +----- src/tqecd/match_utils/sat.py | 238 -------------------------- src/tqecd/pauli.py | 15 ++ 7 files changed, 77 insertions(+), 521 deletions(-) delete mode 100644 src/tqecd/match_utils/sat.py diff --git a/src/tqecd/flow.py b/src/tqecd/flow.py index a06e215..bd4aebe 100644 --- a/src/tqecd/flow.py +++ b/src/tqecd/flow.py @@ -6,7 +6,7 @@ from tqecd.boundary import BoundaryStabilizer from tqecd.exceptions import TQECDException from tqecd.fragment import Fragment, FragmentLoop -from tqecd.match_utils.cover import find_commuting_cover_on_target_qubits_sat +from tqecd.match_utils.cover import find_commuting_cover_on_target_qubits from tqecd.measurement import get_relative_measurement_index from tqecd.pauli import PauliString, pauli_product @@ -73,7 +73,7 @@ def _try_merge_anticommuting_flows_inplace(flows: list[BoundaryStabilizer]) -> N flows[fi].before_collapse for fi in anti_commuting_index_to_flows_index ] indices_of_anti_commuting_stabilizers_to_merge = ( - find_commuting_cover_on_target_qubits_sat( + find_commuting_cover_on_target_qubits( collapsing_pauli, anticommuting_stabilizers ) ) @@ -105,7 +105,7 @@ def _try_merge_anticommuting_flows_inplace(flows: list[BoundaryStabilizer]) -> N flows.append(new_commuting_stabilizer) # Update for loop condition indices_of_anti_commuting_stabilizers_to_merge = ( - find_commuting_cover_on_target_qubits_sat( + find_commuting_cover_on_target_qubits( collapsing_pauli, anticommuting_stabilizers ) ) diff --git a/src/tqecd/match.py b/src/tqecd/match.py index b6db721..ab9297b 100644 --- a/src/tqecd/match.py +++ b/src/tqecd/match.py @@ -10,7 +10,7 @@ from tqecd.boundary import BoundaryStabilizer from tqecd.exceptions import TQECDException from tqecd.flow import FragmentFlows, FragmentLoopFlows -from tqecd.match_utils.cover import find_exact_cover_sat +from tqecd.match_utils.cover import find_exact_cover from tqecd.measurement import RelativeMeasurementLocation @@ -457,7 +457,7 @@ def _match_boundary_stabilizers_by_disjoint_cover( # Try to find the cover. If unsuccessful, skip `target` and continue the loop. # Else, a detector is matched, so insert it into the returned list and mark # `target` as "to remove". - cover_indices = find_exact_cover_sat( + cover_indices = find_exact_cover( target.after_collapse, [cs.after_collapse for cs in covering_stabilizers] ) if cover_indices is None: diff --git a/src/tqecd/match_utils/__init__.py b/src/tqecd/match_utils/__init__.py index e2a7e00..ac8e379 100644 --- a/src/tqecd/match_utils/__init__.py +++ b/src/tqecd/match_utils/__init__.py @@ -6,22 +6,16 @@ The three functions re-exported by this module all aims at finding a list of Pauli strings from a given list that "cover" a target Pauli string: -- :func:`~.cover.find_cover` performs a brute-force search to find Pauli strings - from a provided candidate list that, once multiplied together, exactly match - the provided target Pauli string, -- :func:`~.cover.find_exact_cover_sat` performs the same task as - :func:`~.cover.find_cover` but encodes the problem as a SAT problem and uses a - SAT solver to find potential solutions instead of performing a brute-force - search. -- :func:`~.cover.find_commuting_cover_on_target_qubits_sat` uses the same - strategy as :func:`~.cover.find_exact_cover_sat` by using a SAT solver, but - solves a slightly different problem that consists in finding Pauli strings - that, once multiplied together, commute with a provided target Pauli string - (instead of being exactly equal to it). +- :func:`~.cover.find_exact_cover` performs a Gaussian elimination over GF(2) + to find Pauli strings from a provided candidate list that, once multiplied + together, exactly match the provided target Pauli string. +- :func:`~.cover.find_commuting_cover_on_target_qubits` uses the same strategy + as :func:`~.cover.find_exact_cover` but solves a slightly different problem + that consists in finding Pauli strings that, once multiplied together, commute + with a provided target Pauli string (instead of being exactly equal to it). """ from .cover import ( - find_commuting_cover_on_target_qubits_sat as find_commuting_cover_on_target_qubits_sat, + find_commuting_cover_on_target_qubits as find_commuting_cover_on_target_qubits, ) -from .cover import find_cover as find_cover -from .cover import find_exact_cover_sat as find_exact_cover_sat +from .cover import find_exact_cover as find_exact_cover diff --git a/src/tqecd/match_utils/cover.py b/src/tqecd/match_utils/cover.py index 6a59a15..95c2a07 100644 --- a/src/tqecd/match_utils/cover.py +++ b/src/tqecd/match_utils/cover.py @@ -2,186 +2,13 @@ from __future__ import annotations -import time -from typing import Iterator +from collections.abc import Callable, Iterable +from typing import Any -import pysat.solvers - -from tqecd.boundary import BoundaryStabilizer, manhattan_distance -from tqecd.match_utils.sat import ( - encode_pauli_string_commuting_cover_sat_problem_in_solver, - encode_pauli_string_exact_cover_sat_problem_in_solver, -) from tqecd.pauli import PauliString -def _all_pauli_string_combination_results( - pauli_strings: list[PauliString], -) -> Iterator[tuple[list[bool], PauliString]]: - """Iterate over all the possible Pauli string products. - - This function iterates over all the ``2**len(pauli_strings)`` products - that may be generated by either picking or not picking any of the - provided Pauli strings. - - It is efficient in the sense that it performs the minimum number of - Pauli products required to do so. - - Args: - pauli_strings: a list of Pauli strings that will be considered - in the returned products. - - Yields: - all the ``2**len(pauli_strings)`` possible Pauli products, one by one. - """ - yield from _all_pauli_string_combination_results_impl( - pauli_strings, PauliString({}) - ) - - -def _all_pauli_string_combination_results_impl( - pauli_strings: list[PauliString], current_pauli_string: PauliString -) -> Iterator[tuple[list[bool], PauliString]]: - """Iterate over all the possible Pauli string products. - - This function iterates over all the ``2**len(pauli_strings)`` products - that may be generated by either picking or not picking any of the - provided Pauli strings. - - It is efficient in the sense that it performs the minimum number of - Pauli products required to do so. - - Args: - pauli_strings: a list of Pauli strings that will be considered - in the returned products. - - Yields: - tuples containing a list of boolean of the exact same size as the - provided ``pauli_strings`` list and representing the Pauli strings - that have been considered in the product returned as the second - entry of the tuple. - """ - if len(pauli_strings) == 0: - yield [], current_pauli_string - return - yield from ( - ([True] + choices, res_pauli_string) - for choices, res_pauli_string in _all_pauli_string_combination_results_impl( - pauli_strings[1:], - current_pauli_string * pauli_strings[0], - ) - ) - yield from ( - ([False] + choices, res_pauli_string) - for choices, res_pauli_string in _all_pauli_string_combination_results_impl( - pauli_strings[1:], current_pauli_string - ) - ) - - -def find_cover( - target: BoundaryStabilizer, - sources: list[BoundaryStabilizer], - qubit_coordinates: dict[int, tuple[float, ...]], - maximum_qubit_distance: int = 5, -) -> list[BoundaryStabilizer] | None: - """Try to cover the provided ``target`` stabilizer with stabilizers from - ``sources``. - - This function is currently performing a bruteforce search: it tries all - combinations of stabilizers from ``sources`` and check if one matches with - the provided ``target``. - This approach blows up very quickly, as it may test up to ``2**len(sources)`` - different combinations before being able to tell that no match has been found. - - Note that **all** the combinations are tested, even the only combination that - consist in "picking nothing from the list", resulting in an empty PauliString. - To avoid any surprise, and because we do not expect an empty ``target`` to make - sense here, this function will raise on such a case. - - Args: - target: the boundary stabilizer to try to match with the provided - ``sources``. - sources: the boundary stabilizers that this function will try to combine to - find a stabilizer involving ``target``. - qubit_coordinates: a mapping from qubit indices to coordinates. Used to - annotate the matched detectors with the coordinates from the qubits - involved in the measurement forming the detector. - maximum_qubit_distance: radius (in number of qubits) to consider when - searching for covering boundary stabilizers. Any boundary stabilizer - in ``sources`` that has coordinates outside of that radius from - ``target`` will not be considered, reducing the overall complexity - of this function. The radius is computed with the Manhattan distance. - - Raises: - TQECDException: if ``target`` is the empty PauliString. - TQECDException: if any of the provided instances in ``target`` or - ``sources`` has anti-commuting stabilizers. - - Returns: - a matching set of boundary stabilizers or None if no matching stabilizers - could be found. - """ - - sources = [ - s - for s in sources - if manhattan_distance(target, s, qubit_coordinates) <= maximum_qubit_distance - ] - - after_collapse_sources = [s.after_collapse for s in sources] - for ( - picked_stabilizers, - resulting_pauli_string, - ) in _all_pauli_string_combination_results(after_collapse_sources): - if target.after_collapse == resulting_pauli_string: - return [ - boundary_stabilizer - for i, boundary_stabilizer in enumerate(sources) - if picked_stabilizers[i] - ] - return None - - -def _smallest_solution_shortcircuit( - solutions: Iterator[list[int]], lower_length_bound: int = 0, timeout: float = 0.1 -) -> list[int] | None: - """Iterate over the provided ``solutions`` iterator to find the smallest - possible solution within the provided ``timeout``. - - Args: - solutions: iterator yielding lists of integers representing the indices of - Pauli strings that should be used to cover a specific target. - lower_length_bound: The minimum expected length. This parameter is used to - quit early if a solution with this length is found. Default to exploring - all the solutions to find the best one. - timeout: maximum time in seconds that can be spent finding the smallest - solution. The provided ``solutions`` iterator might yield millions of - inputs, so this parameter is here to stop the search and return the best - found solution when more than ``timeout`` seconds have been spent within - this function. - - Returns: - the smallest solution found, or ``None`` if no solution was found. - """ - start_time: float = time.monotonic() - smallest_solution = next(solutions, None) - - if smallest_solution is None: - return None - if len(smallest_solution) == lower_length_bound: - return smallest_solution - - for solution in solutions: - if len(solution) == lower_length_bound: - return solution - smallest_solution = min((smallest_solution, solution), key=len) - if time.monotonic() - start_time > timeout: - return smallest_solution - return smallest_solution - - -def _find_cover_sat( +def _find_cover( target: PauliString, sources: list[PauliString], on_qubits: frozenset[int] ) -> list[int] | None: """Try to find a set of boundary stabilizers from ``sources`` that generate @@ -202,20 +29,13 @@ def _find_cover_sat( exactly the provided ``target`` on all the qubits provided in ``on_qubits``, or ``None`` if such a list could not be found. """ - with pysat.solvers.CryptoMinisat() as solver: - encode_pauli_string_exact_cover_sat_problem_in_solver( - solver, target, sources, on_qubits - ) - return _smallest_solution_shortcircuit( - ( - [i - 1 for i in satisfying_proof if i > 0] - for satisfying_proof in solver.enum_models() - ), - 2, - ) + return _solve_linear_system( + _construct_basis({}, sources, lambda s: s.to_int(on_qubits)), + target.to_int(on_qubits), + ) -def find_exact_cover_sat( +def find_exact_cover( target: PauliString, sources: list[PauliString] ) -> list[int] | None: """Try to find a set of pauli strings from ``sources`` that generate exactly @@ -225,15 +45,11 @@ def find_exact_cover_sat( multiplied together, should be exactly equal to ``target``. In particular, the following post-condition should hold: - If multiple valid covers exist, the covers involving the lowest number of - :class:`~tqecd.pauli.PauliString` instances from ``sources`` are listed, and - a random cover is picked from that list. - .. code-block:: python target = None # to replace sources = [None] # to replace - cover_indices = find_exact_cover_sat(target, sources) + cover_indices = find_exact_cover(target, sources) resulting_pauli_string = PauliString({}) for i in cover_indices: resulting_pauli_string = resulting_pauli_string * sources[i] @@ -248,8 +64,7 @@ def find_exact_cover_sat( exactly the provided ``target``, or ``None`` if such a list could not be found. """ - # If target is the identity, we do not have to call a SAT solver to find - # a solution: pick no Pauli string. + # If target is the identity, pick no Pauli string. # Note: we might want to disallow an empty return in the future. if target.non_trivial_pauli_count == 0: return [] @@ -266,10 +81,10 @@ def find_exact_cover_sat( for source in sources: involved_qubits |= frozenset(source.qubits) - return _find_cover_sat(target, sources, involved_qubits) + return _find_cover(target, sources, involved_qubits) -def find_commuting_cover_on_target_qubits_sat( +def find_commuting_cover_on_target_qubits( target: PauliString, sources: list[PauliString] ) -> list[int] | None: """Try to find a set of boundary stabilizers from ``sources`` that generate a @@ -280,11 +95,7 @@ def find_commuting_cover_on_target_qubits_sat( the product of each of the returned Pauli strings should commute with ``target``). - If multiple valid covers exist, the covers involving the lowest number of - :class:`~tqecd.pauli.PauliString` instances from ``sources`` are listed, and - a random cover is picked from that list. - - The differences with :func:`find_cover_sat` are: + The differences with :func:`find_cover` are: 1. this function does not restrict the output of the product of each of the returned Pauli string on qubits where ``target`` acts trivially (i.e. @@ -305,15 +116,35 @@ def find_commuting_cover_on_target_qubits_sat( """ if not sources: return None + return _find_cover(target, sources, frozenset(target.qubits)) + - with pysat.solvers.CryptoMinisat() as solver: - encode_pauli_string_commuting_cover_sat_problem_in_solver( - solver, target, sources, frozenset(target.qubits) - ) - return _smallest_solution_shortcircuit( - ( - [i - 1 for i in satisfying_proof if i > 0] - for satisfying_proof in solver.enum_models() - ), - 2, - ) +def _solve_linear_system( + basis: dict[int, tuple[int, int]], x: int, update_basis: bool = True +) -> list[int] | None: + """Gaussian elimination over GF(2).""" + mask = 1 << len(basis) + while x: + highest_bit = x.bit_length() - 1 + if highest_bit not in basis: + if update_basis: + basis[highest_bit] = (x, mask) + return None + pivot, pivot_mask = basis[highest_bit] + x ^= pivot + mask ^= pivot_mask + return _int_to_bit_indices(mask)[:-1] + + +def _int_to_bit_indices(x: int) -> list[int]: + """Convert an integer to a list of indices where the bits are set.""" + return [i for i in range(x.bit_length()) if (x >> i) & 1] + + +def _construct_basis( + basis: dict[int, tuple[int, int]], items: Iterable[Any], func: Callable[[Any], int] +) -> dict[int, tuple[int, int]]: + """Construct a linear basis from the given items using the provided function.""" + for item in items: + _solve_linear_system(basis, func(item)) + return basis diff --git a/src/tqecd/match_utils/cover_test.py b/src/tqecd/match_utils/cover_test.py index f2dcdf2..8b9a396 100644 --- a/src/tqecd/match_utils/cover_test.py +++ b/src/tqecd/match_utils/cover_test.py @@ -1,15 +1,11 @@ from __future__ import annotations -import itertools -from typing import Iterator - import pytest import stim from tqecd.match_utils.cover import ( - _all_pauli_string_combination_results, # pyright: ignore[reportPrivateUsage] - find_commuting_cover_on_target_qubits_sat, - find_exact_cover_sat, + find_commuting_cover_on_target_qubits, + find_exact_cover, ) from tqecd.pauli import PauliString, pauli_product @@ -18,48 +14,6 @@ def _pss(pauli_string: str) -> PauliString: return PauliString.from_stim_pauli_string(stim.PauliString(pauli_string)) -def _all_pauli_string_combination_results_alternative_implementation( - pauli_string_list: list[PauliString], -) -> Iterator[PauliString]: - yield from ( - pauli_product([p for p, c in zip(pauli_string_list, choices) if c]) - for choices in itertools.product([True, False], repeat=len(pauli_string_list)) - ) - - -def test_all_pauli_string_combination_results_randomized() -> None: - paulis = [ - PauliString.from_stim_pauli_string(stim.PauliString.random(10)) - for _ in range(5) - ] - - from_function = { - pauli for _, pauli in _all_pauli_string_combination_results(paulis) - } - from_alternative_implementation = set( - _all_pauli_string_combination_results_alternative_implementation(paulis) - ) - assert from_function == from_alternative_implementation - - -def test_all_pauli_string_combination_results() -> None: - paulis = [PauliString({i: "Z"}) for i in range(3)] - all_combinations = list( - pauli for _, pauli in _all_pauli_string_combination_results(paulis) - ) - assert len(all_combinations) == 2 ** len(paulis) - assert set(all_combinations) == { - PauliString({}), - PauliString({0: "Z"}), - PauliString({1: "Z"}), - PauliString({2: "Z"}), - PauliString({0: "Z", 1: "Z"}), - PauliString({0: "Z", 2: "Z"}), - PauliString({1: "Z", 2: "Z"}), - PauliString({0: "Z", 1: "Z", 2: "Z"}), - } - - @pytest.mark.parametrize( "target,sources,expected_result", [ @@ -117,7 +71,7 @@ def test_all_pauli_string_combination_results() -> None: def test_exact_match( target: PauliString, sources: list[PauliString], expected_result: list[int] | None ) -> None: - obtained_result = find_exact_cover_sat(target, sources) + obtained_result = find_exact_cover(target, sources) # We expect the results to either both be None, or both be a list. assert (obtained_result is None) == (expected_result is None) # If they are both a list, compare them and check the post-condition documented. @@ -154,7 +108,7 @@ def test_exact_match( def test_commuting_match( target: PauliString, sources: list[PauliString], expected_result: list[int] | None ) -> None: - obtained_result = find_commuting_cover_on_target_qubits_sat(target, sources) + obtained_result = find_commuting_cover_on_target_qubits(target, sources) # We expect the results to either both be None, or both be a list. assert (obtained_result is None) == (expected_result is None) # If they are both a list, compare them and check the post-condition documented. diff --git a/src/tqecd/match_utils/sat.py b/src/tqecd/match_utils/sat.py deleted file mode 100644 index ba03c7e..0000000 --- a/src/tqecd/match_utils/sat.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Semi-private module providing functions to construct SAT problems. - -This module is semi-private in the sense that even though the two functions it -implements are not private, they are quite low-level and are not expected to be -used by external users of the library. - -The module defines 2 functions that each take a SAT solver able to encode XOR -clauses, a target Pauli string, a list of potential Pauli strings and a set of -qubits to consider and encode in the provided SAT solver a cover problem, either -exact (i.e., find Pauli strings in the list of potential Pauli strings such that -their product is exactly the target Pauli string on the considered qubits) or -commuting (i.e., the product only needs to commute with the target Pauli string -on the considered qubits). -""" - -from __future__ import annotations - -from pysat.solvers import CryptoMinisat - -from tqecd.pauli import PauliString, pauli_literal_to_bools - - -def encode_pauli_string_exact_cover_sat_problem_in_solver( - solver: CryptoMinisat, - expected_pauli_string: PauliString, - available_pauli_strings: list[PauliString], - qubits_to_consider: frozenset[int], -) -> None: - """Build the SAT problem that should be solved to find an exact cover. - - This function encodes the SAT problem of interest into the provided ``solver``. - As such, the provided ``solver`` is expected to be a newly created instance, - and will be mutated by this function. - - The encoded problem is the following: - - Find the indices of Pauli strings in ``available_pauli_strings`` such that - the product of all the corresponding Pauli strings is exactly equal to - ``expected_pauli_string`` on all the qubits in ``qubits_to_consider``. - - The following post-condition should be checked after solving: - - .. code-block:: python - - expected_pauli_string = PauliString({}) # Fill in! - available_pauli_strings = [PauliString({})] # Fill in! - qubits_to_consider = frozenset() # Fill in! - solver = None # Fill in! - - encode_pauli_string_exact_cover_sat_problem_in_solver( - solver, expected_pauli_string, available_pauli_strings, qubits_to_consider - ) - indices = None # Solve the problem encoded in solver and recover the indices. - - final_pauli_string = pauli_product([available_pauli_strings[i] for i in indices]) - for q in qubits_to_consider: - assert final_pauli_string[q] == expected_pauli_string[q] - - - The SAT problem contains one boolean variable per Pauli string in - ``available_pauli_strings``, deciding if the associated Pauli string should - be part of the cover. - - The formula is quite simple: for each qubit in ``qubits_to_consider``, the - following two boolean formulas should be ``True``: - - 1. The X part of the resulting Pauli string, obtained from XORing the X part - of each Pauli string weighted by the boolean variable that decides if the - Pauli string should be included or not, should be equal to the X part of - the expected final Pauli string. - 2. Same reasoning, but for the Z part. - - Note that the "should be equal to the [P] part of the expected final Pauli - string" is natively implemented in the SAT solver used, so we do not need to - adapt the formula with some binary logic tricks. The solver really solves a - conjunction of ``a XOR b XOR ... XOR z == [True/False]``. - - This leads to a formula with ``2*len(qubits_to_consider)`` XOR clauses that - should all be verified for a cover to exist. - Each of the ``2*len(qubits_to_consider)`` XOR clauses can contain up to - ``len(available_pauli_strings)`` XORed items, but a simplification is made - that should reduce that number: if the Pauli string acts trivially w.r.t the - considered Pauli effect (X or Z), the boolean variable is removed from the - formula as we know for sure that this particular Pauli string cannot impact - the result on that specific qubit and Pauli effect. - - Args: - solver: solver that will be modified in-place to encode the SAT problem. - expected_pauli_string: target Pauli string that should be covered by - strings from ``available_pauli_strings``. - available_pauli_strings: Pauli strings that can be used to try to cover - ``expected_pauli_string``. - qubits_to_consider: qubits on which the cover should be exactly equal to - ``expected_pauli_string``. Qubits not listed in this input will - simply be ignored and no restriction on the value of the resulting - cover on those qubits is added. - """ - - for qubit in qubits_to_consider: - expected_X, expected_Z = pauli_literal_to_bools(expected_pauli_string[qubit]) - available_paulis_bools: list[tuple[bool, bool]] = [ - pauli_literal_to_bools(pauli[qubit]) for pauli in available_pauli_strings - ] - # The two following lists includes the 1-based indices of Pauli strings from - # the provided `available_pauli_strings` input that finish with a X/Z stabilizers - # on the current `qubit`. - # For each index, we want to know if it should be included in the final cover or - # not, so XORing the boolean variables representing whether or not this Pauli - # string should be included results in the stabilizer propagated on that qubit. - x_clause_literals = [ - pi + 1 for pi, (px, _) in enumerate(available_paulis_bools) if px - ] - z_clause_literals = [ - pi + 1 for pi, (_, pz) in enumerate(available_paulis_bools) if pz - ] - solver.add_xor_clause(x_clause_literals, value=expected_X) - solver.add_xor_clause(z_clause_literals, value=expected_Z) - - -def encode_pauli_string_commuting_cover_sat_problem_in_solver( - solver: CryptoMinisat, - expected_pauli_string: PauliString, - available_pauli_strings: list[PauliString], - qubits_to_consider: frozenset[int], -) -> None: - """Build the SAT problem that should be solved to find a commuting cover. - - This function encodes the SAT problem of interest into the provided ``solver``. - As such, the provided ``solver`` is expected to be a newly created instance, - and will be mutated by this function. - - The encoded problem is the following: - - Find the indices of Pauli strings in ``available_pauli_strings`` such that - the product of all the corresponding Pauli strings commutes with - ``expected_pauli_string`` on all the qubits in ``qubits_to_consider``. - - The SAT problem contains one boolean variable per Pauli string in - ``available_pauli_strings``, deciding if the associated Pauli string should - be part of the cover. - - The formula is quite simple: for each qubit in ``qubits_to_consider``, the - following two boolean formulas should be ``True``: - - 1. The X part of the resulting Pauli string, obtained from XORing the X part - of each Pauli string weighted by the boolean variable that decides if the - Pauli string should be included or not, should be equal to the X part of - the expected final Pauli string or be equal to I. - 2. Same reasoning, but for the Z part. - - Note that the "should be equal to the [P] part of the expected final Pauli - string" is natively implemented in the SAT solver used, so we do not need to - adapt the formula with some binary logic tricks. The solver really solves a - conjunction of ``a XOR b XOR ... XOR z == [True/False]``. - - Also note that the "or be equal to I" part is a little bit more tricky and - requires some care. The logic retained for each of the possible Pauli terms - is explained inline within the code. - - The number of XOR clauses in the formula depends on the number of each Pauli - terms in the input ``expected_pauli_string`` that is considered (i.e. on - qubits in ``qubits_to_consider``). Let [P] be the number of Pauli term equal - to P in ``expected_pauli_string``, the number of XOR clauses in the - resulting formula is ``(2 * [I] + [X] + [Y] + [Z]) * len(qubits_to_consider)``. - That means that there are less clauses in the SAT problem defined in this - function than in the one defined in - :func:`encode_pauli_string_exact_cover_sat_problem_in_solver`, except if - ``expected_pauli_string`` only contains identity gates, in which case the - two functions return the same number of XOR clauses. - - Just like :func:`encode_pauli_string_exact_cover_sat_problem_in_solver`, the - number of terms in each XOR clause can vary depending on the provided Pauli - strings in ``available_pauli_strings``. - - Also note that the problem defined above includes a trivial solution: do not - include any Pauli string. This lead to an identity Pauli string, that will - necessarily commute with the provided target. A specific clause is added to - the SAT problem to avoid that particular trivial solution. - - Args: - solver: solver that will be modified in-place to encode the SAT problem. - expected_pauli_string: target Pauli string that should be covered by - strings from ``available_pauli_strings``. - available_pauli_strings: Pauli strings that can be used to try to cover - ``expected_pauli_string``. - qubits_to_consider: qubits on which the cover should be exactly equal to - ``expected_pauli_string``. Qubits not listed in this input will - simply be ignored and no restriction on the value of the resulting - cover on those qubits is added. - """ - # Clause to exclude the trivial solution "include no Pauli string at all" - solver.add_clause([lit + 1 for lit in range(len(available_pauli_strings))]) - - for qubit in qubits_to_consider: - expected_effect = expected_pauli_string[qubit] - available_paulis_bools: list[tuple[bool, bool]] = [ - pauli_literal_to_bools(pauli[qubit]) for pauli in available_pauli_strings - ] - # The two following lists includes the 1-based indices of Pauli strings from - # the provided `available_pauli_strings` input that finish with a X/Z stabilizers - # on the current `qubit`. - # For each index, we want to know if it should be included in the final cover or - # not, so XORing the boolean variables representing whether or not this Pauli - # string should be included results in the stabilizer propagated on that qubit. - x_clause_literals = [ - pi + 1 for pi, (px, _) in enumerate(available_paulis_bools) if px - ] - z_clause_literals = [ - pi + 1 for pi, (_, pz) in enumerate(available_paulis_bools) if pz - ] - if expected_effect == "I": - # Both the X and Z effect on that qubit should be OFF (i.e., identity). - solver.add_xor_clause(x_clause_literals, value=False) - solver.add_xor_clause(z_clause_literals, value=False) - elif expected_effect == "X": - # The X effect should be ON, the Z effect should be OFF. - # Because the identity (0, 0) is also valid, X can be either ON or OFF, - # so we do not need to restrict X effect. - solver.add_xor_clause(z_clause_literals, value=False) - elif expected_effect == "Y": - # This is an expected Y effect. This means that X and Z should be both - # ON (i.e., the Y effect) or both OFF (i.e., the identity effect). - # Rephrasing using XOR, this is equivalent to `X_effect XOR Z_effect == 0`. - # Note that measurements appearing twice here can be removed, as: - # - the XOR operation is commutative (so we can re-organise the measurements - # to be sorted by index). - # - b XOR b = 0. - # - b XOR 0 = b. - # Also, a given measurement cannot appear more than twice, so we are fine - # just removing duplicated entries. - solver.add_xor_clause( - list(set(x_clause_literals) ^ set(z_clause_literals)), value=False - ) - elif expected_effect == "Z": - # The X effect should be OFF, the Z effect should be ON. - # Because the identity (0, 0) is also valid, Z can be either ON or OFF, - # so we do not need to restrict Z effect. - solver.add_xor_clause(x_clause_literals, value=False) diff --git a/src/tqecd/pauli.py b/src/tqecd/pauli.py index 2a19e75..1dad510 100644 --- a/src/tqecd/pauli.py +++ b/src/tqecd/pauli.py @@ -9,6 +9,7 @@ from __future__ import annotations import functools +from itertools import accumulate, chain, repeat import operator from typing import Iterable, Literal @@ -197,6 +198,20 @@ def __hash__(self) -> int: def __getitem__(self, index: int) -> PAULI_STRING_TYPE: return self._pauli_by_qubit.get(index, "I") + def to_int(self, qubits: Iterable[int]) -> int: + """Convert the Pauli string to an integer representation on the provided qubits.""" + return _concat_ints_as_bits( + chain.from_iterable(pauli_literal_to_bools(self[q]) for q in qubits), + bit_length=1, + ) + + +def _concat_ints_as_bits(ints: Iterable[int], bit_length: int | Iterable[int]) -> int: + """Concatenate a list of integers as bits to form a single integer.""" + if isinstance(bit_length, int): + bit_length = repeat(bit_length) + return sum(x << shift for x, shift in zip(ints, chain([0], accumulate(bit_length)))) + def pauli_literal_to_bools( literal: PAULI_STRING_TYPE, From 7b2fffcbd6bc6739537f4a0c6aaf3ed4caccb28d Mon Sep 17 00:00:00 2001 From: Tianyi Hao Date: Wed, 25 Feb 2026 22:39:00 -0600 Subject: [PATCH 02/10] chore: remove sat-solver dependencies --- pyproject.toml | 7 +++++-- requirements.txt | 4 ---- setup.py | 3 --- 3 files changed, 5 insertions(+), 9 deletions(-) delete mode 100644 requirements.txt delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml index ed19566..2c087d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,10 @@ classifiers = [ "Topic :: Utilities", "Typing :: Typed", ] -dynamic = ["dependencies"] +dependencies = [ + "numpy<=2.3.0", + "stim>=1.15.0", +] [project.urls] Documentation = "https://tqec.github.io/tqecd/" @@ -106,7 +109,7 @@ warn_return_any = true # See https://mypy.readthedocs.io/en/stable/config_file.html#using-a-pyproject-toml [[tool.mypy.overrides]] -module = ["stim", "pysat", "pysat.solvers"] +module = ["stim"] ignore_missing_imports = true [tool.coverage.run] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index b51c8ed..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -numpy -stim -pycryptosat!=5.11.23 -python-sat[cryptosat] diff --git a/setup.py b/setup.py deleted file mode 100644 index 6068493..0000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() From 91268c731784d148a6e7ae4778dd0566339a11fb Mon Sep 17 00:00:00 2001 From: Tianyi Hao Date: Wed, 25 Feb 2026 22:39:12 -0600 Subject: [PATCH 03/10] chore: update installation instructions --- README.md | 2 -- docs/user_guide/installation.rst | 15 --------------- 2 files changed, 17 deletions(-) diff --git a/README.md b/README.md index 72dfeb6..2d9ec3e 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,6 @@ Currently, `tqecd` needs to be installed from source using python -m pip install git+https://github.com/tqec/tqecd.git ``` -The `tqecd` package has some dependencies that might be harder to install than a simple `pip install`. If you have any issues with the simple installation method above, please look at the [full installation page](https://tqec.github.io/tqecd/user_guide/installation.html). - ## Basic usage ```py diff --git a/docs/user_guide/installation.rst b/docs/user_guide/installation.rst index 63a5faf..d7a6b68 100644 --- a/docs/user_guide/installation.rst +++ b/docs/user_guide/installation.rst @@ -10,21 +10,6 @@ Python version The ``tqecd`` package only supports Python 3.10 and onward. If you have Python 3.9 or below, please update your Python installation. -Additional toolchains -~~~~~~~~~~~~~~~~~~~~~ - -Some of the dependencies of ``tqecd`` are implemented using compiled languages. This is for -example the case of the `pycryptosat `_ dependency. -Pre-compiled Python packages that should be compatible with any GNU/Linux are provided -by the author, but no pre-compiled package exist for Windows or MacOS. - -This means that, if you try to install the ``tqecd`` package on Windows or MacOS, a working -C++ toolchain should also be installed on your system. - -Here is a list of potential issues you might encounter and how to solve them: - -- `Failed building wheel for pycryptosat `_ - Installation procedure ---------------------- From 99bf2ac09a10129ae18a051e505bd3d9a118dc87 Mon Sep 17 00:00:00 2001 From: Tianyi Hao Date: Wed, 25 Feb 2026 22:40:08 -0600 Subject: [PATCH 04/10] chore: bump tqecd version and required Python version --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2c087d1..e3acbde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tqecd" -version = "0.1.3" +version = "0.1.4" authors = [ { name = "TQEC community", email = "tqec-design-automation@googlegroups.com" }, ] @@ -12,7 +12,7 @@ description = "Automatically find detectors in a topologically quantum error cor readme = "README.md" license = { file = "LICENSE" } keywords = ["topological quantum error correction", "qec", "detectors"] -requires-python = ">= 3.9" +requires-python = ">= 3.10" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", From 6ddaa3b34fb8df2e1375f43ae089ee5795c13091 Mon Sep 17 00:00:00 2001 From: Tianyi Hao Date: Wed, 25 Feb 2026 23:09:16 -0600 Subject: [PATCH 05/10] chore: move cover.py to the top-level module and remove match_utils --- src/tqecd/{match_utils => }/cover.py | 0 src/tqecd/{match_utils => }/cover_test.py | 2 +- src/tqecd/flow.py | 2 +- src/tqecd/match.py | 2 +- src/tqecd/match_utils/__init__.py | 21 --------------------- 5 files changed, 3 insertions(+), 24 deletions(-) rename src/tqecd/{match_utils => }/cover.py (100%) rename src/tqecd/{match_utils => }/cover_test.py (98%) delete mode 100644 src/tqecd/match_utils/__init__.py diff --git a/src/tqecd/match_utils/cover.py b/src/tqecd/cover.py similarity index 100% rename from src/tqecd/match_utils/cover.py rename to src/tqecd/cover.py diff --git a/src/tqecd/match_utils/cover_test.py b/src/tqecd/cover_test.py similarity index 98% rename from src/tqecd/match_utils/cover_test.py rename to src/tqecd/cover_test.py index 8b9a396..742370d 100644 --- a/src/tqecd/match_utils/cover_test.py +++ b/src/tqecd/cover_test.py @@ -3,7 +3,7 @@ import pytest import stim -from tqecd.match_utils.cover import ( +from tqecd.cover import ( find_commuting_cover_on_target_qubits, find_exact_cover, ) diff --git a/src/tqecd/flow.py b/src/tqecd/flow.py index bd4aebe..e8c5a46 100644 --- a/src/tqecd/flow.py +++ b/src/tqecd/flow.py @@ -4,9 +4,9 @@ from dataclasses import dataclass from tqecd.boundary import BoundaryStabilizer +from tqecd.cover import find_commuting_cover_on_target_qubits from tqecd.exceptions import TQECDException from tqecd.fragment import Fragment, FragmentLoop -from tqecd.match_utils.cover import find_commuting_cover_on_target_qubits from tqecd.measurement import get_relative_measurement_index from tqecd.pauli import PauliString, pauli_product diff --git a/src/tqecd/match.py b/src/tqecd/match.py index ab9297b..753cf41 100644 --- a/src/tqecd/match.py +++ b/src/tqecd/match.py @@ -8,9 +8,9 @@ import stim from tqecd.boundary import BoundaryStabilizer +from tqecd.cover import find_exact_cover from tqecd.exceptions import TQECDException from tqecd.flow import FragmentFlows, FragmentLoopFlows -from tqecd.match_utils.cover import find_exact_cover from tqecd.measurement import RelativeMeasurementLocation diff --git a/src/tqecd/match_utils/__init__.py b/src/tqecd/match_utils/__init__.py deleted file mode 100644 index ac8e379..0000000 --- a/src/tqecd/match_utils/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Provides a few utility functions to match flows. - -This module provides functions to encode the problem of finding matching flows -as a SAT problem, as well as a function to solve that SAT problem. - -The three functions re-exported by this module all aims at finding a list of -Pauli strings from a given list that "cover" a target Pauli string: - -- :func:`~.cover.find_exact_cover` performs a Gaussian elimination over GF(2) - to find Pauli strings from a provided candidate list that, once multiplied - together, exactly match the provided target Pauli string. -- :func:`~.cover.find_commuting_cover_on_target_qubits` uses the same strategy - as :func:`~.cover.find_exact_cover` but solves a slightly different problem - that consists in finding Pauli strings that, once multiplied together, commute - with a provided target Pauli string (instead of being exactly equal to it). -""" - -from .cover import ( - find_commuting_cover_on_target_qubits as find_commuting_cover_on_target_qubits, -) -from .cover import find_exact_cover as find_exact_cover From cc13aa1b582e4dfa740ea825843204d0915552ff Mon Sep 17 00:00:00 2001 From: Tianyi Hao Date: Thu, 26 Feb 2026 02:35:40 -0600 Subject: [PATCH 06/10] chore: update CI --- .github/workflows/ci.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fa1193..0a8b6ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" cache: "pip" - name: Install dependencies run: | @@ -29,7 +29,7 @@ jobs: needs: pre-commit-ci strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python @@ -37,17 +37,15 @@ jobs: with: python-version: ${{ matrix.python-version }} cache: "pip" - - name: Install dependencies + - name: Build run: | python -m pip install --upgrade pip - python -m pip install -r requirements.txt + python -m pip install -e . - name: Analyze code with pylint run: | python -m pip install pylint pylint $(git ls-files '*.py') continue-on-error: true - - name: Build - run: python -m pip install -e . - name: Test and coverage run: | python -m pip install pytest pytest-cov @@ -69,7 +67,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.13" cache: "pip" - name: Install dependencies and tqec package run: | From b2502aae3080eca174ad8f5c9d9fdb466cfaf976 Mon Sep 17 00:00:00 2001 From: Tianyi Hao Date: Thu, 26 Feb 2026 23:47:54 -0600 Subject: [PATCH 07/10] chore: fix some Python versions in CI --- .github/workflows/ci.yml | 2 +- .github/workflows/exotic-oses.yml | 12 +++++------- pyproject.toml | 5 +---- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a8b6ca..877ab0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: needs: pre-commit-ci strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - name: Set up Python diff --git a/.github/workflows/exotic-oses.yml b/.github/workflows/exotic-oses.yml index 0ec70df..86bf02d 100644 --- a/.github/workflows/exotic-oses.yml +++ b/.github/workflows/exotic-oses.yml @@ -21,21 +21,19 @@ jobs: uses: actions/setup-python@v5 with: # Use smallest supported Python version as the common denominator. - # In theory, if everything succeed in 3.9, everything should succeed in - # 3.10, 3.11 and follow-up version. - python-version: "3.9" + # In theory, if everything succeed in 3.10, everything should succeed in + # 3.11 and follow-up versions. + python-version: "3.10" cache: "pip" - - name: Install dependencies + - name: Build run: | python -m pip install --upgrade pip - python -m pip install -r requirements.txt + python -m pip install '.[all]' - name: Analyze code with pylint run: | python -m pip install pylint pylint $(git ls-files '*.py') continue-on-error: true - - name: Build - run: python -m pip install '.[all]' - name: Test and coverage run: | python -m pip install pytest pytest-cov diff --git a/pyproject.toml b/pyproject.toml index e3acbde..259a4a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries", "Topic :: Utilities", @@ -67,10 +68,6 @@ exclude = ["*test.py"] [tool.setuptools.package-data] tqecd = ["py.typed"] -[tool.setuptools.dynamic] -dependencies = { file = "requirements.txt" } - - [tool.mypy] # See https://numpy.org/devdocs/reference/typing.html plugins = "numpy.typing.mypy_plugin" From 5e34260175dc484e9f1fb9e1da1cdebf75a72caa Mon Sep 17 00:00:00 2001 From: Tianyi Hao Date: Thu, 9 Apr 2026 00:19:00 -0500 Subject: [PATCH 08/10] chore: keep the numpy version bound consistent with tqec --- pyproject.toml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 259a4a9..c464d45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,10 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "numpy<=2.3.0", + "numpy>=1.22,<2.3", # Upper bound: https://github.com/tqec/tqec/pull/659 + "numpy>=1.24; python_version >= '3.11'", + "numpy>=1.26; python_version >= '3.12'", + "numpy>=2.1; python_version >= '3.13'", "stim>=1.15.0", ] @@ -55,7 +58,7 @@ doc = [ ] test = ["pytest<=8.4.1", "mypy<=1.18.1", "pytest-cov<=7.0.0"] bench = ["pyinstrument<=5.1.1"] -dev = ["pre-commit<=4.2.0", "tqecd[test, doc, bench]","ruff<=0.14.2",] +dev = ["pre-commit<=4.2.0", "tqecd[test, doc, bench]", "ruff<=0.14.2"] all = ["tqecd[test, dev, doc, bench]"] [tool.setuptools] From 49922d43be732a57e5d228c8b2d84a0c1a669ac5 Mon Sep 17 00:00:00 2001 From: Tianyi Hao Date: Thu, 9 Apr 2026 00:23:33 -0500 Subject: [PATCH 09/10] feat: simplify to_int --- src/tqecd/pauli.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/tqecd/pauli.py b/src/tqecd/pauli.py index 1dad510..b1123da 100644 --- a/src/tqecd/pauli.py +++ b/src/tqecd/pauli.py @@ -8,9 +8,9 @@ from __future__ import annotations -import functools -from itertools import accumulate, chain, repeat import operator +from functools import reduce +from itertools import chain from typing import Iterable, Literal import stim @@ -200,19 +200,13 @@ def __getitem__(self, index: int) -> PAULI_STRING_TYPE: def to_int(self, qubits: Iterable[int]) -> int: """Convert the Pauli string to an integer representation on the provided qubits.""" - return _concat_ints_as_bits( + return reduce( + lambda acc, bit: acc << 1 | bit, chain.from_iterable(pauli_literal_to_bools(self[q]) for q in qubits), - bit_length=1, + 0, ) -def _concat_ints_as_bits(ints: Iterable[int], bit_length: int | Iterable[int]) -> int: - """Concatenate a list of integers as bits to form a single integer.""" - if isinstance(bit_length, int): - bit_length = repeat(bit_length) - return sum(x << shift for x, shift in zip(ints, chain([0], accumulate(bit_length)))) - - def pauli_literal_to_bools( literal: PAULI_STRING_TYPE, ) -> tuple[bool, bool]: @@ -227,4 +221,4 @@ def pauli_literal_to_bools( def pauli_product(paulis: Iterable[PauliString]) -> PauliString: - return functools.reduce(operator.mul, paulis, PauliString({})) + return reduce(operator.mul, paulis, PauliString({})) From cd93591f00045cafe4486c9db59d735ebc48e65b Mon Sep 17 00:00:00 2001 From: Tianyi Hao Date: Thu, 9 Apr 2026 00:28:25 -0500 Subject: [PATCH 10/10] chore: remove requirements.txt from workflows --- .devcontainer/devcontainer.json | 2 +- .github/workflows/gh-pages.yml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0cccda3..97a4b68 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,5 +19,5 @@ } }, "remoteUser": "vscode", - "postCreateCommand": "python -m pip install -r ./requirements.txt && python -m pip install -e ./[all] && pre-commit install && pre-commit run -a" + "postCreateCommand": "python -m pip install -e ./[all] && pre-commit install && pre-commit run -a" } diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index c4b9384..751b4cd 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -26,7 +26,6 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends pandoc python -m pip install --upgrade pip - python -m pip install -r requirements.txt python -m pip install '.[all]' - name: Generate the documentation run: |