Skip to main content

md_codec/
bch_decode.rs

1//! Syndrome-based BCH decoder for the MD regular code.
2//!
3//! Forked from `mk-codec` v0.3.1 (`crates/mk-codec/src/string_layer/bch_decode.rs`)
4//! at v0.34.0 per plan §1 D22 + §2.B.1. The algorithm is constant-agnostic —
5//! the caller XORs the polymod residue against the per-HRP target constant
6//! ([`crate::bch::MD_REGULAR_CONST`]) before invoking [`decode_regular_errors`].
7//! The fork copy is expected to be retired once the `mc-codex32` shared-crate
8//! extraction lands (closure Q-9 trigger: both formats v1.0 with cross-
9//! validated conformance vectors).
10//!
11//! Drops the mk1-specific long-code path: md1 only defines the regular
12//! `BCH(93,80,8)` variant. The internal `decode_errors` helper is therefore
13//! also dropped — there is only one public entry point.
14//!
15//! ## Position indexing
16//!
17//! The polymod consumes symbols in the order
18//! `hrp_expand(hrp) || data || checksum`. If `n` is the total number of
19//! symbols fed, then symbol `i` (in feed order) is the coefficient of
20//! `x^{n-1-i}` in the input polynomial. Errors are constrained to the
21//! `data_with_checksum` segment (the HRP prefix is fixed-and-known).
22//! For `data_with_checksum.len() = L` (`L ≤ 93` regular), an error at
23//! index `k` of `data_with_checksum` lies at polynomial degree
24//! `d = L - 1 - k`. The Chien search returns degrees `d` and we translate
25//! to indices via `k = (L - 1) - d`.
26//!
27//! ## Local constants (Q3 lock, plan §2.B.1)
28//!
29//! `POLYMOD_INIT` / `REGULAR_SHIFT` / `REGULAR_MASK` from `bch.rs:19-21`
30//! are re-declared locally here rather than importing from `bch.rs`. Per
31//! the Q3 lock decision the bare-private internals of `bch.rs` stay
32//! bare-private; this module re-declares the small set it needs to keep
33//! the public API surface minimal. (These three values are not currently
34//! used by `bch_decode` itself — the polymod is run by the caller — but
35//! are kept here for parity with the mk-codec source and to support any
36//! future internal verification needs.)
37
38use crate::codex32::REGULAR_CODE_SYMBOLS_MAX;
39
40// ---------------------------------------------------------------------------
41// GF(32) — same field as `crate::bch::GEN_REGULAR` symbols.
42// ---------------------------------------------------------------------------
43
44/// One element of `GF(32) = GF(2)[α] / (α⁵ + α³ + 1)`, encoded as a
45/// 5-bit integer `0..32` whose binary digits are the polynomial
46/// coefficients (low bit = constant term).
47type Gf32 = u8;
48
49/// Primitive polynomial reduction mask for `GF(32)`: when a `GF(32)`
50/// multiplication overflows into bit 5, XOR with `0b00_1001 = 9` to fold
51/// `α⁵ ≡ α³ + 1` back into the residue.
52const GF32_REDUCE: u8 = 0b0_1001;
53
54/// Multiply two `GF(32)` elements (carryless multiply with reduction).
55const fn gf32_mul(a: Gf32, b: Gf32) -> Gf32 {
56    let mut result: u8 = 0;
57    let mut a = a;
58    let mut i = 0;
59    while i < 5 {
60        if (b >> i) & 1 != 0 {
61            result ^= a;
62        }
63        // Multiply a by α; reduce if it leaves the 5-bit window.
64        let carry = (a >> 4) & 1;
65        a = (a << 1) & 0x1F;
66        if carry != 0 {
67            a ^= GF32_REDUCE;
68        }
69        i += 1;
70    }
71    result
72}
73
74// ---------------------------------------------------------------------------
75// GF(1024) — built as GF(32²) via ζ² = ζ + 1
76// ---------------------------------------------------------------------------
77
78/// One element of `GF(1024)` as a pair `(lo, hi)` of `GF(32)` elements
79/// representing `lo + hi·ζ` where `ζ² = ζ + 1` (i.e., `ζ` is a
80/// primitive cube root of unity in `GF(1024)*`).
81#[derive(Copy, Clone, Debug, PartialEq, Eq)]
82struct Gf1024 {
83    lo: Gf32,
84    hi: Gf32,
85}
86
87impl Gf1024 {
88    const ZERO: Gf1024 = Gf1024 { lo: 0, hi: 0 };
89    const ONE: Gf1024 = Gf1024 { lo: 1, hi: 0 };
90
91    /// Embed a `GF(32)` element as the constant term.
92    const fn from_gf32(v: Gf32) -> Self {
93        Gf1024 { lo: v, hi: 0 }
94    }
95
96    fn add(self, other: Self) -> Self {
97        Gf1024 {
98            lo: self.lo ^ other.lo,
99            hi: self.hi ^ other.hi,
100        }
101    }
102
103    fn is_zero(self) -> bool {
104        self.lo == 0 && self.hi == 0
105    }
106
107    /// Multiply two `GF(1024)` elements using the field relation
108    /// `ζ² = ζ + 1`. Concretely:
109    ///
110    /// ```text
111    /// (lo + hi·ζ) · (lo' + hi'·ζ)
112    ///   = lo·lo' + (lo·hi' + hi·lo')·ζ + hi·hi'·ζ²
113    ///   = lo·lo' + (lo·hi' + hi·lo')·ζ + hi·hi'·(ζ + 1)
114    ///   = (lo·lo' + hi·hi') + (lo·hi' + hi·lo' + hi·hi')·ζ
115    /// ```
116    fn mul(self, other: Self) -> Self {
117        let ll = gf32_mul(self.lo, other.lo);
118        let lh = gf32_mul(self.lo, other.hi);
119        let hl = gf32_mul(self.hi, other.lo);
120        let hh = gf32_mul(self.hi, other.hi);
121        Gf1024 {
122            lo: ll ^ hh,
123            hi: lh ^ hl ^ hh,
124        }
125    }
126
127    fn pow(self, mut exp: u32) -> Self {
128        let mut base = self;
129        let mut result = Gf1024::ONE;
130        while exp > 0 {
131            if exp & 1 == 1 {
132                result = result.mul(base);
133            }
134            base = base.mul(base);
135            exp >>= 1;
136        }
137        result
138    }
139
140    fn inv(self) -> Self {
141        // Fermat: a^(2^10 - 2) = a^1022 = a^-1 in GF(1024)*.
142        debug_assert!(!self.is_zero(), "inv of zero in GF(1024)");
143        self.pow(1022)
144    }
145}
146
147/// `β = G·ζ = 8·ζ`, the primitive element for the **regular code**'s
148/// BCH-defining group. `β` has order 93. (BIP 93 §"Generation of valid
149/// checksum".)
150const BETA: Gf1024 = Gf1024 { lo: 0, hi: 8 };
151
152/// Smallest exponent in the 8-consecutive-roots window of the regular
153/// code's generator polynomial: `g_regular(β^j) = 0` for `j = 77, …, 84`.
154const REGULAR_J_START: u32 = 77;
155
156/// Regular-code BCH checksum length (in 5-bit symbols).
157const REGULAR_CHECKSUM_SYMBOLS: u32 = 13;
158
159// ---------------------------------------------------------------------------
160// Horner-form polynomial evaluation
161// ---------------------------------------------------------------------------
162
163/// Horner-form polynomial evaluation: GF(32)-coefficient polynomial at
164/// a GF(1024) point. `coeffs[i]` is the coefficient of `x^i`.
165fn horner(coeffs: &[Gf32], x: Gf1024) -> Gf1024 {
166    let mut acc = Gf1024::ZERO;
167    for &c in coeffs.iter().rev() {
168        acc = acc.mul(x).add(Gf1024::from_gf32(c));
169    }
170    acc
171}
172
173/// Horner-form polynomial evaluation: GF(1024)-coefficient polynomial
174/// at a GF(1024) point. `coeffs[i]` is the coefficient of `x^i`.
175fn horner_ext(coeffs: &[Gf1024], x: Gf1024) -> Gf1024 {
176    let mut acc = Gf1024::ZERO;
177    for &c in coeffs.iter().rev() {
178        acc = acc.mul(x).add(c);
179    }
180    acc
181}
182
183// ---------------------------------------------------------------------------
184// Syndromes
185// ---------------------------------------------------------------------------
186
187/// Compute the eight syndromes `S_m = E(β^{j_start + m - 1})` for
188/// `m = 1, …, 8`, where `E(x)` is the error polynomial (recoverable as
189/// the polymod residue minus the MD target constant). The remainder is
190/// already congruent to `E(x)` modulo `g_regular(x)`, so evaluating it at
191/// the generator's roots is equivalent to evaluating `E(x)` itself.
192fn compute_syndromes_regular(residue_xor_const: u128) -> [Gf1024; 8] {
193    // Unpack the remainder: 13 GF(32) coefficients packed with the
194    // highest-order coefficient (x^12) at bit 60 and the constant term
195    // (x^0) at bits 0..5.
196    let mut coeffs = [0u8; REGULAR_CHECKSUM_SYMBOLS as usize];
197    for (i, slot) in coeffs.iter_mut().enumerate() {
198        *slot = ((residue_xor_const >> (5 * i)) & 0x1F) as u8;
199    }
200
201    let mut syndromes = [Gf1024::ZERO; 8];
202    let alpha_j_start = BETA.pow(REGULAR_J_START);
203    let mut alpha_j = alpha_j_start;
204    for s in &mut syndromes {
205        *s = horner(&coeffs, alpha_j);
206        alpha_j = alpha_j.mul(BETA);
207    }
208    syndromes
209}
210
211// ---------------------------------------------------------------------------
212// Berlekamp–Massey
213// ---------------------------------------------------------------------------
214
215/// Berlekamp–Massey for BCH over `GF(1024)`. Returns the error-locator
216/// polynomial `Λ(x)` with `Λ(0) = 1`. `Λ` has degree equal to the
217/// number of errors when the received word is correctable.
218fn berlekamp_massey(syndromes: &[Gf1024; 8]) -> Vec<Gf1024> {
219    // Standard formulation (Massey 1969 / Lin & Costello §6.3, adapted
220    // for 0-indexed syndromes where syndromes[k] = S_{j_start + k}).
221    let n = syndromes.len();
222    let mut lam: Vec<Gf1024> = vec![Gf1024::ONE]; // current connection poly
223    let mut prev: Vec<Gf1024> = vec![Gf1024::ONE]; // last-updated connection poly
224    let mut l: usize = 0; // current LFSR length
225    let mut m: usize = 1; // shift since last update
226    let mut b = Gf1024::ONE; // discrepancy from last update
227
228    for k in 0..n {
229        // Discrepancy: d = syndromes[k] + sum_{i=1..L} lam[i] * syndromes[k-i]
230        let mut d = syndromes[k];
231        for i in 1..=l {
232            // i > k means k - i would underflow; skip rather than wrap.
233            // i >= lam.len() means lam[i] doesn't exist yet; same skip.
234            if i <= k && i < lam.len() {
235                d = d.add(lam[i].mul(syndromes[k - i]));
236            }
237        }
238
239        if d.is_zero() {
240            m += 1;
241        } else if 2 * l <= k {
242            // Length increases. New lam = lam - (d/b) * x^m * prev.
243            let t = lam.clone();
244            let scale = d.mul(b.inv());
245            let new_len = (lam.len()).max(prev.len() + m);
246            lam.resize(new_len, Gf1024::ZERO);
247            for (i, &p) in prev.iter().enumerate() {
248                let idx = i + m;
249                lam[idx] = lam[idx].add(scale.mul(p));
250            }
251            l = k + 1 - l;
252            prev = t;
253            b = d;
254            m = 1;
255        } else {
256            // Length stays the same. lam = lam - (d/b) * x^m * prev.
257            let scale = d.mul(b.inv());
258            let new_len = (lam.len()).max(prev.len() + m);
259            lam.resize(new_len, Gf1024::ZERO);
260            for (i, &p) in prev.iter().enumerate() {
261                let idx = i + m;
262                lam[idx] = lam[idx].add(scale.mul(p));
263            }
264            m += 1;
265        }
266    }
267
268    while lam.len() > 1 && lam.last().is_some_and(|x| x.is_zero()) {
269        lam.pop();
270    }
271    lam
272}
273
274// ---------------------------------------------------------------------------
275// Chien search + Forney
276// ---------------------------------------------------------------------------
277
278/// Search for the roots of `Λ(x)` among `β⁰, β⁻¹, …, β⁻⁽ᴸ⁻¹⁾`, where
279/// `L = data_with_checksum_len` (we restrict the search to legitimate
280/// error positions; HRP-prefix positions are not transmitted).
281///
282/// Returns the list of polynomial degrees `d ∈ [0, L)` such that
283/// `Λ(β⁻ᵈ) = 0`. Each such `d` is the polynomial degree of an error.
284/// Returns `None` if the number of distinct roots found does not equal
285/// `deg(Λ)`.
286fn chien_search(lambda: &[Gf1024], data_with_checksum_len: usize) -> Option<Vec<usize>> {
287    // cycle-4 M4 internal floor: β has order 93, so degrees d and d+93 alias
288    // for an over-93-symbol word. Never enter the unbounded scan out-of-domain
289    // (belt-and-suspenders beneath the typed chunk-boundary reject).
290    if data_with_checksum_len > REGULAR_CODE_SYMBOLS_MAX {
291        return None;
292    }
293    let deg = lambda.len() - 1;
294    if deg == 0 {
295        return Some(Vec::new());
296    }
297
298    let mut error_degrees = Vec::with_capacity(deg);
299    let beta_inv = BETA.inv();
300    let mut current = Gf1024::ONE; // β^0
301    for d in 0..data_with_checksum_len {
302        if horner_ext(lambda, current).is_zero() {
303            error_degrees.push(d);
304        }
305        current = current.mul(beta_inv);
306    }
307
308    if error_degrees.len() != deg {
309        return None;
310    }
311    Some(error_degrees)
312}
313
314/// Shifted Forney's algorithm: given `Λ(x)`, the syndromes (at
315/// `β^{j_start}, …, β^{j_start + 7}`), and the error degrees `d_k` such
316/// that `β^{-d_k}` are the roots of `Λ`, compute the GF(32) error
317/// magnitudes at each position.
318///
319/// Formula (with `j_start` shift):
320///
321/// ```text
322/// e_k = X_k^{1 - j_start} · Ω(X_k^{-1}) / Λ'(X_k^{-1})
323/// ```
324///
325/// where `X_k = β^{d_k}`, `Ω(x) ≡ S(x)·Λ(x) mod x^8`, and `Λ'(x)` is
326/// the formal derivative.
327///
328/// Returns `None` if any computed magnitude does not lie in the symbol
329/// field `GF(32)`.
330fn forney(
331    syndromes: &[Gf1024; 8],
332    lambda: &[Gf1024],
333    error_degrees: &[usize],
334) -> Option<Vec<Gf32>> {
335    // Ω(x) = S(x) * Λ(x) mod x^8, where S(x) = sum_{m=0..7} S_{j_start + m} * x^m.
336    let s_poly: Vec<Gf1024> = syndromes.to_vec();
337    let mut omega = vec![Gf1024::ZERO; 8];
338    for i in 0..s_poly.len().min(8) {
339        for j in 0..lambda.len() {
340            if i + j < 8 {
341                omega[i + j] = omega[i + j].add(s_poly[i].mul(lambda[j]));
342            }
343        }
344    }
345
346    // Λ'(x) = formal derivative. In characteristic 2 only odd-power
347    // terms survive: Λ'(x) = sum_{i odd} lambda[i] * x^{i-1}.
348    let mut lambda_prime = vec![Gf1024::ZERO; lambda.len().saturating_sub(1)];
349    for i in 1..lambda.len() {
350        if i % 2 == 1 {
351            lambda_prime[i - 1] = lambda[i];
352        }
353    }
354
355    let mut magnitudes = Vec::with_capacity(error_degrees.len());
356    for &d in error_degrees {
357        // X_k = β^d.
358        let x_k = BETA.pow(d as u32);
359        let x_k_inv = x_k.inv();
360        let omega_val = horner_ext(&omega, x_k_inv);
361        let lam_p_val = horner_ext(&lambda_prime, x_k_inv);
362        if lam_p_val.is_zero() {
363            return None;
364        }
365
366        // Compute X_k^{1 - j_start}. Note `1 - j_start` is negative;
367        // since X_k has order ord(β) = 93, we use
368        // X_k^{1 - j_start} = X_k^{(93 - j_start + 1) mod 93}.
369        // But we handle this generically via x_k_inv^{j_start - 1}.
370        let shift = REGULAR_J_START.saturating_sub(1);
371        let x_k_shift = x_k_inv.pow(shift); // = X_k^{-(j_start - 1)} = X_k^{1 - j_start}
372
373        let mag = x_k_shift.mul(omega_val.mul(lam_p_val.inv()));
374
375        // Magnitude must lie in GF(32) (the high coefficient must be zero).
376        if mag.hi != 0 {
377            return None;
378        }
379        if mag.lo == 0 {
380            // Zero magnitude is not a real error — typically signals
381            // more than 4 actual errors that fooled BM.
382            return None;
383        }
384        magnitudes.push(mag.lo);
385    }
386    Some(magnitudes)
387}
388
389// ---------------------------------------------------------------------------
390// Public entry point
391// ---------------------------------------------------------------------------
392
393/// Decode a regular-code BCH error pattern. Inputs:
394///
395/// - `residue_xor_const`: the value
396///   `polymod(hrp_expand("md") || data_with_checksum) ⊕ MD_REGULAR_CONST`.
397///   By the BCH syndrome property, this is congruent to the error
398///   polynomial `E(x)` modulo `g_regular(x)`. The caller is responsible
399///   for running [`crate::bch::polymod_run`] on the full
400///   `hrp_expand(...) || data_with_checksum` slice and XOR-ing the
401///   per-HRP target constant before passing the result here.
402/// - `data_with_checksum_len`: the total symbol count of
403///   `data_with_checksum` (in the `0..=93` range for the regular code).
404///
405/// Returns `Some((positions, magnitudes))` if the algorithm finds a
406/// consistent error pattern of weight `≤ 4`. Each `positions[k]` is an
407/// index into `data_with_checksum` (post-HRP-prefix); each
408/// `magnitudes[k]` is a `GF(32)` symbol that must be XORed into
409/// `data_with_checksum[positions[k]]` to repair the codeword. Returns
410/// `None` if the pattern is uncorrectable (> t = 4 errors).
411pub fn decode_regular_errors(
412    residue_xor_const: u128,
413    data_with_checksum_len: usize,
414) -> Option<(Vec<usize>, Vec<Gf32>)> {
415    // cycle-4 M4 internal floor: reject out-of-domain lengths (> 93) before the
416    // syndrome/correction machinery, so an over-length word can never alias
417    // into a wrong correction. The typed user-facing reject lives at the
418    // `chunk::decode_with_correction` boundary; this is the belt-and-suspenders
419    // internal guard for any caller that bypassed it.
420    if data_with_checksum_len > REGULAR_CODE_SYMBOLS_MAX {
421        return None;
422    }
423    let syndromes = compute_syndromes_regular(residue_xor_const);
424
425    // All-zero syndromes ⇒ no errors (caller usually detects earlier).
426    if syndromes.iter().all(|s| s.is_zero()) {
427        return Some((Vec::new(), Vec::new()));
428    }
429
430    let lambda = berlekamp_massey(&syndromes);
431    let deg = lambda.len() - 1;
432    if deg == 0 || deg > 4 {
433        // > 4 errors is above the BCH(93, 80, 8) / t = 4 capacity.
434        return None;
435    }
436
437    let error_degrees = chien_search(&lambda, data_with_checksum_len)?;
438    if error_degrees.len() != deg {
439        return None;
440    }
441
442    let magnitudes = forney(&syndromes, &lambda, &error_degrees)?;
443
444    // Translate polynomial degrees back to data_with_checksum indices.
445    // For data_with_checksum[k] (k = 0..L-1), polynomial degree d = L - 1 - k.
446    // So k = L - 1 - d.
447    let mut positions = Vec::with_capacity(error_degrees.len());
448    for &d in &error_degrees {
449        if d >= data_with_checksum_len {
450            // Should not happen since chien_search bounds d to [0, L).
451            return None;
452        }
453        let k = data_with_checksum_len - 1 - d;
454        positions.push(k);
455    }
456
457    // Sort ascending by position for deterministic output. Magnitudes
458    // need to be reordered along with the positions.
459    let mut paired: Vec<(usize, Gf32)> = positions.into_iter().zip(magnitudes).collect();
460    paired.sort_by_key(|p| p.0);
461    let positions: Vec<usize> = paired.iter().map(|p| p.0).collect();
462    let magnitudes: Vec<Gf32> = paired.iter().map(|p| p.1).collect();
463
464    Some((positions, magnitudes))
465}
466
467// ---------------------------------------------------------------------------
468// Unit tests (algorithmic sanity; integration cells live in tests/bch_decode.rs)
469// ---------------------------------------------------------------------------
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::bch::{MD_REGULAR_CONST, bch_create_checksum_regular, hrp_expand, polymod_run};
475
476    #[test]
477    fn gf32_mul_identity() {
478        for v in 0..32u8 {
479            assert_eq!(gf32_mul(v, 1), v);
480            assert_eq!(gf32_mul(1, v), v);
481        }
482    }
483
484    #[test]
485    fn gf32_mul_zero() {
486        for v in 0..32u8 {
487            assert_eq!(gf32_mul(v, 0), 0);
488            assert_eq!(gf32_mul(0, v), 0);
489        }
490    }
491
492    #[test]
493    fn beta_has_order_93_regular() {
494        // β = G·ζ has order 93 (BIP 93 §"Generation of valid checksum").
495        let mut p = Gf1024::ONE;
496        for j in 1..=93 {
497            p = p.mul(BETA);
498            if p == Gf1024::ONE {
499                assert_eq!(j, 93, "β prematurely returned to 1 at exponent {}", j);
500            }
501        }
502        assert_eq!(p, Gf1024::ONE, "β^93 should equal 1");
503    }
504
505    #[test]
506    fn one_error_decodes_correctly_regular() {
507        let hrp = "md";
508        let data: Vec<u8> = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
509        let checksum = bch_create_checksum_regular(hrp, &data);
510        let mut codeword = data.clone();
511        codeword.extend_from_slice(&checksum);
512        let original = codeword.clone();
513
514        let err_pos = 5;
515        let err_mag: u8 = 0b10101;
516        codeword[err_pos] ^= err_mag;
517
518        let mut input = hrp_expand(hrp);
519        input.extend_from_slice(&codeword);
520        let polymod = polymod_run(&input);
521        let residue = polymod ^ MD_REGULAR_CONST;
522
523        let (positions, magnitudes) =
524            decode_regular_errors(residue, codeword.len()).expect("1-error must decode");
525        assert_eq!(positions, vec![err_pos]);
526        assert_eq!(magnitudes, vec![err_mag]);
527
528        let mut corrected = codeword.clone();
529        for (p, m) in positions.iter().zip(&magnitudes) {
530            corrected[*p] ^= m;
531        }
532        assert_eq!(corrected, original);
533    }
534
535    #[test]
536    fn two_errors_decode_correctly_regular() {
537        let hrp = "md";
538        let data: Vec<u8> = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
539        let checksum = bch_create_checksum_regular(hrp, &data);
540        let mut codeword = data.clone();
541        codeword.extend_from_slice(&checksum);
542        let original = codeword.clone();
543
544        let positions_in: [usize; 2] = [3, 17];
545        let mags_in: [u8; 2] = [0b11001, 0b00111];
546        for (&p, &m) in positions_in.iter().zip(&mags_in) {
547            codeword[p] ^= m;
548        }
549
550        let mut input = hrp_expand(hrp);
551        input.extend_from_slice(&codeword);
552        let polymod = polymod_run(&input);
553        let residue = polymod ^ MD_REGULAR_CONST;
554
555        let (positions, magnitudes) =
556            decode_regular_errors(residue, codeword.len()).expect("2-error must decode");
557        assert_eq!(positions, vec![3, 17]);
558        assert_eq!(magnitudes, vec![mags_in[0], mags_in[1]]);
559
560        let mut corrected = codeword.clone();
561        for (p, m) in positions.iter().zip(&magnitudes) {
562            corrected[*p] ^= m;
563        }
564        assert_eq!(corrected, original);
565    }
566
567    // ── M4 (cycle-4): decode-side `len > 93` internal None-floor ──────────────
568    // β has order 93, so degrees `d` and `d + 93` alias in chien_search for an
569    // over-93-symbol word. The correcting decoder must never enter its unbounded
570    // loop out-of-domain: both decode_regular_errors and chien_search return
571    // None for `data_with_checksum_len > 93` (belt-and-suspenders floors beneath
572    // the typed chunk-boundary reject in chunk.rs).
573
574    #[test]
575    fn decode_regular_errors_returns_none_for_len_over_93() {
576        // Forge a genuine single-error residue over a 94-symbol "codeword"
577        // (81 data + 13 checksum = 94 > 93). Without the length floor this
578        // residue produces a valid degree-1 locator and decode proceeds into
579        // chien_search — where degree d and d+93 alias. The floor must return
580        // None on length alone, BEFORE that aliasing path.
581        let hrp = "md";
582        let data: Vec<u8> = (0..81u8).map(|i| i & 0x1F).collect();
583        let checksum = bch_create_checksum_regular(hrp, &data);
584        let mut codeword = data.clone();
585        codeword.extend_from_slice(&checksum);
586        assert_eq!(codeword.len(), 94, "forged codeword must be 94 symbols");
587        codeword[5] ^= 0b10101; // single-symbol error → residue != 0
588
589        let mut input = hrp_expand(hrp);
590        input.extend_from_slice(&codeword);
591        let residue = polymod_run(&input) ^ MD_REGULAR_CONST;
592        assert_ne!(residue, 0, "single error must yield a non-zero residue");
593
594        assert!(
595            decode_regular_errors(residue, codeword.len()).is_none(),
596            "len=94 (> 93) must return None before the aliasing chien_search"
597        );
598    }
599
600    #[test]
601    fn chien_search_returns_none_for_len_over_93() {
602        // A degree-1 locator (deg == 1) searched over an out-of-domain length
603        // must return None rather than scan the aliasing loop. lambda = [1, x]
604        // (any non-trivial locator); the floor fires on length alone.
605        let lambda = vec![Gf1024::ONE, BETA];
606        assert!(
607            chien_search(&lambda, 94).is_none(),
608            "len=94 (> 93) must return None before the unbounded loop"
609        );
610    }
611}