gam_models/fit_orchestration/
fit_config.rs1use 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
16pub 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 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 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 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 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}