Skip to main content

comnum_mapping/
demapper.rs

1//! Soft demapping: log-likelihood ratios of the bits of received symbols.
2//!
3//! The soft demapper is the inverse of the mapper and belongs to the same crate.
4//!
5//! # Model
6//!
7//! For every symbol and every bit position,
8//!
9//! $$
10//! L(b) = \ln \sum_{x:\\, b = 0} p(x)\\, e^{-|y - x|^{2} / \sigma^{2}}
11//!      - \ln \sum_{x:\\, b = 1} p(x)\\, e^{-|y - x|^{2} / \sigma^{2}}
12//! $$
13//!
14//! with $\sigma^{2}$ the variance of the complex noise, one per symbol with
15//! [`Constellation::llr_with_variances`]. A positive LLR favours bit 0, the library-wide
16//! convention of `comnum_core::signal`. The sums are computed with log-sum-exp, so that
17//! they stay finite at any SNR; [`Jacobian::MaxLog`] keeps only the largest term of each
18//! sum.
19//!
20//! [`Constellation`] implements the verb traits [`Map`] and [`Demap`] on the
21//! multi-stream containers: [`Demap::demap_into`] gives the exact LLRs under uniform
22//! priors, [`Demap::demap_with_into`] takes the log-priors $\ln p(x)$ of the points
23//! (probabilistic shaping) and the [`Jacobian`] in a [`Demapping`]. Both write into the
24//! caller's [`Llr`] and allocate nothing once its shape is stable (the metric row of a
25//! symbol is on the stack up to [`STACK_POINTS`] points); with log-priors from
26//! [`log_priors`] of the normalised [`Constellation::priors`], their LLRs are those of
27//! [`Constellation::llr`] with the probabilities, bit for bit.
28//!
29//! # Examples
30//! ```
31//! use comnum_core::prelude::*;
32//! use comnum_mapping::constellation::Constellation;
33//! let c = Constellation::qam(4)?.normalized();
34//! let bits = Bits::from_stream(vec![0, 1, 1, 1])?;
35//! let x = c.map(&bits)?;
36//! let llr = c.demap(&x, 0.01)?;
37//! // positive LLR: bit 0
38//! let hard: Vec<u8> = comnum_core::signal::hard_decisions(llr.as_slice());
39//! assert_eq!(hard, bits.as_slice());
40//! // shaped priors and max-log: the log-priors of the normalised probabilities
41//! let px = [0.4, 0.3, 0.2, 0.1];
42//! let lp = comnum_mapping::constellation::log_priors(&c.priors(Some(&px))?);
43//! let shaped = c.demap_with(&x, 0.01, Demapping { log_priors: Some(&lp), jacobian: Jacobian::MaxLog })?;
44//! assert_eq!(shaped.as_slice(), c.llr(x.as_slice(), 0.01, Some(&px), Jacobian::MaxLog)?);
45//! # Ok::<(), comnum_core::Error>(())
46//! ```
47
48use crate::constellation::{Constellation, log_priors};
49use comnum_core::signal::{Bits, Llr, Symbols};
50use comnum_core::traits::{Demap, Demapping, Map};
51use comnum_core::{Result, ensure, same_len};
52use comnum_math::{C64, Jacobian};
53
54/// The largest constellation whose metric row (one `f64` per point, 8 KiB) the demapper
55/// keeps on the stack; a larger one allocates the row once per call.
56pub const STACK_POINTS: usize = 1024;
57
58/// The metrics of one symbol over the two halves of its labels for one bit: the label
59/// $i$ of a point is in half $b$ when bit `shift` of $i$ is $b$, and every half keeps its
60/// largest metric $\mu_{b}$ and the sum $s_{b} = \sum_{i} e^{\ell_{i} - \mu_{b}}$, so that
61///
62/// $$
63/// \ln \sum_{i \in b} e^{\ell_{i}} = \mu_{b} + \ln s_{b}, \qquad
64/// L = \mu_{0} + \ln s_{0} - \mu_{1} - \ln s_{1}
65/// $$
66///
67/// (with max-log, $\mu_{b}$ and $\mu_{0} - \mu_{1}$). The kernel of the bitwise demapper
68/// ([`Constellation::llr`]), of the bitwise closed forms ([`crate::theory`]) and of the
69/// bit-metric rates of `comnum_metrics`: the metric row $\ell_{i}$ (a log-likelihood plus
70/// a log-prior) is theirs, the sums are these.
71///
72/// # Examples
73/// ```
74/// use comnum_mapping::demapper::LabelHalves;
75/// use comnum_math::Jacobian;
76/// // four labels, bit 1 (the most significant): halves {0, 1} and {2, 3}
77/// let h = LabelHalves::new(&[0.0, -1.0, -3.0, -2.0], 1, Jacobian::Exact);
78/// assert!((h.log_sum(0) - (1.0 + (-1f64).exp()).ln()).abs() < 1e-15);
79/// assert_eq!(LabelHalves::new(&[0.0, -1.0, -3.0, -2.0], 1, Jacobian::MaxLog).llr(), 2.0);
80/// ```
81#[derive(Clone, Copy, Debug, PartialEq)]
82pub struct LabelHalves {
83    max: [f64; 2],
84    sum: [f64; 2],
85    jacobian: Jacobian,
86}
87
88impl LabelHalves {
89    /// The halves of the metric row `metric` (one value per label) for bit `shift` of
90    /// the labels; with [`Jacobian::MaxLog`], the maxima only.
91    pub fn new(metric: &[f64], shift: usize, jacobian: Jacobian) -> LabelHalves {
92        // the log-sum-exp of the two halves without allocation: maxima, then sums
93        let (mut m0, mut m1) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
94        for (i, &v) in metric.iter().enumerate() {
95            if (i >> shift) & 1 == 0 {
96                m0 = m0.max(v);
97            } else {
98                m1 = m1.max(v);
99            }
100        }
101        let mut sum = [1.0, 1.0];
102        if jacobian.is_exact() && (m0.is_finite() || m1.is_finite()) {
103            let (mut s0, mut s1) = (0.0, 0.0);
104            for (i, &v) in metric.iter().enumerate() {
105                if (i >> shift) & 1 == 0 {
106                    s0 += (v - m0).exp();
107                } else {
108                    s1 += (v - m1).exp();
109                }
110            }
111            sum = [s0, s1];
112        }
113        LabelHalves { max: [m0, m1], sum, jacobian }
114    }
115
116    /// $\ln \sum_{i \in b} e^{\ell_{i}}$ over the half `bit` (0 or 1): $\mu_{b} + \ln
117    /// s_{b}$, or $\mu_{b}$ with max-log or when $\mu_{b}$ is not finite.
118    pub fn log_sum(&self, bit: usize) -> f64 {
119        let m = self.max[bit & 1];
120        if !self.jacobian.is_exact() || !m.is_finite() { m } else { m + self.sum[bit & 1].ln() }
121    }
122
123    /// The LLR of the bit, $\mu_{0} + \ln s_{0} - \mu_{1} - \ln s_{1}$ (positive for a 0),
124    /// or $\mu_{0} - \mu_{1}$ with max-log or when a maximum is not finite.
125    pub fn llr(&self) -> f64 {
126        let [m0, m1] = self.max;
127        if !self.jacobian.is_exact() || !m0.is_finite() || !m1.is_finite() {
128            m0 - m1
129        } else {
130            m0 + self.sum[0].ln() - m1 - self.sum[1].ln()
131        }
132    }
133}
134
135/// The LLRs of the $b = \log_{2} M$ bits of one symbol from its metric row (the $M$
136/// values $\ell_{i}$ of [`LabelHalves`]) into `out[..b]`, the most significant bit
137/// first.
138///
139/// # Panics
140/// If `out` has fewer than $b$ values.
141pub fn llr_from_metrics(metric: &[f64], jacobian: Jacobian, out: &mut [f64]) {
142    let b = metric.len().trailing_zeros() as usize;
143    for (o, k) in out[..b].iter_mut().zip((0..b).rev()) {
144        *o = LabelHalves::new(metric, k, jacobian).llr();
145    }
146}
147
148impl Constellation {
149    /// Log-likelihood ratios of the bits, `b` per symbol (see the module documentation).
150    ///
151    /// `noise_var` is the variance of the complex noise `E|w|²` (for a real
152    /// constellation over a real channel, pass twice the variance of the real noise, as
153    /// OptiCommPy does); `px` are the prior probabilities of the points (uniform if
154    /// `None`, normalised otherwise).
155    ///
156    /// # Errors
157    /// [`comnum_core::Error::Config`] for a noise variance that is not positive,
158    /// [`comnum_core::Error::Shape`] for priors of another length than the constellation,
159    /// [`comnum_core::Error::Config`] for negative or all-zero priors.
160    pub fn llr(&self, z: &[C64], noise_var: f64, px: Option<&[f64]>, jacobian: Jacobian) -> Result<Vec<f64>> {
161        let mut out = vec![0.0; z.len() * self.bits_per_symbol()];
162        self.llr_into(z, noise_var, px, jacobian, &mut out)?;
163        Ok(out)
164    }
165
166    /// [`Constellation::llr`] into the `z.len() log2 M` values of `out` (no allocation
167    /// with uniform priors up to [`STACK_POINTS`] points).
168    ///
169    /// # Errors
170    /// As [`Constellation::llr`], and [`comnum_core::Error::Shape`] for an `out` of another
171    /// length.
172    ///
173    /// # Examples
174    /// ```
175    /// use comnum_mapping::constellation::Constellation;
176    /// use comnum_math::Jacobian;
177    /// let c = Constellation::qam(16)?.normalized();
178    /// let z = [c.points()[5], c.points()[12]];
179    /// let mut out = [0.0; 8];
180    /// c.llr_into(&z, 0.1, None, Jacobian::Exact, &mut out)?;
181    /// assert_eq!(out[..], c.llr(&z, 0.1, None, Jacobian::Exact)?[..]);
182    /// # Ok::<(), comnum_core::Error>(())
183    /// ```
184    pub fn llr_into(
185        &self,
186        z: &[C64],
187        noise_var: f64,
188        px: Option<&[f64]>,
189        jacobian: Jacobian,
190        out: &mut [f64],
191    ) -> Result<()> {
192        ensure_variance(noise_var)?;
193        same_len(out.len(), z.len() * self.bits_per_symbol(), "LLRs and bits of the symbols")?;
194        self.llr_kernel(z, |_| noise_var, self.log_priors_of(px)?.as_deref(), jacobian, out);
195        Ok(())
196    }
197
198    /// [`Constellation::llr`] with one noise variance per symbol, `noise_vars[i]` that of
199    /// `z[i]` (the output of a linear detector, of Alamouti's combiner).
200    ///
201    /// # Errors
202    /// As [`Constellation::llr`], and [`comnum_core::Error::Shape`] for one variance per
203    /// symbol missing.
204    ///
205    /// # Examples
206    /// ```
207    /// use comnum_mapping::constellation::Constellation;
208    /// use comnum_math::Jacobian;
209    /// let c = Constellation::qam(4)?.normalized();
210    /// let z = [c.points()[0], c.points()[3]];
211    /// let both = c.llr_with_variances(&z, &[0.1, 0.4], None, Jacobian::Exact)?;
212    /// assert_eq!(both[2..], c.llr(&z[1..], 0.4, None, Jacobian::Exact)?[..]);
213    /// # Ok::<(), comnum_core::Error>(())
214    /// ```
215    pub fn llr_with_variances(
216        &self,
217        z: &[C64],
218        noise_vars: &[f64],
219        px: Option<&[f64]>,
220        jacobian: Jacobian,
221    ) -> Result<Vec<f64>> {
222        let mut out = vec![0.0; z.len() * self.bits_per_symbol()];
223        self.llr_with_variances_into(z, noise_vars, px, jacobian, &mut out)?;
224        Ok(out)
225    }
226
227    /// [`Constellation::llr_with_variances`] into the `z.len() log2 M` values of `out`.
228    ///
229    /// # Errors
230    /// As [`Constellation::llr_with_variances`], and [`comnum_core::Error::Shape`] for an
231    /// `out` of another length.
232    pub fn llr_with_variances_into(
233        &self,
234        z: &[C64],
235        noise_vars: &[f64],
236        px: Option<&[f64]>,
237        jacobian: Jacobian,
238        out: &mut [f64],
239    ) -> Result<()> {
240        same_len(noise_vars.len(), z.len(), "noise variances and symbols")?;
241        for &v in noise_vars {
242            ensure_variance(v)?;
243        }
244        same_len(out.len(), z.len() * self.bits_per_symbol(), "LLRs and bits of the symbols")?;
245        self.llr_kernel(z, |i| noise_vars[i], self.log_priors_of(px)?.as_deref(), jacobian, out);
246        Ok(())
247    }
248
249    /// The log-priors of `px`, checked and normalised; `None` for uniform priors, whose
250    /// logarithm $-\ln M$ the kernel uses without a table.
251    fn log_priors_of(&self, px: Option<&[f64]>) -> Result<Option<Vec<f64>>> {
252        Ok(match px {
253            None => None,
254            Some(_) => Some(log_priors(&self.priors(px)?)),
255        })
256    }
257
258    /// `Ok` for log-priors of one value per point, none NaN or $+\infty$, one at least
259    /// finite (a point possible).
260    fn check_log_priors(&self, log_px: &[f64]) -> Result<()> {
261        same_len(log_px.len(), self.order(), "log-priors and constellation points")?;
262        ensure(log_px.iter().all(|v| !v.is_nan() && *v < f64::INFINITY), || {
263            "the log-priors must be below +inf and not NaN".into()
264        })?;
265        ensure(log_px.iter().any(|v| v.is_finite()), || "the log-priors leave no point possible".into())
266    }
267
268    /// The LLRs of `z` into `out`, the noise variance of symbol `i` being `var(i)`, the
269    /// log-priors `log_px` (uniform if `None`).
270    fn llr_kernel(
271        &self,
272        z: &[C64],
273        var: impl Fn(usize) -> f64,
274        log_px: Option<&[f64]>,
275        jacobian: Jacobian,
276        out: &mut [f64],
277    ) {
278        let m = self.order();
279        let b = self.bits_per_symbol();
280        let uniform = -(m as f64).ln();
281        let mut stack = [0.0; STACK_POINTS];
282        let mut heap = Vec::new();
283        let metric: &mut [f64] = if m <= STACK_POINTS {
284            &mut stack[..m]
285        } else {
286            heap.resize(m, 0.0);
287            &mut heap
288        };
289        let points = self.points();
290        for (n, (&y, o)) in z.iter().zip(out.chunks_exact_mut(b)).enumerate() {
291            let sigma2 = var(n);
292            for i in 0..m {
293                metric[i] = -(y - points[i]).norm_sqr() / sigma2 + log_px.map_or(uniform, |l| l[i]);
294            }
295            llr_from_metrics(metric, jacobian, o);
296        }
297    }
298}
299
300/// The LLRs $2 y / \sigma^{2}$ of real BPSK symbols ([`crate::constellation::bpsk`],
301/// $0 \mapsto +1$) received in real Gaussian noise of variance `noise_var` per
302/// dimension.
303///
304/// # Examples
305/// ```
306/// use comnum_mapping::demapper::bpsk_llr;
307/// assert_eq!(bpsk_llr(&[0.5, -1.0], 0.5), [2.0, -4.0]);
308/// ```
309pub fn bpsk_llr(y: &[f64], noise_var: f64) -> Vec<f64> {
310    y.iter().map(|v| 2.0 * v / noise_var).collect()
311}
312
313fn ensure_variance(noise_var: f64) -> Result<()> {
314    ensure(noise_var > 0.0 && noise_var.is_finite(), || format!("the noise variance must be positive, got {noise_var}"))
315}
316
317impl Map for Constellation {
318    fn map_into(&self, bits: &Bits, out: &mut Symbols) -> Result<()> {
319        let b = self.bits_per_symbol();
320        if bits.len() % b != 0 {
321            return Err(comnum_core::Error::Shape(format!(
322                "{} bits per stream is not a multiple of {b} bits per symbol",
323                bits.len()
324            )));
325        }
326        out.resize(bits.n_streams(), bits.len() / b);
327        for s in 0..bits.n_streams() {
328            let idx = self.indices(bits.stream(s))?;
329            for (o, i) in out.stream_mut(s).iter_mut().zip(idx) {
330                *o = self.points()[i];
331            }
332        }
333        Ok(())
334    }
335}
336
337/// The soft demapper of the constellation: the kernel of [`Constellation::llr_into`] on
338/// every stream, with the log-priors and the logarithm of the [`Demapping`].
339impl Demap for Constellation {
340    /// # Errors
341    /// [`comnum_core::Error::Config`] for a noise variance that is not positive and finite,
342    /// or log-priors with a NaN or $+\infty$, or none finite; [`comnum_core::Error::Shape`]
343    /// for log-priors of another length than the constellation.
344    fn demap_with_into(&self, y: &Symbols, noise_var: f64, demapping: Demapping<'_>, out: &mut Llr) -> Result<()> {
345        ensure_variance(noise_var)?;
346        if let Some(lp) = demapping.log_priors {
347            self.check_log_priors(lp)?;
348        }
349        out.resize(y.n_streams(), y.len() * self.bits_per_symbol());
350        for s in 0..y.n_streams() {
351            self.llr_kernel(y.stream(s), |_| noise_var, demapping.log_priors, demapping.jacobian, out.stream_mut(s));
352        }
353        Ok(())
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    /// Two streams of a 16-QAM through a fixed perturbation.
362    fn received(c: &Constellation) -> Symbols {
363        let p = c.points();
364        let row = |k: usize| {
365            (0..40)
366                .map(|i| p[(7 * i + k) % 16] + C64::new(0.3 * ((i % 5) as f64 - 2.0), 0.1 * (i % 3) as f64))
367                .collect()
368        };
369        Symbols::from_streams(&[row(0), row(3)]).unwrap()
370    }
371
372    /// The trait LLRs are those of `Constellation::llr`, bit for bit: uniform priors and
373    /// shaped priors (their log-priors from `log_priors` of the normalised `priors`),
374    /// exact and max-log.
375    #[test]
376    fn demap_with_equals_llr_with_and_without_priors() {
377        let c = Constellation::qam(16).unwrap().normalized();
378        let y = received(&c);
379        let px: Vec<f64> = (1..=16).map(|i| f64::from(i).sqrt()).collect();
380        let lp = log_priors(&c.priors(Some(&px)).unwrap());
381        for jacobian in [Jacobian::Exact, Jacobian::MaxLog] {
382            for (log_priors, probabilities) in [(None, None), (Some(&lp[..]), Some(&px[..]))] {
383                let got = c.demap_with(&y, 0.2, Demapping { log_priors, jacobian }).unwrap();
384                for s in 0..2 {
385                    assert_eq!(
386                        got.stream(s),
387                        c.llr(y.stream(s), 0.2, probabilities, jacobian).unwrap(),
388                        "{jacobian:?}"
389                    );
390                }
391            }
392        }
393        assert_eq!(c.demap(&y, 0.2).unwrap(), c.demap_with(&y, 0.2, Demapping::default()).unwrap());
394    }
395
396    /// The output keeps its storage, a constellation above `STACK_POINTS` points too.
397    #[test]
398    fn demap_with_into_reuses_the_output() {
399        for c in [Constellation::qam(16).unwrap(), Constellation::qam(4096).unwrap()] {
400            let y = Symbols::from_streams(&[c.points()[..8].to_vec(), c.points()[8..16].to_vec()]).unwrap();
401            let mut out = Llr::default();
402            c.demap_into(&y, 0.5, &mut out).unwrap();
403            let ptr = out.as_slice().as_ptr();
404            c.demap_with_into(&y, 0.5, Demapping { log_priors: None, jacobian: Jacobian::MaxLog }, &mut out).unwrap();
405            assert_eq!(out.as_slice().as_ptr(), ptr);
406            assert_eq!(out.stream(1), c.llr(y.stream(1), 0.5, None, Jacobian::MaxLog).unwrap());
407        }
408    }
409
410    /// Log-priors: one per point (`Shape`), no NaN or +inf, one finite (`Config`); an
411    /// unnormalised row gives the same decisions.
412    #[test]
413    fn demap_with_checks_the_log_priors() {
414        let c = Constellation::qam(4).unwrap().normalized();
415        let y = Symbols::from_stream(c.points().to_vec()).unwrap();
416        let with = |lp: &[f64]| c.demap_with(&y, 0.1, Demapping { log_priors: Some(lp), jacobian: Jacobian::Exact });
417        assert!(matches!(with(&[0.0; 3]), Err(comnum_core::Error::Shape(_))));
418        assert!(matches!(with(&[0.0, f64::NAN, 0.0, 0.0]), Err(comnum_core::Error::Config(_))));
419        assert!(matches!(with(&[0.0, f64::INFINITY, 0.0, 0.0]), Err(comnum_core::Error::Config(_))));
420        assert!(matches!(with(&[f64::NEG_INFINITY; 4]), Err(comnum_core::Error::Config(_))));
421        assert!(matches!(c.demap(&y, 0.0), Err(comnum_core::Error::Config(_))));
422        let shifted = with(&[3.0; 4]).unwrap();
423        let uniform = c.demap(&y, 0.1).unwrap();
424        for (a, b) in shifted.as_slice().iter().zip(uniform.as_slice()) {
425            assert!((a - b).abs() < 1e-12);
426        }
427    }
428
429    #[test]
430    fn llr_signs_and_max_log() {
431        let c = Constellation::qam(16).unwrap().normalized();
432        let x = c.points()[0b1010];
433        let l = c.llr(&[x], 0.01, None, Jacobian::Exact).unwrap();
434        let expected = [1u8, 0, 1, 0];
435        for (v, b) in l.iter().zip(expected) {
436            assert!(if b == 0 { *v > 0.0 } else { *v < 0.0 }, "{l:?}");
437        }
438        let lm = c.llr(&[x], 0.01, None, Jacobian::MaxLog).unwrap();
439        for (a, b) in l.iter().zip(&lm) {
440            assert!((a - b).abs() < 1e-3 * a.abs().max(1.0));
441        }
442    }
443
444    /// The demapper as written before the shared kernel: the metric row with a table of
445    /// log-priors, then the halves of every bit.
446    fn former_llr(c: &Constellation, z: &[C64], sigma2: f64, log_px: &[f64], max_log: bool) -> Vec<f64> {
447        let (m, b) = (c.order(), c.bits_per_symbol());
448        let mut out = Vec::new();
449        let mut metric = vec![0.0; m];
450        for &y in z {
451            for i in 0..m {
452                metric[i] = -(y - c.points()[i]).norm_sqr() / sigma2 + log_px[i];
453            }
454            for k in (0..b).rev() {
455                let (mut m0, mut m1) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
456                for (i, &v) in metric.iter().enumerate() {
457                    if (i >> k) & 1 == 0 {
458                        m0 = m0.max(v);
459                    } else {
460                        m1 = m1.max(v);
461                    }
462                }
463                if max_log || !m0.is_finite() || !m1.is_finite() {
464                    out.push(m0 - m1);
465                    continue;
466                }
467                let (mut s0, mut s1) = (0.0, 0.0);
468                for (i, &v) in metric.iter().enumerate() {
469                    if (i >> k) & 1 == 0 {
470                        s0 += (v - m0).exp();
471                    } else {
472                        s1 += (v - m1).exp();
473                    }
474                }
475                out.push(m0 + s0.ln() - m1 - s1.ln());
476            }
477        }
478        out
479    }
480
481    #[test]
482    fn the_shared_kernel_gives_the_former_llrs_bit_for_bit() {
483        let mut rng = comnum_core::rng::Rng::new(77);
484        for c in [
485            Constellation::qam(16).unwrap().normalized(),
486            Constellation::psk(8).unwrap().normalized(),
487            Constellation::pam(4).unwrap().normalized(),
488            Constellation::qam(256).unwrap().normalized(),
489        ] {
490            let m = c.order();
491            let z: Vec<C64> = (0..200).map(|i| c.points()[i % m] + rng.complex_gaussian() * 0.3).collect();
492            let shaped: Vec<f64> = (0..m).map(|i| 1.0 + (i % 3) as f64).collect();
493            let lp = log_priors(&c.priors(Some(&shaped)).unwrap());
494            let uniform = vec![-(m as f64).ln(); m];
495            for (px, table) in [(None, &uniform), (Some(&shaped[..]), &lp)] {
496                for max_log in [false, true] {
497                    for s2 in [1e-3, 0.1, 2.0] {
498                        let new = c.llr(&z, s2, px, Jacobian::from_max_log(max_log)).unwrap();
499                        let old = former_llr(&c, &z, s2, table, max_log);
500                        assert!(new.iter().zip(&old).all(|(a, b)| a.to_bits() == b.to_bits()), "M {m}, {s2}");
501                    }
502                }
503            }
504            let mut out = vec![0.0; c.bits_per_symbol() + 1];
505            assert!(c.llr_into(&z[..1], 0.1, None, Jacobian::Exact, &mut out).is_err());
506        }
507    }
508
509    #[test]
510    fn multi_stream_map_demap_round_trip() {
511        let c = Constellation::qam(16).unwrap().normalized();
512        let mut rng = comnum_core::rng::Rng::new(5);
513        let streams: Vec<Vec<u8>> = (0..3).map(|_| (0..64).map(|_| rng.bit()).collect()).collect();
514        let bits = Bits::from_streams(&streams).unwrap();
515        let x = c.map(&bits).unwrap();
516        assert_eq!((x.n_streams(), x.len()), (3, 16));
517        let llr = c.demap(&x, 1e-3).unwrap();
518        let hard: Vec<u8> = comnum_core::signal::hard_decisions(llr.as_slice());
519        assert_eq!(hard, bits.as_slice());
520        assert!(c.map(&Bits::from_stream(vec![0; 3]).unwrap()).is_err());
521        let _ = C64::new(0.0, 0.0);
522    }
523}