1use crate::model_types::EstimationError;
15use crate::probability::{log1mexp_positive, signed_log_sum_exp};
16use crate::quadrature::{
17 IntegratedExpectationMode, QuadratureContext, log_survival_jet,
18 lognormal_laplace_unit_log_term_shared,
19};
20use serde::{Deserialize, Serialize};
21use std::fmt;
22
23#[derive(Debug, Clone)]
31pub enum LognormalKernelError {
32 InvalidSpec { reason: String },
35}
36
37impl_reason_error_boilerplate! {
38 LognormalKernelError {
39 InvalidSpec,
40 }
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "kebab-case")]
48pub enum HazardLoading {
49 Full,
51 LoadedVsUnloaded,
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
72#[serde(tag = "scale_kind", rename_all = "kebab-case")]
73pub enum FrailtyScale {
74 Fixed { sigma: f64 },
75 Learned { initial_sigma: f64 },
76}
77
78impl FrailtyScale {
79 fn validate(self, kind: &str) -> Result<(), LognormalKernelError> {
80 match self {
81 Self::Fixed { sigma } if sigma.is_finite() && sigma >= 0.0 => Ok(()),
82 Self::Fixed { sigma } => Err(LognormalKernelError::InvalidSpec {
83 reason: format!(
84 "{kind} frailty Fixed scale requires finite sigma >= 0, got {sigma}"
85 ),
86 }),
87 Self::Learned { initial_sigma }
88 if initial_sigma.is_finite() && initial_sigma > 0.0 =>
89 {
90 Ok(())
91 }
92 Self::Learned { initial_sigma } => Err(LognormalKernelError::InvalidSpec {
93 reason: format!(
94 "{kind} frailty Learned scale requires finite initial_sigma > 0, got {initial_sigma}"
95 ),
96 }),
97 }
98 }
99
100 pub(crate) fn learned_log_sigma_coordinate(self) -> Option<(f64, f64, f64)> {
103 match self {
104 Self::Fixed { .. } => None,
105 Self::Learned { initial_sigma } => Some((initial_sigma.ln(), -12.0, 6.0)),
106 }
107 }
108}
109
110#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
111#[serde(tag = "frailty_kind", rename_all = "kebab-case")]
112pub enum FrailtySpec {
113 #[default]
115 None,
116 GaussianShift {
120 scale: FrailtyScale,
121 },
122 HazardMultiplier {
125 scale: FrailtyScale,
126 loading: HazardLoading,
128 },
129}
130
131impl FrailtySpec {
132 #[inline]
138 pub fn is_active(&self) -> bool {
139 !matches!(self, Self::None)
140 }
141
142 pub fn validate(&self) -> Result<(), LognormalKernelError> {
144 let (kind, scale) = match self {
145 Self::None => return Ok(()),
146 Self::GaussianShift { scale } => ("GaussianShift", *scale),
147 Self::HazardMultiplier { scale, .. } => ("HazardMultiplier", *scale),
148 };
149 scale.validate(kind)
150 }
151
152 pub fn validate_for_marginal_slope(&self) -> Result<(), String> {
164 self.validate_for_marginal_slope_typed()
165 .map_err(|e| e.to_string())
166 }
167
168 pub fn validate_for_marginal_slope_typed(&self) -> Result<(), LognormalKernelError> {
172 self.validate()?;
173 match self {
174 Self::None | Self::GaussianShift { .. } => Ok(()),
175 Self::HazardMultiplier { .. } => Err(LognormalKernelError::InvalidSpec {
176 reason:
177 "HazardMultiplier frailty is not finite-state exact with score_warp/linkwiggle \
178 cubic marginal-slope families. Use GaussianShift frailty (exact probit scaling) \
179 or use the standalone latent-cloglog/latent-survival families instead."
180 .to_string(),
181 }),
182 }
183 }
184}
185
186#[inline]
189fn probit_frailty_scale_components(sigma: f64) -> (f64, f64) {
190 let abs_sigma = sigma.abs();
191 if abs_sigma > 1.0 {
192 let inv = 1.0 / abs_sigma;
193 let denom = 1.0 + inv * inv;
194 (inv / denom.sqrt(), 1.0 / denom)
195 } else {
196 let sigma2 = sigma * sigma;
197 let denom = 1.0 + sigma2;
198 (1.0 / denom.sqrt(), sigma2 / denom)
199 }
200}
201
202#[derive(Clone, Copy, Debug)]
210pub struct ProbitFrailtyScaleJet {
211 pub s: f64,
213 pub alpha: f64,
215 pub ds: f64,
217 pub d2s: f64,
219}
220
221impl ProbitFrailtyScaleJet {
222 pub fn new(sigma: f64) -> Self {
227 let (s, alpha) = probit_frailty_scale_components(sigma);
228 Self {
229 s,
230 alpha,
231 ds: -alpha * s,
232 d2s: alpha * (3.0 * alpha - 2.0) * s,
233 }
234 }
235
236 pub fn from_log_sigma(log_sigma: f64) -> Self {
238 Self::new(log_sigma.exp())
239 }
240}
241
242#[inline]
243fn worst_mode(
244 a: IntegratedExpectationMode,
245 b: IntegratedExpectationMode,
246) -> IntegratedExpectationMode {
247 if a.rank() >= b.rank() { a } else { b }
248}
249
250#[inline]
261fn validate_kernel_inputs(m: f64, mu: f64, sigma: f64) -> Result<(), EstimationError> {
262 if !m.is_finite() || m < 0.0 {
263 crate::bail_invalid_estim!("lognormal kernel requires finite m >= 0, got {m}");
264 }
265 if !mu.is_finite() || !sigma.is_finite() || sigma < 0.0 {
266 crate::bail_invalid_estim!(
267 "lognormal kernel requires finite mu and sigma >= 0, got mu={mu}, sigma={sigma}"
268 );
269 }
270 Ok::<(), _>(())
271}
272
273#[inline]
274pub fn log_kernel_term(
275 quadctx: &QuadratureContext,
276 k: usize,
277 m: f64,
278 mu: f64,
279 sigma: f64,
280) -> Result<(f64, IntegratedExpectationMode), EstimationError> {
281 validate_kernel_inputs(m, mu, sigma)?;
282 let kf = k as f64;
283 let sigma2 = sigma * sigma;
284 if !sigma2.is_finite() {
285 crate::bail_invalid_estim!(
286 "lognormal kernel sigma is outside the finite exact-derivative range: sigma={sigma}"
287 );
288 }
289 let prefix_bound = kf * mu.abs() + 0.5 * kf * kf * sigma2;
290 if !prefix_bound.is_finite() {
291 crate::bail_invalid_estim!(
292 "lognormal kernel prefix is outside the finite exact-derivative range: k={k}, mu={mu}, sigma={sigma}"
293 );
294 }
295 let prefix = kf * mu + 0.5 * kf * kf * sigma2;
296 if m == 0.0 {
297 return Ok((prefix, IntegratedExpectationMode::ExactClosedForm));
298 }
299 let log_m = m.ln();
300 let shifted_bound = mu.abs() + kf * sigma2 + log_m.abs();
301 if !shifted_bound.is_finite() {
302 crate::bail_invalid_estim!(
303 "lognormal kernel shifted location is outside the finite exact-derivative range: k={k}, m={m}, mu={mu}, sigma={sigma}"
304 );
305 }
306 let shifted_mu = mu + kf * sigma2 + log_m;
307 let (log_laplace, mode) = lognormal_laplace_unit_log_term_shared(quadctx, shifted_mu, sigma);
312 Ok((prefix + log_laplace, mode))
313}
314
315#[derive(Clone, Debug)]
317pub struct LogLognormalKernelBundle {
318 pub log_values: Vec<f64>,
319 pub log_laplace: Vec<f64>,
330 pub log_scaled_a_derivatives: Option<Vec<KernelSignedLog>>,
353 pub mode: IntegratedExpectationMode,
354}
355
356#[derive(Clone, Copy, Debug)]
361pub struct KernelSignedLog {
362 pub log_abs: f64,
363 pub sign: f64,
364}
365
366impl LogLognormalKernelBundle {
367 #[inline]
368 pub fn get(&self, k: usize) -> f64 {
369 self.log_values[k]
370 }
371
372 #[inline]
373 pub fn len(&self) -> usize {
374 self.log_values.len()
375 }
376
377 pub fn second_cumulant_ratio(&self, k: usize, sigma: f64) -> Option<f64> {
410 if k + 2 >= self.log_values.len() || k + 2 >= self.log_laplace.len() {
411 return None;
412 }
413 let log_k0 = self.log_values[k];
414 let log_k2 = self.log_values[k + 2];
415 if !log_k0.is_finite() || !log_k2.is_finite() {
416 return None;
417 }
418 let (lap0, lap1, lap2) = (
419 self.log_laplace[k],
420 self.log_laplace[k + 1],
421 self.log_laplace[k + 2],
422 );
423 if !(lap0.is_finite() && lap1.is_finite() && lap2.is_finite()) {
424 return None;
425 }
426 let sigma2 = sigma * sigma;
427 if !sigma2.is_finite() {
428 return None;
429 }
430 let second_difference_laplace = (lap2 - lap1) - (lap1 - lap0);
434 let delta = -(sigma2 + second_difference_laplace);
435 let ratio2 = (log_k2 - log_k0).exp();
436 let value = -ratio2 * delta.exp_m1();
437 if value.is_finite() { Some(value) } else { None }
438 }
439}
440
441pub fn log_kernel_bundle(
444 quadctx: &QuadratureContext,
445 m: f64,
446 mu: f64,
447 sigma: f64,
448 max_k: usize,
449) -> Result<LogLognormalKernelBundle, EstimationError> {
450 validate_kernel_inputs(m, mu, sigma)?;
451 let mut log_values = Vec::with_capacity(max_k + 1);
452 let sigma2 = sigma * sigma;
453 if !sigma2.is_finite() {
454 crate::bail_invalid_estim!(
455 "lognormal kernel sigma is outside the finite exact-derivative range: sigma={sigma}"
456 );
457 }
458 let max_kf = max_k as f64;
459 let prefix_bound = max_kf * mu.abs() + 0.5 * max_kf * max_kf * sigma2;
460 if !prefix_bound.is_finite() {
461 crate::bail_invalid_estim!(
462 "lognormal kernel bundle prefix is outside the finite exact-derivative range: max_k={max_k}, mu={mu}, sigma={sigma}"
463 );
464 }
465 if m == 0.0 {
466 let mut prefix = 0.0;
467 for k in 0..=max_k {
468 log_values.push(prefix);
469 prefix += mu + (k as f64 + 0.5) * sigma2;
470 }
471 let log_laplace = vec![0.0; max_k + 1];
474 return Ok(LogLognormalKernelBundle {
475 log_values,
476 log_laplace,
477 log_scaled_a_derivatives: None,
480 mode: IntegratedExpectationMode::ExactClosedForm,
481 });
482 }
483
484 let log_m = m.ln();
485 let shifted_bound = mu.abs() + max_kf * sigma2 + log_m.abs();
486 if !shifted_bound.is_finite() {
487 crate::bail_invalid_estim!(
488 "lognormal kernel bundle shifted location is outside the finite exact-derivative range: max_k={max_k}, m={m}, mu={mu}, sigma={sigma}"
489 );
490 }
491 let mut shifted_mu = mu + log_m;
492 let mut prefix = 0.0;
493 let mut mode = IntegratedExpectationMode::ExactClosedForm;
494 let mut log_laplace_values = Vec::with_capacity(max_k + 1);
495 for k in 0..=max_k {
496 let (log_laplace, val_mode) =
497 lognormal_laplace_unit_log_term_shared(quadctx, shifted_mu, sigma);
498 log_values.push(if log_laplace.is_finite() {
499 prefix + log_laplace
500 } else {
501 f64::NEG_INFINITY
502 });
503 log_laplace_values.push(log_laplace);
508 mode = worst_mode(mode, val_mode);
509 prefix += mu + (k as f64 + 0.5) * sigma2;
510 shifted_mu += sigma2;
511 }
512 let log_values_first = log_values[0];
515 Ok(LogLognormalKernelBundle {
516 log_values,
517 log_laplace: log_laplace_values,
518 log_scaled_a_derivatives: log_scaled_a_derivative_tower(
519 quadctx,
520 mu + log_m,
521 sigma,
522 max_k,
523 log_values_first,
524 ),
525 mode,
526 })
527}
528
529fn log_scaled_a_derivative_tower(
556 quadctx: &QuadratureContext,
557 a: f64,
558 sigma: f64,
559 max_k: usize,
560 log_k0: f64,
561) -> Option<Vec<KernelSignedLog>> {
562 if !log_k0.is_finite() {
563 return None;
564 }
565 let jet = log_survival_jet(quadctx, a, sigma, max_k);
566 let certified_order = jet.certified_prefix_order(max_k)?;
567 let certified = jet.certified_scaled_mu_derivatives(certified_order)?;
568 let mut tower = Vec::with_capacity(certified_order + 1);
569 tower.push(KernelSignedLog {
572 log_abs: log_k0,
573 sign: 1.0,
574 });
575 for entry in &certified[1..] {
576 tower.push(KernelSignedLog {
577 log_abs: entry.log_abs,
578 sign: entry.sign,
579 });
580 }
581 Some(tower)
582}
583
584pub fn kernel_ratio_jet(
594 log_bundle: &LogLognormalKernelBundle,
595 k: usize,
596 m: f64,
597 order: usize,
598) -> [f64; 5] {
599 let kf = k as f64;
600 let log_k0 = log_bundle.get(k);
601
602 let mut rk = [0.0f64; 5]; for r in 1..=order.min(4) {
607 let delta = log_bundle.get(k + r) - log_k0;
608 rk[r] = if delta.is_finite() {
609 delta.exp()
610 } else if delta > 0.0 {
611 f64::INFINITY
612 } else {
613 0.0
614 };
615 }
616
617 let mut jet = [0.0; 5];
618 jet[0] = 1.0;
619
620 if order >= 1 {
621 jet[1] = kf - m * rk[1];
622 }
623 if order >= 2 {
624 jet[2] = kf * kf - (2.0 * kf + 1.0) * m * rk[1] + m * m * rk[2];
625 }
626 if order >= 3 {
627 jet[3] = kf * kf * kf - (3.0 * kf * kf + 3.0 * kf + 1.0) * m * rk[1]
628 + 3.0 * (kf + 1.0) * m * m * rk[2]
629 - m * m * m * rk[3];
630 }
631 if order >= 4 {
632 let k2 = kf * kf;
633 let k3 = k2 * kf;
634 let k4 = k3 * kf;
635 let m2 = m * m;
636 let m3 = m2 * m;
637 let m4 = m3 * m;
638 jet[4] = k4 - (4.0 * k3 + 6.0 * k2 + 4.0 * kf + 1.0) * m * rk[1]
639 + (6.0 * k2 + 12.0 * kf + 7.0) * m2 * rk[2]
640 - (4.0 * kf + 6.0) * m3 * rk[3]
641 + m4 * rk[4];
642 }
643
644 jet
645}
646
647pub use crate::quadrature::{
653 LatentCLogLogJet5, latent_cloglog_inverse_link_jet, latent_cloglog_jet5,
654};
655
656#[derive(Clone, Copy, Debug)]
660pub struct KernelSumTerm {
661 pub coeff: f64,
663 pub k: usize,
665 pub m: f64,
667}
668
669const ANALYTIC_LOG_MASS_GAP_THRESHOLD: f64 = 1e-4;
681
682#[derive(Clone, Copy, Debug)]
696pub struct LogKernelSumJet {
697 pub value: f64,
699 pub d1: f64,
701 pub d2: f64,
703 pub d3: f64,
705 pub d4: f64,
707 pub mode: IntegratedExpectationMode,
708}
709
710impl LogKernelSumJet {
711 #[inline]
712 fn non_positive(mode: IntegratedExpectationMode) -> Self {
713 Self {
714 value: f64::NEG_INFINITY,
715 d1: 0.0,
716 d2: 0.0,
717 d3: 0.0,
718 d4: 0.0,
719 mode,
720 }
721 }
722
723 #[inline]
724 fn from_log_value_and_ratios(
725 value: f64,
726 ratio: [f64; 5],
727 mode: IntegratedExpectationMode,
728 ) -> Self {
729 let r1 = ratio[1];
730 let r2 = ratio[2];
731 let r3 = ratio[3];
732 let r4 = ratio[4];
733 Self {
734 value,
735 d1: r1,
736 d2: r2 - r1 * r1,
737 d3: r3 - 3.0 * r1 * r2 + 2.0 * r1 * r1 * r1,
738 d4: r4 - 4.0 * r1 * r3 - 3.0 * r2 * r2 + 12.0 * r1 * r1 * r2 - 6.0 * r1.powi(4),
739 mode,
740 }
741 }
742
743 #[inline]
744 fn term_log_mag_and_ratio(
745 bundle: &LogLognormalKernelBundle,
746 term: KernelSumTerm,
747 ) -> (f64, [f64; 5]) {
748 (
749 term.coeff.abs().ln() + bundle.get(term.k),
750 kernel_ratio_jet(bundle, term.k, term.m, 4),
753 )
754 }
755
756 fn analytic_log_mag_gap(
791 t0: KernelSumTerm,
792 t1: KernelSumTerm,
793 ratio0: &[f64; 5],
794 ) -> Option<f64> {
795 if t0.k != t1.k || t0.m <= 0.0 || t1.m <= 0.0 {
796 return None;
797 }
798 let dv = ((t1.m - t0.m) / t0.m).ln_1p();
799 if !dv.is_finite() || dv.abs() > ANALYTIC_LOG_MASS_GAP_THRESHOLD {
800 return None;
801 }
802 let kf = t0.k as f64;
803 let (r1, r2, r3) = (ratio0[1], ratio0[2], ratio0[3]);
804 let lambda1 = r1 - kf;
805 let lambda2 = r2 - r1 * r1;
806 let lambda3 = r3 - 3.0 * r1 * r2 + 2.0 * r1 * r1 * r1;
807 let kernel_gap = dv * (lambda1 + dv * (0.5 * lambda2 + dv * (lambda3 / 6.0)));
808 let coeff_gap = t1.coeff.abs().ln() - t0.coeff.abs().ln();
809 let delta = coeff_gap + kernel_gap;
810 if delta.is_finite() { Some(delta) } else { None }
811 }
812
813 fn reduce_signed_pair(sign0: f64, sign1: f64, delta: f64) -> Option<(f64, f64)> {
824 if sign0 == 0.0 || sign1 == 0.0 || !delta.is_finite() {
825 return None;
826 }
827 if sign0 * sign1 > 0.0 {
828 let log_u = if delta > 0.0 {
830 delta + (-delta).exp().ln_1p()
831 } else {
832 delta.exp().ln_1p()
833 };
834 Some((log_u, sign0.signum()))
835 } else if delta == 0.0 {
836 None
838 } else {
839 let log_u = delta.max(0.0) + log1mexp_positive(delta.abs());
841 Some((log_u, sign0.signum() * -delta.signum()))
842 }
843 }
844
845 fn evaluate_two_terms(
846 quadctx: &QuadratureContext,
847 t0: KernelSumTerm,
848 t1: KernelSumTerm,
849 mu: f64,
850 sigma: f64,
851 ) -> Result<Self, EstimationError> {
852 let max_k_needed = t0.k.max(t1.k) + 4;
853 let bundle0 = log_kernel_bundle(quadctx, t0.m, mu, sigma, max_k_needed)?;
854 let mut overall_mode = bundle0.mode;
855 let bundle1_owned = if t0.m == t1.m {
856 None
857 } else {
858 let bundle1 = log_kernel_bundle(quadctx, t1.m, mu, sigma, max_k_needed)?;
859 overall_mode = worst_mode(overall_mode, bundle1.mode);
860 Some(bundle1)
861 };
862 let bundle1 = bundle1_owned.as_ref().unwrap_or(&bundle0);
863
864 let (log_mag0, ratio0) = Self::term_log_mag_and_ratio(&bundle0, t0);
865 let (log_mag1, ratio1) = Self::term_log_mag_and_ratio(bundle1, t1);
866 let log_mags = [log_mag0, log_mag1];
867 let signs = [t0.coeff.signum(), t1.coeff.signum()];
868
869 let analytic = if log_mag0.is_finite() && log_mag1.is_finite() {
873 Self::analytic_log_mag_gap(t0, t1, &ratio0).and_then(|delta| {
874 Self::reduce_signed_pair(signs[0], signs[1], delta)
875 .map(|(log_u, sign_u)| (log_u, sign_u, delta))
876 })
877 } else {
878 None
879 };
880 let (log_s, sign_s, log_w0, log_w1) = match analytic {
881 Some((log_u, sign_u, delta)) => (log_mag0 + log_u, sign_u, -log_u, delta - log_u),
882 None => {
883 let (log_s, sign_s) = signed_log_sum_exp(&log_mags, &signs);
884 (log_s, sign_s, log_mag0 - log_s, log_mag1 - log_s)
885 }
886 };
887 if !log_s.is_finite() || sign_s <= 0.0 {
888 return Ok(Self::non_positive(overall_mode));
889 }
890
891 let w0 = sign_s * signs[0] * log_w0.exp();
892 let w1 = sign_s * signs[1] * log_w1.exp();
893 let wr1 = w0 * ratio0[1] + w1 * ratio1[1];
894 let wr2 = w0 * ratio0[2] + w1 * ratio1[2];
895 let wr3 = w0 * ratio0[3] + w1 * ratio1[3];
896 let wr4 = w0 * ratio0[4] + w1 * ratio1[4];
897
898 Ok(Self {
899 value: log_s,
900 d1: wr1,
901 d2: wr2 - wr1 * wr1,
902 d3: wr3 - 3.0 * wr1 * wr2 + 2.0 * wr1 * wr1 * wr1,
903 d4: wr4 - 4.0 * wr1 * wr3 - 3.0 * wr2 * wr2 + 12.0 * wr1 * wr1 * wr2
904 - 6.0 * wr1.powi(4),
905 mode: overall_mode,
906 })
907 }
908
909 pub fn single_term(
914 quadctx: &QuadratureContext,
915 k: usize,
916 m: f64,
917 mu: f64,
918 sigma: f64,
919 ) -> Result<Self, EstimationError> {
920 let max_k_needed = k + 4;
921 let lb = log_kernel_bundle(quadctx, m, mu, sigma, max_k_needed)?;
922 Ok(Self::from_log_value_and_ratios(
923 lb.get(k),
924 kernel_ratio_jet(&lb, k, m, 4),
925 lb.mode,
926 ))
927 }
928
929 pub fn evaluate(
943 quadctx: &QuadratureContext,
944 terms: &[KernelSumTerm],
945 mu: f64,
946 sigma: f64,
947 ) -> Result<Self, EstimationError> {
948 if terms.is_empty() {
949 crate::bail_invalid_estim!("KernelSumJet requires at least one term");
952 }
953
954 if terms.len() == 1 {
956 let t = &terms[0];
957 if t.coeff <= 0.0 {
958 return Ok(Self::non_positive(
962 IntegratedExpectationMode::ExactClosedForm,
963 ));
964 }
965 let jet = Self::single_term(quadctx, t.k, t.m, mu, sigma)?;
966 return Ok(Self {
967 value: t.coeff.ln() + jet.value,
968 d1: jet.d1,
969 d2: jet.d2,
970 d3: jet.d3,
971 d4: jet.d4,
972 mode: jet.mode,
973 });
974 }
975 if terms.len() == 2 {
976 return Self::evaluate_two_terms(quadctx, terms[0], terms[1], mu, sigma);
977 }
978
979 let max_k_needed = terms.iter().map(|t| t.k).max().unwrap_or(0) + 4;
980
981 let mut log_bundles: Vec<(f64, LogLognormalKernelBundle)> = Vec::with_capacity(2);
983 let mut overall_mode = IntegratedExpectationMode::ExactClosedForm;
984 for term in terms {
985 if !log_bundles.iter().any(|(m, _)| *m == term.m) {
986 let b = log_kernel_bundle(quadctx, term.m, mu, sigma, max_k_needed)?;
987 overall_mode = worst_mode(overall_mode, b.mode);
988 log_bundles.push((term.m, b));
989 }
990 }
991
992 let get_lb = |m: f64| -> &LogLognormalKernelBundle {
993 &log_bundles
994 .iter()
995 .find(|(bm, _)| *bm == m)
996 .expect("the loop above pushes a bundle for every distinct term.m before any lookup")
997 .1
998 };
999
1000 let mut log_mags: Vec<f64> = Vec::with_capacity(terms.len());
1002 let mut signs: Vec<f64> = Vec::with_capacity(terms.len());
1003 let mut ratios: Vec<[f64; 5]> = Vec::with_capacity(terms.len());
1004 for term in terms {
1005 let lb = get_lb(term.m);
1006 log_mags.push(term.coeff.abs().ln() + lb.get(term.k));
1007 signs.push(term.coeff.signum());
1008 ratios.push(kernel_ratio_jet(lb, term.k, term.m, 4));
1009 }
1010
1011 let (log_s, sign_s) = signed_log_sum_exp(&log_mags, &signs);
1013
1014 if !log_s.is_finite() || sign_s <= 0.0 {
1015 return Ok(Self::non_positive(overall_mode));
1017 }
1018
1019 let mut wr1 = 0.0;
1022 let mut wr2 = 0.0;
1023 let mut wr3 = 0.0;
1024 let mut wr4 = 0.0;
1025 for i in 0..terms.len() {
1026 let w = sign_s * signs[i] * (log_mags[i] - log_s).exp();
1027 wr1 += w * ratios[i][1];
1028 wr2 += w * ratios[i][2];
1029 wr3 += w * ratios[i][3];
1030 wr4 += w * ratios[i][4];
1031 }
1032
1033 Ok(Self {
1034 value: log_s,
1035 d1: wr1,
1036 d2: wr2 - wr1 * wr1,
1037 d3: wr3 - 3.0 * wr1 * wr2 + 2.0 * wr1 * wr1 * wr1,
1038 d4: wr4 - 4.0 * wr1 * wr3 - 3.0 * wr2 * wr2 + 12.0 * wr1 * wr1 * wr2
1039 - 6.0 * wr1.powi(4),
1040 mode: overall_mode,
1041 })
1042 }
1043}
1044
1045#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1049pub enum LatentSurvivalEventType {
1050 RightCensored,
1052 ExactEvent,
1054 IntervalCensored,
1056}
1057
1058impl fmt::Display for LatentSurvivalEventType {
1059 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1060 match self {
1061 Self::RightCensored => write!(f, "right_censored"),
1062 Self::ExactEvent => write!(f, "exact_event"),
1063 Self::IntervalCensored => write!(f, "interval_censored"),
1064 }
1065 }
1066}
1067
1068#[derive(Clone, Copy, Debug)]
1083pub struct LatentSurvivalRow {
1084 pub event_type: LatentSurvivalEventType,
1085 pub mass_entry: f64,
1088 pub mass_exit: f64,
1090 pub mass_left: f64,
1092 pub mass_right: f64,
1094 pub mass_unloaded_left: f64,
1096 pub mass_unloaded_right: f64,
1098 pub mass_unloaded_entry: f64,
1100 pub mass_unloaded_exit: f64,
1102 pub hazard_loaded: f64,
1104 pub hazard_unloaded: f64,
1106}
1107
1108impl LatentSurvivalRow {
1109 pub fn right_censored(
1115 mass_entry: f64,
1116 mass_exit: f64,
1117 mass_unloaded_entry: f64,
1118 mass_unloaded_exit: f64,
1119 ) -> Self {
1120 Self {
1121 event_type: LatentSurvivalEventType::RightCensored,
1122 mass_entry,
1123 mass_exit,
1124 mass_left: 0.0,
1125 mass_right: 0.0,
1126 mass_unloaded_left: 0.0,
1127 mass_unloaded_right: 0.0,
1128 mass_unloaded_entry,
1129 mass_unloaded_exit,
1130 hazard_loaded: 0.0,
1131 hazard_unloaded: 0.0,
1132 }
1133 }
1134
1135 pub fn exact_event(
1137 mass_entry: f64,
1138 mass_exit: f64,
1139 mass_unloaded_entry: f64,
1140 mass_unloaded_exit: f64,
1141 hazard_loaded: f64,
1142 hazard_unloaded: f64,
1143 ) -> Self {
1144 Self {
1145 event_type: LatentSurvivalEventType::ExactEvent,
1146 mass_entry,
1147 mass_exit,
1148 mass_left: 0.0,
1149 mass_right: 0.0,
1150 mass_unloaded_left: 0.0,
1151 mass_unloaded_right: 0.0,
1152 mass_unloaded_entry,
1153 mass_unloaded_exit,
1154 hazard_loaded,
1155 hazard_unloaded,
1156 }
1157 }
1158
1159 pub fn interval_censored(
1161 mass_entry: f64,
1162 mass_left: f64,
1163 mass_right: f64,
1164 mass_unloaded_entry: f64,
1165 mass_unloaded_left: f64,
1166 mass_unloaded_right: f64,
1167 ) -> Self {
1168 Self {
1169 event_type: LatentSurvivalEventType::IntervalCensored,
1170 mass_entry,
1171 mass_exit: 0.0,
1172 mass_left,
1173 mass_right,
1174 mass_unloaded_left,
1175 mass_unloaded_right,
1176 mass_unloaded_entry,
1177 mass_unloaded_exit: 0.0,
1178 hazard_loaded: 0.0,
1179 hazard_unloaded: 0.0,
1180 }
1181 }
1182
1183 pub fn validate(&self) -> Result<(), EstimationError> {
1184 let fields = [
1185 ("mass_entry", self.mass_entry),
1186 ("mass_exit", self.mass_exit),
1187 ("mass_left", self.mass_left),
1188 ("mass_right", self.mass_right),
1189 ("mass_unloaded_left", self.mass_unloaded_left),
1190 ("mass_unloaded_right", self.mass_unloaded_right),
1191 ("mass_unloaded_entry", self.mass_unloaded_entry),
1192 ("mass_unloaded_exit", self.mass_unloaded_exit),
1193 ("hazard_loaded", self.hazard_loaded),
1194 ("hazard_unloaded", self.hazard_unloaded),
1195 ];
1196 for (name, value) in fields {
1197 if !value.is_finite() || value < 0.0 {
1198 crate::bail_invalid_estim!(
1199 "latent survival row has invalid {name}={value}; expected a finite non-negative value"
1200 );
1201 }
1202 }
1203
1204 match self.event_type {
1205 LatentSurvivalEventType::RightCensored => {
1206 if self.mass_exit < self.mass_entry {
1207 crate::bail_invalid_estim!(
1208 "latent survival right-censored row requires mass_exit >= mass_entry, got {} < {}",
1209 self.mass_exit,
1210 self.mass_entry
1211 );
1212 }
1213 if self.mass_unloaded_exit < self.mass_unloaded_entry {
1214 crate::bail_invalid_estim!(
1215 "latent survival right-censored row requires unloaded exit mass >= unloaded entry mass, got {} < {}",
1216 self.mass_unloaded_exit,
1217 self.mass_unloaded_entry
1218 );
1219 }
1220 if self.mass_left > 0.0
1221 || self.mass_right > 0.0
1222 || self.mass_unloaded_left > 0.0
1223 || self.mass_unloaded_right > 0.0
1224 || self.hazard_loaded > 0.0
1225 || self.hazard_unloaded > 0.0
1226 {
1227 crate::bail_invalid_estim!("latent survival right-censored row cannot carry interval masses or event hazards"
1228 .to_string(),);
1229 }
1230 }
1231 LatentSurvivalEventType::ExactEvent => {
1232 if self.mass_exit < self.mass_entry {
1233 crate::bail_invalid_estim!(
1234 "latent survival exact-event row requires mass_exit >= mass_entry, got {} < {}",
1235 self.mass_exit,
1236 self.mass_entry
1237 );
1238 }
1239 if self.mass_unloaded_exit < self.mass_unloaded_entry {
1240 crate::bail_invalid_estim!(
1241 "latent survival exact-event row requires unloaded exit mass >= unloaded entry mass, got {} < {}",
1242 self.mass_unloaded_exit,
1243 self.mass_unloaded_entry
1244 );
1245 }
1246 if self.mass_left > 0.0
1247 || self.mass_right > 0.0
1248 || self.mass_unloaded_left > 0.0
1249 || self.mass_unloaded_right > 0.0
1250 {
1251 crate::bail_invalid_estim!(
1252 "latent survival exact-event row cannot carry interval masses"
1253 );
1254 }
1255 if self.hazard_loaded == 0.0 && self.hazard_unloaded == 0.0 {
1256 crate::bail_invalid_estim!("latent survival exact-event row requires a positive loaded or unloaded hazard"
1257 .to_string(),);
1258 }
1259 }
1260 LatentSurvivalEventType::IntervalCensored => {
1261 if self.mass_left < self.mass_entry || self.mass_right < self.mass_left {
1262 crate::bail_invalid_estim!(
1263 "latent survival interval row requires mass_entry <= mass_left <= mass_right, got entry={}, left={}, right={}",
1264 self.mass_entry,
1265 self.mass_left,
1266 self.mass_right
1267 );
1268 }
1269 if self.mass_unloaded_left < self.mass_unloaded_entry
1270 || self.mass_unloaded_right < self.mass_unloaded_left
1271 {
1272 crate::bail_invalid_estim!(
1273 "latent survival interval row requires unloaded_entry <= unloaded_left <= unloaded_right, got entry={}, left={}, right={}",
1274 self.mass_unloaded_entry,
1275 self.mass_unloaded_left,
1276 self.mass_unloaded_right
1277 );
1278 }
1279 if self.mass_exit > 0.0
1280 || self.mass_unloaded_exit > 0.0
1281 || self.hazard_loaded > 0.0
1282 || self.hazard_unloaded > 0.0
1283 {
1284 crate::bail_invalid_estim!(
1285 "latent survival interval row cannot carry exit masses or event hazards"
1286 .to_string(),
1287 );
1288 }
1289 }
1290 }
1291
1292 Ok(())
1293 }
1294}
1295
1296fn exact_event_kernel_jet(
1297 quadctx: &QuadratureContext,
1298 row: &LatentSurvivalRow,
1299 mu: f64,
1300 sigma: f64,
1301) -> Result<LogKernelSumJet, EstimationError> {
1302 if row.hazard_loaded < 0.0 || row.hazard_unloaded < 0.0 {
1303 crate::bail_invalid_estim!(
1304 "latent survival exact-event hazards must be non-negative, got loaded={} unloaded={}",
1305 row.hazard_loaded,
1306 row.hazard_unloaded
1307 );
1308 }
1309 match (row.hazard_unloaded > 0.0, row.hazard_loaded > 0.0) {
1310 (true, true) => {
1311 let terms = [
1312 KernelSumTerm {
1313 coeff: row.hazard_unloaded,
1314 k: 0,
1315 m: row.mass_exit,
1316 },
1317 KernelSumTerm {
1318 coeff: row.hazard_loaded,
1319 k: 1,
1320 m: row.mass_exit,
1321 },
1322 ];
1323 LogKernelSumJet::evaluate(quadctx, &terms, mu, sigma)
1324 }
1325 (true, false) => {
1326 let jet = LogKernelSumJet::single_term(quadctx, 0, row.mass_exit, mu, sigma)?;
1327 Ok(LogKernelSumJet {
1328 value: row.hazard_unloaded.ln() + jet.value,
1329 d1: jet.d1,
1330 d2: jet.d2,
1331 d3: jet.d3,
1332 d4: jet.d4,
1333 mode: jet.mode,
1334 })
1335 }
1336 (false, true) => {
1337 let jet = LogKernelSumJet::single_term(quadctx, 1, row.mass_exit, mu, sigma)?;
1338 Ok(LogKernelSumJet {
1339 value: row.hazard_loaded.ln() + jet.value,
1340 d1: jet.d1,
1341 d2: jet.d2,
1342 d3: jet.d3,
1343 d4: jet.d4,
1344 mode: jet.mode,
1345 })
1346 }
1347 (false, false) => Err(EstimationError::InvalidInput(
1348 "latent survival exact-event row requires a positive loaded or unloaded hazard"
1349 .to_string(),
1350 )),
1351 }
1352}
1353
1354#[derive(Clone, Copy, Debug)]
1361pub struct LatentSurvivalRowJet {
1362 pub log_lik: f64,
1363 pub score: f64,
1364 pub neg_hessian: f64,
1365 pub d3: f64,
1366 pub score_log_sigma: f64,
1367 pub neg_hessian_log_sigma: f64,
1368}
1369
1370#[inline]
1371fn log_sigma_score_from_log_sum(jet: &LogKernelSumJet, sigma: f64) -> f64 {
1372 let sigma2 = sigma * sigma;
1373 sigma2 * (jet.d2 + jet.d1 * jet.d1)
1374}
1375
1376#[inline]
1377fn log_sigma_neg_hessian_from_log_sum(jet: &LogKernelSumJet, sigma: f64) -> f64 {
1378 let sigma2 = sigma * sigma;
1379 let sigma4 = sigma2 * sigma2;
1380 let d1 = jet.d1;
1381 let d2 = jet.d2;
1382 let d3 = jet.d3;
1383 let d4 = jet.d4;
1384 let s2_over_s = d2 + d1 * d1;
1385 let s4_over_s_minus_s2_sq = d4 + 4.0 * d1 * d3 + 2.0 * d2 * d2 + 4.0 * d1 * d1 * d2;
1390 -(2.0 * sigma2 * s2_over_s + sigma4 * s4_over_s_minus_s2_sq)
1391}
1392
1393impl LatentSurvivalRowJet {
1394 pub fn evaluate(
1395 quadctx: &QuadratureContext,
1396 row: &LatentSurvivalRow,
1397 mu: f64,
1398 sigma: f64,
1399 ) -> Result<Self, EstimationError> {
1400 row.validate()?;
1401 match row.event_type {
1402 LatentSurvivalEventType::RightCensored => Self::right_censored(quadctx, mu, sigma, row),
1403 LatentSurvivalEventType::ExactEvent => Self::exact_event(quadctx, mu, sigma, row),
1404 LatentSurvivalEventType::IntervalCensored => {
1405 Self::interval_censored(quadctx, mu, sigma, row)
1406 }
1407 }
1408 }
1409
1410 fn right_censored(
1418 quadctx: &QuadratureContext,
1419 mu: f64,
1420 sigma: f64,
1421 row: &LatentSurvivalRow,
1422 ) -> Result<Self, EstimationError> {
1423 let has_unloaded = row.mass_unloaded_exit != 0.0 || row.mass_unloaded_entry != 0.0;
1424
1425 let mass_exit_loaded = row.mass_exit;
1429 let mass_entry_loaded = row.mass_entry;
1430
1431 let unloaded_offset = if has_unloaded {
1433 -row.mass_unloaded_exit + row.mass_unloaded_entry
1434 } else {
1435 0.0
1436 };
1437
1438 let num = LogKernelSumJet::single_term(quadctx, 0, mass_exit_loaded, mu, sigma)?;
1439 if mass_entry_loaded > 0.0 {
1440 let den = LogKernelSumJet::single_term(quadctx, 0, mass_entry_loaded, mu, sigma)?;
1441 Ok(Self {
1442 log_lik: unloaded_offset + num.value - den.value,
1443 score: num.d1 - den.d1,
1444 neg_hessian: -(num.d2 - den.d2),
1445 d3: num.d3 - den.d3,
1446 score_log_sigma: log_sigma_score_from_log_sum(&num, sigma)
1447 - log_sigma_score_from_log_sum(&den, sigma),
1448 neg_hessian_log_sigma: log_sigma_neg_hessian_from_log_sum(&num, sigma)
1449 - log_sigma_neg_hessian_from_log_sum(&den, sigma),
1450 })
1451 } else {
1452 Ok(Self {
1453 log_lik: unloaded_offset + num.value,
1454 score: num.d1,
1455 neg_hessian: -num.d2,
1456 d3: num.d3,
1457 score_log_sigma: log_sigma_score_from_log_sum(&num, sigma),
1458 neg_hessian_log_sigma: log_sigma_neg_hessian_from_log_sum(&num, sigma),
1459 })
1460 }
1461 }
1462
1463 fn exact_event(
1467 quadctx: &QuadratureContext,
1468 mu: f64,
1469 sigma: f64,
1470 row: &LatentSurvivalRow,
1471 ) -> Result<Self, EstimationError> {
1472 let unloaded_offset = if row.mass_unloaded_exit != 0.0 || row.mass_unloaded_entry != 0.0 {
1473 -row.mass_unloaded_exit + row.mass_unloaded_entry
1474 } else {
1475 0.0
1476 };
1477 let num = exact_event_kernel_jet(quadctx, row, mu, sigma)?;
1478
1479 if row.mass_entry > 0.0 {
1480 let den = LogKernelSumJet::single_term(quadctx, 0, row.mass_entry, mu, sigma)?;
1481 Ok(Self {
1482 log_lik: unloaded_offset + num.value - den.value,
1483 score: num.d1 - den.d1,
1484 neg_hessian: -(num.d2 - den.d2),
1485 d3: num.d3 - den.d3,
1486 score_log_sigma: log_sigma_score_from_log_sum(&num, sigma)
1487 - log_sigma_score_from_log_sum(&den, sigma),
1488 neg_hessian_log_sigma: log_sigma_neg_hessian_from_log_sum(&num, sigma)
1489 - log_sigma_neg_hessian_from_log_sum(&den, sigma),
1490 })
1491 } else {
1492 Ok(Self {
1493 log_lik: unloaded_offset + num.value,
1494 score: num.d1,
1495 neg_hessian: -num.d2,
1496 d3: num.d3,
1497 score_log_sigma: log_sigma_score_from_log_sum(&num, sigma),
1498 neg_hessian_log_sigma: log_sigma_neg_hessian_from_log_sum(&num, sigma),
1499 })
1500 }
1501 }
1502
1503 fn interval_censored(
1505 quadctx: &QuadratureContext,
1506 mu: f64,
1507 sigma: f64,
1508 row: &LatentSurvivalRow,
1509 ) -> Result<Self, EstimationError> {
1510 let num_terms = [
1511 KernelSumTerm {
1512 coeff: (-row.mass_unloaded_left).exp(),
1513 k: 0,
1514 m: row.mass_left,
1515 },
1516 KernelSumTerm {
1517 coeff: -(-row.mass_unloaded_right).exp(),
1518 k: 0,
1519 m: row.mass_right,
1520 },
1521 ];
1522 let num = LogKernelSumJet::evaluate(quadctx, &num_terms, mu, sigma)?;
1523
1524 if row.mass_entry > 0.0 {
1525 let den = LogKernelSumJet::single_term(quadctx, 0, row.mass_entry, mu, sigma)?;
1526 Ok(Self {
1527 log_lik: num.value + row.mass_unloaded_entry - den.value,
1528 score: num.d1 - den.d1,
1529 neg_hessian: -(num.d2 - den.d2),
1530 d3: num.d3 - den.d3,
1531 score_log_sigma: log_sigma_score_from_log_sum(&num, sigma)
1532 - log_sigma_score_from_log_sum(&den, sigma),
1533 neg_hessian_log_sigma: log_sigma_neg_hessian_from_log_sum(&num, sigma)
1534 - log_sigma_neg_hessian_from_log_sum(&den, sigma),
1535 })
1536 } else {
1537 Ok(Self {
1538 log_lik: num.value + row.mass_unloaded_entry,
1539 score: num.d1,
1540 neg_hessian: -num.d2,
1541 d3: num.d3,
1542 score_log_sigma: log_sigma_score_from_log_sum(&num, sigma),
1543 neg_hessian_log_sigma: log_sigma_neg_hessian_from_log_sum(&num, sigma),
1544 })
1545 }
1546 }
1547}
1548
1549#[cfg(test)]
1550mod tests {
1551 use super::*;
1552
1553 #[test]
1573 fn zz_measure_2610_reformulation_reproduces_the_differenced_value_it_replaces() {
1574 let quadctx = QuadratureContext::new();
1575 let (mass, mu) = (1.0_f64, 0.0_f64);
1576
1577 let mut rows: Vec<(f64, f64, f64)> = Vec::new();
1578 let mut log_sigma = 4.0_f64;
1579 while log_sigma <= 7.0001 {
1580 let sigma = log_sigma.exp();
1581 let bundle = log_kernel_bundle(&quadctx, mass, mu, sigma, 4)
1582 .expect("bundle over the measured range");
1583 let ratio1 = (bundle.get(1) - bundle.get(0)).exp();
1585 let ratio2 = (bundle.get(2) - bundle.get(0)).exp();
1586 let differenced = ratio2 - ratio1 * ratio1;
1587 let stable = bundle
1588 .second_cumulant_ratio(0, sigma)
1589 .expect("cancellation-free form is defined on this range");
1590 println!(
1591 "[2610] log_sigma={log_sigma:.2} differenced={differenced:.12e} stable={stable:.12e}"
1592 );
1593 rows.push((log_sigma, differenced, stable));
1594 log_sigma += 0.1;
1595 }
1596
1597 let jump = |a: f64, b: f64| -> f64 {
1599 let scale = a.abs().max(b.abs());
1600 if scale > 0.0 { (b - a).abs() / scale } else { 0.0 }
1601 };
1602 let mut worst_differenced = 0.0_f64;
1603 let mut sign_flips_differenced = 0usize;
1604 for pair in rows.windows(2) {
1605 let (d0, d1) = (pair[0].1, pair[1].1);
1606 let differenced_jump = jump(d0, d1);
1607 if differenced_jump > worst_differenced {
1608 worst_differenced = differenced_jump;
1609 }
1610 if d0 * d1 < 0.0 {
1611 sign_flips_differenced += 1;
1612 }
1613 }
1614
1615 let mut worst_disagreement = 0.0_f64;
1616 for row in &rows {
1617 let scale = row.1.abs().max(row.2.abs());
1618 if scale > 0.0 {
1619 let relative = (row.1 - row.2).abs() / scale;
1620 if relative > worst_disagreement {
1621 worst_disagreement = relative;
1622 }
1623 }
1624 }
1625 println!(
1626 "[2610] worst differenced step jump={worst_differenced:.6} \
1627 sign flips={sign_flips_differenced} worst disagreement={worst_disagreement:.3e}"
1628 );
1629
1630 assert!(
1641 worst_differenced < 1.0 && sign_flips_differenced == 0,
1642 "#2610: this ladder is supposed to be well conditioned, so that agreement \
1643 between the two formations means something (worst jump {worst_differenced}, \
1644 {sign_flips_differenced} sign flips)"
1645 );
1646 assert!(
1647 rows.iter().all(|row| row.2.is_finite()),
1648 "#2610: the cancellation-free second cumulant must be finite across the ladder"
1649 );
1650 assert!(
1656 worst_disagreement < 1.0e-12,
1657 "#2610: the reformulation must reproduce the differenced value wherever that \
1658 value is trustworthy; worst relative disagreement {worst_disagreement:e}"
1659 );
1660 }
1661
1662 #[test]
1663 fn frailty_scale_validation_distinguishes_fixed_and_learned_domains() {
1664 assert!(FrailtySpec::None.validate().is_ok());
1665 assert!(
1666 FrailtySpec::GaussianShift {
1667 scale: FrailtyScale::Fixed { sigma: 0.75 },
1668 }
1669 .validate()
1670 .is_ok()
1671 );
1672 assert!(
1673 FrailtySpec::HazardMultiplier {
1674 scale: FrailtyScale::Learned { initial_sigma: 0.5 },
1675 loading: HazardLoading::Full,
1676 }
1677 .validate()
1678 .is_ok()
1679 );
1680 assert!(
1681 FrailtySpec::GaussianShift {
1682 scale: FrailtyScale::Fixed { sigma: -0.1 },
1683 }
1684 .validate()
1685 .is_err()
1686 );
1687 assert!(
1688 FrailtySpec::GaussianShift {
1689 scale: FrailtyScale::Fixed { sigma: f64::NAN },
1690 }
1691 .validate()
1692 .is_err()
1693 );
1694 assert!(
1695 FrailtySpec::GaussianShift {
1696 scale: FrailtyScale::Learned { initial_sigma: 0.0 },
1697 }
1698 .validate()
1699 .is_err()
1700 );
1701 assert!(
1702 FrailtySpec::GaussianShift {
1703 scale: FrailtyScale::Learned {
1704 initial_sigma: f64::INFINITY,
1705 },
1706 }
1707 .validate()
1708 .is_err()
1709 );
1710 }
1711
1712 fn latent_binomial_row_log_lik(
1713 ctx: &QuadratureContext,
1714 eta: f64,
1715 sigma: f64,
1716 y: f64,
1717 weight: f64,
1718 ) -> f64 {
1719 let mu = latent_cloglog_jet5(ctx, eta, sigma)
1720 .expect("latent jet")
1721 .mean;
1722 let mu = mu.clamp(1e-12, 1.0 - 1e-12);
1723 weight * (y * mu.ln() + (1.0 - y) * (1.0 - mu).ln())
1724 }
1725
1726 #[test]
1727 fn kernel_ratio_jet_d1_fd_check() {
1728 let ctx = QuadratureContext::new();
1729 let mu = 0.3;
1730 let sigma = 0.5;
1731 let m = 1.0;
1732 let k = 0usize;
1733 let h = 1e-5;
1734
1735 let bundle = log_kernel_bundle(&ctx, m, mu, sigma, k + 4).unwrap();
1736 let log_k = bundle.get(k);
1737 let ratios = kernel_ratio_jet(&bundle, k, m, 2);
1738 let kc = log_k.exp();
1739 let d1 = kc * ratios[1];
1740 let d2 = kc * ratios[2];
1741
1742 let kp = log_kernel_term(&ctx, k, m, mu + h, sigma).unwrap().0.exp();
1743 let km = log_kernel_term(&ctx, k, m, mu - h, sigma).unwrap().0.exp();
1744 let fd_d1 = (kp - km) / (2.0 * h);
1745 assert!(
1746 (d1 - fd_d1).abs() / fd_d1.abs().max(1e-15) < 1e-4,
1747 "d1: jet={d1}, fd={fd_d1}",
1748 );
1749
1750 let fd_d2 = (kp - 2.0 * kc + km) / (h * h);
1751 assert!(
1752 (d2 - fd_d2).abs() / fd_d2.abs().max(1e-15) < 1e-3,
1753 "d2: jet={d2}, fd={fd_d2}",
1754 );
1755 }
1756
1757 #[test]
1758 fn survival_right_censored_score_fd() {
1759 let ctx = QuadratureContext::new();
1760 let mu = -0.5;
1761 let sigma = 0.3;
1762 let h = 1e-6;
1763 let row = LatentSurvivalRow::right_censored(0.0, 2.0, 0.0, 0.0);
1764 let ll_p = LatentSurvivalRowJet::evaluate(&ctx, &row, mu + h, sigma)
1765 .unwrap()
1766 .log_lik;
1767 let ll_m = LatentSurvivalRowJet::evaluate(&ctx, &row, mu - h, sigma)
1768 .unwrap()
1769 .log_lik;
1770 let fd_score = (ll_p - ll_m) / (2.0 * h);
1771 let jet = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma).unwrap();
1772 assert!(
1773 (jet.score - fd_score).abs() / fd_score.abs().max(1e-15) < 1e-3,
1774 "score={}, fd={fd_score}",
1775 jet.score
1776 );
1777 }
1778
1779 #[test]
1780 fn survival_exact_event_score_fd() {
1781 let ctx = QuadratureContext::new();
1782 let mu = 0.2;
1783 let sigma = 0.5;
1784 let h = 1e-6;
1785 let row = LatentSurvivalRow::exact_event(0.0, 1.5, 0.0, 0.0, (-0.3f64).exp(), 0.0);
1786 let ll_p = LatentSurvivalRowJet::evaluate(&ctx, &row, mu + h, sigma)
1787 .unwrap()
1788 .log_lik;
1789 let ll_m = LatentSurvivalRowJet::evaluate(&ctx, &row, mu - h, sigma)
1790 .unwrap()
1791 .log_lik;
1792 let fd_score = (ll_p - ll_m) / (2.0 * h);
1793 let jet = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma).unwrap();
1794 assert!(
1795 (jet.score - fd_score).abs() / fd_score.abs().max(1e-15) < 1e-3,
1796 "score={}, fd={fd_score}",
1797 jet.score
1798 );
1799 }
1800
1801 #[test]
1802 fn survival_exact_event_loaded_vs_unloaded_score_fd() {
1803 let ctx = QuadratureContext::new();
1804 let mu = -0.1;
1805 let sigma = 0.4;
1806 let h = 1e-6;
1807 let row = LatentSurvivalRow::exact_event(0.3, 1.2, 0.2, 0.6, 0.9, 0.15);
1808 let ll_p = LatentSurvivalRowJet::evaluate(&ctx, &row, mu + h, sigma)
1809 .unwrap()
1810 .log_lik;
1811 let ll_m = LatentSurvivalRowJet::evaluate(&ctx, &row, mu - h, sigma)
1812 .unwrap()
1813 .log_lik;
1814 let fd_score = (ll_p - ll_m) / (2.0 * h);
1815 let jet = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma).unwrap();
1816 assert!(
1817 (jet.score - fd_score).abs() / fd_score.abs().max(1e-15) < 1e-3,
1818 "score={}, fd={fd_score}",
1819 jet.score
1820 );
1821 }
1822
1823 #[test]
1824 fn survival_right_censored_loaded_vs_unloaded_score_fd() {
1825 let ctx = QuadratureContext::new();
1826 let mu = 0.15;
1827 let sigma: f64 = 0.35;
1828 let h = 1e-6;
1829 let row = LatentSurvivalRow::right_censored(0.4, 1.7, 0.1, 0.5);
1830 let ll_p = LatentSurvivalRowJet::evaluate(&ctx, &row, mu + h, sigma)
1831 .unwrap()
1832 .log_lik;
1833 let ll_m = LatentSurvivalRowJet::evaluate(&ctx, &row, mu - h, sigma)
1834 .unwrap()
1835 .log_lik;
1836 let fd_score = (ll_p - ll_m) / (2.0 * h);
1837 let jet = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma).unwrap();
1838 assert!(
1839 (jet.score - fd_score).abs() / fd_score.abs().max(1e-15) < 1e-3,
1840 "score={}, fd={fd_score}",
1841 jet.score
1842 );
1843 }
1844
1845 #[test]
1846 fn survival_interval_censored_score_fd() {
1847 let ctx = QuadratureContext::new();
1848 let mu = 0.0;
1849 let sigma = 0.6;
1850 let h = 1e-6;
1851 let row = LatentSurvivalRow::interval_censored(0.0, 1.0, 2.0, 0.0, 0.0, 0.0);
1852 let ll_p = LatentSurvivalRowJet::evaluate(&ctx, &row, mu + h, sigma)
1853 .unwrap()
1854 .log_lik;
1855 let ll_m = LatentSurvivalRowJet::evaluate(&ctx, &row, mu - h, sigma)
1856 .unwrap()
1857 .log_lik;
1858 let fd_score = (ll_p - ll_m) / (2.0 * h);
1859 let jet = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma).unwrap();
1860 assert!(
1861 (jet.score - fd_score).abs() / fd_score.abs().max(1e-15) < 1e-3,
1862 "score={}, fd={fd_score}",
1863 jet.score
1864 );
1865 }
1866
1867 #[test]
1878 fn survival_narrow_interval_is_log_domain_stable_issue_2277() {
1879 let ctx = QuadratureContext::new();
1880 let (mu, sigma, m_l) = (0.0_f64, 0.6_f64, 1.0_f64);
1881 let ll_and_width = |gap: f64| -> (f64, f64) {
1893 let m_r = m_l + gap;
1894 let row = LatentSurvivalRow::interval_censored(0.0, m_l, m_r, 0.0, 0.0, 0.0);
1895 let log_lik = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma)
1896 .unwrap()
1897 .log_lik;
1898 (log_lik, m_r - m_l)
1899 };
1900 assert!(
1916 ll_and_width(1e-12).0.is_finite(),
1917 "narrow-interval log-lik must stay finite at the extreme gap: {}",
1918 ll_and_width(1e-12).0
1919 );
1920 let (ll1, width1) = ll_and_width(1e-8);
1921 let (ll2, width2) = ll_and_width(1e-12);
1922 assert!(
1923 ll1.is_finite() && ll2.is_finite(),
1924 "narrow-interval log-lik must stay finite: ll1={ll1}, ll2={ll2}"
1925 );
1926 let expected = (width1 / width2).ln();
1929 assert!(
1930 (ll1 - ll2 - expected).abs() < 1e-5,
1931 "narrow interval must follow the log-domain log(Δ) law: \
1932 ll(Δ₁)-ll(Δ₂)={}, expected {expected} (Δ₁={width1:e}, Δ₂={width2:e})",
1933 ll1 - ll2
1934 );
1935
1936 let mut gap = 1e-6_f64;
1950 let (mut previous, mut previous_width) = ll_and_width(gap);
1951 while gap > 1e-13 {
1952 let next_gap = gap * 1e-2;
1953 let (next, next_width) = ll_and_width(next_gap);
1954 let step_expected = (previous_width / next_width).ln();
1955 let residual = previous - next - step_expected;
1956 assert!(
1957 residual.abs() < 1e-9 + 100.0 * gap,
1958 "log(Δ) law must hold at every rung: Δ {previous_width:e}->{next_width:e} \
1959 gave {}, expected {step_expected}, residual {residual:e}",
1960 previous - next
1961 );
1962 gap = next_gap;
1963 previous = next;
1964 previous_width = next_width;
1965 }
1966 }
1967 #[test]
1968 fn survival_interval_censored_neg_hessian_fd() {
1969 let ctx = QuadratureContext::new();
1973 let mu = -0.2;
1974 let sigma = 0.55;
1975 let h = 2e-4;
1976 let row = LatentSurvivalRow::interval_censored(0.0, 0.7, 1.9, 0.0, 0.0, 0.0);
1977 let ll = |m: f64| {
1978 LatentSurvivalRowJet::evaluate(&ctx, &row, m, sigma)
1979 .unwrap()
1980 .log_lik
1981 };
1982 let fd_d2 = (ll(mu + h) - 2.0 * ll(mu) + ll(mu - h)) / (h * h);
1983 let jet = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma).unwrap();
1984 assert!(
1985 (jet.neg_hessian - (-fd_d2)).abs() / fd_d2.abs().max(1e-12) < 1e-2,
1986 "interval neg_hessian={}, fd(-d2)={}",
1987 jet.neg_hessian,
1988 -fd_d2
1989 );
1990 }
1991
1992 #[test]
1993 fn survival_interval_censored_log_sigma_score_fd() {
1994 let ctx = QuadratureContext::new();
1999 let mu = 0.1;
2000 let sigma: f64 = 0.6;
2001 let h = 1e-5;
2002 let row = LatentSurvivalRow::interval_censored(0.0, 0.8, 2.1, 0.0, 0.0, 0.0);
2003 let ll_at = |s: f64| {
2004 LatentSurvivalRowJet::evaluate(&ctx, &row, mu, s)
2005 .unwrap()
2006 .log_lik
2007 };
2008 let fd_dlogsigma =
2010 (ll_at((sigma.ln() + h).exp()) - ll_at((sigma.ln() - h).exp())) / (2.0 * h);
2011 let jet = LatentSurvivalRowJet::evaluate(&ctx, &row, mu, sigma).unwrap();
2012 assert!(
2013 (jet.score_log_sigma - fd_dlogsigma).abs() / fd_dlogsigma.abs().max(1e-12) < 1e-3,
2014 "interval score_log_sigma={}, fd={fd_dlogsigma}",
2015 jet.score_log_sigma
2016 );
2017 }
2018
2019 #[test]
2020 fn log_kernel_single_term_log_sigma_derivatives_match_ghq_reference() {
2021 let ctx = QuadratureContext::new();
2022 let mu = 0.2;
2023 let sigma = 1.0;
2024 let jet = LogKernelSumJet::single_term(&ctx, 0, 1.0, mu, sigma).unwrap();
2025 let ghq = crate::inference::quadrature::cloglog_ghq_derivatives_adaptive(&ctx, mu, sigma);
2026 let survival = (1.0 - ghq.l).max(1e-300);
2027 let survival_sigma_over_survival = -ghq.l_sigma / survival;
2028 let ref_score = sigma * survival_sigma_over_survival;
2029 let ref_neg_hessian = -(ref_score
2030 + sigma
2031 * sigma
2032 * (-ghq.l_sigmasigma / survival - survival_sigma_over_survival.powi(2)));
2033
2034 assert!(
2035 (log_sigma_score_from_log_sum(&jet, sigma) - ref_score).abs()
2036 / ref_score.abs().max(1e-12)
2037 < 1e-4,
2038 "log-sigma score={}, ref={ref_score}",
2039 log_sigma_score_from_log_sum(&jet, sigma)
2040 );
2041 assert!(
2042 (log_sigma_neg_hessian_from_log_sum(&jet, sigma) - ref_neg_hessian).abs()
2043 / ref_neg_hessian.abs().max(1e-12)
2044 < 1e-3,
2045 "log-sigma neg_hessian={}, ref={ref_neg_hessian}",
2046 log_sigma_neg_hessian_from_log_sum(&jet, sigma)
2047 );
2048 }
2049
2050 #[test]
2051 fn log_kernel_sum_jet_single_term_d1_fd() {
2052 let ctx = QuadratureContext::new();
2053 let mu = 0.5;
2054 let sigma = 0.4;
2055 let m = 1.0;
2056 let k = 0usize;
2057 let h = 1e-6;
2058
2059 let jet = LogKernelSumJet::single_term(&ctx, k, m, mu, sigma).unwrap();
2060 let val_p = log_kernel_term(&ctx, k, m, mu + h, sigma).unwrap().0;
2061 let val_m = log_kernel_term(&ctx, k, m, mu - h, sigma).unwrap().0;
2062 let fd_d1 = (val_p - val_m) / (2.0 * h);
2063 assert!(
2064 (jet.d1 - fd_d1).abs() / fd_d1.abs().max(1e-15) < 1e-3,
2065 "d1={}, fd={fd_d1}",
2066 jet.d1
2067 );
2068 }
2069
2070 #[test]
2071 fn log_kernel_sum_jet_single_term_d4_fd() {
2072 let ctx = QuadratureContext::new();
2073 let mu = 0.35;
2074 let sigma = 0.45;
2075 let m = 1.2;
2076 let k = 1usize;
2077 let h = 2e-3;
2078
2079 let jet = LogKernelSumJet::single_term(&ctx, k, m, mu, sigma).unwrap();
2080 let v_pp = log_kernel_term(&ctx, k, m, mu + 2.0 * h, sigma).unwrap().0;
2081 let v_p = log_kernel_term(&ctx, k, m, mu + h, sigma).unwrap().0;
2082 let v_0 = log_kernel_term(&ctx, k, m, mu, sigma).unwrap().0;
2083 let v_m = log_kernel_term(&ctx, k, m, mu - h, sigma).unwrap().0;
2084 let v_mm = log_kernel_term(&ctx, k, m, mu - 2.0 * h, sigma).unwrap().0;
2085 let fd_d4 = (v_mm - 4.0 * v_m + 6.0 * v_0 - 4.0 * v_p + v_pp) / h.powi(4);
2086 assert!(
2087 (jet.d4 - fd_d4).abs() / jet.d4.abs().max(fd_d4.abs()).max(1e-8) < 2e-2,
2088 "d4={}, fd={fd_d4}",
2089 jet.d4
2090 );
2091 }
2092
2093 #[test]
2094 fn latent_cloglog_jet_matches_point_limit_at_zero_sigma() {
2095 let ctx = QuadratureContext::new();
2096 let eta = -0.4;
2097 let jet = latent_cloglog_jet5(&ctx, eta, 0.0).expect("latent jet");
2098 let t = eta.exp();
2099 let d1 = (eta - t).exp();
2100 let d2 = (1.0 - t) * d1;
2101 let d3 = (t * t - 3.0 * t + 1.0) * d1;
2102 let d4 = (-t * t * t + 6.0 * t * t - 7.0 * t + 1.0) * d1;
2103 let d5 = (t.powi(4) - 10.0 * t.powi(3) + 25.0 * t * t - 15.0 * t + 1.0) * d1;
2104 assert!((jet.mean - (1.0 - (-t).exp())).abs() < 1e-12);
2105 assert!((jet.d1 - d1).abs() < 1e-12);
2106 assert!((jet.d2 - d2).abs() < 1e-12);
2107 assert!((jet.d3 - d3).abs() < 1e-12);
2108 assert!((jet.d4 - d4).abs() < 1e-12);
2109 assert!((jet.d5 - d5).abs() < 1e-12);
2110 }
2111
2112 #[test]
2113 fn latent_cloglog_jet_matches_exact_kernel_recurrence() {
2114 let ctx = QuadratureContext::new();
2115 let cases = [(-4.0, 0.15), (-1.2, 0.35), (0.4, 0.6), (1.3, 0.9)];
2116
2117 for (eta, sigma) in cases {
2118 let jet = latent_cloglog_jet5(&ctx, eta, sigma).expect("latent jet");
2119 let bundle = log_kernel_bundle(&ctx, 1.0, eta, sigma, 5).expect("kernel bundle");
2120 let k0 = bundle.get(0);
2121 let k1 = bundle.get(1).exp();
2122 let k2 = bundle.get(2).exp();
2123 let k3 = bundle.get(3).exp();
2124 let k4 = bundle.get(4).exp();
2125 let k5 = bundle.get(5).exp();
2126
2127 let mean = if k0.is_finite() { -k0.exp_m1() } else { 1.0 };
2128 let d1 = k1;
2129 let d2 = k1 - k2;
2130 let d3 = k1 - 3.0 * k2 + k3;
2131 let d4 = k1 - 7.0 * k2 + 6.0 * k3 - k4;
2132 let d5 = k1 - 15.0 * k2 + 25.0 * k3 - 10.0 * k4 + k5;
2133
2134 assert!((jet.mean - mean).abs() < 1e-12);
2135 assert!((jet.d1 - d1).abs() < 1e-12);
2136 assert!((jet.d2 - d2).abs() < 1e-12);
2137 assert!((jet.d3 - d3).abs() < 1e-12);
2138 assert!((jet.d4 - d4).abs() < 1e-12);
2139 assert!((jet.d5 - d5).abs() < 1e-12);
2140 }
2141 }
2142
2143 #[test]
2144 fn latent_cloglog_binomial_row_neg_hessian_matches_fd() {
2145 let ctx = QuadratureContext::new();
2146 let eta = 0.4;
2147 let sigma = 0.6;
2148 let y = 0.35;
2149 let weight = 2.0;
2150 let h = 1e-4;
2151
2152 let jet = latent_cloglog_jet5(&ctx, eta, sigma).expect("latent jet");
2153 let mu = jet.mean.clamp(1e-12, 1.0 - 1e-12);
2154 let ellmu = y / mu - (1.0 - y) / (1.0 - mu);
2155 let ellmumu = -y / (mu * mu) - (1.0 - y) / ((1.0 - mu) * (1.0 - mu));
2156 let neg_hessian = -weight * (ellmumu * jet.d1 * jet.d1 + ellmu * jet.d2);
2157
2158 let ll_minus = latent_binomial_row_log_lik(&ctx, eta - h, sigma, y, weight);
2159 let ll0 = latent_binomial_row_log_lik(&ctx, eta, sigma, y, weight);
2160 let ll_plus = latent_binomial_row_log_lik(&ctx, eta + h, sigma, y, weight);
2161 let neg_hessian_fd = -(ll_plus - 2.0 * ll0 + ll_minus) / (h * h);
2162
2163 let err = (neg_hessian - neg_hessian_fd).abs();
2164 let tol = 2e-5_f64.max(3e-3 * neg_hessian_fd.abs());
2165 assert!(
2166 err <= tol,
2167 "latent cloglog Bernoulli row curvature mismatch: analytic={} fd={}",
2168 neg_hessian,
2169 neg_hessian_fd
2170 );
2171 }
2172}