Skip to content
Merged
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
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ dev = [
"twine>=5,<7",
]


[tool.setuptools.packages.find]
where = ["src"]

Expand Down
18 changes: 9 additions & 9 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ def _correction_candidates(
fingerprint_match: Callable[[CorrectionCandidate], bool | None] | None = None,
seed_candidates: Sequence[CorrectionCandidate] = (),
required_only: bool = False,
) -> tuple[tuple[CorrectionCandidate, ...], bool, float, bool]:
) -> tuple[tuple[CorrectionCandidate, ...], bool, float]:
count = len(value.replace(" ", ""))
targets, primary, reduced, _timed = _correction_plan(profile, byte_length, count, target)
deadline = monotonic() + 10 if deadline is None else deadline
Expand Down Expand Up @@ -484,7 +484,7 @@ def _correction_candidates(
if len(candidates) == 1 and not candidates[0].search_complete
else ()
)
return results, complete, deadline, False
return results, complete, deadline


def _scheduled_candidates(
Expand All @@ -499,7 +499,7 @@ def _scheduled_candidates(
allowed: Callable[[CorrectionCandidate], bool] | None = None,
deadline: float | None = None,
fingerprint_match: Callable[[CorrectionCandidate], bool | None] | None = None,
) -> tuple[tuple[CorrectionCandidate, ...], bool, bool]:
) -> tuple[tuple[CorrectionCandidate, ...], bool]:
"""Search both case interpretations under one deadline and capture ledger."""
search = partial(
_correction_candidates,
Expand All @@ -519,18 +519,18 @@ def _scheduled_candidates(
# These discovery passes deliberately do not charge capture_layers;
# the full searches below account each admitted frontier once.
for required_value in (first, retry):
seeded, complete, deadline, _ = search(
seeded, complete, deadline = search(
required_value, deadline=deadline, seed_candidates=seeded, required_only=True
)
if not complete:
return (), False, False
return (), False
capture_layers: list[tuple[int, int]] = []
candidates, complete, deadline, ambiguous = search(
candidates, complete, deadline = search(
first, deadline=deadline, capture_layers=capture_layers, seed_candidates=seeded
)
if retry is None:
return candidates, complete, ambiguous
retry_candidates, complete, _deadline, retry_ambiguous = search(
return candidates, complete
retry_candidates, complete, _deadline = search(
retry, deadline=deadline, capture_layers=capture_layers, seed_candidates=(*seeded, *candidates)
)
annotated = []
Expand All @@ -540,7 +540,7 @@ def _scheduled_candidates(
unique: dict[str, CorrectionCandidate] = {}
for item in _best(annotated, prefer_common=byte_length == "?", fingerprint_match=fingerprint_match):
unique.setdefault(item.artifact.text.lower(), item)
return tuple(unique.values()), complete, ambiguous or retry_ambiguous
return tuple(unique.values()), complete


def _fingerprint_matcher(
Expand Down
10 changes: 2 additions & 8 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,8 +426,6 @@ def _create(
raise _UsageError("--bytes applies only to a new random seed.")
if not (sys.stdin.isatty() and sys.stdout.isatty()):
raise _UsageError("Bitcoin backup creation requires an interactive terminal.")
if threshold and not sys.stdin.isatty():
raise _UsageError("Shared creation requires an interactive terminal.")
if threshold and shares is None and indices is None:
if threshold in (2, 3):
shares = {2: 3, 3: 5}[threshold]
Expand Down Expand Up @@ -572,15 +570,11 @@ def _correct(
candidate = None
if candidate is not None:
candidates: tuple[CorrectionCandidate, ...] = (candidate,)
complete, ambiguous = True, False
complete = True
else:
candidates, complete, ambiguous = _scheduled_candidates(
search_value, erased, hrp, byte_length, immutable
)
candidates, complete = _scheduled_candidates(search_value, erased, hrp, byte_length, immutable)
if not complete and not candidates:
raise _CommandError("The correction search did not complete within ten seconds.")
if ambiguous:
raise _CommandError("More than one correction is possible; none was selected.")
if not candidates:
raise _CommandError("No valid correction found. Check the original backup.")
if context.master_seed and len(candidates) > 1:
Expand Down
40 changes: 0 additions & 40 deletions src/codex32/indel.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,6 @@ def _classes(unit: int, depth: int) -> tuple[_StructuralClass, ...]:
_CHARACTER_CLASSES = _classes(1, 4)
_GROUP_CLASSES = _classes(4, 2)
_CLASSES = (_FIXED, *_CHARACTER_CLASSES, *_GROUP_CLASSES)
# Compatibility for older offline tools; public search uses the full class set.
_REDUCED_CLASSES = tuple(shape for shape in _CLASSES if shape.unit == 4 or shape.distance <= 3)


def _group_boundary(immutable_length: int) -> int:
Expand Down Expand Up @@ -161,44 +159,6 @@ def _alignment_counts(
return counts


def _alignment_count(
shape: _StructuralClass,
observed_length: int,
target_length: int,
immutable_length: int,
) -> int:
return sum(_alignment_counts(shape, "q" * observed_length, target_length, immutable_length).values())


def _reductions(
values: tuple[int, ...],
characters: str,
count: int,
offset: int,
) -> Iterator[tuple[tuple[int, ...], tuple[tuple[int, str], ...]]]:
for deleted in combinations(range(len(values)), count):
removed = frozenset(deleted)
kept = tuple(index for index in range(len(values)) if index not in removed)
retained = tuple(values[index] for index in kept)
position = 0
for kept_index in kept:
while values[position] != values[kept_index]:
position += 1
if position != kept_index:
break
position += 1
else:
edits = tuple((offset + index, characters[index]) for index in deleted)
yield retained, edits


def _variants(
text: str, target: int, shape: _StructuralClass, immutable: int, prefix_length: int = 3
) -> Iterator[_Variant]:
for view in _views(text, target, shape, immutable, prefix_length):
yield _view_variant(view, text, prefix_length)


def _views(text: str, target: int, shape: _StructuralClass, immutable: int, base: int) -> Iterator[_View]:
source = tuple(CHARSET.find(char.lower()) for char in text[base:])
initial = _View(source, ((0, len(source)),), len(source))
Expand Down
16 changes: 7 additions & 9 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ class _FakeBitcoinCore:
chain: str = "main"
version: int = 320000
imported: MasterSeed | None = None
private: bool | None = None
account: int | None = None
timestamp: int | str | None = None

Expand All @@ -112,7 +111,7 @@ def initialize(
timestamp: int | str = "now",
) -> str:
self.imported = secret
self.private, self.account, self.timestamp = private, account, timestamp
self.account, self.timestamp = account, timestamp
return "test-wallet"


Expand Down Expand Up @@ -1785,7 +1784,7 @@ def test_correction_bytes_rejects_an_unsupported_ms_size() -> None:
def test_cli_never_accepts_an_incomplete_structural_search() -> None:
original = VECTOR_1["secret_s"]
damaged = original[:19] + original[20:]
with patch("codex32._cli_input._correction_candidates", return_value=((), False, 0.0, False)):
with patch("codex32._cli_input._correction_candidates", return_value=((), False, 0.0)):
result = _invoke(["correct"], damaged)

assert result.exit_code == 3
Expand Down Expand Up @@ -1924,7 +1923,7 @@ def test_correct_searches_mixed_case_erasures_before_normalized_alignment(monkey

def stop_after_first(value, *_args, **_kwargs):
searched.append(value)
return (), False, None, False
return (), False, None

monkeypatch.setattr("codex32._cli_input._correction_candidates", stop_after_first)

Expand Down Expand Up @@ -1983,12 +1982,12 @@ def test_correct_accounts_retry_frontier_after_incomplete_first_search(monkeypat

def incomplete_first(value, *_args, **kwargs):
if kwargs.get("required_only"):
return (), True, 0.0, False
return (), True, 0.0
full_searches.append(value)
kwargs["capture_layers"].append((1, 5))
if len(full_searches) == 1:
return (candidate,), False, 0.0, False
return (), False, 0.0, False
return (candidate,), False, 0.0
return (), False, 0.0

monkeypatch.setattr("codex32._cli_input._correction_candidates", incomplete_first)

Expand Down Expand Up @@ -2022,7 +2021,6 @@ def test_wallet_commands_initialize_selected_master_seed_destinations() -> None:
assert xprv.stderr.endswith("Keep it secret.\n\n")
assert private.stdout == ""
assert private_core.imported == parse_codex32(VECTOR_1["secret_s"])
assert private_core.private is True
assert "Warning: This gives Bitcoin Core the master private key, which can spend funds." in private.stderr
assert "Use only the intended encrypted wallet" not in private.stderr
assert "\x1b[" not in private.stderr + private.stdout
Expand Down Expand Up @@ -2900,7 +2898,7 @@ def test_incomplete_candidate_has_no_search_warning_and_is_never_accepted_automa
candidate = _correct_fixed(source, suspected_profile=Profile.MS)
assert candidate is not None
candidate = replace(candidate, search_complete=False)
with patch("codex32._cli_input._correction_candidates", return_value=((candidate,), False, 0.0, False)):
with patch("codex32._cli_input._correction_candidates", return_value=((candidate,), False, 0.0)):
result = _invoke(["correct"], source[:-1] + "?")
assert result.exit_code == 1 and result.stdout == ""
assert "Search incomplete" not in result.stderr
Expand Down
10 changes: 5 additions & 5 deletions tests/test_correction_disclosure.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def test_noninteractive_gate_emits_only_operational_error(entrypoint, plain):
patch.object(sys, "stdin", io.StringIO(VECTOR_1["secret_s"][:-1] + "?")),
contextlib.redirect_stdout(stdout),
contextlib.redirect_stderr(stderr),
patch.object(_cli_input, "_correction_candidates", return_value=((_candidate(),), True, None, False)),
patch.object(_cli_input, "_correction_candidates", return_value=((_candidate(),), True, None)),
):
status = entrypoint(["correct", *(["--plain"] if plain else [])])
prog = "codex32" if entrypoint is cli.main else "ms32"
Expand Down Expand Up @@ -128,7 +128,7 @@ def respond(prompt, prefill=""):
monkeypatch.setattr(_cli_input, "_suggestions", lambda *args, **kwargs: (candidate,))
monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: (candidate,))
monkeypatch.setattr(
_cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False)
_cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None)
)
core = _FakeBitcoinCore()
monkeypatch.setattr(cli.BitcoinCore, "connect", lambda *args: core)
Expand Down Expand Up @@ -164,7 +164,7 @@ def respond(prompt, prefill=""):

monkeypatch.setattr(_cli_input, "_editable_input", respond)
monkeypatch.setattr(
_cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False)
_cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None)
)
with (
patch.object(sys, "stdin", _TTYInput()),
Expand All @@ -182,7 +182,7 @@ def test_redirected_stderr_blocks_low_discrimination_disclosure(monkeypatch):
responses = iter((source[:-1] + "?",))
monkeypatch.setattr(_cli_input, "_editable_input", lambda *args, **kwargs: next(responses))
monkeypatch.setattr(
_cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False)
_cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None)
)
stdout, stderr = io.StringIO(), io.StringIO()
with (
Expand Down Expand Up @@ -219,7 +219,7 @@ def confirm(prompt):

monkeypatch.setattr(_cli_input, "_confirmation_input", confirm)
monkeypatch.setattr(
_cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None, False)
_cli_input, "_correction_candidates", lambda *args, **kwargs: ((candidate,), True, None)
)
stdout, stderr = io.StringIO(), _TTYOutput()
with (
Expand Down
4 changes: 1 addition & 3 deletions tests/test_correction_indel.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,19 @@
_CLASSES,
_FIXED,
_GROUP_CLASSES,
_REDUCED_CLASSES,
_alignment_count,
_alignment_counts,
_capacities,
_frontier,
_keep,
_prepare,
_reductions,
_required_header_substitutions,
_search_many,
_search_target,
)
from codex32.profiles.ms32 import TEXT_LENGTHS
from tools._wallet_test_vectors import FIXTURE_SEED, core_fingerprint
from tools.correction_capture import cross_length_classes
from tools.correction_reference import _REDUCED_CLASSES, _alignment_count, _reductions

SOURCE = VECTOR_1["secret_s"]
CONTEXT = CorrectionContext(Profile.MS, expected_length=len(SOURCE))
Expand Down
3 changes: 1 addition & 2 deletions tools/correction_benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,12 @@
from codex32.correction import CorrectionContext
from codex32.indel import (
_CHARACTER_CLASSES,
_alignment_count,
_required_header_substitutions,
_search_many,
_variants,
)
from codex32.profiles import Profile
from codex32.profiles.ms32 import TEXT_LENGTHS
from tools.correction_reference import _alignment_count, _variants

DELTAS = (-8, -4, -3, -2, -1, 0, 1, 2, 3, 4, 8)
AUTOMATIC_48_COUNTS = (40, *range(44, 53), 56)
Expand Down
53 changes: 53 additions & 0 deletions tools/correction_reference.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Reference-only structural helpers used by tests and correction benchmarks."""

from collections.abc import Iterator
from itertools import combinations

from codex32.indel import (
_CLASSES,
_alignment_counts,
_StructuralClass,
_Variant,
_view_variant,
_views,
)

_REDUCED_CLASSES = tuple(shape for shape in _CLASSES if shape.unit == 4 or shape.distance <= 3)


def _alignment_count(
shape: _StructuralClass,
observed_length: int,
target_length: int,
immutable_length: int,
) -> int:
return sum(_alignment_counts(shape, "q" * observed_length, target_length, immutable_length).values())


def _reductions(
values: tuple[int, ...],
characters: str,
count: int,
offset: int,
) -> Iterator[tuple[tuple[int, ...], tuple[tuple[int, str], ...]]]:
for deleted in combinations(range(len(values)), count):
removed = frozenset(deleted)
kept = tuple(index for index in range(len(values)) if index not in removed)
retained = tuple(values[index] for index in kept)
position = 0
for kept_index in kept:
while values[position] != values[kept_index]:
position += 1
if position != kept_index:
break
position += 1
else:
edits = tuple((offset + index, characters[index]) for index in deleted)
yield retained, edits


def _variants(
text: str, target: int, shape: _StructuralClass, immutable: int, prefix_length: int = 3
) -> Iterator[_Variant]:
for view in _views(text, target, shape, immutable, prefix_length):
yield _view_variant(view, text, prefix_length)
Loading