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    /// Number of lattice points evaluated.
147    pub candidates_evaluated: usize,
148    /// Symmetrized covariance actually used.
149    pub covariance: Vec<Vec<f64>>,
150    /// Symmetrized inverse covariance.
151    pub covariance_inverse: Vec<Vec<f64>>,
152}
153
154/// Bounded integer least squares over the lattice within `radius` integers of
155/// each rounded float ambiguity.
156///
157/// Returns the best integer vector and its ratio-test verdict, or an error when
158/// the covariance is singular or the lattice exceeds `candidate_limit`.
159pub fn bounded_ils_search(
160    float_cycles: &[f64],
161    covariance: &[Vec<f64>],
162    radius: i64,
163    candidate_limit: usize,
164    ratio_threshold: f64,
165) -> core::result::Result<IlsResult, IlsError> {
166    validate_inputs(float_cycles, covariance)?;
167    validate_covariance_geometry(covariance)?;
168    validate_ratio_threshold(ratio_threshold)?;
169    let q = symmetrize(covariance);
170    let q_inv = symmetrize(&invert(&q).map_err(|_| IlsError::Singular)?);
171    ensure_candidate_limit(float_cycles.len(), radius, candidate_limit)?;
172
173    // Per-ambiguity candidate integers, ordered by |value - float| then value
174    // (matches `integers_near/3`; the final top-two is order-independent, but we
175    // mirror the reference exactly).
176    let ranges: Vec<Vec<i64>> = float_cycles
177        .iter()
178        .map(|&f| bounded_integer_candidates(f, radius))
179        .collect::<core::result::Result<_, _>>()?;
180
181    let mut top: Vec<(f64, Vec<i64>)> = Vec::with_capacity(2);
182    let mut evaluated: usize = 0;
183    let mut current: Vec<i64> = Vec::with_capacity(float_cycles.len());
184
185    let ctx = LatticeEnum {
186        ranges: &ranges,
187        float_cycles,
188        q_inv: &q_inv,
189        limit: candidate_limit,
190    };
191    enumerate(&ctx, 0, &mut current, &mut evaluated, &mut top)?;
192
193    let (best_score, fixed) = match top.first() {
194        Some((s, c)) => (*s, c.clone()),
195        None => return Err(IlsError::NoCandidates(evaluated)),
196    };
197    let second_best_score = top.get(1).map(|(s, _)| *s);
198    let ratio = integer_ratio(best_score, second_best_score);
199
200    Ok(IlsResult {
201        fixed,
202        fixed_status: ratio_pass(ratio, ratio_threshold),
203        ratio,
204        best_score,
205        second_best_score,
206        candidates_evaluated: evaluated,
207        covariance: q,
208        covariance_inverse: q_inv,
209    })
210}
211
212// --- lattice enumeration -------------------------------------------------
213
214/// Immutable inputs shared across the recursive lattice walk: the per-ambiguity
215/// candidate ranges, the float cycles and inverse covariance scoring the leaves,
216/// and the candidate-count cap. Bundled so the recursion threads one context
217/// instead of repeating four positional arguments at every call.
218struct LatticeEnum<'a> {
219    ranges: &'a [Vec<i64>],
220    float_cycles: &'a [f64],
221    q_inv: &'a [Vec<f64>],
222    limit: usize,
223}
224
225fn enumerate(
226    ctx: &LatticeEnum,
227    depth: usize,
228    current: &mut Vec<i64>,
229    evaluated: &mut usize,
230    top: &mut Vec<(f64, Vec<i64>)>,
231) -> core::result::Result<(), IlsError> {
232    if depth == ctx.ranges.len() {
233        *evaluated += 1;
234        if *evaluated > ctx.limit {
235            return Err(IlsError::TooManyCandidates {
236                evaluated: *evaluated,
237                limit: ctx.limit,
238            });
239        }
240        let score = quadratic_score(ctx.float_cycles, current, ctx.q_inv);
241        insert_top_two(top, score, current);
242        return Ok(());
243    }
244
245    for &value in &ctx.ranges[depth] {
246        current.push(value);
247        enumerate(ctx, depth + 1, current, evaluated, top)?;
248        current.pop();
249    }
250    Ok(())
251}
252
253/// Keep the two lowest `(score, cycles)` candidates - same ordering as the
254/// reference `integer_top_two/1` (score ascending, then cycles lexicographic).
255fn insert_top_two(top: &mut Vec<(f64, Vec<i64>)>, score: f64, cycles: &[i64]) {
256    top.push((score, cycles.to_vec()));
257    top.sort_by(|(sa, ca), (sb, cb)| {
258        sa.partial_cmp(sb)
259            .unwrap_or(core::cmp::Ordering::Equal)
260            .then_with(|| ca.cmp(cb))
261    });
262    top.truncate(2);
263}
264
265fn quadratic_score(float_cycles: &[f64], fixed: &[i64], q_inv: &[Vec<f64>]) -> f64 {
266    let n = float_cycles.len();
267    // delta = float - fixed, matching `a - z`.
268    let deltas: Vec<f64> = (0..n).map(|i| float_cycles[i] - fixed[i] as f64).collect();
269
270    // i-outer, j-inner, acc + delta[i] * q_inv[i][j] * delta[j] (left-assoc).
271    let mut acc = 0.0;
272    for i in 0..n {
273        for j in 0..n {
274            acc += deltas[i] * q_inv[i][j] * deltas[j];
275        }
276    }
277    acc
278}
279
280fn integers_near(center: f64, low: i64, high: i64) -> Vec<i64> {
281    let mut values: Vec<i64> = (low..=high).collect();
282    values.sort_by(|&a, &b| {
283        let da = (a as f64 - center).abs();
284        let db = (b as f64 - center).abs();
285        da.partial_cmp(&db)
286            .unwrap_or(core::cmp::Ordering::Equal)
287            .then_with(|| a.cmp(&b))
288    });
289    values
290}
291
292fn bounded_integer_candidates(
293    float_cycle: f64,
294    radius: i64,
295) -> core::result::Result<Vec<i64>, IlsError> {
296    if radius < 0 {
297        return Ok(Vec::new());
298    }
299
300    let rounded = float_cycle.round(); // Elixir round/1: half away from zero
301    const I64_MAX_EXCLUSIVE: f64 = 9_223_372_036_854_775_808.0;
302    if rounded < i64::MIN as f64 || rounded >= I64_MAX_EXCLUSIVE {
303        return Err(IlsError::InvalidInput {
304            field: "ils float_cycles",
305            reason: "outside integer search range",
306        });
307    }
308
309    let center_i64 = rounded as i64;
310    let low = center_i64
311        .checked_sub(radius)
312        .ok_or(IlsError::InvalidInput {
313            field: "ils float_cycles",
314            reason: "outside integer search range",
315        })?;
316    let high = center_i64
317        .checked_add(radius)
318        .ok_or(IlsError::InvalidInput {
319            field: "ils float_cycles",
320            reason: "outside integer search range",
321        })?;
322    Ok(integers_near(float_cycle, low, high))
323}
324
325fn ensure_candidate_limit(
326    dimensions: usize,
327    radius: i64,
328    limit: usize,
329) -> core::result::Result<(), IlsError> {
330    let per_dimension = if radius < 0 {
331        0usize
332    } else {
333        let width = radius
334            .checked_mul(2)
335            .and_then(|width| width.checked_add(1))
336            .ok_or(IlsError::TooManyCandidates {
337                evaluated: usize::MAX,
338                limit,
339            })?;
340        usize::try_from(width).map_err(|_| IlsError::TooManyCandidates {
341            evaluated: usize::MAX,
342            limit,
343        })?
344    };
345
346    let mut candidates = 1usize;
347    for _ in 0..dimensions {
348        candidates = candidates
349            .checked_mul(per_dimension)
350            .ok_or(IlsError::TooManyCandidates {
351                evaluated: usize::MAX,
352                limit,
353            })?;
354        if candidates > limit {
355            return Err(IlsError::TooManyCandidates {
356                evaluated: candidates,
357                limit,
358            });
359        }
360    }
361
362    Ok(())
363}
364
365fn integer_ratio(best_score: f64, second_best_score: Option<f64>) -> f64 {
366    match second_best_score {
367        None => 0.0,
368        Some(second) => {
369            if best_score == 0.0 && second > 0.0 {
370                f64::MAX
371            } else if best_score == 0.0 {
372                0.0
373            } else {
374                second / best_score
375            }
376        }
377    }
378}
379
380fn ratio_pass(ratio: f64, threshold: f64) -> bool {
381    ratio >= threshold
382}
383
384// --- linear algebra (bit-identical to LinearAlgebra) ---------------------
385
386fn symmetrize(m: &[Vec<f64>]) -> Vec<Vec<f64>> {
387    let n = m.len();
388    (0..n)
389        .map(|i| (0..n).map(|j| (m[i][j] + m[j][i]) / 2.0).collect())
390        .collect()
391}
392
393/// Invert by solving `A x = eᵢ` for each unit column, exactly as the reference
394/// `invert_matrix/1`. `pub(crate)` so the RTK-filter kernel can invert the
395/// posterior information into covariance for the ambiguity search.
396pub(crate) fn invert(a: &[Vec<f64>]) -> Result<Vec<Vec<f64>>> {
397    invert_matrix_first_tie(a).ok_or_else(|| Error::InvalidInput("singular matrix".into()))
398}
399
400// Index-based loops mirror the reference Gaussian elimination (pivot scan and
401// row updates index `rows` by position); an iterator form would obscure it.
402// `pub(crate)` so the RTK-filter kernel reuses the same Gaussian elimination
403// (partial pivoting, PIVOT_EPSILON singular guard) for its normal-equations solve.
404pub(crate) fn solve_linear(a: &[Vec<f64>], b: &[f64]) -> Result<Vec<f64>> {
405    solve_linear_first_tie(a, b).ok_or_else(|| Error::InvalidInput("singular matrix".into()))
406}
407
408// =========================================================================
409// LAMBDA / MLAMBDA integer least squares (Teunissen 1995; Chang-Yang-Zhou 2005)
410// -------------------------------------------------------------------------
411// A faithful port of RTKLIB's `lambda()` (BSD-2, _tools/RTKLIB/src/lambda.c):
412// LtDL factorization + integer-Gauss/permutation decorrelation reduction +
413// modified-LAMBDA depth-first search. Unlike `bounded_ils_search` (a naive
414// ±radius box that only finds the true ILS optimum when it lies within the box),
415// this is a *correct* ILS solver for any positive-definite covariance - it is
416// gated against RTKLIB's own committed reference vectors (incl. the strongly-
417// correlated utest2 the box search cannot reach). Validation target is RTKLIB,
418// not bit-identity; the algorithm differs, so agreement is to round-off.
419//
420// Matrices follow RTKLIB's COLUMN-MAJOR convention verbatim - element (row i,
421// col j) of an n×n matrix is `flat[i + j*n]` - so the port reads line-for-line
422// against lambda.c.
423
424const LAMBDA_LOOP_MAX: usize = 10000;
425
426#[inline]
427fn lam_round(x: f64) -> f64 {
428    (x + 0.5).floor() // RTKLIB ROUND(x) = floor(x+0.5)
429}
430
431#[inline]
432fn lam_sgn(x: f64) -> f64 {
433    if x <= 0.0 {
434        -1.0
435    } else {
436        1.0
437    }
438}
439
440/// LtDL factorization `Q = Lᵀ·diag(D)·L` (column-major). Returns `None` if Q is
441/// not positive-definite (a pivot `D[i] <= 0`).
442fn lam_ld(n: usize, q: &[f64]) -> Option<(Vec<f64>, Vec<f64>)> {
443    let mut a = q.to_vec();
444    let mut l = vec![0.0f64; n * n];
445    let mut d = vec![0.0f64; n];
446    for i in (0..n).rev() {
447        d[i] = a[i + i * n];
448        if d[i] <= 0.0 {
449            return None;
450        }
451        let ai = d[i].sqrt();
452        for j in 0..=i {
453            l[i + j * n] = a[i + j * n] / ai;
454        }
455        for j in 0..i {
456            for k in 0..=j {
457                a[j + k * n] -= l[i + k * n] * l[i + j * n];
458            }
459        }
460        for j in 0..=i {
461            l[i + j * n] /= l[i + i * n];
462        }
463    }
464    Some((l, d))
465}
466
467/// Integer Gauss transformation on column `j` using column `i`.
468fn lam_gauss(n: usize, l: &mut [f64], z: &mut [f64], i: usize, j: usize) {
469    let mu = lam_round(l[i + j * n]) as i64;
470    if mu != 0 {
471        let muf = mu as f64;
472        for k in i..n {
473            l[k + j * n] -= muf * l[k + i * n];
474        }
475        for k in 0..n {
476            z[k + j * n] -= muf * z[k + i * n];
477        }
478    }
479}
480
481/// Permutation of adjacent ambiguities `j` and `j+1`.
482fn lam_perm(n: usize, l: &mut [f64], d: &mut [f64], j: usize, del: f64, z: &mut [f64]) {
483    let eta = d[j] / del;
484    let lam = d[j + 1] * l[j + 1 + j * n] / del;
485    d[j] = eta * d[j + 1];
486    d[j + 1] = del;
487    for k in 0..j {
488        let a0 = l[j + k * n];
489        let a1 = l[j + 1 + k * n];
490        l[j + k * n] = -l[j + 1 + j * n] * a0 + a1;
491        l[j + 1 + k * n] = eta * a0 + lam * a1;
492    }
493    l[j + 1 + j * n] = lam;
494    for k in (j + 2)..n {
495        l.swap(k + j * n, k + (j + 1) * n);
496    }
497    for k in 0..n {
498        z.swap(k + j * n, k + (j + 1) * n);
499    }
500}
501
502/// LAMBDA reduction: decorrelate via integer Gauss transformations + adjacent
503/// permutations, accumulating the unimodular transform `Z`.
504fn lam_reduction(n: usize, l: &mut [f64], d: &mut [f64], z: &mut [f64]) {
505    let mut j: isize = n as isize - 2;
506    let mut k: isize = n as isize - 2;
507    while j >= 0 {
508        let ju = j as usize;
509        if j <= k {
510            for i in (ju + 1)..n {
511                lam_gauss(n, l, z, i, ju);
512            }
513        }
514        let del = d[ju] + l[ju + 1 + ju * n] * l[ju + 1 + ju * n] * d[ju + 1];
515        if del + LAMBDA_REDUCTION_EPS < d[ju + 1] {
516            lam_perm(n, l, d, ju, del, z);
517            k = j;
518            j = n as isize - 2;
519        } else {
520            j -= 1;
521        }
522    }
523}
524
525/// Modified-LAMBDA (mlambda) search for the `m` best integer vectors in the
526/// decorrelated space. Returns `(zn, s)` where `zn` is `n*m` column-major
527/// candidates and `s[k]` is the squared residual of candidate `k` (sorted
528/// ascending). `None` on search-loop overflow.
529fn lam_search(
530    n: usize,
531    m: usize,
532    l: &[f64],
533    d: &[f64],
534    zs: &[f64],
535) -> Option<(Vec<f64>, Vec<f64>)> {
536    let mut s = vec![0.0f64; m];
537    let mut zn = vec![0.0f64; n * m];
538    let mut smat = vec![0.0f64; n * n];
539    let mut dist = vec![0.0f64; n];
540    let mut zb = vec![0.0f64; n];
541    let mut z = vec![0.0f64; n];
542    let mut step = vec![0.0f64; n];
543
544    let mut nn: usize = 0;
545    let mut imax: usize = 0;
546    let mut maxdist = 1.0e99;
547
548    let mut k: isize = n as isize - 1;
549    let ku = k as usize;
550    dist[ku] = 0.0;
551    zb[ku] = zs[ku];
552    z[ku] = lam_round(zb[ku]);
553    let mut y = zb[ku] - z[ku];
554    step[ku] = lam_sgn(y);
555
556    let mut c = 0usize;
557    while c < LAMBDA_LOOP_MAX {
558        let kk = k as usize;
559        let newdist = dist[kk] + y * y / d[kk];
560        if newdist < maxdist {
561            if k != 0 {
562                k -= 1;
563                let kk = k as usize;
564                dist[kk] = newdist;
565                for i in 0..=kk {
566                    smat[kk + i * n] =
567                        smat[kk + 1 + i * n] + (z[kk + 1] - zb[kk + 1]) * l[kk + 1 + i * n];
568                }
569                zb[kk] = zs[kk] + smat[kk + kk * n];
570                z[kk] = lam_round(zb[kk]);
571                y = zb[kk] - z[kk];
572                step[kk] = lam_sgn(y);
573            } else {
574                if nn < m {
575                    if nn == 0 || newdist > s[imax] {
576                        imax = nn;
577                    }
578                    for i in 0..n {
579                        zn[i + nn * n] = z[i];
580                    }
581                    s[nn] = newdist;
582                    nn += 1;
583                } else {
584                    if newdist < s[imax] {
585                        for i in 0..n {
586                            zn[i + imax * n] = z[i];
587                        }
588                        s[imax] = newdist;
589                        imax = 0;
590                        for i in 0..m {
591                            if s[imax] < s[i] {
592                                imax = i;
593                            }
594                        }
595                    }
596                    maxdist = s[imax];
597                }
598                z[0] += step[0];
599                y = zb[0] - z[0];
600                step[0] = -step[0] - lam_sgn(step[0]);
601            }
602        } else if k == n as isize - 1 {
603            break;
604        } else {
605            k += 1;
606            let kk = k as usize;
607            z[kk] += step[kk];
608            y = zb[kk] - z[kk];
609            step[kk] = -step[kk] - lam_sgn(step[kk]);
610        }
611        c += 1;
612    }
613
614    if c >= LAMBDA_LOOP_MAX {
615        return None;
616    }
617
618    // Sort the m candidates by ascending residual (RTKLIB's selection sort).
619    for i in 0..m.saturating_sub(1) {
620        for j in (i + 1)..m {
621            if s[i] < s[j] {
622                continue;
623            }
624            s.swap(i, j);
625            for k in 0..n {
626                zn.swap(k + i * n, k + j * n);
627            }
628        }
629    }
630    Some((zn, s))
631}
632
633/// Correct integer-least-squares via the LAMBDA method (RTKLIB `lambda()` port).
634///
635/// Finds the true ILS optimum and runner-up for any positive-definite
636/// covariance - no search box, no combinatorial blow-up. Returns the same
637/// [`IlsResult`] shape as [`bounded_ils_search`] so it is a drop-in: in the
638/// weakly-correlated regime both select the identical integer vector and ratio;
639/// on strongly-correlated geometry only this one is correct.
640pub fn lambda_ils_search(
641    float_cycles: &[f64],
642    covariance: &[Vec<f64>],
643    ratio_threshold: f64,
644) -> core::result::Result<IlsResult, IlsError> {
645    validate_inputs(float_cycles, covariance)?;
646    validate_covariance_geometry(covariance)?;
647    validate_ratio_threshold(ratio_threshold)?;
648    let n = float_cycles.len();
649    let q = symmetrize(covariance);
650    // Inverse is kept only for the diagnostic metadata (LAMBDA itself uses LtDL).
651    let q_inv = symmetrize(&invert(&q).map_err(|_| IlsError::Singular)?);
652
653    // Column-major copy of the symmetrized covariance for the RTKLIB port.
654    let mut q_cm = vec![0.0f64; n * n];
655    for i in 0..n {
656        for j in 0..n {
657            q_cm[i + j * n] = q[i][j];
658        }
659    }
660
661    let (mut l, mut d) = lam_ld(n, &q_cm).ok_or(IlsError::Singular)?;
662    let mut z = {
663        // Z = identity (column-major).
664        let mut e = vec![0.0f64; n * n];
665        for i in 0..n {
666            e[i + i * n] = 1.0;
667        }
668        e
669    };
670    lam_reduction(n, &mut l, &mut d, &mut z);
671
672    // zs = Zᵀ·a.
673    let mut zs = vec![0.0f64; n];
674    for i in 0..n {
675        let mut acc = 0.0;
676        for k in 0..n {
677            acc += z[k + i * n] * float_cycles[k];
678        }
679        zs[i] = acc;
680    }
681
682    // Best + runner-up, for the ratio test. `lam_ld` already failed
683    // `Singular` above; a `None` here is search-loop overflow.
684    let m = 2usize;
685    let (zn, _s) = lam_search(n, m, &l, &d, &zs).ok_or(IlsError::SearchLimitExceeded)?;
686
687    // Back-transform each decorrelated candidate: F = (Zᵀ)⁻¹·E (RTKLIB solve("T",Z,E)).
688    // Z is unimodular, so the result is integer up to round-off.
689    let mut zt = vec![vec![0.0f64; n]; n];
690    for i in 0..n {
691        for j in 0..n {
692            zt[i][j] = z[j + i * n]; // (Zᵀ)[i][j] = Z[j][i]
693        }
694    }
695    let mut fixed_candidates: Vec<Vec<i64>> = Vec::with_capacity(m);
696    for col in 0..m {
697        let b: Vec<f64> = (0..n).map(|i| zn[i + col * n]).collect();
698        let x = solve_linear(&zt, &b).map_err(|_| IlsError::Singular)?;
699        fixed_candidates.push(x.iter().map(|&v| lam_round(v) as i64).collect());
700    }
701
702    // LAMBDA's mlambda distance `s` is computed in the decorrelated LtDL space; to
703    // keep the reported scores consistent with `bounded_ils_search` (and bit-exact
704    // against the explicit `Δᵀ Q⁻¹ Δ` reference / numpy goldens), recompute each
705    // candidate's score with the same quadratic form and order them the same way
706    // (score ascending, then cycles lexicographic). LAMBDA's only job here is to
707    // FIND the candidate set; scoring/ratio use the canonical formula.
708    let mut scored: Vec<(f64, Vec<i64>)> = fixed_candidates
709        .into_iter()
710        .map(|c| (quadratic_score(float_cycles, &c, &q_inv), c))
711        .collect();
712    scored.sort_by(|(sa, ca), (sb, cb)| {
713        sa.partial_cmp(sb)
714            .unwrap_or(core::cmp::Ordering::Equal)
715            .then_with(|| ca.cmp(cb))
716    });
717
718    let best_score = scored[0].0;
719    let fixed = scored[0].1.clone();
720    let second_best_score = scored.get(1).map(|(s, _)| *s);
721    let ratio = integer_ratio(best_score, second_best_score);
722
723    Ok(IlsResult {
724        fixed,
725        fixed_status: ratio_pass(ratio, ratio_threshold),
726        ratio,
727        best_score,
728        second_best_score,
729        // LAMBDA does not enumerate a box; report the number of candidate vectors.
730        candidates_evaluated: m,
731        covariance: q,
732        covariance_inverse: q_inv,
733    })
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739
740    #[test]
741    fn inverts_a_known_matrix() {
742        let a = vec![vec![4.0, 7.0], vec![2.0, 6.0]];
743        let inv = invert(&a).unwrap();
744        // [[0.6, -0.7], [-0.2, 0.4]]
745        assert!((inv[0][0] - 0.6).abs() < 1e-12);
746        assert!((inv[0][1] + 0.7).abs() < 1e-12);
747        assert!((inv[1][0] + 0.2).abs() < 1e-12);
748        assert!((inv[1][1] - 0.4).abs() < 1e-12);
749    }
750
751    #[test]
752    fn rejects_a_singular_matrix() {
753        let a = vec![vec![1.0, 2.0], vec![2.0, 4.0]];
754        assert!(invert(&a).is_err());
755    }
756
757    #[test]
758    fn fixes_a_well_separated_lattice_point() {
759        // Float ambiguities very close to integers, tight diagonal covariance:
760        // the nearest lattice point dominates and the ratio test passes.
761        let float = vec![3.02, -1.98, 5.01];
762        let cov = vec![
763            vec![0.01, 0.0, 0.0],
764            vec![0.0, 0.01, 0.0],
765            vec![0.0, 0.0, 0.01],
766        ];
767        let r = bounded_ils_search(&float, &cov, 1, 200_000, 3.0).unwrap();
768        assert_eq!(r.fixed, vec![3, -2, 5]);
769        assert!(r.fixed_status);
770        assert!(r.ratio > 3.0);
771        assert_eq!(r.candidates_evaluated, 27); // 3^3
772    }
773
774    #[test]
775    fn refuses_an_ambiguous_lattice() {
776        // Half-integer floats: nearest points are equidistant -> low ratio.
777        let float = vec![0.5, 0.5];
778        let cov = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
779        let r = bounded_ils_search(&float, &cov, 1, 200_000, 3.0).unwrap();
780        assert!(!r.fixed_status);
781        assert!(r.ratio < 3.0);
782    }
783
784    #[test]
785    fn errors_when_the_lattice_exceeds_the_candidate_limit() {
786        let float = vec![0.0, 0.0, 0.0];
787        let cov = vec![
788            vec![1.0, 0.0, 0.0],
789            vec![0.0, 1.0, 0.0],
790            vec![0.0, 0.0, 1.0],
791        ];
792        // 3^3 = 27 lattice points, limit 10 -> error.
793        assert_eq!(
794            bounded_ils_search(&float, &cov, 1, 10, 3.0),
795            Err(IlsError::TooManyCandidates {
796                evaluated: 27,
797                limit: 10
798            })
799        );
800    }
801
802    #[test]
803    fn rejects_pathological_lattice_before_allocating_ranges() {
804        let float = vec![0.0, 0.0];
805        let cov = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
806
807        let err = bounded_ils_search(&float, &cov, 1_000_000_000, 100, 3.0)
808            .expect_err("over-limit lattice must be rejected before range allocation");
809        assert!(matches!(
810            err,
811            IlsError::TooManyCandidates {
812                evaluated,
813                limit: 100
814            } if evaluated > 100
815        ));
816
817        let normal = bounded_ils_search(&float, &cov, 1, 9, 3.0)
818            .expect("within-limit lattice should still enumerate normally");
819        assert_eq!(normal.fixed, vec![0, 0]);
820        assert_eq!(normal.candidates_evaluated, 9);
821    }
822
823    #[test]
824    fn rejects_bounded_search_ranges_outside_i64_domain() {
825        let cov = vec![vec![1.0]];
826        let expected = Err(IlsError::InvalidInput {
827            field: "ils float_cycles",
828            reason: "outside integer search range",
829        });
830
831        assert_eq!(bounded_ils_search(&[f64::MAX], &cov, 1, 3, 3.0), expected);
832        assert_eq!(
833            bounded_ils_search(&[i64::MAX as f64], &cov, 1, 3, 3.0),
834            expected
835        );
836        assert_eq!(
837            bounded_ils_search(&[i64::MIN as f64], &cov, 1, 3, 3.0),
838            expected
839        );
840    }
841
842    // --- LAMBDA port vs RTKLIB's own committed reference vectors ----------
843    // (t_lambda.c utest1/utest2; see parity/generator/lambda_ref). RTKLIB's
844    // unit test tolerates 1e-4 on the residuals; we hold the same.
845
846    fn full_matrix(flat: &[f64], n: usize) -> Vec<Vec<f64>> {
847        (0..n)
848            .map(|i| (0..n).map(|j| flat[i * n + j]).collect())
849            .collect()
850    }
851
852    #[test]
853    fn lambda_matches_rtklib_utest1() {
854        let a = [
855            1585184.171,
856            -6716599.430,
857            3915742.905,
858            7627233.455,
859            9565990.879,
860            989457273.200,
861        ];
862        #[rustfmt::skip]
863        let q = full_matrix(&[
864            0.227134, 0.112202, 0.112202, 0.112202, 0.112202, 0.103473,
865            0.112202, 0.227134, 0.112202, 0.112202, 0.112202, 0.103473,
866            0.112202, 0.112202, 0.227134, 0.112202, 0.112202, 0.103473,
867            0.112202, 0.112202, 0.112202, 0.227134, 0.112202, 0.103473,
868            0.112202, 0.112202, 0.112202, 0.112202, 0.227134, 0.103473,
869            0.103473, 0.103473, 0.103473, 0.103473, 0.103473, 0.434339,
870        ], 6);
871
872        let r = lambda_ils_search(&a, &q, 3.0).unwrap();
873        assert_eq!(
874            r.fixed,
875            vec![1585184, -6716599, 3915743, 7627234, 9565991, 989457273]
876        );
877        assert!((r.best_score - 3.5079844392).abs() < 1e-4);
878        assert!((r.second_best_score.unwrap() - 3.70845619249).abs() < 1e-4);
879    }
880
881    #[test]
882    fn lambda_matches_rtklib_utest2_strongly_correlated() {
883        // The case the bounded box search cannot solve: the ILS optimum is up
884        // to 14 cycles from componentwise rounding. LAMBDA gets it exactly.
885        let a = [
886            -13324172.755747,
887            -10668894.713608,
888            -7157225.010770,
889            -6149367.974367,
890            -7454133.571066,
891            -5969200.494550,
892            8336734.058423,
893            6186974.084502,
894            -17549093.883655,
895            -13970158.922370,
896        ];
897        #[rustfmt::skip]
898        let q = full_matrix(&[
899            0.446320,0.223160,0.223160,0.223160,0.223160,0.572775,0.286388,0.286388,0.286388,0.286388,
900            0.223160,0.446320,0.223160,0.223160,0.223160,0.286388,0.572775,0.286388,0.286388,0.286388,
901            0.223160,0.223160,0.446320,0.223160,0.223160,0.286388,0.286388,0.572775,0.286388,0.286388,
902            0.223160,0.223160,0.223160,0.446320,0.223160,0.286388,0.286388,0.286388,0.572775,0.286388,
903            0.223160,0.223160,0.223160,0.223160,0.446320,0.286388,0.286388,0.286388,0.286388,0.572775,
904            0.572775,0.286388,0.286388,0.286388,0.286388,0.735063,0.367531,0.367531,0.367531,0.367531,
905            0.286388,0.572775,0.286388,0.286388,0.286388,0.367531,0.735063,0.367531,0.367531,0.367531,
906            0.286388,0.286388,0.572775,0.286388,0.286388,0.367531,0.367531,0.735063,0.367531,0.367531,
907            0.286388,0.286388,0.286388,0.572775,0.286388,0.367531,0.367531,0.367531,0.735063,0.367531,
908            0.286388,0.286388,0.286388,0.286388,0.572775,0.367531,0.367531,0.367531,0.367531,0.735063,
909        ], 10);
910
911        let r = lambda_ils_search(&a, &q, 3.0).unwrap();
912        assert_eq!(
913            r.fixed,
914            vec![
915                -13324188, -10668901, -7157236, -6149379, -7454143, -5969220, 8336726, 6186960,
916                -17549108, -13970171
917            ]
918        );
919        assert!((r.best_score - 1506.43578925).abs() < 1e-4);
920        assert!((r.second_best_score.unwrap() - 1612.81176533).abs() < 1e-4);
921    }
922
923    #[test]
924    fn lambda_agrees_with_box_search_in_regime() {
925        // Weakly-correlated, ILS optimum near rounding: both kernels must agree.
926        let a = vec![0.30, -0.40, 1.20];
927        let q = vec![
928            vec![0.50, 0.10, 0.05],
929            vec![0.10, 0.50, 0.10],
930            vec![0.05, 0.10, 0.50],
931        ];
932        let lam = lambda_ils_search(&a, &q, 3.0).unwrap();
933        let box_ = bounded_ils_search(&a, &q, 1, 200_000, 3.0).unwrap();
934        assert_eq!(lam.fixed, box_.fixed);
935        assert!((lam.best_score - box_.best_score).abs() < 1e-9);
936        assert!((lam.second_best_score.unwrap() - box_.second_best_score.unwrap()).abs() < 1e-9);
937    }
938
939    // --- input validation (both kernels reject malformed inputs cleanly) -----
940
941    #[test]
942    fn rejects_undersized_covariance() {
943        // 2 ambiguities, 1x1 covariance - would index out of bounds without the guard.
944        let a = vec![0.1, 0.2];
945        let q = vec![vec![1.0]];
946        assert_eq!(
947            bounded_ils_search(&a, &q, 1, 200_000, 3.0),
948            Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
949        );
950        assert_eq!(
951            lambda_ils_search(&a, &q, 3.0),
952            Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
953        );
954    }
955
956    #[test]
957    fn rejects_oversized_covariance() {
958        // 1 ambiguity, 2x2 covariance - would silently use a submatrix.
959        let a = vec![0.1];
960        let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
961        assert_eq!(
962            bounded_ils_search(&a, &q, 1, 200_000, 3.0),
963            Err(IlsError::InvalidDimensions { n: 1, rows: 2 })
964        );
965        assert_eq!(
966            lambda_ils_search(&a, &q, 3.0),
967            Err(IlsError::InvalidDimensions { n: 1, rows: 2 })
968        );
969    }
970
971    #[test]
972    fn rejects_ragged_covariance() {
973        // Square row count but a row of the wrong width.
974        let a = vec![0.1, 0.2];
975        let q = vec![vec![1.0, 0.0], vec![0.0]];
976        assert_eq!(
977            bounded_ils_search(&a, &q, 1, 200_000, 3.0),
978            Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
979        );
980        assert_eq!(
981            lambda_ils_search(&a, &q, 3.0),
982            Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
983        );
984    }
985
986    #[test]
987    fn bounded_search_rejects_invalid_covariance_geometry() {
988        let a = vec![0.1, 0.2];
989        let expected = Err(IlsError::InvalidInput {
990            field: "ils covariance",
991            reason: "not positive",
992        });
993
994        let negative_variance = vec![vec![-1.0, 0.0], vec![0.0, 1.0]];
995        assert_eq!(
996            bounded_ils_search(&a, &negative_variance, 1, 200_000, 3.0),
997            expected
998        );
999
1000        let asymmetric = vec![vec![1.0, 0.5], vec![0.4, 1.0]];
1001        assert_eq!(
1002            bounded_ils_search(&a, &asymmetric, 1, 200_000, 3.0),
1003            expected
1004        );
1005
1006        let indefinite = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
1007        assert_eq!(
1008            bounded_ils_search(&a, &indefinite, 1, 200_000, 3.0),
1009            expected
1010        );
1011    }
1012
1013    #[test]
1014    fn lambda_search_rejects_invalid_covariance_geometry() {
1015        let a = vec![0.1, 0.2];
1016        let expected = Err(IlsError::InvalidInput {
1017            field: "ils covariance",
1018            reason: "not positive",
1019        });
1020
1021        let negative_variance = vec![vec![-1.0, 0.0], vec![0.0, 1.0]];
1022        assert_eq!(lambda_ils_search(&a, &negative_variance, 3.0), expected);
1023
1024        let asymmetric = vec![vec![1.0, 0.5], vec![0.4, 1.0]];
1025        assert_eq!(lambda_ils_search(&a, &asymmetric, 3.0), expected);
1026
1027        let indefinite = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
1028        assert_eq!(lambda_ils_search(&a, &indefinite, 3.0), expected);
1029    }
1030
1031    #[test]
1032    fn rejects_empty_input() {
1033        let a: Vec<f64> = vec![];
1034        let q: Vec<Vec<f64>> = vec![];
1035        assert_eq!(
1036            bounded_ils_search(&a, &q, 1, 200_000, 3.0),
1037            Err(IlsError::InvalidDimensions { n: 0, rows: 0 })
1038        );
1039        assert_eq!(
1040            lambda_ils_search(&a, &q, 3.0),
1041            Err(IlsError::InvalidDimensions { n: 0, rows: 0 })
1042        );
1043    }
1044
1045    #[test]
1046    fn rejects_non_finite_input() {
1047        let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
1048        assert_eq!(
1049            bounded_ils_search(&[f64::NAN, 0.2], &q, 1, 200_000, 3.0),
1050            Err(IlsError::NonFinite)
1051        );
1052        let q_inf = vec![vec![f64::INFINITY, 0.0], vec![0.0, 1.0]];
1053        assert_eq!(
1054            lambda_ils_search(&[0.1, 0.2], &q_inf, 3.0),
1055            Err(IlsError::NonFinite)
1056        );
1057    }
1058
1059    #[test]
1060    fn rejects_invalid_ratio_thresholds() {
1061        let a = vec![0.1, 0.2];
1062        let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
1063
1064        for (threshold, reason) in [
1065            (-1.0, "negative"),
1066            (f64::NAN, "not finite"),
1067            (f64::INFINITY, "not finite"),
1068        ] {
1069            let expected = Err(IlsError::InvalidInput {
1070                field: ILS_RATIO_THRESHOLD_FIELD,
1071                reason,
1072            });
1073            assert_eq!(bounded_ils_search(&a, &q, 1, 200_000, threshold), expected);
1074            assert_eq!(lambda_ils_search(&a, &q, threshold), expected);
1075        }
1076    }
1077
1078    #[test]
1079    fn exact_integer_fix_reports_finite_saturated_ratio() {
1080        let a = vec![1.0];
1081        let q = vec![vec![1.0]];
1082
1083        let bounded = bounded_ils_search(&a, &q, 1, 3, 3.0).unwrap();
1084        assert_eq!(bounded.best_score, 0.0);
1085        assert_eq!(bounded.second_best_score, Some(1.0));
1086        assert_eq!(bounded.ratio, f64::MAX);
1087        assert!(bounded.ratio.is_finite());
1088        assert!(bounded.fixed_status);
1089
1090        let lambda = lambda_ils_search(&a, &q, 3.0).unwrap();
1091        assert_eq!(lambda.best_score, 0.0);
1092        assert_eq!(lambda.second_best_score, Some(1.0));
1093        assert_eq!(lambda.ratio, f64::MAX);
1094        assert!(lambda.ratio.is_finite());
1095        assert!(lambda.fixed_status);
1096    }
1097
1098    #[test]
1099    fn valid_ratio_threshold_still_controls_fix_status() {
1100        let a = vec![3.02, -1.98, 5.01];
1101        let q = vec![
1102            vec![0.01, 0.0, 0.0],
1103            vec![0.0, 0.01, 0.0],
1104            vec![0.0, 0.0, 0.01],
1105        ];
1106
1107        let bounded_fixed = bounded_ils_search(&a, &q, 1, 200_000, 3.0).unwrap();
1108        let bounded_held = bounded_ils_search(&a, &q, 1, 200_000, 1.0e12).unwrap();
1109        assert!(bounded_fixed.fixed_status);
1110        assert!(!bounded_held.fixed_status);
1111        assert_eq!(bounded_fixed.fixed, bounded_held.fixed);
1112
1113        let lambda_fixed = lambda_ils_search(&a, &q, 3.0).unwrap();
1114        let lambda_held = lambda_ils_search(&a, &q, 1.0e12).unwrap();
1115        assert!(lambda_fixed.fixed_status);
1116        assert!(!lambda_held.fixed_status);
1117        assert_eq!(lambda_fixed.fixed, lambda_held.fixed);
1118    }
1119}