Skip to main content

geop_core_math/
quadrature.rs

1//! [`integrate`]: adaptive Gauss–Kronrod quadrature of a vector-valued
2//! function over an interval, in [`Scalar`]s.
3//!
4//! What it returns is an enclosure in two parts, and the two have different
5//! standing:
6//!
7//! - **The rule's own value is enclosed.** Every node and weight of the
8//!   7-point Gauss / 15-point Kronrod pair is taken as the interval between
9//!   the two `f64`s around its tabulated value, so it contains the exact
10//!   node and weight, and the integrand is evaluated in interval arithmetic
11//!   at those intervals. The sum therefore encloses what the exact rule
12//!   gives for the integrand — rounding, and any width the integrand's own
13//!   data carries, included.
14//! - **The rule's truncation error is estimated, not proven.** It is the
15//!   difference between the Kronrod and the Gauss value of each panel: the
16//!   standard estimate, which for a smooth integrand vastly overestimates
17//!   the Kronrod value's error (that is what makes it a usable bound in
18//!   practice), but which a rigorous enclosure would replace by a bound on a
19//!   high derivative of the integrand — something a NURBS integrand over a
20//!   trimmed domain does not offer cheaply. The result is widened by it, so
21//!   that width states how well the integral is known; it is honest about
22//!   being an estimate in [`Integral::converged`] and in the docs of every
23//!   caller, rather than claiming to be proven.
24//!
25//! [`Quadrature`]'s tolerance and panel budget only decide how hard the
26//! search tries: a tighter tolerance gives a narrower result, and a budget
27//! run out gives a wider one, never a different answer.
28//!
29//! [`integrate_polynomial`] is for an integrand known to be a polynomial
30//! of bounded degree on every panel. There the Gauss–Legendre rule of
31//! enough points is exact, so there is no truncation error to estimate,
32//! no halving, and the enclosure is the rule's value alone — proven, and
33//! from a fifth of the evaluations a 15-point panel takes.
34
35use crate::{
36    geop_error::{GeopError, GeopResult},
37    scalars::Scalar,
38};
39
40/// The Kronrod nodes in `[0, 1]` of the 15-point rule, largest first; the
41/// odd ones (`[1]`, `[3]`, `[5]`, `[7] = 0`) are the 7-point Gauss rule's.
42/// QUADPACK's `qk15` tables.
43// The tables as published: `around` encloses the real numbers they name.
44#[allow(clippy::excessive_precision)]
45const XGK: [f64; 8] = [
46    0.991_455_371_120_812_639_206_854_697_526_329,
47    0.949_107_912_342_758_524_526_189_684_047_851,
48    0.864_864_423_359_769_072_789_712_788_640_926,
49    0.741_531_185_599_394_439_863_864_773_280_788,
50    0.586_087_235_467_691_130_294_144_845_693_013,
51    0.405_845_151_377_397_166_906_606_412_076_961,
52    0.207_784_955_007_898_467_600_689_403_773_245,
53    0.0,
54];
55
56/// The 15-point Kronrod weights, for the nodes of [`XGK`].
57// The tables as published: `around` encloses the real numbers they name.
58#[allow(clippy::excessive_precision)]
59const WGK: [f64; 8] = [
60    0.022_935_322_010_529_224_963_732_008_058_970,
61    0.063_092_092_629_978_553_290_700_663_189_204,
62    0.104_790_010_322_250_183_839_876_322_541_518,
63    0.140_653_259_715_525_918_745_189_590_510_238,
64    0.169_004_726_639_267_902_826_583_426_598_550,
65    0.190_350_578_064_785_409_913_256_402_421_014,
66    0.204_432_940_075_298_892_414_161_999_234_649,
67    0.209_482_141_084_727_828_012_999_174_891_714,
68];
69
70/// The 7-point Gauss weights, for the nodes `XGK[1]`, `XGK[3]`, `XGK[5]`
71/// and `XGK[7]`.
72// The tables as published: `around` encloses the real numbers they name.
73#[allow(clippy::excessive_precision)]
74const WG: [f64; 4] = [
75    0.129_484_966_168_869_693_270_611_432_679_082,
76    0.279_705_391_489_276_667_901_467_771_423_780,
77    0.381_830_050_505_118_944_950_369_775_488_975,
78    0.417_959_183_673_469_387_755_102_040_816_327,
79];
80
81/// The Gauss–Legendre rules of 1 to 10 points: each node in `[0, 1]`,
82/// largest first, with its weight on `[-1, 1]` — the nodes of the `n`-point
83/// rule are these and their negatives, `0` once if `n` is odd. The `n`-point
84/// rule integrates polynomials of degree `2n - 1` exactly.
85// Computed to 50 digits (mpmath), printed to 36: `around` encloses the real
86// numbers they name.
87#[allow(clippy::excessive_precision)]
88const GAUSS_LEGENDRE: [&[(f64, f64)]; 10] = [
89    &[(0.0, 2.0)],
90    &[(0.577_350_269_189_625_764_509_148_780_501_957_456, 1.0)],
91    &[
92        (
93            0.774_596_669_241_483_377_035_853_079_956_479_922,
94            0.555_555_555_555_555_555_555_555_555_555_555_556,
95        ),
96        (0.0, 0.888_888_888_888_888_888_888_888_888_888_888_889),
97    ],
98    &[
99        (
100            0.861_136_311_594_052_575_223_946_488_892_809_505,
101            0.347_854_845_137_453_857_373_063_949_221_999_407,
102        ),
103        (
104            0.339_981_043_584_856_264_802_665_759_103_244_687,
105            0.652_145_154_862_546_142_626_936_050_778_000_593,
106        ),
107    ],
108    &[
109        (
110            0.906_179_845_938_663_992_797_626_878_299_392_965,
111            0.236_926_885_056_189_087_514_264_040_719_917_363,
112        ),
113        (
114            0.538_469_310_105_683_091_036_314_420_700_208_805,
115            0.478_628_670_499_366_468_041_291_514_835_638_193,
116        ),
117        (0.0, 0.568_888_888_888_888_888_888_888_888_888_888_889),
118    ],
119    &[
120        (
121            0.932_469_514_203_152_027_812_301_554_493_994_609,
122            0.171_324_492_379_170_345_040_296_142_172_732_894,
123        ),
124        (
125            0.661_209_386_466_264_513_661_399_595_019_905_347,
126            0.360_761_573_048_138_607_569_833_513_837_716_112,
127        ),
128        (
129            0.238_619_186_083_196_908_630_501_721_680_711_935,
130            0.467_913_934_572_691_047_389_870_343_989_550_995,
131        ),
132    ],
133    &[
134        (
135            0.949_107_912_342_758_524_526_189_684_047_851_262,
136            0.129_484_966_168_869_693_270_611_432_679_082_018,
137        ),
138        (
139            0.741_531_185_599_394_439_863_864_773_280_788_407,
140            0.279_705_391_489_276_667_901_467_771_423_779_582,
141        ),
142        (
143            0.405_845_151_377_397_166_906_606_412_076_961_463,
144            0.381_830_050_505_118_944_950_369_775_488_975_134,
145        ),
146        (0.0, 0.417_959_183_673_469_387_755_102_040_816_326_531),
147    ],
148    &[
149        (
150            0.960_289_856_497_536_231_683_560_868_569_472_99,
151            0.101_228_536_290_376_259_152_531_354_309_962_19,
152        ),
153        (
154            0.796_666_477_413_626_739_591_553_936_475_830_437,
155            0.222_381_034_453_374_470_544_355_994_426_240_884,
156        ),
157        (
158            0.525_532_409_916_328_985_817_739_049_189_246_349,
159            0.313_706_645_877_887_287_337_962_201_986_601_313,
160        ),
161        (
162            0.183_434_642_495_649_804_939_476_142_360_183_981,
163            0.362_683_783_378_361_982_965_150_449_277_195_612,
164        ),
165    ],
166    &[
167        (
168            0.968_160_239_507_626_089_835_576_202_903_672_87,
169            0.081_274_388_361_574_411_971_892_158_110_523_650_7,
170        ),
171        (
172            0.836_031_107_326_635_794_299_429_788_069_734_877,
173            0.180_648_160_694_857_404_058_472_031_242_912_81,
174        ),
175        (
176            0.613_371_432_700_590_397_308_702_039_341_474_185,
177            0.260_610_696_402_935_462_318_742_869_418_632_85,
178        ),
179        (
180            0.324_253_423_403_808_929_038_538_014_643_336_609,
181            0.312_347_077_040_002_840_068_630_406_584_443_666,
182        ),
183        (0.0, 0.330_239_355_001_259_763_164_525_069_286_974_049),
184    ],
185    &[
186        (
187            0.973_906_528_517_171_720_077_964_012_084_452_053,
188            0.066_671_344_308_688_137_593_568_809_893_331_792_9,
189        ),
190        (
191            0.865_063_366_688_984_510_732_096_688_423_493_049,
192            0.149_451_349_150_580_593_145_776_339_657_697_332,
193        ),
194        (
195            0.679_409_568_299_024_406_234_327_365_114_873_576,
196            0.219_086_362_515_982_043_995_534_934_228_163_192,
197        ),
198        (
199            0.433_395_394_129_247_190_799_265_943_165_784_162,
200            0.269_266_719_309_996_355_091_226_921_569_469_353,
201        ),
202        (
203            0.148_874_338_981_631_210_884_826_001_129_719_985,
204            0.295_524_224_714_752_870_173_892_994_651_338_329,
205        ),
206    ],
207];
208
209/// The highest degree of a polynomial [`integrate_polynomial`] integrates.
210pub const MAX_POLYNOMIAL_DEGREE: usize = 2 * GAUSS_LEGENDRE.len() - 1;
211
212/// The interval between the two `f64`s around `x` — `x` itself, if it is
213/// zero, which is exact: an enclosure of the real number `x` was rounded
214/// from.
215fn around(x: f64) -> Scalar {
216    if x == 0.0 {
217        return Scalar::ZERO;
218    }
219    let step = |up: bool| {
220        let bits = x.to_bits();
221        f64::from_bits(if (x > 0.0) == up { bits + 1 } else { bits - 1 })
222    };
223    Scalar::from_f64(step(false)).union(Scalar::from_f64(step(true)))
224}
225
226/// How hard [`integrate`] tries: until every component's estimated error
227/// is below `relative_tolerance` times the integral of its absolute value,
228/// or until it has split the interval into `max_panels` panels.
229#[derive(Clone, Copy, Debug)]
230pub struct Quadrature {
231    pub relative_tolerance: f64,
232    pub max_panels: usize,
233}
234
235impl Default for Quadrature {
236    fn default() -> Self {
237        Self {
238            relative_tolerance: 1e-10,
239            max_panels: 200,
240        }
241    }
242}
243
244/// An integral, as [`integrate`] finds it.
245#[derive(Clone, Debug)]
246pub struct Integral {
247    /// Each component's enclosure: the rule's value, enclosed, widened by
248    /// its estimated truncation error (see the module docs).
249    pub value: Vec<Scalar>,
250    /// Whether every component's estimated error came below the tolerance
251    /// within the panel budget. When not, `value` is still widened by the
252    /// estimate, only wider than asked.
253    pub converged: bool,
254    /// How many times the integrand was evaluated: the work it took.
255    pub evaluations: usize,
256}
257
258/// One panel of the adaptive search: its ends, the Kronrod value, its
259/// estimated error and the integral of the integrand's absolute value, per
260/// component.
261struct Panel {
262    a: Scalar,
263    b: Scalar,
264    kronrod: Vec<Scalar>,
265    error: Vec<f64>,
266    absolute: Vec<f64>,
267    /// How wide the two rules' enclosures are together.
268    width: Vec<f64>,
269}
270
271/// The 15-point Kronrod and 7-point Gauss rules applied to `f` on `[a, b]`.
272fn panel(
273    f: &mut impl FnMut(Scalar) -> GeopResult<Vec<Scalar>>,
274    a: Scalar,
275    b: Scalar,
276    components: usize,
277) -> GeopResult<Panel> {
278    let half = b.sub(a).div(Scalar::TWO)?;
279    let center = a.add(half);
280    let mut kronrod = vec![Scalar::ZERO; components];
281    let mut gauss = vec![Scalar::ZERO; components];
282    let mut absolute = vec![0.0; components];
283    let mut add = |values: &[Scalar], k: usize| -> GeopResult<()> {
284        if values.len() != components {
285            return Err(GeopError::new(format!(
286                "integrate: the integrand gave {} components, {components} expected",
287                values.len()
288            )));
289        }
290        let wk = around(WGK[k]).mul(half);
291        // The odd Kronrod nodes are the Gauss rule's.
292        let wg = (k % 2 == 1).then(|| around(WG[k / 2]).mul(half));
293        for (c, &value) in values.iter().enumerate() {
294            kronrod[c] = kronrod[c].add(wk.mul(value));
295            absolute[c] += WGK[k] * half.to_f64().abs() * value.to_f64().abs();
296            if let Some(wg) = wg {
297                gauss[c] = gauss[c].add(wg.mul(value));
298            }
299        }
300        Ok(())
301    };
302    for (k, &node) in XGK.iter().enumerate() {
303        if node == 0.0 {
304            add(&f(center)?, k)?;
305            continue;
306        }
307        let offset = half.mul(around(node));
308        add(&f(center.sub(offset))?, k)?;
309        add(&f(center.add(offset))?, k)?;
310    }
311    let error = kronrod
312        .iter()
313        .zip(&gauss)
314        .map(|(k, g)| (k.midpoint().to_f64() - g.midpoint().to_f64()).abs())
315        .collect();
316    let width = kronrod
317        .iter()
318        .zip(&gauss)
319        .map(|(k, g)| k.width().to_f64() + g.width().to_f64())
320        .collect();
321    Ok(Panel {
322        a,
323        b,
324        kronrod,
325        error,
326        absolute,
327        width,
328    })
329}
330
331/// The integral of the `components`-valued `f` from `breaks[0]` to the last
332/// of `breaks`, each consecutive pair a panel to start with — where the
333/// integrand is not smooth, say, at the knots of a spline.
334///
335/// Global adaptive: the panel whose error is largest, relative to its
336/// component's scale, is halved until every component has converged or
337/// the budget is spent (see [`Quadrature`]). Where to halve a panel is a
338/// free choice, so its midpoint is sharpened.
339pub fn integrate(
340    mut f: impl FnMut(Scalar) -> GeopResult<Vec<Scalar>>,
341    breaks: &[Scalar],
342    components: usize,
343    quadrature: &Quadrature,
344) -> GeopResult<Integral> {
345    let mut evaluations = 0;
346    let mut f = |x: Scalar| {
347        evaluations += 1;
348        f(x)
349    };
350    if breaks.len() < 2 {
351        return Ok(Integral {
352            value: vec![Scalar::ZERO; components],
353            converged: true,
354            evaluations: 0,
355        });
356    }
357    let mut panels = breaks
358        .windows(2)
359        .map(|w| panel(&mut f, w[0], w[1], components))
360        .collect::<GeopResult<Vec<_>>>()?;
361    let total = |panels: &[Panel], pick: fn(&Panel) -> &Vec<f64>| -> Vec<f64> {
362        (0..components)
363            .map(|c| panels.iter().map(|p| pick(p)[c]).sum())
364            .collect()
365    };
366    let converged = loop {
367        let scale = total(&panels, |p| &p.absolute);
368        let error = total(&panels, |p| &p.error);
369        // Converged where the estimate is below the tolerance — or below
370        // what the arithmetic resolves at all: a difference between two
371        // rules smaller than their own enclosures' widths is rounding, not
372        // truncation, and no halving reduces it.
373        let width = total(&panels, |p| &p.width);
374        let pending: Vec<usize> = (0..components)
375            .filter(|&c| error[c] > quadrature.relative_tolerance * scale[c] && error[c] > width[c])
376            .collect();
377        if pending.is_empty() {
378            break true;
379        }
380        if panels.len() >= quadrature.max_panels {
381            break false;
382        }
383        // The panel contributing most to the component furthest from
384        // converging — of those not converged yet. A converged component
385        // that is all rounding, its scale nearly zero (the `z` moments of a
386        // vertical wall), has relative errors far above any other's, and
387        // ranked with the rest it drew every halving to its noise while the
388        // panel holding a pending component's error was never halved.
389        let worst = |p: &Panel| {
390            pending
391                .iter()
392                .filter(|&&c| scale[c] > 0.0)
393                .map(|&c| p.error[c] / scale[c])
394                .fold(0.0, f64::max)
395        };
396        let (index, _) = panels
397            .iter()
398            .enumerate()
399            .map(|(i, p)| (i, worst(p)))
400            .max_by(|a, b| a.1.total_cmp(&b.1))
401            .expect("there is a panel");
402        let Panel { a, b, .. } = panels.swap_remove(index);
403        let mid = a.add(b).div(Scalar::TWO)?.sharpen();
404        panels.push(panel(&mut f, a, mid, components)?);
405        panels.push(panel(&mut f, mid, b, components)?);
406    };
407    let error = total(&panels, |p| &p.error);
408    let value = (0..components)
409        .map(|c| {
410            let sum = panels
411                .iter()
412                .fold(Scalar::ZERO, |acc, p| acc.add(p.kronrod[c]));
413            sum.add(Scalar::from_f64(-error[c]).union(Scalar::from_f64(error[c])))
414        })
415        .collect();
416    Ok(Integral {
417        value,
418        converged,
419        evaluations,
420    })
421}
422
423/// The integral of the `components`-valued `f` from `breaks[0]` to the last
424/// of `breaks`, where `f` is a polynomial of degree at most `degree` between
425/// every two consecutive breaks — a spline, say, split at its knots.
426///
427/// The Gauss–Legendre rule of `degree / 2 + 1` points is exact for such an
428/// `f`, so the result is the rule's value, enclosed in interval arithmetic,
429/// and nothing else: no truncation error, always converged. Whether `f` is
430/// such a polynomial is the caller's claim to make, from what `f` is; a
431/// degree above [`MAX_POLYNOMIAL_DEGREE`] is an error.
432pub fn integrate_polynomial(
433    mut f: impl FnMut(Scalar) -> GeopResult<Vec<Scalar>>,
434    breaks: &[Scalar],
435    components: usize,
436    degree: usize,
437) -> GeopResult<Integral> {
438    let Some(rule) = GAUSS_LEGENDRE.get(degree / 2) else {
439        return Err(GeopError::new(format!(
440            "integrate_polynomial: no rule for degree {degree}, at most \
441             {MAX_POLYNOMIAL_DEGREE}"
442        )));
443    };
444    let mut value = vec![Scalar::ZERO; components];
445    let mut evaluations = 0;
446    for w in breaks.windows(2) {
447        let half = w[1].sub(w[0]).div(Scalar::TWO)?;
448        let center = w[0].add(half);
449        let mut add = |x: Scalar, weight: f64| -> GeopResult<()> {
450            let values = f(x)?;
451            evaluations += 1;
452            if values.len() != components {
453                return Err(GeopError::new(format!(
454                    "integrate_polynomial: the integrand gave {} components, {components} \
455                     expected",
456                    values.len()
457                )));
458            }
459            let weight = around(weight).mul(half);
460            for (sum, v) in value.iter_mut().zip(values) {
461                *sum = sum.add(weight.mul(v));
462            }
463            Ok(())
464        };
465        for &(node, weight) in rule.iter() {
466            if node == 0.0 {
467                add(center, weight)?;
468                continue;
469            }
470            let offset = half.mul(around(node));
471            add(center.sub(offset), weight)?;
472            add(center.add(offset), weight)?;
473        }
474    }
475    Ok(Integral {
476        value,
477        converged: true,
478        evaluations,
479    })
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use crate::scalars::Scalar;
486
487    /// A peak that needs halving, beside a component that is all rounding
488    /// — nearly zero, noisy, converged by its width — on the other half of
489    /// the range. The noise's relative errors are far above the peak's, and
490    /// ranked with them they drew every halving away from the peak: the
491    /// moments of a hole's wall did not converge.
492    #[test]
493    fn halving_follows_the_components_not_converged() {
494        type Scalar = crate::scalars::Scalar;
495        let f = |x: Scalar| {
496            let t = x.to_f64();
497            let peak = 1.0 / (1.0 + 1e4 * (t + 0.5) * (t + 0.5));
498            let noise = if t > 0.0 {
499                1e-18 * (1e7 * t).sin()
500            } else {
501                0.0
502            };
503            Ok(vec![
504                Scalar::from_f64(peak),
505                Scalar::from_f64(noise - 1e-15).union(Scalar::from_f64(noise + 1e-15)),
506            ])
507        };
508        let quadrature = Quadrature {
509            relative_tolerance: 1e-10,
510            max_panels: 64,
511        };
512        let integral =
513            integrate(f, &[Scalar::from_f64(-1.0), Scalar::ONE], 2, &quadrature).unwrap();
514        assert!(integral.converged, "{integral:?}");
515        // ∫ 1 / (1 + 10⁴ (x + ½)²) = (atan(50) + atan(150)) / 100.
516        let exact = (50.0_f64.atan() + 150.0_f64.atan()) / 100.0;
517        assert!(
518            integral.value[0].could_be_equal(Scalar::from_f64(exact)),
519            "{integral:?}"
520        );
521    }
522
523    #[test]
524    fn polynomials_are_exact() {
525        // x^5 - 2x^2 + 1 on [0, 2]: 64/6 - 16/3 + 2 = 22/3.
526        let f = |x: Scalar| {
527            Ok(vec![
528                x.mul(x)
529                    .mul(x)
530                    .mul(x)
531                    .mul(x)
532                    .sub(Scalar::TWO.mul(x).mul(x))
533                    .add(Scalar::ONE),
534            ])
535        };
536        let integral =
537            integrate(f, &[Scalar::ZERO, Scalar::TWO], 1, &Quadrature::default()).unwrap();
538        assert!(integral.converged);
539        let exact = Scalar::from_ratio(22, 3).unwrap();
540        assert!(
541            integral.value[0].could_be_equal(exact),
542            "{:?}",
543            integral.value[0]
544        );
545        assert!(
546            integral.value[0].width().to_f64() < 1e-6,
547            "{:?}",
548            integral.value[0]
549        );
550    }
551
552    /// A function the rule cannot integrate exactly converges by halving
553    /// panels, and the result encloses the exact value.
554    #[test]
555    fn smooth_functions_converge_and_are_enclosed() {
556        use crate::scalars::Scalar;
557        // 1 / (1 + x^2) on [0, 1]: pi / 4.
558        let f = |x: Scalar| Ok(vec![Scalar::ONE.div(Scalar::ONE.add(x.mul(x)))?]);
559        let integral =
560            integrate(f, &[Scalar::ZERO, Scalar::ONE], 1, &Quadrature::default()).unwrap();
561        assert!(integral.converged);
562        assert!(integral.value[0].could_be_equal(Scalar::PI.div(Scalar::from_f64(4.0)).unwrap()));
563        // sqrt(x) on [0, 1] has an unbounded derivative at 0: it takes
564        // halving towards it, and still encloses 2/3.
565        let f = |x: Scalar| Ok(vec![x.sqrt()?]);
566        let integral =
567            integrate(f, &[Scalar::ZERO, Scalar::ONE], 1, &Quadrature::default()).unwrap();
568        assert!(integral.value[0].could_be_equal(Scalar::from_ratio(2, 3).unwrap()));
569        assert!(
570            integral.value[0].width().to_f64() < 1e-6,
571            "{:?}",
572            integral.value[0]
573        );
574    }
575
576    /// Every rule integrates every power up to its degree exactly — each
577    /// table entry checked — over panels of a split range, with `n` points
578    /// a panel.
579    #[test]
580    fn gauss_legendre_is_exact() {
581        for degree in 0..=MAX_POLYNOMIAL_DEGREE {
582            let f = |x: Scalar| {
583                Ok((0..=degree)
584                    .map(|k| (0..k).fold(Scalar::ONE, |p, _| p.mul(x)))
585                    .collect())
586            };
587            let breaks = [Scalar::from_f64(-1.0), Scalar::from_f64(0.25), Scalar::ONE];
588            let integral = integrate_polynomial(f, &breaks, degree + 1, degree).unwrap();
589            assert!(integral.converged);
590            assert_eq!(integral.evaluations, 2 * (degree / 2 + 1));
591            for (k, value) in integral.value.iter().enumerate() {
592                // ∫ x^k from -1 to 1: 2 / (k + 1) for even k, 0 for odd.
593                let exact = if k % 2 == 0 {
594                    Scalar::from_ratio(2, k as i64 + 1).unwrap()
595                } else {
596                    Scalar::ZERO
597                };
598                assert!(
599                    value.could_be_equal(exact),
600                    "x^{k}, degree {degree}: {value:?}"
601                );
602                assert!(value.width().to_f64() < 1e-6, "x^{k}: {value:?}");
603            }
604        }
605        let too_high = integrate_polynomial(
606            |_: Scalar| Ok(vec![Scalar::ONE]),
607            &[Scalar::ZERO, Scalar::ONE],
608            1,
609            MAX_POLYNOMIAL_DEGREE + 1,
610        );
611        assert!(too_high.is_err());
612    }
613
614    /// A budget too small leaves the result wider, and says so.
615    #[test]
616    fn a_spent_budget_is_reported() {
617        use crate::scalars::Scalar;
618        let f = |x: Scalar| Ok(vec![x.sqrt()?]);
619        let tight = Quadrature {
620            relative_tolerance: 1e-14,
621            max_panels: 2,
622        };
623        let integral = integrate(f, &[Scalar::ZERO, Scalar::ONE], 1, &tight).unwrap();
624        assert!(!integral.converged);
625        assert!(integral.value[0].could_be_equal(Scalar::from_ratio(2, 3).unwrap()));
626    }
627}