mfsk_core/fec/rs/mod.rs
1//! Reed-Solomon RS(63, 12) over GF(2^6) — the codec used by JT65.
2//!
3//! Ported from Phil Karn's classic Reed-Solomon library (as
4//! wrapped in WSJT-X `wrapkarn.c` + `init_rs.c` / `encode_rs.c` /
5//! `decode_rs.c`). Parameters match the `init_rs_int(6, 0x43, 3, 1,
6//! 51, 0)` call used for JT65:
7//!
8//! - symbol size: 6 bits (field: GF(2^6), 63 nonzero elements)
9//! - field generator polynomial: `x^6 + x + 1` (0x43)
10//! - first consecutive root of generator polynomial: `α^3`
11//! - primitive element for root stride: 1
12//! - number of parity symbols (generator poly roots): 51
13//! - code: (63, 12), corrects up to `⌊(63 − 12) / 2⌋ = 25` symbol errors
14//!
15//! ## Symbol ordering
16//!
17//! This module provides two entry-point pairs:
18//!
19//! - [`Rs63_12::encode_native`] / [`Rs63_12::decode_native`] — the
20//! canonical Karn layout: codeword = `[data12 || parity51]` with
21//! data / parity each in their original order. General-purpose.
22//! - [`Rs63_12::encode_jt65`] / [`Rs63_12::decode_jt65`] — the
23//! JT65-specific byte ordering used by WSJT-X (`wrapkarn.c`):
24//! `sent[0..51]` is the parity block, reversed; `sent[51..63]`
25//! is the data block, reversed. Use these from `jt65-core`.
26//!
27//! This type implements [`super::FecCodec`] only minimally: the
28//! `encode` path packs a bit-level 72-bit info into 378 codeword bits
29//! (63 × 6 bits), and `decode_soft` always returns `None` because
30//! Reed-Solomon needs hard symbols rather than bit LLRs. The real
31//! RS entry points are [`Rs63_12::encode_native`] /
32//! [`Rs63_12::decode_native`] (and the JT65-specific reversed-layout
33//! variants). The FecCodec impl exists so `Rs63_12` can be named as
34//! a `Protocol::Fec` associated type; callers that want to actually
35//! decode JT65 should go through `jt65-core`'s decode helpers rather
36//! than the generic `decode_frame` pipeline.
37
38/// Sentinel used to mark "log of zero" in the index_of table. Matches
39/// Karn's `A0 = NN` convention: any log value equal to `A0` represents
40/// the field zero.
41const A0: u8 = Rs63_12::NN as u8;
42
43/// Encoder / decoder for Reed-Solomon (63, 12) over GF(2^6).
44#[derive(Clone, Debug)]
45pub struct Rs63_12 {
46 /// `alpha_to[i] = α^i` for `0 ≤ i < 63`, 0 otherwise.
47 alpha_to: [u8; 64],
48 /// `index_of[x] = log_α(x)` for x ≠ 0, [`A0`] for x = 0.
49 index_of: [u8; 64],
50 /// Generator polynomial in index form.
51 genpoly: [u8; Rs63_12::NROOTS + 1],
52}
53
54impl Default for Rs63_12 {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl Rs63_12 {
61 /// Code length in symbols (2^6 − 1 = 63).
62 pub const NN: usize = 63;
63 /// Symbol size in bits.
64 pub const MM: u32 = 6;
65 /// First consecutive root of generator polynomial: `α^FCR = α^3`.
66 pub const FCR: u32 = 3;
67 /// Primitive element for generator roots (α^(FCR·PRIM + i·PRIM)).
68 pub const PRIM: u32 = 1;
69 /// Number of parity symbols.
70 pub const NROOTS: usize = 51;
71 /// Info-symbol count (NN − NROOTS).
72 pub const K_SYMBOLS: usize = Self::NN - Self::NROOTS;
73 /// Full codeword length in symbols.
74 pub const N_SYMBOLS: usize = Self::NN;
75 /// Field generator polynomial (x^6 + x + 1).
76 const GFPOLY: u32 = 0x43;
77
78 /// Build the codec: pre-compute alpha_to, index_of, and genpoly.
79 pub fn new() -> Self {
80 let mut alpha_to = [0u8; 64];
81 let mut index_of = [0u8; 64];
82
83 // Galois field tables.
84 index_of[0] = A0; // log(0) = −∞
85 alpha_to[A0 as usize] = 0; // α^{−∞} = 0
86 let mut sr: u32 = 1;
87 for i in 0..Self::NN {
88 index_of[sr as usize] = i as u8;
89 alpha_to[i] = sr as u8;
90 sr <<= 1;
91 if sr & (1 << Self::MM) != 0 {
92 sr ^= Self::GFPOLY;
93 }
94 sr &= Self::NN as u32;
95 }
96 debug_assert_eq!(sr, 1, "gfpoly must be primitive");
97
98 // Generator polynomial g(x) = Π (x − α^(FCR + i)·PRIM) for i=0..NROOTS.
99 let mut genpoly = [0u8; Self::NROOTS + 1];
100 genpoly[0] = 1;
101 for i in 0..Self::NROOTS {
102 let root = Self::FCR * Self::PRIM + i as u32 * Self::PRIM;
103 genpoly[i + 1] = 1;
104 // Multiply by (x + α^root). In the poly loop below, j descends.
105 for j in (1..=i).rev() {
106 if genpoly[j] != 0 {
107 let idx = Self::modnn(index_of[genpoly[j] as usize] as u32 + root);
108 genpoly[j] = genpoly[j - 1] ^ alpha_to[idx as usize];
109 } else {
110 genpoly[j] = genpoly[j - 1];
111 }
112 }
113 // genpoly[0] is always nonzero at this stage.
114 let idx = Self::modnn(index_of[genpoly[0] as usize] as u32 + root);
115 genpoly[0] = alpha_to[idx as usize];
116 }
117 // Convert genpoly to index form for faster encoding.
118 for g in genpoly.iter_mut() {
119 *g = index_of[*g as usize];
120 }
121
122 Self {
123 alpha_to,
124 index_of,
125 genpoly,
126 }
127 }
128
129 /// `x mod NN` via a subtract-in-a-loop that terminates in at most
130 /// one iteration for inputs `< 2·NN`.
131 #[inline]
132 fn modnn(mut x: u32) -> u32 {
133 while x >= Self::NN as u32 {
134 x -= Self::NN as u32;
135 x = (x >> Self::MM) + (x & Self::NN as u32);
136 }
137 x
138 }
139
140 /// Systematic encode: 12 info symbols → 51 parity symbols. Layout
141 /// in the native Karn order is `[info[0..12] || parity[0..51]]`.
142 pub fn encode_native(&self, info: &[u8; Self::K_SYMBOLS]) -> [u8; Self::N_SYMBOLS] {
143 let mut bb = [0u8; Self::NROOTS];
144 for i in 0..Self::K_SYMBOLS {
145 let feedback = self.index_of[(info[i] ^ bb[0]) as usize];
146 if feedback != A0 {
147 for j in 1..Self::NROOTS {
148 bb[j] ^= self.alpha_to[Self::modnn(
149 feedback as u32 + self.genpoly[Self::NROOTS - j] as u32,
150 ) as usize];
151 }
152 }
153 // Shift bb left by one.
154 for j in 0..Self::NROOTS - 1 {
155 bb[j] = bb[j + 1];
156 }
157 bb[Self::NROOTS - 1] = if feedback != A0 {
158 self.alpha_to[Self::modnn(feedback as u32 + self.genpoly[0] as u32) as usize]
159 } else {
160 0
161 };
162 }
163 let mut out = [0u8; Self::N_SYMBOLS];
164 out[..Self::K_SYMBOLS].copy_from_slice(info);
165 out[Self::K_SYMBOLS..].copy_from_slice(&bb);
166 out
167 }
168
169 /// Decode a received codeword in the native Karn layout with no
170 /// erasures. Returns `Some((corrected, err_count))` on success,
171 /// `None` when uncorrectable.
172 pub fn decode_native(
173 &self,
174 data: &[u8; Self::N_SYMBOLS],
175 ) -> Option<([u8; Self::K_SYMBOLS], u32)> {
176 self.decode_native_erasures(data, &[])
177 }
178
179 /// Like [`Self::decode_native`] but also accepts a list of **erasure
180 /// positions** (symbol indices 0..=62 in the native codeword
181 /// layout that the caller has flagged as unreliable). Each
182 /// erasure lets RS correct one more symbol than the
183 /// ⌊(NROOTS)/2⌋ = 25 hard-error bound: the combined limit is
184 /// `2·errors + erasures ≤ NROOTS = 51`. Passing erasures is
185 /// particularly helpful at low SNR where the demodulator has
186 /// per-symbol confidence information.
187 ///
188 /// Ported from Phil Karn's `decode_rs.c` with the `no_eras > 0`
189 /// branch active. Duplicate or out-of-range entries in
190 /// `eras_pos` will produce `None` from the Chien search.
191 pub fn decode_native_erasures(
192 &self,
193 data: &[u8; Self::N_SYMBOLS],
194 eras_pos: &[u32],
195 ) -> Option<([u8; Self::K_SYMBOLS], u32)> {
196 let no_eras = eras_pos.len();
197 if no_eras > Self::NROOTS {
198 return None; // more erasures than parity — uncorrectable a priori
199 }
200 let mut recd = *data;
201
202 // 1. Syndromes — evaluate recd(x) at α^(FCR + i·PRIM) for i=0..NROOTS.
203 let mut s = [0u8; Self::NROOTS];
204 for i in 0..Self::NROOTS {
205 s[i] = recd[0];
206 }
207 for j in 1..Self::NN {
208 for i in 0..Self::NROOTS {
209 if s[i] == 0 {
210 s[i] = recd[j];
211 } else {
212 let sidx =
213 self.index_of[s[i] as usize] as u32 + (Self::FCR + i as u32) * Self::PRIM;
214 s[i] = recd[j] ^ self.alpha_to[Self::modnn(sidx) as usize];
215 }
216 }
217 }
218
219 // Convert syndromes to index form + detect non-zero syndrome.
220 let mut syn_error: u8 = 0;
221 for i in 0..Self::NROOTS {
222 syn_error |= s[i];
223 s[i] = self.index_of[s[i] as usize];
224 }
225 if syn_error == 0 {
226 let mut info = [0u8; Self::K_SYMBOLS];
227 info.copy_from_slice(&recd[..Self::K_SYMBOLS]);
228 return Some((info, 0));
229 }
230
231 // 2. Berlekamp-Massey. When erasures are supplied, initialise
232 // λ(x) to the erasure locator polynomial
233 // λ(x) = Π (1 + β_j·x), β_j = α^(PRIM·(NN−1−pos_j))
234 // and start BM at r = el = no_eras.
235 let mut lambda = [0u8; Self::NROOTS + 1];
236 lambda[0] = 1;
237 let mut b = [0u8; Self::NROOTS + 1];
238 let mut t = [0u8; Self::NROOTS + 1];
239
240 if no_eras > 0 {
241 for &pos in eras_pos {
242 if pos as usize >= Self::NN {
243 return None;
244 }
245 }
246 let e0 = Self::modnn(Self::PRIM * (Self::NN as u32 - 1 - eras_pos[0]));
247 lambda[1] = self.alpha_to[e0 as usize];
248 for i in 1..no_eras {
249 let u = Self::modnn(Self::PRIM * (Self::NN as u32 - 1 - eras_pos[i]));
250 for j in (1..=i + 1).rev() {
251 let tmp = self.index_of[lambda[j - 1] as usize];
252 if tmp != A0 {
253 lambda[j] ^= self.alpha_to[Self::modnn(u + tmp as u32) as usize];
254 }
255 }
256 }
257 }
258
259 for i in 0..Self::NROOTS + 1 {
260 b[i] = self.index_of[lambda[i] as usize];
261 }
262
263 let mut el: i32 = no_eras as i32;
264 for r in (no_eras + 1)..=Self::NROOTS {
265 // Discrepancy at step r (in poly form).
266 let mut discr_r: u8 = 0;
267 for i in 0..r {
268 if lambda[i] != 0 && s[r - i - 1] != A0 {
269 let idx = self.index_of[lambda[i] as usize] as u32 + s[r - i - 1] as u32;
270 discr_r ^= self.alpha_to[Self::modnn(idx) as usize];
271 }
272 }
273 let discr_idx = self.index_of[discr_r as usize];
274 if discr_idx == A0 {
275 // B(x) ← x·B(x)
276 for j in (1..=Self::NROOTS).rev() {
277 b[j] = b[j - 1];
278 }
279 b[0] = A0;
280 } else {
281 // T(x) ← λ(x) − discr_r·x·B(x)
282 t[0] = lambda[0];
283 for i in 0..Self::NROOTS {
284 if b[i] != A0 {
285 t[i + 1] = lambda[i + 1]
286 ^ self.alpha_to[Self::modnn(discr_idx as u32 + b[i] as u32) as usize];
287 } else {
288 t[i + 1] = lambda[i + 1];
289 }
290 }
291 // With erasures the BM invariant becomes
292 // 2·el ≤ r + no_eras − 1.
293 if 2 * el < r as i32 + no_eras as i32 {
294 el = r as i32 + no_eras as i32 - el;
295 for i in 0..=Self::NROOTS {
296 b[i] = if lambda[i] == 0 {
297 A0
298 } else {
299 Self::modnn(
300 self.index_of[lambda[i] as usize] as u32 + Self::NN as u32
301 - discr_idx as u32,
302 ) as u8
303 };
304 }
305 } else {
306 for j in (1..=Self::NROOTS).rev() {
307 b[j] = b[j - 1];
308 }
309 b[0] = A0;
310 }
311 lambda.copy_from_slice(&t);
312 }
313 }
314
315 // deg(λ) and conversion to index form.
316 let mut deg_lambda = 0usize;
317 let mut lambda_idx = [0u8; Self::NROOTS + 1];
318 for i in 0..Self::NROOTS + 1 {
319 lambda_idx[i] = self.index_of[lambda[i] as usize];
320 if lambda_idx[i] != A0 {
321 deg_lambda = i;
322 }
323 }
324
325 // 3. Chien search for roots of λ(x).
326 let iprim: u32 = Self::find_iprim();
327 let mut reg = [0u8; Self::NROOTS + 1];
328 reg[1..].copy_from_slice(&lambda_idx[1..]);
329 let mut root = [0u32; Self::NROOTS];
330 let mut loc = [0u32; Self::NROOTS];
331 let mut count = 0usize;
332 let mut k_idx: u32 = Self::modnn(iprim + Self::NN as u32 - 1);
333 for i in 1..=Self::NN as u32 {
334 let mut q: u8 = 1;
335 for j in (1..=deg_lambda).rev() {
336 if reg[j] != A0 {
337 reg[j] = Self::modnn(reg[j] as u32 + j as u32) as u8;
338 q ^= self.alpha_to[reg[j] as usize];
339 }
340 }
341 if q != 0 {
342 k_idx = Self::modnn(k_idx + iprim);
343 continue;
344 }
345 root[count] = i;
346 loc[count] = k_idx;
347 count += 1;
348 if count == deg_lambda {
349 break;
350 }
351 k_idx = Self::modnn(k_idx + iprim);
352 }
353 if deg_lambda != count {
354 return None; // uncorrectable
355 }
356
357 // 4. Compute ω(x) = s(x)·λ(x) mod x^NROOTS.
358 let deg_omega = deg_lambda.saturating_sub(1);
359 let mut omega = [0u8; Self::NROOTS + 1];
360 for i in 0..=deg_omega {
361 let mut tmp: u8 = 0;
362 for j in 0..=i {
363 if s[i - j] != A0 && lambda_idx[j] != A0 {
364 tmp ^=
365 self.alpha_to[Self::modnn(s[i - j] as u32 + lambda_idx[j] as u32) as usize];
366 }
367 }
368 omega[i] = self.index_of[tmp as usize];
369 }
370
371 // 5. Forney's formula for error values, applied in place.
372 for j in (0..count).rev() {
373 // num1 = Σ ω[i] · root[j]^i
374 let mut num1: u8 = 0;
375 for i in (0..=deg_omega).rev() {
376 if omega[i] != A0 {
377 num1 ^=
378 self.alpha_to[Self::modnn(omega[i] as u32 + (i as u32) * root[j]) as usize];
379 }
380 }
381 let num2 =
382 self.alpha_to[Self::modnn(root[j] * (Self::FCR - 1) + Self::NN as u32) as usize];
383
384 // den = λ_prime(X^{-1}) — formal derivative, odd-indexed terms.
385 let mut den: u8 = 0;
386 let end = deg_lambda.min(Self::NROOTS - 1) & !1;
387 let mut i = end as i32;
388 while i >= 0 {
389 if lambda_idx[i as usize + 1] != A0 {
390 den ^= self.alpha_to[Self::modnn(
391 lambda_idx[i as usize + 1] as u32 + (i as u32) * root[j],
392 ) as usize];
393 }
394 i -= 2;
395 }
396 if den == 0 {
397 return None;
398 }
399 if num1 != 0 {
400 let err = self.alpha_to[Self::modnn(
401 self.index_of[num1 as usize] as u32
402 + self.index_of[num2 as usize] as u32
403 + Self::NN as u32
404 - self.index_of[den as usize] as u32,
405 ) as usize];
406 let pos = loc[j] as usize;
407 if pos < Self::NN {
408 recd[pos] ^= err;
409 }
410 }
411 }
412
413 // Extract the (now corrected) info symbols from the systematic
414 // prefix and return the error count.
415 let mut info = [0u8; Self::K_SYMBOLS];
416 info.copy_from_slice(&recd[..Self::K_SYMBOLS]);
417 Some((info, count as u32))
418 }
419
420 /// Primitive-root helper. With PRIM = 1, the prim-th root of 1 is
421 /// just 1 itself, so iprim = 1. Computed generically for clarity.
422 fn find_iprim() -> u32 {
423 let mut iprim: u32 = 1;
424 while !iprim.is_multiple_of(Self::PRIM) {
425 iprim += Self::NN as u32;
426 }
427 iprim / Self::PRIM
428 }
429
430 // ─────────────────────────────────────────────────────────────────
431 // WSJT-X (JT65) wrappers
432 // ─────────────────────────────────────────────────────────────────
433
434 /// Encode for JT65 with the byte ordering WSJT-X expects
435 /// (`wrapkarn.c::rs_encode_`): info is reversed before encoding,
436 /// and the output places parity (reversed) at `[0..51]` and data
437 /// (reversed) at `[51..63]`.
438 pub fn encode_jt65(&self, info: &[u8; Self::K_SYMBOLS]) -> [u8; Self::N_SYMBOLS] {
439 let mut dat1 = [0u8; Self::K_SYMBOLS];
440 for i in 0..Self::K_SYMBOLS {
441 dat1[i] = info[Self::K_SYMBOLS - 1 - i];
442 }
443 let cw = self.encode_native(&dat1);
444 // cw = [dat1 || parity]; transform to WSJT-X layout.
445 let mut sent = [0u8; Self::N_SYMBOLS];
446 for i in 0..Self::NROOTS {
447 sent[Self::NROOTS - 1 - i] = cw[Self::K_SYMBOLS + i];
448 }
449 for i in 0..Self::K_SYMBOLS {
450 sent[Self::NROOTS + i] = dat1[Self::K_SYMBOLS - 1 - i];
451 }
452 sent
453 }
454
455 /// Decode JT65 symbols with the WSJT-X layout. Returns
456 /// `Some((info, err_count))` or `None` if uncorrectable.
457 pub fn decode_jt65(
458 &self,
459 recd0: &[u8; Self::N_SYMBOLS],
460 ) -> Option<([u8; Self::K_SYMBOLS], u32)> {
461 self.decode_jt65_erasures(recd0, &[])
462 }
463
464 /// JT65-layout decode with a caller-supplied list of **erasure
465 /// positions in the WSJT-X `sent[]` layout** (0..=50 = parity
466 /// reversed; 51..=62 = data reversed). The positions are
467 /// translated to the native Karn layout (identity mapping
468 /// `native = NN − 1 − wsjt` for both halves) before entering
469 /// [`Self::decode_native_erasures`].
470 pub fn decode_jt65_erasures(
471 &self,
472 recd0: &[u8; Self::N_SYMBOLS],
473 eras_pos_wsjt: &[u32],
474 ) -> Option<([u8; Self::K_SYMBOLS], u32)> {
475 let mut recd = [0u8; Self::N_SYMBOLS];
476 for i in 0..Self::K_SYMBOLS {
477 recd[i] = recd0[Self::NN - 1 - i];
478 }
479 for i in 0..Self::NROOTS {
480 recd[Self::K_SYMBOLS + i] = recd0[Self::NROOTS - 1 - i];
481 }
482 // The WSJT-X ↔ native index relation is `native = NN − 1 − wsjt`
483 // on both halves of the codeword (verified against the loops
484 // above). Translate the caller's erasure positions.
485 let eras_native: Vec<u32> = eras_pos_wsjt
486 .iter()
487 .filter(|&&p| (p as usize) < Self::NN)
488 .map(|&p| (Self::NN as u32 - 1) - p)
489 .collect();
490 let (info_native, nerr) = self.decode_native_erasures(&recd, &eras_native)?;
491 let mut info = [0u8; Self::K_SYMBOLS];
492 for i in 0..Self::K_SYMBOLS {
493 info[i] = info_native[Self::K_SYMBOLS - 1 - i];
494 }
495 Some((info, nerr))
496 }
497}
498
499// ─────────────────────────────────────────────────────────────────────────
500// FecCodec boundary stub
501//
502// Rs63_12 is used as `<Jt65 as Protocol>::Fec`. The `Protocol` trait
503// requires `type Fec: FecCodec`, which is a bit-LLR-oriented interface
504// that does not map naturally onto hard-decision symbol-level RS. We
505// provide the minimum viable impl: `encode` packs 12 × 6 = 72 info
506// bits into 63 × 6 = 378 codeword bits using the JT65 layout, and
507// `decode_soft` always returns `None` — hard-symbol RS decoding lives
508// in `jt65-core` and uses `decode_jt65` / `decode_native` directly.
509// ─────────────────────────────────────────────────────────────────────────
510
511use crate::core::{FecOpts, FecResult};
512
513impl super::FecCodec for Rs63_12 {
514 const N: usize = Rs63_12::N_SYMBOLS * 6; // 63 × 6 = 378 bits
515 const K: usize = Rs63_12::K_SYMBOLS * 6; // 12 × 6 = 72 bits
516
517 fn encode(&self, info: &[u8], codeword: &mut [u8]) {
518 assert_eq!(info.len(), Self::K);
519 assert_eq!(codeword.len(), Self::N);
520 // Pack 72 bits (MSB-first within each 6-bit symbol) into 12 symbols.
521 let mut info_syms = [0u8; Rs63_12::K_SYMBOLS];
522 for (i, slot) in info_syms.iter_mut().enumerate() {
523 let mut w = 0u8;
524 for b in 0..6 {
525 w = (w << 1) | (info[6 * i + b] & 1);
526 }
527 *slot = w;
528 }
529 let sent = self.encode_jt65(&info_syms);
530 // Expand 63 × 6-bit symbols back into 378 bits (MSB-first).
531 for (i, &sym) in sent.iter().enumerate() {
532 for b in 0..6 {
533 codeword[6 * i + b] = (sym >> (5 - b)) & 1;
534 }
535 }
536 }
537
538 /// Symbol-hard RS decoding cannot consume bit LLRs, so this path
539 /// returns `None`. Callers that want JT65 decoding should use the
540 /// symbol-level methods on [`Rs63_12`] from `jt65-core`.
541 fn decode_soft(&self, _llr: &[f32], _opts: &FecOpts) -> Option<FecResult> {
542 None
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
551 fn tables_are_primitive() {
552 let rs = Rs63_12::new();
553 // Every nonzero element 1..=62 should have a log, and α^log(x) = x.
554 for x in 1u8..=62 {
555 let lg = rs.index_of[x as usize];
556 assert_ne!(lg, A0, "log of {x} is A0");
557 assert_eq!(
558 rs.alpha_to[lg as usize], x,
559 "alpha_to[index_of[{x}]] != {x}"
560 );
561 }
562 }
563
564 #[test]
565 fn encode_clean_codeword_has_zero_syndrome() {
566 // Round-tripping a clean codeword through decode_native must
567 // yield err_count == 0 and recover the original info.
568 let rs = Rs63_12::new();
569 let info: [u8; 12] = [0, 1, 2, 3, 4, 5, 62, 61, 7, 8, 33, 44];
570 let cw = rs.encode_native(&info);
571 let (decoded, nerr) = rs.decode_native(&cw).expect("clean decode");
572 assert_eq!(decoded, info);
573 assert_eq!(nerr, 0);
574 }
575
576 #[test]
577 fn corrects_single_error() {
578 let rs = Rs63_12::new();
579 let info: [u8; 12] = [12, 34, 56, 7, 8, 9, 10, 11, 42, 21, 0, 63 & 0x3f];
580 let mut cw = rs.encode_native(&info);
581 cw[17] ^= 0x2a; // inject error
582 let (decoded, nerr) = rs.decode_native(&cw).expect("1 error correctable");
583 assert_eq!(decoded, info);
584 assert_eq!(nerr, 1);
585 }
586
587 #[test]
588 fn corrects_max_errors() {
589 // (63 − 12) / 2 = 25 errors correctable.
590 let rs = Rs63_12::new();
591 let info: [u8; 12] = [5, 17, 29, 41, 53, 62, 1, 13, 25, 37, 49, 61];
592 let mut cw = rs.encode_native(&info);
593 // Flip 25 scattered symbols, each by a different nonzero value.
594 let positions = [
595 0, 3, 5, 8, 11, 14, 17, 20, 23, 26, 29, 32, 35, 38, 41, 44, 47, 50, 53, 56, 59, 62, 1,
596 4, 7,
597 ];
598 // Pick a nonzero XOR value (1..=31) so every flip really is an
599 // error — a 0x40 XOR masked down to 0 is a no-op and would
600 // reduce the effective error count.
601 for (i, &p) in positions.iter().enumerate() {
602 let delta = ((i as u8 * 7 + 1) & 0x1f) | 1;
603 cw[p] ^= delta;
604 debug_assert!(delta != 0);
605 }
606 let (decoded, nerr) = rs.decode_native(&cw).expect("25 errors correctable");
607 assert_eq!(decoded, info);
608 assert_eq!(nerr, 25);
609 }
610
611 #[test]
612 fn rejects_26_errors() {
613 let rs = Rs63_12::new();
614 let info: [u8; 12] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
615 let mut cw = rs.encode_native(&info);
616 // 26 symbol errors exceed the correctable bound; decoder should
617 // return None or miscorrect — either way, it should NOT silently
618 // claim the wrong info with err_count == 26.
619 for p in 0..26 {
620 cw[p] ^= 0x15;
621 }
622 match rs.decode_native(&cw) {
623 None => {} // expected — uncorrectable
624 Some((decoded, _)) => {
625 // Decoder may return "a valid codeword" ≠ original.
626 assert_ne!(decoded, info, "must not decode to original beyond bound");
627 }
628 }
629 }
630
631 #[test]
632 fn erasures_only_all_51_parity() {
633 // 51 erasures on the parity block + 0 errors in data → should
634 // decode. Saturates the `2·errors + eras ≤ NROOTS = 51` bound.
635 let rs = Rs63_12::new();
636 let info: [u8; 12] = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 42, 21];
637 let mut cw = rs.encode_native(&info);
638 // Zero out parity (positions 12..63 in native layout) and
639 // mark them erased.
640 let mut eras = Vec::new();
641 for p in 12..63u32 {
642 cw[p as usize] = 0;
643 eras.push(p);
644 }
645 let (decoded, nerr) = rs
646 .decode_native_erasures(&cw, &eras)
647 .expect("51-erasure decode");
648 assert_eq!(decoded, info);
649 assert_eq!(nerr, 51);
650 }
651
652 #[test]
653 fn erasures_let_us_correct_beyond_25_errors() {
654 // Inject 30 symbol errors BUT tell the decoder where 20 of them
655 // are (erasures). That leaves 10 unknown error positions — well
656 // inside the new bound (`2·10 + 20 = 40 ≤ 51`).
657 let rs = Rs63_12::new();
658 let info: [u8; 12] = [1, 13, 25, 37, 49, 61, 5, 17, 29, 41, 53, 62];
659 let mut cw = rs.encode_native(&info);
660 // Flip 30 distinct positions. (i*2) mod 63 walks all residues
661 // once because gcd(2, 63) = 1, but dedupe defensively.
662 let positions: Vec<usize> = {
663 let mut s = Vec::with_capacity(30);
664 let mut used = [false; 63];
665 for i in 0..63 {
666 let p = (i * 2) % 63;
667 if !used[p] {
668 used[p] = true;
669 s.push(p);
670 if s.len() == 30 {
671 break;
672 }
673 }
674 }
675 s
676 };
677 for (i, &p) in positions.iter().enumerate() {
678 let delta = ((i as u8 * 7 + 1) & 0x1f) | 1;
679 cw[p] ^= delta;
680 }
681 // Reveal the first 20 as erasures.
682 let eras: Vec<u32> = positions.iter().take(20).map(|&p| p as u32).collect();
683 let (decoded, _nerr) = rs
684 .decode_native_erasures(&cw, &eras)
685 .expect("20 erasures + 10 errors must decode");
686 assert_eq!(decoded, info);
687 }
688
689 #[test]
690 fn jt65_wrapper_roundtrip() {
691 // Verify the reversed-layout wrappers are mutual inverses on a
692 // clean codeword.
693 let rs = Rs63_12::new();
694 let info: [u8; 12] = [0, 1, 2, 3, 4, 5, 62, 61, 7, 8, 33, 44];
695 let sent = rs.encode_jt65(&info);
696 let (decoded, nerr) = rs.decode_jt65(&sent).expect("jt65 clean roundtrip");
697 assert_eq!(decoded, info);
698 assert_eq!(nerr, 0);
699 }
700}