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