1use nalgebra::{DMatrix, DVector};
57
58pub const FD_REL_STEP_2POINT: f64 = 1.4901161193847656e-8; const TRF_DEFAULT_GTOL: f64 = 1e-10;
65const TRF_DEFAULT_FTOL: f64 = 1e-8;
67const TRF_DEFAULT_XTOL: f64 = 1e-8;
69const TRF_DEFAULT_MAX_NFEV: usize = 300;
71const TRF_INITIAL_DAMPING_SCALE: f64 = 1e-3;
74
75#[derive(Debug, Clone, PartialEq)]
79pub struct FdStep {
80 pub param_index: usize,
82 pub sign_x0: f64,
85 pub h: f64,
87 pub dx: f64,
91 pub x_perturbed: DVector<f64>,
93}
94
95pub fn fd_steps(x0: &DVector<f64>, rel_step: f64) -> Result<Vec<FdStep>, SolveError> {
101 let rel_step = crate::validate::positive_step(rel_step, "rel_step").map_err(map_field_error)?;
102 fd_steps_checked(x0, rel_step)
103}
104
105fn fd_steps_checked(x0: &DVector<f64>, rel_step: f64) -> Result<Vec<FdStep>, SolveError> {
106 validate_nonempty_vector(x0, "parameters")?;
107 validate_vector(x0, "parameters")?;
108 let steps = fd_steps_unchecked(x0, rel_step);
109 for step in &steps {
110 validate_value(step.h, "fd_step")?;
111 validate_value(step.dx, "fd_step")?;
112 if step.dx == 0.0 {
113 return Err(invalid_input("fd_step", "zero"));
114 }
115 validate_vector(&step.x_perturbed, "perturbed parameters")?;
116 }
117 Ok(steps)
118}
119
120fn fd_steps_unchecked(x0: &DVector<f64>, rel_step: f64) -> Vec<FdStep> {
121 (0..x0.len())
122 .map(|i| {
123 let xi = x0[i];
124 let sign_x0 = if xi >= 0.0 { 1.0 } else { -1.0 };
125 let h = rel_step * sign_x0 * xi.abs().max(1.0);
126 let mut x_perturbed = x0.clone();
127 x_perturbed[i] = xi + h;
128 let dx = x_perturbed[i] - xi;
129 FdStep {
130 param_index: i,
131 sign_x0,
132 h,
133 dx,
134 x_perturbed,
135 }
136 })
137 .collect()
138}
139
140pub fn jacobian_2point<F>(
151 residual: F,
152 x0: &DVector<f64>,
153 f0: &DVector<f64>,
154) -> Result<DMatrix<f64>, SolveError>
155where
156 F: Fn(&DVector<f64>) -> DVector<f64>,
157{
158 jacobian_2point_checked(|x| Ok(residual(x)), x0, f0)
159}
160
161fn jacobian_2point_checked<F>(
162 residual: F,
163 x0: &DVector<f64>,
164 f0: &DVector<f64>,
165) -> Result<DMatrix<f64>, SolveError>
166where
167 F: Fn(&DVector<f64>) -> Result<DVector<f64>, SolveError>,
168{
169 validate_nonempty_vector(x0, "parameters")?;
170 validate_vector(x0, "parameters")?;
171 validate_nonempty_vector(f0, "residual")?;
172 validate_vector(f0, "residual")?;
173 let m = f0.len();
174 let n = x0.len();
175 let steps = fd_steps_checked(x0, FD_REL_STEP_2POINT)?;
176 let mut jac = DMatrix::zeros(m, n);
177 for step in &steps {
178 let f1 = residual(&step.x_perturbed)?;
179 validate_nonempty_vector(&f1, "residual")?;
180 validate_vector(&f1, "residual")?;
181 if f1.len() != m {
182 return Err(invalid_input("residual", "length mismatch"));
183 }
184 let i = step.param_index;
185 for row in 0..m {
186 jac[(row, i)] = (f1[row] - f0[row]) / step.dx;
187 }
188 }
189 validate_matrix(&jac, "jacobian")?;
190 Ok(jac)
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum Status {
197 GradientTolerance,
199 CostTolerance,
201 StepTolerance,
203 MaxEvaluations,
205}
206
207#[derive(Debug, Clone, Copy)]
209pub struct SolveOptions {
210 pub gtol: f64,
212 pub ftol: f64,
214 pub xtol: f64,
216 pub max_nfev: usize,
218}
219
220impl Default for SolveOptions {
221 fn default() -> Self {
222 Self {
224 gtol: TRF_DEFAULT_GTOL,
225 ftol: TRF_DEFAULT_FTOL,
226 xtol: TRF_DEFAULT_XTOL,
227 max_nfev: TRF_DEFAULT_MAX_NFEV,
228 }
229 }
230}
231
232#[derive(Debug, Clone)]
234pub struct LeastSquaresReport {
235 pub x: DVector<f64>,
237 pub residual: DVector<f64>,
239 pub cost: f64,
241 pub jacobian: DMatrix<f64>,
243 pub optimality_inf: f64,
245 pub iterations: usize,
247 pub status: Status,
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
259pub enum TrustRegionSolve {
260 #[default]
264 NalgebraLu,
265 OwnedGaussianFirstTie,
285}
286
287#[derive(Debug, Clone, thiserror::Error)]
289pub enum SolveError {
290 #[error("singular or rank-deficient Jacobian: no usable descent direction")]
293 SingularJacobian,
294 #[error("invalid least-squares {field}: {reason}")]
296 InvalidInput {
297 field: &'static str,
298 reason: &'static str,
299 },
300}
301
302pub fn cost(residual: &DVector<f64>) -> Result<f64, SolveError> {
304 validate_nonempty_vector(residual, "residual")?;
305 validate_vector(residual, "residual")?;
306 validate_value(0.5 * residual.dot(residual), "cost")
307}
308
309pub fn normal_covariance(
335 jacobian: &DMatrix<f64>,
336 variance_scale: f64,
337) -> Result<DMatrix<f64>, SolveError> {
338 validate_matrix(jacobian, "jacobian")?;
339 let m = jacobian.nrows();
340 let n = jacobian.ncols();
341 if n == 0 || m == 0 {
342 return Err(invalid_input("jacobian", "empty"));
343 }
344 if m < n {
345 return Err(invalid_input("jacobian", "fewer rows than columns"));
346 }
347 crate::validate::finite_nonneg(variance_scale, "variance_scale").map_err(map_field_error)?;
348
349 let svd = jacobian.clone().svd(false, true);
351 let v_t = svd.v_t.ok_or(SolveError::SingularJacobian)?;
352 let singular = svd.singular_values;
353
354 let smax = singular.iter().cloned().fold(0.0_f64, f64::max);
359 if smax == 0.0 {
360 return Err(SolveError::SingularJacobian);
361 }
362 let threshold = smax * (m.max(n) as f64) * f64::EPSILON;
363 if singular.iter().any(|&s| s <= threshold) {
364 return Err(SolveError::SingularJacobian);
365 }
366
367 let mut cov = DMatrix::zeros(n, n);
370 for i in 0..n {
371 for j in 0..n {
372 let mut acc = 0.0;
373 for k in 0..n {
374 let inv_s2 = 1.0 / (singular[k] * singular[k]);
375 acc += v_t[(k, i)] * v_t[(k, j)] * inv_s2;
376 }
377 cov[(i, j)] = acc * variance_scale;
378 }
379 }
380 validate_matrix(&cov, "covariance")?;
381 Ok(cov)
382}
383
384pub fn hessian_trace(jacobian: &DMatrix<f64>) -> f64 {
391 let n = jacobian.ncols();
392 let m = jacobian.nrows();
393 let mut trace = 0.0;
394 for i in 0..n {
395 let mut col = 0.0;
396 for r in 0..m {
397 let v = jacobian[(r, i)];
398 col += v * v;
399 }
400 trace += col;
401 }
402 trace
403}
404
405pub fn covariance_from_jacobian(
418 jacobian: &DMatrix<f64>,
419 cost: f64,
420) -> Result<DMatrix<f64>, SolveError> {
421 let m = jacobian.nrows();
422 let n = jacobian.ncols();
423 if m <= n {
424 return Err(invalid_input("degrees_of_freedom", "not positive"));
425 }
426 let dof = (m - n) as f64;
427 let s_sq = validate_value(2.0 * cost / dof, "reduced_chi_square")?;
428 normal_covariance(jacobian, s_sq)
429}
430
431pub fn covariance_from_report(report: &LeastSquaresReport) -> Result<DMatrix<f64>, SolveError> {
439 let m = report.residual.len();
440 let n = report.x.len();
441 if report.jacobian.nrows() != m {
447 return Err(invalid_input("jacobian", "rows must match residual length"));
448 }
449 if report.jacobian.ncols() != n {
450 return Err(invalid_input(
451 "jacobian",
452 "columns must match parameter length",
453 ));
454 }
455 covariance_from_jacobian(&report.jacobian, report.cost)
456}
457
458pub struct LeastSquaresProblem<F> {
463 residual: F,
464 sqrt_weights: Option<DVector<f64>>,
466 x0: DVector<f64>,
467}
468
469impl<F> LeastSquaresProblem<F>
470where
471 F: Fn(&DVector<f64>) -> DVector<f64>,
472{
473 pub fn new(residual: F, x0: DVector<f64>) -> Self {
475 Self {
476 residual,
477 sqrt_weights: None,
478 x0,
479 }
480 }
481
482 pub fn with_weights(residual: F, x0: DVector<f64>, weights: DVector<f64>) -> Self {
485 let sqrt_weights = weights.map(f64::sqrt);
486 Self {
487 residual,
488 sqrt_weights: Some(sqrt_weights),
489 x0,
490 }
491 }
492
493 fn weighted_residual(&self, x: &DVector<f64>) -> Result<DVector<f64>, SolveError> {
495 validate_nonempty_vector(x, "parameters")?;
496 validate_vector(x, "parameters")?;
497 let r = (self.residual)(x);
498 validate_nonempty_vector(&r, "residual")?;
499 validate_vector(&r, "residual")?;
500 match &self.sqrt_weights {
501 Some(sw) => {
502 validate_nonempty_vector(sw, "weights")?;
503 validate_vector(sw, "weights")?;
504 if sw.len() != r.len() {
505 return Err(invalid_input("weights", "length mismatch"));
506 }
507 let weighted = r.component_mul(sw);
508 validate_vector(&weighted, "weighted residual")?;
509 Ok(weighted)
510 }
511 None => Ok(r),
512 }
513 }
514}
515
516pub fn solve_trf<F>(
531 problem: &LeastSquaresProblem<F>,
532 opts: &SolveOptions,
533) -> Result<LeastSquaresReport, SolveError>
534where
535 F: Fn(&DVector<f64>) -> DVector<f64>,
536{
537 solve_trf_with(problem, opts, TrustRegionSolve::NalgebraLu)
538}
539
540fn solve_subproblem(
544 lhs: &DMatrix<f64>,
545 rhs: &DVector<f64>,
546 linear_solve: TrustRegionSolve,
547) -> Option<DVector<f64>> {
548 match linear_solve {
549 TrustRegionSolve::NalgebraLu => lhs.clone().lu().solve(rhs),
550 TrustRegionSolve::OwnedGaussianFirstTie => {
551 let n = rhs.len();
552 let a: Vec<Vec<f64>> = (0..n)
553 .map(|i| (0..n).map(|j| lhs[(i, j)]).collect())
554 .collect();
555 let b: Vec<f64> = rhs.iter().copied().collect();
556 crate::astro::math::linear::solve_linear_first_tie(&a, &b).map(DVector::from_vec)
557 }
558 }
559}
560
561pub fn solve_trf_with<F>(
565 problem: &LeastSquaresProblem<F>,
566 opts: &SolveOptions,
567 linear_solve: TrustRegionSolve,
568) -> Result<LeastSquaresReport, SolveError>
569where
570 F: Fn(&DVector<f64>) -> DVector<f64>,
571{
572 validate_options(opts)?;
573 let n = problem.x0.len();
574
575 let mut x = problem.x0.clone();
576 validate_nonempty_vector(&x, "initial parameters")?;
577 validate_vector(&x, "initial parameters")?;
578 let mut r = problem.weighted_residual(&x)?;
579 let mut f0 = r.clone();
580 let mut jac = jacobian_2point_checked(|p| problem.weighted_residual(p), &x, &f0)?;
581 let mut nfev = 1usize; let mut cur_cost = cost(&r)?;
583
584 let jtj0 = jac.transpose() * &jac;
586 validate_matrix(&jtj0, "normal matrix")?;
587 let mut mu = TRF_INITIAL_DAMPING_SCALE
588 * (0..n)
589 .map(|i| jtj0[(i, i)])
590 .fold(0.0_f64, f64::max)
591 .max(1.0);
592
593 let mut iterations = 0usize;
594
595 loop {
596 let jt = jac.transpose();
597 let grad = &jt * &r;
598 validate_vector(&grad, "gradient")?;
599 let optimality_inf = validate_value(grad.amax(), "optimality")?;
600
601 if optimality_inf < opts.gtol {
602 return finish(x, r, cur_cost, jac, iterations, Status::GradientTolerance);
603 }
604 if nfev >= opts.max_nfev {
605 return finish(x, r, cur_cost, jac, iterations, Status::MaxEvaluations);
606 }
607
608 let jtj = &jt * &jac;
609 validate_matrix(&jtj, "normal matrix")?;
610
611 let mut accepted = false;
613 for _ in 0..30 {
614 let mut lhs = jtj.clone();
615 for i in 0..n {
616 lhs[(i, i)] += mu;
617 }
618 let rhs = -&grad;
619 validate_matrix(&lhs, "subproblem matrix")?;
620 validate_vector(&rhs, "subproblem rhs")?;
621 let step = match solve_subproblem(&lhs, &rhs, linear_solve) {
622 Some(s) => s,
623 None => return Err(SolveError::SingularJacobian),
624 };
625 validate_vector(&step, "step")?;
626
627 let x_trial = &x + &step;
628 let r_trial = problem.weighted_residual(&x_trial)?;
629 nfev += 1;
630 let cost_trial = cost(&r_trial)?;
631
632 if cost_trial < cur_cost {
633 let cost_reduction = (cur_cost - cost_trial) / cur_cost.max(f64::MIN_POSITIVE);
635 let step_norm = step.norm();
636 let x_norm = x.norm();
637 let rel_step = step_norm / x_norm.max(f64::MIN_POSITIVE);
638
639 x = x_trial;
640 r = r_trial;
641 cur_cost = cost_trial;
642 f0 = r.clone();
643 jac = jacobian_2point_checked(|p| problem.weighted_residual(p), &x, &f0)?;
644 nfev += n; iterations += 1;
646 mu *= 0.5;
647 accepted = true;
648
649 if cost_reduction < opts.ftol {
650 return finish(x, r, cur_cost, jac, iterations, Status::CostTolerance);
651 }
652 if rel_step < opts.xtol {
653 return finish(x, r, cur_cost, jac, iterations, Status::StepTolerance);
654 }
655 break;
656 } else {
657 mu *= 2.0;
659 }
660 }
661
662 if !accepted {
663 return finish(x, r, cur_cost, jac, iterations, Status::StepTolerance);
665 }
666 }
667}
668
669fn finish(
670 x: DVector<f64>,
671 residual: DVector<f64>,
672 cost_value: f64,
673 jacobian: DMatrix<f64>,
674 iterations: usize,
675 status: Status,
676) -> Result<LeastSquaresReport, SolveError> {
677 validate_nonempty_vector(&x, "solution")?;
678 validate_vector(&x, "solution")?;
679 validate_nonempty_vector(&residual, "residual")?;
680 validate_vector(&residual, "residual")?;
681 validate_value(cost_value, "cost")?;
682 validate_matrix(&jacobian, "jacobian")?;
683 let optimality_inf = validate_value((jacobian.transpose() * &residual).amax(), "optimality")?;
684 Ok(LeastSquaresReport {
685 x,
686 residual,
687 cost: cost_value,
688 jacobian,
689 optimality_inf,
690 iterations,
691 status,
692 })
693}
694
695fn validate_value(value: f64, field: &'static str) -> Result<f64, SolveError> {
696 crate::validate::finite(value, field).map_err(map_field_error)
697}
698
699fn validate_options(opts: &SolveOptions) -> Result<(), SolveError> {
700 crate::validate::positive_step(opts.gtol, "gtol").map_err(map_field_error)?;
701 crate::validate::positive_step(opts.ftol, "ftol").map_err(map_field_error)?;
702 crate::validate::positive_step(opts.xtol, "xtol").map_err(map_field_error)?;
703 if opts.max_nfev == 0 {
704 return Err(invalid_input("max_nfev", "not positive"));
705 }
706 Ok(())
707}
708
709fn validate_nonempty_vector(vector: &DVector<f64>, field: &'static str) -> Result<(), SolveError> {
710 if vector.is_empty() {
711 Err(invalid_input(field, "empty"))
712 } else {
713 Ok(())
714 }
715}
716
717fn validate_vector(vector: &DVector<f64>, field: &'static str) -> Result<(), SolveError> {
718 crate::validate::finite_slice(vector.as_slice(), field).map_err(map_field_error)
719}
720
721fn validate_matrix(matrix: &DMatrix<f64>, field: &'static str) -> Result<(), SolveError> {
722 crate::validate::finite_slice(matrix.as_slice(), field).map_err(map_field_error)
723}
724
725fn map_field_error(error: crate::validate::FieldError) -> SolveError {
726 invalid_input(error.field(), error.reason())
727}
728
729fn invalid_input(field: &'static str, reason: &'static str) -> SolveError {
730 SolveError::InvalidInput { field, reason }
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736
737 #[test]
738 fn fd_rel_step_is_sqrt_eps() {
739 assert_eq!(FD_REL_STEP_2POINT, (2.0_f64.powi(-52)).sqrt());
740 assert_eq!(FD_REL_STEP_2POINT, 2.0_f64.powi(-26));
741 }
742
743 #[test]
744 fn fd_step_sign_convention() {
745 let x0 = DVector::from_vec(vec![5.0, -2.0, 0.0]);
746 let steps = fd_steps(&x0, FD_REL_STEP_2POINT).unwrap();
747 assert_eq!(steps[0].sign_x0, 1.0);
748 assert_eq!(steps[1].sign_x0, -1.0);
749 assert_eq!(steps[2].sign_x0, 1.0); }
751
752 #[test]
753 fn fd_steps_rejects_zero_relative_step() {
754 let x0 = DVector::from_vec(vec![1.0]);
755 assert_invalid_field(fd_steps(&x0, 0.0).unwrap_err(), "rel_step");
756 }
757
758 #[test]
759 fn fd_steps_rejects_nonfinite_parameters() {
760 let x0 = DVector::from_vec(vec![1.0, f64::NAN]);
761 assert_invalid_field(fd_steps(&x0, FD_REL_STEP_2POINT).unwrap_err(), "parameters");
762 }
763
764 #[test]
765 fn jacobian_rejects_residual_length_mismatch() {
766 let x0 = DVector::from_vec(vec![1.0, 2.0]);
767 let f0 = DVector::from_vec(vec![1.0, 2.0]);
768 let residual = |_: &DVector<f64>| DVector::from_vec(vec![1.0]);
769 assert_invalid_field(jacobian_2point(residual, &x0, &f0).unwrap_err(), "residual");
770 }
771
772 #[test]
773 fn cost_rejects_nonfinite_residual() {
774 assert_invalid_field(
775 cost(&DVector::from_vec(vec![1.0, f64::INFINITY])).unwrap_err(),
776 "residual",
777 );
778 }
779
780 #[test]
781 fn exp_fit_converges() {
782 let t = vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0];
784 let y = vec![
785 3.0123, 2.2083, 1.6889, 1.3713, 1.0903, 0.9302, 0.8104, 0.6303,
786 ];
787 let tt = t.clone();
788 let yy = y.clone();
789 let residual = move |p: &DVector<f64>| {
790 let (a, b, c) = (p[0], p[1], p[2]);
791 DVector::from_iterator(
792 tt.len(),
793 tt.iter()
794 .zip(&yy)
795 .map(|(&tk, &yk)| a * (b * tk).exp() + c - yk),
796 )
797 };
798 let problem = LeastSquaresProblem::new(residual, DVector::from_vec(vec![5.0, -2.0, 2.0]));
799 let report = solve_trf(&problem, &SolveOptions::default()).unwrap();
800 assert!(report.cost < 1.0, "cost did not reduce: {}", report.cost);
801 }
802
803 #[test]
804 fn solve_trf_rejects_nonfinite_initial_residual() {
805 fn residual(_: &DVector<f64>) -> DVector<f64> {
806 DVector::from_element(1, f64::NAN)
807 }
808 let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 0.0));
809 assert_invalid_field(
810 solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
811 "residual",
812 );
813 }
814
815 #[test]
816 fn solve_trf_rejects_nonfinite_initial_cost() {
817 fn residual(_: &DVector<f64>) -> DVector<f64> {
818 DVector::from_element(1, f64::MAX)
819 }
820 let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 0.0));
821 assert_invalid_field(
822 solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
823 "cost",
824 );
825 }
826
827 #[test]
828 fn solve_trf_rejects_nonfinite_trial_residual_instead_of_converging() {
829 use std::cell::Cell;
830
831 let calls = Cell::new(0usize);
832 let residual = move |p: &DVector<f64>| {
833 let call = calls.get();
834 calls.set(call + 1);
835 if call >= 2 {
836 DVector::from_element(1, f64::NAN)
837 } else {
838 DVector::from_element(1, p[0])
839 }
840 };
841 let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 1.0));
842 assert_invalid_field(
843 solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
844 "residual",
845 );
846 }
847
848 #[test]
849 fn solve_trf_rejects_invalid_options() {
850 fn residual(p: &DVector<f64>) -> DVector<f64> {
851 DVector::from_element(1, p[0])
852 }
853 let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 1.0));
854 let opts = SolveOptions {
855 gtol: f64::NAN,
856 ..SolveOptions::default()
857 };
858 assert_invalid_field(solve_trf(&problem, &opts).unwrap_err(), "gtol");
859
860 let opts = SolveOptions {
861 max_nfev: 0,
862 ..SolveOptions::default()
863 };
864 assert_invalid_field(solve_trf(&problem, &opts).unwrap_err(), "max_nfev");
865 }
866
867 #[test]
868 fn solve_trf_rejects_weight_residual_dimension_mismatch() {
869 fn residual(_: &DVector<f64>) -> DVector<f64> {
870 DVector::from_vec(vec![1.0, 2.0])
871 }
872 let problem = LeastSquaresProblem::with_weights(
873 residual,
874 DVector::from_element(1, 0.0),
875 DVector::from_vec(vec![1.0]),
876 );
877 assert_invalid_field(
878 solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
879 "weights",
880 );
881 }
882
883 fn assert_invalid_field(error: SolveError, expected: &'static str) {
884 match error {
885 SolveError::InvalidInput { field, .. } => assert_eq!(field, expected),
886 other => panic!("expected invalid input for {expected}, got {other:?}"),
887 }
888 }
889
890 fn exp_fit_problem() -> LeastSquaresProblem<impl Fn(&DVector<f64>) -> DVector<f64>> {
892 let t = [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0];
893 let y = [
894 3.0123, 2.2083, 1.6889, 1.3713, 1.0903, 0.9302, 0.8104, 0.6303,
895 ];
896 let residual = move |p: &DVector<f64>| {
897 let (a, b, c) = (p[0], p[1], p[2]);
898 DVector::from_iterator(
899 t.len(),
900 t.iter()
901 .zip(&y)
902 .map(|(&tk, &yk)| a * (b * tk).exp() + c - yk),
903 )
904 };
905 LeastSquaresProblem::new(residual, DVector::from_vec(vec![5.0, -2.0, 2.0]))
906 }
907
908 #[test]
917 fn owned_trf_converges_to_frozen_bits() {
918 let problem = exp_fit_problem();
919 let report = solve_trf_with(
920 &problem,
921 &SolveOptions::default(),
922 TrustRegionSolve::OwnedGaussianFirstTie,
923 )
924 .unwrap();
925 assert!(
926 report.cost < 1.0,
927 "owned cost did not reduce: {}",
928 report.cost
929 );
930 assert_eq!(report.x[0].to_bits(), 0x4003c3674cdfadef);
931 assert_eq!(report.x[1].to_bits(), 0xbfe799e0d1929220);
932 assert_eq!(report.x[2].to_bits(), 0x3fe0d5c96d9d3b35);
933
934 let again = solve_trf_with(
936 &problem,
937 &SolveOptions::default(),
938 TrustRegionSolve::OwnedGaussianFirstTie,
939 )
940 .unwrap();
941 for i in 0..3 {
942 assert_eq!(report.x[i].to_bits(), again.x[i].to_bits());
943 }
944 }
945
946 fn covariance_fixture_jacobian() -> DMatrix<f64> {
948 DMatrix::from_row_slice(5, 2, &[1.0, 0.0, 1.0, 1.0, 1.0, 2.0, 1.0, 3.0, 1.0, 4.0])
950 }
951
952 #[test]
953 fn hessian_trace_matches_numpy() {
954 let trace = hessian_trace(&covariance_fixture_jacobian());
956 assert!((trace - 35.0).abs() < 1e-12, "trace {trace}");
957 }
958
959 #[test]
960 fn normal_covariance_matches_numpy_pcov() {
961 let inv = normal_covariance(&covariance_fixture_jacobian(), 1.0).unwrap();
963 let expected = [[0.6000000000000001, -0.2], [-0.2, 0.1]];
964 for i in 0..2 {
965 for j in 0..2 {
966 assert!(
967 (inv[(i, j)] - expected[i][j]).abs() < 1e-12,
968 "inv[{i}][{j}] = {}",
969 inv[(i, j)]
970 );
971 }
972 }
973
974 let s_sq = 0.085 / 3.0;
976 let cov = normal_covariance(&covariance_fixture_jacobian(), s_sq).unwrap();
977 let expected_cov = [
978 [0.017000000000000005, -0.005666666666666667],
979 [-0.005666666666666667, 0.0028333333333333335],
980 ];
981 for i in 0..2 {
982 for j in 0..2 {
983 assert!(
984 (cov[(i, j)] - expected_cov[i][j]).abs() < 1e-12,
985 "cov[{i}][{j}] = {}",
986 cov[(i, j)]
987 );
988 }
989 }
990 }
991
992 #[test]
993 fn normal_covariance_rejects_underdetermined_and_negative_scale() {
994 let wide = DMatrix::from_row_slice(2, 3, &[1.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
995 assert!(matches!(
996 normal_covariance(&wide, 1.0),
997 Err(SolveError::InvalidInput {
998 field: "jacobian",
999 ..
1000 })
1001 ));
1002 assert!(matches!(
1003 normal_covariance(&covariance_fixture_jacobian(), -1.0),
1004 Err(SolveError::InvalidInput {
1005 field: "variance_scale",
1006 ..
1007 })
1008 ));
1009 }
1010
1011 #[test]
1012 fn normal_covariance_matches_closed_form_inverse_for_collinear_jacobian() {
1013 let eps = 1e-2;
1020 let col1: Vec<f64> = (0..5).map(|k| 1.0 + (k as f64) * eps).collect();
1021 let mut data = Vec::with_capacity(10);
1022 for &c1 in &col1 {
1023 data.push(1.0);
1024 data.push(c1);
1025 }
1026 let jac = DMatrix::from_row_slice(5, 2, &data);
1027 let scale = 2.5;
1028 let cov = normal_covariance(&jac, scale).unwrap();
1029
1030 let s00 = 5.0_f64;
1032 let s01: f64 = col1.iter().sum();
1033 let s11: f64 = col1.iter().map(|c| c * c).sum();
1034 let det = s00 * s11 - s01 * s01;
1035 let inv = [[s11 / det, -s01 / det], [-s01 / det, s00 / det]];
1036 for i in 0..2 {
1037 for j in 0..2 {
1038 let expected = inv[i][j] * scale;
1039 let tol = 1e-9 * expected.abs().max(1.0);
1040 assert!(
1041 (cov[(i, j)] - expected).abs() < tol,
1042 "cov[{i}][{j}] = {} (expected {expected})",
1043 cov[(i, j)]
1044 );
1045 }
1046 }
1047 assert!((cov[(0, 1)] - cov[(1, 0)]).abs() <= 1e-12 * cov[(0, 0)].abs().max(1.0));
1049 }
1050
1051 #[test]
1052 fn covariance_from_report_rejects_jacobian_dimension_mismatch() {
1053 let jac = covariance_fixture_jacobian(); let mismatched_rows = LeastSquaresReport {
1058 x: DVector::from_vec(vec![0.0, 0.0]),
1059 residual: DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05]), cost: 0.1,
1061 jacobian: jac.clone(),
1062 optimality_inf: 0.0,
1063 iterations: 0,
1064 status: Status::GradientTolerance,
1065 };
1066 assert_invalid_field(
1067 covariance_from_report(&mismatched_rows).unwrap_err(),
1068 "jacobian",
1069 );
1070
1071 let mismatched_cols = LeastSquaresReport {
1072 x: DVector::from_vec(vec![0.0, 0.0, 0.0]), residual: DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]),
1074 cost: 0.1,
1075 jacobian: jac,
1076 optimality_inf: 0.0,
1077 iterations: 0,
1078 status: Status::GradientTolerance,
1079 };
1080 assert_invalid_field(
1081 covariance_from_report(&mismatched_cols).unwrap_err(),
1082 "jacobian",
1083 );
1084 }
1085
1086 #[test]
1087 fn covariance_from_jacobian_matches_report_path_bit_for_bit() {
1088 let jac = covariance_fixture_jacobian(); let residual = DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]);
1094 let cost = 0.5 * residual.dot(&residual);
1095 let report = LeastSquaresReport {
1096 x: DVector::from_vec(vec![0.0, 0.0]),
1097 cost,
1098 residual,
1099 jacobian: jac.clone(),
1100 optimality_inf: 0.0,
1101 iterations: 0,
1102 status: Status::GradientTolerance,
1103 };
1104
1105 let from_jac = covariance_from_jacobian(&jac, cost).unwrap();
1106 let from_report = covariance_from_report(&report).unwrap();
1107
1108 let m = jac.nrows();
1109 let n = jac.ncols();
1110 let explicit = normal_covariance(&jac, 2.0 * cost / ((m - n) as f64)).unwrap();
1111
1112 assert_eq!(from_jac.shape(), from_report.shape());
1113 for (a, (b, c)) in from_jac.iter().zip(from_report.iter().zip(explicit.iter())) {
1114 assert_eq!(a.to_bits(), b.to_bits());
1115 assert_eq!(a.to_bits(), c.to_bits());
1116 }
1117 }
1118
1119 #[test]
1120 fn covariance_from_jacobian_rejects_insufficient_dof() {
1121 let square = DMatrix::from_row_slice(2, 2, &[1.0, 0.0, 1.0, 1.0]);
1125 assert_invalid_field(
1126 covariance_from_jacobian(&square, 0.1).unwrap_err(),
1127 "degrees_of_freedom",
1128 );
1129
1130 let wide = DMatrix::from_row_slice(2, 3, &[1.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
1131 assert_invalid_field(
1132 covariance_from_jacobian(&wide, 0.1).unwrap_err(),
1133 "degrees_of_freedom",
1134 );
1135 }
1136
1137 #[test]
1138 fn covariance_from_report_uses_reduced_chi_square() {
1139 let jac = covariance_fixture_jacobian();
1141 let residual = DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]);
1142 let report = LeastSquaresReport {
1143 x: DVector::from_vec(vec![0.0, 0.0]),
1144 cost: 0.5 * residual.dot(&residual),
1145 residual,
1146 jacobian: jac,
1147 optimality_inf: 0.0,
1148 iterations: 0,
1149 status: Status::GradientTolerance,
1150 };
1151 let cov = covariance_from_report(&report).unwrap();
1152 let expected_cov = [
1153 [0.017000000000000005, -0.005666666666666667],
1154 [-0.005666666666666667, 0.0028333333333333335],
1155 ];
1156 for i in 0..2 {
1157 for j in 0..2 {
1158 assert!(
1159 (cov[(i, j)] - expected_cov[i][j]).abs() < 1e-12,
1160 "cov[{i}][{j}] = {}",
1161 cov[(i, j)]
1162 );
1163 }
1164 }
1165 }
1166}