Skip to content

Fix exact-distance bugs, report a missing distance with one sentinel, and merge duplicated coset kernels - #841

Merged
perlinm merged 11 commits into
mainfrom
refactor/distance-fixes
Nov 12, 2026
Merged

perlinm merged 11 commits into
mainfrom
refactor/distance-fixes

Conversation

@perlinm

@perlinm perlinm commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator

Motivation

An architecture, correctness, and code-health review of qldpc.codes.distance turned up three bugs, three inconsistent conventions for "no distance", several false statements in docstrings and the Brouwer–Zimmermann guide, and about 800 lines of duplicated compiled coset code. This PR fixes those within the current module layout, and leaves the layout itself unchanged.

Changes

  • Bug fixes.
    • Brute-force distance searches compared their starting value with cutoff before weighing any logical operator, so a large cutoff could return an unobserved weight. For example, QuditCode(SurfaceCode(5).matrix).get_distance_exact(method="brute_force", cutoff=50) gave 50.
    • QDistRnd marks a bound that stops at its mindist (our cutoff) with a negative sign, which qLDPC returned and cached as a distance bound.
    • CSSCode.get_distance_bound_with_decoder looped forever on codes without logical qubits.
  • One "no distance" sentinel. Code objects returned nan, the raw distance functions returned n + 1, and counts reported the weight n. All of them now return NO_DISTANCE, the only member of a new public float enum NoDistance. It behaves exactly like nan at run time, including how it prints, and results are annotated int | Literal[NoDistance.NAN], so type checkers narrow them to int after distance is NO_DISTANCE is ruled out. Inside the searches, n + 1 stays as the value that acts like +∞. CSSLogicalCounts gets an explicit __eq__ and __hash__, because Python 3.13 compares dataclass fields one by one, and NO_DISTANCE would otherwise make equal counts unequal. This changes return values without a deprecation period, at the maintainer's request.
  • Contracts and guides.
    • get_distance_brouwer_zimmermann_many documents its contract and rejects calls with neither a problem nor an upper bound.
    • as_binary_matrix names its rows in its shape errors.
    • brouwer_zimmermann.md no longer describes a removed enumeration shortcut, or says that counts use the distance scan. Its worked example counts the step at which qLDPC actually stops.
    • Several docstrings no longer overstate exactness or the coverage of certificates.
  • Behavior-preserving simplifications.
    • Five pairs of compiled coset searches ("whole words" and "by tails") merge into one body each, specialized by numba on tails.
    • Two coset tail scans with completion tables merge.
    • find_stopping_step replaces calls of plan_steps with placeholder arrays.
    • OverlappingFamilyPolicy replaces an unnamed 5-tuple.
    • A dead guard and a qldpc.math import that shadowed math are removed.

Validation

  • Kernel merges. Compared with main, they give identical representatives, lower bounds, batch indices, and minimum weights on 568,854 coset answers. The comparison covered both methods, both weightings, and forced routes by tails and by tables. Independent exhaustive enumeration agreed as well.
  • Timing. Merging compiled kernels could have slowed them, so the merged kernels were timed against main: about +1% with default tuning, at most +4.7% on cheap queries forced onto routes by tails, and about 6% less first-call compile time.
  • Bug fixes. A standalone differential harness, outside the repository, checked distances, counts, deformed counts, and coset queries against GF(2) enumeration under 14 route-forcing tunings. The brute-force cutoff bug was the only wrong result it found, and this PR fixes it.

Notes

  • Merging scan_coset_layer with scan_coset_layer_by_css_weight was deliberately left out. They are already the two constant-flag entry points of one body, and merging them would need a runtime flag or a fragile None mask.
  • get_distance_classical and get_distance_quantum stay public for now, returning NO_DISTANCE. A later review of the package's public API may change that.

