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
26 changes: 20 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 26 additions & 8 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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 = []
Expand Down
4 changes: 3 additions & 1 deletion src/codex32/_competitors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
6 changes: 4 additions & 2 deletions src/codex32/correction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
57 changes: 31 additions & 26 deletions src/codex32/indel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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())
Expand Down
10 changes: 9 additions & 1 deletion tests/test_cli_lengths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


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

import pytest

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
Expand Down Expand Up @@ -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)
9 changes: 9 additions & 0 deletions tests/test_correction_indel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion tests/test_mixed_case_deadline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Loading