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}