use std::path::Path;
use ndarray::{ArrayView3, ArrayView4};
use ort::value::TensorElementType;
use crate::{
config::RuntimeConfig,
error::{RapidOcrError, Result},
runtime::{
contracts::{
DYNAMIC_DIM, ModelIoProbe, require_dynamic_dim_symbol, require_outputs,
require_single_input, require_tensor,
},
session::{DimensionOverride, OrtSession},
},
table::metadata::{parse_character_metadata, validate_vocabulary_width},
};
pub const TABLE_STRUCTURE_INPUT_RANK: usize = 4;
pub const TABLE_STRUCTURE_INPUT_CHANNELS: i64 = 3;
pub const TABLE_STRUCTURE_HEIGHT_DIM: usize = 2;
pub const TABLE_STRUCTURE_WIDTH_DIM: usize = 3;
pub const TABLE_STRUCTURE_SIDE_LEN: usize = 488;
pub const TABLE_STRUCTURE_BOX_OUTPUT: &str = "save_infer_model/scale_0.tmp_0";
pub const TABLE_STRUCTURE_BOX_RANK: usize = 3;
pub const TABLE_STRUCTURE_BOX_LAST_DIM: i64 = 8;
pub const TABLE_STRUCTURE_TOKEN_OUTPUT: &str = "save_infer_model/scale_1.tmp_0";
pub const TABLE_STRUCTURE_TOKEN_RANK: usize = 3;
pub const TABLE_STRUCTURE_TOKEN_LAST_DIM: i64 = 50;
pub const TABLE_STRUCTURE_OUTPUTS: [&str; 2] =
[TABLE_STRUCTURE_BOX_OUTPUT, TABLE_STRUCTURE_TOKEN_OUTPUT];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableStructureContract {
pub input_name: String,
pub box_output_name: String,
pub structure_output_name: String,
}
pub fn validate_table_structure_contract(
probe: &ModelIoProbe,
model_path: &Path,
) -> Result<TableStructureContract> {
let input = require_single_input(probe, model_path, "table structure")?;
require_tensor(
input,
"input",
TABLE_STRUCTURE_INPUT_RANK,
TensorElementType::Float32,
model_path,
)?;
crate::runtime::contracts::require_fixed_dim(
input,
"input",
1,
TABLE_STRUCTURE_INPUT_CHANNELS,
model_path,
)?;
let outputs = require_outputs(
probe,
model_path,
"table structure",
&TABLE_STRUCTURE_OUTPUTS,
)?;
let (boxes, structure) = (outputs[0], outputs[1]);
require_tensor(
boxes,
"output",
TABLE_STRUCTURE_BOX_RANK,
TensorElementType::Float32,
model_path,
)?;
require_tensor(
structure,
"output",
TABLE_STRUCTURE_TOKEN_RANK,
TensorElementType::Float32,
model_path,
)?;
crate::runtime::contracts::require_fixed_dim(
boxes,
"output",
2,
TABLE_STRUCTURE_BOX_LAST_DIM,
model_path,
)?;
crate::runtime::contracts::require_fixed_dim(
structure,
"output",
2,
TABLE_STRUCTURE_TOKEN_LAST_DIM,
model_path,
)?;
Ok(TableStructureContract {
input_name: input.name.clone(),
box_output_name: boxes.name.clone(),
structure_output_name: structure.name.clone(),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableStructureConfig {
pub input_side_len: usize,
pub fix_input_shape: bool,
pub intra_threads: Option<usize>,
}
impl Default for TableStructureConfig {
fn default() -> Self {
Self {
input_side_len: TABLE_STRUCTURE_SIDE_LEN,
fix_input_shape: true,
intra_threads: None,
}
}
}
impl TableStructureConfig {
pub fn effective_runtime_config(&self, runtime_cfg: &RuntimeConfig) -> RuntimeConfig {
match self.intra_threads {
Some(threads) => runtime_cfg.with_pinned_intra_threads(threads),
None => runtime_cfg.clone(),
}
}
pub fn dimension_overrides(
&self,
probe: &ModelIoProbe,
model_path: &Path,
) -> Result<Vec<DimensionOverride>> {
if !self.fix_input_shape {
return Ok(Vec::new());
}
let input = require_single_input(probe, model_path, "table structure")?;
let side = i64::try_from(self.input_side_len).map_err(|error| {
RapidOcrError::Config(format!("input_side_len does not fit in i64: {error}"))
})?;
let mut overrides = Vec::with_capacity(2);
for dim_index in [TABLE_STRUCTURE_HEIGHT_DIM, TABLE_STRUCTURE_WIDTH_DIM] {
let actual = *input.dims.get(dim_index).ok_or_else(|| {
RapidOcrError::Config(format!(
"model input `{}` is missing dim index {dim_index} (dims={:?}, model={})",
input.name,
input.dims,
model_path.display()
))
})?;
if actual == side {
continue;
}
if actual != DYNAMIC_DIM {
return Err(RapidOcrError::Config(format!(
"table structure input `{}` dim {dim_index} is fixed to {actual}, but `input_side_len` is {side}; \
a fixed dimension cannot be overridden (dims={:?}, model={})",
input.name,
input.dims,
model_path.display()
)));
}
let symbol = require_dynamic_dim_symbol(input, "input", dim_index, model_path)?;
overrides.push(DimensionOverride::new(symbol, side));
}
Ok(overrides)
}
pub fn require_applied(&self, probe: &ModelIoProbe, model_path: &Path) -> Result<()> {
if !self.fix_input_shape {
return Ok(());
}
let input = require_single_input(probe, model_path, "table structure")?;
let side = i64::try_from(self.input_side_len).map_err(|error| {
RapidOcrError::Config(format!("input_side_len does not fit in i64: {error}"))
})?;
for dim_index in [TABLE_STRUCTURE_HEIGHT_DIM, TABLE_STRUCTURE_WIDTH_DIM] {
let actual = input.dims.get(dim_index).copied().unwrap_or(DYNAMIC_DIM);
if actual != side {
return Err(RapidOcrError::Config(format!(
"table structure input `{}` dim {dim_index} is {actual} after opening the session with \
`fix_input_shape = true`; the free-dimension override did not take effect \
(expected {side}, dims={:?}, model={})",
input.name,
input.dims,
model_path.display()
)));
}
}
Ok(())
}
}
#[derive(Debug)]
pub struct TableStructureOutputs<'a> {
pub boxes: ArrayView3<'a, f32>,
pub tokens: ArrayView3<'a, f32>,
}
#[derive(Debug)]
pub struct TableStructureSession {
session: OrtSession,
contract: TableStructureContract,
config: TableStructureConfig,
input_dims: Vec<i64>,
session_threads: (Option<usize>, Option<usize>),
vocabulary: crate::table::decode::StructureVocabulary,
}
impl TableStructureSession {
pub fn open(
model: &crate::model_verify::VerifiedModel,
runtime_cfg: &RuntimeConfig,
config: &TableStructureConfig,
) -> Result<Self> {
let model_path = model.path();
let runtime_cfg = config.effective_runtime_config(runtime_cfg);
let probe_session = OrtSession::open_verified(model, &runtime_cfg, &[])?;
let probe = probe_session.probe_io()?;
let contract = validate_table_structure_contract(&probe, model_path)?;
let character_metadata = probe_session.metadata_custom("character")?.ok_or_else(|| {
RapidOcrError::Config(format!(
"table structure model is missing `character` metadata; refusing to fall back to \
the ordinary OCR dictionary (model={})",
model_path.display()
))
})?;
let character_tokens = parse_character_metadata(&character_metadata)?;
validate_vocabulary_width(
&character_tokens,
TABLE_STRUCTURE_TOKEN_LAST_DIM as usize,
model_path,
)?;
let vocabulary = crate::table::decode::StructureVocabulary::new(character_tokens)?;
let overrides = config.dimension_overrides(&probe, model_path)?;
drop(probe_session);
let session = OrtSession::open_verified(model, &runtime_cfg, &overrides)?;
let actual = session.probe_io()?;
config.require_applied(&actual, model_path)?;
let input_dims = actual.inputs[0].dims.clone();
let session_threads = session.session_threads();
Ok(Self {
session,
contract,
config: config.clone(),
input_dims,
session_threads,
vocabulary,
})
}
pub fn vocabulary(&self) -> &crate::table::decode::StructureVocabulary {
&self.vocabulary
}
pub fn session_threads(&self) -> (Option<usize>, Option<usize>) {
self.session_threads
}
pub fn provider_resolution(&self) -> crate::runtime::provider::ProviderResolution {
self.session.provider_resolution()
}
pub fn onnx_opset(&self) -> Option<u32> {
self.session.onnx_opset()
}
pub fn contract(&self) -> &TableStructureContract {
&self.contract
}
pub fn config(&self) -> &TableStructureConfig {
&self.config
}
pub fn input_dims(&self) -> &[i64] {
&self.input_dims
}
pub fn run_with<T, F>(&mut self, input: ArrayView4<'_, f32>, f: F) -> Result<T>
where
F: for<'a> FnOnce(TableStructureOutputs<'a>) -> Result<T>,
{
let box_name = self.contract.box_output_name.clone();
let structure_name = self.contract.structure_output_name.clone();
self.session.run_all_f32_view_with(input, |outputs| {
let pick = |name: &str| -> Result<ArrayView3<'_, f32>> {
let view = outputs
.iter()
.find(|(output_name, _)| *output_name == name)
.map(|(_, view)| view.clone())
.ok_or_else(|| {
RapidOcrError::Decode(format!(
"table structure run result is missing output `{name}`"
))
})?;
view.into_dimensionality::<ndarray::Ix3>().map_err(|error| {
RapidOcrError::Decode(format!(
"unexpected rank for table structure output `{name}`: expected rank 3: {error}"
))
})
};
let boxes = pick(&box_name)?;
let tokens = pick(&structure_name)?;
f(TableStructureOutputs { boxes, tokens })
})
}
}
#[derive(Debug, Clone)]
pub struct TableStructureModelInfo {
pub model_path: String,
pub input_name: String,
pub input_dims: Vec<i64>,
pub box_output_name: String,
pub structure_output_name: String,
pub structure_last_dim: usize,
pub character_tokens: Vec<String>,
}
impl TableStructureModelInfo {
pub fn probe(model_path: &Path, runtime_cfg: &RuntimeConfig) -> Result<Self> {
if !model_path.is_file() {
return Err(RapidOcrError::FileNotFound(model_path.to_path_buf()));
}
let model = crate::table::registration::verified_registered_model(model_path, None)?;
let session = OrtSession::open_verified(&model, runtime_cfg, &[])?;
let io = session.probe_io()?;
let contract = validate_table_structure_contract(&io, model_path)?;
let input = &io.inputs[0];
let character_metadata = session.metadata_custom("character")?.ok_or_else(|| {
RapidOcrError::Config(format!(
"table structure model is missing `character` metadata; refusing to fall back to \
the ordinary OCR dictionary (model={})",
model_path.display()
))
})?;
let character_tokens = parse_character_metadata(&character_metadata)?;
validate_vocabulary_width(
&character_tokens,
TABLE_STRUCTURE_TOKEN_LAST_DIM as usize,
model_path,
)?;
Ok(Self {
model_path: model_path.display().to_string(),
input_name: contract.input_name,
input_dims: input.dims.clone(),
box_output_name: contract.box_output_name,
structure_output_name: contract.structure_output_name,
structure_last_dim: TABLE_STRUCTURE_TOKEN_LAST_DIM as usize,
character_tokens,
})
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::runtime::contracts::TensorSpec;
fn fixture(name: &str) -> PathBuf {
crate::test_support::fixture_dir("table-onnx").join(name)
}
fn rt() -> RuntimeConfig {
RuntimeConfig::default()
}
fn cred(path: &std::path::Path) -> crate::model_verify::VerifiedModel {
crate::model_verify::VerifiedModel::verify(path, None).expect("test credential")
}
fn probe_fixture(name: &str) -> Result<TableStructureContract> {
let path = fixture(name);
let session = OrtSession::open_unchecked(&path, &rt(), &[]).expect("fixture must load");
let io = session.probe_io().expect("fixture must probe");
validate_table_structure_contract(&io, &path)
}
#[test]
fn table_structure_contract_accepts_the_ok_fixture() {
let contract =
probe_fixture("table_ok.onnx").expect("ok fixture must satisfy the contract");
assert_eq!(contract.input_name, "x");
assert_eq!(contract.box_output_name, TABLE_STRUCTURE_BOX_OUTPUT);
assert_eq!(contract.structure_output_name, TABLE_STRUCTURE_TOKEN_OUTPUT);
}
#[test]
fn table_structure_requires_two_outputs() {
let error = probe_fixture("table_single_output.onnx").expect_err("one output is not two");
let message = error.to_string();
assert!(message.contains("table structure"), "{message}");
assert!(message.contains("1 outputs"), "{message}");
assert!(
message.contains(TABLE_STRUCTURE_TOKEN_OUTPUT),
"the missing output must be named: {message}"
);
assert!(message.contains("table_single_output.onnx"), "{message}");
}
#[test]
fn table_structure_requires_the_exported_output_names() {
let error = probe_fixture("table_renamed_output.onnx").expect_err("renamed outputs");
let message = error.to_string();
assert!(message.contains("output mismatch"), "{message}");
assert!(message.contains(TABLE_STRUCTURE_BOX_OUTPUT), "{message}");
assert!(message.contains("boxes"), "{message}");
assert!(message.contains("structures"), "{message}");
assert!(message.contains("table_renamed_output.onnx"), "{message}");
}
#[test]
fn table_structure_rejects_undeclared_extra_outputs() {
let error = probe_fixture("table_extra_output.onnx").expect_err("extra output");
let message = error.to_string();
assert!(message.contains("unexpected"), "{message}");
assert!(message.contains("extra"), "{message}");
assert!(message.contains("3 outputs"), "{message}");
assert!(message.contains("table_extra_output.onnx"), "{message}");
}
#[test]
fn table_structure_requires_float32_outputs() {
let error = probe_fixture("table_output_dtype.onnx").expect_err("int64 structure output");
let message = error.to_string();
assert!(message.contains("FLOAT32"), "{message}");
assert!(message.contains("INT64"), "{message}");
}
#[test]
fn table_structure_requires_the_contract_last_dims() {
let error = probe_fixture("table_output_last_dim.onnx").expect_err("last dim 51");
let message = error.to_string();
assert!(message.contains("expected 50, got 51"), "{message}");
}
#[test]
fn table_structure_requires_a_single_float_rank4_input() {
let multi = probe_fixture("table_multi_input.onnx").expect_err("two inputs");
assert!(multi.to_string().contains("exactly one input"), "{multi}");
let int64 = probe_fixture("table_input_int64.onnx").expect_err("int64 input");
assert!(int64.to_string().contains("FLOAT32"), "{int64}");
let rank3 = probe_fixture("table_input_rank3.onnx").expect_err("rank-3 input");
assert!(rank3.to_string().contains("rank mismatch"), "{rank3}");
}
#[test]
fn table_structure_returns_both_outputs_in_one_run() {
let path = fixture("table_ok.onnx");
let mut session = OrtSession::open_unchecked(&path, &rt(), &[]).expect("fixture must load");
let input = ndarray::Array4::<f32>::zeros((2, 3, 488, 488));
let observed = session
.run_all_f32_view_with(input.view(), |outputs| {
Ok(outputs
.iter()
.map(|(name, view)| ((*name).to_string(), view.shape().to_vec()))
.collect::<Vec<_>>())
})
.expect("two-output run must succeed");
assert_eq!(observed.len(), 2, "both outputs must be observable");
assert_eq!(
observed[0],
(
TABLE_STRUCTURE_BOX_OUTPUT.to_string(),
vec![2, 4, TABLE_STRUCTURE_BOX_LAST_DIM as usize]
)
);
assert_eq!(
observed[1],
(
TABLE_STRUCTURE_TOKEN_OUTPUT.to_string(),
vec![2, 4, TABLE_STRUCTURE_TOKEN_LAST_DIM as usize]
)
);
}
#[test]
fn character_metadata_must_be_present() {
let error = TableStructureModelInfo::probe(&fixture("table_metadata_absent.onnx"), &rt())
.expect_err("metadata is required");
assert!(error.to_string().contains("character"), "{error}");
}
#[test]
fn character_metadata_must_not_be_json() {
let error = TableStructureModelInfo::probe(&fixture("table_metadata_json.onnx"), &rt())
.expect_err("formula-style JSON metadata must be rejected");
let message = error.to_string();
assert!(message.contains("newline-separated"), "{message}");
assert!(message.contains("JSON"), "{message}");
}
#[test]
fn ok_fixture_probes_the_full_signature_and_vocabulary() {
let info = TableStructureModelInfo::probe(&fixture("table_ok.onnx"), &rt())
.expect("ok fixture must probe");
assert!(
info.model_path.ends_with("table_ok.onnx"),
"the probe must record which model it came from, got {}",
info.model_path
);
assert_eq!(info.input_name, "x");
assert_eq!(info.input_dims.len(), TABLE_STRUCTURE_INPUT_RANK);
assert_eq!(info.structure_last_dim, 50);
assert_eq!(info.character_tokens.len(), 48);
assert_eq!(info.character_tokens[6], "<td");
assert_eq!(info.character_tokens[47], "<td></td>");
}
#[test]
fn missing_model_returns_located_error() {
let error = TableStructureModelInfo::probe(&fixture("does_not_exist.onnx"), &rt())
.expect_err("must fail");
assert!(matches!(error, RapidOcrError::FileNotFound(_)), "{error}");
}
#[test]
fn dimension_overrides_use_the_symbols_from_the_probe() {
let path = fixture("table_ok.onnx");
let session = OrtSession::open_unchecked(&path, &rt(), &[]).expect("fixture must load");
let probe = session.probe_io().expect("fixture must probe");
let overrides = TableStructureConfig::default()
.dimension_overrides(&probe, &path)
.expect("the fixture input is dynamic and symbolically named");
assert_eq!(
overrides,
vec![
DimensionOverride::new("H", TABLE_STRUCTURE_SIDE_LEN as i64),
DimensionOverride::new("W", TABLE_STRUCTURE_SIDE_LEN as i64),
],
"H/W are input dims {TABLE_STRUCTURE_HEIGHT_DIM}/{TABLE_STRUCTURE_WIDTH_DIM}"
);
}
#[test]
fn fixing_the_shape_is_observable_on_the_opened_session() {
let path = fixture("table_ok.onnx");
let session =
TableStructureSession::open(&cred(&path), &rt(), &TableStructureConfig::default())
.expect("fixed session must open");
assert_eq!(
session.input_dims(),
[
-1,
3,
TABLE_STRUCTURE_SIDE_LEN as i64,
TABLE_STRUCTURE_SIDE_LEN as i64
],
"H/W must read back as fixed, and N must stay dynamic"
);
assert_eq!(session.contract().input_name, "x");
assert!(session.config().fix_input_shape);
}
#[test]
fn disabling_the_switch_leaves_the_input_dynamic() {
let path = fixture("table_ok.onnx");
let config = TableStructureConfig {
fix_input_shape: false,
..TableStructureConfig::default()
};
let session = TableStructureSession::open(&cred(&path), &rt(), &config)
.expect("unfixed session must open");
assert_eq!(session.input_dims(), [-1, 3, -1, -1]);
assert_eq!(session.config(), &config);
}
struct ScratchModelDir {
dir: PathBuf,
}
impl ScratchModelDir {
fn new(label: &str) -> Self {
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"rapid-ocr-table-load-{label}-{}-{id}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("scratch dir should be creatable");
Self { dir }
}
fn with_ok_model(&self) -> PathBuf {
let target = self.dir.join("table_ok.onnx");
std::fs::copy(fixture("table_ok.onnx"), &target).expect("fixture should copy");
target
}
fn register(&self, sha256: &str) {
let manifest = format!(
r#"{{
"schema_version": 1,
"id": "scratch",
"family": "SLANet",
"version": "v2.0.0",
"files": [
{{ "name": "table_ok.onnx", "role": "table_structure", "sha256": "{sha256}" }}
]
}}"#
);
std::fs::write(self.dir.join("manifest.json"), manifest)
.expect("manifest should be writable");
}
}
impl Drop for ScratchModelDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[test]
fn a_model_whose_registered_hash_does_not_match_never_reaches_ort() {
let scratch = ScratchModelDir::new("tampered");
let path = scratch.with_ok_model();
scratch.register(&"0".repeat(64));
let error = crate::table::registration::verified_registered_model(&path, None)
.expect_err("a mismatching hash must be refused at credential construction");
assert!(
matches!(error, RapidOcrError::HashMismatch { .. }),
"the refusal must be locatable, got {error:?}"
);
let error = TableStructureModelInfo::probe(&path, &rt())
.expect_err("the probe must be refused as well");
assert!(
matches!(error, RapidOcrError::HashMismatch { .. }),
"{error:?}"
);
}
#[test]
fn a_model_registered_with_its_real_hash_loads() {
let scratch = ScratchModelDir::new("matching");
let path = scratch.with_ok_model();
let real = crate::model_verify::sha256_file_cached(&path).expect("hash should compute");
scratch.register(&real);
let credential = crate::table::registration::verified_registered_model(&path, None)
.expect("the registered hash must be accepted");
assert_eq!(
credential.actual_sha256(),
Some(real.as_str()),
"the credential must record the digest it verified"
);
let session =
TableStructureSession::open(&credential, &rt(), &TableStructureConfig::default())
.expect("a correctly registered model must load");
assert_eq!(session.contract().input_name, "x");
}
#[test]
fn an_explicit_expected_hash_overrides_the_registration() {
let scratch = ScratchModelDir::new("explicit");
let path = scratch.with_ok_model();
scratch.register(&"0".repeat(64));
let real = crate::model_verify::sha256_file_cached(&path).expect("hash should compute");
let credential = crate::table::registration::verified_registered_model(&path, Some(&real))
.expect(
"the explicit hash is the right one, so the wrong registration must not matter",
);
TableStructureSession::open(&credential, &rt(), &TableStructureConfig::default())
.expect("the credential built from the explicit hash must load");
}
#[test]
fn a_model_without_a_manifest_is_loaded_without_a_hash() {
let scratch = ScratchModelDir::new("no-manifest");
let path = scratch.with_ok_model();
let credential = crate::table::registration::verified_registered_model(&path, None)
.expect("a model with no registration has nothing to verify against");
assert!(
!credential.is_verified(),
"no manifest must mean no verified digest"
);
TableStructureSession::open(&credential, &rt(), &TableStructureConfig::default())
.expect("the credential must still bind the content it read");
}
#[test]
fn table_session_opens_the_bytes_the_credential_verified() {
let scratch = ScratchModelDir::new("replaced-at-open");
let path = scratch.with_ok_model();
let credential = cred(&path);
std::fs::write(&path, b"not an onnx model any more").expect("replace after credential");
let session =
TableStructureSession::open(&credential, &rt(), &TableStructureConfig::default())
.expect("the bound content must open even though the path was replaced");
assert_eq!(session.contract().input_name, "x");
let stale = cred(&path);
let failing = TableStructureSession::open(&stale, &rt(), &TableStructureConfig::default())
.expect_err("the replaced content must not load");
assert!(matches!(failing, RapidOcrError::Decode(_)), "{failing}");
}
#[test]
fn open_verified_loads_the_bound_bytes_and_not_the_replaced_path() {
let scratch = ScratchModelDir::new("replaced-after-verify");
let path = scratch.with_ok_model();
let credential =
crate::model_verify::VerifiedModel::verify(&path, None).expect("credential");
std::fs::write(&path, b"not an onnx model any more").expect("replace after verification");
let session = OrtSession::open_verified(&credential, &rt(), &[])
.expect("the verified bytes must load regardless of what the path holds now");
let io = session.probe_io().expect("probe");
validate_table_structure_contract(&io, &path)
.expect("the contract of the *verified* content still holds");
let error = OrtSession::open_unchecked(&path, &rt(), &[])
.expect_err("the replaced content must not load through a path-based open");
assert!(matches!(error, RapidOcrError::Decode(_)), "{error}");
}
#[test]
fn an_unknown_override_name_is_silently_ignored_by_ort() {
let path = fixture("table_ok.onnx");
let bogus = [DimensionOverride::new("NotADimensionInThisGraph", 488)];
let session = OrtSession::open_unchecked(&path, &rt(), &bogus)
.expect("ORT must not reject an unresolvable override name");
let probe = session.probe_io().expect("fixture must probe");
assert_eq!(
probe.inputs[0].dims,
[-1, 3, -1, -1],
"an unresolvable name must have changed nothing"
);
let error = TableStructureConfig::default()
.require_applied(&probe, &path)
.expect_err("the post-condition must catch what ORT let through");
let message = error.to_string();
assert!(message.contains("did not take effect"), "{message}");
assert!(message.contains("488"), "{message}");
}
#[test]
fn an_already_fixed_dimension_cannot_be_overridden() {
let probe = ModelIoProbe {
inputs: vec![TensorSpec::new(
"x",
vec![-1, 3, 256, 256],
TensorElementType::Float32,
)],
outputs: Vec::new(),
};
let error = TableStructureConfig::default()
.dimension_overrides(&probe, Path::new("fixed_256.onnx"))
.expect_err("a fixed dimension is not overridable");
let message = error.to_string();
assert!(message.contains("fixed to 256"), "{message}");
assert!(message.contains("cannot be overridden"), "{message}");
assert!(message.contains("fixed_256.onnx"), "{message}");
}
#[test]
fn a_dynamic_dimension_without_a_symbol_cannot_be_overridden() {
let probe = ModelIoProbe {
inputs: vec![TensorSpec::new(
"x",
vec![-1, 3, -1, -1],
TensorElementType::Float32,
)],
outputs: Vec::new(),
};
let error = TableStructureConfig::default()
.dimension_overrides(&probe, Path::new("unnamed.onnx"))
.expect_err("a dynamic dimension without a symbol cannot be addressed");
let message = error.to_string();
assert!(message.contains("dim_param"), "{message}");
assert!(message.contains("symbol"), "{message}");
assert!(message.contains("unnamed.onnx"), "{message}");
}
#[test]
fn an_already_matching_size_emits_no_override() {
let probe = ModelIoProbe {
inputs: vec![TensorSpec::new(
"x",
vec![-1, 3, 488, 488],
TensorElementType::Float32,
)],
outputs: Vec::new(),
};
let overrides = TableStructureConfig::default()
.dimension_overrides(&probe, Path::new("fixed_488.onnx"))
.expect("488 already matches, so nothing needs overriding");
assert!(overrides.is_empty(), "got {overrides:?}");
}
#[test]
fn run_with_returns_typed_views_in_contract_order() {
let path = fixture("table_ok.onnx");
let mut session =
TableStructureSession::open(&cred(&path), &rt(), &TableStructureConfig::default())
.expect("session must open");
let input = ndarray::Array4::<f32>::zeros((2, 3, 488, 488));
let (boxes_shape, tokens_shape, samples) = session
.run_with(input.view(), |outputs| {
Ok((
outputs.boxes.shape().to_vec(),
outputs.tokens.shape().to_vec(),
outputs.boxes.iter().take(1).copied().collect::<Vec<_>>(),
))
})
.expect("two-output run must succeed");
assert_eq!(
boxes_shape,
vec![2, 4, TABLE_STRUCTURE_BOX_LAST_DIM as usize]
);
assert_eq!(
tokens_shape,
vec![2, 4, TABLE_STRUCTURE_TOKEN_LAST_DIM as usize]
);
assert_eq!(samples.len(), 1, "the declared views must be readable");
}
#[test]
fn session_open_validates_the_contract() {
let renamed = TableStructureSession::open(
&cred(&fixture("table_renamed_output.onnx")),
&rt(),
&TableStructureConfig::default(),
)
.expect_err("renamed outputs must be rejected before any inference");
assert!(renamed.to_string().contains("output mismatch"), "{renamed}");
let missing =
crate::model_verify::VerifiedModel::verify(&fixture("does_not_exist.onnx"), None)
.expect_err("a missing model must be located at credential construction");
assert!(
matches!(missing, RapidOcrError::FileNotFound(_)),
"{missing}"
);
}
}
#[cfg(test)]
mod real_model_tests {
use std::path::PathBuf;
use crate::{
config::RuntimeConfig,
table::structure::{
TABLE_STRUCTURE_OUTPUTS, TableStructureConfig, TableStructureModelInfo,
TableStructureSession,
},
};
fn real_model() -> Option<PathBuf> {
crate::test_support::table_structure_model_path()
}
const SIDE: usize = 488;
#[test]
fn real_model_matches_the_output_golden() {
let Some(path) = real_model() else {
return;
};
let info = TableStructureModelInfo::probe(&path, &RuntimeConfig::default())
.expect("real model must satisfy the contract");
assert_eq!(info.box_output_name, TABLE_STRUCTURE_OUTPUTS[0]);
assert_eq!(info.structure_output_name, TABLE_STRUCTURE_OUTPUTS[1]);
}
#[test]
fn real_model_is_registered_in_the_shipped_manifest() {
let Some(path) = real_model() else {
return;
};
let registration = crate::table::registration::registration_for(&path)
.expect("the shipped manifest must parse")
.expect(
"the shipped model must be registered next to itself; without it the load path \
verifies nothing",
);
assert_eq!(registration.name, "slanet-plus.onnx");
assert_eq!(registration.size_bytes, Some(7_758_305));
assert_eq!(
crate::model_verify::sha256_file_cached(&path).expect("hash should compute"),
registration.sha256,
"the shipped manifest must record the hash of the shipped model"
);
let credential = crate::table::registration::verified_registered_model(&path, None)
.expect("the registered hash must match the file");
assert_eq!(
credential.actual_sha256(),
Some(registration.sha256.as_str())
);
assert!(
credential.is_verified(),
"the shipped manifest must carry a real expectation"
);
}
#[test]
fn real_model_vocabulary_matches_the_graph_width() {
let Some(path) = real_model() else {
return;
};
let info = TableStructureModelInfo::probe(&path, &RuntimeConfig::default())
.expect("real model must satisfy the contract");
assert_eq!(
info.character_tokens.len() + 2,
info.structure_last_dim,
"vocabulary width and graph width must agree"
);
assert_eq!(info.character_tokens.len(), 48);
}
fn deterministic_input() -> ndarray::Array4<f32> {
let mut state = 0x2545_f491_4f6c_dd1d_u64;
let mut values = Vec::with_capacity(3 * SIDE * SIDE);
for _ in 0..(3 * SIDE * SIDE) {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let bucket = (state >> 33) as u32 % 4096;
values.push((bucket as f32 / 2048.0) - 1.0);
}
ndarray::Array4::from_shape_vec((1, 3, SIDE, SIDE), values).expect("shape matches")
}
struct Inference {
boxes_shape: Vec<usize>,
tokens_shape: Vec<usize>,
boxes_bits: Vec<u32>,
tokens_bits: Vec<u32>,
}
fn run_once(session: &mut TableStructureSession, input: &ndarray::Array4<f32>) -> Inference {
session
.run_with(input.view(), |outputs| {
Ok(Inference {
boxes_shape: outputs.boxes.shape().to_vec(),
tokens_shape: outputs.tokens.shape().to_vec(),
boxes_bits: outputs.boxes.iter().map(|value| value.to_bits()).collect(),
tokens_bits: outputs.tokens.iter().map(|value| value.to_bits()).collect(),
})
})
.expect("inference must succeed")
}
fn open(session_path: &std::path::Path, config: TableStructureConfig) -> TableStructureSession {
let credential = crate::table::registration::verified_registered_model(session_path, None)
.expect("credential must build");
TableStructureSession::open(&credential, &RuntimeConfig::default(), &config)
.expect("session must open")
}
#[test]
fn real_model_shape_fixing_is_bit_exact() {
let Some(path) = real_model() else {
return;
};
let input = deterministic_input();
let mut fixed = open(&path, TableStructureConfig::default());
let mut unfixed = open(
&path,
TableStructureConfig {
fix_input_shape: false,
..TableStructureConfig::default()
},
);
assert_eq!(fixed.input_dims(), [-1, 3, SIDE as i64, SIDE as i64]);
assert_eq!(
unfixed.input_dims(),
[-1, 3, -1, -1],
"the control group must really be dynamic"
);
let observed_fixed = run_once(&mut fixed, &input);
let observed_unfixed = run_once(&mut unfixed, &input);
assert_eq!(
observed_fixed.boxes_shape, observed_unfixed.boxes_shape,
"fixing the input shape must not change the box output shape"
);
assert_eq!(
observed_fixed.tokens_shape, observed_unfixed.tokens_shape,
"fixing the input shape must not change the token output shape"
);
assert_eq!(observed_fixed.boxes_shape.len(), 3);
assert_eq!(observed_fixed.boxes_shape[2], 8);
assert_eq!(observed_fixed.tokens_shape[2], 50);
assert!(
observed_fixed.boxes_shape[1] < 500,
"`L` must be produced by the model's early exit, not by the Loop trip limit: got {}",
observed_fixed.boxes_shape[1]
);
assert_eq!(
observed_fixed.boxes_bits, observed_unfixed.boxes_bits,
"box values must be bit-identical"
);
assert_eq!(
observed_fixed.tokens_bits, observed_unfixed.tokens_bits,
"token values must be bit-identical"
);
}
#[test]
fn real_model_records_inference_time_before_and_after_fixing() {
let Some(path) = real_model() else {
return;
};
let input = deterministic_input();
let configs = [
("fixed", TableStructureConfig::default()),
(
"unfixed",
TableStructureConfig {
fix_input_shape: false,
..TableStructureConfig::default()
},
),
];
for (label, config) in configs {
let mut session = open(&path, config);
run_once(&mut session, &input);
let mut samples = Vec::with_capacity(15);
for _ in 0..15 {
let started = std::time::Instant::now();
run_once(&mut session, &input);
samples.push(started.elapsed().as_secs_f64() * 1000.0);
}
let stats = crate::evaluation::stats::Stats::from_samples(samples);
assert_eq!(stats.samples, 15, "every sample must be recorded");
println!(
"table_structure_infer_ms[{label}] p50={:.3} mean={:.3} p95={:.3}",
stats.p50_ms, stats.mean_ms, stats.p95_ms
);
}
}
}
#[cfg(feature = "directml-provider")]
#[cfg(test)]
mod direct_ml_experiment {
use crate::{
config::{ProviderPreference, RuntimeConfig},
table::structure::{TableStructureConfig, TableStructureSession},
};
fn direct_ml_runtime() -> RuntimeConfig {
RuntimeConfig {
provider_preference: ProviderPreference::DirectMl { device_id: 0 },
fail_if_provider_unavailable: true,
..RuntimeConfig::default()
}
}
#[test]
fn the_real_table_model_runs_on_direct_ml_or_says_why_not() {
let Some(path) = crate::test_support::table_structure_model_path() else {
return;
};
let runtime = direct_ml_runtime();
let credential = crate::table::registration::verified_registered_model(&path, None)
.expect("the real model must pass load-time verification");
let opened =
TableStructureSession::open(&credential, &runtime, &TableStructureConfig::default());
let mut session = match opened {
Ok(session) => session,
Err(error) => {
println!("DIRECT_ML_VERDICT=session_open_failed");
println!("DIRECT_ML_ERROR={error}");
return;
}
};
let resolution = session.provider_resolution();
println!("DIRECT_ML_VERDICT=session_opened");
println!("DIRECT_ML_SELECTED_EP={:?}", resolution.selected_ep);
println!("DIRECT_ML_FALLBACK={}", resolution.fallback_used);
println!("DIRECT_ML_OPSET={:?}", session.onnx_opset());
assert_eq!(
resolution.selected_ep,
crate::runtime::provider::ResolvedExecutionProvider::DirectMl,
"the DirectML chain must be the one actually handed to ORT"
);
assert!(
!resolution.fallback_used,
"a fallback would mean the timing and the control-flow answer both describe CPU"
);
let side = TableStructureConfig::default().input_side_len;
let input = ndarray::Array4::<f32>::from_elem((1, 3, side, side), 0.25);
let outcome = session.run_with(input.view(), |outputs| {
Ok((
outputs.boxes.shape().to_vec(),
outputs.tokens.shape().to_vec(),
))
});
match outcome {
Ok((boxes, tokens)) => {
println!("DIRECT_ML_VERDICT=inference_succeeded");
println!("DIRECT_ML_BOXES_SHAPE={boxes:?}");
println!("DIRECT_ML_TOKENS_SHAPE={tokens:?}");
assert_eq!(boxes.first().copied(), Some(1), "batch must be preserved");
if let Some(&steps) = tokens.get(1) {
assert!(
steps < 500,
"the decoder must early-exit on `eos`; {steps} steps means the \
control flow ran to the trip cap"
);
}
}
Err(error) => {
println!("DIRECT_ML_VERDICT=inference_failed");
println!("DIRECT_ML_ERROR={error}");
let text = error.to_string();
assert!(
text.contains("DmlExecutionProvider"),
"the failure must come from the DirectML execution provider itself, \
otherwise this run measured the environment rather than the graph: {text}"
);
assert!(
text.contains("Loop"),
"the documented root cause is the `Loop` control flow; a different \
failure means the docs must be revisited: {text}"
);
}
}
}
}