//! Every number a text-layer verdict is decided on must match the Python //! exactly. //! //! The thresholds these feed were measured against three thousand real PDFs, //! so a score that is a little different is a verdict that is occasionally //! different — and a wrong "garbled" costs an hour of GPU time while a wrong //! "healthy" puts unreadable text in the index for good. The samples are //! synthetic: a parity test over a text-layer classifier needs the score //! inputs, not anybody's book. use serde_json::Value; /// Measurements that are meant to differ, each argued for in `KNOWN.md`. /// /// The two implementations compile against different Unicode versions, and /// the newer one knows letterforms the older one does not. const DIVERGENT: &[(&str, &str)] = &[("cyrillic-extended-c", "letters")]; /// Both sides compute a count divided by a count, so agreement is exact or /// the counts differ. fn same(ours: f64, theirs: &Value) -> bool { match theirs { Value::Null => false, value => value.as_f64() == Some(ours), } } fn cases() -> Vec { let text = std::fs::read_to_string(parity::dir().join("cases/pdftriage/samples.ndjson")) .expect("a readable case file"); text.lines() .filter(|line| !line.trim().is_empty()) .map(|line| serde_json::from_str(line).expect("valid JSON")) .collect() } #[test] fn every_measurement_matches_python() { let golden: Vec = parity::read_golden_text("pdftriage/samples.ndjson") .lines() .filter(|line| !line.trim().is_empty()) .map(|line| serde_json::from_str(line).expect("valid JSON")) .collect(); let cases = cases(); assert_eq!(cases.len(), golden.len(), "case count moved — regenerate"); let mut wrong: Vec = Vec::new(); for (case, theirs) in cases.iter().zip(&golden) { let name = case["name"].as_str().expect("a name"); assert_eq!(theirs["name"].as_str(), Some(name), "cases out of order"); let text = case["text"].as_str().expect("text"); let body = pdftriage::score::body(text); let mut note = |field: &str, ours: String, theirs: &Value| { if DIVERGENT.contains(&(name, field)) { return; } wrong.push(format!("{name}.{field}: python {theirs}, rust {ours}")); }; if !same(body.len() as f64, &theirs["body"]) { note("body", body.len().to_string(), &theirs["body"]); } if !same(pdftriage::score::ascii_text_score(text), &theirs["ascii"]) { let ours = pdftriage::score::ascii_text_score(text); note("ascii", ours.to_string(), &theirs["ascii"]); } if !same(pdftriage::score::letter_share(&body), &theirs["letters"]) { let ours = pdftriage::score::letter_share(&body); note("letters", ours.to_string(), &theirs["letters"]); } if !same(pdftriage::score::latin1_share(&body), &theirs["latin1"]) { let ours = pdftriage::score::latin1_share(&body); note("latin1", ours.to_string(), &theirs["latin1"]); } let shape = pdftriage::score::word_shape(text, 40); match (shape, theirs["vowelled"].as_f64(), theirs["tidy"].as_f64()) { (None, None, None) => {} (Some((vowelled, tidy)), Some(their_vowelled), Some(their_tidy)) => { if vowelled != their_vowelled { note("vowelled", vowelled.to_string(), &theirs["vowelled"]); } if tidy != their_tidy { note("tidy", tidy.to_string(), &theirs["tidy"]); } } (ours, _, _) => note("word_shape", format!("{ours:?}"), &theirs["vowelled"]), } let english = pdftriage::english_prose_share(text, 80); match (english, theirs["english"].as_f64()) { (None, None) => {} (Some(ours), Some(theirs_value)) if ours == theirs_value => {} (ours, _) => note("english", format!("{ours:?}"), &theirs["english"]), } let decoded = pdftriage::reflect::undo(text); let their_decoded = theirs["decoded"].as_str(); if decoded.as_deref() != their_decoded { note( "decoded", match &decoded { None => "none".to_string(), Some(text) => format!("{} characters", text.chars().count()), }, &theirs["decoded"], ); } } // A divergence that has quietly gone away is a note in KNOWN.md that is // no longer true, which is worth knowing about. for (name, field) in DIVERGENT { let case = cases .iter() .find(|case| case["name"].as_str() == Some(name)) .unwrap_or_else(|| panic!("{name} is recorded as divergent but is not a case")); let theirs = golden .iter() .find(|record| record["name"].as_str() == Some(name)) .expect("a golden answer"); let body = pdftriage::score::body(case["text"].as_str().expect("text")); let ours = match *field { "letters" => pdftriage::score::letter_share(&body), other => panic!("nothing measures {other}"), }; assert_ne!( Some(ours), theirs[field].as_f64(), "{name}.{field} no longer diverges — update KNOWN.md" ); } assert!( wrong.is_empty(), "{} measurements differ:\n{}", wrong.len(), wrong .iter() .take(20) .map(|line| line.chars().take(200).collect::()) .collect::>() .join("\n") ); } /// Catches a golden file drifting away from a Python that has moved on. #[test] #[cfg(feature = "python")] fn the_golden_file_still_matches_live_python() { let cases = std::fs::read_to_string(parity::dir().join("cases/pdftriage/samples.ndjson")) .expect("a readable case file"); let live = parity::run_oracle("pdftriage.py", &cases).expect("the oracle runs"); assert_eq!(live, parity::read_golden_text("pdftriage/samples.ndjson")); }