use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{
error::RapidOcrError,
evaluation::formula::metrics::{FormulaTextMetrics, evaluate_text},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailureKind {
None,
ImageDecode,
Inference,
TokenizerDecode,
InputRejected,
TruncatedNoEos,
ModelMismatch,
EmptyGroundTruth,
}
impl FailureKind {
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::ImageDecode => "image_decode",
Self::Inference => "inference",
Self::TokenizerDecode => "tokenizer_decode",
Self::InputRejected => "input_rejected",
Self::TruncatedNoEos => "truncated_no_eos",
Self::ModelMismatch => "model_mismatch",
Self::EmptyGroundTruth => "empty_ground_truth",
}
}
pub fn is_pipeline_error(self) -> bool {
matches!(
self,
Self::ImageDecode | Self::Inference | Self::TokenizerDecode | Self::InputRejected
)
}
pub fn is_model_error(self) -> bool {
matches!(self, Self::ModelMismatch | Self::TruncatedNoEos)
}
}
pub fn classify_error(error: &RapidOcrError) -> FailureKind {
match error {
RapidOcrError::InvalidImage(_) => FailureKind::ImageDecode,
RapidOcrError::FileNotFound(_) => FailureKind::ImageDecode,
RapidOcrError::Tokenizer(_) => FailureKind::TokenizerDecode,
RapidOcrError::Decode(_) => FailureKind::Inference,
RapidOcrError::InvalidInput(_) => FailureKind::InputRejected,
RapidOcrError::Config(_) => FailureKind::InputRejected,
RapidOcrError::UnsupportedProvider(_) => FailureKind::InputRejected,
RapidOcrError::UnsupportedBackend(_) => FailureKind::InputRejected,
RapidOcrError::HashMismatch { .. } => FailureKind::InputRejected,
RapidOcrError::ModelResolve(_) => FailureKind::InputRejected,
RapidOcrError::Download(_) => FailureKind::ImageDecode,
RapidOcrError::Io(_) => FailureKind::ImageDecode,
RapidOcrError::Reqwest(_) => FailureKind::ImageDecode,
RapidOcrError::Yaml(_) => FailureKind::InputRejected,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SampleRecord {
pub image: String,
pub relative_path: String,
pub expected: String,
pub actual: Option<String>,
pub token_ids: Vec<i64>,
pub eos_index: Option<usize>,
pub truncated: Option<bool>,
pub elapsed_ms: f32,
pub batch_size: usize,
pub failure: FailureKind,
pub error: Option<String>,
pub exact_match: bool,
pub normalized_match: bool,
pub edit_distance: usize,
pub cer: f64,
pub python_token_match: Option<bool>,
pub python_latex_match: Option<bool>,
}
impl SampleRecord {
pub fn pipeline_error(image: String, expected: String, error: &RapidOcrError) -> Self {
Self {
image,
relative_path: String::new(),
expected,
actual: None,
token_ids: Vec::new(),
eos_index: None,
truncated: None,
elapsed_ms: 0.0,
batch_size: 0,
failure: classify_error(error),
error: Some(error.to_string()),
exact_match: false,
normalized_match: false,
edit_distance: usize::MAX,
cer: 1.0,
python_token_match: None,
python_latex_match: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EvaluationSummary {
pub total: usize,
pub scored: usize,
pub pipeline_failures: usize,
pub model_mismatches: usize,
pub exact_matches: usize,
pub normalized_matches: usize,
pub truncated: usize,
pub exact_match_rate: f64,
pub normalized_match_rate: f64,
pub mean_cer: f64,
pub failure_counts: BTreeMap<String, usize>,
}
pub fn summarize<'a, I>(records: I) -> EvaluationSummary
where
I: IntoIterator<Item = &'a SampleRecord>,
{
let mut summary = EvaluationSummary::default();
let mut cer_sum = 0.0f64;
for record in records {
summary.total += 1;
*summary
.failure_counts
.entry(record.failure.as_str().to_string())
.or_insert(0) += 1;
if record.failure.is_pipeline_error() {
summary.pipeline_failures += 1;
}
if record.failure == FailureKind::ModelMismatch {
summary.model_mismatches += 1;
}
if record.truncated == Some(true) {
summary.truncated += 1;
}
if record.failure == FailureKind::EmptyGroundTruth || record.failure.is_pipeline_error() {
continue;
}
summary.scored += 1;
if record.exact_match {
summary.exact_matches += 1;
}
if record.normalized_match {
summary.normalized_matches += 1;
}
cer_sum += record.cer;
}
if summary.scored > 0 {
summary.exact_match_rate = summary.exact_matches as f64 / summary.scored as f64;
summary.normalized_match_rate = summary.normalized_matches as f64 / summary.scored as f64;
summary.mean_cer = cer_sum / summary.scored as f64;
}
summary
}
pub fn score_latex(
expected: &str,
actual: &str,
truncated: bool,
) -> (FormulaTextMetrics, FailureKind) {
let metrics = evaluate_text(expected, actual);
let kind = if expected.trim().is_empty() {
FailureKind::EmptyGroundTruth
} else if metrics.exact_match {
FailureKind::None
} else if truncated {
FailureKind::TruncatedNoEos
} else {
FailureKind::ModelMismatch
};
(metrics, kind)
}
#[cfg(test)]
mod tests {
use super::*;
fn record(expected: &str, actual: &str, truncated: bool) -> SampleRecord {
let (metrics, failure) = score_latex(expected, actual, truncated);
SampleRecord {
image: "img.png".to_string(),
relative_path: "img.png".to_string(),
expected: expected.to_string(),
actual: Some(actual.to_string()),
token_ids: vec![0, 2],
eos_index: Some(1),
truncated: Some(truncated),
elapsed_ms: 1.0,
batch_size: 1,
failure,
error: None,
exact_match: metrics.exact_match,
normalized_match: metrics.normalized_match,
edit_distance: metrics.edit_distance,
cer: metrics.cer,
python_token_match: None,
python_latex_match: None,
}
}
#[test]
fn pipeline_and_model_errors_are_counted_separately() {
let pipeline = SampleRecord::pipeline_error(
"a.png".to_string(),
"x".to_string(),
&RapidOcrError::Tokenizer("bad token".to_string()),
);
assert_eq!(pipeline.failure, FailureKind::TokenizerDecode);
assert!(pipeline.failure.is_pipeline_error());
let records = [
record("x+y", "x+y", false),
record("x+y", "x-y", false),
record("x+y", "x+y", true),
pipeline,
record("", "", false),
];
let summary = summarize(records.iter());
assert_eq!(summary.total, 5);
assert_eq!(
summary.scored, 3,
"pipeline error and empty truth are not scored"
);
assert_eq!(summary.pipeline_failures, 1);
assert_eq!(
summary.model_mismatches, 1,
"only the wrong LaTeX is a model error"
);
assert_eq!(summary.exact_matches, 2);
assert_eq!(
summary.truncated, 1,
"truncation stays visible even when the LaTeX happens to match"
);
assert_eq!(summary.failure_counts["empty_ground_truth"], 1);
assert_eq!(
summary
.failure_counts
.get("truncated_no_eos")
.copied()
.unwrap_or(0),
0
);
assert!((summary.exact_match_rate - 2.0 / 3.0).abs() < 1e-12);
}
#[test]
fn truncated_output_is_a_model_error_not_a_pipeline_error() {
let (_, failure) = score_latex("x+y", "x+", true);
assert_eq!(failure, FailureKind::TruncatedNoEos);
assert!(failure.is_model_error());
assert!(!failure.is_pipeline_error());
}
#[test]
fn error_classification_covers_decode_and_inference() {
assert_eq!(
classify_error(&RapidOcrError::Decode("ort".to_string())),
FailureKind::Inference
);
assert_eq!(
classify_error(&RapidOcrError::InvalidImage("png".to_string())),
FailureKind::ImageDecode
);
assert_eq!(
classify_error(&RapidOcrError::Tokenizer("vocab".to_string())),
FailureKind::TokenizerDecode
);
}
#[test]
fn normalized_match_is_tracked_separately_from_exact() {
let (metrics, failure) = score_latex("x + y", "x+y", false);
assert!(!metrics.exact_match);
assert!(metrics.normalized_match);
assert_eq!(failure, FailureKind::ModelMismatch);
}
}