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 checked_integer_search_value(rounded: f64) -> core::result::Result<i64, IlsError> {
296    const I64_MAX_EXCLUSIVE: f64 = 9_223_372_036_854_775_808.0;
297    if !rounded.is_finite() || rounded < i64::MIN as f64 || rounded >= I64_MAX_EXCLUSIVE {
298        return Err(IlsError::InvalidInput {
299            field: "ils float_cycles",
300            reason: "outside integer search range",
301        });
302    }
303    Ok(rounded as i64)
304}
305
306fn bounded_integer_candidates(
307    float_cycle: f64,
308    radius: i64,
309) -> core::result::Result<Vec<i64>, IlsError> {
310    if radius < 0 {
311        return Ok(Vec::new());
312    }
313
314    let rounded = float_cycle.round(); // Elixir round/1: half away from zero
315    let center_i64 = checked_integer_search_value(rounded)?;
316    let low = center_i64
317        .checked_sub(radius)
318        .ok_or(IlsError::InvalidInput {
319            field: "ils float_cycles",
320            reason: "outside integer search range",
321        })?;
322    let high = center_i64
323        .checked_add(radius)
324        .ok_or(IlsError::InvalidInput {
325            field: "ils float_cycles",
326            reason: "outside integer search range",
327        })?;
328    Ok(integers_near(float_cycle, low, high))
329}
330
331fn ensure_candidate_limit(
332    dimensions: usize,
333    radius: i64,
334    limit: usize,
335) -> core::result::Result<(), IlsError> {
336    let per_dimension = if radius < 0 {
337        0usize
338    } else {
339        let width = radius
340            .checked_mul(2)
341            .and_then(|width| width.checked_add(1))
342            .ok_or(IlsError::TooManyCandidates {
343                evaluated: usize::MAX,
344                limit,
345            })?;
346        usize::try_from(width).map_err(|_| IlsError::TooManyCandidates {
347            evaluated: usize::MAX,
348            limit,
349        })?
350    };
351
352    let mut candidates = 1usize;
353    for _ in 0..dimensions {
354        candidates = candidates
355            .checked_mul(per_dimension)
356            .ok_or(IlsError::TooManyCandidates {
357                evaluated: usize::MAX,
358                limit,
359            })?;
360        if candidates > limit {
361            return Err(IlsError::TooManyCandidates {
362                evaluated: candidates,
363                limit,
364            });
365        }
366    }
367
368    Ok(())
369}
370
371fn integer_ratio(best_score: f64, second_best_score: Option<f64>) -> f64 {
372    match second_best_score {
373        None => 0.0,
374        Some(second) => {
375            if best_score == 0.0 && second > 0.0 {
376                f64::MAX
377            } else if best_score == 0.0 {
378                0.0
379            } else {
380                second / best_score
381            }
382        }
383    }
384}
385
386fn ratio_pass(ratio: f64, threshold: f64) -> bool {
387    ratio >= threshold
388}
389
390fn symmetrize(m: &[Vec<f64>]) -> Vec<Vec<f64>> {
391    let n = m.len();
392    (0..n)
393        .map(|i| (0..n).map(|j| (m[i][j] + m[j][i]) / 2.0).collect())
394        .collect()
395}
396
397// =========================================================================
398// LAMBDA / MLAMBDA integer least squares (Teunissen 1995; Chang-Yang-Zhou 2005)
399// -------------------------------------------------------------------------
400// A faithful port of RTKLIB's `lambda()` (BSD-2, _tools/RTKLIB/src/lambda.c):
401// LtDL factorization + integer-Gauss/permutation decorrelation reduction +
402// modified-LAMBDA depth-first search. Unlike `bounded_ils_search` (a naive
403// ±radius box that only finds the true ILS optimum when it lies within the box),
404// this is a *correct* ILS solver for any positive-definite covariance - it is
405// gated against RTKLIB's own committed reference vectors (incl. the strongly-
406// correlated utest2 the box search cannot reach). Validation target is RTKLIB,
407// not bit-identity; the algorithm differs, so agreement is to round-off.
408//
409// Matrices follow RTKLIB's COLUMN-MAJOR convention verbatim - element (row i,
410// col j) of an n×n matrix is `flat[i + j*n]` - so the port reads line-for-line
411// against lambda.c.
412
413const LAMBDA_LOOP_MAX: usize = 10000;
414
415#[inline]
416fn lam_round(x: f64) -> f64 {
417    (x + 0.5).floor() // RTKLIB ROUND(x) = floor(x+0.5)
418}
419
420#[inline]
421fn lam_sgn(x: f64) -> f64 {
422    if x <= 0.0 {
423        -1.0
424    } else {
425        1.0
426    }
427}
428
429/// LtDL factorization `Q = Lᵀ·diag(D)·L` (column-major). Returns `None` if Q is
430/// not positive-definite (a pivot `D[i] <= 0`).
431fn lam_ld(n: usize, q: &[f64]) -> Option<(Vec<f64>, Vec<f64>)> {
432    let mut a = q.to_vec();
433    let mut l = vec![0.0f64; n * n];
434    let mut d = vec![0.0f64; n];
435    for i in (0..n).rev() {
436        d[i] = a[i + i * n];
437        if d[i] <= 0.0 {
438            return None;
439        }
440        let ai = d[i].sqrt();
441        for j in 0..=i {
442            l[i + j * n] = a[i + j * n] / ai;
443        }
444        for j in 0..i {
445            for k in 0..=j {
446                a[j + k * n] -= l[i + k * n] * l[i + j * n];
447            }
448        }
449        for j in 0..=i {
450            l[i + j * n] /= l[i + i * n];
451        }
452    }
453    Some((l, d))
454}
455
456/// Integer Gauss transformation on column `j` using column `i`.
457fn lam_gauss(n: usize, l: &mut [f64], z: &mut [f64], i: usize, j: usize) {
458    let mu = lam_round(l[i + j * n]) as i64;
459    if mu != 0 {
460        let muf = mu as f64;
461        for k in i..n {
462            l[k + j * n] -= muf * l[k + i * n];
463        }
464        for k in 0..n {
465            z[k + j * n] -= muf * z[k + i * n];
466        }
467    }
468}
469
470/// Permutation of adjacent ambiguities `j` and `j+1`.
471fn lam_perm(n: usize, l: &mut [f64], d: &mut [f64], j: usize, del: f64, z: &mut [f64]) {
472    let eta = d[j] / del;
473    let lam = d[j + 1] * l[j + 1 + j * n] / del;
474    d[j] = eta * d[j + 1];
475    d[j + 1] = del;
476    for k in 0..j {
477        let a0 = l[j + k * n];
478        let a1 = l[j + 1 + k * n];
479        l[j + k * n] = -l[j + 1 + j * n] * a0 + a1;
480        l[j + 1 + k * n] = eta * a0 + lam * a1;
481    }
482    l[j + 1 + j * n] = lam;
483    for k in (j + 2)..n {
484        l.swap(k + j * n, k + (j + 1) * n);
485    }
486    for k in 0..n {
487        z.swap(k + j * n, k + (j + 1) * n);
488    }
489}
490
491/// LAMBDA reduction: decorrelate via integer Gauss transformations + adjacent
492/// permutations, accumulating the unimodular transform `Z`.
493fn lam_reduction(n: usize, l: &mut [f64], d: &mut [f64], z: &mut [f64]) {
494    let mut j: isize = n as isize - 2;
495    let mut k: isize = n as isize - 2;
496    while j >= 0 {
497        let ju = j as usize;
498        if j <= k {
499            for i in (ju + 1)..n {
500                lam_gauss(n, l, z, i, ju);
501            }
502        }
503        let del = d[ju] + l[ju + 1 + ju * n] * l[ju + 1 + ju * n] * d[ju + 1];
504        if del + LAMBDA_REDUCTION_EPS < d[ju + 1] {
505            lam_perm(n, l, d, ju, del, z);
506            k = j;
507            j = n as isize - 2;
508        } else {
509            j -= 1;
510        }
511    }
512}
513
514/// Modified-LAMBDA (mlambda) search for the `m` best integer vectors in the
515/// decorrelated space. Returns `(zn, s)` where `zn` is `n*m` column-major
516/// candidates and `s[k]` is the squared residual of candidate `k` (sorted
517/// ascending). `None` on search-loop overflow.
518fn lam_search(
519    n: usize,
520    m: usize,
521    l: &[f64],
522    d: &[f64],
523    zs: &[f64],
524) -> Option<(Vec<f64>, Vec<f64>)> {
525    let mut s = vec![0.0f64; m];
526    let mut zn = vec![0.0f64; n * m];
527    let mut smat = vec![0.0f64; n * n];
528    let mut dist = vec![0.0f64; n];
529    let mut zb = vec![0.0f64; n];
530    let mut z = vec![0.0f64; n];
531    let mut step = vec![0.0f64; n];
532
533    let mut nn: usize = 0;
534    let mut imax: usize = 0;
535    let mut maxdist = 1.0e99;
536
537    let mut k: isize = n as isize - 1;
538    let ku = k as usize;
539    dist[ku] = 0.0;
540    zb[ku] = zs[ku];
541    z[ku] = lam_round(zb[ku]);
542    let mut y = zb[ku] - z[ku];
543    step[ku] = lam_sgn(y);
544
545    let mut c = 0usize;
546    while c < LAMBDA_LOOP_MAX {
547        let kk = k as usize;
548        let newdist = dist[kk] + y * y / d[kk];
549        if newdist < maxdist {
550            if k != 0 {
551                k -= 1;
552                let kk = k as usize;
553                dist[kk] = newdist;
554                for i in 0..=kk {
555                    smat[kk + i * n] =
556                        smat[kk + 1 + i * n] + (z[kk + 1] - zb[kk + 1]) * l[kk + 1 + i * n];
557                }
558                zb[kk] = zs[kk] + smat[kk + kk * n];
559                z[kk] = lam_round(zb[kk]);
560                y = zb[kk] - z[kk];
561                step[kk] = lam_sgn(y);
562            } else {
563                if nn < m {
564                    if nn == 0 || newdist > s[imax] {
565                        imax = nn;
566                    }
567                    for i in 0..n {
568                        zn[i + nn * n] = z[i];
569                    }
570                    s[nn] = newdist;
571                    nn += 1;
572                } else {
573                    if newdist < s[imax] {
574                        for i in 0..n {
575                            zn[i + imax * n] = z[i];
576                        }
577                        s[imax] = newdist;
578                        imax = 0;
579                        for i in 0..m {
580                            if s[imax] < s[i] {
581                                imax = i;
582                            }
583                        }
584                    }
585                    maxdist = s[imax];
586                }
587                z[0] += step[0];
588                y = zb[0] - z[0];
589                step[0] = -step[0] - lam_sgn(step[0]);
590            }
591        } else if k == n as isize - 1 {
592            break;
593        } else {
594            k += 1;
595            let kk = k as usize;
596            z[kk] += step[kk];
597            y = zb[kk] - z[kk];
598            step[kk] = -step[kk] - lam_sgn(step[kk]);
599        }
600        c += 1;
601    }
602
603    if c >= LAMBDA_LOOP_MAX {
604        return None;
605    }
606
607    // Sort the m candidates by ascending residual (RTKLIB's selection sort).
608    for i in 0..m.saturating_sub(1) {
609        for j in (i + 1)..m {
610            if s[i] < s[j] {
611                continue;
612            }
613            s.swap(i, j);
614            for k in 0..n {
615                zn.swap(k + i * n, k + j * n);
616            }
617        }
618    }
619    Some((zn, s))
620}
621
622/// Correct integer-least-squares via the LAMBDA method (RTKLIB `lambda()` port).
623///
624/// Finds the true ILS optimum and runner-up for any positive-definite
625/// covariance - no search box, no combinatorial blow-up. Returns the same
626/// [`IlsResult`] shape as [`bounded_ils_search`] so it is a drop-in: in the
627/// weakly-correlated regime both select the identical integer vector and ratio;
628/// on strongly-correlated geometry only this one is correct.
629pub fn lambda_ils_search(
630    float_cycles: &[f64],
631    covariance: &[Vec<f64>],
632    ratio_threshold: f64,
633) -> core::result::Result<IlsResult, IlsError> {
634    validate_inputs(float_cycles, covariance)?;
635    validate_covariance_geometry(covariance)?;
636    validate_ratio_threshold(ratio_threshold)?;
637    for &float_cycle in float_cycles {
638        checked_integer_search_value(lam_round(float_cycle))?;
639    }
640    let n = float_cycles.len();
641    let q = symmetrize(covariance);
642    // Inverse is kept only for the diagnostic metadata (LAMBDA itself uses LtDL).
643    let q_inv = symmetrize(&invert_matrix_first_tie(&q).ok_or(IlsError::Singular)?);
644
645    // Column-major copy of the symmetrized covariance for the RTKLIB port.
646    let mut q_cm = vec![0.0f64; n * n];
647    for i in 0..n {
648        for j in 0..n {
649            q_cm[i + j * n] = q[i][j];
650        }
651    }
652
653    let (mut l, mut d) = lam_ld(n, &q_cm).ok_or(IlsError::Singular)?;
654    let mut z = {
655        // Z = identity (column-major).
656        let mut e = vec![0.0f64; n * n];
657        for i in 0..n {
658            e[i + i * n] = 1.0;
659        }
660        e
661    };
662    lam_reduction(n, &mut l, &mut d, &mut z);
663
664    // zs = Zᵀ·a.
665    let mut zs = vec![0.0f64; n];
666    for i in 0..n {
667        let mut acc = 0.0;
668        for k in 0..n {
669            acc += z[k + i * n] * float_cycles[k];
670        }
671        zs[i] = acc;
672    }
673
674    // Best + runner-up, for the ratio test. `lam_ld` already failed
675    // `Singular` above; a `None` here is search-loop overflow.
676    let m = 2usize;
677    let (zn, _s) = lam_search(n, m, &l, &d, &zs).ok_or(IlsError::SearchLimitExceeded)?;
678
679    // Back-transform each decorrelated candidate: F = (Zᵀ)⁻¹·E (RTKLIB solve("T",Z,E)).
680    // Z is unimodular, so the result is integer up to round-off.
681    let mut zt = vec![vec![0.0f64; n]; n];
682    for i in 0..n {
683        for j in 0..n {
684            zt[i][j] = z[j + i * n]; // (Zᵀ)[i][j] = Z[j][i]
685        }
686    }
687    let mut fixed_candidates: Vec<Vec<i64>> = Vec::with_capacity(m);
688    for col in 0..m {
689        let b: Vec<f64> = (0..n).map(|i| zn[i + col * n]).collect();
690        let x = solve_linear_first_tie(&zt, &b).ok_or(IlsError::Singular)?;
691        let fixed = x
692            .into_iter()
693            .map(|value| checked_integer_search_value(lam_round(value)))
694            .collect::<core::result::Result<Vec<_>, _>>()?;
695        fixed_candidates.push(fixed);
696    }
697
698    // LAMBDA's mlambda distance `s` is computed in the decorrelated LtDL space; to
699    // keep the reported scores consistent with `bounded_ils_search` (and bit-exact
700    // against the explicit `Δᵀ Q⁻¹ Δ` reference / numpy goldens), recompute each
701    // candidate's score with the same quadratic form and order them the same way
702    // (score ascending, then cycles lexicographic). LAMBDA's only job here is to
703    // FIND the candidate set; scoring/ratio use the canonical formula.
704    let mut scored: Vec<(f64, Vec<i64>)> = fixed_candidates
705        .into_iter()
706        .map(|c| (quadratic_score(float_cycles, &c, &q_inv), c))
707        .collect();
708    scored.sort_by(|(sa, ca), (sb, cb)| {
709        sa.partial_cmp(sb)
710            .unwrap_or(core::cmp::Ordering::Equal)
711            .then_with(|| ca.cmp(cb))
712    });
713
714    let best_score = scored[0].0;
715    let fixed = scored[0].1.clone();
716    let second_best_score = scored.get(1).map(|(s, _)| *s);
717    let ratio = integer_ratio(best_score, second_best_score);
718
719    Ok(IlsResult {
720        fixed,
721        fixed_status: ratio_pass(ratio, ratio_threshold),
722        ratio,
723        best_score,
724        second_best_score,
725        // LAMBDA does not enumerate a box; report the number of candidate vectors.
726        candidates_evaluated: m,
727        covariance: q,
728        covariance_inverse: q_inv,
729    })
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    #[test]
737    fn bounded_search_reports_first_tie_inverse_bits() {
738        let float = vec![0.1, -0.2];
739        let cov = vec![vec![4.0, 1.0], vec![1.0, 3.0]];
740        let result = bounded_ils_search(&float, &cov, 1, 9, 3.0).unwrap();
741
742        assert_eq!(
743            result.covariance_inverse[0][0].to_bits(),
744            0x3fd1745d1745d174
745        );
746        assert_eq!(
747            result.covariance_inverse[0][1].to_bits(),
748            0xbfb745d1745d1746
749        );
750        assert_eq!(
751            result.covariance_inverse[1][0].to_bits(),
752            0xbfb745d1745d1746
753        );
754        assert_eq!(
755            result.covariance_inverse[1][1].to_bits(),
756            0x3fd745d1745d1746
757        );
758    }
759
760    #[test]
761    fn fixes_a_well_separated_lattice_point() {
762        // Float ambiguities very close to integers, tight diagonal covariance:
763        // the nearest lattice point dominates and the ratio test passes.
764        let float = vec![3.02, -1.98, 5.01];
765        let cov = vec![
766            vec![0.01, 0.0, 0.0],
767            vec![0.0, 0.01, 0.0],
768            vec![0.0, 0.0, 0.01],
769        ];
770        let r = bounded_ils_search(&float, &cov, 1, 200_000, 3.0).unwrap();
771        assert_eq!(r.fixed, vec![3, -2, 5]);
772        assert!(r.fixed_status);
773        assert!(r.ratio > 3.0);
774        assert_eq!(r.candidates_evaluated, 27); // 3^3
775    }
776
777    #[test]
778    fn refuses_an_ambiguous_lattice() {
779        // Half-integer floats: nearest points are equidistant -> low ratio.
780        let float = vec![0.5, 0.5];
781        let cov = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
782        let r = bounded_ils_search(&float, &cov, 1, 200_000, 3.0).unwrap();
783        assert!(!r.fixed_status);
784        assert!(r.ratio < 3.0);
785    }
786
787    #[test]
788    fn errors_when_the_lattice_exceeds_the_candidate_limit() {
789        let float = vec![0.0, 0.0, 0.0];
790        let cov = vec![
791            vec![1.0, 0.0, 0.0],
792            vec![0.0, 1.0, 0.0],
793            vec![0.0, 0.0, 1.0],
794        ];
795        // 3^3 = 27 lattice points, limit 10 -> error.
796        assert_eq!(
797            bounded_ils_search(&float, &cov, 1, 10, 3.0),
798            Err(IlsError::TooManyCandidates {
799                evaluated: 27,
800                limit: 10
801            })
802        );
803    }
804
805    #[test]
806    fn rejects_pathological_lattice_before_allocating_ranges() {
807        let float = vec![0.0, 0.0];
808        let cov = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
809
810        let err = bounded_ils_search(&float, &cov, 1_000_000_000, 100, 3.0)
811            .expect_err("over-limit lattice must be rejected before range allocation");
812        assert!(matches!(
813            err,
814            IlsError::TooManyCandidates {
815                evaluated,
816                limit: 100
817            } if evaluated > 100
818        ));
819
820        let normal = bounded_ils_search(&float, &cov, 1, 9, 3.0)
821            .expect("within-limit lattice should still enumerate normally");
822        assert_eq!(normal.fixed, vec![0, 0]);
823        assert_eq!(normal.candidates_evaluated, 9);
824    }
825
826    #[test]
827    fn rejects_search_values_outside_i64_domain() {
828        let cov = vec![vec![1.0]];
829        let expected = Err(IlsError::InvalidInput {
830            field: "ils float_cycles",
831            reason: "outside integer search range",
832        });
833
834        assert_eq!(bounded_ils_search(&[f64::MAX], &cov, 1, 3, 3.0), expected);
835        assert_eq!(
836            bounded_ils_search(&[i64::MAX as f64], &cov, 1, 3, 3.0),
837            expected
838        );
839        assert_eq!(
840            bounded_ils_search(&[i64::MIN as f64], &cov, 1, 3, 3.0),
841            expected
842        );
843
844        assert_eq!(lambda_ils_search(&[f64::MAX], &cov, 3.0), expected);
845        assert_eq!(lambda_ils_search(&[-f64::MAX], &cov, 3.0), expected);
846        assert_eq!(lambda_ils_search(&[i64::MAX as f64], &cov, 3.0), expected);
847    }
848
849    #[test]
850    fn rejects_ci_lambda_crash_outside_integer_domain() {
851        let float_cycles = [1.382_418_547_873_630_5e306, 1.382_417_208_487_871_5e306];
852        let covariance = vec![
853            vec![1.382_418_547_873_630_5e306, 1.382_417_208_487_871_5e306],
854            vec![1.382_417_208_487_871_5e306, 1.382_417_208_487_871_5e306],
855        ];
856
857        assert_eq!(
858            lambda_ils_search(&float_cycles, &covariance, 1.382_417_208_487_871_5e306),
859            Err(IlsError::InvalidInput {
860                field: "ils float_cycles",
861                reason: "outside integer search range",
862            })
863        );
864    }
865
866    // --- LAMBDA port vs RTKLIB's own committed reference vectors ----------
867    // (t_lambda.c utest1/utest2; see parity/generator/lambda_ref). RTKLIB's
868    // unit test tolerates 1e-4 on the residuals; we hold the same.
869
870    fn full_matrix(flat: &[f64], n: usize) -> Vec<Vec<f64>> {
871        (0..n)
872            .map(|i| (0..n).map(|j| flat[i * n + j]).collect())
873            .collect()
874    }
875
876    #[test]
877    fn lambda_matches_rtklib_utest1() {
878        let a = [
879            1585184.171,
880            -6716599.430,
881            3915742.905,
882            7627233.455,
883            9565990.879,
884            989457273.200,
885        ];
886        #[rustfmt::skip]
887        let q = full_matrix(&[
888            0.227134, 0.112202, 0.112202, 0.112202, 0.112202, 0.103473,
889            0.112202, 0.227134, 0.112202, 0.112202, 0.112202, 0.103473,
890            0.112202, 0.112202, 0.227134, 0.112202, 0.112202, 0.103473,
891            0.112202, 0.112202, 0.112202, 0.227134, 0.112202, 0.103473,
892            0.112202, 0.112202, 0.112202, 0.112202, 0.227134, 0.103473,
893            0.103473, 0.103473, 0.103473, 0.103473, 0.103473, 0.434339,
894        ], 6);
895
896        let r = lambda_ils_search(&a, &q, 3.0).unwrap();
897        assert_eq!(
898            r.fixed,
899            vec![1585184, -6716599, 3915743, 7627234, 9565991, 989457273]
900        );
901        assert!((r.best_score - 3.5079844392).abs() < 1e-4);
902        assert!((r.second_best_score.unwrap() - 3.70845619249).abs() < 1e-4);
903    }
904
905    #[test]
906    fn lambda_matches_rtklib_utest2_strongly_correlated() {
907        // The case the bounded box search cannot solve: the ILS optimum is up
908        // to 14 cycles from componentwise rounding. LAMBDA gets it exactly.
909        let a = [
910            -13324172.755747,
911            -10668894.713608,
912            -7157225.010770,
913            -6149367.974367,
914            -7454133.571066,
915            -5969200.494550,
916            8336734.058423,
917            6186974.084502,
918            -17549093.883655,
919            -13970158.922370,
920        ];
921        #[rustfmt::skip]
922        let q = full_matrix(&[
923            0.446320,0.223160,0.223160,0.223160,0.223160,0.572775,0.286388,0.286388,0.286388,0.286388,
924            0.223160,0.446320,0.223160,0.223160,0.223160,0.286388,0.572775,0.286388,0.286388,0.286388,
925            0.223160,0.223160,0.446320,0.223160,0.223160,0.286388,0.286388,0.572775,0.286388,0.286388,
926            0.223160,0.223160,0.223160,0.446320,0.223160,0.286388,0.286388,0.286388,0.572775,0.286388,
927            0.223160,0.223160,0.223160,0.223160,0.446320,0.286388,0.286388,0.286388,0.286388,0.572775,
928            0.572775,0.286388,0.286388,0.286388,0.286388,0.735063,0.367531,0.367531,0.367531,0.367531,
929            0.286388,0.572775,0.286388,0.286388,0.286388,0.367531,0.735063,0.367531,0.367531,0.367531,
930            0.286388,0.286388,0.572775,0.286388,0.286388,0.367531,0.367531,0.735063,0.367531,0.367531,
931            0.286388,0.286388,0.286388,0.572775,0.286388,0.367531,0.367531,0.367531,0.735063,0.367531,
932            0.286388,0.286388,0.286388,0.286388,0.572775,0.367531,0.367531,0.367531,0.367531,0.735063,
933        ], 10);
934
935        let r = lambda_ils_search(&a, &q, 3.0).unwrap();
936        assert_eq!(
937            r.fixed,
938            vec![
939                -13324188, -10668901, -7157236, -6149379, -7454143, -5969220, 8336726, 6186960,
940                -17549108, -13970171
941            ]
942        );
943        assert!((r.best_score - 1506.43578925).abs() < 1e-4);
944        assert!((r.second_best_score.unwrap() - 1612.81176533).abs() < 1e-4);
945    }
946
947    #[test]
948    fn lambda_matches_rtklib_near_tie_low_ratio() {
949        // Near-tie regime: the two best candidates are close, so RTKLIB's ratio
950        // s[1]/s[0] sits at ~2.0 - squarely in the typical 1.5-3 acceptance band
951        // and below our 3.0 threshold, so the fix is NOT accepted. Exercises the
952        // ratio test rather than the integer selection.
953        let a = [
954            2.381283532896866,
955            -4.153279079035503,
956            6.181180039414691,
957            -1.1716816183885634,
958            3.144312353800454,
959        ];
960        #[rustfmt::skip]
961        let q = full_matrix(&[
962            0.30250000000000005, 0.11549999999999999, 0.09625, 0.12512500000000001, 0.11165,
963            0.11549999999999999, 0.36, 0.105, 0.13649999999999998, 0.12179999999999998,
964            0.09625, 0.105, 0.25, 0.11374999999999999, 0.10149999999999999,
965            0.12512500000000001, 0.13649999999999998, 0.11374999999999999, 0.42250000000000004, 0.13194999999999998,
966            0.11165, 0.12179999999999998, 0.10149999999999999, 0.13194999999999998, 0.3364,
967        ], 5);
968
969        let r = lambda_ils_search(&a, &q, 3.0).unwrap();
970        assert_eq!(r.fixed, vec![2, -4, 6, -1, 3]);
971        assert!((r.best_score - 1.1061496957026506).abs() < 1e-4);
972        assert!((r.second_best_score.unwrap() - 2.2123104750064506).abs() < 1e-4);
973        assert!((r.ratio - 2.0000100199830024).abs() < 1e-6);
974        assert!(!r.fixed_status); // ratio < 3.0
975    }
976
977    #[test]
978    fn lambda_matches_rtklib_easy_near_diagonal() {
979        // Well-conditioned anchor: near-diagonal Q with float ambiguities very
980        // close to integers. The ratio is huge (~249), the fix is accepted, and
981        // LAMBDA and RTKLIB agree exactly.
982        let a = [4.03, -2.97, 1.02, 5.98];
983        #[rustfmt::skip]
984        let q = full_matrix(&[
985            0.018, 0.002, 0.0,    0.0,
986            0.002, 0.025, 0.0,    0.0,
987            0.0,   0.0,   0.012,  0.0015,
988            0.0,   0.0,   0.0015, 0.03,
989        ], 4);
990
991        let r = lambda_ils_search(&a, &q, 3.0).unwrap();
992        assert_eq!(r.fixed, vec![4, -3, 1, 6]);
993        assert!((r.best_score - 0.12901401697831202).abs() < 1e-4);
994        assert!((r.second_best_score.unwrap() - 32.16255699391752).abs() < 1e-4);
995        assert!((r.ratio - 249.29505915100856).abs() < 1e-6);
996        assert!(r.fixed_status); // ratio >> 3.0
997    }
998
999    #[test]
1000    fn lambda_agrees_with_box_search_in_regime() {
1001        // Weakly-correlated, ILS optimum near rounding: both kernels must agree.
1002        let a = vec![0.30, -0.40, 1.20];
1003        let q = vec![
1004            vec![0.50, 0.10, 0.05],
1005            vec![0.10, 0.50, 0.10],
1006            vec![0.05, 0.10, 0.50],
1007        ];
1008        let lam = lambda_ils_search(&a, &q, 3.0).unwrap();
1009        let box_ = bounded_ils_search(&a, &q, 1, 200_000, 3.0).unwrap();
1010        assert_eq!(lam.fixed, box_.fixed);
1011        assert!((lam.best_score - box_.best_score).abs() < 1e-9);
1012        assert!((lam.second_best_score.unwrap() - box_.second_best_score.unwrap()).abs() < 1e-9);
1013    }
1014
1015    // --- input validation (both kernels reject malformed inputs cleanly) -----
1016
1017    #[test]
1018    fn rejects_undersized_covariance() {
1019        // 2 ambiguities, 1x1 covariance - would index out of bounds without the guard.
1020        let a = vec![0.1, 0.2];
1021        let q = vec![vec![1.0]];
1022        assert_eq!(
1023            bounded_ils_search(&a, &q, 1, 200_000, 3.0),
1024            Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
1025        );
1026        assert_eq!(
1027            lambda_ils_search(&a, &q, 3.0),
1028            Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
1029        );
1030    }
1031
1032    #[test]
1033    fn rejects_oversized_covariance() {
1034        // 1 ambiguity, 2x2 covariance - would silently use a submatrix.
1035        let a = vec![0.1];
1036        let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
1037        assert_eq!(
1038            bounded_ils_search(&a, &q, 1, 200_000, 3.0),
1039            Err(IlsError::InvalidDimensions { n: 1, rows: 2 })
1040        );
1041        assert_eq!(
1042            lambda_ils_search(&a, &q, 3.0),
1043            Err(IlsError::InvalidDimensions { n: 1, rows: 2 })
1044        );
1045    }
1046
1047    #[test]
1048    fn rejects_ragged_covariance() {
1049        // Square row count but a row of the wrong width.
1050        let a = vec![0.1, 0.2];
1051        let q = vec![vec![1.0, 0.0], vec![0.0]];
1052        assert_eq!(
1053            bounded_ils_search(&a, &q, 1, 200_000, 3.0),
1054            Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
1055        );
1056        assert_eq!(
1057            lambda_ils_search(&a, &q, 3.0),
1058            Err(IlsError::InvalidDimensions { n: 2, rows: 1 })
1059        );
1060    }
1061
1062    #[test]
1063    fn bounded_search_rejects_invalid_covariance_geometry() {
1064        let a = vec![0.1, 0.2];
1065        let expected = Err(IlsError::InvalidInput {
1066            field: "ils covariance",
1067            reason: "not positive",
1068        });
1069
1070        let negative_variance = vec![vec![-1.0, 0.0], vec![0.0, 1.0]];
1071        assert_eq!(
1072            bounded_ils_search(&a, &negative_variance, 1, 200_000, 3.0),
1073            expected
1074        );
1075
1076        let asymmetric = vec![vec![1.0, 0.5], vec![0.4, 1.0]];
1077        assert_eq!(
1078            bounded_ils_search(&a, &asymmetric, 1, 200_000, 3.0),
1079            expected
1080        );
1081
1082        let indefinite = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
1083        assert_eq!(
1084            bounded_ils_search(&a, &indefinite, 1, 200_000, 3.0),
1085            expected
1086        );
1087    }
1088
1089    #[test]
1090    fn lambda_search_rejects_invalid_covariance_geometry() {
1091        let a = vec![0.1, 0.2];
1092        let expected = Err(IlsError::InvalidInput {
1093            field: "ils covariance",
1094            reason: "not positive",
1095        });
1096
1097        let negative_variance = vec![vec![-1.0, 0.0], vec![0.0, 1.0]];
1098        assert_eq!(lambda_ils_search(&a, &negative_variance, 3.0), expected);
1099
1100        let asymmetric = vec![vec![1.0, 0.5], vec![0.4, 1.0]];
1101        assert_eq!(lambda_ils_search(&a, &asymmetric, 3.0), expected);
1102
1103        let indefinite = vec![vec![1.0, 2.0], vec![2.0, 1.0]];
1104        assert_eq!(lambda_ils_search(&a, &indefinite, 3.0), expected);
1105    }
1106
1107    #[test]
1108    fn rejects_empty_input() {
1109        let a: Vec<f64> = vec![];
1110        let q: Vec<Vec<f64>> = vec![];
1111        assert_eq!(
1112            bounded_ils_search(&a, &q, 1, 200_000, 3.0),
1113            Err(IlsError::InvalidDimensions { n: 0, rows: 0 })
1114        );
1115        assert_eq!(
1116            lambda_ils_search(&a, &q, 3.0),
1117            Err(IlsError::InvalidDimensions { n: 0, rows: 0 })
1118        );
1119    }
1120
1121    #[test]
1122    fn rejects_non_finite_input() {
1123        let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
1124        assert_eq!(
1125            bounded_ils_search(&[f64::NAN, 0.2], &q, 1, 200_000, 3.0),
1126            Err(IlsError::NonFinite)
1127        );
1128        let q_inf = vec![vec![f64::INFINITY, 0.0], vec![0.0, 1.0]];
1129        assert_eq!(
1130            lambda_ils_search(&[0.1, 0.2], &q_inf, 3.0),
1131            Err(IlsError::NonFinite)
1132        );
1133    }
1134
1135    #[test]
1136    fn rejects_invalid_ratio_thresholds() {
1137        let a = vec![0.1, 0.2];
1138        let q = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
1139
1140        for (threshold, reason) in [
1141            (-1.0, "negative"),
1142            (f64::NAN, "not finite"),
1143            (f64::INFINITY, "not finite"),
1144        ] {
1145            let expected = Err(IlsError::InvalidInput {
1146                field: ILS_RATIO_THRESHOLD_FIELD,
1147                reason,
1148            });
1149            assert_eq!(bounded_ils_search(&a, &q, 1, 200_000, threshold), expected);
1150            assert_eq!(lambda_ils_search(&a, &q, threshold), expected);
1151        }
1152    }
1153
1154    #[test]
1155    fn exact_integer_fix_reports_finite_saturated_ratio() {
1156        let a = vec![1.0];
1157        let q = vec![vec![1.0]];
1158
1159        let bounded = bounded_ils_search(&a, &q, 1, 3, 3.0).unwrap();
1160        assert_eq!(bounded.best_score, 0.0);
1161        assert_eq!(bounded.second_best_score, Some(1.0));
1162        assert_eq!(bounded.ratio, f64::MAX);
1163        assert!(bounded.ratio.is_finite());
1164        assert!(bounded.fixed_status);
1165
1166        let lambda = lambda_ils_search(&a, &q, 3.0).unwrap();
1167        assert_eq!(lambda.best_score, 0.0);
1168        assert_eq!(lambda.second_best_score, Some(1.0));
1169        assert_eq!(lambda.ratio, f64::MAX);
1170        assert!(lambda.ratio.is_finite());
1171        assert!(lambda.fixed_status);
1172    }
1173
1174    #[test]
1175    fn valid_ratio_threshold_still_controls_fix_status() {
1176        let a = vec![3.02, -1.98, 5.01];
1177        let q = vec![
1178            vec![0.01, 0.0, 0.0],
1179            vec![0.0, 0.01, 0.0],
1180            vec![0.0, 0.0, 0.01],
1181        ];
1182
1183        let bounded_fixed = bounded_ils_search(&a, &q, 1, 200_000, 3.0).unwrap();
1184        let bounded_held = bounded_ils_search(&a, &q, 1, 200_000, 1.0e12).unwrap();
1185        assert!(bounded_fixed.fixed_status);
1186        assert!(!bounded_held.fixed_status);
1187        assert_eq!(bounded_fixed.fixed, bounded_held.fixed);
1188
1189        let lambda_fixed = lambda_ils_search(&a, &q, 3.0).unwrap();
1190        let lambda_held = lambda_ils_search(&a, &q, 1.0e12).unwrap();
1191        assert!(lambda_fixed.fixed_status);
1192        assert!(!lambda_held.fixed_status);
1193        assert_eq!(lambda_fixed.fixed, lambda_held.fixed);
1194    }
1195}