1use serde::{Deserialize, Serialize};
19use unicode_normalization::UnicodeNormalization;
20
21use crate::id::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> {
595 let mut found: Vec<WorkId> = Vec::new();
596 for mention in crate::id::scan(text) {
597 if !found.iter().any(|f| same_work_id(f, &mention.id)) {
598 found.push(mention.id);
599 }
600 }
601 found
602}
603
604#[cfg(test)]
605mod tests {
606 use jiff::Timestamp;
607
608 use super::*;
609 use crate::provider::ProviderId;
610 use crate::record::{FieldSet, Raw};
611 use crate::work::{PartialDate, Work};
612
613 fn record(ids: &[&str], title: &str, authors: &[&str], year: i16) -> SourceRecord {
614 let work = Work {
615 title: Some(title.into()),
616 authors: authors.iter().map(|a| Person::literal(*a)).collect(),
617 issued: Some(PartialDate::from_year(year)),
618 ..Work::default()
619 };
620 let raw = Raw {
621 media_type: "application/json".into(),
622 body: "{}".into(),
623 };
624 SourceRecord::new(
625 ProviderId::from_static("test"),
626 ids.iter().map(|i| i.parse().unwrap()).collect(),
627 work,
628 FieldSet::ALL,
629 raw,
630 Timestamp::UNIX_EPOCH,
631 )
632 }
633
634 const VASWANI: [&str; 3] = ["Ashish Vaswani", "Noam Shazeer", "Niki Parmar"];
635
636 fn attention(ids: &[&str], year: i16) -> SourceRecord {
637 record(ids, "Attention Is All You Need", &VASWANI, year)
638 }
639
640 #[test]
641 fn the_real_paper_verifies_and_the_lookalike_does_not() {
642 let claim = Claim::from_text("Vaswani 2017 Attention is all you need");
643 let real = assess(&claim, &attention(&["arXiv:1706.03762"], 2017));
644 let lookalike = assess(&claim, &attention(&["10.65215/r5bs2d54"], 2025));
645
646 assert!(real.confidence >= VERIFIED, "{real:?}");
647 assert_eq!(real.title.agreement, Agreement::Match);
648 assert_eq!(lookalike.year.agreement, Agreement::Mismatch);
649 assert!(lookalike.confidence < PLAUSIBLE, "{lookalike:?}");
650 }
651
652 #[test]
653 fn a_messy_reference_matches_its_work_not_its_namesakes() {
654 let claim = Claim::from_text(
655 "LeCun Y, Bengio Y, Hinton G. Deep learning. Nature. 2015;521:436-444.",
656 );
657 let authors = ["Yann LeCun", "Yoshua Bengio", "Geoffrey Hinton"];
658 let right = assess(
659 &claim,
660 &record(&["10.1038/nature14539"], "Deep learning", &authors, 2015),
661 );
662 let later = assess(
663 &claim,
664 &record(&["10.1145/3448250"], "Deep learning for AI", &authors, 2021),
665 );
666 assert!(right.confidence >= VERIFIED, "{right:?}");
667 assert!(later.confidence < PLAUSIBLE, "{later:?}");
668 }
669
670 #[test]
671 fn structured_claims_compare_field_by_field() {
672 let mut claim = Claim {
673 title: Some("Attention is all you need".into()),
674 authors: vec!["Vaswani, A.".into(), "N. Shazeer".into()],
675 year: Some(2017),
676 ..Claim::default()
677 };
678 let a = assess(&claim, &attention(&[], 2017));
679 assert_eq!(a.authors.agreement, Agreement::Match);
680 assert!(a.confidence >= VERIFIED);
681
682 claim.year = Some(2018);
683 let off_by_one = assess(&claim, &attention(&[], 2017));
684 assert_eq!(off_by_one.year.agreement, Agreement::Partial);
685 assert!(off_by_one.confidence >= PLAUSIBLE);
686 }
687
688 #[test]
689 fn first_author_alone_matches_but_a_missing_first_author_does_not() {
690 let families = [
691 "vaswani".to_owned(),
692 "shazeer".to_owned(),
693 "parmar".to_owned(),
694 ];
695 let etal = Normalised::new("Vaswani et al. Attention is all you need");
696 let others = Normalised::new("Shazeer N, Parmar N. Attention is all you need");
697 assert_eq!(
698 FieldCheck::scored(etal.author_coverage(&families)).agreement,
699 Agreement::Match
700 );
701 assert_eq!(
702 FieldCheck::scored(others.author_coverage(&families)).agreement,
703 Agreement::Partial
704 );
705 let sole = ["anfinsen".to_owned()];
706 assert_eq!(
707 etal.author_coverage(&sole),
708 0.0,
709 "an unmentioned sole author"
710 );
711 }
712
713 #[test]
714 fn any_edition_year_counts_for_books() {
715 let mut first_edition = record(
716 &[],
717 "Reinforcement Learning: An Introduction",
718 &["Richard S. Sutton", "Andrew G. Barto"],
719 1998,
720 );
721 let claim = Claim::from_text(
722 "Sutton RS, Barto AG. Reinforcement Learning: An Introduction. 2nd ed. MIT Press; 2018.",
723 );
724 assert_eq!(
725 assess(&claim, &first_edition).year.agreement,
726 Agreement::Mismatch
727 );
728 first_edition.work.other_years = vec![2018];
729 let a = assess(&claim, &first_edition);
730 assert_eq!(a.year.agreement, Agreement::Match);
731 assert!(a.confidence >= VERIFIED, "{a:?}");
732 }
733
734 #[test]
735 fn different_authors_are_penalised() {
736 let claim =
737 Claim::from_text("Sutton RS, Barto AG. Reinforcement Learning: An Introduction. 2018.");
738 let other = record(
739 &[],
740 "An Introduction to Deep Reinforcement Learning",
741 &["Vincent Francois-Lavet", "Peter Henderson"],
742 2018,
743 );
744 let a = assess(&claim, &other);
745 assert_eq!(a.authors.agreement, Agreement::Mismatch);
746 assert!(a.confidence < PLAUSIBLE, "{a:?}");
747 }
748
749 #[test]
750 fn nearer_years_score_higher_even_when_both_are_wrong() {
751 let claim =
752 Claim::from_text("Vaswani A, Shazeer N. Attention is all you need. NeurIPS 2019.");
753 let real = assess(&claim, &attention(&[], 2017));
754 let lookalike = assess(&claim, &attention(&[], 2025));
755 assert_eq!(real.year.agreement, Agreement::Mismatch);
756 assert!(real.confidence > lookalike.confidence);
757 }
758
759 #[test]
760 fn a_title_alone_cannot_verify() {
761 let claim = Claim {
762 title: Some("Deep learning".into()),
763 ..Claim::default()
764 };
765 let a = assess(&claim, &record(&[], "Deep learning", &[], 2015));
766 assert_eq!(a.title.agreement, Agreement::Match);
767 assert!(a.confidence <= SINGLE_FIELD_CAP);
768 }
769
770 #[test]
771 fn accents_and_punctuation_do_not_matter() {
772 assert_eq!(
773 normalise("Schölkopf, B. — Kernel‐Methods!"),
774 "scholkopf b kernel methods"
775 );
776 assert_eq!(
777 title_similarity("Attention Is All You Need", "attention is all you need."),
778 1.0
779 );
780 let partial = title_similarity(
781 "Attention Is All You Need",
782 "Attention Is All You Need In Speech Separation",
783 );
784 assert!((0.5..0.9).contains(&partial), "{partial}");
785 }
786
787 #[test]
788 fn claimed_ids_are_checked_against_the_record() {
789 let claim = Claim::from_text("Deep learning. Nature 2015. doi:10.1038/nature14539.");
790 assert_eq!(
791 claim.ids,
792 [WorkId::parse_as(crate::IdKind::Doi, "10.1038/nature14539").unwrap()]
793 );
794 let right = assess(
795 &claim,
796 &record(&["10.1038/nature14539"], "Deep learning", &[], 2015),
797 );
798 let wrong = assess(
799 &claim,
800 &record(&["10.1145/3448250"], "Deep learning for AI", &[], 2021),
801 );
802 assert_eq!(right.ids, Agreement::Match);
803 assert_eq!(wrong.ids, Agreement::Mismatch);
804 }
805
806 #[test]
807 fn input_is_an_identifier_or_a_reference() {
808 let id_only = Claim::from_input(" 10.1038/nature14539 ");
809 assert!(id_only.text.is_none() && id_only.ids.len() == 1);
810 let reference = Claim::from_input("Deep learning. Nature 2015. doi:10.1038/nature14539");
811 assert!(reference.text.is_some() && reference.ids.len() == 1);
812 }
813
814 #[test]
815 fn shared_title_finds_the_cited_title_inside_a_derivative_one() {
816 let reference = "Jumper J, et al. Highly accurate protein structure prediction with AlphaFold. Nature. 2021.";
817 assert_eq!(
818 shared_title(
819 "Faculty Opinions recommendation of Highly accurate protein structure prediction with AlphaFold.",
820 reference
821 )
822 .as_deref(),
823 Some("highly accurate protein structure prediction with alphafold")
824 );
825 assert_eq!(
826 shared_title("Deep learning", "LeCun Y. Deep learning. Nature 2015").as_deref(),
827 Some("deep learning"),
828 "a short title counts when whole"
829 );
830 assert_eq!(
831 shared_title("Deep learning for AI", "Smith J. On learning. 2020"),
832 None
833 );
834 }
835
836 #[test]
837 fn retraction_markers_do_not_hide_a_title_match() {
838 assert_eq!(
839 unmarked("RETRACTED: Ileal-lymphoid hyperplasia"),
840 "Ileal-lymphoid hyperplasia"
841 );
842 assert_eq!(unmarked("Retracted Article - Something"), "Something");
843 assert_eq!(unmarked("Retraction: Something"), "Retraction: Something");
844 assert_eq!(
845 unmarked("Retracted proteins in yeast"),
846 "Retracted proteins in yeast"
847 );
848 assert_eq!(
849 title_similarity(
850 "RETRACTED: Ileal-lymphoid hyperplasia",
851 "Ileal-lymphoid hyperplasia"
852 ),
853 1.0
854 );
855 }
856
857 #[test]
858 fn title_and_year_alone_cannot_verify() {
859 let claim = Claim::from_text(
861 "3CPO (NEJM 2008; Health Technol Assess 2009). New England Journal of Medicine; Health Technology Assessment 2009 2008",
862 );
863 let a = assess(
864 &claim,
865 &record(&[], "Health technology assessment", &[], 2009),
866 );
867 assert_eq!(a.title.agreement, Agreement::Match);
868 assert!(a.confidence < VERIFIED, "{a:?}");
869
870 let claim = Claim {
872 text: Some(
873 "Fibrinolysis for patients with intermediate-risk pulmonary embolism".into(),
874 ),
875 authors: vec!["Meyer".into()],
876 year: Some(2014),
877 ..Claim::default()
878 };
879 let letter = assess(
880 &claim,
881 &record(
882 &[],
883 "Fibrinolysis for Intermediate-Risk Pulmonary Embolism",
884 &[],
885 2014,
886 ),
887 );
888 let article = assess(
889 &claim,
890 &record(
891 &[],
892 "Fibrinolysis for Patients with Intermediate-Risk Pulmonary Embolism",
893 &["Guy Meyer"],
894 2014,
895 ),
896 );
897 assert!(letter.confidence < VERIFIED && article.confidence >= VERIFIED);
898 }
899
900 #[test]
901 fn replies_and_corrections_are_not_the_original() {
902 assert!(is_derivative(
903 "Age-Adjusted D-Dimer Cutoff Levels and Pulmonary Embolism—Reply"
904 ));
905 assert!(is_derivative("Correction to: Attention is all you need"));
906 assert!(is_derivative(
907 "Comment on “Fibrinolysis for patients with intermediate-risk PE”"
908 ));
909 assert!(!is_derivative(
910 "Age-Adjusted D-Dimer Cutoff Levels to Rule Out Pulmonary Embolism"
911 ));
912
913 let claim = Claim {
914 title: Some("Age-adjusted D-dimer cutoff levels to rule out pulmonary embolism".into()),
915 authors: vec!["Righini".into()],
916 year: Some(2014),
917 ..Claim::default()
918 };
919 let reply = assess(
920 &claim,
921 &record(
922 &[],
923 "Age-Adjusted D-Dimer Cutoff Levels and Pulmonary Embolism—Reply",
924 &["Marc Righini"],
925 2014,
926 ),
927 );
928 assert!(reply.confidence < PLAUSIBLE, "{reply:?}");
929 let citing_reply = Claim {
931 title: Some("Age-adjusted D-dimer cutoff levels and pulmonary embolism—reply".into()),
932 ..claim
933 };
934 let a = assess(
935 &citing_reply,
936 &record(
937 &[],
938 "Age-Adjusted D-Dimer Cutoff Levels and Pulmonary Embolism—Reply",
939 &["Marc Righini"],
940 2014,
941 ),
942 );
943 assert!(a.confidence >= VERIFIED, "{a:?}");
944 }
945
946 #[test]
947 fn a_few_shared_topic_words_are_not_a_title_match() {
948 let claim = Claim::from_text("Smith 2019 Ketamine reduces mortality in septic shock");
950 let a = assess(
951 &claim,
952 &record(
953 &[],
954 "Leukotrienes in Endotoxic and Septic Shock",
955 &["J Smith"],
956 2019,
957 ),
958 );
959 assert_eq!(a.title.agreement, Agreement::Mismatch);
960 assert!(a.confidence < PLAUSIBLE, "{a:?}");
961 }
962
963 #[test]
964 fn dropped_subtitles_still_match() {
965 let structured = title_similarity(
966 "Age-adjusted D-dimer cutoff levels to rule out pulmonary embolism",
967 "Age-Adjusted D-Dimer Cutoff Levels to Rule Out Pulmonary Embolism: The ADJUST-PE Study",
968 );
969 assert_eq!(structured, 1.0);
970 let claim = Claim::from_text(
971 "Righini M et al. Age-adjusted D-dimer cutoff levels to rule out pulmonary embolism. JAMA 2014",
972 );
973 let a = assess(
974 &claim,
975 &record(
976 &[],
977 "Age-Adjusted D-Dimer Cutoff Levels to Rule Out Pulmonary Embolism: The ADJUST-PE Study",
978 &["Marc Righini"],
979 2014,
980 ),
981 );
982 assert_eq!(a.title.agreement, Agreement::Match);
983 }
984
985 #[test]
986 fn search_terms_drop_noise() {
987 assert_eq!(
988 search_terms("LeCun Y, Bengio Y, et al. Deep learning. Nature. 2015;521:436-444."),
989 "lecun bengio deep learning nature"
990 );
991 }
992
993 #[test]
994 fn finds_ids_in_running_text() {
995 let text = "See (https://doi.org/10.1145/3368089.3409741), arXiv:1706.03762v5 and \
996 https://arxiv.org/abs/hep-th/9901001; also arXiv:1706.03762.";
997 let ids: Vec<String> = find_ids(text).iter().map(ToString::to_string).collect();
998 assert_eq!(
999 ids,
1000 [
1001 "doi:10.1145/3368089.3409741",
1002 "arxiv:1706.03762v5",
1003 "arxiv:hep-th/9901001"
1004 ]
1005 );
1006 let book = "Goodfellow I, et al. Deep Learning. MIT Press; 2016. ISBN 978-0-262-03561-3. Also ISBN-10: 0262035618.";
1007 let ids: Vec<String> = find_ids(book).iter().map(ToString::to_string).collect();
1008 assert_eq!(ids, ["isbn:9780262035613"], "both forms are one book");
1009 assert!(
1010 find_ids("pages 9780262035613 and 2016").is_empty(),
1011 "unlabelled digits ignored"
1012 );
1013 assert!(find_ids("Published in 2017, pages 10-20.").is_empty());
1014 }
1015}