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 settled = settle_alternatives(claim, &mut report);
182 self.detail_notices(&mut report).await;
183 let mut v = judge(&settled, report);
184 v.read = claim.as_read();
187 v
188 }
189
190 pub fn verify_many<'a>(
194 &'a self,
195 claims: &'a [Claim],
196 limit: u32,
197 ) -> impl Stream<Item = (usize, Verification)> + 'a {
198 stream::iter(claims.iter().enumerate())
199 .map(move |(i, claim)| async move { (i, self.verify(claim, limit).await) })
200 .buffer_unordered(CLAIM_CONCURRENCY)
201 }
202
203 async fn detail_notices(&self, report: &mut ResolveReport) {
208 let bare = |c: &Candidate| {
209 c.updates
210 .iter()
211 .any(|u| u.kind.is_retraction() && u.notice.is_none())
212 };
213 for entry in &self.entries {
214 let provider = entry.provider.id();
215 let kinds = &entry.provider.capabilities().lookup_kinds;
216 let wanted: Vec<(usize, WorkId)> = report
217 .candidates
218 .iter()
219 .enumerate()
220 .filter(|(_, c)| bare(c) && !c.sightings.iter().any(|s| s.provider == provider))
221 .filter_map(|(i, c)| {
222 Some((
223 i,
224 c.ids.iter().find(|id| kinds.contains(&id.kind()))?.clone(),
225 ))
226 })
227 .collect();
228 if wanted.is_empty() {
229 continue;
230 }
231 let ids: Vec<WorkId> = wanted.iter().map(|(_, id)| id.clone()).collect();
232 let answers = self
233 .lookup_at(entry, &ids, FieldSet::ALL, &mut report.usage)
234 .await;
235 for ((i, _), (_, record)) in wanted.into_iter().zip(answers) {
236 if let Some(record) = record {
237 merge_updates(&mut report.candidates[i].updates, &record.work.updates);
238 }
239 }
240 }
241 }
242
243 pub async fn prefetch(&self, claims: &[Claim]) -> crate::LookupReport {
248 let mut ids: Vec<WorkId> = Vec::new();
249 for id in claims.iter().flat_map(|c| &c.ids) {
250 if !ids.contains(id) {
251 ids.push(id.clone());
252 }
253 }
254 self.lookup(&ids, FieldSet::DEFAULT).await
255 }
256
257 async fn gather(&self, claim: &Claim, limit: u32, stop_when_verified: bool) -> ResolveReport {
258 let mut acc = Accumulator {
259 claim,
260 report: ResolveReport::default(),
261 };
262 let done = |acc: &Accumulator| stop_when_verified && acc.verified();
263 let query = |text: &str, citation: bool| {
264 let mut q = if citation {
265 SearchQuery::citation(text)
266 } else {
267 SearchQuery::new(text)
268 };
269 q.page.limit = limit;
270 q
271 };
272
273 let reference = claim.reference();
276 let matchers: Vec<ProviderId> = match &reference {
277 Some(_) => self.searchers(true),
278 None => Vec::new(),
279 };
280 let lookup = async {
281 match claim.ids.is_empty() {
282 true => None,
283 false => Some(self.lookup(&claim.ids, FieldSet::DEFAULT).await),
284 }
285 };
286 let matched = join_all(matchers.iter().map(|p| {
287 let q = query(reference.as_deref().unwrap_or_default(), true);
288 async move { self.search(Some(p.as_str()), &q).await }
289 }));
290 let (lookup, matched) = (std::pin::pin!(lookup), std::pin::pin!(matched));
291 let (lookup, matched) = match select(lookup, matched).await {
292 Either::Left((lookup, matched)) => (lookup, Either::Left(matched)),
293 Either::Right((matched, lookup)) => (lookup.await, Either::Right(matched)),
294 };
295
296 if let Some(lookup) = lookup {
297 for record in lookup.results.iter().filter_map(|r| r.record.as_ref()) {
298 let sighting = Sighting {
299 provider: record.source.clone(),
300 via: Via::Lookup,
301 rank: 1,
302 score: None,
303 };
304 acc.add(record.clone(), sighting);
305 }
306 acc.usage(lookup.usage);
307 acc.report.lookups = lookup.results;
308 }
309 let matched = match matched {
313 Either::Right(matched) => matched,
314 Either::Left(_) if done(&acc) => Vec::new(),
315 Either::Left(pending) => pending.await,
316 };
317 let mut titles: Vec<String> = claim.title.iter().cloned().collect();
318 for (provider, result) in matchers.iter().zip(matched) {
319 let hits = acc.add_search(provider, Via::CitationMatch, result);
320 if let (None, Some(reference)) = (&claim.title, &reference) {
321 titles.extend(
324 hits.iter()
325 .filter_map(|t| matching::shared_title(t, reference)),
326 );
327 }
328 }
329 if done(&acc) || !claim.is_descriptive() {
331 return acc.finish();
332 }
333
334 titles = distinct(titles);
338 titles.truncate(MAX_TITLE_SEARCHES);
339 let mut jobs: Vec<(ProviderId, SearchQuery, Via)> = Vec::new();
340 for provider in self.searchers(false) {
341 if titles.is_empty()
342 && let Some(text) = &claim.text
343 {
344 let q = query(&matching::search_terms(text), false);
345 jobs.push((provider.clone(), q, Via::KeywordSearch));
346 }
347 for title in &titles {
348 jobs.push((provider.clone(), query(title, false), Via::TitleSearch));
349 }
350 }
351 if let Some(place) = claim.place() {
355 let years = claim.cited_years();
356 for provider in self.placers() {
357 let mut q = query("", false);
358 q.page.limit = PLACE_LIMIT;
359 q.place = Some(place.clone());
360 q.years = years
361 .iter()
362 .min()
363 .zip(years.iter().max())
364 .map(|(&a, &b)| YearRange {
365 from: Some(a.saturating_sub(1)),
366 to: Some(b.saturating_add(1)),
367 });
368 jobs.push((provider, q, Via::PlaceSearch));
369 }
370 }
371 let mut pending: FuturesUnordered<_> = jobs
375 .iter()
376 .enumerate()
377 .map(|(i, (p, q, _))| async move { (i, self.search(Some(p.as_str()), q).await) })
378 .collect();
379 let before = acc.report.clone();
380 let mut answers: Vec<Option<Result<SearchReport, EngineError>>> = vec![None; jobs.len()];
381 while let Some((i, result)) = pending.next().await {
382 answers[i] = Some(result);
383 acc.report = before.clone();
384 for ((provider, _, via), answer) in jobs.iter().zip(&answers) {
385 if let Some(result) = answer {
386 acc.add_search(provider, *via, result.clone());
387 }
388 }
389 if done(&acc) {
390 break;
391 }
392 }
393 acc.finish()
394 }
395
396 fn placers(&self) -> Vec<ProviderId> {
398 self.providers()
399 .filter(|p| {
400 let caps = p.capabilities();
401 caps.search && !caps.citation_search && caps.search_filters.contains(&Filter::Place)
402 })
403 .map(|p| p.id())
404 .collect()
405 }
406
407 fn searchers(&self, matchers: bool) -> Vec<ProviderId> {
409 self.providers()
410 .filter(|p| p.capabilities().search && p.capabilities().citation_search == matchers)
411 .map(|p| p.id())
412 .collect()
413 }
414}
415
416pub(crate) trait ClaimExt {
417 fn is_descriptive(&self) -> bool;
418}
419
420impl ClaimExt for Claim {
421 fn is_descriptive(&self) -> bool {
423 self.text.is_some() || self.title.is_some() || !self.authors.is_empty()
424 }
425}
426
427struct Accumulator<'a> {
428 claim: &'a Claim,
429 report: ResolveReport,
430}
431
432impl Accumulator<'_> {
433 fn add(&mut self, mut record: SourceRecord, sighting: Sighting) {
436 let candidates = &mut self.report.candidates;
437 let existing = candidates.iter_mut().find(|c| {
438 c.ids
439 .iter()
440 .any(|a| record.ids.iter().any(|b| same_work_id(a, b)))
441 });
442 let candidate = match existing {
443 Some(c) => {
444 for id in &record.ids {
445 if !c.ids.iter().any(|known| same_work_id(known, id)) {
446 c.ids.push(id.clone());
447 }
448 }
449 c.sightings.push(sighting);
450 merge_updates(&mut c.updates, &record.work.updates);
451 let pooled: Vec<i16> = years(&c.record).chain(years(&record)).collect();
454 add_years(&mut c.record, &pooled);
455 add_years(&mut record, &pooled);
456 let oa = |r: &SourceRecord| r.work.open_access.clone();
458 let known = oa(&c.record).or_else(|| oa(&record));
459 c.record.work.open_access.clone_from(&known);
460 record.work.open_access = record.work.open_access.take().or(known);
461 c.assessment = matching::assess(self.claim, &c.record);
462 let assessment = matching::assess(self.claim, &record);
463 if assessment.confidence > c.assessment.confidence {
464 c.record = record;
465 c.assessment = assessment;
466 }
467 c
468 }
469 None => {
470 let mut updates = Vec::new();
471 merge_updates(&mut updates, &record.work.updates);
472 candidates.push(Candidate {
473 ids: record.ids.clone(),
474 updates,
475 sightings: vec![sighting],
476 assessment: matching::assess(self.claim, &record),
477 record,
478 });
479 candidates.last_mut().expect("just pushed")
480 }
481 };
482 candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
484 }
485
486 fn add_search(
488 &mut self,
489 provider: &ProviderId,
490 via: Via,
491 result: Result<SearchReport, EngineError>,
492 ) -> Vec<String> {
493 match result {
494 Ok(report) => {
495 if let Some(u) = report.usage {
496 add_usage(&mut self.report.usage, provider, u);
497 }
498 let mut titles = Vec::new();
499 for (i, hit) in report.page.hits.into_iter().enumerate() {
500 let w = &hit.record.work;
503 titles.extend(w.title.clone().or_else(|| w.container_title.clone()));
504 let sighting = Sighting {
505 provider: provider.clone(),
506 via,
507 rank: i + 1,
508 score: hit.score,
509 };
510 self.add(hit.record, sighting);
511 }
512 titles
513 }
514 Err(e) => {
515 self.report.errors.push(e);
516 Vec::new()
517 }
518 }
519 }
520
521 fn usage(&mut self, usage: Vec<(ProviderId, Usage)>) {
522 for (provider, u) in usage {
523 add_usage(&mut self.report.usage, &provider, u);
524 }
525 }
526
527 fn verified(&self) -> bool {
535 self.report.candidates.iter().any(|c| {
536 let vouched = || {
537 c.sightings
538 .iter()
539 .any(|s| s.provider != c.sightings[0].provider)
540 };
541 let corroborated = match c.assessment.ids {
542 Agreement::Match => true,
543 Agreement::Mismatch => vouched(),
544 _ => self.claim.ids.is_empty() && vouched(),
545 };
546 c.assessment.confidence >= DECISIVE && corroborated
547 })
548 }
549
550 fn finish(mut self) -> ResolveReport {
551 rank(&mut self.report.candidates);
552 self.report
553 }
554}
555
556fn settle_alternatives(claim: &Claim, report: &mut ResolveReport) -> Claim {
560 let mut claim = claim.clone();
561 let lookup = |id: &WorkId| report.lookups.iter().find(|l| l.id == *id);
562 let missing = |id: &WorkId| lookup(id).is_some_and(LookupResult::is_not_found);
563 let mut settled: Vec<(WorkId, WorkId)> = Vec::new();
564 for a in &claim.alternatives {
565 if missing(&a.claimed) && lookup(&a.alternative).is_some_and(|l| l.record.is_some()) {
566 settled.push((a.claimed.clone(), a.alternative.clone()));
567 }
568 }
569 for id in claim
573 .ids
574 .iter()
575 .filter(|id| id.kind() == IdKind::Doi && missing(id))
576 {
577 let cited = id.to_string();
578 let longer = report
579 .candidates
580 .iter()
581 .flat_map(|c| &c.ids)
582 .find(|c| c.kind() == IdKind::Doi && c.to_string().starts_with(&cited) && *c != id);
583 if let Some(longer) = longer
584 && !settled.iter().any(|(from, _)| from == id)
585 {
586 settled.push((id.clone(), longer.clone()));
587 }
588 }
589 if claim.alternatives.is_empty() && settled.is_empty() {
590 return claim;
591 }
592 for (from, to) in settled {
593 for id in claim.ids.iter_mut().filter(|id| **id == from) {
594 *id = to.clone();
595 }
596 }
597 report.lookups.retain(|l| claim.ids.contains(&l.id));
598 for c in &mut report.candidates {
599 c.assessment = matching::assess(&claim, &c.record);
600 c.assessment.ids = id_agreement(&claim.ids, &c.ids);
601 }
602 rank(&mut report.candidates);
603 claim
604}
605
606fn rank(candidates: &mut [Candidate]) {
610 let issued = |c: &Candidate| {
611 c.record.work.issued.map_or(i64::MAX, |d| {
612 i64::from(d.year()) * 10_000
613 + i64::from(d.month().unwrap_or(12)) * 100
614 + i64::from(d.day().unwrap_or(31))
615 })
616 };
617 candidates.sort_by(|a, b| {
618 b.assessment
619 .confidence
620 .total_cmp(&a.assessment.confidence)
621 .then(issued(a).cmp(&issued(b)))
622 .then(b.sightings.len().cmp(&a.sightings.len()))
623 });
624}
625
626fn years(record: &SourceRecord) -> impl Iterator<Item = i16> + '_ {
628 let w = &record.work;
629 w.issued
630 .map(|d| d.year())
631 .into_iter()
632 .chain(w.other_years.iter().copied())
633}
634
635fn add_years(record: &mut SourceRecord, pooled: &[i16]) {
638 let issued = record.work.issued.map(|d| d.year());
639 for &y in pooled {
640 if Some(y) != issued && !record.work.other_years.contains(&y) {
641 record.work.other_years.push(y);
642 }
643 }
644 record.work.other_years.sort_unstable();
645}
646
647fn distinct(titles: Vec<String>) -> Vec<String> {
649 let mut seen = Vec::new();
650 titles
651 .into_iter()
652 .filter(|t| {
653 let norm = matching::normalise(t);
654 let new = !norm.is_empty() && !seen.contains(&norm);
655 seen.push(norm);
656 new
657 })
658 .collect()
659}
660
661#[cfg(test)]
662mod tests {
663 use aspero_core::UpdateKind;
664
665 use super::*;
666
667 fn update(kind: UpdateKind, notice: Option<&str>) -> Update {
668 let mut u = Update::new(kind);
669 u.notice = notice.map(|n| n.parse().unwrap());
670 u
671 }
672
673 #[test]
674 fn detailed_notices_replace_bare_flags_without_duplicates() {
675 let mut known = vec![update(UpdateKind::Retraction, None)];
676 let crossref = [
677 update(UpdateKind::Correction, Some("10.1000/c1")),
678 update(UpdateKind::Retraction, Some("10.1000/r")),
679 update(UpdateKind::Correction, Some("10.1000/c2")),
680 ];
681 merge_updates(&mut known, &crossref);
682 merge_updates(&mut known, &crossref); merge_updates(&mut known, &[update(UpdateKind::Retraction, None)]);
684 let summary: Vec<(String, Option<String>)> = known
685 .iter()
686 .map(|u| {
687 (
688 u.kind.as_str().to_owned(),
689 u.notice.as_ref().map(ToString::to_string),
690 )
691 })
692 .collect();
693 assert_eq!(
694 summary,
695 [
696 ("retraction".into(), Some("doi:10.1000/r".into())),
697 ("correction".into(), Some("doi:10.1000/c1".into())),
698 ("correction".into(), Some("doi:10.1000/c2".into())),
699 ]
700 );
701 }
702}