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/ci.yml b/.github/workflows/ci.yml index 4fa1193..877ab0d 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", "3.14"] 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: | 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/.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: | 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 ---------------------- diff --git a/pyproject.toml b/pyproject.toml index 0dd9b2a..312d148 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", @@ -28,12 +28,19 @@ 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", "Typing :: Typed", ] -dynamic = ["dependencies"] +dependencies = [ + "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", +] [project.urls] Documentation = "https://tqec.github.io/tqecd/" @@ -64,10 +71,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" @@ -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() diff --git a/src/tqecd/cover.py b/src/tqecd/cover.py new file mode 100644 index 0000000..95c2a07 --- /dev/null +++ b/src/tqecd/cover.py @@ -0,0 +1,150 @@ +"""Provides utility functions to find "covers" of Pauli strings.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import Any + +from tqecd.pauli import PauliString + + +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 + target on qubits ``on_qubits``. + + 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. + + Args: + target: the stabilizers to cover with stabilizers from ``sources``. + sources: stabilizers that can be used to cover `target`. + on_qubits: qubits to consider when trying to cover ``target`` with + ``sources``. + + Returns: + Either a list of indices over ``sources`` that, when combined, cover + exactly the provided ``target`` on all the qubits provided in + ``on_qubits``, or ``None`` if such a list could not be found. + """ + return _solve_linear_system( + _construct_basis({}, sources, lambda s: s.to_int(on_qubits)), + target.to_int(on_qubits), + ) + + +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 + ``target``. + + The Pauli strings returned (via indices over the provided ``sources``), once + multiplied together, should be exactly equal to ``target``. In particular, the + following post-condition should hold: + + .. code-block:: python + + target = None # to replace + sources = [None] # to replace + cover_indices = find_exact_cover(target, sources) + resulting_pauli_string = PauliString({}) + for i in cover_indices: + resulting_pauli_string = resulting_pauli_string * sources[i] + assert resulting_pauli_string == target, "Should hold" + + Args: + target: the stabilizers to cover with stabilizers from ``sources``. + sources: stabilizers that can be used to cover ``target``. + + Returns: + Either a list of indices over ``sources`` that, when combined, cover + exactly the provided ``target``, or ``None`` if such a list could not be + found. + """ + # 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 [] + + # Else, if there are no sources, we cannot find a solution. + if not sources: + return None + + # We want an exact (i.e., equality) cover on all qubits, to be sure that + # the post-condition in the docstring holds. For that, it is sufficient to + # only consider all the qubits where either `target` or at least one of the + # items of `sources` acts non-trivially (i.e., something else than the identity). + involved_qubits = frozenset(target.qubits) + for source in sources: + involved_qubits |= frozenset(source.qubits) + + return _find_cover(target, sources, involved_qubits) + + +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 + superset of ``target``. + + This function try to find a set of Pauli strings from ``sources`` that + includes ``target`` (i.e., on every qubit where ``target`` is non-trivial, + the product of each of the returned Pauli strings should commute with + ``target``). + + 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. + "I"). So in practice, on qubits where ``target[qubit] == "I"``, the value + of the returned Pauli string can be anything. + 2. this function does not restrict the output of the product of each of + the returned Pauli string to exactly match ``target`` on qubits where + it acts non-trivially, but rather requires the output to commute with + ``target`` on those qubits. + + Args: + target: the stabilizers to cover with stabilizers from ``sources``. + sources: stabilizers that can be used to cover ``target``. + + Returns: + Either a list of a stabilizers that, when combined, commute with + the provided ``target``, or ``None`` if such a list could not be found. + """ + if not sources: + return None + return _find_cover(target, sources, frozenset(target.qubits)) + + +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/cover_test.py similarity index 64% rename from src/tqecd/match_utils/cover_test.py rename to src/tqecd/cover_test.py index f2dcdf2..742370d 100644 --- a/src/tqecd/match_utils/cover_test.py +++ b/src/tqecd/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, +from tqecd.cover import ( + 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/flow.py b/src/tqecd/flow.py index a06e215..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_sat 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..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_sat 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 deleted file mode 100644 index e2a7e00..0000000 --- a/src/tqecd/match_utils/__init__.py +++ /dev/null @@ -1,27 +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_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). -""" - -from .cover import ( - find_commuting_cover_on_target_qubits_sat as find_commuting_cover_on_target_qubits_sat, -) -from .cover import find_cover as find_cover -from .cover import find_exact_cover_sat as find_exact_cover_sat diff --git a/src/tqecd/match_utils/cover.py b/src/tqecd/match_utils/cover.py deleted file mode 100644 index 6a59a15..0000000 --- a/src/tqecd/match_utils/cover.py +++ /dev/null @@ -1,319 +0,0 @@ -"""Provides utility functions to find "covers" of Pauli strings.""" - -from __future__ import annotations - -import time -from typing import Iterator - -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( - target: PauliString, sources: list[PauliString], on_qubits: frozenset[int] -) -> list[int] | None: - """Try to find a set of boundary stabilizers from ``sources`` that generate - target on qubits ``on_qubits``. - - 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. - - Args: - target: the stabilizers to cover with stabilizers from ``sources``. - sources: stabilizers that can be used to cover `target`. - on_qubits: qubits to consider when trying to cover ``target`` with - ``sources``. - - Returns: - Either a list of indices over ``sources`` that, when combined, cover - 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, - ) - - -def find_exact_cover_sat( - target: PauliString, sources: list[PauliString] -) -> list[int] | None: - """Try to find a set of pauli strings from ``sources`` that generate exactly - ``target``. - - The Pauli strings returned (via indices over the provided ``sources``), once - 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) - resulting_pauli_string = PauliString({}) - for i in cover_indices: - resulting_pauli_string = resulting_pauli_string * sources[i] - assert resulting_pauli_string == target, "Should hold" - - Args: - target: the stabilizers to cover with stabilizers from ``sources``. - sources: stabilizers that can be used to cover ``target``. - - Returns: - Either a list of indices over ``sources`` that, when combined, cover - 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. - # Note: we might want to disallow an empty return in the future. - if target.non_trivial_pauli_count == 0: - return [] - - # Else, if there are no sources, we cannot find a solution. - if not sources: - return None - - # We want an exact (i.e., equality) cover on all qubits, to be sure that - # the post-condition in the docstring holds. For that, it is sufficient to - # only consider all the qubits where either `target` or at least one of the - # items of `sources` acts non-trivially (i.e., something else than the identity). - involved_qubits = frozenset(target.qubits) - for source in sources: - involved_qubits |= frozenset(source.qubits) - - return _find_cover_sat(target, sources, involved_qubits) - - -def find_commuting_cover_on_target_qubits_sat( - target: PauliString, sources: list[PauliString] -) -> list[int] | None: - """Try to find a set of boundary stabilizers from ``sources`` that generate a - superset of ``target``. - - This function try to find a set of Pauli strings from ``sources`` that - includes ``target`` (i.e., on every qubit where ``target`` is non-trivial, - 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: - - 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. - "I"). So in practice, on qubits where ``target[qubit] == "I"``, the value - of the returned Pauli string can be anything. - 2. this function does not restrict the output of the product of each of - the returned Pauli string to exactly match ``target`` on qubits where - it acts non-trivially, but rather requires the output to commute with - ``target`` on those qubits. - - Args: - target: the stabilizers to cover with stabilizers from ``sources``. - sources: stabilizers that can be used to cover ``target``. - - Returns: - Either a list of a stabilizers that, when combined, commute with - the provided ``target``, or ``None`` if such a list could not be found. - """ - if not sources: - return None - - 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, - ) 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..b1123da 100644 --- a/src/tqecd/pauli.py +++ b/src/tqecd/pauli.py @@ -8,8 +8,9 @@ from __future__ import annotations -import functools import operator +from functools import reduce +from itertools import chain from typing import Iterable, Literal import stim @@ -197,6 +198,14 @@ 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 reduce( + lambda acc, bit: acc << 1 | bit, + chain.from_iterable(pauli_literal_to_bools(self[q]) for q in qubits), + 0, + ) + def pauli_literal_to_bools( literal: PAULI_STRING_TYPE, @@ -212,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({}))