const EXCERPT_HEAD_BYTES: usize = 400;
const EXCERPT_TAIL_BYTES: usize = 200;
#[must_use]
pub fn build_excerpt(raw: &str) -> String {
if raw.len() <= EXCERPT_HEAD_BYTES + EXCERPT_TAIL_BYTES {
return raw.to_owned();
}
let head = safe_prefix(raw, EXCERPT_HEAD_BYTES);
let tail = safe_suffix(raw, EXCERPT_TAIL_BYTES);
let elided = raw.len().saturating_sub(head.len() + tail.len());
format!("{head}\n… ({elided} bytes elided) …\n{tail}")
}
fn safe_prefix(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
fn safe_suffix(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut start = s.len() - max_bytes;
while start < s.len() && !s.is_char_boundary(start) {
start += 1;
}
&s[start..]
}
#[derive(Debug, thiserror::Error)]
pub enum PredictError {
#[error(
"missing output fields: {fields:?} (expected {} field(s); model emitted {raw_bytes_total} bytes)\n raw excerpt:\n {raw_excerpt}",
expected.len(),
)]
MissingFields {
fields: Vec<String>,
expected: Vec<String>,
raw_excerpt: String,
raw_bytes_total: usize,
},
#[error("field '{field}' is not present in this prediction")]
FieldNotInPrediction {
field: String,
},
#[error(
"language model truncated output: stop_reason={stop_reason} \
(output_tokens={output_tokens:?}); raise max_tokens or shrink the input \
to give the model room to finish"
)]
Truncated {
stop_reason: String,
output_tokens: Option<u64>,
},
#[error("field '{field}' type mismatch: expected {expected}, got {actual:?}")]
FieldTypeMismatch {
field: String,
expected: String,
actual: String,
},
#[error(
"no field markers found in completion (expected {expected_markers:?}; \
model emitted {raw_bytes_total} bytes)\n raw excerpt:\n {raw_excerpt}"
)]
NoFieldMarkers {
expected_markers: Vec<String>,
raw_excerpt: String,
raw_bytes_total: usize,
},
#[error("invalid signature: {reason}")]
InvalidSignature {
reason: String,
},
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("language model error: {0}")]
LanguageModel(#[from] modelplease::LanguageModelError),
#[error("optimizer error: {message}")]
Optimizer {
message: String,
},
#[error("evaluation error: {message}")]
Evaluation {
message: String,
},
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error(
"field '{field}' is a tagged variant but its discriminator property \
`{discriminator}` is missing or the value is not a JSON object"
)]
OneOfTagMissing {
field: String,
discriminator: String,
},
#[error(
"field '{field}' discriminator value '{tag}' is not a valid arm tag; \
expected one of: {valid_tags:?}"
)]
OneOfTagInvalid {
field: String,
tag: String,
valid_tags: Vec<String>,
},
#[error(
"field '{field}' did not match any OneOf arm ({} arm(s) tried)",
arm_errors.len()
)]
OneOfNoArmMatched {
field: String,
arm_errors: Vec<(usize, Box<Self>)>,
},
#[error(
"field '{field}' matched multiple OneOf arms ({matching_arms:?}); \
oneOf requires exactly one match. Either make the arms structurally \
disjoint, or use anyOf for first-match-wins semantics."
)]
OneOfAmbiguous {
field: String,
matching_arms: Vec<usize>,
},
#[error(
"field '{field}' did not match any AnyOf arm ({} arm(s) tried)",
arm_errors.len()
)]
AnyOfNoArmMatched {
field: String,
arm_errors: Vec<(usize, Box<Self>)>,
},
}
impl PredictError {
#[must_use]
pub fn missing_fields_from_parse<F, E>(missing: F, expected: E, raw_completion: &str) -> Self
where
F: IntoIterator<Item = String>,
E: IntoIterator<Item = String>,
{
Self::MissingFields {
fields: missing.into_iter().collect(),
expected: expected.into_iter().collect(),
raw_excerpt: build_excerpt(raw_completion),
raw_bytes_total: raw_completion.len(),
}
}
#[must_use]
pub fn no_field_markers_from_parse<E>(expected_markers: E, raw_completion: &str) -> Self
where
E: IntoIterator<Item = String>,
{
Self::NoFieldMarkers {
expected_markers: expected_markers.into_iter().collect(),
raw_excerpt: build_excerpt(raw_completion),
raw_bytes_total: raw_completion.len(),
}
}
pub fn invalid_signature(reason: impl Into<String>) -> Self {
Self::InvalidSignature {
reason: reason.into(),
}
}
pub fn optimizer(message: impl Into<String>) -> Self {
Self::Optimizer {
message: message.into(),
}
}
pub fn evaluation(message: impl Into<String>) -> Self {
Self::Evaluation {
message: message.into(),
}
}
#[must_use]
pub fn truncated(stop_reason: &modelplease::StopReason, output_tokens: Option<u64>) -> Self {
let label = match stop_reason {
modelplease::StopReason::EndTurn => "end_turn".to_owned(),
modelplease::StopReason::MaxTokens => "max_tokens".to_owned(),
modelplease::StopReason::StopSequence => "stop_sequence".to_owned(),
modelplease::StopReason::ToolUse => "tool_use".to_owned(),
modelplease::StopReason::ContentFilter => "content_filter".to_owned(),
modelplease::StopReason::Other(s) => s.clone(),
};
Self::Truncated {
stop_reason: label,
output_tokens,
}
}
}
pub type Result<T> = std::result::Result<T, PredictError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_fields_display_carries_excerpt_and_total_bytes() {
let completion = "[[ ## answer ## ]]\n42\n[[ ## completed ## ]]";
let err = PredictError::missing_fields_from_parse(
["reasoning".to_owned()],
["reasoning".to_owned(), "answer".to_owned()],
completion,
);
let msg = err.to_string();
assert!(msg.contains("reasoning"), "names the missing field: {msg}");
assert!(msg.contains("2 field"), "states expected count: {msg}");
assert!(
msg.contains(&format!("{} bytes", completion.len())),
"names total bytes: {msg}"
);
assert!(
msg.contains("[[ ## answer ## ]]"),
"includes raw excerpt: {msg}"
);
}
#[test]
fn no_field_markers_display_carries_excerpt() {
let completion = "Sorry, I cannot answer that question.";
let err = PredictError::no_field_markers_from_parse(
["[[ ## answer ## ]]".to_owned()],
completion,
);
let msg = err.to_string();
assert!(
msg.contains("[[ ## answer ## ]]"),
"names expected marker: {msg}"
);
assert!(
msg.contains(&format!("{} bytes", completion.len())),
"names total bytes: {msg}"
);
assert!(msg.contains("Sorry"), "includes raw excerpt: {msg}");
}
#[test]
fn field_not_in_prediction_display_names_field() {
let err = PredictError::FieldNotInPrediction {
field: "oops".into(),
};
let msg = err.to_string();
assert!(msg.contains("oops"), "names the missing key: {msg}");
}
#[test]
fn build_excerpt_inlines_short_input() {
let short = "hello";
assert_eq!(build_excerpt(short), short);
}
#[test]
fn build_excerpt_truncates_with_elision_marker() {
let raw = "x".repeat(1000);
let excerpt = build_excerpt(&raw);
assert!(
excerpt.contains("400 bytes elided"),
"names elided count: {excerpt}"
);
assert!(excerpt.len() < raw.len(), "shorter than input");
}
#[test]
fn build_excerpt_respects_char_boundaries() {
let mut raw = String::with_capacity(EXCERPT_HEAD_BYTES + EXCERPT_TAIL_BYTES + 200);
raw.push_str(&"a".repeat(EXCERPT_HEAD_BYTES - 2));
raw.push('🦀'); raw.push_str(&"b".repeat(EXCERPT_TAIL_BYTES + 200));
let excerpt = build_excerpt(&raw);
assert!(!excerpt.is_empty());
}
#[test]
fn field_type_mismatch_display() {
let err = PredictError::FieldTypeMismatch {
field: "age".to_string(),
expected: "int".to_string(),
actual: "not_a_number".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("age"));
assert!(msg.contains("int"));
assert!(msg.contains("not_a_number"));
}
#[test]
fn invalid_signature_display() {
let err = PredictError::invalid_signature("no output fields");
assert!(err.to_string().contains("no output fields"));
}
#[test]
fn serialization_error_from_serde() {
let serde_err = serde_json::from_str::<String>("invalid").unwrap_err();
let err = PredictError::from(serde_err);
assert!(matches!(err, PredictError::Serialization(_)));
}
#[test]
fn one_of_tag_missing_display_names_field_and_discriminator() {
let err = PredictError::OneOfTagMissing {
field: "results[0].assignment".into(),
discriminator: "toolName".into(),
};
let msg = err.to_string();
assert!(msg.contains("results[0].assignment"), "field path: {msg}");
assert!(msg.contains("toolName"), "discriminator name: {msg}");
}
#[test]
fn one_of_tag_invalid_display_lists_valid_tags() {
let err = PredictError::OneOfTagInvalid {
field: "assignment".into(),
tag: "Ranked_Items".into(),
valid_tags: vec!["monthly_breakdown".into(), "ranked_items".into()],
};
let msg = err.to_string();
assert!(msg.contains("assignment"), "field path: {msg}");
assert!(msg.contains("Ranked_Items"), "offending tag: {msg}");
assert!(msg.contains("monthly_breakdown"), "valid tag listed: {msg}");
assert!(msg.contains("ranked_items"), "valid tag listed: {msg}");
}
#[test]
fn one_of_no_arm_matched_display_includes_arm_count() {
let arm_errors = vec![
(
0,
Box::new(PredictError::FieldTypeMismatch {
field: "arm0".into(),
expected: "int".into(),
actual: "abc".into(),
}),
),
(
1,
Box::new(PredictError::FieldTypeMismatch {
field: "arm1".into(),
expected: "object".into(),
actual: "abc".into(),
}),
),
];
let err = PredictError::OneOfNoArmMatched {
field: "value".into(),
arm_errors,
};
let msg = err.to_string();
assert!(msg.contains("value"), "field path: {msg}");
assert!(msg.contains("2 arm"), "arm count: {msg}");
}
#[test]
fn one_of_ambiguous_display_lists_matching_arms_and_remediation() {
let err = PredictError::OneOfAmbiguous {
field: "result".into(),
matching_arms: vec![0, 2],
};
let msg = err.to_string();
assert!(msg.contains("result"), "field path: {msg}");
assert!(msg.contains("[0, 2]"), "matching arms: {msg}");
assert!(
msg.contains("disjoint") || msg.contains("anyOf"),
"remediation hint: {msg}"
);
}
#[test]
fn any_of_no_arm_matched_display_includes_arm_count() {
let arm_errors = vec![(
0,
Box::new(PredictError::FieldTypeMismatch {
field: "arm0".into(),
expected: "int".into(),
actual: "abc".into(),
}),
)];
let err = PredictError::AnyOfNoArmMatched {
field: "data".into(),
arm_errors,
};
let msg = err.to_string();
assert!(msg.contains("data"), "field path: {msg}");
assert!(msg.contains("AnyOf"), "construct name: {msg}");
}
}