Skip to main content

poulpy_ckks/
lib.rs

1#![feature(f128)]
2#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
3#![cfg_attr(test, allow(clippy::disallowed_methods))]
4#![deny(rustdoc::broken_intra_doc_links)]
5//! # poulpy-ckks
6//!
7//! Backend-agnostic implementation of the CKKS (Cheon-Kim-Kim-Song)
8//! homomorphic encryption scheme, built from `poulpy-core` and `poulpy-hal`.
9//!
10//! Public calls dispatch through explicit backend [`oep`] contracts. Public
11//! [`reference`](mod@crate::reference) algorithms compose lower-layer operations;
12//! simple CKKS compositions are crate-private derived defaults on the contracts.
13//! A backend may replace a method while reusing the reference for other members
14//! of its family. Replacements must compute the same circuit and pass parity
15//! against a caller-selected validated implementation. Validation is transitive
16//! for the operations and parameter ranges covered by those tests.
17//!
18//! The crate uses a bivariate polynomial representation over the Torus
19//! (base-`2^{base2k}` digits) instead of the RNS representation used by
20//! most other CKKS libraries. Public precision management is exposed through
21//! [`CKKSMeta`]:
22//!
23//! - `log_delta`: base-2 logarithm of the encoded plaintext scaling factor
24//! - `log_sparsity`: base-2 logarithm of the slot replication
25//!
26//! Remaining homomorphic headroom, `log_budget`, is derived from the wrapped
27//! ciphertext or plaintext width as `k() - log_delta`; it is not stored in
28//! [`CKKSMeta`].
29//!
30//! [`CKKSMeta`] also records the [`SlotsKind`] of a value: whether its slots are
31//! known to be real, or may carry an imaginary part. Operations compose that
32//! claim, keeping `Real` only when every operand is real, so a caller can state
33//! it once at encoding time and have the pipelines specialize on it.
34//!
35//! Together they define the semantic torus width of a value:
36//! `k() = log_delta + log_budget`.
37//! Storage is rounded up to the next multiple of `base2k`, so the allocated
38//! capacity `max_k()` may exceed the effective width `k()`. Arithmetic APIs
39//! update this metadata for you; buffers always stay at their allocated
40//! width, and allocating a destination at exactly the `k` you want is how
41//! results are narrowed.
42//!
43//! Linear operations (additions, subtractions, plaintext additions, doubling,
44//! negation, copies, multiplication by `±i`) do not normalize. Additions,
45//! subtractions and doubling clear the wrapped GLWE's canonical flag, the others
46//! keep their operand's, and the next operation that reads the digits through a
47//! DFT (products, rotations, conjugation, keyswitching, decryption) normalizes a
48//! flag-clear operand first. A value several such operations read is best
49//! normalized once with `glwe_normalize_assign`, and serializing requires a
50//! normalized ciphertext. Each lazy addition can grow the digits by one bit; a
51//! sum of `n` terms stays within `i64` while `n <= 2^(63 - base2k)`.
52//!
53//! ## Modules
54//!
55//! | Module | Role |
56//! |--------|------|
57//! | [`approximation`] | Reusable minimax fitting, precision/depth selection, composite sign generation, and prepared interval-mapped polynomial evaluation |
58//! | [`encoding`] / [`reference::encoding`] | Canonical slot ordering, quantization, and PaCo/SHIP embeddings, with explicit host reference helpers |
59//! | [`layouts`] | CKKS ciphertext/plaintext wrappers and metadata-aware allocation helpers |
60//! | [`presets`] | Ready-to-use parameter sets |
61//! | [`api`] | The public op traits: leveled arithmetic (add, sub, mul, neg, rotate, conjugate), encryption, decryption, rescale, and scratch sizing |
62//! | [`api::CKKSBootstrappingOps`] | API composition of encapsulated ModUp, DFT, and EvalMod; parameterized by [`layouts::BootstrappingPlan`] |
63//! | [`api::CKKSPaCoOps`] | PaCo bootstrapping without ModUp or EvalMod; parameterized by [`layouts::PaCoPlan`] and a compiled [`layouts::PaCoContext`] |
64
65use poulpy_core::layouts::{
66    Base2K, Degree, GLWEInfos, GLWELayout, GLWEToBackendMut, GLWEToBackendRef, LWEInfos, Rank, TorusPrecision,
67};
68use poulpy_hal::layouts::Backend;
69use poulpy_hal::layouts::CyclotomicOrder;
70use poulpy_hal::layouts::Module;
71use poulpy_hal::layouts::galois_element;
72
73pub mod api;
74pub mod approximation;
75pub(crate) mod cosine;
76pub(crate) mod delegates;
77pub mod reference;
78
79/// Re-exports for use inside this crate's exported macros (e.g.
80/// [`impl_ckks_dft_reference`]), so an invoking backend crate does not need
81/// `anyhow` as a direct dependency. Not part of the public API.
82#[doc(hidden)]
83pub mod __macro_reexports {
84    pub use anyhow;
85}
86pub mod encoding;
87mod error;
88mod eval_lut;
89pub mod layouts;
90pub mod numerics;
91/// One-stop imports for the common CKKS path.
92///
93/// `use poulpy_ckks::prelude::*;` brings in the op traits (add/sub/mul/…,
94/// encrypt/decrypt, scratch sizing), the ciphertext/plaintext containers with
95/// their allocation helpers, and the metadata types — everything the
96/// encode → encrypt → evaluate → decrypt → decode loop needs from this crate.
97/// Key material and the `Module`/scratch machinery come from `poulpy-core` and
98/// `poulpy-hal`.
99pub mod prelude {
100    pub use crate::api::{
101        CKKSAddOps, CKKSAllOpsTmpBytes, CKKSApproximationOps, CKKSConjugateOps, CKKSCopyOps, CKKSDecryptOps, CKKSEncodingHostOps,
102        CKKSEncodingOps, CKKSEncryptOps, CKKSImagOps, CKKSModuleInfos, CKKSMulOps, CKKSNegOps, CKKSPlaintextVecOps, CKKSPow2Ops,
103        CKKSRotateOps, CKKSSubOps,
104    };
105    pub use crate::layouts::{CKKSCiphertext, CKKSModuleAlloc, CKKSPlaintext, PolynomialApproximation};
106    pub use crate::{
107        CKKSCompositionError, CKKSError, CKKSInfos, CKKSLayout, CKKSMeta, CKKSResult, CoeffsMeta, Quad, SetCKKSInfos, SlotsKind,
108    };
109}
110pub mod oep;
111pub mod polynomial;
112pub mod power_basis;
113pub mod presets;
114pub mod scalar;
115#[cfg(feature = "test-utils")]
116#[allow(clippy::disallowed_methods)]
117pub mod test_suite;
118pub use error::{CKKSCompositionError, CKKSError, CKKSResult};
119pub(crate) use error::{
120    checked_log_budget_sub, checked_mul_ct_log_budget, checked_mul_pt_log_budget, ckks_bail, ckks_ensure, ensure_base2k_match,
121    ensure_plaintext_alignment, ensure_plaintext_coeff_in_range, ensure_plaintext_degree_embeds,
122};
123/// Quad-precision (IEEE 754 binary128) CKKS scalar.
124///
125/// Always the portable [`scalar::Quad`] newtype over the primitive `f128`, in
126/// every configuration. Under the `libquadmath` feature on non-Apple x86_64
127/// only its libm-backed math is routed through libquadmath (faster for
128/// on-the-fly FFT-table builds); storage, codecs, and exact arithmetic are
129/// unchanged, so the type — and its `bytemuck::Pod` encoding — is identical
130/// across features.
131pub use scalar::Quad;
132
133/// Backend-compatible shared CKKS plaintext storage.
134pub trait CKKSPlaintextToBackendRef<BE: Backend>: GLWEToBackendRef<BE> + GLWEInfos + CKKSInfos {}
135
136impl<BE: Backend, T> CKKSPlaintextToBackendRef<BE> for T where T: GLWEToBackendRef<BE> + GLWEInfos + CKKSInfos {}
137
138/// Backend-compatible mutable CKKS plaintext storage.
139///
140/// Implemented by owned plaintexts and scratch-backed plaintext views alike.
141pub trait CKKSPlaintextToBackendMut<BE: Backend>: CKKSPlaintextToBackendRef<BE> + GLWEToBackendMut<BE> {}
142
143impl<BE: Backend, T> CKKSPlaintextToBackendMut<BE> for T where T: CKKSPlaintextToBackendRef<BE> + GLWEToBackendMut<BE> {}
144
145/// Marker bound for CKKS ciphertext type parameters.
146///
147/// Combines [`GLWEInfos`] (which already implies [`poulpy_core::layouts::LWEInfos`])
148/// with [`CKKSInfos`] to collapse the repeated `GLWEInfos + CKKSInfos` pair found
149/// throughout the API into a single, named constraint.
150pub trait CKKSCtBounds: GLWEInfos + CKKSInfos {}
151
152impl<T: GLWEInfos + CKKSInfos> CKKSCtBounds for T {}
153
154/// Ring-dependent CKKS slot geometry and automorphism identifiers.
155pub trait CKKSModuleInfos {
156    /// Maximum number of slots: `N` real slots or `N/2` complex slots.
157    fn ckks_max_slots(&self) -> usize;
158
159    /// Automorphism identifier used by CKKS rotations and their evaluation keys.
160    /// `rotation` is reduced modulo [`Self::ckks_max_slots`], so a negative shift rotates backwards.
161    fn ckks_galois_element(&self, rotation: i64) -> i64;
162}
163
164impl<BE: Backend> CKKSModuleInfos for Module<BE> {
165    fn ckks_max_slots(&self) -> usize {
166        (self.cyclotomic_order() / 4) as usize
167    }
168
169    fn ckks_galois_element(&self, rotation: i64) -> i64 {
170        // 5 has order `max_slots` in the Galois group on both rings.
171        galois_element(rotation.rem_euclid(self.ckks_max_slots() as i64), self.cyclotomic_order())
172    }
173}
174
175/// Which subfield the encoded slots are known to live in.
176///
177/// The reals are a subring of the complexes, so the two variants are ordered
178/// claims rather than exclusive tags: [`SlotsKind::Real`] is the stronger one,
179/// [`SlotsKind::Complex`] is always sound. Operations compose the claim with
180/// [`SlotsKind::join`], which keeps `Real` only when every operand is `Real`.
181/// `Complex` is the default, so a value that never states its kind is never
182/// mistaken for a real one.
183#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
184pub enum SlotsKind {
185    /// Every slot has a zero imaginary part.
186    Real,
187    /// Slots may carry a nonzero imaginary part.
188    #[default]
189    Complex,
190}
191
192impl SlotsKind {
193    /// Kind of a value built from two operands: `Real` only when both are.
194    pub fn join(self, other: Self) -> Self {
195        match (self, other) {
196            (Self::Real, Self::Real) => Self::Real,
197            _ => Self::Complex,
198        }
199    }
200
201    /// Kind of a value that lies in both fields: `Complex` only when both are.
202    pub fn meet(self, other: Self) -> Self {
203        match (self, other) {
204            (Self::Complex, Self::Complex) => Self::Complex,
205            _ => Self::Real,
206        }
207    }
208
209    /// Whether the slots are known to be real.
210    pub fn is_real(self) -> bool {
211        self == Self::Real
212    }
213}
214
215#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
216/// CKKS semantic precision metadata carried by ciphertexts and plaintexts.
217///
218/// `log_delta` is the scaling precision of the encoded value. The remaining
219/// homomorphic headroom (`log_budget`) is *not* stored here: it is derived from
220/// the wrapped GLWE's torus width `k` as `log_budget = k - log_delta`.
221pub struct CKKSMeta {
222    /// Base 2 logarithm of the decimal precision.
223    pub log_delta: usize,
224    /// Sparse-packing factor: `log2` of the coefficient gap (equivalently, of the
225    /// slot replication). Dense packing has `N/2` complex slots in the standard
226    /// ring or `N` real slots in the conjugate invariant ring. With
227    /// `log_sparsity = s`, `M(X^{2^s})` carries `max_slots >> s` distinct slots,
228    /// each replicated `2^s` times.
229    ///
230    /// A plaintext may store its `M` compactly, at the degree
231    /// `ckks_pt_vec_alloc_compact` picks for its slot count; its own `n()` is
232    /// then below the ring degree and every consumer reads it through the ring
233    /// embedding. `log_sparsity` keeps counting the gap under the ring
234    /// embedding, `log2` of the replication among the ring slots,
235    /// whatever degree the plaintext is stored at.
236    pub log_sparsity: usize,
237    /// Subfield the slots are known to live in. See [`SlotsKind`].
238    pub slots: SlotsKind,
239}
240
241/// Common metadata accessors for CKKS ciphertext and plaintext containers.
242///
243/// This trait exposes the semantic precision of a value independently from the
244/// raw limb storage used by the underlying torus representation. `log_budget` is
245/// derived from the container's torus width `k` (from the wrapped GLWE) and
246/// `log_delta`, so it is only available on containers, not on a bare [`CKKSMeta`].
247pub trait CKKSInfos: LWEInfos {
248    /// Returns the complete metadata pair.
249    fn meta(&self) -> CKKSMeta;
250
251    /// Returns the base-2 logarithm of the encoded decimal scaling factor.
252    fn log_delta(&self) -> usize {
253        self.meta().log_delta
254    }
255
256    /// Returns the base-2 logarithm of the remaining homomorphic capacity,
257    /// derived from the container's torus width: `k − log_delta`.
258    fn log_budget(&self) -> usize {
259        self.k().as_usize().saturating_sub(self.log_delta())
260    }
261
262    /// Returns the sparse-packing factor (`log2` of the coefficient gap / slot
263    /// replication); `0` is dense. See [`CKKSMeta::log_sparsity`].
264    fn log_sparsity(&self) -> usize {
265        self.meta().log_sparsity
266    }
267
268    /// Returns the subfield the slots are known to live in. See [`SlotsKind`].
269    fn slots(&self) -> SlotsKind {
270        self.meta().slots
271    }
272}
273
274/// Mutable CKKS metadata access for ciphertext/plaintext containers.
275pub trait SetCKKSInfos: CKKSInfos {
276    /// Replaces the semantic CKKS metadata (`log_delta`, `log_sparsity`). Does not
277    /// touch the wrapped GLWE's torus width `k`, so `log_budget` is re-derived
278    /// against the (unchanged) `k`. Use [`Self::set_log_delta`] to relabel the
279    /// scale while preserving `log_budget`.
280    fn set_meta(&mut self, meta: CKKSMeta);
281
282    /// Sets the wrapped GLWE's torus width `k` (the total `log_delta + log_budget`).
283    fn set_k(&mut self, k: TorusPrecision);
284
285    /// Updates only the base-2 logarithm of the encoded scaling factor, preserving
286    /// `log_budget` by shifting the torus width `k` accordingly.
287    fn set_log_delta(&mut self, log_delta: usize) {
288        let log_budget = self.log_budget();
289        let mut meta = self.meta();
290        meta.log_delta = log_delta;
291        self.set_meta(meta);
292        self.set_k((log_budget + log_delta).into());
293    }
294
295    /// Updates only the base-2 logarithm of the remaining homomorphic budget by
296    /// setting the torus width `k = log_budget + log_delta`.
297    fn set_log_budget(&mut self, log_budget: usize) {
298        self.set_k((log_budget + self.log_delta()).into());
299    }
300
301    /// Updates only the sparse-packing factor. See [`CKKSMeta::log_sparsity`].
302    fn set_log_sparsity(&mut self, log_sparsity: usize) {
303        let mut meta = self.meta();
304        meta.log_sparsity = log_sparsity;
305        self.set_meta(meta);
306    }
307
308    /// Updates only the slot kind. See [`SlotsKind`].
309    fn set_slots(&mut self, slots: SlotsKind) {
310        let mut meta = self.meta();
311        meta.slots = slots;
312        self.set_meta(meta);
313    }
314}
315
316/// Allocation / precision spec for a CKKS value.
317///
318/// Bundles a core [`GLWELayout`] — which carries `n`, `base2k`, the torus width
319/// `k`, and `rank` — with the [`CKKSMeta`] (`log_delta`, `log_sparsity`). The
320/// budget is derived as `log_budget = k - log_delta`, so `k` lives in the GLWE
321/// layout exactly as it does on a wrapped ciphertext/plaintext.
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub struct CKKSLayout {
324    pub glwe_layout: GLWELayout,
325    pub meta: CKKSMeta,
326}
327
328/// Coefficient metadata for plan-compiled operands: the DFT factor diagonals
329/// ([`layouts::DFTPlan`]), the EvalMod polynomial coefficients
330/// ([`layouts::EvalModPlan`]), and the BSGS polynomial encoders
331/// ([`polynomial::EncodeBSGS`]).
332///
333/// The reduced form of a [`CKKSLayout`]: only the torus width `k` the operand
334/// plaintexts are allocated with and the [`CKKSMeta`] they are stamped with.
335/// Plans carry no ring or radix information — `n` follows the module and
336/// `base2k` is passed explicitly at compile time — so there is nothing to fill
337/// with placeholders.
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub struct CoeffsMeta {
340    /// Torus width the operand plaintexts are allocated with.
341    pub k: TorusPrecision,
342    /// CKKS metadata (`log_delta`, `log_sparsity`) the operands are stamped with.
343    pub meta: CKKSMeta,
344}
345
346impl CoeffsMeta {
347    /// Dense (`log_sparsity = 0`) coefficient meta with
348    /// `k = log_delta + log_budget`.
349    pub fn from_delta_budget(log_delta: usize, log_budget: usize) -> Self {
350        Self {
351            k: (log_delta + log_budget).into(),
352            meta: CKKSMeta {
353                log_delta,
354                log_sparsity: 0,
355                slots: SlotsKind::Complex,
356            },
357        }
358    }
359
360    /// The operand encoding scale.
361    pub fn log_delta(&self) -> usize {
362        self.meta.log_delta
363    }
364
365    /// The operand headroom: `k − log_delta` (saturating).
366    pub fn log_budget(&self) -> usize {
367        usize::from(self.k).saturating_sub(self.meta.log_delta)
368    }
369}
370
371/// Narrowing conversion: keeps `k` and the [`CKKSMeta`], drops the ring/radix
372/// fields plans never consume.
373impl From<CKKSLayout> for CoeffsMeta {
374    fn from(layout: CKKSLayout) -> Self {
375        Self {
376            k: layout.glwe_layout.k,
377            meta: layout.meta,
378        }
379    }
380}
381
382impl LWEInfos for CKKSLayout {
383    fn n(&self) -> Degree {
384        self.glwe_layout.n()
385    }
386
387    fn base2k(&self) -> Base2K {
388        self.glwe_layout.base2k()
389    }
390
391    fn max_size(&self) -> usize {
392        self.glwe_layout.max_size()
393    }
394
395    fn k(&self) -> TorusPrecision {
396        self.glwe_layout.k()
397    }
398}
399
400impl GLWEInfos for CKKSLayout {
401    fn rank(&self) -> Rank {
402        self.glwe_layout.rank()
403    }
404}
405
406impl CKKSInfos for CKKSLayout {
407    fn meta(&self) -> CKKSMeta {
408        self.meta
409    }
410}
411
412/// Bits a binary add/sub must shift its result down to fit `res`: the excess of
413/// the **natural result width** — `min(log_delta) + min(log_budget)`, the meta
414/// the operation stamps — over the destination's requested `res.k()`. Using the natural width (rather
415/// than `min(a.k, b.k)`, which is `≥` it whenever deltas *and* budgets both
416/// differ) charges exactly the budget the narrower destination forces and no
417/// more; the larger-delta operand's truncated tail lies below the claimed
418/// `min(log_delta)` precision. This mirrors the mul family's
419/// `(res_log_budget + res_log_delta).saturating_sub(res_max_k)`.
420pub(crate) fn ckks_offset_binary<R, A, B>(res: &R, a: &A, b: &B) -> usize
421where
422    R: CKKSInfos + ?Sized,
423    A: CKKSInfos + ?Sized,
424    B: CKKSInfos + ?Sized,
425{
426    let natural_k = a.log_delta().min(b.log_delta()) + a.log_budget().min(b.log_budget());
427    natural_k.saturating_sub(res.k().as_usize())
428}
429
430pub(crate) fn ckks_offset_unary<R, A>(res: &R, a: &A) -> usize
431where
432    R: CKKSInfos + ?Sized,
433    A: CKKSInfos + ?Sized,
434{
435    a.k().as_usize().saturating_sub(res.k().as_usize())
436}
437
438/// Whether a limb-wise op can read `a` into `res` without normalizing: `res`
439/// holds every limb of a non-canonical `a` (its carries may sit beyond `a.k()`).
440pub(crate) fn ckks_holds_limbs<BE, R, A>(res: &R, a: &A) -> bool
441where
442    BE: Backend,
443    R: GLWEToBackendRef<BE>,
444    A: GLWEToBackendRef<BE>,
445{
446    a.is_canonical() || res.to_backend_ref().max_size() >= a.to_backend_ref().max_size()
447}
448
449/// Whether a limb-wise unary op can write `src` into `dst` without normalizing.
450pub(crate) fn ckks_unary_exact<BE, R, A>(res: &R, a: &A) -> bool
451where
452    BE: Backend,
453    R: GLWEToBackendRef<BE> + CKKSInfos,
454    A: GLWEToBackendRef<BE> + CKKSInfos,
455{
456    ckks_offset_unary(res, a) == 0 && ckks_holds_limbs(res, a)
457}
458
459/// Shared unary-op preamble: stamps `src`'s metadata with the budget charged
460/// by `offset + extra_charge` and `extra_log_delta` bits moved under
461/// `log_delta`, then aligns `src` into `dst` (left shift by
462/// `offset + extra_shift`). Validates **before** mutating, so on `Err`
463/// (insufficient budget) `dst` is untouched. Returns the computed offset.
464///
465/// This is the single implementation of the "shift + stamp" sequence the
466/// copy/pow2/add-pt/sub-pt into-ops previously hand-rolled (H1 in the 2026-07
467/// review was a drift bug in exactly this preamble).
468#[allow(clippy::too_many_arguments)]
469pub(crate) fn ckks_shift_stamp_unary<BE, M, Dst, Src>(
470    module: &M,
471    op: &'static str,
472    dst: &mut Dst,
473    src: &Src,
474    extra_shift: usize,
475    extra_charge: usize,
476    extra_log_delta: usize,
477    scratch: &mut poulpy_hal::layouts::ScratchArena<'_, BE>,
478) -> CKKSResult<()>
479where
480    BE: Backend,
481    M: poulpy_core::GLWEShift<BE> + ?Sized,
482    Dst: GLWEToBackendMut<BE> + CKKSInfos + SetCKKSInfos,
483    Src: GLWEToBackendRef<BE> + CKKSInfos,
484{
485    let offset = ckks_offset_unary(dst, src);
486    let log_budget = checked_log_budget_sub(op, src.log_budget(), offset + extra_charge)?;
487    // Stamp first, and stamp the final metadata: the shift normalizes at
488    // `dst.k()`, so the label has to be the width the result will carry, not
489    // whatever `dst` held before and not an intermediate. `div_pow2` charges
490    // `bits` to the budget and moves them under `log_delta`; doing the second
491    // half after the shift would have it round `bits` too low. `offset` is
492    // computed against the pre-stamp `dst`.
493    dst.set_meta(src.meta());
494    dst.set_log_budget(log_budget);
495    if extra_log_delta != 0 {
496        dst.set_log_delta(src.log_delta() + extra_log_delta);
497    }
498    module.glwe_lsh(dst, src, offset + extra_shift, scratch);
499    Ok(())
500}
501
502/// Copies `src` into `dst` with `src`'s metadata: verbatim when `dst` is wide
503/// enough, otherwise through [`ckks_shift_stamp_unary`], which charges the
504/// offset to the budget and normalizes. Validates before mutating.
505pub(crate) fn ckks_copy_stamp_unary<BE, M, Dst, Src>(
506    module: &M,
507    op: &'static str,
508    dst: &mut Dst,
509    src: &Src,
510    scratch: &mut poulpy_hal::layouts::ScratchArena<'_, BE>,
511) -> CKKSResult<()>
512where
513    BE: Backend,
514    M: poulpy_core::GLWECopy<BE> + poulpy_core::GLWEShift<BE> + ?Sized,
515    Dst: GLWEToBackendMut<BE> + CKKSInfos + SetCKKSInfos,
516    Src: GLWEToBackendRef<BE> + CKKSInfos,
517{
518    if !ckks_unary_exact(dst, src) {
519        return ckks_shift_stamp_unary(module, op, dst, src, 0, 0, 0, scratch);
520    }
521    // The scratch queries size the copy for the destination as allocated, so it
522    // runs before the relabel.
523    module.glwe_copy(dst, src, scratch);
524    dst.set_meta(src.meta());
525    dst.set_log_budget(src.log_budget());
526    // The copy is exact: lowering `k` back to `src`'s keeps `src`'s form.
527    dst.set_canonical(src.is_canonical());
528    Ok(())
529}
530
531#[cfg(test)]
532mod slots_kind_tests {
533    use super::SlotsKind::{self, Complex, Real};
534
535    #[test]
536    fn join_keeps_real_only_when_both_operands_are_real() {
537        assert_eq!(Real.join(Real), Real);
538        assert_eq!(Real.join(Complex), Complex);
539        assert_eq!(Complex.join(Real), Complex);
540        assert_eq!(Complex.join(Complex), Complex);
541    }
542
543    #[test]
544    fn meet_keeps_complex_only_when_both_operands_are_complex() {
545        assert_eq!(Real.meet(Real), Real);
546        assert_eq!(Real.meet(Complex), Real);
547        assert_eq!(Complex.meet(Real), Real);
548        assert_eq!(Complex.meet(Complex), Complex);
549    }
550
551    #[test]
552    fn unstated_kind_is_complex() {
553        assert_eq!(SlotsKind::default(), Complex);
554        assert!(!SlotsKind::default().is_real());
555    }
556}
557
558#[cfg(test)]
559mod offset_tests {
560    use super::*;
561
562    fn layout(log_delta: usize, log_budget: usize) -> CKKSLayout {
563        CKKSLayout {
564            glwe_layout: poulpy_core::layouts::GLWELayout {
565                n: Degree(0),
566                base2k: Base2K(1),
567                k: (log_delta + log_budget).into(),
568                rank: Rank(1),
569            },
570            meta: CKKSMeta {
571                log_delta,
572                log_sparsity: 0,
573                slots: SlotsKind::Complex,
574            },
575        }
576    }
577
578    /// The binary offset charges exactly the natural result width's excess over
579    /// the destination — not `min(a.k, b.k)`, which over-charges when deltas
580    /// and budgets both differ.
581    #[test]
582    fn offset_binary_charges_natural_result_width() {
583        // Deltas AND budgets differ: natural width = 30 + 20 = 50, while
584        // min(a.k, b.k) = 55 — the old formula charged 5 spurious bits.
585        let a = layout(40, 20); // k = 60
586        let b = layout(30, 25); // k = 55
587        let res = layout(30, 20); // max_k = 50
588        assert_eq!(ckks_offset_binary(&res, &a, &b), 0);
589
590        // A genuinely narrower destination still charges the difference.
591        let narrow = layout(30, 12); // max_k = 42
592        assert_eq!(ckks_offset_binary(&narrow, &a, &b), 8);
593
594        // Degenerate cases match the old formula: equal budgets…
595        let c = layout(40, 25); // k = 65
596        let d = layout(30, 25); // k = 55
597        assert_eq!(ckks_offset_binary(&narrow, &c, &d), (30 + 25) - 42);
598        // …and equal deltas.
599        let e = layout(30, 30); // k = 60
600        assert_eq!(ckks_offset_binary(&narrow, &d, &e), (30 + 25) - 42);
601    }
602}