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