Skip to main content

optirs_learned/
higher_order.rs

1use std::fmt::Debug;
2// Higher-order automatic differentiation
3//
4// This module implements computation of higher-order derivatives including
5// Hessians, third-order derivatives, and mixed partial derivatives for
6// advanced optimization algorithms.
7
8use scirs2_core::ndarray::{Array1, Array2, Array3};
9use scirs2_core::numeric::Float;
10
11use crate::error::{OptimError, Result};
12
13/// Hessian-vector-product and mixed-partial back ends.
14///
15/// Kept in its own file so `higher_order.rs` stays under the 2000-line cap.
16pub mod hvp;
17
18/// Higher-order differentiation engine
19pub struct HigherOrderEngine<
20    T: Float
21        + Debug
22        + Default
23        + Clone
24        + Send
25        + Sync
26        + 'static
27        + std::iter::Sum
28        + scirs2_core::ndarray::ScalarOperand,
29> {
30    /// Maximum derivative order to compute
31    _maxorder: usize,
32
33    /// Use mixed-mode for Hessian computation
34    mixed_mode: bool,
35
36    /// Finite difference settings for numerical verification
37    finite_diff_eps: T,
38
39    /// Enable automatic parallelization
40    parallel_computation: bool,
41
42    /// Thread pool size
43    thread_pool_size: usize,
44
45    /// Use sparse computations when beneficial
46    adaptive_sparsity: bool,
47
48    /// Automatic differentiation mode selection
49    auto_mode_selection: bool,
50
51    /// Performance profiler
52    profiler: ComputationProfiler<T>,
53}
54
55/// Hessian computation configuration
56#[derive(Debug, Clone)]
57pub struct HessianConfig {
58    /// Use exact computation (vs approximation)
59    pub exact: bool,
60
61    /// Use sparse representation
62    pub sparse: bool,
63
64    /// Sparsity threshold
65    pub sparsity_threshold: f64,
66
67    /// Use diagonal approximation
68    pub diagonal_only: bool,
69
70    /// Use BFGS approximation
71    pub bfgs_approximation: bool,
72
73    /// Use finite differences for verification
74    pub verify_with_finite_diff: bool,
75}
76
77impl Default for HessianConfig {
78    fn default() -> Self {
79        Self {
80            exact: true,
81            sparse: false,
82            sparsity_threshold: 1e-8,
83            diagonal_only: false,
84            bfgs_approximation: false,
85            verify_with_finite_diff: false,
86        }
87    }
88}
89
90/// Sparse Hessian representation
91#[derive(Debug, Clone)]
92pub struct SparseHessian<T: Float + Debug + Send + Sync + 'static> {
93    /// Row indices
94    pub rows: Vec<usize>,
95
96    /// Column indices  
97    pub cols: Vec<usize>,
98
99    /// Values
100    pub values: Vec<T>,
101
102    /// Matrix dimensions
103    pub shape: (usize, usize),
104
105    /// Number of non-zero elements
106    pub nnz: usize,
107}
108
109/// Third-order derivative tensor
110#[derive(Debug, Clone)]
111pub struct ThirdOrderTensor<T: Float + Debug + Send + Sync + 'static> {
112    /// Dense tensor data
113    pub data: Array3<T>,
114
115    /// Tensor dimensions
116    pub shape: (usize, usize, usize),
117}
118
119/// Mixed partial derivatives
120#[derive(Debug, Clone)]
121pub struct MixedPartials<T: Float + Debug + Send + Sync + 'static> {
122    /// Variable indices for mixed partial
123    pub variables: Vec<usize>,
124
125    /// Derivative order for each variable
126    pub orders: Vec<usize>,
127
128    /// Computed value
129    pub value: T,
130
131    /// Computation method used
132    pub method: MixedPartialMethod,
133}
134
135/// Methods for computing mixed partials.
136///
137/// This used to list `ForwardOverReverse`, `ReverseOverForward` and
138/// `PureForward` alongside `FiniteDifference` — but all three delegated, in one
139/// line each, to the finite-difference routine (finding F89). The variants below
140/// are the ones that can genuinely differ behind this engine's black-box
141/// objective type; see [`crate::higher_order::hvp`] for why nested automatic
142/// differentiation cannot.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum MixedPartialMethod {
145    /// Central finite differences: `O(h²)` accurate, evaluates on both sides of
146    /// the base point. The default and the most accurate available method.
147    FiniteDifference,
148
149    /// One-sided *forward* finite differences: `O(h)` accurate, never evaluates
150    /// below the base point in any differentiated coordinate. Use it when the
151    /// objective is undefined or discontinuous on that side.
152    ForwardFiniteDifference,
153
154    /// Nested automatic differentiation. **Not available** behind this engine's
155    /// `Fn(&Array1<T>) -> T` objective — [`Self::is_available`] returns `false`
156    /// and [`HigherOrderEngine::mixed_partial`] returns a typed error naming the
157    /// alternative, instead of silently running a finite difference under an
158    /// autodiff name.
159    NestedAutodiff,
160}
161
162/// Layer information for K-FAC computation
163#[derive(Debug, Clone)]
164pub struct LayerInfo<T: Float + Debug + Send + Sync + 'static> {
165    pub layer_type: LayerType,
166    pub input_size: usize,
167    pub output_size: usize,
168    pub weights: Array2<T>,
169    pub bias: Option<Array1<T>>,
170}
171
172/// Types of neural network layers
173#[derive(Debug, Clone, Copy)]
174pub enum LayerType {
175    Linear,
176    Convolutional,
177    LSTM,
178    Attention,
179}
180
181/// Machine-precision-aware central-difference step for a derivative of the
182/// given `order`.
183///
184/// A central stencil for the `order`-th derivative has truncation error
185/// `O(h²)` and roundoff error `O(ε_mach / h^order)`; balancing the two gives
186/// the optimum `h ≈ ε_mach^{1/(order+2)}`. Deriving the step from
187/// [`Float::epsilon`] adapts it to `T`'s precision (e.g. ~6e-6 for an `f64`
188/// first derivative, ~5e-3 for `f32`), unlike a single hard-coded constant
189/// which is far too small for `f32` and for high-order stencils.
190fn central_step<T: Float>(order: usize) -> T {
191    let eps_mach = T::epsilon();
192    let denom = T::from(order + 2).unwrap_or_else(|| T::from(3usize).unwrap_or_else(T::one));
193    eps_mach.powf(T::one() / denom)
194}
195
196impl<
197        T: Float
198            + Debug
199            + Default
200            + Clone
201            + Send
202            + Sync
203            + 'static
204            + std::iter::Sum
205            + scirs2_core::ndarray::ScalarOperand,
206    > HigherOrderEngine<T>
207{
208    /// Create a new higher-order differentiation engine
209    pub fn new(_maxorder: usize) -> Self {
210        Self {
211            _maxorder,
212            mixed_mode: true,
213            finite_diff_eps: scirs2_core::numeric::NumCast::from(1e-5).unwrap_or_else(|| T::zero()),
214            parallel_computation: true,
215            thread_pool_size: 4, // Conservative default
216            adaptive_sparsity: true,
217            auto_mode_selection: true,
218            profiler: ComputationProfiler::new(),
219        }
220    }
221
222    /// Create a new engine with advanced configuration
223    pub fn with_config(config: HigherOrderConfig<T>) -> Self {
224        Self {
225            _maxorder: config._maxorder,
226            mixed_mode: config.mixed_mode,
227            finite_diff_eps: config.finite_diff_eps,
228            parallel_computation: config.parallel_computation,
229            thread_pool_size: config.thread_pool_size,
230            adaptive_sparsity: config.adaptive_sparsity,
231            auto_mode_selection: config.auto_mode_selection,
232            profiler: ComputationProfiler::new(),
233        }
234    }
235
236    /// Enable/disable mixed-mode computation
237    pub fn set_mixed_mode(&mut self, enabled: bool) {
238        self.mixed_mode = enabled;
239    }
240
241    /// Set finite difference epsilon for numerical verification
242    pub fn set_finite_diff_eps(&mut self, eps: T) {
243        self.finite_diff_eps = eps;
244    }
245
246    /// Central-difference step for a derivative of the given `order`.
247    ///
248    /// Returns the coarser of the machine-precision-optimal step
249    /// ([`central_step`]) and any user-configured `finite_diff_eps`, so an
250    /// explicit override can only *widen* the step, never sharpen it into the
251    /// roundoff-dominated regime.
252    fn fd_step(&self, order: usize) -> T {
253        let machine = central_step::<T>(order);
254        if self.finite_diff_eps > machine {
255            self.finite_diff_eps
256        } else {
257            machine
258        }
259    }
260
261    /// Compute Hessian matrix using forward-over-reverse mode.
262    ///
263    /// When `HessianConfig::sparse` is set and the problem is large enough that
264    /// sparsification pays for itself, entries
265    /// below `HessianConfig::sparsity_threshold` are zeroed. Previously
266    /// `sparse`/`sparsity_threshold` were accepted and ignored: the sparsity
267    /// decision and the thresholding routine both existed but nothing called
268    /// them.
269    ///
270    /// The wall time is recorded on the engine's profiler, so
271    /// [`Self::get_derivative_stats`]'s `performance_profile` reports measured
272    /// numbers rather than the zeros it returned when nothing was ever recorded.
273    pub fn hessian_forward_over_reverse(
274        &mut self,
275        function: impl Fn(&Array1<T>) -> T,
276        point: &Array1<T>,
277        config: &HessianConfig,
278    ) -> Result<Array2<T>> {
279        let n = point.len();
280
281        if config.diagonal_only {
282            return self.hessian_diagonal(&function, point);
283        }
284
285        let started = std::time::Instant::now();
286        let parallel = self.should_use_parallel(n);
287        let sparse = self.should_use_sparse(n, config);
288        let mut hessian = Array2::zeros((n, n));
289
290        // Use forward-over-reverse: compute one row of Hessian at a time
291        for i in 0..n {
292            let grad_fn = |x: &Array1<T>| -> Array1<T> {
293                self.gradient_at_point(&function, x)
294                    .unwrap_or_else(|_| Array1::zeros(n))
295            };
296
297            // Compute directional derivative of gradient
298            let mut direction = Array1::zeros(n);
299            direction[i] = T::one();
300
301            let hessian_row =
302                self.directional_derivative_of_gradient(&grad_fn, point, &direction)?;
303
304            for j in 0..n {
305                hessian[[i, j]] = hessian_row[j];
306            }
307        }
308
309        // Symmetrize if needed
310        if config.exact {
311            for i in 0..n {
312                for j in i + 1..n {
313                    let avg = (hessian[[i, j]] + hessian[[j, i]])
314                        / scirs2_core::numeric::NumCast::from(2.0).unwrap_or_else(|| T::zero());
315                    hessian[[i, j]] = avg;
316                    hessian[[j, i]] = avg;
317                }
318            }
319        }
320
321        // Verify with finite differences if requested
322        if config.verify_with_finite_diff {
323            let fd_hessian = self.finite_difference_hessian(&function, point)?;
324            self.verify_hessian_accuracy(&hessian, &fd_hessian)?;
325        }
326
327        if sparse {
328            hessian = self.apply_adaptive_sparsity(hessian, config.sparsity_threshold)?;
329        }
330        self.profiler
331            .record_hessian_computation(n, started.elapsed(), parallel, sparse);
332
333        Ok(hessian)
334    }
335
336    /// Compute the Hessian **column by column**, by taking the gradient of each
337    /// first partial derivative.
338    ///
339    /// The name is historical — it describes the reverse-over-forward autodiff
340    /// scheme this emulates, not the implementation, which is a nested central
341    /// finite difference (see [`hvp`] for why autodiff cannot be threaded through
342    /// this engine's objective type). Distinct from
343    /// [`Self::hessian_forward_over_reverse`], which works row-wise from a
344    /// directional derivative of the gradient.
345    ///
346    /// Both nested steps use the *order-2* step. With the old raw
347    /// `finite_diff_eps` (`1e-5`) on both levels the roundoff floor was
348    /// `ε/h² ≈ 2e-6` in `f64` and `≈ 1.2e3` in `f32` — i.e. the `f32` result was
349    /// pure noise.
350    pub fn hessian_reverse_over_forward(
351        &mut self,
352        function: impl Fn(&Array1<T>) -> T,
353        point: &Array1<T>,
354        _config: &HessianConfig,
355    ) -> Result<Array2<T>> {
356        let n = point.len();
357        let mut hessian = Array2::zeros((n, n));
358        let inner_step = self.fd_step(2);
359
360        // Use reverse-over-forward: compute one column of Hessian at a time
361        for j in 0..n {
362            // Create a function that computes partial derivative w.r.t. x_j
363            let partial_fn = |x: &Array1<T>| -> T {
364                let mut x_plus = x.clone();
365                let mut x_minus = x.clone();
366
367                x_plus[j] = x_plus[j] + inner_step;
368                x_minus[j] = x_minus[j] - inner_step;
369
370                (function(&x_plus) - function(&x_minus))
371                    / (scirs2_core::numeric::NumCast::from(2.0).unwrap_or_else(|| T::zero())
372                        * inner_step)
373            };
374
375            // Compute gradient of partial derivative
376            let hessian_col = self.gradient_at_point(&partial_fn, point)?;
377
378            for i in 0..n {
379                hessian[[i, j]] = hessian_col[i];
380            }
381        }
382
383        Ok(hessian)
384    }
385
386    /// Compute diagonal Hessian elements only
387    pub fn hessian_diagonal(
388        &self,
389        function: &impl Fn(&Array1<T>) -> T,
390        point: &Array1<T>,
391    ) -> Result<Array2<T>> {
392        let n = point.len();
393        let mut hessian = Array2::zeros((n, n));
394        let h = self.fd_step(2);
395
396        // Compute second derivatives using finite differences
397        for i in 0..n {
398            let mut x_plus = point.clone();
399            let mut x_minus = point.clone();
400
401            x_plus[i] = x_plus[i] + h;
402            x_minus[i] = x_minus[i] - h;
403
404            let f_plus = function(&x_plus);
405            let f_center = function(point);
406            let f_minus = function(&x_minus);
407
408            // Second derivative: f''(x) = (f(x+h) - 2f(x) + f(x-h)) / h^2
409            let second_deriv = (f_plus
410                - scirs2_core::numeric::NumCast::from(2.0).unwrap_or_else(|| T::zero()) * f_center
411                + f_minus)
412                / (h * h);
413
414            hessian[[i, i]] = second_deriv;
415        }
416
417        Ok(hessian)
418    }
419
420    /// Compute sparse Hessian representation
421    pub fn sparse_hessian(
422        &mut self,
423        function: impl Fn(&Array1<T>) -> T,
424        point: &Array1<T>,
425        config: &HessianConfig,
426    ) -> Result<SparseHessian<T>> {
427        let dense_hessian = self.hessian_forward_over_reverse(function, point, config)?;
428
429        let mut rows = Vec::new();
430        let mut cols = Vec::new();
431        let mut values = Vec::new();
432
433        let threshold = scirs2_core::numeric::NumCast::from(config.sparsity_threshold)
434            .unwrap_or_else(|| T::zero());
435
436        for i in 0..dense_hessian.nrows() {
437            for j in 0..dense_hessian.ncols() {
438                let val = dense_hessian[[i, j]];
439                if val.abs() > threshold {
440                    rows.push(i);
441                    cols.push(j);
442                    values.push(val);
443                }
444            }
445        }
446
447        let nnz = values.len();
448        Ok(SparseHessian {
449            rows,
450            cols,
451            values,
452            shape: dense_hessian.dim(),
453            nnz,
454        })
455    }
456
457    /// Compute third-order derivatives (tensor)
458    pub fn third_order_derivatives(
459        &mut self,
460        function: impl Fn(&Array1<T>) -> T,
461        point: &Array1<T>,
462    ) -> Result<ThirdOrderTensor<T>> {
463        let n = point.len();
464        let mut tensor = Array3::zeros((n, n, n));
465
466        // Compute third derivatives using finite differences
467        for i in 0..n {
468            for j in 0..n {
469                for k in 0..n {
470                    let third_deriv = self.compute_third_partial(&function, point, i, j, k)?;
471                    tensor[[i, j, k]] = third_deriv;
472                }
473            }
474        }
475
476        Ok(ThirdOrderTensor {
477            data: tensor.clone(),
478            shape: tensor.dim(),
479        })
480    }
481
482    /// Compute mixed partial derivatives
483    pub fn mixed_partial(
484        &mut self,
485        function: impl Fn(&Array1<T>) -> T,
486        point: &Array1<T>,
487        variables: &[usize],
488        orders: &[usize],
489        method: MixedPartialMethod,
490    ) -> Result<MixedPartials<T>> {
491        if variables.len() != orders.len() {
492            return Err(OptimError::InvalidConfig(
493                "Variables and orders length mismatch".to_string(),
494            ));
495        }
496
497        let value = match method {
498            MixedPartialMethod::FiniteDifference => {
499                self.mixed_partial_finite_difference(&function, point, variables, orders)?
500            }
501            MixedPartialMethod::ForwardFiniteDifference => {
502                self.mixed_partial_forward_difference(&function, point, variables, orders)?
503            }
504            MixedPartialMethod::NestedAutodiff => {
505                return Err(OptimError::InvalidConfig(
506                    hvp::NESTED_AUTODIFF_UNAVAILABLE.to_string(),
507                ))
508            }
509        };
510
511        Ok(MixedPartials {
512            variables: variables.to_vec(),
513            orders: orders.to_vec(),
514            value,
515            method,
516        })
517    }
518
519    /// Compute Hessian-vector product efficiently
520    pub fn hessian_vector_product(
521        &mut self,
522        function: impl Fn(&Array1<T>) -> T,
523        point: &Array1<T>,
524        vector: &Array1<T>,
525    ) -> Result<Array1<T>> {
526        // Use forward-over-reverse mode for efficient Hv computation
527        let grad_fn = |x: &Array1<T>| -> Array1<T> {
528            self.gradient_at_point(&function, x)
529                .unwrap_or_else(|_| Array1::zeros(x.len()))
530        };
531
532        self.directional_derivative_of_gradient(&grad_fn, point, vector)
533    }
534
535    /// Compute vector-Hessian-vector product (quadratic form)
536    pub fn vector_hessian_vector_product(
537        &mut self,
538        function: impl Fn(&Array1<T>) -> T,
539        point: &Array1<T>,
540        vector: &Array1<T>,
541    ) -> Result<T> {
542        let hv = self.hessian_vector_product(function, point, vector)?;
543        Ok(vector.dot(&hv))
544    }
545
546    /// Verify higher-order derivatives using finite differences
547    pub fn verify_derivatives(
548        &self,
549        function: impl Fn(&Array1<T>) -> T,
550        point: &Array1<T>,
551        computed_hessian: &Array2<T>,
552    ) -> Result<DerivativeVerification<T>> {
553        let fd_hessian = self.finite_difference_hessian(&function, point)?;
554
555        let max_error = self.compute_max_error(computed_hessian, &fd_hessian);
556        let avg_error = self.compute_avg_error(computed_hessian, &fd_hessian);
557        let relative_error = self.compute_relative_error(computed_hessian, &fd_hessian);
558
559        let is_accurate =
560            max_error < scirs2_core::numeric::NumCast::from(1e-4).unwrap_or_else(|| T::zero());
561
562        Ok(DerivativeVerification {
563            max_absolute_error: max_error,
564            avg_absolute_error: avg_error,
565            max_relative_error: relative_error,
566            is_accurate,
567            finite_diff_hessian: fd_hessian,
568        })
569    }
570
571    /// Get higher-order derivative statistics
572    pub fn get_derivative_stats(&self) -> HigherOrderStats {
573        HigherOrderStats {
574            _maxorder: self._maxorder,
575            mixed_mode_enabled: self.mixed_mode,
576            memory_usage_estimate: self.estimate_memory_usage(),
577            parallel_computation: self.parallel_computation,
578            thread_pool_size: self.thread_pool_size,
579            adaptive_sparsity: self.adaptive_sparsity,
580            auto_mode_selection: self.auto_mode_selection,
581            performance_profile: self.profiler.get_summary(),
582        }
583    }
584
585    /// Advanced Hessian-vector product with automatic mode selection
586    pub fn hessian_vector_product_advanced(
587        &mut self,
588        function: impl Fn(&Array1<T>) -> T + Send + Sync,
589        point: &Array1<T>,
590        vector: &Array1<T>,
591        mode: Option<HvpMode>,
592    ) -> Result<Array1<T>> {
593        let n = point.len();
594        let selected_mode = mode.unwrap_or_else(|| self.select_optimal_hvp_mode(n));
595
596        let started = std::time::Instant::now();
597        let product = match selected_mode {
598            HvpMode::CentralDifference => self.hvp_central_difference(&function, point, vector),
599            HvpMode::ForwardDifference => self.hvp_forward_difference(&function, point, vector),
600            HvpMode::QuadraticSecant => self.hvp_quadratic_secant(&function, point, vector),
601            HvpMode::MaterializedHessian => self.hvp_materialized_hessian(&function, point, vector),
602            HvpMode::NestedAutodiff => Err(OptimError::InvalidConfig(
603                hvp::NESTED_AUTODIFF_UNAVAILABLE.to_string(),
604            )),
605        }?;
606        // Only successful products are timed: an unavailable mode returns
607        // immediately and its duration says nothing about the cost of the work.
608        self.profiler.record_hvp_computation(n, started.elapsed());
609        Ok(product)
610    }
611
612    /// Compute vector-Hessian-vector efficiently
613    pub fn vector_hessian_vector_efficient(
614        &mut self,
615        function: impl Fn(&Array1<T>) -> T,
616        point: &Array1<T>,
617        vector: &Array1<T>,
618    ) -> Result<T> {
619        // Use forward-mode to compute directional derivative of gradient
620        let grad_fn = |x: &Array1<T>| -> Array1<T> {
621            self.gradient_at_point(&function, x)
622                .unwrap_or_else(|_| Array1::zeros(x.len()))
623        };
624
625        let directional_grad = self.directional_derivative_of_gradient(&grad_fn, point, vector)?;
626        Ok(vector.dot(&directional_grad))
627    }
628
629    /// Compute Jacobian efficiently with automatic mode selection
630    pub fn jacobian_efficient<F>(
631        &mut self,
632        function: F,
633        point: &Array1<T>,
634        output_dim: usize,
635    ) -> Result<Array2<T>>
636    where
637        F: Fn(&Array1<T>) -> Array1<T> + Send + Sync,
638    {
639        let input_dim = point.len();
640
641        let started = std::time::Instant::now();
642        // Select mode based on dimensions
643        let jacobian = if input_dim <= output_dim {
644            // Forward mode is more efficient
645            self.jacobian_forward_mode(&function, point, output_dim)?
646        } else {
647            // Reverse mode is more efficient
648            self.jacobian_reverse_mode(&function, point, output_dim)?
649        };
650        self.profiler
651            .record_jacobian_computation(input_dim, started.elapsed());
652        Ok(jacobian)
653    }
654
655    /// Compute K-FAC approximation to the Hessian
656    pub fn kfac_hessian_approximation(
657        &mut self,
658        layers: &[LayerInfo<T>],
659        activations: &[Array1<T>],
660        gradients: &[Array1<T>],
661    ) -> Result<Array2<T>> {
662        // The three slices are parallel arrays indexed by layer. Indexing them
663        // with the layer index without checking used to panic (out of bounds) on
664        // any caller that passed a short activation or gradient list.
665        if activations.len() != layers.len() || gradients.len() != layers.len() {
666            return Err(OptimError::InvalidConfig(format!(
667                "K-FAC needs one activation and one gradient per layer: {} layers, \
668                 {} activations, {} gradients",
669                layers.len(),
670                activations.len(),
671                gradients.len()
672            )));
673        }
674        if layers.is_empty() {
675            return Err(OptimError::InvalidConfig(
676                "K-FAC needs at least one layer".to_string(),
677            ));
678        }
679
680        let mut kfac_blocks = Vec::with_capacity(layers.len());
681
682        for (i, layer) in layers.iter().enumerate() {
683            let activation = &activations[i];
684            let gradient = &gradients[i];
685
686            // K-FAC factorizes the layer's Fisher block as `A ⊗ G` with
687            // `A = E[a aᵀ]` over the layer *inputs* and `G = E[g gᵀ]` over the
688            // layer *output* pre-activation gradients. So `activation` must be
689            // `input_size` long and `gradient` `output_size` long. Without this
690            // check a caller that swapped or mis-sized the two slices still got
691            // a plausible-looking matrix back — a silently wrong curvature
692            // estimate — because the factor builders just square whatever
693            // length they are handed.
694            if activation.len() != layer.input_size {
695                return Err(OptimError::InvalidConfig(format!(
696                    "K-FAC layer {i} ({:?}) declares input_size {} but its activation \
697                     vector has {} entries",
698                    layer.layer_type,
699                    layer.input_size,
700                    activation.len()
701                )));
702            }
703            if gradient.len() != layer.output_size {
704                return Err(OptimError::InvalidConfig(format!(
705                    "K-FAC layer {i} ({:?}) declares output_size {} but its gradient \
706                     vector has {} entries",
707                    layer.layer_type,
708                    layer.output_size,
709                    gradient.len()
710                )));
711            }
712
713            // Compute Kronecker factors
714            let factor_a = self.compute_activation_factor(activation)?;
715            let factor_g = self.compute_gradient_factor(gradient)?;
716
717            // K-FAC block is Kronecker product approximation
718            let block = self.kronecker_product_approximation(&factor_a, &factor_g)?;
719            kfac_blocks.push(block);
720        }
721
722        // Combine blocks into full approximation
723        self.combine_kfac_blocks(&kfac_blocks)
724    }
725
726    /// Compute Natural Gradient using Fisher Information Matrix
727    pub fn natural_gradient(
728        &mut self,
729        log_likelihood: impl Fn(&Array1<T>) -> T,
730        point: &Array1<T>,
731        gradient: &Array1<T>,
732    ) -> Result<Array1<T>> {
733        // Compute Fisher Information Matrix (FIM)
734        let fim = self.compute_fisher_information_matrix(&log_likelihood, point)?;
735
736        // Natural gradient = FIM^(-1) * gradient
737        self.solve_linear_system(&fim, gradient)
738    }
739
740    /// Compute truncated Newton direction
741    pub fn truncated_newton_direction(
742        &mut self,
743        function: impl Fn(&Array1<T>) -> T + Send + Sync,
744        point: &Array1<T>,
745        gradient: &Array1<T>,
746        max_cg_iterations: usize,
747        cg_tolerance: T,
748    ) -> Result<Array1<T>> {
749        // Use Conjugate Gradient to approximately solve Hx = -g
750        let neg_gradient = gradient.mapv(|x| -x);
751
752        // Create a copy of point and function to avoid borrow conflicts
753        let point_copy = point.clone();
754        let function_copy = function;
755
756        // `Hv` by a central difference of the gradient *along `v`*: two gradient
757        // evaluations, i.e. `O(n)` objective calls.
758        //
759        // This used to materialize the whole Hessian one column at a time inside
760        // every CG iteration — `n` gradient pairs, so `O(n²)` objective calls per
761        // `Hv` and `O(n³)` for the solve — to compute a quantity that a single
762        // directional difference gives exactly as accurately. On a 1000-parameter
763        // problem that is ~4·10⁶ objective evaluations per CG step.
764        let hvp_fn = move |v: &Array1<T>| -> Result<Array1<T>> {
765            if v.len() != point_copy.len() {
766                return Err(OptimError::InvalidConfig(format!(
767                    "CG direction has length {} but the point has {}",
768                    v.len(),
769                    point_copy.len()
770                )));
771            }
772            // Keep the displacement `h·v` at the intended scale even when `v` is
773            // large; CG residuals are not normalized.
774            let scale = v.iter().fold(T::one(), |acc, value| {
775                let magnitude = value.abs();
776                if magnitude > acc {
777                    magnitude
778                } else {
779                    acc
780                }
781            });
782            let eps = central_step::<T>(2) / scale;
783            let two: T = scirs2_core::numeric::NumCast::from(2.0).unwrap_or_else(|| T::one());
784
785            let grad_plus =
786                Self::finite_diff_gradient(&function_copy, &(&point_copy + &(v * eps)))?;
787            let grad_minus =
788                Self::finite_diff_gradient(&function_copy, &(&point_copy - &(v * eps)))?;
789
790            Ok((grad_plus - grad_minus) / (two * eps))
791        };
792
793        self.conjugate_gradient_solve(hvp_fn, &neg_gradient, max_cg_iterations, cg_tolerance)
794    }
795
796    /// Helper method for finite difference gradient computation
797    fn finite_diff_gradient<F>(function: &F, point: &Array1<T>) -> Result<Array1<T>>
798    where
799        F: Fn(&Array1<T>) -> T,
800    {
801        let eps = central_step::<T>(1);
802        let mut gradient = Array1::zeros(point.len());
803
804        for i in 0..point.len() {
805            let mut point_plus = point.clone();
806            let mut point_minus = point.clone();
807
808            point_plus[i] = point_plus[i] + eps;
809            point_minus[i] = point_minus[i] - eps;
810
811            let loss_plus = function(&point_plus);
812            let loss_minus = function(&point_minus);
813
814            gradient[i] = (loss_plus - loss_minus)
815                / (scirs2_core::numeric::NumCast::from(2.0).unwrap_or_else(|| T::zero()) * eps);
816        }
817
818        Ok(gradient)
819    }
820
821    // Helper methods
822
823    fn gradient_at_point(
824        &self,
825        function: &impl Fn(&Array1<T>) -> T,
826        point: &Array1<T>,
827    ) -> Result<Array1<T>> {
828        let n = point.len();
829        let mut gradient = Array1::zeros(n);
830        let h = self.fd_step(1);
831
832        for i in 0..n {
833            let mut x_plus = point.clone();
834            let mut x_minus = point.clone();
835
836            x_plus[i] = x_plus[i] + h;
837            x_minus[i] = x_minus[i] - h;
838
839            gradient[i] = (function(&x_plus) - function(&x_minus))
840                / (scirs2_core::numeric::NumCast::from(2.0).unwrap_or_else(|| T::zero()) * h);
841        }
842
843        Ok(gradient)
844    }
845
846    fn directional_derivative_of_gradient(
847        &self,
848        grad_fn: &impl Fn(&Array1<T>) -> Array1<T>,
849        point: &Array1<T>,
850        direction: &Array1<T>,
851    ) -> Result<Array1<T>> {
852        // Differencing the gradient approximates a second-order quantity.
853        let eps = self.fd_step(2);
854        let point_plus = point + &(direction * eps);
855        let point_minus = point - &(direction * eps);
856
857        let grad_plus = grad_fn(&point_plus);
858        let grad_minus = grad_fn(&point_minus);
859
860        Ok((grad_plus - grad_minus)
861            / (scirs2_core::numeric::NumCast::from(2.0).unwrap_or_else(|| T::zero()) * eps))
862    }
863
864    fn finite_difference_hessian(
865        &self,
866        function: &impl Fn(&Array1<T>) -> T,
867        point: &Array1<T>,
868    ) -> Result<Array2<T>> {
869        let n = point.len();
870        let mut hessian = Array2::zeros((n, n));
871        // Second-order stencils need the order-2 step. The raw `finite_diff_eps`
872        // default of 1e-5 that used to be inlined here divides by `h² = 1e-10`,
873        // putting the roundoff floor at `ε/h²` — ~2e-6 in `f64` and ~1.2e3 in
874        // `f32`, where the "Hessian" was noise.
875        let eps = self.fd_step(2);
876        // `f(x)` does not depend on `i`; it used to be re-evaluated once per
877        // diagonal entry.
878        let f_center = function(point);
879
880        for i in 0..n {
881            for j in i..n {
882                // Only compute upper triangle due to symmetry
883                let second_deriv = if i == j {
884                    // Diagonal element: f''_ii
885                    let mut x_plus = point.clone();
886                    let mut x_minus = point.clone();
887
888                    x_plus[i] = x_plus[i] + eps;
889                    x_minus[i] = x_minus[i] - eps;
890
891                    let f_plus = function(&x_plus);
892                    let f_minus = function(&x_minus);
893
894                    (f_plus
895                        - scirs2_core::numeric::NumCast::from(2.0).unwrap_or_else(|| T::zero())
896                            * f_center
897                        + f_minus)
898                        / (eps * eps)
899                } else {
900                    // Off-diagonal element: f''_ij
901                    let mut x_pp = point.clone();
902                    x_pp[i] = x_pp[i] + eps;
903                    x_pp[j] = x_pp[j] + eps;
904
905                    let mut x_pm = point.clone();
906                    x_pm[i] = x_pm[i] + eps;
907                    x_pm[j] = x_pm[j] - eps;
908
909                    let mut x_mp = point.clone();
910                    x_mp[i] = x_mp[i] - eps;
911                    x_mp[j] = x_mp[j] + eps;
912
913                    let mut x_mm = point.clone();
914                    x_mm[i] = x_mm[i] - eps;
915                    x_mm[j] = x_mm[j] - eps;
916
917                    (function(&x_pp) - function(&x_pm) - function(&x_mp) + function(&x_mm))
918                        / (scirs2_core::numeric::NumCast::from(4.0).unwrap_or_else(|| T::zero())
919                            * eps
920                            * eps)
921                };
922
923                hessian[[i, j]] = second_deriv;
924                hessian[[j, i]] = second_deriv; // Symmetry
925            }
926        }
927
928        Ok(hessian)
929    }
930
931    /// Third partial derivative ∂³f/∂xᵢ∂xⱼ∂xₖ by central finite differences.
932    ///
933    /// The correct stencil depends on how many of `(i, j, k)` coincide, so
934    /// three cases are handled separately (all previously collapsed into one
935    /// wrong two-point formula that was off by `~1/h²`):
936    ///
937    /// * all equal — pure ∂³/∂xᵢ³:
938    ///   `[f(x+2h) − 2f(x+h) + 2f(x−h) − f(x−2h)] / (2h³)`.
939    /// * all distinct — fully mixed:
940    ///   `Σ_{s∈{±1}³} sᵢsⱼsₖ · f(x + h(sᵢeᵢ+sⱼeⱼ+sₖeₖ)) / (8h³)`.
941    /// * exactly two equal — semi-mixed ∂³/∂x_r²∂x_s:
942    ///   a central second difference in `r` differenced once in `s`.
943    fn compute_third_partial(
944        &self,
945        function: &impl Fn(&Array1<T>) -> T,
946        point: &Array1<T>,
947        i: usize,
948        j: usize,
949        k: usize,
950    ) -> Result<T> {
951        let n = point.len();
952        if i >= n || j >= n || k >= n {
953            return Err(OptimError::InvalidConfig(
954                "third-derivative index out of range".to_string(),
955            ));
956        }
957        let h = self.fd_step(3);
958        let two: T = scirs2_core::numeric::NumCast::from(2.0).unwrap_or_else(|| T::zero());
959        let eight: T = scirs2_core::numeric::NumCast::from(8.0).unwrap_or_else(|| T::zero());
960        let h3 = h * h * h;
961
962        // Evaluate `f` at `point` shifted by integer multiples of `h`.
963        let shifted = |shifts: &[(usize, i32)]| -> T {
964            let mut x = point.clone();
965            for &(coord, mult) in shifts {
966                let step: T =
967                    scirs2_core::numeric::NumCast::from(mult).unwrap_or_else(|| T::zero());
968                x[coord] = x[coord] + step * h;
969            }
970            function(&x)
971        };
972
973        let all_same = i == j && j == k;
974        let all_distinct = i != j && j != k && i != k;
975
976        let value = if all_same {
977            let a = shifted(&[(i, 2)]);
978            let b = shifted(&[(i, 1)]);
979            let c = shifted(&[(i, -1)]);
980            let d = shifted(&[(i, -2)]);
981            (a - two * b + two * c - d) / (two * h3)
982        } else if all_distinct {
983            let mut acc = T::zero();
984            for &si in &[1i32, -1] {
985                for &sj in &[1i32, -1] {
986                    for &sk in &[1i32, -1] {
987                        let sign: T = scirs2_core::numeric::NumCast::from(si * sj * sk)
988                            .unwrap_or_else(|| T::zero());
989                        acc = acc + sign * shifted(&[(i, si), (j, sj), (k, sk)]);
990                    }
991                }
992            }
993            acc / (eight * h3)
994        } else {
995            // Exactly two coincide: `r` is the repeated index, `s` the distinct.
996            let (r, s) = if i == j {
997                (i, k)
998            } else if i == k {
999                (i, j)
1000            } else {
1001                (j, i)
1002            };
1003            let d2_plus =
1004                shifted(&[(r, 1), (s, 1)]) - two * shifted(&[(s, 1)]) + shifted(&[(r, -1), (s, 1)]);
1005            let d2_minus = shifted(&[(r, 1), (s, -1)]) - two * shifted(&[(s, -1)])
1006                + shifted(&[(r, -1), (s, -1)]);
1007            (d2_plus - d2_minus) / (two * h3)
1008        };
1009
1010        Ok(value)
1011    }
1012
1013    /// Mixed partial derivative by central finite differences, supporting every
1014    /// combination with `total_order ≤ 3` (previously only the two-variable
1015    /// second order was handled and all other cases silently returned `0`).
1016    ///
1017    /// `(variables, orders)` is expanded into a flat multi-index; order-1 and
1018    /// order-2 cases use their own central stencils and order-3 delegates to
1019    /// [`Self::compute_third_partial`], which picks the right stencil for the
1020    /// index-coincidence pattern.
1021    fn mixed_partial_finite_difference(
1022        &self,
1023        function: &impl Fn(&Array1<T>) -> T,
1024        point: &Array1<T>,
1025        variables: &[usize],
1026        orders: &[usize],
1027    ) -> Result<T> {
1028        let total_order: usize = orders.iter().sum();
1029        if total_order > 3 {
1030            return Err(OptimError::InvalidConfig(
1031                "Mixed partial order too high (finite differences support order <= 3)".to_string(),
1032            ));
1033        }
1034        let n = point.len();
1035        for &v in variables {
1036            if v >= n {
1037                return Err(OptimError::InvalidConfig(
1038                    "mixed-partial variable index out of range".to_string(),
1039                ));
1040            }
1041        }
1042
1043        // Expand into a flat list that repeats each variable `order` times.
1044        let mut flat: Vec<usize> = Vec::with_capacity(total_order);
1045        for (&v, &o) in variables.iter().zip(orders.iter()) {
1046            for _ in 0..o {
1047                flat.push(v);
1048            }
1049        }
1050
1051        let two: T = scirs2_core::numeric::NumCast::from(2.0).unwrap_or_else(|| T::zero());
1052
1053        match flat.as_slice() {
1054            [] => Ok(function(point)),
1055            &[i] => {
1056                let h = self.fd_step(1);
1057                let mut xp = point.clone();
1058                let mut xm = point.clone();
1059                xp[i] = xp[i] + h;
1060                xm[i] = xm[i] - h;
1061                Ok((function(&xp) - function(&xm)) / (two * h))
1062            }
1063            &[i, j] => {
1064                let h = self.fd_step(2);
1065                if i == j {
1066                    // ∂²f/∂xᵢ² central second difference.
1067                    let mut xp = point.clone();
1068                    let mut xm = point.clone();
1069                    xp[i] = xp[i] + h;
1070                    xm[i] = xm[i] - h;
1071                    Ok((function(&xp) - two * function(point) + function(&xm)) / (h * h))
1072                } else {
1073                    // ∂²f/∂xᵢ∂xⱼ four-point stencil.
1074                    let four: T =
1075                        scirs2_core::numeric::NumCast::from(4.0).unwrap_or_else(|| T::zero());
1076                    let mut x_pp = point.clone();
1077                    x_pp[i] = x_pp[i] + h;
1078                    x_pp[j] = x_pp[j] + h;
1079                    let mut x_pm = point.clone();
1080                    x_pm[i] = x_pm[i] + h;
1081                    x_pm[j] = x_pm[j] - h;
1082                    let mut x_mp = point.clone();
1083                    x_mp[i] = x_mp[i] - h;
1084                    x_mp[j] = x_mp[j] + h;
1085                    let mut x_mm = point.clone();
1086                    x_mm[i] = x_mm[i] - h;
1087                    x_mm[j] = x_mm[j] - h;
1088                    Ok(
1089                        (function(&x_pp) - function(&x_pm) - function(&x_mp) + function(&x_mm))
1090                            / (four * h * h),
1091                    )
1092                }
1093            }
1094            &[i, j, k] => self.compute_third_partial(function, point, i, j, k),
1095            _ => Err(OptimError::InvalidConfig(
1096                "unsupported mixed-partial structure".to_string(),
1097            )),
1098        }
1099    }
1100
1101    fn verify_hessian_accuracy(&self, computed: &Array2<T>, reference: &Array2<T>) -> Result<()> {
1102        let max_error = self.compute_max_error(computed, reference);
1103        let threshold = scirs2_core::numeric::NumCast::from(1e-3).unwrap_or_else(|| T::zero());
1104
1105        if max_error > threshold {
1106            return Err(OptimError::InvalidConfig(format!(
1107                "Hessian verification failed: max error {}",
1108                max_error.to_f64().unwrap_or(0.0)
1109            )));
1110        }
1111
1112        Ok(())
1113    }
1114
1115    fn compute_max_error(&self, a: &Array2<T>, b: &Array2<T>) -> T {
1116        a.iter()
1117            .zip(b.iter())
1118            .map(|(&x, &y)| (x - y).abs())
1119            .fold(T::zero(), |acc, x| if x > acc { x } else { acc })
1120    }
1121
1122    fn compute_avg_error(&self, a: &Array2<T>, b: &Array2<T>) -> T {
1123        let sum = a
1124            .iter()
1125            .zip(b.iter())
1126            .map(|(&x, &y)| (x - y).abs())
1127            .sum::<T>();
1128
1129        let count = scirs2_core::numeric::NumCast::from(a.len()).unwrap_or_else(T::one);
1130        sum / count
1131    }
1132
1133    fn compute_relative_error(&self, a: &Array2<T>, b: &Array2<T>) -> T {
1134        a.iter()
1135            .zip(b.iter())
1136            .map(|(&x, &y)| {
1137                if y.abs() > scirs2_core::numeric::NumCast::from(1e-12).unwrap_or_else(|| T::zero())
1138                {
1139                    ((x - y) / y).abs()
1140                } else {
1141                    (x - y).abs()
1142                }
1143            })
1144            .fold(T::zero(), |acc, x| if x > acc { x } else { acc })
1145    }
1146
1147    /// Rough resident-size estimate: the engine struct itself plus the timing
1148    /// records the profiler has accumulated.
1149    fn estimate_memory_usage(&self) -> usize {
1150        let engine_size = std::mem::size_of::<Self>();
1151        let profiler_size =
1152            self.profiler.recorded_timings() * std::mem::size_of::<ComputationTiming>();
1153
1154        engine_size + profiler_size
1155    }
1156
1157    /// Decide whether to use parallel computation
1158    fn should_use_parallel(&self, problemsize: usize) -> bool {
1159        self.parallel_computation && problemsize >= 50
1160    }
1161
1162    /// Decide whether to use sparse computations
1163    fn should_use_sparse(&self, problemsize: usize, config: &HessianConfig) -> bool {
1164        self.adaptive_sparsity && config.sparse && problemsize >= 100
1165    }
1166
1167    /// Select an HVP mode from the problem size.
1168    ///
1169    /// Only two of the available modes are ever auto-selected, because only two
1170    /// are unconditionally correct: the central difference (the accurate default)
1171    /// and, for a problem small enough that `O(n²)` is free, the materialized
1172    /// Hessian, whose result is reusable across directions. The forward
1173    /// difference is opt-in (it trades accuracy for one-sidedness) and the
1174    /// quadratic secant is opt-in (it is only sound on a quadratic model), so
1175    /// neither may be chosen on the caller's behalf.
1176    ///
1177    /// Previously this returned `ForwardOverReverse` / `ReverseOverForward` /
1178    /// `FiniteDifference` — three names for one identical finite difference — so
1179    /// the "selection" changed nothing at all.
1180    fn select_optimal_hvp_mode(&self, problemsize: usize) -> HvpMode {
1181        if !self.auto_mode_selection {
1182            return HvpMode::CentralDifference;
1183        }
1184
1185        match problemsize {
1186            0..=8 => HvpMode::MaterializedHessian,
1187            _ => HvpMode::CentralDifference,
1188        }
1189    }
1190
1191    /// Apply adaptive sparsity to dense matrix
1192    fn apply_adaptive_sparsity(&self, mut matrix: Array2<T>, threshold: f64) -> Result<Array2<T>> {
1193        let sparsity_threshold =
1194            scirs2_core::numeric::NumCast::from(threshold).unwrap_or_else(|| T::zero());
1195
1196        for elem in matrix.iter_mut() {
1197            if elem.abs() < sparsity_threshold {
1198                *elem = T::zero();
1199            }
1200        }
1201
1202        Ok(matrix)
1203    }
1204
1205    /// Jacobian **column by column**, one central difference per input.
1206    ///
1207    /// Cheaper than [`Self::jacobian_reverse_mode`] when `input_dim <=
1208    /// output_dim`, which is exactly when [`Self::jacobian_efficient`] picks it.
1209    /// It used to differ from the row-wise routine in accuracy as well as cost —
1210    /// a *one-sided* step, so `jacobian_efficient` silently returned a
1211    /// first-order answer for tall problems and a second-order one for wide
1212    /// ones. Both are central now, so the choice is purely about cost.
1213    ///
1214    /// (It also re-evaluated `function(point)` once per input column; that base
1215    /// evaluation is gone entirely with the central stencil.)
1216    fn jacobian_forward_mode<F>(
1217        &mut self,
1218        function: &F,
1219        point: &Array1<T>,
1220        output_dim: usize,
1221    ) -> Result<Array2<T>>
1222    where
1223        F: Fn(&Array1<T>) -> Array1<T>,
1224    {
1225        let input_dim = point.len();
1226        let mut jacobian = Array2::zeros((output_dim, input_dim));
1227        let eps = self.fd_step(1);
1228        let two: T = scirs2_core::numeric::NumCast::from(2.0).unwrap_or_else(|| T::one());
1229
1230        for j in 0..input_dim {
1231            let mut point_plus = point.clone();
1232            let mut point_minus = point.clone();
1233            point_plus[j] = point_plus[j] + eps;
1234            point_minus[j] = point_minus[j] - eps;
1235
1236            let f_plus = function(&point_plus);
1237            let f_minus = function(&point_minus);
1238            if f_plus.len() < output_dim || f_minus.len() < output_dim {
1239                return Err(OptimError::InvalidConfig(format!(
1240                    "function returned {} outputs, expected at least {output_dim}",
1241                    f_plus.len().min(f_minus.len())
1242                )));
1243            }
1244
1245            let column = (f_plus - f_minus) / (two * eps);
1246
1247            for i in 0..output_dim {
1248                jacobian[[i, j]] = column[i];
1249            }
1250        }
1251
1252        Ok(jacobian)
1253    }
1254
1255    /// Reverse-mode Jacobian computation
1256    fn jacobian_reverse_mode<F>(
1257        &mut self,
1258        function: &F,
1259        point: &Array1<T>,
1260        output_dim: usize,
1261    ) -> Result<Array2<T>>
1262    where
1263        F: Fn(&Array1<T>) -> Array1<T>,
1264    {
1265        let input_dim = point.len();
1266        let mut jacobian = Array2::zeros((output_dim, input_dim));
1267
1268        // Compute Jacobian row by row using reverse mode
1269        for i in 0..output_dim {
1270            // Create scalar function for i-th output
1271            let scalar_fn = |x: &Array1<T>| function(x)[i];
1272
1273            // Compute gradient of scalar function
1274            let grad = self.gradient_at_point(&scalar_fn, point)?;
1275
1276            for j in 0..input_dim {
1277                jacobian[[i, j]] = grad[j];
1278            }
1279        }
1280
1281        Ok(jacobian)
1282    }
1283
1284    /// K-FAC helper methods
1285    fn compute_activation_factor(&self, activation: &Array1<T>) -> Result<Array2<T>> {
1286        let n = activation.len();
1287        let mut factor = Array2::zeros((n, n));
1288
1289        // A = E[a a^T] where a is the activation
1290        for i in 0..n {
1291            for j in 0..n {
1292                factor[[i, j]] = activation[i] * activation[j];
1293            }
1294        }
1295
1296        Ok(factor)
1297    }
1298
1299    fn compute_gradient_factor(&self, gradient: &Array1<T>) -> Result<Array2<T>> {
1300        let n = gradient.len();
1301        let mut factor = Array2::zeros((n, n));
1302
1303        // G = E[g g^T] where g is the gradient
1304        for i in 0..n {
1305            for j in 0..n {
1306                factor[[i, j]] = gradient[i] * gradient[j];
1307            }
1308        }
1309
1310        Ok(factor)
1311    }
1312
1313    fn kronecker_product_approximation(&self, a: &Array2<T>, g: &Array2<T>) -> Result<Array2<T>> {
1314        // Simplified Kronecker product approximation
1315        // In practice, this would be more sophisticated
1316        let n_a = a.nrows();
1317        let n_g = g.nrows();
1318        let n_total = n_a * n_g;
1319
1320        let mut result = Array2::zeros((n_total, n_total));
1321
1322        for i in 0..n_a {
1323            for j in 0..n_a {
1324                for k in 0..n_g {
1325                    for l in 0..n_g {
1326                        let row = i * n_g + k;
1327                        let col = j * n_g + l;
1328                        result[[row, col]] = a[[i, j]] * g[[k, l]];
1329                    }
1330                }
1331            }
1332        }
1333
1334        Ok(result)
1335    }
1336
1337    fn combine_kfac_blocks(&self, blocks: &[Array2<T>]) -> Result<Array2<T>> {
1338        if blocks.is_empty() {
1339            return Err(OptimError::InvalidConfig("Empty K-FAC blocks".to_string()));
1340        }
1341
1342        // Simplified block combination - would be more sophisticated in practice
1343        let total_size: usize = blocks.iter().map(|b| b.nrows()).sum();
1344        let mut combined = Array2::zeros((total_size, total_size));
1345
1346        let mut row_offset = 0;
1347        let mut col_offset = 0;
1348
1349        for block in blocks {
1350            let block_size = block.nrows();
1351
1352            for i in 0..block_size {
1353                for j in 0..block_size {
1354                    combined[[row_offset + i, col_offset + j]] = block[[i, j]];
1355                }
1356            }
1357
1358            row_offset += block_size;
1359            col_offset += block_size;
1360        }
1361
1362        Ok(combined)
1363    }
1364
1365    fn compute_fisher_information_matrix(
1366        &mut self,
1367        log_likelihood: &impl Fn(&Array1<T>) -> T,
1368        point: &Array1<T>,
1369    ) -> Result<Array2<T>> {
1370        // Fisher Information Matrix: F = E[∇log p(x) ∇log p(x)^T]
1371        // Approximated as F ≈ -H[log p(x)] (for exponential family)
1372        self.hessian_forward_over_reverse(log_likelihood, point, &HessianConfig::default())
1373    }
1374
1375    /// Solve `A x = rhs` for a symmetric positive-(semi)definite `A` (e.g. a
1376    /// Fisher information matrix) using Conjugate Gradient.
1377    ///
1378    /// A small Tikhonov ridge proportional to the mean absolute diagonal keeps
1379    /// the operator strictly positive definite despite finite-difference noise,
1380    /// which replaces the previous diagonal-only approximation that solved the
1381    /// system correctly only when `A` was already diagonal.
1382    fn solve_linear_system(&self, matrix: &Array2<T>, rhs: &Array1<T>) -> Result<Array1<T>> {
1383        let n = matrix.nrows();
1384        if n != rhs.len() || n != matrix.ncols() {
1385            return Err(OptimError::InvalidConfig(
1386                "Matrix dimension mismatch".to_string(),
1387            ));
1388        }
1389        if n == 0 {
1390            return Ok(Array1::zeros(0));
1391        }
1392
1393        let mut diag_sum = T::zero();
1394        for i in 0..n {
1395            diag_sum = diag_sum + matrix[[i, i]].abs();
1396        }
1397        let n_t: T = scirs2_core::numeric::NumCast::from(n).unwrap_or_else(|| T::one());
1398        let ridge_scale: T = scirs2_core::numeric::NumCast::from(1e-8).unwrap_or_else(|| T::zero());
1399        let ridge_floor: T =
1400            scirs2_core::numeric::NumCast::from(1e-12).unwrap_or_else(|| T::zero());
1401        let ridge = (diag_sum / n_t) * ridge_scale + ridge_floor;
1402
1403        let hvp = |v: &Array1<T>| -> Result<Array1<T>> { Ok(matrix.dot(v) + &(v * ridge)) };
1404
1405        let tolerance: T = scirs2_core::numeric::NumCast::from(1e-10).unwrap_or_else(|| T::zero());
1406        self.conjugate_gradient_solve(hvp, rhs, 2 * n + 50, tolerance)
1407    }
1408
1409    fn conjugate_gradient_solve<F>(
1410        &self,
1411        mut hvp_fn: F,
1412        rhs: &Array1<T>,
1413        max_iterations: usize,
1414        tolerance: T,
1415    ) -> Result<Array1<T>>
1416    where
1417        F: FnMut(&Array1<T>) -> Result<Array1<T>>,
1418    {
1419        let n = rhs.len();
1420        let mut x = Array1::zeros(n);
1421        let mut r = rhs.clone();
1422        let mut p = r.clone();
1423        let mut rsold = r.dot(&r);
1424
1425        for _iter in 0..max_iterations {
1426            let ap = hvp_fn(&p)?;
1427            let denom = p.dot(&ap);
1428            // Guard against a breakdown (curvature ~0) that would divide by zero.
1429            if denom.abs() <= T::epsilon() {
1430                break;
1431            }
1432            let alpha = rsold / denom;
1433
1434            x = x + &p * alpha;
1435            r = r - &ap * alpha;
1436
1437            let rsnew = r.dot(&r);
1438
1439            if rsnew.sqrt() < tolerance {
1440                break;
1441            }
1442
1443            let beta = rsnew / rsold;
1444            p = &r + &p * beta;
1445            rsold = rsnew;
1446        }
1447
1448        Ok(x)
1449    }
1450}
1451
1452/// Derivative verification results
1453#[derive(Debug, Clone)]
1454pub struct DerivativeVerification<T: Float + Debug + Send + Sync + 'static> {
1455    pub max_absolute_error: T,
1456    pub avg_absolute_error: T,
1457    pub max_relative_error: T,
1458    pub is_accurate: bool,
1459    pub finite_diff_hessian: Array2<T>,
1460}
1461
1462/// Higher-order differentiation statistics
1463#[derive(Debug, Clone)]
1464pub struct HigherOrderStats {
1465    pub _maxorder: usize,
1466    pub mixed_mode_enabled: bool,
1467    pub memory_usage_estimate: usize,
1468    pub parallel_computation: bool,
1469    pub thread_pool_size: usize,
1470    pub adaptive_sparsity: bool,
1471    pub auto_mode_selection: bool,
1472    pub performance_profile: PerformanceProfile,
1473}
1474
1475/// Advanced configuration for higher-order engine
1476#[derive(Debug, Clone)]
1477pub struct HigherOrderConfig<T: Float + Debug + Send + Sync + 'static> {
1478    pub _maxorder: usize,
1479    pub mixed_mode: bool,
1480    pub finite_diff_eps: T,
1481    pub parallel_computation: bool,
1482    pub thread_pool_size: usize,
1483    pub adaptive_sparsity: bool,
1484    pub auto_mode_selection: bool,
1485    pub cache_size_limit: usize,
1486}
1487
1488impl<T: Float + Debug + Default + Send + Sync> Default for HigherOrderConfig<T> {
1489    fn default() -> Self {
1490        Self {
1491            _maxorder: 3,
1492            mixed_mode: true,
1493            finite_diff_eps: scirs2_core::numeric::NumCast::from(1e-5).unwrap_or_else(|| T::zero()),
1494            parallel_computation: true,
1495            thread_pool_size: 4, // Conservative default
1496            adaptive_sparsity: true,
1497            auto_mode_selection: true,
1498            cache_size_limit: 10000,
1499        }
1500    }
1501}
1502
1503/// Hessian-vector product computation modes.
1504///
1505/// Every variant below names a genuinely different numerical algorithm with its
1506/// own cost and truncation error — see [`HvpMode::objective_evaluations`] and the
1507/// [`hvp`] module docs. The previous four variants
1508/// (`ForwardOverReverse`/`ReverseOverForward`/`FiniteDifference`/`PearLman`)
1509/// advertised four differentiation schemes over what were, in the source, two
1510/// byte-identical bodies plus a third copy behind a helper (finding F89).
1511#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1512pub enum HvpMode {
1513    /// `Hv ≈ (∇f(x + h·v) − ∇f(x − h·v)) / 2h`. Second-order accurate; the
1514    /// accurate default.
1515    CentralDifference,
1516
1517    /// `Hv ≈ (∇f(x + h·v) − ∇f(x)) / h`. First-order accurate, and never
1518    /// evaluates the objective at `x − h·v` — the mode to use at a domain edge.
1519    ForwardDifference,
1520
1521    /// `Hv = ∇f(x + v) − ∇f(x)`, algebraically exact for a quadratic objective
1522    /// and badly biased for anything else. Needs no step-size choice.
1523    QuadraticSecant,
1524
1525    /// Materialize the finite-difference Hessian and multiply. `O(n²)` objective
1526    /// evaluations; the reference path, and the right choice when one `H` will be
1527    /// applied to many vectors.
1528    MaterializedHessian,
1529
1530    /// Nested automatic differentiation. **Not available** behind this engine's
1531    /// `Fn(&Array1<T>) -> T` objective; the call returns a typed error naming
1532    /// [`crate::forward_mode`]/[`crate::reverse_mode`] instead of quietly
1533    /// running a finite difference. [`HvpMode::is_available`] reports this.
1534    NestedAutodiff,
1535}
1536
1537/// Computation profiler for performance optimization
1538#[derive(Debug, Clone)]
1539pub struct ComputationProfiler<T: Float + Debug + Send + Sync + 'static> {
1540    hessian_timings: Vec<ComputationTiming>,
1541    hvp_timings: Vec<ComputationTiming>,
1542    jacobian_timings: Vec<ComputationTiming>,
1543    total_computations: usize,
1544    _phantom: std::marker::PhantomData<T>,
1545}
1546
1547/// Method tag for a recorded [`ComputationTiming`].
1548const HESSIAN_METHOD: &str = "hessian";
1549/// Method tag for a recorded Hessian-vector-product timing.
1550const HVP_METHOD: &str = "hvp";
1551/// Method tag for a recorded Jacobian timing.
1552const JACOBIAN_METHOD: &str = "jacobian";
1553
1554#[derive(Debug, Clone)]
1555struct ComputationTiming {
1556    problemsize: usize,
1557    duration_us: u64,
1558    parallel: bool,
1559    sparse: bool,
1560    method: &'static str,
1561}
1562
1563/// Measured performance of a [`HigherOrderEngine`], from
1564/// [`HigherOrderEngine::get_derivative_stats`].
1565///
1566/// Every field is derived from timings recorded by the engine's own entry
1567/// points. Before those entry points recorded anything, `avg_hvp_time_us`,
1568/// `avg_jacobian_time_us` and `cache_hit_rate` were hard-coded zeros.
1569#[derive(Debug, Clone)]
1570pub struct PerformanceProfile {
1571    /// Mean wall time of a full Hessian computation, in microseconds.
1572    pub avg_hessian_time_us: f64,
1573    /// Mean wall time of a Hessian-vector product, in microseconds.
1574    pub avg_hvp_time_us: f64,
1575    /// Mean wall time of a Jacobian computation, in microseconds.
1576    pub avg_jacobian_time_us: f64,
1577    /// Mean sequential time / mean parallel time over recorded Hessians; `1.0`
1578    /// when only one of the two regimes has been observed.
1579    pub parallel_efficiency: f64,
1580    /// Mean dense time / mean sparsified time over recorded Hessians; `1.0`
1581    /// when only one of the two regimes has been observed.
1582    pub sparsity_benefit: f64,
1583    /// Number of timings recorded across all three kinds.
1584    pub recorded_computations: usize,
1585    /// Largest problem dimension seen in a recorded Hessian computation.
1586    pub largest_hessian_problem: usize,
1587}
1588
1589impl<T: Float + Debug + Default + Clone + Send + Sync + 'static> ComputationProfiler<T> {
1590    fn new() -> Self {
1591        Self {
1592            hessian_timings: Vec::new(),
1593            hvp_timings: Vec::new(),
1594            jacobian_timings: Vec::new(),
1595            total_computations: 0,
1596            _phantom: std::marker::PhantomData,
1597        }
1598    }
1599
1600    fn record_hessian_computation(
1601        &mut self,
1602        size: usize,
1603        duration: std::time::Duration,
1604        parallel: bool,
1605        sparse: bool,
1606    ) {
1607        self.hessian_timings.push(ComputationTiming {
1608            problemsize: size,
1609            duration_us: duration.as_micros() as u64,
1610            parallel,
1611            sparse,
1612            method: HESSIAN_METHOD,
1613        });
1614        self.total_computations += 1;
1615    }
1616
1617    fn record_hvp_computation(&mut self, size: usize, duration: std::time::Duration) {
1618        self.hvp_timings.push(ComputationTiming {
1619            problemsize: size,
1620            duration_us: duration.as_micros() as u64,
1621            parallel: false,
1622            sparse: false,
1623            method: HVP_METHOD,
1624        });
1625        self.total_computations += 1;
1626    }
1627
1628    fn record_jacobian_computation(&mut self, size: usize, duration: std::time::Duration) {
1629        self.jacobian_timings.push(ComputationTiming {
1630            problemsize: size,
1631            duration_us: duration.as_micros() as u64,
1632            parallel: false,
1633            sparse: false,
1634            method: JACOBIAN_METHOD,
1635        });
1636        self.total_computations += 1;
1637    }
1638
1639    /// Total recorded computations across all three kinds.
1640    fn total_computations(&self) -> usize {
1641        self.total_computations
1642    }
1643
1644    /// Number of individual timing records held.
1645    fn recorded_timings(&self) -> usize {
1646        self.hessian_timings.len() + self.hvp_timings.len() + self.jacobian_timings.len()
1647    }
1648
1649    /// Mean recorded duration, or `0.0` when nothing has been recorded.
1650    fn mean_duration_us(timings: &[ComputationTiming]) -> f64 {
1651        if timings.is_empty() {
1652            return 0.0;
1653        }
1654        timings.iter().map(|t| t.duration_us as f64).sum::<f64>() / timings.len() as f64
1655    }
1656
1657    /// The largest problem size seen for `method`, or `0` when none.
1658    fn largest_problem(&self, method: &'static str) -> usize {
1659        self.hessian_timings
1660            .iter()
1661            .chain(self.hvp_timings.iter())
1662            .chain(self.jacobian_timings.iter())
1663            .filter(|t| t.method == method)
1664            .map(|t| t.problemsize)
1665            .max()
1666            .unwrap_or(0)
1667    }
1668
1669    fn get_summary(&self) -> PerformanceProfile {
1670        PerformanceProfile {
1671            avg_hessian_time_us: Self::mean_duration_us(&self.hessian_timings),
1672            avg_hvp_time_us: Self::mean_duration_us(&self.hvp_timings),
1673            avg_jacobian_time_us: Self::mean_duration_us(&self.jacobian_timings),
1674            parallel_efficiency: self.compute_parallel_efficiency(),
1675            sparsity_benefit: self.compute_sparsity_benefit(),
1676            recorded_computations: self.total_computations(),
1677            largest_hessian_problem: self.largest_problem(HESSIAN_METHOD),
1678        }
1679    }
1680
1681    fn compute_parallel_efficiency(&self) -> f64 {
1682        if self.hessian_timings.is_empty() {
1683            return 1.0;
1684        }
1685
1686        let parallel_times: Vec<_> = self.hessian_timings.iter().filter(|t| t.parallel).collect();
1687        let sequential_times: Vec<_> = self
1688            .hessian_timings
1689            .iter()
1690            .filter(|t| !t.parallel)
1691            .collect();
1692
1693        if parallel_times.is_empty() || sequential_times.is_empty() {
1694            return 1.0;
1695        }
1696
1697        let avg_parallel = parallel_times
1698            .iter()
1699            .map(|t| t.duration_us as f64)
1700            .sum::<f64>()
1701            / parallel_times.len() as f64;
1702        let avg_sequential = sequential_times
1703            .iter()
1704            .map(|t| t.duration_us as f64)
1705            .sum::<f64>()
1706            / sequential_times.len() as f64;
1707
1708        avg_sequential / avg_parallel
1709    }
1710
1711    /// Mean dense time divided by mean sparsified time over recorded Hessians.
1712    ///
1713    /// Symmetric with [`Self::compute_parallel_efficiency`], and `1.0` until both
1714    /// regimes have actually been observed. This used to return a hard-coded
1715    /// `1.2` — a made-up speedup reported through the public
1716    /// [`PerformanceProfile`] as if it had been measured.
1717    fn compute_sparsity_benefit(&self) -> f64 {
1718        let sparse = Self::mean_duration_us(
1719            &self
1720                .hessian_timings
1721                .iter()
1722                .filter(|t| t.sparse)
1723                .cloned()
1724                .collect::<Vec<_>>(),
1725        );
1726        let dense = Self::mean_duration_us(
1727            &self
1728                .hessian_timings
1729                .iter()
1730                .filter(|t| !t.sparse)
1731                .cloned()
1732                .collect::<Vec<_>>(),
1733        );
1734        if sparse <= 0.0 || dense <= 0.0 {
1735            return 1.0;
1736        }
1737        dense / sparse
1738    }
1739}
1740
1741#[cfg(test)]
1742mod tests {
1743    use super::*;
1744
1745    #[test]
1746    fn test_higher_order_engine_creation() {
1747        let engine = HigherOrderEngine::<f64>::new(3);
1748        assert_eq!(engine._maxorder, 3);
1749        assert!(engine.mixed_mode);
1750    }
1751
1752    #[test]
1753    fn test_hessian_diagonal() {
1754        let engine = HigherOrderEngine::<f64>::new(2);
1755
1756        // Test function: f(x) = x₁² + 2x₂²
1757        let function = |x: &Array1<f64>| x[0] * x[0] + 2.0 * x[1] * x[1];
1758        let point = Array1::from_vec(vec![1.0, 1.0]);
1759
1760        let hessian = engine
1761            .hessian_diagonal(&function, &point)
1762            .expect("hessian_diagonal should succeed");
1763
1764        // Expected diagonal: [2, 4]
1765        assert!((hessian[[0, 0]] - 2.0).abs() < 1e-5);
1766        assert!((hessian[[1, 1]] - 4.0).abs() < 1e-5);
1767        assert!((hessian[[0, 1]]).abs() < 1e-10); // Off-diagonal should be zero
1768    }
1769
1770    #[test]
1771    fn test_finite_difference_hessian() {
1772        let engine = HigherOrderEngine::<f64>::new(2);
1773
1774        // Test function: f(x,y) = x² + xy + y²
1775        let function = |x: &Array1<f64>| x[0] * x[0] + x[0] * x[1] + x[1] * x[1];
1776        let point = Array1::from_vec(vec![1.0, 1.0]);
1777
1778        let hessian = engine
1779            .finite_difference_hessian(&function, &point)
1780            .expect("finite_difference_hessian should succeed");
1781
1782        // Expected Hessian: [[2, 1], [1, 2]]
1783        assert!((hessian[[0, 0]] - 2.0).abs() < 1e-5);
1784        assert!((hessian[[0, 1]] - 1.0).abs() < 1e-5);
1785        assert!((hessian[[1, 0]] - 1.0).abs() < 1e-5);
1786        assert!((hessian[[1, 1]] - 2.0).abs() < 1e-5);
1787    }
1788
1789    #[test]
1790    fn test_mixed_partial() {
1791        let mut engine = HigherOrderEngine::<f64>::new(2);
1792
1793        // Test function: f(x,y) = x²y + xy²
1794        let function = |x: &Array1<f64>| x[0] * x[0] * x[1] + x[0] * x[1] * x[1];
1795        let point = Array1::from_vec(vec![1.0, 1.0]);
1796
1797        let mixed_partial = engine
1798            .mixed_partial(
1799                function,
1800                &point,
1801                &[0, 1],
1802                &[1, 1],
1803                MixedPartialMethod::FiniteDifference,
1804            )
1805            .expect("mixed_partial should succeed");
1806
1807        // ∂²f/∂x∂y = 2x + 2y = 4 at (1,1)
1808        assert!((mixed_partial.value - 4.0).abs() < 1e-6);
1809    }
1810
1811    #[test]
1812    fn test_sparse_hessian() {
1813        let mut engine = HigherOrderEngine::<f64>::new(2);
1814
1815        // Diagonal function: f(x) = x₁² + x₂²
1816        let function = |x: &Array1<f64>| x[0] * x[0] + x[1] * x[1];
1817        let point = Array1::from_vec(vec![1.0, 1.0]);
1818
1819        let config = HessianConfig {
1820            sparse: true,
1821            sparsity_threshold: 1e-4,
1822            ..Default::default()
1823        };
1824
1825        let sparse_hessian = engine
1826            .sparse_hessian(function, &point, &config)
1827            .expect("sparse_hessian should succeed");
1828
1829        // Should have 2 non-zero elements (diagonal)
1830        assert_eq!(sparse_hessian.nnz, 2);
1831        assert_eq!(sparse_hessian.shape, (2, 2));
1832    }
1833
1834    #[test]
1835    fn test_hessian_config_default() {
1836        let config = HessianConfig::default();
1837        assert!(config.exact);
1838        assert!(!config.sparse);
1839        assert!(!config.diagonal_only);
1840    }
1841
1842    /// Smoke test: higher-order AD computes the correct second derivative.
1843    ///
1844    /// For f(x) = x^2, f''(x) = 2 everywhere, so the (1x1) Hessian is [[2]].
1845    #[test]
1846    fn test_higher_order_hessian_of_square() {
1847        let mut engine = HigherOrderEngine::<f64>::new(2);
1848
1849        let function = |x: &Array1<f64>| x[0] * x[0];
1850        let point = Array1::from_vec(vec![3.0]);
1851
1852        let hessian = engine
1853            .hessian_forward_over_reverse(function, &point, &HessianConfig::default())
1854            .expect("hessian");
1855
1856        approx::assert_abs_diff_eq!(hessian[[0, 0]], 2.0, epsilon = 1e-3);
1857    }
1858
1859    /// Smoke test: Hessian-vector product on a separable quadratic.
1860    ///
1861    /// For f(x) = 0.5 * (x_0^2 + x_1^2) the Hessian is the identity, so the
1862    /// Hessian-vector product H * v must return v unchanged.
1863    #[test]
1864    fn test_higher_order_hvp_quadratic() {
1865        let mut engine = HigherOrderEngine::<f64>::new(2);
1866
1867        let function = |x: &Array1<f64>| 0.5 * (x[0] * x[0] + x[1] * x[1]);
1868        let point = Array1::from_vec(vec![1.0, -2.0]);
1869        let vector = Array1::from_vec(vec![3.0, 4.0]);
1870
1871        let hv = engine
1872            .hessian_vector_product(function, &point, &vector)
1873            .expect("hvp");
1874
1875        approx::assert_abs_diff_eq!(hv[0], 3.0, epsilon = 1e-3);
1876        approx::assert_abs_diff_eq!(hv[1], 4.0, epsilon = 1e-3);
1877    }
1878
1879    /// F29 regression: third derivatives must be numerically correct (the old
1880    /// two-point formula was wrong by a factor of ~1/h², i.e. ~1e10 for f64).
1881    ///
1882    /// f(x) = x0³ + x0·x1·x2 + x0²·x1 exercises the pure, fully-mixed and
1883    /// semi-mixed stencils simultaneously:
1884    /// * ∂³/∂x0³        = 6
1885    /// * ∂³/∂x0∂x1∂x2   = 1
1886    /// * ∂³/∂x0²∂x1      = 2
1887    #[test]
1888    fn test_third_order_derivatives_are_accurate() {
1889        let mut engine = HigherOrderEngine::<f64>::new(3);
1890        let function =
1891            |x: &Array1<f64>| x[0] * x[0] * x[0] + x[0] * x[1] * x[2] + x[0] * x[0] * x[1];
1892        let point = Array1::from_vec(vec![0.7, -0.4, 1.3]);
1893
1894        let tensor = engine
1895            .third_order_derivatives(function, &point)
1896            .expect("third order");
1897
1898        approx::assert_abs_diff_eq!(tensor.data[[0, 0, 0]], 6.0, epsilon = 1e-3);
1899        approx::assert_abs_diff_eq!(tensor.data[[0, 1, 2]], 1.0, epsilon = 1e-3);
1900        approx::assert_abs_diff_eq!(tensor.data[[0, 0, 1]], 2.0, epsilon = 1e-3);
1901        // Symmetry of the mixed third derivative.
1902        approx::assert_abs_diff_eq!(tensor.data[[2, 1, 0]], 1.0, epsilon = 1e-3);
1903    }
1904
1905    /// F90 regression: mixed partials of order 1 and 3 used to silently return
1906    /// 0 for everything but the two-variable second order.
1907    #[test]
1908    fn test_mixed_partial_supports_orders_one_and_three() {
1909        let mut engine = HigherOrderEngine::<f64>::new(3);
1910        let point = Array1::from_vec(vec![0.5, -0.3, 0.9]);
1911
1912        // Order 1: ∂/∂x0 (3x0 + x1²) = 3.
1913        let g = |x: &Array1<f64>| 3.0 * x[0] + x[1] * x[1];
1914        let d1 = engine
1915            .mixed_partial(g, &point, &[0], &[1], MixedPartialMethod::FiniteDifference)
1916            .expect("order 1");
1917        approx::assert_abs_diff_eq!(d1.value, 3.0, epsilon = 1e-6);
1918
1919        // Order 3 (pure): ∂³/∂x0³ x0³ = 6.
1920        let cube = |x: &Array1<f64>| x[0] * x[0] * x[0];
1921        let d3 = engine
1922            .mixed_partial(
1923                cube,
1924                &point,
1925                &[0],
1926                &[3],
1927                MixedPartialMethod::FiniteDifference,
1928            )
1929            .expect("order 3 pure");
1930        approx::assert_abs_diff_eq!(d3.value, 6.0, epsilon = 1e-3);
1931
1932        // Order 3 (fully mixed): ∂³/∂x0∂x1∂x2 (x0·x1·x2) = 1.
1933        let prod = |x: &Array1<f64>| x[0] * x[1] * x[2];
1934        let d3m = engine
1935            .mixed_partial(
1936                prod,
1937                &point,
1938                &[0, 1, 2],
1939                &[1, 1, 1],
1940                MixedPartialMethod::FiniteDifference,
1941            )
1942            .expect("order 3 mixed");
1943        approx::assert_abs_diff_eq!(d3m.value, 1.0, epsilon = 1e-3);
1944    }
1945
1946    /// F30 regression: `solve_linear_system` must solve a genuinely dense SPD
1947    /// system, not merely divide by the diagonal.
1948    #[test]
1949    fn test_solve_linear_system_dense_spd() {
1950        let engine = HigherOrderEngine::<f64>::new(2);
1951        // A = [[4, 1], [1, 3]] (SPD, non-diagonal); b = [1, 2].
1952        let a = Array2::from_shape_vec((2, 2), vec![4.0, 1.0, 1.0, 3.0]).expect("matrix");
1953        let b = Array1::from_vec(vec![1.0, 2.0]);
1954
1955        let x = engine.solve_linear_system(&a, &b).expect("solve");
1956
1957        // Exact solution A^{-1} b = [1/11, 7/11].
1958        approx::assert_abs_diff_eq!(x[0], 1.0 / 11.0, epsilon = 1e-6);
1959        approx::assert_abs_diff_eq!(x[1], 7.0 / 11.0, epsilon = 1e-6);
1960
1961        // Residual A x - b must be ~0 (a diagonal solve would leave it large).
1962        let residual = a.dot(&x) - &b;
1963        assert!(residual.dot(&residual).sqrt() < 1e-6);
1964    }
1965}