Skip to main content

aspero_engine/
check.rs

1//! `check`: verifying a whole bibliography, with fixes to suggest.
2//!
3//! Each claim is verified (identifiers prefetched in one batch first), then
4//! its verification is turned into [`Finding`]s beyond the verdict: the DOI
5//! to add, the identifier to replace, a year that differs, and entries that
6//! cite the same work twice.
7
8use aspero_core::matching::same_work_id;
9use aspero_core::{Claim, IdKind, Update, UpdateKind, WorkId};
10use futures::StreamExt;
11
12use crate::{Candidate, Engine, Verdict, Verification};
13
14/// arXiv's DataCite prefix; a publisher DOI is preferred over it.
15const ARXIV_DOI_PREFIX: &str = "10.48550";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18#[non_exhaustive]
19pub enum Severity {
20    Info,
21    Warning,
22    Error,
23}
24
25impl Severity {
26    pub fn as_str(self) -> &'static str {
27        match self {
28            Severity::Info => "info",
29            Severity::Warning => "warning",
30            Severity::Error => "error",
31        }
32    }
33}
34
35/// Something to fix or know about one entry.
36#[derive(Debug, Clone, PartialEq)]
37#[non_exhaustive]
38pub enum Finding {
39    /// The verdict itself, when it isn't `verified`.
40    Verdict { verdict: Verdict, reason: String },
41    /// Verified, but the entry has no DOI; the matched work's.
42    AddDoi { doi: WorkId },
43    /// The entry's identifier is wrong; the matched work's, if known.
44    ReplaceId {
45        claimed: WorkId,
46        with: Option<WorkId>,
47    },
48    /// The entry's year differs from the matched work's: an error beyond
49    /// one year, otherwise info.
50    YearDiffers { claimed: i16, actual: i16 },
51    /// Another entry (by index) cites the same work.
52    Duplicate { of: usize },
53    /// The work has an editorial notice: retraction, correction, ….
54    Updated { update: Update },
55}
56
57impl Finding {
58    pub fn severity(&self) -> Severity {
59        match self {
60            Finding::Verdict { verdict, .. } => match verdict {
61                Verdict::Mismatch | Verdict::NotFound => Severity::Error,
62                _ => Severity::Warning,
63            },
64            Finding::ReplaceId { .. } => Severity::Error,
65            // Off by one is usually legitimate (preprint vs published,
66            // online vs print), so it is only worth knowing.
67            Finding::YearDiffers { claimed, actual } if (claimed - actual).abs() > 1 => {
68                Severity::Error
69            }
70            Finding::YearDiffers { .. } | Finding::AddDoi { .. } => Severity::Info,
71            Finding::Duplicate { .. } => Severity::Warning,
72            Finding::Updated { update } => match update.kind {
73                UpdateKind::Retraction | UpdateKind::Withdrawal => Severity::Error,
74                UpdateKind::ExpressionOfConcern | UpdateKind::NewVersion => Severity::Warning,
75                _ => Severity::Info,
76            },
77        }
78    }
79
80    /// A short machine-friendly name.
81    pub fn kind(&self) -> &'static str {
82        match self {
83            Finding::Verdict { .. } => "verdict",
84            Finding::AddDoi { .. } => "add_doi",
85            Finding::ReplaceId { .. } => "replace_id",
86            Finding::YearDiffers { .. } => "year_differs",
87            Finding::Duplicate { .. } => "duplicate",
88            Finding::Updated { .. } => "updated",
89        }
90    }
91}
92
93/// One checked claim.
94#[derive(Debug, Clone)]
95pub struct Checked {
96    pub verification: Verification,
97    pub findings: Vec<Finding>,
98}
99
100impl Checked {
101    pub fn matched(&self) -> Option<&Candidate> {
102        let v = &self.verification;
103        v.best.map(|i| &v.report.candidates[i])
104    }
105
106    /// The most severe finding, if any.
107    pub fn severity(&self) -> Option<Severity> {
108        self.findings.iter().map(Finding::severity).max()
109    }
110}
111
112impl Engine {
113    /// Verifies every claim and derives findings, including duplicates
114    /// across claims; in claim order. `progress` is called with each
115    /// claim's index as it completes, in any order.
116    pub async fn check(
117        &self,
118        claims: &[Claim],
119        limit: u32,
120        mut progress: impl FnMut(usize),
121    ) -> Vec<Checked> {
122        self.prefetch(claims).await;
123        let mut slots: Vec<Option<Checked>> = (0..claims.len()).map(|_| None).collect();
124        let mut verifications = std::pin::pin!(self.verify_many(claims, limit));
125        while let Some((i, verification)) = verifications.next().await {
126            let findings = findings(&claims[i], &verification);
127            slots[i] = Some(Checked {
128                verification,
129                findings,
130            });
131            progress(i);
132        }
133        let mut checked: Vec<Checked> = slots
134            .into_iter()
135            .map(|c| c.expect("every claim verified"))
136            .collect();
137        let matches: Vec<Option<&Candidate>> = checked.iter().map(Checked::matched).collect();
138        let duplicates = duplicates(&matches);
139        for (c, dup) in checked.iter_mut().zip(duplicates) {
140            if let Some(of) = dup {
141                c.findings.push(Finding::Duplicate { of });
142            }
143        }
144        checked
145    }
146}
147
148/// Findings for one claim from its verification (not duplicates, which
149/// need the whole set).
150pub fn findings(claim: &Claim, v: &Verification) -> Vec<Finding> {
151    let mut out = Vec::new();
152    if v.verdict != Verdict::Verified {
153        out.push(Finding::Verdict {
154            verdict: v.verdict,
155            reason: v.reason.clone(),
156        });
157    }
158    let Some(best) = v.best.map(|i| &v.report.candidates[i]) else {
159        return out;
160    };
161    let suggested = preferred_doi(&best.ids);
162    out.extend(
163        best.updates
164            .iter()
165            .map(|u| Finding::Updated { update: u.clone() }),
166    );
167
168    // Each claimed identifier that isn't the matched work's: flagged when
169    // the work has a different one of the same kind, or the verdict says the
170    // claimed one doesn't belong (e.g. it doesn't exist). A kind the match
171    // simply lacks (a PMID on a Crossref record) proves nothing.
172    for claimed in &claim.ids {
173        let as_arxiv = arxiv_of(claimed);
174        let same = best.ids.iter().any(|id| {
175            same_work_id(id, claimed) || as_arxiv.as_ref().is_some_and(|a| same_work_id(id, a))
176        });
177        let contradicted = best.ids.iter().any(|id| id.kind() == claimed.kind());
178        if !same && (contradicted || v.verdict == Verdict::Mismatch) {
179            out.push(Finding::ReplaceId {
180                claimed: claimed.clone(),
181                with: suggested.cloned(),
182            });
183        }
184    }
185    // No DOI claimed, but the work has one.
186    let has_doi = claim.ids.iter().any(|id| id.kind() == IdKind::Doi);
187    if !has_doi
188        && matches!(v.verdict, Verdict::Verified | Verdict::Plausible)
189        && let Some(doi) = suggested
190    {
191        out.push(Finding::AddDoi { doi: doi.clone() });
192    }
193    // Any edition's year counts (as in scoring); otherwise report the
194    // nearest one.
195    let work = &best.record.work;
196    let years: Vec<i16> = work
197        .issued
198        .map(|d| d.year())
199        .into_iter()
200        .chain(work.other_years.iter().copied())
201        .collect();
202    if let Some(claimed) = claim.year
203        && let Some(&actual) = years.iter().min_by_key(|y| (*y - claimed).abs())
204        && actual != claimed
205    {
206        out.push(Finding::YearDiffers { claimed, actual });
207    }
208    out
209}
210
211/// The arXiv ID behind an arXiv DataCite DOI, which names the same work as
212/// a published version's DOI.
213fn arxiv_of(id: &WorkId) -> Option<WorkId> {
214    let WorkId::Doi(doi) = id else { return None };
215    (doi.prefix() == ARXIV_DOI_PREFIX)
216        .then(|| {
217            doi.suffix()
218                .strip_prefix("arxiv.")?
219                .parse()
220                .ok()
221                .map(WorkId::Arxiv)
222        })
223        .flatten()
224}
225
226/// A publisher DOI if the work has one, else its arXiv DOI.
227fn preferred_doi(ids: &[WorkId]) -> Option<&WorkId> {
228    let dois = || ids.iter().filter(|id| id.kind() == IdKind::Doi);
229    dois()
230        .find(|id| {
231            !id.to_string()
232                .starts_with(&format!("doi:{ARXIV_DOI_PREFIX}/"))
233        })
234        .or_else(|| dois().next())
235}
236
237/// For each match, the earliest other match that is the same work (shares
238/// an identifier).
239pub fn duplicates(matches: &[Option<&Candidate>]) -> Vec<Option<usize>> {
240    matches
241        .iter()
242        .enumerate()
243        .map(|(i, m)| {
244            let m = (*m)?;
245            matches[..i].iter().position(|other| {
246                other.is_some_and(|o| {
247                    o.ids
248                        .iter()
249                        .any(|a| m.ids.iter().any(|b| same_work_id(a, b)))
250                })
251            })
252        })
253        .collect()
254}