pdf_ocr/runtime.rs
1// Copyright (c) 2026 Innovation Trigger B.V.
2//
3// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
4// the PDFluent Commercial Licence. See the LICENSE file in this repository --
5// that file travels with the copy you received, which a URL does not.
6
7// `ModelError`'s seven fields are its public contract, so it is 176 bytes and
8// every function returning it trips `result_large_err`. Boxing would change
9// the field types the contract pins, for no gain: these are cold paths (init
10// once per process, cached sessions), where copying 176 bytes on a failure is
11// cheaper than the heap allocation boxing would add to every construction.
12#![allow(clippy::result_large_err)]
13
14//! The ONE shared ONNX Runtime layer for the opt-in model tiers.
15//!
16//! Two model tiers need ONNX Runtime: the layout model (balanced/best headings,
17//! lists, regions) and the table-structure model (tables), and the paddle OCR
18//! path already runs it. A process loads one `onnxruntime` and one `ort`
19//! version per dependency graph, so model-file resolution, checksum
20//! verification, one-time runtime initialisation, the version check, session
21//! caching and the two failure kinds live here, once. Model-specific
22//! pre/post-processing is NOT here: it lives in each model feature on top of
23//! `SharedSession::run` (behind the `onnx-runtime` feature).
24//!
25//! # Pin
26//!
27//! `ort =2.0.0-rc.13` (a release candidate, pinned exactly), `api-23`, ONNX
28//! Runtime [`ORT_VERSION`](crate::runtime::ORT_VERSION), loaded dynamically,
29//! never linked, never downloaded.
30//! Nothing in this module ever fetches over the network: a missing model file
31//! or library is [`ModelErrorKind::ModelNotAvailable`](crate::runtime::ModelErrorKind::ModelNotAvailable),
32//! never a download.
33//!
34//! # Resolution
35//!
36//! The model file is `<dir>/<spec.name>/<spec.file>`, where `<dir>` is the
37//! first of: [`RuntimeConfig::model_dir`](crate::runtime::RuntimeConfig::model_dir),
38//! the `PDFLUENT_MODEL_DIR`
39//! environment variable (the ONE documented override), or the per-user default
40//! `<data dir>/pdfluent/models/`. The runtime library resolves the same way:
41//! [`RuntimeConfig::ort_dylib`](crate::runtime::RuntimeConfig::ort_dylib), then
42//! `ORT_DYLIB_PATH` (`ort`'s own variable),
43//! then `<model dir>/onnxruntime-1.23.2/<platform library>`, where the model
44//! dir is `PDFLUENT_MODEL_DIR` or the per-user default -- the same root the
45//! install commands put the library under, so an install is found wherever
46//! the override points. The explicit `model_dir` is deliberately not read
47//! for the library (see [`resolve_ort_dylib`]).
48//! The data dir is `$XDG_DATA_HOME` or `~/.local/share` on Linux,
49//! `~/Library/Application Support` on macOS, `%LOCALAPPDATA%` on Windows,
50//! read with `std::env` only.
51//!
52//! # Threads
53//!
54//! `intra_threads` x `sessions_per_model` x the caller's page parallelism
55//! should not exceed the cores; one caller-facing thread budget (later, the
56//! CLI's `--threads`) drives both numbers. There is no automatic pool sizing
57//! here: the pool holds exactly
58//! [`RuntimeConfig::sessions_per_model`](crate::runtime::RuntimeConfig::sessions_per_model)
59//! sessions
60//! and `SharedSession::run` (behind the `onnx-runtime` feature) blocks until
61//! one is free.
62//!
63//! # Errors
64//!
65//! Failures surface as [`ModelError`](crate::runtime::ModelError), which converts into [`std::io::Error`]
66//! via [`std::io::Error::other`] so `pdf-engine` can carry it as
67//! `EngineError::Io` without a new variant; callers match that variant and
68//! find the typed cause with [`model_error`](crate::runtime::model_error). Bindings and the CLI map
69//! [`ModelErrorKind::code`](crate::runtime::ModelErrorKind::code) onto their error codes.
70
71#[cfg(feature = "onnx-runtime")]
72use std::collections::HashMap;
73#[cfg(feature = "onnx-runtime")]
74use std::ffi::CStr;
75#[cfg(feature = "onnx-runtime")]
76use std::path::Path;
77use std::path::PathBuf;
78#[cfg(feature = "onnx-runtime")]
79use std::sync::{Arc, Condvar, Mutex, OnceLock};
80
81/// The exact ONNX Runtime version this layer runs against.
82///
83/// [`init`] loads the library and then checks the version string it reports
84/// against this constant: anything else is
85/// [`ModelErrorKind::VersionMismatch`].
86pub const ORT_VERSION: &str = "1.23.2";
87
88/// The ONE documented environment override for the model directory.
89const MODEL_DIR_ENV: &str = "PDFLUENT_MODEL_DIR";
90
91/// `ort`'s own variable, honoured as the second source for the library path.
92const ORT_DYLIB_ENV: &str = "ORT_DYLIB_PATH";
93
94/// The model name [`ModelError`] carries when the missing file is the ONNX
95/// Runtime library itself rather than a model weight.
96#[cfg(feature = "onnx-runtime")]
97const ORT_MODEL_NAME: &str = "onnxruntime-1.23.2";
98
99/// What a model package must provide.
100///
101/// Declared as a `const` by each model feature; the file is resolved as
102/// `<model dir>/<name>/<file>` and verified against `sha256` before loading.
103#[non_exhaustive]
104pub struct ModelSpec {
105 /// Package directory inside the model dir, e.g. `"layout-heron-ep03"`.
106 pub name: &'static str,
107 /// File name inside the package dir, e.g. `"heron_ep03.onnx"`.
108 pub file: &'static str,
109 /// Lowercase hex SHA-256 the file must match before it is loaded.
110 pub sha256: &'static str,
111 /// Shown verbatim in `MODEL_NOT_AVAILABLE`: where to get the package.
112 pub install_command: &'static str,
113}
114
115impl ModelSpec {
116 /// Declare a model package. `const` so model features can hold it as a
117 /// `const` rather than building it per call.
118 pub const fn new(
119 name: &'static str,
120 file: &'static str,
121 sha256: &'static str,
122 install_command: &'static str,
123 ) -> Self {
124 Self {
125 name,
126 file,
127 sha256,
128 install_command,
129 }
130 }
131}
132
133/// How the shared layer resolves files and sizes its pools.
134///
135/// # Threads
136///
137/// `intra_threads` x `sessions_per_model` x the caller's page parallelism
138/// should not exceed the cores; one caller-facing thread budget (later, the
139/// CLI's `--threads`) drives both numbers. There is no automatic pool sizing:
140/// what is set here is what runs.
141#[non_exhaustive]
142pub struct RuntimeConfig {
143 /// Explicit model dir; wins over the env var and the default. Model
144 /// files only: the runtime library never reads this field (see
145 /// [`resolve_ort_dylib`]).
146 pub model_dir: Option<PathBuf>,
147 /// Explicit ONNX Runtime library; wins over the env var and the default.
148 pub ort_dylib: Option<PathBuf>,
149 /// ONNX Runtime intra-op threads per session. Default 4.
150 pub intra_threads: usize,
151 /// Sessions per model: a pool so parallel pages/tables do not serialise
152 /// on one `Mutex`. Default 1. `0` is clamped to 1 (see below).
153 pub sessions_per_model: usize,
154}
155
156impl Default for RuntimeConfig {
157 fn default() -> Self {
158 Self {
159 model_dir: None,
160 ort_dylib: None,
161 intra_threads: 4,
162 sessions_per_model: 1,
163 }
164 }
165}
166
167impl RuntimeConfig {
168 /// A default config: 4 intra threads, a pool of 1, resolved paths.
169 pub fn new() -> Self {
170 Self::default()
171 }
172
173 /// Set the explicit model dir; wins over everything.
174 pub fn with_model_dir(mut self, dir: impl Into<PathBuf>) -> Self {
175 self.model_dir = Some(dir.into());
176 self
177 }
178
179 /// Set the explicit ONNX Runtime library; wins over everything.
180 pub fn with_ort_dylib(mut self, lib: impl Into<PathBuf>) -> Self {
181 self.ort_dylib = Some(lib.into());
182 self
183 }
184
185 /// Set the ONNX Runtime intra-op threads per session.
186 pub fn with_intra_threads(mut self, threads: usize) -> Self {
187 self.intra_threads = threads;
188 self
189 }
190
191 /// Set the sessions per model.
192 ///
193 /// A pool so parallel pages/tables do not serialise on one `Mutex`;
194 /// `SharedSession::run` blocks until a session is free. `0` would block
195 /// forever (a pool that can never hold a session), so it is clamped to 1
196 /// when the pool is built -- the only automatic sizing in this module, and
197 /// a degenerate-input guard rather than a policy.
198 pub fn with_sessions_per_model(mut self, sessions: usize) -> Self {
199 self.sessions_per_model = sessions;
200 self
201 }
202
203 /// The pool size that is actually built: the configured value, at least 1.
204 #[cfg(feature = "onnx-runtime")]
205 fn effective_pool(&self) -> usize {
206 self.sessions_per_model.max(1)
207 }
208}
209
210/// What went wrong, in one of the two failure kinds the layer reports.
211///
212/// `#[non_exhaustive]`: new 1.x failure cases arrive as new variants, matched
213/// with a fallback arm.
214#[non_exhaustive]
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum ModelErrorKind {
217 /// The model dir/file or the runtime library is missing. Nothing is ever
218 /// fetched to make it appear.
219 ModelNotAvailable,
220 /// The file is present but its SHA-256 differs from the spec.
221 ChecksumMismatch,
222 /// The library loads but reports a version other than [`ORT_VERSION`].
223 VersionMismatch,
224 /// A second [`init`] resolving to a different library path. The process
225 /// keeps the first library; the call fails rather than switching.
226 ConflictingInit,
227 /// Anything else: the library would not load, a session would not build,
228 /// an inference call failed. The detail string says what.
229 Runtime,
230}
231
232impl ModelErrorKind {
233 /// The stable code bindings and the CLI map onto their error codes.
234 pub fn code(self) -> &'static str {
235 match self {
236 Self::ModelNotAvailable => "MODEL_NOT_AVAILABLE",
237 Self::ChecksumMismatch => "MODEL_CHECKSUM_MISMATCH",
238 Self::VersionMismatch => "VERSION_MISMATCH",
239 Self::ConflictingInit => "RUNTIME_CONFLICTING_INIT",
240 Self::Runtime => "MODEL_RUNTIME",
241 }
242 }
243}
244
245/// A shared-layer failure.
246///
247/// Carries the structured fields callers branch on; converts into
248/// [`std::io::Error`] via [`std::io::Error::other`] so `pdf-engine` surfaces
249/// it as `EngineError::Io` without a new variant, downcastable through
250/// [`model_error`].
251#[non_exhaustive]
252#[derive(Debug, Clone)]
253pub struct ModelError {
254 /// What went wrong.
255 pub kind: ModelErrorKind,
256 /// The spec name, or `"onnxruntime-1.23.2"` when the missing or
257 /// mismatched file is the runtime library itself.
258 pub model: Option<String>,
259 /// The digest the file was checked against ([`ModelErrorKind::ChecksumMismatch`]).
260 pub expected_sha256: Option<String>,
261 /// The path looked at: the model file, or the library.
262 pub path: Option<PathBuf>,
263 /// The spec's install command, shown verbatim when the model is missing.
264 pub install_command: Option<String>,
265 /// The runtime version required ([`ModelErrorKind::VersionMismatch`]).
266 pub expected_ort: Option<String>,
267 /// The runtime version the library reported ([`ModelErrorKind::VersionMismatch`]).
268 pub found_ort: Option<String>,
269 /// The unstructured remainder: inference failures, load errors, the paths
270 /// a conflicting init named.
271 detail: String,
272}
273
274impl ModelError {
275 /// The tier is not compiled into this build: a model tier was asked for on
276 /// a build without the cargo feature that runs it (#641).
277 ///
278 /// [`ModelErrorKind::ModelNotAvailable`], naming the feature to rebuild
279 /// with; the [`Display`](std::fmt::Display) appends the spec's install
280 /// command, so the message names both the feature and the install.
281 pub fn feature_not_compiled(spec: &ModelSpec, feature: &'static str) -> Self {
282 Self {
283 kind: ModelErrorKind::ModelNotAvailable,
284 model: Some(spec.name.to_string()),
285 expected_sha256: None,
286 path: None,
287 install_command: Some(spec.install_command.to_string()),
288 expected_ort: None,
289 found_ort: None,
290 detail: format!(
291 "model '{}' is not available: this build lacks the `{feature}` cargo feature \
292 (rebuild with `--features {feature}`)",
293 spec.name
294 ),
295 }
296 }
297
298 /// The model file or dir is missing. The display names the path looked at
299 /// and the install command, verbatim.
300 #[cfg(feature = "onnx-runtime")]
301 fn model_missing(spec: &ModelSpec, path: &Path) -> Self {
302 Self {
303 kind: ModelErrorKind::ModelNotAvailable,
304 model: Some(spec.name.to_string()),
305 expected_sha256: None,
306 path: Some(path.to_path_buf()),
307 install_command: Some(spec.install_command.to_string()),
308 expected_ort: None,
309 found_ort: None,
310 detail: format!(
311 "model '{}' is not available: {} was not found",
312 spec.name,
313 path.display()
314 ),
315 }
316 }
317
318 /// The runtime library is missing. `model` names the runtime, not a model.
319 #[cfg(feature = "onnx-runtime")]
320 fn runtime_missing(path: &Path) -> Self {
321 Self {
322 kind: ModelErrorKind::ModelNotAvailable,
323 model: Some(ORT_MODEL_NAME.to_string()),
324 expected_sha256: None,
325 path: Some(path.to_path_buf()),
326 install_command: None,
327 expected_ort: None,
328 found_ort: None,
329 detail: format!(
330 "model '{ORT_MODEL_NAME}' is not available: {} was not found. \
331 Place ONNX Runtime {ORT_VERSION} there, or point {ORT_DYLIB_ENV} \
332 at the library",
333 path.display()
334 ),
335 }
336 }
337
338 /// The file is present but hashes differently. Carries the expectation and
339 /// the path; the actual digest rides in the detail.
340 #[cfg(feature = "onnx-runtime")]
341 fn checksum_mismatch(spec: &ModelSpec, path: &Path, actual_sha256: &str) -> Self {
342 Self {
343 kind: ModelErrorKind::ChecksumMismatch,
344 model: Some(spec.name.to_string()),
345 expected_sha256: Some(spec.sha256.to_string()),
346 path: Some(path.to_path_buf()),
347 install_command: Some(spec.install_command.to_string()),
348 expected_ort: None,
349 found_ort: None,
350 detail: actual_sha256.to_string(),
351 }
352 }
353
354 /// The library loads but reports another version.
355 #[cfg(feature = "onnx-runtime")]
356 fn version_mismatch(path: &Path, found: &str) -> Self {
357 Self {
358 kind: ModelErrorKind::VersionMismatch,
359 model: Some(ORT_MODEL_NAME.to_string()),
360 expected_sha256: None,
361 path: Some(path.to_path_buf()),
362 install_command: None,
363 expected_ort: Some(ORT_VERSION.to_string()),
364 found_ort: Some(found.to_string()),
365 detail: format!(
366 "expected ONNX Runtime {ORT_VERSION}, found '{found}' at {}",
367 path.display()
368 ),
369 }
370 }
371
372 /// A second init resolving to a different library. The process keeps the
373 /// first; `path` is the rejected one.
374 #[cfg(feature = "onnx-runtime")]
375 fn conflicting_init(requested: &Path, current: &Path) -> Self {
376 Self {
377 kind: ModelErrorKind::ConflictingInit,
378 model: Some(ORT_MODEL_NAME.to_string()),
379 expected_sha256: None,
380 path: Some(requested.to_path_buf()),
381 install_command: None,
382 expected_ort: None,
383 found_ort: None,
384 detail: format!(
385 "ONNX Runtime is already initialised from {} and cannot also use {} \
386 in this process",
387 current.display(),
388 requested.display()
389 ),
390 }
391 }
392
393 /// An unstructured runtime failure: the detail says what.
394 #[cfg(feature = "onnx-runtime")]
395 fn runtime(detail: impl Into<String>) -> Self {
396 Self {
397 kind: ModelErrorKind::Runtime,
398 model: None,
399 expected_sha256: None,
400 path: None,
401 install_command: None,
402 expected_ort: None,
403 found_ort: None,
404 detail: detail.into(),
405 }
406 }
407}
408
409impl std::fmt::Display for ModelError {
410 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411 match self.kind {
412 ModelErrorKind::ModelNotAvailable => {
413 write!(f, "{}", self.detail)?;
414 if let Some(cmd) = &self.install_command {
415 write!(f, ". Install it with: {cmd}")?;
416 }
417 Ok(())
418 }
419 ModelErrorKind::ChecksumMismatch => {
420 let expected = self.expected_sha256.as_deref().unwrap_or("<unknown>");
421 let path = self
422 .path
423 .as_ref()
424 .map(|p| p.display().to_string())
425 .unwrap_or_else(|| "<unknown>".to_string());
426 write!(
427 f,
428 "model '{}' failed its checksum: {path} has sha256 {}, expected {expected}",
429 self.model.as_deref().unwrap_or("<unknown>"),
430 self.detail,
431 )?;
432 if let Some(cmd) = &self.install_command {
433 write!(f, ". Reinstall it with: {cmd}")?;
434 }
435 Ok(())
436 }
437 ModelErrorKind::VersionMismatch
438 | ModelErrorKind::ConflictingInit
439 | ModelErrorKind::Runtime => {
440 write!(f, "{}", self.detail)
441 }
442 }
443 }
444}
445
446impl std::error::Error for ModelError {}
447
448impl From<ModelError> for std::io::Error {
449 fn from(e: ModelError) -> Self {
450 std::io::Error::other(e)
451 }
452}
453
454/// Find the [`ModelError`] in an error chain, if there is one.
455///
456/// `pdf-engine` surfaces these as `EngineError::Io`: match that variant and
457/// walk the inner [`std::io::Error`] with this helper, then branch on
458/// [`ModelErrorKind::code`]. The walk must start at the `io::Error`, not above
459/// it: `EngineError::Io` is `thiserror::transparent`, which forwards
460/// `source()` to the inner error's own `source()` -- and that is `None` for
461/// an [`std::io::Error::other`] payload, which is exactly how this error
462/// travels. So every `io::Error` on the way is also opened through `get_ref()`,
463/// the same way `EngineError::ocr_message` reads its OCR causes.
464pub fn model_error<'a>(mut e: &'a (dyn std::error::Error + 'static)) -> Option<&'a ModelError> {
465 loop {
466 if let Some(found) = e.downcast_ref::<ModelError>() {
467 return Some(found);
468 }
469 if let Some(io) = e.downcast_ref::<std::io::Error>() {
470 if let Some(inner) = io.get_ref() {
471 if let Some(found) = model_error(inner) {
472 return Some(found);
473 }
474 }
475 }
476 e = e.source()?;
477 }
478}
479
480/// The library the process initialised with, once [`init`] has succeeded.
481/// Compared by canonical path, so two spellings of the same file agree.
482#[cfg(feature = "onnx-runtime")]
483static INITIALIZED_DYLIB: OnceLock<PathBuf> = OnceLock::new();
484
485/// Serialises [`init`] bodies: two threads initialising concurrently with
486/// different libraries must come out as one `Ok` and one [`ModelErrorKind::ConflictingInit`],
487/// never as two `Ok`s with `ort` silently keeping the first load.
488#[cfg(feature = "onnx-runtime")]
489static INIT_LOCK: Mutex<()> = Mutex::new(());
490
491/// Initialise ONNX Runtime once per process.
492///
493/// IDEMPOTENT: a later call resolving to the SAME library returns `Ok(())`,
494/// so callers need no once-guard of their own; only a call resolving to a
495/// DIFFERENT library path fails, with [`ModelErrorKind::ConflictingInit`].
496/// Loads the library, then checks the version string it reports against
497/// [`ORT_VERSION`] exactly.
498#[cfg(feature = "onnx-runtime")]
499pub fn init(config: &RuntimeConfig) -> Result<(), ModelError> {
500 let _held = match INIT_LOCK.lock() {
501 Ok(guard) => guard,
502 Err(poisoned) => poisoned.into_inner(),
503 };
504 let resolved = resolve_ort_dylib(config);
505 if let Some(current) = INITIALIZED_DYLIB.get() {
506 if same_library(&resolved, current) {
507 return Ok(());
508 }
509 // A path that is not there is not "a different library": it names
510 // nothing to conflict with, so it stays ModelNotAvailable however the
511 // process initialised.
512 if !resolved.exists() {
513 return Err(ModelError::runtime_missing(&resolved));
514 }
515 return Err(ModelError::conflicting_init(&resolved, current));
516 }
517 if !resolved.exists() {
518 return Err(ModelError::runtime_missing(&resolved));
519 }
520 let found = probe_ort_version(&resolved)?;
521 if check_ort_version(&found).is_err() {
522 return Err(ModelError::version_mismatch(&resolved, &found));
523 }
524 ort::init_from(&resolved)
525 .map_err(|e| {
526 ModelError::runtime(format!(
527 "could not load ONNX Runtime from {}: {e}",
528 resolved.display()
529 ))
530 })?
531 .commit();
532 match INITIALIZED_DYLIB.set(canonical_or_raw(&resolved)) {
533 Ok(()) => Ok(()),
534 Err(_) => match INITIALIZED_DYLIB.get() {
535 // Unreachable under INIT_LOCK, but a lock is not a proof: answer
536 // from the recorded path rather than assuming which thread won.
537 Some(current) if same_library(&resolved, current) => Ok(()),
538 Some(current) => Err(ModelError::conflicting_init(&resolved, current)),
539 None => Ok(()),
540 },
541 }
542}
543
544/// A session builder through the shared init path.
545///
546/// Calls [`init`], then returns `ort`'s builder with the graph optimisation
547/// level and intra threads this layer standardises on (Level3, and the
548/// config's `intra_threads`). The paddle backend builds its sessions through
549/// this so there is one init path.
550#[cfg(feature = "onnx-runtime")]
551pub fn builder(
552 config: &RuntimeConfig,
553) -> Result<ort::session::builder::SessionBuilder, ModelError> {
554 init(config)?;
555 let builder = ort::session::Session::builder().map_err(|e| {
556 ModelError::runtime(format!("could not create an ONNX session builder: {e}"))
557 })?;
558 let builder = builder
559 .with_optimization_level(ort::session::builder::GraphOptimizationLevel::Level3)
560 .map_err(|e| {
561 ModelError::runtime(format!("could not set graph optimisation Level3: {e}"))
562 })?;
563 builder
564 .with_intra_threads(config.intra_threads)
565 .map_err(|e| {
566 ModelError::runtime(format!(
567 "could not set intra_threads={}: {e}",
568 config.intra_threads
569 ))
570 })
571}
572
573/// Resolve + verify + load a model, cached process-wide.
574///
575/// Calls [`init`] if needed. The model file is checked before the library:
576/// a missing model is `ModelNotAvailable` naming the model even where no
577/// runtime is installed. The SHA-256 is verified by streaming the file, and at
578/// most once per process per file: a second `session` for the same key returns
579/// the same `Arc` without re-reading the file.
580#[cfg(feature = "onnx-runtime")]
581pub fn session(spec: &ModelSpec, config: &RuntimeConfig) -> Result<Arc<SharedSession>, ModelError> {
582 let model_path = resolve_model_file(spec, config);
583 let key = CacheKey::for_request(spec, config, &model_path);
584 if let Some(hit) = cache_get(&key) {
585 return Ok(hit);
586 }
587 if !model_path.exists() {
588 return Err(ModelError::model_missing(spec, &model_path));
589 }
590 verify_model_file(spec, &model_path)?;
591 init(config)?;
592 let pool = build_pool(&model_path, spec, config)?;
593 Ok(cache_insert(key, pool))
594}
595
596/// Hash the model file by streaming it and compare against the spec.
597///
598/// The file is read 64 KiB at a time, never held whole: a 300 MB weight must
599/// not sit in memory twice (here and in the session).
600#[cfg(feature = "onnx-runtime")]
601fn verify_model_file(spec: &ModelSpec, path: &Path) -> Result<(), ModelError> {
602 let mut file = std::fs::File::open(path).map_err(|e| {
603 if e.kind() == std::io::ErrorKind::NotFound {
604 ModelError::model_missing(spec, path)
605 } else {
606 ModelError::runtime(format!("could not read {}: {e}", path.display()))
607 }
608 })?;
609 match crate::integrity::verify_reader_sha256(&mut file, spec.sha256) {
610 Ok(()) => Ok(()),
611 Err(crate::integrity::StreamIntegrityError::Io(e)) => Err(ModelError::runtime(format!(
612 "could not read {}: {e}",
613 path.display()
614 ))),
615 Err(crate::integrity::StreamIntegrityError::Integrity(
616 crate::integrity::IntegrityError::DigestMismatch { actual, .. },
617 )) => Err(ModelError::checksum_mismatch(spec, path, &actual)),
618 Err(crate::integrity::StreamIntegrityError::Integrity(
619 crate::integrity::IntegrityError::MalformedDigest(digest),
620 )) => Err(ModelError::runtime(format!(
621 "model '{}' pins a malformed sha256 {digest:?}: a sha256 is 64 hex characters",
622 spec.name
623 ))),
624 }
625}
626
627/// Load `sessions_per_model` sessions for one verified file.
628#[cfg(feature = "onnx-runtime")]
629fn build_pool(
630 model_path: &Path,
631 spec: &ModelSpec,
632 config: &RuntimeConfig,
633) -> Result<SharedSession, ModelError> {
634 let mut sessions = Vec::with_capacity(config.effective_pool());
635 for _ in 0..config.effective_pool() {
636 let mut session_builder = builder(config)?;
637 let session = session_builder.commit_from_file(model_path).map_err(|e| {
638 ModelError::runtime(format!(
639 "could not load model '{}' from {}: {e}",
640 spec.name,
641 model_path.display()
642 ))
643 })?;
644 sessions.push(session);
645 }
646 Ok(SharedSession::new(sessions, spec.sha256))
647}
648
649/// A loaded model: a pool of [`RuntimeConfig::sessions_per_model`] `ort`
650/// sessions behind one handle.
651///
652/// `Send + Sync`. [`run`](Self::run) takes a FREE session (blocking until one
653/// is free -- `ort` 2.0.0-rc.13's `Session::run` takes `&mut self`) and
654/// returns it afterwards, including when the closure fails or panics. Results
655/// depend only on the inputs: no state is carried between calls.
656#[cfg(feature = "onnx-runtime")]
657pub struct SharedSession {
658 /// Idle sessions. Never empty past construction: [`run`](Self::run) waits
659 /// on `available` while it is, and every taken session is returned.
660 pool: Mutex<Vec<ort::session::Session>>,
661 /// Signalled whenever a session is returned to the pool.
662 available: Condvar,
663 /// The verified digest of the loaded file, from the spec.
664 model_sha256: String,
665}
666
667#[cfg(feature = "onnx-runtime")]
668impl std::fmt::Debug for SharedSession {
669 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
670 f.debug_struct("SharedSession")
671 .field("model_sha256", &self.model_sha256)
672 .finish_non_exhaustive()
673 }
674}
675
676#[cfg(feature = "onnx-runtime")]
677impl SharedSession {
678 /// Hold already-loaded sessions. Private: sessions only enter the pool
679 /// through [`session`], after the init, version and checksum checks.
680 fn new(sessions: Vec<ort::session::Session>, sha256: &str) -> Self {
681 Self {
682 pool: Mutex::new(sessions),
683 available: Condvar::new(),
684 model_sha256: sha256.to_string(),
685 }
686 }
687
688 /// Run an inference closure on a free session, blocking until one is free.
689 ///
690 /// The session is returned to the pool afterwards, whether the closure
691 /// fails or succeeds; an `ort` failure becomes
692 /// [`ModelErrorKind::Runtime`].
693 pub fn run<R>(
694 &self,
695 f: impl FnOnce(&mut ort::session::Session) -> ort::Result<R>,
696 ) -> Result<R, ModelError> {
697 let session = self.take();
698 let mut returned = SessionReturn {
699 pool: &self.pool,
700 notify: &self.available,
701 session: Some(session),
702 };
703 // Unreachable `None`: the slot is `Some` until `release` below, and
704 // this is the only use between construction and release.
705 let slot = match returned.session.as_mut() {
706 Some(slot) => slot,
707 None => unreachable!("the taken session is held until release"),
708 };
709 let result = f(slot).map_err(|e| ModelError::runtime(e.to_string()));
710 returned.release();
711 result
712 }
713
714 /// The verified SHA-256 of the loaded model file, from the spec.
715 pub fn model_sha256(&self) -> &str {
716 &self.model_sha256
717 }
718
719 /// Take an idle session, blocking while the pool is empty.
720 fn take(&self) -> ort::session::Session {
721 let mut guard = match self.pool.lock() {
722 Ok(guard) => guard,
723 Err(poisoned) => poisoned.into_inner(),
724 };
725 loop {
726 if let Some(session) = guard.pop() {
727 return session;
728 }
729 guard = match self.available.wait(guard) {
730 Ok(guard) => guard,
731 Err(poisoned) => poisoned.into_inner(),
732 };
733 }
734 }
735}
736
737/// Returns the taken session to the pool on the way out, including when the
738/// inference closure panics: without this a panic would shrink the pool by one
739/// per unwind, and a pool that has shrunk to nothing blocks forever.
740#[cfg(feature = "onnx-runtime")]
741struct SessionReturn<'a> {
742 pool: &'a Mutex<Vec<ort::session::Session>>,
743 notify: &'a Condvar,
744 session: Option<ort::session::Session>,
745}
746
747#[cfg(feature = "onnx-runtime")]
748impl SessionReturn<'_> {
749 fn release(&mut self) {
750 if let Some(session) = self.session.take() {
751 let mut guard = match self.pool.lock() {
752 Ok(guard) => guard,
753 Err(poisoned) => poisoned.into_inner(),
754 };
755 guard.push(session);
756 self.notify.notify_one();
757 }
758 }
759}
760
761#[cfg(feature = "onnx-runtime")]
762impl Drop for SessionReturn<'_> {
763 fn drop(&mut self) {
764 self.release();
765 }
766}
767
768/// The process-wide session cache: the verified SHA-256 plus the thread and
769/// pool sizes, with the canonical model path so two paths can never alias one
770/// entry.
771#[cfg(feature = "onnx-runtime")]
772#[derive(Debug, Clone, PartialEq, Eq, Hash)]
773struct CacheKey {
774 path: PathBuf,
775 sha256: String,
776 intra_threads: usize,
777 sessions_per_model: usize,
778}
779
780#[cfg(feature = "onnx-runtime")]
781impl CacheKey {
782 fn for_request(spec: &ModelSpec, config: &RuntimeConfig, model_path: &Path) -> Self {
783 Self {
784 path: canonical_or_raw(model_path),
785 sha256: spec.sha256.trim().to_ascii_lowercase(),
786 intra_threads: config.intra_threads,
787 sessions_per_model: config.effective_pool(),
788 }
789 }
790}
791
792#[cfg(feature = "onnx-runtime")]
793static SESSION_CACHE: std::sync::LazyLock<Mutex<HashMap<CacheKey, Arc<SharedSession>>>> =
794 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
795
796/// Look up the cache. A poisoned mutex still answers: its map is intact (the
797/// critical sections never panic), so the lock is recovered rather than
798/// treated as a failure.
799#[cfg(feature = "onnx-runtime")]
800fn cache_lock() -> std::sync::MutexGuard<'static, HashMap<CacheKey, Arc<SharedSession>>> {
801 match SESSION_CACHE.lock() {
802 Ok(guard) => guard,
803 Err(poisoned) => poisoned.into_inner(),
804 }
805}
806
807#[cfg(feature = "onnx-runtime")]
808fn cache_get(key: &CacheKey) -> Option<Arc<SharedSession>> {
809 cache_lock().get(key).cloned()
810}
811
812/// Insert a freshly loaded pool, or return the winner when two threads loaded
813/// the same key concurrently: every caller for one key shares one `Arc`.
814#[cfg(feature = "onnx-runtime")]
815fn cache_insert(key: CacheKey, pool: SharedSession) -> Arc<SharedSession> {
816 let mut cache = cache_lock();
817 cache.entry(key).or_insert_with(|| Arc::new(pool)).clone()
818}
819
820/// Resolve the model root: explicit dir, then `PDFLUENT_MODEL_DIR`, then the
821/// per-user default. An empty env var counts as unset, matching `ort`'s own
822/// reading of `ORT_DYLIB_PATH`.
823///
824/// Public so the install commands put every package file -- models and the
825/// root-level notices -- under the one root the loader reads.
826pub fn resolve_model_root(config: &RuntimeConfig) -> PathBuf {
827 if let Some(dir) = &config.model_dir {
828 dir.clone()
829 } else if let Some(dir) = env_dir(MODEL_DIR_ENV) {
830 dir
831 } else {
832 default_model_root()
833 }
834}
835
836/// Resolve the model file: [`resolve_model_root`] plus
837/// `<spec.name>/<spec.file>`, reported even when it is not there.
838///
839/// Public so the install commands put the file exactly where the session
840/// loader looks for it.
841pub fn resolve_model_file(spec: &ModelSpec, config: &RuntimeConfig) -> PathBuf {
842 resolve_model_root(config).join(spec.name).join(spec.file)
843}
844
845/// Resolve the runtime library: explicit path, then `ORT_DYLIB_PATH`, then
846/// `<model dir>/onnxruntime-<version>/<platform library>`, where the model
847/// dir is `PDFLUENT_MODEL_DIR` or the per-user default.
848///
849/// Public for the same reason as [`resolve_model_file`]: the installer and
850/// the loader must agree on `<dir>/onnxruntime-<version>/<platform library>`,
851/// under `PDFLUENT_MODEL_DIR` as much as under the default -- otherwise an
852/// install into a redirected root would leave a library the loader never
853/// finds. The explicit `model_dir` is deliberately NOT read here: it is a
854/// per-call model override, and letting it move the process-wide runtime
855/// would break `extract --model-dir <weights>` wherever the library sits at
856/// its installed default.
857pub fn resolve_ort_dylib(config: &RuntimeConfig) -> PathBuf {
858 if let Some(lib) = &config.ort_dylib {
859 return lib.clone();
860 }
861 if let Ok(path) = std::env::var(ORT_DYLIB_ENV) {
862 if !path.is_empty() {
863 return PathBuf::from(path);
864 }
865 }
866 let root = env_dir(MODEL_DIR_ENV).unwrap_or_else(default_model_root);
867 root.join(format!("onnxruntime-{ORT_VERSION}"))
868 .join(platform_lib_name())
869}
870
871/// A non-empty directory from the environment, or nothing.
872fn env_dir(var: &str) -> Option<PathBuf> {
873 match std::env::var(var) {
874 Ok(dir) if !dir.is_empty() => Some(PathBuf::from(dir)),
875 _ => None,
876 }
877}
878
879/// The per-user model root: `<data dir>/pdfluent/models/`, where the install
880/// commands put packages.
881///
882/// Public so installers and status commands name the same directory the
883/// resolution falls back to. This is the default only: an explicit dir and
884/// `PDFLUENT_MODEL_DIR` still win (see [`resolve_model_file`]).
885pub fn default_model_root() -> PathBuf {
886 user_data_dir().join("pdfluent").join("models")
887}
888
889/// The platform data dir via `std::env` only: `$XDG_DATA_HOME` or
890/// `~/.local/share` on Linux, `~/Library/Application Support` on macOS,
891/// `%LOCALAPPDATA%` on Windows. Without a home to read, the temp dir: a
892/// default that cannot be written is worse than a surprising one.
893fn user_data_dir() -> PathBuf {
894 #[cfg(target_os = "windows")]
895 {
896 if let Ok(dir) = std::env::var("LOCALAPPDATA") {
897 if !dir.is_empty() {
898 return PathBuf::from(dir);
899 }
900 }
901 }
902 #[cfg(target_os = "macos")]
903 {
904 if let Ok(home) = std::env::var("HOME") {
905 if !home.is_empty() {
906 return PathBuf::from(home)
907 .join("Library")
908 .join("Application Support");
909 }
910 }
911 }
912 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
913 {
914 if let Ok(dir) = std::env::var("XDG_DATA_HOME") {
915 if !dir.is_empty() {
916 return PathBuf::from(dir);
917 }
918 }
919 if let Ok(home) = std::env::var("HOME") {
920 if !home.is_empty() {
921 return PathBuf::from(home).join(".local").join("share");
922 }
923 }
924 }
925 std::env::temp_dir()
926}
927
928/// The ONNX Runtime library file name for this platform.
929fn platform_lib_name() -> &'static str {
930 #[cfg(target_os = "windows")]
931 {
932 "onnxruntime.dll"
933 }
934 #[cfg(target_os = "macos")]
935 {
936 "libonnxruntime.dylib"
937 }
938 #[cfg(not(any(target_os = "windows", target_os = "macos")))]
939 {
940 "libonnxruntime.so"
941 }
942}
943
944/// Whether two library paths name the same file. Canonical paths compare, so
945/// two spellings of one library agree; when either side cannot canonicalise
946/// (a file that is not there), the raw paths compare instead.
947#[cfg(feature = "onnx-runtime")]
948fn same_library(a: &Path, b: &Path) -> bool {
949 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
950 (Ok(a), Ok(b)) => a == b,
951 _ => a == b,
952 }
953}
954
955/// Canonicalise for cache keys and init records, falling back to the raw path
956/// when the file is not there (yet).
957#[cfg(feature = "onnx-runtime")]
958fn canonical_or_raw(path: &Path) -> PathBuf {
959 match std::fs::canonicalize(path) {
960 Ok(canonical) => canonical,
961 Err(_) => path.to_path_buf(),
962 }
963}
964
965/// Read the version string the library reports, through a temporary handle.
966///
967/// `ort` exposes no plain version of its own, so the layer opens the library
968/// with the same dlopen wrapper `ort` loads it through, calls
969/// `OrtGetApiBase`/`GetVersionString`, copies the string out, and drops the
970/// handle again; [`init`] then hands the path to `ort`, which opens its own
971/// handle. The double open is one `dlopen` per process, against a check that
972/// must speak before the library is committed to.
973#[cfg(feature = "onnx-runtime")]
974fn probe_ort_version(dylib: &Path) -> Result<String, ModelError> {
975 // SAFETY: mapping the file runs no code; the looked-up symbol is only
976 // called below, with its documented signature.
977 let lib = unsafe { libloading::Library::new(dylib) }.map_err(|e| {
978 ModelError::runtime(format!(
979 "could not load ONNX Runtime from {}: {e}",
980 dylib.display()
981 ))
982 })?;
983 type ApiBaseGetter = unsafe extern "C" fn() -> *const ort::sys::OrtApiBase;
984 // SAFETY: `OrtGetApiBase` is ONNX Runtime's documented entry point with
985 // this C signature; a library without it is rejected, not called.
986 let getter: libloading::Symbol<ApiBaseGetter> =
987 unsafe { lib.get(b"OrtGetApiBase") }.map_err(|e| {
988 ModelError::runtime(format!(
989 "{} is not an ONNX Runtime library (no OrtGetApiBase): {e}",
990 dylib.display()
991 ))
992 })?;
993 // SAFETY: the getter takes no arguments and returns a pointer that is
994 // valid while the library is mapped, which outlives this call.
995 let base = unsafe { getter() };
996 if base.is_null() {
997 return Err(ModelError::runtime(format!(
998 "{} returned a null OrtApiBase",
999 dylib.display()
1000 )));
1001 }
1002 // SAFETY: `GetVersionString` returns a static NUL-terminated UTF-8 string
1003 // the caller must not free; it is copied into an owned `String` before the
1004 // temporary handle drops.
1005 let raw = unsafe { ((*base).GetVersionString)() };
1006 if raw.is_null() {
1007 return Err(ModelError::runtime(format!(
1008 "{} returned a null version string",
1009 dylib.display()
1010 )));
1011 }
1012 Ok(unsafe { CStr::from_ptr(raw) }
1013 .to_string_lossy()
1014 .into_owned())
1015}
1016
1017/// The version comparison: the reported string must equal [`ORT_VERSION`]
1018/// exactly -- no prefix match, no minor-only check.
1019#[cfg(feature = "onnx-runtime")]
1020fn check_ort_version(found: &str) -> Result<(), ModelErrorKind> {
1021 if found == ORT_VERSION {
1022 Ok(())
1023 } else {
1024 Err(ModelErrorKind::VersionMismatch)
1025 }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031
1032 #[cfg(feature = "onnx-runtime")]
1033 #[test]
1034 fn the_pinned_version_passes_and_everything_else_fails() {
1035 assert_eq!(check_ort_version("1.23.2"), Ok(()));
1036 for found in [
1037 "1.23.1", "1.24.0", "1.23.20", "2.0.0", "", "1.23.2\n", " 1.23.2",
1038 ] {
1039 assert_eq!(
1040 check_ort_version(found),
1041 Err(ModelErrorKind::VersionMismatch),
1042 "{found:?} must not pass as {ORT_VERSION}"
1043 );
1044 }
1045 }
1046
1047 #[test]
1048 fn error_codes_are_the_published_strings() {
1049 assert_eq!(
1050 ModelErrorKind::ModelNotAvailable.code(),
1051 "MODEL_NOT_AVAILABLE"
1052 );
1053 assert_eq!(
1054 ModelErrorKind::ChecksumMismatch.code(),
1055 "MODEL_CHECKSUM_MISMATCH"
1056 );
1057 assert_eq!(ModelErrorKind::VersionMismatch.code(), "VERSION_MISMATCH");
1058 assert_eq!(
1059 ModelErrorKind::ConflictingInit.code(),
1060 "RUNTIME_CONFLICTING_INIT"
1061 );
1062 assert_eq!(ModelErrorKind::Runtime.code(), "MODEL_RUNTIME");
1063 }
1064
1065 #[cfg(feature = "onnx-runtime")]
1066 #[test]
1067 fn a_zero_pool_is_clamped_to_one() {
1068 let config = RuntimeConfig::new().with_sessions_per_model(0);
1069 assert_eq!(config.effective_pool(), 1);
1070 let config = RuntimeConfig::new().with_sessions_per_model(3);
1071 assert_eq!(config.effective_pool(), 3);
1072 }
1073
1074 #[test]
1075 fn a_build_without_the_tier_names_the_feature_and_the_install() {
1076 // The `Quality::Balanced` error on a build without `ort-layout`
1077 // (#641): ModelNotAvailable, naming the cargo feature to rebuild
1078 // with and the spec's install command. Runs without the feature --
1079 // that is the build it reports on.
1080 let spec = ModelSpec::new(
1081 "layout-heron-ep03",
1082 "heron_ep03.onnx",
1083 "b7cc5d31f3223c74cc477a1386b894793618aeb398ca59cf5eb7264ec61d8afa",
1084 "pdfluent models install layout",
1085 );
1086 let error = ModelError::feature_not_compiled(&spec, "ort-layout");
1087 assert_eq!(error.kind, ModelErrorKind::ModelNotAvailable);
1088 assert_eq!(error.kind.code(), "MODEL_NOT_AVAILABLE");
1089 let message = error.to_string();
1090 assert!(
1091 message.contains("`ort-layout`"),
1092 "the message names the cargo feature: {message}"
1093 );
1094 assert!(
1095 message.contains("pdfluent models install layout"),
1096 "the message names the install command: {message}"
1097 );
1098 // And it travels the shared path: io::Error::other, downcastable
1099 // through model_error.
1100 let io = std::io::Error::from(error);
1101 let back = model_error(&io).expect("the chain carries the ModelError");
1102 assert_eq!(back.kind, ModelErrorKind::ModelNotAvailable);
1103 }
1104
1105 #[test]
1106 fn resolution_honours_the_explicit_dir_over_everything() {
1107 // The precedence the balanced tier relies on: the explicit model dir
1108 // wins over the environment and the default, so a caller -- and a
1109 // test -- can point the tier at a scratch directory.
1110 let spec = ModelSpec::new("layout-heron-ep03", "heron_ep03.onnx", "0", "install");
1111 let config = RuntimeConfig::new().with_model_dir("/tmp/ort-explicit-test");
1112 assert_eq!(
1113 resolve_model_file(&spec, &config),
1114 std::path::PathBuf::from("/tmp/ort-explicit-test/layout-heron-ep03/heron_ep03.onnx")
1115 );
1116 }
1117
1118 #[test]
1119 fn the_dylib_fallback_reads_the_model_dir_env() {
1120 // The installer and the loader agree on the library path under a
1121 // redirected root: `PDFLUENT_MODEL_DIR` moves the
1122 // `onnxruntime-<version>` dir as much as the model packages. The
1123 // ONLY test in this target that touches the environment: every other
1124 // test resolves explicitly, so no parallel test observes it.
1125 struct Restore {
1126 key: &'static str,
1127 previous: Option<String>,
1128 }
1129 impl Drop for Restore {
1130 fn drop(&mut self) {
1131 match &self.previous {
1132 Some(value) => std::env::set_var(self.key, value),
1133 None => std::env::remove_var(self.key),
1134 }
1135 }
1136 }
1137 let _model_dir = Restore {
1138 key: MODEL_DIR_ENV,
1139 previous: std::env::var(MODEL_DIR_ENV).ok(),
1140 };
1141 let _dylib = Restore {
1142 key: ORT_DYLIB_ENV,
1143 previous: std::env::var(ORT_DYLIB_ENV).ok(),
1144 };
1145 std::env::remove_var(ORT_DYLIB_ENV);
1146 let from_env = std::env::temp_dir().join("ort-env-dylib-test");
1147 std::env::set_var(MODEL_DIR_ENV, &from_env);
1148 let lib = format!("onnxruntime-{ORT_VERSION}");
1149 assert_eq!(
1150 resolve_ort_dylib(&RuntimeConfig::new()),
1151 from_env.join(&lib).join(platform_lib_name()),
1152 "the env root carries the library dir"
1153 );
1154 // `ort`'s own variable still wins over the model root.
1155 std::env::set_var(ORT_DYLIB_ENV, "/tmp/ort-explicit-test/libonnxruntime.so");
1156 assert_eq!(
1157 resolve_ort_dylib(&RuntimeConfig::new()),
1158 std::path::PathBuf::from("/tmp/ort-explicit-test/libonnxruntime.so"),
1159 "ORT_DYLIB_PATH wins over the model root"
1160 );
1161 // An empty override counts as unset, like everywhere else here.
1162 std::env::remove_var(ORT_DYLIB_ENV);
1163 std::env::set_var(MODEL_DIR_ENV, "");
1164 assert_eq!(
1165 resolve_ort_dylib(&RuntimeConfig::new()),
1166 default_model_root().join(&lib).join(platform_lib_name()),
1167 "an empty model dir falls back to the default"
1168 );
1169 // The explicit per-call model dir does NOT move the runtime: it is a
1170 // model override, and the library stays where it was installed.
1171 std::env::remove_var(MODEL_DIR_ENV);
1172 let config = RuntimeConfig::new().with_model_dir("/tmp/ort-explicit-test");
1173 assert_eq!(
1174 resolve_ort_dylib(&config),
1175 default_model_root().join(&lib).join(platform_lib_name()),
1176 "the explicit model dir leaves the library at its default"
1177 );
1178 }
1179}