1const EXCERPT_HEAD_BYTES: usize = 400;
12
13const EXCERPT_TAIL_BYTES: usize = 200;
18
19#[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
40fn 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
53fn 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#[derive(Debug, thiserror::Error)]
68pub enum PredictError {
69 #[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 fields: Vec<String>,
83 expected: Vec<String>,
86 raw_excerpt: String,
91 raw_bytes_total: usize,
95 },
96
97 #[error("field '{field}' is not present in this prediction")]
103 FieldNotInPrediction {
104 field: String,
106 },
107
108 #[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 stop_reason: String,
124 output_tokens: Option<u64>,
128 },
129
130 #[error("field '{field}' type mismatch: expected {expected}, got {actual:?}")]
132 FieldTypeMismatch {
133 field: String,
135 expected: String,
137 actual: String,
139 },
140
141 #[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 expected_markers: Vec<String>,
154 raw_excerpt: String,
156 raw_bytes_total: usize,
158 },
159
160 #[error("invalid signature: {reason}")]
162 InvalidSignature {
163 reason: String,
165 },
166
167 #[error("serialization error: {0}")]
169 Serialization(#[from] serde_json::Error),
170
171 #[error("language model error: {0}")]
173 LanguageModel(#[from] modelplease::LanguageModelError),
174
175 #[error("optimizer error: {message}")]
177 Optimizer {
178 message: String,
180 },
181
182 #[error("evaluation error: {message}")]
184 Evaluation {
185 message: String,
187 },
188
189 #[error("I/O error: {0}")]
191 Io(#[from] std::io::Error),
192
193 #[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 field: String,
203 discriminator: String,
205 },
206
207 #[error(
212 "field '{field}' discriminator value '{tag}' is not a valid arm tag; \
213 expected one of: {valid_tags:?}"
214 )]
215 OneOfTagInvalid {
216 field: String,
218 tag: String,
220 valid_tags: Vec<String>,
222 },
223
224 #[error(
228 "field '{field}' did not match any OneOf arm ({} arm(s) tried)",
229 arm_errors.len()
230 )]
231 OneOfNoArmMatched {
232 field: String,
234 arm_errors: Vec<(usize, Box<Self>)>,
237 },
238
239 #[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 field: String,
250 matching_arms: Vec<usize>,
252 },
253
254 #[error(
257 "field '{field}' did not match any AnyOf arm ({} arm(s) tried)",
258 arm_errors.len()
259 )]
260 AnyOfNoArmMatched {
261 field: String,
263 arm_errors: Vec<(usize, Box<Self>)>,
265 },
266}
267
268impl PredictError {
269 #[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 #[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 pub fn invalid_signature(reason: impl Into<String>) -> Self {
304 Self::InvalidSignature {
305 reason: reason.into(),
306 }
307 }
308
309 pub fn optimizer(message: impl Into<String>) -> Self {
311 Self::Optimizer {
312 message: message.into(),
313 }
314 }
315
316 pub fn evaluation(message: impl Into<String>) -> Self {
318 Self::Evaluation {
319 message: message.into(),
320 }
321 }
322
323 #[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
342pub 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 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 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('🦀'); raw.push_str(&"b".repeat(EXCERPT_TAIL_BYTES + 200));
427 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 #[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 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}