Skip to main content

docling_core/
confidence.rs

1//! Conversion-confidence report — the Rust counterpart of docling's
2//! `ConfidenceReport` / `PageConfidenceScores` (`docling.datamodel.base_models`,
3//! surfaced per conversion by Python docling-serve v1.25+, #183).
4//!
5//! Semantics mirror docling exactly:
6//!
7//! - Four per-page scores, each in `[0, 1]` or *unset* (docling uses `NaN`;
8//!   here `Option<f64>` so JSON serializes as `null` instead of an invalid
9//!   `NaN` literal): `layout_score` (mean confidence of the kept layout
10//!   clusters), `ocr_score` (mean confidence of OCR-recognized cells),
11//!   `parse_score` (10th-percentile text-layer quality — the quantile
12//!   emphasises problems), `table_score` (unset; docling never assigns it
13//!   either, the field exists for wire compatibility).
14//! - A page's `mean_score`/`low_score` are the NaN-ignoring mean / 5th
15//!   percentile of its four scores; document-level `mean_score`/`low_score`
16//!   are the plain means of the per-page values (docling's
17//!   `ConfidenceReport` overrides — note: *mean*, not quantile, for both).
18//! - Document-level per-field aggregation: mean for layout/table/ocr,
19//!   10th percentile for parse.
20//! - Grades: `< 0.5` poor, `< 0.8` fair, `< 0.9` good, `≥ 0.9` excellent,
21//!   unset → unspecified.
22
23use std::collections::BTreeMap;
24
25/// docling's `QualityGrade`: a score bucketed for human consumption.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum QualityGrade {
28    Poor,
29    Fair,
30    Good,
31    Excellent,
32    /// No score available (e.g. a declarative conversion with no ML stages).
33    Unspecified,
34}
35
36impl QualityGrade {
37    /// docling's `_score_to_grade` thresholds.
38    pub fn from_score(score: Option<f64>) -> Self {
39        match score {
40            Some(s) if s < 0.5 => Self::Poor,
41            Some(s) if s < 0.8 => Self::Fair,
42            Some(s) if s < 0.9 => Self::Good,
43            Some(_) => Self::Excellent,
44            None => Self::Unspecified,
45        }
46    }
47
48    /// The wire spelling (docling's lowercase enum values).
49    pub fn as_str(self) -> &'static str {
50        match self {
51            Self::Poor => "poor",
52            Self::Fair => "fair",
53            Self::Good => "good",
54            Self::Excellent => "excellent",
55            Self::Unspecified => "unspecified",
56        }
57    }
58}
59
60/// NaN-ignoring mean (docling's `np.nanmean`): `None` entries are skipped;
61/// all-unset yields `None` (where numpy would warn and return `NaN`).
62pub fn nanmean(values: &[Option<f64>]) -> Option<f64> {
63    let known: Vec<f64> = values.iter().filter_map(|v| *v).collect();
64    if known.is_empty() {
65        return None;
66    }
67    Some(known.iter().sum::<f64>() / known.len() as f64)
68}
69
70/// NaN-ignoring quantile with numpy's default linear interpolation
71/// (docling's `np.nanquantile(..., q)`).
72pub fn nanquantile(values: &[Option<f64>], q: f64) -> Option<f64> {
73    let mut known: Vec<f64> = values.iter().filter_map(|v| *v).collect();
74    if known.is_empty() {
75        return None;
76    }
77    known.sort_by(|a, b| a.total_cmp(b));
78    let pos = q * (known.len() - 1) as f64;
79    let lo = pos.floor() as usize;
80    let hi = pos.ceil() as usize;
81    if lo == hi {
82        return Some(known[lo]);
83    }
84    let frac = pos - lo as f64;
85    Some(known[lo] * (1.0 - frac) + known[hi] * frac)
86}
87
88/// One page's confidence scores (docling's `PageConfidenceScores`).
89#[derive(Debug, Clone, Copy, Default, PartialEq)]
90pub struct PageConfidence {
91    pub parse_score: Option<f64>,
92    pub layout_score: Option<f64>,
93    pub table_score: Option<f64>,
94    pub ocr_score: Option<f64>,
95}
96
97impl PageConfidence {
98    fn scores(&self) -> [Option<f64>; 4] {
99        // docling's aggregation order: ocr, table, layout, parse.
100        [
101            self.ocr_score,
102            self.table_score,
103            self.layout_score,
104            self.parse_score,
105        ]
106    }
107
108    /// NaN-ignoring mean of the four scores.
109    pub fn mean_score(&self) -> Option<f64> {
110        nanmean(&self.scores())
111    }
112
113    /// 5th percentile of the four scores (docling's `low_score`).
114    pub fn low_score(&self) -> Option<f64> {
115        nanquantile(&self.scores(), 0.05)
116    }
117
118    fn to_json(self) -> serde_json::Value {
119        serde_json::json!({
120            "parse_score": self.parse_score,
121            "layout_score": self.layout_score,
122            "table_score": self.table_score,
123            "ocr_score": self.ocr_score,
124            "mean_grade": QualityGrade::from_score(self.mean_score()).as_str(),
125            "low_grade": QualityGrade::from_score(self.low_score()).as_str(),
126            "mean_score": self.mean_score(),
127            "low_score": self.low_score(),
128        })
129    }
130}
131
132/// The document-level report (docling's `ConfidenceReport`): the four scores
133/// aggregated across pages, plus the per-page breakdown. Page keys are the
134/// **real 1-based page numbers** — the same numbering as the JSON export's
135/// `pages` map (#171), `--pages` windows included. (docling keys by its
136/// 0-based internal page index; ours is the more useful spelling and the
137/// difference is documented in `docs/MIGRATION.md`.)
138#[derive(Debug, Clone, Default, PartialEq)]
139pub struct ConfidenceReport {
140    pub pages: BTreeMap<usize, PageConfidence>,
141}
142
143impl ConfidenceReport {
144    /// Build from per-page scores keyed by 1-based page number.
145    pub fn from_pages(pages: BTreeMap<usize, PageConfidence>) -> Self {
146        Self { pages }
147    }
148
149    fn field(&self, get: impl Fn(&PageConfidence) -> Option<f64>) -> Vec<Option<f64>> {
150        self.pages.values().map(get).collect()
151    }
152
153    /// Document `layout_score`: mean of the per-page values.
154    pub fn layout_score(&self) -> Option<f64> {
155        nanmean(&self.field(|p| p.layout_score))
156    }
157
158    /// Document `parse_score`: 10th percentile of the per-page values
159    /// (docling: quantile here too, to emphasise problem pages).
160    pub fn parse_score(&self) -> Option<f64> {
161        nanquantile(&self.field(|p| p.parse_score), 0.1)
162    }
163
164    /// Document `table_score`: mean of the per-page values.
165    pub fn table_score(&self) -> Option<f64> {
166        nanmean(&self.field(|p| p.table_score))
167    }
168
169    /// Document `ocr_score`: mean of the per-page values.
170    pub fn ocr_score(&self) -> Option<f64> {
171        nanmean(&self.field(|p| p.ocr_score))
172    }
173
174    /// Document `mean_score`: mean of the per-page mean scores.
175    pub fn mean_score(&self) -> Option<f64> {
176        nanmean(&self.field(|p| p.mean_score()))
177    }
178
179    /// Document `low_score`: mean (sic — docling's override uses `nanmean`,
180    /// not a quantile) of the per-page low scores.
181    pub fn low_score(&self) -> Option<f64> {
182        nanmean(&self.field(|p| p.low_score()))
183    }
184
185    pub fn mean_grade(&self) -> QualityGrade {
186        QualityGrade::from_score(self.mean_score())
187    }
188
189    pub fn low_grade(&self) -> QualityGrade {
190        QualityGrade::from_score(self.low_score())
191    }
192
193    /// The full report as JSON — docling's `ConfidenceReport` dump shape
194    /// (unset scores as `null`, grades as lowercase strings, `pages` keyed by
195    /// stringified page number).
196    pub fn to_json(&self) -> serde_json::Value {
197        let mut value = self.summary_json();
198        value["pages"] = serde_json::Value::Object(
199            self.pages
200                .iter()
201                .map(|(n, p)| (n.to_string(), p.to_json()))
202                .collect(),
203        );
204        value
205    }
206
207    /// The document-level scores/grades only (no `pages`) — compact enough
208    /// for a response header.
209    pub fn summary_json(&self) -> serde_json::Value {
210        serde_json::json!({
211            "parse_score": self.parse_score(),
212            "layout_score": self.layout_score(),
213            "table_score": self.table_score(),
214            "ocr_score": self.ocr_score(),
215            "mean_grade": self.mean_grade().as_str(),
216            "low_grade": self.low_grade().as_str(),
217            "mean_score": self.mean_score(),
218            "low_score": self.low_score(),
219        })
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn grades_follow_docling_thresholds() {
229        assert_eq!(QualityGrade::from_score(Some(0.49)), QualityGrade::Poor);
230        assert_eq!(QualityGrade::from_score(Some(0.5)), QualityGrade::Fair);
231        assert_eq!(QualityGrade::from_score(Some(0.79)), QualityGrade::Fair);
232        assert_eq!(QualityGrade::from_score(Some(0.8)), QualityGrade::Good);
233        assert_eq!(QualityGrade::from_score(Some(0.89)), QualityGrade::Good);
234        assert_eq!(QualityGrade::from_score(Some(0.9)), QualityGrade::Excellent);
235        assert_eq!(QualityGrade::from_score(None), QualityGrade::Unspecified);
236    }
237
238    #[test]
239    fn nan_handling_matches_numpy() {
240        // nanmean skips unset entries; all-unset is unset.
241        assert_eq!(nanmean(&[Some(0.5), None, Some(1.0)]), Some(0.75));
242        assert_eq!(nanmean(&[None, None]), None);
243        // Linear interpolation: quantile 0.05 over [0.2, 1.0] = 0.2 + 0.05*0.8.
244        let q = nanquantile(&[Some(1.0), Some(0.2)], 0.05).unwrap();
245        assert!((q - 0.24).abs() < 1e-12, "{q}");
246        // Single value: every quantile is that value.
247        assert_eq!(nanquantile(&[None, Some(0.7)], 0.1), Some(0.7));
248    }
249
250    #[test]
251    fn report_aggregates_like_docling() {
252        let mut pages = BTreeMap::new();
253        pages.insert(
254            1,
255            PageConfidence {
256                parse_score: Some(1.0),
257                layout_score: Some(0.9),
258                table_score: None,
259                ocr_score: None,
260            },
261        );
262        pages.insert(
263            2,
264            PageConfidence {
265                parse_score: Some(0.6),
266                layout_score: Some(0.7),
267                table_score: None,
268                ocr_score: Some(0.8),
269            },
270        );
271        let report = ConfidenceReport::from_pages(pages);
272        // layout: mean(0.9, 0.7); ocr: mean over the one page that has it.
273        assert_eq!(report.layout_score(), Some(0.8));
274        assert_eq!(report.ocr_score(), Some(0.8));
275        assert_eq!(report.table_score(), None);
276        // parse: 10th percentile of [0.6, 1.0] = 0.6 + 0.1*0.4.
277        let parse = report.parse_score().unwrap();
278        assert!((parse - 0.64).abs() < 1e-12, "{parse}");
279        // Document mean = mean of the page means: page1 (0.9+1.0)/2 = 0.95,
280        // page2 (0.8+0.7+0.6)/3 = 0.7 → 0.825.
281        let mean = report.mean_score().unwrap();
282        assert!((mean - 0.825).abs() < 1e-12, "{mean}");
283        assert_eq!(report.mean_grade(), QualityGrade::Good);
284
285        let json = report.to_json();
286        assert_eq!(json["mean_grade"], "good");
287        assert_eq!(json["table_score"], serde_json::Value::Null);
288        assert!(json["pages"]["1"]["layout_score"].as_f64().is_some());
289    }
290
291    #[test]
292    fn empty_report_is_unspecified() {
293        let report = ConfidenceReport::default();
294        assert_eq!(report.mean_score(), None);
295        assert_eq!(report.mean_grade(), QualityGrade::Unspecified);
296        assert_eq!(report.to_json()["low_grade"], "unspecified");
297    }
298}