diff --git a/docs/security/model.md b/docs/security/model.md index 6eabc340..57bc8bfc 100644 --- a/docs/security/model.md +++ b/docs/security/model.md @@ -154,7 +154,7 @@ output is an untrusted proposal, never authenticated recovery material. |---|---| | Context | The syntactically present HRP, separator, and program-supplied context are immutable and outside the correction domain. Unknown HRPs are never corrected or ranked toward registered namespaces. | | Target lengths | `ms` searches only 48, 54, 61, 67, 74, or 127 characters; an accepted first string fixes the length of later strings in that recovery set. | -| Character alignment | `A = I + O + AT + 2*T <= 4`; the public API retains its `A<=2` required baseline. CLI scheduling prioritizes single structural mistakes, paired indels, then other combinations. | +| Character alignment | `A = I + O + AT + 2*T <= 4`; the public API retains its `A<=2` required baseline. CLI scheduling prioritizes single structural mistakes and bounded two-group erasure probes, then paired indels and other combinations. | | Group alignment | `G = GI + GO + GS + GAT + 2*GT <= 2`; only complete displayed four-character groups participate, and character/group families never mix. | | Fixed erasures | BCH repairs substitutions and explicit/generated erasures after alignment, with `E + 2S <= 8`; fixed consecutive erasures retain the 13/15-symbol linear path. | | False reconstruction | All target lengths and all admitted fixed/character/group layers share cumulative mass at or below `1`, including fixed consecutive erasures. | diff --git a/src/codex32/_competitors.py b/src/codex32/_competitors.py index b5895a9b..c0fda69d 100644 --- a/src/codex32/_competitors.py +++ b/src/codex32/_competitors.py @@ -23,7 +23,8 @@ def _check_deadline(deadline: float) -> None: def _tier(shape: _StructuralClass) -> int: - if len(shape.operations) == 1: + # Two complete groups take only a bounded set of eight-erasure BCH probes. + if len(shape.operations) == 1 or (shape.unit == 4 and shape.corrupted == 2): return 0 return 1 if shape.inserted == shape.omitted == 1 and len(shape.operations) == 2 else 2 diff --git a/tests/test_correction_indel.py b/tests/test_correction_indel.py index e9c88e4a..4ce47fe4 100644 --- a/tests/test_correction_indel.py +++ b/tests/test_correction_indel.py @@ -3,6 +3,7 @@ from dataclasses import replace from itertools import combinations from math import comb +from time import monotonic from unittest.mock import patch import pytest @@ -10,7 +11,7 @@ from test_bip39 import BIP39_12W_ZERO, BIP39_24W_ZERO from codex32 import CorrectionContext, MasterSeed, Profile, correct -from codex32._cli_input import _correction_plan, _fingerprint_matcher +from codex32._cli_input import _correction_candidates, _correction_plan, _fingerprint_matcher from codex32.correction import CorrectionCandidate, _best, _capture_volume, _erasure_state, _primary from codex32.generation import _fingerprint_identifier from codex32.indel import ( @@ -171,6 +172,23 @@ def test_automatic_secondary_search_recovers_two_group_indels( assert [candidate.artifact.text for candidate in candidates] == [source] +def test_cli_probes_two_corrupted_groups_within_search_deadline() -> None: + source = MasterSeed.from_seed(bytes(range(16)), identifier="test").text + damaged = source[:12] + "pppp" + source[16:24] + "qqqq" + source[28:] + + candidates, _complete, _deadline = _correction_candidates( + damaged, + Profile.MS, + None, + "ms1", + target=len(source), + deadline=monotonic() + 3, + ) + + assert [candidate.artifact.text for candidate in candidates] == [source] + assert candidates[0].erasures_filled == 8 + + def test_group_class_set_and_first_share_counts_are_exact() -> None: assert {(s.inserted, s.omitted, s.corrupted, s.adjacent, s.distant) for s in _GROUP_CLASSES} == { (i, o, gs, at, t)