#![cfg_attr(target_arch = "aarch64", allow(stable_features))]
#![cfg_attr(
target_arch = "aarch64",
feature(stdarch_neon_dotprod, stdarch_neon_i8mm)
)]
#![deny(unsafe_code)]
pub mod adaptive;
#[cfg(feature = "native")]
pub mod batch_events;
#[cfg(feature = "native")]
pub mod cli;
#[cfg(feature = "native")]
pub mod conformance;
#[cfg(feature = "native")]
pub mod dist;
#[cfg(feature = "native")]
pub mod doctor;
pub mod error;
pub mod native_engine;
pub mod page_source;
#[cfg(feature = "pdf")]
pub mod pdf;
pub mod preprocess;
pub mod progress;
pub mod quant;
#[cfg(feature = "native")]
pub mod resident;
#[cfg(feature = "native")]
pub mod robot;
pub mod simd;
#[cfg(feature = "native")]
pub mod storage;
pub mod tall;
pub mod tokenizer;
#[cfg(feature = "native")]
pub use cli::cli_main;
pub use error::{FocrError, FocrResult};
pub use native_engine::DocumentResult;
pub use native_engine::checkpoint::{ResumeKey, RunFiles};
pub use native_engine::model_arch;
pub use native_engine::{
DocumentEvent, DocumentOptions, ExtractedFigure, Half, LayoutSpan, PageFinish, PageOrigin,
PageResult, PageWindow, RecognizedDocument, ResumePolicy, StreamedPage,
};
pub const OUTPUT_CONTRACT_VERSION: u32 = 1;
#[cfg(feature = "native")]
use std::cell::RefCell;
#[cfg(feature = "native")]
use std::path::Path;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(feature = "native")]
use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
#[cfg(feature = "native")]
use std::time::Duration;
#[cfg(feature = "native")]
use asupersync::runtime::{Runtime, RuntimeBuilder};
#[cfg(feature = "native")]
use native_engine::OcrModel;
pub(crate) const UNLIMITED_OCR_ARTIFACT_VERSION: &str = "0.7.0";
pub const MODEL_PATH_ENV: &str = "FOCR_MODEL_PATH";
pub const FOCR_PROJECT_LICENSE_NOTICE: &str =
"franken_ocr - Copyright (c) 2026 Jeffrey Emanuel, MIT License (with OpenAI/Anthropic Rider)";
pub const FOCR_MODEL_LICENSE_NOTICE: &str =
"Baidu Unlimited-OCR - Copyright (c) 2026 Baidu, MIT License";
pub const DEFAULT_MODEL_PATH: &str = "models/unlimited-ocr.focrq";
const DEFAULT_FORWARD_STAGE_BUDGET_MS: u64 = 10 * 60 * 1000;
pub(crate) fn forward_stage_budget() -> Option<std::time::Duration> {
let millis = match std::env::var("FOCR_STAGE_BUDGET_FORWARD_MS") {
Ok(raw) => {
let trimmed = raw.trim();
if trimmed == "0" || trimmed.eq_ignore_ascii_case("unlimited") {
return None;
}
trimmed
.parse::<u64>()
.ok()
.filter(|&ms| ms > 0)
.unwrap_or(DEFAULT_FORWARD_STAGE_BUDGET_MS)
}
Err(_) => DEFAULT_FORWARD_STAGE_BUDGET_MS,
};
Some(std::time::Duration::from_millis(millis))
}
static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);
pub fn request_shutdown() {
SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
}
#[must_use]
pub fn shutdown_requested() -> bool {
SHUTDOWN_REQUESTED.load(Ordering::Relaxed)
}
pub fn reset_shutdown() {
SHUTDOWN_REQUESTED.store(false, Ordering::SeqCst);
}
pub fn cancel_checkpoint() -> FocrResult<()> {
if shutdown_requested() {
return Err(FocrError::Cancelled);
}
#[cfg(feature = "native")]
if RUNNING_STAGE.with_borrow(|stage| stage.as_ref().is_some_and(|s| s.stop_requested())) {
return Err(FocrError::Timeout("the stage's budget ran out".into()));
}
Ok(())
}
#[cfg(feature = "native")]
#[derive(Default)]
struct StageStop {
stop: AtomicBool,
ended: Mutex<bool>,
ended_changed: Condvar,
}
#[cfg(feature = "native")]
impl StageStop {
fn stop_requested(&self) -> bool {
self.stop.load(Ordering::Relaxed)
}
fn halt(&self, grace: Duration) -> bool {
self.stop.store(true, Ordering::Relaxed);
let ended = self.ended.lock().unwrap_or_else(PoisonError::into_inner);
let (ended, _) = self
.ended_changed
.wait_timeout_while(ended, grace, |ended| !*ended)
.unwrap_or_else(PoisonError::into_inner);
*ended
}
}
#[cfg(feature = "native")]
thread_local! {
static RUNNING_STAGE: RefCell<Option<Arc<StageStop>>> = const { RefCell::new(None) };
}
#[cfg(feature = "native")]
struct RunningStage(Arc<StageStop>);
#[cfg(feature = "native")]
impl RunningStage {
fn enter(stage: Arc<StageStop>) -> Self {
RUNNING_STAGE.with_borrow_mut(|running| *running = Some(Arc::clone(&stage)));
Self(stage)
}
}
#[cfg(feature = "native")]
impl Drop for RunningStage {
fn drop(&mut self) {
RUNNING_STAGE.with_borrow_mut(|running| *running = None);
*self.0.ended.lock().unwrap_or_else(PoisonError::into_inner) = true;
self.0.ended_changed.notify_all();
}
}
static THREAD_BUDGET: OnceLock<usize> = OnceLock::new();
pub fn thread_budget() -> usize {
*THREAD_BUDGET.get_or_init(|| {
std::env::var("FOCR_THREADS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or_else(default_thread_budget)
})
}
pub fn set_thread_budget(threads: std::num::NonZeroUsize) -> FocrResult<()> {
let wanted = threads.get();
match *THREAD_BUDGET.get_or_init(|| wanted) {
latched if latched == wanted => Ok(()),
latched => Err(FocrError::Usage(format!(
"--threads {wanted}: this process already runs with {latched}"
))),
}
}
static TIMING: AtomicBool = AtomicBool::new(false);
pub fn enable_timing() {
TIMING.store(true, Ordering::Relaxed);
}
#[must_use]
pub fn timing_enabled() -> bool {
TIMING.load(Ordering::Relaxed) || std::env::var_os("FOCR_TIMING").is_some()
}
fn default_thread_budget() -> usize {
let physical = num_cpus::get_physical();
if cfg!(target_os = "ios") {
physical.saturating_sub(1).max(1)
} else {
fastest_tier_cores()
.filter(|&fast| fast <= physical)
.unwrap_or(physical)
}
}
fn fastest_tier_cores() -> Option<usize> {
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("/usr/sbin/sysctl")
.args(["-n", "hw.perflevel0.physicalcpu"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout)
.ok()?
.trim()
.parse()
.ok()
.filter(|&n: &usize| n > 0)
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
pub fn init_kernel_pool() -> usize {
static INIT: OnceLock<usize> = OnceLock::new();
*INIT.get_or_init(|| {
let width = thread_budget();
let _ = rayon::ThreadPoolBuilder::new()
.num_threads(width)
.thread_name(|i| format!("focr-kernel-{i}"))
.start_handler(|_| apple_qos::pin_worker_to_user_initiated())
.build_global();
rayon::current_num_threads()
})
}
mod apple_qos {
#[cfg(target_vendor = "apple")]
#[allow(unsafe_code)]
pub(super) fn pin_worker_to_user_initiated() {
unsafe {
libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INITIATED, 0);
}
}
#[cfg(not(target_vendor = "apple"))]
pub(super) fn pin_worker_to_user_initiated() {}
}
pub fn kernel_pool_width() -> usize {
init_kernel_pool()
}
#[cfg(feature = "native")]
pub fn stream_pages<T, P, C>(capacity: usize, mut produce: P, mut consume: C) -> FocrResult<usize>
where
T: Send + 'static,
P: FnMut() -> FocrResult<Option<T>> + Send + 'static,
C: FnMut(T),
{
let (tx, rx) = std::sync::mpsc::sync_channel::<T>(capacity.max(1));
let worker = std::thread::Builder::new()
.name("focr-page-stream".into())
.spawn(move || -> FocrResult<()> {
loop {
match produce()? {
Some(item) => {
if tx.send(item).is_err() {
return Err(FocrError::Cancelled);
}
}
None => return Ok(()),
}
}
})
.map_err(|e| FocrError::Other(anyhow::anyhow!("page-stream worker spawn: {e}")))?;
let mut n = 0usize;
loop {
match rx.recv_timeout(Duration::from_millis(40)) {
Ok(item) => {
consume(item);
n += 1;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if worker.is_finished() {
while let Ok(item) = rx.try_recv() {
consume(item);
n += 1;
}
break;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
worker
.join()
.map_err(|_| FocrError::Other(anyhow::anyhow!("page-stream worker panicked")))??;
Ok(n)
}
pub type DocumentSink = Box<dyn FnMut(&DocumentEvent<'_>) + Send>;
#[cfg(feature = "native")]
pub type StageSink = Box<dyn Fn(&'static str, Option<Duration>) + Send>;
#[cfg(feature = "native")]
pub struct OcrEngine {
runtime: Runtime,
model: Mutex<Option<Arc<OcrModel>>>,
stage_sink: Mutex<Option<StageSink>>,
}
#[cfg(feature = "native")]
impl OcrEngine {
#[must_use]
pub fn take_music_page_meta(&self) -> Option<native_engine::MusicPageMeta> {
self.model
.lock()
.ok()
.and_then(|slot| slot.as_ref().map(std::sync::Arc::clone))
.and_then(|model| model.take_music_meta())
}
#[must_use]
pub fn take_chart_page_meta(&self) -> Option<native_engine::ChartPageMeta> {
self.model
.lock()
.ok()
.and_then(|slot| slot.as_ref().map(std::sync::Arc::clone))
.and_then(|model| model.take_chart_meta())
}
#[must_use]
pub fn take_last_decode_report(&self) -> Option<native_engine::DecodeReport> {
self.model
.lock()
.ok()
.and_then(|slot| slot.as_ref().map(std::sync::Arc::clone))
.and_then(|model| model.take_last_decode())
}
#[must_use]
pub fn take_batch_decode_reports(&self) -> Vec<Option<native_engine::DecodeReport>> {
self.model
.lock()
.ok()
.and_then(|slot| slot.as_ref().map(std::sync::Arc::clone))
.map(|model| model.take_batch_decodes())
.unwrap_or_default()
}
#[must_use]
pub fn take_batch_chart_page_metas(&self) -> Vec<Option<native_engine::ChartPageMeta>> {
self.model
.lock()
.ok()
.and_then(|slot| slot.as_ref().map(std::sync::Arc::clone))
.map(|model| model.take_batch_charts())
.unwrap_or_default()
}
#[must_use]
pub fn loaded_model_identity(&self) -> Option<native_engine::ModelIdentity> {
self.model
.lock()
.ok()
.and_then(|slot| slot.as_ref().map(|model| model.identity()))
}
pub fn new() -> FocrResult<Self> {
let _ = init_kernel_pool();
let runtime = RuntimeBuilder::new()
.worker_threads(2)
.blocking_threads(1, 4)
.thread_name_prefix("focr")
.build()
.map_err(|e| FocrError::Other(anyhow::anyhow!("asupersync runtime build: {e}")))?;
Ok(Self {
runtime,
model: Mutex::new(None),
stage_sink: Mutex::new(None),
})
}
pub fn set_stage_sink(&self, sink: Option<StageSink>) {
if let Ok(mut slot) = self.stage_sink.lock() {
*slot = sink;
}
}
fn report_stage(&self, name: &'static str, budget: Option<Duration>) {
if let Ok(slot) = self.stage_sink.lock()
&& let Some(sink) = slot.as_ref()
{
sink(name, budget);
}
}
#[must_use]
pub fn model_path() -> std::path::PathBuf {
std::env::var_os(MODEL_PATH_ENV)
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from(DEFAULT_MODEL_PATH))
}
fn model_at(&self, path: &Path) -> FocrResult<Arc<OcrModel>> {
{
let guard = self.model_guard()?;
if let Some(m) = guard.as_ref()
&& m.path() == path
{
return Ok(Arc::clone(m));
}
}
let loaded = OcrModel::load_observed(path, || self.report_stage("load", None))?;
let loaded_path = loaded.path().to_path_buf();
let mut guard = self.model_guard()?;
if let Some(m) = guard.as_ref()
&& m.path() == loaded_path
{
return Ok(Arc::clone(m));
}
*guard = Some(Arc::clone(&loaded));
Ok(loaded)
}
fn model_guard(&self) -> FocrResult<MutexGuard<'_, Option<Arc<OcrModel>>>> {
self.model
.lock()
.map_err(|_| FocrError::Other(anyhow::anyhow!("OcrEngine model mutex poisoned")))
}
pub fn recognize(&self, image_path: &Path) -> FocrResult<String> {
self.recognize_with_model(&Self::model_path(), image_path)
}
pub fn recognize_with_model(&self, model_path: &Path, image_path: &Path) -> FocrResult<String> {
let model = self.model_at(model_path)?;
let image_path = image_path.to_path_buf();
self.run_blocking_stage_with_budget("forward", Self::forward_budget(&model, 1), move || {
model.recognize(&image_path)
})
}
pub fn recognize_dynamic(&self, image: image::DynamicImage) -> FocrResult<String> {
self.recognize_dynamic_with_model(&Self::model_path(), image)
}
pub fn recognize_dynamic_with_model(
&self,
model_path: &Path,
image: image::DynamicImage,
) -> FocrResult<String> {
let model = self.model_at(model_path)?;
self.run_blocking_stage_with_budget("forward", Self::forward_budget(&model, 1), move || {
model.recognize_dynamic(image)
})
}
pub fn recognize_with_layout(&self, image_path: &Path) -> FocrResult<RecognizedDocument> {
self.recognize_with_layout_model(&Self::model_path(), image_path)
}
pub fn recognize_with_layout_model(
&self,
model_path: &Path,
image_path: &Path,
) -> FocrResult<RecognizedDocument> {
let model = self.model_at(model_path)?;
let image_path = image_path.to_path_buf();
self.run_blocking_stage_with_budget("forward", Self::forward_budget(&model, 1), move || {
model.recognize_with_layout(&image_path)
})
}
pub fn recognize_dynamic_with_layout(
&self,
image: image::DynamicImage,
) -> FocrResult<RecognizedDocument> {
self.recognize_dynamic_with_layout_model(&Self::model_path(), image)
}
pub fn recognize_dynamic_with_layout_model(
&self,
model_path: &Path,
image: image::DynamicImage,
) -> FocrResult<RecognizedDocument> {
let model = self.model_at(model_path)?;
self.run_blocking_stage_with_budget("forward", Self::forward_budget(&model, 1), move || {
model.recognize_dynamic_with_layout(image)
})
}
pub fn recognize_with_figures(
&self,
image_path: &Path,
) -> FocrResult<(RecognizedDocument, Vec<ExtractedFigure>)> {
self.recognize_with_figures_model(&Self::model_path(), image_path)
}
pub fn recognize_with_figures_model(
&self,
model_path: &Path,
image_path: &Path,
) -> FocrResult<(RecognizedDocument, Vec<ExtractedFigure>)> {
let model = self.model_at(model_path)?;
let image_path = image_path.to_path_buf();
self.run_blocking_stage_with_budget("forward", Self::forward_budget(&model, 1), move || {
model.recognize_with_figures(&image_path)
})
}
pub fn recognize_dynamic_with_figures(
&self,
image: image::DynamicImage,
) -> FocrResult<(RecognizedDocument, Vec<ExtractedFigure>)> {
self.recognize_dynamic_with_figures_model(&Self::model_path(), image)
}
pub fn recognize_dynamic_with_figures_model(
&self,
model_path: &Path,
image: image::DynamicImage,
) -> FocrResult<(RecognizedDocument, Vec<ExtractedFigure>)> {
let model = self.model_at(model_path)?;
self.run_blocking_stage_with_budget("forward", Self::forward_budget(&model, 1), move || {
model.recognize_dynamic_with_figures(image)
})
}
pub fn recognize_batch(&self, images: &[&Path]) -> FocrResult<Vec<FocrResult<String>>> {
self.recognize_batch_with_model(&Self::model_path(), images)
}
pub fn recognize_batch_with_model(
&self,
model_path: &Path,
images: &[&Path],
) -> FocrResult<Vec<FocrResult<String>>> {
let model = self.model_at(model_path)?;
let owned: Vec<std::path::PathBuf> = images.iter().map(|p| p.to_path_buf()).collect();
let budget = Self::forward_budget(&model, owned.len());
self.run_blocking_stage_with_budget("forward-batch", budget, move || {
let refs: Vec<&Path> = owned.iter().map(std::path::PathBuf::as_path).collect();
Ok(model.recognize_batch(&refs))
})
}
pub fn recognize_document(
&self,
model_path: &Path,
mut source: page_source::PageSource,
options: &DocumentOptions,
mut on_item: Box<dyn FnMut(&page_source::PageItem) + Send>,
on_event: Option<DocumentSink>,
) -> FocrResult<DocumentResult> {
let model = self.model_at(model_path)?;
let budget = Self::forward_budget(&model, source.len());
let options = options.clone();
self.run_blocking_stage_with_budget("forward-multi-page", budget, move || match on_event {
Some(mut sink) => {
model.recognize_document(&mut source, &options, &mut *on_item, Some(&mut *sink))
}
None => model.recognize_document(&mut source, &options, &mut *on_item, None),
})
}
pub fn recognize_pages(
&self,
model_path: &Path,
mut source: page_source::PageSource,
observer: &mut dyn FnMut(page_source::PageEvent<'_>),
) -> FocrResult<page_source::PagesRead<RecognizedDocument>> {
source.read_pages(
|image| self.recognize_dynamic_with_layout_model(model_path, image),
observer,
)
}
fn forward_budget(model: &OcrModel, count: usize) -> Option<Duration> {
let per_input = forward_stage_budget()?;
let count = u32::try_from(count.max(1)).unwrap_or(u32::MAX);
let budget = per_input
.checked_mul(count)
.unwrap_or_else(|| Duration::from_secs(u64::MAX / 2));
Some(if model.checks_forward_budget() {
budget.saturating_add(native_engine::document_budget_grace(budget))
} else {
budget
})
}
fn run_blocking_stage_with_budget<T, F>(
&self,
stage: &'static str,
budget: Option<Duration>,
op: F,
) -> FocrResult<T>
where
T: Send + 'static,
F: FnOnce() -> FocrResult<T> + Send + 'static,
{
self.report_stage(stage, budget);
let Some(budget) = budget else {
return self
.runtime
.block_on(async move { asupersync::runtime::spawn_blocking(op).await });
};
let stop = Arc::<StageStop>::default();
let running = Arc::clone(&stop);
let finished = self.runtime.block_on(async move {
asupersync::time::timeout(
asupersync::time::wall_now(),
budget,
asupersync::runtime::spawn_blocking(move || {
let _running = RunningStage::enter(running);
op()
}),
)
.await
});
finished.unwrap_or_else(|_| {
let grace = native_engine::document_budget_grace(budget);
if !stop.halt(grace) {
eprintln!(
"[focr] warning: the {stage} stage is still running {}s past its budget",
grace.as_secs()
);
}
Err(FocrError::Timeout(format!(
"{stage} stage exceeded {}ms budget",
budget.as_millis()
)))
})
}
}
#[cfg(all(test, feature = "native"))]
mod tests {
use super::*;
fn log_line(test: &str, phase: &str, outcome: &str, extra: &str) {
eprintln!(
"{{\"test\":\"{test}\",\"phase\":\"{phase}\",\"outcome\":\"{outcome}\"{}{extra}}}",
if extra.is_empty() { "" } else { "," }
);
}
#[test]
fn engine_owns_single_runtime_and_drops_clean() {
let engine = OcrEngine::new().expect("engine builds");
let runtime_witness = engine
.runtime
.block_on(engine.runtime.handle().spawn(async {
Runtime::current_handle().expect("spawned task has a runtime handle")
}));
let out = engine
.run_blocking_stage_with_budget("drop-probe", Some(Duration::from_secs(5)), || Ok(42u8))
.expect("stage runs");
assert_eq!(out, 42);
log_line(
"engine_owns_single_runtime_and_drops_clean",
"live",
"pass",
"",
);
drop(engine);
assert!(
matches!(
runtime_witness.try_spawn(async { 42u8 }),
Err(asupersync::runtime::state::SpawnError::RuntimeUnavailable)
),
"dropped engine runtime should reject new tasks"
);
assert!(
runtime_witness.spawn_blocking(|| {}).is_none(),
"dropped engine runtime should not accept blocking tasks"
);
assert!(
runtime_witness.blocking_handle().is_none(),
"dropped engine runtime should not expose its blocking pool"
);
log_line(
"engine_owns_single_runtime_and_drops_clean",
"dropped",
"pass",
"",
);
}
fn shutdown_flag_test() -> MutexGuard<'static, ()> {
static FLAG: Mutex<()> = Mutex::new(());
FLAG.lock().unwrap_or_else(PoisonError::into_inner)
}
#[test]
fn cancellation_token_into_closure_aborts() {
let _shutdown = shutdown_flag_test();
reset_shutdown();
let engine = OcrEngine::new().expect("engine builds");
let out = engine.run_blocking_stage_with_budget(
"cancel-probe",
Some(Duration::from_secs(10)),
|| {
for step in 0..1_000_000u64 {
cancel_checkpoint()?;
if step == 3 {
request_shutdown();
}
}
Ok(0u64)
},
);
reset_shutdown();
assert!(
matches!(out, Err(FocrError::Cancelled)),
"expected Cancelled, got {out:?}"
);
assert_eq!(FocrError::Cancelled.exit_code(), 6, "exit code contract");
log_line(
"cancellation_token_into_closure_aborts",
"aborted",
"pass",
"",
);
}
#[test]
fn bounded_stream_backpressure() {
use std::sync::atomic::{AtomicI64, Ordering};
static IN_FLIGHT: AtomicI64 = AtomicI64::new(0);
static MAX_SEEN: AtomicI64 = AtomicI64::new(0);
IN_FLIGHT.store(0, Ordering::SeqCst);
MAX_SEEN.store(0, Ordering::SeqCst);
let total = 24u32;
let mut produced = 0u32;
let n = stream_pages(
2,
move || {
if produced == total {
return Ok(None);
}
produced += 1;
let now = IN_FLIGHT.fetch_add(1, Ordering::SeqCst) + 1;
MAX_SEEN.fetch_max(now, Ordering::SeqCst);
Ok(Some(produced))
},
|item: u32| {
std::thread::sleep(Duration::from_millis(5));
IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
let _ = item;
},
)
.expect("stream completes");
assert_eq!(n, total as usize, "every page delivered in order");
let max = MAX_SEEN.load(Ordering::SeqCst);
assert!(
max <= 4,
"in-flight items {max} exceeded capacity(2)+channel slack — backpressure broken"
);
log_line(
"bounded_stream_backpressure",
"drained",
"pass",
&format!("\"max_in_flight\":{max},\"n\":{n}"),
);
}
#[test]
fn thread_budget_reads_env_then_physical() {
let budget = thread_budget();
match std::env::var("FOCR_THREADS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
{
Some(env) if env > 0 => assert_eq!(budget, env, "env wins"),
_ => assert_eq!(budget, default_thread_budget(), "the default"),
}
assert!(
default_thread_budget() <= num_cpus::get_physical(),
"the default never exceeds the physical cores"
);
assert!(budget > 0);
assert!(
budget
<= std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(usize::MAX),
"physical budget cannot exceed logical width"
);
log_line(
"thread_budget_reads_env_then_physical",
"resolved",
"pass",
&format!("\"threads\":{budget}"),
);
}
#[test]
fn default_budget_is_the_fastest_tier_on_apple_silicon() {
let fast = fastest_tier_cores();
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
let fast = fast.expect("hw.perflevel0.physicalcpu on Apple silicon");
assert!(fast <= num_cpus::get_physical(), "{fast} fast cores");
assert_eq!(default_thread_budget(), fast);
} else if cfg!(not(any(target_os = "macos", target_os = "ios"))) {
assert_eq!(fast, None);
assert_eq!(default_thread_budget(), num_cpus::get_physical());
}
log_line(
"default_budget_is_the_fastest_tier_on_apple_silicon",
"resolved",
"pass",
&format!(
"\"fastest_tier_cores\":{},\"default\":{}",
fast.map_or_else(|| "null".to_string(), |n| n.to_string()),
default_thread_budget()
),
);
}
struct TempModel(std::path::PathBuf);
impl TempModel {
fn write_focrq(bytes: &[u8]) -> std::io::Result<Self> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let path = std::env::temp_dir().join(format!(
"franken_ocr_engine_format_mismatch_{}_{}.focrq",
std::process::id(),
nanos
));
std::fs::write(&path, bytes)?;
Ok(Self(path))
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempModel {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn future_focrq_preamble() -> Vec<u8> {
let mut blob = Vec::new();
blob.extend_from_slice(native_engine::weights::FOCRQ_MAGIC);
blob.extend_from_slice(&(native_engine::weights::FOCRQ_FORMAT_VERSION + 1).to_le_bytes());
blob.push(0);
blob.extend_from_slice(&[0u8; 32]);
blob.extend_from_slice(&0u64.to_le_bytes());
blob
}
#[test]
fn engine_constructs_without_model() {
let engine = OcrEngine::new().expect("runtime builds");
assert!(
engine.model_guard().expect("mutex").is_none(),
"model must be loaded lazily, not at construction"
);
}
#[test]
fn recognize_missing_model_is_clean_model_not_found() {
let engine = OcrEngine::new().expect("runtime builds");
let err = engine
.recognize_with_model(
Path::new("/nonexistent/franken_ocr/model.focrq"),
Path::new("/some/document.png"),
)
.expect_err("absent model must error");
assert!(
matches!(err, FocrError::ModelNotFound(_)),
"expected ModelNotFound, got {err:?}"
);
assert_eq!(err.exit_code(), 3, "ModelNotFound must map to exit code 3");
}
#[test]
fn public_recognize_without_weights_is_model_not_found() {
if std::env::var_os(MODEL_PATH_ENV).is_none()
&& !std::path::Path::new(DEFAULT_MODEL_PATH).exists()
&& native_engine::OcrModel::resolve_model(Path::new(DEFAULT_MODEL_PATH)).is_err()
{
let engine = OcrEngine::new().expect("runtime builds");
let err = engine
.recognize(Path::new("/some/document.png"))
.expect_err("absent default model must error");
assert!(matches!(err, FocrError::ModelNotFound(_)));
}
}
#[test]
fn model_path_falls_back_to_default_when_env_unset() {
if std::env::var_os(MODEL_PATH_ENV).is_none() {
assert_eq!(
OcrEngine::model_path(),
std::path::PathBuf::from(DEFAULT_MODEL_PATH)
);
}
}
#[test]
fn repeated_missing_model_stays_model_not_found() {
let engine = OcrEngine::new().expect("runtime builds");
let p = Path::new("/nonexistent/franken_ocr/model.focrq");
let img = Path::new("/some/document.png");
for _ in 0..3 {
let err = engine.recognize_with_model(p, img).expect_err("absent");
assert!(matches!(err, FocrError::ModelNotFound(_)));
}
}
#[test]
fn recognize_pages_stops_on_the_first_page_without_a_model() {
let engine = OcrEngine::new().expect("runtime builds");
let blank = || image::DynamicImage::new_rgb8(8, 8);
let source = page_source::PageSource::rasters(vec![blank(), blank()]).expect("a source");
let mut started = 0;
let err = engine
.recognize_pages(
Path::new("/nonexistent/franken_ocr/model.focrq"),
source,
&mut |event| {
if matches!(event, page_source::PageEvent::Started(_)) {
started += 1;
}
},
)
.expect_err("absent model must error");
assert!(matches!(err, FocrError::ModelNotFound(_)), "got {err:?}");
assert_eq!(started, 1);
}
#[test]
fn blocking_stage_runs_on_runtime_blocking_pool() {
let engine = OcrEngine::new().expect("runtime builds");
let thread_name = engine
.run_blocking_stage_with_budget("test", Some(Duration::from_secs(1)), || {
let thread = std::thread::current();
Ok(thread.name().unwrap_or("<unnamed>").to_string())
})
.expect("stage should complete");
assert!(
thread_name.contains("-blocking-"),
"stage ran on {thread_name:?}, not the runtime blocking pool"
);
}
#[test]
fn blocking_stage_timeout_maps_to_stable_error() {
let engine = OcrEngine::new().expect("runtime builds");
let started = std::time::Instant::now();
let err = engine
.run_blocking_stage_with_budget("test-timeout", Some(Duration::from_millis(10)), || {
std::thread::sleep(Duration::from_millis(100));
Ok(())
})
.expect_err("slow blocking stage must time out");
assert!(
matches!(err, FocrError::Timeout(_)),
"expected Timeout, got {err:?}"
);
assert_eq!(err.exit_code(), error::EXIT_TIMEOUT);
assert!(
started.elapsed() < Duration::from_millis(500),
"timeout wrapper waited for the whole blocking closure"
);
}
#[test]
fn a_stage_past_its_budget_stops_before_the_timeout_returns() {
struct Ended(Arc<AtomicBool>);
impl Drop for Ended {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
let _shutdown = shutdown_flag_test();
let engine = OcrEngine::new().expect("runtime builds");
let ended = Arc::new(AtomicBool::new(false));
let witness = Ended(Arc::clone(&ended));
let err = engine
.run_blocking_stage_with_budget(
"test-runaway",
Some(Duration::from_millis(10)),
move || {
let _witness = witness;
for _ in 0..3_000 {
cancel_checkpoint()?;
std::thread::sleep(Duration::from_millis(1));
}
Ok(())
},
)
.expect_err("the stage must time out");
assert!(matches!(err, FocrError::Timeout(_)), "got {err:?}");
assert!(err.to_string().contains("test-runaway stage exceeded"));
assert!(
ended.load(Ordering::SeqCst),
"the decode outlived its timeout"
);
engine
.run_blocking_stage_with_budget(
"test-next",
Some(Duration::from_secs(5)),
cancel_checkpoint,
)
.expect("the next stage is not stopped");
}
#[test]
fn a_stage_stuck_between_checkpoints_cannot_hold_its_timeout() {
let stuck = Arc::<StageStop>::default();
assert!(!stuck.halt(Duration::from_millis(5)));
assert!(stuck.stop_requested());
let stage = Arc::<StageStop>::default();
drop(RunningStage::enter(Arc::clone(&stage)));
assert!(stage.halt(Duration::from_secs(5)));
}
#[test]
fn public_engine_preserves_focrq_format_mismatch_robot_code()
-> Result<(), Box<dyn std::error::Error>> {
let model = TempModel::write_focrq(&future_focrq_preamble())?;
let engine = OcrEngine::new()?;
let result = engine.recognize_with_model(model.path(), Path::new("/some/document.png"));
let Err(err) = result else {
return Err(std::io::Error::other(
"future .focrq version unexpectedly succeeded before forward",
)
.into());
};
assert!(
matches!(err, FocrError::FormatMismatch(_)),
"expected FormatMismatch, got {err:?}"
);
assert_eq!(err.exit_code(), error::EXIT_FORMAT_MISMATCH);
let event = robot::run_error_event(&err);
assert_eq!(event["event"], "run_error");
assert_eq!(event["error_kind"], "format_mismatch");
assert_eq!(event["code"], error::EXIT_FORMAT_MISMATCH);
Ok(())
}
}