Skip to main content

gam_models/survival/
surface.rs

1//! Typed survival-surface interpolation and resource policy.
2//!
3//! Saved survival, cumulative-hazard, hazard, and standard-error matrices all
4//! share one boundary law and one chunk geometry.  Frontends name the surface
5//! kind; they do not supply numeric extrapolation constants or independently
6//! decide when/how to tile the matrix.
7
8use 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/// Semantic kind of a saved survival surface.
30#[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                // Saved survival surfaces are flat outside their identified
67                // knot support.  The derivative of that continuation is zero,
68                // so an explicitly stored hazard and one derived from the
69                // cumulative-hazard knots must share the same boundary law.
70                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/// Boundary and codomain law attached to a [`SurvivalSurfaceKind`].
88///
89/// A missing left/right value means flat endpoint continuation.  Positive
90/// infinity is separate from finite right extrapolation: saved surfaces carry
91/// no identified finite-time tail beyond their final knot, while survival and
92/// cumulative hazard still have the mathematical limits 0 and +∞ at `t=+∞`.
93#[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
119/// Borrowed, validated survival surface.
120pub 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    /// Evaluate one row at one time under the kind's authoritative policy.
170    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    /// Evaluate all rows on a shared query grid.
217    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    /// Evaluate through explicit tiles chosen by the shared resource policy.
236    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    /// Pointwise hazard of a stored piecewise-linear cumulative-hazard
258    /// surface.
259    ///
260    /// The result is the slope of the knot interval containing each query.
261    /// It therefore depends only on the stored curve, never on which other
262    /// query times share the call.  Outside the knot support the stored
263    /// cumulative hazard is flat, so the hazard is zero.  The saved grid may
264    /// be in any order; [`Self::new`] has already established the authoritative
265    /// sorted column order used here and by [`Self::value_at`].
266    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/// One half-open tile in a row × time surface.
313#[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/// Resource-derived chunk geometry for survival surfaces.
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub struct SurvivalSurfaceChunkPolicy {
324    target_cells: usize,
325}
326
327/// Convert a survival-probability matrix to failure probabilities.
328pub 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
352/// Convert a survival-probability surface to cumulative hazard via
353/// `H = -ln(S)` without a display-oriented probability floor.
354pub 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
378/// Validated per-row log-hazard parameters for the exponential survival
379/// fallback used by compact prediction payloads.
380pub 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    /// Shape used when neither dimension is explicitly pinned.
480    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}