From 56bba337d3d17d438bdf2078cb76cffbda8e17e3 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 7 Oct 2026 10:11:19 +0000 Subject: [PATCH] correct: Poll the deadline inside structural traversals _search_target checked the deadline once per layer and once per 32 yielded alignments. indel._views skips duplicate edits inside a run of one symbol, so on repeated-symbol input some layers did millions of steps between yields: a 126-character all-q string spent 237 s in one ('I', 'I', 'I', 'AT') traversal without yielding, and correct() on "ms10tests" + "q" * 122 returned after 14.2 s instead of 10 s. Pass the deadline into _views and poll it before each edit or swap, including swaps of equal symbols that are then skipped, and every 1024 deletion choices, as _competitors._known_swaps already does; once it passes, every level returns without touching its remaining siblings. A search whose last layer stops at the deadline now reports itself incomplete. The same input now returns at 10.00 s, and a 1,023-symbol all-q distant swap layer stops at once instead of 0.5 s past the deadline. Fixes #136 Claude-Session: https://claude.ai/code/session_01T233rKgZqE5wzDm3EVTHL1 --- src/codex32/indel.py | 22 ++++++++++++++++++---- tests/test_competitors.py | 4 ++-- tests/test_correction_indel.py | 28 +++++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/codex32/indel.py b/src/codex32/indel.py index 26c0071..1b80a0e 100644 --- a/src/codex32/indel.py +++ b/src/codex32/indel.py @@ -159,7 +159,13 @@ def _alignment_counts( return counts -def _views(text: str, target: int, shape: _StructuralClass, immutable: int, base: int) -> Iterator[_View]: +def _views( + text: str, target: int, shape: _StructuralClass, immutable: int, base: int, deadline: float | None = None +) -> Iterator[_View]: + # Skipped repeated-symbol scripts can run for minutes between yields; poll here. + def expired() -> bool: + return deadline is not None and monotonic() >= deadline + source = tuple(CHARSET.find(char.lower()) for char in text[base:]) initial = _View(source, ((0, len(source)),), len(source)) boundary = (immutable if shape.unit == 1 else _group_boundary(immutable)) - base @@ -167,7 +173,9 @@ def _views(text: str, target: int, shape: _StructuralClass, immutable: int, base if not (shape.adjacent or shape.distant or shape.corrupted): observed_units = (len(source) - boundary) // width target_units = (target - base - boundary) // width - for deleted in combinations(range(observed_units), shape.inserted): + for number, deleted in enumerate(combinations(range(observed_units), shape.inserted)): + if number % 1024 == 0 and expired(): + return # Canonical deletion within a run of equal units; no retained-body allocation. if any( i @@ -197,6 +205,8 @@ def walk(view: _View, operations: tuple[str, ...]) -> Iterator[_View]: rest = operations[:index] + operations[index + 1 :] if operation in ("I", "O", "GS"): for unit in range(units + (operation == "O")): + if expired(): + return position = boundary + width * unit yield from walk( view.mask(position, width) @@ -212,6 +222,8 @@ def walk(view: _View, operations: tuple[str, ...]) -> Iterator[_View]: range(left + 1, min(left + 2, units)) if operation == "AT" else range(left + 2, units) ) for right in rights: + if expired(): + return p, q = boundary + width * left, boundary + width * right if all(view[p + i] == view[q + i] for i in range(width)): continue @@ -489,7 +501,9 @@ def _search_target( if incremental is None: incremental = _IncrementalSyndromes(solver.alignment, source) alignments = ( - _views(text, state.target, shape, state.immutable, state.base) if views is None else views + _views(text, state.target, shape, state.immutable, state.base, deadline) + if views is None + else views ) for number, view in enumerate(alignments): if number % 32 == 0 and deadline is not None and monotonic() >= deadline: @@ -544,7 +558,7 @@ def _search_target( candidate = replace(candidate, edits=candidate.edits + tuple(moved)) if candidate.capture_volume == frontier[key]: _keep(results, candidate) - return True + return deadline is None or monotonic() < deadline def _search_many( diff --git a/tests/test_competitors.py b/tests/test_competitors.py index 033cce0..97f6521 100644 --- a/tests/test_competitors.py +++ b/tests/test_competitors.py @@ -40,10 +40,10 @@ def test_reported_substitutions_exhaust_competitors_without_redundant_enumeratio original = indel._views searched = [] - def views(text, target, shape, immutable, base): + def views(text, target, shape, immutable, base, deadline): searched.append((target, shape.operations, shape.unit)) assert not (shape.unit == 1 and (shape.adjacent or shape.distant)) - yield from original(text, target, shape, immutable, base) + yield from original(text, target, shape, immutable, base, deadline) with patch.object(indel, "_views", side_effect=views): candidates, complete = _run(REPORTED) diff --git a/tests/test_correction_indel.py b/tests/test_correction_indel.py index e9c88e4..8f8c6cf 100644 --- a/tests/test_correction_indel.py +++ b/tests/test_correction_indel.py @@ -1,7 +1,7 @@ """Structural-family, immutable-prefix, ranking, and completion evidence.""" from dataclasses import replace -from itertools import combinations +from itertools import combinations, count from math import comb from unittest.mock import patch @@ -26,6 +26,8 @@ _required_header_substitutions, _search_many, _search_target, + _StructuralClass, + _views, ) from codex32.profiles.ms32 import TEXT_LENGTHS from tools._wallet_test_vectors import FIXTURE_SEED, core_fingerprint @@ -557,6 +559,30 @@ def test_structural_input_and_deadline_are_bounded() -> None: assert candidates == () and not complete +@pytest.mark.parametrize( + "shape", [_StructuralClass(3, 0, adjacent=1), _StructuralClass(4, 0), _StructuralClass(0, 0, distant=1)] +) +def test_structural_views_poll_the_deadline_between_yields(shape: _StructuralClass) -> None: + # Edits within one repeated-symbol run are skipped as duplicates, so these + # traversals can run long without yielding; one clock tick per poll. + text = "ms10tests" + "q" * 117 + clock = count() + with patch("codex32.indel.monotonic", side_effect=lambda: next(clock)): + assert len(tuple(_views(text, len(text) - shape.delta, shape, 9, 3, deadline=100))) <= 1 + assert 100 < next(clock) < 200 + + +def test_expired_final_traversal_reports_an_incomplete_search() -> None: + text = "ms10tests" + "q" * 122 + state = _prepare(CorrectionContext(Profile.MS, 127), text, _CLASSES, None) + assert state is not None + shape = _StructuralClass(4, 0) + key = next(key for key in _frontier((state,), frozenset((127,))) if key[1] == shape) + clock = count() + with patch("codex32.indel.monotonic", side_effect=lambda: next(clock)): + assert not _search_target(replace(state, counts={shape: state.counts[shape]}), {key: 1}, {}, 5) + + def test_full_checksum_burst_is_admitted_at_the_shared_mass_ceiling(): from codex32.correction import _correct_fixed