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    /// Formula parsing / term-resolution failed before materialization; the
48    /// source retains the parser-layer category and argument context.
49    FormulaDsl {
50        context: &'static str,
51        source: gam_terms::inference::formula_dsl::FormulaDslError,
52    },
53    /// A formula referenced a column that does not exist in the input data.
54    /// Carries the structured payload through to the FFI boundary so the
55    /// Python side can raise `gamfit.ColumnNotFoundError` with `column`,
56    /// `role`, `available`, `similar`, and `tsv_hint` attributes — issue
57    /// #305 / #343 (typed-dispatch migration; no string classification at
58    /// the boundary).
59    ColumnNotFound {
60        name: String,
61        role: Option<String>,
62        available: Vec<String>,
63        similar: Vec<String>,
64        tsv_hint: bool,
65    },
66}
67
68impl std::fmt::Display for WorkflowError {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            WorkflowError::InvalidConfig { reason }
72            | WorkflowError::SchemaMismatch { reason }
73            | WorkflowError::MissingDependency { reason }
74            | WorkflowError::IntegrationFailed { reason } => f.write_str(reason),
75            WorkflowError::FormulaDsl { context, source } => write!(f, "{context}: {source}"),
76            // Reconstruct the display text from the structured payload so
77            // CLI / `to_string()` consumers see the same prose the legacy
78            // `missing_column_message` produced. The text is a function of
79            // the typed fields — not parsed back out anywhere.
80            WorkflowError::ColumnNotFound {
81                name,
82                role,
83                available,
84                similar,
85                tsv_hint,
86            } => {
87                let label = match role {
88                    Some(r) => format!("{r} column '{name}'"),
89                    None => format!("column '{name}'"),
90                };
91                let tsv_suffix = if *tsv_hint {
92                    " — your file appears to be tab-separated; gam expects comma-separated CSV. \
93         Replace tabs with commas, or pre-convert with `tr '\\t' ',' < file.tsv > file.csv`."
94                } else {
95                    ""
96                };
97                if similar.is_empty() {
98                    write!(
99                        f,
100                        "{label} not found in data. Available columns: [{}]{tsv_suffix}",
101                        available.join(", ")
102                    )
103                } else {
104                    write!(
105                        f,
106                        "{label} not found in data. Did you mean one of [{}]? Full list: [{}]{tsv_suffix}",
107                        similar.join(", "),
108                        available.join(", ")
109                    )
110                }
111            }
112        }
113    }
114}
115
116impl std::error::Error for WorkflowError {
117    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
118        match self {
119            WorkflowError::FormulaDsl { source, .. } => Some(source),
120            WorkflowError::InvalidConfig { .. }
121            | WorkflowError::SchemaMismatch { .. }
122            | WorkflowError::MissingDependency { .. }
123            | WorkflowError::IntegrationFailed { .. }
124            | WorkflowError::ColumnNotFound { .. } => None,
125        }
126    }
127}
128
129impl From<WorkflowError> for String {
130    fn from(err: WorkflowError) -> String {
131        err.to_string()
132    }
133}
134
135/// Catchall lift for legacy `Result<_, String>` chains that flow into a
136/// `WorkflowError`-returning function via `?`. Maps to `InvalidConfig` since
137/// the upstream call sites that still hand out bare strings are
138/// configuration / setup helpers (FitConfig parsing, payload assembly, etc.)
139/// that pre-date the typed-error migration. Specific leaves that carry
140/// structured payload (`DataError`, `FormulaDslError`, `EstimationError`,
141/// …) have their own dedicated `From` impls and bypass this fallback.
142impl From<String> for WorkflowError {
143    fn from(reason: String) -> Self {
144        Self::InvalidConfig { reason }
145    }
146}
147
148impl From<&str> for WorkflowError {
149    fn from(reason: &str) -> Self {
150        Self::InvalidConfig {
151            reason: reason.to_string(),
152        }
153    }
154}
155
156/// Cross-module cascade: a `FormulaDslError` raised inside `materialize` /
157/// `fit_from_formula` (via `parse_formula`, `parse_surv_response`, etc.) flows
158/// up with its parser-layer source attached instead of stringifying into a
159/// generic workflow configuration bucket.
160impl From<gam_terms::inference::formula_dsl::FormulaDslError> for WorkflowError {
161    fn from(err: gam_terms::inference::formula_dsl::FormulaDslError) -> Self {
162        Self::FormulaDsl {
163            context: "workflow formula materialization",
164            source: err,
165        }
166    }
167}
168
169/// Typed lift from term-builder errors. `TermBuilderError::ColumnNotFound`
170/// preserves the structured fields (name, role, available, similar,
171/// tsv_hint) through to the FFI boundary so `gam-pyffi` can raise a
172/// `gamfit.ColumnNotFoundError` with attributes set from the payload —
173/// not from re-parsed prose. Other variants degrade into the closest
174/// generic workflow bucket; the dedicated typed channels for those
175/// failure classes can be added incrementally as their dispatch arrives.
176impl From<gam_terms::term_builder::TermBuilderError> for WorkflowError {
177    fn from(err: gam_terms::term_builder::TermBuilderError) -> Self {
178        use gam_terms::term_builder::TermBuilderError;
179        match err {
180            TermBuilderError::ColumnNotFound {
181                name,
182                role,
183                available,
184                similar,
185                tsv_hint,
186            } => Self::ColumnNotFound {
187                name,
188                role,
189                available,
190                similar,
191                tsv_hint,
192            },
193            TermBuilderError::MissingColumn { reason }
194            | TermBuilderError::MalformedFormula { reason } => Self::SchemaMismatch { reason },
195            TermBuilderError::IncompatibleConfig { reason }
196            | TermBuilderError::InvalidOption { reason }
197            | TermBuilderError::UnsupportedFeature { reason }
198            | TermBuilderError::DegenerateData { reason } => Self::InvalidConfig { reason },
199        }
200    }
201}
202
203/// Typed lift from leaf data-layer errors. `DataError::ColumnNotFound` is
204/// the variant of immediate interest — it preserves the structured fields
205/// so `gam-pyffi` can dispatch to `ColumnNotFoundError` without parsing
206/// human text. Other `DataError` variants degrade to the appropriate
207/// workflow bucket (`SchemaMismatch` for row/column shape problems,
208/// `InvalidConfig` for parse / encoding / empty / invalid-value sources)
209/// since they don't have a dedicated structured destination yet.
210impl From<gam_data::DataError> for WorkflowError {
211    fn from(err: gam_data::DataError) -> Self {
212        use gam_data::DataError;
213        match err {
214            DataError::ColumnNotFound {
215                name,
216                role,
217                available,
218                similar,
219                tsv_hint,
220            } => Self::ColumnNotFound {
221                name,
222                role,
223                available,
224                similar,
225                tsv_hint,
226            },
227            DataError::SchemaMismatch { reason } => Self::SchemaMismatch { reason },
228            DataError::ParseError { reason }
229            | DataError::EncodingFailure { reason }
230            | DataError::EmptyInput { reason }
231            | DataError::InvalidValue { reason } => Self::InvalidConfig { reason },
232        }
233    }
234}