Skip to main content

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}