Skip to content

Commit 7311db8

Browse files
committed
Expose vector construction helpers
Promote the module-level checksum and u5 conversion interfaces needed by reference-vector authors while keeping the package-level API narrow. Document and test the supported vector workflow and justify the remaining private cross-module couplings.\n\nValidation: 866 normal and 866 optimized tests; mypy; Ruff; production size budget.\n\nfixes #49
1 parent 92d75a7 commit 7311db8

17 files changed

Lines changed: 179 additions & 104 deletions

‎docs/developer/api.md‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,57 @@ generic parse-length failure.
100100
hidden state.
101101

102102
Private Python names are convention rather than access control. The supported
103-
surface is the 25-name package `__all__`; direct use of private helpers is
104-
unsupported but remains in the review scope.
103+
top-level surface is the 24-name package `__all__`; direct use of private helpers
104+
is unsupported but remains in the review scope.
105+
106+
### Reference-vector construction API
107+
108+
Reference-vector authors may use the following module-level interfaces without
109+
depending on implementation-private names:
110+
111+
- `codex32.bech32`: `CHARSET`, `bech32_decode`, `bech32_encode`,
112+
`bech32_hrp_expand`, `chars_to_u5`, and `u5_to_chars`;
113+
- `codex32.checksums`: `Checksum`, `CODEX32`, and `CODEX32_LONG`;
114+
- `codex32.bip93`: `checksum_for_body_length` and
115+
`checksum_for_encoded_length`.
116+
117+
For example, a generic codex32 vector can be constructed without importing an
118+
underscore-prefixed name:
119+
120+
```python
121+
from codex32.bech32 import bech32_encode, chars_to_u5
122+
from codex32.bip93 import checksum_for_body_length
123+
124+
hrp = "zz"
125+
body = chars_to_u5("0tests" + "q" * 26)
126+
checksum = checksum_for_body_length(hrp, len(body))
127+
text = bech32_encode(hrp, body, checksum)
128+
```
129+
130+
These are supported module interfaces for codec and vector work; they are not
131+
added to the package-level `codex32.__all__`, whose backup/recovery surface stays
132+
deliberately narrow.
133+
134+
The remaining production cross-module private imports are intentional internal
135+
couplings rather than user-facing APIs:
136+
137+
- `_alignment.py`, `_competitors.py`, `correction.py`, and `indel.py` form one
138+
bounded correction engine. Their underscored search state, views, and pruning
139+
helpers are exchanged only inside that engine.
140+
- `bip93.py` and `correction.py` use the shared private GF(32) arithmetic in
141+
`gf32.py`; profile factories use `_from_parts` and profile-rule helpers to
142+
preserve one artifact-validation path without publishing construction hooks.
143+
- `generation.py`, `wallet.py`, and `_bitcoin_core.py` share the private BIP32
144+
root primitives. `_bitcoin_core.py` also consumes wallet descriptor records
145+
while remaining the sole process/state adapter.
146+
- `_cli_input.py` and `cli.py` share private correction/profile orchestration,
147+
input state, and parser plumbing. Those names exist to compose the CLI, not as
148+
a second domain API.
149+
150+
Internal benchmark and verification tools may import those implementation names
151+
when they are explicitly testing the implementation itself. Tools or examples
152+
that model external vector construction use the supported module interfaces
153+
above.
105154

106155
### Size budget
107156

