Skip to main content

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}