Skip to main content

poly_cool/
cubic.rs

1use arrayvec::ArrayVec;
2
3use crate::{Cubic, Quadratic, different_signs};
4
5#[cfg(feature = "libm")]
6#[allow(unused_imports, reason = "unused if libm and std are both around")]
7use crate::libm_polyfill::FloatFuncs as _;
8
9// We assume that there is at least one element, at most 3 elements, and buf[1]
10// and buf[2] are already in the right order (if present).
11//
12// This might be faster than the stdlib sort for our special case,
13// but the real reason it's here is for no_std support.
14fn partial_sort<const M: usize>(buf: &mut ArrayVec<f64, M>) {
15    if buf.len() > 1 && buf[0] > buf[1] {
16        buf.swap(0, 1);
17        if buf.len() > 2 && buf[1] > buf[2] {
18            buf.swap(1, 2);
19        }
20    }
21}
22
23impl Cubic {
24    /// This is like [`Cubic::eval`] but faster.
25    ///
26    /// It would be nice if we could just make `eval` like this, but I couldn't
27    /// figure out how, given the lack of specialization.
28    #[doc(hidden)]
29    pub fn eval_opt(&self, x: f64) -> f64 {
30        let [c0, c1, c2, c3] = self.coeffs;
31        let xx = x * x;
32        let xxx = xx * x;
33        c0 + c1 * x + c2 * xx + c3 * xxx
34    }
35
36    /// Evaluate this cubic and its gradient at the same time, reusing some
37    /// of the intermediate computations.
38    ///
39    /// In micro-benchmarks, this is faster than evaluating separately. But
40    /// it doesn't help with the performance of Yuksel's algorithm, presumably
41    /// because the compiler is inlining and optimizing reuse of the
42    /// intermediate computations already.
43    #[doc(hidden)]
44    pub fn eval_with_deriv_opt(&self, deriv: &Quadratic, x: f64) -> (f64, f64) {
45        let [c0, c1, c2, c3] = self.coeffs;
46        let [d0, d1, d2] = deriv.coeffs;
47        let xx = x * x;
48        let xxx = xx * x;
49        (c0 + c1 * x + c2 * xx + c3 * xxx, d0 + d1 * x + d2 * xx)
50    }
51
52    /// Computes the critical points of this cubic, as long
53    /// as the discriminant of the derivative is positive.
54    /// The return values are in increasing order.
55    ///
56    /// Some corner cases worth noting:
57    ///   - If the discriminant is zero, returns nothing. That is,
58    ///     we don't find double-roots of the derivative.
59    ///   - If the derivative is linear or close to it, we might
60    ///     return +/- infinity as one of the roots.
61    ///   - Unless some input is NaN, we don't return NaN.
62    fn critical_points(&self) -> Option<(f64, f64)> {
63        let a = 3.0 * self.coeffs[3];
64        let b_2 = self.coeffs[2];
65        let c = self.coeffs[1];
66        let disc_4 = b_2 * b_2 - a * c;
67
68        if !disc_4.is_finite() {
69            return self.rescaled_critical_points();
70        }
71
72        if disc_4 > 0.0 {
73            let q = -(b_2 + disc_4.sqrt().copysign(b_2));
74            let r0 = q / a;
75            let r1 = c / q;
76            Some((r0.min(r1), r0.max(r1)))
77        } else {
78            None
79        }
80    }
81
82    #[cold]
83    fn rescaled_critical_points(&self) -> Option<(f64, f64)> {
84        let scale = 2.0f64.powi(-515);
85        (*self * scale).critical_points()
86    }
87
88    fn one_root(
89        &self,
90        lower: f64,
91        upper: f64,
92        lower_val: f64,
93        upper_val: f64,
94        x_error: f64,
95    ) -> f64 {
96        let deriv = self.deriv();
97        if !deriv.is_finite() {
98            return f64::NAN;
99        }
100        crate::yuksel::find_root(
101            |x| self.eval_opt(x),
102            |x| deriv.eval_opt(x),
103            lower,
104            upper,
105            lower_val,
106            upper_val,
107            x_error,
108        )
109    }
110
111    // This has to be pub because we're benchmarking it right now.
112    #[doc(hidden)]
113    pub fn root_between(self, lower: f64, upper: f64, x_error: f64) -> f64 {
114        self.one_root(lower, upper, self.eval(lower), self.eval(upper), x_error)
115    }
116
117    fn first_root(self, lower: f64, upper: f64, x_error: f64) -> Option<f64> {
118        if let Some((x0, x1)) = self.critical_points() {
119            let possible_endpoints: [f64; 3] = [x0, x1, upper];
120            let mut last = lower;
121            let mut last_val = self.eval(last);
122            for x in possible_endpoints {
123                if x > last && x <= upper {
124                    let val = self.eval(x);
125                    if different_signs(last_val, val) {
126                        return Some(self.one_root(last, x, last_val, val, x_error));
127                    }
128
129                    last = x;
130                    last_val = val;
131                }
132            }
133            None
134        } else {
135            let lower_val = self.eval(lower);
136            let upper_val = self.eval(upper);
137            if different_signs(lower_val, upper_val) {
138                Some(self.one_root(lower, upper, lower_val, upper_val, x_error))
139            } else {
140                None
141            }
142        }
143    }
144
145    /// Computes all roots between `lower` and `upper`, to the desired accuracy.
146    ///
147    /// We make no guarantees about multiplicity. In fact, if there's a
148    /// double-root that isn't a triple-root (and therefore has no sign change
149    /// nearby) then there's a good chance we miss it altogether. This is
150    /// fine if you're using this root-finding to optimize a quartic, because
151    /// double-roots of the derivative aren't local extrema.
152    pub fn roots_between(self, lower: f64, upper: f64, x_error: f64) -> ArrayVec<f64, 3> {
153        let mut ret = ArrayVec::new();
154        let mut scratch = ArrayVec::new();
155        self.roots_between_with_buffer(lower, upper, x_error, &mut scratch, &mut ret);
156        ret
157    }
158
159    pub(crate) fn roots_between_with_buffer<const M: usize>(
160        self,
161        lower: f64,
162        upper: f64,
163        x_error: f64,
164        _scratch: &mut ArrayVec<f64, M>,
165        out: &mut ArrayVec<f64, M>,
166    ) {
167        if let Some(r) = self.first_root(lower, upper, x_error) {
168            out.push(r);
169            let quad = self.deflate(r);
170            if let Some((x0, x1)) = quad.positive_discriminant_roots() {
171                if lower <= x0 && x0 <= upper {
172                    out.push(x0);
173                }
174                if lower <= x1 && x1 <= upper {
175                    out.push(x1);
176                }
177
178                // `self.first_root` is supposed to return the smallest root in
179                // our interval, but it's possible it doesn't because it misses
180                // a double-root (or near-double-root).
181                if lower <= x0 && x0 < r {
182                    partial_sort(out);
183                }
184            }
185        }
186    }
187
188    #[doc(hidden)]
189    pub fn precondition(&self) -> Cubic {
190        // Truncate coefficients too close to zero, to ensure that there's
191        // no underflow when calculating the discriminant.
192        let min_coeff = 2.0f64.powi(-256);
193        let truncate = |x: &mut f64| {
194            if x.abs() <= min_coeff {
195                *x = 0.0
196            }
197        };
198
199        // We can't just truncate, because if some other coefficient is just above
200        // min_coeff then it will introduce a big relative error. So we renormalize
201        // if things are too close.
202        let small_coeff = 2.0f64.powi(-64);
203
204        let large_coeff = 2.0f64.powi(64);
205
206        let mut c = *self;
207        if (self.magnitude() != 0.0 && self.magnitude() <= small_coeff)
208            || self.magnitude() >= large_coeff
209        {
210            c /= self.magnitude();
211        }
212
213        truncate(&mut c.coeffs[0]);
214        truncate(&mut c.coeffs[1]);
215        truncate(&mut c.coeffs[2]);
216        truncate(&mut c.coeffs[3]);
217        c
218    }
219
220    // Blinn's algorithm for roots.
221    //
222    // This is just an experiment, and we only make it public to allow ourselves
223    // to benchmark it.
224    #[doc(hidden)]
225    pub fn roots_blinn(&self) -> ArrayVec<f64, 3> {
226        let mut ret = ArrayVec::new();
227        let a = self.coeffs[3];
228        let b = self.coeffs[2] * (1.0 / 3.0);
229        let c = self.coeffs[1] * (1.0 / 3.0);
230        let d = self.coeffs[0];
231
232        let delta_1 = a * c - b * b;
233        let delta_2 = a * d - b * c;
234        let delta_3 = b * d - c * c;
235        let disc = 4.0 * delta_1 * delta_3 - delta_2 * delta_2;
236
237        if !disc.is_finite() {
238            return ret;
239        }
240        // What about disc = 0?
241        // For now, we put it in the one-root case, although in principle it could also
242        // be a single root and a double root. The issue with the other branch is
243        // that we might end up with `atan(0, 0)`, which gives NaN. Blinn says the NaN
244        // doesn't matter because you end up multiplying it by \bar C = 0, but (1)
245        // floats don't work that way without a little effort, and (2) it's possible to
246        // have disc = \bar D = 0.0 (numerically) and \bar C \ne 0.
247        let mut push = |x: f64| {
248            if x.is_finite() {
249                ret.push(x);
250            }
251        };
252        if disc <= 0.0 {
253            //dbg!(disc);
254            let (tilde_a, tilde_c, tilde_d) = if b * b * b * d >= a * c * c * c {
255                (a, delta_1, -2.0 * b * delta_1 + a * delta_2)
256            } else {
257                (d, delta_3, -d * delta_2 + 2.0 * c * delta_3)
258            };
259            //dbg!(tilde_a, tilde_c, tilde_d);
260            let t_0 = -tilde_a.copysign(tilde_d) * (-disc).sqrt();
261            let t_1 = -tilde_d + t_0;
262            let p = (t_1 / 2.0).cbrt();
263
264            let q = if t_0 == t_1 { -p } else { -tilde_c / p };
265            //dbg!(p, q);
266            let tilde_x = if tilde_c <= 0.0 {
267                p + q
268            } else {
269                -tilde_d / (p * p + q * q + tilde_c)
270            };
271
272            let (x, w) = if b * b * b * d >= a * c * c * c {
273                (tilde_x - b, a)
274            } else {
275                (-d, tilde_x + c)
276            };
277
278            push(x / w);
279        } else {
280            //dbg!(disc);
281            fn one_root(a_or_d: f64, disc: f64, bar_c: f64, bar_d: f64) -> (f64, f64) {
282                let sqrt_c = (-bar_c).sqrt();
283                let theta = (1.0 / 3.0) * (a_or_d * disc.sqrt()).atan2(-bar_d).abs();
284                let (sin_theta, cos_theta) = theta.sin_cos();
285                //dbg!(theta, cos_theta);
286                let tilde_x_1 = 2.0 * sqrt_c * cos_theta;
287                let tilde_x_3 = sqrt_c * (-cos_theta - 3.0f64.sqrt() * sin_theta);
288                (tilde_x_1, tilde_x_3)
289            }
290
291            let bar_c_a = delta_1;
292            let bar_d_a = -2.0 * b * delta_1 + a * delta_2;
293            let (tilde_x_1_a, tilde_x_3_a) = one_root(a, disc, bar_c_a, bar_d_a);
294
295            let bar_c_d = delta_3;
296            let bar_d_d = -d * delta_2 + 2.0 * c * delta_3;
297            let (tilde_x_1_d, tilde_x_3_d) = one_root(d, disc, bar_c_d, bar_d_d);
298
299            let tilde_x_l = if tilde_x_1_a + tilde_x_3_a > 2.0 * b {
300                tilde_x_1_a
301            } else {
302                tilde_x_3_a
303            };
304            let tilde_x_s = if tilde_x_1_d + tilde_x_3_d < 2.0 * c {
305                tilde_x_1_d
306            } else {
307                tilde_x_3_d
308            };
309
310            let (x_l, w_l) = (tilde_x_l - b, a);
311            let (x_s, w_s) = (-d, tilde_x_s + c);
312
313            let e = w_l * w_s;
314            let f = -x_l * w_s - w_l * x_s;
315            let g = x_l * x_s;
316
317            let (x_m, w_m) = (c * f - b * g, c * e - b * f);
318
319            push(x_l / w_l);
320            push(x_s / w_s);
321            push(x_m / w_m);
322        }
323        ret
324    }
325
326    // A variant on Blinn's algorithm for roots.
327    //
328    // This is just an experiment, and we only make it public to allow ourselves
329    // to benchmark it.
330    #[doc(hidden)]
331    pub fn roots_blinn_and_deflate(&self) -> ArrayVec<f64, 3> {
332        let mut ret = ArrayVec::new();
333        let a = self.coeffs[3];
334        let b = self.coeffs[2] * (1.0 / 3.0);
335        let c = self.coeffs[1] * (1.0 / 3.0);
336        let d = self.coeffs[0];
337
338        let delta_1 = a * c - b * b;
339        let delta_2 = a * d - b * c;
340        let delta_3 = b * d - c * c;
341        let disc = 4.0 * delta_1 * delta_3 - delta_2 * delta_2;
342
343        if !disc.is_finite() {
344            return ret;
345        }
346        //dbg!(delta_1, delta_2, delta_3, disc);
347
348        // TODO: what about disc = 0?
349        if disc <= 0.0 {
350            let (tilde_a, tilde_c, tilde_d) = if b * b * b * d >= a * c * c * c {
351                (a, delta_1, -2.0 * b * delta_1 + a * delta_2)
352            } else {
353                (d, delta_3, -d * delta_2 + 2.0 * c * delta_3)
354            };
355            let t_0 = -tilde_a.copysign(tilde_d) * (-disc).sqrt();
356            let t_1 = -tilde_d + t_0;
357            let p = (t_1 / 2.0).cbrt();
358
359            let q = if t_0 == t_1 { -p } else { -tilde_c / p };
360            let tilde_x = if tilde_c <= 0.0 {
361                p + q
362            } else {
363                -tilde_d / (p * p + q * q + tilde_c)
364            };
365
366            let (x, w) = if b * b * b * d >= a * c * c * c {
367                (tilde_x - b, a)
368            } else {
369                (-d, tilde_x + c)
370            };
371
372            if x.is_finite() && w.is_finite() {
373                ret.push(x / w);
374            }
375        } else {
376            fn one_root(a_or_d: f64, disc: f64, bar_c: f64, bar_d: f64) -> (f64, f64) {
377                let sqrt_c = (-bar_c).sqrt();
378                let theta = (1.0 / 3.0) * (a_or_d * disc.sqrt()).atan2(-bar_d).abs();
379                let (sin_theta, cos_theta) = theta.sin_cos();
380                let tilde_x_1 = 2.0 * sqrt_c * cos_theta;
381                let tilde_x_3 = sqrt_c * (-theta.cos() - 3.0f64.sqrt() * sin_theta);
382                (tilde_x_1, tilde_x_3)
383            }
384
385            // FIXME: I'm confused about which choice is supposed to give me the
386            // small-magnitude root...
387            let bar_c_a = delta_1;
388            let bar_d_a = -2.0 * b * delta_1 + a * delta_2;
389            let (tilde_x_1_a, tilde_x_3_a) = one_root(a, disc, bar_c_a, bar_d_a);
390
391            let bar_c_d = delta_3;
392            let bar_d_d = -d * delta_2 + 2.0 * c * delta_3;
393            let (tilde_x_1_d, tilde_x_3_d) = one_root(d, disc, bar_c_d, bar_d_d);
394
395            let tilde_x_l = if tilde_x_1_a + tilde_x_3_a > 2.0 * b {
396                tilde_x_1_a
397            } else {
398                tilde_x_3_a
399            };
400            //dbg!(tilde_x_1_a - b, tilde_x_3_a - b, tilde_x_l - b);
401            let tilde_x_s = if tilde_x_1_d + tilde_x_3_d < 2.0 * c {
402                tilde_x_1_d
403            } else {
404                tilde_x_3_d
405            };
406
407            let (x_l, w_l) = (tilde_x_l - b, a);
408            let (x_s, w_s) = (-d, tilde_x_s + c);
409
410            let x = if (x_l * w_s).abs() <= (x_s * w_l).abs() {
411                x_l / w_l
412            } else {
413                x_s / w_s
414            };
415            if x.is_finite() {
416                ret.push(x);
417            }
418            let q = self.deflate(x);
419            //dbg!(&q);
420            if q.is_finite() {
421                ret.extend(q.roots());
422                partial_sort(&mut ret);
423            }
424        }
425        ret
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use crate::Cubic;
432
433    const TRICKY_CUBICS: [Cubic; 5] = [
434        // This one has infinite discriminant.
435        Cubic::new([
436            1.6149620090145706e-94,
437            1.6149620090145634e-94,
438            1.6149620090145663e-94,
439            9.66803867245343e272,
440        ]),
441        // This one has a very large second root (-7e202), which causes roots_blinn to NaN on the last one.
442        //
443        // When deflating, it gives a quadratic that's basically zero and so it underflows the discriminant
444        // and ends up reporting just a single root.
445        Cubic::new([
446            -6.323283382275869e98,
447            3.0957754283429482e-307,
448            3.095775428342964e-307,
449            3.095775428342951e-307,
450        ]),
451        // Here's one with sane coefficients, but a similar issue as
452        // the last one.
453        Cubic::new([
454            -8.522348907129e-161,
455            4.471145208374078e-67,
456            -0.052026185927646074,
457            -2.9441090045938734e-57,
458        ]),
459        // Here's one where the discriminant is numerically zero, causing some stability issues
460        // for Blinn.
461        Cubic::new([
462            -2.5162489269306657e-175,
463            -2.516248926930655e-175,
464            -2.5162489269306522e-175,
465            -0.39205037382350466,
466        ]),
467        Cubic::new([
468            -6.428720163649757e103,
469            -6.428720163649766e103,
470            -3.3646756114322413e-74,
471            -3.3646756114322547e-74,
472        ]),
473    ];
474
475    #[test]
476    fn smoke() {
477        // Here's an example where Blinn's method has a large error. The
478        // small-magnitude root is about -1.0 and the large magnitude root is
479        // apparently of order 1e225, which then causes big errors when trying
480        // to compute the third root. I guess in general we have expect that
481        // the magnitude of the big root affects the error in the middle root.
482        //
483        // Correction: the middle root is actually ok here. It has a pretty
484        // large magnitude (1e17ish), and so it's allowed to not evaluate
485        // super close to zero.
486        // let poly = super::Cubic {
487        //     c0: -3.565233507454652e74,
488        //     c1: -3.5652335074546437e74,
489        //     c2: -1.2298855640101194e-17,
490        //     c3: 9.133009604987547e-243,
491        // };
492
493        // let poly = Cubic {
494        //     c0: 5.5174454041519107,
495        //     c1: -1.6144740273415798e-245,
496        //     c2: -3.892738574215212e-288,
497        //     c3: 3.0860491510941517e-292,
498        // };
499        let poly = Cubic::new([
500            -6.428720163649757e103,
501            -6.428720163649766e103,
502            -3.3646756114322413e-74,
503            -3.3646756114322547e-74,
504        ]);
505
506        let roots = poly.precondition().roots_blinn();
507        //let roots = poly.roots_between_multiple_searches(-10.0, 10.0, 1e-12);
508        dbg!(&roots);
509        for r in roots {
510            dbg!(poly.eval(r));
511        }
512    }
513
514    #[test]
515    fn bad_for_blinn() {
516        for c in TRICKY_CUBICS {
517            dbg!(c.roots_blinn());
518            dbg!(c.roots_blinn_and_deflate());
519        }
520    }
521
522    // Asserts that the supplied "roots" are close to being roots of the
523    // cubic, in the sense that the cubic evaluates to approximately zero
524    // on each of the roots.
525    fn check_root_values(c: &Cubic, roots: &[f64]) {
526        // Arbitrary cubics can have coefficients with wild magnitudes,
527        // so we need to adjust our error expectations accordingly.
528        let magnitude = c.magnitude().max(1.0);
529        let accuracy = magnitude * 1e-12;
530
531        for r in roots {
532            // We can't expect great accuracy for very large roots,
533            // because the polynomial evaluation will involve very
534            // large terms.
535            let accuracy = accuracy * r.abs().powi(3).max(1.0);
536            let y = c.eval(*r);
537            if y.is_finite() {
538                assert!(
539                    y.abs() <= accuracy,
540                    "cubic {c:?} had root {r} evaluate to {y:?}, but expected {accuracy:?}"
541                );
542            }
543        }
544    }
545
546    #[test]
547    fn root_evaluation() {
548        arbtest::arbtest(|u| {
549            let c = crate::arbitrary::cubic(u)?;
550
551            // We could have a wider range of roots, but then we might need
552            // to lower the accuracy depending on what the actual root is: the
553            // intermediate computations scale like the cube of the root.
554            let roots = c.roots_between(-10.0, 10.0, 1e-13);
555            if roots.iter().all(|r| r.is_finite()) {
556                assert!(roots.is_sorted());
557            }
558            check_root_values(&c, &roots);
559
560            let preconditioned = c.precondition();
561            check_root_values(&c, &preconditioned.roots_blinn());
562            check_root_values(&c, &preconditioned.roots_blinn_and_deflate());
563
564            // Even preconditioning is not enough for kurbo's current solver.
565            // let Cubic { c0, c1, c2, c3 } = preconditioned;
566            // dbg!(preconditioned);
567            // check_root_values(&c, &kurbo::common::solve_cubic(c0, c1, c2, c3));
568
569            Ok(())
570        })
571        .budget_ms(5_000);
572    }
573
574    #[test]
575    #[ignore]
576    fn root_evaluation_kurbo() {
577        arbtest::arbtest(|u| {
578            let c = crate::arbitrary::cubic(u)?;
579            // Arbitrary cubics can have coefficients with wild magnitudes,
580            // so we need to adjust our error expectations accordingly.
581            let magnitude = c.magnitude().max(1.0);
582            let accuracy = magnitude * 1e-12;
583
584            // We could have a wider range of roots, but then we might need
585            // to lower the accuracy depending on what the actual root is: the
586            // intermediate computations scale like the cube of the root.
587            let &[c0, c1, c2, c3] = c.coeffs();
588            for r in kurbo::common::solve_cubic(c0, c1, c2, c3) {
589                let y = c.eval(r);
590                if y.is_finite() {
591                    assert!(y.abs() <= accuracy);
592                }
593            }
594            Ok(())
595        })
596        .budget_ms(5_000);
597    }
598}