Skip to main content

gam_models/fit_orchestration/
fit_config.rs

1use super::*;
2
3fn normalize_optional_column(value: Option<String>, field: &str) -> Result<Option<String>, String> {
4    value
5        .map(|value| {
6            let value = value.trim();
7            if value.is_empty() {
8                Err(format!("{field} must be a non-empty column name"))
9            } else {
10                Ok(value.to_string())
11            }
12        })
13        .transpose()
14}
15
16/// Validate the survival baseline fields shared by every front end.
17pub fn validate_survival_baseline_config(
18    likelihood_mode: SurvivalLikelihoodMode,
19    baseline_target: &str,
20    baseline_scale: Option<f64>,
21    baseline_shape: Option<f64>,
22    baseline_rate: Option<f64>,
23    baseline_makeham: Option<f64>,
24) -> Result<(), String> {
25    if likelihood_mode == SurvivalLikelihoodMode::Weibull {
26        if baseline_rate.is_some() || baseline_makeham.is_some() {
27            return Err(
28                "survival likelihood 'weibull' does not use baseline_rate or baseline_makeham"
29                    .to_string(),
30            );
31        }
32        if !matches!(baseline_target, "linear" | "weibull") {
33            return Err(
34                "survival likelihood 'weibull' supports only baseline_target 'linear' or 'weibull'"
35                    .to_string(),
36            );
37        }
38        return Ok(());
39    }
40
41    match baseline_target {
42        "linear" => {
43            if baseline_scale.is_some()
44                || baseline_shape.is_some()
45                || baseline_rate.is_some()
46                || baseline_makeham.is_some()
47            {
48                return Err("baseline_target 'linear' does not use baseline parameters".to_string());
49            }
50        }
51        "weibull" => {
52            if baseline_rate.is_some() || baseline_makeham.is_some() {
53                return Err(
54                    "baseline_target 'weibull' does not use baseline_rate or baseline_makeham"
55                        .to_string(),
56                );
57            }
58        }
59        "gompertz" => {
60            if baseline_scale.is_some() || baseline_makeham.is_some() {
61                return Err(
62                    "baseline_target 'gompertz' does not use baseline_scale or baseline_makeham"
63                        .to_string(),
64                );
65            }
66        }
67        "gompertz-makeham" => {
68            if baseline_scale.is_some() {
69                return Err(
70                    "baseline_target 'gompertz-makeham' does not use baseline_scale".to_string(),
71                );
72            }
73        }
74        other => {
75            return Err(format!(
76                "unsupported baseline_target '{other}'; use linear, weibull, gompertz, or gompertz-makeham"
77            ));
78        }
79    }
80    Ok(())
81}
82
83impl FitConfig {
84    /// Opt in to cross-process warm starts at the exact supplied root.
85    ///
86    /// The path is neither canonicalized nor relocated through temp/cache
87    /// discovery. Opening remains lazy until a real fit performs its first
88    /// persistence operation.
89    pub fn with_persistent_warm_start_root(mut self, root: impl Into<std::path::PathBuf>) -> Self {
90        self.persistent_warm_start_store = Some(
91            gam_solve::persistent_warm_start::configured_store(root.into()),
92        );
93        self
94    }
95
96    /// Normalize and validate the canonical configuration contract.
97    ///
98    /// CLI and JSON layers translate syntax only. Model-family legality and
99    /// cross-field invariants live here so direct Rust callers cannot bypass
100    /// the same rules enforced by application front ends.
101    pub fn resolve(mut self) -> Result<Self, String> {
102        self.family = self.family.and_then(|value| {
103            let value = value.trim();
104            (!value.eq_ignore_ascii_case("auto")).then(|| value.to_string())
105        });
106        self.survival_likelihood = self
107            .survival_likelihood
108            .map(|value| value.trim().to_ascii_lowercase());
109        self.baseline_target = self.baseline_target.trim().to_ascii_lowercase();
110        self.link = self.link.and_then(|value| {
111            let value = value.trim();
112            (!value.is_empty()).then(|| value.to_string())
113        });
114        self.offset_column = normalize_optional_column(self.offset_column, "offset_column")?;
115        self.noise_offset_column =
116            normalize_optional_column(self.noise_offset_column, "noise_offset_column")?;
117        self.weight_column = normalize_optional_column(self.weight_column, "weight_column")?;
118        self.z_column = normalize_optional_column(self.z_column, "z_column")?;
119        if self
120            .persistent_warm_start_store
121            .as_ref()
122            .is_some_and(|store| store.root().as_os_str().is_empty())
123        {
124            return Err("persistent_warm_start_root must not be empty".to_string());
125        }
126
127        if !self.ridge_lambda.is_finite() || self.ridge_lambda < 0.0 {
128            return Err("ridge_lambda must be finite and >= 0".to_string());
129        }
130        // Normalize the survival time-anchor override through its one validator,
131        // so the CLI flag, a `--request` document, a `gamfit.fit` kwarg and a
132        // direct Rust caller are all held to the same contract and report the
133        // same message (#2631).
134        self.survival_time_anchor = self
135            .survival_time_anchor
136            .map(crate::survival::validate_survival_time_anchor_override)
137            .transpose()?;
138        if self.outer_max_iter == Some(0) {
139            return Err("outer_max_iter must be >= 1".to_string());
140        }
141        self.frailty.validate().map_err(|error| error.to_string())?;
142        self.spatial_optimization.validate()?;
143        let likelihood_mode = parse_survival_likelihood_mode(self.resolved_survival_likelihood())?;
144        validate_survival_baseline_config(
145            likelihood_mode,
146            &self.baseline_target,
147            self.baseline_scale,
148            self.baseline_shape,
149            self.baseline_rate,
150            self.baseline_makeham,
151        )?;
152        Ok(self)
153    }
154
155    /// The survival likelihood mode this config resolves to for a `Surv(...)`
156    /// fit.
157    ///
158    /// `survival_likelihood` is `None` by default — there is no library-side
159    /// string default (#2301). An explicit `Some(mode)` selects that mode; an
160    /// unset `None` resolves to the single canonical default `"transformation"`
161    /// (Royston-Parmar), the same default the CLI documents. This is the ONE
162    /// resolution point: the `Surv(...)` materialization seam, the CLI survival
163    /// path, and the pyffi survival path all consult it, so the default lives in
164    /// exactly one place. A non-`Surv()` formula never calls this — `Some(_)` on
165    /// a non-survival response is a typed configuration error rejected by
166    /// `reject_survival_only_config_for_nonsurvival`, and `None` is unset.
167    pub fn resolved_survival_likelihood(&self) -> &str {
168        self.survival_likelihood
169            .as_deref()
170            .unwrap_or("transformation")
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn resolve_normalizes_front_end_spellings() {
180        let resolved = FitConfig {
181            family: Some(" AUTO ".to_string()),
182            survival_likelihood: Some(" Transformation ".to_string()),
183            baseline_target: " Linear ".to_string(),
184            ..FitConfig::default()
185        }
186        .resolve()
187        .unwrap();
188        assert_eq!(resolved.family, None);
189        assert_eq!(
190            resolved.survival_likelihood.as_deref(),
191            Some("transformation")
192        );
193        assert_eq!(resolved.baseline_target, "linear");
194    }
195
196    #[test]
197    fn resolve_rejects_invalid_shared_fields() {
198        assert!(
199            FitConfig {
200                ridge_lambda: f64::NAN,
201                ..FitConfig::default()
202            }
203            .resolve()
204            .is_err()
205        );
206        assert!(
207            FitConfig {
208                outer_max_iter: Some(0),
209                ..FitConfig::default()
210            }
211            .resolve()
212            .is_err()
213        );
214        assert!(
215            FitConfig {
216                weight_column: Some("   ".to_string()),
217                ..FitConfig::default()
218            }
219            .resolve()
220            .is_err()
221        );
222    }
223}