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