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