gam_models/fit_orchestration/error.rs
1pub(crate) trait WorkflowCauseCountResult {
2 fn into_workflow_result(self) -> Result<usize, String>;
3}
4
5impl WorkflowCauseCountResult for usize {
6 fn into_workflow_result(self) -> Result<usize, String> {
7 Ok(self)
8 }
9}
10
11impl<E: ToString> WorkflowCauseCountResult for Result<usize, E> {
12 fn into_workflow_result(self) -> Result<usize, String> {
13 self.map_err(|err| err.to_string())
14 }
15}
16
17/// Typed error category for the `solver::fit_orchestration` materialization and
18/// fitting pipeline.
19///
20/// Every variant's `Display` impl is byte-equivalent to the original
21/// `format!(...)`/`.to_string()` text the module emitted before the typed
22/// migration. The category split lets internal callers reason about the
23/// failure kind without parsing strings; public entry points keep their
24/// `Result<_, String>` signatures and rely on `From<WorkflowError> for
25/// String` at the boundary.
26#[derive(Debug, Clone)]
27pub enum WorkflowError {
28 /// Fit configuration is internally inconsistent or selects an
29 /// unsupported combination (conflicting `family`/`link`, unsupported
30 /// `linkwiggle(...)`/`link(...)` placement, `frailty` requested for a
31 /// family that does not implement it, duplicate or out-of-range
32 /// hyperpriors, etc.).
33 InvalidConfig { reason: String },
34 /// Saved-model or runtime block dimensions disagree with what the
35 /// rebuilt designs / penalties expect (initial beta length, penalty
36 /// block shape vs range width, time-basis column count, response
37 /// support mismatch).
38 SchemaMismatch { reason: String },
39 /// A required input column, frailty parameter, baseline target, or
40 /// cause count is missing for the requested mode (e.g. cause-specific
41 /// fit with one cause, latent-cloglog without a fixed sigma).
42 MissingDependency { reason: String },
43 /// An underlying numerical step (PIRLS / smoothing-parameter
44 /// optimizer / profile-cost evaluation) failed to converge or
45 /// produced a non-finite value that downstream code cannot consume.
46 IntegrationFailed { reason: String },
47 /// A spatial basis could not be certified at its current resolution and
48 /// the next information-bearing expansion could not be fitted. Carries the
49 /// attempted resolution and underlying evidence instead of returning the
50 /// last under-resolved fit as if it were complete.
51 SpatialUnderresolved {
52 term: String,
53 current_centers: usize,
54 attempted_centers: usize,
55 reason: String,
56 },
57 /// Formula parsing / term-resolution failed before materialization; the
58 /// source retains the parser-layer category and argument context.
59 FormulaDsl {
60 context: &'static str,
61 source: gam_terms::inference::formula_dsl::FormulaDslError,
62 },
63 /// A formula referenced a column that does not exist in the input data.
64 /// Carries the structured payload through to the FFI boundary so the
65 /// Python side can raise `gamfit.ColumnNotFoundError` with `column`,
66 /// `role`, `available`, `similar`, and `tsv_hint` attributes — issue
67 /// #305 / #343 (typed-dispatch migration; no string classification at
68 /// the boundary).
69 ColumnNotFound {
70 name: String,
71 role: Option<String>,
72 available: Vec<String>,
73 similar: Vec<String>,
74 tsv_hint: bool,
75 },
76}
77
78impl std::fmt::Display for WorkflowError {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 WorkflowError::InvalidConfig { reason }
82 | WorkflowError::SchemaMismatch { reason }
83 | WorkflowError::MissingDependency { reason }
84 | WorkflowError::IntegrationFailed { reason } => f.write_str(reason),
85 WorkflowError::SpatialUnderresolved {
86 term,
87 current_centers,
88 attempted_centers,
89 reason,
90 } => write!(
91 f,
92 "spatial term '{term}' remains under-resolution-uncertain at {current_centers} \
93 centers: the {attempted_centers}-center certification refit failed ({reason})"
94 ),
95 WorkflowError::FormulaDsl { context, source } => write!(f, "{context}: {source}"),
96 // Reconstruct the display text from the structured payload so
97 // CLI / `to_string()` consumers see the same prose the legacy
98 // `missing_column_message` produced. The text is a function of
99 // the typed fields — not parsed back out anywhere.
100 WorkflowError::ColumnNotFound {
101 name,
102 role,
103 available,
104 similar,
105 tsv_hint,
106 } => {
107 let label = match role {
108 Some(r) => format!("{r} column '{name}'"),
109 None => format!("column '{name}'"),
110 };
111 let tsv_suffix = if *tsv_hint {
112 " — your file appears to be tab-separated; gam expects comma-separated CSV. \
113 Replace tabs with commas, or pre-convert with `tr '\\t' ',' < file.tsv > file.csv`."
114 } else {
115 ""
116 };
117 if similar.is_empty() {
118 write!(
119 f,
120 "{label} not found in data. Available columns: [{}]{tsv_suffix}",
121 available.join(", ")
122 )
123 } else {
124 write!(
125 f,
126 "{label} not found in data. Did you mean one of [{}]? Full list: [{}]{tsv_suffix}",
127 similar.join(", "),
128 available.join(", ")
129 )
130 }
131 }
132 }
133 }
134}
135
136impl std::error::Error for WorkflowError {
137 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
138 match self {
139 WorkflowError::FormulaDsl { source, .. } => Some(source),
140 WorkflowError::InvalidConfig { .. }
141 | WorkflowError::SchemaMismatch { .. }
142 | WorkflowError::MissingDependency { .. }
143 | WorkflowError::IntegrationFailed { .. }
144 | WorkflowError::SpatialUnderresolved { .. }
145 | WorkflowError::ColumnNotFound { .. } => None,
146 }
147 }
148}
149
150impl From<WorkflowError> for String {
151 fn from(err: WorkflowError) -> String {
152 err.to_string()
153 }
154}
155
156/// Catchall lift for legacy `Result<_, String>` chains that flow into a
157/// `WorkflowError`-returning function via `?`. Maps to `InvalidConfig` since
158/// the upstream call sites that still hand out bare strings are
159/// configuration / setup helpers (FitConfig parsing, payload assembly, etc.)
160/// that pre-date the typed-error migration. Specific leaves that carry
161/// structured payload (`DataError`, `FormulaDslError`, `EstimationError`,
162/// …) have their own dedicated `From` impls and bypass this fallback.
163impl From<String> for WorkflowError {
164 fn from(reason: String) -> Self {
165 Self::InvalidConfig { reason }
166 }
167}
168
169impl From<&str> for WorkflowError {
170 fn from(reason: &str) -> Self {
171 Self::InvalidConfig {
172 reason: reason.to_string(),
173 }
174 }
175}
176
177impl From<crate::survival::lognormal_kernel::LognormalKernelError> for WorkflowError {
178 fn from(err: crate::survival::lognormal_kernel::LognormalKernelError) -> Self {
179 match err {
180 crate::survival::lognormal_kernel::LognormalKernelError::InvalidSpec { reason } => {
181 Self::InvalidConfig { reason }
182 }
183 }
184 }
185}
186
187/// Cross-module cascade: a `FormulaDslError` raised inside `materialize` /
188/// `fit_from_formula` (via `parse_formula`, `parse_surv_response`, etc.) flows
189/// up with its parser-layer source attached instead of stringifying into a
190/// generic workflow configuration bucket.
191impl From<gam_terms::inference::formula_dsl::FormulaDslError> for WorkflowError {
192 fn from(err: gam_terms::inference::formula_dsl::FormulaDslError) -> Self {
193 Self::FormulaDsl {
194 context: "workflow formula materialization",
195 source: err,
196 }
197 }
198}
199
200/// Typed lift from term-builder errors. `TermBuilderError::ColumnNotFound`
201/// preserves the structured fields (name, role, available, similar,
202/// tsv_hint) through to the FFI boundary so `gam-pyffi` can raise a
203/// `gamfit.ColumnNotFoundError` with attributes set from the payload —
204/// not from re-parsed prose. Other variants degrade into the closest
205/// generic workflow bucket; the dedicated typed channels for those
206/// failure classes can be added incrementally as their dispatch arrives.
207impl From<gam_terms::term_builder::TermBuilderError> for WorkflowError {
208 fn from(err: gam_terms::term_builder::TermBuilderError) -> Self {
209 use gam_terms::term_builder::TermBuilderError;
210 match err {
211 TermBuilderError::ColumnNotFound {
212 name,
213 role,
214 available,
215 similar,
216 tsv_hint,
217 } => Self::ColumnNotFound {
218 name,
219 role,
220 available,
221 similar,
222 tsv_hint,
223 },
224 TermBuilderError::MissingColumn { reason }
225 | TermBuilderError::MalformedFormula { reason } => Self::SchemaMismatch { reason },
226 TermBuilderError::IncompatibleConfig { reason }
227 | TermBuilderError::InvalidOption { reason }
228 | TermBuilderError::UnsupportedFeature { reason }
229 | TermBuilderError::DegenerateData { reason } => Self::InvalidConfig { reason },
230 }
231 }
232}
233
234/// Typed lift from leaf data-layer errors. `DataError::ColumnNotFound` is
235/// the variant of immediate interest — it preserves the structured fields
236/// so `gam-pyffi` can dispatch to `ColumnNotFoundError` without parsing
237/// human text. Other `DataError` variants degrade to the appropriate
238/// workflow bucket (`SchemaMismatch` for row/column shape problems,
239/// `InvalidConfig` for parse / encoding / empty / invalid-value sources)
240/// since they don't have a dedicated structured destination yet.
241impl From<gam_data::DataError> for WorkflowError {
242 fn from(err: gam_data::DataError) -> Self {
243 use gam_data::DataError;
244 match err {
245 DataError::ColumnNotFound {
246 name,
247 role,
248 available,
249 similar,
250 tsv_hint,
251 } => Self::ColumnNotFound {
252 name,
253 role,
254 available,
255 similar,
256 tsv_hint,
257 },
258 DataError::SchemaMismatch { reason } => Self::SchemaMismatch { reason },
259 DataError::ParseError { reason }
260 | DataError::EncodingFailure { reason }
261 | DataError::EmptyInput { reason }
262 | DataError::InvalidValue { reason } => Self::InvalidConfig { reason },
263 }
264 }
265}