From 3096763ccbe1084e4ae5ad993f85cbce1318b6b3 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 22 Oct 2026 10:31:43 -0500 Subject: [PATCH 1/2] library: Share card transcription alignment Move the existing CLI alignment into a toolkit-free library module so the graphical interface can reuse the same grouping and tie-breaking rules. Return only entered characters; comparison ignores whitespace and ASCII case without treating Unicode lookalikes as printed card symbols. Exercise alignment with external strings, invalid characters, leading matches, and a property that alignment never completes the transcription. Existing CLI confirmation tests use the shared helper. refs #109 --- src/codex32/_cli_input.py | 63 ---------------------------------- src/codex32/_confirmation.py | 66 ++++++++++++++++++++++++++++++++++++ src/codex32/cli.py | 9 +++-- src/codex32/generation.py | 4 +-- tests/test_cli.py | 8 +++-- tests/test_confirmation.py | 30 ++++++++++++++++ 6 files changed, 107 insertions(+), 73 deletions(-) create mode 100644 src/codex32/_confirmation.py create mode 100644 tests/test_confirmation.py diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 6090352b..edbf92dc 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -237,69 +237,6 @@ def _confirm_correction( ).strip().lower() in ("y", "yes") -def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: - # Keep entered tokens whole unless they exactly span complete groups. - # Unspaced input retains edit/group minimization and edit-order ties. - positions = [i for i, char in enumerate(observed) if not char.isspace()] - compact = "".join(observed.split()) - expected = "".join(expected.split()).lower() - tokens = observed.split() - grouped = len(tokens) > 1 and _ascii_lower(compact) != expected - boundaries = {0} - splits: set[tuple[int, int]] = set() - position = 0 - for token in tokens: - for offset in range(0, len(expected), 4): - if expected[offset : offset + len(token)] == _ascii_lower(token) and ( - len(token) % 4 == 0 or offset + len(token) == len(expected) - ): - splits.update((position + i, offset + i) for i in range(4, len(token), 4)) - position += len(token) - boundaries.add(position) - scores = {0: (0, 0, b"", ())} # type: dict[int, tuple[int, int, bytes, tuple[int, ...]]] - for offset in range(0, len(expected), 4): - canonical = expected[offset : offset + 4] - following: dict[int, tuple[int, int, bytes, tuple[int, ...]]] = {} - for start, (edits, disturbed, trace, ends) in scores.items(): - row = [(j, b"\x03" * j) for j in range(len(canonical) + 1)] - for end in range(start, len(compact) + 1): - if end > start: - current = [(end - start, b"\x02" * (end - start))] - for j, char in enumerate(canonical, 1): - edit = _ascii_lower(compact[end - 1]) != char - current.append( - min( - (row[j - 1][0] + edit, row[j - 1][1] + bytes([edit])), - (row[j][0] + 1, row[j][1] + b"\x02"), - (current[j - 1][0] + 1, current[j - 1][1] + b"\x03"), - ) - ) - row = current - if grouped and end not in boundaries and (end, offset + len(canonical)) not in splits: - continue - score = ( - edits + row[-1][0], - disturbed + (not grouped and _ascii_lower(compact[start:end]) != canonical), - trace + row[-1][1], - (*ends, end), - ) - if end not in following or score < following[end]: - following[end] = score - scores = following - groups = [] - start = 0 - for end in scores[len(compact)][3]: - stop = positions[end] if end < len(positions) else len(observed) - groups.append(observed[start:stop]) - start = stop - changed = { - i - for i, value in enumerate(groups) - if _ascii_lower("".join(value.split())) != expected[i * 4 : i * 4 + 4] - } - return groups, changed - - def _render_groups( groups: list[str], changed: set[int], diff --git a/src/codex32/_confirmation.py b/src/codex32/_confirmation.py new file mode 100644 index 00000000..ae2f7e57 --- /dev/null +++ b/src/codex32/_confirmation.py @@ -0,0 +1,66 @@ +"""Shared alignment of independently transcribed recovery cards.""" + +_ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz") + + +def _compact(text: str) -> str: + """Ignore whitespace and ASCII case without interpreting other characters.""" + return "".join(text.split()).translate(_ASCII_LOWER) + + +def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: + """Align entered groups without supplying any expected characters.""" + positions = [i for i, char in enumerate(observed) if not char.isspace()] + compact = _compact(observed) + expected = _compact(expected) + tokens = observed.split() + grouped = len(tokens) > 1 and compact != expected + boundaries = {0} + splits: set[tuple[int, int]] = set() + position = 0 + for token in tokens: + for offset in range(0, len(expected), 4): + if expected[offset : offset + len(token)] == _compact(token) and ( + len(token) % 4 == 0 or offset + len(token) == len(expected) + ): + splits.update((position + i, offset + i) for i in range(4, len(token), 4)) + position += len(token) + boundaries.add(position) + scores = {0: (0, 0, b"", ())} # type: dict[int, tuple[int, int, bytes, tuple[int, ...]]] + for offset in range(0, len(expected), 4): + canonical = expected[offset : offset + 4] + following: dict[int, tuple[int, int, bytes, tuple[int, ...]]] = {} + for start, (edits, disturbed, trace, ends) in scores.items(): + row = [(j, b"\x03" * j) for j in range(len(canonical) + 1)] + for end in range(start, len(compact) + 1): + if end > start: + current = [(end - start, b"\x02" * (end - start))] + for j, char in enumerate(canonical, 1): + edit = compact[end - 1] != char + current.append( + min( + (row[j - 1][0] + edit, row[j - 1][1] + bytes([edit])), + (row[j][0] + 1, row[j][1] + b"\x02"), + (current[j - 1][0] + 1, current[j - 1][1] + b"\x03"), + ) + ) + row = current + if grouped and end not in boundaries and (end, offset + len(canonical)) not in splits: + continue + score = ( + edits + row[-1][0], + disturbed + (not grouped and compact[start:end] != canonical), + trace + row[-1][1], + (*ends, end), + ) + if end not in following or score < following[end]: + following[end] = score + scores = following + groups = [] + start = 0 + for end in scores[len(compact)][3]: + stop = positions[end] if end < len(positions) else len(observed) + groups.append(observed[start:stop]) + start = stop + changed = {i for i, value in enumerate(groups) if _compact(value) != expected[i * 4 : i * 4 + 4]} + return groups, changed diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 68ca4f12..a234e6ca 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -20,11 +20,9 @@ from codex32._cli_input import ( CorrectionDeclined, InteractiveConfirmationRequired, - _ascii_lower, _card_text, _case_interpretation, _confirm_correction, - _entered_groups, _fingerprint_matcher, _render_groups, _require_correction_confirmation, @@ -35,6 +33,7 @@ from codex32._cli_input import read_artifacts as _artifacts from codex32._cli_input import read_text as _text from codex32._cli_parser import parser as _parser +from codex32._confirmation import _compact, _entered_groups from codex32.bip93 import ( IDX_SORT, Header, @@ -313,10 +312,10 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul prefill=prefill, ) compact = "".join(replacement.split()) - if _ascii_lower(compact) == expected.lower(): + if _compact(compact) == expected.lower(): groups = [replacement] break - if len(compact) > len(expected[start * 4 : end * 4]) and _ascii_lower(compact).startswith( + if len(compact) > len(expected[start * 4 : end * 4]) and _compact(compact).startswith( (expected.split("1", 1)[0] + "1").lower() ): _print( @@ -330,7 +329,7 @@ def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResul changed.difference_update(range(start, end)) changed.update(start + i for i in remaining) entered = "".join(groups) - if _ascii_lower("".join(entered.split())) != expected.lower(): + if _compact(entered) != expected.lower(): raise RuntimeError("Confirmation mismatch.") if confirm is not None and not confirm(entered).accepted: raise RuntimeError("Confirmation rejected.") diff --git a/src/codex32/generation.py b/src/codex32/generation.py index 6da02033..815653d6 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -9,6 +9,7 @@ from typing import NoReturn, SupportsIndex, cast from codex32._bip32 import _valid_root +from codex32._confirmation import _compact from codex32.bech32 import CHARSET, _u5_to_chars, convertbits from codex32.bip93 import ( IDX_SORT, @@ -359,8 +360,7 @@ def confirm(self, text: str) -> ConfirmationResult: raise CeremonyStateError("request a card before confirming it") if not isinstance(text, str): raise TypeError("confirmation text must be str") - observed = "".join(text.split()) - observed = "".join(char.lower() if char.isascii() else char for char in observed) + observed = _compact(text) expected = self._pending.text.lower() mismatched = tuple( group + 1 diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ae0dc42..cd1b8c97 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1518,7 +1518,9 @@ def test_confirmation_alignment_shows_only_entered_text( expected = "abcdefghWXYZijklmnop" if observed == "abcdefghijklmnop" else "abcdefghijklmnop" if observed == "aaaaaaa": expected = "aaaaaaaa" - groups, changed = input_module._entered_groups(observed, expected) + from codex32._confirmation import _entered_groups + + groups, changed = _entered_groups(observed, expected) actual = input_module._render_groups(groups, changed, len(expected)) plain = re.sub(r"\x1b\[[0-9;]*m", "", actual) @@ -2251,7 +2253,7 @@ def test_production_size_budgets_are_enforced() -> None: def test_confirmation_alignment_assigns_complete_canonical_groups( observed: str, expected: str, groups: list[str], changed: set[int] ) -> None: - from codex32._cli_input import _entered_groups + from codex32._confirmation import _entered_groups assert _entered_groups(observed, expected) == (groups, changed) assert "".join(groups) == observed @@ -2408,7 +2410,7 @@ def test_confirmation_placeholders_only_replace_wholly_omitted_groups( def test_confirmation_grouped_alignment_preserves_token_ownership( observed: str, expected: str, groups: list[str], changed: set[int] ) -> None: - from codex32._cli_input import _entered_groups + from codex32._confirmation import _entered_groups assert _entered_groups(observed, expected) == (groups, changed) assert "".join(groups) == observed diff --git a/tests/test_confirmation.py b/tests/test_confirmation.py new file mode 100644 index 00000000..e4c1a0e8 --- /dev/null +++ b/tests/test_confirmation.py @@ -0,0 +1,30 @@ +"""Both interfaces align transcriptions without completing or interpreting them.""" + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from codex32._confirmation import _compact, _entered_groups + + +@pytest.mark.parametrize( + ("expected", "observed", "groups", "changed"), + [ + ("ABCDEFGH", "ABCDXEFGH", ["ABCD", "XEFGH"], {1}), + ("ABCDDEFG", "ABCDEFG", ["ABCD", "EFG"], {1}), + ("ABCDEFGHJKLMNPQR", "ABCDFGHJKLMNXPQR", ["ABCD", "FGH", "JKLM", "NXPQR"], {1, 3}), + ("ABCDEFGH", "ABCD B1!O", ["ABCD ", "B1!O"], {1}), + ("ABCDEFKG", "ABCDEFKG", ["ABCD", "EFKG"], {1}), + ], +) +def test_alignment_preserves_leading_matches_and_invalid_characters( + expected: str, observed: str, groups: list[str], changed: set[int] +) -> None: + assert _entered_groups(observed, expected) == (groups, changed) + + +@given(st.text(alphabet="ABCD!? 1K", max_size=20)) +def test_alignment_only_returns_characters_the_operator_entered(observed: str) -> None: + groups, changed = _entered_groups(observed, "ABCDABCD") + assert _compact("".join(groups)) == _compact(observed) + assert bool(changed) == (_compact(observed) != "abcdabcd") From 37058fa4da285eafdd13b4916fcb082bcb351015 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 28 Oct 2026 18:02:43 +0000 Subject: [PATCH 2/2] library: Skip alignment above twice the card length Aligning a read-back costs time quadratic in its length: about a minute for 1,024 characters against one card, which froze the GUI. Text more than twice the card's length cannot be a careful copy, so mark every group wrong without aligning it. The CLI and GUI both get the bound. Claude-Session: https://claude.ai/code/session_018HrKoywz6PT9c91jvKou7Q --- src/codex32/_confirmation.py | 4 ++++ tests/test_confirmation.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/codex32/_confirmation.py b/src/codex32/_confirmation.py index ae2f7e57..b44e97f8 100644 --- a/src/codex32/_confirmation.py +++ b/src/codex32/_confirmation.py @@ -10,6 +10,10 @@ def _compact(text: str) -> str: def _entered_groups(observed: str, expected: str) -> tuple[list[str], set[int]]: """Align entered groups without supplying any expected characters.""" + if len(_compact(observed)) > 2 * len(_compact(expected)): + # Alignment time grows with the square of the input; mark it all wrong. + chars = "".join(observed.split()) + return [chars[i : i + 4] for i in range(0, len(chars), 4)], set(range((len(chars) + 3) // 4)) positions = [i for i, char in enumerate(observed) if not char.isspace()] compact = _compact(observed) expected = _compact(expected) diff --git a/tests/test_confirmation.py b/tests/test_confirmation.py index e4c1a0e8..feee28c0 100644 --- a/tests/test_confirmation.py +++ b/tests/test_confirmation.py @@ -23,6 +23,10 @@ def test_alignment_preserves_leading_matches_and_invalid_characters( assert _entered_groups(observed, expected) == (groups, changed) +def test_input_over_twice_the_card_is_all_wrong_without_alignment() -> None: + assert _entered_groups("ABCDABCDA bc", "ABCD") == (["ABCD", "ABCD", "Abc"], {0, 1, 2}) + + @given(st.text(alphabet="ABCD!? 1K", max_size=20)) def test_alignment_only_returns_characters_the_operator_entered(observed: str) -> None: groups, changed = _entered_groups(observed, "ABCDABCD")