Skip to main content

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}