Skip to main content

alkahest_cas/solver/
homotopy.rs

1//! Numerical algebraic geometry — total-degree homotopy continuation (V2-14).
2//!
3//! We track `(1−t)·γ·G(z) + t·F(z) = 0` from `t=0→1`, with decoupled start
4//! `G_i(z) = z_i^{d_i} − 1` and `d_i` the total degree of `F_i`.  The Bézout
5//! start count ∏ d_i reaches the affine root count only for sufficiently generic
6//! **dense** systems; **deficient** families (fewer finite roots than the
7//! Bézout bound — e.g. Katsura) need a polyhedral / mixed-volume start; that
8//! is out of scope here.
9//!
10//! Endpoints are Newton-polished in ℝⁿ and checked with a conservative Smale
11//! heuristic plus `ArbBall` enclosures.
12
13#![allow(clippy::needless_range_loop)]
14
15use crate::ball::ArbBall;
16use crate::kernel::{ExprId, ExprPool};
17use crate::poly::groebner::GbPoly;
18use crate::solver::{expr_to_gbpoly, SolverError};
19use rug::Rational;
20use std::f64::consts::PI;
21
22#[derive(Clone, Copy, Debug)]
23struct C64 {
24    re: f64,
25    im: f64,
26}
27
28impl C64 {
29    const ZERO: C64 = C64 { re: 0.0, im: 0.0 };
30
31    fn new(re: f64, im: f64) -> Self {
32        C64 { re, im }
33    }
34
35    fn from_f64(re: f64) -> Self {
36        C64 { re, im: 0.0 }
37    }
38
39    fn norm2(self) -> f64 {
40        self.re.hypot(self.im)
41    }
42
43    fn add(a: C64, b: C64) -> C64 {
44        C64 {
45            re: a.re + b.re,
46            im: a.im + b.im,
47        }
48    }
49
50    fn sub(a: C64, b: C64) -> C64 {
51        C64 {
52            re: a.re - b.re,
53            im: a.im - b.im,
54        }
55    }
56
57    fn mul(a: C64, b: C64) -> C64 {
58        C64 {
59            re: a.re * b.re - a.im * b.im,
60            im: a.re * b.im + a.im * b.re,
61        }
62    }
63
64    fn scale(s: f64, a: C64) -> C64 {
65        C64 {
66            re: s * a.re,
67            im: s * a.im,
68        }
69    }
70
71    fn neg(a: C64) -> C64 {
72        C64 {
73            re: -a.re,
74            im: -a.im,
75        }
76    }
77
78    fn div(a: C64, b: C64) -> Option<C64> {
79        let d = b.re * b.re + b.im * b.im;
80        if d < 1e-30 {
81            return None;
82        }
83        Some(C64 {
84            re: (a.re * b.re + a.im * b.im) / d,
85            im: (a.im * b.re - a.re * b.im) / d,
86        })
87    }
88
89    fn pow_int(base: C64, exp: u32) -> C64 {
90        if exp == 0 {
91            return C64::new(1.0, 0.0);
92        }
93        let mut e = exp;
94        let mut acc = C64::new(1.0, 0.0);
95        let mut cur = base;
96        while e > 0 {
97            if e & 1 == 1 {
98                acc = C64::mul(acc, cur);
99            }
100            cur = C64::mul(cur, cur);
101            e >>= 1;
102        }
103        acc
104    }
105}
106
107/// Controls for [`solve_numerical`].
108#[derive(Debug, Clone)]
109pub struct HomotopyOpts {
110    pub max_tracker_steps: usize,
111    pub dt_initial: f64,
112    pub dt_min: f64,
113    pub homotopy_tol: f64,
114    pub newton_tol: f64,
115    pub newton_cap: usize,
116    pub dedup_tol: f64,
117    pub gamma_angle_seed: Option<u64>,
118    pub certify_prec_bits: u32,
119    /// Abort if Bézout path budget (= ∏ total degrees) exceeds this cap.
120    pub max_bezout_paths: usize,
121}
122
123impl Default for HomotopyOpts {
124    fn default() -> Self {
125        Self {
126            max_tracker_steps: 50_000,
127            dt_initial: 0.02,
128            dt_min: 1e-8,
129            homotopy_tol: 1e-10,
130            newton_tol: 1e-12,
131            newton_cap: 48,
132            dedup_tol: 1e-5,
133            gamma_angle_seed: Some(31415926535897),
134            certify_prec_bits: 128,
135            max_bezout_paths: 20_000,
136        }
137    }
138}
139
140#[derive(Debug, Clone)]
141pub struct CertifiedPoint {
142    pub coordinates: Vec<f64>,
143    pub max_residual_f64: f64,
144    pub smale_alpha: Option<f64>,
145    pub smale_certified: bool,
146    pub enclosure: Vec<ArbBall>,
147}
148
149#[derive(Debug, Clone)]
150pub enum HomotopyError {
151    Algebraic(SolverError),
152    BezoutTooLarge(usize),
153    SingularJacobian,
154    TrackerFailed(&'static str),
155}
156
157impl std::fmt::Display for HomotopyError {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        match self {
160            HomotopyError::Algebraic(e) => write!(f, "{e}"),
161            HomotopyError::BezoutTooLarge(n) => {
162                write!(
163                    f,
164                    "Bézout path budget {n} exceeds HomotopyOpts::max_bezout_paths — \
165                     use mixed-volume starts for large sparse systems",
166                )
167            }
168            HomotopyError::SingularJacobian => write!(f, "singular Jacobian"),
169            HomotopyError::TrackerFailed(s) => write!(f, "path tracker failed: {s}"),
170        }
171    }
172}
173
174impl std::error::Error for HomotopyError {}
175
176impl crate::errors::AlkahestError for HomotopyError {
177    fn code(&self) -> &'static str {
178        match self {
179            HomotopyError::Algebraic(inner) => inner.code(),
180            HomotopyError::BezoutTooLarge(_) => "E-HOMOTOPY-002",
181            HomotopyError::SingularJacobian => "E-HOMOTOPY-003",
182            HomotopyError::TrackerFailed(_) => "E-HOMOTOPY-004",
183        }
184    }
185
186    fn remediation(&self) -> Option<&'static str> {
187        match self {
188            HomotopyError::Algebraic(inner) => inner.remediation(),
189            HomotopyError::BezoutTooLarge(_) => {
190                Some("raise HomotopyOpts::max_bezout_paths or switch to polyhedral continuation")
191            }
192            HomotopyError::SingularJacobian => {
193                Some("try HomotopyOpts::gamma_angle_seed or rescale equations")
194            }
195            HomotopyError::TrackerFailed(_) => {
196                Some("adjust dt_initial, relax tolerances, or increase max_tracker_steps")
197            }
198        }
199    }
200}
201
202fn rat_to_f64(r: &Rational) -> f64 {
203    r.numer().to_f64() / r.denom().to_f64()
204}
205
206fn total_degree(p: &GbPoly) -> u32 {
207    p.terms
208        .keys()
209        .map(|e| e.iter().sum::<u32>())
210        .max()
211        .unwrap_or(0)
212}
213
214fn gbpoly_eval_c(p: &GbPoly, z: &[C64]) -> C64 {
215    let mut acc = C64::ZERO;
216    for (exp, coeff) in &p.terms {
217        let c = rat_to_f64(coeff);
218        let mut mono = C64::new(c, 0.0);
219        for (i, &e) in exp.iter().enumerate() {
220            if e != 0 {
221                mono = C64::mul(mono, C64::pow_int(z[i], e));
222            }
223        }
224        acc = C64::add(acc, mono);
225    }
226    acc
227}
228
229fn gbpoly_derive_var(p: &GbPoly, var: usize) -> GbPoly {
230    let nv = p.n_vars;
231    let mut out = GbPoly::zero(nv);
232    for (exp, coeff) in &p.terms {
233        let e = exp.get(var).copied().unwrap_or(0);
234        if e == 0 {
235            continue;
236        }
237        let mut new_exp = exp.clone();
238        new_exp[var] = e - 1;
239        let scale = coeff * Rational::from(e);
240        out = out.add(&GbPoly::monomial(new_exp, scale));
241    }
242    out
243}
244
245fn jacobian_c(sys: &[GbPoly], z: &[C64]) -> Vec<Vec<C64>> {
246    let n = sys.len();
247    let mut j = vec![vec![C64::ZERO; n]; n];
248    for i in 0..n {
249        for k in 0..n {
250            let di = gbpoly_derive_var(&sys[i], k);
251            j[i][k] = gbpoly_eval_c(&di, z);
252        }
253    }
254    j
255}
256
257fn hessian_ij_c(poly: &GbPoly, row_var: usize, col_var: usize, z: &[C64]) -> C64 {
258    let d_row = gbpoly_derive_var(poly, row_var);
259    gbpoly_eval_c(&gbpoly_derive_var(&d_row, col_var), z)
260}
261
262fn start_system_roots(degrees: &[u32]) -> Vec<Vec<C64>> {
263    let mut curves: Vec<Vec<C64>> = Vec::with_capacity(degrees.len());
264    for &d in degrees {
265        assert!(d > 0);
266        let mut roots = Vec::with_capacity(d as usize);
267        for k in 0..d {
268            let ang = PI * (2.0 * (k as f64) / (d as f64));
269            roots.push(C64::new(ang.cos(), ang.sin()));
270        }
271        curves.push(roots);
272    }
273    let mut out = curves[0]
274        .iter()
275        .cloned()
276        .map(|c| vec![c])
277        .collect::<Vec<_>>();
278    for tier in curves.iter().skip(1) {
279        let mut next = Vec::with_capacity(out.len() * tier.len());
280        for prefix in &out {
281            for r in tier {
282                let mut v = prefix.clone();
283                v.push(*r);
284                next.push(v);
285            }
286        }
287        out = next;
288    }
289    out
290}
291
292fn complex_linsolve(mut a: Vec<Vec<C64>>, mut b: Vec<C64>) -> Option<Vec<C64>> {
293    let n = b.len();
294    for col in 0..n {
295        let mut piv = None;
296        let mut best = -1.0_f64;
297        for row in col..n {
298            let nm = C64::norm2(a[row][col]);
299            if nm > best {
300                best = nm;
301                piv = Some(row);
302            }
303        }
304        let prow = piv?;
305        if best < 1e-18 {
306            return None;
307        }
308        if prow != col {
309            a.swap(prow, col);
310            b.swap(prow, col);
311        }
312        let div = a[col][col];
313        for j in col..n {
314            a[col][j] = C64::div(a[col][j], div)?;
315        }
316        b[col] = C64::div(b[col], div)?;
317        for row in (0..n).filter(|&r| r != col) {
318            let fac = a[row][col];
319            if fac.re.abs() < 1e-30 && fac.im.abs() < 1e-30 {
320                continue;
321            }
322            for j in col..n {
323                a[row][j] = C64::sub(a[row][j], C64::mul(fac, a[col][j]));
324            }
325            b[row] = C64::sub(b[row], C64::mul(fac, b[col]));
326        }
327    }
328    Some(b)
329}
330
331fn hv(gamma: C64, target: &[GbPoly], start_degrees: &[u32], z: &[C64], t: f64) -> Vec<C64> {
332    let one = C64::new(1.0, 0.0);
333    let mt = C64::new(1.0 - t, 0.0);
334    let tt = C64::new(t, 0.0);
335    let mut h = Vec::with_capacity(z.len());
336    for i in 0..z.len() {
337        let f = gbpoly_eval_c(&target[i], z);
338        let d = start_degrees[i];
339        let mon = C64::sub(C64::pow_int(z[i], d), one);
340        let g = C64::mul(gamma, mon);
341        h.push(C64::add(C64::mul(mt, g), C64::mul(tt, f)));
342    }
343    h
344}
345
346fn dh_dt(gamma: C64, target: &[GbPoly], start_degrees: &[u32], z: &[C64]) -> Vec<C64> {
347    let one = C64::new(1.0, 0.0);
348    let mut out = Vec::with_capacity(z.len());
349    for i in 0..z.len() {
350        let f = gbpoly_eval_c(&target[i], z);
351        let d = start_degrees[i];
352        let mon = C64::sub(C64::pow_int(z[i], d), one);
353        out.push(C64::sub(f, C64::mul(gamma, mon)));
354    }
355    out
356}
357
358fn jh(gamma: C64, target: &[GbPoly], start_degrees: &[u32], z: &[C64], t: f64) -> Vec<Vec<C64>> {
359    let n = z.len();
360    let j_f = jacobian_c(target, z);
361    let mt = C64::new(1.0 - t, 0.0);
362    let tt = C64::new(t, 0.0);
363    let mut jac = vec![vec![C64::ZERO; n]; n];
364    for i in 0..n {
365        for k in 0..n {
366            let mut elt = C64::mul(tt, j_f[i][k]);
367            if i == k {
368                let di = start_degrees[i];
369                let deriv_g = if di >= 1 {
370                    C64::scale(di as f64, C64::pow_int(z[i], di - 1))
371                } else {
372                    C64::ZERO
373                };
374                elt = C64::add(elt, C64::mul(C64::mul(mt, gamma), deriv_g));
375            }
376            jac[i][k] = elt;
377        }
378    }
379    jac
380}
381
382fn fv_linf(vals: &[C64]) -> f64 {
383    vals.iter().map(|v| v.norm2()).fold(0.0_f64, f64::max)
384}
385
386fn jacobian_real(sys: &[GbPoly], x: &[f64]) -> Vec<Vec<f64>> {
387    let z: Vec<C64> = x.iter().map(|&r| C64::from_f64(r)).collect();
388    let jc = jacobian_c(sys, &z);
389    let n = x.len();
390    let mut jr = vec![vec![0.0_f64; n]; n];
391    for i in 0..n {
392        for j in 0..n {
393            jr[i][j] = jc[i][j].re;
394        }
395    }
396    jr
397}
398
399fn fv_real(sys: &[GbPoly], x: &[f64]) -> Vec<f64> {
400    let z: Vec<C64> = x.iter().map(|&r| C64::from_f64(r)).collect();
401    (0..sys.len())
402        .map(|i| gbpoly_eval_c(&sys[i], &z).re)
403        .collect()
404}
405
406fn real_gaussian_solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
407    let n = b.len();
408    for i in 0..n {
409        let mut piv = i;
410        let mut best = a[i][i].abs();
411        for r in i + 1..n {
412            if a[r][i].abs() > best {
413                best = a[r][i].abs();
414                piv = r;
415            }
416        }
417        if best < 1e-18 {
418            return None;
419        }
420        if piv != i {
421            a.swap(piv, i);
422            b.swap(piv, i);
423        }
424        let div = a[i][i];
425        for j in i..n {
426            a[i][j] /= div;
427        }
428        b[i] /= div;
429        for r in 0..n {
430            if r == i {
431                continue;
432            }
433            let fac = a[r][i];
434            if fac.abs() < 1e-28 {
435                continue;
436            }
437            for j in i..n {
438                a[r][j] -= fac * a[i][j];
439            }
440            b[r] -= fac * b[i];
441        }
442    }
443    Some(b)
444}
445
446fn damped_correct(
447    gamma: C64,
448    target: &[GbPoly],
449    degs: &[u32],
450    z0: &[C64],
451    t_tgt: f64,
452    opts: &HomotopyOpts,
453) -> Option<Vec<C64>> {
454    let mut z = z0.to_vec();
455    for _ in 0..opts.newton_cap {
456        let fv = hv(gamma, target, degs, &z, t_tgt);
457        let res = fv_linf(&fv);
458        if res < opts.homotopy_tol {
459            return Some(z);
460        }
461        let jac = jh(gamma, target, degs, &z, t_tgt);
462        let neg_f: Vec<C64> = fv.iter().map(|c| C64::neg(*c)).collect();
463        let step = complex_linsolve(jac, neg_f)?;
464        let mut lm = 1.0_f64;
465        loop {
466            let trial: Vec<C64> = z
467                .iter()
468                .zip(step.iter())
469                .map(|(zi, s)| C64::add(*zi, C64::scale(lm, *s)))
470                .collect();
471            let new_res = fv_linf(&hv(gamma, target, degs, &trial, t_tgt));
472            if new_res < res || new_res < opts.homotopy_tol {
473                z = trial;
474                break;
475            }
476            lm *= 0.5;
477            if lm < 1e-12 {
478                return None;
479            }
480        }
481    }
482    fv_linf(&hv(gamma, target, degs, &z, t_tgt))
483        .le(&(opts.homotopy_tol * 8.0))
484        .then_some(z)
485}
486
487fn newton_terminal(target: &[GbPoly], mut x: Vec<f64>, opts: &HomotopyOpts) -> Option<Vec<f64>> {
488    for _ in 0..opts.newton_cap {
489        let f = fv_real(target, &x);
490        let res = f.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
491        if res < opts.newton_tol {
492            return Some(x);
493        }
494        let j = jacobian_real(target, &x);
495        let neg_f = f.iter().map(|v| -*v).collect();
496        let step = real_gaussian_solve(j.clone(), neg_f)?;
497        let mut lm = 1.0_f64;
498        loop {
499            let trial: Vec<f64> = x
500                .iter()
501                .zip(step.iter())
502                .map(|(&xi, &s)| xi + lm * s)
503                .collect();
504            let tres = fv_real(target, &trial)
505                .iter()
506                .map(|v| v.abs())
507                .fold(0.0_f64, f64::max);
508            if tres < res {
509                x = trial;
510                break;
511            }
512            lm *= 0.5;
513            if lm < 1e-14 {
514                return None;
515            }
516        }
517    }
518    Some(x)
519}
520
521fn smale_estimate(target: &[GbPoly], x: &[f64]) -> Option<(f64, f64)> {
522    let n = x.len();
523    let f = fv_real(target, x);
524    let jac = jacobian_real(target, x);
525    let step = real_gaussian_solve(jac.clone(), f.iter().map(|v| -*v).collect())?;
526    let beta = step.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
527    let mut j_inv_inf = 0.0_f64;
528    for i in 0..n {
529        let mut ej = vec![0.0_f64; n];
530        ej[i] = 1.0;
531        let col = real_gaussian_solve(jac.clone(), ej)?;
532        let s = col.iter().map(|v| v.abs()).sum::<f64>();
533        j_inv_inf = j_inv_inf.max(s);
534    }
535    let z: Vec<C64> = x.iter().map(|&r| C64::from_f64(r)).collect();
536    let mut hmax = 0.0_f64;
537    for poly in target {
538        for j in 0..n {
539            for k in 0..n {
540                let h = hessian_ij_c(poly, j, k, &z);
541                hmax = hmax.max(h.re.abs().max(h.im.abs()));
542            }
543        }
544    }
545    let gamma_tilde = j_inv_inf * hmax * (n as f64).sqrt().max(1.0);
546    Some((beta, beta * gamma_tilde))
547}
548
549fn random_gamma(seed: Option<u64>) -> C64 {
550    let mut x = seed
551        .unwrap_or(31415926535897_u64)
552        .wrapping_add(1469580727_u64);
553    x ^= x >> 12;
554    x ^= x << 25;
555    x ^= x >> 27;
556    let frac = ((x >> 11) & ((1_u64 << 53) - 1)) as f64 / ((1_u64 << 53) as f64);
557    let ang = 2.0 * PI * frac;
558    C64::new(ang.cos(), ang.sin())
559}
560
561fn track_path(
562    gamma: C64,
563    target: &[GbPoly],
564    degs: &[u32],
565    z_start: Vec<C64>,
566    opts: &HomotopyOpts,
567) -> Result<Vec<C64>, HomotopyError> {
568    let mut z = z_start;
569    let mut t = 0.0_f64;
570    let mut dt = opts.dt_initial;
571    let mut steps_total = 0usize;
572    while t < 1.0 - 1e-15 {
573        if steps_total > opts.max_tracker_steps {
574            return Err(HomotopyError::TrackerFailed("max_tracker_steps"));
575        }
576        let t_next = (t + dt).min(1.0);
577        let jac = jh(gamma, target, degs, &z, t);
578        let htd = dh_dt(gamma, target, degs, &z);
579        let dt_c = C64::new(t_next - t, 0.0);
580        let rhs: Vec<C64> = htd
581            .into_iter()
582            .map(|h| C64::neg(C64::mul(dt_c, h)))
583            .collect();
584        let step = match complex_linsolve(jac, rhs) {
585            Some(s) => s,
586            None => {
587                dt *= 0.5;
588                if dt < opts.dt_min {
589                    return Err(HomotopyError::SingularJacobian);
590                }
591                continue;
592            }
593        };
594        steps_total += 1;
595        let zp: Vec<C64> = z
596            .iter()
597            .zip(step.iter())
598            .map(|(zi, dsi)| C64::add(*zi, *dsi))
599            .collect();
600        match damped_correct(gamma, target, degs, &zp, t_next, opts) {
601            Some(zn) => {
602                z = zn;
603                t = t_next;
604                dt = (dt * 1.15_f64).min(opts.dt_initial);
605            }
606            None => {
607                dt *= 0.5_f64;
608                if dt < opts.dt_min {
609                    return Err(HomotopyError::TrackerFailed("corrector"));
610                }
611            }
612        }
613    }
614    Ok(z)
615}
616
617fn dedup(points: &[Vec<f64>], tol: f64) -> Vec<Vec<f64>> {
618    let mut uniq: Vec<Vec<f64>> = Vec::new();
619    'outer: for p in points {
620        for u in &uniq {
621            let d2: f64 = u
622                .iter()
623                .zip(p.iter())
624                .map(|(&a, &b)| {
625                    let s = (a - b).abs();
626                    s * s
627                })
628                .sum();
629            if d2.sqrt() < tol {
630                continue 'outer;
631            }
632        }
633        uniq.push(p.clone());
634    }
635    uniq
636}
637
638/// Total-degree continuation + polishing + Smale / ArbBall packaging.
639///
640/// Returns **real projections** whose imaginary tails were negligible; complex
641/// roots with large imaginary part are discarded.
642pub fn solve_numerical(
643    equations: &[ExprId],
644    vars: &[ExprId],
645    pool: &ExprPool,
646    opts: &HomotopyOpts,
647) -> Result<Vec<CertifiedPoint>, HomotopyError> {
648    if equations.len() != vars.len() {
649        return Err(HomotopyError::Algebraic(SolverError::ShapeMismatch));
650    }
651    let mut sys: Vec<GbPoly> = Vec::with_capacity(vars.len());
652    for &eq in equations {
653        sys.push(expr_to_gbpoly(eq, vars, pool).map_err(HomotopyError::Algebraic)?);
654    }
655    let mut degs: Vec<u32> = sys.iter().map(total_degree).collect();
656    for d in &mut degs {
657        if *d == 0 {
658            *d = 1;
659        }
660    }
661    let mut bez = 1usize;
662    for &d in &degs {
663        bez = bez
664            .checked_mul(d as usize)
665            .ok_or(HomotopyError::BezoutTooLarge(usize::MAX))?;
666    }
667    if bez > opts.max_bezout_paths {
668        return Err(HomotopyError::BezoutTooLarge(bez));
669    }
670    let starts = start_system_roots(&degs);
671    let gamma = random_gamma(opts.gamma_angle_seed);
672    let prec = opts.certify_prec_bits;
673    const SMALE_THRESH: f64 = 0.125;
674    let mut raw: Vec<Vec<f64>> = Vec::new();
675    for z0 in starts {
676        let z_end = match track_path(gamma, &sys, &degs, z0, opts) {
677            Ok(z) => z,
678            Err(_) => continue,
679        };
680        if z_end.iter().all(|c| c.im.abs() < 1e-6) {
681            let xr: Vec<f64> = z_end.iter().map(|c| c.re).collect();
682            if let Some(xp) = newton_terminal(&sys, xr, opts) {
683                raw.push(xp);
684            }
685        }
686    }
687    let uniq = dedup(&raw, opts.dedup_tol);
688    let mut out = Vec::new();
689    for x in uniq {
690        let resv = fv_real(&sys, &x);
691        let max_r = resv.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
692        let (smale_alpha, smale_certified, rad) = match smale_estimate(&sys, &x) {
693            Some((beta, alpha)) => {
694                let cert = alpha < SMALE_THRESH;
695                let r = if cert {
696                    beta.clamp(1e-12, 0.05)
697                } else {
698                    1e-6_f64
699                };
700                (Some(alpha), cert, r)
701            }
702            None => (None, false, 1e-6_f64),
703        };
704        let enclosure = x
705            .iter()
706            .map(|&v| ArbBall::from_midpoint_radius(v, rad, prec))
707            .collect();
708        out.push(CertifiedPoint {
709            coordinates: x,
710            max_residual_f64: max_r,
711            smale_alpha,
712            smale_certified,
713            enclosure,
714        });
715    }
716    out.sort_by(|p, q| {
717        let a = &p.coordinates;
718        let b = &q.coordinates;
719        a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
720    });
721    Ok(out)
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use crate::kernel::{Domain, ExprPool};
728
729    #[test]
730    fn product_quadratics_four_real_roots() {
731        let pool = ExprPool::new();
732        let x = pool.symbol("x", Domain::Real);
733        let y = pool.symbol("y", Domain::Real);
734        let eq1 = pool.add(vec![pool.pow(x, pool.integer(2)), pool.integer(-1)]);
735        let eq2 = pool.add(vec![pool.pow(y, pool.integer(2)), pool.integer(-1)]);
736        let opts = HomotopyOpts {
737            dedup_tol: 1e-4,
738            ..Default::default()
739        };
740        let sols = solve_numerical(&[eq1, eq2], &[x, y], &pool, &opts).expect("solve");
741        assert_eq!(sols.len(), 4, "±1 ⊗ ±1");
742        assert!(sols.iter().all(|s| s.max_residual_f64 < 1e-8));
743    }
744
745    #[test]
746    fn circle_line_two_real_roots() {
747        let pool = ExprPool::new();
748        let x = pool.symbol("x", Domain::Real);
749        let y = pool.symbol("y", Domain::Real);
750        let eq1 = pool.add(vec![
751            pool.pow(x, pool.integer(2)),
752            pool.pow(y, pool.integer(2)),
753            pool.integer(-1),
754        ]);
755        let neg_one = pool.integer(-1);
756        let eq2 = pool.add(vec![y, pool.mul(vec![neg_one, x])]);
757        let opts = HomotopyOpts {
758            dedup_tol: 1e-4,
759            ..Default::default()
760        };
761        let sols = solve_numerical(&[eq1, eq2], &[x, y], &pool, &opts).expect("solve");
762        let r = 0.5_f64.sqrt();
763        let mut matched = 0_usize;
764        for s in &sols {
765            let (xv, yv) = (s.coordinates[0], s.coordinates[1]);
766            let ok = ((xv - r).abs() < 5e-3 && (yv - r).abs() < 5e-3)
767                || ((xv + r).abs() < 5e-3 && (yv + r).abs() < 5e-3);
768            if ok {
769                matched += 1;
770            }
771        }
772        assert_eq!(matched, 2, "{sols:?}");
773    }
774}