use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::Arc,
time::Instant,
};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::{
api::{
ClassifierPlan, ClassifierPolicy, DetectionPolicy, ImageInput, OcrEngine, OcrOutput,
OcrRequest, OutputPolicy, PreprocessPolicy, RecognitionPolicy, StagePlan, WordOutputMode,
},
config::{RecImage, RuntimeConfig},
error::{RapidOcrError, Result},
evaluation::{
digest::digest_hex,
runtime_report::attach_runtime_fields,
table::{
fixture::{
SpanLimits, TableFixture, TableSample, load_pubtabnet_val, span_limits, span_usage,
summarize_span_exceedance,
},
protocol::{LOOP_TRIP_CAP, UpperBound, classify_upper_bound, protocol_section},
},
},
ocr::{
config::RecognizeOptions,
pipeline::{config::EngineConfig, rapid_structure::RapidOcrEngine},
rec::recognizer::Recognizer,
},
table::{
cell_content_backfill::{
BackfillOutcome, BackfillSource, CellRecognizer, TextLine, axis_aligned_bbox, backfill,
},
decode::{StructureVocabulary, argmax_structure, decode_token_steps},
emitter::{
RenderedTable, StructureConfidence, StructureDegradation, TextPolicy, emit_html,
},
ocr_resolution::TableOcrResolution,
preprocess::table_structure_input,
structure::{TableStructureConfig, TableStructureModelInfo, TableStructureSession},
},
};
pub const DEFAULT_MODEL_RELATIVE: &str = "models/table/slanet-plus.onnx";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TableContentSource {
StructureOnly,
PageReuse {
config: Option<PathBuf>,
cache: Option<PathBuf>,
},
CropControl {
config: Option<PathBuf>,
},
}
impl TableContentSource {
pub fn reported_metric(&self) -> &'static str {
match self {
Self::StructureOnly => "TEDS-Struct",
Self::PageReuse { .. } | Self::CropControl { .. } => "TEDS",
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::StructureOnly => "structure_only",
Self::PageReuse { .. } => "page_reuse",
Self::CropControl { .. } => "crop_control",
}
}
pub fn uses_engine(&self) -> bool {
!matches!(self, Self::StructureOnly)
}
pub fn is_structure_only(&self) -> bool {
matches!(self, Self::StructureOnly)
}
}
#[derive(Debug, Clone)]
pub struct TableEvalConfig {
pub dataset: PathBuf,
pub model: PathBuf,
pub predictions: PathBuf,
pub limit: usize,
pub side_len: usize,
pub fix_input_shape: bool,
pub intra_threads: Option<usize>,
pub fail_fast: bool,
pub content: TableContentSource,
pub ocr_resolution: TableOcrResolution,
}
impl Default for TableEvalConfig {
fn default() -> Self {
Self {
dataset: PathBuf::from(crate::evaluation::table::fixture::DEFAULT_DATASET_RELATIVE),
model: PathBuf::from(DEFAULT_MODEL_RELATIVE),
predictions: PathBuf::from("table-predictions.json"),
limit: 0,
side_len: 488,
fix_input_shape: true,
intra_threads: None,
fail_fast: false,
content: TableContentSource::StructureOnly,
ocr_resolution: TableOcrResolution::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TableEvalEvent {
Message(String),
Progress { done: usize, total: usize },
}
#[derive(Debug, Clone, Deserialize)]
pub struct TableEvalManifest {
pub domain: String,
#[serde(default)]
pub dataset: Option<PathBuf>,
#[serde(default)]
pub model: Option<PathBuf>,
#[serde(default)]
pub predictions: Option<PathBuf>,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub side_len: Option<usize>,
#[serde(default)]
pub fix_input_shape: Option<bool>,
#[serde(default)]
pub intra_threads: Option<usize>,
#[serde(default)]
pub fail_fast: Option<bool>,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub ocr_config: Option<PathBuf>,
#[serde(default)]
pub ocr_cache: Option<PathBuf>,
#[serde(default)]
pub ocr_resolution: Option<TableOcrResolution>,
}
impl TableEvalManifest {
pub const DOMAIN: &'static str = "table";
pub fn matches(value: &Value) -> bool {
value
.get("domain")
.and_then(Value::as_str)
.is_some_and(|domain| domain == Self::DOMAIN)
}
pub fn into_config(self) -> Result<TableEvalConfig> {
if self.domain != Self::DOMAIN {
return Err(RapidOcrError::Config(format!(
"a table manifest must declare \"domain\": \"{}\", got {:?}",
Self::DOMAIN,
self.domain
)));
}
let defaults = TableEvalConfig::default();
let content = match self.content.as_deref() {
None | Some("structure-only") => TableContentSource::StructureOnly,
Some("page-reuse") => TableContentSource::PageReuse {
config: self.ocr_config,
cache: self.ocr_cache,
},
Some("crop-control") => TableContentSource::CropControl {
config: self.ocr_config,
},
Some(other) => {
return Err(RapidOcrError::Config(format!(
"content must be \"structure-only\", \"page-reuse\" or \"crop-control\", got \
{other:?}"
)));
}
};
Ok(TableEvalConfig {
dataset: self.dataset.unwrap_or(defaults.dataset),
model: self.model.unwrap_or(defaults.model),
predictions: self.predictions.unwrap_or(defaults.predictions),
limit: self.limit.unwrap_or(defaults.limit),
side_len: self.side_len.unwrap_or(defaults.side_len),
fix_input_shape: self.fix_input_shape.unwrap_or(defaults.fix_input_shape),
intra_threads: self.intra_threads,
fail_fast: self.fail_fast.unwrap_or(defaults.fail_fast),
content,
ocr_resolution: self.ocr_resolution.unwrap_or(defaults.ocr_resolution),
})
}
}
#[derive(Debug, Clone)]
pub struct TableEvalOutcome {
pub report: Value,
pub selected: usize,
pub failed: usize,
}
impl TableEvalOutcome {
pub fn field(&self, name: &str) -> Option<&Value> {
self.report.get(name)
}
}
fn ocr_resolution_report(config: &TableEvalConfig) -> Value {
json!({
"limit_side_len": config.ocr_resolution.limit_side_len,
"limit_type": config.ocr_resolution.limit_type,
"max_side_len": config.ocr_resolution.max_side_len,
"live_knob": match config.ocr_resolution.limit_type.as_str() {
"min" => "limit_side_len (det enlarges until the short side reaches it)",
_ => "max_side_len (det ignores limit_side_len under limit_type=max)",
},
"applies_to": if matches!(config.content, TableContentSource::PageReuse { .. }) {
"page_reuse"
} else {
"nothing (only the page-reuse path runs det)"
},
})
}
pub fn run_table_evaluation(
config: &TableEvalConfig,
on_event: &mut dyn FnMut(TableEvalEvent),
) -> Result<TableEvalOutcome> {
let fixture = load_pubtabnet_val(&config.dataset)?;
let verify = fixture.verify()?;
let selected = fixture.select(config.limit);
let sample_set_sha256 = TableFixture::sample_set_digest(&selected);
let content_sha256 = fixture.content_digest(&selected)?;
message(
on_event,
format!(
"dataset: {} sample(s) loaded, {} html hashes verified, {} selected (limit {})",
verify.samples,
verify.hashed,
selected.len(),
config.limit
),
);
message(
on_event,
format!(
"sample_set_sha256 {sample_set_sha256} content_sha256 {}",
content_sha256
.as_deref()
.unwrap_or("<unreadable image, not claimed>")
),
);
let table_config = TableStructureConfig {
input_side_len: config.side_len,
fix_input_shape: config.fix_input_shape,
intra_threads: config.intra_threads,
};
let registered_expected =
crate::table::registration::require_registered_model_hash(&config.model)?;
let runtime_cfg = table_config.effective_runtime_config(&RuntimeConfig::default());
let model_info = TableStructureModelInfo::probe(&config.model, &runtime_cfg)?;
let vocabulary = StructureVocabulary::new(model_info.character_tokens.clone())?;
let limits = span_limits(&vocabulary);
let exceedance = summarize_span_exceedance(&selected, &limits, 8);
message(
on_event,
format!(
"vocabulary: {} fragment(s), {} class(es); span limits rowspan<={:?} colspan<={:?}",
vocabulary.tokens().len(),
vocabulary.class_count(),
limits.max_rowspan,
limits.max_colspan
),
);
message(
on_event,
format!(
"constructive upper bound: {} of {} table(s) ({:.4}%) contain a span the vocabulary \
cannot express",
exceedance.samples_exceeding,
exceedance.samples,
exceedance.fraction() * 100.0
),
);
let upper_bound = tally_upper_bound(&selected, &limits);
message(
on_event,
format!(
"loop trip cap {LOOP_TRIP_CAP}: largest truth structure stream is {} token(s); {} of {} \
table(s) are beyond the cap",
upper_bound.max_structure_tokens, upper_bound.by_loop_cap, upper_bound.tables
),
);
message(
on_event,
format!(
"deducted from the regression criterion (still counted in the headline metric): {} of {} \
table(s) ({:.4}%)",
upper_bound.deducted,
upper_bound.tables,
upper_bound.fraction() * 100.0
),
);
let model_credential =
crate::model_verify::VerifiedModel::verify(&config.model, Some(®istered_expected))?;
let mut session = TableStructureSession::open(&model_credential, &runtime_cfg, &table_config)?;
let effective_threads = session.session_threads();
debug_assert_eq!(
effective_threads,
runtime_cfg.effective_session_threads(),
"the session must report the thread pair the table config asked for"
);
let threads_label = |value: Option<usize>| {
value.map_or_else(|| "ort-default".to_string(), |count| count.to_string())
};
message(
on_event,
format!(
"session: input dims {:?}, threads (intra, inter) = ({}, {})",
session.input_dims(),
threads_label(effective_threads.0),
threads_label(effective_threads.1)
),
);
let mut predictions: BTreeMap<String, String> = BTreeMap::new();
let mut failures: Vec<Value> = Vec::new();
let mut decodable = 0_usize;
let mut preprocess_ms = Vec::with_capacity(selected.len());
let mut infer_ms = Vec::with_capacity(selected.len());
let mut decode_ms = Vec::with_capacity(selected.len());
let mut total_ms = Vec::with_capacity(selected.len());
let mut cell_counts = Vec::with_capacity(selected.len());
let mut backfill_stats: Vec<BackfillStats> = Vec::new();
let mut confidence_counts: BTreeMap<&'static str, usize> = BTreeMap::new();
let mut degradation_counts: BTreeMap<&'static str, usize> = BTreeMap::new();
let mut holes_total = 0_usize;
let mut page_ocr = PageOcr::open(&config.content, &config.ocr_resolution)?;
if let Some(ocr) = page_ocr.as_mut() {
message(on_event, ocr.describe());
message(on_event, config.ocr_resolution.describe());
ocr.prepare(&selected, on_event)?;
}
let mut crop_control = CropRecognizer::open(&config.content)?;
if let Some(control) = crop_control.as_ref() {
message(
on_event,
"crop control: the recognizer is called once per cell with a bbox (this is the \
comparison baseline, not the production path)",
);
let _ = control;
}
let started = Instant::now();
for (index, sample) in selected.iter().enumerate() {
let mut lines = None;
if let Some(ocr) = page_ocr.as_mut() {
match ocr.lines_for(sample) {
Ok(extracted) => lines = Some(extracted),
Err(error) => {
if config.fail_fast {
return Err(error);
}
predictions.insert(sample.file_name().to_string(), String::new());
failures.push(json!({
"file_name": sample.file_name(),
"image_path": sample.relative_path(),
"error": error.to_string(),
}));
if (index + 1) % 500 == 0 {
on_event(TableEvalEvent::Progress {
done: index + 1,
total: selected.len(),
});
}
continue;
}
}
}
let source = match (&lines, crop_control.as_mut()) {
(Some(lines), _) => CellSource::Reuse(lines),
(None, Some(control)) => CellSource::Crop(control),
(None, None) => CellSource::Empty,
};
match predict_one(&mut session, &vocabulary, &table_config, sample, source) {
Ok(outcome) => {
preprocess_ms.push(outcome.preprocess_ms);
infer_ms.push(outcome.infer_ms);
decode_ms.push(outcome.decode_ms);
total_ms.push(outcome.total_ms);
cell_counts.push(outcome.cells);
if let Some(stats) = outcome.backfill {
backfill_stats.push(stats);
}
decodable += 1;
*confidence_counts
.entry(confidence_kind(outcome.confidence))
.or_default() += 1;
for degradation in &outcome.degradations {
*degradation_counts
.entry(degradation_kind(*degradation))
.or_default() += 1;
if let StructureDegradation::GridHoles { holes } = degradation {
holes_total += holes;
}
}
predictions.insert(sample.file_name().to_string(), outcome.html);
}
Err(error) => {
if config.fail_fast {
return Err(error);
}
predictions.insert(sample.file_name().to_string(), String::new());
failures.push(json!({
"file_name": sample.file_name(),
"image_path": sample.relative_path(),
"error": error.to_string(),
}));
}
}
if (index + 1) % 500 == 0 {
on_event(TableEvalEvent::Progress {
done: index + 1,
total: selected.len(),
});
}
}
let wall_ms = started.elapsed().as_secs_f64() * 1000.0;
if let Some(ocr) = page_ocr.as_mut() {
ocr.flush()?;
}
let ocr_report = page_ocr.as_ref().map(PageOcr::report);
let crop_report = crop_control.as_ref().map(CropRecognizer::report);
let mut failure_kinds: BTreeMap<String, usize> = BTreeMap::new();
for failure in &failures {
let text = failure["error"].as_str().unwrap_or_default().to_string();
*failure_kinds.entry(text).or_default() += 1;
}
write_predictions(&config.predictions, &predictions)?;
let prediction_digest = digest_file(&config.predictions)?;
let cell_timing = summarize(
&cell_counts
.iter()
.map(|value| *value as f64)
.collect::<Vec<_>>(),
);
let mut report = json!({
"domain": "table",
"tool": "table_eval",
"percentile_method": crate::evaluation::stats::PERCENTILE_METHOD,
"content_source": config.content.as_str(),
"ocr_resolution": ocr_resolution_report(config),
"metric_scope": {
"structure_only": config.content.is_structure_only(),
"reason": match &config.content {
TableContentSource::StructureOnly => "cell texts need the page-level OCR + \
geometric matching path (doc 09 §10 = E9); every cell text is the empty \
string here",
TableContentSource::PageReuse { .. } => "cell texts are taken from the page-level \
OCR result by geometric matching; the recognizer is never called again for \
a cell",
TableContentSource::CropControl { .. } => "cell texts come from cropping every \
cell and running the recognizer on it (the comparison baseline of doc 09 \
§10.3, not the production path)",
},
"reported_metric": config.content.reported_metric(),
"not_reported": if config.content.uses_engine() {
json!([])
} else {
json!(["TEDS"])
},
"warning": "these numbers are not comparable with the SLANet TEDS in the PubTabNet \
paper (different evaluation set and vocabulary)",
},
"protocol": protocol_section(),
"model": {
"path": model_info.model_path,
"bytes": std::fs::metadata(&config.model)?.len(),
"sha256": digest_file(&config.model)?.1,
"input_name": model_info.input_name,
"input_dims": model_info.input_dims,
"box_output_name": model_info.box_output_name,
"structure_output_name": model_info.structure_output_name,
"structure_last_dim": model_info.structure_last_dim,
"token_count": vocabulary.tokens().len(),
"class_count": vocabulary.class_count(),
"sos_class_index": vocabulary.sos_class_index(),
"eos_class_index": vocabulary.eos_class_index(),
},
"config": {
"input_side_len": table_config.input_side_len,
"fix_input_shape": table_config.fix_input_shape,
"session_input_dims": session.input_dims(),
"intra_threads": effective_threads.0,
"inter_threads": effective_threads.1,
},
"dataset": {
"root": config.dataset.display().to_string(),
"manifest": fixture.manifest_path().display().to_string(),
"manifest_sha256": digest_file(fixture.manifest_path())?.1,
"sample_set_sha256": sample_set_sha256,
"content_sha256": content_sha256,
"samples_loaded": verify.samples,
"html_hashes_verified": verify.hashed,
"limit": config.limit,
"selected": selected.len(),
"decodable": decodable,
"failed": failures.len(),
"predictions_emitted": predictions.len(),
"failure_scoring": "a failed sample is written as an empty prediction, which the \
official metric scores as 0.0; failures are never dropped",
},
"span_limits": {
"max_rowspan": limits.max_rowspan,
"max_colspan": limits.max_colspan,
"source": "derived from the model's own `character` vocabulary, not hardcoded",
},
"span_exceedance": {
"tables": exceedance.samples,
"tables_exceeding": exceedance.samples_exceeding,
"fraction": exceedance.fraction(),
"cells_exceeding": exceedance.cells_exceeding,
"cells_total": exceedance.cells_total,
"observed_max_rowspan": exceedance.max_rowspan,
"observed_max_colspan": exceedance.max_colspan,
"examples": exceedance.examples.iter().map(|(name, kind, value)| json!({
"file_name": name,
"kind": kind.as_str(),
"value": value,
})).collect::<Vec<_>>(),
},
"structure_confidence": {
"high": confidence_counts.get("high").copied().unwrap_or(0),
"low": confidence_counts.get("low").copied().unwrap_or(0),
"degradations": {
"grid_holes": degradation_counts.get("grid_holes").copied().unwrap_or(0),
"truncated": degradation_counts.get("truncated").copied().unwrap_or(0),
},
"holes_total": holes_total,
"scoring": "a low-confidence sample is still scored; the flag says the model could not \
express the truth, not that the prediction is unusable",
"why_grid_holes": "the model cannot emit a span outside its vocabulary (SLANet-plus \
stops at 20, the data reaches colspan=24); it then declares a \
smaller span and the logical grid is left with a hole, which the \
emitter is the only place able to notice",
"why_truncated": "the structure `Loop` hit its trip cap before emitting `eos`, so the \
tail of the structure is missing even though the token stream looks \
well-formed",
},
"upper_bound": {
"loop_trip_cap": LOOP_TRIP_CAP,
"tables": upper_bound.tables,
"tables_deducted": upper_bound.deducted,
"fraction": upper_bound.fraction(),
"by_span_beyond_vocabulary": upper_bound.by_span,
"by_loop_trip_cap": upper_bound.by_loop_cap,
"by_both": upper_bound.by_both,
"largest_structure_tokens": upper_bound.max_structure_tokens,
"headline_metric": "these samples are still scored (their failures are real 0.0s)",
"regression_criterion": "these samples are excluded, because the model cannot \
control whether the truth is expressible",
"examples": upper_bound.examples.iter().map(|(name, reason)| json!({
"file_name": name,
"reason": reason,
})).collect::<Vec<_>>(),
},
"timing": {
"scope": "per-sample, wall clock around each stage; NOT the timing ledger",
"preprocess_ms": summarize(&preprocess_ms),
"infer_ms": summarize(&infer_ms),
"decode_ms": summarize(&decode_ms),
"total_ms": summarize(&total_ms),
"loop_wall_ms": wall_ms,
"cells": cell_timing,
},
"page_ocr": ocr_report,
"crop_control": crop_report,
"backfill": if backfill_stats.is_empty() {
Value::Null
} else {
summarize_backfill(&backfill_stats, &config.content)
},
"failures": failures,
"failure_kinds": failure_kinds,
"outputs": {
"predictions": {
"path": config.predictions.display().to_string(),
"bytes": prediction_digest.0,
"sha256": prediction_digest.1,
},
},
});
attach_runtime_fields(&mut report)?;
Ok(TableEvalOutcome {
report,
selected: selected.len(),
failed: failures.len(),
})
}
#[derive(Debug, Clone, Default)]
struct UpperBoundTally {
tables: usize,
deducted: usize,
by_span: usize,
by_loop_cap: usize,
by_both: usize,
max_structure_tokens: usize,
examples: Vec<(String, &'static str)>,
}
impl UpperBoundTally {
fn fraction(&self) -> f64 {
if self.tables == 0 {
0.0
} else {
self.deducted as f64 / self.tables as f64
}
}
}
fn tally_upper_bound(samples: &[&TableSample], limits: &SpanLimits) -> UpperBoundTally {
const MAX_EXAMPLES: usize = 8;
let mut tally = UpperBoundTally {
tables: samples.len(),
..UpperBoundTally::default()
};
for sample in samples {
let usage = span_usage(sample.ground_truth(), Some(limits));
let bound: UpperBound =
classify_upper_bound(usage.exceeds(), sample.record().structure_tokens);
tally.max_structure_tokens = tally
.max_structure_tokens
.max(sample.record().structure_tokens);
if !bound.is_deducted() {
continue;
}
tally.deducted += 1;
match (bound.span_beyond_vocabulary, bound.beyond_loop_trip_cap) {
(true, true) => tally.by_both += 1,
(true, false) => tally.by_span += 1,
(false, true) => tally.by_loop_cap += 1,
(false, false) => unreachable!("is_deducted() is true only for the three cases above"),
}
if tally.examples.len() < MAX_EXAMPLES {
tally.examples.push((
sample.file_name().to_string(),
bound.reason().expect("a deducted sample has a reason"),
));
}
}
tally
}
struct Prediction {
html: String,
cells: usize,
preprocess_ms: f64,
infer_ms: f64,
decode_ms: f64,
total_ms: f64,
backfill: Option<BackfillStats>,
confidence: StructureConfidence,
degradations: Vec<StructureDegradation>,
}
fn confidence_kind(value: StructureConfidence) -> &'static str {
match value {
StructureConfidence::High => "high",
StructureConfidence::Low => "low",
}
}
fn degradation_kind(value: StructureDegradation) -> &'static str {
match value {
StructureDegradation::GridHoles { .. } => "grid_holes",
StructureDegradation::Truncated => "truncated",
}
}
fn message(on_event: &mut dyn FnMut(TableEvalEvent), text: impl Into<String>) {
on_event(TableEvalEvent::Message(text.into()));
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedLine {
bbox: [f32; 4],
text: String,
score: f32,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct OcrCache {
version: u32,
engine_fingerprint: String,
entries: BTreeMap<String, Vec<CachedLine>>,
}
fn engine_fingerprint(config: &EngineConfig) -> Result<String> {
let canonical = serde_json::to_string(config).map_err(|error| {
RapidOcrError::Config(format!(
"the engine config cannot be fingerprinted: {error}"
))
})?;
Ok(digest_hex(canonical.as_bytes()))
}
impl OcrCache {
const VERSION: u32 = 2;
fn load(path: &Path, expected_fingerprint: &str) -> Result<Self> {
let text = std::fs::read_to_string(path).map_err(|error| {
RapidOcrError::Decode(format!(
"the OCR cache {} is unreadable: {error}",
path.display()
))
})?;
let cache: Self = serde_json::from_str(&text).map_err(|error| {
RapidOcrError::Decode(format!(
"the OCR cache {} is not valid JSON: {error}",
path.display()
))
})?;
if cache.version != Self::VERSION {
return Err(RapidOcrError::Config(format!(
"the OCR cache {} has version {} but this build writes version {}; delete it or \
point --ocr-cache somewhere else",
path.display(),
cache.version,
Self::VERSION
)));
}
if cache.engine_fingerprint != expected_fingerprint {
return Err(RapidOcrError::Config(format!(
"the OCR cache {} was produced by a different engine than the one requested \
(cache fingerprint {}, requested fingerprint {}); reusing it would silently \
attribute another engine's text to this run, so it is refused instead. Delete \
the cache, or point --ocr-cache at a file per engine configuration",
path.display(),
cache.engine_fingerprint,
expected_fingerprint
)));
}
Ok(cache)
}
fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let text = serde_json::to_string(self).map_err(|error| {
RapidOcrError::Decode(format!("the OCR cache is not serializable: {error}"))
})?;
std::fs::write(path, text)?;
Ok(())
}
}
struct PageOcr {
engine: RapidOcrEngine,
cache: Option<(PathBuf, OcrCache)>,
cache_hits: usize,
cache_misses: usize,
ocr_ms: Vec<f64>,
regions: usize,
engine_label: String,
engine_fingerprint: String,
}
fn engine_label_for(config: &Option<PathBuf>) -> String {
match config {
Some(path) => format!("page OCR: {}", path.display()),
None => "page OCR: built-in default engine config".to_string(),
}
}
impl PageOcr {
fn open(content: &TableContentSource, resolution: &TableOcrResolution) -> Result<Option<Self>> {
let TableContentSource::PageReuse { config, cache } = content else {
return Ok(None);
};
let engine_label = engine_label_for(config);
let mut engine_config = match config {
Some(path) => EngineConfig::from_yaml_file(path)?,
None => EngineConfig::default(),
};
resolution.apply(&mut engine_config)?;
let fingerprint = engine_fingerprint(&engine_config)?;
let engine = RapidOcrEngine::new(engine_config)?;
let cache = match cache {
Some(path) => {
let loaded = if path.exists() {
OcrCache::load(path, &fingerprint)?
} else {
OcrCache {
version: OcrCache::VERSION,
engine_fingerprint: fingerprint.clone(),
entries: BTreeMap::new(),
}
};
Some((path.clone(), loaded))
}
None => None,
};
Ok(Some(Self {
engine,
cache,
cache_hits: 0,
cache_misses: 0,
ocr_ms: Vec::new(),
regions: 0,
engine_label,
engine_fingerprint: fingerprint,
}))
}
fn describe(&self) -> String {
match &self.cache {
Some((path, cache)) => format!(
"{}; cache {} ({} sample(s) already cached)",
self.engine_label,
path.display(),
cache.entries.len()
),
None => format!(
"{}; no cache (every run re-OCRs every page)",
self.engine_label
),
}
}
fn prepare(
&mut self,
samples: &[&TableSample],
on_event: &mut dyn FnMut(TableEvalEvent),
) -> Result<()> {
let missing = self.missing_count(samples);
if missing == 0 {
message(
on_event,
format!("page OCR: all {} sample(s) are cached", samples.len()),
);
} else {
message(
on_event,
format!(
"page OCR: {missing} of {} sample(s) need recognition; the rest are cached",
samples.len()
),
);
}
Ok(())
}
fn missing_count(&self, samples: &[&TableSample]) -> usize {
let Some((_, cache)) = &self.cache else {
return samples.len();
};
samples
.iter()
.filter(|sample| !cache.entries.contains_key(sample.relative_path()))
.count()
}
fn lines_for(&mut self, sample: &TableSample) -> Result<Vec<TextLine>> {
let key = sample.relative_path().to_string();
if let Some((_, cache)) = &self.cache
&& let Some(lines) = cache.entries.get(&key)
{
self.cache_hits += 1;
return Ok(lines.iter().map(cached_to_line).collect());
}
self.cache_misses += 1;
let started = Instant::now();
let bytes = std::fs::read(sample.image_path())?;
let output = self.engine.recognize(request_for(bytes))?;
let elapsed = started.elapsed().as_secs_f64() * 1000.0;
self.ocr_ms.push(elapsed);
self.regions += output.regions.len();
let lines = lines_from_output(&output);
if let Some((path, cache)) = &mut self.cache {
cache.entries.insert(
key,
lines
.iter()
.map(|line| CachedLine {
bbox: line.bbox,
text: line.text.clone(),
score: line.score,
})
.collect(),
);
if self.cache_misses.is_multiple_of(250) {
cache.save(path)?;
}
}
Ok(lines)
}
fn flush(&mut self) -> Result<()> {
if let Some((path, cache)) = &self.cache {
cache.save(path)?;
}
Ok(())
}
fn report(&self) -> Value {
json!({
"engine": self.engine_label,
"engine_fingerprint": self.engine_fingerprint,
"cache_hits": self.cache_hits,
"cache_misses": self.cache_misses,
"cache_path": self.cache.as_ref().map(|(path, _)| path.display().to_string()),
"ocr_ms": summarize(&self.ocr_ms),
"regions_seen": self.regions,
})
}
}
fn cached_to_line(line: &CachedLine) -> TextLine {
TextLine {
bbox: line.bbox,
text: line.text.clone(),
score: line.score,
}
}
fn request_for(bytes: Vec<u8>) -> OcrRequest {
OcrRequest {
input: ImageInput::Encoded(Arc::from(bytes)),
roi: None,
scale_hint: None,
stages: StagePlan {
detect: true,
classify: ClassifierPlan {
policy: ClassifierPolicy::IfAvailable,
apply_rotation: true,
},
recognize: true,
},
preprocess: PreprocessPolicy::default(),
detection: DetectionPolicy::default(),
recognition: RecognitionPolicy {
words: WordOutputMode::Off,
},
output: OutputPolicy::default(),
formula: Default::default(),
table: Default::default(),
}
}
fn lines_from_output(output: &OcrOutput) -> Vec<TextLine> {
output
.regions
.iter()
.filter_map(|region| {
let polygon = region.polygon.as_ref()?;
let recognition = region.recognition.as_ref()?;
let mut flat = [0.0_f32; 8];
for (index, point) in polygon.points.iter().enumerate() {
flat[index * 2] = point[0];
flat[index * 2 + 1] = point[1];
}
Some(TextLine {
bbox: axis_aligned_bbox(&flat),
text: recognition.text.clone(),
score: recognition.score,
})
})
.collect()
}
enum CellSource<'a> {
Empty,
Reuse(&'a [TextLine]),
Crop(&'a mut CropRecognizer),
}
struct CropRecognizer {
recognizer: Recognizer,
calls: usize,
ms: Vec<f64>,
}
impl CropRecognizer {
fn open(config: &TableContentSource) -> Result<Option<Self>> {
let TableContentSource::CropControl { config } = config else {
return Ok(None);
};
let engine_config = match config {
Some(path) => EngineConfig::from_yaml_file(path)?,
None => EngineConfig::default(),
};
let recognizer = Recognizer::new(engine_config.rec, &engine_config.runtime)?;
Ok(Some(Self {
recognizer,
calls: 0,
ms: Vec::new(),
}))
}
fn report(&self) -> Value {
json!({
"recognizer_calls": self.calls,
"recognize_ms": summarize(&self.ms),
})
}
}
impl CellRecognizer for CropRecognizer {
fn recognize(&mut self, region: &RecImage) -> Result<(String, f32)> {
let started = Instant::now();
let output = self.recognizer.recognize(
std::slice::from_ref(region),
RecognizeOptions {
return_word_box: false,
return_single_char_box: false,
},
)?;
self.ms.push(started.elapsed().as_secs_f64() * 1000.0);
self.calls += 1;
Ok(match output.lines.into_iter().next() {
Some(line) => (line.text, line.score),
None => (String::new(), 0.0),
})
}
}
#[derive(Debug, Clone)]
struct BackfillStats {
cells: usize,
non_empty: usize,
blank_cells: usize,
unmatched_cells: usize,
single_line_cells: usize,
multi_line_cells: usize,
lines_considered: usize,
lines_above_table: usize,
matched_lines_total: usize,
recognizer_calls: usize,
text_confidence: Option<f32>,
backfill_ms: f64,
}
impl BackfillStats {
fn from_outcome(outcome: &BackfillOutcome, backfill_ms: f64) -> Self {
Self {
cells: outcome.cells,
non_empty: outcome.non_empty(),
blank_cells: outcome.blank_cells,
unmatched_cells: outcome.unmatched_cells,
single_line_cells: outcome.single_line_cells,
multi_line_cells: outcome.multi_line_cells,
lines_considered: outcome.lines_considered,
lines_above_table: outcome.lines_above_table,
matched_lines_total: outcome.matched_lines_total,
recognizer_calls: outcome.recognizer_calls,
text_confidence: outcome.text_confidence().mean,
backfill_ms,
}
}
}
fn summarize_backfill(stats: &[BackfillStats], content: &TableContentSource) -> Value {
let sum = |pick: fn(&BackfillStats) -> usize| stats.iter().map(pick).sum::<usize>();
let cells = sum(|value| value.cells);
let non_empty = sum(|value| value.non_empty);
let recognizer_calls = sum(|value| value.recognizer_calls);
let mut confidences: Vec<f32> = stats
.iter()
.filter_map(|value| value.text_confidence)
.collect();
confidences.sort_by(f32::total_cmp);
let note = match content {
TableContentSource::StructureOnly | TableContentSource::PageReuse { .. } => {
"gate G2: the backfill path must never call the recognizer, so a non-zero count here \
is a failure"
}
TableContentSource::CropControl { .. } => {
"comparison baseline (doc 09 §10.3): one recognizer call per cell is the point of this \
path, so the zero-call invariant does not apply here"
}
};
json!({
"cells": cells,
"non_empty_cells": non_empty,
"non_empty_rate": if cells == 0 { 0.0 } else { non_empty as f64 / cells as f64 },
"blank_cells": sum(|value| value.blank_cells),
"unmatched_cells": sum(|value| value.unmatched_cells),
"single_line_cells": sum(|value| value.single_line_cells),
"multi_line_cells": sum(|value| value.multi_line_cells),
"lines_considered": sum(|value| value.lines_considered),
"lines_above_table": sum(|value| value.lines_above_table),
"matched_lines_total": sum(|value| value.matched_lines_total),
"recognizer_calls": recognizer_calls,
"recognizer_calls_note": note,
"text_confidence": if confidences.is_empty() { Value::Null } else {
Value::from(confidences.iter().sum::<f32>() / confidences.len() as f32)
},
"backfill_ms": summarize(&stats.iter().map(|value| value.backfill_ms).collect::<Vec<_>>()),
})
}
fn predict_one(
session: &mut TableStructureSession,
vocabulary: &StructureVocabulary,
config: &TableStructureConfig,
sample: &TableSample,
source: CellSource<'_>,
) -> Result<Prediction> {
let started = Instant::now();
let image = RecImage::from_path(sample.image_path())?;
let (height, width) = (image.height() as f32, image.width() as f32);
let preprocess_started = Instant::now();
let (tensor, _canvas) = table_structure_input(&image, config.input_side_len)?;
let preprocess_ms = preprocess_started.elapsed().as_secs_f64() * 1000.0;
let infer_started = Instant::now();
let (boxes, structure) = session.run_with(tensor.view(), |outputs| {
Ok((outputs.boxes.to_owned(), outputs.tokens.to_owned()))
})?;
let infer_ms = infer_started.elapsed().as_secs_f64() * 1000.0;
let decode_started = Instant::now();
let steps_count = structure.shape()[1];
let classes = structure.shape()[2];
if structure.shape()[0] != 1 || boxes.shape()[0] != 1 {
return Err(RapidOcrError::Decode(format!(
"{}: the model returned a batch of {} boxes / {} structures, but this harness runs one \
image at a time",
sample.file_name(),
boxes.shape()[0],
structure.shape()[0]
)));
}
if classes != vocabulary.class_count() {
return Err(RapidOcrError::Decode(format!(
"{}: the model's structure logits have {classes} classes but the vocabulary has {}",
sample.file_name(),
vocabulary.class_count()
)));
}
let argmaxed = argmax_structure(structure.as_slice().expect("contiguous"), steps_count)?;
let (steps, termination) = decode_token_steps(vocabulary, &argmaxed)?;
let shape = crate::table::decode::TableShape::new(height, width, config.input_side_len as f32)?;
let ratios = crate::table::decode::rescale_ratios(width, height, config.input_side_len as f32);
let box_flat = boxes.as_slice().expect("contiguous");
let cell_polygons: Vec<[f32; 8]> = steps
.iter()
.filter(|step| step.is_cell_start)
.map(|step| {
let offset = step.step_index * 8;
let mut raw = [0.0_f32; 8];
raw.copy_from_slice(&box_flat[offset..offset + 8]);
crate::table::decode::rescale_polygon(&shape.denormalize_polygon(&raw), ratios)
})
.collect();
let result =
crate::table::decode::tokens_to_structure(vocabulary, &steps, termination, &cell_polygons)?;
let (texts, backfill_stats) = match source {
CellSource::Empty => (vec![String::new(); result.cells.len()], None),
CellSource::Reuse(lines) => {
let backfill_started = Instant::now();
let outcome = backfill(
&result.cells,
BackfillSource::Reuse {
lines,
offset: [0.0, 0.0],
},
)?;
let backfill_ms = backfill_started.elapsed().as_secs_f64() * 1000.0;
let stats = BackfillStats::from_outcome(&outcome, backfill_ms);
(outcome.texts, Some(stats))
}
CellSource::Crop(recognizer) => {
let backfill_started = Instant::now();
let outcome = backfill(
&result.cells,
BackfillSource::Crop {
image: &image,
recognizer,
},
)?;
let backfill_ms = backfill_started.elapsed().as_secs_f64() * 1000.0;
let stats = BackfillStats::from_outcome(&outcome, backfill_ms);
(outcome.texts, Some(stats))
}
};
let RenderedTable {
rendered: html,
confidence,
degradations,
} = emit_html(&result, &texts, TextPolicy::Raw)?;
let decode_ms = decode_started.elapsed().as_secs_f64() * 1000.0;
Ok(Prediction {
html,
cells: result.cells.len(),
preprocess_ms,
infer_ms,
decode_ms,
total_ms: started.elapsed().as_secs_f64() * 1000.0,
backfill: backfill_stats,
confidence,
degradations,
})
}
fn write_predictions(path: &Path, predictions: &BTreeMap<String, String>) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let mut object = Map::with_capacity(predictions.len());
for (name, html) in predictions {
object.insert(name.clone(), Value::String(html.clone()));
}
let encoded = serde_json::to_vec(&Value::Object(object)).map_err(|error| {
RapidOcrError::Decode(format!("predictions are not serializable: {error}"))
})?;
std::fs::write(path, encoded)?;
Ok(())
}
pub fn digest_file(path: &Path) -> Result<(u64, String)> {
let bytes = std::fs::read(path)?;
Ok((bytes.len() as u64, digest_hex(&bytes)))
}
fn summarize(values: &[f64]) -> Value {
if values.is_empty() {
return json!({ "count": 0 });
}
let mut sorted = values.to_vec();
sorted.sort_by(f64::total_cmp);
let sum: f64 = sorted.iter().sum();
json!({
"count": sorted.len(),
"min": sorted[0],
"p50": crate::evaluation::stats::percentile(&sorted, 0.50),
"p90": crate::evaluation::stats::percentile(&sorted, 0.90),
"max": sorted[sorted.len() - 1],
"mean": sum / sorted.len() as f64,
})
}
#[cfg(test)]
mod tests {
use super::{
OcrCache, TableContentSource, TableEvalConfig, TableEvalManifest, engine_fingerprint,
engine_label_for, ocr_resolution_report, summarize, summarize_backfill,
};
use crate::ocr::pipeline::config::EngineConfig;
use crate::table::ocr_resolution::TableOcrResolution;
use serde_json::json;
use std::path::{Path, PathBuf};
#[test]
fn the_default_configuration_matches_the_documented_paths() {
let config = TableEvalConfig::default();
assert_eq!(
config.dataset.to_string_lossy().replace('\\', "/"),
"datasets/table/tabrecset"
);
assert_eq!(
config.model.to_string_lossy().replace('\\', "/"),
"models/table/slanet-plus.onnx"
);
assert_eq!(config.limit, 0);
assert_eq!(config.side_len, 488);
assert!(config.fix_input_shape);
}
#[test]
fn an_empty_distribution_reports_a_count_instead_of_panicking() {
assert_eq!(summarize(&[]), json!({ "count": 0 }));
}
#[test]
fn the_table_report_summary_carries_a_frozen_key_set() {
let summary = summarize(&[1.0, 2.0, 3.0, 4.0]);
let mut keys: Vec<&str> = summary
.as_object()
.expect("non-empty summary must be an object")
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
vec!["count", "max", "mean", "min", "p50", "p90"],
"the table report must keep exactly these summary keys"
);
}
#[test]
fn the_summary_reports_the_same_quartiles_as_the_report() {
let summary = summarize(&[1.0, 2.0, 3.0, 4.0]);
assert_eq!(summary["count"], 4);
assert_eq!(summary["min"], 1.0);
assert_eq!(summary["max"], 4.0);
assert_eq!(summary["mean"], 2.5);
}
#[test]
fn the_table_summary_quantiles_agree_with_the_shared_percentile() {
use crate::evaluation::stats::{Stats, percentile};
let values = [3.0, 1.0, 4.0, 2.0];
let mut sorted = values.to_vec();
sorted.sort_by(f64::total_cmp);
let summary = summarize(&values);
assert_eq!(summary["p50"], percentile(&sorted, 0.50));
assert_eq!(summary["p90"], percentile(&sorted, 0.90));
assert_eq!(summary["p50"], 2.5);
assert!((summary["p90"].as_f64().unwrap() - 3.7).abs() < 1e-12);
assert_eq!(summary["p50"], Stats::from_samples(values.to_vec()).p50_ms);
}
#[test]
fn a_bare_array_is_not_a_table_manifest() {
let value = json!([{ "image": "a.png", "text": "x" }]);
assert!(!TableEvalManifest::matches(&value));
}
#[test]
fn an_object_without_the_domain_field_is_not_a_table_manifest() {
assert!(!TableEvalManifest::matches(&json!({ "cases": [] })));
assert!(!TableEvalManifest::matches(&json!({ "domain": "ocr" })));
}
#[test]
fn the_domain_field_selects_the_table_path() {
assert!(TableEvalManifest::matches(&json!({ "domain": "table" })));
}
#[test]
fn a_table_manifest_only_overrides_the_fields_it_names() {
let manifest: TableEvalManifest =
serde_json::from_value(json!({ "domain": "table", "limit": 8 }))
.expect("a minimal table manifest parses");
let config = manifest.into_config().expect("the domain is table");
assert_eq!(config.limit, 8);
let defaults = TableEvalConfig::default();
assert_eq!(config.dataset, defaults.dataset);
assert_eq!(config.model, defaults.model);
assert_eq!(config.side_len, defaults.side_len);
assert_eq!(config.fix_input_shape, defaults.fix_input_shape);
}
#[test]
fn a_wrong_domain_is_refused_rather_than_silently_run() {
let manifest: TableEvalManifest = serde_json::from_value(json!({ "domain": "ocr" }))
.expect("the shape parses even though the domain is wrong");
let error = manifest
.into_config()
.expect_err("the table runner must not accept a non-table manifest");
assert!(
error.to_string().contains("domain"),
"the error must name the offending field, got: {error}"
);
}
#[test]
fn structure_only_is_the_default_content_source() {
let config = TableEvalConfig::default();
assert_eq!(config.content, TableContentSource::StructureOnly);
assert!(!config.content.uses_engine());
assert!(config.content.is_structure_only());
assert_eq!(config.content.reported_metric(), "TEDS-Struct");
}
#[test]
fn the_manifest_can_select_the_page_reuse_content_source() {
let manifest: TableEvalManifest = serde_json::from_value(json!({
"domain": "table",
"content": "page-reuse",
"ocr_cache": "Temp/ocr-cache.json",
}))
.expect("the manifest parses");
let config = manifest.into_config().expect("the domain is table");
assert!(config.content.uses_engine());
assert!(!config.content.is_structure_only());
assert_eq!(config.content.reported_metric(), "TEDS");
match &config.content {
TableContentSource::PageReuse { config, cache } => {
assert!(
config.is_none(),
"an omitted --ocr-config means the default engine"
);
assert_eq!(
cache
.as_deref()
.map(|path| path.to_string_lossy().replace('\\', "/")),
Some("Temp/ocr-cache.json".to_string())
);
}
other => panic!("expected the page-reuse source, got {other:?}"),
}
}
#[test]
fn an_unknown_content_value_is_refused_rather_than_guessed() {
let manifest: TableEvalManifest =
serde_json::from_value(json!({ "domain": "table", "content": "page_reuse" }))
.expect("the shape parses");
let error = manifest
.into_config()
.expect_err("an unknown content source must not be silently accepted");
assert!(
error.to_string().contains("page-reuse"),
"the error must list the accepted values, got: {error}"
);
}
#[test]
fn the_manifest_can_select_the_crop_control_baseline() {
let manifest: TableEvalManifest = serde_json::from_value(json!({
"domain": "table",
"content": "crop-control",
"ocr_config": "models/configs/test-config-small.yaml",
}))
.expect("the manifest parses");
let config = manifest.into_config().expect("the domain is table");
assert_eq!(config.content.as_str(), "crop_control");
assert_eq!(config.content.reported_metric(), "TEDS");
assert!(config.content.uses_engine());
assert!(!config.content.is_structure_only());
match &config.content {
TableContentSource::CropControl { config } => {
assert_eq!(
config.as_deref(),
Some(Path::new("models/configs/test-config-small.yaml"))
);
}
other => panic!("expected CropControl, got {other:?}"),
}
}
#[test]
fn the_page_ocr_label_names_the_config_that_produced_the_text() {
assert_eq!(
engine_label_for(&Some(PathBuf::from(
"models/configs/test-config-small.yaml"
))),
"page OCR: models/configs/test-config-small.yaml"
);
assert_eq!(
engine_label_for(&None),
"page OCR: built-in default engine config"
);
}
#[test]
fn the_engine_fingerprint_follows_the_resolution_not_the_file_path() {
let mut base = EngineConfig::default();
TableOcrResolution::default()
.apply(&mut base)
.expect("the default resolution is valid");
let at_default = engine_fingerprint(&base).expect("the config is serializable");
let mut coarser = EngineConfig::default();
TableOcrResolution {
limit_side_len: 960,
..TableOcrResolution::default()
}
.apply(&mut coarser)
.expect("960 is a valid limit_side_len");
let at_960 = engine_fingerprint(&coarser).expect("the config is serializable");
assert_ne!(at_default, at_960);
let mut again = EngineConfig::default();
TableOcrResolution::default()
.apply(&mut again)
.expect("the default resolution is valid");
assert_eq!(
engine_fingerprint(&again).expect("the config is serializable"),
at_default
);
}
#[test]
fn a_cache_from_another_engine_is_refused_rather_than_reused() {
let path = std::env::temp_dir().join(format!(
"rapid-ocr-table-cache-fingerprint-{}.json",
std::process::id()
));
let cache = OcrCache {
version: OcrCache::VERSION,
engine_fingerprint: "aaaa".to_string(),
entries: Default::default(),
};
cache.save(&path).expect("the temp directory is writable");
let error = OcrCache::load(&path, "bbbb")
.expect_err("a fingerprint mismatch must not be silently accepted");
let text = error.to_string();
assert!(
text.contains("aaaa"),
"the error must name the cached fingerprint: {text}"
);
assert!(
text.contains("bbbb"),
"the error must name the requested one: {text}"
);
assert!(
text.contains("--ocr-cache"),
"the error must say how to fix it: {text}"
);
let loaded = OcrCache::load(&path, "aaaa").expect("a matching fingerprint loads");
assert!(loaded.entries.is_empty());
let _ = std::fs::remove_file(&path);
}
#[test]
fn an_older_cache_version_is_refused() {
let path = std::env::temp_dir().join(format!(
"rapid-ocr-table-cache-version-{}.json",
std::process::id()
));
std::fs::write(&path, r#"{"version":1,"entries":{}}"#)
.expect("the temp directory is writable");
let error = OcrCache::load(&path, "aaaa").expect_err("version 1 must not be accepted");
assert!(error.to_string().contains("version"), "{error}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn the_manifest_can_override_the_table_ocr_resolution() {
let manifest: TableEvalManifest = serde_json::from_value(json!({
"domain": "table",
"content": "page-reuse",
"ocr_resolution": {
"limit_side_len": 960,
"limit_type": "max",
"max_side_len": 1280
},
}))
.expect("the manifest parses");
let config = manifest.into_config().expect("the domain is table");
assert_eq!(config.ocr_resolution.limit_side_len, 960);
assert_eq!(config.ocr_resolution.limit_type, "max");
assert_eq!(config.ocr_resolution.max_side_len, 1280);
}
#[test]
fn the_default_resolution_never_upscales_the_table_image() {
let config = TableEvalConfig::default();
assert_eq!(config.ocr_resolution, TableOcrResolution::default());
assert_eq!(config.ocr_resolution.limit_side_len, 736);
assert_eq!(config.ocr_resolution.limit_type, "max");
assert_eq!(config.ocr_resolution.max_side_len, 736);
}
#[test]
fn the_report_names_the_live_knob_of_the_resolution_policy() {
let report = ocr_resolution_report(&TableEvalConfig::default());
assert_eq!(report["limit_type"], "max");
assert_eq!(report["limit_side_len"], 736);
assert_eq!(report["max_side_len"], 736);
assert!(
report["live_knob"]
.as_str()
.expect("live_knob is a string")
.starts_with("max_side_len"),
"{report}"
);
let config = TableEvalConfig {
ocr_resolution: TableOcrResolution {
limit_side_len: 320,
limit_type: "min".to_string(),
max_side_len: 2000,
},
..TableEvalConfig::default()
};
let report = ocr_resolution_report(&config);
assert!(
report["live_knob"]
.as_str()
.expect("live_knob is a string")
.starts_with("limit_side_len"),
"{report}"
);
}
#[test]
fn the_report_says_the_resolution_only_applies_to_the_page_reuse_path() {
let report = ocr_resolution_report(&TableEvalConfig::default());
assert_eq!(
report["applies_to"],
"nothing (only the page-reuse path runs det)"
);
let config = TableEvalConfig {
content: TableContentSource::PageReuse {
config: None,
cache: None,
},
..TableEvalConfig::default()
};
assert_eq!(ocr_resolution_report(&config)["applies_to"], "page_reuse");
}
#[test]
fn a_backfill_summary_over_no_samples_reports_counts_not_ratios() {
let summary = summarize_backfill(&[], &TableContentSource::StructureOnly);
assert_eq!(summary["cells"], 0);
assert_eq!(summary["non_empty_rate"], 0.0);
assert_eq!(summary["recognizer_calls"], 0);
assert!(summary["text_confidence"].is_null());
assert!(
summary["recognizer_calls_note"]
.as_str()
.expect("the note is a string")
.contains("gate G2")
);
let control = summarize_backfill(&[], &TableContentSource::CropControl { config: None });
assert!(
!control["recognizer_calls_note"]
.as_str()
.expect("the note is a string")
.contains("gate G2"),
"the comparison baseline must not be reported as a gate G2 failure"
);
}
}