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/// Audits one PDF-to-Markdown conversion: how much of the extracted text
24/// survived into the rendered output, plus a tally of the structural
25/// blocks found, so every conversion is auditable rather than a silent
26/// best-effort guess.
27#[derive(Debug, Clone, Default, PartialEq)]
28pub struct ConversionReport {
29 pub pages: usize,
30
31 /// `split_whitespace().count()` word totals, kept for diagnostics only.
32 /// Do **not** use these to judge recovery -- hyphenation repair, OCR
33 /// word-gap merges, and table-cell tokenization all legitimately shift
34 /// word counts by several percent with zero content loss, which made
35 /// this ratio produce false FAILs on real documents (kopitiam-wwr).
36 /// [`ConversionReport::recovery_ratio`] is the authoritative signal;
37 /// these fields exist so a human auditing a report still has the raw
38 /// word counts to look at.
39 pub extracted_words: usize,
40 pub rendered_words: usize,
41
42 /// Non-whitespace character totals underlying the headline
43 /// [`recovery_ratio`](Self::recovery_ratio). `extracted_chars` counts
44 /// every `TextSpan` on every page (soft line-wrap hyphens excluded, see
45 /// `is_wrap_hyphen`); `rendered_chars` counts the rendered Markdown
46 /// after `strip_rendered_markdown_syntax` removes syntax the renderer
47 /// added (heading hashes, list markers, table pipes/separators,
48 /// blockquote markers, code fences, the figure placeholder).
49 pub extracted_chars: usize,
50 pub rendered_chars: usize,
51
52 pub headings_found: usize,
53 pub lists_found: usize,
54 pub tables_found: usize,
55 pub citations_found: usize,
56}
57
58impl ConversionReport {
59 /// The authoritative recovery signal: `rendered_chars / extracted_chars`,
60 /// both counted as non-whitespace characters after normalizing away
61 /// renderer-added Markdown syntax and PDF line-wrap hyphenation
62 /// artifacts (see `extracted_content_chars` and
63 /// `strip_rendered_markdown_syntax` in `validation::mod`).
64 ///
65 /// **What this can detect:** content that was extracted from the PDF
66 /// but never made it into the rendered Markdown -- a dropped
67 /// paragraph, a truncated table, a figure caption that got lost. A
68 /// character deficit of any size shows up directly as a ratio below
69 /// 1.0, because unlike a word count, non-whitespace character count
70 /// cannot be inflated or deflated by re-tokenizing the same content
71 /// differently.
72 ///
73 /// **What this cannot detect:** content that survived but was
74 /// *corrupted* or *reordered* -- garbled characters from a bad font
75 /// mapping, two paragraphs swapped, a table's rows shuffled, a heading
76 /// demoted to body text. All of those preserve the character count
77 /// while changing or misplacing the content, so this ratio stays at
78 /// ~100% and reports PASS regardless. This metric answers "did we lose
79 /// content?", not "is the rendered document correct?" -- correctness
80 /// still requires the structural tallies below and, ultimately, human
81 /// review.
82 pub fn recovery_ratio(&self) -> f64 {
83 if self.extracted_chars == 0 {
84 return 1.0;
85 }
86 self.rendered_chars as f64 / self.extracted_chars as f64
87 }
88
89 /// Secondary, informational-only word-count ratio. See the field docs
90 /// on [`extracted_words`](Self::extracted_words) for why this is not
91 /// used for PASS/FAIL: it is reported so a human can still see it, not
92 /// because it is trustworthy on its own.
93 pub fn word_recovery_ratio(&self) -> f64 {
94 if self.extracted_words == 0 {
95 return 1.0;
96 }
97 self.rendered_words as f64 / self.extracted_words as f64
98 }
99
100 /// PASS/FAIL against [`MIN_RECOVERY_RATIO`], using the character-based
101 /// [`recovery_ratio`](Self::recovery_ratio) -- never the word-count
102 /// ratio, which is informational only (see kopitiam-wwr).
103 pub fn passes(&self) -> bool {
104 self.recovery_ratio() >= MIN_RECOVERY_RATIO
105 }
106}
107
108impl std::fmt::Display for ConversionReport {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 writeln!(f, "Pages: {}", self.pages)?;
111 writeln!(f)?;
112 writeln!(f, "Recovery (characters, authoritative for PASS/FAIL):")?;
113 writeln!(f, " Extracted: {} chars", self.extracted_chars)?;
114 writeln!(f, " Rendered: {} chars", self.rendered_chars)?;
115 writeln!(f, " Ratio: {:.1}%", self.recovery_ratio() * 100.0)?;
116 writeln!(f)?;
117 writeln!(f, "Words (informational only, not authoritative -- see kopitiam-wwr):")?;
118 writeln!(f, " Extracted: {}", self.extracted_words)?;
119 writeln!(f, " Rendered: {}", self.rendered_words)?;
120 writeln!(f)?;
121 writeln!(f, "Headings: {}", self.headings_found)?;
122 writeln!(f, "Lists: {}", self.lists_found)?;
123 writeln!(f, "Tables: {}", self.tables_found)?;
124 writeln!(f, "Citations: {}", self.citations_found)?;
125 writeln!(f)?;
126 write!(f, "Status: {}", if self.passes() { "PASS" } else { "FAIL" })
127 }
128}