diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7edcac9f..974bcdda 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,9 +11,11 @@ jobs: build: runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.13" @@ -33,18 +35,30 @@ jobs: tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner \ -C normalized-sdist -cf - "codex32-${GITHUB_REF_NAME#v}" | gzip -n > "$archive.new" mv "$archive.new" "$archive" - - name: Attach distributions to the GitHub release - run: gh release upload "$GITHUB_REF_NAME" dist/* - env: - GH_TOKEN: ${{ github.token }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: distributions path: dist/ - publish: + attach: + # Only this job, which runs no repository code, can write to the release. needs: build runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: distributions + path: dist/ + - name: Attach distributions to the GitHub release + run: gh release upload "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" + env: + GH_TOKEN: ${{ github.token }} + + publish: + needs: [build, attach] + runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/codex32 diff --git a/docs/developer/api.md b/docs/developer/api.md index 51c9fb5c..7d3e080d 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -368,6 +368,12 @@ cannot transform one application namespace into another. Every returned artifact crosses `parse_codex32`; suggestions remain untrusted and require exact whole-string confirmation before operational use. +The context supplies the HRP, so a later `1` is an ordinary erasure. The CLIs +follow the same rule wherever the HRP is already known: `ms32` input, a +prefilled prefix, and every string after the first accepted string of a set. +A first generic `codex32` string instead takes its HRP from the last `1`, as +Bech32 requires, so its separator is never corrected into another namespace. + `capture_volume` is the integer primary rank; `addend_hamming_weight` and `crc_padding_match` retain their secondary diagnostic meanings. `cumulative_capture_volume` and `capture_space_bits` describe the conservative @@ -432,7 +438,7 @@ and reparses survivors. No meet-in-the-middle search is used. Each layer is charged `alignment_count * 32**E * C(M-E,S) * 31**S`, with `M` the mutable body length. All fixed, character, group and target-length layers -share one admission ledger. Complete equal-volume batches are admitted only +of both mixed-case interpretations share one admission ledger. Complete equal-volume batches are admitted only while their cumulative mass is at or below `1`. Integer scaling handles 65-bit and 75-bit checksum spaces together. Consecutive fixed erasures are included in this ledger, with no independent allowance. Profile validation, diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 0d2a41e4..710aada8 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -22,7 +22,7 @@ parse_codex32, recover_secret, ) -from codex32.correction import CorrectionCandidate, CorrectionContext, _best, _capture_mass +from codex32.correction import CorrectionCandidate, CorrectionContext, _best, _capture_mass, _validate_context from codex32.errors import ( CodexError, DuplicateShareIndex, @@ -361,6 +361,20 @@ def _display_prefix(prefix: str, grouped: bool) -> str: return displayed +def _separator(value: str, prefix: str, profiles: tuple[Profile, ...] | None) -> int: + """Locate the separator; a later "1" is an erasure once the HRP is known.""" + lowered = value.lower() + if prefix and lowered.startswith(prefix.lower()): + # A prefilled prefix or the first accepted string of a set fixes the HRP. + return prefix.rfind("1") + for profile in profiles or (): + # A profile-restricted command, such as ms32, fixes its own HRP. + if lowered.startswith(f"{profile.value}1"): + return len(profile.value) + # Otherwise Bech32 separates at the last "1", so it is never corrected away. + return lowered.rfind("1") + + def _case_interpretation( value: str, prefix: str, @@ -368,7 +382,7 @@ def _case_interpretation( allowed: Callable[[CorrectionCandidate], bool] | None, ) -> tuple[CorrectionCandidate | None, str, str, str] | None: # Normalize likely casing and mark contrary-case data as erasures. - separator = value.find("1") + separator = _separator(value, prefix, profiles) base_length = separator + 1 if separator >= 0 else 0 immutable_length = len(prefix) if prefix and value.lower().startswith(prefix.lower()) else base_length interpretation = interpret_mixed_case(value, immutable_length) @@ -432,9 +446,19 @@ def _correction_candidates( seed_candidates: Sequence[CorrectionCandidate] = (), required_only: bool = False, optional_only: bool = False, + shared_texts: Sequence[str] = (), ) -> tuple[tuple[CorrectionCandidate, ...], bool, float]: count = len(value.replace(" ", "")) - targets = _correction_plan(profile, byte_length, count, target) + + def supported(length: int) -> bool: + # Like the public unknown-length path, skip lengths no codex32 string can have. + try: + _validate_context(CorrectionContext(profile, length)) + except CodexError: + return False + return True + + targets = tuple(filter(supported, _correction_plan(profile, byte_length, count, target))) deadline = monotonic() + 10 if deadline is None else deadline contexts = tuple(CorrectionContext(profile, length, immutable, excluded) for length in targets) from codex32.indel import _search_many @@ -450,6 +474,7 @@ def _correction_candidates( seed_candidates=seed_candidates, required_only=required_only, optional_only=optional_only, + shared_texts=shared_texts, ) if allowed is not None: candidates = tuple(candidate for candidate in candidates if allowed(candidate)) @@ -492,21 +517,28 @@ def _scheduled_candidates( if retry is not None: # Find required candidates for both case interpretations before either # full search can spend the shared deadline on optional alignment. - # These discovery passes deliberately do not charge capture_layers; - # the full searches below account each admitted frontier once. - for required_value in (first, retry): + # Both interpretations draw on one admission ledger. These discovery + # passes do not charge capture_layers; the full searches below account + # each admitted frontier once. + for required_value, other in ((first, retry), (retry, first)): seeded, complete, deadline = search( - required_value, deadline=deadline, seed_candidates=seeded, required_only=True + required_value, + deadline=deadline, + seed_candidates=seeded, + required_only=True, + shared_texts=(other,), ) if not complete: return (), False capture_layers: list[tuple[int, int]] = [] full_search = partial(search, capture_layers=capture_layers, optional_only=retry is not None) - candidates, complete, deadline = full_search(first, deadline=deadline, seed_candidates=seeded) + candidates, complete, deadline = full_search( + first, deadline=deadline, seed_candidates=seeded, shared_texts=() if retry is None else (retry,) + ) if retry is None: return candidates, complete retry_candidates, retry_complete, _deadline = full_search( - retry, deadline=deadline, seed_candidates=(*seeded, *candidates) + retry, deadline=deadline, seed_candidates=(*seeded, *candidates), shared_texts=(first,) ) complete = complete and retry_complete annotated = [] @@ -556,7 +588,7 @@ def _suggestions( candidate, value, erased, prefix = interpretation if candidate is not None: return (candidate,) - separator = value.lower().rfind("1") + separator = _separator(value, prefix, profiles) if separator <= 0: return () hrp = value[:separator].lower() @@ -637,7 +669,8 @@ def allowed(candidate: CorrectionCandidate) -> bool: candidates = _suggestions( token, - "", + # Every string in a set shares the first accepted string's HRP. + f"{accepted[0].hrp}1" if accepted else "", profiles, accepted, allowed=allowed, diff --git a/src/codex32/_competitors.py b/src/codex32/_competitors.py index b5895a9b..eac37107 100644 --- a/src/codex32/_competitors.py +++ b/src/codex32/_competitors.py @@ -182,7 +182,9 @@ def _search_competitors( completed: set[_Layer] = set() try: for state in states: - if not optional_only and _FIXED in state.counts: + # Coverage proofs read a missing witness as a failed decode, so the + # optional pass recomputes each fixed witness instead of omitting it. + if _FIXED in state.counts: _check_deadline(deadline) fixed[state.target] = _search_fixed(state, frontier, results, allowed) targets = {state.target: state for state in states} diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 8bd2a84f..921e63c6 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -28,6 +28,7 @@ _render_groups, _require_correction_confirmation, _scheduled_candidates, + _separator, _suggestions, ) from codex32._cli_input import InputError as _UsageError @@ -601,7 +602,7 @@ def _correct( if erasures: raise _UsageError("--erasure can be used only with --residue.") normalized = "".join(value.split()) - separator = normalized.lower().rfind("1") + separator = _separator(normalized, "", context.profiles) if separator <= 0: raise _UsageError("Enter a complete application prefix followed by the separator 1.") if len(raw_hrp := normalized[:separator]) > 83 or not all("!" <= c <= "~" for c in raw_hrp): diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 39fc2627..175fe37e 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -857,8 +857,8 @@ def _correct_complete( if interpretation is not None: # Establish both interpretations' fixed/required candidates before # either interpretation can spend the shared deadline on optional - # alignment work. These discovery passes use a private accounting - # ledger; the full searches below account every admitted layer once. + # alignment work. Both interpretations draw on one admission ledger; + # only the full searches below account every admitted layer once. for input_context, value in inputs: preflight_contexts: tuple[CorrectionContext, ...] if input_context.expected_length is not None: @@ -884,6 +884,7 @@ def _correct_complete( observed_text=damaged_text, seed_candidates=candidates, required_only=True, + shared_texts=tuple(other for _context, other in inputs if other != value), ) if not current_complete: return (), False @@ -912,6 +913,7 @@ def _correct_complete( observed_text=damaged_text, seed_candidates=candidates, optional_only=interpretation is not None, + shared_texts=tuple(other for _context, other in inputs if other != value), ) complete &= current_complete if not current_complete and not candidates: diff --git a/src/codex32/indel.py b/src/codex32/indel.py index 98a63698..4786e50a 100644 --- a/src/codex32/indel.py +++ b/src/codex32/indel.py @@ -333,24 +333,30 @@ def _layers( def _frontier( - states: Sequence[_Target], primary: frozenset[int] + states: Sequence[_Target], primary: frozenset[int], shared: Sequence[_Target] = () ) -> dict[tuple[int, _StructuralClass, int, int], int]: - layers = tuple(layer for state in states for layer in _layers(state)) - maximum = max((bits for _volume, bits, _key in layers), default=0) + """Admit layers by volume; ``shared`` interpretations spend the same ledger but are not returned.""" + layers = tuple( + (*layer, own) + for own, group in ((True, states), (False, shared)) + for state in group + for layer in _layers(state) + ) + maximum = max((bits for _volume, bits, _key, _own in layers), default=0) admitted: dict[tuple[int, _StructuralClass, int, int], int] = {} cumulative = 0 def add( - pool: Sequence[tuple[int, int, tuple[int, _StructuralClass, int, int]]], + pool: Sequence[tuple[int, int, tuple[int, _StructuralClass, int, int], bool]], ) -> None: nonlocal cumulative for _rank, grouped in groupby(sorted(pool, key=lambda item: item[0]), key=lambda item: item[0]): batch = tuple(grouped) - increment = sum(volume << (maximum - bits) for volume, bits, _key in batch) + increment = sum(volume << (maximum - bits) for volume, bits, _key, _own in batch) if _FALSE_BOUND_DENOMINATOR * (cumulative + increment) > 1 << maximum: break cumulative += increment - admitted.update((key, volume) for volume, _bits, key in batch) + admitted.update((key, volume) for volume, _bits, key, own in batch if own) add(tuple(layer for layer in layers if layer[2][0] in primary)) add(tuple(layer for layer in layers if layer[2][0] not in primary)) @@ -407,6 +413,7 @@ def _normalize(context: CorrectionContext, damaged_text: str) -> tuple[str, int] return None prefix = context.immutable_prefix or f"{context.hrp}1" matches = text.startswith(prefix) if context.immutable_prefix else text.lower().startswith(prefix) + # The context supplies the HRP, so any later "1" is an ordinary erasure. if not matches or not target - 8 <= len(text) <= target + 8: return None return text, len(prefix) @@ -561,34 +568,32 @@ def _search_many( seed_candidates: Sequence[CorrectionCandidate] = (), required_only: bool = False, optional_only: bool = False, + shared_texts: Sequence[str] = (), ) -> tuple[tuple[CorrectionCandidate, ...], bool]: deadline = monotonic() + 10 if deadline is None else deadline - states = tuple( - state - for context in contexts - if ( - state := _prepare( - context, - damaged_text, - _CLASSES, - observed_text, - ) + + def prepare(text: str) -> tuple[_Target, ...]: + return tuple( + state + for context in contexts + if (state := _prepare(context, text, _CLASSES, observed_text)) is not None ) - is not None - ) + + states = prepare(damaged_text) + # Every case interpretation and pass draws on one complete admission ledger. + frontier = _frontier(states, primary, tuple(state for text in shared_texts for state in prepare(text))) if required_only: + + def required(shape: _StructuralClass) -> bool: + return shape == _FIXED or shape.unit == 4 or shape.distance <= 2 + states = tuple( replace( - state, - counts={ - shape: values - for shape, values in state.counts.items() - if shape == _FIXED or shape.unit == 4 or shape.distance <= 2 - }, + state, counts={shape: values for shape, values in state.counts.items() if required(shape)} ) for state in states ) - frontier = _frontier(states, primary) + frontier = {key: volume for key, volume in frontier.items() if required(key[1])} layers_accounted = [] if capture_layers is None else capture_layers widths = {state.target: 5 * state.degree for state in states} layers_accounted.extend((volume, widths[key[0]]) for key, volume in frontier.items()) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4d930a43..52bdb693 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1811,6 +1811,66 @@ def test_cli_rejects_sixteen_consecutive_erasures_as_outside_regular_bound() -> assert "No valid correction found" in result.stderr +def _later_one(text: str) -> str: + return text[:15] + "1" + text[16:] + + +def test_ms32_first_string_corrects_a_later_one_as_an_erasure() -> None: + from codex32._cli_input import _suggestions + + original = VECTOR_1["secret_s"] + damaged = _later_one(original) + + result = _invoke(["correct", "--bytes", "16"], damaged) + + assert result.exit_code == 1 + assert original in result.stderr + for prefix in ("", "MS1"): + candidates = _suggestions(damaged, prefix, (Profile.MS,), []) + assert [candidate.artifact.text for candidate in candidates] == [original] + + +def test_codex32_first_string_takes_its_hrp_from_the_last_one(monkeypatch: pytest.MonkeyPatch) -> None: + from codex32._cli_input import _suggestions + + damaged = _later_one(VECTOR_1["secret_s"]) + searched: list[tuple[object, ...]] = [] + + def search(*args: object, **_kwargs: object) -> tuple[tuple[CorrectionCandidate, ...], bool]: + searched.append(args) + return (), True + + monkeypatch.setattr("codex32.cli._scheduled_candidates", search) + monkeypatch.setattr("codex32._cli_input._scheduled_candidates", search) + + assert _invoke(["correct"], damaged).exit_code == 3 + assert _suggestions(damaged, "", None, []) == () + # Bech32's separator is the last "1": the ms namespace is never searched. + assert [(args[2], args[4]) for args in searched] == [(damaged[:15], damaged[:16])] * 2 + + +@pytest.mark.parametrize("profiles", (None, (Profile.MS,))) +def test_later_set_strings_correct_a_one_after_the_first_hrp(profiles: tuple[Profile, ...] | None) -> None: + input_module = importlib.import_module("codex32._cli_input") + first, original = VECTOR_2["share_A"], VECTOR_2["share_C"] + accepted: list[Share | Secret] = [parse_codex32(first)] + damaged = _later_one(original) + + interactive = input_module._suggestions( + damaged, input_module._accepted_prefix(accepted), profiles, accepted + ) + with ( + patch.object(input_module, "_stdin", lambda: f"{first} {damaged}"), + patch.object(input_module, "_confirm_correction", lambda *_args: True), + ): + redirected = input_module._redirected( + profiles, basis=False, one=False, excluded_index=None, fingerprint=None + ) + + assert [candidate.artifact.text for candidate in interactive] == [original] + assert [artifact.text for artifact in redirected] == [first, original] + + def test_correct_rejects_malformed_immutable_hrp_as_usage() -> None: damaged = "é" + VECTOR_1["secret_s"][1:] diff --git a/tests/test_cli_lengths.py b/tests/test_cli_lengths.py index 6fa10899..af99612b 100644 --- a/tests/test_cli_lengths.py +++ b/tests/test_cli_lengths.py @@ -3,7 +3,7 @@ import pytest from codex32 import Profile -from codex32._cli_input import InputError, _parse, _prefixed_input_error +from codex32._cli_input import InputError, _parse, _prefixed_input_error, _suggestions from codex32.errors import InvalidChecksum, InvalidLength @@ -45,3 +45,11 @@ def test_reported_invalid_length_vector_in_neutral_entry(): def test_unknown_profile_does_not_replace_checksum_failure_with_a_length_guess(): with pytest.raises(InputError, match="The checksum does not match"): _parse("zz10fauxs" + "x" * 87, (Profile.MS,)) + + +def test_opaque_correction_skips_lengths_inside_the_checksum_gap(): + # 90 characters: nearby targets 92 and 93 would expand to the forbidden 94/95. + source = "zz10tests" + "q" * 68 + "eqq7qmrek3dw3" + damaged = source[:87] + "p" + source[88:] + + assert [candidate.artifact.text for candidate in _suggestions(damaged, "", None, [])] == [source] diff --git a/tests/test_competitors.py b/tests/test_competitors.py index 033cce0e..2a518dc6 100644 --- a/tests/test_competitors.py +++ b/tests/test_competitors.py @@ -1,6 +1,7 @@ """CLI competitor scheduling, proof boundaries, and differential scoring.""" from dataclasses import replace +from math import comb from time import monotonic from unittest.mock import patch @@ -8,6 +9,7 @@ from codex32 import CorrectionContext, MasterSeed, Profile, Share, indel from codex32 import _competitors as search +from codex32._cli_input import _case_interpretation, _scheduled_candidates from codex32.bech32 import CHARSET from codex32.correction import _correct_fixed from codex32.profiles.ms32 import TEXT_LENGTHS @@ -342,3 +344,22 @@ def test_pruning_does_not_change_mass_admission(): search._search_competitors((state,), frontier, monotonic() + 10, None) assert frontier == original assert sum(frontier.values()) <= 2**65 + + +def test_mixed_case_optional_pass_keeps_fixed_witnesses(): + damaged = list(SOURCE) + for left, right in ((11, 12), (23, 34)): + damaged[left], damaged[right] = damaged[right], damaged[left] + lowercase = "".join(damaged) + mixed = lowercase[:40] + lowercase[40].upper() + lowercase[41:] + interpretation = _case_interpretation(mixed, "", (Profile.MS,), None) + assert interpretation is not None + _direct, normalized, erased, _prefix = interpretation + assert normalized == lowercase + + candidates, complete = _scheduled_candidates(normalized, erased, Profile.MS, None, "ms1", target=48) + + assert complete + assert [candidate.artifact.text for candidate in candidates] == [SOURCE] + # One adjacent and one distant swap among 45 body characters, ordered two ways. + assert candidates[0].capture_volume == 2 * 44 * comb(44, 2) diff --git a/tests/test_correction_indel.py b/tests/test_correction_indel.py index d15c6225..58b205bc 100644 --- a/tests/test_correction_indel.py +++ b/tests/test_correction_indel.py @@ -229,6 +229,20 @@ def test_immutable_confirmed_header_reduces_domains_and_cannot_be_repaired() -> assert correct(context, SOURCE[:5] + "?" + SOURCE[6:]) == () +def test_context_supplied_hrp_makes_a_later_one_an_erasure() -> None: + damaged = SOURCE[:15] + "1" + SOURCE[16:] + cases = ( + (CONTEXT, damaged), + (CorrectionContext(Profile.MS), damaged.upper()), + (CorrectionContext(Profile.MS, 48, SOURCE[:8]), damaged), + ) + + for context, text in cases: + candidates = correct(context, text) + assert [candidate.artifact.text.lower() for candidate in candidates] == [SOURCE] + assert [edit.kind for edit in candidates[0].edits] == ["erasure"] + + def test_header_pruning_is_a_lossless_lower_bound() -> None: ordinary = [0, 1, 2, 3, 4, 5] ordinary[0] = 15 # threshold 0 diff --git a/tests/test_mixed_case_deadline.py b/tests/test_mixed_case_deadline.py index f812096f..10b05df5 100644 --- a/tests/test_mixed_case_deadline.py +++ b/tests/test_mixed_case_deadline.py @@ -5,8 +5,9 @@ import pytest -from codex32 import Profile +from codex32 import CorrectionContext, MasterSeed, Profile from codex32._cli_input import _case_interpretation, _correction_candidates, _scheduled_candidates +from codex32.correction import _correct_complete SOURCE = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" @@ -81,3 +82,26 @@ def truncated_first(value: str, *_args: object, **kwargs: object) -> tuple[objec assert [(candidate.artifact.text, candidate.search_complete) for candidate in candidates] == [ (SOURCE, False) ] + + +@pytest.mark.parametrize("entry", ("public", "cli")) +def test_case_interpretations_share_one_admission_ledger(entry: str) -> None: + # Thirteen minority-case symbols: alone, the erased reading admits a + # whole-checksum completion; the normalized reading must share that mass. + source = MasterSeed.from_seed(bytes(16), identifier="test").text + damaged = source[:10] + "P" * 13 + source[23:] + deadline = monotonic() + 1 + if entry == "public": + candidates, _complete = _correct_complete( + CorrectionContext(Profile.MS, 48), damaged, deadline=deadline + ) + else: + interpretation = _case_interpretation(damaged, "", (Profile.MS,), None) + assert interpretation is not None + _direct, normalized, erased, _prefix = interpretation + candidates, _complete = _scheduled_candidates( + normalized, erased, Profile.MS, None, "ms1", target=48, deadline=deadline + ) + + for candidate in candidates: + assert candidate.cumulative_capture_volume <= 1 << candidate.capture_space_bits