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