1use 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
14const 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#[derive(Debug, Clone, PartialEq)]
37#[non_exhaustive]
38pub enum Finding {
39 Verdict { verdict: Verdict, reason: String },
41 AddDoi { doi: WorkId },
43 ReplaceId {
45 claimed: WorkId,
46 with: Option<WorkId>,
47 },
48 YearDiffers { claimed: i16, actual: i16 },
51 Duplicate { of: usize },
53 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 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 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#[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 pub fn severity(&self) -> Option<Severity> {
108 self.findings.iter().map(Finding::severity).max()
109 }
110}
111
112impl Engine {
113 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
148pub 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 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 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 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
211fn 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
226fn 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
237pub 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}