1use ndarray::{Array2, ArrayView1, ArrayView2};
9use rayon::prelude::*;
10
11fn validate_dense_surface_shape(n_rows: usize, n_columns: usize) -> Result<(), String> {
12 let cells = n_rows.checked_mul(n_columns).ok_or_else(|| {
13 format!("survival surface shape {n_rows}x{n_columns} overflows the addressable cell count")
14 })?;
15 let bytes = cells
16 .checked_mul(std::mem::size_of::<f64>())
17 .ok_or_else(|| {
18 format!("survival surface shape {n_rows}x{n_columns} overflows its byte count")
19 })?;
20 let cap = gam_runtime::resource::MemoryGovernor::global().single_materialization_cap_bytes();
21 if bytes > cap {
22 return Err(format!(
23 "dense survival surface {n_rows}x{n_columns} requires {bytes} bytes, exceeding the current single-materialization cap of {cap} bytes; consume survival chunks or stream CSV instead"
24 ));
25 }
26 Ok(())
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum SurvivalSurfaceKind {
32 Survival,
33 CumulativeHazard,
34 Hazard,
35 StandardError,
36}
37
38impl SurvivalSurfaceKind {
39 pub fn parse(value: &str) -> Result<Self, String> {
40 match value {
41 "survival" => Ok(Self::Survival),
42 "cumulative_hazard" => Ok(Self::CumulativeHazard),
43 "hazard" => Ok(Self::Hazard),
44 "survival_se" => Ok(Self::StandardError),
45 other => Err(format!("unknown survival surface kind '{other}'")),
46 }
47 }
48
49 pub const fn policy(self) -> SurvivalSurfacePolicy {
50 match self {
51 Self::Survival => SurvivalSurfacePolicy {
52 left_value: Some(1.0),
53 right_value: None,
54 positive_infinity_value: Some(0.0),
55 lower_bound: Some(0.0),
56 upper_bound: Some(1.0),
57 },
58 Self::CumulativeHazard => SurvivalSurfacePolicy {
59 left_value: Some(0.0),
60 right_value: None,
61 positive_infinity_value: Some(f64::INFINITY),
62 lower_bound: Some(0.0),
63 upper_bound: None,
64 },
65 Self::Hazard => SurvivalSurfacePolicy {
66 left_value: Some(0.0),
71 right_value: Some(0.0),
72 positive_infinity_value: Some(0.0),
73 lower_bound: Some(0.0),
74 upper_bound: None,
75 },
76 Self::StandardError => SurvivalSurfacePolicy {
77 left_value: None,
78 right_value: None,
79 positive_infinity_value: None,
80 lower_bound: Some(0.0),
81 upper_bound: None,
82 },
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq)]
94pub struct SurvivalSurfacePolicy {
95 pub left_value: Option<f64>,
96 pub right_value: Option<f64>,
97 pub positive_infinity_value: Option<f64>,
98 pub lower_bound: Option<f64>,
99 pub upper_bound: Option<f64>,
100}
101
102impl SurvivalSurfacePolicy {
103 #[inline]
104 fn clamp(self, mut value: f64) -> f64 {
105 if let Some(lower) = self.lower_bound
106 && value < lower
107 {
108 value = lower;
109 }
110 if let Some(upper) = self.upper_bound
111 && value > upper
112 {
113 value = upper;
114 }
115 value
116 }
117}
118
119pub struct SurvivalSurface<'a> {
121 kind: SurvivalSurfaceKind,
122 grid: ArrayView1<'a, f64>,
123 values: ArrayView2<'a, f64>,
124 order: Vec<usize>,
125}
126
127impl<'a> SurvivalSurface<'a> {
128 pub fn new(
129 kind: SurvivalSurfaceKind,
130 grid: ArrayView1<'a, f64>,
131 values: ArrayView2<'a, f64>,
132 ) -> Result<Self, String> {
133 let (n_rows, n_knots) = values.dim();
134 if n_knots == 0 || grid.len() != n_knots {
135 return Err(format!(
136 "survival surface requires a non-empty grid matching its columns; grid={}, surface={n_rows}x{n_knots}",
137 grid.len()
138 ));
139 }
140 for (index, &time) in grid.iter().enumerate() {
141 if !time.is_finite() {
142 return Err(format!(
143 "survival surface grid[{index}] must be finite, got {time}"
144 ));
145 }
146 }
147 let mut order: Vec<usize> = (0..grid.len()).collect();
148 order.sort_by(|&left, &right| grid[left].total_cmp(&grid[right]));
149 for pair in order.windows(2) {
150 if grid[pair[1]] <= grid[pair[0]] {
151 return Err(format!(
152 "survival surface grid values must be unique; grid[{}]={} duplicates grid[{}]={}",
153 pair[1], grid[pair[1]], pair[0], grid[pair[0]]
154 ));
155 }
156 }
157 Ok(Self {
158 kind,
159 grid,
160 values,
161 order,
162 })
163 }
164
165 pub fn nrows(&self) -> usize {
166 self.values.nrows()
167 }
168
169 pub fn value_at(&self, row: usize, query: f64) -> Result<f64, String> {
171 if row >= self.values.nrows() {
172 return Err(format!(
173 "survival surface row {row} is out of bounds for {} rows",
174 self.values.nrows()
175 ));
176 }
177 if query.is_nan() {
178 return Ok(f64::NAN);
179 }
180 let policy = self.kind.policy();
181 let last = self.grid.len() - 1;
182 let first_column = self.order[0];
183 let last_column = self.order[last];
184 let value = if query == f64::INFINITY {
185 policy
186 .positive_infinity_value
187 .unwrap_or(self.values[[row, last_column]])
188 } else if query < self.grid[first_column] {
189 policy
190 .left_value
191 .unwrap_or(self.values[[row, first_column]])
192 } else if query == self.grid[first_column] {
193 self.values[[row, first_column]]
194 } else if query > self.grid[last_column] {
195 policy
196 .right_value
197 .unwrap_or(self.values[[row, last_column]])
198 } else if query == self.grid[last_column] {
199 self.values[[row, last_column]]
200 } else {
201 let upper = self
202 .order
203 .partition_point(|&column| self.grid[column] <= query);
204 let lower = upper - 1;
205 let lower_column = self.order[lower];
206 let upper_column = self.order[upper];
207 let x0 = self.grid[lower_column];
208 let x1 = self.grid[upper_column];
209 let y0 = self.values[[row, lower_column]];
210 let y1 = self.values[[row, upper_column]];
211 y0 + (query - x0) * (y1 - y0) / (x1 - x0)
212 };
213 Ok(policy.clamp(value))
214 }
215
216 pub fn interpolate(&self, query: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
218 let n_rows = self.values.nrows();
219 let n_query = query.len();
220 validate_dense_surface_shape(n_rows, n_query)?;
221 let query_values = query.to_vec();
222 let rows: Result<Vec<Vec<f64>>, String> = (0..n_rows)
223 .into_par_iter()
224 .map(|row| {
225 query_values
226 .iter()
227 .map(|&time| self.value_at(row, time))
228 .collect()
229 })
230 .collect();
231 Array2::from_shape_vec((n_rows, n_query), rows?.into_iter().flatten().collect())
232 .map_err(|error| format!("failed to assemble survival surface: {error}"))
233 }
234
235 pub fn interpolate_chunked(
237 &self,
238 query: ArrayView1<'_, f64>,
239 chunk_policy: SurvivalSurfaceChunkPolicy,
240 people_chunk: Option<usize>,
241 time_chunk: Option<usize>,
242 ) -> Result<Array2<f64>, String> {
243 validate_dense_surface_shape(self.values.nrows(), query.len())?;
244 let chunks =
245 chunk_policy.chunks(self.values.nrows(), query.len(), people_chunk, time_chunk)?;
246 let mut output = Array2::<f64>::zeros((self.values.nrows(), query.len()));
247 for chunk in chunks {
248 for row in chunk.row_start..chunk.row_end {
249 for time_index in chunk.time_start..chunk.time_end {
250 output[[row, time_index]] = self.value_at(row, query[time_index])?;
251 }
252 }
253 }
254 Ok(output)
255 }
256
257 pub fn cumulative_hazard_slopes(
267 &self,
268 query: ArrayView1<'_, f64>,
269 ) -> Result<Array2<f64>, String> {
270 if self.kind != SurvivalSurfaceKind::CumulativeHazard {
271 return Err(
272 "cumulative_hazard_slopes requires a cumulative-hazard surface".to_string(),
273 );
274 }
275 let n_rows = self.values.nrows();
276 validate_dense_surface_shape(n_rows, query.len())?;
277 let first = self.order[0];
278 let last = self.order[self.order.len() - 1];
279 let mut output = Array2::<f64>::zeros((n_rows, query.len()));
280 for (query_index, &time) in query.iter().enumerate() {
281 if time.is_nan() {
282 return Err(format!(
283 "cumulative-hazard slope query time at index {query_index} is NaN"
284 ));
285 }
286 if !(time > self.grid[first] && time <= self.grid[last]) {
287 continue;
288 }
289 let upper_order = self
290 .order
291 .partition_point(|&column| self.grid[column] < time);
292 let lower_column = self.order[upper_order - 1];
293 let upper_column = self.order[upper_order];
294 let width = self.grid[upper_column] - self.grid[lower_column];
295 for row in 0..n_rows {
296 let increment = self.values[[row, upper_column]] - self.values[[row, lower_column]];
297 if increment < 0.0 {
298 return Err(format!(
299 "cumulative hazard must be non-decreasing in time; row {row} drops by {} between sorted knots {} and {}",
300 -increment,
301 upper_order - 1,
302 upper_order,
303 ));
304 }
305 output[[row, query_index]] = increment / width;
306 }
307 }
308 Ok(output)
309 }
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub struct SurvivalSurfaceChunk {
315 pub row_start: usize,
316 pub row_end: usize,
317 pub time_start: usize,
318 pub time_end: usize,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub struct SurvivalSurfaceChunkPolicy {
324 target_cells: usize,
325}
326
327pub fn failure_probability_from_survival(
329 survival: ArrayView2<'_, f64>,
330) -> Result<Array2<f64>, String> {
331 validate_dense_surface_shape(survival.nrows(), survival.ncols())?;
332 let values: Result<Vec<f64>, String> = survival
333 .iter()
334 .copied()
335 .enumerate()
336 .map(|(index, value)| {
337 if value.is_nan() {
338 return Ok(f64::NAN);
339 }
340 if !(0.0..=1.0).contains(&value) {
341 return Err(format!(
342 "survival probability at flat index {index} must lie in [0, 1], got {value}"
343 ));
344 }
345 Ok(1.0 - value)
346 })
347 .collect();
348 Array2::from_shape_vec(survival.dim(), values?)
349 .map_err(|error| format!("failed to assemble failure surface: {error}"))
350}
351
352pub fn cumulative_hazard_from_survival(
355 survival: ArrayView2<'_, f64>,
356) -> Result<Array2<f64>, String> {
357 validate_dense_surface_shape(survival.nrows(), survival.ncols())?;
358 let values: Result<Vec<f64>, String> = survival
359 .iter()
360 .copied()
361 .enumerate()
362 .map(|(index, value)| {
363 if value.is_nan() {
364 return Ok(f64::NAN);
365 }
366 if !(0.0..=1.0).contains(&value) {
367 return Err(format!(
368 "survival probability at flat index {index} must lie in [0, 1], got {value}"
369 ));
370 }
371 Ok(-value.ln())
372 })
373 .collect();
374 Array2::from_shape_vec(survival.dim(), values?)
375 .map_err(|error| format!("failed to assemble cumulative-hazard surface: {error}"))
376}
377
378pub struct ExponentialSurvivalParameters<'a> {
381 parameters: ArrayView2<'a, f64>,
382}
383
384impl<'a> ExponentialSurvivalParameters<'a> {
385 pub fn new(parameters: ArrayView2<'a, f64>) -> Result<Self, String> {
386 if parameters.ncols() == 0 {
387 return Err("survival parameter matrix must have at least one column".to_string());
388 }
389 for (row, &log_hazard) in parameters.column(0).iter().enumerate() {
390 if log_hazard.is_nan() {
391 return Err(format!(
392 "survival log-hazard parameter at row {row} must not be NaN"
393 ));
394 }
395 }
396 Ok(Self { parameters })
397 }
398
399 fn evaluate<F>(&self, times: ArrayView1<'_, f64>, value: F) -> Result<Array2<f64>, String>
400 where
401 F: Fn(f64, f64) -> f64 + Sync,
402 {
403 validate_dense_surface_shape(self.parameters.nrows(), times.len())?;
404 if let Some((index, _)) = times.iter().enumerate().find(|(_, time)| time.is_nan()) {
405 return Err(format!(
406 "survival prediction time at index {index} must not be NaN"
407 ));
408 }
409 let rows: Vec<Vec<f64>> = (0..self.parameters.nrows())
410 .into_par_iter()
411 .map(|row| {
412 let hazard = self.parameters[[row, 0]].exp();
413 times.iter().map(|&time| value(hazard, time)).collect()
414 })
415 .collect();
416 Array2::from_shape_vec(
417 (self.parameters.nrows(), times.len()),
418 rows.into_iter().flatten().collect(),
419 )
420 .map_err(|error| format!("failed to assemble exponential survival surface: {error}"))
421 }
422
423 pub fn survival(&self, times: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
424 self.evaluate(times, exponential_survival_at)
425 }
426
427 pub fn cumulative_hazard(&self, times: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
428 self.evaluate(times, exponential_cumulative_hazard_at)
429 }
430
431 pub fn failure_probability(&self, times: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
432 self.evaluate(times, |hazard, time| {
433 -(-exponential_cumulative_hazard_at(hazard, time)).exp_m1()
434 })
435 }
436
437 pub fn hazard(&self, times: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
438 self.evaluate(times, |hazard, _time| hazard)
439 }
440}
441
442#[inline]
443fn exponential_survival_at(hazard: f64, time: f64) -> f64 {
444 if time <= 0.0 {
445 return 1.0;
446 }
447 if time == f64::INFINITY {
448 return if hazard > 0.0 { 0.0 } else { 1.0 };
449 }
450 (-hazard * time).exp()
451}
452
453#[inline]
454fn exponential_cumulative_hazard_at(hazard: f64, time: f64) -> f64 {
455 if time <= 0.0 {
456 return 0.0;
457 }
458 if time == f64::INFINITY {
459 return if hazard > 0.0 { f64::INFINITY } else { 0.0 };
460 }
461 hazard * time
462}
463
464impl Default for SurvivalSurfaceChunkPolicy {
465 fn default() -> Self {
466 let target_bytes =
467 gam_runtime::resource::ResourcePolicy::default_library().row_chunk_target_bytes;
468 Self {
469 target_cells: (target_bytes / std::mem::size_of::<f64>()).max(1),
470 }
471 }
472}
473
474impl SurvivalSurfaceChunkPolicy {
475 pub fn target_cells(self) -> usize {
476 self.target_cells
477 }
478
479 pub fn default_shape(self) -> (usize, usize) {
481 let time = (self.target_cells as f64).sqrt().floor() as usize;
482 let time = time.max(1);
483 let people = (self.target_cells / time).max(1);
484 (people, time)
485 }
486
487 pub fn should_chunk(self, n_rows: usize, n_times: usize) -> bool {
488 n_rows.saturating_mul(n_times) > self.target_cells
489 }
490
491 pub fn resolve_shape(
492 self,
493 people: Option<usize>,
494 times: Option<usize>,
495 ) -> Result<(usize, usize), String> {
496 if people == Some(0) {
497 return Err("people_chunk must be positive".to_string());
498 }
499 if times == Some(0) {
500 return Err("time_grid_chunk must be positive".to_string());
501 }
502 Ok(match (people, times) {
503 (Some(people), Some(times)) => (people, times),
504 (Some(people), None) => (people, (self.target_cells / people).max(1)),
505 (None, Some(times)) => ((self.target_cells / times).max(1), times),
506 (None, None) => self.default_shape(),
507 })
508 }
509
510 pub fn chunks(
511 self,
512 n_rows: usize,
513 n_times: usize,
514 people: Option<usize>,
515 times: Option<usize>,
516 ) -> Result<Vec<SurvivalSurfaceChunk>, String> {
517 let (people, times) = self.resolve_shape(people, times)?;
518 let row_tiles = n_rows.div_ceil(people);
519 let time_tiles = n_times.div_ceil(times);
520 let mut chunks = Vec::with_capacity(row_tiles.saturating_mul(time_tiles));
521 for row_start in (0..n_rows).step_by(people) {
522 let row_end = (row_start + people).min(n_rows);
523 for time_start in (0..n_times).step_by(times) {
524 chunks.push(SurvivalSurfaceChunk {
525 row_start,
526 row_end,
527 time_start,
528 time_end: (time_start + times).min(n_times),
529 });
530 }
531 }
532 Ok(chunks)
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use ndarray::array;
540
541 #[test]
542 fn survival_and_cumulative_hazard_share_consistent_boundaries() {
543 let grid = array![1.0, 2.0];
544 let survival_values = array![[0.8_f64, 0.5]];
545 let cumulative_values = survival_values.mapv(|value| -value.ln());
546 let survival = SurvivalSurface::new(
547 SurvivalSurfaceKind::Survival,
548 grid.view(),
549 survival_values.view(),
550 )
551 .unwrap();
552 let cumulative = SurvivalSurface::new(
553 SurvivalSurfaceKind::CumulativeHazard,
554 grid.view(),
555 cumulative_values.view(),
556 )
557 .unwrap();
558 for time in [-1.0, 1.0, 3.0] {
559 let s = survival.value_at(0, time).unwrap();
560 let h = cumulative.value_at(0, time).unwrap();
561 assert!((s - (-h).exp()).abs() < 1e-15);
562 }
563 assert_eq!(survival.value_at(0, f64::INFINITY).unwrap(), 0.0);
564 assert_eq!(
565 cumulative.value_at(0, f64::INFINITY).unwrap(),
566 f64::INFINITY
567 );
568 }
569
570 #[test]
571 fn chunk_defaults_come_from_the_runtime_byte_policy() {
572 let policy = SurvivalSurfaceChunkPolicy::default();
573 let (people, times) = policy.default_shape();
574 assert!(people > 0 && times > 0);
575 assert!(people.saturating_mul(times) <= policy.target_cells());
576 assert!(policy.target_cells() - people * times < times);
577 }
578
579 #[test]
580 fn explicit_chunk_dimension_derives_its_companion_from_the_same_budget() {
581 let policy = SurvivalSurfaceChunkPolicy::default();
582 let (people, times) = policy.resolve_shape(None, Some(8)).unwrap();
583 assert_eq!(times, 8);
584 assert_eq!(people, policy.target_cells() / 8);
585 }
586
587 #[test]
588 fn unsorted_saved_grid_uses_the_same_typed_interpolant() {
589 let grid = array![2.0, 0.0, 1.0];
590 let values = array![[2.0, 0.0, 1.0]];
591 let surface =
592 SurvivalSurface::new(SurvivalSurfaceKind::Hazard, grid.view(), values.view()).unwrap();
593 assert_eq!(surface.value_at(0, 0.5).unwrap(), 0.5);
594 assert_eq!(surface.value_at(0, 1.5).unwrap(), 1.5);
595 }
596
597 #[test]
598 fn stored_and_derived_hazards_share_zero_extrapolation() {
599 let grid = array![2.0, 1.0];
600 let cumulative_values = array![[3.0, 1.0]];
601 let hazard_values = array![[2.0, 2.0]];
602 let cumulative = SurvivalSurface::new(
603 SurvivalSurfaceKind::CumulativeHazard,
604 grid.view(),
605 cumulative_values.view(),
606 )
607 .unwrap();
608 let stored = SurvivalSurface::new(
609 SurvivalSurfaceKind::Hazard,
610 grid.view(),
611 hazard_values.view(),
612 )
613 .unwrap();
614 let query = array![-1.0, 1.5, 3.0, f64::INFINITY];
615 let derived = cumulative.cumulative_hazard_slopes(query.view()).unwrap();
616 assert_eq!(derived.row(0).to_vec(), vec![0.0, 2.0, 0.0, 0.0]);
617 for &time in &[-1.0, 3.0, f64::INFINITY] {
618 assert_eq!(stored.value_at(0, time).unwrap(), 0.0);
619 }
620 }
621
622 #[test]
623 fn exact_survival_transforms_preserve_tiny_and_large_cumulative_hazard() {
624 let survival = array![[(-100.0_f64).exp(), 0.0, 1.0]];
625 let cumulative = cumulative_hazard_from_survival(survival.view()).unwrap();
626 assert!((cumulative[[0, 0]] - 100.0).abs() < 1e-12);
627 assert_eq!(cumulative[[0, 1]], f64::INFINITY);
628 assert_eq!(cumulative[[0, 2]], 0.0);
629
630 let parameters = array![[0.0]];
631 let exponential = ExponentialSurvivalParameters::new(parameters.view()).unwrap();
632 let times = array![0.0, 1e-20, 1000.0, f64::INFINITY];
633 let cumulative = exponential.cumulative_hazard(times.view()).unwrap();
634 let failure = exponential.failure_probability(times.view()).unwrap();
635 assert_eq!(
636 cumulative.row(0).to_vec(),
637 vec![0.0, 1e-20, 1000.0, f64::INFINITY]
638 );
639 assert!((failure[[0, 1]] - 1e-20).abs() < 1e-32);
640 assert_eq!(failure[[0, 3]], 1.0);
641 }
642
643 #[test]
644 fn dense_shape_overflow_is_rejected_before_allocation() {
645 let error = validate_dense_surface_shape(usize::MAX, 2).unwrap_err();
646 assert!(error.contains("overflows"), "unexpected error: {error}");
647 }
648}