Skip to content

Commit 70cfae4

Browse files
committed
correct: schedule required mixed-case work first
Run the fixed and required correction layers for both mixed-case interpretations before either interpretation can spend the shared deadline on optional alignment work. Seed the later full searches with those candidates so ranking and capture accounting still consider both interpretations. Refs #37
1 parent c703b2a commit 70cfae4

7 files changed

Lines changed: 117 additions & 11 deletions

File tree

‎src/codex32/_cli_input.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import difflib
77
import os
88
import sys
9-
from collections.abc import Callable, Iterator
9+
from collections.abc import Callable, Iterator, Sequence
1010
from time import monotonic
1111
from typing import Any, Literal, cast
1212

@@ -452,6 +452,8 @@ def _correction_candidates(
452452
deadline: float | None = None,
453453
capture_layers: list[tuple[int, int]] | None = None,
454454
fingerprint_match: Callable[[CorrectionCandidate], bool | None] | None = None,
455+
seed_candidates: Sequence[CorrectionCandidate] = (),
456+
required_only: bool = False,
455457
) -> tuple[tuple[CorrectionCandidate, ...], bool, float | None, bool]:
456458
count = len(value.replace(" ", ""))
457459
targets, primary, reduced, _timed = _correction_plan(profile, byte_length, count, target)
@@ -468,6 +470,8 @@ def _correction_candidates(
468470
competitors=True,
469471
allowed=allowed,
470472
capture_layers=capture_layers,
473+
seed_candidates=seed_candidates,
474+
required_only=required_only,
471475
)
472476
if allowed is not None:
473477
candidates = tuple(candidate for candidate in candidates if allowed(candidate))

‎src/codex32/_competitors.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,10 @@ def _search_competitors(
173173
frontier: dict[_Layer, int],
174174
deadline: float,
175175
allowed: Callable[[CorrectionCandidate], bool] | None,
176+
*,
177+
seed_candidates: Sequence[CorrectionCandidate] = (),
176178
) -> tuple[tuple[CorrectionCandidate, ...], bool]:
177-
results: dict[str, CorrectionCandidate] = {}
179+
results = {candidate.artifact.text.lower(): candidate for candidate in seed_candidates}
178180
fixed: dict[int, CorrectionCandidate | None] = {}
179181
completed: set[_Layer] = set()
180182
try:

