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 DECISIVE: f64 = 0.95;
38
39const CLAIM_CONCURRENCY: usize = 4;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum Via {
47 Lookup,
48 CitationMatch,
49 TitleSearch,
50 KeywordSearch,
51}
52
53impl Via {
54 pub fn as_str(self) -> &'static str {
55 match self {
56 Via::Lookup => "lookup",
57 Via::CitationMatch => "citation_match",
58 Via::TitleSearch => "title_search",
59 Via::KeywordSearch => "keyword_search",
60 }
61 }
62}
63
64#[derive(Debug, Clone)]
66pub struct Sighting {
67 pub provider: ProviderId,
68 pub via: Via,
69 pub rank: usize,
71 pub score: Option<f64>,
72}
73
74#[derive(Debug, Clone)]
75pub struct Candidate {
76 pub record: SourceRecord,
78 pub ids: Vec<WorkId>,
80 pub sightings: Vec<Sighting>,
81 pub assessment: Assessment,
82 pub updates: Vec<Update>,
84}
85
86impl Candidate {
87 pub fn retraction(&self) -> Option<&Update> {
89 self.updates.iter().find(|u| u.kind.is_retraction())
90 }
91}
92
93pub(crate) fn merge_updates(known: &mut Vec<Update>, new: &[Update]) {
97 for update in new {
98 let same_kind = |k: &Update| k.kind == update.kind;
99 if known
100 .iter()
101 .any(|k| same_kind(k) && k.notice == update.notice)
102 {
103 continue; }
105 if update.notice.is_none() {
106 if !known.iter().any(same_kind) {
107 known.push(update.clone());
108 }
109 continue; }
111 match known
112 .iter_mut()
113 .find(|k| same_kind(k) && k.notice.is_none())
114 {
115 Some(bare) => *bare = update.clone(),
116 None => known.push(update.clone()),
117 }
118 }
119}
120
121#[derive(Debug, Clone, Default)]
122pub struct ResolveReport {
123 pub candidates: Vec<Candidate>,
125 pub lookups: Vec<LookupResult>,
127 pub errors: Vec<EngineError>,
129 pub usage: Vec<(ProviderId, Usage)>,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133#[non_exhaustive]
134pub enum Verdict {
135 Verified,
137 Plausible,
139 Mismatch,
142 NotFound,
144 Unverifiable,
146}
147
148impl Verdict {
149 pub fn as_str(self) -> &'static str {
150 match self {
151 Verdict::Verified => "verified",
152 Verdict::Plausible => "plausible",
153 Verdict::Mismatch => "mismatch",
154 Verdict::NotFound => "not_found",
155 Verdict::Unverifiable => "unverifiable",
156 }
157 }
158}
159
160#[derive(Debug, Clone)]
161pub struct Verification {
162 pub verdict: Verdict,
163 pub reason: String,
165 pub best: Option<usize>,
169 pub report: ResolveReport,
170}
171
172impl Engine {
173 pub async fn resolve(&self, claim: &Claim, limit: u32) -> ResolveReport {
176 self.gather(claim, limit, false).await
177 }
178
179 pub async fn verify(&self, claim: &Claim, limit: u32) -> Verification {
182 let mut report = self.gather(claim, limit, true).await;
183 self.detail_notices(&mut report).await;
184 judge(claim, report)
185 }
186
187 pub fn verify_many<'a>(
190 &'a self,
191 claims: &'a [Claim],
192 limit: u32,
193 ) -> impl Stream<Item = Verification> + 'a {
194 stream::iter(claims)
195 .map(move |claim| self.verify(claim, limit))
196 .buffered(CLAIM_CONCURRENCY)
197 }
198
199 async fn detail_notices(&self, report: &mut ResolveReport) {
204 let bare = |c: &Candidate| {
205 c.updates
206 .iter()
207 .any(|u| u.kind.is_retraction() && u.notice.is_none())
208 };
209 for entry in &self.entries {
210 let provider = entry.provider.id();
211 let kinds = &entry.provider.capabilities().lookup_kinds;
212 let wanted: Vec<(usize, WorkId)> = report
213 .candidates
214 .iter()
215 .enumerate()
216 .filter(|(_, c)| bare(c) && !c.sightings.iter().any(|s| s.provider == provider))
217 .filter_map(|(i, c)| {
218 Some((
219 i,
220 c.ids.iter().find(|id| kinds.contains(&id.kind()))?.clone(),
221 ))
222 })
223 .collect();
224 if wanted.is_empty() {
225 continue;
226 }
227 let ids: Vec<WorkId> = wanted.iter().map(|(_, id)| id.clone()).collect();
228 let answers = self
229 .lookup_at(entry, &ids, FieldSet::ALL, &mut report.usage)
230 .await;
231 for ((i, _), (_, record)) in wanted.into_iter().zip(answers) {
232 if let Some(record) = record {
233 merge_updates(&mut report.candidates[i].updates, &record.work.updates);
234 }
235 }
236 }
237 }
238
239 pub async fn prefetch(&self, claims: &[Claim]) -> crate::LookupReport {
244 let mut ids: Vec<WorkId> = Vec::new();
245 for id in claims.iter().flat_map(|c| &c.ids) {
246 if !ids.contains(id) {
247 ids.push(id.clone());
248 }
249 }
250 self.lookup(&ids, FieldSet::DEFAULT).await
251 }
252
253 async fn gather(&self, claim: &Claim, limit: u32, stop_when_verified: bool) -> ResolveReport {
254 let mut acc = Accumulator {
255 claim,
256 report: ResolveReport::default(),
257 };
258 let done = |acc: &Accumulator| stop_when_verified && acc.verified();
259 let query = |text: &str, citation: bool| {
260 let mut q = if citation {
261 SearchQuery::citation(text)
262 } else {
263 SearchQuery::new(text)
264 };
265 q.page.limit = limit;
266 q
267 };
268
269 let reference = claim.reference();
272 let matchers: Vec<ProviderId> = match &reference {
273 Some(_) => self.searchers(true),
274 None => Vec::new(),
275 };
276 let lookup = async {
277 match claim.ids.is_empty() {
278 true => None,
279 false => Some(self.lookup(&claim.ids, FieldSet::DEFAULT).await),
280 }
281 };
282 let matched = join_all(matchers.iter().map(|p| {
283 let q = query(reference.as_deref().unwrap_or_default(), true);
284 async move { self.search(Some(p.as_str()), &q).await }
285 }));
286 let (lookup, matched) = join(lookup, matched).await;
287
288 if let Some(lookup) = lookup {
289 for record in lookup.results.iter().filter_map(|r| r.record.as_ref()) {
290 let sighting = Sighting {
291 provider: record.source.clone(),
292 via: Via::Lookup,
293 rank: 1,
294 score: None,
295 };
296 acc.add(record.clone(), sighting);
297 }
298 acc.usage(lookup.usage);
299 acc.report.lookups = lookup.results;
300 }
301 let mut titles: Vec<String> = claim.title.iter().cloned().collect();
302 for (provider, result) in matchers.iter().zip(matched) {
303 let hits = acc.add_search(provider, Via::CitationMatch, result);
304 if let (None, Some(reference)) = (&claim.title, &reference) {
305 titles.extend(
308 hits.iter()
309 .filter_map(|t| matching::shared_title(t, reference)),
310 );
311 }
312 }
313 if done(&acc) || !claim.is_descriptive() {
315 return acc.finish();
316 }
317
318 titles = distinct(titles);
322 titles.truncate(MAX_TITLE_SEARCHES);
323 let mut jobs: Vec<(ProviderId, SearchQuery, Via)> = Vec::new();
324 for provider in self.searchers(false) {
325 if titles.is_empty()
326 && let Some(text) = &claim.text
327 {
328 let q = query(&matching::search_terms(text), false);
329 jobs.push((provider.clone(), q, Via::KeywordSearch));
330 }
331 for title in &titles {
332 jobs.push((provider.clone(), query(title, false), Via::TitleSearch));
333 }
334 }
335 let results = join_all(
336 jobs.iter()
337 .map(|(p, q, _)| self.search(Some(p.as_str()), q)),
338 )
339 .await;
340 for ((provider, _, via), result) in jobs.iter().zip(results) {
341 acc.add_search(provider, *via, result);
342 }
343 acc.finish()
344 }
345
346 fn searchers(&self, matchers: bool) -> Vec<ProviderId> {
348 self.providers()
349 .filter(|p| p.capabilities().search && p.capabilities().citation_search == matchers)
350 .map(|p| p.id())
351 .collect()
352 }
353}
354
355trait ClaimExt {
356 fn is_descriptive(&self) -> bool;
357}
358
359impl ClaimExt for Claim {
360 fn is_descriptive(&self) -> bool {
362 self.text.is_some() || self.title.is_some() || !self.authors.is_empty()
363 }
364}
365
366struct Accumulator<'a> {
367 claim: &'a Claim,
368 report: ResolveReport,
369}
370
371impl Accumulator<'_> {
372 fn add(&mut self, mut record: SourceRecord, sighting: Sighting) {
375 let assessment = matching::assess(self.claim, &record);
376 let candidates = &mut self.report.candidates;
377 let existing = candidates.iter_mut().find(|c| {
378 c.ids
379 .iter()
380 .any(|a| record.ids.iter().any(|b| same_work_id(a, b)))
381 });
382 let candidate = match existing {
383 Some(c) => {
384 for id in &record.ids {
385 if !c.ids.iter().any(|known| same_work_id(known, id)) {
386 c.ids.push(id.clone());
387 }
388 }
389 c.sightings.push(sighting);
390 merge_updates(&mut c.updates, &record.work.updates);
391 let pooled: Vec<i16> = years(&c.record).chain(years(&record)).collect();
394 add_years(&mut c.record, &pooled);
395 add_years(&mut record, &pooled);
396 let oa = |r: &SourceRecord| r.work.open_access.clone();
398 let known = oa(&c.record).or_else(|| oa(&record));
399 c.record.work.open_access.clone_from(&known);
400 record.work.open_access = record.work.open_access.take().or(known);
401 c.assessment = matching::assess(self.claim, &c.record);
402 let assessment = matching::assess(self.claim, &record);
403 if assessment.confidence > c.assessment.confidence {
404 c.record = record;
405 c.assessment = assessment;
406 }
407 c
408 }
409 None => {
410 let mut updates = Vec::new();
411 merge_updates(&mut updates, &record.work.updates);
412 candidates.push(Candidate {
413 ids: record.ids.clone(),
414 updates,
415 record,
416 sightings: vec![sighting],
417 assessment,
418 });
419 candidates.last_mut().expect("just pushed")
420 }
421 };
422 candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
424 }
425
426 fn add_search(
428 &mut self,
429 provider: &ProviderId,
430 via: Via,
431 result: Result<SearchReport, EngineError>,
432 ) -> Vec<String> {
433 match result {
434 Ok(report) => {
435 if let Some(u) = report.usage {
436 add_usage(&mut self.report.usage, provider, u);
437 }
438 let mut titles = Vec::new();
439 for (i, hit) in report.page.hits.into_iter().enumerate() {
440 let w = &hit.record.work;
443 titles.extend(w.title.clone().or_else(|| w.container_title.clone()));
444 let sighting = Sighting {
445 provider: provider.clone(),
446 via,
447 rank: i + 1,
448 score: hit.score,
449 };
450 self.add(hit.record, sighting);
451 }
452 titles
453 }
454 Err(e) => {
455 self.report.errors.push(e);
456 Vec::new()
457 }
458 }
459 }
460
461 fn usage(&mut self, usage: Vec<(ProviderId, Usage)>) {
462 for (provider, u) in usage {
463 add_usage(&mut self.report.usage, &provider, u);
464 }
465 }
466
467 fn verified(&self) -> bool {
472 self.report.candidates.iter().any(|c| {
473 let corroborated = match self.claim.ids.is_empty() {
474 true => c
475 .sightings
476 .iter()
477 .any(|s| s.provider != c.sightings[0].provider),
478 false => c.assessment.ids == Agreement::Match,
479 };
480 c.assessment.confidence >= DECISIVE && corroborated
481 })
482 }
483
484 fn finish(mut self) -> ResolveReport {
485 let issued = |c: &Candidate| {
489 c.record.work.issued.map_or(i64::MAX, |d| {
490 i64::from(d.year()) * 10_000
491 + i64::from(d.month().unwrap_or(12)) * 100
492 + i64::from(d.day().unwrap_or(31))
493 })
494 };
495 self.report.candidates.sort_by(|a, b| {
496 b.assessment
497 .confidence
498 .total_cmp(&a.assessment.confidence)
499 .then(issued(a).cmp(&issued(b)))
500 .then(b.sightings.len().cmp(&a.sightings.len()))
501 });
502 self.report
503 }
504}
505
506fn years(record: &SourceRecord) -> impl Iterator<Item = i16> + '_ {
508 let w = &record.work;
509 w.issued
510 .map(|d| d.year())
511 .into_iter()
512 .chain(w.other_years.iter().copied())
513}
514
515fn add_years(record: &mut SourceRecord, pooled: &[i16]) {
518 let issued = record.work.issued.map(|d| d.year());
519 for &y in pooled {
520 if Some(y) != issued && !record.work.other_years.contains(&y) {
521 record.work.other_years.push(y);
522 }
523 }
524 record.work.other_years.sort_unstable();
525}
526
527fn distinct(titles: Vec<String>) -> Vec<String> {
529 let mut seen = Vec::new();
530 titles
531 .into_iter()
532 .filter(|t| {
533 let norm = matching::normalise(t);
534 let new = !norm.is_empty() && !seen.contains(&norm);
535 seen.push(norm);
536 new
537 })
538 .collect()
539}
540
541fn near_miss(c: &Candidate) -> bool {
546 let a = &c.assessment;
547 a.title.agreement == Agreement::Match
548 && (a.authors.agreement == Agreement::Match || a.year.agreement == Agreement::Match)
549}
550
551fn differences(c: &Candidate) -> String {
554 let a = &c.assessment;
555 let year = c
556 .record
557 .work
558 .issued
559 .map(|d| format!(" ({})", d.year()))
560 .unwrap_or_default();
561 let fields: Vec<String> = [
562 (a.title.agreement != Agreement::Match).then(|| "title".to_owned()),
563 (a.authors.agreement != Agreement::Match).then(|| "authors".to_owned()),
564 matches!(a.year.agreement, Agreement::Mismatch | Agreement::Partial)
565 .then(|| format!("year{year}")),
566 ]
567 .into_iter()
568 .flatten()
569 .collect();
570 match fields.len() {
571 0 => "its details differ".into(),
572 1 => format!("its {} differs", fields[0]),
573 _ => format!("its {} differ", fields.join(" and ")),
574 }
575}
576
577fn describe(c: &Candidate) -> String {
578 let w = &c.record.work;
579 let title = w.title.as_deref().unwrap_or("(untitled)");
580 let year = w
581 .issued
582 .map(|d| format!(" ({})", d.year()))
583 .unwrap_or_default();
584 let id = c.ids.first().map(|i| format!(", {i}")).unwrap_or_default();
585 format!("\"{title}\"{year}{id}")
586}
587
588fn judge(claim: &Claim, report: ResolveReport) -> Verification {
590 let best = report.candidates.first();
591 let best_conf = best.map_or(0.0, |c| c.assessment.confidence);
592 let failed = !report.errors.is_empty()
593 || report
594 .lookups
595 .iter()
596 .any(|l| l.record.is_none() && !l.is_not_found());
597
598 let (verdict, reason, best_index) = if claim.ids.is_empty() {
599 match best {
600 Some(c) if best_conf >= VERIFIED => (
601 Verdict::Verified,
602 format!("matches {}", describe(c)),
603 Some(0),
604 ),
605 Some(c) if best_conf >= PLAUSIBLE => (
606 Verdict::Plausible,
607 format!("partly matches {}; check the details", describe(c)),
608 Some(0),
609 ),
610 Some(c) if near_miss(c) => (
611 Verdict::Mismatch,
612 format!("closest work is {}, but {}", describe(c), differences(c)),
613 Some(0),
614 ),
615 _ if failed => (
616 Verdict::Unverifiable,
617 "providers failed; nothing conclusive".into(),
618 None,
619 ),
620 _ => (Verdict::NotFound, "no matching work found".into(), None),
621 }
622 } else {
623 let claimed = claim
624 .ids
625 .iter()
626 .map(ToString::to_string)
627 .collect::<Vec<_>>()
628 .join(", ");
629 let by_id = report
630 .candidates
631 .iter()
632 .position(|c| c.assessment.ids == Agreement::Match);
633 let elsewhere = |skip: Option<usize>| {
634 report
635 .candidates
636 .iter()
637 .enumerate()
638 .find(|(i, c)| Some(*i) != skip && c.assessment.confidence >= PLAUSIBLE)
639 };
640 match by_id.map(|i| (i, &report.candidates[i])) {
641 Some((i, c)) if !claim.is_descriptive() => (
642 Verdict::Verified,
643 format!("{claimed} exists: {}", describe(c)),
644 Some(i),
645 ),
646 Some((i, c)) if c.assessment.confidence >= VERIFIED => (
647 Verdict::Verified,
648 format!("{claimed} and the citation agree: {}", describe(c)),
649 Some(i),
650 ),
651 Some((i, c)) if c.assessment.confidence >= PLAUSIBLE => (
652 Verdict::Plausible,
653 format!(
654 "{claimed} exists and partly matches the citation: {}",
655 describe(c)
656 ),
657 Some(i),
658 ),
659 Some((i, c)) => match elsewhere(Some(i)) {
660 Some((j, other)) => (
661 Verdict::Mismatch,
662 format!(
663 "{claimed} is {}, but the citation matches {}",
664 describe(c),
665 describe(other)
666 ),
667 Some(j),
668 ),
669 None => (
670 Verdict::Mismatch,
671 format!(
672 "{claimed} is {}, which does not match the citation",
673 describe(c)
674 ),
675 Some(i),
676 ),
677 },
678 None if failed => (
679 Verdict::Unverifiable,
680 format!("could not look up {claimed}: providers failed"),
681 None,
682 ),
683 None => match elsewhere(None) {
684 Some((j, other)) => (
685 Verdict::Mismatch,
686 format!(
687 "{claimed} does not exist; the citation matches {}",
688 describe(other)
689 ),
690 Some(j),
691 ),
692 None => (
693 Verdict::NotFound,
694 format!("{claimed} does not exist and no matching work was found"),
695 None,
696 ),
697 },
698 }
699 };
700 let reason = match best_index.and_then(|i| report.candidates[i].retraction()) {
702 Some(r) => format!("{reason}; note: {}", describe_update(r)),
703 None => reason,
704 };
705 Verification {
706 verdict,
707 reason,
708 best: best_index,
709 report,
710 }
711}
712
713pub fn describe_update(u: &Update) -> String {
715 let what = match &u.kind {
716 UpdateKind::Retraction => "RETRACTED".to_owned(),
717 UpdateKind::Withdrawal => "WITHDRAWN".to_owned(),
718 UpdateKind::ExpressionOfConcern => "expression of concern".to_owned(),
719 UpdateKind::Correction => "corrected".to_owned(),
720 other => other.as_str().replace('_', " "),
721 };
722 let details: Vec<String> = [
723 u.date.map(|d| d.to_string()),
724 u.notice.as_ref().map(|n| format!("notice {n}")),
725 u.source.as_ref().map(|s| format!("via {s}")),
726 ]
727 .into_iter()
728 .flatten()
729 .collect();
730 if details.is_empty() {
731 what
732 } else {
733 format!("{what} ({})", details.join(", "))
734 }
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740
741 fn update(kind: UpdateKind, notice: Option<&str>) -> Update {
742 let mut u = Update::new(kind);
743 u.notice = notice.map(|n| n.parse().unwrap());
744 u
745 }
746
747 #[test]
748 fn detailed_notices_replace_bare_flags_without_duplicates() {
749 let mut known = vec![update(UpdateKind::Retraction, None)];
750 let crossref = [
751 update(UpdateKind::Correction, Some("10.1000/c1")),
752 update(UpdateKind::Retraction, Some("10.1000/r")),
753 update(UpdateKind::Correction, Some("10.1000/c2")),
754 ];
755 merge_updates(&mut known, &crossref);
756 merge_updates(&mut known, &crossref); merge_updates(&mut known, &[update(UpdateKind::Retraction, None)]);
758 let summary: Vec<(String, Option<String>)> = known
759 .iter()
760 .map(|u| {
761 (
762 u.kind.as_str().to_owned(),
763 u.notice.as_ref().map(ToString::to_string),
764 )
765 })
766 .collect();
767 assert_eq!(
768 summary,
769 [
770 ("retraction".into(), Some("doi:10.1000/r".into())),
771 ("correction".into(), Some("doi:10.1000/c1".into())),
772 ("correction".into(), Some("doi:10.1000/c2".into())),
773 ]
774 );
775 }
776}