1use serde::{Deserialize, Serialize};
19use unicode_normalization::UnicodeNormalization;
20
21use crate::id::{ArxivId, WorkId};
22use crate::record::SourceRecord;
23use crate::work::Person;
24
25const W_TITLE: f64 = 0.5;
27const W_AUTHORS: f64 = 0.25;
28const W_YEAR: f64 = 0.25;
29
30const YEAR_MISMATCH_PENALTY: f64 = 0.5;
32const TITLE_MISMATCH_PENALTY: f64 = 0.5;
34const AUTHORS_MISMATCH_PENALTY: f64 = 0.5;
36const SINGLE_FIELD_CAP: f64 = 0.7;
38const UNCORROBORATED_CAP: f64 = 0.8;
40const CORROBORATING_AUTHORS: f64 = 0.75;
43const DERIVATIVE_PENALTY: f64 = 0.5;
45
46pub const VERIFIED: f64 = 0.85;
48pub const PLAUSIBLE: f64 = 0.6;
49
50const AUTHORS_COMPARED: usize = 3;
52
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
55#[non_exhaustive]
56pub struct Claim {
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub text: Option<String>,
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub ids: Vec<WorkId>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub title: Option<String>,
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 pub authors: Vec<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub year: Option<i16>,
70}
71
72impl Claim {
73 pub fn from_text(text: impl Into<String>) -> Self {
76 let text = text.into();
77 Claim {
78 ids: find_ids(&text),
79 text: Some(text),
80 ..Claim::default()
81 }
82 }
83
84 pub fn from_input(input: &str) -> Self {
88 let input = input.trim();
89 match input.parse::<WorkId>() {
90 Ok(id) => Claim {
91 ids: vec![id],
92 ..Claim::default()
93 },
94 Err(_) => Claim::from_text(input),
95 }
96 }
97
98 pub fn is_empty(&self) -> bool {
99 self.text.is_none()
100 && self.ids.is_empty()
101 && self.title.is_none()
102 && self.authors.is_empty()
103 }
104
105 pub fn reference(&self) -> Option<String> {
108 if let Some(text) = &self.text {
109 return Some(text.clone());
110 }
111 let parts: Vec<String> = [
112 (!self.authors.is_empty()).then(|| self.authors.join(", ")),
113 self.title.clone(),
114 self.year.map(|y| y.to_string()),
115 ]
116 .into_iter()
117 .flatten()
118 .collect();
119 (!parts.is_empty()).then(|| parts.join(". "))
120 }
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum Agreement {
126 Match,
127 Partial,
128 Mismatch,
129 Unknown,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
135pub struct FieldCheck {
136 pub agreement: Agreement,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub score: Option<f64>,
139}
140
141impl FieldCheck {
142 const UNKNOWN: FieldCheck = FieldCheck {
143 agreement: Agreement::Unknown,
144 score: None,
145 };
146
147 fn scored(score: f64) -> Self {
148 let agreement = match score {
149 s if s >= 0.9 => Agreement::Match,
150 s if s >= 0.5 => Agreement::Partial,
151 _ => Agreement::Mismatch,
152 };
153 FieldCheck {
154 agreement,
155 score: Some(score),
156 }
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
161#[non_exhaustive]
162pub struct Assessment {
163 pub confidence: f64,
164 pub title: FieldCheck,
165 pub authors: FieldCheck,
166 pub year: FieldCheck,
167 pub ids: Agreement,
171}
172
173pub fn assess(claim: &Claim, record: &SourceRecord) -> Assessment {
175 let work = &record.work;
176 let text = claim.text.as_deref().map(Normalised::new);
177
178 let title = match (&claim.title, &work.title, &text) {
179 (Some(claimed), Some(actual), _) => FieldCheck::scored(title_similarity(claimed, actual)),
180 (None, Some(actual), Some(text)) => FieldCheck::scored(text.title_coverage(actual)),
181 _ => FieldCheck::UNKNOWN,
182 };
183
184 let families: Vec<String> = work.authors.iter().filter_map(family_name).collect();
185 let authors = if families.is_empty() {
186 FieldCheck::UNKNOWN
187 } else if !claim.authors.is_empty() {
188 FieldCheck::scored(author_overlap(&claim.authors, &families))
189 } else if let Some(text) = &text {
190 FieldCheck::scored(text.author_coverage(&families))
191 } else {
192 FieldCheck::UNKNOWN
193 };
194
195 let actual_years: Vec<i16> = work
196 .issued
197 .map(|d| d.year())
198 .into_iter()
199 .chain(work.other_years.iter().copied())
200 .collect();
201 let claimed_years: Vec<i16> = match (claim.year, &text) {
202 (Some(y), _) => vec![y],
203 (None, Some(text)) => text.years(),
204 _ => Vec::new(),
205 };
206 let year = match actual_years.is_empty() || claimed_years.is_empty() {
207 false => {
208 let delta = claimed_years
210 .iter()
211 .flat_map(|c| actual_years.iter().map(move |a| (c - a).unsigned_abs()))
212 .min()
213 .unwrap_or(u16::MAX);
214 FieldCheck::scored(0.5f64.powi(i32::from(delta.min(30))))
218 }
219 true => FieldCheck::UNKNOWN,
220 };
221
222 let ids = id_agreement(&claim.ids, &record.ids);
223 let id_only = [title, authors, year].iter().all(|c| c.score.is_none());
225 let mut confidence = if id_only && ids == Agreement::Match {
226 1.0
227 } else {
228 combine(title, authors, year)
229 };
230 let corroborated =
235 authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS) || ids == Agreement::Match;
236 if !id_only && !corroborated {
237 confidence = confidence.min(UNCORROBORATED_CAP);
238 }
239 if let Some(actual) = &work.title
242 && is_derivative(actual)
243 && !claim_mentions_derivative(claim)
244 {
245 confidence *= DERIVATIVE_PENALTY;
246 }
247
248 Assessment {
249 confidence,
250 title,
251 authors,
252 year,
253 ids,
254 }
255}
256
257fn combine(title: FieldCheck, authors: FieldCheck, year: FieldCheck) -> f64 {
258 let parts = [(title, W_TITLE), (authors, W_AUTHORS), (year, W_YEAR)];
259 let known: Vec<(f64, f64)> = parts
260 .iter()
261 .filter_map(|(check, w)| check.score.map(|s| (s, *w)))
262 .collect();
263 let weight: f64 = known.iter().map(|(_, w)| w).sum();
264 if weight == 0.0 {
265 return 0.0;
266 }
267 let mut confidence = known.iter().map(|(s, w)| s * w).sum::<f64>() / weight;
268 if year.agreement == Agreement::Mismatch {
269 confidence *= YEAR_MISMATCH_PENALTY;
270 }
271 if title.agreement == Agreement::Mismatch {
272 confidence *= TITLE_MISMATCH_PENALTY;
273 }
274 if authors.agreement == Agreement::Mismatch {
277 confidence *= AUTHORS_MISMATCH_PENALTY;
278 }
279 if known.len() < 2 {
280 confidence = confidence.min(SINGLE_FIELD_CAP);
281 }
282 confidence
283}
284
285const DERIVATIVE_MARKERS: &[&str] = &[
288 "reply",
289 "erratum",
290 "errata",
291 "corrigendum",
292 "correction",
293 "comment on",
294 "response to",
295 "in response",
296 "faculty opinions recommendation",
297 "retraction of",
298 "expression of concern",
299];
300
301pub fn is_derivative(title: &str) -> bool {
304 let words = format!(" {} ", normalise(title));
305 DERIVATIVE_MARKERS
306 .iter()
307 .any(|m| words.contains(&format!(" {m} ")))
308}
309
310fn claim_mentions_derivative(claim: &Claim) -> bool {
313 [claim.text.as_deref(), claim.title.as_deref()]
314 .into_iter()
315 .flatten()
316 .any(is_derivative)
317}
318
319pub fn id_agreement(claimed: &[WorkId], actual: &[WorkId]) -> Agreement {
322 if claimed.is_empty() {
323 return Agreement::Unknown;
324 }
325 if claimed
326 .iter()
327 .any(|c| actual.iter().any(|a| same_work_id(c, a)))
328 {
329 return Agreement::Match;
330 }
331 let shares_kind = claimed
332 .iter()
333 .any(|c| actual.iter().any(|a| a.kind() == c.kind()));
334 if shares_kind {
335 Agreement::Mismatch
336 } else {
337 Agreement::Unknown
338 }
339}
340
341pub fn same_work_id(a: &WorkId, b: &WorkId) -> bool {
343 match (a, b) {
344 (WorkId::Arxiv(x), WorkId::Arxiv(y)) => x.unversioned() == y.unversioned(),
345 _ => a == b,
346 }
347}
348
349pub fn normalise(s: &str) -> String {
351 let folded: String = s
352 .nfkd()
353 .filter(|c| !unicode_normalization::char::is_combining_mark(*c))
354 .flat_map(char::to_lowercase)
355 .map(|c| if c.is_alphanumeric() { c } else { ' ' })
356 .collect();
357 folded.split_whitespace().collect::<Vec<_>>().join(" ")
358}
359
360fn words(s: &str) -> Vec<String> {
361 normalise(s)
362 .split(' ')
363 .filter(|w| !w.is_empty())
364 .map(str::to_owned)
365 .collect()
366}
367
368pub fn unmarked(title: &str) -> &str {
373 let trimmed = title.trim_start();
374 for marker in ["retracted article", "retracted", "withdrawn"] {
375 if let Some(head) = trimmed.get(..marker.len())
376 && head.eq_ignore_ascii_case(marker)
377 {
378 let rest = &trimmed[marker.len()..];
379 if let Some(rest) = rest.trim_start().strip_prefix([':', '.', '-', '—']) {
380 return rest.trim_start();
381 }
382 }
383 }
384 title
385}
386
387pub fn title_similarity(a: &str, b: &str) -> f64 {
390 let (a, b) = (unmarked(a), unmarked(b));
392 [(a, b), (main_title(a), b), (a, main_title(b))]
393 .into_iter()
394 .map(|(x, y)| dice(&content_words(x), &content_words(y)))
395 .fold(0.0, f64::max)
396}
397
398fn dice(a: &[String], b: &[String]) -> f64 {
399 if a.is_empty() || b.is_empty() {
400 return 0.0;
401 }
402 let shared = a.iter().filter(|w| b.contains(w)).count();
403 2.0 * shared as f64 / (a.len() + b.len()) as f64
404}
405
406const STOPWORDS: &[&str] = &[
408 "a", "an", "the", "of", "in", "on", "for", "and", "or", "to", "with", "by", "at", "from", "is",
409 "are", "as", "via", "into", "its",
410];
411
412fn content_words(title: &str) -> Vec<String> {
414 let all = dedup(words(title));
415 let content: Vec<String> = all
416 .iter()
417 .filter(|w| !STOPWORDS.contains(&w.as_str()))
418 .cloned()
419 .collect();
420 if content.is_empty() { all } else { content }
421}
422
423fn main_title(title: &str) -> &str {
425 title
426 .find([':', '\u{2014}', '\u{2013}'])
427 .map_or(title, |i| title[..i].trim())
428}
429
430fn dedup(mut v: Vec<String>) -> Vec<String> {
431 v.sort_unstable();
432 v.dedup();
433 v
434}
435
436fn family_name(p: &Person) -> Option<String> {
439 let name = p.family.as_deref().or(p.literal.as_deref())?;
440 let last = if p.family.is_some() {
441 normalise(name)
442 } else {
443 words(name).pop()?
444 };
445 (!last.is_empty()).then_some(last)
446}
447
448fn claimed_family(name: &str) -> Option<String> {
451 let family = match name.split_once(',') {
452 Some((before, _)) => normalise(before),
453 None => words(name).into_iter().rfind(|w| w.chars().count() > 1)?,
455 };
456 (!family.is_empty()).then_some(family)
457}
458
459fn author_overlap(claimed: &[String], families: &[String]) -> f64 {
462 let claimed: Vec<String> = claimed.iter().filter_map(|n| claimed_family(n)).collect();
463 let Some(first) = claimed.first() else {
464 return 0.0;
465 };
466 let first_matches = families.first() == Some(first);
467 let found = claimed.iter().filter(|c| families.contains(c)).count();
468 0.5 * f64::from(u8::from(first_matches)) + 0.5 * found as f64 / claimed.len() as f64
469}
470
471struct Normalised {
473 padded: String,
475 words: Vec<String>,
476}
477
478impl Normalised {
479 fn new(text: &str) -> Self {
480 let norm = normalise(text);
481 Normalised {
482 words: norm.split(' ').map(str::to_owned).collect(),
483 padded: format!(" {norm} "),
484 }
485 }
486
487 fn title_coverage(&self, title: &str) -> f64 {
494 let title = unmarked(title);
495 let phrase = |t: &str| {
496 let norm = normalise(t);
497 !norm.is_empty() && self.padded.contains(&format!(" {norm} "))
498 };
499 if phrase(title) || (main_title(title) != title && phrase(main_title(title))) {
500 return 1.0;
501 }
502 let title_words = content_words(title);
503 if title_words.is_empty() {
504 return 0.0;
505 }
506 let present = title_words
507 .iter()
508 .filter(|w| self.words.contains(w))
509 .count();
510 let share = present as f64 / title_words.len() as f64;
511 if share < 2.0 / 3.0 {
512 share / 2.0
513 } else {
514 share
515 }
516 }
517
518 fn author_coverage(&self, families: &[String]) -> f64 {
523 let present = |f: &String| self.padded.contains(&format!(" {f} "));
524 let Some((first, rest)) = families.split_first() else {
525 return 0.0;
526 };
527 let rest = &rest[..rest.len().min(AUTHORS_COMPARED - 1)];
528 let rest_present = rest.iter().filter(|f| present(f)).count();
529 match (present(first), rest.len()) {
530 (true, 0) => 1.0,
531 (true, n) => 0.9 + 0.1 * rest_present as f64 / n as f64,
532 (false, 0) => 0.0,
534 (false, n) => 0.5 * rest_present as f64 / n as f64,
535 }
536 }
537
538 fn years(&self) -> Vec<i16> {
540 self.words
541 .iter()
542 .filter(|w| w.len() == 4)
543 .filter_map(|w| w.parse::<i16>().ok())
544 .filter(|y| (1600..=2100).contains(y))
545 .collect()
546 }
547}
548
549pub fn shared_title(candidate_title: &str, reference: &str) -> Option<String> {
557 let (title, text) = (words(unmarked(candidate_title)), words(reference));
558 let mut best = (0, 0); let mut prev = vec![0usize; text.len() + 1];
561 for (i, tw) in title.iter().enumerate() {
562 let mut cur = vec![0usize; text.len() + 1];
563 for (j, rw) in text.iter().enumerate() {
564 if tw == rw {
565 cur[j + 1] = prev[j] + 1;
566 if cur[j + 1] > best.0 {
567 best = (cur[j + 1], i + 1);
568 }
569 }
570 }
571 prev = cur;
572 }
573 let (len, end) = best;
574 if len == 0 || (len < 3 && len < title.len()) {
575 return None;
576 }
577 Some(title[end - len..end].join(" "))
578}
579
580pub fn search_terms(text: &str) -> String {
584 words(text)
585 .into_iter()
586 .filter(|w| w.chars().count() > 1)
587 .filter(|w| !w.chars().all(|c| c.is_ascii_digit()))
588 .filter(|w| !matches!(w.as_str(), "et" | "al" | "doi" | "vol" | "pp" | "no"))
589 .collect::<Vec<_>>()
590 .join(" ")
591}
592
593pub fn find_ids(text: &str) -> Vec<WorkId> {
597 let mut found: Vec<WorkId> = Vec::new();
598 let mut isbn_label = false;
601 for token in text.split(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '"' | '\'')) {
602 let token = token
603 .trim_start_matches(['(', '[', '{'])
604 .trim_end_matches(['.', ',', ';', ')', ']', '}']);
605 let lower = token.to_ascii_lowercase();
606 let labelled = std::mem::replace(
607 &mut isbn_label,
608 matches!(
609 lower.trim_end_matches(':'),
610 "isbn" | "isbn-13" | "isbn-10" | "isbn13" | "isbn10"
611 ),
612 );
613 let token = token.trim_end_matches(':');
614 let candidate = if labelled || lower.starts_with("isbn") {
615 WorkId::parse_as(crate::id::IdKind::Isbn, token).ok()
616 } else if let Some(at) = lower.find("10.") {
617 WorkId::parse_as(crate::id::IdKind::Doi, &token[at..]).ok()
619 } else if lower.starts_with("arxiv:") || lower.contains("arxiv.org/") {
620 token
621 .split_once(':')
622 .filter(|_| lower.starts_with("arxiv:"))
623 .map_or(token, |(_, rest)| rest)
624 .parse::<ArxivId>()
625 .ok()
626 .map(WorkId::Arxiv)
627 } else {
628 None
629 };
630 if let Some(id) = candidate
631 && !found.iter().any(|f| same_work_id(f, &id))
632 {
633 found.push(id);
634 }
635 }
636 found
637}
638
639#[cfg(test)]
640mod tests {
641 use jiff::Timestamp;
642
643 use super::*;
644 use crate::provider::ProviderId;
645 use crate::record::{FieldSet, Raw};
646 use crate::work::{PartialDate, Work};
647
648 fn record(ids: &[&str], title: &str, authors: &[&str], year: i16) -> SourceRecord {
649 let work = Work {
650 title: Some(title.into()),
651 authors: authors.iter().map(|a| Person::literal(*a)).collect(),
652 issued: Some(PartialDate::from_year(year)),
653 ..Work::default()
654 };
655 let raw = Raw {
656 media_type: "application/json".into(),
657 body: "{}".into(),
658 };
659 SourceRecord::new(
660 ProviderId::from_static("test"),
661 ids.iter().map(|i| i.parse().unwrap()).collect(),
662 work,
663 FieldSet::ALL,
664 raw,
665 Timestamp::UNIX_EPOCH,
666 )
667 }
668
669 const VASWANI: [&str; 3] = ["Ashish Vaswani", "Noam Shazeer", "Niki Parmar"];
670
671 fn attention(ids: &[&str], year: i16) -> SourceRecord {
672 record(ids, "Attention Is All You Need", &VASWANI, year)
673 }
674
675 #[test]
676 fn the_real_paper_verifies_and_the_lookalike_does_not() {
677 let claim = Claim::from_text("Vaswani 2017 Attention is all you need");
678 let real = assess(&claim, &attention(&["arXiv:1706.03762"], 2017));
679 let lookalike = assess(&claim, &attention(&["10.65215/r5bs2d54"], 2025));
680
681 assert!(real.confidence >= VERIFIED, "{real:?}");
682 assert_eq!(real.title.agreement, Agreement::Match);
683 assert_eq!(lookalike.year.agreement, Agreement::Mismatch);
684 assert!(lookalike.confidence < PLAUSIBLE, "{lookalike:?}");
685 }
686
687 #[test]
688 fn a_messy_reference_matches_its_work_not_its_namesakes() {
689 let claim = Claim::from_text(
690 "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-444.",
691 );
692 let authors = ["Yann LeCun", "Yoshua Bengio", "Geoffrey Hinton"];
693 let right = assess(
694 &claim,
695 &record(&["10.1038/nature14539"], "Deep learning", &authors, 2015),
696 );
697 let later = assess(
698 &claim,
699 &record(&["10.1145/3448250"], "Deep learning for AI", &authors, 2021),
700 );
701 assert!(right.confidence >= VERIFIED, "{right:?}");
702 assert!(later.confidence < PLAUSIBLE, "{later:?}");
703 }
704
705 #[test]
706 fn structured_claims_compare_field_by_field() {
707 let mut claim = Claim {
708 title: Some("Attention is all you need".into()),
709 authors: vec!["Vaswani, A.".into(), "N. Shazeer".into()],
710 year: Some(2017),
711 ..Claim::default()
712 };
713 let a = assess(&claim, &attention(&[], 2017));
714 assert_eq!(a.authors.agreement, Agreement::Match);
715 assert!(a.confidence >= VERIFIED);
716
717 claim.year = Some(2018);
718 let off_by_one = assess(&claim, &attention(&[], 2017));
719 assert_eq!(off_by_one.year.agreement, Agreement::Partial);
720 assert!(off_by_one.confidence >= PLAUSIBLE);
721 }
722
723 #[test]
724 fn first_author_alone_matches_but_a_missing_first_author_does_not() {
725 let families = [
726 "vaswani".to_owned(),
727 "shazeer".to_owned(),
728 "parmar".to_owned(),
729 ];
730 let etal = Normalised::new("Vaswani et al. Attention is all you need");
731 let others = Normalised::new("Shazeer N, Parmar N. Attention is all you need");
732 assert_eq!(
733 FieldCheck::scored(etal.author_coverage(&families)).agreement,
734 Agreement::Match
735 );
736 assert_eq!(
737 FieldCheck::scored(others.author_coverage(&families)).agreement,
738 Agreement::Partial
739 );
740 let sole = ["anfinsen".to_owned()];
741 assert_eq!(
742 etal.author_coverage(&sole),
743 0.0,
744 "an unmentioned sole author"
745 );
746 }
747
748 #[test]
749 fn any_edition_year_counts_for_books() {
750 let mut first_edition = record(
751 &[],
752 "Reinforcement Learning: An Introduction",
753 &["Richard S. Sutton", "Andrew G. Barto"],
754 1998,
755 );
756 let claim = Claim::from_text(
757 "Sutton RS, Barto AG. Reinforcement Learning: An Introduction. 2nd ed. MIT Press; 2018.",
758 );
759 assert_eq!(
760 assess(&claim, &first_edition).year.agreement,
761 Agreement::Mismatch
762 );
763 first_edition.work.other_years = vec![2018];
764 let a = assess(&claim, &first_edition);
765 assert_eq!(a.year.agreement, Agreement::Match);
766 assert!(a.confidence >= VERIFIED, "{a:?}");
767 }
768
769 #[test]
770 fn different_authors_are_penalised() {
771 let claim =
772 Claim::from_text("Sutton RS, Barto AG. Reinforcement Learning: An Introduction. 2018.");
773 let other = record(
774 &[],
775 "An Introduction to Deep Reinforcement Learning",
776 &["Vincent Francois-Lavet", "Peter Henderson"],
777 2018,
778 );
779 let a = assess(&claim, &other);
780 assert_eq!(a.authors.agreement, Agreement::Mismatch);
781 assert!(a.confidence < PLAUSIBLE, "{a:?}");
782 }
783
784 #[test]
785 fn nearer_years_score_higher_even_when_both_are_wrong() {
786 let claim =
787 Claim::from_text("Vaswani A, Shazeer N. Attention is all you need. NeurIPS 2019.");
788 let real = assess(&claim, &attention(&[], 2017));
789 let lookalike = assess(&claim, &attention(&[], 2025));
790 assert_eq!(real.year.agreement, Agreement::Mismatch);
791 assert!(real.confidence > lookalike.confidence);
792 }
793
794 #[test]
795 fn a_title_alone_cannot_verify() {
796 let claim = Claim {
797 title: Some("Deep learning".into()),
798 ..Claim::default()
799 };
800 let a = assess(&claim, &record(&[], "Deep learning", &[], 2015));
801 assert_eq!(a.title.agreement, Agreement::Match);
802 assert!(a.confidence <= SINGLE_FIELD_CAP);
803 }
804
805 #[test]
806 fn accents_and_punctuation_do_not_matter() {
807 assert_eq!(
808 normalise("Schölkopf, B. — Kernel‐Methods!"),
809 "scholkopf b kernel methods"
810 );
811 assert_eq!(
812 title_similarity("Attention Is All You Need", "attention is all you need."),
813 1.0
814 );
815 let partial = title_similarity(
816 "Attention Is All You Need",
817 "Attention Is All You Need In Speech Separation",
818 );
819 assert!((0.5..0.9).contains(&partial), "{partial}");
820 }
821
822 #[test]
823 fn claimed_ids_are_checked_against_the_record() {
824 let claim = Claim::from_text("Deep learning. Nature 2015. doi:10.1038/nature14539.");
825 assert_eq!(
826 claim.ids,
827 [WorkId::parse_as(crate::IdKind::Doi, "10.1038/nature14539").unwrap()]
828 );
829 let right = assess(
830 &claim,
831 &record(&["10.1038/nature14539"], "Deep learning", &[], 2015),
832 );
833 let wrong = assess(
834 &claim,
835 &record(&["10.1145/3448250"], "Deep learning for AI", &[], 2021),
836 );
837 assert_eq!(right.ids, Agreement::Match);
838 assert_eq!(wrong.ids, Agreement::Mismatch);
839 }
840
841 #[test]
842 fn input_is_an_identifier_or_a_reference() {
843 let id_only = Claim::from_input(" 10.1038/nature14539 ");
844 assert!(id_only.text.is_none() && id_only.ids.len() == 1);
845 let reference = Claim::from_input("Deep learning. Nature 2015. doi:10.1038/nature14539");
846 assert!(reference.text.is_some() && reference.ids.len() == 1);
847 }
848
849 #[test]
850 fn shared_title_finds_the_cited_title_inside_a_derivative_one() {
851 let reference = "Jumper J, et al. Highly accurate protein structure prediction with AlphaFold. Nature. 2021.";
852 assert_eq!(
853 shared_title(
854 "Faculty Opinions recommendation of Highly accurate protein structure prediction with AlphaFold.",
855 reference
856 )
857 .as_deref(),
858 Some("highly accurate protein structure prediction with alphafold")
859 );
860 assert_eq!(
861 shared_title("Deep learning", "LeCun Y. Deep learning. Nature 2015").as_deref(),
862 Some("deep learning"),
863 "a short title counts when whole"
864 );
865 assert_eq!(
866 shared_title("Deep learning for AI", "Smith J. On learning. 2020"),
867 None
868 );
869 }
870
871 #[test]
872 fn retraction_markers_do_not_hide_a_title_match() {
873 assert_eq!(
874 unmarked("RETRACTED: Ileal-lymphoid hyperplasia"),
875 "Ileal-lymphoid hyperplasia"
876 );
877 assert_eq!(unmarked("Retracted Article - Something"), "Something");
878 assert_eq!(unmarked("Retraction: Something"), "Retraction: Something");
879 assert_eq!(
880 unmarked("Retracted proteins in yeast"),
881 "Retracted proteins in yeast"
882 );
883 assert_eq!(
884 title_similarity(
885 "RETRACTED: Ileal-lymphoid hyperplasia",
886 "Ileal-lymphoid hyperplasia"
887 ),
888 1.0
889 );
890 }
891
892 #[test]
893 fn title_and_year_alone_cannot_verify() {
894 let claim = Claim::from_text(
896 "3CPO (NEJM 2008; Health Technol Assess 2009). New England Journal of Medicine; Health Technology Assessment 2009 2008",
897 );
898 let a = assess(
899 &claim,
900 &record(&[], "Health technology assessment", &[], 2009),
901 );
902 assert_eq!(a.title.agreement, Agreement::Match);
903 assert!(a.confidence < VERIFIED, "{a:?}");
904
905 let claim = Claim {
907 text: Some(
908 "Fibrinolysis for patients with intermediate-risk pulmonary embolism".into(),
909 ),
910 authors: vec!["Meyer".into()],
911 year: Some(2014),
912 ..Claim::default()
913 };
914 let letter = assess(
915 &claim,
916 &record(
917 &[],
918 "Fibrinolysis for Intermediate-Risk Pulmonary Embolism",
919 &[],
920 2014,
921 ),
922 );
923 let article = assess(
924 &claim,
925 &record(
926 &[],
927 "Fibrinolysis for Patients with Intermediate-Risk Pulmonary Embolism",
928 &["Guy Meyer"],
929 2014,
930 ),
931 );
932 assert!(letter.confidence < VERIFIED && article.confidence >= VERIFIED);
933 }
934
935 #[test]
936 fn replies_and_corrections_are_not_the_original() {
937 assert!(is_derivative(
938 "Age-Adjusted D-Dimer Cutoff Levels and Pulmonary Embolism—Reply"
939 ));
940 assert!(is_derivative("Correction to: Attention is all you need"));
941 assert!(is_derivative(
942 "Comment on “Fibrinolysis for patients with intermediate-risk PE”"
943 ));
944 assert!(!is_derivative(
945 "Age-Adjusted D-Dimer Cutoff Levels to Rule Out Pulmonary Embolism"
946 ));
947
948 let claim = Claim {
949 title: Some("Age-adjusted D-dimer cutoff levels to rule out pulmonary embolism".into()),
950 authors: vec!["Righini".into()],
951 year: Some(2014),
952 ..Claim::default()
953 };
954 let reply = assess(
955 &claim,
956 &record(
957 &[],
958 "Age-Adjusted D-Dimer Cutoff Levels and Pulmonary Embolism—Reply",
959 &["Marc Righini"],
960 2014,
961 ),
962 );
963 assert!(reply.confidence < PLAUSIBLE, "{reply:?}");
964 let citing_reply = Claim {
966 title: Some("Age-adjusted D-dimer cutoff levels and pulmonary embolism—reply".into()),
967 ..claim
968 };
969 let a = assess(
970 &citing_reply,
971 &record(
972 &[],
973 "Age-Adjusted D-Dimer Cutoff Levels and Pulmonary Embolism—Reply",
974 &["Marc Righini"],
975 2014,
976 ),
977 );
978 assert!(a.confidence >= VERIFIED, "{a:?}");
979 }
980
981 #[test]
982 fn a_few_shared_topic_words_are_not_a_title_match() {
983 let claim = Claim::from_text("Smith 2019 Ketamine reduces mortality in septic shock");
985 let a = assess(
986 &claim,
987 &record(
988 &[],
989 "Leukotrienes in Endotoxic and Septic Shock",
990 &["J Smith"],
991 2019,
992 ),
993 );
994 assert_eq!(a.title.agreement, Agreement::Mismatch);
995 assert!(a.confidence < PLAUSIBLE, "{a:?}");
996 }
997
998 #[test]
999 fn dropped_subtitles_still_match() {
1000 let structured = title_similarity(
1001 "Age-adjusted D-dimer cutoff levels to rule out pulmonary embolism",
1002 "Age-Adjusted D-Dimer Cutoff Levels to Rule Out Pulmonary Embolism: The ADJUST-PE Study",
1003 );
1004 assert_eq!(structured, 1.0);
1005 let claim = Claim::from_text(
1006 "Righini M et al. Age-adjusted D-dimer cutoff levels to rule out pulmonary embolism. JAMA 2014",
1007 );
1008 let a = assess(
1009 &claim,
1010 &record(
1011 &[],
1012 "Age-Adjusted D-Dimer Cutoff Levels to Rule Out Pulmonary Embolism: The ADJUST-PE Study",
1013 &["Marc Righini"],
1014 2014,
1015 ),
1016 );
1017 assert_eq!(a.title.agreement, Agreement::Match);
1018 }
1019
1020 #[test]
1021 fn search_terms_drop_noise() {
1022 assert_eq!(
1023 search_terms("LeCun Y, Bengio Y, et al. Deep learning. Nature. 2015;521:436-444."),
1024 "lecun bengio deep learning nature"
1025 );
1026 }
1027
1028 #[test]
1029 fn finds_ids_in_running_text() {
1030 let text = "See (https://doi.org/10.1145/3368089.3409741), arXiv:1706.03762v5 and \
1031 https://arxiv.org/abs/hep-th/9901001; also arXiv:1706.03762.";
1032 let ids: Vec<String> = find_ids(text).iter().map(ToString::to_string).collect();
1033 assert_eq!(
1034 ids,
1035 [
1036 "doi:10.1145/3368089.3409741",
1037 "arxiv:1706.03762v5",
1038 "arxiv:hep-th/9901001"
1039 ]
1040 );
1041 let book = "Goodfellow I, et al. Deep Learning. MIT Press; 2016. ISBN 978-0-262-03561-3. Also ISBN-10: 0262035618.";
1042 let ids: Vec<String> = find_ids(book).iter().map(ToString::to_string).collect();
1043 assert_eq!(ids, ["isbn:9780262035613"], "both forms are one book");
1044 assert!(
1045 find_ids("pages 9780262035613 and 2016").is_empty(),
1046 "unlabelled digits ignored"
1047 );
1048 assert!(find_ids("Published in 2017, pages 10-20.").is_empty());
1049 }
1050}