1use aspero_core::matching::{self, PLAUSIBLE, VERIFIED, id_agreement, same_work_id};
22use aspero_core::{
23 Agreement, Assessment, Claim, FieldSet, ProviderId, SearchQuery, SourceRecord, Update,
24 UpdateKind, Usage, WorkId,
25};
26
27use futures::future::{join, join_all};
28use futures::stream::{self, Stream, StreamExt};
29
30use crate::{Engine, EngineError, LookupResult, SearchReport, add_usage};
31
32const MAX_TITLE_SEARCHES: usize = 2;
35
36const CLAIM_CONCURRENCY: usize = 4;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum Via {
44 Lookup,
45 CitationMatch,
46 TitleSearch,
47 KeywordSearch,
48}
49
50impl Via {
51 pub fn as_str(self) -> &'static str {
52 match self {
53 Via::Lookup => "lookup",
54 Via::CitationMatch => "citation_match",
55 Via::TitleSearch => "title_search",
56 Via::KeywordSearch => "keyword_search",
57 }
58 }
59}
60
61#[derive(Debug, Clone)]
63pub struct Sighting {
64 pub provider: ProviderId,
65 pub via: Via,
66 pub rank: usize,
68 pub score: Option<f64>,
69}
70
71#[derive(Debug, Clone)]
72pub struct Candidate {
73 pub record: SourceRecord,
75 pub ids: Vec<WorkId>,
77 pub sightings: Vec<Sighting>,
78 pub assessment: Assessment,
79 pub updates: Vec<Update>,
81}
82
83impl Candidate {
84 pub fn retraction(&self) -> Option<&Update> {
86 self.updates.iter().find(|u| u.kind.is_retraction())
87 }
88}
89
90fn merge_updates(known: &mut Vec<Update>, new: &[Update]) {
94 for update in new {
95 let same_kind = |k: &Update| k.kind == update.kind;
96 if known
97 .iter()
98 .any(|k| same_kind(k) && k.notice == update.notice)
99 {
100 continue; }
102 if update.notice.is_none() {
103 if !known.iter().any(same_kind) {
104 known.push(update.clone());
105 }
106 continue; }
108 match known
109 .iter_mut()
110 .find(|k| same_kind(k) && k.notice.is_none())
111 {
112 Some(bare) => *bare = update.clone(),
113 None => known.push(update.clone()),
114 }
115 }
116}
117
118#[derive(Debug, Clone, Default)]
119pub struct ResolveReport {
120 pub candidates: Vec<Candidate>,
122 pub lookups: Vec<LookupResult>,
124 pub errors: Vec<EngineError>,
126 pub usage: Vec<(ProviderId, Usage)>,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130#[non_exhaustive]
131pub enum Verdict {
132 Verified,
134 Plausible,
136 Mismatch,
139 NotFound,
141 Unverifiable,
143}
144
145impl Verdict {
146 pub fn as_str(self) -> &'static str {
147 match self {
148 Verdict::Verified => "verified",
149 Verdict::Plausible => "plausible",
150 Verdict::Mismatch => "mismatch",
151 Verdict::NotFound => "not_found",
152 Verdict::Unverifiable => "unverifiable",
153 }
154 }
155}
156
157#[derive(Debug, Clone)]
158pub struct Verification {
159 pub verdict: Verdict,
160 pub reason: String,
162 pub best: Option<usize>,
166 pub report: ResolveReport,
167}
168
169impl Engine {
170 pub async fn resolve(&self, claim: &Claim, limit: u32) -> ResolveReport {
173 self.gather(claim, limit, false).await
174 }
175
176 pub async fn verify(&self, claim: &Claim, limit: u32) -> Verification {
179 let mut report = self.gather(claim, limit, true).await;
180 self.detail_notices(&mut report).await;
181 judge(claim, report)
182 }
183
184 pub fn verify_many<'a>(
187 &'a self,
188 claims: &'a [Claim],
189 limit: u32,
190 ) -> impl Stream<Item = Verification> + 'a {
191 stream::iter(claims)
192 .map(move |claim| self.verify(claim, limit))
193 .buffered(CLAIM_CONCURRENCY)
194 }
195
196 async fn detail_notices(&self, report: &mut ResolveReport) {
201 let bare = |c: &Candidate| {
202 c.updates
203 .iter()
204 .any(|u| u.kind.is_retraction() && u.notice.is_none())
205 };
206 for entry in &self.entries {
207 let provider = entry.provider.id();
208 let kinds = &entry.provider.capabilities().lookup_kinds;
209 let wanted: Vec<(usize, WorkId)> = report
210 .candidates
211 .iter()
212 .enumerate()
213 .filter(|(_, c)| bare(c) && !c.sightings.iter().any(|s| s.provider == provider))
214 .filter_map(|(i, c)| {
215 Some((
216 i,
217 c.ids.iter().find(|id| kinds.contains(&id.kind()))?.clone(),
218 ))
219 })
220 .collect();
221 if wanted.is_empty() {
222 continue;
223 }
224 let ids: Vec<WorkId> = wanted.iter().map(|(_, id)| id.clone()).collect();
225 let answers = self
226 .lookup_at(entry, &ids, FieldSet::ALL, &mut report.usage)
227 .await;
228 for ((i, _), (_, record)) in wanted.into_iter().zip(answers) {
229 if let Some(record) = record {
230 merge_updates(&mut report.candidates[i].updates, &record.work.updates);
231 }
232 }
233 }
234 }
235
236 pub async fn prefetch(&self, claims: &[Claim]) -> crate::LookupReport {
241 let mut ids: Vec<WorkId> = Vec::new();
242 for id in claims.iter().flat_map(|c| &c.ids) {
243 if !ids.contains(id) {
244 ids.push(id.clone());
245 }
246 }
247 self.lookup(&ids, FieldSet::DEFAULT).await
248 }
249
250 async fn gather(&self, claim: &Claim, limit: u32, stop_when_verified: bool) -> ResolveReport {
251 let mut acc = Accumulator {
252 claim,
253 report: ResolveReport::default(),
254 };
255 let done = |acc: &Accumulator| stop_when_verified && acc.verified();
256 let query = |text: &str, citation: bool| {
257 let mut q = if citation {
258 SearchQuery::citation(text)
259 } else {
260 SearchQuery::new(text)
261 };
262 q.page.limit = limit;
263 q
264 };
265
266 let reference = claim.reference();
269 let matchers: Vec<ProviderId> = match &reference {
270 Some(_) => self.searchers(true),
271 None => Vec::new(),
272 };
273 let lookup = async {
274 match claim.ids.is_empty() {
275 true => None,
276 false => Some(self.lookup(&claim.ids, FieldSet::DEFAULT).await),
277 }
278 };
279 let matched = join_all(matchers.iter().map(|p| {
280 let q = query(reference.as_deref().unwrap_or_default(), true);
281 async move { self.search(Some(p.as_str()), &q).await }
282 }));
283 let (lookup, matched) = join(lookup, matched).await;
284
285 if let Some(lookup) = lookup {
286 for record in lookup.results.iter().filter_map(|r| r.record.as_ref()) {
287 let sighting = Sighting {
288 provider: record.source.clone(),
289 via: Via::Lookup,
290 rank: 1,
291 score: None,
292 };
293 acc.add(record.clone(), sighting);
294 }
295 acc.usage(lookup.usage);
296 acc.report.lookups = lookup.results;
297 }
298 let mut titles: Vec<String> = claim.title.iter().cloned().collect();
299 for (provider, result) in matchers.iter().zip(matched) {
300 let hits = acc.add_search(provider, Via::CitationMatch, result);
301 if let (None, Some(reference)) = (&claim.title, &reference) {
302 titles.extend(
305 hits.iter()
306 .filter_map(|t| matching::shared_title(t, reference)),
307 );
308 }
309 }
310 if done(&acc) || !claim.is_descriptive() {
312 return acc.finish();
313 }
314
315 titles = distinct(titles);
319 titles.truncate(MAX_TITLE_SEARCHES);
320 let mut jobs: Vec<(ProviderId, SearchQuery, Via)> = Vec::new();
321 for provider in self.searchers(false) {
322 if titles.is_empty()
323 && let Some(text) = &claim.text
324 {
325 let q = query(&matching::search_terms(text), false);
326 jobs.push((provider.clone(), q, Via::KeywordSearch));
327 }
328 for title in &titles {
329 jobs.push((provider.clone(), query(title, false), Via::TitleSearch));
330 }
331 }
332 let results = join_all(
333 jobs.iter()
334 .map(|(p, q, _)| self.search(Some(p.as_str()), q)),
335 )
336 .await;
337 for ((provider, _, via), result) in jobs.iter().zip(results) {
338 acc.add_search(provider, *via, result);
339 }
340 acc.finish()
341 }
342
343 fn searchers(&self, matchers: bool) -> Vec<ProviderId> {
345 self.providers()
346 .filter(|p| p.capabilities().search && p.capabilities().citation_search == matchers)
347 .map(|p| p.id())
348 .collect()
349 }
350}
351
352trait ClaimExt {
353 fn is_descriptive(&self) -> bool;
354}
355
356impl ClaimExt for Claim {
357 fn is_descriptive(&self) -> bool {
359 self.text.is_some() || self.title.is_some() || !self.authors.is_empty()
360 }
361}
362
363struct Accumulator<'a> {
364 claim: &'a Claim,
365 report: ResolveReport,
366}
367
368impl Accumulator<'_> {
369 fn add(&mut self, record: SourceRecord, sighting: Sighting) {
372 let assessment = matching::assess(self.claim, &record);
373 let candidates = &mut self.report.candidates;
374 let existing = candidates.iter_mut().find(|c| {
375 c.ids
376 .iter()
377 .any(|a| record.ids.iter().any(|b| same_work_id(a, b)))
378 });
379 let candidate = match existing {
380 Some(c) => {
381 for id in &record.ids {
382 if !c.ids.iter().any(|known| same_work_id(known, id)) {
383 c.ids.push(id.clone());
384 }
385 }
386 c.sightings.push(sighting);
387 merge_updates(&mut c.updates, &record.work.updates);
388 if assessment.confidence > c.assessment.confidence {
389 c.record = record;
390 c.assessment = assessment;
391 }
392 c
393 }
394 None => {
395 let mut updates = Vec::new();
396 merge_updates(&mut updates, &record.work.updates);
397 candidates.push(Candidate {
398 ids: record.ids.clone(),
399 updates,
400 record,
401 sightings: vec![sighting],
402 assessment,
403 });
404 candidates.last_mut().expect("just pushed")
405 }
406 };
407 candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
409 }
410
411 fn add_search(
413 &mut self,
414 provider: &ProviderId,
415 via: Via,
416 result: Result<SearchReport, EngineError>,
417 ) -> Vec<String> {
418 match result {
419 Ok(report) => {
420 if let Some(u) = report.usage {
421 add_usage(&mut self.report.usage, provider, u);
422 }
423 let mut titles = Vec::new();
424 for (i, hit) in report.page.hits.into_iter().enumerate() {
425 let w = &hit.record.work;
428 titles.extend(w.title.clone().or_else(|| w.container_title.clone()));
429 let sighting = Sighting {
430 provider: provider.clone(),
431 via,
432 rank: i + 1,
433 score: hit.score,
434 };
435 self.add(hit.record, sighting);
436 }
437 titles
438 }
439 Err(e) => {
440 self.report.errors.push(e);
441 Vec::new()
442 }
443 }
444 }
445
446 fn usage(&mut self, usage: Vec<(ProviderId, Usage)>) {
447 for (provider, u) in usage {
448 add_usage(&mut self.report.usage, &provider, u);
449 }
450 }
451
452 fn verified(&self) -> bool {
455 self.report.candidates.iter().any(|c| {
456 c.assessment.confidence >= VERIFIED
457 && (self.claim.ids.is_empty() || c.assessment.ids == Agreement::Match)
458 })
459 }
460
461 fn finish(mut self) -> ResolveReport {
462 self.report.candidates.sort_by(|a, b| {
463 b.assessment
464 .confidence
465 .total_cmp(&a.assessment.confidence)
466 .then(b.sightings.len().cmp(&a.sightings.len()))
467 });
468 self.report
469 }
470}
471
472fn distinct(titles: Vec<String>) -> Vec<String> {
474 let mut seen = Vec::new();
475 titles
476 .into_iter()
477 .filter(|t| {
478 let norm = matching::normalise(t);
479 let new = !norm.is_empty() && !seen.contains(&norm);
480 seen.push(norm);
481 new
482 })
483 .collect()
484}
485
486fn near_miss(c: &Candidate) -> bool {
491 let a = &c.assessment;
492 a.title.agreement == Agreement::Match
493 && (a.authors.agreement == Agreement::Match || a.year.agreement == Agreement::Match)
494}
495
496fn differences(c: &Candidate) -> String {
499 let a = &c.assessment;
500 let year = c
501 .record
502 .work
503 .issued
504 .map(|d| format!(" ({})", d.year()))
505 .unwrap_or_default();
506 let fields: Vec<String> = [
507 (a.title.agreement != Agreement::Match).then(|| "title".to_owned()),
508 (a.authors.agreement != Agreement::Match).then(|| "authors".to_owned()),
509 matches!(a.year.agreement, Agreement::Mismatch | Agreement::Partial)
510 .then(|| format!("year{year}")),
511 ]
512 .into_iter()
513 .flatten()
514 .collect();
515 match fields.len() {
516 0 => "its details differ".into(),
517 1 => format!("its {} differs", fields[0]),
518 _ => format!("its {} differ", fields.join(" and ")),
519 }
520}
521
522fn describe(c: &Candidate) -> String {
523 let w = &c.record.work;
524 let title = w.title.as_deref().unwrap_or("(untitled)");
525 let year = w
526 .issued
527 .map(|d| format!(" ({})", d.year()))
528 .unwrap_or_default();
529 let id = c.ids.first().map(|i| format!(", {i}")).unwrap_or_default();
530 format!("\"{title}\"{year}{id}")
531}
532
533fn judge(claim: &Claim, report: ResolveReport) -> Verification {
535 let best = report.candidates.first();
536 let best_conf = best.map_or(0.0, |c| c.assessment.confidence);
537 let failed = !report.errors.is_empty()
538 || report
539 .lookups
540 .iter()
541 .any(|l| l.record.is_none() && !l.is_not_found());
542
543 let (verdict, reason, best_index) = if claim.ids.is_empty() {
544 match best {
545 Some(c) if best_conf >= VERIFIED => (
546 Verdict::Verified,
547 format!("matches {}", describe(c)),
548 Some(0),
549 ),
550 Some(c) if best_conf >= PLAUSIBLE => (
551 Verdict::Plausible,
552 format!("partly matches {}; check the details", describe(c)),
553 Some(0),
554 ),
555 Some(c) if near_miss(c) => (
556 Verdict::Mismatch,
557 format!("closest work is {}, but {}", describe(c), differences(c)),
558 Some(0),
559 ),
560 _ if failed => (
561 Verdict::Unverifiable,
562 "providers failed; nothing conclusive".into(),
563 None,
564 ),
565 _ => (Verdict::NotFound, "no matching work found".into(), None),
566 }
567 } else {
568 let claimed = claim
569 .ids
570 .iter()
571 .map(ToString::to_string)
572 .collect::<Vec<_>>()
573 .join(", ");
574 let by_id = report
575 .candidates
576 .iter()
577 .position(|c| c.assessment.ids == Agreement::Match);
578 let elsewhere = |skip: Option<usize>| {
579 report
580 .candidates
581 .iter()
582 .enumerate()
583 .find(|(i, c)| Some(*i) != skip && c.assessment.confidence >= PLAUSIBLE)
584 };
585 match by_id.map(|i| (i, &report.candidates[i])) {
586 Some((i, c)) if !claim.is_descriptive() => (
587 Verdict::Verified,
588 format!("{claimed} exists: {}", describe(c)),
589 Some(i),
590 ),
591 Some((i, c)) if c.assessment.confidence >= VERIFIED => (
592 Verdict::Verified,
593 format!("{claimed} and the citation agree: {}", describe(c)),
594 Some(i),
595 ),
596 Some((i, c)) if c.assessment.confidence >= PLAUSIBLE => (
597 Verdict::Plausible,
598 format!(
599 "{claimed} exists and partly matches the citation: {}",
600 describe(c)
601 ),
602 Some(i),
603 ),
604 Some((i, c)) => match elsewhere(Some(i)) {
605 Some((j, other)) => (
606 Verdict::Mismatch,
607 format!(
608 "{claimed} is {}, but the citation matches {}",
609 describe(c),
610 describe(other)
611 ),
612 Some(j),
613 ),
614 None => (
615 Verdict::Mismatch,
616 format!(
617 "{claimed} is {}, which does not match the citation",
618 describe(c)
619 ),
620 Some(i),
621 ),
622 },
623 None if failed => (
624 Verdict::Unverifiable,
625 format!("could not look up {claimed}: providers failed"),
626 None,
627 ),
628 None => match elsewhere(None) {
629 Some((j, other)) => (
630 Verdict::Mismatch,
631 format!(
632 "{claimed} does not exist; the citation matches {}",
633 describe(other)
634 ),
635 Some(j),
636 ),
637 None => (
638 Verdict::NotFound,
639 format!("{claimed} does not exist and no matching work was found"),
640 None,
641 ),
642 },
643 }
644 };
645 let reason = match best_index.and_then(|i| report.candidates[i].retraction()) {
647 Some(r) => format!("{reason}; note: {}", describe_update(r)),
648 None => reason,
649 };
650 Verification {
651 verdict,
652 reason,
653 best: best_index,
654 report,
655 }
656}
657
658pub fn describe_update(u: &Update) -> String {
660 let what = match &u.kind {
661 UpdateKind::Retraction => "RETRACTED".to_owned(),
662 UpdateKind::Withdrawal => "WITHDRAWN".to_owned(),
663 UpdateKind::ExpressionOfConcern => "expression of concern".to_owned(),
664 UpdateKind::Correction => "corrected".to_owned(),
665 other => other.as_str().replace('_', " "),
666 };
667 let details: Vec<String> = [
668 u.date.map(|d| d.to_string()),
669 u.notice.as_ref().map(|n| format!("notice {n}")),
670 u.source.as_ref().map(|s| format!("via {s}")),
671 ]
672 .into_iter()
673 .flatten()
674 .collect();
675 if details.is_empty() {
676 what
677 } else {
678 format!("{what} ({})", details.join(", "))
679 }
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685
686 fn update(kind: UpdateKind, notice: Option<&str>) -> Update {
687 let mut u = Update::new(kind);
688 u.notice = notice.map(|n| n.parse().unwrap());
689 u
690 }
691
692 #[test]
693 fn detailed_notices_replace_bare_flags_without_duplicates() {
694 let mut known = vec![update(UpdateKind::Retraction, None)];
695 let crossref = [
696 update(UpdateKind::Correction, Some("10.1000/c1")),
697 update(UpdateKind::Retraction, Some("10.1000/r")),
698 update(UpdateKind::Correction, Some("10.1000/c2")),
699 ];
700 merge_updates(&mut known, &crossref);
701 merge_updates(&mut known, &crossref); merge_updates(&mut known, &[update(UpdateKind::Retraction, None)]);
703 let summary: Vec<(String, Option<String>)> = known
704 .iter()
705 .map(|u| {
706 (
707 u.kind.as_str().to_owned(),
708 u.notice.as_ref().map(ToString::to_string),
709 )
710 })
711 .collect();
712 assert_eq!(
713 summary,
714 [
715 ("retraction".into(), Some("doi:10.1000/r".into())),
716 ("correction".into(), Some("doi:10.1000/c1".into())),
717 ("correction".into(), Some("doi:10.1000/c2".into())),
718 ]
719 );
720 }
721}