Skip to main content

stenoxide_core/generate/
carrier.rs

1//! Drawing one sample of the cover distribution, conditioned on the bit it has
2//! to carry.
3//!
4//! # Why rejection sampling and not an overwrite
5//!
6//! The obvious construction is to draw a sample and then set its least
7//! significant bit to the carrier bit. It is also exactly what destroys the one
8//! property this mode exists for. Overwriting redistributes mass within each
9//! pair of values `(2k, 2k+1)`: every sample that landed on the wrong side of
10//! the pair is moved to the other, which flattens the natural imbalance of the
11//! histogram a detector can measure. Applied at the same load factor and
12//! measured against the `pair_ratio` statistic, an overwrite moves the value by
13//! hundreds of pooled standard deviations on a two-peaked texture and by some
14//! fifteen on a texture like this one, whose levels are spread across the
15//! range. The conditioned draw does not move it at all.
16//!
17//! Rejection sampling is exact. The accepted samples are distributed as the
18//! cover restricted to one parity class, and because the two classes carry
19//! equal mass, mixing them over a uniform carrier bit reproduces the
20//! unconditioned distribution itself:
21//!
22//! ```text
23//! sum over b of  P(sample = v | LSB = b) P(b)  =  P(sample = v)
24//! ```
25//!
26//! So the container that carries a message and the container that carries
27//! nothing are draws from one distribution. There are not two hypotheses for a
28//! detector to separate.
29//!
30//! The equality holds as long as the least significant bit of the
31//! unconditioned distribution is a fair coin, which is what
32//! [`GRAIN_SIGMA`](super::texture::GRAIN_SIGMA) is chosen for.
33//!
34//! # Why the loop is bounded
35//!
36//! Each draw accepts with probability one half, so two are needed on average
37//! and the loop terminates with probability one. That is not the same as
38//! terminating: a base level pressed against the clamp could make one parity
39//! unreachable, and the loop would then spin forever. The texture keeps every
40//! base level twelve standard deviations clear of both ends, so the bound is
41//! never approached — but it is there, and reaching it is an error rather than
42//! a panic, because a library that can abort a caller's process has no business
43//! being linked into one.
44
45use rand::rngs::StdRng;
46use rand::Rng;
47
48use std::f32::consts::TAU;
49use std::fmt;
50
51use super::texture::GRAIN_SIGMA;
52
53/// Draws one conditioned sample may take before the attempt is abandoned.
54///
55/// Each iteration accepts with probability one half, so reaching sixty-four is
56/// a `2^-64` event for any base level the texture can produce. It is a guard
57/// against a level that should not exist, not a tuning parameter.
58const MAX_DRAWS: usize = 64;
59
60/// The one way conditioned sampling can fail.
61///
62/// A distinct type rather than a variant of the caller's error enum: it says
63/// something specific about the texture — that a base level ended up against
64/// the end of the range — and the caller is what decides how to report it. It
65/// is re-exported by [`crate::generate`], which is where a caller meets it.
66#[derive(Debug)]
67pub struct RejectionExhausted;
68
69impl fmt::Display for RejectionExhausted {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(
72            f,
73            "conditioned sampling did not converge in {MAX_DRAWS} draws; a base level of the \
74             texture sits against the end of the range"
75        )
76    }
77}
78
79impl std::error::Error for RejectionExhausted {}
80
81/// One sample of the cover distribution, unconstrained.
82///
83/// The distribution every claim in this module is about: `floor(base + N(0,
84/// sigma))`, clamped to the representable range.
85pub(crate) fn draw_free(rng: &mut StdRng, base: f32) -> u8 {
86    (base + gaussian(rng, GRAIN_SIGMA)).clamp(0.0, 255.0) as u8
87}
88
89/// One sample of the cover distribution, conditioned on its least significant
90/// bit being `bit`.
91///
92/// # Errors
93///
94/// Returns [`RejectionExhausted`] when [`MAX_DRAWS`] draws all landed on the
95/// wrong parity, which cannot happen for a base level this crate's texture
96/// produces; see the module documentation.
97pub(crate) fn draw_with_lsb(
98    rng: &mut StdRng,
99    base: f32,
100    bit: u8,
101) -> Result<u8, RejectionExhausted> {
102    for _ in 0..MAX_DRAWS {
103        let value = draw_free(rng, base);
104        if value & 1 == bit & 1 {
105            return Ok(value);
106        }
107    }
108
109    Err(RejectionExhausted)
110}
111
112/// One normally distributed sample of standard deviation `sigma`, by
113/// Box-Muller.
114///
115/// Only the cosine half of the transform is kept and the sine half discarded.
116/// Storing the spare would make the function stateful, and the number of draws
117/// this makes per sample is not fixed — it is what the rejection loop decides —
118/// so a carried-over value would tie one sample's grain to the parity of the
119/// one before it.
120fn gaussian(rng: &mut StdRng, sigma: f32) -> f32 {
121    // Bounded away from zero: `ln(0)` is negative infinity, and a single
122    // infinite grain sample would clamp to a black or white pixel.
123    let uniform = unit(rng).max(f32::EPSILON);
124    let angle = unit(rng) * TAU;
125
126    sigma * (-2.0 * uniform.ln()).sqrt() * angle.cos()
127}
128
129/// A uniform draw in `[0, 1)`, taken straight from the generator.
130///
131/// Twenty-four bits, which is the resolution of an `f32` mantissa: any more
132/// would only produce values that round to the same float. Written out rather
133/// than taken from the range sampler because this is called some fifty million
134/// times per container, and the general form spends a rejection loop and a
135/// widening conversion on a question that is answered here by a shift.
136fn unit(rng: &mut StdRng) -> f32 {
137    (rng.next_u32() >> 8) as f32 / 16_777_216.0
138}
139
140#[cfg(test)]
141mod tests {
142    // The crate-wide bans on panicking helpers reach into `cfg(test)` code as
143    // well. A test that cannot panic cannot fail, so they are lifted here and
144    // only here.
145    #![allow(clippy::expect_used)]
146    #![allow(clippy::panic)]
147
148    use super::*;
149
150    use rand::SeedableRng;
151
152    /// A base level in the middle of the range, where the texture keeps them.
153    const BASE: f32 = 128.0;
154
155    /// Samples each statistical assertion below is made over.
156    const SAMPLES: usize = 20_000;
157
158    /// A generator seeded for reproducibility rather than for secrecy.
159    fn rng(seed: u64) -> StdRng {
160        StdRng::seed_from_u64(seed)
161    }
162
163    /// The conditioned draw returns the parity it was asked for, every time.
164    #[test]
165    fn a_conditioned_sample_carries_the_bit_it_was_given() {
166        let mut rng = rng(11);
167
168        for index in 0..SAMPLES {
169            let bit = (index % 2) as u8;
170            match draw_with_lsb(&mut rng, BASE, bit) {
171                Ok(sample) => assert_eq!(sample & 1, bit),
172                Err(error) => panic!("a mid-range level must converge: {error}"),
173            }
174        }
175    }
176
177    /// Conditioning does not move the distribution.
178    ///
179    /// The claim the mode rests on, checked the only way a test can check it:
180    /// a stream of samples conditioned on alternating bits has the same mean
181    /// and spread as a stream drawn freely. A construction that overwrote the
182    /// bit instead would pass the mean and fail the histogram, so the pairing
183    /// is asserted as well.
184    #[test]
185    fn conditioning_reproduces_the_unconditioned_distribution() {
186        let mut free_rng = rng(101);
187        let mut conditioned_rng = rng(202);
188
189        let free: Vec<u8> = (0..SAMPLES)
190            .map(|_| draw_free(&mut free_rng, BASE))
191            .collect();
192        let conditioned: Vec<u8> = (0..SAMPLES)
193            .map(|index| {
194                draw_with_lsb(&mut conditioned_rng, BASE, (index % 2) as u8)
195                    .expect("a mid-range level must converge")
196            })
197            .collect();
198
199        let mean = |samples: &[u8]| {
200            samples.iter().map(|&sample| sample as f64).sum::<f64>() / samples.len() as f64
201        };
202
203        // The standard error of the mean at this sample count is about
204        // `sigma / sqrt(n)` = 0.014 levels, so a tenth of a level is seven
205        // standard errors and still far below anything conditioning could do.
206        assert!(
207            (mean(&free) - mean(&conditioned)).abs() < 0.1,
208            "free {} against conditioned {}",
209            mean(&free),
210            mean(&conditioned)
211        );
212
213        // The histogram, pair by pair. Overwriting the bit would empty one half
214        // of every pair into the other; a conditioned draw leaves the pair
215        // populated as the cover distribution populates it.
216        let count = |samples: &[u8], value: u8| {
217            samples.iter().filter(|&&sample| sample == value).count()
218        };
219        for value in 120..=136u8 {
220            let free_count = count(&free, value) as f64;
221            let conditioned_count = count(&conditioned, value) as f64;
222            let spread = (free_count + conditioned_count).sqrt().max(1.0);
223
224            assert!(
225                (free_count - conditioned_count).abs() < 6.0 * spread,
226                "value {value}: free {free_count} against conditioned {conditioned_count}"
227            );
228        }
229    }
230
231    /// The free draw stays inside the representable range.
232    #[test]
233    fn a_free_sample_is_clamped_to_the_range() {
234        let mut rng = rng(7);
235
236        for base in [0.0f32, 4.0, 128.0, 251.0, 255.0] {
237            for _ in 0..1_000 {
238                // The type is `u8`, so the assertion is that the clamp happened
239                // rather than that the conversion wrapped: a level of `-3.0`
240                // cast directly would be `0` and one of `300.0` would saturate,
241                // and neither is something to leave to a cast.
242                let _sample = draw_free(&mut rng, base);
243            }
244        }
245    }
246
247    /// A base level against the end of the range is reported, not spun on.
248    ///
249    /// The texture never produces one — that is checked in
250    /// [`super::super::texture`] — so this is the only place the guard can be
251    /// exercised at all.
252    #[test]
253    fn an_unreachable_parity_is_an_error_rather_than_a_hang() {
254        let mut rng = rng(3);
255
256        // At a base of `-1000` every draw clamps to zero, so an odd sample is
257        // unreachable and the loop must give up.
258        let error = draw_with_lsb(&mut rng, -1_000.0, 1)
259            .map(|_| ())
260            .expect_err("an unreachable parity must be reported");
261
262        assert!(error.to_string().contains("did not converge"));
263        assert!(draw_with_lsb(&mut rng, -1_000.0, 0).is_ok());
264    }
265}