@@ -232,12 +232,70 @@ pub(crate) fn resolve_asset(rel: &str) -> String {
232232 rel. to_string ( )
233233}
234234
235+ /// One resolved runtime asset — which file a stage would load right now,
236+ /// given the CWD, the env overrides and the int8/fp32 preference.
235237#[ cfg( feature = "ml" ) ]
238+ #[ derive( Debug , Clone ) ]
239+ pub struct ModelEntry {
240+ /// Pipeline stage, e.g. `layout`, `tableformer.decoder`, `ocr.rec`.
241+ pub stage : & ' static str ,
242+ /// The resolved path (absolute or CWD-relative, as it will be opened).
243+ pub path : String ,
244+ /// Whether the file exists right now.
245+ pub found : bool ,
246+ /// File size in bytes (0 when missing) — enough to tell an int8 quant
247+ /// from an fp32 graph, or a stale model from a re-published one, at a
248+ /// glance without hashing gigabytes per request.
249+ pub bytes : u64 ,
250+ }
251+
252+ /// Resolve the whole runtime model set **without loading anything** — the
253+ /// exact selection each stage performs at load time (layout honors the
254+ /// int8/fp32 preference, TableFormer its decoder ranking, OCR the language
255+ /// pair), plus the pdfium library. docling-serve exposes this at
256+ /// `/v1/config` and logs it at startup, so "the server picked up different
257+ /// models" is one `curl` away instead of a mystery of dissolved tables.
258+ /// Resolution is CWD-relative with an exe-dir fallback, so the answer can
259+ /// legitimately differ between two working directories.
260+ #[ cfg( feature = "ml" ) ]
261+ pub fn model_inventory ( ) -> Vec < ModelEntry > {
262+ fn entry ( stage : & ' static str , path : String ) -> ModelEntry {
263+ let meta = std:: fs:: metadata ( & path) . ok ( ) ;
264+ ModelEntry {
265+ stage,
266+ found : meta. is_some ( ) ,
267+ bytes : meta. map ( |m| m. len ( ) ) . unwrap_or ( 0 ) ,
268+ path,
269+ }
270+ }
271+ let ( enc, dec, bbx) = tableformer:: resolved_paths ( ) ;
272+ let ( rec, dict) = ocr:: resolve_rec_pair ( ocr:: OcrLang :: from_env ( ) ) ;
273+ let pdfium =
274+ std:: env:: var ( "PDFIUM_DYNAMIC_LIB_PATH" ) . unwrap_or_else ( |_| resolve_asset ( ".pdfium/lib" ) ) ;
275+ vec ! [
276+ entry(
277+ "layout" ,
278+ model_path(
279+ "DOCLING_LAYOUT_ONNX" ,
280+ "models/layout_heron.onnx" ,
281+ "models/layout_heron_int8.onnx" ,
282+ ) ,
283+ ) ,
284+ entry( "tableformer.encoder" , enc) ,
285+ entry( "tableformer.decoder" , dec) ,
286+ entry( "tableformer.bbox" , bbx) ,
287+ entry( "ocr.rec" , rec) ,
288+ entry( "ocr.dict" , dict) ,
289+ entry( "pdfium" , pdfium) ,
290+ ]
291+ }
292+
236293/// Resolve a model path: an explicit env override always wins; otherwise the
237294/// INT8 variant of the default path when it exists on disk (the quantized
238295/// models are conformance-validated — see docs/PDF_CONFORMANCE.md — and load/run
239296/// markedly faster on CPU), unless `DOCLING_RS_FP32` opts back into full
240297/// precision; else the fp32 default.
298+ #[ cfg( feature = "ml" ) ]
241299pub ( crate ) fn model_path ( env : & str , fp32_default : & str , int8_default : & str ) -> String {
242300 if let Ok ( p) = std:: env:: var ( env) {
243301 return p;
@@ -495,11 +553,54 @@ impl Worker {
495553 page. code_cells . clear ( ) ;
496554 page. word_cells . clear ( ) ;
497555 }
556+ // Quant-robustness guard: the default int8 layout graph keeps its
557+ // confidences near the 0.5 label thresholds, and a different CPU's
558+ // quantized kernels can flip a whole page's detections under them —
559+ // tables and paragraphs then dissolve into orphan one-liners while the
560+ // same build converts the page perfectly elsewhere. When a dense
561+ // digital page ends up with detections covering almost none of its
562+ // text cells, re-run that one page on the fp32 graph (lazy-loaded,
563+ // auto-int8 selection only) and keep whichever detections cover more.
564+ let mut regions = regions;
565+ if !page. cells . is_empty ( ) {
566+ let thresholded = |rs : & [ layout:: Region ] | -> Vec < layout:: Region > {
567+ rs. iter ( )
568+ . filter ( |r| r. score >= layout:: label_threshold ( r. label ) )
569+ . cloned ( )
570+ . collect ( )
571+ } ;
572+ let text_cells = page
573+ . cells
574+ . iter ( )
575+ . filter ( |c| !c. text . trim ( ) . is_empty ( ) )
576+ . count ( ) ;
577+ let cov = assemble:: layout_cell_coverage ( & thresholded ( & regions) , & page. cells ) ;
578+ if text_cells >= 15 && cov < 0.5 {
579+ let retry = self
580+ . layout
581+ . as_mut ( )
582+ . expect ( "layout model loaded unless no_ocr" )
583+ . predict_fp32_fallback ( & page. image , page. width , page. height )
584+ . map_err ( |e| PdfError :: Layout ( format ! ( "page {}: {e}" , n + 1 ) ) ) ?;
585+ if let Some ( retry) = retry {
586+ let cov2 = assemble:: layout_cell_coverage ( & thresholded ( & retry) , & page. cells ) ;
587+ if cov2 > cov {
588+ eprintln ! (
589+ "docling-pdf: page {}: int8 layout covered {:.0}% of the text \
590+ cells; the fp32 retry covers {:.0}% — using it",
591+ n + 1 ,
592+ cov * 100.0 ,
593+ cov2 * 100.0
594+ ) ;
595+ regions = retry;
596+ }
597+ }
598+ }
599+ }
498600 // docling's LayoutPostprocessor drops each detection below its label's
499601 // confidence threshold (stricter than the 0.3 base the predictor keeps),
500602 // before any overlap resolution. This removes the low-confidence tables /
501603 // pictures / list-items that otherwise double-emit or mis-classify.
502- let mut regions = regions;
503604 regions. retain ( |r| r. score >= layout:: label_threshold ( r. label ) ) ;
504605 // Resolve overlapping detections once, before OCR.
505606 let mut regions = assemble:: resolve ( regions) ;
0 commit comments