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::publication::{self, first_page, journal_in, same_journal};
26use crate::record::SourceRecord;
27
28use authors::{author_overlap, family_name};
29use derivative::claim_mentions_derivative;
30pub use derivative::is_derivative;
31use text::Normalised;
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/// Multiplier for replies, corrections, errata and commentaries.
53const DERIVATIVE_PENALTY: f64 = 0.5;
54/// Confidence ceiling when the claimed volume or first page differs.
55const LOCATION_MISMATCH_CAP: f64 = 0.8;
56/// Multiplier when a structured claim names a different journal.
57const JOURNAL_MISMATCH_PENALTY: f64 = 0.9;
58
59/// Verdict thresholds on confidence.
60pub const VERIFIED: f64 = 0.85;
61pub const PLAUSIBLE: f64 = 0.6;
62
63/// Authors compared from the start of a byline.
64const AUTHORS_COMPARED: usize = 3;
65
66/// What a citation asserts. Any part may be missing.
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[non_exhaustive]
69pub struct Claim {
70    /// A free-text reference, in any style.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub text: Option<String>,
73    /// Identifiers asserted for the work.
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub ids: Vec<WorkId>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub title: Option<String>,
78    /// Names as written: "Ashish Vaswani", "Vaswani, A.", "A. Vaswani".
79    #[serde(default, skip_serializing_if = "Vec::is_empty")]
80    pub authors: Vec<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub year: Option<i16>,
83    /// The journal (or other container), full or abbreviated.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub container: Option<String>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub volume: Option<String>,
88    /// The first page (or a range, of which the first page counts).
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub page: Option<String>,
91    /// Other readings of claimed identifiers (a DOI without the hyphen at
92    /// a line break), to try only if the claimed one doesn't exist.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub alternatives: Vec<Alternative>,
95}
96
97/// Another reading of a claimed identifier.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[non_exhaustive]
100pub struct Alternative {
101    pub claimed: WorkId,
102    pub alternative: WorkId,
103}
104
105impl Alternative {
106    pub fn new(claimed: WorkId, alternative: WorkId) -> Self {
107        Alternative {
108            claimed,
109            alternative,
110        }
111    }
112}
113
114impl Claim {
115    /// A claim from a free-text reference, with any DOIs and arXiv IDs it
116    /// mentions extracted as asserted identifiers.
117    pub fn from_text(text: impl Into<String>) -> Self {
118        let text = text.into();
119        Claim {
120            ids: find_ids(&text),
121            text: Some(text),
122            ..Claim::default()
123        }
124    }
125
126    /// A claim from whatever a user typed: if the whole input is an
127    /// identifier, a claim of just that identifier (an existence check);
128    /// otherwise a free-text reference (see [`Claim::from_text`]).
129    pub fn from_input(input: &str) -> Self {
130        let input = input.trim();
131        match input.parse::<WorkId>() {
132            Ok(id) => Claim {
133                ids: vec![id],
134                ..Claim::default()
135            },
136            Err(_) => Claim::from_text(input),
137        }
138    }
139
140    /// Nothing to search with: no identifiers, and no text, title or
141    /// authors that aren't blank.
142    pub fn is_empty(&self) -> bool {
143        let blank = |s: &Option<String>| s.as_deref().is_none_or(|s| s.trim().is_empty());
144        blank(&self.text)
145            && self.ids.is_empty()
146            && blank(&self.title)
147            && self.authors.iter().all(|a| a.trim().is_empty())
148    }
149
150    /// A reference string for citation matchers: the text, or one composed
151    /// from the fields.
152    pub fn reference(&self) -> Option<String> {
153        if let Some(text) = &self.text {
154            return Some(text.clone());
155        }
156        // Vancouver-like: "Authors. Title. Journal. 2017;376:2545".
157        let location = match (&self.volume, &self.page) {
158            (Some(v), Some(p)) => Some(format!("{v}:{p}")),
159            (Some(v), None) => Some(v.clone()),
160            (None, p) => p.clone(),
161        };
162        let when = [self.year.map(|y| y.to_string()), location]
163            .into_iter()
164            .flatten()
165            .collect::<Vec<_>>()
166            .join(";");
167        let parts: Vec<String> = [
168            (!self.authors.is_empty()).then(|| self.authors.join(", ")),
169            self.title.clone(),
170            self.container.clone(),
171            (!when.is_empty()).then_some(when),
172        ]
173        .into_iter()
174        .flatten()
175        .collect();
176        (!parts.is_empty()).then(|| parts.join(". "))
177    }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum Agreement {
183    Match,
184    Partial,
185    Mismatch,
186    /// The claim or the record doesn't say.
187    Unknown,
188}
189
190/// One field's comparison: a score in `0.0..=1.0` when comparable.
191#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
192pub struct FieldCheck {
193    pub agreement: Agreement,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub score: Option<f64>,
196}
197
198impl FieldCheck {
199    const UNKNOWN: FieldCheck = FieldCheck {
200        agreement: Agreement::Unknown,
201        score: None,
202    };
203
204    fn exact(agrees: bool) -> Self {
205        FieldCheck::scored(if agrees { 1.0 } else { 0.0 })
206    }
207
208    fn scored(score: f64) -> Self {
209        let agreement = match score {
210            s if s >= 0.9 => Agreement::Match,
211            s if s >= 0.5 => Agreement::Partial,
212            _ => Agreement::Mismatch,
213        };
214        FieldCheck {
215            agreement,
216            score: Some(score),
217        }
218    }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
222#[non_exhaustive]
223pub struct Assessment {
224    pub confidence: f64,
225    pub title: FieldCheck,
226    pub authors: FieldCheck,
227    pub year: FieldCheck,
228    /// The journal: `Match` when the claim names it (or an abbreviation),
229    /// `Mismatch` when a structured claim names another.
230    pub container: FieldCheck,
231    pub volume: FieldCheck,
232    /// The first page.
233    pub page: FieldCheck,
234    /// Whether the claim's identifiers are the record's: `Match` if any
235    /// claimed ID is the record's, `Mismatch` if the record has a different
236    /// ID of the same kind, else `Unknown`.
237    pub ids: Agreement,
238}
239
240/// Scores how well `record` matches `claim`.
241pub fn assess(claim: &Claim, record: &SourceRecord) -> Assessment {
242    let work = &record.work;
243    let text = claim.text.as_deref().map(Normalised::new);
244
245    let title = match (&claim.title, &work.title, &text) {
246        (Some(claimed), Some(actual), _) => FieldCheck::scored(title_similarity(claimed, actual)),
247        (None, Some(actual), Some(text)) => FieldCheck::scored(text.title_coverage(actual)),
248        _ => FieldCheck::UNKNOWN,
249    };
250
251    let families: Vec<String> = work.authors.iter().filter_map(family_name).collect();
252    let authors = if families.is_empty() {
253        FieldCheck::UNKNOWN
254    } else if !claim.authors.is_empty() {
255        FieldCheck::scored(author_overlap(&claim.authors, &families))
256    } else if let Some(text) = &text {
257        FieldCheck::scored(text.author_coverage(&families))
258    } else {
259        FieldCheck::UNKNOWN
260    };
261
262    let actual_years: Vec<i16> = work
263        .issued
264        .map(|d| d.year())
265        .into_iter()
266        .chain(work.other_years.iter().copied())
267        .collect();
268    let claimed_years: Vec<i16> = match (claim.year, &text) {
269        (Some(y), _) => vec![y],
270        (None, Some(text)) => text.years(),
271        _ => Vec::new(),
272    };
273    let year = match actual_years.is_empty() || claimed_years.is_empty() {
274        false => {
275            // Nearest pairing: any edition's year may be the one cited.
276            let delta = claimed_years
277                .iter()
278                .flat_map(|c| actual_years.iter().map(move |a| (c - a).unsigned_abs()))
279                .min()
280                .unwrap_or(u16::MAX);
281            // Halves per year off: off by one is common (online-first vs
282            // print, preprint vs published) and counts as partial; two or
283            // more is a mismatch, but a nearer year still ranks higher.
284            FieldCheck::scored(0.5f64.powi(i32::from(delta.min(30))))
285        }
286        true => FieldCheck::UNKNOWN,
287    };
288
289    let container = match (&claim.container, &work.container_title, &text) {
290        (Some(claimed), Some(actual), _) => FieldCheck::exact(same_journal(claimed, actual)),
291        (None, Some(actual), Some(text)) if journal_in(&text.words, actual) => {
292            FieldCheck::exact(true)
293        }
294        _ => FieldCheck::UNKNOWN,
295    };
296    let located = match &claim.text {
297        Some(text) if claim.volume.is_none() && claim.page.is_none() => publication::locator(text),
298        _ => publication::Locator {
299            volume: claim.volume.clone(),
300            page: claim.page.as_deref().map(|p| first_page(p).to_owned()),
301        },
302    };
303    let same = |claimed: Option<&str>, actual: Option<&str>| match (claimed, actual) {
304        (Some(c), Some(a)) if !c.is_empty() && !a.is_empty() => {
305            FieldCheck::exact(c.trim().eq_ignore_ascii_case(a.trim()))
306        }
307        _ => FieldCheck::UNKNOWN,
308    };
309    let volume = same(located.volume.as_deref(), work.volume.as_deref());
310    let page = same(
311        located.page.as_deref(),
312        work.page.as_deref().map(first_page),
313    );
314
315    // Citing the exact place: volume and first page.
316    let located = volume.agreement == Agreement::Match && page.agreement == Agreement::Match;
317    // Short Vancouver references often leave the title out ("Daum RS et
318    // al. N Engl J Med 2017;376:2545"); with the journal and volume
319    // agreeing, a title missing from the text is not a different one (the
320    // page then decides between verified and a wrong page).
321    let placed = container.agreement == Agreement::Match && volume.agreement == Agreement::Match;
322    let title = match placed {
323        true if claim.title.is_none() && title.agreement == Agreement::Mismatch => {
324            FieldCheck::UNKNOWN
325        }
326        _ => title,
327    };
328
329    let ids = id_agreement(&claim.ids, &record.ids);
330    // A claim of identifiers alone is fully confirmed by a matching ID.
331    let id_only = [title, authors, year].iter().all(|c| c.score.is_none());
332    let mut confidence = if id_only && ids == Agreement::Match {
333        1.0
334    } else {
335        combine(title, authors, year)
336    };
337    // Title and year alone can't verify: a short or generic title ("Health
338    // technology assessment") turns up inside many references, and letters
339    // and corrections often carry the original's title without authors.
340    // Corroboration means the first author matches, or a claimed ID does.
341    // So does citing the exact place.
342    let corroborated = authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS)
343        || ids == Agreement::Match
344        || located;
345    if !id_only && !corroborated {
346        confidence = confidence.min(UNCORROBORATED_CAP);
347    }
348    // A wrong volume or page is an error in the citation, and tells an
349    // original from the letters and corrections in the same journal.
350    if [volume, page]
351        .iter()
352        .any(|c| c.agreement == Agreement::Mismatch)
353    {
354        confidence = confidence.min(LOCATION_MISMATCH_CAP);
355    }
356    // Journal names compare by rule, which misses some pairs ("Br Med J" ↔
357    // "BMJ"), so a different journal only lowers the ranking.
358    if container.agreement == Agreement::Mismatch {
359        confidence *= JOURNAL_MISMATCH_PENALTY;
360    }
361    // A reply, correction or commentary is not the work it responds to,
362    // unless the citation says it is citing one.
363    if let Some(actual) = &work.title
364        && is_derivative(actual)
365        && !claim_mentions_derivative(claim)
366    {
367        confidence *= DERIVATIVE_PENALTY;
368    }
369
370    Assessment {
371        confidence,
372        title,
373        authors,
374        year,
375        container,
376        volume,
377        page,
378        ids,
379    }
380}
381
382fn combine(title: FieldCheck, authors: FieldCheck, year: FieldCheck) -> f64 {
383    let parts = [(title, W_TITLE), (authors, W_AUTHORS), (year, W_YEAR)];
384    let known: Vec<(f64, f64)> = parts
385        .iter()
386        .filter_map(|(check, w)| check.score.map(|s| (s, *w)))
387        .collect();
388    let weight: f64 = known.iter().map(|(_, w)| w).sum();
389    if weight == 0.0 {
390        return 0.0;
391    }
392    let mut confidence = known.iter().map(|(s, w)| s * w).sum::<f64>() / weight;
393    if year.agreement == Agreement::Mismatch {
394        confidence *= YEAR_MISMATCH_PENALTY;
395    }
396    if title.agreement == Agreement::Mismatch {
397        confidence *= TITLE_MISMATCH_PENALTY;
398    }
399    // Different authors usually mean a different work (a similarly titled
400    // book or survey), even when title and year agree.
401    if authors.agreement == Agreement::Mismatch {
402        confidence *= AUTHORS_MISMATCH_PENALTY;
403    }
404    if known.len() < 2 {
405        confidence = confidence.min(SINGLE_FIELD_CAP);
406    }
407    confidence
408}
409
410/// Whether claimed identifiers are among a work's identifiers (see
411/// [`Assessment::ids`]).
412pub fn id_agreement(claimed: &[WorkId], actual: &[WorkId]) -> Agreement {
413    if claimed.is_empty() {
414        return Agreement::Unknown;
415    }
416    if claimed
417        .iter()
418        .any(|c| actual.iter().any(|a| same_work_id(c, a)))
419    {
420        return Agreement::Match;
421    }
422    let shares_kind = claimed
423        .iter()
424        .any(|c| actual.iter().any(|a| a.kind() == c.kind()));
425    if shares_kind {
426        Agreement::Mismatch
427    } else {
428        Agreement::Unknown
429    }
430}
431
432/// Identifier equality at the level of works: arXiv versions are the same work.
433pub fn same_work_id(a: &WorkId, b: &WorkId) -> bool {
434    match (a, b) {
435        (WorkId::Arxiv(x), WorkId::Arxiv(y)) => x.unversioned() == y.unversioned(),
436        _ => a == b,
437    }
438}
439
440/// The distinct identifiers mentioned in free text (see [`crate::id::scan`]).
441pub fn find_ids(text: &str) -> Vec<WorkId> {
442    let mut found: Vec<WorkId> = Vec::new();
443    for mention in crate::id::scan(text) {
444        if !found.iter().any(|f| same_work_id(f, &mention.id)) {
445            found.push(mention.id);
446        }
447    }
448    found
449}
450
451#[cfg(test)]
452mod tests;