rigidity_core/linalg/tsqr.rs
1//! Tall-skinny QR by Givens rotations.
2//!
3//! # Why
4//!
5//! Moving to the normal equations squares the condition number:
6//! `κ(JᵀJ) = κ(J)²`. This project is entirely about the small singular
7//! values, and forming `H` destroys those first. At `κ(J) = 10⁷` — the
8//! ceiling imposed by storing points as `f32` — we get `κ(H) = 10¹⁴`,
9//! already comparable to the resolution of `f64`.
10//!
11//! `J = QR` with orthogonal `Q` gives `σᵢ(J) = σᵢ(R)` exactly, and `R` is
12//! only 6×6 (or 7×7 with an appended residual column). The singular values
13//! then come from `R`, and all available accuracy survives.
14//!
15//! # Why Givens rather than Householder
16//!
17//! Rows arrive one at a time, and a rotation folds a row into the triangle
18//! in place: no buffer for all `N` rows, constant memory, strictly
19//! streaming traversal. Backward stability is the same for both methods.
20//!
21//! The same "absorb a row" primitive also merges two triangles in the
22//! reduction tree — feed one's rows to the other.
23
24use rayon::prelude::*;
25
26/// How many rows one block handles.
27///
28/// Block boundaries depend only on this constant and the row count.
29/// Neither the thread count nor the scheduler's split affects the result.
30const CHUNK: usize = 4_096;
31
32/// The upper-triangular factor `R` of the decomposition `J = QR`.
33///
34/// `Q` is neither stored nor computed: the spectrum does not need it, and
35/// for the least-squares solve it is enough to append a residual column to
36/// `J` — then `Qᵀe` falls out of the same rotations.
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct Tsqr<const K: usize> {
39 triangle: [[f64; K]; K],
40}
41
42impl<const K: usize> Default for Tsqr<K> {
43 fn default() -> Self {
44 Self::new()
45 }
46}
47
48impl<const K: usize> Tsqr<K> {
49 /// An empty accumulator.
50 pub fn new() -> Self {
51 Self {
52 triangle: [[0.0; K]; K],
53 }
54 }
55
56 /// The upper-triangular matrix `R`, row by row.
57 pub fn triangle(&self) -> &[[f64; K]; K] {
58 &self.triangle
59 }
60
61 /// Folds a row into the triangle using Givens rotations.
62 ///
63 /// For each column a rotation is built that zeroes the next element of
64 /// the row. The radius comes from [`f64::hypot`], which neither
65 /// overflows on large values nor loses accuracy on small ones, unlike
66 /// `(a*a + b*b).sqrt()`.
67 pub fn absorb_row(&mut self, row: &[f64; K]) {
68 let mut row = *row;
69 for column in 0..K {
70 let lower = row[column];
71 if lower == 0.0 {
72 continue;
73 }
74 let upper = self.triangle[column][column];
75 let radius = upper.hypot(lower);
76 if radius == 0.0 {
77 continue;
78 }
79 let cosine = upper / radius;
80 let sine = lower / radius;
81
82 self.triangle[column][column] = radius;
83 row[column] = 0.0;
84 let tail = column + 1;
85 for (above, below) in self.triangle[column][tail..]
86 .iter_mut()
87 .zip(row[tail..].iter_mut())
88 {
89 let upper_value = *above;
90 let lower_value = *below;
91 *above = cosine * upper_value + sine * lower_value;
92 *below = cosine * lower_value - sine * upper_value;
93 }
94 }
95 }
96
97 /// Merges another triangle into this one.
98 pub fn merge(&mut self, other: &Self) {
99 for row in &other.triangle {
100 self.absorb_row(row);
101 }
102 }
103}
104
105/// Assembles `R` from the rows returned by `row`.
106///
107/// `row(i)` returns `None` for rows to skip — rejected correspondences,
108/// for instance.
109///
110/// # Determinism
111///
112/// The reduction tree is fully specified: blocks are cut by a fixed
113/// internal constant, rows within a block are folded in increasing index
114/// order, and blocks merge through a balanced binary tree in a fixed
115/// order. The result is bit-for-bit identical at any thread count.
116///
117/// A balanced tree rather than a sequential fold: error accumulation grows
118/// as `log P` for the former and as `P` for the latter.
119pub fn reduce<const K: usize, F>(count: usize, row: F) -> Tsqr<K>
120where
121 F: Fn(usize) -> Option<[f64; K]> + Sync,
122{
123 if count == 0 {
124 return Tsqr::new();
125 }
126
127 let chunks = count.div_ceil(CHUNK);
128 let mut level: Vec<Tsqr<K>> = (0..chunks)
129 .into_par_iter()
130 .map(|chunk| {
131 let begin = chunk * CHUNK;
132 let end = ((chunk + 1) * CHUNK).min(count);
133 let mut accumulator = Tsqr::new();
134 for index in begin..end {
135 if let Some(values) = row(index) {
136 accumulator.absorb_row(&values);
137 }
138 }
139 accumulator
140 })
141 .collect();
142
143 while level.len() > 1 {
144 level = level
145 .par_chunks(2)
146 .map(|pair| {
147 let mut left = pair[0];
148 if let Some(right) = pair.get(1) {
149 left.merge(right);
150 }
151 left
152 })
153 .collect();
154 }
155 level[0]
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 /// The Frobenius norm is invariant under orthogonal transformations,
163 /// so `‖R‖_F` must equal `‖J‖_F`.
164 ///
165 /// This checks the orthogonality of `Q` without ever computing it: had
166 /// the rotations been built wrongly, the norm would not survive.
167 #[test]
168 fn frobenius_norm_is_preserved() {
169 let mut state = 12_345u64;
170 let mut next = || {
171 state = state
172 .wrapping_mul(6_364_136_223_846_793_005)
173 .wrapping_add(1_442_695_040_888_963_407);
174 ((state >> 11) as f64) / ((1u64 << 53) as f64) * 2.0 - 1.0
175 };
176
177 let rows: Vec<[f64; 6]> = (0..5_000)
178 .map(|_| [next(), next(), next(), next(), next(), next()])
179 .collect();
180 let source_norm: f64 = rows
181 .iter()
182 .flat_map(|row| row.iter())
183 .map(|v| v * v)
184 .sum::<f64>()
185 .sqrt();
186
187 let result = reduce::<6, _>(rows.len(), |i| Some(rows[i]));
188 let triangle_norm: f64 = result
189 .triangle()
190 .iter()
191 .flat_map(|row| row.iter())
192 .map(|v| v * v)
193 .sum::<f64>()
194 .sqrt();
195
196 let relative = (triangle_norm - source_norm).abs() / source_norm;
197 assert!(relative < 1e-14, "‖R‖_F differs by {relative:.3e}");
198 }
199
200 /// `R` is upper triangular.
201 #[test]
202 fn result_is_upper_triangular() {
203 let rows: Vec<[f64; 6]> = (0..100)
204 .map(|i| {
205 let t = i as f64;
206 [t, t * 0.5, -t, 1.0, t * t * 0.01, 3.0]
207 })
208 .collect();
209 let result = reduce::<6, _>(rows.len(), |i| Some(rows[i]));
210 for (i, row) in result.triangle().iter().enumerate() {
211 for value in row.iter().take(i) {
212 assert_eq!(*value, 0.0, "an element below the diagonal is non-zero");
213 }
214 }
215 }
216
217 /// Skipping rows really skips them.
218 #[test]
219 fn skipped_rows_do_not_contribute() {
220 let rows: Vec<[f64; 6]> = (0..1_000)
221 .map(|i| [i as f64, 1.0, 2.0, 3.0, 4.0, 5.0])
222 .collect();
223 let all = reduce::<6, _>(rows.len(), |i| Some(rows[i]));
224 let even = reduce::<6, _>(rows.len(), |i| (i % 2 == 0).then_some(rows[i]));
225 assert_ne!(all, even);
226
227 let only_even: Vec<[f64; 6]> = rows.iter().step_by(2).copied().collect();
228 let direct = reduce::<6, _>(only_even.len(), |i| Some(only_even[i]));
229 assert_eq!(even, direct);
230 }
231}