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, 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 results = join_all(
342 jobs.iter()
343 .map(|(p, q, _)| self.search(Some(p.as_str()), q)),
344 )
345 .await;
346 for ((provider, _, via), result) in jobs.iter().zip(results) {
347 acc.add_search(provider, *via, result);
348 }
349 acc.finish()
350 }
351
352 fn searchers(&self, matchers: bool) -> Vec<ProviderId> {
354 self.providers()
355 .filter(|p| p.capabilities().search && p.capabilities().citation_search == matchers)
356 .map(|p| p.id())
357 .collect()
358 }
359}
360
361pub(crate) trait ClaimExt {
362 fn is_descriptive(&self) -> bool;
363}
364
365impl ClaimExt for Claim {
366 fn is_descriptive(&self) -> bool {
368 self.text.is_some() || self.title.is_some() || !self.authors.is_empty()
369 }
370}
371
372struct Accumulator<'a> {
373 claim: &'a Claim,
374 report: ResolveReport,
375}
376
377impl Accumulator<'_> {
378 fn add(&mut self, mut record: SourceRecord, sighting: Sighting) {
381 let assessment = matching::assess(self.claim, &record);
382 let candidates = &mut self.report.candidates;
383 let existing = candidates.iter_mut().find(|c| {
384 c.ids
385 .iter()
386 .any(|a| record.ids.iter().any(|b| same_work_id(a, b)))
387 });
388 let candidate = match existing {
389 Some(c) => {
390 for id in &record.ids {
391 if !c.ids.iter().any(|known| same_work_id(known, id)) {
392 c.ids.push(id.clone());
393 }
394 }
395 c.sightings.push(sighting);
396 merge_updates(&mut c.updates, &record.work.updates);
397 let pooled: Vec<i16> = years(&c.record).chain(years(&record)).collect();
400 add_years(&mut c.record, &pooled);
401 add_years(&mut record, &pooled);
402 let oa = |r: &SourceRecord| r.work.open_access.clone();
404 let known = oa(&c.record).or_else(|| oa(&record));
405 c.record.work.open_access.clone_from(&known);
406 record.work.open_access = record.work.open_access.take().or(known);
407 c.assessment = matching::assess(self.claim, &c.record);
408 let assessment = matching::assess(self.claim, &record);
409 if assessment.confidence > c.assessment.confidence {
410 c.record = record;
411 c.assessment = assessment;
412 }
413 c
414 }
415 None => {
416 let mut updates = Vec::new();
417 merge_updates(&mut updates, &record.work.updates);
418 candidates.push(Candidate {
419 ids: record.ids.clone(),
420 updates,
421 record,
422 sightings: vec![sighting],
423 assessment,
424 });
425 candidates.last_mut().expect("just pushed")
426 }
427 };
428 candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
430 }
431
432 fn add_search(
434 &mut self,
435 provider: &ProviderId,
436 via: Via,
437 result: Result<SearchReport, EngineError>,
438 ) -> Vec<String> {
439 match result {
440 Ok(report) => {
441 if let Some(u) = report.usage {
442 add_usage(&mut self.report.usage, provider, u);
443 }
444 let mut titles = Vec::new();
445 for (i, hit) in report.page.hits.into_iter().enumerate() {
446 let w = &hit.record.work;
449 titles.extend(w.title.clone().or_else(|| w.container_title.clone()));
450 let sighting = Sighting {
451 provider: provider.clone(),
452 via,
453 rank: i + 1,
454 score: hit.score,
455 };
456 self.add(hit.record, sighting);
457 }
458 titles
459 }
460 Err(e) => {
461 self.report.errors.push(e);
462 Vec::new()
463 }
464 }
465 }
466
467 fn usage(&mut self, usage: Vec<(ProviderId, Usage)>) {
468 for (provider, u) in usage {
469 add_usage(&mut self.report.usage, &provider, u);
470 }
471 }
472
473 fn verified(&self) -> bool {
478 self.report.candidates.iter().any(|c| {
479 let corroborated = match self.claim.ids.is_empty() {
480 true => c
481 .sightings
482 .iter()
483 .any(|s| s.provider != c.sightings[0].provider),
484 false => c.assessment.ids == Agreement::Match,
485 };
486 c.assessment.confidence >= DECISIVE && corroborated
487 })
488 }
489
490 fn finish(mut self) -> ResolveReport {
491 rank(&mut self.report.candidates);
492 self.report
493 }
494}
495
496fn settle_alternatives(claim: &Claim, report: &mut ResolveReport) -> Claim {
500 let mut claim = claim.clone();
501 if claim.alternatives.is_empty() {
502 return claim;
503 }
504 let lookup = |id: &WorkId| report.lookups.iter().find(|l| l.id == *id);
505 for a in claim.alternatives.clone() {
506 let missing = lookup(&a.claimed).is_some_and(LookupResult::is_not_found);
507 let exists = lookup(&a.alternative).is_some_and(|l| l.record.is_some());
508 if missing && exists {
509 for id in claim.ids.iter_mut().filter(|id| **id == a.claimed) {
510 *id = a.alternative.clone();
511 }
512 }
513 }
514 report.lookups.retain(|l| claim.ids.contains(&l.id));
515 for c in &mut report.candidates {
516 c.assessment = matching::assess(&claim, &c.record);
517 c.assessment.ids = id_agreement(&claim.ids, &c.ids);
518 }
519 rank(&mut report.candidates);
520 claim
521}
522
523fn rank(candidates: &mut [Candidate]) {
527 let issued = |c: &Candidate| {
528 c.record.work.issued.map_or(i64::MAX, |d| {
529 i64::from(d.year()) * 10_000
530 + i64::from(d.month().unwrap_or(12)) * 100
531 + i64::from(d.day().unwrap_or(31))
532 })
533 };
534 candidates.sort_by(|a, b| {
535 b.assessment
536 .confidence
537 .total_cmp(&a.assessment.confidence)
538 .then(issued(a).cmp(&issued(b)))
539 .then(b.sightings.len().cmp(&a.sightings.len()))
540 });
541}
542
543fn years(record: &SourceRecord) -> impl Iterator<Item = i16> + '_ {
545 let w = &record.work;
546 w.issued
547 .map(|d| d.year())
548 .into_iter()
549 .chain(w.other_years.iter().copied())
550}
551
552fn add_years(record: &mut SourceRecord, pooled: &[i16]) {
555 let issued = record.work.issued.map(|d| d.year());
556 for &y in pooled {
557 if Some(y) != issued && !record.work.other_years.contains(&y) {
558 record.work.other_years.push(y);
559 }
560 }
561 record.work.other_years.sort_unstable();
562}
563
564fn distinct(titles: Vec<String>) -> Vec<String> {
566 let mut seen = Vec::new();
567 titles
568 .into_iter()
569 .filter(|t| {
570 let norm = matching::normalise(t);
571 let new = !norm.is_empty() && !seen.contains(&norm);
572 seen.push(norm);
573 new
574 })
575 .collect()
576}
577
578#[cfg(test)]
579mod tests {
580 use aspero_core::UpdateKind;
581
582 use super::*;
583
584 fn update(kind: UpdateKind, notice: Option<&str>) -> Update {
585 let mut u = Update::new(kind);
586 u.notice = notice.map(|n| n.parse().unwrap());
587 u
588 }
589
590 #[test]
591 fn detailed_notices_replace_bare_flags_without_duplicates() {
592 let mut known = vec![update(UpdateKind::Retraction, None)];
593 let crossref = [
594 update(UpdateKind::Correction, Some("10.1000/c1")),
595 update(UpdateKind::Retraction, Some("10.1000/r")),
596 update(UpdateKind::Correction, Some("10.1000/c2")),
597 ];
598 merge_updates(&mut known, &crossref);
599 merge_updates(&mut known, &crossref); merge_updates(&mut known, &[update(UpdateKind::Retraction, None)]);
601 let summary: Vec<(String, Option<String>)> = known
602 .iter()
603 .map(|u| {
604 (
605 u.kind.as_str().to_owned(),
606 u.notice.as_ref().map(ToString::to_string),
607 )
608 })
609 .collect();
610 assert_eq!(
611 summary,
612 [
613 ("retraction".into(), Some("doi:10.1000/r".into())),
614 ("correction".into(), Some("doi:10.1000/c1".into())),
615 ("correction".into(), Some("doi:10.1000/c2".into())),
616 ]
617 );
618 }
619}