Skip to main content

aspero_engine/
resolve.rs

1//! `resolve` and `verify`: from a citation claim to scored candidate works.
2//!
3//! Candidates are gathered in cost order, cheapest first:
4//!
5//! 1. **Lookup** of identifiers the claim asserts (free at OpenAlex and
6//!    Crossref).
7//! 2. **Citation matchers** (Crossref) with the whole reference. Good at
8//!    picking the title out of a messy string, even when the work they
9//!    return is a lookalike.
10//! 3. **Title searches** on keyword providers (OpenAlex, $0.001 each): the
11//!    claim's title, or the title the reference cites as located via the
12//!    matchers' hits ([`matching::shared_title`]). Bare titles rank far
13//!    better there than whole references (DESIGN §9), and locating the
14//!    title in the reference keeps derivative hits ("Faculty Opinions
15//!    recommendation of …") from steering the search.
16//!
17//! Candidates sharing an identifier are merged; each is scored against the
18//! claim with [`assess`]. `verify` stops as soon as a candidate verifies, so
19//! paid searches only run when the free sources weren't conclusive.
20
21use 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
33/// Title searches per claim: the claim's own title or the matchers' top
34/// distinct titles.
35const MAX_TITLE_SEARCHES: usize = 2;
36
37/// Confidence at which `verify` stops gathering candidates early.
38const DECISIVE: f64 = 0.95;
39
40/// Claims verified at once in batches. Providers pace themselves, so this
41/// overlaps different providers' waits rather than adding load to any one;
42/// and it keeps a batch from queueing so many requests that a caller
43/// sharing the engine (someone waiting) is stuck behind them.
44const CLAIM_CONCURRENCY: usize = 4;
45
46/// How a candidate was found.
47#[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/// One provider surfacing a candidate.
68#[derive(Debug, Clone)]
69pub struct Sighting {
70    pub provider: ProviderId,
71    pub via: Via,
72    /// 1-based rank in that provider's results.
73    pub rank: usize,
74    pub score: Option<f64>,
75}
76
77#[derive(Debug, Clone)]
78pub struct Candidate {
79    /// The best-matching record among those merged into this candidate.
80    pub record: SourceRecord,
81    /// Every identifier any merged record asserted.
82    pub ids: Vec<WorkId>,
83    pub sightings: Vec<Sighting>,
84    pub assessment: Assessment,
85    /// Editorial notices from every merged record, most detailed kept.
86    pub updates: Vec<Update>,
87}
88
89impl Candidate {
90    /// The first retraction or withdrawal notice, if any.
91    pub fn retraction(&self) -> Option<&Update> {
92        self.updates.iter().find(|u| u.kind.is_retraction())
93    }
94}
95
96/// Adds notices not already known. A detailed notice (with the notice's
97/// identifier) replaces a bare one of the same kind, such as OpenAlex's
98/// `is_retracted` flag.
99pub(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; // already known
107        }
108        if update.notice.is_none() {
109            if !known.iter().any(same_kind) {
110                known.push(update.clone());
111            }
112            continue; // a bare flag adds nothing to a known notice
113        }
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    /// Most confident first.
127    pub candidates: Vec<Candidate>,
128    /// Lookups of the claim's identifiers, if it had any.
129    pub lookups: Vec<LookupResult>,
130    /// Providers that failed; their absence from candidates is not evidence.
131    pub errors: Vec<EngineError>,
132    pub usage: Vec<(ProviderId, Usage)>,
133}
134
135impl ResolveReport {
136    /// Every failure: providers' searches, and lookups of the claimed
137    /// identifiers that failed rather than finding nothing.
138    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    /// Gathers and scores every candidate for `claim`. `limit` caps results
157    /// per provider search.
158    pub async fn resolve(&self, claim: &Claim, limit: u32) -> ResolveReport {
159        self.gather(claim, limit, false).await
160    }
161
162    /// Decides whether `claim` refers to a real work, stopping early once it
163    /// does.
164    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    /// Verifies many claims, several at a time (each provider's pacing
171    /// still applies), yielding each with its claim's index as soon as it
172    /// completes: a slow claim doesn't hold up the rest.
173    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    /// For candidates known to be retracted only by a bare flag (OpenAlex's
184    /// `is_retracted`), asks the other providers by identifier for the
185    /// notice itself: date, notice DOI, source. Free, cached, and only for
186    /// flagged works.
187    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    /// Looks up every identifier the claims assert, in one batched call per
224    /// provider. With a cache attached, this warms it so verifying the
225    /// claims one by one (and streaming results) doesn't look them up one
226    /// by one.
227    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        // Stage 1, concurrently (both free): lookups of the claimed
254        // identifiers, and citation matchers on the whole reference.
255        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                // Query the title the reference cites, as found via each
290                // hit: a commentary titled "Recommendation of X" yields X.
291                titles.extend(
292                    hits.iter()
293                        .filter_map(|t| matching::shared_title(t, reference)),
294                );
295            }
296        }
297        // An identifier-only claim has nothing else to compare.
298        if done(&acc) || !claim.is_descriptive() {
299            return acc.finish();
300        }
301
302        // Stage 2, concurrently: title searches (or, with no title to go on,
303        // cleaned-up keywords) on every keyword provider. Results are added
304        // in provider-then-title order, so merging stays deterministic.
305        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    /// Providers that search, either citation matchers or keyword engines.
331    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    /// Whether the claim says anything besides identifiers.
345    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    /// Adds a record, merging it into an existing candidate that shares an
357    /// identifier.
358    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                // Pool the years every source reports (online-first vs
376                // print, editions), then keep the better-matching record.
377                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                // And keep a free copy's location, whichever record wins.
381                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        // Judge claimed identifiers against everything known about the work.
407        candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
408    }
409
410    /// Adds one search's hits; returns the hits' titles.
411    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                    // An untitled hit (a book chapter registered under the
425                    // book) points to the cited title via its container.
426                    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    /// A candidate so clearly right that searching further can't improve
452    /// on it: decisive, and either the identifiers the claim asserted or two
453    /// providers vouch for it. A digest or reprint of a paper can match the
454    /// claim exactly while only a later search finds the original.
455    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        // Most confident first; on a tie the earliest-published (an original
470        // precedes its reprints, abstracts and corrections), then the one
471        // more providers agree on.
472        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
490/// Every year a record reports: issued, then other editions or versions.
491fn 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
499/// Adds `pooled` years the record doesn't already report to its
500/// `other_years`.
501fn 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
511/// Titles with duplicates (by normalised form) removed, order kept.
512fn 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); // again: nothing new
547        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}