1#![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#[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 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
638pub 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 °s {
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(°s);
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, °s, 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}