Skip to main content

optirs_learned/higher_order/
hvp.rs

1//! Hessian-vector product and mixed-partial back ends for [`HigherOrderEngine`].
2//!
3//! # Why these are finite differences, and why that is not a stub
4//!
5//! [`HigherOrderEngine`] consumes an objective as a black-box
6//! `impl Fn(&Array1<T>) -> T` that is *monomorphic in `T`*. Nested automatic
7//! differentiation (forward-over-reverse, reverse-over-forward) fundamentally
8//! cannot be threaded through that type:
9//!
10//! * forward mode needs the objective to accept dual numbers, i.e. to be
11//!   generic (`Fn(&Array1<D>) -> D` for any dual `D`), and
12//! * reverse mode needs the objective expressed as tape operations
13//!   ([`crate::reverse_mode::ReverseModeEngine`]), not as an opaque closure.
14//!
15//! `optirs-learned` has real implementations of both
16//! ([`crate::forward_mode`], [`crate::reverse_mode`]) — they simply require a
17//! different entry point than a `Fn(&Array1<T>) -> T`.
18//!
19//! So this module does two honest things instead of one dishonest one. Every
20//! mode that *can* exist behind this signature is implemented as a genuinely
21//! distinct numerical algorithm with its own cost and truncation error, and
22//! [`HvpMode::NestedAutodiff`] / [`MixedPartialMethod::NestedAutodiff`] return a
23//! typed error naming the reason and the alternative, rather than silently
24//! running a finite difference under an autodiff name.
25//!
26//! Historically (finding F89) `hvp_reverse_over_forward` and
27//! `hvp_finite_difference` were byte-identical, `hvp_forward_over_reverse` was a
28//! third copy behind a helper, and all three of
29//! `mixed_partial_{forward_over_reverse, reverse_over_forward, pure_forward}`
30//! contained a one-line delegation to the finite-difference routine with the
31//! comment "Real implementation would use proper forward-over-reverse mode" —
32//! five advertised differentiation modes for one algorithm.
33
34use scirs2_core::ndarray::Array1;
35use scirs2_core::numeric::Float;
36use std::fmt::Debug;
37
38use super::{central_step, HigherOrderEngine, HvpMode, MixedPartialMethod};
39use crate::error::{OptimError, Result};
40
41/// Shared message for the modes that the black-box closure signature rules out.
42pub(super) const NESTED_AUTODIFF_UNAVAILABLE: &str =
43    "nested automatic differentiation (forward-over-reverse / reverse-over-forward) is not \
44     available through HigherOrderEngine: its objective is an opaque Fn(&Array1<T>) -> T that is \
45     monomorphic in T, so neither dual numbers nor a reverse tape can be threaded through it. Use \
46     crate::forward_mode::ForwardModeEngine or crate::reverse_mode::ReverseModeEngine directly, or \
47     pick one of the finite-difference HvpMode variants.";
48
49impl<
50        T: Float
51            + Debug
52            + Default
53            + std::iter::Sum
54            + Send
55            + Sync
56            + scirs2_core::ndarray::ScalarOperand
57            + 'static,
58    > HigherOrderEngine<T>
59{
60    /// `Hv` by a **central** difference of the gradient along `v`:
61    /// `Hv ≈ (∇f(x + h·v) − ∇f(x − h·v)) / (2h)`.
62    ///
63    /// Cost: two gradient evaluations (`2n` extra objective evaluations for the
64    /// inner central-difference gradient, so `4n` objective calls). Truncation
65    /// error `O(h²)`; `h` comes from [`HigherOrderEngine::fd_step`] with order 2
66    /// because differencing a gradient is a second-order quantity — using the
67    /// order-1 step here is what made the old code numerically useless in `f32`.
68    pub(super) fn hvp_central_difference(
69        &mut self,
70        function: &impl Fn(&Array1<T>) -> T,
71        point: &Array1<T>,
72        vector: &Array1<T>,
73    ) -> Result<Array1<T>> {
74        Self::check_hvp_dims(point, vector)?;
75        let h = self.hvp_step(vector);
76        let two = T::one() + T::one();
77
78        let grad_plus = self.gradient_at_point(function, &(point + &(vector * h)))?;
79        let grad_minus = self.gradient_at_point(function, &(point - &(vector * h)))?;
80
81        Ok((grad_plus - grad_minus) / (two * h))
82    }
83
84    /// `Hv` by a **one-sided forward** difference of the gradient along `v`:
85    /// `Hv ≈ (∇f(x + h·v) − ∇f(x)) / h`.
86    ///
87    /// Genuinely different from [`Self::hvp_central_difference`]: it never
88    /// evaluates the objective at `x − h·v`, which matters when the objective is
89    /// undefined or discontinuous on that side (a barrier term, a domain edge, a
90    /// simulator that rejects negative inputs). It is first-order accurate, so
91    /// it uses the *order-1* step — the optimum for an `O(h)` stencil is coarser
92    /// than for an `O(h²)` one.
93    pub(super) fn hvp_forward_difference(
94        &mut self,
95        function: &impl Fn(&Array1<T>) -> T,
96        point: &Array1<T>,
97        vector: &Array1<T>,
98    ) -> Result<Array1<T>> {
99        Self::check_hvp_dims(point, vector)?;
100        let scale = Self::direction_scale(vector);
101        let h = self.fd_step(1) / scale;
102
103        let grad_plus = self.gradient_at_point(function, &(point + &(vector * h)))?;
104        let grad_at = self.gradient_at_point(function, point)?;
105
106        Ok((grad_plus - grad_at) / h)
107    }
108
109    /// `Hv` by the **unit-step secant** `∇f(x + v) − ∇f(x)`.
110    ///
111    /// For a quadratic `f(x) = ½xᵀHx + bᵀx + c` this identity is algebraically
112    /// exact for *any* step, so no step-size tuning and no truncation error are
113    /// involved — the only error left is whatever the inner gradient carries.
114    /// (This is the specialisation of Pearlmutter's R-operator identity that a
115    /// quadratic model admits; the enum variant used to be spelled `PearLman`
116    /// and claimed exactness without noting that the inner gradient is itself a
117    /// finite difference.)
118    ///
119    /// On a non-quadratic objective the `O(‖v‖)` truncation error is *not*
120    /// small, which is why this is a separate, explicitly-named mode rather than
121    /// the default.
122    pub(super) fn hvp_quadratic_secant(
123        &mut self,
124        function: &impl Fn(&Array1<T>) -> T,
125        point: &Array1<T>,
126        vector: &Array1<T>,
127    ) -> Result<Array1<T>> {
128        Self::check_hvp_dims(point, vector)?;
129        let grad_plus = self.gradient_at_point(function, &(point + vector))?;
130        let grad_at = self.gradient_at_point(function, point)?;
131        Ok(grad_plus - grad_at)
132    }
133
134    /// `Hv` by materialising the full finite-difference Hessian and multiplying.
135    ///
136    /// `O(n²)` objective evaluations, so it is the wrong choice for anything
137    /// large — it exists as the independent reference path the cheap directional
138    /// modes are validated against, and as the mode to reach for when the same
139    /// `H` will be applied to many different vectors.
140    pub(super) fn hvp_materialized_hessian(
141        &mut self,
142        function: &impl Fn(&Array1<T>) -> T,
143        point: &Array1<T>,
144        vector: &Array1<T>,
145    ) -> Result<Array1<T>> {
146        Self::check_hvp_dims(point, vector)?;
147        let hessian = self.finite_difference_hessian(function, point)?;
148        Ok(hessian.dot(vector))
149    }
150
151    /// Reject a direction whose length does not match the point.
152    ///
153    /// The old code fed mismatched shapes straight into `point + &(vector * eps)`
154    /// and let `ndarray` panic on the broadcast.
155    fn check_hvp_dims(point: &Array1<T>, vector: &Array1<T>) -> Result<()> {
156        if point.len() != vector.len() {
157            return Err(OptimError::InvalidConfig(format!(
158                "Hessian-vector product needs matching lengths: point has {}, direction has {}",
159                point.len(),
160                vector.len()
161            )));
162        }
163        if point.is_empty() {
164            return Err(OptimError::InvalidConfig(
165                "Hessian-vector product needs a non-empty point".to_string(),
166            ));
167        }
168        Ok(())
169    }
170
171    /// `max(‖v‖∞, 1)`, used to keep the *displacement* `h·v` at the intended
172    /// scale instead of letting a large direction blow the stencil out.
173    fn direction_scale(vector: &Array1<T>) -> T {
174        let norm = vector.iter().fold(T::zero(), |acc, v| {
175            let a = v.abs();
176            if a > acc {
177                a
178            } else {
179                acc
180            }
181        });
182        if norm > T::one() {
183            norm
184        } else {
185            T::one()
186        }
187    }
188
189    /// Second-order step for a directional gradient difference, normalised by
190    /// the direction magnitude.
191    fn hvp_step(&self, vector: &Array1<T>) -> T {
192        self.fd_step(2) / Self::direction_scale(vector)
193    }
194
195    /// Mixed partial by **one-sided forward** central-free stencils.
196    ///
197    /// Every evaluation point is `x + (non-negative multiple of h)`, so the
198    /// objective is never probed below `x` in any differentiated coordinate.
199    /// Supports total order ≤ 3 like the central variant, but with `O(h)`
200    /// truncation error instead of `O(h²)`.
201    ///
202    /// The operator is the exact `k`-fold forward difference, assembled by
203    /// inclusion-exclusion over the differentiated axes:
204    /// `∂f ≈ (f(x+h) − f(x))/h`,
205    /// `∂²f ≈ (f(x+hᵢ+hⱼ) − f(x+hᵢ) − f(x+hⱼ) + f(x))/h²` (which covers `i == j`
206    /// as `(f(x+2h) − 2f(x+h) + f(x))/h²`), and the order-3 analogue.
207    ///
208    /// The step is taken from [`HigherOrderEngine::fd_step`] at the *total
209    /// order*, not at order 1: a `k`-th difference divides by `hᵏ`, so a step
210    /// chosen for a first derivative amplifies roundoff by `h^(1−k)` — at
211    /// `h = 1e-5` an order-3 stencil in `f64` is pure noise.
212    pub(super) fn mixed_partial_forward_difference(
213        &self,
214        function: &impl Fn(&Array1<T>) -> T,
215        point: &Array1<T>,
216        variables: &[usize],
217        orders: &[usize],
218    ) -> Result<T> {
219        let flat = Self::flatten_mixed_index(point.len(), variables, orders)?;
220        let h = self.fd_step(flat.len().max(1));
221
222        // Shift `point` by `h` in each listed coordinate (repeats accumulate).
223        let shift = |axes: &[usize]| -> Array1<T> {
224            let mut x = point.clone();
225            for &axis in axes {
226                x[axis] = x[axis] + h;
227            }
228            x
229        };
230
231        // Forward difference operator: sum over every subset of `flat` with the
232        // inclusion-exclusion sign, divided by h^order. For order k this is the
233        // exact k-fold application of the one-sided operator.
234        let order = flat.len();
235        if order == 0 {
236            return Ok(function(point));
237        }
238        let mut acc = T::zero();
239        let subsets = 1usize << order;
240        for mask in 0..subsets {
241            let mut axes: Vec<usize> = Vec::with_capacity(order);
242            for (bit, &axis) in flat.iter().enumerate() {
243                if mask & (1 << bit) != 0 {
244                    axes.push(axis);
245                }
246            }
247            // (-1)^(order - |subset|)
248            let sign = if (order - axes.len()).is_multiple_of(2) {
249                T::one()
250            } else {
251                -T::one()
252            };
253            acc = acc + sign * function(&shift(&axes));
254        }
255
256        let mut denom = T::one();
257        for _ in 0..order {
258            denom = denom * h;
259        }
260        Ok(acc / denom)
261    }
262
263    /// Validate `(variables, orders)` and expand it into a flat multi-index that
264    /// repeats each variable `order` times.
265    pub(super) fn flatten_mixed_index(
266        dimension: usize,
267        variables: &[usize],
268        orders: &[usize],
269    ) -> Result<Vec<usize>> {
270        let total_order: usize = orders.iter().sum();
271        if total_order > 3 {
272            return Err(OptimError::InvalidConfig(
273                "Mixed partial order too high (finite differences support order <= 3)".to_string(),
274            ));
275        }
276        for &v in variables {
277            if v >= dimension {
278                return Err(OptimError::InvalidConfig(format!(
279                    "mixed-partial variable index {v} is out of range for a {dimension}-dimensional point"
280                )));
281            }
282        }
283        let mut flat: Vec<usize> = Vec::with_capacity(total_order);
284        for (&v, &o) in variables.iter().zip(orders.iter()) {
285            for _ in 0..o {
286                flat.push(v);
287            }
288        }
289        Ok(flat)
290    }
291}
292
293impl HvpMode {
294    /// Human-readable count of objective evaluations, for the docs and for
295    /// callers choosing a mode. `n` is the problem dimension.
296    ///
297    /// This is the property that makes the modes genuinely different rather than
298    /// five names for one algorithm.
299    pub fn objective_evaluations(&self, n: usize) -> Option<usize> {
300        match self {
301            // Two central-difference gradients: 2 · 2n.
302            HvpMode::CentralDifference => Some(4 * n),
303            // Two central-difference gradients as well, but one of them is at
304            // the base point and is the one a caller typically already has.
305            HvpMode::ForwardDifference => Some(4 * n),
306            HvpMode::QuadraticSecant => Some(4 * n),
307            // Full Hessian: the diagonal costs 3n, the off-diagonals 4 each.
308            HvpMode::MaterializedHessian => Some(3 * n + 2 * n * n.saturating_sub(1)),
309            HvpMode::NestedAutodiff => None,
310        }
311    }
312
313    /// Whether this mode can actually run behind the engine's black-box
314    /// objective type.
315    pub fn is_available(&self) -> bool {
316        !matches!(self, HvpMode::NestedAutodiff)
317    }
318}
319
320impl MixedPartialMethod {
321    /// Whether this method can actually run behind the engine's black-box
322    /// objective type.
323    pub fn is_available(&self) -> bool {
324        !matches!(self, MixedPartialMethod::NestedAutodiff)
325    }
326}
327
328/// Re-exported so callers can reason about step sizes without reaching into the
329/// private helper.
330pub fn recommended_step<T: Float + 'static>(order: usize) -> T {
331    central_step::<T>(order)
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::higher_order::{HigherOrderConfig, HigherOrderEngine};
338    use scirs2_core::ndarray::{arr1, arr2, Array2};
339
340    /// `f(x) = ½ xᵀ A x + bᵀ x` with `A = [[4, 1], [1, 3]]`, `b = [1, -2]`.
341    /// The Hessian is exactly `A`, independent of `x`.
342    fn quadratic(x: &Array1<f64>) -> f64 {
343        let a = arr2(&[[4.0, 1.0], [1.0, 3.0]]);
344        let b = arr1(&[1.0, -2.0]);
345        0.5 * x.dot(&a.dot(x)) + b.dot(x)
346    }
347
348    /// A genuinely non-quadratic objective: `f(x) = x0³ + x0·x1² + sin(x1)`.
349    fn cubic(x: &Array1<f64>) -> f64 {
350        x[0] * x[0] * x[0] + x[0] * x[1] * x[1] + x[1].sin()
351    }
352
353    fn engine() -> HigherOrderEngine<f64> {
354        HigherOrderEngine::with_config(HigherOrderConfig::default())
355    }
356
357    fn hessian_of_quadratic() -> Array2<f64> {
358        arr2(&[[4.0, 1.0], [1.0, 3.0]])
359    }
360
361    #[test]
362    fn every_available_mode_matches_the_exact_quadratic_hessian_product() {
363        let point = arr1(&[0.3, -0.7]);
364        let vector = arr1(&[1.0, 2.0]);
365        let expected = hessian_of_quadratic().dot(&vector);
366
367        for mode in [
368            HvpMode::CentralDifference,
369            HvpMode::ForwardDifference,
370            HvpMode::QuadraticSecant,
371            HvpMode::MaterializedHessian,
372        ] {
373            let mut eng = engine();
374            let hv = eng
375                .hessian_vector_product_advanced(quadratic, &point, &vector, Some(mode))
376                .unwrap_or_else(|e| panic!("mode {mode:?} failed: {e}"));
377            for (i, (&got, &want)) in hv.iter().zip(expected.iter()).enumerate() {
378                // 1e-3 absolute on a value of magnitude ~6-7 is a relative
379                // accuracy of ~1.5e-4. Nested finite differences cannot do
380                // better than ~eps^(1/3) in the worst mode, and the point of the
381                // assertion is that every mode really computes `A·v = (6, 7)`.
382                assert!(
383                    (got - want).abs() < 1e-3,
384                    "mode {mode:?} component {i}: got {got}, want {want}"
385                );
386            }
387        }
388    }
389
390    /// The headline F89 assertion. On a *non-quadratic* objective the four
391    /// available modes have different truncation errors, so they must not agree
392    /// bit-for-bit. Two byte-identical implementations (which is what
393    /// `ReverseOverForward` and `FiniteDifference` used to be) would fail this.
394    #[test]
395    fn the_modes_are_genuinely_different_algorithms() {
396        let point = arr1(&[0.8, 1.3]);
397        let vector = arr1(&[1.0, -0.5]);
398
399        let mut values = Vec::new();
400        for mode in [
401            HvpMode::CentralDifference,
402            HvpMode::ForwardDifference,
403            HvpMode::QuadraticSecant,
404            HvpMode::MaterializedHessian,
405        ] {
406            let mut eng = engine();
407            let hv = eng
408                .hessian_vector_product_advanced(cubic, &point, &vector, Some(mode))
409                .unwrap_or_else(|e| panic!("mode {mode:?} failed: {e}"));
410            values.push((mode, hv));
411        }
412
413        for i in 0..values.len() {
414            for j in (i + 1)..values.len() {
415                let (mode_a, ref a) = values[i];
416                let (mode_b, ref b) = values[j];
417                let delta = a
418                    .iter()
419                    .zip(b.iter())
420                    .map(|(x, y)| (x - y).abs())
421                    .fold(0.0_f64, f64::max);
422                assert!(
423                    delta > 0.0,
424                    "{mode_a:?} and {mode_b:?} produced identical output {a:?} — \
425                     they are the same implementation under two names"
426                );
427            }
428        }
429    }
430
431    /// On a non-quadratic objective the unit-step secant is *supposed* to be
432    /// badly biased, and the central difference is supposed to be the accurate
433    /// one. Asserting the ordering proves the modes are not interchangeable.
434    #[test]
435    fn central_difference_beats_the_quadratic_secant_off_quadratics() {
436        let point = arr1(&[0.8, 1.3]);
437        let vector = arr1(&[1.0, -0.5]);
438        // Hessian of x0³ + x0 x1² + sin(x1):
439        //   d²/dx0² = 6 x0,     d²/dx0 dx1 = 2 x1,
440        //   d²/dx1² = 2 x0 - sin(x1)
441        let h = arr2(&[
442            [6.0 * point[0], 2.0 * point[1]],
443            [2.0 * point[1], 2.0 * point[0] - point[1].sin()],
444        ]);
445        let exact = h.dot(&vector);
446
447        let err = |mode: HvpMode| -> f64 {
448            let mut eng = engine();
449            let hv = eng
450                .hessian_vector_product_advanced(cubic, &point, &vector, Some(mode))
451                .expect("hvp");
452            hv.iter()
453                .zip(exact.iter())
454                .map(|(a, b)| (a - b).abs())
455                .fold(0.0_f64, f64::max)
456        };
457
458        let central = err(HvpMode::CentralDifference);
459        let secant = err(HvpMode::QuadraticSecant);
460        assert!(
461            central < secant,
462            "central error {central} should be below the unit-step secant error {secant}"
463        );
464        assert!(
465            central < 1e-4,
466            "central difference error {central} too large"
467        );
468    }
469
470    #[test]
471    fn nested_autodiff_reports_an_honest_error() {
472        let mut eng = engine();
473        let err = eng
474            .hessian_vector_product_advanced(
475                quadratic,
476                &arr1(&[0.0, 0.0]),
477                &arr1(&[1.0, 0.0]),
478                Some(HvpMode::NestedAutodiff),
479            )
480            .expect_err("nested autodiff cannot exist behind a monomorphic closure");
481        let text = err.to_string();
482        assert!(
483            text.contains("nested automatic differentiation"),
484            "error should name the unavailable capability, got: {text}"
485        );
486        assert!(
487            text.contains("reverse_mode") || text.contains("forward_mode"),
488            "error should point at the real engines, got: {text}"
489        );
490        assert!(!HvpMode::NestedAutodiff.is_available());
491        assert!(HvpMode::CentralDifference.is_available());
492        assert!(HvpMode::NestedAutodiff.objective_evaluations(10).is_none());
493    }
494
495    #[test]
496    fn mismatched_direction_length_is_an_error_not_a_panic() {
497        let mut eng = engine();
498        assert!(eng
499            .hessian_vector_product_advanced(
500                quadratic,
501                &arr1(&[0.0, 0.0]),
502                &arr1(&[1.0, 0.0, 0.0]),
503                Some(HvpMode::CentralDifference),
504            )
505            .is_err());
506        assert!(eng
507            .hessian_vector_product_advanced(
508                quadratic,
509                &Array1::<f64>::zeros(0),
510                &Array1::<f64>::zeros(0),
511                Some(HvpMode::CentralDifference),
512            )
513            .is_err());
514    }
515
516    /// The cost model has to actually distinguish the modes, otherwise it is
517    /// another place a "five names, one algorithm" regression could hide.
518    #[test]
519    fn the_cost_model_separates_directional_modes_from_the_materialized_one() {
520        let n = 64;
521        let directional = HvpMode::CentralDifference
522            .objective_evaluations(n)
523            .expect("available");
524        let materialized = HvpMode::MaterializedHessian
525            .objective_evaluations(n)
526            .expect("available");
527        assert!(
528            materialized > 10 * directional,
529            "materializing the Hessian ({materialized}) must be far costlier than a \
530             directional difference ({directional})"
531        );
532    }
533
534    /// Forward-only mixed partials must never probe below the base point. The
535    /// objective here returns NaN for any negative coordinate, so a stencil that
536    /// steps backwards produces NaN and the assertion fails.
537    #[test]
538    fn forward_mixed_partials_never_step_backwards() {
539        let boundary = |x: &Array1<f64>| -> f64 {
540            if x.iter().any(|&v| v < 0.0) {
541                f64::NAN
542            } else {
543                x[0] * x[0] * x[1]
544            }
545        };
546        let eng = engine();
547        // At x = (0, 2): d²/dx0² of x0² x1 is 2 x1 = 4.
548        let value = eng
549            .mixed_partial_forward_difference(&boundary, &arr1(&[0.0, 2.0]), &[0], &[2])
550            .expect("forward mixed partial");
551        assert!(
552            value.is_finite(),
553            "forward stencil stepped into the undefined region"
554        );
555        assert!(
556            (value - 4.0).abs() < 1e-3,
557            "expected 4.0 from the forward stencil, got {value}"
558        );
559    }
560
561    #[test]
562    fn forward_and_central_mixed_partials_agree_on_a_smooth_function() {
563        let mut eng = engine();
564        let point = arr1(&[1.1, -0.4]);
565        // d²/dx0 dx1 of x0³ + x0 x1² + sin(x1) is 2 x1 = -0.8.
566        let central = eng
567            .mixed_partial(
568                cubic,
569                &point,
570                &[0, 1],
571                &[1, 1],
572                MixedPartialMethod::FiniteDifference,
573            )
574            .expect("central");
575        let forward = eng
576            .mixed_partial(
577                cubic,
578                &point,
579                &[0, 1],
580                &[1, 1],
581                MixedPartialMethod::ForwardFiniteDifference,
582            )
583            .expect("forward");
584        assert!((central.value - (-0.8)).abs() < 1e-4, "{}", central.value);
585        assert!((forward.value - (-0.8)).abs() < 1e-3, "{}", forward.value);
586        // Different stencils: they must not be the same number.
587        assert!(
588            (central.value - forward.value).abs() > 0.0,
589            "central and forward stencils returned identical values"
590        );
591    }
592
593    #[test]
594    fn mixed_partial_nested_autodiff_reports_an_honest_error() {
595        let mut eng = engine();
596        let err = eng
597            .mixed_partial(
598                cubic,
599                &arr1(&[0.5, 0.5]),
600                &[0, 1],
601                &[1, 1],
602                MixedPartialMethod::NestedAutodiff,
603            )
604            .expect_err("nested autodiff is unavailable");
605        assert!(err.to_string().contains("nested automatic differentiation"));
606        assert!(!MixedPartialMethod::NestedAutodiff.is_available());
607        assert!(MixedPartialMethod::FiniteDifference.is_available());
608    }
609
610    #[test]
611    fn mixed_partial_rejects_out_of_range_variables() {
612        assert!(HigherOrderEngine::<f64>::flatten_mixed_index(2, &[5], &[1]).is_err());
613        assert!(HigherOrderEngine::<f64>::flatten_mixed_index(2, &[0], &[4]).is_err());
614        assert_eq!(
615            HigherOrderEngine::<f64>::flatten_mixed_index(3, &[0, 2], &[2, 1]).expect("flatten"),
616            vec![0, 0, 2]
617        );
618    }
619
620    #[test]
621    fn recommended_step_sharpens_with_order() {
622        let first: f64 = recommended_step(1);
623        let second: f64 = recommended_step(2);
624        assert!(first > 0.0 && second > 0.0);
625        assert!(
626            second > first,
627            "a second-order stencil needs a coarser step than a first-order one: {first} vs {second}"
628        );
629    }
630}