use aspero_core::matching::same_work_id;
use aspero_core::{Claim, IdKind, Update, UpdateKind, WorkId};
use futures::StreamExt;
use crate::{Candidate, Engine, Verdict, Verification};
const ARXIV_DOI_PREFIX: &str = "10.48550";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Severity {
Info,
Warning,
Error,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Warning => "warning",
Severity::Error => "error",
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Finding {
Verdict { verdict: Verdict, reason: String },
AddDoi { doi: WorkId },
ReplaceId {
claimed: WorkId,
with: Option<WorkId>,
},
YearDiffers { claimed: i16, actual: i16 },
Duplicate { of: usize },
Updated { update: Update },
}
impl Finding {
pub fn severity(&self) -> Severity {
match self {
Finding::Verdict { verdict, .. } => match verdict {
Verdict::Mismatch | Verdict::NotFound => Severity::Error,
_ => Severity::Warning,
},
Finding::ReplaceId { .. } => Severity::Error,
Finding::YearDiffers { claimed, actual } if (claimed - actual).abs() > 1 => {
Severity::Error
}
Finding::YearDiffers { .. } | Finding::AddDoi { .. } => Severity::Info,
Finding::Duplicate { .. } => Severity::Warning,
Finding::Updated { update } => match update.kind {
UpdateKind::Retraction | UpdateKind::Withdrawal => Severity::Error,
UpdateKind::ExpressionOfConcern | UpdateKind::NewVersion => Severity::Warning,
_ => Severity::Info,
},
}
}
pub fn kind(&self) -> &'static str {
match self {
Finding::Verdict { .. } => "verdict",
Finding::AddDoi { .. } => "add_doi",
Finding::ReplaceId { .. } => "replace_id",
Finding::YearDiffers { .. } => "year_differs",
Finding::Duplicate { .. } => "duplicate",
Finding::Updated { .. } => "updated",
}
}
}
#[derive(Debug, Clone)]
pub struct Checked {
pub verification: Verification,
pub findings: Vec<Finding>,
}
impl Checked {
pub fn matched(&self) -> Option<&Candidate> {
let v = &self.verification;
v.best.map(|i| &v.report.candidates[i])
}
pub fn severity(&self) -> Option<Severity> {
self.findings.iter().map(Finding::severity).max()
}
}
impl Engine {
pub async fn check(
&self,
claims: &[Claim],
limit: u32,
mut progress: impl FnMut(usize),
) -> Vec<Checked> {
self.prefetch(claims).await;
let mut checked = Vec::with_capacity(claims.len());
let mut verifications = std::pin::pin!(self.verify_many(claims, limit));
while let Some(verification) = verifications.next().await {
let i = checked.len();
let findings = findings(&claims[i], &verification);
checked.push(Checked {
verification,
findings,
});
progress(i);
}
let matches: Vec<Option<&Candidate>> = checked.iter().map(Checked::matched).collect();
let duplicates = duplicates(&matches);
for (c, dup) in checked.iter_mut().zip(duplicates) {
if let Some(of) = dup {
c.findings.push(Finding::Duplicate { of });
}
}
checked
}
}
pub fn findings(claim: &Claim, v: &Verification) -> Vec<Finding> {
let mut out = Vec::new();
if v.verdict != Verdict::Verified {
out.push(Finding::Verdict {
verdict: v.verdict,
reason: v.reason.clone(),
});
}
let Some(best) = v.best.map(|i| &v.report.candidates[i]) else {
return out;
};
let suggested = preferred_doi(&best.ids);
out.extend(
best.updates
.iter()
.map(|u| Finding::Updated { update: u.clone() }),
);
for claimed in &claim.ids {
let as_arxiv = arxiv_of(claimed);
let same = best.ids.iter().any(|id| {
same_work_id(id, claimed) || as_arxiv.as_ref().is_some_and(|a| same_work_id(id, a))
});
let contradicted = best.ids.iter().any(|id| id.kind() == claimed.kind());
if !same && (contradicted || v.verdict == Verdict::Mismatch) {
out.push(Finding::ReplaceId {
claimed: claimed.clone(),
with: suggested.cloned(),
});
}
}
let has_doi = claim.ids.iter().any(|id| id.kind() == IdKind::Doi);
if !has_doi
&& matches!(v.verdict, Verdict::Verified | Verdict::Plausible)
&& let Some(doi) = suggested
{
out.push(Finding::AddDoi { doi: doi.clone() });
}
let work = &best.record.work;
let years: Vec<i16> = work
.issued
.map(|d| d.year())
.into_iter()
.chain(work.other_years.iter().copied())
.collect();
if let Some(claimed) = claim.year
&& let Some(&actual) = years.iter().min_by_key(|y| (*y - claimed).abs())
&& actual != claimed
{
out.push(Finding::YearDiffers { claimed, actual });
}
out
}
fn arxiv_of(id: &WorkId) -> Option<WorkId> {
let WorkId::Doi(doi) = id else { return None };
(doi.prefix() == ARXIV_DOI_PREFIX)
.then(|| {
doi.suffix()
.strip_prefix("arxiv.")?
.parse()
.ok()
.map(WorkId::Arxiv)
})
.flatten()
}
fn preferred_doi(ids: &[WorkId]) -> Option<&WorkId> {
let dois = || ids.iter().filter(|id| id.kind() == IdKind::Doi);
dois()
.find(|id| {
!id.to_string()
.starts_with(&format!("doi:{ARXIV_DOI_PREFIX}/"))
})
.or_else(|| dois().next())
}
pub fn duplicates(matches: &[Option<&Candidate>]) -> Vec<Option<usize>> {
matches
.iter()
.enumerate()
.map(|(i, m)| {
let m = (*m)?;
matches[..i].iter().position(|other| {
other.is_some_and(|o| {
o.ids
.iter()
.any(|a| m.ids.iter().any(|b| same_work_id(a, b)))
})
})
})
.collect()
}