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