1use aspero_core::matching::{self, id_agreement, same_work_id};
22use aspero_core::{
23 Agreement, Assessment, Claim, FieldSet, ProviderId, SearchQuery, SourceRecord, Update, Usage,
24 WorkId,
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;
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum Via {
50 Lookup,
51 CitationMatch,
52 TitleSearch,
53 KeywordSearch,
54}
55
56impl Via {
57 pub fn as_str(self) -> &'static str {
58 match self {
59 Via::Lookup => "lookup",
60 Via::CitationMatch => "citation_match",
61 Via::TitleSearch => "title_search",
62 Via::KeywordSearch => "keyword_search",
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct Sighting {
70 pub provider: ProviderId,
71 pub via: Via,
72 pub rank: usize,
74 pub score: Option<f64>,
75}
76
77#[derive(Debug, Clone)]
78pub struct Candidate {
79 pub record: SourceRecord,
81 pub ids: Vec<WorkId>,
83 pub sightings: Vec<Sighting>,
84 pub assessment: Assessment,
85 pub updates: Vec<Update>,
87}
88
89impl Candidate {
90 pub fn retraction(&self) -> Option<&Update> {
92 self.updates.iter().find(|u| u.kind.is_retraction())
93 }
94}
95
96pub(crate) fn merge_updates(known: &mut Vec<Update>, new: &[Update]) {
100 for update in new {
101 let same_kind = |k: &Update| k.kind == update.kind;
102 if known
103 .iter()
104 .any(|k| same_kind(k) && k.notice == update.notice)
105 {
106 continue; }
108 if update.notice.is_none() {
109 if !known.iter().any(same_kind) {
110 known.push(update.clone());
111 }
112 continue; }
114 match known
115 .iter_mut()
116 .find(|k| same_kind(k) && k.notice.is_none())
117 {
118 Some(bare) => *bare = update.clone(),
119 None => known.push(update.clone()),
120 }
121 }
122}
123
124#[derive(Debug, Clone, Default)]
125pub struct ResolveReport {
126 pub candidates: Vec<Candidate>,
128 pub lookups: Vec<LookupResult>,
130 pub errors: Vec<EngineError>,
132 pub usage: Vec<(ProviderId, Usage)>,
133}
134
135impl ResolveReport {
136 pub fn failures(&self) -> Vec<EngineError> {
139 let lookups = self
140 .lookups
141 .iter()
142 .filter(|l| l.record.is_none())
143 .flat_map(|l| &l.attempts)
144 .filter_map(|a| match &a.outcome {
145 crate::Outcome::Failed(error) => Some(EngineError::Provider {
146 provider: a.provider.clone(),
147 error: error.clone(),
148 }),
149 _ => None,
150 });
151 self.errors.iter().cloned().chain(lookups).collect()
152 }
153}
154
155impl Engine {
156 pub async fn resolve(&self, claim: &Claim, limit: u32) -> ResolveReport {
159 self.gather(claim, limit, false).await
160 }
161
162 pub async fn verify(&self, claim: &Claim, limit: u32) -> Verification {
165 let mut wide = claim.clone();
169 for a in &claim.alternatives {
170 if claim.ids.contains(&a.claimed) && !wide.ids.contains(&a.alternative) {
171 wide.ids.push(a.alternative.clone());
172 }
173 }
174 let mut report = self.gather(&wide, limit, true).await;
175 let claim = settle_alternatives(claim, &mut report);
176 self.detail_notices(&mut report).await;
177 judge(&claim, report)
178 }
179
180 pub fn verify_many<'a>(
184 &'a self,
185 claims: &'a [Claim],
186 limit: u32,
187 ) -> impl Stream<Item = (usize, Verification)> + 'a {
188 stream::iter(claims.iter().enumerate())
189 .map(move |(i, claim)| async move { (i, self.verify(claim, limit).await) })
190 .buffer_unordered(CLAIM_CONCURRENCY)
191 }
192
193 async fn detail_notices(&self, report: &mut ResolveReport) {
198 let bare = |c: &Candidate| {
199 c.updates
200 .iter()
201 .any(|u| u.kind.is_retraction() && u.notice.is_none())
202 };
203 for entry in &self.entries {
204 let provider = entry.provider.id();
205 let kinds = &entry.provider.capabilities().lookup_kinds;
206 let wanted: Vec<(usize, WorkId)> = report
207 .candidates
208 .iter()
209 .enumerate()
210 .filter(|(_, c)| bare(c) && !c.sightings.iter().any(|s| s.provider == provider))
211 .filter_map(|(i, c)| {
212 Some((
213 i,
214 c.ids.iter().find(|id| kinds.contains(&id.kind()))?.clone(),
215 ))
216 })
217 .collect();
218 if wanted.is_empty() {
219 continue;
220 }
221 let ids: Vec<WorkId> = wanted.iter().map(|(_, id)| id.clone()).collect();
222 let answers = self
223 .lookup_at(entry, &ids, FieldSet::ALL, &mut report.usage)
224 .await;
225 for ((i, _), (_, record)) in wanted.into_iter().zip(answers) {
226 if let Some(record) = record {
227 merge_updates(&mut report.candidates[i].updates, &record.work.updates);
228 }
229 }
230 }
231 }
232
233 pub async fn prefetch(&self, claims: &[Claim]) -> crate::LookupReport {
238 let mut ids: Vec<WorkId> = Vec::new();
239 for id in claims.iter().flat_map(|c| &c.ids) {
240 if !ids.contains(id) {
241 ids.push(id.clone());
242 }
243 }
244 self.lookup(&ids, FieldSet::DEFAULT).await
245 }
246
247 async fn gather(&self, claim: &Claim, limit: u32, stop_when_verified: bool) -> ResolveReport {
248 let mut acc = Accumulator {
249 claim,
250 report: ResolveReport::default(),
251 };
252 let done = |acc: &Accumulator| stop_when_verified && acc.verified();
253 let query = |text: &str, citation: bool| {
254 let mut q = if citation {
255 SearchQuery::citation(text)
256 } else {
257 SearchQuery::new(text)
258 };
259 q.page.limit = limit;
260 q
261 };
262
263 let reference = claim.reference();
266 let matchers: Vec<ProviderId> = match &reference {
267 Some(_) => self.searchers(true),
268 None => Vec::new(),
269 };
270 let lookup = async {
271 match claim.ids.is_empty() {
272 true => None,
273 false => Some(self.lookup(&claim.ids, FieldSet::DEFAULT).await),
274 }
275 };
276 let matched = join_all(matchers.iter().map(|p| {
277 let q = query(reference.as_deref().unwrap_or_default(), true);
278 async move { self.search(Some(p.as_str()), &q).await }
279 }));
280 let (lookup, matched) = (std::pin::pin!(lookup), std::pin::pin!(matched));
281 let (lookup, matched) = match select(lookup, matched).await {
282 Either::Left((lookup, matched)) => (lookup, Either::Left(matched)),
283 Either::Right((matched, lookup)) => (lookup.await, Either::Right(matched)),
284 };
285
286 if let Some(lookup) = lookup {
287 for record in lookup.results.iter().filter_map(|r| r.record.as_ref()) {
288 let sighting = Sighting {
289 provider: record.source.clone(),
290 via: Via::Lookup,
291 rank: 1,
292 score: None,
293 };
294 acc.add(record.clone(), sighting);
295 }
296 acc.usage(lookup.usage);
297 acc.report.lookups = lookup.results;
298 }
299 let matched = match matched {
303 Either::Right(matched) => matched,
304 Either::Left(_) if done(&acc) => Vec::new(),
305 Either::Left(pending) => pending.await,
306 };
307 let mut titles: Vec<String> = claim.title.iter().cloned().collect();
308 for (provider, result) in matchers.iter().zip(matched) {
309 let hits = acc.add_search(provider, Via::CitationMatch, result);
310 if let (None, Some(reference)) = (&claim.title, &reference) {
311 titles.extend(
314 hits.iter()
315 .filter_map(|t| matching::shared_title(t, reference)),
316 );
317 }
318 }
319 if done(&acc) || !claim.is_descriptive() {
321 return acc.finish();
322 }
323
324 titles = distinct(titles);
328 titles.truncate(MAX_TITLE_SEARCHES);
329 let mut jobs: Vec<(ProviderId, SearchQuery, Via)> = Vec::new();
330 for provider in self.searchers(false) {
331 if titles.is_empty()
332 && let Some(text) = &claim.text
333 {
334 let q = query(&matching::search_terms(text), false);
335 jobs.push((provider.clone(), q, Via::KeywordSearch));
336 }
337 for title in &titles {
338 jobs.push((provider.clone(), query(title, false), Via::TitleSearch));
339 }
340 }
341 let mut pending: FuturesUnordered<_> = jobs
345 .iter()
346 .enumerate()
347 .map(|(i, (p, q, _))| async move { (i, self.search(Some(p.as_str()), q).await) })
348 .collect();
349 let before = acc.report.clone();
350 let mut answers: Vec<Option<Result<SearchReport, EngineError>>> = vec![None; jobs.len()];
351 while let Some((i, result)) = pending.next().await {
352 answers[i] = Some(result);
353 acc.report = before.clone();
354 for ((provider, _, via), answer) in jobs.iter().zip(&answers) {
355 if let Some(result) = answer {
356 acc.add_search(provider, *via, result.clone());
357 }
358 }
359 if done(&acc) {
360 break;
361 }
362 }
363 acc.finish()
364 }
365
366 fn searchers(&self, matchers: bool) -> Vec<ProviderId> {
368 self.providers()
369 .filter(|p| p.capabilities().search && p.capabilities().citation_search == matchers)
370 .map(|p| p.id())
371 .collect()
372 }
373}
374
375pub(crate) trait ClaimExt {
376 fn is_descriptive(&self) -> bool;
377}
378
379impl ClaimExt for Claim {
380 fn is_descriptive(&self) -> bool {
382 self.text.is_some() || self.title.is_some() || !self.authors.is_empty()
383 }
384}
385
386struct Accumulator<'a> {
387 claim: &'a Claim,
388 report: ResolveReport,
389}
390
391impl Accumulator<'_> {
392 fn add(&mut self, mut record: SourceRecord, sighting: Sighting) {
395 let assessment = matching::assess(self.claim, &record);
396 let candidates = &mut self.report.candidates;
397 let existing = candidates.iter_mut().find(|c| {
398 c.ids
399 .iter()
400 .any(|a| record.ids.iter().any(|b| same_work_id(a, b)))
401 });
402 let candidate = match existing {
403 Some(c) => {
404 for id in &record.ids {
405 if !c.ids.iter().any(|known| same_work_id(known, id)) {
406 c.ids.push(id.clone());
407 }
408 }
409 c.sightings.push(sighting);
410 merge_updates(&mut c.updates, &record.work.updates);
411 let pooled: Vec<i16> = years(&c.record).chain(years(&record)).collect();
414 add_years(&mut c.record, &pooled);
415 add_years(&mut record, &pooled);
416 let oa = |r: &SourceRecord| r.work.open_access.clone();
418 let known = oa(&c.record).or_else(|| oa(&record));
419 c.record.work.open_access.clone_from(&known);
420 record.work.open_access = record.work.open_access.take().or(known);
421 c.assessment = matching::assess(self.claim, &c.record);
422 let assessment = matching::assess(self.claim, &record);
423 if assessment.confidence > c.assessment.confidence {
424 c.record = record;
425 c.assessment = assessment;
426 }
427 c
428 }
429 None => {
430 let mut updates = Vec::new();
431 merge_updates(&mut updates, &record.work.updates);
432 candidates.push(Candidate {
433 ids: record.ids.clone(),
434 updates,
435 record,
436 sightings: vec![sighting],
437 assessment,
438 });
439 candidates.last_mut().expect("just pushed")
440 }
441 };
442 candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
444 }
445
446 fn add_search(
448 &mut self,
449 provider: &ProviderId,
450 via: Via,
451 result: Result<SearchReport, EngineError>,
452 ) -> Vec<String> {
453 match result {
454 Ok(report) => {
455 if let Some(u) = report.usage {
456 add_usage(&mut self.report.usage, provider, u);
457 }
458 let mut titles = Vec::new();
459 for (i, hit) in report.page.hits.into_iter().enumerate() {
460 let w = &hit.record.work;
463 titles.extend(w.title.clone().or_else(|| w.container_title.clone()));
464 let sighting = Sighting {
465 provider: provider.clone(),
466 via,
467 rank: i + 1,
468 score: hit.score,
469 };
470 self.add(hit.record, sighting);
471 }
472 titles
473 }
474 Err(e) => {
475 self.report.errors.push(e);
476 Vec::new()
477 }
478 }
479 }
480
481 fn usage(&mut self, usage: Vec<(ProviderId, Usage)>) {
482 for (provider, u) in usage {
483 add_usage(&mut self.report.usage, &provider, u);
484 }
485 }
486
487 fn verified(&self) -> bool {
495 self.report.candidates.iter().any(|c| {
496 let vouched = || {
497 c.sightings
498 .iter()
499 .any(|s| s.provider != c.sightings[0].provider)
500 };
501 let corroborated = match c.assessment.ids {
502 Agreement::Match => true,
503 Agreement::Mismatch => vouched(),
504 _ => self.claim.ids.is_empty() && vouched(),
505 };
506 c.assessment.confidence >= DECISIVE && corroborated
507 })
508 }
509
510 fn finish(mut self) -> ResolveReport {
511 rank(&mut self.report.candidates);
512 self.report
513 }
514}
515
516fn settle_alternatives(claim: &Claim, report: &mut ResolveReport) -> Claim {
520 let mut claim = claim.clone();
521 if claim.alternatives.is_empty() {
522 return claim;
523 }
524 let lookup = |id: &WorkId| report.lookups.iter().find(|l| l.id == *id);
525 for a in claim.alternatives.clone() {
526 let missing = lookup(&a.claimed).is_some_and(LookupResult::is_not_found);
527 let exists = lookup(&a.alternative).is_some_and(|l| l.record.is_some());
528 if missing && exists {
529 for id in claim.ids.iter_mut().filter(|id| **id == a.claimed) {
530 *id = a.alternative.clone();
531 }
532 }
533 }
534 report.lookups.retain(|l| claim.ids.contains(&l.id));
535 for c in &mut report.candidates {
536 c.assessment = matching::assess(&claim, &c.record);
537 c.assessment.ids = id_agreement(&claim.ids, &c.ids);
538 }
539 rank(&mut report.candidates);
540 claim
541}
542
543fn rank(candidates: &mut [Candidate]) {
547 let issued = |c: &Candidate| {
548 c.record.work.issued.map_or(i64::MAX, |d| {
549 i64::from(d.year()) * 10_000
550 + i64::from(d.month().unwrap_or(12)) * 100
551 + i64::from(d.day().unwrap_or(31))
552 })
553 };
554 candidates.sort_by(|a, b| {
555 b.assessment
556 .confidence
557 .total_cmp(&a.assessment.confidence)
558 .then(issued(a).cmp(&issued(b)))
559 .then(b.sightings.len().cmp(&a.sightings.len()))
560 });
561}
562
563fn years(record: &SourceRecord) -> impl Iterator<Item = i16> + '_ {
565 let w = &record.work;
566 w.issued
567 .map(|d| d.year())
568 .into_iter()
569 .chain(w.other_years.iter().copied())
570}
571
572fn add_years(record: &mut SourceRecord, pooled: &[i16]) {
575 let issued = record.work.issued.map(|d| d.year());
576 for &y in pooled {
577 if Some(y) != issued && !record.work.other_years.contains(&y) {
578 record.work.other_years.push(y);
579 }
580 }
581 record.work.other_years.sort_unstable();
582}
583
584fn distinct(titles: Vec<String>) -> Vec<String> {
586 let mut seen = Vec::new();
587 titles
588 .into_iter()
589 .filter(|t| {
590 let norm = matching::normalise(t);
591 let new = !norm.is_empty() && !seen.contains(&norm);
592 seen.push(norm);
593 new
594 })
595 .collect()
596}
597
598#[cfg(test)]
599mod tests {
600 use aspero_core::UpdateKind;
601
602 use super::*;
603
604 fn update(kind: UpdateKind, notice: Option<&str>) -> Update {
605 let mut u = Update::new(kind);
606 u.notice = notice.map(|n| n.parse().unwrap());
607 u
608 }
609
610 #[test]
611 fn detailed_notices_replace_bare_flags_without_duplicates() {
612 let mut known = vec![update(UpdateKind::Retraction, None)];
613 let crossref = [
614 update(UpdateKind::Correction, Some("10.1000/c1")),
615 update(UpdateKind::Retraction, Some("10.1000/r")),
616 update(UpdateKind::Correction, Some("10.1000/c2")),
617 ];
618 merge_updates(&mut known, &crossref);
619 merge_updates(&mut known, &crossref); merge_updates(&mut known, &[update(UpdateKind::Retraction, None)]);
621 let summary: Vec<(String, Option<String>)> = known
622 .iter()
623 .map(|u| {
624 (
625 u.kind.as_str().to_owned(),
626 u.notice.as_ref().map(ToString::to_string),
627 )
628 })
629 .collect();
630 assert_eq!(
631 summary,
632 [
633 ("retraction".into(), Some("doi:10.1000/r".into())),
634 ("correction".into(), Some("doi:10.1000/c1".into())),
635 ("correction".into(), Some("doi:10.1000/c2".into())),
636 ]
637 );
638 }
639}