Skip to main content

gam_models/inference/
predict_io.rs

1use crate::bms::{
2    EmpiricalZGrid, LatentMeasureKind, LatentZConditionalCalibration, LatentZRankIntCalibration,
3    bernoulli_marginal_link_map, empirical_intercept_from_marginal,
4};
5use crate::inference::model::{SavedCompiledFlexBlock, SavedLatentZNormalization};
6use crate::marginal_slope_shared::{
7    ObservedDenestedCellPartials, eval_coeff4_at,
8    probit_frailty_scale as marginal_slope_probit_frailty_scale, scale_coeff4,
9};
10use crate::survival::lognormal_kernel::FrailtySpec;
11use gam_linalg::matrix::DesignMatrix;
12use gam_math::probability::{normal_cdf, normal_pdf};
13use gam_problem::types::{InverseLink, LikelihoodSpec};
14use gam_runtime::resource::prediction_chunk_rows;
15use gam_solve::estimate::{EstimationError, UnifiedFitResult};
16use ndarray::{Array1, Array2, ArrayView1};
17use rayon::iter::{IntoParallelIterator, ParallelIterator};
18
19pub struct PredictResult {
20    pub eta: Array1<f64>,
21    pub mean: Array1<f64>,
22}
23
24/// Input to prediction routines. Contains the design matrix and metadata
25/// needed for point prediction plus uncertainty quantification.
26pub struct PredictInput {
27    /// Design matrix for the primary (mean/location) block.
28    pub design: DesignMatrix,
29    /// Offset vector for the primary block.
30    pub offset: Array1<f64>,
31    /// Optional design matrix for the noise/scale block (GAMLSS/survival).
32    pub design_noise: Option<DesignMatrix>,
33    /// Optional offset vector for the noise/scale block.
34    pub offset_noise: Option<Array1<f64>>,
35    /// Optional auxiliary scalar covariate used by specialized predictors.
36    pub auxiliary_scalar: Option<Array1<f64>>,
37    /// Optional auxiliary matrix used by specialized predictors.
38    pub auxiliary_matrix: Option<Array2<f64>>,
39}
40
41pub struct BernoulliMarginalSlopePredictor {
42    pub beta_marginal: Array1<f64>,
43    pub beta_logslope: Array1<f64>,
44    pub beta_score_warp: Option<Array1<f64>>,
45    pub beta_link_dev: Option<Array1<f64>>,
46    pub base_link: InverseLink,
47    pub z_column: String,
48    pub latent_z_normalization: SavedLatentZNormalization,
49    pub latent_measure: LatentMeasureKind,
50    pub baseline_marginal: f64,
51    pub baseline_logslope: f64,
52    pub covariance: Option<Array2<f64>>,
53    pub score_warp_runtime: Option<SavedCompiledFlexBlock>,
54    pub link_deviation_runtime: Option<SavedCompiledFlexBlock>,
55    pub gaussian_frailty_sd: Option<f64>,
56    pub latent_z_calibration: Option<LatentZRankIntCalibration>,
57    pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
58}
59
60/// Per-runtime predict-time anchor correction matrices.
61///
62/// Built once per top-level predict call from the marginal + logslope
63/// designs at the prediction rows. Each `Array2<f64>` is shaped
64/// `n_predict × runtime.basis_dim` and holds `n_row(i) · M` for every
65/// prediction row, where `n_row(i)` is the concatenation of the marginal
66/// and logslope design rows in the runtime's anchor component order.
67///
68/// At any `local_cubic_at` / `basis_cubic_at` / `design` call site we
69/// subtract the appropriate slice of these matrices from the raw cubic
70/// output to apply the cross-block residual `n_row · M` correction.
71///
72/// `n_anchor_rows` is the underlying `n × d` parametric anchor stack
73/// (per-runtime layouts: score_warp gets `[marginal | logslope]`;
74/// link_dev gets `[marginal | logslope | score_warp_design(z)]` when the
75/// fit-time identifiability stage threaded the score-warp basis in as a
76/// flex-evaluation anchor). These layouts must match the column order
77/// `install_compiled_flex_block_into_runtime` used at fit time.
78#[derive(Default)]
79struct BmsAnchorCorrections {
80    /// `[marginal | logslope]` at predict rows. `Some` whenever any
81    /// runtime carries an anchor residual.
82    score_warp_anchor_rows: Option<Array2<f64>>,
83    /// `[marginal | logslope | score_warp_design(z)]` at predict rows.
84    /// `Some` whenever the link-deviation runtime carries an anchor
85    /// residual; the score-warp tail is included iff the saved
86    /// link-deviation runtime's residual components include a
87    /// `FlexEvaluation` entry.
88    link_dev_anchor_rows: Option<Array2<f64>>,
89    score_warp: Option<Array2<f64>>,
90    link_dev: Option<Array2<f64>>,
91}
92
93impl BmsAnchorCorrections {
94    fn score_warp_row(&self, row: usize) -> Option<ndarray::ArrayView1<'_, f64>> {
95        self.score_warp.as_ref().map(|m| m.row(row))
96    }
97
98    fn link_dev_row(&self, row: usize) -> Option<ndarray::ArrayView1<'_, f64>> {
99        self.link_dev.as_ref().map(|m| m.row(row))
100    }
101
102    fn score_warp_anchor_rows_view(&self) -> Option<ndarray::ArrayView2<'_, f64>> {
103        self.score_warp_anchor_rows.as_ref().map(|m| m.view())
104    }
105
106    fn link_dev_anchor_rows_view(&self) -> Option<ndarray::ArrayView2<'_, f64>> {
107        self.link_dev_anchor_rows.as_ref().map(|m| m.view())
108    }
109}
110
111impl BernoulliMarginalSlopePredictor {
112    /// Build the anchor correction matrices for a given predict-input batch.
113    ///
114    /// Returns an empty bundle (all `None`) when neither runtime carries
115    /// an anchor residual — this is the fast path for fits without
116    /// cross-block residualisation. When at least one runtime has a
117    /// residual, materialises the marginal + logslope designs at the
118    /// predict rows once and computes the per-runtime correction matrices
119    /// against each runtime's stored `M`.
120    fn build_anchor_correction_matrices(
121        &self,
122        input: &PredictInput,
123        design_logslope: &DesignMatrix,
124        z: &Array1<f64>,
125    ) -> Result<BmsAnchorCorrections, EstimationError> {
126        use crate::inference::model::SavedAnchorKind;
127        let needs_score = self
128            .score_warp_runtime
129            .as_ref()
130            .is_some_and(|r| r.anchor_correction.is_some());
131        let needs_link = self
132            .link_deviation_runtime
133            .as_ref()
134            .is_some_and(|r| r.anchor_correction.is_some());
135        if !needs_score && !needs_link {
136            return Ok(BmsAnchorCorrections::default());
137        }
138        // Materialise the marginal + logslope designs at predict rows.
139        // For large-scale predict batches the caller already chunks via
140        // `prediction_chunk_rows`, so this densification is bounded per
141        // chunk by `chunk_size × (p_marginal + p_logslope)`.
142        let marginal_dense = input
143            .design
144            .try_to_dense_arc(
145                "bernoulli marginal-slope predict-time marginal anchor materialisation",
146            )
147            .map_err(EstimationError::InvalidInput)?;
148        let logslope_dense = design_logslope
149            .try_to_dense_arc(
150                "bernoulli marginal-slope predict-time logslope anchor materialisation",
151            )
152            .map_err(EstimationError::InvalidInput)?;
153        let n_rows = marginal_dense.nrows();
154        if logslope_dense.nrows() != n_rows {
155            return Err(EstimationError::InvalidInput(format!(
156                "bernoulli marginal-slope predict anchor materialisation row mismatch: marginal {} vs logslope {}",
157                n_rows,
158                logslope_dense.nrows()
159            )));
160        }
161        if z.len() != n_rows {
162            return Err(EstimationError::InvalidInput(format!(
163                "bernoulli marginal-slope predict anchor materialisation: z has {} entries, expected {}",
164                z.len(),
165                n_rows
166            )));
167        }
168        let p_marginal = marginal_dense.ncols();
169        let p_logslope = logslope_dense.ncols();
170        let d_parametric = p_marginal + p_logslope;
171        let mut parametric_rows = Array2::<f64>::zeros((n_rows, d_parametric));
172        parametric_rows
173            .slice_mut(ndarray::s![.., 0..p_marginal])
174            .assign(&marginal_dense.view());
175        parametric_rows
176            .slice_mut(ndarray::s![.., p_marginal..d_parametric])
177            .assign(&logslope_dense.view());
178
179        // Score-warp anchor layout is `[marginal | logslope]` (parametric
180        // only; flex-flex anchoring goes the other direction).
181        let score_warp = if needs_score {
182            let runtime = self.score_warp_runtime.as_ref().unwrap();
183            self.validate_runtime_anchor_layout_parametric_only(runtime, "score_warp")?;
184            runtime
185                .anchor_correction_matrix(parametric_rows.view())
186                .map_err(EstimationError::from)?
187        } else {
188            None
189        };
190
191        // Link-deviation anchor layout matches the fit-time stacking in
192        // `install_compiled_flex_block_into_runtime`: parametric
193        // columns first, then (if a FlexEvaluation component is present)
194        // the score-warp runtime's reparameterised basis at predict rows.
195        let (link_dev_anchor_rows, link_dev) = if needs_link {
196            let runtime = self.link_deviation_runtime.as_ref().unwrap();
197            // Determine whether the saved link-dev residual carries a
198            // FlexEvaluation tail and validate ordering matches the
199            // fit-time invariant (all parametric components first, then
200            // at most one FlexEvaluation tail).
201            let mut saw_flex_tail = false;
202            let mut flex_tail_ncols: usize = 0;
203            for (idx, component) in runtime.anchor_components.iter().enumerate() {
204                match &component.kind {
205                    SavedAnchorKind::Parametric { .. } => {
206                        if saw_flex_tail {
207                            return Err(EstimationError::InvalidInput(format!(
208                                "bernoulli marginal-slope link-deviation saved anchor components \
209                                 are out of order: parametric component at index {idx} follows \
210                                 a FlexEvaluation tail",
211                            )));
212                        }
213                    }
214                    SavedAnchorKind::FlexEvaluation { ncols } => {
215                        if saw_flex_tail {
216                            return Err(EstimationError::InvalidInput(
217                                "bernoulli marginal-slope link-deviation saved anchor components \
218                                 carry more than one FlexEvaluation tail; fit-time stacking emits \
219                                 at most one (score-warp)"
220                                    .to_string(),
221                            ));
222                        }
223                        saw_flex_tail = true;
224                        flex_tail_ncols = *ncols;
225                    }
226                }
227            }
228            let rows = if saw_flex_tail {
229                let score_runtime = self.score_warp_runtime.as_ref().ok_or_else(|| {
230                    EstimationError::InvalidInput(
231                        "bernoulli marginal-slope link-deviation saved anchor includes a \
232                         FlexEvaluation tail but the saved score-warp runtime is missing"
233                            .to_string(),
234                    )
235                })?;
236                // Evaluate the score-warp runtime at predict-row z. When
237                // the score-warp itself carries an anchor residual, route
238                // through `design_with_anchor_rows` so the per-row
239                // subtraction is applied; otherwise the raw `design(z)`
240                // is the reparameterised basis.
241                let score_basis = if score_runtime.anchor_correction.is_some() {
242                    score_runtime
243                        .design_with_anchor_rows(z, parametric_rows.view())
244                        .map_err(EstimationError::from)?
245                } else {
246                    score_runtime.design(z).map_err(EstimationError::from)?
247                };
248                if score_basis.ncols() != flex_tail_ncols {
249                    return Err(EstimationError::InvalidInput(format!(
250                        "bernoulli marginal-slope link-deviation FlexEvaluation tail expects \
251                         {} score-warp basis columns at predict rows, got {}",
252                        flex_tail_ncols,
253                        score_basis.ncols()
254                    )));
255                }
256                let mut combined = Array2::<f64>::zeros((n_rows, d_parametric + flex_tail_ncols));
257                combined
258                    .slice_mut(ndarray::s![.., 0..d_parametric])
259                    .assign(&parametric_rows.view());
260                combined
261                    .slice_mut(ndarray::s![.., d_parametric..])
262                    .assign(&score_basis.view());
263                combined
264            } else {
265                parametric_rows.clone()
266            };
267            let corr = runtime
268                .anchor_correction_matrix(rows.view())
269                .map_err(EstimationError::from)?;
270            (Some(rows), corr)
271        } else {
272            (None, None)
273        };
274
275        Ok(BmsAnchorCorrections {
276            score_warp_anchor_rows: Some(parametric_rows),
277            link_dev_anchor_rows,
278            score_warp,
279            link_dev,
280        })
281    }
282
283    /// Validate that a saved deviation runtime's anchor residual contains
284    /// only `Parametric` components (no `FlexEvaluation` tail). Used for
285    /// the score-warp runtime, whose fit-time stacking is parametric-only.
286    fn validate_runtime_anchor_layout_parametric_only(
287        &self,
288        runtime: &SavedCompiledFlexBlock,
289        runtime_label: &str,
290    ) -> Result<(), EstimationError> {
291        use crate::inference::model::SavedAnchorKind;
292        for (idx, component) in runtime.anchor_components.iter().enumerate() {
293            match &component.kind {
294                SavedAnchorKind::Parametric { .. } => {}
295                SavedAnchorKind::FlexEvaluation { .. } => {
296                    return Err(EstimationError::InvalidInput(format!(
297                        "bernoulli marginal-slope {runtime_label} saved anchor component at \
298                         index {idx} is FlexEvaluation; only Parametric components are \
299                         expected for this runtime",
300                    )));
301                }
302            }
303        }
304        Ok(())
305    }
306
307    pub fn likelihood_family(&self) -> LikelihoodSpec {
308        LikelihoodSpec::binomial_probit()
309    }
310
311    pub fn mean_from_eta(&self, eta: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
312        Ok(eta.mapv(normal_cdf))
313    }
314
315    pub fn mean_derivative_from_eta(
316        &self,
317        eta: &Array1<f64>,
318    ) -> Result<Array1<f64>, EstimationError> {
319        Ok(eta.mapv(normal_pdf))
320    }
321
322    pub(crate) fn probit_frailty_scale(&self) -> f64 {
323        marginal_slope_probit_frailty_scale(self.gaussian_frailty_sd)
324    }
325
326    /// Apply the (optional) rank-INT latent-z calibration to a batch of
327    /// normalized predict-time z values.
328    ///
329    /// The calibration was fit on the training z + weights as a Blom-
330    /// rankit weighted rank inverse-normal transform; the calibrated
331    /// sample is N(0, 1) by construction (exact, not approximate), which
332    /// is why the BMS standard-normal closed-form kernel is correct on
333    /// the calibrated scale. At predict time, every z that flows into a
334    /// kernel evaluation site (`final_eta_and_gradient_from_theta`,
335    /// `predict_eta_and_q_chain`, and indirectly the per-row `solve_intercept_scalar`
336    /// / `evaluate_prediction_calibration` / `observed_denested_cell_partials_at_z`
337    /// helpers that consume per-row scalar z values from the closure-
338    /// captured `z` array) must be routed through the same monotone
339    /// transform. When `latent_z_calibration` is `None`, this returns
340    /// the input unchanged — that case corresponds to training-time z
341    /// having passed the strict normality check, so no transform was
342    /// applied at fit time either.
343    fn apply_latent_z_calibration(&self, z: &Array1<f64>) -> Array1<f64> {
344        match &self.latent_z_calibration {
345            Some(cal) => Array1::from_iter(z.iter().map(|&zi| cal.apply_at_predict(zi))),
346            None => z.clone(),
347        }
348    }
349
350    /// Apply the (optional) conditional location-scale latent-z calibration
351    /// (#905) to a batch of normalized predict-time z values.
352    ///
353    /// When `Some`, training detected a conditional `E[z|C]`/`Var(z|C)` shift
354    /// and replaced its latent score by `ζ = (z − m(C))/√v(C)`. The predictor
355    /// MUST apply the identical map, rebuilding the conditioning span `a(C)`
356    /// from the marginal prediction design (`input.design`) — the same span the
357    /// fit regressed z on. `None` ⇒ no conditional calibration was applied at
358    /// fit time, so z passes through unchanged (mutually exclusive with the
359    /// rank-INT calibration above).
360    fn apply_latent_z_conditional_calibration(
361        &self,
362        z: &Array1<f64>,
363        input: &PredictInput,
364    ) -> Result<Array1<f64>, EstimationError> {
365        let Some(cal) = self.latent_z_conditional_calibration.as_ref() else {
366            return Ok(z.clone());
367        };
368        let a_block = input.design.to_dense();
369        cal.apply(z.view(), a_block.view())
370            .map_err(EstimationError::InvalidInput)
371    }
372
373    fn rigid_intercept_from_marginal(&self, marginal_eta: f64, slope: f64) -> f64 {
374        let probit_scale = self.probit_frailty_scale();
375        marginal_eta * (1.0 + (probit_scale * slope).powi(2)).sqrt() / probit_scale
376    }
377
378    fn empirical_rigid_intercept_and_gradient(
379        &self,
380        marginal_eta: f64,
381        slope: f64,
382        nodes: &[f64],
383        weights: &[f64],
384    ) -> Result<(f64, f64, f64), EstimationError> {
385        let marginal = bernoulli_marginal_link_map(&self.base_link, marginal_eta)
386            .map_err(EstimationError::InvalidInput)?;
387        let scale = self.probit_frailty_scale();
388        let intercept = empirical_intercept_from_marginal(
389            marginal.mu,
390            marginal.q,
391            slope,
392            scale,
393            nodes,
394            weights,
395            None,
396        )
397        .map_err(EstimationError::InvalidInput)?;
398        let observed_slope = scale * slope;
399        let mut f_a = 0.0;
400        let mut f_b = 0.0;
401        for (&node, &weight) in nodes.iter().zip(weights.iter()) {
402            let eta = intercept + observed_slope * node;
403            let pdf = normal_pdf(eta);
404            f_a += weight * pdf;
405            f_b += weight * pdf * scale * node;
406        }
407        if !(f_a.is_finite() && f_a > 0.0 && f_b.is_finite()) {
408            return Err(EstimationError::InvalidInput(format!(
409                "empirical latent prediction calibration derivative is invalid: F_a={f_a}, F_b={f_b}"
410            )));
411        }
412        let a_marginal_eta = marginal.mu1 / f_a;
413        let a_slope = -f_b / f_a;
414        Ok((intercept, a_marginal_eta, a_slope))
415    }
416
417    fn local_empirical_mixture_for_point(
418        point: &[f64],
419        centers: &[Vec<f64>],
420        top_k: usize,
421        bandwidth: f64,
422    ) -> Result<Vec<(usize, f64)>, EstimationError> {
423        if centers.is_empty() {
424            return Err(EstimationError::InvalidInput(
425                "local empirical latent prediction has no centers".to_string(),
426            ));
427        }
428        if top_k == 0 {
429            return Err(EstimationError::InvalidInput(
430                "local empirical latent prediction top_k must be positive".to_string(),
431            ));
432        }
433        if !(bandwidth.is_finite() && bandwidth > 0.0) {
434            return Err(EstimationError::InvalidInput(format!(
435                "local empirical latent prediction bandwidth must be finite and positive, got {bandwidth}"
436            )));
437        }
438        let bw2 = bandwidth * bandwidth;
439        let mut distances = Vec::<(usize, f64)>::with_capacity(centers.len());
440        for (idx, center) in centers.iter().enumerate() {
441            if center.len() != point.len() {
442                return Err(EstimationError::InvalidInput(format!(
443                    "local empirical latent prediction center {idx} dimension mismatch: center={}, point={}",
444                    center.len(),
445                    point.len()
446                )));
447            }
448            let d2 = center
449                .iter()
450                .zip(point.iter())
451                .map(|(&c, &x)| {
452                    let delta = x - c;
453                    delta * delta
454                })
455                .sum::<f64>();
456            if !d2.is_finite() {
457                return Err(EstimationError::InvalidInput(
458                    "local empirical latent prediction distance is non-finite".to_string(),
459                ));
460            }
461            distances.push((idx, d2));
462        }
463        distances.sort_by(|left, right| {
464            left.1
465                .partial_cmp(&right.1)
466                .expect("validated local empirical distances are finite")
467        });
468        let k = top_k.min(distances.len());
469        let mut mixture = Vec::with_capacity(k);
470        let mut total = 0.0;
471        for &(idx, d2) in distances.iter().take(k) {
472            let weight = (-0.5 * d2 / bw2).exp().max(1e-300);
473            mixture.push((idx, weight));
474            total += weight;
475        }
476        if !(total.is_finite() && total > 0.0) {
477            return Err(EstimationError::InvalidInput(
478                "local empirical latent prediction mixture has non-positive total weight"
479                    .to_string(),
480            ));
481        }
482        for (_, weight) in &mut mixture {
483            *weight /= total;
484        }
485        Ok(mixture)
486    }
487
488    fn combine_empirical_grids(
489        grids: &[EmpiricalZGrid],
490        mixture: &[(usize, f64)],
491    ) -> Result<EmpiricalZGrid, EstimationError> {
492        let total_len = mixture
493            .iter()
494            .map(|&(idx, _)| grids.get(idx).map_or(0, |grid| grid.nodes.len()))
495            .sum::<usize>();
496        let mut nodes = Vec::with_capacity(total_len);
497        let mut weights = Vec::with_capacity(total_len);
498        let mut total_weight = 0.0;
499        for &(grid_idx, grid_weight) in mixture {
500            if !(grid_weight.is_finite() && grid_weight >= 0.0) {
501                return Err(EstimationError::InvalidInput(format!(
502                    "local empirical latent prediction mixture weight must be finite and non-negative, got {grid_weight}"
503                )));
504            }
505            let grid = grids.get(grid_idx).ok_or_else(|| {
506                EstimationError::InvalidInput(format!(
507                    "local empirical latent prediction grid index {grid_idx} is out of bounds for {} grids",
508                    grids.len()
509                ))
510            })?;
511            if grid.nodes.len() != grid.weights.len() || grid.nodes.is_empty() {
512                return Err(EstimationError::InvalidInput(format!(
513                    "local empirical latent prediction grid {grid_idx} is invalid: nodes={}, weights={}",
514                    grid.nodes.len(),
515                    grid.weights.len()
516                )));
517            }
518            for (node, weight) in grid.pairs() {
519                let combined_weight = grid_weight * weight;
520                if !(node.is_finite() && combined_weight.is_finite() && combined_weight >= 0.0) {
521                    return Err(EstimationError::InvalidInput(
522                        "local empirical latent prediction grid contains invalid node/weight"
523                            .to_string(),
524                    ));
525                }
526                nodes.push(node);
527                weights.push(combined_weight);
528                total_weight += combined_weight;
529            }
530        }
531        if !(total_weight.is_finite() && total_weight > 0.0) {
532            return Err(EstimationError::InvalidInput(
533                "local empirical latent prediction combined grid has non-positive total weight"
534                    .to_string(),
535            ));
536        }
537        for weight in &mut weights {
538            *weight /= total_weight;
539        }
540        Ok(EmpiricalZGrid { nodes, weights })
541    }
542
543    fn empirical_grid_for_prediction_row(
544        &self,
545        input: &PredictInput,
546        row: usize,
547    ) -> Result<Option<EmpiricalZGrid>, EstimationError> {
548        match &self.latent_measure {
549            LatentMeasureKind::StandardNormal => Ok(None),
550            LatentMeasureKind::GlobalEmpirical { grid } => Ok(Some(grid.clone())),
551            LatentMeasureKind::LocalEmpirical {
552                centers,
553                grids,
554                top_k,
555                bandwidth,
556                ..
557            } => {
558                let conditioning = input.auxiliary_matrix.as_ref().ok_or_else(|| {
559                    EstimationError::InvalidInput(
560                        "bernoulli marginal-slope local empirical prediction requires auxiliary conditioning matrix"
561                            .to_string(),
562                    )
563                })?;
564                if row >= conditioning.nrows() {
565                    return Err(EstimationError::InvalidInput(format!(
566                        "local empirical latent prediction row {row} is out of bounds for {} conditioning rows",
567                        conditioning.nrows()
568                    )));
569                }
570                let expected_dim = centers.first().map_or(0, Vec::len);
571                if conditioning.ncols() != expected_dim {
572                    return Err(EstimationError::InvalidInput(format!(
573                        "local empirical latent prediction conditioning dimension mismatch: got {}, expected {expected_dim}",
574                        conditioning.ncols()
575                    )));
576                }
577                let point = conditioning.row(row).to_vec();
578                let mixture =
579                    Self::local_empirical_mixture_for_point(&point, centers, *top_k, *bandwidth)?;
580                Self::combine_empirical_grids(grids, &mixture).map(Some)
581            }
582        }
583    }
584
585    fn transform_internal_eta_to_base_scale(
586        &self,
587        internal_eta: Array1<f64>,
588        internal_grad: Option<Array2<f64>>,
589    ) -> Result<(Array1<f64>, Option<Array2<f64>>), EstimationError> {
590        Ok((internal_eta, internal_grad))
591    }
592
593    fn link_terms_value_d1(
594        &self,
595        eta0: &Array1<f64>,
596        beta_link_dev: Option<&Array1<f64>>,
597        link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
598    ) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
599        if let (Some(runtime), Some(beta)) = (&self.link_deviation_runtime, beta_link_dev) {
600            // When the runtime carries a cross-block anchor residual, every
601            // raw-design row needs `n_row · M` subtracted. `correction_for_row`
602            // already holds the precomputed `n_row · M` for this predict row
603            // (length basis_dim), so the corrected basis contribution to η
604            // is `basis · beta - correction.dot(beta)` for every eta0 entry.
605            // Derivative paths are unaffected (the anchor argument is a
606            // different scalar than eta0).
607            let basis = runtime
608                .design_uncorrected(eta0)
609                .map_err(EstimationError::from)?;
610            let mut value = &basis.dot(beta) + eta0;
611            if let Some(corr) = link_dev_correction_for_row {
612                let offset = corr.dot(beta);
613                for v in value.iter_mut() {
614                    *v -= offset;
615                }
616            } else if runtime.anchor_correction.is_some() {
617                return Err(EstimationError::InvalidInput(
618                    "bernoulli marginal-slope link-deviation runtime has an anchor residual but \
619                     no per-row correction was supplied to link_terms_value_d1"
620                        .to_string(),
621                ));
622            }
623            let d1 = runtime
624                .first_derivative_design(eta0)
625                .map_err(EstimationError::from)?;
626            Ok((value, d1.dot(beta) + 1.0))
627        } else {
628            Ok((eta0.clone(), Array1::ones(eta0.len())))
629        }
630    }
631
632    fn denested_partition_cells(
633        &self,
634        a: f64,
635        b: f64,
636        beta_score_warp: Option<&Array1<f64>>,
637        beta_link_dev: Option<&Array1<f64>>,
638        score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
639        link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
640    ) -> Result<Vec<crate::cubic_cell_kernel::DenestedPartitionCell>, EstimationError> {
641        let score_breaks = if let Some(runtime) = self.score_warp_runtime.as_ref() {
642            runtime.breakpoints().map_err(EstimationError::from)?
643        } else {
644            Vec::new()
645        };
646        let link_breaks = if let Some(runtime) = self.link_deviation_runtime.as_ref() {
647            runtime.breakpoints().map_err(EstimationError::from)?
648        } else {
649            Vec::new()
650        };
651        let mut cells = crate::cubic_cell_kernel::build_denested_partition_cells_with_tails(
652            a,
653            b,
654            &score_breaks,
655            &link_breaks,
656            |z| {
657                if let (Some(runtime), Some(beta)) =
658                    (self.score_warp_runtime.as_ref(), beta_score_warp)
659                {
660                    let mut span = runtime.local_cubic_at(beta, z)?;
661                    // `local_cubic_at`'s c0 is `Σ_j basis_c0[span][j] · beta[j]`.
662                    // The cross-block residual replaces basis_c0 by
663                    // basis_c0 − n_row · M, contributing a row-constant
664                    // `correction.dot(beta)` to c0. Higher coefficients
665                    // (c1..c3) depend on derivatives of the basis w.r.t.
666                    // its own argument and are untouched.
667                    if let Some(corr) = score_warp_correction_for_row {
668                        span.c0 -= corr.dot(beta);
669                    }
670                    Ok(span)
671                } else {
672                    Ok(crate::cubic_cell_kernel::LocalSpanCubic {
673                        left: 0.0,
674                        right: 1.0,
675                        c0: 0.0,
676                        c1: 0.0,
677                        c2: 0.0,
678                        c3: 0.0,
679                    })
680                }
681            },
682            |u| {
683                if let (Some(runtime), Some(beta)) =
684                    (self.link_deviation_runtime.as_ref(), beta_link_dev)
685                {
686                    let mut span = runtime.local_cubic_at(beta, u)?;
687                    if let Some(corr) = link_dev_correction_for_row {
688                        span.c0 -= corr.dot(beta);
689                    }
690                    Ok(span)
691                } else {
692                    Ok(crate::cubic_cell_kernel::LocalSpanCubic {
693                        left: 0.0,
694                        right: 1.0,
695                        c0: 0.0,
696                        c1: 0.0,
697                        c2: 0.0,
698                        c3: 0.0,
699                    })
700                }
701            },
702        )
703        .map_err(EstimationError::InvalidInput)?;
704        let scale = self.probit_frailty_scale();
705        if scale != 1.0 {
706            for partition_cell in &mut cells {
707                partition_cell.cell.c0 *= scale;
708                partition_cell.cell.c1 *= scale;
709                partition_cell.cell.c2 *= scale;
710                partition_cell.cell.c3 *= scale;
711            }
712        }
713        Ok(cells)
714    }
715
716    fn evaluate_denested_calibration(
717        &self,
718        a: f64,
719        marginal_eta: f64,
720        slope: f64,
721        beta_score_warp: Option<&Array1<f64>>,
722        beta_link_dev: Option<&Array1<f64>>,
723        score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
724        link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
725    ) -> Result<(f64, f64, f64), EstimationError> {
726        let marginal = bernoulli_marginal_link_map(&self.base_link, marginal_eta)
727            .map_err(EstimationError::InvalidInput)?;
728        let cells = self.denested_partition_cells(
729            a,
730            slope,
731            beta_score_warp,
732            beta_link_dev,
733            score_warp_correction_for_row,
734            link_dev_correction_for_row,
735        )?;
736        let scale = self.probit_frailty_scale();
737        let mut f = -marginal.mu;
738        let mut f_a = 0.0;
739        let mut f_aa = 0.0;
740        for partition_cell in cells {
741            let cell = partition_cell.cell;
742            let (dc_da_raw, _) = crate::cubic_cell_kernel::denested_cell_coefficient_partials(
743                partition_cell.score_span,
744                partition_cell.link_span,
745                a,
746                slope,
747            );
748            let (d2c_da2_raw, _, _) = crate::cubic_cell_kernel::denested_cell_second_partials(
749                partition_cell.score_span,
750                partition_cell.link_span,
751                a,
752                slope,
753            );
754            let dc_da = scale_coeff4(dc_da_raw, scale);
755            let d2c_da2 = scale_coeff4(d2c_da2_raw, scale);
756            // Derive the moment `max_degree` from the contractions consumed
757            // below, instead of hardcoding a magic constant. The second-
758            // derivative contraction dominates the first-derivative one, so
759            // its required degree is the binding bound. Hardcoding 7 here
760            // produced 8 moments while the contraction needs 10 (#321).
761            let max_degree = crate::cubic_cell_kernel::cell_second_derivative_required_max_degree(
762                &dc_da, &dc_da, &d2c_da2,
763            );
764            let state = crate::cubic_cell_kernel::evaluate_cell_moments(cell, max_degree)
765                .map_err(EstimationError::InvalidInput)?;
766            f += state.value;
767            f_a += crate::cubic_cell_kernel::cell_first_derivative_from_moments(
768                &dc_da,
769                &state.moments,
770            )
771            .map_err(EstimationError::InvalidInput)?;
772            f_aa += crate::cubic_cell_kernel::cell_second_derivative_from_moments(
773                cell,
774                &dc_da,
775                &dc_da,
776                &d2c_da2,
777                &state.moments,
778            )
779            .map_err(EstimationError::InvalidInput)?;
780        }
781        Ok((f, f_a, f_aa))
782    }
783
784    fn observed_denested_cell_partials_at_z(
785        &self,
786        z_value: f64,
787        a: f64,
788        b: f64,
789        beta_score_warp: Option<&Array1<f64>>,
790        beta_link_dev: Option<&Array1<f64>>,
791        score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
792        link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
793    ) -> Result<ObservedDenestedCellPartials, EstimationError> {
794        use crate::cubic_cell_kernel as exact;
795
796        let zero_span = exact::LocalSpanCubic {
797            left: 0.0,
798            right: 1.0,
799            c0: 0.0,
800            c1: 0.0,
801            c2: 0.0,
802            c3: 0.0,
803        };
804        let u_value = a + b * z_value;
805        let score_span = if let (Some(runtime), Some(beta)) =
806            (self.score_warp_runtime.as_ref(), beta_score_warp)
807        {
808            let mut span = runtime
809                .local_cubic_at(beta, z_value)
810                .map_err(EstimationError::from)?;
811            if let Some(corr) = score_warp_correction_for_row {
812                span.c0 -= corr.dot(beta);
813            }
814            span
815        } else {
816            zero_span
817        };
818        let link_span = if let (Some(runtime), Some(beta)) =
819            (self.link_deviation_runtime.as_ref(), beta_link_dev)
820        {
821            let mut span = runtime
822                .local_cubic_at(beta, u_value)
823                .map_err(EstimationError::from)?;
824            if let Some(corr) = link_dev_correction_for_row {
825                span.c0 -= corr.dot(beta);
826            }
827            span
828        } else {
829            zero_span
830        };
831        let scale = self.probit_frailty_scale();
832        let coeff = scale_coeff4(
833            exact::denested_cell_coefficients(score_span, link_span, a, b),
834            scale,
835        );
836        let (dc_da_raw, dc_db_raw) =
837            exact::denested_cell_coefficient_partials(score_span, link_span, a, b);
838        let (dc_daa_raw, dc_dab_raw, dc_dbb_raw) =
839            exact::denested_cell_second_partials(score_span, link_span, a, b);
840        let (dc_daaa, dc_daab, dc_dabb, dc_dbbb) = exact::denested_cell_third_partials(link_span);
841        Ok(ObservedDenestedCellPartials {
842            coeff,
843            dc_da: scale_coeff4(dc_da_raw, scale),
844            dc_db: scale_coeff4(dc_db_raw, scale),
845            dc_daa: scale_coeff4(dc_daa_raw, scale),
846            dc_dab: scale_coeff4(dc_dab_raw, scale),
847            dc_dbb: scale_coeff4(dc_dbb_raw, scale),
848            dc_daaa: scale_coeff4(dc_daaa, scale),
849            dc_daab: scale_coeff4(dc_daab, scale),
850            dc_dabb: scale_coeff4(dc_dabb, scale),
851            dc_dbbb: scale_coeff4(dc_dbbb, scale),
852        })
853    }
854
855    fn evaluate_empirical_denested_calibration(
856        &self,
857        a: f64,
858        marginal_eta: f64,
859        slope: f64,
860        beta_score_warp: Option<&Array1<f64>>,
861        beta_link_dev: Option<&Array1<f64>>,
862        grid: &EmpiricalZGrid,
863        score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
864        link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
865    ) -> Result<(f64, f64, f64), EstimationError> {
866        let marginal = bernoulli_marginal_link_map(&self.base_link, marginal_eta)
867            .map_err(EstimationError::InvalidInput)?;
868        let mut f = -marginal.mu;
869        let mut f_a = 0.0;
870        let mut f_aa = 0.0;
871        for (node, weight) in grid.pairs() {
872            let obs = self.observed_denested_cell_partials_at_z(
873                node,
874                a,
875                slope,
876                beta_score_warp,
877                beta_link_dev,
878                score_warp_correction_for_row,
879                link_dev_correction_for_row,
880            )?;
881            let eta = eval_coeff4_at(&obs.coeff, node);
882            let eta_a = eval_coeff4_at(&obs.dc_da, node);
883            let eta_aa = eval_coeff4_at(&obs.dc_daa, node);
884            let pdf = normal_pdf(eta);
885            f += weight * normal_cdf(eta);
886            f_a += weight * pdf * eta_a;
887            f_aa += weight * pdf * (eta_aa - eta * eta_a * eta_a);
888        }
889        Ok((f, f_a, f_aa))
890    }
891
892    fn evaluate_prediction_calibration(
893        &self,
894        a: f64,
895        marginal_eta: f64,
896        slope: f64,
897        beta_score_warp: Option<&Array1<f64>>,
898        beta_link_dev: Option<&Array1<f64>>,
899        empirical_grid: Option<&EmpiricalZGrid>,
900        score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
901        link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
902    ) -> Result<(f64, f64, f64), EstimationError> {
903        if let Some(grid) = empirical_grid {
904            self.evaluate_empirical_denested_calibration(
905                a,
906                marginal_eta,
907                slope,
908                beta_score_warp,
909                beta_link_dev,
910                grid,
911                score_warp_correction_for_row,
912                link_dev_correction_for_row,
913            )
914        } else {
915            self.evaluate_denested_calibration(
916                a,
917                marginal_eta,
918                slope,
919                beta_score_warp,
920                beta_link_dev,
921                score_warp_correction_for_row,
922                link_dev_correction_for_row,
923            )
924        }
925    }
926
927    pub fn from_unified(
928        unified: &UnifiedFitResult,
929        z_column: String,
930        latent_z_normalization: SavedLatentZNormalization,
931        latent_measure: LatentMeasureKind,
932        baseline_marginal: f64,
933        baseline_logslope: f64,
934        base_link: InverseLink,
935        frailty: FrailtySpec,
936        score_warp_runtime: Option<SavedCompiledFlexBlock>,
937        link_deviation_runtime: Option<SavedCompiledFlexBlock>,
938        latent_z_calibration: Option<crate::bms::LatentZRankIntCalibration>,
939        latent_z_conditional_calibration: Option<crate::bms::LatentZConditionalCalibration>,
940    ) -> Result<Self, String> {
941        let gaussian_frailty_sd = match frailty {
942            FrailtySpec::None => None,
943            FrailtySpec::GaussianShift {
944                sigma_fixed: Some(sigma),
945            } => Some(sigma),
946            FrailtySpec::GaussianShift { sigma_fixed: None } => {
947                return Err(
948                    "bernoulli marginal-slope predictor requires a fixed GaussianShift sigma"
949                        .to_string(),
950                );
951            }
952            FrailtySpec::HazardMultiplier { .. } => {
953                return Err(
954                    "bernoulli marginal-slope predictor does not support HazardMultiplier frailty"
955                        .to_string(),
956                );
957            }
958        };
959        if !matches!(
960            base_link,
961            InverseLink::Standard(gam_problem::types::StandardLink::Probit)
962        ) {
963            return Err(
964                "bernoulli marginal-slope predictor requires link(type=probit); saved non-probit marginal-slope models must be refit"
965                    .to_string(),
966            );
967        }
968        if let Some(runtime) = score_warp_runtime.as_ref() {
969            runtime.validate_exact_replay_contract().map_err(|e| {
970                format!("bernoulli marginal-slope score-warp runtime is invalid: {e}")
971            })?;
972        }
973        if let Some(runtime) = link_deviation_runtime.as_ref() {
974            runtime.validate_exact_replay_contract().map_err(|e| {
975                format!("bernoulli marginal-slope link-deviation runtime is invalid: {e}")
976            })?;
977        }
978        // Cross-block anchor residuals on either runtime are now applied
979        // per-row by every predict-time `local_cubic_at` / `basis_cubic_at`
980        // / `design` call site via `build_anchor_correction_matrices`.
981        latent_z_normalization
982            .validate("bernoulli marginal-slope predictor")
983            .map_err(|e| {
984                format!("bernoulli marginal-slope predictor latent z normalization is invalid: {e}")
985            })?;
986        latent_measure
987            .validate("bernoulli marginal-slope predictor latent measure")
988            .map_err(|e| {
989                format!("bernoulli marginal-slope predictor latent measure is invalid: {e}")
990            })?;
991        let blocks = &unified.blocks;
992        let expected_blocks = 2
993            + usize::from(score_warp_runtime.is_some())
994            + usize::from(link_deviation_runtime.is_some());
995        if blocks.len() != expected_blocks {
996            return Err(format!(
997                "bernoulli marginal-slope predictor requires exactly {expected_blocks} coefficient blocks under the current exact de-nested semantics, got {}",
998                blocks.len()
999            ));
1000        }
1001        let mut cursor = 2usize;
1002        let beta_score_warp = if score_warp_runtime.is_some() {
1003            let beta = blocks
1004                .get(cursor)
1005                .ok_or_else(|| "missing score-warp coefficient block".to_string())?
1006                .beta
1007                .clone();
1008            cursor += 1;
1009            Some(beta)
1010        } else {
1011            None
1012        };
1013        let beta_link_dev = if link_deviation_runtime.is_some() {
1014            Some(
1015                blocks
1016                    .get(cursor)
1017                    .ok_or_else(|| "missing link-deviation coefficient block".to_string())?
1018                    .beta
1019                    .clone(),
1020            )
1021        } else {
1022            None
1023        };
1024        Ok(Self {
1025            beta_marginal: blocks[0].beta.clone(),
1026            beta_logslope: blocks[1].beta.clone(),
1027            beta_score_warp,
1028            beta_link_dev,
1029            base_link,
1030            z_column,
1031            latent_z_normalization,
1032            latent_measure,
1033            baseline_marginal,
1034            baseline_logslope,
1035            covariance: unified.beta_covariance().cloned(),
1036            score_warp_runtime,
1037            link_deviation_runtime,
1038            gaussian_frailty_sd,
1039            latent_z_calibration,
1040            latent_z_conditional_calibration,
1041        })
1042    }
1043
1044    pub fn theta(&self) -> Array1<f64> {
1045        let total = self.beta_marginal.len()
1046            + self.beta_logslope.len()
1047            + self.beta_score_warp.as_ref().map_or(0, |b| b.len())
1048            + self.beta_link_dev.as_ref().map_or(0, |b| b.len());
1049        let mut theta = Array1::<f64>::zeros(total);
1050        let mut cursor = 0usize;
1051        theta
1052            .slice_mut(ndarray::s![cursor..cursor + self.beta_marginal.len()])
1053            .assign(&self.beta_marginal);
1054        cursor += self.beta_marginal.len();
1055        theta
1056            .slice_mut(ndarray::s![cursor..cursor + self.beta_logslope.len()])
1057            .assign(&self.beta_logslope);
1058        cursor += self.beta_logslope.len();
1059        if let Some(beta) = self.beta_score_warp.as_ref() {
1060            theta
1061                .slice_mut(ndarray::s![cursor..cursor + beta.len()])
1062                .assign(beta);
1063            cursor += beta.len();
1064        }
1065        if let Some(beta) = self.beta_link_dev.as_ref() {
1066            theta
1067                .slice_mut(ndarray::s![cursor..cursor + beta.len()])
1068                .assign(beta);
1069        }
1070        theta
1071    }
1072
1073    fn split_theta<'a>(
1074        &'a self,
1075        theta: &'a Array1<f64>,
1076    ) -> Result<
1077        (
1078            ArrayView1<'a, f64>,
1079            ArrayView1<'a, f64>,
1080            Option<ArrayView1<'a, f64>>,
1081            Option<ArrayView1<'a, f64>>,
1082        ),
1083        EstimationError,
1084    > {
1085        let expected = self.theta().len();
1086        if theta.len() != expected {
1087            return Err(EstimationError::InvalidInput(format!(
1088                "bernoulli marginal-slope theta length mismatch: expected {expected}, got {}",
1089                theta.len()
1090            )));
1091        }
1092        let mut cursor = 0usize;
1093        let marginal = theta.slice(ndarray::s![cursor..cursor + self.beta_marginal.len()]);
1094        cursor += self.beta_marginal.len();
1095        let logslope = theta.slice(ndarray::s![cursor..cursor + self.beta_logslope.len()]);
1096        cursor += self.beta_logslope.len();
1097        let score_warp = self.beta_score_warp.as_ref().map(|beta| {
1098            let view = theta.slice(ndarray::s![cursor..cursor + beta.len()]);
1099            cursor += beta.len();
1100            view
1101        });
1102        let link_dev = self
1103            .beta_link_dev
1104            .as_ref()
1105            .map(|beta| theta.slice(ndarray::s![cursor..cursor + beta.len()]));
1106        Ok((marginal, logslope, score_warp, link_dev))
1107    }
1108
1109    /// Safeguarded monotone root solve for the marginal intercept under the
1110    /// de-nested flexible model
1111    ///   η(z) = a + b z + b Δ_h(z) + Δ_w(a + b z).
1112    fn solve_intercept_scalar(
1113        &self,
1114        marginal_eta: f64,
1115        slope: f64,
1116        link_dev_beta: Option<&Array1<f64>>,
1117        score_warp_beta: Option<&Array1<f64>>,
1118        empirical_grid: Option<&EmpiricalZGrid>,
1119        warm_start_buf: &mut Array1<f64>,
1120        score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
1121        link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
1122    ) -> Result<f64, EstimationError> {
1123        let marginal = bernoulli_marginal_link_map(&self.base_link, marginal_eta)
1124            .map_err(EstimationError::InvalidInput)?;
1125        let eval = |a: f64| -> Result<(f64, f64, f64), String> {
1126            self.evaluate_prediction_calibration(
1127                a,
1128                marginal_eta,
1129                slope,
1130                score_warp_beta,
1131                link_dev_beta,
1132                empirical_grid,
1133                score_warp_correction_for_row,
1134                link_dev_correction_for_row,
1135            )
1136            .map_err(|err| err.to_string())
1137        };
1138
1139        let probit_scale = self.probit_frailty_scale();
1140        let a_rigid = self.rigid_intercept_from_marginal(marginal.q, slope);
1141        let mut intercept = a_rigid;
1142        if let (Some(_), Some(beta)) = (self.link_deviation_runtime.as_ref(), link_dev_beta) {
1143            warm_start_buf[0] = a_rigid;
1144            let one_pt = warm_start_buf.slice(ndarray::s![0..1]).to_owned();
1145            let (l_val, l_d1) =
1146                self.link_terms_value_d1(&one_pt, Some(beta), link_dev_correction_for_row)?;
1147            let ell1 = l_d1[0];
1148            if ell1 > 1e-8 {
1149                let ell0 = l_val[0] - ell1 * a_rigid;
1150                let observed_logslope = probit_scale * ell1 * slope;
1151                intercept = (marginal.q * (1.0 + observed_logslope * observed_logslope).sqrt()
1152                    / probit_scale
1153                    - ell0)
1154                    / ell1;
1155            }
1156        }
1157
1158        // Same adaptive tolerance the acceptance check below uses; passing
1159        // a tighter `convergence_tol` would just iterate past what we accept.
1160        let target = marginal.mu;
1161        let abs_tol = 1e-8_f64.max(1e-4 * target.abs());
1162
1163        let (root, _, f_best) = crate::monotone_root::solve_monotone_root(
1164            eval,
1165            intercept,
1166            "saved bernoulli intercept",
1167            abs_tol,
1168            64,
1169            48,
1170        )?;
1171
1172        if f_best.abs() > abs_tol {
1173            return Err(EstimationError::InvalidInput(format!(
1174                "saved bernoulli marginal-slope intercept solve failed: residual={f_best:.3e} at a={root:.6}, target mu={target:.6}"
1175            )));
1176        }
1177        Ok(root)
1178    }
1179
1180    pub fn final_eta_and_gradient_from_theta(
1181        &self,
1182        input: &PredictInput,
1183        theta: &Array1<f64>,
1184        need_gradient: bool,
1185    ) -> Result<(Array1<f64>, Option<Array2<f64>>), EstimationError> {
1186        let z_raw = input.auxiliary_scalar.as_ref().ok_or_else(|| {
1187            EstimationError::InvalidInput(format!(
1188                "bernoulli marginal-slope prediction requires auxiliary z column '{}'",
1189                self.z_column
1190            ))
1191        })?;
1192        let z_normalized = self
1193            .latent_z_normalization
1194            .apply(z_raw, "bernoulli marginal-slope prediction")
1195            .map_err(EstimationError::from)?;
1196        // P4: when training applied a rank-INT calibration to the latent
1197        // z (so the BMS rigid kernel could use the closed-form
1198        // standard-normal path), the predictor MUST apply the same
1199        // monotone transform to predict-time z before any kernel
1200        // evaluation. The transform is mathematically exact: piecewise-
1201        // linear interpolation on (sorted_z, weighted_cdf) followed by
1202        // Φ⁻¹, both strictly monotone and invertible up to the empirical
1203        // CDF resolution. `None` ⇒ training-time z passed the strict
1204        // normality check, no transform was applied, leave z unchanged.
1205        let z = self.apply_latent_z_calibration(&z_normalized);
1206        // #905: replace z by ζ = (z − m(C))/√v(C) when training engaged the
1207        // conditional Auto gate (no-op otherwise; mutually exclusive with the
1208        // rank-INT calibration above).
1209        let z = self.apply_latent_z_conditional_calibration(&z, input)?;
1210        let design_logslope = input.design_noise.as_ref().ok_or_else(|| {
1211            EstimationError::InvalidInput(
1212                "bernoulli marginal-slope prediction requires logslope design".to_string(),
1213            )
1214        })?;
1215        let (beta_marginal, beta_logslope, beta_score_warp, beta_link_dev) =
1216            self.split_theta(theta)?;
1217        if self.score_warp_runtime.is_some() != beta_score_warp.is_some() {
1218            return Err(EstimationError::InvalidInput(
1219                "bernoulli marginal-slope saved score-warp runtime/coefficients are inconsistent"
1220                    .to_string(),
1221            ));
1222        }
1223        if self.link_deviation_runtime.is_some() != beta_link_dev.is_some() {
1224            return Err(EstimationError::InvalidInput(
1225                "bernoulli marginal-slope saved link-deviation runtime/coefficients are inconsistent"
1226                    .to_string(),
1227            ));
1228        }
1229        let n = z.len();
1230        if input.offset.len() != n {
1231            return Err(EstimationError::InvalidInput(format!(
1232                "bernoulli marginal-slope prediction primary offset length mismatch: rows={n}, offset={}",
1233                input.offset.len()
1234            )));
1235        }
1236        let logslope_offset = input
1237            .offset_noise
1238            .as_ref()
1239            .map_or_else(|| Array1::zeros(n), Clone::clone);
1240        if logslope_offset.len() != n {
1241            return Err(EstimationError::InvalidInput(format!(
1242                "bernoulli marginal-slope prediction logslope offset length mismatch: rows={n}, offset_noise={}",
1243                logslope_offset.len()
1244            )));
1245        }
1246        let marginal_eta = input
1247            .design
1248            .dot(&beta_marginal.to_owned())
1249            .mapv(|v| v + self.baseline_marginal)
1250            + &input.offset;
1251        let logslope_eta = design_logslope
1252            .dot(&beta_logslope.to_owned())
1253            .mapv(|v| v + self.baseline_logslope)
1254            + &logslope_offset;
1255        let flex_active =
1256            self.score_warp_runtime.is_some() || self.link_deviation_runtime.is_some();
1257        let marginal_dim = self.beta_marginal.len();
1258        let logslope_dim = self.beta_logslope.len();
1259        let score_warp_dim = self.beta_score_warp.as_ref().map_or(0, Array1::len);
1260        let link_dev_dim = self.beta_link_dev.as_ref().map_or(0, Array1::len);
1261        let logslope_offset = marginal_dim;
1262        let score_warp_offset = logslope_offset + logslope_dim;
1263        let link_dev_offset = score_warp_offset + score_warp_dim;
1264        let chunk_size = prediction_chunk_rows(theta.len(), 1, n);
1265        let num_chunks = n.div_ceil(chunk_size);
1266        let scale = self.probit_frailty_scale();
1267        // Cross-block anchor corrections: when either runtime carries an
1268        // anchor residual, precompute the per-row correction matrices
1269        // (n × runtime_basis_dim) once. Each subsequent per-row evaluation
1270        // subtracts the corresponding row of these matrices from the raw
1271        // cubic-span basis output. When neither runtime has a residual,
1272        // the returned bundle is empty and threading is a no-op.
1273        let anchor_corrections =
1274            self.build_anchor_correction_matrices(input, design_logslope, &z)?;
1275        let marginal_map = marginal_eta
1276            .iter()
1277            .map(|&eta| {
1278                bernoulli_marginal_link_map(&self.base_link, eta)
1279                    .map_err(EstimationError::InvalidInput)
1280            })
1281            .collect::<Result<Vec<_>, _>>()?;
1282
1283        if !flex_active {
1284            let (final_eta_internal, marginal_scales, logslope_scales) = match &self.latent_measure
1285            {
1286                LatentMeasureKind::StandardNormal => {
1287                    let sb_vec = logslope_eta.mapv(|b| scale * b);
1288                    let c_vec = sb_vec.mapv(|sb| (1.0 + sb * sb).sqrt());
1289                    let final_eta_internal = Array1::from_iter(
1290                        (0..n).map(|i| c_vec[i] * marginal_eta[i] + sb_vec[i] * z[i]),
1291                    );
1292                    let marginal_scales = c_vec;
1293                    let logslope_scales = Array1::from_iter((0..n).map(|i| {
1294                        marginal_eta[i] * (scale * scale) * logslope_eta[i] / marginal_scales[i]
1295                            + scale * z[i]
1296                    }));
1297                    (final_eta_internal, marginal_scales, logslope_scales)
1298                }
1299                LatentMeasureKind::GlobalEmpirical { grid } => {
1300                    let mut final_eta = Array1::<f64>::zeros(n);
1301                    let mut marginal_scales = Array1::<f64>::zeros(n);
1302                    let mut logslope_scales = Array1::<f64>::zeros(n);
1303                    for i in 0..n {
1304                        let (intercept, a_marginal, a_slope) = self
1305                            .empirical_rigid_intercept_and_gradient(
1306                                marginal_eta[i],
1307                                logslope_eta[i],
1308                                &grid.nodes,
1309                                &grid.weights,
1310                            )?;
1311                        final_eta[i] = intercept + scale * logslope_eta[i] * z[i];
1312                        marginal_scales[i] = a_marginal;
1313                        logslope_scales[i] = a_slope + scale * z[i];
1314                    }
1315                    (final_eta, marginal_scales, logslope_scales)
1316                }
1317                LatentMeasureKind::LocalEmpirical { .. } => {
1318                    let mut final_eta = Array1::<f64>::zeros(n);
1319                    let mut marginal_scales = Array1::<f64>::zeros(n);
1320                    let mut logslope_scales = Array1::<f64>::zeros(n);
1321                    for i in 0..n {
1322                        let grid = self
1323                            .empirical_grid_for_prediction_row(input, i)?
1324                            .ok_or_else(|| {
1325                                EstimationError::InvalidInput(
1326                                    "local empirical latent prediction did not produce a row grid"
1327                                        .to_string(),
1328                                )
1329                            })?;
1330                        let (intercept, a_marginal, a_slope) = self
1331                            .empirical_rigid_intercept_and_gradient(
1332                                marginal_eta[i],
1333                                logslope_eta[i],
1334                                &grid.nodes,
1335                                &grid.weights,
1336                            )?;
1337                        final_eta[i] = intercept + scale * logslope_eta[i] * z[i];
1338                        marginal_scales[i] = a_marginal;
1339                        logslope_scales[i] = a_slope + scale * z[i];
1340                    }
1341                    (final_eta, marginal_scales, logslope_scales)
1342                }
1343            };
1344
1345            if !need_gradient {
1346                return self.transform_internal_eta_to_base_scale(final_eta_internal, None);
1347            }
1348
1349            // Chunk Jacobian: one pass per row fills both blocks.
1350            let mut grad_internal = Array2::<f64>::zeros((n, theta.len()));
1351            let mut start = 0usize;
1352            while start < n {
1353                let end = (start + chunk_size).min(n);
1354                let mc = input
1355                    .design
1356                    .try_row_chunk(start..end)
1357                    .map_err(|e| EstimationError::InvalidInput(e.to_string()))?;
1358                let lc = design_logslope
1359                    .try_row_chunk(start..end)
1360                    .map_err(|e| EstimationError::InvalidInput(e.to_string()))?;
1361
1362                for li in 0..(end - start) {
1363                    let i = start + li;
1364                    let c = marginal_scales[i];
1365                    let g_scale = logslope_scales[i];
1366                    let mut row = grad_internal.row_mut(i);
1367                    for j in 0..marginal_dim {
1368                        row[j] = c * mc[[li, j]];
1369                    }
1370                    for j in 0..logslope_dim {
1371                        row[logslope_offset + j] = g_scale * lc[[li, j]];
1372                    }
1373                }
1374
1375                start = end;
1376            }
1377            return self
1378                .transform_internal_eta_to_base_scale(final_eta_internal, Some(grad_internal));
1379        }
1380
1381        // ── Flexible path: per-row intercept solve, chunked Jacobians ──
1382        let score_warp_obs_design = self
1383            .score_warp_runtime
1384            .as_ref()
1385            .map(|runtime| {
1386                if runtime.anchor_correction.is_some() {
1387                    let anchor_rows = anchor_corrections
1388                        .score_warp_anchor_rows_view()
1389                        .ok_or_else(|| {
1390                            EstimationError::InvalidInput(
1391                                "bernoulli marginal-slope score-warp anchor residual present but \
1392                                 anchor_corrections bundle is missing the parametric anchor rows"
1393                                    .to_string(),
1394                            )
1395                        })?;
1396                    runtime
1397                        .design_with_anchor_rows(&z, anchor_rows)
1398                        .map_err(EstimationError::from)
1399                } else {
1400                    runtime.design(&z).map_err(EstimationError::from)
1401                }
1402            })
1403            .transpose()?;
1404        let score_dev_obs =
1405            if let (Some(design), Some(beta)) = (score_warp_obs_design.as_ref(), beta_score_warp) {
1406                design.dot(&beta.to_owned())
1407            } else {
1408                Array1::zeros(n)
1409            };
1410
1411        // Solve intercepts and (when gradient needed) IFT scalars in chunk-parallel passes.
1412        // Outputs are preallocated and each parallel worker writes directly into
1413        // its exclusive `axis_chunks_iter_mut` slice; no per-chunk owned buffer
1414        // and no serial copy pass over the result chunks.
1415        let score_warp_beta_owned = beta_score_warp.as_ref().map(|v| v.to_owned());
1416        let link_dev_beta_owned = beta_link_dev.as_ref().map(|v| v.to_owned());
1417        let mut intercepts = Array1::<f64>::zeros(n);
1418        let mut a_q_vec = need_gradient.then(|| Array1::<f64>::zeros(n));
1419        let mut a_b_vec = need_gradient.then(|| Array1::<f64>::zeros(n));
1420        let mut a_h_rows = if need_gradient && score_warp_dim > 0 {
1421            Some(Array2::<f64>::zeros((n, score_warp_dim)))
1422        } else {
1423            None
1424        };
1425        let mut a_w_rows = if need_gradient && link_dev_dim > 0 {
1426            Some(Array2::<f64>::zeros((n, link_dev_dim)))
1427        } else {
1428            None
1429        };
1430        let solve_result: Result<(), EstimationError> = {
1431            use ndarray::Axis;
1432            use rayon::iter::IndexedParallelIterator;
1433            let intercepts_chunks: Vec<ndarray::ArrayViewMut1<f64>> = intercepts
1434                .axis_chunks_iter_mut(Axis(0), chunk_size)
1435                .collect();
1436            let a_q_chunks: Option<Vec<ndarray::ArrayViewMut1<f64>>> = a_q_vec
1437                .as_mut()
1438                .map(|a| a.axis_chunks_iter_mut(Axis(0), chunk_size).collect());
1439            let a_b_chunks: Option<Vec<ndarray::ArrayViewMut1<f64>>> = a_b_vec
1440                .as_mut()
1441                .map(|a| a.axis_chunks_iter_mut(Axis(0), chunk_size).collect());
1442            let a_h_chunks: Option<Vec<ndarray::ArrayViewMut2<f64>>> = a_h_rows
1443                .as_mut()
1444                .map(|a| a.axis_chunks_iter_mut(Axis(0), chunk_size).collect());
1445            let a_w_chunks: Option<Vec<ndarray::ArrayViewMut2<f64>>> = a_w_rows
1446                .as_mut()
1447                .map(|a| a.axis_chunks_iter_mut(Axis(0), chunk_size).collect());
1448
1449            // Bundle per-chunk sinks so each parallel worker owns disjoint mutable
1450            // views into the shared output arrays.
1451            struct FlexSolveSink<'a> {
1452                intercepts: ndarray::ArrayViewMut1<'a, f64>,
1453                a_q: Option<ndarray::ArrayViewMut1<'a, f64>>,
1454                a_b: Option<ndarray::ArrayViewMut1<'a, f64>>,
1455                a_h: Option<ndarray::ArrayViewMut2<'a, f64>>,
1456                a_w: Option<ndarray::ArrayViewMut2<'a, f64>>,
1457            }
1458            let mut sinks: Vec<FlexSolveSink<'_>> = Vec::with_capacity(num_chunks);
1459            // Move each Option<Vec> into iterators so we can zip them.
1460            let mut intercepts_iter = intercepts_chunks.into_iter();
1461            let mut a_q_iter = a_q_chunks.map(|v| v.into_iter());
1462            let mut a_b_iter = a_b_chunks.map(|v| v.into_iter());
1463            let mut a_h_iter = a_h_chunks.map(|v| v.into_iter());
1464            let mut a_w_iter = a_w_chunks.map(|v| v.into_iter());
1465            for _ in 0..num_chunks {
1466                sinks.push(FlexSolveSink {
1467                    intercepts: intercepts_iter.next().expect("chunk count matches"),
1468                    a_q: a_q_iter
1469                        .as_mut()
1470                        .map(|it| it.next().expect("chunk count matches")),
1471                    a_b: a_b_iter
1472                        .as_mut()
1473                        .map(|it| it.next().expect("chunk count matches")),
1474                    a_h: a_h_iter
1475                        .as_mut()
1476                        .map(|it| it.next().expect("chunk count matches")),
1477                    a_w: a_w_iter
1478                        .as_mut()
1479                        .map(|it| it.next().expect("chunk count matches")),
1480                });
1481            }
1482
1483            // Precompute the score-warp basis cubic table once when the latent
1484            // grid is row-constant (`GlobalEmpirical`). The per-row inner loop
1485            // calls `basis_cubic_at(j, node)` with `node` taken from the grid,
1486            // which is identical for every row in this code path, so the
1487            // n_rows × n_nodes × score_warp_dim table can be hoisted out of
1488            // the parallel chunk dispatch. Per-row work only touches the
1489            // basis-function-specific `c0` shift via `score_corr_row`, which
1490            // stays inside the row loop. Computed at the top level so no
1491            // OnceLock / lazy init lives inside the par closure (per the
1492            // OnceLock + nested rayon deadlock rule).
1493            let global_score_basis_table: Option<
1494                Vec<Vec<crate::cubic_cell_kernel::LocalSpanCubic>>,
1495            > = if let (LatentMeasureKind::GlobalEmpirical { grid }, Some(runtime)) =
1496                (&self.latent_measure, self.score_warp_runtime.as_ref())
1497            {
1498                let mut table = Vec::with_capacity(score_warp_dim);
1499                for j in 0..score_warp_dim {
1500                    let mut row = Vec::with_capacity(grid.nodes.len());
1501                    for &node in &grid.nodes {
1502                        row.push(
1503                            runtime
1504                                .basis_cubic_at(j, node)
1505                                .map_err(EstimationError::from)?,
1506                        );
1507                    }
1508                    table.push(row);
1509                }
1510                Some(table)
1511            } else {
1512                None
1513            };
1514            let global_score_basis_table = global_score_basis_table.as_ref();
1515
1516            sinks
1517                .into_par_iter()
1518                .enumerate()
1519                .try_for_each(|(chunk_idx, mut sink)| -> Result<(), EstimationError> {
1520                let start = chunk_idx * chunk_size;
1521                let end = (start + chunk_size).min(n);
1522                let rows = end - start;
1523                // Destructure the sink into independent `&mut` references so we
1524                // can borrow them disjointly across iterations of the inner row
1525                // loop without further reborrowing through `Option::as_mut`.
1526                let intercepts_view = &mut sink.intercepts;
1527                let mut a_q = sink.a_q.as_mut();
1528                let mut a_b = sink.a_b.as_mut();
1529                let mut a_h = sink.a_h.as_mut();
1530                let mut a_w = sink.a_w.as_mut();
1531                let mut warm_start_buf = Array1::<f64>::zeros(1);
1532                let mut f_h_row = vec![0.0; score_warp_dim];
1533                let mut f_w_row = vec![0.0; link_dev_dim];
1534
1535                for local_row in 0..rows {
1536                    let i = start + local_row;
1537                    let slope = logslope_eta[i];
1538                    let q = marginal_eta[i];
1539                    let empirical_grid = self.empirical_grid_for_prediction_row(input, i)?;
1540                    let score_corr_row = anchor_corrections.score_warp_row(i);
1541                    let link_corr_row = anchor_corrections.link_dev_row(i);
1542                    intercepts_view[local_row] = self.solve_intercept_scalar(
1543                        q,
1544                        slope,
1545                        link_dev_beta_owned.as_ref(),
1546                        score_warp_beta_owned.as_ref(),
1547                        empirical_grid.as_ref(),
1548                        &mut warm_start_buf,
1549                        score_corr_row,
1550                        link_corr_row,
1551                    )?;
1552
1553                    if !need_gradient {
1554                        continue;
1555                    }
1556
1557                    let intercept = intercepts_view[local_row];
1558                    let (_, m_a_raw, _) = self.evaluate_prediction_calibration(
1559                        intercept,
1560                        q,
1561                        slope,
1562                        score_warp_beta_owned.as_ref(),
1563                        link_dev_beta_owned.as_ref(),
1564                        empirical_grid.as_ref(),
1565                        score_corr_row,
1566                        link_corr_row,
1567                    )?;
1568                    let m_a = m_a_raw.max(1e-12);
1569                    a_q.as_mut().expect("a_q allocated when need_gradient")[local_row] =
1570                        marginal_map[i].mu1 / m_a;
1571                    let mut f_b = 0.0;
1572                    f_h_row.fill(0.0);
1573                    f_w_row.fill(0.0);
1574                    if let Some(grid) = empirical_grid.as_ref() {
1575                        for (node_idx, (node, weight)) in grid.pairs().enumerate() {
1576                            let obs = self.observed_denested_cell_partials_at_z(
1577                                node,
1578                                intercept,
1579                                slope,
1580                                score_warp_beta_owned.as_ref(),
1581                                link_dev_beta_owned.as_ref(),
1582                                score_corr_row,
1583                                link_corr_row,
1584                            )?;
1585                            let eta = eval_coeff4_at(&obs.coeff, node);
1586                            let pdf = normal_pdf(eta);
1587                            f_b += weight * pdf * eval_coeff4_at(&obs.dc_db, node);
1588
1589                            if let Some(runtime) = self.score_warp_runtime.as_ref() {
1590                                for j in 0..score_warp_dim {
1591                                    // When the latent grid is row-constant
1592                                    // (`GlobalEmpirical`), the per-(j, node)
1593                                    // basis cubic is identical for every row
1594                                    // and lives in `global_score_basis_table`.
1595                                    // Otherwise (`LocalEmpirical`) the grid
1596                                    // varies per row and we fall back to a
1597                                    // direct `basis_cubic_at` call.
1598                                    let mut basis_span = if let Some(table) =
1599                                        global_score_basis_table
1600                                    {
1601                                        table[j][node_idx]
1602                                    } else {
1603                                        runtime
1604                                            .basis_cubic_at(j, node)
1605                                            .map_err(EstimationError::from)?
1606                                    };
1607                                    // `basis_cubic_at` returns the j-th basis
1608                                    // function's local cubic; the residual
1609                                    // subtracts `correction[j]` from the
1610                                    // constant term (row-constant, basis-
1611                                    // function-specific). Higher span
1612                                    // coefficients are unaffected.
1613                                    if let Some(corr) = score_corr_row {
1614                                        basis_span.c0 -= corr[j];
1615                                    }
1616                                    let coeffs = crate::cubic_cell_kernel::score_basis_cell_coefficients(
1617                                        basis_span,
1618                                        slope,
1619                                    );
1620                                    let coeffs = scale_coeff4(coeffs, scale);
1621                                    f_h_row[j] += weight * pdf * eval_coeff4_at(&coeffs, node);
1622                                }
1623                            }
1624
1625                            if let Some(runtime) = self.link_deviation_runtime.as_ref() {
1626                                for j in 0..link_dev_dim {
1627                                    let mut basis_span = runtime
1628                                        .basis_cubic_at(j, intercept + slope * node)
1629                                        .map_err(EstimationError::from)?;
1630                                    if let Some(corr) = link_corr_row {
1631                                        basis_span.c0 -= corr[j];
1632                                    }
1633                                    let coeffs = crate::cubic_cell_kernel::link_basis_cell_coefficients(
1634                                        basis_span,
1635                                        intercept,
1636                                        slope,
1637                                    );
1638                                    let coeffs = scale_coeff4(coeffs, scale);
1639                                    f_w_row[j] += weight * pdf * eval_coeff4_at(&coeffs, node);
1640                                }
1641                            }
1642                        }
1643                    } else {
1644                        let cells = self.denested_partition_cells(
1645                            intercept,
1646                            slope,
1647                            score_warp_beta_owned.as_ref(),
1648                            link_dev_beta_owned.as_ref(),
1649                            score_corr_row,
1650                            link_corr_row,
1651                        )?;
1652                        for partition_cell in cells {
1653                            let cell = partition_cell.cell;
1654                            let state =
1655                                crate::cubic_cell_kernel::evaluate_cell_moments(
1656                                    cell, 9,
1657                                )
1658                                .map_err(EstimationError::InvalidInput)?;
1659                            let (_, dc_db_raw) = crate::cubic_cell_kernel::denested_cell_coefficient_partials(
1660                                partition_cell.score_span,
1661                                partition_cell.link_span,
1662                                intercept,
1663                                slope,
1664                            );
1665                            // `denested_partition_cells` scales the cell itself for
1666                            // Gaussian frailty, so every coefficient partial of
1667                            // F(a, theta) must carry the same probit scale as F_a.
1668                            let dc_db = scale_coeff4(dc_db_raw, scale);
1669                            f_b += crate::cubic_cell_kernel::cell_first_derivative_from_moments(
1670                                &dc_db,
1671                                &state.moments,
1672                            )
1673                            .map_err(EstimationError::InvalidInput)?;
1674
1675                            let mid = 0.5 * (cell.left + cell.right);
1676                            if let Some(runtime) = self.score_warp_runtime.as_ref() {
1677                                for j in 0..score_warp_dim {
1678                                    let mut basis_span = runtime
1679                                        .basis_cubic_at(j, mid)
1680                                        .map_err(EstimationError::from)?;
1681                                    if let Some(corr) = score_corr_row {
1682                                        basis_span.c0 -= corr[j];
1683                                    }
1684                                    let coeffs = crate::cubic_cell_kernel::score_basis_cell_coefficients(
1685                                        basis_span, slope,
1686                                    );
1687                                    let coeffs = scale_coeff4(coeffs, scale);
1688                                    f_h_row[j] += crate::cubic_cell_kernel::cell_first_derivative_from_moments(
1689                                        &coeffs,
1690                                        &state.moments,
1691                                    )
1692                                    .map_err(EstimationError::InvalidInput)?;
1693                                }
1694                            }
1695
1696                            if let Some(runtime) = self.link_deviation_runtime.as_ref() {
1697                                for j in 0..link_dev_dim {
1698                                    let mut basis_span = runtime
1699                                        .basis_cubic_at(j, intercept + slope * mid)
1700                                        .map_err(EstimationError::from)?;
1701                                    if let Some(corr) = link_corr_row {
1702                                        basis_span.c0 -= corr[j];
1703                                    }
1704                                    let coeffs = crate::cubic_cell_kernel::link_basis_cell_coefficients(
1705                                        basis_span,
1706                                        intercept,
1707                                        slope,
1708                                    );
1709                                    let coeffs = scale_coeff4(coeffs, scale);
1710                                    f_w_row[j] += crate::cubic_cell_kernel::cell_first_derivative_from_moments(
1711                                        &coeffs,
1712                                        &state.moments,
1713                                    )
1714                                    .map_err(EstimationError::InvalidInput)?;
1715                                }
1716                            }
1717                        }
1718                    }
1719                    if let Some(a_h_view) = a_h.as_mut() {
1720                        let factor = -1.0 / m_a;
1721                        for j in 0..score_warp_dim {
1722                            a_h_view[[local_row, j]] = factor * f_h_row[j];
1723                        }
1724                    }
1725                    if let Some(a_w_view) = a_w.as_mut() {
1726                        let factor = -1.0 / m_a;
1727                        for j in 0..link_dev_dim {
1728                            a_w_view[[local_row, j]] = factor * f_w_row[j];
1729                        }
1730                    }
1731                    a_b.as_mut().expect("a_b allocated when need_gradient")[local_row] =
1732                        -f_b / m_a;
1733                }
1734                Ok(())
1735            })
1736        };
1737        solve_result?;
1738
1739        let eta_base = &intercepts + &(&logslope_eta * &z);
1740
1741        let mut link_c_obs: Option<Array1<f64>> = None;
1742        let mut link_basis_obs: Option<Array2<f64>> = None;
1743        let link_dev_obs = if let (Some(runtime), Some(beta_owned)) = (
1744            self.link_deviation_runtime.as_ref(),
1745            link_dev_beta_owned.as_ref(),
1746        ) {
1747            let basis = if runtime.anchor_correction.is_some() {
1748                let anchor_rows =
1749                    anchor_corrections
1750                        .link_dev_anchor_rows_view()
1751                        .ok_or_else(|| {
1752                            EstimationError::InvalidInput(
1753                            "bernoulli marginal-slope link-deviation anchor residual present but \
1754                             anchor_corrections bundle is missing the parametric anchor rows"
1755                                .to_string(),
1756                        )
1757                        })?;
1758                runtime
1759                    .design_with_anchor_rows(&eta_base, anchor_rows)
1760                    .map_err(EstimationError::from)?
1761            } else {
1762                runtime.design(&eta_base).map_err(EstimationError::from)?
1763            };
1764            let dev = basis.dot(beta_owned);
1765            if need_gradient {
1766                let d1 = runtime
1767                    .first_derivative_design(&eta_base)
1768                    .map_err(EstimationError::from)?;
1769                let mut c_obs = d1.dot(beta_owned);
1770                c_obs.mapv_inplace(|v| v + 1.0);
1771                link_c_obs = Some(c_obs);
1772                link_basis_obs = Some(basis);
1773            }
1774            dev
1775        } else {
1776            Array1::zeros(n)
1777        };
1778        let final_eta_internal =
1779            (&eta_base + &(&logslope_eta * &score_dev_obs) + &link_dev_obs).mapv(|v| scale * v);
1780
1781        if !need_gradient {
1782            return self.transform_internal_eta_to_base_scale(final_eta_internal, None);
1783        }
1784
1785        let a_q_vec = a_q_vec.unwrap();
1786        let a_b_vec = a_b_vec.unwrap();
1787
1788        // Emit chunk Jacobians using precomputed scalars; each worker writes
1789        // directly into its exclusive `axis_chunks_iter_mut` slice of the
1790        // preallocated `grad` output so no serial copy pass is needed.
1791        let mut grad = Array2::<f64>::zeros((n, theta.len()));
1792        {
1793            use ndarray::Axis;
1794            use rayon::iter::IndexedParallelIterator;
1795            let grad_result: Result<(), String> = grad
1796                .axis_chunks_iter_mut(Axis(0), chunk_size)
1797                .into_par_iter()
1798                .enumerate()
1799                .try_for_each(|(chunk_idx, mut grad_chunk)| -> Result<(), String> {
1800                    let start = chunk_idx * chunk_size;
1801                    let end = (start + chunk_size).min(n);
1802                    let mc = input
1803                        .design
1804                        .try_row_chunk(start..end)
1805                        .map_err(|e| e.to_string())?;
1806                    let lc = design_logslope
1807                        .try_row_chunk(start..end)
1808                        .map_err(|e| e.to_string())?;
1809                    let rows = end - start;
1810
1811                    for li in 0..rows {
1812                        let i = start + li;
1813                        let mut row = grad_chunk.row_mut(li);
1814
1815                        let a_q = a_q_vec[i];
1816                        for j in 0..marginal_dim {
1817                            row[j] = a_q * mc[[li, j]];
1818                        }
1819
1820                        let base_multiplier = link_c_obs.as_ref().map_or(1.0, |c| c[i]);
1821                        let g_scale = base_multiplier * (a_b_vec[i] + z[i]) + score_dev_obs[i];
1822                        for j in 0..logslope_dim {
1823                            row[logslope_offset + j] = g_scale * lc[[li, j]];
1824                        }
1825
1826                        if let (Some(a_h_rows), Some(obs_design)) =
1827                            (a_h_rows.as_ref(), score_warp_obs_design.as_ref())
1828                        {
1829                            let slope = logslope_eta[i];
1830                            for j in 0..score_warp_dim {
1831                                row[score_warp_offset + j] =
1832                                    base_multiplier * a_h_rows[[i, j]] + slope * obs_design[[i, j]];
1833                            }
1834                        }
1835
1836                        if let Some(a_w_rows) = a_w_rows.as_ref() {
1837                            for j in 0..link_dev_dim {
1838                                row[link_dev_offset + j] = a_w_rows[[i, j]];
1839                            }
1840                        }
1841
1842                        if let (Some(link_c), Some(link_basis)) =
1843                            (link_c_obs.as_ref(), link_basis_obs.as_ref())
1844                        {
1845                            let c = link_c[i];
1846                            for j in 0..marginal_dim {
1847                                row[j] *= c;
1848                            }
1849                            for j in 0..link_dev_dim {
1850                                row[link_dev_offset + j] =
1851                                    c * row[link_dev_offset + j] + link_basis[[i, j]];
1852                            }
1853                        }
1854                    }
1855                    Ok(())
1856                });
1857            grad_result.map_err(EstimationError::InvalidInput)?;
1858        }
1859        if scale != 1.0 {
1860            grad.mapv_inplace(|v| scale * v);
1861        }
1862        self.transform_internal_eta_to_base_scale(final_eta_internal, Some(grad))
1863    }
1864
1865    /// Per-row final (base-scale) linear predictor for an arbitrary
1866    /// coefficient vector `theta` in the saved `[marginal | logslope |
1867    /// score_warp? | link_dev?]` block order. The marginal-slope rigid
1868    /// kernel is applied exactly per row, so the returned η is the same
1869    /// object the point predictor consumes — only parameterised by an
1870    /// external draw instead of `self.theta()`. Used by the posterior
1871    /// predictive path (#1049) to map each Laplace draw to its η surface
1872    /// before the shared eta→bands collapse; the response scale is the
1873    /// probit inverse link `μ = Φ(η)`.
1874    pub fn final_eta_from_theta(
1875        &self,
1876        input: &PredictInput,
1877        theta: &Array1<f64>,
1878    ) -> Result<Array1<f64>, EstimationError> {
1879        let (eta, _) = self.final_eta_and_gradient_from_theta(input, theta, false)?;
1880        Ok(eta)
1881    }
1882
1883    /// Length of the concatenated coefficient vector this predictor
1884    /// consumes (`marginal + logslope + score_warp? + link_dev?`). The
1885    /// posterior predictive path validates each saved draw against this
1886    /// before mapping it through [`Self::final_eta_from_theta`].
1887    pub fn theta_len(&self) -> usize {
1888        self.beta_marginal.len()
1889            + self.beta_logslope.len()
1890            + self.beta_score_warp.as_ref().map_or(0, Array1::len)
1891            + self.beta_link_dev.as_ref().map_or(0, Array1::len)
1892    }
1893
1894    /// Per-row `(eta, ∂eta/∂q_marginal)` under the exact IFT pull-back.
1895    ///
1896    /// Returns the same `eta` as `predict_plugin_response`/`predict_linear_predictor`
1897    /// plus the analytic derivative of the internal probit index with respect to
1898    /// the per-row marginal q (the linear predictor before the de-nested
1899    /// calibration). Survival prediction multiplies the second component by the
1900    /// per-row `dq/dt` to obtain the exact hazard time derivative under
1901    /// score-warp / link-deviation flex blocks.
1902    ///
1903    /// Rigid path (no flex blocks): `∂eta/∂q = c = sqrt(1 + (s b)^2)`, recovering
1904    /// the rigid-path probit-frailty composition. Flex path: `∂eta/∂q =
1905    /// scale · link_c_obs · a_q` where `link_c_obs = 1 + Δ_w'(eta_base)` is the
1906    /// link-deviation slope at the observed `eta_base = a + b z` and `a_q =
1907    /// φ(q) / |F_a|` is the implicit-function derivative of the calibration
1908    /// intercept (mirrors the bernoulli `final_eta_and_gradient_from_theta`
1909    /// flex branch lines 1399-1593).
1910    pub fn predict_eta_and_q_chain(
1911        &self,
1912        input: &PredictInput,
1913    ) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
1914        let z_raw = input.auxiliary_scalar.as_ref().ok_or_else(|| {
1915            EstimationError::InvalidInput(format!(
1916                "bernoulli marginal-slope prediction requires auxiliary z column '{}'",
1917                self.z_column
1918            ))
1919        })?;
1920        let z_normalized = self
1921            .latent_z_normalization
1922            .apply(z_raw, "bernoulli marginal-slope prediction")
1923            .map_err(EstimationError::from)?;
1924        // P4: see `final_eta_and_gradient_from_theta` for the rationale.
1925        // The rank-INT calibration is a mathematically exact monotone
1926        // transform; both the rigid standard-normal kernel and the
1927        // implicit-function chain rule consume the calibrated z, never
1928        // the raw normalized z, exactly mirroring fit-time semantics.
1929        let z = self.apply_latent_z_calibration(&z_normalized);
1930        // #905: replace z by ζ = (z − m(C))/√v(C) when training engaged the
1931        // conditional Auto gate (no-op otherwise; mutually exclusive with the
1932        // rank-INT calibration above).
1933        let z = self.apply_latent_z_conditional_calibration(&z, input)?;
1934        let design_logslope = input.design_noise.as_ref().ok_or_else(|| {
1935            EstimationError::InvalidInput(
1936                "bernoulli marginal-slope prediction requires logslope design".to_string(),
1937            )
1938        })?;
1939        let n = z.len();
1940        if input.offset.len() != n {
1941            return Err(EstimationError::InvalidInput(format!(
1942                "bernoulli marginal-slope prediction primary offset length mismatch: rows={n}, offset={}",
1943                input.offset.len()
1944            )));
1945        }
1946        let logslope_offset = input
1947            .offset_noise
1948            .as_ref()
1949            .map_or_else(|| Array1::zeros(n), Clone::clone);
1950        if logslope_offset.len() != n {
1951            return Err(EstimationError::InvalidInput(format!(
1952                "bernoulli marginal-slope prediction logslope offset length mismatch: rows={n}, offset_noise={}",
1953                logslope_offset.len()
1954            )));
1955        }
1956        let marginal_eta = input
1957            .design
1958            .dot(&self.beta_marginal)
1959            .mapv(|v| v + self.baseline_marginal)
1960            + &input.offset;
1961        let logslope_eta = design_logslope
1962            .dot(&self.beta_logslope)
1963            .mapv(|v| v + self.baseline_logslope)
1964            + &logslope_offset;
1965        let scale = self.probit_frailty_scale();
1966        let flex_active =
1967            self.score_warp_runtime.is_some() || self.link_deviation_runtime.is_some();
1968
1969        // Rigid path mirrors `final_eta_and_gradient_from_theta` lines 1342-1383:
1970        //   eta = c·q + s·b·z,  ∂eta/∂q = c.
1971        if !flex_active {
1972            match &self.latent_measure {
1973                LatentMeasureKind::StandardNormal => {
1974                    // Vectorize: sb = scale·logslope, c = sqrt(1 + sb²),
1975                    // eta = c·marginal_eta + sb·z, ∂eta/∂q = c.
1976                    let sb = logslope_eta.mapv(|x| scale * x);
1977                    let deta_dq = sb.mapv(|s| (1.0 + s * s).sqrt());
1978                    let eta = &deta_dq * marginal_eta + &sb * z;
1979                    return Ok((eta, deta_dq));
1980                }
1981                _ => {
1982                    let mut eta = Array1::<f64>::zeros(n);
1983                    let mut deta_dq = Array1::<f64>::zeros(n);
1984                    for i in 0..n {
1985                        let grid = self
1986                            .empirical_grid_for_prediction_row(input, i)?
1987                            .ok_or_else(|| {
1988                                EstimationError::InvalidInput(
1989                                    "empirical latent prediction did not produce a row grid"
1990                                        .to_string(),
1991                                )
1992                            })?;
1993                        let (intercept, a_marginal, _) = self
1994                            .empirical_rigid_intercept_and_gradient(
1995                                marginal_eta[i],
1996                                logslope_eta[i],
1997                                &grid.nodes,
1998                                &grid.weights,
1999                            )?;
2000                        eta[i] = intercept + scale * logslope_eta[i] * z[i];
2001                        deta_dq[i] = a_marginal;
2002                    }
2003                    return Ok((eta, deta_dq));
2004                }
2005            }
2006        }
2007
2008        // Flex path: solve the per-row intercept, then evaluate
2009        //   eta = scale · (a + b·z + b·Δ_h(z) + Δ_w(a + b·z))
2010        //   ∂eta/∂q = scale · (1 + Δ_w'(a + b·z)) · ∂a/∂q,
2011        //   ∂a/∂q   = φ(q) / |F_a|         (IFT, marginal_link is probit so mu1 = φ(q))
2012        // Mirrors `final_eta_and_gradient_from_theta` lines 1385-1621.
2013        let marginal_map = marginal_eta
2014            .iter()
2015            .map(|&eta_marg| {
2016                bernoulli_marginal_link_map(&self.base_link, eta_marg)
2017                    .map_err(EstimationError::InvalidInput)
2018            })
2019            .collect::<Result<Vec<_>, _>>()?;
2020        // Cross-block anchor corrections (see final_eta_and_gradient_from_theta
2021        // for the design); precompute once before the per-row loop.
2022        let anchor_corrections =
2023            self.build_anchor_correction_matrices(input, design_logslope, &z)?;
2024        // Per-row: solve intercept scalar, evaluate denested calibration,
2025        // record (intercept, a_q). The `warm_start_buf` is just per-call
2026        // scratch — give each rayon worker its own buffer via fold init.
2027        use rayon::iter::{IntoParallelIterator, ParallelIterator};
2028        let pairs: Result<Vec<(f64, f64)>, EstimationError> = (0..n)
2029            .into_par_iter()
2030            .map_init(
2031                || Array1::<f64>::zeros(1),
2032                |warm_start_buf, i| {
2033                    let q = marginal_eta[i];
2034                    let slope = logslope_eta[i];
2035                    let empirical_grid = self.empirical_grid_for_prediction_row(input, i)?;
2036                    let score_corr_row = anchor_corrections.score_warp_row(i);
2037                    let link_corr_row = anchor_corrections.link_dev_row(i);
2038                    let intercept = self.solve_intercept_scalar(
2039                        q,
2040                        slope,
2041                        self.beta_link_dev.as_ref(),
2042                        self.beta_score_warp.as_ref(),
2043                        empirical_grid.as_ref(),
2044                        warm_start_buf,
2045                        score_corr_row,
2046                        link_corr_row,
2047                    )?;
2048                    let (_, m_a_raw, _) = self.evaluate_prediction_calibration(
2049                        intercept,
2050                        q,
2051                        slope,
2052                        self.beta_score_warp.as_ref(),
2053                        self.beta_link_dev.as_ref(),
2054                        empirical_grid.as_ref(),
2055                        score_corr_row,
2056                        link_corr_row,
2057                    )?;
2058                    let m_a = m_a_raw.max(1e-12);
2059                    Ok((intercept, marginal_map[i].mu1 / m_a))
2060                },
2061            )
2062            .collect();
2063        let pairs = pairs?;
2064        let mut intercepts = Array1::<f64>::zeros(n);
2065        let mut a_q = Array1::<f64>::zeros(n);
2066        for (i, (intercept, a)) in pairs.into_iter().enumerate() {
2067            intercepts[i] = intercept;
2068            a_q[i] = a;
2069        }
2070
2071        let score_dev_obs = if let (Some(runtime), Some(beta)) = (
2072            self.score_warp_runtime.as_ref(),
2073            self.beta_score_warp.as_ref(),
2074        ) {
2075            let design = if runtime.anchor_correction.is_some() {
2076                let anchor_rows = anchor_corrections
2077                    .score_warp_anchor_rows_view()
2078                    .ok_or_else(|| {
2079                        EstimationError::InvalidInput(
2080                            "bernoulli marginal-slope score-warp anchor residual present but \
2081                             anchor_corrections bundle is missing the parametric anchor rows"
2082                                .to_string(),
2083                        )
2084                    })?;
2085                runtime
2086                    .design_with_anchor_rows(&z, anchor_rows)
2087                    .map_err(EstimationError::from)?
2088            } else {
2089                runtime.design(&z).map_err(EstimationError::from)?
2090            };
2091            design.dot(beta)
2092        } else {
2093            Array1::zeros(n)
2094        };
2095        let eta_base = &intercepts + &(&logslope_eta * &z);
2096        let (link_dev_obs, link_c_obs) = if let (Some(runtime), Some(beta)) = (
2097            self.link_deviation_runtime.as_ref(),
2098            self.beta_link_dev.as_ref(),
2099        ) {
2100            let basis = if runtime.anchor_correction.is_some() {
2101                let anchor_rows =
2102                    anchor_corrections
2103                        .link_dev_anchor_rows_view()
2104                        .ok_or_else(|| {
2105                            EstimationError::InvalidInput(
2106                            "bernoulli marginal-slope link-deviation anchor residual present but \
2107                             anchor_corrections bundle is missing the parametric anchor rows"
2108                                .to_string(),
2109                        )
2110                        })?;
2111                runtime
2112                    .design_with_anchor_rows(&eta_base, anchor_rows)
2113                    .map_err(EstimationError::from)?
2114            } else {
2115                runtime.design(&eta_base).map_err(EstimationError::from)?
2116            };
2117            let dev = basis.dot(beta);
2118            let d1 = runtime
2119                .first_derivative_design(&eta_base)
2120                .map_err(EstimationError::from)?;
2121            let mut c_obs = d1.dot(beta);
2122            c_obs.mapv_inplace(|v| v + 1.0);
2123            (dev, c_obs)
2124        } else {
2125            (Array1::zeros(n), Array1::ones(n))
2126        };
2127        let final_eta_internal =
2128            (&eta_base + &(&logslope_eta * &score_dev_obs) + &link_dev_obs).mapv(|v| scale * v);
2129        let deta_dq = (&link_c_obs * &a_q).mapv(|v| scale * v);
2130        Ok((final_eta_internal, deta_dq))
2131    }
2132}