orion_sdr/fec/ldpc_codes.rs
1// Copyright (c) 2026 G & R Associates LLC
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4// src/fec/ldpc_codes.rs
5//
6// A small, self-contained family of binary LDPC codes for the inner stage of
7// the concatenated COFDM FEC. Unlike the FT8 LDPC in `codec/ldpc.rs` — whose
8// parity/generator tables are hardcoded to the single (174,91) code — these
9// codes are *parameterized*: the sparse parity-check matrix H is generated
10// deterministically by code at construction, and both encoder and decoder are
11// driven from that runtime H.
12//
13// Construction (systematic, lower-triangular parity — an "IRA"/staircase
14// style):
15//
16// H = [ A | T ]
17//
18// where the message occupies the first K columns (block A, sparse and
19// deterministic) and the M = N − K parity columns form a lower-bidiagonal
20// "staircase" T:
21//
22// T[i][i] = 1, T[i][i-1] = 1 (i > 0)
23//
24// This makes the code systematic and gives an O(M) direct encoder: parity bit
25// p_i = (row-i parity of A·message) XOR p_(i-1), so no Gaussian elimination is
26// needed and a valid systematic generator always exists. The A block is filled
27// with a fixed per-column weight at deterministic (seeded) row positions,
28// yielding a regular column weight in the message part — a genuine, decodable
29// LDPC structure.
30//
31// The decoder is the standard sum-product / belief-propagation algorithm,
32// reusing the fast tanh/atanh rational approximations and best-snapshot
33// tracking from `codec/ldpc.rs`, but driven from this code's sparse adjacency
34// (check→bit and bit→check incidence lists) built once from H, rather than the
35// FT8 hardcoded NM/MN tables.
36//
37// LLR convention: positive ⇒ bit more likely 0 (matches `OfdmSoftDemod` and
38// `codec::ldpc::ldpc_decode_soft`).
39
40/// Selects one of the fixed-family LDPC code points. Each maps to a
41/// deterministic (N, K) with a constructed sparse parity-check matrix.
42///
43/// The block lengths/rates here are `orion-sdr`'s own constructive codes (not a
44/// transcribed standard); see the plan's follow-on note for named-standard
45/// code points and runtime matrix ingestion, which are additive extensions.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum LdpcCode {
48 /// Rate 1/2: N = 512, K = 256.
49 N512R12,
50 /// Rate 2/3: N = 576, K = 384.
51 N576R23,
52 /// Rate 3/4: N = 512, K = 384.
53 N512R34,
54}
55
56impl LdpcCode {
57 /// Codeword length in bits.
58 pub fn n(self) -> usize {
59 match self {
60 LdpcCode::N512R12 => 512,
61 LdpcCode::N576R23 => 576,
62 LdpcCode::N512R34 => 512,
63 }
64 }
65
66 /// Information length in bits.
67 pub fn k(self) -> usize {
68 match self {
69 LdpcCode::N512R12 => 256,
70 LdpcCode::N576R23 => 384,
71 LdpcCode::N512R34 => 384,
72 }
73 }
74
75 /// Number of parity bits (`N − K`).
76 pub fn m(self) -> usize {
77 self.n() - self.k()
78 }
79
80 /// Column weight of the message part of H (rows tapped per message column).
81 fn col_weight(self) -> usize {
82 3
83 }
84}
85
86/// The check-node update rule for [`Ldpc::decode_soft_with`].
87///
88/// [`SumProduct`](DecodeRule::SumProduct) is the exact belief-propagation rule
89/// (`2·atanh(∏ tanh(msg/2))`) and the default everywhere — on-air decode uses it
90/// unless a caller explicitly opts into a min-sum variant. The min-sum rules
91/// approximate the check-node update by its dominant term (`∏sign · min|msg|`),
92/// trading a small coding-gain loss for a cheaper, transcendental-free update;
93/// [`ScaledMinSum`](DecodeRule::ScaledMinSum) attenuates the min-sum message by a
94/// factor (~0.75–0.8 recovers most of the gap). This enum exists to *measure*
95/// that trade (see the `snr::ldpc_decode_rule` sweep and the `throughput::fec`
96/// LDPC benchmarks); it is not wired into the frame layer.
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub enum DecodeRule {
99 /// Exact sum-product (tanh product rule). The default.
100 SumProduct,
101 /// Min-sum approximation: `ext = ∏sign(other) · min|other|`.
102 MinSum,
103 /// Scaled (attenuated) min-sum: min-sum message multiplied by this factor.
104 ScaledMinSum(f32),
105}
106
107/// A constructed LDPC code: sparse parity-check incidence plus the dimensions
108/// needed to encode and decode.
109#[derive(Debug, Clone)]
110pub struct Ldpc {
111 code: LdpcCode,
112 n: usize,
113 k: usize,
114 m: usize,
115 /// For each of the K message columns, the list of parity-check rows it
116 /// participates in (into the A block). Length K.
117 msg_col_rows: Vec<Vec<usize>>,
118 /// check → bit incidence over the full N columns (message A-block bits plus
119 /// the two staircase parity bits per row). Length M.
120 check_bits: Vec<Vec<usize>>,
121 /// bit → check incidence over all M rows. Length N.
122 bit_checks: Vec<Vec<usize>>,
123 /// Parallel to `bit_checks`: `bit_check_edge_idx[bit][j]` is the position of
124 /// `bit` within `check_bits[bit_checks[bit][j]]`. Precomputed once so the
125 /// per-iteration variable-node loops in `decode_soft` index the parallel
126 /// `msg`/`ext` edge arrays directly, instead of a linear `position()` scan of
127 /// the check's bit list on every edge, every iteration. Pure function of the
128 /// graph — bit-exact, decode output unchanged.
129 bit_check_edge_idx: Vec<Vec<usize>>,
130}
131
132impl Ldpc {
133 /// Builds the code selected by `code`.
134 pub fn new(code: LdpcCode) -> Self {
135 let n = code.n();
136 let k = code.k();
137 let m = code.m();
138 assert!(m >= 1 && k >= 1 && n == k + m);
139
140 // Deterministic sparse A block: each message column taps `col_weight`
141 // distinct parity rows. To keep belief-propagation well-behaved we
142 // enforce two properties as the block is filled:
143 // • row-degree balance — prefer the least-loaded rows, so no check
144 // node is over-connected;
145 // • no A-block 4-cycles — reject any row that would make two message
146 // columns share the same *pair* of rows, the dominant cause of
147 // sum-product oscillation.
148 // Note this eliminates 4-cycles *within the A block only*. The fixed
149 // staircase column p_{i-1} occupies rows {i-1, i}, so an A-column that
150 // taps both of those rows still forms a message↔staircase 4-cycle; the
151 // assembled H therefore has girth 4, not 6 (a modest error-floor cost,
152 // not a correctness issue — the codes show a clean FER waterfall). The
153 // guard runs before the staircase edges exist and does not see them.
154 // A fixed xorshift only breaks ties, so the same code is reproduced
155 // identically on TX and RX with no stored table.
156 let cw = code.col_weight();
157 let mut msg_col_rows: Vec<Vec<usize>> = Vec::with_capacity(k);
158 let mut row_load = vec![0usize; m];
159 // Set of unordered row-pairs already used by some column (4-cycle guard).
160 let mut used_pairs: std::collections::HashSet<(usize, usize)> =
161 std::collections::HashSet::new();
162 let mut state: u64 = code_seed(code);
163 let mut next = || {
164 state ^= state << 13;
165 state ^= state >> 7;
166 state ^= state << 17;
167 state
168 };
169
170 for _col in 0..k {
171 let mut rows: Vec<usize> = Vec::with_capacity(cw);
172 while rows.len() < cw {
173 // Rank candidate rows by current load (ascending), tie-broken by
174 // a rotating pseudo-random offset for spread; pick the first
175 // that keeps this column distinct and forms no 4-cycle with the
176 // rows already chosen for it.
177 let offset = (next() % m as u64) as usize;
178 let mut best: Option<usize> = None;
179 let mut best_load = usize::MAX;
180 for step in 0..m {
181 let r = (offset + step) % m;
182 if rows.contains(&r) {
183 continue;
184 }
185 // Would adding r create a 4-cycle with any already-chosen row?
186 let makes_cycle = rows
187 .iter()
188 .any(|&q| used_pairs.contains(&ordered_pair(q, r)));
189 if makes_cycle {
190 continue;
191 }
192 if row_load[r] < best_load {
193 best_load = row_load[r];
194 best = Some(r);
195 }
196 }
197 match best {
198 Some(r) => rows.push(r),
199 // No cycle-free row available (dense corner) — relax the
200 // girth constraint for this last pick rather than loop
201 // forever, keeping the column weight exact.
202 None => {
203 let r = (0..m)
204 .map(|s| (offset + s) % m)
205 .find(|r| !rows.contains(r))
206 .expect("m > col_weight guarantees a free row");
207 rows.push(r);
208 }
209 }
210 }
211 // Register the new row-pairs and loads.
212 for i in 0..rows.len() {
213 row_load[rows[i]] += 1;
214 for j in (i + 1)..rows.len() {
215 used_pairs.insert(ordered_pair(rows[i], rows[j]));
216 }
217 }
218 rows.sort_unstable();
219 msg_col_rows.push(rows);
220 }
221
222 // Build the full check→bit and bit→check incidence. Column layout:
223 // [0 .. K) message bits (A block)
224 // [K .. K+M) parity bits p_0 .. p_(M-1) (staircase T)
225 let mut check_bits: Vec<Vec<usize>> = vec![Vec::new(); m];
226 let mut bit_checks: Vec<Vec<usize>> = vec![Vec::new(); n];
227
228 for (col, rows) in msg_col_rows.iter().enumerate() {
229 for &r in rows {
230 check_bits[r].push(col);
231 bit_checks[col].push(r);
232 }
233 }
234 // Staircase parity part: row i touches parity col (K+i), and (K+i-1) for
235 // i>0. `i` indexes `check_bits` and derives the parity column K+i into
236 // `bit_checks` — a cross-index that an iterator rewrite can't express.
237 #[allow(clippy::needless_range_loop)]
238 for i in 0..m {
239 let pcol = k + i;
240 check_bits[i].push(pcol);
241 bit_checks[pcol].push(i);
242 if i > 0 {
243 let prev = k + i - 1;
244 check_bits[i].push(prev);
245 bit_checks[prev].push(i);
246 }
247 }
248
249 // Precompute each edge's index into its check's bit list, so the decoder
250 // never does a `position()` scan in its inner loops. For bit `b` and its
251 // j-th incident check `c = bit_checks[b][j]`, store the position of `b`
252 // within `check_bits[c]`. Every bit appears exactly once in each of its
253 // checks, so the lookup is total.
254 let bit_check_edge_idx: Vec<Vec<usize>> = bit_checks
255 .iter()
256 .enumerate()
257 .map(|(b, checks)| {
258 checks
259 .iter()
260 .map(|&c| {
261 check_bits[c]
262 .iter()
263 .position(|&x| x == b)
264 .expect("bit is incident to its check")
265 })
266 .collect()
267 })
268 .collect();
269
270 Self {
271 code,
272 n,
273 k,
274 m,
275 msg_col_rows,
276 check_bits,
277 bit_checks,
278 bit_check_edge_idx,
279 }
280 }
281
282 pub fn code(&self) -> LdpcCode {
283 self.code
284 }
285
286 pub fn n(&self) -> usize {
287 self.n
288 }
289
290 pub fn k(&self) -> usize {
291 self.k
292 }
293
294 pub fn m(&self) -> usize {
295 self.m
296 }
297
298 /// Systematically encodes `message` (`K` bits, values in {0,1}) into an
299 /// `N`-bit codeword `[message | parity]`.
300 ///
301 /// Direct staircase encoding: for each parity row i, `p_i = s_i XOR
302 /// p_(i-1)`, where `s_i` is the parity of the A·message dot-product for row
303 /// i (`p_-1 = 0`).
304 pub fn encode(&self, message: &[u8]) -> Vec<u8> {
305 assert_eq!(message.len(), self.k, "LDPC message must be exactly K bits");
306 let mut cw = vec![0u8; self.n];
307 cw[..self.k].copy_from_slice(message);
308
309 // Row sums s_i = XOR of message bits tapped into row i (A block only).
310 let mut s = vec![0u8; self.m];
311 for (col, rows) in self.msg_col_rows.iter().enumerate() {
312 let bit = message[col] & 1;
313 if bit != 0 {
314 for &r in rows {
315 s[r] ^= 1;
316 }
317 }
318 }
319
320 // Staircase back-substitution.
321 let mut prev = 0u8;
322 for i in 0..self.m {
323 let p = s[i] ^ prev;
324 cw[self.k + i] = p;
325 prev = p;
326 }
327 cw
328 }
329
330 /// Hard-decision syndrome weight: number of unsatisfied parity checks for
331 /// `hard` (0 ⇒ valid codeword). `hard` is `N` bits.
332 pub fn syndrome_weight(&self, hard: &[u8]) -> usize {
333 let mut unsat = 0;
334 for bits in &self.check_bits {
335 let mut x = 0u8;
336 for &b in bits {
337 x ^= hard[b] & 1;
338 }
339 if x != 0 {
340 unsat += 1;
341 }
342 }
343 unsat
344 }
345
346 /// Soft-decision sum-product decoding.
347 ///
348 /// `llr` — `N` channel LLRs (positive ⇒ bit more likely 0).
349 /// `max_iter` — maximum belief-propagation iterations.
350 /// Returns the recovered `K`-bit message and the residual unsatisfied-check
351 /// count (0 ⇒ a valid codeword was reached).
352 ///
353 /// This is the on-air decoder: it uses the exact sum-product check-node rule.
354 /// [`decode_soft_with`](Self::decode_soft_with) selects a [`DecodeRule`] for
355 /// the min-sum investigation; `decode_soft` is `decode_soft_with(…,
356 /// SumProduct)` and its output is unchanged by that refactor.
357 pub fn decode_soft(&self, llr: &[f32], max_iter: usize) -> (Vec<u8>, usize) {
358 self.decode_soft_with(llr, max_iter, DecodeRule::SumProduct)
359 }
360
361 /// [`decode_soft`](Self::decode_soft) with a selectable check-node
362 /// [`DecodeRule`]. Only the check-node update differs between rules; the
363 /// variable-node update, syndrome checks, best-snapshot tracking, and
364 /// early-exit are identical. `SumProduct` is bit-identical to `decode_soft`
365 /// before this knob existed.
366 pub fn decode_soft_with(
367 &self,
368 llr: &[f32],
369 max_iter: usize,
370 rule: DecodeRule,
371 ) -> (Vec<u8>, usize) {
372 assert_eq!(llr.len(), self.n, "LDPC LLR slice must be N long");
373
374 let mut hard = vec![0u8; self.n];
375 for (h, &l) in hard.iter_mut().zip(llr) {
376 *h = u8::from(l <= 0.0);
377 }
378 let init_unsat = self.syndrome_weight(&hard);
379 if init_unsat == 0 {
380 return (hard[..self.k].to_vec(), 0);
381 }
382
383 // Edge messages in a flat CSR layout: one contiguous buffer over all
384 // edges, with `check_start[c]..check_start[c+1]` the slice for check `c`
385 // (parallel to `check_bits[c]`). This replaces the jagged
386 // `Vec<Vec<f32>>` — one allocation and one pointer-chase-free scan per
387 // check — while indexing identically (`msg[c][i]` → `msg[check_start[c]
388 // + i]`). Bit-exact: same values, same order.
389 let n_edges: usize = self.check_bits.iter().map(Vec::len).sum();
390 let mut check_start = vec![0usize; self.m + 1];
391 for (c, bits) in self.check_bits.iter().enumerate() {
392 check_start[c + 1] = check_start[c] + bits.len();
393 }
394 let mut msg = vec![0.0f32; n_edges];
395 for (c, bits) in self.check_bits.iter().enumerate() {
396 let base = check_start[c];
397 for (i, &b) in bits.iter().enumerate() {
398 msg[base + i] = llr[b];
399 }
400 }
401 let mut ext = vec![0.0f32; n_edges];
402
403 let mut min_unsat = init_unsat;
404 let mut best = hard.clone();
405
406 // Reusable per-check scratch for `tanh(msg/2)` of each incident edge,
407 // sized to the largest check degree so the check-node loop below computes
408 // each edge's `fast_tanh` once per iteration instead of once per
409 // leave-one-out product (an O(deg²)→O(deg) transcendental saving).
410 let max_deg = self.check_bits.iter().map(Vec::len).max().unwrap_or(0);
411 let mut tanh_half = vec![0.0f32; max_deg];
412
413 for _iter in 0..max_iter {
414 // Check-node update (tanh product rule):
415 // ext = 2·atanh(∏_{other bits} tanh(msg/2)).
416 // Written without the `tanh(-msg/2)` / `-2·atanh` double-negation
417 // form some fixed-degree decoders use: that form's sign is only
418 // correct when every check has the same degree parity, whereas this
419 // code's checks have mixed degrees (4 and 5).
420 for (c, bits) in self.check_bits.iter().enumerate() {
421 let deg = bits.len();
422 let base = check_start[c];
423 let msg_c = &msg[base..base + deg];
424 let ext_c = &mut ext[base..base + deg];
425 match rule {
426 DecodeRule::SumProduct => {
427 // Cache `tanh(msg/2)` per incident edge once, so the
428 // leave-one-out products below read it instead of
429 // recomputing `fast_tanh` for every (i1, i2) pair.
430 // `tanh_half[i2]` here is bit-identical to the
431 // `fast_tanh(msg[c][i2] / 2.0)` the product used before, and
432 // the products still multiply in the same index order — so
433 // the float result is unchanged, only the transcendental
434 // count drops.
435 for j in 0..deg {
436 tanh_half[j] = fast_tanh(msg_c[j] / 2.0);
437 }
438 // `i1`/`i2` index the parallel per-edge `msg`/`ext` arrays;
439 // the leave-one-out product needs both indices, so this
440 // stays a range loop (same pattern as `codec::ldpc`'s BP
441 // decoder).
442 #[allow(clippy::needless_range_loop)]
443 for i1 in 0..deg {
444 let mut prod = 1.0f32;
445 for i2 in 0..deg {
446 if i2 != i1 {
447 prod *= tanh_half[i2];
448 }
449 }
450 // Clamp before `fast_atanh`: `fast_tanh` can overshoot
451 // slightly above 1.0 near its cutoff, so a high-degree
452 // product could exceed 1.0 and cross `fast_atanh`'s pole
453 // (~1.1035), injecting a huge wrong-signed message. The
454 // true tanh product is always within [-1, 1], so this
455 // clamp only removes the approximation's overshoot —
456 // harmless for the current codes (max product ~1.07 <
457 // pole) and a hard safety guard for any denser code.
458 ext_c[i1] = 2.0 * fast_atanh(prod.clamp(-1.0, 1.0));
459 }
460 }
461 DecodeRule::MinSum | DecodeRule::ScaledMinSum(_) => {
462 // Min-sum: the check→bit message is the product of the
463 // *other* edges' signs times the *minimum* of their
464 // magnitudes. Computed leave-one-out via the two smallest
465 // magnitudes over the whole check plus the total sign
466 // parity, so each edge is O(1) after an O(deg) pass.
467 let scale = match rule {
468 DecodeRule::ScaledMinSum(a) => a,
469 _ => 1.0,
470 };
471 let mut min1 = f32::INFINITY; // smallest |msg|
472 let mut min2 = f32::INFINITY; // second smallest |msg|
473 let mut argmin = 0usize; // index of the smallest
474 let mut sign_parity = 1.0f32; // ∏ sign over all edges
475 for (j, &v) in msg_c.iter().enumerate() {
476 if v < 0.0 {
477 sign_parity = -sign_parity;
478 }
479 let a = v.abs();
480 if a < min1 {
481 min2 = min1;
482 min1 = a;
483 argmin = j;
484 } else if a < min2 {
485 min2 = a;
486 }
487 }
488 for i1 in 0..deg {
489 // Leave-one-out: exclude edge i1 from both the sign
490 // product and the magnitude min.
491 let s_other = if msg_c[i1] < 0.0 {
492 -sign_parity
493 } else {
494 sign_parity
495 };
496 let mag = if i1 == argmin { min2 } else { min1 };
497 ext_c[i1] = scale * s_other * mag;
498 }
499 }
500 }
501 }
502
503 // Variable-node hard decision from channel LLR + all incoming ext.
504 for (bit, checks) in self.bit_checks.iter().enumerate() {
505 let edge_idx = &self.bit_check_edge_idx[bit];
506 let mut l = llr[bit];
507 for (&c, &idx) in checks.iter().zip(edge_idx) {
508 l += ext[check_start[c] + idx];
509 }
510 hard[bit] = u8::from(l <= 0.0);
511 }
512
513 let unsat = self.syndrome_weight(&hard);
514 if unsat < min_unsat {
515 min_unsat = unsat;
516 best.copy_from_slice(&hard);
517 if unsat == 0 {
518 break;
519 }
520 }
521
522 // Variable→check update: message on edge (c, bit) excludes c's own
523 // extrinsic contribution.
524 for (bit, checks) in self.bit_checks.iter().enumerate() {
525 let edge_idx = &self.bit_check_edge_idx[bit];
526 let total: f32 = llr[bit]
527 + checks
528 .iter()
529 .zip(edge_idx)
530 .map(|(&c, &idx)| ext[check_start[c] + idx])
531 .sum::<f32>();
532 for (&c, &idx) in checks.iter().zip(edge_idx) {
533 let e = check_start[c] + idx;
534 msg[e] = total - ext[e];
535 }
536 }
537 }
538
539 (best[..self.k].to_vec(), min_unsat)
540 }
541}
542
543/// Orders a row pair so `(a, b)` and `(b, a)` hash identically.
544#[inline]
545fn ordered_pair(a: usize, b: usize) -> (usize, usize) {
546 if a <= b { (a, b) } else { (b, a) }
547}
548
549/// Fixed xorshift seed per code point, so TX and RX build an identical H
550/// without a stored table.
551#[inline]
552fn code_seed(code: LdpcCode) -> u64 {
553 match code {
554 LdpcCode::N512R12 => 0x4C44_5043_3531_3200,
555 LdpcCode::N576R23 => 0x4C44_5043_3531_3201,
556 LdpcCode::N512R34 => 0x4C44_5043_3531_3202,
557 }
558}
559
560#[inline]
561fn fast_tanh(x: f32) -> f32 {
562 if x < -4.97 {
563 return -1.0;
564 }
565 if x > 4.97 {
566 return 1.0;
567 }
568 let x2 = x * x;
569 let a = x * (945.0 + x2 * (105.0 + x2));
570 let b = 945.0 + x2 * (420.0 + x2 * 15.0);
571 a / b
572}
573
574#[inline]
575fn fast_atanh(x: f32) -> f32 {
576 let x2 = x * x;
577 let a = x * (945.0 + x2 * (-735.0 + x2 * 64.0));
578 let b = 945.0 + x2 * (-1050.0 + x2 * 225.0);
579 a / b
580}