Skip to main content

sidereon_core/
ils.rs

1//! Integer least squares - ambiguity-resolution kernels for precise / RTK
2//! positioning.
3//!
4//! The bounded kernel preserves the historical public score/order contract:
5//! Gaussian elimination with partial pivoting (max-abs pivot, first-index tie
6//! break, `<= PIVOT_EPSILON` singular guard), `Δᵀ Q⁻¹ Δ` summation order (i-outer,
7//! j-inner, left-associated products), lattice enumeration, and `{score, cycles}`
8//! candidate ordering. The LAMBDA kernel is a faithful RTKLIB `lambda()` port.
9//! Crate-side tests pin the RTKLIB oracle fixture plus frozen output bits. All
10//! arithmetic is plain `*` / `-` / `+` (no FMA), per the crate's reproducibility
11//! rule.
12//!
13//! These kernels live in Rust because the bounded search, LAMBDA, and the
14//! partial-ambiguity subset search built on top of them are compute hot paths for
15//! multi-epoch RTK arcs.
16
17use 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/// Why a bounded ILS search could not produce a result. Mapped by the NIF onto
26/// the reference Elixir error tuples (`:singular_geometry`,
27/// `{:no_integer_candidates, n}`, `{:too_many_integer_candidates, n, limit}`).
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum IlsError {
30    /// The covariance matrix is singular (degenerate geometry).
31    Singular,
32    /// The lattice yielded no candidate (an empty search box).
33    NoCandidates(usize),
34    /// The lattice exceeded `candidate_limit`.
35    TooManyCandidates { evaluated: usize, limit: usize },
36    /// `float_cycles` was empty, or `covariance` was not exactly `n x n` for
37    /// `n = float_cycles.len()` (`rows` is the offending row count, or the length
38    /// of the first row that was not `n` wide).
39    InvalidDimensions { n: usize, rows: usize },
40    /// A `float_cycles` or `covariance` entry was NaN or infinite.
41    NonFinite,
42    /// A public ILS option was malformed.
43    InvalidInput {
44        field: &'static str,
45        reason: &'static str,
46    },
47    /// The MLAMBDA search did not converge within `LAMBDA_LOOP_MAX` iterations
48    /// (distinct from a singular/degenerate covariance).
49    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
81/// Validate inputs before any indexing or arithmetic: `covariance` must be a
82/// square `n x n` matrix matching the number of float ambiguities (`n >= 1`),
83/// and every value must be finite. Without the shape check an undersized
84/// covariance indexes out of bounds (panic) and an oversized one is silently
85/// truncated to a wrong-dimension submatrix; without the finite check NaN/Inf
86/// propagate into a garbage "fix".
87fn 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/// Outcome of a bounded ILS search.
133#[derive(Debug, Clone, PartialEq)]
134pub struct IlsResult {
135    /// Best integer vector, parallel to the input `float_cycles`.
136    pub fixed: Vec<i64>,
137    /// Whether the ratio test passes at the requested threshold.
138    pub fixed_status: bool,
139    /// Runner-up / best score ratio. Saturates to `f64::MAX` when the best score
140    /// is exactly zero with a positive runner-up; `0.0` when there is no runner-up.
141    pub ratio: f64,
142    /// Best (lowest) quadratic score `Δᵀ Q⁻¹ Δ`.
143    pub best_score: f64,
144    /// Runner-up score, if a second lattice point exists.
145    pub second_best_score: Option<f64>,
146    /// Count of candidates considered. Its meaning depends on the search:
147    /// [`bounded_ils_search`] reports the number of lattice points actually
148    /// evaluated inside the box; the LAMBDA search ([`lambda_ils_search`]) does
149    /// not enumerate a box, so it reports the number of candidate vectors produced
150    /// (the requested `ncands`, typically 2), not a lattice-point count.
151    pub candidates_evaluated: usize,
152    /// Symmetrized covariance actually used.
153    pub covariance: Vec<Vec<f64>>,
154    /// Symmetrized inverse covariance.
155    pub covariance_inverse: Vec<Vec<f64>>,
156}
157
158/// Bounded integer least squares over the lattice within `radius` integers of
159/// each rounded float ambiguity.
160///
161/// Returns the best integer vector and its ratio-test verdict, or an error when
162/// the covariance is singular or the lattice exceeds `candidate_limit`.
163pub 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    // Per-ambiguity candidate integers, ordered by |value - float| then value
178    // (matches `integers_near/3`; the final top-two is order-independent, but we
179    // mirror the reference exactly).
180    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
216// --- lattice enumeration -------------------------------------------------
217
218/// Immutable inputs shared across the recursive lattice walk: the per-ambiguity
219/// candidate ranges, the float cycles and inverse covariance scoring the leaves,
220/// and the candidate-count cap. Bundled so the recursion threads one context
221/// instead of repeating four positional arguments at every call.
222struct 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
257/// Keep the two lowest `(score, cycles)` candidates - same ordering as the
258/// reference `integer_top_two/1` (score ascending, then cycles lexicographic).
259fn 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    // delta = float - fixed, matching `a - z`.
272    let deltas: Vec<f64> = (0..n).map(|i| float_cycles[i] - fixed[i] as f64).collect();
273
274    // i-outer, j-inner, acc + delta[i] * q_inv[i][j] * delta[j] (left-assoc).
275    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(); // Elixir round/1: half away from zero
305    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
388// --- linear algebra (bit-identical to LinearAlgebra) ---------------------
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
397/// Invert by solving `A x = eᵢ` for each unit column, exactly as the reference
398/// `invert_matrix/1`. `pub(crate)` so the RTK-filter kernel can invert the
399/// posterior information into covariance for the ambiguity search.
400pub(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
404// Index-based loops mirror the reference Gaussian elimination (pivot scan and
405// row updates index `rows` by position); an iterator form would obscure it.
406// `pub(crate)` so the RTK-filter kernel reuses the same Gaussian elimination
407// (partial pivoting, PIVOT_EPSILON singular guard) for its normal-equations solve.
408pub(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
412// =========================================================================
413// LAMBDA / MLAMBDA integer least squares (Teunissen 1995; Chang-Yang-Zhou 2005)
414// -------------------------------------------------------------------------
415// A faithful port of RTKLIB's `lambda()` (BSD-2, _tools/RTKLIB/src/lambda.c):
416// LtDL factorization + integer-Gauss/permutation decorrelation reduction +
417// modified-LAMBDA depth-first search. Unlike `bounded_ils_search` (a naive
418// ±radius box that only finds the true ILS optimum when it lies within the box),
419// this is a *correct* ILS solver for any positive-definite covariance - it is
420// gated against RTKLIB's own committed reference vectors (incl. the strongly-
421// correlated utest2 the box search cannot reach). Validation target is RTKLIB,
422// not bit-identity; the algorithm differs, so agreement is to round-off.
423//
424// Matrices follow RTKLIB's COLUMN-MAJOR convention verbatim - element (row i,
425// col j) of an n×n matrix is `flat[i + j*n]` - so the port reads line-for-line
426// against lambda.c.
427
428const LAMBDA_LOOP_MAX: usize = 10000;
429
430#[inline]
431fn lam_round(x: f64) -> f64 {
432    (x + 0.5).floor() // RTKLIB ROUND(x) = floor(x+0.5)
433}
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
444/// LtDL factorization `Q = Lᵀ·diag(D)·L` (column-major). Returns `None` if Q is
445/// not positive-definite (a pivot `D[i] <= 0`).
446fn 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
471/// Integer Gauss transformation on column `j` using column `i`.
472fn 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
485/// Permutation of adjacent ambiguities `j` and `j+1`.
486fn 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
506/// LAMBDA reduction: decorrelate via integer Gauss transformations + adjacent
507/// permutations, accumulating the unimodular transform `Z`.
508fn 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
529/// Modified-LAMBDA (mlambda) search for the `m` best integer vectors in the
530/// decorrelated space. Returns `(zn, s)` where `zn` is `n*m` column-major
531/// candidates and `s[k]` is the squared residual of candidate `k` (sorted
532/// ascending). `None` on search-loop overflow.
533fn 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    // Sort the m candidates by ascending residual (RTKLIB's selection sort).
623    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
637/// Correct integer-least-squares via the LAMBDA method (RTKLIB `lambda()` port).
638///
639/// Finds the true ILS optimum and runner-up for any positive-definite
640/// covariance - no search box, no combinatorial blow-up. Returns the same
641/// [`IlsResult`] shape as [`bounded_ils_search`] so it is a drop-in: in the
642/// weakly-correlated regime both select the identical integer vector and ratio;
643/// on strongly-correlated geometry only this one is correct.
644pub 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    // Inverse is kept only for the diagnostic metadata (LAMBDA itself uses LtDL).
655    let q_inv = symmetrize(&invert(&q).map_err(|_| IlsError::Singular)?);
656
657    // Column-major copy of the symmetrized covariance for the RTKLIB port.
658    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        // Z = identity (column-major).
668        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    // zs = Zᵀ·a.
677    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    // Best + runner-up, for the ratio test. `lam_ld` already failed
687    // `Singular` above; a `None` here is search-loop overflow.
688    let m = 2usize;
689    let (zn, _s) = lam_search(n, m, &l, &d, &zs).ok_or(IlsError::SearchLimitExceeded)?;
690
691    // Back-transform each decorrelated candidate: F = (Zᵀ)⁻¹·E (RTKLIB solve("T",Z,E)).
692    // Z is unimodular, so the result is integer up to round-off.
693    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]; // (Zᵀ)[i][j] = Z[j][i]
697        }
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    // LAMBDA's mlambda distance `s` is computed in the decorrelated LtDL space; to
707    // keep the reported scores consistent with `bounded_ils_search` (and bit-exact
708    // against the explicit `Δᵀ Q⁻¹ Δ` reference / numpy goldens), recompute each
709    // candidate's score with the same quadratic form and order them the same way
710    // (score ascending, then cycles lexicographic). LAMBDA's only job here is to
711    // FIND the candidate set; scoring/ratio use the canonical formula.
712    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        // LAMBDA does not enumerate a box; report the number of candidate vectors.
734        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        // [[0.6, -0.7], [-0.2, 0.4]]
749        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        // Float ambiguities very close to integers, tight diagonal covariance:
764        // the nearest lattice point dominates and the ratio test passes.
765        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); // 3^3
776    }
777
778    #[test]
779    fn refuses_an_ambiguous_lattice() {
780        // Half-integer floats: nearest points are equidistant -> low ratio.
781        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        // 3^3 = 27 lattice points, limit 10 -> error.
797        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    // --- LAMBDA port vs RTKLIB's own committed reference vectors ----------
847    // (t_lambda.c utest1/utest2; see parity/generator/lambda_ref). RTKLIB's
848    // unit test tolerates 1e-4 on the residuals; we hold the same.
849
850    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        // The case the bounded box search cannot solve: the ILS optimum is up
888        // to 14 cycles from componentwise rounding. LAMBDA gets it exactly.
889        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        // Near-tie regime: the two best candidates are close, so RTKLIB's ratio
930        // s[1]/s[0] sits at ~2.0 - squarely in the typical 1.5-3 acceptance band
931        // and below our 3.0 threshold, so the fix is NOT accepted. Exercises the
932        // ratio test rather than the integer selection.
933        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); // ratio < 3.0
955    }
956
957    #[test]
958    fn lambda_matches_rtklib_easy_near_diagonal() {
959        // Well-conditioned anchor: near-diagonal Q with float ambiguities very
960        // close to integers. The ratio is huge (~249), the fix is accepted, and
961        // LAMBDA and RTKLIB agree exactly.
962        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); // ratio >> 3.0
977    }
978
979    #[test]
980    fn lambda_agrees_with_box_search_in_regime() {
981        // Weakly-correlated, ILS optimum near rounding: both kernels must agree.
982        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    // --- input validation (both kernels reject malformed inputs cleanly) -----
996
997    #[test]
998    fn rejects_undersized_covariance() {
999        // 2 ambiguities, 1x1 covariance - would index out of bounds without the guard.
1000        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        // 1 ambiguity, 2x2 covariance - would silently use a submatrix.
1015        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        // Square row count but a row of the wrong width.
1030        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}