1use aspero_core::matching::{self, id_agreement, same_work_id};
22use aspero_core::{
23 Agreement, Assessment, Claim, FieldSet, Filter, IdKind, ProviderId, SearchQuery, SourceRecord,
24 Update, Usage, WorkId, YearRange,
25};
26
27use futures::future::{Either, join_all, select};
28use futures::stream::{self, FuturesUnordered, Stream, StreamExt};
29
30use crate::verdict::{Verification, judge};
31use crate::{Engine, EngineError, LookupResult, SearchReport, add_usage};
32
33const MAX_TITLE_SEARCHES: usize = 2;
36
37const DECISIVE: f64 = 0.95;
39
40const CLAIM_CONCURRENCY: usize = 4;
45const PLACE_LIMIT: u32 = 25;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51#[non_exhaustive]
52pub enum Via {
53 Lookup,
54 CitationMatch,
55 TitleSearch,
56 KeywordSearch,
57 PlaceSearch,
59}
60
61impl Via {
62 pub fn as_str(self) -> &'static str {
63 match self {
64 Via::Lookup => "lookup",
65 Via::CitationMatch => "citation_match",
66 Via::TitleSearch => "title_search",
67 Via::KeywordSearch => "keyword_search",
68 Via::PlaceSearch => "place_search",
69 }
70 }
71}
72
73#[derive(Debug, Clone)]
75pub struct Sighting {
76 pub provider: ProviderId,
77 pub via: Via,
78 pub rank: usize,
80 pub score: Option<f64>,
81}
82
83#[derive(Debug, Clone)]
84pub struct Candidate {
85 pub record: SourceRecord,
87 pub ids: Vec<WorkId>,
89 pub sightings: Vec<Sighting>,
90 pub assessment: Assessment,
91 pub updates: Vec<Update>,
93}
94
95impl Candidate {
96 pub fn retraction(&self) -> Option<&Update> {
98 self.updates.iter().find(|u| u.kind.is_retraction())
99 }
100}
101
102pub(crate) fn merge_updates(known: &mut Vec<Update>, new: &[Update]) {
106 for update in new {
107 let same_kind = |k: &Update| k.kind == update.kind;
108 if known
109 .iter()
110 .any(|k| same_kind(k) && k.notice == update.notice)
111 {
112 continue; }
114 if update.notice.is_none() {
115 if !known.iter().any(same_kind) {
116 known.push(update.clone());
117 }
118 continue; }
120 match known
121 .iter_mut()
122 .find(|k| same_kind(k) && k.notice.is_none())
123 {
124 Some(bare) => *bare = update.clone(),
125 None => known.push(update.clone()),
126 }
127 }
128}
129
130#[derive(Debug, Clone, Default)]
131pub struct ResolveReport {
132 pub candidates: Vec<Candidate>,
134 pub lookups: Vec<LookupResult>,
136 pub errors: Vec<EngineError>,
138 pub usage: Vec<(ProviderId, Usage)>,
139}
140
141impl ResolveReport {
142 pub fn failures(&self) -> Vec<EngineError> {
145 let lookups = self
146 .lookups
147 .iter()
148 .filter(|l| l.record.is_none())
149 .flat_map(|l| &l.attempts)
150 .filter_map(|a| match &a.outcome {
151 crate::Outcome::Failed(error) => Some(EngineError::Provider {
152 provider: a.provider.clone(),
153 error: error.clone(),
154 }),
155 _ => None,
156 });
157 self.errors.iter().cloned().chain(lookups).collect()
158 }
159}
160
161impl Engine {
162 pub async fn resolve(&self, claim: &Claim, limit: u32) -> ResolveReport {
165 self.gather(claim, limit, false).await
166 }
167
168 pub async fn verify(&self, claim: &Claim, limit: u32) -> Verification {
171 let mut wide = claim.clone();
175 for a in &claim.alternatives {
176 if claim.ids.contains(&a.claimed) && !wide.ids.contains(&a.alternative) {
177 wide.ids.push(a.alternative.clone());
178 }
179 }
180 let mut report = self.gather(&wide, limit, true).await;
181 let claim = settle_alternatives(claim, &mut report);
182 self.detail_notices(&mut report).await;
183 judge(&claim, report)
184 }
185
186 pub fn verify_many<'a>(
190 &'a self,
191 claims: &'a [Claim],
192 limit: u32,
193 ) -> impl Stream<Item = (usize, Verification)> + 'a {
194 stream::iter(claims.iter().enumerate())
195 .map(move |(i, claim)| async move { (i, self.verify(claim, limit).await) })
196 .buffer_unordered(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) = (std::pin::pin!(lookup), std::pin::pin!(matched));
287 let (lookup, matched) = match select(lookup, matched).await {
288 Either::Left((lookup, matched)) => (lookup, Either::Left(matched)),
289 Either::Right((matched, lookup)) => (lookup.await, Either::Right(matched)),
290 };
291
292 if let Some(lookup) = lookup {
293 for record in lookup.results.iter().filter_map(|r| r.record.as_ref()) {
294 let sighting = Sighting {
295 provider: record.source.clone(),
296 via: Via::Lookup,
297 rank: 1,
298 score: None,
299 };
300 acc.add(record.clone(), sighting);
301 }
302 acc.usage(lookup.usage);
303 acc.report.lookups = lookup.results;
304 }
305 let matched = match matched {
309 Either::Right(matched) => matched,
310 Either::Left(_) if done(&acc) => Vec::new(),
311 Either::Left(pending) => pending.await,
312 };
313 let mut titles: Vec<String> = claim.title.iter().cloned().collect();
314 for (provider, result) in matchers.iter().zip(matched) {
315 let hits = acc.add_search(provider, Via::CitationMatch, result);
316 if let (None, Some(reference)) = (&claim.title, &reference) {
317 titles.extend(
320 hits.iter()
321 .filter_map(|t| matching::shared_title(t, reference)),
322 );
323 }
324 }
325 if done(&acc) || !claim.is_descriptive() {
327 return acc.finish();
328 }
329
330 titles = distinct(titles);
334 titles.truncate(MAX_TITLE_SEARCHES);
335 let mut jobs: Vec<(ProviderId, SearchQuery, Via)> = Vec::new();
336 for provider in self.searchers(false) {
337 if titles.is_empty()
338 && let Some(text) = &claim.text
339 {
340 let q = query(&matching::search_terms(text), false);
341 jobs.push((provider.clone(), q, Via::KeywordSearch));
342 }
343 for title in &titles {
344 jobs.push((provider.clone(), query(title, false), Via::TitleSearch));
345 }
346 }
347 if let Some(place) = claim.place() {
351 let years = claim.cited_years();
352 for provider in self.placers() {
353 let mut q = query("", false);
354 q.page.limit = PLACE_LIMIT;
355 q.place = Some(place.clone());
356 q.years = years
357 .iter()
358 .min()
359 .zip(years.iter().max())
360 .map(|(&a, &b)| YearRange {
361 from: Some(a - 1),
362 to: Some(b + 1),
363 });
364 jobs.push((provider, q, Via::PlaceSearch));
365 }
366 }
367 let mut pending: FuturesUnordered<_> = jobs
371 .iter()
372 .enumerate()
373 .map(|(i, (p, q, _))| async move { (i, self.search(Some(p.as_str()), q).await) })
374 .collect();
375 let before = acc.report.clone();
376 let mut answers: Vec<Option<Result<SearchReport, EngineError>>> = vec![None; jobs.len()];
377 while let Some((i, result)) = pending.next().await {
378 answers[i] = Some(result);
379 acc.report = before.clone();
380 for ((provider, _, via), answer) in jobs.iter().zip(&answers) {
381 if let Some(result) = answer {
382 acc.add_search(provider, *via, result.clone());
383 }
384 }
385 if done(&acc) {
386 break;
387 }
388 }
389 acc.finish()
390 }
391
392 fn placers(&self) -> Vec<ProviderId> {
394 self.providers()
395 .filter(|p| {
396 let caps = p.capabilities();
397 caps.search && !caps.citation_search && caps.search_filters.contains(&Filter::Place)
398 })
399 .map(|p| p.id())
400 .collect()
401 }
402
403 fn searchers(&self, matchers: bool) -> Vec<ProviderId> {
405 self.providers()
406 .filter(|p| p.capabilities().search && p.capabilities().citation_search == matchers)
407 .map(|p| p.id())
408 .collect()
409 }
410}
411
412pub(crate) trait ClaimExt {
413 fn is_descriptive(&self) -> bool;
414}
415
416impl ClaimExt for Claim {
417 fn is_descriptive(&self) -> bool {
419 self.text.is_some() || self.title.is_some() || !self.authors.is_empty()
420 }
421}
422
423struct Accumulator<'a> {
424 claim: &'a Claim,
425 report: ResolveReport,
426}
427
428impl Accumulator<'_> {
429 fn add(&mut self, mut record: SourceRecord, sighting: Sighting) {
432 let assessment = matching::assess(self.claim, &record);
433 let candidates = &mut self.report.candidates;
434 let existing = candidates.iter_mut().find(|c| {
435 c.ids
436 .iter()
437 .any(|a| record.ids.iter().any(|b| same_work_id(a, b)))
438 });
439 let candidate = match existing {
440 Some(c) => {
441 for id in &record.ids {
442 if !c.ids.iter().any(|known| same_work_id(known, id)) {
443 c.ids.push(id.clone());
444 }
445 }
446 c.sightings.push(sighting);
447 merge_updates(&mut c.updates, &record.work.updates);
448 let pooled: Vec<i16> = years(&c.record).chain(years(&record)).collect();
451 add_years(&mut c.record, &pooled);
452 add_years(&mut record, &pooled);
453 let oa = |r: &SourceRecord| r.work.open_access.clone();
455 let known = oa(&c.record).or_else(|| oa(&record));
456 c.record.work.open_access.clone_from(&known);
457 record.work.open_access = record.work.open_access.take().or(known);
458 c.assessment = matching::assess(self.claim, &c.record);
459 let assessment = matching::assess(self.claim, &record);
460 if assessment.confidence > c.assessment.confidence {
461 c.record = record;
462 c.assessment = assessment;
463 }
464 c
465 }
466 None => {
467 let mut updates = Vec::new();
468 merge_updates(&mut updates, &record.work.updates);
469 candidates.push(Candidate {
470 ids: record.ids.clone(),
471 updates,
472 record,
473 sightings: vec![sighting],
474 assessment,
475 });
476 candidates.last_mut().expect("just pushed")
477 }
478 };
479 candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
481 }
482
483 fn add_search(
485 &mut self,
486 provider: &ProviderId,
487 via: Via,
488 result: Result<SearchReport, EngineError>,
489 ) -> Vec<String> {
490 match result {
491 Ok(report) => {
492 if let Some(u) = report.usage {
493 add_usage(&mut self.report.usage, provider, u);
494 }
495 let mut titles = Vec::new();
496 for (i, hit) in report.page.hits.into_iter().enumerate() {
497 let w = &hit.record.work;
500 titles.extend(w.title.clone().or_else(|| w.container_title.clone()));
501 let sighting = Sighting {
502 provider: provider.clone(),
503 via,
504 rank: i + 1,
505 score: hit.score,
506 };
507 self.add(hit.record, sighting);
508 }
509 titles
510 }
511 Err(e) => {
512 self.report.errors.push(e);
513 Vec::new()
514 }
515 }
516 }
517
518 fn usage(&mut self, usage: Vec<(ProviderId, Usage)>) {
519 for (provider, u) in usage {
520 add_usage(&mut self.report.usage, &provider, u);
521 }
522 }
523
524 fn verified(&self) -> bool {
532 self.report.candidates.iter().any(|c| {
533 let vouched = || {
534 c.sightings
535 .iter()
536 .any(|s| s.provider != c.sightings[0].provider)
537 };
538 let corroborated = match c.assessment.ids {
539 Agreement::Match => true,
540 Agreement::Mismatch => vouched(),
541 _ => self.claim.ids.is_empty() && vouched(),
542 };
543 c.assessment.confidence >= DECISIVE && corroborated
544 })
545 }
546
547 fn finish(mut self) -> ResolveReport {
548 rank(&mut self.report.candidates);
549 self.report
550 }
551}
552
553fn settle_alternatives(claim: &Claim, report: &mut ResolveReport) -> Claim {
557 let mut claim = claim.clone();
558 let lookup = |id: &WorkId| report.lookups.iter().find(|l| l.id == *id);
559 let missing = |id: &WorkId| lookup(id).is_some_and(LookupResult::is_not_found);
560 let mut settled: Vec<(WorkId, WorkId)> = Vec::new();
561 for a in &claim.alternatives {
562 if missing(&a.claimed) && lookup(&a.alternative).is_some_and(|l| l.record.is_some()) {
563 settled.push((a.claimed.clone(), a.alternative.clone()));
564 }
565 }
566 for id in claim
570 .ids
571 .iter()
572 .filter(|id| id.kind() == IdKind::Doi && missing(id))
573 {
574 let cited = id.to_string();
575 let longer = report
576 .candidates
577 .iter()
578 .flat_map(|c| &c.ids)
579 .find(|c| c.kind() == IdKind::Doi && c.to_string().starts_with(&cited) && *c != id);
580 if let Some(longer) = longer
581 && !settled.iter().any(|(from, _)| from == id)
582 {
583 settled.push((id.clone(), longer.clone()));
584 }
585 }
586 if claim.alternatives.is_empty() && settled.is_empty() {
587 return claim;
588 }
589 for (from, to) in settled {
590 for id in claim.ids.iter_mut().filter(|id| **id == from) {
591 *id = to.clone();
592 }
593 }
594 report.lookups.retain(|l| claim.ids.contains(&l.id));
595 for c in &mut report.candidates {
596 c.assessment = matching::assess(&claim, &c.record);
597 c.assessment.ids = id_agreement(&claim.ids, &c.ids);
598 }
599 rank(&mut report.candidates);
600 claim
601}
602
603fn rank(candidates: &mut [Candidate]) {
607 let issued = |c: &Candidate| {
608 c.record.work.issued.map_or(i64::MAX, |d| {
609 i64::from(d.year()) * 10_000
610 + i64::from(d.month().unwrap_or(12)) * 100
611 + i64::from(d.day().unwrap_or(31))
612 })
613 };
614 candidates.sort_by(|a, b| {
615 b.assessment
616 .confidence
617 .total_cmp(&a.assessment.confidence)
618 .then(issued(a).cmp(&issued(b)))
619 .then(b.sightings.len().cmp(&a.sightings.len()))
620 });
621}
622
623fn years(record: &SourceRecord) -> impl Iterator<Item = i16> + '_ {
625 let w = &record.work;
626 w.issued
627 .map(|d| d.year())
628 .into_iter()
629 .chain(w.other_years.iter().copied())
630}
631
632fn add_years(record: &mut SourceRecord, pooled: &[i16]) {
635 let issued = record.work.issued.map(|d| d.year());
636 for &y in pooled {
637 if Some(y) != issued && !record.work.other_years.contains(&y) {
638 record.work.other_years.push(y);
639 }
640 }
641 record.work.other_years.sort_unstable();
642}
643
644fn distinct(titles: Vec<String>) -> Vec<String> {
646 let mut seen = Vec::new();
647 titles
648 .into_iter()
649 .filter(|t| {
650 let norm = matching::normalise(t);
651 let new = !norm.is_empty() && !seen.contains(&norm);
652 seen.push(norm);
653 new
654 })
655 .collect()
656}
657
658#[cfg(test)]
659mod tests {
660 use aspero_core::UpdateKind;
661
662 use super::*;
663
664 fn update(kind: UpdateKind, notice: Option<&str>) -> Update {
665 let mut u = Update::new(kind);
666 u.notice = notice.map(|n| n.parse().unwrap());
667 u
668 }
669
670 #[test]
671 fn detailed_notices_replace_bare_flags_without_duplicates() {
672 let mut known = vec![update(UpdateKind::Retraction, None)];
673 let crossref = [
674 update(UpdateKind::Correction, Some("10.1000/c1")),
675 update(UpdateKind::Retraction, Some("10.1000/r")),
676 update(UpdateKind::Correction, Some("10.1000/c2")),
677 ];
678 merge_updates(&mut known, &crossref);
679 merge_updates(&mut known, &crossref); merge_updates(&mut known, &[update(UpdateKind::Retraction, None)]);
681 let summary: Vec<(String, Option<String>)> = known
682 .iter()
683 .map(|u| {
684 (
685 u.kind.as_str().to_owned(),
686 u.notice.as_ref().map(ToString::to_string),
687 )
688 })
689 .collect();
690 assert_eq!(
691 summary,
692 [
693 ("retraction".into(), Some("doi:10.1000/r".into())),
694 ("correction".into(), Some("doi:10.1000/c1".into())),
695 ("correction".into(), Some("doi:10.1000/c2".into())),
696 ]
697 );
698 }
699}