Skip to main content

typesayer_types/
error.rs

1// Copyright 2026 Thomas Santerre and Moderately AI Inc.
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Predict engine error types.
6
7/// Head budget for raw-completion excerpts attached to parse errors.
8/// Calibrated to surface the first marker / opening JSON brace plus a
9/// couple of fields' worth of content so the operator can see where the
10/// model's output went off the rails.
11const EXCERPT_HEAD_BYTES: usize = 400;
12
13/// Tail budget for raw-completion excerpts attached to parse errors.
14/// Smaller than the head because tail context is usually a trailing
15/// `[[ ## completed ## ]]` (or a half-emitted final value) — enough to
16/// confirm "did the model finish?" without dominating the error log.
17const EXCERPT_TAIL_BYTES: usize = 200;
18
19/// Build a head + tail excerpt of `raw` suitable for embedding in an error message.
20///
21/// Pure head when `raw` fits in the combined budget; otherwise emits
22/// `<head>… (N bytes elided) …<tail>` with a real "bytes elided" count
23/// so the operator knows how much was dropped.
24///
25/// Always slices on char boundaries — a raw completion can contain
26/// arbitrary UTF-8 (model emitted prose, emoji in user-provided
27/// inputs echoed back, etc.), and slicing through a multi-byte code
28/// point would panic.
29#[must_use]
30pub fn build_excerpt(raw: &str) -> String {
31    if raw.len() <= EXCERPT_HEAD_BYTES + EXCERPT_TAIL_BYTES {
32        return raw.to_owned();
33    }
34    let head = safe_prefix(raw, EXCERPT_HEAD_BYTES);
35    let tail = safe_suffix(raw, EXCERPT_TAIL_BYTES);
36    let elided = raw.len().saturating_sub(head.len() + tail.len());
37    format!("{head}\n… ({elided} bytes elided) …\n{tail}")
38}
39
40/// Largest char-boundary-safe prefix of `s` whose byte length is at
41/// most `max_bytes`.
42fn safe_prefix(s: &str, max_bytes: usize) -> &str {
43    if s.len() <= max_bytes {
44        return s;
45    }
46    let mut end = max_bytes;
47    while end > 0 && !s.is_char_boundary(end) {
48        end -= 1;
49    }
50    &s[..end]
51}
52
53/// Largest char-boundary-safe suffix of `s` whose byte length is at
54/// most `max_bytes`.
55fn safe_suffix(s: &str, max_bytes: usize) -> &str {
56    if s.len() <= max_bytes {
57        return s;
58    }
59    let mut start = s.len() - max_bytes;
60    while start < s.len() && !s.is_char_boundary(start) {
61        start += 1;
62    }
63    &s[start..]
64}
65
66/// Errors that can occur in predict operations.
67#[derive(Debug, thiserror::Error)]
68pub enum PredictError {
69    /// One or more required output fields are absent from the completion.
70    ///
71    /// Surfaced from an adapter's `parse` implementation when the
72    /// parser found at least one field marker but not every required
73    /// output field. The raw excerpt + total-bytes context lets an
74    /// operator diagnose from the error alone instead of running the
75    /// model again with extra logging.
76    #[error(
77        "missing output fields: {fields:?} (expected {} field(s); model emitted {raw_bytes_total} bytes)\n  raw excerpt:\n    {raw_excerpt}",
78        expected.len(),
79    )]
80    MissingFields {
81        /// The names of the missing fields.
82        fields: Vec<String>,
83        /// All output field names, in declaration order. Pairs with
84        /// `fields` to surface "missing 2 of 3" context.
85        expected: Vec<String>,
86        /// Head + tail excerpt of the raw model completion at parse
87        /// time. Empty when the error is constructed outside the
88        /// adapter parse path (e.g. a `Prediction::get` lookup miss
89        /// — though that uses [`FieldNotInPrediction`](Self::FieldNotInPrediction)).
90        raw_excerpt: String,
91        /// Total byte length of the raw completion. Reading the excerpt
92        /// with `(0 of 50000)` context vs `(0 of 200)` context changes
93        /// the operator's diagnosis (truncation vs malformed output).
94        raw_bytes_total: usize,
95    },
96
97    /// Caller asked for a field via `Prediction::get`
98    /// that is not present in the prediction. Distinct from
99    /// [`MissingFields`](Self::MissingFields) — which is a parser-level
100    /// "model failed to emit" — because this one is a "caller asked
101    /// for the wrong key" and the raw-completion context is irrelevant.
102    #[error("field '{field}' is not present in this prediction")]
103    FieldNotInPrediction {
104        /// The lookup key that was not in the prediction.
105        field: String,
106    },
107
108    /// The language model stopped generating before producing a complete
109    /// response — most often because the configured `max_tokens` budget
110    /// was exhausted mid-output. The completion is therefore truncated
111    /// and cannot be parsed for output fields. Surfaced ahead of
112    /// [`MissingFields`](Self::MissingFields) so operators see the real
113    /// root cause instead of a generic parse failure.
114    #[error(
115        "language model truncated output: stop_reason={stop_reason} \
116         (output_tokens={output_tokens:?}); raise max_tokens or shrink the input \
117         to give the model room to finish"
118    )]
119    Truncated {
120        /// The normalized stop reason that triggered the early stop.
121        /// Stringified at the boundary so this error type doesn't take
122        /// a public dependency on `modelplease::StopReason`.
123        stop_reason: String,
124        /// Output tokens generated before truncation, when the provider
125        /// reported usage. `None` for providers that omit usage on a
126        /// truncated response.
127        output_tokens: Option<u64>,
128    },
129
130    /// A field value could not be coerced to the declared [`FieldType`](crate::FieldType).
131    #[error("field '{field}' type mismatch: expected {expected}, got {actual:?}")]
132    FieldTypeMismatch {
133        /// The field name.
134        field: String,
135        /// The expected type label.
136        expected: String,
137        /// The raw value that failed coercion.
138        actual: String,
139    },
140
141    /// The completion contained no `[[ ## field ## ]]` markers at all.
142    ///
143    /// The raw excerpt names what the model emitted instead — usually
144    /// either a code-fenced JSON object the prompt didn't ask for, a
145    /// natural-language refusal, or a near-miss marker (`[[answer]]`
146    /// without the surrounding `## ` decorations).
147    #[error(
148        "no field markers found in completion (expected {expected_markers:?}; \
149         model emitted {raw_bytes_total} bytes)\n  raw excerpt:\n    {raw_excerpt}"
150    )]
151    NoFieldMarkers {
152        /// Markers the parser hoped to find (one per output field).
153        expected_markers: Vec<String>,
154        /// Head + tail excerpt of the raw model completion.
155        raw_excerpt: String,
156        /// Total byte length of the raw completion.
157        raw_bytes_total: usize,
158    },
159
160    /// Signature construction failed due to invalid configuration.
161    #[error("invalid signature: {reason}")]
162    InvalidSignature {
163        /// Why the signature is invalid.
164        reason: String,
165    },
166
167    /// JSON serialization or deserialization failure.
168    #[error("serialization error: {0}")]
169    Serialization(#[from] serde_json::Error),
170
171    /// A language model call failed.
172    #[error("language model error: {0}")]
173    LanguageModel(#[from] modelplease::LanguageModelError),
174
175    /// An optimizer encountered a runtime error (e.g. exceeded max errors).
176    #[error("optimizer error: {message}")]
177    Optimizer {
178        /// Description of the optimizer error.
179        message: String,
180    },
181
182    /// The evaluation runner exceeded its error budget.
183    #[error("evaluation error: {message}")]
184    Evaluation {
185        /// Description of the evaluation error.
186        message: String,
187    },
188
189    /// File I/O error during state save or load.
190    #[error("I/O error: {0}")]
191    Io(#[from] std::io::Error),
192
193    /// A tagged [`OneOf`](crate::FieldType::OneOf) input is missing its
194    /// discriminator property. Either the property is absent from the JSON
195    /// object, or the value being parsed isn't a JSON object at all.
196    #[error(
197        "field '{field}' is a tagged variant but its discriminator property \
198         `{discriminator}` is missing or the value is not a JSON object"
199    )]
200    OneOfTagMissing {
201        /// The field path being parsed.
202        field: String,
203        /// The discriminator property name declared by the OneOf type.
204        discriminator: String,
205    },
206
207    /// A tagged [`OneOf`](crate::FieldType::OneOf) input carried a discriminator
208    /// value that doesn't match any declared arm tag. Lists the valid tags so
209    /// the operator can compare against the offending value (case, whitespace,
210    /// near-miss spellings).
211    #[error(
212        "field '{field}' discriminator value '{tag}' is not a valid arm tag; \
213         expected one of: {valid_tags:?}"
214    )]
215    OneOfTagInvalid {
216        /// The field path being parsed.
217        field: String,
218        /// The offending discriminator value.
219        tag: String,
220        /// The full set of valid arm tags declared by the OneOf type.
221        valid_tags: Vec<String>,
222    },
223
224    /// An untagged [`OneOf`](crate::FieldType::OneOf) input matched zero arms.
225    /// Each per-arm parse failure is preserved so the operator can see why
226    /// every arm rejected the value rather than guessing.
227    #[error(
228        "field '{field}' did not match any OneOf arm ({} arm(s) tried)",
229        arm_errors.len()
230    )]
231    OneOfNoArmMatched {
232        /// The field path being parsed.
233        field: String,
234        /// Per-arm rejection reasons, parallel to the arms vector. The `usize`
235        /// is the arm index.
236        arm_errors: Vec<(usize, Box<Self>)>,
237    },
238
239    /// An untagged [`OneOf`](crate::FieldType::OneOf) input matched more than
240    /// one arm. JSON Schema's `oneOf` semantics require exactly one match;
241    /// ambiguity is a schema-design bug surfaced to the operator.
242    #[error(
243        "field '{field}' matched multiple OneOf arms ({matching_arms:?}); \
244         oneOf requires exactly one match. Either make the arms structurally \
245         disjoint, or use anyOf for first-match-wins semantics."
246    )]
247    OneOfAmbiguous {
248        /// The field path being parsed.
249        field: String,
250        /// Indices of every arm that successfully parsed the value.
251        matching_arms: Vec<usize>,
252    },
253
254    /// An [`AnyOf`](crate::FieldType::AnyOf) input matched zero arms. AnyOf is
255    /// first-match-wins; "no arm matched" means every arm rejected the value.
256    #[error(
257        "field '{field}' did not match any AnyOf arm ({} arm(s) tried)",
258        arm_errors.len()
259    )]
260    AnyOfNoArmMatched {
261        /// The field path being parsed.
262        field: String,
263        /// Per-arm rejection reasons, parallel to the arms vector.
264        arm_errors: Vec<(usize, Box<Self>)>,
265    },
266}
267
268impl PredictError {
269    /// Create a [`MissingFields`](PredictError::MissingFields) error
270    /// from the chat-adapter parse path. The raw completion is in scope
271    /// at the construction site; passing it through populates the
272    /// excerpt + total-bytes context the operator needs to debug from
273    /// the error alone.
274    #[must_use]
275    pub fn missing_fields_from_parse<F, E>(missing: F, expected: E, raw_completion: &str) -> Self
276    where
277        F: IntoIterator<Item = String>,
278        E: IntoIterator<Item = String>,
279    {
280        Self::MissingFields {
281            fields: missing.into_iter().collect(),
282            expected: expected.into_iter().collect(),
283            raw_excerpt: build_excerpt(raw_completion),
284            raw_bytes_total: raw_completion.len(),
285        }
286    }
287
288    /// Create a [`NoFieldMarkers`](PredictError::NoFieldMarkers) error
289    /// from the chat-adapter parse path.
290    #[must_use]
291    pub fn no_field_markers_from_parse<E>(expected_markers: E, raw_completion: &str) -> Self
292    where
293        E: IntoIterator<Item = String>,
294    {
295        Self::NoFieldMarkers {
296            expected_markers: expected_markers.into_iter().collect(),
297            raw_excerpt: build_excerpt(raw_completion),
298            raw_bytes_total: raw_completion.len(),
299        }
300    }
301
302    /// Create an [`InvalidSignature`](PredictError::InvalidSignature) error.
303    pub fn invalid_signature(reason: impl Into<String>) -> Self {
304        Self::InvalidSignature {
305            reason: reason.into(),
306        }
307    }
308
309    /// Create an [`Optimizer`](PredictError::Optimizer) error.
310    pub fn optimizer(message: impl Into<String>) -> Self {
311        Self::Optimizer {
312            message: message.into(),
313        }
314    }
315
316    /// Create an [`Evaluation`](PredictError::Evaluation) error.
317    pub fn evaluation(message: impl Into<String>) -> Self {
318        Self::Evaluation {
319            message: message.into(),
320        }
321    }
322
323    /// Create a [`Truncated`](PredictError::Truncated) error from a
324    /// language-model `StopReason` and optional output token count.
325    #[must_use]
326    pub fn truncated(stop_reason: &modelplease::StopReason, output_tokens: Option<u64>) -> Self {
327        let label = match stop_reason {
328            modelplease::StopReason::EndTurn => "end_turn".to_owned(),
329            modelplease::StopReason::MaxTokens => "max_tokens".to_owned(),
330            modelplease::StopReason::StopSequence => "stop_sequence".to_owned(),
331            modelplease::StopReason::ToolUse => "tool_use".to_owned(),
332            modelplease::StopReason::ContentFilter => "content_filter".to_owned(),
333            modelplease::StopReason::Other(s) => s.clone(),
334        };
335        Self::Truncated {
336            stop_reason: label,
337            output_tokens,
338        }
339    }
340}
341
342/// A specialized `Result` type for predict operations.
343pub type Result<T> = std::result::Result<T, PredictError>;
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn missing_fields_display_carries_excerpt_and_total_bytes() {
351        let completion = "[[ ## answer ## ]]\n42\n[[ ## completed ## ]]";
352        let err = PredictError::missing_fields_from_parse(
353            ["reasoning".to_owned()],
354            ["reasoning".to_owned(), "answer".to_owned()],
355            completion,
356        );
357        let msg = err.to_string();
358        assert!(msg.contains("reasoning"), "names the missing field: {msg}");
359        assert!(msg.contains("2 field"), "states expected count: {msg}");
360        assert!(
361            msg.contains(&format!("{} bytes", completion.len())),
362            "names total bytes: {msg}"
363        );
364        assert!(
365            msg.contains("[[ ## answer ## ]]"),
366            "includes raw excerpt: {msg}"
367        );
368    }
369
370    #[test]
371    fn no_field_markers_display_carries_excerpt() {
372        let completion = "Sorry, I cannot answer that question.";
373        let err = PredictError::no_field_markers_from_parse(
374            ["[[ ## answer ## ]]".to_owned()],
375            completion,
376        );
377        let msg = err.to_string();
378        assert!(
379            msg.contains("[[ ## answer ## ]]"),
380            "names expected marker: {msg}"
381        );
382        assert!(
383            msg.contains(&format!("{} bytes", completion.len())),
384            "names total bytes: {msg}"
385        );
386        assert!(msg.contains("Sorry"), "includes raw excerpt: {msg}");
387    }
388
389    #[test]
390    fn field_not_in_prediction_display_names_field() {
391        let err = PredictError::FieldNotInPrediction {
392            field: "oops".into(),
393        };
394        let msg = err.to_string();
395        assert!(msg.contains("oops"), "names the missing key: {msg}");
396    }
397
398    #[test]
399    fn build_excerpt_inlines_short_input() {
400        let short = "hello";
401        assert_eq!(build_excerpt(short), short);
402    }
403
404    #[test]
405    fn build_excerpt_truncates_with_elision_marker() {
406        // 1000-byte ASCII string so head + tail = 600 bytes and 400 are
407        // elided. The exact byte count appears in the elision separator
408        // so an operator can size up "truncation vs malformed".
409        let raw = "x".repeat(1000);
410        let excerpt = build_excerpt(&raw);
411        assert!(
412            excerpt.contains("400 bytes elided"),
413            "names elided count: {excerpt}"
414        );
415        assert!(excerpt.len() < raw.len(), "shorter than input");
416    }
417
418    #[test]
419    fn build_excerpt_respects_char_boundaries() {
420        // A multi-byte boundary right at EXCERPT_HEAD_BYTES (400) — slicing
421        // naïvely would panic. Construct a payload where byte 400 falls
422        // mid-codepoint by interleaving a 4-byte char near the cut.
423        let mut raw = String::with_capacity(EXCERPT_HEAD_BYTES + EXCERPT_TAIL_BYTES + 200);
424        raw.push_str(&"a".repeat(EXCERPT_HEAD_BYTES - 2));
425        raw.push('🦀'); // 4 bytes — straddles the 400-byte cut
426        raw.push_str(&"b".repeat(EXCERPT_TAIL_BYTES + 200));
427        // Just calling it is the test — a char-boundary failure would panic.
428        let excerpt = build_excerpt(&raw);
429        assert!(!excerpt.is_empty());
430    }
431
432    #[test]
433    fn field_type_mismatch_display() {
434        let err = PredictError::FieldTypeMismatch {
435            field: "age".to_string(),
436            expected: "int".to_string(),
437            actual: "not_a_number".to_string(),
438        };
439        let msg = err.to_string();
440        assert!(msg.contains("age"));
441        assert!(msg.contains("int"));
442        assert!(msg.contains("not_a_number"));
443    }
444
445    #[test]
446    fn invalid_signature_display() {
447        let err = PredictError::invalid_signature("no output fields");
448        assert!(err.to_string().contains("no output fields"));
449    }
450
451    #[test]
452    fn serialization_error_from_serde() {
453        let serde_err = serde_json::from_str::<String>("invalid").unwrap_err();
454        let err = PredictError::from(serde_err);
455        assert!(matches!(err, PredictError::Serialization(_)));
456    }
457
458    // ============================================================
459    // Phase 8: Error-display ergonomics for the new variant errors.
460    //
461    // Each new variant's Display impl must surface the information a
462    // production operator needs to debug from the error alone: the field
463    // path, the offending value, the valid options (if any). Snapshot-
464    // style assertions guard against drift under future refactors.
465    // ============================================================
466
467    #[test]
468    fn one_of_tag_missing_display_names_field_and_discriminator() {
469        let err = PredictError::OneOfTagMissing {
470            field: "results[0].assignment".into(),
471            discriminator: "toolName".into(),
472        };
473        let msg = err.to_string();
474        assert!(msg.contains("results[0].assignment"), "field path: {msg}");
475        assert!(msg.contains("toolName"), "discriminator name: {msg}");
476    }
477
478    #[test]
479    fn one_of_tag_invalid_display_lists_valid_tags() {
480        let err = PredictError::OneOfTagInvalid {
481            field: "assignment".into(),
482            tag: "Ranked_Items".into(),
483            valid_tags: vec!["monthly_breakdown".into(), "ranked_items".into()],
484        };
485        let msg = err.to_string();
486        assert!(msg.contains("assignment"), "field path: {msg}");
487        assert!(msg.contains("Ranked_Items"), "offending tag: {msg}");
488        assert!(msg.contains("monthly_breakdown"), "valid tag listed: {msg}");
489        assert!(msg.contains("ranked_items"), "valid tag listed: {msg}");
490    }
491
492    #[test]
493    fn one_of_no_arm_matched_display_includes_arm_count() {
494        let arm_errors = vec![
495            (
496                0,
497                Box::new(PredictError::FieldTypeMismatch {
498                    field: "arm0".into(),
499                    expected: "int".into(),
500                    actual: "abc".into(),
501                }),
502            ),
503            (
504                1,
505                Box::new(PredictError::FieldTypeMismatch {
506                    field: "arm1".into(),
507                    expected: "object".into(),
508                    actual: "abc".into(),
509                }),
510            ),
511        ];
512        let err = PredictError::OneOfNoArmMatched {
513            field: "value".into(),
514            arm_errors,
515        };
516        let msg = err.to_string();
517        assert!(msg.contains("value"), "field path: {msg}");
518        assert!(msg.contains("2 arm"), "arm count: {msg}");
519    }
520
521    #[test]
522    fn one_of_ambiguous_display_lists_matching_arms_and_remediation() {
523        let err = PredictError::OneOfAmbiguous {
524            field: "result".into(),
525            matching_arms: vec![0, 2],
526        };
527        let msg = err.to_string();
528        assert!(msg.contains("result"), "field path: {msg}");
529        assert!(msg.contains("[0, 2]"), "matching arms: {msg}");
530        // Remediation hint helps the schema author resolve the
531        // ambiguity rather than just naming the symptom.
532        assert!(
533            msg.contains("disjoint") || msg.contains("anyOf"),
534            "remediation hint: {msg}"
535        );
536    }
537
538    #[test]
539    fn any_of_no_arm_matched_display_includes_arm_count() {
540        let arm_errors = vec![(
541            0,
542            Box::new(PredictError::FieldTypeMismatch {
543                field: "arm0".into(),
544                expected: "int".into(),
545                actual: "abc".into(),
546            }),
547        )];
548        let err = PredictError::AnyOfNoArmMatched {
549            field: "data".into(),
550            arm_errors,
551        };
552        let msg = err.to_string();
553        assert!(msg.contains("data"), "field path: {msg}");
554        assert!(msg.contains("AnyOf"), "construct name: {msg}");
555    }
556}