baseh 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
baseh-1.1.0/PKG-INFO ADDED
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: baseh
3
+ Version: 1.1.0
4
+ Summary: baseH codec: checksumed, optionally permuted human-readable identifiers
5
+ License: AGPL-3.0-only
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+
9
+ # baseh
10
+
11
+ Python implementation of the baseH codec. Encodes an internal integer ID as
12
+ a checksummed, optionally permuted human-readable reference code. Implements
13
+ the normative spec in `../spec/IMPLEMENTATION_CODEC.md` and passes the
14
+ frozen cross-language vectors in `../vectors/`.
15
+
16
+ Zero runtime dependencies. HMAC-SHA-256 comes from the standard library.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install ./python
22
+ ```
23
+
24
+ Or run in place without installing:
25
+
26
+ ```bash
27
+ PYTHONPATH=python/src python3 -c "import baseh"
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```python
33
+ from baseh import Baseh, BasehError, baseh_medium_v1
34
+
35
+ codec = Baseh(baseh_medium_v1())
36
+
37
+ code = codec.encode(123456789)
38
+ print(code) # fixed-length, checksummed code
39
+
40
+ result = codec.decode(code.lower()) # case-insensitive
41
+ print(result.id) # 123456789
42
+ print(result.canonical_code) # canonical rendering
43
+ print(result.corrected) # False (input differed only in case)
44
+
45
+ # Assisted correction over spoken-confusion pairs (B/D, P/T, ...):
46
+ fixed = codec.decode(
47
+ code,
48
+ try_correction=True,
49
+ confusion_profile="light",
50
+ )
51
+
52
+ # Non-throwing validation for user input:
53
+ check = codec.validate("0000000")
54
+ print(check) # {"valid": False, "reason": "INVALID_CHECKSUM"}
55
+
56
+ print(codec.capacity()) # 481890304
57
+ ```
58
+
59
+ Errors raise `BasehError` with a `.code` attribute, one of:
60
+ `INVALID_PROFILE`, `OUT_OF_RANGE`, `PERMUTATION_FAILURE`, `INVALID_LENGTH`,
61
+ `INVALID_CHARACTER`, `INVALID_CHECKSUM`, `AMBIGUOUS_INPUT`,
62
+ `TOO_MANY_CANDIDATES`, `BLOCKED_CODE`.
63
+
64
+ ## Frozen tiers
65
+
66
+ Four frozen profiles, each 6 body symbols and case-insensitive, built from
67
+ the full alphanumeric set with cumulative visual and spoken strips:
68
+
69
+ | Tier | Helper | Symbols | Checksum | Capacity |
70
+ |------|--------|---------|----------|----------|
71
+ | Minimum | `baseh_minimum_v1()` | 36 | none | 2,176,782,336 |
72
+ | Light | `baseh_light_v1()` | 31 | 1 | 887,503,681 |
73
+ | Medium | `baseh_medium_v1()` | 28 | 1 | 481,890,304 |
74
+ | Heavy | `baseh_heavy_v1()` | 26 | 1 | 308,915,776 |
75
+
76
+ Medium is the default. All four keep the typed O/I/L aliases where possible
77
+ and run the default profanity blocklist. Minimum also uses a hyphen
78
+ delimiter (`XXX-XXX`); the rest have none.
79
+
80
+ Each helper returns a freshly-built mutable profile dict on every call, so
81
+ callers can load a default and modify it:
82
+
83
+ ```python
84
+ profile = baseh_medium_v1()
85
+ profile["checksumLength"] = 2
86
+ codec = Baseh(profile)
87
+ ```
88
+
89
+ ### Permuted variants
90
+
91
+ The `_p` variants are identical to their tier but enable feistel-v1
92
+ permutation and require caller-supplied key material:
93
+
94
+ ```python
95
+ from baseh import Baseh, baseh_medium_p_v1
96
+
97
+ key = bytes.fromhex("746573742d6f6e6c792d6b65792d6d6174657269616c2d30303031")
98
+ codec = Baseh(baseh_medium_p_v1(key, key_id="my-app-01")) # rounds defaults to 8
99
+ ```
100
+
101
+ Available as `baseh_minimum_p_v1`, `baseh_light_p_v1`, `baseh_medium_p_v1`
102
+ and `baseh_heavy_p_v1`.
103
+
104
+ ## Profanity safety (spec 18)
105
+
106
+ All frozen tiers run the default blocklist. Profiles accept a `profanity`
107
+ field to change that:
108
+
109
+ ```python
110
+ # Block specific words (substrings of the raw code) at encode time:
111
+ profile = baseh_medium_v1()
112
+ profile["profanity"] = {"mode": "blocklist", "extraWords": ["QQQQ"]}
113
+ codec = Baseh(profile)
114
+ # codec.encode(id) raises BasehError(code="BLOCKED_CODE") on a match.
115
+
116
+ # Or remove vowels from both alphabets entirely:
117
+ profile["profanity"] = {"mode": "no-vowels"}
118
+ ```
119
+
120
+ ## Tests
121
+
122
+ ```bash
123
+ cd python
124
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
125
+ ```
baseh-1.1.0/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # baseh
2
+
3
+ Python implementation of the baseH codec. Encodes an internal integer ID as
4
+ a checksummed, optionally permuted human-readable reference code. Implements
5
+ the normative spec in `../spec/IMPLEMENTATION_CODEC.md` and passes the
6
+ frozen cross-language vectors in `../vectors/`.
7
+
8
+ Zero runtime dependencies. HMAC-SHA-256 comes from the standard library.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install ./python
14
+ ```
15
+
16
+ Or run in place without installing:
17
+
18
+ ```bash
19
+ PYTHONPATH=python/src python3 -c "import baseh"
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```python
25
+ from baseh import Baseh, BasehError, baseh_medium_v1
26
+
27
+ codec = Baseh(baseh_medium_v1())
28
+
29
+ code = codec.encode(123456789)
30
+ print(code) # fixed-length, checksummed code
31
+
32
+ result = codec.decode(code.lower()) # case-insensitive
33
+ print(result.id) # 123456789
34
+ print(result.canonical_code) # canonical rendering
35
+ print(result.corrected) # False (input differed only in case)
36
+
37
+ # Assisted correction over spoken-confusion pairs (B/D, P/T, ...):
38
+ fixed = codec.decode(
39
+ code,
40
+ try_correction=True,
41
+ confusion_profile="light",
42
+ )
43
+
44
+ # Non-throwing validation for user input:
45
+ check = codec.validate("0000000")
46
+ print(check) # {"valid": False, "reason": "INVALID_CHECKSUM"}
47
+
48
+ print(codec.capacity()) # 481890304
49
+ ```
50
+
51
+ Errors raise `BasehError` with a `.code` attribute, one of:
52
+ `INVALID_PROFILE`, `OUT_OF_RANGE`, `PERMUTATION_FAILURE`, `INVALID_LENGTH`,
53
+ `INVALID_CHARACTER`, `INVALID_CHECKSUM`, `AMBIGUOUS_INPUT`,
54
+ `TOO_MANY_CANDIDATES`, `BLOCKED_CODE`.
55
+
56
+ ## Frozen tiers
57
+
58
+ Four frozen profiles, each 6 body symbols and case-insensitive, built from
59
+ the full alphanumeric set with cumulative visual and spoken strips:
60
+
61
+ | Tier | Helper | Symbols | Checksum | Capacity |
62
+ |------|--------|---------|----------|----------|
63
+ | Minimum | `baseh_minimum_v1()` | 36 | none | 2,176,782,336 |
64
+ | Light | `baseh_light_v1()` | 31 | 1 | 887,503,681 |
65
+ | Medium | `baseh_medium_v1()` | 28 | 1 | 481,890,304 |
66
+ | Heavy | `baseh_heavy_v1()` | 26 | 1 | 308,915,776 |
67
+
68
+ Medium is the default. All four keep the typed O/I/L aliases where possible
69
+ and run the default profanity blocklist. Minimum also uses a hyphen
70
+ delimiter (`XXX-XXX`); the rest have none.
71
+
72
+ Each helper returns a freshly-built mutable profile dict on every call, so
73
+ callers can load a default and modify it:
74
+
75
+ ```python
76
+ profile = baseh_medium_v1()
77
+ profile["checksumLength"] = 2
78
+ codec = Baseh(profile)
79
+ ```
80
+
81
+ ### Permuted variants
82
+
83
+ The `_p` variants are identical to their tier but enable feistel-v1
84
+ permutation and require caller-supplied key material:
85
+
86
+ ```python
87
+ from baseh import Baseh, baseh_medium_p_v1
88
+
89
+ key = bytes.fromhex("746573742d6f6e6c792d6b65792d6d6174657269616c2d30303031")
90
+ codec = Baseh(baseh_medium_p_v1(key, key_id="my-app-01")) # rounds defaults to 8
91
+ ```
92
+
93
+ Available as `baseh_minimum_p_v1`, `baseh_light_p_v1`, `baseh_medium_p_v1`
94
+ and `baseh_heavy_p_v1`.
95
+
96
+ ## Profanity safety (spec 18)
97
+
98
+ All frozen tiers run the default blocklist. Profiles accept a `profanity`
99
+ field to change that:
100
+
101
+ ```python
102
+ # Block specific words (substrings of the raw code) at encode time:
103
+ profile = baseh_medium_v1()
104
+ profile["profanity"] = {"mode": "blocklist", "extraWords": ["QQQQ"]}
105
+ codec = Baseh(profile)
106
+ # codec.encode(id) raises BasehError(code="BLOCKED_CODE") on a match.
107
+
108
+ # Or remove vowels from both alphabets entirely:
109
+ profile["profanity"] = {"mode": "no-vowels"}
110
+ ```
111
+
112
+ ## Tests
113
+
114
+ ```bash
115
+ cd python
116
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
117
+ ```
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "baseh"
7
+ version = "1.1.0"
8
+ description = "baseH codec: checksumed, optionally permuted human-readable identifiers"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "AGPL-3.0-only" }
12
+ dependencies = []
13
+
14
+ [tool.setuptools]
15
+ package-dir = { "" = "src" }
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
baseh-1.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,62 @@
1
+ """baseH codec, Python implementation.
2
+
3
+ Public API mirrors spec section 12: the Baseh codec class, BasehError with a
4
+ stable .code attribute and the frozen profile tier helpers (baseh_medium_v1
5
+ is the default tier; the _p variants enable feistel-v1 permutation).
6
+ """
7
+
8
+ from .blocklist import DEFAULT_BLOCKLIST
9
+ from .codec import CONFUSION_MAPS, Baseh, DecodeResult, generate_candidates
10
+ from .errors import (
11
+ AMBIGUOUS_INPUT,
12
+ BLOCKED_CODE,
13
+ INVALID_CHARACTER,
14
+ INVALID_CHECKSUM,
15
+ INVALID_LENGTH,
16
+ INVALID_PROFILE,
17
+ OUT_OF_RANGE,
18
+ PERMUTATION_FAILURE,
19
+ TOO_MANY_CANDIDATES,
20
+ BasehError,
21
+ )
22
+ from .profiles import (
23
+ baseh_heavy_p_v1,
24
+ baseh_heavy_v1,
25
+ baseh_light_p_v1,
26
+ baseh_light_v1,
27
+ baseh_medium_p_v1,
28
+ baseh_medium_v1,
29
+ baseh_minimum_p_v1,
30
+ baseh_minimum_v1,
31
+ )
32
+ from .zero import from_code, to_code
33
+
34
+ __all__ = [
35
+ "Baseh",
36
+ "BasehError",
37
+ "DecodeResult",
38
+ "CONFUSION_MAPS",
39
+ "DEFAULT_BLOCKLIST",
40
+ "generate_candidates",
41
+ "baseh_minimum_v1",
42
+ "baseh_minimum_p_v1",
43
+ "baseh_light_v1",
44
+ "baseh_light_p_v1",
45
+ "baseh_medium_v1",
46
+ "baseh_medium_p_v1",
47
+ "baseh_heavy_v1",
48
+ "baseh_heavy_p_v1",
49
+ "INVALID_PROFILE",
50
+ "OUT_OF_RANGE",
51
+ "PERMUTATION_FAILURE",
52
+ "INVALID_LENGTH",
53
+ "INVALID_CHARACTER",
54
+ "INVALID_CHECKSUM",
55
+ "AMBIGUOUS_INPUT",
56
+ "TOO_MANY_CANDIDATES",
57
+ "BLOCKED_CODE",
58
+ "to_code",
59
+ "from_code",
60
+ ]
61
+
62
+ __version__ = "1.0.0"
@@ -0,0 +1,36 @@
1
+ """Fixed-length base-N encode and decode, spec sections 5.1 through 5.3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .errors import INVALID_CHARACTER, OUT_OF_RANGE, BasehError
6
+
7
+
8
+ def alphabet_index(alphabet: str) -> dict:
9
+ return {ch: i for i, ch in enumerate(alphabet)}
10
+
11
+
12
+ def encode_base_n(value: int, alphabet: str, length: int) -> str:
13
+ """Fixed-length base-N encode, most significant digit first."""
14
+ base = len(alphabet)
15
+ capacity = base ** length
16
+ if value < 0 or value >= capacity:
17
+ raise BasehError(OUT_OF_RANGE, "value is outside the fixed-length capacity")
18
+ out = [""] * length
19
+ v = value
20
+ for pos in range(length - 1, -1, -1):
21
+ out[pos] = alphabet[v % base]
22
+ v //= base
23
+ return "".join(out)
24
+
25
+
26
+ def decode_base_n(text: str, alphabet: str, index: dict | None = None) -> int:
27
+ base = len(alphabet)
28
+ if index is None:
29
+ index = alphabet_index(alphabet)
30
+ value = 0
31
+ for ch in text:
32
+ digit = index.get(ch)
33
+ if digit is None:
34
+ raise BasehError(INVALID_CHARACTER, f"Symbol {ch!r} is not in the alphabet")
35
+ value = value * base + digit
36
+ return value
@@ -0,0 +1,41 @@
1
+ """Profanity safety primitives, spec section 18."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from .errors import INVALID_PROFILE, BasehError
8
+
9
+ # Spec 18.2 default list. Deliberately small; applications extend it.
10
+ DEFAULT_BLOCKLIST = (
11
+ "CRAP", "TWAT", "SHAG", "DAMN", "FCK", "FUC",
12
+ "SHT", "CNT", "TWT", "DCK", "AZZ", "BCH",
13
+ )
14
+
15
+ _WORD = re.compile(r"^[A-Za-z]{2,32}$")
16
+ _VOWELS = frozenset("AEIOU")
17
+
18
+
19
+ def effective_blocklist(profanity: dict) -> list:
20
+ """Spec 18.2: replacement semantics, then augmentation, uppercased and
21
+ deduplicated. Raises BasehError INVALID_PROFILE on a malformed entry."""
22
+ base = list(profanity["words"]) if "words" in profanity else list(DEFAULT_BLOCKLIST)
23
+ words = base + list(profanity.get("extraWords") or [])
24
+ out: list = []
25
+ for word in words:
26
+ if not isinstance(word, str) or not _WORD.match(word):
27
+ raise BasehError(
28
+ INVALID_PROFILE,
29
+ "Invalid baseH profile: blocklist entries must be 2 through 32 ASCII letters",
30
+ False,
31
+ )
32
+ upper = word.upper()
33
+ if upper not in out:
34
+ out.append(upper)
35
+ return out
36
+
37
+
38
+ def strip_vowels(alphabet_norm: str) -> str:
39
+ """Spec 18.1: vowels removed for no-vowels mode, applied after case
40
+ normalization."""
41
+ return "".join(ch for ch in alphabet_norm if ch not in _VOWELS)
@@ -0,0 +1,36 @@
1
+ """Version 1 rolling polynomial checksum, spec section 6.2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .basen import alphabet_index, encode_base_n
6
+ from .errors import INVALID_CHARACTER, BasehError
7
+ from .profile import PreparedProfile
8
+
9
+ _INITIAL_STATE = 17
10
+ _MULTIPLIER = 37
11
+
12
+
13
+ def checksum_value(profile: PreparedProfile, body: str, body_index: dict) -> int:
14
+ """Return the checksum value in [0, modulus)."""
15
+ modulus = profile.checksum_modulus
16
+ state = _INITIAL_STATE
17
+ for byte in profile.profile_id.encode("ascii"):
18
+ state = (state * _MULTIPLIER + byte + 1) % modulus
19
+ state = (state * _MULTIPLIER) % modulus
20
+ for pos, ch in enumerate(body):
21
+ value = body_index.get(ch)
22
+ if value is None:
23
+ raise BasehError(
24
+ INVALID_CHARACTER, f"Symbol {ch!r} is not in the body alphabet"
25
+ )
26
+ state = (state * _MULTIPLIER + value + pos + 1) % modulus
27
+ return state
28
+
29
+
30
+ def calculate_checksum(profile: PreparedProfile, body: str) -> str:
31
+ """Compute the expected checksum string for a normalized body."""
32
+ if profile.checksum_length == 0:
33
+ return ""
34
+ index = alphabet_index(profile.body_alphabet_norm)
35
+ value = checksum_value(profile, body, index)
36
+ return encode_base_n(value, profile.checksum_alphabet_norm, profile.checksum_length)
@@ -0,0 +1,242 @@
1
+ """Full encode and decode flows, spec sections 8, 9, 10, 11 and 12."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .basen import alphabet_index, decode_base_n, encode_base_n
8
+ from .checksum import calculate_checksum
9
+ from .errors import (
10
+ AMBIGUOUS_INPUT,
11
+ BLOCKED_CODE,
12
+ INVALID_CHARACTER,
13
+ INVALID_CHECKSUM,
14
+ INVALID_LENGTH,
15
+ OUT_OF_RANGE,
16
+ TOO_MANY_CANDIDATES,
17
+ BasehError,
18
+ )
19
+ from .feistel import FeistelKey, inverse_permute, permute
20
+ from .profile import PreparedProfile, prepare_profile
21
+
22
+ # Built-in spoken-confusion candidate maps, spec 3.3. Body symbols only.
23
+ CONFUSION_MAPS = {
24
+ "light": {"B": ["D"], "D": ["B"], "P": ["T"], "T": ["P"]},
25
+ "medium": {
26
+ "B": ["D"], "D": ["B"], "P": ["T"], "T": ["P"],
27
+ "M": ["N"], "N": ["M"], "V": ["W"], "W": ["V"],
28
+ },
29
+ "heavy": {
30
+ "B": ["D"], "D": ["B"], "P": ["T"], "T": ["P"],
31
+ "M": ["N"], "N": ["M"], "V": ["W"], "W": ["V"],
32
+ "F": ["S"], "S": ["F"], "C": ["G"], "G": ["C"],
33
+ },
34
+ }
35
+
36
+ _ASCII_WS = "\t\n\v\f\r "
37
+ _MAX_CANDIDATES = 64
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class DecodeResult:
42
+ id: int
43
+ canonical_code: str
44
+ corrected: bool
45
+
46
+
47
+ def normalize(input: str, profile: PreparedProfile, accept_spaces: bool = False) -> str:
48
+ """Spec 3.1 normalization, steps 1-7. Returns the raw unformatted string."""
49
+ if not isinstance(input, str):
50
+ raise BasehError(INVALID_CHARACTER, "input must be a string")
51
+ s = input.strip(_ASCII_WS)
52
+ if profile.separator:
53
+ s = s.replace(profile.separator, "")
54
+ if accept_spaces:
55
+ s = s.replace(" ", "")
56
+ if not profile.case_sensitive:
57
+ s = s.upper()
58
+ if profile.aliases_norm:
59
+ s = "".join(profile.aliases_norm.get(ch, ch) for ch in s)
60
+ allowed = set(profile.body_alphabet_norm) | set(profile.checksum_alphabet_norm)
61
+ for ch in s:
62
+ if ch not in allowed:
63
+ raise BasehError(INVALID_CHARACTER, f"Symbol {ch!r} is not accepted")
64
+ expected = profile.body_length + profile.checksum_length
65
+ # Spec 3.4: a code that lost leading zero body symbols is re-padded with
66
+ # the body zero symbol. The checksum symbols always remain, so the split
67
+ # point is unambiguous. A fully stripped no-checksum code would be empty
68
+ # and stays a length error.
69
+ if len(s) < expected and len(s) >= max(profile.checksum_length, 1):
70
+ zero = profile.body_alphabet_norm[0]
71
+ s = zero * (expected - len(s)) + s
72
+ if len(s) != expected:
73
+ raise BasehError(INVALID_LENGTH, f"Expected {expected} symbols, got {len(s)}")
74
+ return s
75
+
76
+
77
+ def format_raw(raw: str, profile: PreparedProfile) -> str:
78
+ if not profile.separator:
79
+ return raw
80
+ parts = []
81
+ offset = 0
82
+ for size in profile.grouping:
83
+ parts.append(raw[offset : offset + size])
84
+ offset += size
85
+ return profile.separator.join(parts)
86
+
87
+
88
+ def generate_candidates(body: str, confusion_map: dict, max_edits: int = 1) -> list:
89
+ """Spec 10. Substitution-only candidate generation, capped and deduplicated."""
90
+ if max_edits == 0:
91
+ return []
92
+ results: set = set()
93
+ for pos, source in enumerate(body):
94
+ for replacement in confusion_map.get(source, ()):
95
+ candidate = body[:pos] + replacement + body[pos + 1 :]
96
+ results.add(candidate)
97
+ if len(results) > _MAX_CANDIDATES:
98
+ raise BasehError(
99
+ TOO_MANY_CANDIDATES,
100
+ "Candidate generation exceeded 64 entries",
101
+ False,
102
+ )
103
+ return list(results)
104
+
105
+
106
+ class Baseh:
107
+ """Codec bound to one validated profile. The profile is validated once at
108
+ construction per spec 2.2, never per encode or decode."""
109
+
110
+ def __init__(self, profile: dict) -> None:
111
+ if isinstance(profile, PreparedProfile):
112
+ self._profile = profile
113
+ else:
114
+ self._profile = prepare_profile(profile)
115
+ self._body_index = alphabet_index(self._profile.body_alphabet_norm)
116
+
117
+ @property
118
+ def profile(self) -> PreparedProfile:
119
+ return self._profile
120
+
121
+ def capacity(self) -> int:
122
+ return self._profile.capacity
123
+
124
+ def _feistel_key(self) -> FeistelKey:
125
+ perm = self._profile.permutation
126
+ return FeistelKey(
127
+ profile_id=self._profile.profile_id,
128
+ key_bytes=perm.key_bytes,
129
+ rounds=perm.rounds,
130
+ )
131
+
132
+ def encode(self, id: int) -> str:
133
+ """Spec 8, including the section 18.2 blocked-substring scan."""
134
+ if isinstance(id, bool) or not isinstance(id, int):
135
+ raise BasehError(OUT_OF_RANGE, "id must be an integer")
136
+ value = id
137
+ if value < 0 or value >= self._profile.capacity:
138
+ raise BasehError(OUT_OF_RANGE, f"ID {value} is outside the profile capacity")
139
+ if self._profile.permutation.enabled:
140
+ value = permute(value, self._profile.capacity, self._feistel_key())
141
+ body = encode_base_n(
142
+ value, self._profile.body_alphabet_norm, self._profile.body_length
143
+ )
144
+ checksum = calculate_checksum(self._profile, body)
145
+ raw = body + checksum
146
+ # Spec 18.2: case-insensitive substring scan over the raw code.
147
+ if self._profile.blocklist:
148
+ upper = raw.upper()
149
+ if any(word in upper for word in self._profile.blocklist):
150
+ raise BasehError(
151
+ BLOCKED_CODE,
152
+ "The generated reference contains a blocked substring",
153
+ False,
154
+ )
155
+ return format_raw(raw, self._profile)
156
+
157
+ def decode(
158
+ self,
159
+ input: str,
160
+ *,
161
+ accept_spaces: bool = False,
162
+ try_correction: bool = False,
163
+ confusion_profile: str = "none",
164
+ max_corrections: int = 1,
165
+ ) -> DecodeResult:
166
+ """Spec 9."""
167
+ raw = normalize(input, self._profile, accept_spaces)
168
+ body = raw[: self._profile.body_length]
169
+ supplied_checksum = raw[self._profile.body_length :]
170
+
171
+ # Spec 3.1 validates union membership before the split. There is no
172
+ # per-region membership check: a checksum-region symbol outside the
173
+ # checksum alphabet fails as INVALID_CHECKSUM and a body symbol
174
+ # outside the body alphabet fails in the checksum or base-N work as
175
+ # INVALID_CHARACTER.
176
+
177
+ if calculate_checksum(self._profile, body) != supplied_checksum:
178
+ if not try_correction or max_corrections == 0:
179
+ raise BasehError(
180
+ INVALID_CHECKSUM, "The reference code did not pass validation"
181
+ )
182
+ if confusion_profile == "none":
183
+ raw_map: dict = {}
184
+ elif confusion_profile in CONFUSION_MAPS:
185
+ raw_map = CONFUSION_MAPS[confusion_profile]
186
+ else:
187
+ raise ValueError(
188
+ f"unknown confusion profile: {confusion_profile!r}"
189
+ )
190
+ # Spec 10: replacements that are not body alphabet symbols are
191
+ # dropped before candidate generation. A suggested symbol the
192
+ # alphabet cannot contain (say a spoken drop on a stripped-alphabet
193
+ # profile) could never validate; generating it anyway would raise
194
+ # INVALID_CHARACTER from the checksum step instead of reporting an
195
+ # honest INVALID_CHECKSUM.
196
+ body_set = set(self._profile.body_alphabet_norm)
197
+ confusion_map = {}
198
+ for source, replacements in raw_map.items():
199
+ kept = [r for r in replacements if r in body_set]
200
+ if kept:
201
+ confusion_map[source] = kept
202
+ valid: set = set()
203
+ for candidate in generate_candidates(body, confusion_map, max_corrections):
204
+ if calculate_checksum(self._profile, candidate) == supplied_checksum:
205
+ valid.add(candidate)
206
+ if not valid:
207
+ raise BasehError(
208
+ INVALID_CHECKSUM, "The reference code did not pass validation"
209
+ )
210
+ if len(valid) > 1:
211
+ raise BasehError(
212
+ AMBIGUOUS_INPUT,
213
+ "The reference code matches more than one record",
214
+ False,
215
+ )
216
+ body = next(iter(valid))
217
+
218
+ value = decode_base_n(
219
+ body, self._profile.body_alphabet_norm, self._body_index
220
+ )
221
+ if self._profile.permutation.enabled:
222
+ value = inverse_permute(
223
+ value, self._profile.capacity, self._feistel_key()
224
+ )
225
+ canonical_code = self.encode(value)
226
+ if self._profile.separator:
227
+ canonical_raw = canonical_code.replace(self._profile.separator, "")
228
+ else:
229
+ canonical_raw = canonical_code
230
+ return DecodeResult(
231
+ id=value,
232
+ canonical_code=canonical_code,
233
+ corrected=(raw != canonical_raw),
234
+ )
235
+
236
+ def validate(self, input: str, **options) -> dict:
237
+ """Spec 12.4. Never raises on user input."""
238
+ try:
239
+ result = self.decode(input, **options)
240
+ return {"valid": True, "canonical_code": result.canonical_code}
241
+ except BasehError as err:
242
+ return {"valid": False, "reason": err.code}