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::{join, join_all};
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 report = self.gather(claim, limit, true).await;
166 self.detail_notices(&mut report).await;
167 judge(claim, report)
168 }
169
170 pub fn verify_many<'a>(
174 &'a self,
175 claims: &'a [Claim],
176 limit: u32,
177 ) -> impl Stream<Item = (usize, Verification)> + 'a {
178 stream::iter(claims.iter().enumerate())
179 .map(move |(i, claim)| async move { (i, self.verify(claim, limit).await) })
180 .buffer_unordered(CLAIM_CONCURRENCY)
181 }
182
183 async fn detail_notices(&self, report: &mut ResolveReport) {
188 let bare = |c: &Candidate| {
189 c.updates
190 .iter()
191 .any(|u| u.kind.is_retraction() && u.notice.is_none())
192 };
193 for entry in &self.entries {
194 let provider = entry.provider.id();
195 let kinds = &entry.provider.capabilities().lookup_kinds;
196 let wanted: Vec<(usize, WorkId)> = report
197 .candidates
198 .iter()
199 .enumerate()
200 .filter(|(_, c)| bare(c) && !c.sightings.iter().any(|s| s.provider == provider))
201 .filter_map(|(i, c)| {
202 Some((
203 i,
204 c.ids.iter().find(|id| kinds.contains(&id.kind()))?.clone(),
205 ))
206 })
207 .collect();
208 if wanted.is_empty() {
209 continue;
210 }
211 let ids: Vec<WorkId> = wanted.iter().map(|(_, id)| id.clone()).collect();
212 let answers = self
213 .lookup_at(entry, &ids, FieldSet::ALL, &mut report.usage)
214 .await;
215 for ((i, _), (_, record)) in wanted.into_iter().zip(answers) {
216 if let Some(record) = record {
217 merge_updates(&mut report.candidates[i].updates, &record.work.updates);
218 }
219 }
220 }
221 }
222
223 pub async fn prefetch(&self, claims: &[Claim]) -> crate::LookupReport {
228 let mut ids: Vec<WorkId> = Vec::new();
229 for id in claims.iter().flat_map(|c| &c.ids) {
230 if !ids.contains(id) {
231 ids.push(id.clone());
232 }
233 }
234 self.lookup(&ids, FieldSet::DEFAULT).await
235 }
236
237 async fn gather(&self, claim: &Claim, limit: u32, stop_when_verified: bool) -> ResolveReport {
238 let mut acc = Accumulator {
239 claim,
240 report: ResolveReport::default(),
241 };
242 let done = |acc: &Accumulator| stop_when_verified && acc.verified();
243 let query = |text: &str, citation: bool| {
244 let mut q = if citation {
245 SearchQuery::citation(text)
246 } else {
247 SearchQuery::new(text)
248 };
249 q.page.limit = limit;
250 q
251 };
252
253 let reference = claim.reference();
256 let matchers: Vec<ProviderId> = match &reference {
257 Some(_) => self.searchers(true),
258 None => Vec::new(),
259 };
260 let lookup = async {
261 match claim.ids.is_empty() {
262 true => None,
263 false => Some(self.lookup(&claim.ids, FieldSet::DEFAULT).await),
264 }
265 };
266 let matched = join_all(matchers.iter().map(|p| {
267 let q = query(reference.as_deref().unwrap_or_default(), true);
268 async move { self.search(Some(p.as_str()), &q).await }
269 }));
270 let (lookup, matched) = join(lookup, matched).await;
271
272 if let Some(lookup) = lookup {
273 for record in lookup.results.iter().filter_map(|r| r.record.as_ref()) {
274 let sighting = Sighting {
275 provider: record.source.clone(),
276 via: Via::Lookup,
277 rank: 1,
278 score: None,
279 };
280 acc.add(record.clone(), sighting);
281 }
282 acc.usage(lookup.usage);
283 acc.report.lookups = lookup.results;
284 }
285 let mut titles: Vec<String> = claim.title.iter().cloned().collect();
286 for (provider, result) in matchers.iter().zip(matched) {
287 let hits = acc.add_search(provider, Via::CitationMatch, result);
288 if let (None, Some(reference)) = (&claim.title, &reference) {
289 titles.extend(
292 hits.iter()
293 .filter_map(|t| matching::shared_title(t, reference)),
294 );
295 }
296 }
297 if done(&acc) || !claim.is_descriptive() {
299 return acc.finish();
300 }
301
302 titles = distinct(titles);
306 titles.truncate(MAX_TITLE_SEARCHES);
307 let mut jobs: Vec<(ProviderId, SearchQuery, Via)> = Vec::new();
308 for provider in self.searchers(false) {
309 if titles.is_empty()
310 && let Some(text) = &claim.text
311 {
312 let q = query(&matching::search_terms(text), false);
313 jobs.push((provider.clone(), q, Via::KeywordSearch));
314 }
315 for title in &titles {
316 jobs.push((provider.clone(), query(title, false), Via::TitleSearch));
317 }
318 }
319 let results = join_all(
320 jobs.iter()
321 .map(|(p, q, _)| self.search(Some(p.as_str()), q)),
322 )
323 .await;
324 for ((provider, _, via), result) in jobs.iter().zip(results) {
325 acc.add_search(provider, *via, result);
326 }
327 acc.finish()
328 }
329
330 fn searchers(&self, matchers: bool) -> Vec<ProviderId> {
332 self.providers()
333 .filter(|p| p.capabilities().search && p.capabilities().citation_search == matchers)
334 .map(|p| p.id())
335 .collect()
336 }
337}
338
339pub(crate) trait ClaimExt {
340 fn is_descriptive(&self) -> bool;
341}
342
343impl ClaimExt for Claim {
344 fn is_descriptive(&self) -> bool {
346 self.text.is_some() || self.title.is_some() || !self.authors.is_empty()
347 }
348}
349
350struct Accumulator<'a> {
351 claim: &'a Claim,
352 report: ResolveReport,
353}
354
355impl Accumulator<'_> {
356 fn add(&mut self, mut record: SourceRecord, sighting: Sighting) {
359 let assessment = matching::assess(self.claim, &record);
360 let candidates = &mut self.report.candidates;
361 let existing = candidates.iter_mut().find(|c| {
362 c.ids
363 .iter()
364 .any(|a| record.ids.iter().any(|b| same_work_id(a, b)))
365 });
366 let candidate = match existing {
367 Some(c) => {
368 for id in &record.ids {
369 if !c.ids.iter().any(|known| same_work_id(known, id)) {
370 c.ids.push(id.clone());
371 }
372 }
373 c.sightings.push(sighting);
374 merge_updates(&mut c.updates, &record.work.updates);
375 let pooled: Vec<i16> = years(&c.record).chain(years(&record)).collect();
378 add_years(&mut c.record, &pooled);
379 add_years(&mut record, &pooled);
380 let oa = |r: &SourceRecord| r.work.open_access.clone();
382 let known = oa(&c.record).or_else(|| oa(&record));
383 c.record.work.open_access.clone_from(&known);
384 record.work.open_access = record.work.open_access.take().or(known);
385 c.assessment = matching::assess(self.claim, &c.record);
386 let assessment = matching::assess(self.claim, &record);
387 if assessment.confidence > c.assessment.confidence {
388 c.record = record;
389 c.assessment = assessment;
390 }
391 c
392 }
393 None => {
394 let mut updates = Vec::new();
395 merge_updates(&mut updates, &record.work.updates);
396 candidates.push(Candidate {
397 ids: record.ids.clone(),
398 updates,
399 record,
400 sightings: vec![sighting],
401 assessment,
402 });
403 candidates.last_mut().expect("just pushed")
404 }
405 };
406 candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
408 }
409
410 fn add_search(
412 &mut self,
413 provider: &ProviderId,
414 via: Via,
415 result: Result<SearchReport, EngineError>,
416 ) -> Vec<String> {
417 match result {
418 Ok(report) => {
419 if let Some(u) = report.usage {
420 add_usage(&mut self.report.usage, provider, u);
421 }
422 let mut titles = Vec::new();
423 for (i, hit) in report.page.hits.into_iter().enumerate() {
424 let w = &hit.record.work;
427 titles.extend(w.title.clone().or_else(|| w.container_title.clone()));
428 let sighting = Sighting {
429 provider: provider.clone(),
430 via,
431 rank: i + 1,
432 score: hit.score,
433 };
434 self.add(hit.record, sighting);
435 }
436 titles
437 }
438 Err(e) => {
439 self.report.errors.push(e);
440 Vec::new()
441 }
442 }
443 }
444
445 fn usage(&mut self, usage: Vec<(ProviderId, Usage)>) {
446 for (provider, u) in usage {
447 add_usage(&mut self.report.usage, &provider, u);
448 }
449 }
450
451 fn verified(&self) -> bool {
456 self.report.candidates.iter().any(|c| {
457 let corroborated = match self.claim.ids.is_empty() {
458 true => c
459 .sightings
460 .iter()
461 .any(|s| s.provider != c.sightings[0].provider),
462 false => c.assessment.ids == Agreement::Match,
463 };
464 c.assessment.confidence >= DECISIVE && corroborated
465 })
466 }
467
468 fn finish(mut self) -> ResolveReport {
469 let issued = |c: &Candidate| {
473 c.record.work.issued.map_or(i64::MAX, |d| {
474 i64::from(d.year()) * 10_000
475 + i64::from(d.month().unwrap_or(12)) * 100
476 + i64::from(d.day().unwrap_or(31))
477 })
478 };
479 self.report.candidates.sort_by(|a, b| {
480 b.assessment
481 .confidence
482 .total_cmp(&a.assessment.confidence)
483 .then(issued(a).cmp(&issued(b)))
484 .then(b.sightings.len().cmp(&a.sightings.len()))
485 });
486 self.report
487 }
488}
489
490fn years(record: &SourceRecord) -> impl Iterator<Item = i16> + '_ {
492 let w = &record.work;
493 w.issued
494 .map(|d| d.year())
495 .into_iter()
496 .chain(w.other_years.iter().copied())
497}
498
499fn add_years(record: &mut SourceRecord, pooled: &[i16]) {
502 let issued = record.work.issued.map(|d| d.year());
503 for &y in pooled {
504 if Some(y) != issued && !record.work.other_years.contains(&y) {
505 record.work.other_years.push(y);
506 }
507 }
508 record.work.other_years.sort_unstable();
509}
510
511fn distinct(titles: Vec<String>) -> Vec<String> {
513 let mut seen = Vec::new();
514 titles
515 .into_iter()
516 .filter(|t| {
517 let norm = matching::normalise(t);
518 let new = !norm.is_empty() && !seen.contains(&norm);
519 seen.push(norm);
520 new
521 })
522 .collect()
523}
524
525#[cfg(test)]
526mod tests {
527 use aspero_core::UpdateKind;
528
529 use super::*;
530
531 fn update(kind: UpdateKind, notice: Option<&str>) -> Update {
532 let mut u = Update::new(kind);
533 u.notice = notice.map(|n| n.parse().unwrap());
534 u
535 }
536
537 #[test]
538 fn detailed_notices_replace_bare_flags_without_duplicates() {
539 let mut known = vec![update(UpdateKind::Retraction, None)];
540 let crossref = [
541 update(UpdateKind::Correction, Some("10.1000/c1")),
542 update(UpdateKind::Retraction, Some("10.1000/r")),
543 update(UpdateKind::Correction, Some("10.1000/c2")),
544 ];
545 merge_updates(&mut known, &crossref);
546 merge_updates(&mut known, &crossref); merge_updates(&mut known, &[update(UpdateKind::Retraction, None)]);
548 let summary: Vec<(String, Option<String>)> = known
549 .iter()
550 .map(|u| {
551 (
552 u.kind.as_str().to_owned(),
553 u.notice.as_ref().map(ToString::to_string),
554 )
555 })
556 .collect();
557 assert_eq!(
558 summary,
559 [
560 ("retraction".into(), Some("doi:10.1000/r".into())),
561 ("correction".into(), Some("doi:10.1000/c1".into())),
562 ("correction".into(), Some("doi:10.1000/c2".into())),
563 ]
564 );
565 }
566}