Skip to content
Draft
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
63 changes: 0 additions & 63 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
70 changes: 70 additions & 0 deletions src/codex32/_confirmation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""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."""
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)
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
9 changes: 4 additions & 5 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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.")
Expand Down
4 changes: 2 additions & 2 deletions src/codex32/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions tests/test_confirmation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""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)


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")
assert _compact("".join(groups)) == _compact(observed)
assert bool(changed) == (_compact(observed) != "abcdabcd")
Loading