Skip to main content

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}