From c1a77257d1084be3f5bc6231220a4454044bfce4 Mon Sep 17 00:00:00 2001 From: "Michael A. Perlin" Date: Thu, 12 Nov 2026 00:55:35 -0500 Subject: [PATCH 1/7] Move the exact searches into subpackages by method Split `_search/` by role. Each method gets one home: `_brute_force.py`, `_brouwer_zimmermann/` (the distance search, its packed instances, the compiled scans of layers, and the coset routes), and `_connected_support/search.py`. The information-set machinery that the searches and counts share moves to `_information_sets/`, where `information_sets.py` splits into `basis_families.py`, `certificates.py`, and `support_stepping.py`. `coset_leader_search.py` moves up to `_coset_leader_search.py`, the private entry module of the coset-leader search. The moves change no behavior. Imports, test helpers, the layers that `layering_test.py` enforces, and the paths that the guides cite follow the new layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../circuits/certification/_fault_search.py | 2 +- .../certification/fault_propagation.py | 2 +- .../certification/fault_propagation_test.py | 2 +- src/qldpc/codes/conftest.py | 2 +- src/qldpc/codes/distance/README.md | 48 +- src/qldpc/codes/distance/_binary.py | 54 +- src/qldpc/codes/distance/_binary_test.py | 53 +- .../distance/_brouwer_zimmermann/__init__.py | 8 + .../affine_instance.py | 14 +- .../affine_instance_test.py | 17 +- .../affine_search.py | 4 +- .../affine_search_test.py | 3 +- .../coset_routes.py | 23 +- .../coset_routes_test.py | 7 +- .../distance.py} | 38 +- .../distance_test.py} | 33 +- .../brute_force.py => _brute_force.py} | 49 +- ...ute_force_test.py => _brute_force_test.py} | 53 +- .../distance/_connected_support/__init__.py | 7 + .../search.py} | 4 +- .../search_test.py} | 2 +- ...ader_search.py => _coset_leader_search.py} | 27 +- ...h_test.py => _coset_leader_search_test.py} | 198 ++--- .../codes/distance/_counting/macwilliams.py | 2 +- src/qldpc/codes/distance/_counting/scan.py | 40 +- .../codes/distance/_counting/scan_test.py | 10 +- .../distance/_information_sets/__init__.py | 9 + .../automorphism_families.py | 3 +- .../automorphism_families_test.py | 4 +- .../basis_families.py} | 562 +------------ .../_information_sets/basis_families_test.py | 390 +++++++++ .../_information_sets/certificates.py | 520 ++++++++++++ .../_information_sets/certificates_test.py | 357 ++++++++ .../completion_bounds.py | 4 +- .../completion_bounds_test.py | 0 .../matroid_partition.py | 0 .../matroid_partition_test.py | 0 .../_information_sets/support_stepping.py | 58 ++ .../support_stepping_test.py | 44 + src/qldpc/codes/distance/_search/__init__.py | 7 - .../distance/_search/information_sets_test.py | 771 ------------------ src/qldpc/codes/distance/_tuning.py | 14 +- src/qldpc/codes/distance/_tuning_test.py | 21 +- .../codes/distance/brouwer_zimmermann.md | 62 +- src/qldpc/codes/distance/conftest.py | 38 +- src/qldpc/codes/distance/connected_support.md | 16 +- src/qldpc/codes/distance/core.py | 8 +- src/qldpc/codes/distance/layering_test.py | 6 +- src/qldpc/codes/distance/logical_counts.py | 22 +- .../codes/distance/logical_counts_test.py | 20 +- src/qldpc/codes/quantum/two_block_test.py | 2 +- .../circuits/benchmarking/residual_weights.py | 2 +- 52 files changed, 1856 insertions(+), 1786 deletions(-) create mode 100644 src/qldpc/codes/distance/_brouwer_zimmermann/__init__.py rename src/qldpc/codes/distance/{_search => _brouwer_zimmermann}/affine_instance.py (96%) rename src/qldpc/codes/distance/{_search => _brouwer_zimmermann}/affine_instance_test.py (93%) rename src/qldpc/codes/distance/{_search => _brouwer_zimmermann}/affine_search.py (99%) rename src/qldpc/codes/distance/{_search => _brouwer_zimmermann}/affine_search_test.py (99%) rename src/qldpc/codes/distance/{_search => _brouwer_zimmermann}/coset_routes.py (96%) rename src/qldpc/codes/distance/{_search => _brouwer_zimmermann}/coset_routes_test.py (98%) rename src/qldpc/codes/distance/{_search/brouwer_zimmermann.py => _brouwer_zimmermann/distance.py} (96%) rename src/qldpc/codes/distance/{_search/brouwer_zimmermann_test.py => _brouwer_zimmermann/distance_test.py} (97%) rename src/qldpc/codes/distance/{_search/brute_force.py => _brute_force.py} (78%) rename src/qldpc/codes/distance/{_search/brute_force_test.py => _brute_force_test.py} (78%) create mode 100644 src/qldpc/codes/distance/_connected_support/__init__.py rename src/qldpc/codes/distance/{_search/connected_support.py => _connected_support/search.py} (99%) rename src/qldpc/codes/distance/{_search/connected_support_test.py => _connected_support/search_test.py} (99%) rename src/qldpc/codes/distance/{_search/coset_leader_search.py => _coset_leader_search.py} (99%) rename src/qldpc/codes/distance/{_search/coset_leader_search_test.py => _coset_leader_search_test.py} (93%) create mode 100644 src/qldpc/codes/distance/_information_sets/__init__.py rename src/qldpc/codes/distance/{_search => _information_sets}/automorphism_families.py (98%) rename src/qldpc/codes/distance/{_search => _information_sets}/automorphism_families_test.py (98%) rename src/qldpc/codes/distance/{_search/information_sets.py => _information_sets/basis_families.py} (51%) create mode 100644 src/qldpc/codes/distance/_information_sets/basis_families_test.py create mode 100644 src/qldpc/codes/distance/_information_sets/certificates.py create mode 100644 src/qldpc/codes/distance/_information_sets/certificates_test.py rename src/qldpc/codes/distance/{_search => _information_sets}/completion_bounds.py (99%) rename src/qldpc/codes/distance/{_search => _information_sets}/completion_bounds_test.py (100%) rename src/qldpc/codes/distance/{_search => _information_sets}/matroid_partition.py (100%) rename src/qldpc/codes/distance/{_search => _information_sets}/matroid_partition_test.py (100%) create mode 100644 src/qldpc/codes/distance/_information_sets/support_stepping.py create mode 100644 src/qldpc/codes/distance/_information_sets/support_stepping_test.py delete mode 100644 src/qldpc/codes/distance/_search/__init__.py delete mode 100644 src/qldpc/codes/distance/_search/information_sets_test.py diff --git a/src/qldpc/circuits/certification/_fault_search.py b/src/qldpc/circuits/certification/_fault_search.py index 216470121..9cda17e0a 100644 --- a/src/qldpc/circuits/certification/_fault_search.py +++ b/src/qldpc/circuits/certification/_fault_search.py @@ -24,7 +24,7 @@ from qldpc._binary import get_rank_packed, popcount_word, trailing_zeros_word from qldpc._compilation import always_compiled -from qldpc.codes.distance._search.coset_leader_search import ( +from qldpc.codes.distance._coset_leader_search import ( CosetLeaderSearch, CosetSearchMethod, MinWeightBound, diff --git a/src/qldpc/circuits/certification/fault_propagation.py b/src/qldpc/circuits/certification/fault_propagation.py index fff4d12d3..cd1289272 100644 --- a/src/qldpc/circuits/certification/fault_propagation.py +++ b/src/qldpc/circuits/certification/fault_propagation.py @@ -14,7 +14,7 @@ from qldpc import math from qldpc._binary import get_rank_packed, pack_bits from qldpc.codes.distance import DistanceMethod -from qldpc.codes.distance._search.coset_leader_search import CosetSearchMethod, MinWeightBound +from qldpc.codes.distance._coset_leader_search import CosetSearchMethod, MinWeightBound from .._post_selection import get_flag_detectors, get_selected_detectors, is_integer from ..bookkeeping import DetectorRecord diff --git a/src/qldpc/circuits/certification/fault_propagation_test.py b/src/qldpc/circuits/certification/fault_propagation_test.py index 205ec31ce..8d30d142e 100644 --- a/src/qldpc/circuits/certification/fault_propagation_test.py +++ b/src/qldpc/circuits/certification/fault_propagation_test.py @@ -18,7 +18,7 @@ from qldpc import circuits from qldpc.codes.distance import DistanceMethod -from qldpc.codes.distance._search.coset_leader_search import MinWeightBound +from qldpc.codes.distance._coset_leader_search import MinWeightBound from . import fault_propagation diff --git a/src/qldpc/codes/conftest.py b/src/qldpc/codes/conftest.py index d3ca0f93a..93c6a7330 100644 --- a/src/qldpc/codes/conftest.py +++ b/src/qldpc/codes/conftest.py @@ -15,7 +15,7 @@ from qldpc import codes from qldpc.codes.distance import _tuning -from qldpc.codes.distance._search import automorphism_families +from qldpc.codes.distance._information_sets import automorphism_families from qldpc.objects import Pauli # Changes fields of the active search tuning until the end of a test (see the tune fixture). diff --git a/src/qldpc/codes/distance/README.md b/src/qldpc/codes/distance/README.md index 674c77d2e..3ab3bb4f7 100644 --- a/src/qldpc/codes/distance/README.md +++ b/src/qldpc/codes/distance/README.md @@ -27,31 +27,33 @@ Users reach it through the `get_distance_exact` method of binary codes in `src/q It is another exact method, selected by `method="connected_support"`, and it also finds light vectors with a given syndrome. - **Scan:** the enumeration of the combinations of rows of each information set, layer by layer. Counting scans in `_counting/scan.py`. - The Brouwer–Zimmermann distance search in `_search/brouwer_zimmermann.py` and the Brouwer–Zimmermann coset search in `_search/coset_leader_search.py` scan with the compiled scans of `_search/affine_search.py`. - All of them step through a layer with `advance_support` in `_search/information_sets.py`. + The Brouwer–Zimmermann distance search in `_brouwer_zimmermann/distance.py` and the Brouwer–Zimmermann coset search in `_coset_leader_search.py` scan with the compiled scans of `_brouwer_zimmermann/affine_search.py`. + All of them step through a layer with `advance_support` in `_information_sets/support_stepping.py`. ## Where to look | To change… | Look in (this directory) | | --- | --- | | A distance method, or the validation of method and backend names | `core.py`, and where `logical_counts.py`, `_counting/scan.py`, `get_distance_exact`, `get_distance`, and `CSSCode._get_distance_of_sectors` in `src/qldpc/codes/common.py` dispatch on the method name | -| The Brouwer–Zimmermann (BZ) distance search | `_search/brouwer_zimmermann.py` | -| The compiled scans of layers that the BZ distance search and the BZ coset-leader search share, one body specialized by constant flags | `_search/affine_search.py` | -| The connected-support search, of distances and of vectors with a given syndrome | `_search/connected_support.py` | -| The packed information sets of a BZ distance search or a BZ coset-leader search, and the certificate of their steps | `_search/affine_instance.py` | -| How basis families and their certificates are built, and how searches plan their steps: where they stop, and the work of the steps up to there | `_search/information_sets.py` | -| Automorphism families: the images of one information set under automorphisms of a code, and the check of those automorphisms, which the connected-support search uses too | `_search/automorphism_families.py` | -| Completion tables, which bound the words that complete the first rows of a BZ support with two more rows: a distance search skips the supports whose words cannot be lighter than its best word, a count those whose words all weigh more than its target weight, and a BZ coset query on a route by tails those whose words all weigh more than its maximum weight. A BZ search for the minimum weight of a coset skips those too, and those whose words cannot be lighter than its lightest word so far. Also the choice of whether to build them | `_search/completion_bounds.py` | -| Information sets with bounded column coverage, by matroid partitioning | `_search/matroid_partition.py` | -| Exhaustive enumeration, for small codes, fallbacks, and the `brute_force` method | `_search/brute_force.py` | -| Minimum weights of syndrome classes of Pauli errors (coset-leader weights), by BZ or by connected-support searches, with two classical searches for the CSS weight on CSS states, used by `src/qldpc/circuits/certification/` and `src/qldpc/experimental/circuits/benchmarking/residual_weights.py` | `_search/coset_leader_search.py` | -| The families of information sets with which a BZ coset-leader search answers costly queries, the gate that chooses them for each maximum weight, and the routes whose queries weigh words by their tails and skip completions by tables | `_search/coset_routes.py` | +| The Brouwer–Zimmermann (BZ) distance search | `_brouwer_zimmermann/distance.py` | +| The compiled scans of layers that the BZ distance search and the BZ coset-leader search share, one body specialized by constant flags | `_brouwer_zimmermann/affine_search.py` | +| The connected-support search, of distances and of vectors with a given syndrome | `_connected_support/search.py` | +| The packed information sets of a BZ distance search or a BZ coset-leader search, and the certificate of their steps | `_brouwer_zimmermann/affine_instance.py` | +| How basis families are built | `_information_sets/basis_families.py` | +| Certificates, and how searches plan their steps: where they stop, and the work of the steps up to there | `_information_sets/certificates.py` | +| The step from one support of a layer to the next, which every scan of layers shares | `_information_sets/support_stepping.py` | +| Automorphism families: the images of one information set under automorphisms of a code, and the check of those automorphisms, which the connected-support search uses too | `_information_sets/automorphism_families.py` | +| Completion tables, which bound the words that complete the first rows of a BZ support with two more rows: a distance search skips the supports whose words cannot be lighter than its best word, a count those whose words all weigh more than its target weight, and a BZ coset query on a route by tails those whose words all weigh more than its maximum weight. A BZ search for the minimum weight of a coset skips those too, and those whose words cannot be lighter than its lightest word so far. Also the choice of whether to build them | `_information_sets/completion_bounds.py` | +| Information sets with bounded column coverage, by matroid partitioning | `_information_sets/matroid_partition.py` | +| Exhaustive enumeration, for small codes, fallbacks, and the `brute_force` method, with its packed symplectic weights | `_brute_force.py` | +| Minimum weights of syndrome classes of Pauli errors (coset-leader weights), by BZ or by connected-support searches, with two classical searches for the CSS weight on CSS states, used by `src/qldpc/circuits/certification/` and `src/qldpc/experimental/circuits/benchmarking/residual_weights.py` | `_coset_leader_search.py` | +| The families of information sets with which a BZ coset-leader search answers costly queries, the gate that chooses them for each maximum weight, and the routes whose queries weigh words by their tails and skip completions by tables | `_brouwer_zimmermann/coset_routes.py` | | The counter, its deformed counters, or `compile_search_setup` | `logical_counts.py` | | The scan of a count, or how its basis families follow local gates | `_counting/scan.py` | | The local Clifford gates of deformed counters | `_counting/local_cliffords.py` | | The MacWilliams counting method | `_counting/macwilliams.py` | | The settings that a search reads when it starts, such as the thresholds that choose between exhaustive enumeration, the kinds of information-set families, and completion tables, the threshold of states before which a connected-support search asks for automorphisms, the coefficients of cost models, memory limits, and batch sizes (`SearchTuning`, `use_tuning`), and the constants that size caches when qLDPC is imported | `_tuning.py` | -| Binary input checks, nested bases, or packed weights | `_binary.py` | +| Binary input checks, nested bases, or the map that turns symplectic weights into Hamming weights | `_binary.py` | | Bit packing, bit counts, or row reduction over GF(2), which other subpackages share | `src/qldpc/_binary.py` | | How numba code is compiled or cached | `src/qldpc/_compilation.py` | @@ -61,20 +63,20 @@ From the top: 1. `logical_counts.py`, the public interface of the counts, and `core.py`, the public interface of the distances. 2. The `_counting` package, the counting methods. -3. `_search/brouwer_zimmermann.py`, `_search/brute_force.py`, `_search/connected_support.py`, and `_search/coset_leader_search.py`, the searches. +3. `_brouwer_zimmermann/distance.py`, `_brute_force.py`, `_connected_support/search.py`, and `_coset_leader_search.py`, the searches. 4. The modules that the searches build on. - `_search/affine_instance.py` packs the information sets of a BZ distance or BZ coset-leader search, with the certificate of their steps. - `_search/coset_routes.py` chooses the families of information sets with which a BZ coset-leader search answers each maximum weight. - `_search/affine_search.py` holds the compiled scans of layers that the BZ distance and BZ coset-leader searches share. - `_search/information_sets.py` builds the basis families of those two searches and of the counting scans. - `_search/automorphism_families.py` builds automorphism families from them. - `_search/completion_bounds.py` bounds the words that complete the first rows of a support with two more rows. -5. `_search/matroid_partition.py`, which `_search/information_sets.py` uses to build some of those families. + `_brouwer_zimmermann/affine_instance.py` packs the information sets of a BZ distance or BZ coset-leader search, with the certificate of their steps. + `_brouwer_zimmermann/coset_routes.py` chooses the families of information sets with which a BZ coset-leader search answers each maximum weight. + `_brouwer_zimmermann/affine_search.py` holds the compiled scans of layers that the BZ distance and BZ coset-leader searches share. + `_information_sets/basis_families.py` builds the basis families of those two searches and of the counting scans. + `_information_sets/automorphism_families.py` builds automorphism families from them. + `_information_sets/completion_bounds.py` bounds the words that complete the first rows of a support with two more rows. +5. `_information_sets/matroid_partition.py`, which `_information_sets/basis_families.py` uses to build some of those families. 6. `_tuning.py`, the settings of the searches and counts, and the sizes of their caches. 7. `_binary.py`, the foundation. A module imports only modules of its own level or below. -`layering_test.py` checks this for `logical_counts.py`, `core.py`, and the `_counting`, `_search`, `_tuning`, and `_binary` units; the order within `_search/` is a convention. +`layering_test.py` checks the order of the top-level modules and subpackages; the order within a subpackage is a convention. Modules of every level may also import `src/qldpc/_binary.py`, the bit-packed linear algebra over GF(2) that other subpackages share. `src/qldpc/codes/common.py` imports this package, so `logical_counts.py` imports the code classes of `codes.common` and `codes.quantum` inside the functions that use them. Modules elsewhere in qLDPC, such as `src/qldpc/codes/common.py`, modules in `src/qldpc/circuits/certification/`, and the deprecation registry in `src/qldpc/_deprecations/core.py`, use private names of this package, so grep `src/qldpc` for a private name before you move or rename it. diff --git a/src/qldpc/codes/distance/_binary.py b/src/qldpc/codes/distance/_binary.py index 95b16640b..0cd0561b7 100644 --- a/src/qldpc/codes/distance/_binary.py +++ b/src/qldpc/codes/distance/_binary.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 -"""Binary input checks, nested bases, packed weights, and array helpers for distance searches. +"""Binary input checks, nested bases, and the symplectic-to-Hamming map for distance searches. The generic bit-packed linear algebra over GF(2) that the searches use is in ``qldpc._binary``. """ @@ -15,9 +15,6 @@ from qldpc._binary import get_independent_rows -_MASK55 = np.uint64(0x5555555555555555) - - #################################################################################################### # input checks @@ -135,52 +132,3 @@ def symplectic_to_hamming(vectors: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint vectors_x = vectors[:, :half_width] vectors_z = vectors[:, half_width:] return np.hstack([vectors_x, vectors_z, vectors_x ^ vectors_z]) - - -#################################################################################################### -# weight functions - - -def symplectic_weight( - words: npt.NDArray[np.uint64], out: npt.NDArray[np.uint64] | None = None -) -> npt.NDArray[np.uint64]: - """Count the qubits in the support of each riffled word (see ``riffle``). - - Each qubit occupies one aligned bit pair ``(2m, 2m + 1)`` of a word, so a qubit is in the - support if either bit of its pair is set. The result is uint64, and ``out`` doubles as scratch - space. - """ - out = np.right_shift(words, 1, out=out) - out |= words - out &= _MASK55 - return np.bitwise_count(out, out=out) - - -#################################################################################################### -# array helpers - - -def inplace_rowsum(arr: npt.NDArray[np.uint64]) -> npt.NDArray[np.uint64]: - """Destructively compute ``arr.sum(-1)``, placing the result in the first column of ``arr``. - - When complete, the returned sum will be stored in ``arr[..., 0]``, while other entries in - ``arr[..., 1:]`` will be left in indeterminate states. This permits a faster sum - implementation. - """ - width = arr.shape[-1] - while width > 1: - split = width // 2 - arr[..., :split] += arr[..., width - split : width] - width -= split - - return arr[..., 0] - - -def riffle(array: npt.ArrayLike) -> npt.ArrayLike: - """'Riffle' Pauli strings, putting X and Z support bits for each qubit next to each other.""" - arr = np.asarray(array) - num_bits = arr.shape[-1] - assert num_bits % 2 == 0 - if arr.size == 0: - return np.empty((0, num_bits), dtype=arr.dtype) # nothing to riffle (e.g. no stabilizers) - return np.reshape(arr, (-1, 2, num_bits // 2)).transpose(0, 2, 1).reshape(-1, num_bits) diff --git a/src/qldpc/codes/distance/_binary_test.py b/src/qldpc/codes/distance/_binary_test.py index b15e99c91..067357d12 100644 --- a/src/qldpc/codes/distance/_binary_test.py +++ b/src/qldpc/codes/distance/_binary_test.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 -"""Unit tests for the binary input checks, nested bases, and weight helpers of distance searches.""" +"""Unit tests for the binary input checks and nested bases of distance searches.""" from __future__ import annotations @@ -13,10 +13,9 @@ import qldpc from . import _binary as binary -from .conftest import count_set_bits #################################################################################################### -# binary matrices, weights, and array helpers +# binary matrices def test_binary_matrices_and_nested_bases() -> None: @@ -53,45 +52,6 @@ def test_eligible_rows_have_nonzero_labels() -> None: assert binary.get_eligible_rows(np.empty((3, 0), dtype=np.uint8)).tolist() == [True] * 3 -def test_riffle_and_symplectic_isometry() -> None: - symplectic = np.array(list(itertools.product((0, 1), repeat=6)), dtype=np.uint8) - expected = np.stack((symplectic[:, :3], symplectic[:, 3:]), axis=2).reshape(-1, 6) - np.testing.assert_array_equal(binary.riffle(symplectic), expected) - assert np.shape(binary.riffle(np.empty((0, 6)))) == (0, 6) - with pytest.raises(AssertionError): - binary.riffle(np.zeros((1, 3))) - transformed = binary.symplectic_to_hamming(symplectic) - np.testing.assert_array_equal( - np.count_nonzero(transformed, axis=1), - 2 * np.count_nonzero(symplectic[:, :3] | symplectic[:, 3:], axis=1), - ) - - -def test_symplectic_weight() -> None: - """Validate symplectic_weight against the reference bit-counting implementation.""" - rng = np.random.default_rng(64) - vals = rng.integers(0, 2**64, size=(7, 11), dtype=np.uint64) - expected_weights = count_set_bits((vals | (vals >> np.uint64(1))) & 0x5555555555555555) - weights = binary.symplectic_weight(vals) - assert weights.dtype == np.uint64 - np.testing.assert_array_equal(weights, expected_weights) - - out = rng.integers(0, 2**64, size=vals.shape, dtype=vals.dtype) - weights = binary.symplectic_weight(vals, out=out) - np.testing.assert_array_equal(weights, expected_weights) - assert weights is out - - -@pytest.mark.parametrize("width", range(1, 8)) -def test_inplace_rowsum(width: int) -> None: - """inplace_rowsum reduces each row to its sum and stores the result in the first column.""" - arr = np.random.default_rng(width).integers(2**31, size=(10, width), dtype=np.uint64) - expected = arr.sum(-1) - actual = binary.inplace_rowsum(arr) - np.testing.assert_array_equal(actual, expected) - np.testing.assert_array_equal(actual, arr[:, 0]) - - def test_distance_requires_binary_input() -> None: """Distance calculations reject non-binary input rather than silently truncating it. @@ -114,3 +74,12 @@ def test_distance_requires_binary_input() -> None: # A binary generator matrix is accepted with either a plain integer dtype or a GF(2) dtype. assert qldpc.codes.distance.get_distance_classical(np.array([[1, 1, 1]])) == 3 assert qldpc.codes.distance.get_distance_classical(qldpc.codes.HammingCode(4).generator) == 3 + + +def test_symplectic_to_hamming_doubles_weights() -> None: + symplectic = np.array(list(itertools.product((0, 1), repeat=6)), dtype=np.uint8) + transformed = binary.symplectic_to_hamming(symplectic) + np.testing.assert_array_equal( + np.count_nonzero(transformed, axis=1), + 2 * np.count_nonzero(symplectic[:, :3] | symplectic[:, 3:], axis=1), + ) diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/__init__.py b/src/qldpc/codes/distance/_brouwer_zimmermann/__init__.py new file mode 100644 index 000000000..ac8045674 --- /dev/null +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/__init__.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Brouwer-Zimmermann searches: of the distances of nested codes, and of the cosets of a code. + +``qldpc.codes.distance.core`` and ``qldpc.codes.distance._coset_leader_search`` are the +interfaces of these modules, and ``qldpc.codes.distance.logical_counts`` also runs the distance +searches of its counters. +""" diff --git a/src/qldpc/codes/distance/_search/affine_instance.py b/src/qldpc/codes/distance/_brouwer_zimmermann/affine_instance.py similarity index 96% rename from src/qldpc/codes/distance/_search/affine_instance.py rename to src/qldpc/codes/distance/_brouwer_zimmermann/affine_instance.py index 1cd32a4c5..e46a69183 100644 --- a/src/qldpc/codes/distance/_search/affine_instance.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/affine_instance.py @@ -34,7 +34,7 @@ from qldpc._binary import pack_bits -from . import information_sets +from .._information_sets import basis_families, certificates #################################################################################################### # instances @@ -73,7 +73,7 @@ class AffineInstance: label_widths: npt.NDArray[np.int64] = field(init=False) pivots: npt.NDArray[np.int64] ranks: npt.NDArray[np.int64] = field(init=False) - certificate: information_sets.Certificate + certificate: certificates.Certificate weight_factor: int = 1 def __post_init__(self) -> None: @@ -97,7 +97,7 @@ def get_weight_bounds(self, parity: int | None = None) -> npt.NDArray[np.int64]: def get_distance_instance( - basis_family: information_sets.BasisFamily, certificate: information_sets.Certificate + basis_family: basis_families.BasisFamily, certificate: certificates.Certificate ) -> AffineInstance: """Pack the sets of a basis family for the distance search, with their pivot columns deleted. @@ -160,17 +160,17 @@ def get_coset_instance( labels=np.zeros((1, 0, 0), dtype=np.uint64), widths=np.full(1, width, dtype=np.int64), pivots=np.full((1, 0), -1, dtype=np.int64), - certificate=information_sets.Certificate(0, (0,)), + certificate=certificates.Certificate(0, (0,)), weight_factor=weight_factor, ) - family = information_sets.get_disjoint_family( + family = basis_families.get_disjoint_family( generators, None, random_column_orders=random_column_orders ) return get_coset_family_instance(family, weight_factor=weight_factor) def get_coset_family_instance( - basis_family: information_sets.BasisFamily, *, weight_factor: int + basis_family: basis_families.BasisFamily, *, weight_factor: int ) -> AffineInstance: """Pack the sets of any basis family of a code for the coset search, with every column. @@ -191,7 +191,7 @@ def get_coset_family_instance( labels=np.zeros((num_sets, num_generators, 0), dtype=np.uint64), widths=np.full(num_sets, packed_generators.shape[2], dtype=np.int64), pivots=basis_family.pivots, - certificate=information_sets.Certificate( + certificate=certificates.Certificate( num_generators, tuple(int(rank) for rank in basis_family.ranks), max_coverage=basis_family.max_coverage, diff --git a/src/qldpc/codes/distance/_search/affine_instance_test.py b/src/qldpc/codes/distance/_brouwer_zimmermann/affine_instance_test.py similarity index 93% rename from src/qldpc/codes/distance/_search/affine_instance_test.py rename to src/qldpc/codes/distance/_brouwer_zimmermann/affine_instance_test.py index 3cb0aca2d..0e90ff981 100644 --- a/src/qldpc/codes/distance/_search/affine_instance_test.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/affine_instance_test.py @@ -12,8 +12,9 @@ from qldpc._binary import row_reduce_binary, unpack_bits +from .._information_sets import basis_families, certificates from ..conftest import get_random_full_rank_matrix -from . import affine_instance, information_sets +from . import affine_instance def test_coset_bounds_hold_for_every_unvisited_word(pytestconfig: pytest.Config) -> None: @@ -40,7 +41,7 @@ def test_coset_bounds_hold_for_every_unvisited_word(pytestconfig: pytest.Config) for _ in range(num_sets) ] pivots = np.array([found for *_, found in sets], dtype=np.int64) - overlapping = information_sets.BasisFamily( + overlapping = basis_families.BasisFamily( np.array([rows for rows, *_ in sets]), np.zeros((num_sets, num_rows, 0), dtype=np.uint8), pivots, @@ -89,7 +90,7 @@ def test_distance_instances_delete_pivot_columns(pytestconfig: pytest.Config) -> rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) basis = get_random_full_rank_matrix(rng, 4, 70) labels = np.eye(4, dtype=np.uint8) - family = information_sets.get_disjoint_family(basis, labels, random_column_orders=0) + family = basis_families.get_disjoint_family(basis, labels, random_column_orders=0) certificate = family.get_search_certificate(3, 0, 1) instance = affine_instance.get_distance_instance(family, certificate) assert instance.certificate is certificate @@ -113,14 +114,14 @@ def test_coset_instances_keep_every_column(pytestconfig: pytest.Config) -> None: """A coset instance packs the full rows of its sets, and the empty code has one set.""" rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) generators = get_random_full_rank_matrix(rng, 5, 70) - family = information_sets.get_disjoint_family(generators, None, random_column_orders=0) + family = basis_families.get_disjoint_family(generators, None, random_column_orders=0) instance = affine_instance.get_coset_instance( generators, random_column_orders=0, weight_factor=2 ) assert instance.weight_factor == 2 assert np.array_equal(instance.ranks, family.ranks) assert np.array_equal(instance.pivots, family.pivots) - assert instance.certificate == information_sets.Certificate(5, tuple(family.ranks.tolist())) + assert instance.certificate == certificates.Certificate(5, tuple(family.ranks.tolist())) assert np.array_equal(instance.widths, [2] * len(family.ranks)) for rows, packed in zip(family.generators, instance.generators, strict=True): assert np.array_equal(unpack_bits(packed, 70), rows) @@ -131,7 +132,7 @@ def test_coset_instances_keep_every_column(pytestconfig: pytest.Config) -> None: first = family.pivots[0] order = np.concatenate([first[:1], np.setdiff1d(np.arange(70), first)]) reduced, _, second = row_reduce_binary(generators, None, columns=order) - overlapping = information_sets.BasisFamily( + overlapping = basis_families.BasisFamily( np.stack([family.generators[0], reduced]), np.zeros((2, 5, 0), dtype=np.uint8), np.stack([first, second]), @@ -141,7 +142,7 @@ def test_coset_instances_keep_every_column(pytestconfig: pytest.Config) -> None: ) instance = affine_instance.get_coset_family_instance(overlapping, weight_factor=1) assert np.array_equal(instance.pivots, overlapping.pivots) - assert instance.certificate == information_sets.Certificate(5, (5, 5), max_coverage=2) + assert instance.certificate == certificates.Certificate(5, (5, 5), max_coverage=2) for rows, packed in zip(overlapping.generators, instance.generators, strict=True): assert np.array_equal(unpack_bits(packed, 70), rows) @@ -152,7 +153,7 @@ def test_coset_instances_keep_every_column(pytestconfig: pytest.Config) -> None: assert np.array_equal(empty.widths, [2]) assert empty.pivots.shape == (1, 0) assert np.array_equal(empty.ranks, [0]) - assert np.array_equal(empty.get_weight_bounds(), [[0, information_sets.NO_WORD_BOUND]]) + assert np.array_equal(empty.get_weight_bounds(), [[0, certificates.NO_WORD_BOUND]]) def _get_unvisited_minima( diff --git a/src/qldpc/codes/distance/_search/affine_search.py b/src/qldpc/codes/distance/_brouwer_zimmermann/affine_search.py similarity index 99% rename from src/qldpc/codes/distance/_search/affine_search.py rename to src/qldpc/codes/distance/_brouwer_zimmermann/affine_search.py index ba6f968a7..7175db687 100644 --- a/src/qldpc/codes/distance/_search/affine_search.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/affine_search.py @@ -29,8 +29,8 @@ from qldpc._binary import popcount_word from qldpc._compilation import always_compiled -from .completion_bounds import get_completion_bound -from .information_sets import advance_support +from .._information_sets.completion_bounds import get_completion_bound +from .._information_sets.support_stepping import advance_support # Placeholders for the completion tables of scans without them, which never read them. _NO_TABLE_BYTES = np.zeros(0, dtype=np.uint8) diff --git a/src/qldpc/codes/distance/_search/affine_search_test.py b/src/qldpc/codes/distance/_brouwer_zimmermann/affine_search_test.py similarity index 99% rename from src/qldpc/codes/distance/_search/affine_search_test.py rename to src/qldpc/codes/distance/_brouwer_zimmermann/affine_search_test.py index ed48628ab..94e1ebf17 100644 --- a/src/qldpc/codes/distance/_search/affine_search_test.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/affine_search_test.py @@ -14,7 +14,8 @@ from qldpc._binary import pack_bits -from . import affine_search, completion_bounds +from .._information_sets import completion_bounds +from . import affine_search def test_family_scan() -> None: diff --git a/src/qldpc/codes/distance/_search/coset_routes.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_routes.py similarity index 96% rename from src/qldpc/codes/distance/_search/coset_routes.py rename to src/qldpc/codes/distance/_brouwer_zimmermann/coset_routes.py index fe1c83b95..21eb25d49 100644 --- a/src/qldpc/codes/distance/_search/coset_routes.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_routes.py @@ -87,8 +87,9 @@ from qldpc._binary import unpack_bits +from .._information_sets import basis_families, certificates, completion_bounds from .._tuning import MAX_VALUE, SearchTuning -from . import affine_instance, completion_bounds, information_sets +from . import affine_instance class _SearchTables(Protocol): @@ -179,7 +180,7 @@ def __init__( overlapping_minimum_work=0, overlapping_attempts_per_set=attempts_per_set, ) - self._binomials = information_sets.get_capped_binomials(len(basis)) + self._binomials = certificates.get_capped_binomials(len(basis)) self.disjoint_tables = build_tables(disjoint) # The tables of the route of each maximum weight, and the position of its family among the # disjoint family and the others if it is a route by tails. @@ -220,7 +221,7 @@ def _get_works( syndrome. """ return [ - information_sets.plan_steps( + certificates.plan_steps( table[:-1], max_weight, 0, @@ -296,18 +297,18 @@ def _get_sets_needing_tables( num_rows = len(self._basis) sets = set() for table in tables.planning: - stop_layer, stop_num_completed = information_sets.find_stopping_step( + stop_layer, stop_num_completed = certificates.find_stopping_step( table[:-1], max_weight, 0, len(table) - 2 ) for set_index in range(len(instance.ranks)): - last_layer = information_sets.get_completed_layer( + last_layer = certificates.get_completed_layer( set_index, stop_layer, stop_num_completed ) # The predicate checks first that the last layer has at least two rows, so the check # of unused sets gets the nonnegative layer that it requires. if completion_bounds.considers_tables( num_rows, last_layer, _TABLE_TUNING - ) and not information_sets.is_unused_set( + ) and not certificates.is_unused_set( instance.pivots, set_index, stop_layer, stop_num_completed ): sets.add(set_index) @@ -324,13 +325,13 @@ def _get_candidates( """ weight_factor = self._disjoint.weight_factor disjoint = _get_basis_family(self._disjoint, self._basis.shape[1]) - maximum = information_sets.get_maximum_disjoint_family(self._basis, disjoint) - overlapping = information_sets.get_overlapping_family( + maximum = basis_families.get_maximum_disjoint_family(self._basis, disjoint) + overlapping = basis_families.get_overlapping_family( self._basis, np.empty((len(self._basis), 0), dtype=np.uint8), maximum if len(maximum.ranks) else disjoint, weight_factor * target_weight + 1, - information_sets.get_overlapping_family_policy( + basis_families.get_overlapping_family_policy( self._overlapping_tuning, len(self._basis) ), ) @@ -348,7 +349,7 @@ def _get_candidates( def _get_basis_family( instance: affine_instance.AffineInstance, num_columns: int -) -> information_sets.BasisFamily: +) -> basis_families.BasisFamily: """The basis family of the sets of a coset instance, without labels or a weight divisor. The routes keep the coset instance of the disjoint family, which every query may scan, and @@ -356,7 +357,7 @@ def _get_basis_family( """ num_sets, dimension, width = instance.generators.shape generators = unpack_bits(instance.generators.reshape(-1, width), num_columns) - return information_sets.BasisFamily( + return basis_families.BasisFamily( generators.reshape(num_sets, dimension, num_columns), np.empty((num_sets, dimension, 0), dtype=np.uint8), instance.pivots, diff --git a/src/qldpc/codes/distance/_search/coset_routes_test.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_routes_test.py similarity index 98% rename from src/qldpc/codes/distance/_search/coset_routes_test.py rename to src/qldpc/codes/distance/_brouwer_zimmermann/coset_routes_test.py index d87d6dd2f..2e7f61ea1 100644 --- a/src/qldpc/codes/distance/_search/coset_routes_test.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_routes_test.py @@ -17,8 +17,9 @@ import numpy.typing as npt import pytest +from .._information_sets import certificates from ..conftest import EVEN_ROUTES_CODE, OVERLAPPING_ROUTES_CODE, TWO_FAMILIES_CODE -from . import affine_instance, coset_routes, information_sets +from . import affine_instance, coset_routes class _Tables(NamedTuple): @@ -90,9 +91,9 @@ def _get_instance_record( def _get_work(tables: _Tables, max_weight: int) -> int: """The work of the plans of the steps of the tables, without the sets that a query skips.""" dimension = len(tables.planning[0]) - 2 - binomials = information_sets.get_capped_binomials(dimension) + binomials = certificates.get_capped_binomials(dimension) return sum( - information_sets.plan_steps( + certificates.plan_steps( table[:-1], max_weight, 0, dimension, tables.instance.ranks, binomials, True )[2] for table in tables.planning diff --git a/src/qldpc/codes/distance/_search/brouwer_zimmermann.py b/src/qldpc/codes/distance/_brouwer_zimmermann/distance.py similarity index 96% rename from src/qldpc/codes/distance/_search/brouwer_zimmermann.py rename to src/qldpc/codes/distance/_brouwer_zimmermann/distance.py index 07c62677e..d531722e4 100644 --- a/src/qldpc/codes/distance/_search/brouwer_zimmermann.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/distance.py @@ -27,19 +27,19 @@ from qldpc._binary import pack_bits from .._binary import get_eligible_rows -from .._tuning import SearchTuning, get_tuning -from . import ( - affine_instance, - affine_search, +from .._brute_force import get_nested_distance_brute_force +from .._information_sets import ( automorphism_families, + basis_families, + certificates, completion_bounds, - information_sets, ) -from .brute_force import get_nested_distance_brute_force +from .._tuning import SearchTuning, get_tuning +from . import affine_instance, affine_search # A function that returns the automorphism family of a problem for a weight divisor, or None. _GetAutomorphismFamily = Callable[ - [int], tuple[information_sets.BasisFamily, information_sets.Certificate] | None + [int], tuple[basis_families.BasisFamily, certificates.Certificate] | None ] #################################################################################################### @@ -52,7 +52,7 @@ def get_distance_brouwer_zimmermann_many( cutoff: int, weight_divisor: int = 1, upper_bound: int | None = None, - get_disjoint_basis_families: Sequence[Callable[[], information_sets.BasisFamily]] | None = None, + get_disjoint_basis_families: Sequence[Callable[[], basis_families.BasisFamily]] | None = None, get_automorphisms: Callable[[], npt.NDArray[np.int_] | None] | None = None, tuning: SearchTuning | None = None, ) -> int: @@ -200,7 +200,7 @@ def enumeration_is_cheaper_than_setup( def _enumeration_is_cheaper_than_search( *, num_logical_rows: int, - certificate: information_sets.Certificate, + certificate: certificates.Certificate, upper_bound: int, ) -> bool: """Estimate whether exhaustive nested-code enumeration will outperform BZ. @@ -314,9 +314,7 @@ def advance(self) -> int: # Before a costly layer, consider scanning one set and certifying with its images. Like # the work estimates of certificates, this estimate saturates at their cap, so that the # largest threshold of a tuning forbids the switch. - layer_work = min( - num_scanned * math.comb(self.dimension, weight), information_sets._WORK_CAP - ) + layer_work = min(num_scanned * math.comb(self.dimension, weight), certificates._WORK_CAP) if ( self.get_automorphism_family is not None and layer_work >= self.tuning.automorphism_minimum_layer_work @@ -551,7 +549,7 @@ def _prepare_brouwer_zimmermann_search( weight_divisor: int, upper_bound: int, initial_upper_bound: int | None = None, - get_disjoint_basis_family: Callable[[], information_sets.BasisFamily] | None = None, + get_disjoint_basis_family: Callable[[], basis_families.BasisFamily] | None = None, get_automorphisms: Callable[[], npt.NDArray[np.int_] | None] | None = None, tuning: SearchTuning, ) -> int | _BrouwerZimmermannSearch: @@ -592,7 +590,7 @@ def _prepare_brouwer_zimmermann_search( # The disjoint basis family's certificate also uses any inferred even ambient parity. basis_family = ( - information_sets.get_disjoint_family( + basis_families.get_disjoint_family( basis, labels, random_column_orders=tuning.disjoint_random_column_orders, @@ -605,7 +603,7 @@ def _prepare_brouwer_zimmermann_search( if best <= cutoff: return best - basis_family = information_sets.prune_family(basis_family, upper_bound=best) + basis_family = basis_families.prune_family(basis_family, upper_bound=best) # Each basis family of an independent basis has a set, whose systematic rows complete layer one. completed_weight = 1 lower_bound = basis_family.certificate.lower_bound(completed_weight) @@ -615,14 +613,14 @@ def _prepare_brouwer_zimmermann_search( # Replace the basis family only when overlapping sets, built heuristically and then, for costly # searches, by matroid partitioning, predict less certification work. for get_replacement in ( - lambda family, best: information_sets.get_overlapping_family( + lambda family, best: basis_families.get_overlapping_family( basis, labels, family, best, - information_sets.get_overlapping_family_policy(tuning, dimension), + basis_families.get_overlapping_family_policy(tuning, dimension), ), - lambda family, best: information_sets.get_partitioned_family( + lambda family, best: basis_families.get_partitioned_family( basis, labels, family, @@ -683,7 +681,7 @@ def _get_automorphism_family( labels: npt.NDArray[np.uint8], get_automorphisms: Callable[[], npt.NDArray[np.int_] | None], weight_divisor: int, -) -> tuple[information_sets.BasisFamily, information_sets.Certificate] | None: +) -> tuple[basis_families.BasisFamily, certificates.Certificate] | None: """Verify the automorphisms of a problem, and build the automorphism family of one set. Args: @@ -720,7 +718,7 @@ def _get_lightest_eligible_row_weight( def _get_basis_family_upper_bound( - basis_family: information_sets.BasisFamily, + basis_family: basis_families.BasisFamily, upper_bound: int, ) -> int: """Tighten an upper bound with the systematic rows of a basis family: its layer-one words.""" diff --git a/src/qldpc/codes/distance/_search/brouwer_zimmermann_test.py b/src/qldpc/codes/distance/_brouwer_zimmermann/distance_test.py similarity index 97% rename from src/qldpc/codes/distance/_search/brouwer_zimmermann_test.py rename to src/qldpc/codes/distance/_brouwer_zimmermann/distance_test.py index 825378dea..df7d93296 100644 --- a/src/qldpc/codes/distance/_search/brouwer_zimmermann_test.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/distance_test.py @@ -21,10 +21,11 @@ from qldpc.objects import PAULIS_XZ, Pauli from ...conftest import Tune +from .._information_sets import basis_families, certificates, completion_bounds from .._tuning import DEFAULT_TUNING, MAX_VALUE, SearchTuning, use_tuning from ..conftest import CostlyStages, get_random_full_rank_matrix, get_sector_problem -from . import affine_instance, affine_search, completion_bounds, information_sets -from . import brouwer_zimmermann as bz +from . import affine_instance, affine_search +from . import distance as bz # A tuning that never uses completion tables, for searches built directly. TABLES_DISABLED = dataclasses.replace( @@ -48,7 +49,7 @@ def test_brouwer_zimmermann_search_state() -> None: labels=np.ones((1, 2, 1), dtype=np.uint64), widths=np.array([1], dtype=np.int64), pivots=np.full((1, 2), -1, dtype=np.int64), - certificate=information_sets.Certificate(2, (0,)), + certificate=certificates.Certificate(2, (0,)), ), cutoff=1, batch_size=1, @@ -96,7 +97,7 @@ def test_the_largest_layer_threshold_never_asks_for_automorphisms() -> None: labels=np.zeros((1, dimension, 1), dtype=np.uint64), widths=np.array([2], dtype=np.int64), pivots=np.arange(dimension, dtype=np.int64)[None], - certificate=information_sets.Certificate(dimension, (dimension,)), + certificate=certificates.Certificate(dimension, (dimension,)), ), cutoff=0, batch_size=1, @@ -311,7 +312,7 @@ def test_a_given_disjoint_basis_family_replaces_the_first_stage() -> None: labels = np.vstack([np.zeros((2, 3), dtype=np.uint8), np.eye(3, dtype=np.uint8)]) expected = bz.get_distance_brouwer_zimmermann_many([(basis, labels)], cutoff=0) get_basis_family = mock.Mock( - return_value=information_sets.get_disjoint_family( + return_value=basis_families.get_disjoint_family( basis, labels, random_column_orders=DEFAULT_TUNING.disjoint_random_column_orders ) ) @@ -321,7 +322,7 @@ def test_a_given_disjoint_basis_family_replaces_the_first_stage() -> None: get_disjoint_basis_families=[get_basis_family], ) with mock.patch.object( - information_sets, "get_disjoint_family", wraps=information_sets.get_disjoint_family + basis_families, "get_disjoint_family", wraps=basis_families.get_disjoint_family ) as first_stage: assert search(cutoff=0) == expected get_basis_family.assert_called_once_with() @@ -457,9 +458,9 @@ def test_brouwer_zimmermann_overlap_improves_quotient_witness(tune: Tune) -> Non # Lower only the work threshold so this small oracle exercises production basis family # construction, and check that the overlapping family has no witness as light as the distance. tune(overlapping_minimum_work=0) - original = information_sets.get_overlapping_family + original = basis_families.get_overlapping_family - def select(*args: Any, **kwargs: Any) -> information_sets.BasisFamily: + def select(*args: Any, **kwargs: Any) -> basis_families.BasisFamily: selected = original(*args, **kwargs) assert len(selected.ranks) and selected.max_coverage > 1 assert ( @@ -472,7 +473,7 @@ def select(*args: Any, **kwargs: Any) -> information_sets.BasisFamily: return selected with mock.patch.object( - information_sets, "get_overlapping_family", side_effect=select + basis_families, "get_overlapping_family", side_effect=select ) as select_mock: assert ( qldpc.codes.get_distance_quantum(logicals, stabilizers, homogeneous=True, cutoff=0) @@ -712,12 +713,12 @@ def test_automorphism_family_searches_scan_their_new_set_from_layer_two( labels = _no_labels(basis) shift = np.roll(np.arange(size), -1) automorphisms = np.concatenate([shift, shift + size])[None] - first_family = information_sets.BasisFamily( + first_family = basis_families.BasisFamily( basis[None], labels[None], np.arange(size)[None], np.array([size]), - information_sets.infer_weight_divisor(basis), + basis_families.infer_weight_divisor(basis), 1, ) tune(automorphism_minimum_layer_work=math.comb(size, 3)) @@ -793,7 +794,7 @@ def _no_labels(basis: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint8]: def _get_family( generators: npt.NDArray[np.uint8], column_sets: list[list[int]] -) -> tuple[information_sets.BasisFamily, information_sets.Certificate]: +) -> tuple[basis_families.BasisFamily, certificates.Certificate]: """A basis family without labels, of generators row reduced on each set of columns. Returns the family and its certificate, with the ranks that the row reduction finds. @@ -805,7 +806,7 @@ def _get_family( reduced.append(rows) pivots[index, : len(found)] = found ranks = np.count_nonzero(pivots >= 0, axis=1) - family = information_sets.BasisFamily( + family = basis_families.BasisFamily( np.array(reduced), np.empty((len(column_sets), num_rows, 0), dtype=np.uint8), pivots, @@ -813,7 +814,7 @@ def _get_family( 1, 1, ) - return family, information_sets.Certificate(num_rows, tuple(ranks.tolist())) + return family, certificates.Certificate(num_rows, tuple(ranks.tolist())) @pytest.mark.usefixtures("force_information_sets", "switch_before_any_layer") @@ -902,7 +903,7 @@ def test_sets_that_are_not_information_sets_use_the_general_scan() -> None: labels=np.array([[[1], [2]]], dtype=np.uint64), widths=np.array([1], dtype=np.int64), pivots=np.full((1, 2), -1, dtype=np.int64), - certificate=information_sets.Certificate(2, (0,)), + certificate=certificates.Certificate(2, (0,)), ), cutoff=-1, batch_size=1, @@ -1102,7 +1103,7 @@ def predict(*args: Any, **kwargs: Any) -> tuple[float, int]: labels=np.zeros((2, 3, 0), dtype=np.uint64), widths=np.ones(2, dtype=np.int64), pivots=np.zeros((2, 3), dtype=np.int64), - certificate=information_sets.Certificate(3, (3, 3)), + certificate=certificates.Certificate(3, (3, 3)), ), cutoff=0, batch_size=1, diff --git a/src/qldpc/codes/distance/_search/brute_force.py b/src/qldpc/codes/distance/_brute_force.py similarity index 78% rename from src/qldpc/codes/distance/_search/brute_force.py rename to src/qldpc/codes/distance/_brute_force.py index f967f98ed..33e9e9a19 100644 --- a/src/qldpc/codes/distance/_search/brute_force.py +++ b/src/qldpc/codes/distance/_brute_force.py @@ -9,7 +9,9 @@ from qldpc._binary import count_trailing_zeros, pack_bits -from .._binary import get_eligible_rows, inplace_rowsum, riffle, symplectic_weight +from ._binary import get_eligible_rows + +_MASK55 = np.uint64(0x5555555555555555) #################################################################################################### # exhaustive enumeration @@ -147,3 +149,48 @@ def get_nested_distance_brute_force( batch_exponent=batch_exponent, homogeneous=homogeneous, ) + + +#################################################################################################### +# weights of riffled words, and array helpers + + +def symplectic_weight( + words: npt.NDArray[np.uint64], out: npt.NDArray[np.uint64] | None = None +) -> npt.NDArray[np.uint64]: + """Count the qubits in the support of each riffled word (see ``riffle``). + + Each qubit occupies one aligned bit pair ``(2m, 2m + 1)`` of a word, so a qubit is in the + support if either bit of its pair is set. The result is uint64, and ``out`` doubles as scratch + space. + """ + out = np.right_shift(words, 1, out=out) + out |= words + out &= _MASK55 + return np.bitwise_count(out, out=out) + + +def inplace_rowsum(arr: npt.NDArray[np.uint64]) -> npt.NDArray[np.uint64]: + """Destructively compute ``arr.sum(-1)``, placing the result in the first column of ``arr``. + + When complete, the returned sum will be stored in ``arr[..., 0]``, while other entries in + ``arr[..., 1:]`` will be left in indeterminate states. This permits a faster sum + implementation. + """ + width = arr.shape[-1] + while width > 1: + split = width // 2 + arr[..., :split] += arr[..., width - split : width] + width -= split + + return arr[..., 0] + + +def riffle(array: npt.ArrayLike) -> npt.ArrayLike: + """'Riffle' Pauli strings, putting X and Z support bits for each qubit next to each other.""" + arr = np.asarray(array) + num_bits = arr.shape[-1] + assert num_bits % 2 == 0 + if arr.size == 0: + return np.empty((0, num_bits), dtype=arr.dtype) # nothing to riffle (e.g. no stabilizers) + return np.reshape(arr, (-1, 2, num_bits // 2)).transpose(0, 2, 1).reshape(-1, num_bits) diff --git a/src/qldpc/codes/distance/_search/brute_force_test.py b/src/qldpc/codes/distance/_brute_force_test.py similarity index 78% rename from src/qldpc/codes/distance/_search/brute_force_test.py rename to src/qldpc/codes/distance/_brute_force_test.py index ab493bc80..e452b45ee 100644 --- a/src/qldpc/codes/distance/_search/brute_force_test.py +++ b/src/qldpc/codes/distance/_brute_force_test.py @@ -14,10 +14,9 @@ import qldpc from qldpc._binary import pack_bits -from ...conftest import Tune -from .. import _binary as binary -from ..conftest import count_set_bits -from . import brute_force +from ..conftest import Tune +from . import _brute_force +from .conftest import count_set_bits @pytest.mark.parametrize("batch_exponent", [1, 3, 13]) @@ -82,7 +81,7 @@ def test_get_distance_quantum(homogeneous: bool, batch_exponent: int, tune: Tune # The weight spy records every nonzero logical combination combined with every stabilizer # combination, including the empty one. observed_bitstrings: list[tuple[int, ...]] = [] - weight_function = np.bitwise_count if homogeneous else binary.symplectic_weight + weight_function = np.bitwise_count if homogeneous else _brute_force.symplectic_weight def _spy_weight( arr: npt.NDArray[np.uint64], out: npt.NDArray[np.uint64] | None = None @@ -93,7 +92,7 @@ def _spy_weight( spy = ( mock.patch.object(np, "bitwise_count", _spy_weight) if homogeneous - else mock.patch("qldpc.codes.distance._search.brute_force.symplectic_weight", _spy_weight) + else mock.patch("qldpc.codes.distance._brute_force.symplectic_weight", _spy_weight) ) with spy: distance = qldpc.codes.distance.get_distance_quantum( @@ -104,8 +103,8 @@ def _spy_weight( ) # Symplectic words are packed with their X and Z bits interleaved. - stabilizer_bits = stabilizers if homogeneous else binary.riffle(stabilizers) - logical_bits = logical_ops if homogeneous else binary.riffle(logical_ops) + stabilizer_bits = stabilizers if homogeneous else _brute_force.riffle(stabilizers) + logical_bits = logical_ops if homogeneous else _brute_force.riffle(logical_ops) int_stabilizers = pack_bits(np.asarray(stabilizer_bits, dtype=np.uint8)) int_logical_ops = pack_bits(np.asarray(logical_bits, dtype=np.uint8)) expected_bitstrings = np.array( @@ -183,7 +182,7 @@ def test_early_exit_returns_only_weighed_operators(homogeneous: bool) -> None: num_qubits = 3 logical_op = [1, 0, 0] if homogeneous else [1, 0, 0, 0, 0, 0] stabilizer = [0, 1, 1] if homogeneous else [0, 1, 1, 0, 0, 0] - distance = brute_force.get_distance_quantum_brute_force( + distance = _brute_force.get_distance_quantum_brute_force( [logical_op], [stabilizer], cutoff=len(logical_op), @@ -193,7 +192,7 @@ def test_early_exit_returns_only_weighed_operators(homogeneous: bool) -> None: assert distance == 1 # Without logical operators, the result exceeds every weight, whatever the cutoff. - distance = brute_force.get_distance_quantum_brute_force( + distance = _brute_force.get_distance_quantum_brute_force( np.empty((0, len(logical_op)), dtype=np.uint8), [stabilizer], cutoff=100, @@ -201,3 +200,37 @@ def test_early_exit_returns_only_weighed_operators(homogeneous: bool) -> None: homogeneous=homogeneous, ) assert distance == num_qubits + 1 + + +def test_riffle() -> None: + symplectic = np.array(list(itertools.product((0, 1), repeat=6)), dtype=np.uint8) + expected = np.stack((symplectic[:, :3], symplectic[:, 3:]), axis=2).reshape(-1, 6) + np.testing.assert_array_equal(_brute_force.riffle(symplectic), expected) + assert np.shape(_brute_force.riffle(np.empty((0, 6)))) == (0, 6) + with pytest.raises(AssertionError): + _brute_force.riffle(np.zeros((1, 3))) + + +def test_symplectic_weight() -> None: + """Validate symplectic_weight against the reference bit-counting implementation.""" + rng = np.random.default_rng(64) + vals = rng.integers(0, 2**64, size=(7, 11), dtype=np.uint64) + expected_weights = count_set_bits((vals | (vals >> np.uint64(1))) & 0x5555555555555555) + weights = _brute_force.symplectic_weight(vals) + assert weights.dtype == np.uint64 + np.testing.assert_array_equal(weights, expected_weights) + + out = rng.integers(0, 2**64, size=vals.shape, dtype=vals.dtype) + weights = _brute_force.symplectic_weight(vals, out=out) + np.testing.assert_array_equal(weights, expected_weights) + assert weights is out + + +@pytest.mark.parametrize("width", range(1, 8)) +def test_inplace_rowsum(width: int) -> None: + """inplace_rowsum reduces each row to its sum and stores the result in the first column.""" + arr = np.random.default_rng(width).integers(2**31, size=(10, width), dtype=np.uint64) + expected = arr.sum(-1) + actual = _brute_force.inplace_rowsum(arr) + np.testing.assert_array_equal(actual, expected) + np.testing.assert_array_equal(actual, arr[:, 0]) diff --git a/src/qldpc/codes/distance/_connected_support/__init__.py b/src/qldpc/codes/distance/_connected_support/__init__.py new file mode 100644 index 000000000..e54dec9d3 --- /dev/null +++ b/src/qldpc/codes/distance/_connected_support/__init__.py @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Searches of the supports that are connected in a Tanner graph: of distances, and of cosets. + +``qldpc.codes.distance.core`` and ``qldpc.codes.distance._coset_leader_search`` are the +interfaces of these modules. +""" diff --git a/src/qldpc/codes/distance/_search/connected_support.py b/src/qldpc/codes/distance/_connected_support/search.py similarity index 99% rename from src/qldpc/codes/distance/_search/connected_support.py rename to src/qldpc/codes/distance/_connected_support/search.py index c383289b9..35fdb386b 100644 --- a/src/qldpc/codes/distance/_search/connected_support.py +++ b/src/qldpc/codes/distance/_connected_support/search.py @@ -33,9 +33,9 @@ from qldpc._compilation import always_compiled from .._binary import assert_binary, get_nested_code_basis +from .._information_sets import automorphism_families +from .._information_sets.basis_families import infer_weight_divisor from .._tuning import MAX_VALUE, SearchTuning, get_tuning -from . import automorphism_families -from .information_sets import infer_weight_divisor # The largest scale of the weights of checks, which keeps their sums far from overflowing 64 bits. _MAX_SCALE = 2**40 diff --git a/src/qldpc/codes/distance/_search/connected_support_test.py b/src/qldpc/codes/distance/_connected_support/search_test.py similarity index 99% rename from src/qldpc/codes/distance/_search/connected_support_test.py rename to src/qldpc/codes/distance/_connected_support/search_test.py index 30fd0cd82..51f692235 100644 --- a/src/qldpc/codes/distance/_search/connected_support_test.py +++ b/src/qldpc/codes/distance/_connected_support/search_test.py @@ -20,7 +20,7 @@ from .. import _tuning from ..conftest import get_span -from . import connected_support as cs +from . import search as cs Problem = tuple[npt.NDArray[np.uint8], npt.NDArray[np.uint8]] diff --git a/src/qldpc/codes/distance/_search/coset_leader_search.py b/src/qldpc/codes/distance/_coset_leader_search.py similarity index 99% rename from src/qldpc/codes/distance/_search/coset_leader_search.py rename to src/qldpc/codes/distance/_coset_leader_search.py index d73a70e3c..94ba32cec 100644 --- a/src/qldpc/codes/distance/_search/coset_leader_search.py +++ b/src/qldpc/codes/distance/_coset_leader_search.py @@ -30,17 +30,12 @@ ) from qldpc._compilation import always_compiled -from .. import _tuning -from .._binary import as_binary_matrix, assert_binary, symplectic_to_hamming -from .._tuning import SearchTuning, get_tuning -from . import ( - affine_instance, - affine_search, - completion_bounds, - connected_support, - coset_routes, - information_sets, -) +from . import _tuning +from ._binary import as_binary_matrix, assert_binary, symplectic_to_hamming +from ._brouwer_zimmermann import affine_instance, affine_search, coset_routes +from ._connected_support import search as connected_support +from ._information_sets import certificates, completion_bounds +from ._tuning import SearchTuning, get_tuning # The method of a coset-leader search. CosetSearchMethod = Literal["brouwer_zimmermann", "connected_support"] @@ -1627,7 +1622,7 @@ def _get_stopping_step(table: npt.NDArray[np.int64], max_weight: int) -> tuple[i max_weight < bounds[layer, num_completed] and bounds[layer, num_completed - 1] <= max_weight ) if not is_stopping_step: - layer, num_completed = information_sets.find_stopping_step( + layer, num_completed = certificates.find_stopping_step( bounds, max_weight, 0, len(bounds) - 1 ) table[-1, 0], table[-1, 1] = layer, num_completed @@ -1708,7 +1703,7 @@ def _search_from_syndrome_word( prefixes = np.empty((num_rows, 1, width), dtype=np.uint64) for layer in range(stop_layer + 1): for set_index in range(num_sets if layer < stop_layer else stop_num_completed): - if information_sets.is_unused_set(pivots, set_index, stop_layer, stop_num_completed): + if certificates.is_unused_set(pivots, set_index, stop_layer, stop_num_completed): continue set_skipped = 0 if layer == 0: @@ -1946,9 +1941,7 @@ def _search_coset_for_min_weight( # the floor or the bound of the step before this one. A scan returns once its best # weight, which starts at the incumbent, is at most its cutoff, so the cutoff stays # below the incumbent. - if not information_sets.is_unused_set( - pivots, set_index, stop_layer, stop_num_completed - ): + if not certificates.is_unused_set(pivots, set_index, stop_layer, stop_num_completed): incumbent = min(best_units - slack, skip_bound) step_floor = max(floor, bounds[layer, set_index]) cutoff = min(factor * step_floor, incumbent - 1) @@ -2040,7 +2033,7 @@ def _get_first_step_reaching(bounds: npt.NDArray[np.int64], target: int) -> tupl ``target - 1``. Unlike ``_get_stopping_step``, this function caches no step, so the cached steps of the planning buffer serve only the queries of ``_search_coset``. """ - return information_sets.find_stopping_step(bounds, target - 1, 0, len(bounds) - 1) + return certificates.find_stopping_step(bounds, target - 1, 0, len(bounds) - 1) @always_compiled(inline=True) diff --git a/src/qldpc/codes/distance/_search/coset_leader_search_test.py b/src/qldpc/codes/distance/_coset_leader_search_test.py similarity index 93% rename from src/qldpc/codes/distance/_search/coset_leader_search_test.py rename to src/qldpc/codes/distance/_coset_leader_search_test.py index 0702d6d27..d00e4e66c 100644 --- a/src/qldpc/codes/distance/_search/coset_leader_search_test.py +++ b/src/qldpc/codes/distance/_coset_leader_search_test.py @@ -18,18 +18,21 @@ from qldpc import math from qldpc._binary import get_kernel, pack_bits, row_reduce_binary, unpack_bits -from ...conftest import Tune -from .._binary import symplectic_to_hamming -from .._tuning import DEFAULT_TUNING, MAX_VALUE, get_tuning, use_tuning -from ..conftest import ( +from ..conftest import Tune +from . import _coset_leader_search +from ._binary import symplectic_to_hamming +from ._brouwer_zimmermann import affine_instance +from ._coset_leader_search import CosetLeaderSearch, CosetSearchMethod, MinWeightBound +from ._information_sets import basis_families, completion_bounds +from ._information_sets import certificates as certificates_module +from ._tuning import DEFAULT_TUNING, MAX_VALUE, get_tuning, use_tuning +from .conftest import ( EVEN_ROUTES_CODE, OVERLAPPING_ROUTES_CODE, TWO_FAMILIES_CODE, get_binary_matrix, get_random_full_rank_matrix, ) -from . import affine_instance, completion_bounds, coset_leader_search, information_sets -from .coset_leader_search import CosetLeaderSearch, CosetSearchMethod, MinWeightBound def _stabilizer_matrix(generators: Sequence[stim.PauliString]) -> npt.NDArray[np.uint8]: @@ -668,21 +671,21 @@ def test_css_weight_searches_of_even_stabilizers_stop_at_bounds_of_each_class_pa # Find the stopping step of each query as ``_search_coset`` does. Steps ``(layer, # num_completed)`` compare as tuples in the order of the search. instance = affine_instance.get_coset_instance(matrix, random_column_orders=0, weight_factor=2) - inverse = coset_leader_search._get_inverse(checks) - tables = coset_leader_search._get_search_tables(instance, inverse, num_qubits) + inverse = _coset_leader_search._get_inverse(checks) + tables = _coset_leader_search._get_search_tables(instance, inverse, num_qubits) unrounded = instance.get_weight_bounds() steps = list(itertools.product(range(len(unrounded)), range(1, unrounded.shape[1]))) best = np.empty(tables.inverse.shape[1], dtype=np.uint64) num_sooner = 0 for syndrome in range(1 << num_qubits): - _, parity = coset_leader_search._get_syndrome_word( + _, parity = _coset_leader_search._get_syndrome_word( _pack_syndromes([syndrome], num_qubits)[0], best, tables.inverse, tables.x_mask, 0 ) - table = coset_leader_search._get_planning_table(tables.planning, parity) + table = _coset_leader_search._get_planning_table(tables.planning, parity) bounds = instance.get_weight_bounds(class_parities[syndrome]) for max_weight in range(2, num_qubits): stopping_step = next(step for step in steps if bounds[step] > max_weight) - assert coset_leader_search._get_stopping_step(table, max_weight)[:2] == stopping_step + assert _coset_leader_search._get_stopping_step(table, max_weight)[:2] == stopping_step num_sooner += stopping_step < next( step for step in steps if unrounded[step] > max_weight ) @@ -703,7 +706,7 @@ def test_cached_searches_answer_only_for_their_stabilizer_group( zero_plus = np.block([[zeros, eye, zeros, zeros], [zeros, zeros, eye, zeros]]) plus_zero = np.block([[eye, zeros, zeros, zeros], [zeros, zeros, zeros, eye]]) matrices = [_scramble(matrix, rng) for matrix in (zero_plus, zero_plus, plus_zero)] - coset_leader_search._get_css_searches.cache_clear() + _coset_leader_search._get_css_searches.cache_clear() oracles = [CosetLeaderSearch(matrix, css_weight=True) for matrix in matrices] for oracle, matrix in zip(oracles, matrices, strict=True): minima = _css_minima(matrix) @@ -730,18 +733,18 @@ def test_oracles_use_the_tuning_with_which_they_are_built(monkeypatch: pytest.Mo ``coset_completion_block_bits``. """ orders: list[int] = [] - get_disjoint_family = information_sets.get_disjoint_family + get_disjoint_family = basis_families.get_disjoint_family - def record(*args: Any, random_column_orders: int) -> information_sets.BasisFamily: + def record(*args: Any, random_column_orders: int) -> basis_families.BasisFamily: orders.append(random_column_orders) return get_disjoint_family(*args, random_column_orders=random_column_orders) - monkeypatch.setattr(information_sets, "get_disjoint_family", record) + monkeypatch.setattr(basis_families, "get_disjoint_family", record) eye, zeros = np.eye(3, dtype=np.uint8), np.zeros((3, 3), dtype=np.uint8) zero_plus = np.block([[zeros, eye, zeros, zeros], [zeros, zeros, eye, zeros]]) phased = _graph_state(2, [(0, 1)], phase=True) - coset_leader_search._get_css_searches.cache_clear() - coset_leader_search._get_pauli_search.cache_clear() + _coset_leader_search._get_css_searches.cache_clear() + _coset_leader_search._get_pauli_search.cache_clear() default = DEFAULT_TUNING.disjoint_random_column_orders for column_orders, matrix in itertools.product((default, 3), (zero_plus, phased)): with use_tuning(disjoint_random_column_orders=column_orders): @@ -780,11 +783,11 @@ def test_oracles_build_other_families_with_the_tuning_they_are_built_with( """ calls: list[tuple[int, int]] = [] table_calls: set[tuple[int, int]] = set() - get_overlapping_family = information_sets.get_overlapping_family + get_overlapping_family = basis_families.get_overlapping_family build_tables_within = completion_bounds.build_tables_within signature = inspect.signature(get_overlapping_family) - def record(*args: Any, **kwargs: Any) -> information_sets.BasisFamily: + def record(*args: Any, **kwargs: Any) -> basis_families.BasisFamily: policy = signature.bind(*args, **kwargs).arguments["policy"] calls.append((policy.max_sets, policy.attempts_per_set)) return get_overlapping_family(*args, **kwargs) @@ -795,9 +798,9 @@ def record_tables( table_calls.add((block_bits, max_bytes)) return build_tables_within(rows, block_bits, max_bytes) - monkeypatch.setattr(information_sets, "get_overlapping_family", record) + monkeypatch.setattr(basis_families, "get_overlapping_family", record) monkeypatch.setattr(completion_bounds, "build_tables_within", record_tables) - coset_leader_search._get_pauli_search.cache_clear() + _coset_leader_search._get_pauli_search.cache_clear() with use_tuning( overlapping_minimum_work=0, max_information_sets=5, @@ -822,7 +825,7 @@ def test_connected_support_oracles_make_checks_sparser_with_their_tuning( state with phases, which is not CSS, so the oracle has one search of Pauli errors. """ calls: list[tuple[int, int]] = [] - get_sparser_rows = coset_leader_search._get_sparser_rows + get_sparser_rows = _coset_leader_search._get_sparser_rows signature = inspect.signature(get_sparser_rows) def record(*args: Any, **kwargs: Any) -> tuple[npt.NDArray[np.uint8], npt.NDArray[np.uint8]]: @@ -830,7 +833,7 @@ def record(*args: Any, **kwargs: Any) -> tuple[npt.NDArray[np.uint8], npt.NDArra calls.append((arguments["max_rounds"], arguments["rounds_without_improvement"])) return get_sparser_rows(*args, **kwargs) - monkeypatch.setattr(coset_leader_search, "_get_sparser_rows", record) + monkeypatch.setattr(_coset_leader_search, "_get_sparser_rows", record) with use_tuning(max_sparsifying_rounds=5, sparsifying_rounds_without_improvement=2): CosetLeaderSearch(_graph_state(2, [(0, 1)], phase=True), method="connected_support") assert calls == [(5, 2)] @@ -858,8 +861,8 @@ def test_oracles_under_other_gates_do_not_share_searches(tune: Tune) -> None: queries = list(itertools.product(syndromes, range(6))) answers = {} for minimum_work in (0, MAX_VALUE): - coset_leader_search._get_css_searches.cache_clear() - coset_leader_search._get_pauli_search.cache_clear() + _coset_leader_search._get_css_searches.cache_clear() + _coset_leader_search._get_pauli_search.cache_clear() with use_tuning(overlapping_minimum_work=minimum_work): oracle = CosetLeaderSearch(matrix, css_weight=css_weight) answers[minimum_work] = [oracle.get_min_weight_bound(*query) for query in queries] @@ -886,7 +889,7 @@ def test_cached_pauli_searches_answer_for_their_own_stabilizers_and_weight() -> _product_state(generators) for generators in (("Y__", "_Z_", "__X"), ("Y__", "YZ_", "__X"), ("X__", "_Y_", "__Z")) ] - coset_leader_search._get_pauli_search.cache_clear() + _coset_leader_search._get_pauli_search.cache_clear() oracles = [] for changes in ({}, {"max_css_single_pairs": 0}, {"disjoint_random_column_orders": 3}): with use_tuning(**changes): @@ -915,7 +918,7 @@ def test_cached_pauli_searches_ignore_later_changes_to_the_stabilizer_array(tune """ tune(overlapping_minimum_work=0) first, second = _ROUTED_CSS_WEIGHT_STATE, _ROUTED_PHYSICAL_STATES[1] - coset_leader_search._get_pauli_search.cache_clear() + _coset_leader_search._get_pauli_search.cache_clear() array = first.copy() oracles = [ (first, css_weight, CosetLeaderSearch(array, css_weight=css_weight)) @@ -942,9 +945,9 @@ def test_tables_of_pairs_of_single_qubit_errors_respect_their_limit() -> None: """ x_singles = {0: 0, 0b01: 0b0001, 0b11: 0b0010} z_singles = {0: 0, 0b10: 0b0100} - table = coset_leader_search._tabulate_css_singles(x_singles, z_singles, 6) + table = _coset_leader_search._tabulate_css_singles(x_singles, z_singles, 6) assert table == {0: 0, 0b10: 0b0100, 0b01: 0b0001, 0b11: 0b0101} - assert coset_leader_search._tabulate_css_singles(x_singles, z_singles, 5) is None + assert _coset_leader_search._tabulate_css_singles(x_singles, z_singles, 5) is None def test_invalid_arguments_are_rejected() -> None: @@ -980,28 +983,28 @@ def test_cached_stopping_steps_are_reused_exactly_while_they_stay_first( zero, two, and four in a code of dimension four, for both weight factors. """ plans: list[int] = [] - find_stopping_step = information_sets.find_stopping_step + find_stopping_step = certificates_module.find_stopping_step def record(*args: Any) -> tuple[int, int]: plans.append(args[1]) return find_stopping_step(*args) - monkeypatch.setattr(information_sets, "find_stopping_step", record) + monkeypatch.setattr(certificates_module, "find_stopping_step", record) # Take the Python version even when the function is compiled, since only it calls the recording # function. - function = coset_leader_search._get_stopping_step + function = _coset_leader_search._get_stopping_step get_stopping_step = getattr(function, "py_func", function) certificates = [ - information_sets.Certificate(0, (0,)), - information_sets.Certificate(1, (1,) * 64), - information_sets.Certificate(4, (0, 2, 4, 0)), + certificates_module.Certificate(0, (0,)), + certificates_module.Certificate(1, (1,) * 64), + certificates_module.Certificate(4, (0, 2, 4, 0)), ] for certificate, factor in itertools.product(certificates, (1, 2)): bounds = certificate.get_weight_bounds(factor) num_rows, num_sets = bounds.shape[0] - 1, bounds.shape[1] - 1 steps = list(itertools.product(range(num_rows + 1), range(1, num_sets + 1))) - largest = int(bounds[bounds < information_sets.NO_WORD_BOUND].max()) - (table,) = coset_leader_search._get_planning([bounds]) + largest = int(bounds[bounds < certificates_module.NO_WORD_BOUND].max()) + (table,) = _coset_leader_search._get_planning([bounds]) for planted, max_weight in itertools.product(steps, range(largest + 2)): stopping_step = next(step for step in steps if bounds[step] > max_weight) expected = (*stopping_step, int(bounds[stopping_step])) @@ -1072,8 +1075,8 @@ def _assert_classical_cosets_match_brute_force(generators: npt.NDArray[np.uint8] """ num_columns = generators.shape[1] checks = get_kernel(generators) - tuning = coset_leader_search._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) - search = coset_leader_search._get_classical_search( + tuning = _coset_leader_search._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + search = _coset_leader_search._get_classical_search( generators, checks, tuning._replace(disjoint_random_column_orders=0) ) minima = _classical_minima(checks) @@ -1225,10 +1228,10 @@ def test_answers_do_not_depend_on_earlier_queries(pytestconfig: pytest.Config, t def get_answers(order: str) -> dict[tuple[int, int], MinWeightBound]: """Every answer of a fresh search, with the maximum weights in the given order.""" - tuning = coset_leader_search._CosetTuning.from_tuning( + tuning = _coset_leader_search._CosetTuning.from_tuning( get_tuning(), paulis_by_css_weight=False ) - search = coset_leader_search._get_classical_search( + search = _coset_leader_search._get_classical_search( OVERLAPPING_ROUTES_CODE, checks, tuning._replace(disjoint_random_column_orders=0) ) weights = list(range(15)) @@ -1267,7 +1270,7 @@ def test_searches_visit_only_the_sets_that_add_to_their_bounds(pytestconfig: pyt num_rows = int(rng.integers(1, num_columns)) generators = get_random_full_rank_matrix(rng, num_rows, num_columns) checks = get_kernel(generators) - family = information_sets.get_disjoint_family(generators, None, random_column_orders=0) + family = basis_families.get_disjoint_family(generators, None, random_column_orders=0) ranks = (*family.ranks.tolist(), 0) num_sets = len(ranks) rows = np.concatenate([family.generators, generators[None]]) @@ -1276,10 +1279,10 @@ def test_searches_visit_only_the_sets_that_add_to_their_bounds(pytestconfig: pyt labels=np.zeros((num_sets, num_rows, 0), dtype=np.uint64), widths=np.ones(num_sets, dtype=np.int64), pivots=np.vstack([family.pivots, np.full(num_rows, -1)]), - certificate=information_sets.Certificate(num_rows, ranks), + certificate=certificates_module.Certificate(num_rows, ranks), ) - inverse = coset_leader_search._get_inverse(checks) - tables = coset_leader_search._get_search_tables(instance, inverse, num_columns) + inverse = _coset_leader_search._get_inverse(checks) + tables = _coset_leader_search._get_search_tables(instance, inverse, num_columns) minima = _classical_minima(checks) for syndrome, max_weight in itertools.product( @@ -1287,16 +1290,16 @@ def test_searches_visit_only_the_sets_that_add_to_their_bounds(pytestconfig: pyt ): best = np.empty(tables.inverse.shape[1], dtype=np.uint64) packed_syndrome = _pack_syndromes([syndrome], len(checks))[0] - best_weight, parity = coset_leader_search._get_syndrome_word( + best_weight, parity = _coset_leader_search._get_syndrome_word( packed_syndrome, best, tables.inverse, tables.x_mask, 1 ) if best_weight <= max_weight: continue - table = coset_leader_search._get_planning_table(tables.planning, parity) - stop_layer, stop_num_completed, stop_bound = coset_leader_search._get_stopping_step( + table = _coset_leader_search._get_planning_table(tables.planning, parity) + stop_layer, stop_num_completed, stop_bound = _coset_leader_search._get_stopping_step( table, max_weight ) - lower_bound, weighed, _ = coset_leader_search._search_from_syndrome_word( + lower_bound, weighed, _ = _coset_leader_search._search_from_syndrome_word( packed_syndrome, max_weight, stop_layer, @@ -1326,8 +1329,8 @@ def test_searches_visit_only_the_sets_that_add_to_their_bounds(pytestconfig: pyt if last_layer >= num_rows - rank for layer in range(last_layer + 1) ) - binomials = information_sets.get_capped_binomials(num_rows) - plan = information_sets.plan_steps( + binomials = certificates_module.get_capped_binomials(num_rows) + plan = certificates_module.plan_steps( table[:-1], max_weight, 0, num_rows, instance.ranks, binomials, True ) assert plan == (stop_layer, stop_num_completed, weighed) @@ -1385,13 +1388,13 @@ def _assert_searches_by_tails_match_searches_of_words( labels=np.zeros((len(sets), num_rows, 0), dtype=np.uint64), widths=np.ones(len(sets), dtype=np.int64), pivots=np.array(pivots, dtype=np.int64), - certificate=information_sets.Certificate( + certificate=certificates_module.Certificate( num_rows, tuple(int(np.sum(row >= 0)) for row in pivots) ), weight_factor=2 if scale == 2 else 1, ) - tables = coset_leader_search._get_search_tables(instance, inverse, num_columns) - tails = coset_leader_search._get_coset_tails( + tables = _coset_leader_search._get_search_tables(instance, inverse, num_columns) + tails = _coset_leader_search._get_coset_tails( instance, list(range(len(sets))) if block_bits else [], None, @@ -1406,7 +1409,7 @@ def _assert_searches_by_tails_match_searches_of_words( answers = {} for syndrome, packed_syndrome in zip(syndromes, packed_syndromes, strict=True): words = np.empty((2, tables.inverse.shape[1]), dtype=np.uint64) - best_weight, parity = coset_leader_search._get_syndrome_word( + best_weight, parity = _coset_leader_search._get_syndrome_word( packed_syndrome, words[0], tables.inverse, tables.x_mask, scale ) if best_weight <= max_weight: @@ -1414,10 +1417,10 @@ def _assert_searches_by_tails_match_searches_of_words( words[1] = words[0] word = unpack_bits(words[:1], num_columns)[0] coset_parity = int(word.sum()) % 2 - stop_layer, stop_num_completed, stop_bound = coset_leader_search._get_stopping_step( - coset_leader_search._get_planning_table(tables.planning, parity), max_weight + stop_layer, stop_num_completed, stop_bound = _coset_leader_search._get_stopping_step( + _coset_leader_search._get_planning_table(tables.planning, parity), max_weight ) - *answer, _ = coset_leader_search._search_from_syndrome_word( + *answer, _ = _coset_leader_search._search_from_syndrome_word( packed_syndrome, max_weight, stop_layer, @@ -1432,7 +1435,7 @@ def _assert_searches_by_tails_match_searches_of_words( scale, parity if len(tables.planning) == 2 else -1, ) - *answer_by_tails, skipped = coset_leader_search._search_from_syndrome_word( + *answer_by_tails, skipped = _coset_leader_search._search_from_syndrome_word( packed_syndrome, max_weight, stop_layer, @@ -1481,10 +1484,10 @@ def _assert_searches_by_tails_match_searches_of_words( start = 0 while True: words = np.empty((2, tables.inverse.shape[1]), dtype=np.uint64) - found = coset_leader_search._find_first_not_within_max_weight( + found = _coset_leader_search._find_first_not_within_max_weight( packed_syndromes[start:], max_weight, words[0], *tables, None, scale ) - found_by_tails = coset_leader_search._find_first_not_within_max_weight( + found_by_tails = _coset_leader_search._find_first_not_within_max_weight( packed_syndromes[start:], max_weight, words[1], *tables, tails, scale ) assert found_by_tails[0] == found[0] @@ -1582,7 +1585,7 @@ def test_searches_by_tails_answer_as_searches_of_whole_words( ( ( code, - coset_leader_search._get_inverse(checks), + _coset_leader_search._get_inverse(checks), 1, (num_rows, num_rows - 1), range(-1, code.shape[1] + 2), @@ -1600,7 +1603,7 @@ def test_searches_by_tails_answer_as_searches_of_whole_words( ( ( even, - coset_leader_search._get_inverse(checks), + _coset_leader_search._get_inverse(checks), 1, (5, 4), range(-1, even.shape[1] + 2), @@ -1618,7 +1621,7 @@ def test_searches_by_tails_answer_as_searches_of_whole_words( ( ( wide, - coset_leader_search._get_inverse(checks), + _coset_leader_search._get_inverse(checks), 1, (4, 3), range(-1, 40, 4), @@ -1634,7 +1637,7 @@ def test_searches_by_tails_answer_as_searches_of_whole_words( ( ( symplectic_to_hamming(matrix), - symplectic_to_hamming(coset_leader_search._get_inverse(checks)), + symplectic_to_hamming(_coset_leader_search._get_inverse(checks)), 2, (2, 1) if block_bits else (4, 3), range(-1, 6), @@ -1661,20 +1664,20 @@ def test_queries_of_routes_by_tails_weigh_the_tails_of_their_record( queries at maximum weight two find a light enough word in every coset, even in the cosets whose words weigh at least three, which a search of whole words would reject. """ - get_coset_tails = coset_leader_search._get_coset_tails + get_coset_tails = _coset_leader_search._get_coset_tails - def get_zero_tails(*args: Any, **kwargs: Any) -> coset_leader_search._CosetTails: + def get_zero_tails(*args: Any, **kwargs: Any) -> _coset_leader_search._CosetTails: """The record of the tails of a family, with every tail zero.""" record = get_coset_tails(*args, **kwargs) return record._replace( tails=np.zeros_like(record.tails), inverse_tails=np.zeros_like(record.inverse_tails) ) - monkeypatch.setattr(coset_leader_search, "_get_coset_tails", get_zero_tails) + monkeypatch.setattr(_coset_leader_search, "_get_coset_tails", get_zero_tails) generators = np.ones((1, 7), dtype=np.uint8) checks = get_kernel(generators) - tuning = coset_leader_search._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) - search = coset_leader_search._get_classical_search( + tuning = _coset_leader_search._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + search = _coset_leader_search._get_classical_search( generators, checks, tuning._replace(overlapping_minimum_work=0) ) syndromes = np.flatnonzero(_classical_minima(checks) > 2).tolist() @@ -1698,17 +1701,17 @@ def test_queries_of_routes_by_tails_skip_completions_by_the_tables_of_their_reco bound such cosets by four. The code is not even, so four is the least weight beyond the maximum weight that a word of such a coset can have. """ - get_coset_tails = coset_leader_search._get_coset_tails + get_coset_tails = _coset_leader_search._get_coset_tails - def get_skipping_tails(*args: Any, **kwargs: Any) -> coset_leader_search._CosetTails: + def get_skipping_tails(*args: Any, **kwargs: Any) -> _coset_leader_search._CosetTails: """The record of the tails of a family, with tables that skip every completion.""" record = get_coset_tails(*args, **kwargs) return record._replace(completion_tables=np.full_like(record.completion_tables, 255)) - monkeypatch.setattr(coset_leader_search, "_get_coset_tails", get_skipping_tails) + monkeypatch.setattr(_coset_leader_search, "_get_coset_tails", get_skipping_tails) checks = get_kernel(OVERLAPPING_ROUTES_CODE) - tuning = coset_leader_search._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) - search = coset_leader_search._get_classical_search( + tuning = _coset_leader_search._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + search = _coset_leader_search._get_classical_search( OVERLAPPING_ROUTES_CODE, checks, tuning._replace(overlapping_minimum_work=0) ) minima = _classical_minima(checks) @@ -1739,17 +1742,17 @@ def test_records_of_tails_add_the_tables_of_the_sets_that_fit() -> None: _RANK_DEFICIENT_CODE, random_column_orders=0, weight_factor=1 ) assert instance.ranks.tolist() == [5, 4] - inverse = coset_leader_search._get_inverse(checks) + inverse = _coset_leader_search._get_inverse(checks) def get_record( - sets: list[int], record: coset_leader_search._CosetTails | None, max_table_bytes: int - ) -> coset_leader_search._CosetTails: + sets: list[int], record: _coset_leader_search._CosetTails | None, max_table_bytes: int + ) -> _coset_leader_search._CosetTails: """The record of the tails of the family, with the tables of the sets that fit.""" - return coset_leader_search._get_coset_tails( + return _coset_leader_search._get_coset_tails( instance, sets, record, inverse=inverse, block_bits=8, max_table_bytes=max_table_bytes ) - def get_set_tables(record: coset_leader_search._CosetTails, set_index: int) -> list[int]: + def get_set_tables(record: _coset_leader_search._CosetTails, set_index: int) -> list[int]: """The tables of a set, as its layout places them in the record.""" start, num_blocks = record.layout[set_index, :2] offsets = record.layout[set_index, 2 : num_blocks + 3] @@ -1784,13 +1787,13 @@ def _coset_minima( """ code = np.array(list(itertools.product([0, 1], repeat=len(generators)))) @ generators % 2 bits = (np.array(syndromes)[:, None] >> np.arange(len(checks))) & 1 - words = bits @ coset_leader_search._get_inverse(checks) % 2 + words = bits @ _coset_leader_search._get_inverse(checks) % 2 assert np.array_equal(_classical_syndrome(checks, words), syndromes) return np.array([((word + code) % 2).sum(axis=1).min() for word in words]) def _assert_min_weights_match_brute_force( - search: coset_leader_search._CosetSearch, + search: _coset_leader_search._CosetSearch, syndromes: Sequence[int], minima: npt.NDArray[np.int_], num_syndrome_bits: int, @@ -1841,7 +1844,7 @@ def test_min_weights_of_cosets_match_brute_force( """ tune(**tuning) rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - tuning_fields = coset_leader_search._CosetTuning.from_tuning( + tuning_fields = _coset_leader_search._CosetTuning.from_tuning( get_tuning(), paulis_by_css_weight=False ) num_rows, num_columns = int(rng.integers(3, 6)), int(rng.integers(8, 11)) @@ -1850,12 +1853,12 @@ def test_min_weights_of_cosets_match_brute_force( even_code = np.hstack([code, code.sum(axis=1, keepdims=True) % 2]).astype(np.uint8) for generators in (code, even_code): checks = get_kernel(generators) - search = coset_leader_search._get_classical_search(generators, checks, tuning_fields) + search = _coset_leader_search._get_classical_search(generators, checks, tuning_fields) syndromes = range(1 << len(checks)) minima = _coset_minima(generators, checks, syndromes) _assert_min_weights_match_brute_force(search, syndromes, minima, len(checks), rng) checks = get_kernel(_HEAVY_COSETS_CODE) - search = coset_leader_search._get_classical_search(_HEAVY_COSETS_CODE, checks, tuning_fields) + search = _coset_leader_search._get_classical_search(_HEAVY_COSETS_CODE, checks, tuning_fields) early_stop_syndromes = [1126, 4578, 6129] minima = _coset_minima(_HEAVY_COSETS_CODE, checks, early_stop_syndromes) _assert_min_weights_match_brute_force(search, early_stop_syndromes, minima, len(checks), rng) @@ -1944,7 +1947,7 @@ def test_min_weight_searches_above_max_weight_examine_as_queries( each coset. """ tune(**tuning) - tuning_fields = coset_leader_search._CosetTuning.from_tuning( + tuning_fields = _coset_leader_search._CosetTuning.from_tuning( get_tuning(), paulis_by_css_weight=False ) cases = [] @@ -1956,13 +1959,13 @@ def test_min_weight_searches_above_max_weight_examine_as_queries( ]: checks = get_kernel(generators) syndromes = chosen_syndromes or list(range(1 << len(checks))) - search = coset_leader_search._get_classical_search(generators, checks, tuning_fields) + search = _coset_leader_search._get_classical_search(generators, checks, tuning_fields) minima = _coset_minima(generators, checks, syndromes) cases.append((search, len(checks), syndromes, minima, True)) matrix = _ROUTED_PHYSICAL_STATES[1] checks = (math.symplectic_conjugate(matrix.astype(int)) % 2).astype(np.uint8) - search = coset_leader_search._get_pauli_search( - coset_leader_search._StabilizerKey(matrix, checks), False, tuning_fields + search = _coset_leader_search._get_pauli_search( + _coset_leader_search._StabilizerKey(matrix, checks), False, tuning_fields ) syndromes = list(range(1 << len(matrix))) cases.append((search, len(matrix), syndromes, _css_minima(matrix, css_weight=False), False)) @@ -1976,13 +1979,16 @@ def test_min_weight_searches_above_max_weight_examine_as_queries( continue packed_syndrome = _pack_syndromes([syndrome], num_syndrome_bits)[0] best = np.empty(tables.inverse.shape[1], dtype=np.uint64) - best_weight, parity = coset_leader_search._get_syndrome_word( + best_weight, parity = _coset_leader_search._get_syndrome_word( packed_syndrome, best, tables.inverse, tables.x_mask, search.scale ) - stop_layer, stop_num_completed, stop_bound = coset_leader_search._get_stopping_step( - coset_leader_search._get_planning_table(tables.planning, parity), max_weight + stop_layer, stop_num_completed, stop_bound = ( + _coset_leader_search._get_stopping_step( + _coset_leader_search._get_planning_table(tables.planning, parity), + max_weight, + ) ) - _, *expected = coset_leader_search._search_from_syndrome_word( + _, *expected = _coset_leader_search._search_from_syndrome_word( packed_syndrome, max_weight, stop_layer, @@ -1997,7 +2003,7 @@ def test_min_weight_searches_above_max_weight_examine_as_queries( search.scale, parity if len(tables.planning) == 2 else -1, ) - _, *found = coset_leader_search._search_coset_for_min_weight( + _, *found = _coset_leader_search._search_coset_for_min_weight( packed_syndrome, max_weight, 0, best, *tables, tails, search.scale ) skipped += expected[1] @@ -2131,7 +2137,7 @@ def get_weights(rows: npt.NDArray[np.uint8], homogeneous: bool) -> npt.NDArray[n matrix = rng.integers(2, size=(6, 8), dtype=np.uint8) cases.append((matrix[row_reduce_binary(matrix.T.copy())[2]], homogeneous)) for matrix, homogeneous in cases: - rows, transform = coset_leader_search._sparsify(matrix, homogeneous=homogeneous) + rows, transform = _coset_leader_search._sparsify(matrix, homogeneous=homogeneous) assert np.array_equal(transform @ matrix % 2, rows) assert len(row_reduce_binary(transform)[2]) == len(matrix) weights = get_weights(rows, homogeneous) @@ -2157,7 +2163,7 @@ def test_rounds_of_random_reductions_make_rows_sparser() -> None: dtype=np.uint8, ) for max_rounds, total_weight in [(0, 10), (20, 9)]: - rows, transform = coset_leader_search._get_sparser_rows( + rows, transform = _coset_leader_search._get_sparser_rows( matrix, homogeneous=False, max_rounds=max_rounds, rounds_without_improvement=20 ) assert np.array_equal(transform @ matrix % 2, rows) diff --git a/src/qldpc/codes/distance/_counting/macwilliams.py b/src/qldpc/codes/distance/_counting/macwilliams.py index 538278305..2cf3d090c 100644 --- a/src/qldpc/codes/distance/_counting/macwilliams.py +++ b/src/qldpc/codes/distance/_counting/macwilliams.py @@ -15,7 +15,7 @@ bound rules out overflow, and exact Python integers at the others. The walk visits the words of a span in reflected Gray-code order, as the brute-force distance search -in ``_search/brute_force.py`` does, but it reduces every word to a weight histogram in one compiled +in ``_brute_force.py`` does, but it reduces every word to a weight histogram in one compiled loop instead of to a minimum weight over vectorized blocks. References: diff --git a/src/qldpc/codes/distance/_counting/scan.py b/src/qldpc/codes/distance/_counting/scan.py index 25aea8669..82bdb99ef 100644 --- a/src/qldpc/codes/distance/_counting/scan.py +++ b/src/qldpc/codes/distance/_counting/scan.py @@ -7,7 +7,7 @@ them, and visits each word of the given weight once, by enumerating combinations of the rows of every set, layer by layer. A step of two or more rows of an information set may skip the supports whose words all weigh more than the given weight, with the completion tables of -``_search/completion_bounds.py`` (Section 3.9 of ``brouwer_zimmermann.md``). +``_information_sets/completion_bounds.py`` (Section 3.9 of ``brouwer_zimmermann.md``). ``transport_basis_family`` moves the disjoint basis family that a counter keeps for a sector, from which both its counts and its distance searches start, through a local gate, for deformed counters. ``logical_counts.py`` calls both. Section 3 of ``brouwer_zimmermann.md`` describes the algorithm, @@ -25,9 +25,9 @@ from qldpc._binary import pack_bits, popcount_word, row_reduce_packed, unpack_bits from qldpc._compilation import always_compiled, compiled_on_request -from .._search import completion_bounds, information_sets -from .._search.completion_bounds import get_completion_bound -from .._search.information_sets import advance_support +from .._information_sets import basis_families, certificates, completion_bounds +from .._information_sets.completion_bounds import get_completion_bound +from .._information_sets.support_stepping import advance_support from .._tuning import SearchTuning from . import local_cliffords @@ -56,7 +56,7 @@ def count_words( complement: bool, max_exponent: int, reject_lighter: bool, - get_disjoint_basis_family: Callable[[], information_sets.BasisFamily], + get_disjoint_basis_family: Callable[[], basis_families.BasisFamily], tuning: SearchTuning, ) -> npt.NDArray[np.int64] | None: """Visit each nontrivial word of one sector with the given weight exactly once. @@ -81,10 +81,10 @@ def count_words( labels, weight, get_disjoint_basis_family(), - information_sets.get_overlapping_family_policy(tuning, len(basis)), + basis_families.get_overlapping_family_policy(tuning, len(basis)), ) # Matroid partitioning runs outside of _select_basis_family, which can be compiled. - partitioned = information_sets.get_partitioned_family( + partitioned = basis_families.get_partitioned_family( basis, labels, basis_family, @@ -156,11 +156,11 @@ def count_words( def transport_basis_family( - basis_family: information_sets.BasisFamily, + basis_family: basis_families.BasisFamily, positions: npt.NDArray[np.int64], local: npt.NDArray[np.uint64], basis: npt.NDArray[np.uint8], -) -> information_sets.BasisFamily | None: +) -> basis_families.BasisFamily | None: """The basis family of a sector after a local gate, or None if one of its sets stops being one. Each set is mapped through the gate and reduced again on its own pivot columns, so the pivots @@ -180,7 +180,7 @@ def transport_basis_family( return basis_family._replace( generators=generators, labels=labels, - weight_divisor=information_sets.infer_weight_divisor(basis), + weight_divisor=basis_families.infer_weight_divisor(basis), ) @@ -189,25 +189,23 @@ def _select_basis_family( basis: npt.NDArray[np.uint8], labels: npt.NDArray[np.uint8], weight: int, - basis_family: information_sets.BasisFamily, - policy: information_sets.OverlappingFamilyPolicy, -) -> information_sets.BasisFamily: + basis_family: basis_families.BasisFamily, + policy: basis_families.OverlappingFamilyPolicy, +) -> basis_families.BasisFamily: """Run the basis family stages that can be compiled: pruning, then an overlapping replacement. The stages aim at the counting stop ``L(t) >= weight + 1``; any basis family gives an exact count. ``policy`` is that of the overlapping replacement. """ upper_bound = weight + 1 - pruned = information_sets.prune_family(basis_family, upper_bound) - replacement = information_sets.get_overlapping_family( - basis, labels, pruned, upper_bound, policy - ) + pruned = basis_families.prune_family(basis_family, upper_bound) + replacement = basis_families.get_overlapping_family(basis, labels, pruned, upper_bound, policy) return replacement if len(replacement.ranks) > 0 else pruned @compiled_on_request def _pack_basis_family( - basis_family: information_sets.BasisFamily, num_columns: int, num_label_columns: int + basis_family: basis_families.BasisFamily, num_columns: int, num_label_columns: int ) -> tuple[_PackedMatrix, _PackedMatrix, _PackedMatrix]: """Pack the rows and labels of the sets of a basis family, and their coefficient extractors. @@ -223,7 +221,7 @@ def _pack_basis_family( return rows, packed_labels, _get_coefficient_extractors(basis_family, width) -def _get_last_step(certificate: information_sets.Certificate, weight: int) -> tuple[int, int]: +def _get_last_step(certificate: certificates.Certificate, weight: int) -> tuple[int, int]: """The first step after which the certificate rules out unvisited words of the given weight. A step completes a layer of one set, and the scan visits the sets of each layer in family order. @@ -291,7 +289,7 @@ class _StepTables: def __init__( self, - basis_family: information_sets.BasisFamily | None, + basis_family: basis_families.BasisFamily | None, rows_and_labels: _PackedMatrix, upper_bound: int, tuning: SearchTuning, @@ -387,7 +385,7 @@ def _get_tails(self, set_index: int) -> _PackedMatrix: @compiled_on_request def _get_coefficient_extractors( - basis_family: information_sets.BasisFamily, width: int + basis_family: basis_families.BasisFamily, width: int ) -> _PackedMatrix: """Packed rows ``E.T`` for each set such that ``generators @ E`` is the identity over GF(2). diff --git a/src/qldpc/codes/distance/_counting/scan_test.py b/src/qldpc/codes/distance/_counting/scan_test.py index 85d1c33dc..1b7894203 100644 --- a/src/qldpc/codes/distance/_counting/scan_test.py +++ b/src/qldpc/codes/distance/_counting/scan_test.py @@ -17,7 +17,7 @@ from ...conftest import Tune from .. import logical_counts as lc -from .._search import completion_bounds, information_sets +from .._information_sets import basis_families, completion_bounds from .._tuning import MAX_VALUE from ..conftest import ( HAND_X, @@ -104,14 +104,14 @@ def test_the_information_set_limit_bounds_the_basis_families_of_a_count( ) -> None: """The limit shrinks the overlapping basis families that a count may use, not the counts.""" sizes: list[int] = [] - original = information_sets.get_overlapping_family + original = basis_families.get_overlapping_family - def record(*args: Any) -> information_sets.BasisFamily: + def record(*args: Any) -> basis_families.BasisFamily: replacement = original(*args) sizes.append(len(replacement.ranks)) return replacement - monkeypatch.setattr(information_sets, "get_overlapping_family", record) + monkeypatch.setattr(basis_families, "get_overlapping_family", record) tune(overlapping_minimum_work=0) code = get_random_css_code(np.random.default_rng(4), 12, 4, 4) weights = range(len(code) + 1) @@ -243,7 +243,7 @@ def test_step_tables_choose_and_keep_tables(tune: Tune) -> None: """Steps get tables only for layers of two or more rows of full sets, within a memory limit.""" code = get_random_css_code(np.random.default_rng(1), 24, 8, 8) basis, labels = get_sector_problem(code, Pauli.X) - disjoint = information_sets.get_disjoint_family(basis, labels, random_column_orders=0) + disjoint = basis_families.get_disjoint_family(basis, labels, random_column_orders=0) deficient = len(disjoint.ranks) - 1 assert disjoint.ranks[0] == len(basis) and disjoint.ranks[deficient] < len(basis) diff --git a/src/qldpc/codes/distance/_information_sets/__init__.py b/src/qldpc/codes/distance/_information_sets/__init__.py new file mode 100644 index 000000000..a34aaedb8 --- /dev/null +++ b/src/qldpc/codes/distance/_information_sets/__init__.py @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Information sets of exact searches: basis families and their certificates. + +The Brouwer-Zimmermann searches and counts build on these modules: basis families, their +certificates and plans of steps, the step from one support of a layer to the next, automorphism +families, completion tables, and matroid partitioning. The connected-support search uses their +weight divisors, and their checks and orbits of automorphisms. +""" diff --git a/src/qldpc/codes/distance/_search/automorphism_families.py b/src/qldpc/codes/distance/_information_sets/automorphism_families.py similarity index 98% rename from src/qldpc/codes/distance/_search/automorphism_families.py rename to src/qldpc/codes/distance/_information_sets/automorphism_families.py index 47ba64665..94222cda1 100644 --- a/src/qldpc/codes/distance/_search/automorphism_families.py +++ b/src/qldpc/codes/distance/_information_sets/automorphism_families.py @@ -25,7 +25,8 @@ from qldpc._compilation import always_compiled from .._binary import get_eligible_rows -from .information_sets import BasisFamily, Certificate, get_capped_binomials, plan_steps +from .basis_families import BasisFamily +from .certificates import Certificate, get_capped_binomials, plan_steps #################################################################################################### # automorphism families diff --git a/src/qldpc/codes/distance/_search/automorphism_families_test.py b/src/qldpc/codes/distance/_information_sets/automorphism_families_test.py similarity index 98% rename from src/qldpc/codes/distance/_search/automorphism_families_test.py rename to src/qldpc/codes/distance/_information_sets/automorphism_families_test.py index ab9db9aa8..568e8afb8 100644 --- a/src/qldpc/codes/distance/_search/automorphism_families_test.py +++ b/src/qldpc/codes/distance/_information_sets/automorphism_families_test.py @@ -17,7 +17,7 @@ from .._binary import get_eligible_rows from ..conftest import get_sector_problem -from . import automorphism_families, information_sets +from . import automorphism_families, certificates def test_automorphisms_must_map_both_spaces_onto_themselves() -> None: @@ -115,7 +115,7 @@ def test_automorphism_family_work_scans_one_set_per_layer() -> None: This certificate of three images of an information set of a five-row basis, with coverage two, bounds unvisited words by ``ceil(3 (t + 1) / 2)`` once every image has completed layer ``t``. """ - certificate = information_sets.Certificate(5, (5,) * 3, max_coverage=2) + certificate = certificates.Certificate(5, (5,) * 3, max_coverage=2) # layer one certifies weight three; layer two certifies five; no layer certifies 100 assert automorphism_families.estimate_automorphism_family_work(certificate, 3) == 5 assert automorphism_families.estimate_automorphism_family_work(certificate, 4) == 5 + 10 diff --git a/src/qldpc/codes/distance/_search/information_sets.py b/src/qldpc/codes/distance/_information_sets/basis_families.py similarity index 51% rename from src/qldpc/codes/distance/_search/information_sets.py rename to src/qldpc/codes/distance/_information_sets/basis_families.py index 5852ba871..f49f2f895 100644 --- a/src/qldpc/codes/distance/_search/information_sets.py +++ b/src/qldpc/codes/distance/_information_sets/basis_families.py @@ -41,11 +41,7 @@ from __future__ import annotations import dataclasses -import functools import math -from collections.abc import Sequence -from dataclasses import dataclass -from fractions import Fraction from typing import NamedTuple, TypeVar import numpy as np @@ -57,16 +53,13 @@ from .. import _tuning from .._tuning import SearchTuning from . import matroid_partition - -# A bound on the weight of unvisited words when no word can remain unvisited. -NO_WORD_BOUND = np.iinfo(np.int64).max - -# Caps that keep work estimates within a native integer. A binomial above _BINOMIAL_CAP counts as -# the cap, and a sum saturates at _WORK_CAP, so an estimate is exact only if it reaches neither cap. -# Searches that reach a cap are infeasible, so their order, against other basis families or -# exhaustive enumeration, does not matter. -_BINOMIAL_CAP = 1 << 50 -_WORK_CAP = 1 << 61 +from .certificates import ( + _WORK_CAP, + Certificate, + estimate_certificate_work, + get_capped_binomials, + lower_bound_from_occupancy, +) # One disjoint information set: its rows, its labels, and its pivot columns. _DisjointSet = tuple[npt.NDArray[np.uint8], npt.NDArray[np.uint8], npt.NDArray[np.int_]] @@ -79,203 +72,6 @@ # basis families -@dataclass(frozen=True) -class Certificate: - """Lower bound on the Hamming weight of eligible words that a family has not yet visited. - - ``lower_bound`` holds after every search basis of the family has completed each coefficient - layer through ``completed_weight``. ``step_lower_bound`` also holds part way through a layer, - once the first sets in family order have completed it, in whatever order their words were - visited. A set that is interrupted within a layer does not advance either bound. The bounds do - not bound the weights of witnesses already found, and they do not transfer to another basis or - CSS sector. A search that skips supports by completion tables completes a layer without - visiting them, and then the bounds hold only for the eligible words lighter than its best weight - (Section 3.9 of ``brouwer_zimmermann.md``). - - Each bound is the coverage bound: the occupancy of the sets (see ``get_step_lower_bounds``) - divided by ``max_coverage``, rounded up, and then rounded up to a multiple of - ``weight_divisor``. With ``pivot_blocks``, each bound is the larger of the coverage - bound and the bound of ``get_lp_lower_bound``, which can be larger when coverage is uneven. - - Attributes: - dimension: Number of rows of the searched basis. - ranks: Rank of the pivot block of each search basis, in family order. - weight_divisor: A proven divisor of the Hamming weight of every eligible word. It combines - any divisor proven by the caller, such as two for the symplectic-to-Hamming map, with - ambient even parity. It is in the Hamming units of the searched basis, not Pauli units. - max_coverage: An upper bound on the number of pivot blocks that contain any one coordinate. - Overestimates are sound; underestimates are not. - pivot_blocks: The pivot columns of each search basis, in family order, to bound unvisited - words also by linear programming, or no blocks to use only the coverage bound. - lp_max_denominator: The largest denominator of the rational dual weights of the linear - program (see ``get_lp_lower_bound``), which ``pivot_blocks`` needs to be positive. - """ - - dimension: int - ranks: tuple[int, ...] - weight_divisor: int = 1 - max_coverage: int = 1 - pivot_blocks: tuple[tuple[int, ...], ...] = () - lp_max_denominator: int = 0 - - def lower_bound(self, completed_weight: int) -> int: - """Bound the Hamming weight of unvisited eligible words after the given complete layers.""" - return self.step_lower_bound(completed_weight, len(self.ranks)) - - def step_lower_bound(self, weight: int, num_completed: int) -> int: - """Bound the Hamming weight of unvisited eligible words part way through layer ``weight``. - - The first ``num_completed`` sets, in family order, have completed layer ``weight``, and the - others have completed layer ``weight - 1``. - """ - bound = int(self._step_lower_bounds[weight, num_completed]) - if not self._solves_linear_programs: - return bound - step = (weight, num_completed) - if step not in self._lp_lower_bounds: - lp_bound = get_lp_lower_bound( - self.pivot_blocks, self._get_requirements(*step), self.lp_max_denominator - ) - if lp_bound < NO_WORD_BOUND: - lp_bound += (-lp_bound) % self.weight_divisor - self._lp_lower_bounds[step] = lp_bound - return max(bound, self._lp_lower_bounds[step]) - - def certifies(self, weight: int, num_completed: int, upper_bound: int) -> bool: - """Whether ``step_lower_bound(weight, num_completed)`` is at least ``upper_bound``. - - This solves a linear program only if the coverage bound falls short of the upper bound and - the linear program could reach it: its optimum is at most the value of the feasible solution - that sets each pivot column to the largest ``requirement / len(block)`` over the blocks that - contain it. - """ - if self._step_lower_bounds[weight, num_completed] >= upper_bound: - return True - if not self._solves_linear_programs: - return False - # No requirement exceeds its block, since a set that has completed the last layer makes the - # coverage bound certify any upper bound. - requirements = self._get_requirements(weight, num_completed) - largest_fractions: dict[int, Fraction] = {} - for block, requirement in zip(self.pivot_blocks, requirements): - for column in block: - fraction = Fraction(max(requirement, 0), len(block)) - largest_fractions[column] = max(fraction, largest_fractions.get(column, fraction)) - reachable = math.ceil(sum(largest_fractions.values(), Fraction(0))) - reachable += (-reachable) % self.weight_divisor - return ( - reachable >= upper_bound and self.step_lower_bound(weight, num_completed) >= upper_bound - ) - - def plan(self, limit: int, layer: int) -> int: - """Find where a search of layer ``layer`` stops above a limit. - - The search stops at its stopping step: the first step of the layer whose - ``step_lower_bound`` exceeds ``limit``, or the last step, ``(layer, len(ranks))``, if none - does. Returns the number of sets that have completed the layer at that step. Without - linear programs, ``find_stopping_step`` finds the step. A certificate that solves linear - programs checks the steps in order with ``certifies``, except the last step, which stops the - search anyway. So it solves a program only for a step before the stopping step or at it, - never for the last step, and only when the coverage bound of the step does not exceed the - limit. - """ - num_sets = len(self.ranks) - if not self._solves_linear_programs: - _, num_completed = find_stopping_step(self._step_lower_bounds, limit, layer, layer) - return num_completed - for num_completed in range(1, num_sets): - if self.certifies(layer, num_completed, limit + 1): - return num_completed - return num_sets - - def get_num_sets_to_certify(self, weight: int, upper_bound: int) -> int: - """The fewest leading sets whose completion of layer ``weight`` certifies a bound. - - Once these sets have completed layer ``weight``, and the others layer ``weight - 1``, every - unvisited eligible word weighs at least ``upper_bound``. Returns the number of sets if no - fewer sets suffice, in which case the bound may need later layers too. The number is that - of the stopping step of ``plan`` in the layer for the limit ``upper_bound - 1``. Without - linear programs, ``find_stopping_step`` finds that step, so the distance and coset searches - find their stopping steps by one shared rule. - """ - return self.plan(upper_bound - 1, weight) - - def work(self, upper_bound: int) -> int: - """Estimate the candidate/set evaluations needed to certify ``upper_bound``. - - This is the work of ``plan_steps`` for every layer from one, every set, and the limit - ``upper_bound - 1``, on the table of the coverage bound. So the estimate solves no linear - programs. It is pessimistic: it includes systematic rows (coefficient weight one), and it - assumes no lighter witness is found before the certificate reaches the upper bound. - """ - ranks, *_ = self._as_array_arguments() - binomials = get_capped_binomials(self.dimension) - _, _, work = plan_steps( - self._step_lower_bounds, upper_bound - 1, 1, self.dimension, ranks, binomials, False - ) - return work - - def get_weight_bounds( - self, weight_factor: int, parity: int | None = None - ) -> npt.NDArray[np.int64]: - """Coverage bounds on the weight of unvisited eligible words after each step. - - Every word must weigh at least its Hamming weight divided by ``weight_factor``. Entry - ``[w, j]`` is the entry of the table of ``get_step_lower_bounds`` for this certificate, - divided by ``weight_factor`` and rounded up, except that the entries ``[dimension, j]`` for - ``j >= 1`` stay ``NO_WORD_BOUND``, since no word remains unvisited. Given ``parity``, zero - or one, which the Hamming weight of every eligible word must have, the entries of that table - are first rounded up to that parity: each becomes the smallest integer of that parity that - is at least the entry. The bounds do not decrease in the order of the steps. Column zero - of row zero, which precedes every step, stays zero, and column zero of each later row - repeats the last column of the row before. Each call returns a new table. - """ - bounds = self._step_lower_bounds - if parity is not None: - # Round each finite entry up to the parity. The entry before the first step bounds no - # step, and stays zero so that every nonnegative maximum weight passes it, as a check of - # a cached stopping step requires (see ``_get_stopping_step`` of a coset search). - bounds = bounds + (bounds - parity) % 2 * (bounds < NO_WORD_BOUND) - bounds[0, 0] = 0 - - # Round up by negated floor division, which cannot overflow. Division shrinks the entries - # that mean that no word remains, so they are reset. - bounds = -(-bounds // weight_factor) - bounds[self.dimension, 1:] = NO_WORD_BOUND - return bounds - - @property - def _solves_linear_programs(self) -> bool: - """Whether the certificate also bounds unvisited words by linear programs. - - It does so only with pivot blocks and a maximum coverage above one. With a coverage of - one, the blocks are disjoint, the linear program separates by block, and its optimum is the - coverage bound. - """ - return bool(self.pivot_blocks) and self.max_coverage != 1 - - def _as_array_arguments(self) -> tuple[npt.NDArray[np.int64], int, int]: - """The ranks, weight divisor, and maximum coverage that the array functions take.""" - return np.array(self.ranks, dtype=np.int64), self.weight_divisor, self.max_coverage - - @functools.cached_property - def _step_lower_bounds(self) -> npt.NDArray[np.int64]: - """The table of ``get_step_lower_bounds`` for this certificate.""" - return get_step_lower_bounds(self.dimension, *self._as_array_arguments()) - - def _get_requirements(self, weight: int, num_completed: int) -> list[int]: - """The fewest ones that an unvisited word has in each block after the given step.""" - return [ - (weight if index < num_completed else weight - 1) + 1 - (self.dimension - rank) - for index, rank in enumerate(self.ranks) - ] - - @functools.cached_property - def _lp_lower_bounds(self) -> dict[tuple[int, int], int]: - """The rounded bounds of ``get_lp_lower_bound`` for the steps computed so far.""" - return {} - - class BasisFamily(NamedTuple): """The information sets of one binary basis that a search uses together, stacked into arrays. @@ -797,265 +593,7 @@ def _get_disjoint_sets_for_columns( #################################################################################################### -# certificates - - -def get_lp_lower_bound( - pivot_blocks: Sequence[Sequence[int]], requirements: Sequence[int], max_denominator: int -) -> int: - """Bound the weight of binary words with given numbers of ones in blocks, by linear programming. - - Let ``B_i = pivot_blocks[i]`` and ``b_i = requirements[i]``. Every binary word with at least - ``b_i`` ones in the columns of ``B_i``, for each ``i``, has weight at least the optimum of the - linear program - - minimize ``sum_c x_c`` subject to ``sum_{c in B_i} x_c >= b_i`` and ``0 <= x_c <= 1``. - - By weak duality, for every ``y >= 0``, such a word has weight at least the dual value - - ``sum_i b_i y_i - sum_c max(0, sum_{i : c in B_i} y_i - 1)``. - - The solver of ``scipy.optimize.linprog`` finds a nearly optimal ``y``, and the bound is the - ceiling of its dual value in exact rational arithmetic, so rounding in the solver can only - weaken the bound, never invalidate it. The coverage bound is the dual value of ``y_i = 1/C`` - for the maximum coverage ``C``, so the optimum is at least that bound. - - Returns ``NO_WORD_BOUND`` if a requirement exceeds the size of its block, since then no word - meets the requirements, and zero if no requirement is positive or if the solver fails. - """ - blocks = [ - list(block) for block, requirement in zip(pivot_blocks, requirements) if requirement > 0 - ] - positive = [requirement for requirement in requirements if requirement > 0] - if not blocks: - return 0 - if any(requirement > len(block) for block, requirement in zip(blocks, positive)): - return NO_WORD_BOUND - - # Import the solver only here, since importing qLDPC does not otherwise load it. - import scipy.optimize - - # Number the columns of the blocks, and solve the linear program in floating point. - columns = sorted({column for block in blocks for column in block}) - index = {column: position for position, column in enumerate(columns)} - incidence = np.zeros((len(blocks), len(columns))) - for row, block in enumerate(blocks): - incidence[row, [index[column] for column in block]] = 1 - result = scipy.optimize.linprog( - np.ones(len(columns)), - A_ub=-incidence, - b_ub=-np.array(positive, dtype=float), - bounds=(0, 1), - method="highs", - ) - if result.status != 0: - return 0 - - # Evaluate the dual value exactly. The solver gives the dual of each constraint - # ``-sum x >= -b`` as a nonpositive marginal. - weights = [ - max( - Fraction(0), - Fraction(-float(marginal)).limit_denominator(max_denominator), - ) - for marginal in result.ineqlin.marginals - ] - value = sum( - (requirement * weight for requirement, weight in zip(positive, weights)), Fraction(0) - ) - for position in range(len(columns)): - load = sum( - (weight for row, weight in enumerate(weights) if incidence[row, position]), Fraction(0) - ) - value -= max(Fraction(0), load - 1) - return max(0, math.ceil(value)) - - -@functools.lru_cache(maxsize=_tuning.BINOMIAL_CACHE_SIZE) -def get_capped_binomials(dimension: int) -> npt.NDArray[np.int64]: - """The binomial coefficients ``C(dimension, weight)`` for every weight, capped to fit. - - The array is shared between calls, so it is read-only. - """ - binomials = np.array( - [min(math.comb(dimension, weight), _BINOMIAL_CAP) for weight in range(dimension + 1)], - dtype=np.int64, - ) - binomials.flags.writeable = False - return binomials - - -@always_compiled -def lower_bound_from_occupancy(occupancy: int, weight_divisor: int, max_coverage: int) -> int: - """Turn the pivot occupancy of a certificate into a bound on the weight of unvisited words.""" - # Correct for repeated Hamming coordinates by dividing occupancy and rounding up. - bound = (occupancy + max_coverage - 1) // max_coverage - if weight_divisor > 1: - # Every eligible Hamming weight is a multiple of weight_divisor, so the bound rounds up. - bound += (-bound) % weight_divisor - return bound - - -@always_compiled -def get_step_lower_bounds( - dimension: int, - ranks: npt.NDArray[np.int64], - weight_divisor: int, - max_coverage: int, -) -> npt.NDArray[np.int64]: - """Certificate lower bounds after each step of a search, which completes one layer of one set. - - Entry ``[weight, num_completed]`` holds once the first ``num_completed`` sets, in family order, - have completed layer ``weight``, and the others have completed layer ``weight - 1``, in whatever - order their words were visited. So column ``len(ranks)`` holds once every set has completed - layer ``weight``, and column zero of row ``weight`` repeats column ``len(ranks)`` of row - ``weight - 1``. Rank deficiency limits how many selected rows can have zero pivot support: a - set with deficiency ``s`` that has completed layer ``c`` adds ``c + 1 - s`` to the occupancy if - positive, so it adds one more for each layer that it completes once its deficiency no longer - keeps its contribution at zero. Entries ``[dimension, j]`` for ``j >= 1`` hold - ``NO_WORD_BOUND``, since a set that has completed layer ``dimension`` has visited every word. - """ - num_sets = len(ranks) - bounds = np.empty((dimension + 1, num_sets + 1), dtype=np.int64) - for weight in range(dimension + 1): - # The occupancy once every set has completed layer ``weight - 1``. - occupancy = 0 - for rank in ranks: - occupancy += max(0, weight - (dimension - rank)) - bounds[weight, 0] = lower_bound_from_occupancy(occupancy, weight_divisor, max_coverage) - - # Completing layer ``weight`` adds one for each set whose deficiency it overcomes. - for index in range(num_sets): - if weight + 1 > dimension - ranks[index]: - occupancy += 1 - bounds[weight, index + 1] = lower_bound_from_occupancy( - occupancy, weight_divisor, max_coverage - ) - bounds[dimension, 1:] = NO_WORD_BOUND - return bounds - - -@always_compiled -def plan_steps( - step_bounds: npt.NDArray[np.int64], - limit: int, - first_layer: int, - last_layer: int, - ranks: npt.NDArray[np.int64], - binomials: npt.NDArray[np.int64], - skip_unused: bool, -) -> tuple[int, int, int]: - """Plan a search of layers ``first_layer`` to ``last_layer`` that stops above a limit. - - The work estimates of the distance search and of the routes of the coset searches add up the - work of the steps of a search with this function, and searches that need only where they stop - call ``find_stopping_step``. The table ``step_bounds`` has a row for each layer from zero to - the dimension, and a column for each set after column zero. The steps of layer ``w`` are - ``(w, 1)`` to ``(w, num_sets)``, in that order and layer by layer, and the bound of step - ``(w, j)`` is ``step_bounds[w, j]``, as in the table of ``get_step_lower_bounds``. These bounds - must not decrease in the order of the steps, which holds for that table and for any - nondecreasing function of it, such as the tables of ``Certificate.get_weight_bounds``. - - The stopping step ``(w*, j*)`` is the first step of the layers whose bound exceeds ``limit``, - or ``(last_layer, num_sets)`` if there is none, which ``find_stopping_step`` finds. At that - step, set ``i`` has completed layer ``w_i = w*`` if ``i < j*``, and ``w* - 1`` otherwise. A set - of rank deficiency ``s`` adds to the bound of the step only if ``w_i >= s`` (see - ``get_step_lower_bounds``). The other sets are the unused sets of the step. If ``skip_unused`` - is True, the plan leaves them out, as a coset search skips them, and reads the ranks of the sets - from ``ranks``. Otherwise, it reads no ranks. - - The work adds ``binomials[w]`` candidate/set evaluations for each set that the plan keeps and - each layer ``w`` from ``first_layer`` to ``w_i``. Empty ``binomials`` give zero work. The work - saturates at ``_WORK_CAP``: it is the cap once a layer up to ``w*`` has a binomial above - ``_WORK_CAP // num_sets``, or once the sum reaches the cap after a layer. With the binomials of - ``get_capped_binomials``, a plan from layer one for the limit ``U - 1`` gives the work estimate - of a distance search (Section 3.6 of ``brouwer_zimmermann.md``). A plan from layer zero for a - maximum weight ``max_weight`` that skips unused sets gives the number of words that a coset - search weighs, or skips by completion tables (Section 3.9 of ``brouwer_zimmermann.md``), after - the word of its syndrome, if no word of the coset weighs at most ``max_weight``. - - Returns: - The stopping step ``(w*, j*)``, and the work. - """ - layer, num_completed = find_stopping_step(step_bounds, limit, first_layer, last_layer) - work = _get_steps_work( - step_bounds.shape[0] - 1, - step_bounds.shape[1] - 1, - ranks, - layer, - num_completed, - first_layer, - binomials, - skip_unused, - ) - return layer, num_completed, work - - -@always_compiled -def find_stopping_step( - step_bounds: npt.NDArray[np.int64], limit: int, first_layer: int, last_layer: int -) -> tuple[int, int]: - """Find the stopping step of a search of layers ``first_layer`` to ``last_layer``. - - The steps and their bounds are those of ``plan_steps``, and so is the stopping step: the first - step of the layers whose bound exceeds ``limit``, or ``(last_layer, num_sets)`` if there is - none. The bounds do not decrease in the order of the steps, so a binary search finds it. The - coset searches find where they stop with this function, and so does the distance search, - through ``Certificate.plan``, when its certificate solves no linear programs. - """ - num_sets = step_bounds.shape[1] - 1 - low, high = 0, (last_layer - first_layer + 1) * num_sets - while low < high: - middle = (low + high) // 2 - if step_bounds[first_layer + middle // num_sets, 1 + middle % num_sets] > limit: - high = middle - else: - low = middle + 1 - if low == (last_layer - first_layer + 1) * num_sets: - return last_layer, num_sets - return first_layer + low // num_sets, 1 + low % num_sets - - -@always_compiled(inline=True) -def get_completed_layer(set_index: int, layer: int, num_completed: int) -> int: - """The last layer that set ``set_index`` has completed at step ``(layer, num_completed)``.""" - return layer if set_index < num_completed else layer - 1 - - -@always_compiled(inline=True) -def is_unused_set( - pivots: npt.NDArray[np.int64], set_index: int, layer: int, num_completed: int -) -> bool: - """Whether a set is an unused set of step ``(layer, num_completed)`` (see ``plan_steps``). - - ``pivots`` holds the pivot columns of each set of ``num_rows = pivots.shape[1]`` rows, padded - with -1 beyond its rank. A set is unused if its rank deficiency exceeds the last layer that it - has completed, which must be at least zero. This holds exactly if its rank is at most - ``position = num_rows - completed_layer - 1``, that is, if its pivot at ``position`` is padding. - ``position`` is negative only if the set has completed layer ``num_rows``, and then it is used. - """ - position = pivots.shape[1] - get_completed_layer(set_index, layer, num_completed) - 1 - return position >= 0 and pivots[set_index, position] < 0 - - -@always_compiled -def estimate_certificate_work( - dimension: int, - ranks: npt.NDArray[np.int64], - weight_divisor: int, - max_coverage: int, - upper_bound: int, - binomials: npt.NDArray[np.int64], -) -> int: - """Estimate the candidate/set evaluations needed to certify ``upper_bound``. - - This is ``Certificate.work`` for arrays, which compiled code can pass. It is the work of - ``plan_steps`` for every layer from one, every set, and the limit ``upper_bound - 1``. - ``binomials`` are ``get_capped_binomials(dimension)``. - """ - bounds = get_step_lower_bounds(dimension, ranks, weight_divisor, max_coverage) - _, _, work = plan_steps(bounds, upper_bound - 1, 1, dimension, ranks, binomials, False) - return work +# certificate estimates of the stages @always_compiled @@ -1113,87 +651,3 @@ def _estimate_optimistic_work( ) optimistic_work = min(optimistic_work, work) return optimistic_work - - -@always_compiled -def _get_steps_work( - dimension: int, - num_sets: int, - ranks: npt.NDArray[np.int64], - layer: int, - num_completed: int, - first_layer: int, - binomials: npt.NDArray[np.int64], - skip_unused: bool, -) -> int: - """The work of ``plan_steps`` for its stopping step ``(layer, num_completed)``.""" - if len(binomials) == 0: - return 0 - work = 0 - for weight in range(first_layer, layer + 1): - # Saturate before adding, so that no sum can overflow: a layer adds at most the cap. - if binomials[weight] > _WORK_CAP // max(num_sets, 1): - return _WORK_CAP - for index in range(num_sets): - # Skip the layers that the set has not completed, and the sets that the plan leaves out. - completed_layer = get_completed_layer(index, layer, num_completed) - if weight > completed_layer or ( - skip_unused and completed_layer < dimension - ranks[index] - ): - continue - work += binomials[weight] - if work >= _WORK_CAP: - return _WORK_CAP - return work - - -#################################################################################################### -# coefficient layers - - -@always_compiled(inline=True) -def advance_support( - rows: npt.NDArray[np.uint64], - widths: npt.NDArray[np.int64], - support: npt.NDArray[np.int64], - prefixes: npt.NDArray[np.uint64], - depth: int, -) -> int: - """Move to the next support of a layer of the search. - - Layer ``w = len(support) >= 1`` holds, for each information set, the sums of its offset and - ``w`` of its rows. A support lists the indices of such rows in increasing order, and the - supports come in lexicographic order. ``rows`` holds the packed rows of each information set, - and set ``j`` uses the first ``widths[j]`` machine words of each row. The rows chosen so far - are ``support[:depth]``, and ``support[depth]`` is the next row to try at ``depth``. Row - ``level`` of ``prefixes`` holds, for each set, its offset plus its rows ``support[:level]``. - Start with ``depth = 0``, ``support[0] = 0``, and the offsets in ``prefixes[0]``, which this - function does not change. - - Return the last depth, ``len(support) - 1``, once ``support`` is complete. The word of the - support in set ``j`` is then ``prefixes[depth, j] ^ rows[j, support[depth]]``. To continue, - raise ``support[depth]``, by one or past the last row to skip the supports that differ only in - their last row, and call again from that depth. Return -1 once every support has been visited. - """ - num_rows = rows.shape[1] - weight = len(support) - while depth >= 0: - # Backtrack once the row at this depth leaves too few rows to complete the support. - if support[depth] > num_rows - weight + depth: - depth -= 1 - if depth >= 0: - support[depth] += 1 - continue - if depth + 1 == weight: - return depth - - # Add the row at this depth to the prefixes, and try the next rows at the next depth. - row = support[depth] - for set_index in range(len(widths)): - for word in range(widths[set_index]): - prefixes[depth + 1, set_index, word] = ( - prefixes[depth, set_index, word] ^ rows[set_index, row, word] - ) - depth += 1 - support[depth] = row + 1 - return depth diff --git a/src/qldpc/codes/distance/_information_sets/basis_families_test.py b/src/qldpc/codes/distance/_information_sets/basis_families_test.py new file mode 100644 index 000000000..ad4e84cab --- /dev/null +++ b/src/qldpc/codes/distance/_information_sets/basis_families_test.py @@ -0,0 +1,390 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the basis families and certificates shared by BZ distance and counting.""" + +from __future__ import annotations + +import dataclasses +import functools +import itertools +from typing import Any + +import numpy as np +import numpy.typing as npt +import pytest + +import qldpc +from qldpc import _compilation +from qldpc._binary import get_independent_rows, row_reduce_binary + +from .._tuning import DEFAULT_TUNING, SearchTuning +from ..conftest import get_random_full_rank_matrix +from . import basis_families + +# The default numbers of column orders of disjoint families, and denominators of LP dual weights. +ORDERS = DEFAULT_TUNING.disjoint_random_column_orders +DENOMINATOR = DEFAULT_TUNING.lp_dual_max_denominator + +# Two disjoint full blocks and a rank-two remainder. +PARTIAL_BASIS = np.hstack( + [ + np.eye(3, dtype=np.uint8), + np.eye(3, dtype=np.uint8), + np.array([[1, 0], [0, 1], [0, 0]], dtype=np.uint8), + ] +) +EVEN_BASIS = np.array([[1, 1, 0, 0], [0, 1, 1, 0], [1, 0, 0, 1]], dtype=np.uint8) + + +def test_information_set_packing() -> None: + """Information sets pack full blocks first and retain the residual rank.""" + get_family = functools.partial(basis_families.get_disjoint_family, random_column_orders=ORDERS) + assert get_family(PARTIAL_BASIS, np.eye(3, dtype=np.uint8)).ranks.tolist() == [3, 3, 2] + assert not len(get_family(np.zeros((1, 3), dtype=np.uint8), None).ranks) + + basis = np.array( + [ + [1, 0, 1, 1, 0, 1], + [0, 1, 0, 1, 1, 0], + [1, 0, 0, 0, 1, 1], + ], + dtype=np.uint8, + ) + assert get_family(basis, None).ranks.tolist() == [3, 3] + + # Only one column carries the second row, so no column order packs a second full set. + basis = np.array([[1, 1, 1, 0], [0, 0, 0, 1]], dtype=np.uint8) + assert get_family(basis, None).ranks.tolist() == [2, 1] + + +def test_a_basis_family_stacks_its_sets() -> None: + """The sets of a basis family are reduced on their pivots, which are padded for partial sets.""" + labels = np.eye(3, dtype=np.uint8) + basis_family = basis_families.get_disjoint_family( + PARTIAL_BASIS, labels, random_column_orders=ORDERS + ) + assert basis_family.generators.shape == (3, 3, PARTIAL_BASIS.shape[1]) + assert basis_family.labels.shape == (3, 3, 3) + assert basis_family.certificate.dimension == 3 + for generators, set_labels, pivots, rank in zip(*basis_family[:4], strict=True): + # The labels record the row operations that reduce the basis. + np.testing.assert_array_equal(generators, set_labels @ PARTIAL_BASIS % 2) + np.testing.assert_array_equal(generators[:rank, pivots[:rank]], np.eye(rank)) + assert np.all(pivots[rank:] == -1) + no_labels = basis_families.get_disjoint_family(PARTIAL_BASIS, None, random_column_orders=ORDERS) + assert no_labels.labels.shape == (3, 3, 0) + + +@pytest.mark.parametrize("basis", [PARTIAL_BASIS, EVEN_BASIS]) +def test_family_certificates(basis: npt.NDArray[np.uint8]) -> None: + """Rank-deficient blocks and inferred parity give certificates valid for every word.""" + # A target above every weight keeps the rank-deficient block. + basis_family = basis_families.get_disjoint_family(basis, None, random_column_orders=ORDERS) + basis_family = basis_families.prune_family(basis_family, basis.shape[1] + 1) + _assert_certificate_bounds_unvisited_words(basis, basis_family) + + +def test_weight_divisor_inference() -> None: + """Even rows span only even weights, and a divisor proven by the caller is retained.""" + odd = np.array([[1, 0, 0, 0], [0, 1, 1, 0]], dtype=np.uint8) + get_family = functools.partial(basis_families.get_disjoint_family, random_column_orders=ORDERS) + assert get_family(EVEN_BASIS, None).certificate.weight_divisor == 2 + assert get_family(odd, None).certificate.weight_divisor == 1 + assert get_family(odd, None, weight_divisor=2).certificate.weight_divisor == 2 + + +@pytest.mark.parametrize( + "ranks, upper_bound, retained", + [([10, 6, 2], 6, [10, 6]), ([10, 6, 2], 12, [10, 6, 2]), ([6, 2], 6, [6, 2])], +) +def test_prune_family(ranks: list[int], upper_bound: int, retained: list[int]) -> None: + """Pruning keeps full sets, and the partial sets that can help certify the target. + + A set of ten rows with rank deficiency ``d`` adds to the bound only once layer ``d`` is + complete. The full set alone certifies 6 once layer 5 is complete, so the set of deficiency 8 + cannot help with 6, but no layer lets the full set alone certify 12. Without a full set, every + set stays. + """ + no_columns = np.empty((len(ranks), 10, 0), dtype=np.uint8) + pivots = np.full((len(ranks), 10), -1, dtype=np.int64) + basis_family = basis_families.BasisFamily( + no_columns, no_columns, pivots, np.array(ranks, dtype=np.int64), 1, 1 + ) + pruned = basis_families.prune_family(basis_family, upper_bound) + assert pruned.ranks.tolist() == retained + assert pruned.certificate.work(upper_bound) <= basis_family.certificate.work(upper_bound) + + +def test_overlapping_family_certificates() -> None: + """Check pivot coverage and label transformations against exhaustive codeword weights.""" + + # Label each row separately so its row operations can be checked. + basis = np.asarray(qldpc.codes.HammingCode(4).generator, dtype=np.uint8) + basis = get_independent_rows(basis) + labels = np.eye(len(basis), dtype=np.uint8) + disjoint = basis_families.get_disjoint_family(basis, labels, random_column_orders=ORDERS) + # One full set certifies a target of one after its first layer, which no replacement improves. + assert not len(_get_overlapping_family(basis, labels, disjoint, 1, minimum_work=0).ranks) + basis_family = _get_overlapping_family(basis, labels, disjoint, 3, minimum_work=0) + assert np.all(basis_family.ranks == len(basis)) + loads = np.bincount(basis_family.pivots.ravel(), minlength=basis.shape[1]) + assert basis_family.max_coverage >= loads.max() > 1 + for generators, set_labels, pivots in zip(*basis_family[:3], strict=True): + np.testing.assert_array_equal(generators, set_labels @ basis % 2) + np.testing.assert_array_equal(generators[:, pivots], np.eye(len(basis))) + _assert_certificate_bounds_unvisited_words(basis, basis_family) + + +def test_a_cheap_search_keeps_its_disjoint_basis_family() -> None: + """No overlapping basis family is built if the work of the disjoint one is below the minimum.""" + basis = get_independent_rows(np.asarray(qldpc.codes.HammingCode(4).generator)) + labels = np.eye(len(basis), dtype=np.uint8) + disjoint = basis_families.get_disjoint_family(basis, labels, random_column_orders=ORDERS) + assert not len(_get_overlapping_family(basis, labels, disjoint, 3).ranks) + assert len(_get_overlapping_family(basis, labels, disjoint, 3, minimum_work=0).ranks) + # A limit of zero sets builds no family at all. + assert not len(_get_overlapping_family(basis, labels, disjoint, 3, 0, minimum_work=0).ranks) + + +def test_overlapping_family_guards() -> None: + """Reject unhelpful coverage and terminate repeated attempts at the same pivot set.""" + + # Complete or nearly complete disjoint coverage leaves no cheaper family within the size budget. + basis = np.hstack([np.eye(25, dtype=np.uint8)] * 4) + complete = basis_families.get_disjoint_family(basis, None, random_column_orders=ORDERS) + assert not len(_get_overlapping_family(basis, None, complete, 26).ranks) + shortened = basis[:, :-1] + disjoint = basis_families.get_disjoint_family(shortened, None, random_column_orders=ORDERS) + assert not len(_get_overlapping_family(shortened, None, disjoint, 26).ranks) + + # A zero basis has no information sets to replace. + zeros = np.zeros((1, 3), dtype=np.uint8) + empty = basis_families.get_disjoint_family(zeros, None, random_column_orders=ORDERS) + assert not len(empty.ranks) + assert not len(_get_overlapping_family(zeros, None, empty, 2).ranks) + + # An information set holds one of the third to fifth columns, which are equal, and two of the + # first, second, and sixth, so attempts rediscover the few such sets. The search must terminate + # without a replacement that beats the disjoint family. + small = np.array([[1, 0, 0, 0, 0, 1], [0, 1, 0, 0, 0, 1], [0, 0, 1, 1, 1, 0]], dtype=np.uint8) + disjoint = basis_families.get_disjoint_family(small, None, random_column_orders=ORDERS) + assert not len(_get_overlapping_family(small, None, disjoint, 3, minimum_work=0).ranks) + + +def test_partitioned_families_replace_costlier_ones() -> None: + """Matroid partitioning replaces a disjoint family only in costly searches, and only if cheaper. + + This [11, 4] basis has disjoint information sets of ranks four, four, and two, and information + sets that share columns certify the weight five with less work. + """ + rows = ["10001011100", "01000001100", "00100000110", "00010011001"] + basis = np.array([[int(bit) for bit in row] for row in rows], dtype=np.uint8) + labels = np.eye(len(basis), dtype=np.uint8) + disjoint = basis_families.get_disjoint_family(basis, labels, random_column_orders=ORDERS) + assert disjoint.ranks.tolist() == [4, 4, 2] + + def get_family(**changes: int) -> basis_families.BasisFamily: + fields: dict[str, Any] = {"partition_work_ratio": 0, **changes} + tuning = dataclasses.replace(DEFAULT_TUNING, **fields) + return basis_families.get_partitioned_family(basis, labels, disjoint, 5, tuning) + + # A family needs a set to hold, a coverage of two or more, and partitioning that pays. + assert not len(get_family(partition_work_ratio=2**62).ranks) + assert not len(get_family(partition_cost_factor=2**40, partition_work_ratio=1).ranks) + assert len(get_family(partition_cost_factor=0, partition_work_ratio=1).ranks) + assert not len(get_family(max_information_sets=0).ranks) + assert not len(get_family(max_partition_coverage=1).ranks) + family = get_family() + assert family.max_coverage > 1 + assert family.certificate.work(5) < disjoint.certificate.work(5) + for generators, set_labels, pivots in zip(*family[:3], strict=True): + np.testing.assert_array_equal(generators, set_labels @ basis % 2) + np.testing.assert_array_equal(generators[:, pivots], np.eye(len(basis))) + _assert_certificate_bounds_unvisited_words(basis, family) + + +def test_maximum_disjoint_families_replace_smaller_packings() -> None: + """A maximum disjoint family has the most information sets, and then the largest partial set. + + In natural column order, a greedy packing of the columns ``e1, e2, e3, e1 + e2, e1 + e2`` takes + the first three, and leaves a partial set of rank one, but ``e1 + e2, e2, e3`` leave ``e1`` and + ``e1 + e2``, of rank two. Likewise, ``e1, e2, e1 + e2, e1 + e2`` hold two disjoint information + sets, but a greedy packing finds one. The maximum families are disjoint sets, reduced on their + pivots, with valid certificates. No family replaces a packing whose sets use every nonzero + column, or one of a basis whose packings all have the same ranks. + """ + for columns, ranks in (([1, 2, 4, 3, 3], [3, 2]), ([1, 2, 3, 3], [2, 2])): + dimension = max(columns).bit_length() + basis = ((np.array(columns) >> np.arange(dimension)[:, None]) & 1).astype(np.uint8) + disjoint = basis_families.get_disjoint_family(basis, None, random_column_orders=0) + assert disjoint.ranks.tolist() == [dimension, 1] + family = basis_families.get_maximum_disjoint_family(basis, disjoint) + assert family.ranks.tolist() == ranks + pivots = family.pivots[family.pivots >= 0] + assert len(set(pivots.tolist())) == len(pivots) + for generators, set_pivots, rank in zip(family.generators, family.pivots, family.ranks): + np.testing.assert_array_equal( + generators[:, set_pivots[:rank]], np.eye(dimension)[:, :rank] + ) + _assert_certificate_bounds_unvisited_words(basis, family) + + one_full_set = np.array([[1, 1, 1, 0], [0, 0, 0, 1]], dtype=np.uint8) + for basis in (PARTIAL_BASIS, one_full_set): + disjoint = basis_families.get_disjoint_family(basis, None, random_column_orders=0) + assert not len(basis_families.get_maximum_disjoint_family(basis, disjoint).ranks) + + +def test_linear_programming_certificates_bound_unvisited_words(pytestconfig: pytest.Config) -> None: + """Families of random information sets, with uneven coverage, have valid certificates.""" + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + for _ in range(20): + dimension = int(rng.integers(3, 6)) + basis = get_random_full_rank_matrix( + rng, dimension, int(rng.integers(dimension + 2, 3 * dimension)) + ) + num_sets = int(rng.integers(3, 6)) + sets = [ + row_reduce_binary(basis, None, columns=rng.permutation(basis.shape[1])) + for _ in range(num_sets) + ] + pivots = np.array([found for _, _, found in sets], dtype=np.int64) + family = basis_families.BasisFamily( + np.array([reduced for reduced, _, _ in sets]), + np.zeros((num_sets, dimension, 0), dtype=np.uint8), + pivots, + np.full(num_sets, dimension, dtype=np.int64), + basis_families.infer_weight_divisor(basis), + int(np.bincount(pivots.ravel()).max()), + ) + _assert_certificate_bounds_unvisited_words(basis, family) + + +def test_searches_of_overlapping_sets_use_linear_programming_when_costly() -> None: + """Only a costly search of overlapping sets gets the bound of linear programming.""" + basis = np.asarray(qldpc.codes.HammingCode(4).generator, dtype=np.uint8) + disjoint = basis_families.get_disjoint_family(basis, None, random_column_orders=ORDERS) + overlapping = _get_overlapping_family(basis, None, disjoint, 3, minimum_work=0) + assert overlapping.max_coverage > 1 + assert not overlapping.get_search_certificate(3, 2**62, DENOMINATOR).pivot_blocks + assert len(overlapping.get_search_certificate(3, 0, DENOMINATOR).pivot_blocks) == len( + overlapping.ranks + ) + assert not disjoint.get_search_certificate(3, 0, DENOMINATOR).pivot_blocks + + +@pytest.mark.parametrize("max_sets, num_sets", [(1, 0), (3, 0), (4, 4), (16, 4)]) +def test_the_size_limit_bounds_the_overlapping_basis_family(max_sets: int, num_sets: int) -> None: + """A cheaper basis family of overlapping sets needs four sets, so a lower limit finds none.""" + basis = get_independent_rows(np.asarray(qldpc.codes.HammingCode(4).generator)) + labels = np.eye(len(basis), dtype=np.uint8) + disjoint = basis_families.get_disjoint_family(basis, labels, random_column_orders=ORDERS) + overlapping = _get_overlapping_family(basis, labels, disjoint, 3, max_sets, minimum_work=0) + assert len(overlapping.ranks) == num_sets + + +@pytest.mark.usefixtures("plain_search_setup") +def test_functions_agree_in_plain_python_and_compiled(pytestconfig: pytest.Config) -> None: + """Pruned and replacement basis families are identical, bit for bit, plain and compiled.""" + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + # The Hamming code has an overlapping basis family that does less work. Random bases rarely do. + hamming = get_independent_rows(np.asarray(qldpc.codes.HammingCode(4).generator)) + cases: list[tuple[npt.NDArray[np.uint8], npt.NDArray[np.uint8] | None, int]] = [ + (hamming, np.eye(len(hamming), dtype=np.uint8), 3) + ] + for trial in range(12): + rows = int(rng.integers(3, 10)) + basis = get_random_full_rank_matrix(rng, rows, int(rng.integers(2 * rows, 5 * rows))) + labels = np.eye(rows, dtype=np.uint8) if trial % 2 else None + cases.append((basis, labels, int(rng.integers(2, rows + 2)))) + for case, (basis, labels, upper_bound) in enumerate(cases): + results = [] + for compiled in (False, True): + _compilation.set_on_request_functions_compiled(compiled) + try: + disjoint = basis_families.get_disjoint_family( + basis, labels, random_column_orders=ORDERS + ) + pruned = basis_families.prune_family(disjoint, upper_bound) + replacement = _get_overlapping_family( + basis, labels, pruned, upper_bound, minimum_work=0 + ) + finally: + _compilation.set_on_request_functions_compiled(False) + results.append((pruned, replacement)) + assert case or len(results[0][1].ranks) + for plain, compiled_result in zip(*results, strict=True): + for field, value in zip(plain._fields, plain, strict=True): + np.testing.assert_array_equal(value, getattr(compiled_result, field), field) + + +def _get_overlapping_family( + basis: npt.NDArray[np.uint8], + labels: npt.NDArray[np.uint8] | None, + basis_family: basis_families.BasisFamily, + upper_bound: int, + max_sets: int = DEFAULT_TUNING.max_information_sets, + *, + minimum_work: int = DEFAULT_TUNING.overlapping_minimum_work, +) -> basis_families.BasisFamily: + """Stage three, as a search whose tuning has these thresholds calls it.""" + tuning = SearchTuning(max_information_sets=max_sets, overlapping_minimum_work=minimum_work) + return basis_families.get_overlapping_family( + basis, + np.empty((len(basis), 0), dtype=np.uint8) if labels is None else labels, + basis_family, + upper_bound, + basis_families.get_overlapping_family_policy(tuning, len(basis)), + ) + + +def _assert_certificate_bounds_unvisited_words( + basis: npt.NDArray[np.uint8], family: basis_families.BasisFamily +) -> None: + """Check a basis family's certificates against every word that its search bases span. + + A search basis visits a word after layer ``t`` if the word combines at most ``t`` of its rows. + After the first ``j`` search bases have completed layer ``t`` and the others layer ``t - 1``, + every word that none of them has visited must weigh at least the certificate of that step, with + or without the bound of linear programming. + """ + + def get_coefficient_weights(generators: npt.NDArray[np.uint8]) -> dict[bytes, int]: + return { + (np.asarray(bits) @ generators % 2).astype(np.uint8).tobytes(): sum(bits) + for bits in itertools.product((0, 1), repeat=len(generators)) + } + + words = get_coefficient_weights(basis) + coefficient_weights = [get_coefficient_weights(generators) for generators in family.generators] + certificate = family.certificate + pivot_blocks = tuple( + tuple(int(column) for column in pivots[:rank]) + for pivots, rank in zip(family.pivots, family.ranks, strict=True) + ) + certificates = [ + certificate, + dataclasses.replace(certificate, pivot_blocks=pivot_blocks, lp_max_denominator=DENOMINATOR), + ] + # Every search basis spans the same space as the original basis. + assert all(weights.keys() == words.keys() for weights in coefficient_weights) + weights = {word: np.count_nonzero(np.frombuffer(word, dtype=np.uint8)) for word in words} + steps = [ + (layer, num_completed) + for layer in range(1, len(basis)) + for num_completed in range(len(family.ranks) + 1) + ] + for word, weight in weights.items(): + assert weight % certificate.weight_divisor == 0 + for layer, num_completed in steps: + completed = [ + layer if index < num_completed else layer - 1 for index in range(len(family.ranks)) + ] + if all(item[word] > done for item, done in zip(coefficient_weights, completed)): + for each in certificates: + assert weight >= each.step_lower_bound(layer, num_completed) + + # Deciding whether a step certifies a bound agrees with the bound of that step. + for each, (layer, num_completed) in itertools.product(certificates, steps): + bound = each.step_lower_bound(layer, num_completed) + for upper_bound in range(1, basis.shape[1] + 2): + assert each.certifies(layer, num_completed, upper_bound) == (bound >= upper_bound) diff --git a/src/qldpc/codes/distance/_information_sets/certificates.py b/src/qldpc/codes/distance/_information_sets/certificates.py new file mode 100644 index 000000000..e5bc0df38 --- /dev/null +++ b/src/qldpc/codes/distance/_information_sets/certificates.py @@ -0,0 +1,520 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Certificates of the basis families of exact searches, and the plans of their steps. + +A certificate bounds the weights of the words that a search of a basis family has not visited, from +the layers that its sets have completed. It tells a search when it can stop, and a plan of steps +says where a search stops for a weight limit, and the work of the steps up to there. +""" + +from __future__ import annotations + +import functools +import math +from collections.abc import Sequence +from dataclasses import dataclass +from fractions import Fraction + +import numpy as np +import numpy.typing as npt + +from qldpc._compilation import always_compiled + +from .. import _tuning + +# A bound on the weight of unvisited words when no word can remain unvisited. +NO_WORD_BOUND = np.iinfo(np.int64).max + +# Caps that keep work estimates within a native integer. A binomial above _BINOMIAL_CAP counts as +# the cap, and a sum saturates at _WORK_CAP, so an estimate is exact only if it reaches neither cap. +# Searches that reach a cap are infeasible, so their order, against other basis families or +# exhaustive enumeration, does not matter. +_BINOMIAL_CAP = 1 << 50 +_WORK_CAP = 1 << 61 + + +@dataclass(frozen=True) +class Certificate: + """Lower bound on the Hamming weight of eligible words that a family has not yet visited. + + ``lower_bound`` holds after every search basis of the family has completed each coefficient + layer through ``completed_weight``. ``step_lower_bound`` also holds part way through a layer, + once the first sets in family order have completed it, in whatever order their words were + visited. A set that is interrupted within a layer does not advance either bound. The bounds do + not bound the weights of witnesses already found, and they do not transfer to another basis or + CSS sector. A search that skips supports by completion tables completes a layer without + visiting them, and then the bounds hold only for the eligible words lighter than its best weight + (Section 3.9 of ``brouwer_zimmermann.md``). + + Each bound is the coverage bound: the occupancy of the sets (see ``get_step_lower_bounds``) + divided by ``max_coverage``, rounded up, and then rounded up to a multiple of + ``weight_divisor``. With ``pivot_blocks``, each bound is the larger of the coverage + bound and the bound of ``get_lp_lower_bound``, which can be larger when coverage is uneven. + + Attributes: + dimension: Number of rows of the searched basis. + ranks: Rank of the pivot block of each search basis, in family order. + weight_divisor: A proven divisor of the Hamming weight of every eligible word. It combines + any divisor proven by the caller, such as two for the symplectic-to-Hamming map, with + ambient even parity. It is in the Hamming units of the searched basis, not Pauli units. + max_coverage: An upper bound on the number of pivot blocks that contain any one coordinate. + Overestimates are sound; underestimates are not. + pivot_blocks: The pivot columns of each search basis, in family order, to bound unvisited + words also by linear programming, or no blocks to use only the coverage bound. + lp_max_denominator: The largest denominator of the rational dual weights of the linear + program (see ``get_lp_lower_bound``), which ``pivot_blocks`` needs to be positive. + """ + + dimension: int + ranks: tuple[int, ...] + weight_divisor: int = 1 + max_coverage: int = 1 + pivot_blocks: tuple[tuple[int, ...], ...] = () + lp_max_denominator: int = 0 + + def lower_bound(self, completed_weight: int) -> int: + """Bound the Hamming weight of unvisited eligible words after the given complete layers.""" + return self.step_lower_bound(completed_weight, len(self.ranks)) + + def step_lower_bound(self, weight: int, num_completed: int) -> int: + """Bound the Hamming weight of unvisited eligible words part way through layer ``weight``. + + The first ``num_completed`` sets, in family order, have completed layer ``weight``, and the + others have completed layer ``weight - 1``. + """ + bound = int(self._step_lower_bounds[weight, num_completed]) + if not self._solves_linear_programs: + return bound + step = (weight, num_completed) + if step not in self._lp_lower_bounds: + lp_bound = get_lp_lower_bound( + self.pivot_blocks, self._get_requirements(*step), self.lp_max_denominator + ) + if lp_bound < NO_WORD_BOUND: + lp_bound += (-lp_bound) % self.weight_divisor + self._lp_lower_bounds[step] = lp_bound + return max(bound, self._lp_lower_bounds[step]) + + def certifies(self, weight: int, num_completed: int, upper_bound: int) -> bool: + """Whether ``step_lower_bound(weight, num_completed)`` is at least ``upper_bound``. + + This solves a linear program only if the coverage bound falls short of the upper bound and + the linear program could reach it: its optimum is at most the value of the feasible solution + that sets each pivot column to the largest ``requirement / len(block)`` over the blocks that + contain it. + """ + if self._step_lower_bounds[weight, num_completed] >= upper_bound: + return True + if not self._solves_linear_programs: + return False + # No requirement exceeds its block, since a set that has completed the last layer makes the + # coverage bound certify any upper bound. + requirements = self._get_requirements(weight, num_completed) + largest_fractions: dict[int, Fraction] = {} + for block, requirement in zip(self.pivot_blocks, requirements): + for column in block: + fraction = Fraction(max(requirement, 0), len(block)) + largest_fractions[column] = max(fraction, largest_fractions.get(column, fraction)) + reachable = math.ceil(sum(largest_fractions.values(), Fraction(0))) + reachable += (-reachable) % self.weight_divisor + return ( + reachable >= upper_bound and self.step_lower_bound(weight, num_completed) >= upper_bound + ) + + def plan(self, limit: int, layer: int) -> int: + """Find where a search of layer ``layer`` stops above a limit. + + The search stops at its stopping step: the first step of the layer whose + ``step_lower_bound`` exceeds ``limit``, or the last step, ``(layer, len(ranks))``, if none + does. Returns the number of sets that have completed the layer at that step. Without + linear programs, ``find_stopping_step`` finds the step. A certificate that solves linear + programs checks the steps in order with ``certifies``, except the last step, which stops the + search anyway. So it solves a program only for a step before the stopping step or at it, + never for the last step, and only when the coverage bound of the step does not exceed the + limit. + """ + num_sets = len(self.ranks) + if not self._solves_linear_programs: + _, num_completed = find_stopping_step(self._step_lower_bounds, limit, layer, layer) + return num_completed + for num_completed in range(1, num_sets): + if self.certifies(layer, num_completed, limit + 1): + return num_completed + return num_sets + + def get_num_sets_to_certify(self, weight: int, upper_bound: int) -> int: + """The fewest leading sets whose completion of layer ``weight`` certifies a bound. + + Once these sets have completed layer ``weight``, and the others layer ``weight - 1``, every + unvisited eligible word weighs at least ``upper_bound``. Returns the number of sets if no + fewer sets suffice, in which case the bound may need later layers too. The number is that + of the stopping step of ``plan`` in the layer for the limit ``upper_bound - 1``. Without + linear programs, ``find_stopping_step`` finds that step, so the distance and coset searches + find their stopping steps by one shared rule. + """ + return self.plan(upper_bound - 1, weight) + + def work(self, upper_bound: int) -> int: + """Estimate the candidate/set evaluations needed to certify ``upper_bound``. + + This is the work of ``plan_steps`` for every layer from one, every set, and the limit + ``upper_bound - 1``, on the table of the coverage bound. So the estimate solves no linear + programs. It is pessimistic: it includes systematic rows (coefficient weight one), and it + assumes no lighter witness is found before the certificate reaches the upper bound. + """ + ranks, *_ = self._as_array_arguments() + binomials = get_capped_binomials(self.dimension) + _, _, work = plan_steps( + self._step_lower_bounds, upper_bound - 1, 1, self.dimension, ranks, binomials, False + ) + return work + + def get_weight_bounds( + self, weight_factor: int, parity: int | None = None + ) -> npt.NDArray[np.int64]: + """Coverage bounds on the weight of unvisited eligible words after each step. + + Every word must weigh at least its Hamming weight divided by ``weight_factor``. Entry + ``[w, j]`` is the entry of the table of ``get_step_lower_bounds`` for this certificate, + divided by ``weight_factor`` and rounded up, except that the entries ``[dimension, j]`` for + ``j >= 1`` stay ``NO_WORD_BOUND``, since no word remains unvisited. Given ``parity``, zero + or one, which the Hamming weight of every eligible word must have, the entries of that table + are first rounded up to that parity: each becomes the smallest integer of that parity that + is at least the entry. The bounds do not decrease in the order of the steps. Column zero + of row zero, which precedes every step, stays zero, and column zero of each later row + repeats the last column of the row before. Each call returns a new table. + """ + bounds = self._step_lower_bounds + if parity is not None: + # Round each finite entry up to the parity. The entry before the first step bounds no + # step, and stays zero so that every nonnegative maximum weight passes it, as a check of + # a cached stopping step requires (see ``_get_stopping_step`` of a coset search). + bounds = bounds + (bounds - parity) % 2 * (bounds < NO_WORD_BOUND) + bounds[0, 0] = 0 + + # Round up by negated floor division, which cannot overflow. Division shrinks the entries + # that mean that no word remains, so they are reset. + bounds = -(-bounds // weight_factor) + bounds[self.dimension, 1:] = NO_WORD_BOUND + return bounds + + @property + def _solves_linear_programs(self) -> bool: + """Whether the certificate also bounds unvisited words by linear programs. + + It does so only with pivot blocks and a maximum coverage above one. With a coverage of + one, the blocks are disjoint, the linear program separates by block, and its optimum is the + coverage bound. + """ + return bool(self.pivot_blocks) and self.max_coverage != 1 + + def _as_array_arguments(self) -> tuple[npt.NDArray[np.int64], int, int]: + """The ranks, weight divisor, and maximum coverage that the array functions take.""" + return np.array(self.ranks, dtype=np.int64), self.weight_divisor, self.max_coverage + + @functools.cached_property + def _step_lower_bounds(self) -> npt.NDArray[np.int64]: + """The table of ``get_step_lower_bounds`` for this certificate.""" + return get_step_lower_bounds(self.dimension, *self._as_array_arguments()) + + def _get_requirements(self, weight: int, num_completed: int) -> list[int]: + """The fewest ones that an unvisited word has in each block after the given step.""" + return [ + (weight if index < num_completed else weight - 1) + 1 - (self.dimension - rank) + for index, rank in enumerate(self.ranks) + ] + + @functools.cached_property + def _lp_lower_bounds(self) -> dict[tuple[int, int], int]: + """The rounded bounds of ``get_lp_lower_bound`` for the steps computed so far.""" + return {} + + +def get_lp_lower_bound( + pivot_blocks: Sequence[Sequence[int]], requirements: Sequence[int], max_denominator: int +) -> int: + """Bound the weight of binary words with given numbers of ones in blocks, by linear programming. + + Let ``B_i = pivot_blocks[i]`` and ``b_i = requirements[i]``. Every binary word with at least + ``b_i`` ones in the columns of ``B_i``, for each ``i``, has weight at least the optimum of the + linear program + + minimize ``sum_c x_c`` subject to ``sum_{c in B_i} x_c >= b_i`` and ``0 <= x_c <= 1``. + + By weak duality, for every ``y >= 0``, such a word has weight at least the dual value + + ``sum_i b_i y_i - sum_c max(0, sum_{i : c in B_i} y_i - 1)``. + + The solver of ``scipy.optimize.linprog`` finds a nearly optimal ``y``, and the bound is the + ceiling of its dual value in exact rational arithmetic, so rounding in the solver can only + weaken the bound, never invalidate it. The coverage bound is the dual value of ``y_i = 1/C`` + for the maximum coverage ``C``, so the optimum is at least that bound. + + Returns ``NO_WORD_BOUND`` if a requirement exceeds the size of its block, since then no word + meets the requirements, and zero if no requirement is positive or if the solver fails. + """ + blocks = [ + list(block) for block, requirement in zip(pivot_blocks, requirements) if requirement > 0 + ] + positive = [requirement for requirement in requirements if requirement > 0] + if not blocks: + return 0 + if any(requirement > len(block) for block, requirement in zip(blocks, positive)): + return NO_WORD_BOUND + + # Import the solver only here, since importing qLDPC does not otherwise load it. + import scipy.optimize + + # Number the columns of the blocks, and solve the linear program in floating point. + columns = sorted({column for block in blocks for column in block}) + index = {column: position for position, column in enumerate(columns)} + incidence = np.zeros((len(blocks), len(columns))) + for row, block in enumerate(blocks): + incidence[row, [index[column] for column in block]] = 1 + result = scipy.optimize.linprog( + np.ones(len(columns)), + A_ub=-incidence, + b_ub=-np.array(positive, dtype=float), + bounds=(0, 1), + method="highs", + ) + if result.status != 0: + return 0 + + # Evaluate the dual value exactly. The solver gives the dual of each constraint + # ``-sum x >= -b`` as a nonpositive marginal. + weights = [ + max( + Fraction(0), + Fraction(-float(marginal)).limit_denominator(max_denominator), + ) + for marginal in result.ineqlin.marginals + ] + value = sum( + (requirement * weight for requirement, weight in zip(positive, weights)), Fraction(0) + ) + for position in range(len(columns)): + load = sum( + (weight for row, weight in enumerate(weights) if incidence[row, position]), Fraction(0) + ) + value -= max(Fraction(0), load - 1) + return max(0, math.ceil(value)) + + +@functools.lru_cache(maxsize=_tuning.BINOMIAL_CACHE_SIZE) +def get_capped_binomials(dimension: int) -> npt.NDArray[np.int64]: + """The binomial coefficients ``C(dimension, weight)`` for every weight, capped to fit. + + The array is shared between calls, so it is read-only. + """ + binomials = np.array( + [min(math.comb(dimension, weight), _BINOMIAL_CAP) for weight in range(dimension + 1)], + dtype=np.int64, + ) + binomials.flags.writeable = False + return binomials + + +@always_compiled +def lower_bound_from_occupancy(occupancy: int, weight_divisor: int, max_coverage: int) -> int: + """Turn the pivot occupancy of a certificate into a bound on the weight of unvisited words.""" + # Correct for repeated Hamming coordinates by dividing occupancy and rounding up. + bound = (occupancy + max_coverage - 1) // max_coverage + if weight_divisor > 1: + # Every eligible Hamming weight is a multiple of weight_divisor, so the bound rounds up. + bound += (-bound) % weight_divisor + return bound + + +@always_compiled +def get_step_lower_bounds( + dimension: int, + ranks: npt.NDArray[np.int64], + weight_divisor: int, + max_coverage: int, +) -> npt.NDArray[np.int64]: + """Certificate lower bounds after each step of a search, which completes one layer of one set. + + Entry ``[weight, num_completed]`` holds once the first ``num_completed`` sets, in family order, + have completed layer ``weight``, and the others have completed layer ``weight - 1``, in whatever + order their words were visited. So column ``len(ranks)`` holds once every set has completed + layer ``weight``, and column zero of row ``weight`` repeats column ``len(ranks)`` of row + ``weight - 1``. Rank deficiency limits how many selected rows can have zero pivot support: a + set with deficiency ``s`` that has completed layer ``c`` adds ``c + 1 - s`` to the occupancy if + positive, so it adds one more for each layer that it completes once its deficiency no longer + keeps its contribution at zero. Entries ``[dimension, j]`` for ``j >= 1`` hold + ``NO_WORD_BOUND``, since a set that has completed layer ``dimension`` has visited every word. + """ + num_sets = len(ranks) + bounds = np.empty((dimension + 1, num_sets + 1), dtype=np.int64) + for weight in range(dimension + 1): + # The occupancy once every set has completed layer ``weight - 1``. + occupancy = 0 + for rank in ranks: + occupancy += max(0, weight - (dimension - rank)) + bounds[weight, 0] = lower_bound_from_occupancy(occupancy, weight_divisor, max_coverage) + + # Completing layer ``weight`` adds one for each set whose deficiency it overcomes. + for index in range(num_sets): + if weight + 1 > dimension - ranks[index]: + occupancy += 1 + bounds[weight, index + 1] = lower_bound_from_occupancy( + occupancy, weight_divisor, max_coverage + ) + bounds[dimension, 1:] = NO_WORD_BOUND + return bounds + + +@always_compiled +def plan_steps( + step_bounds: npt.NDArray[np.int64], + limit: int, + first_layer: int, + last_layer: int, + ranks: npt.NDArray[np.int64], + binomials: npt.NDArray[np.int64], + skip_unused: bool, +) -> tuple[int, int, int]: + """Plan a search of layers ``first_layer`` to ``last_layer`` that stops above a limit. + + The work estimates of the distance search and of the routes of the coset searches add up the + work of the steps of a search with this function, and searches that need only where they stop + call ``find_stopping_step``. The table ``step_bounds`` has a row for each layer from zero to + the dimension, and a column for each set after column zero. The steps of layer ``w`` are + ``(w, 1)`` to ``(w, num_sets)``, in that order and layer by layer, and the bound of step + ``(w, j)`` is ``step_bounds[w, j]``, as in the table of ``get_step_lower_bounds``. These bounds + must not decrease in the order of the steps, which holds for that table and for any + nondecreasing function of it, such as the tables of ``Certificate.get_weight_bounds``. + + The stopping step ``(w*, j*)`` is the first step of the layers whose bound exceeds ``limit``, + or ``(last_layer, num_sets)`` if there is none, which ``find_stopping_step`` finds. At that + step, set ``i`` has completed layer ``w_i = w*`` if ``i < j*``, and ``w* - 1`` otherwise. A set + of rank deficiency ``s`` adds to the bound of the step only if ``w_i >= s`` (see + ``get_step_lower_bounds``). The other sets are the unused sets of the step. If ``skip_unused`` + is True, the plan leaves them out, as a coset search skips them, and reads the ranks of the sets + from ``ranks``. Otherwise, it reads no ranks. + + The work adds ``binomials[w]`` candidate/set evaluations for each set that the plan keeps and + each layer ``w`` from ``first_layer`` to ``w_i``. Empty ``binomials`` give zero work. The work + saturates at ``_WORK_CAP``: it is the cap once a layer up to ``w*`` has a binomial above + ``_WORK_CAP // num_sets``, or once the sum reaches the cap after a layer. With the binomials of + ``get_capped_binomials``, a plan from layer one for the limit ``U - 1`` gives the work estimate + of a distance search (Section 3.6 of ``brouwer_zimmermann.md``). A plan from layer zero for a + maximum weight ``max_weight`` that skips unused sets gives the number of words that a coset + search weighs, or skips by completion tables (Section 3.9 of ``brouwer_zimmermann.md``), after + the word of its syndrome, if no word of the coset weighs at most ``max_weight``. + + Returns: + The stopping step ``(w*, j*)``, and the work. + """ + layer, num_completed = find_stopping_step(step_bounds, limit, first_layer, last_layer) + work = _get_steps_work( + step_bounds.shape[0] - 1, + step_bounds.shape[1] - 1, + ranks, + layer, + num_completed, + first_layer, + binomials, + skip_unused, + ) + return layer, num_completed, work + + +@always_compiled +def find_stopping_step( + step_bounds: npt.NDArray[np.int64], limit: int, first_layer: int, last_layer: int +) -> tuple[int, int]: + """Find the stopping step of a search of layers ``first_layer`` to ``last_layer``. + + The steps and their bounds are those of ``plan_steps``, and so is the stopping step: the first + step of the layers whose bound exceeds ``limit``, or ``(last_layer, num_sets)`` if there is + none. The bounds do not decrease in the order of the steps, so a binary search finds it. The + coset searches find where they stop with this function, and so does the distance search, + through ``Certificate.plan``, when its certificate solves no linear programs. + """ + num_sets = step_bounds.shape[1] - 1 + low, high = 0, (last_layer - first_layer + 1) * num_sets + while low < high: + middle = (low + high) // 2 + if step_bounds[first_layer + middle // num_sets, 1 + middle % num_sets] > limit: + high = middle + else: + low = middle + 1 + if low == (last_layer - first_layer + 1) * num_sets: + return last_layer, num_sets + return first_layer + low // num_sets, 1 + low % num_sets + + +@always_compiled(inline=True) +def get_completed_layer(set_index: int, layer: int, num_completed: int) -> int: + """The last layer that set ``set_index`` has completed at step ``(layer, num_completed)``.""" + return layer if set_index < num_completed else layer - 1 + + +@always_compiled(inline=True) +def is_unused_set( + pivots: npt.NDArray[np.int64], set_index: int, layer: int, num_completed: int +) -> bool: + """Whether a set is an unused set of step ``(layer, num_completed)`` (see ``plan_steps``). + + ``pivots`` holds the pivot columns of each set of ``num_rows = pivots.shape[1]`` rows, padded + with -1 beyond its rank. A set is unused if its rank deficiency exceeds the last layer that it + has completed, which must be at least zero. This holds exactly if its rank is at most + ``position = num_rows - completed_layer - 1``, that is, if its pivot at ``position`` is padding. + ``position`` is negative only if the set has completed layer ``num_rows``, and then it is used. + """ + position = pivots.shape[1] - get_completed_layer(set_index, layer, num_completed) - 1 + return position >= 0 and pivots[set_index, position] < 0 + + +@always_compiled +def estimate_certificate_work( + dimension: int, + ranks: npt.NDArray[np.int64], + weight_divisor: int, + max_coverage: int, + upper_bound: int, + binomials: npt.NDArray[np.int64], +) -> int: + """Estimate the candidate/set evaluations needed to certify ``upper_bound``. + + This is ``Certificate.work`` for arrays, which compiled code can pass. It is the work of + ``plan_steps`` for every layer from one, every set, and the limit ``upper_bound - 1``. + ``binomials`` are ``get_capped_binomials(dimension)``. + """ + bounds = get_step_lower_bounds(dimension, ranks, weight_divisor, max_coverage) + _, _, work = plan_steps(bounds, upper_bound - 1, 1, dimension, ranks, binomials, False) + return work + + +@always_compiled +def _get_steps_work( + dimension: int, + num_sets: int, + ranks: npt.NDArray[np.int64], + layer: int, + num_completed: int, + first_layer: int, + binomials: npt.NDArray[np.int64], + skip_unused: bool, +) -> int: + """The work of ``plan_steps`` for its stopping step ``(layer, num_completed)``.""" + if len(binomials) == 0: + return 0 + work = 0 + for weight in range(first_layer, layer + 1): + # Saturate before adding, so that no sum can overflow: a layer adds at most the cap. + if binomials[weight] > _WORK_CAP // max(num_sets, 1): + return _WORK_CAP + for index in range(num_sets): + # Skip the layers that the set has not completed, and the sets that the plan leaves out. + completed_layer = get_completed_layer(index, layer, num_completed) + if weight > completed_layer or ( + skip_unused and completed_layer < dimension - ranks[index] + ): + continue + work += binomials[weight] + if work >= _WORK_CAP: + return _WORK_CAP + return work diff --git a/src/qldpc/codes/distance/_information_sets/certificates_test.py b/src/qldpc/codes/distance/_information_sets/certificates_test.py new file mode 100644 index 000000000..2751efdab --- /dev/null +++ b/src/qldpc/codes/distance/_information_sets/certificates_test.py @@ -0,0 +1,357 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the certificates of basis families and the plans of their steps.""" + +from __future__ import annotations + +import dataclasses +import itertools +import math + +import numpy as np +import numpy.typing as npt +import pytest +import scipy.optimize + +from .._tuning import DEFAULT_TUNING +from . import certificates + +DENOMINATOR = DEFAULT_TUNING.lp_dual_max_denominator + + +def test_certificate_follows_its_definition(pytestconfig: pytest.Config) -> None: + """The bounds, work estimate, and number of sets that certify a bound equal their definitions. + + A search visits the sets of a layer in family order and stops after the first set whose + completion of the layer certifies the upper bound, so the work counts the sets visited, and + ``get_num_sets_to_certify`` gives the number of sets visited in each layer. + """ + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + for _ in range(30): + dimension = int(rng.integers(1, 13)) + ranks = tuple(int(rank) for rank in rng.integers(0, dimension + 1, rng.integers(1, 6))) + weight_divisor, max_coverage = int(rng.integers(1, 5)), int(rng.integers(1, 5)) + certificate = certificates.Certificate(dimension, ranks, weight_divisor, max_coverage) + + oracle = [ + _get_lower_bound_from_definition(dimension, ranks, weight_divisor, max_coverage, weight) + for weight in range(dimension + 1) + ] + assert [certificate.lower_bound(weight) for weight in range(dimension + 1)] == oracle + steps = [ + (weight, count) + for weight in range(1, dimension + 1) + for count in range(1, len(ranks) + 1) + ] + step_oracle = { + step: _get_lower_bound_from_definition( + dimension, ranks, weight_divisor, max_coverage, *step + ) + for step in steps + } + assert {step: certificate.step_lower_bound(*step) for step in steps} == step_oracle + for upper_bound in range(1, 2 * dimension + 2): + candidates = 0 + for weight, count in steps: + candidates += math.comb(dimension, weight) + if step_oracle[weight, count] >= upper_bound: + break + assert certificate.work(upper_bound) == candidates + for weight in range(1, dimension + 1): + num_sets = next( + ( + count + for count in range(1, len(ranks)) + if step_oracle[weight, count] >= upper_bound + ), + len(ranks), + ) + assert certificate.get_num_sets_to_certify(weight, upper_bound) == num_sets + + # A family without sets, which the overlapping stage returns for no replacement, does no work. + assert certificates.Certificate(3, ()).work(2) == 0 + + +def test_weight_bounds_divide_the_step_bounds(pytestconfig: pytest.Config) -> None: + """The weight bounds divide the certificate's bounds by the weight factor, rounding up. + + The certificate's bounds come from their definition, and the entries ``[dimension, j]`` for + ``j >= 1`` are ``NO_WORD_BOUND``. With a parity, each other entry is first rounded up to the + least integer of that parity that is at least the entry, except the zero before the first step. + This holds for sets of random ranks, including zero, both weight factors, and every parity. + Each call returns a table of its own. + """ + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + for _ in range(60): + dimension = int(rng.integers(0, 7)) + ranks = tuple(int(rank) for rank in rng.integers(0, dimension + 1, rng.integers(1, 5))) + divisor, coverage = int(rng.integers(1, 3)), int(rng.integers(1, 3)) + certificate = certificates.Certificate(dimension, ranks, divisor, coverage) + for factor, parity in itertools.product((1, 2), (None, 0, 1)): + bounds = certificate.get_weight_bounds(factor, parity) + assert bounds.shape == (dimension + 1, len(ranks) + 1) + for layer, num_completed in itertools.product( + range(dimension + 1), range(len(ranks) + 1) + ): + bound = _get_lower_bound_from_definition( + dimension, ranks, divisor, coverage, layer, num_completed + ) + if parity is not None and (layer, num_completed) != (0, 0): + bound += (bound - parity) % 2 + expected = ( + certificates.NO_WORD_BOUND + if layer == dimension and num_completed + else -(-bound // factor) + ) + assert bounds[layer, num_completed] == expected + expected_bounds = bounds.copy() + bounds[:] = -1 + assert np.array_equal(certificate.get_weight_bounds(factor, parity), expected_bounds) + + +def test_plans_follow_their_definition(pytestconfig: pytest.Config) -> None: + """The stopping step and the work of a plan follow their definitions. + + The stopping step is the first step of the given layers whose bound exceeds the limit, or the + last step if none does. At that step, each set has completed its last layer, and the work adds + the binomial of each layer from the first layer to the last layer of each set, leaving out the + sets whose rank keeps them out of the bound if unused sets are skipped. The work saturates at + the cap if a layer up to that of the step has a binomial above the cap divided by the number of + sets, or the sum reaches the cap, and no binomials give no work. + + At the stopping step of a plan over every layer, ``get_completed_layer`` gives the last layer of + each set. ``is_unused_set`` tells from the pivots of a set, which are -1 beyond its rank, + whether a plan that skips unused sets leaves the set out. + + The bounds are certificate tables of sets of every rank, including zero, a table without sets, + and the weight bounds of the coset searches, which divide them by a weight factor after rounding + them to a parity. The limits run past both ends of each table, to the largest native integer. + The binomials are random, and some exceed the share of the cap of each set. Thousands of sets + tell the cap of a layer from the cap of the sum, since the stopping step comes after the first + set of a layer whose binomial exceeds the share, while the sum stays below the cap. + """ + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + work_cap = certificates._WORK_CAP + cases: list[tuple[npt.NDArray[np.int64], npt.NDArray[np.int64]]] = [ + (np.zeros((3, 1), dtype=np.int64), np.empty(0, dtype=np.int64)) + ] + for _ in range(20): + dimension = int(rng.integers(0, 7)) + ranks = rng.integers(0, dimension + 1, rng.integers(1, 5)) + certificate = certificates.Certificate( + dimension, tuple(ranks.tolist()), int(rng.integers(1, 3)), int(rng.integers(1, 3)) + ) + parity = [None, 0, 1][int(rng.integers(3))] + cases.append((certificate._step_lower_bounds, ranks)) + cases.append((certificate.get_weight_bounds(int(rng.integers(1, 3)), parity), ranks)) + many_sets = np.full(3000, 2, dtype=np.int64) + cases.append((certificates.get_step_lower_bounds(2, many_sets, 1, 1), many_sets)) + + for bounds, ranks in cases: + num_rows, num_sets = bounds.shape[0], bounds.shape[1] - 1 + no_word_bound = certificates.NO_WORD_BOUND + finite = bounds[bounds < no_word_bound] + limits = [*range(-2, int(finite.max(initial=0)) + 3), no_word_bound - 1, no_word_bound] + binomials_choices = [ + np.empty(0, dtype=np.int64), + certificates.get_capped_binomials(num_rows - 1), + rng.choice([1, 5, work_cap // max(num_sets, 1) + 1, 2**60], num_rows), + ] + if num_sets == len(many_sets): + # The layers before the stopping step (2, 1) sum below the cap, but its layer does not. + limits = [2 * num_sets] + binomials_choices = [np.array([1, 1, 2**50])] + for first_layer, binomials, skip_unused in itertools.product( + range(num_rows), binomials_choices, (False, True) + ): + for last_layer, limit in itertools.product(range(first_layer, num_rows), limits): + steps = [ + (layer, count) + for layer in range(first_layer, last_layer + 1) + for count in range(1, num_sets + 1) + ] + layer, num_completed = next( + (step for step in steps if bounds[step] > limit), (last_layer, num_sets) + ) + last_layers = [ + layer if index < num_completed else layer - 1 for index in range(num_sets) + ] + work = 0 + if len(binomials): + work = sum( + int(binomials[weight]) + for rank, last in zip(ranks, last_layers) + if not skip_unused or (last >= 0 and last + 1 > num_rows - 1 - rank) + for weight in range(first_layer, last + 1) + ) + if work >= work_cap or any( + binomials[weight] > work_cap // max(num_sets, 1) + for weight in range(first_layer, layer + 1) + ): + work = work_cap + expected = (layer, num_completed, work) + plan = certificates.plan_steps( + bounds, limit, first_layer, last_layer, ranks, binomials, skip_unused + ) + assert plan == expected + dimension = num_rows - 1 + if first_layer or last_layer < dimension or skip_unused or len(binomials): + continue + step = (layer, num_completed) + pivots = np.where(np.arange(dimension) < ranks[:, np.newaxis], 0, -1) + for index, (rank, last) in enumerate(zip(ranks, last_layers)): + assert certificates.get_completed_layer(index, *step) == last + if last >= 0: + unused = last + 1 <= dimension - rank + assert certificates.is_unused_set(pivots, index, *step) == unused + + +def test_sets_to_certify_a_bound_may_come_from_linear_programs() -> None: + """With linear programs, fewer sets can certify a bound than the coverage bound needs. + + In the [7, 3] example of ``test_linear_programming_can_beat_the_coverage_bound``, the coverage + bound needs all five sets to certify four in layer one, and cannot certify six in layer two. + Linear programs certify them after three sets and two sets. Five needs all five sets in layer + one either way. + """ + certificate = certificates.Certificate( + 3, + (3,) * 5, + max_coverage=3, + pivot_blocks=((0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3), (4, 5, 6)), + lp_max_denominator=DENOMINATOR, + ) + assert certificate.get_num_sets_to_certify(1, 4) == 3 + assert certificate.get_num_sets_to_certify(2, 6) == 2 + assert certificate.get_num_sets_to_certify(1, 5) == 5 + + +def test_plans_with_linear_programs_solve_their_programs_lazily( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A plan with linear programs stops at the first step that certifies, and solves lazily. + + The certificate is that of ``test_sets_to_certify_a_bound_may_come_from_linear_programs``. For + every limit and layer, the plan stops at the first step of the layer whose bound, with linear + programs, exceeds the limit, or at the last step. It solves the linear program of a step only + if that step is at or before the stopping step, and its coverage bound does not exceed the + limit. It never solves that of the last step, which stops the search anyway. Some plans solve + programs, and the work estimate of the certificate solves none, since it uses only the coverage + bound. The requirements of the program of a step identify the step. + """ + blocks = ((0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3), (4, 5, 6)) + + def get_certificate() -> certificates.Certificate: + return certificates.Certificate( + 3, (3,) * 5, max_coverage=3, pivot_blocks=blocks, lp_max_denominator=DENOMINATOR + ) + + reference = get_certificate() + coverage = certificates.Certificate(3, (3,) * 5, max_coverage=3) + steps_by_requirements = { + tuple((layer if index < count else layer - 1) + 1 for index in range(5)): (layer, count) + for layer, count in itertools.product(range(1, 4), range(1, 6)) + } + solved: list[tuple[int, int]] = [] + get_lp_lower_bound = certificates.get_lp_lower_bound + + def record( + pivot_blocks: tuple[tuple[int, ...], ...], requirements: list[int], max_denominator: int + ) -> int: + solved.append(steps_by_requirements[tuple(requirements)]) + return get_lp_lower_bound(pivot_blocks, requirements, max_denominator) + + monkeypatch.setattr(certificates, "get_lp_lower_bound", record) + num_solved = 0 + for layer, limit in itertools.product(range(1, 4), range(9)): + steps = [(layer, count) for count in range(1, 6)] + stop = next( + (step for step in steps[:-1] if reference.step_lower_bound(*step) > limit), steps[-1] + ) + solved.clear() + assert get_certificate().plan(limit, layer) == stop[1] + assert steps[-1] not in solved + for step in solved: + assert steps.index(step) <= steps.index(stop) + assert coverage.step_lower_bound(*step) <= limit + num_solved += len(solved) + assert num_solved + + # The estimate of work solves no program. + solved.clear() + for upper_bound in range(9): + assert get_certificate().work(upper_bound) == coverage.work(upper_bound) + assert not solved + + +def test_linear_programming_can_beat_the_coverage_bound() -> None: + """Five information sets of a [7, 3] code, where linear programming certifies five, not four. + + The columns are ``e1, e2, e3, e1 + e2 + e3, e1, e2, e3``. Four blocks are the 3-subsets of the + first four columns, and the fifth is the last three columns, so the maximum coverage is three. + After layer one, each block holds at least two ones of an unvisited word. The coverage bound is + ``ceil(10 / 3) = 4``, but ``x = 2/3`` on every column and dual weights of ``1/3`` on the first + four blocks and one on the fifth both have value ``14/3``, so the linear program certifies five. + """ + blocks = ((0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3), (4, 5, 6)) + assert certificates.get_lp_lower_bound(blocks, [2] * 5, DENOMINATOR) == 5 + # Dual weights of thirds need a denominator of three; coarser ones round part of the bound away. + bounds = [certificates.get_lp_lower_bound(blocks, [2] * 5, d) for d in (1, 2, 3)] + assert bounds == [2, 4, 5] + certificate = certificates.Certificate(3, (3,) * 5, max_coverage=3) + assert certificate.lower_bound(1) == 4 + assert ( + dataclasses.replace( + certificate, pivot_blocks=blocks, lp_max_denominator=DENOMINATOR + ).lower_bound(1) + == 5 + ) + + # No word has three ones in a block of two columns, and requirements of zero ask for nothing. + assert certificates.get_lp_lower_bound([(0, 1)], [3], DENOMINATOR) == certificates.NO_WORD_BOUND + assert certificates.get_lp_lower_bound([(0, 1)], [0], DENOMINATOR) == 0 + + +def test_a_failed_linear_program_gives_no_bound(monkeypatch: pytest.MonkeyPatch) -> None: + """If the solver reports a failure, the bound claims nothing rather than a solver's guess.""" + failure = scipy.optimize.OptimizeResult(status=4, message="numerical difficulties") + monkeypatch.setattr(scipy.optimize, "linprog", lambda *args, **kwargs: failure) + blocks = ((0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3), (4, 5, 6)) + assert certificates.get_lp_lower_bound(blocks, [2] * 5, DENOMINATOR) == 0 + + +@pytest.mark.parametrize("dimension, num_sets", [(300, 16), (60, 8192)]) +def test_work_estimates_saturate(dimension: int, num_sets: int) -> None: + """Work estimates for huge searches stay large and positive, rather than overflowing.""" + certificate = certificates.Certificate(dimension, ranks=(dimension,) * num_sets) + assert 2**50 < certificate.work(upper_bound=10**6) < 2**63 + + +def test_binomials_are_exact_where_they_fit_and_cannot_be_modified() -> None: + binomials = certificates.get_capped_binomials(70) + assert not binomials.flags.writeable + assert binomials[:5].tolist() == [math.comb(70, weight) for weight in range(5)] + assert 0 < binomials[35] < math.comb(70, 35) + + +def _get_lower_bound_from_definition( + dimension: int, + ranks: tuple[int, ...], + weight_divisor: int, + max_coverage: int, + weight: int, + num_completed: int | None = None, +) -> int: + """The certificate lower bound once sets have completed layers, from its definition. + + The first ``num_completed`` sets have completed layer ``weight``, and the others layer ``weight + - 1``; by default, every set has completed layer ``weight``. + """ + num_completed = len(ranks) if num_completed is None else num_completed + if weight == dimension and num_completed: + # A set that has completed the last layer has visited every word. + return certificates.NO_WORD_BOUND + completed = [weight if index < num_completed else weight - 1 for index in range(len(ranks))] + occupancy = sum(max(0, layer + 1 - (dimension - rank)) for layer, rank in zip(completed, ranks)) + bound = -(-occupancy // max_coverage) + return bound + (-bound) % weight_divisor diff --git a/src/qldpc/codes/distance/_search/completion_bounds.py b/src/qldpc/codes/distance/_information_sets/completion_bounds.py similarity index 99% rename from src/qldpc/codes/distance/_search/completion_bounds.py rename to src/qldpc/codes/distance/_information_sets/completion_bounds.py index ce4d26373..fb5b2c388 100644 --- a/src/qldpc/codes/distance/_search/completion_bounds.py +++ b/src/qldpc/codes/distance/_information_sets/completion_bounds.py @@ -47,7 +47,7 @@ from qldpc._compilation import always_compiled from .._tuning import SearchTuning -from . import information_sets +from . import certificates #################################################################################################### # completion tables @@ -254,7 +254,7 @@ def get_completion_bound( def _get_layer_work(num_rows: int, weight: int) -> int: """The number of supports of a layer, saturated at the cap of work estimates.""" - return min(math.comb(num_rows, weight), information_sets._WORK_CAP) + return min(math.comb(num_rows, weight), certificates._WORK_CAP) def _get_num_tail_bits(rows: npt.NDArray[np.uint64]) -> int: diff --git a/src/qldpc/codes/distance/_search/completion_bounds_test.py b/src/qldpc/codes/distance/_information_sets/completion_bounds_test.py similarity index 100% rename from src/qldpc/codes/distance/_search/completion_bounds_test.py rename to src/qldpc/codes/distance/_information_sets/completion_bounds_test.py diff --git a/src/qldpc/codes/distance/_search/matroid_partition.py b/src/qldpc/codes/distance/_information_sets/matroid_partition.py similarity index 100% rename from src/qldpc/codes/distance/_search/matroid_partition.py rename to src/qldpc/codes/distance/_information_sets/matroid_partition.py diff --git a/src/qldpc/codes/distance/_search/matroid_partition_test.py b/src/qldpc/codes/distance/_information_sets/matroid_partition_test.py similarity index 100% rename from src/qldpc/codes/distance/_search/matroid_partition_test.py rename to src/qldpc/codes/distance/_information_sets/matroid_partition_test.py diff --git a/src/qldpc/codes/distance/_information_sets/support_stepping.py b/src/qldpc/codes/distance/_information_sets/support_stepping.py new file mode 100644 index 000000000..af0e3a4bc --- /dev/null +++ b/src/qldpc/codes/distance/_information_sets/support_stepping.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""The step of the scans of information sets from one support of a layer to the next.""" + +from __future__ import annotations + +import numpy as np +import numpy.typing as npt + +from qldpc._compilation import always_compiled + + +@always_compiled(inline=True) +def advance_support( + rows: npt.NDArray[np.uint64], + widths: npt.NDArray[np.int64], + support: npt.NDArray[np.int64], + prefixes: npt.NDArray[np.uint64], + depth: int, +) -> int: + """Move to the next support of a layer of the search. + + Layer ``w = len(support) >= 1`` holds, for each information set, the sums of its offset and + ``w`` of its rows. A support lists the indices of such rows in increasing order, and the + supports come in lexicographic order. ``rows`` holds the packed rows of each information set, + and set ``j`` uses the first ``widths[j]`` machine words of each row. The rows chosen so far + are ``support[:depth]``, and ``support[depth]`` is the next row to try at ``depth``. Row + ``level`` of ``prefixes`` holds, for each set, its offset plus its rows ``support[:level]``. + Start with ``depth = 0``, ``support[0] = 0``, and the offsets in ``prefixes[0]``, which this + function does not change. + + Return the last depth, ``len(support) - 1``, once ``support`` is complete. The word of the + support in set ``j`` is then ``prefixes[depth, j] ^ rows[j, support[depth]]``. To continue, + raise ``support[depth]``, by one or past the last row to skip the supports that differ only in + their last row, and call again from that depth. Return -1 once every support has been visited. + """ + num_rows = rows.shape[1] + weight = len(support) + while depth >= 0: + # Backtrack once the row at this depth leaves too few rows to complete the support. + if support[depth] > num_rows - weight + depth: + depth -= 1 + if depth >= 0: + support[depth] += 1 + continue + if depth + 1 == weight: + return depth + + # Add the row at this depth to the prefixes, and try the next rows at the next depth. + row = support[depth] + for set_index in range(len(widths)): + for word in range(widths[set_index]): + prefixes[depth + 1, set_index, word] = ( + prefixes[depth, set_index, word] ^ rows[set_index, row, word] + ) + depth += 1 + support[depth] = row + 1 + return depth diff --git a/src/qldpc/codes/distance/_information_sets/support_stepping_test.py b/src/qldpc/codes/distance/_information_sets/support_stepping_test.py new file mode 100644 index 000000000..773386ca4 --- /dev/null +++ b/src/qldpc/codes/distance/_information_sets/support_stepping_test.py @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the step from one support of a layer to the next.""" + +from __future__ import annotations + +import itertools + +import numpy as np +import pytest + +from . import support_stepping + + +def test_layers_visit_each_support_once_with_its_sums(pytestconfig: pytest.Config) -> None: + """A layer visits the combinations of rows in order, each with its sum in every set. + + The second set uses fewer words than the first, and the starting words are random. + """ + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + widths = np.array([3, 1], dtype=np.int64) + for dimension in range(1, 7): + rows = rng.integers(2**64, size=(2, dimension, 3), dtype=np.uint64) + start = rng.integers(2**64, size=(2, 3), dtype=np.uint64) + for weight in range(1, dimension + 1): + support = np.zeros(weight, dtype=np.int64) + prefixes = np.zeros((weight, 2, 3), dtype=np.uint64) + prefixes[0] = start + visited = [] + depth = support_stepping.advance_support(rows, widths, support, prefixes, 0) + while depth >= 0: + sums = prefixes[depth] ^ rows[:, support[depth]] + visited.append( + (support.tolist(), [sums[jj, : widths[jj]].tolist() for jj in range(2)]) + ) + support[depth] += 1 + depth = support_stepping.advance_support(rows, widths, support, prefixes, depth) + expected = [] + for indices in itertools.combinations(range(dimension), weight): + sums = start ^ np.bitwise_xor.reduce(rows[:, list(indices)], axis=1) + expected.append( + (list(indices), [sums[jj, : widths[jj]].tolist() for jj in range(2)]) + ) + assert visited == expected diff --git a/src/qldpc/codes/distance/_search/__init__.py b/src/qldpc/codes/distance/_search/__init__.py deleted file mode 100644 index a9724d489..000000000 --- a/src/qldpc/codes/distance/_search/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 - -"""Internals of the exact searches: information-set machinery and the searches that use it. - -``qldpc.codes.distance.core`` and ``qldpc.codes.distance.logical_counts`` are the public interfaces -of these modules, and ``qldpc.circuits.certification`` uses the coset-leader search. -""" diff --git a/src/qldpc/codes/distance/_search/information_sets_test.py b/src/qldpc/codes/distance/_search/information_sets_test.py deleted file mode 100644 index 2a6013ff9..000000000 --- a/src/qldpc/codes/distance/_search/information_sets_test.py +++ /dev/null @@ -1,771 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 - -"""Tests for the basis families and certificates shared by BZ distance and counting.""" - -from __future__ import annotations - -import dataclasses -import functools -import itertools -import math -from typing import Any - -import numpy as np -import numpy.typing as npt -import pytest -import scipy.optimize - -import qldpc -from qldpc import _compilation -from qldpc._binary import get_independent_rows, row_reduce_binary - -from .._tuning import DEFAULT_TUNING, SearchTuning -from ..conftest import get_random_full_rank_matrix -from . import information_sets - -# The default numbers of column orders of disjoint families, and denominators of LP dual weights. -ORDERS = DEFAULT_TUNING.disjoint_random_column_orders -DENOMINATOR = DEFAULT_TUNING.lp_dual_max_denominator - -# Two disjoint full blocks and a rank-two remainder. -PARTIAL_BASIS = np.hstack( - [ - np.eye(3, dtype=np.uint8), - np.eye(3, dtype=np.uint8), - np.array([[1, 0], [0, 1], [0, 0]], dtype=np.uint8), - ] -) -EVEN_BASIS = np.array([[1, 1, 0, 0], [0, 1, 1, 0], [1, 0, 0, 1]], dtype=np.uint8) - - -def test_information_set_packing() -> None: - """Information sets pack full blocks first and retain the residual rank.""" - get_family = functools.partial( - information_sets.get_disjoint_family, random_column_orders=ORDERS - ) - assert get_family(PARTIAL_BASIS, np.eye(3, dtype=np.uint8)).ranks.tolist() == [3, 3, 2] - assert not len(get_family(np.zeros((1, 3), dtype=np.uint8), None).ranks) - - basis = np.array( - [ - [1, 0, 1, 1, 0, 1], - [0, 1, 0, 1, 1, 0], - [1, 0, 0, 0, 1, 1], - ], - dtype=np.uint8, - ) - assert get_family(basis, None).ranks.tolist() == [3, 3] - - # Only one column carries the second row, so no column order packs a second full set. - basis = np.array([[1, 1, 1, 0], [0, 0, 0, 1]], dtype=np.uint8) - assert get_family(basis, None).ranks.tolist() == [2, 1] - - -def test_a_basis_family_stacks_its_sets() -> None: - """The sets of a basis family are reduced on their pivots, which are padded for partial sets.""" - labels = np.eye(3, dtype=np.uint8) - basis_family = information_sets.get_disjoint_family( - PARTIAL_BASIS, labels, random_column_orders=ORDERS - ) - assert basis_family.generators.shape == (3, 3, PARTIAL_BASIS.shape[1]) - assert basis_family.labels.shape == (3, 3, 3) - assert basis_family.certificate.dimension == 3 - for generators, set_labels, pivots, rank in zip(*basis_family[:4], strict=True): - # The labels record the row operations that reduce the basis. - np.testing.assert_array_equal(generators, set_labels @ PARTIAL_BASIS % 2) - np.testing.assert_array_equal(generators[:rank, pivots[:rank]], np.eye(rank)) - assert np.all(pivots[rank:] == -1) - no_labels = information_sets.get_disjoint_family( - PARTIAL_BASIS, None, random_column_orders=ORDERS - ) - assert no_labels.labels.shape == (3, 3, 0) - - -@pytest.mark.parametrize("basis", [PARTIAL_BASIS, EVEN_BASIS]) -def test_family_certificates(basis: npt.NDArray[np.uint8]) -> None: - """Rank-deficient blocks and inferred parity give certificates valid for every word.""" - # A target above every weight keeps the rank-deficient block. - basis_family = information_sets.get_disjoint_family(basis, None, random_column_orders=ORDERS) - basis_family = information_sets.prune_family(basis_family, basis.shape[1] + 1) - _assert_certificate_bounds_unvisited_words(basis, basis_family) - - -def test_weight_divisor_inference() -> None: - """Even rows span only even weights, and a divisor proven by the caller is retained.""" - odd = np.array([[1, 0, 0, 0], [0, 1, 1, 0]], dtype=np.uint8) - get_family = functools.partial( - information_sets.get_disjoint_family, random_column_orders=ORDERS - ) - assert get_family(EVEN_BASIS, None).certificate.weight_divisor == 2 - assert get_family(odd, None).certificate.weight_divisor == 1 - assert get_family(odd, None, weight_divisor=2).certificate.weight_divisor == 2 - - -@pytest.mark.parametrize( - "ranks, upper_bound, retained", - [([10, 6, 2], 6, [10, 6]), ([10, 6, 2], 12, [10, 6, 2]), ([6, 2], 6, [6, 2])], -) -def test_prune_family(ranks: list[int], upper_bound: int, retained: list[int]) -> None: - """Pruning keeps full sets, and the partial sets that can help certify the target. - - A set of ten rows with rank deficiency ``d`` adds to the bound only once layer ``d`` is - complete. The full set alone certifies 6 once layer 5 is complete, so the set of deficiency 8 - cannot help with 6, but no layer lets the full set alone certify 12. Without a full set, every - set stays. - """ - no_columns = np.empty((len(ranks), 10, 0), dtype=np.uint8) - pivots = np.full((len(ranks), 10), -1, dtype=np.int64) - basis_family = information_sets.BasisFamily( - no_columns, no_columns, pivots, np.array(ranks, dtype=np.int64), 1, 1 - ) - pruned = information_sets.prune_family(basis_family, upper_bound) - assert pruned.ranks.tolist() == retained - assert pruned.certificate.work(upper_bound) <= basis_family.certificate.work(upper_bound) - - -def test_overlapping_family_certificates() -> None: - """Check pivot coverage and label transformations against exhaustive codeword weights.""" - - # Label each row separately so its row operations can be checked. - basis = np.asarray(qldpc.codes.HammingCode(4).generator, dtype=np.uint8) - basis = get_independent_rows(basis) - labels = np.eye(len(basis), dtype=np.uint8) - disjoint = information_sets.get_disjoint_family(basis, labels, random_column_orders=ORDERS) - # One full set certifies a target of one after its first layer, which no replacement improves. - assert not len(_get_overlapping_family(basis, labels, disjoint, 1, minimum_work=0).ranks) - basis_family = _get_overlapping_family(basis, labels, disjoint, 3, minimum_work=0) - assert np.all(basis_family.ranks == len(basis)) - loads = np.bincount(basis_family.pivots.ravel(), minlength=basis.shape[1]) - assert basis_family.max_coverage >= loads.max() > 1 - for generators, set_labels, pivots in zip(*basis_family[:3], strict=True): - np.testing.assert_array_equal(generators, set_labels @ basis % 2) - np.testing.assert_array_equal(generators[:, pivots], np.eye(len(basis))) - _assert_certificate_bounds_unvisited_words(basis, basis_family) - - -def test_a_cheap_search_keeps_its_disjoint_basis_family() -> None: - """No overlapping basis family is built if the work of the disjoint one is below the minimum.""" - basis = get_independent_rows(np.asarray(qldpc.codes.HammingCode(4).generator)) - labels = np.eye(len(basis), dtype=np.uint8) - disjoint = information_sets.get_disjoint_family(basis, labels, random_column_orders=ORDERS) - assert not len(_get_overlapping_family(basis, labels, disjoint, 3).ranks) - assert len(_get_overlapping_family(basis, labels, disjoint, 3, minimum_work=0).ranks) - # A limit of zero sets builds no family at all. - assert not len(_get_overlapping_family(basis, labels, disjoint, 3, 0, minimum_work=0).ranks) - - -def test_overlapping_family_guards() -> None: - """Reject unhelpful coverage and terminate repeated attempts at the same pivot set.""" - - # Complete or nearly complete disjoint coverage leaves no cheaper family within the size budget. - basis = np.hstack([np.eye(25, dtype=np.uint8)] * 4) - complete = information_sets.get_disjoint_family(basis, None, random_column_orders=ORDERS) - assert not len(_get_overlapping_family(basis, None, complete, 26).ranks) - shortened = basis[:, :-1] - disjoint = information_sets.get_disjoint_family(shortened, None, random_column_orders=ORDERS) - assert not len(_get_overlapping_family(shortened, None, disjoint, 26).ranks) - - # A zero basis has no information sets to replace. - zeros = np.zeros((1, 3), dtype=np.uint8) - empty = information_sets.get_disjoint_family(zeros, None, random_column_orders=ORDERS) - assert not len(empty.ranks) - assert not len(_get_overlapping_family(zeros, None, empty, 2).ranks) - - # An information set holds one of the third to fifth columns, which are equal, and two of the - # first, second, and sixth, so attempts rediscover the few such sets. The search must terminate - # without a replacement that beats the disjoint family. - small = np.array([[1, 0, 0, 0, 0, 1], [0, 1, 0, 0, 0, 1], [0, 0, 1, 1, 1, 0]], dtype=np.uint8) - disjoint = information_sets.get_disjoint_family(small, None, random_column_orders=ORDERS) - assert not len(_get_overlapping_family(small, None, disjoint, 3, minimum_work=0).ranks) - - -def test_certificate_follows_its_definition(pytestconfig: pytest.Config) -> None: - """The bounds, work estimate, and number of sets that certify a bound equal their definitions. - - A search visits the sets of a layer in family order and stops after the first set whose - completion of the layer certifies the upper bound, so the work counts the sets visited, and - ``get_num_sets_to_certify`` gives the number of sets visited in each layer. - """ - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - for _ in range(30): - dimension = int(rng.integers(1, 13)) - ranks = tuple(int(rank) for rank in rng.integers(0, dimension + 1, rng.integers(1, 6))) - weight_divisor, max_coverage = int(rng.integers(1, 5)), int(rng.integers(1, 5)) - certificate = information_sets.Certificate(dimension, ranks, weight_divisor, max_coverage) - - oracle = [ - _get_lower_bound_from_definition(dimension, ranks, weight_divisor, max_coverage, weight) - for weight in range(dimension + 1) - ] - assert [certificate.lower_bound(weight) for weight in range(dimension + 1)] == oracle - steps = [ - (weight, count) - for weight in range(1, dimension + 1) - for count in range(1, len(ranks) + 1) - ] - step_oracle = { - step: _get_lower_bound_from_definition( - dimension, ranks, weight_divisor, max_coverage, *step - ) - for step in steps - } - assert {step: certificate.step_lower_bound(*step) for step in steps} == step_oracle - for upper_bound in range(1, 2 * dimension + 2): - candidates = 0 - for weight, count in steps: - candidates += math.comb(dimension, weight) - if step_oracle[weight, count] >= upper_bound: - break - assert certificate.work(upper_bound) == candidates - for weight in range(1, dimension + 1): - num_sets = next( - ( - count - for count in range(1, len(ranks)) - if step_oracle[weight, count] >= upper_bound - ), - len(ranks), - ) - assert certificate.get_num_sets_to_certify(weight, upper_bound) == num_sets - - # A family without sets, which the overlapping stage returns for no replacement, does no work. - assert information_sets.Certificate(3, ()).work(2) == 0 - - -def test_weight_bounds_divide_the_step_bounds(pytestconfig: pytest.Config) -> None: - """The weight bounds divide the certificate's bounds by the weight factor, rounding up. - - The certificate's bounds come from their definition, and the entries ``[dimension, j]`` for - ``j >= 1`` are ``NO_WORD_BOUND``. With a parity, each other entry is first rounded up to the - least integer of that parity that is at least the entry, except the zero before the first step. - This holds for sets of random ranks, including zero, both weight factors, and every parity. - Each call returns a table of its own. - """ - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - for _ in range(60): - dimension = int(rng.integers(0, 7)) - ranks = tuple(int(rank) for rank in rng.integers(0, dimension + 1, rng.integers(1, 5))) - divisor, coverage = int(rng.integers(1, 3)), int(rng.integers(1, 3)) - certificate = information_sets.Certificate(dimension, ranks, divisor, coverage) - for factor, parity in itertools.product((1, 2), (None, 0, 1)): - bounds = certificate.get_weight_bounds(factor, parity) - assert bounds.shape == (dimension + 1, len(ranks) + 1) - for layer, num_completed in itertools.product( - range(dimension + 1), range(len(ranks) + 1) - ): - bound = _get_lower_bound_from_definition( - dimension, ranks, divisor, coverage, layer, num_completed - ) - if parity is not None and (layer, num_completed) != (0, 0): - bound += (bound - parity) % 2 - expected = ( - information_sets.NO_WORD_BOUND - if layer == dimension and num_completed - else -(-bound // factor) - ) - assert bounds[layer, num_completed] == expected - expected_bounds = bounds.copy() - bounds[:] = -1 - assert np.array_equal(certificate.get_weight_bounds(factor, parity), expected_bounds) - - -def test_plans_follow_their_definition(pytestconfig: pytest.Config) -> None: - """The stopping step and the work of a plan follow their definitions. - - The stopping step is the first step of the given layers whose bound exceeds the limit, or the - last step if none does. At that step, each set has completed its last layer, and the work adds - the binomial of each layer from the first layer to the last layer of each set, leaving out the - sets whose rank keeps them out of the bound if unused sets are skipped. The work saturates at - the cap if a layer up to that of the step has a binomial above the cap divided by the number of - sets, or the sum reaches the cap, and no binomials give no work. - - At the stopping step of a plan over every layer, ``get_completed_layer`` gives the last layer of - each set. ``is_unused_set`` tells from the pivots of a set, which are -1 beyond its rank, - whether a plan that skips unused sets leaves the set out. - - The bounds are certificate tables of sets of every rank, including zero, a table without sets, - and the weight bounds of the coset searches, which divide them by a weight factor after rounding - them to a parity. The limits run past both ends of each table, to the largest native integer. - The binomials are random, and some exceed the share of the cap of each set. Thousands of sets - tell the cap of a layer from the cap of the sum, since the stopping step comes after the first - set of a layer whose binomial exceeds the share, while the sum stays below the cap. - """ - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - work_cap = information_sets._WORK_CAP - cases: list[tuple[npt.NDArray[np.int64], npt.NDArray[np.int64]]] = [ - (np.zeros((3, 1), dtype=np.int64), np.empty(0, dtype=np.int64)) - ] - for _ in range(20): - dimension = int(rng.integers(0, 7)) - ranks = rng.integers(0, dimension + 1, rng.integers(1, 5)) - certificate = information_sets.Certificate( - dimension, tuple(ranks.tolist()), int(rng.integers(1, 3)), int(rng.integers(1, 3)) - ) - parity = [None, 0, 1][int(rng.integers(3))] - cases.append((certificate._step_lower_bounds, ranks)) - cases.append((certificate.get_weight_bounds(int(rng.integers(1, 3)), parity), ranks)) - many_sets = np.full(3000, 2, dtype=np.int64) - cases.append((information_sets.get_step_lower_bounds(2, many_sets, 1, 1), many_sets)) - - for bounds, ranks in cases: - num_rows, num_sets = bounds.shape[0], bounds.shape[1] - 1 - no_word_bound = information_sets.NO_WORD_BOUND - finite = bounds[bounds < no_word_bound] - limits = [*range(-2, int(finite.max(initial=0)) + 3), no_word_bound - 1, no_word_bound] - binomials_choices = [ - np.empty(0, dtype=np.int64), - information_sets.get_capped_binomials(num_rows - 1), - rng.choice([1, 5, work_cap // max(num_sets, 1) + 1, 2**60], num_rows), - ] - if num_sets == len(many_sets): - # The layers before the stopping step (2, 1) sum below the cap, but its layer does not. - limits = [2 * num_sets] - binomials_choices = [np.array([1, 1, 2**50])] - for first_layer, binomials, skip_unused in itertools.product( - range(num_rows), binomials_choices, (False, True) - ): - for last_layer, limit in itertools.product(range(first_layer, num_rows), limits): - steps = [ - (layer, count) - for layer in range(first_layer, last_layer + 1) - for count in range(1, num_sets + 1) - ] - layer, num_completed = next( - (step for step in steps if bounds[step] > limit), (last_layer, num_sets) - ) - last_layers = [ - layer if index < num_completed else layer - 1 for index in range(num_sets) - ] - work = 0 - if len(binomials): - work = sum( - int(binomials[weight]) - for rank, last in zip(ranks, last_layers) - if not skip_unused or (last >= 0 and last + 1 > num_rows - 1 - rank) - for weight in range(first_layer, last + 1) - ) - if work >= work_cap or any( - binomials[weight] > work_cap // max(num_sets, 1) - for weight in range(first_layer, layer + 1) - ): - work = work_cap - expected = (layer, num_completed, work) - plan = information_sets.plan_steps( - bounds, limit, first_layer, last_layer, ranks, binomials, skip_unused - ) - assert plan == expected - dimension = num_rows - 1 - if first_layer or last_layer < dimension or skip_unused or len(binomials): - continue - step = (layer, num_completed) - pivots = np.where(np.arange(dimension) < ranks[:, np.newaxis], 0, -1) - for index, (rank, last) in enumerate(zip(ranks, last_layers)): - assert information_sets.get_completed_layer(index, *step) == last - if last >= 0: - unused = last + 1 <= dimension - rank - assert information_sets.is_unused_set(pivots, index, *step) == unused - - -def test_sets_to_certify_a_bound_may_come_from_linear_programs() -> None: - """With linear programs, fewer sets can certify a bound than the coverage bound needs. - - In the [7, 3] example of ``test_linear_programming_can_beat_the_coverage_bound``, the coverage - bound needs all five sets to certify four in layer one, and cannot certify six in layer two. - Linear programs certify them after three sets and two sets. Five needs all five sets in layer - one either way. - """ - certificate = information_sets.Certificate( - 3, - (3,) * 5, - max_coverage=3, - pivot_blocks=((0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3), (4, 5, 6)), - lp_max_denominator=DENOMINATOR, - ) - assert certificate.get_num_sets_to_certify(1, 4) == 3 - assert certificate.get_num_sets_to_certify(2, 6) == 2 - assert certificate.get_num_sets_to_certify(1, 5) == 5 - - -def test_plans_with_linear_programs_solve_their_programs_lazily( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A plan with linear programs stops at the first step that certifies, and solves lazily. - - The certificate is that of ``test_sets_to_certify_a_bound_may_come_from_linear_programs``. For - every limit and layer, the plan stops at the first step of the layer whose bound, with linear - programs, exceeds the limit, or at the last step. It solves the linear program of a step only - if that step is at or before the stopping step, and its coverage bound does not exceed the - limit. It never solves that of the last step, which stops the search anyway. Some plans solve - programs, and the work estimate of the certificate solves none, since it uses only the coverage - bound. The requirements of the program of a step identify the step. - """ - blocks = ((0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3), (4, 5, 6)) - - def get_certificate() -> information_sets.Certificate: - return information_sets.Certificate( - 3, (3,) * 5, max_coverage=3, pivot_blocks=blocks, lp_max_denominator=DENOMINATOR - ) - - reference = get_certificate() - coverage = information_sets.Certificate(3, (3,) * 5, max_coverage=3) - steps_by_requirements = { - tuple((layer if index < count else layer - 1) + 1 for index in range(5)): (layer, count) - for layer, count in itertools.product(range(1, 4), range(1, 6)) - } - solved: list[tuple[int, int]] = [] - get_lp_lower_bound = information_sets.get_lp_lower_bound - - def record( - pivot_blocks: tuple[tuple[int, ...], ...], requirements: list[int], max_denominator: int - ) -> int: - solved.append(steps_by_requirements[tuple(requirements)]) - return get_lp_lower_bound(pivot_blocks, requirements, max_denominator) - - monkeypatch.setattr(information_sets, "get_lp_lower_bound", record) - num_solved = 0 - for layer, limit in itertools.product(range(1, 4), range(9)): - steps = [(layer, count) for count in range(1, 6)] - stop = next( - (step for step in steps[:-1] if reference.step_lower_bound(*step) > limit), steps[-1] - ) - solved.clear() - assert get_certificate().plan(limit, layer) == stop[1] - assert steps[-1] not in solved - for step in solved: - assert steps.index(step) <= steps.index(stop) - assert coverage.step_lower_bound(*step) <= limit - num_solved += len(solved) - assert num_solved - - # The estimate of work solves no program. - solved.clear() - for upper_bound in range(9): - assert get_certificate().work(upper_bound) == coverage.work(upper_bound) - assert not solved - - -def test_partitioned_families_replace_costlier_ones() -> None: - """Matroid partitioning replaces a disjoint family only in costly searches, and only if cheaper. - - This [11, 4] basis has disjoint information sets of ranks four, four, and two, and information - sets that share columns certify the weight five with less work. - """ - rows = ["10001011100", "01000001100", "00100000110", "00010011001"] - basis = np.array([[int(bit) for bit in row] for row in rows], dtype=np.uint8) - labels = np.eye(len(basis), dtype=np.uint8) - disjoint = information_sets.get_disjoint_family(basis, labels, random_column_orders=ORDERS) - assert disjoint.ranks.tolist() == [4, 4, 2] - - def get_family(**changes: int) -> information_sets.BasisFamily: - fields: dict[str, Any] = {"partition_work_ratio": 0, **changes} - tuning = dataclasses.replace(DEFAULT_TUNING, **fields) - return information_sets.get_partitioned_family(basis, labels, disjoint, 5, tuning) - - # A family needs a set to hold, a coverage of two or more, and partitioning that pays. - assert not len(get_family(partition_work_ratio=2**62).ranks) - assert not len(get_family(partition_cost_factor=2**40, partition_work_ratio=1).ranks) - assert len(get_family(partition_cost_factor=0, partition_work_ratio=1).ranks) - assert not len(get_family(max_information_sets=0).ranks) - assert not len(get_family(max_partition_coverage=1).ranks) - family = get_family() - assert family.max_coverage > 1 - assert family.certificate.work(5) < disjoint.certificate.work(5) - for generators, set_labels, pivots in zip(*family[:3], strict=True): - np.testing.assert_array_equal(generators, set_labels @ basis % 2) - np.testing.assert_array_equal(generators[:, pivots], np.eye(len(basis))) - _assert_certificate_bounds_unvisited_words(basis, family) - - -def test_maximum_disjoint_families_replace_smaller_packings() -> None: - """A maximum disjoint family has the most information sets, and then the largest partial set. - - In natural column order, a greedy packing of the columns ``e1, e2, e3, e1 + e2, e1 + e2`` takes - the first three, and leaves a partial set of rank one, but ``e1 + e2, e2, e3`` leave ``e1`` and - ``e1 + e2``, of rank two. Likewise, ``e1, e2, e1 + e2, e1 + e2`` hold two disjoint information - sets, but a greedy packing finds one. The maximum families are disjoint sets, reduced on their - pivots, with valid certificates. No family replaces a packing whose sets use every nonzero - column, or one of a basis whose packings all have the same ranks. - """ - for columns, ranks in (([1, 2, 4, 3, 3], [3, 2]), ([1, 2, 3, 3], [2, 2])): - dimension = max(columns).bit_length() - basis = ((np.array(columns) >> np.arange(dimension)[:, None]) & 1).astype(np.uint8) - disjoint = information_sets.get_disjoint_family(basis, None, random_column_orders=0) - assert disjoint.ranks.tolist() == [dimension, 1] - family = information_sets.get_maximum_disjoint_family(basis, disjoint) - assert family.ranks.tolist() == ranks - pivots = family.pivots[family.pivots >= 0] - assert len(set(pivots.tolist())) == len(pivots) - for generators, set_pivots, rank in zip(family.generators, family.pivots, family.ranks): - np.testing.assert_array_equal( - generators[:, set_pivots[:rank]], np.eye(dimension)[:, :rank] - ) - _assert_certificate_bounds_unvisited_words(basis, family) - - one_full_set = np.array([[1, 1, 1, 0], [0, 0, 0, 1]], dtype=np.uint8) - for basis in (PARTIAL_BASIS, one_full_set): - disjoint = information_sets.get_disjoint_family(basis, None, random_column_orders=0) - assert not len(information_sets.get_maximum_disjoint_family(basis, disjoint).ranks) - - -def test_linear_programming_can_beat_the_coverage_bound() -> None: - """Five information sets of a [7, 3] code, where linear programming certifies five, not four. - - The columns are ``e1, e2, e3, e1 + e2 + e3, e1, e2, e3``. Four blocks are the 3-subsets of the - first four columns, and the fifth is the last three columns, so the maximum coverage is three. - After layer one, each block holds at least two ones of an unvisited word. The coverage bound is - ``ceil(10 / 3) = 4``, but ``x = 2/3`` on every column and dual weights of ``1/3`` on the first - four blocks and one on the fifth both have value ``14/3``, so the linear program certifies five. - """ - blocks = ((0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3), (4, 5, 6)) - assert information_sets.get_lp_lower_bound(blocks, [2] * 5, DENOMINATOR) == 5 - # Dual weights of thirds need a denominator of three; coarser ones round part of the bound away. - bounds = [information_sets.get_lp_lower_bound(blocks, [2] * 5, d) for d in (1, 2, 3)] - assert bounds == [2, 4, 5] - certificate = information_sets.Certificate(3, (3,) * 5, max_coverage=3) - assert certificate.lower_bound(1) == 4 - assert ( - dataclasses.replace( - certificate, pivot_blocks=blocks, lp_max_denominator=DENOMINATOR - ).lower_bound(1) - == 5 - ) - - # No word has three ones in a block of two columns, and requirements of zero ask for nothing. - assert ( - information_sets.get_lp_lower_bound([(0, 1)], [3], DENOMINATOR) - == information_sets.NO_WORD_BOUND - ) - assert information_sets.get_lp_lower_bound([(0, 1)], [0], DENOMINATOR) == 0 - - -def test_a_failed_linear_program_gives_no_bound(monkeypatch: pytest.MonkeyPatch) -> None: - """If the solver reports a failure, the bound claims nothing rather than a solver's guess.""" - failure = scipy.optimize.OptimizeResult(status=4, message="numerical difficulties") - monkeypatch.setattr(scipy.optimize, "linprog", lambda *args, **kwargs: failure) - blocks = ((0, 1, 2), (0, 1, 3), (0, 2, 3), (1, 2, 3), (4, 5, 6)) - assert information_sets.get_lp_lower_bound(blocks, [2] * 5, DENOMINATOR) == 0 - - -def test_linear_programming_certificates_bound_unvisited_words(pytestconfig: pytest.Config) -> None: - """Families of random information sets, with uneven coverage, have valid certificates.""" - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - for _ in range(20): - dimension = int(rng.integers(3, 6)) - basis = get_random_full_rank_matrix( - rng, dimension, int(rng.integers(dimension + 2, 3 * dimension)) - ) - num_sets = int(rng.integers(3, 6)) - sets = [ - row_reduce_binary(basis, None, columns=rng.permutation(basis.shape[1])) - for _ in range(num_sets) - ] - pivots = np.array([found for _, _, found in sets], dtype=np.int64) - family = information_sets.BasisFamily( - np.array([reduced for reduced, _, _ in sets]), - np.zeros((num_sets, dimension, 0), dtype=np.uint8), - pivots, - np.full(num_sets, dimension, dtype=np.int64), - information_sets.infer_weight_divisor(basis), - int(np.bincount(pivots.ravel()).max()), - ) - _assert_certificate_bounds_unvisited_words(basis, family) - - -def test_searches_of_overlapping_sets_use_linear_programming_when_costly() -> None: - """Only a costly search of overlapping sets gets the bound of linear programming.""" - basis = np.asarray(qldpc.codes.HammingCode(4).generator, dtype=np.uint8) - disjoint = information_sets.get_disjoint_family(basis, None, random_column_orders=ORDERS) - overlapping = _get_overlapping_family(basis, None, disjoint, 3, minimum_work=0) - assert overlapping.max_coverage > 1 - assert not overlapping.get_search_certificate(3, 2**62, DENOMINATOR).pivot_blocks - assert len(overlapping.get_search_certificate(3, 0, DENOMINATOR).pivot_blocks) == len( - overlapping.ranks - ) - assert not disjoint.get_search_certificate(3, 0, DENOMINATOR).pivot_blocks - - -@pytest.mark.parametrize("dimension, num_sets", [(300, 16), (60, 8192)]) -def test_work_estimates_saturate(dimension: int, num_sets: int) -> None: - """Work estimates for huge searches stay large and positive, rather than overflowing.""" - certificate = information_sets.Certificate(dimension, ranks=(dimension,) * num_sets) - assert 2**50 < certificate.work(upper_bound=10**6) < 2**63 - - -def test_binomials_are_exact_where_they_fit_and_cannot_be_modified() -> None: - binomials = information_sets.get_capped_binomials(70) - assert not binomials.flags.writeable - assert binomials[:5].tolist() == [math.comb(70, weight) for weight in range(5)] - assert 0 < binomials[35] < math.comb(70, 35) - - -@pytest.mark.parametrize("max_sets, num_sets", [(1, 0), (3, 0), (4, 4), (16, 4)]) -def test_the_size_limit_bounds_the_overlapping_basis_family(max_sets: int, num_sets: int) -> None: - """A cheaper basis family of overlapping sets needs four sets, so a lower limit finds none.""" - basis = get_independent_rows(np.asarray(qldpc.codes.HammingCode(4).generator)) - labels = np.eye(len(basis), dtype=np.uint8) - disjoint = information_sets.get_disjoint_family(basis, labels, random_column_orders=ORDERS) - overlapping = _get_overlapping_family(basis, labels, disjoint, 3, max_sets, minimum_work=0) - assert len(overlapping.ranks) == num_sets - - -def test_layers_visit_each_support_once_with_its_sums(pytestconfig: pytest.Config) -> None: - """A layer visits the combinations of rows in order, each with its sum in every set. - - The second set uses fewer words than the first, and the starting words are random. - """ - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - widths = np.array([3, 1], dtype=np.int64) - for dimension in range(1, 7): - rows = rng.integers(2**64, size=(2, dimension, 3), dtype=np.uint64) - start = rng.integers(2**64, size=(2, 3), dtype=np.uint64) - for weight in range(1, dimension + 1): - support = np.zeros(weight, dtype=np.int64) - prefixes = np.zeros((weight, 2, 3), dtype=np.uint64) - prefixes[0] = start - visited = [] - depth = information_sets.advance_support(rows, widths, support, prefixes, 0) - while depth >= 0: - sums = prefixes[depth] ^ rows[:, support[depth]] - visited.append( - (support.tolist(), [sums[jj, : widths[jj]].tolist() for jj in range(2)]) - ) - support[depth] += 1 - depth = information_sets.advance_support(rows, widths, support, prefixes, depth) - expected = [] - for indices in itertools.combinations(range(dimension), weight): - sums = start ^ np.bitwise_xor.reduce(rows[:, list(indices)], axis=1) - expected.append( - (list(indices), [sums[jj, : widths[jj]].tolist() for jj in range(2)]) - ) - assert visited == expected - - -@pytest.mark.usefixtures("plain_search_setup") -def test_functions_agree_in_plain_python_and_compiled(pytestconfig: pytest.Config) -> None: - """Pruned and replacement basis families are identical, bit for bit, plain and compiled.""" - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - # The Hamming code has an overlapping basis family that does less work. Random bases rarely do. - hamming = get_independent_rows(np.asarray(qldpc.codes.HammingCode(4).generator)) - cases: list[tuple[npt.NDArray[np.uint8], npt.NDArray[np.uint8] | None, int]] = [ - (hamming, np.eye(len(hamming), dtype=np.uint8), 3) - ] - for trial in range(12): - rows = int(rng.integers(3, 10)) - basis = get_random_full_rank_matrix(rng, rows, int(rng.integers(2 * rows, 5 * rows))) - labels = np.eye(rows, dtype=np.uint8) if trial % 2 else None - cases.append((basis, labels, int(rng.integers(2, rows + 2)))) - for case, (basis, labels, upper_bound) in enumerate(cases): - results = [] - for compiled in (False, True): - _compilation.set_on_request_functions_compiled(compiled) - try: - disjoint = information_sets.get_disjoint_family( - basis, labels, random_column_orders=ORDERS - ) - pruned = information_sets.prune_family(disjoint, upper_bound) - replacement = _get_overlapping_family( - basis, labels, pruned, upper_bound, minimum_work=0 - ) - finally: - _compilation.set_on_request_functions_compiled(False) - results.append((pruned, replacement)) - assert case or len(results[0][1].ranks) - for plain, compiled_result in zip(*results, strict=True): - for field, value in zip(plain._fields, plain, strict=True): - np.testing.assert_array_equal(value, getattr(compiled_result, field), field) - - -def _get_lower_bound_from_definition( - dimension: int, - ranks: tuple[int, ...], - weight_divisor: int, - max_coverage: int, - weight: int, - num_completed: int | None = None, -) -> int: - """The certificate lower bound once sets have completed layers, from its definition. - - The first ``num_completed`` sets have completed layer ``weight``, and the others layer ``weight - - 1``; by default, every set has completed layer ``weight``. - """ - num_completed = len(ranks) if num_completed is None else num_completed - if weight == dimension and num_completed: - # A set that has completed the last layer has visited every word. - return information_sets.NO_WORD_BOUND - completed = [weight if index < num_completed else weight - 1 for index in range(len(ranks))] - occupancy = sum(max(0, layer + 1 - (dimension - rank)) for layer, rank in zip(completed, ranks)) - bound = -(-occupancy // max_coverage) - return bound + (-bound) % weight_divisor - - -def _get_overlapping_family( - basis: npt.NDArray[np.uint8], - labels: npt.NDArray[np.uint8] | None, - basis_family: information_sets.BasisFamily, - upper_bound: int, - max_sets: int = DEFAULT_TUNING.max_information_sets, - *, - minimum_work: int = DEFAULT_TUNING.overlapping_minimum_work, -) -> information_sets.BasisFamily: - """Stage three, as a search whose tuning has these thresholds calls it.""" - tuning = SearchTuning(max_information_sets=max_sets, overlapping_minimum_work=minimum_work) - return information_sets.get_overlapping_family( - basis, - np.empty((len(basis), 0), dtype=np.uint8) if labels is None else labels, - basis_family, - upper_bound, - information_sets.get_overlapping_family_policy(tuning, len(basis)), - ) - - -def _assert_certificate_bounds_unvisited_words( - basis: npt.NDArray[np.uint8], family: information_sets.BasisFamily -) -> None: - """Check a basis family's certificates against every word that its search bases span. - - A search basis visits a word after layer ``t`` if the word combines at most ``t`` of its rows. - After the first ``j`` search bases have completed layer ``t`` and the others layer ``t - 1``, - every word that none of them has visited must weigh at least the certificate of that step, with - or without the bound of linear programming. - """ - - def get_coefficient_weights(generators: npt.NDArray[np.uint8]) -> dict[bytes, int]: - return { - (np.asarray(bits) @ generators % 2).astype(np.uint8).tobytes(): sum(bits) - for bits in itertools.product((0, 1), repeat=len(generators)) - } - - words = get_coefficient_weights(basis) - coefficient_weights = [get_coefficient_weights(generators) for generators in family.generators] - certificate = family.certificate - pivot_blocks = tuple( - tuple(int(column) for column in pivots[:rank]) - for pivots, rank in zip(family.pivots, family.ranks, strict=True) - ) - certificates = [ - certificate, - dataclasses.replace(certificate, pivot_blocks=pivot_blocks, lp_max_denominator=DENOMINATOR), - ] - # Every search basis spans the same space as the original basis. - assert all(weights.keys() == words.keys() for weights in coefficient_weights) - weights = {word: np.count_nonzero(np.frombuffer(word, dtype=np.uint8)) for word in words} - steps = [ - (layer, num_completed) - for layer in range(1, len(basis)) - for num_completed in range(len(family.ranks) + 1) - ] - for word, weight in weights.items(): - assert weight % certificate.weight_divisor == 0 - for layer, num_completed in steps: - completed = [ - layer if index < num_completed else layer - 1 for index in range(len(family.ranks)) - ] - if all(item[word] > done for item, done in zip(coefficient_weights, completed)): - for each in certificates: - assert weight >= each.step_lower_bound(layer, num_completed) - - # Deciding whether a step certifies a bound agrees with the bound of that step. - for each, (layer, num_completed) in itertools.product(certificates, steps): - bound = each.step_lower_bound(layer, num_completed) - for upper_bound in range(1, basis.shape[1] + 2): - assert each.certifies(layer, num_completed, upper_bound) == (bound >= upper_bound) diff --git a/src/qldpc/codes/distance/_tuning.py b/src/qldpc/codes/distance/_tuning.py index 2bfc221f9..40ca94939 100644 --- a/src/qldpc/codes/distance/_tuning.py +++ b/src/qldpc/codes/distance/_tuning.py @@ -100,14 +100,14 @@ below, the measured ones. To refit the cost model of completion tables, which ``predict_completion_cost`` and -``_estimate_build_cost`` in ``_search/completion_bounds.py`` compute: +``_estimate_build_cost`` in ``_information_sets/completion_bounds.py`` compute: 1. Time layers of one information set with tables disabled (``completion_bound_max_table_bytes=0``), forced (``completion_bound_minimum_layer_work=0`` and ``completion_bound_max_cost_percent=MAX_VALUE``), and at the defaults. For each layer, record the share of its supports that tables skipped and its number of lookups, which - ``scan_information_set`` in ``_search/affine_search.py`` returns for each call, and time - ``CompletionTables.build`` separately. + ``scan_information_set`` in ``_brouwer_zimmermann/affine_search.py`` returns for each call, and + time ``CompletionTables.build`` separately. 2. Express times in scanned supports, the time per support of the arm without tables. 3. Fit the coefficients that the cost model reads, ``completion_bound_lookup_block_cost``, ``completion_bound_lookup_block_cost_per_mib``, ``completion_bound_kept_first_row_ratio``, @@ -445,10 +445,10 @@ class SearchTuning: #: The cost of building completion tables, in scanned supports for each step of the #: breadth-first searches that fill them, as ``_estimate_build_cost`` in - #: ``_search/completion_bounds.py`` counts steps. Raising it makes searches decline tables that - #: are costly to build; lowering it lets them build tables that small layers do not repay. - #: Defaults to 0.2, chosen by hand with ``completion_bound_table_byte_cost``. To refit it, see - #: :ref:`Retuning `. + #: ``_information_sets/completion_bounds.py`` counts steps. Raising it makes searches decline + #: tables that are costly to build; lowering it lets them build tables that small layers do not + #: repay. Defaults to 0.2, chosen by hand with ``completion_bound_table_byte_cost``. To refit + #: it, see :ref:`Retuning `. completion_bound_build_step_cost: float = 0.2 # Fields of batches and memory limits. diff --git a/src/qldpc/codes/distance/_tuning_test.py b/src/qldpc/codes/distance/_tuning_test.py index 4b5ade0bd..ba0b39e4e 100644 --- a/src/qldpc/codes/distance/_tuning_test.py +++ b/src/qldpc/codes/distance/_tuning_test.py @@ -26,9 +26,10 @@ from . import _tuning, core from . import logical_counts as lc +from ._brouwer_zimmermann import distance as bz +from ._connected_support import search as connected_support from ._counting import scan -from ._search import brouwer_zimmermann as bz -from ._search import connected_support, information_sets +from ._information_sets import basis_families from .conftest import CostlyStages, get_random_css_code, get_sector_problem @@ -143,7 +144,7 @@ def test_tunings_change_routes_but_not_results(costly_stages: CostlyStages) -> N By default, and with every threshold below at ``MAX_VALUE``, the distance searches of this code enumerate every word. With every threshold below at zero, the searches take a family found by matroid partitioning instead. These searches never scan one information set at a time, so the - tests of ``_search/brouwer_zimmermann_test.py`` force completion tables instead. + tests of ``_brouwer_zimmermann/distance_test.py`` force completion tables instead. """ code = get_random_css_code(np.random.default_rng(2), 24, 8, 8) eager = { @@ -259,11 +260,11 @@ def test_every_field_reaches_the_code_that_reads_it(monkeypatch: pytest.MonkeyPa Tuning keeps results, so recording the arguments of each reader is what shows that a value arrives. The tuning takes every route, with values that differ from the defaults. Fields that this test does not follow are read in one of two ways. Some are read within one function from - the tuning that it receives, and their tests in ``_search/completion_bounds_test.py`` and - ``_search/information_sets_test.py`` change them there. Others are read once by + the tuning that it receives, and their tests in ``_information_sets/completion_bounds_test.py`` + and ``_information_sets/basis_families_test.py`` change them there. Others are read once by ``CosetLeaderSearch``, which passes their values down to its coset searches: ``max_css_single_pairs``, ``max_sparsifying_rounds``, and - ``sparsifying_rounds_without_improvement``. Tests in ``_search/coset_leader_search_test.py`` + ``sparsifying_rounds_without_improvement``. Tests in ``_coset_leader_search_test.py`` change or record these, and the fields that the oracle passes down to the routes of its BZ searches. """ @@ -282,10 +283,10 @@ def recording(*args: Any, **kwargs: Any) -> Any: monkeypatch.setattr(owner, name, recording) record(bz, "enumeration_is_cheaper_than_setup", "max_dimension", "setup_cost_factor") - record(information_sets, "_get_disjoint_sets", "random_column_orders") - record(information_sets, "get_overlapping_family_policy", "tuning", prefix="overlapping ") - record(information_sets, "get_partitioned_family", "tuning") - record(information_sets.BasisFamily, "get_search_certificate", "lp_max_denominator") + record(basis_families, "_get_disjoint_sets", "random_column_orders") + record(basis_families, "get_overlapping_family_policy", "tuning", prefix="overlapping ") + record(basis_families, "get_partitioned_family", "tuning") + record(basis_families.BasisFamily, "get_search_certificate", "lp_max_denominator") record(bz, "get_nested_distance_brute_force", "batch_exponent", prefix="bz ") record(core, "get_distance_quantum_brute_force", "batch_exponent", prefix="classical ") record(core, "get_nested_distance_brute_force", "batch_exponent", prefix="quantum ") diff --git a/src/qldpc/codes/distance/brouwer_zimmermann.md b/src/qldpc/codes/distance/brouwer_zimmermann.md index b57a08de1..7747ef89e 100644 --- a/src/qldpc/codes/distance/brouwer_zimmermann.md +++ b/src/qldpc/codes/distance/brouwer_zimmermann.md @@ -583,7 +583,7 @@ Verification of a lifted permutation therefore checks the original one. ### 3.8 Worked example -This example uses one CSS sector of the MB64 code from the test suite ([_search/brouwer_zimmermann_test.py](_search/brouwer_zimmermann_test.py)). +This example uses one CSS sector of the MB64 code from the test suite ([_brouwer_zimmermann/distance_test.py](_brouwer_zimmermann/distance_test.py)). The sector has these properties: - It has 64 coordinates and dimension `r = 41`. @@ -717,7 +717,7 @@ A search for the minimum weight of a coset skips at `h(ω)` too until it finds a #### When a search uses the tables -Every layer of two or more rows of a distance search that scans one information set uses `scan_information_set` of [_search/affine_search.py](_search/affine_search.py), a scan specialized to that case, with or without tables. +Every layer of two or more rows of a distance search that scans one information set uses `scan_information_set` of [_brouwer_zimmermann/affine_search.py](_brouwer_zimmermann/affine_search.py), a scan specialized to that case, with or without tables. A count scans with its own kernels in [_counting/scan.py](_counting/scan.py), which use the tables as described below. Tables of a basis with `r = 150` rows and 138 tail columns, in blocks of sixteen columns, take 75 MiB, and about a twentieth of a second to build on the machine described below. That is worthwhile for a layer of `10^10` supports, but not for an easy search. @@ -749,7 +749,7 @@ The cost ratio of a layer is its time with tables, including their construction, The coefficients of the cost model are fields of `SearchTuning` in [_tuning.py](_tuning.py), whose module docstring says on what layers they were fitted and how to refit them. For the X sector of the [[288, 12, 18]] code above, with `U = 18` and the scanned set and row order that qLDPC chooses, layer six had a cost ratio of 0.22 under the default tuning, and its tables skipped 87% of its supports. On nine layers of `5 × 10^8` to `1.5 × 10^10` supports of that code and of three pair-partition codes, the scan of one information set without tables took 70% to 95% of the time of the scan of any family of sets. -These two scans were measured as the kernels `_scan_one_information_set` and `_scan_brouwer_zimmermann`, before the distance and coset searches shared their scans; they are now `scan_information_set` and `scan_family` of [_search/affine_search.py](_search/affine_search.py), which scan in the same order. +These two scans were measured as the kernels `_scan_one_information_set` and `_scan_brouwer_zimmermann`, before the distance and coset searches shared their scans; they are now `scan_information_set` and `scan_family` of [_brouwer_zimmermann/affine_search.py](_brouwer_zimmermann/affine_search.py), which scan in the same order. With tables forced, by `completion_bound_minimum_layer_work` set to zero and `completion_bound_max_cost_percent` set to `MAX_VALUE`, three of four layers of `2 × 10^7` to `5 × 10^7` supports, of that code and of two pair-partition codes, were slower, with cost ratios up to 1.6, and the cost model predicted ratios above one for all four. Such small layers gain little even when tables pay, and estimating the share of skipped supports takes milliseconds, so a search considers tables only for layers of at least `completion_bound_minimum_layer_work` supports. The share of skipped supports depends on the code, the scanned set, and the order of its rows, and other codes may gain less, or nothing when a search declines the tables. @@ -816,34 +816,34 @@ The rest of this section describes how the searches use those modules. [`__init__.py`](__init__.py) exports names from [core.py](core.py) and [logical_counts.py](logical_counts.py). [logical_counts.py](logical_counts.py) counts the logical operators of a given weight exactly. -It builds its families and certificates with the same stages of [_search/information_sets.py](_search/information_sets.py) as the distance search, under the constraint in [Section 5](#5-correctness-requirements). +It builds its families and certificates with the same stages of [_information_sets/basis_families.py](_information_sets/basis_families.py) as the distance search, under the constraint in [Section 5](#5-correctness-requirements). It runs the stages in `_select_basis_family` and `count_words` of [_counting/scan.py](_counting/scan.py), and its distance search starts from the disjoint basis families that it keeps for counting. The set-up functions of both searches can be compiled on request, through [_compilation.py](../../_compilation.py). The code that only counting uses is in the `_counting` package. -`get_overlapping_family_policy` in [_search/information_sets.py](_search/information_sets.py) gives every caller of the overlapping stage its thresholds and its fixed-seed generator, so a change to either applies to the distance search, the counts, and the coset routes of [_search/coset_routes.py](_search/coset_routes.py). -[core.py](core.py) calls [_search/brouwer_zimmermann.py](_search/brouwer_zimmermann.py), [_search/brute_force.py](_search/brute_force.py), and [_search/connected_support.py](_search/connected_support.py), which [connected_support.md](connected_support.md) describes, and [_search/brouwer_zimmermann.py](_search/brouwer_zimmermann.py) calls [_search/brute_force.py](_search/brute_force.py) for its exhaustive routes. -All four modules use [_binary.py](_binary.py), and they and [_search/information_sets.py](_search/information_sets.py) use the bit-packed linear algebra over GF(2) in [qldpc/_binary.py](../../_binary.py). +`get_overlapping_family_policy` in [_information_sets/basis_families.py](_information_sets/basis_families.py) gives every caller of the overlapping stage its thresholds and its fixed-seed generator, so a change to either applies to the distance search, the counts, and the coset routes of [_brouwer_zimmermann/coset_routes.py](_brouwer_zimmermann/coset_routes.py). +[core.py](core.py) calls [_brouwer_zimmermann/distance.py](_brouwer_zimmermann/distance.py), [_brute_force.py](_brute_force.py), and [_connected_support/search.py](_connected_support/search.py), which [connected_support.md](connected_support.md) describes, and [_brouwer_zimmermann/distance.py](_brouwer_zimmermann/distance.py) calls [_brute_force.py](_brute_force.py) for its exhaustive routes. +All four modules use [_binary.py](_binary.py), and they and [_information_sets/basis_families.py](_information_sets/basis_families.py) use the bit-packed linear algebra over GF(2) in [qldpc/_binary.py](../../_binary.py). Every module with compiled functions uses [_compilation.py](../../_compilation.py). `_get_distance_css_brouwer_zimmermann` of [core.py](core.py) builds an adapted basis ([Section 3.4](#34-quotient-labels-for-the-stabilizer-test)) for each nontrivial sector, then passes the resulting problems to `get_distance_brouwer_zimmermann_many`. It also passes a function that returns automorphisms of the code. `get_distance_exact` of `CSSCode` in `src/qldpc/codes/common.py` passes the code's `_found_qudit_automorphisms`, which uses bliss from `src/qldpc/external/_bliss.py` or the code's `_get_qudit_automorphisms` method. `get_distance_exact` of `QuditCode` passes the same function to `_get_distance_quantum`, a private form of `get_distance_quantum` that accepts automorphisms. `_get_distance_quantum` lifts them to the coordinates of a general Pauli problem with `_get_lifted_automorphisms`, and passes its problem to `get_distance_brouwer_zimmermann_many`. -[_search/automorphism_families.py](_search/automorphism_families.py) verifies automorphisms and builds the automorphism families of [Section 3.7](#37-images-of-an-information-set-under-automorphisms), using [_search/information_sets.py](_search/information_sets.py) for their certificates. -Distance searches and counts use [_search/completion_bounds.py](_search/completion_bounds.py) to choose and build the tables of [Section 3.9](#39-skipping-completions-by-completion-tables), and coset searches use it to choose and build the tables of their routes by tails. -The distance search and `CosetLeaderSearch` scan their layers with the compiled entry points of [_search/affine_search.py](_search/affine_search.py), which run one body, `_scan`, with constant flags. +[_information_sets/automorphism_families.py](_information_sets/automorphism_families.py) verifies automorphisms and builds the automorphism families of [Section 3.7](#37-images-of-an-information-set-under-automorphisms), using [_information_sets/basis_families.py](_information_sets/basis_families.py) for their certificates. +Distance searches and counts use [_information_sets/completion_bounds.py](_information_sets/completion_bounds.py) to choose and build the tables of [Section 3.9](#39-skipping-completions-by-completion-tables), and coset searches use it to choose and build the tables of their routes by tails. +The distance search and `CosetLeaderSearch` scan their layers with the compiled entry points of [_brouwer_zimmermann/affine_search.py](_brouwer_zimmermann/affine_search.py), which run one body, `_scan`, with constant flags. `scan_family` scans every set of a family in lockstep, `scan_information_set` scans one information set with or without completion tables, and `scan_coset_layer` and `scan_coset_layer_by_css_weight` scan one layer of one set of a coset and keep its lightest word. `scan_coset_layer_by_tails` scans one layer of one set of a coset by the tails of its words, without the pivot columns of the set ([Section 3.5](#35-packed-enumeration-with-pivot-column-deletion)), and keeps the support of its lightest word. `scan_coset_layer_by_tails_with_tables` does the same for a layer of two or more rows, and skips the completions of a prefix by tables if their bound reaches the lesser of a skip bound and the best weight so far ([Section 3.9](#39-skipping-completions-by-completion-tables)). So a query skips at its skip bound, since its scans return before their best weight falls below it, and a search for minimum weights of [Section 3.10](#310-minimum-weights-of-cosets) skips at the best weight, which never exceeds its skip bound. Numba compiles the body separately for each entry point, so no flag costs a branch for each word. -Each search builds an `AffineInstance` of [_search/affine_instance.py](_search/affine_instance.py) for its problem before it scans, rather than for each layer or query. +Each search builds an `AffineInstance` of [_brouwer_zimmermann/affine_instance.py](_brouwer_zimmermann/affine_instance.py) for its problem before it scans, rather than for each layer or query. The instance holds the packed rows, labels, and pivots of the sets that the search scans, the ranks of those sets, and the certificate of their steps. The weight divisor of the certificate rounds its Hamming bounds up to multiples of a proven divisor of eligible weights. `get_distance_instance` packs a basis family with its pivot columns deleted ([Section 3.5](#35-packed-enumeration-with-pivot-column-deletion)), and takes the certificate that the distance search selects. The distance search builds another instance when it switches to an automorphism family, whose certificate covers every image of the one set that it scans. -`CosetLeaderSearch` in [_search/coset_leader_search.py](_search/coset_leader_search.py), which the fault-propagation certifier in `qldpc.circuits.certification` uses, applies the same search to a coset `u + V` instead of `V`, unless it is built with `method="connected_support"` ([connected_support.md](connected_support.md)). +`CosetLeaderSearch` in [_coset_leader_search.py](_coset_leader_search.py), which the fault-propagation certifier in `qldpc.circuits.certification` uses, applies the same search to a coset `u + V` instead of `V`, unless it is built with `method="connected_support"` ([connected_support.md](connected_support.md)). There a layer adds `w` rows to an **offset**, a word of the coset that is zero on the pivot columns ([Section 2.3](#23-systematic-bases-and-information-sets)), so layer zero is the offset itself. A **query** of `CosetLeaderSearch` asks whether the coset of a syndrome has a word of weight at most a given maximum weight. `CosetLeaderSearch` answers each query with one or two **coset searches**: a search of Pauli errors, or, for the CSS weight on a CSS state, searches of their X and Z parts, as described at the end of this section. @@ -860,7 +860,7 @@ A query of a coset search asks the same of a coset of its code, whose words have It starts from the **word of its syndrome**, a word of the coset that sums the rows of a right inverse of the checks for the set bits of the syndrome. It stops at its **stopping step**, the first step whose bound exceeds the maximum weight, unless it finds a light enough word sooner. It skips each rank-deficient search basis whose deficiency keeps it out of the bound of that step. -`find_stopping_step` of [_search/information_sets.py](_search/information_sets.py) finds the stopping steps of the coset searches, and of the distance searches whose certificates solve no linear programs, by a binary search of steps whose bounds never decrease. +`find_stopping_step` of [_information_sets/basis_families.py](_information_sets/basis_families.py) finds the stopping steps of the coset searches, and of the distance searches whose certificates solve no linear programs, by a binary search of steps whose bounds never decrease. A certificate that solves linear programs checks steps in order instead, through `Certificate.plan` (see below). `plan_steps` also adds up the work of the steps up to the stopping step ([Section 3.6](#36-exhaustive-enumeration-as-a-fallback)). Each family of a coset search keeps the bounds of its steps in a **planning buffer**, with a table for each parity of the cosets if the search rounds to parity, and one table otherwise. @@ -869,7 +869,7 @@ Each table has one more row that caches a stopping step. For a nonnegative maximum weight, `_get_stopping_step` reuses the cached step of a table exactly while it stays the stopping step of that weight, and replaces it otherwise. `get_min_weight_bound` first looks up the words of weight at most one. If there is none and its maximum weight is below two, it gives the lower bound two without a search, or three for an odd coset in a search of X or Z parts that rounds to parity. -Otherwise, a query takes the tables of the **route** of its maximum weight, which `CosetRoutes` of [_search/coset_routes.py](_search/coset_routes.py) chooses once for each maximum weight ([Section 4.4](#44-heuristics-and-cost-models)). +Otherwise, a query takes the tables of the **route** of its maximum weight, which `CosetRoutes` of [_brouwer_zimmermann/coset_routes.py](_brouwer_zimmermann/coset_routes.py) chooses once for each maximum weight ([Section 4.4](#44-heuristics-and-cost-models)). A route is a family of search bases of the code, with its coset instance and planning buffer: the disjoint sets, or a family that the routes build for costly queries. `find_first_not_within_max_weight` answers a batch with the route of its maximum weight, which it takes once, and the batch of the X parts and that of the Z parts each take their own. `_get_min_weights` of `CosetLeaderSearch` finds the minimum weights of [Section 3.10](#310-minimum-weights-of-cosets) for the residual-weight estimator. @@ -911,7 +911,7 @@ An integer returned by preparation can incorporate a witness from another sector | `method="brute_force"` | Runs exhaustive enumeration over an independent basis, adapted to `S ⊆ V` for a quantum problem | `_prepare_brouwer_zimmermann_search` considers the following exits in order. -The disjoint family, its pruning, and any overlapping or partitioned replacement come from the stages of [_search/information_sets.py](_search/information_sets.py); the exits between those stages belong to the distance search. +The disjoint family, its pruning, and any overlapping or partitioned replacement come from the stages of [_information_sets/basis_families.py](_information_sets/basis_families.py); the exits between those stages belong to the distance search. | Preparation stage | Outcome | | --- | --- | @@ -942,9 +942,9 @@ The tests `test_brouwer_zimmermann_setup_exits`, `test_brouwer_zimmermann_setup_ | Lower certificate `L` | `lower_bound` of a search, computed by `Certificate.lower_bound(completed_weight)` from the certificate's `dimension`, `ranks`, `weight_divisor`, and `max_coverage`; it bounds the eligible words lighter than `U`, so a bound on the distance is `min(L, U)` | | Certificate within a layer `L(t, j)` | `Certificate.step_lower_bound(weight, num_completed)` and the table of `get_step_lower_bounds`. `Certificate.get_num_sets_to_certify` gives the smallest `j` that certifies a bound. `find_stopping_step` gives the first step whose bound exceeds a limit, the stopping step, and `plan_steps` also the work of the steps up to it. `Certificate.plan` gives the stopping step of one layer, with linear programs if the certificate solves them. `Certificate.get_weight_bounds` divides the table by a weight factor, rounding up, after rounding it up to a parity if it is given one. A coset search keeps the results in its planning buffer, `planning` of `_CosetSearchTables`: a table for each parity of its cosets if it rounds to parity, and one table otherwise, each with a cached stopping step. | | Certificate from linear programming | `get_lp_lower_bound`, which a `Certificate` with `pivot_blocks` combines with the coverage-divided certificate; `BasisFamily.get_search_certificate` adds the blocks to costly searches of overlapping families | -| Family of search bases | `BasisFamily`, which stacks the search bases into arrays and gives their `Certificate`. A distance search keeps the `AffineInstance` of [_search/affine_instance.py](_search/affine_instance.py) that packs them, with the certificate. A coset search keeps the generators, pivots, and planning buffer of each family of its routes in `_CosetSearchTables`, and the tails and completion tables of the family of each route by tails in `_CosetTails`. | +| Family of search bases | `BasisFamily`, which stacks the search bases into arrays and gives their `Certificate`. A distance search keeps the `AffineInstance` of [_brouwer_zimmermann/affine_instance.py](_brouwer_zimmermann/affine_instance.py) that packs them, with the certificate. A coset search keeps the generators, pivots, and planning buffer of each family of its routes in `_CosetSearchTables`, and the tails and completion tables of the family of each route by tails in `_CosetTails`. | | Automorphisms, generators of `G` | `automorphisms`, whose row `i` maps column `j` to column `automorphisms[i, j]`, as returned by `get_automorphisms`; `_found_qudit_automorphisms` and `_get_qudit_automorphisms` of `QuditCode` | -| Automorphism family | `get_automorphism_family` of [_search/automorphism_families.py](_search/automorphism_families.py), which returns a `BasisFamily` of the scanned set and the `Certificate` of all of its images; `_switch_to_automorphism_family` adopts them, and sets `uses_automorphism_family` of the search | +| Automorphism family | `get_automorphism_family` of [_information_sets/automorphism_families.py](_information_sets/automorphism_families.py), which returns a `BasisFamily` of the scanned set and the `Certificate` of all of its images; `_switch_to_automorphism_family` adopts them, and sets `uses_automorphism_family` of the search | | Last completed layer `t`, next layer | `completed_weight`, `next_weight` | | Divisor `D` | `Certificate.weight_divisor`, in Hamming units of the searched basis. Coset searches take no divisor, but those of even codes with odd cosets round to the parity of each coset (`_get_search_tables`). | | Maximum coverage `C` | `Certificate.max_coverage` | @@ -954,12 +954,12 @@ The tests `test_brouwer_zimmermann_setup_exits`, `test_brouwer_zimmermann_setup_ | Stored width `N_stored` | `basis.shape[1]` before pivot deletion, including any all-zero columns | | Labels `M` | `labels`, which has no columns for a classical problem | | Eligible rows | `get_eligible_rows` of [_binary.py](_binary.py): every row of a classical basis; rows with nonzero labels for a quantum problem | -| Support, prefix XORs | `support` and `prefixes` in `advance` and in the scans of [_search/affine_search.py](_search/affine_search.py), which step through a layer with `advance_support` of [_search/information_sets.py](_search/information_sets.py) | +| Support, prefix XORs | `support` and `prefixes` in `advance` and in the scans of [_brouwer_zimmermann/affine_search.py](_brouwer_zimmermann/affine_search.py), which step through a layer with `advance_support` of [_information_sets/basis_families.py](_information_sets/basis_families.py) | | Packed generators | `generators` of the `AffineInstance` of a distance search: `uint64` words, after deleting pivot columns. `widths` counts the valid words for each search basis. | | Packed labels | `labels` of the `AffineInstance` of a distance search: `uint64` quotient-label words, without pivot-column deletion; `label_widths` counts the valid words | | Chunk | `batch_size`, equal to `1 << information_set_batch_exponent` supports, or supports and lookups together in a scan with completion tables ([Section 4.5](#45-behavior-spanning-files)) | | Candidate/set evaluation | The unit of the work of `plan_steps`, which `Certificate.work` counts | -| Completion tables ([Section 3.9](#39-skipping-completions-by-completion-tables)) | `CompletionTables` of [_search/completion_bounds.py](_search/completion_bounds.py), chosen for each layer of one information set by `select_completion_tables` from the completion fields of the search's `SearchTuning` and kept in `completion_tables` of a search, and for each set of a layer that scans several sets one at a time by `_choose_tables_of_sets`; the scan is `scan_information_set` of [_search/affine_search.py](_search/affine_search.py), which returns the numbers of supports that it weighs and skips and of bounds that it looks up. A coset search keeps the tables of the search bases of the family of each route by tails in `_CosetTails`. Its scan with tables is `scan_coset_layer_by_tails_with_tables`. | +| Completion tables ([Section 3.9](#39-skipping-completions-by-completion-tables)) | `CompletionTables` of [_information_sets/completion_bounds.py](_information_sets/completion_bounds.py), chosen for each layer of one information set by `select_completion_tables` from the completion fields of the search's `SearchTuning` and kept in `completion_tables` of a search, and for each set of a layer that scans several sets one at a time by `_choose_tables_of_sets`; the scan is `scan_information_set` of [_brouwer_zimmermann/affine_search.py](_brouwer_zimmermann/affine_search.py), which returns the numbers of supports that it weighs and skips and of bounds that it looks up. A coset search keeps the tables of the search bases of the family of each route by tails in `_CosetTails`. Its scan with tables is `scan_coset_layer_by_tails_with_tables`. | For the rank-deficient remainder of [Section 2.4](#24-search-bases-and-rank-deficient-blocks), the implementation records only the independent pivot columns, in the first `rank` entries of the set's row of `BasisFamily.pivots`, and pads the rest with -1. The remaining columns of that remainder stay in the generator matrix. @@ -987,14 +987,14 @@ The Tuning section of [README.md](README.md) explains how searches read the thre Otherwise it builds search bases by row reducing on column orders that put the least-used columns first, breaking ties with a generator that `get_overlapping_family_policy` seeds afresh for every call, and keeps the cheapest family built. It makes `overlapping_attempts_per_set` attempts for each set that it may add, and a repeated pivot set is discarded but still consumes an attempt. - **`get_partitioned_family`** builds families of information sets in which no column appears more than `b` times, for `b` from two to `max_partition_coverage`. - It uses the matroid partitioning of Section 5.1 of Lisoněk and Trummer (see [References](#references)), in `_search/matroid_partition.py`, so each family has the most information sets, up to `max_information_sets`, that its coverage allows. + It uses the matroid partitioning of Section 5.1 of Lisoněk and Trummer (see [References](#references)), in `_information_sets/matroid_partition.py`, so each family has the most information sets, up to `max_information_sets`, that its coverage allows. A search uses the family with the least estimated work if it does less work than the family of the earlier stages. It tries this only if its estimated work is at least `partition_work_ratio` times an estimate of the work of partitioning, and the optimistic coverage `ceil(m*r/N)` does not rule out an improvement. - **`get_search_certificate`** adds the certificate from linear programming ([Section 3.1](#31-overlapping-search-bases)) to searches of overlapping families whose estimated work reaches `lp_minimum_work`, since smaller searches cost less than their linear programs. The work estimates that choose families use only the coverage-divided certificate. - **`get_automorphism_family`** row reduces in natural column order, then exchanges pivots between orbits in the compiled `_balance_orbit_shares`, as [Section 3.7](#37-images-of-an-information-set-under-automorphisms) explains. A search asks for automorphisms before the first layer whose scan would cost at least `automorphism_minimum_layer_work`, and switches to the automorphism family if `estimate_automorphism_family_work` is less than the `Certificate.work` of its family. -- **`CosetRoutes`** chooses the family with which a coset search answers each maximum weight `ω`, as the module docstring of [_search/coset_routes.py](_search/coset_routes.py) explains. +- **`CosetRoutes`** chooses the family with which a coset search answers each maximum weight `ω`, as the module docstring of [_brouwer_zimmermann/coset_routes.py](_brouwer_zimmermann/coset_routes.py) explains. The work of a table of a search at `ω` is the work of `plan_steps` from layer zero, without the search bases that a query skips. Besides the word of its syndrome, a query at `ω` with no light enough word examines or skips as many words as the work of the table of its parity. So its predicted examinations without tables are one more than the largest work of a table. @@ -1109,7 +1109,7 @@ Changes to the BZ algorithm can use different representations, certificates, and A search for the minimum weight of a coset up to `ω` may stop only at a step whose bound reaches `U`, the lesser of `ω + 1` and the weight of its lightest word, or within a step once that weight is at most the bound of the step before or the floor of the search ([Section 3.10](#310-minimum-weights-of-cosets)). The floor is the weight of the X part of a class in the search of its Z part, for the CSS weight on a CSS state, and zero otherwise. The scans of the search may keep only the words lighter than the incumbent of that section, and its tables may skip only the completions whose bound reaches it: `h(ω)` until it finds a word within `ω`, and then the Hamming weight of its lightest word, less one only where every word of the coset has the parity of that word. - `test_min_weights_of_cosets_match_brute_force` and `test_min_weights_match_exhaustive_minima` in [_search/coset_leader_search_test.py](_search/coset_leader_search_test.py) check the weights against every word of each coset, on even codes and with tables, and `test_min_weight_searches_above_max_weight_examine_as_queries` checks that a search of a coset without a word within `ω` weighs and skips as many words as a query at `ω`. + `test_min_weights_of_cosets_match_brute_force` and `test_min_weights_match_exhaustive_minima` in [_coset_leader_search_test.py](_coset_leader_search_test.py) check the weights against every word of each coset, on even codes and with tables, and `test_min_weight_searches_above_max_weight_examine_as_queries` checks that a search of a coset without a word within `ω` weighs and skips as many words as a query at `ω`. - **Keep the strict certificate available to fixed-weight counting.** [logical_counts.py](logical_counts.py) builds its basis family with the same stages as the distance search, in `_select_basis_family` and `count_words` of [_counting/scan.py](_counting/scan.py), and stops at the first step after which that basis family's certificate exceeds a target weight `w`, to count every word of weight `w`, which defaults to the distance. Distance may stop at `L(t, j) ≥ U`, but counting must also exhaust words of weight exactly `w`, so it stops only at `L(t, j) > w`. @@ -1120,21 +1120,21 @@ Changes to the BZ algorithm can use different representations, certificates, and The family that answers a coset query, and so its representative and lower bound, depends only on the code, the maximum weight, and the tuning, not on earlier queries. So do the completion tables that it reads: the record of a route can hold tables that earlier routes of its family added, but a query reads only those of the search bases that it scans in layers of two or more rows, which its own route asks for. This matters because the counterexamples of the fault-propagation certifier report the representative and the bound. - `test_answers_do_not_depend_on_earlier_queries` in [_search/coset_leader_search_test.py](_search/coset_leader_search_test.py) checks this. - The tests of routes by tails in [_search/coset_routes_test.py](_search/coset_routes_test.py) check that a route asks for the tables of the search bases that its queries scan in layers of two or more rows, and that the latest record of its family holds them, even when threads choose routes at once. + `test_answers_do_not_depend_on_earlier_queries` in [_coset_leader_search_test.py](_coset_leader_search_test.py) checks this. + The tests of routes by tails in [_brouwer_zimmermann/coset_routes_test.py](_brouwer_zimmermann/coset_routes_test.py) check that a route asks for the tables of the search bases that its queries scan in layers of two or more rows, and that the latest record of its family holds them, even when threads choose routes at once. - **Preserve the return contract.** Return the exact minimum unless taking an early exit permitted by `cutoff`. Such an early exit returns the weight of a known eligible witness, an upper bound between the distance and `cutoff` ([Section 4.5](#45-behavior-spanning-files)). Under a weight limit, return the smaller of the distance and `max_weight+1`; an early exit must still return a witness within the limit. -Representative correctness tests check certificate validity against exhaustive words, including rank-deficient blocks, inferred parity, and overlapping coverage (`test_family_certificates` and `test_overlapping_family_certificates` in [_search/information_sets_test.py](_search/information_sets_test.py)), and joint-search results with distinct sector certificates (`test_brouwer_zimmermann_sector_certificates` in [_search/brouwer_zimmermann_test.py](_search/brouwer_zimmermann_test.py)). +Representative correctness tests check certificate validity against exhaustive words, including rank-deficient blocks, inferred parity, and overlapping coverage (`test_family_certificates` and `test_overlapping_family_certificates` in [_information_sets/basis_families_test.py](_information_sets/basis_families_test.py)), and joint-search results with distinct sector certificates (`test_brouwer_zimmermann_sector_certificates` in [_brouwer_zimmermann/distance_test.py](_brouwer_zimmermann/distance_test.py)). `test_counting_continues_at_an_equal_lower_bound` in [logical_counts_test.py](logical_counts_test.py) checks the strict counting stop with a count that a stop at `L(t) = w` would miss, and `test_counts_with_completion_tables_match_enumeration` in [_counting/scan_test.py](_counting/scan_test.py) checks counts with completion tables forced against enumeration, which counts that skipped at equality would miss. -`test_automorphism_families_certify_like_the_family_of_all_images` in [_search/automorphism_families_test.py](_search/automorphism_families_test.py) checks the certificate of an automorphism family against every image of the scanned set, and `test_automorphism_family_searches_scan_every_layer_they_certify` in [_search/brouwer_zimmermann_test.py](_search/brouwer_zimmermann_test.py) checks a search of an automorphism family whose lightest word appears only in its last layer. +`test_automorphism_families_certify_like_the_family_of_all_images` in [_information_sets/automorphism_families_test.py](_information_sets/automorphism_families_test.py) checks the certificate of an automorphism family against every image of the scanned set, and `test_automorphism_family_searches_scan_every_layer_they_certify` in [_brouwer_zimmermann/distance_test.py](_brouwer_zimmermann/distance_test.py) checks a search of an automorphism family whose lightest word appears only in its last layer. `test_automorphism_family_searches_scan_their_new_set_from_layer_two` checks a distance that a search that switched to an automorphism family would miss if it skipped the early layers of its new set. `test_searches_ask_for_automorphisms_only_before_costly_layers` checks that easy searches do not pay to find automorphisms. -`test_bounds_are_exact_relaxations` in [_search/completion_bounds_test.py](_search/completion_bounds_test.py) checks completion bounds against enumerated completions. -`test_scan_of_one_information_set` in [_search/affine_search_test.py](_search/affine_search_test.py) checks that the scan finds the lightest word of a layer and weighs or skips each support exactly once, and `test_completion_tables_keep_distances_exact` in [_search/brouwer_zimmermann_test.py](_search/brouwer_zimmermann_test.py) checks whole searches against brute force. -`test_coset_tail_scans_with_tables_skip_only_completions_that_reach_the_skip_bound` in [_search/affine_search_test.py](_search/affine_search_test.py) checks the bound of coset scans, including search bases of rank deficiency three, against enumeration, and `test_searches_by_tails_answer_as_searches_of_whole_words` in [_search/coset_leader_search_test.py](_search/coset_leader_search_test.py) checks the answers, skipped words, and bounds `R` of coset queries with tables, also on even codes, against searches without tables and brute force. +`test_bounds_are_exact_relaxations` in [_information_sets/completion_bounds_test.py](_information_sets/completion_bounds_test.py) checks completion bounds against enumerated completions. +`test_scan_of_one_information_set` in [_brouwer_zimmermann/affine_search_test.py](_brouwer_zimmermann/affine_search_test.py) checks that the scan finds the lightest word of a layer and weighs or skips each support exactly once, and `test_completion_tables_keep_distances_exact` in [_brouwer_zimmermann/distance_test.py](_brouwer_zimmermann/distance_test.py) checks whole searches against brute force. +`test_coset_tail_scans_with_tables_skip_only_completions_that_reach_the_skip_bound` in [_brouwer_zimmermann/affine_search_test.py](_brouwer_zimmermann/affine_search_test.py) checks the bound of coset scans, including search bases of rank deficiency three, against enumeration, and `test_searches_by_tails_answer_as_searches_of_whole_words` in [_coset_leader_search_test.py](_coset_leader_search_test.py) checks the answers, skipped words, and bounds `R` of coset queries with tables, also on even codes, against searches without tables and brute force. In [core_test.py](core_test.py), `test_brouwer_zimmermann_random_cross_checks` forces the BZ route and compares both homogeneous and general Pauli problems with exhaustive results. `test_max_weight` checks weight-limited results against enumeration on every route, and `test_max_weight_caps_cutoff` checks a case that a weight limit without the `cutoff` cap would get wrong. @@ -1147,5 +1147,5 @@ Those tests can evolve with the implementation; they do not make a particular in - M. Grassl, "Searching for linear codes with large minimum distance," in *Discovering Mathematics with Magma*, Springer, 2006, pp. 287-313 ([doi:10.1007/978-3-540-37634-7_13](https://doi.org/10.1007/978-3-540-37634-7_13)): Algorithm 2.5 gives the certificate for overlapping information sets, footnote 2 on page 295 the certificate within a layer, page 299 the rounding to a weight divisor, and Algorithm 2.11 the choice between BZ and the weight distribution. - P. Lisoněk and L. Trummer, "Algorithms for the minimum weight of linear codes," *Advances in Mathematics of Communications* 10(1), 195-207, 2016 ([doi:10.3934/amc.2016.10.195](https://doi.org/10.3934/amc.2016.10.195)): equation (5) gives the certificate within a layer, Proposition 5 a coverage-divided certificate for any blocks, and Section 5.1 builds families of information sets with bounded coverage by matroid partitioning. - [arXiv:2408.10743v2](https://arxiv.org/abs/2408.10743v2): Section 2.4 describes the `Saved_isometry` symplectic-to-Hamming construction. -- [core.py](core.py), [_search/brouwer_zimmermann.py](_search/brouwer_zimmermann.py), [_search/coset_leader_search.py](_search/coset_leader_search.py), [_search/coset_routes.py](_search/coset_routes.py), [_search/affine_search.py](_search/affine_search.py), [_search/affine_instance.py](_search/affine_instance.py), [_search/information_sets.py](_search/information_sets.py), [_search/automorphism_families.py](_search/automorphism_families.py), [_search/completion_bounds.py](_search/completion_bounds.py), [_search/matroid_partition.py](_search/matroid_partition.py), [_search/brute_force.py](_search/brute_force.py), [_binary.py](_binary.py), and [qldpc/_binary.py](../../_binary.py): the implementation. -- [_search/brouwer_zimmermann_test.py](_search/brouwer_zimmermann_test.py), [_search/coset_leader_search_test.py](_search/coset_leader_search_test.py), [_search/coset_routes_test.py](_search/coset_routes_test.py), [_search/affine_search_test.py](_search/affine_search_test.py), [_search/affine_instance_test.py](_search/affine_instance_test.py), [_search/information_sets_test.py](_search/information_sets_test.py), [_search/automorphism_families_test.py](_search/automorphism_families_test.py), [_search/completion_bounds_test.py](_search/completion_bounds_test.py), and [core_test.py](core_test.py): executable invariants and independent small-instance oracles. +- [core.py](core.py), [_brouwer_zimmermann/distance.py](_brouwer_zimmermann/distance.py), [_coset_leader_search.py](_coset_leader_search.py), [_brouwer_zimmermann/coset_routes.py](_brouwer_zimmermann/coset_routes.py), [_brouwer_zimmermann/affine_search.py](_brouwer_zimmermann/affine_search.py), [_brouwer_zimmermann/affine_instance.py](_brouwer_zimmermann/affine_instance.py), [_information_sets/basis_families.py](_information_sets/basis_families.py), [_information_sets/automorphism_families.py](_information_sets/automorphism_families.py), [_information_sets/completion_bounds.py](_information_sets/completion_bounds.py), [_information_sets/matroid_partition.py](_information_sets/matroid_partition.py), [_brute_force.py](_brute_force.py), [_binary.py](_binary.py), and [qldpc/_binary.py](../../_binary.py): the implementation. +- [_brouwer_zimmermann/distance_test.py](_brouwer_zimmermann/distance_test.py), [_coset_leader_search_test.py](_coset_leader_search_test.py), [_brouwer_zimmermann/coset_routes_test.py](_brouwer_zimmermann/coset_routes_test.py), [_brouwer_zimmermann/affine_search_test.py](_brouwer_zimmermann/affine_search_test.py), [_brouwer_zimmermann/affine_instance_test.py](_brouwer_zimmermann/affine_instance_test.py), [_information_sets/basis_families_test.py](_information_sets/basis_families_test.py), [_information_sets/automorphism_families_test.py](_information_sets/automorphism_families_test.py), [_information_sets/completion_bounds_test.py](_information_sets/completion_bounds_test.py), and [core_test.py](core_test.py): executable invariants and independent small-instance oracles. diff --git a/src/qldpc/codes/distance/conftest.py b/src/qldpc/codes/distance/conftest.py index dd418d7fc..4550f8466 100644 --- a/src/qldpc/codes/distance/conftest.py +++ b/src/qldpc/codes/distance/conftest.py @@ -18,7 +18,7 @@ from ..conftest import Tune from . import logical_counts as lc from ._binary import get_nested_code_basis -from ._search import information_sets +from ._information_sets import basis_families, certificates @pytest.fixture @@ -44,8 +44,8 @@ def plain_search_setup() -> None: class CostlyStages(NamedTuple): """The results of the costly stages of searches, which the costly_stages fixture records.""" - partitioned_families: list[information_sets.BasisFamily] - search_certificates: list[information_sets.Certificate] + partitioned_families: list[basis_families.BasisFamily] + search_certificates: list[certificates.Certificate] @pytest.fixture @@ -56,23 +56,23 @@ def costly_stages(monkeypatch: pytest.MonkeyPatch) -> CostlyStages: family, and a certificate with pivot blocks shows that a search used linear programming. """ stages = CostlyStages([], []) - get_partitioned_family = information_sets.get_partitioned_family - get_search_certificate = information_sets.BasisFamily.get_search_certificate + get_partitioned_family = basis_families.get_partitioned_family + get_search_certificate = basis_families.BasisFamily.get_search_certificate - def record_family(*args: Any, **kwargs: Any) -> information_sets.BasisFamily: + def record_family(*args: Any, **kwargs: Any) -> basis_families.BasisFamily: family = get_partitioned_family(*args, **kwargs) stages.partitioned_families.append(family) return family def record_certificate( - basis_family: information_sets.BasisFamily, *args: Any, **kwargs: Any - ) -> information_sets.Certificate: + basis_family: basis_families.BasisFamily, *args: Any, **kwargs: Any + ) -> certificates.Certificate: certificate = get_search_certificate(basis_family, *args, **kwargs) stages.search_certificates.append(certificate) return certificate - monkeypatch.setattr(information_sets, "get_partitioned_family", record_family) - monkeypatch.setattr(information_sets.BasisFamily, "get_search_certificate", record_certificate) + monkeypatch.setattr(basis_families, "get_partitioned_family", record_family) + monkeypatch.setattr(basis_families.BasisFamily, "get_search_certificate", record_certificate) return stages @@ -82,15 +82,15 @@ def get_binary_matrix(rows: Iterable[str]) -> npt.NDArray[np.uint8]: # Codes whose greedy disjoint packings, in natural column order, leave room for the other families -# of the routes of coset searches (see ``_search/coset_routes.py``). The even code has the columns -# e1, e2, e3, e1 + e2, e1 + e2, and e1 + e2 + e3, so every row has even weight. Its greedy packing -# has ranks three and two, and its columns hold two disjoint information sets. In the random [10, -# 6] code, a greedy packing of ranks six and three leaves room for one of ranks six and four, and -# for a family of three overlapping information sets, and queries take each of these at some maximum -# weights. In the random [14, 8] code, a greedy packing of ranks eight and six uses every column, -# and three overlapping information sets do less work for some queries, but not for others. So its -# overlapping family depends on the weight at which it is built, and that family changes some -# answers. +# of the routes of coset searches (see ``_brouwer_zimmermann/coset_routes.py``). The even code has +# the columns e1, e2, e3, e1 + e2, e1 + e2, and e1 + e2 + e3, so every row has even weight. Its +# greedy packing has ranks three and two, and its columns hold two disjoint information sets. In +# the random [10, 6] code, a greedy packing of ranks six and three leaves room for one of ranks six +# and four, and for a family of three overlapping information sets, and queries take each of these +# at some maximum weights. In the random [14, 8] code, a greedy packing of ranks eight and six uses +# every column, and three overlapping information sets do less work for some queries, but not for +# others. So its overlapping family depends on the weight at which it is built, and that family +# changes some answers. EVEN_ROUTES_CODE = get_binary_matrix(["100111", "010111", "001001"]) TWO_FAMILIES_CODE = get_binary_matrix( ["1001010111", "0110001011", "0101111010", "0010111100", "0010100000", "0000100110"] diff --git a/src/qldpc/codes/distance/connected_support.md b/src/qldpc/codes/distance/connected_support.md index d36c649e2..78862a9af 100644 --- a/src/qldpc/codes/distance/connected_support.md +++ b/src/qldpc/codes/distance/connected_support.md @@ -153,7 +153,7 @@ Then `m` is the root of its orbit, since a variable of the orbit of lower rank t So it suffices to search from the roots. By the lemma, this witness is connected, so the completeness argument of [Section 3.3](#33-the-depth-first-search) applies to it. The lemma holds for every minimum-weight witness, so `Γ` need not map the rows of `H` to each other, and only has to map `ker(H)` and `rowspan(G)` to themselves. -qLDPC verifies this with `verify_automorphisms` of `_search/automorphism_families.py`, as BZ does. +qLDPC verifies this with `verify_automorphisms` of `_information_sets/automorphism_families.py`, as BZ does. The search from a root forbids the variables of lower rank, so the ranks decide how much each search is cut. qLDPC ranks the variables orbit by orbit, in the order of their least variables, so that the search from each root forbids every earlier orbit. @@ -185,7 +185,7 @@ The search keeps these rows as packed columns, one 64-bit block per 64 rows, and **Weight divisor.** If every vector of `ker(H)` has even weight, as for the pair-partition codes, whose block rows of checks sum to the all-ones vector, weights are multiples of two. -`infer_weight_divisor` of `_search/information_sets.py` finds this from a basis of `ker(H)`, as BZ does. +`infer_weight_divisor` of `_information_sets/basis_families.py` finds this from a basis of `ker(H)`, as BZ does. **Exact distance.** Let `L` be the smallest weight that an unexamined witness can have, which is the divisor at first. @@ -277,7 +277,7 @@ Four parts of a distance search do not carry over: - The weights of the vectors with a syndrome need not share a divisor, so the query has none. - Every vector with the syndrome counts, so the query has no logical test. -The fault-propagation certifier in `qldpc.circuits.certification`, and the experimental residual-weight estimator, use `CosetLeaderSearch` in `_search/coset_leader_search.py`. +The fault-propagation certifier in `qldpc.circuits.certification`, and the experimental residual-weight estimator, use `CosetLeaderSearch` in `_coset_leader_search.py`. When it is built with `method="connected_support"`, `CosetLeaderSearch` answers each of its queries by a table lookup, or by such queries at one or more weight limits. It searches the Pauli errors of a state by their physical weight. The CSS weight `max(|x|, |z|)` is not a sum over qubits, so for it, on a CSS state, it searches the X and Z parts separately, and on any other state it raises an error. @@ -330,7 +330,7 @@ A distance search, whose checks are usually sparse, computes them again each tim ## 4. Guide to the source code -The names below are in `_search/connected_support.py`, unless the table says otherwise. +The names below are in `_connected_support/search.py`, unless the table says otherwise. | Concept | Code | | --- | --- | @@ -342,15 +342,15 @@ The names below are in `_search/connected_support.py`, unless the table says oth | Search through a weight limit `W` | `SupportSearch.find_witness` | | Syndrome query | `SupportSearch.find_vector_with_syndrome`, which coset searches skip for `SupportSearch._find_vector_with_packed_syndrome`, and the root `-1` of `_search_supports` | | Overlaps and repairs that a syndrome query reuses within a state | `memoize` of `_search_supports`, and `memos` and `stamp` of `_get_branches` | -| Coset leaders of Pauli errors, and the sparser checks that their searches use | `_ConnectedSupportCosetSearch`, `_get_sparser_rows`, `_sparsify`, and `CosetLeaderSearch` in `_search/coset_leader_search.py` | +| Coset leaders of Pauli errors, and the sparser checks that their searches use | `_ConnectedSupportCosetSearch`, `_get_sparser_rows`, `_sparsify`, and `CosetLeaderSearch` in `_coset_leader_search.py` | | How many rounds make the checks of a coset search sparser | `max_sparsifying_rounds` and `sparsifying_rounds_without_improvement` of `SearchTuning` in `_tuning.py` | -| Table of light vectors, and increasing limits for batches and minimum weights | `_ConnectedSupportCosetSearch` (its `_singles` and `get_min_weight_bound`), and `_get_batch_support_bound` and `_get_min_support_weight` of `CosetLeaderSearch`, in `_search/coset_leader_search.py` | +| Table of light vectors, and increasing limits for batches and minimum weights | `_ConnectedSupportCosetSearch` (its `_singles` and `get_min_weight_bound`), and `_get_batch_support_bound` and `_get_min_support_weight` of `CosetLeaderSearch`, in `_coset_leader_search.py` | | Logical test | `SupportSearch._get_tests`, and `_is_outside_excluded` | -| Roots, ranks, and symmetries | `SupportSearch._use_automorphisms`, and `verify_automorphisms` and `get_orbits` of `_search/automorphism_families.py` | +| Roots, ranks, and symmetries | `SupportSearch._use_automorphisms`, and `verify_automorphisms` and `get_orbits` of `_information_sets/automorphism_families.py` | | States, branching, and undoing what a state forbade when the search leaves it | `_search_supports`, whose `levels` mark the available symbols | | Weights, pruning, and the symbols that a state forbids for its subtree | `_get_branches` | | When a search asks for symmetries | `automorphism_minimum_states` of `SearchTuning` in `_tuning.py` | -| Weight divisor | `infer_weight_divisor` of `_search/information_sets.py` | +| Weight divisor | `infer_weight_divisor` of `_information_sets/basis_families.py` | `CSSCode._get_distance_of_sectors` in `src/qldpc/codes/common.py` builds the sectors and calls `_get_distance_connected_support` of `core.py`, which passes the active tuning to the search. `ClassicalCode.get_distance_exact` and `QuditCode.get_distance_exact` call it with their problems, and `SupportSearch` validates every problem. diff --git a/src/qldpc/codes/distance/core.py b/src/qldpc/codes/distance/core.py index f4526fc50..18bfe4e39 100644 --- a/src/qldpc/codes/distance/core.py +++ b/src/qldpc/codes/distance/core.py @@ -26,12 +26,12 @@ get_nested_code_basis, symplectic_to_hamming, ) -from ._search.brouwer_zimmermann import ( +from ._brouwer_zimmermann.distance import ( get_distance_brouwer_zimmermann, get_distance_brouwer_zimmermann_many, ) -from ._search.brute_force import get_distance_quantum_brute_force, get_nested_distance_brute_force -from ._search.connected_support import get_distance_connected_support +from ._brute_force import get_distance_quantum_brute_force, get_nested_distance_brute_force +from ._connected_support.search import get_distance_connected_support from ._tuning import SearchTuning, get_tuning DistanceBackend = Literal["auto", "decoder", "gap", "sqetch"] @@ -386,7 +386,7 @@ def _get_distance_connected_support( sector of a CSS code, the parity checks of a classical code with nothing excluded, or the symplectic stabilizers and gauge generators of a qubit code (``homogeneous=False``). This function searches them with the active tuning. Its other arguments, its result, and its errors - are those of ``get_distance_connected_support`` in ``_search/connected_support.py``, which + are those of ``get_distance_connected_support`` in ``_connected_support/search.py``, which validates the problems. """ return get_distance_connected_support( diff --git a/src/qldpc/codes/distance/layering_test.py b/src/qldpc/codes/distance/layering_test.py index c7e19fa0f..f0282fbd5 100644 --- a/src/qldpc/codes/distance/layering_test.py +++ b/src/qldpc/codes/distance/layering_test.py @@ -11,9 +11,9 @@ _LAYERS = ( ("_binary",), ("_tuning",), - ("_search",), - ("_counting",), - ("core",), + ("_brute_force", "_information_sets"), + ("_brouwer_zimmermann", "_connected_support", "_counting"), + ("_coset_leader_search", "core"), ("logical_counts",), ) diff --git a/src/qldpc/codes/distance/logical_counts.py b/src/qldpc/codes/distance/logical_counts.py index 021a4bb89..2eb9c30be 100644 --- a/src/qldpc/codes/distance/logical_counts.py +++ b/src/qldpc/codes/distance/logical_counts.py @@ -40,10 +40,10 @@ from qldpc._utils import get_external_caller_stacklevel from ._binary import as_integer, get_nested_code_basis +from ._brouwer_zimmermann.distance import get_distance_brouwer_zimmermann_many +from ._brute_force import get_nested_distance_brute_force from ._counting import local_cliffords, macwilliams, scan -from ._search import information_sets -from ._search.brouwer_zimmermann import get_distance_brouwer_zimmermann_many -from ._search.brute_force import get_nested_distance_brute_force +from ._information_sets import basis_families from ._tuning import DEFAULT_TUNING, SearchTuning, get_tuning from .core import NO_DISTANCE, NoDistance @@ -553,7 +553,7 @@ def _forget_kept_work(self) -> None: self._distances: dict[_CountDistanceMethod, int] = {} self._sectors: tuple[_Sector, _Sector] | None = None # Disjoint basis families, by sector and number of column orders. - self._disjoint_basis_families: dict[tuple[int, int], information_sets.BasisFamily] = {} + self._disjoint_basis_families: dict[tuple[int, int], basis_families.BasisFamily] = {} self._enumerators: macwilliams.CheckEnumerators | None = None def _validate_macwilliams_request(self, count_pauli: bool) -> None: @@ -638,7 +638,7 @@ def _get_sectors(self) -> tuple[_Sector, _Sector]: def _get_disjoint_basis_family( self, index: int, random_column_orders: int - ) -> information_sets.BasisFamily: + ) -> basis_families.BasisFamily: """The disjoint basis family of a sector, the first stage of its basis families, kept. This stage does not depend on the counted weight, so counting at another weight, as when a @@ -647,7 +647,7 @@ def _get_disjoint_basis_family( """ key = (index, random_column_orders) if key not in self._disjoint_basis_families: - self._disjoint_basis_families[key] = information_sets.get_disjoint_family( + self._disjoint_basis_families[key] = basis_families.get_disjoint_family( *self._get_sectors()[index], random_column_orders=random_column_orders ) return self._disjoint_basis_families[key] @@ -761,18 +761,18 @@ def _warm_up_compiled_functions() -> None: # search, is their purpose. basis, labels = counter._get_sectors()[0] upper_bound = 4 - basis_family = information_sets.prune_family( - information_sets.get_disjoint_family( + basis_family = basis_families.prune_family( + basis_families.get_disjoint_family( basis, labels, random_column_orders=DEFAULT_TUNING.disjoint_random_column_orders ), upper_bound, ) - information_sets.get_overlapping_family( + basis_families.get_overlapping_family( basis, labels, basis_family, upper_bound, - information_sets.get_overlapping_family_policy(DEFAULT_TUNING, len(basis)), + basis_families.get_overlapping_family_policy(DEFAULT_TUNING, len(basis)), ) @@ -783,7 +783,7 @@ def _warm_up_compiled_functions() -> None: def _get_distance( sectors: Sequence[_Sector], method: _CountDistanceMethod, - get_disjoint_basis_families: Sequence[Callable[[], information_sets.BasisFamily]], + get_disjoint_basis_families: Sequence[Callable[[], basis_families.BasisFamily]], tuning: SearchTuning, ) -> int: """Minimum weight of a nontrivial word in any of the given sectors. diff --git a/src/qldpc/codes/distance/logical_counts_test.py b/src/qldpc/codes/distance/logical_counts_test.py index 1c01e0c37..f20cddf1c 100644 --- a/src/qldpc/codes/distance/logical_counts_test.py +++ b/src/qldpc/codes/distance/logical_counts_test.py @@ -25,7 +25,7 @@ from ..conftest import Tune from . import logical_counts as lc from ._counting import macwilliams -from ._search import information_sets +from ._information_sets import basis_families from ._tuning import DEFAULT_TUNING, use_tuning from .conftest import ( HAND_X, @@ -240,7 +240,7 @@ def refuse() -> None: monkeypatch.setattr(lc, "_warm_up_compiled_functions", refuse) with pytest.raises(RuntimeError, match="refused"): qldpc.codes.distance.compile_search_setup() - select = information_sets.get_overlapping_family + select = basis_families.get_overlapping_family assert not isinstance(select, numba.core.registry.CPUDispatcher) @@ -291,14 +291,14 @@ def fresh() -> codes.CSSCode: # The limit also reaches the scans of a count of a given weight, which searches no distance. sizes: list[int] = [] - original_family = information_sets.get_overlapping_family + original_family = basis_families.get_overlapping_family - def family(*args: Any) -> information_sets.BasisFamily: + def family(*args: Any) -> basis_families.BasisFamily: replacement = original_family(*args) sizes.append(len(replacement.ranks)) return replacement - monkeypatch.setattr(information_sets, "get_overlapping_family", family) + monkeypatch.setattr(basis_families, "get_overlapping_family", family) tune(overlapping_minimum_work=0) code = get_random_css_code(np.random.default_rng(5), 24, 8, 8) expected = lc.count_logical_ops_css(code, 3, method="brute_force") @@ -318,14 +318,14 @@ def test_the_information_set_limit_reaches_every_search_of_a_count( that a limit of three bounds it only if the limit reaches the distance search. """ sizes: list[int] = [] - original = information_sets.get_overlapping_family + original = basis_families.get_overlapping_family - def record(*args: Any, **kwargs: Any) -> information_sets.BasisFamily: + def record(*args: Any, **kwargs: Any) -> basis_families.BasisFamily: family = original(*args, **kwargs) sizes.append(len(family.ranks)) return family - monkeypatch.setattr(information_sets, "get_overlapping_family", record) + monkeypatch.setattr(basis_families, "get_overlapping_family", record) # Make the distance search build information sets, and every search try overlapping families. tune( allow_enumeration_by_size=False, @@ -357,7 +357,7 @@ def test_later_counts_reuse_the_first_family_stage() -> None: """ counter = lc.CSSLogicalCounter(codes.SurfaceCode(5, rotated=False)) with mock.patch.object( - information_sets, "get_disjoint_family", wraps=information_sets.get_disjoint_family + basis_families, "get_disjoint_family", wraps=basis_families.get_disjoint_family ) as first_stage: assert counter.count_logical_ops() == Counts(5, 5, 5, None) num_first_stages = first_stage.call_count @@ -398,7 +398,7 @@ def test_deformed_counter_reuses_the_first_family_stage() -> None: fresh = get_counter(deformed.checks_x, deformed.checks_z) expected = [fresh.count_logical_ops(weight) for weight in weights] with mock.patch.object( - information_sets, "get_disjoint_family", wraps=information_sets.get_disjoint_family + basis_families, "get_disjoint_family", wraps=basis_families.get_disjoint_family ) as first_stage: counts = [deformed.count_logical_ops(weight) for weight in weights] with use_tuning(disjoint_random_column_orders=3): diff --git a/src/qldpc/codes/quantum/two_block_test.py b/src/qldpc/codes/quantum/two_block_test.py index 7bcc4c658..13ad3c17a 100644 --- a/src/qldpc/codes/quantum/two_block_test.py +++ b/src/qldpc/codes/quantum/two_block_test.py @@ -15,7 +15,7 @@ from qldpc import abstract, codes from qldpc.abstract.monomials import get_coefficient_and_exponents -from qldpc.codes.distance._search import automorphism_families +from qldpc.codes.distance._information_sets import automorphism_families from qldpc.objects import Node from ..conftest import ( diff --git a/src/qldpc/experimental/circuits/benchmarking/residual_weights.py b/src/qldpc/experimental/circuits/benchmarking/residual_weights.py index 15dfae84c..680db78ab 100644 --- a/src/qldpc/experimental/circuits/benchmarking/residual_weights.py +++ b/src/qldpc/experimental/circuits/benchmarking/residual_weights.py @@ -41,7 +41,7 @@ _is_css_state, ) from qldpc.codes.distance import DistanceMethod -from qldpc.codes.distance._search.coset_leader_search import CosetLeaderSearch, CosetSearchMethod +from qldpc.codes.distance._coset_leader_search import CosetLeaderSearch, CosetSearchMethod from qldpc.objects import Pauli, PauliXZ, PauliXZLike from .consumer_profiles import ConsumerProfile From a2a75b99810d4148a7a78e8f93df533b8d5059ce Mon Sep 17 00:00:00 2001 From: "Michael A. Perlin" Date: Thu, 12 Nov 2026 00:58:30 -0500 Subject: [PATCH 2/7] Split the coset-leader search by method `_coset_leader_search.py` keeps `CosetLeaderSearch` and the queries that split a syndrome class into coset searches. The coset searches move to their methods: the Brouwer-Zimmermann ones to `_brouwer_zimmermann/coset.py`, with the tables of their steps and tails in `_brouwer_zimmermann/coset_tables.py`, and the connected-support ones to `_connected_support/coset.py`. What both methods share, including `MinWeightBound`, moves to `_cosets.py`, a layer below the methods. External modules still import from `_coset_leader_search.py`. The tests follow their code, and helpers that several test files use move to `conftest.py`. The moves change no behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/qldpc/codes/distance/README.md | 4 + .../distance/_brouwer_zimmermann/coset.py | 1112 +++++++++++ .../_brouwer_zimmermann/coset_tables.py | 290 +++ .../_brouwer_zimmermann/coset_tables_test.py | 128 ++ .../_brouwer_zimmermann/coset_test.py | 1178 +++++++++++ .../distance/_connected_support/coset.py | 288 +++ .../distance/_connected_support/coset_test.py | 209 ++ .../codes/distance/_coset_leader_search.py | 1757 +---------------- .../distance/_coset_leader_search_test.py | 1662 +--------------- src/qldpc/codes/distance/_cosets.py | 135 ++ src/qldpc/codes/distance/conftest.py | 212 +- src/qldpc/codes/distance/layering_test.py | 2 +- 12 files changed, 3594 insertions(+), 3383 deletions(-) create mode 100644 src/qldpc/codes/distance/_brouwer_zimmermann/coset.py create mode 100644 src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py create mode 100644 src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py create mode 100644 src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py create mode 100644 src/qldpc/codes/distance/_connected_support/coset.py create mode 100644 src/qldpc/codes/distance/_connected_support/coset_test.py create mode 100644 src/qldpc/codes/distance/_cosets.py diff --git a/src/qldpc/codes/distance/README.md b/src/qldpc/codes/distance/README.md index 3ab3bb4f7..1239b3c82 100644 --- a/src/qldpc/codes/distance/README.md +++ b/src/qldpc/codes/distance/README.md @@ -47,6 +47,10 @@ Users reach it through the `get_distance_exact` method of binary codes in `src/q | Information sets with bounded column coverage, by matroid partitioning | `_information_sets/matroid_partition.py` | | Exhaustive enumeration, for small codes, fallbacks, and the `brute_force` method, with its packed symplectic weights | `_brute_force.py` | | Minimum weights of syndrome classes of Pauli errors (coset-leader weights), by BZ or by connected-support searches, with two classical searches for the CSS weight on CSS states, used by `src/qldpc/circuits/certification/` and `src/qldpc/experimental/circuits/benchmarking/residual_weights.py` | `_coset_leader_search.py` | +| The BZ coset searches of the coset-leader search | `_brouwer_zimmermann/coset.py` | +| The tables of the steps and tails of BZ coset searches | `_brouwer_zimmermann/coset_tables.py` | +| The connected-support coset searches of the coset-leader search | `_connected_support/coset.py` | +| The parts that the coset searches of both methods share: their answers (`MinWeightBound`), inverses of checks, packed rows, and tables of light errors | `_cosets.py` | | The families of information sets with which a BZ coset-leader search answers costly queries, the gate that chooses them for each maximum weight, and the routes whose queries weigh words by their tails and skip completions by tables | `_brouwer_zimmermann/coset_routes.py` | | The counter, its deformed counters, or `compile_search_setup` | `logical_counts.py` | | The scan of a count, or how its basis families follow local gates | `_counting/scan.py` | diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py new file mode 100644 index 000000000..fac0931d8 --- /dev/null +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py @@ -0,0 +1,1112 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Brouwer-Zimmermann coset searches of ``CosetLeaderSearch``: the cosets of one binary code.""" + +from __future__ import annotations + +import functools +from typing import NamedTuple + +import numpy as np +import numpy.typing as npt + +from qldpc._binary import ( + popcount_word, + unpack_bits, +) +from qldpc._compilation import always_compiled + +from .. import _tuning +from .._binary import symplectic_to_hamming +from .._cosets import ( + MinWeightBound, + _get_inverse, + _int_to_words, + _packed_row_int, + _row_ints, + _tabulate_singles, + _xor_selected, + _xor_selected_rows, +) +from .._information_sets import certificates +from .._tuning import SearchTuning +from . import affine_instance, affine_search, coset_routes +from .coset_tables import ( + _CosetSearchTables, + _CosetTails, + _get_coset_tails, + _get_first_step_reaching, + _get_planning_table, + _get_search_tables, + _get_stopping_step, + _PlanningBuffer, + _TailFields, +) + +#################################################################################################### +# coset searches + + +class _CosetSearch: + """One coset search of a ``CosetLeaderSearch``: a search of the cosets of one binary code. + + The words of a search are either packed Paulis on ``n`` qubits, in which case the code is + spanned by the packed stabilizers, or the X parts (or Z parts) of Paulis, in which case the code + is spanned by the X parts of the pure-X stabilizers (or the Z parts of the pure-Z stabilizers). + Each coset is labeled by the syndrome of its words, whose bit ``j`` is the parity of a word with + row ``j`` of a matrix of checks. + + The search answers queries of each maximum weight with the tables of a family of information + sets of the code, which ``routes`` choose (see ``coset_routes``). The tables of every family + have the same ``inverse`` and ``x_mask``. The queries of a route by tails weigh the words of + its family by their tails, and skip completions by the completion tables, from its record of + tails. + + Args: + routes: The routes of the search. + css_weight: True to weigh words by their CSS weight, which is the Hamming weight of an X + or Z part, or False to weigh them by their physical weight. + num_qubits: The number of qubits ``n``. + inverse_rows: Row ``j`` of a right inverse of the checks, not embedded, as an integer: a + word whose syndrome has only bit ``j`` set. + check_columns: Column ``c`` of the checks as an integer: the syndrome of the word with only + bit ``c`` set. + is_pauli: Whether the words are packed Paulis of ``2 n`` bits, rather than X or Z parts of + ``n`` bits. + max_css_single_pairs: For a search of Pauli errors by CSS weight, the most pairs of distinct + syndromes of single-qubit X and Z errors that it tabulates (see ``_css_singles``). + """ + + def __init__( + self, + routes: coset_routes.CosetRoutes[_CosetSearchTables, _CosetTails], + css_weight: bool, + num_qubits: int, + inverse_rows: tuple[int, ...], + check_columns: tuple[int, ...], + *, + is_pauli: bool, + max_css_single_pairs: int = 0, + ) -> None: + self._routes = routes + self._max_css_single_pairs = max_css_single_pairs + self.css_weight = css_weight + # The compiled search weighs embedded words by their CSS weight only in a search of Pauli + # errors by CSS weight, and otherwise by their Hamming weight, which is twice the physical + # weight of an error, and the weight of an X or Z part (see ``_search_coset``). The scale + # chooses which of these weights a scan computes. The weight factor of the coset instances + # is separate: it is two for every search of Pauli errors, including those by CSS weight, + # whose scale is zero, since an error weighs at least half the Hamming weight of its + # embedded word (see ``_get_pauli_search``). + self.scale = 0 if css_weight and is_pauli else 1 if css_weight else 2 + self._num_qubits = num_qubits + self._is_pauli = is_pauli + self._inverse_rows = inverse_rows + self._num_syndrome_bits = len(inverse_rows) + # A query below maximum weight two bounds a coset without words of weight at most one by + # two. If the search is of X or Z parts and rounds to parity (see ``_get_search_tables``), + # every word of an odd coset has odd weight, so such an odd coset has no word of weight two + # either, and the query bounds it by three. An odd coset of a search of Pauli errors by + # CSS weight can still hold an error of weight two, such as ``YX``, whose binary word has + # three ones. + num_tables = len(routes.disjoint_tables.planning) + self._odd_coset_lookup_bound = 3 if not is_pauli and num_tables == 2 else 2 + + # Map each syndrome of a word of weight zero or one to such a word. For the CSS weight, + # ``_weight_one_word`` also pairs the single-qubit X errors with the single-qubit Z errors, + # to find ``X_i Z_j`` on distinct qubits. + self._singles, self._x_singles, self._z_singles = _tabulate_singles( + check_columns, num_qubits, is_pauli=is_pauli + ) + + def get_min_weight_bound(self, syndrome: int, max_weight: int) -> MinWeightBound: + """Decide whether the coset of this syndrome has a word of weight at most ``max_weight``. + + Returns: + A ``MinWeightBound`` for the coset, whose representative is a word of the coset. + """ + word = self._weight_one_word(syndrome) + if word is not None: + weight = self._weight(word) + return MinWeightBound(weight <= max_weight, word, weight) + + # Otherwise the coset has no word of weight at most one, so two is a lower bound, and so is + # the odd-coset lookup bound for an odd coset. + if max_weight < 2: + word = _xor_selected(self._inverse_rows, syndrome) + return MinWeightBound( + False, word, self._odd_coset_lookup_bound if word.bit_count() % 2 else 2 + ) + + # Search the coset with the tables of the route of the maximum weight, by tails on a route + # by tails, and keep the lightest word that the search weighs in ``best``. + tables, tails = self._get_route(max_weight) + best = np.empty(tables.inverse.shape[1], dtype=np.uint64) + packed_syndrome = _int_to_words(syndrome, max(1, (self._num_syndrome_bits + 63) // 64)) + lower_bound = _search_coset(packed_syndrome, max_weight, best, *tables, tails, self.scale) + # A search that finds a light enough word returns zero, but every word of the coset weighs + # at least two, since the coset has no word of weight at most one. + return MinWeightBound(lower_bound <= max_weight, self.unembed(best), max(2, lower_bound)) + + def find_first_not_within_max_weight( + self, syndromes: npt.NDArray[np.uint64], max_weight: int + ) -> tuple[int, MinWeightBound | None]: + """Find the first syndrome whose coset has no word of weight at most ``max_weight``. + + The syndromes are packed as for ``CosetLeaderSearch.find_first_not_within_max_weight``. + The search answers them with the route of the maximum weight, which it takes once. + + Returns: + The index of that syndrome, or the number of syndromes if there is none, and a + ``MinWeightBound`` for its coset, with the lightest word that the search weighed, or + None if there is none. + """ + tables, tails = self._get_route(max_weight) + best = np.empty(tables.inverse.shape[1], dtype=np.uint64) + index, lower_bound = _find_first_not_within_max_weight( + syndromes, max_weight, best, *tables, tails, self.scale + ) + if index == len(syndromes): + return index, None + return index, MinWeightBound(False, self.unembed(best), int(lower_bound)) + + def get_min_weights( + self, syndromes: npt.NDArray[np.uint64], max_weight: int, floors: npt.NDArray[np.int64] + ) -> npt.NDArray[np.int64]: + """Find the minimum weight of the coset of each syndrome, if it is at most ``max_weight``. + + The syndromes are packed as for ``CosetLeaderSearch.find_first_not_within_max_weight``. The + search of a syndrome also ends at a word of weight at most its floor in ``floors``, which + must be at most ``max_weight``. No coset has a minimum weight above the number ``n`` of + qubits, so the search answers the syndromes with the route of the lesser of ``max_weight`` + and ``n``, which it takes once. Unlike ``get_min_weight_bound``, it looks up no words of + weight at most one, since the first layers of a search find them cheaply. + + Returns: + For each syndrome, the weight of a word of its coset of weight at most its floor if + there is one, and otherwise the minimum weight of its coset, or ``max_weight + 1`` if + that is larger (see ``_search_coset_for_min_weight``). + """ + max_weight = min(max_weight, self._num_qubits) + tables, tails = self._get_route(max_weight) + return _find_min_weights(syndromes, max_weight, floors, *tables, tails, self.scale) + + def unembed(self, embedded_word: npt.NDArray[np.uint64]) -> int: + """Convert a packed embedded word into a word, without the columns that the embedding adds. + + The embedding for the physical weight appends ``x xor z`` to a packed Pauli (see + ``_CosetSearchTables``), and the other embeddings add nothing. + """ + return _packed_row_int(embedded_word) & ((1 << self._num_word_bits) - 1) + + def _get_route(self, max_weight: int) -> tuple[_CosetSearchTables, _TailFields | None]: + """The tables of the route of a maximum weight, and the fields of its tails, if any. + + The fields are a plain tuple, which numba types at each call of a compiled search several + times faster than the named tuple ``_CosetTails``. + """ + tables, tails = self._routes.get_route(max_weight) + return tables, None if tails is None else tails[:] + + @property + def _num_word_bits(self) -> int: + """The number of bits of a word: ``2 n`` for Paulis, and ``n`` for X or Z parts.""" + return 2 * self._num_qubits if self._is_pauli else self._num_qubits + + def _weight(self, word: int) -> int: + """The weight of a word: of its Pauli, or the Hamming weight of an X or Z part.""" + if not self._is_pauli: + return word.bit_count() + mask = (1 << self._num_qubits) - 1 + x, z = word & mask, (word >> self._num_qubits) & mask + return max(x.bit_count(), z.bit_count()) if self.css_weight else (x | z).bit_count() + + def _weight_one_word(self, syndrome: int) -> int | None: + """Find a word of weight zero or one with this syndrome, if there is one.""" + if syndrome in self._singles: + return self._singles[syndrome] + if not (self._is_pauli and self.css_weight): + return None + # CSS weight one also allows X_i Z_j on distinct qubits. Callers such as the certification + # of fault propagation make a query for each of thousands of syndromes, so a table of these + # errors, built on the first query that needs it, saves a scan of the qubits per query. + if self._css_singles is not None: + return self._css_singles.get(syndrome) + # If that table would be too large, match the syndrome of each single-qubit X error against + # those of the single-qubit Z errors, in time linear in the number of qubits. + for sx, x in self._x_singles.items(): + z = self._z_singles.get(syndrome ^ sx) + if z is not None: + return x ^ z + return None + + @functools.cached_property + def _css_singles(self) -> dict[int, int] | None: + """Errors X_i Z_j (with X_i or Z_j possibly the identity) by syndrome, if not too many.""" + return _tabulate_css_singles(self._x_singles, self._z_singles, self._max_css_single_pairs) + + +class _StabilizerKey: + """A stabilizer matrix and its checks, which a cache can hash and compare by the matrix. + + The checks follow from the stabilizers, so the stabilizers alone key the cache, and the key + keeps both matrices to build a search that the cache has not stored yet. Keys of packed bytes + would not hold the matrices, but unpacking them would cost about 0.4% of the setup of a search. + The matrices may be arrays of the caller. The key compares a copy of the bytes of the + stabilizers, and the search copies what it keeps of them, so later changes to the arrays of the + caller reach neither. + """ + + def __init__(self, stabilizers: npt.NDArray[np.uint8], checks: npt.NDArray[np.uint8]) -> None: + self.stabilizers = stabilizers + self.checks = checks + self._key = (stabilizers.shape, stabilizers.tobytes()) + self._hash = hash(self._key) + + def __hash__(self) -> int: + return self._hash + + def __eq__(self, other: object) -> bool: + return isinstance(other, _StabilizerKey) and self._key == other._key + + +class _CosetTuning(NamedTuple): + """The fields of the tuning that the setup of a BZ coset search reads, which key its cache. + + ``disjoint_random_column_orders`` configures the disjoint information sets of the search. Only + a search of Pauli errors by CSS weight reads ``max_css_single_pairs``, for its lookups. The + fields from ``overlapping_minimum_work`` to ``overlapping_attempts_per_set`` configure the + families with which the search answers costly queries, and ``overlapping_minimum_work`` also + chooses its routes by tails, whose queries weigh words by their tails and skip completions by + tables, if it weighs Hamming weights (see ``coset_routes``). The last two fields configure + those tables, which the search of Pauli errors by CSS weight does not have. The search builds + the families, tails, and tables after the cache stores it. + """ + + disjoint_random_column_orders: int + max_css_single_pairs: int + overlapping_minimum_work: int + max_information_sets: int + overlapping_attempts_per_set: int + completion_bound_max_table_bytes: int + coset_completion_block_bits: int + + @classmethod + def from_tuning(cls, tuning: SearchTuning, *, paulis_by_css_weight: bool) -> _CosetTuning: + """The fields of a tuning that the setup of a search reads. + + ``paulis_by_css_weight`` is True for a search of Pauli errors by CSS weight. Only that + search reads ``max_css_single_pairs``, and it alone has no completion tables. The fields + that a search does not read are zero, so that oracles built under tunings that differ only + in those fields share cached searches. + """ + fields = (getattr(tuning, name) for name in cls._fields) + coset_tuning = cls(*fields) + if paulis_by_css_weight: + return coset_tuning._replace( + completion_bound_max_table_bytes=0, coset_completion_block_bits=0 + ) + return coset_tuning._replace(max_css_single_pairs=0) + + +@functools.lru_cache(maxsize=_tuning.PAULI_SEARCH_CACHE_SIZE) +def _get_pauli_search(key: _StabilizerKey, css_weight: bool, tuning: _CosetTuning) -> _CosetSearch: + """The Pauli search, of Pauli errors modulo the stabilizers, with syndromes for the stabilizers. + + ``key`` holds the stabilizers and their checks (see ``CosetLeaderSearch``). The syndromes + depend on the rows of the stabilizer matrix, not only on the group that they generate, so the + rows key the cache. ``css_weight`` and the fields of the tuning that the setup reads key it + too. + """ + stabilizers, checks = key.stabilizers, key.checks + num_qubits = len(stabilizers) + inverse = _get_inverse(checks) + embedded_stabilizers = stabilizers if css_weight else symplectic_to_hamming(stabilizers) + embedded_inverse = inverse if css_weight else symplectic_to_hamming(inverse) + # An error weighs at least half of the Hamming weight of its embedded word, by either measure of + # weight, so the certificate bounds are halved. A search by CSS weight never weighs words by + # tails, since that weight needs to know which deleted pivot columns are in the X part. + routes = _get_routes( + embedded_stabilizers, + embedded_inverse, + num_qubits, + weight_factor=2, + tuning=tuning, + by_tails=not css_weight, + ) + check_columns = _row_ints(np.ascontiguousarray(checks.T)) + return _CosetSearch( + routes, + css_weight, + num_qubits, + _row_ints(inverse), + check_columns, + is_pauli=True, + max_css_single_pairs=tuning.max_css_single_pairs, + ) + + +@functools.lru_cache(maxsize=_tuning.CSS_SEARCH_CACHE_SIZE) +def _get_css_searches( + num_qubits: int, num_x_rows: int, x_rows: bytes, z_rows: bytes, tuning: _CosetTuning +) -> tuple[_CosetSearch, _CosetSearch]: + """The classical searches of the X parts and of the Z parts of the errors on a CSS state. + + ``x_rows`` holds the X parts of ``num_x_rows`` pure-X stabilizers, and ``z_rows`` the Z parts of + the other ``num_qubits - num_x_rows`` stabilizers, which are pure-Z. Both are in reduced row + echelon form, which depends only on the stabilizer group, and packed by ``pack_bits`` into + bytes, which ``lru_cache`` can hash. The X parts of errors are searched modulo the code of the + X rows, with the Z rows as checks, and the Z parts the other way around (see + ``CosetLeaderSearch``). The fields of the tuning that the setup reads key the cache too. + """ + num_words = max(1, (num_qubits + 63) // 64) + x_matrix = unpack_bits( + np.frombuffer(x_rows, dtype=np.uint64).reshape(num_x_rows, num_words), num_qubits + ) + z_matrix = unpack_bits( + np.frombuffer(z_rows, dtype=np.uint64).reshape(num_qubits - num_x_rows, num_words), + num_qubits, + ) + return ( + _get_classical_search(x_matrix, z_matrix, tuning), + _get_classical_search(z_matrix, x_matrix, tuning), + ) + + +def _get_classical_search( + generators: npt.NDArray[np.uint8], checks: npt.NDArray[np.uint8], tuning: _CosetTuning +) -> _CosetSearch: + """The classical search of X or Z parts modulo the row space of independent generators. + + The row space of the generators is the null space of the independent checks, so each coset is + labeled by its syndrome for the checks. Words are weighed by their Hamming weight, which the BZ + certificate bounds directly, so its bounds are not halved. + """ + num_qubits = generators.shape[1] + inverse = _get_inverse(checks) + # Every column counts as the X part, so the CSS weight of a word is its Hamming weight. + routes = _get_routes( + generators, inverse, num_qubits, weight_factor=1, tuning=tuning, by_tails=True + ) + check_columns = _row_ints(np.ascontiguousarray(checks.T)) + return _CosetSearch(routes, True, num_qubits, _row_ints(inverse), check_columns, is_pauli=False) + + +def _get_routes( + generators: npt.NDArray[np.uint8], + inverse: npt.NDArray[np.uint8], + num_x_columns: int, + *, + weight_factor: int, + tuning: _CosetTuning, + by_tails: bool, +) -> coset_routes.CosetRoutes[_CosetSearchTables, _CosetTails]: + """The routes of a coset search of the code of independent embedded generators. + + The routes start from the coset instance of the disjoint information sets of the generators, + with the weight factor ``weight_factor`` (see ``affine_instance.get_coset_instance``). The + tables of every family of the routes come from ``_get_search_tables``, with ``inverse`` and + ``num_x_columns``. If ``by_tails`` is True, the routes whose queries are predicted to examine + at least ``tuning.overlapping_minimum_work`` words with their own family are routes by tails, + with records of tails and tables from ``_get_coset_tails``, and otherwise no route is. + """ + instance = affine_instance.get_coset_instance( + generators, + random_column_orders=tuning.disjoint_random_column_orders, + weight_factor=weight_factor, + ) + return coset_routes.CosetRoutes( + generators, + instance, + functools.partial(_get_search_tables, inverse=inverse, num_x_columns=num_x_columns), + functools.partial( + _get_coset_tails, + inverse=inverse, + block_bits=tuning.coset_completion_block_bits, + max_table_bytes=tuning.completion_bound_max_table_bytes, + ) + if by_tails + else None, + minimum_work=tuning.overlapping_minimum_work, + max_sets=tuning.max_information_sets, + attempts_per_set=tuning.overlapping_attempts_per_set, + ) + + +def _tabulate_css_singles( + x_singles: dict[int, int], z_singles: dict[int, int], max_pairs: int +) -> dict[int, int] | None: + """Tabulate the products of single-qubit X and Z errors by syndrome, if not too many. + + Args: + x_singles: Single-qubit X errors, and the identity, by syndrome. + z_singles: Single-qubit Z errors, and the identity, by syndrome. + max_pairs: The most pairs of an X and a Z error that the table may come from. + + Returns: + None if there are more than ``max_pairs`` pairs, and otherwise the first product + ``x ^ z`` of each syndrome ``sx ^ sz``, in the order of the two dictionaries. + """ + if len(x_singles) * len(z_singles) > max_pairs: + return None + table: dict[int, int] = {} + for sx, x in x_singles.items(): + for sz, z in z_singles.items(): + table.setdefault(sx ^ sz, x ^ z) + return table + + +#################################################################################################### +# compiled coset search + + +@always_compiled +def _find_first_not_within_max_weight( + syndromes: npt.NDArray[np.uint64], + max_weight: int, + best: npt.NDArray[np.uint64], + inverse: npt.NDArray[np.uint64], + generators: npt.NDArray[np.uint64], + pivots: npt.NDArray[np.int64], + planning: _PlanningBuffer, + x_mask: npt.NDArray[np.uint64], + tails: _TailFields | None, + scale: int, +) -> tuple[int, int]: + """Search the coset of each syndrome in turn with ``_search_coset``. + + The tables, from ``inverse`` to ``x_mask``, and ``tails`` are those of ``_search_coset``. + Return the index of the first syndrome whose coset has no word of weight at most + ``max_weight``, and the lower bound that ``_search_coset`` returns for it, with the lightest + word that it weighed left in ``best``. If there is no such syndrome, return the number of + syndromes and zero. + """ + for index in range(len(syndromes)): + lower_bound = _search_coset( + syndromes[index], + max_weight, + best, + inverse, + generators, + pivots, + planning, + x_mask, + tails, + scale, + ) + if lower_bound > max_weight: + return index, lower_bound + return len(syndromes), 0 + + +@always_compiled(inline=True) +def _search_coset( + packed_syndrome: npt.NDArray[np.uint64], + max_weight: int, + best: npt.NDArray[np.uint64], + inverse: npt.NDArray[np.uint64], + generators: npt.NDArray[np.uint64], + pivots: npt.NDArray[np.int64], + planning: _PlanningBuffer, + x_mask: npt.NDArray[np.uint64], + tails: _TailFields | None, + scale: int, +) -> int: + """Search the coset of a syndrome for a word of weight at most ``max_weight``. + + ``packed_syndrome`` is the syndrome split into 64-bit machine words, with machine word ``j`` + holding bits ``64 j`` and up. The search runs on embedded words. In the Pauli search, the + words are Pauli errors, and the embedded word of an error is its packed Pauli when errors are + weighed by CSS weight, and its packed Pauli followed by ``x xor z`` when they are weighed by + physical weight, so that its Hamming weight is twice the physical weight (see + ``symplectic_to_hamming``). In a classical search, the words are X or Z parts, each is its own + embedded word, and its weight is its Hamming weight. The weight of an embedded word is that of + its word: its CSS weight if ``scale`` is zero, with its X part selected by ``x_mask``, and + otherwise its Hamming weight divided by ``scale``, which is two for the physical weight and one + for X or Z parts. The tables are those of ``_CosetSearchTables``. Row ``j`` of ``inverse`` is + the embedded word of a word whose syndrome has only bit ``j`` set. ``generators`` and + ``pivots`` are the information sets of a family of the embedded generators of the code, with + pivots padded with -1. ``planning`` is the planning buffer of the search with this family, + whose cached stopping steps a query may update. ``tails`` is None, or, on a route by tails + (see ``coset_routes``), the fields of the ``_CosetTails`` of the family, and then ``scale`` is + one or two, since the search weighs words by their Hamming weight. Numba compiles the search + separately for None and for tails, and leaves out the branches that the type of ``tails`` rules + out, so choosing between them costs nothing for each word. + + The embedded words of the coset form a coset of the embedded code. The search starts from the + sum of the rows of ``inverse`` for the set bits of the syndrome, a word with that syndrome. The + offset of an information set is this word reduced by rows of the set until it is zero on the + set's pivot columns. Every embedded word of the coset is the offset plus a unique sum of rows + of the set, and has a one on a pivot column exactly where it adds that pivot's row, so the BZ + certificate applies to the coset as it does to a space. Layer ``w`` of a set is its offset plus + each sum of ``w`` of its rows, so layer zero is the offset itself. + + The search reads the bounds of its steps from the table of the planning buffer for the parity of + the Hamming weight of the word of the syndrome (see ``_get_planning_table``). + + The search visits layer zero of the information sets in order, then layer one, and so on. It + keeps the lightest word visited in ``best``, and stops at the first word of weight at most + ``max_weight``. Once the first ``j`` sets have completed layer ``w``, every unvisited word + weighs at least the bound of step ``(w, j)`` in its table. So the search also stops at its + stopping step, the first step whose bound exceeds ``max_weight``, which ``_get_stopping_step`` + finds. It skips each set that adds nothing to the bound of that step (see + ``_search_from_syndrome_word``). A search by tails visits the same words in the same order, + except those that the tables of ``tails`` prove to lie beyond ``max_weight``, which it skips. + So it finds the same first word of weight at most ``max_weight``, if any. + + Returns: + A lower bound on the weight of every word in the coset: zero, which is at most + ``max_weight``, if ``best`` has weight at most ``max_weight``, and otherwise a bound that + exceeds ``max_weight`` (see ``_search_from_syndrome_word``). + """ + best_weight, parity = _get_syndrome_word(packed_syndrome, best, inverse, x_mask, scale) + if best_weight <= max_weight: + return 0 + table = _get_planning_table(planning, parity) + stop_layer, stop_num_completed, stop_bound = _get_stopping_step(table, max_weight) + # The search rounds to the parity of the coset if it weighs Hamming weights and its planning + # buffer has a table for each parity (see ``_get_planning_table``). + lower_bound, _, _ = _search_from_syndrome_word( + packed_syndrome, + max_weight, + stop_layer, + stop_num_completed, + stop_bound, + best, + best_weight, + generators, + pivots, + x_mask, + tails, + scale, + parity if scale != 0 and len(planning) == 2 else -1, + ) + return lower_bound + + +@always_compiled +def _search_from_syndrome_word( + packed_syndrome: npt.NDArray[np.uint64], + max_weight: int, + stop_layer: int, + stop_num_completed: int, + stop_bound: int, + best: npt.NDArray[np.uint64], + best_weight: int, + generators: npt.NDArray[np.uint64], + pivots: npt.NDArray[np.int64], + x_mask: npt.NDArray[np.uint64], + tails: _TailFields | None, + scale: int, + parity: int, +) -> tuple[int, int, int]: + """Search a coset, as ``_search_coset`` does, from the word of its syndrome. + + ``best`` holds that word, of weight ``best_weight`` above ``max_weight``, and + ``packed_syndrome`` is its syndrome. The stopping step and its bound are those of + ``_get_stopping_step``. ``parity`` is the parity of the Hamming weights of the words of the + coset if the search weighs Hamming weights and rounds its bounds to that parity (see + ``_get_search_tables``), and otherwise -1. A set of rank deficiency ``s`` adds to the bound of + a step only once it has completed layer ``s``, so a set that would not have completed it by the + stopping step adds nothing, and the search skips it. These are the unused sets of + ``information_sets.plan_steps``, whose work for this step, from layer zero, counts the words + that the search weighs after the word of the syndrome if it finds no light enough word. + + A search by tails (see ``_search_coset``) weighs a word by its rows below the rank of its set + and its tail. It keeps the set, the layer, and the support of the lightest word, and writes + that word into ``best`` before it returns. In each layer of two or more rows of a set with + tables, it skips the completions of a prefix whose bound reaches the skip bound: the least bound + that proves a word of the coset heavier than ``max_weight``, one more than the greatest Hamming + weight at most ``scale * max_weight`` that a word of the coset can have (see + ``affine_search.scan_coset_layer_by_tails_with_tables``). So it finds the same first word of + weight at most ``max_weight``, if any. If it finds none and skipped any word, it bounds the + coset by the least weight beyond ``max_weight`` that a word of the coset can have, which bounds + the skipped words, the words that it weighed, and the words beyond the stopping step. + + Returns: + A lower bound on the weight of every word in the coset: zero if the search finds a word of + weight at most ``max_weight``, the least weight beyond ``max_weight`` that a word of the + coset can have if it skipped any word, and otherwise the lesser of the weight of the + lightest word that it weighed and the bound of the stopping step. Also the numbers of words + weighed after the word of the syndrome and of supports skipped. Only tests read the + numbers, to check which sets a search skips and that a search by tails weighs as many words. + """ + # Reduce the word of the syndrome to the offset of each information set, or, by tails, find the + # rank of each set and the tail of its offset. + num_sets, num_rows, width = generators.shape + if tails is None: + offsets = _get_offsets(best, generators, pivots) + else: + tail_rows, inverse_tails, completion_tables, layout, block_bits = tails + ranks, offsets = _get_offset_tails(packed_syndrome, best, pivots, tail_rows, inverse_tails) + width = tail_rows.shape[2] + + # The scans weigh ``factor`` units for each unit of weight: Hamming weights, which are ``scale`` + # times the weights, or CSS weights if ``scale`` is zero. + factor = max(1, scale) + best_units, cutoff = factor * best_weight, factor * max_weight + skip_bound = _get_skip_bound(cutoff, parity) + + # Visit each layer one information set at a time, up to the stopping step. The scans take work + # space for one set. Those by tails keep the support of each lighter word in ``best_support``, + # and the others keep the word itself in ``best``. + best_set, best_layer = -1, 0 + weighed, skipped = 0, 0 + widths = np.full(1, width, dtype=np.int64) + support = np.empty(num_rows, dtype=np.int64) + best_support = np.empty(num_rows, dtype=np.int64) + prefixes = np.empty((num_rows, 1, width), dtype=np.uint64) + for layer in range(stop_layer + 1): + for set_index in range(num_sets if layer < stop_layer else stop_num_completed): + if certificates.is_unused_set(pivots, set_index, stop_layer, stop_num_completed): + continue + set_skipped = 0 + if layer == 0: + # The offset is zero on the pivot columns, so its tail has its Hamming weight. + weight, ones = _get_weight(offsets[set_index], x_mask, scale) + found, count = weight if scale == 0 else ones, 1 + if tails is None and found < best_units: + best[:] = offsets[set_index] + elif tails is None: + if scale == 0: + found, count = affine_search.scan_coset_layer_by_css_weight( + offsets[set_index], + generators[set_index : set_index + 1], + widths, + support[:layer], + prefixes, + best_units, + cutoff, + x_mask, + best, + ) + else: + found, count = affine_search.scan_coset_layer( + offsets[set_index], + generators[set_index : set_index + 1], + widths, + support[:layer], + prefixes, + best_units, + cutoff, + best, + ) + elif layer >= 2 and layout[set_index, 1]: + # The tables of the set start at ``layout[set_index, 0]``, with a row of tables for + # each row of the set but the last. + num_blocks = layout[set_index, 1] + block_offsets = layout[set_index, 2 : num_blocks + 3] + start = layout[set_index, 0] + end = start + (num_rows - 1) * block_offsets[-1] + found, count, set_skipped = affine_search.scan_coset_layer_by_tails_with_tables( + offsets[set_index], + tail_rows[set_index : set_index + 1], + ranks[set_index : set_index + 1], + widths, + support[:layer], + prefixes, + best_units, + cutoff, + skip_bound, + completion_tables[start:end], + block_offsets, + block_bits, + best_support, + ) + else: + found, count = affine_search.scan_coset_layer_by_tails( + offsets[set_index], + tail_rows[set_index : set_index + 1], + ranks[set_index : set_index + 1], + widths, + support[:layer], + prefixes, + best_units, + cutoff, + best_support, + ) + weighed += count + skipped += set_skipped + if found < best_units: + best_units, best_set, best_layer = found, set_index, layer + if best_units <= cutoff: + break + if best_units <= cutoff: + break + + # By tails, write the lightest word into ``best``, which holds the word of the syndrome until + # then. + if tails is not None and best_set >= 0: + _rebuild_word(best, generators[best_set], pivots[best_set], best_support[:best_layer]) + + # A search that finds no light enough word but skipped words bounds the coset by the least + # weight beyond the maximum weight that a word of the coset can have: the skip bound rounded up + # to the parity of the coset, then divided by the scale and rounded up, as the bounds of the + # steps are. + if best_units <= cutoff: + lower_bound = 0 + elif skipped: + rounded = skip_bound + (skip_bound - parity) % 2 if parity >= 0 else skip_bound + lower_bound = -(-rounded // factor) + else: + lower_bound = min(best_units // factor, stop_bound) + return lower_bound, weighed, skipped + + +@always_compiled +def _find_min_weights( + syndromes: npt.NDArray[np.uint64], + max_weight: int, + floors: npt.NDArray[np.int64], + inverse: npt.NDArray[np.uint64], + generators: npt.NDArray[np.uint64], + pivots: npt.NDArray[np.int64], + planning: _PlanningBuffer, + x_mask: npt.NDArray[np.uint64], + tails: _TailFields | None, + scale: int, +) -> npt.NDArray[np.int64]: + """Search the coset of each syndrome in turn with ``_search_coset_for_min_weight``. + + The arguments are those of ``_find_first_not_within_max_weight``, without ``best``, and with the + floor of each syndrome in ``floors``. Return the weight that ``_search_coset_for_min_weight`` + gives each syndrome. + """ + weights = np.empty(len(syndromes), dtype=np.int64) + best = np.empty(inverse.shape[1], dtype=np.uint64) + for index in range(len(syndromes)): + weight, _, _ = _search_coset_for_min_weight( + syndromes[index], + max_weight, + floors[index], + best, + inverse, + generators, + pivots, + planning, + x_mask, + tails, + scale, + ) + weights[index] = weight + return weights + + +@always_compiled(inline=True) +def _search_coset_for_min_weight( + packed_syndrome: npt.NDArray[np.uint64], + max_weight: int, + floor: int, + best: npt.NDArray[np.uint64], + inverse: npt.NDArray[np.uint64], + generators: npt.NDArray[np.uint64], + pivots: npt.NDArray[np.int64], + planning: _PlanningBuffer, + x_mask: npt.NDArray[np.uint64], + tails: _TailFields | None, + scale: int, +) -> tuple[int, int, int]: + """Search the coset of a syndrome for its minimum weight, if that is at most ``max_weight``. + + The arguments are those of ``_search_coset``, with a floor of at most ``max_weight``, and + ``best`` is work space. A word of weight at most ``floor`` ends the search, which saves work + for callers to which any such word gives the same answer. + + The search visits the layers of the information sets in the order of ``_search_coset``. Its + best weight is that of the word of the syndrome, or of the lightest word within ``max_weight`` + that it has found since. It stops at its stopping step, the first step whose bound reaches the + lesser of its best weight and ``max_weight + 1``, since every word that it has not visited then + weighs at least as much. This step moves earlier whenever the best weight falls. The search + skips each set that adds nothing to the bound of its current stopping step, as + ``_search_from_syndrome_word`` does. Such a set adds nothing to the bound of an earlier step + either, so the bound of the step at which the search stops holds. Within a step, every word not + yet visited weighs at least the bound of the step before it. So the search also stops as soon + as its best weight is at most that bound, or at most ``floor``. + + The scans weigh Hamming weights, which are ``scale`` times the weights, or CSS weights if + ``scale`` is zero. They keep only the words lighter than an incumbent: the skip bound of + ``max_weight`` (see ``_get_skip_bound``) until the search finds a word within that weight, and + then the best weight. Where every word of the coset has the parity of the Hamming weight of the + best word, no word is one lighter, so the incumbent is one less and the scans keep the same + words. This holds in a search of Pauli errors by physical weight, whose embedded words have + even Hamming weights, and in a search of X or Z parts that rounds to the parity of each coset. + The lower incumbent lets the tables of a search by tails skip more. + + A search by tails visits the same words in the same order, but weighs a word by its rows below + the rank of its set and its tail, as ``_search_from_syndrome_word`` does. In each layer of two + or more rows of a set with tables, it skips the completions of a prefix whose bound reaches the + best weight of the scan, which starts at its incumbent: the scan takes the skip bound of + ``max_weight``, which is at least the incumbent, as its skip bound (see + ``affine_search.scan_coset_layer_by_tails_with_tables``). Until the search finds a word within + ``max_weight``, the incumbent is that skip bound, at which a query by tails skips too. Then it + is the Hamming weight of the best word, or one less where every word of the coset has its + parity, so the search skips no word lighter than the best word. So the search gives the weight + of a search of whole words. For a coset without a word within ``max_weight``, it weighs and + skips the words that ``_search_from_syndrome_word`` weighs and skips at ``max_weight``. + + Returns: + The best weight when the search stops, or ``max_weight + 1`` if it found no word within + ``max_weight``, and the numbers of words weighed after the word of the syndrome and of + supports skipped. The best weight is the minimum weight of the coset, unless it is at most + ``floor``. Only tests read the numbers. + """ + # Start from the word of the syndrome, which ends the search if it is within the floor. Reduce + # it to the offset of each information set, or, by tails, find the rank of each set and the + # tail of its offset. + best_weight, parity = _get_syndrome_word(packed_syndrome, best, inverse, x_mask, scale) + if best_weight <= floor: + return best_weight, 0, 0 + bounds = _get_planning_table(planning, parity)[:-1] + num_sets, num_rows, width = generators.shape + if tails is None: + offsets = _get_offsets(best, generators, pivots) + else: + tail_rows, inverse_tails, completion_tables, layout, block_bits = tails + ranks, offsets = _get_offset_tails(packed_syndrome, best, pivots, tail_rows, inverse_tails) + width = tail_rows.shape[2] + + # Find the skip bound and the parity slack of the incumbent, in the units of the scans, which + # weigh ``factor`` units for each unit of weight. Only Hamming weights have a parity. Find + # also the upper bound ``U`` of the search, in units of weight (Section 3.10 of + # ``brouwer_zimmermann.md``), and its first stopping step. + factor = max(1, scale) + rounds = scale != 0 and len(planning) == 2 + skip_bound = _get_skip_bound(factor * max_weight, parity if rounds else -1) + slack = 1 if scale == 2 or rounds else 0 + best_units = factor * best_weight + upper_bound = min(best_weight, max_weight + 1) + stop_layer, stop_num_completed = _get_first_step_reaching(bounds, upper_bound) + + # Visit each layer one information set at a time, up to the stopping step. The scans take work + # space for one set. The search reaches a stopping step by the last layer, whose bounds are + # ``NO_WORD_BOUND``, since its upper bound is at most ``max_weight + 1``, which + # ``_CosetSearch.get_min_weights`` keeps at most ``n + 1``. The scans keep each lighter word in + # ``best``, or its support in ``best_support`` by tails, which are only work space, since the + # search returns no word. + weighed, skipped = 0, 0 + widths = np.full(1, width, dtype=np.int64) + support = np.empty(num_rows, dtype=np.int64) + best_support = np.empty(num_rows, dtype=np.int64) + prefixes = np.empty((num_rows, 1, width), dtype=np.uint64) + layer = 0 + while True: + for set_index in range(num_sets): + # Weigh the words of this step that are lighter than the incumbent, unless the set adds + # nothing to the bound of the stopping step, and return once the best weight is at most + # the floor or the bound of the step before this one. A scan returns once its best + # weight, which starts at the incumbent, is at most its cutoff, so the cutoff stays + # below the incumbent. + if not certificates.is_unused_set(pivots, set_index, stop_layer, stop_num_completed): + incumbent = min(best_units - slack, skip_bound) + step_floor = max(floor, bounds[layer, set_index]) + cutoff = min(factor * step_floor, incumbent - 1) + set_skipped = 0 + if layer == 0: + # The offset is zero on the pivot columns, so its tail has its Hamming weight. + weight, ones = _get_weight(offsets[set_index], x_mask, scale) + found, count = weight if scale == 0 else ones, 1 + elif tails is None: + if scale == 0: + found, count = affine_search.scan_coset_layer_by_css_weight( + offsets[set_index], + generators[set_index : set_index + 1], + widths, + support[:layer], + prefixes, + incumbent, + cutoff, + x_mask, + best, + ) + else: + found, count = affine_search.scan_coset_layer( + offsets[set_index], + generators[set_index : set_index + 1], + widths, + support[:layer], + prefixes, + incumbent, + cutoff, + best, + ) + elif layer >= 2 and layout[set_index, 1]: + # The tables of the set start at ``layout[set_index, 0]`` (see + # ``_search_from_syndrome_word``). + num_blocks = layout[set_index, 1] + block_offsets = layout[set_index, 2 : num_blocks + 3] + start = layout[set_index, 0] + end = start + (num_rows - 1) * block_offsets[-1] + found, count, set_skipped = affine_search.scan_coset_layer_by_tails_with_tables( + offsets[set_index], + tail_rows[set_index : set_index + 1], + ranks[set_index : set_index + 1], + widths, + support[:layer], + prefixes, + incumbent, + cutoff, + skip_bound, + completion_tables[start:end], + block_offsets, + block_bits, + best_support, + ) + else: + found, count = affine_search.scan_coset_layer_by_tails( + offsets[set_index], + tail_rows[set_index : set_index + 1], + ranks[set_index : set_index + 1], + widths, + support[:layer], + prefixes, + incumbent, + cutoff, + best_support, + ) + weighed += count + skipped += set_skipped + if found < incumbent: + best_units, best_weight = found, found // factor + if best_weight <= step_floor: + return best_weight, weighed, skipped + + # Stop at the stopping step, and move that step earlier if the best weight fell. + new_upper_bound = min(best_weight, max_weight + 1) + if bounds[layer, set_index + 1] >= new_upper_bound: + return new_upper_bound, weighed, skipped + if new_upper_bound < upper_bound: + upper_bound = new_upper_bound + stop_layer, stop_num_completed = _get_first_step_reaching(bounds, upper_bound) + layer += 1 + + +@always_compiled(inline=True) +def _get_offsets( + word: npt.NDArray[np.uint64], generators: npt.NDArray[np.uint64], pivots: npt.NDArray[np.int64] +) -> npt.NDArray[np.uint64]: + """Reduce a word of a coset to the offset of each information set, which is zero on its pivots. + + ``generators`` and ``pivots`` are those of ``_search_coset``. + """ + num_sets, _, width = generators.shape + offsets = np.empty((num_sets, width), dtype=np.uint64) + for set_index in range(num_sets): + offsets[set_index] = word + _reduce_to_offset(offsets[set_index], generators[set_index], pivots[set_index]) + return offsets + + +@always_compiled(inline=True) +def _get_offset_tails( + packed_syndrome: npt.NDArray[np.uint64], + word: npt.NDArray[np.uint64], + pivots: npt.NDArray[np.int64], + tails: npt.NDArray[np.uint64], + inverse_tails: npt.NDArray[np.uint64], +) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.uint64]]: + """Find the rank of each information set and the tail of its offset. + + ``word`` is the word of the syndrome ``packed_syndrome``, ``pivots`` are those of + ``_search_coset``, and ``tails`` and ``inverse_tails`` are those of ``_CosetTails``. The rows + of a set with pivots on which the word has ones reduce the word to the offset, since every row + is zero on the pivots of the other rows. The tail of the word sums the tails of the rows of the + inverse for the set bits of the syndrome. + """ + num_sets, num_rows, tail_width = tails.shape + ranks = np.zeros(num_sets, dtype=np.int64) + offset_tails = np.zeros((num_sets, tail_width), dtype=np.uint64) + for set_index in range(num_sets): + _xor_selected_rows(packed_syndrome, inverse_tails[set_index], offset_tails[set_index]) + rank = 0 + while rank < num_rows and pivots[set_index, rank] >= 0: + pivot = pivots[set_index, rank] + if word[pivot // 64] >> np.uint64(pivot % 64) & np.uint64(1): + for index in range(tail_width): + offset_tails[set_index, index] ^= tails[set_index, rank, index] + rank += 1 + ranks[set_index] = rank + return ranks, offset_tails + + +@always_compiled(inline=True) +def _get_skip_bound(cutoff: int, parity: int) -> int: + """The least Hamming weight that proves a word of a coset heavier than ``cutoff``. + + It is one more than the greatest Hamming weight at most the cutoff that a word of the coset can + have. ``parity`` is the parity of the coset if the search rounds to it, and -1 otherwise. The + bound is ``cutoff + 1``, or ``cutoff`` if the search rounds and the words have the parity of + ``cutoff + 1``. + """ + if parity >= 0 and (cutoff - parity) % 2: + return cutoff + return cutoff + 1 + + +@always_compiled +def _rebuild_word( + word: npt.NDArray[np.uint64], + rows: npt.NDArray[np.uint64], + pivots: npt.NDArray[np.int64], + support: npt.NDArray[np.int64], +) -> None: + """Turn a word of a coset into the offset of an information set plus the rows of a support. + + ``rows`` and ``pivots`` are those of the set, as for ``_reduce_to_offset``. + """ + _reduce_to_offset(word, rows, pivots) + for row in support: + for index in range(len(word)): + word[index] ^= rows[row, index] + + +@always_compiled(inline=True) +def _reduce_to_offset( + word: npt.NDArray[np.uint64], rows: npt.NDArray[np.uint64], pivots: npt.NDArray[np.int64] +) -> None: + """Reduce a word of a coset to the offset of an information set, which is zero on its pivots. + + ``rows`` are the packed rows of the set, and ``pivots`` their pivot columns, padded with -1 + beyond its rank. The word adds the row of each pivot that it has a one on, since every row is + zero on the pivots of the other rows. + """ + for row in range(len(pivots)): + pivot = pivots[row] + if pivot < 0: + break + if word[pivot // 64] >> np.uint64(pivot % 64) & np.uint64(1): + for index in range(len(word)): + word[index] ^= rows[row, index] + + +@always_compiled(inline=True) +def _get_syndrome_word( + packed_syndrome: npt.NDArray[np.uint64], + word: npt.NDArray[np.uint64], + inverse: npt.NDArray[np.uint64], + x_mask: npt.NDArray[np.uint64], + scale: int, +) -> tuple[int, int]: + """Write the sum of the rows of ``inverse`` for the set bits of a packed syndrome into ``word``. + + The sum is an embedded word with that syndrome. Return its weight (see ``_search_coset``), and + the parity of its Hamming weight. + """ + word[:] = 0 + _xor_selected_rows(packed_syndrome, inverse, word) + weight, ones = _get_weight(word, x_mask, scale) + return weight, ones % 2 + + +@always_compiled +def _get_weight( + word: npt.NDArray[np.uint64], x_mask: npt.NDArray[np.uint64], scale: int +) -> tuple[int, int]: + """The weight of the error with an embedded word, and the Hamming weight of the embedded word. + + See ``_search_coset``. + """ + ones = x_ones = np.int64(0) + for index in range(len(word)): + ones += popcount_word(word[index]) + x_ones += popcount_word(word[index] & x_mask[index]) + return int(max(x_ones, ones - x_ones) if scale == 0 else ones // scale), int(ones) diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py new file mode 100644 index 000000000..33cf7bc95 --- /dev/null +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py @@ -0,0 +1,290 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Tables of the Brouwer-Zimmermann coset searches: bounds of steps, stopping steps, and tails.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import NamedTuple + +import numpy as np +import numpy.typing as npt + +from qldpc._binary import pack_bits, unpack_bits +from qldpc._compilation import always_compiled + +from .._cosets import _int_to_words +from .._information_sets import certificates, completion_bounds +from . import affine_instance + +# The planning buffer of a coset search: its tables of the bounds of its steps, each with one more +# row that caches a stopping step (see ``_get_planning``). +_PlanningBuffer = npt.NDArray[np.int64] + +# The fields of a ``_CosetTails`` record, which the compiled searches take as the record itself or +# as a plain tuple. +_TailFields = tuple[ + npt.NDArray[np.uint64], + npt.NDArray[np.uint64], + npt.NDArray[np.uint8], + npt.NDArray[np.int64], + int, +] + + +class _CosetSearchTables(NamedTuple): + """The tables of ``_search_coset`` for one coset search of a ``CosetLeaderSearch``. + + The search runs on embedded words. For a search of Pauli errors, the embedded word of an error + is its packed Pauli for the CSS weight, and its packed Pauli followed by ``x xor z`` for the + physical weight (see ``symplectic_to_hamming``). For a search of X or Z parts, the embedded + word of a part is the part itself. + + Attributes: + inverse: Row ``j`` is the packed embedded word of a word whose syndrome has only bit ``j`` + set. + generators: The packed rows of each set of a family of information sets of the embedded + generators of the code (see ``coset_routes``). The last set of a disjoint family may + be partial, of rank below the dimension of the code. + pivots: The pivot columns of each set, padded with -1 beyond its rank. + planning: The planning buffer of the search with this family: its tables of the bounds of + its steps, each with one more row that caches a stopping step (see ``_get_planning``). + For a nonnegative maximum weight, ``_get_stopping_step`` reuses the cached step of a + table exactly while it stays the first step whose bound exceeds that weight, and + replaces it otherwise. Queries write only the rows of cached steps, and oracles that + share a cached search share them. + x_mask: Selects the bits of a packed embedded word that count as its X part, with one + machine word for each machine word of the embedded words. Only searches of Pauli + errors by CSS weight use the X part, and their layers scan with it, but every search + reads the mask when it weighs the word of a syndrome, and a search of whole words also + reads it when it weighs the offsets of layer zero. + """ + + inverse: npt.NDArray[np.uint64] + generators: npt.NDArray[np.uint64] + pivots: npt.NDArray[np.int64] + planning: _PlanningBuffer + x_mask: npt.NDArray[np.uint64] + + +class _CosetTails(NamedTuple): + """The tails with which ``_search_coset`` weighs the words of a family by tails, and its tables. + + The tail of an embedded word on a set of the family holds every column of the word but the + pivot columns of the set, including columns on which no row of the set has a one. The tails are + packed, and padded with zeros to one common width, that of the set of least rank. A word of a + coset is the offset of a set, which is zero on its pivot columns, plus rows of the set. Each + row below the rank of the set has a one on its own pivot column, where the other rows are zero. + So the word weighs the number of its rows below the rank plus the weight of its tail (see + Section 3.5 of ``brouwer_zimmermann.md``). + + Attributes: + tails: The tails of the rows of each set, with shape ``(num_sets, num_rows, num_words)``. + inverse_tails: The tails of the rows of ``inverse`` of ``_CosetSearchTables`` on each set, + with shape ``(num_sets, num_syndrome_bits, num_words)``. The tail of the word of a + syndrome on a set is the sum of the rows for the set bits of the syndrome. These rows + can be derived from ``inverse``, but a query that sums them directly finds the tails of + its offsets faster than one that gathers the columns of each tail from the full word. + completion_tables: The completion tables of the tails of the sets that have them, one after + another (see ``completion_bounds.CompletionTables``). + layout: Where the tables of each set lie in ``completion_tables``, with a row for each set: + the start of its tables, their number of blocks ``b``, and their ``b + 1`` block + offsets, padded with zeros. A set without tables has zero blocks. The compiled search + takes the layout of every set in one array, since each argument of a compiled call adds + to the time of every query. + block_bits: The number of columns of the blocks of the tables. + """ + + tails: npt.NDArray[np.uint64] + inverse_tails: npt.NDArray[np.uint64] + completion_tables: npt.NDArray[np.uint8] + layout: npt.NDArray[np.int64] + block_bits: int + + +def _get_search_tables( + instance: affine_instance.AffineInstance, inverse: npt.NDArray[np.uint8], num_x_columns: int +) -> _CosetSearchTables: + """Build the tables of ``_search_coset`` for the cosets of the code of a coset instance. + + ``inverse`` holds embedded words whose syndromes have one bit set, and the first + ``num_x_columns`` columns count as the X part of an embedded word. + + The search rounds its bounds to the parity of each coset if every word of the code has even + Hamming weight and some coset is odd. Then the words of each coset share a parity (see + ``get_coset_instance``). The word of a syndrome sums rows of ``inverse``, so some coset is odd + exactly when some row is. If the code and every row of ``inverse`` are even, every coset is + even. This happens only in the search of Pauli errors by physical weight, whose bounds are + halved, so that rounding them up to even would change none. + """ + width = instance.generators.shape[2] + x_mask = _int_to_words((1 << num_x_columns) - 1, width) + + # Round to the parity of each coset if the code is even and some coset is odd. The rows of any + # one information set span the code. + even_code = not np.any(np.bitwise_count(instance.generators[0]).sum(axis=1) % 2) + has_odd_coset = bool(np.any(np.count_nonzero(inverse, axis=1) % 2)) + parities = (0, 1) if even_code and has_odd_coset else (None,) + return _CosetSearchTables( + pack_bits(inverse), + instance.generators, + instance.pivots, + _get_planning([instance.get_weight_bounds(parity) for parity in parities]), + x_mask, + ) + + +def _get_coset_tails( + instance: affine_instance.AffineInstance, + sets: list[int], + record: _CosetTails | None, + *, + inverse: npt.NDArray[np.uint8], + block_bits: int, + max_table_bytes: int, +) -> _CosetTails: + """Build the record of the tails of the family of a coset instance, with tables of some sets. + + ``inverse`` holds embedded words whose syndromes have one bit set, with every column of the + embedded words. The tails of each set keep every column but its pivots, and are zero-padded to + the width of the set of least rank, which keeps the most columns. A new record has tables on + blocks of ``block_bits`` columns. Given the ``record`` of the family, the function keeps its + tails, tables, and blocks rather than build them again. The record that it returns also has the + completion tables of each set of ``sets``, unless they would exceed ``max_table_bytes`` bytes or + be empty (see ``completion_bounds.build_tables_within``). It holds the tables of each set once, + and is ``record`` itself if it adds no tables. + """ + # Build the tails of the family once, with a layout of no tables. + if record is None: + num_sets, num_rows, width = instance.generators.shape + num_columns = inverse.shape[1] + rows = unpack_bits(instance.generators.reshape(-1, width), num_columns) + rows = rows.reshape(num_sets, num_rows, num_columns) + tail_width = (num_columns - int(instance.ranks.min()) + 63) // 64 + tails = np.zeros((num_sets, num_rows, tail_width), dtype=np.uint64) + inverse_tails = np.zeros((num_sets, len(inverse), tail_width), dtype=np.uint64) + for set_index, (pivots, rank) in enumerate( + zip(instance.pivots, instance.ranks, strict=True) + ): + columns = np.delete(np.arange(num_columns), pivots[:rank]) + set_width = (len(columns) + 63) // 64 + tails[set_index, :, :set_width] = pack_bits(rows[set_index][:, columns]) + inverse_tails[set_index, :, :set_width] = pack_bits(inverse[:, columns]) + # The tables of a set have at most one block for each block_bits columns of its tails, and + # block_bits divides the 64 bits of a word. + max_blocks = 64 * tail_width // block_bits + layout = np.zeros((num_sets, max_blocks + 3), dtype=np.int64) + record = _CosetTails(tails, inverse_tails, np.zeros(0, np.uint8), layout, block_bits) + + # Append the tables of each set that has none yet, if they fit, and lay them out. The routes + # keep only the latest record of a family, so the earlier tables are held once. + layout = record.layout.copy() + added = [] + start = len(record.completion_tables) + for set_index in sets: + if layout[set_index, 1]: + continue + tables = completion_bounds.build_tables_within( + record.tails[set_index], record.block_bits, max_table_bytes + ) + if tables is not None: + num_blocks = len(tables.offsets) - 1 + layout[set_index, : num_blocks + 3] = [start, num_blocks, *tables.offsets] + added.append(tables.tables) + start += len(tables.tables) + if not added: + return record + return record._replace( + completion_tables=np.concatenate([record.completion_tables, *added]), layout=layout + ) + + +def _get_planning(bound_tables: Sequence[npt.NDArray[np.int64]]) -> _PlanningBuffer: + """Build the planning buffer of a coset search from tables of the bounds of its steps. + + The compiled search takes the bounds and its cached stopping steps in this one array, since each + argument of a compiled call adds to the time of every query. The buffer stacks one table for + each given table of bounds. A search that rounds its bounds to parity (see + ``_get_search_tables``) gives two tables: table ``p`` holds the bounds of the cosets whose words + have Hamming weights of parity ``p`` (see ``_get_planning_table``). Other searches give one + table, for every coset. Each table of the buffer has one more row than the bounds: + + - Every row but the last holds the bounds of ``AffineInstance.get_weight_bounds``: entry + ``[w, j]`` bounds the weight of every word that the search has not visited once the first + ``j`` sets have completed layer ``w`` and the others layer ``w - 1``. So column zero of each + row holds the bound before the first step of that row. These rows never change, and code + reads them as the view ``table[:-1]``. + - The last row holds no bounds. Its first two entries are the layer and the number of completed + sets of the stopping step that ``_get_stopping_step`` returned last for this table, or of the + first step, ``(0, 1)``, before its first call, and its other entries are zero. Only + ``_get_stopping_step`` reads and writes this row. + """ + planning = np.zeros( + (len(bound_tables), len(bound_tables[0]) + 1, bound_tables[0].shape[1]), dtype=np.int64 + ) + planning[:, :-1] = bound_tables + planning[:, -1, 1] = 1 + return planning + + +@always_compiled +def _get_planning_table(planning: _PlanningBuffer, parity: int) -> npt.NDArray[np.int64]: + """The table of a planning buffer for a coset whose Hamming weights have parity ``parity``. + + The Hamming weights are those of the embedded words of the coset. A buffer with a table for + each parity belongs to an even code, whose cosets each have Hamming weights of one parity, and + gives the table of the parity (see ``_get_planning``). A buffer with one table gives it for + every coset. + """ + return planning[parity] if len(planning) == 2 else planning[0] + + +@always_compiled +def _get_stopping_step(table: npt.NDArray[np.int64], max_weight: int) -> tuple[int, int, int]: + """Find the step at which a coset query stops, and cache it in a table of the planning buffer. + + The stopping step is the first step whose bound in the table exceeds the maximum weight: the + step at which ``stop_num_completed`` sets complete layer ``stop_layer``. A query stops there, + unless it finds a light enough word sooner. The bounds of the steps do not decrease in their + order. Column zero of each row of bounds holds the bound before the first step of the row, + which is zero in row zero. So the bound of the step before step ``(w, j)`` is entry + ``[w, j - 1]``, and for a nonnegative maximum weight, step ``(w, j)`` is the stopping step + exactly if its bound exceeds the maximum weight and entry ``[w, j - 1]`` does not. The step + cached in the table (see ``_get_planning``) is reused if it passes this check. Otherwise, + ``information_sets.find_stopping_step`` finds the stopping step by binary search, and it is + cached in its place. A negative maximum weight fails the check, and the binary search returns + the first step. If no bound exceeds the maximum weight, which happens only when the maximum + weight reaches the largest native integer, the binary search returns the last step. + + Oracles that share a cached search share its planning buffer. Compiled calls hold the global + interpreter lock, since numba compiles them without ``nogil``, so calls interleave only when + these functions run as plain Python in several threads, or in free-threaded Python. The layer + and the number of completed sets of a step are written as two entries, so a read may pair the + layer of one step with the number of completed sets of another. Any such pair indexes the + bounds, which never change, and it is checked against them before it is used. + + Returns: + ``stop_layer``, ``stop_num_completed``, and the bound of that step. + """ + bounds = table[:-1] + layer, num_completed = table[-1, 0], table[-1, 1] + is_stopping_step = ( + max_weight < bounds[layer, num_completed] and bounds[layer, num_completed - 1] <= max_weight + ) + if not is_stopping_step: + layer, num_completed = certificates.find_stopping_step( + bounds, max_weight, 0, len(bounds) - 1 + ) + table[-1, 0], table[-1, 1] = layer, num_completed + return layer, num_completed, int(bounds[layer, num_completed]) + + +@always_compiled(inline=True) +def _get_first_step_reaching(bounds: npt.NDArray[np.int64], target: int) -> tuple[int, int]: + """The first step whose bound in a table of bounds of steps reaches ``target``. + + It is the stopping step of ``information_sets.find_stopping_step`` for the limit + ``target - 1``. Unlike ``_get_stopping_step``, this function caches no step, so the cached + steps of the planning buffer serve only the queries of ``_search_coset``. + """ + return certificates.find_stopping_step(bounds, target - 1, 0, len(bounds) - 1) diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py new file mode 100644 index 000000000..937dfcf8d --- /dev/null +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the tables, tails, and stopping steps of Brouwer-Zimmermann coset searches.""" + +from __future__ import annotations + +import itertools +from typing import Any + +import numpy as np +import pytest + +from qldpc._binary import get_kernel + +from .. import _cosets +from .._information_sets import certificates as certificates_module +from .._information_sets import completion_bounds +from ..conftest import ( + _RANK_DEFICIENT_CODE, +) +from . import affine_instance, coset_tables + + +def test_cached_stopping_steps_are_reused_exactly_while_they_stay_first( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """For nonnegative weights, a cached stopping step is reused exactly while it stays first. + + The stopping step of a maximum weight is the first step whose bound exceeds it. + ``_get_stopping_step`` reuses the step that a planning buffer caches if that step is the + stopping step. Otherwise, it plans the stopping step, which a binary search finds, and caches + it. A reuse is observable only as a skipped plan, so the test records the plans. + + Each step of the bounds is planted as the cached step. A planted step may be stale, or pair the + layer of one step with the number of completed sets of another, as interleaved queries might + leave it. Each maximum weight from zero to past the largest finite bound is then queried twice. + The first query plans exactly if the planted step is not its stopping step. The second query + does not plan, since the first cached its step. Both queries return the stopping step, which a + linear search finds, and its bound. The bounds in the buffer never change. A negative maximum + weight always plans, since every bound is at least zero, so it is left out. + + The bounds are those of the empty code, of a rank-one code with 64 sets, and of sets of ranks + zero, two, and four in a code of dimension four, for both weight factors. + """ + plans: list[int] = [] + find_stopping_step = certificates_module.find_stopping_step + + def record(*args: Any) -> tuple[int, int]: + plans.append(args[1]) + return find_stopping_step(*args) + + monkeypatch.setattr(certificates_module, "find_stopping_step", record) + # Take the Python version even when the function is compiled, since only it calls the recording + # function. + function = coset_tables._get_stopping_step + get_stopping_step = getattr(function, "py_func", function) + certificates = [ + certificates_module.Certificate(0, (0,)), + certificates_module.Certificate(1, (1,) * 64), + certificates_module.Certificate(4, (0, 2, 4, 0)), + ] + for certificate, factor in itertools.product(certificates, (1, 2)): + bounds = certificate.get_weight_bounds(factor) + num_rows, num_sets = bounds.shape[0] - 1, bounds.shape[1] - 1 + steps = list(itertools.product(range(num_rows + 1), range(1, num_sets + 1))) + largest = int(bounds[bounds < certificates_module.NO_WORD_BOUND].max()) + (table,) = coset_tables._get_planning([bounds]) + for planted, max_weight in itertools.product(steps, range(largest + 2)): + stopping_step = next(step for step in steps if bounds[step] > max_weight) + expected = (*stopping_step, int(bounds[stopping_step])) + # Plant the cached step in the last row of the table (see ``_get_planning``). + table[-1, :2] = planted + plans.clear() + assert get_stopping_step(table, max_weight) == expected + assert len(plans) == (planted != stopping_step) + assert get_stopping_step(table, max_weight) == expected + assert len(plans) == (planted != stopping_step) + assert np.array_equal(table[:-1], bounds) + + +def test_records_of_tails_add_the_tables_of_the_sets_that_fit() -> None: + """A record of tails gains the tables of the given sets that fit its limit, once for each set. + + The rank-deficient code has disjoint information sets of ranks five and four, whose tails of + five and six columns have tables of 128 and 256 bytes on blocks of eight columns. Under a limit + of 128 bytes, only the first set gets tables, in the layout of the record, and a second request + for both sets keeps the record. Under a larger limit, the second set gets its tables too in a + new record, and the first set keeps its own, while the earlier record stays as it was. A record + built under that limit gets the tables of both sets at once. Each record holds the tables of + each set once. + """ + checks = get_kernel(_RANK_DEFICIENT_CODE) + instance = affine_instance.get_coset_instance( + _RANK_DEFICIENT_CODE, random_column_orders=0, weight_factor=1 + ) + assert instance.ranks.tolist() == [5, 4] + inverse = _cosets._get_inverse(checks) + + def get_record( + sets: list[int], record: coset_tables._CosetTails | None, max_table_bytes: int + ) -> coset_tables._CosetTails: + """The record of the tails of the family, with the tables of the sets that fit.""" + return coset_tables._get_coset_tails( + instance, sets, record, inverse=inverse, block_bits=8, max_table_bytes=max_table_bytes + ) + + def get_set_tables(record: coset_tables._CosetTails, set_index: int) -> list[int]: + """The tables of a set, as its layout places them in the record.""" + start, num_blocks = record.layout[set_index, :2] + offsets = record.layout[set_index, 2 : num_blocks + 3] + size = (len(_RANK_DEFICIENT_CODE) - 1) * offsets[-1] if num_blocks else 0 + assert start + size <= len(record.completion_tables) + return record.completion_tables[start : start + size].tolist() + + expected = [ + completion_bounds.CompletionTables.build(tails, 8) + for tails in get_record([], None, 0).tails + ] + first = get_record([0, 1], None, 128) + assert get_set_tables(first, 0) == expected[0].tables.tolist() + assert get_set_tables(first, 1) == [] + assert get_record([0, 1], first, 128) is first + for both in (get_record([1], first, 256), get_record([0, 1], None, 256)): + assert [get_set_tables(both, index) for index in range(2)] == [ + tables.tables.tolist() for tables in expected + ] + assert len(both.completion_tables) == sum(len(tables.tables) for tables in expected) + assert get_set_tables(first, 1) == [] diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py new file mode 100644 index 000000000..894805313 --- /dev/null +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py @@ -0,0 +1,1178 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the Brouwer-Zimmermann coset searches of ``CosetLeaderSearch``.""" + +from __future__ import annotations + +import inspect +import itertools +from collections.abc import Sequence +from math import comb +from typing import Any + +import numpy as np +import numpy.typing as npt +import pytest +import stim + +from qldpc import math +from qldpc._binary import get_kernel, pack_bits, row_reduce_binary, unpack_bits + +from ...conftest import Tune +from .. import _cosets +from .._binary import symplectic_to_hamming +from .._coset_leader_search import CosetLeaderSearch +from .._cosets import MinWeightBound +from .._information_sets import basis_families, completion_bounds +from .._information_sets import certificates as certificates_module +from .._tuning import DEFAULT_TUNING, MAX_VALUE, get_tuning, use_tuning +from ..conftest import ( + _RANK_DEFICIENT_CODE, + _ROUTED_CSS_WEIGHT_STATE, + _ROUTED_PHYSICAL_STATES, + EVEN_ROUTES_CODE, + OVERLAPPING_ROUTES_CODE, + TWO_FAMILIES_CODE, + _assert_exact_answers, + _css_minima, + _get_syndrome, + _graph_state, + _pack_syndromes, + _product_state, + _scramble, + _stabilizer_matrix, + get_binary_matrix, + get_random_full_rank_matrix, +) +from . import affine_instance, coset, coset_tables + + +def _classical_syndrome(checks: npt.NDArray[np.uint8], words: npt.NDArray[np.int_]) -> Any: + """The packed syndrome of a binary word for binary checks, or of each row of an array of words. + + Bit ``j`` of a syndrome is the parity of the word with row ``j`` of ``checks``. + """ + return (words @ checks.T % 2) @ (1 << np.arange(len(checks))) + + +def _classical_minima(checks: npt.NDArray[np.uint8]) -> npt.NDArray[np.int_]: + """The least Hamming weight of the words with each packed syndrome for binary checks. + + The minima come from all ``2**n`` words of ``n`` bits. + """ + num_columns = checks.shape[1] + words = (np.arange(1 << num_columns)[:, None] >> np.arange(num_columns)) & 1 + minima = np.full(1 << len(checks), num_columns + 1) + np.minimum.at(minima, _classical_syndrome(checks, words), words.sum(axis=1)) + return minima + + +def test_css_weight_searches_of_even_stabilizers_stop_at_bounds_of_each_class_parity() -> None: + """Searches of Pauli errors by CSS weight stop at the bounds of each class, rounded to parity. + + The state is the graph state of a triangle on qubits 2, 5, and 6, with qubit 1 joined to qubit 2 + and qubits 0, 3, and 4 isolated, after S on each qubit of even degree. Its stabilizers have + even weight as binary words ``(x|z)``, and it is not CSS, so the oracle searches its Pauli + errors. The errors of a syndrome class share the parity of ``|x| + |z|``, which the test reads + from every error. Every query agrees with exhaustive minima. A search that took the parity of + the CSS weight instead would not: for syndrome 57, whose class has minimum two, the word of the + syndrome has Hamming weight four but CSS weight three, and the bounds of odd classes stop the + search at maximum weight two before it finds an error of weight two. + + Answers alone cannot show whether these searches round, since rounding mainly moves where an + odd class stops (see Section 3.2 of ``brouwer_zimmermann.md``). So the test also finds the + stopping step of each query as ``_search_coset`` does, and checks it against the first step + whose bound in ``AffineInstance.get_weight_bounds`` for the parity of the class exceeds the + maximum weight. ``information_sets_test.py`` and ``affine_instance_test.py`` check those bounds + against their definition and against brute force. Some odd classes stop sooner than they would + without parity. + """ + generators = ("Y______", "_XZ____", "_ZX__ZZ", "___Y___", "____Y__", "__Z__YZ", "__Z__ZY") + matrix = _stabilizer_matrix([stim.PauliString(pauli) for pauli in generators]) + _assert_exact_answers(CosetLeaderSearch(matrix, css_weight=True), matrix, True) + + # Read the parity of each syndrome class from every error, and check that the class shares it. + num_qubits = len(matrix) + checks = (math.symplectic_conjugate(matrix.astype(int)) % 2).astype(np.uint8) + errors = (np.arange(1 << 2 * num_qubits)[:, None] >> np.arange(2 * num_qubits)) & 1 + syndromes, parities = _classical_syndrome(checks, errors), errors.sum(axis=1) % 2 + class_parities = np.zeros(1 << num_qubits, dtype=int) + class_parities[syndromes] = parities + assert np.array_equal(class_parities[syndromes], parities) + + # Find the stopping step of each query as ``_search_coset`` does. Steps ``(layer, + # num_completed)`` compare as tuples in the order of the search. + instance = affine_instance.get_coset_instance(matrix, random_column_orders=0, weight_factor=2) + inverse = _cosets._get_inverse(checks) + tables = coset_tables._get_search_tables(instance, inverse, num_qubits) + unrounded = instance.get_weight_bounds() + steps = list(itertools.product(range(len(unrounded)), range(1, unrounded.shape[1]))) + best = np.empty(tables.inverse.shape[1], dtype=np.uint64) + num_sooner = 0 + for syndrome in range(1 << num_qubits): + _, parity = coset._get_syndrome_word( + _pack_syndromes([syndrome], num_qubits)[0], best, tables.inverse, tables.x_mask, 0 + ) + table = coset_tables._get_planning_table(tables.planning, parity) + bounds = instance.get_weight_bounds(class_parities[syndrome]) + for max_weight in range(2, num_qubits): + stopping_step = next(step for step in steps if bounds[step] > max_weight) + assert coset_tables._get_stopping_step(table, max_weight)[:2] == stopping_step + num_sooner += stopping_step < next( + step for step in steps if unrounded[step] > max_weight + ) + assert num_sooner + + +def test_cached_searches_answer_only_for_their_stabilizer_group( + pytestconfig: pytest.Config, +) -> None: + """Oracles built in turn for |000+++>, another generating set of it, and |+++000> are exact. + + The searches of a CSS state are cached by stabilizer group, so the second oracle reuses those of + the first. |+++000> has the same numbers of qubits and of pure-X stabilizers as |000+++>, but a + different stabilizer group, so its oracle must not reuse them. + """ + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + eye, zeros = np.eye(3, dtype=np.uint8), np.zeros((3, 3), dtype=np.uint8) + zero_plus = np.block([[zeros, eye, zeros, zeros], [zeros, zeros, eye, zeros]]) + plus_zero = np.block([[eye, zeros, zeros, zeros], [zeros, zeros, zeros, eye]]) + matrices = [_scramble(matrix, rng) for matrix in (zero_plus, zero_plus, plus_zero)] + coset._get_css_searches.cache_clear() + oracles = [CosetLeaderSearch(matrix, css_weight=True) for matrix in matrices] + for oracle, matrix in zip(oracles, matrices, strict=True): + minima = _css_minima(matrix) + for syndrome, max_weight in itertools.product(range(1 << 6), range(4)): + bound = oracle.get_min_weight_bound(syndrome, max_weight) + assert bound.within_max_weight == (minima[syndrome] <= max_weight) + assert _get_syndrome(matrix, bound.representative) == syndrome + + +def test_oracles_use_the_tuning_with_which_they_are_built(monkeypatch: pytest.MonkeyPatch) -> None: + """An oracle builds its searches with the tuning in force, keyed by the fields that they read. + + The searches are cached, and the column orders key the cache. So the first oracle built under + some orders builds searches of its own with them, and a second oracle built under the same + orders shares those searches. The orders reach a search through + ``information_sets.get_disjoint_family``, which the test wraps to record them. The states are + |000+++>, whose two classical searches are cached by stabilizer group, and a two-qubit graph + state with phases, which is not CSS, so its search of Pauli errors is cached by stabilizers. + Every oracle answers exactly. The caches are cleared first, since earlier tests may have filled + them. Only a search of Pauli errors by CSS weight reads ``max_css_single_pairs``, so the + classical searches, and a search of Pauli errors by physical weight, are shared by oracles built + under different values of it, and a search of Pauli errors by CSS weight is not. That search + alone has no completion tables, so it alone is shared by oracles built under different values of + ``coset_completion_block_bits``. + """ + orders: list[int] = [] + get_disjoint_family = basis_families.get_disjoint_family + + def record(*args: Any, random_column_orders: int) -> basis_families.BasisFamily: + orders.append(random_column_orders) + return get_disjoint_family(*args, random_column_orders=random_column_orders) + + monkeypatch.setattr(basis_families, "get_disjoint_family", record) + eye, zeros = np.eye(3, dtype=np.uint8), np.zeros((3, 3), dtype=np.uint8) + zero_plus = np.block([[zeros, eye, zeros, zeros], [zeros, zeros, eye, zeros]]) + phased = _graph_state(2, [(0, 1)], phase=True) + coset._get_css_searches.cache_clear() + coset._get_pauli_search.cache_clear() + default = DEFAULT_TUNING.disjoint_random_column_orders + for column_orders, matrix in itertools.product((default, 3), (zero_plus, phased)): + with use_tuning(disjoint_random_column_orders=column_orders): + first = CosetLeaderSearch(matrix, css_weight=True) + assert orders and set(orders) == {column_orders} + orders.clear() + second = CosetLeaderSearch(matrix, css_weight=True) + assert not orders + for oracle in (first, second): + _assert_exact_answers(oracle, matrix, True) + for matrix, css_weight in ((zero_plus, True), (phased, False), (phased, True)): + CosetLeaderSearch(matrix, css_weight=css_weight) + paulis_by_css_weight = matrix is phased and css_weight + changes: list[tuple[dict[str, Any], bool]] = [ + ({"max_css_single_pairs": 0}, paulis_by_css_weight), + ({"coset_completion_block_bits": 4}, not paulis_by_css_weight), + ] + for change, reads in changes: + orders.clear() + with use_tuning(**change): + CosetLeaderSearch(matrix, css_weight=css_weight) + assert bool(orders) == reads + + +def test_oracles_build_other_families_with_the_tuning_they_are_built_with( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Costly queries build their families and tables with the tuning of the oracle that answers. + + With the threshold of the gate at zero, the first query that searches builds the other families + of the search (see ``coset_routes``). The most sets of the overlapping family and the column + orders that it tries for each set reach ``information_sets.get_overlapping_family``, and the + width of the blocks of completion tables and their limit of bytes reach + ``completion_bounds.build_tables_within``, which the test wraps to record them. They are those + of the tuning in force when the oracle was built, not when it answers. + """ + calls: list[tuple[int, int]] = [] + table_calls: set[tuple[int, int]] = set() + get_overlapping_family = basis_families.get_overlapping_family + build_tables_within = completion_bounds.build_tables_within + signature = inspect.signature(get_overlapping_family) + + def record(*args: Any, **kwargs: Any) -> basis_families.BasisFamily: + policy = signature.bind(*args, **kwargs).arguments["policy"] + calls.append((policy.max_sets, policy.attempts_per_set)) + return get_overlapping_family(*args, **kwargs) + + def record_tables( + rows: npt.NDArray[np.uint64], block_bits: int, max_bytes: int + ) -> completion_bounds.CompletionTables | None: + table_calls.add((block_bits, max_bytes)) + return build_tables_within(rows, block_bits, max_bytes) + + monkeypatch.setattr(basis_families, "get_overlapping_family", record) + monkeypatch.setattr(completion_bounds, "build_tables_within", record_tables) + coset._get_pauli_search.cache_clear() + with use_tuning( + overlapping_minimum_work=0, + max_information_sets=5, + overlapping_attempts_per_set=3, + coset_completion_block_bits=4, + completion_bound_max_table_bytes=12345, + ): + oracle = CosetLeaderSearch(_ROUTED_PHYSICAL_STATES[1]) + for syndrome, max_weight in itertools.product(range(1 << 5), range(2, 5)): + oracle.get_min_weight_bound(syndrome, max_weight) + assert calls == [(5, 3)] + assert table_calls == {(4, 12345)} + + +def test_oracles_under_other_gates_do_not_share_searches(tune: Tune) -> None: + """An oracle answers as a fresh oracle under the threshold of its gate, whatever is cached. + + Oracles are built for a graph state, whose search of Pauli errors is cached by stabilizers, and + for a CSS state whose pure-X stabilizers span a random code, whose two classical searches are + cached by stabilizer group. They are built under the threshold zero, then the largest value, + then zero again. Some answers differ between the thresholds, so an oracle that shared the + search of another threshold would answer differently. + """ + checks = get_kernel(OVERLAPPING_ROUTES_CODE) + css_state = np.block( + [ + [OVERLAPPING_ROUTES_CODE, np.zeros_like(OVERLAPPING_ROUTES_CODE)], + [np.zeros_like(checks), checks], + ] + ) + for matrix in (_ROUTED_PHYSICAL_STATES[1], css_state): + css_weight = matrix is css_state + syndromes = range(0, 1 << len(matrix), max(1, (1 << len(matrix)) // 64)) + queries = list(itertools.product(syndromes, range(6))) + answers = {} + for minimum_work in (0, MAX_VALUE): + coset._get_css_searches.cache_clear() + coset._get_pauli_search.cache_clear() + with use_tuning(overlapping_minimum_work=minimum_work): + oracle = CosetLeaderSearch(matrix, css_weight=css_weight) + answers[minimum_work] = [oracle.get_min_weight_bound(*query) for query in queries] + assert answers[0] != answers[MAX_VALUE] + for minimum_work in (0, MAX_VALUE, 0): + tune(overlapping_minimum_work=minimum_work) + oracle = CosetLeaderSearch(matrix, css_weight=css_weight) + answers_now = [oracle.get_min_weight_bound(*query) for query in queries] + assert answers_now == answers[minimum_work] + + +def test_cached_pauli_searches_answer_for_their_own_stabilizers_and_weight() -> None: + """Oracles built in turn answer exactly for their own stabilizers and weight. + + The product states with stabilizers ``Y__``, ``_Z_``, ``__X`` and ``X__``, ``_Y_``, ``__Z`` are + not CSS, so their oracles search Pauli errors by either weight, and both have three qubits. The + generators ``Y__``, ``YZ_``, ``__X`` of the first state give other syndromes to the same errors. + In the first state, ``Z0 X1`` has the syndrome ``0b011`` and CSS weight one, but no error with + that syndrome has physical weight below two. Oracles are built for every state and weight under + three tunings of the setup of their searches, and queried only once all are built, so that each + answers after every search is cached. + """ + states = [ + _product_state(generators) + for generators in (("Y__", "_Z_", "__X"), ("Y__", "YZ_", "__X"), ("X__", "_Y_", "__Z")) + ] + coset._get_pauli_search.cache_clear() + oracles = [] + for changes in ({}, {"max_css_single_pairs": 0}, {"disjoint_random_column_orders": 3}): + with use_tuning(**changes): + for matrix, css_weight in itertools.product(states, (False, True)): + oracles.append( + (matrix, css_weight, CosetLeaderSearch(matrix, css_weight=css_weight)) + ) + physical_bound = oracles[0][2].get_min_weight_bound(0b011, 1) + assert not physical_bound.within_max_weight and physical_bound.lower_bound == 2 + css_bound = oracles[1][2].get_min_weight_bound(0b011, 1) + assert css_bound.within_max_weight and css_bound.representative == 0b001010 + for matrix, css_weight, oracle in oracles: + _assert_exact_answers(oracle, matrix, css_weight) + + +def test_cached_pauli_searches_ignore_later_changes_to_the_stabilizer_array(tune: Tune) -> None: + """Oracles answer for the stabilizers that they were built from, when an array changes later. + + Oracles of a graph state are built from an array, which is then overwritten with another graph + state, from which further oracles are built from the same array. Oracles of the first state + built again from a copy of it, and the oracles built before the change, still answer for the + first state. With the threshold of the gate at zero, the searches build their other families + at their first queries, after the change (see ``coset_routes``). The first state has a larger + disjoint family by CSS weight, and its search by CSS weight reads its generators from the + stabilizers themselves. + """ + tune(overlapping_minimum_work=0) + first, second = _ROUTED_CSS_WEIGHT_STATE, _ROUTED_PHYSICAL_STATES[1] + coset._get_pauli_search.cache_clear() + array = first.copy() + oracles = [ + (first, css_weight, CosetLeaderSearch(array, css_weight=css_weight)) + for css_weight in (False, True) + ] + array[:] = second + oracles += [ + (second, css_weight, CosetLeaderSearch(array, css_weight=css_weight)) + for css_weight in (False, True) + ] + oracles += [ + (first, css_weight, CosetLeaderSearch(first.copy(), css_weight=css_weight)) + for css_weight in (False, True) + ] + for matrix, css_weight, oracle in oracles: + _assert_exact_answers(oracle, matrix, css_weight) + + +# A code whose searches for minimum weights by tails skip completions by tables at the threshold +# zero, as do those of its even version. +_SKIPPING_CODE = np.array( + [ + [0, 1, 0, 1, 1, 0, 1, 1, 1, 0, 1], + [0, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1], + [0, 0, 0, 1, 1, 0, 1, 1, 1, 1, 1], + [0, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1], + [0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0], + ], + dtype=np.uint8, +) + +# A longer code with cosets of minimum weight six. At maximum weight four and the threshold zero, +# its searches of them for minimum weights skip some completions by tables whose bound reaches the +# skip bound of that weight, but not the weight of the lightest word that the searches weigh. The +# searches by tails of some of its cosets, such as those of syndromes 1126, 4578, and 6129, stop +# early within steps. +_HEAVY_COSETS_CODE = get_binary_matrix( + [ + "11100011110000110001", + "00001011001010001000", + "10010110101010101100", + "10001100101100100101", + "01010100110001001100", + "00010000001110010101", + "00101100011111000001", + ] +) + + +def _assert_classical_cosets_match_brute_force(generators: npt.NDArray[np.uint8]) -> None: + """Check the classical search of the cosets of a code against the least weight of their words. + + Single queries and batches, at maximum weights from below zero to past the number of columns, + agree with the least weight of the words of each syndrome. From maximum weight two, they also + agree with each other. A coset without a word of weight at most the maximum weight gets a lower + bound of at least the least weight above the maximum weight that a word of the coset can have. + That weight has the parity of the coset's least weight if every row of ``generators`` has even + weight. Below maximum weight two, a single query of a coset without a word of weight at most + one gets exactly that weight for maximum weight one, which it finds without a search. + """ + num_columns = generators.shape[1] + checks = get_kernel(generators) + tuning = coset._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + search = coset._get_classical_search( + generators, checks, tuning._replace(disjoint_random_column_orders=0) + ) + minima = _classical_minima(checks) + even = not np.any(generators.sum(axis=1) % 2) + + def get_least_bound(syndrome: int, max_weight: int) -> int: + """The least weight above the maximum weight that a word of the coset can have.""" + return max_weight + 1 + int(even and (max_weight + 1 - minima[syndrome]) % 2) + + syndromes = list(range(1 << len(checks))) + packed_syndromes = _pack_syndromes(syndromes, len(checks)) + for max_weight in range(-1, num_columns + 2): + bounds = {} + for syndrome in syndromes: + bound = search.get_min_weight_bound(syndrome, max_weight) + representative = (bound.representative >> np.arange(num_columns)) & 1 + weight = int(representative.sum()) + assert bound.within_max_weight == (minima[syndrome] <= max_weight) + assert _classical_syndrome(checks, representative) == syndrome + assert bound.lower_bound <= minima[syndrome] <= weight + if bound.within_max_weight: + assert weight <= max_weight + else: + assert bound.lower_bound >= get_least_bound(syndrome, max_weight) + if max_weight < 2 and minima[syndrome] >= 2: + assert bound.lower_bound == get_least_bound(syndrome, 1) + bounds[syndrome] = bound + + # Find each syndrome whose minimum exceeds the maximum weight with batches, each of which + # starts after the last syndrome found. + found, start = [], 0 + while True: + index, batch_bound = search.find_first_not_within_max_weight( + packed_syndromes[start:], max_weight + ) + if batch_bound is None: + assert start + index == len(syndromes) + break + syndrome = syndromes[start + index] + representative = (batch_bound.representative >> np.arange(num_columns)) & 1 + assert _classical_syndrome(checks, representative) == syndrome + assert not batch_bound.within_max_weight + assert get_least_bound(syndrome, max_weight) <= batch_bound.lower_bound + assert batch_bound.lower_bound <= minima[syndrome] + if max_weight >= 2: + assert bounds[syndrome] == batch_bound + found.append(syndrome) + start += index + 1 + assert found == [syndrome for syndrome in syndromes if minima[syndrome] > max_weight] + + +def test_cosets_of_a_rank_deficient_code_match_brute_force() -> None: + """The coset searches of a rank-deficient code match the minima of all words. + + The code has disjoint information sets of ranks five and four, so its searches skip the second + set while it adds nothing to the bounds of their stopping steps. Syndrome 19 has a word of + weight two that the first set reaches only above layer one. At maximum weight two, the stopping + step is the one at which the second set completes layer one, which overcomes its deficiency of + one, so the search visits that set. It finds the word in layer zero of that set, its offset. + Single queries and batches agree with the least weight of the words of each syndrome (see + ``_assert_classical_cosets_match_brute_force``). Below two, a single query of a coset without a + word of weight at most one returns a bound of two without searching. + """ + _assert_classical_cosets_match_brute_force(_RANK_DEFICIENT_CODE) + + +def test_cosets_of_an_even_code_have_bounds_of_their_parity() -> None: + """Coset searches of an even code give each coset with no light word a bound of its parity. + + The code is ``_RANK_DEFICIENT_CODE`` with a last column that holds the parity of each row, so + every word of the code has even weight, and the weights of the words of each coset share a + parity. A coset without words of weight at most ``m`` then has no word of weight ``m + 1`` if + that integer has the other parity, so its lower bound is at least the least integer above ``m`` + of its parity (see ``_assert_classical_cosets_match_brute_force``). The bounds of the searches, + which round up to the parity of each coset, reach it. So does the bound three of an odd coset + of least weight three in a single query below maximum weight two, which does not search. Bounds + without parity would fall short of both. The cosets have both parities, and least weights up to + four. + """ + parities = (_RANK_DEFICIENT_CODE.sum(axis=1, keepdims=True) % 2).astype(np.uint8) + _assert_classical_cosets_match_brute_force(np.hstack([_RANK_DEFICIENT_CODE, parities])) + + +@pytest.mark.parametrize( + "code", [EVEN_ROUTES_CODE, TWO_FAMILIES_CODE], ids=["even", "two-families"] +) +def test_cosets_match_brute_force_with_other_families( + code: npt.NDArray[np.uint8], tune: Tune +) -> None: + """Searches of X or Z parts answer exactly when costly queries take other families. + + With the threshold of the gate at zero, queries that the disjoint family would answer with more + work take another family (see ``coset_routes``). The even code takes a disjoint family with + two information sets, for cosets of both parities. The random code takes a disjoint family with + a larger partial set, and a family of overlapping information sets. Single queries and + batches, at maximum weights from below zero to past the number of columns, and so just below + and at the least weight of each coset, agree with the least weight of the words of each coset + (see ``_assert_classical_cosets_match_brute_force``). At the threshold zero, the queries also + run the searches by tails. + """ + tune(overlapping_minimum_work=0) + _assert_classical_cosets_match_brute_force(code) + + +def test_answers_do_not_depend_on_earlier_queries(pytestconfig: pytest.Config, tune: Tune) -> None: + """The answers of a search depend only on its code, the syndrome, and the maximum weight. + + With the threshold of the gate at zero, fresh searches of the cosets of a random code answer + every query in increasing, decreasing, and random order of the maximum weight. Each order + reaches the threshold at a different query, but the overlapping family of the code, which + depends on the weight at which it is built, and so the answers, are the same. With the largest + threshold, some answers differ, so the other families answer some of the queries. + """ + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + checks = get_kernel(OVERLAPPING_ROUTES_CODE) + + def get_answers(order: str) -> dict[tuple[int, int], MinWeightBound]: + """Every answer of a fresh search, with the maximum weights in the given order.""" + tuning = coset._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + search = coset._get_classical_search( + OVERLAPPING_ROUTES_CODE, checks, tuning._replace(disjoint_random_column_orders=0) + ) + weights = list(range(15)) + if order != "up": + weights = weights[::-1] if order == "down" else rng.permutation(weights).tolist() + return { + (syndrome, max_weight): search.get_min_weight_bound(syndrome, max_weight) + for max_weight in weights + for syndrome in range(1 << len(checks)) + } + + tune(overlapping_minimum_work=0) + answers = [get_answers(order) for order in ("up", "down", "random")] + assert answers[0] == answers[1] == answers[2] + with use_tuning(overlapping_minimum_work=MAX_VALUE): + assert get_answers("up") != answers[0] + + +def test_searches_visit_only_the_sets_that_add_to_their_bounds(pytestconfig: pytest.Config) -> None: + """A search skips exactly the sets that add nothing to the bound of its stopping step. + + A set of rank ``r`` in a code of dimension ``k`` adds to the bound of a step only once it has + completed layer ``k - r`` (see ``information_sets.get_step_lower_bounds``). A search that stops + at step ``(w, j)`` completes layer ``w`` on the first ``j`` sets and layer ``w - 1`` on the + others, so it visits a set only if that layer is at least ``k - r``. The sets are the disjoint + information sets of random codes, whose last one is often rank deficient, and a set of rank zero + with every row of the code. A search whose answer is no weighs every word of the layers of the + sets that it visits, and every answer is exact. The exact number of words weighed guards the + documented rule for skipping sets, so a legitimate future pruning of the coset scan would + change it. The work of the plan of the steps of the search, from layer zero and without the + sets that it skips (see ``information_sets.plan_steps``), predicts this number. + """ + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + for _ in range(12): + num_columns = int(rng.integers(2, 9)) + num_rows = int(rng.integers(1, num_columns)) + generators = get_random_full_rank_matrix(rng, num_rows, num_columns) + checks = get_kernel(generators) + family = basis_families.get_disjoint_family(generators, None, random_column_orders=0) + ranks = (*family.ranks.tolist(), 0) + num_sets = len(ranks) + rows = np.concatenate([family.generators, generators[None]]) + instance = affine_instance.AffineInstance( + generators=pack_bits(rows.reshape(-1, num_columns)).reshape(num_sets, num_rows, -1), + labels=np.zeros((num_sets, num_rows, 0), dtype=np.uint64), + widths=np.ones(num_sets, dtype=np.int64), + pivots=np.vstack([family.pivots, np.full(num_rows, -1)]), + certificate=certificates_module.Certificate(num_rows, ranks), + ) + inverse = _cosets._get_inverse(checks) + tables = coset_tables._get_search_tables(instance, inverse, num_columns) + minima = _classical_minima(checks) + + for syndrome, max_weight in itertools.product( + range(1 << len(checks)), range(num_columns + 1) + ): + best = np.empty(tables.inverse.shape[1], dtype=np.uint64) + packed_syndrome = _pack_syndromes([syndrome], len(checks))[0] + best_weight, parity = coset._get_syndrome_word( + packed_syndrome, best, tables.inverse, tables.x_mask, 1 + ) + if best_weight <= max_weight: + continue + table = coset_tables._get_planning_table(tables.planning, parity) + stop_layer, stop_num_completed, stop_bound = coset_tables._get_stopping_step( + table, max_weight + ) + lower_bound, weighed, _ = coset._search_from_syndrome_word( + packed_syndrome, + max_weight, + stop_layer, + stop_num_completed, + stop_bound, + best, + best_weight, + tables.generators, + tables.pivots, + tables.x_mask, + None, + 1, + parity if len(tables.planning) == 2 else -1, + ) + representative = unpack_bits(best[None], num_columns)[0].astype(int) + assert _classical_syndrome(checks, representative) == syndrome + assert (lower_bound <= max_weight) == (minima[syndrome] <= max_weight) + if lower_bound > max_weight: + assert lower_bound <= minima[syndrome] + last_layers = [ + stop_layer if index < stop_num_completed else stop_layer - 1 + for index in range(num_sets) + ] + assert weighed == sum( + comb(num_rows, layer) + for rank, last_layer in zip(ranks, last_layers, strict=True) + if last_layer >= num_rows - rank + for layer in range(last_layer + 1) + ) + binomials = certificates_module.get_capped_binomials(num_rows) + plan = certificates_module.plan_steps( + table[:-1], max_weight, 0, num_rows, instance.ranks, binomials, True + ) + assert plan == (stop_layer, stop_num_completed, weighed) + + +def _assert_searches_by_tails_match_searches_of_words( + generators: npt.NDArray[np.uint8], + inverse: npt.NDArray[np.uint8], + scale: int, + block_sizes: Sequence[int], + max_weights: Sequence[int], + syndromes: Sequence[int], + minima: npt.NDArray[np.int_] | None = None, + *, + block_bits: int | None = None, +) -> int: + """Check searches by tails against searches of whole words, without or with completion tables. + + The family holds a set of the embedded generators reduced on each block of consecutive columns, + whose sizes cycle through ``block_sizes``, and a set of rank zero with every generator. The + blocks are disjoint, so the certificate of the ranks of the sets bounds the words of each coset. + Every column counts as the X part. The lower bound of the search of whole words agrees with the + minimum of the coset in ``minima``, if given. Without ``block_bits``, for each syndrome and + maximum weight, the two searches from the word of the syndrome give the same lower bound, word, + and number of words weighed. For each maximum weight, the two batch loops find the same + syndromes, in turn, with the same bounds and words. + + With ``block_bits``, every set of at least two rows has completion tables on blocks of that many + columns, and the searches by tails decide as the searches of whole words, and give the same word + to a coset with a word within the maximum weight. To another coset, they give the same lower + bound, or the least weight beyond the maximum weight that a word of the coset can have if they + skipped any word, and a word of at least that weight. This least weight is one more than the + maximum weight, or two more if the generators are even and the words of the coset have the + parity of the maximum weight. They weigh every word that the search of whole words weighs, but + the words that they skip, which ``_count_skipped_supports`` counts. The batch loops with tables + find the same syndromes, with the bounds and words of single queries with tables. + + Returns: + The number of supports that the searches by tails skipped. + """ + num_rows, num_columns = generators.shape + sets, pivots, start = [], [], 0 + for size in itertools.cycle(block_sizes): + if start >= num_columns: + break + block = np.arange(start, min(start + size, num_columns)) + reduced, _, block_pivots = row_reduce_binary(generators, columns=block) + sets.append(reduced) + pivots.append(np.pad(block_pivots, (0, num_rows - len(block_pivots)), constant_values=-1)) + start += size + sets.append(generators) + pivots.append(np.full(num_rows, -1)) + instance = affine_instance.AffineInstance( + generators=np.stack([pack_bits(rows) for rows in sets]), + labels=np.zeros((len(sets), num_rows, 0), dtype=np.uint64), + widths=np.ones(len(sets), dtype=np.int64), + pivots=np.array(pivots, dtype=np.int64), + certificate=certificates_module.Certificate( + num_rows, tuple(int(np.sum(row >= 0)) for row in pivots) + ), + weight_factor=2 if scale == 2 else 1, + ) + tables = coset_tables._get_search_tables(instance, inverse, num_columns) + tails = coset_tables._get_coset_tails( + instance, + list(range(len(sets))) if block_bits else [], + None, + inverse=inverse, + block_bits=block_bits or 8, + max_table_bytes=MAX_VALUE, + ) + even = scale == 1 and not np.any(generators.sum(axis=1) % 2) + packed_syndromes = _pack_syndromes(syndromes, len(inverse)) + total_skipped = 0 + for max_weight in max_weights: + answers = {} + for syndrome, packed_syndrome in zip(syndromes, packed_syndromes, strict=True): + words = np.empty((2, tables.inverse.shape[1]), dtype=np.uint64) + best_weight, parity = coset._get_syndrome_word( + packed_syndrome, words[0], tables.inverse, tables.x_mask, scale + ) + if best_weight <= max_weight: + continue + words[1] = words[0] + word = unpack_bits(words[:1], num_columns)[0] + coset_parity = int(word.sum()) % 2 + stop_layer, stop_num_completed, stop_bound = coset_tables._get_stopping_step( + coset_tables._get_planning_table(tables.planning, parity), max_weight + ) + *answer, _ = coset._search_from_syndrome_word( + packed_syndrome, + max_weight, + stop_layer, + stop_num_completed, + stop_bound, + words[0], + best_weight, + tables.generators, + tables.pivots, + tables.x_mask, + None, + scale, + parity if len(tables.planning) == 2 else -1, + ) + *answer_by_tails, skipped = coset._search_from_syndrome_word( + packed_syndrome, + max_weight, + stop_layer, + stop_num_completed, + stop_bound, + words[1], + best_weight, + tables.generators, + tables.pivots, + tables.x_mask, + tails, + scale, + parity if len(tables.planning) == 2 else -1, + ) + if minima is not None: + assert (answer[0] <= max_weight) == (minima[syndrome] <= max_weight) + assert answer[0] <= minima[syndrome] + answers[syndrome] = answer_by_tails[0], words[1] + total_skipped += skipped + if not block_bits: + assert answer_by_tails == list(answer) and not skipped + np.testing.assert_array_equal(words[0], words[1]) + elif answer[0] <= max_weight: + assert answer_by_tails[0] == 0 + np.testing.assert_array_equal(words[0], words[1]) + else: + least_beyond = max_weight + 1 + if even and (least_beyond + coset_parity) % 2: + least_beyond += 1 + assert answer_by_tails[0] == (least_beyond if skipped else answer[0]) + assert int(np.bitwise_count(words[1]).sum()) >= scale * answer_by_tails[0] + # The skip bound is one more than the greatest Hamming weight at most + # ``scale * max_weight`` that a word of the coset can have. + skip_bound = scale * max_weight + 1 + if even and (skip_bound - coset_parity) % 2 == 0: + skip_bound -= 1 + assert answer_by_tails[1] + skipped == answer[1] + assert skipped == sum( + _count_skipped_supports(rows, set_pivots, word, skip_bound, block_bits, last) + for index, (rows, set_pivots) in enumerate(zip(sets, pivots, strict=True)) + if (last := stop_layer - (index >= stop_num_completed)) + >= num_rows - np.sum(set_pivots >= 0) + ) + + # Find each syndrome without a light enough word with both batch loops, in turn. + start = 0 + while True: + words = np.empty((2, tables.inverse.shape[1]), dtype=np.uint64) + found = coset._find_first_not_within_max_weight( + packed_syndromes[start:], max_weight, words[0], *tables, None, scale + ) + found_by_tails = coset._find_first_not_within_max_weight( + packed_syndromes[start:], max_weight, words[1], *tables, tails, scale + ) + assert found_by_tails[0] == found[0] + if start + found[0] == len(packed_syndromes): + break + if block_bits: + bound, word = answers[syndromes[start + found[0]]] + assert found_by_tails[1] == bound + np.testing.assert_array_equal(words[1], word) + else: + assert found_by_tails == found + np.testing.assert_array_equal(words[0], words[1]) + start += found[0] + 1 + return total_skipped + + +def _count_skipped_supports( + rows: npt.NDArray[np.uint8], + pivots: npt.NDArray[np.int_], + word: npt.NDArray[np.uint8], + skip_bound: int, + block_bits: int, + last_layer: int, +) -> int: + """Count the supports that tables skip in the layers from two to ``last_layer`` of a set. + + ``rows`` are the rows of the set, ``pivots`` their pivot columns, padded with -1 beyond the + rank, and ``word`` the word of a syndrome, which the rows of its pivot ones reduce to the + offset. A support of a layer is skipped if the pivot ones of the rows of its prefix below the + rank, the fewest pivot ones of a pair of rows from its row ``s`` on, and the least weight of the + tail of such a completion on each block of columns add up to at least ``skip_bound``. The tails + hold the columns other than the pivots, and the blocks cover them up to the last on which a row + has a one. A set whose tails have no ones has no tables, and skips nothing. + """ + rank = int(np.sum(pivots >= 0)) + offset = (word + rows[[row for row in range(rank) if word[pivots[row]]]].sum(axis=0)) % 2 + columns = np.delete(np.arange(rows.shape[1]), pivots[:rank]) + tails, offset_tail = rows[:, columns], offset[columns] + num_bits = int(np.flatnonzero(tails.any(axis=0)).max(initial=-1)) + 1 + tails, offset_tail = tails[:, :num_bits], offset_tail[:num_bits] + skipped = 0 + for layer in range(2, last_layer + 1): + for support in itertools.combinations(range(len(rows)), layer): + prefix = (offset_tail + tails[list(support[:-2])].sum(axis=0)) % 2 + pairs = list(itertools.combinations(range(support[-2], len(rows)), 2)) + bound = sum(row < rank for row in support[:-2]) + bound += min(sum(row < rank for row in pair) for pair in pairs) + bound += sum( + min( + int(((prefix + tails[i] + tails[j]) % 2)[start : start + block_bits].sum()) + for i, j in pairs + ) + for start in range(0, num_bits, block_bits) + ) + if num_bits and bound >= skip_bound: + skipped += 1 + return skipped + + +@pytest.mark.parametrize("block_bits", [None, 4]) +def test_searches_by_tails_answer_as_searches_of_whole_words( + block_bits: int | None, pytestconfig: pytest.Config +) -> None: + """Coset searches by tails decide as searches of whole words, and match them without tables. + + The searches by tails weigh a word by its rows below the rank of its set and its tail, which + holds every column but the pivot columns of the set. Without completion tables, they visit the + same words, and keep the same lightest word, as the searches of whole words (see + ``_assert_searches_by_tails_match_searches_of_words``). With tables on blocks of four columns, + they skip words beyond the maximum weight, which changes only the answers to cosets without a + word within it. Blocks of as many columns as the generators have rows alternate with blocks of + one column fewer, whose sets are rank deficient, so that the tails of the sets differ in their + numbers of columns. The random codes have a column of zeros, which a coset can still have ones + on. With tables, each is also made even by a column of the parities of its rows, so that the + searches round their bounds to the parity of each coset. A rank-deficient code made even in the + same way is searched too. The searches of every syndrome of these codes, at every maximum + weight up to past their length, agree with the minima of the cosets. A wide code begins with an + information set, whose tails fit in one machine word, while the tails of its rank-deficient sets + take two, and a sample of its syndromes is searched. The words of the Pauli errors of a graph + state that is not CSS are weighed by their physical weight, and with tables, its family has sets + of ranks two and one. With tables, the searches of the even rank-deficient code, the wide code, + and the graph state skip words; those of small random codes need not. + """ + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + # The arguments of each check, and whether its searches with tables must skip words. + searches: list[tuple[tuple[Any, ...], bool]] = [] + for _ in range(3): + num_rows, num_columns = int(rng.integers(3, 5)), int(rng.integers(6, 10)) + generators = get_random_full_rank_matrix(rng, num_rows, num_columns) + code = np.insert(generators, int(rng.integers(num_columns + 1)), 0, axis=1) + codes = [code, np.hstack([code, code.sum(axis=1, keepdims=True) % 2]).astype(np.uint8)] + for code in codes if block_bits else codes[:1]: + checks = get_kernel(code) + searches.append( + ( + ( + code, + _cosets._get_inverse(checks), + 1, + (num_rows, num_rows - 1), + range(-1, code.shape[1] + 2), + range(1 << len(checks)), + _classical_minima(checks), + ), + False, + ) + ) + if block_bits: + parities = (_RANK_DEFICIENT_CODE.sum(axis=1, keepdims=True) % 2).astype(np.uint8) + even = np.hstack([_RANK_DEFICIENT_CODE, parities]) + checks = get_kernel(even) + searches.append( + ( + ( + even, + _cosets._get_inverse(checks), + 1, + (5, 4), + range(-1, even.shape[1] + 2), + range(1 << len(checks)), + _classical_minima(checks), + ), + True, + ) + ) + + wide = np.hstack([np.eye(4, dtype=np.uint8), rng.integers(0, 2, (4, 64), dtype=np.uint8)]) + checks = get_kernel(wide) + syndromes = [int.from_bytes(rng.bytes(9), "little") % (1 << len(checks)) for _ in range(16)] + searches.append( + ( + ( + wide, + _cosets._get_inverse(checks), + 1, + (4, 3), + range(-1, 40, 4), + syndromes, + ), + True, + ) + ) + + matrix = _graph_state(4, [(0, 1), (1, 2)], phase=True) + checks = (math.symplectic_conjugate(matrix.astype(int)) % 2).astype(np.uint8) + searches.append( + ( + ( + symplectic_to_hamming(matrix), + symplectic_to_hamming(_cosets._get_inverse(checks)), + 2, + (2, 1) if block_bits else (4, 3), + range(-1, 6), + range(16), + _css_minima(matrix, css_weight=False), + ), + True, + ) + ) + for arguments, must_skip in searches: + skipped = _assert_searches_by_tails_match_searches_of_words( + *arguments, block_bits=block_bits + ) + assert skipped or not (block_bits and must_skip) + + +def test_queries_of_routes_by_tails_weigh_the_tails_of_their_record( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Single queries and batches of routes by tails weigh words by the tails of their record. + + At the threshold zero, every route of the classical search of the repetition code of length + seven is by tails. A record whose tails are all zero makes every offset weigh nothing, so the + queries at maximum weight two find a light enough word in every coset, even in the cosets whose + words weigh at least three, which a search of whole words would reject. + """ + get_coset_tails = coset_tables._get_coset_tails + + def get_zero_tails(*args: Any, **kwargs: Any) -> coset_tables._CosetTails: + """The record of the tails of a family, with every tail zero.""" + record = get_coset_tails(*args, **kwargs) + return record._replace( + tails=np.zeros_like(record.tails), inverse_tails=np.zeros_like(record.inverse_tails) + ) + + monkeypatch.setattr(coset, "_get_coset_tails", get_zero_tails) + generators = np.ones((1, 7), dtype=np.uint8) + checks = get_kernel(generators) + tuning = coset._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + search = coset._get_classical_search( + generators, checks, tuning._replace(overlapping_minimum_work=0) + ) + syndromes = np.flatnonzero(_classical_minima(checks) > 2).tolist() + assert syndromes + for syndrome in syndromes: + bound = search.get_min_weight_bound(syndrome, 2) + assert (bound.within_max_weight, bound.lower_bound) == (True, 2) + packed_syndromes = _pack_syndromes(syndromes, len(checks)) + assert search.find_first_not_within_max_weight(packed_syndromes, 2) == (len(syndromes), None) + + +def test_queries_of_routes_by_tails_skip_completions_by_the_tables_of_their_record( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Single queries and batches of routes by tails skip completions by the tables of their record. + + At the threshold zero, every route of the classical search of a random code is by tails. A + record whose tables give every completion the bound 255 makes each query skip every completion + in its layers of two or more rows. At maximum weight three, some coset then gets no word within + that weight, although it has one. The searches skipped words, so single queries and batches + bound such cosets by four. The code is not even, so four is the least weight beyond the maximum + weight that a word of such a coset can have. + """ + get_coset_tails = coset_tables._get_coset_tails + + def get_skipping_tails(*args: Any, **kwargs: Any) -> coset_tables._CosetTails: + """The record of the tails of a family, with tables that skip every completion.""" + record = get_coset_tails(*args, **kwargs) + return record._replace(completion_tables=np.full_like(record.completion_tables, 255)) + + monkeypatch.setattr(coset, "_get_coset_tails", get_skipping_tails) + checks = get_kernel(OVERLAPPING_ROUTES_CODE) + tuning = coset._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + search = coset._get_classical_search( + OVERLAPPING_ROUTES_CODE, checks, tuning._replace(overlapping_minimum_work=0) + ) + minima = _classical_minima(checks) + missed = [ + syndrome + for syndrome in np.flatnonzero(minima <= 3).tolist() + if not search.get_min_weight_bound(syndrome, 3).within_max_weight + ] + assert missed + assert {search.get_min_weight_bound(syndrome, 3).lower_bound for syndrome in missed} == {4} + _, bound = search.find_first_not_within_max_weight(_pack_syndromes(missed, len(checks)), 3) + assert bound is not None and bound.lower_bound == 4 + + +def _coset_minima( + generators: npt.NDArray[np.uint8], checks: npt.NDArray[np.uint8], syndromes: Sequence[int] +) -> npt.NDArray[np.int_]: + """The least weight of the words of the coset of each syndrome, from every word of the coset. + + A word with the syndrome sums rows of a right inverse of the checks, from the module under test, + so the helper checks the syndromes of these words. The coset adds to such a word every word of + the code of ``generators``. + """ + code = np.array(list(itertools.product([0, 1], repeat=len(generators)))) @ generators % 2 + bits = (np.array(syndromes)[:, None] >> np.arange(len(checks))) & 1 + words = bits @ _cosets._get_inverse(checks) % 2 + assert np.array_equal(_classical_syndrome(checks, words), syndromes) + return np.array([((word + code) % 2).sum(axis=1).min() for word in words]) + + +def _assert_min_weights_match_brute_force( + search: coset._CosetSearch, + syndromes: Sequence[int], + minima: npt.NDArray[np.int_], + num_syndrome_bits: int, + rng: np.random.Generator, +) -> None: + """Check the minimum weights that a search finds for cosets against their least weights. + + One batch holds the syndromes, in random order, and ``minima`` holds the least weight of the + coset of each. At each maximum weight from zero to one past the largest least weight, the + floors of the batch are zero, then random up to the maximum weight, and then that weight. A + coset whose least weight is at most its floor gets a weight from its least weight to its floor. + Any other coset gets its least weight, or one more than the maximum weight if that is smaller. + """ + order = rng.permutation(len(syndromes)) + packed_syndromes = _pack_syndromes([syndromes[index] for index in order], num_syndrome_bits) + least = minima[order] + for max_weight in range(int(least.max()) + 2): + random_floors = rng.integers(0, max_weight + 1, len(least)) + for floors in (np.zeros_like(least), random_floors, np.full_like(least, max_weight)): + weights = search.get_min_weights(packed_syndromes, max_weight, floors.astype(np.int64)) + exact = least > floors + assert np.array_equal(weights[exact], np.minimum(least, max_weight + 1)[exact]) + assert np.all(exact | ((least <= weights) & (weights <= floors))) + + +@pytest.mark.parametrize( + "tuning", + [ + {}, + {"overlapping_minimum_work": 0}, + {"overlapping_minimum_work": 0, "completion_bound_max_table_bytes": 0}, + ], + ids=["disjoint", "tables", "no-tables"], +) +def test_min_weights_of_cosets_match_brute_force( + tuning: dict[str, int], pytestconfig: pytest.Config, tune: Tune +) -> None: + """Searches for the minimum weights of cosets agree with the least weights of their words. + + The codes are the rank-deficient code, a code whose searches skip words by tables, and a random + code, each also made even by a column of the parities of its rows. The searches of the even + codes round to the parity of each coset, and their incumbents are one below the Hamming weight + of their best words. The cosets have both parities. Three cosets of a longer code are + searched too. Their searches by tails stop early within steps. The searches take the disjoint + family of each code, and at the threshold zero other families by tails, with completion tables + and without them (see ``_assert_min_weights_match_brute_force``). The least weights come from + every word of each coset. + """ + tune(**tuning) + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + tuning_fields = coset._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + num_rows, num_columns = int(rng.integers(3, 6)), int(rng.integers(8, 11)) + random_code = get_random_full_rank_matrix(rng, num_rows, num_columns) + for code in (_RANK_DEFICIENT_CODE, _SKIPPING_CODE, random_code): + even_code = np.hstack([code, code.sum(axis=1, keepdims=True) % 2]).astype(np.uint8) + for generators in (code, even_code): + checks = get_kernel(generators) + search = coset._get_classical_search(generators, checks, tuning_fields) + syndromes = range(1 << len(checks)) + minima = _coset_minima(generators, checks, syndromes) + _assert_min_weights_match_brute_force(search, syndromes, minima, len(checks), rng) + checks = get_kernel(_HEAVY_COSETS_CODE) + search = coset._get_classical_search(_HEAVY_COSETS_CODE, checks, tuning_fields) + early_stop_syndromes = [1126, 4578, 6129] + minima = _coset_minima(_HEAVY_COSETS_CODE, checks, early_stop_syndromes) + _assert_min_weights_match_brute_force(search, early_stop_syndromes, minima, len(checks), rng) + + +@pytest.mark.parametrize("tuning", [{}, {"overlapping_minimum_work": 0}], ids=["words", "tails"]) +def test_min_weight_searches_above_max_weight_examine_as_queries( + tuning: dict[str, int], tune: Tune +) -> None: + """A search for the minimum weight of a coset without a word within a weight works as a query. + + Until it finds a word within ``max_weight``, the search keeps the skip bound of ``max_weight`` + as its incumbent, at which a query at ``max_weight`` skips completions too. It stops at the + stopping step of that query, so it weighs and skips the same words. A code and its even version + are searched at every syndrome, a longer code at three syndromes of minimum weight six, and the + Pauli errors of a graph state by physical weight at every syndrome. They are searched by whole + words, and at the threshold zero by tails, with tables, which skip words of each of the three + codes but need not skip any of the graph state. The minimum weights come from every word of + each coset. + """ + tune(**tuning) + tuning_fields = coset._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + cases = [] + parities = (_SKIPPING_CODE.sum(axis=1, keepdims=True) % 2).astype(np.uint8) + for generators, chosen_syndromes in [ + (_SKIPPING_CODE, None), + (np.hstack([_SKIPPING_CODE, parities]), None), + (_HEAVY_COSETS_CODE, [3571, 3722, 7870]), + ]: + checks = get_kernel(generators) + syndromes = chosen_syndromes or list(range(1 << len(checks))) + search = coset._get_classical_search(generators, checks, tuning_fields) + minima = _coset_minima(generators, checks, syndromes) + cases.append((search, len(checks), syndromes, minima, True)) + matrix = _ROUTED_PHYSICAL_STATES[1] + checks = (math.symplectic_conjugate(matrix.astype(int)) % 2).astype(np.uint8) + search = coset._get_pauli_search(coset._StabilizerKey(matrix, checks), False, tuning_fields) + syndromes = list(range(1 << len(matrix))) + cases.append((search, len(matrix), syndromes, _css_minima(matrix, css_weight=False), False)) + + for search, num_syndrome_bits, syndromes, minima, must_skip in cases: + skipped = 0 + for max_weight in range(2, int(max(minima))): + tables, tails = search._routes.get_route(max_weight) + for syndrome, minimum in zip(syndromes, minima, strict=True): + if minimum <= max_weight: + continue + packed_syndrome = _pack_syndromes([syndrome], num_syndrome_bits)[0] + best = np.empty(tables.inverse.shape[1], dtype=np.uint64) + best_weight, parity = coset._get_syndrome_word( + packed_syndrome, best, tables.inverse, tables.x_mask, search.scale + ) + stop_layer, stop_num_completed, stop_bound = coset_tables._get_stopping_step( + coset_tables._get_planning_table(tables.planning, parity), + max_weight, + ) + _, *expected = coset._search_from_syndrome_word( + packed_syndrome, + max_weight, + stop_layer, + stop_num_completed, + stop_bound, + best, + best_weight, + tables.generators, + tables.pivots, + tables.x_mask, + tails, + search.scale, + parity if len(tables.planning) == 2 else -1, + ) + _, *found = coset._search_coset_for_min_weight( + packed_syndrome, max_weight, 0, best, *tables, tails, search.scale + ) + skipped += expected[1] + assert found == expected + assert skipped or not (tuning and must_skip) + + +def test_tables_of_pairs_of_single_qubit_errors_respect_their_limit() -> None: + """A table of products of X and Z errors keeps the first product of each syndrome. + + Three entries of X errors and two of Z errors, each counting the identity, make six pairs, which + fit a limit of six but not of five. + """ + x_singles = {0: 0, 0b01: 0b0001, 0b11: 0b0010} + z_singles = {0: 0, 0b10: 0b0100} + table = coset._tabulate_css_singles(x_singles, z_singles, 6) + assert table == {0: 0, 0b10: 0b0100, 0b01: 0b0001, 0b11: 0b0101} + assert coset._tabulate_css_singles(x_singles, z_singles, 5) is None diff --git a/src/qldpc/codes/distance/_connected_support/coset.py b/src/qldpc/codes/distance/_connected_support/coset.py new file mode 100644 index 000000000..f42a9e8a1 --- /dev/null +++ b/src/qldpc/codes/distance/_connected_support/coset.py @@ -0,0 +1,288 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Connected-support coset searches of ``CosetLeaderSearch``, with checks made sparser first.""" + +from __future__ import annotations + +import numpy as np +import numpy.typing as npt + +from qldpc._binary import pack_bits, row_reduce_binary, unpack_bits + +from .. import _tuning +from .._cosets import ( + MinWeightBound, + _get_inverse, + _int_to_words, + _row_ints, + _tabulate_singles, + _xor_selected, +) +from .search import SupportSearch + +# The seed of the random orders of columns in which connected-support coset searches row-reduce +# their checks to make them sparser (see ``_get_sparser_rows``). It makes the checks reproducible. +_SPARSIFYING_SEED = 0 + + +class _ConnectedSupportCosetSearch: + """A coset search of a ``CosetLeaderSearch`` by the connected-support search of its checks. + + The words, checks, and syndromes are those of a ``_CosetSearch``: packed Paulis, whose syndrome + for the stabilizers is that of the symplectic search of ``SupportSearch``, or + the X or Z parts of Paulis, whose syndrome is that of a binary search of the checks. The search + starts from the syndrome of a query, and grows supports through its unsatisfied checks (Section + 3.8 of ``connected_support.md``). + + Like a ``_CosetSearch``, it first looks up a word of weight zero or one by its syndrome: a + single variable, or a single-qubit Pauli. It searches only cosets with no such word, so a coset + with such a word gets the weight of that word as a lower bound. Any other coset gets two if it + has a word within ``max_weight``. With increasing weight limits from two (see + ``get_min_weight_bound``), it instead gets the weight of its lightest words. Without a word + within ``max_weight``, it gets the larger of two and ``max_weight + 1``. These bounds are often + weaker than those of a ``_CosetSearch``. + + The search is fast only if its checks are sparse, but the given checks, such as row-reduced + stabilizers, can be dense. So it searches the sparser checks of ``_get_sparser_rows``, which + span the same space, and maps the syndrome of a query to them. + + Args: + matrix: The checks, whose kernel is the code: the stabilizers, in ``[X|Z]`` order, for + Paulis, or the rows that check the X or Z parts. Bit ``j`` of the syndrome of a word + is its symplectic product with row ``j`` for Paulis, and its dot product for parts. + homogeneous: False for Paulis, and True for X or Z parts. + max_rounds: The ``max_sparsifying_rounds`` of the tuning (see ``_get_sparser_rows``). + rounds_without_improvement: The ``sparsifying_rounds_without_improvement`` of the tuning. + """ + + def __init__( + self, + matrix: npt.NDArray[np.uint8], + *, + homogeneous: bool, + max_rounds: int, + rounds_without_improvement: int, + ) -> None: + # Map each syndrome of a word of weight zero or one to such a word. The syndrome of a word + # with one nonzero bit is a column of the checks, once the halves of symplectic checks are + # swapped. + num_variables = matrix.shape[1] if homogeneous else matrix.shape[1] // 2 + syndrome_matrix = matrix if homogeneous else np.roll(matrix, num_variables, axis=1) + columns = _row_ints(np.ascontiguousarray(syndrome_matrix.T)) + self._singles, _, _ = _tabulate_singles(columns, num_variables, is_pauli=not homogeneous) + + rows, transform = _get_sparser_rows( + matrix, + homogeneous=homogeneous, + max_rounds=max_rounds, + rounds_without_improvement=rounds_without_improvement, + ) + self._num_checks = len(rows) + # Row ``i`` of the sparse rows sums the given rows that row ``i`` of the transform selects, + # so bit ``i`` of a syndrome for them is the parity of those bits of the given syndrome. + # Entry ``j`` of the map is column ``j`` of the transform, which a given syndrome bit adds. + self._syndrome_map_columns = _row_ints(np.ascontiguousarray(transform.T)) + # Without a function that finds automorphisms, the search never asks for them. + self._search = SupportSearch( + rows, + np.zeros((0, matrix.shape[1]), dtype=np.uint8), + homogeneous=homogeneous, + automorphism_minimum_states=_tuning.MAX_VALUE, + ) + # Row ``j`` of the inverse is a word whose syndrome has only bit ``j`` set, so the words of + # the set bits of a syndrome sum to a representative of its coset. + syndrome_rows = rows if homogeneous else np.roll(rows, num_variables, axis=1) + self._inverse_rows = _row_ints(_get_inverse(syndrome_rows)) + + def get_min_weight_bound( + self, + syndrome: int, + max_weight: int, + *, + first_limit: int | None = None, + ) -> MinWeightBound: + """Decide whether the coset of this syndrome has a word of weight at most ``max_weight``. + + A negative ``max_weight`` admits no word, as for a ``_CosetSearch``. + + A search with weight limit ``max_weight`` can visit many heavy supports before it finds a + word that is much lighter. Given ``first_limit``, the search instead tries every limit from + ``first_limit`` to ``max_weight``, and stops at the first limit within which it finds a + word. It tries no limit below two, which the lookup of words of weight at most one makes + useless, and lowers a first limit above ``max_weight`` to it. From the first limit two, it + finds a lightest word. This can save time on cosets with words much lighter than + ``max_weight``, and repeats the work of the lower limits on cosets without a word within + ``max_weight``. A first limit above two suits callers to which any word within it gives the + same answer. A word that the search finds at that limit can be lighter than the limit, so + its coset gets the lower bound two. + """ + # Look up a word of weight zero or one. + word = self._singles.get(syndrome) + if word is not None: + weight = int(word != 0) + return MinWeightBound(weight <= max_weight, word, weight) + + # Map the syndrome to the sparse checks. The map is invertible, and the lookup above + # answers the zero syndrome, so the mapped syndrome is not zero. The sparse checks are + # independent, so the search keeps all of them, in order, and the mapped syndrome has one + # bit for each check that it keeps. So the search can start from the packed syndrome, + # without the validation of ``find_vector_with_syndrome``, which would cost more than the + # search itself in many queries. + syndrome = _xor_selected(self._syndrome_map_columns, syndrome) + packed_syndrome = _int_to_words(syndrome, (self._num_checks + 63) // 64) + + # The lookup leaves only cosets without a word below weight two, so no limit below two is + # searched, and a single query searches only at ``max_weight``. Each limit within which the + # search finds no word raises the lower bound above that limit. + lower_bound = 2 + first = max_weight if first_limit is None else min(first_limit, max_weight) + for limit in range(max(2, first), max_weight + 1): + symbols = self._search._find_vector_with_packed_syndrome(packed_syndrome, limit) + if symbols is not None: + return MinWeightBound(True, self._get_word(symbols), lower_bound) + lower_bound = limit + 1 + representative = _xor_selected(self._inverse_rows, syndrome) + return MinWeightBound(False, representative, lower_bound) + + def _get_word(self, symbols: npt.NDArray[np.int64]) -> int: + """Pack the symbols of a support into a word. + + A symbol gives the qubit ``symbol % n`` the Pauli X, Z, or Y for values 0, 1, and 2. The + symbols of an X or Z part have only the value 0. + """ + num_variables = self._search.num_variables + word = 0 + for symbol in symbols.tolist(): + value, variable = divmod(symbol, num_variables) + if value != 1: + word |= 1 << variable + if value: + word |= 1 << (num_variables + variable) + return word + + +def _get_sparser_rows( + matrix: npt.NDArray[np.uint8], + *, + homogeneous: bool, + max_rounds: int, + rounds_without_improvement: int, +) -> tuple[npt.NDArray[np.uint8], npt.NDArray[np.uint8]]: + """Rows with the span of independent rows, made sparser by row reductions in random orders. + + The rows start as those of ``_sparsify``, and are weighed as it weighs them. Each round + row-reduces the given rows with pivots in a random order of the columns, which gives the words + of the span that have exactly one pivot column in their support. Those that are lighter than + the heaviest current row join the rows as candidates, and the greedy algorithm for matroids + keeps the lightest independent candidates: it takes them by increasing weight, and keeps each + that is independent of those that it kept. So the rows are always a lightest basis drawn from + the candidates, and their total weight never rises. The rounds stop after ``max_rounds``, or + once ``rounds_without_improvement`` rounds in a row have left the total weight unchanged, and a + last pass of ``_sparsify`` adds rows to each other where that helps. The rows need not be the + sparsest. Rows with the same span give a search the same answers and bounds, though perhaps + other representatives. So the rounds may be random, and a fixed seed makes them reproducible. + + Args: + matrix: Independent rows. + homogeneous: False if the rows are symplectic, and True otherwise. + max_rounds: The most rounds, or zero for the rows of ``_sparsify``. + rounds_without_improvement: The number of rounds in a row without a lower total weight after + which the rounds stop. + + Returns: + The rows, and the invertible transform ``T``, so that the rows are ``T @ matrix % 2``. The + total weight of the rows is at most that of the rows of ``_sparsify``. + """ + rows, transform = _sparsify(matrix, homogeneous=homogeneous) + if not len(rows): + return rows, transform + + # Each round adds the rows of a reduction that are lighter than the heaviest current row. The + # rows come first among candidates of equal weight, so a round that does not lower the total + # weight leaves the rows as they are. Row-reducing the transposed candidates, sorted by weight, + # takes as pivots the first candidates that are independent of those before them. + rng = np.random.default_rng(_SPARSIFYING_SEED) + weights = _get_row_weights(rows, homogeneous=homogeneous) + rounds_unchanged = 0 + for _ in range(max_rounds): + reduced, _, _ = row_reduce_binary(matrix, columns=rng.permutation(matrix.shape[1])) + reduced_weights = _get_row_weights(reduced, homogeneous=homogeneous) + light = reduced_weights < weights.max() + previous_total = weights.sum() + if np.any(light): + candidates = np.vstack([rows, reduced[light]]) + candidate_weights = np.concatenate([weights, reduced_weights[light]]) + order = np.argsort(candidate_weights, kind="stable") + _, _, pivots = row_reduce_binary(np.ascontiguousarray(candidates[order].T)) + rows, weights = candidates[order[pivots]], candidate_weights[order[pivots]] + rounds_unchanged = rounds_unchanged + 1 if weights.sum() == previous_total else 0 + if rounds_unchanged >= rounds_without_improvement: + break + + # Add rows to each other where that helps, and find the transform from the given rows. The + # rows are in the span of ``matrix``, whose product with the transposed inverse is the identity, + # so their product with it is the transform. Products of binary matrices in float32 are exact + # while their inner dimension is below ``2**24``, and faster than in integers. + rows, _ = _sparsify(rows, homogeneous=homogeneous) + inverse = _get_inverse(matrix) + product = rows.astype(np.float32) @ inverse.T.astype(np.float32) + return rows, (product % 2).astype(np.uint8) + + +def _sparsify( + matrix: npt.NDArray[np.uint8], *, homogeneous: bool +) -> tuple[npt.NDArray[np.uint8], npt.NDArray[np.uint8]]: + """Rows with the span of the given rows, made sparser by adding rows to each other. + + The weight of a row is its number of nonzero entries, or, if the rows are symplectic, the + number of qubits on which its X part ``[:n]`` or its Z part ``[n:]`` is nonzero. While adding + another row to a row lowers its weight, the row takes the sum that is lightest. Every addition + lowers the total weight, so this ends. + + Args: + matrix: The rows. + homogeneous: False if the rows are symplectic, and True otherwise. + + Returns: + The rows, and the invertible transform ``T``, so that the rows are ``T @ matrix % 2``. + """ + # Pack the X and Z parts, or the rows and zeros, so that a weight counts the nonzero qubits. + num_rows, width = matrix.shape + num_variables = width if homogeneous else width // 2 + part_x = pack_bits(np.ascontiguousarray(matrix[:, :num_variables])) + part_z = ( + np.zeros_like(part_x) + if homogeneous + else pack_bits(np.ascontiguousarray(matrix[:, num_variables:])) + ) + transform = np.eye(num_rows, dtype=np.uint8) + weights = np.bitwise_count(part_x | part_z).sum(axis=1, dtype=np.int64) + + # Replace each row by its lightest sum with another row, while that lowers its weight. A row + # added to itself would vanish, so its own entry keeps its weight, which never wins. + improved = True + while improved: + improved = False + for row in range(num_rows): + sums_x, sums_z = part_x ^ part_x[row], part_z ^ part_z[row] + sum_weights = np.bitwise_count(sums_x | sums_z).sum(axis=1, dtype=np.int64) + sum_weights[row] = weights[row] + other = int(np.argmin(sum_weights)) + if sum_weights[other] < weights[row]: + part_x[row], part_z[row] = sums_x[other], sums_z[other] + transform[row] ^= transform[other] + weights[row] = sum_weights[other] + improved = True + + parts = [unpack_bits(part_x, num_variables)] + if not homogeneous: + parts.append(unpack_bits(part_z, num_variables)) + return np.hstack(parts), transform + + +def _get_row_weights(rows: npt.NDArray[np.uint8], *, homogeneous: bool) -> npt.NDArray[np.int64]: + """The weights of rows, as ``_sparsify`` counts them: of nonzero entries, or of qubits.""" + if homogeneous: + return rows.sum(axis=1, dtype=np.int64) + half = rows.shape[1] // 2 + return (rows[:, :half] | rows[:, half:]).sum(axis=1, dtype=np.int64) diff --git a/src/qldpc/codes/distance/_connected_support/coset_test.py b/src/qldpc/codes/distance/_connected_support/coset_test.py new file mode 100644 index 000000000..f65f3dfcd --- /dev/null +++ b/src/qldpc/codes/distance/_connected_support/coset_test.py @@ -0,0 +1,209 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the connected-support coset searches of ``CosetLeaderSearch``.""" + +from __future__ import annotations + +import inspect +import itertools +from typing import Any + +import numpy as np +import numpy.typing as npt +import pytest +import stim + +from qldpc._binary import row_reduce_binary + +from ...conftest import Tune +from .._coset_leader_search import CosetLeaderSearch +from .._tuning import use_tuning +from ..conftest import ( + _PHASED_GHZ_BLOCK, + _block_generators, + _block_minimum, + _css_minima, + _find_each_not_within_max_weight, + _get_syndrome, + _graph_state, + _pack_syndromes, + _pauli_weight, + _random_css_state, + _stabilizer_matrix, +) +from . import coset + + +def test_connected_support_oracles_make_checks_sparser_with_their_tuning( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A connected-support oracle makes its checks sparser in the rounds of its tuning. + + The most rounds and the rounds without improvement of the tuning in force when the oracle is + built reach ``_get_sparser_rows``, which the test wraps to record them. The state is a graph + state with phases, which is not CSS, so the oracle has one search of Pauli errors. + """ + calls: list[tuple[int, int]] = [] + get_sparser_rows = coset._get_sparser_rows + signature = inspect.signature(get_sparser_rows) + + def record(*args: Any, **kwargs: Any) -> tuple[npt.NDArray[np.uint8], npt.NDArray[np.uint8]]: + arguments = signature.bind(*args, **kwargs).arguments + calls.append((arguments["max_rounds"], arguments["rounds_without_improvement"])) + return get_sparser_rows(*args, **kwargs) + + monkeypatch.setattr(coset, "_get_sparser_rows", record) + with use_tuning(max_sparsifying_rounds=5, sparsifying_rounds_without_improvement=2): + CosetLeaderSearch(_graph_state(2, [(0, 1)], phase=True), method="connected_support") + assert calls == [(5, 2)] + + +def test_connected_support_answers_match_exhaustive_minima(pytestconfig: pytest.Config) -> None: + """The connected-support engine answers every query exactly, by either measure of weight. + + Graph states with phases are not CSS, and are weighed by physical weight. CSS states with + no pure-X stabilizer, with only pure-X stabilizers, and with both are weighed by both measures, + and the CSS weight splits their searches. A syndrome whose lightest errors weigh at most one + gets their weight as its bound. Otherwise a query within ``max_weight`` gets 2, and one with no + error within ``max_weight`` gets ``max(2, max_weight + 1)``. The queries of batches, which try + increasing weight limits, instead get the exact minimum and a lightest error when they are + within ``max_weight``. These exact bounds imply the inequalities that ``_assert_exact_answers`` + checks. A search of Pauli errors from a first weight limit above ``max_weight`` answers as a + single query. + """ + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + for num_qubits in range(1, 6): + edges = [pair for pair in itertools.combinations(range(num_qubits), 2) if rng.integers(2)] + graph_state = _graph_state(num_qubits, edges, phase=True) + num_x_stabilizers = [0, num_qubits, num_qubits // 2][num_qubits % 3] + css_state = _random_css_state(num_qubits, num_x_stabilizers, rng) + for matrix, css_weight in [(graph_state, False), (css_state, False), (css_state, True)]: + oracle = CosetLeaderSearch(matrix, css_weight=css_weight, method="connected_support") + minima = _css_minima(matrix, css_weight) + for syndrome, max_weight in itertools.product( + range(1 << num_qubits), range(-1, num_qubits + 1) + ): + minimum = int(minima[syndrome]) + single = oracle.get_min_weight_bound(syndrome, max_weight) + batched = oracle._get_batch_support_bound(syndrome, max_weight) + if not css_weight: + search = oracle._support_searches[0] + late = search.get_min_weight_bound( + syndrome, max_weight, first_limit=max_weight + 1 + ) + assert late == single + for bound, exact in [(single, False), (batched, True)]: + weight = _pauli_weight(bound.representative, num_qubits, css_weight) + assert _get_syndrome(matrix, bound.representative) == syndrome + assert bound.within_max_weight == (minimum <= max_weight) + if not bound.within_max_weight: + expected_bound = minimum if minimum < 2 else max(2, max_weight + 1) + assert bound.lower_bound == expected_bound + elif exact or minimum < 2: + assert bound.lower_bound == weight == minimum + else: + assert bound.lower_bound == 2 and weight <= max_weight + + +def test_connected_support_batches_read_syndromes_of_several_words( + pytestconfig: pytest.Config, tune: Tune +) -> None: + """On 17 phased GHZ blocks, batches of the connected-support engine agree with block minima. + + The 68 syndrome bits span two words, and the physical weight takes one search of the Pauli + errors. The batch holds the syndromes of 30 errors. The first acts on one qubit in each of + blocks 0, 8, and 16. Block 16 has its syndrome bits in the second word, so a search that read + only the first word would find a lighter error than the minimum of three. The other errors each + act on one to three random qubits, and blocks that an error does not touch add nothing to its + minimum weight. The oracle looks for sparser checks for one round, rather than for the many + rounds that are slow as plain Python. On these blocks, one round gives the default checks. + """ + tune(max_sparsifying_rounds=1) + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + num_blocks = 17 + generators = _block_generators(_PHASED_GHZ_BLOCK, num_blocks) + matrix = _stabilizer_matrix(generators) + oracle = CosetLeaderSearch(matrix, method="connected_support") + syndromes, minima = [], [] + for error_index in range(30): + error = stim.PauliString(4 * num_blocks) + qubits = ( + [0, 35, 67] + if error_index == 0 + else rng.choice(4 * num_blocks, size=rng.integers(1, 4), replace=False) + ) + for qubit in qubits: + error[int(qubit)] = int(rng.integers(1, 4)) + syndromes.append(sum((not error.commutes(gg)) << jj for jj, gg in enumerate(generators))) + bits = [(syndromes[-1] >> jj) & 1 for jj in range(4 * num_blocks)] + local = [bits[4 * index : 4 * index + 4] for index in range(num_blocks)] + minima.append(_block_minimum(_PHASED_GHZ_BLOCK, [ss for ss in local if any(ss)], False)) + assert syndromes[0] >> 64 and minima[0] == 3 + words = _pack_syndromes(syndromes, 68) + for max_weight in range(4): + found = _find_each_not_within_max_weight(oracle, matrix, words, max_weight) + assert found == np.flatnonzero(np.array(minima) > max_weight).tolist() + + +def test_sparser_rows_span_the_same_space(pytestconfig: pytest.Config) -> None: + """Adding rows to each other leaves rows of the same span, which no such addition makes lighter. + + The weight of a symplectic row counts the qubits on which it acts, whether by X, Y, or Z. + """ + + def get_weights(rows: npt.NDArray[np.uint8], homogeneous: bool) -> npt.NDArray[np.int_]: + if homogeneous: + return rows.sum(axis=1) + half = rows.shape[1] // 2 + return (rows[:, :half] | rows[:, half:]).sum(axis=1) + + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + cases = [] + for homogeneous in (True, False): + for _ in range(10): + matrix = rng.integers(2, size=(6, 8), dtype=np.uint8) + cases.append((matrix[row_reduce_binary(matrix.T.copy())[2]], homogeneous)) + for matrix, homogeneous in cases: + rows, transform = coset._sparsify(matrix, homogeneous=homogeneous) + assert np.array_equal(transform @ matrix % 2, rows) + assert len(row_reduce_binary(transform)[2]) == len(matrix) + weights = get_weights(rows, homogeneous) + for row, other in itertools.permutations(range(len(rows)), 2): + assert get_weights((rows[row] ^ rows[other])[None], homogeneous)[0] >= weights[row] + + +def test_rounds_of_random_reductions_make_rows_sparser() -> None: + """Rounds reach a lightest basis, which adding rows to each other misses. + + Three independent words of the span of these Paulis on five qubits act on three qubits each, so + they form a lightest basis, of total weight 9. Adding rows to each other replaces the first row + with its sum with the second, which is one of them, and then no sum of two rows is lighter than + a row, so zero rounds give total weight 10. Another of the three words has two Ys, so it counts + five bits, and rows weighed by bits would miss it. + """ + matrix = np.array( + [ + [0, 1, 1, 0, 1, 1, 0, 0, 0, 1], + [0, 0, 0, 1, 1, 1, 0, 0, 0, 1], + [1, 0, 1, 0, 1, 0, 1, 0, 0, 0], + ], + dtype=np.uint8, + ) + for max_rounds, total_weight in [(0, 10), (20, 9)]: + rows, transform = coset._get_sparser_rows( + matrix, homogeneous=False, max_rounds=max_rounds, rounds_without_improvement=20 + ) + assert np.array_equal(transform @ matrix % 2, rows) + assert len(row_reduce_binary(transform)[2]) == len(matrix) + assert (rows[:, :5] | rows[:, 5:]).sum() == total_weight + + +def test_connected_support_rejects_what_it_cannot_search() -> None: + """Oracles reject unknown methods, and the CSS weight on non-CSS states by connected support.""" + with pytest.raises(ValueError, match="only on CSS states"): + CosetLeaderSearch( + _graph_state(2, [(0, 1)], phase=True), css_weight=True, method="connected_support" + ) + method: Any = "brute_force" + with pytest.raises(ValueError, match="Unknown coset-leader search method"): + CosetLeaderSearch([[1, 0]], method=method) diff --git a/src/qldpc/codes/distance/_coset_leader_search.py b/src/qldpc/codes/distance/_coset_leader_search.py index 94ba32cec..e08c4a5eb 100644 --- a/src/qldpc/codes/distance/_coset_leader_search.py +++ b/src/qldpc/codes/distance/_coset_leader_search.py @@ -5,88 +5,47 @@ The Pauli errors with a given syndrome form a coset of the stabilizer group, and a lightest error in it is a coset leader. ``CosetLeaderSearch`` decides whether the coset leaders of a syndrome class weigh at most a given maximum weight, with Brouwer-Zimmermann searches of cosets of binary codes, or -with connected-support searches that start from the syndrome (see ``connected_support.py``). For -the residual-weight estimator of ``qldpc.experimental``, it also finds the weight of the coset -leaders of each syndrome class up to a maximum weight. +with connected-support searches that start from the syndrome. For the residual-weight estimator of +``qldpc.experimental``, it also finds the weight of the coset leaders of each syndrome class up to a +maximum weight. + +This module is the entry point of these queries. It splits a query into coset searches, which live +with their methods: ``_brouwer_zimmermann/coset.py``, with the tables of its steps and tails in +``_brouwer_zimmermann/coset_tables.py``, and ``_connected_support/coset.py``. The parts that both +methods share are in ``_cosets.py``. """ from __future__ import annotations import functools -from collections.abc import Sequence -from dataclasses import dataclass -from typing import Literal, NamedTuple, get_args +from typing import Literal, get_args import numpy as np import numpy.typing as npt -from qldpc._binary import ( - get_kernel, - pack_bits, - popcount_word, - row_reduce_binary, - trailing_zeros_word, - unpack_bits, -) +from qldpc._binary import get_kernel, pack_bits, row_reduce_binary from qldpc._compilation import always_compiled -from . import _tuning -from ._binary import as_binary_matrix, assert_binary, symplectic_to_hamming -from ._brouwer_zimmermann import affine_instance, affine_search, coset_routes -from ._connected_support import search as connected_support -from ._information_sets import certificates, completion_bounds -from ._tuning import SearchTuning, get_tuning +from ._binary import as_binary_matrix, assert_binary +from ._brouwer_zimmermann.coset import ( + _CosetSearch, + _CosetTuning, + _get_css_searches, + _get_pauli_search, + _StabilizerKey, +) +from ._connected_support.coset import _ConnectedSupportCosetSearch +from ._cosets import MinWeightBound, _packed_row_int, _row_ints, _xor_selected, _xor_selected_rows +from ._tuning import get_tuning # The method of a coset-leader search. CosetSearchMethod = Literal["brouwer_zimmermann", "connected_support"] -# The seed of the random orders of columns in which connected-support coset searches row-reduce -# their checks to make them sparser (see ``_get_sparser_rows``). It makes the checks reproducible. -_SPARSIFYING_SEED = 0 - -# The planning buffer of a coset search: its tables of the bounds of its steps, each with one more -# row that caches a stopping step (see ``_get_planning``). -_PlanningBuffer = npt.NDArray[np.int64] - -# The fields of a ``_CosetTails`` record, which the compiled searches take as the record itself or -# as a plain tuple. -_TailFields = tuple[ - npt.NDArray[np.uint64], - npt.NDArray[np.uint64], - npt.NDArray[np.uint8], - npt.NDArray[np.int64], - int, -] #################################################################################################### # coset-leader minimum-weight bounds -@dataclass(frozen=True) -class MinWeightBound: - """The answer to a query about the minimum weight of a coset. - - A query of ``CosetLeaderSearch`` asks whether a syndrome class of Pauli errors, which is a coset - of the stabilizer group, has an error of weight at most ``max_weight``. A query of one of its - coset searches asks the same about a coset of the code that the search covers. Which element - and which lower bound a query returns can depend on the method and tuning of the search, and on - the version of qLDPC, within the guarantees below. - - Attributes: - within_max_weight: Whether the coset has an element of weight at most ``max_weight``. - representative: An element of the coset: a packed Pauli (see ``CosetLeaderSearch``) for a - query of the oracle, and a word of the search (a packed Pauli, or an X or Z part) for a - query of a coset search. If ``within_max_weight`` is True, its weight is at most - ``max_weight``. - lower_bound: A lower bound on the minimum weight of the coset. If ``within_max_weight`` is - False, it exceeds ``max_weight``. - """ - - within_max_weight: bool - representative: int - lower_bound: int - - class CosetLeaderSearch: """Minimum-weight oracle for Pauli errors modulo the stabilizers of a pure stabilizer state. @@ -488,859 +447,6 @@ def _join_part_bounds(self, x_bound: MinWeightBound, z_bound: MinWeightBound) -> ) -class _CosetSearchTables(NamedTuple): - """The tables of ``_search_coset`` for one coset search of a ``CosetLeaderSearch``. - - The search runs on embedded words. For a search of Pauli errors, the embedded word of an error - is its packed Pauli for the CSS weight, and its packed Pauli followed by ``x xor z`` for the - physical weight (see ``symplectic_to_hamming``). For a search of X or Z parts, the embedded - word of a part is the part itself. - - Attributes: - inverse: Row ``j`` is the packed embedded word of a word whose syndrome has only bit ``j`` - set. - generators: The packed rows of each set of a family of information sets of the embedded - generators of the code (see ``coset_routes``). The last set of a disjoint family may - be partial, of rank below the dimension of the code. - pivots: The pivot columns of each set, padded with -1 beyond its rank. - planning: The planning buffer of the search with this family: its tables of the bounds of - its steps, each with one more row that caches a stopping step (see ``_get_planning``). - For a nonnegative maximum weight, ``_get_stopping_step`` reuses the cached step of a - table exactly while it stays the first step whose bound exceeds that weight, and - replaces it otherwise. Queries write only the rows of cached steps, and oracles that - share a cached search share them. - x_mask: Selects the bits of a packed embedded word that count as its X part, with one - machine word for each machine word of the embedded words. Only searches of Pauli - errors by CSS weight use the X part, and their layers scan with it, but every search - reads the mask when it weighs the word of a syndrome, and a search of whole words also - reads it when it weighs the offsets of layer zero. - """ - - inverse: npt.NDArray[np.uint64] - generators: npt.NDArray[np.uint64] - pivots: npt.NDArray[np.int64] - planning: _PlanningBuffer - x_mask: npt.NDArray[np.uint64] - - -class _CosetTails(NamedTuple): - """The tails with which ``_search_coset`` weighs the words of a family by tails, and its tables. - - The tail of an embedded word on a set of the family holds every column of the word but the - pivot columns of the set, including columns on which no row of the set has a one. The tails are - packed, and padded with zeros to one common width, that of the set of least rank. A word of a - coset is the offset of a set, which is zero on its pivot columns, plus rows of the set. Each - row below the rank of the set has a one on its own pivot column, where the other rows are zero. - So the word weighs the number of its rows below the rank plus the weight of its tail (see - Section 3.5 of ``brouwer_zimmermann.md``). - - Attributes: - tails: The tails of the rows of each set, with shape ``(num_sets, num_rows, num_words)``. - inverse_tails: The tails of the rows of ``inverse`` of ``_CosetSearchTables`` on each set, - with shape ``(num_sets, num_syndrome_bits, num_words)``. The tail of the word of a - syndrome on a set is the sum of the rows for the set bits of the syndrome. These rows - can be derived from ``inverse``, but a query that sums them directly finds the tails of - its offsets faster than one that gathers the columns of each tail from the full word. - completion_tables: The completion tables of the tails of the sets that have them, one after - another (see ``completion_bounds.CompletionTables``). - layout: Where the tables of each set lie in ``completion_tables``, with a row for each set: - the start of its tables, their number of blocks ``b``, and their ``b + 1`` block - offsets, padded with zeros. A set without tables has zero blocks. The compiled search - takes the layout of every set in one array, since each argument of a compiled call adds - to the time of every query. - block_bits: The number of columns of the blocks of the tables. - """ - - tails: npt.NDArray[np.uint64] - inverse_tails: npt.NDArray[np.uint64] - completion_tables: npt.NDArray[np.uint8] - layout: npt.NDArray[np.int64] - block_bits: int - - -class _CosetSearch: - """One coset search of a ``CosetLeaderSearch``: a search of the cosets of one binary code. - - The words of a search are either packed Paulis on ``n`` qubits, in which case the code is - spanned by the packed stabilizers, or the X parts (or Z parts) of Paulis, in which case the code - is spanned by the X parts of the pure-X stabilizers (or the Z parts of the pure-Z stabilizers). - Each coset is labeled by the syndrome of its words, whose bit ``j`` is the parity of a word with - row ``j`` of a matrix of checks. - - The search answers queries of each maximum weight with the tables of a family of information - sets of the code, which ``routes`` choose (see ``coset_routes``). The tables of every family - have the same ``inverse`` and ``x_mask``. The queries of a route by tails weigh the words of - its family by their tails, and skip completions by the completion tables, from its record of - tails. - - Args: - routes: The routes of the search. - css_weight: True to weigh words by their CSS weight, which is the Hamming weight of an X - or Z part, or False to weigh them by their physical weight. - num_qubits: The number of qubits ``n``. - inverse_rows: Row ``j`` of a right inverse of the checks, not embedded, as an integer: a - word whose syndrome has only bit ``j`` set. - check_columns: Column ``c`` of the checks as an integer: the syndrome of the word with only - bit ``c`` set. - is_pauli: Whether the words are packed Paulis of ``2 n`` bits, rather than X or Z parts of - ``n`` bits. - max_css_single_pairs: For a search of Pauli errors by CSS weight, the most pairs of distinct - syndromes of single-qubit X and Z errors that it tabulates (see ``_css_singles``). - """ - - def __init__( - self, - routes: coset_routes.CosetRoutes[_CosetSearchTables, _CosetTails], - css_weight: bool, - num_qubits: int, - inverse_rows: tuple[int, ...], - check_columns: tuple[int, ...], - *, - is_pauli: bool, - max_css_single_pairs: int = 0, - ) -> None: - self._routes = routes - self._max_css_single_pairs = max_css_single_pairs - self.css_weight = css_weight - # The compiled search weighs embedded words by their CSS weight only in a search of Pauli - # errors by CSS weight, and otherwise by their Hamming weight, which is twice the physical - # weight of an error, and the weight of an X or Z part (see ``_search_coset``). The scale - # chooses which of these weights a scan computes. The weight factor of the coset instances - # is separate: it is two for every search of Pauli errors, including those by CSS weight, - # whose scale is zero, since an error weighs at least half the Hamming weight of its - # embedded word (see ``_get_pauli_search``). - self.scale = 0 if css_weight and is_pauli else 1 if css_weight else 2 - self._num_qubits = num_qubits - self._is_pauli = is_pauli - self._inverse_rows = inverse_rows - self._num_syndrome_bits = len(inverse_rows) - # A query below maximum weight two bounds a coset without words of weight at most one by - # two. If the search is of X or Z parts and rounds to parity (see ``_get_search_tables``), - # every word of an odd coset has odd weight, so such an odd coset has no word of weight two - # either, and the query bounds it by three. An odd coset of a search of Pauli errors by - # CSS weight can still hold an error of weight two, such as ``YX``, whose binary word has - # three ones. - num_tables = len(routes.disjoint_tables.planning) - self._odd_coset_lookup_bound = 3 if not is_pauli and num_tables == 2 else 2 - - # Map each syndrome of a word of weight zero or one to such a word. For the CSS weight, - # ``_weight_one_word`` also pairs the single-qubit X errors with the single-qubit Z errors, - # to find ``X_i Z_j`` on distinct qubits. - self._singles, self._x_singles, self._z_singles = _tabulate_singles( - check_columns, num_qubits, is_pauli=is_pauli - ) - - def get_min_weight_bound(self, syndrome: int, max_weight: int) -> MinWeightBound: - """Decide whether the coset of this syndrome has a word of weight at most ``max_weight``. - - Returns: - A ``MinWeightBound`` for the coset, whose representative is a word of the coset. - """ - word = self._weight_one_word(syndrome) - if word is not None: - weight = self._weight(word) - return MinWeightBound(weight <= max_weight, word, weight) - - # Otherwise the coset has no word of weight at most one, so two is a lower bound, and so is - # the odd-coset lookup bound for an odd coset. - if max_weight < 2: - word = _xor_selected(self._inverse_rows, syndrome) - return MinWeightBound( - False, word, self._odd_coset_lookup_bound if word.bit_count() % 2 else 2 - ) - - # Search the coset with the tables of the route of the maximum weight, by tails on a route - # by tails, and keep the lightest word that the search weighs in ``best``. - tables, tails = self._get_route(max_weight) - best = np.empty(tables.inverse.shape[1], dtype=np.uint64) - packed_syndrome = _int_to_words(syndrome, max(1, (self._num_syndrome_bits + 63) // 64)) - lower_bound = _search_coset(packed_syndrome, max_weight, best, *tables, tails, self.scale) - # A search that finds a light enough word returns zero, but every word of the coset weighs - # at least two, since the coset has no word of weight at most one. - return MinWeightBound(lower_bound <= max_weight, self.unembed(best), max(2, lower_bound)) - - def find_first_not_within_max_weight( - self, syndromes: npt.NDArray[np.uint64], max_weight: int - ) -> tuple[int, MinWeightBound | None]: - """Find the first syndrome whose coset has no word of weight at most ``max_weight``. - - The syndromes are packed as for ``CosetLeaderSearch.find_first_not_within_max_weight``. - The search answers them with the route of the maximum weight, which it takes once. - - Returns: - The index of that syndrome, or the number of syndromes if there is none, and a - ``MinWeightBound`` for its coset, with the lightest word that the search weighed, or - None if there is none. - """ - tables, tails = self._get_route(max_weight) - best = np.empty(tables.inverse.shape[1], dtype=np.uint64) - index, lower_bound = _find_first_not_within_max_weight( - syndromes, max_weight, best, *tables, tails, self.scale - ) - if index == len(syndromes): - return index, None - return index, MinWeightBound(False, self.unembed(best), int(lower_bound)) - - def get_min_weights( - self, syndromes: npt.NDArray[np.uint64], max_weight: int, floors: npt.NDArray[np.int64] - ) -> npt.NDArray[np.int64]: - """Find the minimum weight of the coset of each syndrome, if it is at most ``max_weight``. - - The syndromes are packed as for ``CosetLeaderSearch.find_first_not_within_max_weight``. The - search of a syndrome also ends at a word of weight at most its floor in ``floors``, which - must be at most ``max_weight``. No coset has a minimum weight above the number ``n`` of - qubits, so the search answers the syndromes with the route of the lesser of ``max_weight`` - and ``n``, which it takes once. Unlike ``get_min_weight_bound``, it looks up no words of - weight at most one, since the first layers of a search find them cheaply. - - Returns: - For each syndrome, the weight of a word of its coset of weight at most its floor if - there is one, and otherwise the minimum weight of its coset, or ``max_weight + 1`` if - that is larger (see ``_search_coset_for_min_weight``). - """ - max_weight = min(max_weight, self._num_qubits) - tables, tails = self._get_route(max_weight) - return _find_min_weights(syndromes, max_weight, floors, *tables, tails, self.scale) - - def unembed(self, embedded_word: npt.NDArray[np.uint64]) -> int: - """Convert a packed embedded word into a word, without the columns that the embedding adds. - - The embedding for the physical weight appends ``x xor z`` to a packed Pauli (see - ``_CosetSearchTables``), and the other embeddings add nothing. - """ - return _packed_row_int(embedded_word) & ((1 << self._num_word_bits) - 1) - - def _get_route(self, max_weight: int) -> tuple[_CosetSearchTables, _TailFields | None]: - """The tables of the route of a maximum weight, and the fields of its tails, if any. - - The fields are a plain tuple, which numba types at each call of a compiled search several - times faster than the named tuple ``_CosetTails``. - """ - tables, tails = self._routes.get_route(max_weight) - return tables, None if tails is None else tails[:] - - @property - def _num_word_bits(self) -> int: - """The number of bits of a word: ``2 n`` for Paulis, and ``n`` for X or Z parts.""" - return 2 * self._num_qubits if self._is_pauli else self._num_qubits - - def _weight(self, word: int) -> int: - """The weight of a word: of its Pauli, or the Hamming weight of an X or Z part.""" - if not self._is_pauli: - return word.bit_count() - mask = (1 << self._num_qubits) - 1 - x, z = word & mask, (word >> self._num_qubits) & mask - return max(x.bit_count(), z.bit_count()) if self.css_weight else (x | z).bit_count() - - def _weight_one_word(self, syndrome: int) -> int | None: - """Find a word of weight zero or one with this syndrome, if there is one.""" - if syndrome in self._singles: - return self._singles[syndrome] - if not (self._is_pauli and self.css_weight): - return None - # CSS weight one also allows X_i Z_j on distinct qubits. Callers such as the certification - # of fault propagation make a query for each of thousands of syndromes, so a table of these - # errors, built on the first query that needs it, saves a scan of the qubits per query. - if self._css_singles is not None: - return self._css_singles.get(syndrome) - # If that table would be too large, match the syndrome of each single-qubit X error against - # those of the single-qubit Z errors, in time linear in the number of qubits. - for sx, x in self._x_singles.items(): - z = self._z_singles.get(syndrome ^ sx) - if z is not None: - return x ^ z - return None - - @functools.cached_property - def _css_singles(self) -> dict[int, int] | None: - """Errors X_i Z_j (with X_i or Z_j possibly the identity) by syndrome, if not too many.""" - return _tabulate_css_singles(self._x_singles, self._z_singles, self._max_css_single_pairs) - - -class _StabilizerKey: - """A stabilizer matrix and its checks, which a cache can hash and compare by the matrix. - - The checks follow from the stabilizers, so the stabilizers alone key the cache, and the key - keeps both matrices to build a search that the cache has not stored yet. Keys of packed bytes - would not hold the matrices, but unpacking them would cost about 0.4% of the setup of a search. - The matrices may be arrays of the caller. The key compares a copy of the bytes of the - stabilizers, and the search copies what it keeps of them, so later changes to the arrays of the - caller reach neither. - """ - - def __init__(self, stabilizers: npt.NDArray[np.uint8], checks: npt.NDArray[np.uint8]) -> None: - self.stabilizers = stabilizers - self.checks = checks - self._key = (stabilizers.shape, stabilizers.tobytes()) - self._hash = hash(self._key) - - def __hash__(self) -> int: - return self._hash - - def __eq__(self, other: object) -> bool: - return isinstance(other, _StabilizerKey) and self._key == other._key - - -class _CosetTuning(NamedTuple): - """The fields of the tuning that the setup of a BZ coset search reads, which key its cache. - - ``disjoint_random_column_orders`` configures the disjoint information sets of the search. Only - a search of Pauli errors by CSS weight reads ``max_css_single_pairs``, for its lookups. The - fields from ``overlapping_minimum_work`` to ``overlapping_attempts_per_set`` configure the - families with which the search answers costly queries, and ``overlapping_minimum_work`` also - chooses its routes by tails, whose queries weigh words by their tails and skip completions by - tables, if it weighs Hamming weights (see ``coset_routes``). The last two fields configure - those tables, which the search of Pauli errors by CSS weight does not have. The search builds - the families, tails, and tables after the cache stores it. - """ - - disjoint_random_column_orders: int - max_css_single_pairs: int - overlapping_minimum_work: int - max_information_sets: int - overlapping_attempts_per_set: int - completion_bound_max_table_bytes: int - coset_completion_block_bits: int - - @classmethod - def from_tuning(cls, tuning: SearchTuning, *, paulis_by_css_weight: bool) -> _CosetTuning: - """The fields of a tuning that the setup of a search reads. - - ``paulis_by_css_weight`` is True for a search of Pauli errors by CSS weight. Only that - search reads ``max_css_single_pairs``, and it alone has no completion tables. The fields - that a search does not read are zero, so that oracles built under tunings that differ only - in those fields share cached searches. - """ - fields = (getattr(tuning, name) for name in cls._fields) - coset_tuning = cls(*fields) - if paulis_by_css_weight: - return coset_tuning._replace( - completion_bound_max_table_bytes=0, coset_completion_block_bits=0 - ) - return coset_tuning._replace(max_css_single_pairs=0) - - -class _ConnectedSupportCosetSearch: - """A coset search of a ``CosetLeaderSearch`` by the connected-support search of its checks. - - The words, checks, and syndromes are those of a ``_CosetSearch``: packed Paulis, whose syndrome - for the stabilizers is that of the symplectic search of ``connected_support.SupportSearch``, or - the X or Z parts of Paulis, whose syndrome is that of a binary search of the checks. The search - starts from the syndrome of a query, and grows supports through its unsatisfied checks (Section - 3.8 of ``connected_support.md``). - - Like a ``_CosetSearch``, it first looks up a word of weight zero or one by its syndrome: a - single variable, or a single-qubit Pauli. It searches only cosets with no such word, so a coset - with such a word gets the weight of that word as a lower bound. Any other coset gets two if it - has a word within ``max_weight``. With increasing weight limits from two (see - ``get_min_weight_bound``), it instead gets the weight of its lightest words. Without a word - within ``max_weight``, it gets the larger of two and ``max_weight + 1``. These bounds are often - weaker than those of a ``_CosetSearch``. - - The search is fast only if its checks are sparse, but the given checks, such as row-reduced - stabilizers, can be dense. So it searches the sparser checks of ``_get_sparser_rows``, which - span the same space, and maps the syndrome of a query to them. - - Args: - matrix: The checks, whose kernel is the code: the stabilizers, in ``[X|Z]`` order, for - Paulis, or the rows that check the X or Z parts. Bit ``j`` of the syndrome of a word - is its symplectic product with row ``j`` for Paulis, and its dot product for parts. - homogeneous: False for Paulis, and True for X or Z parts. - max_rounds: The ``max_sparsifying_rounds`` of the tuning (see ``_get_sparser_rows``). - rounds_without_improvement: The ``sparsifying_rounds_without_improvement`` of the tuning. - """ - - def __init__( - self, - matrix: npt.NDArray[np.uint8], - *, - homogeneous: bool, - max_rounds: int, - rounds_without_improvement: int, - ) -> None: - # Map each syndrome of a word of weight zero or one to such a word. The syndrome of a word - # with one nonzero bit is a column of the checks, once the halves of symplectic checks are - # swapped. - num_variables = matrix.shape[1] if homogeneous else matrix.shape[1] // 2 - syndrome_matrix = matrix if homogeneous else np.roll(matrix, num_variables, axis=1) - columns = _row_ints(np.ascontiguousarray(syndrome_matrix.T)) - self._singles, _, _ = _tabulate_singles(columns, num_variables, is_pauli=not homogeneous) - - rows, transform = _get_sparser_rows( - matrix, - homogeneous=homogeneous, - max_rounds=max_rounds, - rounds_without_improvement=rounds_without_improvement, - ) - self._num_checks = len(rows) - # Row ``i`` of the sparse rows sums the given rows that row ``i`` of the transform selects, - # so bit ``i`` of a syndrome for them is the parity of those bits of the given syndrome. - # Entry ``j`` of the map is column ``j`` of the transform, which a given syndrome bit adds. - self._syndrome_map_columns = _row_ints(np.ascontiguousarray(transform.T)) - # Without a function that finds automorphisms, the search never asks for them. - self._search = connected_support.SupportSearch( - rows, - np.zeros((0, matrix.shape[1]), dtype=np.uint8), - homogeneous=homogeneous, - automorphism_minimum_states=_tuning.MAX_VALUE, - ) - # Row ``j`` of the inverse is a word whose syndrome has only bit ``j`` set, so the words of - # the set bits of a syndrome sum to a representative of its coset. - syndrome_rows = rows if homogeneous else np.roll(rows, num_variables, axis=1) - self._inverse_rows = _row_ints(_get_inverse(syndrome_rows)) - - def get_min_weight_bound( - self, - syndrome: int, - max_weight: int, - *, - first_limit: int | None = None, - ) -> MinWeightBound: - """Decide whether the coset of this syndrome has a word of weight at most ``max_weight``. - - A negative ``max_weight`` admits no word, as for a ``_CosetSearch``. - - A search with weight limit ``max_weight`` can visit many heavy supports before it finds a - word that is much lighter. Given ``first_limit``, the search instead tries every limit from - ``first_limit`` to ``max_weight``, and stops at the first limit within which it finds a - word. It tries no limit below two, which the lookup of words of weight at most one makes - useless, and lowers a first limit above ``max_weight`` to it. From the first limit two, it - finds a lightest word. This can save time on cosets with words much lighter than - ``max_weight``, and repeats the work of the lower limits on cosets without a word within - ``max_weight``. A first limit above two suits callers to which any word within it gives the - same answer. A word that the search finds at that limit can be lighter than the limit, so - its coset gets the lower bound two. - """ - # Look up a word of weight zero or one. - word = self._singles.get(syndrome) - if word is not None: - weight = int(word != 0) - return MinWeightBound(weight <= max_weight, word, weight) - - # Map the syndrome to the sparse checks. The map is invertible, and the lookup above - # answers the zero syndrome, so the mapped syndrome is not zero. The sparse checks are - # independent, so the search keeps all of them, in order, and the mapped syndrome has one - # bit for each check that it keeps. So the search can start from the packed syndrome, - # without the validation of ``find_vector_with_syndrome``, which would cost more than the - # search itself in many queries. - syndrome = _xor_selected(self._syndrome_map_columns, syndrome) - packed_syndrome = _int_to_words(syndrome, (self._num_checks + 63) // 64) - - # The lookup leaves only cosets without a word below weight two, so no limit below two is - # searched, and a single query searches only at ``max_weight``. Each limit within which the - # search finds no word raises the lower bound above that limit. - lower_bound = 2 - first = max_weight if first_limit is None else min(first_limit, max_weight) - for limit in range(max(2, first), max_weight + 1): - symbols = self._search._find_vector_with_packed_syndrome(packed_syndrome, limit) - if symbols is not None: - return MinWeightBound(True, self._get_word(symbols), lower_bound) - lower_bound = limit + 1 - representative = _xor_selected(self._inverse_rows, syndrome) - return MinWeightBound(False, representative, lower_bound) - - def _get_word(self, symbols: npt.NDArray[np.int64]) -> int: - """Pack the symbols of a support into a word. - - A symbol gives the qubit ``symbol % n`` the Pauli X, Z, or Y for values 0, 1, and 2. The - symbols of an X or Z part have only the value 0. - """ - num_variables = self._search.num_variables - word = 0 - for symbol in symbols.tolist(): - value, variable = divmod(symbol, num_variables) - if value != 1: - word |= 1 << variable - if value: - word |= 1 << (num_variables + variable) - return word - - -@functools.lru_cache(maxsize=_tuning.PAULI_SEARCH_CACHE_SIZE) -def _get_pauli_search(key: _StabilizerKey, css_weight: bool, tuning: _CosetTuning) -> _CosetSearch: - """The Pauli search, of Pauli errors modulo the stabilizers, with syndromes for the stabilizers. - - ``key`` holds the stabilizers and their checks (see ``CosetLeaderSearch``). The syndromes - depend on the rows of the stabilizer matrix, not only on the group that they generate, so the - rows key the cache. ``css_weight`` and the fields of the tuning that the setup reads key it - too. - """ - stabilizers, checks = key.stabilizers, key.checks - num_qubits = len(stabilizers) - inverse = _get_inverse(checks) - embedded_stabilizers = stabilizers if css_weight else symplectic_to_hamming(stabilizers) - embedded_inverse = inverse if css_weight else symplectic_to_hamming(inverse) - # An error weighs at least half of the Hamming weight of its embedded word, by either measure of - # weight, so the certificate bounds are halved. A search by CSS weight never weighs words by - # tails, since that weight needs to know which deleted pivot columns are in the X part. - routes = _get_routes( - embedded_stabilizers, - embedded_inverse, - num_qubits, - weight_factor=2, - tuning=tuning, - by_tails=not css_weight, - ) - check_columns = _row_ints(np.ascontiguousarray(checks.T)) - return _CosetSearch( - routes, - css_weight, - num_qubits, - _row_ints(inverse), - check_columns, - is_pauli=True, - max_css_single_pairs=tuning.max_css_single_pairs, - ) - - -@functools.lru_cache(maxsize=_tuning.CSS_SEARCH_CACHE_SIZE) -def _get_css_searches( - num_qubits: int, num_x_rows: int, x_rows: bytes, z_rows: bytes, tuning: _CosetTuning -) -> tuple[_CosetSearch, _CosetSearch]: - """The classical searches of the X parts and of the Z parts of the errors on a CSS state. - - ``x_rows`` holds the X parts of ``num_x_rows`` pure-X stabilizers, and ``z_rows`` the Z parts of - the other ``num_qubits - num_x_rows`` stabilizers, which are pure-Z. Both are in reduced row - echelon form, which depends only on the stabilizer group, and packed by ``pack_bits`` into - bytes, which ``lru_cache`` can hash. The X parts of errors are searched modulo the code of the - X rows, with the Z rows as checks, and the Z parts the other way around (see - ``CosetLeaderSearch``). The fields of the tuning that the setup reads key the cache too. - """ - num_words = max(1, (num_qubits + 63) // 64) - x_matrix = unpack_bits( - np.frombuffer(x_rows, dtype=np.uint64).reshape(num_x_rows, num_words), num_qubits - ) - z_matrix = unpack_bits( - np.frombuffer(z_rows, dtype=np.uint64).reshape(num_qubits - num_x_rows, num_words), - num_qubits, - ) - return ( - _get_classical_search(x_matrix, z_matrix, tuning), - _get_classical_search(z_matrix, x_matrix, tuning), - ) - - -def _get_classical_search( - generators: npt.NDArray[np.uint8], checks: npt.NDArray[np.uint8], tuning: _CosetTuning -) -> _CosetSearch: - """The classical search of X or Z parts modulo the row space of independent generators. - - The row space of the generators is the null space of the independent checks, so each coset is - labeled by its syndrome for the checks. Words are weighed by their Hamming weight, which the BZ - certificate bounds directly, so its bounds are not halved. - """ - num_qubits = generators.shape[1] - inverse = _get_inverse(checks) - # Every column counts as the X part, so the CSS weight of a word is its Hamming weight. - routes = _get_routes( - generators, inverse, num_qubits, weight_factor=1, tuning=tuning, by_tails=True - ) - check_columns = _row_ints(np.ascontiguousarray(checks.T)) - return _CosetSearch(routes, True, num_qubits, _row_ints(inverse), check_columns, is_pauli=False) - - -def _get_routes( - generators: npt.NDArray[np.uint8], - inverse: npt.NDArray[np.uint8], - num_x_columns: int, - *, - weight_factor: int, - tuning: _CosetTuning, - by_tails: bool, -) -> coset_routes.CosetRoutes[_CosetSearchTables, _CosetTails]: - """The routes of a coset search of the code of independent embedded generators. - - The routes start from the coset instance of the disjoint information sets of the generators, - with the weight factor ``weight_factor`` (see ``affine_instance.get_coset_instance``). The - tables of every family of the routes come from ``_get_search_tables``, with ``inverse`` and - ``num_x_columns``. If ``by_tails`` is True, the routes whose queries are predicted to examine - at least ``tuning.overlapping_minimum_work`` words with their own family are routes by tails, - with records of tails and tables from ``_get_coset_tails``, and otherwise no route is. - """ - instance = affine_instance.get_coset_instance( - generators, - random_column_orders=tuning.disjoint_random_column_orders, - weight_factor=weight_factor, - ) - return coset_routes.CosetRoutes( - generators, - instance, - functools.partial(_get_search_tables, inverse=inverse, num_x_columns=num_x_columns), - functools.partial( - _get_coset_tails, - inverse=inverse, - block_bits=tuning.coset_completion_block_bits, - max_table_bytes=tuning.completion_bound_max_table_bytes, - ) - if by_tails - else None, - minimum_work=tuning.overlapping_minimum_work, - max_sets=tuning.max_information_sets, - attempts_per_set=tuning.overlapping_attempts_per_set, - ) - - -def _get_inverse(checks: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint8]: - """A right inverse of independent checks: row ``j`` has a syndrome of only bit ``j``.""" - _, labels, pivots = row_reduce_binary(checks, np.eye(len(checks), dtype=np.uint8)) - assert labels is not None - inverse = np.zeros(checks.shape, dtype=np.uint8) - inverse[:, pivots] = labels.T - return inverse - - -def _get_sparser_rows( - matrix: npt.NDArray[np.uint8], - *, - homogeneous: bool, - max_rounds: int, - rounds_without_improvement: int, -) -> tuple[npt.NDArray[np.uint8], npt.NDArray[np.uint8]]: - """Rows with the span of independent rows, made sparser by row reductions in random orders. - - The rows start as those of ``_sparsify``, and are weighed as it weighs them. Each round - row-reduces the given rows with pivots in a random order of the columns, which gives the words - of the span that have exactly one pivot column in their support. Those that are lighter than - the heaviest current row join the rows as candidates, and the greedy algorithm for matroids - keeps the lightest independent candidates: it takes them by increasing weight, and keeps each - that is independent of those that it kept. So the rows are always a lightest basis drawn from - the candidates, and their total weight never rises. The rounds stop after ``max_rounds``, or - once ``rounds_without_improvement`` rounds in a row have left the total weight unchanged, and a - last pass of ``_sparsify`` adds rows to each other where that helps. The rows need not be the - sparsest. Rows with the same span give a search the same answers and bounds, though perhaps - other representatives. So the rounds may be random, and a fixed seed makes them reproducible. - - Args: - matrix: Independent rows. - homogeneous: False if the rows are symplectic, and True otherwise. - max_rounds: The most rounds, or zero for the rows of ``_sparsify``. - rounds_without_improvement: The number of rounds in a row without a lower total weight after - which the rounds stop. - - Returns: - The rows, and the invertible transform ``T``, so that the rows are ``T @ matrix % 2``. The - total weight of the rows is at most that of the rows of ``_sparsify``. - """ - rows, transform = _sparsify(matrix, homogeneous=homogeneous) - if not len(rows): - return rows, transform - - # Each round adds the rows of a reduction that are lighter than the heaviest current row. The - # rows come first among candidates of equal weight, so a round that does not lower the total - # weight leaves the rows as they are. Row-reducing the transposed candidates, sorted by weight, - # takes as pivots the first candidates that are independent of those before them. - rng = np.random.default_rng(_SPARSIFYING_SEED) - weights = _get_row_weights(rows, homogeneous=homogeneous) - rounds_unchanged = 0 - for _ in range(max_rounds): - reduced, _, _ = row_reduce_binary(matrix, columns=rng.permutation(matrix.shape[1])) - reduced_weights = _get_row_weights(reduced, homogeneous=homogeneous) - light = reduced_weights < weights.max() - previous_total = weights.sum() - if np.any(light): - candidates = np.vstack([rows, reduced[light]]) - candidate_weights = np.concatenate([weights, reduced_weights[light]]) - order = np.argsort(candidate_weights, kind="stable") - _, _, pivots = row_reduce_binary(np.ascontiguousarray(candidates[order].T)) - rows, weights = candidates[order[pivots]], candidate_weights[order[pivots]] - rounds_unchanged = rounds_unchanged + 1 if weights.sum() == previous_total else 0 - if rounds_unchanged >= rounds_without_improvement: - break - - # Add rows to each other where that helps, and find the transform from the given rows. The - # rows are in the span of ``matrix``, whose product with the transposed inverse is the identity, - # so their product with it is the transform. Products of binary matrices in float32 are exact - # while their inner dimension is below ``2**24``, and faster than in integers. - rows, _ = _sparsify(rows, homogeneous=homogeneous) - inverse = _get_inverse(matrix) - product = rows.astype(np.float32) @ inverse.T.astype(np.float32) - return rows, (product % 2).astype(np.uint8) - - -def _sparsify( - matrix: npt.NDArray[np.uint8], *, homogeneous: bool -) -> tuple[npt.NDArray[np.uint8], npt.NDArray[np.uint8]]: - """Rows with the span of the given rows, made sparser by adding rows to each other. - - The weight of a row is its number of nonzero entries, or, if the rows are symplectic, the - number of qubits on which its X part ``[:n]`` or its Z part ``[n:]`` is nonzero. While adding - another row to a row lowers its weight, the row takes the sum that is lightest. Every addition - lowers the total weight, so this ends. - - Args: - matrix: The rows. - homogeneous: False if the rows are symplectic, and True otherwise. - - Returns: - The rows, and the invertible transform ``T``, so that the rows are ``T @ matrix % 2``. - """ - # Pack the X and Z parts, or the rows and zeros, so that a weight counts the nonzero qubits. - num_rows, width = matrix.shape - num_variables = width if homogeneous else width // 2 - part_x = pack_bits(np.ascontiguousarray(matrix[:, :num_variables])) - part_z = ( - np.zeros_like(part_x) - if homogeneous - else pack_bits(np.ascontiguousarray(matrix[:, num_variables:])) - ) - transform = np.eye(num_rows, dtype=np.uint8) - weights = np.bitwise_count(part_x | part_z).sum(axis=1, dtype=np.int64) - - # Replace each row by its lightest sum with another row, while that lowers its weight. A row - # added to itself would vanish, so its own entry keeps its weight, which never wins. - improved = True - while improved: - improved = False - for row in range(num_rows): - sums_x, sums_z = part_x ^ part_x[row], part_z ^ part_z[row] - sum_weights = np.bitwise_count(sums_x | sums_z).sum(axis=1, dtype=np.int64) - sum_weights[row] = weights[row] - other = int(np.argmin(sum_weights)) - if sum_weights[other] < weights[row]: - part_x[row], part_z[row] = sums_x[other], sums_z[other] - transform[row] ^= transform[other] - weights[row] = sum_weights[other] - improved = True - - parts = [unpack_bits(part_x, num_variables)] - if not homogeneous: - parts.append(unpack_bits(part_z, num_variables)) - return np.hstack(parts), transform - - -def _get_row_weights(rows: npt.NDArray[np.uint8], *, homogeneous: bool) -> npt.NDArray[np.int64]: - """The weights of rows, as ``_sparsify`` counts them: of nonzero entries, or of qubits.""" - if homogeneous: - return rows.sum(axis=1, dtype=np.int64) - half = rows.shape[1] // 2 - return (rows[:, :half] | rows[:, half:]).sum(axis=1, dtype=np.int64) - - -def _get_search_tables( - instance: affine_instance.AffineInstance, inverse: npt.NDArray[np.uint8], num_x_columns: int -) -> _CosetSearchTables: - """Build the tables of ``_search_coset`` for the cosets of the code of a coset instance. - - ``inverse`` holds embedded words whose syndromes have one bit set, and the first - ``num_x_columns`` columns count as the X part of an embedded word. - - The search rounds its bounds to the parity of each coset if every word of the code has even - Hamming weight and some coset is odd. Then the words of each coset share a parity (see - ``get_coset_instance``). The word of a syndrome sums rows of ``inverse``, so some coset is odd - exactly when some row is. If the code and every row of ``inverse`` are even, every coset is - even. This happens only in the search of Pauli errors by physical weight, whose bounds are - halved, so that rounding them up to even would change none. - """ - width = instance.generators.shape[2] - x_mask = _int_to_words((1 << num_x_columns) - 1, width) - - # Round to the parity of each coset if the code is even and some coset is odd. The rows of any - # one information set span the code. - even_code = not np.any(np.bitwise_count(instance.generators[0]).sum(axis=1) % 2) - has_odd_coset = bool(np.any(np.count_nonzero(inverse, axis=1) % 2)) - parities = (0, 1) if even_code and has_odd_coset else (None,) - return _CosetSearchTables( - pack_bits(inverse), - instance.generators, - instance.pivots, - _get_planning([instance.get_weight_bounds(parity) for parity in parities]), - x_mask, - ) - - -def _get_coset_tails( - instance: affine_instance.AffineInstance, - sets: list[int], - record: _CosetTails | None, - *, - inverse: npt.NDArray[np.uint8], - block_bits: int, - max_table_bytes: int, -) -> _CosetTails: - """Build the record of the tails of the family of a coset instance, with tables of some sets. - - ``inverse`` holds embedded words whose syndromes have one bit set, with every column of the - embedded words. The tails of each set keep every column but its pivots, and are zero-padded to - the width of the set of least rank, which keeps the most columns. A new record has tables on - blocks of ``block_bits`` columns. Given the ``record`` of the family, the function keeps its - tails, tables, and blocks rather than build them again. The record that it returns also has the - completion tables of each set of ``sets``, unless they would exceed ``max_table_bytes`` bytes or - be empty (see ``completion_bounds.build_tables_within``). It holds the tables of each set once, - and is ``record`` itself if it adds no tables. - """ - # Build the tails of the family once, with a layout of no tables. - if record is None: - num_sets, num_rows, width = instance.generators.shape - num_columns = inverse.shape[1] - rows = unpack_bits(instance.generators.reshape(-1, width), num_columns) - rows = rows.reshape(num_sets, num_rows, num_columns) - tail_width = (num_columns - int(instance.ranks.min()) + 63) // 64 - tails = np.zeros((num_sets, num_rows, tail_width), dtype=np.uint64) - inverse_tails = np.zeros((num_sets, len(inverse), tail_width), dtype=np.uint64) - for set_index, (pivots, rank) in enumerate( - zip(instance.pivots, instance.ranks, strict=True) - ): - columns = np.delete(np.arange(num_columns), pivots[:rank]) - set_width = (len(columns) + 63) // 64 - tails[set_index, :, :set_width] = pack_bits(rows[set_index][:, columns]) - inverse_tails[set_index, :, :set_width] = pack_bits(inverse[:, columns]) - # The tables of a set have at most one block for each block_bits columns of its tails, and - # block_bits divides the 64 bits of a word. - max_blocks = 64 * tail_width // block_bits - layout = np.zeros((num_sets, max_blocks + 3), dtype=np.int64) - record = _CosetTails(tails, inverse_tails, np.zeros(0, np.uint8), layout, block_bits) - - # Append the tables of each set that has none yet, if they fit, and lay them out. The routes - # keep only the latest record of a family, so the earlier tables are held once. - layout = record.layout.copy() - added = [] - start = len(record.completion_tables) - for set_index in sets: - if layout[set_index, 1]: - continue - tables = completion_bounds.build_tables_within( - record.tails[set_index], record.block_bits, max_table_bytes - ) - if tables is not None: - num_blocks = len(tables.offsets) - 1 - layout[set_index, : num_blocks + 3] = [start, num_blocks, *tables.offsets] - added.append(tables.tables) - start += len(tables.tables) - if not added: - return record - return record._replace( - completion_tables=np.concatenate([record.completion_tables, *added]), layout=layout - ) - - -def _get_planning(bound_tables: Sequence[npt.NDArray[np.int64]]) -> _PlanningBuffer: - """Build the planning buffer of a coset search from tables of the bounds of its steps. - - The compiled search takes the bounds and its cached stopping steps in this one array, since each - argument of a compiled call adds to the time of every query. The buffer stacks one table for - each given table of bounds. A search that rounds its bounds to parity (see - ``_get_search_tables``) gives two tables: table ``p`` holds the bounds of the cosets whose words - have Hamming weights of parity ``p`` (see ``_get_planning_table``). Other searches give one - table, for every coset. Each table of the buffer has one more row than the bounds: - - - Every row but the last holds the bounds of ``AffineInstance.get_weight_bounds``: entry - ``[w, j]`` bounds the weight of every word that the search has not visited once the first - ``j`` sets have completed layer ``w`` and the others layer ``w - 1``. So column zero of each - row holds the bound before the first step of that row. These rows never change, and code - reads them as the view ``table[:-1]``. - - The last row holds no bounds. Its first two entries are the layer and the number of completed - sets of the stopping step that ``_get_stopping_step`` returned last for this table, or of the - first step, ``(0, 1)``, before its first call, and its other entries are zero. Only - ``_get_stopping_step`` reads and writes this row. - """ - planning = np.zeros( - (len(bound_tables), len(bound_tables[0]) + 1, bound_tables[0].shape[1]), dtype=np.int64 - ) - planning[:, :-1] = bound_tables - planning[:, -1, 1] = 1 - return planning - - def _get_css_combinations( stabilizers: npt.NDArray[np.uint8], ) -> tuple[npt.NDArray[np.uint8], npt.NDArray[np.uint8]] | None: @@ -1363,827 +469,8 @@ def _get_css_combinations( return x_combinations, z_combinations -def _row_ints(matrix: npt.NDArray[np.uint8]) -> tuple[int, ...]: - """Pack each row of a binary matrix into an integer, with entry ``j`` in bit ``j``.""" - return tuple(map(_packed_row_int, pack_bits(matrix))) - - -def _packed_row_int(row: npt.NDArray[np.uint64]) -> int: - """Convert a row of 64-bit words into an integer, with word ``j`` in bits ``64 j`` and up.""" - return sum(int(word) << (64 * jj) for jj, word in enumerate(row)) - - -def _int_to_words(value: int, width: int) -> npt.NDArray[np.uint64]: - """Split an integer into ``width`` 64-bit words, with word ``j`` in bits ``64 j`` and up.""" - return np.frombuffer(value.to_bytes(8 * width, "little"), dtype=" tuple[dict[int, int], dict[int, int], dict[int, int]]: - """Tabulate the words of weight zero or one by syndrome, and the single-qubit X and Z errors. - - The words are the identity and, for packed Paulis, X, Z, and Y on each qubit, and for X or Z - parts, the single bits. The syndrome of Y on a qubit is the sum of those of X and Z there. - Each table maps each syndrome of its words to one of them. - - Args: - check_columns: Column ``c`` of the checks as an integer: the syndrome of the word with only - bit ``c`` set. - num_qubits: The number of qubits ``n`` of packed Paulis, which have ``2 n`` bits; unused for - X or Z parts. - is_pauli: Whether the words are packed Paulis, rather than X or Z parts. - - Returns: - The table of these words, and the tables of the identity and the single-qubit X errors, and - of the identity and the single-qubit Z errors. For X or Z parts, the last two hold only the - identity. - """ - singles, x_singles, z_singles = {0: 0}, {0: 0}, {0: 0} - if not is_pauli: - for column, syndrome in enumerate(check_columns): - singles.setdefault(syndrome, 1 << column) - return singles, x_singles, z_singles - for qubit in range(num_qubits): - x, z = 1 << qubit, 1 << (qubit + num_qubits) - sx, sz = check_columns[qubit], check_columns[qubit + num_qubits] - x_singles.setdefault(sx, x) - z_singles.setdefault(sz, z) - singles.setdefault(sx, x) - singles.setdefault(sz, z) - singles.setdefault(sx ^ sz, x ^ z) - return singles, x_singles, z_singles - - -def _tabulate_css_singles( - x_singles: dict[int, int], z_singles: dict[int, int], max_pairs: int -) -> dict[int, int] | None: - """Tabulate the products of single-qubit X and Z errors by syndrome, if not too many. - - Args: - x_singles: Single-qubit X errors, and the identity, by syndrome. - z_singles: Single-qubit Z errors, and the identity, by syndrome. - max_pairs: The most pairs of an X and a Z error that the table may come from. - - Returns: - None if there are more than ``max_pairs`` pairs, and otherwise the first product - ``x ^ z`` of each syndrome ``sx ^ sz``, in the order of the two dictionaries. - """ - if len(x_singles) * len(z_singles) > max_pairs: - return None - table: dict[int, int] = {} - for sx, x in x_singles.items(): - for sz, z in z_singles.items(): - table.setdefault(sx ^ sz, x ^ z) - return table - - -def _xor_selected(rows: Sequence[int], support: int) -> int: - """XOR together the rows whose indices are the set bits of ``support``.""" - word = 0 - while support: - # Add the row for the lowest set bit, and then clear that bit. - low = support & -support - word ^= rows[low.bit_length() - 1] - support ^= low - return word - - #################################################################################################### -# compiled coset search - - -@always_compiled -def _find_first_not_within_max_weight( - syndromes: npt.NDArray[np.uint64], - max_weight: int, - best: npt.NDArray[np.uint64], - inverse: npt.NDArray[np.uint64], - generators: npt.NDArray[np.uint64], - pivots: npt.NDArray[np.int64], - planning: _PlanningBuffer, - x_mask: npt.NDArray[np.uint64], - tails: _TailFields | None, - scale: int, -) -> tuple[int, int]: - """Search the coset of each syndrome in turn with ``_search_coset``. - - The tables, from ``inverse`` to ``x_mask``, and ``tails`` are those of ``_search_coset``. - Return the index of the first syndrome whose coset has no word of weight at most - ``max_weight``, and the lower bound that ``_search_coset`` returns for it, with the lightest - word that it weighed left in ``best``. If there is no such syndrome, return the number of - syndromes and zero. - """ - for index in range(len(syndromes)): - lower_bound = _search_coset( - syndromes[index], - max_weight, - best, - inverse, - generators, - pivots, - planning, - x_mask, - tails, - scale, - ) - if lower_bound > max_weight: - return index, lower_bound - return len(syndromes), 0 - - -@always_compiled(inline=True) -def _search_coset( - packed_syndrome: npt.NDArray[np.uint64], - max_weight: int, - best: npt.NDArray[np.uint64], - inverse: npt.NDArray[np.uint64], - generators: npt.NDArray[np.uint64], - pivots: npt.NDArray[np.int64], - planning: _PlanningBuffer, - x_mask: npt.NDArray[np.uint64], - tails: _TailFields | None, - scale: int, -) -> int: - """Search the coset of a syndrome for a word of weight at most ``max_weight``. - - ``packed_syndrome`` is the syndrome split into 64-bit machine words, with machine word ``j`` - holding bits ``64 j`` and up. The search runs on embedded words. In the Pauli search, the - words are Pauli errors, and the embedded word of an error is its packed Pauli when errors are - weighed by CSS weight, and its packed Pauli followed by ``x xor z`` when they are weighed by - physical weight, so that its Hamming weight is twice the physical weight (see - ``symplectic_to_hamming``). In a classical search, the words are X or Z parts, each is its own - embedded word, and its weight is its Hamming weight. The weight of an embedded word is that of - its word: its CSS weight if ``scale`` is zero, with its X part selected by ``x_mask``, and - otherwise its Hamming weight divided by ``scale``, which is two for the physical weight and one - for X or Z parts. The tables are those of ``_CosetSearchTables``. Row ``j`` of ``inverse`` is - the embedded word of a word whose syndrome has only bit ``j`` set. ``generators`` and - ``pivots`` are the information sets of a family of the embedded generators of the code, with - pivots padded with -1. ``planning`` is the planning buffer of the search with this family, - whose cached stopping steps a query may update. ``tails`` is None, or, on a route by tails - (see ``coset_routes``), the fields of the ``_CosetTails`` of the family, and then ``scale`` is - one or two, since the search weighs words by their Hamming weight. Numba compiles the search - separately for None and for tails, and leaves out the branches that the type of ``tails`` rules - out, so choosing between them costs nothing for each word. - - The embedded words of the coset form a coset of the embedded code. The search starts from the - sum of the rows of ``inverse`` for the set bits of the syndrome, a word with that syndrome. The - offset of an information set is this word reduced by rows of the set until it is zero on the - set's pivot columns. Every embedded word of the coset is the offset plus a unique sum of rows - of the set, and has a one on a pivot column exactly where it adds that pivot's row, so the BZ - certificate applies to the coset as it does to a space. Layer ``w`` of a set is its offset plus - each sum of ``w`` of its rows, so layer zero is the offset itself. - - The search reads the bounds of its steps from the table of the planning buffer for the parity of - the Hamming weight of the word of the syndrome (see ``_get_planning_table``). - - The search visits layer zero of the information sets in order, then layer one, and so on. It - keeps the lightest word visited in ``best``, and stops at the first word of weight at most - ``max_weight``. Once the first ``j`` sets have completed layer ``w``, every unvisited word - weighs at least the bound of step ``(w, j)`` in its table. So the search also stops at its - stopping step, the first step whose bound exceeds ``max_weight``, which ``_get_stopping_step`` - finds. It skips each set that adds nothing to the bound of that step (see - ``_search_from_syndrome_word``). A search by tails visits the same words in the same order, - except those that the tables of ``tails`` prove to lie beyond ``max_weight``, which it skips. - So it finds the same first word of weight at most ``max_weight``, if any. - - Returns: - A lower bound on the weight of every word in the coset: zero, which is at most - ``max_weight``, if ``best`` has weight at most ``max_weight``, and otherwise a bound that - exceeds ``max_weight`` (see ``_search_from_syndrome_word``). - """ - best_weight, parity = _get_syndrome_word(packed_syndrome, best, inverse, x_mask, scale) - if best_weight <= max_weight: - return 0 - table = _get_planning_table(planning, parity) - stop_layer, stop_num_completed, stop_bound = _get_stopping_step(table, max_weight) - # The search rounds to the parity of the coset if it weighs Hamming weights and its planning - # buffer has a table for each parity (see ``_get_planning_table``). - lower_bound, _, _ = _search_from_syndrome_word( - packed_syndrome, - max_weight, - stop_layer, - stop_num_completed, - stop_bound, - best, - best_weight, - generators, - pivots, - x_mask, - tails, - scale, - parity if scale != 0 and len(planning) == 2 else -1, - ) - return lower_bound - - -@always_compiled -def _get_planning_table(planning: _PlanningBuffer, parity: int) -> npt.NDArray[np.int64]: - """The table of a planning buffer for a coset whose Hamming weights have parity ``parity``. - - The Hamming weights are those of the embedded words of the coset. A buffer with a table for - each parity belongs to an even code, whose cosets each have Hamming weights of one parity, and - gives the table of the parity (see ``_get_planning``). A buffer with one table gives it for - every coset. - """ - return planning[parity] if len(planning) == 2 else planning[0] - - -@always_compiled -def _get_stopping_step(table: npt.NDArray[np.int64], max_weight: int) -> tuple[int, int, int]: - """Find the step at which a coset query stops, and cache it in a table of the planning buffer. - - The stopping step is the first step whose bound in the table exceeds the maximum weight: the - step at which ``stop_num_completed`` sets complete layer ``stop_layer``. A query stops there, - unless it finds a light enough word sooner. The bounds of the steps do not decrease in their - order. Column zero of each row of bounds holds the bound before the first step of the row, - which is zero in row zero. So the bound of the step before step ``(w, j)`` is entry - ``[w, j - 1]``, and for a nonnegative maximum weight, step ``(w, j)`` is the stopping step - exactly if its bound exceeds the maximum weight and entry ``[w, j - 1]`` does not. The step - cached in the table (see ``_get_planning``) is reused if it passes this check. Otherwise, - ``information_sets.find_stopping_step`` finds the stopping step by binary search, and it is - cached in its place. A negative maximum weight fails the check, and the binary search returns - the first step. If no bound exceeds the maximum weight, which happens only when the maximum - weight reaches the largest native integer, the binary search returns the last step. - - Oracles that share a cached search share its planning buffer. Compiled calls hold the global - interpreter lock, since numba compiles them without ``nogil``, so calls interleave only when - these functions run as plain Python in several threads, or in free-threaded Python. The layer - and the number of completed sets of a step are written as two entries, so a read may pair the - layer of one step with the number of completed sets of another. Any such pair indexes the - bounds, which never change, and it is checked against them before it is used. - - Returns: - ``stop_layer``, ``stop_num_completed``, and the bound of that step. - """ - bounds = table[:-1] - layer, num_completed = table[-1, 0], table[-1, 1] - is_stopping_step = ( - max_weight < bounds[layer, num_completed] and bounds[layer, num_completed - 1] <= max_weight - ) - if not is_stopping_step: - layer, num_completed = certificates.find_stopping_step( - bounds, max_weight, 0, len(bounds) - 1 - ) - table[-1, 0], table[-1, 1] = layer, num_completed - return layer, num_completed, int(bounds[layer, num_completed]) - - -@always_compiled -def _search_from_syndrome_word( - packed_syndrome: npt.NDArray[np.uint64], - max_weight: int, - stop_layer: int, - stop_num_completed: int, - stop_bound: int, - best: npt.NDArray[np.uint64], - best_weight: int, - generators: npt.NDArray[np.uint64], - pivots: npt.NDArray[np.int64], - x_mask: npt.NDArray[np.uint64], - tails: _TailFields | None, - scale: int, - parity: int, -) -> tuple[int, int, int]: - """Search a coset, as ``_search_coset`` does, from the word of its syndrome. - - ``best`` holds that word, of weight ``best_weight`` above ``max_weight``, and - ``packed_syndrome`` is its syndrome. The stopping step and its bound are those of - ``_get_stopping_step``. ``parity`` is the parity of the Hamming weights of the words of the - coset if the search weighs Hamming weights and rounds its bounds to that parity (see - ``_get_search_tables``), and otherwise -1. A set of rank deficiency ``s`` adds to the bound of - a step only once it has completed layer ``s``, so a set that would not have completed it by the - stopping step adds nothing, and the search skips it. These are the unused sets of - ``information_sets.plan_steps``, whose work for this step, from layer zero, counts the words - that the search weighs after the word of the syndrome if it finds no light enough word. - - A search by tails (see ``_search_coset``) weighs a word by its rows below the rank of its set - and its tail. It keeps the set, the layer, and the support of the lightest word, and writes - that word into ``best`` before it returns. In each layer of two or more rows of a set with - tables, it skips the completions of a prefix whose bound reaches the skip bound: the least bound - that proves a word of the coset heavier than ``max_weight``, one more than the greatest Hamming - weight at most ``scale * max_weight`` that a word of the coset can have (see - ``affine_search.scan_coset_layer_by_tails_with_tables``). So it finds the same first word of - weight at most ``max_weight``, if any. If it finds none and skipped any word, it bounds the - coset by the least weight beyond ``max_weight`` that a word of the coset can have, which bounds - the skipped words, the words that it weighed, and the words beyond the stopping step. - - Returns: - A lower bound on the weight of every word in the coset: zero if the search finds a word of - weight at most ``max_weight``, the least weight beyond ``max_weight`` that a word of the - coset can have if it skipped any word, and otherwise the lesser of the weight of the - lightest word that it weighed and the bound of the stopping step. Also the numbers of words - weighed after the word of the syndrome and of supports skipped. Only tests read the - numbers, to check which sets a search skips and that a search by tails weighs as many words. - """ - # Reduce the word of the syndrome to the offset of each information set, or, by tails, find the - # rank of each set and the tail of its offset. - num_sets, num_rows, width = generators.shape - if tails is None: - offsets = _get_offsets(best, generators, pivots) - else: - tail_rows, inverse_tails, completion_tables, layout, block_bits = tails - ranks, offsets = _get_offset_tails(packed_syndrome, best, pivots, tail_rows, inverse_tails) - width = tail_rows.shape[2] - - # The scans weigh ``factor`` units for each unit of weight: Hamming weights, which are ``scale`` - # times the weights, or CSS weights if ``scale`` is zero. - factor = max(1, scale) - best_units, cutoff = factor * best_weight, factor * max_weight - skip_bound = _get_skip_bound(cutoff, parity) - - # Visit each layer one information set at a time, up to the stopping step. The scans take work - # space for one set. Those by tails keep the support of each lighter word in ``best_support``, - # and the others keep the word itself in ``best``. - best_set, best_layer = -1, 0 - weighed, skipped = 0, 0 - widths = np.full(1, width, dtype=np.int64) - support = np.empty(num_rows, dtype=np.int64) - best_support = np.empty(num_rows, dtype=np.int64) - prefixes = np.empty((num_rows, 1, width), dtype=np.uint64) - for layer in range(stop_layer + 1): - for set_index in range(num_sets if layer < stop_layer else stop_num_completed): - if certificates.is_unused_set(pivots, set_index, stop_layer, stop_num_completed): - continue - set_skipped = 0 - if layer == 0: - # The offset is zero on the pivot columns, so its tail has its Hamming weight. - weight, ones = _get_weight(offsets[set_index], x_mask, scale) - found, count = weight if scale == 0 else ones, 1 - if tails is None and found < best_units: - best[:] = offsets[set_index] - elif tails is None: - if scale == 0: - found, count = affine_search.scan_coset_layer_by_css_weight( - offsets[set_index], - generators[set_index : set_index + 1], - widths, - support[:layer], - prefixes, - best_units, - cutoff, - x_mask, - best, - ) - else: - found, count = affine_search.scan_coset_layer( - offsets[set_index], - generators[set_index : set_index + 1], - widths, - support[:layer], - prefixes, - best_units, - cutoff, - best, - ) - elif layer >= 2 and layout[set_index, 1]: - # The tables of the set start at ``layout[set_index, 0]``, with a row of tables for - # each row of the set but the last. - num_blocks = layout[set_index, 1] - block_offsets = layout[set_index, 2 : num_blocks + 3] - start = layout[set_index, 0] - end = start + (num_rows - 1) * block_offsets[-1] - found, count, set_skipped = affine_search.scan_coset_layer_by_tails_with_tables( - offsets[set_index], - tail_rows[set_index : set_index + 1], - ranks[set_index : set_index + 1], - widths, - support[:layer], - prefixes, - best_units, - cutoff, - skip_bound, - completion_tables[start:end], - block_offsets, - block_bits, - best_support, - ) - else: - found, count = affine_search.scan_coset_layer_by_tails( - offsets[set_index], - tail_rows[set_index : set_index + 1], - ranks[set_index : set_index + 1], - widths, - support[:layer], - prefixes, - best_units, - cutoff, - best_support, - ) - weighed += count - skipped += set_skipped - if found < best_units: - best_units, best_set, best_layer = found, set_index, layer - if best_units <= cutoff: - break - if best_units <= cutoff: - break - - # By tails, write the lightest word into ``best``, which holds the word of the syndrome until - # then. - if tails is not None and best_set >= 0: - _rebuild_word(best, generators[best_set], pivots[best_set], best_support[:best_layer]) - - # A search that finds no light enough word but skipped words bounds the coset by the least - # weight beyond the maximum weight that a word of the coset can have: the skip bound rounded up - # to the parity of the coset, then divided by the scale and rounded up, as the bounds of the - # steps are. - if best_units <= cutoff: - lower_bound = 0 - elif skipped: - rounded = skip_bound + (skip_bound - parity) % 2 if parity >= 0 else skip_bound - lower_bound = -(-rounded // factor) - else: - lower_bound = min(best_units // factor, stop_bound) - return lower_bound, weighed, skipped - - -@always_compiled -def _find_min_weights( - syndromes: npt.NDArray[np.uint64], - max_weight: int, - floors: npt.NDArray[np.int64], - inverse: npt.NDArray[np.uint64], - generators: npt.NDArray[np.uint64], - pivots: npt.NDArray[np.int64], - planning: _PlanningBuffer, - x_mask: npt.NDArray[np.uint64], - tails: _TailFields | None, - scale: int, -) -> npt.NDArray[np.int64]: - """Search the coset of each syndrome in turn with ``_search_coset_for_min_weight``. - - The arguments are those of ``_find_first_not_within_max_weight``, without ``best``, and with the - floor of each syndrome in ``floors``. Return the weight that ``_search_coset_for_min_weight`` - gives each syndrome. - """ - weights = np.empty(len(syndromes), dtype=np.int64) - best = np.empty(inverse.shape[1], dtype=np.uint64) - for index in range(len(syndromes)): - weight, _, _ = _search_coset_for_min_weight( - syndromes[index], - max_weight, - floors[index], - best, - inverse, - generators, - pivots, - planning, - x_mask, - tails, - scale, - ) - weights[index] = weight - return weights - - -@always_compiled(inline=True) -def _search_coset_for_min_weight( - packed_syndrome: npt.NDArray[np.uint64], - max_weight: int, - floor: int, - best: npt.NDArray[np.uint64], - inverse: npt.NDArray[np.uint64], - generators: npt.NDArray[np.uint64], - pivots: npt.NDArray[np.int64], - planning: _PlanningBuffer, - x_mask: npt.NDArray[np.uint64], - tails: _TailFields | None, - scale: int, -) -> tuple[int, int, int]: - """Search the coset of a syndrome for its minimum weight, if that is at most ``max_weight``. - - The arguments are those of ``_search_coset``, with a floor of at most ``max_weight``, and - ``best`` is work space. A word of weight at most ``floor`` ends the search, which saves work - for callers to which any such word gives the same answer. - - The search visits the layers of the information sets in the order of ``_search_coset``. Its - best weight is that of the word of the syndrome, or of the lightest word within ``max_weight`` - that it has found since. It stops at its stopping step, the first step whose bound reaches the - lesser of its best weight and ``max_weight + 1``, since every word that it has not visited then - weighs at least as much. This step moves earlier whenever the best weight falls. The search - skips each set that adds nothing to the bound of its current stopping step, as - ``_search_from_syndrome_word`` does. Such a set adds nothing to the bound of an earlier step - either, so the bound of the step at which the search stops holds. Within a step, every word not - yet visited weighs at least the bound of the step before it. So the search also stops as soon - as its best weight is at most that bound, or at most ``floor``. - - The scans weigh Hamming weights, which are ``scale`` times the weights, or CSS weights if - ``scale`` is zero. They keep only the words lighter than an incumbent: the skip bound of - ``max_weight`` (see ``_get_skip_bound``) until the search finds a word within that weight, and - then the best weight. Where every word of the coset has the parity of the Hamming weight of the - best word, no word is one lighter, so the incumbent is one less and the scans keep the same - words. This holds in a search of Pauli errors by physical weight, whose embedded words have - even Hamming weights, and in a search of X or Z parts that rounds to the parity of each coset. - The lower incumbent lets the tables of a search by tails skip more. - - A search by tails visits the same words in the same order, but weighs a word by its rows below - the rank of its set and its tail, as ``_search_from_syndrome_word`` does. In each layer of two - or more rows of a set with tables, it skips the completions of a prefix whose bound reaches the - best weight of the scan, which starts at its incumbent: the scan takes the skip bound of - ``max_weight``, which is at least the incumbent, as its skip bound (see - ``affine_search.scan_coset_layer_by_tails_with_tables``). Until the search finds a word within - ``max_weight``, the incumbent is that skip bound, at which a query by tails skips too. Then it - is the Hamming weight of the best word, or one less where every word of the coset has its - parity, so the search skips no word lighter than the best word. So the search gives the weight - of a search of whole words. For a coset without a word within ``max_weight``, it weighs and - skips the words that ``_search_from_syndrome_word`` weighs and skips at ``max_weight``. - - Returns: - The best weight when the search stops, or ``max_weight + 1`` if it found no word within - ``max_weight``, and the numbers of words weighed after the word of the syndrome and of - supports skipped. The best weight is the minimum weight of the coset, unless it is at most - ``floor``. Only tests read the numbers. - """ - # Start from the word of the syndrome, which ends the search if it is within the floor. Reduce - # it to the offset of each information set, or, by tails, find the rank of each set and the - # tail of its offset. - best_weight, parity = _get_syndrome_word(packed_syndrome, best, inverse, x_mask, scale) - if best_weight <= floor: - return best_weight, 0, 0 - bounds = _get_planning_table(planning, parity)[:-1] - num_sets, num_rows, width = generators.shape - if tails is None: - offsets = _get_offsets(best, generators, pivots) - else: - tail_rows, inverse_tails, completion_tables, layout, block_bits = tails - ranks, offsets = _get_offset_tails(packed_syndrome, best, pivots, tail_rows, inverse_tails) - width = tail_rows.shape[2] - - # Find the skip bound and the parity slack of the incumbent, in the units of the scans, which - # weigh ``factor`` units for each unit of weight. Only Hamming weights have a parity. Find - # also the upper bound ``U`` of the search, in units of weight (Section 3.10 of - # ``brouwer_zimmermann.md``), and its first stopping step. - factor = max(1, scale) - rounds = scale != 0 and len(planning) == 2 - skip_bound = _get_skip_bound(factor * max_weight, parity if rounds else -1) - slack = 1 if scale == 2 or rounds else 0 - best_units = factor * best_weight - upper_bound = min(best_weight, max_weight + 1) - stop_layer, stop_num_completed = _get_first_step_reaching(bounds, upper_bound) - - # Visit each layer one information set at a time, up to the stopping step. The scans take work - # space for one set. The search reaches a stopping step by the last layer, whose bounds are - # ``NO_WORD_BOUND``, since its upper bound is at most ``max_weight + 1``, which - # ``_CosetSearch.get_min_weights`` keeps at most ``n + 1``. The scans keep each lighter word in - # ``best``, or its support in ``best_support`` by tails, which are only work space, since the - # search returns no word. - weighed, skipped = 0, 0 - widths = np.full(1, width, dtype=np.int64) - support = np.empty(num_rows, dtype=np.int64) - best_support = np.empty(num_rows, dtype=np.int64) - prefixes = np.empty((num_rows, 1, width), dtype=np.uint64) - layer = 0 - while True: - for set_index in range(num_sets): - # Weigh the words of this step that are lighter than the incumbent, unless the set adds - # nothing to the bound of the stopping step, and return once the best weight is at most - # the floor or the bound of the step before this one. A scan returns once its best - # weight, which starts at the incumbent, is at most its cutoff, so the cutoff stays - # below the incumbent. - if not certificates.is_unused_set(pivots, set_index, stop_layer, stop_num_completed): - incumbent = min(best_units - slack, skip_bound) - step_floor = max(floor, bounds[layer, set_index]) - cutoff = min(factor * step_floor, incumbent - 1) - set_skipped = 0 - if layer == 0: - # The offset is zero on the pivot columns, so its tail has its Hamming weight. - weight, ones = _get_weight(offsets[set_index], x_mask, scale) - found, count = weight if scale == 0 else ones, 1 - elif tails is None: - if scale == 0: - found, count = affine_search.scan_coset_layer_by_css_weight( - offsets[set_index], - generators[set_index : set_index + 1], - widths, - support[:layer], - prefixes, - incumbent, - cutoff, - x_mask, - best, - ) - else: - found, count = affine_search.scan_coset_layer( - offsets[set_index], - generators[set_index : set_index + 1], - widths, - support[:layer], - prefixes, - incumbent, - cutoff, - best, - ) - elif layer >= 2 and layout[set_index, 1]: - # The tables of the set start at ``layout[set_index, 0]`` (see - # ``_search_from_syndrome_word``). - num_blocks = layout[set_index, 1] - block_offsets = layout[set_index, 2 : num_blocks + 3] - start = layout[set_index, 0] - end = start + (num_rows - 1) * block_offsets[-1] - found, count, set_skipped = affine_search.scan_coset_layer_by_tails_with_tables( - offsets[set_index], - tail_rows[set_index : set_index + 1], - ranks[set_index : set_index + 1], - widths, - support[:layer], - prefixes, - incumbent, - cutoff, - skip_bound, - completion_tables[start:end], - block_offsets, - block_bits, - best_support, - ) - else: - found, count = affine_search.scan_coset_layer_by_tails( - offsets[set_index], - tail_rows[set_index : set_index + 1], - ranks[set_index : set_index + 1], - widths, - support[:layer], - prefixes, - incumbent, - cutoff, - best_support, - ) - weighed += count - skipped += set_skipped - if found < incumbent: - best_units, best_weight = found, found // factor - if best_weight <= step_floor: - return best_weight, weighed, skipped - - # Stop at the stopping step, and move that step earlier if the best weight fell. - new_upper_bound = min(best_weight, max_weight + 1) - if bounds[layer, set_index + 1] >= new_upper_bound: - return new_upper_bound, weighed, skipped - if new_upper_bound < upper_bound: - upper_bound = new_upper_bound - stop_layer, stop_num_completed = _get_first_step_reaching(bounds, upper_bound) - layer += 1 - - -@always_compiled(inline=True) -def _get_first_step_reaching(bounds: npt.NDArray[np.int64], target: int) -> tuple[int, int]: - """The first step whose bound in a table of bounds of steps reaches ``target``. - - It is the stopping step of ``information_sets.find_stopping_step`` for the limit - ``target - 1``. Unlike ``_get_stopping_step``, this function caches no step, so the cached - steps of the planning buffer serve only the queries of ``_search_coset``. - """ - return certificates.find_stopping_step(bounds, target - 1, 0, len(bounds) - 1) - - -@always_compiled(inline=True) -def _get_offsets( - word: npt.NDArray[np.uint64], generators: npt.NDArray[np.uint64], pivots: npt.NDArray[np.int64] -) -> npt.NDArray[np.uint64]: - """Reduce a word of a coset to the offset of each information set, which is zero on its pivots. - - ``generators`` and ``pivots`` are those of ``_search_coset``. - """ - num_sets, _, width = generators.shape - offsets = np.empty((num_sets, width), dtype=np.uint64) - for set_index in range(num_sets): - offsets[set_index] = word - _reduce_to_offset(offsets[set_index], generators[set_index], pivots[set_index]) - return offsets - - -@always_compiled(inline=True) -def _get_offset_tails( - packed_syndrome: npt.NDArray[np.uint64], - word: npt.NDArray[np.uint64], - pivots: npt.NDArray[np.int64], - tails: npt.NDArray[np.uint64], - inverse_tails: npt.NDArray[np.uint64], -) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.uint64]]: - """Find the rank of each information set and the tail of its offset. - - ``word`` is the word of the syndrome ``packed_syndrome``, ``pivots`` are those of - ``_search_coset``, and ``tails`` and ``inverse_tails`` are those of ``_CosetTails``. The rows - of a set with pivots on which the word has ones reduce the word to the offset, since every row - is zero on the pivots of the other rows. The tail of the word sums the tails of the rows of the - inverse for the set bits of the syndrome. - """ - num_sets, num_rows, tail_width = tails.shape - ranks = np.zeros(num_sets, dtype=np.int64) - offset_tails = np.zeros((num_sets, tail_width), dtype=np.uint64) - for set_index in range(num_sets): - _xor_selected_rows(packed_syndrome, inverse_tails[set_index], offset_tails[set_index]) - rank = 0 - while rank < num_rows and pivots[set_index, rank] >= 0: - pivot = pivots[set_index, rank] - if word[pivot // 64] >> np.uint64(pivot % 64) & np.uint64(1): - for index in range(tail_width): - offset_tails[set_index, index] ^= tails[set_index, rank, index] - rank += 1 - ranks[set_index] = rank - return ranks, offset_tails - - -@always_compiled(inline=True) -def _get_skip_bound(cutoff: int, parity: int) -> int: - """The least Hamming weight that proves a word of a coset heavier than ``cutoff``. - - It is one more than the greatest Hamming weight at most the cutoff that a word of the coset can - have. ``parity`` is the parity of the coset if the search rounds to it, and -1 otherwise. The - bound is ``cutoff + 1``, or ``cutoff`` if the search rounds and the words have the parity of - ``cutoff + 1``. - """ - if parity >= 0 and (cutoff - parity) % 2: - return cutoff - return cutoff + 1 - - -@always_compiled -def _rebuild_word( - word: npt.NDArray[np.uint64], - rows: npt.NDArray[np.uint64], - pivots: npt.NDArray[np.int64], - support: npt.NDArray[np.int64], -) -> None: - """Turn a word of a coset into the offset of an information set plus the rows of a support. - - ``rows`` and ``pivots`` are those of the set, as for ``_reduce_to_offset``. - """ - _reduce_to_offset(word, rows, pivots) - for row in support: - for index in range(len(word)): - word[index] ^= rows[row, index] - - -@always_compiled(inline=True) -def _reduce_to_offset( - word: npt.NDArray[np.uint64], rows: npt.NDArray[np.uint64], pivots: npt.NDArray[np.int64] -) -> None: - """Reduce a word of a coset to the offset of an information set, which is zero on its pivots. - - ``rows`` are the packed rows of the set, and ``pivots`` their pivot columns, padded with -1 - beyond its rank. The word adds the row of each pivot that it has a one on, since every row is - zero on the pivots of the other rows. - """ - for row in range(len(pivots)): - pivot = pivots[row] - if pivot < 0: - break - if word[pivot // 64] >> np.uint64(pivot % 64) & np.uint64(1): - for index in range(len(word)): - word[index] ^= rows[row, index] - - -@always_compiled(inline=True) -def _get_syndrome_word( - packed_syndrome: npt.NDArray[np.uint64], - word: npt.NDArray[np.uint64], - inverse: npt.NDArray[np.uint64], - x_mask: npt.NDArray[np.uint64], - scale: int, -) -> tuple[int, int]: - """Write the sum of the rows of ``inverse`` for the set bits of a packed syndrome into ``word``. - - The sum is an embedded word with that syndrome. Return its weight (see ``_search_coset``), and - the parity of its Hamming weight. - """ - word[:] = 0 - _xor_selected_rows(packed_syndrome, inverse, word) - weight, ones = _get_weight(word, x_mask, scale) - return weight, ones % 2 - - -@always_compiled(inline=True) -def _xor_selected_rows( - packed_bits: npt.NDArray[np.uint64], rows: npt.NDArray[np.uint64], word: npt.NDArray[np.uint64] -) -> None: - """Add to ``word`` row ``b`` of ``rows`` for each set bit ``b`` of ``packed_bits``. - - ``packed_bits`` is split into 64-bit machine words, with machine word ``j`` holding bits - ``64 j`` and up, and each set bit must be below the number of rows; this is not checked. - """ - for machine_word in range(len(packed_bits)): - bits = packed_bits[machine_word] - while bits: - bit = 64 * machine_word + trailing_zeros_word(bits) - for index in range(len(word)): - word[index] ^= rows[bit, index] - bits &= bits - np.uint64(1) - - -@always_compiled -def _get_weight( - word: npt.NDArray[np.uint64], x_mask: npt.NDArray[np.uint64], scale: int -) -> tuple[int, int]: - """The weight of the error with an embedded word, and the Hamming weight of the embedded word. - - See ``_search_coset``. - """ - ones = x_ones = np.int64(0) - for index in range(len(word)): - ones += popcount_word(word[index]) - x_ones += popcount_word(word[index] & x_mask[index]) - return int(max(x_ones, ones - x_ones) if scale == 0 else ones // scale), int(ones) +# compiled syndrome maps @always_compiled diff --git a/src/qldpc/codes/distance/_coset_leader_search_test.py b/src/qldpc/codes/distance/_coset_leader_search_test.py index d00e4e66c..b2c762e75 100644 --- a/src/qldpc/codes/distance/_coset_leader_search_test.py +++ b/src/qldpc/codes/distance/_coset_leader_search_test.py @@ -4,58 +4,36 @@ from __future__ import annotations -import inspect import itertools -from collections.abc import Sequence -from math import comb -from typing import Any import numpy as np import numpy.typing as npt import pytest import stim -from qldpc import math -from qldpc._binary import get_kernel, pack_bits, row_reduce_binary, unpack_bits - from ..conftest import Tune -from . import _coset_leader_search -from ._binary import symplectic_to_hamming -from ._brouwer_zimmermann import affine_instance -from ._coset_leader_search import CosetLeaderSearch, CosetSearchMethod, MinWeightBound -from ._information_sets import basis_families, completion_bounds -from ._information_sets import certificates as certificates_module -from ._tuning import DEFAULT_TUNING, MAX_VALUE, get_tuning, use_tuning +from ._coset_leader_search import CosetLeaderSearch, CosetSearchMethod +from ._cosets import MinWeightBound from .conftest import ( - EVEN_ROUTES_CODE, - OVERLAPPING_ROUTES_CODE, - TWO_FAMILIES_CODE, - get_binary_matrix, - get_random_full_rank_matrix, + _PHASED_GHZ_BLOCK, + _ROUTED_CSS_WEIGHT_STATE, + _ROUTED_PHYSICAL_STATES, + _assert_exact_answers, + _block_generators, + _block_minimum, + _css_minima, + _find_each_not_within_max_weight, + _get_syndrome, + _graph_state, + _pack_syndromes, + _pauli_weight, + _product_state, + _random_css_state, + _scramble, + _stabilizer_matrix, ) -def _stabilizer_matrix(generators: Sequence[stim.PauliString]) -> npt.NDArray[np.uint8]: - """Stabilizer matrix [X|Z] with the given Pauli strings as rows.""" - return np.array([np.concatenate(gg.to_numpy()) for gg in generators], dtype=np.uint8) - - -def _graph_state( - num_qubits: int, edges: Sequence[tuple[int, int]], phase: bool = False -) -> npt.NDArray[np.uint8]: - """Stabilizer matrix [X|Z] of a graph state, with S gates on every qubit if ``phase``. - - Generator j acts as X (or Y) on qubit j, so the X parts of the generators form the identity. - """ - circuit = stim.Circuit() - circuit.append("H", range(num_qubits)) - for edge in edges: - circuit.append("CZ", edge) - if phase: - circuit.append("S", range(num_qubits)) - return _stabilizer_matrix(stim.Tableau.from_circuit(circuit).to_stabilizers()) - - def _class_weights( matrix: npt.NDArray[np.uint8], syndrome: int, css_weight: bool ) -> npt.NDArray[np.int_]: @@ -73,39 +51,10 @@ def _class_weights( return (x | z).sum(axis=1) if not css_weight else np.maximum(x.sum(axis=1), z.sum(axis=1)) -def _pauli_weight(pauli: int, num_qubits: int, css_weight: bool) -> int: - """Weight of a packed Pauli, with X in the low bits and Z in the next block.""" - x, z = pauli & ((1 << num_qubits) - 1), pauli >> num_qubits - return (x | z).bit_count() if not css_weight else max(x.bit_count(), z.bit_count()) - - -def _get_syndrome(matrix: npt.NDArray[np.uint8], pauli: int) -> int: - """Syndrome of a packed Pauli: its symplectic product with each row of the stabilizer matrix.""" - bits = np.array([(pauli >> jj) & 1 for jj in range(matrix.shape[1])]) - products = matrix @ math.symplectic_conjugate(bits) % 2 - return sum(int(bit) << jj for jj, bit in enumerate(products)) - - -def _classical_syndrome(checks: npt.NDArray[np.uint8], words: npt.NDArray[np.int_]) -> Any: - """The packed syndrome of a binary word for binary checks, or of each row of an array of words. - - Bit ``j`` of a syndrome is the parity of the word with row ``j`` of ``checks``. - """ - return (words @ checks.T % 2) @ (1 << np.arange(len(checks))) - - -def _pack_syndromes(syndromes: Sequence[int], num_qubits: int) -> npt.NDArray[np.uint64]: - """Split packed syndromes into rows of 64-bit words, with word ``j`` in bits ``64 j`` and up.""" - width = (num_qubits + 63) // 64 - words = [[(syndrome >> (64 * jj)) % 2**64 for jj in range(width)] for syndrome in syndromes] - return np.array(words, dtype=np.uint64).reshape(len(syndromes), width) - - # Four-qubit blocks: a GHZ state, which is CSS, and the same state after S on its first qubit, which # is not. For the CSS weight, the oracle splits its search only on the GHZ block, and for the # physical weight it searches Pauli errors on both, so one block covers that path. _GHZ_BLOCK = ("XXXX", "ZZ__", "_ZZ_", "__ZZ") -_PHASED_GHZ_BLOCK = ("YXXX", "ZZ__", "_ZZ_", "__ZZ") _BLOCK_CASES = pytest.mark.parametrize( ("block", "css_weight"), [(_GHZ_BLOCK, False), (_GHZ_BLOCK, True), (_PHASED_GHZ_BLOCK, True)], @@ -113,118 +62,6 @@ def _pack_syndromes(syndromes: Sequence[int], num_qubits: int) -> npt.NDArray[np ) -# Graph states whose searches of Pauli errors, by physical and by CSS weight, have a maximum -# disjoint family with more information sets, or a larger partial set, than their greedy packing, -# which their costly queries take (see ``coset_routes``). The family of the first state for the -# physical weight has more information sets, and those of the other two states have a larger partial -# set. The two states for the physical weight differ in the answers that this family changes. On -# the second, it returns other errors for some classes without a light enough error. -_ROUTED_PHYSICAL_STATES = ( - _graph_state(5, [(0, 3), (1, 2), (1, 3), (1, 4)], phase=True), - _graph_state(5, [(0, 4), (2, 3)]), -) -_ROUTED_CSS_WEIGHT_STATE = _graph_state(5, [(0, 1), (0, 4), (1, 4)]) - - -def _find_each_not_within_max_weight( - oracle: CosetLeaderSearch, - matrix: npt.NDArray[np.uint8], - syndromes: npt.NDArray[np.uint64], - max_weight: int, -) -> list[int]: - """Find each syndrome whose class has no error of weight at most ``max_weight``, in turn. - - The representative of each bound that a search returns has the syndrome for the stabilizers of - ``matrix``, its lower bound exceeds ``max_weight``, and, for a maximum weight of at least two, - the bound equals that of ``get_min_weight_bound``. With one coset search, - ``find_first_not_within_max_weight`` computes its bound separately from - ``get_min_weight_bound``, so this checks one against the other. - """ - found: list[int] = [] - while True: - start = found[-1] + 1 if found else 0 - index, bound = oracle.find_first_not_within_max_weight(syndromes[start:], max_weight) - if bound is None: - assert start + index == len(syndromes) - return found - found.append(start + index) - syndrome = int.from_bytes(syndromes[found[-1]].astype(" max_weight - assert _get_syndrome(matrix, bound.representative) == syndrome - if max_weight >= 2: - assert bound == oracle.get_min_weight_bound(syndrome, max_weight) - - -def _random_css_state( - num_qubits: int, num_x_stabilizers: int, rng: np.random.Generator -) -> npt.NDArray[np.uint8]: - """Stabilizer matrix [X|Z] of a random CSS state, with generators that mix pure-X and pure-Z. - - The X parts of the pure-X stabilizers are random independent rows, and the Z parts of the pure-Z - stabilizers span their null space. The generators are random invertible combinations of these - stabilizers. - """ - while True: - x_rows = rng.integers(2, size=(num_x_stabilizers, num_qubits), dtype=np.uint8) - if len(row_reduce_binary(x_rows)[2]) == num_x_stabilizers: - break - pure = np.zeros((num_qubits, 2 * num_qubits), dtype=np.uint8) - pure[:num_x_stabilizers, :num_qubits] = x_rows - pure[num_x_stabilizers:, num_qubits:] = get_kernel(x_rows) - return _scramble(pure, rng) - - -def _scramble(matrix: npt.NDArray[np.uint8], rng: np.random.Generator) -> npt.NDArray[np.uint8]: - """Random invertible combinations of the rows of a stabilizer matrix, which generate its group. - - The combinations are a random permutation of an upper triangular matrix with ones on its - diagonal and random bits above it. - """ - size = len(matrix) - upper = np.triu(rng.integers(2, size=(size, size), dtype=np.uint8), 1) + np.eye( - size, dtype=np.uint8 - ) - return (upper[rng.permutation(size)] @ matrix) % 2 - - -def _css_minima(matrix: npt.NDArray[np.uint8], css_weight: bool = True) -> npt.NDArray[np.int_]: - """The smallest CSS (or physical) weight of an error with each packed syndrome. - - The minima come from all ``4**n`` Paulis. - """ - n = len(matrix) - errors = (np.arange(1 << (2 * n))[:, None] >> np.arange(2 * n)) & 1 - checks = math.symplectic_conjugate(matrix.astype(int)) - syndromes = (errors @ checks.T % 2) @ (1 << np.arange(n)) - x, z = errors[:, :n], errors[:, n:] - weights = np.maximum(x.sum(axis=1), z.sum(axis=1)) if css_weight else (x | z).sum(axis=1) - minima = np.full(1 << n, 2 * n + 1) - np.minimum.at(minima, syndromes, weights) - return minima - - -def _classical_minima(checks: npt.NDArray[np.uint8]) -> npt.NDArray[np.int_]: - """The least Hamming weight of the words with each packed syndrome for binary checks. - - The minima come from all ``2**n`` words of ``n`` bits. - """ - num_columns = checks.shape[1] - words = (np.arange(1 << num_columns)[:, None] >> np.arange(num_columns)) & 1 - minima = np.full(1 << len(checks), num_columns + 1) - np.minimum.at(minima, _classical_syndrome(checks, words), words.sum(axis=1)) - return minima - - -def _block_generators(block: Sequence[str], num_blocks: int) -> list[stim.PauliString]: - """Generators of independent copies of a block state, given the generators of one block.""" - size = len(block[0]) - return [ - stim.PauliString("_" * (size * index) + local + "_" * (size * (num_blocks - index - 1))) - for index in range(num_blocks) - for local in block - ] - - def _ghz_blocks(num_blocks: int) -> tuple[npt.NDArray[np.uint8], int]: """Stabilizers of independent GHZ blocks, and the syndrome of Z on each block's last qubit. @@ -238,33 +75,6 @@ def _ghz_blocks(num_blocks: int) -> tuple[npt.NDArray[np.uint8], int]: return _stabilizer_matrix(generators), syndrome -def _block_minimum( - block: Sequence[str], local_syndromes: Sequence[Sequence[int]], css_weight: bool -) -> int: - """Minimum weight of an error on independent blocks with the given per-block syndromes. - - The syndrome bits of a block are the commutation with its generators, in order. Every Pauli on - one block is listed, and the blocks are combined by dynamic programming. - """ - size = len(block[0]) - stabilizers = [stim.PauliString(local) for local in block] - options: dict[tuple[int, ...], list[tuple[int, int, int]]] = {} - for paulis in itertools.product("_XYZ", repeat=size): - error = stim.PauliString("".join(paulis)) - syndrome = tuple(int(not error.commutes(ss)) for ss in stabilizers) - x_weight = sum(pp in "XY" for pp in paulis) - z_weight = sum(pp in "YZ" for pp in paulis) - options.setdefault(syndrome, []).append((x_weight, z_weight, size - paulis.count("_"))) - if not css_weight: - return sum( - min(support for _, _, support in options[tuple(local)]) for local in local_syndromes - ) - totals = {(0, 0)} - for local in local_syndromes: - totals = {(a + x, b + z) for a, b in totals for x, z, _ in options[tuple(local)]} - return min(max(a, b) for a, b in totals) - - @pytest.mark.parametrize( ("css_weight", "css_singles_table"), [(False, True), (True, True), (True, False)] ) @@ -635,321 +445,6 @@ def test_css_bounds_match_exhaustive_minima(pytestconfig: pytest.Config) -> None assert found == np.flatnonzero(minima[syndromes] > max_weight).tolist() -def test_css_weight_searches_of_even_stabilizers_stop_at_bounds_of_each_class_parity() -> None: - """Searches of Pauli errors by CSS weight stop at the bounds of each class, rounded to parity. - - The state is the graph state of a triangle on qubits 2, 5, and 6, with qubit 1 joined to qubit 2 - and qubits 0, 3, and 4 isolated, after S on each qubit of even degree. Its stabilizers have - even weight as binary words ``(x|z)``, and it is not CSS, so the oracle searches its Pauli - errors. The errors of a syndrome class share the parity of ``|x| + |z|``, which the test reads - from every error. Every query agrees with exhaustive minima. A search that took the parity of - the CSS weight instead would not: for syndrome 57, whose class has minimum two, the word of the - syndrome has Hamming weight four but CSS weight three, and the bounds of odd classes stop the - search at maximum weight two before it finds an error of weight two. - - Answers alone cannot show whether these searches round, since rounding mainly moves where an - odd class stops (see Section 3.2 of ``brouwer_zimmermann.md``). So the test also finds the - stopping step of each query as ``_search_coset`` does, and checks it against the first step - whose bound in ``AffineInstance.get_weight_bounds`` for the parity of the class exceeds the - maximum weight. ``information_sets_test.py`` and ``affine_instance_test.py`` check those bounds - against their definition and against brute force. Some odd classes stop sooner than they would - without parity. - """ - generators = ("Y______", "_XZ____", "_ZX__ZZ", "___Y___", "____Y__", "__Z__YZ", "__Z__ZY") - matrix = _stabilizer_matrix([stim.PauliString(pauli) for pauli in generators]) - _assert_exact_answers(CosetLeaderSearch(matrix, css_weight=True), matrix, True) - - # Read the parity of each syndrome class from every error, and check that the class shares it. - num_qubits = len(matrix) - checks = (math.symplectic_conjugate(matrix.astype(int)) % 2).astype(np.uint8) - errors = (np.arange(1 << 2 * num_qubits)[:, None] >> np.arange(2 * num_qubits)) & 1 - syndromes, parities = _classical_syndrome(checks, errors), errors.sum(axis=1) % 2 - class_parities = np.zeros(1 << num_qubits, dtype=int) - class_parities[syndromes] = parities - assert np.array_equal(class_parities[syndromes], parities) - - # Find the stopping step of each query as ``_search_coset`` does. Steps ``(layer, - # num_completed)`` compare as tuples in the order of the search. - instance = affine_instance.get_coset_instance(matrix, random_column_orders=0, weight_factor=2) - inverse = _coset_leader_search._get_inverse(checks) - tables = _coset_leader_search._get_search_tables(instance, inverse, num_qubits) - unrounded = instance.get_weight_bounds() - steps = list(itertools.product(range(len(unrounded)), range(1, unrounded.shape[1]))) - best = np.empty(tables.inverse.shape[1], dtype=np.uint64) - num_sooner = 0 - for syndrome in range(1 << num_qubits): - _, parity = _coset_leader_search._get_syndrome_word( - _pack_syndromes([syndrome], num_qubits)[0], best, tables.inverse, tables.x_mask, 0 - ) - table = _coset_leader_search._get_planning_table(tables.planning, parity) - bounds = instance.get_weight_bounds(class_parities[syndrome]) - for max_weight in range(2, num_qubits): - stopping_step = next(step for step in steps if bounds[step] > max_weight) - assert _coset_leader_search._get_stopping_step(table, max_weight)[:2] == stopping_step - num_sooner += stopping_step < next( - step for step in steps if unrounded[step] > max_weight - ) - assert num_sooner - - -def test_cached_searches_answer_only_for_their_stabilizer_group( - pytestconfig: pytest.Config, -) -> None: - """Oracles built in turn for |000+++>, another generating set of it, and |+++000> are exact. - - The searches of a CSS state are cached by stabilizer group, so the second oracle reuses those of - the first. |+++000> has the same numbers of qubits and of pure-X stabilizers as |000+++>, but a - different stabilizer group, so its oracle must not reuse them. - """ - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - eye, zeros = np.eye(3, dtype=np.uint8), np.zeros((3, 3), dtype=np.uint8) - zero_plus = np.block([[zeros, eye, zeros, zeros], [zeros, zeros, eye, zeros]]) - plus_zero = np.block([[eye, zeros, zeros, zeros], [zeros, zeros, zeros, eye]]) - matrices = [_scramble(matrix, rng) for matrix in (zero_plus, zero_plus, plus_zero)] - _coset_leader_search._get_css_searches.cache_clear() - oracles = [CosetLeaderSearch(matrix, css_weight=True) for matrix in matrices] - for oracle, matrix in zip(oracles, matrices, strict=True): - minima = _css_minima(matrix) - for syndrome, max_weight in itertools.product(range(1 << 6), range(4)): - bound = oracle.get_min_weight_bound(syndrome, max_weight) - assert bound.within_max_weight == (minima[syndrome] <= max_weight) - assert _get_syndrome(matrix, bound.representative) == syndrome - - -def test_oracles_use_the_tuning_with_which_they_are_built(monkeypatch: pytest.MonkeyPatch) -> None: - """An oracle builds its searches with the tuning in force, keyed by the fields that they read. - - The searches are cached, and the column orders key the cache. So the first oracle built under - some orders builds searches of its own with them, and a second oracle built under the same - orders shares those searches. The orders reach a search through - ``information_sets.get_disjoint_family``, which the test wraps to record them. The states are - |000+++>, whose two classical searches are cached by stabilizer group, and a two-qubit graph - state with phases, which is not CSS, so its search of Pauli errors is cached by stabilizers. - Every oracle answers exactly. The caches are cleared first, since earlier tests may have filled - them. Only a search of Pauli errors by CSS weight reads ``max_css_single_pairs``, so the - classical searches, and a search of Pauli errors by physical weight, are shared by oracles built - under different values of it, and a search of Pauli errors by CSS weight is not. That search - alone has no completion tables, so it alone is shared by oracles built under different values of - ``coset_completion_block_bits``. - """ - orders: list[int] = [] - get_disjoint_family = basis_families.get_disjoint_family - - def record(*args: Any, random_column_orders: int) -> basis_families.BasisFamily: - orders.append(random_column_orders) - return get_disjoint_family(*args, random_column_orders=random_column_orders) - - monkeypatch.setattr(basis_families, "get_disjoint_family", record) - eye, zeros = np.eye(3, dtype=np.uint8), np.zeros((3, 3), dtype=np.uint8) - zero_plus = np.block([[zeros, eye, zeros, zeros], [zeros, zeros, eye, zeros]]) - phased = _graph_state(2, [(0, 1)], phase=True) - _coset_leader_search._get_css_searches.cache_clear() - _coset_leader_search._get_pauli_search.cache_clear() - default = DEFAULT_TUNING.disjoint_random_column_orders - for column_orders, matrix in itertools.product((default, 3), (zero_plus, phased)): - with use_tuning(disjoint_random_column_orders=column_orders): - first = CosetLeaderSearch(matrix, css_weight=True) - assert orders and set(orders) == {column_orders} - orders.clear() - second = CosetLeaderSearch(matrix, css_weight=True) - assert not orders - for oracle in (first, second): - _assert_exact_answers(oracle, matrix, True) - for matrix, css_weight in ((zero_plus, True), (phased, False), (phased, True)): - CosetLeaderSearch(matrix, css_weight=css_weight) - paulis_by_css_weight = matrix is phased and css_weight - changes: list[tuple[dict[str, Any], bool]] = [ - ({"max_css_single_pairs": 0}, paulis_by_css_weight), - ({"coset_completion_block_bits": 4}, not paulis_by_css_weight), - ] - for change, reads in changes: - orders.clear() - with use_tuning(**change): - CosetLeaderSearch(matrix, css_weight=css_weight) - assert bool(orders) == reads - - -def test_oracles_build_other_families_with_the_tuning_they_are_built_with( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Costly queries build their families and tables with the tuning of the oracle that answers. - - With the threshold of the gate at zero, the first query that searches builds the other families - of the search (see ``coset_routes``). The most sets of the overlapping family and the column - orders that it tries for each set reach ``information_sets.get_overlapping_family``, and the - width of the blocks of completion tables and their limit of bytes reach - ``completion_bounds.build_tables_within``, which the test wraps to record them. They are those - of the tuning in force when the oracle was built, not when it answers. - """ - calls: list[tuple[int, int]] = [] - table_calls: set[tuple[int, int]] = set() - get_overlapping_family = basis_families.get_overlapping_family - build_tables_within = completion_bounds.build_tables_within - signature = inspect.signature(get_overlapping_family) - - def record(*args: Any, **kwargs: Any) -> basis_families.BasisFamily: - policy = signature.bind(*args, **kwargs).arguments["policy"] - calls.append((policy.max_sets, policy.attempts_per_set)) - return get_overlapping_family(*args, **kwargs) - - def record_tables( - rows: npt.NDArray[np.uint64], block_bits: int, max_bytes: int - ) -> completion_bounds.CompletionTables | None: - table_calls.add((block_bits, max_bytes)) - return build_tables_within(rows, block_bits, max_bytes) - - monkeypatch.setattr(basis_families, "get_overlapping_family", record) - monkeypatch.setattr(completion_bounds, "build_tables_within", record_tables) - _coset_leader_search._get_pauli_search.cache_clear() - with use_tuning( - overlapping_minimum_work=0, - max_information_sets=5, - overlapping_attempts_per_set=3, - coset_completion_block_bits=4, - completion_bound_max_table_bytes=12345, - ): - oracle = CosetLeaderSearch(_ROUTED_PHYSICAL_STATES[1]) - for syndrome, max_weight in itertools.product(range(1 << 5), range(2, 5)): - oracle.get_min_weight_bound(syndrome, max_weight) - assert calls == [(5, 3)] - assert table_calls == {(4, 12345)} - - -def test_connected_support_oracles_make_checks_sparser_with_their_tuning( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A connected-support oracle makes its checks sparser in the rounds of its tuning. - - The most rounds and the rounds without improvement of the tuning in force when the oracle is - built reach ``_get_sparser_rows``, which the test wraps to record them. The state is a graph - state with phases, which is not CSS, so the oracle has one search of Pauli errors. - """ - calls: list[tuple[int, int]] = [] - get_sparser_rows = _coset_leader_search._get_sparser_rows - signature = inspect.signature(get_sparser_rows) - - def record(*args: Any, **kwargs: Any) -> tuple[npt.NDArray[np.uint8], npt.NDArray[np.uint8]]: - arguments = signature.bind(*args, **kwargs).arguments - calls.append((arguments["max_rounds"], arguments["rounds_without_improvement"])) - return get_sparser_rows(*args, **kwargs) - - monkeypatch.setattr(_coset_leader_search, "_get_sparser_rows", record) - with use_tuning(max_sparsifying_rounds=5, sparsifying_rounds_without_improvement=2): - CosetLeaderSearch(_graph_state(2, [(0, 1)], phase=True), method="connected_support") - assert calls == [(5, 2)] - - -def test_oracles_under_other_gates_do_not_share_searches(tune: Tune) -> None: - """An oracle answers as a fresh oracle under the threshold of its gate, whatever is cached. - - Oracles are built for a graph state, whose search of Pauli errors is cached by stabilizers, and - for a CSS state whose pure-X stabilizers span a random code, whose two classical searches are - cached by stabilizer group. They are built under the threshold zero, then the largest value, - then zero again. Some answers differ between the thresholds, so an oracle that shared the - search of another threshold would answer differently. - """ - checks = get_kernel(OVERLAPPING_ROUTES_CODE) - css_state = np.block( - [ - [OVERLAPPING_ROUTES_CODE, np.zeros_like(OVERLAPPING_ROUTES_CODE)], - [np.zeros_like(checks), checks], - ] - ) - for matrix in (_ROUTED_PHYSICAL_STATES[1], css_state): - css_weight = matrix is css_state - syndromes = range(0, 1 << len(matrix), max(1, (1 << len(matrix)) // 64)) - queries = list(itertools.product(syndromes, range(6))) - answers = {} - for minimum_work in (0, MAX_VALUE): - _coset_leader_search._get_css_searches.cache_clear() - _coset_leader_search._get_pauli_search.cache_clear() - with use_tuning(overlapping_minimum_work=minimum_work): - oracle = CosetLeaderSearch(matrix, css_weight=css_weight) - answers[minimum_work] = [oracle.get_min_weight_bound(*query) for query in queries] - assert answers[0] != answers[MAX_VALUE] - for minimum_work in (0, MAX_VALUE, 0): - tune(overlapping_minimum_work=minimum_work) - oracle = CosetLeaderSearch(matrix, css_weight=css_weight) - answers_now = [oracle.get_min_weight_bound(*query) for query in queries] - assert answers_now == answers[minimum_work] - - -def test_cached_pauli_searches_answer_for_their_own_stabilizers_and_weight() -> None: - """Oracles built in turn answer exactly for their own stabilizers and weight. - - The product states with stabilizers ``Y__``, ``_Z_``, ``__X`` and ``X__``, ``_Y_``, ``__Z`` are - not CSS, so their oracles search Pauli errors by either weight, and both have three qubits. The - generators ``Y__``, ``YZ_``, ``__X`` of the first state give other syndromes to the same errors. - In the first state, ``Z0 X1`` has the syndrome ``0b011`` and CSS weight one, but no error with - that syndrome has physical weight below two. Oracles are built for every state and weight under - three tunings of the setup of their searches, and queried only once all are built, so that each - answers after every search is cached. - """ - states = [ - _product_state(generators) - for generators in (("Y__", "_Z_", "__X"), ("Y__", "YZ_", "__X"), ("X__", "_Y_", "__Z")) - ] - _coset_leader_search._get_pauli_search.cache_clear() - oracles = [] - for changes in ({}, {"max_css_single_pairs": 0}, {"disjoint_random_column_orders": 3}): - with use_tuning(**changes): - for matrix, css_weight in itertools.product(states, (False, True)): - oracles.append( - (matrix, css_weight, CosetLeaderSearch(matrix, css_weight=css_weight)) - ) - physical_bound = oracles[0][2].get_min_weight_bound(0b011, 1) - assert not physical_bound.within_max_weight and physical_bound.lower_bound == 2 - css_bound = oracles[1][2].get_min_weight_bound(0b011, 1) - assert css_bound.within_max_weight and css_bound.representative == 0b001010 - for matrix, css_weight, oracle in oracles: - _assert_exact_answers(oracle, matrix, css_weight) - - -def test_cached_pauli_searches_ignore_later_changes_to_the_stabilizer_array(tune: Tune) -> None: - """Oracles answer for the stabilizers that they were built from, when an array changes later. - - Oracles of a graph state are built from an array, which is then overwritten with another graph - state, from which further oracles are built from the same array. Oracles of the first state - built again from a copy of it, and the oracles built before the change, still answer for the - first state. With the threshold of the gate at zero, the searches build their other families - at their first queries, after the change (see ``coset_routes``). The first state has a larger - disjoint family by CSS weight, and its search by CSS weight reads its generators from the - stabilizers themselves. - """ - tune(overlapping_minimum_work=0) - first, second = _ROUTED_CSS_WEIGHT_STATE, _ROUTED_PHYSICAL_STATES[1] - _coset_leader_search._get_pauli_search.cache_clear() - array = first.copy() - oracles = [ - (first, css_weight, CosetLeaderSearch(array, css_weight=css_weight)) - for css_weight in (False, True) - ] - array[:] = second - oracles += [ - (second, css_weight, CosetLeaderSearch(array, css_weight=css_weight)) - for css_weight in (False, True) - ] - oracles += [ - (first, css_weight, CosetLeaderSearch(first.copy(), css_weight=css_weight)) - for css_weight in (False, True) - ] - for matrix, css_weight, oracle in oracles: - _assert_exact_answers(oracle, matrix, css_weight) - - -def test_tables_of_pairs_of_single_qubit_errors_respect_their_limit() -> None: - """A table of products of X and Z errors keeps the first product of each syndrome. - - Three X errors (with the identity) and two Z errors make six pairs, which fit a limit of six but - not of five. - """ - x_singles = {0: 0, 0b01: 0b0001, 0b11: 0b0010} - z_singles = {0: 0, 0b10: 0b0100} - table = _coset_leader_search._tabulate_css_singles(x_singles, z_singles, 6) - assert table == {0: 0, 0b10: 0b0100, 0b01: 0b0001, 0b11: 0b0101} - assert _coset_leader_search._tabulate_css_singles(x_singles, z_singles, 5) is None - - def test_invalid_arguments_are_rejected() -> None: """Malformed states and queries raise, for either measure of weight.""" # Not 2n columns, dependent rows, anticommuting rows, and a syndrome out of range. @@ -961,226 +456,6 @@ def test_invalid_arguments_are_rejected() -> None: CosetLeaderSearch([[1, 0]], css_weight=css_weight).get_min_weight_bound(2, 1) -def test_cached_stopping_steps_are_reused_exactly_while_they_stay_first( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """For nonnegative weights, a cached stopping step is reused exactly while it stays first. - - The stopping step of a maximum weight is the first step whose bound exceeds it. - ``_get_stopping_step`` reuses the step that a planning buffer caches if that step is the - stopping step. Otherwise, it plans the stopping step, which a binary search finds, and caches - it. A reuse is observable only as a skipped plan, so the test records the plans. - - Each step of the bounds is planted as the cached step. A planted step may be stale, or pair the - layer of one step with the number of completed sets of another, as interleaved queries might - leave it. Each maximum weight from zero to past the largest finite bound is then queried twice. - The first query plans exactly if the planted step is not its stopping step. The second query - does not plan, since the first cached its step. Both queries return the stopping step, which a - linear search finds, and its bound. The bounds in the buffer never change. A negative maximum - weight always plans, since every bound is at least zero, so it is left out. - - The bounds are those of the empty code, of a rank-one code with 64 sets, and of sets of ranks - zero, two, and four in a code of dimension four, for both weight factors. - """ - plans: list[int] = [] - find_stopping_step = certificates_module.find_stopping_step - - def record(*args: Any) -> tuple[int, int]: - plans.append(args[1]) - return find_stopping_step(*args) - - monkeypatch.setattr(certificates_module, "find_stopping_step", record) - # Take the Python version even when the function is compiled, since only it calls the recording - # function. - function = _coset_leader_search._get_stopping_step - get_stopping_step = getattr(function, "py_func", function) - certificates = [ - certificates_module.Certificate(0, (0,)), - certificates_module.Certificate(1, (1,) * 64), - certificates_module.Certificate(4, (0, 2, 4, 0)), - ] - for certificate, factor in itertools.product(certificates, (1, 2)): - bounds = certificate.get_weight_bounds(factor) - num_rows, num_sets = bounds.shape[0] - 1, bounds.shape[1] - 1 - steps = list(itertools.product(range(num_rows + 1), range(1, num_sets + 1))) - largest = int(bounds[bounds < certificates_module.NO_WORD_BOUND].max()) - (table,) = _coset_leader_search._get_planning([bounds]) - for planted, max_weight in itertools.product(steps, range(largest + 2)): - stopping_step = next(step for step in steps if bounds[step] > max_weight) - expected = (*stopping_step, int(bounds[stopping_step])) - # Plant the cached step in the last row of the table (see ``_get_planning``). - table[-1, :2] = planted - plans.clear() - assert get_stopping_step(table, max_weight) == expected - assert len(plans) == (planted != stopping_step) - assert get_stopping_step(table, max_weight) == expected - assert len(plans) == (planted != stopping_step) - assert np.array_equal(table[:-1], bounds) - - -# A code whose disjoint information sets have ranks five and four. -_RANK_DEFICIENT_CODE = np.array( - [ - [0, 0, 1, 1, 0, 1, 1, 1, 0, 0], - [0, 0, 1, 1, 0, 0, 1, 1, 1, 1], - [1, 0, 1, 1, 0, 1, 0, 1, 1, 0], - [0, 1, 1, 0, 1, 1, 0, 1, 0, 1], - [1, 0, 1, 1, 0, 1, 1, 1, 0, 1], - ], - dtype=np.uint8, -) - -# A code whose searches for minimum weights by tails skip completions by tables at the threshold -# zero, as do those of its even version. -_SKIPPING_CODE = np.array( - [ - [0, 1, 0, 1, 1, 0, 1, 1, 1, 0, 1], - [0, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1], - [0, 0, 0, 1, 1, 0, 1, 1, 1, 1, 1], - [0, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1], - [0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0], - ], - dtype=np.uint8, -) - - -# A longer code with cosets of minimum weight six. At maximum weight four and the threshold zero, -# its searches of them for minimum weights skip some completions by tables whose bound reaches the -# skip bound of that weight, but not the weight of the lightest word that the searches weigh. The -# searches by tails of some of its cosets, such as those of syndromes 1126, 4578, and 6129, stop -# early within steps. -_HEAVY_COSETS_CODE = get_binary_matrix( - [ - "11100011110000110001", - "00001011001010001000", - "10010110101010101100", - "10001100101100100101", - "01010100110001001100", - "00010000001110010101", - "00101100011111000001", - ] -) - - -def _assert_classical_cosets_match_brute_force(generators: npt.NDArray[np.uint8]) -> None: - """Check the classical search of the cosets of a code against the least weight of their words. - - Single queries and batches, at maximum weights from below zero to past the number of columns, - agree with the least weight of the words of each syndrome. From maximum weight two, they also - agree with each other. A coset without a word of weight at most the maximum weight gets a lower - bound of at least the least weight above the maximum weight that a word of the coset can have. - That weight has the parity of the coset's least weight if every row of ``generators`` has even - weight. Below maximum weight two, a single query of a coset without a word of weight at most - one gets exactly that weight for maximum weight one, which it finds without a search. - """ - num_columns = generators.shape[1] - checks = get_kernel(generators) - tuning = _coset_leader_search._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) - search = _coset_leader_search._get_classical_search( - generators, checks, tuning._replace(disjoint_random_column_orders=0) - ) - minima = _classical_minima(checks) - even = not np.any(generators.sum(axis=1) % 2) - - def get_least_bound(syndrome: int, max_weight: int) -> int: - """The least weight above the maximum weight that a word of the coset can have.""" - return max_weight + 1 + int(even and (max_weight + 1 - minima[syndrome]) % 2) - - syndromes = list(range(1 << len(checks))) - packed_syndromes = _pack_syndromes(syndromes, len(checks)) - for max_weight in range(-1, num_columns + 2): - bounds = {} - for syndrome in syndromes: - bound = search.get_min_weight_bound(syndrome, max_weight) - representative = (bound.representative >> np.arange(num_columns)) & 1 - weight = int(representative.sum()) - assert bound.within_max_weight == (minima[syndrome] <= max_weight) - assert _classical_syndrome(checks, representative) == syndrome - assert bound.lower_bound <= minima[syndrome] <= weight - if bound.within_max_weight: - assert weight <= max_weight - else: - assert bound.lower_bound >= get_least_bound(syndrome, max_weight) - if max_weight < 2 and minima[syndrome] >= 2: - assert bound.lower_bound == get_least_bound(syndrome, 1) - bounds[syndrome] = bound - - # Find each syndrome whose minimum exceeds the maximum weight with batches, each of which - # starts after the last syndrome found. - found, start = [], 0 - while True: - index, batch_bound = search.find_first_not_within_max_weight( - packed_syndromes[start:], max_weight - ) - if batch_bound is None: - assert start + index == len(syndromes) - break - syndrome = syndromes[start + index] - representative = (batch_bound.representative >> np.arange(num_columns)) & 1 - assert _classical_syndrome(checks, representative) == syndrome - assert not batch_bound.within_max_weight - assert get_least_bound(syndrome, max_weight) <= batch_bound.lower_bound - assert batch_bound.lower_bound <= minima[syndrome] - if max_weight >= 2: - assert bounds[syndrome] == batch_bound - found.append(syndrome) - start += index + 1 - assert found == [syndrome for syndrome in syndromes if minima[syndrome] > max_weight] - - -def test_cosets_of_a_rank_deficient_code_match_brute_force() -> None: - """The coset searches of a rank-deficient code match the minima of all words. - - The code has disjoint information sets of ranks five and four, so its searches skip the second - set while it adds nothing to the bounds of their stopping steps. Syndrome 19 has a word of - weight two that the first set reaches only above layer one. At maximum weight two, the stopping - step is the one at which the second set completes layer one, which overcomes its deficiency of - one, so the search visits that set. It finds the word in layer zero of that set, its offset. - Single queries and batches agree with the least weight of the words of each syndrome (see - ``_assert_classical_cosets_match_brute_force``). Below two, a single query of a coset without a - word of weight at most one returns a bound of two without searching. - """ - _assert_classical_cosets_match_brute_force(_RANK_DEFICIENT_CODE) - - -def test_cosets_of_an_even_code_have_bounds_of_their_parity() -> None: - """Coset searches of an even code give each coset with no light word a bound of its parity. - - The code is ``_RANK_DEFICIENT_CODE`` with a last column that holds the parity of each row, so - every word of the code has even weight, and the weights of the words of each coset share a - parity. A coset without words of weight at most ``m`` then has no word of weight ``m + 1`` if - that integer has the other parity, so its lower bound is at least the least integer above ``m`` - of its parity (see ``_assert_classical_cosets_match_brute_force``). The bounds of the searches, - which round up to the parity of each coset, reach it. So does the bound three of an odd coset - of least weight three in a single query below maximum weight two, which does not search. Bounds - without parity would fall short of both. The cosets have both parities, and least weights up to - four. - """ - parities = (_RANK_DEFICIENT_CODE.sum(axis=1, keepdims=True) % 2).astype(np.uint8) - _assert_classical_cosets_match_brute_force(np.hstack([_RANK_DEFICIENT_CODE, parities])) - - -@pytest.mark.parametrize( - "code", [EVEN_ROUTES_CODE, TWO_FAMILIES_CODE], ids=["even", "two-families"] -) -def test_cosets_match_brute_force_with_other_families( - code: npt.NDArray[np.uint8], tune: Tune -) -> None: - """Searches of X or Z parts answer exactly when costly queries take other families. - - With the threshold of the gate at zero, queries that the disjoint family would answer with more - work take another family (see ``coset_routes``). The even code takes a disjoint family with - two information sets, for cosets of both parities. The random code takes a disjoint family with - a larger partial set, and a family of overlapping information sets. Single queries and - batches, at maximum weights from below zero to past the number of columns, and so just below - and at the least weight of each coset, agree with the least weight of the words of each coset - (see ``_assert_classical_cosets_match_brute_force``). At the threshold zero, the queries also - run the searches by tails. - """ - tune(overlapping_minimum_work=0) - _assert_classical_cosets_match_brute_force(code) - - @pytest.mark.parametrize( "matrix, css_weight", [ @@ -1214,656 +489,6 @@ def test_oracles_answer_exactly_with_other_families( assert found == np.flatnonzero(minima > max_weight).tolist() -def test_answers_do_not_depend_on_earlier_queries(pytestconfig: pytest.Config, tune: Tune) -> None: - """The answers of a search depend only on its code, the syndrome, and the maximum weight. - - With the threshold of the gate at zero, fresh searches of the cosets of a random code answer - every query in increasing, decreasing, and random order of the maximum weight. Each order - reaches the threshold at a different query, but the overlapping family of the code, which - depends on the weight at which it is built, and so the answers, are the same. With the largest - threshold, some answers differ, so the other families answer some of the queries. - """ - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - checks = get_kernel(OVERLAPPING_ROUTES_CODE) - - def get_answers(order: str) -> dict[tuple[int, int], MinWeightBound]: - """Every answer of a fresh search, with the maximum weights in the given order.""" - tuning = _coset_leader_search._CosetTuning.from_tuning( - get_tuning(), paulis_by_css_weight=False - ) - search = _coset_leader_search._get_classical_search( - OVERLAPPING_ROUTES_CODE, checks, tuning._replace(disjoint_random_column_orders=0) - ) - weights = list(range(15)) - if order != "up": - weights = weights[::-1] if order == "down" else rng.permutation(weights).tolist() - return { - (syndrome, max_weight): search.get_min_weight_bound(syndrome, max_weight) - for max_weight in weights - for syndrome in range(1 << len(checks)) - } - - tune(overlapping_minimum_work=0) - answers = [get_answers(order) for order in ("up", "down", "random")] - assert answers[0] == answers[1] == answers[2] - with use_tuning(overlapping_minimum_work=MAX_VALUE): - assert get_answers("up") != answers[0] - - -def test_searches_visit_only_the_sets_that_add_to_their_bounds(pytestconfig: pytest.Config) -> None: - """A search skips exactly the sets that add nothing to the bound of its stopping step. - - A set of rank ``r`` in a code of dimension ``k`` adds to the bound of a step only once it has - completed layer ``k - r`` (see ``information_sets.get_step_lower_bounds``). A search that stops - at step ``(w, j)`` completes layer ``w`` on the first ``j`` sets and layer ``w - 1`` on the - others, so it visits a set only if that layer is at least ``k - r``. The sets are the disjoint - information sets of random codes, whose last one is often rank deficient, and a set of rank zero - with every row of the code. A search whose answer is no weighs every word of the layers of the - sets that it visits, and every answer is exact. The exact number of words weighed guards the - documented rule for skipping sets, so a legitimate future pruning of the coset scan would - change it. The work of the plan of the steps of the search, from layer zero and without the - sets that it skips (see ``information_sets.plan_steps``), predicts this number. - """ - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - for _ in range(12): - num_columns = int(rng.integers(2, 9)) - num_rows = int(rng.integers(1, num_columns)) - generators = get_random_full_rank_matrix(rng, num_rows, num_columns) - checks = get_kernel(generators) - family = basis_families.get_disjoint_family(generators, None, random_column_orders=0) - ranks = (*family.ranks.tolist(), 0) - num_sets = len(ranks) - rows = np.concatenate([family.generators, generators[None]]) - instance = affine_instance.AffineInstance( - generators=pack_bits(rows.reshape(-1, num_columns)).reshape(num_sets, num_rows, -1), - labels=np.zeros((num_sets, num_rows, 0), dtype=np.uint64), - widths=np.ones(num_sets, dtype=np.int64), - pivots=np.vstack([family.pivots, np.full(num_rows, -1)]), - certificate=certificates_module.Certificate(num_rows, ranks), - ) - inverse = _coset_leader_search._get_inverse(checks) - tables = _coset_leader_search._get_search_tables(instance, inverse, num_columns) - minima = _classical_minima(checks) - - for syndrome, max_weight in itertools.product( - range(1 << len(checks)), range(num_columns + 1) - ): - best = np.empty(tables.inverse.shape[1], dtype=np.uint64) - packed_syndrome = _pack_syndromes([syndrome], len(checks))[0] - best_weight, parity = _coset_leader_search._get_syndrome_word( - packed_syndrome, best, tables.inverse, tables.x_mask, 1 - ) - if best_weight <= max_weight: - continue - table = _coset_leader_search._get_planning_table(tables.planning, parity) - stop_layer, stop_num_completed, stop_bound = _coset_leader_search._get_stopping_step( - table, max_weight - ) - lower_bound, weighed, _ = _coset_leader_search._search_from_syndrome_word( - packed_syndrome, - max_weight, - stop_layer, - stop_num_completed, - stop_bound, - best, - best_weight, - tables.generators, - tables.pivots, - tables.x_mask, - None, - 1, - parity if len(tables.planning) == 2 else -1, - ) - representative = unpack_bits(best[None], num_columns)[0].astype(int) - assert _classical_syndrome(checks, representative) == syndrome - assert (lower_bound <= max_weight) == (minima[syndrome] <= max_weight) - if lower_bound > max_weight: - assert lower_bound <= minima[syndrome] - last_layers = [ - stop_layer if index < stop_num_completed else stop_layer - 1 - for index in range(num_sets) - ] - assert weighed == sum( - comb(num_rows, layer) - for rank, last_layer in zip(ranks, last_layers, strict=True) - if last_layer >= num_rows - rank - for layer in range(last_layer + 1) - ) - binomials = certificates_module.get_capped_binomials(num_rows) - plan = certificates_module.plan_steps( - table[:-1], max_weight, 0, num_rows, instance.ranks, binomials, True - ) - assert plan == (stop_layer, stop_num_completed, weighed) - - -def _assert_searches_by_tails_match_searches_of_words( - generators: npt.NDArray[np.uint8], - inverse: npt.NDArray[np.uint8], - scale: int, - block_sizes: Sequence[int], - max_weights: Sequence[int], - syndromes: Sequence[int], - minima: npt.NDArray[np.int_] | None = None, - *, - block_bits: int | None = None, -) -> int: - """Check searches by tails against searches of whole words, without or with completion tables. - - The family holds a set of the embedded generators reduced on each block of consecutive columns, - whose sizes cycle through ``block_sizes``, and a set of rank zero with every generator. The - blocks are disjoint, so the certificate of the ranks of the sets bounds the words of each coset. - Every column counts as the X part. The lower bound of the search of whole words agrees with the - minimum of the coset in ``minima``, if given. Without ``block_bits``, for each syndrome and - maximum weight, the two searches from the word of the syndrome give the same lower bound, word, - and number of words weighed. For each maximum weight, the two batch loops find the same - syndromes, in turn, with the same bounds and words. - - With ``block_bits``, every set of at least two rows has completion tables on blocks of that many - columns, and the searches by tails decide as the searches of whole words, and give the same word - to a coset with a word within the maximum weight. To another coset, they give the same lower - bound, or the least weight beyond the maximum weight that a word of the coset can have if they - skipped any word, and a word of at least that weight. This least weight is one more than the - maximum weight, or two more if the generators are even and the words of the coset have the - parity of the maximum weight. They weigh every word that the search of whole words weighs, but - the words that they skip, which ``_count_skipped_supports`` counts. The batch loops with tables - find the same syndromes, with the bounds and words of single queries with tables. - - Returns: - The number of supports that the searches by tails skipped. - """ - num_rows, num_columns = generators.shape - sets, pivots, start = [], [], 0 - for size in itertools.cycle(block_sizes): - if start >= num_columns: - break - block = np.arange(start, min(start + size, num_columns)) - reduced, _, block_pivots = row_reduce_binary(generators, columns=block) - sets.append(reduced) - pivots.append(np.pad(block_pivots, (0, num_rows - len(block_pivots)), constant_values=-1)) - start += size - sets.append(generators) - pivots.append(np.full(num_rows, -1)) - instance = affine_instance.AffineInstance( - generators=np.stack([pack_bits(rows) for rows in sets]), - labels=np.zeros((len(sets), num_rows, 0), dtype=np.uint64), - widths=np.ones(len(sets), dtype=np.int64), - pivots=np.array(pivots, dtype=np.int64), - certificate=certificates_module.Certificate( - num_rows, tuple(int(np.sum(row >= 0)) for row in pivots) - ), - weight_factor=2 if scale == 2 else 1, - ) - tables = _coset_leader_search._get_search_tables(instance, inverse, num_columns) - tails = _coset_leader_search._get_coset_tails( - instance, - list(range(len(sets))) if block_bits else [], - None, - inverse=inverse, - block_bits=block_bits or 8, - max_table_bytes=MAX_VALUE, - ) - even = scale == 1 and not np.any(generators.sum(axis=1) % 2) - packed_syndromes = _pack_syndromes(syndromes, len(inverse)) - total_skipped = 0 - for max_weight in max_weights: - answers = {} - for syndrome, packed_syndrome in zip(syndromes, packed_syndromes, strict=True): - words = np.empty((2, tables.inverse.shape[1]), dtype=np.uint64) - best_weight, parity = _coset_leader_search._get_syndrome_word( - packed_syndrome, words[0], tables.inverse, tables.x_mask, scale - ) - if best_weight <= max_weight: - continue - words[1] = words[0] - word = unpack_bits(words[:1], num_columns)[0] - coset_parity = int(word.sum()) % 2 - stop_layer, stop_num_completed, stop_bound = _coset_leader_search._get_stopping_step( - _coset_leader_search._get_planning_table(tables.planning, parity), max_weight - ) - *answer, _ = _coset_leader_search._search_from_syndrome_word( - packed_syndrome, - max_weight, - stop_layer, - stop_num_completed, - stop_bound, - words[0], - best_weight, - tables.generators, - tables.pivots, - tables.x_mask, - None, - scale, - parity if len(tables.planning) == 2 else -1, - ) - *answer_by_tails, skipped = _coset_leader_search._search_from_syndrome_word( - packed_syndrome, - max_weight, - stop_layer, - stop_num_completed, - stop_bound, - words[1], - best_weight, - tables.generators, - tables.pivots, - tables.x_mask, - tails, - scale, - parity if len(tables.planning) == 2 else -1, - ) - if minima is not None: - assert (answer[0] <= max_weight) == (minima[syndrome] <= max_weight) - assert answer[0] <= minima[syndrome] - answers[syndrome] = answer_by_tails[0], words[1] - total_skipped += skipped - if not block_bits: - assert answer_by_tails == list(answer) and not skipped - np.testing.assert_array_equal(words[0], words[1]) - elif answer[0] <= max_weight: - assert answer_by_tails[0] == 0 - np.testing.assert_array_equal(words[0], words[1]) - else: - least_beyond = max_weight + 1 - if even and (least_beyond + coset_parity) % 2: - least_beyond += 1 - assert answer_by_tails[0] == (least_beyond if skipped else answer[0]) - assert int(np.bitwise_count(words[1]).sum()) >= scale * answer_by_tails[0] - # The skip bound is one more than the greatest Hamming weight at most - # ``scale * max_weight`` that a word of the coset can have. - skip_bound = scale * max_weight + 1 - if even and (skip_bound - coset_parity) % 2 == 0: - skip_bound -= 1 - assert answer_by_tails[1] + skipped == answer[1] - assert skipped == sum( - _count_skipped_supports(rows, set_pivots, word, skip_bound, block_bits, last) - for index, (rows, set_pivots) in enumerate(zip(sets, pivots, strict=True)) - if (last := stop_layer - (index >= stop_num_completed)) - >= num_rows - np.sum(set_pivots >= 0) - ) - - # Find each syndrome without a light enough word with both batch loops, in turn. - start = 0 - while True: - words = np.empty((2, tables.inverse.shape[1]), dtype=np.uint64) - found = _coset_leader_search._find_first_not_within_max_weight( - packed_syndromes[start:], max_weight, words[0], *tables, None, scale - ) - found_by_tails = _coset_leader_search._find_first_not_within_max_weight( - packed_syndromes[start:], max_weight, words[1], *tables, tails, scale - ) - assert found_by_tails[0] == found[0] - if start + found[0] == len(packed_syndromes): - break - if block_bits: - bound, word = answers[syndromes[start + found[0]]] - assert found_by_tails[1] == bound - np.testing.assert_array_equal(words[1], word) - else: - assert found_by_tails == found - np.testing.assert_array_equal(words[0], words[1]) - start += found[0] + 1 - return total_skipped - - -def _count_skipped_supports( - rows: npt.NDArray[np.uint8], - pivots: npt.NDArray[np.int_], - word: npt.NDArray[np.uint8], - skip_bound: int, - block_bits: int, - last_layer: int, -) -> int: - """Count the supports that tables skip in the layers from two to ``last_layer`` of a set. - - ``rows`` are the rows of the set, ``pivots`` their pivot columns, padded with -1 beyond the - rank, and ``word`` the word of a syndrome, which the rows of its pivot ones reduce to the - offset. A support of a layer is skipped if the pivot ones of the rows of its prefix below the - rank, the fewest pivot ones of a pair of rows from its row ``s`` on, and the least weight of the - tail of such a completion on each block of columns add up to at least ``skip_bound``. The tails - hold the columns other than the pivots, and the blocks cover them up to the last on which a row - has a one. A set whose tails have no ones has no tables, and skips nothing. - """ - rank = int(np.sum(pivots >= 0)) - offset = (word + rows[[row for row in range(rank) if word[pivots[row]]]].sum(axis=0)) % 2 - columns = np.delete(np.arange(rows.shape[1]), pivots[:rank]) - tails, offset_tail = rows[:, columns], offset[columns] - num_bits = int(np.flatnonzero(tails.any(axis=0)).max(initial=-1)) + 1 - tails, offset_tail = tails[:, :num_bits], offset_tail[:num_bits] - skipped = 0 - for layer in range(2, last_layer + 1): - for support in itertools.combinations(range(len(rows)), layer): - prefix = (offset_tail + tails[list(support[:-2])].sum(axis=0)) % 2 - pairs = list(itertools.combinations(range(support[-2], len(rows)), 2)) - bound = sum(row < rank for row in support[:-2]) - bound += min(sum(row < rank for row in pair) for pair in pairs) - bound += sum( - min( - int(((prefix + tails[i] + tails[j]) % 2)[start : start + block_bits].sum()) - for i, j in pairs - ) - for start in range(0, num_bits, block_bits) - ) - if num_bits and bound >= skip_bound: - skipped += 1 - return skipped - - -@pytest.mark.parametrize("block_bits", [None, 4]) -def test_searches_by_tails_answer_as_searches_of_whole_words( - block_bits: int | None, pytestconfig: pytest.Config -) -> None: - """Coset searches by tails decide as searches of whole words, and match them without tables. - - The searches by tails weigh a word by its rows below the rank of its set and its tail, which - holds every column but the pivot columns of the set. Without completion tables, they visit the - same words, and keep the same lightest word, as the searches of whole words (see - ``_assert_searches_by_tails_match_searches_of_words``). With tables on blocks of four columns, - they skip words beyond the maximum weight, which changes only the answers to cosets without a - word within it. Blocks of as many columns as the generators have rows alternate with blocks of - one column fewer, whose sets are rank deficient, so that the tails of the sets differ in their - numbers of columns. The random codes have a column of zeros, which a coset can still have ones - on. With tables, each is also made even by a column of the parities of its rows, so that the - searches round their bounds to the parity of each coset. A rank-deficient code made even in the - same way is searched too. The searches of every syndrome of these codes, at every maximum - weight up to past their length, agree with the minima of the cosets. A wide code begins with an - information set, whose tails fit in one machine word, while the tails of its rank-deficient sets - take two, and a sample of its syndromes is searched. The words of the Pauli errors of a graph - state that is not CSS are weighed by their physical weight, and with tables, its family has sets - of ranks two and one. With tables, the searches of the even rank-deficient code, the wide code, - and the graph state skip words; those of small random codes need not. - """ - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - # The arguments of each check, and whether its searches with tables must skip words. - searches: list[tuple[tuple[Any, ...], bool]] = [] - for _ in range(3): - num_rows, num_columns = int(rng.integers(3, 5)), int(rng.integers(6, 10)) - generators = get_random_full_rank_matrix(rng, num_rows, num_columns) - code = np.insert(generators, int(rng.integers(num_columns + 1)), 0, axis=1) - codes = [code, np.hstack([code, code.sum(axis=1, keepdims=True) % 2]).astype(np.uint8)] - for code in codes if block_bits else codes[:1]: - checks = get_kernel(code) - searches.append( - ( - ( - code, - _coset_leader_search._get_inverse(checks), - 1, - (num_rows, num_rows - 1), - range(-1, code.shape[1] + 2), - range(1 << len(checks)), - _classical_minima(checks), - ), - False, - ) - ) - if block_bits: - parities = (_RANK_DEFICIENT_CODE.sum(axis=1, keepdims=True) % 2).astype(np.uint8) - even = np.hstack([_RANK_DEFICIENT_CODE, parities]) - checks = get_kernel(even) - searches.append( - ( - ( - even, - _coset_leader_search._get_inverse(checks), - 1, - (5, 4), - range(-1, even.shape[1] + 2), - range(1 << len(checks)), - _classical_minima(checks), - ), - True, - ) - ) - - wide = np.hstack([np.eye(4, dtype=np.uint8), rng.integers(0, 2, (4, 64), dtype=np.uint8)]) - checks = get_kernel(wide) - syndromes = [int.from_bytes(rng.bytes(9), "little") % (1 << len(checks)) for _ in range(16)] - searches.append( - ( - ( - wide, - _coset_leader_search._get_inverse(checks), - 1, - (4, 3), - range(-1, 40, 4), - syndromes, - ), - True, - ) - ) - - matrix = _graph_state(4, [(0, 1), (1, 2)], phase=True) - checks = (math.symplectic_conjugate(matrix.astype(int)) % 2).astype(np.uint8) - searches.append( - ( - ( - symplectic_to_hamming(matrix), - symplectic_to_hamming(_coset_leader_search._get_inverse(checks)), - 2, - (2, 1) if block_bits else (4, 3), - range(-1, 6), - range(16), - _css_minima(matrix, css_weight=False), - ), - True, - ) - ) - for arguments, must_skip in searches: - skipped = _assert_searches_by_tails_match_searches_of_words( - *arguments, block_bits=block_bits - ) - assert skipped or not (block_bits and must_skip) - - -def test_queries_of_routes_by_tails_weigh_the_tails_of_their_record( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Single queries and batches of routes by tails weigh words by the tails of their record. - - At the threshold zero, every route of the classical search of the repetition code of length - seven is by tails. A record whose tails are all zero makes every offset weigh nothing, so the - queries at maximum weight two find a light enough word in every coset, even in the cosets whose - words weigh at least three, which a search of whole words would reject. - """ - get_coset_tails = _coset_leader_search._get_coset_tails - - def get_zero_tails(*args: Any, **kwargs: Any) -> _coset_leader_search._CosetTails: - """The record of the tails of a family, with every tail zero.""" - record = get_coset_tails(*args, **kwargs) - return record._replace( - tails=np.zeros_like(record.tails), inverse_tails=np.zeros_like(record.inverse_tails) - ) - - monkeypatch.setattr(_coset_leader_search, "_get_coset_tails", get_zero_tails) - generators = np.ones((1, 7), dtype=np.uint8) - checks = get_kernel(generators) - tuning = _coset_leader_search._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) - search = _coset_leader_search._get_classical_search( - generators, checks, tuning._replace(overlapping_minimum_work=0) - ) - syndromes = np.flatnonzero(_classical_minima(checks) > 2).tolist() - assert syndromes - for syndrome in syndromes: - bound = search.get_min_weight_bound(syndrome, 2) - assert (bound.within_max_weight, bound.lower_bound) == (True, 2) - packed_syndromes = _pack_syndromes(syndromes, len(checks)) - assert search.find_first_not_within_max_weight(packed_syndromes, 2) == (len(syndromes), None) - - -def test_queries_of_routes_by_tails_skip_completions_by_the_tables_of_their_record( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Single queries and batches of routes by tails skip completions by the tables of their record. - - At the threshold zero, every route of the classical search of a random code is by tails. A - record whose tables give every completion the bound 255 makes each query skip every completion - in its layers of two or more rows. At maximum weight three, some coset then gets no word within - that weight, although it has one. The searches skipped words, so single queries and batches - bound such cosets by four. The code is not even, so four is the least weight beyond the maximum - weight that a word of such a coset can have. - """ - get_coset_tails = _coset_leader_search._get_coset_tails - - def get_skipping_tails(*args: Any, **kwargs: Any) -> _coset_leader_search._CosetTails: - """The record of the tails of a family, with tables that skip every completion.""" - record = get_coset_tails(*args, **kwargs) - return record._replace(completion_tables=np.full_like(record.completion_tables, 255)) - - monkeypatch.setattr(_coset_leader_search, "_get_coset_tails", get_skipping_tails) - checks = get_kernel(OVERLAPPING_ROUTES_CODE) - tuning = _coset_leader_search._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) - search = _coset_leader_search._get_classical_search( - OVERLAPPING_ROUTES_CODE, checks, tuning._replace(overlapping_minimum_work=0) - ) - minima = _classical_minima(checks) - missed = [ - syndrome - for syndrome in np.flatnonzero(minima <= 3).tolist() - if not search.get_min_weight_bound(syndrome, 3).within_max_weight - ] - assert missed - assert {search.get_min_weight_bound(syndrome, 3).lower_bound for syndrome in missed} == {4} - _, bound = search.find_first_not_within_max_weight(_pack_syndromes(missed, len(checks)), 3) - assert bound is not None and bound.lower_bound == 4 - - -def test_records_of_tails_add_the_tables_of_the_sets_that_fit() -> None: - """A record of tails gains the tables of the given sets that fit its limit, once for each set. - - The rank-deficient code has disjoint information sets of ranks five and four, whose tails of - five and six columns have tables of 128 and 256 bytes on blocks of eight columns. Under a limit - of 128 bytes, only the first set gets tables, in the layout of the record, and a second request - for both sets keeps the record. Under a larger limit, the second set gets its tables too in a - new record, and the first set keeps its own, while the earlier record stays as it was. A record - built under that limit gets the tables of both sets at once. Each record holds the tables of - each set once. - """ - checks = get_kernel(_RANK_DEFICIENT_CODE) - instance = affine_instance.get_coset_instance( - _RANK_DEFICIENT_CODE, random_column_orders=0, weight_factor=1 - ) - assert instance.ranks.tolist() == [5, 4] - inverse = _coset_leader_search._get_inverse(checks) - - def get_record( - sets: list[int], record: _coset_leader_search._CosetTails | None, max_table_bytes: int - ) -> _coset_leader_search._CosetTails: - """The record of the tails of the family, with the tables of the sets that fit.""" - return _coset_leader_search._get_coset_tails( - instance, sets, record, inverse=inverse, block_bits=8, max_table_bytes=max_table_bytes - ) - - def get_set_tables(record: _coset_leader_search._CosetTails, set_index: int) -> list[int]: - """The tables of a set, as its layout places them in the record.""" - start, num_blocks = record.layout[set_index, :2] - offsets = record.layout[set_index, 2 : num_blocks + 3] - size = (len(_RANK_DEFICIENT_CODE) - 1) * offsets[-1] if num_blocks else 0 - assert start + size <= len(record.completion_tables) - return record.completion_tables[start : start + size].tolist() - - expected = [ - completion_bounds.CompletionTables.build(tails, 8) - for tails in get_record([], None, 0).tails - ] - first = get_record([0, 1], None, 128) - assert get_set_tables(first, 0) == expected[0].tables.tolist() - assert get_set_tables(first, 1) == [] - assert get_record([0, 1], first, 128) is first - for both in (get_record([1], first, 256), get_record([0, 1], None, 256)): - assert [get_set_tables(both, index) for index in range(2)] == [ - tables.tables.tolist() for tables in expected - ] - assert len(both.completion_tables) == sum(len(tables.tables) for tables in expected) - assert get_set_tables(first, 1) == [] - - -def _coset_minima( - generators: npt.NDArray[np.uint8], checks: npt.NDArray[np.uint8], syndromes: Sequence[int] -) -> npt.NDArray[np.int_]: - """The least weight of the words of the coset of each syndrome, from every word of the coset. - - A word with the syndrome sums rows of a right inverse of the checks, from the module under test, - so the helper checks the syndromes of these words. The coset adds to such a word every word of - the code of ``generators``. - """ - code = np.array(list(itertools.product([0, 1], repeat=len(generators)))) @ generators % 2 - bits = (np.array(syndromes)[:, None] >> np.arange(len(checks))) & 1 - words = bits @ _coset_leader_search._get_inverse(checks) % 2 - assert np.array_equal(_classical_syndrome(checks, words), syndromes) - return np.array([((word + code) % 2).sum(axis=1).min() for word in words]) - - -def _assert_min_weights_match_brute_force( - search: _coset_leader_search._CosetSearch, - syndromes: Sequence[int], - minima: npt.NDArray[np.int_], - num_syndrome_bits: int, - rng: np.random.Generator, -) -> None: - """Check the minimum weights that a search finds for cosets against their least weights. - - One batch holds the syndromes, in random order, and ``minima`` holds the least weight of the - coset of each. At each maximum weight from zero to one past the largest least weight, the - floors of the batch are zero, then random up to the maximum weight, and then that weight. A - coset whose least weight is at most its floor gets a weight from its least weight to its floor. - Any other coset gets its least weight, or one more than the maximum weight if that is smaller. - """ - order = rng.permutation(len(syndromes)) - packed_syndromes = _pack_syndromes([syndromes[index] for index in order], num_syndrome_bits) - least = minima[order] - for max_weight in range(int(least.max()) + 2): - random_floors = rng.integers(0, max_weight + 1, len(least)) - for floors in (np.zeros_like(least), random_floors, np.full_like(least, max_weight)): - weights = search.get_min_weights(packed_syndromes, max_weight, floors.astype(np.int64)) - exact = least > floors - assert np.array_equal(weights[exact], np.minimum(least, max_weight + 1)[exact]) - assert np.all(exact | ((least <= weights) & (weights <= floors))) - - -@pytest.mark.parametrize( - "tuning", - [ - {}, - {"overlapping_minimum_work": 0}, - {"overlapping_minimum_work": 0, "completion_bound_max_table_bytes": 0}, - ], - ids=["disjoint", "tables", "no-tables"], -) -def test_min_weights_of_cosets_match_brute_force( - tuning: dict[str, int], pytestconfig: pytest.Config, tune: Tune -) -> None: - """Searches for the minimum weights of cosets agree with the least weights of their words. - - The codes are the rank-deficient code, a code whose searches skip words by tables, and a random - code, each also made even by a column of the parities of its rows. The searches of the even - codes round to the parity of each coset, and their incumbents are one below the Hamming weight - of their best words. The cosets have both parities. Three cosets of a longer code are - searched too. Their searches by tails stop early within steps. The searches take the disjoint - family of each code, and at the threshold zero other families by tails, with completion tables - and without them (see ``_assert_min_weights_match_brute_force``). The least weights come from - every word of each coset. - """ - tune(**tuning) - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - tuning_fields = _coset_leader_search._CosetTuning.from_tuning( - get_tuning(), paulis_by_css_weight=False - ) - num_rows, num_columns = int(rng.integers(3, 6)), int(rng.integers(8, 11)) - random_code = get_random_full_rank_matrix(rng, num_rows, num_columns) - for code in (_RANK_DEFICIENT_CODE, _SKIPPING_CODE, random_code): - even_code = np.hstack([code, code.sum(axis=1, keepdims=True) % 2]).astype(np.uint8) - for generators in (code, even_code): - checks = get_kernel(generators) - search = _coset_leader_search._get_classical_search(generators, checks, tuning_fields) - syndromes = range(1 << len(checks)) - minima = _coset_minima(generators, checks, syndromes) - _assert_min_weights_match_brute_force(search, syndromes, minima, len(checks), rng) - checks = get_kernel(_HEAVY_COSETS_CODE) - search = _coset_leader_search._get_classical_search(_HEAVY_COSETS_CODE, checks, tuning_fields) - early_stop_syndromes = [1126, 4578, 6129] - minima = _coset_minima(_HEAVY_COSETS_CODE, checks, early_stop_syndromes) - _assert_min_weights_match_brute_force(search, early_stop_syndromes, minima, len(checks), rng) - - @pytest.mark.parametrize( "tuning", [ @@ -1931,257 +556,6 @@ def test_min_weights_match_exhaustive_minima( assert np.array_equal(weights, np.minimum(minima[syndromes], max_weight + 1)) -@pytest.mark.parametrize("tuning", [{}, {"overlapping_minimum_work": 0}], ids=["words", "tails"]) -def test_min_weight_searches_above_max_weight_examine_as_queries( - tuning: dict[str, int], tune: Tune -) -> None: - """A search for the minimum weight of a coset without a word within a weight works as a query. - - Until it finds a word within ``max_weight``, the search keeps the skip bound of ``max_weight`` - as its incumbent, at which a query at ``max_weight`` skips completions too. It stops at the - stopping step of that query, so it weighs and skips the same words. A code and its even version - are searched at every syndrome, a longer code at three syndromes of minimum weight six, and the - Pauli errors of a graph state by physical weight at every syndrome. They are searched by whole - words, and at the threshold zero by tails, with tables, which skip words of each of the three - codes but need not skip any of the graph state. The minimum weights come from every word of - each coset. - """ - tune(**tuning) - tuning_fields = _coset_leader_search._CosetTuning.from_tuning( - get_tuning(), paulis_by_css_weight=False - ) - cases = [] - parities = (_SKIPPING_CODE.sum(axis=1, keepdims=True) % 2).astype(np.uint8) - for generators, chosen_syndromes in [ - (_SKIPPING_CODE, None), - (np.hstack([_SKIPPING_CODE, parities]), None), - (_HEAVY_COSETS_CODE, [3571, 3722, 7870]), - ]: - checks = get_kernel(generators) - syndromes = chosen_syndromes or list(range(1 << len(checks))) - search = _coset_leader_search._get_classical_search(generators, checks, tuning_fields) - minima = _coset_minima(generators, checks, syndromes) - cases.append((search, len(checks), syndromes, minima, True)) - matrix = _ROUTED_PHYSICAL_STATES[1] - checks = (math.symplectic_conjugate(matrix.astype(int)) % 2).astype(np.uint8) - search = _coset_leader_search._get_pauli_search( - _coset_leader_search._StabilizerKey(matrix, checks), False, tuning_fields - ) - syndromes = list(range(1 << len(matrix))) - cases.append((search, len(matrix), syndromes, _css_minima(matrix, css_weight=False), False)) - - for search, num_syndrome_bits, syndromes, minima, must_skip in cases: - skipped = 0 - for max_weight in range(2, int(max(minima))): - tables, tails = search._routes.get_route(max_weight) - for syndrome, minimum in zip(syndromes, minima, strict=True): - if minimum <= max_weight: - continue - packed_syndrome = _pack_syndromes([syndrome], num_syndrome_bits)[0] - best = np.empty(tables.inverse.shape[1], dtype=np.uint64) - best_weight, parity = _coset_leader_search._get_syndrome_word( - packed_syndrome, best, tables.inverse, tables.x_mask, search.scale - ) - stop_layer, stop_num_completed, stop_bound = ( - _coset_leader_search._get_stopping_step( - _coset_leader_search._get_planning_table(tables.planning, parity), - max_weight, - ) - ) - _, *expected = _coset_leader_search._search_from_syndrome_word( - packed_syndrome, - max_weight, - stop_layer, - stop_num_completed, - stop_bound, - best, - best_weight, - tables.generators, - tables.pivots, - tables.x_mask, - tails, - search.scale, - parity if len(tables.planning) == 2 else -1, - ) - _, *found = _coset_leader_search._search_coset_for_min_weight( - packed_syndrome, max_weight, 0, best, *tables, tails, search.scale - ) - skipped += expected[1] - assert found == expected - assert skipped or not (tuning and must_skip) - - -def _product_state(generators: Sequence[str]) -> npt.NDArray[np.uint8]: - """Stabilizer matrix [X|Z] of the given Pauli strings.""" - return _stabilizer_matrix([stim.PauliString(pauli) for pauli in generators]) - - -def _assert_exact_answers( - oracle: CosetLeaderSearch, matrix: npt.NDArray[np.uint8], css_weight: bool -) -> None: - """Check every query of an oracle against exhaustive minima of the state of ``matrix``.""" - num_qubits = len(matrix) - minima = _css_minima(matrix, css_weight) - for syndrome, max_weight in itertools.product(range(1 << num_qubits), range(num_qubits + 1)): - bound = oracle.get_min_weight_bound(syndrome, max_weight) - weight = _pauli_weight(bound.representative, num_qubits, css_weight) - assert bound.within_max_weight == (minima[syndrome] <= max_weight) - assert _get_syndrome(matrix, bound.representative) == syndrome - assert bound.lower_bound <= minima[syndrome] <= weight - assert weight <= max_weight if bound.within_max_weight else bound.lower_bound > max_weight - - -def test_connected_support_answers_match_exhaustive_minima(pytestconfig: pytest.Config) -> None: - """The connected-support engine answers every query exactly, by either measure of weight. - - Graph states with phases are not CSS, and are weighed by physical weight. CSS states with - no pure-X stabilizer, with only pure-X stabilizers, and with both are weighed by both measures, - and the CSS weight splits their searches. A syndrome whose lightest errors weigh at most one - gets their weight as its bound. Otherwise a query within ``max_weight`` gets 2, and one with no - error within ``max_weight`` gets ``max(2, max_weight + 1)``. The queries of batches, which try - increasing weight limits, instead get the exact minimum and a lightest error when they are - within ``max_weight``. These exact bounds imply the inequalities that ``_assert_exact_answers`` - checks. A search of Pauli errors from a first weight limit above ``max_weight`` answers as a - single query. - """ - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - for num_qubits in range(1, 6): - edges = [pair for pair in itertools.combinations(range(num_qubits), 2) if rng.integers(2)] - graph_state = _graph_state(num_qubits, edges, phase=True) - num_x_stabilizers = [0, num_qubits, num_qubits // 2][num_qubits % 3] - css_state = _random_css_state(num_qubits, num_x_stabilizers, rng) - for matrix, css_weight in [(graph_state, False), (css_state, False), (css_state, True)]: - oracle = CosetLeaderSearch(matrix, css_weight=css_weight, method="connected_support") - minima = _css_minima(matrix, css_weight) - for syndrome, max_weight in itertools.product( - range(1 << num_qubits), range(-1, num_qubits + 1) - ): - minimum = int(minima[syndrome]) - single = oracle.get_min_weight_bound(syndrome, max_weight) - batched = oracle._get_batch_support_bound(syndrome, max_weight) - if not css_weight: - search = oracle._support_searches[0] - late = search.get_min_weight_bound( - syndrome, max_weight, first_limit=max_weight + 1 - ) - assert late == single - for bound, exact in [(single, False), (batched, True)]: - weight = _pauli_weight(bound.representative, num_qubits, css_weight) - assert _get_syndrome(matrix, bound.representative) == syndrome - assert bound.within_max_weight == (minimum <= max_weight) - if not bound.within_max_weight: - expected_bound = minimum if minimum < 2 else max(2, max_weight + 1) - assert bound.lower_bound == expected_bound - elif exact or minimum < 2: - assert bound.lower_bound == weight == minimum - else: - assert bound.lower_bound == 2 and weight <= max_weight - - -def test_connected_support_batches_read_syndromes_of_several_words( - pytestconfig: pytest.Config, tune: Tune -) -> None: - """On 17 phased GHZ blocks, batches of the connected-support engine agree with block minima. - - The 68 syndrome bits span two words, and the physical weight takes one search of the Pauli - errors. The batch holds the syndromes of 30 errors. The first acts on one qubit in each of - blocks 0, 8, and 16. Block 16 has its syndrome bits in the second word, so a search that read - only the first word would find a lighter error than the minimum of three. The other errors each - act on one to three random qubits, and blocks that an error does not touch add nothing to its - minimum weight. The oracle looks for sparser checks for one round, rather than for the many - rounds that are slow as plain Python. On these blocks, one round gives the default checks. - """ - tune(max_sparsifying_rounds=1) - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - num_blocks = 17 - generators = _block_generators(_PHASED_GHZ_BLOCK, num_blocks) - matrix = _stabilizer_matrix(generators) - oracle = CosetLeaderSearch(matrix, method="connected_support") - syndromes, minima = [], [] - for error_index in range(30): - error = stim.PauliString(4 * num_blocks) - qubits = ( - [0, 35, 67] - if error_index == 0 - else rng.choice(4 * num_blocks, size=rng.integers(1, 4), replace=False) - ) - for qubit in qubits: - error[int(qubit)] = int(rng.integers(1, 4)) - syndromes.append(sum((not error.commutes(gg)) << jj for jj, gg in enumerate(generators))) - bits = [(syndromes[-1] >> jj) & 1 for jj in range(4 * num_blocks)] - local = [bits[4 * index : 4 * index + 4] for index in range(num_blocks)] - minima.append(_block_minimum(_PHASED_GHZ_BLOCK, [ss for ss in local if any(ss)], False)) - assert syndromes[0] >> 64 and minima[0] == 3 - words = _pack_syndromes(syndromes, 68) - for max_weight in range(4): - found = _find_each_not_within_max_weight(oracle, matrix, words, max_weight) - assert found == np.flatnonzero(np.array(minima) > max_weight).tolist() - - -def test_sparser_rows_span_the_same_space(pytestconfig: pytest.Config) -> None: - """Adding rows to each other leaves rows of the same span, which no such addition makes lighter. - - The weight of a symplectic row counts the qubits on which it acts, whether by X, Y, or Z. - """ - - def get_weights(rows: npt.NDArray[np.uint8], homogeneous: bool) -> npt.NDArray[np.int_]: - if homogeneous: - return rows.sum(axis=1) - half = rows.shape[1] // 2 - return (rows[:, :half] | rows[:, half:]).sum(axis=1) - - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - cases = [] - for homogeneous in (True, False): - for _ in range(10): - matrix = rng.integers(2, size=(6, 8), dtype=np.uint8) - cases.append((matrix[row_reduce_binary(matrix.T.copy())[2]], homogeneous)) - for matrix, homogeneous in cases: - rows, transform = _coset_leader_search._sparsify(matrix, homogeneous=homogeneous) - assert np.array_equal(transform @ matrix % 2, rows) - assert len(row_reduce_binary(transform)[2]) == len(matrix) - weights = get_weights(rows, homogeneous) - for row, other in itertools.permutations(range(len(rows)), 2): - assert get_weights((rows[row] ^ rows[other])[None], homogeneous)[0] >= weights[row] - - -def test_rounds_of_random_reductions_make_rows_sparser() -> None: - """Rounds reach a lightest basis, which adding rows to each other misses. - - Three independent words of the span of these Paulis on five qubits act on three qubits each, so - they form a lightest basis, of total weight 9. Adding rows to each other replaces the first row - with its sum with the second, which is one of them, and then no sum of two rows is lighter than - a row, so zero rounds give total weight 10. Another of the three words has two Ys, so it counts - five bits, and rows weighed by bits would miss it. - """ - matrix = np.array( - [ - [0, 1, 1, 0, 1, 1, 0, 0, 0, 1], - [0, 0, 0, 1, 1, 1, 0, 0, 0, 1], - [1, 0, 1, 0, 1, 0, 1, 0, 0, 0], - ], - dtype=np.uint8, - ) - for max_rounds, total_weight in [(0, 10), (20, 9)]: - rows, transform = _coset_leader_search._get_sparser_rows( - matrix, homogeneous=False, max_rounds=max_rounds, rounds_without_improvement=20 - ) - assert np.array_equal(transform @ matrix % 2, rows) - assert len(row_reduce_binary(transform)[2]) == len(matrix) - assert (rows[:, :5] | rows[:, 5:]).sum() == total_weight - - -def test_connected_support_rejects_what_it_cannot_search() -> None: - """Oracles reject unknown methods, and the CSS weight on non-CSS states by connected support.""" - with pytest.raises(ValueError, match="only on CSS states"): - CosetLeaderSearch( - _graph_state(2, [(0, 1)], phase=True), css_weight=True, method="connected_support" - ) - method: Any = "brute_force" - with pytest.raises(ValueError, match="Unknown coset-leader search method"): - CosetLeaderSearch([[1, 0]], method=method) - - @pytest.mark.parametrize("method", ["brouwer_zimmermann", "connected_support"]) def test_batches_reject_syndromes_out_of_range(method: CosetSearchMethod) -> None: """Batches of either method reject a syndrome at or above ``2**num_qubits``. diff --git a/src/qldpc/codes/distance/_cosets.py b/src/qldpc/codes/distance/_cosets.py new file mode 100644 index 000000000..316d7bcca --- /dev/null +++ b/src/qldpc/codes/distance/_cosets.py @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Parts that the coset searches of ``CosetLeaderSearch`` share. + +They are the answers of coset queries (:class:`MinWeightBound`), the inverses of checks that turn +syndromes into words, the packing of binary rows into integers and back, the sums of the rows that +a support selects, and the tables of the errors of weight zero or one by syndrome. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +import numpy as np +import numpy.typing as npt + +from qldpc._binary import pack_bits, row_reduce_binary, trailing_zeros_word +from qldpc._compilation import always_compiled + + +@dataclass(frozen=True) +class MinWeightBound: + """The answer to a query about the minimum weight of a coset. + + A query of ``CosetLeaderSearch`` asks whether a syndrome class of Pauli errors, which is a coset + of the stabilizer group, has an error of weight at most ``max_weight``. A query of one of its + coset searches asks the same about a coset of the code that the search covers. Which element + and which lower bound a query returns can depend on the method and tuning of the search, and on + the version of qLDPC, within the guarantees below. + + Attributes: + within_max_weight: Whether the coset has an element of weight at most ``max_weight``. + representative: An element of the coset: a packed Pauli (see ``CosetLeaderSearch``) for a + query of the oracle, and a word of the search (a packed Pauli, or an X or Z part) for a + query of a coset search. If ``within_max_weight`` is True, its weight is at most + ``max_weight``. + lower_bound: A lower bound on the minimum weight of the coset. If ``within_max_weight`` is + False, it exceeds ``max_weight``. + """ + + within_max_weight: bool + representative: int + lower_bound: int + + +def _get_inverse(checks: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint8]: + """A right inverse of independent checks: row ``j`` has a syndrome of only bit ``j``.""" + _, labels, pivots = row_reduce_binary(checks, np.eye(len(checks), dtype=np.uint8)) + assert labels is not None + inverse = np.zeros(checks.shape, dtype=np.uint8) + inverse[:, pivots] = labels.T + return inverse + + +def _row_ints(matrix: npt.NDArray[np.uint8]) -> tuple[int, ...]: + """Pack each row of a binary matrix into an integer, with entry ``j`` in bit ``j``.""" + return tuple(map(_packed_row_int, pack_bits(matrix))) + + +def _packed_row_int(row: npt.NDArray[np.uint64]) -> int: + """Convert a row of 64-bit words into an integer, with word ``j`` in bits ``64 j`` and up.""" + return sum(int(word) << (64 * jj) for jj, word in enumerate(row)) + + +def _int_to_words(value: int, width: int) -> npt.NDArray[np.uint64]: + """Split an integer into ``width`` 64-bit words, with word ``j`` in bits ``64 j`` and up.""" + return np.frombuffer(value.to_bytes(8 * width, "little"), dtype=" tuple[dict[int, int], dict[int, int], dict[int, int]]: + """Tabulate the words of weight zero or one by syndrome, and the single-qubit X and Z errors. + + The words are the identity and, for packed Paulis, X, Z, and Y on each qubit, and for X or Z + parts, the single bits. The syndrome of Y on a qubit is the sum of those of X and Z there. + Each table maps each syndrome of its words to the first of them, in the order of the identity, + and then of the qubits, with X before Z before Y on each, or of the bits. + + Args: + check_columns: Column ``c`` of the checks as an integer: the syndrome of the word with only + bit ``c`` set. + num_qubits: The number of qubits ``n`` of packed Paulis, which have ``2 n`` bits; unused for + X or Z parts. + is_pauli: Whether the words are packed Paulis, rather than X or Z parts. + + Returns: + The table of these words, and the tables of the identity and the single-qubit X errors, and + of the identity and the single-qubit Z errors. For X or Z parts, the last two hold only the + identity. + """ + singles, x_singles, z_singles = {0: 0}, {0: 0}, {0: 0} + if not is_pauli: + for column, syndrome in enumerate(check_columns): + singles.setdefault(syndrome, 1 << column) + return singles, x_singles, z_singles + for qubit in range(num_qubits): + x, z = 1 << qubit, 1 << (qubit + num_qubits) + sx, sz = check_columns[qubit], check_columns[qubit + num_qubits] + x_singles.setdefault(sx, x) + z_singles.setdefault(sz, z) + singles.setdefault(sx, x) + singles.setdefault(sz, z) + singles.setdefault(sx ^ sz, x ^ z) + return singles, x_singles, z_singles + + +def _xor_selected(rows: Sequence[int], support: int) -> int: + """XOR together the rows whose indices are the set bits of ``support``.""" + word = 0 + while support: + # Add the row for the lowest set bit, and then clear that bit. + low = support & -support + word ^= rows[low.bit_length() - 1] + support ^= low + return word + + +@always_compiled(inline=True) +def _xor_selected_rows( + packed_bits: npt.NDArray[np.uint64], rows: npt.NDArray[np.uint64], word: npt.NDArray[np.uint64] +) -> None: + """Add to ``word`` row ``b`` of ``rows`` for each set bit ``b`` of ``packed_bits``. + + ``packed_bits`` is split into 64-bit machine words, with machine word ``j`` holding bits + ``64 j`` and up, and each set bit must be below the number of rows; this is not checked. + """ + for machine_word in range(len(packed_bits)): + bits = packed_bits[machine_word] + while bits: + bit = 64 * machine_word + trailing_zeros_word(bits) + for index in range(len(word)): + word[index] ^= rows[bit, index] + bits &= bits - np.uint64(1) diff --git a/src/qldpc/codes/distance/conftest.py b/src/qldpc/codes/distance/conftest.py index 4550f8466..fddee6687 100644 --- a/src/qldpc/codes/distance/conftest.py +++ b/src/qldpc/codes/distance/conftest.py @@ -3,21 +3,23 @@ """Shared fixtures and helpers for exact-distance, binary-algebra, BZ, and count tests.""" import itertools -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from typing import Any, NamedTuple import galois import numpy as np import numpy.typing as npt import pytest +import stim -from qldpc import _compilation, codes -from qldpc._binary import get_independent_rows, get_kernel +from qldpc import _compilation, codes, math +from qldpc._binary import get_independent_rows, get_kernel, row_reduce_binary from qldpc.objects import Pauli, PauliXZ from ..conftest import Tune from . import logical_counts as lc from ._binary import get_nested_code_basis +from ._coset_leader_search import CosetLeaderSearch from ._information_sets import basis_families, certificates @@ -242,3 +244,207 @@ def assert_counts_match_enumeration(counter: lc.CSSLogicalCounter, **options: An # {2,4}, {0,3} and {1,3}. The Z word {2,4} also supports the X stabilizer {2,4}, so in total there # are 2 + 3 + 1 = 6 logical Pauli operators of weight two. HAND_X, HAND_Z = get_bits([31, 20], 5), get_bits([30, 3], 5) + + +def _stabilizer_matrix(generators: Sequence[stim.PauliString]) -> npt.NDArray[np.uint8]: + """Stabilizer matrix [X|Z] with the given Pauli strings as rows.""" + return np.array([np.concatenate(gg.to_numpy()) for gg in generators], dtype=np.uint8) + + +def _graph_state( + num_qubits: int, edges: Sequence[tuple[int, int]], phase: bool = False +) -> npt.NDArray[np.uint8]: + """Stabilizer matrix [X|Z] of a graph state, with S gates on every qubit if ``phase``. + + Generator j acts as X (or Y) on qubit j, so the X parts of the generators form the identity. + """ + circuit = stim.Circuit() + circuit.append("H", range(num_qubits)) + for edge in edges: + circuit.append("CZ", edge) + if phase: + circuit.append("S", range(num_qubits)) + return _stabilizer_matrix(stim.Tableau.from_circuit(circuit).to_stabilizers()) + + +def _pauli_weight(pauli: int, num_qubits: int, css_weight: bool) -> int: + """Weight of a packed Pauli, with X in the low bits and Z in the next block.""" + x, z = pauli & ((1 << num_qubits) - 1), pauli >> num_qubits + return (x | z).bit_count() if not css_weight else max(x.bit_count(), z.bit_count()) + + +def _get_syndrome(matrix: npt.NDArray[np.uint8], pauli: int) -> int: + """Syndrome of a packed Pauli: its symplectic product with each row of the stabilizer matrix.""" + bits = np.array([(pauli >> jj) & 1 for jj in range(matrix.shape[1])]) + products = matrix @ math.symplectic_conjugate(bits) % 2 + return sum(int(bit) << jj for jj, bit in enumerate(products)) + + +def _pack_syndromes(syndromes: Sequence[int], num_qubits: int) -> npt.NDArray[np.uint64]: + """Split packed syndromes into rows of 64-bit words, with word ``j`` in bits ``64 j`` and up.""" + width = (num_qubits + 63) // 64 + words = [[(syndrome >> (64 * jj)) % 2**64 for jj in range(width)] for syndrome in syndromes] + return np.array(words, dtype=np.uint64).reshape(len(syndromes), width) + + +_PHASED_GHZ_BLOCK = ("YXXX", "ZZ__", "_ZZ_", "__ZZ") + +# Graph states whose searches of Pauli errors, by physical and by CSS weight, have a maximum +# disjoint family with more information sets, or a larger partial set, than their greedy packing, +# which their costly queries take (see ``coset_routes``). The family of the first state for the +# physical weight has more information sets, and those of the other two states have a larger partial +# set. The two states for the physical weight differ in the answers that this family changes. On +# the second, it returns other errors for some classes without a light enough error. +_ROUTED_PHYSICAL_STATES = ( + _graph_state(5, [(0, 3), (1, 2), (1, 3), (1, 4)], phase=True), + _graph_state(5, [(0, 4), (2, 3)]), +) + +_ROUTED_CSS_WEIGHT_STATE = _graph_state(5, [(0, 1), (0, 4), (1, 4)]) + + +def _find_each_not_within_max_weight( + oracle: CosetLeaderSearch, + matrix: npt.NDArray[np.uint8], + syndromes: npt.NDArray[np.uint64], + max_weight: int, +) -> list[int]: + """Find each syndrome whose class has no error of weight at most ``max_weight``, in turn. + + The representative of each bound that a search returns has the syndrome for the stabilizers of + ``matrix``, its lower bound exceeds ``max_weight``, and, for a maximum weight of at least two, + the bound equals that of ``get_min_weight_bound``. With one coset search, + ``find_first_not_within_max_weight`` computes its bound separately from + ``get_min_weight_bound``, so this checks one against the other. + """ + found: list[int] = [] + while True: + start = found[-1] + 1 if found else 0 + index, bound = oracle.find_first_not_within_max_weight(syndromes[start:], max_weight) + if bound is None: + assert start + index == len(syndromes) + return found + found.append(start + index) + syndrome = int.from_bytes(syndromes[found[-1]].astype(" max_weight + assert _get_syndrome(matrix, bound.representative) == syndrome + if max_weight >= 2: + assert bound == oracle.get_min_weight_bound(syndrome, max_weight) + + +def _random_css_state( + num_qubits: int, num_x_stabilizers: int, rng: np.random.Generator +) -> npt.NDArray[np.uint8]: + """Stabilizer matrix [X|Z] of a random CSS state, with generators that mix pure-X and pure-Z. + + The X parts of the pure-X stabilizers are random independent rows, and the Z parts of the pure-Z + stabilizers span their null space. The generators are random invertible combinations of these + stabilizers. + """ + while True: + x_rows = rng.integers(2, size=(num_x_stabilizers, num_qubits), dtype=np.uint8) + if len(row_reduce_binary(x_rows)[2]) == num_x_stabilizers: + break + pure = np.zeros((num_qubits, 2 * num_qubits), dtype=np.uint8) + pure[:num_x_stabilizers, :num_qubits] = x_rows + pure[num_x_stabilizers:, num_qubits:] = get_kernel(x_rows) + return _scramble(pure, rng) + + +def _scramble(matrix: npt.NDArray[np.uint8], rng: np.random.Generator) -> npt.NDArray[np.uint8]: + """Random invertible combinations of the rows of a stabilizer matrix, which generate its group. + + The combinations are a random permutation of an upper triangular matrix with ones on its + diagonal and random bits above it. + """ + size = len(matrix) + upper = np.triu(rng.integers(2, size=(size, size), dtype=np.uint8), 1) + np.eye( + size, dtype=np.uint8 + ) + return (upper[rng.permutation(size)] @ matrix) % 2 + + +def _css_minima(matrix: npt.NDArray[np.uint8], css_weight: bool = True) -> npt.NDArray[np.int_]: + """The smallest CSS (or physical) weight of an error with each packed syndrome. + + The minima come from all ``4**n`` Paulis. + """ + n = len(matrix) + errors = (np.arange(1 << (2 * n))[:, None] >> np.arange(2 * n)) & 1 + checks = math.symplectic_conjugate(matrix.astype(int)) + syndromes = (errors @ checks.T % 2) @ (1 << np.arange(n)) + x, z = errors[:, :n], errors[:, n:] + weights = np.maximum(x.sum(axis=1), z.sum(axis=1)) if css_weight else (x | z).sum(axis=1) + minima = np.full(1 << n, 2 * n + 1) + np.minimum.at(minima, syndromes, weights) + return minima + + +def _block_generators(block: Sequence[str], num_blocks: int) -> list[stim.PauliString]: + """Generators of independent copies of a block state, given the generators of one block.""" + size = len(block[0]) + return [ + stim.PauliString("_" * (size * index) + local + "_" * (size * (num_blocks - index - 1))) + for index in range(num_blocks) + for local in block + ] + + +def _block_minimum( + block: Sequence[str], local_syndromes: Sequence[Sequence[int]], css_weight: bool +) -> int: + """Minimum weight of an error on independent blocks with the given per-block syndromes. + + The syndrome bits of a block are the commutation with its generators, in order. Every Pauli on + one block is listed, and the blocks are combined by dynamic programming. + """ + size = len(block[0]) + stabilizers = [stim.PauliString(local) for local in block] + options: dict[tuple[int, ...], list[tuple[int, int, int]]] = {} + for paulis in itertools.product("_XYZ", repeat=size): + error = stim.PauliString("".join(paulis)) + syndrome = tuple(int(not error.commutes(ss)) for ss in stabilizers) + x_weight = sum(pp in "XY" for pp in paulis) + z_weight = sum(pp in "YZ" for pp in paulis) + options.setdefault(syndrome, []).append((x_weight, z_weight, size - paulis.count("_"))) + if not css_weight: + return sum( + min(support for _, _, support in options[tuple(local)]) for local in local_syndromes + ) + totals = {(0, 0)} + for local in local_syndromes: + totals = {(a + x, b + z) for a, b in totals for x, z, _ in options[tuple(local)]} + return min(max(a, b) for a, b in totals) + + +# A code whose disjoint information sets have ranks five and four. +_RANK_DEFICIENT_CODE = np.array( + [ + [0, 0, 1, 1, 0, 1, 1, 1, 0, 0], + [0, 0, 1, 1, 0, 0, 1, 1, 1, 1], + [1, 0, 1, 1, 0, 1, 0, 1, 1, 0], + [0, 1, 1, 0, 1, 1, 0, 1, 0, 1], + [1, 0, 1, 1, 0, 1, 1, 1, 0, 1], + ], + dtype=np.uint8, +) + + +def _product_state(generators: Sequence[str]) -> npt.NDArray[np.uint8]: + """Stabilizer matrix [X|Z] of the given Pauli strings.""" + return _stabilizer_matrix([stim.PauliString(pauli) for pauli in generators]) + + +def _assert_exact_answers( + oracle: CosetLeaderSearch, matrix: npt.NDArray[np.uint8], css_weight: bool +) -> None: + """Check every query of an oracle against exhaustive minima of the state of ``matrix``.""" + num_qubits = len(matrix) + minima = _css_minima(matrix, css_weight) + for syndrome, max_weight in itertools.product(range(1 << num_qubits), range(num_qubits + 1)): + bound = oracle.get_min_weight_bound(syndrome, max_weight) + weight = _pauli_weight(bound.representative, num_qubits, css_weight) + assert bound.within_max_weight == (minima[syndrome] <= max_weight) + assert _get_syndrome(matrix, bound.representative) == syndrome + assert bound.lower_bound <= minima[syndrome] <= weight + assert weight <= max_weight if bound.within_max_weight else bound.lower_bound > max_weight diff --git a/src/qldpc/codes/distance/layering_test.py b/src/qldpc/codes/distance/layering_test.py index f0282fbd5..067ba5d49 100644 --- a/src/qldpc/codes/distance/layering_test.py +++ b/src/qldpc/codes/distance/layering_test.py @@ -11,7 +11,7 @@ _LAYERS = ( ("_binary",), ("_tuning",), - ("_brute_force", "_information_sets"), + ("_brute_force", "_information_sets", "_cosets"), ("_brouwer_zimmermann", "_connected_support", "_counting"), ("_coset_leader_search", "core"), ("logical_counts",), From 2dea54360a9714dc8416192e2bee63fc9468e024 Mon Sep 17 00:00:00 2001 From: "Michael A. Perlin" Date: Thu, 12 Nov 2026 01:05:04 -0500 Subject: [PATCH 3/7] Cover the split modules from their own tests Each module must be fully covered by its own test file, and the splits left code that only other modules' tests reached. Add direct tests of the shared coset parts in `_cosets.py`, of the search tables and first steps in `coset_tables.py`, of BZ coset searches by CSS weight, of the certificate checks and work estimates for arrays in `certificates.py`, and of batches of connected-support searches by CSS weight. Two tests of `CosetLeaderSearch` itself move back to its test file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_brouwer_zimmermann/coset_tables_test.py | 51 +++++- .../_brouwer_zimmermann/coset_test.py | 12 +- .../distance/_connected_support/coset_test.py | 67 +------- .../distance/_coset_leader_search_test.py | 145 +++++++++++++++++- src/qldpc/codes/distance/_cosets_test.py | 74 +++++++++ .../_information_sets/certificates_test.py | 14 +- src/qldpc/codes/distance/conftest.py | 68 -------- 7 files changed, 288 insertions(+), 143 deletions(-) create mode 100644 src/qldpc/codes/distance/_cosets_test.py diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py index 937dfcf8d..c71b0d539 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py @@ -15,9 +15,7 @@ from .. import _cosets from .._information_sets import certificates as certificates_module from .._information_sets import completion_bounds -from ..conftest import ( - _RANK_DEFICIENT_CODE, -) +from ..conftest import _RANK_DEFICIENT_CODE, EVEN_ROUTES_CODE from . import affine_instance, coset_tables @@ -126,3 +124,50 @@ def get_set_tables(record: coset_tables._CosetTails, set_index: int) -> list[int ] assert len(both.completion_tables) == sum(len(tables.tables) for tables in expected) assert get_set_tables(first, 1) == [] + + +def test_search_tables_plan_by_parity_exactly_when_cosets_of_an_even_code_differ() -> None: + """A search plans with a table of bounds for each parity if its code is even and a coset odd. + + The words of a coset of an even code share a parity, and a word of each syndrome bit is a row + of the inverse, so some coset is odd exactly when one of those rows is. The even code with the + inverse of its checks has odd cosets, and plans with a table for each parity. With an inverse + of even rows, it plans with one table, and so does the rank-deficient code, which is not even. + That table serves cosets of either parity. + """ + even_inverse = _cosets._get_inverse(get_kernel(EVEN_ROUTES_CODE)) + assert np.any(even_inverse.sum(axis=1) % 2) + rank_deficient_inverse = _cosets._get_inverse(get_kernel(_RANK_DEFICIENT_CODE)) + assert np.any(_RANK_DEFICIENT_CODE.sum(axis=1) % 2) + for code, inverse, parities in [ + (EVEN_ROUTES_CODE, even_inverse, (0, 1)), + (EVEN_ROUTES_CODE, np.zeros_like(even_inverse), (None,)), + (_RANK_DEFICIENT_CODE, rank_deficient_inverse, (None,)), + ]: + instance = affine_instance.get_coset_instance(code, random_column_orders=0, weight_factor=1) + tables = coset_tables._get_search_tables(instance, inverse, 0) + expected = [instance.get_weight_bounds(parity).tolist() for parity in parities] + assert [table[:-1].tolist() for table in tables.planning] == expected + for parity in (0, 1): + table = coset_tables._get_planning_table(tables.planning, parity) + assert table[:-1].tolist() == expected[parity if len(expected) == 2 else 0] + + +def test_first_steps_reaching_a_target_follow_their_definition() -> None: + """The first step whose bound reaches a target is the first step whose bound is at least it. + + The bounds are those of a rank-one code with 64 sets, and of sets of ranks zero, two, and four + in a code of dimension four, for both weight factors. Their last row holds ``NO_WORD_BOUND``, + which reaches every target. + """ + certificates = [ + certificates_module.Certificate(1, (1,) * 64), + certificates_module.Certificate(4, (0, 2, 4, 0)), + ] + for certificate, factor in itertools.product(certificates, (1, 2)): + bounds = certificate.get_weight_bounds(factor) + steps = list(itertools.product(range(len(bounds)), range(1, bounds.shape[1]))) + largest = int(bounds[bounds < certificates_module.NO_WORD_BOUND].max()) + for target in range(largest + 2): + expected = next(step for step in steps if bounds[step] >= target) + assert coset_tables._get_first_step_reaching(bounds, target) == expected diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py index 894805313..27f70dbb9 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py @@ -1069,7 +1069,9 @@ def test_min_weights_of_cosets_match_brute_force( searched too. Their searches by tails stop early within steps. The searches take the disjoint family of each code, and at the threshold zero other families by tails, with completion tables and without them (see ``_assert_min_weights_match_brute_force``). The least weights come from - every word of each coset. + every word of each coset. The Pauli errors of a graph state that is not CSS are searched by + CSS weight too, with the disjoint family and at the threshold zero, and their least weights come + from every Pauli. These searches never weigh words by tails, so they have no completion tables. """ tune(**tuning) rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) @@ -1089,6 +1091,14 @@ def test_min_weights_of_cosets_match_brute_force( early_stop_syndromes = [1126, 4578, 6129] minima = _coset_minima(_HEAVY_COSETS_CODE, checks, early_stop_syndromes) _assert_min_weights_match_brute_force(search, early_stop_syndromes, minima, len(checks), rng) + if "completion_bound_max_table_bytes" in tuning: + return + matrix = _graph_state(4, [(0, 1), (1, 2), (2, 3)], phase=True) + checks = (math.symplectic_conjugate(matrix.astype(int)) % 2).astype(np.uint8) + css_fields = coset._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=True) + search = coset._get_pauli_search(coset._StabilizerKey(matrix, checks), True, css_fields) + syndromes = range(1 << len(matrix)) + _assert_min_weights_match_brute_force(search, syndromes, _css_minima(matrix), len(matrix), rng) @pytest.mark.parametrize("tuning", [{}, {"overlapping_minimum_work": 0}], ids=["words", "tails"]) diff --git a/src/qldpc/codes/distance/_connected_support/coset_test.py b/src/qldpc/codes/distance/_connected_support/coset_test.py index f65f3dfcd..bed20534c 100644 --- a/src/qldpc/codes/distance/_connected_support/coset_test.py +++ b/src/qldpc/codes/distance/_connected_support/coset_test.py @@ -11,26 +11,12 @@ import numpy as np import numpy.typing as npt import pytest -import stim from qldpc._binary import row_reduce_binary -from ...conftest import Tune from .._coset_leader_search import CosetLeaderSearch from .._tuning import use_tuning -from ..conftest import ( - _PHASED_GHZ_BLOCK, - _block_generators, - _block_minimum, - _css_minima, - _find_each_not_within_max_weight, - _get_syndrome, - _graph_state, - _pack_syndromes, - _pauli_weight, - _random_css_state, - _stabilizer_matrix, -) +from ..conftest import _css_minima, _get_syndrome, _graph_state, _pauli_weight, _random_css_state from . import coset @@ -105,46 +91,6 @@ def test_connected_support_answers_match_exhaustive_minima(pytestconfig: pytest. assert bound.lower_bound == 2 and weight <= max_weight -def test_connected_support_batches_read_syndromes_of_several_words( - pytestconfig: pytest.Config, tune: Tune -) -> None: - """On 17 phased GHZ blocks, batches of the connected-support engine agree with block minima. - - The 68 syndrome bits span two words, and the physical weight takes one search of the Pauli - errors. The batch holds the syndromes of 30 errors. The first acts on one qubit in each of - blocks 0, 8, and 16. Block 16 has its syndrome bits in the second word, so a search that read - only the first word would find a lighter error than the minimum of three. The other errors each - act on one to three random qubits, and blocks that an error does not touch add nothing to its - minimum weight. The oracle looks for sparser checks for one round, rather than for the many - rounds that are slow as plain Python. On these blocks, one round gives the default checks. - """ - tune(max_sparsifying_rounds=1) - rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - num_blocks = 17 - generators = _block_generators(_PHASED_GHZ_BLOCK, num_blocks) - matrix = _stabilizer_matrix(generators) - oracle = CosetLeaderSearch(matrix, method="connected_support") - syndromes, minima = [], [] - for error_index in range(30): - error = stim.PauliString(4 * num_blocks) - qubits = ( - [0, 35, 67] - if error_index == 0 - else rng.choice(4 * num_blocks, size=rng.integers(1, 4), replace=False) - ) - for qubit in qubits: - error[int(qubit)] = int(rng.integers(1, 4)) - syndromes.append(sum((not error.commutes(gg)) << jj for jj, gg in enumerate(generators))) - bits = [(syndromes[-1] >> jj) & 1 for jj in range(4 * num_blocks)] - local = [bits[4 * index : 4 * index + 4] for index in range(num_blocks)] - minima.append(_block_minimum(_PHASED_GHZ_BLOCK, [ss for ss in local if any(ss)], False)) - assert syndromes[0] >> 64 and minima[0] == 3 - words = _pack_syndromes(syndromes, 68) - for max_weight in range(4): - found = _find_each_not_within_max_weight(oracle, matrix, words, max_weight) - assert found == np.flatnonzero(np.array(minima) > max_weight).tolist() - - def test_sparser_rows_span_the_same_space(pytestconfig: pytest.Config) -> None: """Adding rows to each other leaves rows of the same span, which no such addition makes lighter. @@ -196,14 +142,3 @@ def test_rounds_of_random_reductions_make_rows_sparser() -> None: assert np.array_equal(transform @ matrix % 2, rows) assert len(row_reduce_binary(transform)[2]) == len(matrix) assert (rows[:, :5] | rows[:, 5:]).sum() == total_weight - - -def test_connected_support_rejects_what_it_cannot_search() -> None: - """Oracles reject unknown methods, and the CSS weight on non-CSS states by connected support.""" - with pytest.raises(ValueError, match="only on CSS states"): - CosetLeaderSearch( - _graph_state(2, [(0, 1)], phase=True), css_weight=True, method="connected_support" - ) - method: Any = "brute_force" - with pytest.raises(ValueError, match="Unknown coset-leader search method"): - CosetLeaderSearch([[1, 0]], method=method) diff --git a/src/qldpc/codes/distance/_coset_leader_search_test.py b/src/qldpc/codes/distance/_coset_leader_search_test.py index b2c762e75..76cd9351c 100644 --- a/src/qldpc/codes/distance/_coset_leader_search_test.py +++ b/src/qldpc/codes/distance/_coset_leader_search_test.py @@ -5,6 +5,8 @@ from __future__ import annotations import itertools +from collections.abc import Sequence +from typing import Any import numpy as np import numpy.typing as npt @@ -15,14 +17,10 @@ from ._coset_leader_search import CosetLeaderSearch, CosetSearchMethod from ._cosets import MinWeightBound from .conftest import ( - _PHASED_GHZ_BLOCK, _ROUTED_CSS_WEIGHT_STATE, _ROUTED_PHYSICAL_STATES, _assert_exact_answers, - _block_generators, - _block_minimum, _css_minima, - _find_each_not_within_max_weight, _get_syndrome, _graph_state, _pack_syndromes, @@ -55,6 +53,7 @@ def _class_weights( # is not. For the CSS weight, the oracle splits its search only on the GHZ block, and for the # physical weight it searches Pauli errors on both, so one block covers that path. _GHZ_BLOCK = ("XXXX", "ZZ__", "_ZZ_", "__ZZ") +_PHASED_GHZ_BLOCK = ("YXXX", "ZZ__", "_ZZ_", "__ZZ") _BLOCK_CASES = pytest.mark.parametrize( ("block", "css_weight"), [(_GHZ_BLOCK, False), (_GHZ_BLOCK, True), (_PHASED_GHZ_BLOCK, True)], @@ -75,6 +74,72 @@ def _ghz_blocks(num_blocks: int) -> tuple[npt.NDArray[np.uint8], int]: return _stabilizer_matrix(generators), syndrome +def _block_generators(block: Sequence[str], num_blocks: int) -> list[stim.PauliString]: + """Generators of independent copies of a block state, given the generators of one block.""" + size = len(block[0]) + return [ + stim.PauliString("_" * (size * index) + local + "_" * (size * (num_blocks - index - 1))) + for index in range(num_blocks) + for local in block + ] + + +def _block_minimum( + block: Sequence[str], local_syndromes: Sequence[Sequence[int]], css_weight: bool +) -> int: + """Minimum weight of an error on independent blocks with the given per-block syndromes. + + The syndrome bits of a block are the commutation with its generators, in order. Every Pauli on + one block is listed, and the blocks are combined by dynamic programming. + """ + size = len(block[0]) + stabilizers = [stim.PauliString(local) for local in block] + options: dict[tuple[int, ...], list[tuple[int, int, int]]] = {} + for paulis in itertools.product("_XYZ", repeat=size): + error = stim.PauliString("".join(paulis)) + syndrome = tuple(int(not error.commutes(ss)) for ss in stabilizers) + x_weight = sum(pp in "XY" for pp in paulis) + z_weight = sum(pp in "YZ" for pp in paulis) + options.setdefault(syndrome, []).append((x_weight, z_weight, size - paulis.count("_"))) + if not css_weight: + return sum( + min(support for _, _, support in options[tuple(local)]) for local in local_syndromes + ) + totals = {(0, 0)} + for local in local_syndromes: + totals = {(a + x, b + z) for a, b in totals for x, z, _ in options[tuple(local)]} + return min(max(a, b) for a, b in totals) + + +def _find_each_not_within_max_weight( + oracle: CosetLeaderSearch, + matrix: npt.NDArray[np.uint8], + syndromes: npt.NDArray[np.uint64], + max_weight: int, +) -> list[int]: + """Find each syndrome whose class has no error of weight at most ``max_weight``, in turn. + + The representative of each bound that a search returns has the syndrome for the stabilizers of + ``matrix``, its lower bound exceeds ``max_weight``, and, for a maximum weight of at least two, + the bound equals that of ``get_min_weight_bound``. With one coset search, + ``find_first_not_within_max_weight`` computes its bound separately from + ``get_min_weight_bound``, so this checks one against the other. + """ + found: list[int] = [] + while True: + start = found[-1] + 1 if found else 0 + index, bound = oracle.find_first_not_within_max_weight(syndromes[start:], max_weight) + if bound is None: + assert start + index == len(syndromes) + return found + found.append(start + index) + syndrome = int.from_bytes(syndromes[found[-1]].astype(" max_weight + assert _get_syndrome(matrix, bound.representative) == syndrome + if max_weight >= 2: + assert bound == oracle.get_min_weight_bound(syndrome, max_weight) + + @pytest.mark.parametrize( ("css_weight", "css_singles_table"), [(False, True), (True, True), (True, False)] ) @@ -573,3 +638,75 @@ def test_batches_reject_syndromes_out_of_range(method: CosetSearchMethod) -> Non with pytest.raises(ValueError, match="out of range"): oracle._get_min_weights(syndromes, 1) assert oracle._get_min_weights(np.array([[3, 0]], dtype=np.uint64), 1).tolist() == [2] + + +def test_connected_support_batches_by_css_weight_join_their_parts( + pytestconfig: pytest.Config, +) -> None: + """Batches of the connected-support method by CSS weight find the classes above ``max_weight``. + + On CSS states, the CSS weight splits the search of a class into searches of its X and Z parts. + Every syndrome of random CSS states on one to five qubits, in random order, is checked at every + maximum weight against exhaustive minima. + """ + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + for num_qubits in range(1, 6): + matrix = _random_css_state(num_qubits, num_qubits // 2, rng) + oracle = CosetLeaderSearch(matrix, css_weight=True, method="connected_support") + syndromes = rng.permutation(1 << num_qubits) + minima = _css_minima(matrix)[syndromes] + words = _pack_syndromes(syndromes.tolist(), num_qubits) + for max_weight in range(num_qubits + 2): + found = _find_each_not_within_max_weight(oracle, matrix, words, max_weight) + assert found == np.flatnonzero(minima > max_weight).tolist() + + +def test_connected_support_batches_read_syndromes_of_several_words( + pytestconfig: pytest.Config, tune: Tune +) -> None: + """On 17 phased GHZ blocks, batches of the connected-support engine agree with block minima. + + The 68 syndrome bits span two words, and the physical weight takes one search of the Pauli + errors. The batch holds the syndromes of 30 errors. The first acts on one qubit in each of + blocks 0, 8, and 16. Block 16 has its syndrome bits in the second word, so a search that read + only the first word would find a lighter error than the minimum of three. The other errors each + act on one to three random qubits, and blocks that an error does not touch add nothing to its + minimum weight. The oracle looks for sparser checks for one round, rather than for the many + rounds that are slow as plain Python. On these blocks, one round gives the default checks. + """ + tune(max_sparsifying_rounds=1) + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + num_blocks = 17 + generators = _block_generators(_PHASED_GHZ_BLOCK, num_blocks) + matrix = _stabilizer_matrix(generators) + oracle = CosetLeaderSearch(matrix, method="connected_support") + syndromes, minima = [], [] + for error_index in range(30): + error = stim.PauliString(4 * num_blocks) + qubits = ( + [0, 35, 67] + if error_index == 0 + else rng.choice(4 * num_blocks, size=rng.integers(1, 4), replace=False) + ) + for qubit in qubits: + error[int(qubit)] = int(rng.integers(1, 4)) + syndromes.append(sum((not error.commutes(gg)) << jj for jj, gg in enumerate(generators))) + bits = [(syndromes[-1] >> jj) & 1 for jj in range(4 * num_blocks)] + local = [bits[4 * index : 4 * index + 4] for index in range(num_blocks)] + minima.append(_block_minimum(_PHASED_GHZ_BLOCK, [ss for ss in local if any(ss)], False)) + assert syndromes[0] >> 64 and minima[0] == 3 + words = _pack_syndromes(syndromes, 68) + for max_weight in range(4): + found = _find_each_not_within_max_weight(oracle, matrix, words, max_weight) + assert found == np.flatnonzero(np.array(minima) > max_weight).tolist() + + +def test_connected_support_rejects_what_it_cannot_search() -> None: + """Oracles reject unknown methods, and the CSS weight on non-CSS states by connected support.""" + with pytest.raises(ValueError, match="only on CSS states"): + CosetLeaderSearch( + _graph_state(2, [(0, 1)], phase=True), css_weight=True, method="connected_support" + ) + method: Any = "brute_force" + with pytest.raises(ValueError, match="Unknown coset-leader search method"): + CosetLeaderSearch([[1, 0]], method=method) diff --git a/src/qldpc/codes/distance/_cosets_test.py b/src/qldpc/codes/distance/_cosets_test.py new file mode 100644 index 000000000..98b211b39 --- /dev/null +++ b/src/qldpc/codes/distance/_cosets_test.py @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the parts that the coset searches of ``CosetLeaderSearch`` share.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from qldpc._binary import pack_bits + +from . import _cosets + + +def test_inverses_of_checks_have_one_syndrome_bit_in_each_row(pytestconfig: pytest.Config) -> None: + """Row ``j`` of the inverse of independent checks has a syndrome of only bit ``j``.""" + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + for num_checks, num_bits in [(1, 1), (3, 7), (5, 12)]: + identity = np.eye(num_checks, dtype=np.uint8) + extra = rng.integers(0, 2, (num_checks, num_bits - num_checks), dtype=np.uint8) + checks = np.hstack([identity, extra])[:, rng.permutation(num_bits)] + inverse = _cosets._get_inverse(checks) + assert inverse.shape == checks.shape + np.testing.assert_array_equal(checks @ inverse.T % 2, identity) + + +def test_rows_pack_into_integers_and_back(pytestconfig: pytest.Config) -> None: + """Entry ``j`` of a row is bit ``j`` of its integer, which splits back into its packed words.""" + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + matrix = rng.integers(0, 2, (5, 130), dtype=np.uint8) + values = _cosets._row_ints(matrix) + assert values == tuple(sum(int(bit) << jj for jj, bit in enumerate(row)) for row in matrix) + packed = pack_bits(matrix) + for row, value in zip(packed, values, strict=True): + assert _cosets._packed_row_int(row) == value + np.testing.assert_array_equal(_cosets._int_to_words(value, packed.shape[1]), row) + + +def test_sums_of_selected_rows_follow_their_definition(pytestconfig: pytest.Config) -> None: + """Both sums of the rows that the set bits of a support select add those rows modulo two. + + The 70 rows make the packed supports span two machine words. + """ + rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) + rows = rng.integers(0, 2, (70, 9), dtype=np.uint8) + row_values, packed_rows = _cosets._row_ints(rows), pack_bits(rows) + for _ in range(10): + selected = rng.integers(0, 2, len(rows), dtype=np.uint8) + expected = rows[selected.astype(bool)].sum(axis=0) % 2 + (support,) = _cosets._row_ints(selected[None]) + assert _cosets._xor_selected(row_values, support) == _cosets._row_ints(expected[None])[0] + word = np.zeros(packed_rows.shape[1], dtype=np.uint64) + _cosets._xor_selected_rows(pack_bits(selected[None])[0], packed_rows, word) + np.testing.assert_array_equal(word, pack_bits(expected[None])[0]) + + +def test_tables_of_single_errors_keep_the_first_word_of_each_syndrome() -> None: + """Tables of the words of weight zero or one map each syndrome to its first word. + + For packed Paulis on two qubits, the words are tried qubit by qubit, X before Z before Y. So + the Z error on qubit zero, at bit two, takes the syndrome 0b10 before the X error on qubit one, + and the Y error on qubit zero takes 0b11 before the Z error on qubit one. For X or Z parts, the + two bits with the syndrome 0b01 keep the first, and the tables of X and Z errors hold only the + identity. + """ + singles, x_singles, z_singles = _cosets._tabulate_singles( + [0b01, 0b10, 0b10, 0b11], 2, is_pauli=True + ) + assert singles == {0: 0, 0b01: 0b0001, 0b10: 0b0100, 0b11: 0b0101} + assert x_singles == {0: 0, 0b01: 0b0001, 0b10: 0b0010} + assert z_singles == {0: 0, 0b10: 0b0100, 0b11: 0b1000} + singles, x_singles, z_singles = _cosets._tabulate_singles([0b01, 0b01, 0b10], 0, is_pauli=False) + assert singles == {0: 0, 0b01: 0b001, 0b10: 0b100} + assert x_singles == z_singles == {0: 0} diff --git a/src/qldpc/codes/distance/_information_sets/certificates_test.py b/src/qldpc/codes/distance/_information_sets/certificates_test.py index 2751efdab..9725cf238 100644 --- a/src/qldpc/codes/distance/_information_sets/certificates_test.py +++ b/src/qldpc/codes/distance/_information_sets/certificates_test.py @@ -20,7 +20,7 @@ def test_certificate_follows_its_definition(pytestconfig: pytest.Config) -> None: - """The bounds, work estimate, and number of sets that certify a bound equal their definitions. + """The bounds, work estimates, and number of sets that certify a bound equal their definitions. A search visits the sets of a layer in family order and stops after the first set whose completion of the layer certifies the upper bound, so the work counts the sets visited, and @@ -50,6 +50,8 @@ def test_certificate_follows_its_definition(pytestconfig: pytest.Config) -> None for step in steps } assert {step: certificate.step_lower_bound(*step) for step in steps} == step_oracle + ranks_array = np.array(ranks, dtype=np.int64) + binomials = certificates.get_capped_binomials(dimension) for upper_bound in range(1, 2 * dimension + 2): candidates = 0 for weight, count in steps: @@ -57,6 +59,16 @@ def test_certificate_follows_its_definition(pytestconfig: pytest.Config) -> None if step_oracle[weight, count] >= upper_bound: break assert certificate.work(upper_bound) == candidates + assert ( + certificates.estimate_certificate_work( + dimension, ranks_array, weight_divisor, max_coverage, upper_bound, binomials + ) + == candidates + ) + for step in steps: + assert certificate.certifies(*step, upper_bound) == ( + step_oracle[step] >= upper_bound + ) for weight in range(1, dimension + 1): num_sets = next( ( diff --git a/src/qldpc/codes/distance/conftest.py b/src/qldpc/codes/distance/conftest.py index fddee6687..0307c0749 100644 --- a/src/qldpc/codes/distance/conftest.py +++ b/src/qldpc/codes/distance/conftest.py @@ -287,8 +287,6 @@ def _pack_syndromes(syndromes: Sequence[int], num_qubits: int) -> npt.NDArray[np return np.array(words, dtype=np.uint64).reshape(len(syndromes), width) -_PHASED_GHZ_BLOCK = ("YXXX", "ZZ__", "_ZZ_", "__ZZ") - # Graph states whose searches of Pauli errors, by physical and by CSS weight, have a maximum # disjoint family with more information sets, or a larger partial set, than their greedy packing, # which their costly queries take (see ``coset_routes``). The family of the first state for the @@ -303,35 +301,6 @@ def _pack_syndromes(syndromes: Sequence[int], num_qubits: int) -> npt.NDArray[np _ROUTED_CSS_WEIGHT_STATE = _graph_state(5, [(0, 1), (0, 4), (1, 4)]) -def _find_each_not_within_max_weight( - oracle: CosetLeaderSearch, - matrix: npt.NDArray[np.uint8], - syndromes: npt.NDArray[np.uint64], - max_weight: int, -) -> list[int]: - """Find each syndrome whose class has no error of weight at most ``max_weight``, in turn. - - The representative of each bound that a search returns has the syndrome for the stabilizers of - ``matrix``, its lower bound exceeds ``max_weight``, and, for a maximum weight of at least two, - the bound equals that of ``get_min_weight_bound``. With one coset search, - ``find_first_not_within_max_weight`` computes its bound separately from - ``get_min_weight_bound``, so this checks one against the other. - """ - found: list[int] = [] - while True: - start = found[-1] + 1 if found else 0 - index, bound = oracle.find_first_not_within_max_weight(syndromes[start:], max_weight) - if bound is None: - assert start + index == len(syndromes) - return found - found.append(start + index) - syndrome = int.from_bytes(syndromes[found[-1]].astype(" max_weight - assert _get_syndrome(matrix, bound.representative) == syndrome - if max_weight >= 2: - assert bound == oracle.get_min_weight_bound(syndrome, max_weight) - - def _random_css_state( num_qubits: int, num_x_stabilizers: int, rng: np.random.Generator ) -> npt.NDArray[np.uint8]: @@ -380,43 +349,6 @@ def _css_minima(matrix: npt.NDArray[np.uint8], css_weight: bool = True) -> npt.N return minima -def _block_generators(block: Sequence[str], num_blocks: int) -> list[stim.PauliString]: - """Generators of independent copies of a block state, given the generators of one block.""" - size = len(block[0]) - return [ - stim.PauliString("_" * (size * index) + local + "_" * (size * (num_blocks - index - 1))) - for index in range(num_blocks) - for local in block - ] - - -def _block_minimum( - block: Sequence[str], local_syndromes: Sequence[Sequence[int]], css_weight: bool -) -> int: - """Minimum weight of an error on independent blocks with the given per-block syndromes. - - The syndrome bits of a block are the commutation with its generators, in order. Every Pauli on - one block is listed, and the blocks are combined by dynamic programming. - """ - size = len(block[0]) - stabilizers = [stim.PauliString(local) for local in block] - options: dict[tuple[int, ...], list[tuple[int, int, int]]] = {} - for paulis in itertools.product("_XYZ", repeat=size): - error = stim.PauliString("".join(paulis)) - syndrome = tuple(int(not error.commutes(ss)) for ss in stabilizers) - x_weight = sum(pp in "XY" for pp in paulis) - z_weight = sum(pp in "YZ" for pp in paulis) - options.setdefault(syndrome, []).append((x_weight, z_weight, size - paulis.count("_"))) - if not css_weight: - return sum( - min(support for _, _, support in options[tuple(local)]) for local in local_syndromes - ) - totals = {(0, 0)} - for local in local_syndromes: - totals = {(a + x, b + z) for a, b in totals for x, z, _ in options[tuple(local)]} - return min(max(a, b) for a, b in totals) - - # A code whose disjoint information sets have ranks five and four. _RANK_DEFICIENT_CODE = np.array( [ From d112932a650ad0c5d3a47d5b940c980e9178b652 Mon Sep 17 00:00:00 2001 From: "Michael A. Perlin" Date: Thu, 12 Nov 2026 01:13:05 -0500 Subject: [PATCH 4/7] Point the guides and docstrings at the new layout The README gains a short account of the layout, and its map of where to look and its levels of dependency follow the subpackages, with the order of the modules within each. The code maps of both guides, the module docstrings that described the old modules, and the references of the form `module.name` name the modules where the code now lives. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/qldpc/_compilation.py | 5 +- src/qldpc/codes/distance/README.md | 71 +++++++++++-------- src/qldpc/codes/distance/_binary_test.py | 2 +- .../_brouwer_zimmermann/affine_instance.py | 44 ++++++------ .../_brouwer_zimmermann/affine_search.py | 2 +- .../distance/_brouwer_zimmermann/coset.py | 4 +- .../_brouwer_zimmermann/coset_routes.py | 43 ++++++----- .../_brouwer_zimmermann/coset_tables.py | 14 ++-- .../_brouwer_zimmermann/coset_test.py | 36 +++++----- .../distance/_brouwer_zimmermann/distance.py | 25 ++++--- src/qldpc/codes/distance/_brute_force.py | 1 + .../distance/_connected_support/coset.py | 10 +-- .../distance/_connected_support/search.py | 9 +-- .../codes/distance/_coset_leader_search.py | 43 +++++------ src/qldpc/codes/distance/_counting/scan.py | 2 +- .../automorphism_families.py | 5 +- .../_information_sets/basis_families.py | 39 +++++----- .../_information_sets/certificates.py | 6 +- .../_information_sets/completion_bounds.py | 9 +-- src/qldpc/codes/distance/_tuning.py | 30 ++++---- src/qldpc/codes/distance/_tuning_test.py | 6 +- .../codes/distance/brouwer_zimmermann.md | 21 +++--- src/qldpc/codes/distance/conftest.py | 11 +-- src/qldpc/codes/distance/connected_support.md | 4 +- src/qldpc/codes/distance/logical_counts.py | 2 +- 25 files changed, 233 insertions(+), 211 deletions(-) diff --git a/src/qldpc/_compilation.py b/src/qldpc/_compilation.py index 5e31034ee..f08456911 100644 --- a/src/qldpc/_compilation.py +++ b/src/qldpc/_compilation.py @@ -40,9 +40,8 @@ their module-level names, which are looked up when it is compiled, so a compiled function calls compiled functions. This is why every function that a compiled-on-request function calls must be always-compiled or compiled on request too. Other modules must call a compiled-on-request function -through the module that defines it, as in ``information_sets.get_overlapping_family(...)``, and -never through a name imported by ``from``, which would keep the version that was current at import -time. +through the module that defines it, as in ``basis_families.get_overlapping_family(...)``, and never +through a name imported by ``from``, which would keep the version that was current at import time. To choose between them, use :func:`always_compiled` for the per-word loops and for the helper functions that other compiled code calls: every search needs them, and the disk cache makes diff --git a/src/qldpc/codes/distance/README.md b/src/qldpc/codes/distance/README.md index 1239b3c82..bf052de69 100644 --- a/src/qldpc/codes/distance/README.md +++ b/src/qldpc/codes/distance/README.md @@ -27,35 +27,47 @@ Users reach it through the `get_distance_exact` method of binary codes in `src/q It is another exact method, selected by `method="connected_support"`, and it also finds light vectors with a given syndrome. - **Scan:** the enumeration of the combinations of rows of each information set, layer by layer. Counting scans in `_counting/scan.py`. - The Brouwer–Zimmermann distance search in `_brouwer_zimmermann/distance.py` and the Brouwer–Zimmermann coset search in `_coset_leader_search.py` scan with the compiled scans of `_brouwer_zimmermann/affine_search.py`. + The Brouwer–Zimmermann distance search in `_brouwer_zimmermann/distance.py` and the Brouwer–Zimmermann coset searches in `_brouwer_zimmermann/coset.py` scan with the compiled scans of `_brouwer_zimmermann/affine_search.py`. All of them step through a layer with `advance_support` in `_information_sets/support_stepping.py`. +## Layout + +- `core.py` and `logical_counts.py` are the public interfaces of the distances and the counts. +- `_coset_leader_search.py` is the entry module of the coset-leader search, which modules elsewhere in qLDPC import. +- `_brouwer_zimmermann/` and `_connected_support/` hold the two searches over information sets and over connected supports. + Each holds its distance search, and the coset searches with which the coset-leader search answers its queries by that method. + Exhaustive enumeration fits in one module, `_brute_force.py`. +- `_counting/` holds the methods of the counts. +- `_information_sets/` holds the families of information sets and their certificates, which the Brouwer–Zimmermann searches and the counts share. +- `_cosets.py` holds what the coset searches of both methods share. +- `_tuning.py` holds the settings of the searches, and `_binary.py` the binary input checks. + ## Where to look | To change… | Look in (this directory) | | --- | --- | | A distance method, or the validation of method and backend names | `core.py`, and where `logical_counts.py`, `_counting/scan.py`, `get_distance_exact`, `get_distance`, and `CSSCode._get_distance_of_sectors` in `src/qldpc/codes/common.py` dispatch on the method name | +| The counter, its deformed counters, or `compile_search_setup` | `logical_counts.py` | +| Minimum weights of syndrome classes of Pauli errors (coset-leader weights), by BZ or by connected-support searches, with two classical searches for the CSS weight on CSS states, used by `src/qldpc/circuits/certification/` and `src/qldpc/experimental/circuits/benchmarking/residual_weights.py` | `_coset_leader_search.py` | | The Brouwer–Zimmermann (BZ) distance search | `_brouwer_zimmermann/distance.py` | | The compiled scans of layers that the BZ distance search and the BZ coset-leader search share, one body specialized by constant flags | `_brouwer_zimmermann/affine_search.py` | -| The connected-support search, of distances and of vectors with a given syndrome | `_connected_support/search.py` | | The packed information sets of a BZ distance search or a BZ coset-leader search, and the certificate of their steps | `_brouwer_zimmermann/affine_instance.py` | -| How basis families are built | `_information_sets/basis_families.py` | -| Certificates, and how searches plan their steps: where they stop, and the work of the steps up to there | `_information_sets/certificates.py` | -| The step from one support of a layer to the next, which every scan of layers shares | `_information_sets/support_stepping.py` | -| Automorphism families: the images of one information set under automorphisms of a code, and the check of those automorphisms, which the connected-support search uses too | `_information_sets/automorphism_families.py` | -| Completion tables, which bound the words that complete the first rows of a BZ support with two more rows: a distance search skips the supports whose words cannot be lighter than its best word, a count those whose words all weigh more than its target weight, and a BZ coset query on a route by tails those whose words all weigh more than its maximum weight. A BZ search for the minimum weight of a coset skips those too, and those whose words cannot be lighter than its lightest word so far. Also the choice of whether to build them | `_information_sets/completion_bounds.py` | -| Information sets with bounded column coverage, by matroid partitioning | `_information_sets/matroid_partition.py` | -| Exhaustive enumeration, for small codes, fallbacks, and the `brute_force` method, with its packed symplectic weights | `_brute_force.py` | -| Minimum weights of syndrome classes of Pauli errors (coset-leader weights), by BZ or by connected-support searches, with two classical searches for the CSS weight on CSS states, used by `src/qldpc/circuits/certification/` and `src/qldpc/experimental/circuits/benchmarking/residual_weights.py` | `_coset_leader_search.py` | | The BZ coset searches of the coset-leader search | `_brouwer_zimmermann/coset.py` | | The tables of the steps and tails of BZ coset searches | `_brouwer_zimmermann/coset_tables.py` | -| The connected-support coset searches of the coset-leader search | `_connected_support/coset.py` | -| The parts that the coset searches of both methods share: their answers (`MinWeightBound`), inverses of checks, packed rows, and tables of light errors | `_cosets.py` | | The families of information sets with which a BZ coset-leader search answers costly queries, the gate that chooses them for each maximum weight, and the routes whose queries weigh words by their tails and skip completions by tables | `_brouwer_zimmermann/coset_routes.py` | -| The counter, its deformed counters, or `compile_search_setup` | `logical_counts.py` | +| The connected-support search, of distances and of vectors with a given syndrome | `_connected_support/search.py` | +| The connected-support coset searches of the coset-leader search | `_connected_support/coset.py` | +| Exhaustive enumeration, for small codes, fallbacks, and the `brute_force` method, with its packed symplectic weights | `_brute_force.py` | | The scan of a count, or how its basis families follow local gates | `_counting/scan.py` | | The local Clifford gates of deformed counters | `_counting/local_cliffords.py` | | The MacWilliams counting method | `_counting/macwilliams.py` | +| The parts that the coset searches of both methods share: their answers (`MinWeightBound`), inverses of checks, packed rows, sums of the rows that a support selects, and tables of the errors of weight zero or one | `_cosets.py` | +| How basis families are built | `_information_sets/basis_families.py` | +| Certificates, and how searches plan their steps: where they stop, and the work of the steps up to there | `_information_sets/certificates.py` | +| The step from one support of a layer to the next, which every scan of layers shares | `_information_sets/support_stepping.py` | +| Automorphism families: the images of one information set under automorphisms of a code, and the check of those automorphisms, which the connected-support search uses too | `_information_sets/automorphism_families.py` | +| Completion tables, which bound the words that complete the first rows of a BZ support with two more rows: a distance search skips the supports whose words cannot be lighter than its best word, a count those whose words all weigh more than its target weight, and a BZ coset query on a route by tails those whose words all weigh more than its maximum weight. A BZ search for the minimum weight of a coset skips those too, and those whose words cannot be lighter than its lightest word so far. Also the choice of whether to build them | `_information_sets/completion_bounds.py` | +| Information sets with bounded column coverage, by matroid partitioning | `_information_sets/matroid_partition.py` | | The settings that a search reads when it starts, such as the thresholds that choose between exhaustive enumeration, the kinds of information-set families, and completion tables, the threshold of states before which a connected-support search asks for automorphisms, the coefficients of cost models, memory limits, and batch sizes (`SearchTuning`, `use_tuning`), and the constants that size caches when qLDPC is imported | `_tuning.py` | | Binary input checks, nested bases, or the map that turns symplectic weights into Hamming weights | `_binary.py` | | Bit packing, bit counts, or row reduction over GF(2), which other subpackages share | `src/qldpc/_binary.py` | @@ -65,22 +77,23 @@ Users reach it through the `get_distance_exact` method of binary codes in `src/q From the top: -1. `logical_counts.py`, the public interface of the counts, and `core.py`, the public interface of the distances. -2. The `_counting` package, the counting methods. -3. `_brouwer_zimmermann/distance.py`, `_brute_force.py`, `_connected_support/search.py`, and `_coset_leader_search.py`, the searches. -4. The modules that the searches build on. - `_brouwer_zimmermann/affine_instance.py` packs the information sets of a BZ distance or BZ coset-leader search, with the certificate of their steps. - `_brouwer_zimmermann/coset_routes.py` chooses the families of information sets with which a BZ coset-leader search answers each maximum weight. - `_brouwer_zimmermann/affine_search.py` holds the compiled scans of layers that the BZ distance and BZ coset-leader searches share. - `_information_sets/basis_families.py` builds the basis families of those two searches and of the counting scans. - `_information_sets/automorphism_families.py` builds automorphism families from them. - `_information_sets/completion_bounds.py` bounds the words that complete the first rows of a support with two more rows. -5. `_information_sets/matroid_partition.py`, which `_information_sets/basis_families.py` uses to build some of those families. -6. `_tuning.py`, the settings of the searches and counts, and the sizes of their caches. -7. `_binary.py`, the foundation. - -A module imports only modules of its own level or below. -`layering_test.py` checks the order of the top-level modules and subpackages; the order within a subpackage is a convention. +1. `logical_counts.py`, the public interface of the counts, which takes `NoDistance` from `core.py`. +2. `core.py`, the public interface of the distances, and `_coset_leader_search.py`, the entry module of the coset-leader search. +3. The methods `_brouwer_zimmermann/`, `_connected_support/`, and `_counting/`. +4. What the methods share. + `_information_sets/` holds the information sets that the Brouwer–Zimmermann searches and the counts build on. + The connected-support search uses its weight divisors, and its checks and orbits of automorphisms. + `_cosets.py` holds what the coset searches of both methods share. + `_brute_force.py` is the exhaustive method, which the Brouwer–Zimmermann distance search also uses for its exhaustive routes. +5. `_tuning.py`, the settings of the searches and counts, and the sizes of their caches. +6. `_binary.py`, the foundation. + +A top-level module or subpackage imports only from lower levels, and a module also from its own subpackage. +`layering_test.py` checks this. +Within a subpackage, the order is a convention. +In `_information_sets/`, `basis_families` builds on `certificates` and `matroid_partition`, `automorphism_families` on `basis_families` and `certificates`, and `completion_bounds` on `certificates`. +In `_brouwer_zimmermann/`, `distance` and `coset` build on `affine_instance` and `affine_search`, `coset` also on `coset_routes` and `coset_tables`, and those two on `affine_instance`. +In `_connected_support/`, `coset` builds on `search`, and in `_counting/`, `scan` on `local_cliffords`. Modules of every level may also import `src/qldpc/_binary.py`, the bit-packed linear algebra over GF(2) that other subpackages share. `src/qldpc/codes/common.py` imports this package, so `logical_counts.py` imports the code classes of `codes.common` and `codes.quantum` inside the functions that use them. Modules elsewhere in qLDPC, such as `src/qldpc/codes/common.py`, modules in `src/qldpc/circuits/certification/`, and the deprecation registry in `src/qldpc/_deprecations/core.py`, use private names of this package, so grep `src/qldpc` for a private name before you move or rename it. diff --git a/src/qldpc/codes/distance/_binary_test.py b/src/qldpc/codes/distance/_binary_test.py index 067357d12..1868b2301 100644 --- a/src/qldpc/codes/distance/_binary_test.py +++ b/src/qldpc/codes/distance/_binary_test.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 -"""Unit tests for the binary input checks and nested bases of distance searches.""" +"""Tests of the input checks, nested bases, and symplectic-to-Hamming map of distance searches.""" from __future__ import annotations diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/affine_instance.py b/src/qldpc/codes/distance/_brouwer_zimmermann/affine_instance.py index e46a69183..07e86322c 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/affine_instance.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/affine_instance.py @@ -2,27 +2,27 @@ """Instances of the affine BZ search: the packed sets of a problem and their certificate. -The distance search (``brouwer_zimmermann.py``) and the coset-leader search -(``coset_leader_search.py``) run the same Brouwer-Zimmermann (BZ) search over the words of an -affine space (see ``brouwer_zimmermann.md``). The layers of this search add rows of an information -set to an offset. An :class:`AffineInstance` holds what a search builds for its problem before it -scans, rather than for each layer or query: the packed rows and quotient labels of the sets that it -scans, their pivot columns and ranks, the certificate that bounds the Hamming weight of the words -that it has not yet visited, and the factor that converts these bounds into bounds on the weights -that the search compares. A distance search builds another instance when it switches to an -automorphism family. Each family with which a coset search of ``CosetLeaderSearch`` answers queries -has a coset instance (see ``coset_routes.py``). The offsets and stopping step of a coset search -depend on the query, so the instance holds neither. +The distance search (``distance.py``) and the coset searches of the coset-leader search +(``coset.py``) run the same Brouwer-Zimmermann (BZ) search over the words of an affine space (see +``brouwer_zimmermann.md``). The layers of this search add rows of an information set to an offset. +An :class:`AffineInstance` holds what a search builds for its problem before it scans, rather than +for each layer or query: the packed rows and quotient labels of the sets that it scans, their pivot +columns and ranks, the certificate that bounds the Hamming weight of the words that it has not yet +visited, and the factor that converts these bounds into bounds on the weights that the search +compares. A distance search builds another instance when it switches to an automorphism family. +Each family with which a coset search of ``CosetLeaderSearch`` answers queries has a coset instance +(see ``coset_routes.py``). The offsets and stopping step of a coset search depend on the query, so +the instance holds neither. A distance instance packs the rows of each set with its pivot columns deleted, since a scan of a space restores their weight arithmetically. Its certificate is the one that the distance search selects, with the proven divisor of eligible weights. A coset instance packs the full rows of the sets of a family of information sets of its code, disjoint or overlapping, which a coset search weighs whole or, in a search that weighs Hamming weights, on its routes by tails, by tails derived -from them (see ``coset_leader_search._CosetTails``). Its certificate takes the maximum coverage of -the family, and no divisor, since the words of a coset need not share the divisibility of the code. -If every word of the code has even Hamming weight, the Hamming weights of the words of each coset -all have one parity instead, and the bounds of a coset may be rounded up to its parity. +from them (see ``coset_tables._CosetTails``). Its certificate takes the maximum coverage of the +family, and no divisor, since the words of a coset need not share the divisibility of the code. If +every word of the code has even Hamming weight, the Hamming weights of the words of each coset all +have one parity instead, and the bounds of a coset may be rounded up to its parity. """ from __future__ import annotations @@ -140,17 +140,17 @@ def get_coset_instance( The rows keep every column. The certificate takes no weight divisor, since the words of a coset need not share the divisibility of the code, even if the family infers one. Its coverage is one, since the sets are disjoint. ``random_column_orders`` is passed to - ``information_sets.get_disjoint_family``. ``weight_factor`` is the weight factor of the - instance. It is two for a search of Pauli errors, since an error weighs at least half the - Hamming weight of the binary word that represents it in the search (see - ``coset_leader_search.py``). It is one for a search of X or Z parts. Without generators, each - coset is a single word, which the search visits on layer zero of one empty set. + ``basis_families.get_disjoint_family``. ``weight_factor`` is the weight factor of the instance. + It is two for a search of Pauli errors, since an error weighs at least half the Hamming weight + of the binary word that represents it in the search (see ``_get_pauli_search`` in ``coset.py``). + It is one for a search of X or Z parts. Without generators, each coset is a single word, which + the search visits on layer zero of one empty set. If every generator has even Hamming weight, so does every word ``v`` of the code, and every word ``u + v`` of a coset has the parity of ``|u|``, since ``|u + v| = |u| + |v| - 2 |u ∩ v|``. The bounds of each coset may then be rounded up to its parity (see ``get_weight_bounds``). The coset search decides whether to, since only it knows whether some coset is odd (see - ``_get_search_tables`` in ``coset_leader_search.py``). + ``coset_tables._get_search_tables``). """ num_generators, num_columns = generators.shape if not num_generators: @@ -175,7 +175,7 @@ def get_coset_family_instance( """Pack the sets of any basis family of a code for the coset search, with every column. The family may hold disjoint sets, as in ``get_coset_instance``, or overlapping ones, such as - those of ``information_sets.get_overlapping_family``. The certificate takes the ranks and the + those of ``basis_families.get_overlapping_family``. The certificate takes the ranks and the maximum coverage of the family, and no weight divisor. Its coverage bound holds for the words of every coset as it does for the words of the code: a word of a coset adds a unique sum of the rows of each set to the offset of the set, which is zero on the pivots of the set, so a word in diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/affine_search.py b/src/qldpc/codes/distance/_brouwer_zimmermann/affine_search.py index 7175db687..98ea1b34a 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/affine_search.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/affine_search.py @@ -403,7 +403,7 @@ def scan_coset_layer_by_tails_with_tables( skips, as a distance search does, the completions whose bound reaches the best weight so far. So it skips no word lighter than the best weight at the time, and finds the lightest word of the layer if it is lighter than ``best``, unless it returns at the cutoff first (see - ``coset_leader_search._search_coset_for_min_weight``). + ``_search_coset_for_min_weight`` in ``coset.py``). Returns: The weight of the lightest word found, or ``best`` if none is lighter, and the numbers of diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py index fac0931d8..e48783157 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py @@ -609,8 +609,8 @@ def _search_from_syndrome_word( ``_get_search_tables``), and otherwise -1. A set of rank deficiency ``s`` adds to the bound of a step only once it has completed layer ``s``, so a set that would not have completed it by the stopping step adds nothing, and the search skips it. These are the unused sets of - ``information_sets.plan_steps``, whose work for this step, from layer zero, counts the words - that the search weighs after the word of the syndrome if it finds no light enough word. + ``certificates.plan_steps``, whose work for this step, from layer zero, counts the words that + the search weighs after the word of the syndrome if it finds no light enough word. A search by tails (see ``_search_coset``) weighs a word by its rows below the rank of its set and its tail. It keeps the set, the layer, and the support of the lightest word, and writes diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_routes.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_routes.py index 21eb25d49..77bf5c0ee 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_routes.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_routes.py @@ -4,20 +4,20 @@ A BZ coset search of ``CosetLeaderSearch`` answers whether the coset of a syndrome has a word of weight at most ``max_weight`` by scanning the layers of a family of information sets of its code, up -to the stopping step of ``max_weight`` (see ``coset_leader_search.py``). It is built with the -disjoint information sets of a greedy packing, which are cheap to find. A code whose length is less -than twice its dimension has only one such information set, so queries at larger maximum weights -scan many layers. A greedy packing can also miss information sets. The columns ``e1``, ``e2``, +to the stopping step of ``max_weight`` (see ``coset.py``). It is built with the disjoint +information sets of a greedy packing, which are cheap to find. A code whose length is less than +twice its dimension has only one such information set, so queries at larger maximum weights scan +many layers. A greedy packing can also miss information sets. The columns ``e1``, ``e2``, ``e1 + e2``, and ``e1 + e2`` hold two disjoint information sets, but a greedy packing in natural order takes ``e1`` and ``e2``, and leaves a partial set of rank one. Families with more information sets stop sooner, but take milliseconds to build, which only costly queries repay. The routes choose the family that answers each maximum weight. A search has a table of the bounds of its steps for each parity of its cosets, or one table for every coset. The work of a table at -``max_weight`` is the work of the plan of its steps (see ``information_sets.plan_steps``). It is -the number of words that a query that takes the table weighs, or skips by completion tables (Section -3.9 of ``brouwer_zimmermann.md``), after the word of its syndrome if no word of its coset weighs at -most ``max_weight``, and at most that number otherwise. The predicted examinations of a query at +``max_weight`` is the work of the plan of its steps (see ``certificates.plan_steps``). It is the +number of words that a query that takes the table weighs, or skips by completion tables (Section 3.9 +of ``brouwer_zimmermann.md``), after the word of its syndrome if no word of its coset weighs at most +``max_weight``, and at most that number otherwise. The predicted examinations of a query at ``max_weight`` are one more than the largest work of a table of the disjoint family: the examinations of the costliest query that finds no light enough word, without completion tables, and at most that number with them. The gate compares them with its threshold, the field @@ -26,9 +26,9 @@ the smallest maximum weight from two whose predicted examinations reach the threshold. They keep each that they find: -- the maximum disjoint family of ``information_sets.get_maximum_disjoint_family``, if it has more +- the maximum disjoint family of ``basis_families.get_maximum_disjoint_family``, if it has more information sets or a larger partial set than the greedy packing; and -- a family of overlapping information sets from ``information_sets.get_overlapping_family``, if it +- a family of overlapping information sets from ``basis_families.get_overlapping_family``, if it finds one that does less work than the family that it starts from. It is aimed at the Hamming weight ``η * target_weight + 1`` that a query at the target weight without a light word must certify, for the weight factor ``η`` of the search, and it is built with the fields @@ -48,9 +48,9 @@ whether a query may take another family. A route is a route by tails if those with the route's own family, which can do less work, also reach the threshold. The queries of a route by tails weigh the words of its family by their tails, which hold the columns other than the pivot columns of each set -(see ``coset_leader_search._CosetTails``). They also skip the completions that completion tables of -the tails show to lie beyond their maximum weight (Section 3.9 of ``brouwer_zimmermann.md``). So -the routes also give the record of the tails and tables of the family of each route by tails, with a +(see ``coset_tables._CosetTails``). They also skip the completions that completion tables of the +tails show to lie beyond their maximum weight (Section 3.9 of ``brouwer_zimmermann.md``). So the +routes also give the record of the tails and tables of the family of each route by tails, with a builder that the search gives them. A search that gives none, such as the search of Pauli errors by CSS weight, has no routes by tails. The routes build the tails of a family once, and the tables of a set of the family once, for the first route that needs them. A route needs the tables of the sets @@ -95,9 +95,9 @@ class _SearchTables(Protocol): """The tables of a coset search that its routes read: its planning buffer. - The buffer holds a table of the bounds of the steps of the search for each parity of its - cosets, or one table for every coset, each with one more row that caches a stopping step (see - ``coset_leader_search._get_planning``). + The buffer holds a table of the bounds of the steps of the search for each parity of its cosets, + or one table for every coset, each with one more row that caches a stopping step (see + ``coset_tables._get_planning``). """ @property @@ -214,7 +214,7 @@ def _get_works( ) -> list[int]: """The work of a family at a maximum weight, for each table of its planning buffer. - This is the work of the plan of the steps of the table (see ``information_sets.plan_steps``) + This is the work of the plan of the steps of the table (see ``certificates.plan_steps``) from layer zero, for the limit ``max_weight``, without the unused sets that a query leaves out. It is the number of words that a query that finds no word weighs, or skips by completion tables (Section 3.9 of ``brouwer_zimmermann.md``), after the word of its @@ -288,11 +288,10 @@ def _get_sets_needing_tables( """The sets of a family whose completion tables the queries at a maximum weight use. A query visits each set up to the last layer that the set completes by the stopping step of - its table, unless the set is an unused set of that step (see - ``information_sets.plan_steps``), and then the query skips the set. It uses the tables of a - set in every layer of two or more rows, the layers that - ``completion_bounds.considers_tables`` considers under ``_TABLE_TUNING``. So a set needs - tables if the predicate considers its last layer. + its table, unless the set is an unused set of that step (see ``certificates.plan_steps``), + and then the query skips the set. It uses the tables of a set in every layer of two or more + rows, the layers that ``completion_bounds.considers_tables`` considers under + ``_TABLE_TUNING``. So a set needs tables if the predicate considers its last layer. """ num_rows = len(self._basis) sets = set() diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py index 33cf7bc95..8ce23d560 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py @@ -251,10 +251,10 @@ def _get_stopping_step(table: npt.NDArray[np.int64], max_weight: int) -> tuple[i ``[w, j - 1]``, and for a nonnegative maximum weight, step ``(w, j)`` is the stopping step exactly if its bound exceeds the maximum weight and entry ``[w, j - 1]`` does not. The step cached in the table (see ``_get_planning``) is reused if it passes this check. Otherwise, - ``information_sets.find_stopping_step`` finds the stopping step by binary search, and it is - cached in its place. A negative maximum weight fails the check, and the binary search returns - the first step. If no bound exceeds the maximum weight, which happens only when the maximum - weight reaches the largest native integer, the binary search returns the last step. + ``certificates.find_stopping_step`` finds the stopping step by binary search, and it is cached + in its place. A negative maximum weight fails the check, and the binary search returns the + first step. If no bound exceeds the maximum weight, which happens only when the maximum weight + reaches the largest native integer, the binary search returns the last step. Oracles that share a cached search share its planning buffer. Compiled calls hold the global interpreter lock, since numba compiles them without ``nogil``, so calls interleave only when @@ -283,8 +283,8 @@ def _get_stopping_step(table: npt.NDArray[np.int64], max_weight: int) -> tuple[i def _get_first_step_reaching(bounds: npt.NDArray[np.int64], target: int) -> tuple[int, int]: """The first step whose bound in a table of bounds of steps reaches ``target``. - It is the stopping step of ``information_sets.find_stopping_step`` for the limit - ``target - 1``. Unlike ``_get_stopping_step``, this function caches no step, so the cached - steps of the planning buffer serve only the queries of ``_search_coset``. + It is the stopping step of ``certificates.find_stopping_step`` for the limit ``target - 1``. + Unlike ``_get_stopping_step``, this function caches no step, so the cached steps of the planning + buffer serve only the queries of ``_search_coset``. """ return certificates.find_stopping_step(bounds, target - 1, 0, len(bounds) - 1) diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py index 27f70dbb9..8faaf1147 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py @@ -79,13 +79,13 @@ def test_css_weight_searches_of_even_stabilizers_stop_at_bounds_of_each_class_pa syndrome has Hamming weight four but CSS weight three, and the bounds of odd classes stop the search at maximum weight two before it finds an error of weight two. - Answers alone cannot show whether these searches round, since rounding mainly moves where an - odd class stops (see Section 3.2 of ``brouwer_zimmermann.md``). So the test also finds the - stopping step of each query as ``_search_coset`` does, and checks it against the first step - whose bound in ``AffineInstance.get_weight_bounds`` for the parity of the class exceeds the - maximum weight. ``information_sets_test.py`` and ``affine_instance_test.py`` check those bounds - against their definition and against brute force. Some odd classes stop sooner than they would - without parity. + Answers alone cannot show whether these searches round, since rounding mainly moves where an odd + class stops (see Section 3.2 of ``brouwer_zimmermann.md``). So the test also finds the stopping + step of each query as ``_search_coset`` does, and checks it against the first step whose bound + in ``AffineInstance.get_weight_bounds`` for the parity of the class exceeds the maximum weight. + ``certificates_test.py`` and ``affine_instance_test.py`` check those bounds against their + definition and against brute force. Some odd classes stop sooner than they would without + parity. """ generators = ("Y______", "_XZ____", "_ZX__ZZ", "___Y___", "____Y__", "__Z__YZ", "__Z__ZY") matrix = _stabilizer_matrix([stim.PauliString(pauli) for pauli in generators]) @@ -154,7 +154,7 @@ def test_oracles_use_the_tuning_with_which_they_are_built(monkeypatch: pytest.Mo The searches are cached, and the column orders key the cache. So the first oracle built under some orders builds searches of its own with them, and a second oracle built under the same orders shares those searches. The orders reach a search through - ``information_sets.get_disjoint_family``, which the test wraps to record them. The states are + ``basis_families.get_disjoint_family``, which the test wraps to record them. The states are |000+++>, whose two classical searches are cached by stabilizer group, and a two-qubit graph state with phases, which is not CSS, so its search of Pauli errors is cached by stabilizers. Every oracle answers exactly. The caches are cleared first, since earlier tests may have filled @@ -208,8 +208,8 @@ def test_oracles_build_other_families_with_the_tuning_they_are_built_with( With the threshold of the gate at zero, the first query that searches builds the other families of the search (see ``coset_routes``). The most sets of the overlapping family and the column - orders that it tries for each set reach ``information_sets.get_overlapping_family``, and the - width of the blocks of completion tables and their limit of bytes reach + orders that it tries for each set reach ``basis_families.get_overlapping_family``, and the width + of the blocks of completion tables and their limit of bytes reach ``completion_bounds.build_tables_within``, which the test wraps to record them. They are those of the tuning in force when the oracle was built, not when it answers. """ @@ -533,15 +533,15 @@ def test_searches_visit_only_the_sets_that_add_to_their_bounds(pytestconfig: pyt """A search skips exactly the sets that add nothing to the bound of its stopping step. A set of rank ``r`` in a code of dimension ``k`` adds to the bound of a step only once it has - completed layer ``k - r`` (see ``information_sets.get_step_lower_bounds``). A search that stops - at step ``(w, j)`` completes layer ``w`` on the first ``j`` sets and layer ``w - 1`` on the - others, so it visits a set only if that layer is at least ``k - r``. The sets are the disjoint + completed layer ``k - r`` (see ``certificates.get_step_lower_bounds``). A search that stops at + step ``(w, j)`` completes layer ``w`` on the first ``j`` sets and layer ``w - 1`` on the others, + so it visits a set only if that layer is at least ``k - r``. The sets are the disjoint information sets of random codes, whose last one is often rank deficient, and a set of rank zero with every row of the code. A search whose answer is no weighs every word of the layers of the sets that it visits, and every answer is exact. The exact number of words weighed guards the - documented rule for skipping sets, so a legitimate future pruning of the coset scan would - change it. The work of the plan of the steps of the search, from layer zero and without the - sets that it skips (see ``information_sets.plan_steps``), predicts this number. + documented rule for skipping sets, so a legitimate future pruning of the coset scan would change + it. The work of the plan of the steps of the search, from layer zero and without the sets that + it skips (see ``certificates.plan_steps``), predicts this number. """ rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) for _ in range(12): @@ -1010,8 +1010,8 @@ def _coset_minima( ) -> npt.NDArray[np.int_]: """The least weight of the words of the coset of each syndrome, from every word of the coset. - A word with the syndrome sums rows of a right inverse of the checks, from the module under test, - so the helper checks the syndromes of these words. The coset adds to such a word every word of + A word with the syndrome sums rows of a right inverse of the checks, from ``_cosets``, so the + helper checks the syndromes of these words. The coset adds to such a word every word of the code of ``generators``. """ code = np.array(list(itertools.product([0, 1], repeat=len(generators)))) @ generators % 2 diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/distance.py b/src/qldpc/codes/distance/_brouwer_zimmermann/distance.py index d531722e4..c9417c815 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/distance.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/distance.py @@ -5,13 +5,16 @@ The disjoint/rank-deficient certificates follow `Algorithm 994 `_; the symplectic reduction follows the `quantum-code adaptation `_. The overlapping-set certificate and implementation invariants are derived in -``brouwer_zimmermann.md`` beside this module. Information-set families and their certificates are -built in ``information_sets.py``, which fixed-weight logical counting shares. Families of -images of one information set under automorphisms are built in ``automorphism_families.py``, and -tables that let a scan skip hopeless supports in ``completion_bounds.py``. The compiled scans of -layers, which the coset search shares, are in ``affine_search.py``. ``affine_instance.py`` packs -the sets of a search with their certificate, for the coset search too. This module owns witness -discovery, early exits, exhaustive dispatch, and the resumable scans of layers in chunks. +``brouwer_zimmermann.md`` in ``src/qldpc/codes/distance/``. + +Information-set families and their certificates are built in ``_information_sets/``, which +fixed-weight logical counting shares. Its ``basis_families.py`` builds the families, and +``certificates.py`` their certificates. Its ``automorphism_families.py`` builds families of the +images of one information set under automorphisms, and ``completion_bounds.py`` the tables that let +a scan skip hopeless supports. Beside this module, ``affine_search.py`` holds the compiled scans of +layers, and ``affine_instance.py`` packs the sets of a search with their certificate. The coset +searches share both. This module owns witness discovery, early +exits, exhaustive dispatch, and the resumable scans of layers in chunks. """ from __future__ import annotations @@ -74,9 +77,9 @@ def get_distance_brouwer_zimmermann_many( get_disjoint_basis_families: If given, holds a function for each problem that returns a disjoint basis family of it: sets with pairwise disjoint pivot blocks that span the problem's basis, with quotient labels of the problem, certified with this - ``weight_divisor``. The result of ``information_sets.get_disjoint_family`` is one, - and a caller that keeps such families saves that first stage. Each function is called - only if its search needs it. + ``weight_divisor``. The result of ``basis_families.get_disjoint_family`` is one, and a + caller that keeps such families saves that first stage. Each function is called only if + its search needs it. get_automorphisms: If given, returns an array of generators of a group of column permutations, or None. Row ``i`` of the array maps column ``j`` to column ``row[j]``. Each generator must map the searched space and the excluded subspace of every problem @@ -555,7 +558,7 @@ def _prepare_brouwer_zimmermann_search( ) -> int | _BrouwerZimmermannSearch: """Prepare one BZ search, or return immediately when another route is cheaper. - The basis family stages of ``information_sets`` run in order, with distance-specific witness, + The basis family stages of ``basis_families`` run in order, with distance-specific witness, cutoff, certificate, and exhaustive exits between them. ``tuning`` sets the thresholds of the stages and exits. ``get_disjoint_basis_family``, if given, returns the result of the first stage. ``get_automorphisms``, if given, lets the search switch to an automorphism family before diff --git a/src/qldpc/codes/distance/_brute_force.py b/src/qldpc/codes/distance/_brute_force.py index 33e9e9a19..0f1b53289 100644 --- a/src/qldpc/codes/distance/_brute_force.py +++ b/src/qldpc/codes/distance/_brute_force.py @@ -11,6 +11,7 @@ from ._binary import get_eligible_rows +# The low bit of each aligned pair of bits of a riffled word, one pair for each qubit. _MASK55 = np.uint64(0x5555555555555555) #################################################################################################### diff --git a/src/qldpc/codes/distance/_connected_support/coset.py b/src/qldpc/codes/distance/_connected_support/coset.py index f42a9e8a1..a11bf607c 100644 --- a/src/qldpc/codes/distance/_connected_support/coset.py +++ b/src/qldpc/codes/distance/_connected_support/coset.py @@ -28,11 +28,11 @@ class _ConnectedSupportCosetSearch: """A coset search of a ``CosetLeaderSearch`` by the connected-support search of its checks. - The words, checks, and syndromes are those of a ``_CosetSearch``: packed Paulis, whose syndrome - for the stabilizers is that of the symplectic search of ``SupportSearch``, or - the X or Z parts of Paulis, whose syndrome is that of a binary search of the checks. The search - starts from the syndrome of a query, and grows supports through its unsatisfied checks (Section - 3.8 of ``connected_support.md``). + The words, checks, and syndromes are those of a BZ ``_CosetSearch`` + (``_brouwer_zimmermann/coset.py``): packed Paulis, whose syndrome for the stabilizers is that of + the symplectic search of ``SupportSearch``, or the X or Z parts of Paulis, whose syndrome is + that of a binary search of the checks. The search starts from the syndrome of a query, and + grows supports through its unsatisfied checks (Section 3.8 of ``connected_support.md``). Like a ``_CosetSearch``, it first looks up a word of weight zero or one by its syndrome: a single variable, or a single-qubit Pauli. It searches only cosets with no such word, so a coset diff --git a/src/qldpc/codes/distance/_connected_support/search.py b/src/qldpc/codes/distance/_connected_support/search.py index 35fdb386b..485b3daea 100644 --- a/src/qldpc/codes/distance/_connected_support/search.py +++ b/src/qldpc/codes/distance/_connected_support/search.py @@ -14,10 +14,11 @@ on a stabilizer state. ``connected_support.md`` in ``src/qldpc/codes/distance/`` derives the search and states its -correctness requirements. The Brouwer-Zimmermann search in ``brouwer_zimmermann.py`` solves the -same problem for any binary code. This module reuses the weight divisors of -``information_sets.py``, and the verification and orbits of automorphisms of -``automorphism_families.py``. +correctness requirements. The Brouwer-Zimmermann search in ``_brouwer_zimmermann/distance.py`` +solves the same problem for any binary code. This module reuses the weight divisors of +``_information_sets/basis_families.py``, and the verification and orbits of automorphisms of +``_information_sets/automorphism_families.py``. The coset searches of ``CosetLeaderSearch`` that +start from a syndrome are in ``coset.py`` beside it. """ from __future__ import annotations diff --git a/src/qldpc/codes/distance/_coset_leader_search.py b/src/qldpc/codes/distance/_coset_leader_search.py index e08c4a5eb..014b5a7aa 100644 --- a/src/qldpc/codes/distance/_coset_leader_search.py +++ b/src/qldpc/codes/distance/_coset_leader_search.py @@ -10,9 +10,9 @@ maximum weight. This module is the entry point of these queries. It splits a query into coset searches, which live -with their methods: ``_brouwer_zimmermann/coset.py``, with the tables of its steps and tails in -``_brouwer_zimmermann/coset_tables.py``, and ``_connected_support/coset.py``. The parts that both -methods share are in ``_cosets.py``. +with their methods. The BZ coset searches are in ``_brouwer_zimmermann/coset.py``, and the tables +of their steps and tails in ``_brouwer_zimmermann/coset_tables.py``. The connected-support coset +searches are in ``_connected_support/coset.py``. What both methods share is in ``_cosets.py``. """ from __future__ import annotations @@ -63,7 +63,8 @@ class CosetLeaderSearch: The oracle answers a query with one or two coset searches. By default, each searches the cosets of a binary code with the Brouwer-Zimmermann (BZ) algorithm (see - ``brouwer_zimmermann.md``), as ``_search_coset`` describes, and ``get_min_weight_bound`` first + ``brouwer_zimmermann.md``), as ``_search_coset`` in ``_brouwer_zimmermann/coset.py`` describes, + and ``get_min_weight_bound`` first looks up words of weight at most one in a table. - In general, one Pauli search covers the Pauli errors themselves, modulo the stabilizers. @@ -85,17 +86,17 @@ class CosetLeaderSearch: share them. With ``method="connected_support"``, each coset search is instead the search of - ``connected_support.SupportSearch`` from the syndrome of a query, through the unsatisfied - stabilizers for Pauli errors, the pure-Z stabilizers for X parts, and the pure-X stabilizers - for Z parts (Section 3.8 of ``connected_support.md``). It is fast if its checks are sparse. - Given stabilizers, such as row-reduced ones, can be dense, so it first replaces its checks with - sparser ones that span the same space, which can fall short of the sparsest generators. Which - checks it finds depends on the tuning, and can change which error a query returns, but not its - answer or its lower bound. The CSS weight is not a sum over qubits, so it weighs errors by CSS - weight only on CSS states, whose X and Z parts it searches separately. Like the BZ - searches, it first looks up errors of weight at most one, so a syndrome with such an error gets - the weight of its lightest errors as a lower bound. For any other syndrome, a query with an - error within ``max_weight`` gives two, and a query with none gives the larger of two and + ``SupportSearch`` in ``_connected_support/search.py`` from the syndrome of a query, through the + unsatisfied stabilizers for Pauli errors, the pure-Z stabilizers for X parts, and the pure-X + stabilizers for Z parts (Section 3.8 of ``connected_support.md``). It is fast if its checks are + sparse. Given stabilizers, such as row-reduced ones, can be dense, so it first replaces its + checks with sparser ones that span the same space, which can fall short of the sparsest + generators. Which checks it finds depends on the tuning, and can change which error a query + returns, but not its answer or its lower bound. The CSS weight is not a sum over qubits, so it + weighs errors by CSS weight only on CSS states, whose X and Z parts it searches separately. + Like the BZ searches, it first looks up errors of weight at most one, so a syndrome with such an + error gets the weight of its lightest errors as a lower bound. For any other syndrome, a query + with an error within ``max_weight`` gives two, and a query with none gives the larger of two and ``max_weight + 1``. ``find_first_not_within_max_weight`` searches each syndrome that the table lacks with increasing weight limits, which can find light errors sooner than one search at ``max_weight``. The BZ searches often give higher bounds. @@ -105,13 +106,13 @@ class CosetLeaderSearch: ``max_css_single_pairs`` configures the BZ searches by CSS weight that are not split into two classical searches. The fields ``overlapping_minimum_work``, ``max_information_sets``, and ``overlapping_attempts_per_set`` configure the families of information sets with which a BZ - search answers costly queries (see ``coset_routes``). These families can change the - representatives and lower bounds of the answers, but never whether an answer is within - ``max_weight``. The threshold ``overlapping_minimum_work`` also decides which queries of the BZ - searches by physical weight, and of the classical searches, weigh words without the pivot + search answers costly queries (see ``_brouwer_zimmermann/coset_routes.py``). These families can + change the representatives and lower bounds of the answers, but never whether an answer is + within ``max_weight``. The threshold ``overlapping_minimum_work`` also decides which queries of + the BZ searches by physical weight, and of the classical searches, weigh words without the pivot columns of each set of their family, and skip the words that tables of bounds prove to weigh - more than ``max_weight`` (see ``coset_routes``). The fields ``coset_completion_block_bits`` and - ``completion_bound_max_table_bytes`` configure these tables. Skipping words can change the + more than ``max_weight`` (see ``coset_routes.py``). The fields ``coset_completion_block_bits`` + and ``completion_bound_max_table_bytes`` configure these tables. Skipping words can change the representatives and lower bounds of the answers without a word within ``max_weight``, but never whether an answer is within it. The fields ``max_sparsifying_rounds`` and ``sparsifying_rounds_without_improvement`` configure how the connected-support searches make diff --git a/src/qldpc/codes/distance/_counting/scan.py b/src/qldpc/codes/distance/_counting/scan.py index 82bdb99ef..6dfb28146 100644 --- a/src/qldpc/codes/distance/_counting/scan.py +++ b/src/qldpc/codes/distance/_counting/scan.py @@ -3,7 +3,7 @@ """The scan of the words of one weight in a sector: its set-up, its loop, and its native chunks. A sector is a basis whose quotient labels mark its nontrivial words. ``count_words`` selects -information sets for the basis, with the basis family stages of ``information_sets.py``, packs +information sets for the basis, with the basis family stages of ``_information_sets/``, packs them, and visits each word of the given weight once, by enumerating combinations of the rows of every set, layer by layer. A step of two or more rows of an information set may skip the supports whose words all weigh more than the given weight, with the completion tables of diff --git a/src/qldpc/codes/distance/_information_sets/automorphism_families.py b/src/qldpc/codes/distance/_information_sets/automorphism_families.py index 94222cda1..f5f3e48a7 100644 --- a/src/qldpc/codes/distance/_information_sets/automorphism_families.py +++ b/src/qldpc/codes/distance/_information_sets/automorphism_families.py @@ -10,8 +10,9 @@ images of ``I`` under all of ``G``, which completes each coefficient layer when ``I`` does. Section 3.7 of ``brouwer_zimmermann.md`` derives the certificate of this family. -The connected-support search of ``connected_support.py`` also uses ``verify_automorphisms``, and the -orbits of ``get_orbits``, whose order fixes its ranks of variables. +The connected-support search of ``_connected_support/search.py`` also uses +``verify_automorphisms``, and the orbits of ``get_orbits``, whose order fixes its ranks of +variables. """ from __future__ import annotations diff --git a/src/qldpc/codes/distance/_information_sets/basis_families.py b/src/qldpc/codes/distance/_information_sets/basis_families.py index f49f2f895..f1c4e0877 100644 --- a/src/qldpc/codes/distance/_information_sets/basis_families.py +++ b/src/qldpc/codes/distance/_information_sets/basis_families.py @@ -1,23 +1,20 @@ # SPDX-License-Identifier: Apache-2.0 -"""Basis families of information sets and their certificates, shared by exact BZ and coset searches. - -Exact distance search (``brouwer_zimmermann.py``) and fixed-weight logical counting -(``_counting/scan.py``) enumerate coefficient layers over the same basis families (see -``brouwer_zimmermann.md`` for the terminology). Layer ``w`` of an information set holds the words -that add ``w`` of its rows to an offset. The offset is zero in the distance and counting scans, -which search a space. The coset-leader oracle (``CosetLeaderSearch``) answers its queries with -coset searches, whose offset is a fixed word of the searched coset (see ``_search_coset`` in -``coset_leader_search.py``). This module owns how those basis families are built, pruned, and -replaced by overlapping ones, and the certificate that bounds the Hamming weight of eligible words -that a basis family has not yet visited. The coset searches use the disjoint information sets, and -for costly queries the families of :func:`get_maximum_disjoint_family` and -:func:`get_overlapping_family` (see ``coset_routes.py``). The distance, counting, and coset scans -all enumerate a layer with :func:`advance_support`. The distance and coset searches find where -they stop with :func:`find_stopping_step`: the first step whose bound exceeds a limit. -:func:`plan_steps` also adds up the work of the steps up to it. A distance search whose certificate -solves linear programs instead finds where it stops in each layer by checking the steps in order -(see ``Certificate.plan``). +"""Basis families of information sets, shared by exact BZ and coset searches and by counts. + +The Brouwer-Zimmermann distance search (``_brouwer_zimmermann/distance.py``) and fixed-weight +logical counting (``_counting/scan.py``) enumerate coefficient layers over the same basis families +(see ``brouwer_zimmermann.md`` for the terminology). Layer ``w`` of an information set holds the +words that add ``w`` of its rows to an offset. The offset is zero in the distance and counting +scans, which search a space. The coset-leader oracle (``CosetLeaderSearch``) answers its queries +with coset searches, whose offset is a fixed word of the searched coset (see ``_search_coset`` in +``_brouwer_zimmermann/coset.py``). This module owns how those basis families are built, pruned, +and replaced by overlapping ones. The coset searches use the disjoint information sets, and for +costly queries the families of :func:`get_maximum_disjoint_family` and +:func:`get_overlapping_family` (see ``_brouwer_zimmermann/coset_routes.py``). The certificate that +bounds the Hamming weight of the eligible words that a basis family has not yet visited, and the +plans of the steps of searches, are in ``certificates.py``. The step from one support of a layer +to the next is in ``support_stepping.py``. A :class:`BasisFamily` stacks its information sets into arrays, so that one set of functions serves every caller, in the subset of Python that numba compiles, and some of them are compiled (see @@ -328,8 +325,8 @@ def get_overlapping_family_policy(tuning: SearchTuning, dimension: int) -> Overl It holds the limit, the minimum work, and the attempts of ``tuning``, the capped binomials of the dimension, and a new generator with a fixed seed. So every search, of the distance, of the - counts, or of cosets (see ``coset_routes.py``), chooses the same replacement for the same basis - family and tuning. + counts, or of cosets (see ``_brouwer_zimmermann/coset_routes.py``), chooses the same replacement + for the same basis family and tuning. """ return OverlappingFamilyPolicy( tuning.max_information_sets, @@ -494,7 +491,7 @@ def _get_no_replacement( Stages three and four return it, and so does ``get_maximum_disjoint_family``. The distance search and the counts then keep their basis family, and the routes of coset searches (see - ``coset_routes.py``) leave the family out of their candidates. + ``_brouwer_zimmermann/coset_routes.py``) leave the family out of their candidates. """ dimension = basis.shape[0] return BasisFamily( diff --git a/src/qldpc/codes/distance/_information_sets/certificates.py b/src/qldpc/codes/distance/_information_sets/certificates.py index e5bc0df38..3e0709d02 100644 --- a/src/qldpc/codes/distance/_information_sets/certificates.py +++ b/src/qldpc/codes/distance/_information_sets/certificates.py @@ -2,9 +2,9 @@ """Certificates of the basis families of exact searches, and the plans of their steps. -A certificate bounds the weights of the words that a search of a basis family has not visited, from -the layers that its sets have completed. It tells a search when it can stop, and a plan of steps -says where a search stops for a weight limit, and the work of the steps up to there. +A certificate bounds the weights of the eligible words that a search of a basis family has not +visited, from the layers that its sets have completed. It tells a search when it can stop, and a +plan of steps says where a search stops for a weight limit, and the work of the steps up to there. """ from __future__ import annotations diff --git a/src/qldpc/codes/distance/_information_sets/completion_bounds.py b/src/qldpc/codes/distance/_information_sets/completion_bounds.py index fb5b2c388..35ddc58f3 100644 --- a/src/qldpc/codes/distance/_information_sets/completion_bounds.py +++ b/src/qldpc/codes/distance/_information_sets/completion_bounds.py @@ -18,9 +18,10 @@ for a minimum weight uses that bound until it finds a word within the maximum weight, and then, when each scan starts, the Hamming weight of its lightest word, or one less where every word of the coset has its parity. Once a scan finds a lighter word, the tables skip at the Hamming weight of the -lightest word that it has found (see ``coset_leader_search._search_coset_for_min_weight``). -The bases of the coset searches need not be information sets, so in place of ``w`` they bound the -ones of a completion on pivot columns (see ``affine_search.scan_coset_layer_by_tails_with_tables``). +lightest word that it has found (see ``_search_coset_for_min_weight`` in +``_brouwer_zimmermann/coset.py``). The bases of the coset searches need not be information sets, so +in place of ``w`` they bound the ones of a completion on pivot columns (see +``scan_coset_layer_by_tails_with_tables`` in ``_brouwer_zimmermann/affine_search.py``). Tables of the least weights on each block, for every value of ``p`` on the block and every ``s``, make each bound a few lookups. They cost memory and time to build, so ``predict_completion_cost`` @@ -32,7 +33,7 @@ cannot lower ``U``. ``considers_tables`` states which layers a tuning considers for tables at all. Coset searches build the tables of the bases of their costliest queries without sampling, if they fit within a limit (see -``coset_routes`` and ``build_tables_within``). +``_brouwer_zimmermann/coset_routes.py``, and ``build_tables_within``). """ from __future__ import annotations diff --git a/src/qldpc/codes/distance/_tuning.py b/src/qldpc/codes/distance/_tuning.py index 40ca94939..3e3f56409 100644 --- a/src/qldpc/codes/distance/_tuning.py +++ b/src/qldpc/codes/distance/_tuning.py @@ -263,8 +263,8 @@ class SearchTuning: #: ``k`` rows, in units of ``b * k**2 * n`` candidate/set evaluations. An underestimate wastes #: time on partitioning that cannot repay itself; an overestimate skips families that would save #: time. Defaults to 20, measured on bases of 85 to 156 rows and 127 to 432 columns, which are - #: not recorded. To refit it, time ``matroid_partition.get_information_sets_with_coverage`` in - #: candidate/set evaluations. + #: not recorded. To refit it, time ``get_information_sets_with_coverage`` of + #: ``_information_sets/matroid_partition.py`` in candidate/set evaluations. partition_cost_factor: int = 20 #: The estimated work, in candidate/set evaluations, from which a search of overlapping @@ -598,12 +598,13 @@ def use_tuning(**changes: Any) -> Iterator[SearchTuning]: #: typical workloads, and keep the smallest size past which it stops rising. BINOMIAL_CACHE_SIZE = 1024 -#: The number of entries that ``coset_leader_search._get_css_searches`` keeps, one for each CSS -#: state and tuning of the setup of the searches, each a pair of classical searches, for the X and Z -#: parts of errors. It takes effect when qLDPC is imported. A larger cache uses more memory, and a -#: smaller one repeats the setup of searches that a workload reuses. Chosen by hand. To retune it, -#: record the hit rate, the setup time of a missed pair, and the memory of each pair on workloads -#: such as certification, and keep the smallest size beyond which the hit rate stops growing. +#: The number of entries that ``_get_css_searches`` in ``_brouwer_zimmermann/coset.py`` keeps, one +#: for each CSS state and tuning of the setup of the searches, each a pair of classical searches, +#: for the X and Z parts of errors. It takes effect when qLDPC is imported. A larger cache uses +#: more memory, and a smaller one repeats the setup of searches that a workload reuses. Chosen by +#: hand. To retune it, record the hit rate, the setup time of a missed pair, and the memory of each +#: pair on workloads such as certification, and keep the smallest size beyond which the hit rate +#: stops growing. CSS_SEARCH_CACHE_SIZE = 8 #: The number of results that each of two caches of the MacWilliams method keeps, one of single @@ -622,10 +623,11 @@ def use_tuning(**changes: Any) -> Iterator[SearchTuning]: #: and keep the smallest size beyond which the hit rate stops growing. LOCAL_CLIFFORD_CACHE_SIZE = 1024 -#: The number of searches of Pauli errors that ``coset_leader_search._get_pauli_search`` keeps, one -#: for each stabilizer matrix, measure of weight, and tuning of the setup of the search. It takes -#: effect when qLDPC is imported. A larger cache uses more memory, a smaller one repeats setups, -#: and zero disables the cache. Only speed and memory depend on it. Chosen by hand. To retune it, -#: record the hit rate, the setup time of a missed search, and the memory per search on workloads -#: such as certification, and keep the smallest size beyond which the hit rate stops growing. +#: The number of searches of Pauli errors that ``_get_pauli_search`` in +#: ``_brouwer_zimmermann/coset.py`` keeps, one for each stabilizer matrix, measure of weight, and +#: tuning of the setup of the search. It takes effect when qLDPC is imported. A larger cache uses +#: more memory, a smaller one repeats setups, and zero disables the cache. Only speed and memory +#: depend on it. Chosen by hand. To retune it, record the hit rate, the setup time of a missed +#: search, and the memory per search on workloads such as certification, and keep the smallest size +#: beyond which the hit rate stops growing. PAULI_SEARCH_CACHE_SIZE = 8 diff --git a/src/qldpc/codes/distance/_tuning_test.py b/src/qldpc/codes/distance/_tuning_test.py index ba0b39e4e..e47eeb1bc 100644 --- a/src/qldpc/codes/distance/_tuning_test.py +++ b/src/qldpc/codes/distance/_tuning_test.py @@ -264,9 +264,9 @@ def test_every_field_reaches_the_code_that_reads_it(monkeypatch: pytest.MonkeyPa and ``_information_sets/basis_families_test.py`` change them there. Others are read once by ``CosetLeaderSearch``, which passes their values down to its coset searches: ``max_css_single_pairs``, ``max_sparsifying_rounds``, and - ``sparsifying_rounds_without_improvement``. Tests in ``_coset_leader_search_test.py`` - change or record these, and the fields that the oracle passes down to the routes of its BZ - searches. + ``sparsifying_rounds_without_improvement``. Tests in ``_coset_leader_search_test.py``, + ``_brouwer_zimmermann/coset_test.py``, and ``_connected_support/coset_test.py`` change or + record these, and the fields that the oracle passes down to the routes of its BZ searches. """ seen: dict[str, list[Any]] = collections.defaultdict(list) diff --git a/src/qldpc/codes/distance/brouwer_zimmermann.md b/src/qldpc/codes/distance/brouwer_zimmermann.md index 7747ef89e..ab12d1926 100644 --- a/src/qldpc/codes/distance/brouwer_zimmermann.md +++ b/src/qldpc/codes/distance/brouwer_zimmermann.md @@ -829,7 +829,7 @@ It also passes a function that returns automorphisms of the code. `get_distance_exact` of `CSSCode` in `src/qldpc/codes/common.py` passes the code's `_found_qudit_automorphisms`, which uses bliss from `src/qldpc/external/_bliss.py` or the code's `_get_qudit_automorphisms` method. `get_distance_exact` of `QuditCode` passes the same function to `_get_distance_quantum`, a private form of `get_distance_quantum` that accepts automorphisms. `_get_distance_quantum` lifts them to the coordinates of a general Pauli problem with `_get_lifted_automorphisms`, and passes its problem to `get_distance_brouwer_zimmermann_many`. -[_information_sets/automorphism_families.py](_information_sets/automorphism_families.py) verifies automorphisms and builds the automorphism families of [Section 3.7](#37-images-of-an-information-set-under-automorphisms), using [_information_sets/basis_families.py](_information_sets/basis_families.py) for their certificates. +[_information_sets/automorphism_families.py](_information_sets/automorphism_families.py) verifies automorphisms and builds the automorphism families of [Section 3.7](#37-images-of-an-information-set-under-automorphisms), using the certificates of [_information_sets/certificates.py](_information_sets/certificates.py). Distance searches and counts use [_information_sets/completion_bounds.py](_information_sets/completion_bounds.py) to choose and build the tables of [Section 3.9](#39-skipping-completions-by-completion-tables), and coset searches use it to choose and build the tables of their routes by tails. The distance search and `CosetLeaderSearch` scan their layers with the compiled entry points of [_brouwer_zimmermann/affine_search.py](_brouwer_zimmermann/affine_search.py), which run one body, `_scan`, with constant flags. `scan_family` scans every set of a family in lockstep, `scan_information_set` scans one information set with or without completion tables, and `scan_coset_layer` and `scan_coset_layer_by_css_weight` scan one layer of one set of a coset and keep its lightest word. @@ -844,6 +844,9 @@ The weight divisor of the certificate rounds its Hamming bounds up to multiples The distance search builds another instance when it switches to an automorphism family, whose certificate covers every image of the one set that it scans. `CosetLeaderSearch` in [_coset_leader_search.py](_coset_leader_search.py), which the fault-propagation certifier in `qldpc.circuits.certification` uses, applies the same search to a coset `u + V` instead of `V`, unless it is built with `method="connected_support"` ([connected_support.md](connected_support.md)). +Its BZ coset searches, `_CosetSearch` with `_search_coset` and `_find_min_weights`, are in [_brouwer_zimmermann/coset.py](_brouwer_zimmermann/coset.py). +The tables of their steps and tails, such as `_get_search_tables`, `_get_stopping_step`, and `_CosetTails`, are in [_brouwer_zimmermann/coset_tables.py](_brouwer_zimmermann/coset_tables.py). +What they share with the connected-support coset searches, such as `MinWeightBound`, is in [_cosets.py](_cosets.py). There a layer adds `w` rows to an **offset**, a word of the coset that is zero on the pivot columns ([Section 2.3](#23-systematic-bases-and-information-sets)), so layer zero is the offset itself. A **query** of `CosetLeaderSearch` asks whether the coset of a syndrome has a word of weight at most a given maximum weight. `CosetLeaderSearch` answers each query with one or two **coset searches**: a search of Pauli errors, or, for the CSS weight on a CSS state, searches of their X and Z parts, as described at the end of this section. @@ -860,7 +863,7 @@ A query of a coset search asks the same of a coset of its code, whose words have It starts from the **word of its syndrome**, a word of the coset that sums the rows of a right inverse of the checks for the set bits of the syndrome. It stops at its **stopping step**, the first step whose bound exceeds the maximum weight, unless it finds a light enough word sooner. It skips each rank-deficient search basis whose deficiency keeps it out of the bound of that step. -`find_stopping_step` of [_information_sets/basis_families.py](_information_sets/basis_families.py) finds the stopping steps of the coset searches, and of the distance searches whose certificates solve no linear programs, by a binary search of steps whose bounds never decrease. +`find_stopping_step` of [_information_sets/certificates.py](_information_sets/certificates.py) finds the stopping steps of the coset searches, and of the distance searches whose certificates solve no linear programs, by a binary search of steps whose bounds never decrease. A certificate that solves linear programs checks steps in order instead, through `Certificate.plan` (see below). `plan_steps` also adds up the work of the steps up to the stopping step ([Section 3.6](#36-exhaustive-enumeration-as-a-fallback)). Each family of a coset search keeps the bounds of its steps in a **planning buffer**, with a table for each parity of the cosets if the search rounds to parity, and one table otherwise. @@ -942,7 +945,7 @@ The tests `test_brouwer_zimmermann_setup_exits`, `test_brouwer_zimmermann_setup_ | Lower certificate `L` | `lower_bound` of a search, computed by `Certificate.lower_bound(completed_weight)` from the certificate's `dimension`, `ranks`, `weight_divisor`, and `max_coverage`; it bounds the eligible words lighter than `U`, so a bound on the distance is `min(L, U)` | | Certificate within a layer `L(t, j)` | `Certificate.step_lower_bound(weight, num_completed)` and the table of `get_step_lower_bounds`. `Certificate.get_num_sets_to_certify` gives the smallest `j` that certifies a bound. `find_stopping_step` gives the first step whose bound exceeds a limit, the stopping step, and `plan_steps` also the work of the steps up to it. `Certificate.plan` gives the stopping step of one layer, with linear programs if the certificate solves them. `Certificate.get_weight_bounds` divides the table by a weight factor, rounding up, after rounding it up to a parity if it is given one. A coset search keeps the results in its planning buffer, `planning` of `_CosetSearchTables`: a table for each parity of its cosets if it rounds to parity, and one table otherwise, each with a cached stopping step. | | Certificate from linear programming | `get_lp_lower_bound`, which a `Certificate` with `pivot_blocks` combines with the coverage-divided certificate; `BasisFamily.get_search_certificate` adds the blocks to costly searches of overlapping families | -| Family of search bases | `BasisFamily`, which stacks the search bases into arrays and gives their `Certificate`. A distance search keeps the `AffineInstance` of [_brouwer_zimmermann/affine_instance.py](_brouwer_zimmermann/affine_instance.py) that packs them, with the certificate. A coset search keeps the generators, pivots, and planning buffer of each family of its routes in `_CosetSearchTables`, and the tails and completion tables of the family of each route by tails in `_CosetTails`. | +| Family of search bases | `BasisFamily` of [_information_sets/basis_families.py](_information_sets/basis_families.py), which stacks the search bases into arrays and gives their `Certificate` of [_information_sets/certificates.py](_information_sets/certificates.py). A distance search keeps the `AffineInstance` of [_brouwer_zimmermann/affine_instance.py](_brouwer_zimmermann/affine_instance.py) that packs them, with the certificate. A coset search keeps the generators, pivots, and planning buffer of each family of its routes in `_CosetSearchTables`, and the tails and completion tables of the family of each route by tails in `_CosetTails`, both of [_brouwer_zimmermann/coset_tables.py](_brouwer_zimmermann/coset_tables.py). | | Automorphisms, generators of `G` | `automorphisms`, whose row `i` maps column `j` to column `automorphisms[i, j]`, as returned by `get_automorphisms`; `_found_qudit_automorphisms` and `_get_qudit_automorphisms` of `QuditCode` | | Automorphism family | `get_automorphism_family` of [_information_sets/automorphism_families.py](_information_sets/automorphism_families.py), which returns a `BasisFamily` of the scanned set and the `Certificate` of all of its images; `_switch_to_automorphism_family` adopts them, and sets `uses_automorphism_family` of the search | | Last completed layer `t`, next layer | `completed_weight`, `next_weight` | @@ -954,7 +957,7 @@ The tests `test_brouwer_zimmermann_setup_exits`, `test_brouwer_zimmermann_setup_ | Stored width `N_stored` | `basis.shape[1]` before pivot deletion, including any all-zero columns | | Labels `M` | `labels`, which has no columns for a classical problem | | Eligible rows | `get_eligible_rows` of [_binary.py](_binary.py): every row of a classical basis; rows with nonzero labels for a quantum problem | -| Support, prefix XORs | `support` and `prefixes` in `advance` and in the scans of [_brouwer_zimmermann/affine_search.py](_brouwer_zimmermann/affine_search.py), which step through a layer with `advance_support` of [_information_sets/basis_families.py](_information_sets/basis_families.py) | +| Support, prefix XORs | `support` and `prefixes` in `advance` and in the scans of [_brouwer_zimmermann/affine_search.py](_brouwer_zimmermann/affine_search.py), which step through a layer with `advance_support` of [_information_sets/support_stepping.py](_information_sets/support_stepping.py) | | Packed generators | `generators` of the `AffineInstance` of a distance search: `uint64` words, after deleting pivot columns. `widths` counts the valid words for each search basis. | | Packed labels | `labels` of the `AffineInstance` of a distance search: `uint64` quotient-label words, without pivot-column deletion; `label_widths` counts the valid words | | Chunk | `batch_size`, equal to `1 << information_set_batch_exponent` supports, or supports and lookups together in a scan with completion tables ([Section 4.5](#45-behavior-spanning-files)) | @@ -1109,7 +1112,7 @@ Changes to the BZ algorithm can use different representations, certificates, and A search for the minimum weight of a coset up to `ω` may stop only at a step whose bound reaches `U`, the lesser of `ω + 1` and the weight of its lightest word, or within a step once that weight is at most the bound of the step before or the floor of the search ([Section 3.10](#310-minimum-weights-of-cosets)). The floor is the weight of the X part of a class in the search of its Z part, for the CSS weight on a CSS state, and zero otherwise. The scans of the search may keep only the words lighter than the incumbent of that section, and its tables may skip only the completions whose bound reaches it: `h(ω)` until it finds a word within `ω`, and then the Hamming weight of its lightest word, less one only where every word of the coset has the parity of that word. - `test_min_weights_of_cosets_match_brute_force` and `test_min_weights_match_exhaustive_minima` in [_coset_leader_search_test.py](_coset_leader_search_test.py) check the weights against every word of each coset, on even codes and with tables, and `test_min_weight_searches_above_max_weight_examine_as_queries` checks that a search of a coset without a word within `ω` weighs and skips as many words as a query at `ω`. + `test_min_weights_of_cosets_match_brute_force` in [_brouwer_zimmermann/coset_test.py](_brouwer_zimmermann/coset_test.py) and `test_min_weights_match_exhaustive_minima` in [_coset_leader_search_test.py](_coset_leader_search_test.py) check the weights against every word of each coset, on even codes and with tables, and `test_min_weight_searches_above_max_weight_examine_as_queries` in [_brouwer_zimmermann/coset_test.py](_brouwer_zimmermann/coset_test.py) checks that a search of a coset without a word within `ω` weighs and skips as many words as a query at `ω`. - **Keep the strict certificate available to fixed-weight counting.** [logical_counts.py](logical_counts.py) builds its basis family with the same stages as the distance search, in `_select_basis_family` and `count_words` of [_counting/scan.py](_counting/scan.py), and stops at the first step after which that basis family's certificate exceeds a target weight `w`, to count every word of weight `w`, which defaults to the distance. Distance may stop at `L(t, j) ≥ U`, but counting must also exhaust words of weight exactly `w`, so it stops only at `L(t, j) > w`. @@ -1120,7 +1123,7 @@ Changes to the BZ algorithm can use different representations, certificates, and The family that answers a coset query, and so its representative and lower bound, depends only on the code, the maximum weight, and the tuning, not on earlier queries. So do the completion tables that it reads: the record of a route can hold tables that earlier routes of its family added, but a query reads only those of the search bases that it scans in layers of two or more rows, which its own route asks for. This matters because the counterexamples of the fault-propagation certifier report the representative and the bound. - `test_answers_do_not_depend_on_earlier_queries` in [_coset_leader_search_test.py](_coset_leader_search_test.py) checks this. + `test_answers_do_not_depend_on_earlier_queries` in [_brouwer_zimmermann/coset_test.py](_brouwer_zimmermann/coset_test.py) checks this. The tests of routes by tails in [_brouwer_zimmermann/coset_routes_test.py](_brouwer_zimmermann/coset_routes_test.py) check that a route asks for the tables of the search bases that its queries scan in layers of two or more rows, and that the latest record of its family holds them, even when threads choose routes at once. - **Preserve the return contract.** Return the exact minimum unless taking an early exit permitted by `cutoff`. @@ -1134,7 +1137,7 @@ Representative correctness tests check certificate validity against exhaustive w `test_searches_ask_for_automorphisms_only_before_costly_layers` checks that easy searches do not pay to find automorphisms. `test_bounds_are_exact_relaxations` in [_information_sets/completion_bounds_test.py](_information_sets/completion_bounds_test.py) checks completion bounds against enumerated completions. `test_scan_of_one_information_set` in [_brouwer_zimmermann/affine_search_test.py](_brouwer_zimmermann/affine_search_test.py) checks that the scan finds the lightest word of a layer and weighs or skips each support exactly once, and `test_completion_tables_keep_distances_exact` in [_brouwer_zimmermann/distance_test.py](_brouwer_zimmermann/distance_test.py) checks whole searches against brute force. -`test_coset_tail_scans_with_tables_skip_only_completions_that_reach_the_skip_bound` in [_brouwer_zimmermann/affine_search_test.py](_brouwer_zimmermann/affine_search_test.py) checks the bound of coset scans, including search bases of rank deficiency three, against enumeration, and `test_searches_by_tails_answer_as_searches_of_whole_words` in [_coset_leader_search_test.py](_coset_leader_search_test.py) checks the answers, skipped words, and bounds `R` of coset queries with tables, also on even codes, against searches without tables and brute force. +`test_coset_tail_scans_with_tables_skip_only_completions_that_reach_the_skip_bound` in [_brouwer_zimmermann/affine_search_test.py](_brouwer_zimmermann/affine_search_test.py) checks the bound of coset scans, including search bases of rank deficiency three, against enumeration, and `test_searches_by_tails_answer_as_searches_of_whole_words` in [_brouwer_zimmermann/coset_test.py](_brouwer_zimmermann/coset_test.py) checks the answers, skipped words, and bounds `R` of coset queries with tables, also on even codes, against searches without tables and brute force. In [core_test.py](core_test.py), `test_brouwer_zimmermann_random_cross_checks` forces the BZ route and compares both homogeneous and general Pauli problems with exhaustive results. `test_max_weight` checks weight-limited results against enumeration on every route, and `test_max_weight_caps_cutoff` checks a case that a weight limit without the `cutoff` cap would get wrong. @@ -1147,5 +1150,5 @@ Those tests can evolve with the implementation; they do not make a particular in - M. Grassl, "Searching for linear codes with large minimum distance," in *Discovering Mathematics with Magma*, Springer, 2006, pp. 287-313 ([doi:10.1007/978-3-540-37634-7_13](https://doi.org/10.1007/978-3-540-37634-7_13)): Algorithm 2.5 gives the certificate for overlapping information sets, footnote 2 on page 295 the certificate within a layer, page 299 the rounding to a weight divisor, and Algorithm 2.11 the choice between BZ and the weight distribution. - P. Lisoněk and L. Trummer, "Algorithms for the minimum weight of linear codes," *Advances in Mathematics of Communications* 10(1), 195-207, 2016 ([doi:10.3934/amc.2016.10.195](https://doi.org/10.3934/amc.2016.10.195)): equation (5) gives the certificate within a layer, Proposition 5 a coverage-divided certificate for any blocks, and Section 5.1 builds families of information sets with bounded coverage by matroid partitioning. - [arXiv:2408.10743v2](https://arxiv.org/abs/2408.10743v2): Section 2.4 describes the `Saved_isometry` symplectic-to-Hamming construction. -- [core.py](core.py), [_brouwer_zimmermann/distance.py](_brouwer_zimmermann/distance.py), [_coset_leader_search.py](_coset_leader_search.py), [_brouwer_zimmermann/coset_routes.py](_brouwer_zimmermann/coset_routes.py), [_brouwer_zimmermann/affine_search.py](_brouwer_zimmermann/affine_search.py), [_brouwer_zimmermann/affine_instance.py](_brouwer_zimmermann/affine_instance.py), [_information_sets/basis_families.py](_information_sets/basis_families.py), [_information_sets/automorphism_families.py](_information_sets/automorphism_families.py), [_information_sets/completion_bounds.py](_information_sets/completion_bounds.py), [_information_sets/matroid_partition.py](_information_sets/matroid_partition.py), [_brute_force.py](_brute_force.py), [_binary.py](_binary.py), and [qldpc/_binary.py](../../_binary.py): the implementation. -- [_brouwer_zimmermann/distance_test.py](_brouwer_zimmermann/distance_test.py), [_coset_leader_search_test.py](_coset_leader_search_test.py), [_brouwer_zimmermann/coset_routes_test.py](_brouwer_zimmermann/coset_routes_test.py), [_brouwer_zimmermann/affine_search_test.py](_brouwer_zimmermann/affine_search_test.py), [_brouwer_zimmermann/affine_instance_test.py](_brouwer_zimmermann/affine_instance_test.py), [_information_sets/basis_families_test.py](_information_sets/basis_families_test.py), [_information_sets/automorphism_families_test.py](_information_sets/automorphism_families_test.py), [_information_sets/completion_bounds_test.py](_information_sets/completion_bounds_test.py), and [core_test.py](core_test.py): executable invariants and independent small-instance oracles. +- [core.py](core.py), [_brouwer_zimmermann/distance.py](_brouwer_zimmermann/distance.py), [_coset_leader_search.py](_coset_leader_search.py), [_brouwer_zimmermann/coset.py](_brouwer_zimmermann/coset.py), [_brouwer_zimmermann/coset_tables.py](_brouwer_zimmermann/coset_tables.py), [_brouwer_zimmermann/coset_routes.py](_brouwer_zimmermann/coset_routes.py), [_cosets.py](_cosets.py), [_brouwer_zimmermann/affine_search.py](_brouwer_zimmermann/affine_search.py), [_brouwer_zimmermann/affine_instance.py](_brouwer_zimmermann/affine_instance.py), [_information_sets/basis_families.py](_information_sets/basis_families.py), [_information_sets/certificates.py](_information_sets/certificates.py), [_information_sets/support_stepping.py](_information_sets/support_stepping.py), [_information_sets/automorphism_families.py](_information_sets/automorphism_families.py), [_information_sets/completion_bounds.py](_information_sets/completion_bounds.py), [_information_sets/matroid_partition.py](_information_sets/matroid_partition.py), [_brute_force.py](_brute_force.py), [_binary.py](_binary.py), and [qldpc/_binary.py](../../_binary.py): the implementation. +- [_brouwer_zimmermann/distance_test.py](_brouwer_zimmermann/distance_test.py), [_coset_leader_search_test.py](_coset_leader_search_test.py), [_brouwer_zimmermann/coset_test.py](_brouwer_zimmermann/coset_test.py), [_brouwer_zimmermann/coset_tables_test.py](_brouwer_zimmermann/coset_tables_test.py), [_brouwer_zimmermann/coset_routes_test.py](_brouwer_zimmermann/coset_routes_test.py), [_cosets_test.py](_cosets_test.py), [_brouwer_zimmermann/affine_search_test.py](_brouwer_zimmermann/affine_search_test.py), [_brouwer_zimmermann/affine_instance_test.py](_brouwer_zimmermann/affine_instance_test.py), [_information_sets/basis_families_test.py](_information_sets/basis_families_test.py), [_information_sets/certificates_test.py](_information_sets/certificates_test.py), [_information_sets/support_stepping_test.py](_information_sets/support_stepping_test.py), [_information_sets/automorphism_families_test.py](_information_sets/automorphism_families_test.py), [_information_sets/completion_bounds_test.py](_information_sets/completion_bounds_test.py), and [core_test.py](core_test.py): executable invariants and independent small-instance oracles. diff --git a/src/qldpc/codes/distance/conftest.py b/src/qldpc/codes/distance/conftest.py index 0307c0749..23590b5e2 100644 --- a/src/qldpc/codes/distance/conftest.py +++ b/src/qldpc/codes/distance/conftest.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 -"""Shared fixtures and helpers for exact-distance, binary-algebra, BZ, and count tests.""" +"""Shared fixtures and helpers for tests of exact distances, counts, and coset-leader searches.""" import itertools from collections.abc import Iterable, Sequence @@ -289,10 +289,11 @@ def _pack_syndromes(syndromes: Sequence[int], num_qubits: int) -> npt.NDArray[np # Graph states whose searches of Pauli errors, by physical and by CSS weight, have a maximum # disjoint family with more information sets, or a larger partial set, than their greedy packing, -# which their costly queries take (see ``coset_routes``). The family of the first state for the -# physical weight has more information sets, and those of the other two states have a larger partial -# set. The two states for the physical weight differ in the answers that this family changes. On -# the second, it returns other errors for some classes without a light enough error. +# which their costly queries take (see ``_brouwer_zimmermann/coset_routes.py``). The family of the +# first state for the physical weight has more information sets, and those of the other two states +# have a larger partial set. The two states for the physical weight differ in the answers that this +# family changes. On the second, it returns other errors for some classes without a light enough +# error. _ROUTED_PHYSICAL_STATES = ( _graph_state(5, [(0, 3), (1, 2), (1, 3), (1, 4)], phase=True), _graph_state(5, [(0, 4), (2, 3)]), diff --git a/src/qldpc/codes/distance/connected_support.md b/src/qldpc/codes/distance/connected_support.md index 78862a9af..2257a0680 100644 --- a/src/qldpc/codes/distance/connected_support.md +++ b/src/qldpc/codes/distance/connected_support.md @@ -342,9 +342,9 @@ The names below are in `_connected_support/search.py`, unless the table says oth | Search through a weight limit `W` | `SupportSearch.find_witness` | | Syndrome query | `SupportSearch.find_vector_with_syndrome`, which coset searches skip for `SupportSearch._find_vector_with_packed_syndrome`, and the root `-1` of `_search_supports` | | Overlaps and repairs that a syndrome query reuses within a state | `memoize` of `_search_supports`, and `memos` and `stamp` of `_get_branches` | -| Coset leaders of Pauli errors, and the sparser checks that their searches use | `_ConnectedSupportCosetSearch`, `_get_sparser_rows`, `_sparsify`, and `CosetLeaderSearch` in `_coset_leader_search.py` | +| Coset leaders of Pauli errors, and the sparser checks that their searches use | `_ConnectedSupportCosetSearch`, `_get_sparser_rows`, and `_sparsify` in `_connected_support/coset.py`, and `CosetLeaderSearch` in `_coset_leader_search.py` | | How many rounds make the checks of a coset search sparser | `max_sparsifying_rounds` and `sparsifying_rounds_without_improvement` of `SearchTuning` in `_tuning.py` | -| Table of light vectors, and increasing limits for batches and minimum weights | `_ConnectedSupportCosetSearch` (its `_singles` and `get_min_weight_bound`), and `_get_batch_support_bound` and `_get_min_support_weight` of `CosetLeaderSearch`, in `_coset_leader_search.py` | +| Table of light vectors, and increasing limits for batches and minimum weights | `_ConnectedSupportCosetSearch` (its `_singles` and `get_min_weight_bound`) in `_connected_support/coset.py`, and `_get_batch_support_bound` and `_get_min_support_weight` of `CosetLeaderSearch` in `_coset_leader_search.py` | | Logical test | `SupportSearch._get_tests`, and `_is_outside_excluded` | | Roots, ranks, and symmetries | `SupportSearch._use_automorphisms`, and `verify_automorphisms` and `get_orbits` of `_information_sets/automorphism_families.py` | | States, branching, and undoing what a state forbade when the search leaves it | `_search_supports`, whose `levels` mark the available symbols | diff --git a/src/qldpc/codes/distance/logical_counts.py b/src/qldpc/codes/distance/logical_counts.py index 2eb9c30be..9547172d2 100644 --- a/src/qldpc/codes/distance/logical_counts.py +++ b/src/qldpc/codes/distance/logical_counts.py @@ -6,7 +6,7 @@ Each X/Z sector uses a normalizer basis whose quotient labels mark nontrivial words (Section 3.4 of ``brouwer_zimmermann.md``). Words are enumerated in layers over Brouwer--Zimmermann (BZ) -information sets, whose family and certificate come from the stages in ``information_sets.py`` that +information sets, whose family and certificate come from the stages in ``_information_sets/`` that the distance search shares. Unlike a distance search, the scan stops only when the BZ lower bound exceeds ``w`` (Section 5 of ``brouwer_zimmermann.md``), and a word visited by several information sets is counted by only one of them. Full Pauli counts are derived from the homogeneous words of From 699e9c46e00654898837a20a15eb9ea02fd4efeb Mon Sep 17 00:00:00 2001 From: "Michael A. Perlin" Date: Thu, 12 Nov 2026 01:45:01 -0500 Subject: [PATCH 5/7] Name plainly what other modules of the package use The splits made 22 underscore names of the new private modules cross module boundaries, and `_WORK_CAP` already did. The code standard asks private modules to use plain names for what other modules use, so these lose their underscore. Two get clearer names: `_CosetSearch` becomes `BrouwerZimmermannCosetSearch`, beside `ConnectedSupportCosetSearch`, and `_get_inverse` becomes `get_unit_syndrome_words`, since its rows are the transpose of a right inverse. `_packed_row_int` becomes `words_to_int`, the inverse of `int_to_words`. `_get_planning` moves after the plain names of `coset_tables.py`. The helpers that several test files share also get plain names, as the other helpers of `distance/conftest.py` have, in a section of their own. No behavior changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/qldpc/codes/distance/README.md | 4 +- .../_brouwer_zimmermann/affine_instance.py | 6 +- .../distance/_brouwer_zimmermann/coset.py | 158 ++++++++-------- .../_brouwer_zimmermann/coset_routes.py | 2 +- .../_brouwer_zimmermann/coset_tables.py | 98 +++++----- .../_brouwer_zimmermann/coset_tables_test.py | 36 ++-- .../_brouwer_zimmermann/coset_test.py | 178 +++++++++--------- .../distance/_brouwer_zimmermann/distance.py | 2 +- .../distance/_connected_support/coset.py | 46 ++--- .../distance/_connected_support/coset_test.py | 22 ++- .../codes/distance/_coset_leader_search.py | 56 +++--- .../distance/_coset_leader_search_test.py | 136 ++++++------- src/qldpc/codes/distance/_cosets.py | 27 +-- src/qldpc/codes/distance/_cosets_test.py | 20 +- .../_information_sets/basis_families.py | 6 +- .../_information_sets/certificates.py | 18 +- .../_information_sets/certificates_test.py | 2 +- .../_information_sets/completion_bounds.py | 2 +- src/qldpc/codes/distance/_tuning.py | 4 +- .../codes/distance/brouwer_zimmermann.md | 22 +-- src/qldpc/codes/distance/conftest.py | 46 ++--- src/qldpc/codes/distance/connected_support.md | 4 +- 22 files changed, 457 insertions(+), 438 deletions(-) diff --git a/src/qldpc/codes/distance/README.md b/src/qldpc/codes/distance/README.md index bf052de69..b168cddae 100644 --- a/src/qldpc/codes/distance/README.md +++ b/src/qldpc/codes/distance/README.md @@ -107,9 +107,9 @@ Section 4.4 of `brouwer_zimmermann.md` says where each heuristic of a Brouwer– - A search reads the active tuning once, when it starts, and passes the values that it needs down to the stages and loops that use them. The distance functions of `core.py` read it for a distance search, `CSSLogicalCounter.count_logical_ops` for a count, and `CosetLeaderSearch` when it is built; `get_distance_brouwer_zimmermann_many` and `get_distance_connected_support` read it only if their callers pass none. Pass these values as arguments, including to compiled code, and do not read the tuning anywhere else, so that one search never mixes two tunings. - A value that the code keeps in a cache that outlives a search also keys that cache, as the fields of `_CosetTuning` key the cached searches of `CosetLeaderSearch`. + A value that the code keeps in a cache that outlives a search also keys that cache, as the fields of `CosetTuning` key the cached searches of `CosetLeaderSearch`. - Only a value that qLDPC fixes before any search starts, such as the size of a cache, which `functools.lru_cache` fixes at import, is a constant in `_tuning.py`, read as `_tuning.NAME`. - Constants fixed by the algorithm or by native integer widths, such as `_WORK_CAP`, and the fixed seeds of random generators, which make results reproducible rather than tune anything, stay in the modules that use them. + Constants fixed by the algorithm or by native integer widths, such as `WORK_CAP`, and the fixed seeds of random generators, which make results reproducible rather than tune anything, stay in the modules that use them. - Document each field and constant in its own `#:` comment directly above it, so that Sphinx renders it. Say, in this order, what it decides and in what unit; how a smaller or larger value changes a search, including the effects of its smallest and largest values; how its default was chosen and on what data, or that this is not recorded, in one clause; and what to measure to retune it. Keep each comment readable without knowing the method that uses the value, and within a hundred words. diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/affine_instance.py b/src/qldpc/codes/distance/_brouwer_zimmermann/affine_instance.py index 07e86322c..ed57af84e 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/affine_instance.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/affine_instance.py @@ -19,7 +19,7 @@ selects, with the proven divisor of eligible weights. A coset instance packs the full rows of the sets of a family of information sets of its code, disjoint or overlapping, which a coset search weighs whole or, in a search that weighs Hamming weights, on its routes by tails, by tails derived -from them (see ``coset_tables._CosetTails``). Its certificate takes the maximum coverage of the +from them (see ``coset_tables.CosetTails``). Its certificate takes the maximum coverage of the family, and no divisor, since the words of a coset need not share the divisibility of the code. If every word of the code has even Hamming weight, the Hamming weights of the words of each coset all have one parity instead, and the bounds of a coset may be rounded up to its parity. @@ -142,7 +142,7 @@ def get_coset_instance( one, since the sets are disjoint. ``random_column_orders`` is passed to ``basis_families.get_disjoint_family``. ``weight_factor`` is the weight factor of the instance. It is two for a search of Pauli errors, since an error weighs at least half the Hamming weight - of the binary word that represents it in the search (see ``_get_pauli_search`` in ``coset.py``). + of the binary word that represents it in the search (see ``get_pauli_search`` in ``coset.py``). It is one for a search of X or Z parts. Without generators, each coset is a single word, which the search visits on layer zero of one empty set. @@ -150,7 +150,7 @@ def get_coset_instance( ``u + v`` of a coset has the parity of ``|u|``, since ``|u + v| = |u| + |v| - 2 |u ∩ v|``. The bounds of each coset may then be rounded up to its parity (see ``get_weight_bounds``). The coset search decides whether to, since only it knows whether some coset is odd (see - ``coset_tables._get_search_tables``). + ``coset_tables.get_search_tables``). """ num_generators, num_columns = generators.shape if not num_generators: diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py index e48783157..f57bd6055 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py @@ -20,34 +20,34 @@ from .._binary import symplectic_to_hamming from .._cosets import ( MinWeightBound, - _get_inverse, - _int_to_words, - _packed_row_int, - _row_ints, - _tabulate_singles, - _xor_selected, - _xor_selected_rows, + get_unit_syndrome_words, + int_to_words, + row_ints, + tabulate_singles, + words_to_int, + xor_selected, + xor_selected_rows, ) from .._information_sets import certificates from .._tuning import SearchTuning from . import affine_instance, affine_search, coset_routes from .coset_tables import ( - _CosetSearchTables, - _CosetTails, - _get_coset_tails, - _get_first_step_reaching, - _get_planning_table, - _get_search_tables, - _get_stopping_step, - _PlanningBuffer, - _TailFields, + CosetSearchTables, + CosetTails, + PlanningBuffer, + TailFields, + get_coset_tails, + get_first_step_reaching, + get_planning_table, + get_search_tables, + get_stopping_step, ) #################################################################################################### # coset searches -class _CosetSearch: +class BrouwerZimmermannCosetSearch: """One coset search of a ``CosetLeaderSearch``: a search of the cosets of one binary code. The words of a search are either packed Paulis on ``n`` qubits, in which case the code is @@ -79,7 +79,7 @@ class _CosetSearch: def __init__( self, - routes: coset_routes.CosetRoutes[_CosetSearchTables, _CosetTails], + routes: coset_routes.CosetRoutes[CosetSearchTables, CosetTails], css_weight: bool, num_qubits: int, inverse_rows: tuple[int, ...], @@ -97,14 +97,14 @@ def __init__( # chooses which of these weights a scan computes. The weight factor of the coset instances # is separate: it is two for every search of Pauli errors, including those by CSS weight, # whose scale is zero, since an error weighs at least half the Hamming weight of its - # embedded word (see ``_get_pauli_search``). + # embedded word (see ``get_pauli_search``). self.scale = 0 if css_weight and is_pauli else 1 if css_weight else 2 self._num_qubits = num_qubits self._is_pauli = is_pauli self._inverse_rows = inverse_rows self._num_syndrome_bits = len(inverse_rows) # A query below maximum weight two bounds a coset without words of weight at most one by - # two. If the search is of X or Z parts and rounds to parity (see ``_get_search_tables``), + # two. If the search is of X or Z parts and rounds to parity (see ``get_search_tables``), # every word of an odd coset has odd weight, so such an odd coset has no word of weight two # either, and the query bounds it by three. An odd coset of a search of Pauli errors by # CSS weight can still hold an error of weight two, such as ``YX``, whose binary word has @@ -115,7 +115,7 @@ def __init__( # Map each syndrome of a word of weight zero or one to such a word. For the CSS weight, # ``_weight_one_word`` also pairs the single-qubit X errors with the single-qubit Z errors, # to find ``X_i Z_j`` on distinct qubits. - self._singles, self._x_singles, self._z_singles = _tabulate_singles( + self._singles, self._x_singles, self._z_singles = tabulate_singles( check_columns, num_qubits, is_pauli=is_pauli ) @@ -133,7 +133,7 @@ def get_min_weight_bound(self, syndrome: int, max_weight: int) -> MinWeightBound # Otherwise the coset has no word of weight at most one, so two is a lower bound, and so is # the odd-coset lookup bound for an odd coset. if max_weight < 2: - word = _xor_selected(self._inverse_rows, syndrome) + word = xor_selected(self._inverse_rows, syndrome) return MinWeightBound( False, word, self._odd_coset_lookup_bound if word.bit_count() % 2 else 2 ) @@ -142,7 +142,7 @@ def get_min_weight_bound(self, syndrome: int, max_weight: int) -> MinWeightBound # by tails, and keep the lightest word that the search weighs in ``best``. tables, tails = self._get_route(max_weight) best = np.empty(tables.inverse.shape[1], dtype=np.uint64) - packed_syndrome = _int_to_words(syndrome, max(1, (self._num_syndrome_bits + 63) // 64)) + packed_syndrome = int_to_words(syndrome, max(1, (self._num_syndrome_bits + 63) // 64)) lower_bound = _search_coset(packed_syndrome, max_weight, best, *tables, tails, self.scale) # A search that finds a light enough word returns zero, but every word of the coset weighs # at least two, since the coset has no word of weight at most one. @@ -195,15 +195,15 @@ def unembed(self, embedded_word: npt.NDArray[np.uint64]) -> int: """Convert a packed embedded word into a word, without the columns that the embedding adds. The embedding for the physical weight appends ``x xor z`` to a packed Pauli (see - ``_CosetSearchTables``), and the other embeddings add nothing. + ``CosetSearchTables``), and the other embeddings add nothing. """ - return _packed_row_int(embedded_word) & ((1 << self._num_word_bits) - 1) + return words_to_int(embedded_word) & ((1 << self._num_word_bits) - 1) - def _get_route(self, max_weight: int) -> tuple[_CosetSearchTables, _TailFields | None]: + def _get_route(self, max_weight: int) -> tuple[CosetSearchTables, TailFields | None]: """The tables of the route of a maximum weight, and the fields of its tails, if any. The fields are a plain tuple, which numba types at each call of a compiled search several - times faster than the named tuple ``_CosetTails``. + times faster than the named tuple ``CosetTails``. """ tables, tails = self._routes.get_route(max_weight) return tables, None if tails is None else tails[:] @@ -246,7 +246,7 @@ def _css_singles(self) -> dict[int, int] | None: return _tabulate_css_singles(self._x_singles, self._z_singles, self._max_css_single_pairs) -class _StabilizerKey: +class StabilizerKey: """A stabilizer matrix and its checks, which a cache can hash and compare by the matrix. The checks follow from the stabilizers, so the stabilizers alone key the cache, and the key @@ -267,10 +267,10 @@ def __hash__(self) -> int: return self._hash def __eq__(self, other: object) -> bool: - return isinstance(other, _StabilizerKey) and self._key == other._key + return isinstance(other, StabilizerKey) and self._key == other._key -class _CosetTuning(NamedTuple): +class CosetTuning(NamedTuple): """The fields of the tuning that the setup of a BZ coset search reads, which key its cache. ``disjoint_random_column_orders`` configures the disjoint information sets of the search. Only @@ -292,7 +292,7 @@ class _CosetTuning(NamedTuple): coset_completion_block_bits: int @classmethod - def from_tuning(cls, tuning: SearchTuning, *, paulis_by_css_weight: bool) -> _CosetTuning: + def from_tuning(cls, tuning: SearchTuning, *, paulis_by_css_weight: bool) -> CosetTuning: """The fields of a tuning that the setup of a search reads. ``paulis_by_css_weight`` is True for a search of Pauli errors by CSS weight. Only that @@ -310,7 +310,9 @@ def from_tuning(cls, tuning: SearchTuning, *, paulis_by_css_weight: bool) -> _Co @functools.lru_cache(maxsize=_tuning.PAULI_SEARCH_CACHE_SIZE) -def _get_pauli_search(key: _StabilizerKey, css_weight: bool, tuning: _CosetTuning) -> _CosetSearch: +def get_pauli_search( + key: StabilizerKey, css_weight: bool, tuning: CosetTuning +) -> BrouwerZimmermannCosetSearch: """The Pauli search, of Pauli errors modulo the stabilizers, with syndromes for the stabilizers. ``key`` holds the stabilizers and their checks (see ``CosetLeaderSearch``). The syndromes @@ -320,7 +322,7 @@ def _get_pauli_search(key: _StabilizerKey, css_weight: bool, tuning: _CosetTunin """ stabilizers, checks = key.stabilizers, key.checks num_qubits = len(stabilizers) - inverse = _get_inverse(checks) + inverse = get_unit_syndrome_words(checks) embedded_stabilizers = stabilizers if css_weight else symplectic_to_hamming(stabilizers) embedded_inverse = inverse if css_weight else symplectic_to_hamming(inverse) # An error weighs at least half of the Hamming weight of its embedded word, by either measure of @@ -334,12 +336,12 @@ def _get_pauli_search(key: _StabilizerKey, css_weight: bool, tuning: _CosetTunin tuning=tuning, by_tails=not css_weight, ) - check_columns = _row_ints(np.ascontiguousarray(checks.T)) - return _CosetSearch( + check_columns = row_ints(np.ascontiguousarray(checks.T)) + return BrouwerZimmermannCosetSearch( routes, css_weight, num_qubits, - _row_ints(inverse), + row_ints(inverse), check_columns, is_pauli=True, max_css_single_pairs=tuning.max_css_single_pairs, @@ -347,9 +349,9 @@ def _get_pauli_search(key: _StabilizerKey, css_weight: bool, tuning: _CosetTunin @functools.lru_cache(maxsize=_tuning.CSS_SEARCH_CACHE_SIZE) -def _get_css_searches( - num_qubits: int, num_x_rows: int, x_rows: bytes, z_rows: bytes, tuning: _CosetTuning -) -> tuple[_CosetSearch, _CosetSearch]: +def get_css_searches( + num_qubits: int, num_x_rows: int, x_rows: bytes, z_rows: bytes, tuning: CosetTuning +) -> tuple[BrouwerZimmermannCosetSearch, BrouwerZimmermannCosetSearch]: """The classical searches of the X parts and of the Z parts of the errors on a CSS state. ``x_rows`` holds the X parts of ``num_x_rows`` pure-X stabilizers, and ``z_rows`` the Z parts of @@ -374,8 +376,8 @@ def _get_css_searches( def _get_classical_search( - generators: npt.NDArray[np.uint8], checks: npt.NDArray[np.uint8], tuning: _CosetTuning -) -> _CosetSearch: + generators: npt.NDArray[np.uint8], checks: npt.NDArray[np.uint8], tuning: CosetTuning +) -> BrouwerZimmermannCosetSearch: """The classical search of X or Z parts modulo the row space of independent generators. The row space of the generators is the null space of the independent checks, so each coset is @@ -383,13 +385,15 @@ def _get_classical_search( certificate bounds directly, so its bounds are not halved. """ num_qubits = generators.shape[1] - inverse = _get_inverse(checks) + inverse = get_unit_syndrome_words(checks) # Every column counts as the X part, so the CSS weight of a word is its Hamming weight. routes = _get_routes( generators, inverse, num_qubits, weight_factor=1, tuning=tuning, by_tails=True ) - check_columns = _row_ints(np.ascontiguousarray(checks.T)) - return _CosetSearch(routes, True, num_qubits, _row_ints(inverse), check_columns, is_pauli=False) + check_columns = row_ints(np.ascontiguousarray(checks.T)) + return BrouwerZimmermannCosetSearch( + routes, True, num_qubits, row_ints(inverse), check_columns, is_pauli=False + ) def _get_routes( @@ -398,17 +402,17 @@ def _get_routes( num_x_columns: int, *, weight_factor: int, - tuning: _CosetTuning, + tuning: CosetTuning, by_tails: bool, -) -> coset_routes.CosetRoutes[_CosetSearchTables, _CosetTails]: +) -> coset_routes.CosetRoutes[CosetSearchTables, CosetTails]: """The routes of a coset search of the code of independent embedded generators. The routes start from the coset instance of the disjoint information sets of the generators, with the weight factor ``weight_factor`` (see ``affine_instance.get_coset_instance``). The - tables of every family of the routes come from ``_get_search_tables``, with ``inverse`` and + tables of every family of the routes come from ``get_search_tables``, with ``inverse`` and ``num_x_columns``. If ``by_tails`` is True, the routes whose queries are predicted to examine at least ``tuning.overlapping_minimum_work`` words with their own family are routes by tails, - with records of tails and tables from ``_get_coset_tails``, and otherwise no route is. + with records of tails and tables from ``get_coset_tails``, and otherwise no route is. """ instance = affine_instance.get_coset_instance( generators, @@ -418,9 +422,9 @@ def _get_routes( return coset_routes.CosetRoutes( generators, instance, - functools.partial(_get_search_tables, inverse=inverse, num_x_columns=num_x_columns), + functools.partial(get_search_tables, inverse=inverse, num_x_columns=num_x_columns), functools.partial( - _get_coset_tails, + get_coset_tails, inverse=inverse, block_bits=tuning.coset_completion_block_bits, max_table_bytes=tuning.completion_bound_max_table_bytes, @@ -468,9 +472,9 @@ def _find_first_not_within_max_weight( inverse: npt.NDArray[np.uint64], generators: npt.NDArray[np.uint64], pivots: npt.NDArray[np.int64], - planning: _PlanningBuffer, + planning: PlanningBuffer, x_mask: npt.NDArray[np.uint64], - tails: _TailFields | None, + tails: TailFields | None, scale: int, ) -> tuple[int, int]: """Search the coset of each syndrome in turn with ``_search_coset``. @@ -507,9 +511,9 @@ def _search_coset( inverse: npt.NDArray[np.uint64], generators: npt.NDArray[np.uint64], pivots: npt.NDArray[np.int64], - planning: _PlanningBuffer, + planning: PlanningBuffer, x_mask: npt.NDArray[np.uint64], - tails: _TailFields | None, + tails: TailFields | None, scale: int, ) -> int: """Search the coset of a syndrome for a word of weight at most ``max_weight``. @@ -523,12 +527,12 @@ def _search_coset( embedded word, and its weight is its Hamming weight. The weight of an embedded word is that of its word: its CSS weight if ``scale`` is zero, with its X part selected by ``x_mask``, and otherwise its Hamming weight divided by ``scale``, which is two for the physical weight and one - for X or Z parts. The tables are those of ``_CosetSearchTables``. Row ``j`` of ``inverse`` is + for X or Z parts. The tables are those of ``CosetSearchTables``. Row ``j`` of ``inverse`` is the embedded word of a word whose syndrome has only bit ``j`` set. ``generators`` and ``pivots`` are the information sets of a family of the embedded generators of the code, with pivots padded with -1. ``planning`` is the planning buffer of the search with this family, whose cached stopping steps a query may update. ``tails`` is None, or, on a route by tails - (see ``coset_routes``), the fields of the ``_CosetTails`` of the family, and then ``scale`` is + (see ``coset_routes``), the fields of the ``CosetTails`` of the family, and then ``scale`` is one or two, since the search weighs words by their Hamming weight. Numba compiles the search separately for None and for tails, and leaves out the branches that the type of ``tails`` rules out, so choosing between them costs nothing for each word. @@ -542,13 +546,13 @@ def _search_coset( each sum of ``w`` of its rows, so layer zero is the offset itself. The search reads the bounds of its steps from the table of the planning buffer for the parity of - the Hamming weight of the word of the syndrome (see ``_get_planning_table``). + the Hamming weight of the word of the syndrome (see ``get_planning_table``). The search visits layer zero of the information sets in order, then layer one, and so on. It keeps the lightest word visited in ``best``, and stops at the first word of weight at most ``max_weight``. Once the first ``j`` sets have completed layer ``w``, every unvisited word weighs at least the bound of step ``(w, j)`` in its table. So the search also stops at its - stopping step, the first step whose bound exceeds ``max_weight``, which ``_get_stopping_step`` + stopping step, the first step whose bound exceeds ``max_weight``, which ``get_stopping_step`` finds. It skips each set that adds nothing to the bound of that step (see ``_search_from_syndrome_word``). A search by tails visits the same words in the same order, except those that the tables of ``tails`` prove to lie beyond ``max_weight``, which it skips. @@ -562,10 +566,10 @@ def _search_coset( best_weight, parity = _get_syndrome_word(packed_syndrome, best, inverse, x_mask, scale) if best_weight <= max_weight: return 0 - table = _get_planning_table(planning, parity) - stop_layer, stop_num_completed, stop_bound = _get_stopping_step(table, max_weight) + table = get_planning_table(planning, parity) + stop_layer, stop_num_completed, stop_bound = get_stopping_step(table, max_weight) # The search rounds to the parity of the coset if it weighs Hamming weights and its planning - # buffer has a table for each parity (see ``_get_planning_table``). + # buffer has a table for each parity (see ``get_planning_table``). lower_bound, _, _ = _search_from_syndrome_word( packed_syndrome, max_weight, @@ -596,7 +600,7 @@ def _search_from_syndrome_word( generators: npt.NDArray[np.uint64], pivots: npt.NDArray[np.int64], x_mask: npt.NDArray[np.uint64], - tails: _TailFields | None, + tails: TailFields | None, scale: int, parity: int, ) -> tuple[int, int, int]: @@ -604,9 +608,9 @@ def _search_from_syndrome_word( ``best`` holds that word, of weight ``best_weight`` above ``max_weight``, and ``packed_syndrome`` is its syndrome. The stopping step and its bound are those of - ``_get_stopping_step``. ``parity`` is the parity of the Hamming weights of the words of the + ``get_stopping_step``. ``parity`` is the parity of the Hamming weights of the words of the coset if the search weighs Hamming weights and rounds its bounds to that parity (see - ``_get_search_tables``), and otherwise -1. A set of rank deficiency ``s`` adds to the bound of + ``get_search_tables``), and otherwise -1. A set of rank deficiency ``s`` adds to the bound of a step only once it has completed layer ``s``, so a set that would not have completed it by the stopping step adds nothing, and the search skips it. These are the unused sets of ``certificates.plan_steps``, whose work for this step, from layer zero, counts the words that @@ -761,9 +765,9 @@ def _find_min_weights( inverse: npt.NDArray[np.uint64], generators: npt.NDArray[np.uint64], pivots: npt.NDArray[np.int64], - planning: _PlanningBuffer, + planning: PlanningBuffer, x_mask: npt.NDArray[np.uint64], - tails: _TailFields | None, + tails: TailFields | None, scale: int, ) -> npt.NDArray[np.int64]: """Search the coset of each syndrome in turn with ``_search_coset_for_min_weight``. @@ -801,9 +805,9 @@ def _search_coset_for_min_weight( inverse: npt.NDArray[np.uint64], generators: npt.NDArray[np.uint64], pivots: npt.NDArray[np.int64], - planning: _PlanningBuffer, + planning: PlanningBuffer, x_mask: npt.NDArray[np.uint64], - tails: _TailFields | None, + tails: TailFields | None, scale: int, ) -> tuple[int, int, int]: """Search the coset of a syndrome for its minimum weight, if that is at most ``max_weight``. @@ -856,7 +860,7 @@ def _search_coset_for_min_weight( best_weight, parity = _get_syndrome_word(packed_syndrome, best, inverse, x_mask, scale) if best_weight <= floor: return best_weight, 0, 0 - bounds = _get_planning_table(planning, parity)[:-1] + bounds = get_planning_table(planning, parity)[:-1] num_sets, num_rows, width = generators.shape if tails is None: offsets = _get_offsets(best, generators, pivots) @@ -875,14 +879,14 @@ def _search_coset_for_min_weight( slack = 1 if scale == 2 or rounds else 0 best_units = factor * best_weight upper_bound = min(best_weight, max_weight + 1) - stop_layer, stop_num_completed = _get_first_step_reaching(bounds, upper_bound) + stop_layer, stop_num_completed = get_first_step_reaching(bounds, upper_bound) # Visit each layer one information set at a time, up to the stopping step. The scans take work # space for one set. The search reaches a stopping step by the last layer, whose bounds are # ``NO_WORD_BOUND``, since its upper bound is at most ``max_weight + 1``, which - # ``_CosetSearch.get_min_weights`` keeps at most ``n + 1``. The scans keep each lighter word in - # ``best``, or its support in ``best_support`` by tails, which are only work space, since the - # search returns no word. + # ``BrouwerZimmermannCosetSearch.get_min_weights`` keeps at most ``n + 1``. The scans keep each + # lighter word in ``best``, or its support in ``best_support`` by tails, which are only work + # space, since the search returns no word. weighed, skipped = 0, 0 widths = np.full(1, width, dtype=np.int64) support = np.empty(num_rows, dtype=np.int64) @@ -976,7 +980,7 @@ def _search_coset_for_min_weight( return new_upper_bound, weighed, skipped if new_upper_bound < upper_bound: upper_bound = new_upper_bound - stop_layer, stop_num_completed = _get_first_step_reaching(bounds, upper_bound) + stop_layer, stop_num_completed = get_first_step_reaching(bounds, upper_bound) layer += 1 @@ -1007,7 +1011,7 @@ def _get_offset_tails( """Find the rank of each information set and the tail of its offset. ``word`` is the word of the syndrome ``packed_syndrome``, ``pivots`` are those of - ``_search_coset``, and ``tails`` and ``inverse_tails`` are those of ``_CosetTails``. The rows + ``_search_coset``, and ``tails`` and ``inverse_tails`` are those of ``CosetTails``. The rows of a set with pivots on which the word has ones reduce the word to the offset, since every row is zero on the pivots of the other rows. The tail of the word sums the tails of the rows of the inverse for the set bits of the syndrome. @@ -1016,7 +1020,7 @@ def _get_offset_tails( ranks = np.zeros(num_sets, dtype=np.int64) offset_tails = np.zeros((num_sets, tail_width), dtype=np.uint64) for set_index in range(num_sets): - _xor_selected_rows(packed_syndrome, inverse_tails[set_index], offset_tails[set_index]) + xor_selected_rows(packed_syndrome, inverse_tails[set_index], offset_tails[set_index]) rank = 0 while rank < num_rows and pivots[set_index, rank] >= 0: pivot = pivots[set_index, rank] @@ -1092,7 +1096,7 @@ def _get_syndrome_word( the parity of its Hamming weight. """ word[:] = 0 - _xor_selected_rows(packed_syndrome, inverse, word) + xor_selected_rows(packed_syndrome, inverse, word) weight, ones = _get_weight(word, x_mask, scale) return weight, ones % 2 diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_routes.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_routes.py index 77bf5c0ee..4eb7feafb 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_routes.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_routes.py @@ -48,7 +48,7 @@ whether a query may take another family. A route is a route by tails if those with the route's own family, which can do less work, also reach the threshold. The queries of a route by tails weigh the words of its family by their tails, which hold the columns other than the pivot columns of each set -(see ``coset_tables._CosetTails``). They also skip the completions that completion tables of the +(see ``coset_tables.CosetTails``). They also skip the completions that completion tables of the tails show to lie beyond their maximum weight (Section 3.9 of ``brouwer_zimmermann.md``). So the routes also give the record of the tails and tables of the family of each route by tails, with a builder that the search gives them. A search that gives none, such as the search of Pauli errors by diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py index 8ce23d560..9243abfad 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py @@ -13,17 +13,17 @@ from qldpc._binary import pack_bits, unpack_bits from qldpc._compilation import always_compiled -from .._cosets import _int_to_words +from .._cosets import int_to_words from .._information_sets import certificates, completion_bounds from . import affine_instance # The planning buffer of a coset search: its tables of the bounds of its steps, each with one more # row that caches a stopping step (see ``_get_planning``). -_PlanningBuffer = npt.NDArray[np.int64] +PlanningBuffer = npt.NDArray[np.int64] -# The fields of a ``_CosetTails`` record, which the compiled searches take as the record itself or +# The fields of a ``CosetTails`` record, which the compiled searches take as the record itself or # as a plain tuple. -_TailFields = tuple[ +TailFields = tuple[ npt.NDArray[np.uint64], npt.NDArray[np.uint64], npt.NDArray[np.uint8], @@ -32,7 +32,7 @@ ] -class _CosetSearchTables(NamedTuple): +class CosetSearchTables(NamedTuple): """The tables of ``_search_coset`` for one coset search of a ``CosetLeaderSearch``. The search runs on embedded words. For a search of Pauli errors, the embedded word of an error @@ -49,7 +49,7 @@ class _CosetSearchTables(NamedTuple): pivots: The pivot columns of each set, padded with -1 beyond its rank. planning: The planning buffer of the search with this family: its tables of the bounds of its steps, each with one more row that caches a stopping step (see ``_get_planning``). - For a nonnegative maximum weight, ``_get_stopping_step`` reuses the cached step of a + For a nonnegative maximum weight, ``get_stopping_step`` reuses the cached step of a table exactly while it stays the first step whose bound exceeds that weight, and replaces it otherwise. Queries write only the rows of cached steps, and oracles that share a cached search share them. @@ -63,11 +63,11 @@ class _CosetSearchTables(NamedTuple): inverse: npt.NDArray[np.uint64] generators: npt.NDArray[np.uint64] pivots: npt.NDArray[np.int64] - planning: _PlanningBuffer + planning: PlanningBuffer x_mask: npt.NDArray[np.uint64] -class _CosetTails(NamedTuple): +class CosetTails(NamedTuple): """The tails with which ``_search_coset`` weighs the words of a family by tails, and its tables. The tail of an embedded word on a set of the family holds every column of the word but the @@ -80,7 +80,7 @@ class _CosetTails(NamedTuple): Attributes: tails: The tails of the rows of each set, with shape ``(num_sets, num_rows, num_words)``. - inverse_tails: The tails of the rows of ``inverse`` of ``_CosetSearchTables`` on each set, + inverse_tails: The tails of the rows of ``inverse`` of ``CosetSearchTables`` on each set, with shape ``(num_sets, num_syndrome_bits, num_words)``. The tail of the word of a syndrome on a set is the sum of the rows for the set bits of the syndrome. These rows can be derived from ``inverse``, but a query that sums them directly finds the tails of @@ -102,9 +102,9 @@ class _CosetTails(NamedTuple): block_bits: int -def _get_search_tables( +def get_search_tables( instance: affine_instance.AffineInstance, inverse: npt.NDArray[np.uint8], num_x_columns: int -) -> _CosetSearchTables: +) -> CosetSearchTables: """Build the tables of ``_search_coset`` for the cosets of the code of a coset instance. ``inverse`` holds embedded words whose syndromes have one bit set, and the first @@ -118,14 +118,14 @@ def _get_search_tables( halved, so that rounding them up to even would change none. """ width = instance.generators.shape[2] - x_mask = _int_to_words((1 << num_x_columns) - 1, width) + x_mask = int_to_words((1 << num_x_columns) - 1, width) # Round to the parity of each coset if the code is even and some coset is odd. The rows of any # one information set span the code. even_code = not np.any(np.bitwise_count(instance.generators[0]).sum(axis=1) % 2) has_odd_coset = bool(np.any(np.count_nonzero(inverse, axis=1) % 2)) parities = (0, 1) if even_code and has_odd_coset else (None,) - return _CosetSearchTables( + return CosetSearchTables( pack_bits(inverse), instance.generators, instance.pivots, @@ -134,15 +134,15 @@ def _get_search_tables( ) -def _get_coset_tails( +def get_coset_tails( instance: affine_instance.AffineInstance, sets: list[int], - record: _CosetTails | None, + record: CosetTails | None, *, inverse: npt.NDArray[np.uint8], block_bits: int, max_table_bytes: int, -) -> _CosetTails: +) -> CosetTails: """Build the record of the tails of the family of a coset instance, with tables of some sets. ``inverse`` holds embedded words whose syndromes have one bit set, with every column of the @@ -174,7 +174,7 @@ def _get_coset_tails( # block_bits divides the 64 bits of a word. max_blocks = 64 * tail_width // block_bits layout = np.zeros((num_sets, max_blocks + 3), dtype=np.int64) - record = _CosetTails(tails, inverse_tails, np.zeros(0, np.uint8), layout, block_bits) + record = CosetTails(tails, inverse_tails, np.zeros(0, np.uint8), layout, block_bits) # Append the tables of each set that has none yet, if they fit, and lay them out. The routes # keep only the latest record of a family, so the earlier tables are held once. @@ -199,36 +199,8 @@ def _get_coset_tails( ) -def _get_planning(bound_tables: Sequence[npt.NDArray[np.int64]]) -> _PlanningBuffer: - """Build the planning buffer of a coset search from tables of the bounds of its steps. - - The compiled search takes the bounds and its cached stopping steps in this one array, since each - argument of a compiled call adds to the time of every query. The buffer stacks one table for - each given table of bounds. A search that rounds its bounds to parity (see - ``_get_search_tables``) gives two tables: table ``p`` holds the bounds of the cosets whose words - have Hamming weights of parity ``p`` (see ``_get_planning_table``). Other searches give one - table, for every coset. Each table of the buffer has one more row than the bounds: - - - Every row but the last holds the bounds of ``AffineInstance.get_weight_bounds``: entry - ``[w, j]`` bounds the weight of every word that the search has not visited once the first - ``j`` sets have completed layer ``w`` and the others layer ``w - 1``. So column zero of each - row holds the bound before the first step of that row. These rows never change, and code - reads them as the view ``table[:-1]``. - - The last row holds no bounds. Its first two entries are the layer and the number of completed - sets of the stopping step that ``_get_stopping_step`` returned last for this table, or of the - first step, ``(0, 1)``, before its first call, and its other entries are zero. Only - ``_get_stopping_step`` reads and writes this row. - """ - planning = np.zeros( - (len(bound_tables), len(bound_tables[0]) + 1, bound_tables[0].shape[1]), dtype=np.int64 - ) - planning[:, :-1] = bound_tables - planning[:, -1, 1] = 1 - return planning - - @always_compiled -def _get_planning_table(planning: _PlanningBuffer, parity: int) -> npt.NDArray[np.int64]: +def get_planning_table(planning: PlanningBuffer, parity: int) -> npt.NDArray[np.int64]: """The table of a planning buffer for a coset whose Hamming weights have parity ``parity``. The Hamming weights are those of the embedded words of the coset. A buffer with a table for @@ -240,7 +212,7 @@ def _get_planning_table(planning: _PlanningBuffer, parity: int) -> npt.NDArray[n @always_compiled -def _get_stopping_step(table: npt.NDArray[np.int64], max_weight: int) -> tuple[int, int, int]: +def get_stopping_step(table: npt.NDArray[np.int64], max_weight: int) -> tuple[int, int, int]: """Find the step at which a coset query stops, and cache it in a table of the planning buffer. The stopping step is the first step whose bound in the table exceeds the maximum weight: the @@ -280,11 +252,39 @@ def _get_stopping_step(table: npt.NDArray[np.int64], max_weight: int) -> tuple[i @always_compiled(inline=True) -def _get_first_step_reaching(bounds: npt.NDArray[np.int64], target: int) -> tuple[int, int]: +def get_first_step_reaching(bounds: npt.NDArray[np.int64], target: int) -> tuple[int, int]: """The first step whose bound in a table of bounds of steps reaches ``target``. It is the stopping step of ``certificates.find_stopping_step`` for the limit ``target - 1``. - Unlike ``_get_stopping_step``, this function caches no step, so the cached steps of the planning + Unlike ``get_stopping_step``, this function caches no step, so the cached steps of the planning buffer serve only the queries of ``_search_coset``. """ return certificates.find_stopping_step(bounds, target - 1, 0, len(bounds) - 1) + + +def _get_planning(bound_tables: Sequence[npt.NDArray[np.int64]]) -> PlanningBuffer: + """Build the planning buffer of a coset search from tables of the bounds of its steps. + + The compiled search takes the bounds and its cached stopping steps in this one array, since each + argument of a compiled call adds to the time of every query. The buffer stacks one table for + each given table of bounds. A search that rounds its bounds to parity (see + ``get_search_tables``) gives two tables: table ``p`` holds the bounds of the cosets whose words + have Hamming weights of parity ``p`` (see ``get_planning_table``). Other searches give one + table, for every coset. Each table of the buffer has one more row than the bounds: + + - Every row but the last holds the bounds of ``AffineInstance.get_weight_bounds``: entry + ``[w, j]`` bounds the weight of every word that the search has not visited once the first + ``j`` sets have completed layer ``w`` and the others layer ``w - 1``. So column zero of each + row holds the bound before the first step of that row. These rows never change, and code + reads them as the view ``table[:-1]``. + - The last row holds no bounds. Its first two entries are the layer and the number of completed + sets of the stopping step that ``get_stopping_step`` returned last for this table, or of the + first step, ``(0, 1)``, before its first call, and its other entries are zero. Only + ``get_stopping_step`` reads and writes this row. + """ + planning = np.zeros( + (len(bound_tables), len(bound_tables[0]) + 1, bound_tables[0].shape[1]), dtype=np.int64 + ) + planning[:, :-1] = bound_tables + planning[:, -1, 1] = 1 + return planning diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py index c71b0d539..ef5c67e01 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py @@ -15,7 +15,7 @@ from .. import _cosets from .._information_sets import certificates as certificates_module from .._information_sets import completion_bounds -from ..conftest import _RANK_DEFICIENT_CODE, EVEN_ROUTES_CODE +from ..conftest import EVEN_ROUTES_CODE, RANK_DEFICIENT_CODE from . import affine_instance, coset_tables @@ -25,7 +25,7 @@ def test_cached_stopping_steps_are_reused_exactly_while_they_stay_first( """For nonnegative weights, a cached stopping step is reused exactly while it stays first. The stopping step of a maximum weight is the first step whose bound exceeds it. - ``_get_stopping_step`` reuses the step that a planning buffer caches if that step is the + ``get_stopping_step`` reuses the step that a planning buffer caches if that step is the stopping step. Otherwise, it plans the stopping step, which a binary search finds, and caches it. A reuse is observable only as a skipped plan, so the test records the plans. @@ -50,7 +50,7 @@ def record(*args: Any) -> tuple[int, int]: monkeypatch.setattr(certificates_module, "find_stopping_step", record) # Take the Python version even when the function is compiled, since only it calls the recording # function. - function = coset_tables._get_stopping_step + function = coset_tables.get_stopping_step get_stopping_step = getattr(function, "py_func", function) certificates = [ certificates_module.Certificate(0, (0,)), @@ -87,26 +87,26 @@ def test_records_of_tails_add_the_tables_of_the_sets_that_fit() -> None: built under that limit gets the tables of both sets at once. Each record holds the tables of each set once. """ - checks = get_kernel(_RANK_DEFICIENT_CODE) + checks = get_kernel(RANK_DEFICIENT_CODE) instance = affine_instance.get_coset_instance( - _RANK_DEFICIENT_CODE, random_column_orders=0, weight_factor=1 + RANK_DEFICIENT_CODE, random_column_orders=0, weight_factor=1 ) assert instance.ranks.tolist() == [5, 4] - inverse = _cosets._get_inverse(checks) + inverse = _cosets.get_unit_syndrome_words(checks) def get_record( - sets: list[int], record: coset_tables._CosetTails | None, max_table_bytes: int - ) -> coset_tables._CosetTails: + sets: list[int], record: coset_tables.CosetTails | None, max_table_bytes: int + ) -> coset_tables.CosetTails: """The record of the tails of the family, with the tables of the sets that fit.""" - return coset_tables._get_coset_tails( + return coset_tables.get_coset_tails( instance, sets, record, inverse=inverse, block_bits=8, max_table_bytes=max_table_bytes ) - def get_set_tables(record: coset_tables._CosetTails, set_index: int) -> list[int]: + def get_set_tables(record: coset_tables.CosetTails, set_index: int) -> list[int]: """The tables of a set, as its layout places them in the record.""" start, num_blocks = record.layout[set_index, :2] offsets = record.layout[set_index, 2 : num_blocks + 3] - size = (len(_RANK_DEFICIENT_CODE) - 1) * offsets[-1] if num_blocks else 0 + size = (len(RANK_DEFICIENT_CODE) - 1) * offsets[-1] if num_blocks else 0 assert start + size <= len(record.completion_tables) return record.completion_tables[start : start + size].tolist() @@ -135,21 +135,21 @@ def test_search_tables_plan_by_parity_exactly_when_cosets_of_an_even_code_differ of even rows, it plans with one table, and so does the rank-deficient code, which is not even. That table serves cosets of either parity. """ - even_inverse = _cosets._get_inverse(get_kernel(EVEN_ROUTES_CODE)) + even_inverse = _cosets.get_unit_syndrome_words(get_kernel(EVEN_ROUTES_CODE)) assert np.any(even_inverse.sum(axis=1) % 2) - rank_deficient_inverse = _cosets._get_inverse(get_kernel(_RANK_DEFICIENT_CODE)) - assert np.any(_RANK_DEFICIENT_CODE.sum(axis=1) % 2) + rank_deficient_inverse = _cosets.get_unit_syndrome_words(get_kernel(RANK_DEFICIENT_CODE)) + assert np.any(RANK_DEFICIENT_CODE.sum(axis=1) % 2) for code, inverse, parities in [ (EVEN_ROUTES_CODE, even_inverse, (0, 1)), (EVEN_ROUTES_CODE, np.zeros_like(even_inverse), (None,)), - (_RANK_DEFICIENT_CODE, rank_deficient_inverse, (None,)), + (RANK_DEFICIENT_CODE, rank_deficient_inverse, (None,)), ]: instance = affine_instance.get_coset_instance(code, random_column_orders=0, weight_factor=1) - tables = coset_tables._get_search_tables(instance, inverse, 0) + tables = coset_tables.get_search_tables(instance, inverse, 0) expected = [instance.get_weight_bounds(parity).tolist() for parity in parities] assert [table[:-1].tolist() for table in tables.planning] == expected for parity in (0, 1): - table = coset_tables._get_planning_table(tables.planning, parity) + table = coset_tables.get_planning_table(tables.planning, parity) assert table[:-1].tolist() == expected[parity if len(expected) == 2 else 0] @@ -170,4 +170,4 @@ def test_first_steps_reaching_a_target_follow_their_definition() -> None: largest = int(bounds[bounds < certificates_module.NO_WORD_BOUND].max()) for target in range(largest + 2): expected = next(step for step in steps if bounds[step] >= target) - assert coset_tables._get_first_step_reaching(bounds, target) == expected + assert coset_tables.get_first_step_reaching(bounds, target) == expected diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py index 8faaf1147..ce2a5273d 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py @@ -27,22 +27,22 @@ from .._information_sets import certificates as certificates_module from .._tuning import DEFAULT_TUNING, MAX_VALUE, get_tuning, use_tuning from ..conftest import ( - _RANK_DEFICIENT_CODE, - _ROUTED_CSS_WEIGHT_STATE, - _ROUTED_PHYSICAL_STATES, EVEN_ROUTES_CODE, OVERLAPPING_ROUTES_CODE, + RANK_DEFICIENT_CODE, + ROUTED_CSS_WEIGHT_STATE, + ROUTED_PHYSICAL_STATES, TWO_FAMILIES_CODE, - _assert_exact_answers, - _css_minima, - _get_syndrome, - _graph_state, - _pack_syndromes, - _product_state, - _scramble, - _stabilizer_matrix, + assert_exact_answers, get_binary_matrix, + get_css_minima, + get_graph_state, + get_product_state, get_random_full_rank_matrix, + get_stabilizer_matrix, + get_syndrome, + pack_syndromes, + scramble, ) from . import affine_instance, coset, coset_tables @@ -88,8 +88,8 @@ class stops (see Section 3.2 of ``brouwer_zimmermann.md``). So the test also fi parity. """ generators = ("Y______", "_XZ____", "_ZX__ZZ", "___Y___", "____Y__", "__Z__YZ", "__Z__ZY") - matrix = _stabilizer_matrix([stim.PauliString(pauli) for pauli in generators]) - _assert_exact_answers(CosetLeaderSearch(matrix, css_weight=True), matrix, True) + matrix = get_stabilizer_matrix([stim.PauliString(pauli) for pauli in generators]) + assert_exact_answers(CosetLeaderSearch(matrix, css_weight=True), matrix, True) # Read the parity of each syndrome class from every error, and check that the class shares it. num_qubits = len(matrix) @@ -103,21 +103,21 @@ class stops (see Section 3.2 of ``brouwer_zimmermann.md``). So the test also fi # Find the stopping step of each query as ``_search_coset`` does. Steps ``(layer, # num_completed)`` compare as tuples in the order of the search. instance = affine_instance.get_coset_instance(matrix, random_column_orders=0, weight_factor=2) - inverse = _cosets._get_inverse(checks) - tables = coset_tables._get_search_tables(instance, inverse, num_qubits) + inverse = _cosets.get_unit_syndrome_words(checks) + tables = coset_tables.get_search_tables(instance, inverse, num_qubits) unrounded = instance.get_weight_bounds() steps = list(itertools.product(range(len(unrounded)), range(1, unrounded.shape[1]))) best = np.empty(tables.inverse.shape[1], dtype=np.uint64) num_sooner = 0 for syndrome in range(1 << num_qubits): _, parity = coset._get_syndrome_word( - _pack_syndromes([syndrome], num_qubits)[0], best, tables.inverse, tables.x_mask, 0 + pack_syndromes([syndrome], num_qubits)[0], best, tables.inverse, tables.x_mask, 0 ) - table = coset_tables._get_planning_table(tables.planning, parity) + table = coset_tables.get_planning_table(tables.planning, parity) bounds = instance.get_weight_bounds(class_parities[syndrome]) for max_weight in range(2, num_qubits): stopping_step = next(step for step in steps if bounds[step] > max_weight) - assert coset_tables._get_stopping_step(table, max_weight)[:2] == stopping_step + assert coset_tables.get_stopping_step(table, max_weight)[:2] == stopping_step num_sooner += stopping_step < next( step for step in steps if unrounded[step] > max_weight ) @@ -137,15 +137,15 @@ def test_cached_searches_answer_only_for_their_stabilizer_group( eye, zeros = np.eye(3, dtype=np.uint8), np.zeros((3, 3), dtype=np.uint8) zero_plus = np.block([[zeros, eye, zeros, zeros], [zeros, zeros, eye, zeros]]) plus_zero = np.block([[eye, zeros, zeros, zeros], [zeros, zeros, zeros, eye]]) - matrices = [_scramble(matrix, rng) for matrix in (zero_plus, zero_plus, plus_zero)] - coset._get_css_searches.cache_clear() + matrices = [scramble(matrix, rng) for matrix in (zero_plus, zero_plus, plus_zero)] + coset.get_css_searches.cache_clear() oracles = [CosetLeaderSearch(matrix, css_weight=True) for matrix in matrices] for oracle, matrix in zip(oracles, matrices, strict=True): - minima = _css_minima(matrix) + minima = get_css_minima(matrix) for syndrome, max_weight in itertools.product(range(1 << 6), range(4)): bound = oracle.get_min_weight_bound(syndrome, max_weight) assert bound.within_max_weight == (minima[syndrome] <= max_weight) - assert _get_syndrome(matrix, bound.representative) == syndrome + assert get_syndrome(matrix, bound.representative) == syndrome def test_oracles_use_the_tuning_with_which_they_are_built(monkeypatch: pytest.MonkeyPatch) -> None: @@ -174,9 +174,9 @@ def record(*args: Any, random_column_orders: int) -> basis_families.BasisFamily: monkeypatch.setattr(basis_families, "get_disjoint_family", record) eye, zeros = np.eye(3, dtype=np.uint8), np.zeros((3, 3), dtype=np.uint8) zero_plus = np.block([[zeros, eye, zeros, zeros], [zeros, zeros, eye, zeros]]) - phased = _graph_state(2, [(0, 1)], phase=True) - coset._get_css_searches.cache_clear() - coset._get_pauli_search.cache_clear() + phased = get_graph_state(2, [(0, 1)], phase=True) + coset.get_css_searches.cache_clear() + coset.get_pauli_search.cache_clear() default = DEFAULT_TUNING.disjoint_random_column_orders for column_orders, matrix in itertools.product((default, 3), (zero_plus, phased)): with use_tuning(disjoint_random_column_orders=column_orders): @@ -186,7 +186,7 @@ def record(*args: Any, random_column_orders: int) -> basis_families.BasisFamily: second = CosetLeaderSearch(matrix, css_weight=True) assert not orders for oracle in (first, second): - _assert_exact_answers(oracle, matrix, True) + assert_exact_answers(oracle, matrix, True) for matrix, css_weight in ((zero_plus, True), (phased, False), (phased, True)): CosetLeaderSearch(matrix, css_weight=css_weight) paulis_by_css_weight = matrix is phased and css_weight @@ -232,7 +232,7 @@ def record_tables( monkeypatch.setattr(basis_families, "get_overlapping_family", record) monkeypatch.setattr(completion_bounds, "build_tables_within", record_tables) - coset._get_pauli_search.cache_clear() + coset.get_pauli_search.cache_clear() with use_tuning( overlapping_minimum_work=0, max_information_sets=5, @@ -240,7 +240,7 @@ def record_tables( coset_completion_block_bits=4, completion_bound_max_table_bytes=12345, ): - oracle = CosetLeaderSearch(_ROUTED_PHYSICAL_STATES[1]) + oracle = CosetLeaderSearch(ROUTED_PHYSICAL_STATES[1]) for syndrome, max_weight in itertools.product(range(1 << 5), range(2, 5)): oracle.get_min_weight_bound(syndrome, max_weight) assert calls == [(5, 3)] @@ -263,14 +263,14 @@ def test_oracles_under_other_gates_do_not_share_searches(tune: Tune) -> None: [np.zeros_like(checks), checks], ] ) - for matrix in (_ROUTED_PHYSICAL_STATES[1], css_state): + for matrix in (ROUTED_PHYSICAL_STATES[1], css_state): css_weight = matrix is css_state syndromes = range(0, 1 << len(matrix), max(1, (1 << len(matrix)) // 64)) queries = list(itertools.product(syndromes, range(6))) answers = {} for minimum_work in (0, MAX_VALUE): - coset._get_css_searches.cache_clear() - coset._get_pauli_search.cache_clear() + coset.get_css_searches.cache_clear() + coset.get_pauli_search.cache_clear() with use_tuning(overlapping_minimum_work=minimum_work): oracle = CosetLeaderSearch(matrix, css_weight=css_weight) answers[minimum_work] = [oracle.get_min_weight_bound(*query) for query in queries] @@ -294,10 +294,10 @@ def test_cached_pauli_searches_answer_for_their_own_stabilizers_and_weight() -> answers after every search is cached. """ states = [ - _product_state(generators) + get_product_state(generators) for generators in (("Y__", "_Z_", "__X"), ("Y__", "YZ_", "__X"), ("X__", "_Y_", "__Z")) ] - coset._get_pauli_search.cache_clear() + coset.get_pauli_search.cache_clear() oracles = [] for changes in ({}, {"max_css_single_pairs": 0}, {"disjoint_random_column_orders": 3}): with use_tuning(**changes): @@ -310,7 +310,7 @@ def test_cached_pauli_searches_answer_for_their_own_stabilizers_and_weight() -> css_bound = oracles[1][2].get_min_weight_bound(0b011, 1) assert css_bound.within_max_weight and css_bound.representative == 0b001010 for matrix, css_weight, oracle in oracles: - _assert_exact_answers(oracle, matrix, css_weight) + assert_exact_answers(oracle, matrix, css_weight) def test_cached_pauli_searches_ignore_later_changes_to_the_stabilizer_array(tune: Tune) -> None: @@ -325,8 +325,8 @@ def test_cached_pauli_searches_ignore_later_changes_to_the_stabilizer_array(tune stabilizers themselves. """ tune(overlapping_minimum_work=0) - first, second = _ROUTED_CSS_WEIGHT_STATE, _ROUTED_PHYSICAL_STATES[1] - coset._get_pauli_search.cache_clear() + first, second = ROUTED_CSS_WEIGHT_STATE, ROUTED_PHYSICAL_STATES[1] + coset.get_pauli_search.cache_clear() array = first.copy() oracles = [ (first, css_weight, CosetLeaderSearch(array, css_weight=css_weight)) @@ -342,7 +342,7 @@ def test_cached_pauli_searches_ignore_later_changes_to_the_stabilizer_array(tune for css_weight in (False, True) ] for matrix, css_weight, oracle in oracles: - _assert_exact_answers(oracle, matrix, css_weight) + assert_exact_answers(oracle, matrix, css_weight) # A code whose searches for minimum weights by tails skip completions by tables at the threshold @@ -389,7 +389,7 @@ def _assert_classical_cosets_match_brute_force(generators: npt.NDArray[np.uint8] """ num_columns = generators.shape[1] checks = get_kernel(generators) - tuning = coset._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + tuning = coset.CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) search = coset._get_classical_search( generators, checks, tuning._replace(disjoint_random_column_orders=0) ) @@ -401,7 +401,7 @@ def get_least_bound(syndrome: int, max_weight: int) -> int: return max_weight + 1 + int(even and (max_weight + 1 - minima[syndrome]) % 2) syndromes = list(range(1 << len(checks))) - packed_syndromes = _pack_syndromes(syndromes, len(checks)) + packed_syndromes = pack_syndromes(syndromes, len(checks)) for max_weight in range(-1, num_columns + 2): bounds = {} for syndrome in syndromes: @@ -454,13 +454,13 @@ def test_cosets_of_a_rank_deficient_code_match_brute_force() -> None: ``_assert_classical_cosets_match_brute_force``). Below two, a single query of a coset without a word of weight at most one returns a bound of two without searching. """ - _assert_classical_cosets_match_brute_force(_RANK_DEFICIENT_CODE) + _assert_classical_cosets_match_brute_force(RANK_DEFICIENT_CODE) def test_cosets_of_an_even_code_have_bounds_of_their_parity() -> None: """Coset searches of an even code give each coset with no light word a bound of its parity. - The code is ``_RANK_DEFICIENT_CODE`` with a last column that holds the parity of each row, so + The code is ``RANK_DEFICIENT_CODE`` with a last column that holds the parity of each row, so every word of the code has even weight, and the weights of the words of each coset share a parity. A coset without words of weight at most ``m`` then has no word of weight ``m + 1`` if that integer has the other parity, so its lower bound is at least the least integer above ``m`` @@ -470,8 +470,8 @@ def test_cosets_of_an_even_code_have_bounds_of_their_parity() -> None: without parity would fall short of both. The cosets have both parities, and least weights up to four. """ - parities = (_RANK_DEFICIENT_CODE.sum(axis=1, keepdims=True) % 2).astype(np.uint8) - _assert_classical_cosets_match_brute_force(np.hstack([_RANK_DEFICIENT_CODE, parities])) + parities = (RANK_DEFICIENT_CODE.sum(axis=1, keepdims=True) % 2).astype(np.uint8) + _assert_classical_cosets_match_brute_force(np.hstack([RANK_DEFICIENT_CODE, parities])) @pytest.mark.parametrize( @@ -509,7 +509,7 @@ def test_answers_do_not_depend_on_earlier_queries(pytestconfig: pytest.Config, t def get_answers(order: str) -> dict[tuple[int, int], MinWeightBound]: """Every answer of a fresh search, with the maximum weights in the given order.""" - tuning = coset._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + tuning = coset.CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) search = coset._get_classical_search( OVERLAPPING_ROUTES_CODE, checks, tuning._replace(disjoint_random_column_orders=0) ) @@ -560,22 +560,22 @@ def test_searches_visit_only_the_sets_that_add_to_their_bounds(pytestconfig: pyt pivots=np.vstack([family.pivots, np.full(num_rows, -1)]), certificate=certificates_module.Certificate(num_rows, ranks), ) - inverse = _cosets._get_inverse(checks) - tables = coset_tables._get_search_tables(instance, inverse, num_columns) + inverse = _cosets.get_unit_syndrome_words(checks) + tables = coset_tables.get_search_tables(instance, inverse, num_columns) minima = _classical_minima(checks) for syndrome, max_weight in itertools.product( range(1 << len(checks)), range(num_columns + 1) ): best = np.empty(tables.inverse.shape[1], dtype=np.uint64) - packed_syndrome = _pack_syndromes([syndrome], len(checks))[0] + packed_syndrome = pack_syndromes([syndrome], len(checks))[0] best_weight, parity = coset._get_syndrome_word( packed_syndrome, best, tables.inverse, tables.x_mask, 1 ) if best_weight <= max_weight: continue - table = coset_tables._get_planning_table(tables.planning, parity) - stop_layer, stop_num_completed, stop_bound = coset_tables._get_stopping_step( + table = coset_tables.get_planning_table(tables.planning, parity) + stop_layer, stop_num_completed, stop_bound = coset_tables.get_stopping_step( table, max_weight ) lower_bound, weighed, _ = coset._search_from_syndrome_word( @@ -672,8 +672,8 @@ def _assert_searches_by_tails_match_searches_of_words( ), weight_factor=2 if scale == 2 else 1, ) - tables = coset_tables._get_search_tables(instance, inverse, num_columns) - tails = coset_tables._get_coset_tails( + tables = coset_tables.get_search_tables(instance, inverse, num_columns) + tails = coset_tables.get_coset_tails( instance, list(range(len(sets))) if block_bits else [], None, @@ -682,7 +682,7 @@ def _assert_searches_by_tails_match_searches_of_words( max_table_bytes=MAX_VALUE, ) even = scale == 1 and not np.any(generators.sum(axis=1) % 2) - packed_syndromes = _pack_syndromes(syndromes, len(inverse)) + packed_syndromes = pack_syndromes(syndromes, len(inverse)) total_skipped = 0 for max_weight in max_weights: answers = {} @@ -696,8 +696,8 @@ def _assert_searches_by_tails_match_searches_of_words( words[1] = words[0] word = unpack_bits(words[:1], num_columns)[0] coset_parity = int(word.sum()) % 2 - stop_layer, stop_num_completed, stop_bound = coset_tables._get_stopping_step( - coset_tables._get_planning_table(tables.planning, parity), max_weight + stop_layer, stop_num_completed, stop_bound = coset_tables.get_stopping_step( + coset_tables.get_planning_table(tables.planning, parity), max_weight ) *answer, _ = coset._search_from_syndrome_word( packed_syndrome, @@ -864,7 +864,7 @@ def test_searches_by_tails_answer_as_searches_of_whole_words( ( ( code, - _cosets._get_inverse(checks), + _cosets.get_unit_syndrome_words(checks), 1, (num_rows, num_rows - 1), range(-1, code.shape[1] + 2), @@ -875,14 +875,14 @@ def test_searches_by_tails_answer_as_searches_of_whole_words( ) ) if block_bits: - parities = (_RANK_DEFICIENT_CODE.sum(axis=1, keepdims=True) % 2).astype(np.uint8) - even = np.hstack([_RANK_DEFICIENT_CODE, parities]) + parities = (RANK_DEFICIENT_CODE.sum(axis=1, keepdims=True) % 2).astype(np.uint8) + even = np.hstack([RANK_DEFICIENT_CODE, parities]) checks = get_kernel(even) searches.append( ( ( even, - _cosets._get_inverse(checks), + _cosets.get_unit_syndrome_words(checks), 1, (5, 4), range(-1, even.shape[1] + 2), @@ -900,7 +900,7 @@ def test_searches_by_tails_answer_as_searches_of_whole_words( ( ( wide, - _cosets._get_inverse(checks), + _cosets.get_unit_syndrome_words(checks), 1, (4, 3), range(-1, 40, 4), @@ -910,18 +910,18 @@ def test_searches_by_tails_answer_as_searches_of_whole_words( ) ) - matrix = _graph_state(4, [(0, 1), (1, 2)], phase=True) + matrix = get_graph_state(4, [(0, 1), (1, 2)], phase=True) checks = (math.symplectic_conjugate(matrix.astype(int)) % 2).astype(np.uint8) searches.append( ( ( symplectic_to_hamming(matrix), - symplectic_to_hamming(_cosets._get_inverse(checks)), + symplectic_to_hamming(_cosets.get_unit_syndrome_words(checks)), 2, (2, 1) if block_bits else (4, 3), range(-1, 6), range(16), - _css_minima(matrix, css_weight=False), + get_css_minima(matrix, css_weight=False), ), True, ) @@ -943,19 +943,19 @@ def test_queries_of_routes_by_tails_weigh_the_tails_of_their_record( queries at maximum weight two find a light enough word in every coset, even in the cosets whose words weigh at least three, which a search of whole words would reject. """ - get_coset_tails = coset_tables._get_coset_tails + get_coset_tails = coset_tables.get_coset_tails - def get_zero_tails(*args: Any, **kwargs: Any) -> coset_tables._CosetTails: + def get_zero_tails(*args: Any, **kwargs: Any) -> coset_tables.CosetTails: """The record of the tails of a family, with every tail zero.""" record = get_coset_tails(*args, **kwargs) return record._replace( tails=np.zeros_like(record.tails), inverse_tails=np.zeros_like(record.inverse_tails) ) - monkeypatch.setattr(coset, "_get_coset_tails", get_zero_tails) + monkeypatch.setattr(coset, "get_coset_tails", get_zero_tails) generators = np.ones((1, 7), dtype=np.uint8) checks = get_kernel(generators) - tuning = coset._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + tuning = coset.CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) search = coset._get_classical_search( generators, checks, tuning._replace(overlapping_minimum_work=0) ) @@ -964,7 +964,7 @@ def get_zero_tails(*args: Any, **kwargs: Any) -> coset_tables._CosetTails: for syndrome in syndromes: bound = search.get_min_weight_bound(syndrome, 2) assert (bound.within_max_weight, bound.lower_bound) == (True, 2) - packed_syndromes = _pack_syndromes(syndromes, len(checks)) + packed_syndromes = pack_syndromes(syndromes, len(checks)) assert search.find_first_not_within_max_weight(packed_syndromes, 2) == (len(syndromes), None) @@ -980,16 +980,16 @@ def test_queries_of_routes_by_tails_skip_completions_by_the_tables_of_their_reco bound such cosets by four. The code is not even, so four is the least weight beyond the maximum weight that a word of such a coset can have. """ - get_coset_tails = coset_tables._get_coset_tails + get_coset_tails = coset_tables.get_coset_tails - def get_skipping_tails(*args: Any, **kwargs: Any) -> coset_tables._CosetTails: + def get_skipping_tails(*args: Any, **kwargs: Any) -> coset_tables.CosetTails: """The record of the tails of a family, with tables that skip every completion.""" record = get_coset_tails(*args, **kwargs) return record._replace(completion_tables=np.full_like(record.completion_tables, 255)) - monkeypatch.setattr(coset, "_get_coset_tails", get_skipping_tails) + monkeypatch.setattr(coset, "get_coset_tails", get_skipping_tails) checks = get_kernel(OVERLAPPING_ROUTES_CODE) - tuning = coset._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + tuning = coset.CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) search = coset._get_classical_search( OVERLAPPING_ROUTES_CODE, checks, tuning._replace(overlapping_minimum_work=0) ) @@ -1001,7 +1001,7 @@ def get_skipping_tails(*args: Any, **kwargs: Any) -> coset_tables._CosetTails: ] assert missed assert {search.get_min_weight_bound(syndrome, 3).lower_bound for syndrome in missed} == {4} - _, bound = search.find_first_not_within_max_weight(_pack_syndromes(missed, len(checks)), 3) + _, bound = search.find_first_not_within_max_weight(pack_syndromes(missed, len(checks)), 3) assert bound is not None and bound.lower_bound == 4 @@ -1016,13 +1016,13 @@ def _coset_minima( """ code = np.array(list(itertools.product([0, 1], repeat=len(generators)))) @ generators % 2 bits = (np.array(syndromes)[:, None] >> np.arange(len(checks))) & 1 - words = bits @ _cosets._get_inverse(checks) % 2 + words = bits @ _cosets.get_unit_syndrome_words(checks) % 2 assert np.array_equal(_classical_syndrome(checks, words), syndromes) return np.array([((word + code) % 2).sum(axis=1).min() for word in words]) def _assert_min_weights_match_brute_force( - search: coset._CosetSearch, + search: coset.BrouwerZimmermannCosetSearch, syndromes: Sequence[int], minima: npt.NDArray[np.int_], num_syndrome_bits: int, @@ -1037,7 +1037,7 @@ def _assert_min_weights_match_brute_force( Any other coset gets its least weight, or one more than the maximum weight if that is smaller. """ order = rng.permutation(len(syndromes)) - packed_syndromes = _pack_syndromes([syndromes[index] for index in order], num_syndrome_bits) + packed_syndromes = pack_syndromes([syndromes[index] for index in order], num_syndrome_bits) least = minima[order] for max_weight in range(int(least.max()) + 2): random_floors = rng.integers(0, max_weight + 1, len(least)) @@ -1075,10 +1075,10 @@ def test_min_weights_of_cosets_match_brute_force( """ tune(**tuning) rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - tuning_fields = coset._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + tuning_fields = coset.CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) num_rows, num_columns = int(rng.integers(3, 6)), int(rng.integers(8, 11)) random_code = get_random_full_rank_matrix(rng, num_rows, num_columns) - for code in (_RANK_DEFICIENT_CODE, _SKIPPING_CODE, random_code): + for code in (RANK_DEFICIENT_CODE, _SKIPPING_CODE, random_code): even_code = np.hstack([code, code.sum(axis=1, keepdims=True) % 2]).astype(np.uint8) for generators in (code, even_code): checks = get_kernel(generators) @@ -1093,12 +1093,14 @@ def test_min_weights_of_cosets_match_brute_force( _assert_min_weights_match_brute_force(search, early_stop_syndromes, minima, len(checks), rng) if "completion_bound_max_table_bytes" in tuning: return - matrix = _graph_state(4, [(0, 1), (1, 2), (2, 3)], phase=True) + matrix = get_graph_state(4, [(0, 1), (1, 2), (2, 3)], phase=True) checks = (math.symplectic_conjugate(matrix.astype(int)) % 2).astype(np.uint8) - css_fields = coset._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=True) - search = coset._get_pauli_search(coset._StabilizerKey(matrix, checks), True, css_fields) + css_fields = coset.CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=True) + search = coset.get_pauli_search(coset.StabilizerKey(matrix, checks), True, css_fields) syndromes = range(1 << len(matrix)) - _assert_min_weights_match_brute_force(search, syndromes, _css_minima(matrix), len(matrix), rng) + _assert_min_weights_match_brute_force( + search, syndromes, get_css_minima(matrix), len(matrix), rng + ) @pytest.mark.parametrize("tuning", [{}, {"overlapping_minimum_work": 0}], ids=["words", "tails"]) @@ -1117,7 +1119,7 @@ def test_min_weight_searches_above_max_weight_examine_as_queries( each coset. """ tune(**tuning) - tuning_fields = coset._CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) + tuning_fields = coset.CosetTuning.from_tuning(get_tuning(), paulis_by_css_weight=False) cases = [] parities = (_SKIPPING_CODE.sum(axis=1, keepdims=True) % 2).astype(np.uint8) for generators, chosen_syndromes in [ @@ -1130,11 +1132,11 @@ def test_min_weight_searches_above_max_weight_examine_as_queries( search = coset._get_classical_search(generators, checks, tuning_fields) minima = _coset_minima(generators, checks, syndromes) cases.append((search, len(checks), syndromes, minima, True)) - matrix = _ROUTED_PHYSICAL_STATES[1] + matrix = ROUTED_PHYSICAL_STATES[1] checks = (math.symplectic_conjugate(matrix.astype(int)) % 2).astype(np.uint8) - search = coset._get_pauli_search(coset._StabilizerKey(matrix, checks), False, tuning_fields) + search = coset.get_pauli_search(coset.StabilizerKey(matrix, checks), False, tuning_fields) syndromes = list(range(1 << len(matrix))) - cases.append((search, len(matrix), syndromes, _css_minima(matrix, css_weight=False), False)) + cases.append((search, len(matrix), syndromes, get_css_minima(matrix, css_weight=False), False)) for search, num_syndrome_bits, syndromes, minima, must_skip in cases: skipped = 0 @@ -1143,13 +1145,13 @@ def test_min_weight_searches_above_max_weight_examine_as_queries( for syndrome, minimum in zip(syndromes, minima, strict=True): if minimum <= max_weight: continue - packed_syndrome = _pack_syndromes([syndrome], num_syndrome_bits)[0] + packed_syndrome = pack_syndromes([syndrome], num_syndrome_bits)[0] best = np.empty(tables.inverse.shape[1], dtype=np.uint64) best_weight, parity = coset._get_syndrome_word( packed_syndrome, best, tables.inverse, tables.x_mask, search.scale ) - stop_layer, stop_num_completed, stop_bound = coset_tables._get_stopping_step( - coset_tables._get_planning_table(tables.planning, parity), + stop_layer, stop_num_completed, stop_bound = coset_tables.get_stopping_step( + coset_tables.get_planning_table(tables.planning, parity), max_weight, ) _, *expected = coset._search_from_syndrome_word( diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/distance.py b/src/qldpc/codes/distance/_brouwer_zimmermann/distance.py index c9417c815..33473ee49 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/distance.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/distance.py @@ -317,7 +317,7 @@ def advance(self) -> int: # Before a costly layer, consider scanning one set and certifying with its images. Like # the work estimates of certificates, this estimate saturates at their cap, so that the # largest threshold of a tuning forbids the switch. - layer_work = min(num_scanned * math.comb(self.dimension, weight), certificates._WORK_CAP) + layer_work = min(num_scanned * math.comb(self.dimension, weight), certificates.WORK_CAP) if ( self.get_automorphism_family is not None and layer_work >= self.tuning.automorphism_minimum_layer_work diff --git a/src/qldpc/codes/distance/_connected_support/coset.py b/src/qldpc/codes/distance/_connected_support/coset.py index a11bf607c..b992601a3 100644 --- a/src/qldpc/codes/distance/_connected_support/coset.py +++ b/src/qldpc/codes/distance/_connected_support/coset.py @@ -12,11 +12,11 @@ from .. import _tuning from .._cosets import ( MinWeightBound, - _get_inverse, - _int_to_words, - _row_ints, - _tabulate_singles, - _xor_selected, + get_unit_syndrome_words, + int_to_words, + row_ints, + tabulate_singles, + xor_selected, ) from .search import SupportSearch @@ -25,22 +25,22 @@ _SPARSIFYING_SEED = 0 -class _ConnectedSupportCosetSearch: +class ConnectedSupportCosetSearch: """A coset search of a ``CosetLeaderSearch`` by the connected-support search of its checks. - The words, checks, and syndromes are those of a BZ ``_CosetSearch`` + The words, checks, and syndromes are those of a ``BrouwerZimmermannCosetSearch`` (``_brouwer_zimmermann/coset.py``): packed Paulis, whose syndrome for the stabilizers is that of the symplectic search of ``SupportSearch``, or the X or Z parts of Paulis, whose syndrome is that of a binary search of the checks. The search starts from the syndrome of a query, and grows supports through its unsatisfied checks (Section 3.8 of ``connected_support.md``). - Like a ``_CosetSearch``, it first looks up a word of weight zero or one by its syndrome: a - single variable, or a single-qubit Pauli. It searches only cosets with no such word, so a coset - with such a word gets the weight of that word as a lower bound. Any other coset gets two if it - has a word within ``max_weight``. With increasing weight limits from two (see - ``get_min_weight_bound``), it instead gets the weight of its lightest words. Without a word - within ``max_weight``, it gets the larger of two and ``max_weight + 1``. These bounds are often - weaker than those of a ``_CosetSearch``. + Like a ``BrouwerZimmermannCosetSearch``, it first looks up a word of weight zero or one by its + syndrome: a single variable, or a single-qubit Pauli. It searches only cosets with no such + word, so a coset with such a word gets the weight of that word as a lower bound. Any other + coset gets two if it has a word within ``max_weight``. With increasing weight limits from two + (see ``get_min_weight_bound``), it instead gets the weight of its lightest words. Without a + word within ``max_weight``, it gets the larger of two and ``max_weight + 1``. These bounds are + often weaker than those of a ``BrouwerZimmermannCosetSearch``. The search is fast only if its checks are sparse, but the given checks, such as row-reduced stabilizers, can be dense. So it searches the sparser checks of ``_get_sparser_rows``, which @@ -68,8 +68,8 @@ def __init__( # swapped. num_variables = matrix.shape[1] if homogeneous else matrix.shape[1] // 2 syndrome_matrix = matrix if homogeneous else np.roll(matrix, num_variables, axis=1) - columns = _row_ints(np.ascontiguousarray(syndrome_matrix.T)) - self._singles, _, _ = _tabulate_singles(columns, num_variables, is_pauli=not homogeneous) + columns = row_ints(np.ascontiguousarray(syndrome_matrix.T)) + self._singles, _, _ = tabulate_singles(columns, num_variables, is_pauli=not homogeneous) rows, transform = _get_sparser_rows( matrix, @@ -81,7 +81,7 @@ def __init__( # Row ``i`` of the sparse rows sums the given rows that row ``i`` of the transform selects, # so bit ``i`` of a syndrome for them is the parity of those bits of the given syndrome. # Entry ``j`` of the map is column ``j`` of the transform, which a given syndrome bit adds. - self._syndrome_map_columns = _row_ints(np.ascontiguousarray(transform.T)) + self._syndrome_map_columns = row_ints(np.ascontiguousarray(transform.T)) # Without a function that finds automorphisms, the search never asks for them. self._search = SupportSearch( rows, @@ -92,7 +92,7 @@ def __init__( # Row ``j`` of the inverse is a word whose syndrome has only bit ``j`` set, so the words of # the set bits of a syndrome sum to a representative of its coset. syndrome_rows = rows if homogeneous else np.roll(rows, num_variables, axis=1) - self._inverse_rows = _row_ints(_get_inverse(syndrome_rows)) + self._inverse_rows = row_ints(get_unit_syndrome_words(syndrome_rows)) def get_min_weight_bound( self, @@ -103,7 +103,7 @@ def get_min_weight_bound( ) -> MinWeightBound: """Decide whether the coset of this syndrome has a word of weight at most ``max_weight``. - A negative ``max_weight`` admits no word, as for a ``_CosetSearch``. + A negative ``max_weight`` admits no word, as for a ``BrouwerZimmermannCosetSearch``. A search with weight limit ``max_weight`` can visit many heavy supports before it finds a word that is much lighter. Given ``first_limit``, the search instead tries every limit from @@ -128,8 +128,8 @@ def get_min_weight_bound( # bit for each check that it keeps. So the search can start from the packed syndrome, # without the validation of ``find_vector_with_syndrome``, which would cost more than the # search itself in many queries. - syndrome = _xor_selected(self._syndrome_map_columns, syndrome) - packed_syndrome = _int_to_words(syndrome, (self._num_checks + 63) // 64) + syndrome = xor_selected(self._syndrome_map_columns, syndrome) + packed_syndrome = int_to_words(syndrome, (self._num_checks + 63) // 64) # The lookup leaves only cosets without a word below weight two, so no limit below two is # searched, and a single query searches only at ``max_weight``. Each limit within which the @@ -141,7 +141,7 @@ def get_min_weight_bound( if symbols is not None: return MinWeightBound(True, self._get_word(symbols), lower_bound) lower_bound = limit + 1 - representative = _xor_selected(self._inverse_rows, syndrome) + representative = xor_selected(self._inverse_rows, syndrome) return MinWeightBound(False, representative, lower_bound) def _get_word(self, symbols: npt.NDArray[np.int64]) -> int: @@ -224,7 +224,7 @@ def _get_sparser_rows( # so their product with it is the transform. Products of binary matrices in float32 are exact # while their inner dimension is below ``2**24``, and faster than in integers. rows, _ = _sparsify(rows, homogeneous=homogeneous) - inverse = _get_inverse(matrix) + inverse = get_unit_syndrome_words(matrix) product = rows.astype(np.float32) @ inverse.T.astype(np.float32) return rows, (product % 2).astype(np.uint8) diff --git a/src/qldpc/codes/distance/_connected_support/coset_test.py b/src/qldpc/codes/distance/_connected_support/coset_test.py index bed20534c..31c79be0d 100644 --- a/src/qldpc/codes/distance/_connected_support/coset_test.py +++ b/src/qldpc/codes/distance/_connected_support/coset_test.py @@ -16,7 +16,13 @@ from .._coset_leader_search import CosetLeaderSearch from .._tuning import use_tuning -from ..conftest import _css_minima, _get_syndrome, _graph_state, _pauli_weight, _random_css_state +from ..conftest import ( + get_css_minima, + get_graph_state, + get_pauli_weight, + get_random_css_state, + get_syndrome, +) from . import coset @@ -40,7 +46,7 @@ def record(*args: Any, **kwargs: Any) -> tuple[npt.NDArray[np.uint8], npt.NDArra monkeypatch.setattr(coset, "_get_sparser_rows", record) with use_tuning(max_sparsifying_rounds=5, sparsifying_rounds_without_improvement=2): - CosetLeaderSearch(_graph_state(2, [(0, 1)], phase=True), method="connected_support") + CosetLeaderSearch(get_graph_state(2, [(0, 1)], phase=True), method="connected_support") assert calls == [(5, 2)] @@ -53,19 +59,19 @@ def test_connected_support_answers_match_exhaustive_minima(pytestconfig: pytest. gets their weight as its bound. Otherwise a query within ``max_weight`` gets 2, and one with no error within ``max_weight`` gets ``max(2, max_weight + 1)``. The queries of batches, which try increasing weight limits, instead get the exact minimum and a lightest error when they are - within ``max_weight``. These exact bounds imply the inequalities that ``_assert_exact_answers`` + within ``max_weight``. These exact bounds imply the inequalities that ``assert_exact_answers`` checks. A search of Pauli errors from a first weight limit above ``max_weight`` answers as a single query. """ rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) for num_qubits in range(1, 6): edges = [pair for pair in itertools.combinations(range(num_qubits), 2) if rng.integers(2)] - graph_state = _graph_state(num_qubits, edges, phase=True) + graph_state = get_graph_state(num_qubits, edges, phase=True) num_x_stabilizers = [0, num_qubits, num_qubits // 2][num_qubits % 3] - css_state = _random_css_state(num_qubits, num_x_stabilizers, rng) + css_state = get_random_css_state(num_qubits, num_x_stabilizers, rng) for matrix, css_weight in [(graph_state, False), (css_state, False), (css_state, True)]: oracle = CosetLeaderSearch(matrix, css_weight=css_weight, method="connected_support") - minima = _css_minima(matrix, css_weight) + minima = get_css_minima(matrix, css_weight) for syndrome, max_weight in itertools.product( range(1 << num_qubits), range(-1, num_qubits + 1) ): @@ -79,8 +85,8 @@ def test_connected_support_answers_match_exhaustive_minima(pytestconfig: pytest. ) assert late == single for bound, exact in [(single, False), (batched, True)]: - weight = _pauli_weight(bound.representative, num_qubits, css_weight) - assert _get_syndrome(matrix, bound.representative) == syndrome + weight = get_pauli_weight(bound.representative, num_qubits, css_weight) + assert get_syndrome(matrix, bound.representative) == syndrome assert bound.within_max_weight == (minimum <= max_weight) if not bound.within_max_weight: expected_bound = minimum if minimum < 2 else max(2, max_weight + 1) diff --git a/src/qldpc/codes/distance/_coset_leader_search.py b/src/qldpc/codes/distance/_coset_leader_search.py index 014b5a7aa..22bbd7530 100644 --- a/src/qldpc/codes/distance/_coset_leader_search.py +++ b/src/qldpc/codes/distance/_coset_leader_search.py @@ -28,14 +28,14 @@ from ._binary import as_binary_matrix, assert_binary from ._brouwer_zimmermann.coset import ( - _CosetSearch, - _CosetTuning, - _get_css_searches, - _get_pauli_search, - _StabilizerKey, + BrouwerZimmermannCosetSearch, + CosetTuning, + StabilizerKey, + get_css_searches, + get_pauli_search, ) -from ._connected_support.coset import _ConnectedSupportCosetSearch -from ._cosets import MinWeightBound, _packed_row_int, _row_ints, _xor_selected, _xor_selected_rows +from ._connected_support.coset import ConnectedSupportCosetSearch +from ._cosets import MinWeightBound, row_ints, words_to_int, xor_selected, xor_selected_rows from ._tuning import get_tuning # The method of a coset-leader search. @@ -147,13 +147,13 @@ def __init__( tuning = get_tuning() # The connected-support searches make their checks sparser in rounds that the tuning bounds. support_search = functools.partial( - _ConnectedSupportCosetSearch, + ConnectedSupportCosetSearch, max_rounds=tuning.max_sparsifying_rounds, rounds_without_improvement=tuning.sparsifying_rounds_without_improvement, ) # Exactly one of the BZ searches and the connected-support searches is nonempty. - self._searches: tuple[_CosetSearch, ...] = () - self._support_searches: tuple[_ConnectedSupportCosetSearch, ...] = () + self._searches: tuple[BrouwerZimmermannCosetSearch, ...] = () + self._support_searches: tuple[ConnectedSupportCosetSearch, ...] = () self.css_weight = css_weight assert_binary(stabilizers, "stabilizers") matrix = as_binary_matrix(stabilizers, "stabilizers") @@ -174,10 +174,10 @@ def __init__( if combinations is None: if method == "brouwer_zimmermann": self._searches = ( - _get_pauli_search( - _StabilizerKey(matrix, checks), + get_pauli_search( + StabilizerKey(matrix, checks), css_weight, - _CosetTuning.from_tuning(tuning, paulis_by_css_weight=css_weight), + CosetTuning.from_tuning(tuning, paulis_by_css_weight=css_weight), ), ) elif css_weight: @@ -208,12 +208,12 @@ def __init__( support_search(x_rows, homogeneous=True), ) else: - self._searches = _get_css_searches( + self._searches = get_css_searches( n, len(x_rows), pack_bits(x_rows).tobytes(), pack_bits(z_rows).tobytes(), - _CosetTuning.from_tuning(tuning, paulis_by_css_weight=False), + CosetTuning.from_tuning(tuning, paulis_by_css_weight=False), ) # Row ``j`` of ``z_rows`` is the Z part of a pure-Z stabilizer, the product of the given @@ -235,7 +235,7 @@ def __init__( # holds column ``i`` of the X map in its low ``_num_x_syndrome_bits`` bits, and column ``i`` # of the Z map above them. self._num_x_syndrome_bits = len(z_rows) - self._joint_map_columns = _row_ints( + self._joint_map_columns = row_ints( np.ascontiguousarray(np.vstack([x_syndrome_map, z_syndrome_map]).T) ) @@ -293,7 +293,7 @@ def find_first_not_within_max_weight( # The connected-support searches answer one syndrome at a time. if self._support_searches: for index, row in enumerate(syndromes): - bound = self._get_batch_support_bound(_packed_row_int(row), max_weight) + bound = self._get_batch_support_bound(words_to_int(row), max_weight) if not bound.within_max_weight: return index, bound return len(syndromes), None @@ -313,7 +313,7 @@ def find_first_not_within_max_weight( index, _ = search.find_first_not_within_max_weight(part_syndromes, max_weight) if index == len(syndromes): return index, None - return index, self.get_min_weight_bound(_packed_row_int(syndromes[index]), max_weight) + return index, self.get_min_weight_bound(words_to_int(syndromes[index]), max_weight) def _get_min_weights( self, syndromes: npt.NDArray[np.uint64], max_weight: int @@ -322,9 +322,10 @@ def _get_min_weights( The residual-weight estimator of ``qldpc.experimental`` asks for these weights. The BZ searches find the minimum weight of each coset in one search, and answer the syndromes in - one compiled loop for each coset search (see ``_CosetSearch.get_min_weights``). The - connected-support searches answer one syndrome at a time, by trying increasing weight - limits (see ``_get_min_support_weight``). + one compiled loop for each coset search (see + ``BrouwerZimmermannCosetSearch.get_min_weights``). The connected-support searches answer + one syndrome at a time, by trying increasing weight limits (see + ``_get_min_support_weight``). Args: syndromes: Packed syndromes, as for ``find_first_not_within_max_weight``. @@ -346,10 +347,7 @@ def _get_min_weights( # The connected-support searches answer one syndrome at a time. if self._support_searches: return np.array( - [ - self._get_min_support_weight(_packed_row_int(row), max_weight) - for row in syndromes - ], + [self._get_min_support_weight(words_to_int(row), max_weight) for row in syndromes], dtype=np.int64, ) @@ -376,7 +374,7 @@ def _get_min_support_weight(self, syndrome: int, max_weight: int) -> int: The search of the Pauli errors or of the X part tries increasing weight limits from two, so it finds a lightest word of its coset, if within ``max_weight``, and gets its weight as its - lower bound (see ``_ConnectedSupportCosetSearch.get_min_weight_bound``). With two searches, + lower bound (see ``ConnectedSupportCosetSearch.get_min_weight_bound``). With two searches, the search of the Z part starts at a weight limit equal to the weight of the X part, within which any Z part gives the CSS weight of the class. @@ -402,7 +400,7 @@ def _get_batch_support_bound(self, syndrome: int, max_weight: int) -> MinWeightB """Answer a query of a batch with the connected-support searches, by increasing limits. Most syndromes of a batch have an error within ``max_weight``, which a search at a lower - weight limit finds sooner (see ``_ConnectedSupportCosetSearch.get_min_weight_bound``). + weight limit finds sooner (see ``ConnectedSupportCosetSearch.get_min_weight_bound``). ``find_first_not_within_max_weight`` checks the range of every syndrome first (``_check_syndromes``). """ @@ -431,7 +429,7 @@ def _check_syndromes(self, syndromes: npt.NDArray[np.uint64]) -> None: def _split_syndrome(self, syndrome: int) -> tuple[int, int]: """Map a syndrome for the given stabilizers to the syndromes of the parts of its errors.""" - part_syndromes = _xor_selected(self._joint_map_columns, syndrome) + part_syndromes = xor_selected(self._joint_map_columns, syndrome) x_syndrome = part_syndromes & ((1 << self._num_x_syndrome_bits) - 1) return x_syndrome, part_syndromes >> self._num_x_syndrome_bits @@ -487,5 +485,5 @@ def _map_syndromes( """ mapped = np.zeros((len(syndromes), map_columns.shape[1]), dtype=np.uint64) for row in range(len(syndromes)): - _xor_selected_rows(syndromes[row], map_columns, mapped[row]) + xor_selected_rows(syndromes[row], map_columns, mapped[row]) return mapped diff --git a/src/qldpc/codes/distance/_coset_leader_search_test.py b/src/qldpc/codes/distance/_coset_leader_search_test.py index 76cd9351c..57c0a85a0 100644 --- a/src/qldpc/codes/distance/_coset_leader_search_test.py +++ b/src/qldpc/codes/distance/_coset_leader_search_test.py @@ -17,18 +17,18 @@ from ._coset_leader_search import CosetLeaderSearch, CosetSearchMethod from ._cosets import MinWeightBound from .conftest import ( - _ROUTED_CSS_WEIGHT_STATE, - _ROUTED_PHYSICAL_STATES, - _assert_exact_answers, - _css_minima, - _get_syndrome, - _graph_state, - _pack_syndromes, - _pauli_weight, - _product_state, - _random_css_state, - _scramble, - _stabilizer_matrix, + ROUTED_CSS_WEIGHT_STATE, + ROUTED_PHYSICAL_STATES, + assert_exact_answers, + get_css_minima, + get_graph_state, + get_pauli_weight, + get_product_state, + get_random_css_state, + get_stabilizer_matrix, + get_syndrome, + pack_syndromes, + scramble, ) @@ -71,7 +71,7 @@ def _ghz_blocks(num_blocks: int) -> tuple[npt.NDArray[np.uint8], int]: generators = _block_generators(("ZZZ", "X_X", "_XX"), num_blocks) witness = stim.PauliString("__Z" * num_blocks) syndrome = sum((not witness.commutes(gg)) << jj for jj, gg in enumerate(generators)) - return _stabilizer_matrix(generators), syndrome + return get_stabilizer_matrix(generators), syndrome def _block_generators(block: Sequence[str], num_blocks: int) -> list[stim.PauliString]: @@ -135,7 +135,7 @@ def _find_each_not_within_max_weight( found.append(start + index) syndrome = int.from_bytes(syndromes[found[-1]].astype(" max_weight - assert _get_syndrome(matrix, bound.representative) == syndrome + assert get_syndrome(matrix, bound.representative) == syndrome if max_weight >= 2: assert bound == oracle.get_min_weight_bound(syndrome, max_weight) @@ -160,17 +160,19 @@ def test_bounds_match_exhaustive_minima( rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) for num_qubits in range(1, 7): edges = [pair for pair in itertools.combinations(range(num_qubits), 2) if rng.integers(2)] - matrix = _graph_state(num_qubits, edges, phase=num_qubits % 2 == 0) + matrix = get_graph_state(num_qubits, edges, phase=num_qubits % 2 == 0) oracle = CosetLeaderSearch(matrix, css_weight=css_weight) for syndrome in rng.permutation(1 << num_qubits).tolist(): minimum = int(_class_weights(matrix, syndrome, css_weight).min()) for max_weight in rng.permutation(num_qubits + 1).tolist(): bound = oracle.get_min_weight_bound(syndrome, max_weight) assert bound.within_max_weight == (minimum <= max_weight) - assert _get_syndrome(matrix, bound.representative) == syndrome + assert get_syndrome(matrix, bound.representative) == syndrome assert bound.lower_bound <= minimum if bound.within_max_weight: - assert _pauli_weight(bound.representative, num_qubits, css_weight) <= max_weight + assert ( + get_pauli_weight(bound.representative, num_qubits, css_weight) <= max_weight + ) else: assert bound.lower_bound > max_weight @@ -188,12 +190,12 @@ def test_batches_find_each_class_above_max_weight( rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) for num_qubits in range(1, 7): edges = [pair for pair in itertools.combinations(range(num_qubits), 2) if rng.integers(2)] - matrix = _graph_state(num_qubits, edges, phase=num_qubits % 2 == 0) + matrix = get_graph_state(num_qubits, edges, phase=num_qubits % 2 == 0) oracle = CosetLeaderSearch(matrix, css_weight=css_weight) syndromes = rng.permutation(1 << num_qubits).tolist() minima = np.array([_class_weights(matrix, ss, css_weight).min() for ss in syndromes]) for max_weight in range(num_qubits + 2): - words = _pack_syndromes(syndromes, num_qubits) + words = pack_syndromes(syndromes, num_qubits) found = _find_each_not_within_max_weight(oracle, matrix, words, max_weight) assert found == np.flatnonzero(minima > max_weight).tolist() @@ -207,7 +209,7 @@ def test_batches_return_bounds_above_max_weight_plus_one() -> None: """ matrix = np.hstack([np.zeros((4, 4), dtype=np.uint8), np.eye(4, dtype=np.uint8)]) oracle = CosetLeaderSearch(matrix, css_weight=True) - words = _pack_syndromes([0b0011, 0b1111], 4) + words = pack_syndromes([0b0011, 0b1111], 4) assert _find_each_not_within_max_weight(oracle, matrix, words, 2) == [1] assert oracle.get_min_weight_bound(0b1111, 2).lower_bound == 4 @@ -224,7 +226,7 @@ def test_batches_read_syndromes_of_several_words( rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) num_blocks = 17 generators = _block_generators(block, num_blocks) - matrix = _stabilizer_matrix(generators) + matrix = get_stabilizer_matrix(generators) oracle = CosetLeaderSearch(matrix, css_weight=css_weight) syndromes, minima = [], [] for _ in range(30): @@ -236,7 +238,7 @@ def test_batches_read_syndromes_of_several_words( local = [bits[4 * index : 4 * index + 4] for index in range(num_blocks)] minima.append(_block_minimum(block, [ss for ss in local if any(ss)], css_weight)) for max_weight in range(4): - words = _pack_syndromes(syndromes, 68) + words = pack_syndromes(syndromes, 68) found = _find_each_not_within_max_weight(oracle, matrix, words, max_weight) assert found == np.flatnonzero(np.array(minima) > max_weight).tolist() @@ -254,7 +256,7 @@ def test_batches_read_syndromes_of_fewer_words( rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) num_blocks = 17 generators = _block_generators(block, num_blocks) - matrix = _stabilizer_matrix(generators) + matrix = get_stabilizer_matrix(generators) oracle = CosetLeaderSearch(matrix, css_weight=css_weight) syndromes, minima = [], [] for _ in range(30): @@ -283,7 +285,7 @@ def test_x_and_z_syndromes_span_several_words(pytestconfig: pytest.Config) -> No pure = np.zeros((num_qubits, 2 * num_qubits), dtype=np.uint8) pure[:70, num_qubits:][np.arange(70), np.arange(70)] = 1 # Z on each of the first 70 qubits pure[70:, :num_qubits][np.arange(70), np.arange(70, num_qubits)] = 1 # X on each of the rest - matrix = _scramble(pure, rng) + matrix = scramble(pure, rng) oracle = CosetLeaderSearch(matrix, css_weight=True) syndromes, minima = [], [] for _ in range(20): @@ -292,14 +294,14 @@ def test_x_and_z_syndromes_span_several_words(pytestconfig: pytest.Config) -> No pauli = sum(1 << int(qq) for qq in x_qubits) + sum( 1 << int(qq + num_qubits) for qq in z_qubits ) - syndromes.append(_get_syndrome(matrix, pauli)) + syndromes.append(get_syndrome(matrix, pauli)) minima.append(max(len(x_qubits), len(z_qubits))) for max_weight in range(4): for syndrome, minimum in zip(syndromes, minima, strict=True): bound = oracle.get_min_weight_bound(syndrome, max_weight) assert bound.within_max_weight == (minimum <= max_weight) - assert _get_syndrome(matrix, bound.representative) == syndrome - words = _pack_syndromes(syndromes, num_qubits) + assert get_syndrome(matrix, bound.representative) == syndrome + words = pack_syndromes(syndromes, num_qubits) found = _find_each_not_within_max_weight(oracle, matrix, words, max_weight) assert found == np.flatnonzero(np.array(minima) > max_weight).tolist() @@ -320,12 +322,12 @@ def test_css_weight_minimizes_both_sectors_jointly() -> None: The X part and the Z part can each be made zero, yet no one error has CSS weight below two. """ - matrix = _graph_state(4, [(0, 1), (1, 2), (2, 3)]) + matrix = get_graph_state(4, [(0, 1), (1, 2), (2, 3)]) assert _class_weights(matrix, 15, True).min() == 2 oracle = CosetLeaderSearch(matrix, css_weight=True) bound = oracle.get_min_weight_bound(15, 1) assert not bound.within_max_weight and bound.lower_bound == 2 - assert _get_syndrome(matrix, bound.representative) == 15 + assert get_syndrome(matrix, bound.representative) == 15 assert oracle.get_min_weight_bound(15, 2).within_max_weight @@ -339,14 +341,14 @@ def test_rank_deficient_information_sets_give_valid_bounds(syndrome: int, minimu two skips the deficient set, whose first two layers contribute nothing to the certificate. """ edges = [(0, 1), (0, 2), (0, 7), (1, 3), (1, 5), (2, 4), (3, 5), (4, 7), (5, 7)] - matrix = _graph_state(8, edges) + matrix = get_graph_state(8, edges) assert _class_weights(matrix, syndrome, False).min() == minimum bound = CosetLeaderSearch(matrix).get_min_weight_bound(syndrome, 2) assert bound.within_max_weight == (minimum <= 2) assert bound.lower_bound <= minimum - assert _get_syndrome(matrix, bound.representative) == syndrome + assert get_syndrome(matrix, bound.representative) == syndrome if bound.within_max_weight: - assert _pauli_weight(bound.representative, len(matrix), False) <= 2 + assert get_pauli_weight(bound.representative, len(matrix), False) <= 2 else: assert bound.lower_bound > 2 @@ -359,7 +361,7 @@ def test_exhausted_search_gives_the_exact_minimum() -> None: errors, and the bounds of its certificate stay below four, so only exhausting every layer proves the minimum. """ - matrix = _stabilizer_matrix( + matrix = get_stabilizer_matrix( [stim.PauliString(pauli) for pauli in ("Y____", "_Z___", "__Z__", "___Z_", "____Z")] ) oracle = CosetLeaderSearch(matrix, css_weight=True) @@ -381,8 +383,8 @@ def test_wide_states_span_several_packed_words(css_weight: bool) -> None: assert not below.within_max_weight and below.lower_bound == num_blocks found = oracle.get_min_weight_bound(syndrome, num_blocks) assert found.within_max_weight - assert _get_syndrome(matrix, found.representative) == syndrome - assert _pauli_weight(found.representative, 3 * num_blocks, css_weight) == num_blocks + assert get_syndrome(matrix, found.representative) == syndrome + assert get_pauli_weight(found.representative, 3 * num_blocks, css_weight) == num_blocks @_BLOCK_CASES @@ -395,7 +397,7 @@ def test_bounds_match_per_block_minima( """ rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) num_blocks = 5 - matrix = _stabilizer_matrix(_block_generators(block, num_blocks)) + matrix = get_stabilizer_matrix(_block_generators(block, num_blocks)) oracle = CosetLeaderSearch(matrix, css_weight=css_weight) # For the first syndrome, the physical-weight search stops by its certificate while the lightest # error that it has visited is heavier than the minimum. @@ -450,7 +452,7 @@ def test_queries_return_the_word_and_bound_of_the_search_order() -> None: """ for block, css_weight, syndrome, max_weight, expected in _ORDERED_QUERIES: num_qubits = 3 * len(block) - matrix = _stabilizer_matrix(_block_generators(block, 3)) + matrix = get_stabilizer_matrix(_block_generators(block, 3)) oracle = CosetLeaderSearch(matrix, css_weight=css_weight) bound = oracle.get_min_weight_bound(syndrome, max_weight) assert bound == expected @@ -461,16 +463,16 @@ def test_queries_return_the_word_and_bound_of_the_search_order() -> None: for index in range(3) ] minimum = _block_minimum(block, local, css_weight) - assert _get_syndrome(matrix, bound.representative) == syndrome + assert get_syndrome(matrix, bound.representative) == syndrome assert bound.within_max_weight == (minimum <= max_weight) assert ( bound.lower_bound <= minimum - <= _pauli_weight(bound.representative, num_qubits, css_weight) + <= get_pauli_weight(bound.representative, num_qubits, css_weight) ) - words = _pack_syndromes( - [_get_syndrome(matrix, 1), _get_syndrome(matrix, 1 << num_qubits), syndrome], num_qubits + words = pack_syndromes( + [get_syndrome(matrix, 1), get_syndrome(matrix, 1 << num_qubits), syndrome], num_qubits ) index, batch_bound = oracle.find_first_not_within_max_weight(words, max_weight) if expected.within_max_weight: @@ -489,22 +491,22 @@ def test_css_bounds_match_exhaustive_minima(pytestconfig: pytest.Config) -> None rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) for num_qubits in range(1, 6): for num_x_stabilizers in range(num_qubits + 1): - matrix = _random_css_state(num_qubits, num_x_stabilizers, rng) + matrix = get_random_css_state(num_qubits, num_x_stabilizers, rng) oracle = CosetLeaderSearch(matrix, css_weight=True) - minima = _css_minima(matrix) + minima = get_css_minima(matrix) for syndrome in rng.permutation(1 << num_qubits).tolist(): for max_weight in rng.permutation(num_qubits + 1).tolist(): bound = oracle.get_min_weight_bound(syndrome, max_weight) assert bound.within_max_weight == (minima[syndrome] <= max_weight) - assert _get_syndrome(matrix, bound.representative) == syndrome + assert get_syndrome(matrix, bound.representative) == syndrome assert bound.lower_bound <= minima[syndrome] if bound.within_max_weight: - weight = _pauli_weight(bound.representative, num_qubits, True) + weight = get_pauli_weight(bound.representative, num_qubits, True) assert weight <= max_weight else: assert bound.lower_bound > max_weight syndromes = rng.permutation(1 << num_qubits).tolist() - words = _pack_syndromes(syndromes, num_qubits) + words = pack_syndromes(syndromes, num_qubits) for max_weight in range(num_qubits + 1): found = _find_each_not_within_max_weight(oracle, matrix, words, max_weight) assert found == np.flatnonzero(minima[syndromes] > max_weight).tolist() @@ -524,9 +526,9 @@ def test_invalid_arguments_are_rejected() -> None: @pytest.mark.parametrize( "matrix, css_weight", [ - (_ROUTED_PHYSICAL_STATES[0], False), - (_ROUTED_PHYSICAL_STATES[1], False), - (_ROUTED_CSS_WEIGHT_STATE, True), + (ROUTED_PHYSICAL_STATES[0], False), + (ROUTED_PHYSICAL_STATES[1], False), + (ROUTED_CSS_WEIGHT_STATE, True), (np.hstack([np.zeros((4, 4), np.uint8), np.eye(4, dtype=np.uint8)]), True), ], ids=["physical-0", "physical-1", "css-weight", "empty-x-code"], @@ -545,10 +547,10 @@ def test_oracles_answer_exactly_with_other_families( """ tune(overlapping_minimum_work=0) oracle = CosetLeaderSearch(matrix, css_weight=css_weight) - _assert_exact_answers(oracle, matrix, css_weight) + assert_exact_answers(oracle, matrix, css_weight) num_qubits = len(matrix) - minima = _css_minima(matrix, css_weight) - syndromes = _pack_syndromes(list(range(1 << num_qubits)), num_qubits) + minima = get_css_minima(matrix, css_weight) + syndromes = pack_syndromes(list(range(1 << num_qubits)), num_qubits) for max_weight in range(num_qubits + 1): found = _find_each_not_within_max_weight(oracle, matrix, syndromes, max_weight) assert found == np.flatnonzero(minima > max_weight).tolist() @@ -592,25 +594,25 @@ def test_min_weights_match_exhaustive_minima( cases = [] for num_qubits in range(2, 6): edges = [pair for pair in itertools.combinations(range(num_qubits), 2) if rng.integers(2)] - graph_state = _graph_state(num_qubits, edges, phase=True) - css_state = _random_css_state(num_qubits, int(rng.integers(num_qubits + 1)), rng) + graph_state = get_graph_state(num_qubits, edges, phase=True) + css_state = get_random_css_state(num_qubits, int(rng.integers(num_qubits + 1)), rng) cases += [(graph_state, False, True), (graph_state, True, False), (css_state, True, True)] edges = [(0, 1), (0, 2), (0, 3), (1, 4), (1, 5), (1, 6), (1, 7), (2, 3), (2, 4), (2, 5)] edges += [(2, 6), (2, 7), (3, 4), (3, 5), (3, 7), (4, 5), (5, 6), (5, 7)] - graph_state = _graph_state(8, edges, phase=True) + graph_state = get_graph_state(8, edges, phase=True) cases += [(graph_state, False, True), (graph_state, True, False)] - cycle_state = _graph_state(5, [(0, 1), (1, 2), (2, 3), (3, 4), (0, 4)], phase=True) + cycle_state = get_graph_state(5, [(0, 1), (1, 2), (2, 3), (3, 4), (0, 4)], phase=True) cases.append((cycle_state, True, False)) steane_zero = ("XXXX___", "XX__XX_", "X_X_X_X", "ZZZZ___", "ZZ__ZZ_", "Z_Z_Z_Z", "ZZZZZZZ") - cases += [(_product_state(steane_zero), css_weight, True) for css_weight in (False, True)] + cases += [(get_product_state(steane_zero), css_weight, True) for css_weight in (False, True)] ghz_block = ("XXXX____", "ZZ______", "_ZZ_____", "__ZZ____") conjugate_block = ("____ZZZZ", "____XX__", "_____XX_", "______XX") - cases.append((_product_state(ghz_block + conjugate_block), True, True)) + cases.append((get_product_state(ghz_block + conjugate_block), True, True)) for matrix, css_weight, by_support in cases: num_qubits = len(matrix) - minima = _css_minima(matrix, css_weight) + minima = get_css_minima(matrix, css_weight) syndromes = rng.permutation(1 << num_qubits) - packed_syndromes = _pack_syndromes(syndromes.tolist(), num_qubits) + packed_syndromes = pack_syndromes(syndromes.tolist(), num_qubits) methods: list[CosetSearchMethod] = ["brouwer_zimmermann"] if by_support and not tuning: methods.append("connected_support") @@ -630,7 +632,7 @@ def test_batches_reject_syndromes_out_of_range(method: CosetSearchMethod) -> Non weights. A row with a zero second word is in range: the syndrome three needs Z on the first qubit and X on the second. """ - oracle = CosetLeaderSearch(_product_state(["X_", "_Z"]), method=method) + oracle = CosetLeaderSearch(get_product_state(["X_", "_Z"]), method=method) for row in ([4], [3, 1]): syndromes = np.array([row], dtype=np.uint64) with pytest.raises(ValueError, match="out of range"): @@ -651,11 +653,11 @@ def test_connected_support_batches_by_css_weight_join_their_parts( """ rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) for num_qubits in range(1, 6): - matrix = _random_css_state(num_qubits, num_qubits // 2, rng) + matrix = get_random_css_state(num_qubits, num_qubits // 2, rng) oracle = CosetLeaderSearch(matrix, css_weight=True, method="connected_support") syndromes = rng.permutation(1 << num_qubits) - minima = _css_minima(matrix)[syndromes] - words = _pack_syndromes(syndromes.tolist(), num_qubits) + minima = get_css_minima(matrix)[syndromes] + words = pack_syndromes(syndromes.tolist(), num_qubits) for max_weight in range(num_qubits + 2): found = _find_each_not_within_max_weight(oracle, matrix, words, max_weight) assert found == np.flatnonzero(minima > max_weight).tolist() @@ -678,7 +680,7 @@ def test_connected_support_batches_read_syndromes_of_several_words( rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) num_blocks = 17 generators = _block_generators(_PHASED_GHZ_BLOCK, num_blocks) - matrix = _stabilizer_matrix(generators) + matrix = get_stabilizer_matrix(generators) oracle = CosetLeaderSearch(matrix, method="connected_support") syndromes, minima = [], [] for error_index in range(30): @@ -695,7 +697,7 @@ def test_connected_support_batches_read_syndromes_of_several_words( local = [bits[4 * index : 4 * index + 4] for index in range(num_blocks)] minima.append(_block_minimum(_PHASED_GHZ_BLOCK, [ss for ss in local if any(ss)], False)) assert syndromes[0] >> 64 and minima[0] == 3 - words = _pack_syndromes(syndromes, 68) + words = pack_syndromes(syndromes, 68) for max_weight in range(4): found = _find_each_not_within_max_weight(oracle, matrix, words, max_weight) assert found == np.flatnonzero(np.array(minima) > max_weight).tolist() @@ -705,7 +707,7 @@ def test_connected_support_rejects_what_it_cannot_search() -> None: """Oracles reject unknown methods, and the CSS weight on non-CSS states by connected support.""" with pytest.raises(ValueError, match="only on CSS states"): CosetLeaderSearch( - _graph_state(2, [(0, 1)], phase=True), css_weight=True, method="connected_support" + get_graph_state(2, [(0, 1)], phase=True), css_weight=True, method="connected_support" ) method: Any = "brute_force" with pytest.raises(ValueError, match="Unknown coset-leader search method"): diff --git a/src/qldpc/codes/distance/_cosets.py b/src/qldpc/codes/distance/_cosets.py index 316d7bcca..f79cfa9d5 100644 --- a/src/qldpc/codes/distance/_cosets.py +++ b/src/qldpc/codes/distance/_cosets.py @@ -2,9 +2,9 @@ """Parts that the coset searches of ``CosetLeaderSearch`` share. -They are the answers of coset queries (:class:`MinWeightBound`), the inverses of checks that turn -syndromes into words, the packing of binary rows into integers and back, the sums of the rows that -a support selects, and the tables of the errors of weight zero or one by syndrome. +They are the answers of coset queries (:class:`MinWeightBound`), the words with unit syndromes that +turn syndromes into words, the packing of binary rows into integers and back, the sums of the rows +that a support selects, and the tables of the errors of weight zero or one by syndrome. """ from __future__ import annotations @@ -44,8 +44,11 @@ class MinWeightBound: lower_bound: int -def _get_inverse(checks: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint8]: - """A right inverse of independent checks: row ``j`` has a syndrome of only bit ``j``.""" +def get_unit_syndrome_words(checks: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint8]: + """Words with unit syndromes for independent checks: row ``j`` has a syndrome of only bit ``j``. + + As columns, these words form a right inverse of the checks. + """ _, labels, pivots = row_reduce_binary(checks, np.eye(len(checks), dtype=np.uint8)) assert labels is not None inverse = np.zeros(checks.shape, dtype=np.uint8) @@ -53,22 +56,22 @@ def _get_inverse(checks: npt.NDArray[np.uint8]) -> npt.NDArray[np.uint8]: return inverse -def _row_ints(matrix: npt.NDArray[np.uint8]) -> tuple[int, ...]: +def row_ints(matrix: npt.NDArray[np.uint8]) -> tuple[int, ...]: """Pack each row of a binary matrix into an integer, with entry ``j`` in bit ``j``.""" - return tuple(map(_packed_row_int, pack_bits(matrix))) + return tuple(map(words_to_int, pack_bits(matrix))) -def _packed_row_int(row: npt.NDArray[np.uint64]) -> int: +def words_to_int(row: npt.NDArray[np.uint64]) -> int: """Convert a row of 64-bit words into an integer, with word ``j`` in bits ``64 j`` and up.""" return sum(int(word) << (64 * jj) for jj, word in enumerate(row)) -def _int_to_words(value: int, width: int) -> npt.NDArray[np.uint64]: +def int_to_words(value: int, width: int) -> npt.NDArray[np.uint64]: """Split an integer into ``width`` 64-bit words, with word ``j`` in bits ``64 j`` and up.""" return np.frombuffer(value.to_bytes(8 * width, "little"), dtype=" tuple[dict[int, int], dict[int, int], dict[int, int]]: """Tabulate the words of weight zero or one by syndrome, and the single-qubit X and Z errors. @@ -106,7 +109,7 @@ def _tabulate_singles( return singles, x_singles, z_singles -def _xor_selected(rows: Sequence[int], support: int) -> int: +def xor_selected(rows: Sequence[int], support: int) -> int: """XOR together the rows whose indices are the set bits of ``support``.""" word = 0 while support: @@ -118,7 +121,7 @@ def _xor_selected(rows: Sequence[int], support: int) -> int: @always_compiled(inline=True) -def _xor_selected_rows( +def xor_selected_rows( packed_bits: npt.NDArray[np.uint64], rows: npt.NDArray[np.uint64], word: npt.NDArray[np.uint64] ) -> None: """Add to ``word`` row ``b`` of ``rows`` for each set bit ``b`` of ``packed_bits``. diff --git a/src/qldpc/codes/distance/_cosets_test.py b/src/qldpc/codes/distance/_cosets_test.py index 98b211b39..975f15c00 100644 --- a/src/qldpc/codes/distance/_cosets_test.py +++ b/src/qldpc/codes/distance/_cosets_test.py @@ -19,7 +19,7 @@ def test_inverses_of_checks_have_one_syndrome_bit_in_each_row(pytestconfig: pyte identity = np.eye(num_checks, dtype=np.uint8) extra = rng.integers(0, 2, (num_checks, num_bits - num_checks), dtype=np.uint8) checks = np.hstack([identity, extra])[:, rng.permutation(num_bits)] - inverse = _cosets._get_inverse(checks) + inverse = _cosets.get_unit_syndrome_words(checks) assert inverse.shape == checks.shape np.testing.assert_array_equal(checks @ inverse.T % 2, identity) @@ -28,12 +28,12 @@ def test_rows_pack_into_integers_and_back(pytestconfig: pytest.Config) -> None: """Entry ``j`` of a row is bit ``j`` of its integer, which splits back into its packed words.""" rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) matrix = rng.integers(0, 2, (5, 130), dtype=np.uint8) - values = _cosets._row_ints(matrix) + values = _cosets.row_ints(matrix) assert values == tuple(sum(int(bit) << jj for jj, bit in enumerate(row)) for row in matrix) packed = pack_bits(matrix) for row, value in zip(packed, values, strict=True): - assert _cosets._packed_row_int(row) == value - np.testing.assert_array_equal(_cosets._int_to_words(value, packed.shape[1]), row) + assert _cosets.words_to_int(row) == value + np.testing.assert_array_equal(_cosets.int_to_words(value, packed.shape[1]), row) def test_sums_of_selected_rows_follow_their_definition(pytestconfig: pytest.Config) -> None: @@ -43,14 +43,14 @@ def test_sums_of_selected_rows_follow_their_definition(pytestconfig: pytest.Conf """ rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) rows = rng.integers(0, 2, (70, 9), dtype=np.uint8) - row_values, packed_rows = _cosets._row_ints(rows), pack_bits(rows) + row_values, packed_rows = _cosets.row_ints(rows), pack_bits(rows) for _ in range(10): selected = rng.integers(0, 2, len(rows), dtype=np.uint8) expected = rows[selected.astype(bool)].sum(axis=0) % 2 - (support,) = _cosets._row_ints(selected[None]) - assert _cosets._xor_selected(row_values, support) == _cosets._row_ints(expected[None])[0] + (support,) = _cosets.row_ints(selected[None]) + assert _cosets.xor_selected(row_values, support) == _cosets.row_ints(expected[None])[0] word = np.zeros(packed_rows.shape[1], dtype=np.uint64) - _cosets._xor_selected_rows(pack_bits(selected[None])[0], packed_rows, word) + _cosets.xor_selected_rows(pack_bits(selected[None])[0], packed_rows, word) np.testing.assert_array_equal(word, pack_bits(expected[None])[0]) @@ -63,12 +63,12 @@ def test_tables_of_single_errors_keep_the_first_word_of_each_syndrome() -> None: two bits with the syndrome 0b01 keep the first, and the tables of X and Z errors hold only the identity. """ - singles, x_singles, z_singles = _cosets._tabulate_singles( + singles, x_singles, z_singles = _cosets.tabulate_singles( [0b01, 0b10, 0b10, 0b11], 2, is_pauli=True ) assert singles == {0: 0, 0b01: 0b0001, 0b10: 0b0100, 0b11: 0b0101} assert x_singles == {0: 0, 0b01: 0b0001, 0b10: 0b0010} assert z_singles == {0: 0, 0b10: 0b0100, 0b11: 0b1000} - singles, x_singles, z_singles = _cosets._tabulate_singles([0b01, 0b01, 0b10], 0, is_pauli=False) + singles, x_singles, z_singles = _cosets.tabulate_singles([0b01, 0b01, 0b10], 0, is_pauli=False) assert singles == {0: 0, 0b01: 0b001, 0b10: 0b100} assert x_singles == z_singles == {0: 0} diff --git a/src/qldpc/codes/distance/_information_sets/basis_families.py b/src/qldpc/codes/distance/_information_sets/basis_families.py index f1c4e0877..06defb275 100644 --- a/src/qldpc/codes/distance/_information_sets/basis_families.py +++ b/src/qldpc/codes/distance/_information_sets/basis_families.py @@ -51,7 +51,7 @@ from .._tuning import SearchTuning from . import matroid_partition from .certificates import ( - _WORK_CAP, + WORK_CAP, Certificate, estimate_certificate_work, get_capped_binomials, @@ -637,9 +637,9 @@ def _estimate_optimistic_work( The basis has ``dimension`` rows and ``num_columns`` nonzero columns, and ``binomials`` are ``get_capped_binomials(dimension)``. Some column of ``m`` full sets is in at least ``ceil(m * dimension / num_columns)`` of them, so no such family is certified with a smaller maximum - coverage. Returns ``_WORK_CAP`` if ``max_sets`` is zero. + coverage. Returns ``WORK_CAP`` if ``max_sets`` is zero. """ - optimistic_work = _WORK_CAP + optimistic_work = WORK_CAP for num_sets in range(1, max_sets + 1): coverage = (num_sets * dimension + num_columns - 1) // num_columns full_ranks = np.full(num_sets, dimension, dtype=np.int64) diff --git a/src/qldpc/codes/distance/_information_sets/certificates.py b/src/qldpc/codes/distance/_information_sets/certificates.py index 3e0709d02..fd113f3dd 100644 --- a/src/qldpc/codes/distance/_information_sets/certificates.py +++ b/src/qldpc/codes/distance/_information_sets/certificates.py @@ -26,11 +26,11 @@ NO_WORD_BOUND = np.iinfo(np.int64).max # Caps that keep work estimates within a native integer. A binomial above _BINOMIAL_CAP counts as -# the cap, and a sum saturates at _WORK_CAP, so an estimate is exact only if it reaches neither cap. +# the cap, and a sum saturates at WORK_CAP, so an estimate is exact only if it reaches neither cap. # Searches that reach a cap are infeasible, so their order, against other basis families or # exhaustive enumeration, does not matter. _BINOMIAL_CAP = 1 << 50 -_WORK_CAP = 1 << 61 +WORK_CAP = 1 << 61 @dataclass(frozen=True) @@ -188,7 +188,7 @@ def get_weight_bounds( if parity is not None: # Round each finite entry up to the parity. The entry before the first step bounds no # step, and stays zero so that every nonnegative maximum weight passes it, as a check of - # a cached stopping step requires (see ``_get_stopping_step`` of a coset search). + # a cached stopping step requires (see ``get_stopping_step`` of a coset search). bounds = bounds + (bounds - parity) % 2 * (bounds < NO_WORD_BOUND) bounds[0, 0] = 0 @@ -396,8 +396,8 @@ def plan_steps( The work adds ``binomials[w]`` candidate/set evaluations for each set that the plan keeps and each layer ``w`` from ``first_layer`` to ``w_i``. Empty ``binomials`` give zero work. The work - saturates at ``_WORK_CAP``: it is the cap once a layer up to ``w*`` has a binomial above - ``_WORK_CAP // num_sets``, or once the sum reaches the cap after a layer. With the binomials of + saturates at ``WORK_CAP``: it is the cap once a layer up to ``w*`` has a binomial above + ``WORK_CAP // num_sets``, or once the sum reaches the cap after a layer. With the binomials of ``get_capped_binomials``, a plan from layer one for the limit ``U - 1`` gives the work estimate of a distance search (Section 3.6 of ``brouwer_zimmermann.md``). A plan from layer zero for a maximum weight ``max_weight`` that skips unused sets gives the number of words that a coset @@ -505,8 +505,8 @@ def _get_steps_work( work = 0 for weight in range(first_layer, layer + 1): # Saturate before adding, so that no sum can overflow: a layer adds at most the cap. - if binomials[weight] > _WORK_CAP // max(num_sets, 1): - return _WORK_CAP + if binomials[weight] > WORK_CAP // max(num_sets, 1): + return WORK_CAP for index in range(num_sets): # Skip the layers that the set has not completed, and the sets that the plan leaves out. completed_layer = get_completed_layer(index, layer, num_completed) @@ -515,6 +515,6 @@ def _get_steps_work( ): continue work += binomials[weight] - if work >= _WORK_CAP: - return _WORK_CAP + if work >= WORK_CAP: + return WORK_CAP return work diff --git a/src/qldpc/codes/distance/_information_sets/certificates_test.py b/src/qldpc/codes/distance/_information_sets/certificates_test.py index 9725cf238..0bc358d65 100644 --- a/src/qldpc/codes/distance/_information_sets/certificates_test.py +++ b/src/qldpc/codes/distance/_information_sets/certificates_test.py @@ -143,7 +143,7 @@ def test_plans_follow_their_definition(pytestconfig: pytest.Config) -> None: set of a layer whose binomial exceeds the share, while the sum stays below the cap. """ rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) - work_cap = certificates._WORK_CAP + work_cap = certificates.WORK_CAP cases: list[tuple[npt.NDArray[np.int64], npt.NDArray[np.int64]]] = [ (np.zeros((3, 1), dtype=np.int64), np.empty(0, dtype=np.int64)) ] diff --git a/src/qldpc/codes/distance/_information_sets/completion_bounds.py b/src/qldpc/codes/distance/_information_sets/completion_bounds.py index 35ddc58f3..398baab91 100644 --- a/src/qldpc/codes/distance/_information_sets/completion_bounds.py +++ b/src/qldpc/codes/distance/_information_sets/completion_bounds.py @@ -255,7 +255,7 @@ def get_completion_bound( def _get_layer_work(num_rows: int, weight: int) -> int: """The number of supports of a layer, saturated at the cap of work estimates.""" - return min(math.comb(num_rows, weight), certificates._WORK_CAP) + return min(math.comb(num_rows, weight), certificates.WORK_CAP) def _get_num_tail_bits(rows: npt.NDArray[np.uint64]) -> int: diff --git a/src/qldpc/codes/distance/_tuning.py b/src/qldpc/codes/distance/_tuning.py index 3e3f56409..2534d89c2 100644 --- a/src/qldpc/codes/distance/_tuning.py +++ b/src/qldpc/codes/distance/_tuning.py @@ -598,7 +598,7 @@ def use_tuning(**changes: Any) -> Iterator[SearchTuning]: #: typical workloads, and keep the smallest size past which it stops rising. BINOMIAL_CACHE_SIZE = 1024 -#: The number of entries that ``_get_css_searches`` in ``_brouwer_zimmermann/coset.py`` keeps, one +#: The number of entries that ``get_css_searches`` in ``_brouwer_zimmermann/coset.py`` keeps, one #: for each CSS state and tuning of the setup of the searches, each a pair of classical searches, #: for the X and Z parts of errors. It takes effect when qLDPC is imported. A larger cache uses #: more memory, and a smaller one repeats the setup of searches that a workload reuses. Chosen by @@ -623,7 +623,7 @@ def use_tuning(**changes: Any) -> Iterator[SearchTuning]: #: and keep the smallest size beyond which the hit rate stops growing. LOCAL_CLIFFORD_CACHE_SIZE = 1024 -#: The number of searches of Pauli errors that ``_get_pauli_search`` in +#: The number of searches of Pauli errors that ``get_pauli_search`` in #: ``_brouwer_zimmermann/coset.py`` keeps, one for each stabilizer matrix, measure of weight, and #: tuning of the setup of the search. It takes effect when qLDPC is imported. A larger cache uses #: more memory, a smaller one repeats setups, and zero disables the cache. Only speed and memory diff --git a/src/qldpc/codes/distance/brouwer_zimmermann.md b/src/qldpc/codes/distance/brouwer_zimmermann.md index ab12d1926..0c57fd939 100644 --- a/src/qldpc/codes/distance/brouwer_zimmermann.md +++ b/src/qldpc/codes/distance/brouwer_zimmermann.md @@ -844,8 +844,8 @@ The weight divisor of the certificate rounds its Hamming bounds up to multiples The distance search builds another instance when it switches to an automorphism family, whose certificate covers every image of the one set that it scans. `CosetLeaderSearch` in [_coset_leader_search.py](_coset_leader_search.py), which the fault-propagation certifier in `qldpc.circuits.certification` uses, applies the same search to a coset `u + V` instead of `V`, unless it is built with `method="connected_support"` ([connected_support.md](connected_support.md)). -Its BZ coset searches, `_CosetSearch` with `_search_coset` and `_find_min_weights`, are in [_brouwer_zimmermann/coset.py](_brouwer_zimmermann/coset.py). -The tables of their steps and tails, such as `_get_search_tables`, `_get_stopping_step`, and `_CosetTails`, are in [_brouwer_zimmermann/coset_tables.py](_brouwer_zimmermann/coset_tables.py). +Its BZ coset searches, `BrouwerZimmermannCosetSearch` with `_search_coset` and `_find_min_weights`, are in [_brouwer_zimmermann/coset.py](_brouwer_zimmermann/coset.py). +The tables of their steps and tails, such as `get_search_tables`, `get_stopping_step`, and `CosetTails`, are in [_brouwer_zimmermann/coset_tables.py](_brouwer_zimmermann/coset_tables.py). What they share with the connected-support coset searches, such as `MinWeightBound`, is in [_cosets.py](_cosets.py). There a layer adds `w` rows to an **offset**, a word of the coset that is zero on the pivot columns ([Section 2.3](#23-systematic-bases-and-information-sets)), so layer zero is the offset itself. A **query** of `CosetLeaderSearch` asks whether the coset of a syndrome has a word of weight at most a given maximum weight. @@ -855,7 +855,7 @@ Each family of search bases with which a coset search answers queries has a **co The certificate of a coset instance applies unchanged, except that it takes no weight divisor ([Section 3.2](#32-rounding-the-certificate-to-a-proven-weight-multiple)), because the words of a coset need not share the divisibility of `V`. This holds even when `get_disjoint_family` infers a divisor for `V`. If every word of `V` has even weight, the bounds of each coset may instead be rounded up to its parity, which `AffineInstance.get_weight_bounds` does when it is given the parity. -`_get_search_tables` rounds them when some coset of the search is odd, which holds exactly when some row of the right inverse of its checks, as a binary word of the search, is odd. +`get_search_tables` rounds them when some coset of the search is odd, which holds exactly when some row of the right inverse of its checks, as a binary word of the search, is odd. In a search of Pauli errors, an error weighs at least half the Hamming weight of the binary word that represents it. So the instance of such a search has a **weight factor** `η` of two: `AffineInstance.get_weight_bounds` divides the bounds of its certificate by two, rounding up, after any rounding to parity. The instance of a search of X or Z parts has the weight factor `η = 1`. @@ -869,23 +869,23 @@ A certificate that solves linear programs checks steps in order instead, through Each family of a coset search keeps the bounds of its steps in a **planning buffer**, with a table for each parity of the cosets if the search rounds to parity, and one table otherwise. A query takes the table of the parity of the word of its syndrome. Each table has one more row that caches a stopping step. -For a nonnegative maximum weight, `_get_stopping_step` reuses the cached step of a table exactly while it stays the stopping step of that weight, and replaces it otherwise. +For a nonnegative maximum weight, `get_stopping_step` reuses the cached step of a table exactly while it stays the stopping step of that weight, and replaces it otherwise. `get_min_weight_bound` first looks up the words of weight at most one. If there is none and its maximum weight is below two, it gives the lower bound two without a search, or three for an odd coset in a search of X or Z parts that rounds to parity. Otherwise, a query takes the tables of the **route** of its maximum weight, which `CosetRoutes` of [_brouwer_zimmermann/coset_routes.py](_brouwer_zimmermann/coset_routes.py) chooses once for each maximum weight ([Section 4.4](#44-heuristics-and-cost-models)). A route is a family of search bases of the code, with its coset instance and planning buffer: the disjoint sets, or a family that the routes build for costly queries. `find_first_not_within_max_weight` answers a batch with the route of its maximum weight, which it takes once, and the batch of the X parts and that of the Z parts each take their own. `_get_min_weights` of `CosetLeaderSearch` finds the minimum weights of [Section 3.10](#310-minimum-weights-of-cosets) for the residual-weight estimator. -`_CosetSearch.get_min_weights` answers a batch on the route of the maximum weight ([Section 4.4](#44-heuristics-and-cost-models)), with `_find_min_weights`. +`BrouwerZimmermannCosetSearch.get_min_weights` answers a batch on the route of the maximum weight ([Section 4.4](#44-heuristics-and-cost-models)), with `_find_min_weights`. These searches find their stopping steps with `find_stopping_step`, and leave the cached steps of the planning buffer to the queries. The gate tests the predicted examinations of a query with the disjoint family against its threshold, to decide whether the query may take another family ([Section 4.4](#44-heuristics-and-cost-models)). A route is a **route by tails** if those with the route's own family, which can do less work, also reach the threshold. The queries of a route by tails weigh words by their tails ([Section 3.5](#35-packed-enumeration-with-pivot-column-deletion)): they pass the record of tails of the route to the compiled searches, such as `_search_coset`, in place of None. Numba compiles each search separately for None and for a record of tails, so the choice costs nothing for each word. -Every route by tails of a family gives the latest record `_CosetTails` of the family. +Every route by tails of a family gives the latest record `CosetTails` of the family. The record holds the tails of the rows of each search basis, and of the rows of the right inverse of the checks, from which a query adds up the tail of each offset. It also holds the completion tables of the search bases that the queries of the route scan in layers of two or more rows, if they fit in `completion_bound_max_table_bytes`. -The routes build the tails of a family once, and the tables of each of its search bases once, for the first route that needs them (`_get_coset_tails`, `completion_bounds.considers_tables`, and `completion_bounds.build_tables_within`). +The routes build the tails of a family once, and the tables of each of its search bases once, for the first route that needs them (`get_coset_tails`, `completion_bounds.considers_tables`, and `completion_bounds.build_tables_within`). They keep only the latest record of a family, so they hold the tables of each search basis once. A query by tails scans each layer of two or more rows of a search basis that has tables with `scan_coset_layer_by_tails_with_tables`. It scans the other layers with `scan_coset_layer_by_tails`. @@ -943,13 +943,13 @@ The tests `test_brouwer_zimmermann_setup_exits`, `test_brouwer_zimmermann_setup_ | --- | --- | | Upper bound `U` | `best`, or `upper_bound` as a parameter | | Lower certificate `L` | `lower_bound` of a search, computed by `Certificate.lower_bound(completed_weight)` from the certificate's `dimension`, `ranks`, `weight_divisor`, and `max_coverage`; it bounds the eligible words lighter than `U`, so a bound on the distance is `min(L, U)` | -| Certificate within a layer `L(t, j)` | `Certificate.step_lower_bound(weight, num_completed)` and the table of `get_step_lower_bounds`. `Certificate.get_num_sets_to_certify` gives the smallest `j` that certifies a bound. `find_stopping_step` gives the first step whose bound exceeds a limit, the stopping step, and `plan_steps` also the work of the steps up to it. `Certificate.plan` gives the stopping step of one layer, with linear programs if the certificate solves them. `Certificate.get_weight_bounds` divides the table by a weight factor, rounding up, after rounding it up to a parity if it is given one. A coset search keeps the results in its planning buffer, `planning` of `_CosetSearchTables`: a table for each parity of its cosets if it rounds to parity, and one table otherwise, each with a cached stopping step. | +| Certificate within a layer `L(t, j)` | `Certificate.step_lower_bound(weight, num_completed)` and the table of `get_step_lower_bounds`. `Certificate.get_num_sets_to_certify` gives the smallest `j` that certifies a bound. `find_stopping_step` gives the first step whose bound exceeds a limit, the stopping step, and `plan_steps` also the work of the steps up to it. `Certificate.plan` gives the stopping step of one layer, with linear programs if the certificate solves them. `Certificate.get_weight_bounds` divides the table by a weight factor, rounding up, after rounding it up to a parity if it is given one. A coset search keeps the results in its planning buffer, `planning` of `CosetSearchTables`: a table for each parity of its cosets if it rounds to parity, and one table otherwise, each with a cached stopping step. | | Certificate from linear programming | `get_lp_lower_bound`, which a `Certificate` with `pivot_blocks` combines with the coverage-divided certificate; `BasisFamily.get_search_certificate` adds the blocks to costly searches of overlapping families | -| Family of search bases | `BasisFamily` of [_information_sets/basis_families.py](_information_sets/basis_families.py), which stacks the search bases into arrays and gives their `Certificate` of [_information_sets/certificates.py](_information_sets/certificates.py). A distance search keeps the `AffineInstance` of [_brouwer_zimmermann/affine_instance.py](_brouwer_zimmermann/affine_instance.py) that packs them, with the certificate. A coset search keeps the generators, pivots, and planning buffer of each family of its routes in `_CosetSearchTables`, and the tails and completion tables of the family of each route by tails in `_CosetTails`, both of [_brouwer_zimmermann/coset_tables.py](_brouwer_zimmermann/coset_tables.py). | +| Family of search bases | `BasisFamily` of [_information_sets/basis_families.py](_information_sets/basis_families.py), which stacks the search bases into arrays and gives their `Certificate` of [_information_sets/certificates.py](_information_sets/certificates.py). A distance search keeps the `AffineInstance` of [_brouwer_zimmermann/affine_instance.py](_brouwer_zimmermann/affine_instance.py) that packs them, with the certificate. A coset search keeps the generators, pivots, and planning buffer of each family of its routes in `CosetSearchTables`, and the tails and completion tables of the family of each route by tails in `CosetTails`, both of [_brouwer_zimmermann/coset_tables.py](_brouwer_zimmermann/coset_tables.py). | | Automorphisms, generators of `G` | `automorphisms`, whose row `i` maps column `j` to column `automorphisms[i, j]`, as returned by `get_automorphisms`; `_found_qudit_automorphisms` and `_get_qudit_automorphisms` of `QuditCode` | | Automorphism family | `get_automorphism_family` of [_information_sets/automorphism_families.py](_information_sets/automorphism_families.py), which returns a `BasisFamily` of the scanned set and the `Certificate` of all of its images; `_switch_to_automorphism_family` adopts them, and sets `uses_automorphism_family` of the search | | Last completed layer `t`, next layer | `completed_weight`, `next_weight` | -| Divisor `D` | `Certificate.weight_divisor`, in Hamming units of the searched basis. Coset searches take no divisor, but those of even codes with odd cosets round to the parity of each coset (`_get_search_tables`). | +| Divisor `D` | `Certificate.weight_divisor`, in Hamming units of the searched basis. Coset searches take no divisor, but those of even codes with odd cosets round to the parity of each coset (`get_search_tables`). | | Maximum coverage `C` | `Certificate.max_coverage` | | Dimension `r`, block rank `r_i` | `dimension`, `ranks` (`BasisFamily.ranks`). `AffineInstance.ranks` holds the ranks of the packed sets of a search, counted from their pivots, as a native array. | | Search basis and its block | One set of a `BasisFamily`: its entries of `generators`, `labels`, `pivots`, and `ranks`; the recorded block is the pivot subset, as explained below | @@ -962,7 +962,7 @@ The tests `test_brouwer_zimmermann_setup_exits`, `test_brouwer_zimmermann_setup_ | Packed labels | `labels` of the `AffineInstance` of a distance search: `uint64` quotient-label words, without pivot-column deletion; `label_widths` counts the valid words | | Chunk | `batch_size`, equal to `1 << information_set_batch_exponent` supports, or supports and lookups together in a scan with completion tables ([Section 4.5](#45-behavior-spanning-files)) | | Candidate/set evaluation | The unit of the work of `plan_steps`, which `Certificate.work` counts | -| Completion tables ([Section 3.9](#39-skipping-completions-by-completion-tables)) | `CompletionTables` of [_information_sets/completion_bounds.py](_information_sets/completion_bounds.py), chosen for each layer of one information set by `select_completion_tables` from the completion fields of the search's `SearchTuning` and kept in `completion_tables` of a search, and for each set of a layer that scans several sets one at a time by `_choose_tables_of_sets`; the scan is `scan_information_set` of [_brouwer_zimmermann/affine_search.py](_brouwer_zimmermann/affine_search.py), which returns the numbers of supports that it weighs and skips and of bounds that it looks up. A coset search keeps the tables of the search bases of the family of each route by tails in `_CosetTails`. Its scan with tables is `scan_coset_layer_by_tails_with_tables`. | +| Completion tables ([Section 3.9](#39-skipping-completions-by-completion-tables)) | `CompletionTables` of [_information_sets/completion_bounds.py](_information_sets/completion_bounds.py), chosen for each layer of one information set by `select_completion_tables` from the completion fields of the search's `SearchTuning` and kept in `completion_tables` of a search, and for each set of a layer that scans several sets one at a time by `_choose_tables_of_sets`; the scan is `scan_information_set` of [_brouwer_zimmermann/affine_search.py](_brouwer_zimmermann/affine_search.py), which returns the numbers of supports that it weighs and skips and of bounds that it looks up. A coset search keeps the tables of the search bases of the family of each route by tails in `CosetTails`. Its scan with tables is `scan_coset_layer_by_tails_with_tables`. | For the rank-deficient remainder of [Section 2.4](#24-search-bases-and-rank-deficient-blocks), the implementation records only the independent pivot columns, in the first `rank` entries of the set's row of `BasisFamily.pivots`, and pads the rest with -1. The remaining columns of that remainder stay in the generator matrix. diff --git a/src/qldpc/codes/distance/conftest.py b/src/qldpc/codes/distance/conftest.py index 23590b5e2..d2eb5ca18 100644 --- a/src/qldpc/codes/distance/conftest.py +++ b/src/qldpc/codes/distance/conftest.py @@ -246,12 +246,16 @@ def assert_counts_match_enumeration(counter: lc.CSSLogicalCounter, **options: An HAND_X, HAND_Z = get_bits([31, 20], 5), get_bits([30, 3], 5) -def _stabilizer_matrix(generators: Sequence[stim.PauliString]) -> npt.NDArray[np.uint8]: +#################################################################################################### +# helpers for the tests of coset-leader searches + + +def get_stabilizer_matrix(generators: Sequence[stim.PauliString]) -> npt.NDArray[np.uint8]: """Stabilizer matrix [X|Z] with the given Pauli strings as rows.""" return np.array([np.concatenate(gg.to_numpy()) for gg in generators], dtype=np.uint8) -def _graph_state( +def get_graph_state( num_qubits: int, edges: Sequence[tuple[int, int]], phase: bool = False ) -> npt.NDArray[np.uint8]: """Stabilizer matrix [X|Z] of a graph state, with S gates on every qubit if ``phase``. @@ -264,23 +268,23 @@ def _graph_state( circuit.append("CZ", edge) if phase: circuit.append("S", range(num_qubits)) - return _stabilizer_matrix(stim.Tableau.from_circuit(circuit).to_stabilizers()) + return get_stabilizer_matrix(stim.Tableau.from_circuit(circuit).to_stabilizers()) -def _pauli_weight(pauli: int, num_qubits: int, css_weight: bool) -> int: +def get_pauli_weight(pauli: int, num_qubits: int, css_weight: bool) -> int: """Weight of a packed Pauli, with X in the low bits and Z in the next block.""" x, z = pauli & ((1 << num_qubits) - 1), pauli >> num_qubits return (x | z).bit_count() if not css_weight else max(x.bit_count(), z.bit_count()) -def _get_syndrome(matrix: npt.NDArray[np.uint8], pauli: int) -> int: +def get_syndrome(matrix: npt.NDArray[np.uint8], pauli: int) -> int: """Syndrome of a packed Pauli: its symplectic product with each row of the stabilizer matrix.""" bits = np.array([(pauli >> jj) & 1 for jj in range(matrix.shape[1])]) products = matrix @ math.symplectic_conjugate(bits) % 2 return sum(int(bit) << jj for jj, bit in enumerate(products)) -def _pack_syndromes(syndromes: Sequence[int], num_qubits: int) -> npt.NDArray[np.uint64]: +def pack_syndromes(syndromes: Sequence[int], num_qubits: int) -> npt.NDArray[np.uint64]: """Split packed syndromes into rows of 64-bit words, with word ``j`` in bits ``64 j`` and up.""" width = (num_qubits + 63) // 64 words = [[(syndrome >> (64 * jj)) % 2**64 for jj in range(width)] for syndrome in syndromes] @@ -294,15 +298,15 @@ def _pack_syndromes(syndromes: Sequence[int], num_qubits: int) -> npt.NDArray[np # have a larger partial set. The two states for the physical weight differ in the answers that this # family changes. On the second, it returns other errors for some classes without a light enough # error. -_ROUTED_PHYSICAL_STATES = ( - _graph_state(5, [(0, 3), (1, 2), (1, 3), (1, 4)], phase=True), - _graph_state(5, [(0, 4), (2, 3)]), +ROUTED_PHYSICAL_STATES = ( + get_graph_state(5, [(0, 3), (1, 2), (1, 3), (1, 4)], phase=True), + get_graph_state(5, [(0, 4), (2, 3)]), ) -_ROUTED_CSS_WEIGHT_STATE = _graph_state(5, [(0, 1), (0, 4), (1, 4)]) +ROUTED_CSS_WEIGHT_STATE = get_graph_state(5, [(0, 1), (0, 4), (1, 4)]) -def _random_css_state( +def get_random_css_state( num_qubits: int, num_x_stabilizers: int, rng: np.random.Generator ) -> npt.NDArray[np.uint8]: """Stabilizer matrix [X|Z] of a random CSS state, with generators that mix pure-X and pure-Z. @@ -318,10 +322,10 @@ def _random_css_state( pure = np.zeros((num_qubits, 2 * num_qubits), dtype=np.uint8) pure[:num_x_stabilizers, :num_qubits] = x_rows pure[num_x_stabilizers:, num_qubits:] = get_kernel(x_rows) - return _scramble(pure, rng) + return scramble(pure, rng) -def _scramble(matrix: npt.NDArray[np.uint8], rng: np.random.Generator) -> npt.NDArray[np.uint8]: +def scramble(matrix: npt.NDArray[np.uint8], rng: np.random.Generator) -> npt.NDArray[np.uint8]: """Random invertible combinations of the rows of a stabilizer matrix, which generate its group. The combinations are a random permutation of an upper triangular matrix with ones on its @@ -334,7 +338,7 @@ def _scramble(matrix: npt.NDArray[np.uint8], rng: np.random.Generator) -> npt.ND return (upper[rng.permutation(size)] @ matrix) % 2 -def _css_minima(matrix: npt.NDArray[np.uint8], css_weight: bool = True) -> npt.NDArray[np.int_]: +def get_css_minima(matrix: npt.NDArray[np.uint8], css_weight: bool = True) -> npt.NDArray[np.int_]: """The smallest CSS (or physical) weight of an error with each packed syndrome. The minima come from all ``4**n`` Paulis. @@ -351,7 +355,7 @@ def _css_minima(matrix: npt.NDArray[np.uint8], css_weight: bool = True) -> npt.N # A code whose disjoint information sets have ranks five and four. -_RANK_DEFICIENT_CODE = np.array( +RANK_DEFICIENT_CODE = np.array( [ [0, 0, 1, 1, 0, 1, 1, 1, 0, 0], [0, 0, 1, 1, 0, 0, 1, 1, 1, 1], @@ -363,21 +367,21 @@ def _css_minima(matrix: npt.NDArray[np.uint8], css_weight: bool = True) -> npt.N ) -def _product_state(generators: Sequence[str]) -> npt.NDArray[np.uint8]: +def get_product_state(generators: Sequence[str]) -> npt.NDArray[np.uint8]: """Stabilizer matrix [X|Z] of the given Pauli strings.""" - return _stabilizer_matrix([stim.PauliString(pauli) for pauli in generators]) + return get_stabilizer_matrix([stim.PauliString(pauli) for pauli in generators]) -def _assert_exact_answers( +def assert_exact_answers( oracle: CosetLeaderSearch, matrix: npt.NDArray[np.uint8], css_weight: bool ) -> None: """Check every query of an oracle against exhaustive minima of the state of ``matrix``.""" num_qubits = len(matrix) - minima = _css_minima(matrix, css_weight) + minima = get_css_minima(matrix, css_weight) for syndrome, max_weight in itertools.product(range(1 << num_qubits), range(num_qubits + 1)): bound = oracle.get_min_weight_bound(syndrome, max_weight) - weight = _pauli_weight(bound.representative, num_qubits, css_weight) + weight = get_pauli_weight(bound.representative, num_qubits, css_weight) assert bound.within_max_weight == (minima[syndrome] <= max_weight) - assert _get_syndrome(matrix, bound.representative) == syndrome + assert get_syndrome(matrix, bound.representative) == syndrome assert bound.lower_bound <= minima[syndrome] <= weight assert weight <= max_weight if bound.within_max_weight else bound.lower_bound > max_weight diff --git a/src/qldpc/codes/distance/connected_support.md b/src/qldpc/codes/distance/connected_support.md index 2257a0680..202c96cd5 100644 --- a/src/qldpc/codes/distance/connected_support.md +++ b/src/qldpc/codes/distance/connected_support.md @@ -342,9 +342,9 @@ The names below are in `_connected_support/search.py`, unless the table says oth | Search through a weight limit `W` | `SupportSearch.find_witness` | | Syndrome query | `SupportSearch.find_vector_with_syndrome`, which coset searches skip for `SupportSearch._find_vector_with_packed_syndrome`, and the root `-1` of `_search_supports` | | Overlaps and repairs that a syndrome query reuses within a state | `memoize` of `_search_supports`, and `memos` and `stamp` of `_get_branches` | -| Coset leaders of Pauli errors, and the sparser checks that their searches use | `_ConnectedSupportCosetSearch`, `_get_sparser_rows`, and `_sparsify` in `_connected_support/coset.py`, and `CosetLeaderSearch` in `_coset_leader_search.py` | +| Coset leaders of Pauli errors, and the sparser checks that their searches use | `ConnectedSupportCosetSearch`, `_get_sparser_rows`, and `_sparsify` in `_connected_support/coset.py`, and `CosetLeaderSearch` in `_coset_leader_search.py` | | How many rounds make the checks of a coset search sparser | `max_sparsifying_rounds` and `sparsifying_rounds_without_improvement` of `SearchTuning` in `_tuning.py` | -| Table of light vectors, and increasing limits for batches and minimum weights | `_ConnectedSupportCosetSearch` (its `_singles` and `get_min_weight_bound`) in `_connected_support/coset.py`, and `_get_batch_support_bound` and `_get_min_support_weight` of `CosetLeaderSearch` in `_coset_leader_search.py` | +| Table of light vectors, and increasing limits for batches and minimum weights | `ConnectedSupportCosetSearch` (its `_singles` and `get_min_weight_bound`) in `_connected_support/coset.py`, and `_get_batch_support_bound` and `_get_min_support_weight` of `CosetLeaderSearch` in `_coset_leader_search.py` | | Logical test | `SupportSearch._get_tests`, and `_is_outside_excluded` | | Roots, ranks, and symmetries | `SupportSearch._use_automorphisms`, and `verify_automorphisms` and `get_orbits` of `_information_sets/automorphism_families.py` | | States, branching, and undoing what a state forbade when the search leaves it | `_search_supports`, whose `levels` mark the available symbols | From 9cd42275e2c7e5a6a7b48a34387a49ac9e11cdb1 Mon Sep 17 00:00:00 2001 From: "Michael A. Perlin" Date: Thu, 12 Nov 2026 02:34:53 -0500 Subject: [PATCH 6/7] Qualify references, clarify names, and test the order of single errors Docstrings and comments that cite code in another module now give its path, and paragraphs that the edits left ragged are filled. The README describes `_binary.py` and `_counting/` precisely, says that the method name "information_sets" is an alias of "brouwer_zimmermann", and its levels of dependency name the modules without repeating the layout. The code map of the coset searches moves to the end of Section 4.1 of `brouwer_zimmermann.md`, after the terms that it uses, and the tests that the guide cites without a file name their files. Text that called the words with unit syndromes the rows of a right inverse now says what they are. Three test helpers get names that say what they do: `get_product_state` merges into `get_stabilizer_matrix`, which now takes strings too, and `get_css_minima`, which also gives physical weights, becomes `get_exhaustive_minima`, whose callers now pass the weight. The private attributes of `CosetLeaderSearch` that hold its coset searches are named for their methods: `_brouwer_zimmermann_searches` and `_connected_support_searches`. `tabulate_singles` keeps the first word of each syndrome, with X before Z before Y on each qubit. New cases check that order where words on one qubit, or Z errors on two qubits, share a syndrome. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/qldpc/codes/distance/README.md | 28 +++++---- .../distance/_brouwer_zimmermann/__init__.py | 6 +- .../distance/_brouwer_zimmermann/coset.py | 21 +++---- .../_brouwer_zimmermann/coset_tables.py | 20 +++---- .../_brouwer_zimmermann/coset_tables_test.py | 10 ++-- .../_brouwer_zimmermann/coset_test.py | 24 ++++---- .../distance/_brouwer_zimmermann/distance.py | 4 +- .../distance/_connected_support/__init__.py | 4 +- .../distance/_connected_support/coset.py | 13 +++-- .../distance/_connected_support/coset_test.py | 6 +- .../distance/_connected_support/search.py | 6 +- .../codes/distance/_coset_leader_search.py | 57 ++++++++++--------- .../distance/_coset_leader_search_test.py | 23 ++++---- src/qldpc/codes/distance/_cosets.py | 20 ++++--- src/qldpc/codes/distance/_cosets_test.py | 45 ++++++++++----- .../codes/distance/_counting/macwilliams.py | 4 +- src/qldpc/codes/distance/_counting/scan.py | 8 +-- .../distance/_information_sets/__init__.py | 4 +- .../_information_sets/certificates.py | 3 +- .../codes/distance/brouwer_zimmermann.md | 25 ++++---- src/qldpc/codes/distance/conftest.py | 15 ++--- 21 files changed, 181 insertions(+), 165 deletions(-) diff --git a/src/qldpc/codes/distance/README.md b/src/qldpc/codes/distance/README.md index b168cddae..4d846257b 100644 --- a/src/qldpc/codes/distance/README.md +++ b/src/qldpc/codes/distance/README.md @@ -34,13 +34,14 @@ Users reach it through the `get_distance_exact` method of binary codes in `src/q - `core.py` and `logical_counts.py` are the public interfaces of the distances and the counts. - `_coset_leader_search.py` is the entry module of the coset-leader search, which modules elsewhere in qLDPC import. -- `_brouwer_zimmermann/` and `_connected_support/` hold the two searches over information sets and over connected supports. +- `_brouwer_zimmermann/` holds the Brouwer–Zimmermann searches, which scan information sets, and `_connected_support/` the searches over connected supports. Each holds its distance search, and the coset searches with which the coset-leader search answers its queries by that method. Exhaustive enumeration fits in one module, `_brute_force.py`. -- `_counting/` holds the methods of the counts. +- `_counting/` holds the word scan of the counts, the local Clifford gates of deformed counters, and the MacWilliams method. - `_information_sets/` holds the families of information sets and their certificates, which the Brouwer–Zimmermann searches and the counts share. + The method name `"information_sets"` is an alias of `"brouwer_zimmermann"`, for distances and counts alike. - `_cosets.py` holds what the coset searches of both methods share. -- `_tuning.py` holds the settings of the searches, and `_binary.py` the binary input checks. +- `_tuning.py` holds the settings of the searches, and `_binary.py` the binary input checks, nested bases, and the symplectic-to-Hamming map. ## Where to look @@ -48,7 +49,7 @@ Users reach it through the `get_distance_exact` method of binary codes in `src/q | --- | --- | | A distance method, or the validation of method and backend names | `core.py`, and where `logical_counts.py`, `_counting/scan.py`, `get_distance_exact`, `get_distance`, and `CSSCode._get_distance_of_sectors` in `src/qldpc/codes/common.py` dispatch on the method name | | The counter, its deformed counters, or `compile_search_setup` | `logical_counts.py` | -| Minimum weights of syndrome classes of Pauli errors (coset-leader weights), by BZ or by connected-support searches, with two classical searches for the CSS weight on CSS states, used by `src/qldpc/circuits/certification/` and `src/qldpc/experimental/circuits/benchmarking/residual_weights.py` | `_coset_leader_search.py` | +| The queries of the minimum weights of syndrome classes of Pauli errors (coset-leader weights), and how a query splits into coset searches, used by `src/qldpc/circuits/certification/` and `src/qldpc/experimental/circuits/benchmarking/residual_weights.py` | `_coset_leader_search.py` | | The Brouwer–Zimmermann (BZ) distance search | `_brouwer_zimmermann/distance.py` | | The compiled scans of layers that the BZ distance search and the BZ coset-leader search share, one body specialized by constant flags | `_brouwer_zimmermann/affine_search.py` | | The packed information sets of a BZ distance search or a BZ coset-leader search, and the certificate of their steps | `_brouwer_zimmermann/affine_instance.py` | @@ -61,7 +62,7 @@ Users reach it through the `get_distance_exact` method of binary codes in `src/q | The scan of a count, or how its basis families follow local gates | `_counting/scan.py` | | The local Clifford gates of deformed counters | `_counting/local_cliffords.py` | | The MacWilliams counting method | `_counting/macwilliams.py` | -| The parts that the coset searches of both methods share: their answers (`MinWeightBound`), inverses of checks, packed rows, sums of the rows that a support selects, and tables of the errors of weight zero or one | `_cosets.py` | +| The parts that the coset searches of both methods share: their answers (`MinWeightBound`), words with unit syndromes, packed rows, sums of the rows that a support selects, and tables of the errors of weight zero or one | `_cosets.py` | | How basis families are built | `_information_sets/basis_families.py` | | Certificates, and how searches plan their steps: where they stop, and the work of the steps up to there | `_information_sets/certificates.py` | | The step from one support of a layer to the next, which every scan of layers shares | `_information_sets/support_stepping.py` | @@ -69,7 +70,7 @@ Users reach it through the `get_distance_exact` method of binary codes in `src/q | Completion tables, which bound the words that complete the first rows of a BZ support with two more rows: a distance search skips the supports whose words cannot be lighter than its best word, a count those whose words all weigh more than its target weight, and a BZ coset query on a route by tails those whose words all weigh more than its maximum weight. A BZ search for the minimum weight of a coset skips those too, and those whose words cannot be lighter than its lightest word so far. Also the choice of whether to build them | `_information_sets/completion_bounds.py` | | Information sets with bounded column coverage, by matroid partitioning | `_information_sets/matroid_partition.py` | | The settings that a search reads when it starts, such as the thresholds that choose between exhaustive enumeration, the kinds of information-set families, and completion tables, the threshold of states before which a connected-support search asks for automorphisms, the coefficients of cost models, memory limits, and batch sizes (`SearchTuning`, `use_tuning`), and the constants that size caches when qLDPC is imported | `_tuning.py` | -| Binary input checks, nested bases, or the map that turns symplectic weights into Hamming weights | `_binary.py` | +| Binary input checks, nested bases, or the map of symplectic vectors to binary vectors of twice their weight | `_binary.py` | | Bit packing, bit counts, or row reduction over GF(2), which other subpackages share | `src/qldpc/_binary.py` | | How numba code is compiled or cached | `src/qldpc/_compilation.py` | @@ -77,16 +78,13 @@ Users reach it through the `get_distance_exact` method of binary codes in `src/q From the top: -1. `logical_counts.py`, the public interface of the counts, which takes `NoDistance` from `core.py`. -2. `core.py`, the public interface of the distances, and `_coset_leader_search.py`, the entry module of the coset-leader search. +1. `logical_counts.py`, which takes `NoDistance` from `core.py`. +2. `core.py` and `_coset_leader_search.py`. 3. The methods `_brouwer_zimmermann/`, `_connected_support/`, and `_counting/`. -4. What the methods share. - `_information_sets/` holds the information sets that the Brouwer–Zimmermann searches and the counts build on. - The connected-support search uses its weight divisors, and its checks and orbits of automorphisms. - `_cosets.py` holds what the coset searches of both methods share. - `_brute_force.py` is the exhaustive method, which the Brouwer–Zimmermann distance search also uses for its exhaustive routes. -5. `_tuning.py`, the settings of the searches and counts, and the sizes of their caches. -6. `_binary.py`, the foundation. +4. `_information_sets/`, `_cosets.py`, and `_brute_force.py`, which the methods build on. + The connected-support search uses `infer_weight_divisor`, `verify_automorphisms`, and `get_orbits` of `_information_sets/`, and the Brouwer–Zimmermann distance search uses `_brute_force.py` for its exhaustive routes. +5. `_tuning.py`. +6. `_binary.py`. A top-level module or subpackage imports only from lower levels, and a module also from its own subpackage. `layering_test.py` checks this. diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/__init__.py b/src/qldpc/codes/distance/_brouwer_zimmermann/__init__.py index ac8045674..b4f21d611 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/__init__.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/__init__.py @@ -2,7 +2,7 @@ """Brouwer-Zimmermann searches: of the distances of nested codes, and of the cosets of a code. -``qldpc.codes.distance.core`` and ``qldpc.codes.distance._coset_leader_search`` are the -interfaces of these modules, and ``qldpc.codes.distance.logical_counts`` also runs the distance -searches of its counters. +``qldpc.codes.distance.core`` and ``qldpc.codes.distance._coset_leader_search`` are the interfaces +of these modules, and ``qldpc.codes.distance.logical_counts`` also runs the distance searches of its +counters. """ diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py index f57bd6055..a3c3b1226 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset.py @@ -10,10 +10,7 @@ import numpy as np import numpy.typing as npt -from qldpc._binary import ( - popcount_word, - unpack_bits, -) +from qldpc._binary import popcount_word, unpack_bits from qldpc._compilation import always_compiled from .. import _tuning @@ -48,7 +45,7 @@ class BrouwerZimmermannCosetSearch: - """One coset search of a ``CosetLeaderSearch``: a search of the cosets of one binary code. + """A BZ coset search of a ``CosetLeaderSearch``: a search of the cosets of one binary code. The words of a search are either packed Paulis on ``n`` qubits, in which case the code is spanned by the packed stabilizers, or the X parts (or Z parts) of Paulis, in which case the code @@ -67,8 +64,8 @@ class BrouwerZimmermannCosetSearch: css_weight: True to weigh words by their CSS weight, which is the Hamming weight of an X or Z part, or False to weigh them by their physical weight. num_qubits: The number of qubits ``n``. - inverse_rows: Row ``j`` of a right inverse of the checks, not embedded, as an integer: a - word whose syndrome has only bit ``j`` set. + inverse_rows: Row ``j`` of ``get_unit_syndrome_words`` of the checks, not embedded, as an + integer: a word whose syndrome has only bit ``j`` set. check_columns: Column ``c`` of the checks as an integer: the syndrome of the word with only bit ``c`` set. is_pauli: Whether the words are packed Paulis of ``2 n`` bits, rather than X or Z parts of @@ -883,7 +880,7 @@ def _search_coset_for_min_weight( # Visit each layer one information set at a time, up to the stopping step. The scans take work # space for one set. The search reaches a stopping step by the last layer, whose bounds are - # ``NO_WORD_BOUND``, since its upper bound is at most ``max_weight + 1``, which + # ``certificates.NO_WORD_BOUND``, since its upper bound is at most ``max_weight + 1``, which # ``BrouwerZimmermannCosetSearch.get_min_weights`` keeps at most ``n + 1``. The scans keep each # lighter word in ``best``, or its support in ``best_support`` by tails, which are only work # space, since the search returns no word. @@ -1011,10 +1008,10 @@ def _get_offset_tails( """Find the rank of each information set and the tail of its offset. ``word`` is the word of the syndrome ``packed_syndrome``, ``pivots`` are those of - ``_search_coset``, and ``tails`` and ``inverse_tails`` are those of ``CosetTails``. The rows - of a set with pivots on which the word has ones reduce the word to the offset, since every row - is zero on the pivots of the other rows. The tail of the word sums the tails of the rows of the - inverse for the set bits of the syndrome. + ``_search_coset``, and ``tails`` and ``inverse_tails`` are those of ``CosetTails``. The rows of + a set with pivots on which the word has ones reduce the word to the offset, since every row is + zero on the pivots of the other rows. The tail of the word sums the tails of the words with + unit syndromes (``CosetTails.inverse_tails``) for the set bits of the syndrome. """ num_sets, num_rows, tail_width = tails.shape ranks = np.zeros(num_sets, dtype=np.int64) diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py index 9243abfad..1110a7228 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables.py @@ -33,12 +33,12 @@ class CosetSearchTables(NamedTuple): - """The tables of ``_search_coset`` for one coset search of a ``CosetLeaderSearch``. + """The tables of ``_search_coset`` in ``coset.py`` for one BZ coset search. The search runs on embedded words. For a search of Pauli errors, the embedded word of an error is its packed Pauli for the CSS weight, and its packed Pauli followed by ``x xor z`` for the - physical weight (see ``symplectic_to_hamming``). For a search of X or Z parts, the embedded - word of a part is the part itself. + physical weight (see ``symplectic_to_hamming`` in ``_binary.py`` of this package). For a search + of X or Z parts, the embedded word of a part is the part itself. Attributes: inverse: Row ``j`` is the packed embedded word of a word whose syndrome has only bit ``j`` @@ -68,7 +68,7 @@ class CosetSearchTables(NamedTuple): class CosetTails(NamedTuple): - """The tails with which ``_search_coset`` weighs the words of a family by tails, and its tables. + """The tails with which a BZ coset search weighs the words of a family by tails, and its tables. The tail of an embedded word on a set of the family holds every column of the word but the pivot columns of the set, including columns on which no row of the set has a one. The tails are @@ -105,17 +105,17 @@ class CosetTails(NamedTuple): def get_search_tables( instance: affine_instance.AffineInstance, inverse: npt.NDArray[np.uint8], num_x_columns: int ) -> CosetSearchTables: - """Build the tables of ``_search_coset`` for the cosets of the code of a coset instance. + """Build the tables of a BZ coset search for the cosets of the code of a coset instance. ``inverse`` holds embedded words whose syndromes have one bit set, and the first ``num_x_columns`` columns count as the X part of an embedded word. The search rounds its bounds to the parity of each coset if every word of the code has even Hamming weight and some coset is odd. Then the words of each coset share a parity (see - ``get_coset_instance``). The word of a syndrome sums rows of ``inverse``, so some coset is odd - exactly when some row is. If the code and every row of ``inverse`` are even, every coset is - even. This happens only in the search of Pauli errors by physical weight, whose bounds are - halved, so that rounding them up to even would change none. + ``affine_instance.get_coset_instance``). The word of a syndrome sums rows of ``inverse``, so + some coset is odd exactly when some row is. If the code and every row of ``inverse`` are even, + every coset is even. This happens only in the search of Pauli errors by physical weight, whose + bounds are halved, so that rounding them up to even would change none. """ width = instance.generators.shape[2] x_mask = int_to_words((1 << num_x_columns) - 1, width) @@ -257,7 +257,7 @@ def get_first_step_reaching(bounds: npt.NDArray[np.int64], target: int) -> tuple It is the stopping step of ``certificates.find_stopping_step`` for the limit ``target - 1``. Unlike ``get_stopping_step``, this function caches no step, so the cached steps of the planning - buffer serve only the queries of ``_search_coset``. + buffer serve only the queries of ``_search_coset`` in ``coset.py``. """ return certificates.find_stopping_step(bounds, target - 1, 0, len(bounds) - 1) diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py index ef5c67e01..b59f1fc66 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_tables_test.py @@ -129,11 +129,11 @@ def get_set_tables(record: coset_tables.CosetTails, set_index: int) -> list[int] def test_search_tables_plan_by_parity_exactly_when_cosets_of_an_even_code_differ() -> None: """A search plans with a table of bounds for each parity if its code is even and a coset odd. - The words of a coset of an even code share a parity, and a word of each syndrome bit is a row - of the inverse, so some coset is odd exactly when one of those rows is. The even code with the - inverse of its checks has odd cosets, and plans with a table for each parity. With an inverse - of even rows, it plans with one table, and so does the rank-deficient code, which is not even. - That table serves cosets of either parity. + The words of a coset of an even code share a parity, and the word of a syndrome sums words with + unit syndromes, so some coset is odd exactly when one of those words is. Some word with a unit + syndrome for the checks of the even code is odd, so it has odd cosets, and plans with a table + for each parity. With even words in their place, it plans with one table, and so does the + rank-deficient code, which is not even. That table serves cosets of either parity. """ even_inverse = _cosets.get_unit_syndrome_words(get_kernel(EVEN_ROUTES_CODE)) assert np.any(even_inverse.sum(axis=1) % 2) diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py index ce2a5273d..de29769ab 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/coset_test.py @@ -13,7 +13,6 @@ import numpy as np import numpy.typing as npt import pytest -import stim from qldpc import math from qldpc._binary import get_kernel, pack_bits, row_reduce_binary, unpack_bits @@ -35,9 +34,8 @@ TWO_FAMILIES_CODE, assert_exact_answers, get_binary_matrix, - get_css_minima, + get_exhaustive_minima, get_graph_state, - get_product_state, get_random_full_rank_matrix, get_stabilizer_matrix, get_syndrome, @@ -88,7 +86,7 @@ class stops (see Section 3.2 of ``brouwer_zimmermann.md``). So the test also fi parity. """ generators = ("Y______", "_XZ____", "_ZX__ZZ", "___Y___", "____Y__", "__Z__YZ", "__Z__ZY") - matrix = get_stabilizer_matrix([stim.PauliString(pauli) for pauli in generators]) + matrix = get_stabilizer_matrix(generators) assert_exact_answers(CosetLeaderSearch(matrix, css_weight=True), matrix, True) # Read the parity of each syndrome class from every error, and check that the class shares it. @@ -141,7 +139,7 @@ def test_cached_searches_answer_only_for_their_stabilizer_group( coset.get_css_searches.cache_clear() oracles = [CosetLeaderSearch(matrix, css_weight=True) for matrix in matrices] for oracle, matrix in zip(oracles, matrices, strict=True): - minima = get_css_minima(matrix) + minima = get_exhaustive_minima(matrix, css_weight=True) for syndrome, max_weight in itertools.product(range(1 << 6), range(4)): bound = oracle.get_min_weight_bound(syndrome, max_weight) assert bound.within_max_weight == (minima[syndrome] <= max_weight) @@ -294,7 +292,7 @@ def test_cached_pauli_searches_answer_for_their_own_stabilizers_and_weight() -> answers after every search is cached. """ states = [ - get_product_state(generators) + get_stabilizer_matrix(generators) for generators in (("Y__", "_Z_", "__X"), ("Y__", "YZ_", "__X"), ("X__", "_Y_", "__Z")) ] coset.get_pauli_search.cache_clear() @@ -921,7 +919,7 @@ def test_searches_by_tails_answer_as_searches_of_whole_words( (2, 1) if block_bits else (4, 3), range(-1, 6), range(16), - get_css_minima(matrix, css_weight=False), + get_exhaustive_minima(matrix, css_weight=False), ), True, ) @@ -1010,9 +1008,9 @@ def _coset_minima( ) -> npt.NDArray[np.int_]: """The least weight of the words of the coset of each syndrome, from every word of the coset. - A word with the syndrome sums rows of a right inverse of the checks, from ``_cosets``, so the - helper checks the syndromes of these words. The coset adds to such a word every word of - the code of ``generators``. + A word with the syndrome sums words with unit syndromes, from + ``_cosets.get_unit_syndrome_words``, so the helper checks the syndromes of these words. The + coset adds to such a word every word of the code of ``generators``. """ code = np.array(list(itertools.product([0, 1], repeat=len(generators)))) @ generators % 2 bits = (np.array(syndromes)[:, None] >> np.arange(len(checks))) & 1 @@ -1099,7 +1097,7 @@ def test_min_weights_of_cosets_match_brute_force( search = coset.get_pauli_search(coset.StabilizerKey(matrix, checks), True, css_fields) syndromes = range(1 << len(matrix)) _assert_min_weights_match_brute_force( - search, syndromes, get_css_minima(matrix), len(matrix), rng + search, syndromes, get_exhaustive_minima(matrix, css_weight=True), len(matrix), rng ) @@ -1136,7 +1134,9 @@ def test_min_weight_searches_above_max_weight_examine_as_queries( checks = (math.symplectic_conjugate(matrix.astype(int)) % 2).astype(np.uint8) search = coset.get_pauli_search(coset.StabilizerKey(matrix, checks), False, tuning_fields) syndromes = list(range(1 << len(matrix))) - cases.append((search, len(matrix), syndromes, get_css_minima(matrix, css_weight=False), False)) + cases.append( + (search, len(matrix), syndromes, get_exhaustive_minima(matrix, css_weight=False), False) + ) for search, num_syndrome_bits, syndromes, minima, must_skip in cases: skipped = 0 diff --git a/src/qldpc/codes/distance/_brouwer_zimmermann/distance.py b/src/qldpc/codes/distance/_brouwer_zimmermann/distance.py index 33473ee49..a94321234 100644 --- a/src/qldpc/codes/distance/_brouwer_zimmermann/distance.py +++ b/src/qldpc/codes/distance/_brouwer_zimmermann/distance.py @@ -13,8 +13,8 @@ images of one information set under automorphisms, and ``completion_bounds.py`` the tables that let a scan skip hopeless supports. Beside this module, ``affine_search.py`` holds the compiled scans of layers, and ``affine_instance.py`` packs the sets of a search with their certificate. The coset -searches share both. This module owns witness discovery, early -exits, exhaustive dispatch, and the resumable scans of layers in chunks. +searches share both. This module owns witness discovery, early exits, exhaustive dispatch, and the +resumable scans of layers in chunks. """ from __future__ import annotations diff --git a/src/qldpc/codes/distance/_connected_support/__init__.py b/src/qldpc/codes/distance/_connected_support/__init__.py index e54dec9d3..a152c6502 100644 --- a/src/qldpc/codes/distance/_connected_support/__init__.py +++ b/src/qldpc/codes/distance/_connected_support/__init__.py @@ -2,6 +2,6 @@ """Searches of the supports that are connected in a Tanner graph: of distances, and of cosets. -``qldpc.codes.distance.core`` and ``qldpc.codes.distance._coset_leader_search`` are the -interfaces of these modules. +``qldpc.codes.distance.core`` and ``qldpc.codes.distance._coset_leader_search`` are the interfaces +of these modules. """ diff --git a/src/qldpc/codes/distance/_connected_support/coset.py b/src/qldpc/codes/distance/_connected_support/coset.py index b992601a3..6661d99f7 100644 --- a/src/qldpc/codes/distance/_connected_support/coset.py +++ b/src/qldpc/codes/distance/_connected_support/coset.py @@ -89,8 +89,8 @@ def __init__( homogeneous=homogeneous, automorphism_minimum_states=_tuning.MAX_VALUE, ) - # Row ``j`` of the inverse is a word whose syndrome has only bit ``j`` set, so the words of - # the set bits of a syndrome sum to a representative of its coset. + # Word ``j`` of ``get_unit_syndrome_words`` of the sparser rows has a syndrome of only bit + # ``j``, so the words of the set bits of a syndrome sum to a representative of its coset. syndrome_rows = rows if homogeneous else np.roll(rows, num_variables, axis=1) self._inverse_rows = row_ints(get_unit_syndrome_words(syndrome_rows)) @@ -219,10 +219,11 @@ def _get_sparser_rows( if rounds_unchanged >= rounds_without_improvement: break - # Add rows to each other where that helps, and find the transform from the given rows. The - # rows are in the span of ``matrix``, whose product with the transposed inverse is the identity, - # so their product with it is the transform. Products of binary matrices in float32 are exact - # while their inner dimension is below ``2**24``, and faster than in integers. + # Add rows to each other where that helps, and find the transform from the given rows. The rows + # are in the span of ``matrix``, whose product with the transpose of ``get_unit_syndrome_words`` + # of ``matrix`` is the identity, so their product with it is the transform. Products of binary + # matrices in float32 are exact while their inner dimension is below ``2**24``, and faster than + # in integers. rows, _ = _sparsify(rows, homogeneous=homogeneous) inverse = get_unit_syndrome_words(matrix) product = rows.astype(np.float32) @ inverse.T.astype(np.float32) diff --git a/src/qldpc/codes/distance/_connected_support/coset_test.py b/src/qldpc/codes/distance/_connected_support/coset_test.py index 31c79be0d..48c8d08ac 100644 --- a/src/qldpc/codes/distance/_connected_support/coset_test.py +++ b/src/qldpc/codes/distance/_connected_support/coset_test.py @@ -17,7 +17,7 @@ from .._coset_leader_search import CosetLeaderSearch from .._tuning import use_tuning from ..conftest import ( - get_css_minima, + get_exhaustive_minima, get_graph_state, get_pauli_weight, get_random_css_state, @@ -71,7 +71,7 @@ def test_connected_support_answers_match_exhaustive_minima(pytestconfig: pytest. css_state = get_random_css_state(num_qubits, num_x_stabilizers, rng) for matrix, css_weight in [(graph_state, False), (css_state, False), (css_state, True)]: oracle = CosetLeaderSearch(matrix, css_weight=css_weight, method="connected_support") - minima = get_css_minima(matrix, css_weight) + minima = get_exhaustive_minima(matrix, css_weight) for syndrome, max_weight in itertools.product( range(1 << num_qubits), range(-1, num_qubits + 1) ): @@ -79,7 +79,7 @@ def test_connected_support_answers_match_exhaustive_minima(pytestconfig: pytest. single = oracle.get_min_weight_bound(syndrome, max_weight) batched = oracle._get_batch_support_bound(syndrome, max_weight) if not css_weight: - search = oracle._support_searches[0] + search = oracle._connected_support_searches[0] late = search.get_min_weight_bound( syndrome, max_weight, first_limit=max_weight + 1 ) diff --git a/src/qldpc/codes/distance/_connected_support/search.py b/src/qldpc/codes/distance/_connected_support/search.py index 485b3daea..3ef254e37 100644 --- a/src/qldpc/codes/distance/_connected_support/search.py +++ b/src/qldpc/codes/distance/_connected_support/search.py @@ -15,10 +15,10 @@ ``connected_support.md`` in ``src/qldpc/codes/distance/`` derives the search and states its correctness requirements. The Brouwer-Zimmermann search in ``_brouwer_zimmermann/distance.py`` -solves the same problem for any binary code. This module reuses the weight divisors of -``_information_sets/basis_families.py``, and the verification and orbits of automorphisms of +solves the same problem for any binary code. This module reuses ``infer_weight_divisor`` of +``_information_sets/basis_families.py``, and ``verify_automorphisms`` and ``get_orbits`` of ``_information_sets/automorphism_families.py``. The coset searches of ``CosetLeaderSearch`` that -start from a syndrome are in ``coset.py`` beside it. +start from a syndrome are in ``coset.py`` beside this module. """ from __future__ import annotations diff --git a/src/qldpc/codes/distance/_coset_leader_search.py b/src/qldpc/codes/distance/_coset_leader_search.py index 22bbd7530..7675f45c1 100644 --- a/src/qldpc/codes/distance/_coset_leader_search.py +++ b/src/qldpc/codes/distance/_coset_leader_search.py @@ -61,11 +61,10 @@ class CosetLeaderSearch: stabilizer ``j``. The weight of an error is either its physical weight, the number of qubits that it acts on, or its CSS weight ``max(|x|, |z|)``. - The oracle answers a query with one or two coset searches. By default, each searches the - cosets of a binary code with the Brouwer-Zimmermann (BZ) algorithm (see - ``brouwer_zimmermann.md``), as ``_search_coset`` in ``_brouwer_zimmermann/coset.py`` describes, - and ``get_min_weight_bound`` first - looks up words of weight at most one in a table. + The oracle answers a query with one or two coset searches. By default, each searches the cosets + of a binary code with the Brouwer-Zimmermann (BZ) algorithm (see ``brouwer_zimmermann.md``), as + ``_search_coset`` in ``_brouwer_zimmermann/coset.py`` describes, and ``get_min_weight_bound`` of + each coset search first looks up words of weight at most one in a table. - In general, one Pauli search covers the Pauli errors themselves, modulo the stabilizers. Its syndromes are for the given stabilizers, so it is cached for the most recently used @@ -111,8 +110,8 @@ class CosetLeaderSearch: within ``max_weight``. The threshold ``overlapping_minimum_work`` also decides which queries of the BZ searches by physical weight, and of the classical searches, weigh words without the pivot columns of each set of their family, and skip the words that tables of bounds prove to weigh - more than ``max_weight`` (see ``coset_routes.py``). The fields ``coset_completion_block_bits`` - and ``completion_bound_max_table_bytes`` configure these tables. Skipping words can change the + more than ``max_weight``. The fields ``coset_completion_block_bits`` and + ``completion_bound_max_table_bytes`` configure these tables. Skipping words can change the representatives and lower bounds of the answers without a word within ``max_weight``, but never whether an answer is within it. The fields ``max_sparsifying_rounds`` and ``sparsifying_rounds_without_improvement`` configure how the connected-support searches make @@ -152,8 +151,8 @@ def __init__( rounds_without_improvement=tuning.sparsifying_rounds_without_improvement, ) # Exactly one of the BZ searches and the connected-support searches is nonempty. - self._searches: tuple[BrouwerZimmermannCosetSearch, ...] = () - self._support_searches: tuple[ConnectedSupportCosetSearch, ...] = () + self._brouwer_zimmermann_searches: tuple[BrouwerZimmermannCosetSearch, ...] = () + self._connected_support_searches: tuple[ConnectedSupportCosetSearch, ...] = () self.css_weight = css_weight assert_binary(stabilizers, "stabilizers") matrix = as_binary_matrix(stabilizers, "stabilizers") @@ -173,7 +172,7 @@ def __init__( combinations = _get_css_combinations(matrix) if css_weight else None if combinations is None: if method == "brouwer_zimmermann": - self._searches = ( + self._brouwer_zimmermann_searches = ( get_pauli_search( StabilizerKey(matrix, checks), css_weight, @@ -186,7 +185,7 @@ def __init__( " use css_weight=False or the default method" ) else: - self._support_searches = (support_search(matrix, homogeneous=False),) + self._connected_support_searches = (support_search(matrix, homogeneous=False),) return # Combine the stabilizers into pure-X and pure-Z stabilizers, and row-reduce the X parts of @@ -203,12 +202,12 @@ def __init__( if method == "connected_support": # The Z parts of the pure-Z stabilizers check the X parts, and the X parts of the pure-X # stabilizers check the Z parts. - self._support_searches = ( + self._connected_support_searches = ( support_search(z_rows, homogeneous=True), support_search(x_rows, homogeneous=True), ) else: - self._searches = get_css_searches( + self._brouwer_zimmermann_searches = get_css_searches( n, len(x_rows), pack_bits(x_rows).tobytes(), @@ -254,7 +253,7 @@ def get_min_weight_bound(self, syndrome: int, max_weight: int) -> MinWeightBound """ if not 0 <= syndrome < 1 << self.num_qubits: raise ValueError("The syndrome is out of range") - searches = self._support_searches or self._searches + searches = self._connected_support_searches or self._brouwer_zimmermann_searches if len(searches) == 1: return searches[0].get_min_weight_bound(syndrome, max_weight) x_syndrome, z_syndrome = self._split_syndrome(syndrome) @@ -291,7 +290,7 @@ def find_first_not_within_max_weight( self._check_syndromes(syndromes) # The connected-support searches answer one syndrome at a time. - if self._support_searches: + if self._connected_support_searches: for index, row in enumerate(syndromes): bound = self._get_batch_support_bound(words_to_int(row), max_weight) if not bound.within_max_weight: @@ -299,8 +298,10 @@ def find_first_not_within_max_weight( return len(syndromes), None # With one search, take its bound and the lightest error that it weighed. - if len(self._searches) == 1: - return self._searches[0].find_first_not_within_max_weight(syndromes, max_weight) + if len(self._brouwer_zimmermann_searches) == 1: + return self._brouwer_zimmermann_searches[0].find_first_not_within_max_weight( + syndromes, max_weight + ) # With two searches, a syndrome's class fails, that is, has no error of weight at most # ``max_weight``, exactly when the coset of its X part or that of its Z part does. So find @@ -308,7 +309,7 @@ def find_first_not_within_max_weight( # syndromes before it. The bound for the failing syndrome needs the representatives of both # cosets, which ``get_min_weight_bound`` finds. index = len(syndromes) - for search, map_columns in zip(self._searches, self._map_columns): + for search, map_columns in zip(self._brouwer_zimmermann_searches, self._map_columns): part_syndromes = _map_syndromes(syndromes[:index], map_columns) index, _ = search.find_first_not_within_max_weight(part_syndromes, max_weight) if index == len(syndromes): @@ -345,7 +346,7 @@ def _get_min_weights( return np.full(len(syndromes), max_weight + 1, dtype=np.int64) # The connected-support searches answer one syndrome at a time. - if self._support_searches: + if self._connected_support_searches: return np.array( [self._get_min_support_weight(words_to_int(row), max_weight) for row in syndromes], dtype=np.int64, @@ -353,14 +354,16 @@ def _get_min_weights( # With one search, take its weights. floors = np.zeros(len(syndromes), dtype=np.int64) - if len(self._searches) == 1: - return self._searches[0].get_min_weights(syndromes, max_weight, floors) + if len(self._brouwer_zimmermann_searches) == 1: + return self._brouwer_zimmermann_searches[0].get_min_weights( + syndromes, max_weight, floors + ) # With two searches, the smallest CSS weight of an error in a class is the larger of the # minimum weights of its X part and its Z part. So search the X parts, and then the Z parts # of the classes whose X parts are within ``max_weight``. Any Z part within the weight of # its X part gives that weight, so that weight is the floor of its search. - x_search, z_search = self._searches + x_search, z_search = self._brouwer_zimmermann_searches x_syndromes = _map_syndromes(syndromes, self._map_columns[0]) weights = x_search.get_min_weights(x_syndromes, max_weight, floors) rows = np.flatnonzero(weights <= max_weight) @@ -381,7 +384,7 @@ def _get_min_support_weight(self, syndrome: int, max_weight: int) -> int: Returns: The minimum weight of the class, or ``max_weight + 1`` if that is larger. """ - searches = self._support_searches + searches = self._connected_support_searches if len(searches) == 1: bound = searches[0].get_min_weight_bound(syndrome, max_weight, first_limit=2) return bound.lower_bound if bound.within_max_weight else max_weight + 1 @@ -404,7 +407,7 @@ def _get_batch_support_bound(self, syndrome: int, max_weight: int) -> MinWeightB ``find_first_not_within_max_weight`` checks the range of every syndrome first (``_check_syndromes``). """ - searches = self._support_searches + searches = self._connected_support_searches if len(searches) == 1: return searches[0].get_min_weight_bound(syndrome, max_weight, first_limit=2) x_syndrome, z_syndrome = self._split_syndrome(syndrome) @@ -416,9 +419,9 @@ def _get_batch_support_bound(self, syndrome: int, max_weight: int) -> MinWeightB def _check_syndromes(self, syndromes: npt.NDArray[np.uint64]) -> None: """Raise a ``ValueError`` unless each packed syndrome of a batch is below ``2**num_qubits``. - The compiled searches read a row of the right inverse of their checks for each set bit of a - syndrome, and do not check the range of the bit. So a batch checks its rows first, in the - machine word that holds bit ``num_qubits``, if any, and in the words above it. + The compiled searches read a word with a unit syndrome for each set bit of a syndrome, and + do not check the range of the bit. So a batch checks its rows first, in the machine word + that holds bit ``num_qubits``, if any, and in the machine words above it. """ num_words, num_bits = divmod(self.num_qubits, 64) high_words = syndromes[:, num_words:] diff --git a/src/qldpc/codes/distance/_coset_leader_search_test.py b/src/qldpc/codes/distance/_coset_leader_search_test.py index 57c0a85a0..f0f0538b5 100644 --- a/src/qldpc/codes/distance/_coset_leader_search_test.py +++ b/src/qldpc/codes/distance/_coset_leader_search_test.py @@ -20,10 +20,9 @@ ROUTED_CSS_WEIGHT_STATE, ROUTED_PHYSICAL_STATES, assert_exact_answers, - get_css_minima, + get_exhaustive_minima, get_graph_state, get_pauli_weight, - get_product_state, get_random_css_state, get_stabilizer_matrix, get_syndrome, @@ -361,9 +360,7 @@ def test_exhausted_search_gives_the_exact_minimum() -> None: errors, and the bounds of its certificate stay below four, so only exhausting every layer proves the minimum. """ - matrix = get_stabilizer_matrix( - [stim.PauliString(pauli) for pauli in ("Y____", "_Z___", "__Z__", "___Z_", "____Z")] - ) + matrix = get_stabilizer_matrix(("Y____", "_Z___", "__Z__", "___Z_", "____Z")) oracle = CosetLeaderSearch(matrix, css_weight=True) bound = oracle.get_min_weight_bound(0b11111, 3) assert not bound.within_max_weight and bound.lower_bound == 4 @@ -493,7 +490,7 @@ def test_css_bounds_match_exhaustive_minima(pytestconfig: pytest.Config) -> None for num_x_stabilizers in range(num_qubits + 1): matrix = get_random_css_state(num_qubits, num_x_stabilizers, rng) oracle = CosetLeaderSearch(matrix, css_weight=True) - minima = get_css_minima(matrix) + minima = get_exhaustive_minima(matrix, css_weight=True) for syndrome in rng.permutation(1 << num_qubits).tolist(): for max_weight in rng.permutation(num_qubits + 1).tolist(): bound = oracle.get_min_weight_bound(syndrome, max_weight) @@ -549,7 +546,7 @@ def test_oracles_answer_exactly_with_other_families( oracle = CosetLeaderSearch(matrix, css_weight=css_weight) assert_exact_answers(oracle, matrix, css_weight) num_qubits = len(matrix) - minima = get_css_minima(matrix, css_weight) + minima = get_exhaustive_minima(matrix, css_weight) syndromes = pack_syndromes(list(range(1 << num_qubits)), num_qubits) for max_weight in range(num_qubits + 1): found = _find_each_not_within_max_weight(oracle, matrix, syndromes, max_weight) @@ -604,13 +601,15 @@ def test_min_weights_match_exhaustive_minima( cycle_state = get_graph_state(5, [(0, 1), (1, 2), (2, 3), (3, 4), (0, 4)], phase=True) cases.append((cycle_state, True, False)) steane_zero = ("XXXX___", "XX__XX_", "X_X_X_X", "ZZZZ___", "ZZ__ZZ_", "Z_Z_Z_Z", "ZZZZZZZ") - cases += [(get_product_state(steane_zero), css_weight, True) for css_weight in (False, True)] + cases += [ + (get_stabilizer_matrix(steane_zero), css_weight, True) for css_weight in (False, True) + ] ghz_block = ("XXXX____", "ZZ______", "_ZZ_____", "__ZZ____") conjugate_block = ("____ZZZZ", "____XX__", "_____XX_", "______XX") - cases.append((get_product_state(ghz_block + conjugate_block), True, True)) + cases.append((get_stabilizer_matrix(ghz_block + conjugate_block), True, True)) for matrix, css_weight, by_support in cases: num_qubits = len(matrix) - minima = get_css_minima(matrix, css_weight) + minima = get_exhaustive_minima(matrix, css_weight) syndromes = rng.permutation(1 << num_qubits) packed_syndromes = pack_syndromes(syndromes.tolist(), num_qubits) methods: list[CosetSearchMethod] = ["brouwer_zimmermann"] @@ -632,7 +631,7 @@ def test_batches_reject_syndromes_out_of_range(method: CosetSearchMethod) -> Non weights. A row with a zero second word is in range: the syndrome three needs Z on the first qubit and X on the second. """ - oracle = CosetLeaderSearch(get_product_state(["X_", "_Z"]), method=method) + oracle = CosetLeaderSearch(get_stabilizer_matrix(["X_", "_Z"]), method=method) for row in ([4], [3, 1]): syndromes = np.array([row], dtype=np.uint64) with pytest.raises(ValueError, match="out of range"): @@ -656,7 +655,7 @@ def test_connected_support_batches_by_css_weight_join_their_parts( matrix = get_random_css_state(num_qubits, num_qubits // 2, rng) oracle = CosetLeaderSearch(matrix, css_weight=True, method="connected_support") syndromes = rng.permutation(1 << num_qubits) - minima = get_css_minima(matrix)[syndromes] + minima = get_exhaustive_minima(matrix, css_weight=True)[syndromes] words = pack_syndromes(syndromes.tolist(), num_qubits) for max_weight in range(num_qubits + 2): found = _find_each_not_within_max_weight(oracle, matrix, words, max_weight) diff --git a/src/qldpc/codes/distance/_cosets.py b/src/qldpc/codes/distance/_cosets.py index f79cfa9d5..5f8d50f32 100644 --- a/src/qldpc/codes/distance/_cosets.py +++ b/src/qldpc/codes/distance/_cosets.py @@ -2,9 +2,10 @@ """Parts that the coset searches of ``CosetLeaderSearch`` share. -They are the answers of coset queries (:class:`MinWeightBound`), the words with unit syndromes that -turn syndromes into words, the packing of binary rows into integers and back, the sums of the rows -that a support selects, and the tables of the errors of weight zero or one by syndrome. +They are the answers of coset queries (:class:`MinWeightBound`), the words with unit syndromes +(whose sums give a word of each syndrome), the packing of binary rows into integers and back, the +sums of the rows that a support selects, and the tables of the errors of weight zero or one by +syndrome. """ from __future__ import annotations @@ -31,10 +32,10 @@ class MinWeightBound: Attributes: within_max_weight: Whether the coset has an element of weight at most ``max_weight``. - representative: An element of the coset: a packed Pauli (see ``CosetLeaderSearch``) for a - query of the oracle, and a word of the search (a packed Pauli, or an X or Z part) for a - query of a coset search. If ``within_max_weight`` is True, its weight is at most - ``max_weight``. + representative: An element of the coset. For a query of ``CosetLeaderSearch`` + (``_coset_leader_search.py``), it is a packed Pauli, as that class packs it. For a + query of a coset search, it is a word of that search (a packed Pauli, or an X or Z + part). If ``within_max_weight`` is True, its weight is at most ``max_weight``. lower_bound: A lower bound on the minimum weight of the coset. If ``within_max_weight`` is False, it exceeds ``max_weight``. """ @@ -78,8 +79,9 @@ def tabulate_singles( The words are the identity and, for packed Paulis, X, Z, and Y on each qubit, and for X or Z parts, the single bits. The syndrome of Y on a qubit is the sum of those of X and Z there. - Each table maps each syndrome of its words to the first of them, in the order of the identity, - and then of the qubits, with X before Z before Y on each, or of the bits. + Each table maps each syndrome of its words to the first word with that syndrome. The words + come in this order: the identity, and then X, Z, and Y on each qubit in turn for packed Paulis, + or each bit in turn for X or Z parts. Args: check_columns: Column ``c`` of the checks as an integer: the syndrome of the word with only diff --git a/src/qldpc/codes/distance/_cosets_test.py b/src/qldpc/codes/distance/_cosets_test.py index 975f15c00..ef4ac6b7a 100644 --- a/src/qldpc/codes/distance/_cosets_test.py +++ b/src/qldpc/codes/distance/_cosets_test.py @@ -12,8 +12,8 @@ from . import _cosets -def test_inverses_of_checks_have_one_syndrome_bit_in_each_row(pytestconfig: pytest.Config) -> None: - """Row ``j`` of the inverse of independent checks has a syndrome of only bit ``j``.""" +def test_unit_syndrome_words_have_one_syndrome_bit_each(pytestconfig: pytest.Config) -> None: + """Row ``j`` of the unit-syndrome words of independent checks has only syndrome bit ``j``.""" rng = np.random.default_rng(pytestconfig.getoption("randomly_seed")) for num_checks, num_bits in [(1, 1), (3, 7), (5, 12)]: identity = np.eye(num_checks, dtype=np.uint8) @@ -55,20 +55,37 @@ def test_sums_of_selected_rows_follow_their_definition(pytestconfig: pytest.Conf def test_tables_of_single_errors_keep_the_first_word_of_each_syndrome() -> None: - """Tables of the words of weight zero or one map each syndrome to its first word. + """Each table of the words of weight zero or one keeps the first word with each syndrome. - For packed Paulis on two qubits, the words are tried qubit by qubit, X before Z before Y. So - the Z error on qubit zero, at bit two, takes the syndrome 0b10 before the X error on qubit one, - and the Y error on qubit zero takes 0b11 before the Z error on qubit one. For X or Z parts, the - two bits with the syndrome 0b01 keep the first, and the tables of X and Z errors hold only the - identity. + On two qubits, Z on qubit zero has the syndrome 0b11 of X on qubit one, and Y on qubit zero has + the syndrome 0b10, the sum of 0b01 and 0b11, of Z on qubit one. The table of all words keeps + the words on qubit zero, since it takes the words of one qubit at a time. On three qubits, X + and Z on qubit zero and X on qubit two have the syndrome 0b01, so the table of all words and + that of X errors keep X on qubit zero. Y on qubit zero has the trivial syndrome, so the table + of all words keeps the identity, and Y on qubit one has the syndrome of X there, so it keeps + that X. Z on qubit one has the trivial syndrome, so the table of Z errors keeps the identity. + On two qubits whose X errors have the trivial syndrome, the tables keep the identity rather than + those X errors, and Z on qubit zero rather than Y there or Z and Y on qubit one, so Z comes + before Y. For X or Z parts, the table keeps the first of the two bits with the syndrome 0b01, + and the identity rather than the third bit, whose syndrome is trivial. The tables of X and Z + errors hold only the identity. """ singles, x_singles, z_singles = _cosets.tabulate_singles( - [0b01, 0b10, 0b10, 0b11], 2, is_pauli=True + [0b01, 0b11, 0b11, 0b10], 2, is_pauli=True ) - assert singles == {0: 0, 0b01: 0b0001, 0b10: 0b0100, 0b11: 0b0101} - assert x_singles == {0: 0, 0b01: 0b0001, 0b10: 0b0010} - assert z_singles == {0: 0, 0b10: 0b0100, 0b11: 0b1000} - singles, x_singles, z_singles = _cosets.tabulate_singles([0b01, 0b01, 0b10], 0, is_pauli=False) - assert singles == {0: 0, 0b01: 0b001, 0b10: 0b100} + assert singles == {0: 0, 0b01: 0b0001, 0b11: 0b0100, 0b10: 0b0101} + assert x_singles == {0: 0, 0b01: 0b0001, 0b11: 0b0010} + assert z_singles == {0: 0, 0b11: 0b0100, 0b10: 0b1000} + singles, x_singles, z_singles = _cosets.tabulate_singles( + [0b01, 0b10, 0b01, 0b01, 0, 0b11], 3, is_pauli=True + ) + assert singles == {0: 0, 0b01: 0b000001, 0b10: 0b000010, 0b11: 0b100000} + assert x_singles == {0: 0, 0b01: 0b000001, 0b10: 0b000010} + assert z_singles == {0: 0, 0b01: 0b001000, 0b11: 0b100000} + singles, x_singles, z_singles = _cosets.tabulate_singles([0, 0, 0b1, 0b1], 2, is_pauli=True) + assert singles == {0: 0, 0b1: 0b0100} + assert x_singles == {0: 0} + assert z_singles == {0: 0, 0b1: 0b0100} + singles, x_singles, z_singles = _cosets.tabulate_singles([0b01, 0b01, 0], 0, is_pauli=False) + assert singles == {0: 0, 0b01: 0b001} assert x_singles == z_singles == {0: 0} diff --git a/src/qldpc/codes/distance/_counting/macwilliams.py b/src/qldpc/codes/distance/_counting/macwilliams.py index 2cf3d090c..4a9e4ecce 100644 --- a/src/qldpc/codes/distance/_counting/macwilliams.py +++ b/src/qldpc/codes/distance/_counting/macwilliams.py @@ -15,8 +15,8 @@ bound rules out overflow, and exact Python integers at the others. The walk visits the words of a span in reflected Gray-code order, as the brute-force distance search -in ``_brute_force.py`` does, but it reduces every word to a weight histogram in one compiled -loop instead of to a minimum weight over vectorized blocks. +in ``_brute_force.py`` does, but it reduces every word to a weight histogram in one compiled loop +instead of to a minimum weight over vectorized blocks. References: diff --git a/src/qldpc/codes/distance/_counting/scan.py b/src/qldpc/codes/distance/_counting/scan.py index 6dfb28146..a0ec4f5fc 100644 --- a/src/qldpc/codes/distance/_counting/scan.py +++ b/src/qldpc/codes/distance/_counting/scan.py @@ -3,10 +3,10 @@ """The scan of the words of one weight in a sector: its set-up, its loop, and its native chunks. A sector is a basis whose quotient labels mark its nontrivial words. ``count_words`` selects -information sets for the basis, with the basis family stages of ``_information_sets/``, packs -them, and visits each word of the given weight once, by enumerating combinations of the rows of -every set, layer by layer. A step of two or more rows of an information set may skip the supports -whose words all weigh more than the given weight, with the completion tables of +information sets for the basis, with the basis family stages of ``_information_sets/``, packs them, +and visits each word of the given weight once, by enumerating combinations of the rows of every set, +layer by layer. A step of two or more rows of an information set may skip the supports whose words +all weigh more than the given weight, with the completion tables of ``_information_sets/completion_bounds.py`` (Section 3.9 of ``brouwer_zimmermann.md``). ``transport_basis_family`` moves the disjoint basis family that a counter keeps for a sector, from which both its counts and its distance searches start, through a local gate, for deformed counters. diff --git a/src/qldpc/codes/distance/_information_sets/__init__.py b/src/qldpc/codes/distance/_information_sets/__init__.py index a34aaedb8..f0d7379c5 100644 --- a/src/qldpc/codes/distance/_information_sets/__init__.py +++ b/src/qldpc/codes/distance/_information_sets/__init__.py @@ -4,6 +4,6 @@ The Brouwer-Zimmermann searches and counts build on these modules: basis families, their certificates and plans of steps, the step from one support of a layer to the next, automorphism -families, completion tables, and matroid partitioning. The connected-support search uses their -weight divisors, and their checks and orbits of automorphisms. +families, completion tables, and matroid partitioning. The connected-support search uses +``infer_weight_divisor``, ``verify_automorphisms``, and ``get_orbits`` of these modules. """ diff --git a/src/qldpc/codes/distance/_information_sets/certificates.py b/src/qldpc/codes/distance/_information_sets/certificates.py index fd113f3dd..128aad259 100644 --- a/src/qldpc/codes/distance/_information_sets/certificates.py +++ b/src/qldpc/codes/distance/_information_sets/certificates.py @@ -188,7 +188,8 @@ def get_weight_bounds( if parity is not None: # Round each finite entry up to the parity. The entry before the first step bounds no # step, and stays zero so that every nonnegative maximum weight passes it, as a check of - # a cached stopping step requires (see ``get_stopping_step`` of a coset search). + # a cached stopping step requires (see ``get_stopping_step`` in + # ``_brouwer_zimmermann/coset_tables.py``). bounds = bounds + (bounds - parity) % 2 * (bounds < NO_WORD_BOUND) bounds[0, 0] = 0 diff --git a/src/qldpc/codes/distance/brouwer_zimmermann.md b/src/qldpc/codes/distance/brouwer_zimmermann.md index 0c57fd939..5e9ec783a 100644 --- a/src/qldpc/codes/distance/brouwer_zimmermann.md +++ b/src/qldpc/codes/distance/brouwer_zimmermann.md @@ -844,9 +844,6 @@ The weight divisor of the certificate rounds its Hamming bounds up to multiples The distance search builds another instance when it switches to an automorphism family, whose certificate covers every image of the one set that it scans. `CosetLeaderSearch` in [_coset_leader_search.py](_coset_leader_search.py), which the fault-propagation certifier in `qldpc.circuits.certification` uses, applies the same search to a coset `u + V` instead of `V`, unless it is built with `method="connected_support"` ([connected_support.md](connected_support.md)). -Its BZ coset searches, `BrouwerZimmermannCosetSearch` with `_search_coset` and `_find_min_weights`, are in [_brouwer_zimmermann/coset.py](_brouwer_zimmermann/coset.py). -The tables of their steps and tails, such as `get_search_tables`, `get_stopping_step`, and `CosetTails`, are in [_brouwer_zimmermann/coset_tables.py](_brouwer_zimmermann/coset_tables.py). -What they share with the connected-support coset searches, such as `MinWeightBound`, is in [_cosets.py](_cosets.py). There a layer adds `w` rows to an **offset**, a word of the coset that is zero on the pivot columns ([Section 2.3](#23-systematic-bases-and-information-sets)), so layer zero is the offset itself. A **query** of `CosetLeaderSearch` asks whether the coset of a syndrome has a word of weight at most a given maximum weight. `CosetLeaderSearch` answers each query with one or two **coset searches**: a search of Pauli errors, or, for the CSS weight on a CSS state, searches of their X and Z parts, as described at the end of this section. @@ -855,12 +852,12 @@ Each family of search bases with which a coset search answers queries has a **co The certificate of a coset instance applies unchanged, except that it takes no weight divisor ([Section 3.2](#32-rounding-the-certificate-to-a-proven-weight-multiple)), because the words of a coset need not share the divisibility of `V`. This holds even when `get_disjoint_family` infers a divisor for `V`. If every word of `V` has even weight, the bounds of each coset may instead be rounded up to its parity, which `AffineInstance.get_weight_bounds` does when it is given the parity. -`get_search_tables` rounds them when some coset of the search is odd, which holds exactly when some row of the right inverse of its checks, as a binary word of the search, is odd. +`get_search_tables` rounds them when some coset of the search is odd, which holds exactly when some word whose syndrome has one set bit, as a binary word of the search, is odd. In a search of Pauli errors, an error weighs at least half the Hamming weight of the binary word that represents it. So the instance of such a search has a **weight factor** `η` of two: `AffineInstance.get_weight_bounds` divides the bounds of its certificate by two, rounding up, after any rounding to parity. The instance of a search of X or Z parts has the weight factor `η = 1`. A query of a coset search asks the same of a coset of its code, whose words have syndromes for the checks of that code. -It starts from the **word of its syndrome**, a word of the coset that sums the rows of a right inverse of the checks for the set bits of the syndrome. +It starts from the **word of its syndrome**, a word of the coset that sums, for each set bit of the syndrome, a word whose syndrome has only that bit (`get_unit_syndrome_words`). It stops at its **stopping step**, the first step whose bound exceeds the maximum weight, unless it finds a light enough word sooner. It skips each rank-deficient search basis whose deficiency keeps it out of the bound of that step. `find_stopping_step` of [_information_sets/certificates.py](_information_sets/certificates.py) finds the stopping steps of the coset searches, and of the distance searches whose certificates solve no linear programs, by a binary search of steps whose bounds never decrease. @@ -870,11 +867,12 @@ Each family of a coset search keeps the bounds of its steps in a **planning buff A query takes the table of the parity of the word of its syndrome. Each table has one more row that caches a stopping step. For a nonnegative maximum weight, `get_stopping_step` reuses the cached step of a table exactly while it stays the stopping step of that weight, and replaces it otherwise. -`get_min_weight_bound` first looks up the words of weight at most one. +`BrouwerZimmermannCosetSearch.get_min_weight_bound` first looks up the words of weight at most one. If there is none and its maximum weight is below two, it gives the lower bound two without a search, or three for an odd coset in a search of X or Z parts that rounds to parity. Otherwise, a query takes the tables of the **route** of its maximum weight, which `CosetRoutes` of [_brouwer_zimmermann/coset_routes.py](_brouwer_zimmermann/coset_routes.py) chooses once for each maximum weight ([Section 4.4](#44-heuristics-and-cost-models)). A route is a family of search bases of the code, with its coset instance and planning buffer: the disjoint sets, or a family that the routes build for costly queries. -`find_first_not_within_max_weight` answers a batch with the route of its maximum weight, which it takes once, and the batch of the X parts and that of the Z parts each take their own. +`BrouwerZimmermannCosetSearch.find_first_not_within_max_weight` answers a batch with the route of its maximum weight, which it takes once. +For the CSS weight on a CSS state, `CosetLeaderSearch` gives the X parts and the Z parts of a batch to their own searches, each with its own route. `_get_min_weights` of `CosetLeaderSearch` finds the minimum weights of [Section 3.10](#310-minimum-weights-of-cosets) for the residual-weight estimator. `BrouwerZimmermannCosetSearch.get_min_weights` answers a batch on the route of the maximum weight ([Section 4.4](#44-heuristics-and-cost-models)), with `_find_min_weights`. These searches find their stopping steps with `find_stopping_step`, and leave the cached steps of the planning buffer to the queries. @@ -883,7 +881,7 @@ A route is a **route by tails** if those with the route's own family, which can The queries of a route by tails weigh words by their tails ([Section 3.5](#35-packed-enumeration-with-pivot-column-deletion)): they pass the record of tails of the route to the compiled searches, such as `_search_coset`, in place of None. Numba compiles each search separately for None and for a record of tails, so the choice costs nothing for each word. Every route by tails of a family gives the latest record `CosetTails` of the family. -The record holds the tails of the rows of each search basis, and of the rows of the right inverse of the checks, from which a query adds up the tail of each offset. +The record holds the tails of the rows of each search basis, and of the words with unit syndromes, from which a query adds up the tail of each offset. It also holds the completion tables of the search bases that the queries of the route scan in layers of two or more rows, if they fit in `completion_bound_max_table_bytes`. The routes build the tails of a family once, and the tables of each of its search bases once, for the first route that needs them (`get_coset_tails`, `completion_bounds.considers_tables`, and `completion_bounds.build_tables_within`). They keep only the latest record of a family, so they hold the tables of each search basis once. @@ -897,6 +895,9 @@ The distance search finds the stopping step of each layer with `Certificate.plan `Certificate.plan` calls `find_stopping_step` when its certificate solves no linear programs. A certificate with linear programs instead checks the steps of the layer but the last in order with `certifies`, which solves each program only when the coverage bound falls short. For the CSS weight `max(|x|, |z|)` on a CSS state, whose stabilizer group is generated by stabilizers with no Z part and stabilizers with no X part, `CosetLeaderSearch` instead runs this search on two classical codes, spanned by the X parts of the first kind and by the Z parts of the second, with words weighed by their Hamming weight. +The BZ coset searches of `CosetLeaderSearch`, `BrouwerZimmermannCosetSearch` with `_search_coset` and `_find_min_weights`, are in [_brouwer_zimmermann/coset.py](_brouwer_zimmermann/coset.py). +The tables of their steps and tails, such as `get_search_tables`, `get_stopping_step`, and `CosetTails`, are in [_brouwer_zimmermann/coset_tables.py](_brouwer_zimmermann/coset_tables.py). +What they share with the connected-support coset searches, such as `MinWeightBound`, is in [_cosets.py](_cosets.py). ### 4.2 Flow and exit points @@ -935,7 +936,7 @@ Exhaustive enumeration is chosen automatically at two points: preparation's size | `_BrouwerZimmermannSearch.finished` | Marks a search finished if `U ≤ cutoff`, `L ≥ U`, or every layer has been enumerated (`next_weight > dimension`) | | `get_distance_brouwer_zimmermann_many` | Returns the shared best bound when it is at most `cutoff` after an advance, or when no search can advance | -The tests `test_brouwer_zimmermann_setup_exits`, `test_brouwer_zimmermann_setup_cutoffs`, `test_rows_of_an_automorphism_family_can_reach_the_cutoff`, and `test_enumeration_by_work_can_be_forbidden` exercise these exits. +The tests `test_brouwer_zimmermann_setup_exits`, `test_brouwer_zimmermann_setup_cutoffs`, `test_rows_of_an_automorphism_family_can_reach_the_cutoff`, and `test_enumeration_by_work_can_be_forbidden` in [_brouwer_zimmermann/distance_test.py](_brouwer_zimmermann/distance_test.py) exercise these exits. ### 4.3 Concept-to-code glossary @@ -1133,13 +1134,13 @@ Changes to the BZ algorithm can use different representations, certificates, and Representative correctness tests check certificate validity against exhaustive words, including rank-deficient blocks, inferred parity, and overlapping coverage (`test_family_certificates` and `test_overlapping_family_certificates` in [_information_sets/basis_families_test.py](_information_sets/basis_families_test.py)), and joint-search results with distinct sector certificates (`test_brouwer_zimmermann_sector_certificates` in [_brouwer_zimmermann/distance_test.py](_brouwer_zimmermann/distance_test.py)). `test_counting_continues_at_an_equal_lower_bound` in [logical_counts_test.py](logical_counts_test.py) checks the strict counting stop with a count that a stop at `L(t) = w` would miss, and `test_counts_with_completion_tables_match_enumeration` in [_counting/scan_test.py](_counting/scan_test.py) checks counts with completion tables forced against enumeration, which counts that skipped at equality would miss. `test_automorphism_families_certify_like_the_family_of_all_images` in [_information_sets/automorphism_families_test.py](_information_sets/automorphism_families_test.py) checks the certificate of an automorphism family against every image of the scanned set, and `test_automorphism_family_searches_scan_every_layer_they_certify` in [_brouwer_zimmermann/distance_test.py](_brouwer_zimmermann/distance_test.py) checks a search of an automorphism family whose lightest word appears only in its last layer. -`test_automorphism_family_searches_scan_their_new_set_from_layer_two` checks a distance that a search that switched to an automorphism family would miss if it skipped the early layers of its new set. -`test_searches_ask_for_automorphisms_only_before_costly_layers` checks that easy searches do not pay to find automorphisms. +`test_automorphism_family_searches_scan_their_new_set_from_layer_two` in [_brouwer_zimmermann/distance_test.py](_brouwer_zimmermann/distance_test.py) checks a distance that a search that switched to an automorphism family would miss if it skipped the early layers of its new set. +`test_searches_ask_for_automorphisms_only_before_costly_layers` in [_brouwer_zimmermann/distance_test.py](_brouwer_zimmermann/distance_test.py) checks that easy searches do not pay to find automorphisms. `test_bounds_are_exact_relaxations` in [_information_sets/completion_bounds_test.py](_information_sets/completion_bounds_test.py) checks completion bounds against enumerated completions. `test_scan_of_one_information_set` in [_brouwer_zimmermann/affine_search_test.py](_brouwer_zimmermann/affine_search_test.py) checks that the scan finds the lightest word of a layer and weighs or skips each support exactly once, and `test_completion_tables_keep_distances_exact` in [_brouwer_zimmermann/distance_test.py](_brouwer_zimmermann/distance_test.py) checks whole searches against brute force. `test_coset_tail_scans_with_tables_skip_only_completions_that_reach_the_skip_bound` in [_brouwer_zimmermann/affine_search_test.py](_brouwer_zimmermann/affine_search_test.py) checks the bound of coset scans, including search bases of rank deficiency three, against enumeration, and `test_searches_by_tails_answer_as_searches_of_whole_words` in [_brouwer_zimmermann/coset_test.py](_brouwer_zimmermann/coset_test.py) checks the answers, skipped words, and bounds `R` of coset queries with tables, also on even codes, against searches without tables and brute force. In [core_test.py](core_test.py), `test_brouwer_zimmermann_random_cross_checks` forces the BZ route and compares both homogeneous and general Pauli problems with exhaustive results. -`test_max_weight` checks weight-limited results against enumeration on every route, and `test_max_weight_caps_cutoff` checks a case that a weight limit without the `cutoff` cap would get wrong. +`test_max_weight` in [core_test.py](core_test.py) checks weight-limited results against enumeration on every route, and `test_max_weight_caps_cutoff` checks a case that a weight limit without the `cutoff` cap would get wrong. Performance tests also check broad work budgets, and helper tests exercise the current implementation's interfaces. Those tests can evolve with the implementation; they do not make a particular information-set family, packing order, or optimization a correctness requirement. diff --git a/src/qldpc/codes/distance/conftest.py b/src/qldpc/codes/distance/conftest.py index d2eb5ca18..7908c5110 100644 --- a/src/qldpc/codes/distance/conftest.py +++ b/src/qldpc/codes/distance/conftest.py @@ -250,9 +250,11 @@ def assert_counts_match_enumeration(counter: lc.CSSLogicalCounter, **options: An # helpers for the tests of coset-leader searches -def get_stabilizer_matrix(generators: Sequence[stim.PauliString]) -> npt.NDArray[np.uint8]: +def get_stabilizer_matrix(generators: Sequence[stim.PauliString | str]) -> npt.NDArray[np.uint8]: """Stabilizer matrix [X|Z] with the given Pauli strings as rows.""" - return np.array([np.concatenate(gg.to_numpy()) for gg in generators], dtype=np.uint8) + return np.array( + [np.concatenate(stim.PauliString(gg).to_numpy()) for gg in generators], dtype=np.uint8 + ) def get_graph_state( @@ -338,7 +340,7 @@ def scramble(matrix: npt.NDArray[np.uint8], rng: np.random.Generator) -> npt.NDA return (upper[rng.permutation(size)] @ matrix) % 2 -def get_css_minima(matrix: npt.NDArray[np.uint8], css_weight: bool = True) -> npt.NDArray[np.int_]: +def get_exhaustive_minima(matrix: npt.NDArray[np.uint8], css_weight: bool) -> npt.NDArray[np.int_]: """The smallest CSS (or physical) weight of an error with each packed syndrome. The minima come from all ``4**n`` Paulis. @@ -367,17 +369,12 @@ def get_css_minima(matrix: npt.NDArray[np.uint8], css_weight: bool = True) -> np ) -def get_product_state(generators: Sequence[str]) -> npt.NDArray[np.uint8]: - """Stabilizer matrix [X|Z] of the given Pauli strings.""" - return get_stabilizer_matrix([stim.PauliString(pauli) for pauli in generators]) - - def assert_exact_answers( oracle: CosetLeaderSearch, matrix: npt.NDArray[np.uint8], css_weight: bool ) -> None: """Check every query of an oracle against exhaustive minima of the state of ``matrix``.""" num_qubits = len(matrix) - minima = get_css_minima(matrix, css_weight) + minima = get_exhaustive_minima(matrix, css_weight) for syndrome, max_weight in itertools.product(range(1 << num_qubits), range(num_qubits + 1)): bound = oracle.get_min_weight_bound(syndrome, max_weight) weight = get_pauli_weight(bound.representative, num_qubits, css_weight) From 3d6922b7cb930c31b7a80f43bfa3f330696580bf Mon Sep 17 00:00:00 2001 From: "Michael A. Perlin" Date: Thu, 12 Nov 2026 10:23:51 -0500 Subject: [PATCH 7/7] Rename the layering tests to _layering_test.py The tests of the import layers of `qldpc.circuits`, `qldpc.decoders`, and `qldpc.codes.distance` test no module of their own, so they get a leading underscore, as `_code_style_test.py` has. The package docstrings, the README of the distance package, the code and decoder standards, and the message that `check_import_layering` prints cite the new names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/source/contributing/standards/code.rst | 4 ++-- docs/source/contributing/standards/decoders.rst | 2 +- src/qldpc/circuits/__init__.py | 2 +- src/qldpc/circuits/{layering_test.py => _layering_test.py} | 0 src/qldpc/codes/distance/README.md | 2 +- .../codes/distance/{layering_test.py => _layering_test.py} | 0 src/qldpc/conftest.py | 2 +- src/qldpc/decoders/__init__.py | 2 +- src/qldpc/decoders/{layering_test.py => _layering_test.py} | 0 9 files changed, 7 insertions(+), 7 deletions(-) rename src/qldpc/circuits/{layering_test.py => _layering_test.py} (100%) rename src/qldpc/codes/distance/{layering_test.py => _layering_test.py} (100%) rename src/qldpc/decoders/{layering_test.py => _layering_test.py} (100%) diff --git a/docs/source/contributing/standards/code.rst b/docs/source/contributing/standards/code.rst index 4378c8aec..9419a3911 100644 --- a/docs/source/contributing/standards/code.rst +++ b/docs/source/contributing/standards/code.rst @@ -38,10 +38,10 @@ Code style and organization * Numba code uses ``always_compiled`` or ``compiled_on_request`` from ``qldpc/_compilation.py``, never ``njit(cache=True)``; that module's docstring says which to use, and ``_compilation_test.py`` enforces its rules. * ``src/qldpc/_code_style_test.py`` checks license headers, section headers, and the order of plain and underscore names. * The top-level modules and subpackages of ``qldpc.circuits`` and ``qldpc.decoders`` import each other in layers: each imports only from itself and from lower layers, and never from the root of its package. - ``_LAYERS`` in the package's ``layering_test.py`` lists the layers and is the only place to update when adding a top-level module or subpackage; changes inside a subpackage need no edits. + ``_LAYERS`` in the package's ``_layering_test.py`` lists the layers and is the only place to update when adding a top-level module or subpackage; changes inside a subpackage need no edits. Imports in functions and under ``TYPE_CHECKING`` count; tests and ``conftest.py`` files may import from any layer. To keep an import that breaks the layers, end its line (for an import that spans several lines, its first line) with ``# layering-exception: ``; the check fails if such a comment is no longer needed. - To layer another package, add a ``layering_test.py`` to its directory that calls ``check_import_layering`` from ``src/qldpc/conftest.py``. + To layer another package, add a ``_layering_test.py`` to its directory that calls ``check_import_layering`` from ``src/qldpc/conftest.py``. * Do not weaken a failure with ``noqa``, ``type: ignore``, or coverage exclusions unless the exceptional condition is real and documented. .. _simplicity-and-clarity: diff --git a/docs/source/contributing/standards/decoders.rst b/docs/source/contributing/standards/decoders.rst index 8ff1a0634..c07180fbb 100644 --- a/docs/source/contributing/standards/decoders.rst +++ b/docs/source/contributing/standards/decoders.rst @@ -64,7 +64,7 @@ These requirements apply when changing how qLDPC builds, coerces, or resolves de * Coerce user-supplied error decoders with ``_as_error_decoder`` from ``decoders/protocols.py`` inside ``qldpc.decoders``, or ``build_error_decoder`` from ``decoders/_resolution.py`` outside it, and call ``decode_errors``. * Reject prebuilt decoders with ``reject_prebuilt_decoder`` from ``decoders/_resolution.py`` when a method constructs the matrix that it decodes. -* Keep the import layers of ``qldpc.decoders`` that ``_LAYERS`` in ``decoders/layering_test.py`` lists, and add a new top-level module or subpackage to a layer there; the :doc:`code standard ` describes the rule and its exceptions. +* Keep the import layers of ``qldpc.decoders`` that ``_LAYERS`` in ``decoders/_layering_test.py`` lists, and add a new top-level module or subpackage to a layer there; the :doc:`code standard ` describes the rule and its exceptions. * Keep ``build_error_decoder`` specific to error decoders. Dispatch on explicit capabilities such as ``compile_decoder_for_dem``, ``decode_observables``, and ``ErrorDecoder``, never on output length. * In code-capacity callers under ``qldpc.codes``, resolve decoder inputs with ``get_code_capacity_decoder`` from ``codes/_code_capacity.py``. diff --git a/src/qldpc/circuits/__init__.py b/src/qldpc/circuits/__init__.py index 3f8828145..32131380e 100644 --- a/src/qldpc/circuits/__init__.py +++ b/src/qldpc/circuits/__init__.py @@ -4,7 +4,7 @@ The top-level modules and subpackages of this package import each other in layers, from shared foundations to complete workflows: each imports only from itself and from lower layers, which rules -out import cycles between them. ``layering_test.py`` lists the layers and checks these imports, +out import cycles between them. ``_layering_test.py`` lists the layers and checks these imports, apart from any that are marked as exceptions. """ diff --git a/src/qldpc/circuits/layering_test.py b/src/qldpc/circuits/_layering_test.py similarity index 100% rename from src/qldpc/circuits/layering_test.py rename to src/qldpc/circuits/_layering_test.py diff --git a/src/qldpc/codes/distance/README.md b/src/qldpc/codes/distance/README.md index 4d846257b..6509515a5 100644 --- a/src/qldpc/codes/distance/README.md +++ b/src/qldpc/codes/distance/README.md @@ -87,7 +87,7 @@ From the top: 6. `_binary.py`. A top-level module or subpackage imports only from lower levels, and a module also from its own subpackage. -`layering_test.py` checks this. +`_layering_test.py` checks this. Within a subpackage, the order is a convention. In `_information_sets/`, `basis_families` builds on `certificates` and `matroid_partition`, `automorphism_families` on `basis_families` and `certificates`, and `completion_bounds` on `certificates`. In `_brouwer_zimmermann/`, `distance` and `coset` build on `affine_instance` and `affine_search`, `coset` also on `coset_routes` and `coset_tables`, and those two on `affine_instance`. diff --git a/src/qldpc/codes/distance/layering_test.py b/src/qldpc/codes/distance/_layering_test.py similarity index 100% rename from src/qldpc/codes/distance/layering_test.py rename to src/qldpc/codes/distance/_layering_test.py diff --git a/src/qldpc/conftest.py b/src/qldpc/conftest.py index 282f4dd2a..71fa4442a 100644 --- a/src/qldpc/conftest.py +++ b/src/qldpc/conftest.py @@ -431,7 +431,7 @@ def check_import_layering(package: str, layers: Sequence[Sequence[str]]) -> None """ graph = _get_import_graph() sources = _get_source_files(package) - config = f"_LAYERS in {_get_display_path(sources[package].with_name('layering_test.py'))}" + config = f"_LAYERS in {_get_display_path(sources[package].with_name('_layering_test.py'))}" listed = [unit for layer in layers for unit in layer] layer_index = {unit: index for index, layer in enumerate(layers) for unit in layer} units = {_get_unit(package, module) for module in graph.find_children(package)} diff --git a/src/qldpc/decoders/__init__.py b/src/qldpc/decoders/__init__.py index ce0685047..3c90f488d 100644 --- a/src/qldpc/decoders/__init__.py +++ b/src/qldpc/decoders/__init__.py @@ -4,7 +4,7 @@ The top-level modules and subpackages of this package import each other in layers, from shared foundations to complete workflows: each imports only from itself and from lower layers, which rules -out import cycles between them. ``layering_test.py`` lists the layers and checks these imports, +out import cycles between them. ``_layering_test.py`` lists the layers and checks these imports, apart from any that are marked as exceptions. """ diff --git a/src/qldpc/decoders/layering_test.py b/src/qldpc/decoders/_layering_test.py similarity index 100% rename from src/qldpc/decoders/layering_test.py rename to src/qldpc/decoders/_layering_test.py