1use dyn_stack::MemStack;
25use faer::{
26 Accum, Mat, get_global_parallelism,
27 prelude::Reborrow,
28 sparse::linalg::matmul::{
29 SparseMatMulInfo, sparse_dense_matmul, sparse_sparse_matmul_numeric,
30 sparse_sparse_matmul_numeric_scratch, sparse_sparse_matmul_symbolic,
31 },
32 sparse::{
33 SparseColMat, SparseColMatMut, SparseColMatRef, SymbolicSparseColMat,
34 SymbolicSparseColMatRef,
35 },
36};
37use rayon::prelude::*;
38use std::mem::MaybeUninit;
39
40use crate::error::ErrorLogging;
41use crate::linalg::{Damping, LinAlgError, LinAlgResult};
42
43pub struct NormalEquationsCache {
46 jt_pattern: SymbolicSparseColMat<usize>,
48 value_perm: Vec<usize>,
50 product_symbolic: SymbolicSparseColMat<usize>,
52 product_info: SparseMatMulInfo,
54 diag_pos: Vec<Option<usize>>,
57 augmented: Option<AugmentedDiagonal>,
65 pattern: super::pattern::PatternFingerprint,
71 jt_values: Vec<f64>,
73 hessian_values: Vec<f64>,
75 stack_buf: Vec<MaybeUninit<u8>>,
77}
78
79pub struct NormalEquations {
81 pub hessian: SparseColMat<usize, f64>,
82 pub gradient: Mat<f64>,
84}
85
86#[derive(Default)]
90pub struct LazyNormalEquations(Option<NormalEquationsCache>);
91
92impl Clone for LazyNormalEquations {
93 fn clone(&self) -> Self {
94 Self(None)
95 }
96}
97
98impl std::fmt::Debug for LazyNormalEquations {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.write_str("LazyNormalEquations(rebuilds on next solve)")
101 }
102}
103
104impl LazyNormalEquations {
105 pub fn ensure(&mut self, jacobians: &SparseColMat<usize, f64>) -> LinAlgResult<()> {
107 if self.0.as_ref().is_none_or(|c| !c.matches(jacobians)) {
108 self.0 = Some(NormalEquationsCache::try_new(jacobians)?);
109 }
110 Ok(())
111 }
112
113 pub fn compute(
115 &mut self,
116 residuals: &Mat<f64>,
117 jacobians: &SparseColMat<usize, f64>,
118 ) -> LinAlgResult<NormalEquations> {
119 self.ensure(jacobians)?;
120 match self.0.as_mut() {
121 Some(cache) => cache.compute(residuals, jacobians),
122 None => Err(LinAlgError::InvalidState(
123 "normal equations cache not initialized".to_string(),
124 )),
125 }
126 }
127
128 pub fn damped_hessian(&self, damping: &Damping) -> LinAlgResult<SparseColMat<usize, f64>> {
131 match self.0.as_ref() {
132 Some(cache) => cache.damped_hessian(damping),
133 None => Err(LinAlgError::InvalidState(
134 "normal equations cache not initialized".to_string(),
135 )),
136 }
137 }
138
139 pub fn product_symbolic(&self) -> LinAlgResult<SymbolicSparseColMat<usize>> {
141 match self.0.as_ref() {
142 Some(cache) => Ok(cache.product_symbolic()),
143 None => Err(LinAlgError::InvalidState(
144 "normal equations cache not initialized".to_string(),
145 )),
146 }
147 }
148}
149
150impl NormalEquationsCache {
151 pub fn try_new(jacobian: &SparseColMat<usize, f64>) -> LinAlgResult<Self> {
153 let sym = jacobian.as_ref().symbolic();
154 let (jt_pattern, value_perm) = transpose_symbolic(sym)?;
155 let (product_symbolic, product_info) = sparse_sparse_matmul_symbolic(jt_pattern.rb(), sym)
156 .map_err(|e| {
157 LinAlgError::MatrixConversion(format!("Symbolic JᵀJ product failed: {e:?}"))
158 .log_with_source(e)
159 })?;
160
161 let ncols = jacobian.ncols();
162 let diag_pos: Vec<Option<usize>> = (0..ncols)
163 .map(|col| {
164 let col_start = product_symbolic.col_range(col).start;
166 product_symbolic
167 .row_idx_of_col_raw(col)
168 .iter()
169 .position(|&r| r == col)
170 .map(|local| col_start + local)
171 })
172 .collect();
173
174 let scratch_req = sparse_sparse_matmul_numeric_scratch::<usize, f64>(
175 product_symbolic.rb(),
176 get_global_parallelism(),
177 );
178
179 let augmented = if diag_pos.iter().any(Option::is_none) {
180 Some(AugmentedDiagonal::build(&product_symbolic)?)
181 } else {
182 None
183 };
184
185 Ok(Self {
186 jt_values: vec![0.0; jt_pattern.compute_nnz()],
187 hessian_values: vec![0.0; product_symbolic.compute_nnz()],
188 stack_buf: vec![MaybeUninit::uninit(); scratch_req.unaligned_bytes_required()],
189 jt_pattern,
190 value_perm,
191 product_symbolic,
192 product_info,
193 diag_pos,
194 augmented,
195 pattern: super::pattern::PatternFingerprint::of(jacobian),
196 })
197 }
198
199 pub fn matches(&self, jacobian: &SparseColMat<usize, f64>) -> bool {
205 self.pattern == super::pattern::PatternFingerprint::of(jacobian)
206 }
207
208 pub fn product_symbolic(&self) -> SymbolicSparseColMat<usize> {
210 self.product_symbolic.clone()
211 }
212
213 pub fn compute(
215 &mut self,
216 residuals: &Mat<f64>,
217 jacobian: &SparseColMat<usize, f64>,
218 ) -> LinAlgResult<NormalEquations> {
219 let j_values = jacobian.as_ref().val();
220 let perm = &self.value_perm;
221
222 self.jt_values
224 .par_iter_mut()
225 .zip(perm.par_iter())
226 .for_each(|(v, &src)| *v = j_values[src]);
227
228 let hessian = {
230 let mut stack_buf = std::mem::take(&mut self.stack_buf);
231 {
232 let stack = MemStack::new(&mut stack_buf);
233 let dst =
234 SparseColMatMut::new(self.product_symbolic.rb(), &mut self.hessian_values);
235 let jt = SparseColMatRef::new(self.jt_pattern.rb(), &self.jt_values);
236 sparse_sparse_matmul_numeric(
237 dst,
238 Accum::Replace,
239 jt,
240 jacobian.as_ref(),
241 1.0,
242 &self.product_info,
243 get_global_parallelism(),
244 stack,
245 );
246 }
247 self.stack_buf = stack_buf;
248 SparseColMat::new(self.product_symbolic.clone(), self.hessian_values.clone())
249 };
250
251 let mut gradient = Mat::<f64>::zeros(self.pattern.ncols(), 1);
253 {
254 let jt = SparseColMatRef::new(self.jt_pattern.rb(), &self.jt_values);
255 sparse_dense_matmul(
256 gradient.as_mut(),
257 Accum::Replace,
258 jt,
259 residuals.as_ref(),
260 1.0,
261 get_global_parallelism(),
262 );
263 }
264
265 Ok(NormalEquations { hessian, gradient })
266 }
267
268 pub fn damped_hessian(&self, damping: &Damping) -> LinAlgResult<SparseColMat<usize, f64>> {
282 let Some(aug) = &self.augmented else {
283 let mut values = self.hessian_values.clone();
284 for pos in self.diag_pos.iter().flatten() {
285 values[*pos] += damping.diagonal_term(values[*pos]);
286 }
287 return Ok(SparseColMat::new(self.product_symbolic.clone(), values));
288 };
289
290 let mut values = vec![0.0; aug.nnz];
291 for (base, &target) in aug.from_base.iter().enumerate() {
292 values[target] = self.hessian_values[base];
293 }
294 for &pos in &aug.diag_pos {
295 values[pos] += damping.diagonal_term(values[pos]);
296 }
297 Ok(SparseColMat::new(aug.symbolic.clone(), values))
298 }
299
300 pub fn product_symbolic_for_damping(&self) -> SymbolicSparseColMat<usize> {
306 match &self.augmented {
307 Some(aug) => aug.symbolic.clone(),
308 None => self.product_symbolic.clone(),
309 }
310 }
311}
312
313struct AugmentedDiagonal {
320 symbolic: SymbolicSparseColMat<usize>,
321 from_base: Vec<usize>,
323 diag_pos: Vec<usize>,
325 nnz: usize,
326}
327
328impl AugmentedDiagonal {
329 fn build(product: &SymbolicSparseColMat<usize>) -> LinAlgResult<Self> {
330 let ncols = product.ncols();
331 let nrows = product.nrows();
332
333 let mut col_ptr = vec![0usize; ncols + 1];
335 let mut row_idx = Vec::with_capacity(product.compute_nnz() + ncols);
336 let mut from_base = vec![0usize; product.compute_nnz()];
337 let mut diag_pos = Vec::with_capacity(ncols);
338
339 for col in 0..ncols {
340 let base_start = product.col_range(col).start;
341 let rows = product.row_idx_of_col_raw(col);
342 let mut inserted_diag = false;
343
344 for (local, &row) in rows.iter().enumerate() {
345 if !inserted_diag && row > col {
346 diag_pos.push(row_idx.len());
347 row_idx.push(col);
348 inserted_diag = true;
349 }
350 if row == col {
351 diag_pos.push(row_idx.len());
352 inserted_diag = true;
353 }
354 from_base[base_start + local] = row_idx.len();
355 row_idx.push(row);
356 }
357 if !inserted_diag {
358 diag_pos.push(row_idx.len());
359 row_idx.push(col);
360 }
361 col_ptr[col + 1] = row_idx.len();
362 }
363
364 let nnz = row_idx.len();
365 let symbolic = SymbolicSparseColMat::new_checked(nrows, ncols, col_ptr, None, row_idx);
366
367 Ok(Self {
368 symbolic,
369 from_base,
370 diag_pos,
371 nnz,
372 })
373 }
374}
375
376fn transpose_symbolic(
383 j: SymbolicSparseColMatRef<'_, usize>,
384) -> LinAlgResult<(SymbolicSparseColMat<usize>, Vec<usize>)> {
385 let (m, n) = (j.nrows(), j.ncols());
386 let col_ptr = j.col_ptr();
387 let row_idx = j.row_idx();
388 let nnz = row_idx.len();
389
390 let mut t_col_ptr = vec![0usize; m + 1];
392 for &row in row_idx {
393 t_col_ptr[row + 1] += 1;
394 }
395 for i in 0..m {
396 t_col_ptr[i + 1] += t_col_ptr[i];
397 }
398
399 let mut t_row_idx = vec![0usize; nnz];
400 let mut perm = vec![0usize; nnz];
401 let mut next = t_col_ptr[..m].to_vec();
402 for col in 0..n {
403 let col_start = col_ptr[col];
404 let col_end = col_ptr[col + 1];
405 for (k, &row) in row_idx[col_start..col_end].iter().enumerate() {
406 let pos = next[row];
407 t_row_idx[pos] = col;
408 perm[pos] = col_start + k;
409 next[row] = pos + 1;
410 }
411 }
412
413 let pattern = SymbolicSparseColMat::new_checked(n, m, t_col_ptr, None, t_row_idx);
414 Ok((pattern, perm))
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use faer::sparse::Triplet;
421
422 type TestResult<T> = Result<T, Box<dyn std::error::Error>>;
423
424 fn sample_jacobian(m: usize, n: usize, seed: u64) -> TestResult<SparseColMat<usize, f64>> {
426 let mut triplets: Vec<Triplet<usize, usize, f64>> = Vec::new();
427 let mut state = seed | 1;
428 for col in 0..n {
429 for row in 0..m {
430 state = state
431 .wrapping_mul(6364136223846793005)
432 .wrapping_add(1442695040888963407);
433 if (state >> 33) % 5 < 2 {
434 let value = ((state >> 34) % 1000) as f64 / 250.0 - 2.0;
435 triplets.push(Triplet::new(row, col, value));
436 }
437 }
438 }
439 Ok(SparseColMat::try_new_from_triplets(m, n, &triplets)?)
440 }
441
442 fn naive_hessian(j: &SparseColMat<usize, f64>) -> TestResult<SparseColMat<usize, f64>> {
443 use std::ops::Mul;
444 let jt = j
445 .as_ref()
446 .transpose()
447 .to_col_major()
448 .map_err(|e| format!("transpose to_col_major failed: {e:?}"))?;
449 Ok(jt.mul(j.as_ref()))
450 }
451
452 fn assert_hessians_close(a: &SparseColMat<usize, f64>, b: &SparseColMat<usize, f64>) {
453 assert_eq!(a.nrows(), b.nrows());
454 assert_eq!(a.ncols(), b.ncols());
455 for col in 0..a.ncols() {
456 let rows_a = a.symbolic().row_idx_of_col_raw(col);
457 let rows_b = b.symbolic().row_idx_of_col_raw(col);
458 assert_eq!(rows_a, rows_b, "col {col}: pattern mismatch");
459 for (va, vb) in a.val_of_col(col).iter().zip(b.val_of_col(col)) {
460 assert!(
461 (va - vb).abs() < 1e-10 * (1.0 + va.abs()),
462 "col {col}: {va} vs {vb}"
463 );
464 }
465 }
466 }
467
468 #[test]
469 fn test_hessian_and_gradient_match_naive() -> TestResult<()> {
470 let j = sample_jacobian(40, 25, 7)?;
471 let residuals = Mat::from_fn(40, 1, |i, _| ((i * 13) % 17) as f64 - 8.0);
472
473 let mut cache = NormalEquationsCache::try_new(&j)?;
474 let ne = cache.compute(&residuals, &j)?;
475
476 assert_hessians_close(&ne.hessian, &naive_hessian(&j)?);
477
478 use std::ops::Mul;
479 let jt = j
480 .as_ref()
481 .transpose()
482 .to_col_major()
483 .map_err(|e| format!("{e:?}"))?;
484 let expected_g = jt.mul(residuals.as_ref());
485 for i in 0..ne.gradient.nrows() {
486 assert!((ne.gradient[(i, 0)] - expected_g[(i, 0)]).abs() < 1e-10);
487 }
488 Ok(())
489 }
490
491 #[test]
492 fn test_compute_reusable_across_calls() -> TestResult<()> {
493 let j = sample_jacobian(30, 20, 42)?;
494 let residuals = Mat::from_fn(30, 1, |i, _| (i as f64) * 0.25);
495
496 let mut cache = NormalEquationsCache::try_new(&j)?;
497 let first = cache.compute(&residuals, &j)?;
498 let second = cache.compute(&residuals, &j)?;
500 assert_hessians_close(&first.hessian, &second.hessian);
501 for i in 0..first.gradient.nrows() {
502 assert!((first.gradient[(i, 0)] - second.gradient[(i, 0)]).abs() < 1e-12);
503 }
504 Ok(())
505 }
506
507 #[test]
508 fn test_matches_detects_shape_change() -> TestResult<()> {
509 let j = sample_jacobian(30, 20, 1)?;
510 let cache = NormalEquationsCache::try_new(&j)?;
511 assert!(cache.matches(&j));
512
513 let other = sample_jacobian(30, 21, 1)?;
514 assert!(!cache.matches(&other));
515 Ok(())
516 }
517
518 #[test]
519 fn test_matches_rejects_equal_nnz_permutation() -> TestResult<()> {
520 let t = vec![
524 Triplet::new(0usize, 0usize, 1.0f64),
525 Triplet::new(1, 0, 2.0),
526 Triplet::new(1, 1, 3.0),
527 Triplet::new(2, 1, 4.0),
528 ];
529 let j = SparseColMat::try_new_from_triplets(3, 2, &t)?;
530 let cache = NormalEquationsCache::try_new(&j)?;
531 assert!(cache.matches(&j));
532
533 let moved = vec![
534 Triplet::new(0usize, 0usize, 1.0f64),
535 Triplet::new(1, 0, 2.0),
536 Triplet::new(0, 1, 3.0),
537 Triplet::new(2, 1, 4.0),
538 ];
539 let permuted = SparseColMat::try_new_from_triplets(3, 2, &moved)?;
540 assert_eq!(
541 permuted.as_ref().compute_nnz(),
542 j.as_ref().compute_nnz(),
543 "relocation must preserve nnz for the regression to be meaningful"
544 );
545 assert!(
546 !cache.matches(&permuted),
547 "an equal-nnz permutation must invalidate the cache"
548 );
549 Ok(())
550 }
551
552 #[test]
553 fn test_damped_hessian_adds_lambda_on_diagonal() -> TestResult<()> {
554 let j = sample_jacobian(20, 15, 99)?;
555 let residuals = Mat::zeros(20, 1);
556 let mut cache = NormalEquationsCache::try_new(&j)?;
557 let ne = cache.compute(&residuals, &j)?;
558
559 let damped = cache.damped_hessian(&Damping::identity(0.5))?;
560 for col in 0..damped.ncols() {
561 let rows = damped.symbolic().row_idx_of_col_raw(col);
562 let pos = rows
563 .iter()
564 .position(|&r| r == col)
565 .ok_or("missing diagonal in damped hessian")?;
566 let expected = ne.hessian.val_of_col(col)[pos] + 0.5;
567 assert!((damped.val_of_col(col)[pos] - expected).abs() < 1e-12);
568 }
569 Ok(())
570 }
571}