Skip to main content

poulpy_core/layouts/
polynomial_evaluation.rs

1//! Scheme-agnostic Baby-Step / Giant-Step (BSGS) polynomial-evaluation schedule
2//! and host-side polynomial representation.
3//!
4//! Holds the integer BSGS planning (split-strategy selection, coefficient
5//! decomposition into baby steps) and the cleartext [`Polynomial<F>`] /
6//! decomposed [`BSGSPolynomial<C>`] types. Per-baby-step coefficient encoding is
7//! supplied by the scheme layer through the closure passed to
8//! [`Polynomial::decompose_bsgs_with`].
9
10use std::collections::HashMap;
11use std::fmt::Debug;
12
13use anyhow::{Result, anyhow, ensure};
14use poulpy_hal::layouts::Backend;
15
16use crate::layouts::IntPolyInfos;
17use rand_distr::num_traits::{Float, FloatConst, FromPrimitive};
18
19use crate::layouts::{GLWEInfos, GLWEToBackendMut, GLWEToBackendRef};
20
21// ── Basis / Parity ───────────────────────────────────────────────────────────
22
23/// Polynomial evaluation basis.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum Basis {
26    /// Standard monomial basis: {1, X, X², …}
27    Monomial,
28    /// Chebyshev first-kind basis: {T₀(X), T₁(X), T₂(X), …}
29    Chebyshev,
30}
31
32/// Symmetry class of a polynomial.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum Parity {
35    /// No symmetry: all powers may be non-zero.
36    Full,
37    /// Even polynomial: only even-degree coefficients are non-zero.
38    Even,
39    /// Odd polynomial: only odd-degree coefficients are non-zero.
40    Odd,
41}
42
43/// Input rewrite attached to a decomposed polynomial.
44///
45/// Polynomials with a known parity can be folded through `x²` (monomial basis)
46/// or `T₂(x) = 2x² - 1` (Chebyshev basis), halving the encoded degree. Odd
47/// polynomials additionally factor out one copy of the original input.
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum PolynomialInputTransform {
50    /// Evaluate the encoded polynomial directly on the input.
51    #[default]
52    Identity,
53    /// Evaluate the encoded polynomial on `x²`.
54    Square,
55    /// Evaluate the encoded polynomial on `x²`, then multiply by `x`.
56    SquareTimesInput,
57    /// Evaluate the encoded polynomial on `T₂(x)`.
58    ChebyshevT2,
59    /// Evaluate the encoded polynomial on `T₂(x)`, then multiply by `x`.
60    ChebyshevT2TimesInput,
61}
62
63impl PolynomialInputTransform {
64    fn extra_depth(self) -> usize {
65        match self {
66            Self::Identity => 0,
67            Self::Square | Self::ChebyshevT2 => 1,
68            Self::SquareTimesInput | Self::ChebyshevT2TimesInput => 2,
69        }
70    }
71}
72
73// ── BSGS split-strategy planning ─────────────────────────────────────────────
74
75/// Chooses how [`Polynomial::decompose_bsgs_with`] picks `log_split`.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum SplitStrategy {
78    /// Closed-form choice minimising multiplicative depth.
79    MinDepth,
80    /// Sweep `log_split` to minimise `(CT-CT, PT-CT)` lexicographically.
81    MinMult,
82}
83
84/// Default planner picked by [`Polynomial::decompose_bsgs_with`].
85pub const DEFAULT_SPLIT_STRATEGY: SplitStrategy = SplitStrategy::MinDepth;
86
87/// Returns the BSGS log-split that minimises multiplication depth for a
88/// polynomial of log-degree `log_degree`.
89pub(crate) fn min_depth_split(log_degree: usize) -> usize {
90    debug_assert!(log_degree >= 1, "min_depth_split requires log_degree ≥ 1");
91    let s = log_degree >> 1;
92    let a = (1 << s) + (1 << (log_degree - s)) + log_degree - s - 3;
93    let b = (1 << (s + 1)) + (1 << (log_degree - s - 1)) + log_degree - s - 4;
94    if a > b { s + 1 } else { s }
95}
96
97/// Returns the BSGS log-split that minimises total multiplication count
98/// `(CT-CT, PT-CT)` lexicographically, sweeping `log_split ∈ [1, log_degree]`.
99pub(crate) fn min_mult_split(degree: usize, parity: Parity, basis: Basis) -> usize {
100    let log_degree = bit_len(degree);
101    if log_degree <= 1 {
102        return 1;
103    }
104    let mut best_log_split = 1usize;
105    let mut best_score = (usize::MAX, usize::MAX);
106    for log_split in 1..=log_degree {
107        let score = estimate_op_counts(degree, log_split, parity, basis);
108        if score < best_score {
109            best_score = score;
110            best_log_split = log_split;
111        }
112    }
113    best_log_split
114}
115
116pub(crate) fn split_for_strategy(strategy: SplitStrategy, degree: usize, parity: Parity, basis: Basis) -> usize {
117    match strategy {
118        SplitStrategy::MinDepth => min_depth_split(bit_len(degree)),
119        SplitStrategy::MinMult => min_mult_split(degree, parity, basis),
120    }
121}
122
123/// `(ct_ct, pt_ct)` mul count for the given plan.
124fn estimate_op_counts(degree: usize, log_split: usize, parity: Parity, basis: Basis) -> (usize, usize) {
125    let mut baby_degrees: Vec<usize> = Vec::new();
126    simulate_baby_step_decomposition(degree, log_split, &mut baby_degrees);
127    if baby_degrees.is_empty() {
128        return (usize::MAX, usize::MAX);
129    }
130    let pt_ct: usize = baby_degrees.iter().map(|&d| pt_ct_muls_for_baby_step(d, parity)).sum();
131    let giant_steps = baby_degrees.len().saturating_sub(1);
132    let power_basis = estimate_power_basis_muls(degree, log_split, parity, basis);
133    let mut cc = giant_steps + power_basis;
134    let mut pc = pt_ct;
135    let trailing_const = baby_degrees.len() >= 2 && *baby_degrees.last().unwrap() == 0;
136    if trailing_const && degree.is_power_of_two() && cc > 0 {
137        cc -= 1;
138        pc += 1;
139    }
140    (cc, pc)
141}
142
143/// Replays `decompose_bsgs_coeffs` structurally to collect baby-step degrees.
144fn simulate_baby_step_decomposition(degree: usize, log_split: usize, out: &mut Vec<usize>) {
145    let base = 1usize << log_split;
146    if degree < base {
147        out.push(degree);
148        return;
149    }
150    let mut next_power = base;
151    while next_power < (degree >> 1) + 1 {
152        next_power <<= 1;
153    }
154    simulate_baby_step_decomposition(next_power - 1, log_split, out);
155    simulate_baby_step_decomposition(degree - next_power, log_split, out);
156}
157
158fn pt_ct_muls_for_baby_step(degree: usize, parity: Parity) -> usize {
159    let (first, step) = match parity {
160        Parity::Even => (2usize, 2usize),
161        Parity::Odd => (1, 2),
162        Parity::Full => (1, 1),
163    };
164    if degree < first { 0 } else { (degree - first) / step + 1 }
165}
166
167fn estimate_power_basis_muls(degree: usize, log_split: usize, parity: Parity, basis: Basis) -> usize {
168    if degree < 2 {
169        return 0;
170    }
171    let log_degree = bit_len(degree);
172    let largest_pow2 = 1usize << (log_degree - 1);
173    let base = 1usize << log_split;
174
175    let mut targets: Vec<usize> = Vec::new();
176    if largest_pow2 >= 2 {
177        targets.push(largest_pow2);
178    }
179    let baby_limit = base.min(degree + 1);
180    match parity {
181        Parity::Even => targets.extend((4..baby_limit).step_by(2)),
182        Parity::Odd => targets.extend((3..baby_limit).step_by(2)),
183        Parity::Full => targets.extend(3..baby_limit),
184    }
185
186    let mut needed = std::collections::HashSet::<usize>::new();
187    let mut stack = targets;
188    while let Some(n) = stack.pop() {
189        if n <= 1 || !needed.insert(n) {
190            continue;
191        }
192        let (a, b) = split_degree(n);
193        stack.push(a);
194        stack.push(b);
195        if matches!(basis, Basis::Chebyshev) {
196            let c = a.abs_diff(b);
197            if c >= 2 {
198                stack.push(c);
199            }
200        }
201    }
202    needed.len()
203}
204
205fn decompose_bsgs_coeffs<F>(
206    basis: Basis,
207    coeffs: &[F],
208    log_split: usize,
209    max_degree: usize,
210    lead: bool,
211    split_leading: bool,
212    visit: &mut impl FnMut(&[F]) -> Result<()>,
213) -> Result<()>
214where
215    F: Float,
216{
217    let degree = coeffs.len().saturating_sub(1);
218    let base = 1usize << log_split;
219    if degree < base {
220        if split_leading && should_split_leading_baby_step(degree, log_split, max_degree, lead) {
221            let log_degree = bit_len(degree);
222            let smaller = min_depth_split(log_degree);
223            if smaller < log_split {
224                return decompose_bsgs_coeffs(basis, coeffs, smaller, max_degree, lead, split_leading, visit);
225            }
226        }
227        return visit(coeffs);
228    }
229
230    let mut next_power = base;
231    while next_power < (degree >> 1) + 1 {
232        next_power <<= 1;
233    }
234
235    match basis {
236        Basis::Monomial => {
237            decompose_bsgs_coeffs(
238                basis,
239                &coeffs[..next_power],
240                log_split,
241                max_degree,
242                false,
243                split_leading,
244                visit,
245            )?;
246            decompose_bsgs_coeffs(
247                basis,
248                &coeffs[next_power..],
249                log_split,
250                max_degree,
251                lead,
252                split_leading,
253                visit,
254            )
255        }
256        Basis::Chebyshev => {
257            let (q, r) = factorize_coeffs_chebyshev(coeffs, next_power);
258            decompose_bsgs_coeffs(basis, &r, log_split, max_degree, false, split_leading, visit)?;
259            decompose_bsgs_coeffs(basis, &q, log_split, max_degree, lead, split_leading, visit)
260        }
261    }
262}
263
264fn should_split_leading_baby_step(degree: usize, log_split: usize, max_degree: usize, lead: bool) -> bool {
265    if !lead || degree == 0 || log_split <= 1 {
266        return false;
267    }
268    let next_strict_power_of_two = 1usize << bit_len(max_degree);
269    let close_to_next_power = next_strict_power_of_two - (1usize << (log_split - 1));
270    max_degree > close_to_next_power
271}
272
273fn bit_len(n: usize) -> usize {
274    usize::BITS as usize - n.leading_zeros() as usize
275}
276
277fn factorize_coeffs_chebyshev<F>(coeffs: &[F], n: usize) -> (Vec<F>, Vec<F>)
278where
279    F: Float,
280{
281    let mut r = vec![F::zero(); n];
282    r.copy_from_slice(&coeffs[..n]);
283
284    let mut q = vec![F::zero(); coeffs.len() - n];
285    q[0] = coeffs[n];
286
287    let two = F::one() + F::one();
288    for (i, j) in (n + 1..coeffs.len()).zip(1..) {
289        q[i - n] = two * coeffs[i];
290        r[n - j] = r[n - j] - coeffs[i];
291    }
292
293    (q, r)
294}
295
296/// Splits `n` into `(a, b)` with `n = a + b` and `|a – b|` minimised.
297///
298/// When `n` is a power of two `a = b = n/2`; otherwise uses the
299/// Lee et al. (2020) strategy that maximises the number of odd-degree
300/// Chebyshev terms.
301pub fn split_degree(n: usize) -> (usize, usize) {
302    assert!(n > 1);
303    if n.is_power_of_two() {
304        (n / 2, n / 2)
305    } else {
306        let k = (usize::BITS - (n - 1).leading_zeros()) as usize - 1;
307        let a = (1usize << k) - 1;
308        let b = n + 1 - (1usize << k);
309        (a, b)
310    }
311}
312
313/// Multiplicative depth (CT-CT multiplication levels) consumed by a BSGS
314/// evaluation of a degree-`degree` polynomial under `strategy`.
315///
316/// The depth-optimal [`SplitStrategy::MinDepth`] split reaches `bit_len(degree)`
317/// (`= ceil(log2(degree + 1))`). [`SplitStrategy::MinMult`] minimises the
318/// multiplication count instead, which for the upper part of each
319/// `[2^(b-1), 2^b)` band costs one extra level. The threshold within a band of
320/// `b = bit_len(degree)` bits is `2^b − 2^((b-1)/2) + 1` (matching the giant-step
321/// structure of `eval_giant_steps`).
322/// `log_budget` bits a BSGS evaluation of a degree-`degree` polynomial consumes:
323/// the multiplicative depth of the heaviest chain through its data-dependency
324/// graph. Each `ct×ct` (power-basis build step and giant-step multiply) weighs
325/// `input_log_delta`; the single baby-step `ct×pt` inner product weighs
326/// `coeff_log_delta`.
327///
328/// Closed form. Any single path contains at most one `ct×pt` (build powers → one
329/// baby-step inner product → giant `ct×ct`s), so the heaviest path is the larger
330/// of the longest **pure** `ct×ct` chain and the longest chain ending in a
331/// `ct×pt`:
332///
333/// ```text
334/// consumed = max( P·Δ_in , R·Δ_in + Δ_coeff )
335/// ```
336///
337/// `P`, the longest pure `ct×ct` chain, is `bit_len(degree)`, minus one when
338/// `degree` is a power of two: then its leading term is a lone constant reached
339/// only through a `ct×pt` (a `MinDepth` fold, or the deepest `MinMult` baby step),
340/// so no pure chain reaches the top. For `MinMult` with `degree ≥ threshold` —
341/// where [`bsgs_eval_depth`] already adds the extra level — `P = bit_len =
342/// eval_depth − 1` drops out automatically, so this one expression covers both
343/// strategies.
344///
345/// `R`, the `ct×ct` count of the longest `ct×pt`-terminated chain, is
346/// `eval_depth − 1` (the highest term realises a `ct×pt` chain of the full
347/// critical-path length [`bsgs_eval_depth`]) for every degree, with one
348/// exception: `MinMult` degree 5, whose `[1,1,1]` baby-step layout strands the
349/// third step as an additive carry, capping `R` at 1 (verified the sole exception
350/// for all degrees up to 8191).
351///
352/// Parity- and basis-independent: those affect op *counts*, not critical-path
353/// depth. Validated exhaustively against a faithful replay of the evaluator for
354/// every degree `2..=511`, both strategies and bases (see the `consumed_bits_*`
355/// tests).
356pub fn bsgs_consumed_bits(
357    degree: usize,
358    strategy: SplitStrategy,
359    _parity: Parity,
360    _basis: Basis,
361    input_log_delta: usize,
362    coeff_log_delta: usize,
363) -> usize {
364    if degree == 0 {
365        return 0;
366    }
367    let eval_depth = bsgs_eval_depth(degree, strategy);
368    let pure_depth = bit_len(degree) - degree.is_power_of_two() as usize;
369    let ctpt_depth = if matches!(strategy, SplitStrategy::MinMult) && degree == 5 {
370        1
371    } else {
372        eval_depth - 1
373    };
374    (pure_depth * input_log_delta).max(ctpt_depth * input_log_delta + coeff_log_delta)
375}
376
377/// Faithful replay of the homomorphic BSGS schedule — flat baby-step list,
378/// iterative giant-step pairing (`b = b·Xᵍˢᵖ + a`), trailing-constant fold —
379/// computing the same heaviest-chain weight as [`bsgs_consumed_bits`]. Kept as the
380/// structure-exact reference the closed form is tested against.
381#[cfg(test)]
382fn bsgs_consumed_bits_reference(
383    degree: usize,
384    strategy: SplitStrategy,
385    parity: Parity,
386    basis: Basis,
387    input_log_delta: usize,
388    coeff_log_delta: usize,
389) -> usize {
390    if degree == 0 {
391        return 0;
392    }
393    let log_split = split_for_strategy(strategy, degree, parity, basis);
394    // Accurate flat baby-step degree list, mirroring `decompose_bsgs_coeffs`
395    // (including the `MinDepth` leading-split recursion).
396    let split_leading = matches!(strategy, SplitStrategy::MinDepth);
397    let mut baby_degrees: Vec<usize> = Vec::new();
398    collect_baby_step_degrees(degree, log_split, degree, true, split_leading, &mut baby_degrees);
399
400    // A trailing lone constant is folded into the top power as a `ct×pt` (see the
401    // ckks evaluator); its fold power `degree` is a built giant power iff `degree`
402    // is a power of two.
403    let n_baby = baby_degrees.len();
404    let trailing_const = n_baby >= 2 && baby_degrees[n_baby - 1] == 0;
405    let can_fold = trailing_const && degree.is_power_of_two();
406    let n_to_process = if can_fold { n_baby - 1 } else { n_baby };
407
408    // Weight of one evaluated baby step: the build depth of its deepest used power
409    // (`ct×ct`s) plus the single `ct×pt` inner product. A lone constant is free
410    // (it is just an encoded plaintext at full budget).
411    let baby_weight = |d: usize| -> usize {
412        let highest = match parity {
413            Parity::Full => d,
414            Parity::Odd => d - (d + 1) % 2,
415            Parity::Even => d - d % 2,
416        };
417        if highest == 0 {
418            0
419        } else {
420            power_basis_depth(highest) * input_log_delta + coeff_log_delta
421        }
422    };
423
424    let mut active: Vec<(usize, usize)> = baby_degrees[..n_to_process].iter().map(|&d| (d, baby_weight(d))).collect();
425
426    // Replay the giant-step pairing of `eval_giant_steps`: adjacent equal-degree
427    // steps combine as `b = b·Xᵍˢᵖ + a` (one `ct×ct`); the odd one out carries.
428    while active.len() > 1 {
429        let mut next: Vec<(usize, usize)> = Vec::with_capacity(active.len().div_ceil(2));
430        let mut i = 0;
431        while i < active.len() {
432            let is_last = i + 1 == active.len();
433            if !is_last && active[i].0 == active[i + 1].0 {
434                let gsp = (active[i].0 + 1).next_power_of_two();
435                let x_pow = power_basis_depth(gsp) * input_log_delta;
436                // b = b·Xᵍˢᵖ (ct×ct) then + a.
437                let combined = (x_pow.max(active[i + 1].1) + input_log_delta).max(active[i].1);
438                next.push((2 * gsp - 1, combined));
439                i += 2;
440            } else if is_last && i > 0 {
441                let degree_carry = next.last().map_or(active[i].0, |&(d, _)| d);
442                next.push((degree_carry, active[i].1));
443                i += 1;
444            } else {
445                next.push(active[i]);
446                i += 1;
447            }
448        }
449        active = next;
450    }
451
452    let mut consumed = active[0].1;
453    if can_fold {
454        // res += X^degree · last_const (build `ct×ct`s + one `ct×pt`).
455        consumed = consumed.max(power_basis_depth(degree) * input_log_delta + coeff_log_delta);
456    }
457    consumed
458}
459
460/// Degree-only replay of [`decompose_bsgs_coeffs`] collecting the flat baby-step
461/// degree list (basis-independent: both monomial and Chebyshev factorizations
462/// split at the same `next_power`). Honors the `MinDepth` leading-split recursion.
463#[cfg(test)]
464fn collect_baby_step_degrees(
465    degree: usize,
466    log_split: usize,
467    max_degree: usize,
468    lead: bool,
469    split_leading: bool,
470    out: &mut Vec<usize>,
471) {
472    let base = 1usize << log_split;
473    if degree < base {
474        if split_leading && should_split_leading_baby_step(degree, log_split, max_degree, lead) {
475            let smaller = min_depth_split(bit_len(degree));
476            if smaller < log_split {
477                collect_baby_step_degrees(degree, smaller, max_degree, lead, split_leading, out);
478                return;
479            }
480        }
481        out.push(degree);
482        return;
483    }
484    let mut next_power = base;
485    while next_power < (degree >> 1) + 1 {
486        next_power <<= 1;
487    }
488    collect_baby_step_degrees(next_power - 1, log_split, max_degree, false, split_leading, out);
489    collect_baby_step_degrees(degree - next_power, log_split, max_degree, lead, split_leading, out);
490}
491
492/// Multiplicative depth (chained `ct×ct`) to build the power-basis element of
493/// index `i`, via the same balanced [`split_degree`] recursion the evaluator
494/// uses. `X¹` (the input) has depth 0.
495#[cfg(test)]
496fn power_basis_depth(i: usize) -> usize {
497    if i <= 1 {
498        0
499    } else {
500        let (a, b) = split_degree(i);
501        power_basis_depth(a).max(power_basis_depth(b)) + 1
502    }
503}
504
505/// `(ct×ct, ct×pt)` multiplication counts for a BSGS evaluation.
506pub fn bsgs_op_counts(degree: usize, strategy: SplitStrategy, parity: Parity, basis: Basis) -> (usize, usize) {
507    if degree == 0 {
508        return (0, 0);
509    }
510    estimate_op_counts(degree, split_for_strategy(strategy, degree, parity, basis), parity, basis)
511}
512
513pub fn bsgs_eval_depth(degree: usize, strategy: SplitStrategy) -> usize {
514    if degree == 0 {
515        return 0;
516    }
517    let b = bit_len(degree);
518    match strategy {
519        SplitStrategy::MinDepth => b,
520        SplitStrategy::MinMult => {
521            let threshold = (1usize << b) - (1usize << ((b - 1) / 2)) + 1;
522            if degree >= threshold { b + 1 } else { b }
523        }
524    }
525}
526
527// ── Polynomial ───────────────────────────────────────────────────────────────
528
529/// Affine change of basis `(u, w)` such that `y = u·x + w` maps an evaluation
530/// point `x` in the approximation interval `[a, b]` to the variable the
531/// coefficients of `basis` are expressed in.
532///
533/// - [`Basis::Monomial`]: identity — `(1, 0)` (coefficients are in `x` directly).
534/// - [`Basis::Chebyshev`]: normalization of `[a, b]` onto the canonical `[-1, 1]`
535///   — `u = 2/(b−a)`, `w = −(a+b)/(b−a)`, i.e. `y = (2x − a − b)/(b − a)`.
536///
537/// The pair lets a caller apply the remap to a ciphertext (`ct ← u·ct + w`)
538/// before a homomorphic evaluation that assumes coefficients in the normalized
539/// variable. See [`Polynomial::change_of_basis`].
540pub fn change_of_basis<F: Float>(basis: Basis, a: F, b: F) -> (F, F) {
541    match basis {
542        Basis::Monomial => (F::one(), F::zero()),
543        Basis::Chebyshev => {
544            let two = F::one() + F::one();
545            let span = b - a;
546            (two / span, -(a + b) / span)
547        }
548    }
549}
550
551/// A plaintext polynomial with real coefficients.
552///
553/// `coeffs[i]` is the coefficient of the degree-`i` term (monomial basis) or
554/// of `Tᵢ(x)` (Chebyshev basis).
555pub struct Polynomial<F> {
556    pub basis: Basis,
557    pub coeffs: Vec<F>,
558    pub parity: Parity,
559    /// Lower bound of the interval `[a, b]` the approximation is valid over.
560    pub a: F,
561    /// Upper bound of the interval `[a, b]` the approximation is valid over.
562    ///
563    /// The coefficients are expressed in the variable `y = u·x + w` of
564    /// [`change_of_basis`](Self::change_of_basis): for `Chebyshev` this maps
565    /// `[a, b]` onto `[-1, 1]`, for `Monomial` it is the identity (so `[a, b]` is
566    /// pure metadata). Defaults to `[-1, 1]`; set via
567    /// [`with_interval`](Self::with_interval) or
568    /// [`chebyshev_interpolate`](Self::chebyshev_interpolate).
569    pub b: F,
570}
571
572impl<F> Polynomial<F>
573where
574    F: Float + FloatConst + FromPrimitive + Debug,
575{
576    /// Constructs a polynomial and auto-detects even/odd symmetry.
577    pub fn new(basis: Basis, coeffs: Vec<F>) -> Self {
578        let parity = if coeffs.iter().enumerate().all(|(i, &c)| i.is_multiple_of(2) || c == F::zero()) {
579            Parity::Even
580        } else if coeffs
581            .iter()
582            .enumerate()
583            .all(|(i, &c)| !i.is_multiple_of(2) || c == F::zero())
584        {
585            Parity::Odd
586        } else {
587            Parity::Full
588        };
589        Self::new_with_parity(basis, coeffs, parity)
590    }
591
592    pub fn new_with_parity(basis: Basis, coeffs: Vec<F>, parity: Parity) -> Self {
593        let one = F::one();
594        Self {
595            basis,
596            coeffs,
597            parity,
598            a: -one,
599            b: one,
600        }
601    }
602
603    /// Sets the approximation interval `[a, b]` (see the [`a`](Self::a)/[`b`](Self::b)
604    /// fields). For `Chebyshev` this is the domain remapped onto `[-1, 1]`; for
605    /// `Monomial` it is metadata only.
606    pub fn with_interval(mut self, a: F, b: F) -> Self {
607        self.a = a;
608        self.b = b;
609        self
610    }
611
612    /// The approximation interval `[a, b]`.
613    pub fn interval(&self) -> (F, F) {
614        (self.a, self.b)
615    }
616
617    /// Affine change of basis `(u, w)` mapping `x ∈ [a, b]` to the variable the
618    /// coefficients are expressed in (`y = u·x + w`). See the free function
619    /// [`change_of_basis`].
620    pub fn change_of_basis(&self) -> (F, F) {
621        change_of_basis(self.basis, self.a, self.b)
622    }
623
624    pub fn chebyshev_interpolate<Fun>(degree: usize, a: F, b: F, f: Fun) -> Result<Self>
625    where
626        Fun: Fn(F) -> F,
627    {
628        chebyshev_interpolate(degree, a, b, f)
629    }
630
631    pub fn degree(&self) -> usize {
632        self.coeffs.len().saturating_sub(1)
633    }
634
635    /// Evaluates the polynomial at `x`.
636    ///
637    /// Uses Horner's method (monomial) or Clenshaw's algorithm (Chebyshev).
638    /// For Chebyshev, `x` should lie in `[−1, 1]`.
639    pub fn evaluate(&self, x: F) -> F {
640        evaluate_coeffs(self.basis, &self.coeffs, x)
641    }
642
643    /// Evaluates this polynomial at `x` in its original interval `[a, b]`,
644    /// applying [`change_of_basis`](Self::change_of_basis) first: identity for
645    /// `Monomial`, the `[a,b]→[-1,1]` remap for `Chebyshev`.
646    pub fn evaluate_on_interval(&self, x: F) -> F {
647        let (u, w) = self.change_of_basis();
648        self.evaluate(u * x + w)
649    }
650
651    /// Folds an even or odd polynomial through a quadratic input transform.
652    ///
653    /// Monomial polynomials use `u = x²`; Chebyshev polynomials use
654    /// `u = T₂(x)`. For even `P`, the result satisfies `P(x) = Q(u)`. For odd
655    /// `P`, it satisfies `P(x) = x·Q(u)`. The accompanying transform records how
656    /// to recover the source polynomial.
657    pub fn fold_parity(&self) -> Result<(Self, PolynomialInputTransform)> {
658        let (start, transform) = match (self.basis, self.parity) {
659            (Basis::Monomial, Parity::Even) => (0, PolynomialInputTransform::Square),
660            (Basis::Monomial, Parity::Odd) => (1, PolynomialInputTransform::SquareTimesInput),
661            (Basis::Chebyshev, Parity::Even) => (0, PolynomialInputTransform::ChebyshevT2),
662            (Basis::Chebyshev, Parity::Odd) => (1, PolynomialInputTransform::ChebyshevT2TimesInput),
663            (_, Parity::Full) => return Err(anyhow!("parity folding requires even or odd parity")),
664        };
665        let minimum_degree = if self.parity == Parity::Even { 2 } else { 3 };
666        ensure!(
667            self.degree() >= minimum_degree,
668            "parity folding a {:?} polynomial requires degree ≥ {minimum_degree}",
669            self.parity
670        );
671        ensure!(
672            self.coeffs
673                .iter()
674                .enumerate()
675                .all(|(i, &coefficient)| { i.is_multiple_of(2) == (self.parity == Parity::Even) || coefficient == F::zero() }),
676            "parity folding requires coefficients consistent with {:?} parity",
677            self.parity
678        );
679
680        if self.basis == Basis::Monomial || self.parity == Parity::Even {
681            let coeffs = self.coeffs.iter().skip(start).step_by(2).copied().collect();
682            return Ok((Self::new_with_parity(self.basis, coeffs, Parity::Full), transform));
683        }
684
685        match self.parity {
686            Parity::Odd => {
687                // Rₖ(u) = T₂ₖ₊₁(x) / x in the Chebyshev basis of
688                // u = T₂(x). Build Rₖ with the recurrence
689                // R₀ = 1, R₁ = 2T₁ - T₀,
690                // Rₖ₊₁ = 2T₁ Rₖ - Rₖ₋₁.
691                let odd_coeffs: Vec<F> = self.coeffs.iter().skip(1).step_by(2).copied().collect();
692                let mut q = vec![F::zero(); odd_coeffs.len()];
693                let mut r_prev = Vec::new();
694                let mut r = vec![F::one()];
695                let two = F::one() + F::one();
696
697                for (k, &coefficient) in odd_coeffs.iter().enumerate() {
698                    for (i, &value) in r.iter().enumerate() {
699                        q[i] = q[i] + coefficient * value;
700                    }
701                    if k + 1 == odd_coeffs.len() {
702                        break;
703                    }
704
705                    let mut next = if k == 0 {
706                        vec![-F::one(), two]
707                    } else {
708                        let mut next = vec![F::zero(); r.len() + 1];
709                        for (i, &value) in r.iter().enumerate() {
710                            if i == 0 {
711                                next[1] = next[1] + two * value;
712                            } else {
713                                next[i - 1] = next[i - 1] + value;
714                                next[i + 1] = next[i + 1] + value;
715                            }
716                        }
717                        for (i, &value) in r_prev.iter().enumerate() {
718                            next[i] = next[i] - value;
719                        }
720                        next
721                    };
722                    std::mem::swap(&mut r_prev, &mut r);
723                    std::mem::swap(&mut r, &mut next);
724                }
725
726                Ok((Self::new_with_parity(Basis::Chebyshev, q, Parity::Full), transform))
727            }
728            _ => unreachable!("monomial and even folds return above"),
729        }
730    }
731
732    /// Decomposes this polynomial into a [`BSGSPolynomial`], encoding each
733    /// baby-step coefficient slice with the scheme-supplied `encode` closure.
734    pub fn decompose_bsgs_with<C>(
735        &self,
736        split_strategy: SplitStrategy,
737        mut encode: impl FnMut(&[F]) -> Result<C>,
738    ) -> Result<BSGSPolynomial<C>> {
739        ensure!(self.degree() >= 1, "polynomial must have degree ≥ 1");
740
741        let degree = self.degree();
742        let log_split = split_for_strategy(split_strategy, degree, self.parity, self.basis);
743        let base = 1usize << log_split;
744        let split_leading = matches!(split_strategy, SplitStrategy::MinDepth);
745
746        let mut baby_steps = Vec::new();
747        decompose_bsgs_coeffs(
748            self.basis,
749            &self.coeffs,
750            log_split,
751            degree,
752            true,
753            split_leading,
754            &mut |baby_coeffs| {
755                baby_steps.push(encode(baby_coeffs)?);
756                Ok(())
757            },
758        )?;
759
760        Ok(BSGSPolynomial {
761            basis: self.basis,
762            degree,
763            base,
764            baby_steps,
765            parity: self.parity,
766            split_strategy,
767            input_transform: PolynomialInputTransform::Identity,
768            a: self.a.to_f64().expect("interval lower bound must convert to f64"),
769            b: self.b.to_f64().expect("interval upper bound must convert to f64"),
770        })
771    }
772
773    /// Folds this even/odd polynomial through `x²` (monomial) or `T₂`
774    /// (Chebyshev), then decomposes and encodes the lower-degree polynomial.
775    ///
776    /// This is explicit because the odd transform costs a final ciphertext
777    /// multiplication and can increase depth for some degrees. The returned
778    /// decomposition carries the transform and includes its cost in
779    /// [`BSGSPolynomial::eval_depth`] and [`BSGSPolynomial::consumed_bits`].
780    pub fn decompose_bsgs_folded_with<C>(
781        &self,
782        split_strategy: SplitStrategy,
783        encode: impl FnMut(&[F]) -> Result<C>,
784    ) -> Result<BSGSPolynomial<C>> {
785        let (folded, input_transform) = self.fold_parity()?;
786        let mut bsgs = folded.decompose_bsgs_with(split_strategy, encode)?;
787        bsgs.input_transform = input_transform;
788        // The public interval still describes the source input. Evaluation first
789        // applies its change of basis, then the attached quadratic transform.
790        bsgs.a = self.a.to_f64().expect("interval lower bound must convert to f64");
791        bsgs.b = self.b.to_f64().expect("interval upper bound must convert to f64");
792        Ok(bsgs)
793    }
794}
795
796/// Evaluates the polynomial with coefficients `coeffs` (in `basis`) at `x`.
797///
798/// Horner's method (monomial) or Clenshaw's algorithm (Chebyshev); for
799/// Chebyshev, `x` should lie in `[−1, 1]`. Operates on a borrowed slice so
800/// callers (e.g. complex polynomials) can evaluate their components without
801/// allocating intermediate [`Polynomial`]s.
802pub fn evaluate_coeffs<F>(basis: Basis, coeffs: &[F], x: F) -> F
803where
804    F: Float,
805{
806    match basis {
807        Basis::Monomial => {
808            let mut y = F::zero();
809            for &c in coeffs.iter().rev() {
810                y = y * x + c;
811            }
812            y
813        }
814        Basis::Chebyshev => {
815            let n = coeffs.len();
816            if n == 0 {
817                return F::zero();
818            }
819            if n == 1 {
820                return coeffs[0];
821            }
822            let two = F::one() + F::one();
823            let mut b2 = F::zero();
824            let mut b1 = F::zero();
825            for i in (1..n).rev() {
826                let tmp = two * x * b1 - b2 + coeffs[i];
827                b2 = b1;
828                b1 = tmp;
829            }
830            coeffs[0] + x * b1 - b2
831        }
832    }
833}
834
835/// Returns the Chebyshev interpolation polynomial of degree `degree` for `f`
836/// on `[a, b]`.
837///
838/// The coefficients are expressed in the normalized Chebyshev variable
839/// `u = (2x-a-b)/(b-a)`. Use [`Polynomial::evaluate_on_interval`] for host
840/// evaluation on the original interval, or evaluate homomorphically on an
841/// input ciphertext that has already been mapped to `u`.
842fn chebyshev_interpolate<F, Fun>(degree: usize, a: F, b: F, f: Fun) -> Result<Polynomial<F>>
843where
844    F: Float + FloatConst + FromPrimitive + Debug,
845    Fun: Fn(F) -> F,
846{
847    ensure!(a < b, "chebyshev_interpolate: expected a < b");
848
849    let n = degree + 1;
850    let two = F::one() + F::one();
851    let half = F::from_f64(0.5).expect("0.5 must be representable");
852    let center = (a + b) * half;
853    let radius = (b - a) * half;
854    let pi_over_n = F::PI() / F::from_usize(n).expect("n must fit in scalar");
855
856    let mut coeffs = vec![F::zero(); n];
857    for k in (1..=n).rev() {
858        let theta = (F::from_usize(k).expect("k must fit in scalar") - half) * pi_over_n;
859        let u = theta.cos();
860        let val = f(center + radius * u);
861        let mut t_prev = F::one();
862        let mut t = u;
863        for coeff in coeffs.iter_mut() {
864            *coeff = *coeff + val * t_prev;
865            let t_next = two * u * t - t_prev;
866            t_prev = t;
867            t = t_next;
868        }
869    }
870
871    let inv_n = F::one() / F::from_usize(n).expect("n must fit in scalar");
872    coeffs[0] = coeffs[0] * inv_n;
873    let two_over_n = two * inv_n;
874    for coeff in coeffs.iter_mut().skip(1) {
875        *coeff = *coeff * two_over_n;
876    }
877
878    Ok(Polynomial::new(Basis::Chebyshev, coeffs).with_interval(a, b))
879}
880
881// ── BSGSPolynomial ────────────────────────────────────────────────────────────
882
883/// A polynomial decomposed for Baby-Step-Giant-Step (BSGS) evaluation.
884///
885/// `baby_steps[0]` is the lowest-degree encoded baby polynomial containing the constant and
886/// low-degree terms; `baby_steps[n−1]` is the highest-degree encoded baby polynomial.
887///
888/// Construct via [`Polynomial::decompose_bsgs_with`].
889pub struct BSGSPolynomial<C> {
890    basis: Basis,
891    degree: usize,
892    base: usize,
893    baby_steps: Vec<C>,
894    parity: Parity,
895    split_strategy: SplitStrategy,
896    input_transform: PolynomialInputTransform,
897    /// Approximation interval `[a, b]`, carried from the source [`Polynomial`]
898    /// (stored as `f64`, decoupled from the erased coefficient type `C`). See
899    /// [`change_of_basis`](Self::change_of_basis).
900    a: f64,
901    b: f64,
902}
903
904impl<BE: Backend, C> BSGSPolynomialInfos<BE> for BSGSPolynomial<C>
905where
906    C: GLWEToBackendRef<BE> + GLWEInfos + BSGSMeta + IntPolyInfos,
907{
908    type Coeffs = C;
909
910    fn degree(&self) -> usize {
911        BSGSPolynomial::degree(self)
912    }
913
914    fn baby_steps(&self) -> usize {
915        BSGSPolynomial::baby_steps(self).len()
916    }
917
918    fn baby_step(&self, i: usize) -> &Self::Coeffs {
919        BSGSPolynomial::baby_step(self, i)
920    }
921
922    fn basis(&self) -> Basis {
923        BSGSPolynomial::basis(self)
924    }
925
926    fn parity(&self) -> Parity {
927        BSGSPolynomial::parity(self)
928    }
929
930    fn log_split(&self) -> usize {
931        BSGSPolynomial::log_split(self)
932    }
933
934    fn split_strategy(&self) -> SplitStrategy {
935        self.split_strategy
936    }
937
938    fn input_transform(&self) -> PolynomialInputTransform {
939        self.input_transform
940    }
941}
942
943impl<C> BSGSPolynomial<C> {
944    /// Returns the polynomial basis used by this decomposition.
945    pub fn basis(&self) -> Basis {
946        self.basis
947    }
948
949    /// Returns the degree encoded by this BSGS decomposition.
950    ///
951    /// This is half the source degree when [`Self::input_transform`] is a
952    /// quadratic parity fold.
953    pub fn degree(&self) -> usize {
954        self.degree
955    }
956
957    /// Returns the baby-step base used by this decomposition.
958    pub fn base(&self) -> usize {
959        self.base
960    }
961
962    /// Returns the baby-step split as `log2(base)`.
963    pub fn log_split(&self) -> usize {
964        self.base.trailing_zeros() as usize
965    }
966
967    /// Number consecutives multiplications needed to evaluate this polynomial.
968    pub fn eval_depth(&self) -> usize {
969        bsgs_eval_depth(self.degree(), self.split_strategy) + self.input_transform.extra_depth()
970    }
971
972    /// `log_budget` bits consumed evaluating this polynomial on a ciphertext, as
973    /// the longest (heaviest) chain through the BSGS data-dependency graph.
974    ///
975    /// `input_log_delta` is the scale of the input ciphertext / its powers
976    /// (consumed by every `ct×ct` along the chain — power basis and giant steps);
977    /// `coeff_log_delta` is the scale of the encoded polynomial coefficients
978    /// (consumed by the single `ct×pt` baby-step inner product on the chain).
979    ///
980    /// The chain has `eval_depth` multiplications; `eval_depth - 1` of them are
981    /// `ct×ct` (weight `input_log_delta`). The weight of the deepest level
982    /// depends on whether a full-depth all-`ct×ct` chain exists:
983    ///
984    /// - **`MinDepth`, non-degenerate**: the recursion keeps the baby-step
985    ///   `ct×pt` shallow, so the deepest chain is all `ct×ct` (a giant squaring);
986    ///   the top weight is `max(input, coeff)` (the `coeff` only wins if it
987    ///   exceeds `input`).
988    /// - **`MinMult`, or the degenerate `MinDepth` case** where the leading chunk
989    ///   is a bare constant (a power-of-two degree with a trailing-constant
990    ///   split): the deepest chain includes the baby-step `ct×pt`, so the top
991    ///   weight is `coeff`.
992    pub fn consumed_bits(&self, input_log_delta: usize, coeff_log_delta: usize) -> usize {
993        bsgs_consumed_bits(
994            self.degree,
995            self.split_strategy,
996            self.parity,
997            self.basis,
998            input_log_delta,
999            coeff_log_delta,
1000        ) + self.input_transform.extra_depth() * input_log_delta
1001    }
1002
1003    /// Returns all encoded baby-step coefficient polynomials.
1004    pub fn baby_steps(&self) -> &[C] {
1005        &self.baby_steps
1006    }
1007
1008    /// Returns one encoded baby-step coefficient polynomial.
1009    ///
1010    /// Panics if `i >= self.baby_steps().len()`.
1011    pub fn baby_step(&self, i: usize) -> &C {
1012        &self.baby_steps[i]
1013    }
1014
1015    /// Returns the polynomial parity carried by this decomposition.
1016    pub fn parity(&self) -> Parity {
1017        self.parity
1018    }
1019
1020    /// Returns the input rewrite required by this decomposition.
1021    pub fn input_transform(&self) -> PolynomialInputTransform {
1022        self.input_transform
1023    }
1024
1025    /// The approximation interval `[a, b]` carried from the source polynomial.
1026    pub fn interval(&self) -> (f64, f64) {
1027        (self.a, self.b)
1028    }
1029
1030    /// Affine change of basis `(u, w)` mapping `x ∈ [a, b]` to the coefficient
1031    /// variable (`y = u·x + w`). See the free function [`change_of_basis`].
1032    pub fn change_of_basis(&self) -> (f64, f64) {
1033        change_of_basis(self.basis, self.a, self.b)
1034    }
1035
1036    /// Rebuilds this BSGS polynomial by mapping borrowed baby-step coefficients.
1037    pub fn map_baby_steps_ref<D>(&self, mut f: impl FnMut(&C) -> D) -> BSGSPolynomial<D> {
1038        BSGSPolynomial {
1039            basis: self.basis,
1040            degree: self.degree,
1041            base: self.base,
1042            baby_steps: self.baby_steps.iter().map(&mut f).collect(),
1043            parity: self.parity,
1044            split_strategy: self.split_strategy,
1045            input_transform: self.input_transform,
1046            a: self.a,
1047            b: self.b,
1048        }
1049    }
1050}
1051
1052// ── BSGS evaluation data traits ───────────────────────────────────────────────
1053
1054/// Per-operation semantic precision carried by a value during BSGS evaluation.
1055///
1056/// `log_budget` is the remaining homomorphic headroom and `log_delta` the
1057/// encoded scaling precision; `k = log_budget + log_delta`.
1058pub trait BSGSMeta {
1059    fn bsgs_log_budget(&self) -> usize;
1060    fn bsgs_log_delta(&self) -> usize;
1061}
1062
1063/// Mutable semantic precision access.
1064pub trait SetBSGSMeta: BSGSMeta {
1065    fn set_bsgs_log_budget(&mut self, log_budget: usize);
1066    fn set_bsgs_log_delta(&mut self, log_delta: usize);
1067}
1068
1069/// Read access to a decomposed BSGS polynomial during evaluation.
1070pub trait BSGSPolynomialInfos<BE: Backend> {
1071    type Coeffs: GLWEToBackendRef<BE> + IntPolyInfos + GLWEInfos + BSGSMeta;
1072    fn degree(&self) -> usize;
1073    fn baby_steps(&self) -> usize;
1074    fn baby_step(&self, i: usize) -> &Self::Coeffs;
1075    fn basis(&self) -> Basis;
1076    fn parity(&self) -> Parity;
1077    fn log_split(&self) -> usize;
1078    fn split_strategy(&self) -> SplitStrategy;
1079    fn input_transform(&self) -> PolynomialInputTransform {
1080        PolynomialInputTransform::Identity
1081    }
1082}
1083
1084/// A single evaluated baby step with its degree.
1085pub trait BabyStep<BE: Backend> {
1086    type Value: GLWEToBackendMut<BE> + GLWEToBackendRef<BE> + GLWEInfos + SetBSGSMeta;
1087    fn degree(&self) -> usize;
1088    fn get(&self) -> &Self::Value;
1089    fn get_mut(&mut self) -> &mut Self::Value;
1090}
1091
1092// ── PowerBasis ────────────────────────────────────────────────────────────────
1093
1094/// Read access to the pre-computed powers feeding a BSGS evaluation.
1095pub trait PowerBasisHelper<BE: Backend, A> {
1096    fn basis(&self) -> Basis;
1097    fn has_power(&self, power: usize) -> bool;
1098    fn get(&self, power: usize) -> Result<&A>;
1099}
1100
1101/// Stores pre-computed powers of a ciphertext for BSGS polynomial evaluation.
1102///
1103/// `values[n]` = X^n (monomial basis) or Tₙ(X) (Chebyshev basis).
1104/// `values[1]` must be provided at construction time.
1105pub struct PowerBasis<A> {
1106    pub(crate) basis: Basis,
1107    pub(crate) values: HashMap<usize, A>,
1108}
1109
1110impl<A> PowerBasis<A> {
1111    /// Creates a power basis with `x` treated as X (or T₁(X) for Chebyshev).
1112    pub fn new(basis: Basis, x: A) -> Self {
1113        let mut values = HashMap::new();
1114        values.insert(1, x);
1115        Self { basis, values }
1116    }
1117
1118    /// Returns the polynomial basis represented by the stored powers.
1119    pub fn basis(&self) -> Basis {
1120        self.basis
1121    }
1122
1123    /// Returns a reference to the stored power at degree `n`, if computed.
1124    pub fn get_stored(&self, n: usize) -> Option<&A> {
1125        self.values.get(&n)
1126    }
1127
1128    /// Returns whether the power at degree `n` is stored.
1129    pub fn contains_power(&self, n: usize) -> bool {
1130        self.values.contains_key(&n)
1131    }
1132
1133    /// Stores `value` as the power at degree `n`, replacing any existing entry.
1134    pub fn set_power(&mut self, n: usize, value: A) {
1135        self.values.insert(n, value);
1136    }
1137
1138    /// Removes and returns a stored power.
1139    pub fn take_power(&mut self, n: usize) -> Option<A> {
1140        self.values.remove(&n)
1141    }
1142}
1143
1144impl<BE: Backend, A> PowerBasisHelper<BE, A> for PowerBasis<A>
1145where
1146    A: GLWEToBackendRef<BE>,
1147{
1148    fn basis(&self) -> Basis {
1149        self.basis
1150    }
1151
1152    fn has_power(&self, power: usize) -> bool {
1153        self.values.contains_key(&power)
1154    }
1155
1156    fn get(&self, power: usize) -> Result<&A> {
1157        self.values
1158            .get(&power)
1159            .ok_or_else(|| anyhow!("PowerBasis: X^{power} not computed; call gen_power or populate first"))
1160    }
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165    use super::*;
1166
1167    fn collect_baby_step_degrees(basis: Basis, degree: usize, log_split: usize, split_leading: bool) -> Vec<usize> {
1168        let coeffs = vec![0.0f64; degree + 1];
1169        let mut degrees = Vec::new();
1170        decompose_bsgs_coeffs(basis, &coeffs, log_split, degree, true, split_leading, &mut |s| {
1171            degrees.push(s.len() - 1);
1172            Ok(())
1173        })
1174        .unwrap();
1175        degrees
1176    }
1177
1178    /// The closed-form [`bsgs_consumed_bits`] must equal the structure-exact
1179    /// schedule replay [`bsgs_consumed_bits_reference`] for every degree, both
1180    /// strategies, both bases, and a spread of `(Δ_in, Δ_coeff)` orderings
1181    /// (including `Δ_in < Δ_coeff`, equal, and zeros).
1182    #[test]
1183    fn consumed_bits_closed_form_matches_reference_full_parity() {
1184        let deltas = [(6, 3), (3, 6), (1, 1), (10, 1), (1, 10), (5, 5), (7, 2), (0, 5), (5, 0)];
1185        for strategy in [SplitStrategy::MinDepth, SplitStrategy::MinMult] {
1186            for basis in [Basis::Monomial, Basis::Chebyshev] {
1187                for degree in 2..=511usize {
1188                    for &(din, dco) in &deltas {
1189                        let got = bsgs_consumed_bits(degree, strategy, Parity::Full, basis, din, dco);
1190                        let want = bsgs_consumed_bits_reference(degree, strategy, Parity::Full, basis, din, dco);
1191                        assert_eq!(got, want, "degree {degree} {strategy:?} {basis:?} din={din} dco={dco}");
1192                    }
1193                }
1194            }
1195        }
1196    }
1197
1198    /// Same, restricted to odd-degree odd-parity polynomials (the eval_mod
1199    /// sine/arcsine families) to confirm the closed form is parity-independent.
1200    #[test]
1201    fn consumed_bits_closed_form_matches_reference_odd_parity() {
1202        let deltas = [(6, 3), (3, 6), (1, 1), (10, 1), (1, 10)];
1203        for strategy in [SplitStrategy::MinDepth, SplitStrategy::MinMult] {
1204            for basis in [Basis::Monomial, Basis::Chebyshev] {
1205                for degree in (3..=511usize).step_by(2) {
1206                    for &(din, dco) in &deltas {
1207                        let got = bsgs_consumed_bits(degree, strategy, Parity::Odd, basis, din, dco);
1208                        let want = bsgs_consumed_bits_reference(degree, strategy, Parity::Odd, basis, din, dco);
1209                        assert_eq!(got, want, "degree {degree} {strategy:?} {basis:?} din={din} dco={dco}");
1210                    }
1211                }
1212            }
1213        }
1214    }
1215
1216    #[test]
1217    fn change_of_basis_maps_interval_endpoints() {
1218        // Monomial: identity regardless of interval.
1219        assert_eq!(change_of_basis(Basis::Monomial, -3.0_f64, 7.0), (1.0, 0.0));
1220
1221        // Chebyshev: y = u·x + w must send a → −1 and b → +1.
1222        for &(a, b) in &[(-1.0_f64, 1.0), (0.0, 2.0), (-8.0, 8.0), (3.0, 11.0)] {
1223            let (u, w) = change_of_basis(Basis::Chebyshev, a, b);
1224            assert!((u * a + w + 1.0).abs() < 1e-12, "a→-1 failed for [{a},{b}]");
1225            assert!((u * b + w - 1.0).abs() < 1e-12, "b→+1 failed for [{a},{b}]");
1226        }
1227
1228        // Canonical Chebyshev domain is the identity.
1229        assert_eq!(change_of_basis(Basis::Chebyshev, -1.0_f64, 1.0), (1.0, 0.0));
1230
1231        // The interval propagates Polynomial → BSGSPolynomial, and both expose the
1232        // same change of basis.
1233        let poly = Polynomial::chebyshev_interpolate(8, 0.0_f64, 4.0, |x| x).unwrap();
1234        assert_eq!(poly.interval(), (0.0, 4.0));
1235        let (u, w) = poly.change_of_basis();
1236        assert_eq!((u, w), change_of_basis(Basis::Chebyshev, 0.0, 4.0));
1237        let bsgs = poly
1238            .decompose_bsgs_with(SplitStrategy::MinDepth, |c| Ok::<_, anyhow::Error>(c.to_vec()))
1239            .unwrap();
1240        assert_eq!(bsgs.interval(), (0.0, 4.0));
1241        assert_eq!(bsgs.change_of_basis(), (u, w));
1242    }
1243
1244    #[test]
1245    fn even_chebyshev_t2_fold_matches_source() {
1246        let poly = Polynomial::new_with_parity(
1247            Basis::Chebyshev,
1248            vec![1.0_f64, 0.0, -0.5, 0.0, 0.25, 0.0, 0.125],
1249            Parity::Even,
1250        );
1251        let (folded, transform) = poly.fold_parity().unwrap();
1252
1253        assert_eq!(transform, PolynomialInputTransform::ChebyshevT2);
1254        assert_eq!(folded.coeffs, vec![1.0, -0.5, 0.25, 0.125]);
1255        for i in 0..=64 {
1256            let x = -1.0 + 2.0 * i as f64 / 64.0;
1257            assert!((poly.evaluate(x) - folded.evaluate(2.0 * x * x - 1.0)).abs() < 1e-12);
1258        }
1259    }
1260
1261    #[test]
1262    fn odd_chebyshev_t2_fold_matches_source() {
1263        let poly = Polynomial::new_with_parity(
1264            Basis::Chebyshev,
1265            vec![0.0_f64, 1.0, 0.0, -0.5, 0.0, 0.25, 0.0, -0.125],
1266            Parity::Odd,
1267        );
1268        let (folded, transform) = poly.fold_parity().unwrap();
1269
1270        assert_eq!(transform, PolynomialInputTransform::ChebyshevT2TimesInput);
1271        for i in 0..=64 {
1272            let x = -1.0 + 2.0 * i as f64 / 64.0;
1273            assert!((poly.evaluate(x) - x * folded.evaluate(2.0 * x * x - 1.0)).abs() < 1e-12);
1274        }
1275    }
1276
1277    #[test]
1278    fn parity_fold_bsgs_preserves_source_interval_and_accounts_for_transform() {
1279        let even = Polynomial::new_with_parity(Basis::Chebyshev, vec![1.0_f64, 0.0, 2.0, 0.0, 3.0, 0.0, 4.0], Parity::Even)
1280            .with_interval(-3.0, 5.0);
1281        let even_bsgs = even
1282            .decompose_bsgs_folded_with(SplitStrategy::MinDepth, |c| Ok::<_, anyhow::Error>(c.to_vec()))
1283            .unwrap();
1284        assert_eq!(even_bsgs.input_transform(), PolynomialInputTransform::ChebyshevT2);
1285        assert_eq!(even_bsgs.degree(), 3);
1286        assert_eq!(even_bsgs.parity(), Parity::Full);
1287        assert_eq!(even_bsgs.interval(), (-3.0, 5.0));
1288        assert_eq!(even_bsgs.eval_depth(), bsgs_eval_depth(3, SplitStrategy::MinDepth) + 1);
1289
1290        let odd = Polynomial::new_with_parity(
1291            Basis::Chebyshev,
1292            vec![0.0_f64, 1.0, 0.0, 2.0, 0.0, 3.0, 0.0, 4.0],
1293            Parity::Odd,
1294        );
1295        let odd_bsgs = odd
1296            .decompose_bsgs_folded_with(SplitStrategy::MinDepth, |c| Ok::<_, anyhow::Error>(c.to_vec()))
1297            .unwrap();
1298        assert_eq!(odd_bsgs.input_transform(), PolynomialInputTransform::ChebyshevT2TimesInput);
1299        assert_eq!(odd_bsgs.degree(), 3);
1300        assert_eq!(odd_bsgs.eval_depth(), bsgs_eval_depth(3, SplitStrategy::MinDepth) + 2);
1301        assert_eq!(
1302            odd_bsgs.consumed_bits(5, 3),
1303            bsgs_consumed_bits(3, SplitStrategy::MinDepth, Parity::Full, Basis::Chebyshev, 5, 3) + 10
1304        );
1305    }
1306
1307    #[test]
1308    fn monomial_parity_folds_match_source() {
1309        let even = Polynomial::new_with_parity(Basis::Monomial, vec![1.0_f64, 0.0, -0.5, 0.0, 0.25, 0.0, 0.125], Parity::Even);
1310        let odd = Polynomial::new_with_parity(
1311            Basis::Monomial,
1312            vec![0.0_f64, 1.0, 0.0, -0.5, 0.0, 0.25, 0.0, -0.125],
1313            Parity::Odd,
1314        );
1315        let (even_folded, even_transform) = even.fold_parity().unwrap();
1316        let (odd_folded, odd_transform) = odd.fold_parity().unwrap();
1317
1318        assert_eq!(even_transform, PolynomialInputTransform::Square);
1319        assert_eq!(odd_transform, PolynomialInputTransform::SquareTimesInput);
1320        for i in 0..=64 {
1321            let x = -1.0 + 2.0 * i as f64 / 64.0;
1322            assert!((even.evaluate(x) - even_folded.evaluate(x * x)).abs() < 1e-12);
1323            assert!((odd.evaluate(x) - x * odd_folded.evaluate(x * x)).abs() < 1e-12);
1324        }
1325    }
1326
1327    #[test]
1328    fn power_basis_can_transfer_ownership_of_a_power() {
1329        let mut powers = PowerBasis::new(Basis::Monomial, 1_u32);
1330        powers.set_power(2, 4);
1331        assert_eq!(powers.take_power(2), Some(4));
1332        assert!(!powers.contains_power(2));
1333    }
1334
1335    #[test]
1336    fn min_mult_chebyshev_degree31_uniform_baby_steps() {
1337        let log_split = min_mult_split(31, Parity::Full, Basis::Chebyshev);
1338        assert_eq!(log_split, 3);
1339        assert_eq!(
1340            collect_baby_step_degrees(Basis::Chebyshev, 31, log_split, false),
1341            vec![7, 7, 7, 7]
1342        );
1343    }
1344
1345    #[test]
1346    fn min_depth_chebyshev_degree31_splits_leading_baby_step() {
1347        let log_split = min_depth_split(bit_len(31));
1348        assert_eq!(log_split, 3);
1349        assert_eq!(
1350            collect_baby_step_degrees(Basis::Chebyshev, 31, log_split, true),
1351            vec![7, 7, 7, 3, 1, 1]
1352        );
1353    }
1354
1355    #[test]
1356    fn bsgs_eval_depth_matches_closed_form() {
1357        assert_eq!(bsgs_eval_depth(0, SplitStrategy::MinDepth), 0);
1358        assert_eq!(bsgs_eval_depth(0, SplitStrategy::MinMult), 0);
1359        // MinDepth reaches `k = bit_len(d) = ceil(log2(d + 1))`. MinMult costs one
1360        // extra level on the upper part of each `[2^(k-1), 2^k)` band, where
1361        // `2^k - d <= 2^((k-1)/2) - 1`.
1362        for d in 1..1024 {
1363            let k = bit_len(d);
1364            let min_mult = if (1usize << k) - d < (1usize << ((k - 1) / 2)) {
1365                k + 1
1366            } else {
1367                k
1368            };
1369            assert_eq!(bsgs_eval_depth(d, SplitStrategy::MinDepth), k, "MinDepth degree {d}");
1370            assert_eq!(bsgs_eval_depth(d, SplitStrategy::MinMult), min_mult, "MinMult degree {d}");
1371        }
1372    }
1373
1374    #[test]
1375    fn power_basis_estimate_caps_baby_steps_at_degree() {
1376        assert_eq!(estimate_power_basis_muls(5, 3, Parity::Full, Basis::Monomial), 4);
1377        assert_eq!(estimate_power_basis_muls(5, 3, Parity::Even, Basis::Monomial), 2);
1378    }
1379}