perlinm and others added 11 commits November 12, 2026 09:40
When the vectorized block of a brute-force distance search held no logical
operator, the search compared its starting value, the number of columns (or
2n for symplectic rows), with the cutoff, and could return it without weighing
any operator.  With a large cutoff, QuditCode(SurfaceCode(5).matrix) gave 50.
The search now starts at one more than any weight, and compares only weights
it has computed with the cutoff.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
QDistRnd returns a bound at or below its argument mindist, which qLDPC sets to
the cutoff, with a negative sign to mark an early stop.  qLDPC returned and
cached that negative value as a distance bound.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A code or circuit with no nontrivial logical operator has no distance.  Code
objects reported np.nan, the public raw distance functions n + 1 (capped at
max_weight + 1), and logical counts the real-looking weight n.  All of them now
report NO_DISTANCE, the only member of the new public enum NoDistance.  It is a
float nan, so it prints, compares, and propagates as nan did, and results that
can be NO_DISTANCE are annotated int | Literal[NoDistance.NAN], which type
checkers narrow to int once the sentinel is ruled out.  Inside the searches, a
problem without nontrivial words still counts as n + 1, so that it never lowers
the minimum over problems.

CSSLogicalCounts compares its fields as a tuple, so that two counts with weight
NO_DISTANCE are equal; dataclasses compare fields one by one from Python 3.13.

CSSCode.get_distance_bound_with_decoder now returns NO_DISTANCE for a code
without logical qudits, instead of drawing random nonzero logical coefficients
from an empty set forever.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- get_distance_brouwer_zimmermann_many documents its arguments and result,
  and needs a problem or an upper bound, instead of failing in min([]).  A
  comment in get_distance_connected_support claimed that both functions treat
  problems without witnesses alike.
- as_binary_matrix names the rows in its errors, which named the generators
  even for logical operators and stabilizers, and get_nested_code_basis says
  how callers must treat a trivial quotient.
- get_distance_classical states the early exit at cutoff in its result, and
  get_distance_brouwer_zimmermann no longer calls its result exact.
- brouwer_zimmermann.md no longer describes the removed shortcut to exhaustive
  enumeration, or counts that scan with affine_search; its worked example counts
  the step at which qLDPC stops within layer four (135,710 evaluations, not
  338,250); it cites an existing test and the missing exit of
  get_overlapping_family.
- The README and _tuning say that a certificate bounds the words neither
  visited nor skipped, and which word an early exit returns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`scan_coset_layer_by_tails_with_tables_below_best` differed from
`scan_coset_layer_by_tails_with_tables` only in skipping completions whose bound reaches the best
weight so far rather than a skip bound.  The merged scan skips at the lesser of the two.  A coset
query passes a skip bound of at most its cutoff plus one, and its scans return once the best weight
is at most the cutoff, so it still skips at the skip bound.  A search for minimum weights passes the
skip bound of its maximum weight, which its incumbent never exceeds, so it still skips at the best
weight.  The one-set scan of the distance search passes its best weight as its skip bound, so the
`threshold` flag of `_scan` goes away.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Five pairs of compiled functions of `coset_leader_search.py` repeated each other's control flow,
including the stopping step, for searches of whole words and searches by tails.  Each pair is now
one function that takes the fields of the record of tails of its route, or None, and numba compiles
it separately for each, leaving out the branches that the type of the argument rules out.  The
searches keep their order of words, their bounds, and their representatives.  `_keep_lighter` goes,
since the merged search weighs the offsets of both kinds of route with `_get_weight`.

Python passes the fields as a plain tuple: numba types a named tuple argument at each call about
half a microsecond more slowly, which cheap queries on routes by tails would notice.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`CosetLeaderSearch` built its checks with `qldpc.math.symplectic_conjugate` and a parity, through
an import that shadowed the standard `math` module, while the same file swaps the X and Z halves of
checks elsewhere with `np.roll`.  For binary stabilizers both give the same uint8 matrix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The coset searches, the coset routes, and `Certificate.plan` called `plan_steps` with empty
placeholder arrays only to get its stopping step.  The binary search that finds that step is now
`information_sets.find_stopping_step`, which they call directly, and which `plan_steps` calls too.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The stage returns early unless the family has fewer full sets than `max_sets`, so the number of
open sets is always at least one, and `max(max_sets - num_full, 1)` never raised it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`get_overlapping_family_policy` returned an unnamed 5-tuple that every caller splatted into
`get_overlapping_family`, and `_select_basis_family` of the counts declared its five parameters only
to forward them.  The policy is now an `OverlappingFamilyPolicy` named tuple, which both take whole;
numba compiles it, generator included, when they are compiled on request.  The tuning test records
the tuning that reaches `get_overlapping_family_policy`, which reads the attempts per set.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@perlinm
perlinm merged commit bf45a47 into main Nov 12, 2026
9 checks passed
@perlinm
perlinm deleted the refactor/distance-fixes branch November 12, 2026 14:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant