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 c.assessment = matching::assess(self.claim, &c.record);
397 let assessment = matching::assess(self.claim, &record);
398 if assessment.confidence > c.assessment.confidence {
399 c.record = record;
400 c.assessment = assessment;
401 }
402 c
403 }
404 None => {
405 let mut updates = Vec::new();
406 merge_updates(&mut updates, &record.work.updates);
407 candidates.push(Candidate {
408 ids: record.ids.clone(),
409 updates,
410 record,
411 sightings: vec![sighting],
412 assessment,
413 });
414 candidates.last_mut().expect("just pushed")
415 }
416 };
417 candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
419 }
420
421 fn add_search(
423 &mut self,
424 provider: &ProviderId,
425 via: Via,
426 result: Result<SearchReport, EngineError>,
427 ) -> Vec<String> {
428 match result {
429 Ok(report) => {
430 if let Some(u) = report.usage {
431 add_usage(&mut self.report.usage, provider, u);
432 }
433 let mut titles = Vec::new();
434 for (i, hit) in report.page.hits.into_iter().enumerate() {
435 let w = &hit.record.work;
438 titles.extend(w.title.clone().or_else(|| w.container_title.clone()));
439 let sighting = Sighting {
440 provider: provider.clone(),
441 via,
442 rank: i + 1,
443 score: hit.score,
444 };
445 self.add(hit.record, sighting);
446 }
447 titles
448 }
449 Err(e) => {
450 self.report.errors.push(e);
451 Vec::new()
452 }
453 }
454 }
455
456 fn usage(&mut self, usage: Vec<(ProviderId, Usage)>) {
457 for (provider, u) in usage {
458 add_usage(&mut self.report.usage, &provider, u);
459 }
460 }
461
462 fn verified(&self) -> bool {
467 self.report.candidates.iter().any(|c| {
468 let corroborated = match self.claim.ids.is_empty() {
469 true => c
470 .sightings
471 .iter()
472 .any(|s| s.provider != c.sightings[0].provider),
473 false => c.assessment.ids == Agreement::Match,
474 };
475 c.assessment.confidence >= DECISIVE && corroborated
476 })
477 }
478
479 fn finish(mut self) -> ResolveReport {
480 let issued = |c: &Candidate| {
484 c.record.work.issued.map_or(i64::MAX, |d| {
485 i64::from(d.year()) * 10_000
486 + i64::from(d.month().unwrap_or(12)) * 100
487 + i64::from(d.day().unwrap_or(31))
488 })
489 };
490 self.report.candidates.sort_by(|a, b| {
491 b.assessment
492 .confidence
493 .total_cmp(&a.assessment.confidence)
494 .then(issued(a).cmp(&issued(b)))
495 .then(b.sightings.len().cmp(&a.sightings.len()))
496 });
497 self.report
498 }
499}
500
501fn years(record: &SourceRecord) -> impl Iterator<Item = i16> + '_ {
503 let w = &record.work;
504 w.issued
505 .map(|d| d.year())
506 .into_iter()
507 .chain(w.other_years.iter().copied())
508}
509
510fn add_years(record: &mut SourceRecord, pooled: &[i16]) {
513 let issued = record.work.issued.map(|d| d.year());
514 for &y in pooled {
515 if Some(y) != issued && !record.work.other_years.contains(&y) {
516 record.work.other_years.push(y);
517 }
518 }
519 record.work.other_years.sort_unstable();
520}
521
522fn distinct(titles: Vec<String>) -> Vec<String> {
524 let mut seen = Vec::new();
525 titles
526 .into_iter()
527 .filter(|t| {
528 let norm = matching::normalise(t);
529 let new = !norm.is_empty() && !seen.contains(&norm);
530 seen.push(norm);
531 new
532 })
533 .collect()
534}
535
536fn near_miss(c: &Candidate) -> bool {
541 let a = &c.assessment;
542 a.title.agreement == Agreement::Match
543 && (a.authors.agreement == Agreement::Match || a.year.agreement == Agreement::Match)
544}
545
546fn differences(c: &Candidate) -> String {
549 let a = &c.assessment;
550 let year = c
551 .record
552 .work
553 .issued
554 .map(|d| format!(" ({})", d.year()))
555 .unwrap_or_default();
556 let fields: Vec<String> = [
557 (a.title.agreement != Agreement::Match).then(|| "title".to_owned()),
558 (a.authors.agreement != Agreement::Match).then(|| "authors".to_owned()),
559 matches!(a.year.agreement, Agreement::Mismatch | Agreement::Partial)
560 .then(|| format!("year{year}")),
561 ]
562 .into_iter()
563 .flatten()
564 .collect();
565 match fields.len() {
566 0 => "its details differ".into(),
567 1 => format!("its {} differs", fields[0]),
568 _ => format!("its {} differ", fields.join(" and ")),
569 }
570}
571
572fn describe(c: &Candidate) -> String {
573 let w = &c.record.work;
574 let title = w.title.as_deref().unwrap_or("(untitled)");
575 let year = w
576 .issued
577 .map(|d| format!(" ({})", d.year()))
578 .unwrap_or_default();
579 let id = c.ids.first().map(|i| format!(", {i}")).unwrap_or_default();
580 format!("\"{title}\"{year}{id}")
581}
582
583fn judge(claim: &Claim, report: ResolveReport) -> Verification {
585 let best = report.candidates.first();
586 let best_conf = best.map_or(0.0, |c| c.assessment.confidence);
587 let failed = !report.errors.is_empty()
588 || report
589 .lookups
590 .iter()
591 .any(|l| l.record.is_none() && !l.is_not_found());
592
593 let (verdict, reason, best_index) = if claim.ids.is_empty() {
594 match best {
595 Some(c) if best_conf >= VERIFIED => (
596 Verdict::Verified,
597 format!("matches {}", describe(c)),
598 Some(0),
599 ),
600 Some(c) if best_conf >= PLAUSIBLE => (
601 Verdict::Plausible,
602 format!("partly matches {}; check the details", describe(c)),
603 Some(0),
604 ),
605 Some(c) if near_miss(c) => (
606 Verdict::Mismatch,
607 format!("closest work is {}, but {}", describe(c), differences(c)),
608 Some(0),
609 ),
610 _ if failed => (
611 Verdict::Unverifiable,
612 "providers failed; nothing conclusive".into(),
613 None,
614 ),
615 _ => (Verdict::NotFound, "no matching work found".into(), None),
616 }
617 } else {
618 let claimed = claim
619 .ids
620 .iter()
621 .map(ToString::to_string)
622 .collect::<Vec<_>>()
623 .join(", ");
624 let by_id = report
625 .candidates
626 .iter()
627 .position(|c| c.assessment.ids == Agreement::Match);
628 let elsewhere = |skip: Option<usize>| {
629 report
630 .candidates
631 .iter()
632 .enumerate()
633 .find(|(i, c)| Some(*i) != skip && c.assessment.confidence >= PLAUSIBLE)
634 };
635 match by_id.map(|i| (i, &report.candidates[i])) {
636 Some((i, c)) if !claim.is_descriptive() => (
637 Verdict::Verified,
638 format!("{claimed} exists: {}", describe(c)),
639 Some(i),
640 ),
641 Some((i, c)) if c.assessment.confidence >= VERIFIED => (
642 Verdict::Verified,
643 format!("{claimed} and the citation agree: {}", describe(c)),
644 Some(i),
645 ),
646 Some((i, c)) if c.assessment.confidence >= PLAUSIBLE => (
647 Verdict::Plausible,
648 format!(
649 "{claimed} exists and partly matches the citation: {}",
650 describe(c)
651 ),
652 Some(i),
653 ),
654 Some((i, c)) => match elsewhere(Some(i)) {
655 Some((j, other)) => (
656 Verdict::Mismatch,
657 format!(
658 "{claimed} is {}, but the citation matches {}",
659 describe(c),
660 describe(other)
661 ),
662 Some(j),
663 ),
664 None => (
665 Verdict::Mismatch,
666 format!(
667 "{claimed} is {}, which does not match the citation",
668 describe(c)
669 ),
670 Some(i),
671 ),
672 },
673 None if failed => (
674 Verdict::Unverifiable,
675 format!("could not look up {claimed}: providers failed"),
676 None,
677 ),
678 None => match elsewhere(None) {
679 Some((j, other)) => (
680 Verdict::Mismatch,
681 format!(
682 "{claimed} does not exist; the citation matches {}",
683 describe(other)
684 ),
685 Some(j),
686 ),
687 None => (
688 Verdict::NotFound,
689 format!("{claimed} does not exist and no matching work was found"),
690 None,
691 ),
692 },
693 }
694 };
695 let reason = match best_index.and_then(|i| report.candidates[i].retraction()) {
697 Some(r) => format!("{reason}; note: {}", describe_update(r)),
698 None => reason,
699 };
700 Verification {
701 verdict,
702 reason,
703 best: best_index,
704 report,
705 }
706}
707
708pub fn describe_update(u: &Update) -> String {
710 let what = match &u.kind {
711 UpdateKind::Retraction => "RETRACTED".to_owned(),
712 UpdateKind::Withdrawal => "WITHDRAWN".to_owned(),
713 UpdateKind::ExpressionOfConcern => "expression of concern".to_owned(),
714 UpdateKind::Correction => "corrected".to_owned(),
715 other => other.as_str().replace('_', " "),
716 };
717 let details: Vec<String> = [
718 u.date.map(|d| d.to_string()),
719 u.notice.as_ref().map(|n| format!("notice {n}")),
720 u.source.as_ref().map(|s| format!("via {s}")),
721 ]
722 .into_iter()
723 .flatten()
724 .collect();
725 if details.is_empty() {
726 what
727 } else {
728 format!("{what} ({})", details.join(", "))
729 }
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735
736 fn update(kind: UpdateKind, notice: Option<&str>) -> Update {
737 let mut u = Update::new(kind);
738 u.notice = notice.map(|n| n.parse().unwrap());
739 u
740 }
741
742 #[test]
743 fn detailed_notices_replace_bare_flags_without_duplicates() {
744 let mut known = vec![update(UpdateKind::Retraction, None)];
745 let crossref = [
746 update(UpdateKind::Correction, Some("10.1000/c1")),
747 update(UpdateKind::Retraction, Some("10.1000/r")),
748 update(UpdateKind::Correction, Some("10.1000/c2")),
749 ];
750 merge_updates(&mut known, &crossref);
751 merge_updates(&mut known, &crossref); merge_updates(&mut known, &[update(UpdateKind::Retraction, None)]);
753 let summary: Vec<(String, Option<String>)> = known
754 .iter()
755 .map(|u| {
756 (
757 u.kind.as_str().to_owned(),
758 u.notice.as_ref().map(ToString::to_string),
759 )
760 })
761 .collect();
762 assert_eq!(
763 summary,
764 [
765 ("retraction".into(), Some("doi:10.1000/r".into())),
766 ("correction".into(), Some("doi:10.1000/c1".into())),
767 ("correction".into(), Some("doi:10.1000/c2".into())),
768 ]
769 );
770 }
771}