use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use rapid_structure_rs::{
DefaultModelSelection, EngineConfig, ModelFileSpec, ModelFileState, ModelRequest, ModelRole,
ModelSet, ModelSetStatus, ModelSource, ModelSourceKind, RapidOcrError,
};
use super::state::ModelReadiness;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Pipeline {
Text,
Formula,
Table,
}
impl Pipeline {
pub const ALL: [Self; 3] = [Self::Text, Self::Formula, Self::Table];
pub fn of(role: ModelRole) -> Self {
match role {
ModelRole::FormulaDetector | ModelRole::FormulaRecognizer => Self::Formula,
ModelRole::TableStructure => Self::Table,
ModelRole::Detector
| ModelRole::Classifier
| ModelRole::Recognizer
| ModelRole::Dictionary
| ModelRole::Tokenizer => Self::Text,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Text => "text",
Self::Formula => "formula",
Self::Table => "table",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct BlockingFile {
pub name: String,
pub pipeline: Pipeline,
pub corrupt: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct PlanFileStatus {
pub name: String,
pub role: ModelRole,
pub pipeline: Pipeline,
pub state: ModelFileState,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct PlanFile {
pub name: String,
pub role: ModelRole,
pub path: PathBuf,
pub declared_sha256: Option<String>,
pub pipeline: Pipeline,
}
impl PlanFile {
pub fn has_declared_digest(&self) -> bool {
self.declared_sha256.is_some()
}
fn expected(&self) -> String {
self.declared_sha256.clone().unwrap_or_default()
}
fn spec(&self) -> Result<ModelFileSpec, RapidOcrError> {
ModelFileSpec::new(
self.name.clone(),
self.role,
None,
self.expected(),
String::new(),
)
}
fn root(&self) -> &Path {
self.path.parent().unwrap_or_else(|| Path::new("."))
}
pub fn state(&self) -> ModelFileState {
let expected = self.expected();
let spec = match self.spec() {
Ok(spec) => spec,
Err(error) => {
return ModelFileState::Corrupt {
expected,
actual: format!("the planned file cannot be described: {error}"),
};
}
};
if self.has_declared_digest() {
return spec.state_in(self.root());
}
match spec.state_in(self.root()) {
ModelFileState::Missing => ModelFileState::Missing,
_ => match undeclared_digest_failure(&self.path) {
None => ModelFileState::Present,
Some(actual) => ModelFileState::Corrupt { expected, actual },
},
}
}
pub fn status(&self) -> PlanFileStatus {
PlanFileStatus {
name: self.name.clone(),
role: self.role,
pipeline: self.pipeline,
state: self.state(),
}
}
}
#[derive(Debug, Clone)]
pub(super) struct ModelPlan {
model_dir: PathBuf,
source: ModelSourceKind,
sets: Vec<ModelSet>,
planned: Vec<PlanFile>,
detector: Option<FormulaDetectorSpec>,
table_model: Option<TableModelSpec>,
}
impl ModelPlan {
pub fn resolve(
model_dir: &Path,
engine: &EngineConfig,
formula_detector: Option<&Path>,
table_model: Option<&Path>,
) -> Result<Self, ModelPlanError> {
let source = ModelSource::select(model_dir).map_err(ModelPlanError::Source)?;
if matches!(source.kind(), ModelSourceKind::DefaultTable) {
if engine.det.model_type != engine.rec.model.model_type {
return Err(ModelPlanError::ModelTypeConflict {
det: engine.det.model_type.as_str(),
rec: engine.rec.model.model_type.as_str(),
});
}
}
let request = ModelRequest::text_and_formula(selection(engine));
let sets = source
.model_sets(&request)
.map_err(ModelPlanError::Source)?;
if sets.is_empty() {
return Err(ModelPlanError::NoSets);
}
let mut plan = Self {
model_dir: model_dir.to_path_buf(),
source: source.kind(),
sets,
planned: Vec::new(),
detector: None,
table_model: None,
};
for role in ModelRole::ALL {
plan.spec_for(role)?;
}
plan.detector = plan.resolve_formula_detector(formula_detector)?;
plan.table_model = plan.resolve_table_model(table_model)?;
plan.planned = plan.build_plan(engine.global.use_cls)?;
Ok(plan)
}
pub fn source(&self) -> ModelSourceKind {
self.source
}
pub fn model_dir(&self) -> &Path {
&self.model_dir
}
pub fn set_by_id(&self, set_id: &str) -> Option<&ModelSet> {
self.sets.iter().find(|set| set.id == set_id)
}
pub fn set_ids(&self) -> Vec<String> {
self.sets.iter().map(|set| set.id.clone()).collect()
}
pub fn plan_files(&self) -> &[PlanFile] {
&self.planned
}
pub fn plan_files_in(&self, pipeline: Pipeline) -> Vec<&PlanFile> {
self.plan_files()
.iter()
.filter(|file| file.pipeline == pipeline)
.collect()
}
pub fn plan_file(&self, role: ModelRole) -> Option<&PlanFile> {
self.planned.iter().find(|file| file.role == role)
}
pub fn plan_blocking(&self, pipeline: Pipeline) -> Vec<BlockingFile> {
self.plan_files_in(pipeline)
.into_iter()
.filter_map(|file| match file.state() {
ModelFileState::Present => None,
ModelFileState::Missing => Some(BlockingFile {
name: file.name.clone(),
pipeline,
corrupt: false,
}),
ModelFileState::Corrupt { .. } => Some(BlockingFile {
name: file.name.clone(),
pipeline,
corrupt: true,
}),
})
.collect()
}
pub fn plan_complete(&self, pipeline: Pipeline) -> bool {
self.plan_blocking(pipeline).is_empty()
}
pub fn formula_in_plan(&self) -> bool {
self.detector.is_some()
}
pub fn formula_detector(&self) -> Option<&FormulaDetectorSpec> {
self.detector.as_ref()
}
pub fn table_in_plan(&self) -> bool {
self.table_model.is_some()
}
pub fn table_model(&self) -> Option<&TableModelSpec> {
self.table_model.as_ref()
}
pub fn report(&self) -> ModelReport {
let mut cold_this_call = 0_usize;
let statuses = self
.sets
.iter()
.map(|set| {
let (status, computed) = set.status_probed(&self.model_dir);
cold_this_call += computed;
status
})
.collect();
ModelReport {
statuses,
cold_this_call,
}
}
pub fn snapshot(&self) -> ModelSnapshot {
ModelSnapshot {
model_dir: self.model_dir.clone(),
source: self.source,
blocking: self.plan_blocking(Pipeline::Text),
formula_blocking: self.plan_blocking(Pipeline::Formula),
formula_in_plan: self.formula_in_plan(),
table_blocking: self.plan_blocking(Pipeline::Table),
table_in_plan: self.table_in_plan(),
}
}
pub fn pin_engine_paths(&self, engine: &mut EngineConfig) -> Result<(), ModelPlanError> {
let det = self.planned_path(ModelRole::Detector)?;
engine.det.model_path = Some(det);
engine.det.model_store_dir = Some(self.model_dir.clone());
engine.det.allow_download = false;
let rec = self.planned_path(ModelRole::Recognizer)?;
engine.rec.model.model_path = Some(rec);
engine.rec.model_store_dir = Some(self.model_dir.clone());
engine.rec.model.allow_download = false;
let dict = self.planned_path(ModelRole::Dictionary)?;
engine.rec.model.rec_keys_path = Some(dict);
if engine.global.use_cls {
let cls = self.planned_path(ModelRole::Classifier)?;
engine.cls.model_path = Some(cls);
engine.cls.model_store_dir = Some(self.model_dir.clone());
engine.cls.allow_download = false;
} else {
engine.cls.model_path = None;
engine.cls.model_store_dir = Some(self.model_dir.clone());
engine.cls.allow_download = false;
}
Ok(())
}
fn planned_path(&self, role: ModelRole) -> Result<PathBuf, ModelPlanError> {
self.plan_file(role)
.map(|file| file.path.clone())
.ok_or(ModelPlanError::MissingRole { role })
}
fn build_plan(&self, use_cls: bool) -> Result<Vec<PlanFile>, ModelPlanError> {
let formula = self.detector.is_some();
let table = self.table_model.is_some();
let mut roles = vec![ModelRole::Detector];
if use_cls {
roles.push(ModelRole::Classifier);
}
roles.push(ModelRole::Recognizer);
roles.push(ModelRole::Dictionary);
if formula {
roles.push(ModelRole::FormulaRecognizer);
if self.detector_is_declared() {
roles.push(ModelRole::FormulaDetector);
}
}
if table && self.table_is_declared() {
roles.push(ModelRole::TableStructure);
}
let mut out: Vec<PlanFile> = Vec::new();
for set in &self.sets {
for file in &set.files {
if !roles.contains(&file.role) {
continue;
}
if out.iter().any(|planned| planned.name == file.name) {
continue;
}
out.push(PlanFile {
name: file.name.clone(),
role: file.role,
path: self.model_dir.join(&file.name),
declared_sha256: file.has_hash().then(|| file.sha256.clone()),
pipeline: Pipeline::of(file.role),
});
}
}
if formula {
if !out
.iter()
.any(|planned| planned.role == ModelRole::FormulaRecognizer)
{
return Err(ModelPlanError::MissingRole {
role: ModelRole::FormulaRecognizer,
});
}
let spec = self
.detector
.as_ref()
.expect("the formula pipeline is only planned when a detector was resolved");
if !out
.iter()
.any(|planned| same_file(&planned.path, &spec.path))
{
out.push(PlanFile {
name: file_name(&spec.path),
role: ModelRole::FormulaDetector,
path: spec.path.clone(),
declared_sha256: spec.expected_sha256.clone(),
pipeline: Pipeline::Formula,
});
}
}
if table {
let spec = self
.table_model
.as_ref()
.expect("the table pipeline is only planned when a structure model was resolved");
if !out
.iter()
.any(|planned| same_file(&planned.path, &spec.path))
{
out.push(PlanFile {
name: file_name(&spec.path),
role: ModelRole::TableStructure,
path: spec.path.clone(),
declared_sha256: spec.expected_sha256.clone(),
pipeline: Pipeline::Table,
});
}
}
Ok(out)
}
fn detector_is_declared(&self) -> bool {
let Some(spec) = self.detector.as_ref() else {
return false;
};
self.spec_for(ModelRole::FormulaDetector)
.ok()
.flatten()
.is_some_and(|declared| same_file(&self.model_dir.join(&declared.name), &spec.path))
}
fn table_is_declared(&self) -> bool {
let Some(spec) = self.table_model.as_ref() else {
return false;
};
self.spec_for(ModelRole::TableStructure)
.ok()
.flatten()
.is_some_and(|declared| same_file(&self.model_dir.join(&declared.name), &spec.path))
}
fn spec_for(
&self,
role: ModelRole,
) -> Result<Option<&rapid_structure_rs::ModelFileSpec>, ModelPlanError> {
let mut found: Option<&rapid_structure_rs::ModelFileSpec> = None;
for set in &self.sets {
for file in &set.files {
if file.role != role {
continue;
}
match found {
None => found = Some(file),
Some(existing) if existing.name == file.name => {}
Some(existing) => {
return Err(ModelPlanError::AmbiguousRole {
role,
first: existing.name.clone(),
second: file.name.clone(),
});
}
}
}
}
Ok(found)
}
fn resolve_formula_detector(
&self,
cli: Option<&Path>,
) -> Result<Option<FormulaDetectorSpec>, ModelPlanError> {
let declared = self.spec_for(ModelRole::FormulaDetector)?;
let Some(cli) = cli else {
return Ok(declared.map(|spec| FormulaDetectorSpec {
path: self.model_dir.join(&spec.name),
expected_sha256: spec.has_hash().then(|| spec.sha256.clone()),
}));
};
let expected_sha256 = declared
.filter(|spec| same_file(&self.model_dir.join(&spec.name), cli))
.filter(|spec| spec.has_hash())
.map(|spec| spec.sha256.clone());
Ok(Some(FormulaDetectorSpec {
path: cli.to_path_buf(),
expected_sha256,
}))
}
fn resolve_table_model(
&self,
cli: Option<&Path>,
) -> Result<Option<TableModelSpec>, ModelPlanError> {
let declared = self.spec_for(ModelRole::TableStructure)?;
let Some(cli) = cli else {
return Ok(declared.map(|spec| TableModelSpec {
path: self.model_dir.join(&spec.name),
expected_sha256: spec.has_hash().then(|| spec.sha256.clone()),
}));
};
let expected_sha256 = declared
.filter(|spec| same_file(&self.model_dir.join(&spec.name), cli))
.filter(|spec| spec.has_hash())
.map(|spec| spec.sha256.clone());
Ok(Some(TableModelSpec {
path: cli.to_path_buf(),
expected_sha256,
}))
}
pub fn reverify(&self) -> PlanReverification {
let mut files = Vec::new();
let mut digests_computed = 0_usize;
let mut content_changed = Vec::new();
for planned in &self.planned {
let expected = planned.expected();
let outcome = if planned.path.is_file() {
Some(rapid_structure_rs::force_verify_file(&planned.path))
} else {
None
};
let (state, digest, cause) = match outcome {
None => (
ModelFileState::Missing,
None,
rapid_structure_rs::ReverifyCause::FirstSight,
),
Some(Ok(outcome)) => {
digests_computed += 1;
if outcome.cause == rapid_structure_rs::ReverifyCause::ContentChanged {
content_changed.push(planned.name.clone());
}
let state = if planned.has_declared_digest() {
if outcome.sha256.eq_ignore_ascii_case(&expected) {
ModelFileState::Present
} else {
ModelFileState::Corrupt {
expected: expected.clone(),
actual: outcome.sha256.clone(),
}
}
} else {
match undeclared_digest_failure(&planned.path) {
None => ModelFileState::Present,
Some(actual) => ModelFileState::Corrupt {
expected: String::new(),
actual,
},
}
};
(state, Some(outcome.sha256), outcome.cause)
}
Some(Err(error)) => (
ModelFileState::Corrupt {
expected: expected.clone(),
actual: format!("unreadable: {error}"),
},
None,
rapid_structure_rs::ReverifyCause::FirstSight,
),
};
files.push(VerifiedPlanFile {
name: planned.name.clone(),
role: planned.role,
pipeline: planned.pipeline,
declared_sha256: planned.declared_sha256.clone(),
sha256: digest,
state,
cause,
});
}
PlanReverification {
files,
digests_computed,
content_changed,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct PlanReverification {
files: Vec<VerifiedPlanFile>,
digests_computed: usize,
content_changed: Vec<String>,
}
impl PlanReverification {
pub fn files(&self) -> &[VerifiedPlanFile] {
&self.files
}
pub fn digests_computed(&self) -> usize {
self.digests_computed
}
pub fn content_changed(&self) -> &[String] {
&self.content_changed
}
pub fn blocking(&self) -> Vec<&VerifiedPlanFile> {
self.files
.iter()
.filter(|file| !file.state.is_present())
.collect()
}
pub fn blocking_in(&self, pipeline: Pipeline) -> Vec<&VerifiedPlanFile> {
self.files
.iter()
.filter(|file| file.pipeline == pipeline && !file.state.is_present())
.collect()
}
pub fn files_in(&self, pipeline: Pipeline) -> Vec<&VerifiedPlanFile> {
self.files
.iter()
.filter(|file| file.pipeline == pipeline)
.collect()
}
pub fn blocking_summary(&self) -> String {
let mut lines = Vec::new();
for pipeline in Pipeline::ALL {
let files = self.blocking_in(pipeline);
if files.is_empty() {
continue;
}
lines.push(format!(
"{} pipeline: {}",
pipeline.as_str(),
files
.iter()
.map(|file| file.describe())
.collect::<Vec<_>>()
.join("; ")
));
}
lines.join("\n ")
}
}
#[derive(Debug, Clone)]
pub(crate) struct VerifiedPlanFile {
pub name: String,
pub role: ModelRole,
pub pipeline: Pipeline,
pub declared_sha256: Option<String>,
pub sha256: Option<String>,
pub state: ModelFileState,
pub cause: rapid_structure_rs::ReverifyCause,
}
impl VerifiedPlanFile {
pub fn describe(&self) -> String {
let digest = match (&self.declared_sha256, &self.sha256) {
(Some(_), Some(value)) => format!("sha256={}", short_digest(value)),
(None, Some(value)) => format!(
"sha256={} (computed by this call; no digest was declared)",
short_digest(value)
),
_ => "sha256=<none>".to_string(),
};
match &self.state {
ModelFileState::Present => format!("{} ({}) present {digest}", self.name, self.role),
ModelFileState::Missing => format!("{} ({}) missing", self.name, self.role),
ModelFileState::Corrupt { actual, .. } => {
format!("{} ({}) corrupt (actual: {actual})", self.name, self.role)
}
}
}
}
fn short_digest(value: &str) -> &str {
&value[..16.min(value.len())]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct FormulaDetectorSpec {
pub path: PathBuf,
pub expected_sha256: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct TableModelSpec {
pub path: PathBuf,
pub expected_sha256: Option<String>,
}
fn undeclared_digest_failure(path: &Path) -> Option<String> {
let mut file = match File::open(path) {
Ok(file) => file,
Err(error) => return Some(format!("unreadable: {error}")),
};
let mut head = [0_u8; 8];
let read = match file.read(&mut head) {
Ok(read) => read,
Err(error) => return Some(format!("unreadable: {error}")),
};
if read == 0 {
return Some("the file is empty (0 bytes), so it cannot be an ONNX model".to_string());
}
if read < 2 || head[0] != 0x08 {
return Some(format!(
"not a plausible ONNX model: the file must start with the protobuf `ir_version` field \
(0x08 + varint 1..=64), found {:02x?}",
&head[..read]
));
}
if !(1..=64).contains(&head[1]) {
return Some(format!(
"not a plausible ONNX model: `ir_version` is {} (expected 1..=64)",
head[1]
));
}
None
}
fn same_file(a: &Path, b: &Path) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
fn file_name(path: &Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned())
}
pub(super) fn blocking_files(statuses: &[ModelSetStatus], pipeline: Pipeline) -> Vec<BlockingFile> {
let mut out = Vec::new();
for status in statuses {
for (file, state) in &status.files {
if Pipeline::of(file.role) != pipeline {
continue;
}
match state {
ModelFileState::Present => {}
ModelFileState::Missing => out.push(BlockingFile {
name: file.name.clone(),
pipeline,
corrupt: false,
}),
ModelFileState::Corrupt { .. } => out.push(BlockingFile {
name: file.name.clone(),
pipeline,
corrupt: true,
}),
}
}
}
out
}
pub(super) fn missing_names(files: &[BlockingFile]) -> Vec<String> {
files
.iter()
.filter(|file| !file.corrupt)
.map(|file| file.name.clone())
.collect()
}
pub(super) fn corrupt_names(files: &[BlockingFile]) -> Vec<String> {
files
.iter()
.filter(|file| file.corrupt)
.map(|file| file.name.clone())
.collect()
}
pub(super) fn blocked_names(files: &[BlockingFile]) -> Vec<String> {
files.iter().map(|file| file.name.clone()).collect()
}
fn pipeline_clause(in_plan: bool, blocked: usize, absent_reason: &str) -> String {
if in_plan {
format!("{blocked} model file(s) missing or corrupt")
} else {
format!("not part of this run ({absent_reason})")
}
}
pub(super) fn pending_download(status: &ModelSetStatus) -> PendingDownload {
let mut files = 0_usize;
let mut bytes = Some(0_u64);
for (file, state) in &status.files {
if state.is_present() {
continue;
}
files += 1;
bytes = match (bytes, file.size_bytes) {
(Some(total), Some(size)) => total.checked_add(size),
_ => None,
};
}
PendingDownload { files, bytes }
}
#[derive(Debug, Clone)]
pub(super) struct ModelReport {
statuses: Vec<ModelSetStatus>,
cold_this_call: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct PendingDownload {
pub files: usize,
pub bytes: Option<u64>,
}
impl ModelReport {
pub fn statuses(&self) -> &[ModelSetStatus] {
&self.statuses
}
pub fn cold_this_call(&self) -> usize {
self.cold_this_call
}
pub fn pending_download(&self, set_id: &str) -> Option<PendingDownload> {
self.statuses
.iter()
.find(|status| status.set_id == set_id)
.map(pending_download)
}
pub fn formula_inventory_blocking(&self) -> Vec<BlockingFile> {
blocking_files(&self.statuses, Pipeline::Formula)
}
pub fn formula_inventory_complete(&self) -> bool {
let mut seen = false;
for status in &self.statuses {
for (file, state) in &status.files {
if Pipeline::of(file.role) != Pipeline::Formula {
continue;
}
seen = true;
if !state.is_present() || !file.has_hash() {
return false;
}
}
}
seen
}
}
#[derive(Debug, Clone)]
pub(super) struct ModelSnapshot {
model_dir: PathBuf,
source: ModelSourceKind,
blocking: Vec<BlockingFile>,
formula_blocking: Vec<BlockingFile>,
formula_in_plan: bool,
table_blocking: Vec<BlockingFile>,
table_in_plan: bool,
}
impl ModelSnapshot {
pub fn model_dir(&self) -> &Path {
&self.model_dir
}
pub fn readiness(&self) -> ModelReadiness {
if self.blocking.is_empty() {
ModelReadiness::Complete
} else {
ModelReadiness::Incomplete {
missing: self.blocking_names(),
}
}
}
pub fn blocking_names(&self) -> Vec<String> {
blocked_names(&self.blocking)
}
pub fn formula_blocking_names(&self) -> Vec<String> {
blocked_names(&self.formula_blocking)
}
pub fn formula_in_plan(&self) -> bool {
self.formula_in_plan
}
pub fn table_blocking_names(&self) -> Vec<String> {
blocked_names(&self.table_blocking)
}
pub fn table_in_plan(&self) -> bool {
self.table_in_plan
}
pub fn summary(&self) -> String {
format!(
"text pipeline: {} model file(s) missing or corrupt; formula pipeline: {}; table pipeline: {}",
self.blocking.len(),
pipeline_clause(
self.formula_in_plan,
self.formula_blocking.len(),
"no formula detector configured",
),
pipeline_clause(
self.table_in_plan,
self.table_blocking.len(),
"no table structure model configured",
),
)
}
pub fn source_label(&self) -> &'static str {
source_label(self.source)
}
}
pub(super) fn source_label(kind: ModelSourceKind) -> &'static str {
match kind {
ModelSourceKind::DefaultTable => "default_table",
ModelSourceKind::LocalManifest => "local_manifest",
}
}
fn selection(engine: &EngineConfig) -> DefaultModelSelection {
DefaultModelSelection {
ocr_version: engine.rec.model.ocr_version,
det_lang: engine.det.lang,
rec_lang: engine.rec.model.lang,
cls_lang: engine.cls.lang,
model_type: engine.rec.model.model_type,
include_classifier: engine.global.use_cls,
}
}
#[derive(Debug)]
pub(crate) enum ModelPlanError {
Source(RapidOcrError),
ModelTypeConflict {
det: &'static str,
rec: &'static str,
},
NoSets,
MissingRole { role: ModelRole },
AmbiguousRole {
role: ModelRole,
first: String,
second: String,
},
}
impl std::fmt::Display for ModelPlanError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Source(error) => write!(f, "{error}"),
Self::ModelTypeConflict { det, rec } => write!(
f,
"the default model table selects one `model_type` for the whole pipeline, but the \
configuration uses det.model_type={det} and rec.model.model_type={rec}; make them \
equal, or describe this directory with a local manifest.json (docs/05 §5.3)"
),
Self::NoSets => write!(
f,
"the selected model source produced no model set for the text pipeline"
),
Self::MissingRole { role } => write!(
f,
"the model set does not declare the required role `{role}`"
),
Self::AmbiguousRole {
role,
first,
second,
} => write!(
f,
"the role `{role}` is declared twice with different files (`{first}` and \
`{second}`); serve cannot tell which one the engine must load"
),
}
}
}
impl std::error::Error for ModelPlanError {}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use rapid_structure_rs::{EngineConfig, ModelFileState, ModelRole, sha256_file};
use super::{ModelPlan, ModelPlanError, Pipeline, undeclared_digest_failure};
fn fixture_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/m1-plan-fixture")
}
fn plausible_onnx_bytes(tag: &str) -> Vec<u8> {
let mut bytes = vec![0x08, 0x07];
bytes.extend_from_slice(tag.as_bytes());
bytes
}
fn resolve(dir: &Path) -> ModelPlan {
ModelPlan::resolve(dir, &EngineConfig::default(), None, None).expect("model plan")
}
fn write_manifest(dir: &Path, files: &[(&str, &str, Vec<u8>)]) {
std::fs::create_dir_all(dir).expect("fixture dir");
let mut manifest = String::from(
"{\"schema_version\":1,\"id\":\"t\",\"family\":\"PP-OCR\",\"version\":\"v6\",\
\"files\":[",
);
for (index, (name, role, body)) in files.iter().enumerate() {
let path = dir.join(name);
std::fs::write(&path, body).expect("fixture file");
let sha = sha256_file(&path).expect("hash the fixture");
if index > 0 {
manifest.push(',');
}
manifest.push_str(&format!(
"{{\"name\":\"{name}\",\"role\":\"{role}\",\"sha256\":\"{sha}\"}}"
));
}
manifest.push_str("]}");
std::fs::write(dir.join("manifest.json"), manifest).expect("write the manifest");
}
fn healthy_files() -> Vec<(&'static str, &'static str, Vec<u8>)> {
vec![
(
"det.onnx",
"detector",
plausible_onnx_bytes("healthy detector"),
),
(
"rec.onnx",
"recognizer",
plausible_onnx_bytes("healthy recognizer"),
),
(
"dict.txt",
"dictionary",
plausible_onnx_bytes("healthy dictionary"),
),
(
"fx.onnx",
"formula_recognizer",
plausible_onnx_bytes("healthy formula recognizer"),
),
]
}
#[test]
fn the_default_table_describes_the_configured_pipeline() {
let dir = fixture_dir();
std::fs::create_dir_all(&dir).expect("fixture dir");
let plan = resolve(&dir);
let report = plan.report();
assert_eq!(report.statuses().len(), 2, "text set + formula set (M4)");
let set = &report.statuses()[0];
let roles: Vec<&str> = set
.files
.iter()
.map(|(file, _)| file.role.as_str())
.collect();
assert!(roles.contains(&"detector"), "{roles:?}");
assert!(roles.contains(&"recognizer"), "{roles:?}");
assert!(roles.contains(&"dictionary"), "{roles:?}");
let formula = &report.statuses()[1];
let formula_roles: Vec<&str> = formula
.files
.iter()
.map(|(file, _)| file.role.as_str())
.collect();
assert_eq!(formula_roles, vec!["formula_recognizer"]);
assert_eq!(formula.set_id, "PP-FormulaNet_plus-M");
let (name, size) = (
formula.files[0].0.name.clone(),
formula.files[0].0.size_bytes,
);
assert_eq!(name, "pp_formulanet_plus_m.onnx");
assert!(
size.is_some_and(|bytes| bytes > 500_000_000),
"the page must be able to show the ~566 MB size before downloading: {size:?}"
);
assert!(formula.files[0].0.has_hash(), "the set declares a SHA-256");
assert!(formula.files[0].0.has_source_url(), "and a trusted source");
assert!(!formula.complete, "the fixture dir has no formula model");
let snapshot = plan.snapshot();
let declared: Vec<String> = set
.files
.iter()
.map(|(file, _)| file.name.clone())
.collect();
assert_eq!(snapshot.blocking_names(), declared);
assert_eq!(plan.plan_blocking(Pipeline::Text).len(), declared.len());
assert_eq!(
super::blocked_names(&plan.plan_blocking(Pipeline::Text)),
declared
);
assert!(!plan.plan_complete(Pipeline::Text));
assert!(matches!(
snapshot.readiness(),
super::ModelReadiness::Incomplete { .. }
));
assert_eq!(
super::blocked_names(&report.formula_inventory_blocking()),
vec![name.clone()],
"the inventory reports the formula model as missing (the page offers the download)"
);
assert!(!report.formula_inventory_complete());
assert!(!plan.formula_in_plan());
assert!(plan.plan_files_in(Pipeline::Formula).is_empty());
assert!(
snapshot.formula_blocking_names().is_empty(),
"the run plan has no formula pipeline, so nothing formula-related blocks"
);
assert!(!snapshot.formula_in_plan());
assert_eq!(plan.plan_files().len(), declared.len());
assert!(plan.formula_detector().is_none());
}
#[test]
fn a_missing_formula_model_never_blocks_the_text_pipeline() {
let dir = fixture_dir().join("text-only-complete");
std::fs::create_dir_all(&dir).expect("fixture dir");
let plan = resolve(&dir);
let text_files: Vec<String> = plan
.plan_files_in(Pipeline::Text)
.into_iter()
.map(|file| file.name.clone())
.collect();
assert_eq!(text_files.len(), 3, "{text_files:?}");
for name in &text_files {
std::fs::write(dir.join(name), b"placeholder").expect("fixture file");
}
let plan = resolve(&dir);
let blocking = plan.plan_blocking(Pipeline::Text);
assert_eq!(super::missing_names(&blocking), Vec::<String>::new());
assert_eq!(super::corrupt_names(&blocking), text_files);
assert!(!plan.plan_complete(Pipeline::Text));
assert!(plan.plan_blocking(Pipeline::Formula).is_empty());
for name in super::blocked_names(&blocking) {
assert!(
!name.contains("formula"),
"the engine list must stay text-scoped: {name}"
);
}
assert_eq!(
super::blocked_names(&plan.report().formula_inventory_blocking()),
vec!["pp_formulanet_plus_m.onnx"]
);
let snapshot = plan.snapshot();
assert_eq!(snapshot.blocking_names(), text_files);
assert_eq!(
super::corrupt_names(&plan.plan_blocking(Pipeline::Text)),
text_files,
"the text files exist but do not match their declared digests"
);
assert!(snapshot.formula_blocking_names().is_empty());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_formula_pipeline_is_planned_only_when_a_detector_is_resolved() {
let dir = fixture_dir().join("formula-plan");
std::fs::create_dir_all(&dir).expect("fixture dir");
let detector = dir.join("mfd.onnx");
std::fs::write(&detector, plausible_onnx_bytes("detector fixture"))
.expect("fixture detector");
let without = resolve(&dir);
assert!(!without.formula_in_plan());
let names: Vec<&str> = without
.plan_files()
.iter()
.map(|file| file.name.as_str())
.collect();
assert_eq!(names.len(), 3, "{names:?}");
assert!(!names.contains(&"pp_formulanet_plus_m.onnx"));
let with = ModelPlan::resolve(&dir, &EngineConfig::default(), Some(&detector), None)
.expect("model plan");
assert!(with.formula_in_plan());
assert_eq!(with.plan_files_in(Pipeline::Formula).len(), 2);
let planned = with
.plan_file(ModelRole::FormulaDetector)
.expect("the detector is planned");
assert_eq!(planned.path, detector);
assert_eq!(planned.name, "mfd.onnx");
assert!(
!planned.has_declared_digest(),
"an external detector has no declared digest"
);
let recognizer = with
.plan_file(ModelRole::FormulaRecognizer)
.expect("the formula recognizer is planned with a detector");
assert_eq!(recognizer.path, dir.join("pp_formulanet_plus_m.onnx"));
assert_eq!(
recognizer.declared_sha256.as_deref().map(str::len),
Some(64),
"the default table's declared SHA-256 travels with the planned file"
);
assert!(
!with.plan_complete(Pipeline::Formula),
"the 566 MB model is absent"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_set_declared_detector_is_planned_once_with_its_declared_digest() {
let dir = fixture_dir().join("set-detector-plan");
std::fs::create_dir_all(&dir).expect("fixture dir");
let declared = "a".repeat(64);
std::fs::write(
dir.join("manifest.json"),
format!(
r#"{{"schema_version":1,"id":"t","family":"PP-OCR","version":"v6",
"files":[{{"name":"det.onnx","role":"detector","sha256":"aa"}},
{{"name":"rec.onnx","role":"recognizer","sha256":"bb"}},
{{"name":"dict.txt","role":"dictionary","sha256":"cc"}},
{{"name":"fx.onnx","role":"formula_recognizer","sha256":"dd"}},
{{"name":"mfd.onnx","role":"formula_detector","sha256":"{declared}"}}]}}"#
),
)
.expect("manifest");
std::fs::write(dir.join("mfd.onnx"), plausible_onnx_bytes("set detector"))
.expect("fixture file");
let plan = resolve(&dir);
assert!(plan.formula_in_plan());
let detectors: Vec<&str> = plan
.plan_files_in(Pipeline::Formula)
.into_iter()
.filter(|file| file.role == ModelRole::FormulaDetector)
.map(|file| file.name.as_str())
.collect();
assert_eq!(detectors, vec!["mfd.onnx"], "planned exactly once");
let planned = plan.plan_file(ModelRole::FormulaDetector).expect("planned");
assert_eq!(planned.declared_sha256.as_deref(), Some(declared.as_str()));
assert_eq!(plan.plan_files_in(Pipeline::Formula).len(), 2);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_formula_detector_hash_travels_with_the_selected_file() {
let dir = fixture_dir().join("detector-hash");
std::fs::create_dir_all(&dir).expect("fixture dir");
let declared = "a".repeat(64);
std::fs::write(
dir.join("manifest.json"),
format!(
r#"{{"schema_version":1,"id":"t","family":"PP-OCR","version":"v6",
"files":[{{"name":"det.onnx","role":"detector","sha256":"aa"}},
{{"name":"rec.onnx","role":"recognizer","sha256":"bb"}},
{{"name":"dict.txt","role":"dictionary","sha256":"cc"}},
{{"name":"fx.onnx","role":"formula_recognizer","sha256":"dd"}},
{{"name":"mfd.onnx","role":"formula_detector","sha256":"{declared}"}}]}}"#
),
)
.expect("manifest");
std::fs::write(dir.join("mfd.onnx"), b"placeholder").expect("fixture file");
let plan =
ModelPlan::resolve(&dir, &EngineConfig::default(), None, None).expect("manifest");
let spec = plan
.formula_detector()
.expect("the role is declared")
.clone();
assert_eq!(spec.path, dir.join("mfd.onnx"));
assert_eq!(
spec.expected_sha256.as_deref(),
Some(declared.as_str()),
"the declared hash must not be dropped"
);
assert_eq!(
plan.plan_file(ModelRole::FormulaDetector)
.expect("planned")
.declared_sha256
.as_deref(),
Some(declared.as_str())
);
let indirect = dir.join(".").join("mfd.onnx");
let plan = ModelPlan::resolve(&dir, &EngineConfig::default(), Some(&indirect), None)
.expect("manifest");
let spec = plan.formula_detector().expect("cli override").clone();
assert_eq!(
spec.expected_sha256.as_deref(),
Some(declared.as_str()),
"a CLI path that resolves to the declared file keeps its hash"
);
let detectors: Vec<&Path> = plan
.plan_files_in(Pipeline::Formula)
.into_iter()
.filter(|file| file.role == ModelRole::FormulaDetector)
.map(|file| file.path.as_path())
.collect();
assert_eq!(
detectors.len(),
1,
"the same file must not be planned twice"
);
let other = dir.join("other-mfd.onnx");
std::fs::write(&other, b"placeholder").expect("fixture file");
let plan = ModelPlan::resolve(&dir, &EngineConfig::default(), Some(&other), None)
.expect("manifest");
assert_eq!(plan.formula_detector().expect("cli").path, other);
assert_eq!(plan.formula_detector().expect("cli").expected_sha256, None);
let planned = plan.plan_file(ModelRole::FormulaDetector).expect("planned");
assert_eq!(planned.path, other);
assert!(!planned.has_declared_digest());
for file in plan.plan_files() {
assert_ne!(
file.path,
dir.join("mfd.onnx"),
"a file that will not be loaded must not be planned"
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_external_detector_without_a_declared_digest_follows_the_documented_rule() {
let dir = fixture_dir().join("external-detector");
write_manifest(&dir, &healthy_files());
let outside = fixture_dir().join("external-detector-outside.onnx");
std::fs::write(&outside, plausible_onnx_bytes("external detector")).expect("detector");
let plan = ModelPlan::resolve(&dir, &EngineConfig::default(), Some(&outside), None)
.expect("model plan");
let planned = plan.plan_file(ModelRole::FormulaDetector).expect("planned");
assert_eq!(planned.path, outside);
assert!(!planned.has_declared_digest());
assert_eq!(
planned.state(),
ModelFileState::Present,
"exists + readable + plausible ONNX"
);
assert_eq!(
super::blocked_names(&plan.plan_blocking(Pipeline::Formula)),
Vec::<String>::new(),
"everything in the formula plan is usable"
);
std::fs::write(&outside, b"not a model at all").expect("corrupt the detector");
let planned = plan.plan_file(ModelRole::FormulaDetector).expect("planned");
assert!(matches!(planned.state(), ModelFileState::Corrupt { .. }));
assert_eq!(
super::blocked_names(&plan.plan_blocking(Pipeline::Formula)),
vec!["external-detector-outside.onnx"],
"the external detector blocks the formula pipeline and is named"
);
std::fs::write(&outside, b"").expect("empty the detector");
assert!(undeclared_digest_failure(&outside).is_some());
std::fs::remove_file(&outside).expect("remove the detector");
let planned = plan.plan_file(ModelRole::FormulaDetector).expect("planned");
assert_eq!(planned.state(), ModelFileState::Missing);
std::fs::remove_dir_all(&dir).ok();
std::fs::remove_file(&outside).ok();
}
#[test]
fn a_declared_digest_is_enforced_and_is_the_authority() {
let dir = fixture_dir().join("declared-digest");
std::fs::create_dir_all(&dir).expect("fixture dir");
let body = plausible_onnx_bytes("detector with a declared digest");
std::fs::write(dir.join("det.onnx"), &body).expect("fixture file");
let sha = sha256_file(dir.join("det.onnx")).expect("hash");
std::fs::write(
dir.join("manifest.json"),
format!(
r#"{{"schema_version":1,"id":"t","family":"PP-OCR","version":"v6",
"files":[{{"name":"det.onnx","role":"detector","sha256":"{sha}"}},
{{"name":"rec.onnx","role":"recognizer","sha256":"bb"}},
{{"name":"dict.txt","role":"dictionary","sha256":"cc"}},
{{"name":"fx.onnx","role":"formula_recognizer","sha256":"dd"}}]}}"#
),
)
.expect("manifest");
let plan = resolve(&dir);
let planned = plan.plan_file(ModelRole::Detector).expect("planned");
assert_eq!(planned.state(), ModelFileState::Present);
let mut other = plausible_onnx_bytes("x");
other.clear();
other.extend_from_slice(b"garbage whose declared digest matches");
std::fs::write(dir.join("det.onnx"), &other).expect("rewrite");
let sha = sha256_file(dir.join("det.onnx")).expect("hash");
std::fs::write(
dir.join("manifest.json"),
format!(
r#"{{"schema_version":1,"id":"t","family":"PP-OCR","version":"v6",
"files":[{{"name":"det.onnx","role":"detector","sha256":"{sha}"}},
{{"name":"rec.onnx","role":"recognizer","sha256":"bb"}},
{{"name":"dict.txt","role":"dictionary","sha256":"cc"}},
{{"name":"fx.onnx","role":"formula_recognizer","sha256":"dd"}}]}}"#
),
)
.expect("manifest");
let plan = resolve(&dir);
assert_eq!(
plan.plan_file(ModelRole::Detector)
.expect("planned")
.state(),
ModelFileState::Present,
"a matching declared digest is the authority (the prologue rule does not apply)"
);
std::fs::write(dir.join("det.onnx"), b"different bytes entirely").expect("rewrite");
let plan = resolve(&dir);
match plan
.plan_file(ModelRole::Detector)
.expect("planned")
.state()
{
ModelFileState::Corrupt { expected, actual } => {
assert_eq!(expected, sha, "the declared digest must be reported");
assert_ne!(actual, sha);
}
other => panic!("expected corrupt, got {other:?}"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn one_plan_feeds_reverification_the_snapshot_and_the_loading_paths() {
let dir = fixture_dir().join("identity");
std::fs::create_dir_all(&dir).expect("fixture dir");
let detector = dir.join("mfd.onnx");
std::fs::write(&detector, plausible_onnx_bytes("identity detector")).expect("detector");
let plan = ModelPlan::resolve(&dir, &EngineConfig::default(), Some(&detector), None)
.expect("plan");
let planned: Vec<(String, ModelRole, Pipeline)> = plan
.plan_files()
.iter()
.map(|file| (file.name.clone(), file.role, file.pipeline))
.collect();
assert_eq!(planned.len(), 5, "{planned:?}");
for file in plan.plan_files() {
std::fs::write(&file.path, plausible_onnx_bytes(&file.name)).expect("planned file");
}
let plan = ModelPlan::resolve(&dir, &EngineConfig::default(), Some(&detector), None)
.expect("plan");
let report = plan.reverify();
let verified: Vec<(String, ModelRole, Pipeline)> = report
.files()
.iter()
.map(|file| (file.name.clone(), file.role, file.pipeline))
.collect();
assert_eq!(verified, planned);
assert_eq!(report.digests_computed(), planned.len());
let snapshot = plan.snapshot();
assert_eq!(
snapshot.blocking_names(),
super::blocked_names(&plan.plan_blocking(Pipeline::Text))
);
assert_eq!(
snapshot.formula_blocking_names(),
super::blocked_names(&plan.plan_blocking(Pipeline::Formula))
);
assert!(snapshot.formula_in_plan());
let mut engine = EngineConfig::default();
plan.pin_engine_paths(&mut engine)
.expect("all roles planned");
assert_eq!(
engine.det.model_path.as_deref(),
Some(
plan.plan_file(ModelRole::Detector)
.expect("planned")
.path
.as_path()
)
);
assert_eq!(
engine.rec.model.model_path.as_deref(),
Some(
plan.plan_file(ModelRole::Recognizer)
.expect("planned")
.path
.as_path()
)
);
assert_eq!(
engine.rec.model.rec_keys_path.as_deref(),
Some(
plan.plan_file(ModelRole::Dictionary)
.expect("planned")
.path
.as_path()
)
);
let detector_spec = plan.formula_detector().expect("detector");
assert_eq!(
plan.plan_file(ModelRole::FormulaDetector)
.expect("planned")
.path,
detector_spec.path
);
assert_eq!(
plan.plan_file(ModelRole::FormulaRecognizer)
.expect("planned")
.declared_sha256,
plan.plan_files_in(Pipeline::Formula)
.into_iter()
.find(|file| file.role == ModelRole::FormulaRecognizer)
.expect("planned")
.declared_sha256
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_corrupt_formula_recognizer_blocks_the_formula_pipeline_when_it_is_planned() {
let dir = fixture_dir().join("corrupt-formula-recognizer");
write_manifest(&dir, &healthy_files());
std::fs::write(dir.join("fx.onnx"), b"not the declared bytes").expect("corrupt fx");
let detector = dir.join("mfd.onnx");
std::fs::write(&detector, plausible_onnx_bytes("detector")).expect("detector");
let enabled = ModelPlan::resolve(&dir, &EngineConfig::default(), Some(&detector), None)
.expect("plan");
assert_eq!(
super::blocked_names(&enabled.plan_blocking(Pipeline::Formula)),
vec!["fx.onnx"]
);
assert!(!enabled.plan_complete(Pipeline::Formula));
assert!(enabled.plan_complete(Pipeline::Text));
let disabled = resolve(&dir);
assert!(disabled.plan_blocking(Pipeline::Formula).is_empty());
assert!(disabled.plan_complete(Pipeline::Formula));
let report = disabled.reverify();
assert_eq!(
report.digests_computed(),
3,
"the 566 MB formula recognizer must not be hashed when formula is disabled: {:?}",
report.files().iter().map(|f| &f.name).collect::<Vec<_>>()
);
assert_eq!(
report.files().len(),
3,
"the formula models are not in this run's plan at all"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn reverification_covers_exactly_the_plan() {
let dir = fixture_dir().join("reverify-plan");
std::fs::create_dir_all(&dir).expect("fixture dir");
let detector = dir.join("mfd.onnx");
std::fs::write(&detector, plausible_onnx_bytes("reverify detector")).expect("detector");
let plan = ModelPlan::resolve(&dir, &EngineConfig::default(), Some(&detector), None)
.expect("plan");
let report = plan.reverify();
assert_eq!(report.files().len(), plan.plan_files().len());
assert_eq!(report.digests_computed(), 1);
for file in report.files() {
assert_eq!(file.cause.as_str(), "first_sight");
if file.role == ModelRole::FormulaDetector {
assert_eq!(file.state.as_str(), "present");
assert!(file.sha256.is_some(), "{}", file.describe());
assert_eq!(file.declared_sha256, None);
} else {
assert_eq!(file.state.as_str(), "missing", "{}", file.describe());
assert!(file.sha256.is_none(), "a missing file has no digest");
}
}
assert_eq!(report.blocking().len(), plan.plan_files().len() - 1);
let summary = report.blocking_summary();
assert!(summary.contains("text pipeline:"), "{summary}");
assert!(summary.contains("formula pipeline:"), "{summary}");
assert!(
!summary.contains("mfd.onnx"),
"the external detector is present, so it must not be reported as blocking: {summary}"
);
assert!(
report
.blocking_in(Pipeline::Text)
.iter()
.all(|file| file.name != "mfd.onnx"),
"the detector must not be reported as a text-pipeline file"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_model_type_conflict_is_rejected_with_a_locating_error() {
let dir = fixture_dir();
let mut engine = EngineConfig::default();
engine.det.model_type = rapid_structure_rs::ModelType::Server;
engine.rec.model.model_type = rapid_structure_rs::ModelType::Mobile;
let error = ModelPlan::resolve(&dir, &engine, None, None).expect_err("must reject");
let text = error.to_string();
assert!(text.contains("det.model_type"), "{text}");
assert!(text.contains("rec.model.model_type"), "{text}");
assert!(matches!(error, ModelPlanError::ModelTypeConflict { .. }));
}
#[test]
fn engine_paths_are_pinned_to_the_model_set_files() {
let dir = fixture_dir();
let plan = resolve(&dir);
let mut engine = EngineConfig::default();
engine.det.allow_download = true;
engine.rec.model.allow_download = true;
engine.cls.allow_download = true;
plan.pin_engine_paths(&mut engine).expect("all roles exist");
let det = engine.det.model_path.expect("detector path");
let rec = engine.rec.model.model_path.expect("recognizer path");
let dict = engine.rec.model.rec_keys_path.expect("dictionary path");
for path in [&det, &rec, &dict] {
assert_eq!(path.parent(), Some(dir.as_path()), "{}", path.display());
}
assert_ne!(det, rec);
assert!(!engine.det.allow_download);
assert!(!engine.rec.model.allow_download);
assert!(!engine.cls.allow_download);
assert!(
engine.cls.model_path.is_none(),
"use_cls=false keeps cls off"
);
assert_eq!(engine.det.model_store_dir.as_deref(), Some(dir.as_path()));
}
#[test]
fn the_classifier_joins_the_plan_when_use_cls_is_on() {
let dir = fixture_dir().join("use-cls");
std::fs::create_dir_all(&dir).expect("fixture dir");
let mut engine = EngineConfig::default();
engine.global.use_cls = true;
let plan = ModelPlan::resolve(&dir, &engine, None, None).expect("default table");
assert!(
plan.plan_file(ModelRole::Classifier).is_some(),
"the engine loads the classifier when use_cls is on"
);
assert_eq!(plan.plan_files_in(Pipeline::Text).len(), 4);
let mut pinned = EngineConfig::default();
pinned.global.use_cls = true;
plan.pin_engine_paths(&mut pinned).expect("planned");
assert!(pinned.cls.model_path.is_some());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_missing_role_is_reported_by_the_shared_rule() {
let dir = fixture_dir().join("no-dictionary");
std::fs::create_dir_all(&dir).expect("fixture dir");
std::fs::write(
dir.join("manifest.json"),
r#"{"schema_version":1,"id":"t","family":"PP-OCR","version":"v6",
"files":[{"name":"det.onnx","role":"detector","sha256":"aa"},
{"name":"rec.onnx","role":"recognizer","sha256":"bb"}]}"#,
)
.expect("manifest");
let error =
ModelPlan::resolve(&dir, &EngineConfig::default(), None, None).expect_err("reject");
let text = error.to_string();
assert!(text.contains("dictionary"), "{text}");
assert!(
text.contains("formula_recognizer"),
"the formula pipeline's required role must be named too: {text}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_legacy_manifest_is_rejected_with_a_migration_hint() {
let dir = fixture_dir().join("legacy");
std::fs::create_dir_all(&dir).expect("fixture dir");
std::fs::write(
dir.join("manifest.json"),
r#"{"detector":{"file_name":"d.onnx","sha256":"a"}}"#,
)
.expect("manifest");
let error =
ModelPlan::resolve(&dir, &EngineConfig::default(), None, None).expect_err("reject");
let text = error.to_string();
assert!(text.contains("schema_version"), "{text}");
assert!(text.contains("migrate"), "{text}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_ambiguous_role_is_rejected_instead_of_picking_one() {
let dir = fixture_dir().join("ambiguous");
std::fs::create_dir_all(&dir).expect("fixture dir");
std::fs::write(
dir.join("manifest.json"),
r#"{"schema_version":1,"id":"t","family":"PP-OCR","version":"v6",
"files":[{"name":"det-a.onnx","role":"detector","sha256":"aa"},
{"name":"det-b.onnx","role":"detector","sha256":"bb"},
{"name":"rec.onnx","role":"recognizer","sha256":"cc"},
{"name":"dict.txt","role":"dictionary","sha256":"dd"},
{"name":"fx.onnx","role":"formula_recognizer","sha256":"ee"}]}"#,
)
.expect("manifest");
let error =
ModelPlan::resolve(&dir, &EngineConfig::default(), None, None).expect_err("reject");
assert!(matches!(
error,
ModelPlanError::AmbiguousRole {
role: ModelRole::Detector,
..
}
));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_ambiguous_formula_recognizer_is_rejected_at_startup() {
let dir = fixture_dir().join("ambiguous-formula");
std::fs::create_dir_all(&dir).expect("fixture dir");
std::fs::write(
dir.join("manifest.json"),
r#"{"schema_version":1,"id":"t","family":"PP-OCR","version":"v6",
"files":[{"name":"det.onnx","role":"detector","sha256":"aa"},
{"name":"rec.onnx","role":"recognizer","sha256":"cc"},
{"name":"dict.txt","role":"dictionary","sha256":"dd"},
{"name":"fx-a.onnx","role":"formula_recognizer","sha256":"ee"},
{"name":"fx-b.onnx","role":"formula_recognizer","sha256":"ff"}]}"#,
)
.expect("manifest");
let error =
ModelPlan::resolve(&dir, &EngineConfig::default(), None, None).expect_err("reject");
let text = error.to_string();
assert!(text.contains("formula_recognizer"), "{text}");
assert!(text.contains("fx-a.onnx"), "{text}");
assert!(text.contains("fx-b.onnx"), "{text}");
assert!(matches!(
error,
ModelPlanError::AmbiguousRole {
role: ModelRole::FormulaRecognizer,
..
}
));
std::fs::remove_dir_all(&dir).ok();
}
}