Skip to main content

aspero_core/matching/
mod.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
18mod authors;
19mod derivative;
20mod text;
21
22use serde::{Deserialize, Serialize};
23
24use crate::id::{WorkId, starts_with_ci};
25use crate::provider::Place;
26use crate::publication::{self, first_page, journal_in, same_journal};
27use crate::record::SourceRecord;
28
29use authors::{author_overlap, family_name};
30pub use derivative::{
31    WorkKind, claim_mentions_derivative, derivative_kind, derivative_work_kind, is_derivative,
32};
33use text::{Normalised, content_words};
34pub use text::{normalise, search_terms, shared_title, title_similarity, unmarked};
35
36/// Field weights in the confidence (before penalties).
37const W_TITLE: f64 = 0.5;
38const W_AUTHORS: f64 = 0.25;
39const W_YEAR: f64 = 0.25;
40
41/// Multiplier when the year is off by two or more.
42const YEAR_MISMATCH_PENALTY: f64 = 0.5;
43/// Multiplier when the title clearly differs.
44const TITLE_MISMATCH_PENALTY: f64 = 0.5;
45/// Multiplier when the authors clearly differ.
46const AUTHORS_MISMATCH_PENALTY: f64 = 0.5;
47/// Confidence ceiling when fewer than two fields could be compared.
48const SINGLE_FIELD_CAP: f64 = 0.7;
49/// Confidence ceiling without corroborating authors or identifiers.
50const UNCORROBORATED_CAP: f64 = 0.8;
51/// Author score that corroborates a match: the first author present
52/// (0.75 for structured claims, 0.9 in free text).
53const CORROBORATING_AUTHORS: f64 = 0.75;
54/// Confidence when the journal, volume and first page all agree and
55/// nothing contradicts them: verified.
56const PLACED: f64 = 0.9;
57/// The same with the year off by one (online first, then in print):
58/// plausible.
59const PLACED_YEAR_OFF: f64 = 0.8;
60/// Confidence ceiling, below plausible, without evidence of which work is
61/// cited: only a year or journal agreeing, or a short title found inside
62/// free text.
63const NO_EVIDENCE_CAP: f64 = 0.5;
64/// Content words below which a title found inside free text is short: one
65/// or two words turn up in any text ("Aspero", "Deep learning").
66const SHORT_TITLE_WORDS: usize = 3;
67/// Multiplier for replies, corrections, errata and commentaries.
68const DERIVATIVE_PENALTY: f64 = 0.5;
69/// Confidence ceiling when the claimed volume or first page differs.
70const LOCATION_MISMATCH_CAP: f64 = 0.8;
71/// Multiplier when a structured claim names a different journal.
72const JOURNAL_MISMATCH_PENALTY: f64 = 0.9;
73
74/// Verdict thresholds on confidence.
75pub const VERIFIED: f64 = 0.85;
76pub const PLAUSIBLE: f64 = 0.6;
77
78/// Authors compared from the start of a byline.
79const AUTHORS_COMPARED: usize = 3;
80
81/// What a citation asserts. Any part may be missing.
82#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
83#[non_exhaustive]
84pub struct Claim {
85    /// A free-text reference, in any style.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub text: Option<String>,
88    /// Identifiers asserted for the work.
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub ids: Vec<WorkId>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub title: Option<String>,
93    /// Names as written: "Ashish Vaswani", "Vaswani, A.", "A. Vaswani".
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub authors: Vec<String>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub year: Option<i16>,
98    /// The journal (or other container), full or abbreviated.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub container: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub volume: Option<String>,
103    /// The first page (or a range, of which the first page counts).
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub page: Option<String>,
106    /// Other readings of claimed identifiers (a DOI without the hyphen at
107    /// a line break), to try only if the claimed one doesn't exist.
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub alternatives: Vec<Alternative>,
110}
111
112/// Another reading of a claimed identifier.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[non_exhaustive]
115pub struct Alternative {
116    pub claimed: WorkId,
117    pub alternative: WorkId,
118}
119
120impl Alternative {
121    pub fn new(claimed: WorkId, alternative: WorkId) -> Self {
122        Alternative {
123            claimed,
124            alternative,
125        }
126    }
127}
128
129impl Claim {
130    /// A claim from a free-text reference, with any DOIs and arXiv IDs it
131    /// mentions extracted as asserted identifiers.
132    pub fn from_text(text: impl Into<String>) -> Self {
133        let text = text.into();
134        // A DOI broken across lines may be meant otherwise (#39).
135        let mut alternatives: Vec<Alternative> = Vec::new();
136        for m in crate::id::scan(&text) {
137            if let Some(other) = m.alternative(&text) {
138                let a = Alternative::new(m.id, other);
139                if !alternatives.contains(&a) {
140                    alternatives.push(a);
141                }
142            }
143        }
144        Claim {
145            ids: find_ids(&text),
146            text: Some(text),
147            alternatives,
148            ..Claim::default()
149        }
150    }
151
152    /// A claim from whatever a user typed: if the whole input is an
153    /// identifier, a claim of just that identifier (an existence check);
154    /// otherwise a free-text reference (see [`Claim::from_text`]).
155    pub fn from_input(input: &str) -> Self {
156        let input = input.trim();
157        let whole = || match crate::id::scan(input).as_slice() {
158            // As a copy spaced it out: `https:// doi. org/ 10. 1183/ …`.
159            [m] if m.span == (0..input.len()) => Some(m.id.clone()),
160            _ => None,
161        };
162        match input.parse::<WorkId>().ok().or_else(whole) {
163            Some(id) => Claim {
164                ids: vec![id],
165                ..Claim::default()
166            },
167            None => Claim::from_text(input),
168        }
169    }
170
171    /// The volume and first page the claim cites: its fields, each one not
172    /// given read from its text ("Injury 47 (2016) 797–804").
173    pub(crate) fn locator(&self) -> publication::Locator {
174        let given = publication::Locator {
175            volume: self.volume.clone(),
176            page: self.page.as_deref().map(|p| first_page(p).to_owned()),
177        };
178        match &self.text {
179            Some(text) if given.volume.is_none() || given.page.is_none() => {
180                let found = publication::locator(text);
181                publication::Locator {
182                    volume: given.volume.or(found.volume),
183                    page: given.page.or(found.page),
184                }
185            }
186            _ => given,
187        }
188    }
189
190    /// Where the claim says the work appeared: volume and first page.
191    pub fn place(&self) -> Option<Place> {
192        let located = self.locator();
193        Some(Place::new(located.volume?, located.page?))
194    }
195
196    /// The claim as read: as given, with what its text says of the year
197    /// (when it gives one), volume and first page filled in where not
198    /// given. Free text's title, authors and journal aren't parsed out:
199    /// they're scored by finding a work's own in the text.
200    pub fn as_read(&self) -> Claim {
201        let mut read = self.clone();
202        if read.year.is_none()
203            && let [year] = dedup_years(self.cited_years())[..]
204        {
205            read.year = Some(year);
206        }
207        let located = self.locator();
208        read.volume = read.volume.or(located.volume);
209        read.page = read.page.or(located.page);
210        read
211    }
212
213    /// The years the claim cites: its year, else those in its text.
214    pub fn cited_years(&self) -> Vec<i16> {
215        match (self.year, &self.text) {
216            (Some(year), _) => vec![year],
217            (None, Some(text)) => Normalised::new(text).years(),
218            _ => Vec::new(),
219        }
220    }
221
222    /// Nothing to search with: no identifiers, and no text, title or
223    /// authors that aren't blank.
224    pub fn is_empty(&self) -> bool {
225        let blank = |s: &Option<String>| s.as_deref().is_none_or(|s| s.trim().is_empty());
226        blank(&self.text)
227            && self.ids.is_empty()
228            && blank(&self.title)
229            && self.authors.iter().all(|a| a.trim().is_empty())
230    }
231
232    /// A reference string for citation matchers: the text, or one composed
233    /// from the fields.
234    pub fn reference(&self) -> Option<String> {
235        if let Some(text) = &self.text {
236            return Some(text.clone());
237        }
238        // Vancouver-like: "Authors. Title. Journal. 2017;376:2545".
239        let location = match (&self.volume, &self.page) {
240            (Some(v), Some(p)) => Some(format!("{v}:{p}")),
241            (Some(v), None) => Some(v.clone()),
242            (None, p) => p.clone(),
243        };
244        let when = [self.year.map(|y| y.to_string()), location]
245            .into_iter()
246            .flatten()
247            .collect::<Vec<_>>()
248            .join(";");
249        let parts: Vec<String> = [
250            (!self.authors.is_empty()).then(|| self.authors.join(", ")),
251            self.title.clone(),
252            self.container.clone(),
253            (!when.is_empty()).then_some(when),
254        ]
255        .into_iter()
256        .flatten()
257        .collect();
258        (!parts.is_empty()).then(|| parts.join(". "))
259    }
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264pub enum Agreement {
265    Match,
266    Partial,
267    Mismatch,
268    /// The claim or the record doesn't say.
269    Unknown,
270}
271
272/// One field's comparison: a score in `0.0..=1.0` when comparable.
273#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
274pub struct FieldCheck {
275    pub agreement: Agreement,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub score: Option<f64>,
278}
279
280impl FieldCheck {
281    const UNKNOWN: FieldCheck = FieldCheck {
282        agreement: Agreement::Unknown,
283        score: None,
284    };
285
286    fn exact(agrees: bool) -> Self {
287        FieldCheck::scored(if agrees { 1.0 } else { 0.0 })
288    }
289
290    fn scored(score: f64) -> Self {
291        let agreement = match score {
292            s if s >= 0.9 => Agreement::Match,
293            s if s >= 0.5 => Agreement::Partial,
294            _ => Agreement::Mismatch,
295        };
296        FieldCheck {
297            agreement,
298            score: Some(score),
299        }
300    }
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
304#[non_exhaustive]
305pub struct Assessment {
306    pub confidence: f64,
307    pub title: FieldCheck,
308    pub authors: FieldCheck,
309    pub year: FieldCheck,
310    /// The journal: `Match` when the claim names it (or an abbreviation),
311    /// `Mismatch` when a structured claim names another.
312    pub container: FieldCheck,
313    pub volume: FieldCheck,
314    /// The first page.
315    pub page: FieldCheck,
316    /// Whether the claim's identifiers are the record's: `Match` if any
317    /// claimed ID is the record's, `Mismatch` if the record has a different
318    /// ID of the same kind, else `Unknown`.
319    pub ids: Agreement,
320}
321
322/// A title that is a URL.
323fn is_url(title: &str) -> bool {
324    let t = title.trim();
325    ["http://", "https://", "www."]
326        .iter()
327        .any(|p| starts_with_ci(t, p))
328        || t.contains("://")
329}
330
331fn dedup_years(mut years: Vec<i16>) -> Vec<i16> {
332    years.sort_unstable();
333    years.dedup();
334    years
335}
336
337/// Scores how well `record` matches `claim`.
338pub fn assess(claim: &Claim, record: &SourceRecord) -> Assessment {
339    let work = &record.work;
340    let text = claim.text.as_deref().map(Normalised::new);
341    // A placeholder record's "title" may be a URL (a registration agency's
342    // notice page): no title at all.
343    let work_title = work.title.clone().filter(|t| !is_url(t));
344
345    let families: Vec<String> = work.authors.iter().filter_map(family_name).collect();
346    // A reference that gives no title ("Wittermans E, et al. ERJ Open Res.
347    // 2022") doesn't give a different one.
348    let untitled = claim.title.is_none()
349        && text
350            .as_ref()
351            .is_some_and(|t| t.untitled(&families, work.container_title.as_deref()));
352
353    let title = match (&claim.title, &work_title, &text) {
354        (Some(claimed), Some(actual), _) => FieldCheck::scored(title_similarity(claimed, actual)),
355        (None, Some(actual), Some(text)) if !untitled => {
356            FieldCheck::scored(text.title_coverage(actual))
357        }
358        _ => FieldCheck::UNKNOWN,
359    };
360
361    let authors = if families.is_empty() {
362        FieldCheck::UNKNOWN
363    } else if !claim.authors.is_empty() {
364        FieldCheck::scored(author_overlap(&claim.authors, &families))
365    } else if let Some(text) = &text {
366        // A reference that names no one ("Injury 47 (2016) 797–804", a
367        // pasted title) doesn't name others.
368        match text.author_coverage(&families) {
369            0.0 if !claim.text.as_deref().is_some_and(authors::names_given) => FieldCheck::UNKNOWN,
370            coverage => FieldCheck::scored(coverage),
371        }
372    } else {
373        FieldCheck::UNKNOWN
374    };
375
376    let actual_years: Vec<i16> = work
377        .issued
378        .map(|d| d.year())
379        .into_iter()
380        .chain(work.other_years.iter().copied())
381        .collect();
382    let claimed_years: Vec<i16> = match (claim.year, &text) {
383        (Some(y), _) => vec![y],
384        (None, Some(text)) => text.years(),
385        _ => Vec::new(),
386    };
387    let year = match actual_years.is_empty() || claimed_years.is_empty() {
388        false => {
389            // Nearest pairing: any edition's year may be the one cited.
390            let delta = claimed_years
391                .iter()
392                .flat_map(|c| actual_years.iter().map(move |a| c.abs_diff(*a)))
393                .min()
394                .unwrap_or(u16::MAX);
395            // Halves per year off: off by one is common (online-first vs
396            // print, preprint vs published) and counts as partial; two or
397            // more is a mismatch, but a nearer year still ranks higher.
398            FieldCheck::scored(0.5f64.powi(i32::from(delta.min(30))))
399        }
400        true => FieldCheck::UNKNOWN,
401    };
402
403    let container = match (&claim.container, &work.container_title, &text) {
404        (Some(claimed), Some(actual), _) => FieldCheck::exact(same_journal(claimed, actual)),
405        (None, Some(actual), Some(text)) if journal_in(&text.words, actual) => {
406            FieldCheck::exact(true)
407        }
408        _ => FieldCheck::UNKNOWN,
409    };
410    let located = claim.locator();
411    let same = |claimed: Option<&str>, actual: Option<&str>| match (claimed, actual) {
412        (Some(c), Some(a)) if !c.is_empty() && !a.is_empty() => {
413            FieldCheck::exact(c.trim().eq_ignore_ascii_case(a.trim()))
414        }
415        _ => FieldCheck::UNKNOWN,
416    };
417    let volume = same(located.volume.as_deref(), work.volume.as_deref());
418    let page = same(
419        located.page.as_deref(),
420        work.page.as_deref().map(first_page),
421    );
422
423    // Citing the exact place: volume and first page.
424    let located = volume.agreement == Agreement::Match && page.agreement == Agreement::Match;
425    // Short Vancouver references often leave the title out ("Daum RS et
426    // al. N Engl J Med 2017;376:2545"); with the journal and volume
427    // agreeing, a title missing from the text is not a different one (the
428    // page then decides between verified and a wrong page).
429    let placed = container.agreement == Agreement::Match && volume.agreement == Agreement::Match;
430    let title = match placed {
431        true if claim.title.is_none() && title.agreement == Agreement::Mismatch => {
432            FieldCheck::UNKNOWN
433        }
434        _ => title,
435    };
436
437    let ids = id_agreement(&claim.ids, &record.ids);
438    // A claim of identifiers alone is fully confirmed by a matching ID.
439    let id_only = [title, authors, year].iter().all(|c| c.score.is_none());
440    let mut confidence = if id_only && ids == Agreement::Match {
441        1.0
442    } else {
443        combine(title, authors, year)
444    };
445    // Title and year alone can't verify: a short or generic title ("Health
446    // technology assessment") turns up inside many references, and letters
447    // and corrections often carry the original's title without authors.
448    // Corroboration means the first author matches, or a claimed ID does.
449    // So does citing the exact place.
450    let corroborated = authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS)
451        || ids == Agreement::Match
452        || located;
453    if !id_only && !corroborated {
454        confidence = confidence.min(UNCORROBORATED_CAP);
455    }
456    // A title of a word or two inside free text ("Aspero" in "aspero 0.6.5
457    // is in") is no evidence by itself, nor with a year: it takes the
458    // authors, the journal, the place or an identifier.
459    let short_title_in_text = claim.title.is_none()
460        && text.is_some()
461        && work_title
462            .as_deref()
463            .is_some_and(|t| content_words(t).len() < SHORT_TITLE_WORDS);
464    if short_title_in_text && !corroborated && container.agreement != Agreement::Match {
465        confidence = confidence.min(NO_EVIDENCE_CAP);
466    }
467    // A year, or a year and journal, identify nothing: some work matches
468    // them by chance. It takes the title, authors, an identifier or the
469    // place.
470    let identified =
471        matches!(title.agreement, Agreement::Match | Agreement::Partial) || corroborated;
472    if !id_only && !identified {
473        confidence = confidence.min(NO_EVIDENCE_CAP);
474    }
475    // Without a title, authors, a year and a journal fit many works: it
476    // takes an identifier, the place, or the journal, volume and authors.
477    let pinned = ids == Agreement::Match
478        || located
479        || (placed && authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS));
480    if untitled && !pinned {
481        confidence = confidence.min(NO_EVIDENCE_CAP);
482    }
483    // Journal, volume and first page together name one work, title or no
484    // title ("Injury 47 (2016) 797–804"), unless something contradicts
485    // it.
486    if located
487        && container.agreement == Agreement::Match
488        && title.agreement != Agreement::Mismatch
489        && authors.agreement != Agreement::Mismatch
490    {
491        let floor = match year.agreement {
492            Agreement::Match | Agreement::Unknown => PLACED,
493            Agreement::Partial => PLACED_YEAR_OFF,
494            Agreement::Mismatch => 0.0,
495        };
496        confidence = confidence.max(floor);
497    }
498    // A wrong volume or page is an error in the citation, and tells an
499    // original from the letters and corrections in the same journal.
500    if [volume, page]
501        .iter()
502        .any(|c| c.agreement == Agreement::Mismatch)
503    {
504        confidence = confidence.min(LOCATION_MISMATCH_CAP);
505    }
506    // Journal names compare by rule, which misses some pairs ("Br Med J" ↔
507    // "BMJ"), so a different journal only lowers the ranking.
508    if container.agreement == Agreement::Mismatch {
509        confidence *= JOURNAL_MISMATCH_PENALTY;
510    }
511    // A reply, correction or commentary is not the work it responds to,
512    // unless the citation says it is citing one.
513    if let Some(actual) = &work_title
514        && is_derivative(actual)
515        && !claim_mentions_derivative(claim)
516    {
517        confidence *= DERIVATIVE_PENALTY;
518    }
519
520    Assessment {
521        confidence,
522        title,
523        authors,
524        year,
525        container,
526        volume,
527        page,
528        ids,
529    }
530}
531
532fn combine(title: FieldCheck, authors: FieldCheck, year: FieldCheck) -> f64 {
533    let parts = [(title, W_TITLE), (authors, W_AUTHORS), (year, W_YEAR)];
534    let known: Vec<(f64, f64)> = parts
535        .iter()
536        .filter_map(|(check, w)| check.score.map(|s| (s, *w)))
537        .collect();
538    let weight: f64 = known.iter().map(|(_, w)| w).sum();
539    if weight == 0.0 {
540        return 0.0;
541    }
542    let mut confidence = known.iter().map(|(s, w)| s * w).sum::<f64>() / weight;
543    if year.agreement == Agreement::Mismatch {
544        confidence *= YEAR_MISMATCH_PENALTY;
545    }
546    if title.agreement == Agreement::Mismatch {
547        confidence *= TITLE_MISMATCH_PENALTY;
548    }
549    // Different authors usually mean a different work (a similarly titled
550    // book or survey), even when title and year agree.
551    if authors.agreement == Agreement::Mismatch {
552        confidence *= AUTHORS_MISMATCH_PENALTY;
553    }
554    if known.len() < 2 {
555        confidence = confidence.min(SINGLE_FIELD_CAP);
556    }
557    confidence
558}
559
560/// Whether claimed identifiers are among a work's identifiers (see
561/// [`Assessment::ids`]).
562pub fn id_agreement(claimed: &[WorkId], actual: &[WorkId]) -> Agreement {
563    if claimed.is_empty() {
564        return Agreement::Unknown;
565    }
566    if claimed
567        .iter()
568        .any(|c| actual.iter().any(|a| same_work_id(c, a)))
569    {
570        return Agreement::Match;
571    }
572    let shares_kind = claimed
573        .iter()
574        .any(|c| actual.iter().any(|a| a.kind() == c.kind()));
575    if shares_kind {
576        Agreement::Mismatch
577    } else {
578        Agreement::Unknown
579    }
580}
581
582/// Identifier equality at the level of works: arXiv versions are the same work.
583pub fn same_work_id(a: &WorkId, b: &WorkId) -> bool {
584    match (a, b) {
585        (WorkId::Arxiv(x), WorkId::Arxiv(y)) => x.unversioned() == y.unversioned(),
586        _ => a == b,
587    }
588}
589
590/// The distinct identifiers mentioned in free text (see [`crate::id::scan`]).
591pub fn find_ids(text: &str) -> Vec<WorkId> {
592    let mut found: Vec<WorkId> = Vec::new();
593    for mention in crate::id::scan(text) {
594        if !found.iter().any(|f| same_work_id(f, &mention.id)) {
595            found.push(mention.id);
596        }
597    }
598    found
599}
600
601#[cfg(test)]
602mod tests;