kopitiam_document/validation/report.rs
1/// PASS threshold for [`ConversionReport::recovery_ratio`], the
2/// non-whitespace-character-based headline signal.
3///
4/// The old word-count ratio needed a lenient 0.95 threshold because
5/// re-tokenization (hyphenation repair, OCR word-gap merges, table-cell
6/// splitting) routinely shifted word counts by several percent even with
7/// zero content loss -- see kopitiam-wwr. The character-based signal is
8/// immune to re-tokenization by construction (concatenating text with a
9/// different choice of whitespace never changes a non-whitespace character
10/// count), so the only things that can now depress the ratio are: real
11/// content loss, or a gap in [`strip_rendered_markdown_syntax`]'s
12/// normalization (an unhandled scaffolding pattern, or one of the
13/// documented conservative false-positives in `strip_list_marker`). Both
14/// failure modes are rare and small once they occur, so the threshold is
15/// tightened to 0.98 rather than kept at 0.95: a genuinely complete
16/// conversion should now land within a fraction of a percent of 100%, and
17/// a 2%+ shortfall is a meaningful signal worth investigating rather than
18/// noise to be tolerated.
19///
20/// [`strip_rendered_markdown_syntax`]: super::strip_rendered_markdown_syntax
21const MIN_RECOVERY_RATIO: f64 = 0.98;
22
23/// Character recovery for a single source page, attributed via the
24/// `<!-- page N -->` anchors the renderer emits at page boundaries
25/// (`kopitiam_token_max.md` §8 card I-B).
26///
27/// The document-wide [`ConversionReport::recovery_ratio`] answers "did we lose
28/// content *somewhere*?"; these answer "*which page*?" — turning "97% overall,
29/// re-check everything" into "page 4 is 71%, check only page 4". Their
30/// `extracted_chars`/`rendered_chars` partition the document-wide totals
31/// exactly (they sum back to [`ConversionReport::extracted_chars`] /
32/// [`ConversionReport::rendered_chars`]), because the same normalization is
33/// applied and the anchor lines themselves contribute zero content.
34#[derive(Debug, Clone, Default, PartialEq)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize))]
36pub struct PageRecovery {
37 /// 1-based page number, matching the block's `Document::block_pages` value
38 /// and the `<!-- page N -->` anchor.
39 pub page: usize,
40 pub extracted_chars: usize,
41 pub rendered_chars: usize,
42}
43
44impl PageRecovery {
45 /// This page's `rendered_chars / extracted_chars`, with the same
46 /// empty-extraction convention as [`ConversionReport::recovery_ratio`]: a
47 /// page from which nothing was extracted (e.g. one that was entirely a
48 /// running head, now stripped) is full recovery, not a divide-by-zero.
49 pub fn recovery_ratio(&self) -> f64 {
50 if self.extracted_chars == 0 {
51 return 1.0;
52 }
53 self.rendered_chars as f64 / self.extracted_chars as f64
54 }
55}
56
57/// Audits one PDF-to-Markdown conversion: how much of the extracted text
58/// survived into the rendered output, plus a tally of the structural
59/// blocks found, so every conversion is auditable rather than a silent
60/// best-effort guess.
61#[derive(Debug, Clone, Default, PartialEq)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize))]
63pub struct ConversionReport {
64 pub pages: usize,
65
66 /// `split_whitespace().count()` word totals, kept for diagnostics only.
67 /// Do **not** use these to judge recovery -- hyphenation repair, OCR
68 /// word-gap merges, and table-cell tokenization all legitimately shift
69 /// word counts by several percent with zero content loss, which made
70 /// this ratio produce false FAILs on real documents (kopitiam-wwr).
71 /// [`ConversionReport::recovery_ratio`] is the authoritative signal;
72 /// these fields exist so a human auditing a report still has the raw
73 /// word counts to look at.
74 pub extracted_words: usize,
75 pub rendered_words: usize,
76
77 /// Non-whitespace character totals underlying the headline
78 /// [`recovery_ratio`](Self::recovery_ratio). `extracted_chars` counts
79 /// every `TextSpan` on every page (soft line-wrap hyphens excluded, see
80 /// `is_wrap_hyphen`); `rendered_chars` counts the rendered Markdown
81 /// after `strip_rendered_markdown_syntax` removes syntax the renderer
82 /// added (heading hashes, list markers, table pipes/separators,
83 /// blockquote markers, code fences, the figure placeholder).
84 pub extracted_chars: usize,
85 pub rendered_chars: usize,
86
87 pub headings_found: usize,
88 pub lists_found: usize,
89 pub tables_found: usize,
90 pub citations_found: usize,
91
92 /// Per-page character recovery, one entry per source page, in page order.
93 /// Populated only when the `Document` carries page provenance
94 /// (`Document::block_pages`); empty otherwise (a hand-built document has no
95 /// page to attribute content to, and guessing would be dishonest). See
96 /// [`PageRecovery`].
97 pub per_page: Vec<PageRecovery>,
98}
99
100impl ConversionReport {
101 /// The authoritative recovery signal: `rendered_chars / extracted_chars`,
102 /// both counted as non-whitespace characters after normalizing away
103 /// renderer-added Markdown syntax and PDF line-wrap hyphenation
104 /// artifacts (see `extracted_content_chars` and
105 /// `strip_rendered_markdown_syntax` in `validation::mod`).
106 ///
107 /// **What this can detect:** content that was extracted from the PDF
108 /// but never made it into the rendered Markdown -- a dropped
109 /// paragraph, a truncated table, a figure caption that got lost. A
110 /// character deficit of any size shows up directly as a ratio below
111 /// 1.0, because unlike a word count, non-whitespace character count
112 /// cannot be inflated or deflated by re-tokenizing the same content
113 /// differently.
114 ///
115 /// **What this cannot detect:** content that survived but was
116 /// *corrupted* or *reordered* -- garbled characters from a bad font
117 /// mapping, two paragraphs swapped, a table's rows shuffled, a heading
118 /// demoted to body text. All of those preserve the character count
119 /// while changing or misplacing the content, so this ratio stays at
120 /// ~100% and reports PASS regardless. This metric answers "did we lose
121 /// content?", not "is the rendered document correct?" -- correctness
122 /// still requires the structural tallies below and, ultimately, human
123 /// review.
124 pub fn recovery_ratio(&self) -> f64 {
125 if self.extracted_chars == 0 {
126 return 1.0;
127 }
128 self.rendered_chars as f64 / self.extracted_chars as f64
129 }
130
131 /// Secondary, informational-only word-count ratio. See the field docs
132 /// on [`extracted_words`](Self::extracted_words) for why this is not
133 /// used for PASS/FAIL: it is reported so a human can still see it, not
134 /// because it is trustworthy on its own.
135 pub fn word_recovery_ratio(&self) -> f64 {
136 if self.extracted_words == 0 {
137 return 1.0;
138 }
139 self.rendered_words as f64 / self.extracted_words as f64
140 }
141
142 /// PASS/FAIL against [`MIN_RECOVERY_RATIO`], using the character-based
143 /// [`recovery_ratio`](Self::recovery_ratio) -- never the word-count
144 /// ratio, which is informational only (see kopitiam-wwr).
145 pub fn passes(&self) -> bool {
146 self.recovery_ratio() >= MIN_RECOVERY_RATIO
147 }
148
149 /// A machine-readable JSON view of this report (`kopitiam pdf2md
150 /// --report-json`, card I-F).
151 ///
152 /// # Why a bespoke view rather than the bare `#[derive(Serialize)]`
153 ///
154 /// The point of `--report-json` is that a caller gates on the recovery
155 /// ratio *without parsing prose* (`kopitiam_token_max.md` §0.2). But the
156 /// headline signals a caller actually gates on — [`recovery_ratio`], whether
157 /// it [`passes`], and the **per-page** ratios from card I-B — are computed
158 /// methods, not stored fields, so the raw field derive omits exactly them.
159 /// This view emits the stored fields *and* those computed signals, so
160 /// `recovery_ratio`, `passes`, and each page's `recovery_ratio` are present
161 /// as first-class numbers a script can test directly.
162 ///
163 /// [`recovery_ratio`]: Self::recovery_ratio
164 /// [`passes`]: Self::passes
165 #[cfg(feature = "serde")]
166 pub fn to_json(&self) -> serde_json::Value {
167 serde_json::json!({
168 "pages": self.pages,
169 "recovery_ratio": self.recovery_ratio(),
170 "passes": self.passes(),
171 "extracted_chars": self.extracted_chars,
172 "rendered_chars": self.rendered_chars,
173 "word_recovery_ratio": self.word_recovery_ratio(),
174 "extracted_words": self.extracted_words,
175 "rendered_words": self.rendered_words,
176 "headings_found": self.headings_found,
177 "lists_found": self.lists_found,
178 "tables_found": self.tables_found,
179 "citations_found": self.citations_found,
180 "per_page": self
181 .per_page
182 .iter()
183 .map(|p| {
184 serde_json::json!({
185 "page": p.page,
186 "extracted_chars": p.extracted_chars,
187 "rendered_chars": p.rendered_chars,
188 "recovery_ratio": p.recovery_ratio(),
189 })
190 })
191 .collect::<Vec<_>>(),
192 })
193 }
194}
195
196impl std::fmt::Display for ConversionReport {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 writeln!(f, "Pages: {}", self.pages)?;
199 writeln!(f)?;
200 writeln!(f, "Recovery (characters, authoritative for PASS/FAIL):")?;
201 writeln!(f, " Extracted: {} chars", self.extracted_chars)?;
202 writeln!(f, " Rendered: {} chars", self.rendered_chars)?;
203 writeln!(f, " Ratio: {:.1}%", self.recovery_ratio() * 100.0)?;
204 if !self.per_page.is_empty() {
205 writeln!(f)?;
206 writeln!(f, " Per page:")?;
207 for page in &self.per_page {
208 writeln!(
209 f,
210 " Page {:>3}: {:>5.1}% ({}/{} chars)",
211 page.page,
212 page.recovery_ratio() * 100.0,
213 page.rendered_chars,
214 page.extracted_chars,
215 )?;
216 }
217 }
218 writeln!(f)?;
219 writeln!(f, "Words (informational only, not authoritative -- see kopitiam-wwr):")?;
220 writeln!(f, " Extracted: {}", self.extracted_words)?;
221 writeln!(f, " Rendered: {}", self.rendered_words)?;
222 writeln!(f)?;
223 writeln!(f, "Headings: {}", self.headings_found)?;
224 writeln!(f, "Lists: {}", self.lists_found)?;
225 writeln!(f, "Tables: {}", self.tables_found)?;
226 writeln!(f, "Citations: {}", self.citations_found)?;
227 writeln!(f)?;
228 write!(f, "Status: {}", if self.passes() { "PASS" } else { "FAIL" })
229 }
230}
231
232#[cfg(all(test, feature = "serde"))]
233mod json_tests {
234 use super::*;
235
236 fn sample() -> ConversionReport {
237 ConversionReport {
238 pages: 2,
239 extracted_words: 10,
240 rendered_words: 10,
241 extracted_chars: 100,
242 rendered_chars: 98,
243 headings_found: 3,
244 lists_found: 1,
245 tables_found: 2,
246 citations_found: 4,
247 per_page: vec![
248 PageRecovery { page: 1, extracted_chars: 60, rendered_chars: 60 },
249 PageRecovery { page: 2, extracted_chars: 40, rendered_chars: 38 },
250 ],
251 }
252 }
253
254 #[test]
255 fn to_json_carries_the_computed_signals_a_caller_gates_on() {
256 // Card I-F / §0.2: the computed recovery_ratio + passes must be present
257 // as first-class numbers, not left for the caller to re-derive.
258 let json = sample().to_json();
259 assert_eq!(json["pages"], 2);
260 // 98 / 100 = 0.98 -> exactly the PASS threshold.
261 assert!((json["recovery_ratio"].as_f64().unwrap() - 0.98).abs() < 1e-9);
262 assert_eq!(json["passes"], true);
263 assert_eq!(json["extracted_chars"], 100);
264 assert_eq!(json["rendered_chars"], 98);
265 assert_eq!(json["headings_found"], 3);
266 assert_eq!(json["tables_found"], 2);
267 assert_eq!(json["citations_found"], 4);
268 }
269
270 #[test]
271 fn to_json_includes_per_page_ratios_from_card_i_b() {
272 let json = sample().to_json();
273 let per_page = json["per_page"].as_array().unwrap();
274 assert_eq!(per_page.len(), 2);
275 assert_eq!(per_page[0]["page"], 1);
276 assert!((per_page[0]["recovery_ratio"].as_f64().unwrap() - 1.0).abs() < 1e-9);
277 assert_eq!(per_page[1]["page"], 2);
278 // 38 / 40 = 0.95: the per-page view localizes the shortfall to page 2.
279 assert!((per_page[1]["recovery_ratio"].as_f64().unwrap() - 0.95).abs() < 1e-9);
280 }
281}