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 diagnostics = singular_value_diagnostics(singular.as_slice(), m, n);
359 if diagnostics.rank < n {
360 return Err(SolveError::SingularJacobian);
361 }
362 debug_assert!(diagnostics.condition_number.is_finite());
363
364 let mut cov = DMatrix::zeros(n, n);
367 for i in 0..n {
368 for j in 0..n {
369 let mut acc = 0.0;
370 for k in 0..n {
371 let inv_s2 = 1.0 / (singular[k] * singular[k]);
372 acc += v_t[(k, i)] * v_t[(k, j)] * inv_s2;
373 }
374 cov[(i, j)] = acc * variance_scale;
375 }
376 }
377 validate_matrix(&cov, "covariance")?;
378 Ok(cov)
379}
380
381pub fn hessian_trace(jacobian: &DMatrix<f64>) -> f64 {
388 let n = jacobian.ncols();
389 let m = jacobian.nrows();
390 let mut trace = 0.0;
391 for i in 0..n {
392 let mut col = 0.0;
393 for r in 0..m {
394 let v = jacobian[(r, i)];
395 col += v * v;
396 }
397 trace += col;
398 }
399 trace
400}
401
402#[derive(Debug, Clone, Copy, PartialEq)]
403pub(crate) struct SingularValueDiagnostics {
404 pub(crate) rank: usize,
405 pub(crate) condition_number: f64,
406}
407
408pub(crate) fn singular_value_diagnostics(
409 singular_values: &[f64],
410 rows: usize,
411 cols: usize,
412) -> SingularValueDiagnostics {
413 let smax = singular_values.iter().copied().fold(0.0_f64, f64::max);
414 if smax == 0.0 {
415 return SingularValueDiagnostics {
416 rank: 0,
417 condition_number: f64::INFINITY,
418 };
419 }
420
421 let threshold = smax * (rows.max(cols) as f64) * f64::EPSILON;
422 let rank = singular_values.iter().filter(|&&s| s > threshold).count();
423 let condition_number = if rank < cols {
424 f64::INFINITY
425 } else {
426 let smin = singular_values
427 .iter()
428 .copied()
429 .fold(f64::INFINITY, f64::min);
430 smax / smin
431 };
432
433 SingularValueDiagnostics {
434 rank,
435 condition_number,
436 }
437}
438
439pub fn covariance_from_jacobian(
452 jacobian: &DMatrix<f64>,
453 cost: f64,
454) -> Result<DMatrix<f64>, SolveError> {
455 let m = jacobian.nrows();
456 let n = jacobian.ncols();
457 if m <= n {
458 return Err(invalid_input("degrees_of_freedom", "not positive"));
459 }
460 let dof = (m - n) as f64;
461 let s_sq = validate_value(2.0 * cost / dof, "reduced_chi_square")?;
462 normal_covariance(jacobian, s_sq)
463}
464
465pub fn covariance_from_report(report: &LeastSquaresReport) -> Result<DMatrix<f64>, SolveError> {
473 let m = report.residual.len();
474 let n = report.x.len();
475 if report.jacobian.nrows() != m {
481 return Err(invalid_input("jacobian", "rows must match residual length"));
482 }
483 if report.jacobian.ncols() != n {
484 return Err(invalid_input(
485 "jacobian",
486 "columns must match parameter length",
487 ));
488 }
489 covariance_from_jacobian(&report.jacobian, report.cost)
490}
491
492pub struct LeastSquaresProblem<F> {
497 residual: F,
498 sqrt_weights: Option<DVector<f64>>,
500 x0: DVector<f64>,
501}
502
503impl<F> LeastSquaresProblem<F>
504where
505 F: Fn(&DVector<f64>) -> DVector<f64>,
506{
507 pub fn new(residual: F, x0: DVector<f64>) -> Self {
509 Self {
510 residual,
511 sqrt_weights: None,
512 x0,
513 }
514 }
515
516 pub fn with_weights(residual: F, x0: DVector<f64>, weights: DVector<f64>) -> Self {
519 let sqrt_weights = weights.map(f64::sqrt);
520 Self {
521 residual,
522 sqrt_weights: Some(sqrt_weights),
523 x0,
524 }
525 }
526
527 fn weighted_residual(&self, x: &DVector<f64>) -> Result<DVector<f64>, SolveError> {
529 validate_nonempty_vector(x, "parameters")?;
530 validate_vector(x, "parameters")?;
531 let r = (self.residual)(x);
532 validate_nonempty_vector(&r, "residual")?;
533 validate_vector(&r, "residual")?;
534 match &self.sqrt_weights {
535 Some(sw) => {
536 validate_nonempty_vector(sw, "weights")?;
537 validate_vector(sw, "weights")?;
538 if sw.len() != r.len() {
539 return Err(invalid_input("weights", "length mismatch"));
540 }
541 let weighted = r.component_mul(sw);
542 validate_vector(&weighted, "weighted residual")?;
543 Ok(weighted)
544 }
545 None => Ok(r),
546 }
547 }
548}
549
550pub fn solve_trf<F>(
565 problem: &LeastSquaresProblem<F>,
566 opts: &SolveOptions,
567) -> Result<LeastSquaresReport, SolveError>
568where
569 F: Fn(&DVector<f64>) -> DVector<f64>,
570{
571 solve_trf_with(problem, opts, TrustRegionSolve::NalgebraLu)
572}
573
574fn solve_subproblem(
578 lhs: &DMatrix<f64>,
579 rhs: &DVector<f64>,
580 linear_solve: TrustRegionSolve,
581) -> Option<DVector<f64>> {
582 match linear_solve {
583 TrustRegionSolve::NalgebraLu => lhs.clone().lu().solve(rhs),
584 TrustRegionSolve::OwnedGaussianFirstTie => {
585 let n = rhs.len();
586 let a: Vec<Vec<f64>> = (0..n)
587 .map(|i| (0..n).map(|j| lhs[(i, j)]).collect())
588 .collect();
589 let b: Vec<f64> = rhs.iter().copied().collect();
590 crate::astro::math::linear::solve_linear_first_tie(&a, &b).map(DVector::from_vec)
591 }
592 }
593}
594
595pub fn solve_trf_with<F>(
599 problem: &LeastSquaresProblem<F>,
600 opts: &SolveOptions,
601 linear_solve: TrustRegionSolve,
602) -> Result<LeastSquaresReport, SolveError>
603where
604 F: Fn(&DVector<f64>) -> DVector<f64>,
605{
606 validate_options(opts)?;
607 let n = problem.x0.len();
608
609 let mut x = problem.x0.clone();
610 validate_nonempty_vector(&x, "initial parameters")?;
611 validate_vector(&x, "initial parameters")?;
612 let mut r = problem.weighted_residual(&x)?;
613 let mut f0 = r.clone();
614 let mut jac = jacobian_2point_checked(|p| problem.weighted_residual(p), &x, &f0)?;
615 let mut nfev = 1usize; let mut cur_cost = cost(&r)?;
617
618 let jtj0 = jac.transpose() * &jac;
620 validate_matrix(&jtj0, "normal matrix")?;
621 let mut mu = TRF_INITIAL_DAMPING_SCALE
622 * (0..n)
623 .map(|i| jtj0[(i, i)])
624 .fold(0.0_f64, f64::max)
625 .max(1.0);
626
627 let mut iterations = 0usize;
628
629 loop {
630 let jt = jac.transpose();
631 let grad = &jt * &r;
632 validate_vector(&grad, "gradient")?;
633 let optimality_inf = validate_value(grad.amax(), "optimality")?;
634
635 if optimality_inf < opts.gtol {
636 return finish(x, r, cur_cost, jac, iterations, Status::GradientTolerance);
637 }
638 if nfev >= opts.max_nfev {
639 return finish(x, r, cur_cost, jac, iterations, Status::MaxEvaluations);
640 }
641
642 let jtj = &jt * &jac;
643 validate_matrix(&jtj, "normal matrix")?;
644
645 let mut accepted = false;
647 for _ in 0..30 {
648 let mut lhs = jtj.clone();
649 for i in 0..n {
650 lhs[(i, i)] += mu;
651 }
652 let rhs = -&grad;
653 validate_matrix(&lhs, "subproblem matrix")?;
654 validate_vector(&rhs, "subproblem rhs")?;
655 let step = match solve_subproblem(&lhs, &rhs, linear_solve) {
656 Some(s) => s,
657 None => return Err(SolveError::SingularJacobian),
658 };
659 validate_vector(&step, "step")?;
660
661 let x_trial = &x + &step;
662 let r_trial = problem.weighted_residual(&x_trial)?;
663 nfev += 1;
664 let cost_trial = cost(&r_trial)?;
665
666 if cost_trial < cur_cost {
667 let cost_reduction = (cur_cost - cost_trial) / cur_cost.max(f64::MIN_POSITIVE);
669 let step_norm = step.norm();
670 let x_norm = x.norm();
671 let rel_step = step_norm / x_norm.max(f64::MIN_POSITIVE);
672
673 x = x_trial;
674 r = r_trial;
675 cur_cost = cost_trial;
676 f0 = r.clone();
677 jac = jacobian_2point_checked(|p| problem.weighted_residual(p), &x, &f0)?;
678 nfev += n; iterations += 1;
680 mu *= 0.5;
681 accepted = true;
682
683 if cost_reduction < opts.ftol {
684 return finish(x, r, cur_cost, jac, iterations, Status::CostTolerance);
685 }
686 if rel_step < opts.xtol {
687 return finish(x, r, cur_cost, jac, iterations, Status::StepTolerance);
688 }
689 break;
690 } else {
691 mu *= 2.0;
693 }
694 }
695
696 if !accepted {
697 return finish(x, r, cur_cost, jac, iterations, Status::StepTolerance);
699 }
700 }
701}
702
703fn finish(
704 x: DVector<f64>,
705 residual: DVector<f64>,
706 cost_value: f64,
707 jacobian: DMatrix<f64>,
708 iterations: usize,
709 status: Status,
710) -> Result<LeastSquaresReport, SolveError> {
711 validate_nonempty_vector(&x, "solution")?;
712 validate_vector(&x, "solution")?;
713 validate_nonempty_vector(&residual, "residual")?;
714 validate_vector(&residual, "residual")?;
715 validate_value(cost_value, "cost")?;
716 validate_matrix(&jacobian, "jacobian")?;
717 let optimality_inf = validate_value((jacobian.transpose() * &residual).amax(), "optimality")?;
718 Ok(LeastSquaresReport {
719 x,
720 residual,
721 cost: cost_value,
722 jacobian,
723 optimality_inf,
724 iterations,
725 status,
726 })
727}
728
729fn validate_value(value: f64, field: &'static str) -> Result<f64, SolveError> {
730 crate::validate::finite(value, field).map_err(map_field_error)
731}
732
733fn validate_options(opts: &SolveOptions) -> Result<(), SolveError> {
734 crate::validate::positive_step(opts.gtol, "gtol").map_err(map_field_error)?;
735 crate::validate::positive_step(opts.ftol, "ftol").map_err(map_field_error)?;
736 crate::validate::positive_step(opts.xtol, "xtol").map_err(map_field_error)?;
737 if opts.max_nfev == 0 {
738 return Err(invalid_input("max_nfev", "not positive"));
739 }
740 Ok(())
741}
742
743fn validate_nonempty_vector(vector: &DVector<f64>, field: &'static str) -> Result<(), SolveError> {
744 if vector.is_empty() {
745 Err(invalid_input(field, "empty"))
746 } else {
747 Ok(())
748 }
749}
750
751fn validate_vector(vector: &DVector<f64>, field: &'static str) -> Result<(), SolveError> {
752 crate::validate::finite_slice(vector.as_slice(), field).map_err(map_field_error)
753}
754
755fn validate_matrix(matrix: &DMatrix<f64>, field: &'static str) -> Result<(), SolveError> {
756 crate::validate::finite_slice(matrix.as_slice(), field).map_err(map_field_error)
757}
758
759fn map_field_error(error: crate::validate::FieldError) -> SolveError {
760 invalid_input(error.field(), error.reason())
761}
762
763fn invalid_input(field: &'static str, reason: &'static str) -> SolveError {
764 SolveError::InvalidInput { field, reason }
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770
771 #[test]
772 fn fd_rel_step_is_sqrt_eps() {
773 assert_eq!(FD_REL_STEP_2POINT, (2.0_f64.powi(-52)).sqrt());
774 assert_eq!(FD_REL_STEP_2POINT, 2.0_f64.powi(-26));
775 }
776
777 #[test]
778 fn fd_step_sign_convention() {
779 let x0 = DVector::from_vec(vec![5.0, -2.0, 0.0]);
780 let steps = fd_steps(&x0, FD_REL_STEP_2POINT).unwrap();
781 assert_eq!(steps[0].sign_x0, 1.0);
782 assert_eq!(steps[1].sign_x0, -1.0);
783 assert_eq!(steps[2].sign_x0, 1.0); }
785
786 #[test]
787 fn fd_steps_rejects_zero_relative_step() {
788 let x0 = DVector::from_vec(vec![1.0]);
789 assert_invalid_field(fd_steps(&x0, 0.0).unwrap_err(), "rel_step");
790 }
791
792 #[test]
793 fn fd_steps_rejects_nonfinite_parameters() {
794 let x0 = DVector::from_vec(vec![1.0, f64::NAN]);
795 assert_invalid_field(fd_steps(&x0, FD_REL_STEP_2POINT).unwrap_err(), "parameters");
796 }
797
798 #[test]
799 fn jacobian_rejects_residual_length_mismatch() {
800 let x0 = DVector::from_vec(vec![1.0, 2.0]);
801 let f0 = DVector::from_vec(vec![1.0, 2.0]);
802 let residual = |_: &DVector<f64>| DVector::from_vec(vec![1.0]);
803 assert_invalid_field(jacobian_2point(residual, &x0, &f0).unwrap_err(), "residual");
804 }
805
806 #[test]
807 fn cost_rejects_nonfinite_residual() {
808 assert_invalid_field(
809 cost(&DVector::from_vec(vec![1.0, f64::INFINITY])).unwrap_err(),
810 "residual",
811 );
812 }
813
814 #[test]
815 fn exp_fit_converges() {
816 let t = vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0];
818 let y = vec![
819 3.0123, 2.2083, 1.6889, 1.3713, 1.0903, 0.9302, 0.8104, 0.6303,
820 ];
821 let tt = t.clone();
822 let yy = y.clone();
823 let residual = move |p: &DVector<f64>| {
824 let (a, b, c) = (p[0], p[1], p[2]);
825 DVector::from_iterator(
826 tt.len(),
827 tt.iter()
828 .zip(&yy)
829 .map(|(&tk, &yk)| a * (b * tk).exp() + c - yk),
830 )
831 };
832 let problem = LeastSquaresProblem::new(residual, DVector::from_vec(vec![5.0, -2.0, 2.0]));
833 let report = solve_trf(&problem, &SolveOptions::default()).unwrap();
834 assert!(report.cost < 1.0, "cost did not reduce: {}", report.cost);
835 }
836
837 #[test]
838 fn solve_trf_rejects_nonfinite_initial_residual() {
839 fn residual(_: &DVector<f64>) -> DVector<f64> {
840 DVector::from_element(1, f64::NAN)
841 }
842 let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 0.0));
843 assert_invalid_field(
844 solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
845 "residual",
846 );
847 }
848
849 #[test]
850 fn solve_trf_rejects_nonfinite_initial_cost() {
851 fn residual(_: &DVector<f64>) -> DVector<f64> {
852 DVector::from_element(1, f64::MAX)
853 }
854 let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 0.0));
855 assert_invalid_field(
856 solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
857 "cost",
858 );
859 }
860
861 #[test]
862 fn solve_trf_rejects_nonfinite_trial_residual_instead_of_converging() {
863 use std::cell::Cell;
864
865 let calls = Cell::new(0usize);
866 let residual = move |p: &DVector<f64>| {
867 let call = calls.get();
868 calls.set(call + 1);
869 if call >= 2 {
870 DVector::from_element(1, f64::NAN)
871 } else {
872 DVector::from_element(1, p[0])
873 }
874 };
875 let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 1.0));
876 assert_invalid_field(
877 solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
878 "residual",
879 );
880 }
881
882 #[test]
883 fn solve_trf_rejects_invalid_options() {
884 fn residual(p: &DVector<f64>) -> DVector<f64> {
885 DVector::from_element(1, p[0])
886 }
887 let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 1.0));
888 let opts = SolveOptions {
889 gtol: f64::NAN,
890 ..SolveOptions::default()
891 };
892 assert_invalid_field(solve_trf(&problem, &opts).unwrap_err(), "gtol");
893
894 let opts = SolveOptions {
895 max_nfev: 0,
896 ..SolveOptions::default()
897 };
898 assert_invalid_field(solve_trf(&problem, &opts).unwrap_err(), "max_nfev");
899 }
900
901 #[test]
902 fn solve_trf_rejects_weight_residual_dimension_mismatch() {
903 fn residual(_: &DVector<f64>) -> DVector<f64> {
904 DVector::from_vec(vec![1.0, 2.0])
905 }
906 let problem = LeastSquaresProblem::with_weights(
907 residual,
908 DVector::from_element(1, 0.0),
909 DVector::from_vec(vec![1.0]),
910 );
911 assert_invalid_field(
912 solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
913 "weights",
914 );
915 }
916
917 fn assert_invalid_field(error: SolveError, expected: &'static str) {
918 match error {
919 SolveError::InvalidInput { field, .. } => assert_eq!(field, expected),
920 other => panic!("expected invalid input for {expected}, got {other:?}"),
921 }
922 }
923
924 fn exp_fit_problem() -> LeastSquaresProblem<impl Fn(&DVector<f64>) -> DVector<f64>> {
926 let t = [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0];
927 let y = [
928 3.0123, 2.2083, 1.6889, 1.3713, 1.0903, 0.9302, 0.8104, 0.6303,
929 ];
930 let residual = move |p: &DVector<f64>| {
931 let (a, b, c) = (p[0], p[1], p[2]);
932 DVector::from_iterator(
933 t.len(),
934 t.iter()
935 .zip(&y)
936 .map(|(&tk, &yk)| a * (b * tk).exp() + c - yk),
937 )
938 };
939 LeastSquaresProblem::new(residual, DVector::from_vec(vec![5.0, -2.0, 2.0]))
940 }
941
942 #[test]
951 fn owned_trf_converges_to_frozen_bits() {
952 let problem = exp_fit_problem();
953 let report = solve_trf_with(
954 &problem,
955 &SolveOptions::default(),
956 TrustRegionSolve::OwnedGaussianFirstTie,
957 )
958 .unwrap();
959 assert!(
960 report.cost < 1.0,
961 "owned cost did not reduce: {}",
962 report.cost
963 );
964 assert_eq!(report.x[0].to_bits(), 0x4003c3674cdfadef);
965 assert_eq!(report.x[1].to_bits(), 0xbfe799e0d1929220);
966 assert_eq!(report.x[2].to_bits(), 0x3fe0d5c96d9d3b35);
967
968 let again = solve_trf_with(
970 &problem,
971 &SolveOptions::default(),
972 TrustRegionSolve::OwnedGaussianFirstTie,
973 )
974 .unwrap();
975 for i in 0..3 {
976 assert_eq!(report.x[i].to_bits(), again.x[i].to_bits());
977 }
978 }
979
980 fn covariance_fixture_jacobian() -> DMatrix<f64> {
982 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])
984 }
985
986 #[test]
987 fn hessian_trace_matches_numpy() {
988 let trace = hessian_trace(&covariance_fixture_jacobian());
990 assert!((trace - 35.0).abs() < 1e-12, "trace {trace}");
991 }
992
993 #[test]
994 fn normal_covariance_matches_numpy_pcov() {
995 let inv = normal_covariance(&covariance_fixture_jacobian(), 1.0).unwrap();
997 let expected = [[0.6000000000000001, -0.2], [-0.2, 0.1]];
998 for i in 0..2 {
999 for j in 0..2 {
1000 assert!(
1001 (inv[(i, j)] - expected[i][j]).abs() < 1e-12,
1002 "inv[{i}][{j}] = {}",
1003 inv[(i, j)]
1004 );
1005 }
1006 }
1007
1008 let s_sq = 0.085 / 3.0;
1010 let cov = normal_covariance(&covariance_fixture_jacobian(), s_sq).unwrap();
1011 let expected_cov = [
1012 [0.017000000000000005, -0.005666666666666667],
1013 [-0.005666666666666667, 0.0028333333333333335],
1014 ];
1015 for i in 0..2 {
1016 for j in 0..2 {
1017 assert!(
1018 (cov[(i, j)] - expected_cov[i][j]).abs() < 1e-12,
1019 "cov[{i}][{j}] = {}",
1020 cov[(i, j)]
1021 );
1022 }
1023 }
1024 }
1025
1026 #[test]
1027 fn normal_covariance_rejects_underdetermined_and_negative_scale() {
1028 let wide = DMatrix::from_row_slice(2, 3, &[1.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
1029 assert!(matches!(
1030 normal_covariance(&wide, 1.0),
1031 Err(SolveError::InvalidInput {
1032 field: "jacobian",
1033 ..
1034 })
1035 ));
1036 assert!(matches!(
1037 normal_covariance(&covariance_fixture_jacobian(), -1.0),
1038 Err(SolveError::InvalidInput {
1039 field: "variance_scale",
1040 ..
1041 })
1042 ));
1043 }
1044
1045 #[test]
1046 fn normal_covariance_matches_closed_form_inverse_for_collinear_jacobian() {
1047 let eps = 1e-2;
1054 let col1: Vec<f64> = (0..5).map(|k| 1.0 + (k as f64) * eps).collect();
1055 let mut data = Vec::with_capacity(10);
1056 for &c1 in &col1 {
1057 data.push(1.0);
1058 data.push(c1);
1059 }
1060 let jac = DMatrix::from_row_slice(5, 2, &data);
1061 let scale = 2.5;
1062 let cov = normal_covariance(&jac, scale).unwrap();
1063
1064 let s00 = 5.0_f64;
1066 let s01: f64 = col1.iter().sum();
1067 let s11: f64 = col1.iter().map(|c| c * c).sum();
1068 let det = s00 * s11 - s01 * s01;
1069 let inv = [[s11 / det, -s01 / det], [-s01 / det, s00 / det]];
1070 for i in 0..2 {
1071 for j in 0..2 {
1072 let expected = inv[i][j] * scale;
1073 let tol = 1e-9 * expected.abs().max(1.0);
1074 assert!(
1075 (cov[(i, j)] - expected).abs() < tol,
1076 "cov[{i}][{j}] = {} (expected {expected})",
1077 cov[(i, j)]
1078 );
1079 }
1080 }
1081 assert!((cov[(0, 1)] - cov[(1, 0)]).abs() <= 1e-12 * cov[(0, 0)].abs().max(1.0));
1083 }
1084
1085 #[test]
1086 fn covariance_from_report_rejects_jacobian_dimension_mismatch() {
1087 let jac = covariance_fixture_jacobian(); let mismatched_rows = LeastSquaresReport {
1092 x: DVector::from_vec(vec![0.0, 0.0]),
1093 residual: DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05]), cost: 0.1,
1095 jacobian: jac.clone(),
1096 optimality_inf: 0.0,
1097 iterations: 0,
1098 status: Status::GradientTolerance,
1099 };
1100 assert_invalid_field(
1101 covariance_from_report(&mismatched_rows).unwrap_err(),
1102 "jacobian",
1103 );
1104
1105 let mismatched_cols = LeastSquaresReport {
1106 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]),
1108 cost: 0.1,
1109 jacobian: jac,
1110 optimality_inf: 0.0,
1111 iterations: 0,
1112 status: Status::GradientTolerance,
1113 };
1114 assert_invalid_field(
1115 covariance_from_report(&mismatched_cols).unwrap_err(),
1116 "jacobian",
1117 );
1118 }
1119
1120 #[test]
1121 fn covariance_from_jacobian_matches_report_path_bit_for_bit() {
1122 let jac = covariance_fixture_jacobian(); let residual = DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]);
1128 let cost = 0.5 * residual.dot(&residual);
1129 let report = LeastSquaresReport {
1130 x: DVector::from_vec(vec![0.0, 0.0]),
1131 cost,
1132 residual,
1133 jacobian: jac.clone(),
1134 optimality_inf: 0.0,
1135 iterations: 0,
1136 status: Status::GradientTolerance,
1137 };
1138
1139 let from_jac = covariance_from_jacobian(&jac, cost).unwrap();
1140 let from_report = covariance_from_report(&report).unwrap();
1141
1142 let m = jac.nrows();
1143 let n = jac.ncols();
1144 let explicit = normal_covariance(&jac, 2.0 * cost / ((m - n) as f64)).unwrap();
1145
1146 assert_eq!(from_jac.shape(), from_report.shape());
1147 for (a, (b, c)) in from_jac.iter().zip(from_report.iter().zip(explicit.iter())) {
1148 assert_eq!(a.to_bits(), b.to_bits());
1149 assert_eq!(a.to_bits(), c.to_bits());
1150 }
1151 }
1152
1153 #[test]
1154 fn covariance_from_jacobian_rejects_insufficient_dof() {
1155 let square = DMatrix::from_row_slice(2, 2, &[1.0, 0.0, 1.0, 1.0]);
1159 assert_invalid_field(
1160 covariance_from_jacobian(&square, 0.1).unwrap_err(),
1161 "degrees_of_freedom",
1162 );
1163
1164 let wide = DMatrix::from_row_slice(2, 3, &[1.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
1165 assert_invalid_field(
1166 covariance_from_jacobian(&wide, 0.1).unwrap_err(),
1167 "degrees_of_freedom",
1168 );
1169 }
1170
1171 #[test]
1172 fn covariance_from_report_uses_reduced_chi_square() {
1173 let jac = covariance_fixture_jacobian();
1175 let residual = DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]);
1176 let report = LeastSquaresReport {
1177 x: DVector::from_vec(vec![0.0, 0.0]),
1178 cost: 0.5 * residual.dot(&residual),
1179 residual,
1180 jacobian: jac,
1181 optimality_inf: 0.0,
1182 iterations: 0,
1183 status: Status::GradientTolerance,
1184 };
1185 let cov = covariance_from_report(&report).unwrap();
1186 let expected_cov = [
1187 [0.017000000000000005, -0.005666666666666667],
1188 [-0.005666666666666667, 0.0028333333333333335],
1189 ];
1190 for i in 0..2 {
1191 for j in 0..2 {
1192 assert!(
1193 (cov[(i, j)] - expected_cov[i][j]).abs() < 1e-12,
1194 "cov[{i}][{j}] = {}",
1195 cov[(i, j)]
1196 );
1197 }
1198 }
1199 }
1200}