Skip to main content

gam_models/transformation_normal/
family.rs

1use super::*;
2
3pub(crate) fn beta_bits_match(cached: &Array1<f64>, candidate: &Array1<f64>) -> bool {
4    cached.len() == candidate.len()
5        && cached
6            .iter()
7            .zip(candidate.iter())
8            .all(|(&left, &right)| left.to_bits() == right.to_bits())
9}
10
11/// Optional warm-start for the transformation model: per-observation location and
12/// scale values from a prior mean/SD normalizer.
13#[derive(Clone, Debug)]
14pub struct TransformationWarmStart {
15    /// μ(x_i): conditional mean of the response at each observation's covariates.
16    pub location: Array1<f64>,
17    /// τ(x_i): conditional standard deviation at each observation's covariates.
18    pub scale: Array1<f64>,
19}
20
21// ---------------------------------------------------------------------------
22// The family
23// ---------------------------------------------------------------------------
24
25/// Conditional transformation model mapping Y|x to N(0,1).
26///
27/// Single-block `CustomFamily`. The block design is `x_val` (tensor product of
28/// response value basis × covariate design). The family internally holds `x_deriv`
29/// (tensor product of response derivative basis × covariate design) for the
30/// Jacobian term in the likelihood.
31#[derive(Clone)]
32pub struct TransformationNormalFamily {
33    // --- Tensor product design matrices ---
34    /// Value design operator: keeps the tensor factors separate and materializes
35    /// only row chunks or explicitly requested dense diagnostics.
36    pub(crate) x_val_kron: KroneckerDesign,
37    /// Derivative design operator: keeps the tensor factors separate.
38    pub(crate) x_deriv_kron: KroneckerDesign,
39    // --- Response-direction basis (fixed, does not depend on κ) ---
40    /// Response value basis: n × p_resp. Columns: [1, I_1(y), ..., I_k(y)].
41    pub(crate) response_val_basis: Array2<f64>,
42    /// Response value basis at the finite lower support endpoint.
43    pub(crate) response_lower_basis: Array1<f64>,
44    /// Response value basis at the finite upper support endpoint.
45    pub(crate) response_upper_basis: Array1<f64>,
46    /// Response derivative basis: n × p_resp. Columns: [0, M_1(y), ..., M_k(y)].
47    pub(crate) response_deriv_basis: Array2<f64>,
48
49    // --- Covariate side (rebuilt on κ change) ---
50    /// Original covariate design used on the right side of the tensor product.
51    pub(crate) covariate_design: DesignMatrix,
52    /// Dense covariate block shared by row-quantity and endpoint evaluations.
53    ///
54    /// CTN row quantities are rebuilt at every accepted/probed β, but the
55    /// covariate design is fixed for the family. Caching this immutable
56    /// `n × p_cov` block avoids repeated chunk materialization and keeps
57    /// large-scale runs from churning large transient allocations.
58    pub(crate) covariate_dense_cache: Arc<Mutex<Option<Arc<Array2<f64>>>>>,
59    /// Optional non-negative row weights folded directly into the likelihood.
60    pub(crate) weights: Arc<Array1<f64>>,
61    /// Additive offset for the transformation linear predictor.
62    pub(crate) offset: Arc<Array1<f64>>,
63    // --- Tensor penalties ---
64    pub(crate) tensor_penalties: Vec<PenaltyMatrix>,
65    /// Assembled order/counts of `tensor_penalties`
66    /// (`[covariate.., response.., double?]`). The response and double penalties
67    /// carry the κ-moving `G_x` factor; the psi-derivative channel uses this to
68    /// address them by index.
69    pub(crate) tensor_penalty_layout: CtnTensorPenaltyLayout,
70
71    // --- Initial values ---
72    pub(crate) initial_beta: Array1<f64>,
73
74    // --- Config ---
75    pub(crate) block_name: String,
76
77    // --- Response basis metadata (for reconstruction at predict time) ---
78    pub(crate) response_knots: Array1<f64>,
79    pub(crate) response_transform: Array2<f64>,
80    pub(crate) response_degree: usize,
81    pub(crate) response_median: f64,
82    pub(crate) response_floor_offset: Arc<Array1<f64>>,
83    pub(crate) response_lower_floor_offset: f64,
84    pub(crate) response_upper_floor_offset: f64,
85
86    /// Last row-space transformation quantities for an exact beta vector.
87    ///
88    /// CTN line searches and exact-Newton workspace construction frequently ask
89    /// for likelihood, gradient, and Hessian row factors at the same candidate
90    /// coefficients. This cache keeps the expensive Khatri-Rao forward products
91    /// and reciprocal powers behind a single exact-keyed entry instead of
92    /// recomputing `h`, `h'`, `1/h'`, and derivative powers per call.
93    pub(crate) row_quantity_cache: Arc<Mutex<Option<TransformationNormalRowQuantityCache>>>,
94    /// Optional outer-score Horvitz-Thompson per-row weights.
95    ///
96    /// When present, this is an `n`-vector equal to the original `weights`
97    /// pre-multiplied row-wise by the HT inverse-inclusion multiplier `m_i`
98    /// (`m_i = 1/π_i` on sampled rows, `0.0` on unsampled rows). Assembly
99    /// sites read row weights via [`Self::effective_weights`], which returns
100    /// this array when present and `self.weights` otherwise. Because every
101    /// per-row CTN contribution is linear in `w_i`, masking at this site
102    /// gives `E[Σ_i (m_i · w_i) · f(row_i)] = Σ_i w_i · f(row_i) = full-sum`
103    /// — i.e. an unbiased estimator across log-likelihood, gradient, joint
104    /// Hessian (dense / matvec / diagonal), ψ, and ψ-ψ kernels.
105    ///
106    /// `None` preserves byte-identical legacy behavior (`effective_weights`
107    /// returns the original `weights` array).
108    pub(crate) outer_subsample_weights: Option<Arc<Array1<f64>>>,
109}
110
111#[derive(Clone)]
112pub(crate) struct TransformationNormalRowQuantityCache {
113    pub(crate) beta: Arc<Array1<f64>>,
114    /// Per-row factored coordinates `α_k(x_i) = ψ_iᵀ A_{k,:}` (`n × p_resp`).
115    pub(crate) alpha: Arc<Array2<f64>>,
116    pub(crate) h: Arc<Array1<f64>>,
117    pub(crate) h_prime: Arc<Array1<f64>>,
118    pub(crate) log_likelihood: f64,
119}
120
121#[derive(Debug)]
122pub(crate) struct TransformationNormalRowDerived {
123    pub(crate) log_likelihood: f64,
124}
125
126impl TransformationNormalRowQuantityCache {
127    pub(crate) fn matches_beta(&self, beta: &Array1<f64>) -> bool {
128        beta_bits_match(&self.beta, beta)
129    }
130}
131
132pub(crate) fn build_transformation_row_derived(
133    h: &Array1<f64>,
134    h_prime: &Array1<f64>,
135    weights: &Array1<f64>,
136) -> Result<TransformationNormalRowDerived, String> {
137    let n = h_prime.len();
138    assert_eq!(h.len(), n);
139    assert_eq!(weights.len(), n);
140
141    if let Some((i, value)) = h
142        .iter()
143        .copied()
144        .enumerate()
145        .find(|(_, value)| !value.is_finite())
146    {
147        return Err(TransformationNormalError::NonFinite {
148            reason: format!(
149                "TransformationNormalFamily row_quantities: h[{i}] = {value} is not finite"
150            ),
151        }
152        .into());
153    }
154    if let Some((i, value)) = weights
155        .iter()
156        .copied()
157        .enumerate()
158        .find(|(_, value)| !value.is_finite())
159    {
160        return Err(TransformationNormalError::NonFinite {
161            reason: format!(
162                "TransformationNormalFamily row_quantities: weight[{i}] = {value} is not finite"
163            ),
164        }
165        .into());
166    }
167
168    // Rows are fully independent — no shared state, no OnceLock guards — so the
169    // per-row reciprocal-power build parallelizes directly. The fast finiteness
170    // check rolls all seven derived quantities into a single short-circuit `||`
171    // chain so the named-field error format only runs on the non-finite slow
172    // path.
173    use rayon::iter::{IntoParallelIterator, ParallelIterator};
174    let rows: Vec<f64> = (0..n)
175        .into_par_iter()
176        .map(|i| -> Result<f64, String> {
177            let hp = h_prime[i];
178            let inv_h_prime = 1.0 / hp;
179            let inv_h_prime_sq = inv_h_prime * inv_h_prime;
180            let inv_h_prime_cu = inv_h_prime_sq * inv_h_prime;
181            let inv_h_prime_qu = inv_h_prime_sq * inv_h_prime_sq;
182            let w_i = weights[i];
183            let h_i = h[i];
184            let weighted_h = w_i * h_i;
185            let weighted_inv_h_prime = w_i * inv_h_prime;
186            let weighted_inv_h_prime_sq = w_i * inv_h_prime_sq;
187            // gam#2600: the most-likely-transformation density, log φ(h) + log h',
188            // with NO renormalization by the mass between the fitted support
189            // endpoints. The −½ln(2π) constant is kept so the reported absolute
190            // log-likelihood (and AIC) is comparable to mlt/tram; it is
191            // coefficient-independent.
192            let row_ll =
193                w_i * (-0.5 * h_i * h_i - 0.5 * (2.0 * std::f64::consts::PI).ln() + hp.ln());
194            // Fast path: a single short-circuited finiteness check. Only
195            // when something is non-finite do we walk the named-field
196            // table to produce a precise diagnostic.
197            if !(inv_h_prime.is_finite()
198                && inv_h_prime_sq.is_finite()
199                && inv_h_prime_cu.is_finite()
200                && inv_h_prime_qu.is_finite()
201                && weighted_h.is_finite()
202                && weighted_inv_h_prime.is_finite()
203                && weighted_inv_h_prime_sq.is_finite())
204            {
205                let derived_values = [
206                    ("1/h'", inv_h_prime),
207                    ("1/h'^2", inv_h_prime_sq),
208                    ("1/h'^3", inv_h_prime_cu),
209                    ("1/h'^4", inv_h_prime_qu),
210                    ("w*h", weighted_h),
211                    ("w/h'", weighted_inv_h_prime),
212                    ("w/h'^2", weighted_inv_h_prime_sq),
213                ];
214                for (name, value) in derived_values {
215                    if !value.is_finite() {
216                        return Err(TransformationNormalError::NonFinite { reason: format!(
217                            "TransformationNormalFamily row_quantities: {name} at row {i} is not finite ({value}); h'={hp} is outside the finite exact-derivative range",
218                        ) }.into());
219                    }
220                }
221                return Err(TransformationNormalError::NonFinite { reason: format!(
222                    "TransformationNormalFamily row_quantities: row {i} entered non-finite branch but no named field was non-finite; h'={hp}",
223                ) }.into());
224            }
225            Ok(row_ll)
226        })
227        .collect::<Result<Vec<_>, _>>()?;
228
229    // Sum row contributions in index order so the result is bit-identical
230    // to the previous serial accumulation. The parallel section above only
231    // parallelized the independent per-row computation; the final scalar
232    // reduction stays serial to preserve numerical reproducibility against
233    // existing tests.
234    let mut log_likelihood = 0.0_f64;
235    for row_ll in rows {
236        log_likelihood += row_ll;
237    }
238    if !log_likelihood.is_finite() {
239        return Err(TransformationNormalError::NonFinite { reason: format!(
240            "TransformationNormalFamily row_quantities: log-likelihood is not finite ({log_likelihood})"
241        ) }.into());
242    }
243
244    Ok(TransformationNormalRowDerived { log_likelihood })
245}
246
247impl TransformationNormalFamily {
248    /// Build a transformation model from response values and a pre-built covariate
249    /// design operator with associated penalties.
250    ///
251    /// # Arguments
252    ///
253    /// * `response` - The response variable y (n observations).
254    /// * `covariate_design` - Pre-built covariate-side design operator (n × p_cov).
255    /// * `covariate_penalties` - Penalty matrices for the covariate basis.
256    /// * `config` - Response-direction basis configuration.
257    /// * `warm_start` - Optional location/scale from a prior normalizer.
258    pub fn new(
259        response: &Array1<f64>,
260        weights: &Array1<f64>,
261        offset: &Array1<f64>,
262        covariate_design: DesignMatrix,
263        covariate_penalties: Vec<PenaltyMatrix>,
264        config: &TransformationNormalConfig,
265        warm_start: Option<&TransformationWarmStart>,
266    ) -> Result<Self, String> {
267        let n = response.len();
268        if covariate_design.nrows() != n {
269            return Err(TransformationNormalError::InvalidInput {
270                reason: format!(
271                    "response length {} != covariate design rows {}",
272                    n,
273                    covariate_design.nrows()
274                ),
275            }
276            .into());
277        }
278        let p_cov = covariate_design.ncols();
279        if p_cov == 0 {
280            return Err(TransformationNormalError::DesignDegenerate {
281                reason: "covariate design has zero columns".to_string(),
282            }
283            .into());
284        }
285        if weights.len() != n {
286            return Err(TransformationNormalError::InvalidInput {
287                reason: format!("response length {} != weights length {}", n, weights.len()),
288            }
289            .into());
290        }
291        if offset.len() != n {
292            return Err(TransformationNormalError::InvalidInput {
293                reason: format!("response length {} != offset length {}", n, offset.len()),
294            }
295            .into());
296        }
297        for (i, &weight) in weights.iter().enumerate() {
298            if !weight.is_finite() {
299                return Err(TransformationNormalError::NonFinite {
300                    reason: format!("weights[{i}] is not finite: {weight}"),
301                }
302                .into());
303            }
304            if weight < 0.0 {
305                return Err(TransformationNormalError::InvalidInput {
306                    reason: format!("weights[{i}] must be non-negative: {weight}"),
307                }
308                .into());
309            }
310        }
311        for (i, &value) in offset.iter().enumerate() {
312            if !value.is_finite() {
313                return Err(TransformationNormalError::NonFinite {
314                    reason: format!("offset[{i}] is not finite: {value}"),
315                }
316                .into());
317            }
318        }
319        for (i, sp) in covariate_penalties.iter().enumerate() {
320            let (r, c) = sp.shape();
321            if r != p_cov || c != p_cov {
322                return Err(TransformationNormalError::InvalidInput {
323                    reason: format!(
324                        "covariate penalty {} has shape ({r}, {c}), expected ({p_cov}, {p_cov})",
325                        i,
326                    ),
327                }
328                .into());
329            }
330        }
331
332        // ----- 1. Build response-direction basis -----
333        let (resp_val, resp_deriv, resp_penalties, resp_knots, resp_transform) =
334            build_response_basis(response, config)?;
335        let p_resp = resp_val.ncols();
336        let (response_lower_basis, response_upper_basis) = ctn_endpoint_bases(&resp_transform);
337
338        // ----- 2. Row-wise Kronecker product (operator form) -----
339        let x_val_kron = KroneckerDesign::new_khatri_rao(&resp_val, covariate_design.clone())?;
340        let x_deriv_kron = KroneckerDesign::new_khatri_rao(&resp_deriv, covariate_design.clone())?;
341        let p_total = p_resp * p_cov;
342        assert_eq!(x_val_kron.ncols(), p_total);
343        assert_eq!(x_deriv_kron.ncols(), p_total);
344
345        // ----- 3. Warm start -----
346        let initial_beta = compute_warm_start(
347            response,
348            weights,
349            offset,
350            &x_val_kron,
351            &x_deriv_kron,
352            &covariate_design,
353            &covariate_penalties,
354            p_resp,
355            p_cov,
356            warm_start,
357        )?;
358
359        // ----- 4. Tensor penalties (Kronecker-separable) -----
360        let covariate_dense = covariate_design
361            .try_row_chunk(0..n)
362            .map_err(|e| format!("SCOP covariate dense materialization failed: {e}"))?;
363        let affine_shape = affine_shape_direction(
364            resp_knots.view(),
365            config.response_degree,
366            p_resp.saturating_sub(1),
367        )?;
368        let (tensor_penalties, tensor_penalty_layout) = build_tensor_penalties_kronecker(
369            &resp_penalties,
370            covariate_penalties,
371            resp_val.view(),
372            covariate_dense.view(),
373            weights.view(),
374            p_resp,
375            p_cov,
376            affine_shape.view(),
377            config,
378        )?;
379        // Compute response median for anchoring
380        let mut sorted_resp = response.to_vec();
381        sorted_resp.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
382        let resp_median = if sorted_resp.len() % 2 == 1 {
383            sorted_resp[sorted_resp.len() / 2]
384        } else {
385            0.5 * (sorted_resp[sorted_resp.len() / 2 - 1] + sorted_resp[sorted_resp.len() / 2])
386        };
387        let (response_floor_offset, response_lower_floor_offset, response_upper_floor_offset) =
388            ctn_floor_offsets(response.view(), resp_knots.view(), resp_median)?;
389
390        Ok(Self {
391            x_val_kron,
392            x_deriv_kron,
393            response_val_basis: resp_val,
394            response_lower_basis,
395            response_upper_basis,
396            response_deriv_basis: resp_deriv,
397            covariate_design,
398            weights: Arc::new(weights.clone()),
399            offset: Arc::new(offset.clone()),
400            tensor_penalties,
401            tensor_penalty_layout,
402            initial_beta,
403            block_name: "transformation".to_string(),
404            response_knots: resp_knots,
405            response_transform: resp_transform,
406            response_degree: config.response_degree,
407            response_median: resp_median,
408            response_floor_offset: Arc::new(response_floor_offset),
409            response_lower_floor_offset,
410            response_upper_floor_offset,
411            covariate_dense_cache: Arc::new(Mutex::new(None)),
412            row_quantity_cache: Arc::new(Mutex::new(None)),
413            outer_subsample_weights: None,
414        })
415    }
416
417    /// Build from a prebuilt response basis, skipping response basis construction.
418    ///
419    /// For the outer loop where the response basis is precomputed once and reused
420    /// across κ iterations.
421    pub(crate) fn from_prebuilt_response_basis(
422        response: &Array1<f64>,
423        response_val_basis: Array2<f64>,
424        response_deriv_basis: Array2<f64>,
425        response_penalties: Vec<Array2<f64>>,
426        response_knots: Array1<f64>,
427        response_degree: usize,
428        response_transform: Array2<f64>,
429        weights: &Array1<f64>,
430        offset: &Array1<f64>,
431        covariate_design: DesignMatrix,
432        covariate_penalties: Vec<PenaltyMatrix>,
433        config: &TransformationNormalConfig,
434        warm_start: Option<&TransformationWarmStart>,
435    ) -> Result<Self, String> {
436        let n = response_val_basis.nrows();
437        if n == 0 {
438            return Err(TransformationNormalError::InvalidInput {
439                reason: "response basis has zero rows".to_string(),
440            }
441            .into());
442        }
443        if response.len() != n {
444            return Err(TransformationNormalError::InvalidInput {
445                reason: format!(
446                    "response length {} != response basis rows {}",
447                    response.len(),
448                    n
449                ),
450            }
451            .into());
452        }
453        if covariate_design.nrows() != n {
454            return Err(TransformationNormalError::InvalidInput {
455                reason: format!(
456                    "response basis rows {} != covariate design rows {}",
457                    n,
458                    covariate_design.nrows()
459                ),
460            }
461            .into());
462        }
463        let p_cov = covariate_design.ncols();
464        if p_cov == 0 {
465            return Err(TransformationNormalError::DesignDegenerate {
466                reason: "covariate design has zero columns".to_string(),
467            }
468            .into());
469        }
470        if weights.len() != n {
471            return Err(TransformationNormalError::InvalidInput {
472                reason: format!(
473                    "response basis rows {} != weights length {}",
474                    n,
475                    weights.len()
476                ),
477            }
478            .into());
479        }
480        if offset.len() != n {
481            return Err(TransformationNormalError::InvalidInput {
482                reason: format!(
483                    "response basis rows {} != offset length {}",
484                    n,
485                    offset.len()
486                ),
487            }
488            .into());
489        }
490        for (i, &weight) in weights.iter().enumerate() {
491            if !weight.is_finite() {
492                return Err(TransformationNormalError::NonFinite {
493                    reason: format!("weights[{i}] is not finite: {weight}"),
494                }
495                .into());
496            }
497            if weight < 0.0 {
498                return Err(TransformationNormalError::InvalidInput {
499                    reason: format!("weights[{i}] must be non-negative: {weight}"),
500                }
501                .into());
502            }
503        }
504        for (i, &value) in offset.iter().enumerate() {
505            if !value.is_finite() {
506                return Err(TransformationNormalError::NonFinite {
507                    reason: format!("offset[{i}] is not finite: {value}"),
508                }
509                .into());
510            }
511        }
512        for (i, sp) in covariate_penalties.iter().enumerate() {
513            let (r, c) = sp.shape();
514            if r != p_cov || c != p_cov {
515                return Err(TransformationNormalError::InvalidInput {
516                    reason: format!(
517                        "covariate penalty {} has shape ({r}, {c}), expected ({p_cov}, {p_cov})",
518                        i,
519                    ),
520                }
521                .into());
522            }
523        }
524
525        let p_resp = response_val_basis.ncols();
526        if response_transform.ncols() + 1 != p_resp {
527            return Err(TransformationNormalError::InvalidInput { reason: format!(
528                "response transform columns {} imply p_resp {}, but response value basis has {} columns",
529                response_transform.ncols(),
530                response_transform.ncols() + 1,
531                p_resp
532            ) }.into());
533        }
534        let (response_lower_basis, response_upper_basis) = ctn_endpoint_bases(&response_transform);
535
536        // Row-wise Kronecker product (operator form).
537        let x_val_kron =
538            KroneckerDesign::new_khatri_rao(&response_val_basis, covariate_design.clone())?;
539        let x_deriv_kron =
540            KroneckerDesign::new_khatri_rao(&response_deriv_basis, covariate_design.clone())?;
541        let p_total = p_resp * p_cov;
542        assert_eq!(x_val_kron.ncols(), p_total);
543        assert_eq!(x_deriv_kron.ncols(), p_total);
544
545        let initial_beta = compute_warm_start(
546            response,
547            weights,
548            offset,
549            &x_val_kron,
550            &x_deriv_kron,
551            &covariate_design,
552            &covariate_penalties,
553            p_resp,
554            p_cov,
555            warm_start,
556        )?;
557
558        // Tensor penalties (Kronecker-separable).
559        let covariate_dense = covariate_design
560            .try_row_chunk(0..n)
561            .map_err(|e| format!("SCOP covariate dense materialization failed: {e}"))?;
562        let affine_shape = affine_shape_direction(
563            response_knots.view(),
564            response_degree,
565            p_resp.saturating_sub(1),
566        )?;
567        let (tensor_penalties, tensor_penalty_layout) = build_tensor_penalties_kronecker(
568            &response_penalties,
569            covariate_penalties,
570            response_val_basis.view(),
571            covariate_dense.view(),
572            weights.view(),
573            p_resp,
574            p_cov,
575            affine_shape.view(),
576            config,
577        )?;
578        // Compute response median.
579        let mut sorted_resp = response.to_vec();
580        sorted_resp.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
581        let resp_median = if sorted_resp.len() % 2 == 1 {
582            sorted_resp[sorted_resp.len() / 2]
583        } else {
584            0.5 * (sorted_resp[sorted_resp.len() / 2 - 1] + sorted_resp[sorted_resp.len() / 2])
585        };
586        let (response_floor_offset, response_lower_floor_offset, response_upper_floor_offset) =
587            ctn_floor_offsets(response.view(), response_knots.view(), resp_median)?;
588
589        Ok(Self {
590            x_val_kron,
591            x_deriv_kron,
592            response_val_basis,
593            response_lower_basis,
594            response_upper_basis,
595            response_deriv_basis,
596            covariate_design,
597            weights: Arc::new(weights.clone()),
598            offset: Arc::new(offset.clone()),
599            tensor_penalties,
600            tensor_penalty_layout,
601            initial_beta,
602            block_name: "transformation".to_string(),
603            response_knots: response_knots.clone(),
604            response_transform: response_transform.clone(),
605            response_degree,
606            response_median: resp_median,
607            response_floor_offset: Arc::new(response_floor_offset),
608            response_lower_floor_offset,
609            response_upper_floor_offset,
610            covariate_dense_cache: Arc::new(Mutex::new(None)),
611            row_quantity_cache: Arc::new(Mutex::new(None)),
612            outer_subsample_weights: None,
613        })
614    }
615
616    /// Response basis metadata for serialization/prediction.
617    pub fn response_knots(&self) -> &Array1<f64> {
618        &self.response_knots
619    }
620    pub fn response_transform(&self) -> &Array2<f64> {
621        &self.response_transform
622    }
623    pub fn response_degree(&self) -> usize {
624        self.response_degree
625    }
626    pub fn response_median(&self) -> f64 {
627        self.response_median
628    }
629
630    /// Derive the one cold-start smoothing vector from the likelihood/penalty
631    /// scale ratio without materializing the rowwise-Kronecker Gram.
632    pub(crate) fn penalty_scale_log_lambdas(&self) -> Result<Array1<f64>, String> {
633        let policy = ResourcePolicy::default_library();
634        let likelihood_diagonal_mean = self
635            .x_val_kron
636            .weighted_gram_diagonal_mean(self.weights.as_ref(), &policy)?;
637        Ok(ctn_penalty_scale_log_lambdas(
638            &self.tensor_penalties,
639            likelihood_diagonal_mean,
640        ))
641    }
642
643    /// Return the single coefficient block under one explicit smoothing state.
644    /// Family geometry owns penalties and coefficients; rho belongs to the
645    /// optimizer/block state and has exactly one caller-supplied authority.
646    pub(crate) fn block_spec(
647        &self,
648        initial_log_lambdas: &Array1<f64>,
649    ) -> Result<ParameterBlockSpec, String> {
650        if initial_log_lambdas.len() != self.tensor_penalties.len() {
651            return Err(TransformationNormalError::InvalidInput {
652                reason: format!(
653                    "transformation smoothing vector has length {}, expected {}",
654                    initial_log_lambdas.len(),
655                    self.tensor_penalties.len(),
656                ),
657            }
658            .into());
659        }
660        gam_problem::validate_log_strengths(initial_log_lambdas.iter().copied())
661            .map_err(|error| format!("invalid transformation smoothing strength: {error}"))?;
662        let offset = self.offset.as_ref() + self.response_floor_offset.as_ref();
663        Ok(ParameterBlockSpec {
664            name: self.block_name.clone(),
665            design: DesignMatrix::Dense(DenseDesignMatrix::from(Arc::new(self.x_val_kron.clone()))),
666            offset,
667            penalties: self.tensor_penalties.clone(),
668            nullspace_dims: vec![],
669            initial_log_lambdas: initial_log_lambdas.clone(),
670            initial_beta: Some(self.initial_beta.clone()),
671            gauge_priority: 100,
672            jacobian_callback: None,
673            stacked_design: None,
674            stacked_offset: None,
675        })
676    }
677
678    /// Total number of coefficients.
679    pub fn p_total(&self) -> usize {
680        self.x_val_kron.ncols()
681    }
682
683    /// Number of observations.
684    pub fn n_obs(&self) -> usize {
685        self.x_val_kron.nrows()
686    }
687
688    /// Number of response-direction basis columns `p_resp` (`[1, I_1, …, I_K]`).
689    pub(crate) fn p_resp(&self) -> usize {
690        self.response_val_basis.ncols()
691    }
692
693    /// Number of covariate-side design columns `p_cov`.
694    pub(crate) fn p_cov(&self) -> usize {
695        self.covariate_design.ncols()
696    }
697
698    /// Response value basis evaluated at the finite lower support endpoint
699    /// (row-independent; `[1, I_1(y_min), …, I_K(y_min)]`).
700    pub(crate) fn response_lower_basis(&self) -> &Array1<f64> {
701        &self.response_lower_basis
702    }
703
704    /// Response value basis evaluated at the finite upper support endpoint
705    /// (row-independent; `[1, I_1(y_max), …, I_K(y_max)]`).
706    pub(crate) fn response_upper_basis(&self) -> &Array1<f64> {
707        &self.response_upper_basis
708    }
709
710    /// Monotonicity floor offset applied to the lower-endpoint score
711    /// `ε·(y_min − median_y)`.
712    pub(crate) fn response_lower_floor_offset(&self) -> f64 {
713        self.response_lower_floor_offset
714    }
715
716    /// Monotonicity floor offset applied to the upper-endpoint score
717    /// `ε·(y_max − median_y)`.
718    pub(crate) fn response_upper_floor_offset(&self) -> f64 {
719        self.response_upper_floor_offset
720    }
721
722    /// Per-row weight array used by every row-streaming SCOP assembly site.
723    ///
724    /// Returns the masked HT weights when an outer-score subsample is active
725    /// (`outer_subsample_weights = Some(_)`), else the original `weights`.
726    ///
727    /// Math invariant: every CTN per-row contribution to the gradient,
728    /// negative-Hessian, ψ-term, ψ-ψ-term, and log-likelihood is **linear**
729    /// in this scalar — i.e. each `for i in 0..n` step is of the form
730    /// `wᵢ · g(row_quantities_i, β)` with `wᵢ` appearing to the first power
731    /// only. Replacing `wᵢ` with `wᵢ · m_i` (where `m_i = 1/πᵢ` on sampled
732    /// rows and `0` on unsampled) yields an unbiased Horvitz-Thompson
733    /// estimator: `E[Σᵢ mᵢ wᵢ g(row_i)] = Σᵢ wᵢ g(row_i) = full sum`.
734    #[inline]
735    pub(crate) fn effective_weights(&self) -> &Array1<f64> {
736        match self.outer_subsample_weights.as_ref() {
737            Some(w) => w.as_ref(),
738            None => self.weights.as_ref(),
739        }
740    }
741
742    /// Evaluate the response value / derivative bases `[1, I_k(y)]` and
743    /// `[0, M_k(y)]` (both n × p_resp) at arbitrary response values using the
744    /// *fitted* clamped knots and degree.
745    ///
746    /// This is the out-of-sample analogue of the in-sample `response_val_basis` /
747    /// `response_deriv_basis`, and it goes through the same
748    /// [`ctn_response_bases_at`] the fit's own basis build uses, so the location
749    /// column is prepended identically and `I_k(y)` on held-out rows is the same
750    /// function `h` was assembled from during the fit (gam#2680). Knots are taken
751    /// from the family, never re-derived from `response`.
752    pub(crate) fn evaluate_response_bases(
753        &self,
754        response: ArrayView1<'_, f64>,
755    ) -> Result<(Array2<f64>, Array2<f64>), String> {
756        for (i, &v) in response.iter().enumerate() {
757            if !v.is_finite() {
758                return Err(TransformationNormalError::NonFinite {
759                    reason: format!("evaluate_response_bases: response[{i}] is not finite: {v}"),
760                }
761                .into());
762            }
763        }
764        let (value, derivative) = ctn_response_bases_at(
765            response,
766            self.response_knots.view(),
767            self.response_degree,
768            None,
769        )?;
770        let p_resp = self.response_val_basis.ncols();
771        if value.ncols() != p_resp {
772            return Err(TransformationNormalError::InvalidInput {
773                reason: format!(
774                    "evaluate_response_bases: rebuilt basis has {} columns but the fitted basis \
775                     has {p_resp}",
776                    value.ncols()
777                ),
778            }
779            .into());
780        }
781        Ok((value, derivative))
782    }
783
784    /// Clone the family with an outer-score Horvitz-Thompson mask installed.
785    ///
786    /// The mask `m` (length `n`) is `1/πᵢ` for sampled rows and `0.0` for
787    /// unsampled. The returned family carries `outer_subsample_weights =
788    /// Some(weights ⊙ m)`. The row-quantity cache and persistent dense
789    /// Hessian cache are reset (they were keyed on β alone; the masked
790    /// family's `log_likelihood` and Hessian differ from the full-data
791    /// build at the same β so they must not alias). The subsample hash is
792    /// computed over `m` so that two distinct masks at the same β never
793    /// share a cache entry.
794    pub(crate) fn with_outer_subsample(
795        &self,
796        mask: &Array1<f64>,
797    ) -> Result<Self, TransformationNormalError> {
798        let n = self.weights.len();
799        if mask.len() != n {
800            bail_invalid_tnorm!(
801                "outer-score subsample mask length {} != n={}",
802                mask.len(),
803                n
804            );
805        }
806        let mut effective = Array1::<f64>::zeros(n);
807        for i in 0..n {
808            let m = mask[i];
809            if !m.is_finite() || m < 0.0 {
810                bail_invalid_tnorm!(
811                    "outer-score subsample mask[{i}] = {m} is invalid (must be finite and >= 0)"
812                );
813            }
814            effective[i] = self.weights[i] * m;
815        }
816        Ok(Self {
817            // Inherit immutable design / response state cheaply via Arc / clone.
818            x_val_kron: self.x_val_kron.clone(),
819            x_deriv_kron: self.x_deriv_kron.clone(),
820            response_val_basis: self.response_val_basis.clone(),
821            response_lower_basis: self.response_lower_basis.clone(),
822            response_upper_basis: self.response_upper_basis.clone(),
823            response_deriv_basis: self.response_deriv_basis.clone(),
824            covariate_design: self.covariate_design.clone(),
825            covariate_dense_cache: Arc::clone(&self.covariate_dense_cache),
826            weights: Arc::clone(&self.weights),
827            offset: Arc::clone(&self.offset),
828            tensor_penalties: self.tensor_penalties.clone(),
829            tensor_penalty_layout: self.tensor_penalty_layout,
830            initial_beta: self.initial_beta.clone(),
831            block_name: self.block_name.clone(),
832            response_knots: self.response_knots.clone(),
833            response_transform: self.response_transform.clone(),
834            response_degree: self.response_degree,
835            response_median: self.response_median,
836            response_floor_offset: Arc::clone(&self.response_floor_offset),
837            response_lower_floor_offset: self.response_lower_floor_offset,
838            response_upper_floor_offset: self.response_upper_floor_offset,
839            // Caches must NOT be shared between full-data and subsampled
840            // families: the row-quantity cache stores the LL (mask-dependent),
841            // and the persistent dense Hessian is keyed on β alone.
842            row_quantity_cache: Arc::new(Mutex::new(None)),
843            outer_subsample_weights: Some(Arc::new(effective)),
844        })
845    }
846
847    /// Build an outer-subsample clone from a `BlockwiseFitOptions` row mask,
848    /// returning `None` when no subsample is requested.
849    pub(crate) fn maybe_with_outer_subsample_from_options(
850        &self,
851        options: &BlockwiseFitOptions,
852    ) -> Result<Option<Self>, TransformationNormalError> {
853        let Some(sub) = options.outer_score_subsample.as_ref() else {
854            return Ok(None);
855        };
856        let n = self.weights.len();
857        let mut mask = Array1::<f64>::zeros(n);
858        for row in sub.rows.iter() {
859            if row.index < n {
860                mask[row.index] = row.weight;
861            }
862        }
863        Ok(Some(self.with_outer_subsample(&mask)?))
864    }
865
866    // --- Internal helpers ---
867
868    pub(crate) fn covariate_dense_arc(&self) -> Result<Arc<Array2<f64>>, String> {
869        let mut cache = self
870            .covariate_dense_cache
871            .lock()
872            .expect("CTN covariate dense cache mutex poisoned");
873        if let Some(cached) = cache.as_ref() {
874            return Ok(cached.clone());
875        }
876        let dense = Arc::new(
877            self.covariate_design
878                .try_row_chunk(0..self.response_val_basis.nrows())
879                .map_err(|e| format!("SCOP covariate dense materialization failed: {e}"))?,
880        );
881        *cache = Some(dense.clone());
882        Ok(dense)
883    }
884
885    pub(crate) fn row_quantities(
886        &self,
887        beta: &Array1<f64>,
888    ) -> Result<TransformationNormalRowQuantityCache, String> {
889        {
890            let cache = self
891                .row_quantity_cache
892                .lock()
893                .expect("CTN row quantity cache mutex poisoned");
894            if let Some(cached) = cache.as_ref().filter(|cached| cached.matches_beta(beta)) {
895                return Ok(cached.clone());
896            }
897        }
898
899        let p_resp = self.response_val_basis.ncols();
900        let p_cov = self.covariate_design.ncols();
901        let beta_mat = beta
902            .view()
903            .into_shape_with_order((p_resp, p_cov))
904            .map_err(|e| format!("SCOP endpoint beta reshape failed: {e}"))?;
905        let cov = self.covariate_dense_arc()?;
906
907        // Direct-α CTN (gam#2306): h(y, x) = α_0(x) + Σ_k α_k(x) · I_k(y),
908        // with α_k(x) = ψ(x)ᵀ A_{k,:} and h'(y, x) = Σ_k α_k(x) · M_k(y).
909        // Response column 0 is the unconstrained affine/location component;
910        // the remaining response columns are the shape coordinates, kept
911        // non-negative at every observation by the factored monotonicity cone
912        // (`block_linear_constraints`), NOT by a squared latent chart. h is
913        // exactly linear in the coefficients, so the function-space penalties
914        // are quadratic in the FINAL function and the likelihood curvature
915        // carries no chart second-derivative terms.
916        //
917        // The observed value, derivative value, and finite-support endpoints
918        // all depend on the same covariate-side α_k(x_i).  Compute α once and
919        // fan it out exactly.
920        let alpha = fast_abt(cov.as_ref(), &beta_mat);
921        let n = alpha.nrows();
922        let mut h = Array1::<f64>::zeros(n);
923        let mut h_prime = Array1::<f64>::zeros(n);
924        // Write directly into the preallocated arrays in parallel; the previous
925        // path collected a `Vec<(f64, f64)>` then serially scattered into these
926        // arrays, costing transient allocation per row and a single-threaded
927        // post-pass at large scale. The chart evaluator also returns the two
928        // support endpoints; since gam#2600 they are not a term of the
929        // likelihood, so the fit does not retain them (predict rebuilds them
930        // from the same evaluator when it needs the certified support).
931        ndarray::Zip::indexed(&mut h)
932            .and(&mut h_prime)
933            .par_for_each(|i, h_i, hp_i| {
934                let alpha_row = alpha.row(i);
935                let val_row = self.response_val_basis.row(i);
936                let deriv_row = self.response_deriv_basis.row(i);
937                // One chart, evaluated by one kernel (gam#2680).
938                let geometry = ctn_row_geometry(
939                    TransformationNormalParameterization::DirectAlpha,
940                    alpha_row,
941                    CtnRowBases {
942                        value: val_row,
943                        derivative: deriv_row,
944                        lower: self.response_lower_basis.view(),
945                        upper: self.response_upper_basis.view(),
946                    },
947                    CtnRowFloors {
948                        additive_offset: self.offset[i],
949                        value_floor: self.response_floor_offset[i],
950                        lower_floor: self.response_lower_floor_offset,
951                        upper_floor: self.response_upper_floor_offset,
952                    },
953                );
954                *h_i = geometry.h;
955                *hp_i = geometry.h_prime;
956            });
957        for (i, &value) in h.iter().enumerate() {
958            if !value.is_finite() {
959                return Err(TransformationNormalError::NonFinite {
960                    reason: format!(
961                        "TransformationNormalFamily row_quantities: h[{i}] = {value} is not finite"
962                    ),
963                }
964                .into());
965            }
966            if value.abs() > TRANSFORMATION_NORMAL_H_ABS_MAX {
967                return Err(TransformationNormalError::InvalidInput { reason: format!(
968                    "TransformationNormalFamily row_quantities: h[{i}] = {value:.6e} exceeds the standard-normal domain bound ±{TRANSFORMATION_NORMAL_H_ABS_MAX}"
969                ) }.into());
970            }
971        }
972        // Hard monotonicity / finiteness gate: the reciprocal powers `1/h'^k`
973        // for k ∈ {1,2,3,4} feed the gradient, Hessian, and psi-psi outer
974        // Hessian formulas. A non-finite or non-positive h' produces +∞ /
975        // signed-∞ reciprocals which then collide with zero-valued probe
976        // vectors (`v_*_deriv * weights`) to yield NaN entries throughout the
977        // dense psi-psi block (`hessian_psi_psi`). The likelihood gate in
978        // `evaluate` already rejects such β; surface the same error here so
979        // outer-Hessian probe callsites that call `row_quantities` directly
980        // (psi/psi second-order terms, etc.) produce a clean Err for the
981        // outer evaluator to retreat on, rather than a NaN dense block that
982        // routes a flagrant non-finite Hessian back into the planner.
983        let mut min_hp = f64::INFINITY;
984        let mut nonfinite_idx: Option<usize> = None;
985        for (i, &hp) in h_prime.iter().enumerate() {
986            if !hp.is_finite() {
987                nonfinite_idx = Some(i);
988                break;
989            }
990            if hp < min_hp {
991                min_hp = hp;
992            }
993        }
994        if let Some(i) = nonfinite_idx {
995            return Err(TransformationNormalError::NonFinite {
996                reason: format!(
997                    "TransformationNormalFamily row_quantities: h'[{i}] = {} is not finite",
998                    h_prime[i]
999                ),
1000            }
1001            .into());
1002        }
1003        if min_hp <= 0.0 {
1004            return Err(TransformationNormalError::MonotonicityViolated { reason: format!(
1005                "TransformationNormalFamily row_quantities: h' has non-positive values (min = {min_hp:.6e}). \
1006                 Monotonicity constraint may be violated."
1007            ) }.into());
1008        }
1009        // Compute exact f64 row derivatives. If any required reciprocal power
1010        // is outside the finite representable range, surface an evaluation
1011        // error so the outer solver can retreat; do not clamp or approximate
1012        // the analytic Hessian terms.
1013        let derived =
1014            build_transformation_row_derived(&h, &h_prime, self.effective_weights())?;
1015        let row_quantities = TransformationNormalRowQuantityCache {
1016            beta: Arc::new(beta.clone()),
1017            alpha: Arc::new(alpha),
1018            h: Arc::new(h),
1019            h_prime: Arc::new(h_prime),
1020            log_likelihood: derived.log_likelihood,
1021        };
1022
1023        let mut cache = self
1024            .row_quantity_cache
1025            .lock()
1026            .expect("CTN row quantity cache mutex poisoned");
1027        *cache = Some(row_quantities.clone());
1028        Ok(row_quantities)
1029    }
1030}