Skip to main content

poulpy_core/
dist.rs

1use std::io::{Read, Result, Write};
2
3/// Read-only access to the [`Distribution`] associated with a secret key.
4pub trait GetDistribution {
5    /// Returns the distribution the *base* secret was sampled from.
6    ///
7    /// See [`Distribution`] for what this tag does and does not describe;
8    /// in particular it is not re-derived for secrets obtained as products
9    /// of other secrets.
10    fn dist(&self) -> &Distribution;
11}
12
13/// Mutable access to the [`Distribution`] associated with a secret key.
14pub trait GetDistributionMut {
15    /// Returns a mutable reference to the base-secret distribution tag.
16    ///
17    /// Only sampling routines and the transforms that propagate the tag
18    /// should write through this; see [`Distribution`].
19    fn dist_mut(&mut self) -> &mut Distribution;
20}
21
22impl<T: GetDistribution + ?Sized> GetDistribution for &T {
23    fn dist(&self) -> &Distribution {
24        (*self).dist()
25    }
26}
27
28impl<T: GetDistribution + ?Sized> GetDistribution for &mut T {
29    fn dist(&self) -> &Distribution {
30        (**self).dist()
31    }
32}
33
34impl<T: GetDistributionMut + ?Sized> GetDistributionMut for &mut T {
35    fn dist_mut(&mut self) -> &mut Distribution {
36        (**self).dist_mut()
37    }
38}
39
40/// Describes the probability distribution the *base* secret was sampled
41/// from.
42///
43/// Each variant encodes either a fixed Hamming weight or a per-coefficient
44/// probability. The enum is serialised as a single little-endian `u64`
45/// word via [`write_to`](Self::write_to) / [`read_from`](Self::read_from).
46///
47/// For probabilistic variants the `f64` payload is stored with a
48/// precision loss below 2^-44 (8 least-significant mantissa bits
49/// are discarded to fit the tag byte).
50///
51/// # What this tag means
52///
53/// It records how the key material was originally sampled, which is what
54/// the security estimate and the noise analysis are stated against. It is
55/// *not* a claim that a given buffer's coefficients are, right now, an
56/// i.i.d. sample from that distribution.
57///
58/// The tag is set only by the `fill_*` samplers (and by
59/// [`Distribution::ZERO`] for the debug all-zero secret). Every other
60/// operation on a secret propagates it verbatim.
61///
62/// # Transforms that preserve it
63///
64/// A secret keeps its tag under any transform that permutes and/or negates
65/// coefficients, or that only changes the representation:
66///
67/// - the `X -> X^-1` automorphism used by
68///   `glwe_secret_from_lwe_secret` / `lwe_secret_from_glwe_secret`, and
69///   any other `X -> X^k` automorphism: the multiset of non-zero
70///   coefficients, and hence the Hamming weight and the per-coefficient
71///   marginals, are unchanged (up to sign, which the ternary and binary
72///   families are analysed against anyway);
73/// - flattening a rank-`r` GLWE secret into an LWE secret and back: the
74///   tag describes each polynomial component of the source key and is not
75///   rescaled by the rank;
76/// - DFT preparation ([`GLWESecretPrepared`](crate::layouts::GLWESecretPrepared))
77///   and transfers between backends: pure changes of representation.
78///
79/// # Where it deliberately does not describe the coefficients
80///
81/// [`GLWESecretTensor`](crate::layouts::GLWESecretTensor) holds the products
82/// `s_i * s_j` of a base secret `(s_0, ..., s_{r-1})`, e.g.
83/// `(1, s_0, s_1)^(x)2 = (s_0^2, s_0*s_1, s_1^2)`. Those coefficients are
84/// *not* ternary or binary any more, and no variant of this enum describes
85/// them. The tensor key still carries the base secret's tag, on purpose:
86/// it is the handle on the underlying secret's parameters, from which the
87/// product's own statistics follow.
88///
89/// Concretely, if the base secret has zero-mean coefficients of variance
90/// `s^2` in ring degree `N` (for instance `s^2 = h/N` for
91/// [`TernaryFixed(h)`](Self::TernaryFixed)), then for independent
92/// components `i != j` each coefficient of `s_i * s_j` mod `X^N + 1` is a
93/// sum of `N` independent products and has variance `N * s^4`. The diagonal
94/// blocks `s_i^2` carry twice that, `2 * N * s^4`, because each unordered
95/// pair `s_a * s_b` contributes to the same coefficient from both orders.
96/// Both are measured to hold on the reference backend. The statistics of
97/// the tensor therefore stay a closed-form function of the base
98/// distribution recorded here; see `var_tensor_key` in the noise module.
99#[derive(Clone, Copy, Debug)]
100pub enum Distribution {
101    /// Ternary in {-1, 0, 1} with exactly `h` non-zero coefficients.
102    TernaryFixed(usize),
103    /// Ternary in {-1, 0, 1} where each coefficient is non-zero with probability `p`.
104    TernaryProb(f64),
105    /// Binary in {0, 1} with exactly `h` ones.
106    BinaryFixed(usize),
107    /// Binary in {0, 1} where each coefficient is 1 with probability `p`.
108    BinaryProb(f64),
109    /// Binary in {0, 1} split into blocks of size 2^k, with one 1 per block.
110    BinaryBlock(usize),
111    /// Encapsulated category, only valid within its ephemeral context: cannot
112    /// back a public key and cannot be serialized.
113    ENCAPSULATED(&'static str),
114    /// All-zero secret (debug / testing only).
115    ZERO,
116    /// Uninitialized — no distribution has been set yet.
117    NONE,
118}
119
120const TAG_TERNARY_FIXED: u8 = 0;
121const TAG_TERNARY_PROB: u8 = 1;
122const TAG_BINARY_FIXED: u8 = 2;
123const TAG_BINARY_PROB: u8 = 3;
124const TAG_BINARY_BLOCK: u8 = 4;
125const TAG_ZERO: u8 = 5;
126const TAG_NONE: u8 = 6;
127
128use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
129
130impl Distribution {
131    /// Packs a tag (u8) and an f64 into a single u64.
132    /// The f64 is shifted right by 8, discarding the 8 least-significant
133    /// mantissa bits (precision loss < 2^-44), and the tag is placed
134    /// in the freed top byte.
135    #[inline]
136    fn pack_f64(tag: u8, p: f64) -> u64 {
137        (tag as u64) << 56 | (p.to_bits() >> 8)
138    }
139
140    /// Unpacks a tag-stripped 56-bit payload back into an f64
141    /// by shifting left by 8 (the 8 LSB mantissa bits become zero).
142    #[inline]
143    fn unpack_f64(payload: u64) -> f64 {
144        f64::from_bits(payload << 8)
145    }
146
147    /// Serialises this distribution as a single little-endian `u64` word.
148    ///
149    /// The top byte carries a variant tag; the lower 56 bits carry either
150    /// a `usize` payload (for fixed/block variants) or a truncated `f64`
151    /// (for probabilistic variants).
152    ///
153    /// [`ENCAPSULATED`](Self::ENCAPSULATED) has no wire form and returns
154    /// [`std::io::ErrorKind::InvalidData`].
155    pub fn write_to<W: Write>(&self, writer: &mut W) -> Result<()> {
156        let word: u64 = match self {
157            Distribution::TernaryFixed(v) => (TAG_TERNARY_FIXED as u64) << 56 | (*v as u64),
158            Distribution::TernaryProb(p) => Self::pack_f64(TAG_TERNARY_PROB, *p),
159            Distribution::BinaryFixed(v) => (TAG_BINARY_FIXED as u64) << 56 | (*v as u64),
160            Distribution::BinaryProb(p) => Self::pack_f64(TAG_BINARY_PROB, *p),
161            Distribution::BinaryBlock(v) => (TAG_BINARY_BLOCK as u64) << 56 | (*v as u64),
162            Distribution::ZERO => (TAG_ZERO as u64) << 56,
163            Distribution::NONE => (TAG_NONE as u64) << 56,
164            Distribution::ENCAPSULATED(name) => {
165                return Err(std::io::Error::new(
166                    std::io::ErrorKind::InvalidData,
167                    format!("Distribution::ENCAPSULATED({name}) is not serializable"),
168                ));
169            }
170        };
171        writer.write_u64::<LittleEndian>(word)
172    }
173
174    /// Deserialises a [`Distribution`] from a single little-endian `u64` word.
175    ///
176    /// Returns [`std::io::ErrorKind::InvalidData`] if the tag byte is unrecognised.
177    pub fn read_from<R: Read>(reader: &mut R) -> Result<Self> {
178        let word = reader.read_u64::<LittleEndian>()?;
179        let tag = (word >> 56) as u8;
180        let payload = word & 0x00FF_FFFF_FFFF_FFFF;
181
182        let dist = match tag {
183            TAG_TERNARY_FIXED => Distribution::TernaryFixed(payload as usize),
184            TAG_TERNARY_PROB => Distribution::TernaryProb(Self::unpack_f64(payload)),
185            TAG_BINARY_FIXED => Distribution::BinaryFixed(payload as usize),
186            TAG_BINARY_PROB => Distribution::BinaryProb(Self::unpack_f64(payload)),
187            TAG_BINARY_BLOCK => Distribution::BinaryBlock(payload as usize),
188            TAG_ZERO => Distribution::ZERO,
189            TAG_NONE => Distribution::NONE,
190            _ => {
191                return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid tag"));
192            }
193        };
194        Ok(dist)
195    }
196}
197
198impl PartialEq for Distribution {
199    fn eq(&self, other: &Self) -> bool {
200        use Distribution::*;
201        match (self, other) {
202            (TernaryFixed(a), TernaryFixed(b)) => a == b,
203            (TernaryProb(a), TernaryProb(b)) => a.to_bits() == b.to_bits(),
204            (BinaryFixed(a), BinaryFixed(b)) => a == b,
205            (BinaryProb(a), BinaryProb(b)) => a.to_bits() == b.to_bits(),
206            (BinaryBlock(a), BinaryBlock(b)) => a == b,
207            (ENCAPSULATED(a), ENCAPSULATED(b)) => a == b,
208            (ZERO, ZERO) => true,
209            (NONE, NONE) => true,
210            _ => false,
211        }
212    }
213}
214
215impl Eq for Distribution {}