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