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}