use crate::batch_events::{BatchEvents, BatchMode, BatchRun};
use crate::native_engine::Half;
use crate::page_source::{PageEvent, PageItem, PageSource, PageStep};
use crate::{
FOCR_MODEL_LICENSE_NOTICE, FOCR_PROJECT_LICENSE_NOTICE, FocrError, FocrResult, OcrEngine, dist,
native_engine, pdf, progress, quant, robot, simd,
};
use clap::{Args, Parser, Subcommand, ValueEnum};
use std::ffi::OsString;
use std::io::{IsTerminal as _, Read as _};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
#[cfg(debug_assertions)]
const FORCE_TEST_ERROR_ENV: &str = "FOCR_TEST_FORCE_ERROR";
pub fn cli_main() -> ExitCode {
if is_exact_long_version_request(std::env::args_os()) {
let report = long_version_report();
let mut lines = report.lines();
if let Some(version_line) = lines.next() {
println!("{version_line}");
}
for license_line in lines {
eprintln!("{license_line}");
}
return ExitCode::SUCCESS;
}
let _ = ctrlc::set_handler(|| {
if crate::shutdown_requested() {
std::process::exit(130);
}
crate::request_shutdown();
progress::suppress_for_interrupt();
let _ = std::thread::Builder::new()
.name("focr-interrupt-notice".into())
.spawn(|| {
progress::stderr_message(format_args!(
"focr: interrupt received — finishing the current step then aborting \
(Ctrl+C again to force)"
));
});
});
let cli = Cli::parse();
let error_mode = ErrorMode::from_cli(&cli);
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => exit_code_from_error(&err, error_mode),
}
}
#[derive(Parser)]
#[command(
name = "focr",
version,
about = "Pure-Rust, CPU-hyper-optimized runner for the Baidu Unlimited-OCR model",
after_help = "Agent orientation: `focr robot triage` (one JSON object: quick reference, live \
health, next commands, exit codes)\nMachine contract: `focr robot schema` | \
Kernel proof on this CPU: `focr robot selftest`\nModel zoo + pull status: \
`focr models --json` | Self-check/repair: `focr doctor`"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
fn is_exact_long_version_request<I>(mut args: I) -> bool
where
I: Iterator<Item = OsString>,
{
let _program = args.next();
matches!(
(args.next().as_deref(), args.next()),
(Some(arg), None) if arg == "--version"
)
}
#[must_use]
pub fn long_version_report() -> String {
format!(
"focr {}\nsource_license: {}\nmodel_license: {}\n",
env!("CARGO_PKG_VERSION"),
FOCR_PROJECT_LICENSE_NOTICE,
crate::model_arch::default_arch().license_notice()
)
}
#[derive(Subcommand)]
pub enum Command {
Ocr(OcrArgs),
OcrBatch(OcrBatchArgs),
Convert(ConvertArgs),
Pull(PullArgs),
Models(ModelsArgs),
Robot {
#[command(subcommand)]
cmd: RobotCmd,
},
Runs(RunsArgs),
Sync(SyncArgs),
Doctor(DoctorArgs),
#[command(hide = true)]
ResidentDaemon(ResidentDaemonArgs),
}
#[derive(Clone, Debug, Args)]
pub struct ResidentDaemonArgs {
#[arg(long)]
pub model_root: PathBuf,
}
#[derive(Clone, Debug, Args)]
pub struct OcrArgs {
#[command(flatten)]
pub request: OcrRequestArgs,
#[arg(long)]
pub json: bool,
#[arg(short = 'o', long)]
pub output: Option<PathBuf>,
#[arg(long)]
pub extract_figures: bool,
#[arg(long, value_name = "DIR")]
pub figures_dir: Option<PathBuf>,
#[arg(long)]
pub robot: bool,
#[arg(long)]
pub check: bool,
#[arg(long)]
pub raw: bool,
}
#[derive(Clone, Debug, Args)]
pub struct OcrBatchArgs {
#[arg(required_unless_present = "inputs_from")]
pub images: Vec<PathBuf>,
#[arg(long, value_name = "FILE")]
pub inputs_from: Option<PathBuf>,
#[arg(long, value_name = "DIR")]
pub output_dir: Option<PathBuf>,
#[arg(long)]
pub fail_fast: bool,
#[arg(long)]
pub model: Option<PathBuf>,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub experimental_full_int8: bool,
#[arg(long)]
pub multi_page: bool,
#[arg(long, value_name = "PATH")]
pub progress_events: Option<PathBuf>,
#[arg(long, value_name = "N")]
pub threads: Option<std::num::NonZeroUsize>,
#[arg(long)]
pub timing: bool,
}
#[derive(Clone, Debug, Args)]
pub struct ModelsArgs {
#[arg(long)]
pub json: bool,
}
#[derive(Clone, Debug, Args)]
pub struct PullArgs {
pub model: Option<String>,
#[arg(long, default_value = dist::DEFAULT_QUANT)]
pub quant: String,
#[arg(long)]
pub manifest: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Clone, Debug, Args)]
pub struct RobotRunArgs {
#[command(flatten)]
pub request: OcrRequestArgs,
}
#[derive(Clone, Debug, Args)]
pub struct OcrRequestArgs {
pub image: PathBuf,
#[arg(long)]
pub model: Option<PathBuf>,
#[arg(long)]
pub base_size: Option<i64>,
#[arg(long)]
pub image_size: Option<i64>,
#[arg(long, value_enum)]
pub crop_mode: Option<CropMode>,
#[arg(long)]
pub max_length: Option<i64>,
#[arg(long)]
pub temperature: Option<f32>,
#[arg(long, env = "FOCR_NO_REPEAT_NGRAM")]
pub no_repeat_ngram: Option<i64>,
#[arg(long)]
pub ngram_window: Option<i64>,
#[arg(long)]
pub max_page_tokens: Option<i64>,
#[arg(long)]
pub format: bool,
#[arg(long, value_enum, default_value_t = OcrTask::Ocr)]
pub task: OcrTask,
#[arg(long)]
pub question: Option<String>,
#[arg(long = "box", value_name = "X1,Y1,X2,Y2")]
pub region_box: Option<String>,
#[arg(long, value_enum, value_name = "COLOR", conflicts_with = "region_box")]
pub color: Option<RegionColor>,
#[arg(long)]
pub pages: Option<String>,
#[arg(long)]
pub split_spreads: bool,
#[arg(long)]
pub multi_page: bool,
#[arg(long, value_name = "N|auto|off")]
pub page_window: Option<native_engine::PageWindow>,
#[arg(
long,
value_enum,
value_name = "MODE",
num_args = 0..=1,
require_equals = true,
default_missing_value = "matching"
)]
pub resume: Option<ResumeMode>,
#[arg(long)]
pub keep_checkpoint: bool,
#[arg(long)]
pub no_resident: bool,
#[arg(long, value_name = "N")]
pub threads: Option<std::num::NonZeroUsize>,
#[arg(long)]
pub timing: bool,
#[arg(long)]
pub fail_on_low_yield: bool,
}
#[derive(Clone, Debug)]
pub struct OcrRequest {
pub image: PathBuf,
pub model: Option<PathBuf>,
pub base_size: Option<u32>,
pub image_size: Option<u32>,
pub crop_mode: Option<CropMode>,
pub max_length: Option<u32>,
pub temperature: Option<f32>,
pub no_repeat_ngram: Option<u32>,
pub ngram_window: Option<u32>,
pub max_page_tokens: Option<u32>,
pub format: bool,
pub square_pad: bool,
pub region: Option<native_engine::got::Region>,
pub question: Option<String>,
pub pages: Option<String>,
pub split_spreads: bool,
pub multi_page: bool,
pub page_window: Option<native_engine::PageWindow>,
pub resume: Option<ResumeMode>,
pub keep_checkpoint: bool,
pub no_resident: bool,
}
impl OcrArgs {
fn to_request(&self) -> FocrResult<OcrRequest> {
self.request.to_request()
}
}
impl RobotRunArgs {
fn into_ocr_args(self) -> OcrArgs {
OcrArgs {
request: self.request,
json: false,
output: None,
extract_figures: false,
figures_dir: None,
robot: true,
check: false,
raw: false,
}
}
}
fn preprocess_overrides_from(request: &OcrRequest) -> native_engine::PreprocessOverrides {
native_engine::PreprocessOverrides {
base_size: request.base_size.map(|v| v as usize),
image_size: request.image_size.map(|v| v as usize),
gundam: request.crop_mode.map(|mode| mode == CropMode::Gundam),
}
}
fn decode_overrides_from(request: &OcrRequest) -> native_engine::DecodeOverrides {
native_engine::DecodeOverrides {
max_length: request.max_length.map(|v| v as usize),
temperature: request.temperature,
no_repeat_ngram: request.no_repeat_ngram.map(|v| v as usize),
ngram_window: request.ngram_window.map(|v| v as usize),
max_page_tokens: request.max_page_tokens.map(|v| v as usize),
}
}
impl OcrRequestArgs {
fn to_request(&self) -> FocrResult<OcrRequest> {
validate_task_selection(self.task, self.effective_model_spec().as_deref())?;
if self.question.is_some() && self.task != OcrTask::Describe {
return Err(FocrError::Usage(
"--question is the smolvlm2 VQA prompt and requires --task describe".into(),
));
}
let region = self.region()?;
if let Some(region) = region {
validate_region(region, self.task, self.effective_model_spec().as_deref())?;
}
Ok(OcrRequest {
image: self.image.clone(),
model: self.model.clone(),
base_size: self
.base_size
.map(|v| positive_u32("base-size", v))
.transpose()?,
image_size: self
.image_size
.map(|v| positive_u32("image-size", v))
.transpose()?,
crop_mode: self.crop_mode,
max_length: self
.max_length
.map(|v| positive_u32("max-length", v))
.transpose()?,
temperature: self.temperature.map(greedy_temperature).transpose()?,
no_repeat_ngram: self
.no_repeat_ngram
.map(|v| non_negative_u32("no-repeat-ngram", v))
.transpose()?,
ngram_window: self
.ngram_window
.map(|v| non_negative_u32("ngram-window", v))
.transpose()?,
max_page_tokens: self
.max_page_tokens
.map(|v| non_negative_u32("max-page-tokens", v))
.transpose()?,
format: self.format || self.task.implies_got_format(),
square_pad: self.task.pads_got_image(),
region,
question: self.question.clone(),
pages: self.pages.clone(),
split_spreads: self.split_spreads,
multi_page: self.multi_page,
page_window: self.page_window,
resume: self.resume,
keep_checkpoint: self.keep_checkpoint,
no_resident: self.no_resident,
})
}
fn effective_model_spec(&self) -> Option<PathBuf> {
self.model
.clone()
.or_else(|| std::env::var_os(crate::MODEL_PATH_ENV).map(PathBuf::from))
}
fn region(&self) -> FocrResult<Option<native_engine::got::Region>> {
if let Some(color) = self.color {
return Ok(Some(native_engine::got::Region::Color(color.frame())));
}
self.region_box.as_deref().map(parse_region_box).transpose()
}
}
fn parse_region_box(text: &str) -> FocrResult<native_engine::got::Region> {
use native_engine::got::Region;
let usage = |why: String| {
FocrError::Usage(format!(
"--box {text:?}: {why}. It takes x1,y1,x2,y2 (top left, bottom right) or a point \
x,y, in pixels of the input image"
))
};
let values = text
.trim()
.trim_start_matches('[')
.trim_end_matches(']')
.split(',')
.map(|v| {
v.trim()
.parse::<f64>()
.ok()
.filter(|v| v.is_finite() && *v >= 0.0)
})
.collect::<Option<Vec<f64>>>()
.ok_or_else(|| usage("every value must be a non-negative number".into()))?;
match values[..] {
[x, y] => Ok(Region::Point([x, y])),
[x1, y1, x2, y2] if x1 < x2 && y1 < y2 => Ok(Region::Box([x1, y1, x2, y2])),
[_, _, _, _] => Err(usage(
"the second corner must be below and to the right of the first".into(),
)),
_ => Err(usage(format!("{} values, not 4 or 2", values.len()))),
}
}
fn validate_region(
region: native_engine::got::Region,
task: OcrTask,
model_spec: Option<&Path>,
) -> FocrResult<()> {
if task.pads_got_image() && !matches!(region, native_engine::got::Region::Color(_)) {
return Err(FocrError::Usage(format!(
"--box names pixels of the image as given, and --task {task} pads it to a square \
first. Use --format for a region's structured output"
)));
}
if let Some(models) = task_models(task).filter(|models| !models.contains(&"got-ocr2")) {
return Err(FocrError::Usage(format!(
"--box and --color are got-ocr2's fine-grained OCR, and --task {task} runs {}",
models.join(" or ")
)));
}
if model_spec_is_knowably_not_got(model_spec) {
return Err(FocrError::Usage(
"--box and --color are got-ocr2's fine-grained OCR, but this run would use the \
plain-text unlimited-ocr model. Run `focr pull got-ocr2`, then re-run with \
`--model got-ocr2.int8.focrq` (see `focr models`)"
.into(),
));
}
Ok(())
}
fn validate_region_for_header(request: &OcrRequest) -> FocrResult<()> {
if request.region.is_none() {
return Ok(());
}
let spec = request.model.clone().unwrap_or_else(OcrEngine::model_path);
match native_engine::model_id_at(&spec) {
Some(id) if id != "got-ocr2" => Err(FocrError::Usage(format!(
"--box and --color are got-ocr2's fine-grained OCR, but {} holds {id}. Re-run \
with `--model got-ocr2.int8.focrq` (see `focr models`)",
spec.display()
))),
_ => Ok(()),
}
}
fn validate_task_selection(task: OcrTask, model_spec: Option<&Path>) -> FocrResult<()> {
if task == OcrTask::ChartData && model_spec_is_knowably_not_onechart(model_spec) {
return Err(FocrError::Usage(
"--task chart-data (chart→dict + number-head self-verify) needs the onechart \
model, but this run would use a different model. Re-run with \
`--model onechart.int8.focrq` (see `focr models`)"
.into(),
));
}
if task == OcrTask::Describe && model_spec_is_knowably_not_smolvlm2(model_spec) {
return Err(FocrError::Usage(
"--task describe (photo description/VQA) needs the smolvlm2 model, but this \
run would use a different model. Re-run with `--model smolvlm2.int8.focrq` \
(see `focr models`)"
.into(),
));
}
if task == OcrTask::Music {
if model_spec_is_knowably_not_got(model_spec)
&& model_spec_is_knowably_not_tromr(model_spec)
{
return Err(FocrError::Usage(
"--task music needs the tromr (native OMR -> MusicXML) or got-ocr2 \
(sheet-music format mode) model, but this run would use a different \
model. Re-run with `--model tromr.focrq` (see `focr models`)"
.into(),
));
}
return Ok(());
}
if task.implies_got_format() && model_spec_is_knowably_not_got(model_spec) {
return Err(FocrError::Usage(format!(
"--task {task} needs the got-ocr2 model, but this run would use the plain-text \
unlimited-ocr model. Run `focr pull got-ocr2`, then re-run with \
`--model got-ocr2.int8.focrq` (see `focr models`)"
)));
}
Ok(())
}
fn task_models(task: OcrTask) -> Option<&'static [&'static str]> {
match task {
OcrTask::Ocr => None,
OcrTask::Describe => Some(&["smolvlm2"]),
OcrTask::ChartData => Some(&["onechart"]),
OcrTask::Music => Some(&["tromr", "got-ocr2"]),
OcrTask::Formula
| OcrTask::Tables
| OcrTask::Chart
| OcrTask::Molecular
| OcrTask::Geometry => Some(&["got-ocr2"]),
}
}
fn validate_task_for_header(task: OcrTask, request: &OcrRequest) -> FocrResult<()> {
let Some(models) = task_models(task) else {
return Ok(());
};
let spec = request.model.clone().unwrap_or_else(OcrEngine::model_path);
match native_engine::model_id_at(&spec) {
Some(id) if !models.contains(&id) => Err(FocrError::Usage(format!(
"--task {task} needs {}, but {} holds {id}. Re-run with a model that serves it \
(see `focr models`)",
models.join(" or "),
spec.display()
))),
_ => Ok(()),
}
}
fn model_spec_is_knowably_not_onechart(spec: Option<&Path>) -> bool {
let Some(path) = spec else {
return true;
};
let Some(name) = path.file_name() else {
return false;
};
let name = name.to_string_lossy().to_ascii_lowercase();
!name.contains("onechart")
&& (name.contains("unlimited") || name.contains("got") || name.contains("smolvlm"))
}
fn model_spec_is_knowably_not_tromr(spec: Option<&Path>) -> bool {
let Some(path) = spec else {
return true;
};
let Some(name) = path.file_name() else {
return false;
};
let name = name.to_string_lossy().to_ascii_lowercase();
!name.contains("tromr")
&& (name.contains("unlimited")
|| name.contains("got")
|| name.contains("smolvlm")
|| name.contains("onechart"))
}
fn model_spec_is_knowably_not_smolvlm2(spec: Option<&Path>) -> bool {
let Some(path) = spec else {
return true;
};
let Some(name) = path.file_name() else {
return false;
};
let name = name.to_string_lossy().to_ascii_lowercase();
!name.contains("smolvlm") && (name.contains("unlimited") || name.contains("got"))
}
fn model_spec_is_knowably_not_got(spec: Option<&Path>) -> bool {
let Some(path) = spec else {
return true;
};
let Some(name) = path.file_name() else {
return false;
};
let name = name.to_string_lossy().to_ascii_lowercase();
name.contains("unlimited") && !name.contains("got")
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum ResumeMode {
Matching,
Recompute,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum RegionColor {
Red,
Green,
Blue,
}
impl RegionColor {
fn frame(self) -> native_engine::got::FrameColor {
match self {
Self::Red => native_engine::got::FrameColor::Red,
Self::Green => native_engine::got::FrameColor::Green,
Self::Blue => native_engine::got::FrameColor::Blue,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum CropMode {
Gundam,
Base,
}
impl std::fmt::Display for CropMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Gundam => "gundam",
Self::Base => "base",
})
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum OcrTask {
Ocr,
Formula,
Tables,
Chart,
Molecular,
Geometry,
Music,
Describe,
ChartData,
}
impl OcrTask {
fn implies_got_format(self) -> bool {
matches!(
self,
Self::Formula
| Self::Tables
| Self::Chart
| Self::Molecular
| Self::Geometry
| Self::Music
)
}
fn pads_got_image(self) -> bool {
matches!(self, Self::Chart | Self::Formula)
}
}
impl std::fmt::Display for OcrTask {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Ocr => "ocr",
Self::Formula => "formula",
Self::Tables => "tables",
Self::Chart => "chart",
Self::Molecular => "molecular",
Self::Geometry => "geometry",
Self::Music => "music",
Self::Describe => "describe",
Self::ChartData => "chart-data",
})
}
}
#[derive(Clone, Debug, Args)]
pub struct ConvertArgs {
pub input: PathBuf,
#[arg(short, long)]
pub output: PathBuf,
#[arg(long, value_enum, default_value_t = QuantTarget::Int8)]
pub quant: QuantTarget,
#[arg(long, value_enum, default_value_t = ArchTarget::Generic)]
pub arch: ArchTarget,
#[arg(long)]
pub model_id: Option<String>,
#[arg(long, value_name = "FILE")]
pub calib: Option<PathBuf>,
#[arg(long)]
pub json: bool,
}
#[derive(Clone, Debug, Args)]
pub struct RunsArgs {
#[arg(long)]
pub id: Option<String>,
#[arg(long, default_value_t = 20)]
pub limit: i64,
#[arg(long, value_enum, default_value_t = OutputFormat::Plain)]
pub format: OutputFormat,
#[arg(long)]
pub json: bool,
}
#[derive(Clone, Debug, Args)]
pub struct SyncArgs {
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
pub cmd: SyncCmd,
}
#[derive(Clone, Debug, Args)]
pub struct DoctorArgs {
#[arg(long)]
pub json: bool,
#[arg(long)]
pub fix: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub robot_triage: bool,
#[command(subcommand)]
pub cmd: Option<DoctorCmd>,
}
#[derive(Clone, Debug, Eq, PartialEq, Subcommand)]
pub enum DoctorCmd {
Undo {
run_id: String,
},
Capabilities,
RobotDocs,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum OutputFormat {
Plain,
Json,
Ndjson,
}
impl std::fmt::Display for OutputFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Plain => "plain",
Self::Json => "json",
Self::Ndjson => "ndjson",
})
}
}
#[derive(Clone, Debug, Eq, PartialEq, Subcommand)]
pub enum SyncCmd {
ExportJsonl {
#[arg(long)]
file: Option<std::path::PathBuf>,
},
ImportJsonl {
#[arg(long)]
file: std::path::PathBuf,
},
}
#[derive(Subcommand)]
pub enum RobotCmd {
Run(Box<RobotRunArgs>),
Schema,
Health,
Backends,
Selftest,
Triage,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum QuantTarget {
Int8,
Int4,
}
impl QuantTarget {
fn as_str(self) -> &'static str {
match self {
Self::Int8 => "int8",
Self::Int4 => "int4",
}
}
}
impl std::fmt::Display for QuantTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum ArchTarget {
Generic,
Aarch64Smmla,
X86Vnni,
X86Amx,
}
impl ArchTarget {
fn as_str(self) -> &'static str {
match self {
Self::Generic => "generic",
Self::Aarch64Smmla => "aarch64-smmla",
Self::X86Vnni => "x86-vnni",
Self::X86Amx => "x86-amx",
}
}
fn packing_byte(self) -> u8 {
match self {
Self::Generic => 0,
Self::Aarch64Smmla => 1,
Self::X86Vnni => 2,
Self::X86Amx => 3,
}
}
}
impl std::fmt::Display for ArchTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
pub fn run(cli: Cli) -> FocrResult<()> {
match cli.command {
Command::Robot {
cmd: RobotCmd::Run(args),
} => {
emit(&robot::run_start_event("ocr"));
run_ocr((*args).into_ocr_args(), true)
}
Command::Robot {
cmd: RobotCmd::Schema,
} => {
emit(&robot::robot_schema());
Ok(())
}
Command::Robot {
cmd: RobotCmd::Health,
} => {
emit(&robot_health_payload());
Ok(())
}
Command::Robot {
cmd: RobotCmd::Backends,
} => {
emit(&robot_backends_payload());
Ok(())
}
Command::Robot {
cmd: RobotCmd::Selftest,
} => run_robot_selftest(),
Command::Robot {
cmd: RobotCmd::Triage,
} => {
emit(&robot_triage_payload());
Ok(())
}
Command::Ocr(args) if args.robot => {
emit(&robot::run_start_event("ocr"));
run_ocr(args, true)
}
Command::Ocr(args) => run_ocr(args, false),
Command::OcrBatch(args) => run_ocr_batch(args),
Command::Convert(args) => run_convert(&args),
Command::Pull(args) => run_pull(&args),
Command::Models(args) => run_models(&args),
Command::Runs(args) => run_runs(&args),
Command::Sync(args) => run_sync(&args),
Command::Doctor(args) => run_doctor(&args),
Command::ResidentDaemon(args) => crate::resident::run_daemon(&args.model_root),
}
}
enum Recognition {
Single(native_engine::RecognizedDocument),
Document(DocumentRecognition),
}
impl Recognition {
fn markdown(&self) -> &str {
match self {
Recognition::Single(doc) => &doc.markdown,
Recognition::Document(document) => &document.markdown,
}
}
fn raw(&self) -> &str {
match self {
Recognition::Single(doc) => &doc.raw,
Recognition::Document(document) => &document.raw,
}
}
fn text(&self, raw: bool) -> &str {
if raw { self.raw() } else { self.markdown() }
}
fn unstreamed_page_events(&self) -> Vec<serde_json::Value> {
match self {
Recognition::Single(doc) => vec![robot::page_decoded_event(1, doc.raw.trim())],
Recognition::Document(document) => match &document.pages {
DocumentPageList::Layouts(pages) => pages
.iter()
.map(|page| {
let mut event = robot::page_decoded_event(page.page, page.raw.trim());
if let Some(half) = page.half {
event["half"] = half.into();
}
event
})
.collect(),
DocumentPageList::Read(_) => Vec::new(),
},
}
}
fn to_json(&self, figures: &[WrittenFigure], raw: bool) -> serde_json::Value {
let mut value = match self {
Recognition::Single(doc) => serde_json::json!({
"schema_version": robot::ROBOT_SCHEMA_VERSION,
"markdown": doc.markdown,
"layout": layout_to_json(&doc.layout),
}),
Recognition::Document(document) => {
let pages: Vec<serde_json::Value> = match &document.pages {
DocumentPageList::Layouts(layouts) => layouts
.iter()
.map(|p| {
let mut page = serde_json::json!({
"page": p.page,
"layout": layout_to_json(&p.layout),
});
if let Some(half) = p.half {
page["half"] = serde_json::json!(half);
}
if raw {
page["raw"] = p.raw.as_str().into();
}
page
})
.collect(),
DocumentPageList::Read(pages) => pages
.iter()
.map(|p| {
let mut page = p.to_json();
if raw {
page["raw"] = p.raw.as_str().into();
}
page
})
.collect(),
};
serde_json::json!({
"schema_version": robot::ROBOT_SCHEMA_VERSION,
"markdown": document.markdown,
"pages": pages,
})
}
};
if raw {
value["raw"] = self.raw().into();
}
if !figures.is_empty()
&& let Some(obj) = value.as_object_mut()
{
let arr: Vec<serde_json::Value> = figures
.iter()
.map(|f| {
let mut figure = serde_json::json!({
"label": f.label,
"page": f.page,
"bbox": f.bbox,
"path": f.path,
});
if let Some(half) = f.half {
figure["half"] = half.as_str().into();
}
figure
})
.collect();
obj.insert("figures".to_string(), serde_json::Value::Array(arr));
}
value
}
}
fn layout_to_json(layout: &[native_engine::LayoutSpan]) -> serde_json::Value {
serde_json::Value::Array(
layout
.iter()
.map(native_engine::LayoutSpan::to_json)
.collect(),
)
}
fn output_is_json(output: Option<&Path>) -> bool {
output
.and_then(Path::extension)
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
}
#[derive(Clone, Copy)]
struct OutputShape {
json: bool,
raw: bool,
}
impl OutputShape {
fn name(self) -> &'static str {
match (self.json, self.raw) {
(true, _) => "json",
(false, true) => "raw decode",
(false, false) => "markdown",
}
}
}
fn write_ocr_output(
path: &Path,
rec: &Recognition,
shape: OutputShape,
figures: &[WrittenFigure],
extras: RunExtras<'_>,
) -> FocrResult<()> {
let contents = if shape.json {
let mut value = rec.to_json(figures, shape.raw);
extras.merge_into(&mut value);
let mut s = serde_json::to_string_pretty(&value).map_err(|e| {
FocrError::Other(anyhow::anyhow!(
"serializing OCR JSON for {}: {e}",
path.display()
))
})?;
s.push('\n');
s
} else {
let text = rec.text(shape.raw);
if text.ends_with('\n') {
text.to_string()
} else {
format!("{text}\n")
}
};
std::fs::write(path, contents).map_err(|e| {
FocrError::Other(anyhow::anyhow!(
"writing OCR output to {}: {e}",
path.display()
))
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FigureFormat {
Png,
Jpeg,
}
impl FigureFormat {
fn ext(self) -> &'static str {
match self {
FigureFormat::Png => "png",
FigureFormat::Jpeg => "jpg",
}
}
}
fn choose_figure_format(img: &image::DynamicImage) -> FigureFormat {
let rgb = img.to_rgb8();
let total = u64::from(rgb.width()) * u64::from(rgb.height());
if total == 0 {
return FigureFormat::Png;
}
let step = total.div_ceil(4096).max(1) as usize;
let mut seen = std::collections::HashSet::new();
let mut sampled = 0u64;
for px in rgb.pixels().step_by(step) {
let [r, g, b] = px.0;
let key = (u32::from(r >> 3) << 10) | (u32::from(g >> 3) << 5) | u32::from(b >> 3);
seen.insert(key);
sampled += 1;
}
if sampled == 0 {
return FigureFormat::Png;
}
let ratio = seen.len() as f64 / sampled as f64;
if seen.len() <= 64 || ratio < 0.10 {
FigureFormat::Png
} else {
FigureFormat::Jpeg
}
}
fn write_figure(img: &image::DynamicImage, path: &Path, fmt: FigureFormat) -> FocrResult<()> {
let file = std::fs::File::create(path)
.map_err(|e| FocrError::Other(anyhow::anyhow!("create figure {}: {e}", path.display())))?;
let mut writer = std::io::BufWriter::new(file);
let enc = |e: image::ImageError| {
FocrError::Other(anyhow::anyhow!("encode figure {}: {e}", path.display()))
};
match fmt {
FigureFormat::Jpeg => {
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut writer, 85)
.encode_image(img)
.map_err(enc)?;
}
FigureFormat::Png => {
img.write_to(&mut writer, image::ImageFormat::Png)
.map_err(enc)?;
}
}
Ok(())
}
struct WrittenFigure {
label: String,
page: usize,
half: Option<Half>,
bbox: [i64; 4],
path: String,
}
#[derive(Debug)]
struct FigurePlan {
dir: PathBuf,
ref_prefix: String,
}
impl FigurePlan {
fn resolve(args: &OcrArgs) -> FocrResult<Option<FigurePlan>> {
if !args.extract_figures && args.figures_dir.is_none() {
return Ok(None);
}
let output = args.output.as_deref();
let plan = if let Some(dir_arg) = args.figures_dir.as_deref() {
let ref_prefix = with_trailing_slash(&dir_arg.to_string_lossy());
let dir = if dir_arg.is_absolute() {
dir_arg.to_path_buf()
} else {
output_parent(output).join(dir_arg)
};
FigurePlan { dir, ref_prefix }
} else {
let Some(out) = output else {
return Err(FocrError::Usage(
"--extract-figures needs -o/--output to derive the figures \
subfolder; pass --figures-dir DIR for a stdout run"
.to_string(),
));
};
let stem = out
.file_stem()
.map_or_else(|| "ocr".to_string(), |s| s.to_string_lossy().into_owned());
let dirname = format!("{stem}_figures");
let dir = output_parent(output).join(&dirname);
FigurePlan {
dir,
ref_prefix: format!("{dirname}/"),
}
};
Ok(Some(plan))
}
fn writer(&self) -> FigureWriter {
FigureWriter {
dir: self.dir.clone(),
ref_prefix: self.ref_prefix.clone(),
created: false,
written: Vec::new(),
}
}
}
fn output_parent(output: Option<&Path>) -> PathBuf {
output
.and_then(Path::parent)
.filter(|p| !p.as_os_str().is_empty())
.map_or_else(|| PathBuf::from("."), Path::to_path_buf)
}
fn with_trailing_slash(s: &str) -> String {
if s.is_empty() || s.ends_with('/') {
s.to_string()
} else {
format!("{s}/")
}
}
struct FigureWriter {
dir: PathBuf,
ref_prefix: String,
created: bool,
written: Vec<WrittenFigure>,
}
impl FigureWriter {
fn ensure_dir(&mut self) -> FocrResult<()> {
if !self.created {
std::fs::create_dir_all(&self.dir).map_err(|e| {
FocrError::Other(anyhow::anyhow!(
"create figures dir {}: {e}",
self.dir.display()
))
})?;
self.created = true;
}
Ok(())
}
fn process_page(
&mut self,
page: usize,
half: Option<Half>,
markdown: &str,
figures: Vec<native_engine::ExtractedFigure>,
) -> FocrResult<String> {
let mut md = markdown.to_string();
let side = half
.map(Half::initial)
.map(String::from)
.unwrap_or_default();
for fig in figures {
let fignum = fig.index + 1;
let fmt = choose_figure_format(&fig.image);
let name = format!("page{page}{side}_figure_{fignum}.{}", fmt.ext());
self.ensure_dir()?;
write_figure(&fig.image, &self.dir.join(&name), fmt)?;
let rel = format!("{}{name}", self.ref_prefix);
md = md.replace(&fig.markdown_ref, &format!(""));
self.written.push(WrittenFigure {
label: fig.label,
page,
half,
bbox: fig.bbox,
path: rel,
});
}
Ok(md)
}
fn into_written(self) -> Vec<WrittenFigure> {
self.written
}
}
fn apply_tuning(threads: Option<std::num::NonZeroUsize>, timing: bool) -> FocrResult<()> {
if let Some(threads) = threads {
crate::set_thread_budget(threads)?;
}
if timing {
crate::enable_timing();
}
Ok(())
}
fn run_ocr(args: OcrArgs, robot_mode: bool) -> FocrResult<()> {
let telemetry_input = args.request.image.display().to_string();
let telemetry_model = args
.request
.model
.as_ref()
.map_or_else(|| "default".to_owned(), |p| p.display().to_string());
let started = crate::storage::now_millis();
let mut ran = None;
let check = args.check;
apply_tuning(args.request.threads, args.request.timing)?;
let outcome = run_ocr_inner(args, robot_mode, &mut ran);
if check {
return outcome;
}
let quant = if telemetry_model.contains("int8") {
"int8"
} else if telemetry_model.contains("int4") {
"int4"
} else {
"f32-or-default"
};
let (status, exit_code) = match &outcome {
Ok(()) => ("ok", 0i64),
Err(FocrError::Cancelled) => ("cancelled", 6),
Err(e) => ("error", i64::from(e.exit_code())),
};
let record = crate::storage::RunRecord {
run_id: uuid::Uuid::new_v4().to_string(),
started_at: started,
finished_at: Some(crate::storage::now_millis()),
input_path: telemetry_input,
mode: "ocr".into(),
quant: quant.into(),
model_version_tag: ran.as_ref().map_or_else(
|| "unknown".to_owned(),
native_engine::ModelIdentity::version_tag,
),
exit_code,
status: status.into(),
};
if let Err(e) = crate::storage::RunStore::default_path()
.and_then(|p| crate::storage::RunStore::open(&p))
.and_then(|store| store.insert_run(&record))
{
if !robot_mode {
let _ = progress::try_stderr_message(format_args!(
"[focr] run-store note (telemetry only, run unaffected): {e}"
));
}
}
outcome
}
const STDIN_INPUT: &str = "-";
struct StdinInput {
dir: PathBuf,
file: PathBuf,
}
impl StdinInput {
fn capture() -> FocrResult<Self> {
let stdin = std::io::stdin();
if stdin.is_terminal() {
return Err(FocrError::Usage(
"`focr ocr -` reads an image or a PDF from stdin, and nothing is piped in".into(),
));
}
let dir =
std::env::temp_dir().join(format!("focr-stdin-{}", uuid::Uuid::new_v4().simple()));
let unwritable = |e: std::io::Error| {
FocrError::Other(anyhow::anyhow!("saving stdin under {}: {e}", dir.display()))
};
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(&dir).map_err(unwritable)?;
let mut input = Self {
file: dir.join("stdin"),
dir: dir.clone(),
};
let mut file = std::fs::File::create_new(&input.file).map_err(unwritable)?;
let bytes = std::io::copy(&mut stdin.lock(), &mut file).map_err(unwritable)?;
drop(file);
if bytes == 0 {
return Err(FocrError::InputDecode(
"stdin is empty: pipe an image or a PDF into `focr ocr -`".into(),
));
}
let mut head = [0u8; 64];
let read = std::fs::File::open(&input.file)
.and_then(|mut file| file.read(&mut head))
.map_err(unwritable)?;
let extension = if pdf::looks_like_pdf_bytes(&head[..read]) {
"pdf"
} else {
image::guess_format(&head[..read])
.ok()
.and_then(|format| format.extensions_str().first().copied())
.ok_or_else(|| {
FocrError::InputDecode(
"stdin holds neither a PDF nor an image format focr reads".into(),
)
})?
};
let named = input.file.with_extension(extension);
std::fs::rename(&input.file, &named).map_err(unwritable)?;
input.file = named;
Ok(input)
}
}
impl Drop for StdinInput {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn check_input(
named: &Path,
request: &OcrRequest,
source: Option<&PageSource>,
) -> FocrResult<serde_json::Value> {
let path = &request.image;
if let Some(source) = source {
let kind = if path.is_dir() { "directory" } else { "pdf" };
return Ok(serde_json::json!({
"path": named,
"kind": kind,
"pages": source.len(),
"source_pages": source.source_len(),
}));
}
let (width, height) = image::image_dimensions(path)
.map_err(|e| FocrError::InputDecode(format!("{}: {e}", named.display())))?;
Ok(serde_json::json!({
"path": named,
"kind": "image",
"width": width,
"height": height,
}))
}
fn check_output(output: &Path) -> FocrResult<()> {
let parent = output
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
if output.is_dir() {
return Err(FocrError::Usage(format!(
"-o {} is a directory: name a file",
output.display()
)));
}
if !parent.is_dir() {
return Err(FocrError::Usage(format!(
"-o {}: {} is not a directory",
output.display(),
parent.display()
)));
}
Ok(())
}
fn report_check(
args: &OcrArgs,
request: &OcrRequest,
source: Option<&PageSource>,
warnings: &[native_engine::RunWarning],
) -> FocrResult<()> {
let model = request.model.clone().unwrap_or_else(OcrEngine::model_path);
let (artifact, model_id) = native_engine::preflight_model(&model).map_err(|e| match e {
FocrError::ModelNotFound(msg) => {
FocrError::ModelNotFound(with_pull_hint(&msg, request.model.as_deref()))
}
other => other,
})?;
let input = check_input(&args.request.image, request, source)?;
if let Some(output) = args.output.as_deref() {
check_output(output)?;
}
if args.json {
let mut value = serde_json::json!({
"schema_version": robot::ROBOT_SCHEMA_VERSION,
"command": "ocr.check",
"ok": true,
"input": input,
"model": { "id": model_id, "path": artifact },
"output": args.output,
"threads": crate::thread_budget(),
"timing": crate::timing_enabled(),
});
merge_run_warnings(&mut value, warnings);
println!("{value}");
} else {
let described = match (input["kind"].as_str(), input["pages"].as_u64()) {
(Some(kind), Some(pages)) => format!("{kind}, {pages} pages to read"),
_ => format!("image, {}x{}", input["width"], input["height"]),
};
println!(
"ok: {} ({described}); {model_id} from {}",
args.request.image.display(),
artifact.display()
);
}
Ok(())
}
fn run_ocr_inner(
args: OcrArgs,
robot_mode: bool,
ran: &mut Option<native_engine::ModelIdentity>,
) -> FocrResult<()> {
if args.check && robot_mode {
return Err(FocrError::Usage(
"--check reports once and streams no events: pass --json instead of --robot".into(),
));
}
let mut request = args.to_request()?;
let stdin_input = (request.image.as_os_str() == STDIN_INPUT)
.then(StdinInput::capture)
.transpose()?;
if let Some(input) = &stdin_input {
request.image.clone_from(&input.file);
}
native_engine::force_got_format(request.format);
native_engine::force_got_square_pad(request.square_pad);
native_engine::set_got_region(request.region);
native_engine::set_smolvlm2_question(request.question.clone());
native_engine::set_decode_overrides(decode_overrides_from(&request));
native_engine::set_preprocess_overrides(preprocess_overrides_from(&request));
if let Some(err) = forced_test_error()? {
return Err(err);
}
native_engine::validate_test_max_positions()?;
let figure_plan = FigurePlan::resolve(&args)?;
let engine = OcrEngine::new()?;
let is_document = pdf::looks_like_pdf(&request.image) || request.image.is_dir();
let show_progress = !robot_mode && !args.json;
if !is_document && (request.pages.is_some() || request.split_spreads || request.multi_page) {
return Err(FocrError::Usage(format!(
"--pages/--split-spreads/--multi-page operate on the pages of a PDF or of a \
directory of page images, but {} is neither (for an image list, use `focr \
ocr-batch --multi-page`)",
request.image.display()
)));
}
let mode = RunMode::of_ocr(&request, is_document);
let pull_hint = apply_mode_default(&mut request.model, mode, &MODE_DEFAULTS);
validate_task_for_header(args.request.task, &request)?;
validate_region_for_header(&request)?;
let paged_model = is_document.then(|| paged_model_id(&request)).flatten();
let paged_document = request.multi_page && paged_model.is_some();
if request.multi_page && !paged_document && (request.split_spreads || figure_plan.is_some()) {
return Err(FocrError::Usage(
"--multi-page is one cross-page pass over whole pages; it does not compose \
with --split-spreads or --extract-figures (run those per-page passes \
separately, or pass --model unlimited-ocr-2, whose pages are exact)"
.into(),
));
}
if request.split_spreads && figure_plan.is_some() && !paged_document {
return Err(FocrError::Usage(
"--split-spreads does not compose with --extract-figures yet (figure \
naming is per source page; splitting would collide the indices) — \
run the passes separately, or with --model unlimited-ocr-2 add \
--multi-page"
.into(),
));
}
if request.page_window.is_some() && !request.multi_page {
return Err(FocrError::Usage(
"--page-window splits a document read as one: pass --multi-page".into(),
));
}
let unwindowed = request.page_window == Some(native_engine::PageWindow::Off);
if unwindowed && (request.resume.is_some() || request.keep_checkpoint) {
return Err(FocrError::Usage(
"--page-window off reads the document in one pass, which has no window to resume \
from: drop --resume and --keep-checkpoint, or choose a window"
.into(),
));
}
if (request.resume.is_some() || request.keep_checkpoint) && !paged_document {
return Err(FocrError::Usage(
"--resume and --keep-checkpoint continue a windowed document: pass --multi-page \
with --model unlimited-ocr-2"
.into(),
));
}
let run_files = if paged_document && !unwindowed {
document_run_files(&request, args.output.as_deref())?
} else {
None
};
let source = is_document
.then(|| open_page_source(&request))
.transpose()?;
let mut early_warnings: Vec<native_engine::RunWarning> = pull_hint.into_iter().collect();
early_warnings.extend(
source
.as_ref()
.map(PageSource::warnings)
.unwrap_or_default(),
);
if !robot_mode {
early_warnings.iter().for_each(print_run_warning);
}
if args.check {
return report_check(&args, &request, source.as_ref(), &early_warnings);
}
let mut resident_report: Option<native_engine::DecodeReport> = None;
let mut resident_model: Option<native_engine::ModelIdentity> = None;
let mut resident_music: Option<native_engine::MusicPageMeta> = None;
let mut resident_chart: Option<native_engine::ChartPageMeta> = None;
let stages = robot_mode.then(|| Arc::new(robot::StageClock::start()));
if let Some(stages) = &stages {
let stages = Arc::clone(stages);
engine.set_stage_sink(Some(Box::new(move |name, budget| {
emit(&stages.entered(name, budget));
})));
}
let mut document_outcome = DocumentOutcome::default();
let (recognition, figures): (Recognition, Vec<WrittenFigure>) = match (&figure_plan, source) {
(plan, Some(source)) if request.multi_page => {
let (document, figs, outcome) = recognize_document_multi_page(
&engine,
&request,
source,
DocumentRun {
robot_mode,
show_progress,
paged_model,
},
plan.as_ref(),
run_files.as_ref(),
)?;
document_outcome = outcome;
(Recognition::Document(document), figs)
}
(Some(plan), Some(source)) => {
let (pages, figs) = recognize_document_pages_with_figures(
&engine,
&request,
source,
DocumentRun {
robot_mode,
show_progress,
paged_model,
},
plan,
)?;
(Recognition::Document(pages), figs)
}
(Some(plan), None) => {
let (mut doc, raw) =
recognize_with_autodownload(&request, robot_mode, |model| match model {
Some(m) => engine.recognize_with_figures_model(m, &request.image),
None => engine.recognize_with_figures(&request.image),
})?;
let mut writer = plan.writer();
doc.markdown = writer.process_page(1, None, &doc.markdown, raw)?;
(Recognition::Single(doc), writer.into_written())
}
(None, Some(source)) => (
Recognition::Document(recognize_document_pages(
&engine,
&request,
source,
DocumentRun {
robot_mode,
show_progress,
paged_model,
},
)?),
Vec::new(),
),
(None, None) => {
let tall_dims = (args.request.task == OcrTask::Ocr)
.then(|| image::image_dimensions(&request.image).ok())
.flatten()
.filter(|&(w, h)| crate::tall::is_tall(w, h));
if let Some((width, height)) = tall_dims {
let doc = recognize_tall_capture(&engine, &request, robot_mode, width, height)?;
(Recognition::Single(doc), Vec::new())
} else {
let doc = match resident_recognition(&args, &request, stages.as_deref())? {
Some(reply) => {
resident_report = reply.report;
resident_model = reply.model;
resident_music = reply.music;
resident_chart = reply.chart;
reply.document
}
None => {
recognize_with_autodownload(&request, robot_mode, |model| match model {
Some(m) => engine.recognize_with_layout_model(m, &request.image),
None => engine.recognize_with_layout(&request.image),
})?
}
};
(Recognition::Single(doc), Vec::new())
}
}
};
let decode_report = resident_report.or_else(|| engine.take_last_decode_report());
let finish_reason = decode_report
.as_ref()
.map(native_engine::DecodeReport::run_finish_reason);
let model = resident_model.or_else(|| engine.loaded_model_identity());
ran.clone_from(&model);
let mut run_warnings = early_warnings;
run_warnings.extend(
decode_report
.as_ref()
.map(native_engine::decode_warnings)
.unwrap_or_default(),
);
run_warnings.extend(document_outcome.warnings);
let chart_meta = resident_chart.or_else(|| engine.take_chart_page_meta());
run_warnings.extend(chart_meta.as_ref().and_then(native_engine::chart_warning));
let resumed_from = document_outcome.resumed_from;
let partial = decode_report
.as_ref()
.zip(model.as_ref())
.and_then(|(report, model)| native_engine::partial_output(report, &run_warnings, model))
.map(|stopped| with_resume_fields(stopped, run_files.as_ref(), resumed_from));
if !robot_mode {
print_run_warnings(&run_warnings);
}
let music_meta = resident_music.or_else(|| engine.take_music_page_meta());
if robot_mode && let Some(meta) = &music_meta {
let total = meta.staves.len() + meta.skips.len();
for (index, bbox) in &meta.staves {
emit(&robot::staff_event(*index, total, *bbox, "ok", None));
}
for skip in &meta.skips {
emit(&robot::staff_event(
skip.index,
total,
skip.bbox,
"skipped",
Some(&skip.reason),
));
}
for w in &meta.warnings {
emit(&robot::music_warning_event(
w.kind, w.part, w.measure, &w.detail,
));
}
}
if !robot_mode
&& let Some(meta) = &music_meta
&& !meta.warnings.is_empty()
{
eprintln!(
"[focr] {} musical-sanity warning(s) — annotated in the MusicXML; \
re-run with --robot for machine-readable detail",
meta.warnings.len()
);
}
let markdown = recognition.markdown();
let low_yield = if is_document || args.request.task != OcrTask::Ocr {
None
} else {
image::image_dimensions(&request.image)
.ok()
.and_then(|(w, h)| crate::tall::low_yield_assessment(markdown, w, h))
};
if let Some(assessment) = &low_yield {
if args.request.fail_on_low_yield {
return Err(FocrError::LowYield(format!(
"{:.2} MP input produced only {} character(s) of text (< {} chars/MP)",
assessment.input_megapixels,
assessment.yield_chars,
crate::tall::LOW_YIELD_CHARS_PER_MEGAPIXEL
)));
}
if !robot_mode {
eprintln!(
"[focr] warning: low yield — {:.2} MP input produced only {} character(s) of \
recognized text. If this is a full-page capture, re-capture at higher \
resolution: very tall images are tiled automatically, but glyphs below \
~12px are unrecoverable by any OCR engine.",
assessment.input_megapixels, assessment.yield_chars
);
}
}
let shape = OutputShape {
json: args.json || output_is_json(args.output.as_deref()),
raw: args.raw,
};
let extras = RunExtras {
finish_reason,
music: music_meta.as_ref(),
chart: chart_meta.as_ref(),
warnings: &run_warnings,
model: model.as_ref(),
};
if let Some(path) = args.output.as_deref() {
write_ocr_output(path, &recognition, shape, &figures, extras)?;
}
if partial.is_none()
&& let Some(files) = &run_files
&& let Err(e) = files.remove(request.keep_checkpoint)
{
eprintln!("[focr] warning: {e}");
}
if robot_mode {
if partial.is_none() {
let mut event = robot::run_complete_event_assessed(
markdown,
low_yield.as_ref(),
finish_reason,
&run_warnings,
model.as_ref(),
);
if args.raw {
event["raw"] = recognition.raw().into();
}
if let Some(page) = resumed_from {
event["resumed_from"] = page.into();
}
if let Some(meta) = &chart_meta {
event["chart"] = chart_meta_to_json(meta);
}
if let Some(counts) = &document_outcome.counts {
event["pages_out"] = counts.pages_out.into();
event["pages_skipped"] = counts.pages_skipped.into();
event["windows"] = counts.windows.into();
event["extra_markers"] = counts.extra_markers.into();
}
emit(&event);
} else if matches!(&partial, Some(FocrError::Partial(p)) if p.finish_reason == "runaway") {
for event in recognition.unstreamed_page_events() {
emit(&event);
}
}
} else if let Some(path) = args.output.as_deref() {
let figs = if figures.is_empty() {
String::new()
} else {
format!(", {} figure(s)", figures.len())
};
eprintln!("[focr] wrote {} ({}{figs})", path.display(), shape.name());
} else if args.json {
let mut value = recognition.to_json(&figures, args.raw);
extras.merge_into(&mut value);
emit(&value);
} else {
println!("{}", recognition.text(args.raw));
}
partial.map_or(Ok(()), Err)
}
#[derive(Clone, Copy, Default)]
struct RunExtras<'a> {
finish_reason: Option<native_engine::FinishReason>,
music: Option<&'a native_engine::MusicPageMeta>,
chart: Option<&'a native_engine::ChartPageMeta>,
warnings: &'a [native_engine::RunWarning],
model: Option<&'a native_engine::ModelIdentity>,
}
impl RunExtras<'_> {
fn merge_into(self, value: &mut serde_json::Value) {
if let Some(reason) = self.finish_reason {
value["finish_reason"] = reason.as_str().into();
}
if let Some(meta) = self.music {
value["staves"] = music_meta_to_json(meta);
value["warnings"] = music_warnings_to_json(meta);
}
if let Some(meta) = self.chart {
value["chart"] = chart_meta_to_json(meta);
}
merge_run_warnings(value, self.warnings);
if let Some(model) = self.model {
model.merge_into(value);
}
}
}
fn chart_meta_to_json(meta: &native_engine::ChartPageMeta) -> serde_json::Value {
serde_json::json!({
"pred_locs": meta.pred_locs,
"reliable_distance": meta.reliable_distance,
"reliable": meta.reliable,
})
}
fn merge_run_warnings(value: &mut serde_json::Value, warnings: &[native_engine::RunWarning]) {
if warnings.is_empty() {
return;
}
let entries: Vec<serde_json::Value> = warnings
.iter()
.map(native_engine::RunWarning::to_json)
.collect();
match value
.get_mut("warnings")
.and_then(serde_json::Value::as_array_mut)
{
Some(existing) => existing.extend(entries),
None => value["warnings"] = serde_json::Value::Array(entries),
}
}
fn print_run_warnings(warnings: &[native_engine::RunWarning]) {
warnings
.iter()
.filter(|w| !printed_early(w))
.for_each(print_run_warning);
}
fn printed_early(warning: &native_engine::RunWarning) -> bool {
matches!(
warning.kind,
"ignored_lever" | "ignored_files" | "pull_hint"
)
}
fn print_run_warning(warning: &native_engine::RunWarning) {
eprintln!("[focr] warning: {} — {}", warning.message, warning.recovery);
}
fn music_warnings_to_json(meta: &native_engine::MusicPageMeta) -> serde_json::Value {
serde_json::Value::Array(
meta.warnings
.iter()
.map(|w| {
serde_json::json!({
"kind": w.kind,
"part": w.part,
"measure": w.measure,
"detail": w.detail,
})
})
.collect(),
)
}
fn music_meta_to_json(meta: &native_engine::MusicPageMeta) -> serde_json::Value {
let mut entries: Vec<(usize, serde_json::Value)> = meta
.staves
.iter()
.map(|(index, bbox)| {
(
*index,
serde_json::json!({
"staff": index + 1,
"bbox": [bbox.0, bbox.1, bbox.2, bbox.3],
"status": "ok",
}),
)
})
.chain(meta.skips.iter().map(|skip| {
(
skip.index,
serde_json::json!({
"staff": skip.index + 1,
"bbox": [skip.bbox.0, skip.bbox.1, skip.bbox.2, skip.bbox.3],
"status": "skipped",
"reason": skip.reason,
}),
)
}))
.collect();
entries.sort_by_key(|(index, _)| *index);
serde_json::Value::Array(entries.into_iter().map(|(_, v)| v).collect())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RunMode {
SinglePage,
PdfDocument,
DirectoryDocument,
MultiPage,
Batch,
BatchMultiPage,
Pull,
}
impl RunMode {
fn of_ocr(request: &OcrRequest, is_document: bool) -> Self {
if !is_document {
Self::SinglePage
} else if request.multi_page {
Self::MultiPage
} else if request.image.is_dir() {
Self::DirectoryDocument
} else {
Self::PdfDocument
}
}
fn as_str(self) -> &'static str {
match self {
Self::SinglePage => "single-page",
Self::PdfDocument => "pdf",
Self::DirectoryDocument => "directory",
Self::MultiPage => "multi-page",
Self::Batch => "ocr-batch",
Self::BatchMultiPage => "ocr-batch --multi-page",
Self::Pull => "pull",
}
}
}
const MODE_DEFAULTS: [(RunMode, &str); 7] = [
(
RunMode::SinglePage,
native_engine::model_arch::UNLIMITED_OCR_V1_ID,
),
(
RunMode::PdfDocument,
native_engine::model_arch::UNLIMITED_OCR_V1_ID,
),
(
RunMode::DirectoryDocument,
native_engine::model_arch::UNLIMITED_OCR_V1_ID,
),
(
RunMode::MultiPage,
native_engine::model_arch::UNLIMITED_OCR_V1_ID,
),
(
RunMode::Batch,
native_engine::model_arch::UNLIMITED_OCR_V1_ID,
),
(
RunMode::BatchMultiPage,
native_engine::model_arch::UNLIMITED_OCR_V1_ID,
),
(
RunMode::Pull,
native_engine::model_arch::UNLIMITED_OCR_V1_ID,
),
];
fn mode_default(table: &[(RunMode, &'static str)], mode: RunMode) -> &'static str {
table
.iter()
.find(|(entry, _)| *entry == mode)
.map_or(native_engine::model_arch::UNLIMITED_OCR_V1_ID, |(_, id)| id)
}
#[derive(Debug, PartialEq, Eq)]
enum ModelChoice {
Named,
V1Default,
ModeDefault(&'static str),
MissingDefault(&'static str),
}
fn choose_model(
named: bool,
default_id: &'static str,
installed: impl FnOnce(&str) -> bool,
) -> ModelChoice {
if named {
ModelChoice::Named
} else if default_id == native_engine::model_arch::UNLIMITED_OCR_V1_ID {
ModelChoice::V1Default
} else if installed(default_id) {
ModelChoice::ModeDefault(default_id)
} else {
ModelChoice::MissingDefault(default_id)
}
}
fn model_installed(id: &str) -> bool {
native_engine::model_id_at(Path::new(id)) == Some(id)
}
fn model_path_env_set() -> bool {
std::env::var_os(crate::MODEL_PATH_ENV).is_some()
}
fn paged_model_id(request: &OcrRequest) -> Option<&'static str> {
let path = request.model.clone().unwrap_or_else(OcrEngine::model_path);
let id = native_engine::model_id_at(&path)?;
native_engine::model_arch::arch_by_id(id)?
.decode_contract()
.pages
.map(|_| id)
}
fn apply_mode_default(
model: &mut Option<PathBuf>,
mode: RunMode,
table: &[(RunMode, &'static str)],
) -> Option<native_engine::RunWarning> {
let named = model.is_some() || model_path_env_set();
match choose_model(named, mode_default(table, mode), model_installed) {
ModelChoice::Named | ModelChoice::V1Default => None,
ModelChoice::ModeDefault(id) => {
*model = Some(PathBuf::from(id));
None
}
ModelChoice::MissingDefault(id) => Some(pull_hint(mode, id)),
}
}
fn pull_hint(mode: RunMode, id: &'static str) -> native_engine::RunWarning {
let mut fields = serde_json::Map::new();
fields.insert("model_id".into(), id.into());
fields.insert("mode".into(), mode.as_str().into());
native_engine::RunWarning {
kind: "pull_hint",
message: format!(
"the {} default is {id}, which is not installed; {} served this run (`focr pull \
{id}` installs it)",
mode.as_str(),
native_engine::model_arch::UNLIMITED_OCR_V1_ID
),
recovery: native_engine::warning_recovery(native_engine::WarningKind::PullHint),
fields,
}
}
fn first_run_offer(
table: &[(RunMode, &'static str)],
manifest: &dist::Manifest,
) -> Option<&'static str> {
let id = mode_default(table, RunMode::Pull);
let available = !pull_availability(Some(manifest), id).compatible.is_empty();
available.then_some(id)
}
fn resident_recognition(
args: &OcrArgs,
request: &OcrRequest,
stages: Option<&robot::StageClock>,
) -> FocrResult<Option<crate::resident::ResidentReply>> {
if !crate::resident::enabled(request.no_resident)
|| args.request.threads.is_some()
|| crate::timing_enabled()
{
return Ok(None);
}
let spec = request
.model
.clone()
.unwrap_or_else(crate::OcrEngine::model_path);
let Ok(model) = native_engine::OcrModel::resolve_model(&spec) else {
return Ok(None);
};
let on_stage = |name: &str, budget: Option<std::time::Duration>| {
if let Some(stages) = stages {
emit(&stages.entered(name, budget));
}
};
crate::resident::try_recognize(&crate::resident::ResidentRequest {
image: &request.image,
model,
decode: decode_overrides_from(request),
preprocess: preprocess_overrides_from(request),
format: request.format,
square_pad: request.square_pad,
region: request.region,
question: request.question.as_deref(),
on_stage: Some(&on_stage),
})
}
fn recognize_with_autodownload<T, F>(
request: &OcrRequest,
robot_mode: bool,
recog: F,
) -> FocrResult<T>
where
F: Fn(Option<&Path>) -> FocrResult<T>,
{
match recog(request.model.as_deref()) {
Ok(md) => Ok(md),
Err(FocrError::ModelNotFound(msg)) => {
if request.model.is_none() && !robot_mode && is_interactive() {
progress::clear_active_line();
match offer_first_run_download()? {
Some(outcome) => recog(Some(&outcome.focrq_path)),
None => Err(FocrError::ModelNotFound(with_pull_hint(&msg, None))),
}
} else {
Err(FocrError::ModelNotFound(with_pull_hint(
&msg,
request.model.as_deref(),
)))
}
}
Err(e) => Err(e),
}
}
fn recognize_tall_capture(
engine: &OcrEngine,
request: &OcrRequest,
robot_mode: bool,
width: u32,
height: u32,
) -> FocrResult<native_engine::RecognizedDocument> {
let img = image::open(&request.image).map_err(|e| {
FocrError::InputDecode(format!("failed to decode {}: {e}", request.image.display()))
})?;
let profile = crate::tall::ink_profile(&img);
let plan = crate::tall::plan_strips(width, height, &profile);
crate::native_engine::timing_log(&format!(
"tall capture {}x{} (aspect {:.2}): OCR as {} strips",
width,
height,
f64::from(height) / f64::from(width),
plan.len()
));
let strips = crate::tall::cut_strips(&img, &plan);
recognize_with_autodownload(request, robot_mode, |model| {
let mut parts = Vec::with_capacity(strips.len());
for (strip, bounds) in strips.iter().zip(&plan) {
let doc = match model {
Some(m) => engine.recognize_dynamic_with_layout_model(m, strip.clone())?,
None => engine.recognize_dynamic_with_layout(strip.clone())?,
};
parts.push((doc, bounds.top));
}
Ok(crate::tall::merge_documents(parts))
})
}
fn open_page_source(request: &OcrRequest) -> FocrResult<PageSource> {
Ok(PageSource::open(&request.image)?
.select(request.pages.as_deref())?
.split_spreads(request.split_spreads))
}
fn attempt_source(
opened: &std::cell::Cell<Option<PageSource>>,
request: &OcrRequest,
) -> FocrResult<PageSource> {
match opened.take() {
Some(source) => Ok(source),
None => open_page_source(request),
}
}
fn page_noun(request: &OcrRequest) -> &'static str {
if request.image.is_dir() {
"page"
} else {
"PDF page"
}
}
#[derive(Debug, Clone, Copy)]
struct DocumentRun {
robot_mode: bool,
show_progress: bool,
paged_model: Option<&'static str>,
}
fn report_skipped_page(
run: DocumentRun,
bar: &progress::Progress,
noun: &str,
page: usize,
err: &FocrError,
) {
if run.robot_mode {
emit(&match run.paged_model {
Some(model_id) => robot::document_page_skipped_event(page, err, model_id),
None => robot::page_skipped_event(page, err),
});
} else {
bar.note(&format!("[focr] {noun} {page} skipped: {err}"));
}
}
fn page_progress<'a>(
run: DocumentRun,
bar: &'a progress::Progress,
noun: &'a str,
page_count: usize,
) -> impl FnMut(PageEvent<'_>) + 'a {
let finished = |page: PageStep| {
if page.half != Some(Half::Left) {
bar.complete_item();
}
};
move |event| match event {
PageEvent::Started(page) => {
if page.half != Some(Half::Right) {
bar.start_item(format!("page {}/{page_count}", page.source_page));
}
}
PageEvent::Done(page) => finished(page),
PageEvent::Skipped(page, error) => {
report_skipped_page(run, bar, noun, page.source_page, error);
finished(page);
}
}
}
struct DocumentPageLayout {
page: usize,
half: Option<&'static str>,
raw: String,
layout: Vec<crate::native_engine::LayoutSpan>,
}
#[derive(Debug, Default)]
struct DocumentOutcome {
resumed_from: Option<usize>,
warnings: Vec<native_engine::RunWarning>,
counts: Option<DocumentCounts>,
}
#[derive(Debug)]
struct DocumentCounts {
pages_out: usize,
pages_skipped: usize,
windows: usize,
extra_markers: usize,
}
fn document_run_files(
request: &OcrRequest,
output: Option<&std::path::Path>,
) -> FocrResult<Option<native_engine::checkpoint::RunFiles>> {
use native_engine::checkpoint::{INPUT_FIELD, ResumeKey, RunFiles, SELECTION_FIELD};
let key = ResumeKey::new()
.with(
INPUT_FIELD,
crate::page_source::input_sha256(&request.image)?,
)
.with(
SELECTION_FIELD,
format!(
"pages={} split_spreads={}",
request.pages.as_deref().unwrap_or("all"),
request.split_spreads
),
)
.with(
"decode_flags",
format!("{:?}", decode_overrides_from(request)),
);
let files = match (output, crate::dist::cache_root()) {
(Some(path), _) => RunFiles::beside(path, key),
(None, Some(root)) => RunFiles::keyed(&root.join("document-runs"), key),
(None, None) => return Ok(None),
};
Ok(Some(files.with_command(resume_command())))
}
fn resume_command() -> String {
let quoted = std::env::args().skip(1).map(|arg| {
let plain = !arg.is_empty()
&& arg
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./=:,@%+".contains(c));
if plain {
arg
} else {
format!("'{}'", arg.replace('\'', r"'\''"))
}
});
std::iter::once("focr".to_owned())
.chain(quoted)
.collect::<Vec<_>>()
.join(" ")
}
fn with_resume_fields(
mut error: FocrError,
files: Option<&native_engine::checkpoint::RunFiles>,
resumed_from: Option<usize>,
) -> FocrError {
if let FocrError::Partial(partial) = &mut error {
partial.resumed_from = resumed_from;
partial.checkpoint = files
.filter(|files| files.has_checkpoint())
.map(|files| files.checkpoint.display().to_string());
}
error
}
enum DocumentPageList {
Layouts(Vec<DocumentPageLayout>),
Read(Vec<native_engine::PageResult>),
}
struct DocumentRecognition {
markdown: String,
raw: String,
pages: DocumentPageList,
}
fn recognize_document_multi_page(
engine: &OcrEngine,
request: &OcrRequest,
source: PageSource,
run: DocumentRun,
figures: Option<&FigurePlan>,
files: Option<&native_engine::checkpoint::RunFiles>,
) -> FocrResult<(DocumentRecognition, Vec<WrittenFigure>, DocumentOutcome)> {
let DocumentRun {
robot_mode,
show_progress,
paged_model,
} = run;
let opened = std::cell::Cell::new(Some(source));
let noun = page_noun(request);
let recompute = request.resume == Some(ResumeMode::Recompute);
let mut options = match files {
Some(files) => {
if files.journal.exists() {
eprintln!(
"[focr] {} the interrupted run in {}",
if recompute {
"recomputing"
} else {
"continuing"
},
files.journal.display()
);
}
native_engine::DocumentOptions::default()
.resume(if recompute {
native_engine::ResumePolicy::Recompute
} else {
native_engine::ResumePolicy::IfMatching
})
.run(files.clone())
}
None => native_engine::DocumentOptions::default(),
};
options.page_window = request.page_window;
options.reread_capped = native_engine::reread_capped_requested();
let document = recognize_with_autodownload(request, robot_mode, |model| {
let source = attempt_source(&opened, request)?;
let model_path = model
.map(std::path::Path::to_path_buf)
.unwrap_or_else(OcrEngine::model_path);
let (page_count, selected) = (source.source_len(), source.len());
let decode_bar =
std::sync::Arc::new(progress::Progress::new("ocr", selected, show_progress));
let raster_bar = paged_model
.is_none()
.then(|| progress::Progress::new("raster", selected, show_progress));
let on_item: Box<dyn FnMut(&PageItem) + Send> = {
let decode_bar = std::sync::Arc::clone(&decode_bar);
let mut failed = 0usize;
Box::new(move |item: &PageItem| {
let status = format!("page {}/{page_count}", item.source_page);
match &raster_bar {
Some(bar) => bar.start_item(status),
None => decode_bar.reading_item(format!("read {status}")),
}
if let Err(e) = &item.raster {
failed += 1;
let bar = raster_bar.as_ref().unwrap_or(&decode_bar);
report_skipped_page(run, bar, noun, item.source_page, e);
}
if let Some(bar) = &raster_bar
&& item.half != Some(Half::Left)
{
bar.complete_item();
}
let rendered = item.index + 1 - failed;
let walked = item.walk.next - usize::from(item.walk.held);
if walked == selected {
decode_bar.set_total(rendered);
if let Some(bar) = &raster_bar {
bar.finish();
if rendered > 0 {
decode_bar.start_item(format!("page 1/{rendered}"));
}
}
}
})
};
let on_event: Option<crate::DocumentSink> = if robot_mode {
let mut eta = progress::PageEta::default();
Some(Box::new(move |event: &crate::DocumentEvent<'_>| {
emit(&match event {
crate::DocumentEvent::Page(streamed) => match streamed.page {
Some(page) => robot::document_page_decoded_event(page, streamed.body),
None => robot::page_decoded_event(streamed.index, streamed.body),
},
crate::DocumentEvent::Window(window) => {
eta.add_window(window.pages.len(), window.elapsed_ms);
let left = selected.saturating_sub(window.pages.end);
robot::window_event(window, eta.remaining_ms(left))
}
});
}))
} else if decode_bar.is_enabled() {
Some(decode_bar.document_sink())
} else {
None
};
let out = engine.recognize_document(&model_path, source, &options, on_item, on_event);
if out.is_ok() {
decode_bar.finish();
} else {
decode_bar.retire();
}
out
})?;
let counts = paged_model.map(|_| DocumentCounts {
pages_out: document.pages_out,
pages_skipped: document.skipped.len(),
windows: document.windows.len(),
extra_markers: document.extra_markers,
});
let native_engine::DocumentResult {
mut markdown,
raw,
mut pages,
resumed_from,
warnings,
..
} = document;
let written = match figures {
Some(plan) => write_page_figures(request, &mut pages, plan)?,
None => Vec::new(),
};
if !written.is_empty() {
markdown = native_engine::postprocess::join_page_texts(pages.iter().map(|p| &p.text));
}
Ok((
DocumentRecognition {
markdown,
raw,
pages: DocumentPageList::Read(pages),
},
written,
DocumentOutcome {
resumed_from,
warnings: warnings
.into_iter()
.filter(|w| {
matches!(
w.kind,
"v2_page_reread" | "resume_recompute" | "checkpoint_failed"
)
})
.collect(),
counts,
},
))
}
fn write_page_figures(
request: &OcrRequest,
pages: &mut [native_engine::PageResult],
plan: &FigurePlan,
) -> FocrResult<Vec<WrittenFigure>> {
if pages.iter().all(|p| p.figures.is_empty()) {
return Ok(Vec::new());
}
let rasters = open_page_source(request)?;
let mut writer = plan.writer();
for page in pages.iter_mut().filter(|p| !p.figures.is_empty()) {
let raster = rasters.fetch(page.source_page, page.half)?;
let crops = native_engine::crop_figure_refs(&page.figures, &raster);
page.text = writer.process_page(page.source_page, page.half, &page.text, crops)?;
}
Ok(writer.into_written())
}
fn recognize_document_pages(
engine: &OcrEngine,
request: &OcrRequest,
source: PageSource,
run: DocumentRun,
) -> FocrResult<DocumentRecognition> {
let DocumentRun {
robot_mode,
show_progress,
..
} = run;
let opened = std::cell::Cell::new(Some(source));
let noun = page_noun(request);
recognize_with_autodownload(request, robot_mode, |model| {
let mut source = attempt_source(&opened, request)?;
let page_count = source.source_len();
let bar = progress::Progress::new("ocr", source.len(), show_progress);
let read = source.read_pages(
|image| match model {
Some(m) => engine.recognize_dynamic_with_layout_model(m, image),
None => engine.recognize_dynamic_with_layout(image),
},
&mut page_progress(run, &bar, noun, page_count),
)?;
bar.finish();
Ok(DocumentRecognition {
markdown: read.markdown(),
raw: read.raw(),
pages: DocumentPageList::Layouts(
read.pages
.into_iter()
.map(|page| DocumentPageLayout {
page: page.source_page,
half: page.half.map(Half::as_str),
raw: page.value.raw,
layout: page.value.layout,
})
.collect(),
),
})
})
}
fn recognize_document_pages_with_figures(
engine: &OcrEngine,
request: &OcrRequest,
source: PageSource,
run: DocumentRun,
plan: &FigurePlan,
) -> FocrResult<(DocumentRecognition, Vec<WrittenFigure>)> {
let DocumentRun {
robot_mode,
show_progress,
..
} = run;
let opened = std::cell::Cell::new(Some(source));
let noun = page_noun(request);
let read = recognize_with_autodownload(request, robot_mode, |model| {
let mut source = attempt_source(&opened, request)?;
let page_count = source.source_len();
let bar = progress::Progress::new("ocr", source.len(), show_progress);
let read = source.read_pages(
|image| match model {
Some(m) => engine.recognize_dynamic_with_figures_model(m, image),
None => engine.recognize_dynamic_with_figures(image),
},
&mut page_progress(run, &bar, noun, page_count),
)?;
bar.finish();
Ok(read)
})?;
let mut writer = plan.writer();
let mut document = String::new();
let mut raws: Vec<String> = Vec::new();
let mut page_layouts: Vec<DocumentPageLayout> = Vec::new();
for (i, page) in read.pages.into_iter().enumerate() {
let (doc, figs) = page.value;
let md = writer.process_page(page.source_page, None, &doc.markdown, figs)?;
if i > 0 {
document.push_str("\n\n");
}
document.push_str(md.trim_end());
raws.push(doc.raw.clone());
page_layouts.push(DocumentPageLayout {
page: page.source_page,
half: None,
raw: doc.raw,
layout: doc.layout,
});
}
Ok((
DocumentRecognition {
markdown: document,
raw: raws.join("\n\n"),
pages: DocumentPageList::Layouts(page_layouts),
},
writer.into_written(),
))
}
#[derive(Default)]
struct PageEnd {
finish_reason: Option<native_engine::FinishReason>,
warnings: Vec<native_engine::RunWarning>,
chart: Option<native_engine::ChartPageMeta>,
partial: Option<FocrError>,
}
impl PageEnd {
fn take(engine: &OcrEngine) -> Self {
Self::from_report(
engine.take_last_decode_report(),
engine.take_chart_page_meta(),
engine,
)
}
fn from_report(
report: Option<native_engine::DecodeReport>,
chart: Option<native_engine::ChartPageMeta>,
engine: &OcrEngine,
) -> Self {
let mut warnings = report
.as_ref()
.map(native_engine::decode_warnings)
.unwrap_or_default();
warnings.extend(chart.as_ref().and_then(native_engine::chart_warning));
let partial = report
.as_ref()
.zip(engine.loaded_model_identity().as_ref())
.and_then(|(report, model)| native_engine::partial_output(report, &warnings, model));
Self {
finish_reason: report
.as_ref()
.map(native_engine::DecodeReport::run_finish_reason),
warnings,
chart,
partial,
}
}
}
fn emit_batch_result(
json: bool,
image: &std::path::Path,
secs: f64,
outcome: FocrResult<String>,
saved: Option<&Path>,
end: PageEnd,
results: &mut Vec<serde_json::Value>,
) -> Option<FocrError> {
match outcome {
Ok(markdown) => {
if json {
let mut result = serde_json::json!({
"image": image.display().to_string(),
"ok": end.partial.is_none(),
"seconds": secs,
"markdown": markdown,
});
if let Some(saved) = saved {
result["output"] = saved.display().to_string().into();
}
if let Some(err) = &end.partial {
result["error"] = err.to_string().into();
}
RunExtras {
finish_reason: end.finish_reason,
chart: end.chart.as_ref(),
warnings: &end.warnings,
..RunExtras::default()
}
.merge_into(&mut result);
results.push(result);
} else {
print_run_warnings(&end.warnings);
if let Some(saved) = saved {
eprintln!(
"[focr] {} ({secs:.2}s) -> {}",
image.display(),
saved.display()
);
} else {
eprintln!("[focr] {} ({secs:.2}s)", image.display());
println!("===== {} =====", image.display());
println!("{markdown}");
}
}
end.partial
}
Err(err) => {
if json {
results.push(serde_json::json!({
"image": image.display().to_string(),
"ok": false,
"seconds": secs,
"error": err.to_string(),
}));
} else {
eprintln!("[focr] {} FAILED ({secs:.2}s): {err}", image.display());
}
Some(err)
}
}
}
fn batch_inputs(args: &OcrBatchArgs) -> FocrResult<Vec<PathBuf>> {
let mut images = args.images.clone();
if let Some(list) = &args.inputs_from {
let text = if list == Path::new("-") {
std::io::read_to_string(std::io::stdin())
} else {
std::fs::read_to_string(list)
}
.map_err(|e| FocrError::Usage(format!("--inputs-from {}: {e}", list.display())))?;
images.extend(parse_input_list(&text));
}
if images.is_empty() {
return Err(FocrError::Usage(
"ocr-batch has no images to read: --inputs-from listed none".into(),
));
}
Ok(images)
}
fn parse_input_list(text: &str) -> impl Iterator<Item = PathBuf> + '_ {
text.strip_prefix('\u{feff}')
.unwrap_or(text)
.lines()
.filter(|line| !line.is_empty())
.map(PathBuf::from)
}
fn batch_output_paths(dir: &Path, images: &[PathBuf]) -> FocrResult<Vec<PathBuf>> {
let mut seen: std::collections::HashMap<String, &Path> = std::collections::HashMap::new();
images
.iter()
.map(|image| {
let Some(stem) = image.file_stem() else {
return Err(FocrError::Usage(format!(
"--output-dir names each file after its input, and {} has no file name",
image.display()
)));
};
if let Some(first) = seen.insert(stem.to_string_lossy().to_lowercase(), image) {
return Err(FocrError::Usage(format!(
"--output-dir would write {} and {} to one file: rename one, or run them \
separately",
first.display(),
image.display()
)));
}
let mut name = stem.to_os_string();
name.push(".md");
Ok(dir.join(name))
})
.collect()
}
fn save_batch_markdown(saved: Option<&Path>, markdown: &str) -> FocrResult<()> {
let Some(path) = saved else {
return Ok(());
};
std::fs::write(path, markdown).map_err(|e| {
FocrError::Other(anyhow::anyhow!(
"writing OCR output to {}: {e}",
path.display()
))
})
}
fn run_ocr_batch(args: OcrBatchArgs) -> FocrResult<()> {
if let Some(err) = forced_test_error()? {
return Err(err);
}
apply_tuning(args.threads, args.timing)?;
let images = batch_inputs(&args)?;
let spine = !args.multi_page && native_engine::batch_scheduler::spine_enabled();
if args.multi_page && (args.output_dir.is_some() || args.fail_fast) {
return Err(FocrError::Usage(
"--output-dir and --fail-fast work image by image, and --multi-page reads one \
document: use -o on `focr ocr`, or drop --multi-page"
.into(),
));
}
if spine && (args.progress_events.is_some() || args.fail_fast) {
return Err(FocrError::Usage(
"--progress-events and --fail-fast act as each image ends, and FOCR_BATCH_SPINE \
decodes them together: unset it for this run"
.into(),
));
}
let saved = match &args.output_dir {
Some(dir) => {
let paths = batch_output_paths(dir, &images)?;
std::fs::create_dir_all(dir)
.map_err(|e| FocrError::Usage(format!("--output-dir {}: {e}", dir.display())))?;
paths.into_iter().map(Some).collect()
}
None => vec![None; images.len()],
};
let events = args
.progress_events
.as_deref()
.map(BatchEvents::open)
.transpose()?;
let outcome = run_batch_images(&args, &images, &saved, spine, events.as_ref());
if let Some(events) = &events {
events.end(&outcome);
}
outcome
}
fn run_batch_images(
args: &OcrBatchArgs,
images: &[PathBuf],
saved: &[Option<PathBuf>],
spine: bool,
events: Option<&BatchEvents>,
) -> FocrResult<()> {
if args.experimental_full_int8 {
native_engine::force_int8_decode(true)?;
}
native_engine::validate_experimental_full_int8_decode()?;
native_engine::validate_test_max_positions()?;
let experimental_full_int8 = native_engine::experimental_full_int8_decode_requested();
let decode_mode = if experimental_full_int8 {
"experimental_full_int8"
} else {
"conservative_recipe"
};
if let Some(n) = std::env::var("FOCR_NO_REPEAT_NGRAM")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
{
native_engine::set_decode_overrides(native_engine::DecodeOverrides {
no_repeat_ngram: Some(n),
..Default::default()
});
}
let engine = OcrEngine::new()?;
let mut model = args.model.clone();
let mode = if args.multi_page {
RunMode::BatchMultiPage
} else {
RunMode::Batch
};
let pull_hint = apply_mode_default(&mut model, mode, &MODE_DEFAULTS);
if let Some(hint) = &pull_hint {
print_run_warning(hint);
}
let count = images.len();
let total = std::time::Instant::now();
let mut results: Vec<serde_json::Value> = Vec::with_capacity(count);
let _policed = native_engine::load_if_policed(
&model.clone().unwrap_or_else(crate::OcrEngine::model_path),
)?;
if let Some(events) = events {
let model_path = model.clone().unwrap_or_else(OcrEngine::model_path);
events.start(&BatchRun {
mode: if args.multi_page {
BatchMode::Document
} else {
BatchMode::Images
},
total: count,
threads: crate::thread_budget(),
model_path: &model_path,
model_id: native_engine::model_id_at(&model_path),
backend: simd::effective_dense_route().tag(),
decode_mode,
});
}
if args.multi_page {
let model_path = model.clone().unwrap_or_else(OcrEngine::model_path);
let document = engine.recognize_document(
&model_path,
PageSource::images(images.to_vec())?,
&native_engine::DocumentOptions::default()
.reread_capped(native_engine::reread_capped_requested()),
events.map_or_else(
|| Box::new(|_: &PageItem| {}) as Box<dyn FnMut(&PageItem) + Send>,
|events| events.page_read_sink(images.to_vec()),
),
events.map(|events| events.document_sink(images.to_vec())),
)?;
let markdown = document.markdown;
let skipped: Vec<serde_json::Value> = document
.skipped
.iter()
.map(|skip| {
let image = images[skip.page - 1].display().to_string();
eprintln!(
"[focr] image {} ({image}) skipped: {}",
skip.page, skip.reason
);
serde_json::json!({"page": skip.page, "image": image, "reason": skip.reason})
})
.collect();
let elapsed = total.elapsed().as_secs_f64();
let report = engine.take_last_decode_report();
let ran = engine.loaded_model_identity();
let mut run_warnings: Vec<native_engine::RunWarning> = pull_hint.into_iter().collect();
run_warnings.extend(
report
.as_ref()
.map(native_engine::decode_warnings)
.unwrap_or_default(),
);
run_warnings.extend(
document
.warnings
.into_iter()
.filter(|w| w.kind == "v2_page_reread"),
);
let partial = report
.as_ref()
.zip(ran.as_ref())
.and_then(|(report, ran)| native_engine::partial_output(report, &run_warnings, ran));
print_run_warnings(&run_warnings);
if args.json {
let mut value = serde_json::json!({
"schema_version": robot::ROBOT_SCHEMA_VERSION,
"command": "batch.multi_page",
"pages": count,
"seconds": elapsed,
"decode_mode": decode_mode,
"markdown": markdown,
});
if let Some(report) = &report {
value["finish_reason"] = report.run_finish_reason().as_str().into();
}
if !skipped.is_empty() {
value["skipped"] = skipped.into();
}
merge_run_warnings(&mut value, &run_warnings);
if let Some(ran) = &ran {
ran.merge_into(&mut value);
}
emit(&value);
} else {
println!("{markdown}");
let read = count - document.skipped.len();
let parse = match document.windows.len() {
0 => "one cross-page pass".to_string(),
windows => format!(
"{windows} windows ({} recomputed)",
document
.windows
.iter()
.filter(|w| w.fallback_reason.is_some())
.count()
),
};
eprintln!("[focr] multi-page: {read} of {count} pages in {parse}, {elapsed:.2}s");
}
return partial.map_or(Ok(()), Err);
}
let mut stopped = None;
let mut failed = 0;
let mut first_failure = None;
if spine {
let image_refs: Vec<&std::path::Path> =
images.iter().map(std::path::PathBuf::as_path).collect();
let batch = match model.as_deref() {
Some(m) => engine.recognize_batch_with_model(m, &image_refs),
None => engine.recognize_batch(&image_refs),
}?;
let per_image = total.elapsed().as_secs_f64() / (count.max(1) as f64);
let mut reports = engine.take_batch_decode_reports().into_iter();
let mut charts = engine.take_batch_chart_page_metas().into_iter();
for ((image, outcome), saved) in images.iter().zip(batch).zip(saved) {
let saved = saved.as_deref();
let outcome = outcome.and_then(|markdown| {
save_batch_markdown(saved, &markdown)?;
Ok(markdown)
});
let end =
PageEnd::from_report(reports.next().flatten(), charts.next().flatten(), &engine);
if let Some(err) = emit_batch_result(
args.json,
image,
per_image,
outcome,
saved,
end,
&mut results,
) {
failed += 1;
first_failure.get_or_insert(err);
}
}
} else {
let bar = progress::Progress::new("batch", count, !args.json);
for (i, (image, saved)) in images.iter().zip(saved).enumerate() {
let saved = saved.as_deref();
let name = image.file_name().map_or_else(
|| image.display().to_string(),
|n| n.to_string_lossy().into_owned(),
);
bar.start_item(format!("image {}/{count}: {name}", i + 1));
let started = std::time::Instant::now();
let outcome = match (forced_image_error(i + 1), model.as_deref()) {
(Some(err), _) => Err(err),
(None, Some(m)) => engine.recognize_with_model(m, image),
(None, None) => engine.recognize(image),
};
let outcome = outcome.and_then(|markdown| {
save_batch_markdown(saved, &markdown)?;
Ok(markdown)
});
let secs = started.elapsed().as_secs_f64();
let end = PageEnd::take(&engine);
bar.complete_item();
bar.suspend();
if let Some(events) = events {
let failure = outcome.as_ref().err().or(end.partial.as_ref());
events.image_ended(i + 1, image, failure);
}
if matches!(outcome, Err(FocrError::Cancelled)) {
stopped = Some("cancelled");
first_failure = Some(FocrError::Cancelled);
break;
}
if let Some(err) =
emit_batch_result(args.json, image, secs, outcome, saved, end, &mut results)
{
failed += 1;
first_failure.get_or_insert(err);
if args.fail_fast {
stopped = Some("image_failed");
break;
}
}
}
bar.finish();
}
let elapsed = total.elapsed().as_secs_f64();
let ended = if stopped.is_some() {
results.len()
} else {
count
};
let per_image = elapsed / (ended.max(1) as f64);
if args.json {
let mut value = serde_json::json!({
"schema_version": robot::ROBOT_SCHEMA_VERSION,
"command": "ocr-batch",
"count": count,
"decode_mode": decode_mode,
"experimental_full_int8": experimental_full_int8,
"seconds_total": elapsed,
"seconds_per_image": per_image,
"results": results,
});
if let Some(stopped) = stopped {
value["stopped"] = stopped.into();
}
emit(&value);
} else if stopped.is_some() {
eprintln!("[focr] batch stopped after {elapsed:.2}s");
} else {
eprintln!(
"[focr] batch complete: {count} images in {elapsed:.2}s \
({per_image:.2}s/image, decode_mode={decode_mode})"
);
}
match first_failure {
Some(err) if stopped.is_some() || failed == count => Err(err),
_ => Ok(()),
}
}
fn run_convert(args: &ConvertArgs) -> FocrResult<()> {
if args.quant == QuantTarget::Int4 && args.arch != ArchTarget::Generic {
return Err(FocrError::Usage(format!(
"focr convert --quant int4 emits the wasm recipe and supports only \
--arch generic, got --arch {}",
args.arch.as_str()
)));
}
let resolved = native_engine::OcrModel::resolve_model(&args.input)?;
let source = native_engine::shards::SourceShards::open(&resolved)?;
let shard_count = source.files().len();
let convert_quant = match args.quant {
QuantTarget::Int8 => quant::convert::ConvertQuant::Int8,
QuantTarget::Int4 => quant::convert::ConvertQuant::Int4,
};
let int4_refusal = |arch: &dyn native_engine::model_arch::ModelArch| -> FocrResult<()> {
if convert_quant == quant::convert::ConvertQuant::Int4 {
quant::convert::ensure_int4_recipe(arch)?;
}
Ok(())
};
let explicit = match args.model_id.as_deref() {
None => None,
Some(id) => {
let arch = native_engine::model_arch::arch_by_id(id).ok_or_else(|| {
FocrError::Usage(format!(
"unknown --model-id {id:?} (see `focr models` for the registry)"
))
})?;
int4_refusal(arch)?;
Some(arch)
}
};
let (headers, prefixes) = native_engine::read_source_headers(&source)?;
let arch = native_engine::conversion_arch(explicit, &headers)?;
if explicit.is_none() {
int4_refusal(arch)?;
}
let census = arch.tensor_census();
if let Some(census) = census {
native_engine::unlimited_ocr_census::validate_conversion_shard_count(census, shard_count)?;
}
if convert_quant == quant::convert::ConvertQuant::Int4 && shard_count != 1 {
return Err(FocrError::NotImplemented(format!(
"focr convert --quant int4 (the wasm recipe) needs a single-shard source; {} has \
{shard_count} shards",
resolved.display()
)));
}
let calib = match &args.calib {
None => None,
Some(path) => {
let text = std::fs::read_to_string(path).map_err(|e| {
FocrError::ModelNotFound(format!(
"cannot read --calib JSON at {}: {e}",
path.display()
))
})?;
Some(quant::calib::CalibStats::from_json(&text)?)
}
};
let int64_rules = native_engine::shards::int64_rules(Some(arch));
native_engine::shards::check_int64_entries(&headers.int64, int64_rules, arch.id())?;
native_engine::shards::check_int64_rules_present(&headers.int64, int64_rules, arch.id())?;
let omit_lm_head = arch.tie_word_embeddings();
let mut int8 = (convert_quant == quant::convert::ConvertQuant::Int8)
.then(|| quant::convert::Int8Conversion::new(arch, args.arch.packing_byte()));
let mut int4_blob = None;
let mut digests = Vec::with_capacity(shard_count);
let mut tensor_count = 0usize;
let mut quantized_int8 = 0usize;
let mut quantized_int4 = 0usize;
let mut calib_coverage: Option<((usize, usize), (usize, usize))> = None;
for (position, (name, path)) in source.files().iter().enumerate() {
let (bytes, digest) = native_engine::shards::read_shard(name, path)?;
if !bytes.starts_with(&prefixes[position]) {
return Err(FocrError::FormatMismatch(format!(
"{} changed between its header check and its conversion",
path.display()
)));
}
if let Some(census) = census {
native_engine::unlimited_ocr_census::validate_conversion_source_shard(
census,
position,
&digest.sha256,
)?;
}
let weights = native_engine::weights::Weights::from_source_shard(bytes, arch)?;
tensor_count += weights.len();
match convert_quant {
quant::convert::ConvertQuant::Int8 => {
quantized_int8 += weights
.names()
.filter(|name| quant::convert::is_decoder_int8_tensor_for(name, arch))
.filter(|name| !(omit_lm_head && *name == "lm_head.weight"))
.count();
}
quant::convert::ConvertQuant::Int4 => {
for name in weights.names() {
match quant::recipe::classify_wasm_experts_int4(name) {
quant::recipe::WasmInt4Policy::ExpertInt4 => quantized_int4 += 1,
quant::recipe::WasmInt4Policy::Int8 => quantized_int8 += 1,
quant::recipe::WasmInt4Policy::KeepHighPrecision => {}
}
}
}
}
if let Some(stats) = &calib {
let ((i4c, i4t), (i8c, i8t)) = quant::convert::calib_coverage(&weights, stats);
let ((c4, t4), (c8, t8)) = calib_coverage.unwrap_or_default();
calib_coverage = Some(((c4 + i4c, t4 + i4t), (c8 + i8c, t8 + i8t)));
}
match int8.as_mut() {
Some(conversion) => conversion.add_shard(&weights)?,
None => {
int4_blob = Some(quant::convert::safetensors_to_focrq_calibrated(
&weights,
convert_quant,
args.arch.packing_byte(),
digest.sha256,
arch,
calib.as_ref(),
)?);
}
}
digests.push(digest);
}
let source_sha256 = native_engine::shards::source_sha256(&digests);
let input_bytes: u64 = digests.iter().map(|digest| digest.size).sum();
let quantized = quantized_int8 + quantized_int4;
let blob = match (int8, int4_blob) {
(Some(conversion), _) => conversion.finish(
source_sha256,
native_engine::shards::provenance_json(&digests),
)?,
(None, Some(blob)) => blob,
(None, None) => {
return Err(FocrError::Other(anyhow::anyhow!(
"convert produced no artifact for {}",
resolved.display()
)));
}
};
let output_bytes = blob.len();
let output_sha256 = hex_encode32(&quant::convert::sha256_of_bytes(&blob));
std::fs::write(&args.output, &blob).map_err(|e| {
FocrError::Other(anyhow::anyhow!(
"writing .focrq to {}: {e}",
args.output.display()
))
})?;
let sha_hex = hex_encode32(&source_sha256);
let quant_recipe = quant::convert::stamped_quant_recipe(arch, convert_quant);
if args.json {
emit(&serde_json::json!({
"schema_version": robot::ROBOT_SCHEMA_VERSION,
"command": "convert",
"status": "ok",
"implemented": true,
"input": resolved,
"output": args.output,
"quant": args.quant.as_str(),
"arch": args.arch.as_str(),
"model_id": arch.id(),
"quant_recipe": quant_recipe,
"source_sha256": sha_hex,
"source_shards": digests
.iter()
.map(|digest| serde_json::json!({
"name": digest.name,
"size": digest.size,
"sha256": digest.sha256_hex(),
}))
.collect::<Vec<_>>(),
"tensors": tensor_count,
"tensors_quantized": quantized,
"tensors_int8": quantized_int8,
"tensors_int4": quantized_int4,
"input_bytes": input_bytes,
"output_bytes": output_bytes,
"output_sha256": output_sha256,
"calib": args.calib,
"calib_covered_int4": calib_coverage.map(|((c, _), _)| c),
"calib_total_int4": calib_coverage.map(|((_, t), _)| t),
"calib_covered_int8": calib_coverage.map(|(_, (c, _))| c),
"calib_total_int8": calib_coverage.map(|(_, (_, t))| t),
}));
} else {
eprintln!(
"[focr] convert: wrote {} ({} quant {}: {tensor_count} tensors, \
{quantized_int8} int8 + {quantized_int4} int4, \
{input_bytes} -> {output_bytes} bytes) source_sha256={sha_hex} \
output_sha256={output_sha256}{}",
args.output.display(),
args.arch.as_str(),
args.quant.as_str(),
quant_recipe.map_or_else(String::new, |id| format!(" quant_recipe={id}")),
);
if let Some(((i4c, i4t), (i8c, i8t))) = calib_coverage {
eprintln!(
"[focr] convert: calibration-aware quantization (bd-50wo B+C): \
int4 coverage {i4c}/{i4t} tensors, int8 coverage {i8c}/{i8t} tensors \
(uncovered tensors fall back to uniform importance)"
);
}
}
Ok(())
}
fn hex_encode32(bytes: &[u8; 32]) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(64);
for &b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
fn run_runs(args: &RunsArgs) -> FocrResult<()> {
let limit = i64::from(non_negative_u32("limit", args.limit)?);
let store = crate::storage::RunStore::open(&crate::storage::RunStore::default_path()?)?;
let records = store.query(args.id.as_deref(), limit)?;
let format = if args.json {
OutputFormat::Json
} else {
args.format
};
let record_json = |r: &crate::storage::RunRecord| {
serde_json::json!({
"schema_version": crate::storage::SCHEMA_VERSION,
"run_id": r.run_id,
"started_at": r.started_at,
"finished_at": r.finished_at,
"input_path": r.input_path,
"mode": r.mode,
"quant": r.quant,
"model_version_tag": r.model_version_tag,
"exit_code": r.exit_code,
"status": r.status,
})
};
match format {
OutputFormat::Json => emit(&serde_json::json!({
"schema_version": robot::ROBOT_SCHEMA_VERSION,
"command": "runs",
"store": store.path(),
"count": records.len(),
"runs": records.iter().map(record_json).collect::<Vec<_>>(),
})),
OutputFormat::Ndjson => {
for r in &records {
emit(&record_json(r));
}
}
OutputFormat::Plain => {
if records.is_empty() {
println!("no recorded runs ({})", store.path().display());
}
for r in &records {
println!(
"{} {} {} exit {} {} {}",
r.run_id, r.status, r.mode, r.exit_code, r.quant, r.input_path
);
}
}
}
Ok(())
}
fn run_sync(args: &SyncArgs) -> FocrResult<()> {
let store = crate::storage::RunStore::open(&crate::storage::RunStore::default_path()?)?;
let (subcommand, file, n) = match &args.cmd {
SyncCmd::ExportJsonl { file } => {
let out = file.clone().unwrap_or_else(|| {
let mut p = store.path().to_path_buf();
p.set_extension("jsonl");
p
});
let n = crate::storage::export_jsonl(&store, &out)?;
("export-jsonl", out, n)
}
SyncCmd::ImportJsonl { file } => {
let n = crate::storage::import_jsonl(&store, file)?;
("import-jsonl", file.clone(), n)
}
};
if args.json {
emit(&serde_json::json!({
"schema_version": robot::ROBOT_SCHEMA_VERSION,
"command": "sync",
"subcommand": subcommand,
"store": store.path(),
"file": file,
"records": n,
}));
} else {
eprintln!(
"[focr] sync {subcommand}: {n} records via {}",
file.display()
);
}
Ok(())
}
fn task_name(t: crate::model_arch::Task) -> &'static str {
use crate::model_arch::Task;
match t {
Task::Ocr => "ocr",
Task::Formula => "formula",
Task::Tables => "tables",
Task::Chart => "chart",
Task::Molecular => "molecular",
Task::Geometry => "geometry",
Task::Music => "music",
Task::Describe => "describe",
Task::Vqa => "vqa",
Task::Handwriting => "handwriting",
}
}
fn model_arch_json(a: &dyn crate::model_arch::ModelArch) -> serde_json::Value {
serde_json::json!({
"id": a.id(),
"display_name": a.display_name(),
"implemented": a.implemented(),
"status": a.maturity().as_str(),
"tasks": a.tasks().iter().map(|t| task_name(*t)).collect::<Vec<_>>(),
"vision_encoder": format!("{:?}", a.vision_encoder()),
"decoder": format!("{:?}", a.decoder()),
"tokenizer": format!("{:?}", a.tokenizer()),
"default_artifact": a.default_artifact_basename(),
"recipe": a.required_quant_recipe("int8"),
"license": a.license_notice(),
})
}
#[derive(Debug, Default)]
struct PullAvailability {
in_manifest: bool,
compatible: Vec<(String, String)>,
blocked: Vec<(String, String)>,
}
fn pull_availability(manifest: Option<&dist::Manifest>, model_id: &str) -> PullAvailability {
let Some(manifest) = manifest else {
return PullAvailability::default();
};
let quants = if manifest.model == model_id {
Some(&manifest.quants)
} else {
manifest.models.get(model_id).map(|entry| &entry.quants)
};
let Some(quants) = quants else {
return PullAvailability::default();
};
let mut availability = PullAvailability {
in_manifest: true,
..PullAvailability::default()
};
for (quant, entry) in quants {
let item = (quant.clone(), entry.recipe.clone());
if dist::quant_recipe_is_compatible(model_id, quant, &entry.recipe) {
availability.compatible.push(item);
} else {
availability.blocked.push(item);
}
}
availability
}
fn run_models(args: &ModelsArgs) -> FocrResult<()> {
let archs = crate::model_arch::registry();
let manifest = dist::builtin_manifest()?;
if args.json {
let models: Vec<serde_json::Value> = archs
.iter()
.map(|a| {
let mut j = model_arch_json(*a);
j["installed"] = model_installed(a.id()).into();
let pull = pull_availability(Some(&manifest), a.id());
j["pull"] = serde_json::json!({
"in_manifest": pull.in_manifest,
"available": !pull.compatible.is_empty(),
"quants": pull.compatible.iter().map(|(quant, _)| quant).collect::<Vec<_>>(),
"recipes": pull.compatible.iter().map(|(quant, recipe)| serde_json::json!({
"quant": quant,
"recipe": recipe,
})).collect::<Vec<_>>(),
"blocked": pull.blocked.iter().map(|(quant, recipe)| serde_json::json!({
"quant": quant,
"recipe": recipe,
"required_recipe": a.required_quant_recipe(quant),
})).collect::<Vec<_>>(),
});
j
})
.collect();
emit(&serde_json::json!({
"schema_version": robot::ROBOT_SCHEMA_VERSION,
"models": models,
"guidance": {
"unlimited-ocr": "default runtime; FAST plain-text OCR; `focr pull` installs the conservative exact-recipe int8 artifact",
"unlimited-ocr-2": "best for documents and long documents: Baidu's long-document successor (SR-SWA); `focr pull unlimited-ocr-2` installs its certified int8 artifact, then `--model unlimited-ocr-2` reads single pages and documents of any length (`--multi-page`, in windows of 10 pages); `--model <dir>` also runs a raw checkpoint; unlimited-ocr stays the default until unlimited-ocr-2 passes its default gates",
"got-ocr2": "specialized structured output the default can't produce — math (LaTeX), tables, charts, molecular (SMILES), geometry, sheet music; heavier per page, use when you need FORMAT not plain text; shorthand: `focr ocr --task formula|tables|chart|molecular|geometry|music` (implies `--format`)"
},
}));
} else {
println!(
"{:<16} {:<12} {:<12} {:<5} {:<22} TASKS",
"ID", "STATUS", "PULL", "LOCAL", "MODEL"
);
for a in archs {
let tasks = a
.tasks()
.iter()
.map(|t| task_name(*t))
.collect::<Vec<_>>()
.join(",");
let status = a.maturity().as_str();
let availability = pull_availability(Some(&manifest), a.id());
let pull = if !availability.compatible.is_empty() {
availability
.compatible
.iter()
.map(|(quant, _)| quant.as_str())
.collect::<Vec<_>>()
.join(",")
} else if !availability.blocked.is_empty() {
"blocked".to_owned()
} else if a.implemented() {
"local".to_owned()
} else {
"-".to_owned()
};
println!(
"{:<16} {:<12} {:<12} {:<5} {:<22} {}",
a.id(),
status,
pull,
if model_installed(a.id()) { "yes" } else { "-" },
a.display_name(),
tasks
);
}
println!();
println!("Choosing a model:");
println!(" unlimited-ocr (default) FAST plain-text OCR. `focr pull` installs the");
println!(" conservative exact-recipe int8 artifact.");
println!(" unlimited-ocr-2 Best for documents and long documents");
println!(" (`--multi-page` over a PDF or a directory of pages).");
println!(" `focr pull unlimited-ocr-2`, then");
println!(" `--model unlimited-ocr-2`. unlimited-ocr stays the");
println!(" default until it passes its default gates.");
println!(
" got-ocr2 SPECIALIZED structured output the default can't produce:"
);
println!(" math (LaTeX), tables, charts, molecular (SMILES),");
println!(
" geometry, sheet music. Heavier per page — use it when you"
);
println!(
" need FORMAT, not for plain text. `focr pull got-ocr2`,"
);
println!(" then `focr ocr --model got-ocr2.int8.focrq <image>`.");
println!(
" Add `--format` for structured .mmd output (LaTeX/tables/…),"
);
println!(
" or `--task formula|tables|chart|molecular|geometry|music`"
);
println!(
" to select the format mode by task (implies `--format`)."
);
}
Ok(())
}
fn run_doctor(args: &DoctorArgs) -> FocrResult<()> {
use crate::doctor;
match &args.cmd {
Some(DoctorCmd::Capabilities) => {
emit(&doctor::capabilities());
return Ok(());
}
Some(DoctorCmd::RobotDocs) => {
print!("{}", doctor::robot_docs());
return Ok(());
}
Some(DoctorCmd::Undo { run_id }) => {
let root = doctor::DoctorRoot::resolve()?;
let restored = doctor::undo(&root, run_id)?;
emit(&serde_json::json!({
"schema_version": doctor::DOCTOR_SCHEMA_VERSION,
"command": "doctor.undo",
"run_id": run_id,
"actions_restored": restored,
"verified": "every restored file hash-matched its recorded before_hash",
}));
return Ok(());
}
None => {}
}
let root = doctor::DoctorRoot::resolve()?;
let findings = doctor::detect(&root);
let run_id = format!(
"run-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or_default()
);
let planned: Vec<serde_json::Value> = findings
.iter()
.map(|f| {
serde_json::json!({
"detector": f.detector,
"severity": f.severity,
"path": f.path,
"message": f.message,
"fixability": f.fixability,
})
})
.collect();
if args.robot_triage {
let recommended = if findings.is_empty() {
"focr ocr <image> -o out.md".to_string()
} else if args.fix {
"focr doctor --fix".to_string()
} else {
"focr doctor --fix # safe repairs only; see `focr doctor capabilities --json`"
.to_string()
};
emit(&serde_json::json!({
"schema_version": doctor::DOCTOR_SCHEMA_VERSION,
"command": "doctor.robot-triage",
"summary": {"findings": findings.len(), "healthy": findings.is_empty()},
"findings": planned,
"actions_planned": findings.iter().filter(|f| matches!(f.fixability, doctor::Fixability::Auto{..})).count(),
"recommended_command": recommended,
"capabilities_hint": "focr doctor capabilities --json",
}));
std::process::exit(if findings.is_empty() {
doctor::EXIT_HEALTHY
} else {
doctor::EXIT_FINDINGS
});
}
if args.dry_run {
emit(&serde_json::json!({
"schema_version": doctor::DOCTOR_SCHEMA_VERSION,
"command": "doctor.dry-run",
"would_mutate": findings.iter().filter(|f| matches!(f.fixability, doctor::Fixability::Auto{..})).count(),
"blast_radius": root.cache_root.display().to_string(),
"findings": planned,
"note": "NO mutation performed; --fix applies the auto items backup-first",
}));
std::process::exit(if findings.is_empty() {
doctor::EXIT_HEALTHY
} else {
doctor::EXIT_FINDINGS
});
}
if args.fix {
let report = match doctor::fix(&root, &findings, &run_id) {
Ok(r) => r,
Err(e) => {
let msg = format!("{e}");
if msg.contains("doctor lock held") {
eprintln!("focr doctor: {msg}");
std::process::exit(doctor::EXIT_CONCURRENCY_LOST);
}
return Err(e);
}
};
emit(&serde_json::json!({
"schema_version": doctor::DOCTOR_SCHEMA_VERSION,
"command": "doctor.fix",
"run_id": report.run_id,
"fixed": report.fixed,
"refused_unsafe": report.refused,
"advice_only": report.advice_only,
"failed_rolled_back": report.failed_rolled_back,
"undo": format!("focr doctor undo {}", report.run_id),
"findings": planned,
}));
std::process::exit(report.exit_code);
}
if args.json {
emit(&serde_json::json!({
"schema_version": doctor::DOCTOR_SCHEMA_VERSION,
"command": "doctor",
"healthy": findings.is_empty(),
"findings": planned,
}));
} else if findings.is_empty() {
println!("focr doctor: healthy ({} checks green)", 4);
} else {
for f in &findings {
println!("[{}] {}: {}", f.severity, f.detector, f.message);
if let doctor::Fixability::RefusedUnsafe {
recommended_command,
} = &f.fixability
{
println!(" run: {recommended_command}");
}
}
}
std::process::exit(if findings.is_empty() {
doctor::EXIT_HEALTHY
} else {
doctor::EXIT_FINDINGS
});
}
fn forced_test_error() -> FocrResult<Option<FocrError>> {
#[cfg(debug_assertions)]
{
let Some(raw) = std::env::var_os(FORCE_TEST_ERROR_ENV) else {
return Ok(None);
};
if raw.as_os_str().is_empty() {
return Ok(None);
}
let value = raw.to_string_lossy();
if value.contains('@') {
return Ok(None);
}
forced_error_of_kind(&value).map(Some)
}
#[cfg(not(debug_assertions))]
{
Ok(None)
}
}
#[cfg(debug_assertions)]
fn forced_error_of_kind(kind: &str) -> FocrResult<FocrError> {
match kind {
"input_decode" => Ok(FocrError::InputDecode(format!(
"forced by {FORCE_TEST_ERROR_ENV}=input_decode"
))),
"timeout" => Ok(FocrError::Timeout(format!(
"forced by {FORCE_TEST_ERROR_ENV}=timeout"
))),
"cancelled" => Ok(FocrError::Cancelled),
other => Err(FocrError::Usage(format!(
"invalid {FORCE_TEST_ERROR_ENV}={other:?}; expected input_decode, timeout, or \
cancelled"
))),
}
}
fn forced_image_error(ordinal: usize) -> Option<FocrError> {
#[cfg(debug_assertions)]
{
let value = std::env::var(FORCE_TEST_ERROR_ENV).ok()?;
let (kind, at) = value.split_once('@')?;
at.parse::<usize>()
.is_ok_and(|at| at == ordinal)
.then(|| forced_error_of_kind(kind).ok())
.flatten()
}
#[cfg(not(debug_assertions))]
{
let _ = ordinal;
None
}
}
fn positive_u32(name: &str, value: i64) -> FocrResult<u32> {
if value <= 0 {
return Err(FocrError::Usage(format!("{name} must be > 0, got {value}")));
}
u32::try_from(value)
.map_err(|_| FocrError::Usage(format!("{name} is too large for u32: {value}")))
}
fn non_negative_u32(name: &str, value: i64) -> FocrResult<u32> {
if value < 0 {
return Err(FocrError::Usage(format!(
"{name} must be >= 0, got {value}"
)));
}
u32::try_from(value)
.map_err(|_| FocrError::Usage(format!("{name} is too large for u32: {value}")))
}
fn greedy_temperature(value: f32) -> FocrResult<f32> {
if value != 0.0 {
return Err(FocrError::Usage(format!(
"temperature must be 0: every model here decodes greedily, got {value}"
)));
}
Ok(value)
}
fn robot_health_payload() -> serde_json::Value {
let model_spec = OcrEngine::model_path();
let model_present = native_engine::native_model_available(&model_spec);
let model_search_dirs: Vec<_> = native_engine::model_resolution_search_dirs()
.into_iter()
.map(|p| p.display().to_string())
.collect();
serde_json::json!({
"schema_version": robot::ROBOT_SCHEMA_VERSION,
"status": "scaffold",
"ready": false,
"phase": "pre-Phase-0 skeleton",
"model_present": model_present,
"model_spec": model_spec.display().to_string(),
"model_search_dirs": model_search_dirs,
"model_license_notice": FOCR_MODEL_LICENSE_NOTICE,
})
}
fn robot_triage_payload() -> serde_json::Value {
let health = robot_health_payload();
let model_present = health["model_present"].as_bool().unwrap_or(false);
let recommendations: Vec<&str> = if model_present {
vec![
"focr ocr <image-or-pdf> -o out.md # primary: OCR to markdown",
"focr ocr <image> --json # structured JSON + bounding boxes",
"focr models # which zoo models/tasks this build can run",
"focr ocr-batch <img1> <img2> ... # load weights once, many pages",
]
} else {
vec![
"focr pull # install the default conservative Unlimited-OCR model",
"FOCR_MODEL_PATH=/path/to/raw-or-compatible-model focr ocr <image-or-pdf> -o out.md",
"focr models # inspect compatible and blocked model pulls",
]
};
serde_json::json!({
"schema_version": robot::ROBOT_SCHEMA_VERSION,
"command": "robot.triage",
"quick_ref": {
"ocr": "parse a document image/PDF into markdown (--json for boxes; -o FILE to write)",
"ocr-batch": "many images in one process (weights load once)",
"pull": "download a manifest-compatible model into the cache; defaults to the conservative Unlimited-OCR artifact",
"models": "list model ids, tasks, and compatible or blocked pull status",
"convert": "offline safetensors -> .focrq quantization",
"runs": "query run history (--format json|ndjson; empty history = exit 0)",
"sync": "export/import the append-only run audit JSONL (one-way contract)",
"doctor": "self-check/repair",
"robot": "agent surfaces: run (NDJSON stream), schema, health, backends, selftest, triage",
},
"health": health,
"recommendations": recommendations,
"commands": {
"first_ocr": "focr ocr page.png -o page.md",
"structured": "focr ocr page.png --json",
"stream_events": "focr robot run page.png",
"contract": "focr robot schema",
},
"exit_codes": robot::robot_schema()["exit_codes"].clone(),
})
}
fn emit(value: &serde_json::Value) {
println!(
"{}",
serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
);
}
fn is_interactive() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}
fn with_pull_hint(msg: &str, requested: Option<&Path>) -> String {
let Some(id) = requested
.and_then(Path::to_str)
.filter(|id| crate::model_arch::arch_by_id(id).is_some())
else {
return format!(
"{msg} — run `focr pull` to install the compatible conservative default, point \
FOCR_MODEL_PATH at raw BF16 weights or an exact-recipe local artifact, or inspect \
`focr models` for other compatible pulls"
);
};
let manifest = dist::builtin_manifest().ok();
if !pull_availability(manifest.as_ref(), id)
.compatible
.is_empty()
{
format!(
"{msg} — run `focr pull {id}` to install it, or point FOCR_MODEL_PATH at its raw \
weights or an exact-recipe local artifact"
)
} else {
format!(
"{msg} — this release's manifest does not publish {id}; point FOCR_MODEL_PATH at \
its raw weights or an exact-recipe local artifact, or inspect `focr models` for \
the models `focr pull` installs"
)
}
}
fn offer_first_run_download() -> FocrResult<Option<dist::PullOutcome>> {
use std::io::Write as _;
let source = dist::resolve_manifest_source(None);
let model = if source == dist::DEFAULT_MANIFEST_SOURCE {
match first_run_offer(&MODE_DEFAULTS, &dist::builtin_manifest()?) {
Some(model) => model,
None => return Ok(None),
}
} else {
mode_default(&MODE_DEFAULTS, RunMode::Pull)
};
eprint!("focr: model not found. Download {model} from the configured manifest now? [y/N] ");
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.map_err(|e| FocrError::Other(anyhow::anyhow!("reading prompt response: {e}")))?;
if !matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
return Ok(None);
}
let outcome = dist::pull(Some(model), dist::DEFAULT_QUANT, &source, |event| {
eprintln!("focr pull: {event}");
})?;
Ok(Some(outcome))
}
fn run_pull(args: &PullArgs) -> FocrResult<()> {
let source = dist::resolve_manifest_source(args.manifest.as_deref());
let outcome = dist::pull(
Some(
args.model
.as_deref()
.unwrap_or_else(|| mode_default(&MODE_DEFAULTS, RunMode::Pull)),
),
&args.quant,
&source,
|event| {
if args.json {
let mut line = event.to_json();
line["schema_version"] = robot::ROBOT_SCHEMA_VERSION.into();
line["command"] = "pull".into();
eprintln!("{line}");
} else {
eprintln!("focr pull: {event}");
}
},
)?;
if args.json {
emit(&serde_json::json!({
"schema_version": robot::ROBOT_SCHEMA_VERSION,
"command": "pull",
"status": "ok",
"quant": outcome.quant,
"focrq": outcome.focrq_path.display().to_string(),
"tokenizer": outcome.tokenizer_path.display().to_string(),
"sidecars": outcome
.sidecar_paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>(),
"from_cache": outcome.from_cache,
"model_license_notice": if outcome.license_notice.is_empty() {
args.model
.as_deref()
.and_then(crate::model_arch::arch_by_id)
.unwrap_or_else(crate::model_arch::default_arch)
.license_notice()
} else {
&outcome.license_notice
},
}));
} else {
eprintln!(
"focr pull: ready — model at {} ({})",
outcome.focrq_path.display(),
if outcome.from_cache {
"already cached"
} else {
"downloaded"
}
);
}
Ok(())
}
fn robot_backends_payload() -> serde_json::Value {
let hardware_selected = simd::detected_tier();
let effective_route = simd::effective_dense_route();
let available: Vec<_> = simd::available_tiers()
.iter()
.map(|tier| {
serde_json::json!({
"tag": tier.tag(),
"feature": tier.feature_string(),
})
})
.collect();
serde_json::json!({
"schema_version": robot::ROBOT_SCHEMA_VERSION,
"simd_tiers": {
"selected": effective_route.tag(),
"selected_feature": effective_route.feature_string(),
"hardware_selected": hardware_selected.tag(),
"hardware_selected_feature": hardware_selected.feature_string(),
"available": available,
"override_env": "FOCR_FORCE_ARCH",
"selection_scope": "ordinary_dense_int8",
"status": "runtime capability and effective-route selection active"
},
"logical_cpus": std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0),
"threads": crate::thread_budget()
})
}
fn run_robot_selftest() -> FocrResult<()> {
let report = simd::selftest();
let cases: Vec<_> = report
.cases
.iter()
.map(|c| {
serde_json::json!({
"kind": c.kind,
"label": c.label,
"m": c.m,
"k": c.k,
"n": c.n,
"ok": c.ok,
"mismatches": c.mismatches,
"first_bad": c.first_bad.map(|(i, got, want)| serde_json::json!({
"index": i, "dispatched": got, "oracle": want,
})),
})
})
.collect();
let available: Vec<_> = report.available.iter().map(|t| t.tag()).collect();
let executed_routes: Vec<_> = report.executed_routes.iter().map(|r| r.tag()).collect();
let passed = report.cases.iter().filter(|c| c.ok).count();
let oracle_independent = !matches!(
report.effective_route,
simd::EffectiveI8Route::Autovec | simd::EffectiveI8Route::Scalar
);
emit(&serde_json::json!({
"schema_version": robot::ROBOT_SCHEMA_VERSION,
"command": "robot.selftest",
"selected": report.effective_route.tag(),
"selected_feature": report.effective_route.feature_string(),
"hardware_selected": report.hardware_selected.tag(),
"hardware_selected_feature": report.hardware_selected.feature_string(),
"executed_routes": executed_routes,
"route_consistent": report.route_consistent,
"oracle_independent": oracle_independent,
"available": available,
"override_env": "FOCR_FORCE_ARCH",
"logical_cpus": std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0),
"threads": crate::thread_budget(),
"cases_total": report.cases.len(),
"cases_passed": passed,
"all_ok": report.all_ok,
"verdict": if report.all_ok { "pass" } else { "fail" },
"models": report.models.iter().map(|(id, ok)| serde_json::json!({
"id": id, "verdict": if *ok { "pass" } else { "fail" },
})).collect::<Vec<_>>(),
"cases": cases,
}));
if report.all_ok {
Ok(())
} else {
let failed = report.cases.len() - passed;
Err(FocrError::Other(anyhow::anyhow!(
"robot selftest: {failed}/{} parity case(s) diverged; route_consistent={} \
expected={} observed={:?}, hardware={} — the dense int8 path is not certified on this CPU",
report.cases.len(),
report.route_consistent,
report.effective_route.tag(),
report
.executed_routes
.iter()
.map(|r| r.tag())
.collect::<Vec<_>>(),
report.hardware_selected.feature_string(),
)))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ErrorMode {
Human,
Robot,
}
impl ErrorMode {
fn from_cli(cli: &Cli) -> Self {
match &cli.command {
Command::Ocr(args) if args.robot => Self::Robot,
Command::Robot {
cmd: RobotCmd::Run(_),
} => Self::Robot,
_ => Self::Human,
}
}
}
fn exit_code_from_error(err: &FocrError, mode: ErrorMode) -> ExitCode {
match mode {
ErrorMode::Human => {
let _ = progress::try_stderr_message(format_args!("focr: {err}"));
}
ErrorMode::Robot => emit(&robot::run_error_event(err)),
}
ExitCode::from(exit_code_byte(err))
}
fn exit_code_byte(err: &FocrError) -> u8 {
u8::try_from(err.exit_code()).unwrap_or(1)
}
#[cfg(test)]
mod tests {
use super::*;
const V1: &str = native_engine::model_arch::UNLIMITED_OCR_V1_ID;
const V2: &str = native_engine::model_arch::UNLIMITED_OCR_V2_ID;
const MODES: [RunMode; 7] = [
RunMode::SinglePage,
RunMode::PdfDocument,
RunMode::DirectoryDocument,
RunMode::MultiPage,
RunMode::Batch,
RunMode::BatchMultiPage,
RunMode::Pull,
];
fn v2_table() -> [(RunMode, &'static str); 7] {
MODES.map(|mode| (mode, V2))
}
#[test]
fn the_shipped_mode_table_defaults_every_mode_to_v1() {
for mode in MODES {
let entries = MODE_DEFAULTS.iter().filter(|(m, _)| *m == mode).count();
assert_eq!(entries, 1, "{mode:?}");
assert_eq!(mode_default(&MODE_DEFAULTS, mode), V1, "{mode:?}");
}
}
#[test]
fn an_ocr_run_mode_follows_its_input() {
let mode = |argv: &[&str], is_document: bool| {
let cli = Cli::try_parse_from(argv).expect("ocr parses");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
RunMode::of_ocr(&args.to_request().expect("request builds"), is_document)
};
let dir = std::env::temp_dir().display().to_string();
assert_eq!(
mode(&["focr", "ocr", "scan.png"], false),
RunMode::SinglePage
);
assert_eq!(
mode(&["focr", "ocr", "doc.pdf"], true),
RunMode::PdfDocument
);
assert_eq!(
mode(&["focr", "ocr", &dir], true),
RunMode::DirectoryDocument
);
assert_eq!(
mode(&["focr", "ocr", "doc.pdf", "--multi-page"], true),
RunMode::MultiPage
);
}
#[test]
fn model_choice_follows_the_precedence_matrix() {
for table in [MODE_DEFAULTS, v2_table()] {
for mode in MODES {
let default_id = mode_default(&table, mode);
for named in [true, false] {
for installed in [true, false] {
let mut probed = false;
let choice = choose_model(named, default_id, |id| {
probed = true;
assert_eq!(id, default_id);
installed
});
let want = match (named, default_id == V1, installed) {
(true, ..) => ModelChoice::Named,
(false, true, _) => ModelChoice::V1Default,
(false, false, true) => ModelChoice::ModeDefault(default_id),
(false, false, false) => ModelChoice::MissingDefault(default_id),
};
assert_eq!(choice, want, "{mode:?} named={named} installed={installed}");
assert_eq!(probed, !named && default_id != V1, "{mode:?}");
}
}
}
}
}
#[test]
fn a_pull_hint_names_the_missing_default_and_prints_once() {
let hint = pull_hint(RunMode::PdfDocument, V2);
assert_eq!(hint.kind, "pull_hint");
assert!(native_engine::RUN_WARNING_KINDS.contains(&hint.kind));
let json = hint.to_json();
assert_eq!(json["model_id"], V2);
assert_eq!(json["mode"], "pdf");
assert!(
hint.message
.contains("unlimited-ocr-2, which is not installed")
&& hint.message.contains("`focr pull unlimited-ocr-2`"),
"{}",
hint.message
);
assert_eq!(
hint.recovery,
native_engine::warning_recovery(native_engine::WarningKind::PullHint)
);
assert!(printed_early(&hint));
}
#[test]
fn the_first_run_offer_names_the_table_default() {
let builtin = dist::builtin_manifest().expect("embedded manifest parses");
assert_eq!(first_run_offer(&MODE_DEFAULTS, &builtin), Some(V1));
assert_eq!(
first_run_offer(&v2_table(), &builtin),
pull_availability(Some(&builtin), V2)
.compatible
.first()
.map(|_| V2)
);
let entry = |recipe: &str| dist::ModelEntry {
license_notice: builtin.license_notice.clone(),
version: None,
quants: [(
"int8".to_owned(),
dist::QuantEntry {
recipe: recipe.to_owned(),
focrq: builtin.quants["int8"].focrq.clone(),
},
)]
.into(),
tokenizer: builtin.tokenizer.clone(),
sidecars: Vec::new(),
};
let mut local = builtin.clone();
local.models.insert(
V2.to_owned(),
entry(crate::quant::convert::UNLIMITED_OCR_2_INT8_RECIPE_ID),
);
assert_eq!(first_run_offer(&v2_table(), &local), Some(V2));
assert_eq!(first_run_offer(&MODE_DEFAULTS, &local), Some(V1));
local
.models
.insert(V2.to_owned(), entry("some-other-recipe-v1"));
assert_eq!(first_run_offer(&v2_table(), &local), None);
}
#[test]
fn every_error_variant_maps_to_process_exit_byte_from_error_contract() {
let cases = [
(FocrError::Usage("bad flag".into()), 2),
(FocrError::ModelNotFound("missing".into()), 3),
(FocrError::InputDecode("bad image".into()), 4),
(FocrError::Timeout("stage".into()), 5),
(FocrError::Cancelled, 6),
(FocrError::FormatMismatch("bad header".into()), 7),
(FocrError::LowYield("sparse".into()), 8),
(FocrError::NotImplemented("phase gap".into()), 1),
(FocrError::Other(anyhow::anyhow!("misc")), 1),
];
for (err, code) in cases {
eprintln!(
"{}",
serde_json::json!({
"suite": "cli",
"test": "every_error_variant_maps_to_process_exit_byte_from_error_contract",
"variant": err.kind(),
"exit_code": code,
"process_exit_byte": exit_code_byte(&err),
})
);
assert_eq!(exit_code_byte(&err), code);
}
}
#[test]
fn page_window_flag_takes_pages_auto_or_off_up_to_the_page_limit() {
use native_engine::{PageWindow, UOCR2_UNWINDOWED_PAGE_LIMIT};
let parse = |value: &str| {
Cli::try_parse_from([
"focr",
"ocr",
"b.pdf",
"--multi-page",
"--page-window",
value,
])
.map(|cli| match cli.command {
Command::Ocr(args) => args.request.page_window,
_ => panic!("not ocr"),
})
.map_err(|error| (error.kind(), error.to_string()))
};
assert_eq!(parse("7"), Ok(Some(PageWindow::Pages(7))));
assert_eq!(parse("auto"), Ok(Some(PageWindow::Auto)));
assert_eq!(parse("off"), Ok(Some(PageWindow::Off)));
let limit = UOCR2_UNWINDOWED_PAGE_LIMIT;
assert_eq!(
parse(&limit.to_string()),
Ok(Some(PageWindow::Pages(limit)))
);
let (kind, message) = parse(&(limit + 1).to_string()).expect_err("above the limit");
assert_eq!(kind, clap::error::ErrorKind::ValueValidation);
assert!(message.contains("at most 102 pages"), "{message}");
for bad in ["0", "many", "-3", "1.5", ""] {
assert!(parse(bad).is_err(), "{bad:?}");
}
}
#[test]
fn resume_flags_parse_and_a_stopped_document_names_its_checkpoint() {
let resume = |flags: &[&str]| {
let mut argv = vec!["focr", "ocr", "book.pdf", "--multi-page"];
argv.extend_from_slice(flags);
match Cli::try_parse_from(argv).expect("ocr parses").command {
Command::Ocr(args) => (args.request.resume, args.request.keep_checkpoint),
_ => panic!("not ocr"),
}
};
assert_eq!(resume(&[]), (None, false));
assert_eq!(resume(&["--resume"]), (Some(ResumeMode::Matching), false));
assert_eq!(
resume(&["--resume=recompute", "--keep-checkpoint"]),
(Some(ResumeMode::Recompute), true)
);
assert!(Cli::try_parse_from(["focr", "ocr", "b.pdf", "--resume=sideways"]).is_err());
let cli = Cli::try_parse_from(["focr", "ocr", "--resume", "book.pdf"]).expect("parses");
assert!(
matches!(cli.command, Command::Ocr(args) if args.request.image.ends_with("book.pdf"))
);
let dir = std::env::temp_dir().join(format!("focr-cli-resume-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("a directory");
let key = native_engine::checkpoint::ResumeKey::new();
let files = native_engine::checkpoint::RunFiles::beside(&dir.join("out.md"), key);
let stopped = || {
FocrError::Partial(Box::new(crate::error::PartialOutput {
finish_reason: "cancelled",
pages_out: 10,
warnings: Vec::new(),
model_id: "unlimited-ocr-2",
source_sha256: None,
message: "x".into(),
checkpoint: None,
resumed_from: None,
}))
};
let error = with_resume_fields(stopped(), Some(&files), None);
assert!(matches!(&error, FocrError::Partial(p) if p.checkpoint.is_none()));
std::fs::write(&files.checkpoint, b"state").expect("a checkpoint");
let error = with_resume_fields(stopped(), Some(&files), Some(11));
match &error {
FocrError::Partial(partial) => {
assert_eq!(partial.resumed_from, Some(11));
assert_eq!(
partial.checkpoint.as_deref(),
Some(files.checkpoint.display().to_string().as_str())
);
}
other => panic!("not partial: {other:?}"),
}
let other = with_resume_fields(FocrError::Cancelled, Some(&files), None);
assert!(matches!(other, FocrError::Cancelled));
}
#[test]
fn long_version_carries_project_and_model_license_notices() {
let report = long_version_report();
assert!(report.contains("focr "));
assert!(report.contains(FOCR_PROJECT_LICENSE_NOTICE));
assert!(report.contains(&format!("model_license: {FOCR_MODEL_LICENSE_NOTICE}")));
}
#[test]
fn exact_long_version_detection_only_matches_top_level_long_flag() {
assert!(is_exact_long_version_request(
["focr", "--version"].into_iter().map(OsString::from)
));
assert!(!is_exact_long_version_request(
["focr", "-V"].into_iter().map(OsString::from)
));
assert!(!is_exact_long_version_request(
["focr", "--version", "robot"]
.into_iter()
.map(OsString::from)
));
}
#[test]
fn robot_health_carries_single_source_model_license_notice() {
let payload = robot_health_payload();
assert_eq!(
payload["model_license_notice"],
serde_json::json!(FOCR_MODEL_LICENSE_NOTICE)
);
}
#[test]
fn ocr_robot_flag_selects_robot_error_mode() {
let cli = Cli {
command: Command::Ocr(OcrArgs {
request: OcrRequestArgs {
image: PathBuf::from("scan.png"),
model: None,
base_size: None,
image_size: None,
crop_mode: Some(CropMode::Gundam),
max_length: None,
temperature: None,
no_repeat_ngram: None,
ngram_window: None,
max_page_tokens: None,
format: false,
task: OcrTask::Ocr,
question: None,
region_box: None,
color: None,
pages: None,
split_spreads: false,
multi_page: false,
page_window: None,
resume: None,
keep_checkpoint: false,
no_resident: false,
threads: None,
timing: false,
fail_on_low_yield: false,
},
json: false,
output: None,
extract_figures: false,
figures_dir: None,
robot: true,
check: false,
raw: false,
}),
};
assert_eq!(ErrorMode::from_cli(&cli), ErrorMode::Robot);
}
#[test]
fn robot_run_selects_robot_error_mode() {
let cli = Cli {
command: Command::Robot {
cmd: RobotCmd::Run(Box::new(RobotRunArgs {
request: OcrRequestArgs {
image: PathBuf::from("scan.png"),
model: None,
base_size: None,
image_size: None,
crop_mode: Some(CropMode::Gundam),
max_length: None,
temperature: None,
no_repeat_ngram: None,
ngram_window: None,
max_page_tokens: None,
format: false,
task: OcrTask::Ocr,
question: None,
region_box: None,
color: None,
pages: None,
split_spreads: false,
multi_page: false,
page_window: None,
resume: None,
keep_checkpoint: false,
no_resident: false,
threads: None,
timing: false,
fail_on_low_yield: false,
},
})),
},
};
assert_eq!(ErrorMode::from_cli(&cli), ErrorMode::Robot);
}
#[test]
fn ocr_args_validate_rejects_negative_size() {
let args = OcrArgs {
request: OcrRequestArgs {
image: PathBuf::from("scan.png"),
model: None,
base_size: Some(-1),
image_size: None,
crop_mode: Some(CropMode::Gundam),
max_length: None,
temperature: None,
no_repeat_ngram: None,
ngram_window: None,
max_page_tokens: None,
format: false,
task: OcrTask::Ocr,
question: None,
region_box: None,
color: None,
pages: None,
split_spreads: false,
multi_page: false,
page_window: None,
resume: None,
keep_checkpoint: false,
no_resident: false,
threads: None,
timing: false,
fail_on_low_yield: false,
},
json: false,
output: None,
extract_figures: false,
figures_dir: None,
robot: false,
check: false,
raw: false,
};
let err = args.to_request().expect_err("negative base-size is usage");
assert!(matches!(err, FocrError::Usage(_)), "got {err:?}");
assert_eq!(err.exit_code(), 2);
}
#[test]
fn output_is_json_follows_extension_case_insensitively() {
assert!(output_is_json(Some(Path::new("out.json"))));
assert!(output_is_json(Some(Path::new("OUT.JSON"))));
assert!(output_is_json(Some(Path::new("/tmp/a/b.Json"))));
assert!(!output_is_json(Some(Path::new("out.md"))));
assert!(!output_is_json(Some(Path::new("out.txt"))));
assert!(!output_is_json(Some(Path::new("out"))));
assert!(!output_is_json(None));
}
#[test]
fn ocr_output_flag_parses_short_and_long() {
for flag in ["-o", "--output"] {
let cli = Cli::try_parse_from(["focr", "ocr", "scan.png", flag, "result.json"])
.expect("ocr -o/--output parses");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
assert_eq!(args.output.as_deref(), Some(Path::new("result.json")));
}
let cli = Cli::try_parse_from(["focr", "ocr", "scan.png"]).expect("ocr parses");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
assert!(args.output.is_none());
}
#[test]
fn ocr_format_flag_threads_to_request() {
let cli = Cli::try_parse_from(["focr", "ocr", "scan.png", "--format"])
.expect("ocr --format parses");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
assert!(args.to_request().expect("request builds").format);
let cli = Cli::try_parse_from(["focr", "ocr", "scan.png"]).expect("ocr parses");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
assert!(!args.to_request().expect("request builds").format);
}
fn ocr_request_from(argv: &[&str]) -> FocrResult<OcrRequest> {
let full: Vec<&str> = ["focr", "ocr"].iter().chain(argv).copied().collect();
let cli = Cli::try_parse_from(full).expect("ocr argv parses");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
args.to_request()
}
#[test]
fn ocr_task_flag_threads_format_to_request() {
for task in [
"formula",
"tables",
"chart",
"molecular",
"geometry",
"music",
] {
let req =
ocr_request_from(&["scan.png", "--task", task, "--model", "got-ocr2.int8.focrq"])
.expect("request builds");
assert!(req.format, "--task {task} must imply format");
}
let req = ocr_request_from(&["scan.png", "--task", "ocr"]).expect("request builds");
assert!(!req.format, "--task ocr stays plain");
let req = ocr_request_from(&["scan.png"]).expect("request builds");
assert!(!req.format, "default task stays plain");
}
#[test]
fn only_chart_and_formula_pad_the_got_image() {
for (task, pads) in [
("chart", true),
("formula", true),
("tables", false),
("molecular", false),
("geometry", false),
("music", false),
] {
let req =
ocr_request_from(&["scan.png", "--task", task, "--model", "got-ocr2.int8.focrq"])
.expect("request builds");
assert_eq!(req.square_pad, pads, "--task {task}");
}
let req = ocr_request_from(&["scan.png", "--format", "--model", "got-ocr2.int8.focrq"])
.expect("request builds");
assert!(!req.square_pad, "`--format` alone is the upstream squash");
}
#[test]
fn box_and_color_name_a_got_region() {
use native_engine::got::{FrameColor, Region};
let got = ["--model", "got-ocr2.int8.focrq"];
for (spec, want) in [
("10,20,300,400", Region::Box([10.0, 20.0, 300.0, 400.0])),
(
"[10, 20.5, 300, 400]",
Region::Box([10.0, 20.5, 300.0, 400.0]),
),
("7,8", Region::Point([7.0, 8.0])),
] {
let req = ocr_request_from(&[&["scan.png", "--box", spec][..], &got[..]].concat())
.expect("request builds");
assert_eq!(req.region, Some(want), "--box {spec}");
}
let argv = [&["scan.png", "--color", "blue", "--format"][..], &got[..]].concat();
let req = ocr_request_from(&argv).expect("request builds");
assert_eq!(req.region, Some(Region::Color(FrameColor::Blue)));
assert!(req.format, "a region composes with --format");
let argv = [
&["scan.png", "--color", "red", "--task", "chart"][..],
&got[..],
]
.concat();
let req = ocr_request_from(&argv).expect("request builds");
assert!(req.square_pad && req.region.is_some());
assert_eq!(
ocr_request_from(&["scan.png"])
.expect("request builds")
.region,
None
);
}
#[test]
fn a_region_is_refused_where_got_would_not_read_it() {
let got = ["--model", "got-ocr2.int8.focrq"];
for (argv, says) in [
(vec!["--box", "1,2,3"], "3 values"),
(vec!["--box", "1,2,3,4,5"], "5 values"),
(vec!["--box", "10,20,5,40"], "below and to the right"),
(vec!["--box", "10,-1,20,40"], "non-negative"),
(vec!["--box", "a,b"], "non-negative"),
(vec!["--box", ""], "non-negative"),
(
vec!["--box", "1,2,3,4", "--task", "chart"],
"pads it to a square",
),
(
vec!["--box", "1,2,3,4", "--task", "formula"],
"pads it to a square",
),
] {
let argv = [&["scan.png"][..], &argv[..], &got[..]].concat();
let err = ocr_request_from(&argv).expect_err("refused");
assert!(matches!(err, FocrError::Usage(_)), "{argv:?}: {err}");
assert!(err.to_string().contains(says), "{argv:?}: {err}");
}
for argv in [
vec!["scan.png", "--box", "1,2,3,4"],
vec![
"scan.png",
"--color",
"red",
"--model",
"unlimited-ocr.int8.focrq",
],
vec![
"scan.png",
"--color",
"red",
"--task",
"describe",
"--model",
"smolvlm2.int8.focrq",
],
vec![
"scan.png",
"--color",
"red",
"--task",
"chart-data",
"--model",
"onechart.int8.focrq",
],
] {
let err = ocr_request_from(&argv).expect_err("refused");
assert!(matches!(err, FocrError::Usage(_)), "{argv:?}: {err}");
assert!(err.to_string().contains("got-ocr2"), "{argv:?}: {err}");
}
for argv in [
vec![
"focr", "ocr", "scan.png", "--box", "1,2,3,4", "--color", "red",
],
vec!["focr", "ocr", "scan.png", "--color", "purple"],
] {
let Err(err) = Cli::try_parse_from(&argv) else {
panic!("{argv:?}: clap accepted it");
};
assert_eq!(err.exit_code(), 2, "{argv:?}");
}
}
#[test]
fn ocr_task_composes_with_explicit_format() {
let req =
ocr_request_from(&["scan.png", "--task", "ocr", "--format"]).expect("request builds");
assert!(req.format, "--format must not be masked by --task ocr");
let with_both = ocr_request_from(&[
"scan.png",
"--task",
"tables",
"--format",
"--model",
"got-ocr2.int8.focrq",
])
.expect("request builds");
let task_only = ocr_request_from(&[
"scan.png",
"--task",
"tables",
"--model",
"got-ocr2.int8.focrq",
])
.expect("request builds");
assert!(
with_both.format && task_only.format,
"--format is idempotent with --task"
);
}
#[test]
fn ocr_task_describe_fails_clean_naming_smolvlm2() {
let err = ocr_request_from(&["photo.jpg", "--task", "describe"])
.expect_err("describe against the default model must guide");
assert!(matches!(err, FocrError::Usage(_)), "got {err:?}");
assert_eq!(err.exit_code(), 2);
let msg = err.to_string();
assert!(
msg.contains("smolvlm2"),
"must name the required model: {msg}"
);
let req = ocr_request_from(&[
"photo.jpg",
"--task",
"describe",
"--model",
"smolvlm2.int8.focrq",
"--question",
"What color is the car?",
])
.expect("describe with a smolvlm2 model spec");
assert_eq!(req.question.as_deref(), Some("What color is the car?"));
assert!(!req.format, "describe must not imply GOT --format");
let err = ocr_request_from(&["photo.jpg", "--question", "what?"])
.expect_err("--question requires --task describe");
assert!(matches!(err, FocrError::Usage(_)), "got {err:?}");
let err = ocr_request_from(&[
"photo.jpg",
"--task",
"describe",
"--model",
"got-ocr2.int8.focrq",
])
.expect_err("describe against a got model must guide");
assert!(matches!(err, FocrError::Usage(_)), "got {err:?}");
}
#[test]
fn ocr_task_got_only_task_guides_to_got_model() {
let err = ocr_request_from(&[
"scan.png",
"--task",
"formula",
"--model",
"unlimited-ocr.int8.focrq",
])
.expect_err("unlimited-ocr cannot serve --task formula");
assert!(matches!(err, FocrError::Usage(_)), "got {err:?}");
assert_eq!(err.exit_code(), 2);
let msg = err.to_string();
assert!(
msg.contains("focr pull got-ocr2"),
"must carry the pull hint: {msg}"
);
assert!(
msg.contains("--task formula"),
"must name the offending task: {msg}"
);
if std::env::var_os(crate::MODEL_PATH_ENV).is_none() {
let err = ocr_request_from(&["scan.png", "--task", "music"])
.expect_err("default model cannot serve --task music");
assert!(matches!(err, FocrError::Usage(_)), "got {err:?}");
}
let req = ocr_request_from(&[
"scan.png",
"--task",
"geometry",
"--model",
"/models/GOT-OCR2.int8.focrq",
])
.expect("got model serves geometry");
assert!(req.format);
assert!(model_spec_is_knowably_not_got(None));
assert!(model_spec_is_knowably_not_got(Some(Path::new(
"/m/unlimited-ocr.int8.focrq"
))));
assert!(!model_spec_is_knowably_not_got(Some(Path::new(
"got-ocr2.int8.focrq"
))));
assert!(!model_spec_is_knowably_not_got(Some(Path::new(
"/m/custom.focrq"
))));
}
#[test]
fn ocr_task_rejects_unknown_value_and_composes_with_robot_run() {
assert!(Cli::try_parse_from(["focr", "ocr", "scan.png", "--task", "poetry"]).is_err());
let cli = Cli::try_parse_from([
"focr",
"robot",
"run",
"scan.png",
"--task",
"chart",
"--model",
"got-ocr2.int8.focrq",
])
.expect("robot run --task parses");
let Command::Robot {
cmd: RobotCmd::Run(args),
} = cli.command
else {
unreachable!("expected robot run");
};
assert!(args.request.to_request().expect("request builds").format);
}
#[test]
fn preprocess_flags_become_overrides_only_when_explicit() {
let cli = Cli::try_parse_from(["focr", "ocr", "scan.png"]).expect("ocr parses");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
let req = args.to_request().expect("request builds");
assert_eq!(
preprocess_overrides_from(&req),
native_engine::PreprocessOverrides::default()
);
let cli = Cli::try_parse_from([
"focr",
"ocr",
"scan.png",
"--base-size",
"512",
"--image-size",
"512",
"--crop-mode",
"gundam",
])
.expect("ocr with preprocess flags parses");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
let o = preprocess_overrides_from(&args.to_request().expect("request builds"));
assert_eq!(o.base_size, Some(512));
assert_eq!(o.image_size, Some(512));
assert_eq!(o.gundam, Some(true));
let cli = Cli::try_parse_from(["focr", "ocr", "scan.png", "--crop-mode", "base"])
.expect("ocr parses");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
let o = preprocess_overrides_from(&args.to_request().expect("request builds"));
assert_eq!(o.gundam, Some(false));
}
#[test]
fn tuning_flags_become_decode_overrides_only_when_explicit() {
let cli = Cli::try_parse_from(["focr", "ocr", "scan.png"]).expect("ocr parses");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
let req = args.to_request().expect("request builds");
assert_eq!(
decode_overrides_from(&req),
native_engine::DecodeOverrides::default()
);
let cli = Cli::try_parse_from([
"focr",
"ocr",
"scan.png",
"--max-length",
"700",
"--temperature",
"0",
"--no-repeat-ngram",
"20",
"--ngram-window",
"1024",
"--max-page-tokens",
"0",
])
.expect("ocr with tuning flags parses");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
let o = decode_overrides_from(&args.to_request().expect("request builds"));
assert_eq!(o.max_length, Some(700));
assert_eq!(o.temperature, Some(0.0));
assert_eq!(o.no_repeat_ngram, Some(20));
assert_eq!(o.ngram_window, Some(1024));
assert_eq!(o.max_page_tokens, Some(0));
let cli = Cli::try_parse_from([
"focr",
"ocr",
"scan.png",
"--max-length",
"32768",
"--no-repeat-ngram",
"35",
"--ngram-window",
"128",
"--temperature",
"0",
])
.expect("ocr parses");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
let o = decode_overrides_from(&args.to_request().expect("request builds"));
assert_eq!(o.max_length, Some(32_768));
assert_eq!(o.no_repeat_ngram, Some(35));
assert_eq!(o.ngram_window, Some(128));
assert_eq!(o.temperature, Some(0.0));
}
#[test]
fn a_nonzero_temperature_is_refused_before_a_model_loads() {
for value in ["0.5", "1", "inf", "NaN"] {
let cli = Cli::try_parse_from(["focr", "ocr", "scan.png", "--temperature", value])
.expect("the flag parses as a number");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
assert!(
matches!(
args.to_request(),
Err(FocrError::Usage(message)) if message.contains("decodes greedily")
),
"--temperature {value}"
);
}
}
#[test]
fn single_image_json_carries_markdown_and_bounding_boxes() {
let rec = Recognition::Single(native_engine::RecognizedDocument {
markdown: "# Title\n\nbody".to_string(),
raw: "<|ref|>title<|/ref|><|det|>[[1, 2, 3, 4]]<|/det|>\n# Title\n\nbody".to_string(),
layout: vec![
native_engine::LayoutSpan {
label: "title".to_string(),
boxes: vec![[10, 20, 110, 60]],
level: None,
text: Some("# Title".to_string()),
},
native_engine::LayoutSpan {
label: "title".to_string(),
boxes: vec![[10, 70, 110, 90]],
level: Some(2),
text: None,
},
],
});
let json = rec.to_json(&[], false);
assert_eq!(json["schema_version"], robot::ROBOT_SCHEMA_VERSION);
assert_eq!(json["markdown"], "# Title\n\nbody");
assert_eq!(json["layout"][0]["label"], "title");
assert_eq!(
json["layout"][0]["boxes"][0],
serde_json::json!([10, 20, 110, 60])
);
assert!(json["layout"][0].get("level").is_none());
assert_eq!(json["layout"][1]["level"], 2);
assert_eq!(json["layout"][0]["text"], "# Title");
assert!(json["layout"][1].get("text").is_none());
assert!(json.get("raw").is_none());
assert_eq!(rec.to_json(&[], true)["raw"], rec.raw());
assert!(json.get("pages").is_none());
}
#[test]
fn raw_replaces_text_and_joins_json() {
let rec = Recognition::Document(DocumentRecognition {
markdown: "md".to_string(),
raw: "whole".to_string(),
pages: DocumentPageList::Layouts(vec![DocumentPageLayout {
page: 1,
half: None,
raw: "page one".to_string(),
layout: vec![],
}]),
});
assert_eq!(rec.text(false), "md");
assert_eq!(rec.text(true), "whole");
let plain = rec.to_json(&[], false);
assert!(plain.get("raw").is_none());
assert!(plain["pages"][0].get("raw").is_none());
let json = rec.to_json(&[], true);
assert_eq!(json["markdown"], "md");
assert_eq!(json["raw"], "whole");
assert_eq!(json["pages"][0]["raw"], "page one");
}
#[test]
fn unstreamed_pages_become_decoded_page_events() {
let single = Recognition::Single(native_engine::RecognizedDocument {
markdown: "so far".to_string(),
raw: " so far \n".to_string(),
layout: vec![],
});
let events = single.unstreamed_page_events();
assert_eq!(events, [robot::page_decoded_event(1, "so far")]);
assert_eq!(events[0]["status"], "decoded");
let pages = Recognition::Document(DocumentRecognition {
markdown: "md".to_string(),
raw: "whole".to_string(),
pages: DocumentPageList::Layouts(vec![
DocumentPageLayout {
page: 3,
half: None,
raw: "page three".to_string(),
layout: vec![],
},
DocumentPageLayout {
page: 4,
half: Some("left"),
raw: "left half\n".to_string(),
layout: vec![],
},
]),
});
let mut left = robot::page_decoded_event(4, "left half");
left["half"] = "left".into();
assert_eq!(
pages.unstreamed_page_events(),
[robot::page_decoded_event(3, "page three"), left]
);
let read = Recognition::Document(DocumentRecognition {
markdown: "md".to_string(),
raw: "whole".to_string(),
pages: DocumentPageList::Read(Vec::new()),
});
assert!(read.unstreamed_page_events().is_empty());
}
#[test]
fn a_layout_span_round_trips_its_json() {
for (level, text) in [(None, None), (Some(1), Some("body".to_string()))] {
let span = native_engine::LayoutSpan {
label: "title".to_string(),
boxes: vec![[1, 2, 3, 4], [5, 6, 7, 8]],
level,
text,
};
let back = native_engine::LayoutSpan::from_json(&span.to_json());
assert_eq!(back, Some(span));
}
assert_eq!(
native_engine::LayoutSpan::from_json(&serde_json::json!({"boxes": []})),
None
);
}
#[test]
fn music_meta_json_interleaves_in_detection_order() {
let meta = native_engine::MusicPageMeta {
staves: vec![(0, (0, 10, 800, 100)), (2, (0, 300, 800, 100))],
skips: vec![native_engine::tromr::StaffSkip {
index: 1,
bbox: (0, 150, 800, 90),
reason: "resized width 1296 exceeds the 1280 position clamp".into(),
}],
warnings: Vec::new(),
};
let v = music_meta_to_json(&meta);
let arr = v.as_array().expect("array");
assert_eq!(arr.len(), 3);
assert_eq!(arr[0]["staff"], 1);
assert_eq!(arr[0]["status"], "ok");
assert!(arr[0].get("reason").is_none());
assert_eq!(arr[1]["staff"], 2);
assert_eq!(arr[1]["status"], "skipped");
assert!(
arr[1]["reason"].as_str().unwrap_or("").contains("1280"),
"skip carries the reason"
);
assert_eq!(arr[2]["staff"], 3);
assert_eq!(arr[2]["bbox"], serde_json::json!([0, 300, 800, 100]));
}
#[test]
fn page_spec_parses_and_rejects() {
let parse = crate::page_source::select_pages;
assert_eq!(parse(None, 3).unwrap(), vec![0, 1, 2]);
assert_eq!(parse(Some("3"), 218).unwrap(), vec![2]);
assert_eq!(parse(Some("3-7"), 10).unwrap(), vec![2, 3, 4, 5, 6]);
assert_eq!(
parse(Some("1,5-9,218"), 218).unwrap(),
vec![0, 4, 5, 6, 7, 8, 217]
);
assert_eq!(parse(Some("5-7,6,1"), 10).unwrap(), vec![0, 4, 5, 6]);
let idx = parse(Some("2,4-6"), 9).unwrap();
let rendered = idx
.iter()
.map(|i| (i + 1).to_string())
.collect::<Vec<_>>()
.join(",");
assert_eq!(parse(Some(&rendered), 9).unwrap(), idx);
for bad in ["", " ", ",", "0", "abc", "3-2", "1-", "-4", "300"] {
let err = parse(Some(bad), 218).unwrap_err();
assert!(
matches!(err, FocrError::Usage(_)),
"{bad:?} must be a usage error, got {err:?}"
);
assert!(
err.to_string().contains("218"),
"{bad:?}: error names the page count: {err}"
);
}
}
#[test]
fn document_figures_are_cropped_from_refetched_pages_and_named_by_half() {
let dir = std::env::temp_dir().join(format!("focr_cli_doc_figs_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
let synth = crate::page_source::tests::synth_page;
synth(200, 300, &[(40, 160)])
.save(dir.join("1.png"))
.expect("write page 1");
synth(800, 500, &[(50, 350), (450, 750)])
.save(dir.join("2.png"))
.expect("write the spread");
synth(200, 300, &[(40, 160)])
.save(dir.join("3.png"))
.expect("write page 3");
let request = ocr_args_with(|a| {
a.request.image = dir.clone();
a.request.split_spreads = true;
})
.to_request()
.expect("request");
let walked: Vec<PageItem> = open_page_source(&request).expect("open").collect();
let boxes = [[30, 20, 90, 70], [40, 30, 140, 230], [10, 50, 210, 90]];
let mut pages: Vec<native_engine::PageResult> = walked
.iter()
.map(|item| {
let k = item.index;
let token = format!("");
let figures = boxes
.get(k)
.map(|&b| native_engine::postprocess::FigureRef {
index: 0,
label: "image".to_string(),
boxes: vec![b],
markdown_ref: token.clone(),
})
.into_iter()
.collect();
native_engine::PageResult {
run_index: k + 1,
source_page: item.source_page,
half: item.half,
window: None,
size: (640, 480),
text: format!("before\n{token}"),
raw: String::new(),
layout: Vec::new(),
figures,
tokens: Some(1),
finish: Some(native_engine::PageFinish::Marker),
elapsed_ms: Some(1),
model_id: native_engine::model_arch::UNLIMITED_OCR_V2_ID,
}
})
.collect();
assert_eq!(
pages
.iter()
.map(|p| (p.source_page, p.half))
.collect::<Vec<_>>(),
[
(1, None),
(2, Some(Half::Left)),
(2, Some(Half::Right)),
(3, None)
]
);
let plan = FigurePlan {
dir: dir.join("figs"),
ref_prefix: "figs/".to_string(),
};
let written = write_page_figures(&request, &mut pages, &plan).expect("figures");
let names: Vec<&str> = written.iter().map(|w| w.path.as_str()).collect();
assert_eq!(
names,
[
"figs/page1_figure_1.png",
"figs/page2l_figure_1.png",
"figs/page2r_figure_1.png"
]
);
for ((page, item), want) in pages.iter().zip(&walked).zip(&written) {
assert_eq!(page.text, format!("before\n", want.path));
assert_eq!((want.page, want.half), (page.source_page, page.half));
let [x1, y1, x2, y2] = boxes[item.index];
assert_eq!(want.bbox, [x1, y1, x2, y2]);
let raster = item.raster.as_ref().expect("rasterized");
#[allow(clippy::cast_sign_loss)]
let expected = raster
.crop_imm(x1 as u32, y1 as u32, (x2 - x1) as u32, (y2 - y1) as u32)
.to_luma8();
let file = dir.join(want.path.as_str());
let got = image::open(&file).expect("figure decodes").to_luma8();
assert_eq!(got, expected, "{}", file.display());
}
assert_eq!(pages[3].text, "before\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_retried_attempt_reopens_the_page_source_and_reads_the_same_pages() {
let dir = std::env::temp_dir().join(format!("focr_cli_retry_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
for (n, width) in [(1u32, 30u32), (2, 40), (3, 50)] {
image::RgbImage::from_pixel(width, 60, image::Rgb([200, 100, 50]))
.save(dir.join(format!("p{n}.png")))
.expect("write page");
}
let request = ocr_args_with(|a| {
a.request.image = dir.clone();
a.request.pages = Some("3,1".into());
})
.to_request()
.expect("request");
let walk = |source: PageSource| -> Vec<(usize, Vec<u8>)> {
source
.map(|item| {
let raster = item.raster.expect("page rasterizes");
(item.source_page, raster.as_bytes().to_vec())
})
.collect()
};
let expected = walk(open_page_source(&request).expect("open"));
assert_eq!(
expected.iter().map(|(page, _)| *page).collect::<Vec<_>>(),
vec![1, 3]
);
let opened = std::cell::Cell::new(Some(open_page_source(&request).expect("open")));
let mut first = attempt_source(&opened, &request).expect("first attempt");
assert_eq!(first.next().map(|item| item.source_page), Some(1));
drop(first);
let retry = attempt_source(&opened, &request).expect("retry");
assert_eq!(walk(retry), expected);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pdf_json_carries_per_page_layout_with_one_based_page_numbers() {
let rec = Recognition::Document(DocumentRecognition {
markdown: "p1\n\np2".to_string(),
raw: "p1\n\np2".to_string(),
pages: DocumentPageList::Layouts(vec![
DocumentPageLayout {
page: 1,
half: None,
raw: "p1".to_string(),
layout: vec![native_engine::LayoutSpan {
label: "text".to_string(),
boxes: vec![[0, 0, 5, 5]],
level: None,
text: None,
}],
},
DocumentPageLayout {
page: 2,
half: None,
raw: "p2".to_string(),
layout: vec![],
},
]),
});
let json = rec.to_json(&[], false);
assert_eq!(json["markdown"], "p1\n\np2");
assert_eq!(json["pages"][0]["page"], 1);
assert_eq!(
json["pages"][0]["layout"][0]["boxes"][0],
serde_json::json!([0, 0, 5, 5])
);
assert_eq!(json["pages"][1]["page"], 2);
assert_eq!(json["pages"][1]["layout"], serde_json::json!([]));
let mut keys: Vec<&str> = json["pages"][0]
.as_object()
.expect("an object")
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(keys, ["layout", "page"]);
}
#[test]
fn multi_page_json_carries_each_page_as_read() {
let span = native_engine::LayoutSpan {
label: "title".to_string(),
boxes: vec![[1, 2, 3, 4]],
level: Some(1),
text: None,
};
let page = |run_index, source_page, half, finish| native_engine::PageResult {
run_index,
source_page,
half,
window: None,
size: (640, 480),
text: format!("page {source_page}"),
raw: format!("raw {run_index}"),
layout: vec![span.clone()],
figures: Vec::new(),
tokens: Some(7),
finish,
elapsed_ms: Some(1234),
model_id: native_engine::model_arch::UNLIMITED_OCR_V2_ID,
};
let rec = Recognition::Document(DocumentRecognition {
markdown: "<PAGE>\npage 1\n<PAGE>\npage 3\n<PAGE>\npage 3".to_string(),
raw: "<PAGE>\nraw 1\n<PAGE>\nraw 2\n<PAGE>\nraw 3".to_string(),
pages: DocumentPageList::Read(vec![
page(1, 1, None, Some(native_engine::PageFinish::Marker)),
page(
2,
3,
Some(Half::Left),
Some(native_engine::PageFinish::PageCap),
),
page(
3,
3,
Some(Half::Right),
Some(native_engine::PageFinish::Eos),
),
]),
});
let json = rec.to_json(&[], false);
assert_eq!(
json["pages"][0],
serde_json::json!({
"page": 1, "run_index": 1, "width": 640, "height": 480, "text": "page 1",
"tokens": 7, "finish": "marker",
"layout": [{"label": "title", "boxes": [[1, 2, 3, 4]], "level": 1}],
}),
"no half, no window, and elapsed_ms only on the robot event"
);
assert_eq!(rec.to_json(&[], true)["pages"][1]["raw"], "raw 2");
let sides: Vec<_> = json["pages"]
.as_array()
.expect("pages")
.iter()
.map(|p| (p["page"].clone(), p["half"].clone(), p["finish"].clone()))
.collect();
assert_eq!(
sides,
[
(1.into(), serde_json::Value::Null, "marker".into()),
(3.into(), "left".into(), "page_cap".into()),
(3.into(), "right".into(), "eos".into()),
]
);
let merged = Recognition::Document(DocumentRecognition {
markdown: "<PAGE>\nall".to_string(),
raw: "<PAGE>\nall".to_string(),
pages: DocumentPageList::Read(Vec::new()),
});
assert_eq!(merged.to_json(&[], false)["pages"], serde_json::json!([]));
}
#[test]
fn a_batch_row_says_how_its_page_ended() {
use native_engine::FinishReason;
let page = Path::new("a.png");
let mut rows = Vec::new();
let mut emit =
|end| emit_batch_result(true, page, 1.0, Ok("text".into()), None, end, &mut rows);
let clean = PageEnd {
finish_reason: Some(FinishReason::Stop),
..PageEnd::default()
};
assert!(emit(clean).is_none());
let capped = PageEnd {
finish_reason: Some(FinishReason::Length),
..PageEnd::default()
};
assert!(emit(capped).is_none());
let stopped = PageEnd {
finish_reason: Some(FinishReason::Budget),
partial: Some(FocrError::Timeout("stopped early".into())),
..PageEnd::default()
};
let failure = emit(stopped).expect("a stopped page is a failed page");
assert_eq!(failure.exit_code(), 5);
assert_eq!(rows[0]["ok"], true);
assert_eq!(rows[0]["finish_reason"], "stop");
assert!(rows[0].get("error").is_none());
assert_eq!(rows[1]["ok"], true);
assert_eq!(rows[1]["finish_reason"], "length");
assert_eq!(rows[2]["ok"], false);
assert_eq!(rows[2]["finish_reason"], "budget");
assert_eq!(rows[2]["markdown"], "text");
assert!(rows[2]["error"].as_str().unwrap().contains("stopped early"));
}
#[test]
fn a_chart_keeps_its_head_whether_or_not_it_warned() {
let chart = |reliable: Option<bool>| native_engine::ChartPageMeta {
pred_locs: vec![0.25, 1.0],
reliable_distance: reliable.map(|_| 0.01),
reliable,
};
let run = |meta: &native_engine::ChartPageMeta| {
let warnings: Vec<_> = native_engine::chart_warning(meta).into_iter().collect();
let mut value = serde_json::json!({ "markdown": "x" });
RunExtras {
chart: Some(meta),
warnings: &warnings,
..RunExtras::default()
}
.merge_into(&mut value);
value
};
let verified = run(&chart(Some(true)));
assert_eq!(
verified["chart"]["pred_locs"],
serde_json::json!([0.25, 1.0])
);
assert_eq!(verified["chart"]["reliable"], true);
assert!(verified.get("warnings").is_none());
let unverified = run(&chart(None));
assert_eq!(
unverified["chart"]["pred_locs"],
serde_json::json!([0.25, 1.0])
);
assert!(unverified["chart"]["reliable"].is_null());
assert!(unverified["chart"]["reliable_distance"].is_null());
assert_eq!(unverified["warnings"][0]["kind"], "chart_unverified");
let mut other = serde_json::json!({ "markdown": "x" });
RunExtras::default().merge_into(&mut other);
assert!(other.get("chart").is_none());
}
#[test]
fn write_ocr_output_writes_markdown_and_json_with_boxes() {
let dir = std::env::temp_dir().join(format!("focr_output_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let rec = Recognition::Single(native_engine::RecognizedDocument {
markdown: "hello world".to_string(),
raw: "<|ref|>text<|/ref|><|det|>[[1, 2, 3, 4]]<|/det|>\nhello world".to_string(),
layout: vec![native_engine::LayoutSpan {
label: "text".to_string(),
boxes: vec![[1, 2, 3, 4]],
level: None,
text: Some("hello world".to_string()),
}],
});
let md_path = dir.join("out.md");
let text = OutputShape {
json: false,
raw: false,
};
write_ocr_output(&md_path, &rec, text, &[], RunExtras::default()).expect("write md");
assert_eq!(std::fs::read_to_string(&md_path).unwrap(), "hello world\n");
let raw_shape = OutputShape {
json: false,
raw: true,
};
write_ocr_output(&md_path, &rec, raw_shape, &[], RunExtras::default()).expect("write raw");
assert_eq!(
std::fs::read_to_string(&md_path).unwrap(),
"<|ref|>text<|/ref|><|det|>[[1, 2, 3, 4]]<|/det|>\nhello world\n"
);
let json_path = dir.join("out.json");
let model = native_engine::ModelIdentity {
model_id: "unlimited-ocr",
source_sha256: Some("cd".repeat(32)),
};
let json = OutputShape {
json: true,
raw: false,
};
let extras = RunExtras {
model: Some(&model),
finish_reason: Some(native_engine::FinishReason::Length),
..RunExtras::default()
};
write_ocr_output(&json_path, &rec, json, &[], extras).expect("write json");
let raw = std::fs::read_to_string(&json_path).unwrap();
assert!(raw.ends_with('\n'), "json file should end with a newline");
let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
assert_eq!(parsed["markdown"], "hello world");
assert_eq!(
parsed["layout"][0]["boxes"][0],
serde_json::json!([1, 2, 3, 4])
);
assert_eq!(parsed["model_id"], "unlimited-ocr");
assert_eq!(parsed["source_sha256"], "cd".repeat(32));
assert_eq!(parsed["finish_reason"], "length");
let _ = std::fs::remove_dir_all(&dir);
}
fn ocr_args_with(f: impl FnOnce(&mut OcrArgs)) -> OcrArgs {
let mut args = OcrArgs {
request: OcrRequestArgs {
image: PathBuf::from("scan.png"),
model: None,
base_size: None,
image_size: None,
crop_mode: None,
max_length: None,
temperature: None,
no_repeat_ngram: None,
ngram_window: None,
max_page_tokens: None,
format: false,
task: OcrTask::Ocr,
question: None,
region_box: None,
color: None,
pages: None,
split_spreads: false,
multi_page: false,
page_window: None,
resume: None,
keep_checkpoint: false,
no_resident: false,
threads: None,
timing: false,
fail_on_low_yield: false,
},
json: false,
output: None,
extract_figures: false,
figures_dir: None,
robot: false,
check: false,
raw: false,
};
f(&mut args);
args
}
#[test]
fn extract_figures_flag_parses() {
let cli = Cli::try_parse_from([
"focr",
"ocr",
"scan.png",
"-o",
"out.md",
"--extract-figures",
])
.expect("--extract-figures parses");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
assert!(args.extract_figures);
let cli = Cli::try_parse_from(["focr", "ocr", "scan.png", "--figures-dir", "assets"])
.expect("--figures-dir parses");
let Command::Ocr(args) = cli.command else {
unreachable!("expected ocr command");
};
assert_eq!(args.figures_dir.as_deref(), Some(Path::new("assets")));
}
#[test]
fn figure_plan_resolves_auto_subfolder_explicit_dir_and_usage_error() {
let plan = FigurePlan::resolve(&ocr_args_with(|a| {
a.extract_figures = true;
a.output = Some(PathBuf::from("/a/b/report.md"));
}))
.unwrap()
.expect("enabled");
assert_eq!(plan.dir, PathBuf::from("/a/b/report_figures"));
assert_eq!(plan.ref_prefix, "report_figures/");
let plan = FigurePlan::resolve(&ocr_args_with(|a| {
a.figures_dir = Some(PathBuf::from("assets"));
a.output = Some(PathBuf::from("/a/b/report.md"));
}))
.unwrap()
.expect("enabled");
assert_eq!(plan.dir, PathBuf::from("/a/b/assets"));
assert_eq!(plan.ref_prefix, "assets/");
assert!(
FigurePlan::resolve(&ocr_args_with(|_| {}))
.unwrap()
.is_none()
);
let err = FigurePlan::resolve(&ocr_args_with(|a| a.extract_figures = true))
.expect_err("needs a place for the subfolder");
assert!(matches!(err, FocrError::Usage(_)), "got {err:?}");
}
#[test]
fn choose_figure_format_png_for_flat_jpg_for_photo() {
let mut flat = image::RgbImage::new(64, 64);
for (i, px) in flat.pixels_mut().enumerate() {
*px = if i % 9 == 0 {
image::Rgb([0, 0, 0])
} else {
image::Rgb([255, 255, 255])
};
}
assert_eq!(
choose_figure_format(&image::DynamicImage::ImageRgb8(flat)),
FigureFormat::Png
);
let mut photo = image::RgbImage::new(64, 64);
for (i, px) in photo.pixels_mut().enumerate() {
let x = (i % 64) as u8;
let y = (i / 64) as u8;
*px = image::Rgb([x.wrapping_mul(4), y.wrapping_mul(4), x ^ (y << 1)]);
}
assert_eq!(
choose_figure_format(&image::DynamicImage::ImageRgb8(photo)),
FigureFormat::Jpeg
);
}
#[test]
fn figure_writer_writes_file_and_rewrites_markdown_reference() {
let dir = std::env::temp_dir().join(format!("focr_figwriter_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let plan = FigurePlan {
dir: dir.clone(),
ref_prefix: "figs/".to_string(),
};
let mut writer = plan.writer();
let fig = native_engine::ExtractedFigure {
index: 0,
label: "image".to_string(),
bbox: [5, 6, 25, 16],
markdown_ref: "".to_string(),
image: image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
20,
10,
image::Rgb([255, 255, 255]),
)),
};
let md = writer
.process_page(
1,
None,
"before \nafter",
vec![fig.clone()],
)
.expect("process page");
assert!(
md.contains(""),
"md: {md}"
);
assert!(!md.contains("images/0.jpg"), "old token gone; md: {md}");
assert!(dir.join("page1_figure_1.png").is_file());
for (half, side) in [(Half::Left, 'l'), (Half::Right, 'r')] {
let fig = native_engine::ExtractedFigure {
markdown_ref: "".to_string(),
..fig.clone()
};
let md = writer
.process_page(2, Some(half), "", vec![fig])
.expect("process a half");
assert_eq!(md, format!(""));
assert!(dir.join(format!("page2{side}_figure_1.png")).is_file());
}
let written = writer.into_written();
assert_eq!(written.len(), 3);
assert_eq!(written[0].path, "figs/page1_figure_1.png");
assert_eq!(written[0].page, 1);
assert_eq!(written[0].half, None);
assert_eq!(written[0].bbox, [5, 6, 25, 16]);
assert_eq!(
written[1..]
.iter()
.map(|w| (w.page, w.half))
.collect::<Vec<_>>(),
[(2, Some(Half::Left)), (2, Some(Half::Right))]
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn json_appends_figures_array_only_when_present() {
let rec = Recognition::Single(native_engine::RecognizedDocument {
markdown: "see ".to_string(),
raw: String::new(),
layout: vec![],
});
let figures = vec![
WrittenFigure {
label: "image".to_string(),
page: 1,
half: None,
bbox: [1, 2, 3, 4],
path: "figs/page1_figure_1.png".to_string(),
},
WrittenFigure {
label: "image".to_string(),
page: 2,
half: Some(Half::Right),
bbox: [1, 2, 3, 4],
path: "figs/page2r_figure_1.png".to_string(),
},
];
let json = rec.to_json(&figures, false);
assert_eq!(json["figures"][0]["path"], "figs/page1_figure_1.png");
assert_eq!(json["figures"][0]["page"], 1);
assert_eq!(json["figures"][0]["bbox"], serde_json::json!([1, 2, 3, 4]));
assert!(json["figures"][0].get("half").is_none(), "absent unsplit");
assert_eq!(json["figures"][1]["half"], "right");
assert!(rec.to_json(&[], false).get("figures").is_none());
}
#[test]
fn models_json_describes_the_registered_archs() {
let archs = crate::model_arch::registry();
assert!(!archs.is_empty());
let j = model_arch_json(archs[0]);
assert_eq!(j["id"], "unlimited-ocr");
assert_eq!(j["status"], "ready");
assert_eq!(j["implemented"], true);
assert_eq!(j["tasks"], serde_json::json!(["ocr"]));
assert_eq!(j["decoder"], "DeepSeekV2MoeRswa");
assert_eq!(j["vision_encoder"], "SamClip");
assert!(j["license"].as_str().unwrap_or_default().contains("Baidu"));
}
#[test]
fn ready_archs_have_honest_committed_pull_status() {
use crate::model_arch::Maturity;
let m = crate::dist::builtin_manifest().expect("embedded manifest parses");
for a in crate::model_arch::registry() {
let pull = pull_availability(Some(&m), a.id());
if a.maturity() == Maturity::Experimental {
continue;
}
if a.maturity() == Maturity::Ready {
assert!(
pull.in_manifest,
"{} is runtime-ready but has no manifest entry — publish its \
artifacts (bd-av64.7 pattern) or record why not",
a.id()
);
assert!(
!pull.compatible.is_empty(),
"{} must retain at least one compatible pull",
a.id()
);
assert!(pull.blocked.is_empty());
} else {
assert!(
!pull.in_manifest,
"{} is planned-only but published in the manifest",
a.id()
);
}
}
}
#[test]
fn the_pull_hint_names_the_requested_model_when_it_is_published() {
let manifest = crate::dist::builtin_manifest().expect("embedded manifest parses");
for arch in crate::model_arch::registry() {
let id = arch.id();
let hint = with_pull_hint("model not found", Some(Path::new(id)));
let pullable = !pull_availability(Some(&manifest), id).compatible.is_empty();
assert_eq!(
hint.contains(&format!("run `focr pull {id}`")),
pullable,
"{id}: {hint}"
);
assert_eq!(
hint.contains(&format!("does not publish {id}")),
!pullable,
"{id}: {hint}"
);
}
assert!(
pull_availability(Some(&manifest), "got-ocr2")
.compatible
.iter()
.any(|(quant, _)| quant == "int8"),
"a published model keeps the positive case exercised"
);
for requested in [None, Some(Path::new("/models/custom.focrq"))] {
assert!(
with_pull_hint("model not found", requested)
.contains("run `focr pull` to install the compatible conservative default"),
"{requested:?}"
);
}
}
#[test]
fn v1_recoveries_name_the_v2_pull_exactly_when_it_is_published() {
use native_engine::{WarningKind, warning_recovery};
let manifest = crate::dist::builtin_manifest().expect("embedded manifest parses");
let published = !pull_availability(Some(&manifest), V2).compatible.is_empty();
for kind in [
WarningKind::V1TruncatedPositions,
WarningKind::V1PageMerge,
WarningKind::PromptTooLong,
] {
let recovery = warning_recovery(kind);
assert_eq!(
recovery.contains("`focr pull unlimited-ocr-2`, then `--model unlimited-ocr-2`"),
published,
"{kind:?}: {recovery}"
);
}
for kind in [
WarningKind::V2PromptTooLong,
WarningKind::V2Length,
WarningKind::V2Budget,
WarningKind::V2PageCap,
] {
assert!(!warning_recovery(kind).contains("focr pull"), "{kind:?}");
}
}
#[test]
fn task_name_is_stable_lowercase() {
use crate::model_arch::Task;
assert_eq!(task_name(Task::Ocr), "ocr");
assert_eq!(task_name(Task::Music), "music");
assert_eq!(task_name(Task::Describe), "describe");
assert_eq!(task_name(Task::Chart), "chart");
}
#[test]
fn models_command_parses() {
let cli = Cli::try_parse_from(["focr", "models"]).expect("focr models parses");
assert!(matches!(cli.command, Command::Models(_)));
let cli = Cli::try_parse_from(["focr", "models", "--json"]).expect("--json parses");
let Command::Models(args) = cli.command else {
unreachable!("expected models");
};
assert!(args.json);
}
#[test]
fn convert_arch_enum_parses() {
let parsed = Cli::try_parse_from([
"focr",
"convert",
"in.safetensors",
"-o",
"out.focrq",
"--arch",
"x86-vnni",
]);
let parse_error = parsed
.as_ref()
.err()
.map(std::string::ToString::to_string)
.unwrap_or_default();
assert!(parsed.is_ok(), "convert --arch parses: {parse_error}");
let Ok(cli) = parsed else {
return;
};
let is_convert = matches!(cli.command, Command::Convert(_));
assert!(is_convert, "expected convert command");
if let Command::Convert(args) = cli.command {
assert_eq!(args.quant, QuantTarget::Int8);
assert_eq!(args.arch, ArchTarget::X86Vnni);
};
}
#[test]
fn robot_backends_reflects_simd_dispatch_snapshot() {
let payload = robot_backends_payload();
let tiers = &payload["simd_tiers"];
let effective = simd::effective_dense_route();
let hardware = simd::detected_tier();
assert_eq!(payload["schema_version"], robot::ROBOT_SCHEMA_VERSION);
assert_eq!(tiers["selected"], effective.tag());
assert_eq!(tiers["selected_feature"], effective.feature_string());
assert_eq!(tiers["hardware_selected"], hardware.tag());
assert_eq!(
tiers["hardware_selected_feature"],
hardware.feature_string()
);
assert_eq!(tiers["override_env"], "FOCR_FORCE_ARCH");
assert!(
tiers["available"].as_array().is_some_and(|available| {
!available.is_empty()
&& available.last().and_then(|v| v["tag"].as_str())
== Some(simd::IsaTier::Scalar.tag())
}),
"available tiers must be a non-empty array ending with the scalar floor"
);
}
fn sharded_got_tensors(
head_matches: bool,
) -> Vec<Vec<native_engine::shards::tests::SyntheticTensor>> {
use native_engine::shards::tests::arange_bytes;
let bf16 = |values: &[f32]| -> Vec<u8> {
values
.iter()
.flat_map(|&v| half::bf16::from_f32(v).to_le_bytes())
.collect()
};
let embed: Vec<f32> = (0..8).map(|i| i as f32 * 0.25 - 1.0).collect();
let mut head = embed.clone();
if !head_matches {
head[3] += 1.0;
}
let down: Vec<f32> = (0..32).map(|i| ((i * 7 % 13) as f32 - 6.0) / 8.0).collect();
vec![
vec![
(
"model.embed_tokens.weight",
"BF16",
vec![2, 4],
bf16(&embed),
),
(
"model.layers.0.mlp.down_proj.weight",
"BF16",
vec![4, 8],
bf16(&down),
),
],
vec![
("lm_head.weight", "BF16", vec![2, 4], bf16(&head)),
(
"model.norm.weight",
"BF16",
vec![4],
bf16(&[1.0, 0.5, 2.0, -1.0]),
),
("vision.position_ids", "I64", vec![1, 4], arange_bytes(4)),
],
]
}
fn write_sharded_checkpoint(
label: &str,
shards: &[Vec<native_engine::shards::tests::SyntheticTensor>],
) -> PathBuf {
use native_engine::shards::tests::{index_json, safetensors_file};
let dir =
std::env::temp_dir().join(format!("franken_ocr_cli_{label}_{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let mut map = Vec::new();
let mut total = 0u64;
for (position, tensors) in shards.iter().enumerate() {
let name: &'static str = Box::leak(
format!(
"model-{:05}-of-{:05}.safetensors",
position + 1,
shards.len()
)
.into_boxed_str(),
);
std::fs::write(dir.join(name), safetensors_file(tensors)).expect("write shard");
for (tensor, _, _, bytes) in tensors {
map.push((*tensor, name));
total += bytes.len() as u64;
}
}
std::fs::write(
dir.join(native_engine::shards::SHARD_INDEX_NAME),
index_json(&map, total),
)
.expect("write index");
dir
}
fn convert_args(input: &Path, output: &Path, quant: QuantTarget) -> ConvertArgs {
ConvertArgs {
input: input.to_path_buf(),
output: output.to_path_buf(),
quant,
arch: ArchTarget::Generic,
model_id: Some("got-ocr2".into()),
calib: None,
json: false,
}
}
fn focrq_header(blob: &[u8]) -> serde_json::Value {
let header_len = u64::from_le_bytes(blob[43..51].try_into().expect("8 bytes")) as usize;
serde_json::from_slice(&blob[51..51 + header_len]).expect("header JSON")
}
#[test]
fn sharded_source_converts_shard_by_shard_and_reloads() {
use native_engine::shards::{self, test_rules, tests::POSITION_IDS_RULE};
use native_engine::weights::{DType, Weights};
let started = std::time::Instant::now();
let test = "sharded_source_converts_shard_by_shard_and_reloads";
let dir = write_sharded_checkpoint("sharded_convert", &sharded_got_tensors(true));
let out = dir.join("out.focrq");
let error = run_convert(&convert_args(&dir, &out, QuantTarget::Int8))
.expect_err("got-ocr2 has no verify-and-skip rule");
assert!(
error.to_string().contains("got-ocr2 accepts I64 only"),
"{error}"
);
let _rules = test_rules::supply(&POSITION_IDS_RULE);
run_convert(&convert_args(&dir, &out, QuantTarget::Int8)).expect("streaming convert");
let blob = std::fs::read(&out).expect("read artifact");
let digests =
shards::hash_shards(&dir.join(shards::SHARD_INDEX_NAME)).expect("hash the source");
for digest in &digests {
println!(
"{}",
serde_json::json!({
"schema_version": 1, "ts": started.elapsed().as_secs_f64(), "test": test,
"case": digest.name, "run_seq": 0, "event": "assert", "result": "pass",
"assertion": "shard header checked against the index and hashed",
"pass": true,
"bytes": digest.size, "header_ok": true, "hashed": true,
"sha256": digest.sha256_hex(),
})
);
}
let convention: String = shards::source_sha256(&digests)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
let header = focrq_header(&blob);
assert_eq!(header["source_sha256"], convention.as_str());
let listed = header["provenance"]["source_shards"]
.as_array()
.expect("multi-shard provenance");
assert_eq!(listed.len(), 2);
for (entry, digest) in listed.iter().zip(&digests) {
assert_eq!(entry["name"], digest.name.as_str());
assert_eq!(entry["size"], digest.size);
assert_eq!(entry["sha256"], digest.sha256_hex().as_str());
}
let reloaded =
Weights::from_bytes(blob.clone()).expect("reload passes the provenance gate");
assert_eq!(reloaded.model_id(), "got-ocr2");
assert_eq!(reloaded.source_sha256(), convention);
assert_eq!(
reloaded.names().collect::<Vec<_>>(),
[
"model.embed_tokens.weight",
"model.layers.0.mlp.down_proj.weight",
"model.norm.weight"
],
"tied lm_head omitted; the verified I64 tensor never stored"
);
assert_eq!(
reloaded
.record("model.layers.0.mlp.down_proj.weight")
.expect("down")
.dtype,
DType::QInt8PerChan
);
assert!(native_engine::native_model_available(&out));
let plain_dir = dir.join("plain");
std::fs::create_dir_all(&plain_dir).expect("plain dir");
let plain = plain_dir.join("model.safetensors");
let all: Vec<_> = sharded_got_tensors(true).concat();
std::fs::write(&plain, shards::tests::safetensors_file(&all)).expect("write plain");
let plain_out = plain_dir.join("plain.focrq");
run_convert(&convert_args(&plain, &plain_out, QuantTarget::Int8)).expect("plain convert");
let plain_blob = std::fs::read(&plain_out).expect("read plain artifact");
assert!(
focrq_header(&plain_blob).get("provenance").is_none(),
"one shard: no list"
);
let single = Weights::from_bytes(plain_blob).expect("reload plain");
for name in single.names() {
let (a, b) = (
reloaded.tensor(name).expect("sharded"),
single.tensor(name).expect("plain"),
);
assert_eq!(
(a.dtype, a.data, a.scales),
(b.dtype, b.data, b.scales),
"{name}"
);
}
let first = digests[0].sha256_hex();
let flipped = format!(
"{}{}",
if first.starts_with('0') { '1' } else { '0' },
&first[1..]
);
let hits: Vec<usize> = blob
.windows(64)
.enumerate()
.filter(|(_, window)| *window == first.as_bytes())
.map(|(at, _)| at)
.collect();
assert_eq!(
hits.len(),
1,
"the shard digest appears once, in the provenance"
);
let at = hits[0];
let tampered_path = dir.join("tampered.focrq");
let mut tampered = blob.clone();
tampered[at..at + 64].copy_from_slice(flipped.as_bytes());
std::fs::write(&tampered_path, &tampered).expect("write tampered");
let error = Weights::load(&tampered_path).expect_err("convention mismatch");
assert_eq!(error.exit_code(), 7);
assert!(error.to_string().contains("convention digest"), "{error}");
assert!(!native_engine::native_model_available(&tampered_path));
println!(
"{}",
serde_json::json!({
"schema_version": 1, "ts": started.elapsed().as_secs_f64(), "test": test,
"case": "convert", "run_seq": 0, "event": "result", "result": "pass",
"elapsed_us": started.elapsed().as_micros() as u64,
"source_sha256": convention, "shards": digests.len(),
})
);
}
#[test]
fn sharded_convert_checks_the_tie_across_shards_and_refuses_int4() {
use native_engine::shards::{test_rules, tests::POSITION_IDS_RULE};
let _rules = test_rules::supply(&POSITION_IDS_RULE);
let dir = write_sharded_checkpoint("sharded_untied", &sharded_got_tensors(false));
let out = dir.join("out.focrq");
let error = run_convert(&convert_args(&dir, &out, QuantTarget::Int8))
.expect_err("a tied arch fed an untied head");
assert!(
error.to_string().contains("declares TIED embeddings"),
"{error}"
);
assert!(!out.exists(), "nothing is written for a refused source");
let error = run_convert(&convert_args(&dir, &out, QuantTarget::Int4))
.expect_err("got-ocr2 has no int4 recipe");
assert!(matches!(error, FocrError::NotImplemented(_)), "{error}");
assert!(
error.to_string().contains("has no certified int4 recipe"),
"{error}"
);
let mut args = convert_args(&dir, &out, QuantTarget::Int8);
args.model_id = Some("unlimited-ocr".into());
for quant in [QuantTarget::Int8, QuantTarget::Int4] {
args.quant = quant;
let error = run_convert(&args).expect_err("not v1's census");
assert!(matches!(error, FocrError::FormatMismatch(_)), "{error}");
assert!(
error
.to_string()
.contains("unlimited-ocr source safetensors tensor census failed"),
"{error}"
);
assert!(!out.exists(), "nothing is written for a refused source");
}
args.model_id = None;
args.quant = QuantTarget::Int8;
let error = run_convert(&args).expect_err("no census, no id");
assert!(matches!(error, FocrError::Usage(_)), "{error}");
assert!(error.to_string().contains("pass --model-id"), "{error}");
}
}