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