1use nalgebra::{DMatrix, DVector};
53
54pub const FD_REL_STEP_2POINT: f64 = 1.4901161193847656e-8; const TRF_DEFAULT_GTOL: f64 = 1e-10;
61const TRF_DEFAULT_FTOL: f64 = 1e-8;
63const TRF_DEFAULT_XTOL: f64 = 1e-8;
65const TRF_DEFAULT_MAX_NFEV: usize = 300;
67const TRF_INITIAL_DAMPING_SCALE: f64 = 1e-3;
70
71#[derive(Debug, Clone, PartialEq)]
75pub struct FdStep {
76 pub param_index: usize,
78 pub sign_x0: f64,
81 pub h: f64,
83 pub dx: f64,
87 pub x_perturbed: DVector<f64>,
89}
90
91pub fn fd_steps(x0: &DVector<f64>, rel_step: f64) -> Result<Vec<FdStep>, SolveError> {
97 let rel_step = crate::validate::positive_step(rel_step, "rel_step").map_err(map_field_error)?;
98 fd_steps_checked(x0, rel_step)
99}
100
101fn fd_steps_checked(x0: &DVector<f64>, rel_step: f64) -> Result<Vec<FdStep>, SolveError> {
102 fd_steps_checked_with_min_steps(x0, rel_step, None)
103}
104
105fn fd_steps_checked_with_min_steps(
106 x0: &DVector<f64>,
107 rel_step: f64,
108 min_steps: Option<&DVector<f64>>,
109) -> Result<Vec<FdStep>, SolveError> {
110 validate_nonempty_vector(x0, "parameters")?;
111 validate_vector(x0, "parameters")?;
112 if let Some(min_steps) = min_steps {
113 if min_steps.len() != x0.len() {
114 return Err(invalid_input("fd_min_steps", "length mismatch"));
115 }
116 for &min_step in min_steps.iter() {
117 crate::validate::finite_nonneg(min_step, "fd_min_steps").map_err(map_field_error)?;
118 }
119 }
120 let steps = fd_steps_unchecked(x0, rel_step, min_steps);
121 for step in &steps {
122 validate_value(step.h, "fd_step")?;
123 validate_value(step.dx, "fd_step")?;
124 if step.dx == 0.0 {
125 return Err(invalid_input("fd_step", "zero"));
126 }
127 validate_vector(&step.x_perturbed, "perturbed parameters")?;
128 }
129 Ok(steps)
130}
131
132fn fd_steps_unchecked(
133 x0: &DVector<f64>,
134 rel_step: f64,
135 min_steps: Option<&DVector<f64>>,
136) -> Vec<FdStep> {
137 (0..x0.len())
138 .map(|i| {
139 let xi = x0[i];
140 let sign_x0 = if xi >= 0.0 { 1.0 } else { -1.0 };
141 let relative_h = rel_step * xi.abs().max(1.0);
142 let min_h = min_steps.map_or(0.0, |steps| steps[i]);
143 let h = sign_x0 * relative_h.max(min_h);
144 let mut x_perturbed = x0.clone();
145 x_perturbed[i] = xi + h;
146 let dx = x_perturbed[i] - xi;
147 FdStep {
148 param_index: i,
149 sign_x0,
150 h,
151 dx,
152 x_perturbed,
153 }
154 })
155 .collect()
156}
157
158pub fn jacobian_2point<F>(
169 residual: F,
170 x0: &DVector<f64>,
171 f0: &DVector<f64>,
172) -> Result<DMatrix<f64>, SolveError>
173where
174 F: Fn(&DVector<f64>) -> DVector<f64>,
175{
176 jacobian_2point_checked_with_min_steps(|x| Ok(residual(x)), x0, f0, None)
177}
178
179#[cfg(test)]
184pub(crate) fn jacobian_2point_with_min_steps<F>(
185 residual: F,
186 x0: &DVector<f64>,
187 f0: &DVector<f64>,
188 min_steps: &DVector<f64>,
189) -> Result<DMatrix<f64>, SolveError>
190where
191 F: Fn(&DVector<f64>) -> DVector<f64>,
192{
193 jacobian_2point_checked_with_min_steps(|x| Ok(residual(x)), x0, f0, Some(min_steps))
194}
195
196fn jacobian_2point_checked_with_min_steps<F>(
197 residual: F,
198 x0: &DVector<f64>,
199 f0: &DVector<f64>,
200 min_steps: Option<&DVector<f64>>,
201) -> Result<DMatrix<f64>, SolveError>
202where
203 F: Fn(&DVector<f64>) -> Result<DVector<f64>, SolveError>,
204{
205 validate_nonempty_vector(x0, "parameters")?;
206 validate_vector(x0, "parameters")?;
207 validate_nonempty_vector(f0, "residual")?;
208 validate_vector(f0, "residual")?;
209 let m = f0.len();
210 let n = x0.len();
211 let steps = fd_steps_checked_with_min_steps(x0, FD_REL_STEP_2POINT, min_steps)?;
212 let mut jac = DMatrix::zeros(m, n);
213 for step in &steps {
214 let f1 = residual(&step.x_perturbed)?;
215 validate_nonempty_vector(&f1, "residual")?;
216 validate_vector(&f1, "residual")?;
217 if f1.len() != m {
218 return Err(invalid_input("residual", "length mismatch"));
219 }
220 let i = step.param_index;
221 for row in 0..m {
222 jac[(row, i)] = (f1[row] - f0[row]) / step.dx;
223 }
224 }
225 validate_matrix(&jac, "jacobian")?;
226 Ok(jac)
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum Status {
233 GradientTolerance,
235 CostTolerance,
237 StepTolerance,
239 MaxEvaluations,
241}
242
243#[derive(Debug, Clone, Copy)]
245pub struct SolveOptions {
246 pub gtol: f64,
248 pub ftol: f64,
250 pub xtol: f64,
252 pub max_nfev: usize,
254}
255
256impl Default for SolveOptions {
257 fn default() -> Self {
258 Self {
260 gtol: TRF_DEFAULT_GTOL,
261 ftol: TRF_DEFAULT_FTOL,
262 xtol: TRF_DEFAULT_XTOL,
263 max_nfev: TRF_DEFAULT_MAX_NFEV,
264 }
265 }
266}
267
268#[derive(Debug, Clone)]
270pub struct LeastSquaresReport {
271 pub x: DVector<f64>,
273 pub residual: DVector<f64>,
275 pub cost: f64,
277 pub jacobian: DMatrix<f64>,
279 pub optimality_inf: f64,
281 pub iterations: usize,
283 pub status: Status,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
292pub enum TrustRegionSolve {
293 #[default]
297 NalgebraLu,
298 OwnedGaussianFirstTie,
305}
306
307#[derive(Debug, Clone, thiserror::Error)]
309pub enum SolveError {
310 #[error("singular or rank-deficient Jacobian: no usable descent direction")]
313 SingularJacobian,
314 #[error("invalid least-squares {field}: {reason}")]
316 InvalidInput {
317 field: &'static str,
318 reason: &'static str,
319 },
320}
321
322pub fn cost(residual: &DVector<f64>) -> Result<f64, SolveError> {
324 validate_nonempty_vector(residual, "residual")?;
325 validate_vector(residual, "residual")?;
326 validate_value(0.5 * residual.dot(residual), "cost")
327}
328
329pub fn normal_covariance(
355 jacobian: &DMatrix<f64>,
356 variance_scale: f64,
357) -> Result<DMatrix<f64>, SolveError> {
358 validate_matrix(jacobian, "jacobian")?;
359 let m = jacobian.nrows();
360 let n = jacobian.ncols();
361 if n == 0 || m == 0 {
362 return Err(invalid_input("jacobian", "empty"));
363 }
364 if m < n {
365 return Err(invalid_input("jacobian", "fewer rows than columns"));
366 }
367 crate::validate::finite_nonneg(variance_scale, "variance_scale").map_err(map_field_error)?;
368
369 let svd = jacobian.clone().svd(false, true);
371 let v_t = svd.v_t.ok_or(SolveError::SingularJacobian)?;
372 let singular = svd.singular_values;
373
374 let diagnostics = singular_value_diagnostics(singular.as_slice(), m, n);
379 if diagnostics.rank < n {
380 return Err(SolveError::SingularJacobian);
381 }
382 debug_assert!(diagnostics.condition_number.is_finite());
383
384 let mut cov = DMatrix::zeros(n, n);
387 for i in 0..n {
388 for j in 0..n {
389 let mut acc = 0.0;
390 for k in 0..n {
391 let inv_s2 = 1.0 / (singular[k] * singular[k]);
392 acc += v_t[(k, i)] * v_t[(k, j)] * inv_s2;
393 }
394 cov[(i, j)] = acc * variance_scale;
395 }
396 }
397 validate_matrix(&cov, "covariance")?;
398 Ok(cov)
399}
400
401pub fn hessian_trace(jacobian: &DMatrix<f64>) -> f64 {
408 let n = jacobian.ncols();
409 let m = jacobian.nrows();
410 let mut trace = 0.0;
411 for i in 0..n {
412 let mut col = 0.0;
413 for r in 0..m {
414 let v = jacobian[(r, i)];
415 col += v * v;
416 }
417 trace += col;
418 }
419 trace
420}
421
422#[derive(Debug, Clone, Copy, PartialEq)]
423pub(crate) struct SingularValueDiagnostics {
424 pub(crate) rank: usize,
425 pub(crate) condition_number: f64,
426}
427
428pub(crate) fn singular_value_diagnostics(
429 singular_values: &[f64],
430 rows: usize,
431 cols: usize,
432) -> SingularValueDiagnostics {
433 let smax = singular_values.iter().copied().fold(0.0_f64, f64::max);
434 if smax == 0.0 {
435 return SingularValueDiagnostics {
436 rank: 0,
437 condition_number: f64::INFINITY,
438 };
439 }
440
441 let threshold = smax * (rows.max(cols) as f64) * f64::EPSILON;
442 let rank = singular_values.iter().filter(|&&s| s > threshold).count();
443 let condition_number = if rank < cols {
444 f64::INFINITY
445 } else {
446 let smin = singular_values
447 .iter()
448 .copied()
449 .fold(f64::INFINITY, f64::min);
450 smax / smin
451 };
452
453 SingularValueDiagnostics {
454 rank,
455 condition_number,
456 }
457}
458
459pub fn covariance_from_jacobian(
472 jacobian: &DMatrix<f64>,
473 cost: f64,
474) -> Result<DMatrix<f64>, SolveError> {
475 let m = jacobian.nrows();
476 let n = jacobian.ncols();
477 if m <= n {
478 return Err(invalid_input("degrees_of_freedom", "not positive"));
479 }
480 let dof = (m - n) as f64;
481 let s_sq = validate_value(2.0 * cost / dof, "reduced_chi_square")?;
482 normal_covariance(jacobian, s_sq)
483}
484
485pub fn covariance_from_report(report: &LeastSquaresReport) -> Result<DMatrix<f64>, SolveError> {
493 let m = report.residual.len();
494 let n = report.x.len();
495 if report.jacobian.nrows() != m {
501 return Err(invalid_input("jacobian", "rows must match residual length"));
502 }
503 if report.jacobian.ncols() != n {
504 return Err(invalid_input(
505 "jacobian",
506 "columns must match parameter length",
507 ));
508 }
509 covariance_from_jacobian(&report.jacobian, report.cost)
510}
511
512pub struct LeastSquaresProblem<F> {
517 residual: F,
518 sqrt_weights: Option<DVector<f64>>,
520 fd_min_steps: Option<DVector<f64>>,
522 x0: DVector<f64>,
523}
524
525impl<F> LeastSquaresProblem<F>
526where
527 F: Fn(&DVector<f64>) -> DVector<f64>,
528{
529 pub fn new(residual: F, x0: DVector<f64>) -> Self {
531 Self {
532 residual,
533 sqrt_weights: None,
534 fd_min_steps: None,
535 x0,
536 }
537 }
538
539 pub fn with_weights(residual: F, x0: DVector<f64>, weights: DVector<f64>) -> Self {
542 let sqrt_weights = weights.map(f64::sqrt);
543 Self {
544 residual,
545 sqrt_weights: Some(sqrt_weights),
546 fd_min_steps: None,
547 x0,
548 }
549 }
550
551 pub fn with_weights_and_fd_min_steps(
554 residual: F,
555 x0: DVector<f64>,
556 weights: DVector<f64>,
557 fd_min_steps: DVector<f64>,
558 ) -> Self {
559 let sqrt_weights = weights.map(f64::sqrt);
560 Self {
561 residual,
562 sqrt_weights: Some(sqrt_weights),
563 fd_min_steps: Some(fd_min_steps),
564 x0,
565 }
566 }
567
568 fn weighted_residual(&self, x: &DVector<f64>) -> Result<DVector<f64>, SolveError> {
570 validate_nonempty_vector(x, "parameters")?;
571 validate_vector(x, "parameters")?;
572 let r = (self.residual)(x);
573 validate_nonempty_vector(&r, "residual")?;
574 validate_vector(&r, "residual")?;
575 match &self.sqrt_weights {
576 Some(sw) => {
577 validate_nonempty_vector(sw, "weights")?;
578 validate_vector(sw, "weights")?;
579 if sw.len() != r.len() {
580 return Err(invalid_input("weights", "length mismatch"));
581 }
582 let weighted = r.component_mul(sw);
583 validate_vector(&weighted, "weighted residual")?;
584 Ok(weighted)
585 }
586 None => Ok(r),
587 }
588 }
589
590 fn jacobian(&self, x: &DVector<f64>, f0: &DVector<f64>) -> Result<DMatrix<f64>, SolveError> {
591 jacobian_2point_checked_with_min_steps(
592 |p| self.weighted_residual(p),
593 x,
594 f0,
595 self.fd_min_steps.as_ref(),
596 )
597 }
598}
599
600pub fn solve_trf<F>(
615 problem: &LeastSquaresProblem<F>,
616 opts: &SolveOptions,
617) -> Result<LeastSquaresReport, SolveError>
618where
619 F: Fn(&DVector<f64>) -> DVector<f64>,
620{
621 solve_trf_with(problem, opts, TrustRegionSolve::NalgebraLu)
622}
623
624fn solve_subproblem(
628 lhs: &DMatrix<f64>,
629 rhs: &DVector<f64>,
630 linear_solve: TrustRegionSolve,
631) -> Option<DVector<f64>> {
632 match linear_solve {
633 TrustRegionSolve::NalgebraLu => lhs.clone().lu().solve(rhs),
634 TrustRegionSolve::OwnedGaussianFirstTie => {
635 let n = rhs.len();
636 let a: Vec<Vec<f64>> = (0..n)
637 .map(|i| (0..n).map(|j| lhs[(i, j)]).collect())
638 .collect();
639 let b: Vec<f64> = rhs.iter().copied().collect();
640 crate::astro::math::linear::solve_linear_first_tie(&a, &b).map(DVector::from_vec)
641 }
642 }
643}
644
645fn normal_matrix_scalar(jacobian: &DMatrix<f64>) -> DMatrix<f64> {
646 let rows = jacobian.nrows();
647 let cols = jacobian.ncols();
648 let mut result = DMatrix::zeros(cols, cols);
649 for column in 0..cols {
650 for other_column in 0..cols {
651 let mut sum = 0.0_f64;
652 for row in 0..rows {
653 sum += jacobian[(row, column)] * jacobian[(row, other_column)];
654 }
655 result[(column, other_column)] = sum;
656 }
657 }
658 result
659}
660
661fn gradient_scalar(jacobian: &DMatrix<f64>, residual: &DVector<f64>) -> DVector<f64> {
662 let rows = jacobian.nrows();
663 let cols = jacobian.ncols();
664 DVector::from_iterator(
665 cols,
666 (0..cols).map(|column| {
667 let mut sum = 0.0_f64;
668 for row in 0..rows {
669 sum += jacobian[(row, column)] * residual[row];
670 }
671 sum
672 }),
673 )
674}
675
676fn dot_scalar(lhs: &DVector<f64>, rhs: &DVector<f64>) -> f64 {
677 let mut sum = 0.0_f64;
678 for index in 0..lhs.len() {
679 sum += lhs[index] * rhs[index];
680 }
681 sum
682}
683
684fn norm_scalar(vector: &DVector<f64>) -> f64 {
685 dot_scalar(vector, vector).sqrt()
686}
687
688fn amax_scalar(vector: &DVector<f64>) -> f64 {
689 vector
690 .iter()
691 .map(|value| value.abs())
692 .fold(0.0_f64, f64::max)
693}
694
695fn add_scalar(lhs: &DVector<f64>, rhs: &DVector<f64>) -> DVector<f64> {
696 DVector::from_iterator(
697 lhs.len(),
698 (0..lhs.len()).map(|index| lhs[index] + rhs[index]),
699 )
700}
701
702pub fn solve_trf_with<F>(
707 problem: &LeastSquaresProblem<F>,
708 opts: &SolveOptions,
709 linear_solve: TrustRegionSolve,
710) -> Result<LeastSquaresReport, SolveError>
711where
712 F: Fn(&DVector<f64>) -> DVector<f64>,
713{
714 validate_options(opts)?;
715 let n = problem.x0.len();
716
717 let mut x = problem.x0.clone();
718 validate_nonempty_vector(&x, "initial parameters")?;
719 validate_vector(&x, "initial parameters")?;
720 let mut r = problem.weighted_residual(&x)?;
721 let mut f0 = r.clone();
722 let mut jac = problem.jacobian(&x, &f0)?;
723 let mut nfev = 1usize; let scalar_reductions = linear_solve == TrustRegionSolve::OwnedGaussianFirstTie;
725 let mut cur_cost = if scalar_reductions {
726 validate_value(0.5 * dot_scalar(&r, &r), "cost")?
727 } else {
728 cost(&r)?
729 };
730
731 let jtj0 = if scalar_reductions {
733 normal_matrix_scalar(&jac)
734 } else {
735 jac.transpose() * &jac
736 };
737 validate_matrix(&jtj0, "normal matrix")?;
738 let mut mu = TRF_INITIAL_DAMPING_SCALE
739 * (0..n)
740 .map(|i| jtj0[(i, i)])
741 .fold(0.0_f64, f64::max)
742 .max(1.0);
743
744 let mut iterations = 0usize;
745
746 loop {
747 let grad = if scalar_reductions {
748 gradient_scalar(&jac, &r)
749 } else {
750 let jt = jac.transpose();
751 &jt * &r
752 };
753 validate_vector(&grad, "gradient")?;
754 let optimality_inf = validate_value(
755 if scalar_reductions {
756 amax_scalar(&grad)
757 } else {
758 grad.amax()
759 },
760 "optimality",
761 )?;
762
763 if optimality_inf < opts.gtol {
764 return finish(
765 x,
766 r,
767 cur_cost,
768 jac,
769 iterations,
770 Status::GradientTolerance,
771 scalar_reductions,
772 );
773 }
774 if nfev >= opts.max_nfev {
775 return finish(
776 x,
777 r,
778 cur_cost,
779 jac,
780 iterations,
781 Status::MaxEvaluations,
782 scalar_reductions,
783 );
784 }
785
786 let jtj = if scalar_reductions {
787 normal_matrix_scalar(&jac)
788 } else {
789 let jt = jac.transpose();
790 &jt * &jac
791 };
792 validate_matrix(&jtj, "normal matrix")?;
793
794 let mut accepted = false;
796 for _ in 0..30 {
797 let mut lhs = jtj.clone();
798 for i in 0..n {
799 lhs[(i, i)] += mu;
800 }
801 let rhs = -&grad;
802 validate_matrix(&lhs, "subproblem matrix")?;
803 validate_vector(&rhs, "subproblem rhs")?;
804 let step = match solve_subproblem(&lhs, &rhs, linear_solve) {
805 Some(s) => s,
806 None => return Err(SolveError::SingularJacobian),
807 };
808 validate_vector(&step, "step")?;
809
810 let x_trial = if scalar_reductions {
811 add_scalar(&x, &step)
812 } else {
813 &x + &step
814 };
815 let r_trial = problem.weighted_residual(&x_trial)?;
816 nfev += 1;
817 let cost_trial = if scalar_reductions {
818 validate_value(0.5 * dot_scalar(&r_trial, &r_trial), "cost")?
819 } else {
820 cost(&r_trial)?
821 };
822
823 if cost_trial < cur_cost {
824 let cost_reduction = (cur_cost - cost_trial) / cur_cost.max(f64::MIN_POSITIVE);
826 let step_norm = if scalar_reductions {
827 norm_scalar(&step)
828 } else {
829 step.norm()
830 };
831 let x_norm = if scalar_reductions {
832 norm_scalar(&x)
833 } else {
834 x.norm()
835 };
836 let rel_step = step_norm / x_norm.max(f64::MIN_POSITIVE);
837
838 x = x_trial;
839 r = r_trial;
840 cur_cost = cost_trial;
841 f0 = r.clone();
842 jac = problem.jacobian(&x, &f0)?;
843 nfev += n; iterations += 1;
845 mu *= 0.5;
846 accepted = true;
847
848 if cost_reduction < opts.ftol {
849 return finish(
850 x,
851 r,
852 cur_cost,
853 jac,
854 iterations,
855 Status::CostTolerance,
856 scalar_reductions,
857 );
858 }
859 if rel_step < opts.xtol {
860 return finish(
861 x,
862 r,
863 cur_cost,
864 jac,
865 iterations,
866 Status::StepTolerance,
867 scalar_reductions,
868 );
869 }
870 break;
871 } else {
872 mu *= 2.0;
874 }
875 }
876
877 if !accepted {
878 return finish(
880 x,
881 r,
882 cur_cost,
883 jac,
884 iterations,
885 Status::StepTolerance,
886 scalar_reductions,
887 );
888 }
889 }
890}
891
892fn finish(
893 x: DVector<f64>,
894 residual: DVector<f64>,
895 cost_value: f64,
896 jacobian: DMatrix<f64>,
897 iterations: usize,
898 status: Status,
899 scalar_reductions: bool,
900) -> Result<LeastSquaresReport, SolveError> {
901 validate_nonempty_vector(&x, "solution")?;
902 validate_vector(&x, "solution")?;
903 validate_nonempty_vector(&residual, "residual")?;
904 validate_vector(&residual, "residual")?;
905 validate_value(cost_value, "cost")?;
906 validate_matrix(&jacobian, "jacobian")?;
907 let optimality_inf = validate_value(
908 if scalar_reductions {
909 amax_scalar(&gradient_scalar(&jacobian, &residual))
910 } else {
911 (jacobian.transpose() * &residual).amax()
912 },
913 "optimality",
914 )?;
915 Ok(LeastSquaresReport {
916 x,
917 residual,
918 cost: cost_value,
919 jacobian,
920 optimality_inf,
921 iterations,
922 status,
923 })
924}
925
926fn validate_value(value: f64, field: &'static str) -> Result<f64, SolveError> {
927 crate::validate::finite(value, field).map_err(map_field_error)
928}
929
930fn validate_options(opts: &SolveOptions) -> Result<(), SolveError> {
931 crate::validate::positive_step(opts.gtol, "gtol").map_err(map_field_error)?;
932 crate::validate::positive_step(opts.ftol, "ftol").map_err(map_field_error)?;
933 crate::validate::positive_step(opts.xtol, "xtol").map_err(map_field_error)?;
934 if opts.max_nfev == 0 {
935 return Err(invalid_input("max_nfev", "not positive"));
936 }
937 Ok(())
938}
939
940fn validate_nonempty_vector(vector: &DVector<f64>, field: &'static str) -> Result<(), SolveError> {
941 if vector.is_empty() {
942 Err(invalid_input(field, "empty"))
943 } else {
944 Ok(())
945 }
946}
947
948fn validate_vector(vector: &DVector<f64>, field: &'static str) -> Result<(), SolveError> {
949 crate::validate::finite_slice(vector.as_slice(), field).map_err(map_field_error)
950}
951
952fn validate_matrix(matrix: &DMatrix<f64>, field: &'static str) -> Result<(), SolveError> {
953 crate::validate::finite_slice(matrix.as_slice(), field).map_err(map_field_error)
954}
955
956fn map_field_error(error: crate::validate::FieldError) -> SolveError {
957 invalid_input(error.field(), error.reason())
958}
959
960fn invalid_input(field: &'static str, reason: &'static str) -> SolveError {
961 SolveError::InvalidInput { field, reason }
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967
968 #[test]
969 fn fd_rel_step_is_sqrt_eps() {
970 assert_eq!(FD_REL_STEP_2POINT, (2.0_f64.powi(-52)).sqrt());
971 assert_eq!(FD_REL_STEP_2POINT, 2.0_f64.powi(-26));
972 }
973
974 #[test]
975 fn fd_step_sign_convention() {
976 let x0 = DVector::from_vec(vec![5.0, -2.0, 0.0]);
977 let steps = fd_steps(&x0, FD_REL_STEP_2POINT).unwrap();
978 assert_eq!(steps[0].sign_x0, 1.0);
979 assert_eq!(steps[1].sign_x0, -1.0);
980 assert_eq!(steps[2].sign_x0, 1.0); }
982
983 #[test]
984 fn fd_steps_rejects_zero_relative_step() {
985 let x0 = DVector::from_vec(vec![1.0]);
986 assert_invalid_field(fd_steps(&x0, 0.0).unwrap_err(), "rel_step");
987 }
988
989 #[test]
990 fn fd_steps_rejects_nonfinite_parameters() {
991 let x0 = DVector::from_vec(vec![1.0, f64::NAN]);
992 assert_invalid_field(fd_steps(&x0, FD_REL_STEP_2POINT).unwrap_err(), "parameters");
993 }
994
995 #[test]
996 fn jacobian_rejects_residual_length_mismatch() {
997 let x0 = DVector::from_vec(vec![1.0, 2.0]);
998 let f0 = DVector::from_vec(vec![1.0, 2.0]);
999 let residual = |_: &DVector<f64>| DVector::from_vec(vec![1.0]);
1000 assert_invalid_field(jacobian_2point(residual, &x0, &f0).unwrap_err(), "residual");
1001 }
1002
1003 #[test]
1004 fn cost_rejects_nonfinite_residual() {
1005 assert_invalid_field(
1006 cost(&DVector::from_vec(vec![1.0, f64::INFINITY])).unwrap_err(),
1007 "residual",
1008 );
1009 }
1010
1011 #[test]
1012 fn exp_fit_converges() {
1013 let t = vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0];
1015 let y = vec![
1016 3.0123, 2.2083, 1.6889, 1.3713, 1.0903, 0.9302, 0.8104, 0.6303,
1017 ];
1018 let tt = t.clone();
1019 let yy = y.clone();
1020 let residual = move |p: &DVector<f64>| {
1021 let (a, b, c) = (p[0], p[1], p[2]);
1022 DVector::from_iterator(
1023 tt.len(),
1024 tt.iter()
1025 .zip(&yy)
1026 .map(|(&tk, &yk)| a * libm::exp(b * tk) + c - yk),
1027 )
1028 };
1029 let problem = LeastSquaresProblem::new(residual, DVector::from_vec(vec![5.0, -2.0, 2.0]));
1030 let report = solve_trf(&problem, &SolveOptions::default()).unwrap();
1031 assert!(report.cost < 1.0, "cost did not reduce: {}", report.cost);
1032 }
1033
1034 #[test]
1035 fn solve_trf_rejects_nonfinite_initial_residual() {
1036 fn residual(_: &DVector<f64>) -> DVector<f64> {
1037 DVector::from_element(1, f64::NAN)
1038 }
1039 let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 0.0));
1040 assert_invalid_field(
1041 solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
1042 "residual",
1043 );
1044 }
1045
1046 #[test]
1047 fn solve_trf_rejects_nonfinite_initial_cost() {
1048 fn residual(_: &DVector<f64>) -> DVector<f64> {
1049 DVector::from_element(1, f64::MAX)
1050 }
1051 let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 0.0));
1052 assert_invalid_field(
1053 solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
1054 "cost",
1055 );
1056 }
1057
1058 #[test]
1059 fn solve_trf_rejects_nonfinite_trial_residual_instead_of_converging() {
1060 use std::cell::Cell;
1061
1062 let calls = Cell::new(0usize);
1063 let residual = move |p: &DVector<f64>| {
1064 let call = calls.get();
1065 calls.set(call + 1);
1066 if call >= 2 {
1067 DVector::from_element(1, f64::NAN)
1068 } else {
1069 DVector::from_element(1, p[0])
1070 }
1071 };
1072 let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 1.0));
1073 assert_invalid_field(
1074 solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
1075 "residual",
1076 );
1077 }
1078
1079 #[test]
1080 fn solve_trf_rejects_invalid_options() {
1081 fn residual(p: &DVector<f64>) -> DVector<f64> {
1082 DVector::from_element(1, p[0])
1083 }
1084 let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 1.0));
1085 let opts = SolveOptions {
1086 gtol: f64::NAN,
1087 ..SolveOptions::default()
1088 };
1089 assert_invalid_field(solve_trf(&problem, &opts).unwrap_err(), "gtol");
1090
1091 let opts = SolveOptions {
1092 max_nfev: 0,
1093 ..SolveOptions::default()
1094 };
1095 assert_invalid_field(solve_trf(&problem, &opts).unwrap_err(), "max_nfev");
1096 }
1097
1098 #[test]
1099 fn solve_trf_rejects_weight_residual_dimension_mismatch() {
1100 fn residual(_: &DVector<f64>) -> DVector<f64> {
1101 DVector::from_vec(vec![1.0, 2.0])
1102 }
1103 let problem = LeastSquaresProblem::with_weights(
1104 residual,
1105 DVector::from_element(1, 0.0),
1106 DVector::from_vec(vec![1.0]),
1107 );
1108 assert_invalid_field(
1109 solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
1110 "weights",
1111 );
1112 }
1113
1114 fn assert_invalid_field(error: SolveError, expected: &'static str) {
1115 match error {
1116 SolveError::InvalidInput { field, .. } => assert_eq!(field, expected),
1117 other => panic!("expected invalid input for {expected}, got {other:?}"),
1118 }
1119 }
1120
1121 fn exp_fit_problem() -> LeastSquaresProblem<impl Fn(&DVector<f64>) -> DVector<f64>> {
1123 let t = [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0];
1124 let y = [
1125 3.0123, 2.2083, 1.6889, 1.3713, 1.0903, 0.9302, 0.8104, 0.6303,
1126 ];
1127 let residual = move |p: &DVector<f64>| {
1128 let (a, b, c) = (p[0], p[1], p[2]);
1129 DVector::from_iterator(
1130 t.len(),
1131 t.iter()
1132 .zip(&y)
1133 .map(|(&tk, &yk)| a * (b * tk).exp() + c - yk),
1134 )
1135 };
1136 LeastSquaresProblem::new(residual, DVector::from_vec(vec![5.0, -2.0, 2.0]))
1137 }
1138
1139 #[test]
1148 fn owned_trf_converges_to_frozen_bits() {
1149 let problem = exp_fit_problem();
1150 let report = solve_trf_with(
1151 &problem,
1152 &SolveOptions::default(),
1153 TrustRegionSolve::OwnedGaussianFirstTie,
1154 )
1155 .unwrap();
1156 assert!(
1157 report.cost < 1.0,
1158 "owned cost did not reduce: {}",
1159 report.cost
1160 );
1161 assert_eq!(report.x[0].to_bits(), 0x4003c3674cdfadef);
1162 assert_eq!(report.x[1].to_bits(), 0xbfe799e0d1929220);
1163 assert_eq!(report.x[2].to_bits(), 0x3fe0d5c96d9d3b35);
1164
1165 let again = solve_trf_with(
1167 &problem,
1168 &SolveOptions::default(),
1169 TrustRegionSolve::OwnedGaussianFirstTie,
1170 )
1171 .unwrap();
1172 for i in 0..3 {
1173 assert_eq!(report.x[i].to_bits(), again.x[i].to_bits());
1174 }
1175 }
1176
1177 fn covariance_fixture_jacobian() -> DMatrix<f64> {
1179 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])
1181 }
1182
1183 #[test]
1184 fn hessian_trace_matches_numpy() {
1185 let trace = hessian_trace(&covariance_fixture_jacobian());
1187 assert!((trace - 35.0).abs() < 1e-12, "trace {trace}");
1188 }
1189
1190 #[test]
1191 fn normal_covariance_matches_numpy_pcov() {
1192 let inv = normal_covariance(&covariance_fixture_jacobian(), 1.0).unwrap();
1194 let expected = [[0.6000000000000001, -0.2], [-0.2, 0.1]];
1195 for i in 0..2 {
1196 for j in 0..2 {
1197 assert!(
1198 (inv[(i, j)] - expected[i][j]).abs() < 1e-12,
1199 "inv[{i}][{j}] = {}",
1200 inv[(i, j)]
1201 );
1202 }
1203 }
1204
1205 let s_sq = 0.085 / 3.0;
1207 let cov = normal_covariance(&covariance_fixture_jacobian(), s_sq).unwrap();
1208 let expected_cov = [
1209 [0.017000000000000005, -0.005666666666666667],
1210 [-0.005666666666666667, 0.0028333333333333335],
1211 ];
1212 for i in 0..2 {
1213 for j in 0..2 {
1214 assert!(
1215 (cov[(i, j)] - expected_cov[i][j]).abs() < 1e-12,
1216 "cov[{i}][{j}] = {}",
1217 cov[(i, j)]
1218 );
1219 }
1220 }
1221 }
1222
1223 #[test]
1224 fn normal_covariance_rejects_underdetermined_and_negative_scale() {
1225 let wide = DMatrix::from_row_slice(2, 3, &[1.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
1226 assert!(matches!(
1227 normal_covariance(&wide, 1.0),
1228 Err(SolveError::InvalidInput {
1229 field: "jacobian",
1230 ..
1231 })
1232 ));
1233 assert!(matches!(
1234 normal_covariance(&covariance_fixture_jacobian(), -1.0),
1235 Err(SolveError::InvalidInput {
1236 field: "variance_scale",
1237 ..
1238 })
1239 ));
1240 }
1241
1242 #[test]
1243 fn normal_covariance_matches_closed_form_inverse_for_collinear_jacobian() {
1244 let eps = 1e-2;
1251 let col1: Vec<f64> = (0..5).map(|k| 1.0 + (k as f64) * eps).collect();
1252 let mut data = Vec::with_capacity(10);
1253 for &c1 in &col1 {
1254 data.push(1.0);
1255 data.push(c1);
1256 }
1257 let jac = DMatrix::from_row_slice(5, 2, &data);
1258 let scale = 2.5;
1259 let cov = normal_covariance(&jac, scale).unwrap();
1260
1261 let s00 = 5.0_f64;
1263 let s01: f64 = col1.iter().sum();
1264 let s11: f64 = col1.iter().map(|c| c * c).sum();
1265 let det = s00 * s11 - s01 * s01;
1266 let inv = [[s11 / det, -s01 / det], [-s01 / det, s00 / det]];
1267 for i in 0..2 {
1268 for j in 0..2 {
1269 let expected = inv[i][j] * scale;
1270 let tol = 1e-9 * expected.abs().max(1.0);
1271 assert!(
1272 (cov[(i, j)] - expected).abs() < tol,
1273 "cov[{i}][{j}] = {} (expected {expected})",
1274 cov[(i, j)]
1275 );
1276 }
1277 }
1278 assert!((cov[(0, 1)] - cov[(1, 0)]).abs() <= 1e-12 * cov[(0, 0)].abs().max(1.0));
1280 }
1281
1282 #[test]
1283 fn covariance_from_report_rejects_jacobian_dimension_mismatch() {
1284 let jac = covariance_fixture_jacobian(); let mismatched_rows = LeastSquaresReport {
1289 x: DVector::from_vec(vec![0.0, 0.0]),
1290 residual: DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05]), cost: 0.1,
1292 jacobian: jac.clone(),
1293 optimality_inf: 0.0,
1294 iterations: 0,
1295 status: Status::GradientTolerance,
1296 };
1297 assert_invalid_field(
1298 covariance_from_report(&mismatched_rows).unwrap_err(),
1299 "jacobian",
1300 );
1301
1302 let mismatched_cols = LeastSquaresReport {
1303 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]),
1305 cost: 0.1,
1306 jacobian: jac,
1307 optimality_inf: 0.0,
1308 iterations: 0,
1309 status: Status::GradientTolerance,
1310 };
1311 assert_invalid_field(
1312 covariance_from_report(&mismatched_cols).unwrap_err(),
1313 "jacobian",
1314 );
1315 }
1316
1317 #[test]
1318 fn covariance_from_jacobian_matches_report_path_bit_for_bit() {
1319 let jac = covariance_fixture_jacobian(); let residual = DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]);
1325 let cost = 0.5 * residual.dot(&residual);
1326 let report = LeastSquaresReport {
1327 x: DVector::from_vec(vec![0.0, 0.0]),
1328 cost,
1329 residual,
1330 jacobian: jac.clone(),
1331 optimality_inf: 0.0,
1332 iterations: 0,
1333 status: Status::GradientTolerance,
1334 };
1335
1336 let from_jac = covariance_from_jacobian(&jac, cost).unwrap();
1337 let from_report = covariance_from_report(&report).unwrap();
1338
1339 let m = jac.nrows();
1340 let n = jac.ncols();
1341 let explicit = normal_covariance(&jac, 2.0 * cost / ((m - n) as f64)).unwrap();
1342
1343 assert_eq!(from_jac.shape(), from_report.shape());
1344 for (a, (b, c)) in from_jac.iter().zip(from_report.iter().zip(explicit.iter())) {
1345 assert_eq!(a.to_bits(), b.to_bits());
1346 assert_eq!(a.to_bits(), c.to_bits());
1347 }
1348 }
1349
1350 #[test]
1351 fn covariance_from_jacobian_rejects_insufficient_dof() {
1352 let square = DMatrix::from_row_slice(2, 2, &[1.0, 0.0, 1.0, 1.0]);
1356 assert_invalid_field(
1357 covariance_from_jacobian(&square, 0.1).unwrap_err(),
1358 "degrees_of_freedom",
1359 );
1360
1361 let wide = DMatrix::from_row_slice(2, 3, &[1.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
1362 assert_invalid_field(
1363 covariance_from_jacobian(&wide, 0.1).unwrap_err(),
1364 "degrees_of_freedom",
1365 );
1366 }
1367
1368 #[test]
1369 fn covariance_from_report_uses_reduced_chi_square() {
1370 let jac = covariance_fixture_jacobian();
1372 let residual = DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]);
1373 let report = LeastSquaresReport {
1374 x: DVector::from_vec(vec![0.0, 0.0]),
1375 cost: 0.5 * residual.dot(&residual),
1376 residual,
1377 jacobian: jac,
1378 optimality_inf: 0.0,
1379 iterations: 0,
1380 status: Status::GradientTolerance,
1381 };
1382 let cov = covariance_from_report(&report).unwrap();
1383 let expected_cov = [
1384 [0.017000000000000005, -0.005666666666666667],
1385 [-0.005666666666666667, 0.0028333333333333335],
1386 ];
1387 for i in 0..2 {
1388 for j in 0..2 {
1389 assert!(
1390 (cov[(i, j)] - expected_cov[i][j]).abs() < 1e-12,
1391 "cov[{i}][{j}] = {}",
1392 cov[(i, j)]
1393 );
1394 }
1395 }
1396 }
1397}