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,
152 pub covariance: Vec<Vec<f64>>,
154 pub covariance_inverse: Vec<Vec<f64>>,
156}
157
158pub fn bounded_ils_search(
164 float_cycles: &[f64],
165 covariance: &[Vec<f64>],
166 radius: i64,
167 candidate_limit: usize,
168 ratio_threshold: f64,
169) -> core::result::Result<IlsResult, IlsError> {
170 validate_inputs(float_cycles, covariance)?;
171 validate_covariance_geometry(covariance)?;
172 validate_ratio_threshold(ratio_threshold)?;
173 let q = symmetrize(covariance);
174 let q_inv = symmetrize(&invert(&q).map_err(|_| IlsError::Singular)?);
175 ensure_candidate_limit(float_cycles.len(), radius, candidate_limit)?;
176
177 let ranges: Vec<Vec<i64>> = float_cycles
181 .iter()
182 .map(|&f| bounded_integer_candidates(f, radius))
183 .collect::<core::result::Result<_, _>>()?;
184
185 let mut top: Vec<(f64, Vec<i64>)> = Vec::with_capacity(2);
186 let mut evaluated: usize = 0;
187 let mut current: Vec<i64> = Vec::with_capacity(float_cycles.len());
188
189 let ctx = LatticeEnum {
190 ranges: &ranges,
191 float_cycles,
192 q_inv: &q_inv,
193 limit: candidate_limit,
194 };
195 enumerate(&ctx, 0, &mut current, &mut evaluated, &mut top)?;
196
197 let (best_score, fixed) = match top.first() {
198 Some((s, c)) => (*s, c.clone()),
199 None => return Err(IlsError::NoCandidates(evaluated)),
200 };
201 let second_best_score = top.get(1).map(|(s, _)| *s);
202 let ratio = integer_ratio(best_score, second_best_score);
203
204 Ok(IlsResult {
205 fixed,
206 fixed_status: ratio_pass(ratio, ratio_threshold),
207 ratio,
208 best_score,
209 second_best_score,
210 candidates_evaluated: evaluated,
211 covariance: q,
212 covariance_inverse: q_inv,
213 })
214}
215
216struct LatticeEnum<'a> {
223 ranges: &'a [Vec<i64>],
224 float_cycles: &'a [f64],
225 q_inv: &'a [Vec<f64>],
226 limit: usize,
227}
228
229fn enumerate(
230 ctx: &LatticeEnum,
231 depth: usize,
232 current: &mut Vec<i64>,
233 evaluated: &mut usize,
234 top: &mut Vec<(f64, Vec<i64>)>,
235) -> core::result::Result<(), IlsError> {
236 if depth == ctx.ranges.len() {
237 *evaluated += 1;
238 if *evaluated > ctx.limit {
239 return Err(IlsError::TooManyCandidates {
240 evaluated: *evaluated,
241 limit: ctx.limit,
242 });
243 }
244 let score = quadratic_score(ctx.float_cycles, current, ctx.q_inv);
245 insert_top_two(top, score, current);
246 return Ok(());
247 }
248
249 for &value in &ctx.ranges[depth] {
250 current.push(value);
251 enumerate(ctx, depth + 1, current, evaluated, top)?;
252 current.pop();
253 }
254 Ok(())
255}
256
257fn insert_top_two(top: &mut Vec<(f64, Vec<i64>)>, score: f64, cycles: &[i64]) {
260 top.push((score, cycles.to_vec()));
261 top.sort_by(|(sa, ca), (sb, cb)| {
262 sa.partial_cmp(sb)
263 .unwrap_or(core::cmp::Ordering::Equal)
264 .then_with(|| ca.cmp(cb))
265 });
266 top.truncate(2);
267}
268
269fn quadratic_score(float_cycles: &[f64], fixed: &[i64], q_inv: &[Vec<f64>]) -> f64 {
270 let n = float_cycles.len();
271 let deltas: Vec<f64> = (0..n).map(|i| float_cycles[i] - fixed[i] as f64).collect();
273
274 let mut acc = 0.0;
276 for i in 0..n {
277 for j in 0..n {
278 acc += deltas[i] * q_inv[i][j] * deltas[j];
279 }
280 }
281 acc
282}
283
284fn integers_near(center: f64, low: i64, high: i64) -> Vec<i64> {
285 let mut values: Vec<i64> = (low..=high).collect();
286 values.sort_by(|&a, &b| {
287 let da = (a as f64 - center).abs();
288 let db = (b as f64 - center).abs();
289 da.partial_cmp(&db)
290 .unwrap_or(core::cmp::Ordering::Equal)
291 .then_with(|| a.cmp(&b))
292 });
293 values
294}
295
296fn bounded_integer_candidates(
297 float_cycle: f64,
298 radius: i64,
299) -> core::result::Result<Vec<i64>, IlsError> {
300 if radius < 0 {
301 return Ok(Vec::new());
302 }
303
304 let rounded = float_cycle.round(); const I64_MAX_EXCLUSIVE: f64 = 9_223_372_036_854_775_808.0;
306 if rounded < i64::MIN as f64 || rounded >= I64_MAX_EXCLUSIVE {
307 return Err(IlsError::InvalidInput {
308 field: "ils float_cycles",
309 reason: "outside integer search range",
310 });
311 }
312
313 let center_i64 = rounded as i64;
314 let low = center_i64
315 .checked_sub(radius)
316 .ok_or(IlsError::InvalidInput {
317 field: "ils float_cycles",
318 reason: "outside integer search range",
319 })?;
320 let high = center_i64
321 .checked_add(radius)
322 .ok_or(IlsError::InvalidInput {
323 field: "ils float_cycles",
324 reason: "outside integer search range",
325 })?;
326 Ok(integers_near(float_cycle, low, high))
327}
328
329fn ensure_candidate_limit(
330 dimensions: usize,
331 radius: i64,
332 limit: usize,
333) -> core::result::Result<(), IlsError> {
334 let per_dimension = if radius < 0 {
335 0usize
336 } else {
337 let width = radius
338 .checked_mul(2)
339 .and_then(|width| width.checked_add(1))
340 .ok_or(IlsError::TooManyCandidates {
341 evaluated: usize::MAX,
342 limit,
343 })?;
344 usize::try_from(width).map_err(|_| IlsError::TooManyCandidates {
345 evaluated: usize::MAX,
346 limit,
347 })?
348 };
349
350 let mut candidates = 1usize;
351 for _ in 0..dimensions {
352 candidates = candidates
353 .checked_mul(per_dimension)
354 .ok_or(IlsError::TooManyCandidates {
355 evaluated: usize::MAX,
356 limit,
357 })?;
358 if candidates > limit {
359 return Err(IlsError::TooManyCandidates {
360 evaluated: candidates,
361 limit,
362 });
363 }
364 }
365
366 Ok(())
367}
368
369fn integer_ratio(best_score: f64, second_best_score: Option<f64>) -> f64 {
370 match second_best_score {
371 None => 0.0,
372 Some(second) => {
373 if best_score == 0.0 && second > 0.0 {
374 f64::MAX
375 } else if best_score == 0.0 {
376 0.0
377 } else {
378 second / best_score
379 }
380 }
381 }
382}
383
384fn ratio_pass(ratio: f64, threshold: f64) -> bool {
385 ratio >= threshold
386}
387
388fn 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
397pub(crate) fn invert(a: &[Vec<f64>]) -> Result<Vec<Vec<f64>>> {
401 invert_matrix_first_tie(a).ok_or_else(|| Error::InvalidInput("singular matrix".into()))
402}
403
404pub(crate) fn solve_linear(a: &[Vec<f64>], b: &[f64]) -> Result<Vec<f64>> {
409 solve_linear_first_tie(a, b).ok_or_else(|| Error::InvalidInput("singular matrix".into()))
410}
411
412const LAMBDA_LOOP_MAX: usize = 10000;
429
430#[inline]
431fn lam_round(x: f64) -> f64 {
432 (x + 0.5).floor() }
434
435#[inline]
436fn lam_sgn(x: f64) -> f64 {
437 if x <= 0.0 {
438 -1.0
439 } else {
440 1.0
441 }
442}
443
444fn lam_ld(n: usize, q: &[f64]) -> Option<(Vec<f64>, Vec<f64>)> {
447 let mut a = q.to_vec();
448 let mut l = vec![0.0f64; n * n];
449 let mut d = vec![0.0f64; n];
450 for i in (0..n).rev() {
451 d[i] = a[i + i * n];
452 if d[i] <= 0.0 {
453 return None;
454 }
455 let ai = d[i].sqrt();
456 for j in 0..=i {
457 l[i + j * n] = a[i + j * n] / ai;
458 }
459 for j in 0..i {
460 for k in 0..=j {
461 a[j + k * n] -= l[i + k * n] * l[i + j * n];
462 }
463 }
464 for j in 0..=i {
465 l[i + j * n] /= l[i + i * n];
466 }
467 }
468 Some((l, d))
469}
470
471fn lam_gauss(n: usize, l: &mut [f64], z: &mut [f64], i: usize, j: usize) {
473 let mu = lam_round(l[i + j * n]) as i64;
474 if mu != 0 {
475 let muf = mu as f64;
476 for k in i..n {
477 l[k + j * n] -= muf * l[k + i * n];
478 }
479 for k in 0..n {
480 z[k + j * n] -= muf * z[k + i * n];
481 }
482 }
483}
484
485fn lam_perm(n: usize, l: &mut [f64], d: &mut [f64], j: usize, del: f64, z: &mut [f64]) {
487 let eta = d[j] / del;
488 let lam = d[j + 1] * l[j + 1 + j * n] / del;
489 d[j] = eta * d[j + 1];
490 d[j + 1] = del;
491 for k in 0..j {
492 let a0 = l[j + k * n];
493 let a1 = l[j + 1 + k * n];
494 l[j + k * n] = -l[j + 1 + j * n] * a0 + a1;
495 l[j + 1 + k * n] = eta * a0 + lam * a1;
496 }
497 l[j + 1 + j * n] = lam;
498 for k in (j + 2)..n {
499 l.swap(k + j * n, k + (j + 1) * n);
500 }
501 for k in 0..n {
502 z.swap(k + j * n, k + (j + 1) * n);
503 }
504}
505
506fn lam_reduction(n: usize, l: &mut [f64], d: &mut [f64], z: &mut [f64]) {
509 let mut j: isize = n as isize - 2;
510 let mut k: isize = n as isize - 2;
511 while j >= 0 {
512 let ju = j as usize;
513 if j <= k {
514 for i in (ju + 1)..n {
515 lam_gauss(n, l, z, i, ju);
516 }
517 }
518 let del = d[ju] + l[ju + 1 + ju * n] * l[ju + 1 + ju * n] * d[ju + 1];
519 if del + LAMBDA_REDUCTION_EPS < d[ju + 1] {
520 lam_perm(n, l, d, ju, del, z);
521 k = j;
522 j = n as isize - 2;
523 } else {
524 j -= 1;
525 }
526 }
527}
528
529fn lam_search(
534 n: usize,
535 m: usize,
536 l: &[f64],
537 d: &[f64],
538 zs: &[f64],
539) -> Option<(Vec<f64>, Vec<f64>)> {
540 let mut s = vec![0.0f64; m];
541 let mut zn = vec![0.0f64; n * m];
542 let mut smat = vec![0.0f64; n * n];
543 let mut dist = vec![0.0f64; n];
544 let mut zb = vec![0.0f64; n];
545 let mut z = vec![0.0f64; n];
546 let mut step = vec![0.0f64; n];
547
548 let mut nn: usize = 0;
549 let mut imax: usize = 0;
550 let mut maxdist = 1.0e99;
551
552 let mut k: isize = n as isize - 1;
553 let ku = k as usize;
554 dist[ku] = 0.0;
555 zb[ku] = zs[ku];
556 z[ku] = lam_round(zb[ku]);
557 let mut y = zb[ku] - z[ku];
558 step[ku] = lam_sgn(y);
559
560 let mut c = 0usize;
561 while c < LAMBDA_LOOP_MAX {
562 let kk = k as usize;
563 let newdist = dist[kk] + y * y / d[kk];
564 if newdist < maxdist {
565 if k != 0 {
566 k -= 1;
567 let kk = k as usize;
568 dist[kk] = newdist;
569 for i in 0..=kk {
570 smat[kk + i * n] =
571 smat[kk + 1 + i * n] + (z[kk + 1] - zb[kk + 1]) * l[kk + 1 + i * n];
572 }
573 zb[kk] = zs[kk] + smat[kk + kk * n];
574 z[kk] = lam_round(zb[kk]);
575 y = zb[kk] - z[kk];
576 step[kk] = lam_sgn(y);
577 } else {
578 if nn < m {
579 if nn == 0 || newdist > s[imax] {
580 imax = nn;
581 }
582 for i in 0..n {
583 zn[i + nn * n] = z[i];
584 }
585 s[nn] = newdist;
586 nn += 1;
587 } else {
588 if newdist < s[imax] {
589 for i in 0..n {
590 zn[i + imax * n] = z[i];
591 }
592 s[imax] = newdist;
593 imax = 0;
594 for i in 0..m {
595 if s[imax] < s[i] {
596 imax = i;
597 }
598 }
599 }
600 maxdist = s[imax];
601 }
602 z[0] += step[0];
603 y = zb[0] - z[0];
604 step[0] = -step[0] - lam_sgn(step[0]);
605 }
606 } else if k == n as isize - 1 {
607 break;
608 } else {
609 k += 1;
610 let kk = k as usize;
611 z[kk] += step[kk];
612 y = zb[kk] - z[kk];
613 step[kk] = -step[kk] - lam_sgn(step[kk]);
614 }
615 c += 1;
616 }
617
618 if c >= LAMBDA_LOOP_MAX {
619 return None;
620 }
621
622 for i in 0..m.saturating_sub(1) {
624 for j in (i + 1)..m {
625 if s[i] < s[j] {
626 continue;
627 }
628 s.swap(i, j);
629 for k in 0..n {
630 zn.swap(k + i * n, k + j * n);
631 }
632 }
633 }
634 Some((zn, s))
635}
636
637pub fn lambda_ils_search(
645 float_cycles: &[f64],
646 covariance: &[Vec<f64>],
647 ratio_threshold: f64,
648) -> core::result::Result<IlsResult, IlsError> {
649 validate_inputs(float_cycles, covariance)?;
650 validate_covariance_geometry(covariance)?;
651 validate_ratio_threshold(ratio_threshold)?;
652 let n = float_cycles.len();
653 let q = symmetrize(covariance);
654 let q_inv = symmetrize(&invert(&q).map_err(|_| IlsError::Singular)?);
656
657 let mut q_cm = vec![0.0f64; n * n];
659 for i in 0..n {
660 for j in 0..n {
661 q_cm[i + j * n] = q[i][j];
662 }
663 }
664
665 let (mut l, mut d) = lam_ld(n, &q_cm).ok_or(IlsError::Singular)?;
666 let mut z = {
667 let mut e = vec![0.0f64; n * n];
669 for i in 0..n {
670 e[i + i * n] = 1.0;
671 }
672 e
673 };
674 lam_reduction(n, &mut l, &mut d, &mut z);
675
676 let mut zs = vec![0.0f64; n];
678 for i in 0..n {
679 let mut acc = 0.0;
680 for k in 0..n {
681 acc += z[k + i * n] * float_cycles[k];
682 }
683 zs[i] = acc;
684 }
685
686 let m = 2usize;
689 let (zn, _s) = lam_search(n, m, &l, &d, &zs).ok_or(IlsError::SearchLimitExceeded)?;
690
691 let mut zt = vec![vec![0.0f64; n]; n];
694 for i in 0..n {
695 for j in 0..n {
696 zt[i][j] = z[j + i * n]; }
698 }
699 let mut fixed_candidates: Vec<Vec<i64>> = Vec::with_capacity(m);
700 for col in 0..m {
701 let b: Vec<f64> = (0..n).map(|i| zn[i + col * n]).collect();
702 let x = solve_linear(&zt, &b).map_err(|_| IlsError::Singular)?;
703 fixed_candidates.push(x.iter().map(|&v| lam_round(v) as i64).collect());
704 }
705
706 let mut scored: Vec<(f64, Vec<i64>)> = fixed_candidates
713 .into_iter()
714 .map(|c| (quadratic_score(float_cycles, &c, &q_inv), c))
715 .collect();
716 scored.sort_by(|(sa, ca), (sb, cb)| {
717 sa.partial_cmp(sb)
718 .unwrap_or(core::cmp::Ordering::Equal)
719 .then_with(|| ca.cmp(cb))
720 });
721
722 let best_score = scored[0].0;
723 let fixed = scored[0].1.clone();
724 let second_best_score = scored.get(1).map(|(s, _)| *s);
725 let ratio = integer_ratio(best_score, second_best_score);
726
727 Ok(IlsResult {
728 fixed,
729 fixed_status: ratio_pass(ratio, ratio_threshold),
730 ratio,
731 best_score,
732 second_best_score,
733 candidates_evaluated: m,
735 covariance: q,
736 covariance_inverse: q_inv,
737 })
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743
744 #[test]
745 fn inverts_a_known_matrix() {
746 let a = vec![vec![4.0, 7.0], vec![2.0, 6.0]];
747 let inv = invert(&a).unwrap();
748 assert!((inv[0][0] - 0.6).abs() < 1e-12);
750 assert!((inv[0][1] + 0.7).abs() < 1e-12);
751 assert!((inv[1][0] + 0.2).abs() < 1e-12);
752 assert!((inv[1][1] - 0.4).abs() < 1e-12);
753 }
754
755 #[test]
756 fn rejects_a_singular_matrix() {
757 let a = vec![vec![1.0, 2.0], vec![2.0, 4.0]];
758 assert!(invert(&a).is_err());
759 }
760
761 #[test]
762 fn fixes_a_well_separated_lattice_point() {
763 let float = vec![3.02, -1.98, 5.01];
766 let cov = vec![
767 vec![0.01, 0.0, 0.0],
768 vec![0.0, 0.01, 0.0],
769 vec![0.0, 0.0, 0.01],
770 ];
771 let r = bounded_ils_search(&float, &cov, 1, 200_000, 3.0).unwrap();
772 assert_eq!(r.fixed, vec![3, -2, 5]);
773 assert!(r.fixed_status);
774 assert!(r.ratio > 3.0);
775 assert_eq!(r.candidates_evaluated, 27); }
777
778 #[test]
779 fn refuses_an_ambiguous_lattice() {
780 let float = vec![0.5, 0.5];
782 let cov = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
783 let r = bounded_ils_search(&float, &cov, 1, 200_000, 3.0).unwrap();
784 assert!(!r.fixed_status);
785 assert!(r.ratio < 3.0);
786 }
787
788 #[test]
789 fn errors_when_the_lattice_exceeds_the_candidate_limit() {
790 let float = vec![0.0, 0.0, 0.0];
791 let cov = vec![
792 vec![1.0, 0.0, 0.0],
793 vec![0.0, 1.0, 0.0],
794 vec![0.0, 0.0, 1.0],
795 ];
796 assert_eq!(
798 bounded_ils_search(&float, &cov, 1, 10, 3.0),
799 Err(IlsError::TooManyCandidates {
800 evaluated: 27,
801 limit: 10
802 })
803 );
804 }
805
806 #[test]
807 fn rejects_pathological_lattice_before_allocating_ranges() {
808 let float = vec![0.0, 0.0];
809 let cov = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
810
811 let err = bounded_ils_search(&float, &cov, 1_000_000_000, 100, 3.0)
812 .expect_err("over-limit lattice must be rejected before range allocation");
813 assert!(matches!(
814 err,
815 IlsError::TooManyCandidates {
816 evaluated,
817 limit: 100
818 } if evaluated > 100
819 ));
820
821 let normal = bounded_ils_search(&float, &cov, 1, 9, 3.0)
822 .expect("within-limit lattice should still enumerate normally");
823 assert_eq!(normal.fixed, vec![0, 0]);
824 assert_eq!(normal.candidates_evaluated, 9);
825 }
826
827 #[test]
828 fn rejects_bounded_search_ranges_outside_i64_domain() {
829 let cov = vec![vec![1.0]];
830 let expected = Err(IlsError::InvalidInput {
831 field: "ils float_cycles",
832 reason: "outside integer search range",
833 });
834
835 assert_eq!(bounded_ils_search(&[f64::MAX], &cov, 1, 3, 3.0), expected);
836 assert_eq!(
837 bounded_ils_search(&[i64::MAX as f64], &cov, 1, 3, 3.0),
838 expected
839 );
840 assert_eq!(
841 bounded_ils_search(&[i64::MIN as f64], &cov, 1, 3, 3.0),
842 expected
843 );
844 }
845
846 fn full_matrix(flat: &[f64], n: usize) -> Vec<Vec<f64>> {
851 (0..n)
852 .map(|i| (0..n).map(|j| flat[i * n + j]).collect())
853 .collect()
854 }
855
856 #[test]
857 fn lambda_matches_rtklib_utest1() {
858 let a = [
859 1585184.171,
860 -6716599.430,
861 3915742.905,
862 7627233.455,
863 9565990.879,
864 989457273.200,
865 ];
866 #[rustfmt::skip]
867 let q = full_matrix(&[
868 0.227134, 0.112202, 0.112202, 0.112202, 0.112202, 0.103473,
869 0.112202, 0.227134, 0.112202, 0.112202, 0.112202, 0.103473,
870 0.112202, 0.112202, 0.227134, 0.112202, 0.112202, 0.103473,
871 0.112202, 0.112202, 0.112202, 0.227134, 0.112202, 0.103473,
872 0.112202, 0.112202, 0.112202, 0.112202, 0.227134, 0.103473,
873 0.103473, 0.103473, 0.103473, 0.103473, 0.103473, 0.434339,
874 ], 6);
875
876 let r = lambda_ils_search(&a, &q, 3.0).unwrap();
877 assert_eq!(
878 r.fixed,
879 vec![1585184, -6716599, 3915743, 7627234, 9565991, 989457273]
880 );
881 assert!((r.best_score - 3.5079844392).abs() < 1e-4);
882 assert!((r.second_best_score.unwrap() - 3.70845619249).abs() < 1e-4);
883 }
884
885 #[test]
886 fn lambda_matches_rtklib_utest2_strongly_correlated() {
887 let a = [
890 -13324172.755747,
891 -10668894.713608,
892 -7157225.010770,
893 -6149367.974367,
894 -7454133.571066,
895 -5969200.494550,
896 8336734.058423,
897 6186974.084502,
898 -17549093.883655,
899 -13970158.922370,
900 ];
901 #[rustfmt::skip]
902 let q = full_matrix(&[
903 0.446320,0.223160,0.223160,0.223160,0.223160,0.572775,0.286388,0.286388,0.286388,0.286388,
904 0.223160,0.446320,0.223160,0.223160,0.223160,0.286388,0.572775,0.286388,0.286388,0.286388,
905 0.223160,0.223160,0.446320,0.223160,0.223160,0.286388,0.286388,0.572775,0.286388,0.286388,
906 0.223160,0.223160,0.223160,0.446320,0.223160,0.286388,0.286388,0.286388,0.572775,0.286388,
907 0.223160,0.223160,0.223160,0.223160,0.446320,0.286388,0.286388,0.286388,0.286388,0.572775,
908 0.572775,0.286388,0.286388,0.286388,0.286388,0.735063,0.367531,0.367531,0.367531,0.367531,
909 0.286388,0.572775,0.286388,0.286388,0.286388,0.367531,0.735063,0.367531,0.367531,0.367531,
910 0.286388,0.286388,0.572775,0.286388,0.286388,0.367531,0.367531,0.735063,0.367531,0.367531,
911 0.286388,0.286388,0.286388,0.572775,0.286388,0.367531,0.367531,0.367531,0.735063,0.367531,
912 0.286388,0.286388,0.286388,0.286388,0.572775,0.367531,0.367531,0.367531,0.367531,0.735063,
913 ], 10);
914
915 let r = lambda_ils_search(&a, &q, 3.0).unwrap();
916 assert_eq!(
917 r.fixed,
918 vec![
919 -13324188, -10668901, -7157236, -6149379, -7454143, -5969220, 8336726, 6186960,
920 -17549108, -13970171
921 ]
922 );
923 assert!((r.best_score - 1506.43578925).abs() < 1e-4);
924 assert!((r.second_best_score.unwrap() - 1612.81176533).abs() < 1e-4);
925 }
926
927 #[test]
928 fn lambda_matches_rtklib_near_tie_low_ratio() {
929 let a = [
934 2.381283532896866,
935 -4.153279079035503,
936 6.181180039414691,
937 -1.1716816183885634,
938 3.144312353800454,
939 ];
940 #[rustfmt::skip]
941 let q = full_matrix(&[
942 0.30250000000000005, 0.11549999999999999, 0.09625, 0.12512500000000001, 0.11165,
943 0.11549999999999999, 0.36, 0.105, 0.13649999999999998, 0.12179999999999998,
944 0.09625, 0.105, 0.25, 0.11374999999999999, 0.10149999999999999,
945 0.12512500000000001, 0.13649999999999998, 0.11374999999999999, 0.42250000000000004, 0.13194999999999998,
946 0.11165, 0.12179999999999998, 0.10149999999999999, 0.13194999999999998, 0.3364,
947 ], 5);
948
949 let r = lambda_ils_search(&a, &q, 3.0).unwrap();
950 assert_eq!(r.fixed, vec![2, -4, 6, -1, 3]);
951 assert!((r.best_score - 1.1061496957026506).abs() < 1e-4);
952 assert!((r.second_best_score.unwrap() - 2.2123104750064506).abs() < 1e-4);
953 assert!((r.ratio - 2.0000100199830024).abs() < 1e-6);
954 assert!(!r.fixed_status); }
956
957 #[test]
958 fn lambda_matches_rtklib_easy_near_diagonal() {
959 let a = [4.03, -2.97, 1.02, 5.98];
963 #[rustfmt::skip]
964 let q = full_matrix(&[
965 0.018, 0.002, 0.0, 0.0,
966 0.002, 0.025, 0.0, 0.0,
967 0.0, 0.0, 0.012, 0.0015,
968 0.0, 0.0, 0.0015, 0.03,
969 ], 4);
970
971 let r = lambda_ils_search(&a, &q, 3.0).unwrap();
972 assert_eq!(r.fixed, vec![4, -3, 1, 6]);
973 assert!((r.best_score - 0.12901401697831202).abs() < 1e-4);
974 assert!((r.second_best_score.unwrap() - 32.16255699391752).abs() < 1e-4);
975 assert!((r.ratio - 249.29505915100856).abs() < 1e-6);
976 assert!(r.fixed_status); }
978
979 #[test]
980 fn lambda_agrees_with_box_search_in_regime() {
981 let a = vec![0.30, -0.40, 1.20];
983 let q = vec![
984 vec![0.50, 0.10, 0.05],
985 vec![0.10, 0.50, 0.10],
986 vec![0.05, 0.10, 0.50],
987 ];
988 let lam = lambda_ils_search(&a, &q, 3.0).unwrap();
989 let box_ = bounded_ils_search(&a, &q, 1, 200_000, 3.0).unwrap();
990 assert_eq!(lam.fixed, box_.fixed);
991 assert!((lam.best_score - box_.best_score).abs() < 1e-9);
992 assert!((lam.second_best_score.unwrap() - box_.second_best_score.unwrap()).abs() < 1e-9);
993 }
994
995 #[test]
998 fn rejects_undersized_covariance() {
999 let a = vec![0.1, 0.2];
1001 let q = vec![vec![1.0]];
1002 assert_eq!(
1003 bounded_ils_search(&a, &q, 1, 200_000, 3.0),
1004 Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
1005 );
1006 assert_eq!(
1007 lambda_ils_search(&a, &q, 3.0),
1008 Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
1009 );
1010 }
1011
1012 #[test]
1013 fn rejects_oversized_covariance() {
1014 let a = vec![0.1];
1016 let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
1017 assert_eq!(
1018 bounded_ils_search(&a, &q, 1, 200_000, 3.0),
1019 Err(IlsError::InvalidDimensions { n: 1, rows: 2 })
1020 );
1021 assert_eq!(
1022 lambda_ils_search(&a, &q, 3.0),
1023 Err(IlsError::InvalidDimensions { n: 1, rows: 2 })
1024 );
1025 }
1026
1027 #[test]
1028 fn rejects_ragged_covariance() {
1029 let a = vec![0.1, 0.2];
1031 let q = vec![vec![1.0, 0.0], vec![0.0]];
1032 assert_eq!(
1033 bounded_ils_search(&a, &q, 1, 200_000, 3.0),
1034 Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
1035 );
1036 assert_eq!(
1037 lambda_ils_search(&a, &q, 3.0),
1038 Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
1039 );
1040 }
1041
1042 #[test]
1043 fn bounded_search_rejects_invalid_covariance_geometry() {
1044 let a = vec![0.1, 0.2];
1045 let expected = Err(IlsError::InvalidInput {
1046 field: "ils covariance",
1047 reason: "not positive",
1048 });
1049
1050 let negative_variance = vec![vec![-1.0, 0.0], vec![0.0, 1.0]];
1051 assert_eq!(
1052 bounded_ils_search(&a, &negative_variance, 1, 200_000, 3.0),
1053 expected
1054 );
1055
1056 let asymmetric = vec![vec![1.0, 0.5], vec![0.4, 1.0]];
1057 assert_eq!(
1058 bounded_ils_search(&a, &asymmetric, 1, 200_000, 3.0),
1059 expected
1060 );
1061
1062 let indefinite = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
1063 assert_eq!(
1064 bounded_ils_search(&a, &indefinite, 1, 200_000, 3.0),
1065 expected
1066 );
1067 }
1068
1069 #[test]
1070 fn lambda_search_rejects_invalid_covariance_geometry() {
1071 let a = vec![0.1, 0.2];
1072 let expected = Err(IlsError::InvalidInput {
1073 field: "ils covariance",
1074 reason: "not positive",
1075 });
1076
1077 let negative_variance = vec![vec![-1.0, 0.0], vec![0.0, 1.0]];
1078 assert_eq!(lambda_ils_search(&a, &negative_variance, 3.0), expected);
1079
1080 let asymmetric = vec![vec![1.0, 0.5], vec![0.4, 1.0]];
1081 assert_eq!(lambda_ils_search(&a, &asymmetric, 3.0), expected);
1082
1083 let indefinite = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
1084 assert_eq!(lambda_ils_search(&a, &indefinite, 3.0), expected);
1085 }
1086
1087 #[test]
1088 fn rejects_empty_input() {
1089 let a: Vec<f64> = vec![];
1090 let q: Vec<Vec<f64>> = vec![];
1091 assert_eq!(
1092 bounded_ils_search(&a, &q, 1, 200_000, 3.0),
1093 Err(IlsError::InvalidDimensions { n: 0, rows: 0 })
1094 );
1095 assert_eq!(
1096 lambda_ils_search(&a, &q, 3.0),
1097 Err(IlsError::InvalidDimensions { n: 0, rows: 0 })
1098 );
1099 }
1100
1101 #[test]
1102 fn rejects_non_finite_input() {
1103 let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
1104 assert_eq!(
1105 bounded_ils_search(&[f64::NAN, 0.2], &q, 1, 200_000, 3.0),
1106 Err(IlsError::NonFinite)
1107 );
1108 let q_inf = vec![vec![f64::INFINITY, 0.0], vec![0.0, 1.0]];
1109 assert_eq!(
1110 lambda_ils_search(&[0.1, 0.2], &q_inf, 3.0),
1111 Err(IlsError::NonFinite)
1112 );
1113 }
1114
1115 #[test]
1116 fn rejects_invalid_ratio_thresholds() {
1117 let a = vec![0.1, 0.2];
1118 let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
1119
1120 for (threshold, reason) in [
1121 (-1.0, "negative"),
1122 (f64::NAN, "not finite"),
1123 (f64::INFINITY, "not finite"),
1124 ] {
1125 let expected = Err(IlsError::InvalidInput {
1126 field: ILS_RATIO_THRESHOLD_FIELD,
1127 reason,
1128 });
1129 assert_eq!(bounded_ils_search(&a, &q, 1, 200_000, threshold), expected);
1130 assert_eq!(lambda_ils_search(&a, &q, threshold), expected);
1131 }
1132 }
1133
1134 #[test]
1135 fn exact_integer_fix_reports_finite_saturated_ratio() {
1136 let a = vec![1.0];
1137 let q = vec![vec![1.0]];
1138
1139 let bounded = bounded_ils_search(&a, &q, 1, 3, 3.0).unwrap();
1140 assert_eq!(bounded.best_score, 0.0);
1141 assert_eq!(bounded.second_best_score, Some(1.0));
1142 assert_eq!(bounded.ratio, f64::MAX);
1143 assert!(bounded.ratio.is_finite());
1144 assert!(bounded.fixed_status);
1145
1146 let lambda = lambda_ils_search(&a, &q, 3.0).unwrap();
1147 assert_eq!(lambda.best_score, 0.0);
1148 assert_eq!(lambda.second_best_score, Some(1.0));
1149 assert_eq!(lambda.ratio, f64::MAX);
1150 assert!(lambda.ratio.is_finite());
1151 assert!(lambda.fixed_status);
1152 }
1153
1154 #[test]
1155 fn valid_ratio_threshold_still_controls_fix_status() {
1156 let a = vec![3.02, -1.98, 5.01];
1157 let q = vec![
1158 vec![0.01, 0.0, 0.0],
1159 vec![0.0, 0.01, 0.0],
1160 vec![0.0, 0.0, 0.01],
1161 ];
1162
1163 let bounded_fixed = bounded_ils_search(&a, &q, 1, 200_000, 3.0).unwrap();
1164 let bounded_held = bounded_ils_search(&a, &q, 1, 200_000, 1.0e12).unwrap();
1165 assert!(bounded_fixed.fixed_status);
1166 assert!(!bounded_held.fixed_status);
1167 assert_eq!(bounded_fixed.fixed, bounded_held.fixed);
1168
1169 let lambda_fixed = lambda_ils_search(&a, &q, 3.0).unwrap();
1170 let lambda_held = lambda_ils_search(&a, &q, 1.0e12).unwrap();
1171 assert!(lambda_fixed.fixed_status);
1172 assert!(!lambda_held.fixed_status);
1173 assert_eq!(lambda_fixed.fixed, lambda_held.fixed);
1174 }
1175}