Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions src/codex32/indel.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,23 @@ 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
width = shape.unit
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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions tests/test_competitors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 27 additions & 1 deletion tests/test_correction_indel.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading