Skip to main content

gam_models/inference/
predict_io.rs

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