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::{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
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        // Alternative readings of claimed identifiers are looked up with
166        // them, alongside the citation matchers; which was meant is settled
167        // after, so a broken DOI costs no extra round of lookups.
168        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    /// Verifies many claims, several at a time (each provider's pacing
181    /// still applies), yielding each with its claim's index as soon as it
182    /// completes: a slow claim doesn't hold up the rest.
183    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    /// For candidates known to be retracted only by a bare flag (OpenAlex's
194    /// `is_retracted`), asks the other providers by identifier for the
195    /// notice itself: date, notice DOI, source. Free, cached, and only for
196    /// flagged works.
197    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    /// Looks up every identifier the claims assert, in one batched call per
234    /// provider. With a cache attached, this warms it so verifying the
235    /// claims one by one (and streaming results) doesn't look them up one
236    /// by one.
237    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        // Stage 1, concurrently (both free): lookups of the claimed
264        // identifiers, and citation matchers on the whole reference.
265        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        // When the identifiers' lookup alone verifies the claim, the
300        // matchers can't change the verdict: stop waiting for them (Crossref's
301        // takes seconds).
302        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                // Query the title the reference cites, as found via each
312                // hit: a commentary titled "Recommendation of X" yields X.
313                titles.extend(
314                    hits.iter()
315                        .filter_map(|t| matching::shared_title(t, reference)),
316                );
317            }
318        }
319        // An identifier-only claim has nothing else to compare.
320        if done(&acc) || !claim.is_descriptive() {
321            return acc.finish();
322        }
323
324        // Stage 2, concurrently: title searches (or, with no title to go on,
325        // cleaned-up keywords) on every keyword provider. Results are added
326        // in provider-then-title order, so merging stays deterministic.
327        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    /// Providers that search, either citation matchers or keyword engines.
353    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    /// Whether the claim says anything besides identifiers.
367    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    /// Adds a record, merging it into an existing candidate that shares an
379    /// identifier.
380    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                // Pool the years every source reports (online-first vs
398                // print, editions), then keep the better-matching record.
399                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                // And keep a free copy's location, whichever record wins.
403                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        // Judge claimed identifiers against everything known about the work.
429        candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
430    }
431
432    /// Adds one search's hits; returns the hits' titles.
433    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                    // An untitled hit (a book chapter registered under the
447                    // book) points to the cited title via its container.
448                    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    /// A candidate so clearly right that searching further can't improve
474    /// on it: decisive, and either the identifiers the claim asserted or two
475    /// providers vouch for it. A digest or reprint of a paper can match the
476    /// claim exactly while only a later search finds the original.
477    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
496/// The claim with each claimed identifier that doesn't exist replaced by
497/// an alternative reading that does, and the report made about that
498/// claim: lookups of readings not taken dropped, candidates rescored.
499fn 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
523/// Most confident first; on a tie the earliest-published (an original
524/// precedes its reprints, abstracts and corrections), then the one more
525/// providers agree on.
526fn 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
543/// Every year a record reports: issued, then other editions or versions.
544fn 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
552/// Adds `pooled` years the record doesn't already report to its
553/// `other_years`.
554fn 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
564/// Titles with duplicates (by normalised form) removed, order kept.
565fn 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); // again: nothing new
600        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}