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}