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/// 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}