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