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