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::{ArxivId, 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/// DOIs and arXiv IDs mentioned in free text: `doi:…`, `https://doi.org/…`,
594/// bare `10.…/…`, `arXiv:…` and arxiv.org URLs. Surrounding brackets,
595/// quotes and sentence punctuation are ignored.
596pub fn find_ids(text: &str) -> Vec<WorkId> {
597    let mut found: Vec<WorkId> = Vec::new();
598    // An ISBN only counts when labelled ("ISBN 978-…"): in prose, a bare
599    // run of digits could be anything.
600    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            // A DOI, possibly inside a URL or after "doi:".
618            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        // Dogfood: a generic title contained in the reference, no authors.
895        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        // An unauthored letter with the same title loses to the article.
906        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        // Citing the reply itself is fine.
965        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        // Dogfood control: a fabricated citation sharing "septic shock".
984        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}