1use super::*;
5
6pub struct SparsePirlsDecision {
7 pub path: PirlsLinearSolvePath,
8 pub reason: &'static str,
9 pub p: usize,
10 pub nnz_x: usize,
11 pub nnz_xtwx_symbolic: Option<usize>,
12 pub nnz_s_lambda: usize,
13 pub nnz_h_est: Option<usize>,
14 pub density_h_est: Option<f64>,
15}
16
17pub(crate) fn fmt_opt_usize(v: Option<usize>) -> String {
18 v.map(|v| v.to_string()).unwrap_or_else(|| "na".to_string())
19}
20
21pub(crate) fn fmt_opt_f64(v: Option<f64>) -> String {
22 v.map(|v| format!("{v:.4}"))
23 .unwrap_or_else(|| "na".to_string())
24}
25
26impl SparsePirlsDecision {
27 pub(crate) fn path_str(&self) -> &'static str {
28 match self.path {
29 PirlsLinearSolvePath::DenseTransformed => "dense_transformed",
30 PirlsLinearSolvePath::SparseNative => "sparse_native",
31 }
32 }
33
34 pub(crate) fn format_fields(&self, path: &str) -> String {
35 format!(
36 "path={path} reason={} p={} nnz_x={} nnz_xtwx_symbolic={} nnz_s_lambda={} nnz_h_est={} density_h_est={}",
37 self.reason,
38 self.p,
39 self.nnz_x,
40 fmt_opt_usize(self.nnz_xtwx_symbolic),
41 self.nnz_s_lambda,
42 fmt_opt_usize(self.nnz_h_est),
43 fmt_opt_f64(self.density_h_est),
44 )
45 }
46
47 pub(crate) fn log_once(&self) {
56 let path = self.path_str();
57 let key = self.format_fields(path);
58 let repetition_count = pirls_decision_repetition_count(key.clone());
59 if repetition_count == 1 {
60 log::info!("[pirls-path] {key}");
61 return;
62 }
63
64 if should_log_pirls_decision_summary(repetition_count) {
65 log::info!(
66 "[pirls-path] repeated path={} reason={} count={} (suppressing identical decisions)",
67 path,
68 self.reason,
69 repetition_count,
70 );
71 }
72 }
73}
74
75pub(crate) fn pirls_decision_repetition_count(log_key: String) -> usize {
76 static PIRLS_DECISION_LOG_COUNTS: OnceLock<Mutex<HashMap<String, usize>>> = OnceLock::new();
77 let counts = PIRLS_DECISION_LOG_COUNTS.get_or_init(|| Mutex::new(HashMap::new()));
78 let mut counts = counts.lock().expect("pirls decision log counter poisoned");
79 let count = counts.entry(log_key).or_insert(0);
80 *count += 1;
81 *count
82}
83
84pub(crate) fn should_log_pirls_decision_summary(repetition_count: usize) -> bool {
85 repetition_count > 1 && repetition_count.is_power_of_two()
86}
87
88pub(crate) const SPARSE_NATIVE_MAX_H_DENSITY: f64 = 0.30;
89
90#[derive(Clone, Debug)]
91pub(crate) struct SparsePenaltyPattern {
92 pub(crate) upper_triplets: Vec<(usize, usize, f64)>,
93 pub(crate) nnz_upper: usize,
94}
95
96impl SparsePenaltyPattern {
97 pub(crate) fn from_dense_upper(matrix: &Array2<f64>, tol: f64) -> Self {
98 let p = matrix.nrows().min(matrix.ncols());
99 let mut upper_triplets = Vec::new();
100 for col in 0..p {
101 for row in 0..=col {
102 let value = matrix[[row, col]];
103 if value.abs() > tol {
104 upper_triplets.push((row, col, value));
105 }
106 }
107 }
108 let nnz_upper = upper_triplets.len();
109 Self {
110 upper_triplets,
111 nnz_upper,
112 }
113 }
114}
115
116#[derive(Clone, Debug)]
117pub(crate) struct SparsePenalizedSystemStats {
118 pub(crate) nnz_xtwx_symbolic: usize,
119 pub(crate) nnz_s_lambda_upper: usize,
120 pub(crate) nnz_h_upper: usize,
121 pub(crate) density_upper: f64,
122}
123
124pub(crate) struct SparsePenalizedSystemCache {
148 pub(crate) xtwx_cache: SparseXtWxCache,
149 pub(crate) penalty_pattern: SparsePenaltyPattern,
150 pub(crate) h_upper_symbolic: SymbolicSparseColMat<usize>,
151 pub(crate) h_uppervalues: Vec<f64>,
152 pub(crate) h_upper_col_ptr: Vec<usize>,
153 pub(crate) h_upperrow_idx: Vec<usize>,
154 pub(crate) p: usize,
155}
156
157impl SparsePenalizedSystemCache {
158 pub(crate) fn new(
159 x: &SparseColMat<usize, f64>,
160 penalty_pattern: SparsePenaltyPattern,
161 ) -> Result<Self, EstimationError> {
162 let xtwx_cache = SparseXtWxCache::new(x)?;
163 let p = x.ncols();
164 let h_upper_symbolic = build_penalized_symbolic(
165 p,
166 xtwx_cache.xtwx_symbolic.col_ptr(),
167 xtwx_cache.xtwx_symbolic.row_idx(),
168 &penalty_pattern.upper_triplets,
169 )?;
170 let h_uppervalues = vec![0.0; h_upper_symbolic.row_idx().len()];
171 Ok(Self {
172 xtwx_cache,
173 penalty_pattern,
174 h_upper_col_ptr: h_upper_symbolic.col_ptr().to_vec(),
175 h_upperrow_idx: h_upper_symbolic.row_idx().to_vec(),
176 h_upper_symbolic,
177 h_uppervalues,
178 p,
179 })
180 }
181
182 pub(crate) fn matches(
183 &self,
184 x: &SparseColMat<usize, f64>,
185 penalty_pattern: &SparsePenaltyPattern,
186 ) -> bool {
187 self.xtwx_cache.matches(x)
188 && self.penalty_pattern.nnz_upper == penalty_pattern.nnz_upper
189 && self.penalty_pattern.upper_triplets == penalty_pattern.upper_triplets
190 }
191
192 pub(crate) fn stats(&self) -> SparsePenalizedSystemStats {
193 let upper_total = self.p.saturating_mul(self.p + 1) / 2;
194 SparsePenalizedSystemStats {
195 nnz_xtwx_symbolic: self.xtwx_cache.xtwx_symbolic.row_idx().len(),
196 nnz_s_lambda_upper: self.penalty_pattern.nnz_upper,
197 nnz_h_upper: self.h_upper_symbolic.row_idx().len(),
198 density_upper: if upper_total == 0 {
199 0.0
200 } else {
201 self.h_upper_symbolic.row_idx().len() as f64 / upper_total as f64
202 },
203 }
204 }
205
206 pub(crate) fn assemble_upper(
207 &mut self,
208 x: &SparseColMat<usize, f64>,
209 weights: &Array1<f64>,
210 ridge: f64,
211 precomputed_xtwx: Option<&SparseXtwxPrecomputed>,
212 ) -> Result<SparseColMat<usize, f64>, EstimationError> {
213 if weights.len() != self.xtwx_cache.nrows {
214 crate::bail_invalid_estim!(
215 "weights length {} does not match design rows {}",
216 weights.len(),
217 self.xtwx_cache.nrows
218 );
219 }
220 let use_precomputed = match precomputed_xtwx {
227 Some(pre) => {
228 let col_ptr_ok =
229 pre.xtwx_symbolic_col_ptr.as_slice() == self.xtwx_cache.xtwx_symbolic.col_ptr();
230 let row_idx_ok =
231 pre.xtwx_symbolic_row_idx.as_slice() == self.xtwx_cache.xtwx_symbolic.row_idx();
232 let values_ok = pre.xtwxvalues.len() == self.xtwx_cache.xtwxvalues.len();
233 if col_ptr_ok && row_idx_ok && values_ok {
234 self.xtwx_cache.xtwxvalues.copy_from_slice(&pre.xtwxvalues);
235 true
236 } else {
237 log::warn!(
238 "[sparse-xtwx-cache] precomputed XᵀWX pattern mismatch; \
239 falling back to per-call recompute"
240 );
241 false
242 }
243 }
244 None => false,
245 };
246 if !use_precomputed {
247 self.xtwx_cache.compute_numeric(x, weights)?;
248 }
249 self.h_uppervalues.fill(0.0);
250
251 let mut cursor = self.h_upper_col_ptr[..self.p].to_vec();
252
253 let xtwx_col_ptr = self.xtwx_cache.xtwx_symbolic.col_ptr();
254 let xtwxrow_idx = self.xtwx_cache.xtwx_symbolic.row_idx();
255 for col in 0..self.p {
256 let start = xtwx_col_ptr[col];
257 let end = xtwx_col_ptr[col + 1];
258 for idx in start..end {
259 let row = xtwxrow_idx[idx];
260 if row <= col {
261 let cursor_idx = &mut cursor[col];
262 while *cursor_idx < self.h_upper_col_ptr[col + 1]
263 && self.h_upperrow_idx[*cursor_idx] < row
264 {
265 *cursor_idx += 1;
266 }
267 if *cursor_idx >= self.h_upper_col_ptr[col + 1]
268 || self.h_upperrow_idx[*cursor_idx] != row
269 {
270 crate::bail_invalid_estim!("penalized symbolic pattern missing XtWX entry");
271 }
272 self.h_uppervalues[*cursor_idx] += self.xtwx_cache.xtwxvalues[idx];
273 }
274 }
275 }
276
277 cursor.copy_from_slice(&self.h_upper_col_ptr[..self.p]);
278 for &(row, col, value) in &self.penalty_pattern.upper_triplets {
279 let cursor_idx = &mut cursor[col];
280 while *cursor_idx < self.h_upper_col_ptr[col + 1]
281 && self.h_upperrow_idx[*cursor_idx] < row
282 {
283 *cursor_idx += 1;
284 }
285 if *cursor_idx >= self.h_upper_col_ptr[col + 1]
286 || self.h_upperrow_idx[*cursor_idx] != row
287 {
288 crate::bail_invalid_estim!("penalized symbolic pattern missing penalty entry");
289 }
290 self.h_uppervalues[*cursor_idx] += value;
291 }
292
293 if ridge > 0.0 {
294 cursor.copy_from_slice(&self.h_upper_col_ptr[..self.p]);
295 for col in 0..self.p {
296 let cursor_idx = &mut cursor[col];
297 while *cursor_idx < self.h_upper_col_ptr[col + 1]
298 && self.h_upperrow_idx[*cursor_idx] < col
299 {
300 *cursor_idx += 1;
301 }
302 if *cursor_idx >= self.h_upper_col_ptr[col + 1]
303 || self.h_upperrow_idx[*cursor_idx] != col
304 {
305 crate::bail_invalid_estim!("penalized symbolic pattern missing diagonal entry");
306 }
307 self.h_uppervalues[*cursor_idx] += ridge;
308 }
309 }
310
311 Ok(SparseColMat::new(
312 self.h_upper_symbolic.clone(),
313 self.h_uppervalues.clone(),
314 ))
315 }
316}
317
318pub(crate) fn build_penalized_symbolic(
319 p: usize,
320 xtwx_col_ptr: &[usize],
321 xtwxrow_idx: &[usize],
322 penalty_triplets: &[(usize, usize, f64)],
323) -> Result<SymbolicSparseColMat<usize>, EstimationError> {
324 let mut cols: Vec<BTreeSet<usize>> = (0..p).map(|_| BTreeSet::new()).collect();
325 for col in 0..p {
326 cols[col].insert(col);
327 let start = xtwx_col_ptr[col];
328 let end = xtwx_col_ptr[col + 1];
329 for &row in &xtwxrow_idx[start..end] {
330 if row <= col {
331 cols[col].insert(row);
332 }
333 }
334 }
335 for &(row, col, _) in penalty_triplets {
336 if row > col || col >= p {
337 crate::bail_invalid_estim!(
338 "penalty sparse pattern must be upper-triangular within bounds"
339 );
340 }
341 cols[col].insert(row);
342 }
343
344 let mut col_ptr = Vec::with_capacity(p + 1);
345 let mut row_idx = Vec::new();
346 col_ptr.push(0);
347 for rows in cols {
348 row_idx.extend(rows.into_iter());
349 col_ptr.push(row_idx.len());
350 }
351 Ok(unsafe { SymbolicSparseColMat::new_unchecked(p, p, col_ptr, None, row_idx) })
361}
362
363#[derive(Clone)]
364pub struct SparsePenalizedSystem {
365 pub h_sparse: SparseColMat<usize, f64>,
366 pub factor: gam_linalg::sparse_exact::SparseExactFactor,
367 pub logdet_h: f64,
368}
369
370pub(crate) fn sparse_reml_penalized_hessian(
371 workspace: &mut PirlsWorkspace,
372 x: &SparseColMat<usize, f64>,
373 weights: &Array1<f64>,
374 s_lambda: &Array2<f64>,
375 ridge: f64,
376 precomputed_xtwx: Option<&SparseXtwxPrecomputed>,
377) -> Result<SparseColMat<usize, f64>, EstimationError> {
378 workspace.assemble_sparse_penalized_hessian(x, weights, s_lambda, ridge, precomputed_xtwx)
379}
380
381pub fn assemble_and_factor_sparse_penalized_system(
382 workspace: &mut PirlsWorkspace,
383 x: &SparseColMat<usize, f64>,
384 weights: &Array1<f64>,
385 s_lambda: &Array2<f64>,
386 ridge: f64,
387 precomputed_xtwx: Option<&SparseXtwxPrecomputed>,
388) -> Result<SparsePenalizedSystem, EstimationError> {
389 use gam_linalg::sparse_exact::{factorize_sparse_spd, logdet_from_factor};
390
391 let logdet_h_start = std::time::Instant::now();
392 let h_sparse =
393 sparse_reml_penalized_hessian(workspace, x, weights, s_lambda, ridge, precomputed_xtwx)?;
394 let factor = factorize_sparse_spd(&h_sparse)?;
395 let logdet_h = logdet_from_factor(&factor)?;
396 log::info!(
397 "[STAGE] logdet H (sparse Cholesky) p={} elapsed={:.3}s",
398 h_sparse.nrows(),
399 logdet_h_start.elapsed().as_secs_f64(),
400 );
401 Ok(SparsePenalizedSystem {
402 h_sparse,
403 factor,
404 logdet_h,
405 })
406}