1use aspero_core::matching::{self, PLAUSIBLE, VERIFIED, WorkKind};
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 read: Claim,
54 pub best: Option<usize>,
58 pub report: ResolveReport,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
64#[non_exhaustive]
65pub enum Reason {
66 Matches,
68 PartlyMatches { differs: Vec<Difference> },
71 Unconfirmed,
75 Closest { differs: Vec<Difference> },
78 NoMatch,
80 IdExists,
83 IdAgrees,
85 IdPartlyMatches { differs: Vec<Difference> },
89 IdElsewhere { cited: usize },
92 IdDisagrees,
95 IdMissingElsewhere,
98 IdMissing,
101 ProvidersFailed,
103}
104
105impl Reason {
106 pub fn as_str(&self) -> &'static str {
108 match self {
109 Reason::Matches => "matches",
110 Reason::PartlyMatches { .. } => "partly_matches",
111 Reason::Unconfirmed => "unconfirmed",
112 Reason::Closest { .. } => "closest",
113 Reason::NoMatch => "no_match",
114 Reason::IdExists => "id_exists",
115 Reason::IdAgrees => "id_agrees",
116 Reason::IdPartlyMatches { .. } => "id_partly_matches",
117 Reason::IdElsewhere { .. } => "id_elsewhere",
118 Reason::IdDisagrees => "id_disagrees",
119 Reason::IdMissingElsewhere => "id_missing_elsewhere",
120 Reason::IdMissing => "id_missing",
121 Reason::ProvidersFailed => "providers_failed",
122 }
123 }
124
125 pub fn differs(&self) -> &[Difference] {
127 match self {
128 Reason::PartlyMatches { differs }
129 | Reason::Closest { differs }
130 | Reason::IdPartlyMatches { differs } => differs,
131 _ => &[],
132 }
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
139#[non_exhaustive]
140pub struct Difference {
141 pub field: CitedField,
142 pub actual: Option<String>,
143 pub cited: Option<String>,
148 pub kind: Option<WorkKind>,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153#[non_exhaustive]
154pub enum CitedField {
155 Title,
156 Authors,
157 Year,
158 Journal,
159 Volume,
160 FirstPage,
161 Kind,
164}
165
166impl CitedField {
167 pub fn as_str(self) -> &'static str {
168 match self {
169 CitedField::Title => "title",
170 CitedField::Authors => "authors",
171 CitedField::Year => "year",
172 CitedField::Journal => "journal",
173 CitedField::Volume => "volume",
174 CitedField::FirstPage => "first_page",
175 CitedField::Kind => "kind",
176 }
177 }
178}
179
180impl std::fmt::Display for Difference {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 f.write_str(&self.field.as_str().replace('_', " "))?;
183 match (&self.actual, self.field) {
184 (Some(v), CitedField::Journal) => write!(f, " (\"{v}\")"),
185 (Some(v), _) => write!(f, " ({v})"),
186 (None, _) => Ok(()),
187 }
188 }
189}
190
191fn near_miss(c: &Candidate) -> bool {
196 let a = &c.assessment;
197 a.title.agreement == Agreement::Match
198 && (a.authors.agreement == Agreement::Match || a.year.agreement == Agreement::Match)
199}
200
201fn differences(claim: &Claim, c: &Candidate, known_only: bool) -> Vec<Difference> {
205 let a = &c.assessment;
206 let w = &c.record.work;
207 let off = |check: &matching::FieldCheck| match known_only {
208 true => matches!(check.agreement, Agreement::Mismatch | Agreement::Partial),
209 false => check.agreement != Agreement::Match,
210 };
211 let read = claim.as_read();
212 let diff = |field, actual: Option<String>, cited: Option<String>| Difference {
213 field,
214 actual,
215 cited,
216 kind: None,
217 };
218 let authors = (!claim.authors.is_empty()).then(|| claim.authors.join("; "));
219 let actual_year = w.issued.map(|d| d.year());
221 let cited_year = match (read.year, actual_year) {
222 (Some(y), _) => Some(y),
223 (None, Some(actual)) => claim
224 .cited_years()
225 .into_iter()
226 .min_by_key(|y| y.abs_diff(actual)),
227 (None, None) => None,
228 };
229 [
230 off(&a.title).then(|| diff(CitedField::Title, None, claim.title.clone())),
231 off(&a.authors).then(|| diff(CitedField::Authors, None, authors.clone())),
232 matches!(a.year.agreement, Agreement::Mismatch | Agreement::Partial).then(|| {
233 diff(
234 CitedField::Year,
235 actual_year.map(|y| y.to_string()),
236 cited_year.map(|y| y.to_string()),
237 )
238 }),
239 (a.container.agreement == Agreement::Mismatch).then(|| {
240 diff(
241 CitedField::Journal,
242 w.container_title.clone(),
243 claim.container.clone(),
244 )
245 }),
246 (a.volume.agreement == Agreement::Mismatch)
247 .then(|| diff(CitedField::Volume, w.volume.clone(), read.volume.clone())),
248 (a.page.agreement == Agreement::Mismatch).then(|| {
249 let first = w.page.as_deref().map(|p| first_page(p).to_owned());
250 let cited = read.page.as_deref().map(|p| first_page(p).to_owned());
251 diff(CitedField::FirstPage, first, cited)
252 }),
253 w.title
254 .as_deref()
255 .and_then(matching::derivative_work_kind)
256 .filter(|_| !matching::claim_mentions_derivative(claim))
257 .map(|kind| Difference {
258 kind: Some(kind),
259 ..diff(CitedField::Kind, Some(kind.as_str().to_owned()), None)
260 }),
261 ]
262 .into_iter()
263 .flatten()
264 .collect()
265}
266
267fn clause(differs: &[Difference]) -> Option<String> {
270 let fields: Vec<String> = differs.iter().map(ToString::to_string).collect();
271 match fields.len() {
272 0 => None,
273 1 => Some(format!("its {} differs", fields[0])),
274 _ => Some(format!("its {} differ", fields.join(" and "))),
275 }
276}
277
278fn describe(c: &Candidate) -> String {
279 let w = &c.record.work;
280 let title = w.title.as_deref().unwrap_or("(untitled)");
281 let year = w
282 .issued
283 .map(|d| format!(" ({})", d.year()))
284 .unwrap_or_default();
285 let id = c.ids.first().map(|i| format!(", {i}")).unwrap_or_default();
286 format!("\"{title}\"{year}{id}")
287}
288
289pub(crate) fn judge(claim: &Claim, report: ResolveReport) -> Verification {
291 let (verdict, why, best) = decide(claim, &report);
292 let notes: Vec<Update> = best
295 .map(|i| &report.candidates[i])
296 .into_iter()
297 .flat_map(|c| {
298 [
299 c.retraction(),
300 c.updates.iter().find(|u| u.kind == UpdateKind::NewVersion),
301 ]
302 })
303 .flatten()
304 .cloned()
305 .collect();
306 let mut reason = explain(claim, &report, &why, best);
307 if !notes.is_empty() {
308 let notes: Vec<String> = notes.iter().map(describe_update).collect();
309 reason = format!("{reason}; note: {}", notes.join("; "));
310 }
311 Verification {
312 verdict,
313 reason,
314 why,
315 notes,
316 read: claim.as_read(),
317 best,
318 report,
319 }
320}
321
322fn decide(claim: &Claim, report: &ResolveReport) -> (Verdict, Reason, Option<usize>) {
326 match weigh(claim, report) {
327 (
328 verdict,
329 Reason::PartlyMatches { differs } | Reason::IdPartlyMatches { differs },
330 best,
331 ) if differs.is_empty() => (verdict, Reason::Unconfirmed, best),
332 (_, Reason::Closest { differs }, _) if differs.is_empty() => {
333 (Verdict::NotFound, Reason::NoMatch, None)
334 }
335 settled => settled,
336 }
337}
338
339fn weigh(claim: &Claim, report: &ResolveReport) -> (Verdict, Reason, Option<usize>) {
340 let best = report.candidates.first();
341 let best_conf = best.map_or(0.0, |c| c.assessment.confidence);
342 let failed = !report.errors.is_empty()
343 || report
344 .lookups
345 .iter()
346 .any(|l| l.record.is_none() && !l.is_not_found());
347
348 if claim.ids.is_empty() {
349 return match best {
350 Some(_) if best_conf >= VERIFIED => (Verdict::Verified, Reason::Matches, Some(0)),
351 Some(c) if best_conf >= PLAUSIBLE => (
352 Verdict::Plausible,
353 Reason::PartlyMatches {
354 differs: differences(claim, c, true),
355 },
356 Some(0),
357 ),
358 Some(c) if near_miss(c) => (
359 Verdict::Mismatch,
360 Reason::Closest {
361 differs: differences(claim, c, false),
362 },
363 Some(0),
364 ),
365 _ if failed => (Verdict::Unverifiable, Reason::ProvidersFailed, None),
366 _ => (Verdict::NotFound, Reason::NoMatch, None),
367 };
368 }
369 let by_id = report
370 .candidates
371 .iter()
372 .position(|c| c.assessment.ids == Agreement::Match);
373 let elsewhere = |skip: Option<usize>| {
374 report
375 .candidates
376 .iter()
377 .enumerate()
378 .position(|(i, c)| Some(i) != skip && c.assessment.confidence >= PLAUSIBLE)
379 };
380 match by_id.map(|i| (i, &report.candidates[i])) {
381 Some((i, c)) if !claim.is_descriptive() => {
385 let differs: Vec<Difference> = differences(claim, c, true)
386 .into_iter()
387 .filter(|d| d.field != CitedField::Kind)
388 .collect();
389 match differs.is_empty() {
390 true => (Verdict::Verified, Reason::IdExists, Some(i)),
391 false => (
392 Verdict::Plausible,
393 Reason::IdPartlyMatches { differs },
394 Some(i),
395 ),
396 }
397 }
398 Some((i, c)) if c.assessment.confidence >= VERIFIED => {
399 (Verdict::Verified, Reason::IdAgrees, Some(i))
400 }
401 Some((i, c)) if c.assessment.confidence >= PLAUSIBLE => (
402 Verdict::Plausible,
403 Reason::IdPartlyMatches {
404 differs: differences(claim, c, true),
405 },
406 Some(i),
407 ),
408 Some((i, c)) => match elsewhere(Some(i)) {
409 Some(j) => (Verdict::Mismatch, Reason::IdElsewhere { cited: i }, Some(j)),
410 None if near_miss(c) => (
413 Verdict::Plausible,
414 Reason::IdPartlyMatches {
415 differs: differences(claim, c, true),
416 },
417 Some(i),
418 ),
419 None => (Verdict::Mismatch, Reason::IdDisagrees, Some(i)),
420 },
421 None if failed => (Verdict::Unverifiable, Reason::ProvidersFailed, None),
422 None => match elsewhere(None) {
423 Some(j) => (Verdict::Mismatch, Reason::IdMissingElsewhere, Some(j)),
424 None => (Verdict::NotFound, Reason::IdMissing, None),
425 },
426 }
427}
428
429fn explain(claim: &Claim, report: &ResolveReport, why: &Reason, best: Option<usize>) -> String {
431 let claimed = claim
432 .ids
433 .iter()
434 .map(ToString::to_string)
435 .collect::<Vec<_>>()
436 .join(", ");
437 let work = || {
438 best.map(|i| describe(&report.candidates[i]))
439 .unwrap_or_default()
440 };
441 let details = || clause(why.differs());
442 match why {
443 Reason::Matches => format!("matches {}", work()),
444 Reason::PartlyMatches { .. } => format!(
445 "partly matches {}; {}",
446 work(),
447 details().unwrap_or_else(|| "check the details".into())
448 ),
449 Reason::Unconfirmed => format!(
450 "agrees with {} as far as it goes, but gives too little to confirm it",
451 work()
452 ),
453 Reason::Closest { .. } => format!(
454 "closest work is {}, but {}",
455 work(),
456 details().unwrap_or_else(|| "its details differ".into())
457 ),
458 Reason::NoMatch => "no matching work found".into(),
459 Reason::IdExists => format!("{claimed} exists: {}", work()),
460 Reason::IdAgrees => format!("{claimed} and the citation agree: {}", work()),
461 Reason::IdPartlyMatches { .. } => format!(
462 "{claimed} exists and partly matches the citation: {}{}",
463 work(),
464 details().map(|d| format!("; {d}")).unwrap_or_default()
465 ),
466 Reason::IdElsewhere { cited } => format!(
467 "{claimed} is {}, but the citation matches {}",
468 describe(&report.candidates[*cited]),
469 work()
470 ),
471 Reason::IdDisagrees => {
472 format!("{claimed} is {}, which does not match the citation", work())
473 }
474 Reason::IdMissingElsewhere => {
475 format!("{claimed} does not exist; the citation matches {}", work())
476 }
477 Reason::IdMissing => format!("{claimed} does not exist and no matching work was found"),
478 Reason::ProvidersFailed if claim.ids.is_empty() => {
479 "providers failed; nothing conclusive".into()
480 }
481 Reason::ProvidersFailed => format!("could not look up {claimed}: providers failed"),
482 }
483}
484
485pub fn describe_update(u: &Update) -> String {
487 if u.kind == UpdateKind::NewVersion {
489 let by = u
490 .notice
491 .as_ref()
492 .map(|n| format!(" by {n}"))
493 .unwrap_or_default();
494 let date = u.date.map(|d| format!(" ({d})")).unwrap_or_default();
495 return format!("superseded{by}{date}");
496 }
497 let what = match &u.kind {
498 UpdateKind::Retraction => "RETRACTED".to_owned(),
499 UpdateKind::Withdrawal => "WITHDRAWN".to_owned(),
500 UpdateKind::ExpressionOfConcern => "expression of concern".to_owned(),
501 UpdateKind::Correction => "corrected".to_owned(),
502 other => other.as_str().replace('_', " "),
503 };
504 let details: Vec<String> = [
505 u.date.map(|d| d.to_string()),
506 u.notice.as_ref().map(|n| format!("notice {n}")),
507 u.source.as_ref().map(|s| format!("via {s}")),
508 ]
509 .into_iter()
510 .flatten()
511 .collect();
512 if details.is_empty() {
513 what
514 } else {
515 format!("{what} ({})", details.join(", "))
516 }
517}