1use 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
41pub(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 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 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 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 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 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 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 fn hvp_step(&self, vector: &Array1<T>) -> T {
192 self.fd_step(2) / Self::direction_scale(vector)
193 }
194
195 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 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 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 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 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 pub fn objective_evaluations(&self, n: usize) -> Option<usize> {
300 match self {
301 HvpMode::CentralDifference => Some(4 * n),
303 HvpMode::ForwardDifference => Some(4 * n),
306 HvpMode::QuadraticSecant => Some(4 * n),
307 HvpMode::MaterializedHessian => Some(3 * n + 2 * n * n.saturating_sub(1)),
309 HvpMode::NestedAutodiff => None,
310 }
311 }
312
313 pub fn is_available(&self) -> bool {
316 !matches!(self, HvpMode::NestedAutodiff)
317 }
318}
319
320impl MixedPartialMethod {
321 pub fn is_available(&self) -> bool {
324 !matches!(self, MixedPartialMethod::NestedAutodiff)
325 }
326}
327
328pub 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 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 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 assert!(
383 (got - want).abs() < 1e-3,
384 "mode {mode:?} component {i}: got {got}, want {want}"
385 );
386 }
387 }
388 }
389
390 #[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 #[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 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 #[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 #[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 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 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 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}