Skip to main content

ginger/
aberth.rs

1#![allow(non_snake_case)]
2
3use super::horner::{horner_eval_c, horner_eval_f};
4use super::Options;
5use crate::execution_policy::{atomic_decoupled_run, jacobi_mt_run, sequential_run, Step};
6use crate::leja_order::leja_order;
7use crate::scaling::{derivative_coeffs, scaled_coeffs};
8use crate::seqlock::AtomicComplex;
9use num_complex::Complex;
10
11const TWO_PI: f64 = std::f64::consts::TAU;
12
13/// Initial guess for Aberth's method using low-discrepancy sequence
14///
15/// The center $$ c $$ and radius $$ R $$ are derived from the coefficients:
16///
17/// $$ c = -\frac{a_1}{n a_0}, \qquad R = \sqrt\[n\]{-P(c)}, \qquad z_i = c + R \cdot (\cos\theta_i + i\sin\theta_i) $$
18///
19/// where $$ \theta_i = 2\pi v_i $$ with $$ v_i $$ from a van der Corput low-discrepancy sequence.
20pub fn initial_aberth(coeffs: &[f64]) -> Vec<Complex<f64>> {
21    let degree = coeffs.len() - 1;
22    let center = -coeffs[1] / (coeffs[0] * degree as f64);
23    let poly_c = horner_eval_f(coeffs, center);
24    let radius = Complex::<f64>::new(-poly_c, 0.0).powf(1.0 / degree as f64);
25    (0..degree)
26        .map(|i| {
27            // note! swap x and y to match C++ Complex{circle2_table_y, circle2_table_x}
28            let xcoord = crate::tables::circle2_table_y(i);
29            let ycoord = crate::tables::circle2_table_x(i);
30            center + radius * Complex::<f64>::new(xcoord, ycoord)
31        })
32        .collect()
33}
34
35/// Initial guess for Aberth's method
36///
37/// The center $$ c $$ and radius $$ R $$ are derived from the coefficients:
38///
39/// $$ c = -\frac{a_1}{n a_0}, \qquad R = \sqrt\[n\]{-P(c)}, \qquad z_i = c + R \cdot e^{i\theta_i} $$
40///
41/// where $$ \theta_i = 2\pi v_i $$ with $$ v_i $$ from a van der Corput sequence
42/// for low-discrepancy angular spacing.
43///
44/// The `initial_aberth` function calculates the initial guesses for Aberth's method given a
45/// polynomial's coefficients.
46///
47/// Arguments:
48///
49/// * `coeffs`: The `coeffs` parameter is a slice of `f64` values representing the coefficients of a
50///   polynomial. The coefficients are ordered from highest degree to lowest degree. For example, if the
51///   polynomial is `3x^2 + 2x + 1`, the `coeffs` slice would
52///
53/// Returns:
54///
55/// The function `initial_aberth` returns a vector of `Complex<f64>` values, which represent the initial
56/// guesses for the roots of a polynomial.
57///
58/// # Examples:
59///
60/// ```
61/// use ginger::aberth::initial_aberth_orig;
62/// use num_complex::Complex;
63/// use approx_eq::assert_approx_eq;
64///
65/// let coeffs = vec![10.0, 34.0, 75.0, 94.0, 150.0, 94.0, 75.0, 34.0, 10.0];
66/// let z0s = initial_aberth_orig(&coeffs);
67///
68/// assert_approx_eq!(z0s[0].re, 0.6116610247366323);
69/// assert_approx_eq!(z0s[0].im, 0.6926747514925476);
70/// ```
71/// Original initial guess for Aberth's method (equi-angular spacing)
72///
73/// $$ z_i = c + R \cdot \left(\cos\frac{2\pi k}{n} + i\sin\frac{2\pi k}{n}\right) $$
74pub fn initial_aberth_orig(coeffs: &[f64]) -> Vec<Complex<f64>> {
75    let degree = coeffs.len() - 1;
76    let center = -coeffs[1] / (coeffs[0] * degree as f64);
77    let poly_c = horner_eval_f(coeffs, center);
78    let radius = Complex::<f64>::new(-poly_c, 0.0).powf(1.0 / degree as f64);
79    let k = TWO_PI / (degree as f64);
80    (0..degree)
81        .map(|idx| {
82            let theta = k * (0.25 + idx as f64);
83            center + radius * Complex::<f64>::new(theta.cos(), theta.sin())
84        })
85        .collect()
86}
87
88/// One Aberth-Ehrlich correction for a single root.
89///
90/// This mirrors `ginger::detail::aberth_step` in ginger-cpp.
91pub struct AberthStep<'a> {
92    /// Polynomial coefficients (highest degree first).
93    pub coeffs: &'a [f64],
94    /// Derivative coefficients of `coeffs`.
95    pub coeffs1: &'a [f64],
96}
97
98impl Step<Complex<f64>> for AberthStep<'_> {
99    type Cell = AtomicComplex;
100
101    fn run<G, N>(&self, idx: usize, get: G, neighbors: N) -> (f64, Option<Complex<f64>>)
102    where
103        G: Fn(usize) -> Complex<f64>,
104        N: Iterator<Item = usize>,
105    {
106        let zi = get(idx);
107        let p_eval = horner_eval_c(self.coeffs, &zi);
108        let tol_i = p_eval.l1_norm(); // ???
109        let mut p1_eval = horner_eval_c(self.coeffs1, &zi);
110        for j in neighbors {
111            let zj = get(j);
112            p1_eval -= p_eval / (zi - zj);
113        }
114        (tol_i, Some(zi - p_eval / p1_eval)) // Gauss-Seidel fashion
115    }
116}
117
118/// Aberth's method
119///
120/// The `aberth` function implements Aberth's method for finding roots of a polynomial.
121///
122/// Each estimate $$ z_i $$ is updated by the correction:
123///
124/// $$ z_i' = z_i - \frac{P(z_i)}{P'(z_i)} $$
125///
126/// where the derivative is modified by all other root estimates:
127///
128/// $$ P'(z_i) = P_1(z_i) - \sum_{\substack{j=1\\j\neq i}}^n \frac{P(z_i)}{z_i - z_j} $$
129///
130/// Here $$ P_1(z) $$ is the ordinary derivative of $$ P(z) $$. Convergence is cubic.
131///
132/// Arguments:
133///
134/// * `coeffs`: The `coeffs` parameter is a slice of `f64` values representing the coefficients of a
135///   polynomial. The coefficients are ordered from highest degree to lowest degree. For example, if the
136///   polynomial is `3x^2 + 2x + 1`, the `coeffs` slice would
137/// * `zs`: A vector of complex numbers representing the initial guesses for the roots of the polynomial.
138/// * `options`: The `options` parameter is an instance of the `Options` struct, which contains the
139///   following fields:
140///
141/// # Examples:
142///
143/// ```
144/// use ginger::rootfinding::Options;
145/// use ginger::aberth::{initial_aberth, aberth};
146///
147/// let coeffs = vec![10.0, 34.0, 75.0, 94.0, 150.0, 94.0, 75.0, 34.0, 10.0];
148/// let mut zrs = initial_aberth(&coeffs);
149/// let (niter, _found) = aberth(&coeffs, &mut zrs, &Options::default());
150///
151/// assert_eq!(niter, 5);
152/// ```
153#[cfg_attr(feature = "doc-images", doc = svgbobdoc::transform!(
154/// ```svgbob
155///  .───────────.    .───────────.    .──────────────.
156///  │ Polynomial│───►│  Initial  │───►│  Aberth      │
157///  │ coeffs    │    │  Guess    │    │  Iteration   │
158///  '───────────'    '───────────'    '──────┬───────'
159///                                           │
160///                                      .────▼───────.
161///                                      │ Converged?  │
162///                                      '────┬───────'
163///                                     No   │   Yes
164///                                .─────────┘    │
165///                                ▼              ▼
166///                           .───────────.  .───────────.
167///                           │ Iterate   │  │  Roots    │
168///                           '───────────'  '───────────'
169/// ```
170))]
171pub fn aberth(coeffs: &[f64], zs: &mut [Complex<f64>], options: &Options) -> (usize, bool) {
172    let scaled = scaled_coeffs(coeffs);
173    let coeffs1 = derivative_coeffs(&scaled);
174    let step = AberthStep {
175        coeffs: &scaled,
176        coeffs1: &coeffs1,
177    };
178    sequential_run(zs, options, &step)
179}
180
181/// Multi-threading Aberth's method
182///
183/// Each estimate $$ z_i $$ is updated by the correction:
184///
185/// $$ z_i' = z_i - \frac{P(z_i)}{P'(z_i)} $$
186///
187/// where the derivative is modified by all other root estimates:
188///
189/// $$ P'(z_i) = P_1(z_i) - \sum_{\substack{j=1\\j\neq i}}^n \frac{P(z_i)}{z_i - z_j} $$
190///
191/// The `aberth_mt` function in Rust implements the multi-threaded Aberth's method for root finding.
192///
193/// Arguments:
194///
195/// * `coeffs`: The `coeffs` parameter is a slice of `f64` values representing the coefficients of a
196///   polynomial. The polynomial is defined by the equation:
197/// * `zs`: A mutable reference to a vector of Complex numbers. These numbers represent the initial
198///   guesses for the roots of the polynomial equation.
199/// * `options`: The `options` parameter is an instance of the `Options` struct, which contains the
200///   following fields:
201///
202/// # Examples:
203///
204/// ```
205/// use ginger::rootfinding::Options;
206/// use ginger::aberth::{initial_aberth, aberth_mt};
207///
208/// let coeffs = vec![10.0, 34.0, 75.0, 94.0, 150.0, 94.0, 75.0, 34.0, 10.0];
209/// let mut zrs = initial_aberth(&coeffs);
210/// let (niter, _found) = aberth_mt(&coeffs, &mut zrs, &Options::default());
211///
212/// assert_eq!(niter, 6);
213/// ```
214pub fn aberth_mt(coeffs: &[f64], zs: &mut [Complex<f64>], options: &Options) -> (usize, bool) {
215    let scaled = scaled_coeffs(coeffs);
216    let coeffs1 = derivative_coeffs(&scaled);
217    let step = AberthStep {
218        coeffs: &scaled,
219        coeffs1: &coeffs1,
220    };
221    jacobi_mt_run(zs, options, &step)
222}
223
224/// Atomic multi-threading Aberth's method (decoupled)
225///
226/// Each estimate $$ z_i $$ is updated by the correction:
227///
228/// $$ z_i' = z_i - \frac{P(z_i)}{P'(z_i)} $$
229///
230/// where the derivative is modified by all other root estimates:
231///
232/// $$ P'(z_i) = P_1(z_i) - \sum_{\substack{j=1\\j\neq i}}^n \frac{P(z_i)}{z_i - z_j} $$
233///
234/// Unlike `aberth_mt` (Jacobi snapshot + per-iteration barrier), this variant
235/// builds a single atomic working buffer once: each thread owns a chunk of root
236/// slots (single-writer, multi-reader via a seqlock) and runs its own iteration
237/// loop independently. There is no per-iteration synchronization — a thread
238/// exits as soon as its own chunk converges or the maximum number of
239/// iterations is exceeded. The returned iteration count is the maximum across
240/// threads, and `found` is true only if every chunk converged.
241///
242/// Arguments:
243///
244/// * `coeffs`: The `coeffs` parameter is a slice of `f64` values representing the coefficients of a
245///   polynomial. The polynomial is defined by the equation:
246/// * `zs`: A mutable reference to a vector of Complex numbers. These numbers represent the initial
247///   guesses for the roots of the polynomial equation.
248/// * `options`: The `options` parameter is an instance of the `Options` struct, which contains the
249///   following fields:
250///
251/// # Examples:
252///
253/// ```
254/// use ginger::rootfinding::Options;
255/// use ginger::aberth::{initial_aberth, aberth_atomic};
256///
257/// let coeffs = vec![10.0, 34.0, 75.0, 94.0, 150.0, 94.0, 75.0, 34.0, 10.0];
258/// let mut zrs = initial_aberth(&coeffs);
259/// let (niter, _found) = aberth_atomic(&coeffs, &mut zrs, &Options::default());
260///
261/// assert!(niter > 0);
262/// ```
263pub fn aberth_atomic(coeffs: &[f64], zs: &mut [Complex<f64>], options: &Options) -> (usize, bool) {
264    let scaled = scaled_coeffs(coeffs);
265    let coeffs1 = derivative_coeffs(&scaled);
266    let step = AberthStep {
267        coeffs: &scaled,
268        coeffs1: &coeffs1,
269    };
270    atomic_decoupled_run(zs, options, &step)
271}
272
273/// Initial guess for Aberth's method using auto-correlation
274///
275/// $$ R = \sqrt\[n\]{|a_n|},\qquad R \leftarrow \max(R, 1/R),\qquad z_i = c + R \cdot e^{i\theta_i} $$
276///
277/// The `initial_aberth_autocorr` function calculates initial guesses for Aberth's method
278/// specifically tailored for auto-correlation polynomials.
279///
280/// Arguments:
281///
282/// * `coeffs`: The `coeffs` parameter is a slice of `f64` values representing the coefficients
283///   of a polynomial. The coefficients are ordered from highest degree to lowest degree.
284///
285/// Returns:
286///
287/// The function returns a vector of `Complex<f64>` values, representing the initial guesses
288/// for the roots of a polynomial.
289pub fn initial_aberth_autocorr(coeffs: &[f64]) -> Vec<Complex<f64>> {
290    let degree = coeffs.len() - 1; // assume even
291    let center = -coeffs[1] / (coeffs[0] * degree as f64);
292    let poly_c = horner_eval_f(coeffs, center);
293    let mut radius = poly_c.abs().powf(1.0 / degree as f64);
294    if radius > 1.0 {
295        radius = 1.0 / radius;
296    }
297    (0..degree / 2)
298        .map(|i| {
299            // note! swap x and y to match C++ Complex{circle2_table_y, circle2_table_x}
300            let xcoord = crate::tables::circle2_table_y(i);
301            let ycoord = crate::tables::circle2_table_x(i);
302            center + radius * Complex::<f64>::new(xcoord, ycoord)
303        })
304        .collect()
305}
306
307/// One Aberth-Ehrlich correction for a palindromic (auto-correlation) root;
308/// each neighbor root contributes both `zj` and its reciprocal `1/zj`.
309///
310/// This mirrors `ginger::detail::aberth_autocorr_step` in ginger-cpp.
311pub struct AberthAutocorrStep<'a> {
312    /// Polynomial coefficients (highest degree first).
313    pub coeffs: &'a [f64],
314    /// Derivative coefficients of `coeffs`.
315    pub coeffs1: &'a [f64],
316}
317
318impl Step<Complex<f64>> for AberthAutocorrStep<'_> {
319    type Cell = AtomicComplex;
320
321    fn run<G, N>(&self, idx: usize, get: G, neighbors: N) -> (f64, Option<Complex<f64>>)
322    where
323        G: Fn(usize) -> Complex<f64>,
324        N: Iterator<Item = usize>,
325    {
326        let zi = get(idx);
327        let p_eval = horner_eval_c(self.coeffs, &zi);
328        let tol_i = p_eval.l1_norm(); // ???
329        let mut p1_eval = horner_eval_c(self.coeffs1, &zi);
330        for j in neighbors {
331            let zj = get(j);
332            p1_eval -= p_eval / (zi - zj);
333            p1_eval -= p_eval / (zi - 1.0 / zj);
334        }
335        (tol_i, Some(zi - p_eval / p1_eval)) // Gauss-Seidel fashion
336    }
337}
338
339/// Aberth's method for auto-correlation polynomials
340///
341/// Each estimate $$ z_i $$ is updated by the correction, considering both $$ z $$
342/// and $$ 1/\bar{z} $$ in the derivative:
343///
344/// $$ P'(z_i) = P_1(z_i) - \sum_{\substack{j=1\\j\neq i}}^n
345///    \left(\frac{P(z_i)}{z_i - z_j} + \frac{P(z_i)}{z_i - 1/z_j}\right) $$
346///
347/// The `aberth_autocorr` function implements Aberth's method specifically for
348/// auto-correlation polynomials, where roots come in reciprocal pairs.
349///
350/// Arguments:
351///
352/// * `coeffs`: The polynomial coefficients (highest to lowest degree)
353/// * `zs`: Mutable slice of complex root approximations
354/// * `options`: Iteration options (max iterations, tolerance)
355///
356/// Returns:
357///
358/// A tuple of (number of iterations, whether convergence was achieved)
359pub fn aberth_autocorr(
360    coeffs: &[f64],
361    zs: &mut [Complex<f64>],
362    options: &Options,
363) -> (usize, bool) {
364    let scaled = scaled_coeffs(coeffs);
365    let coeffs1 = derivative_coeffs(&scaled);
366    let step = AberthAutocorrStep {
367        coeffs: &scaled,
368        coeffs1: &coeffs1,
369    };
370    sequential_run(zs, options, &step)
371}
372
373/// Reconstruct a monic polynomial from its roots using Leja ordering
374///
375/// $$ P(x) = \prod_{i=1}^n (x - r_i) = x^n + a_{n-1} x^{n-1} + \cdots + a_0 $$
376///
377/// The coefficients are computed by repeated convolution. Starting from $$ \[1\] $$,
378/// for each root $$ r $$:
379///
380/// $$ c_{k+1} \leftarrow c_{k+1} - r \cdot c_k $$
381///
382/// Given a set of complex roots, reconstruct the monic polynomial coefficients
383/// (highest degree first) by multiplying (x - root) factors. Leja ordering is
384/// applied for numerical accuracy.
385///
386/// Arguments:
387///
388/// * `zs` - Input vector of complex roots
389///
390/// Returns:
391///
392/// Monic polynomial coefficients (highest degree first)
393pub fn poly_from_roots(zs: &[Complex<f64>]) -> Vec<f64> {
394    if zs.is_empty() {
395        return vec![1.0];
396    }
397    let ordered = leja_order(zs.to_vec());
398    let mut coeffs = vec![Complex::new(1.0, 0.0)];
399    for z in &ordered {
400        let mut prev = coeffs[0];
401        for item in coeffs.iter_mut().skip(1) {
402            let old = *item;
403            *item -= z * prev;
404            prev = old;
405        }
406        coeffs.push(-z * prev);
407    }
408    coeffs.iter().map(|c| c.re).collect()
409}
410
411/// Reconstruct a monic polynomial from its autocorrelation roots
412///
413/// Auto-correlation (palindromic) polynomials have roots in reciprocal pairs.
414/// The `aberth_autocorr` functions find the degree/2 "independent" roots.
415/// This function adds the reciprocal of each root $1/z$ to get the full set
416/// of degree roots, then reconstructs with Leja ordering.
417///
418/// $$ P(x) = \prod_{i=1}^{n/2} (x - z_i)(x - 1/z_i) $$
419///
420/// Arguments:
421///
422/// * `zs` - Roots found by aberth_autocorr
423///
424/// Returns:
425///
426/// Monic polynomial coefficients (highest degree first)
427pub fn poly_from_autocorr_roots(zs: &[Complex<f64>]) -> Vec<f64> {
428    if zs.is_empty() {
429        return vec![1.0];
430    }
431    // Add reciprocals to account for the palindromic root-pair structure
432    let mut all_roots: Vec<Complex<f64>> = Vec::with_capacity(2 * zs.len());
433    for z in zs {
434        all_roots.push(*z);
435        all_roots.push(1.0 / z);
436    }
437    poly_from_roots(&all_roots)
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_horner_eval() {
446        let coeffs = vec![10.0, 34.0, 75.0, 94.0, 150.0, 94.0, 75.0, 34.0, 10.0];
447        let z = Complex::new(0.0, 0.0);
448        let p_eval = horner_eval_c(&coeffs, &z);
449        assert_eq!(p_eval.re, 10.0);
450        assert_eq!(p_eval.im, 0.0);
451        let z = Complex::new(1.0, 0.0);
452        let p_eval = horner_eval_c(&coeffs, &z);
453        assert_eq!(p_eval.re, 576.0);
454        assert_eq!(p_eval.im, 0.0);
455    }
456
457    #[test]
458    fn test_aberth() {
459        let coeffs = vec![10.0, 34.0, 75.0, 94.0, 150.0, 94.0, 75.0, 34.0, 10.0];
460        let mut zrs = initial_aberth(&coeffs);
461        let (niter, found) = aberth(&coeffs, &mut zrs, &Options::default());
462        assert_eq!(niter, 5);
463        assert!(found);
464    }
465
466    #[test]
467    fn test_aberth_mt() {
468        let coeffs = vec![10.0, 34.0, 75.0, 94.0, 150.0, 94.0, 75.0, 34.0, 10.0];
469        let mut zrs = initial_aberth(&coeffs);
470        let (niter, found) = aberth_mt(&coeffs, &mut zrs, &Options::default());
471        assert_eq!(niter, 6);
472        assert!(found);
473    }
474
475    #[test]
476    fn test_aberth_atomic() {
477        let coeffs = vec![10.0, 34.0, 75.0, 94.0, 150.0, 94.0, 75.0, 34.0, 10.0];
478        let mut zrs = initial_aberth(&coeffs);
479        let (niter, found) = aberth_atomic(&coeffs, &mut zrs, &Options::default());
480        assert!(found);
481        assert!(niter <= 14);
482    }
483
484    const FIR_COEFFS: [f64; 49] = [
485        -0.00196191,
486        -0.00094597,
487        -0.00023823,
488        0.00134667,
489        0.00380494,
490        0.00681596,
491        0.0097864,
492        0.01186197,
493        0.0121238,
494        0.00985211,
495        0.00474894,
496        -0.00281751,
497        -0.01173923,
498        -0.0201885,
499        -0.02590168,
500        -0.02658216,
501        -0.02035729,
502        -0.00628271,
503        0.01534627,
504        0.04279982,
505        0.0732094,
506        0.10275561,
507        0.12753013,
508        0.14399228,
509        0.15265722,
510        0.14399228,
511        0.12753013,
512        0.10275561,
513        0.0732094,
514        0.04279982,
515        0.01534627,
516        -0.00628271,
517        -0.02035729,
518        -0.02658216,
519        -0.02590168,
520        -0.0201885,
521        -0.01173923,
522        -0.00281751,
523        0.00474894,
524        0.00985211,
525        0.0121238,
526        0.01186197,
527        0.0097864,
528        0.00681596,
529        0.00380494,
530        0.00134667,
531        -0.00023823,
532        -0.00094597,
533        -0.00196191,
534    ];
535
536    #[test]
537    fn test_aberth_atomic_fir() {
538        let options = Options {
539            tolerance: 1e-8,
540            ..Options::default()
541        };
542        let mut zrs = initial_aberth(&FIR_COEFFS);
543        let (niter, found) = aberth_atomic(&FIR_COEFFS, &mut zrs, &options);
544        assert!(found);
545        assert!(niter <= 100, "niter={niter}");
546    }
547
548    #[test]
549    fn test_aberth_atomic_reconstruction() {
550        let coeffs = vec![10.0, 34.0, 75.0, 94.0, 150.0, 94.0, 75.0, 34.0, 10.0];
551        let mut zrs = initial_aberth(&coeffs);
552        let (_, found) = aberth_atomic(&coeffs, &mut zrs, &Options::default());
553        assert!(found);
554        let monic = poly_from_roots(&zrs);
555        let scale = coeffs[0];
556        for (i, c) in coeffs.iter().enumerate() {
557            assert!(
558                (monic[i] * scale - c).abs() < 1e-8,
559                "coefficient {i} mismatch: {} vs {}",
560                monic[i] * scale,
561                c
562            );
563        }
564    }
565
566    #[test]
567    fn test_aberth_autocorr() {
568        let coeffs = vec![10.0, 34.0, 75.0, 94.0, 150.0, 94.0, 75.0, 34.0, 10.0];
569        let mut zrs = initial_aberth_autocorr(&coeffs);
570        let (niter, found) = aberth_autocorr(&coeffs, &mut zrs, &Options::default());
571        assert!(niter <= 7);
572        assert!(found);
573    }
574
575    #[test]
576    fn test_aberth_scaled_polynomial_converges() {
577        // Same roots as the degree-8 palindromic polynomial, but with
578        // coefficients large enough that an absolute residual test can never
579        // reach the default 1e-12 tolerance.
580        let base = vec![10.0, 34.0, 75.0, 94.0, 150.0, 94.0, 75.0, 34.0, 10.0];
581        let scaled: Vec<f64> = base.iter().map(|c| c * 1e6).collect();
582        let mut zs = initial_aberth(&scaled);
583        let (niter, found) = aberth(&scaled, &mut zs, &Options::default());
584        assert!(found);
585        assert!(niter <= 12, "niter={niter}");
586    }
587
588    #[test]
589    fn test_scaling_does_not_change_roots() {
590        let base = vec![10.0, 34.0, 75.0, 94.0, 150.0, 94.0, 75.0, 34.0, 10.0];
591        let scaled: Vec<f64> = base.iter().map(|c| c * 1e6).collect();
592        let z0 = initial_aberth(&base);
593        let mut za = z0.clone();
594        let mut zb = z0.clone();
595        let (na, oka) = aberth(&base, &mut za, &Options::default());
596        let (nb, okb) = aberth(&scaled, &mut zb, &Options::default());
597        assert!(oka && okb);
598        assert_eq!(na, nb);
599        for (a, b) in za.iter().zip(zb.iter()) {
600            assert!((a - b).norm() < 1e-9, "{a} vs {b}");
601        }
602    }
603
604    #[test]
605    fn test_poly_from_roots() {
606        // Polynomial (x-1)(x-2) = x^2 - 3x + 2
607        let roots = vec![Complex::new(1.0, 0.0), Complex::new(2.0, 0.0)];
608        let coeffs = poly_from_roots(&roots);
609        assert_eq!(coeffs.len(), 3);
610        assert!((coeffs[0] - 1.0).abs() < 1e-12);
611        assert!((coeffs[1] + 3.0).abs() < 1e-12);
612        assert!((coeffs[2] - 2.0).abs() < 1e-12);
613    }
614
615    #[test]
616    fn test_poly_from_autocorr_roots() {
617        // Simple test: just check it doesn't panic and returns right length
618        let roots = vec![Complex::new(0.5, 0.5), Complex::new(0.5, -0.5)];
619        let coeffs = poly_from_autocorr_roots(&roots);
620        // Should have 2*roots.len() + 1 coefficients
621        assert_eq!(coeffs.len(), 5);
622        // Monic
623        assert!((coeffs[0] - 1.0).abs() < 1e-12);
624    }
625
626    #[test]
627    fn test_poly_from_roots_empty() {
628        let coeffs = poly_from_roots(&[]);
629        assert_eq!(coeffs, vec![1.0]);
630    }
631}