Skip to main content

comnum_fec/
density_evolution.rs

1//! Density evolution of regular LDPC ensembles under message passing (Richardson &
2//! Urbanke 2001): the expected fraction of incorrect messages on a cycle-free Tanner
3//! graph, iteration after iteration, and the threshold of the channel parameter below
4//! which it tends to zero.
5//!
6//! For the $(d_{v}, d_{c})$-regular ensemble, an output-symmetric binary-input channel
7//! and a symmetric decoder, the all-one codeword may be assumed (Lemma 1 of the paper)
8//! and the densities of the messages evolve deterministically:
9//!
10//! - **hard decisions on the BSC** ([`hard_decision_error`], [`hard_decision_threshold`]):
11//!   Gallager's algorithm A, eq. (6),
12//!   $$p^{(\ell)} = \epsilon - \epsilon \left[\frac{1 + (1 - 2p^{(\ell-1)})^{d_{c}-1}}{2}\right]^{d_{v}-1} + (1 - \epsilon) \left[\frac{1 - (1 - 2p^{(\ell-1)})^{d_{c}-1}}{2}\right]^{d_{v}-1},$$
13//!   algorithm B (Example 4: the received bit is flipped when at least $b_{\ell}$ of the
14//!   $d_{v} - 1$ incoming check messages disagree, $b_{\ell}$ chosen by Gallager's rule),
15//!   and algorithm E (Example 5: ternary messages $\{-1, 0, 1\}$, the received bit weighted
16//!   by $w^{(\ell)}$ chosen to maximise the capacity (7) of the induced binary symmetric
17//!   erasure channel);
18//! - **belief propagation** ([`BpDensityEvolution`]): the densities of the
19//!   log-likelihood ratios on a uniform grid; at a variable node the channel density
20//!   convolved with $d_{v} - 1$ check densities (eq. (9), by FFT), at a check node the
21//!   $d_{c} - 1$ variable densities combined by $m = 2 \operatorname{atanh}\left(\tanh\frac{a}{2} \tanh\frac{b}{2}\right)$
22//!   pair by pair on the grid (the discretized density evolution of Chung, Forney,
23//!   Richardson & Urbanke, *IEEE Commun. Lett.* 5(2), 2001; the paper computes the same
24//!   density with the transform of eq. (8)). An LLR of zero counts as half an error.
25//!
26//! The threshold is found by bisection ([`comnum_math::bisect`]) of the predicate "the
27//! fraction of incorrect messages falls below the target". With a finite grid and a
28//! finite number of iterations the computed threshold is an estimate; Richardson &
29//! Urbanke give theirs rounded down (Tables I and II).
30//!
31//! # Examples
32//! ```
33//! use comnum_fec::density_evolution::{hard_decision_threshold, HardDecoder, RegularEnsemble};
34//! // Gallager A on the (3, 6)-regular ensemble: 0.0395 (Table I prints 0.04)
35//! let e = RegularEnsemble::new(3, 6)?;
36//! let t = hard_decision_threshold(&e, HardDecoder::GallagerA)?;
37//! assert!((t - 0.0395).abs() < 5e-4, "{t}");
38//! # Ok::<(), comnum_core::Error>(())
39//! ```
40//!
41//! # References
42//! 1. T. J. Richardson, R. L. Urbanke, "The capacity of low-density parity-check codes
43//!    under message-passing decoding," *IEEE Trans. Inf. Theory* 47(2), 599–618, 2001:
44//!    eq. (6), Examples 3–5, 10–11, Tables I–II.
45//! 2. S.-Y. Chung, G. D. Forney, T. J. Richardson, R. Urbanke, "On the design of
46//!    low-density parity-check codes within 0.0045 dB of the Shannon limit," *IEEE Commun.
47//!    Lett.* 5(2), 58–60, 2001: the discretized density evolution of the check node.
48//! 3. D. J. C. MacKay, "Good error-correcting codes based on very sparse matrices,"
49//!    *IEEE Trans. Inf. Theory* 45(2), 399–431, 1999: eqs. (44), (54)–(57).
50
51use comnum_core::{Error, Result};
52use comnum_math::fft::{fft, ifft};
53use comnum_math::{C64, bisect, entropy, qfunc};
54
55/// The $(d_{v}, d_{c})$-regular LDPC ensemble: every variable node of degree $d_{v}$,
56/// every check node of degree $d_{c}$, design rate $1 - d_{v}/d_{c}$.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub struct RegularEnsemble {
59    /// Variable-node degree $d_{v}$.
60    pub dv: usize,
61    /// Check-node degree $d_{c}$.
62    pub dc: usize,
63}
64
65impl RegularEnsemble {
66    /// The ensemble of degrees `dv` and `dc`.
67    ///
68    /// # Errors
69    /// [`Error::Config`] unless $2 \le d_{v} < d_{c}$.
70    pub fn new(dv: usize, dc: usize) -> Result<RegularEnsemble> {
71        if dv < 2 || dc <= dv {
72            return Err(Error::Config(format!("a regular ensemble needs 2 <= dv < dc, got ({dv}, {dc})")));
73        }
74        Ok(RegularEnsemble { dv, dc })
75    }
76
77    /// The design rate $1 - d_{v}/d_{c}$.
78    pub fn rate(&self) -> f64 {
79        1.0 - self.dv as f64 / self.dc as f64
80    }
81}
82
83/// A hard-decision message-passing decoder on the binary symmetric channel.
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum HardDecoder {
86    /// Gallager's algorithm A (Example 3, eq. (6)).
87    GallagerA,
88    /// Gallager's algorithm B (Example 4).
89    GallagerB,
90    /// The ternary decoder with erasures in the messages (Example 5).
91    AlgorithmE,
92}
93
94/// The binomial coefficient as a float.
95fn binomial(n: usize, k: usize) -> f64 {
96    (0..k).fold(1.0, |c, i| c * (n - i) as f64 / (i + 1) as f64)
97}
98
99/// The fraction of incorrect variable-to-check messages after `iterations` iterations of
100/// `decoder` on the BSC of crossover probability `eps` (an erasure counts as half an
101/// error), on a cycle-free graph of the ensemble.
102///
103/// # Errors
104/// [`Error::Config`] unless $0 \le \epsilon \le 1/2$.
105pub fn hard_decision_error(
106    ensemble: &RegularEnsemble,
107    decoder: HardDecoder,
108    eps: f64,
109    iterations: usize,
110) -> Result<f64> {
111    if !(0.0..=0.5).contains(&eps) {
112        return Err(Error::Config(format!("the crossover probability must be in [0, 1/2], got {eps}")));
113    }
114    let (dv, dc) = (ensemble.dv, ensemble.dc);
115    match decoder {
116        HardDecoder::GallagerA | HardDecoder::GallagerB => {
117            let mut p = eps;
118            for _ in 0..iterations {
119                let x = (1.0 - 2.0 * p).powi(dc as i32 - 1);
120                let (agree, disagree) = ((1.0 + x) / 2.0, (1.0 - x) / 2.0);
121                // b: the number of disagreeing checks that flips the received bit; A: all
122                // of them; B: the smallest b of Gallager's rule (Example 4)
123                let b = match decoder {
124                    HardDecoder::GallagerA => dv - 1,
125                    _ => (1..dv)
126                        .find(|&b| {
127                            let e = 2 * b as i32 - dv as i32 + 1;
128                            e > 0 && (1.0 - eps) / eps <= (agree / disagree).powi(e)
129                        })
130                        .unwrap_or(dv - 1),
131                };
132                let tail = |u: f64, v: f64| {
133                    (b..dv).map(|k| binomial(dv - 1, k) * u.powi(k as i32) * v.powi((dv - 1 - k) as i32)).sum::<f64>()
134                };
135                p = eps - eps * tail(agree, disagree) + (1.0 - eps) * tail(disagree, agree);
136                if p < 1e-300 {
137                    return Ok(0.0);
138                }
139            }
140            Ok(p)
141        }
142        HardDecoder::AlgorithmE => Ok(algorithm_e(dv, dc, eps, iterations)),
143    }
144}
145
146/// Algorithm E (Example 5): the probabilities $(p_{1}, p_{-1}, p_{0})$ of a correct, an
147/// incorrect and an erased variable-to-check message, iterated; returns $p_{-1} + p_{0}/2$.
148fn algorithm_e(dv: usize, dc: usize, eps: f64, iterations: usize) -> f64 {
149    let n = dv - 1;
150    let (mut p1, mut pm, mut p0): (f64, f64, f64) = (1.0 - eps, eps, 0.0);
151    for _ in 0..iterations {
152        // check node
153        let (s, d) = ((p1 + pm).powi(dc as i32 - 1), (p1 - pm).powi(dc as i32 - 1));
154        // q0 = 1 - (1 - p0)^(dc - 1) taken as the complement, so that the three sum to one:
155        // a total off by rounding grows by (dc - 1)(dv - 1) per iteration
156        let _ = p0;
157        let (q1, qm) = (((s + d) / 2.0).max(0.0), ((s - d) / 2.0).max(0.0));
158        let q0 = (1.0 - q1 - qm).max(0.0);
159        // variable node: for every weight, the trinomial distribution of i - j over the
160        // n incoming messages (i correct, j incorrect), then the sign of w m0 + i - j
161        let mut best: Option<(f64, f64, f64, f64)> = None;
162        for w in 1..=dv as i64 {
163            let (mut n1, mut nm, mut n0) = (0.0, 0.0, 0.0);
164            for i in 0..=n {
165                for j in 0..=n - i {
166                    let pr = binomial(n, i)
167                        * binomial(n - i, j)
168                        * q1.powi(i as i32)
169                        * qm.powi(j as i32)
170                        * q0.powi((n - i - j) as i32);
171                    let diff = i as i64 - j as i64;
172                    for (m0, pm0) in [(1i64, 1.0 - eps), (-1, eps)] {
173                        let v = w * m0 + diff;
174                        let mass = pr * pm0;
175                        match v.signum() {
176                            1 => n1 += mass,
177                            -1 => nm += mass,
178                            _ => n0 += mass,
179                        }
180                    }
181                }
182            }
183            // eq. (7): 1 - p0 + h(p0) - h(p0, p_-1), the capacity of the binary symmetric
184            // erasure channel of these probabilities
185            let (n1, nm, n0): (f64, f64, f64) = (n1.max(0.0), nm.max(0.0), n0.max(0.0));
186            let cap = 1.0 - n0 + entropy(&[n0, (1.0 - n0).max(0.0)]) - entropy(&[n0, nm, n1]);
187            if best.is_none_or(|b| cap > b.0) {
188                best = Some((cap, n1, nm, n0));
189            }
190        }
191        let Some((_, a, b, c)) = best else { break };
192        let total = a + b + c;
193        (p1, pm, p0) = (a / total, b / total, c / total);
194        if pm + p0 / 2.0 < 1e-300 {
195            return 0.0;
196        }
197    }
198    pm + p0 / 2.0
199}
200
201/// The largest crossover probability for which `decoder` drives the fraction of
202/// incorrect messages to zero on the ensemble (Table I of Richardson & Urbanke): a
203/// bisection of $[0, 1/2]$ to $10^{-6}$, each point iterated until the fraction falls
204/// below $10^{-12}$ (at most $10^{5}$ iterations).
205///
206/// # Errors
207/// As [`hard_decision_error`] (never for this range).
208pub fn hard_decision_threshold(ensemble: &RegularEnsemble, decoder: HardDecoder) -> Result<f64> {
209    let converges = |eps: f64| hard_decision_error(ensemble, decoder, eps, 100_000).is_ok_and(|p| p < 1e-12);
210    Ok(bisect(converges, 0.0, 0.5, 20))
211}
212
213/// The crossover probability below which a bit of the BSC that every one of its $t$ checks
214/// votes against is more likely in error than correct, the "correction effect" bound of
215/// MacKay's simple decoder (MacKay 1999, eqs. (44), (54)–(57)): with
216/// $p_{z}(\tau) = \frac12 - \frac12(1 - 2f)^{\tau}$, $a = p_{z}(t_{r} - 1)^{t}$ and
217/// $b = (1 - p_{z}(t_{r} - 1))^{t}$, the root in $f$ of $a(1 - f) - b f$ ($t = d_{v}$,
218/// $t_{r} = d_{c}$; bisection to $10^{-12}$). It bounds the first iteration only.
219///
220/// # Examples
221/// ```
222/// use comnum_fec::density_evolution::{correction_effect_threshold, RegularEnsemble};
223/// // (4, 8): about 0.08 (MacKay 1999, Fig. 6(b))
224/// let f = correction_effect_threshold(&RegularEnsemble::new(4, 8)?);
225/// assert!((f - 0.0796).abs() < 1e-4, "{f}");
226/// # Ok::<(), comnum_core::Error>(())
227/// ```
228pub fn correction_effect_threshold(ensemble: &RegularEnsemble) -> f64 {
229    let (t, tr) = (ensemble.dv as i32, ensemble.dc as i32);
230    let corrects = |f: f64| {
231        let pz = 0.5 - 0.5 * (1.0 - 2.0 * f).powi(tr - 1);
232        let (a, b) = (pz.powi(t), (1.0 - pz).powi(t));
233        a * (1.0 - f) - b * f < 0.0
234    };
235    bisect(corrects, 1e-12, 0.5, 60)
236}
237
238/// A binary-input memoryless output-symmetric channel, the input of
239/// [`BpDensityEvolution`].
240#[derive(Clone, Copy, Debug, PartialEq)]
241pub enum BinaryInputChannel {
242    /// The binary symmetric channel of crossover probability `crossover` (Example 1).
243    Bsc {
244        /// Crossover probability $\epsilon$.
245        crossover: f64,
246    },
247    /// The binary-input AWGN channel $y = x + z$, $x \in \{\pm 1\}$,
248    /// $z \sim \mathcal{N}(0, \sigma^{2})$ (Example 2).
249    Biawgn {
250        /// Noise standard deviation $\sigma$.
251        sigma: f64,
252    },
253}
254
255/// The outcome of a run of density evolution.
256#[derive(Clone, Copy, Debug, PartialEq)]
257pub struct DensityEvolutionRun {
258    /// Fraction of incorrect variable-to-check messages at the last iteration (an LLR of
259    /// zero counts as half).
260    pub error: f64,
261    /// Iterations run.
262    pub iterations: usize,
263    /// Whether the fraction fell below the target.
264    pub converged: bool,
265}
266
267/// Discretized density evolution of belief propagation (see the module).
268#[derive(Clone, Copy, Debug, PartialEq)]
269pub struct BpDensityEvolution {
270    /// Step of the LLR grid (nominal: the step is adjusted so that the LLR of the BSC
271    /// falls on the grid).
272    pub llr_step: f64,
273    /// Largest LLR of the grid; larger magnitudes are clipped to it.
274    pub llr_max: f64,
275    /// Largest number of iterations.
276    pub max_iterations: usize,
277    /// The fraction of incorrect messages below which the run has converged.
278    pub target: f64,
279}
280
281impl Default for BpDensityEvolution {
282    /// A step of 0.05, LLRs up to 25, 3000 iterations, a target of $10^{-7}$.
283    fn default() -> Self {
284        BpDensityEvolution { llr_step: 0.05, llr_max: 25.0, max_iterations: 3000, target: 1e-7 }
285    }
286}
287
288/// The grid of the LLRs and the table of the check-node operation on it.
289struct Grid {
290    /// Half-width $K$: the LLRs are $k \Delta$, $-K \le k \le K$.
291    half: usize,
292    /// For every pair of bins, the bin of $2 \operatorname{atanh}(\tanh(a/2) \tanh(b/2))$.
293    table: Vec<u32>,
294}
295
296impl Grid {
297    fn new(step: f64, half: usize) -> Grid {
298        let n = 2 * half + 1;
299        let th: Vec<f64> = (0..n).map(|i| ((i as f64 - half as f64) * step / 2.0).tanh()).collect();
300        let mut table = vec![0u32; n * n];
301        for i in 0..n {
302            for j in 0..n {
303                let t = (th[i] * th[j]).clamp(-1.0, 1.0);
304                let m = 2.0 * t.atanh();
305                let k = if m.is_finite() { (m / step).round() } else { m.signum() * half as f64 };
306                table[i * n + j] = (k.clamp(-(half as f64), half as f64) + half as f64) as u32;
307            }
308        }
309        Grid { half, table }
310    }
311
312    fn len(&self) -> usize {
313        2 * self.half + 1
314    }
315
316    /// The density of the check-node combination of two independent messages.
317    fn boxplus(&self, a: &[f64], b: &[f64]) -> Vec<f64> {
318        let n = self.len();
319        let mut out = vec![0.0; n];
320        for (i, &pa) in a.iter().enumerate() {
321            if pa == 0.0 {
322                continue;
323            }
324            let row = &self.table[i * n..(i + 1) * n];
325            for (&k, &pb) in row.iter().zip(b) {
326                out[k as usize] += pa * pb;
327            }
328        }
329        out
330    }
331
332    /// The density of the sum of independent messages of densities `parts` (indices add),
333    /// clipped to the grid.
334    fn convolve(&self, parts: &[&[f64]]) -> Vec<f64> {
335        let n = self.len();
336        let full = parts.len() * (n - 1) + 1;
337        let size = full.next_power_of_two();
338        let mut acc = vec![C64::new(1.0, 0.0); size];
339        for p in parts {
340            let mut x = vec![C64::new(0.0, 0.0); size];
341            for (xi, &pi) in x.iter_mut().zip(p.iter()) {
342                *xi = C64::new(pi, 0.0);
343            }
344            for (a, f) in acc.iter_mut().zip(fft(&x)) {
345                *a *= f;
346            }
347        }
348        let y = ifft(&acc);
349        // index t of the full convolution is the LLR (t - parts.len() * half) step
350        let offset = parts.len() * self.half;
351        let mut out = vec![0.0; n];
352        for (t, v) in y.iter().take(full).enumerate() {
353            let k = (t as i64 - offset as i64).clamp(-(self.half as i64), self.half as i64);
354            out[(k + self.half as i64) as usize] += v.re.max(0.0);
355        }
356        let total: f64 = out.iter().sum();
357        for v in &mut out {
358            *v /= total;
359        }
360        out
361    }
362
363    /// Fraction of incorrect messages: the negative LLRs and half the zero bin.
364    fn error(&self, p: &[f64]) -> f64 {
365        p[..self.half].iter().sum::<f64>() + 0.5 * p[self.half]
366    }
367}
368
369impl BpDensityEvolution {
370    /// The grid step and the density of the channel LLRs on it.
371    fn channel(&self, channel: BinaryInputChannel) -> Result<(f64, usize, Vec<f64>)> {
372        if !(self.llr_step > 0.0 && self.llr_max > self.llr_step) {
373            return Err(Error::Config(format!(
374                "density evolution needs 0 < llr_step < llr_max, got {} and {}",
375                self.llr_step, self.llr_max
376            )));
377        }
378        match channel {
379            BinaryInputChannel::Bsc { crossover: eps } => {
380                if !(eps > 0.0 && eps < 0.5) {
381                    return Err(Error::Config(format!("the crossover probability must be in (0, 1/2), got {eps}")));
382                }
383                // the step that puts the LLR log((1 - eps)/eps) on a bin (Example 10)
384                let l0 = ((1.0 - eps) / eps).ln();
385                let bins = (l0 / self.llr_step).round().max(1.0);
386                let step = l0 / bins;
387                let half = (self.llr_max / step).round() as usize;
388                let half = half.max(bins as usize);
389                let mut p = vec![0.0; 2 * half + 1];
390                p[half + bins as usize] = 1.0 - eps;
391                p[half - bins as usize] = eps;
392                Ok((step, half, p))
393            }
394            BinaryInputChannel::Biawgn { sigma } => {
395                if !(sigma > 0.0 && sigma.is_finite()) {
396                    return Err(Error::Config(format!("the noise standard deviation must be positive, got {sigma}")));
397                }
398                // LLR 2y/sigma^2 ~ N(2/sigma^2, 4/sigma^2) for the input +1 (Example 11)
399                let (mu, sd) = (2.0 / (sigma * sigma), 2.0 / sigma);
400                let step = self.llr_step;
401                let half = (self.llr_max / step).round() as usize;
402                let cdf = |x: f64| 1.0 - qfunc((x - mu) / sd);
403                let p = (0..=2 * half)
404                    .map(|i| {
405                        let k = i as f64 - half as f64;
406                        let lo = if i == 0 { f64::NEG_INFINITY } else { (k - 0.5) * step };
407                        let hi = if i == 2 * half { f64::INFINITY } else { (k + 0.5) * step };
408                        let c = |x: f64| if x.is_infinite() { if x > 0.0 { 1.0 } else { 0.0 } } else { cdf(x) };
409                        (c(hi) - c(lo)).max(0.0)
410                    })
411                    .collect();
412                Ok((step, half, p))
413            }
414        }
415    }
416
417    /// Run density evolution of belief propagation on the ensemble and the channel until
418    /// the fraction of incorrect messages falls below [`target`](Self::target), stops
419    /// decreasing, or [`max_iterations`](Self::max_iterations) is reached.
420    ///
421    /// # Errors
422    /// [`Error::Config`] for an invalid grid or channel parameter.
423    pub fn run(&self, ensemble: &RegularEnsemble, channel: BinaryInputChannel) -> Result<DensityEvolutionRun> {
424        let (step, half, p0) = self.channel(channel)?;
425        let grid = Grid::new(step, half);
426        let mut p = p0.clone();
427        let mut last = grid.error(&p);
428        for it in 1..=self.max_iterations {
429            // check node: d_c - 1 variable messages
430            let mut q = p.clone();
431            for _ in 1..ensemble.dc - 1 {
432                q = grid.boxplus(&q, &p);
433            }
434            // variable node: the channel and d_v - 1 check messages, eq. (9)
435            let mut parts: Vec<&[f64]> = vec![&p0];
436            parts.extend(std::iter::repeat_n(q.as_slice(), ensemble.dv - 1));
437            p = grid.convolve(&parts);
438            let e = grid.error(&p);
439            if e < self.target {
440                return Ok(DensityEvolutionRun { error: e, iterations: it, converged: true });
441            }
442            // a fixed point: the fraction no longer decreases
443            if e >= last - 1e-15 * last.max(1e-300) && it > 1 {
444                return Ok(DensityEvolutionRun { error: e, iterations: it, converged: false });
445            }
446            last = e;
447        }
448        Ok(DensityEvolutionRun { error: last, iterations: self.max_iterations, converged: false })
449    }
450
451    /// The threshold of a family of channels `channel(t)` that degrades as `t` grows,
452    /// between `lo` (converges) and `hi` (does not), by `steps` bisection steps.
453    ///
454    /// # Errors
455    /// As [`run`](Self::run) for a parameter of the bracket.
456    pub fn threshold(
457        &self,
458        ensemble: &RegularEnsemble,
459        channel: impl Fn(f64) -> BinaryInputChannel,
460        lo: f64,
461        hi: f64,
462        steps: usize,
463    ) -> Result<f64> {
464        let mut err = None;
465        let t = bisect(
466            |x| match self.run(ensemble, channel(x)) {
467                Ok(r) => r.converged,
468                Err(e) => {
469                    err = Some(e);
470                    false
471                }
472            },
473            lo,
474            hi,
475            steps,
476        );
477        match err {
478            Some(e) => Err(e),
479            None => Ok(t),
480        }
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn an_ensemble_needs_two_le_dv_lt_dc() {
490        assert!(RegularEnsemble::new(1, 6).is_err() && RegularEnsemble::new(6, 6).is_err());
491        assert!((RegularEnsemble::new(3, 6).unwrap().rate() - 0.5).abs() < 1e-15);
492        let e = RegularEnsemble::new(3, 6).unwrap();
493        assert!(hard_decision_error(&e, HardDecoder::GallagerA, 0.6, 10).is_err());
494        let bp = BpDensityEvolution::default();
495        assert!(bp.run(&e, BinaryInputChannel::Bsc { crossover: 0.0 }).is_err());
496        assert!(bp.run(&e, BinaryInputChannel::Biawgn { sigma: -1.0 }).is_err());
497    }
498
499    /// For $d_{v} = 3$ algorithms A and B coincide (Table I caption); the noiseless channel
500    /// has no error; above the threshold the fraction stays at a fixed point.
501    #[test]
502    fn gallager_a_and_b_coincide_for_dv_3_and_have_fixed_points_above_threshold() {
503        let e = RegularEnsemble::new(3, 6).unwrap();
504        for eps in [0.03, 0.05] {
505            let a = hard_decision_error(&e, HardDecoder::GallagerA, eps, 2000).unwrap();
506            let b = hard_decision_error(&e, HardDecoder::GallagerB, eps, 2000).unwrap();
507            assert_eq!(a, b);
508        }
509        assert_eq!(hard_decision_error(&e, HardDecoder::GallagerA, 0.0, 10).unwrap(), 0.0);
510        assert!(hard_decision_error(&e, HardDecoder::GallagerA, 0.05, 2000).unwrap() > 0.01);
511    }
512
513    /// The check and variable maps preserve the total probability and the symmetry
514    /// $P(-m) = e^{-m} P(m)$ of LLR densities, approximately on the grid.
515    #[test]
516    fn the_grid_operations_keep_the_mass_and_the_density_symmetry() {
517        let bp = BpDensityEvolution::default();
518        let (step, half, p0) = bp.channel(BinaryInputChannel::Biawgn { sigma: 0.9 }).unwrap();
519        let g = Grid::new(step, half);
520        let q = g.boxplus(&p0, &p0);
521        let v = g.convolve(&[&p0, &q, &q]);
522        for d in [&p0, &q, &v] {
523            assert!((d.iter().sum::<f64>() - 1.0).abs() < 1e-9);
524        }
525        // symmetry at a few bins of the check output
526        for k in [5usize, 20, 40] {
527            let (pos, neg) = (q[half + k], q[half - k]);
528            let m = k as f64 * step;
529            assert!((neg - (-m).exp() * pos).abs() < 0.05 * pos, "bin {k}: {neg} against {}", (-m).exp() * pos);
530        }
531    }
532
533    /// On a cycle-free graph a channel far better than the threshold converges in a few
534    /// iterations, a channel far worse never does.
535    #[test]
536    fn bp_density_evolution_converges_well_below_the_threshold_only() {
537        let e = RegularEnsemble::new(3, 6).unwrap();
538        let bp = BpDensityEvolution { llr_step: 0.1, ..BpDensityEvolution::default() };
539        let good = bp.run(&e, BinaryInputChannel::Biawgn { sigma: 0.7 }).unwrap();
540        assert!(good.converged && good.iterations < 30, "{good:?}");
541        let bad = bp.run(&e, BinaryInputChannel::Biawgn { sigma: 1.0 }).unwrap();
542        assert!(!bad.converged && bad.error > 0.05, "{bad:?}");
543    }
544
545    /// Gallager's algorithm B is A with the flipping rule of Example 4: it only helps for
546    /// $d_{v} > 3$; algorithm E with the weights that maximise (7) beats both (Table I).
547    #[test]
548    fn algorithm_b_and_e_improve_on_a_as_in_table_i() {
549        let e48 = RegularEnsemble::new(4, 8).unwrap();
550        let a = hard_decision_threshold(&e48, HardDecoder::GallagerA).unwrap();
551        let b = hard_decision_threshold(&e48, HardDecoder::GallagerB).unwrap();
552        let ee = hard_decision_threshold(&e48, HardDecoder::AlgorithmE).unwrap();
553        assert!((a - 0.0476).abs() < 2e-4 && (b - 0.0517).abs() < 2e-4 && (ee - 0.0595).abs() < 2e-4, "{a} {b} {ee}");
554        // (3, 6): Example 5 finds the weights 2 then 1 and a threshold near 0.07
555        let e36 = RegularEnsemble::new(3, 6).unwrap();
556        let ee = hard_decision_threshold(&e36, HardDecoder::AlgorithmE).unwrap();
557        assert!((0.070..0.0715).contains(&ee), "{ee}");
558        // below capacity: eps_opt = 0.11 at rate 1/2
559        assert!(ee < 0.11);
560    }
561}