1use std::collections::BTreeMap;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum QualityGrade {
28 Poor,
29 Fair,
30 Good,
31 Excellent,
32 Unspecified,
34}
35
36impl QualityGrade {
37 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 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
60pub 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
70pub 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#[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 [
101 self.ocr_score,
102 self.table_score,
103 self.layout_score,
104 self.parse_score,
105 ]
106 }
107
108 pub fn mean_score(&self) -> Option<f64> {
110 nanmean(&self.scores())
111 }
112
113 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#[derive(Debug, Clone, Default, PartialEq)]
139pub struct ConfidenceReport {
140 pub pages: BTreeMap<usize, PageConfidence>,
141}
142
143impl ConfidenceReport {
144 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 pub fn layout_score(&self) -> Option<f64> {
155 nanmean(&self.field(|p| p.layout_score))
156 }
157
158 pub fn parse_score(&self) -> Option<f64> {
161 nanquantile(&self.field(|p| p.parse_score), 0.1)
162 }
163
164 pub fn table_score(&self) -> Option<f64> {
166 nanmean(&self.field(|p| p.table_score))
167 }
168
169 pub fn ocr_score(&self) -> Option<f64> {
171 nanmean(&self.field(|p| p.ocr_score))
172 }
173
174 pub fn mean_score(&self) -> Option<f64> {
176 nanmean(&self.field(|p| p.mean_score()))
177 }
178
179 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 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 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 assert_eq!(nanmean(&[Some(0.5), None, Some(1.0)]), Some(0.75));
242 assert_eq!(nanmean(&[None, None]), None);
243 let q = nanquantile(&[Some(1.0), Some(0.2)], 0.05).unwrap();
245 assert!((q - 0.24).abs() < 1e-12, "{q}");
246 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 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 let parse = report.parse_score().unwrap();
278 assert!((parse - 0.64).abs() < 1e-12, "{parse}");
279 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}