Skip to main content

spacelab_gnss/
lambda.rs

1//! **LAMBDA** — integer least-squares estimation of carrier-phase ambiguities.
2//!
3//! Given a float ambiguity vector `â` and its covariance `Q`, the integer least-squares (ILS)
4//! problem is
5//!
6//! ```text
7//! min over a in Z^n of (a − â)ᵀ Q⁻¹ (a − â)
8//! ```
9//!
10//! which is NP-hard in general, so every method for it searches. Rounding `â` entry by entry solves
11//! it only when `Q` is diagonal; on real GNSS covariances, which are extremely elongated, rounding
12//! is wrong far more often than not.
13//!
14//! The Least-squares AMBiguity Decorrelation Adjustment (Teunissen 1993) makes the search cheap in
15//! two stages, and both are here:
16//!
17//! 1. **Reduction.** Find a unimodular integer `Z` — `det Z = ±1`, so `z = Zᵀa` is integer exactly
18//!    when `a` is — that makes `Q_z = ZᵀQZ` as close to diagonal as integer arithmetic allows. It is
19//!    built from integer Gauss transformations (which drive the off-diagonal entries of the `LᵀDL`
20//!    factor below ½ in magnitude) interleaved with permutations (which push the conditional
21//!    variances into decreasing order). This is [`Reduction`].
22//! 2. **Search.** Enumerate integer vectors inside the ellipsoid `(z − ẑ)ᵀQ_z⁻¹(z − ẑ) ≤ χ²`,
23//!    level by level, shrinking `χ²` as candidates are found. Decorrelated, the levels are nearly
24//!    independent and the first candidates tried are almost always the answer. This is
25//!    [`Reduction::search`].
26//!
27//! [`Reduction::bootstrap`] is the cheap non-searching estimator on the same factorisation: fix the
28//! last ambiguity by rounding, condition the rest on it, repeat. It is the Babai point of the
29//! lattice, it is what the search starts from, and on a reduced `Q` it very often *is* the ILS
30//! solution — but "very often" is not "always", which is why it is offered separately rather than
31//! substituted for the search.
32//!
33//! Implemented from Chang, Yang & Zhou (2005), *MLAMBDA: a modified LAMBDA method for integer
34//! least-squares estimation*, J. Geod. 79:552–565 — Algorithms 2.1 (integer Gauss transformation),
35//! 2.2 (permutation), 2.3 (reduction) and the search bounds of its §2.2, which restate the original
36//! LAMBDA. The modifications that paper proposes on top of them (symmetric pivoting, greedy
37//! selection, lazy transformation) are *not* implemented: they change the cost, not the answer.
38//!
39//! Verified against a brute-force integer search over the full box the ellipsoid inscribes: see
40//! `tests/lambda_integer_least_squares.rs`.
41
42// The doc allow is for method and author names (LAMBDA, Teunissen, Babai, MLAMBDA); the single
43// letters (l, d, n, z, q) are the source's own matrix notation.
44#![allow(clippy::doc_markdown, clippy::many_single_char_names)]
45
46use std::fmt;
47
48/// A refusal to reduce a covariance.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum LambdaError {
51    /// The covariance is not square, or its side does not match the float vector's length.
52    DimensionMismatch {
53        /// Entries the covariance carries.
54        covariance: usize,
55        /// Entries the float ambiguity vector carries.
56        ambiguities: usize,
57    },
58    /// The problem has no ambiguities to resolve.
59    Empty,
60    /// The covariance carries a non-finite entry, or the float vector does.
61    NotFinite,
62    /// The covariance is not symmetric to within its own scale.
63    ///
64    /// Checked rather than assumed: the factorisation reads only the lower triangle, so an
65    /// asymmetric input would be silently reinterpreted as the symmetric matrix built from that
66    /// half — a different problem, solved without complaint.
67    NotSymmetric {
68        /// Row of the worst offending pair.
69        row: usize,
70        /// Column of the worst offending pair.
71        column: usize,
72    },
73    /// The covariance is not positive definite, so the ILS objective is not a distance.
74    NotPositiveDefinite {
75        /// The pivot, in elimination order, that came out non-positive.
76        pivot: usize,
77    },
78    /// The enumeration reached [`MAX_SEARCH_NODES`] without exhausting the ellipsoid.
79    SearchExhausted {
80        /// Candidates held when the budget ran out. Fewer than were asked for, and not provably the
81        /// best ones, which is why they are reported as a count rather than returned.
82        found: usize,
83    },
84}
85
86impl fmt::Display for LambdaError {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match *self {
89            Self::DimensionMismatch {
90                covariance,
91                ambiguities,
92            } => write!(
93                f,
94                "a covariance of {covariance} entries does not go with {ambiguities} ambiguities"
95            ),
96            Self::Empty => write!(f, "there are no ambiguities to resolve"),
97            Self::NotFinite => write!(f, "the covariance or the float vector is not finite"),
98            Self::NotSymmetric { row, column } => write!(
99                f,
100                "the covariance is not symmetric between entry ({row}, {column}) and its transpose"
101            ),
102            Self::NotPositiveDefinite { pivot } => write!(
103                f,
104                "the covariance is not positive definite: pivot {pivot} is not positive"
105            ),
106            Self::SearchExhausted { found } => write!(
107                f,
108                "the enumeration visited {MAX_SEARCH_NODES} nodes holding {found} candidates \
109                 without exhausting the ellipsoid"
110            ),
111        }
112    }
113}
114
115impl std::error::Error for LambdaError {}
116
117/// Tree nodes [`Reduction::search`] will visit before it refuses to keep going.
118///
119/// The enumeration terminates for every positive-definite factorisation — it walks outwards from
120/// the conditional float at each level, so the partial cost grows without bound along any branch
121/// and every branch is eventually pruned — but *how many* nodes that takes is a property of the
122/// conditioning, not of the dimension, and a caller whose covariance is badly conditioned has no
123/// other way to be told that the answer is not coming. A search that stops without saying so is
124/// worse than one that refuses.
125///
126/// The value is a budget rather than a bound on anything the mathematics promises, so it is public
127/// and reachable. A unit covariance with every float at a half-integer is the cheapest way to reach
128/// it: every level ties, so both integers either side of each conditional float cost the same and
129/// the ellipsoid never shrinks past them, and the tree doubles with every ambiguity added. Measured
130/// on that family, 18 ambiguities finish inside this budget and 19 exceed it.
131pub const MAX_SEARCH_NODES: usize = 1_000_000;
132
133/// One integer candidate and the value of the ILS objective at it.
134#[derive(Debug, Clone, PartialEq)]
135pub struct Candidate {
136    /// The integer ambiguity vector, in the caller's original parametrisation.
137    pub ambiguities: Vec<i64>,
138    /// `(a − â)ᵀ Q⁻¹ (a − â)` — smaller is better, and the ratio of the second to the first is the
139    /// classical acceptance test.
140    pub squared_norm: f64,
141}
142
143/// A row-major square matrix of the side the reduction was built at.
144#[derive(Debug, Clone, PartialEq)]
145struct Square {
146    n: usize,
147    data: Vec<f64>,
148}
149
150impl Square {
151    fn identity(n: usize) -> Self {
152        let mut data = vec![0.0; n * n];
153        for i in 0..n {
154            data[i * n + i] = 1.0;
155        }
156        Self { n, data }
157    }
158
159    fn at(&self, i: usize, j: usize) -> f64 {
160        self.data[i * self.n + j]
161    }
162
163    fn set(&mut self, i: usize, j: usize, v: f64) {
164        self.data[i * self.n + j] = v;
165    }
166
167    fn swap_columns(&mut self, a: usize, b: usize) {
168        for i in 0..self.n {
169            self.data.swap(i * self.n + a, i * self.n + b);
170        }
171    }
172
173    fn swap_rows(&mut self, a: usize, b: usize) {
174        for j in 0..self.n {
175            self.data.swap(a * self.n + j, b * self.n + j);
176        }
177    }
178}
179
180/// A decorrelated ILS problem: the transformation, the factorisation and the transformed float
181/// vector the search runs on.
182///
183/// Building one is the expensive half of LAMBDA and depends on `Q` alone, so a caller holding `Q`
184/// fixed across epochs — the usual case, since the ambiguity covariance changes far more slowly
185/// than the float estimate — builds it once.
186#[derive(Debug, Clone, PartialEq)]
187pub struct Reduction {
188    n: usize,
189    /// Unit lower triangular `L̄` of `Q_z = L̄ᵀ D̄ L̄`.
190    l: Square,
191    /// Conditional variances `d̄`, strictly positive.
192    d: Vec<f64>,
193    /// The unimodular transformation `Z`, integer-valued.
194    z: Square,
195    /// `Z⁻¹`, integer-valued — carried along rather than recomputed, since every operation applied
196    /// to `Z` has a known inverse and integer matrix inversion after the fact does not.
197    z_inverse: Square,
198    /// `ẑ = Zᵀâ`, the float vector in the decorrelated parametrisation.
199    z_hat: Vec<f64>,
200}
201
202/// `Q = LᵀDL` with `L` unit lower triangular and `D` diagonal — the factorisation LAMBDA is built
203/// on, in which `d[i]` is the variance of `â[i]` conditioned on `a[i+1..n]` being fixed.
204///
205/// Eliminates from the last index backwards, which is what makes the conditioning run in that
206/// direction.
207fn ltdl(q: &[f64], n: usize) -> Result<(Square, Vec<f64>), LambdaError> {
208    let mut work = q.to_vec();
209    let mut l = Square::identity(n);
210    let mut d = vec![0.0; n];
211
212    for i in (0..n).rev() {
213        let pivot = work[i * n + i];
214        if !(pivot.is_finite() && pivot > 0.0) {
215            return Err(LambdaError::NotPositiveDefinite { pivot: i });
216        }
217        d[i] = pivot;
218        for j in 0..=i {
219            l.set(i, j, work[i * n + j] / pivot.sqrt());
220        }
221        for j in 0..i {
222            let scale = l.at(i, j);
223            for k in 0..=j {
224                work[j * n + k] -= l.at(i, k) * scale;
225            }
226        }
227        let diagonal = l.at(i, i);
228        for j in 0..=i {
229            l.set(i, j, l.at(i, j) / diagonal);
230        }
231    }
232    Ok((l, d))
233}
234
235impl Reduction {
236    /// Decorrelate the problem given by the float ambiguities `float` and their covariance
237    /// `covariance` (row-major, `n × n`).
238    ///
239    /// # Errors
240    /// [`LambdaError`] if the dimensions disagree, the problem is empty, an entry is not finite, the
241    /// covariance is not symmetric, or it is not positive definite.
242    pub fn new(float: &[f64], covariance: &[f64]) -> Result<Self, LambdaError> {
243        let n = float.len();
244        if n == 0 {
245            return Err(LambdaError::Empty);
246        }
247        if covariance.len() != n * n {
248            return Err(LambdaError::DimensionMismatch {
249                covariance: covariance.len(),
250                ambiguities: n,
251            });
252        }
253        if !float.iter().chain(covariance).all(|v| v.is_finite()) {
254            return Err(LambdaError::NotFinite);
255        }
256        // Symmetry to the matrix's own scale: an absolute tolerance would reject a covariance in
257        // squared cycles of a long arc and accept one in squared metres that is visibly asymmetric.
258        let scale = covariance.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
259        for i in 0..n {
260            for j in 0..i {
261                let asymmetry = (covariance[i * n + j] - covariance[j * n + i]).abs();
262                if asymmetry > 1e-9 * scale.max(f64::MIN_POSITIVE) {
263                    return Err(LambdaError::NotSymmetric { row: i, column: j });
264                }
265            }
266        }
267
268        let (mut l, mut d) = ltdl(covariance, n)?;
269        let mut z = Square::identity(n);
270        let mut z_inverse = Square::identity(n);
271        let mut z_hat = float.to_vec();
272
273        // Algorithm 2.3: sweep the columns from the second to last towards the first, driving the
274        // sub-diagonal entries below 1/2 and permuting whenever that lowers the conditional
275        // variance of the pair. A permutation restarts the sweep, because it changes the columns to
276        // its right; `settled` records how far the previous sweep got so those columns are not
277        // re-transformed for nothing.
278        // A single ambiguity has no pair to decorrelate: the sweep below starts at `n − 2`, which
279        // does not exist, and the answer is already the rounded float.
280        if n >= 2 {
281            let mut k = n - 2;
282            let mut settled = k;
283            loop {
284                if k <= settled {
285                    for i in k + 1..n {
286                        gauss(&mut l, &mut z, &mut z_inverse, &mut z_hat, i, k);
287                    }
288                }
289                let candidate = d[k] + l.at(k + 1, k) * l.at(k + 1, k) * d[k + 1];
290                if candidate < d[k + 1] {
291                    permute(
292                        &mut l,
293                        &mut d,
294                        &mut z,
295                        &mut z_inverse,
296                        &mut z_hat,
297                        k,
298                        candidate,
299                    );
300                    settled = k;
301                    k = n - 2;
302                } else if k == 0 {
303                    break;
304                } else {
305                    k -= 1;
306                }
307            }
308        }
309
310        Ok(Self {
311            n,
312            l,
313            d,
314            z,
315            z_inverse,
316            z_hat,
317        })
318    }
319
320    /// The number of ambiguities.
321    #[must_use]
322    pub const fn len(&self) -> usize {
323        self.n
324    }
325
326    /// Whether there are no ambiguities. Never true — [`Reduction::new`] refuses an empty problem —
327    /// and present because a type with `len` and without `is_empty` is a trap for the reader.
328    #[must_use]
329    pub const fn is_empty(&self) -> bool {
330        self.n == 0
331    }
332
333    /// The unimodular transformation `Z`, row-major, with `z = Zᵀa`.
334    ///
335    /// Integer-valued by construction: every entry is an exact integer stored in an `f64`, which is
336    /// lossless well past any magnitude a GNSS reduction produces.
337    #[must_use]
338    pub fn transformation(&self) -> &[f64] {
339        &self.z.data
340    }
341
342    /// The conditional variances `d̄` of the decorrelated problem, in index order.
343    ///
344    /// Their spread is what the reduction exists to shrink: the ratio of the largest to the smallest
345    /// is the search's difficulty, and on GNSS covariances the reduction lowers it by orders of
346    /// magnitude.
347    #[must_use]
348    pub fn conditional_variances(&self) -> &[f64] {
349        &self.d
350    }
351
352    /// The bootstrapped (Babai) estimate: round the last ambiguity, condition the rest on it, work
353    /// backwards, and map back through `Z`.
354    ///
355    /// Cheap — no search — and on a reduced problem usually the ILS solution, but not guaranteed to
356    /// be. Use it where an answer is needed within a fixed budget, or as the starting point it is
357    /// for [`Reduction::search`].
358    #[must_use]
359    pub fn bootstrap(&self) -> Candidate {
360        let mut z = vec![0_i64; self.n];
361        let mut conditioned = vec![0.0_f64; self.n];
362        for i in (0..self.n).rev() {
363            conditioned[i] = self.conditional_float(&z, &conditioned, i);
364            z[i] = round_half_away(conditioned[i]);
365        }
366        let squared_norm = self.objective(&z, &conditioned);
367        Candidate {
368            ambiguities: self.back_transform(&z),
369            squared_norm,
370        }
371    }
372
373    /// The `count` best integer vectors, in increasing order of the ILS objective.
374    ///
375    /// The first is the integer least-squares solution. The second is what the standard ratio test
376    /// needs, which is why asking for two is the usual call. Returns fewer than `count` only when
377    /// `count` exceeds the number of integer vectors that exist, which for `n ≥ 1` it does not.
378    ///
379    /// Enumerates in the Schnorr–Euchner order — at every level the nearest integer first, then
380    /// alternating outwards — so the first full vector found is the bootstrapped estimate and the
381    /// ellipsoid can be shrunk to it immediately. Once `count` candidates are held, the bound is the
382    /// worst of them, and the search is exhaustive within that bound: any vector it discards is
383    /// provably worse than every one it kept.
384    ///
385    /// `count` is therefore also a work bound — the ellipsoid stays unbounded until that many
386    /// candidates are held, so asking for a large `count` asks for a large enumeration.
387    ///
388    /// The projections are carried between levels rather than rebuilt at each node, after
389    /// Ghasemmehdi and Agrell (*Faster projection in sphere decoding*, IEEE Trans. Inf. Theory
390    /// **57**(6):3530–3536, 2011, §III-B). A node revisited after a climb costs one multiply–add
391    /// per level the climb passed through instead of a fresh inner product over every level above
392    /// it, which is what LAMBDA 4.0 adopts and where its speed-up at high dimension comes from.
393    ///
394    /// # Errors
395    /// [`LambdaError::SearchExhausted`] if the enumeration visits [`MAX_SEARCH_NODES`] nodes
396    /// without emptying the ellipsoid.
397    pub fn search(&self, count: usize) -> Result<Vec<Candidate>, LambdaError> {
398        if count == 0 {
399            return Ok(Vec::new());
400        }
401        let n = self.n;
402        let mut best: Vec<(Vec<i64>, f64)> = Vec::with_capacity(count + 1);
403        let mut bound = f64::INFINITY;
404
405        let mut z = vec![0_i64; n];
406        let mut conditioned = vec![0.0_f64; n];
407        // `z[i] - conditioned[i]`, held rather than recomputed because the projections below read
408        // it once per level they fold in.
409        let mut offset = vec![0.0_f64; n];
410        // Partial objective accumulated from level `i` upwards, so `partial[i]` is the cost of the
411        // levels above `i` and `partial[0]` plus level 0 is the full objective.
412        let mut partial = vec![0.0_f64; n + 1];
413        // The Schnorr-Euchner state at each level: the current integer and the step that walks
414        // outwards from the conditional float, alternating sign and growing by one each turn.
415        let mut step = vec![0.0_f64; n];
416        // `projected[i * stride + j]` is `Σ_{k≥j} offset[k] · L̄(k, i)`, the part of level `i`'s
417        // conditioning contributed by the levels at or above `j`. Row `n` is the empty sum, so a
418        // column is `n + 1` long, and the column is the contiguous axis because folding walks a
419        // column downwards. The stride is written once and read by both the allocation and the
420        // slicing: a layout stated twice is a layout that can disagree with itself.
421        let stride = n + 1;
422        let mut projected = vec![0.0_f64; n * stride];
423        // The smallest row of each column that still holds. Row `n` always does, so that is where
424        // every column starts and where an invalidated column is measured from.
425        let mut valid = vec![n; n];
426        // Counted down rather than up, so the budget is spent rather than compared against.
427        let mut budget = MAX_SEARCH_NODES;
428
429        let mut level = n - 1;
430        let mut deepest = level;
431        self.enter(
432            level,
433            stride,
434            &mut conditioned,
435            &mut projected,
436            &mut valid,
437            &offset,
438        );
439        z[level] = round_half_away(conditioned[level]);
440        offset[level] = float_of(z[level]) - conditioned[level];
441        step[level] = if offset[level] > 0.0 { -1.0 } else { 1.0 };
442
443        loop {
444            let Some(left) = budget.checked_sub(1) else {
445                return Err(LambdaError::SearchExhausted { found: best.len() });
446            };
447            budget = left;
448            let cost = partial[level + 1] + offset[level] * offset[level] / self.d[level];
449            if cost < bound {
450                if level > 0 {
451                    partial[level] = cost;
452                    level -= 1;
453                    deepest = level;
454                    self.enter(
455                        level,
456                        stride,
457                        &mut conditioned,
458                        &mut projected,
459                        &mut valid,
460                        &offset,
461                    );
462                    z[level] = round_half_away(conditioned[level]);
463                    offset[level] = float_of(z[level]) - conditioned[level];
464                    step[level] = if offset[level] > 0.0 { -1.0 } else { 1.0 };
465                    continue;
466                }
467                best.push((z.clone(), cost));
468                best.sort_by(|a, b| a.1.total_cmp(&b.1));
469                best.truncate(count);
470                if best.len() == count {
471                    bound = best[best.len() - 1].1;
472                }
473                // Fall through to the next integer at level 0. Nothing is conditioned on level 0,
474                // so no column of `projected` goes stale.
475                advance(&mut z, &mut step, level);
476                offset[level] = float_of(z[level]) - conditioned[level];
477            } else {
478                // The enumeration at this level is outward from the conditional float, so every
479                // integer still to come here is at least as far — the level is exhausted.
480                if level == n - 1 {
481                    break;
482                }
483                level += 1;
484                advance(&mut z, &mut step, level);
485                offset[level] = float_of(z[level]) - conditioned[level];
486                invalidate(&mut valid, deepest, level);
487                deepest = level;
488            }
489        }
490
491        Ok(best
492            .into_iter()
493            .map(|(z, squared_norm)| Candidate {
494                ambiguities: self.back_transform(&z),
495                squared_norm,
496            })
497            .collect())
498    }
499
500    /// Bring column `i` of the projections up to date and write level `i`'s conditional float.
501    ///
502    /// Folds in one level per row the column is stale by, which after a descent from the level
503    /// immediately above is a single multiply–add. `conditional_float` computes the same number
504    /// from scratch, and does so in the opposite summation order.
505    fn enter(
506        &self,
507        i: usize,
508        stride: usize,
509        conditioned: &mut [f64],
510        projected: &mut [f64],
511        valid: &mut [usize],
512        offset: &[f64],
513    ) {
514        // The row the column is current from once the fold below has run — the first level that
515        // conditions `i`, the last row the fold writes, and the row the conditional float reads.
516        // One binding for all three, because they are one number.
517        let current = i + 1;
518        let column = &mut projected[i * stride..][..=self.n];
519        for j in (current..valid[i]).rev() {
520            column[j] = column[j + 1] + offset[j] * self.l.at(j, i);
521        }
522        valid[i] = current;
523        conditioned[i] = self.z_hat[i] + column[current];
524    }
525
526    /// `ẑ_i` conditioned on the integers already fixed at levels above `i` (Chang et al. Eqn. 17).
527    fn conditional_float(&self, z: &[i64], conditioned: &[f64], i: usize) -> f64 {
528        let mut acc = self.z_hat[i];
529        for j in i + 1..self.n {
530            acc += (float_of(z[j]) - conditioned[j]) * self.l.at(j, i);
531        }
532        acc
533    }
534
535    /// The ILS objective at a fully fixed integer vector (Chang et al. Eqn. 18).
536    fn objective(&self, z: &[i64], conditioned: &[f64]) -> f64 {
537        (0..self.n)
538            .map(|i| {
539                let r = float_of(z[i]) - conditioned[i];
540                r * r / self.d[i]
541            })
542            .sum()
543    }
544
545    /// `a = Z⁻ᵀ z` — the decorrelated integers back in the caller's parametrisation.
546    fn back_transform(&self, z: &[i64]) -> Vec<i64> {
547        (0..self.n)
548            .map(|i| {
549                let value: f64 = (0..self.n)
550                    .map(|j| self.z_inverse.at(j, i) * float_of(z[j]))
551                    .sum();
552                round_half_away(value)
553            })
554            .collect()
555    }
556}
557
558/// Nearest integer, ties away from zero. `i64` saturating, which no reduced ambiguity approaches.
559#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
560fn round_half_away(x: f64) -> i64 {
561    let r = x.round();
562    if r >= i64::MAX as f64 {
563        i64::MAX
564    } else if r <= i64::MIN as f64 {
565        i64::MIN
566    } else {
567        r as i64
568    }
569}
570
571#[allow(clippy::cast_precision_loss)]
572fn float_of(x: i64) -> f64 {
573    x as f64
574}
575
576/// Take the next integer at `level` in the Schnorr–Euchner order: the conditional float's nearest
577/// integer, then one below, one above, two below, … so the enumeration is outward by distance.
578fn advance(z: &mut [i64], step: &mut [f64], level: usize) {
579    z[level] += round_half_away(step[level]);
580    step[level] = -step[level] - step[level].signum();
581}
582
583/// Mark every column whose conditioning read level `level` as stale, after a climb from `deepest`.
584///
585/// A column at or above row `level + 1` sums only levels the climb did not touch, so it still
586/// holds; anything at or below row `level` folded in the integer that has just moved. The columns
587/// the climb passed through are stale by construction, and the ones below them are stale only
588/// until the first that a previous climb already lifted — below that, `valid` is the flat block
589/// that climb left behind, so the scan can stop.
590fn invalidate(valid: &mut [usize], deepest: usize, level: usize) {
591    for entry in &mut valid[deepest..level] {
592        *entry = level + 1;
593    }
594    for entry in valid[..deepest].iter_mut().rev() {
595        if *entry > level {
596            break;
597        }
598        *entry = level + 1;
599    }
600}
601
602/// Algorithm 2.1: the integer Gauss transformation `Z_ij`, applied to `L`, `Z`, `Z⁻¹` and `ẑ`.
603///
604/// Drives `L(i, j)` into `[−½, ½]` by subtracting an integer multiple of column `i` from column `j`.
605fn gauss(
606    l: &mut Square,
607    z: &mut Square,
608    z_inverse: &mut Square,
609    z_hat: &mut [f64],
610    i: usize,
611    j: usize,
612) {
613    let mu = float_of(round_half_away(l.at(i, j)));
614    if mu == 0.0 {
615        return;
616    }
617    let n = l.n;
618    for k in i..n {
619        l.set(k, j, l.at(k, j) - mu * l.at(k, i));
620    }
621    for k in 0..n {
622        z.set(k, j, z.at(k, j) - mu * z.at(k, i));
623        // Z <- Z·(I − µ eᵢ eⱼᵀ) inverts to Z⁻¹ <- (I + µ eᵢ eⱼᵀ)·Z⁻¹, which is a row operation.
624        z_inverse.set(i, k, z_inverse.at(i, k) + mu * z_inverse.at(j, k));
625    }
626    z_hat[j] -= mu * z_hat[i];
627}
628
629/// Algorithm 2.2: interchange ambiguities `k` and `k + 1`, updating the factorisation in place.
630///
631/// `delta` is the post-swap conditional variance `d̄[k+1]` the caller has already evaluated as the
632/// reason to permute at all.
633fn permute(
634    l: &mut Square,
635    d: &mut [f64],
636    z: &mut Square,
637    z_inverse: &mut Square,
638    z_hat: &mut [f64],
639    k: usize,
640    delta: f64,
641) {
642    let n = l.n;
643    let eta = d[k] / delta;
644    let lambda = d[k + 1] * l.at(k + 1, k) / delta;
645    d[k] = eta * d[k + 1];
646    d[k + 1] = delta;
647
648    for j in 0..k {
649        let (a, b) = (l.at(k, j), l.at(k + 1, j));
650        l.set(k, j, (-l.at(k + 1, k)).mul_add(a, b));
651        l.set(k + 1, j, eta.mul_add(a, lambda * b));
652    }
653    l.set(k + 1, k, lambda);
654    for i in k + 2..n {
655        let (a, b) = (l.at(i, k), l.at(i, k + 1));
656        l.set(i, k, b);
657        l.set(i, k + 1, a);
658    }
659    z.swap_columns(k, k + 1);
660    z_inverse.swap_rows(k, k + 1);
661    z_hat.swap(k, k + 1);
662}
663
664#[cfg(test)]
665mod tests {
666    #![allow(clippy::unwrap_used)]
667    use proptest::prelude::*;
668
669    use super::*;
670
671    /// `Q` rebuilt from a reduction's own factors: `L̄ᵀ D̄ L̄`.
672    fn from_factors(r: &Reduction) -> Vec<f64> {
673        let n = r.n;
674        let mut out = vec![0.0; n * n];
675        for i in 0..n {
676            for j in 0..n {
677                out[i * n + j] = (0..n).map(|k| r.l.at(k, i) * r.d[k] * r.l.at(k, j)).sum();
678            }
679        }
680        out
681    }
682
683    /// `ZᵀQZ` for the reduction's own `Z`.
684    fn transformed(q: &[f64], r: &Reduction) -> Vec<f64> {
685        let n = r.n;
686        let mut out = vec![0.0; n * n];
687        for i in 0..n {
688            for j in 0..n {
689                out[i * n + j] = (0..n)
690                    .map(|a| {
691                        r.z.at(a, i) * (0..n).map(|b| q[a * n + b] * r.z.at(b, j)).sum::<f64>()
692                    })
693                    .sum();
694            }
695        }
696        out
697    }
698
699    /// A three-dimensional covariance with the elongation a real GNSS ambiguity problem has.
700    fn elongated() -> (Vec<f64>, Vec<f64>) {
701        let q = vec![
702            6.290, 5.978, 0.544, //
703            5.978, 6.292, 2.340, //
704            0.544, 2.340, 6.288,
705        ];
706        let float = vec![5.45, 3.10, 2.97];
707        (float, q)
708    }
709
710    /// The factorisation is what it claims: `Q = LᵀDL`, `L` unit lower triangular, `D > 0`.
711    #[test]
712    fn the_factorisation_reproduces_the_matrix() {
713        let (_, q) = elongated();
714        let (l, d) = ltdl(&q, 3).unwrap();
715        for (i, &di) in d.iter().enumerate() {
716            assert!((l.at(i, i) - 1.0).abs() < 1e-12, "unit diagonal at {i}");
717            assert!(di > 0.0, "d[{i}] = {di}");
718            for j in i + 1..3 {
719                assert!(l.at(i, j).abs() < 1e-15, "upper entry ({i}, {j})");
720            }
721        }
722        let mut worst = 0.0_f64;
723        for i in 0..3 {
724            for j in 0..3 {
725                let rebuilt: f64 = (0..3).map(|k| l.at(k, i) * d[k] * l.at(k, j)).sum();
726                worst = worst.max((rebuilt - q[i * 3 + j]).abs());
727            }
728        }
729        assert!(worst < 1e-12, "LᵀDL residual {worst:.3e}");
730    }
731
732    /// The reduction's three defining properties, each of which a wrong bookkeeping step breaks on
733    /// its own: `Z` is unimodular, `Q_z = ZᵀQZ` really is what the stored factors factorise, and the
734    /// off-diagonal entries of `L̄` are bounded by ½.
735    #[test]
736    fn the_reduction_is_unimodular_consistent_and_bounded() {
737        let (float, q) = elongated();
738        let r = Reduction::new(&float, &q).unwrap();
739
740        // Integer entries, and det Z = ±1 by the 3x3 rule.
741        for v in r.transformation() {
742            assert!((v - v.round()).abs() < 1e-12, "non-integer Z entry {v}");
743        }
744        let z = |i: usize, j: usize| r.z.at(i, j);
745        let det = z(0, 0) * (z(1, 1) * z(2, 2) - z(1, 2) * z(2, 1))
746            - z(0, 1) * (z(1, 0) * z(2, 2) - z(1, 2) * z(2, 0))
747            + z(0, 2) * (z(1, 0) * z(2, 1) - z(1, 1) * z(2, 0));
748        assert!((det.abs() - 1.0).abs() < 1e-9, "det Z = {det}");
749
750        // Z·Z⁻¹ = I, which is what back-transforming a candidate depends on.
751        for i in 0..3 {
752            for j in 0..3 {
753                let entry: f64 = (0..3).map(|k| r.z.at(i, k) * r.z_inverse.at(k, j)).sum();
754                let want = f64::from(u8::from(i == j));
755                assert!((entry - want).abs() < 1e-9, "ZZ⁻¹ ({i}, {j}) = {entry}");
756            }
757        }
758
759        // The stored factors factorise the transformed covariance, not something adjacent to it.
760        let want = transformed(&q, &r);
761        let got = from_factors(&r);
762        let worst = want
763            .iter()
764            .zip(&got)
765            .fold(0.0_f64, |m, (a, b)| m.max((a - b).abs()));
766        assert!(worst < 1e-9, "ZᵀQZ against L̄ᵀD̄L̄: {worst:.3e}");
767
768        // Bounded off-diagonals — the point of the integer Gauss transformations.
769        for i in 0..3 {
770            for j in 0..i {
771                assert!(
772                    r.l.at(i, j).abs() <= 0.5 + 1e-12,
773                    "L̄({i}, {j}) = {}",
774                    r.l.at(i, j)
775                );
776            }
777        }
778    }
779
780    /// Decorrelation is the whole justification for the transformation, so it is measured rather
781    /// than asserted in prose.
782    ///
783    /// On the covariance above the conditional-variance spread `max(d)/min(d)` falls from
784    /// **6.9977e1 to 6.8852e0**, a factor of 10.2 — that ratio is what sets how many integer
785    /// candidates the search has to visit.
786    #[test]
787    fn the_reduction_flattens_the_conditional_variances() {
788        let (float, q) = elongated();
789        let (_, before) = ltdl(&q, 3).unwrap();
790        let r = Reduction::new(&float, &q).unwrap();
791        let after = r.conditional_variances();
792
793        let spread = |d: &[f64]| {
794            let hi = d.iter().fold(f64::MIN, |m, v| m.max(*v));
795            let lo = d.iter().fold(f64::MAX, |m, v| m.min(*v));
796            hi / lo
797        };
798        let (was, now) = (spread(&before), spread(after));
799        assert!(
800            now < was / 5.0,
801            "spread {was:.4e} -> {now:.4e} is not a decorrelation"
802        );
803        assert!((was - 6.9977e1).abs() < 1e-2, "before {was:.4e}");
804        assert!((now - 6.8852e0).abs() < 1e-3, "after {now:.4e}");
805    }
806
807    /// A diagonal covariance is the one case where the answer is known without a search: the
808    /// ambiguities are independent, so integer least squares *is* rounding, and the reduction must
809    /// not move the answer.
810    #[test]
811    fn a_diagonal_covariance_resolves_to_the_rounded_float() {
812        let float = vec![1.4, -2.6, 0.5, 7.49];
813        let q = vec![
814            0.20, 0.0, 0.0, 0.0, //
815            0.0, 0.11, 0.0, 0.0, //
816            0.0, 0.0, 0.37, 0.0, //
817            0.0, 0.0, 0.0, 0.05,
818        ];
819        let r = Reduction::new(&float, &q).unwrap();
820        let best = r.search(1).unwrap();
821        assert_eq!(best[0].ambiguities, vec![1, -3, 1, 7]);
822        assert_eq!(r.bootstrap().ambiguities, vec![1, -3, 1, 7]);
823    }
824
825    /// An integer float vector is already the solution, and the objective there is exactly zero —
826    /// the one value in the whole method that admits no tolerance.
827    #[test]
828    fn an_integer_float_vector_resolves_to_itself() {
829        let (_, q) = elongated();
830        let float = vec![4.0, -7.0, 11.0];
831        let r = Reduction::new(&float, &q).unwrap();
832        let best = r.search(2).unwrap();
833        assert_eq!(best[0].ambiguities, vec![4, -7, 11]);
834        assert!(
835            best[0].squared_norm.abs() < 1e-18,
836            "{}",
837            best[0].squared_norm
838        );
839        assert!(
840            best[1].squared_norm > 0.0,
841            "the second candidate is the first"
842        );
843    }
844
845    /// The candidates come back ordered, and the objective reported for each is the objective of
846    /// the vector reported with it — computed here in the *original* parametrisation from `Q⁻¹`,
847    /// which the search never forms, so agreement is a check on the transformation and not on
848    /// arithmetic reused from it.
849    #[test]
850    fn the_reported_objective_is_the_objective_in_the_original_problem() {
851        let (float, q) = elongated();
852        let r = Reduction::new(&float, &q).unwrap();
853        let candidates = r.search(5).unwrap();
854        assert_eq!(candidates.len(), 5);
855
856        // Q⁻¹ by Gauss-Jordan on the 3x3, independent of the reduction's factorisation.
857        let inverse = invert3(&q);
858        let mut previous = f64::NEG_INFINITY;
859        for c in &candidates {
860            let residual: Vec<f64> = (0..3)
861                .map(|i| float_of(c.ambiguities[i]) - float[i])
862                .collect();
863            let quadratic: f64 = (0..3)
864                .map(|i| {
865                    residual[i]
866                        * (0..3)
867                            .map(|j| inverse[i * 3 + j] * residual[j])
868                            .sum::<f64>()
869                })
870                .sum();
871            assert!(
872                (quadratic - c.squared_norm).abs() < 1e-9 * quadratic.max(1.0),
873                "{:?}: {quadratic} vs {}",
874                c.ambiguities,
875                c.squared_norm
876            );
877            assert!(c.squared_norm >= previous, "candidates out of order");
878            previous = c.squared_norm;
879        }
880    }
881
882    fn invert3(q: &[f64]) -> Vec<f64> {
883        let m = |i: usize, j: usize| q[i * 3 + j];
884        let c = [
885            m(1, 1) * m(2, 2) - m(1, 2) * m(2, 1),
886            m(0, 2) * m(2, 1) - m(0, 1) * m(2, 2),
887            m(0, 1) * m(1, 2) - m(0, 2) * m(1, 1),
888            m(1, 2) * m(2, 0) - m(1, 0) * m(2, 2),
889            m(0, 0) * m(2, 2) - m(0, 2) * m(2, 0),
890            m(0, 2) * m(1, 0) - m(0, 0) * m(1, 2),
891            m(1, 0) * m(2, 1) - m(1, 1) * m(2, 0),
892            m(0, 1) * m(2, 0) - m(0, 0) * m(2, 1),
893            m(0, 0) * m(1, 1) - m(0, 1) * m(1, 0),
894        ];
895        let det = m(0, 0) * c[0] + m(0, 1) * c[3] + m(0, 2) * c[6];
896        c.iter().map(|v| v / det).collect()
897    }
898
899    #[test]
900    fn a_covariance_it_cannot_use_is_refused() {
901        assert_eq!(Reduction::new(&[], &[]), Err(LambdaError::Empty));
902        assert_eq!(
903            Reduction::new(&[1.0, 2.0], &[1.0, 0.0, 0.0]),
904            Err(LambdaError::DimensionMismatch {
905                covariance: 3,
906                ambiguities: 2,
907            })
908        );
909        assert_eq!(
910            Reduction::new(&[f64::NAN], &[1.0]),
911            Err(LambdaError::NotFinite)
912        );
913        assert_eq!(
914            Reduction::new(&[1.0, 2.0], &[1.0, 0.5, 0.4, 1.0]),
915            Err(LambdaError::NotSymmetric { row: 1, column: 0 })
916        );
917        // Symmetric, but indefinite: the off-diagonal exceeds the geometric mean of the diagonal.
918        assert_eq!(
919            Reduction::new(&[1.0, 2.0], &[1.0, 2.0, 2.0, 1.0]),
920            Err(LambdaError::NotPositiveDefinite { pivot: 0 })
921        );
922        assert!(r_messages_are_distinct());
923    }
924
925    fn r_messages_are_distinct() -> bool {
926        let messages: Vec<String> = [
927            LambdaError::Empty,
928            LambdaError::DimensionMismatch {
929                covariance: 3,
930                ambiguities: 2,
931            },
932            LambdaError::NotFinite,
933            LambdaError::NotSymmetric { row: 1, column: 0 },
934            LambdaError::NotPositiveDefinite { pivot: 0 },
935            LambdaError::SearchExhausted { found: 1 },
936        ]
937        .iter()
938        .map(ToString::to_string)
939        .collect();
940        let distinct: std::collections::HashSet<&String> = messages.iter().collect();
941        distinct.len() == messages.len() && messages.iter().all(|m| !m.is_empty())
942    }
943
944    /// Asking for nothing returns nothing rather than searching for it.
945    #[test]
946    fn a_zero_candidate_search_is_empty() {
947        let (float, q) = elongated();
948        assert_eq!(
949            Reduction::new(&float, &q).unwrap().search(0).unwrap().len(),
950            0
951        );
952    }
953
954    /// A zero on the diagonal is not positive definite, and the refusal has to be strict.
955    ///
956    /// `pivot > 0.0` and `pivot >= 0.0` differ on exactly one input, and it is the one that matters:
957    /// at `pivot == 0.0` the accepting form stores `d[i] = 0`, divides the whole row by `sqrt(0)` and
958    /// hands the search a factorisation of infinities. A zero variance is not a contrived input
959    /// either — it is what a caller writes for an ambiguity it considers already fixed.
960    #[test]
961    fn a_zero_variance_is_refused_rather_than_factorised() {
962        assert_eq!(
963            Reduction::new(&[1.0, 2.0], &[1.0, 0.0, 0.0, 0.0]),
964            Err(LambdaError::NotPositiveDefinite { pivot: 1 })
965        );
966        // The sweep runs from the last index down, so the index named is the last row reached, and
967        // with both variances zero it is still 1 rather than 0.
968        assert_eq!(
969            Reduction::new(&[1.0, 2.0], &[0.0, 0.0, 0.0, 0.0]),
970            Err(LambdaError::NotPositiveDefinite { pivot: 1 })
971        );
972    }
973
974    /// The bootstrapped objective, against the quadratic form written out by hand.
975    ///
976    /// A diagonal covariance is the one case that needs no factorisation to check: the reduction
977    /// leaves it alone, so the conditional floats *are* the input floats and the objective is
978    /// `Σ (round(fᵢ) − fᵢ)² / qᵢᵢ`, formed here from the inputs rather than from anything the module
979    /// produced. What existed before asserted the integers `bootstrap` returns and, in the
980    /// integration test, only that its objective is no *better* than the optimum — an inequality wide
981    /// enough to survive any mutation that makes the number larger.
982    #[test]
983    fn the_bootstrapped_objective_is_the_quadratic_form_at_the_rounded_float() {
984        let float = [1.4, -2.6, 0.5, 7.49];
985        let variance = [0.20, 0.11, 0.37, 0.05];
986        let mut q = vec![0.0; 16];
987        for (i, v) in variance.iter().enumerate() {
988            q[i * 4 + i] = *v;
989        }
990        let candidate = Reduction::new(&float, &q).unwrap().bootstrap();
991        assert_eq!(candidate.ambiguities, vec![1, -3, 1, 7]);
992
993        let want: f64 = (0..4)
994            .map(|i| {
995                let residual = float[i].round() - float[i];
996                residual * residual / variance[i]
997            })
998            .sum();
999        // 0.8 + 1.45454… + 0.67567… + 4.802
1000        assert!((want - 7.732_221_130_221_133).abs() < 1e-15, "{want}");
1001        assert!(
1002            (candidate.squared_norm - want).abs() < 1e-15,
1003            "{} vs {want}",
1004            candidate.squared_norm
1005        );
1006    }
1007
1008    /// The enumeration leaves each level's conditional float in one direction first, and which one is
1009    /// observable exactly where the conditional float is an integer.
1010    ///
1011    /// `float_of(z) > conditioned` chooses the first step's sign, and against `>=` the two differ on
1012    /// one input: equality. That is not a measure-zero curiosity here. An integer float vector makes
1013    /// `ẑ = Zᵀa` an exact integer vector — `Z` is unimodular with exactly-representable entries — and
1014    /// at the solution every residual above a level is exactly zero, so *every* level ties. The two
1015    /// neighbours of the solution then cost the same to the last bit, and which one the search
1016    /// reports second is decided by the step's sign and by nothing else: the sort is stable, so it is
1017    /// the push order that survives into the answer.
1018    #[test]
1019    fn the_enumeration_walks_outwards_from_the_conditional_float() {
1020        let (_, q) = elongated();
1021        let r = Reduction::new(&[4.0, -7.0, 11.0], &q).unwrap();
1022        assert_eq!(r.len(), 3);
1023
1024        // Fourteen, and the depth is the measurement rather than a margin. The sign is set at two
1025        // places: once per descent for every level below the top, and exactly once before the loop
1026        // for the top itself. The first speaks immediately — the two runners-up below are a ± pair
1027        // and their order is its doing. The second says nothing until the search has exhausted the
1028        // top level's first integer and climbed back, which on this covariance takes thirteen
1029        // candidates; asking for five leaves that statement unread. Measured: flipping the top
1030        // level's initialisation alone changes candidate 13 from `[4, -8, 8]` to `[4, -6, 14]`, its
1031        // ± partner at the identical cost 1.638185311189144e0, and changes nothing before it.
1032        let found = r.search(14).unwrap();
1033        let want = [
1034            (0, vec![4, -7, 11]),
1035            (1, vec![5, -6, 11]),
1036            (2, vec![3, -8, 11]),
1037            (3, vec![5, -6, 12]),
1038            (4, vec![3, -8, 10]),
1039            (13, vec![4, -8, 8]),
1040        ];
1041        for (index, want) in &want {
1042            assert_eq!(&found[*index].ambiguities, want, "candidate {index}");
1043        }
1044        // Each pair ties to the last bit, which is what leaves its order to the step's sign alone.
1045        for pair in [(1, 2), (3, 4), (11, 12)] {
1046            assert_eq!(
1047                found[pair.0].squared_norm.to_bits(),
1048                found[pair.1].squared_norm.to_bits(),
1049                "candidates {pair:?} must tie: {} vs {}",
1050                found[pair.0].squared_norm,
1051                found[pair.1].squared_norm
1052            );
1053        }
1054    }
1055
1056    /// One ambiguity is a degenerate case the loops must still handle: the reduction sweep starts at
1057    /// `n − 2`, which for `n = 1` does not exist.
1058    #[test]
1059    fn a_single_ambiguity_resolves() {
1060        let r = Reduction::new(&[3.4], &[0.25]).unwrap();
1061        assert_eq!(r.len(), 1);
1062        assert!(!r.is_empty());
1063        assert_eq!(r.search(1).unwrap()[0].ambiguities, vec![3]);
1064        // (3 − 3.4)² / 0.25 = 0.64
1065        assert!((r.search(1).unwrap()[0].squared_norm - 0.64).abs() < 1e-12);
1066    }
1067
1068    /// The enumeration as it was written before the conditioning was carried down the tree: every
1069    /// conditional float recomputed from scratch out of the integers fixed above it.
1070    ///
1071    /// This is the oracle for [`Reduction::search`] and not a second implementation of it. It walks
1072    /// the identical tree in the identical order and differs in one thing only — where each
1073    /// conditional float comes from — so any disagreement is a disagreement about the bookkeeping,
1074    /// which is the whole of what the rewrite changed.
1075    fn recomputed_search(r: &Reduction, count: usize) -> Vec<Candidate> {
1076        let n = r.n;
1077        // The same budget the method under test carries. An oracle without one is a way for a
1078        // mutation of the shared code beneath both to stop the suite instead of failing it, and a
1079        // wall clock cannot tell those apart.
1080        let mut budget = MAX_SEARCH_NODES;
1081        let mut best: Vec<(Vec<i64>, f64)> = Vec::with_capacity(count + 1);
1082        let mut bound = f64::INFINITY;
1083        let mut z = vec![0_i64; n];
1084        let mut conditioned = vec![0.0_f64; n];
1085        let mut partial = vec![0.0_f64; n + 1];
1086        let mut step = vec![0.0_f64; n];
1087
1088        let mut level = n - 1;
1089        conditioned[level] = r.conditional_float(&z, &conditioned, level);
1090        z[level] = round_half_away(conditioned[level]);
1091        step[level] = if float_of(z[level]) > conditioned[level] {
1092            -1.0
1093        } else {
1094            1.0
1095        };
1096
1097        loop {
1098            assert!(budget > 0, "the recomputing oracle outran the node budget");
1099            budget -= 1;
1100            let residual = float_of(z[level]) - conditioned[level];
1101            let cost = partial[level + 1] + residual * residual / r.d[level];
1102            if cost < bound {
1103                if level > 0 {
1104                    partial[level] = cost;
1105                    level -= 1;
1106                    conditioned[level] = r.conditional_float(&z, &conditioned, level);
1107                    z[level] = round_half_away(conditioned[level]);
1108                    step[level] = if float_of(z[level]) > conditioned[level] {
1109                        -1.0
1110                    } else {
1111                        1.0
1112                    };
1113                    continue;
1114                }
1115                best.push((z.clone(), cost));
1116                best.sort_by(|a, b| a.1.total_cmp(&b.1));
1117                best.truncate(count);
1118                if best.len() == count {
1119                    bound = best[best.len() - 1].1;
1120                }
1121                advance(&mut z, &mut step, level);
1122            } else {
1123                if level == n - 1 {
1124                    break;
1125                }
1126                level += 1;
1127                advance(&mut z, &mut step, level);
1128            }
1129        }
1130
1131        best.into_iter()
1132            .map(|(z, squared_norm)| Candidate {
1133                ambiguities: r.back_transform(&z),
1134                squared_norm,
1135            })
1136            .collect()
1137    }
1138
1139    /// `Q = B Bᵀ + δI`, positive definite by construction and elongated by a column every row
1140    /// shares — without that the draw is nearly isotropic, rounding solves it, and the search never
1141    /// climbs far enough to leave a projection stale.
1142    fn covariance_from(entries: &[f64], n: usize, elongation: f64) -> Vec<f64> {
1143        let mut b = vec![0.0_f64; n * n];
1144        b.copy_from_slice(&entries[..n * n]);
1145        let shared = &entries[n * n..n * n + n];
1146        for i in 0..n {
1147            for j in 0..n {
1148                b[i * n + j] += elongation * shared[j];
1149            }
1150        }
1151        let mut q = vec![0.0_f64; n * n];
1152        for i in 0..n {
1153            for j in 0..n {
1154                q[i * n + j] = (0..n).map(|k| b[i * n + k] * b[j * n + k]).sum();
1155            }
1156            q[i * n + i] += 0.05;
1157        }
1158        q
1159    }
1160
1161    proptest! {
1162        /// The carried projections against recomputation from scratch, candidate for candidate.
1163        ///
1164        /// The rewrite's risk is not that a conditional float comes out wrong once — it is that a
1165        /// column of projections goes stale, which needs the tree to descend, climb several levels
1166        /// and descend again before it is observable at all. A fixture reaches that by luck; a
1167        /// sweep over elongated covariances reaches it by construction, and the levels below the
1168        /// climb are exactly the ones `invalidate`'s second loop decides about.
1169        ///
1170        /// The two summation orders differ — scratch sums a column downwards from the top level,
1171        /// recomputation sums it upwards from the level below — so the objectives are compared to a
1172        /// tolerance rather than bit-for-bit. The integer vectors are not: those are the answer.
1173        #[test]
1174        fn the_carried_projections_reproduce_the_recomputed_search(
1175            n in 2_usize..=10,
1176            entries in prop::collection::vec(-1.0_f64..1.0, 110),
1177            floats in prop::collection::vec(-20.0_f64..20.0, 10),
1178            count in 1_usize..=6,
1179        ) {
1180            let q = covariance_from(&entries, n, 2.5);
1181            let Ok(r) = Reduction::new(&floats[..n], &q) else {
1182                return Ok(());
1183            };
1184            let got = r.search(count).unwrap();
1185            let want = recomputed_search(&r, count);
1186            prop_assert_eq!(got.len(), want.len(), "candidate count");
1187            for (i, (a, b)) in got.iter().zip(&want).enumerate() {
1188                prop_assert_eq!(
1189                    &a.ambiguities, &b.ambiguities,
1190                    "candidate {} differs: {:?} against {:?}", i, a, b
1191                );
1192                let scale = b.squared_norm.abs().max(1.0);
1193                prop_assert!(
1194                    (a.squared_norm - b.squared_norm).abs() < 1e-9 * scale,
1195                    "candidate {} objective {} against {}", i, a.squared_norm, b.squared_norm
1196                );
1197            }
1198        }
1199    }
1200
1201    /// The node budget is a number a caller can reach, and the refusal names what it was holding.
1202    ///
1203    /// A unit covariance with every float at a half-integer ties at every level: both integers
1204    /// either side of each conditional float cost the same, so the first candidate found never
1205    /// shrinks the ellipsoid past its own siblings and the tree doubles with each ambiguity. The
1206    /// two dimensions here sit either side of [`MAX_SEARCH_NODES`], which is what makes the
1207    /// constant's *value* observable rather than merely its existence — #315's rule, that a limit
1208    /// no test can reach is a limit nothing has checked the sense of.
1209    #[test]
1210    fn the_node_budget_is_reachable_and_refuses_rather_than_truncates() {
1211        let tied = |n: usize| {
1212            let mut q = vec![0.0_f64; n * n];
1213            for i in 0..n {
1214                q[i * n + i] = 1.0;
1215            }
1216            Reduction::new(&vec![0.5_f64; n], &q).unwrap().search(1)
1217        };
1218        assert_eq!(tied(18).unwrap().len(), 1);
1219        assert_eq!(tied(19), Err(LambdaError::SearchExhausted { found: 1 }));
1220    }
1221
1222    /// The reduced conditioning against recomputation from scratch, in wall-clock seconds.
1223    ///
1224    /// `#[ignore]`d because it is a measurement and not a check: a timing assertion on a shared
1225    /// machine is a flake generator, and the number belongs in the doc comment where it can be
1226    /// read. Run it with `cargo test -p spacelab-gnss --release --lib the_reduced_conditioning --
1227    /// --ignored --nocapture`.
1228    ///
1229    /// Measured, alternating A-B-A-B over four rounds so startup drift cannot be mistaken for the
1230    /// effect, on random elongated covariances at `count = 1`, µs per search:
1231    ///
1232    /// | `n` | carried | recomputed | ratio |
1233    /// |----:|--------:|-----------:|------:|
1234    /// | 5   |    0.32 |       0.24 | 0.73  |
1235    /// | 10  |    0.67 |       0.48 | 0.71  |
1236    /// | 20  |    39.0 |       35.2 | 0.90  |
1237    /// | 30  |   1 174 |      1 292 | 1.10  |
1238    /// | 40  |  17 141 |     19 531 | 1.14  |
1239    ///
1240    /// The two sub-microsecond rows move by ±0.06 in the ratio between runs, which is the timer and
1241    /// not the algorithm; the rows from `n = 20` up repeat to three figures. At `n = 50` this draw
1242    /// no longer fits inside [`MAX_SEARCH_NODES`], which is the other half of the same measurement:
1243    /// what grows with the dimension is the tree, and the per-node saving is second-order against
1244    /// it.
1245    ///
1246    /// So the crossover is near thirty ambiguities, and the vendor's "~20× at dimension 200" —
1247    /// measured on their MATLAB, where every saved flop is an interpreted operation — does not
1248    /// survive translation into compiled code. The reason is visible in the two loops: the flops
1249    /// the method saves are in `conditional_float`'s summation, which is contiguous and which the
1250    /// compiler vectorises, and it replaces them with a serial recurrence that it cannot. What is
1251    /// left is a real but modest win exactly where the wall clock is — at `n = 40` the saving is
1252    /// 2.4 ms per search — and a loss of a few hundred nanoseconds where a search costs a
1253    /// microsecond anyway.
1254    #[test]
1255    #[ignore = "a measurement, not a check: see the table in the doc comment"]
1256    #[expect(
1257        clippy::cast_precision_loss,
1258        reason = "a draw in [-1, 1) and a repetition count, neither near 2^53"
1259    )]
1260    fn the_reduced_conditioning_pays_above_thirty_ambiguities() {
1261        fn splitmix(s: &mut u64) -> f64 {
1262            *s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
1263            let mut z = *s;
1264            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1265            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1266            let u = z ^ (z >> 31);
1267            (u >> 11) as f64 / (1_u64 << 53) as f64 * 2.0 - 1.0
1268        }
1269        for n in [5_usize, 10, 20, 30, 40, 50, 60, 70, 80] {
1270            let mut s = 0x5EED_0000_0000_0001_u64;
1271            let entries: Vec<f64> = (0..n * n + n).map(|_| splitmix(&mut s)).collect();
1272            let q = covariance_from(&entries, n, 0.6);
1273            let floats: Vec<f64> = (0..n).map(|_| splitmix(&mut s) * 20.0).collect();
1274            let r = Reduction::new(&floats, &q).unwrap();
1275            if r.search(1).is_err() {
1276                println!("n={n:3}  the ellipsoid outgrew the node budget");
1277                break;
1278            }
1279            let reps = 20_usize.max(2000 / n);
1280            let mut ta = std::time::Duration::ZERO;
1281            let mut tb = std::time::Duration::ZERO;
1282            let mut rounds = 0;
1283            for _ in 0..4 {
1284                let t = std::time::Instant::now();
1285                for _ in 0..reps {
1286                    std::hint::black_box(r.search(1).unwrap());
1287                }
1288                ta += t.elapsed();
1289                let t = std::time::Instant::now();
1290                for _ in 0..reps {
1291                    std::hint::black_box(recomputed_search(&r, 1));
1292                }
1293                tb += t.elapsed();
1294                rounds += reps;
1295            }
1296            let a = ta.as_secs_f64() / rounds as f64;
1297            let b = tb.as_secs_f64() / rounds as f64;
1298            println!(
1299                "n={n:3}  new {:9.3} us   old {:9.3} us   x{:.2}",
1300                a * 1e6,
1301                b * 1e6,
1302                b / a
1303            );
1304        }
1305    }
1306}