1use aspero_core::matching::{self, PLAUSIBLE, VERIFIED};
5use aspero_core::publication::first_page;
6use aspero_core::{Agreement, Claim, Update, UpdateKind};
7
8use crate::resolve::{Candidate, ClaimExt, ResolveReport};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum Verdict {
13 Verified,
15 Plausible,
17 Mismatch,
20 NotFound,
22 Unverifiable,
24}
25
26impl Verdict {
27 pub fn as_str(self) -> &'static str {
28 match self {
29 Verdict::Verified => "verified",
30 Verdict::Plausible => "plausible",
31 Verdict::Mismatch => "mismatch",
32 Verdict::NotFound => "not_found",
33 Verdict::Unverifiable => "unverifiable",
34 }
35 }
36}
37
38#[derive(Debug, Clone)]
39#[non_exhaustive]
40pub struct Verification {
41 pub verdict: Verdict,
42 pub reason: String,
45 pub why: Reason,
47 pub notes: Vec<Update>,
50 pub best: Option<usize>,
54 pub report: ResolveReport,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
60#[non_exhaustive]
61pub enum Reason {
62 Matches,
64 PartlyMatches { differs: Vec<Difference> },
66 Closest { differs: Vec<Difference> },
69 NoMatch,
71 IdExists,
74 IdAgrees,
76 IdPartlyMatches { differs: Vec<Difference> },
79 IdElsewhere { cited: usize },
82 IdDisagrees,
85 IdMissingElsewhere,
88 IdMissing,
91 ProvidersFailed,
93}
94
95impl Reason {
96 pub fn as_str(&self) -> &'static str {
98 match self {
99 Reason::Matches => "matches",
100 Reason::PartlyMatches { .. } => "partly_matches",
101 Reason::Closest { .. } => "closest",
102 Reason::NoMatch => "no_match",
103 Reason::IdExists => "id_exists",
104 Reason::IdAgrees => "id_agrees",
105 Reason::IdPartlyMatches { .. } => "id_partly_matches",
106 Reason::IdElsewhere { .. } => "id_elsewhere",
107 Reason::IdDisagrees => "id_disagrees",
108 Reason::IdMissingElsewhere => "id_missing_elsewhere",
109 Reason::IdMissing => "id_missing",
110 Reason::ProvidersFailed => "providers_failed",
111 }
112 }
113
114 pub fn differs(&self) -> &[Difference] {
116 match self {
117 Reason::PartlyMatches { differs }
118 | Reason::Closest { differs }
119 | Reason::IdPartlyMatches { differs } => differs,
120 _ => &[],
121 }
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
128#[non_exhaustive]
129pub struct Difference {
130 pub field: CitedField,
131 pub actual: Option<String>,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135#[non_exhaustive]
136pub enum CitedField {
137 Title,
138 Authors,
139 Year,
140 Journal,
141 Volume,
142 FirstPage,
143}
144
145impl CitedField {
146 pub fn as_str(self) -> &'static str {
147 match self {
148 CitedField::Title => "title",
149 CitedField::Authors => "authors",
150 CitedField::Year => "year",
151 CitedField::Journal => "journal",
152 CitedField::Volume => "volume",
153 CitedField::FirstPage => "first_page",
154 }
155 }
156}
157
158impl std::fmt::Display for Difference {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 f.write_str(&self.field.as_str().replace('_', " "))?;
161 match (&self.actual, self.field) {
162 (Some(v), CitedField::Journal) => write!(f, " (\"{v}\")"),
163 (Some(v), _) => write!(f, " ({v})"),
164 (None, _) => Ok(()),
165 }
166 }
167}
168
169fn near_miss(c: &Candidate) -> bool {
174 let a = &c.assessment;
175 a.title.agreement == Agreement::Match
176 && (a.authors.agreement == Agreement::Match || a.year.agreement == Agreement::Match)
177}
178
179fn differences(c: &Candidate, known_only: bool) -> Vec<Difference> {
183 let a = &c.assessment;
184 let w = &c.record.work;
185 let off = |check: &matching::FieldCheck| match known_only {
186 true => matches!(check.agreement, Agreement::Mismatch | Agreement::Partial),
187 false => check.agreement != Agreement::Match,
188 };
189 let diff = |field, actual: Option<String>| Difference { field, actual };
190 [
191 off(&a.title).then(|| diff(CitedField::Title, None)),
192 off(&a.authors).then(|| diff(CitedField::Authors, None)),
193 matches!(a.year.agreement, Agreement::Mismatch | Agreement::Partial)
194 .then(|| diff(CitedField::Year, w.issued.map(|d| d.year().to_string()))),
195 (a.container.agreement == Agreement::Mismatch)
196 .then(|| diff(CitedField::Journal, w.container_title.clone())),
197 (a.volume.agreement == Agreement::Mismatch)
198 .then(|| diff(CitedField::Volume, w.volume.clone())),
199 (a.page.agreement == Agreement::Mismatch).then(|| {
200 let first = w.page.as_deref().map(|p| first_page(p).to_owned());
201 diff(CitedField::FirstPage, first)
202 }),
203 ]
204 .into_iter()
205 .flatten()
206 .collect()
207}
208
209fn clause(differs: &[Difference]) -> Option<String> {
212 let fields: Vec<String> = differs.iter().map(ToString::to_string).collect();
213 match fields.len() {
214 0 => None,
215 1 => Some(format!("its {} differs", fields[0])),
216 _ => Some(format!("its {} differ", fields.join(" and "))),
217 }
218}
219
220fn describe(c: &Candidate) -> String {
221 let w = &c.record.work;
222 let title = w.title.as_deref().unwrap_or("(untitled)");
223 let year = w
224 .issued
225 .map(|d| format!(" ({})", d.year()))
226 .unwrap_or_default();
227 let id = c.ids.first().map(|i| format!(", {i}")).unwrap_or_default();
228 format!("\"{title}\"{year}{id}")
229}
230
231pub(crate) fn judge(claim: &Claim, report: ResolveReport) -> Verification {
233 let (verdict, why, best) = decide(claim, &report);
234 let notes: Vec<Update> = best
237 .map(|i| &report.candidates[i])
238 .into_iter()
239 .flat_map(|c| {
240 [
241 c.retraction(),
242 c.updates.iter().find(|u| u.kind == UpdateKind::NewVersion),
243 ]
244 })
245 .flatten()
246 .cloned()
247 .collect();
248 let mut reason = explain(claim, &report, &why, best);
249 if !notes.is_empty() {
250 let notes: Vec<String> = notes.iter().map(describe_update).collect();
251 reason = format!("{reason}; note: {}", notes.join("; "));
252 }
253 Verification {
254 verdict,
255 reason,
256 why,
257 notes,
258 best,
259 report,
260 }
261}
262
263fn decide(claim: &Claim, report: &ResolveReport) -> (Verdict, Reason, Option<usize>) {
264 let best = report.candidates.first();
265 let best_conf = best.map_or(0.0, |c| c.assessment.confidence);
266 let failed = !report.errors.is_empty()
267 || report
268 .lookups
269 .iter()
270 .any(|l| l.record.is_none() && !l.is_not_found());
271
272 if claim.ids.is_empty() {
273 return match best {
274 Some(_) if best_conf >= VERIFIED => (Verdict::Verified, Reason::Matches, Some(0)),
275 Some(c) if best_conf >= PLAUSIBLE => (
276 Verdict::Plausible,
277 Reason::PartlyMatches {
278 differs: differences(c, true),
279 },
280 Some(0),
281 ),
282 Some(c) if near_miss(c) => (
283 Verdict::Mismatch,
284 Reason::Closest {
285 differs: differences(c, false),
286 },
287 Some(0),
288 ),
289 _ if failed => (Verdict::Unverifiable, Reason::ProvidersFailed, None),
290 _ => (Verdict::NotFound, Reason::NoMatch, None),
291 };
292 }
293 let by_id = report
294 .candidates
295 .iter()
296 .position(|c| c.assessment.ids == Agreement::Match);
297 let elsewhere = |skip: Option<usize>| {
298 report
299 .candidates
300 .iter()
301 .enumerate()
302 .position(|(i, c)| Some(i) != skip && c.assessment.confidence >= PLAUSIBLE)
303 };
304 match by_id.map(|i| (i, &report.candidates[i])) {
305 Some((i, _)) if !claim.is_descriptive() => (Verdict::Verified, Reason::IdExists, Some(i)),
306 Some((i, c)) if c.assessment.confidence >= VERIFIED => {
307 (Verdict::Verified, Reason::IdAgrees, Some(i))
308 }
309 Some((i, c)) if c.assessment.confidence >= PLAUSIBLE => (
310 Verdict::Plausible,
311 Reason::IdPartlyMatches {
312 differs: differences(c, true),
313 },
314 Some(i),
315 ),
316 Some((i, c)) => match elsewhere(Some(i)) {
317 Some(j) => (Verdict::Mismatch, Reason::IdElsewhere { cited: i }, Some(j)),
318 None if near_miss(c) => (
321 Verdict::Plausible,
322 Reason::IdPartlyMatches {
323 differs: differences(c, true),
324 },
325 Some(i),
326 ),
327 None => (Verdict::Mismatch, Reason::IdDisagrees, Some(i)),
328 },
329 None if failed => (Verdict::Unverifiable, Reason::ProvidersFailed, None),
330 None => match elsewhere(None) {
331 Some(j) => (Verdict::Mismatch, Reason::IdMissingElsewhere, Some(j)),
332 None => (Verdict::NotFound, Reason::IdMissing, None),
333 },
334 }
335}
336
337fn explain(claim: &Claim, report: &ResolveReport, why: &Reason, best: Option<usize>) -> String {
339 let claimed = claim
340 .ids
341 .iter()
342 .map(ToString::to_string)
343 .collect::<Vec<_>>()
344 .join(", ");
345 let work = || {
346 best.map(|i| describe(&report.candidates[i]))
347 .unwrap_or_default()
348 };
349 let details = || clause(why.differs());
350 match why {
351 Reason::Matches => format!("matches {}", work()),
352 Reason::PartlyMatches { .. } => format!(
353 "partly matches {}; {}",
354 work(),
355 details().unwrap_or_else(|| "check the details".into())
356 ),
357 Reason::Closest { .. } => format!(
358 "closest work is {}, but {}",
359 work(),
360 details().unwrap_or_else(|| "its details differ".into())
361 ),
362 Reason::NoMatch => "no matching work found".into(),
363 Reason::IdExists => format!("{claimed} exists: {}", work()),
364 Reason::IdAgrees => format!("{claimed} and the citation agree: {}", work()),
365 Reason::IdPartlyMatches { .. } => format!(
366 "{claimed} exists and partly matches the citation: {}{}",
367 work(),
368 details().map(|d| format!("; {d}")).unwrap_or_default()
369 ),
370 Reason::IdElsewhere { cited } => format!(
371 "{claimed} is {}, but the citation matches {}",
372 describe(&report.candidates[*cited]),
373 work()
374 ),
375 Reason::IdDisagrees => {
376 format!("{claimed} is {}, which does not match the citation", work())
377 }
378 Reason::IdMissingElsewhere => {
379 format!("{claimed} does not exist; the citation matches {}", work())
380 }
381 Reason::IdMissing => format!("{claimed} does not exist and no matching work was found"),
382 Reason::ProvidersFailed if claim.ids.is_empty() => {
383 "providers failed; nothing conclusive".into()
384 }
385 Reason::ProvidersFailed => format!("could not look up {claimed}: providers failed"),
386 }
387}
388
389pub fn describe_update(u: &Update) -> String {
391 if u.kind == UpdateKind::NewVersion {
393 let by = u
394 .notice
395 .as_ref()
396 .map(|n| format!(" by {n}"))
397 .unwrap_or_default();
398 let date = u.date.map(|d| format!(" ({d})")).unwrap_or_default();
399 return format!("superseded{by}{date}");
400 }
401 let what = match &u.kind {
402 UpdateKind::Retraction => "RETRACTED".to_owned(),
403 UpdateKind::Withdrawal => "WITHDRAWN".to_owned(),
404 UpdateKind::ExpressionOfConcern => "expression of concern".to_owned(),
405 UpdateKind::Correction => "corrected".to_owned(),
406 other => other.as_str().replace('_', " "),
407 };
408 let details: Vec<String> = [
409 u.date.map(|d| d.to_string()),
410 u.notice.as_ref().map(|n| format!("notice {n}")),
411 u.source.as_ref().map(|s| format!("via {s}")),
412 ]
413 .into_iter()
414 .flatten()
415 .collect();
416 if details.is_empty() {
417 what
418 } else {
419 format!("{what} ({})", details.join(", "))
420 }
421}