‎src/codex32/_cli_input.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
from codex32.bip93 import (
1414
Secret,
1515
Share,
16-
_checksum_for_encoded_length,
1716
_validate_basis_prefix,
1817
_validate_recovery_prefix,
18+
checksum_for_encoded_length,
1919
parse_codex32,
2020
recover_secret,
2121
)
@@ -393,7 +393,7 @@ def _case_interpretation(
393393
candidate = None
394394
else:
395395
bits = (
396-
5 * _checksum_for_encoded_length(artifact.hrp, len(artifact.text) - len(artifact.hrp) - 1).length
396+
5 * checksum_for_encoded_length(artifact.hrp, len(artifact.text) - len(artifact.hrp) - 1).length
397397
)
398398
proposed = CorrectionCandidate(artifact, (), 1, 0, 0, None, capture_space_bits=bits)
399399
candidate = proposed if allowed is None or allowed(proposed) else None

‎src/codex32/bech32.py‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Bech32 character, container, and bit-conversion helpers."""
22

3-
from codex32.checksums import _Checksum
3+
from codex32.checksums import Checksum
44
from codex32.errors import (
55
InvalidCase,
66
InvalidCharacter,
@@ -19,14 +19,18 @@ def bech32_hrp_expand(hrp: str) -> list[int]:
1919
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
2020

2121

22-
def _u5_to_chars(values: list[int] | tuple[int, ...]) -> str:
22+
def u5_to_chars(values: list[int] | tuple[int, ...]) -> str:
23+
"""Convert 5-bit values to Bech32 characters."""
24+
2325
for index, value in enumerate(values):
2426
if not 0 <= value < 32:
2527
raise InvalidCharacter(f"u5 value {value} at index {index} is outside 0..31")
2628
return "".join(CHARSET[value] for value in values)
2729

2830

29-
def _chars_to_u5(value: str, first_position: int = 1) -> list[int]:
31+
def chars_to_u5(value: str, first_position: int = 1) -> list[int]:
32+
"""Convert Bech32 characters to 5-bit values."""
33+
3034
result: list[int] = []
3135
for index, character in enumerate(value.lower()):
3236
position = CHARSET.find(character)
@@ -53,18 +57,18 @@ def _validate_single_case_ascii(value: str) -> bool:
5357
return value.isupper()
5458

5559

56-
def bech32_encode(hrp: str, data: list[int], spec: _Checksum) -> str:
60+
def bech32_encode(hrp: str, data: list[int], spec: Checksum) -> str:
5761
"""Compute a Bech32 string given HRP and data values."""
5862
checksum = spec.create(bech32_hrp_expand(hrp) + list(data))
59-
return f"{hrp}1{_u5_to_chars([*data, *checksum])}"
63+
return f"{hrp}1{u5_to_chars([*data, *checksum])}"
6064

6165

62-
def bech32_verify_checksum(hrp: str, data: list[int], spec: _Checksum) -> bool:
66+
def bech32_verify_checksum(hrp: str, data: list[int], spec: Checksum) -> bool:
6367
"""Verify the checksum selected by the calling application."""
6468
return spec.verify(bech32_hrp_expand(hrp) + list(data))
6569

6670

67-
def bech32_decode(value: str, spec: _Checksum | None = None) -> tuple[str, list[int]]:
71+
def bech32_decode(value: str, spec: Checksum | None = None) -> tuple[str, list[int]]:
6872
"""Validate a Bech32 string, optionally including its checksum."""
6973
_validate_single_case_ascii(value)
7074
separator = value.rfind("1")
@@ -74,7 +78,7 @@ def bech32_decode(value: str, spec: _Checksum | None = None) -> tuple[str, list[
7478
raise MissingSeparator("The application prefix before 1 is missing.")
7579
lowered = value.lower()
7680
hrp = lowered[:separator]
77-
data = _chars_to_u5(lowered[separator + 1 :], separator + 2)
81+
data = chars_to_u5(lowered[separator + 1 :], separator + 2)
7882
if spec is None:
7983
return hrp, data
8084
if len(data) < spec.length or not bech32_verify_checksum(hrp, data, spec):

‎src/codex32/bip93.py‎

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66

77
from codex32.bech32 import (
88
CHARSET,
9-
_chars_to_u5,
10-
_u5_to_chars,
119
bech32_decode,
1210
bech32_encode,
1311
bech32_verify_checksum,
12+
chars_to_u5,
13+
u5_to_chars,
1414
)
15-
from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum
15+
from codex32.checksums import CODEX32, CODEX32_LONG, Checksum
1616
from codex32.errors import (
1717
DuplicateShareIndex,
1818
ExistingTargetIndex,
@@ -68,7 +68,7 @@ def __post_init__(self) -> None:
6868
def _from_symbols(cls, symbols: tuple[int, ...]) -> "Header":
6969
if len(symbols) != 6:
7070
raise InvalidLength("codex32 header must contain six symbols")
71-
text = _u5_to_chars(symbols)
71+
text = u5_to_chars(symbols)
7272
if text[0] not in "023456789":
7373
raise InvalidThreshold(
7474
f"The threshold must be 0 or a number from 2 through 9; found {text[0]!r}."
@@ -77,25 +77,36 @@ def _from_symbols(cls, symbols: tuple[int, ...]) -> "Header":
7777

7878
@property
7979
def _symbols(self) -> tuple[int, ...]:
80-
return tuple(_chars_to_u5(f"{self.threshold}{self.identifier}{self.index}"))
80+
return tuple(chars_to_u5(f"{self.threshold}{self.identifier}{self.index}"))
8181

8282

83-
def _checksum_for_encoded_length(hrp: str, encoded_length: int) -> _Checksum:
83+
def checksum_for_encoded_length(hrp: str, encoded_length: int) -> Checksum:
84+
"""Return the codex32 checksum required by an encoded HRP/data length."""
85+
8486
expanded_length = 2 * len(hrp) + 1 + encoded_length
8587
if expanded_length <= 93:
86-
return _CODEX32
88+
return CODEX32
8789
if expanded_length < 96:
8890
raise InvalidLength("expanded codex32 lengths 94 and 95 are invalid")
8991
if expanded_length <= 1023:
90-
return _CODEX32_LONG
92+
return CODEX32_LONG
9193
raise InvalidLength("expanded codex32 codeword exceeds 1023 symbols")
9294

9395

94-
def _decode_codex32(text: str) -> tuple[str, _ProfileRules | None, tuple[int, ...], _Checksum]:
96+
def checksum_for_body_length(hrp: str, body_length: int) -> Checksum:
97+
"""Return the checksum required when constructing a codex32 body."""
98+
99+
expanded_body_length = 2 * len(hrp) + 1 + body_length
100+
checksum = CODEX32 if expanded_body_length <= 80 else CODEX32_LONG
101+
checksum_for_encoded_length(hrp, body_length + checksum.length)
102+
return checksum
103+
104+
105+
def _decode_codex32(text: str) -> tuple[str, _ProfileRules | None, tuple[int, ...], Checksum]:
95106
hrp, encoded = bech32_decode(text)
96107
if len(hrp) + 1 + len(encoded) < 21:
97108
raise InvalidLength("codex32 string must contain at least 21 characters")
98-
checksum = _checksum_for_encoded_length(hrp, len(encoded))
109+
checksum = checksum_for_encoded_length(hrp, len(encoded))
99110
body = tuple(encoded[: -checksum.length])
100111
Header._from_symbols(body[:6])
101112
if not bech32_verify_checksum(hrp, encoded, checksum):
@@ -205,10 +216,7 @@ def _from_parts(
205216
if rules is not None:
206217
_validate_payload(rules.profile, header, payload)
207218
body = [*header._symbols, *payload]
208-
expanded_body_length = 2 * len(normalized_hrp) + 1 + len(body)
209-
checksum = _CODEX32 if expanded_body_length <= 80 else _CODEX32_LONG
210-
# Validate the completed generic codeword, including the 94/95 gap.
211-
_checksum_for_encoded_length(normalized_hrp, len(body) + checksum.length)
219+
checksum = checksum_for_body_length(normalized_hrp, len(body))
212220
text = bech32_encode(normalized_hrp, body, checksum)
213221
return parse_codex32(text.upper() if uppercase else text)
214222

@@ -330,7 +338,7 @@ def _interpolate_tail(share_set: _ShareSet, target: str) -> Share | Secret:
330338
value ^= _gf32_multiply(weight, row[column])
331339
result.append(value)
332340
header = Header(share_set.threshold, share_set.identifier, target)
333-
text = f"{share_set.hrp}1{_u5_to_chars((*header._symbols, *result))}"
341+
text = f"{share_set.hrp}1{u5_to_chars((*header._symbols, *result))}"
334342
return parse_codex32(text.upper() if share_set.uppercase else text)
335343

336344

‎src/codex32/checksums.py‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020

2121

2222
@dataclass(frozen=True, slots=True)
23-
class _Checksum:
23+
class Checksum:
24+
"""Immutable checksum specification for reference-vector construction."""
25+
2426
kind: str
2527
generators: tuple[int, ...]
2628
length: int
@@ -49,21 +51,21 @@ def create(self, values: list[int] | tuple[int, ...]) -> list[int]:
4951
return [(residue >> (width * (self.length - 1 - index))) & mask for index in range(self.length)]
5052

5153

52-
_CODEX32 = _Checksum("codex32", _CODEX32_GEN, 13, 0x10CE0795C2FD1E62A, 93)
53-
_CODEX32_LONG = _Checksum("Long codex32", _CODEX32_LONG_GEN, 15, 0x43381E570BF4798AB26, 1023)
54+
CODEX32 = Checksum("codex32", _CODEX32_GEN, 13, 0x10CE0795C2FD1E62A, 93)
55+
CODEX32_LONG = Checksum("Long codex32", _CODEX32_LONG_GEN, 15, 0x43381E570BF4798AB26, 1023)
5456

5557
# Descriptor checksum remains an independently specified, non-codex32 helper.
56-
DESCSUM = _Checksum("Descriptor", _DESCSUM_GEN, 8, 1)
58+
DESCSUM = Checksum("Descriptor", _DESCSUM_GEN, 8, 1)
5759

5860
_CRC = (
5961
None,
60-
# ``_Checksum`` consumes input bits most-significant bit first. With its
62+
# ``Checksum`` consumes input bits most-significant bit first. With its
6163
# implicit leading term, these generator values spell x+1, x^2+x+1,
6264
# x^3+x+1, and x^4+x+1 respectively.
63-
_Checksum("CRC1", (1,), 1, 0),
64-
_Checksum("CRC2", (3,), 2, 0),
65-
_Checksum("CRC3", (3,), 3, 0),
66-
_Checksum("CRC4", (3,), 4, 0),
65+
Checksum("CRC1", (1,), 1, 0),
66+
Checksum("CRC2", (3,), 2, 0),
67+
Checksum("CRC3", (3,), 3, 0),
68+
Checksum("CRC4", (3,), 4, 0),
6769
)
6870

6971

‎src/codex32/correction.py‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,20 @@
3030

3131
from codex32.bech32 import (
3232
CHARSET,
33-
_chars_to_u5,
34-
_u5_to_chars,
3533
_validate_single_case_ascii,
3634
bech32_hrp_expand,
35+
chars_to_u5,
36+
u5_to_chars,
3737
)
3838
from codex32.bip93 import (
3939
IDX_SORT,
4040
Header,
4141
Secret,
4242
Share,
43-
_checksum_for_encoded_length,
43+
checksum_for_encoded_length,
4444
parse_codex32,
4545
)
46-
from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum
46+
from codex32.checksums import CODEX32, CODEX32_LONG, Checksum
4747
from codex32.errors import CodexError, InvalidCorrectionInput
4848
from codex32.gf32 import _inverse as _gf32_inverse
4949
from codex32.gf32 import _multiply as _gf32_multiply
@@ -255,10 +255,10 @@ class _Spec:
255255
_ALIGNMENT_CACHE_SIZE = 11
256256

257257

258-
def _spec_for_checksum(checksum: _Checksum) -> _Spec:
259-
if checksum is _CODEX32:
258+
def _spec_for_checksum(checksum: Checksum) -> _Spec:
259+
if checksum is CODEX32:
260260
return _SHORT_SPEC
261-
if checksum is _CODEX32_LONG:
261+
if checksum is CODEX32_LONG:
262262
return _LONG_SPEC
263263
raise AssertionError("registered profile selected a non-codex32 checksum")
264264

@@ -544,7 +544,7 @@ def __init__(
544544
) -> None:
545545
normalized_hrp = hrp.value if isinstance(hrp, Profile) else hrp.lower()
546546
profile_rules = _optional_profile_rules(normalized_hrp)
547-
checksum = _checksum_for_encoded_length(normalized_hrp, body_length)
547+
checksum = checksum_for_encoded_length(normalized_hrp, body_length)
548548
if profile_rules is not None:
549549
profile_rules.validate_payload_length(body_length - checksum.length - 6)
550550
self.hrp = normalized_hrp
@@ -598,7 +598,7 @@ def correct(
598598
return None
599599
for index, addend in result:
600600
corrected_reversed[index] ^= addend
601-
corrected = self.prefix + _u5_to_chars(list(reversed(corrected_reversed)))
601+
corrected = self.prefix + u5_to_chars(list(reversed(corrected_reversed)))
602602
corrected = corrected.upper() if self.uppercase else corrected
603603
try:
604604
artifact = parse_codex32(corrected)
@@ -774,7 +774,7 @@ def _validate_context(context: CorrectionContext) -> None:
774774
if length < 21:
775775
raise ValueError("expected_length must permit the generic codex32 minimum length")
776776
body_length = length - len(context.hrp) - 1
777-
checksum = _checksum_for_encoded_length(context.hrp, body_length)
777+
checksum = checksum_for_encoded_length(context.hrp, body_length)
778778
if body_length < checksum.length + 6:
779779
raise ValueError("expected_length must contain a header and checksum")
780780
rules = _optional_profile_rules(context.hrp)
@@ -913,7 +913,7 @@ def correct_worksheet_residue(
913913
raise InvalidCorrectionInput("codex32 input exceeds 15 characters")
914914
try:
915915
_validate_single_case_ascii(residue)
916-
values = list(reversed(_chars_to_u5(residue)))
916+
values = list(reversed(chars_to_u5(residue)))
917917
except TypeError:
918918
raise
919919
except CodexError as error:

‎src/codex32/generation.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Never, SupportsIndex, cast
1010

1111
from codex32._bip32 import _valid_root
12-
from codex32.bech32 import CHARSET, _u5_to_chars, convertbits
12+
from codex32.bech32 import CHARSET, convertbits, u5_to_chars
1313
from codex32.bip93 import (
1414
IDX_SORT,
1515
Header,
@@ -112,15 +112,15 @@ def _selection(threshold: int, share_count: object, indices: Sequence[str] | str
112112

113113

114114
def _random_identifier() -> str:
115-
return _u5_to_chars(tuple(value & 31 for value in secrets.token_bytes(4)))
115+
return u5_to_chars(tuple(value & 31 for value in secrets.token_bytes(4)))
116116

117117

118118
def _fingerprint_identifier(fingerprint: bytes) -> str:
119119
if not isinstance(fingerprint, bytes):
120120
raise TypeError("fingerprint must be bytes")
121121
if len(fingerprint) != 4:
122122
raise ValueError("fingerprint must contain four bytes")
123-
return _u5_to_chars(tuple(convertbits(fingerprint, 8, 5, pad=True)[:4]))
123+
return u5_to_chars(tuple(convertbits(fingerprint, 8, 5, pad=True)[:4]))
124124

125125

126126
def _random_share(profile: Profile, threshold: int, identifier: str, index: str, length: int) -> Share:

‎src/codex32/indel.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from codex32._alignment import _IncrementalSyndromes, _View
1010
from codex32.bech32 import CHARSET, _validate_single_case_ascii
11-
from codex32.bip93 import _checksum_for_encoded_length
11+
from codex32.bip93 import checksum_for_encoded_length
1212
from codex32.correction import (
1313
CorrectionCandidate,
1414
CorrectionContext,
@@ -310,7 +310,7 @@ def _prepare(
310310
for shape in shapes
311311
}
312312
base = len(context.hrp) + 1
313-
degree = _checksum_for_encoded_length(context.hrp, target - base).length
313+
degree = checksum_for_encoded_length(context.hrp, target - base).length
314314
return _Target(context, text, immutable, target, base, degree, counts)
315315

316316

0 commit comments

Comments
 (0)