1use crate::custom_family::{
21 BlockWorkingSet, BlockwiseFitOptions, ConstraintSet, CustomFamily,
22 ExactNewtonJointGradientEvaluation, ExactNewtonJointHessianWorkspace, FamilyEvaluation,
23 ParameterBlockSpec, ParameterBlockState, PenaltyMatrix, fit_custom_family,
24 fit_custom_family_fixed_log_lambdas,
25};
26use crate::fit_orchestration::drivers::freeze_term_collection_from_design;
27use crate::gamlss::{FamilyMetadata, ParameterLink};
28use crate::model_types::UnifiedFitResult;
29use crate::probability::{
30 exact_binary64_sum_sign, log1mexp_positive, signed_log_sum_exp,
31};
32use crate::quadrature::{IntegratedExpectationMode, QuadratureContext};
33use crate::sigma_link::{exp_sigma_eta_for_sigma_scalar, exp_sigma_from_eta_scalar};
34use crate::survival::latent::interval::{
35 LatentFrailtyResolution, LatentIntervalModel, LatentIntervalRowView,
36 validate_latent_interval_inputs,
37};
38use crate::survival::location_scale::{
39 TimeBlockInput, project_onto_linear_constraints, structural_time_coefficient_constraints,
40};
41use crate::survival::lognormal_kernel::{
42 FrailtyScale, FrailtySpec, HazardLoading, LatentSurvivalEventType, LatentSurvivalRow,
43 LatentSurvivalRowJet, LogLognormalKernelBundle, log_kernel_bundle,
44};
45use gam_linalg::matrix::{DenseDesignMatrix, DesignMatrix, LinearOperator, SymmetricMatrix};
46use gam_math::jet_scalar::{JetScalar, OneSeed, Order2, TwoSeed};
47use gam_math::nested_dual::JetField;
50use gam_solve::pirls::LinearInequalityConstraints;
51use gam_terms::smooth::{TermCollectionDesign, TermCollectionSpec, build_term_collection_design};
52use ndarray::{Array1, Array2, ArrayView1, ArrayView2, s};
53use smallvec::SmallVec;
54use std::sync::Arc;
55
56#[derive(Debug, Clone)]
62pub enum LatentSurvivalError {
63 InvalidFrailty { reason: String },
67 InvalidDataset { reason: String },
71 BlockMismatch { reason: String },
74 NumericalFailure { reason: String },
77 DerivativeAccuracyUnresolved { reason: String },
80 UnsupportedConfiguration { reason: String },
84}
85
86impl_reason_error_boilerplate! {
87 LatentSurvivalError {
88 InvalidFrailty,
89 InvalidDataset,
90 BlockMismatch,
91 NumericalFailure,
92 DerivativeAccuracyUnresolved,
93 UnsupportedConfiguration,
94 }
95}
96
97impl From<crate::block_layout::block_count::BlockCountMismatch> for LatentSurvivalError {
98 fn from(err: crate::block_layout::block_count::BlockCountMismatch) -> LatentSurvivalError {
99 LatentSurvivalError::BlockMismatch {
100 reason: err.message(),
101 }
102 }
103}
104
105impl From<String> for LatentSurvivalError {
106 fn from(reason: String) -> LatentSurvivalError {
112 LatentSurvivalError::InvalidDataset { reason }
113 }
114}
115
116pub const LATENT_SURVIVAL_EVENT_INTERVAL: u8 = u8::MAX;
122
123#[inline]
124fn latent_survival_event_type_for(code: u8) -> LatentSurvivalEventType {
125 match code {
126 0 => LatentSurvivalEventType::RightCensored,
127 LATENT_SURVIVAL_EVENT_INTERVAL => LatentSurvivalEventType::IntervalCensored,
128 _ => LatentSurvivalEventType::ExactEvent,
129 }
130}
131
132#[derive(Clone, Copy)]
138struct ValidatedLikelihoodWeights<'a> {
139 values: &'a Array1<f64>,
140}
141
142impl<'a> ValidatedLikelihoodWeights<'a> {
143 fn new(values: &'a Array1<f64>, context: &str) -> Result<Self, LatentSurvivalError> {
144 if let Some((row, &weight)) = values
145 .iter()
146 .enumerate()
147 .find(|(_, weight)| !weight.is_finite() || **weight < 0.0)
148 {
149 return Err(LatentSurvivalError::InvalidDataset {
150 reason: format!(
151 "{context} row {} has invalid likelihood weight {weight:?}; expected finite weight >= 0",
152 row + 1
153 ),
154 });
155 }
156 Ok(Self { values })
157 }
158
159 #[inline]
160 fn at(self, row: usize) -> f64 {
161 self.values[row]
162 }
163}
164
165fn checked_weighted_row_value(
169 weight: f64,
170 value: f64,
171 row: usize,
172 quantity: &str,
173) -> Result<f64, String> {
174 assert!(weight.is_finite() && weight > 0.0);
175 if !value.is_finite() {
176 return Err(format!(
177 "latent likelihood row {} has non-finite unweighted {quantity}: {value:?}",
178 row + 1
179 ));
180 }
181 let weighted = weight * value;
182 if !weighted.is_finite() {
183 return Err(format!(
184 "latent likelihood row {} weighted {quantity} is not representable: {weight:?} * {value:?}",
185 row + 1
186 ));
187 }
188 if value != 0.0 && weighted == 0.0 {
189 return Err(format!(
190 "latent likelihood row {} weighted {quantity} underflowed and is not representable: {weight:?} * {value:?}",
191 row + 1
192 ));
193 }
194 Ok(weighted)
195}
196
197fn checked_weighted_row_matrix(
198 weight: f64,
199 values: &Array2<f64>,
200 row: usize,
201 quantity: &str,
202) -> Result<Array2<f64>, String> {
203 let mut weighted = Array2::<f64>::zeros(values.dim());
204 for ((left, right), &value) in values.indexed_iter() {
205 if !value.is_finite() {
206 return Err(format!(
207 "latent likelihood row {} has non-finite unweighted {quantity}[{left},{right}]: {value:?}",
208 row + 1
209 ));
210 }
211 let product = weight * value;
212 if !product.is_finite() || (value != 0.0 && product == 0.0) {
213 return Err(format!(
214 "latent likelihood row {} weighted {quantity}[{left},{right}] is not representable: {weight:?} * {value:?}",
215 row + 1
216 ));
217 }
218 weighted[[left, right]] = product;
219 }
220 Ok(weighted)
221}
222
223fn require_finite_likelihood_scalar(value: f64, quantity: &str) -> Result<f64, String> {
224 if value.is_finite() {
225 Ok(value)
226 } else {
227 Err(format!(
228 "latent likelihood accumulated {quantity} is not representable: {value:?}"
229 ))
230 }
231}
232
233fn require_finite_likelihood_vector(values: &Array1<f64>, quantity: &str) -> Result<(), String> {
234 if let Some((index, &value)) = values
235 .iter()
236 .enumerate()
237 .find(|(_, value)| !value.is_finite())
238 {
239 return Err(format!(
240 "latent likelihood accumulated {quantity}[{index}] is not representable: {value:?}"
241 ));
242 }
243 Ok(())
244}
245
246fn require_finite_likelihood_matrix(values: &Array2<f64>, quantity: &str) -> Result<(), String> {
247 if let Some(((row, col), &value)) = values.indexed_iter().find(|(_, value)| !value.is_finite())
248 {
249 return Err(format!(
250 "latent likelihood accumulated {quantity}[{row},{col}] is not representable: {value:?}"
251 ));
252 }
253 Ok(())
254}
255
256#[derive(Clone)]
257pub struct LatentSurvivalTermSpec {
258 pub age_entry: Array1<f64>,
259 pub age_exit: Array1<f64>,
260 pub event_target: Array1<u8>,
261 pub weights: Array1<f64>,
262 pub derivative_guard: f64,
263 pub time_block: TimeBlockInput,
264 pub time_design_right: Option<DesignMatrix>,
271 pub time_offset_right: Option<Array1<f64>>,
272 pub unloaded_mass_entry: Array1<f64>,
273 pub unloaded_mass_exit: Array1<f64>,
274 pub unloaded_mass_right: Array1<f64>,
278 pub unloaded_hazard_exit: Array1<f64>,
279 pub meanspec: TermCollectionSpec,
280 pub mean_offset: Array1<f64>,
281}
282
283pub struct LatentSurvivalTermFitResult {
284 pub fit: UnifiedFitResult,
285 pub design: TermCollectionDesign,
286 pub resolvedspec: TermCollectionSpec,
287 pub latent_sd: f64,
288 pub baseline_offset_residuals: crate::survival::OffsetChannelResiduals,
294}
295
296#[derive(Clone)]
297pub struct LatentBinaryTermSpec {
298 pub age_entry: Array1<f64>,
299 pub age_exit: Array1<f64>,
300 pub event_target: Array1<u8>,
301 pub weights: Array1<f64>,
302 pub derivative_guard: f64,
303 pub time_block: TimeBlockInput,
304 pub unloaded_mass_entry: Array1<f64>,
305 pub unloaded_mass_exit: Array1<f64>,
306 pub meanspec: TermCollectionSpec,
307 pub mean_offset: Array1<f64>,
308}
309
310pub struct LatentBinaryTermFitResult {
311 pub fit: UnifiedFitResult,
312 pub design: TermCollectionDesign,
313 pub resolvedspec: TermCollectionSpec,
314 pub baseline_offset_residuals: crate::survival::OffsetChannelResiduals,
318}
319
320#[derive(Clone)]
321struct PreparedLatentTimeBlock {
322 design_entry: Array2<f64>,
323 design_exit: Array2<f64>,
324 design_derivative_exit: Array2<f64>,
325 design_right: Array2<f64>,
330 linear_constraints: Option<LinearInequalityConstraints>,
331 penalties: Vec<Array2<f64>>,
332 initial_beta: Option<Array1<f64>>,
333}
334
335#[derive(Clone)]
336pub struct LatentSurvivalFamily {
337 pub event_target: Array1<u8>,
338 pub weights: Array1<f64>,
339 pub latent_sd_fixed: Option<f64>,
340 pub hazard_loading: HazardLoading,
341 pub unloaded_mass_entry: Array1<f64>,
342 pub unloaded_mass_exit: Array1<f64>,
343 pub unloaded_hazard_exit: Array1<f64>,
344 pub x_time_entry: Array2<f64>,
345 pub x_time_exit: Array2<f64>,
346 pub x_time_derivative_exit: Array2<f64>,
347 pub x_time_right: Array2<f64>,
353 pub time_offset_right: Array1<f64>,
355 pub unloaded_mass_right: Array1<f64>,
358 pub x_mean: DesignMatrix,
359 pub time_linear_constraints: Option<LinearInequalityConstraints>,
360 pub quadctx: Arc<QuadratureContext>,
361}
362
363#[derive(Clone)]
364pub struct LatentBinaryFamily {
365 pub event_target: Array1<u8>,
366 pub weights: Array1<f64>,
367 pub latent_sd: f64,
368 pub hazard_loading: HazardLoading,
369 pub unloaded_mass_entry: Array1<f64>,
370 pub unloaded_mass_exit: Array1<f64>,
371 pub x_time_entry: Array2<f64>,
372 pub x_time_exit: Array2<f64>,
373 pub x_mean: DesignMatrix,
374 pub time_linear_constraints: Option<LinearInequalityConstraints>,
375 pub quadctx: Arc<QuadratureContext>,
376}
377
378impl LatentSurvivalFamily {
379 pub const BLOCK_TIME: usize = 0;
380 pub const BLOCK_MEAN: usize = 1;
381 pub const BLOCK_LOG_SIGMA: usize = 2;
382
383 pub fn parameter_names() -> &'static [&'static str] {
384 &["time_transform", "mean"]
385 }
386
387 pub fn parameter_links() -> &'static [ParameterLink] {
388 &[ParameterLink::Identity, ParameterLink::Identity]
389 }
390
391 pub fn metadata() -> FamilyMetadata {
392 FamilyMetadata {
393 name: "latent_survival",
394 parameternames: Self::parameter_names(),
395 parameter_links: Self::parameter_links(),
396 }
397 }
398
399 fn split_time_eta<'a>(
400 &self,
401 block_states: &'a [ParameterBlockState],
402 ) -> Result<
403 (
404 ArrayView1<'a, f64>,
405 ArrayView1<'a, f64>,
406 ArrayView1<'a, f64>,
407 &'a Array1<f64>,
408 ),
409 LatentSurvivalError,
410 > {
411 let expected_blocks = if self.latent_sd_fixed.is_some() { 2 } else { 3 };
412 crate::block_layout::block_count::validate_block_count::<LatentSurvivalError>(
413 "LatentSurvivalFamily",
414 expected_blocks,
415 block_states.len(),
416 )?;
417 let n = self.event_target.len();
418 let eta_time = &block_states[Self::BLOCK_TIME].eta;
419 let eta_mean = &block_states[Self::BLOCK_MEAN].eta;
420 if eta_time.len() != 3 * n {
421 return Err(LatentSurvivalError::BlockMismatch {
422 reason: format!(
423 "latent survival time eta length mismatch: got {}, expected {}",
424 eta_time.len(),
425 3 * n
426 ),
427 });
428 }
429 if eta_mean.len() != n || self.weights.len() != n {
430 return Err(LatentSurvivalError::BlockMismatch {
431 reason: "latent survival mean eta dimension mismatch".to_string(),
432 });
433 }
434 Ok((
435 eta_time.slice(s![0..n]),
436 eta_time.slice(s![n..2 * n]),
437 eta_time.slice(s![2 * n..3 * n]),
438 eta_mean,
439 ))
440 }
441
442 fn time_q_right(
449 &self,
450 block_states: &[ParameterBlockState],
451 ) -> Result<Array1<f64>, LatentSurvivalError> {
452 let n = self.event_target.len();
453 let beta_time = &block_states[Self::BLOCK_TIME].beta;
454 if self.x_time_right.ncols() != beta_time.len() {
455 return Err(LatentSurvivalError::BlockMismatch {
456 reason: format!(
457 "latent survival interval right design has {} columns but time beta has {}",
458 self.x_time_right.ncols(),
459 beta_time.len()
460 ),
461 });
462 }
463 if self.x_time_right.nrows() != n || self.time_offset_right.len() != n {
464 return Err(LatentSurvivalError::BlockMismatch {
465 reason: "latent survival interval right design/offset row count mismatch"
466 .to_string(),
467 });
468 }
469 let mut q_right = self.x_time_right.dot(beta_time);
470 q_right += &self.time_offset_right;
471 Ok(q_right)
472 }
473
474 fn latent_sd(&self, block_states: &[ParameterBlockState]) -> Result<f64, LatentSurvivalError> {
475 if let Some(sigma) = self.latent_sd_fixed {
476 return Ok(sigma);
477 }
478 let eta = *block_states
479 .get(Self::BLOCK_LOG_SIGMA)
480 .and_then(|state| state.eta.get(0))
481 .ok_or_else(|| LatentSurvivalError::BlockMismatch {
482 reason: "latent survival learnable log_sigma block is missing".to_string(),
483 })?;
484 let sigma = exp_sigma_from_eta_scalar(eta);
485 if !(sigma.is_finite() && sigma > 0.0) {
486 return Err(LatentSurvivalError::NumericalFailure {
487 reason: format!(
488 "latent survival learnable sigma became invalid: log_sigma={eta}, sigma={sigma}"
489 ),
490 });
491 }
492 Ok(sigma)
493 }
494}
495
496impl LatentBinaryFamily {
497 pub const BLOCK_TIME: usize = 0;
498 pub const BLOCK_MEAN: usize = 1;
499
500 fn split_time_eta<'a>(
501 &self,
502 block_states: &'a [ParameterBlockState],
503 ) -> Result<(ArrayView1<'a, f64>, ArrayView1<'a, f64>, &'a Array1<f64>), LatentSurvivalError>
504 {
505 crate::block_layout::block_count::validate_block_count::<LatentSurvivalError>(
506 "LatentBinaryFamily",
507 2,
508 block_states.len(),
509 )?;
510 let n = self.event_target.len();
511 let eta_time = &block_states[Self::BLOCK_TIME].eta;
512 let eta_mean = &block_states[Self::BLOCK_MEAN].eta;
513 if eta_time.len() != 3 * n {
514 return Err(LatentSurvivalError::BlockMismatch {
515 reason: format!(
516 "latent binary time eta length mismatch: got {}, expected {}",
517 eta_time.len(),
518 3 * n
519 ),
520 });
521 }
522 if eta_mean.len() != n || self.weights.len() != n {
523 return Err(LatentSurvivalError::BlockMismatch {
524 reason: "latent binary mean eta dimension mismatch".to_string(),
525 });
526 }
527 Ok((
528 eta_time.slice(s![0..n]),
529 eta_time.slice(s![n..2 * n]),
530 eta_mean,
531 ))
532 }
533}
534
535pub fn fixed_latent_hazard_frailty(
536 frailty: &FrailtySpec,
537 context: &str,
538) -> Result<(f64, HazardLoading), String> {
539 fixed_latent_hazard_frailty_typed(frailty, context).map_err(Into::into)
540}
541
542fn fixed_latent_hazard_frailty_typed(
543 frailty: &FrailtySpec,
544 context: &str,
545) -> Result<(f64, HazardLoading), LatentSurvivalError> {
546 frailty
547 .validate()
548 .map_err(|err| LatentSurvivalError::InvalidFrailty {
549 reason: err.to_string(),
550 })?;
551 match frailty {
552 FrailtySpec::HazardMultiplier {
553 scale: FrailtyScale::Fixed { sigma },
554 loading,
555 } => Ok((*sigma, *loading)),
556 FrailtySpec::HazardMultiplier {
557 scale: FrailtyScale::Learned { .. },
558 ..
559 } => Err(LatentSurvivalError::InvalidFrailty {
560 reason: format!("{context} requires a fixed hazard-multiplier sigma"),
561 }),
562 FrailtySpec::GaussianShift { .. } => Err(LatentSurvivalError::InvalidFrailty {
563 reason: format!("{context} requires HazardMultiplier frailty, not GaussianShift"),
564 }),
565 FrailtySpec::None => Err(LatentSurvivalError::InvalidFrailty {
566 reason: format!("{context} requires a fixed HazardMultiplier frailty specification"),
567 }),
568 }
569}
570
571pub fn latent_hazard_loading(
572 frailty: &FrailtySpec,
573 context: &str,
574) -> Result<HazardLoading, String> {
575 latent_hazard_loading_typed(frailty, context).map_err(Into::into)
576}
577
578fn latent_hazard_loading_typed(
579 frailty: &FrailtySpec,
580 context: &str,
581) -> Result<HazardLoading, LatentSurvivalError> {
582 match frailty {
583 FrailtySpec::HazardMultiplier { loading, .. } => Ok(*loading),
584 FrailtySpec::GaussianShift { .. } => Err(LatentSurvivalError::InvalidFrailty {
585 reason: format!("{context} requires HazardMultiplier frailty, not GaussianShift"),
586 }),
587 FrailtySpec::None => Err(LatentSurvivalError::InvalidFrailty {
588 reason: format!("{context} requires a HazardMultiplier frailty specification"),
589 }),
590 }
591}
592
593#[derive(Clone, Copy)]
594struct LatentSurvivalTimeJet {
595 grad_entry: f64,
596 grad_exit: f64,
597 neg_hess_entry: f64,
598 neg_hess_exit: f64,
599}
600
601pub fn fit_latent_survival_terms(
602 data: ArrayView2<'_, f64>,
603 mut spec: LatentSurvivalTermSpec,
604 frailty: FrailtySpec,
605 options: &BlockwiseFitOptions,
606) -> Result<LatentSurvivalTermFitResult, String> {
607 let frailty_scale = validate_latent_survival_inputs(data, &spec, &frailty)?;
608 install_latent_time_nullspace_shrinkage_penalty(&mut spec.time_block)?;
614 let (latent_sd, learned_initial_sigma) = match frailty_scale {
615 FrailtyScale::Fixed { sigma } => (Some(sigma), None),
616 FrailtyScale::Learned { initial_sigma } => (None, Some(initial_sigma)),
617 };
618 let hazard_loading = latent_hazard_loading(&frailty, "latent-survival")?;
619 let mean_design =
620 build_term_collection_design(data, &spec.meanspec).map_err(|e| e.to_string())?;
621 let mean_offset = mean_design
622 .compose_offset(spec.mean_offset.view(), "latent-survival mean block")
623 .map_err(|e| e.to_string())?;
624 let resolvedspec = freeze_term_collection_from_design(&spec.meanspec, &mean_design)
625 .map_err(|e| e.to_string())?;
626 let time_prepared = prepare_latent_time_block(
627 &spec.time_block,
628 spec.time_design_right.as_ref(),
629 spec.derivative_guard,
630 )?;
631
632 let n = spec.event_target.len();
633 let time_offset_right = match spec.time_offset_right.as_ref() {
634 Some(offset) => {
635 if offset.len() != n {
636 return Err(format!(
637 "latent survival interval right time offset must have length {n}, got {}",
638 offset.len()
639 ));
640 }
641 offset.clone()
642 }
643 None => Array1::zeros(n),
644 };
645 let unloaded_mass_right = if spec.unloaded_mass_right.is_empty() {
646 Array1::zeros(n)
647 } else {
648 if spec.unloaded_mass_right.len() != n {
649 return Err(format!(
650 "latent survival interval right unloaded mass must have length {n}, got {}",
651 spec.unloaded_mass_right.len()
652 ));
653 }
654 spec.unloaded_mass_right.clone()
655 };
656
657 let family = LatentSurvivalFamily {
658 event_target: spec.event_target.clone(),
659 weights: spec.weights.clone(),
660 latent_sd_fixed: latent_sd,
661 hazard_loading,
662 unloaded_mass_entry: spec.unloaded_mass_entry.clone(),
663 unloaded_mass_exit: spec.unloaded_mass_exit.clone(),
664 unloaded_hazard_exit: spec.unloaded_hazard_exit.clone(),
665 x_time_entry: time_prepared.design_entry.clone(),
666 x_time_exit: time_prepared.design_exit.clone(),
667 x_time_derivative_exit: time_prepared.design_derivative_exit.clone(),
668 x_time_right: time_prepared.design_right.clone(),
669 time_offset_right,
670 unloaded_mass_right,
671 x_mean: mean_design.design.clone(),
672 time_linear_constraints: time_prepared.linear_constraints.clone(),
673 quadctx: Arc::new(QuadratureContext::new()),
674 };
675
676 let mut blocks = vec![
677 build_time_blockspec(&time_prepared, &spec.time_block),
678 build_mean_blockspec(&mean_design, mean_offset),
679 ];
680 if let Some(initial_sigma) = learned_initial_sigma {
681 blocks.push(build_log_sigma_blockspec(
682 initial_sigma,
683 mean_design.design.nrows(),
684 ));
685 }
686 let has_interval_rows = spec
711 .event_target
712 .iter()
713 .any(|&code| code == LATENT_SURVIVAL_EVENT_INTERVAL);
714 if has_interval_rows {
715 let censored_warm_event_target = spec.event_target.mapv(|code| {
716 if code == LATENT_SURVIVAL_EVENT_INTERVAL {
717 0u8
718 } else {
719 code
720 }
721 });
722 let mut warm_family = family.clone();
723 warm_family.event_target = censored_warm_event_target;
724 let warm_fit_result = fit_custom_family_fixed_log_lambdas(
731 &warm_family,
732 &blocks,
733 options,
734 None,
735 );
736 let warm_fit = match warm_fit_result {
737 Ok(fit) => fit,
738 Err(censored_error) => {
739 let has_finite_event_in_censored_surrogate =
740 warm_family.event_target.iter().any(|&code| code != 0);
741 if has_finite_event_in_censored_surrogate {
742 return Err(format!(
743 "latent interval warm start: right-censored-at-L surrogate fit failed \
744 (so the interval fit cannot be safely warm-started; this surrogate is \
745 log-concave and should converge — investigate the surrogate, not the \
746 interval kernel): {censored_error}"
747 ));
748 }
749
750 let lower_event_warm_target = spec.event_target.mapv(|code| {
759 if code == LATENT_SURVIVAL_EVENT_INTERVAL {
760 1u8
761 } else {
762 code
763 }
764 });
765 let mut event_warm_family = family.clone();
766 event_warm_family.event_target = lower_event_warm_target;
767 fit_custom_family_fixed_log_lambdas(
768 &event_warm_family,
769 &blocks,
770 options,
771 None,
772 )
773 .map_err(|event_error| {
774 format!(
775 "latent interval warm start failed: the right-censored-at-L surrogate \
776 has no finite failures and refused its boundary optimum ({censored_error}); \
777 the finite lower-endpoint event surrogate also failed ({event_error})"
778 )
779 })?
780 }
781 };
782 let warm_beta_usable = warm_fit
783 .block_states
784 .iter()
785 .any(|s| s.beta.iter().all(|v| v.is_finite()) && s.beta.iter().any(|&v| v != 0.0));
786 if !warm_beta_usable {
787 return Err(
788 "latent interval warm start: right-censored-at-L surrogate returned a \
789 degenerate (non-finite or all-zero) β across every block; the warm start \
790 cannot seed the interval fit. This indicates the surrogate's time-block \
791 design is rank-deficient or the inner solve stalled at the seed — \
792 investigate the surrogate before retrying the interval fit."
793 .to_string(),
794 );
795 }
796 for (block, state) in blocks.iter_mut().zip(warm_fit.block_states.iter()) {
797 if state.beta.iter().all(|v| v.is_finite()) {
798 block.initial_beta = Some(state.beta.clone());
799 }
800 }
801 }
802 let fit = fit_custom_family(&family, &blocks, options).map_err(|e| e.to_string())?;
803 let latent_sd = family.latent_sd(&fit.block_states)?;
804 let baseline_offset_residuals = family.offset_channel_residuals(&fit.block_states)?;
805 Ok(LatentSurvivalTermFitResult {
806 fit,
807 design: mean_design,
808 resolvedspec,
809 latent_sd,
810 baseline_offset_residuals,
811 })
812}
813
814pub fn fit_latent_binary_terms(
815 data: ArrayView2<'_, f64>,
816 spec: LatentBinaryTermSpec,
817 frailty: FrailtySpec,
818 options: &BlockwiseFitOptions,
819) -> Result<LatentBinaryTermFitResult, String> {
820 let latent_sd = validate_latent_binary_inputs(data, &spec, &frailty)?;
821 let (_, hazard_loading) = fixed_latent_hazard_frailty(&frailty, "latent-binary")?;
822 let mean_design =
823 build_term_collection_design(data, &spec.meanspec).map_err(|e| e.to_string())?;
824 let mean_offset = mean_design
825 .compose_offset(spec.mean_offset.view(), "latent-binary mean block")
826 .map_err(|e| e.to_string())?;
827 let resolvedspec = freeze_term_collection_from_design(&spec.meanspec, &mean_design)
828 .map_err(|e| e.to_string())?;
829 let time_prepared = prepare_latent_time_block(&spec.time_block, None, spec.derivative_guard)?;
830
831 let family = LatentBinaryFamily {
832 event_target: spec.event_target.clone(),
833 weights: spec.weights.clone(),
834 latent_sd,
835 hazard_loading,
836 unloaded_mass_entry: spec.unloaded_mass_entry.clone(),
837 unloaded_mass_exit: spec.unloaded_mass_exit.clone(),
838 x_time_entry: time_prepared.design_entry.clone(),
839 x_time_exit: time_prepared.design_exit.clone(),
840 x_mean: mean_design.design.clone(),
841 time_linear_constraints: time_prepared.linear_constraints.clone(),
842 quadctx: Arc::new(QuadratureContext::new()),
843 };
844
845 let blocks = vec![
846 build_time_blockspec(&time_prepared, &spec.time_block),
847 build_mean_blockspec(&mean_design, mean_offset),
848 ];
849 let fit = fit_custom_family(&family, &blocks, options).map_err(|e| e.to_string())?;
850 let baseline_offset_residuals = family.offset_channel_residuals(&fit.block_states)?;
851 Ok(LatentBinaryTermFitResult {
852 fit,
853 design: mean_design,
854 resolvedspec,
855 baseline_offset_residuals,
856 })
857}
858
859struct LatentSurvivalModel;
865
866impl LatentIntervalModel for LatentSurvivalModel {
867 fn context() -> &'static str {
868 "latent-survival"
869 }
870
871 fn allows_interval() -> bool {
872 true
873 }
874
875 fn frailty_policy(
876 frailty: &FrailtySpec,
877 ) -> Result<LatentFrailtyResolution, LatentSurvivalError> {
878 frailty
879 .validate()
880 .map_err(|err| LatentSurvivalError::InvalidFrailty {
881 reason: err.to_string(),
882 })?;
883 match frailty {
884 FrailtySpec::HazardMultiplier {
885 scale,
886 loading,
887 } => Ok(LatentFrailtyResolution {
888 scale: *scale,
889 loading: *loading,
890 }),
891 FrailtySpec::GaussianShift { .. } => Err(LatentSurvivalError::InvalidFrailty {
892 reason: "latent-survival requires HazardMultiplier frailty, not GaussianShift"
893 .to_string(),
894 }),
895 FrailtySpec::None => Err(LatentSurvivalError::InvalidFrailty {
896 reason: "latent-survival requires a HazardMultiplier frailty specification"
897 .to_string(),
898 }),
899 }
900 }
901}
902
903fn validate_latent_survival_inputs(
904 data: ArrayView2<'_, f64>,
905 spec: &LatentSurvivalTermSpec,
906 frailty: &FrailtySpec,
907) -> Result<FrailtyScale, LatentSurvivalError> {
908 let row = LatentIntervalRowView {
909 frailty,
910 age_entry: &spec.age_entry,
911 age_exit: &spec.age_exit,
912 event_target: &spec.event_target,
913 weights: &spec.weights,
914 unloaded_mass_entry: &spec.unloaded_mass_entry,
915 unloaded_mass_exit: &spec.unloaded_mass_exit,
916 unloaded_hazard_exit: Some(&spec.unloaded_hazard_exit),
917 mean_offset: &spec.mean_offset,
918 derivative_guard: spec.derivative_guard,
919 time_block: &spec.time_block,
920 };
921 validate_latent_interval_inputs::<LatentSurvivalModel>(data, &row)
922}
923
924pub(crate) fn validate_unloaded_components_for_loading(
925 context: &str,
926 row_index: usize,
927 loading: HazardLoading,
928 unloaded_entry: f64,
929 unloaded_exit: f64,
930 unloaded_hazard: Option<f64>,
931) -> Result<(), LatentSurvivalError> {
932 match loading {
933 HazardLoading::Full => {
934 if unloaded_entry != 0.0
935 || unloaded_exit != 0.0
936 || unloaded_hazard.is_some_and(|hazard| hazard != 0.0)
937 {
938 return Err(LatentSurvivalError::InvalidDataset {
939 reason: format!(
940 "{context} row {} uses full hazard loading, so unloaded components must be exactly zero; got entry_mass={}, exit_mass={}, exit_hazard={}",
941 row_index + 1,
942 unloaded_entry,
943 unloaded_exit,
944 unloaded_hazard.unwrap_or(0.0)
945 ),
946 });
947 }
948 }
949 HazardLoading::LoadedVsUnloaded => {}
950 }
951 Ok(())
952}
953
954struct LatentBinaryModel;
961
962impl LatentIntervalModel for LatentBinaryModel {
963 fn context() -> &'static str {
964 "latent-binary"
965 }
966
967 fn frailty_policy(
968 frailty: &FrailtySpec,
969 ) -> Result<LatentFrailtyResolution, LatentSurvivalError> {
970 let (sigma, loading) = fixed_latent_hazard_frailty_typed(frailty, "latent-binary")?;
971 Ok(LatentFrailtyResolution {
972 scale: FrailtyScale::Fixed { sigma },
973 loading,
974 })
975 }
976}
977
978fn validate_latent_binary_inputs(
979 data: ArrayView2<'_, f64>,
980 spec: &LatentBinaryTermSpec,
981 frailty: &FrailtySpec,
982) -> Result<f64, LatentSurvivalError> {
983 let row = LatentIntervalRowView {
984 frailty,
985 age_entry: &spec.age_entry,
986 age_exit: &spec.age_exit,
987 event_target: &spec.event_target,
988 weights: &spec.weights,
989 unloaded_mass_entry: &spec.unloaded_mass_entry,
990 unloaded_mass_exit: &spec.unloaded_mass_exit,
991 unloaded_hazard_exit: None,
992 mean_offset: &spec.mean_offset,
993 derivative_guard: spec.derivative_guard,
994 time_block: &spec.time_block,
995 };
996 match validate_latent_interval_inputs::<LatentBinaryModel>(data, &row)? {
997 FrailtyScale::Fixed { sigma } => Ok(sigma),
998 FrailtyScale::Learned { .. } => Err(LatentSurvivalError::InvalidFrailty {
999 reason: "latent-binary requires a fixed latent sigma".to_string(),
1000 }),
1001 }
1002}
1003
1004fn prepare_latent_time_block(
1005 input: &TimeBlockInput,
1006 design_right: Option<&DesignMatrix>,
1007 derivative_guard: f64,
1008) -> Result<PreparedLatentTimeBlock, LatentSurvivalError> {
1009 if !input.time_monotonicity.is_coordinate_cone() {
1010 return Err(LatentSurvivalError::UnsupportedConfiguration {
1011 reason: format!(
1012 "latent survival requires a coordinate-cone monotonicity strategy; got {:?}",
1013 input.time_monotonicity
1014 ),
1015 });
1016 }
1017 let design_entry = input
1018 .design_entry
1019 .try_to_dense_by_chunks("latent survival entry time design")?;
1020 let design_exit = input
1021 .design_exit
1022 .try_to_dense_by_chunks("latent survival exit time design")?;
1023 let design_derivative_exit = input
1024 .design_derivative_exit
1025 .try_to_dense_by_chunks("latent survival derivative time design")?;
1026 let design_right = match design_right {
1032 Some(matrix) => {
1033 let dense =
1034 matrix.try_to_dense_by_chunks("latent survival interval right time design")?;
1035 if dense.nrows() != design_exit.nrows() || dense.ncols() != design_exit.ncols() {
1036 return Err(LatentSurvivalError::InvalidDataset {
1037 reason: format!(
1038 "latent survival interval right time design must match exit design shape \
1039 {:?}, got {:?}",
1040 design_exit.dim(),
1041 dense.dim()
1042 ),
1043 });
1044 }
1045 dense
1046 }
1047 None => design_exit.clone(),
1048 };
1049 let linear_constraints = structural_time_coefficient_constraints(
1050 &input.design_derivative_exit,
1051 &input.derivative_offset_exit,
1052 derivative_guard,
1053 )?;
1054 let initial_beta = match linear_constraints.as_ref() {
1055 Some(constraints) => Some(project_onto_linear_constraints(
1060 design_exit.ncols(),
1061 constraints,
1062 input.initial_beta.as_ref(),
1063 )?),
1064 None => None,
1065 };
1066 Ok(PreparedLatentTimeBlock {
1067 design_entry,
1068 design_exit,
1069 design_derivative_exit,
1070 design_right,
1071 linear_constraints,
1072 penalties: input.penalties.clone(),
1073 initial_beta,
1074 })
1075}
1076
1077fn stack_rows(blocks: &[&Array2<f64>]) -> Array2<f64> {
1078 let ncols = blocks.first().map_or(0, |m| m.ncols());
1079 let nrows = blocks.iter().map(|m| m.nrows()).sum();
1080 let mut out = Array2::<f64>::zeros((nrows, ncols));
1081 let mut row = 0usize;
1082 for block in blocks {
1083 let end = row + block.nrows();
1084 out.slice_mut(s![row..end, ..]).assign(block);
1085 row = end;
1086 }
1087 out
1088}
1089
1090fn build_time_blockspec(
1091 prepared: &PreparedLatentTimeBlock,
1092 input: &TimeBlockInput,
1093) -> ParameterBlockSpec {
1094 let stacked_design = stack_rows(&[
1106 &prepared.design_entry,
1107 &prepared.design_exit,
1108 &prepared.design_derivative_exit,
1109 ]);
1110 let stacked_offset = gam_linalg::utils::stack_offsets(&[
1111 &input.offset_entry,
1112 &input.offset_exit,
1113 &input.derivative_offset_exit,
1114 ]);
1115 ParameterBlockSpec {
1116 name: "time_transform".to_string(),
1117 design: DesignMatrix::Dense(DenseDesignMatrix::from(Arc::new(
1118 prepared.design_exit.clone(),
1119 ))),
1120 offset: input.offset_exit.clone(),
1121 penalties: prepared
1122 .penalties
1123 .iter()
1124 .cloned()
1125 .map(PenaltyMatrix::Dense)
1126 .collect(),
1127 nullspace_dims: input.nullspace_dims.clone(),
1128 initial_log_lambdas: input
1129 .initial_log_lambdas
1130 .clone()
1131 .unwrap_or_else(|| Array1::zeros(prepared.penalties.len())),
1132 initial_beta: prepared.initial_beta.clone(),
1133 gauge_priority: 200,
1139 jacobian_callback: None,
1140 stacked_design: Some(DesignMatrix::Dense(DenseDesignMatrix::from(Arc::new(
1141 stacked_design,
1142 )))),
1143 stacked_offset: Some(stacked_offset),
1144 }
1145}
1146
1147fn build_mean_blockspec(design: &TermCollectionDesign, offset: Array1<f64>) -> ParameterBlockSpec {
1148 ParameterBlockSpec {
1149 name: "mean".to_string(),
1150 design: design.design.clone(),
1151 offset,
1152 penalties: design.penalties_as_penalty_matrix(),
1153 nullspace_dims: design.nullspace_dims.clone(),
1154 initial_log_lambdas: Array1::zeros(design.penalties.len()),
1155 initial_beta: None,
1156 gauge_priority: 150,
1162 jacobian_callback: None,
1163 stacked_design: None,
1164 stacked_offset: None,
1165 }
1166}
1167
1168fn build_log_sigma_blockspec(initial_sigma: f64, n_obs: usize) -> ParameterBlockSpec {
1169 ParameterBlockSpec {
1170 name: "log_sigma".to_string(),
1171 design: DesignMatrix::Dense(DenseDesignMatrix::from(Arc::new(Array2::from_elem(
1181 (n_obs, 1),
1182 1.0,
1183 )))),
1184 offset: Array1::zeros(n_obs),
1185 penalties: vec![],
1186 nullspace_dims: vec![],
1187 initial_log_lambdas: Array1::zeros(0),
1188 initial_beta: Some(Array1::from_elem(
1189 1,
1190 exp_sigma_eta_for_sigma_scalar(initial_sigma),
1191 )),
1192 gauge_priority: 120,
1195 jacobian_callback: None,
1196 stacked_design: None,
1197 stacked_offset: None,
1198 }
1199}
1200
1201fn install_latent_time_nullspace_shrinkage_penalty(
1231 time_block: &mut TimeBlockInput,
1232) -> Result<bool, String> {
1233 let p = time_block.design_exit.ncols();
1234 if p == 0 || time_block.penalties.is_empty() {
1235 return Ok(false);
1236 }
1237 if time_block.nullspace_dims.len() != time_block.penalties.len() {
1238 return Err(format!(
1239 "latent-survival time_block nullspace_dims length {} does not match penalties {}",
1240 time_block.nullspace_dims.len(),
1241 time_block.penalties.len(),
1242 ));
1243 }
1244
1245 let mut aggregate = Array2::<f64>::zeros((p, p));
1249 for (idx, penalty) in time_block.penalties.iter().enumerate() {
1250 if penalty.nrows() != p || penalty.ncols() != p {
1251 return Err(format!(
1252 "latent-survival time_block penalty {idx} must be {p}x{p}, got {}x{}",
1253 penalty.nrows(),
1254 penalty.ncols(),
1255 ));
1256 }
1257 let scale = penalty
1258 .iter()
1259 .try_fold(0.0_f64, |acc, &value| {
1260 value.is_finite().then_some(acc.max(value.abs()))
1261 })
1262 .ok_or_else(|| {
1263 format!("latent-survival time_block penalty {idx} contains non-finite values")
1264 })?;
1265 if scale > 0.0 {
1266 ndarray::Zip::from(&mut aggregate)
1267 .and(penalty)
1268 .for_each(|agg, &value| *agg += value / scale);
1269 }
1270 }
1271
1272 if time_block.design_entry.ncols() != p {
1278 return Err(format!(
1279 "latent-survival time_block entry design has {} columns, expected {p}",
1280 time_block.design_entry.ncols(),
1281 ));
1282 }
1283 let entry_mass = time_block.design_entry.nrows();
1284 let exit_mass = time_block.design_exit.nrows();
1285 let total_mass = entry_mass.saturating_add(exit_mass);
1286 if total_mass == 0 {
1287 return Err(
1288 "latent-survival time_block cannot define a function metric from zero endpoint rows"
1289 .to_string(),
1290 );
1291 }
1292 let entry_gram = time_block
1293 .design_entry
1294 .diag_xtw_x(&Array1::ones(entry_mass))
1295 .map_err(|err| format!("latent-survival time_block entry function Gram: {err}"))?;
1296 let exit_gram = time_block
1297 .design_exit
1298 .diag_xtw_x(&Array1::ones(exit_mass))
1299 .map_err(|err| format!("latent-survival time_block exit function Gram: {err}"))?;
1300 let function_gram = (entry_gram + exit_gram).mapv(|value| value / total_mass as f64);
1301
1302 let Some(shrinkage) =
1303 gam_terms::basis::function_space_nullspace_shrinkage(&aggregate, &function_gram)
1304 .map_err(|err| format!("latent-survival time_block nullspace shrinkage: {err}"))?
1305 else {
1306 return Ok(false);
1307 };
1308 if shrinkage.nrows() != p || shrinkage.ncols() != p {
1309 return Err(format!(
1310 "latent-survival time_block nullspace shrinkage penalty must be {p}x{p}, got {}x{}",
1311 shrinkage.nrows(),
1312 shrinkage.ncols(),
1313 ));
1314 }
1315 time_block.penalties.push(shrinkage);
1316 time_block.nullspace_dims.push(0);
1317 if let Some(seeds) = time_block.initial_log_lambdas.as_mut() {
1323 let seed = seeds.iter().copied().last().unwrap_or(0.0);
1324 let mut widened = seeds.to_vec();
1325 widened.push(seed);
1326 *seeds = Array1::from_vec(widened);
1327 }
1328 Ok(true)
1329}
1330
1331const LATENT_SURVIVAL_PRIMARY_Q_ENTRY: usize = 0;
1332const LATENT_SURVIVAL_PRIMARY_Q_EXIT: usize = 1;
1333const LATENT_SURVIVAL_PRIMARY_QDOT_EXIT: usize = 2;
1334const LATENT_SURVIVAL_PRIMARY_Q_RIGHT: usize = 3;
1341const LATENT_SURVIVAL_PRIMARY_MU: usize = 4;
1342const LATENT_SURVIVAL_PRIMARY_LOG_SIGMA: usize = 5;
1343const LATENT_SURVIVAL_PRIMARY_DIM: usize = 6;
1344
1345fn latent_unary_derivatives_log1mexp_negative(
1362 x: f64,
1363 derivative_order: usize,
1364 context: &str,
1365) -> Result<[f64; 5], LatentSurvivalError> {
1366 assert!(derivative_order <= 4);
1367 if !(x.is_finite() && x < 0.0) {
1368 return Err(LatentSurvivalError::NumericalFailure {
1369 reason: format!("{context} requires a finite negative log-boundary gap, got {x:?}"),
1370 });
1371 }
1372 let value = log1mexp_positive(-x);
1373 let exp_x = x.exp();
1374 let log_derivative_magnitudes = [
1375 x - value,
1376 x - 2.0 * value,
1377 x + exp_x.ln_1p() - 3.0 * value,
1378 x + (exp_x * (4.0 + exp_x)).ln_1p() - 4.0 * value,
1379 ];
1380 let mut derivatives = [value, 0.0, 0.0, 0.0, 0.0];
1381 for (offset, log_magnitude) in log_derivative_magnitudes
1382 .into_iter()
1383 .take(derivative_order)
1384 .enumerate()
1385 {
1386 let order = offset + 1;
1387 let magnitude = log_magnitude.exp();
1388 if !magnitude.is_finite() {
1389 return Err(LatentSurvivalError::NumericalFailure {
1390 reason: format!(
1391 "{context} derivative order {order} is not representable at \
1392 log-boundary gap {x:?} (log magnitude {log_magnitude:?})"
1393 ),
1394 });
1395 }
1396 derivatives[order] = -magnitude;
1397 }
1398 Ok(derivatives)
1399}
1400
1401fn latent_survival_positive_log_difference_jet<J: JetField>(
1414 log_left: &J,
1415 log_coefficient_left: f64,
1416 log_right: &J,
1417 log_coefficient_right: f64,
1418 derivative_order: usize,
1419 context: &str,
1420 all_channels_finite: impl Fn(&J) -> bool,
1421) -> Result<J, LatentSurvivalError> {
1422 let weighted_left = log_left.add(&log_left.constant_like(log_coefficient_left));
1423 let weighted_right = log_right.add(&log_right.constant_like(log_coefficient_right));
1424 let delta = weighted_right.sub(&weighted_left);
1425 let delta_value = delta.value();
1426 if !(delta_value.is_finite() && delta_value < 0.0) {
1427 return Err(LatentSurvivalError::NumericalFailure {
1428 reason: format!(
1429 "{context} must be a positive survival-mass difference: \
1430 log(c_L*K0(M_L))={:?}, log(c_R*K0(M_R))={:?}; \
1431 require M_L < M_R (i.e. L < R)",
1432 weighted_left.value(),
1433 weighted_right.value(),
1434 ),
1435 });
1436 }
1437 let derivatives =
1438 latent_unary_derivatives_log1mexp_negative(delta_value, derivative_order, context)?;
1439 let out = weighted_left.add(&delta.compose_unary(derivatives));
1440 if !all_channels_finite(&out) {
1441 return Err(LatentSurvivalError::NumericalFailure {
1442 reason: format!(
1443 "{context} derivative jet is not representable at log-boundary gap {delta_value:?}"
1444 ),
1445 });
1446 }
1447 Ok(out)
1448}
1449
1450#[derive(Clone, Copy, Debug)]
1451struct LatentKernelPrimaryTerm {
1452 coeff: f64,
1453 q_exp: usize,
1454 qdot_power: usize,
1455 tau_exp: usize,
1456 k: usize,
1457}
1458
1459#[derive(Clone, Copy, Debug)]
1467struct LatentSignedLog {
1468 log_abs: f64,
1469 sign: f64,
1470}
1471
1472impl LatentSignedLog {
1473 const ZERO: Self = Self {
1474 log_abs: f64::NEG_INFINITY,
1475 sign: 0.0,
1476 };
1477 const ONE: Self = Self {
1478 log_abs: 0.0,
1479 sign: 1.0,
1480 };
1481}
1482
1483const LATENT_EXACT_EXPANSION_ORDER1: usize = 1;
1496const LATENT_EXACT_EXPANSION_ORDER2: usize = 1 + 2 * LATENT_EXACT_EXPANSION_ORDER1;
1497const LATENT_EXACT_EXPANSION_ORDER3: usize =
1498 1 + 2 * LATENT_EXACT_EXPANSION_ORDER1 + 2 * 2 * LATENT_EXACT_EXPANSION_ORDER2;
1499const LATENT_EXACT_EXPANSION_ORDER4: usize = 1
1500 + 2 * LATENT_EXACT_EXPANSION_ORDER1
1501 + 3 * 2 * LATENT_EXACT_EXPANSION_ORDER2
1502 + 3 * 2 * LATENT_EXACT_EXPANSION_ORDER3;
1503const LATENT_EXACT_EXPANSION_CAPACITY: usize = LATENT_EXACT_EXPANSION_ORDER4 + 1;
1504
1505const LATENT_UNREPRESENTABLE_MASS_REACHES_CELL: &str =
1510 "the product mass binary64 cannot represent reaches the cumulant's rounding cell";
1511const _: () = assert!(LATENT_EXACT_EXPANSION_ORDER1 == 1);
1512const _: () = assert!(LATENT_EXACT_EXPANSION_ORDER2 == 3);
1513const _: () = assert!(LATENT_EXACT_EXPANSION_ORDER3 == 15);
1514const _: () = assert!(LATENT_EXACT_EXPANSION_ORDER4 == 111);
1515const _: () = assert!(LATENT_EXACT_EXPANSION_CAPACITY == 112);
1516
1517#[derive(Clone, Copy)]
1527struct LatentExactExpansion {
1528 components: [f64; LATENT_EXACT_EXPANSION_CAPACITY],
1529 len: usize,
1530 unrepresentable_mass: f64,
1540}
1541
1542#[derive(Clone, Copy, Debug)]
1543struct LatentCertifiedCumulant {
1544 value: f64,
1546 absolute_term_mass: f64,
1552}
1553
1554impl LatentCertifiedCumulant {
1555 const ZERO: Self = Self {
1556 value: 0.0,
1557 absolute_term_mass: 0.0,
1558 };
1559}
1560
1561impl LatentExactExpansion {
1562 const ZERO: Self = Self {
1563 components: [0.0; LATENT_EXACT_EXPANSION_CAPACITY],
1564 len: 0,
1565 unrepresentable_mass: 0.0,
1566 };
1567
1568 const SUBNORMAL_ULP: f64 = f64::from_bits(1);
1580
1581 #[inline]
1588 fn outward_bound(value: f64) -> Result<f64, &'static str> {
1589 if !value.is_finite() || value < 0.0 {
1590 return Err("an unrepresentable-mass bound left the finite nonnegative range");
1591 }
1592 if value == 0.0 {
1593 return Ok(0.0);
1594 }
1595 Ok(Self::next_up(value))
1596 }
1597
1598 fn scalar(value: f64) -> Self {
1599 if value == 0.0 {
1600 Self::ZERO
1601 } else {
1602 let mut out = Self::ZERO;
1603 out.components[0] = value;
1604 out.len = 1;
1605 out
1606 }
1607 }
1608
1609 #[inline]
1610 fn component(&self, index: usize) -> f64 {
1611 self.components[index]
1612 }
1613
1614 fn push_nonzero(&mut self, value: f64) -> Result<(), &'static str> {
1615 if value == 0.0 {
1616 return Ok(());
1617 }
1618 if !value.is_finite() {
1619 return Err("an exact-expansion component became non-finite");
1620 }
1621 if self.len == LATENT_EXACT_EXPANSION_CAPACITY {
1622 return Err("the structurally bounded exact expansion exhausted its capacity");
1623 }
1624 self.components[self.len] = value;
1625 self.len += 1;
1626 Ok(())
1627 }
1628
1629 #[inline]
1631 fn two_sum(a: f64, b: f64) -> Result<(f64, f64), &'static str> {
1632 let sum = a + b;
1633 if !sum.is_finite() {
1634 return Err("an exact-expansion addition overflowed");
1635 }
1636 let virtual_b = sum - a;
1637 let virtual_a = sum - virtual_b;
1638 let error = (a - virtual_a) + (b - virtual_b);
1639 Ok((sum, error))
1640 }
1641
1642 #[inline]
1670 fn two_product(a: f64, b: f64) -> Result<(f64, f64, f64), &'static str> {
1671 if a == 0.0 || b == 0.0 {
1672 return Ok((0.0, 0.0, 0.0));
1673 }
1674 let product = a * b;
1675 if !product.is_finite() {
1676 return Err("an exact-expansion product overflowed");
1677 }
1678 let error = a.mul_add(b, -product);
1679 if !error.is_finite() {
1680 return Err("an exact-expansion product residual became non-finite");
1681 }
1682 let unrepresentable = if product.abs() < f64::MIN_POSITIVE / f64::EPSILON {
1683 Self::SUBNORMAL_ULP
1684 } else {
1685 0.0
1686 };
1687 Ok((product, error, unrepresentable))
1688 }
1689
1690 fn add(self, other: Self) -> Result<Self, &'static str> {
1695 let carried = Self::outward_bound(self.unrepresentable_mass + other.unrepresentable_mass)?;
1696 if self.len == 0 {
1697 return Ok(Self {
1698 unrepresentable_mass: carried,
1699 ..other
1700 });
1701 }
1702 if other.len == 0 {
1703 return Ok(Self {
1704 unrepresentable_mass: carried,
1705 ..self
1706 });
1707 }
1708 if self.len + other.len > LATENT_EXACT_EXPANSION_CAPACITY {
1709 return Err("an exact-expansion sum exceeded its structural support bound");
1710 }
1711
1712 let mut left = 0usize;
1713 let mut right = 0usize;
1714 let take_left = |left_value: f64, right_value: f64| {
1715 left_value.abs() <= right_value.abs()
1716 };
1717 let mut q = if take_left(self.component(left), other.component(right)) {
1718 let value = self.component(left);
1719 left += 1;
1720 value
1721 } else {
1722 let value = other.component(right);
1723 right += 1;
1724 value
1725 };
1726 let mut out = Self::ZERO;
1727
1728 while left < self.len || right < other.len {
1729 let next = if right == other.len
1730 || (left < self.len
1731 && take_left(self.component(left), other.component(right)))
1732 {
1733 let value = self.component(left);
1734 left += 1;
1735 value
1736 } else {
1737 let value = other.component(right);
1738 right += 1;
1739 value
1740 };
1741 let (sum, error) = Self::two_sum(q, next)?;
1742 out.push_nonzero(error)?;
1743 q = sum;
1744 }
1745 out.push_nonzero(q)?;
1746 out.unrepresentable_mass = carried;
1747 Ok(out)
1748 }
1749
1750 fn scale(self, scalar: f64) -> Result<Self, &'static str> {
1757 if self.len == 0 || scalar == 0.0 {
1758 return Ok(Self::ZERO);
1759 }
1760 if self.len * 2 > LATENT_EXACT_EXPANSION_CAPACITY {
1761 return Err("a scaled exact expansion exceeded its structural support bound");
1762 }
1763
1764 let mut unrepresentable =
1765 Self::outward_bound(self.unrepresentable_mass * scalar.abs())?;
1766 let (mut q, first_error, first_unrepresentable) =
1767 Self::two_product(self.component(0), scalar)?;
1768 unrepresentable = Self::outward_bound(unrepresentable + first_unrepresentable)?;
1769 let mut out = Self::ZERO;
1770 out.push_nonzero(first_error)?;
1771 for index in 1..self.len {
1772 let (product, product_error, product_unrepresentable) =
1773 Self::two_product(self.component(index), scalar)?;
1774 unrepresentable = Self::outward_bound(unrepresentable + product_unrepresentable)?;
1775 let (sum, sum_error) = Self::two_sum(q, product_error)?;
1776 out.push_nonzero(sum_error)?;
1777 let (next_q, product_sum_error) = Self::two_sum(product, sum)?;
1781 out.push_nonzero(product_sum_error)?;
1782 q = next_q;
1783 }
1784 out.push_nonzero(q)?;
1785 out.unrepresentable_mass = unrepresentable;
1786 Ok(out)
1787 }
1788
1789 #[inline]
1790 fn next_up(value: f64) -> f64 {
1791 if value.is_nan() || value == f64::INFINITY {
1792 value
1793 } else if value == 0.0 {
1794 f64::from_bits(1)
1795 } else if value > 0.0 {
1796 f64::from_bits(value.to_bits() + 1)
1797 } else {
1798 f64::from_bits(value.to_bits() - 1)
1799 }
1800 }
1801
1802 #[inline]
1803 fn next_down(value: f64) -> f64 {
1804 -Self::next_up(-value)
1805 }
1806
1807 fn exact_sign_after_adding_scalar(self, scalar: f64) -> Result<f64, &'static str> {
1815 self.exact_sign_after_adding_scalars(scalar, 0.0)
1816 }
1817
1818 fn exact_sign_after_adding_scalars(
1826 self,
1827 first: f64,
1828 second: f64,
1829 ) -> Result<f64, &'static str> {
1830 let ordering = exact_binary64_sum_sign(
1831 self.components[..self.len]
1832 .iter()
1833 .copied()
1834 .chain([first, second]),
1835 )
1836 .map_err(|_| "the shared exact-sign accumulator rejected its structural finite input")?;
1837 Ok(match ordering {
1838 std::cmp::Ordering::Less => -1.0,
1839 std::cmp::Ordering::Equal => 0.0,
1840 std::cmp::Ordering::Greater => 1.0,
1841 })
1842 }
1843
1844 #[inline]
1847 fn rounding_cell_radius(value: f64) -> f64 {
1848 let up = Self::next_up(value) - value;
1849 let down = value - Self::next_down(value);
1850 0.5 * up.min(down)
1851 }
1852
1853 fn certify_exact_sum_against_mass(candidate: f64, mass: f64) -> Result<f64, &'static str> {
1859 if mass == 0.0 {
1860 return Ok(candidate);
1861 }
1862 if mass < Self::rounding_cell_radius(candidate) {
1863 return Ok(candidate);
1864 }
1865 Err(LATENT_UNREPRESENTABLE_MASS_REACHES_CELL)
1866 }
1867
1868 fn certified_round(self) -> Result<f64, &'static str> {
1886 let mass = self.unrepresentable_mass;
1887 if !(mass.is_finite() && mass >= 0.0) {
1888 return Err("an unrepresentable-mass bound left the finite nonnegative range");
1889 }
1890 if self.len == 0 {
1891 return Self::certify_exact_sum_against_mass(0.0, mass);
1892 }
1893 let mut candidate = 0.0_f64;
1894 for index in 0..self.len {
1895 candidate += self.component(index);
1896 }
1897 if !candidate.is_finite() {
1898 return Err("the exact cumulant is outside the finite binary64 range");
1899 }
1900
1901 let mut walked = 0usize;
1916 loop {
1917 let residual = self.add(Self::scalar(-candidate))?;
1918 if residual.len == 0 {
1919 return Self::certify_exact_sum_against_mass(candidate, mass);
1920 }
1921 let residual_sign = residual.exact_sign_after_adding_scalar(0.0)?;
1922 if residual_sign == 0.0 {
1923 return Self::certify_exact_sum_against_mass(candidate, mass);
1927 }
1928 let adjacent = if residual_sign > 0.0 {
1929 Self::next_up(candidate)
1930 } else {
1931 Self::next_down(candidate)
1932 };
1933 if !adjacent.is_finite() {
1934 return Err("the exact cumulant is outside the finite binary64 range");
1935 }
1936 let midpoint_distance = 0.5 * (adjacent - candidate).abs();
1937 if midpoint_distance == 0.0 {
1938 return Err("the exact cumulant reached an unrepresentable subnormal midpoint");
1939 }
1940 let boundary_offset = -residual_sign * midpoint_distance;
1941 let cell_holds_the_mass = move |residual: Self| -> Result<f64, &'static str> {
1946 let widened = residual
1947 .exact_sign_after_adding_scalars(boundary_offset, residual_sign * mass)?;
1948 if widened == -residual_sign {
1949 Ok(candidate)
1950 } else {
1951 Err(LATENT_UNREPRESENTABLE_MASS_REACHES_CELL)
1952 }
1953 };
1954 match residual
1955 .exact_sign_after_adding_scalar(boundary_offset)?
1956 .partial_cmp(&0.0)
1957 {
1958 Some(std::cmp::Ordering::Less) if residual_sign > 0.0 => {
1959 return cell_holds_the_mass(residual);
1960 }
1961 Some(std::cmp::Ordering::Greater) if residual_sign < 0.0 => {
1962 return cell_holds_the_mass(residual);
1963 }
1964 Some(std::cmp::Ordering::Equal) if mass != 0.0 => {
1965 return Err(LATENT_UNREPRESENTABLE_MASS_REACHES_CELL);
1969 }
1970 Some(std::cmp::Ordering::Equal) => {
1971 return Ok(if candidate.to_bits() % 2 == 0 {
1994 candidate
1995 } else {
1996 adjacent
1997 });
1998 }
1999 _ => {
2000 if walked == LATENT_EXACT_EXPANSION_CAPACITY {
2007 return Err(
2008 "the exact cumulant stayed outside its rounding cell after a full \
2009 structural walk",
2010 );
2011 }
2012 walked += 1;
2013 candidate = adjacent;
2014 }
2015 }
2016 }
2017 }
2018}
2019
2020#[derive(Clone, Copy)]
2021struct LatentSignedLogOrder2<const K: usize> {
2022 v: LatentSignedLog,
2023 g: [LatentSignedLog; K],
2024 h: [[LatentSignedLog; K]; K],
2025}
2026
2027impl<const K: usize> LatentSignedLogOrder2<K> {
2028 const fn zero() -> Self {
2029 Self {
2030 v: LatentSignedLog::ZERO,
2031 g: [LatentSignedLog::ZERO; K],
2032 h: [[LatentSignedLog::ZERO; K]; K],
2033 }
2034 }
2035}
2036
2037#[derive(Clone, Copy, Debug)]
2038struct LatentKernelPrimaryDirection {
2039 dq: f64,
2040 dqd: f64,
2041 dmu: f64,
2042 dtau: f64,
2043}
2044
2045#[derive(Clone, Copy, Debug)]
2046struct LatentSurvivalPrimaryDirection {
2047 dq_entry: f64,
2048 dq_exit: f64,
2049 dqdot_exit: f64,
2050 dq_right: f64,
2051 dmu: f64,
2052 dlog_sigma: f64,
2053}
2054
2055#[derive(Clone, Copy, Debug)]
2056struct LatentKernelPrimaryState {
2057 q: f64,
2058 qdot: f64,
2059 mu: f64,
2060 sigma: f64,
2061 log_sigma_factor: f64,
2062}
2063
2064#[derive(Clone, Copy, Debug)]
2074pub struct LatentSurvivalPrimaryPoint {
2075 pub q_entry: f64,
2076 pub q_exit: f64,
2077 pub qdot_exit: f64,
2078 pub q_right: f64,
2079 pub mu: f64,
2080 pub sigma: f64,
2081}
2082
2083impl LatentSurvivalPrimaryPoint {
2084 #[inline]
2089 fn log_sigma_factor(self) -> f64 {
2090 if self.sigma == 0.0 {
2091 0.0
2092 } else {
2093 self.sigma.ln()
2094 }
2095 }
2096}
2097
2098#[cfg(test)]
2099mod tests_kernel_recurrence {
2100 use super::*;
2101 use std::collections::BTreeMap;
2102
2103 fn latent_kernel_accumulate_term(
2104 terms: &mut BTreeMap<(usize, usize, usize, usize), f64>,
2105 term: LatentKernelPrimaryTerm,
2106 scale: f64,
2107 ) {
2108 if scale == 0.0 || term.coeff == 0.0 {
2109 return;
2110 }
2111 *terms
2112 .entry((term.q_exp, term.qdot_power, term.tau_exp, term.k))
2113 .or_insert(0.0) += scale * term.coeff;
2114 }
2115
2116 pub(super) fn latent_kernel_differentiate_terms(
2117 terms: &[LatentKernelPrimaryTerm],
2118 dir: LatentKernelPrimaryDirection,
2119 ) -> Vec<LatentKernelPrimaryTerm> {
2120 let mut out = BTreeMap::<(usize, usize, usize, usize), f64>::new();
2121 for term in terms {
2122 if dir.dq != 0.0 {
2123 if term.q_exp > 0 {
2124 latent_kernel_accumulate_term(&mut out, *term, dir.dq * term.q_exp as f64);
2125 }
2126 latent_kernel_accumulate_term(
2127 &mut out,
2128 LatentKernelPrimaryTerm {
2129 q_exp: term.q_exp + 1,
2130 k: term.k + 1,
2131 ..*term
2132 },
2133 -dir.dq,
2134 );
2135 }
2136 if dir.dmu != 0.0 {
2137 if term.k > 0 {
2138 latent_kernel_accumulate_term(&mut out, *term, dir.dmu * term.k as f64);
2139 }
2140 latent_kernel_accumulate_term(
2141 &mut out,
2142 LatentKernelPrimaryTerm {
2143 q_exp: term.q_exp + 1,
2144 k: term.k + 1,
2145 ..*term
2146 },
2147 -dir.dmu,
2148 );
2149 }
2150 if dir.dtau != 0.0 {
2151 if term.tau_exp > 0 {
2152 latent_kernel_accumulate_term(&mut out, *term, dir.dtau * term.tau_exp as f64);
2153 }
2154 let kf = term.k as f64;
2155 latent_kernel_accumulate_term(
2156 &mut out,
2157 LatentKernelPrimaryTerm {
2158 tau_exp: term.tau_exp + 2,
2159 ..*term
2160 },
2161 dir.dtau * kf * kf,
2162 );
2163 latent_kernel_accumulate_term(
2164 &mut out,
2165 LatentKernelPrimaryTerm {
2166 q_exp: term.q_exp + 1,
2167 tau_exp: term.tau_exp + 2,
2168 k: term.k + 1,
2169 ..*term
2170 },
2171 -dir.dtau * (2.0 * kf + 1.0),
2172 );
2173 latent_kernel_accumulate_term(
2174 &mut out,
2175 LatentKernelPrimaryTerm {
2176 q_exp: term.q_exp + 2,
2177 tau_exp: term.tau_exp + 2,
2178 k: term.k + 2,
2179 ..*term
2180 },
2181 dir.dtau,
2182 );
2183 }
2184 if dir.dqd != 0.0 && term.qdot_power > 0 {
2185 latent_kernel_accumulate_term(
2186 &mut out,
2187 LatentKernelPrimaryTerm {
2188 qdot_power: term.qdot_power - 1,
2189 ..*term
2190 },
2191 dir.dqd * term.qdot_power as f64,
2192 );
2193 }
2194 }
2195 out.into_iter()
2196 .filter_map(|((q_exp, qdot_power, tau_exp, k), coeff)| {
2197 (coeff != 0.0).then_some(LatentKernelPrimaryTerm {
2198 coeff,
2199 q_exp,
2200 qdot_power,
2201 tau_exp,
2202 k,
2203 })
2204 })
2205 .collect()
2206 }
2207}
2208
2209const LATENT_TERM_INLINE_CAPACITY: usize = 64;
2214type LatentTermBuffer = SmallVec<[LatentKernelPrimaryTerm; LATENT_TERM_INLINE_CAPACITY]>;
2215
2216const LATENT_A_BASIS_MAX_RUNG: usize = 12;
2224const LATENT_A_BASIS_MAX_TAU_EXP: usize = 12;
2225const LATENT_A_BASIS_MAX_QDOT_POWER: usize = 4;
2226
2227const fn latent_falling_factorial_table()
2235-> [[i64; LATENT_A_BASIS_MAX_RUNG + 1]; LATENT_A_BASIS_MAX_RUNG + 1] {
2236 let mut table = [[0_i64; LATENT_A_BASIS_MAX_RUNG + 1]; LATENT_A_BASIS_MAX_RUNG + 1];
2237 table[0][0] = 1;
2238 let mut rung = 0usize;
2239 while rung < LATENT_A_BASIS_MAX_RUNG {
2240 let mut power = 0usize;
2241 while power <= rung {
2242 let value = table[rung][power];
2243 if value != 0 {
2244 table[rung + 1][power + 1] += value;
2245 table[rung + 1][power] -= (rung as i64) * value;
2246 }
2247 power += 1;
2248 }
2249 rung += 1;
2250 }
2251 table
2252}
2253
2254const LATENT_FALLING_FACTORIAL: [[i64; LATENT_A_BASIS_MAX_RUNG + 1];
2255 LATENT_A_BASIS_MAX_RUNG + 1] = latent_falling_factorial_table();
2256
2257fn latent_kernel_evaluate_terms(
2263 bundle: &LogLognormalKernelBundle,
2264 state: LatentKernelPrimaryState,
2265 terms: &[LatentKernelPrimaryTerm],
2266 context: &str,
2267) -> Result<(f64, f64), LatentSurvivalError> {
2268 let needs_qdot = terms
2269 .iter()
2270 .any(|term| term.coeff != 0.0 && term.qdot_power > 0);
2271 if needs_qdot && !(state.qdot.is_finite() && state.qdot > 0.0) {
2272 return Err(LatentSurvivalError::NumericalFailure {
2273 reason: format!(
2274 "{context} requires positive finite qdot for exact-event directional terms, got {}",
2275 state.qdot
2276 ),
2277 });
2278 }
2279 let log_qdot = if needs_qdot { state.qdot.ln() } else { 0.0 };
2280 if let Some(sum) = latent_kernel_evaluate_terms_in_a_basis(bundle, state, terms, log_qdot) {
2281 return Ok(sum);
2282 }
2283 let mut log_mags = SmallVec::<[f64; LATENT_TERM_INLINE_CAPACITY]>::new();
2284 let mut signs = SmallVec::<[f64; LATENT_TERM_INLINE_CAPACITY]>::new();
2285 for term in terms {
2286 if term.coeff == 0.0 {
2287 continue;
2288 }
2289 log_mags.push(
2290 term.coeff.abs().ln()
2291 + term.q_exp as f64 * state.q
2292 + term.tau_exp as f64 * state.log_sigma_factor
2293 + term.qdot_power as f64 * log_qdot
2294 + bundle.get(term.k),
2295 );
2296 signs.push(term.coeff.signum());
2297 }
2298 if log_mags.is_empty() {
2299 return Ok((f64::NEG_INFINITY, 0.0));
2300 }
2301 Ok(signed_log_sum_exp(&log_mags, &signs))
2302}
2303
2304fn latent_kernel_evaluate_terms_in_a_basis(
2322 bundle: &LogLognormalKernelBundle,
2323 state: LatentKernelPrimaryState,
2324 terms: &[LatentKernelPrimaryTerm],
2325 log_qdot: f64,
2326) -> Option<(f64, f64)> {
2327 let tower = bundle.log_scaled_a_derivatives.as_ref()?;
2328 let mut max_rung = 0usize;
2329 let mut max_tau_exp = 0usize;
2330 let mut max_qdot_power = 0usize;
2331 for term in terms {
2332 if term.coeff == 0.0 {
2333 continue;
2334 }
2335 if term.q_exp != term.k
2336 || term.k >= tower.len()
2337 || term.k > LATENT_A_BASIS_MAX_RUNG
2338 || term.tau_exp > LATENT_A_BASIS_MAX_TAU_EXP
2339 || term.qdot_power > LATENT_A_BASIS_MAX_QDOT_POWER
2340 {
2341 return None;
2342 }
2343 max_rung = max_rung.max(term.k);
2344 max_tau_exp = max_tau_exp.max(term.tau_exp);
2345 max_qdot_power = max_qdot_power.max(term.qdot_power);
2346 }
2347 let rung_stride = max_rung + 1;
2352 let tau_stride = max_tau_exp + 1;
2353 let mut coefficients =
2354 SmallVec::<[f64; 256]>::from_elem(0.0, (max_qdot_power + 1) * tau_stride * rung_stride);
2355 for term in terms {
2356 if term.coeff == 0.0 {
2357 continue;
2358 }
2359 let rung_parity = if term.k % 2 == 0 { 1.0 } else { -1.0 };
2360 let cell = (term.qdot_power * tau_stride + term.tau_exp) * rung_stride;
2361 for power in 0..=term.k {
2362 let stirling = LATENT_FALLING_FACTORIAL[term.k][power];
2363 if stirling != 0 {
2364 coefficients[cell + power] += rung_parity * term.coeff * stirling as f64;
2365 }
2366 }
2367 }
2368 let mut log_mags = SmallVec::<[f64; LATENT_TERM_INLINE_CAPACITY]>::new();
2369 let mut signs = SmallVec::<[f64; LATENT_TERM_INLINE_CAPACITY]>::new();
2370 for qdot_power in 0..=max_qdot_power {
2371 for tau_exp in 0..=max_tau_exp {
2372 for power in 0..=max_rung {
2373 let coefficient =
2374 coefficients[(qdot_power * tau_stride + tau_exp) * rung_stride + power];
2375 let entry = tower[power];
2376 if coefficient == 0.0 || entry.sign == 0.0 {
2377 continue;
2378 }
2379 log_mags.push(
2382 coefficient.abs().ln()
2383 + (tau_exp as f64 - power as f64) * state.log_sigma_factor
2384 + qdot_power as f64 * log_qdot
2385 + entry.log_abs,
2386 );
2387 signs.push(coefficient.signum() * entry.sign);
2388 }
2389 }
2390 }
2391 if log_mags.is_empty() {
2392 return Some((f64::NEG_INFINITY, 0.0));
2393 }
2394 Some(signed_log_sum_exp(&log_mags, &signs))
2395}
2396
2397#[inline]
2398fn latent_kernel_accumulate_term_inline(
2399 terms: &mut LatentTermBuffer,
2400 term: LatentKernelPrimaryTerm,
2401 scale: f64,
2402) {
2403 if scale == 0.0 || term.coeff == 0.0 {
2404 return;
2405 }
2406 let contribution = scale * term.coeff;
2407 if let Some(existing) = terms.iter_mut().find(|existing| {
2408 existing.q_exp == term.q_exp
2409 && existing.qdot_power == term.qdot_power
2410 && existing.tau_exp == term.tau_exp
2411 && existing.k == term.k
2412 }) {
2413 existing.coeff += contribution;
2414 } else {
2415 terms.push(LatentKernelPrimaryTerm {
2416 coeff: contribution,
2417 ..term
2418 });
2419 }
2420}
2421
2422fn latent_kernel_differentiate_terms_inline(
2423 terms: &[LatentKernelPrimaryTerm],
2424 dir: LatentKernelPrimaryDirection,
2425) -> LatentTermBuffer {
2426 let mut out = LatentTermBuffer::new();
2427 for term in terms {
2428 if dir.dq != 0.0 {
2429 if term.q_exp > 0 {
2430 latent_kernel_accumulate_term_inline(&mut out, *term, dir.dq * term.q_exp as f64);
2431 }
2432 latent_kernel_accumulate_term_inline(
2433 &mut out,
2434 LatentKernelPrimaryTerm {
2435 q_exp: term.q_exp + 1,
2436 k: term.k + 1,
2437 ..*term
2438 },
2439 -dir.dq,
2440 );
2441 }
2442 if dir.dmu != 0.0 {
2443 if term.k > 0 {
2444 latent_kernel_accumulate_term_inline(&mut out, *term, dir.dmu * term.k as f64);
2445 }
2446 latent_kernel_accumulate_term_inline(
2447 &mut out,
2448 LatentKernelPrimaryTerm {
2449 q_exp: term.q_exp + 1,
2450 k: term.k + 1,
2451 ..*term
2452 },
2453 -dir.dmu,
2454 );
2455 }
2456 if dir.dtau != 0.0 {
2457 if term.tau_exp > 0 {
2458 latent_kernel_accumulate_term_inline(
2459 &mut out,
2460 *term,
2461 dir.dtau * term.tau_exp as f64,
2462 );
2463 }
2464 let kf = term.k as f64;
2465 latent_kernel_accumulate_term_inline(
2466 &mut out,
2467 LatentKernelPrimaryTerm {
2468 tau_exp: term.tau_exp + 2,
2469 ..*term
2470 },
2471 dir.dtau * kf * kf,
2472 );
2473 latent_kernel_accumulate_term_inline(
2474 &mut out,
2475 LatentKernelPrimaryTerm {
2476 q_exp: term.q_exp + 1,
2477 tau_exp: term.tau_exp + 2,
2478 k: term.k + 1,
2479 ..*term
2480 },
2481 -dir.dtau * (2.0 * kf + 1.0),
2482 );
2483 latent_kernel_accumulate_term_inline(
2484 &mut out,
2485 LatentKernelPrimaryTerm {
2486 q_exp: term.q_exp + 2,
2487 tau_exp: term.tau_exp + 2,
2488 k: term.k + 2,
2489 ..*term
2490 },
2491 dir.dtau,
2492 );
2493 }
2494 if dir.dqd != 0.0 && term.qdot_power > 0 {
2495 latent_kernel_accumulate_term_inline(
2496 &mut out,
2497 LatentKernelPrimaryTerm {
2498 qdot_power: term.qdot_power - 1,
2499 ..*term
2500 },
2501 dir.dqd * term.qdot_power as f64,
2502 );
2503 }
2504 }
2505 out.retain(|term| term.coeff != 0.0);
2506 out.sort_unstable_by_key(|term| (term.q_exp, term.qdot_power, term.tau_exp, term.k));
2507 out
2508}
2509
2510fn latent_kernel_term_sequence_inline(
2511 base_terms: &[LatentKernelPrimaryTerm],
2512 axes: &[LatentKernelPrimaryDirection],
2513 suffix: &[LatentKernelPrimaryDirection],
2514) -> LatentTermBuffer {
2515 let mut terms = LatentTermBuffer::from_slice(base_terms);
2516 terms.retain(|term| term.coeff != 0.0);
2517 for direction in axes.iter().chain(suffix.iter()).rev() {
2524 terms = latent_kernel_differentiate_terms_inline(&terms, *direction);
2525 }
2526 terms
2527}
2528
2529#[cfg(test)]
2530mod tests_multidir_kernel {
2531 #[inline]
2539 fn latent_unary_derivatives_log_at_one() -> [f64; 5] {
2540 [0.0, 1.0, -1.0, 2.0, -6.0]
2541 }
2542
2543 use super::tests_kernel_recurrence::latent_kernel_differentiate_terms;
2544 use super::*;
2545 use gam_math::jet_partitions::MultiDirJet as LatentMultiDirJet;
2546
2547 fn latent_kernel_term_lists_for_directions(
2548 base_terms: &[LatentKernelPrimaryTerm],
2549 directions: &[LatentKernelPrimaryDirection],
2550 ) -> Vec<Vec<LatentKernelPrimaryTerm>> {
2551 fn build_mask(
2552 mask: usize,
2553 base_terms: &[LatentKernelPrimaryTerm],
2554 directions: &[LatentKernelPrimaryDirection],
2555 cache: &mut [Option<Vec<LatentKernelPrimaryTerm>>],
2556 ) -> Vec<LatentKernelPrimaryTerm> {
2557 if let Some(existing) = &cache[mask] {
2558 return existing.clone();
2559 }
2560 let built = if mask == 0 {
2561 base_terms.to_vec()
2562 } else {
2563 let bit = 1usize << mask.trailing_zeros();
2564 let prev = build_mask(mask ^ bit, base_terms, directions, cache);
2565 latent_kernel_differentiate_terms(&prev, directions[bit.trailing_zeros() as usize])
2566 };
2567 cache[mask] = Some(built.clone());
2568 built
2569 }
2570
2571 let mut cache = vec![None; 1usize << directions.len()];
2572 (0..cache.len())
2573 .map(|mask| build_mask(mask, base_terms, directions, &mut cache))
2574 .collect()
2575 }
2576
2577 pub(super) fn latent_kernel_sum_log_jet(
2578 quadctx: &QuadratureContext,
2579 base_terms: &[LatentKernelPrimaryTerm],
2580 state: LatentKernelPrimaryState,
2581 directions: &[LatentKernelPrimaryDirection],
2582 context: &str,
2583 ) -> Result<LatentMultiDirJet, LatentSurvivalError> {
2584 let term_lists = latent_kernel_term_lists_for_directions(base_terms, directions);
2585 let max_k = term_lists
2586 .iter()
2587 .flat_map(|terms| terms.iter().map(|term| term.k))
2588 .max()
2589 .unwrap_or(0);
2590 let bundle = log_kernel_bundle(quadctx, state.q.exp(), state.mu, state.sigma, max_k)
2591 .map_err(|e| LatentSurvivalError::NumericalFailure {
2592 reason: format!("{context} kernel evaluation failed: {e}"),
2593 })?;
2594
2595 let evaluate_terms = |terms: &[LatentKernelPrimaryTerm]| {
2596 latent_kernel_evaluate_terms(&bundle, state, terms, context)
2597 };
2598
2599 let (base_log_sum, base_sign) = evaluate_terms(&term_lists[0])?;
2600 if !(base_log_sum.is_finite() && base_sign > 0.0) {
2601 return Err(LatentSurvivalError::NumericalFailure {
2602 reason: format!("{context} produced a non-positive signed kernel sum"),
2603 });
2604 }
2605
2606 let mut normalized = LatentMultiDirJet::constant(directions.len(), 1.0);
2607 let mut signed_moments = [LatentSignedLog::ZERO; 16];
2608 signed_moments[0] = LatentSignedLog::ONE;
2609 for mask in 1..term_lists.len() {
2610 let (log_abs, sign) = evaluate_terms(&term_lists[mask])?;
2611 let moment =
2612 latent_signed_log_normalized(log_abs, sign, base_log_sum, context)?;
2613 signed_moments[mask] = moment;
2614 normalized.coeffs[mask] = latent_signed_log_materialize(moment, context)?;
2615 }
2616
2617 let mut out = normalized.compose_unary(latent_unary_derivatives_log_at_one());
2618 out.coeffs[0] += base_log_sum;
2619 if term_lists.len() == 1 {
2620 return Ok(out);
2621 }
2622
2623 const MULTIDIR_DOT2_TERMS_K4: usize = 34;
2631 const MULTIDIR_DOT2_OPERATIONS: usize = 10;
2632 const MULTIDIR_POWER_OUTPUTS_K4: usize = 17;
2633 const MULTIDIR_POWER_OUTPUT_OPERATIONS: usize = 5;
2634 const MULTIDIR_COMBINE_OPERATIONS: usize = 32;
2635 const MULTIDIR_MAX_OPERATIONS: usize =
2636 MULTIDIR_DOT2_TERMS_K4 * MULTIDIR_DOT2_OPERATIONS
2637 + MULTIDIR_POWER_OUTPUTS_K4 * MULTIDIR_POWER_OUTPUT_OPERATIONS
2638 + MULTIDIR_COMBINE_OPERATIONS;
2639 const _: () = assert!(MULTIDIR_MAX_OPERATIONS == 457);
2640 let operation_roundoff = MULTIDIR_MAX_OPERATIONS as f64 * f64::EPSILON;
2641 let gamma = LatentExactExpansion::next_up(
2642 operation_roundoff / (1.0 - operation_roundoff),
2643 );
2644 let exact = latent_certified_cumulants(
2645 signed_moments,
2646 term_lists.len() - 1,
2647 context,
2648 )?;
2649 for mask in 1..term_lists.len() {
2650 let relative_roundoff =
2651 LatentExactExpansion::next_up(gamma * exact[mask].absolute_term_mass);
2652 let gradual_underflow =
2653 MULTIDIR_MAX_OPERATIONS as f64 * f64::from_bits(1);
2654 let arithmetic_bound =
2655 LatentExactExpansion::next_up(relative_roundoff + gradual_underflow);
2656 let error =
2657 LatentExactExpansion::next_up((out.coeffs[mask] - exact[mask].value).abs());
2658 assert!(
2659 error <= arithmetic_bound,
2660 "{context}: MultiDir mask {mask:#06b} escaped its derived forward-error \
2661 certificate: got={:.17e}, exact-rounded={:.17e}, error={error:.17e}, \
2662 bound={arithmetic_bound:.17e}, absolute-term-mass={:.17e}, operations={MULTIDIR_MAX_OPERATIONS}",
2663 out.coeffs[mask],
2664 exact[mask].value,
2665 exact[mask].absolute_term_mass,
2666 );
2667 }
2668 Ok(out)
2669 }
2670}
2671
2672fn latent_signed_log_checked(
2673 log_abs: f64,
2674 sign: f64,
2675 context: &str,
2676 quantity: &str,
2677) -> Result<LatentSignedLog, LatentSurvivalError> {
2678 if log_abs == f64::NEG_INFINITY && sign == 0.0 {
2679 return Ok(LatentSignedLog::ZERO);
2680 }
2681 if log_abs.is_finite() && (sign == -1.0 || sign == 1.0) {
2682 return Ok(LatentSignedLog { log_abs, sign });
2683 }
2684 Err(LatentSurvivalError::NumericalFailure {
2685 reason: format!(
2686 "{context} produced an invalid signed-log {quantity}: log_abs={log_abs}, sign={sign}"
2687 ),
2688 })
2689}
2690
2691fn latent_signed_log_normalized(
2692 log_abs: f64,
2693 sign: f64,
2694 base_log_sum: f64,
2695 context: &str,
2696) -> Result<LatentSignedLog, LatentSurvivalError> {
2697 let value = latent_signed_log_checked(log_abs, sign, context, "kernel derivative")?;
2698 if value.sign == 0.0 {
2699 Ok(value)
2700 } else {
2701 latent_signed_log_checked(
2702 value.log_abs - base_log_sum,
2703 value.sign,
2704 context,
2705 "normalised kernel derivative",
2706 )
2707 }
2708}
2709
2710fn latent_signed_log_materialize(
2711 value: LatentSignedLog,
2712 context: &str,
2713) -> Result<f64, LatentSurvivalError> {
2714 let value =
2715 latent_signed_log_checked(value.log_abs, value.sign, context, "log-sum derivative")?;
2716 if value.sign == 0.0 {
2717 return Ok(0.0);
2718 }
2719 let materialized = value.sign * value.log_abs.exp();
2720 if materialized.is_finite() {
2721 Ok(materialized)
2722 } else {
2723 Err(LatentSurvivalError::NumericalFailure {
2724 reason: format!(
2725 "{context} log-sum derivative is outside the finite f64 range: \
2726 log_abs={}, sign={}",
2727 value.log_abs, value.sign
2728 ),
2729 })
2730 }
2731}
2732
2733fn latent_certified_cumulants(
2755 moments: [LatentSignedLog; 16],
2756 target_mask: usize,
2757 context: &str,
2758) -> Result<[LatentCertifiedCumulant; 16], LatentSurvivalError> {
2759 assert!(target_mask > 0 && target_mask < 16);
2760
2761 let unresolved = |mask: usize, reason: &str| {
2762 LatentSurvivalError::DerivativeAccuracyUnresolved {
2763 reason: format!(
2764 "{context} derivative mask {mask:#06b} has no certified binary64 value: {reason}"
2765 ),
2766 }
2767 };
2768 let mut rounded_moments = [0.0_f64; 16];
2769 for mask in 0usize..16 {
2770 if mask & !target_mask != 0 {
2771 continue;
2772 }
2773 let moment = latent_signed_log_checked(
2774 moments[mask].log_abs,
2775 moments[mask].sign,
2776 context,
2777 "normalised cumulant moment",
2778 )?;
2779 if moment.sign == 0.0 {
2780 continue;
2781 }
2782 let magnitude = moment.log_abs.exp();
2783 if !magnitude.is_finite() {
2784 return Err(unresolved(
2785 mask,
2786 "the rounded moment magnitude overflowed",
2787 ));
2788 }
2789 if magnitude == 0.0 {
2790 return Err(unresolved(
2791 mask,
2792 "the rounded moment magnitude underflowed to zero",
2793 ));
2794 }
2795 rounded_moments[mask] = moment.sign * magnitude;
2796 }
2797
2798 let moment_span = || -> String {
2803 let mut lowest = f64::INFINITY;
2804 let mut highest = 0.0_f64;
2805 for mask in 0usize..16 {
2806 let magnitude = rounded_moments[mask].abs();
2807 if magnitude > 0.0 {
2808 lowest = lowest.min(magnitude);
2809 highest = highest.max(magnitude);
2810 }
2811 }
2812 if highest == 0.0 {
2813 return "no nonzero rounded moment".to_string();
2814 }
2815 format!("rounded moment magnitudes span [{lowest:.6e}, {highest:.6e}]")
2816 };
2817 let unresolved_with_span = |mask: usize, reason: &str| {
2818 unresolved(mask, &format!("{reason} ({})", moment_span()))
2819 };
2820
2821 let mut exact_cumulants = [LatentExactExpansion::ZERO; 16];
2822 let mut certificates = [LatentCertifiedCumulant::ZERO; 16];
2823 for mask in 1usize..16 {
2824 if mask & !target_mask != 0 {
2825 continue;
2826 }
2827 if mask.is_power_of_two() {
2828 exact_cumulants[mask] = LatentExactExpansion::scalar(rounded_moments[mask]);
2829 certificates[mask] = LatentCertifiedCumulant {
2830 value: rounded_moments[mask],
2831 absolute_term_mass: rounded_moments[mask].abs(),
2832 };
2833 continue;
2834 }
2835 let pivot = 1usize << mask.trailing_zeros();
2836 let mut cumulant = LatentExactExpansion::scalar(rounded_moments[mask]);
2837 let mut absolute_term_mass = rounded_moments[mask].abs();
2838 let mut block = (mask - 1) & mask;
2839 while block != 0 {
2840 if block & pivot != 0 {
2841 let complement = mask ^ block;
2842 let subtract = exact_cumulants[block]
2843 .scale(-rounded_moments[complement])
2844 .and_then(|term| cumulant.add(term))
2845 .map_err(|reason| unresolved_with_span(mask, reason))?;
2846 cumulant = subtract;
2847 let term_mass = LatentExactExpansion::next_up(
2848 certificates[block].absolute_term_mass
2849 * rounded_moments[complement].abs(),
2850 );
2851 if !term_mass.is_finite() {
2852 return Err(unresolved(
2853 mask,
2854 "the conditioning mass overflowed binary64",
2855 ));
2856 }
2857 absolute_term_mass =
2858 LatentExactExpansion::next_up(absolute_term_mass + term_mass);
2859 if !absolute_term_mass.is_finite() {
2860 return Err(unresolved(
2861 mask,
2862 "the accumulated conditioning mass overflowed binary64",
2863 ));
2864 }
2865 }
2866 block = (block - 1) & mask;
2867 }
2868 let value = cumulant
2869 .certified_round()
2870 .map_err(|reason| unresolved_with_span(mask, reason))?;
2871 exact_cumulants[mask] = cumulant;
2872 certificates[mask] = LatentCertifiedCumulant {
2873 value,
2874 absolute_term_mass,
2875 };
2876 }
2877 Ok(certificates)
2878}
2879
2880fn latent_kernel_sum_max_k<const K: usize>(
2905 base_terms: &[LatentKernelPrimaryTerm],
2906 primary_directions: &[LatentKernelPrimaryDirection; K],
2907 suffixes: &[&[LatentKernelPrimaryDirection]],
2908) -> usize {
2909 let base_max_k = base_terms.iter().map(|term| term.k).max().unwrap_or(0);
2910 let k_increment = |direction: &LatentKernelPrimaryDirection| {
2911 if direction.dtau != 0.0 {
2912 2
2913 } else if direction.dq != 0.0 || direction.dmu != 0.0 {
2914 1
2915 } else {
2916 0
2917 }
2918 };
2919 let max_primary_increment = primary_directions
2920 .iter()
2921 .map(&k_increment)
2922 .max()
2923 .unwrap_or(0);
2924 let max_suffix_increment = suffixes
2925 .iter()
2926 .map(|suffix| suffix.iter().map(&k_increment).sum::<usize>())
2927 .max()
2928 .unwrap_or(0);
2929 base_max_k + 2 * max_primary_increment + max_suffix_increment
2930}
2931
2932fn latent_kernel_sum_base<const K: usize>(
2941 quadctx: &QuadratureContext,
2942 base_terms: &[LatentKernelPrimaryTerm],
2943 state: LatentKernelPrimaryState,
2944 primary_directions: &[LatentKernelPrimaryDirection; K],
2945 suffixes: &[&[LatentKernelPrimaryDirection]],
2946 context: &str,
2947) -> Result<(LogLognormalKernelBundle, f64), LatentSurvivalError> {
2948 let max_k = latent_kernel_sum_max_k(base_terms, primary_directions, suffixes);
2949 let bundle =
2950 log_kernel_bundle(quadctx, state.q.exp(), state.mu, state.sigma, max_k).map_err(|e| {
2951 LatentSurvivalError::NumericalFailure {
2952 reason: format!("{context} kernel evaluation failed: {e}"),
2953 }
2954 })?;
2955 let (base_log_sum, base_sign) =
2956 latent_kernel_evaluate_terms(&bundle, state, base_terms, context)?;
2957 if !(base_log_sum.is_finite() && base_sign > 0.0) {
2958 return Err(LatentSurvivalError::NumericalFailure {
2959 reason: format!("{context} produced a non-positive signed kernel sum"),
2960 });
2961 }
2962 Ok((bundle, base_log_sum))
2963}
2964
2965fn latent_kernel_sum_order2_parts<const K: usize>(
2966 quadctx: &QuadratureContext,
2967 base_terms: &[LatentKernelPrimaryTerm],
2968 state: LatentKernelPrimaryState,
2969 primary_directions: &[LatentKernelPrimaryDirection; K],
2970 suffixes: &[&[LatentKernelPrimaryDirection]],
2971 context: &str,
2972) -> Result<[Order2<K>; 4], LatentSurvivalError> {
2973 assert!(
2974 !suffixes.is_empty() && suffixes.len() <= 4,
2975 "latent kernel lift supports one to four order-two parts"
2976 );
2977 let (bundle, base_log_sum) = latent_kernel_sum_base(
2978 quadctx,
2979 base_terms,
2980 state,
2981 primary_directions,
2982 suffixes,
2983 context,
2984 )?;
2985
2986 let evaluate_terms = |terms: &[LatentKernelPrimaryTerm]| {
2987 latent_kernel_evaluate_terms(&bundle, state, terms, context)
2988 };
2989
2990 let normalized = |axes: &[LatentKernelPrimaryDirection],
2991 suffix: &[LatentKernelPrimaryDirection]|
2992 -> Result<LatentSignedLog, LatentSurvivalError> {
2993 let is_zero = |direction: &LatentKernelPrimaryDirection| {
2994 direction.dq == 0.0
2995 && direction.dqd == 0.0
2996 && direction.dmu == 0.0
2997 && direction.dtau == 0.0
2998 };
2999 if axes.iter().chain(suffix.iter()).any(is_zero) {
3000 return Ok(LatentSignedLog::ZERO);
3001 }
3002 let terms = latent_kernel_term_sequence_inline(base_terms, axes, suffix);
3003 assert!(
3004 !terms.spilled(),
3005 "latent derivative support exceeded the inline allocation-free capacity: {} > {}",
3006 terms.len(),
3007 LATENT_TERM_INLINE_CAPACITY
3008 );
3009 let (log_abs, sign) = evaluate_terms(&terms)?;
3010 latent_signed_log_normalized(log_abs, sign, base_log_sum, context)
3011 };
3012
3013 let mut parts = [LatentSignedLogOrder2::<K>::zero(); 4];
3014 for (part, suffix) in suffixes.iter().enumerate() {
3015 let value = if part == 0 {
3016 LatentSignedLog::ONE
3018 } else {
3019 normalized(&[], suffix)?
3020 };
3021 let mut tower = LatentSignedLogOrder2::<K>::zero();
3022 tower.v = value;
3023 for a in 0..K {
3024 tower.g[a] = normalized(&[primary_directions[a]], suffix)?;
3025 }
3026 for a in 0..K {
3027 for b in a..K {
3028 let derivative =
3029 normalized(&[primary_directions[a], primary_directions[b]], suffix)?;
3030 tower.h[a][b] = derivative;
3031 tower.h[b][a] = derivative;
3032 }
3033 }
3034 parts[part] = tower;
3035 }
3036 latent_kernel_signed_log_parts(
3037 base_log_sum,
3038 parts,
3039 suffixes.len(),
3040 context,
3041 )
3042}
3043
3044fn latent_kernel_signed_log_parts<const K: usize>(
3054 base_log_sum: f64,
3055 normalized_parts: [LatentSignedLogOrder2<K>; 4],
3056 part_count: usize,
3057 context: &str,
3058) -> Result<[Order2<K>; 4], LatentSurvivalError> {
3059 assert!(matches!(part_count, 1 | 2 | 4));
3060 let compose_log = |moments: [LatentSignedLog; 16], target_mask: usize| {
3061 latent_certified_cumulants(moments, target_mask, context)
3062 };
3063 let moments_for = |a: usize, b: usize| {
3064 let base = &normalized_parts[0];
3065 let u = &normalized_parts[1];
3066 let v = &normalized_parts[2];
3067 let uv = &normalized_parts[3];
3068 [
3069 LatentSignedLog::ONE,
3070 base.g[a],
3071 base.g[b],
3072 base.h[a][b],
3073 u.v,
3074 u.g[a],
3075 u.g[b],
3076 u.h[a][b],
3077 v.v,
3078 v.g[a],
3079 v.g[b],
3080 v.h[a][b],
3081 uv.v,
3082 uv.g[a],
3083 uv.g[b],
3084 uv.h[a][b],
3085 ]
3086 };
3087
3088 let mut out = [Order2::<K>::constant(0.0); 4];
3089 out[0].0.v = base_log_sum;
3090 if part_count >= 2 {
3091 out[1].0.v = latent_signed_log_materialize(normalized_parts[1].v, context)?;
3092 }
3093 if part_count == 4 {
3094 out[2].0.v = latent_signed_log_materialize(normalized_parts[2].v, context)?;
3095 let composed = compose_log(moments_for(0, 0), 0b1100)?;
3096 out[3].0.v = composed[0b1100].value;
3097 }
3098
3099 let (gradient_mask, hessian_mask) = match part_count {
3100 1 => (0b0001, 0b0011),
3101 2 => (0b0101, 0b0111),
3102 4 => (0b1101, 0b1111),
3103 other => {
3104 return Err(LatentSurvivalError::NumericalFailure {
3105 reason: format!(
3106 "{context} composed a latent moment jet over {other} parts; \
3107 only 1, 2, or 4 are constructible"
3108 ),
3109 })
3110 }
3111 };
3112 for a in 0..K {
3113 let composed = compose_log(moments_for(a, a), gradient_mask)?;
3114 out[0].0.g[a] = composed[0b0001].value;
3115 if part_count >= 2 {
3116 out[1].0.g[a] = composed[0b0101].value;
3117 }
3118 if part_count == 4 {
3119 out[2].0.g[a] = composed[0b1001].value;
3120 out[3].0.g[a] = composed[0b1101].value;
3121 }
3122 for b in a..K {
3123 let composed = compose_log(moments_for(a, b), hessian_mask)?;
3124 out[0].0.h[a][b] = composed[0b0011].value;
3125 if part_count >= 2 {
3126 out[1].0.h[a][b] = composed[0b0111].value;
3127 }
3128 if part_count == 4 {
3129 out[2].0.h[a][b] = composed[0b1011].value;
3130 out[3].0.h[a][b] = composed[0b1111].value;
3131 }
3132 for part in 0..part_count {
3133 out[part].0.h[b][a] = out[part].0.h[a][b];
3134 }
3135 }
3136 }
3137 Ok(out)
3138}
3139
3140#[inline]
3141fn latent_kernel_direction_linear_combination<const K: usize>(
3142 primary_directions: &[LatentKernelPrimaryDirection; K],
3143 coefficients: &[f64; K],
3144) -> LatentKernelPrimaryDirection {
3145 let mut out = LatentKernelPrimaryDirection {
3146 dq: 0.0,
3147 dqd: 0.0,
3148 dmu: 0.0,
3149 dtau: 0.0,
3150 };
3151 for a in 0..K {
3152 out.dq += coefficients[a] * primary_directions[a].dq;
3153 out.dqd += coefficients[a] * primary_directions[a].dqd;
3154 out.dmu += coefficients[a] * primary_directions[a].dmu;
3155 out.dtau += coefficients[a] * primary_directions[a].dtau;
3156 }
3157 out
3158}
3159
3160fn latent_order2_all_finite<const K: usize>(jet: &Order2<K>) -> bool {
3161 jet.value().is_finite()
3162 && jet.g().iter().all(|value| value.is_finite())
3163 && jet
3164 .h()
3165 .iter()
3166 .flatten()
3167 .all(|value| value.is_finite())
3168}
3169
3170trait LatentPrimaryJetBackend<const K: usize> {
3174 type Jet: JetScalar<K>;
3175
3176 fn derivative_order(&self) -> usize;
3177 fn all_channels_finite(&self, jet: &Self::Jet) -> bool;
3178
3179 fn kernel_sum_log(
3180 &self,
3181 quadctx: &QuadratureContext,
3182 base_terms: &[LatentKernelPrimaryTerm],
3183 state: LatentKernelPrimaryState,
3184 primary_directions: &[LatentKernelPrimaryDirection; K],
3185 context: &str,
3186 ) -> Result<Self::Jet, LatentSurvivalError>;
3187}
3188
3189#[derive(Clone, Copy)]
3190struct LatentOrder2Backend;
3191
3192impl<const K: usize> LatentPrimaryJetBackend<K> for LatentOrder2Backend {
3193 type Jet = Order2<K>;
3194
3195 fn derivative_order(&self) -> usize {
3196 2
3197 }
3198
3199 fn all_channels_finite(&self, jet: &Self::Jet) -> bool {
3200 latent_order2_all_finite(jet)
3201 }
3202
3203 fn kernel_sum_log(
3204 &self,
3205 quadctx: &QuadratureContext,
3206 base_terms: &[LatentKernelPrimaryTerm],
3207 state: LatentKernelPrimaryState,
3208 primary_directions: &[LatentKernelPrimaryDirection; K],
3209 context: &str,
3210 ) -> Result<Self::Jet, LatentSurvivalError> {
3211 let suffixes: [&[LatentKernelPrimaryDirection]; 1] = [&[]];
3212 let parts = latent_kernel_sum_order2_parts(
3213 quadctx,
3214 base_terms,
3215 state,
3216 primary_directions,
3217 &suffixes,
3218 context,
3219 )?;
3220 Ok(parts[0])
3221 }
3222}
3223
3224#[derive(Clone, Copy)]
3249struct LatentValueBackend;
3250
3251impl<const K: usize> LatentPrimaryJetBackend<K> for LatentValueBackend {
3252 type Jet = Order2<K>;
3253
3254 fn derivative_order(&self) -> usize {
3255 0
3256 }
3257
3258 fn all_channels_finite(&self, jet: &Self::Jet) -> bool {
3259 latent_order2_all_finite(jet)
3260 }
3261
3262 fn kernel_sum_log(
3263 &self,
3264 quadctx: &QuadratureContext,
3265 base_terms: &[LatentKernelPrimaryTerm],
3266 state: LatentKernelPrimaryState,
3267 primary_directions: &[LatentKernelPrimaryDirection; K],
3268 context: &str,
3269 ) -> Result<Self::Jet, LatentSurvivalError> {
3270 let suffixes: [&[LatentKernelPrimaryDirection]; 1] = [&[]];
3273 let (_, base_log_sum) = latent_kernel_sum_base(
3274 quadctx,
3275 base_terms,
3276 state,
3277 primary_directions,
3278 &suffixes,
3279 context,
3280 )?;
3281 Ok(Order2::<K>::constant(base_log_sum))
3282 }
3283}
3284
3285#[derive(Clone, Copy)]
3286struct LatentOneSeedBackend<const K: usize> {
3287 direction: [f64; K],
3288}
3289
3290impl<const K: usize> LatentPrimaryJetBackend<K> for LatentOneSeedBackend<K> {
3291 type Jet = OneSeed<K>;
3292
3293 fn derivative_order(&self) -> usize {
3294 3
3295 }
3296
3297 fn all_channels_finite(&self, jet: &Self::Jet) -> bool {
3298 latent_order2_all_finite(&jet.base) && latent_order2_all_finite(&jet.eps)
3299 }
3300
3301 fn kernel_sum_log(
3302 &self,
3303 quadctx: &QuadratureContext,
3304 base_terms: &[LatentKernelPrimaryTerm],
3305 state: LatentKernelPrimaryState,
3306 primary_directions: &[LatentKernelPrimaryDirection; K],
3307 context: &str,
3308 ) -> Result<Self::Jet, LatentSurvivalError> {
3309 let seed = latent_kernel_direction_linear_combination(primary_directions, &self.direction);
3310 let seed_suffix = [seed];
3311 let suffixes: [&[LatentKernelPrimaryDirection]; 2] = [&[], &seed_suffix];
3312 let parts = latent_kernel_sum_order2_parts(
3313 quadctx,
3314 base_terms,
3315 state,
3316 primary_directions,
3317 &suffixes,
3318 context,
3319 )?;
3320 Ok(OneSeed {
3321 base: parts[0],
3322 eps: parts[1],
3323 })
3324 }
3325}
3326
3327#[derive(Clone, Copy)]
3328struct LatentTwoSeedBackend<const K: usize> {
3329 direction_u: [f64; K],
3330 direction_v: [f64; K],
3331}
3332
3333impl<const K: usize> LatentPrimaryJetBackend<K> for LatentTwoSeedBackend<K> {
3334 type Jet = TwoSeed<K>;
3335
3336 fn derivative_order(&self) -> usize {
3337 4
3338 }
3339
3340 fn all_channels_finite(&self, jet: &Self::Jet) -> bool {
3341 latent_order2_all_finite(&jet.base)
3342 && latent_order2_all_finite(&jet.eps)
3343 && latent_order2_all_finite(&jet.del)
3344 && latent_order2_all_finite(&jet.eps_del)
3345 }
3346
3347 fn kernel_sum_log(
3348 &self,
3349 quadctx: &QuadratureContext,
3350 base_terms: &[LatentKernelPrimaryTerm],
3351 state: LatentKernelPrimaryState,
3352 primary_directions: &[LatentKernelPrimaryDirection; K],
3353 context: &str,
3354 ) -> Result<Self::Jet, LatentSurvivalError> {
3355 let seed_u =
3356 latent_kernel_direction_linear_combination(primary_directions, &self.direction_u);
3357 let seed_v =
3358 latent_kernel_direction_linear_combination(primary_directions, &self.direction_v);
3359 let suffix_u = [seed_u];
3360 let suffix_v = [seed_v];
3361 let suffix_uv = [seed_u, seed_v];
3362 let suffixes: [&[LatentKernelPrimaryDirection]; 4] =
3363 [&[], &suffix_u, &suffix_v, &suffix_uv];
3364 let parts = latent_kernel_sum_order2_parts(
3365 quadctx,
3366 base_terms,
3367 state,
3368 primary_directions,
3369 &suffixes,
3370 context,
3371 )?;
3372 Ok(TwoSeed {
3373 base: parts[0],
3374 eps: parts[1],
3375 del: parts[2],
3376 eps_del: parts[3],
3377 })
3378 }
3379}
3380
3381fn latent_survival_basis_direction(primary_idx: usize) -> LatentSurvivalPrimaryDirection {
3382 match primary_idx {
3383 LATENT_SURVIVAL_PRIMARY_Q_ENTRY => LatentSurvivalPrimaryDirection {
3384 dq_entry: 1.0,
3385 dq_exit: 0.0,
3386 dqdot_exit: 0.0,
3387 dq_right: 0.0,
3388 dmu: 0.0,
3389 dlog_sigma: 0.0,
3390 },
3391 LATENT_SURVIVAL_PRIMARY_Q_EXIT => LatentSurvivalPrimaryDirection {
3392 dq_entry: 0.0,
3393 dq_exit: 1.0,
3394 dqdot_exit: 0.0,
3395 dq_right: 0.0,
3396 dmu: 0.0,
3397 dlog_sigma: 0.0,
3398 },
3399 LATENT_SURVIVAL_PRIMARY_QDOT_EXIT => LatentSurvivalPrimaryDirection {
3400 dq_entry: 0.0,
3401 dq_exit: 0.0,
3402 dqdot_exit: 1.0,
3403 dq_right: 0.0,
3404 dmu: 0.0,
3405 dlog_sigma: 0.0,
3406 },
3407 LATENT_SURVIVAL_PRIMARY_Q_RIGHT => LatentSurvivalPrimaryDirection {
3408 dq_entry: 0.0,
3409 dq_exit: 0.0,
3410 dqdot_exit: 0.0,
3411 dq_right: 1.0,
3412 dmu: 0.0,
3413 dlog_sigma: 0.0,
3414 },
3415 LATENT_SURVIVAL_PRIMARY_MU => LatentSurvivalPrimaryDirection {
3416 dq_entry: 0.0,
3417 dq_exit: 0.0,
3418 dqdot_exit: 0.0,
3419 dq_right: 0.0,
3420 dmu: 1.0,
3421 dlog_sigma: 0.0,
3422 },
3423 LATENT_SURVIVAL_PRIMARY_LOG_SIGMA => LatentSurvivalPrimaryDirection {
3424 dq_entry: 0.0,
3425 dq_exit: 0.0,
3426 dqdot_exit: 0.0,
3427 dq_right: 0.0,
3428 dmu: 0.0,
3429 dlog_sigma: 1.0,
3430 },
3431 _ => std::panic::panic_any(format!(
3439 "latent survival primary index out of bounds: primary_idx={primary_idx}, primary_dim={LATENT_SURVIVAL_PRIMARY_DIM}"
3440 )),
3441 }
3442}
3443
3444fn latent_survival_map_entry_direction(
3445 direction: LatentSurvivalPrimaryDirection,
3446) -> LatentKernelPrimaryDirection {
3447 LatentKernelPrimaryDirection {
3448 dq: direction.dq_entry,
3449 dqd: 0.0,
3450 dmu: direction.dmu,
3451 dtau: direction.dlog_sigma,
3452 }
3453}
3454
3455fn latent_survival_map_exit_direction(
3456 direction: LatentSurvivalPrimaryDirection,
3457 event_type: LatentSurvivalEventType,
3458) -> LatentKernelPrimaryDirection {
3459 LatentKernelPrimaryDirection {
3460 dq: direction.dq_exit,
3461 dqd: if matches!(event_type, LatentSurvivalEventType::ExactEvent) {
3462 direction.dqdot_exit
3463 } else {
3464 0.0
3465 },
3466 dmu: direction.dmu,
3467 dtau: direction.dlog_sigma,
3468 }
3469}
3470
3471fn latent_survival_map_left_direction(
3475 direction: LatentSurvivalPrimaryDirection,
3476) -> LatentKernelPrimaryDirection {
3477 LatentKernelPrimaryDirection {
3478 dq: direction.dq_exit,
3479 dqd: 0.0,
3480 dmu: direction.dmu,
3481 dtau: direction.dlog_sigma,
3482 }
3483}
3484
3485fn latent_survival_map_right_direction(
3490 direction: LatentSurvivalPrimaryDirection,
3491) -> LatentKernelPrimaryDirection {
3492 LatentKernelPrimaryDirection {
3493 dq: direction.dq_right,
3494 dqd: 0.0,
3495 dmu: direction.dmu,
3496 dtau: direction.dlog_sigma,
3497 }
3498}
3499
3500#[cfg(test)]
3501mod tests_multidir_row {
3502 use super::tests_multidir_kernel::latent_kernel_sum_log_jet;
3503 use super::*;
3504 use gam_math::jet_partitions::MultiDirJet as LatentMultiDirJet;
3505
3506 pub(super) fn latent_survival_row_primary_log_jet_multidir_reference(
3507 quadctx: &QuadratureContext,
3508 row: &LatentSurvivalRow,
3509 point: LatentSurvivalPrimaryPoint,
3510 directions: &[LatentSurvivalPrimaryDirection],
3511 ) -> Result<LatentMultiDirJet, String> {
3512 let LatentSurvivalPrimaryPoint {
3513 q_entry,
3514 q_exit,
3515 qdot_exit,
3516 mu,
3517 sigma,
3518 ..
3519 } = point;
3520 let log_sigma_factor = point.log_sigma_factor();
3521 let entry_state = LatentKernelPrimaryState {
3522 q: q_entry,
3523 qdot: 1.0,
3524 mu,
3525 sigma,
3526 log_sigma_factor,
3527 };
3528 let entry_directions = directions
3529 .iter()
3530 .copied()
3531 .map(latent_survival_map_entry_direction)
3532 .collect::<Vec<_>>();
3533
3534 let denominator = latent_kernel_sum_log_jet(
3535 quadctx,
3536 &[LatentKernelPrimaryTerm {
3537 coeff: 1.0,
3538 q_exp: 0,
3539 qdot_power: 0,
3540 tau_exp: 0,
3541 k: 0,
3542 }],
3543 entry_state,
3544 &entry_directions,
3545 "latent survival denominator",
3546 )?;
3547
3548 let numerator = match row.event_type {
3553 LatentSurvivalEventType::RightCensored | LatentSurvivalEventType::ExactEvent => {
3554 let exit_state = LatentKernelPrimaryState {
3555 q: q_exit,
3556 qdot: qdot_exit,
3557 mu,
3558 sigma,
3559 log_sigma_factor,
3560 };
3561 let exit_directions = directions
3562 .iter()
3563 .copied()
3564 .map(|dir| latent_survival_map_exit_direction(dir, row.event_type))
3565 .collect::<Vec<_>>();
3566 let numerator_terms = match row.event_type {
3567 LatentSurvivalEventType::RightCensored => vec![LatentKernelPrimaryTerm {
3568 coeff: 1.0,
3569 q_exp: 0,
3570 qdot_power: 0,
3571 tau_exp: 0,
3572 k: 0,
3573 }],
3574 LatentSurvivalEventType::ExactEvent => {
3575 let mut terms = Vec::new();
3576 if row.hazard_unloaded > 0.0 {
3577 terms.push(LatentKernelPrimaryTerm {
3578 coeff: row.hazard_unloaded,
3579 q_exp: 0,
3580 qdot_power: 0,
3581 tau_exp: 0,
3582 k: 0,
3583 });
3584 }
3585 terms.push(LatentKernelPrimaryTerm {
3586 coeff: 1.0,
3587 q_exp: 1,
3588 qdot_power: 1,
3589 tau_exp: 0,
3590 k: 1,
3591 });
3592 terms
3593 }
3594 LatentSurvivalEventType::IntervalCensored => {
3595 return Err(
3600 "interval-censored row reached the single-state numerator branch; \
3601 it must take the dedicated two-state branch"
3602 .to_string(),
3603 );
3604 }
3605 };
3606 latent_kernel_sum_log_jet(
3607 quadctx,
3608 &numerator_terms,
3609 exit_state,
3610 &exit_directions,
3611 "latent survival numerator",
3612 )?
3613 }
3614 LatentSurvivalEventType::IntervalCensored => {
3615 latent_survival_interval_numerator_log_jet_multidir_reference(
3616 quadctx, row, point, directions,
3617 )?
3618 }
3619 };
3620
3621 let mut total = numerator.add(&denominator.scale(-1.0));
3622 match row.event_type {
3628 LatentSurvivalEventType::IntervalCensored => {
3629 total.coeffs[0] += row.mass_unloaded_entry;
3630 }
3631 _ => {
3632 total.coeffs[0] += -row.mass_unloaded_exit + row.mass_unloaded_entry;
3633 }
3634 }
3635 Ok(total)
3636 }
3637
3638 fn latent_survival_interval_numerator_log_jet_multidir_reference(
3654 quadctx: &QuadratureContext,
3655 row: &LatentSurvivalRow,
3656 point: LatentSurvivalPrimaryPoint,
3657 directions: &[LatentSurvivalPrimaryDirection],
3658 ) -> Result<LatentMultiDirJet, String> {
3659 let LatentSurvivalPrimaryPoint {
3660 q_exit,
3661 q_right,
3662 mu,
3663 sigma,
3664 ..
3665 } = point;
3666 let log_sigma_factor = point.log_sigma_factor();
3667 let single_k0 = [LatentKernelPrimaryTerm {
3668 coeff: 1.0,
3669 q_exp: 0,
3670 qdot_power: 0,
3671 tau_exp: 0,
3672 k: 0,
3673 }];
3674
3675 let left_state = LatentKernelPrimaryState {
3676 q: q_exit,
3677 qdot: 1.0,
3678 mu,
3679 sigma,
3680 log_sigma_factor,
3681 };
3682 let right_state = LatentKernelPrimaryState {
3683 q: q_right,
3684 qdot: 1.0,
3685 mu,
3686 sigma,
3687 log_sigma_factor,
3688 };
3689 let left_directions = directions
3690 .iter()
3691 .copied()
3692 .map(latent_survival_map_left_direction)
3693 .collect::<Vec<_>>();
3694 let right_directions = directions
3695 .iter()
3696 .copied()
3697 .map(latent_survival_map_right_direction)
3698 .collect::<Vec<_>>();
3699
3700 let log_left = latent_kernel_sum_log_jet(
3701 quadctx,
3702 &single_k0,
3703 left_state,
3704 &left_directions,
3705 "latent survival interval left boundary",
3706 )?;
3707 let log_right = latent_kernel_sum_log_jet(
3708 quadctx,
3709 &single_k0,
3710 right_state,
3711 &right_directions,
3712 "latent survival interval right boundary",
3713 )?;
3714
3715 let weighted_left = log_left.add(&LatentMultiDirJet::constant(
3719 directions.len(),
3720 -row.mass_unloaded_left,
3721 ));
3722 let weighted_right = log_right.add(&LatentMultiDirJet::constant(
3723 directions.len(),
3724 -row.mass_unloaded_right,
3725 ));
3726 let delta = weighted_right.sub(&weighted_left);
3727 let delta_value = delta.coeff(0);
3728 if !(delta_value.is_finite() && delta_value < 0.0) {
3729 return Err(LatentSurvivalError::NumericalFailure {
3730 reason: format!(
3731 "latent survival interval numerator must be a positive \
3732 survival-mass difference: log(c_L*K0(M_L))={:?}, \
3733 log(c_R*K0(M_R))={:?}; require M_L < M_R (i.e. L < R)",
3734 weighted_left.coeff(0),
3735 weighted_right.coeff(0),
3736 ),
3737 }
3738 .to_string());
3739 }
3740 let derivatives = latent_unary_derivatives_log1mexp_negative(
3741 delta_value,
3742 directions.len(),
3743 "latent survival interval numerator",
3744 )
3745 .map_err(|error| error.to_string())?;
3746 let out = weighted_left.add(&delta.compose_unary(derivatives));
3747 if !out.coeffs.iter().all(|value| value.is_finite()) {
3748 return Err(LatentSurvivalError::NumericalFailure {
3749 reason: format!(
3750 "latent survival interval numerator derivative jet is not \
3751 representable at log-boundary gap {delta_value:?}"
3752 ),
3753 }
3754 .to_string());
3755 }
3756 Ok(out)
3757 }
3758}
3759
3760fn latent_survival_row_primary_jet<const K: usize, B: LatentPrimaryJetBackend<K>>(
3765 backend: &B,
3766 quadctx: &QuadratureContext,
3767 row: &LatentSurvivalRow,
3768 point: LatentSurvivalPrimaryPoint,
3769) -> Result<B::Jet, LatentSurvivalError> {
3770 let LatentSurvivalPrimaryPoint {
3771 q_entry,
3772 q_exit,
3773 qdot_exit,
3774 mu,
3775 sigma,
3776 ..
3777 } = point;
3778 let log_sigma_factor = point.log_sigma_factor();
3779 let entry_state = LatentKernelPrimaryState {
3780 q: q_entry,
3781 qdot: 1.0,
3782 mu,
3783 sigma,
3784 log_sigma_factor,
3785 };
3786 let entry_directions: [LatentKernelPrimaryDirection; K] = std::array::from_fn(|a| {
3787 latent_survival_map_entry_direction(latent_survival_basis_direction(a))
3788 });
3789 let denominator = backend
3790 .kernel_sum_log(
3791 quadctx,
3792 &[LatentKernelPrimaryTerm {
3793 coeff: 1.0,
3794 q_exp: 0,
3795 qdot_power: 0,
3796 tau_exp: 0,
3797 k: 0,
3798 }],
3799 entry_state,
3800 &entry_directions,
3801 "latent survival denominator",
3802 )?;
3803
3804 let numerator = match row.event_type {
3805 LatentSurvivalEventType::RightCensored => {
3806 let exit_state = LatentKernelPrimaryState {
3807 q: q_exit,
3808 qdot: qdot_exit,
3809 mu,
3810 sigma,
3811 log_sigma_factor,
3812 };
3813 let exit_directions: [LatentKernelPrimaryDirection; K] = std::array::from_fn(|a| {
3814 latent_survival_map_exit_direction(
3815 latent_survival_basis_direction(a),
3816 row.event_type,
3817 )
3818 });
3819 backend
3820 .kernel_sum_log(
3821 quadctx,
3822 &[LatentKernelPrimaryTerm {
3823 coeff: 1.0,
3824 q_exp: 0,
3825 qdot_power: 0,
3826 tau_exp: 0,
3827 k: 0,
3828 }],
3829 exit_state,
3830 &exit_directions,
3831 "latent survival numerator",
3832 )?
3833 }
3834 LatentSurvivalEventType::ExactEvent => {
3835 let exit_state = LatentKernelPrimaryState {
3836 q: q_exit,
3837 qdot: qdot_exit,
3838 mu,
3839 sigma,
3840 log_sigma_factor,
3841 };
3842 let exit_directions: [LatentKernelPrimaryDirection; K] = std::array::from_fn(|a| {
3843 latent_survival_map_exit_direction(
3844 latent_survival_basis_direction(a),
3845 LatentSurvivalEventType::ExactEvent,
3846 )
3847 });
3848 let numerator_terms = [
3851 LatentKernelPrimaryTerm {
3852 coeff: row.hazard_unloaded,
3853 q_exp: 0,
3854 qdot_power: 0,
3855 tau_exp: 0,
3856 k: 0,
3857 },
3858 LatentKernelPrimaryTerm {
3859 coeff: 1.0,
3860 q_exp: 1,
3861 qdot_power: 1,
3862 tau_exp: 0,
3863 k: 1,
3864 },
3865 ];
3866 backend
3867 .kernel_sum_log(
3868 quadctx,
3869 &numerator_terms,
3870 exit_state,
3871 &exit_directions,
3872 "latent survival numerator",
3873 )?
3874 }
3875 LatentSurvivalEventType::IntervalCensored => {
3876 latent_survival_interval_numerator_jet(backend, quadctx, row, point)?
3877 }
3878 };
3879
3880 let unloaded_offset = match row.event_type {
3881 LatentSurvivalEventType::IntervalCensored => row.mass_unloaded_entry,
3882 _ => -row.mass_unloaded_exit + row.mass_unloaded_entry,
3883 };
3884 Ok(numerator
3885 .sub(&denominator)
3886 .add(&B::Jet::constant(unloaded_offset)))
3887}
3888
3889fn latent_survival_interval_numerator_jet<const K: usize, B: LatentPrimaryJetBackend<K>>(
3890 backend: &B,
3891 quadctx: &QuadratureContext,
3892 row: &LatentSurvivalRow,
3893 point: LatentSurvivalPrimaryPoint,
3894) -> Result<B::Jet, LatentSurvivalError> {
3895 let LatentSurvivalPrimaryPoint {
3896 q_exit,
3897 q_right,
3898 mu,
3899 sigma,
3900 ..
3901 } = point;
3902 let log_sigma_factor = point.log_sigma_factor();
3903 let single_k0 = [LatentKernelPrimaryTerm {
3904 coeff: 1.0,
3905 q_exp: 0,
3906 qdot_power: 0,
3907 tau_exp: 0,
3908 k: 0,
3909 }];
3910 let left_state = LatentKernelPrimaryState {
3911 q: q_exit,
3912 qdot: 1.0,
3913 mu,
3914 sigma,
3915 log_sigma_factor,
3916 };
3917 let right_state = LatentKernelPrimaryState {
3918 q: q_right,
3919 qdot: 1.0,
3920 mu,
3921 sigma,
3922 log_sigma_factor,
3923 };
3924 let left_directions: [LatentKernelPrimaryDirection; K] = std::array::from_fn(|a| {
3925 latent_survival_map_left_direction(latent_survival_basis_direction(a))
3926 });
3927 let right_directions: [LatentKernelPrimaryDirection; K] = std::array::from_fn(|a| {
3928 latent_survival_map_right_direction(latent_survival_basis_direction(a))
3929 });
3930 let log_left = backend
3931 .kernel_sum_log(
3932 quadctx,
3933 &single_k0,
3934 left_state,
3935 &left_directions,
3936 "latent survival interval left boundary",
3937 )?;
3938 let log_right = backend
3939 .kernel_sum_log(
3940 quadctx,
3941 &single_k0,
3942 right_state,
3943 &right_directions,
3944 "latent survival interval right boundary",
3945 )?;
3946
3947 latent_survival_positive_log_difference_jet(
3948 &log_left,
3949 -row.mass_unloaded_left,
3950 &log_right,
3951 -row.mass_unloaded_right,
3952 backend.derivative_order(),
3953 "latent survival interval numerator",
3954 |jet| backend.all_channels_finite(jet),
3955 )
3956}
3957
3958fn latent_survival_row_primary_gradient_hessian(
3995 quadctx: &QuadratureContext,
3996 row: &LatentSurvivalRow,
3997 point: LatentSurvivalPrimaryPoint,
3998 include_log_sigma: bool,
3999) -> Result<(f64, Array1<f64>, Array2<f64>), LatentSurvivalError> {
4000 if include_log_sigma {
4001 let out = latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_DIM, _>(
4002 &LatentOrder2Backend,
4003 quadctx,
4004 row,
4005 point,
4006 )?;
4007 let out_gradient = out.g();
4008 let hessian = out.h();
4009 Ok((
4010 out.value(),
4011 Array1::from_shape_fn(LATENT_SURVIVAL_PRIMARY_DIM, |a| out_gradient[a]),
4012 Array2::from_shape_fn(
4013 (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
4014 |(a, b)| -hessian[a][b],
4015 ),
4016 ))
4017 } else {
4018 let out = latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_LOG_SIGMA, _>(
4019 &LatentOrder2Backend,
4020 quadctx,
4021 row,
4022 point,
4023 )?;
4024 let out_gradient = out.g();
4025 let out_hessian = out.h();
4026 Ok((
4027 out.value(),
4028 Array1::from_shape_fn(LATENT_SURVIVAL_PRIMARY_DIM, |a| {
4029 if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA {
4030 out_gradient[a]
4031 } else {
4032 0.0
4033 }
4034 }),
4035 Array2::from_shape_fn(
4036 (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
4037 |(a, b)| {
4038 if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
4039 && b < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
4040 {
4041 -out_hessian[a][b]
4042 } else {
4043 0.0
4044 }
4045 },
4046 ),
4047 ))
4048 }
4049}
4050
4051fn latent_survival_row_primary_value(
4059 quadctx: &QuadratureContext,
4060 row: &LatentSurvivalRow,
4061 point: LatentSurvivalPrimaryPoint,
4062 include_log_sigma: bool,
4063) -> Result<f64, LatentSurvivalError> {
4064 if include_log_sigma {
4065 latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_DIM, _>(
4066 &LatentValueBackend,
4067 quadctx,
4068 row,
4069 point,
4070 )
4071 .map(|jet| jet.value())
4072 } else {
4073 latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_LOG_SIGMA, _>(
4074 &LatentValueBackend,
4075 quadctx,
4076 row,
4077 point,
4078 )
4079 .map(|jet| jet.value())
4080 }
4081}
4082
4083fn latent_survival_row_primary_one_seed_fixed_sigma(
4084 quadctx: &QuadratureContext,
4085 row: &LatentSurvivalRow,
4086 point: LatentSurvivalPrimaryPoint,
4087 direction: &Array1<f64>,
4088) -> Result<OneSeed<LATENT_SURVIVAL_PRIMARY_LOG_SIGMA>, LatentSurvivalError> {
4089 let backend = LatentOneSeedBackend {
4090 direction: std::array::from_fn(|a| direction[a]),
4091 };
4092 latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_LOG_SIGMA, _>(
4093 &backend, quadctx, row, point,
4094 )
4095}
4096
4097fn latent_survival_row_primary_two_seed_fixed_sigma(
4098 quadctx: &QuadratureContext,
4099 row: &LatentSurvivalRow,
4100 point: LatentSurvivalPrimaryPoint,
4101 direction_u: &Array1<f64>,
4102 direction_v: &Array1<f64>,
4103) -> Result<TwoSeed<LATENT_SURVIVAL_PRIMARY_LOG_SIGMA>, LatentSurvivalError> {
4104 let backend = LatentTwoSeedBackend {
4105 direction_u: std::array::from_fn(|a| direction_u[a]),
4106 direction_v: std::array::from_fn(|a| direction_v[a]),
4107 };
4108 latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_LOG_SIGMA, _>(
4109 &backend, quadctx, row, point,
4110 )
4111}
4112
4113fn latent_survival_row_primary_third_contracted(
4114 quadctx: &QuadratureContext,
4115 row: &LatentSurvivalRow,
4116 point: LatentSurvivalPrimaryPoint,
4117 direction: &Array1<f64>,
4118 include_log_sigma: bool,
4119) -> Result<Array2<f64>, LatentSurvivalError> {
4120 if include_log_sigma {
4121 let backend = LatentOneSeedBackend {
4122 direction: std::array::from_fn(|a| direction[a]),
4123 };
4124 let out = latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_DIM, _>(
4125 &backend, quadctx, row, point,
4126 )?;
4127 let third = out.contracted_third();
4128 Ok(Array2::from_shape_fn(
4129 (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
4130 |(a, b)| -third[a][b],
4131 ))
4132 } else {
4133 let out = latent_survival_row_primary_one_seed_fixed_sigma(quadctx, row, point, direction)?;
4134 let third = out.contracted_third();
4135 Ok(Array2::from_shape_fn(
4136 (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
4137 |(a, b)| {
4138 if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA && b < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA {
4139 -third[a][b]
4140 } else {
4141 0.0
4142 }
4143 },
4144 ))
4145 }
4146}
4147
4148fn latent_survival_row_primary_fourth_contracted(
4149 quadctx: &QuadratureContext,
4150 row: &LatentSurvivalRow,
4151 point: LatentSurvivalPrimaryPoint,
4152 direction_u: &Array1<f64>,
4153 direction_v: &Array1<f64>,
4154 include_log_sigma: bool,
4155) -> Result<Array2<f64>, LatentSurvivalError> {
4156 if include_log_sigma {
4157 let backend = LatentTwoSeedBackend {
4158 direction_u: std::array::from_fn(|a| direction_u[a]),
4159 direction_v: std::array::from_fn(|a| direction_v[a]),
4160 };
4161 let out = latent_survival_row_primary_jet::<LATENT_SURVIVAL_PRIMARY_DIM, _>(
4162 &backend, quadctx, row, point,
4163 )?;
4164 let fourth = out.contracted_fourth();
4165 Ok(Array2::from_shape_fn(
4166 (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
4167 |(a, b)| -fourth[a][b],
4168 ))
4169 } else {
4170 let out = latent_survival_row_primary_two_seed_fixed_sigma(
4171 quadctx,
4172 row,
4173 point,
4174 direction_u,
4175 direction_v,
4176 )?;
4177 let fourth = out.contracted_fourth();
4178 Ok(Array2::from_shape_fn(
4179 (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
4180 |(a, b)| {
4181 if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA && b < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA {
4182 -fourth[a][b]
4183 } else {
4184 0.0
4185 }
4186 },
4187 ))
4188 }
4189}
4190
4191#[cfg(test)]
4192mod tests_multidir_channels {
4193 use super::tests_multidir_row::latent_survival_row_primary_log_jet_multidir_reference;
4194 use super::*;
4195
4196 pub(super) fn latent_survival_row_primary_gradient_hessian_multidir_reference(
4197 quadctx: &QuadratureContext,
4198 row: &LatentSurvivalRow,
4199 point: LatentSurvivalPrimaryPoint,
4200 include_log_sigma: bool,
4201 ) -> Result<(f64, Array1<f64>, Array2<f64>), String> {
4202 let mut gradient = Array1::<f64>::zeros(LATENT_SURVIVAL_PRIMARY_DIM);
4203 let mut neg_hessian =
4204 Array2::<f64>::zeros((LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM));
4205 let active_primary = if include_log_sigma {
4206 LATENT_SURVIVAL_PRIMARY_DIM
4207 } else {
4208 LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
4209 };
4210 let log_lik =
4211 latent_survival_row_primary_log_jet_multidir_reference(quadctx, row, point, &[])?
4212 .coeff(0);
4213 for a in 0..active_primary {
4214 let dir_a = latent_survival_basis_direction(a);
4215 gradient[a] = latent_survival_row_primary_log_jet_multidir_reference(
4216 quadctx,
4217 row,
4218 point,
4219 &[dir_a],
4220 )?
4221 .coeff(1);
4222 for b in a..active_primary {
4223 let coeff = latent_survival_row_primary_log_jet_multidir_reference(
4224 quadctx,
4225 row,
4226 point,
4227 &[dir_a, latent_survival_basis_direction(b)],
4228 )?
4229 .coeff(3);
4230 neg_hessian[[a, b]] = -coeff;
4231 neg_hessian[[b, a]] = -coeff;
4232 }
4233 }
4234 Ok((log_lik, gradient, neg_hessian))
4235 }
4236
4237 pub(super) fn latent_survival_row_primary_third_contracted_multidir_reference(
4238 quadctx: &QuadratureContext,
4239 row: &LatentSurvivalRow,
4240 point: LatentSurvivalPrimaryPoint,
4241 direction: &Array1<f64>,
4242 include_log_sigma: bool,
4243 ) -> Result<Array2<f64>, String> {
4244 let active_primary = if include_log_sigma {
4245 LATENT_SURVIVAL_PRIMARY_DIM
4246 } else {
4247 LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
4248 };
4249 let dir = LatentSurvivalPrimaryDirection {
4250 dq_entry: direction[LATENT_SURVIVAL_PRIMARY_Q_ENTRY],
4251 dq_exit: direction[LATENT_SURVIVAL_PRIMARY_Q_EXIT],
4252 dqdot_exit: direction[LATENT_SURVIVAL_PRIMARY_QDOT_EXIT],
4253 dq_right: direction[LATENT_SURVIVAL_PRIMARY_Q_RIGHT],
4254 dmu: direction[LATENT_SURVIVAL_PRIMARY_MU],
4255 dlog_sigma: direction[LATENT_SURVIVAL_PRIMARY_LOG_SIGMA],
4256 };
4257 let mut out =
4258 Array2::<f64>::zeros((LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM));
4259 for a in 0..active_primary {
4260 let dir_a = latent_survival_basis_direction(a);
4261 for b in a..active_primary {
4262 let coeff = latent_survival_row_primary_log_jet_multidir_reference(
4263 quadctx,
4264 row,
4265 point,
4266 &[dir_a, latent_survival_basis_direction(b), dir],
4267 )?
4268 .coeff(7);
4269 out[[a, b]] = -coeff;
4270 out[[b, a]] = -coeff;
4271 }
4272 }
4273 Ok(out)
4274 }
4275
4276 pub(super) fn latent_survival_row_primary_fourth_contracted_multidir_reference(
4277 quadctx: &QuadratureContext,
4278 row: &LatentSurvivalRow,
4279 point: LatentSurvivalPrimaryPoint,
4280 direction_u: &Array1<f64>,
4281 direction_v: &Array1<f64>,
4282 include_log_sigma: bool,
4283 ) -> Result<Array2<f64>, String> {
4284 let active_primary = if include_log_sigma {
4285 LATENT_SURVIVAL_PRIMARY_DIM
4286 } else {
4287 LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
4288 };
4289 let dir_u = LatentSurvivalPrimaryDirection {
4290 dq_entry: direction_u[LATENT_SURVIVAL_PRIMARY_Q_ENTRY],
4291 dq_exit: direction_u[LATENT_SURVIVAL_PRIMARY_Q_EXIT],
4292 dqdot_exit: direction_u[LATENT_SURVIVAL_PRIMARY_QDOT_EXIT],
4293 dq_right: direction_u[LATENT_SURVIVAL_PRIMARY_Q_RIGHT],
4294 dmu: direction_u[LATENT_SURVIVAL_PRIMARY_MU],
4295 dlog_sigma: direction_u[LATENT_SURVIVAL_PRIMARY_LOG_SIGMA],
4296 };
4297 let dir_v = LatentSurvivalPrimaryDirection {
4298 dq_entry: direction_v[LATENT_SURVIVAL_PRIMARY_Q_ENTRY],
4299 dq_exit: direction_v[LATENT_SURVIVAL_PRIMARY_Q_EXIT],
4300 dqdot_exit: direction_v[LATENT_SURVIVAL_PRIMARY_QDOT_EXIT],
4301 dq_right: direction_v[LATENT_SURVIVAL_PRIMARY_Q_RIGHT],
4302 dmu: direction_v[LATENT_SURVIVAL_PRIMARY_MU],
4303 dlog_sigma: direction_v[LATENT_SURVIVAL_PRIMARY_LOG_SIGMA],
4304 };
4305 let mut out =
4306 Array2::<f64>::zeros((LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM));
4307 for a in 0..active_primary {
4308 let dir_a = latent_survival_basis_direction(a);
4309 for b in a..active_primary {
4310 let coeff = latent_survival_row_primary_log_jet_multidir_reference(
4311 quadctx,
4312 row,
4313 point,
4314 &[dir_a, latent_survival_basis_direction(b), dir_u, dir_v],
4315 )?
4316 .coeff(15);
4317 out[[a, b]] = -coeff;
4318 out[[b, a]] = -coeff;
4319 }
4320 }
4321 Ok(out)
4322 }
4323}
4324
4325#[derive(Clone)]
4326struct LatentSurvivalJointSlices {
4327 time: std::ops::Range<usize>,
4328 mean: std::ops::Range<usize>,
4329 log_sigma: Option<std::ops::Range<usize>>,
4330 total: usize,
4331}
4332
4333#[derive(Clone)]
4334struct LatentSurvivalJointGradientAccum {
4335 ll: CompensatedRowSum,
4336 gradient: Array1<f64>,
4337}
4338
4339#[derive(Clone)]
4340struct LatentSurvivalJointDenseAccum {
4341 ll: CompensatedRowSum,
4342 gradient: Array1<f64>,
4343 hessian: Array2<f64>,
4344}
4345
4346#[derive(Clone)]
4347struct LatentSurvivalDenseHessianAccum {
4348 hessian: Array2<f64>,
4349}
4350
4351#[derive(Clone, Copy, Default)]
4352struct CompensatedRowSum {
4353 sum: f64,
4354 correction: f64,
4355}
4356
4357impl CompensatedRowSum {
4358 #[inline]
4359 fn add(&mut self, value: f64) {
4360 let next = self.sum + value;
4361 if self.sum.abs() >= value.abs() {
4362 self.correction += (self.sum - next) + value;
4363 } else {
4364 self.correction += (value - next) + self.sum;
4365 }
4366 self.sum = next;
4367 }
4368
4369 #[inline]
4370 fn value(self) -> f64 {
4371 self.sum + self.correction
4372 }
4373}
4374
4375fn deterministic_latent_survival_row_reduction<Acc, Init, Process, Combine>(
4379 n_rows: usize,
4380 init: Init,
4381 process_row: Process,
4382 mut combine: Combine,
4383) -> Result<Acc, String>
4384where
4385 Acc: Send,
4386 Init: Fn() -> Acc + Sync,
4387 Process: Fn(usize, &mut Acc) -> Result<(), String> + Sync,
4388 Combine: FnMut(&mut Acc, Acc),
4389{
4390 use rayon::iter::{IntoParallelIterator, ParallelIterator};
4391
4392 const TARGET_CHUNK_COUNT: usize = 32;
4393 if n_rows == 0 {
4394 return Ok(init());
4395 }
4396 let chunk_size = n_rows.div_ceil(TARGET_CHUNK_COUNT).max(1);
4397 let n_chunks = n_rows.div_ceil(chunk_size);
4398 let chunk_accumulators: Vec<Acc> = (0..n_chunks)
4399 .into_par_iter()
4400 .map(|chunk_idx| -> Result<Acc, String> {
4401 let start = chunk_idx * chunk_size;
4402 let end = (start + chunk_size).min(n_rows);
4403 let mut acc = init();
4404 for row_idx in start..end {
4405 process_row(row_idx, &mut acc)?;
4406 }
4407 Ok(acc)
4408 })
4409 .collect::<Result<Vec<_>, String>>()?;
4410
4411 let mut total = init();
4412 for acc in chunk_accumulators {
4413 combine(&mut total, acc);
4414 }
4415 Ok(total)
4416}
4417
4418impl LatentSurvivalFamily {
4419 fn build_row_at(
4427 &self,
4428 row_idx: usize,
4429 q_entry: f64,
4430 q_exit: f64,
4431 qdot_exit: f64,
4432 q_right: f64,
4433 ) -> Result<LatentSurvivalRow, LatentSurvivalError> {
4434 let event_type = latent_survival_event_type_for(self.event_target[row_idx]);
4435 build_latent_survival_row(
4436 row_idx,
4437 self.hazard_loading,
4438 event_type,
4439 q_entry,
4440 q_exit,
4441 qdot_exit,
4442 q_right,
4443 self.unloaded_mass_entry[row_idx],
4444 self.unloaded_mass_exit[row_idx],
4445 self.unloaded_mass_right[row_idx],
4446 self.unloaded_hazard_exit[row_idx],
4447 )
4448 }
4449
4450 fn joint_slices(&self) -> LatentSurvivalJointSlices {
4451 let p_time = self.x_time_exit.ncols();
4452 let p_mean = self.x_mean.ncols();
4453 let time = 0..p_time;
4454 let mean = p_time..p_time + p_mean;
4455 let log_sigma = self
4456 .latent_sd_fixed
4457 .is_none()
4458 .then_some((p_time + p_mean)..(p_time + p_mean + 1));
4459 LatentSurvivalJointSlices {
4460 total: log_sigma
4461 .as_ref()
4462 .map_or(p_time + p_mean, |range| range.end),
4463 time,
4464 mean,
4465 log_sigma,
4466 }
4467 }
4468
4469 fn row_primary_direction_from_flat(
4470 &self,
4471 row: usize,
4472 slices: &LatentSurvivalJointSlices,
4473 d_beta_flat: &Array1<f64>,
4474 ) -> Array1<f64> {
4475 let mut out = Array1::<f64>::zeros(LATENT_SURVIVAL_PRIMARY_DIM);
4476 let d_time = d_beta_flat.slice(s![slices.time.clone()]);
4477 out[LATENT_SURVIVAL_PRIMARY_Q_ENTRY] = self.x_time_entry.row(row).dot(&d_time);
4478 out[LATENT_SURVIVAL_PRIMARY_Q_EXIT] = self.x_time_exit.row(row).dot(&d_time);
4479 out[LATENT_SURVIVAL_PRIMARY_QDOT_EXIT] = self.x_time_derivative_exit.row(row).dot(&d_time);
4480 out[LATENT_SURVIVAL_PRIMARY_Q_RIGHT] = self.x_time_right.row(row).dot(&d_time);
4481 out[LATENT_SURVIVAL_PRIMARY_MU] = self
4482 .x_mean
4483 .dot_row_view(row, d_beta_flat.slice(s![slices.mean.clone()]));
4484 if let Some(range) = &slices.log_sigma {
4485 out[LATENT_SURVIVAL_PRIMARY_LOG_SIGMA] = d_beta_flat[range.start];
4486 }
4487 out
4488 }
4489
4490 fn joint_block_ranges(&self) -> Vec<std::ops::Range<usize>> {
4491 let slices = self.joint_slices();
4492 let mut ranges = vec![slices.time.clone(), slices.mean.clone()];
4493 if let Some(log_sigma) = slices.log_sigma {
4494 ranges.push(log_sigma);
4495 }
4496 ranges
4497 }
4498
4499 fn add_pullback_primary_gradient(
4500 &self,
4501 target: &mut Array1<f64>,
4502 row: usize,
4503 slices: &LatentSurvivalJointSlices,
4504 primary_gradient: &Array1<f64>,
4505 weight: f64,
4506 ) -> Result<(), String> {
4507 for (primary_idx, time_vec) in [
4508 (LATENT_SURVIVAL_PRIMARY_Q_ENTRY, self.x_time_entry.row(row)),
4509 (LATENT_SURVIVAL_PRIMARY_Q_EXIT, self.x_time_exit.row(row)),
4510 (
4511 LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4512 self.x_time_derivative_exit.row(row),
4513 ),
4514 (LATENT_SURVIVAL_PRIMARY_Q_RIGHT, self.x_time_right.row(row)),
4515 ] {
4516 let scale = checked_weighted_row_value(
4517 weight,
4518 primary_gradient[primary_idx],
4519 row,
4520 "primary gradient",
4521 )?;
4522 if scale == 0.0 {
4523 continue;
4524 }
4525 for i in 0..time_vec.len() {
4526 let xi = time_vec[i];
4527 if xi != 0.0 {
4528 target[slices.time.start + i] += scale * xi;
4529 }
4530 }
4531 }
4532
4533 let mean_scale = checked_weighted_row_value(
4534 weight,
4535 primary_gradient[LATENT_SURVIVAL_PRIMARY_MU],
4536 row,
4537 "mean gradient",
4538 )?;
4539 if mean_scale != 0.0 {
4540 self.x_mean
4541 .axpy_row_into(
4542 row,
4543 mean_scale,
4544 &mut target.slice_mut(s![slices.mean.clone()]),
4545 )
4546 .map_err(|error| {
4547 format!(
4548 "latent survival mean gradient pullback dimension mismatch: row={row}, mean_slice={:?}, target_len={}, x_mean_cols={}, error={error}",
4549 slices.mean,
4550 target.len(),
4551 self.x_mean.ncols()
4552 )
4553 })?;
4554 }
4555
4556 if let Some(log_sigma) = &slices.log_sigma {
4557 target[log_sigma.start] += checked_weighted_row_value(
4558 weight,
4559 primary_gradient[LATENT_SURVIVAL_PRIMARY_LOG_SIGMA],
4560 row,
4561 "log-sigma gradient",
4562 )?;
4563 }
4564 Ok(())
4565 }
4566
4567 fn add_pullback_primary_hessian(
4568 &self,
4569 target: &mut Array2<f64>,
4570 row: usize,
4571 slices: &LatentSurvivalJointSlices,
4572 primary_hessian: &Array2<f64>,
4573 ) -> Result<(), String> {
4574 let time_weights = [
4575 primary_hessian[[
4576 LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4577 LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4578 ]],
4579 primary_hessian[[
4580 LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4581 LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4582 ]],
4583 primary_hessian[[
4584 LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4585 LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4586 ]],
4587 primary_hessian[[
4588 LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
4589 LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
4590 ]],
4591 ];
4592 let time_cross_weights = [
4593 (
4594 LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4595 LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4596 &self.x_time_entry,
4597 &self.x_time_exit,
4598 ),
4599 (
4600 LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4601 LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4602 &self.x_time_entry,
4603 &self.x_time_derivative_exit,
4604 ),
4605 (
4606 LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4607 LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4608 &self.x_time_exit,
4609 &self.x_time_derivative_exit,
4610 ),
4611 (
4612 LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4613 LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
4614 &self.x_time_entry,
4615 &self.x_time_right,
4616 ),
4617 (
4618 LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4619 LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
4620 &self.x_time_exit,
4621 &self.x_time_right,
4622 ),
4623 (
4624 LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4625 LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
4626 &self.x_time_derivative_exit,
4627 &self.x_time_right,
4628 ),
4629 ];
4630 {
4631 let time_target = &mut target.slice_mut(s![slices.time.clone(), slices.time.clone()]);
4632 dense_outer_accumulate(time_target, time_weights[0], self.x_time_entry.row(row));
4633 dense_outer_accumulate(time_target, time_weights[1], self.x_time_exit.row(row));
4634 dense_outer_accumulate(
4635 time_target,
4636 time_weights[2],
4637 self.x_time_derivative_exit.row(row),
4638 );
4639 dense_outer_accumulate(time_target, time_weights[3], self.x_time_right.row(row));
4640 for (a, b, lhs, rhs) in time_cross_weights {
4641 let weight = primary_hessian[[a, b]];
4642 if weight == 0.0 {
4643 continue;
4644 }
4645 dense_symmetric_cross_accumulate(time_target, weight, lhs.row(row), rhs.row(row));
4646 }
4647 }
4648
4649 let mean_weight = primary_hessian[[LATENT_SURVIVAL_PRIMARY_MU, LATENT_SURVIVAL_PRIMARY_MU]];
4650 self.x_mean
4651 .syr_row_into_view(
4652 row,
4653 mean_weight,
4654 target.slice_mut(s![slices.mean.clone(), slices.mean.clone()]),
4655 )
4656 .map_err(|error| {
4657 format!(
4658 "latent survival mean Hessian pullback dimension mismatch: row={row}, mean_slice={:?}, target_dim={:?}, x_mean_cols={}, error={error}",
4659 slices.mean,
4660 target.dim(),
4661 self.x_mean.ncols()
4662 )
4663 })?;
4664
4665 let mean_row = self
4666 .x_mean
4667 .try_row_chunk(row..row + 1)
4668 .map_err(|error| {
4669 format!(
4670 "latent survival mean pullback row chunk failed: row={row}, x_mean_rows={}, x_mean_cols={}, error={error}",
4671 self.x_mean.nrows(),
4672 self.x_mean.ncols()
4673 )
4674 })?;
4675 let mean_vec = mean_row.row(0);
4676 let time_mean_weights = [
4677 (LATENT_SURVIVAL_PRIMARY_Q_ENTRY, self.x_time_entry.row(row)),
4678 (LATENT_SURVIVAL_PRIMARY_Q_EXIT, self.x_time_exit.row(row)),
4679 (
4680 LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4681 self.x_time_derivative_exit.row(row),
4682 ),
4683 (LATENT_SURVIVAL_PRIMARY_Q_RIGHT, self.x_time_right.row(row)),
4684 ];
4685 for (primary_idx, time_vec) in time_mean_weights {
4686 let weight = primary_hessian[[primary_idx, LATENT_SURVIVAL_PRIMARY_MU]];
4687 if weight == 0.0 {
4688 continue;
4689 }
4690 for i in 0..time_vec.len() {
4691 let xi = time_vec[i];
4692 if xi == 0.0 {
4693 continue;
4694 }
4695 for j in 0..mean_vec.len() {
4696 let xj = mean_vec[j];
4697 if xj == 0.0 {
4698 continue;
4699 }
4700 target[[slices.time.start + i, slices.mean.start + j]] += weight * xi * xj;
4701 target[[slices.mean.start + j, slices.time.start + i]] += weight * xj * xi;
4702 }
4703 }
4704 }
4705
4706 if let Some(log_sigma) = &slices.log_sigma {
4707 let sigma_idx = log_sigma.start;
4708 target[[sigma_idx, sigma_idx]] += primary_hessian[[
4709 LATENT_SURVIVAL_PRIMARY_LOG_SIGMA,
4710 LATENT_SURVIVAL_PRIMARY_LOG_SIGMA,
4711 ]];
4712
4713 for (primary_idx, time_vec) in [
4714 (LATENT_SURVIVAL_PRIMARY_Q_ENTRY, self.x_time_entry.row(row)),
4715 (LATENT_SURVIVAL_PRIMARY_Q_EXIT, self.x_time_exit.row(row)),
4716 (
4717 LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4718 self.x_time_derivative_exit.row(row),
4719 ),
4720 (LATENT_SURVIVAL_PRIMARY_Q_RIGHT, self.x_time_right.row(row)),
4721 ] {
4722 let weight = primary_hessian[[primary_idx, LATENT_SURVIVAL_PRIMARY_LOG_SIGMA]];
4723 if weight == 0.0 {
4724 continue;
4725 }
4726 for i in 0..time_vec.len() {
4727 let xi = time_vec[i];
4728 if xi == 0.0 {
4729 continue;
4730 }
4731 target[[slices.time.start + i, sigma_idx]] += weight * xi;
4732 target[[sigma_idx, slices.time.start + i]] += weight * xi;
4733 }
4734 }
4735
4736 let mean_sigma_weight = primary_hessian[[
4737 LATENT_SURVIVAL_PRIMARY_MU,
4738 LATENT_SURVIVAL_PRIMARY_LOG_SIGMA,
4739 ]];
4740 if mean_sigma_weight != 0.0 {
4741 for j in 0..mean_vec.len() {
4742 let xj = mean_vec[j];
4743 if xj == 0.0 {
4744 continue;
4745 }
4746 target[[slices.mean.start + j, sigma_idx]] += mean_sigma_weight * xj;
4747 target[[sigma_idx, slices.mean.start + j]] += mean_sigma_weight * xj;
4748 }
4749 }
4750 }
4751 Ok(())
4752 }
4753
4754 fn evaluate_exact_newton_joint_gradient_dense(
4755 &self,
4756 block_states: &[ParameterBlockState],
4757 ) -> Result<(f64, Array1<f64>), String> {
4758 let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-survival")
4759 .map_err(String::from)?;
4760 let (q_entry, q_exit, qdot_exit, mu) = self.split_time_eta(block_states)?;
4761 let q_right = self.time_q_right(block_states)?;
4762 let sigma = self.latent_sd(block_states)?;
4763 let slices = self.joint_slices();
4764 let include_log_sigma = slices.log_sigma.is_some();
4765 let total = slices.total;
4766 let acc = deterministic_latent_survival_row_reduction(
4767 self.event_target.len(),
4768 || LatentSurvivalJointGradientAccum {
4769 ll: CompensatedRowSum::default(),
4770 gradient: Array1::<f64>::zeros(total),
4771 },
4772 |row_idx, acc| {
4773 let wi = weights.at(row_idx);
4774 if wi == 0.0 {
4775 return Ok(());
4776 }
4777 let row = self.build_row_at(
4778 row_idx,
4779 q_entry[row_idx],
4780 q_exit[row_idx],
4781 qdot_exit[row_idx],
4782 q_right[row_idx],
4783 )?;
4784 let point = LatentSurvivalPrimaryPoint {
4785 q_entry: q_entry[row_idx],
4786 q_exit: q_exit[row_idx],
4787 qdot_exit: qdot_exit[row_idx],
4788 q_right: q_right[row_idx],
4789 mu: mu[row_idx],
4790 sigma,
4791 };
4792 let (row_ll, primary_gradient, _) = latent_survival_row_primary_gradient_hessian(
4793 &self.quadctx,
4794 &row,
4795 point,
4796 include_log_sigma,
4797 )?;
4798 acc.ll.add(checked_weighted_row_value(
4799 wi,
4800 row_ll,
4801 row_idx,
4802 "log likelihood",
4803 )?);
4804 self.add_pullback_primary_gradient(
4805 &mut acc.gradient,
4806 row_idx,
4807 &slices,
4808 &primary_gradient,
4809 wi,
4810 )?;
4811 Ok(())
4812 },
4813 |total_acc, chunk_acc| {
4814 total_acc.ll.add(chunk_acc.ll.value());
4815 total_acc.gradient += &chunk_acc.gradient;
4816 },
4817 )?;
4818 let ll = require_finite_likelihood_scalar(acc.ll.value(), "log likelihood")?;
4819 require_finite_likelihood_vector(&acc.gradient, "gradient")?;
4820 Ok((ll, acc.gradient))
4821 }
4822
4823 pub fn offset_channel_residuals(
4852 &self,
4853 block_states: &[ParameterBlockState],
4854 ) -> Result<crate::survival::OffsetChannelResiduals, LatentSurvivalError> {
4855 let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-survival")?;
4856 let n = self.event_target.len();
4857 let (q_entry, q_exit, qdot_exit, mu) = self.split_time_eta(block_states)?;
4862 let q_right = self.time_q_right(block_states)?;
4863 let sigma = self.latent_sd(block_states)?;
4864 let include_log_sigma = self.joint_slices().log_sigma.is_some();
4865 let mut entry = Array1::<f64>::zeros(n);
4866 let mut exit = Array1::<f64>::zeros(n);
4867 let mut derivative = Array1::<f64>::zeros(n);
4868 let mut right = Array1::<f64>::zeros(n);
4869 for row_idx in 0..n {
4870 let wi = weights.at(row_idx);
4871 if wi == 0.0 {
4872 continue;
4873 }
4874 let row = self.build_row_at(
4875 row_idx,
4876 q_entry[row_idx],
4877 q_exit[row_idx],
4878 qdot_exit[row_idx],
4879 q_right[row_idx],
4880 )?;
4881 let point = LatentSurvivalPrimaryPoint {
4882 q_entry: q_entry[row_idx],
4883 q_exit: q_exit[row_idx],
4884 qdot_exit: qdot_exit[row_idx],
4885 q_right: q_right[row_idx],
4886 mu: mu[row_idx],
4887 sigma,
4888 };
4889 let (_, primary_gradient, _) = latent_survival_row_primary_gradient_hessian(
4890 &self.quadctx,
4891 &row,
4892 point,
4893 include_log_sigma,
4894 )?;
4895 entry[row_idx] = -checked_weighted_row_value(
4897 wi,
4898 primary_gradient[LATENT_SURVIVAL_PRIMARY_Q_ENTRY],
4899 row_idx,
4900 "entry-offset score",
4901 )
4902 .map_err(|reason| LatentSurvivalError::NumericalFailure { reason })?;
4903 exit[row_idx] = -checked_weighted_row_value(
4904 wi,
4905 primary_gradient[LATENT_SURVIVAL_PRIMARY_Q_EXIT],
4906 row_idx,
4907 "exit-offset score",
4908 )
4909 .map_err(|reason| LatentSurvivalError::NumericalFailure { reason })?;
4910 derivative[row_idx] = -checked_weighted_row_value(
4911 wi,
4912 primary_gradient[LATENT_SURVIVAL_PRIMARY_QDOT_EXIT],
4913 row_idx,
4914 "derivative-offset score",
4915 )
4916 .map_err(|reason| LatentSurvivalError::NumericalFailure { reason })?;
4917 right[row_idx] = -checked_weighted_row_value(
4924 wi,
4925 primary_gradient[LATENT_SURVIVAL_PRIMARY_Q_RIGHT],
4926 row_idx,
4927 "right-offset score",
4928 )
4929 .map_err(|reason| LatentSurvivalError::NumericalFailure { reason })?;
4930 }
4931 Ok(crate::survival::OffsetChannelResiduals {
4932 exit,
4933 entry,
4934 derivative,
4935 right,
4936 })
4937 }
4938
4939 fn add_pullback_primary_block_diagonals(
4944 &self,
4945 row: usize,
4946 primary_hessian: &Array2<f64>,
4947 time_target: &mut Array2<f64>,
4948 mean_target: &mut Array2<f64>,
4949 log_sigma_target: Option<&mut Array2<f64>>,
4950 ) -> Result<(), String> {
4951 let h = primary_hessian;
4952 dense_outer_accumulate(
4956 time_target,
4957 h[[
4958 LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4959 LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4960 ]],
4961 self.x_time_entry.row(row),
4962 );
4963 dense_outer_accumulate(
4964 time_target,
4965 h[[
4966 LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4967 LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4968 ]],
4969 self.x_time_exit.row(row),
4970 );
4971 dense_outer_accumulate(
4972 time_target,
4973 h[[
4974 LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4975 LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4976 ]],
4977 self.x_time_derivative_exit.row(row),
4978 );
4979 dense_outer_accumulate(
4980 time_target,
4981 h[[
4982 LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
4983 LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
4984 ]],
4985 self.x_time_right.row(row),
4986 );
4987 for (a, b, lhs, rhs) in [
4988 (
4989 LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4990 LATENT_SURVIVAL_PRIMARY_Q_EXIT,
4991 &self.x_time_entry,
4992 &self.x_time_exit,
4993 ),
4994 (
4995 LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
4996 LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
4997 &self.x_time_entry,
4998 &self.x_time_derivative_exit,
4999 ),
5000 (
5001 LATENT_SURVIVAL_PRIMARY_Q_EXIT,
5002 LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
5003 &self.x_time_exit,
5004 &self.x_time_derivative_exit,
5005 ),
5006 (
5007 LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
5008 LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
5009 &self.x_time_entry,
5010 &self.x_time_right,
5011 ),
5012 (
5013 LATENT_SURVIVAL_PRIMARY_Q_EXIT,
5014 LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
5015 &self.x_time_exit,
5016 &self.x_time_right,
5017 ),
5018 (
5019 LATENT_SURVIVAL_PRIMARY_QDOT_EXIT,
5020 LATENT_SURVIVAL_PRIMARY_Q_RIGHT,
5021 &self.x_time_derivative_exit,
5022 &self.x_time_right,
5023 ),
5024 ] {
5025 let weight = h[[a, b]];
5026 if weight == 0.0 {
5027 continue;
5028 }
5029 dense_symmetric_cross_accumulate(time_target, weight, lhs.row(row), rhs.row(row));
5030 }
5031 let mean_weight = h[[LATENT_SURVIVAL_PRIMARY_MU, LATENT_SURVIVAL_PRIMARY_MU]];
5033 self.x_mean
5034 .syr_row_into_view(row, mean_weight, mean_target.view_mut())
5035 .map_err(|error| {
5036 format!(
5037 "latent survival mean block-diagonal pullback dimension mismatch: row={row}, mean_target_dim={:?}, x_mean_cols={}, error={error}",
5038 mean_target.dim(),
5039 self.x_mean.ncols()
5040 )
5041 })?;
5042 if let Some(target) = log_sigma_target {
5044 target[[0, 0]] += h[[
5045 LATENT_SURVIVAL_PRIMARY_LOG_SIGMA,
5046 LATENT_SURVIVAL_PRIMARY_LOG_SIGMA,
5047 ]];
5048 }
5049 Ok(())
5050 }
5051
5052 fn evaluate_exact_newton_block_diagonals(
5057 &self,
5058 block_states: &[ParameterBlockState],
5059 ) -> Result<
5060 (
5061 f64,
5062 Array1<f64>,
5063 Array2<f64>,
5064 Array2<f64>,
5065 Option<Array2<f64>>,
5066 ),
5067 String,
5068 > {
5069 let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-survival")
5070 .map_err(String::from)?;
5071 let (q_entry, q_exit, qdot_exit, mu) = self.split_time_eta(block_states)?;
5072 let q_right = self.time_q_right(block_states)?;
5073 let sigma = self.latent_sd(block_states)?;
5074 let slices = self.joint_slices();
5075 let include_log_sigma = slices.log_sigma.is_some();
5076 let mut ll = CompensatedRowSum::default();
5077 let mut gradient = Array1::<f64>::zeros(slices.total);
5078 let p_time = slices.time.len();
5079 let p_mean = slices.mean.len();
5080 let mut hess_time = Array2::<f64>::zeros((p_time, p_time));
5081 let mut hess_mean = Array2::<f64>::zeros((p_mean, p_mean));
5082 let mut hess_log_sigma = if include_log_sigma {
5083 Some(Array2::<f64>::zeros((1, 1)))
5084 } else {
5085 None
5086 };
5087 for row_idx in 0..self.event_target.len() {
5088 let wi = weights.at(row_idx);
5089 if wi == 0.0 {
5090 continue;
5091 }
5092 let row = self.build_row_at(
5093 row_idx,
5094 q_entry[row_idx],
5095 q_exit[row_idx],
5096 qdot_exit[row_idx],
5097 q_right[row_idx],
5098 )?;
5099 let (row_ll, primary_gradient, primary_hessian) =
5100 latent_survival_row_primary_gradient_hessian(
5101 &self.quadctx,
5102 &row,
5103 LatentSurvivalPrimaryPoint {
5104 q_entry: q_entry[row_idx],
5105 q_exit: q_exit[row_idx],
5106 qdot_exit: qdot_exit[row_idx],
5107 q_right: q_right[row_idx],
5108 mu: mu[row_idx],
5109 sigma,
5110 },
5111 include_log_sigma,
5112 )?;
5113 ll.add(checked_weighted_row_value(
5114 wi,
5115 row_ll,
5116 row_idx,
5117 "log likelihood",
5118 )?);
5119 self.add_pullback_primary_gradient(
5120 &mut gradient,
5121 row_idx,
5122 &slices,
5123 &primary_gradient,
5124 wi,
5125 )?;
5126 let weighted_primary_hessian =
5127 checked_weighted_row_matrix(wi, &primary_hessian, row_idx, "primary Hessian")?;
5128 self.add_pullback_primary_block_diagonals(
5129 row_idx,
5130 &weighted_primary_hessian,
5131 &mut hess_time,
5132 &mut hess_mean,
5133 hess_log_sigma.as_mut(),
5134 )?;
5135 }
5136 let ll = require_finite_likelihood_scalar(ll.value(), "log likelihood")?;
5137 require_finite_likelihood_vector(&gradient, "gradient")?;
5138 require_finite_likelihood_matrix(&hess_time, "time Hessian")?;
5139 require_finite_likelihood_matrix(&hess_mean, "mean Hessian")?;
5140 if let Some(hessian) = hess_log_sigma.as_ref() {
5141 require_finite_likelihood_matrix(hessian, "log-sigma Hessian")?;
5142 }
5143 Ok((ll, gradient, hess_time, hess_mean, hess_log_sigma))
5144 }
5145
5146 fn evaluate_exact_newton_joint_dense(
5147 &self,
5148 block_states: &[ParameterBlockState],
5149 ) -> Result<(f64, Array1<f64>, Array2<f64>), String> {
5150 let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-survival")
5151 .map_err(String::from)?;
5152 let (q_entry, q_exit, qdot_exit, mu) = self.split_time_eta(block_states)?;
5153 let q_right = self.time_q_right(block_states)?;
5154 let sigma = self.latent_sd(block_states)?;
5155 let slices = self.joint_slices();
5156 let include_log_sigma = slices.log_sigma.is_some();
5157 let total = slices.total;
5158 let acc = deterministic_latent_survival_row_reduction(
5159 self.event_target.len(),
5160 || LatentSurvivalJointDenseAccum {
5161 ll: CompensatedRowSum::default(),
5162 gradient: Array1::<f64>::zeros(total),
5163 hessian: Array2::<f64>::zeros((total, total)),
5164 },
5165 |row_idx, acc| {
5166 let wi = weights.at(row_idx);
5167 if wi == 0.0 {
5168 return Ok(());
5169 }
5170 let row = self.build_row_at(
5171 row_idx,
5172 q_entry[row_idx],
5173 q_exit[row_idx],
5174 qdot_exit[row_idx],
5175 q_right[row_idx],
5176 )?;
5177 let (row_ll, primary_gradient, primary_hessian) =
5178 latent_survival_row_primary_gradient_hessian(
5179 &self.quadctx,
5180 &row,
5181 LatentSurvivalPrimaryPoint {
5182 q_entry: q_entry[row_idx],
5183 q_exit: q_exit[row_idx],
5184 qdot_exit: qdot_exit[row_idx],
5185 q_right: q_right[row_idx],
5186 mu: mu[row_idx],
5187 sigma,
5188 },
5189 include_log_sigma,
5190 )?;
5191 acc.ll.add(checked_weighted_row_value(
5192 wi,
5193 row_ll,
5194 row_idx,
5195 "log likelihood",
5196 )?);
5197 self.add_pullback_primary_gradient(
5198 &mut acc.gradient,
5199 row_idx,
5200 &slices,
5201 &primary_gradient,
5202 wi,
5203 )?;
5204 let weighted_primary_hessian =
5205 checked_weighted_row_matrix(wi, &primary_hessian, row_idx, "primary Hessian")?;
5206 self.add_pullback_primary_hessian(
5207 &mut acc.hessian,
5208 row_idx,
5209 &slices,
5210 &weighted_primary_hessian,
5211 )?;
5212 Ok(())
5213 },
5214 |total_acc, chunk_acc| {
5215 total_acc.ll.add(chunk_acc.ll.value());
5216 total_acc.gradient += &chunk_acc.gradient;
5217 total_acc.hessian += &chunk_acc.hessian;
5218 },
5219 )?;
5220 let ll = require_finite_likelihood_scalar(acc.ll.value(), "log likelihood")?;
5221 require_finite_likelihood_vector(&acc.gradient, "gradient")?;
5222 require_finite_likelihood_matrix(&acc.hessian, "Hessian")?;
5223 Ok((ll, acc.gradient, acc.hessian))
5224 }
5225
5226 fn exact_newton_joint_hessian_directional_derivative_dense(
5227 &self,
5228 block_states: &[ParameterBlockState],
5229 d_beta_flat: &Array1<f64>,
5230 ) -> Result<Array2<f64>, String> {
5231 let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-survival")
5232 .map_err(String::from)?;
5233 let (q_entry, q_exit, qdot_exit, mu) = self.split_time_eta(block_states)?;
5234 let q_right = self.time_q_right(block_states)?;
5235 let sigma = self.latent_sd(block_states)?;
5236 let slices = self.joint_slices();
5237 if d_beta_flat.len() != slices.total {
5238 return Err(format!(
5239 "latent survival joint dH direction length mismatch: got {}, expected {}",
5240 d_beta_flat.len(),
5241 slices.total
5242 ));
5243 }
5244 let include_log_sigma = slices.log_sigma.is_some();
5245 let total = slices.total;
5246 let acc = deterministic_latent_survival_row_reduction(
5247 self.event_target.len(),
5248 || LatentSurvivalDenseHessianAccum {
5249 hessian: Array2::<f64>::zeros((total, total)),
5250 },
5251 |row_idx, acc| {
5252 let wi = weights.at(row_idx);
5253 if wi == 0.0 {
5254 return Ok(());
5255 }
5256 let row = self.build_row_at(
5257 row_idx,
5258 q_entry[row_idx],
5259 q_exit[row_idx],
5260 qdot_exit[row_idx],
5261 q_right[row_idx],
5262 )?;
5263 let direction = self.row_primary_direction_from_flat(row_idx, &slices, d_beta_flat);
5264 let third = latent_survival_row_primary_third_contracted(
5265 &self.quadctx,
5266 &row,
5267 LatentSurvivalPrimaryPoint {
5268 q_entry: q_entry[row_idx],
5269 q_exit: q_exit[row_idx],
5270 qdot_exit: qdot_exit[row_idx],
5271 q_right: q_right[row_idx],
5272 mu: mu[row_idx],
5273 sigma,
5274 },
5275 &direction,
5276 include_log_sigma,
5277 )?;
5278 let weighted_third =
5279 checked_weighted_row_matrix(wi, &third, row_idx, "contracted third")?;
5280 self.add_pullback_primary_hessian(
5281 &mut acc.hessian,
5282 row_idx,
5283 &slices,
5284 &weighted_third,
5285 )?;
5286 Ok(())
5287 },
5288 |total_acc, chunk_acc| {
5289 total_acc.hessian += &chunk_acc.hessian;
5290 },
5291 )?;
5292 require_finite_likelihood_matrix(&acc.hessian, "directional Hessian derivative")?;
5293 Ok(acc.hessian)
5294 }
5295
5296 fn exact_newton_joint_hessian_second_directional_derivative_dense(
5297 &self,
5298 block_states: &[ParameterBlockState],
5299 d_beta_u_flat: &Array1<f64>,
5300 d_beta_v_flat: &Array1<f64>,
5301 ) -> Result<Array2<f64>, String> {
5302 let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-survival")
5303 .map_err(String::from)?;
5304 let (q_entry, q_exit, qdot_exit, mu) = self.split_time_eta(block_states)?;
5305 let q_right = self.time_q_right(block_states)?;
5306 let sigma = self.latent_sd(block_states)?;
5307 let slices = self.joint_slices();
5308 if d_beta_u_flat.len() != slices.total || d_beta_v_flat.len() != slices.total {
5309 return Err(format!(
5310 "latent survival joint d2H direction length mismatch: got {} and {}, expected {}",
5311 d_beta_u_flat.len(),
5312 d_beta_v_flat.len(),
5313 slices.total
5314 ));
5315 }
5316 let include_log_sigma = slices.log_sigma.is_some();
5317 let total = slices.total;
5318 let acc = deterministic_latent_survival_row_reduction(
5319 self.event_target.len(),
5320 || LatentSurvivalDenseHessianAccum {
5321 hessian: Array2::<f64>::zeros((total, total)),
5322 },
5323 |row_idx, acc| {
5324 let wi = weights.at(row_idx);
5325 if wi == 0.0 {
5326 return Ok(());
5327 }
5328 let row = self.build_row_at(
5329 row_idx,
5330 q_entry[row_idx],
5331 q_exit[row_idx],
5332 qdot_exit[row_idx],
5333 q_right[row_idx],
5334 )?;
5335 let direction_u =
5336 self.row_primary_direction_from_flat(row_idx, &slices, d_beta_u_flat);
5337 let direction_v =
5338 self.row_primary_direction_from_flat(row_idx, &slices, d_beta_v_flat);
5339 let fourth = latent_survival_row_primary_fourth_contracted(
5340 &self.quadctx,
5341 &row,
5342 LatentSurvivalPrimaryPoint {
5343 q_entry: q_entry[row_idx],
5344 q_exit: q_exit[row_idx],
5345 qdot_exit: qdot_exit[row_idx],
5346 q_right: q_right[row_idx],
5347 mu: mu[row_idx],
5348 sigma,
5349 },
5350 &direction_u,
5351 &direction_v,
5352 include_log_sigma,
5353 )?;
5354 let weighted_fourth =
5355 checked_weighted_row_matrix(wi, &fourth, row_idx, "contracted fourth")?;
5356 self.add_pullback_primary_hessian(
5357 &mut acc.hessian,
5358 row_idx,
5359 &slices,
5360 &weighted_fourth,
5361 )?;
5362 Ok(())
5363 },
5364 |total_acc, chunk_acc| {
5365 total_acc.hessian += &chunk_acc.hessian;
5366 },
5367 )?;
5368 require_finite_likelihood_matrix(&acc.hessian, "second directional Hessian derivative")?;
5369 Ok(acc.hessian)
5370 }
5371}
5372
5373fn log_kernel_ratio(
5374 bundle: &crate::survival::lognormal_kernel::LogLognormalKernelBundle,
5375 num: usize,
5376 den: usize,
5377) -> f64 {
5378 let delta = bundle.get(num) - bundle.get(den);
5379 if delta.is_finite() {
5380 delta.exp()
5381 } else if delta > 0.0 {
5382 f64::INFINITY
5383 } else {
5384 0.0
5385 }
5386}
5387
5388fn logk_q_derivatives(
5389 quadctx: &QuadratureContext,
5390 k: usize,
5391 mass: f64,
5392 mu: f64,
5393 sigma: f64,
5394) -> Result<(f64, f64, IntegratedExpectationMode), LatentSurvivalError> {
5395 if mass <= 0.0 {
5396 return Ok((0.0, 0.0, IntegratedExpectationMode::ExactClosedForm));
5397 }
5398 let bundle = log_kernel_bundle(quadctx, mass, mu, sigma, k + 2).map_err(|e| {
5399 LatentSurvivalError::NumericalFailure {
5400 reason: format!("latent survival kernel evaluation failed: {e}"),
5401 }
5402 })?;
5403 let r1 = log_kernel_ratio(&bundle, k + 1, k);
5404 let r2 = log_kernel_ratio(&bundle, k + 2, k);
5405 let d1 = -mass * r1;
5406 let second_cumulant = match bundle.second_cumulant_ratio(k, sigma) {
5424 Some(value) => value,
5425 None => {
5426 log::warn!(
5427 "[#2610] cancellation-free second cumulant unavailable at k={k} \
5428 (sigma={sigma:.6e}, mass={mass:.6e}); falling back to the \
5429 differencing form, whose relative error grows without bound past \
5430 log sigma ~ 5.4"
5431 );
5432 r2 - r1 * r1
5433 }
5434 };
5435 let d2 = d1 + mass * mass * second_cumulant;
5436 Ok((d1, d2, bundle.mode))
5437}
5438
5439fn latent_survival_time_jet(
5440 quadctx: &QuadratureContext,
5441 row: &LatentSurvivalRow,
5442 qdot_exit: f64,
5443 mu: f64,
5444 sigma: f64,
5445) -> Result<LatentSurvivalTimeJet, LatentSurvivalError> {
5446 let (entry_d1, entry_d2, _) = logk_q_derivatives(quadctx, 0, row.mass_entry, mu, sigma)?;
5447 match row.event_type {
5448 LatentSurvivalEventType::RightCensored => {
5449 let (exit_d1, exit_d2, _) = logk_q_derivatives(quadctx, 0, row.mass_exit, mu, sigma)?;
5450 Ok(LatentSurvivalTimeJet {
5451 grad_entry: -entry_d1,
5452 grad_exit: exit_d1,
5453 neg_hess_entry: entry_d2,
5454 neg_hess_exit: -exit_d2,
5455 })
5456 }
5457 LatentSurvivalEventType::ExactEvent => {
5458 if !(qdot_exit.is_finite() && qdot_exit > 0.0) {
5459 return Err(LatentSurvivalError::NumericalFailure {
5460 reason: format!(
5461 "latent survival requires positive finite baseline hazard derivative, got {qdot_exit}"
5462 ),
5463 });
5464 }
5465 if row.hazard_unloaded > 0.0 {
5466 let bundle =
5467 log_kernel_bundle(quadctx, row.mass_exit, mu, sigma, 3).map_err(|e| {
5468 LatentSurvivalError::NumericalFailure {
5469 reason: format!("latent survival kernel evaluation failed: {e}"),
5470 }
5471 })?;
5472 let (unloaded_d1, unloaded_d2, _) =
5473 logk_q_derivatives(quadctx, 0, row.mass_exit, mu, sigma)?;
5474 let (loaded_log_d1, loaded_d2, _) =
5475 logk_q_derivatives(quadctx, 1, row.mass_exit, mu, sigma)?;
5476 let loaded_d1 = 1.0 + loaded_log_d1;
5477 let log_loaded = row.hazard_loaded.ln() + bundle.get(1);
5478 let log_unloaded = row.hazard_unloaded.ln() + bundle.get(0);
5479 let shift = log_loaded.max(log_unloaded);
5480 let loaded_weight = (log_loaded - shift).exp();
5481 let unloaded_weight = (log_unloaded - shift).exp();
5482 let normalizer = loaded_weight + unloaded_weight;
5483 if !(normalizer.is_finite() && normalizer > 0.0) {
5484 return Err(LatentSurvivalError::NumericalFailure {
5485 reason: "latent survival exact-event numerator became non-finite under loaded/unloaded hazard decomposition"
5486 .to_string(),
5487 });
5488 }
5489 let w_loaded = loaded_weight / normalizer;
5490 let w_unloaded = unloaded_weight / normalizer;
5491 let grad_exit = w_loaded * loaded_d1 + w_unloaded * unloaded_d1;
5492 let d2_exit = w_loaded * (loaded_d2 + loaded_d1 * loaded_d1)
5493 + w_unloaded * (unloaded_d2 + unloaded_d1 * unloaded_d1)
5494 - grad_exit * grad_exit;
5495 Ok(LatentSurvivalTimeJet {
5496 grad_entry: -entry_d1,
5497 grad_exit,
5498 neg_hess_entry: entry_d2,
5499 neg_hess_exit: -d2_exit,
5500 })
5501 } else {
5502 let (exit_d1, exit_d2, _) =
5503 logk_q_derivatives(quadctx, 1, row.mass_exit, mu, sigma)?;
5504 Ok(LatentSurvivalTimeJet {
5505 grad_entry: -entry_d1,
5506 grad_exit: 1.0 + exit_d1,
5507 neg_hess_entry: entry_d2,
5508 neg_hess_exit: -exit_d2,
5509 })
5510 }
5511 }
5512 LatentSurvivalEventType::IntervalCensored => {
5513 Err(LatentSurvivalError::UnsupportedConfiguration {
5514 reason:
5515 "latent survival dynamic time derivatives do not implement interval censoring"
5516 .to_string(),
5517 })
5518 }
5519 }
5520}
5521
5522fn dense_outer_accumulate<S>(
5523 target: &mut ndarray::ArrayBase<S, ndarray::Ix2>,
5524 weight: f64,
5525 x: ArrayView1<'_, f64>,
5526) where
5527 S: ndarray::DataMut<Elem = f64>,
5528{
5529 for a in 0..x.len() {
5530 let xa = x[a];
5531 if xa == 0.0 {
5532 continue;
5533 }
5534 for b in 0..x.len() {
5535 let xb = x[b];
5536 if xb == 0.0 {
5537 continue;
5538 }
5539 target[[a, b]] += weight * xa * xb;
5540 }
5541 }
5542}
5543
5544fn dense_symmetric_cross_accumulate<S>(
5545 target: &mut ndarray::ArrayBase<S, ndarray::Ix2>,
5546 weight: f64,
5547 x: ArrayView1<'_, f64>,
5548 y: ArrayView1<'_, f64>,
5549) where
5550 S: ndarray::DataMut<Elem = f64>,
5551{
5552 for a in 0..x.len() {
5553 let xa = x[a];
5554 let ya = y[a];
5555 if xa == 0.0 && ya == 0.0 {
5556 continue;
5557 }
5558 for b in 0..x.len() {
5559 let xb = x[b];
5560 let yb = y[b];
5561 let contribution = xa * yb + ya * xb;
5562 if contribution == 0.0 {
5563 continue;
5564 }
5565 target[[a, b]] += weight * contribution;
5566 }
5567 }
5568}
5569
5570fn build_latent_survival_row(
5571 row_index: usize,
5572 hazard_loading: HazardLoading,
5573 event_type: LatentSurvivalEventType,
5574 q_entry: f64,
5575 q_exit: f64,
5576 qdot_exit: f64,
5577 q_right: f64,
5578 unloaded_mass_entry: f64,
5579 unloaded_mass_exit: f64,
5580 unloaded_mass_right: f64,
5581 unloaded_hazard_exit: f64,
5582) -> Result<LatentSurvivalRow, LatentSurvivalError> {
5583 if !(q_entry.is_finite() && q_exit.is_finite()) {
5584 return Err(LatentSurvivalError::NumericalFailure {
5585 reason: format!(
5586 "latent survival requires finite q_entry and q_exit, got q_entry={q_entry}, q_exit={q_exit}"
5587 ),
5588 });
5589 }
5590 if q_exit < q_entry {
5591 return Err(LatentSurvivalError::NumericalFailure {
5592 reason: format!(
5593 "latent survival requires q_exit >= q_entry so cumulative mass is monotone, got q_entry={q_entry}, q_exit={q_exit}"
5594 ),
5595 });
5596 }
5597 if !(unloaded_mass_entry.is_finite()
5598 && unloaded_mass_exit.is_finite()
5599 && unloaded_hazard_exit.is_finite())
5600 {
5601 return Err(LatentSurvivalError::InvalidDataset {
5602 reason: format!(
5603 "latent survival requires finite unloaded components, got entry_mass={unloaded_mass_entry}, exit_mass={unloaded_mass_exit}, exit_hazard={unloaded_hazard_exit}"
5604 ),
5605 });
5606 }
5607 if unloaded_mass_entry < 0.0
5608 || unloaded_mass_exit < unloaded_mass_entry
5609 || unloaded_hazard_exit < 0.0
5610 {
5611 return Err(LatentSurvivalError::InvalidDataset {
5612 reason: format!(
5613 "latent survival requires unloaded masses/hazard to be non-negative and monotone, got entry_mass={unloaded_mass_entry}, exit_mass={unloaded_mass_exit}, exit_hazard={unloaded_hazard_exit}"
5614 ),
5615 });
5616 }
5617 let mass_entry = q_entry.exp();
5618 let mass_exit = q_exit.exp();
5619 let row = match event_type {
5620 LatentSurvivalEventType::RightCensored => {
5621 validate_unloaded_components_for_loading(
5622 "latent-survival",
5623 row_index,
5624 hazard_loading,
5625 unloaded_mass_entry,
5626 unloaded_mass_exit,
5627 Some(unloaded_hazard_exit),
5628 )?;
5629 LatentSurvivalRow::right_censored(
5630 mass_entry,
5631 mass_exit,
5632 unloaded_mass_entry,
5633 unloaded_mass_exit,
5634 )
5635 }
5636 LatentSurvivalEventType::ExactEvent => {
5637 validate_unloaded_components_for_loading(
5638 "latent-survival",
5639 row_index,
5640 hazard_loading,
5641 unloaded_mass_entry,
5642 unloaded_mass_exit,
5643 Some(unloaded_hazard_exit),
5644 )?;
5645 LatentSurvivalRow::exact_event(
5646 mass_entry,
5647 mass_exit,
5648 unloaded_mass_entry,
5649 unloaded_mass_exit,
5650 mass_exit
5651 * if qdot_exit.is_finite() && qdot_exit > 0.0 {
5652 qdot_exit
5653 } else {
5654 return Err(LatentSurvivalError::NumericalFailure {
5655 reason: format!(
5656 "latent survival exact event requires positive finite baseline hazard derivative, got {qdot_exit}"
5657 ),
5658 });
5659 },
5660 unloaded_hazard_exit,
5661 )
5662 }
5663 LatentSurvivalEventType::IntervalCensored => {
5664 if !q_right.is_finite() {
5672 return Err(LatentSurvivalError::NumericalFailure {
5673 reason: format!(
5674 "latent survival interval row {} requires a finite q_right, got {q_right}",
5675 row_index + 1
5676 ),
5677 });
5678 }
5679 if q_right < q_exit {
5680 return Err(LatentSurvivalError::NumericalFailure {
5681 reason: format!(
5682 "latent survival interval row {} requires q_right >= q_exit (R >= L) so the \
5683 survival-mass difference is non-negative, got q_left={q_exit}, q_right={q_right}",
5684 row_index + 1
5685 ),
5686 });
5687 }
5688 if !(unloaded_mass_right.is_finite()) || unloaded_mass_right < unloaded_mass_exit {
5689 return Err(LatentSurvivalError::InvalidDataset {
5690 reason: format!(
5691 "latent survival interval row {} requires a finite unloaded right mass >= unloaded left mass, got left={unloaded_mass_exit}, right={unloaded_mass_right}",
5692 row_index + 1
5693 ),
5694 });
5695 }
5696 let mass_right = q_right.exp();
5700 LatentSurvivalRow::interval_censored(
5701 mass_entry,
5702 mass_exit,
5703 mass_right,
5704 unloaded_mass_entry,
5705 unloaded_mass_exit,
5706 unloaded_mass_right,
5707 )
5708 }
5709 };
5710 row.validate()
5711 .map_err(|e| LatentSurvivalError::InvalidDataset {
5712 reason: e.to_string(),
5713 })?;
5714 Ok(row)
5715}
5716
5717#[derive(Clone, Copy, Debug)]
5718struct BinaryFromLogSurvival {
5719 log_lik: f64,
5720 grad_scale: f64,
5723 neg_hess_scale: f64,
5732 outer_scale: f64,
5734}
5735
5736fn binary_log_likelihood_from_log_survival(
5743 log_survival: f64,
5744 event: u8,
5745) -> Result<f64, LatentSurvivalError> {
5746 match event {
5747 0 => {
5748 if !log_survival.is_finite() || log_survival > 0.0 {
5749 return Err(LatentSurvivalError::NumericalFailure {
5750 reason: format!(
5751 "latent-binary requires finite log survival <= 0 for a censored row, got {log_survival:?}"
5752 ),
5753 });
5754 }
5755 Ok(log_survival)
5756 }
5757 1 => {
5758 if !log_survival.is_finite() || log_survival >= 0.0 {
5759 return Err(LatentSurvivalError::NumericalFailure {
5760 reason: format!(
5761 "latent-binary requires finite log survival < 0 for an observed event, got {log_survival:?}"
5762 ),
5763 });
5764 }
5765 let event_prob = -log_survival.exp_m1();
5766 if !(event_prob.is_finite() && event_prob > 0.0) {
5767 return Err(LatentSurvivalError::NumericalFailure {
5768 reason: format!(
5769 "latent-binary event probability is not representable from log survival {log_survival:?}"
5770 ),
5771 });
5772 }
5773 Ok(event_prob.ln())
5774 }
5775 _ => Err(LatentSurvivalError::InvalidDataset {
5776 reason: format!("latent-binary requires event targets in {{0,1}}, got {event}"),
5777 }),
5778 }
5779}
5780
5781fn binary_from_log_survival_through_first(
5783 log_survival: f64,
5784 event: u8,
5785) -> Result<(f64, f64), LatentSurvivalError> {
5786 let log_lik = binary_log_likelihood_from_log_survival(log_survival, event)?;
5787 if event == 0 {
5788 return Ok((log_lik, 1.0));
5789 }
5790 let odds = (log_survival - log_lik).exp();
5791 if !odds.is_finite() {
5792 return Err(LatentSurvivalError::NumericalFailure {
5793 reason: format!(
5794 "latent-binary log-survival derivative order 1 is not representable at {log_survival:?}: {odds:?}"
5795 ),
5796 });
5797 }
5798 Ok((log_lik, -odds))
5799}
5800
5801fn binary_log_survival_scales(log_survival: f64) -> Result<(f64, f64, f64), LatentSurvivalError> {
5813 let (log_lik, ell_prime) = binary_from_log_survival_through_first(log_survival, 1)?;
5814 let odds = -ell_prime;
5815 let one_plus_odds = 1.0 + odds;
5816 let ell_pp = -odds * one_plus_odds;
5817 let scales = [log_lik, ell_prime, ell_pp];
5818 if let Some((order, value)) = scales
5819 .iter()
5820 .enumerate()
5821 .find(|(_, value)| !value.is_finite())
5822 {
5823 return Err(LatentSurvivalError::NumericalFailure {
5824 reason: format!(
5825 "latent-binary log-survival derivative order {order} is not representable at {log_survival:?}: {value:?}"
5826 ),
5827 });
5828 }
5829 Ok((log_lik, ell_prime, ell_pp))
5830}
5831
5832fn binary_from_log_survival(
5833 log_survival: f64,
5834 event: u8,
5835) -> Result<BinaryFromLogSurvival, LatentSurvivalError> {
5836 if event == 0 {
5837 return Ok(BinaryFromLogSurvival {
5839 log_lik: binary_log_likelihood_from_log_survival(log_survival, event)?,
5840 grad_scale: 1.0,
5841 neg_hess_scale: 1.0,
5842 outer_scale: 0.0,
5843 });
5844 }
5845 if event != 1 {
5846 return Err(LatentSurvivalError::InvalidDataset {
5847 reason: format!("latent-binary requires event targets in {{0,1}}, got {event}"),
5848 });
5849 }
5850 let (log_lik, ell_prime, ell_pp) = binary_log_survival_scales(log_survival)?;
5851 let grad_scale = ell_prime;
5852 let neg_hess_scale = ell_prime; let outer_scale = -ell_pp;
5854 assert!(
5859 (grad_scale - neg_hess_scale).abs() <= 1e-15 * grad_scale.abs().max(1.0),
5860 "binary_from_log_survival invariant: neg_hess_scale ({neg_hess_scale}) must equal grad_scale ({grad_scale}) so that grad_scale and the coefficient on neg_hessian share sign"
5861 );
5862 assert!(
5863 outer_scale >= 0.0 || !outer_scale.is_finite(),
5864 "binary_from_log_survival invariant: outer_scale (= -ℓ'') must be non-negative for event=1; got {outer_scale}"
5865 );
5866 Ok(BinaryFromLogSurvival {
5867 log_lik,
5868 grad_scale,
5869 neg_hess_scale,
5870 outer_scale,
5871 })
5872}
5873
5874fn binary_from_log_survival_through_third(
5878 log_survival: f64,
5879 event: u8,
5880) -> Result<(BinaryFromLogSurvival, f64), LatentSurvivalError> {
5881 let base = binary_from_log_survival(log_survival, event)?;
5882 if event == 0 {
5883 return Ok((base, 0.0));
5884 }
5885 let odds = -base.grad_scale;
5886 let ell_pp = -base.outer_scale;
5887 let ell_ppp = ell_pp * (1.0 + 2.0 * odds);
5888 if !ell_ppp.is_finite() {
5889 return Err(LatentSurvivalError::NumericalFailure {
5890 reason: format!(
5891 "latent-binary log-survival derivative order 3 is not representable at {log_survival:?}: {ell_ppp:?}"
5892 ),
5893 });
5894 }
5895 Ok((base, -ell_ppp))
5896}
5897
5898fn binary_from_log_survival_through_fourth(
5902 log_survival: f64,
5903 event: u8,
5904) -> Result<(BinaryFromLogSurvival, f64, f64), LatentSurvivalError> {
5905 let (base, outer_scale_prime) = binary_from_log_survival_through_third(log_survival, event)?;
5906 if event == 0 {
5907 return Ok((base, 0.0, 0.0));
5908 }
5909 let odds = -base.grad_scale;
5910 let ell_pp = -base.outer_scale;
5911 let ell_pppp = ell_pp * (1.0 + 6.0 * odds + 6.0 * odds * odds);
5912 if !ell_pppp.is_finite() {
5913 return Err(LatentSurvivalError::NumericalFailure {
5914 reason: format!(
5915 "latent-binary log-survival derivative order 4 is not representable at {log_survival:?}: {ell_pppp:?}"
5916 ),
5917 });
5918 }
5919 Ok((base, outer_scale_prime, -ell_pppp))
5920}
5921
5922#[derive(Clone, Copy, Debug)]
5929pub enum LatentSurvivalAloSigma {
5930 Fixed(f64),
5931 LearnedLogScale(f64),
5932}
5933
5934pub struct LatentSurvivalAloRowInput<'a> {
5936 pub quadrature: &'a QuadratureContext,
5937 pub hazard_loading: HazardLoading,
5938 pub event_code: u8,
5939 pub prior_weight: f64,
5940 pub q_entry: f64,
5941 pub q_exit: f64,
5942 pub qdot_exit: f64,
5943 pub q_right: f64,
5944 pub mu: f64,
5945 pub sigma: LatentSurvivalAloSigma,
5946 pub unloaded_mass_entry: f64,
5947 pub unloaded_mass_exit: f64,
5948 pub unloaded_mass_right: f64,
5949 pub unloaded_hazard_exit: f64,
5950}
5951
5952pub struct LatentBinaryAloRowInput<'a> {
5955 pub quadrature: &'a QuadratureContext,
5956 pub hazard_loading: HazardLoading,
5957 pub event: u8,
5958 pub prior_weight: f64,
5959 pub q_entry: f64,
5960 pub q_exit: f64,
5961 pub mu: f64,
5962 pub sigma: f64,
5963 pub unloaded_mass_entry: f64,
5964 pub unloaded_mass_exit: f64,
5965}
5966
5967pub struct LatentWindowAloRowGeometry {
5969 pub nll_score: Array1<f64>,
5970 pub observed_hessian: Array2<f64>,
5971 pub coordinate_values: Array1<f64>,
5972}
5973
5974fn validate_saved_alo_weight(weight: f64, context: &str) -> Result<(), String> {
5975 if weight.is_finite() && weight >= 0.0 {
5976 Ok(())
5977 } else {
5978 Err(format!(
5979 "{context} prior weight must be finite and non-negative, got {weight}"
5980 ))
5981 }
5982}
5983
5984fn checked_saved_alo_scale_vector(
5985 values: Array1<f64>,
5986 scale: f64,
5987 context: &str,
5988) -> Result<Array1<f64>, String> {
5989 let mut out = Array1::<f64>::zeros(values.len());
5990 for (axis, value) in values.into_iter().enumerate() {
5991 let product = scale * value;
5992 if !product.is_finite() || (scale != 0.0 && value != 0.0 && product == 0.0) {
5993 return Err(format!(
5994 "{context}[{axis}] is not representable: {scale:?} * {value:?}"
5995 ));
5996 }
5997 out[axis] = product;
5998 }
5999 Ok(out)
6000}
6001
6002fn checked_saved_alo_scale_matrix(
6003 values: Array2<f64>,
6004 scale: f64,
6005 context: &str,
6006) -> Result<Array2<f64>, String> {
6007 let mut out = Array2::<f64>::zeros(values.dim());
6008 for ((row, column), value) in values.indexed_iter() {
6009 let product = scale * value;
6010 if !product.is_finite() || (scale != 0.0 && *value != 0.0 && product == 0.0) {
6011 return Err(format!(
6012 "{context}[{row},{column}] is not representable: {scale:?} * {value:?}"
6013 ));
6014 }
6015 out[[row, column]] = product;
6016 }
6017 Ok(out)
6018}
6019
6020pub fn latent_survival_alo_row_geometry(
6028 input: LatentSurvivalAloRowInput<'_>,
6029) -> Result<LatentWindowAloRowGeometry, String> {
6030 validate_saved_alo_weight(input.prior_weight, "latent-survival ALO")?;
6031 let mut coordinate_values = vec![
6032 input.q_entry,
6033 input.q_exit,
6034 input.qdot_exit,
6035 input.q_right,
6036 input.mu,
6037 ];
6038 let (sigma, include_log_sigma) = match input.sigma {
6039 LatentSurvivalAloSigma::Fixed(sigma) => (sigma, false),
6040 LatentSurvivalAloSigma::LearnedLogScale(log_sigma) => {
6041 coordinate_values.push(log_sigma);
6042 (log_sigma.exp(), true)
6043 }
6044 };
6045 let coordinate_values = Array1::from_vec(coordinate_values);
6046 let dimension = coordinate_values.len();
6047 if input.prior_weight == 0.0 {
6048 return Ok(LatentWindowAloRowGeometry {
6049 nll_score: Array1::zeros(dimension),
6050 observed_hessian: Array2::zeros((dimension, dimension)),
6051 coordinate_values,
6052 });
6053 }
6054 if !matches!(input.event_code, 0 | 1 | LATENT_SURVIVAL_EVENT_INTERVAL) {
6055 return Err(format!(
6056 "latent-survival ALO event code must be 0, 1, or the interval sentinel {LATENT_SURVIVAL_EVENT_INTERVAL}, got {}",
6057 input.event_code
6058 ));
6059 }
6060 if !sigma.is_finite()
6061 || sigma < 0.0
6062 || (include_log_sigma && (sigma == 0.0 || !coordinate_values[5].is_finite()))
6063 {
6064 return Err(format!(
6065 "latent-survival ALO frailty scale is invalid: sigma={sigma:?}, learned={include_log_sigma}"
6066 ));
6067 }
6068 if coordinate_values.iter().any(|value| !value.is_finite()) {
6069 return Err("latent-survival ALO affine coordinates must be finite".to_string());
6070 }
6071 let event_type = latent_survival_event_type_for(input.event_code);
6072 let row = build_latent_survival_row(
6073 0,
6074 input.hazard_loading,
6075 event_type,
6076 input.q_entry,
6077 input.q_exit,
6078 input.qdot_exit,
6079 input.q_right,
6080 input.unloaded_mass_entry,
6081 input.unloaded_mass_exit,
6082 input.unloaded_mass_right,
6083 input.unloaded_hazard_exit,
6084 )
6085 .map_err(String::from)?;
6086 let (_, log_likelihood_score, negative_log_likelihood_hessian) =
6087 latent_survival_row_primary_gradient_hessian(
6088 input.quadrature,
6089 &row,
6090 LatentSurvivalPrimaryPoint {
6091 q_entry: input.q_entry,
6092 q_exit: input.q_exit,
6093 qdot_exit: input.qdot_exit,
6094 q_right: input.q_right,
6095 mu: input.mu,
6096 sigma,
6097 },
6098 include_log_sigma,
6099 )?;
6100 let nll_score = checked_saved_alo_scale_vector(
6101 log_likelihood_score.slice(s![0..dimension]).to_owned(),
6102 -input.prior_weight,
6103 "latent-survival ALO NLL score",
6104 )?;
6105 let observed_hessian = checked_saved_alo_scale_matrix(
6106 negative_log_likelihood_hessian
6107 .slice(s![0..dimension, 0..dimension])
6108 .to_owned(),
6109 input.prior_weight,
6110 "latent-survival ALO observed Hessian",
6111 )?;
6112 Ok(LatentWindowAloRowGeometry {
6113 nll_score,
6114 observed_hessian,
6115 coordinate_values,
6116 })
6117}
6118
6119pub fn latent_binary_alo_row_geometry(
6124 input: LatentBinaryAloRowInput<'_>,
6125) -> Result<LatentWindowAloRowGeometry, String> {
6126 validate_saved_alo_weight(input.prior_weight, "latent-binary ALO")?;
6127 let coordinate_values = Array1::from_vec(vec![input.q_entry, input.q_exit, input.mu]);
6128 const DIMENSION: usize = 3;
6129 if input.prior_weight == 0.0 {
6130 return Ok(LatentWindowAloRowGeometry {
6131 nll_score: Array1::zeros(DIMENSION),
6132 observed_hessian: Array2::zeros((DIMENSION, DIMENSION)),
6133 coordinate_values,
6134 });
6135 }
6136 if input.event > 1 {
6137 return Err(format!(
6138 "latent-binary ALO event must be 0 or 1, got {}",
6139 input.event
6140 ));
6141 }
6142 if !input.sigma.is_finite() || input.sigma < 0.0 {
6143 return Err(format!(
6144 "latent-binary ALO frailty sigma must be finite and non-negative, got {:?}",
6145 input.sigma
6146 ));
6147 }
6148 if coordinate_values.iter().any(|value| !value.is_finite()) {
6149 return Err("latent-binary ALO affine coordinates must be finite".to_string());
6150 }
6151 let row = build_latent_survival_row(
6152 0,
6153 input.hazard_loading,
6154 LatentSurvivalEventType::RightCensored,
6155 input.q_entry,
6156 input.q_exit,
6157 1.0,
6158 input.q_exit,
6159 input.unloaded_mass_entry,
6160 input.unloaded_mass_exit,
6161 0.0,
6162 0.0,
6163 )
6164 .map_err(String::from)?;
6165 let (log_survival, survival_score, survival_negative_hessian) =
6166 latent_survival_row_primary_gradient_hessian(
6167 input.quadrature,
6168 &row,
6169 LatentSurvivalPrimaryPoint {
6170 q_entry: input.q_entry,
6171 q_exit: input.q_exit,
6172 qdot_exit: 1.0,
6173 q_right: input.q_exit,
6174 mu: input.mu,
6175 sigma: input.sigma,
6176 },
6177 false,
6178 )?;
6179 let binary = binary_from_log_survival(log_survival, input.event).map_err(String::from)?;
6180 let primary_indices = [
6181 LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
6182 LATENT_SURVIVAL_PRIMARY_Q_EXIT,
6183 LATENT_SURVIVAL_PRIMARY_MU,
6184 ];
6185 let binary_log_likelihood_score = Array1::from_shape_fn(DIMENSION, |axis| {
6186 binary.grad_scale * survival_score[primary_indices[axis]]
6187 });
6188 let binary_negative_log_likelihood_hessian =
6189 Array2::from_shape_fn((DIMENSION, DIMENSION), |(left, right)| {
6190 let source_left = primary_indices[left];
6191 let source_right = primary_indices[right];
6192 binary.neg_hess_scale * survival_negative_hessian[[source_left, source_right]]
6193 + binary.outer_scale * survival_score[source_left] * survival_score[source_right]
6194 });
6195 let nll_score = checked_saved_alo_scale_vector(
6196 binary_log_likelihood_score,
6197 -input.prior_weight,
6198 "latent-binary ALO NLL score",
6199 )?;
6200 let observed_hessian = checked_saved_alo_scale_matrix(
6201 binary_negative_log_likelihood_hessian,
6202 input.prior_weight,
6203 "latent-binary ALO observed Hessian",
6204 )?;
6205 Ok(LatentWindowAloRowGeometry {
6206 nll_score,
6207 observed_hessian,
6208 coordinate_values,
6209 })
6210}
6211
6212impl LatentBinaryFamily {
6213 fn build_right_censored_row_at(
6219 &self,
6220 row_idx: usize,
6221 q_entry: f64,
6222 q_exit: f64,
6223 ) -> Result<LatentSurvivalRow, LatentSurvivalError> {
6224 build_latent_survival_row(
6225 row_idx,
6226 self.hazard_loading,
6227 LatentSurvivalEventType::RightCensored,
6228 q_entry,
6229 q_exit,
6230 1.0,
6231 q_exit,
6232 self.unloaded_mass_entry[row_idx],
6233 self.unloaded_mass_exit[row_idx],
6234 0.0,
6235 0.0,
6236 )
6237 }
6238
6239 fn joint_slices(&self) -> LatentSurvivalJointSlices {
6240 let p_time = self.x_time_exit.ncols();
6241 let p_mean = self.x_mean.ncols();
6242 LatentSurvivalJointSlices {
6243 time: 0..p_time,
6244 mean: p_time..p_time + p_mean,
6245 log_sigma: None,
6246 total: p_time + p_mean,
6247 }
6248 }
6249
6250 fn row_primary_direction_from_flat(
6251 &self,
6252 row: usize,
6253 slices: &LatentSurvivalJointSlices,
6254 d_beta_flat: &Array1<f64>,
6255 ) -> Array1<f64> {
6256 let mut out = Array1::<f64>::zeros(LATENT_SURVIVAL_PRIMARY_DIM);
6257 let d_time = d_beta_flat.slice(s![slices.time.clone()]);
6258 out[LATENT_SURVIVAL_PRIMARY_Q_ENTRY] = self.x_time_entry.row(row).dot(&d_time);
6259 out[LATENT_SURVIVAL_PRIMARY_Q_EXIT] = self.x_time_exit.row(row).dot(&d_time);
6260 out[LATENT_SURVIVAL_PRIMARY_MU] = self
6261 .x_mean
6262 .dot_row_view(row, d_beta_flat.slice(s![slices.mean.clone()]));
6263 out
6264 }
6265
6266 fn add_pullback_primary_gradient(
6267 &self,
6268 target: &mut Array1<f64>,
6269 row: usize,
6270 slices: &LatentSurvivalJointSlices,
6271 primary_gradient: &Array1<f64>,
6272 weight: f64,
6273 ) -> Result<(), String> {
6274 for (primary_idx, time_vec) in [
6275 (LATENT_SURVIVAL_PRIMARY_Q_ENTRY, self.x_time_entry.row(row)),
6276 (LATENT_SURVIVAL_PRIMARY_Q_EXIT, self.x_time_exit.row(row)),
6277 ] {
6278 let scale = checked_weighted_row_value(
6279 weight,
6280 primary_gradient[primary_idx],
6281 row,
6282 "binary primary gradient",
6283 )?;
6284 if scale == 0.0 {
6285 continue;
6286 }
6287 for i in 0..time_vec.len() {
6288 let xi = time_vec[i];
6289 if xi != 0.0 {
6290 target[slices.time.start + i] += scale * xi;
6291 }
6292 }
6293 }
6294
6295 let mean_scale = checked_weighted_row_value(
6296 weight,
6297 primary_gradient[LATENT_SURVIVAL_PRIMARY_MU],
6298 row,
6299 "binary mean gradient",
6300 )?;
6301 if mean_scale != 0.0 {
6302 self.x_mean
6303 .axpy_row_into(
6304 row,
6305 mean_scale,
6306 &mut target.slice_mut(s![slices.mean.clone()]),
6307 )
6308 .map_err(|error| {
6309 format!(
6310 "latent binary mean gradient pullback dimension mismatch: row={row}, mean_slice={:?}, target_len={}, x_mean_cols={}, error={error}",
6311 slices.mean,
6312 target.len(),
6313 self.x_mean.ncols()
6314 )
6315 })?;
6316 }
6317 Ok(())
6318 }
6319
6320 fn add_pullback_primary_hessian(
6321 &self,
6322 target: &mut Array2<f64>,
6323 row: usize,
6324 slices: &LatentSurvivalJointSlices,
6325 primary_hessian: &Array2<f64>,
6326 ) {
6327 {
6328 let time_target = &mut target.slice_mut(s![slices.time.clone(), slices.time.clone()]);
6329 dense_outer_accumulate(
6330 time_target,
6331 primary_hessian[[
6332 LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
6333 LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
6334 ]],
6335 self.x_time_entry.row(row),
6336 );
6337 dense_outer_accumulate(
6338 time_target,
6339 primary_hessian[[
6340 LATENT_SURVIVAL_PRIMARY_Q_EXIT,
6341 LATENT_SURVIVAL_PRIMARY_Q_EXIT,
6342 ]],
6343 self.x_time_exit.row(row),
6344 );
6345 dense_symmetric_cross_accumulate(
6346 time_target,
6347 primary_hessian[[
6348 LATENT_SURVIVAL_PRIMARY_Q_ENTRY,
6349 LATENT_SURVIVAL_PRIMARY_Q_EXIT,
6350 ]],
6351 self.x_time_entry.row(row),
6352 self.x_time_exit.row(row),
6353 );
6354 }
6355
6356 let mean_weight = primary_hessian[[LATENT_SURVIVAL_PRIMARY_MU, LATENT_SURVIVAL_PRIMARY_MU]];
6357 self.x_mean
6358 .syr_row_into_view(
6359 row,
6360 mean_weight,
6361 target.slice_mut(s![slices.mean.clone(), slices.mean.clone()]),
6362 )
6363 .unwrap_or_else(|error| {
6364 panic!(
6370 "latent binary mean Hessian pullback dimension mismatch: row={row}, mean_slice={:?}, target_dim={:?}, x_mean_cols={}, error={error}",
6371 slices.mean,
6372 target.dim(),
6373 self.x_mean.ncols()
6374 )
6375 });
6376
6377 let mean_row = self
6378 .x_mean
6379 .try_row_chunk(row..row + 1)
6380 .unwrap_or_else(|error| {
6381 panic!(
6385 "latent binary mean pullback row chunk failed: row={row}, x_mean_rows={}, x_mean_cols={}, error={error}",
6386 self.x_mean.nrows(),
6387 self.x_mean.ncols()
6388 )
6389 });
6390 let mean_vec = mean_row.row(0);
6391 for (primary_idx, time_vec) in [
6392 (LATENT_SURVIVAL_PRIMARY_Q_ENTRY, self.x_time_entry.row(row)),
6393 (LATENT_SURVIVAL_PRIMARY_Q_EXIT, self.x_time_exit.row(row)),
6394 ] {
6395 let weight = primary_hessian[[primary_idx, LATENT_SURVIVAL_PRIMARY_MU]];
6396 if weight == 0.0 {
6397 continue;
6398 }
6399 for i in 0..time_vec.len() {
6400 let xi = time_vec[i];
6401 if xi == 0.0 {
6402 continue;
6403 }
6404 for j in 0..mean_vec.len() {
6405 let xj = mean_vec[j];
6406 if xj == 0.0 {
6407 continue;
6408 }
6409 target[[slices.time.start + i, slices.mean.start + j]] += weight * xi * xj;
6410 target[[slices.mean.start + j, slices.time.start + i]] += weight * xj * xi;
6411 }
6412 }
6413 }
6414 }
6415
6416 fn evaluate_exact_newton_joint_dense(
6417 &self,
6418 block_states: &[ParameterBlockState],
6419 ) -> Result<(f64, Array1<f64>, Array2<f64>), String> {
6420 let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-binary")
6421 .map_err(String::from)?;
6422 let (q_entry, q_exit, mu) = self.split_time_eta(block_states)?;
6423 let slices = self.joint_slices();
6424 let mut ll = CompensatedRowSum::default();
6425 let mut gradient = Array1::<f64>::zeros(slices.total);
6426 let mut hessian = Array2::<f64>::zeros((slices.total, slices.total));
6427 for row_idx in 0..self.event_target.len() {
6428 let wi = weights.at(row_idx);
6429 if wi == 0.0 {
6430 continue;
6431 }
6432 let row =
6433 self.build_right_censored_row_at(row_idx, q_entry[row_idx], q_exit[row_idx])?;
6434 let (row_log_survival, survival_gradient, survival_hessian) =
6435 latent_survival_row_primary_gradient_hessian(
6436 &self.quadctx,
6437 &row,
6438 LatentSurvivalPrimaryPoint {
6439 q_entry: q_entry[row_idx],
6440 q_exit: q_exit[row_idx],
6441 qdot_exit: 1.0,
6442 q_right: q_exit[row_idx],
6443 mu: mu[row_idx],
6444 sigma: self.latent_sd,
6445 },
6446 false,
6447 )?;
6448 let binary = binary_from_log_survival(row_log_survival, self.event_target[row_idx])?;
6449 ll.add(checked_weighted_row_value(
6450 wi,
6451 binary.log_lik,
6452 row_idx,
6453 "binary log likelihood",
6454 )?);
6455 let primary_gradient = binary.grad_scale * &survival_gradient;
6456 let mut primary_hessian = binary.grad_scale * survival_hessian;
6457 for a in 0..LATENT_SURVIVAL_PRIMARY_DIM {
6458 for b in 0..LATENT_SURVIVAL_PRIMARY_DIM {
6459 primary_hessian[[a, b]] +=
6460 binary.outer_scale * survival_gradient[a] * survival_gradient[b];
6461 }
6462 }
6463 self.add_pullback_primary_gradient(
6464 &mut gradient,
6465 row_idx,
6466 &slices,
6467 &primary_gradient,
6468 wi,
6469 )?;
6470 let weighted_primary_hessian = checked_weighted_row_matrix(
6471 wi,
6472 &primary_hessian,
6473 row_idx,
6474 "binary primary Hessian",
6475 )?;
6476 self.add_pullback_primary_hessian(
6477 &mut hessian,
6478 row_idx,
6479 &slices,
6480 &weighted_primary_hessian,
6481 );
6482 }
6483 let ll = require_finite_likelihood_scalar(ll.value(), "binary log likelihood")?;
6484 require_finite_likelihood_vector(&gradient, "binary gradient")?;
6485 require_finite_likelihood_matrix(&hessian, "binary Hessian")?;
6486 Ok((ll, gradient, hessian))
6487 }
6488
6489 pub fn offset_channel_residuals(
6501 &self,
6502 block_states: &[ParameterBlockState],
6503 ) -> Result<crate::survival::OffsetChannelResiduals, LatentSurvivalError> {
6504 let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-binary")?;
6505 let n = self.event_target.len();
6506 let (q_entry, q_exit, mu) = self.split_time_eta(block_states)?;
6510 let mut entry = Array1::<f64>::zeros(n);
6511 let mut exit = Array1::<f64>::zeros(n);
6512 for row_idx in 0..n {
6513 let wi = weights.at(row_idx);
6514 if wi == 0.0 {
6515 continue;
6516 }
6517 let row =
6518 self.build_right_censored_row_at(row_idx, q_entry[row_idx], q_exit[row_idx])?;
6519 let (row_log_survival, survival_gradient, _) =
6520 latent_survival_row_primary_gradient_hessian(
6521 &self.quadctx,
6522 &row,
6523 LatentSurvivalPrimaryPoint {
6524 q_entry: q_entry[row_idx],
6525 q_exit: q_exit[row_idx],
6526 qdot_exit: 1.0,
6527 q_right: q_exit[row_idx],
6528 mu: mu[row_idx],
6529 sigma: self.latent_sd,
6530 },
6531 false,
6532 )?;
6533 let (_, grad_scale) = binary_from_log_survival_through_first(
6534 row_log_survival,
6535 self.event_target[row_idx],
6536 )?;
6537 entry[row_idx] = -checked_weighted_row_value(
6539 wi,
6540 grad_scale * survival_gradient[LATENT_SURVIVAL_PRIMARY_Q_ENTRY],
6541 row_idx,
6542 "binary entry-offset score",
6543 )
6544 .map_err(|reason| LatentSurvivalError::NumericalFailure { reason })?;
6545 exit[row_idx] = -checked_weighted_row_value(
6546 wi,
6547 grad_scale * survival_gradient[LATENT_SURVIVAL_PRIMARY_Q_EXIT],
6548 row_idx,
6549 "binary exit-offset score",
6550 )
6551 .map_err(|reason| LatentSurvivalError::NumericalFailure { reason })?;
6552 }
6553 Ok(crate::survival::OffsetChannelResiduals {
6554 exit,
6555 entry,
6556 derivative: Array1::<f64>::zeros(n),
6557 right: Array1::<f64>::zeros(n),
6560 })
6561 }
6562
6563 fn exact_newton_joint_hessian_directional_derivative_dense(
6564 &self,
6565 block_states: &[ParameterBlockState],
6566 d_beta_flat: &Array1<f64>,
6567 ) -> Result<Array2<f64>, String> {
6568 let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-binary")
6569 .map_err(String::from)?;
6570 let (q_entry, q_exit, mu) = self.split_time_eta(block_states)?;
6571 let slices = self.joint_slices();
6572 if d_beta_flat.len() != slices.total {
6573 return Err(format!(
6574 "latent binary joint dH direction length mismatch: got {}, expected {}",
6575 d_beta_flat.len(),
6576 slices.total
6577 ));
6578 }
6579 let mut out = Array2::<f64>::zeros((slices.total, slices.total));
6580 for row_idx in 0..self.event_target.len() {
6581 let wi = weights.at(row_idx);
6582 if wi == 0.0 {
6583 continue;
6584 }
6585 let row =
6586 self.build_right_censored_row_at(row_idx, q_entry[row_idx], q_exit[row_idx])?;
6587 let direction = self.row_primary_direction_from_flat(row_idx, &slices, d_beta_flat);
6588 let row_jet = latent_survival_row_primary_one_seed_fixed_sigma(
6592 &self.quadctx,
6593 &row,
6594 LatentSurvivalPrimaryPoint {
6595 q_entry: q_entry[row_idx],
6596 q_exit: q_exit[row_idx],
6597 qdot_exit: 1.0,
6598 q_right: q_exit[row_idx],
6599 mu: mu[row_idx],
6600 sigma: self.latent_sd,
6601 },
6602 &direction,
6603 )?;
6604 let (binary, outer_scale_prime) = binary_from_log_survival_through_third(
6605 row_jet.base.value(),
6606 self.event_target[row_idx],
6607 )?;
6608 let base_gradient = row_jet.base.g();
6609 let base_hessian = row_jet.base.h();
6610 let contracted_third = row_jet.contracted_third();
6611 let survival_gradient = Array1::from_shape_fn(LATENT_SURVIVAL_PRIMARY_DIM, |a| {
6612 if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA {
6613 base_gradient[a]
6614 } else {
6615 0.0
6616 }
6617 });
6618 let survival_hessian = Array2::from_shape_fn(
6619 (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
6620 |(a, b)| {
6621 if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
6622 && b < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
6623 {
6624 -base_hessian[a][b]
6625 } else {
6626 0.0
6627 }
6628 },
6629 );
6630 let third = Array2::from_shape_fn(
6631 (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
6632 |(a, b)| {
6633 if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
6634 && b < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
6635 {
6636 -contracted_third[a][b]
6637 } else {
6638 0.0
6639 }
6640 },
6641 );
6642 let g_u = -survival_hessian.dot(&direction);
6643 let t_u = survival_gradient.dot(&direction);
6644 let mut primary = binary.grad_scale * third;
6645 primary.scaled_add(-binary.outer_scale * t_u, &survival_hessian);
6646 for a in 0..LATENT_SURVIVAL_PRIMARY_DIM {
6647 for b in 0..LATENT_SURVIVAL_PRIMARY_DIM {
6648 primary[[a, b]] +=
6649 outer_scale_prime * t_u * survival_gradient[a] * survival_gradient[b]
6650 + binary.outer_scale
6651 * (g_u[a] * survival_gradient[b] + survival_gradient[a] * g_u[b]);
6652 }
6653 }
6654 let weighted_primary =
6655 checked_weighted_row_matrix(wi, &primary, row_idx, "binary contracted third")?;
6656 self.add_pullback_primary_hessian(&mut out, row_idx, &slices, &weighted_primary);
6657 }
6658 require_finite_likelihood_matrix(&out, "binary directional Hessian derivative")?;
6659 Ok(out)
6660 }
6661
6662 fn exact_newton_joint_hessian_second_directional_derivative_dense(
6663 &self,
6664 block_states: &[ParameterBlockState],
6665 d_beta_u_flat: &Array1<f64>,
6666 d_beta_v_flat: &Array1<f64>,
6667 ) -> Result<Array2<f64>, String> {
6668 let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-binary")
6669 .map_err(String::from)?;
6670 let (q_entry, q_exit, mu) = self.split_time_eta(block_states)?;
6671 let slices = self.joint_slices();
6672 if d_beta_u_flat.len() != slices.total || d_beta_v_flat.len() != slices.total {
6673 return Err(format!(
6674 "latent binary joint d2H direction length mismatch: got {} and {}, expected {}",
6675 d_beta_u_flat.len(),
6676 d_beta_v_flat.len(),
6677 slices.total
6678 ));
6679 }
6680 let mut out = Array2::<f64>::zeros((slices.total, slices.total));
6681 for row_idx in 0..self.event_target.len() {
6682 let wi = weights.at(row_idx);
6683 if wi == 0.0 {
6684 continue;
6685 }
6686 let row =
6687 self.build_right_censored_row_at(row_idx, q_entry[row_idx], q_exit[row_idx])?;
6688 let direction_u = self.row_primary_direction_from_flat(row_idx, &slices, d_beta_u_flat);
6689 let direction_v = self.row_primary_direction_from_flat(row_idx, &slices, d_beta_v_flat);
6690 let row_jet = latent_survival_row_primary_two_seed_fixed_sigma(
6695 &self.quadctx,
6696 &row,
6697 LatentSurvivalPrimaryPoint {
6698 q_entry: q_entry[row_idx],
6699 q_exit: q_exit[row_idx],
6700 qdot_exit: 1.0,
6701 q_right: q_exit[row_idx],
6702 mu: mu[row_idx],
6703 sigma: self.latent_sd,
6704 },
6705 &direction_u,
6706 &direction_v,
6707 )?;
6708 let (binary, outer_scale_prime, outer_scale_second) =
6709 binary_from_log_survival_through_fourth(
6710 row_jet.base.value(),
6711 self.event_target[row_idx],
6712 )?;
6713 let base_gradient = row_jet.base.g();
6714 let base_hessian = row_jet.base.h();
6715 let contracted_third_u = row_jet.eps.h();
6716 let contracted_third_v = row_jet.del.h();
6717 let contracted_fourth = row_jet.contracted_fourth();
6718 let survival_gradient = Array1::from_shape_fn(LATENT_SURVIVAL_PRIMARY_DIM, |a| {
6719 if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA {
6720 base_gradient[a]
6721 } else {
6722 0.0
6723 }
6724 });
6725 let pad_matrix =
6726 |matrix: &[[f64; LATENT_SURVIVAL_PRIMARY_LOG_SIGMA];
6727 LATENT_SURVIVAL_PRIMARY_LOG_SIGMA]| {
6728 Array2::from_shape_fn(
6729 (LATENT_SURVIVAL_PRIMARY_DIM, LATENT_SURVIVAL_PRIMARY_DIM),
6730 |(a, b)| {
6731 if a < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
6732 && b < LATENT_SURVIVAL_PRIMARY_LOG_SIGMA
6733 {
6734 -matrix[a][b]
6735 } else {
6736 0.0
6737 }
6738 },
6739 )
6740 };
6741 let survival_hessian = pad_matrix(&base_hessian);
6742 let third_u = pad_matrix(&contracted_third_u);
6743 let third_v = pad_matrix(&contracted_third_v);
6744 let fourth = pad_matrix(&contracted_fourth);
6745 let g_u = -survival_hessian.dot(&direction_u);
6746 let g_v = -survival_hessian.dot(&direction_v);
6747 let g_uv = -third_v.dot(&direction_u);
6748 let t_u = survival_gradient.dot(&direction_u);
6749 let t_v = survival_gradient.dot(&direction_v);
6750 let l_uv = -direction_u.dot(&survival_hessian.dot(&direction_v));
6751 let grad_scale_prime = -binary.outer_scale;
6752 let grad_scale_second = -outer_scale_prime;
6753 let c_u = grad_scale_prime * t_u;
6754 let c_v = grad_scale_prime * t_v;
6755 let c_uv = grad_scale_second * t_u * t_v + grad_scale_prime * l_uv;
6756 let o_u = outer_scale_prime * t_u;
6757 let o_v = outer_scale_prime * t_v;
6758 let o_uv = outer_scale_second * t_u * t_v + outer_scale_prime * l_uv;
6759 let mut primary = binary.grad_scale * fourth;
6760 primary.scaled_add(c_u, &third_v);
6761 primary.scaled_add(c_v, &third_u);
6762 primary.scaled_add(c_uv, &survival_hessian);
6763 for a in 0..LATENT_SURVIVAL_PRIMARY_DIM {
6764 for b in 0..LATENT_SURVIVAL_PRIMARY_DIM {
6765 primary[[a, b]] += o_uv * survival_gradient[a] * survival_gradient[b]
6766 + o_v * (g_u[a] * survival_gradient[b] + survival_gradient[a] * g_u[b])
6767 + o_u * (g_v[a] * survival_gradient[b] + survival_gradient[a] * g_v[b])
6768 + binary.outer_scale
6769 * (g_uv[a] * survival_gradient[b]
6770 + g_u[a] * g_v[b]
6771 + g_v[a] * g_u[b]
6772 + survival_gradient[a] * g_uv[b]);
6773 }
6774 }
6775 let weighted_primary =
6776 checked_weighted_row_matrix(wi, &primary, row_idx, "binary contracted fourth")?;
6777 self.add_pullback_primary_hessian(&mut out, row_idx, &slices, &weighted_primary);
6778 }
6779 require_finite_likelihood_matrix(&out, "binary second directional Hessian derivative")?;
6780 Ok(out)
6781 }
6782}
6783
6784trait LatentJointHessianFamily {
6798 fn ws_joint_slices(&self) -> LatentSurvivalJointSlices;
6799
6800 fn ws_evaluate_dense(
6801 &self,
6802 block_states: &[ParameterBlockState],
6803 ) -> Result<(f64, Array1<f64>, Array2<f64>), String>;
6804
6805 fn ws_dh_directional(
6806 &self,
6807 block_states: &[ParameterBlockState],
6808 d_beta_flat: &Array1<f64>,
6809 ) -> Result<Array2<f64>, String>;
6810
6811 fn ws_dh_second_directional(
6812 &self,
6813 block_states: &[ParameterBlockState],
6814 d_beta_u: &Array1<f64>,
6815 d_beta_v: &Array1<f64>,
6816 ) -> Result<Array2<f64>, String>;
6817
6818 fn ws_matvec_into(
6822 &self,
6823 slices: &LatentSurvivalJointSlices,
6824 block_states: &[ParameterBlockState],
6825 v: &Array1<f64>,
6826 out: &mut Array1<f64>,
6827 ) -> Result<bool, String>;
6828
6829 fn ws_label() -> &'static str;
6833}
6834
6835impl LatentJointHessianFamily for LatentSurvivalFamily {
6836 fn ws_joint_slices(&self) -> LatentSurvivalJointSlices {
6837 self.joint_slices()
6838 }
6839
6840 fn ws_evaluate_dense(
6841 &self,
6842 block_states: &[ParameterBlockState],
6843 ) -> Result<(f64, Array1<f64>, Array2<f64>), String> {
6844 self.evaluate_exact_newton_joint_dense(block_states)
6845 }
6846
6847 fn ws_dh_directional(
6848 &self,
6849 block_states: &[ParameterBlockState],
6850 d_beta_flat: &Array1<f64>,
6851 ) -> Result<Array2<f64>, String> {
6852 self.exact_newton_joint_hessian_directional_derivative_dense(block_states, d_beta_flat)
6853 }
6854
6855 fn ws_dh_second_directional(
6856 &self,
6857 block_states: &[ParameterBlockState],
6858 d_beta_u: &Array1<f64>,
6859 d_beta_v: &Array1<f64>,
6860 ) -> Result<Array2<f64>, String> {
6861 self.exact_newton_joint_hessian_second_directional_derivative_dense(
6862 block_states,
6863 d_beta_u,
6864 d_beta_v,
6865 )
6866 }
6867
6868 fn ws_matvec_into(
6869 &self,
6870 slices: &LatentSurvivalJointSlices,
6871 block_states: &[ParameterBlockState],
6872 v: &Array1<f64>,
6873 out: &mut Array1<f64>,
6874 ) -> Result<bool, String> {
6875 let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-survival")
6876 .map_err(String::from)?;
6877 let (q_entry, q_exit, qdot_exit, mu) = self.split_time_eta(block_states)?;
6878 let q_right = self.time_q_right(block_states)?;
6879 let sigma = self.latent_sd(block_states)?;
6880 let include_log_sigma = slices.log_sigma.is_some();
6881 for row_idx in 0..self.event_target.len() {
6882 let wi = weights.at(row_idx);
6883 if wi == 0.0 {
6884 continue;
6885 }
6886 let row = self.build_row_at(
6887 row_idx,
6888 q_entry[row_idx],
6889 q_exit[row_idx],
6890 qdot_exit[row_idx],
6891 q_right[row_idx],
6892 )?;
6893 let (_, _, primary_hessian) = latent_survival_row_primary_gradient_hessian(
6894 &self.quadctx,
6895 &row,
6896 LatentSurvivalPrimaryPoint {
6897 q_entry: q_entry[row_idx],
6898 q_exit: q_exit[row_idx],
6899 qdot_exit: qdot_exit[row_idx],
6900 q_right: q_right[row_idx],
6901 mu: mu[row_idx],
6902 sigma,
6903 },
6904 include_log_sigma,
6905 )?;
6906 let primary_dir = self.row_primary_direction_from_flat(row_idx, slices, v);
6907 let primary_hv = primary_hessian.dot(&primary_dir);
6908 self.add_pullback_primary_gradient(out, row_idx, slices, &primary_hv, wi)?;
6909 }
6910 require_finite_likelihood_vector(out, "Hessian matvec")?;
6911 Ok(true)
6912 }
6913
6914 fn ws_label() -> &'static str {
6915 "survival"
6916 }
6917}
6918
6919impl LatentJointHessianFamily for LatentBinaryFamily {
6920 fn ws_joint_slices(&self) -> LatentSurvivalJointSlices {
6921 self.joint_slices()
6922 }
6923
6924 fn ws_evaluate_dense(
6925 &self,
6926 block_states: &[ParameterBlockState],
6927 ) -> Result<(f64, Array1<f64>, Array2<f64>), String> {
6928 self.evaluate_exact_newton_joint_dense(block_states)
6929 }
6930
6931 fn ws_dh_directional(
6932 &self,
6933 block_states: &[ParameterBlockState],
6934 d_beta_flat: &Array1<f64>,
6935 ) -> Result<Array2<f64>, String> {
6936 self.exact_newton_joint_hessian_directional_derivative_dense(block_states, d_beta_flat)
6937 }
6938
6939 fn ws_dh_second_directional(
6940 &self,
6941 block_states: &[ParameterBlockState],
6942 d_beta_u: &Array1<f64>,
6943 d_beta_v: &Array1<f64>,
6944 ) -> Result<Array2<f64>, String> {
6945 self.exact_newton_joint_hessian_second_directional_derivative_dense(
6946 block_states,
6947 d_beta_u,
6948 d_beta_v,
6949 )
6950 }
6951
6952 fn ws_matvec_into(
6953 &self,
6954 slices: &LatentSurvivalJointSlices,
6955 block_states: &[ParameterBlockState],
6956 v: &Array1<f64>,
6957 out: &mut Array1<f64>,
6958 ) -> Result<bool, String> {
6959 let weights = ValidatedLikelihoodWeights::new(&self.weights, "latent-binary")
6960 .map_err(String::from)?;
6961 let (q_entry, q_exit, mu) = self.split_time_eta(block_states)?;
6962 for row_idx in 0..self.event_target.len() {
6963 let wi = weights.at(row_idx);
6964 if wi == 0.0 {
6965 continue;
6966 }
6967 let row =
6968 self.build_right_censored_row_at(row_idx, q_entry[row_idx], q_exit[row_idx])?;
6969 let (row_log_survival, survival_gradient, survival_hessian) =
6970 latent_survival_row_primary_gradient_hessian(
6971 &self.quadctx,
6972 &row,
6973 LatentSurvivalPrimaryPoint {
6974 q_entry: q_entry[row_idx],
6975 q_exit: q_exit[row_idx],
6976 qdot_exit: 1.0,
6977 q_right: q_exit[row_idx],
6978 mu: mu[row_idx],
6979 sigma: self.latent_sd,
6980 },
6981 false,
6982 )?;
6983 let binary = binary_from_log_survival(row_log_survival, self.event_target[row_idx])?;
6984 let primary_dir = self.row_primary_direction_from_flat(row_idx, slices, v);
6985 let mut primary_hv = binary.grad_scale * survival_hessian.dot(&primary_dir);
6986 let outer_dot = survival_gradient.dot(&primary_dir);
6987 for a in 0..LATENT_SURVIVAL_PRIMARY_DIM {
6988 primary_hv[a] += binary.outer_scale * survival_gradient[a] * outer_dot;
6989 }
6990 self.add_pullback_primary_gradient(out, row_idx, slices, &primary_hv, wi)?;
6991 }
6992 require_finite_likelihood_vector(out, "binary Hessian matvec")?;
6993 Ok(true)
6994 }
6995
6996 fn ws_label() -> &'static str {
6997 "binary"
6998 }
6999}
7000
7001struct LatentHessianWorkspace<F: LatentJointHessianFamily> {
7008 family: F,
7009 block_states: Vec<ParameterBlockState>,
7010 slices: LatentSurvivalJointSlices,
7011}
7012
7013impl<F: LatentJointHessianFamily> LatentHessianWorkspace<F> {
7014 fn new(family: F, block_states: Vec<ParameterBlockState>) -> Self {
7015 let slices = family.ws_joint_slices();
7016 Self {
7017 family,
7018 block_states,
7019 slices,
7020 }
7021 }
7022}
7023
7024impl<F> ExactNewtonJointHessianWorkspace for LatentHessianWorkspace<F>
7025where
7026 F: LatentJointHessianFamily + Send + Sync + 'static,
7027{
7028 fn warm_up_outer_caches_for_mode(
7029 &self,
7030 eval_mode: gam_problem::EvalMode,
7031 ) -> Result<(), String> {
7032 match eval_mode {
7033 gam_problem::EvalMode::ValueOnly
7034 | gam_problem::EvalMode::ValueAndGradient
7035 | gam_problem::EvalMode::ValueGradientHessian => Ok(()),
7036 }
7037 }
7038
7039 fn hessian_dense(&self) -> Result<Option<Array2<f64>>, String> {
7040 self.family
7041 .ws_evaluate_dense(&self.block_states)
7042 .map(|(_, _, hessian)| Some(hessian))
7043 }
7044
7045 fn hessian_matvec(&self, v: &Array1<f64>) -> Result<Option<Array1<f64>>, String> {
7046 let mut out = Array1::<f64>::zeros(self.slices.total);
7047 self.hessian_matvec_into(v, &mut out)?;
7048 Ok(Some(out))
7049 }
7050
7051 fn hessian_matvec_into(&self, v: &Array1<f64>, out: &mut Array1<f64>) -> Result<bool, String> {
7052 if v.len() != self.slices.total || out.len() != self.slices.total {
7053 return Err(format!(
7054 "latent {} Hessian matvec dimension mismatch: v={} out={} expected={}",
7055 F::ws_label(),
7056 v.len(),
7057 out.len(),
7058 self.slices.total
7059 ));
7060 }
7061 out.fill(0.0);
7062 self.family
7063 .ws_matvec_into(&self.slices, &self.block_states, v, out)
7064 }
7065
7066 fn hessian_diagonal(&self) -> Result<Option<Array1<f64>>, String> {
7067 let dense = self.family.ws_evaluate_dense(&self.block_states)?.2;
7068 Ok(Some(dense.diag().to_owned()))
7069 }
7070
7071 fn directional_derivative(
7072 &self,
7073 d_beta_flat: &Array1<f64>,
7074 ) -> Result<Option<Array2<f64>>, String> {
7075 self.family
7076 .ws_dh_directional(&self.block_states, d_beta_flat)
7077 .map(Some)
7078 }
7079
7080 fn second_directional_derivative(
7081 &self,
7082 d_beta_u: &Array1<f64>,
7083 d_beta_v: &Array1<f64>,
7084 ) -> Result<Option<Array2<f64>>, String> {
7085 self.family
7086 .ws_dh_second_directional(&self.block_states, d_beta_u, d_beta_v)
7087 .map(Some)
7088 }
7089}
7090
7091type LatentSurvivalHessianWorkspace = LatentHessianWorkspace<LatentSurvivalFamily>;
7092type LatentBinaryHessianWorkspace = LatentHessianWorkspace<LatentBinaryFamily>;
7093
7094mod custom_family;
7097
7098mod log_sigma_curvature_certificate;
7102
7103pub use log_sigma_curvature_certificate::{
7104 CertifiedLogSigmaCurvature, latent_survival_log_sigma_curvature_certified,
7105};
7106
7107
7108#[cfg(test)]
7109mod tests;