Skip to main content

poulpy_ckks/
lib.rs

1#![feature(f128)]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! # poulpy-ckks
4//!
5//! Backend-agnostic implementation of the CKKS (Cheon-Kim-Kim-Song)
6//! homomorphic encryption scheme, built on top of the low-level primitives
7//! provided by `poulpy-core`, `poulpy-hal`, and the available compute
8//! backends (`poulpy-cpu-ref`, `poulpy-cpu-avx`).
9//!
10//! The crate uses a bivariate polynomial representation over the Torus
11//! (base-`2^{base2k}` digits) instead of the RNS representation used by
12//! most other CKKS libraries. Public precision management is exposed through
13//! [`CKKSMeta`]:
14//!
15//! - `log_delta`: base-2 logarithm of the encoded plaintext scaling factor
16//! - `log_budget`: remaining homomorphic headroom, also tracked in bits
17//!
18//! [`CKKSMeta`] also records the [`SlotsKind`] of a value: whether its slots are
19//! known to be real, or may carry an imaginary part. Operations compose that
20//! claim, keeping `Real` only when every operand is real, so a caller can state
21//! it once at encoding time and have the pipelines specialize on it.
22//!
23//! Together they define the semantic torus width of a value:
24//! `k() = log_delta + log_budget`.
25//! Storage is rounded up to the next multiple of `base2k`, so the allocated
26//! capacity `max_k()` may exceed the effective width `k()`. Arithmetic APIs
27//! update this metadata for you; buffers always stay at their allocated
28//! width, and allocating a destination at exactly the `k` you want is how
29//! results are narrowed.
30//!
31//! Safe add/sub operations return K-normalized ciphertexts. Their
32//! unnormalized variants live on [`api::CKKSAddOps`] and [`api::CKKSSubOps`]
33//! and write into an [`layouts::UnnormalizedCKKSCiphertext`] for callers who
34//! want to fuse several linear steps before normalizing explicitly. Limb
35//! digits in that wrapper may hold un-propagated carries (wider than `base2k`
36//! bits), so passing it to any DFT-domain primitive (keyswitching,
37//! convolution, automorphisms) would produce incorrect decryptions. The
38//! wrapper does not implement [`GLWEToBackendRef`] or [`GLWEToBackendMut`],
39//! making such misuse a compile error. Call
40//! [`layouts::UnnormalizedCKKSCiphertext::normalize`] before the next
41//! keyswitching or convolution step.
42//!
43//! ## Modules
44//!
45//! | Module | Role |
46//! |--------|------|
47//! | [`approximation`] | Reusable minimax fitting, precision/depth selection, composite sign generation, and prepared interval-mapped polynomial evaluation |
48//! | [`encoding`] | CKKS encoders/decoders, including slot-wise real/imaginary packing |
49//! | [`layouts`] | CKKS ciphertext/plaintext wrappers and metadata-aware allocation helpers |
50//! | [`presets`] | Ready-to-use parameter sets |
51//! | [`api`] | The public op traits: leveled arithmetic (add, sub, mul, neg, rotate, conjugate), encryption, decryption, rescale, and scratch sizing |
52//! | [`api::CKKSBootstrappingOps`] | The CKKS bootstrapping pipeline: its one native primitive ModUp (modulus raise), plus CoeffsToSlots / SlotsToCoeffs and EvalMod re-exported as supertraits ([`api::CKKSDFTOps`] / [`api::CKKSEvalModOps`]); parameterized by [`layouts::BootstrappingPlan`] |
53//! | [`api::CKKSPaCoOps`] | PaCo bootstrapping without ModUp or EvalMod; parameterized by [`layouts::PaCoPlan`] and a compiled [`layouts::PaCoContext`] |
54
55use poulpy_core::layouts::{
56    Base2K, Degree, GLWEInfos, GLWELayout, GLWEToBackendMut, GLWEToBackendRef, LWEInfos, Rank, TorusPrecision,
57};
58use poulpy_hal::layouts::Backend;
59
60pub mod api;
61pub mod approximation;
62pub(crate) mod cosine;
63pub mod default;
64pub(crate) mod delegates;
65
66/// Re-exports for use inside this crate's exported macros (e.g.
67/// [`impl_ckks_dft_defaults`]), so an invoking backend crate does not need
68/// `anyhow` as a direct dependency. Not part of the public API.
69#[doc(hidden)]
70pub mod __macro_reexports {
71    pub use anyhow;
72}
73pub mod encoding;
74mod error;
75mod eval_lut;
76pub mod layouts;
77/// One-stop imports for the common CKKS path.
78///
79/// `use poulpy_ckks::prelude::*;` brings in the op traits (add/sub/mul/…,
80/// encrypt/decrypt, scratch sizing), the ciphertext/plaintext containers with
81/// their allocation helpers, and the metadata types — everything the
82/// encode → encrypt → evaluate → decrypt → decode loop needs from this crate.
83/// Key material and the `Module`/scratch machinery come from `poulpy-core` and
84/// `poulpy-hal`.
85pub mod prelude {
86    pub use crate::api::{
87        CKKSAddOps, CKKSAllOpsTmpBytes, CKKSApproximationOps, CKKSConjugateOps, CKKSCopyOps, CKKSDecryptOps, CKKSEncodingHostOps,
88        CKKSEncodingOps, CKKSEncryptOps, CKKSImagOps, CKKSMulOps, CKKSNegOps, CKKSPlaintextVecOps, CKKSPow2Ops, CKKSRotateOps,
89        CKKSSubOps,
90    };
91    pub use crate::layouts::{
92        CKKSCiphertext, CKKSModuleAlloc, CKKSPlaintext, PolynomialApproximation, UnnormalizedCKKSCiphertext,
93    };
94    pub use crate::{
95        CKKSCompositionError, CKKSError, CKKSInfos, CKKSLayout, CKKSMeta, CKKSResult, CoeffsMeta, Quad, SetCKKSInfos, SlotsKind,
96    };
97}
98pub mod oep;
99pub mod polynomial;
100pub mod power_basis;
101pub mod presets;
102pub mod scalar;
103#[cfg(feature = "test-utils")]
104pub mod test_suite;
105pub use error::{CKKSCompositionError, CKKSError, CKKSResult};
106pub(crate) use error::{
107    checked_log_budget_sub, checked_mul_ct_log_budget, checked_mul_pt_log_budget, ckks_bail, ckks_ensure, ensure_base2k_match,
108    ensure_plaintext_alignment, ensure_plaintext_coeff_in_range, ensure_plaintext_degree_match,
109};
110/// Quad-precision (IEEE 754 binary128) CKKS scalar.
111///
112/// Always the portable [`scalar::Quad`] newtype over the primitive `f128`, in
113/// every configuration. Under the `libquadmath` feature on non-Apple x86_64
114/// only its libm-backed math is routed through libquadmath (faster for
115/// on-the-fly FFT-table builds); storage, codecs, and exact arithmetic are
116/// unchanged, so the type — and its `bytemuck::Pod` encoding — is identical
117/// across features.
118pub use scalar::Quad;
119
120/// Backend-compatible shared CKKS plaintext storage.
121pub trait CKKSPlaintextToBackendRef<BE: Backend>: GLWEToBackendRef<BE> + GLWEInfos + CKKSInfos {}
122
123impl<BE: Backend, T> CKKSPlaintextToBackendRef<BE> for T where T: GLWEToBackendRef<BE> + GLWEInfos + CKKSInfos {}
124
125/// Backend-compatible mutable CKKS plaintext storage.
126///
127/// Implemented by owned plaintexts and scratch-backed plaintext views alike.
128pub trait CKKSPlaintextToBackendMut<BE: Backend>: CKKSPlaintextToBackendRef<BE> + GLWEToBackendMut<BE> {}
129
130impl<BE: Backend, T> CKKSPlaintextToBackendMut<BE> for T where T: CKKSPlaintextToBackendRef<BE> + GLWEToBackendMut<BE> {}
131
132/// Marker bound for CKKS ciphertext type parameters.
133///
134/// Combines [`GLWEInfos`] (which already implies [`poulpy_core::layouts::LWEInfos`])
135/// with [`CKKSInfos`] to collapse the repeated `GLWEInfos + CKKSInfos` pair found
136/// throughout the API into a single, named constraint.
137pub trait CKKSCtBounds: GLWEInfos + CKKSInfos {}
138
139impl<T: GLWEInfos + CKKSInfos> CKKSCtBounds for T {}
140
141/// Which subfield the encoded slots are known to live in.
142///
143/// The reals are a subring of the complexes, so the two variants are ordered
144/// claims rather than exclusive tags: [`SlotsKind::Real`] is the stronger one,
145/// [`SlotsKind::Complex`] is always sound. Operations compose the claim with
146/// [`SlotsKind::join`], which keeps `Real` only when every operand is `Real`.
147/// `Complex` is the default, so a value that never states its kind is never
148/// mistaken for a real one.
149#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
150pub enum SlotsKind {
151    /// Every slot has a zero imaginary part.
152    Real,
153    /// Slots may carry a nonzero imaginary part.
154    #[default]
155    Complex,
156}
157
158impl SlotsKind {
159    /// Kind of a value built from two operands: `Real` only when both are.
160    pub fn join(self, other: Self) -> Self {
161        match (self, other) {
162            (Self::Real, Self::Real) => Self::Real,
163            _ => Self::Complex,
164        }
165    }
166
167    /// Whether the slots are known to be real.
168    pub fn is_real(self) -> bool {
169        self == Self::Real
170    }
171}
172
173#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
174/// CKKS semantic precision metadata carried by ciphertexts and plaintexts.
175///
176/// `log_delta` is the scaling precision of the encoded value. The remaining
177/// homomorphic headroom (`log_budget`) is *not* stored here: it is derived from
178/// the wrapped GLWE's torus width `k` as `log_budget = k - log_delta`.
179pub struct CKKSMeta {
180    /// Base 2 logarithm of the decimal precision.
181    pub log_delta: usize,
182    /// Sparse-packing factor: `log2` of the coefficient gap (equivalently, of the
183    /// slot replication). `0` is dense / full packing (`N/2` slots). For
184    /// `log_sparsity = s` the message polynomial is sparse — `M(X^{2^s})` — and
185    /// carries `(N/2) >> s` distinct slots, each replicated `2^s` times, i.e. a
186    /// coefficient gap of `2^s`.
187    pub log_sparsity: usize,
188    /// Subfield the slots are known to live in. See [`SlotsKind`].
189    pub slots: SlotsKind,
190}
191
192/// Common metadata accessors for CKKS ciphertext and plaintext containers.
193///
194/// This trait exposes the semantic precision of a value independently from the
195/// raw limb storage used by the underlying torus representation. `log_budget` is
196/// derived from the container's torus width `k` (from the wrapped GLWE) and
197/// `log_delta`, so it is only available on containers, not on a bare [`CKKSMeta`].
198pub trait CKKSInfos: LWEInfos {
199    /// Returns the complete metadata pair.
200    fn meta(&self) -> CKKSMeta;
201
202    /// Returns the base-2 logarithm of the encoded decimal scaling factor.
203    fn log_delta(&self) -> usize {
204        self.meta().log_delta
205    }
206
207    /// Returns the base-2 logarithm of the remaining homomorphic capacity,
208    /// derived from the container's torus width: `k − log_delta`.
209    fn log_budget(&self) -> usize {
210        self.k().as_usize().saturating_sub(self.log_delta())
211    }
212
213    /// Returns the sparse-packing factor (`log2` of the coefficient gap / slot
214    /// replication); `0` is dense. See [`CKKSMeta::log_sparsity`].
215    fn log_sparsity(&self) -> usize {
216        self.meta().log_sparsity
217    }
218
219    /// Returns the subfield the slots are known to live in. See [`SlotsKind`].
220    fn slots(&self) -> SlotsKind {
221        self.meta().slots
222    }
223}
224
225/// Mutable CKKS metadata access for ciphertext/plaintext containers.
226pub trait SetCKKSInfos: CKKSInfos {
227    /// Replaces the semantic CKKS metadata (`log_delta`, `log_sparsity`). Does not
228    /// touch the wrapped GLWE's torus width `k`, so `log_budget` is re-derived
229    /// against the (unchanged) `k`. Use [`Self::set_log_delta`] to relabel the
230    /// scale while preserving `log_budget`.
231    fn set_meta(&mut self, meta: CKKSMeta);
232
233    /// Sets the wrapped GLWE's torus width `k` (the total `log_delta + log_budget`).
234    fn set_k(&mut self, k: TorusPrecision);
235
236    /// Updates only the base-2 logarithm of the encoded scaling factor, preserving
237    /// `log_budget` by shifting the torus width `k` accordingly.
238    fn set_log_delta(&mut self, log_delta: usize) {
239        let log_budget = self.log_budget();
240        let mut meta = self.meta();
241        meta.log_delta = log_delta;
242        self.set_meta(meta);
243        self.set_k((log_budget + log_delta).into());
244    }
245
246    /// Updates only the base-2 logarithm of the remaining homomorphic budget by
247    /// setting the torus width `k = log_budget + log_delta`.
248    fn set_log_budget(&mut self, log_budget: usize) {
249        self.set_k((log_budget + self.log_delta()).into());
250    }
251
252    /// Updates only the sparse-packing factor. See [`CKKSMeta::log_sparsity`].
253    fn set_log_sparsity(&mut self, log_sparsity: usize) {
254        let mut meta = self.meta();
255        meta.log_sparsity = log_sparsity;
256        self.set_meta(meta);
257    }
258
259    /// Updates only the slot kind. See [`SlotsKind`].
260    fn set_slots(&mut self, slots: SlotsKind) {
261        let mut meta = self.meta();
262        meta.slots = slots;
263        self.set_meta(meta);
264    }
265}
266
267/// Allocation / precision spec for a CKKS value.
268///
269/// Bundles a core [`GLWELayout`] — which carries `n`, `base2k`, the torus width
270/// `k`, and `rank` — with the [`CKKSMeta`] (`log_delta`, `log_sparsity`). The
271/// budget is derived as `log_budget = k - log_delta`, so `k` lives in the GLWE
272/// layout exactly as it does on a wrapped ciphertext/plaintext.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub struct CKKSLayout {
275    pub glwe_layout: GLWELayout,
276    pub meta: CKKSMeta,
277}
278
279/// Coefficient metadata for plan-compiled operands: the DFT factor diagonals
280/// ([`layouts::DFTPlan`]), the EvalMod polynomial coefficients
281/// ([`layouts::EvalModPlan`]), and the BSGS polynomial encoders
282/// ([`polynomial::EncodeBSGS`]).
283///
284/// The reduced form of a [`CKKSLayout`]: only the torus width `k` the operand
285/// plaintexts are allocated with and the [`CKKSMeta`] they are stamped with.
286/// Plans carry no ring or radix information — `n` follows the module and
287/// `base2k` is passed explicitly at compile time — so there is nothing to fill
288/// with placeholders.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub struct CoeffsMeta {
291    /// Torus width the operand plaintexts are allocated with.
292    pub k: TorusPrecision,
293    /// CKKS metadata (`log_delta`, `log_sparsity`) the operands are stamped with.
294    pub meta: CKKSMeta,
295}
296
297impl CoeffsMeta {
298    /// Dense (`log_sparsity = 0`) coefficient meta with
299    /// `k = log_delta + log_budget`.
300    pub fn from_delta_budget(log_delta: usize, log_budget: usize) -> Self {
301        Self {
302            k: (log_delta + log_budget).into(),
303            meta: CKKSMeta {
304                log_delta,
305                log_sparsity: 0,
306                slots: SlotsKind::Complex,
307            },
308        }
309    }
310
311    /// The operand encoding scale.
312    pub fn log_delta(&self) -> usize {
313        self.meta.log_delta
314    }
315
316    /// The operand headroom: `k − log_delta` (saturating).
317    pub fn log_budget(&self) -> usize {
318        usize::from(self.k).saturating_sub(self.meta.log_delta)
319    }
320}
321
322/// Narrowing conversion: keeps `k` and the [`CKKSMeta`], drops the ring/radix
323/// fields plans never consume.
324impl From<CKKSLayout> for CoeffsMeta {
325    fn from(layout: CKKSLayout) -> Self {
326        Self {
327            k: layout.glwe_layout.k,
328            meta: layout.meta,
329        }
330    }
331}
332
333impl LWEInfos for CKKSLayout {
334    fn n(&self) -> Degree {
335        self.glwe_layout.n()
336    }
337
338    fn base2k(&self) -> Base2K {
339        self.glwe_layout.base2k()
340    }
341
342    fn max_size(&self) -> usize {
343        self.glwe_layout.max_size()
344    }
345
346    fn k(&self) -> TorusPrecision {
347        self.glwe_layout.k()
348    }
349}
350
351impl GLWEInfos for CKKSLayout {
352    fn rank(&self) -> Rank {
353        self.glwe_layout.rank()
354    }
355}
356
357impl CKKSInfos for CKKSLayout {
358    fn meta(&self) -> CKKSMeta {
359        self.meta
360    }
361}
362
363/// Bits a binary add/sub must shift its result down to fit `res`: the excess of
364/// the **natural result width** — `min(log_delta) + min(log_budget)`, the meta
365/// the operation stamps — over the destination's requested `res.k()`. Using the natural width (rather
366/// than `min(a.k, b.k)`, which is `≥` it whenever deltas *and* budgets both
367/// differ) charges exactly the budget the narrower destination forces and no
368/// more; the larger-delta operand's truncated tail lies below the claimed
369/// `min(log_delta)` precision. This mirrors the mul family's
370/// `(res_log_budget + res_log_delta).saturating_sub(res_max_k)`.
371pub(crate) fn ckks_offset_binary<R, A, B>(res: &R, a: &A, b: &B) -> usize
372where
373    R: CKKSInfos + ?Sized,
374    A: CKKSInfos + ?Sized,
375    B: CKKSInfos + ?Sized,
376{
377    let natural_k = a.log_delta().min(b.log_delta()) + a.log_budget().min(b.log_budget());
378    natural_k.saturating_sub(res.k().as_usize())
379}
380
381pub(crate) fn ckks_offset_unary<R, A>(res: &R, a: &A) -> usize
382where
383    R: CKKSInfos + ?Sized,
384    A: CKKSInfos + ?Sized,
385{
386    a.k().as_usize().saturating_sub(res.k().as_usize())
387}
388
389/// Shared unary-op preamble: aligns `src` into `dst` (left shift by
390/// `offset + extra_shift`) and stamps `src`'s metadata with the budget charged
391/// by `offset + extra_charge`. Validates **before** mutating, so on `Err`
392/// (insufficient budget) `dst` is untouched. Returns the computed offset.
393///
394/// This is the single implementation of the "shift + stamp" sequence the
395/// copy/pow2/add-pt/sub-pt into-ops previously hand-rolled (H1 in the 2026-07
396/// review was a drift bug in exactly this preamble).
397pub(crate) fn ckks_shift_stamp_unary<BE, M, Dst, Src>(
398    module: &M,
399    op: &'static str,
400    dst: &mut Dst,
401    src: &Src,
402    extra_shift: usize,
403    extra_charge: usize,
404    scratch: &mut poulpy_hal::layouts::ScratchArena<'_, BE>,
405) -> CKKSResult<()>
406where
407    BE: Backend,
408    M: poulpy_core::GLWEShift<BE> + ?Sized,
409    Dst: GLWEToBackendMut<BE> + CKKSInfos + SetCKKSInfos,
410    Src: GLWEToBackendRef<BE> + CKKSInfos,
411{
412    let offset = ckks_offset_unary(dst, src);
413    let log_budget = checked_log_budget_sub(op, src.log_budget(), offset + extra_charge)?;
414    module.glwe_lsh(dst, src, offset + extra_shift, scratch);
415    dst.set_meta(src.meta());
416    dst.set_log_budget(log_budget);
417    Ok(())
418}
419
420#[cfg(test)]
421mod slots_kind_tests {
422    use super::SlotsKind::{self, Complex, Real};
423
424    #[test]
425    fn join_keeps_real_only_when_both_operands_are_real() {
426        assert_eq!(Real.join(Real), Real);
427        assert_eq!(Real.join(Complex), Complex);
428        assert_eq!(Complex.join(Real), Complex);
429        assert_eq!(Complex.join(Complex), Complex);
430    }
431
432    #[test]
433    fn unstated_kind_is_complex() {
434        assert_eq!(SlotsKind::default(), Complex);
435        assert!(!SlotsKind::default().is_real());
436    }
437}
438
439#[cfg(test)]
440mod offset_tests {
441    use super::*;
442
443    fn layout(log_delta: usize, log_budget: usize) -> CKKSLayout {
444        CKKSLayout {
445            glwe_layout: poulpy_core::layouts::GLWELayout {
446                n: Degree(0),
447                base2k: Base2K(1),
448                k: (log_delta + log_budget).into(),
449                rank: Rank(1),
450            },
451            meta: CKKSMeta {
452                log_delta,
453                log_sparsity: 0,
454                slots: SlotsKind::Complex,
455            },
456        }
457    }
458
459    /// The binary offset charges exactly the natural result width's excess over
460    /// the destination — not `min(a.k, b.k)`, which over-charges when deltas
461    /// and budgets both differ.
462    #[test]
463    fn offset_binary_charges_natural_result_width() {
464        // Deltas AND budgets differ: natural width = 30 + 20 = 50, while
465        // min(a.k, b.k) = 55 — the old formula charged 5 spurious bits.
466        let a = layout(40, 20); // k = 60
467        let b = layout(30, 25); // k = 55
468        let res = layout(30, 20); // max_k = 50
469        assert_eq!(ckks_offset_binary(&res, &a, &b), 0);
470
471        // A genuinely narrower destination still charges the difference.
472        let narrow = layout(30, 12); // max_k = 42
473        assert_eq!(ckks_offset_binary(&narrow, &a, &b), 8);
474
475        // Degenerate cases match the old formula: equal budgets…
476        let c = layout(40, 25); // k = 65
477        let d = layout(30, 25); // k = 55
478        assert_eq!(ckks_offset_binary(&narrow, &c, &d), (30 + 25) - 42);
479        // …and equal deltas.
480        let e = layout(30, 30); // k = 60
481        assert_eq!(ckks_offset_binary(&narrow, &d, &e), (30 + 25) - 42);
482    }
483}