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..8d46bf11 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -432,7 +432,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..304a3ccb 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, @@ -432,9 +432,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 +460,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 +503,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 = [] 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/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..5ba5635c 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,7 +413,8 @@ 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) - if not matches or not target - 8 <= len(text) <= target + 8: + # Bech32 separates at the last "1"; a later one enters a different HRP. + if not matches or text.rfind("1") != len(context.hrp) 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_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..08d9b233 100644 --- a/tests/test_correction_indel.py +++ b/tests/test_correction_indel.py @@ -229,6 +229,15 @@ def test_immutable_confirmed_header_reduces_domains_and_cannot_be_repaired() -> assert correct(context, SOURCE[:5] + "?" + SOURCE[6:]) == () +def test_later_separator_enters_another_namespace_and_is_not_repaired() -> None: + # Bech32 splits at the last "1", so this text no longer carries the ms HRP. + damaged = SOURCE[:15] + "1" + SOURCE[16:] + + assert correct(CONTEXT, damaged) == () + assert correct(CorrectionContext(Profile.MS), damaged) == () + assert correct(CONTEXT, damaged.upper()) == () + + 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