1use crate::astro::math::linear::{invert_matrix_first_tie, solve_linear_first_tie};
18
19use crate::tolerances::LAMBDA_REDUCTION_EPS;
20use crate::validate::{self, FieldError};
21use crate::{Error, Result};
22
23const ILS_RATIO_THRESHOLD_FIELD: &str = "ils ratio_threshold";
24
25#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum IlsError {
30 Singular,
32 NoCandidates(usize),
34 TooManyCandidates { evaluated: usize, limit: usize },
36 InvalidDimensions { n: usize, rows: usize },
40 NonFinite,
42 InvalidInput {
44 field: &'static str,
45 reason: &'static str,
46 },
47 SearchLimitExceeded,
50}
51
52impl core::fmt::Display for IlsError {
53 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
54 match self {
55 Self::Singular => write!(f, "integer least-squares covariance is singular"),
56 Self::NoCandidates(evaluated) => write!(
57 f,
58 "integer least-squares search found no candidates after {evaluated} evaluations"
59 ),
60 Self::TooManyCandidates { evaluated, limit } => write!(
61 f,
62 "integer least-squares search evaluated {evaluated} candidates, exceeding limit {limit}"
63 ),
64 Self::InvalidDimensions { n, rows } => write!(
65 f,
66 "integer least-squares input dimensions are invalid: {n} ambiguities, {rows} covariance rows"
67 ),
68 Self::NonFinite => write!(f, "integer least-squares inputs contain NaN or infinity"),
69 Self::InvalidInput { field, reason } => {
70 write!(f, "invalid integer least-squares input {field}: {reason}")
71 }
72 Self::SearchLimitExceeded => {
73 write!(f, "integer least-squares search did not converge")
74 }
75 }
76 }
77}
78
79impl std::error::Error for IlsError {}
80
81fn validate_inputs(
88 float_cycles: &[f64],
89 covariance: &[Vec<f64>],
90) -> core::result::Result<(), IlsError> {
91 let n = float_cycles.len();
92 if n == 0 {
93 return Err(IlsError::InvalidDimensions { n, rows: 0 });
94 }
95 if covariance.len() != n {
96 return Err(IlsError::InvalidDimensions {
97 n,
98 rows: covariance.len(),
99 });
100 }
101 for row in covariance {
102 if row.len() != n {
103 return Err(IlsError::InvalidDimensions { n, rows: row.len() });
104 }
105 }
106 if float_cycles.iter().any(|v| !v.is_finite())
107 || covariance.iter().flatten().any(|v| !v.is_finite())
108 {
109 return Err(IlsError::NonFinite);
110 }
111 Ok(())
112}
113
114fn validate_ratio_threshold(ratio_threshold: f64) -> core::result::Result<(), IlsError> {
115 validate::finite_nonneg(ratio_threshold, ILS_RATIO_THRESHOLD_FIELD)
116 .map(|_| ())
117 .map_err(invalid_input)
118}
119
120fn validate_covariance_geometry(covariance: &[Vec<f64>]) -> core::result::Result<(), IlsError> {
121 let rows: Vec<&[f64]> = covariance.iter().map(Vec::as_slice).collect();
122 validate::validate_covariance_psd_rows(&rows, "ils covariance").map_err(invalid_input)
123}
124
125fn invalid_input(error: FieldError) -> IlsError {
126 IlsError::InvalidInput {
127 field: error.field(),
128 reason: error.reason(),
129 }
130}
131
132#[derive(Debug, Clone, PartialEq)]
134pub struct IlsResult {
135 pub fixed: Vec<i64>,
137 pub fixed_status: bool,
139 pub ratio: f64,
142 pub best_score: f64,
144 pub second_best_score: Option<f64>,
146 pub candidates_evaluated: usize,
148 pub covariance: Vec<Vec<f64>>,
150 pub covariance_inverse: Vec<Vec<f64>>,
152}
153
154pub fn bounded_ils_search(
160 float_cycles: &[f64],
161 covariance: &[Vec<f64>],
162 radius: i64,
163 candidate_limit: usize,
164 ratio_threshold: f64,
165) -> core::result::Result<IlsResult, IlsError> {
166 validate_inputs(float_cycles, covariance)?;
167 validate_covariance_geometry(covariance)?;
168 validate_ratio_threshold(ratio_threshold)?;
169 let q = symmetrize(covariance);
170 let q_inv = symmetrize(&invert(&q).map_err(|_| IlsError::Singular)?);
171 ensure_candidate_limit(float_cycles.len(), radius, candidate_limit)?;
172
173 let ranges: Vec<Vec<i64>> = float_cycles
177 .iter()
178 .map(|&f| bounded_integer_candidates(f, radius))
179 .collect::<core::result::Result<_, _>>()?;
180
181 let mut top: Vec<(f64, Vec<i64>)> = Vec::with_capacity(2);
182 let mut evaluated: usize = 0;
183 let mut current: Vec<i64> = Vec::with_capacity(float_cycles.len());
184
185 let ctx = LatticeEnum {
186 ranges: &ranges,
187 float_cycles,
188 q_inv: &q_inv,
189 limit: candidate_limit,
190 };
191 enumerate(&ctx, 0, &mut current, &mut evaluated, &mut top)?;
192
193 let (best_score, fixed) = match top.first() {
194 Some((s, c)) => (*s, c.clone()),
195 None => return Err(IlsError::NoCandidates(evaluated)),
196 };
197 let second_best_score = top.get(1).map(|(s, _)| *s);
198 let ratio = integer_ratio(best_score, second_best_score);
199
200 Ok(IlsResult {
201 fixed,
202 fixed_status: ratio_pass(ratio, ratio_threshold),
203 ratio,
204 best_score,
205 second_best_score,
206 candidates_evaluated: evaluated,
207 covariance: q,
208 covariance_inverse: q_inv,
209 })
210}
211
212struct LatticeEnum<'a> {
219 ranges: &'a [Vec<i64>],
220 float_cycles: &'a [f64],
221 q_inv: &'a [Vec<f64>],
222 limit: usize,
223}
224
225fn enumerate(
226 ctx: &LatticeEnum,
227 depth: usize,
228 current: &mut Vec<i64>,
229 evaluated: &mut usize,
230 top: &mut Vec<(f64, Vec<i64>)>,
231) -> core::result::Result<(), IlsError> {
232 if depth == ctx.ranges.len() {
233 *evaluated += 1;
234 if *evaluated > ctx.limit {
235 return Err(IlsError::TooManyCandidates {
236 evaluated: *evaluated,
237 limit: ctx.limit,
238 });
239 }
240 let score = quadratic_score(ctx.float_cycles, current, ctx.q_inv);
241 insert_top_two(top, score, current);
242 return Ok(());
243 }
244
245 for &value in &ctx.ranges[depth] {
246 current.push(value);
247 enumerate(ctx, depth + 1, current, evaluated, top)?;
248 current.pop();
249 }
250 Ok(())
251}
252
253fn insert_top_two(top: &mut Vec<(f64, Vec<i64>)>, score: f64, cycles: &[i64]) {
256 top.push((score, cycles.to_vec()));
257 top.sort_by(|(sa, ca), (sb, cb)| {
258 sa.partial_cmp(sb)
259 .unwrap_or(core::cmp::Ordering::Equal)
260 .then_with(|| ca.cmp(cb))
261 });
262 top.truncate(2);
263}
264
265fn quadratic_score(float_cycles: &[f64], fixed: &[i64], q_inv: &[Vec<f64>]) -> f64 {
266 let n = float_cycles.len();
267 let deltas: Vec<f64> = (0..n).map(|i| float_cycles[i] - fixed[i] as f64).collect();
269
270 let mut acc = 0.0;
272 for i in 0..n {
273 for j in 0..n {
274 acc += deltas[i] * q_inv[i][j] * deltas[j];
275 }
276 }
277 acc
278}
279
280fn integers_near(center: f64, low: i64, high: i64) -> Vec<i64> {
281 let mut values: Vec<i64> = (low..=high).collect();
282 values.sort_by(|&a, &b| {
283 let da = (a as f64 - center).abs();
284 let db = (b as f64 - center).abs();
285 da.partial_cmp(&db)
286 .unwrap_or(core::cmp::Ordering::Equal)
287 .then_with(|| a.cmp(&b))
288 });
289 values
290}
291
292fn bounded_integer_candidates(
293 float_cycle: f64,
294 radius: i64,
295) -> core::result::Result<Vec<i64>, IlsError> {
296 if radius < 0 {
297 return Ok(Vec::new());
298 }
299
300 let rounded = float_cycle.round(); const I64_MAX_EXCLUSIVE: f64 = 9_223_372_036_854_775_808.0;
302 if rounded < i64::MIN as f64 || rounded >= I64_MAX_EXCLUSIVE {
303 return Err(IlsError::InvalidInput {
304 field: "ils float_cycles",
305 reason: "outside integer search range",
306 });
307 }
308
309 let center_i64 = rounded as i64;
310 let low = center_i64
311 .checked_sub(radius)
312 .ok_or(IlsError::InvalidInput {
313 field: "ils float_cycles",
314 reason: "outside integer search range",
315 })?;
316 let high = center_i64
317 .checked_add(radius)
318 .ok_or(IlsError::InvalidInput {
319 field: "ils float_cycles",
320 reason: "outside integer search range",
321 })?;
322 Ok(integers_near(float_cycle, low, high))
323}
324
325fn ensure_candidate_limit(
326 dimensions: usize,
327 radius: i64,
328 limit: usize,
329) -> core::result::Result<(), IlsError> {
330 let per_dimension = if radius < 0 {
331 0usize
332 } else {
333 let width = radius
334 .checked_mul(2)
335 .and_then(|width| width.checked_add(1))
336 .ok_or(IlsError::TooManyCandidates {
337 evaluated: usize::MAX,
338 limit,
339 })?;
340 usize::try_from(width).map_err(|_| IlsError::TooManyCandidates {
341 evaluated: usize::MAX,
342 limit,
343 })?
344 };
345
346 let mut candidates = 1usize;
347 for _ in 0..dimensions {
348 candidates = candidates
349 .checked_mul(per_dimension)
350 .ok_or(IlsError::TooManyCandidates {
351 evaluated: usize::MAX,
352 limit,
353 })?;
354 if candidates > limit {
355 return Err(IlsError::TooManyCandidates {
356 evaluated: candidates,
357 limit,
358 });
359 }
360 }
361
362 Ok(())
363}
364
365fn integer_ratio(best_score: f64, second_best_score: Option<f64>) -> f64 {
366 match second_best_score {
367 None => 0.0,
368 Some(second) => {
369 if best_score == 0.0 && second > 0.0 {
370 f64::MAX
371 } else if best_score == 0.0 {
372 0.0
373 } else {
374 second / best_score
375 }
376 }
377 }
378}
379
380fn ratio_pass(ratio: f64, threshold: f64) -> bool {
381 ratio >= threshold
382}
383
384fn symmetrize(m: &[Vec<f64>]) -> Vec<Vec<f64>> {
387 let n = m.len();
388 (0..n)
389 .map(|i| (0..n).map(|j| (m[i][j] + m[j][i]) / 2.0).collect())
390 .collect()
391}
392
393pub(crate) fn invert(a: &[Vec<f64>]) -> Result<Vec<Vec<f64>>> {
397 invert_matrix_first_tie(a).ok_or_else(|| Error::InvalidInput("singular matrix".into()))
398}
399
400pub(crate) fn solve_linear(a: &[Vec<f64>], b: &[f64]) -> Result<Vec<f64>> {
405 solve_linear_first_tie(a, b).ok_or_else(|| Error::InvalidInput("singular matrix".into()))
406}
407
408const LAMBDA_LOOP_MAX: usize = 10000;
425
426#[inline]
427fn lam_round(x: f64) -> f64 {
428 (x + 0.5).floor() }
430
431#[inline]
432fn lam_sgn(x: f64) -> f64 {
433 if x <= 0.0 {
434 -1.0
435 } else {
436 1.0
437 }
438}
439
440fn lam_ld(n: usize, q: &[f64]) -> Option<(Vec<f64>, Vec<f64>)> {
443 let mut a = q.to_vec();
444 let mut l = vec![0.0f64; n * n];
445 let mut d = vec![0.0f64; n];
446 for i in (0..n).rev() {
447 d[i] = a[i + i * n];
448 if d[i] <= 0.0 {
449 return None;
450 }
451 let ai = d[i].sqrt();
452 for j in 0..=i {
453 l[i + j * n] = a[i + j * n] / ai;
454 }
455 for j in 0..i {
456 for k in 0..=j {
457 a[j + k * n] -= l[i + k * n] * l[i + j * n];
458 }
459 }
460 for j in 0..=i {
461 l[i + j * n] /= l[i + i * n];
462 }
463 }
464 Some((l, d))
465}
466
467fn lam_gauss(n: usize, l: &mut [f64], z: &mut [f64], i: usize, j: usize) {
469 let mu = lam_round(l[i + j * n]) as i64;
470 if mu != 0 {
471 let muf = mu as f64;
472 for k in i..n {
473 l[k + j * n] -= muf * l[k + i * n];
474 }
475 for k in 0..n {
476 z[k + j * n] -= muf * z[k + i * n];
477 }
478 }
479}
480
481fn lam_perm(n: usize, l: &mut [f64], d: &mut [f64], j: usize, del: f64, z: &mut [f64]) {
483 let eta = d[j] / del;
484 let lam = d[j + 1] * l[j + 1 + j * n] / del;
485 d[j] = eta * d[j + 1];
486 d[j + 1] = del;
487 for k in 0..j {
488 let a0 = l[j + k * n];
489 let a1 = l[j + 1 + k * n];
490 l[j + k * n] = -l[j + 1 + j * n] * a0 + a1;
491 l[j + 1 + k * n] = eta * a0 + lam * a1;
492 }
493 l[j + 1 + j * n] = lam;
494 for k in (j + 2)..n {
495 l.swap(k + j * n, k + (j + 1) * n);
496 }
497 for k in 0..n {
498 z.swap(k + j * n, k + (j + 1) * n);
499 }
500}
501
502fn lam_reduction(n: usize, l: &mut [f64], d: &mut [f64], z: &mut [f64]) {
505 let mut j: isize = n as isize - 2;
506 let mut k: isize = n as isize - 2;
507 while j >= 0 {
508 let ju = j as usize;
509 if j <= k {
510 for i in (ju + 1)..n {
511 lam_gauss(n, l, z, i, ju);
512 }
513 }
514 let del = d[ju] + l[ju + 1 + ju * n] * l[ju + 1 + ju * n] * d[ju + 1];
515 if del + LAMBDA_REDUCTION_EPS < d[ju + 1] {
516 lam_perm(n, l, d, ju, del, z);
517 k = j;
518 j = n as isize - 2;
519 } else {
520 j -= 1;
521 }
522 }
523}
524
525fn lam_search(
530 n: usize,
531 m: usize,
532 l: &[f64],
533 d: &[f64],
534 zs: &[f64],
535) -> Option<(Vec<f64>, Vec<f64>)> {
536 let mut s = vec![0.0f64; m];
537 let mut zn = vec![0.0f64; n * m];
538 let mut smat = vec![0.0f64; n * n];
539 let mut dist = vec![0.0f64; n];
540 let mut zb = vec![0.0f64; n];
541 let mut z = vec![0.0f64; n];
542 let mut step = vec![0.0f64; n];
543
544 let mut nn: usize = 0;
545 let mut imax: usize = 0;
546 let mut maxdist = 1.0e99;
547
548 let mut k: isize = n as isize - 1;
549 let ku = k as usize;
550 dist[ku] = 0.0;
551 zb[ku] = zs[ku];
552 z[ku] = lam_round(zb[ku]);
553 let mut y = zb[ku] - z[ku];
554 step[ku] = lam_sgn(y);
555
556 let mut c = 0usize;
557 while c < LAMBDA_LOOP_MAX {
558 let kk = k as usize;
559 let newdist = dist[kk] + y * y / d[kk];
560 if newdist < maxdist {
561 if k != 0 {
562 k -= 1;
563 let kk = k as usize;
564 dist[kk] = newdist;
565 for i in 0..=kk {
566 smat[kk + i * n] =
567 smat[kk + 1 + i * n] + (z[kk + 1] - zb[kk + 1]) * l[kk + 1 + i * n];
568 }
569 zb[kk] = zs[kk] + smat[kk + kk * n];
570 z[kk] = lam_round(zb[kk]);
571 y = zb[kk] - z[kk];
572 step[kk] = lam_sgn(y);
573 } else {
574 if nn < m {
575 if nn == 0 || newdist > s[imax] {
576 imax = nn;
577 }
578 for i in 0..n {
579 zn[i + nn * n] = z[i];
580 }
581 s[nn] = newdist;
582 nn += 1;
583 } else {
584 if newdist < s[imax] {
585 for i in 0..n {
586 zn[i + imax * n] = z[i];
587 }
588 s[imax] = newdist;
589 imax = 0;
590 for i in 0..m {
591 if s[imax] < s[i] {
592 imax = i;
593 }
594 }
595 }
596 maxdist = s[imax];
597 }
598 z[0] += step[0];
599 y = zb[0] - z[0];
600 step[0] = -step[0] - lam_sgn(step[0]);
601 }
602 } else if k == n as isize - 1 {
603 break;
604 } else {
605 k += 1;
606 let kk = k as usize;
607 z[kk] += step[kk];
608 y = zb[kk] - z[kk];
609 step[kk] = -step[kk] - lam_sgn(step[kk]);
610 }
611 c += 1;
612 }
613
614 if c >= LAMBDA_LOOP_MAX {
615 return None;
616 }
617
618 for i in 0..m.saturating_sub(1) {
620 for j in (i + 1)..m {
621 if s[i] < s[j] {
622 continue;
623 }
624 s.swap(i, j);
625 for k in 0..n {
626 zn.swap(k + i * n, k + j * n);
627 }
628 }
629 }
630 Some((zn, s))
631}
632
633pub fn lambda_ils_search(
641 float_cycles: &[f64],
642 covariance: &[Vec<f64>],
643 ratio_threshold: f64,
644) -> core::result::Result<IlsResult, IlsError> {
645 validate_inputs(float_cycles, covariance)?;
646 validate_covariance_geometry(covariance)?;
647 validate_ratio_threshold(ratio_threshold)?;
648 let n = float_cycles.len();
649 let q = symmetrize(covariance);
650 let q_inv = symmetrize(&invert(&q).map_err(|_| IlsError::Singular)?);
652
653 let mut q_cm = vec![0.0f64; n * n];
655 for i in 0..n {
656 for j in 0..n {
657 q_cm[i + j * n] = q[i][j];
658 }
659 }
660
661 let (mut l, mut d) = lam_ld(n, &q_cm).ok_or(IlsError::Singular)?;
662 let mut z = {
663 let mut e = vec![0.0f64; n * n];
665 for i in 0..n {
666 e[i + i * n] = 1.0;
667 }
668 e
669 };
670 lam_reduction(n, &mut l, &mut d, &mut z);
671
672 let mut zs = vec![0.0f64; n];
674 for i in 0..n {
675 let mut acc = 0.0;
676 for k in 0..n {
677 acc += z[k + i * n] * float_cycles[k];
678 }
679 zs[i] = acc;
680 }
681
682 let m = 2usize;
685 let (zn, _s) = lam_search(n, m, &l, &d, &zs).ok_or(IlsError::SearchLimitExceeded)?;
686
687 let mut zt = vec![vec![0.0f64; n]; n];
690 for i in 0..n {
691 for j in 0..n {
692 zt[i][j] = z[j + i * n]; }
694 }
695 let mut fixed_candidates: Vec<Vec<i64>> = Vec::with_capacity(m);
696 for col in 0..m {
697 let b: Vec<f64> = (0..n).map(|i| zn[i + col * n]).collect();
698 let x = solve_linear(&zt, &b).map_err(|_| IlsError::Singular)?;
699 fixed_candidates.push(x.iter().map(|&v| lam_round(v) as i64).collect());
700 }
701
702 let mut scored: Vec<(f64, Vec<i64>)> = fixed_candidates
709 .into_iter()
710 .map(|c| (quadratic_score(float_cycles, &c, &q_inv), c))
711 .collect();
712 scored.sort_by(|(sa, ca), (sb, cb)| {
713 sa.partial_cmp(sb)
714 .unwrap_or(core::cmp::Ordering::Equal)
715 .then_with(|| ca.cmp(cb))
716 });
717
718 let best_score = scored[0].0;
719 let fixed = scored[0].1.clone();
720 let second_best_score = scored.get(1).map(|(s, _)| *s);
721 let ratio = integer_ratio(best_score, second_best_score);
722
723 Ok(IlsResult {
724 fixed,
725 fixed_status: ratio_pass(ratio, ratio_threshold),
726 ratio,
727 best_score,
728 second_best_score,
729 candidates_evaluated: m,
731 covariance: q,
732 covariance_inverse: q_inv,
733 })
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 #[test]
741 fn inverts_a_known_matrix() {
742 let a = vec![vec![4.0, 7.0], vec![2.0, 6.0]];
743 let inv = invert(&a).unwrap();
744 assert!((inv[0][0] - 0.6).abs() < 1e-12);
746 assert!((inv[0][1] + 0.7).abs() < 1e-12);
747 assert!((inv[1][0] + 0.2).abs() < 1e-12);
748 assert!((inv[1][1] - 0.4).abs() < 1e-12);
749 }
750
751 #[test]
752 fn rejects_a_singular_matrix() {
753 let a = vec![vec![1.0, 2.0], vec![2.0, 4.0]];
754 assert!(invert(&a).is_err());
755 }
756
757 #[test]
758 fn fixes_a_well_separated_lattice_point() {
759 let float = vec![3.02, -1.98, 5.01];
762 let cov = vec![
763 vec![0.01, 0.0, 0.0],
764 vec![0.0, 0.01, 0.0],
765 vec![0.0, 0.0, 0.01],
766 ];
767 let r = bounded_ils_search(&float, &cov, 1, 200_000, 3.0).unwrap();
768 assert_eq!(r.fixed, vec![3, -2, 5]);
769 assert!(r.fixed_status);
770 assert!(r.ratio > 3.0);
771 assert_eq!(r.candidates_evaluated, 27); }
773
774 #[test]
775 fn refuses_an_ambiguous_lattice() {
776 let float = vec![0.5, 0.5];
778 let cov = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
779 let r = bounded_ils_search(&float, &cov, 1, 200_000, 3.0).unwrap();
780 assert!(!r.fixed_status);
781 assert!(r.ratio < 3.0);
782 }
783
784 #[test]
785 fn errors_when_the_lattice_exceeds_the_candidate_limit() {
786 let float = vec![0.0, 0.0, 0.0];
787 let cov = vec![
788 vec![1.0, 0.0, 0.0],
789 vec![0.0, 1.0, 0.0],
790 vec![0.0, 0.0, 1.0],
791 ];
792 assert_eq!(
794 bounded_ils_search(&float, &cov, 1, 10, 3.0),
795 Err(IlsError::TooManyCandidates {
796 evaluated: 27,
797 limit: 10
798 })
799 );
800 }
801
802 #[test]
803 fn rejects_pathological_lattice_before_allocating_ranges() {
804 let float = vec![0.0, 0.0];
805 let cov = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
806
807 let err = bounded_ils_search(&float, &cov, 1_000_000_000, 100, 3.0)
808 .expect_err("over-limit lattice must be rejected before range allocation");
809 assert!(matches!(
810 err,
811 IlsError::TooManyCandidates {
812 evaluated,
813 limit: 100
814 } if evaluated > 100
815 ));
816
817 let normal = bounded_ils_search(&float, &cov, 1, 9, 3.0)
818 .expect("within-limit lattice should still enumerate normally");
819 assert_eq!(normal.fixed, vec![0, 0]);
820 assert_eq!(normal.candidates_evaluated, 9);
821 }
822
823 #[test]
824 fn rejects_bounded_search_ranges_outside_i64_domain() {
825 let cov = vec![vec![1.0]];
826 let expected = Err(IlsError::InvalidInput {
827 field: "ils float_cycles",
828 reason: "outside integer search range",
829 });
830
831 assert_eq!(bounded_ils_search(&[f64::MAX], &cov, 1, 3, 3.0), expected);
832 assert_eq!(
833 bounded_ils_search(&[i64::MAX as f64], &cov, 1, 3, 3.0),
834 expected
835 );
836 assert_eq!(
837 bounded_ils_search(&[i64::MIN as f64], &cov, 1, 3, 3.0),
838 expected
839 );
840 }
841
842 fn full_matrix(flat: &[f64], n: usize) -> Vec<Vec<f64>> {
847 (0..n)
848 .map(|i| (0..n).map(|j| flat[i * n + j]).collect())
849 .collect()
850 }
851
852 #[test]
853 fn lambda_matches_rtklib_utest1() {
854 let a = [
855 1585184.171,
856 -6716599.430,
857 3915742.905,
858 7627233.455,
859 9565990.879,
860 989457273.200,
861 ];
862 #[rustfmt::skip]
863 let q = full_matrix(&[
864 0.227134, 0.112202, 0.112202, 0.112202, 0.112202, 0.103473,
865 0.112202, 0.227134, 0.112202, 0.112202, 0.112202, 0.103473,
866 0.112202, 0.112202, 0.227134, 0.112202, 0.112202, 0.103473,
867 0.112202, 0.112202, 0.112202, 0.227134, 0.112202, 0.103473,
868 0.112202, 0.112202, 0.112202, 0.112202, 0.227134, 0.103473,
869 0.103473, 0.103473, 0.103473, 0.103473, 0.103473, 0.434339,
870 ], 6);
871
872 let r = lambda_ils_search(&a, &q, 3.0).unwrap();
873 assert_eq!(
874 r.fixed,
875 vec![1585184, -6716599, 3915743, 7627234, 9565991, 989457273]
876 );
877 assert!((r.best_score - 3.5079844392).abs() < 1e-4);
878 assert!((r.second_best_score.unwrap() - 3.70845619249).abs() < 1e-4);
879 }
880
881 #[test]
882 fn lambda_matches_rtklib_utest2_strongly_correlated() {
883 let a = [
886 -13324172.755747,
887 -10668894.713608,
888 -7157225.010770,
889 -6149367.974367,
890 -7454133.571066,
891 -5969200.494550,
892 8336734.058423,
893 6186974.084502,
894 -17549093.883655,
895 -13970158.922370,
896 ];
897 #[rustfmt::skip]
898 let q = full_matrix(&[
899 0.446320,0.223160,0.223160,0.223160,0.223160,0.572775,0.286388,0.286388,0.286388,0.286388,
900 0.223160,0.446320,0.223160,0.223160,0.223160,0.286388,0.572775,0.286388,0.286388,0.286388,
901 0.223160,0.223160,0.446320,0.223160,0.223160,0.286388,0.286388,0.572775,0.286388,0.286388,
902 0.223160,0.223160,0.223160,0.446320,0.223160,0.286388,0.286388,0.286388,0.572775,0.286388,
903 0.223160,0.223160,0.223160,0.223160,0.446320,0.286388,0.286388,0.286388,0.286388,0.572775,
904 0.572775,0.286388,0.286388,0.286388,0.286388,0.735063,0.367531,0.367531,0.367531,0.367531,
905 0.286388,0.572775,0.286388,0.286388,0.286388,0.367531,0.735063,0.367531,0.367531,0.367531,
906 0.286388,0.286388,0.572775,0.286388,0.286388,0.367531,0.367531,0.735063,0.367531,0.367531,
907 0.286388,0.286388,0.286388,0.572775,0.286388,0.367531,0.367531,0.367531,0.735063,0.367531,
908 0.286388,0.286388,0.286388,0.286388,0.572775,0.367531,0.367531,0.367531,0.367531,0.735063,
909 ], 10);
910
911 let r = lambda_ils_search(&a, &q, 3.0).unwrap();
912 assert_eq!(
913 r.fixed,
914 vec![
915 -13324188, -10668901, -7157236, -6149379, -7454143, -5969220, 8336726, 6186960,
916 -17549108, -13970171
917 ]
918 );
919 assert!((r.best_score - 1506.43578925).abs() < 1e-4);
920 assert!((r.second_best_score.unwrap() - 1612.81176533).abs() < 1e-4);
921 }
922
923 #[test]
924 fn lambda_agrees_with_box_search_in_regime() {
925 let a = vec![0.30, -0.40, 1.20];
927 let q = vec![
928 vec![0.50, 0.10, 0.05],
929 vec![0.10, 0.50, 0.10],
930 vec![0.05, 0.10, 0.50],
931 ];
932 let lam = lambda_ils_search(&a, &q, 3.0).unwrap();
933 let box_ = bounded_ils_search(&a, &q, 1, 200_000, 3.0).unwrap();
934 assert_eq!(lam.fixed, box_.fixed);
935 assert!((lam.best_score - box_.best_score).abs() < 1e-9);
936 assert!((lam.second_best_score.unwrap() - box_.second_best_score.unwrap()).abs() < 1e-9);
937 }
938
939 #[test]
942 fn rejects_undersized_covariance() {
943 let a = vec![0.1, 0.2];
945 let q = vec![vec![1.0]];
946 assert_eq!(
947 bounded_ils_search(&a, &q, 1, 200_000, 3.0),
948 Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
949 );
950 assert_eq!(
951 lambda_ils_search(&a, &q, 3.0),
952 Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
953 );
954 }
955
956 #[test]
957 fn rejects_oversized_covariance() {
958 let a = vec![0.1];
960 let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
961 assert_eq!(
962 bounded_ils_search(&a, &q, 1, 200_000, 3.0),
963 Err(IlsError::InvalidDimensions { n: 1, rows: 2 })
964 );
965 assert_eq!(
966 lambda_ils_search(&a, &q, 3.0),
967 Err(IlsError::InvalidDimensions { n: 1, rows: 2 })
968 );
969 }
970
971 #[test]
972 fn rejects_ragged_covariance() {
973 let a = vec![0.1, 0.2];
975 let q = vec![vec![1.0, 0.0], vec![0.0]];
976 assert_eq!(
977 bounded_ils_search(&a, &q, 1, 200_000, 3.0),
978 Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
979 );
980 assert_eq!(
981 lambda_ils_search(&a, &q, 3.0),
982 Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
983 );
984 }
985
986 #[test]
987 fn bounded_search_rejects_invalid_covariance_geometry() {
988 let a = vec![0.1, 0.2];
989 let expected = Err(IlsError::InvalidInput {
990 field: "ils covariance",
991 reason: "not positive",
992 });
993
994 let negative_variance = vec![vec![-1.0, 0.0], vec![0.0, 1.0]];
995 assert_eq!(
996 bounded_ils_search(&a, &negative_variance, 1, 200_000, 3.0),
997 expected
998 );
999
1000 let asymmetric = vec![vec![1.0, 0.5], vec![0.4, 1.0]];
1001 assert_eq!(
1002 bounded_ils_search(&a, &asymmetric, 1, 200_000, 3.0),
1003 expected
1004 );
1005
1006 let indefinite = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
1007 assert_eq!(
1008 bounded_ils_search(&a, &indefinite, 1, 200_000, 3.0),
1009 expected
1010 );
1011 }
1012
1013 #[test]
1014 fn lambda_search_rejects_invalid_covariance_geometry() {
1015 let a = vec![0.1, 0.2];
1016 let expected = Err(IlsError::InvalidInput {
1017 field: "ils covariance",
1018 reason: "not positive",
1019 });
1020
1021 let negative_variance = vec![vec![-1.0, 0.0], vec![0.0, 1.0]];
1022 assert_eq!(lambda_ils_search(&a, &negative_variance, 3.0), expected);
1023
1024 let asymmetric = vec![vec![1.0, 0.5], vec![0.4, 1.0]];
1025 assert_eq!(lambda_ils_search(&a, &asymmetric, 3.0), expected);
1026
1027 let indefinite = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
1028 assert_eq!(lambda_ils_search(&a, &indefinite, 3.0), expected);
1029 }
1030
1031 #[test]
1032 fn rejects_empty_input() {
1033 let a: Vec<f64> = vec![];
1034 let q: Vec<Vec<f64>> = vec![];
1035 assert_eq!(
1036 bounded_ils_search(&a, &q, 1, 200_000, 3.0),
1037 Err(IlsError::InvalidDimensions { n: 0, rows: 0 })
1038 );
1039 assert_eq!(
1040 lambda_ils_search(&a, &q, 3.0),
1041 Err(IlsError::InvalidDimensions { n: 0, rows: 0 })
1042 );
1043 }
1044
1045 #[test]
1046 fn rejects_non_finite_input() {
1047 let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
1048 assert_eq!(
1049 bounded_ils_search(&[f64::NAN, 0.2], &q, 1, 200_000, 3.0),
1050 Err(IlsError::NonFinite)
1051 );
1052 let q_inf = vec![vec![f64::INFINITY, 0.0], vec![0.0, 1.0]];
1053 assert_eq!(
1054 lambda_ils_search(&[0.1, 0.2], &q_inf, 3.0),
1055 Err(IlsError::NonFinite)
1056 );
1057 }
1058
1059 #[test]
1060 fn rejects_invalid_ratio_thresholds() {
1061 let a = vec![0.1, 0.2];
1062 let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
1063
1064 for (threshold, reason) in [
1065 (-1.0, "negative"),
1066 (f64::NAN, "not finite"),
1067 (f64::INFINITY, "not finite"),
1068 ] {
1069 let expected = Err(IlsError::InvalidInput {
1070 field: ILS_RATIO_THRESHOLD_FIELD,
1071 reason,
1072 });
1073 assert_eq!(bounded_ils_search(&a, &q, 1, 200_000, threshold), expected);
1074 assert_eq!(lambda_ils_search(&a, &q, threshold), expected);
1075 }
1076 }
1077
1078 #[test]
1079 fn exact_integer_fix_reports_finite_saturated_ratio() {
1080 let a = vec![1.0];
1081 let q = vec![vec![1.0]];
1082
1083 let bounded = bounded_ils_search(&a, &q, 1, 3, 3.0).unwrap();
1084 assert_eq!(bounded.best_score, 0.0);
1085 assert_eq!(bounded.second_best_score, Some(1.0));
1086 assert_eq!(bounded.ratio, f64::MAX);
1087 assert!(bounded.ratio.is_finite());
1088 assert!(bounded.fixed_status);
1089
1090 let lambda = lambda_ils_search(&a, &q, 3.0).unwrap();
1091 assert_eq!(lambda.best_score, 0.0);
1092 assert_eq!(lambda.second_best_score, Some(1.0));
1093 assert_eq!(lambda.ratio, f64::MAX);
1094 assert!(lambda.ratio.is_finite());
1095 assert!(lambda.fixed_status);
1096 }
1097
1098 #[test]
1099 fn valid_ratio_threshold_still_controls_fix_status() {
1100 let a = vec![3.02, -1.98, 5.01];
1101 let q = vec![
1102 vec![0.01, 0.0, 0.0],
1103 vec![0.0, 0.01, 0.0],
1104 vec![0.0, 0.0, 0.01],
1105 ];
1106
1107 let bounded_fixed = bounded_ils_search(&a, &q, 1, 200_000, 3.0).unwrap();
1108 let bounded_held = bounded_ils_search(&a, &q, 1, 200_000, 1.0e12).unwrap();
1109 assert!(bounded_fixed.fixed_status);
1110 assert!(!bounded_held.fixed_status);
1111 assert_eq!(bounded_fixed.fixed, bounded_held.fixed);
1112
1113 let lambda_fixed = lambda_ils_search(&a, &q, 3.0).unwrap();
1114 let lambda_held = lambda_ils_search(&a, &q, 1.0e12).unwrap();
1115 assert!(lambda_fixed.fixed_status);
1116 assert!(!lambda_held.fixed_status);
1117 assert_eq!(lambda_fixed.fixed, lambda_held.fixed);
1118 }
1119}