Skip to content
Open
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
8 changes: 7 additions & 1 deletion docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
55 changes: 44 additions & 11 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 @@ -361,14 +361,28 @@ 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,
profiles: tuple[Profile, ...] | None,
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)
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
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
3 changes: 2 additions & 1 deletion src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
_render_groups,
_require_correction_confirmation,
_scheduled_candidates,
_separator,
_suggestions,
)
from codex32._cli_input import InputError as _UsageError
Expand Down Expand Up @@ -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):
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
55 changes: 30 additions & 25 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,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)
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
Loading
Loading