‎src/codex32/cli.py‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -577,12 +577,33 @@ def _correct(
577577
else:
578578
first_search = erased if erased != search_value else search_value
579579
retry_search = search_value if erased != search_value else None
580+
seeded: tuple[CorrectionCandidate, ...] = ()
581+
deadline = None
582+
if retry_search is not None:
583+
# Discover required candidates for both case interpretations before
584+
# either full search can spend the shared deadline on optional
585+
# alignment work. Full searches below own capture accounting.
586+
for required_value in (first_search, retry_search):
587+
required, required_complete, deadline, _ = _correction_candidates(
588+
required_value,
589+
hrp,
590+
byte_length,
591+
immutable,
592+
deadline=deadline,
593+
seed_candidates=seeded,
594+
required_only=True,
595+
)
596+
if not required_complete:
597+
raise _CommandError("The correction search did not complete within ten seconds.")
598+
seeded = required
580599
candidates, complete, deadline, ambiguous = _correction_candidates(
581600
first_search,
582601
hrp,
583602
byte_length,
584603
immutable,
604+
deadline=deadline,
585605
capture_layers=capture_layers,
606+
seed_candidates=seeded,
586607
)
587608
if complete and retry_search is not None:
588609
retry_candidates, complete, deadline, retry_ambiguous = _correction_candidates(
@@ -592,16 +613,15 @@ def _correct(
592613
immutable,
593614
deadline=deadline,
594615
capture_layers=capture_layers,
616+
seed_candidates=(*seeded, *candidates),
595617
)
596618
ambiguous = ambiguous or retry_ambiguous
597619
combined = (*candidates, *retry_candidates)
598620
if combined:
599621
annotated = []
600622
for item in combined:
601623
volume, bits = _capture_mass(capture_layers, item.capture_volume)
602-
annotated.append(
603-
replace(item, cumulative_capture_volume=volume, capture_space_bits=bits)
604-
)
624+
annotated.append(replace(item, cumulative_capture_volume=volume, capture_space_bits=bits))
605625
ranked = _best(annotated, prefer_common=byte_length == "?")
606626
unique: dict[str, CorrectionCandidate] = {}
607627
for item in ranked:

‎src/codex32/correction.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,6 +910,39 @@ def _correct_complete(
910910
capture_layers: list[tuple[int, int]] = []
911911
candidates: tuple[CorrectionCandidate, ...] = ()
912912
complete = True
913+
if interpretation is not None:
914+
# Establish both interpretations' fixed/required candidates before
915+
# either interpretation can spend the shared deadline on optional
916+
# alignment work. These discovery passes use a private accounting
917+
# ledger; the full searches below account every admitted layer once.
918+
for input_context, value in inputs:
919+
preflight_contexts: tuple[CorrectionContext, ...]
920+
if input_context.expected_length is not None:
921+
preflight_contexts = (input_context,)
922+
else:
923+
observed = len(value.replace(" ", ""))
924+
contexts_list = []
925+
for target in sorted({observed + delta for delta in (*range(-4, 5), -8, 8)}):
926+
candidate_context = replace(input_context, expected_length=target)
927+
try:
928+
_validate_context(candidate_context)
929+
except InvalidCorrectionInput:
930+
continue
931+
contexts_list.append(candidate_context)
932+
preflight_contexts = tuple(contexts_list)
933+
candidates, current_complete = _search_many(
934+
preflight_contexts,
935+
value,
936+
primary=frozenset(
937+
c.expected_length for c in preflight_contexts if c.expected_length is not None
938+
),
939+
deadline=deadline,
940+
observed_text=damaged_text,
941+
seed_candidates=candidates,
942+
required_only=True,
943+
)
944+
if not current_complete:
945+
return (), False
913946
for input_context, value in inputs:
914947
contexts: tuple[CorrectionContext, ...]
915948
if input_context.expected_length is not None:

‎src/codex32/indel.py‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,7 @@ def _search_many(
600600
capture_layers: list[tuple[int, int]] | None = None,
601601
observed_text: str | None = None,
602602
seed_candidates: Sequence[CorrectionCandidate] = (),
603+
required_only: bool = False,
603604
) -> tuple[tuple[CorrectionCandidate, ...], bool]:
604605
deadline = monotonic() + 10 if deadline is None else deadline
605606
states = tuple(
@@ -615,6 +616,18 @@ def _search_many(
615616
)
616617
is not None
617618
)
619+
if required_only:
620+
states = tuple(
621+
replace(
622+
state,
623+
counts={
624+
shape: values
625+
for shape, values in state.counts.items()
626+
if shape == _FIXED or shape.unit == 4 or shape.distance <= 2
627+
},
628+
)
629+
for state in states
630+
)
618631
frontier = _frontier(states, primary)
619632
layers_accounted = [] if capture_layers is None else capture_layers
620633
widths = {state.target: 5 * state.degree for state in states}
@@ -632,7 +645,18 @@ def finish(
632645
if competitors:
633646
from codex32._competitors import _search_competitors
634647

635-
return finish(*_search_competitors(states, frontier, deadline, allowed))
648+
result = (
649+
_search_competitors(
650+
states,
651+
frontier,
652+
deadline,
653+
allowed,
654+
seed_candidates=seed_candidates,
655+
)
656+
if seed_candidates
657+
else _search_competitors(states, frontier, deadline, allowed)
658+
)
659+
return finish(*result)
636660
results = {candidate.artifact.text.lower(): candidate for candidate in seed_candidates}
637661
# One global admission ledger, then fixed, required, and optional work.
638662
# The minimum supported public sphere is A<=2 / G<=2; deeper cutoffs

‎tests/test_cli.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1928,6 +1928,18 @@ def test_correct_reranks_mixed_case_erasure_and_normalized_interpretations() ->
19281928
assert "interactive confirmation required" not in result.stderr
19291929

19301930

1931+
def test_correct_required_work_is_not_starved_by_erasure_alignment() -> None:
1932+
source = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw"
1933+
damaged = "ms10TpstsxXxxxxxXxxxxxXxxxxXxxxxxXx4nzvcA9cmczlW"
1934+
1935+
result = _invoke(["correct"], damaged)
1936+
1937+
assert result.exit_code == 1
1938+
assert source in result.stderr
1939+
assert "did not complete within ten seconds" not in result.stderr
1940+
assert "interactive confirmation required" not in result.stderr
1941+
1942+
19311943
def test_correction_hides_internal_candidate_reparse_failures() -> None:
19321944
result = _invoke(["correct"], "ms12auxxxxxxxxxxxxxxxxxxxxxxxxxxxxxda3kr3s0s2swg")
19331945

‎tests/test_correction_bch.py‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -319,19 +319,30 @@ def test_mixed_case_candidate_still_searches_normalized_competitors(monkeypatch:
319319
)
320320
damaged = source[:position] + source[position].upper() + source[position + 1 :]
321321
candidate = CorrectionCandidate(codex32.parse_codex32(source), (), 1, 0, 0, None)
322-
searched: list[tuple[str, tuple[CorrectionCandidate, ...]]] = []
322+
searched: list[tuple[str, tuple[CorrectionCandidate, ...], bool]] = []
323323

324324
def search(_contexts, value, **kwargs): # type: ignore[no-untyped-def]
325325
seeded = kwargs["seed_candidates"]
326-
searched.append((value, seeded))
326+
searched.append((value, seeded, kwargs.get("required_only", False)))
327327
return ((candidate,), True) if len(searched) == 1 else (seeded, True)
328328

329329
monkeypatch.setattr("codex32.indel._search_many", search)
330330

331331
assert correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged) == (candidate,)
332-
assert len(searched) == 2
333-
assert "?" in searched[0][0] and "?" not in searched[1][0]
334-
assert searched[1][1] == (candidate,)
332+
assert len(searched) == 4
333+
assert ["?" in value for value, _seeded, _required in searched] == [True, False, True, False]
334+
assert [required for _value, _seeded, required in searched] == [True, True, False, False]
335+
assert all(seeded == (candidate,) for _value, seeded, _required in searched[1:])
336+
337+
338+
def test_mixed_case_required_work_is_not_starved_by_erasure_alignment() -> None:
339+
source = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw"
340+
damaged = "ms10TpstsxXxxxxxXxxxxxXxxxxXxxxxxXx4nzvcA9cmczlW"
341+
342+
result = correct(CorrectionContext(Profile.MS, expected_length=len(source)), damaged)
343+
344+
assert len(result) == 1
345+
assert result[0].artifact.text == source
335346

336347

337348
@pytest.mark.parametrize("uppercase", (False, True))

0 commit comments

Comments
 (0)