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.
42const CLAIM_CONCURRENCY: usize = 4;
43
44/// How a candidate was found.
45#[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/// One provider surfacing a candidate.
66#[derive(Debug, Clone)]
67pub struct Sighting {
68    pub provider: ProviderId,
69    pub via: Via,
70    /// 1-based rank in that provider's results.
71    pub rank: usize,
72    pub score: Option<f64>,
73}
74
75#[derive(Debug, Clone)]
76pub struct Candidate {
77    /// The best-matching record among those merged into this candidate.
78    pub record: SourceRecord,
79    /// Every identifier any merged record asserted.
80    pub ids: Vec<WorkId>,
81    pub sightings: Vec<Sighting>,
82    pub assessment: Assessment,
83    /// Editorial notices from every merged record, most detailed kept.
84    pub updates: Vec<Update>,
85}
86
87impl Candidate {
88    /// The first retraction or withdrawal notice, if any.
89    pub fn retraction(&self) -> Option<&Update> {
90        self.updates.iter().find(|u| u.kind.is_retraction())
91    }
92}
93
94/// Adds notices not already known. A detailed notice (with the notice's
95/// identifier) replaces a bare one of the same kind, such as OpenAlex's
96/// `is_retracted` flag.
97pub(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; // already known
105        }
106        if update.notice.is_none() {
107            if !known.iter().any(same_kind) {
108                known.push(update.clone());
109            }
110            continue; // a bare flag adds nothing to a known notice
111        }
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    /// Most confident first.
125    pub candidates: Vec<Candidate>,
126    /// Lookups of the claim's identifiers, if it had any.
127    pub lookups: Vec<LookupResult>,
128    /// Providers that failed; their absence from candidates is not evidence.
129    pub errors: Vec<EngineError>,
130    pub usage: Vec<(ProviderId, Usage)>,
131}
132
133impl Engine {
134    /// Gathers and scores every candidate for `claim`. `limit` caps results
135    /// per provider search.
136    pub async fn resolve(&self, claim: &Claim, limit: u32) -> ResolveReport {
137        self.gather(claim, limit, false).await
138    }
139
140    /// Decides whether `claim` refers to a real work, stopping early once it
141    /// does.
142    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    /// Verifies many claims, several at a time (each provider's pacing
149    /// still applies), yielding results in claim order as they complete.
150    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    /// For candidates known to be retracted only by a bare flag (OpenAlex's
161    /// `is_retracted`), asks the other providers by identifier for the
162    /// notice itself: date, notice DOI, source. Free, cached, and only for
163    /// flagged works.
164    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    /// Looks up every identifier the claims assert, in one batched call per
201    /// provider. With a cache attached, this warms it so verifying the
202    /// claims one by one (and streaming results) doesn't look them up one
203    /// by one.
204    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        // Stage 1, concurrently (both free): lookups of the claimed
231        // identifiers, and citation matchers on the whole reference.
232        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                // Query the title the reference cites, as found via each
267                // hit: a commentary titled "Recommendation of X" yields X.
268                titles.extend(
269                    hits.iter()
270                        .filter_map(|t| matching::shared_title(t, reference)),
271                );
272            }
273        }
274        // An identifier-only claim has nothing else to compare.
275        if done(&acc) || !claim.is_descriptive() {
276            return acc.finish();
277        }
278
279        // Stage 2, concurrently: title searches (or, with no title to go on,
280        // cleaned-up keywords) on every keyword provider. Results are added
281        // in provider-then-title order, so merging stays deterministic.
282        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    /// Providers that search, either citation matchers or keyword engines.
308    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    /// Whether the claim says anything besides identifiers.
322    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    /// Adds a record, merging it into an existing candidate that shares an
334    /// identifier.
335    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                // Pool the years every source reports (online-first vs
353                // print, editions), then keep the better-matching record.
354                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                // And keep a free copy's location, whichever record wins.
358                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        // Judge claimed identifiers against everything known about the work.
384        candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
385    }
386
387    /// Adds one search's hits; returns the hits' titles.
388    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                    // An untitled hit (a book chapter registered under the
402                    // book) points to the cited title via its container.
403                    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    /// A candidate so clearly right that searching further can't improve
429    /// on it: decisive, and either the identifiers the claim asserted or two
430    /// providers vouch for it. A digest or reprint of a paper can match the
431    /// claim exactly while only a later search finds the original.
432    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        // Most confident first; on a tie the earliest-published (an original
447        // precedes its reprints, abstracts and corrections), then the one
448        // more providers agree on.
449        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
467/// Every year a record reports: issued, then other editions or versions.
468fn 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
476/// Adds `pooled` years the record doesn't already report to its
477/// `other_years`.
478fn 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
488/// Titles with duplicates (by normalised form) removed, order kept.
489fn 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); // again: nothing new
524        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}