poulpy_core/layouts/linear_transformation.rs
1//! Unprepared GLWE linear transformation (BSGS) data and schedule.
2//!
3//! Holds the unprepared transform (`LinearTransformation`, encoded diagonals
4//! bucketed by giant step) and the integer-level BSGS schedule types
5//! (`LinearTransformationLayout`, `LinearTransformationPlan`,
6//! `LinearTransformationStrategy`) plus their derivation, which is pure integer
7//! math (cf. docs/linear_transformation.md).
8//!
9//! The *prepared* (convolution-domain) caches live in
10//! [`crate::layouts::prepared`]; the HAL-dependent allocators and the
11//! prepare/eval reference algorithms live in
12//! [`crate::default::linear_transformation`].
13
14use std::collections::{BTreeMap, BTreeSet};
15
16use poulpy_hal::layouts::galois_elements_from_rotations;
17
18// ===================================================================
19// Unprepared transform
20// ===================================================================
21
22/// One non-zero diagonal of the linear map, attached to a giant step.
23///
24/// `plaintext` is the pre-rotated diagonal `u~_{j,k} = rot(diag_{n1*j+k}, -n1*j)`,
25/// already encoded as a plaintext polynomial. `baby` is the baby-step slot
26/// rotation `k` whose prepared `rot(v, k)` it multiplies.
27pub struct LinearTransformationDiagonal<P> {
28 /// Baby-step slot rotation.
29 pub baby: i64,
30 /// Pre-rotated diagonal, encoded as a plaintext polynomial.
31 pub plaintext: P,
32}
33
34/// A single giant step `j`: its inner sum is rotated by `rot` slots and added to
35/// the output (`rot == 0` is the identity giant step).
36pub struct LinearTransformationGiantStep<P> {
37 /// Slot rotation amount `n1*j`.
38 pub rot: i64,
39 /// The non-zero diagonals contributing to this giant step.
40 pub diagonals: Vec<LinearTransformationDiagonal<P>>,
41}
42
43/// A linear transformation in baby-step / giant-step form.
44///
45/// `P` is the encoded-plaintext container.
46pub struct LinearTransformation<P> {
47 /// Distinct baby-step slot rotations `k`; `baby_steps[0] == 0` (the identity).
48 pub baby_steps: Vec<i64>,
49 /// The giant steps.
50 pub giant_steps: Vec<LinearTransformationGiantStep<P>>,
51}
52
53impl<P> LinearTransformation<P> {
54 /// The distinct baby-step slot rotations `k` (`baby_steps[0] == 0`, the
55 /// identity); the set the prepared baby cache must hold. Accessor over the
56 /// [`baby_steps`](Self::baby_steps) field for callers that prefer a method.
57 pub fn baby_steps(&self) -> &[i64] {
58 &self.baby_steps
59 }
60
61 /// The first encoded diagonal across all giant steps, or `None` if the
62 /// transform is empty. The diagonals share a uniform plaintext shape, so this
63 /// is the canonical place callers read `base2k` / `k` / `log_delta` from.
64 pub fn first_diagonal_plaintext(&self) -> Option<&P> {
65 self.giant_steps
66 .iter()
67 .flat_map(|gs| gs.diagonals.iter())
68 .map(|d| &d.plaintext)
69 .next()
70 }
71
72 /// Derives the BSGS index schedule implied by this transform's actual
73 /// baby/giant rotations. Useful for one-shot allocation of the prepared
74 /// cache directly from an unprepared transform.
75 ///
76 /// The output is canonical — giant steps sorted, per-giant baby rotations
77 /// sorted and de-duplicated — so it matches the schedule
78 /// [`LinearTransformationLayout::index`] would derive from the same diagonal
79 /// set. Only baby rotations actually referenced by at least one non-empty
80 /// giant step are included; the field `self.baby_steps` may declare more
81 /// rotations than the transform's data populates, and those extras would
82 /// otherwise force the caller to provide automorphism keys that the
83 /// schedule does not actually need.
84 pub fn index(&self) -> LinearTransformationPlan {
85 let mut by_giant: BTreeMap<i64, BTreeSet<i64>> = BTreeMap::new();
86 let mut used_babies: BTreeSet<i64> = BTreeSet::new();
87 for gs in &self.giant_steps {
88 if gs.diagonals.is_empty() {
89 continue;
90 }
91 let babies = by_giant.entry(gs.rot).or_default();
92 for d in &gs.diagonals {
93 used_babies.insert(d.baby);
94 babies.insert(d.baby);
95 }
96 }
97 let baby_steps: Vec<i64> = used_babies.into_iter().collect();
98 let mut giant_steps = Vec::with_capacity(by_giant.len());
99 let mut index = Vec::with_capacity(by_giant.len());
100 for (rot, babies) in by_giant {
101 giant_steps.push(rot);
102 index.push(babies.into_iter().collect());
103 }
104 LinearTransformationPlan {
105 baby_steps,
106 giant_steps,
107 index,
108 }
109 }
110
111 /// The Galois elements whose automorphism keys are required to evaluate this
112 /// transform: one per non-zero baby- and giant-step rotation.
113 ///
114 /// Automorphism keys are keyed by Galois element throughout the engine (cf.
115 /// [`LinearTransformationPlan::galois_elements`]); pass the result here to
116 /// index the key store the eval entry points look up.
117 pub fn galois_elements(&self, cyclotomic_order: i64) -> Vec<i64> {
118 let babies = self.giant_steps.iter().flat_map(|gs| gs.diagonals.iter()).map(|d| d.baby);
119 let giants = self.giant_steps.iter().filter(|gs| !gs.diagonals.is_empty()).map(|gs| gs.rot);
120 galois_elements_from_rotations(babies.chain(giants), cyclotomic_order)
121 }
122}
123
124// ===================================================================
125// Schedule (strategy / layout / index) + derivation
126// ===================================================================
127
128/// Strategy used to derive a linear-transformation evaluation schedule from
129/// non-zero diagonal indexes.
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131pub enum LinearTransformationStrategy {
132 /// Use an explicit BSGS giant-step width. For the cost-optimal width, call
133 /// [`optimal_bsgs_giant_step`] and pass the result here.
134 Bsgs { giant_step: usize },
135 /// Use one giant step per diagonal and no baby rotations.
136 Direct,
137}
138
139/// Scheme-agnostic specification of a linear transformation.
140///
141/// Carries only the integer-level information needed to derive the BSGS
142/// schedule: the non-zero diagonal indexes, the slot count, and the
143/// schedule-selection strategy.
144#[derive(Clone, Debug, PartialEq, Eq)]
145pub struct LinearTransformationLayout {
146 /// Non-zero diagonal indexes of the matrix.
147 pub indexes: Vec<i64>,
148 /// Number of slots (typically `n / 2` for a CKKS plaintext over `C^{n/2}`).
149 pub slots: usize,
150 /// Strategy used to pick the BSGS schedule.
151 pub strategy: LinearTransformationStrategy,
152}
153
154impl LinearTransformationLayout {
155 /// Returns the BSGS index schedule implied by this layout.
156 pub fn index(&self) -> LinearTransformationPlan {
157 linear_transform_index(self.indexes.iter().copied(), self.slots, self.strategy)
158 }
159
160 /// Returns the BSGS schedule for an explicit `giant_step`, ignoring `strategy`.
161 pub fn plan(&self, giant_step: usize) -> LinearTransformationPlan {
162 linear_transformation_plan(self.indexes.iter().copied(), self.slots, giant_step)
163 }
164
165 /// Distinct baby-step rotations (`k`) used by the schedule.
166 ///
167 /// This is the set of rotations the prepared baby cache must hold; pass it
168 /// to `LinearTransformationBabySteps::alloc` to size the cache up-front.
169 pub fn baby_steps(&self) -> Vec<i64> {
170 self.index().baby_steps
171 }
172
173 /// Galois elements required for all non-zero baby- and giant-step rotations.
174 pub fn galois_elements(&self, cyclotomic_order: i64) -> Vec<i64> {
175 self.index().galois_elements(cyclotomic_order)
176 }
177}
178
179/// BSGS index schedule for a linear transformation.
180#[derive(Clone, Debug, PartialEq, Eq)]
181pub struct LinearTransformationPlan {
182 /// Distinct baby-step rotations used by the schedule.
183 pub baby_steps: Vec<i64>,
184 /// Giant-step rotations used by the schedule.
185 pub giant_steps: Vec<i64>,
186 /// Baby-step rotations grouped by giant step.
187 ///
188 /// `index[g]` contains the real baby rotations `k` used with
189 /// `giant_steps[g]`. The corresponding diagonal is
190 /// `giant_steps[g] + k` modulo the slot count.
191 pub index: Vec<Vec<i64>>,
192}
193
194impl LinearTransformationPlan {
195 /// Galois elements required for all non-zero baby- and giant-step rotations.
196 pub fn galois_elements(&self, cyclotomic_order: i64) -> Vec<i64> {
197 let rots = self.baby_steps.iter().copied().chain(self.giant_steps.iter().copied());
198 galois_elements_from_rotations(rots, cyclotomic_order)
199 }
200}
201
202/// Normalizes a diagonal index modulo the number of slots.
203///
204/// Internal helper; external callers access schedule construction through
205/// [`LinearTransformationLayout`] methods (`.index()` / `.plan(giant_step)`).
206pub(crate) fn normalize_linear_transform_diagonal(diagonal: i64, slots: usize) -> usize {
207 assert!(slots > 0, "linear transformation slot count must be non-zero");
208 diagonal.rem_euclid(slots as i64) as usize
209}
210
211/// Returns a BSGS schedule for the provided non-zero diagonal indexes.
212///
213/// Internal helper; external callers go through
214/// [`LinearTransformationLayout::plan`].
215pub(crate) fn linear_transformation_plan<I>(diagonal_indexes: I, slots: usize, giant_step: usize) -> LinearTransformationPlan
216where
217 I: IntoIterator<Item = i64>,
218{
219 assert!(slots > 0, "linear transformation slot count must be non-zero");
220 assert!(giant_step > 0, "linear transformation giant step must be non-zero");
221
222 let mut by_giant: BTreeMap<usize, Vec<(usize, usize)>> = BTreeMap::new();
223 let mut baby_rots: BTreeSet<usize> = BTreeSet::from([0]);
224 for diagonal in diagonal_indexes {
225 let diagonal = normalize_linear_transform_diagonal(diagonal, slots);
226 let baby_rot = diagonal % giant_step;
227 let giant_rot = diagonal - baby_rot;
228 baby_rots.insert(baby_rot);
229 by_giant.entry(giant_rot).or_default().push((diagonal, baby_rot));
230 }
231
232 let baby_steps: Vec<i64> = baby_rots.iter().map(|&rot| rot as i64).collect();
233
234 let mut giant_steps = Vec::with_capacity(by_giant.len());
235 let mut index = Vec::with_capacity(by_giant.len());
236 for (rot, mut diagonals) in by_giant {
237 diagonals.sort_unstable();
238 diagonals.dedup_by_key(|(diagonal, _)| *diagonal);
239 giant_steps.push(rot as i64);
240 index.push(diagonals.into_iter().map(|(_, baby_rot)| baby_rot as i64).collect());
241 }
242
243 LinearTransformationPlan {
244 baby_steps,
245 giant_steps,
246 index,
247 }
248}
249
250/// Returns the optimal BSGS giant-step width.
251///
252/// Only evaluates candidates that are multiples of the minimum gap between
253/// consecutive sorted diagonal indices. This prunes the search space from
254/// `O(slots)` to `O(slots / min_gap)`, which is significant for structured
255/// sparse matrices (e.g. stride-k diagonals).
256pub fn optimal_bsgs_giant_step<I>(diagonal_indexes: I, slots: usize) -> usize
257where
258 I: IntoIterator<Item = i64>,
259{
260 assert!(slots > 0, "linear transformation slot count must be non-zero");
261
262 let diagonals: Vec<usize> = {
263 let set: BTreeSet<usize> = diagonal_indexes
264 .into_iter()
265 .map(|diagonal| normalize_linear_transform_diagonal(diagonal, slots))
266 .collect();
267 set.into_iter().collect() // BTreeSet iteration is already sorted
268 };
269
270 if diagonals.len() <= 1 {
271 return 1;
272 }
273
274 // Minimum gap between consecutive sorted normalized diagonal indices.
275 // Candidate giant steps only need to be multiples of this value.
276 let min_gap = diagonals.windows(2).map(|w| w[1] - w[0]).min().unwrap().max(1);
277
278 let mut best_cost = usize::MAX;
279 let mut best_step = min_gap;
280
281 let mut step = min_gap;
282 while step < slots {
283 let mut baby_rots = BTreeSet::new();
284 let mut giant_rots = BTreeSet::new();
285 for &diagonal in &diagonals {
286 let baby_rot = diagonal % step;
287 baby_rots.insert(baby_rot);
288 giant_rots.insert(diagonal - baby_rot);
289 }
290 let n1 = baby_rots.len();
291 let n2 = giant_rots.len();
292 let cost = (n1 + n2) + n1.abs_diff(n2);
293 if cost <= best_cost {
294 best_step = step;
295 best_cost = cost;
296 }
297 step += min_gap;
298 }
299
300 best_step
301}
302
303/// Derives an index schedule from diagonal indexes and a strategy.
304fn linear_transform_index<I>(
305 diagonal_indexes: I,
306 slots: usize,
307 strategy: LinearTransformationStrategy,
308) -> LinearTransformationPlan
309where
310 I: IntoIterator<Item = i64>,
311{
312 match strategy {
313 LinearTransformationStrategy::Bsgs { giant_step } => linear_transformation_plan(diagonal_indexes, slots, giant_step),
314 LinearTransformationStrategy::Direct => {
315 let diagonals: BTreeSet<usize> = diagonal_indexes
316 .into_iter()
317 .map(|diagonal| normalize_linear_transform_diagonal(diagonal, slots))
318 .collect();
319 LinearTransformationPlan {
320 baby_steps: vec![0],
321 giant_steps: diagonals.iter().map(|&diagonal| diagonal as i64).collect(),
322 index: diagonals.into_iter().map(|_| vec![0]).collect(),
323 }
324 }
325 }
326}