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;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum Via {
48 Lookup,
49 CitationMatch,
50 TitleSearch,
51 KeywordSearch,
52}
53
54impl Via {
55 pub fn as_str(self) -> &'static str {
56 match self {
57 Via::Lookup => "lookup",
58 Via::CitationMatch => "citation_match",
59 Via::TitleSearch => "title_search",
60 Via::KeywordSearch => "keyword_search",
61 }
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct Sighting {
68 pub provider: ProviderId,
69 pub via: Via,
70 pub rank: usize,
72 pub score: Option<f64>,
73}
74
75#[derive(Debug, Clone)]
76pub struct Candidate {
77 pub record: SourceRecord,
79 pub ids: Vec<WorkId>,
81 pub sightings: Vec<Sighting>,
82 pub assessment: Assessment,
83 pub updates: Vec<Update>,
85}
86
87impl Candidate {
88 pub fn retraction(&self) -> Option<&Update> {
90 self.updates.iter().find(|u| u.kind.is_retraction())
91 }
92}
93
94pub(crate) fn merge_updates(known: &mut Vec<Update>, new: &[Update]) {
98 for update in new {
99 let same_kind = |k: &Update| k.kind == update.kind;
100 if known
101 .iter()
102 .any(|k| same_kind(k) && k.notice == update.notice)
103 {
104 continue; }
106 if update.notice.is_none() {
107 if !known.iter().any(same_kind) {
108 known.push(update.clone());
109 }
110 continue; }
112 match known
113 .iter_mut()
114 .find(|k| same_kind(k) && k.notice.is_none())
115 {
116 Some(bare) => *bare = update.clone(),
117 None => known.push(update.clone()),
118 }
119 }
120}
121
122#[derive(Debug, Clone, Default)]
123pub struct ResolveReport {
124 pub candidates: Vec<Candidate>,
126 pub lookups: Vec<LookupResult>,
128 pub errors: Vec<EngineError>,
130 pub usage: Vec<(ProviderId, Usage)>,
131}
132
133impl Engine {
134 pub async fn resolve(&self, claim: &Claim, limit: u32) -> ResolveReport {
137 self.gather(claim, limit, false).await
138 }
139
140 pub async fn verify(&self, claim: &Claim, limit: u32) -> Verification {
143 let mut report = self.gather(claim, limit, true).await;
144 self.detail_notices(&mut report).await;
145 judge(claim, report)
146 }
147
148 pub fn verify_many<'a>(
151 &'a self,
152 claims: &'a [Claim],
153 limit: u32,
154 ) -> impl Stream<Item = Verification> + 'a {
155 stream::iter(claims)
156 .map(move |claim| self.verify(claim, limit))
157 .buffered(CLAIM_CONCURRENCY)
158 }
159
160 async fn detail_notices(&self, report: &mut ResolveReport) {
165 let bare = |c: &Candidate| {
166 c.updates
167 .iter()
168 .any(|u| u.kind.is_retraction() && u.notice.is_none())
169 };
170 for entry in &self.entries {
171 let provider = entry.provider.id();
172 let kinds = &entry.provider.capabilities().lookup_kinds;
173 let wanted: Vec<(usize, WorkId)> = report
174 .candidates
175 .iter()
176 .enumerate()
177 .filter(|(_, c)| bare(c) && !c.sightings.iter().any(|s| s.provider == provider))
178 .filter_map(|(i, c)| {
179 Some((
180 i,
181 c.ids.iter().find(|id| kinds.contains(&id.kind()))?.clone(),
182 ))
183 })
184 .collect();
185 if wanted.is_empty() {
186 continue;
187 }
188 let ids: Vec<WorkId> = wanted.iter().map(|(_, id)| id.clone()).collect();
189 let answers = self
190 .lookup_at(entry, &ids, FieldSet::ALL, &mut report.usage)
191 .await;
192 for ((i, _), (_, record)) in wanted.into_iter().zip(answers) {
193 if let Some(record) = record {
194 merge_updates(&mut report.candidates[i].updates, &record.work.updates);
195 }
196 }
197 }
198 }
199
200 pub async fn prefetch(&self, claims: &[Claim]) -> crate::LookupReport {
205 let mut ids: Vec<WorkId> = Vec::new();
206 for id in claims.iter().flat_map(|c| &c.ids) {
207 if !ids.contains(id) {
208 ids.push(id.clone());
209 }
210 }
211 self.lookup(&ids, FieldSet::DEFAULT).await
212 }
213
214 async fn gather(&self, claim: &Claim, limit: u32, stop_when_verified: bool) -> ResolveReport {
215 let mut acc = Accumulator {
216 claim,
217 report: ResolveReport::default(),
218 };
219 let done = |acc: &Accumulator| stop_when_verified && acc.verified();
220 let query = |text: &str, citation: bool| {
221 let mut q = if citation {
222 SearchQuery::citation(text)
223 } else {
224 SearchQuery::new(text)
225 };
226 q.page.limit = limit;
227 q
228 };
229
230 let reference = claim.reference();
233 let matchers: Vec<ProviderId> = match &reference {
234 Some(_) => self.searchers(true),
235 None => Vec::new(),
236 };
237 let lookup = async {
238 match claim.ids.is_empty() {
239 true => None,
240 false => Some(self.lookup(&claim.ids, FieldSet::DEFAULT).await),
241 }
242 };
243 let matched = join_all(matchers.iter().map(|p| {
244 let q = query(reference.as_deref().unwrap_or_default(), true);
245 async move { self.search(Some(p.as_str()), &q).await }
246 }));
247 let (lookup, matched) = join(lookup, matched).await;
248
249 if let Some(lookup) = lookup {
250 for record in lookup.results.iter().filter_map(|r| r.record.as_ref()) {
251 let sighting = Sighting {
252 provider: record.source.clone(),
253 via: Via::Lookup,
254 rank: 1,
255 score: None,
256 };
257 acc.add(record.clone(), sighting);
258 }
259 acc.usage(lookup.usage);
260 acc.report.lookups = lookup.results;
261 }
262 let mut titles: Vec<String> = claim.title.iter().cloned().collect();
263 for (provider, result) in matchers.iter().zip(matched) {
264 let hits = acc.add_search(provider, Via::CitationMatch, result);
265 if let (None, Some(reference)) = (&claim.title, &reference) {
266 titles.extend(
269 hits.iter()
270 .filter_map(|t| matching::shared_title(t, reference)),
271 );
272 }
273 }
274 if done(&acc) || !claim.is_descriptive() {
276 return acc.finish();
277 }
278
279 titles = distinct(titles);
283 titles.truncate(MAX_TITLE_SEARCHES);
284 let mut jobs: Vec<(ProviderId, SearchQuery, Via)> = Vec::new();
285 for provider in self.searchers(false) {
286 if titles.is_empty()
287 && let Some(text) = &claim.text
288 {
289 let q = query(&matching::search_terms(text), false);
290 jobs.push((provider.clone(), q, Via::KeywordSearch));
291 }
292 for title in &titles {
293 jobs.push((provider.clone(), query(title, false), Via::TitleSearch));
294 }
295 }
296 let results = join_all(
297 jobs.iter()
298 .map(|(p, q, _)| self.search(Some(p.as_str()), q)),
299 )
300 .await;
301 for ((provider, _, via), result) in jobs.iter().zip(results) {
302 acc.add_search(provider, *via, result);
303 }
304 acc.finish()
305 }
306
307 fn searchers(&self, matchers: bool) -> Vec<ProviderId> {
309 self.providers()
310 .filter(|p| p.capabilities().search && p.capabilities().citation_search == matchers)
311 .map(|p| p.id())
312 .collect()
313 }
314}
315
316pub(crate) trait ClaimExt {
317 fn is_descriptive(&self) -> bool;
318}
319
320impl ClaimExt for Claim {
321 fn is_descriptive(&self) -> bool {
323 self.text.is_some() || self.title.is_some() || !self.authors.is_empty()
324 }
325}
326
327struct Accumulator<'a> {
328 claim: &'a Claim,
329 report: ResolveReport,
330}
331
332impl Accumulator<'_> {
333 fn add(&mut self, mut record: SourceRecord, sighting: Sighting) {
336 let assessment = matching::assess(self.claim, &record);
337 let candidates = &mut self.report.candidates;
338 let existing = candidates.iter_mut().find(|c| {
339 c.ids
340 .iter()
341 .any(|a| record.ids.iter().any(|b| same_work_id(a, b)))
342 });
343 let candidate = match existing {
344 Some(c) => {
345 for id in &record.ids {
346 if !c.ids.iter().any(|known| same_work_id(known, id)) {
347 c.ids.push(id.clone());
348 }
349 }
350 c.sightings.push(sighting);
351 merge_updates(&mut c.updates, &record.work.updates);
352 let pooled: Vec<i16> = years(&c.record).chain(years(&record)).collect();
355 add_years(&mut c.record, &pooled);
356 add_years(&mut record, &pooled);
357 let oa = |r: &SourceRecord| r.work.open_access.clone();
359 let known = oa(&c.record).or_else(|| oa(&record));
360 c.record.work.open_access.clone_from(&known);
361 record.work.open_access = record.work.open_access.take().or(known);
362 c.assessment = matching::assess(self.claim, &c.record);
363 let assessment = matching::assess(self.claim, &record);
364 if assessment.confidence > c.assessment.confidence {
365 c.record = record;
366 c.assessment = assessment;
367 }
368 c
369 }
370 None => {
371 let mut updates = Vec::new();
372 merge_updates(&mut updates, &record.work.updates);
373 candidates.push(Candidate {
374 ids: record.ids.clone(),
375 updates,
376 record,
377 sightings: vec![sighting],
378 assessment,
379 });
380 candidates.last_mut().expect("just pushed")
381 }
382 };
383 candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
385 }
386
387 fn add_search(
389 &mut self,
390 provider: &ProviderId,
391 via: Via,
392 result: Result<SearchReport, EngineError>,
393 ) -> Vec<String> {
394 match result {
395 Ok(report) => {
396 if let Some(u) = report.usage {
397 add_usage(&mut self.report.usage, provider, u);
398 }
399 let mut titles = Vec::new();
400 for (i, hit) in report.page.hits.into_iter().enumerate() {
401 let w = &hit.record.work;
404 titles.extend(w.title.clone().or_else(|| w.container_title.clone()));
405 let sighting = Sighting {
406 provider: provider.clone(),
407 via,
408 rank: i + 1,
409 score: hit.score,
410 };
411 self.add(hit.record, sighting);
412 }
413 titles
414 }
415 Err(e) => {
416 self.report.errors.push(e);
417 Vec::new()
418 }
419 }
420 }
421
422 fn usage(&mut self, usage: Vec<(ProviderId, Usage)>) {
423 for (provider, u) in usage {
424 add_usage(&mut self.report.usage, &provider, u);
425 }
426 }
427
428 fn verified(&self) -> bool {
433 self.report.candidates.iter().any(|c| {
434 let corroborated = match self.claim.ids.is_empty() {
435 true => c
436 .sightings
437 .iter()
438 .any(|s| s.provider != c.sightings[0].provider),
439 false => c.assessment.ids == Agreement::Match,
440 };
441 c.assessment.confidence >= DECISIVE && corroborated
442 })
443 }
444
445 fn finish(mut self) -> ResolveReport {
446 let issued = |c: &Candidate| {
450 c.record.work.issued.map_or(i64::MAX, |d| {
451 i64::from(d.year()) * 10_000
452 + i64::from(d.month().unwrap_or(12)) * 100
453 + i64::from(d.day().unwrap_or(31))
454 })
455 };
456 self.report.candidates.sort_by(|a, b| {
457 b.assessment
458 .confidence
459 .total_cmp(&a.assessment.confidence)
460 .then(issued(a).cmp(&issued(b)))
461 .then(b.sightings.len().cmp(&a.sightings.len()))
462 });
463 self.report
464 }
465}
466
467fn years(record: &SourceRecord) -> impl Iterator<Item = i16> + '_ {
469 let w = &record.work;
470 w.issued
471 .map(|d| d.year())
472 .into_iter()
473 .chain(w.other_years.iter().copied())
474}
475
476fn add_years(record: &mut SourceRecord, pooled: &[i16]) {
479 let issued = record.work.issued.map(|d| d.year());
480 for &y in pooled {
481 if Some(y) != issued && !record.work.other_years.contains(&y) {
482 record.work.other_years.push(y);
483 }
484 }
485 record.work.other_years.sort_unstable();
486}
487
488fn distinct(titles: Vec<String>) -> Vec<String> {
490 let mut seen = Vec::new();
491 titles
492 .into_iter()
493 .filter(|t| {
494 let norm = matching::normalise(t);
495 let new = !norm.is_empty() && !seen.contains(&norm);
496 seen.push(norm);
497 new
498 })
499 .collect()
500}
501
502#[cfg(test)]
503mod tests {
504 use aspero_core::UpdateKind;
505
506 use super::*;
507
508 fn update(kind: UpdateKind, notice: Option<&str>) -> Update {
509 let mut u = Update::new(kind);
510 u.notice = notice.map(|n| n.parse().unwrap());
511 u
512 }
513
514 #[test]
515 fn detailed_notices_replace_bare_flags_without_duplicates() {
516 let mut known = vec![update(UpdateKind::Retraction, None)];
517 let crossref = [
518 update(UpdateKind::Correction, Some("10.1000/c1")),
519 update(UpdateKind::Retraction, Some("10.1000/r")),
520 update(UpdateKind::Correction, Some("10.1000/c2")),
521 ];
522 merge_updates(&mut known, &crossref);
523 merge_updates(&mut known, &crossref); merge_updates(&mut known, &[update(UpdateKind::Retraction, None)]);
525 let summary: Vec<(String, Option<String>)> = known
526 .iter()
527 .map(|u| {
528 (
529 u.kind.as_str().to_owned(),
530 u.notice.as_ref().map(ToString::to_string),
531 )
532 })
533 .collect();
534 assert_eq!(
535 summary,
536 [
537 ("retraction".into(), Some("doi:10.1000/r".into())),
538 ("correction".into(), Some("doi:10.1000/c1".into())),
539 ("correction".into(), Some("doi:10.1000/c2".into())),
540 ]
541 );
542 }
543}