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 checked_integer_search_value(rounded: f64) -> core::result::Result<i64, IlsError> {
296 const I64_MAX_EXCLUSIVE: f64 = 9_223_372_036_854_775_808.0;
297 if !rounded.is_finite() || rounded < i64::MIN as f64 || rounded >= I64_MAX_EXCLUSIVE {
298 return Err(IlsError::InvalidInput {
299 field: "ils float_cycles",
300 reason: "outside integer search range",
301 });
302 }
303 Ok(rounded as i64)
304}
305
306fn bounded_integer_candidates(
307 float_cycle: f64,
308 radius: i64,
309) -> core::result::Result<Vec<i64>, IlsError> {
310 if radius < 0 {
311 return Ok(Vec::new());
312 }
313
314 let rounded = float_cycle.round(); let center_i64 = checked_integer_search_value(rounded)?;
316 let low = center_i64
317 .checked_sub(radius)
318 .ok_or(IlsError::InvalidInput {
319 field: "ils float_cycles",
320 reason: "outside integer search range",
321 })?;
322 let high = center_i64
323 .checked_add(radius)
324 .ok_or(IlsError::InvalidInput {
325 field: "ils float_cycles",
326 reason: "outside integer search range",
327 })?;
328 Ok(integers_near(float_cycle, low, high))
329}
330
331fn ensure_candidate_limit(
332 dimensions: usize,
333 radius: i64,
334 limit: usize,
335) -> core::result::Result<(), IlsError> {
336 let per_dimension = if radius < 0 {
337 0usize
338 } else {
339 let width = radius
340 .checked_mul(2)
341 .and_then(|width| width.checked_add(1))
342 .ok_or(IlsError::TooManyCandidates {
343 evaluated: usize::MAX,
344 limit,
345 })?;
346 usize::try_from(width).map_err(|_| IlsError::TooManyCandidates {
347 evaluated: usize::MAX,
348 limit,
349 })?
350 };
351
352 let mut candidates = 1usize;
353 for _ in 0..dimensions {
354 candidates = candidates
355 .checked_mul(per_dimension)
356 .ok_or(IlsError::TooManyCandidates {
357 evaluated: usize::MAX,
358 limit,
359 })?;
360 if candidates > limit {
361 return Err(IlsError::TooManyCandidates {
362 evaluated: candidates,
363 limit,
364 });
365 }
366 }
367
368 Ok(())
369}
370
371fn integer_ratio(best_score: f64, second_best_score: Option<f64>) -> f64 {
372 match second_best_score {
373 None => 0.0,
374 Some(second) => {
375 if best_score == 0.0 && second > 0.0 {
376 f64::MAX
377 } else if best_score == 0.0 {
378 0.0
379 } else {
380 second / best_score
381 }
382 }
383 }
384}
385
386fn ratio_pass(ratio: f64, threshold: f64) -> bool {
387 ratio >= threshold
388}
389
390fn symmetrize(m: &[Vec<f64>]) -> Vec<Vec<f64>> {
391 let n = m.len();
392 (0..n)
393 .map(|i| (0..n).map(|j| (m[i][j] + m[j][i]) / 2.0).collect())
394 .collect()
395}
396
397const LAMBDA_LOOP_MAX: usize = 10000;
414
415#[inline]
416fn lam_round(x: f64) -> f64 {
417 (x + 0.5).floor() }
419
420#[inline]
421fn lam_sgn(x: f64) -> f64 {
422 if x <= 0.0 {
423 -1.0
424 } else {
425 1.0
426 }
427}
428
429fn lam_ld(n: usize, q: &[f64]) -> Option<(Vec<f64>, Vec<f64>)> {
432 let mut a = q.to_vec();
433 let mut l = vec![0.0f64; n * n];
434 let mut d = vec![0.0f64; n];
435 for i in (0..n).rev() {
436 d[i] = a[i + i * n];
437 if d[i] <= 0.0 {
438 return None;
439 }
440 let ai = d[i].sqrt();
441 for j in 0..=i {
442 l[i + j * n] = a[i + j * n] / ai;
443 }
444 for j in 0..i {
445 for k in 0..=j {
446 a[j + k * n] -= l[i + k * n] * l[i + j * n];
447 }
448 }
449 for j in 0..=i {
450 l[i + j * n] /= l[i + i * n];
451 }
452 }
453 Some((l, d))
454}
455
456fn lam_gauss(n: usize, l: &mut [f64], z: &mut [f64], i: usize, j: usize) {
458 let mu = lam_round(l[i + j * n]) as i64;
459 if mu != 0 {
460 let muf = mu as f64;
461 for k in i..n {
462 l[k + j * n] -= muf * l[k + i * n];
463 }
464 for k in 0..n {
465 z[k + j * n] -= muf * z[k + i * n];
466 }
467 }
468}
469
470fn lam_perm(n: usize, l: &mut [f64], d: &mut [f64], j: usize, del: f64, z: &mut [f64]) {
472 let eta = d[j] / del;
473 let lam = d[j + 1] * l[j + 1 + j * n] / del;
474 d[j] = eta * d[j + 1];
475 d[j + 1] = del;
476 for k in 0..j {
477 let a0 = l[j + k * n];
478 let a1 = l[j + 1 + k * n];
479 l[j + k * n] = -l[j + 1 + j * n] * a0 + a1;
480 l[j + 1 + k * n] = eta * a0 + lam * a1;
481 }
482 l[j + 1 + j * n] = lam;
483 for k in (j + 2)..n {
484 l.swap(k + j * n, k + (j + 1) * n);
485 }
486 for k in 0..n {
487 z.swap(k + j * n, k + (j + 1) * n);
488 }
489}
490
491fn lam_reduction(n: usize, l: &mut [f64], d: &mut [f64], z: &mut [f64]) {
494 let mut j: isize = n as isize - 2;
495 let mut k: isize = n as isize - 2;
496 while j >= 0 {
497 let ju = j as usize;
498 if j <= k {
499 for i in (ju + 1)..n {
500 lam_gauss(n, l, z, i, ju);
501 }
502 }
503 let del = d[ju] + l[ju + 1 + ju * n] * l[ju + 1 + ju * n] * d[ju + 1];
504 if del + LAMBDA_REDUCTION_EPS < d[ju + 1] {
505 lam_perm(n, l, d, ju, del, z);
506 k = j;
507 j = n as isize - 2;
508 } else {
509 j -= 1;
510 }
511 }
512}
513
514fn lam_search(
519 n: usize,
520 m: usize,
521 l: &[f64],
522 d: &[f64],
523 zs: &[f64],
524) -> Option<(Vec<f64>, Vec<f64>)> {
525 let mut s = vec![0.0f64; m];
526 let mut zn = vec![0.0f64; n * m];
527 let mut smat = vec![0.0f64; n * n];
528 let mut dist = vec![0.0f64; n];
529 let mut zb = vec![0.0f64; n];
530 let mut z = vec![0.0f64; n];
531 let mut step = vec![0.0f64; n];
532
533 let mut nn: usize = 0;
534 let mut imax: usize = 0;
535 let mut maxdist = 1.0e99;
536
537 let mut k: isize = n as isize - 1;
538 let ku = k as usize;
539 dist[ku] = 0.0;
540 zb[ku] = zs[ku];
541 z[ku] = lam_round(zb[ku]);
542 let mut y = zb[ku] - z[ku];
543 step[ku] = lam_sgn(y);
544
545 let mut c = 0usize;
546 while c < LAMBDA_LOOP_MAX {
547 let kk = k as usize;
548 let newdist = dist[kk] + y * y / d[kk];
549 if newdist < maxdist {
550 if k != 0 {
551 k -= 1;
552 let kk = k as usize;
553 dist[kk] = newdist;
554 for i in 0..=kk {
555 smat[kk + i * n] =
556 smat[kk + 1 + i * n] + (z[kk + 1] - zb[kk + 1]) * l[kk + 1 + i * n];
557 }
558 zb[kk] = zs[kk] + smat[kk + kk * n];
559 z[kk] = lam_round(zb[kk]);
560 y = zb[kk] - z[kk];
561 step[kk] = lam_sgn(y);
562 } else {
563 if nn < m {
564 if nn == 0 || newdist > s[imax] {
565 imax = nn;
566 }
567 for i in 0..n {
568 zn[i + nn * n] = z[i];
569 }
570 s[nn] = newdist;
571 nn += 1;
572 } else {
573 if newdist < s[imax] {
574 for i in 0..n {
575 zn[i + imax * n] = z[i];
576 }
577 s[imax] = newdist;
578 imax = 0;
579 for i in 0..m {
580 if s[imax] < s[i] {
581 imax = i;
582 }
583 }
584 }
585 maxdist = s[imax];
586 }
587 z[0] += step[0];
588 y = zb[0] - z[0];
589 step[0] = -step[0] - lam_sgn(step[0]);
590 }
591 } else if k == n as isize - 1 {
592 break;
593 } else {
594 k += 1;
595 let kk = k as usize;
596 z[kk] += step[kk];
597 y = zb[kk] - z[kk];
598 step[kk] = -step[kk] - lam_sgn(step[kk]);
599 }
600 c += 1;
601 }
602
603 if c >= LAMBDA_LOOP_MAX {
604 return None;
605 }
606
607 for i in 0..m.saturating_sub(1) {
609 for j in (i + 1)..m {
610 if s[i] < s[j] {
611 continue;
612 }
613 s.swap(i, j);
614 for k in 0..n {
615 zn.swap(k + i * n, k + j * n);
616 }
617 }
618 }
619 Some((zn, s))
620}
621
622pub fn lambda_ils_search(
630 float_cycles: &[f64],
631 covariance: &[Vec<f64>],
632 ratio_threshold: f64,
633) -> core::result::Result<IlsResult, IlsError> {
634 validate_inputs(float_cycles, covariance)?;
635 validate_covariance_geometry(covariance)?;
636 validate_ratio_threshold(ratio_threshold)?;
637 for &float_cycle in float_cycles {
638 checked_integer_search_value(lam_round(float_cycle))?;
639 }
640 let n = float_cycles.len();
641 let q = symmetrize(covariance);
642 let q_inv = symmetrize(&invert_matrix_first_tie(&q).ok_or(IlsError::Singular)?);
644
645 let mut q_cm = vec![0.0f64; n * n];
647 for i in 0..n {
648 for j in 0..n {
649 q_cm[i + j * n] = q[i][j];
650 }
651 }
652
653 let (mut l, mut d) = lam_ld(n, &q_cm).ok_or(IlsError::Singular)?;
654 let mut z = {
655 let mut e = vec![0.0f64; n * n];
657 for i in 0..n {
658 e[i + i * n] = 1.0;
659 }
660 e
661 };
662 lam_reduction(n, &mut l, &mut d, &mut z);
663
664 let mut zs = vec![0.0f64; n];
666 for i in 0..n {
667 let mut acc = 0.0;
668 for k in 0..n {
669 acc += z[k + i * n] * float_cycles[k];
670 }
671 zs[i] = acc;
672 }
673
674 let m = 2usize;
677 let (zn, _s) = lam_search(n, m, &l, &d, &zs).ok_or(IlsError::SearchLimitExceeded)?;
678
679 let mut zt = vec![vec![0.0f64; n]; n];
682 for i in 0..n {
683 for j in 0..n {
684 zt[i][j] = z[j + i * n]; }
686 }
687 let mut fixed_candidates: Vec<Vec<i64>> = Vec::with_capacity(m);
688 for col in 0..m {
689 let b: Vec<f64> = (0..n).map(|i| zn[i + col * n]).collect();
690 let x = solve_linear_first_tie(&zt, &b).ok_or(IlsError::Singular)?;
691 let fixed = x
692 .into_iter()
693 .map(|value| checked_integer_search_value(lam_round(value)))
694 .collect::<core::result::Result<Vec<_>, _>>()?;
695 fixed_candidates.push(fixed);
696 }
697
698 let mut scored: Vec<(f64, Vec<i64>)> = fixed_candidates
705 .into_iter()
706 .map(|c| (quadratic_score(float_cycles, &c, &q_inv), c))
707 .collect();
708 scored.sort_by(|(sa, ca), (sb, cb)| {
709 sa.partial_cmp(sb)
710 .unwrap_or(core::cmp::Ordering::Equal)
711 .then_with(|| ca.cmp(cb))
712 });
713
714 let best_score = scored[0].0;
715 let fixed = scored[0].1.clone();
716 let second_best_score = scored.get(1).map(|(s, _)| *s);
717 let ratio = integer_ratio(best_score, second_best_score);
718
719 Ok(IlsResult {
720 fixed,
721 fixed_status: ratio_pass(ratio, ratio_threshold),
722 ratio,
723 best_score,
724 second_best_score,
725 candidates_evaluated: m,
727 covariance: q,
728 covariance_inverse: q_inv,
729 })
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735
736 #[test]
737 fn bounded_search_reports_first_tie_inverse_bits() {
738 let float = vec![0.1, -0.2];
739 let cov = vec![vec![4.0, 1.0], vec![1.0, 3.0]];
740 let result = bounded_ils_search(&float, &cov, 1, 9, 3.0).unwrap();
741
742 assert_eq!(
743 result.covariance_inverse[0][0].to_bits(),
744 0x3fd1745d1745d174
745 );
746 assert_eq!(
747 result.covariance_inverse[0][1].to_bits(),
748 0xbfb745d1745d1746
749 );
750 assert_eq!(
751 result.covariance_inverse[1][0].to_bits(),
752 0xbfb745d1745d1746
753 );
754 assert_eq!(
755 result.covariance_inverse[1][1].to_bits(),
756 0x3fd745d1745d1746
757 );
758 }
759
760 #[test]
761 fn fixes_a_well_separated_lattice_point() {
762 let float = vec![3.02, -1.98, 5.01];
765 let cov = vec![
766 vec![0.01, 0.0, 0.0],
767 vec![0.0, 0.01, 0.0],
768 vec![0.0, 0.0, 0.01],
769 ];
770 let r = bounded_ils_search(&float, &cov, 1, 200_000, 3.0).unwrap();
771 assert_eq!(r.fixed, vec![3, -2, 5]);
772 assert!(r.fixed_status);
773 assert!(r.ratio > 3.0);
774 assert_eq!(r.candidates_evaluated, 27); }
776
777 #[test]
778 fn refuses_an_ambiguous_lattice() {
779 let float = vec![0.5, 0.5];
781 let cov = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
782 let r = bounded_ils_search(&float, &cov, 1, 200_000, 3.0).unwrap();
783 assert!(!r.fixed_status);
784 assert!(r.ratio < 3.0);
785 }
786
787 #[test]
788 fn errors_when_the_lattice_exceeds_the_candidate_limit() {
789 let float = vec![0.0, 0.0, 0.0];
790 let cov = vec![
791 vec![1.0, 0.0, 0.0],
792 vec![0.0, 1.0, 0.0],
793 vec![0.0, 0.0, 1.0],
794 ];
795 assert_eq!(
797 bounded_ils_search(&float, &cov, 1, 10, 3.0),
798 Err(IlsError::TooManyCandidates {
799 evaluated: 27,
800 limit: 10
801 })
802 );
803 }
804
805 #[test]
806 fn rejects_pathological_lattice_before_allocating_ranges() {
807 let float = vec![0.0, 0.0];
808 let cov = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
809
810 let err = bounded_ils_search(&float, &cov, 1_000_000_000, 100, 3.0)
811 .expect_err("over-limit lattice must be rejected before range allocation");
812 assert!(matches!(
813 err,
814 IlsError::TooManyCandidates {
815 evaluated,
816 limit: 100
817 } if evaluated > 100
818 ));
819
820 let normal = bounded_ils_search(&float, &cov, 1, 9, 3.0)
821 .expect("within-limit lattice should still enumerate normally");
822 assert_eq!(normal.fixed, vec![0, 0]);
823 assert_eq!(normal.candidates_evaluated, 9);
824 }
825
826 #[test]
827 fn rejects_search_values_outside_i64_domain() {
828 let cov = vec![vec![1.0]];
829 let expected = Err(IlsError::InvalidInput {
830 field: "ils float_cycles",
831 reason: "outside integer search range",
832 });
833
834 assert_eq!(bounded_ils_search(&[f64::MAX], &cov, 1, 3, 3.0), expected);
835 assert_eq!(
836 bounded_ils_search(&[i64::MAX as f64], &cov, 1, 3, 3.0),
837 expected
838 );
839 assert_eq!(
840 bounded_ils_search(&[i64::MIN as f64], &cov, 1, 3, 3.0),
841 expected
842 );
843
844 assert_eq!(lambda_ils_search(&[f64::MAX], &cov, 3.0), expected);
845 assert_eq!(lambda_ils_search(&[-f64::MAX], &cov, 3.0), expected);
846 assert_eq!(lambda_ils_search(&[i64::MAX as f64], &cov, 3.0), expected);
847 }
848
849 #[test]
850 fn rejects_ci_lambda_crash_outside_integer_domain() {
851 let float_cycles = [1.382_418_547_873_630_5e306, 1.382_417_208_487_871_5e306];
852 let covariance = vec![
853 vec![1.382_418_547_873_630_5e306, 1.382_417_208_487_871_5e306],
854 vec![1.382_417_208_487_871_5e306, 1.382_417_208_487_871_5e306],
855 ];
856
857 assert_eq!(
858 lambda_ils_search(&float_cycles, &covariance, 1.382_417_208_487_871_5e306),
859 Err(IlsError::InvalidInput {
860 field: "ils float_cycles",
861 reason: "outside integer search range",
862 })
863 );
864 }
865
866 fn full_matrix(flat: &[f64], n: usize) -> Vec<Vec<f64>> {
871 (0..n)
872 .map(|i| (0..n).map(|j| flat[i * n + j]).collect())
873 .collect()
874 }
875
876 #[test]
877 fn lambda_matches_rtklib_utest1() {
878 let a = [
879 1585184.171,
880 -6716599.430,
881 3915742.905,
882 7627233.455,
883 9565990.879,
884 989457273.200,
885 ];
886 #[rustfmt::skip]
887 let q = full_matrix(&[
888 0.227134, 0.112202, 0.112202, 0.112202, 0.112202, 0.103473,
889 0.112202, 0.227134, 0.112202, 0.112202, 0.112202, 0.103473,
890 0.112202, 0.112202, 0.227134, 0.112202, 0.112202, 0.103473,
891 0.112202, 0.112202, 0.112202, 0.227134, 0.112202, 0.103473,
892 0.112202, 0.112202, 0.112202, 0.112202, 0.227134, 0.103473,
893 0.103473, 0.103473, 0.103473, 0.103473, 0.103473, 0.434339,
894 ], 6);
895
896 let r = lambda_ils_search(&a, &q, 3.0).unwrap();
897 assert_eq!(
898 r.fixed,
899 vec![1585184, -6716599, 3915743, 7627234, 9565991, 989457273]
900 );
901 assert!((r.best_score - 3.5079844392).abs() < 1e-4);
902 assert!((r.second_best_score.unwrap() - 3.70845619249).abs() < 1e-4);
903 }
904
905 #[test]
906 fn lambda_matches_rtklib_utest2_strongly_correlated() {
907 let a = [
910 -13324172.755747,
911 -10668894.713608,
912 -7157225.010770,
913 -6149367.974367,
914 -7454133.571066,
915 -5969200.494550,
916 8336734.058423,
917 6186974.084502,
918 -17549093.883655,
919 -13970158.922370,
920 ];
921 #[rustfmt::skip]
922 let q = full_matrix(&[
923 0.446320,0.223160,0.223160,0.223160,0.223160,0.572775,0.286388,0.286388,0.286388,0.286388,
924 0.223160,0.446320,0.223160,0.223160,0.223160,0.286388,0.572775,0.286388,0.286388,0.286388,
925 0.223160,0.223160,0.446320,0.223160,0.223160,0.286388,0.286388,0.572775,0.286388,0.286388,
926 0.223160,0.223160,0.223160,0.446320,0.223160,0.286388,0.286388,0.286388,0.572775,0.286388,
927 0.223160,0.223160,0.223160,0.223160,0.446320,0.286388,0.286388,0.286388,0.286388,0.572775,
928 0.572775,0.286388,0.286388,0.286388,0.286388,0.735063,0.367531,0.367531,0.367531,0.367531,
929 0.286388,0.572775,0.286388,0.286388,0.286388,0.367531,0.735063,0.367531,0.367531,0.367531,
930 0.286388,0.286388,0.572775,0.286388,0.286388,0.367531,0.367531,0.735063,0.367531,0.367531,
931 0.286388,0.286388,0.286388,0.572775,0.286388,0.367531,0.367531,0.367531,0.735063,0.367531,
932 0.286388,0.286388,0.286388,0.286388,0.572775,0.367531,0.367531,0.367531,0.367531,0.735063,
933 ], 10);
934
935 let r = lambda_ils_search(&a, &q, 3.0).unwrap();
936 assert_eq!(
937 r.fixed,
938 vec![
939 -13324188, -10668901, -7157236, -6149379, -7454143, -5969220, 8336726, 6186960,
940 -17549108, -13970171
941 ]
942 );
943 assert!((r.best_score - 1506.43578925).abs() < 1e-4);
944 assert!((r.second_best_score.unwrap() - 1612.81176533).abs() < 1e-4);
945 }
946
947 #[test]
948 fn lambda_matches_rtklib_near_tie_low_ratio() {
949 let a = [
954 2.381283532896866,
955 -4.153279079035503,
956 6.181180039414691,
957 -1.1716816183885634,
958 3.144312353800454,
959 ];
960 #[rustfmt::skip]
961 let q = full_matrix(&[
962 0.30250000000000005, 0.11549999999999999, 0.09625, 0.12512500000000001, 0.11165,
963 0.11549999999999999, 0.36, 0.105, 0.13649999999999998, 0.12179999999999998,
964 0.09625, 0.105, 0.25, 0.11374999999999999, 0.10149999999999999,
965 0.12512500000000001, 0.13649999999999998, 0.11374999999999999, 0.42250000000000004, 0.13194999999999998,
966 0.11165, 0.12179999999999998, 0.10149999999999999, 0.13194999999999998, 0.3364,
967 ], 5);
968
969 let r = lambda_ils_search(&a, &q, 3.0).unwrap();
970 assert_eq!(r.fixed, vec![2, -4, 6, -1, 3]);
971 assert!((r.best_score - 1.1061496957026506).abs() < 1e-4);
972 assert!((r.second_best_score.unwrap() - 2.2123104750064506).abs() < 1e-4);
973 assert!((r.ratio - 2.0000100199830024).abs() < 1e-6);
974 assert!(!r.fixed_status); }
976
977 #[test]
978 fn lambda_matches_rtklib_easy_near_diagonal() {
979 let a = [4.03, -2.97, 1.02, 5.98];
983 #[rustfmt::skip]
984 let q = full_matrix(&[
985 0.018, 0.002, 0.0, 0.0,
986 0.002, 0.025, 0.0, 0.0,
987 0.0, 0.0, 0.012, 0.0015,
988 0.0, 0.0, 0.0015, 0.03,
989 ], 4);
990
991 let r = lambda_ils_search(&a, &q, 3.0).unwrap();
992 assert_eq!(r.fixed, vec![4, -3, 1, 6]);
993 assert!((r.best_score - 0.12901401697831202).abs() < 1e-4);
994 assert!((r.second_best_score.unwrap() - 32.16255699391752).abs() < 1e-4);
995 assert!((r.ratio - 249.29505915100856).abs() < 1e-6);
996 assert!(r.fixed_status); }
998
999 #[test]
1000 fn lambda_agrees_with_box_search_in_regime() {
1001 let a = vec![0.30, -0.40, 1.20];
1003 let q = vec![
1004 vec![0.50, 0.10, 0.05],
1005 vec![0.10, 0.50, 0.10],
1006 vec![0.05, 0.10, 0.50],
1007 ];
1008 let lam = lambda_ils_search(&a, &q, 3.0).unwrap();
1009 let box_ = bounded_ils_search(&a, &q, 1, 200_000, 3.0).unwrap();
1010 assert_eq!(lam.fixed, box_.fixed);
1011 assert!((lam.best_score - box_.best_score).abs() < 1e-9);
1012 assert!((lam.second_best_score.unwrap() - box_.second_best_score.unwrap()).abs() < 1e-9);
1013 }
1014
1015 #[test]
1018 fn rejects_undersized_covariance() {
1019 let a = vec![0.1, 0.2];
1021 let q = vec![vec![1.0]];
1022 assert_eq!(
1023 bounded_ils_search(&a, &q, 1, 200_000, 3.0),
1024 Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
1025 );
1026 assert_eq!(
1027 lambda_ils_search(&a, &q, 3.0),
1028 Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
1029 );
1030 }
1031
1032 #[test]
1033 fn rejects_oversized_covariance() {
1034 let a = vec![0.1];
1036 let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
1037 assert_eq!(
1038 bounded_ils_search(&a, &q, 1, 200_000, 3.0),
1039 Err(IlsError::InvalidDimensions { n: 1, rows: 2 })
1040 );
1041 assert_eq!(
1042 lambda_ils_search(&a, &q, 3.0),
1043 Err(IlsError::InvalidDimensions { n: 1, rows: 2 })
1044 );
1045 }
1046
1047 #[test]
1048 fn rejects_ragged_covariance() {
1049 let a = vec![0.1, 0.2];
1051 let q = vec![vec![1.0, 0.0], vec![0.0]];
1052 assert_eq!(
1053 bounded_ils_search(&a, &q, 1, 200_000, 3.0),
1054 Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
1055 );
1056 assert_eq!(
1057 lambda_ils_search(&a, &q, 3.0),
1058 Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
1059 );
1060 }
1061
1062 #[test]
1063 fn bounded_search_rejects_invalid_covariance_geometry() {
1064 let a = vec![0.1, 0.2];
1065 let expected = Err(IlsError::InvalidInput {
1066 field: "ils covariance",
1067 reason: "not positive",
1068 });
1069
1070 let negative_variance = vec![vec![-1.0, 0.0], vec![0.0, 1.0]];
1071 assert_eq!(
1072 bounded_ils_search(&a, &negative_variance, 1, 200_000, 3.0),
1073 expected
1074 );
1075
1076 let asymmetric = vec![vec![1.0, 0.5], vec![0.4, 1.0]];
1077 assert_eq!(
1078 bounded_ils_search(&a, &asymmetric, 1, 200_000, 3.0),
1079 expected
1080 );
1081
1082 let indefinite = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
1083 assert_eq!(
1084 bounded_ils_search(&a, &indefinite, 1, 200_000, 3.0),
1085 expected
1086 );
1087 }
1088
1089 #[test]
1090 fn lambda_search_rejects_invalid_covariance_geometry() {
1091 let a = vec![0.1, 0.2];
1092 let expected = Err(IlsError::InvalidInput {
1093 field: "ils covariance",
1094 reason: "not positive",
1095 });
1096
1097 let negative_variance = vec![vec![-1.0, 0.0], vec![0.0, 1.0]];
1098 assert_eq!(lambda_ils_search(&a, &negative_variance, 3.0), expected);
1099
1100 let asymmetric = vec![vec![1.0, 0.5], vec![0.4, 1.0]];
1101 assert_eq!(lambda_ils_search(&a, &asymmetric, 3.0), expected);
1102
1103 let indefinite = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
1104 assert_eq!(lambda_ils_search(&a, &indefinite, 3.0), expected);
1105 }
1106
1107 #[test]
1108 fn rejects_empty_input() {
1109 let a: Vec<f64> = vec![];
1110 let q: Vec<Vec<f64>> = vec![];
1111 assert_eq!(
1112 bounded_ils_search(&a, &q, 1, 200_000, 3.0),
1113 Err(IlsError::InvalidDimensions { n: 0, rows: 0 })
1114 );
1115 assert_eq!(
1116 lambda_ils_search(&a, &q, 3.0),
1117 Err(IlsError::InvalidDimensions { n: 0, rows: 0 })
1118 );
1119 }
1120
1121 #[test]
1122 fn rejects_non_finite_input() {
1123 let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
1124 assert_eq!(
1125 bounded_ils_search(&[f64::NAN, 0.2], &q, 1, 200_000, 3.0),
1126 Err(IlsError::NonFinite)
1127 );
1128 let q_inf = vec![vec![f64::INFINITY, 0.0], vec![0.0, 1.0]];
1129 assert_eq!(
1130 lambda_ils_search(&[0.1, 0.2], &q_inf, 3.0),
1131 Err(IlsError::NonFinite)
1132 );
1133 }
1134
1135 #[test]
1136 fn rejects_invalid_ratio_thresholds() {
1137 let a = vec![0.1, 0.2];
1138 let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
1139
1140 for (threshold, reason) in [
1141 (-1.0, "negative"),
1142 (f64::NAN, "not finite"),
1143 (f64::INFINITY, "not finite"),
1144 ] {
1145 let expected = Err(IlsError::InvalidInput {
1146 field: ILS_RATIO_THRESHOLD_FIELD,
1147 reason,
1148 });
1149 assert_eq!(bounded_ils_search(&a, &q, 1, 200_000, threshold), expected);
1150 assert_eq!(lambda_ils_search(&a, &q, threshold), expected);
1151 }
1152 }
1153
1154 #[test]
1155 fn exact_integer_fix_reports_finite_saturated_ratio() {
1156 let a = vec![1.0];
1157 let q = vec![vec![1.0]];
1158
1159 let bounded = bounded_ils_search(&a, &q, 1, 3, 3.0).unwrap();
1160 assert_eq!(bounded.best_score, 0.0);
1161 assert_eq!(bounded.second_best_score, Some(1.0));
1162 assert_eq!(bounded.ratio, f64::MAX);
1163 assert!(bounded.ratio.is_finite());
1164 assert!(bounded.fixed_status);
1165
1166 let lambda = lambda_ils_search(&a, &q, 3.0).unwrap();
1167 assert_eq!(lambda.best_score, 0.0);
1168 assert_eq!(lambda.second_best_score, Some(1.0));
1169 assert_eq!(lambda.ratio, f64::MAX);
1170 assert!(lambda.ratio.is_finite());
1171 assert!(lambda.fixed_status);
1172 }
1173
1174 #[test]
1175 fn valid_ratio_threshold_still_controls_fix_status() {
1176 let a = vec![3.02, -1.98, 5.01];
1177 let q = vec![
1178 vec![0.01, 0.0, 0.0],
1179 vec![0.0, 0.01, 0.0],
1180 vec![0.0, 0.0, 0.01],
1181 ];
1182
1183 let bounded_fixed = bounded_ils_search(&a, &q, 1, 200_000, 3.0).unwrap();
1184 let bounded_held = bounded_ils_search(&a, &q, 1, 200_000, 1.0e12).unwrap();
1185 assert!(bounded_fixed.fixed_status);
1186 assert!(!bounded_held.fixed_status);
1187 assert_eq!(bounded_fixed.fixed, bounded_held.fixed);
1188
1189 let lambda_fixed = lambda_ils_search(&a, &q, 3.0).unwrap();
1190 let lambda_held = lambda_ils_search(&a, &q, 1.0e12).unwrap();
1191 assert!(lambda_fixed.fixed_status);
1192 assert!(!lambda_held.fixed_status);
1193 assert_eq!(lambda_fixed.fixed, lambda_held.fixed);
1194 }
1195}