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}