poly_cool/
cubic.rs

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