Skip to main content

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}