Skip to main content

aspero_core/
matching.rs

1//! Does a record match what a citation claims? Pure scoring, no I/O.
2//!
3//! A [`Claim`] is what someone (often an agent) asserted: a free-text
4//! reference, structured fields, identifiers, or a mix. [`assess`] compares
5//! one candidate record against it, field by field, and combines the fields
6//! into a confidence in `0.0..=1.0`.
7//!
8//! Lessons from real data shape the rules (see DESIGN §9):
9//! - **Lookalikes exist**: re-deposits with the real title, authors and
10//!   abstract under a different year and publisher. A wrong year is a strong
11//!   penalty, never a detail.
12//! - **One field is not proof**: a title alone ("Deep learning") matches
13//!   many works, so confidence is capped unless at least two fields agree.
14//! - Free-text references are not parsed into fields. Instead the record's
15//!   fields are looked for *in* the text: its title as a phrase, its authors'
16//!   family names, its year. This is robust to citation styles.
17
18use serde::{Deserialize, Serialize};
19use unicode_normalization::UnicodeNormalization;
20
21use crate::id::WorkId;
22use crate::record::SourceRecord;
23use crate::work::Person;
24
25/// Field weights in the confidence (before penalties).
26const W_TITLE: f64 = 0.5;
27const W_AUTHORS: f64 = 0.25;
28const W_YEAR: f64 = 0.25;
29
30/// Multiplier when the year is off by two or more.
31const YEAR_MISMATCH_PENALTY: f64 = 0.5;
32/// Multiplier when the title clearly differs.
33const TITLE_MISMATCH_PENALTY: f64 = 0.5;
34/// Multiplier when the authors clearly differ.
35const AUTHORS_MISMATCH_PENALTY: f64 = 0.5;
36/// Confidence ceiling when fewer than two fields could be compared.
37const SINGLE_FIELD_CAP: f64 = 0.7;
38/// Confidence ceiling without corroborating authors or identifiers.
39const UNCORROBORATED_CAP: f64 = 0.8;
40/// Author score that corroborates a match: the first author present
41/// (0.75 for structured claims, 0.9 in free text).
42const CORROBORATING_AUTHORS: f64 = 0.75;
43/// Multiplier for replies, corrections, errata and commentaries.
44const DERIVATIVE_PENALTY: f64 = 0.5;
45
46/// Verdict thresholds on confidence.
47pub const VERIFIED: f64 = 0.85;
48pub const PLAUSIBLE: f64 = 0.6;
49
50/// Authors compared from the start of a byline.
51const AUTHORS_COMPARED: usize = 3;
52
53/// What a citation asserts. Any part may be missing.
54#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
55#[non_exhaustive]
56pub struct Claim {
57    /// A free-text reference, in any style.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub text: Option<String>,
60    /// Identifiers asserted for the work.
61    #[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    /// Names as written: "Ashish Vaswani", "Vaswani, A.", "A. Vaswani".
66    #[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    /// A claim from a free-text reference, with any DOIs and arXiv IDs it
74    /// mentions extracted as asserted identifiers.
75    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    /// A claim from whatever a user typed: if the whole input is an
85    /// identifier, a claim of just that identifier (an existence check);
86    /// otherwise a free-text reference (see [`Claim::from_text`]).
87    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    /// A reference string for citation matchers: the text, or one composed
106    /// from the fields.
107    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    /// The claim or the record doesn't say.
130    Unknown,
131}
132
133/// One field's comparison: a score in `0.0..=1.0` when comparable.
134#[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    /// Whether the claim's identifiers are the record's: `Match` if any
168    /// claimed ID is the record's, `Mismatch` if the record has a different
169    /// ID of the same kind, else `Unknown`.
170    pub ids: Agreement,
171}
172
173/// Scores how well `record` matches `claim`.
174pub 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            // Nearest pairing: any edition's year may be the one cited.
209            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            // Halves per year off: off by one is common (online-first vs
215            // print, preprint vs published) and counts as partial; two or
216            // more is a mismatch, but a nearer year still ranks higher.
217            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    // A claim of identifiers alone is fully confirmed by a matching ID.
224    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    // Title and year alone can't verify: a short or generic title ("Health
231    // technology assessment") turns up inside many references, and letters
232    // and corrections often carry the original's title without authors.
233    // Corroboration means the first author matches, or a claimed ID does.
234    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    // A reply, correction or commentary is not the work it responds to,
240    // unless the citation says it is citing one.
241    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    // Different authors usually mean a different work (a similarly titled
275    // book or survey), even when title and year agree.
276    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
285/// Words marking a record as *about* another work rather than the work
286/// itself: replies, corrections, errata, commentaries.
287const 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
301/// Whether a title marks a derivative record: "…—Reply", "Correction to:
302/// …", "Comment on “…”", "Faculty Opinions recommendation of …".
303pub 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
310/// Whether the claim itself cites a derivative (its text or title uses a
311/// marker), in which case derivatives are fair matches.
312fn 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
319/// Whether claimed identifiers are among a work's identifiers (see
320/// [`Assessment::ids`]).
321pub 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
341/// Identifier equality at the level of works: arXiv versions are the same work.
342pub 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
349/// Lowercase ASCII-folded words: accents dropped, punctuation as spaces.
350pub 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
368/// A title without a leading status marker publishers add to retracted or
369/// withdrawn works ("RETRACTED: Ileal-lymphoid…", "RETRACTED ARTICLE: …"),
370/// so a citation of the original title still matches. Notices themselves
371/// ("Retraction: …") are left alone.
372pub 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
387/// Dice coefficient over the two titles' word sets: 1.0 for the same
388/// words, lower as either title gains words the other lacks.
389pub fn title_similarity(a: &str, b: &str) -> f64 {
390    // Citations often drop a subtitle: compare main titles too.
391    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
406/// Words too common to count as evidence that two titles agree.
407const 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
412/// A title's distinct words, without stopwords (unless that leaves none).
413fn 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
423/// The title before a subtitle separator (":" or a dash), if there is one.
424fn 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
436/// A person's family name, normalised: the `family` field, or the last
437/// word of a literal name.
438fn 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
448/// The family name in a claimed author string: "Vaswani, A." → "vaswani",
449/// "Ashish Vaswani" → "vaswani", "A. Vaswani" → "vaswani".
450fn claimed_family(name: &str) -> Option<String> {
451    let family = match name.split_once(',') {
452        Some((before, _)) => normalise(before),
453        // The last word that isn't an initial.
454        None => words(name).into_iter().rfind(|w| w.chars().count() > 1)?,
455    };
456    (!family.is_empty()).then_some(family)
457}
458
459/// Claimed authors vs the record's family names: half for the first author
460/// matching, half for the share of claimed authors found anywhere.
461fn 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
471/// A reference string prepared for "is X mentioned?" questions.
472struct Normalised {
473    /// Space-padded, so phrase matches respect word boundaries.
474    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    /// 1.0 if the title appears as a phrase; otherwise the share of its
488    /// words present.
489    /// 1.0 if the title (or its main title, without subtitle) appears as a
490    /// phrase; otherwise the share of its content words present. Under two
491    /// thirds present is scored as a mismatch: a couple of topic words
492    /// ("septic shock") shared with a reference are not a title match.
493    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    /// Citation styles routinely list only the first author or two ("Vaswani
519    /// et al."), so the first author's family name appearing is a match on
520    /// its own (0.9), with the next authors a small bonus. Without the first
521    /// author, later names count for at most half.
522    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            // A sole author who isn't mentioned is no match at all.
533            (false, 0) => 0.0,
534            (false, n) => 0.5 * rest_present as f64 / n as f64,
535        }
536    }
537
538    /// Four-digit numbers that look like publication years.
539    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
549/// The longest run of words a candidate's title shares with a reference,
550/// as a search query for the title the reference actually cites. Uses the
551/// candidate as a guide to find the title inside the reference without
552/// parsing citation styles: for a re-deposit it is the whole title; for
553/// "Faculty Opinions recommendation of *Highly accurate … AlphaFold*" it is
554/// just the cited title. `None` if the run is too short to be a title (under
555/// three words, unless it is the candidate's whole title).
556pub fn shared_title(candidate_title: &str, reference: &str) -> Option<String> {
557    let (title, text) = (words(unmarked(candidate_title)), words(reference));
558    // Longest common substring over words; references are short.
559    let mut best = (0, 0); // (length, end index in title)
560    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
580/// Keywords for a plain search engine from a messy reference: years,
581/// numbers, initials and "et al." dropped, since they dilute keyword
582/// ranking (observed with OpenAlex).
583pub 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
593/// The distinct identifiers mentioned in free text (see [`crate::id::scan`]).
594pub 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        // Dogfood: a generic title contained in the reference, no authors.
860        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        // An unauthored letter with the same title loses to the article.
871        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        // Citing the reply itself is fine.
930        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        // Dogfood control: a fabricated citation sharing "septic shock".
949        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}