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, PLAUSIBLE, VERIFIED, id_agreement, same_work_id};
22use aspero_core::{
23    Agreement, Assessment, Claim, FieldSet, ProviderId, SearchQuery, SourceRecord, Update,
24    UpdateKind, Usage, WorkId,
25};
26
27use futures::future::{join, join_all};
28use futures::stream::{self, Stream, StreamExt};
29
30use crate::{Engine, EngineError, LookupResult, SearchReport, add_usage};
31
32/// Title searches per claim: the claim's own title or the matchers' top
33/// distinct titles.
34const MAX_TITLE_SEARCHES: usize = 2;
35
36/// Claims verified at once in batches. Providers pace themselves, so this
37/// overlaps different providers' waits rather than adding load to any one.
38const CLAIM_CONCURRENCY: usize = 4;
39
40/// How a candidate was found.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum Via {
44    Lookup,
45    CitationMatch,
46    TitleSearch,
47    KeywordSearch,
48}
49
50impl Via {
51    pub fn as_str(self) -> &'static str {
52        match self {
53            Via::Lookup => "lookup",
54            Via::CitationMatch => "citation_match",
55            Via::TitleSearch => "title_search",
56            Via::KeywordSearch => "keyword_search",
57        }
58    }
59}
60
61/// One provider surfacing a candidate.
62#[derive(Debug, Clone)]
63pub struct Sighting {
64    pub provider: ProviderId,
65    pub via: Via,
66    /// 1-based rank in that provider's results.
67    pub rank: usize,
68    pub score: Option<f64>,
69}
70
71#[derive(Debug, Clone)]
72pub struct Candidate {
73    /// The best-matching record among those merged into this candidate.
74    pub record: SourceRecord,
75    /// Every identifier any merged record asserted.
76    pub ids: Vec<WorkId>,
77    pub sightings: Vec<Sighting>,
78    pub assessment: Assessment,
79    /// Editorial notices from every merged record, most detailed kept.
80    pub updates: Vec<Update>,
81}
82
83impl Candidate {
84    /// The first retraction or withdrawal notice, if any.
85    pub fn retraction(&self) -> Option<&Update> {
86        self.updates.iter().find(|u| u.kind.is_retraction())
87    }
88}
89
90/// Adds notices not already known. A detailed notice (with the notice's
91/// identifier) replaces a bare one of the same kind, such as OpenAlex's
92/// `is_retracted` flag.
93fn merge_updates(known: &mut Vec<Update>, new: &[Update]) {
94    for update in new {
95        let same_kind = |k: &Update| k.kind == update.kind;
96        if known
97            .iter()
98            .any(|k| same_kind(k) && k.notice == update.notice)
99        {
100            continue; // already known
101        }
102        if update.notice.is_none() {
103            if !known.iter().any(same_kind) {
104                known.push(update.clone());
105            }
106            continue; // a bare flag adds nothing to a known notice
107        }
108        match known
109            .iter_mut()
110            .find(|k| same_kind(k) && k.notice.is_none())
111        {
112            Some(bare) => *bare = update.clone(),
113            None => known.push(update.clone()),
114        }
115    }
116}
117
118#[derive(Debug, Clone, Default)]
119pub struct ResolveReport {
120    /// Most confident first.
121    pub candidates: Vec<Candidate>,
122    /// Lookups of the claim's identifiers, if it had any.
123    pub lookups: Vec<LookupResult>,
124    /// Providers that failed; their absence from candidates is not evidence.
125    pub errors: Vec<EngineError>,
126    pub usage: Vec<(ProviderId, Usage)>,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130#[non_exhaustive]
131pub enum Verdict {
132    /// A work matching the claim exists (and so do its claimed identifiers).
133    Verified,
134    /// A work partly matching the claim exists; check the details.
135    Plausible,
136    /// The claimed identifier is wrong: it doesn't exist, or it belongs to
137    /// a work that doesn't match the rest of the claim.
138    Mismatch,
139    /// No matching work was found.
140    NotFound,
141    /// Providers failed, so nothing conclusive can be said.
142    Unverifiable,
143}
144
145impl Verdict {
146    pub fn as_str(self) -> &'static str {
147        match self {
148            Verdict::Verified => "verified",
149            Verdict::Plausible => "plausible",
150            Verdict::Mismatch => "mismatch",
151            Verdict::NotFound => "not_found",
152            Verdict::Unverifiable => "unverifiable",
153        }
154    }
155}
156
157#[derive(Debug, Clone)]
158pub struct Verification {
159    pub verdict: Verdict,
160    /// One sentence, for people and agents.
161    pub reason: String,
162    /// The candidate the verdict is about, as an index into
163    /// `report.candidates`: the matching work, or the best guess at what was
164    /// meant.
165    pub best: Option<usize>,
166    pub report: ResolveReport,
167}
168
169impl Engine {
170    /// Gathers and scores every candidate for `claim`. `limit` caps results
171    /// per provider search.
172    pub async fn resolve(&self, claim: &Claim, limit: u32) -> ResolveReport {
173        self.gather(claim, limit, false).await
174    }
175
176    /// Decides whether `claim` refers to a real work, stopping early once it
177    /// does.
178    pub async fn verify(&self, claim: &Claim, limit: u32) -> Verification {
179        let mut report = self.gather(claim, limit, true).await;
180        self.detail_notices(&mut report).await;
181        judge(claim, report)
182    }
183
184    /// Verifies many claims, several at a time (each provider's pacing
185    /// still applies), yielding results in claim order as they complete.
186    pub fn verify_many<'a>(
187        &'a self,
188        claims: &'a [Claim],
189        limit: u32,
190    ) -> impl Stream<Item = Verification> + 'a {
191        stream::iter(claims)
192            .map(move |claim| self.verify(claim, limit))
193            .buffered(CLAIM_CONCURRENCY)
194    }
195
196    /// For candidates known to be retracted only by a bare flag (OpenAlex's
197    /// `is_retracted`), asks the other providers by identifier for the
198    /// notice itself: date, notice DOI, source. Free, cached, and only for
199    /// flagged works.
200    async fn detail_notices(&self, report: &mut ResolveReport) {
201        let bare = |c: &Candidate| {
202            c.updates
203                .iter()
204                .any(|u| u.kind.is_retraction() && u.notice.is_none())
205        };
206        for entry in &self.entries {
207            let provider = entry.provider.id();
208            let kinds = &entry.provider.capabilities().lookup_kinds;
209            let wanted: Vec<(usize, WorkId)> = report
210                .candidates
211                .iter()
212                .enumerate()
213                .filter(|(_, c)| bare(c) && !c.sightings.iter().any(|s| s.provider == provider))
214                .filter_map(|(i, c)| {
215                    Some((
216                        i,
217                        c.ids.iter().find(|id| kinds.contains(&id.kind()))?.clone(),
218                    ))
219                })
220                .collect();
221            if wanted.is_empty() {
222                continue;
223            }
224            let ids: Vec<WorkId> = wanted.iter().map(|(_, id)| id.clone()).collect();
225            let answers = self
226                .lookup_at(entry, &ids, FieldSet::ALL, &mut report.usage)
227                .await;
228            for ((i, _), (_, record)) in wanted.into_iter().zip(answers) {
229                if let Some(record) = record {
230                    merge_updates(&mut report.candidates[i].updates, &record.work.updates);
231                }
232            }
233        }
234    }
235
236    /// Looks up every identifier the claims assert, in one batched call per
237    /// provider. With a cache attached, this warms it so verifying the
238    /// claims one by one (and streaming results) doesn't look them up one
239    /// by one.
240    pub async fn prefetch(&self, claims: &[Claim]) -> crate::LookupReport {
241        let mut ids: Vec<WorkId> = Vec::new();
242        for id in claims.iter().flat_map(|c| &c.ids) {
243            if !ids.contains(id) {
244                ids.push(id.clone());
245            }
246        }
247        self.lookup(&ids, FieldSet::DEFAULT).await
248    }
249
250    async fn gather(&self, claim: &Claim, limit: u32, stop_when_verified: bool) -> ResolveReport {
251        let mut acc = Accumulator {
252            claim,
253            report: ResolveReport::default(),
254        };
255        let done = |acc: &Accumulator| stop_when_verified && acc.verified();
256        let query = |text: &str, citation: bool| {
257            let mut q = if citation {
258                SearchQuery::citation(text)
259            } else {
260                SearchQuery::new(text)
261            };
262            q.page.limit = limit;
263            q
264        };
265
266        // Stage 1, concurrently (both free): lookups of the claimed
267        // identifiers, and citation matchers on the whole reference.
268        let reference = claim.reference();
269        let matchers: Vec<ProviderId> = match &reference {
270            Some(_) => self.searchers(true),
271            None => Vec::new(),
272        };
273        let lookup = async {
274            match claim.ids.is_empty() {
275                true => None,
276                false => Some(self.lookup(&claim.ids, FieldSet::DEFAULT).await),
277            }
278        };
279        let matched = join_all(matchers.iter().map(|p| {
280            let q = query(reference.as_deref().unwrap_or_default(), true);
281            async move { self.search(Some(p.as_str()), &q).await }
282        }));
283        let (lookup, matched) = join(lookup, matched).await;
284
285        if let Some(lookup) = lookup {
286            for record in lookup.results.iter().filter_map(|r| r.record.as_ref()) {
287                let sighting = Sighting {
288                    provider: record.source.clone(),
289                    via: Via::Lookup,
290                    rank: 1,
291                    score: None,
292                };
293                acc.add(record.clone(), sighting);
294            }
295            acc.usage(lookup.usage);
296            acc.report.lookups = lookup.results;
297        }
298        let mut titles: Vec<String> = claim.title.iter().cloned().collect();
299        for (provider, result) in matchers.iter().zip(matched) {
300            let hits = acc.add_search(provider, Via::CitationMatch, result);
301            if let (None, Some(reference)) = (&claim.title, &reference) {
302                // Query the title the reference cites, as found via each
303                // hit: a commentary titled "Recommendation of X" yields X.
304                titles.extend(
305                    hits.iter()
306                        .filter_map(|t| matching::shared_title(t, reference)),
307                );
308            }
309        }
310        // An identifier-only claim has nothing else to compare.
311        if done(&acc) || !claim.is_descriptive() {
312            return acc.finish();
313        }
314
315        // Stage 2, concurrently: title searches (or, with no title to go on,
316        // cleaned-up keywords) on every keyword provider. Results are added
317        // in provider-then-title order, so merging stays deterministic.
318        titles = distinct(titles);
319        titles.truncate(MAX_TITLE_SEARCHES);
320        let mut jobs: Vec<(ProviderId, SearchQuery, Via)> = Vec::new();
321        for provider in self.searchers(false) {
322            if titles.is_empty()
323                && let Some(text) = &claim.text
324            {
325                let q = query(&matching::search_terms(text), false);
326                jobs.push((provider.clone(), q, Via::KeywordSearch));
327            }
328            for title in &titles {
329                jobs.push((provider.clone(), query(title, false), Via::TitleSearch));
330            }
331        }
332        let results = join_all(
333            jobs.iter()
334                .map(|(p, q, _)| self.search(Some(p.as_str()), q)),
335        )
336        .await;
337        for ((provider, _, via), result) in jobs.iter().zip(results) {
338            acc.add_search(provider, *via, result);
339        }
340        acc.finish()
341    }
342
343    /// Providers that search, either citation matchers or keyword engines.
344    fn searchers(&self, matchers: bool) -> Vec<ProviderId> {
345        self.providers()
346            .filter(|p| p.capabilities().search && p.capabilities().citation_search == matchers)
347            .map(|p| p.id())
348            .collect()
349    }
350}
351
352trait ClaimExt {
353    fn is_descriptive(&self) -> bool;
354}
355
356impl ClaimExt for Claim {
357    /// Whether the claim says anything besides identifiers.
358    fn is_descriptive(&self) -> bool {
359        self.text.is_some() || self.title.is_some() || !self.authors.is_empty()
360    }
361}
362
363struct Accumulator<'a> {
364    claim: &'a Claim,
365    report: ResolveReport,
366}
367
368impl Accumulator<'_> {
369    /// Adds a record, merging it into an existing candidate that shares an
370    /// identifier.
371    fn add(&mut self, record: SourceRecord, sighting: Sighting) {
372        let assessment = matching::assess(self.claim, &record);
373        let candidates = &mut self.report.candidates;
374        let existing = candidates.iter_mut().find(|c| {
375            c.ids
376                .iter()
377                .any(|a| record.ids.iter().any(|b| same_work_id(a, b)))
378        });
379        let candidate = match existing {
380            Some(c) => {
381                for id in &record.ids {
382                    if !c.ids.iter().any(|known| same_work_id(known, id)) {
383                        c.ids.push(id.clone());
384                    }
385                }
386                c.sightings.push(sighting);
387                merge_updates(&mut c.updates, &record.work.updates);
388                if assessment.confidence > c.assessment.confidence {
389                    c.record = record;
390                    c.assessment = assessment;
391                }
392                c
393            }
394            None => {
395                let mut updates = Vec::new();
396                merge_updates(&mut updates, &record.work.updates);
397                candidates.push(Candidate {
398                    ids: record.ids.clone(),
399                    updates,
400                    record,
401                    sightings: vec![sighting],
402                    assessment,
403                });
404                candidates.last_mut().expect("just pushed")
405            }
406        };
407        // Judge claimed identifiers against everything known about the work.
408        candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
409    }
410
411    /// Adds one search's hits; returns the hits' titles.
412    fn add_search(
413        &mut self,
414        provider: &ProviderId,
415        via: Via,
416        result: Result<SearchReport, EngineError>,
417    ) -> Vec<String> {
418        match result {
419            Ok(report) => {
420                if let Some(u) = report.usage {
421                    add_usage(&mut self.report.usage, provider, u);
422                }
423                let mut titles = Vec::new();
424                for (i, hit) in report.page.hits.into_iter().enumerate() {
425                    // An untitled hit (a book chapter registered under the
426                    // book) points to the cited title via its container.
427                    let w = &hit.record.work;
428                    titles.extend(w.title.clone().or_else(|| w.container_title.clone()));
429                    let sighting = Sighting {
430                        provider: provider.clone(),
431                        via,
432                        rank: i + 1,
433                        score: hit.score,
434                    };
435                    self.add(hit.record, sighting);
436                }
437                titles
438            }
439            Err(e) => {
440                self.report.errors.push(e);
441                Vec::new()
442            }
443        }
444    }
445
446    fn usage(&mut self, usage: Vec<(ProviderId, Usage)>) {
447        for (provider, u) in usage {
448            add_usage(&mut self.report.usage, &provider, u);
449        }
450    }
451
452    /// A candidate already verifies the claim (including its identifiers,
453    /// if it asserted any).
454    fn verified(&self) -> bool {
455        self.report.candidates.iter().any(|c| {
456            c.assessment.confidence >= VERIFIED
457                && (self.claim.ids.is_empty() || c.assessment.ids == Agreement::Match)
458        })
459    }
460
461    fn finish(mut self) -> ResolveReport {
462        self.report.candidates.sort_by(|a, b| {
463            b.assessment
464                .confidence
465                .total_cmp(&a.assessment.confidence)
466                .then(b.sightings.len().cmp(&a.sightings.len()))
467        });
468        self.report
469    }
470}
471
472/// Titles with duplicates (by normalised form) removed, order kept.
473fn distinct(titles: Vec<String>) -> Vec<String> {
474    let mut seen = Vec::new();
475    titles
476        .into_iter()
477        .filter(|t| {
478            let norm = matching::normalise(t);
479            let new = !norm.is_empty() && !seen.contains(&norm);
480            seen.push(norm);
481            new
482        })
483        .collect()
484}
485
486/// The same title plus matching authors *or* year, with the other wrong: a
487/// real work cited with an error (wrong year, misattributed authors), not a
488/// missing one. The title alone proves little: short titles like "Protein
489/// Folding" occur inside many longer ones.
490fn near_miss(c: &Candidate) -> bool {
491    let a = &c.assessment;
492    a.title.agreement == Agreement::Match
493        && (a.authors.agreement == Agreement::Match || a.year.agreement == Agreement::Match)
494}
495
496/// The fields that don't fully agree, as a clause: "its year (2017)
497/// differs", "its authors and year (2017) differ".
498fn differences(c: &Candidate) -> String {
499    let a = &c.assessment;
500    let year = c
501        .record
502        .work
503        .issued
504        .map(|d| format!(" ({})", d.year()))
505        .unwrap_or_default();
506    let fields: Vec<String> = [
507        (a.title.agreement != Agreement::Match).then(|| "title".to_owned()),
508        (a.authors.agreement != Agreement::Match).then(|| "authors".to_owned()),
509        matches!(a.year.agreement, Agreement::Mismatch | Agreement::Partial)
510            .then(|| format!("year{year}")),
511    ]
512    .into_iter()
513    .flatten()
514    .collect();
515    match fields.len() {
516        0 => "its details differ".into(),
517        1 => format!("its {} differs", fields[0]),
518        _ => format!("its {} differ", fields.join(" and ")),
519    }
520}
521
522fn describe(c: &Candidate) -> String {
523    let w = &c.record.work;
524    let title = w.title.as_deref().unwrap_or("(untitled)");
525    let year = w
526        .issued
527        .map(|d| format!(" ({})", d.year()))
528        .unwrap_or_default();
529    let id = c.ids.first().map(|i| format!(", {i}")).unwrap_or_default();
530    format!("\"{title}\"{year}{id}")
531}
532
533/// Turns gathered candidates into a verdict.
534fn judge(claim: &Claim, report: ResolveReport) -> Verification {
535    let best = report.candidates.first();
536    let best_conf = best.map_or(0.0, |c| c.assessment.confidence);
537    let failed = !report.errors.is_empty()
538        || report
539            .lookups
540            .iter()
541            .any(|l| l.record.is_none() && !l.is_not_found());
542
543    let (verdict, reason, best_index) = if claim.ids.is_empty() {
544        match best {
545            Some(c) if best_conf >= VERIFIED => (
546                Verdict::Verified,
547                format!("matches {}", describe(c)),
548                Some(0),
549            ),
550            Some(c) if best_conf >= PLAUSIBLE => (
551                Verdict::Plausible,
552                format!("partly matches {}; check the details", describe(c)),
553                Some(0),
554            ),
555            Some(c) if near_miss(c) => (
556                Verdict::Mismatch,
557                format!("closest work is {}, but {}", describe(c), differences(c)),
558                Some(0),
559            ),
560            _ if failed => (
561                Verdict::Unverifiable,
562                "providers failed; nothing conclusive".into(),
563                None,
564            ),
565            _ => (Verdict::NotFound, "no matching work found".into(), None),
566        }
567    } else {
568        let claimed = claim
569            .ids
570            .iter()
571            .map(ToString::to_string)
572            .collect::<Vec<_>>()
573            .join(", ");
574        let by_id = report
575            .candidates
576            .iter()
577            .position(|c| c.assessment.ids == Agreement::Match);
578        let elsewhere = |skip: Option<usize>| {
579            report
580                .candidates
581                .iter()
582                .enumerate()
583                .find(|(i, c)| Some(*i) != skip && c.assessment.confidence >= PLAUSIBLE)
584        };
585        match by_id.map(|i| (i, &report.candidates[i])) {
586            Some((i, c)) if !claim.is_descriptive() => (
587                Verdict::Verified,
588                format!("{claimed} exists: {}", describe(c)),
589                Some(i),
590            ),
591            Some((i, c)) if c.assessment.confidence >= VERIFIED => (
592                Verdict::Verified,
593                format!("{claimed} and the citation agree: {}", describe(c)),
594                Some(i),
595            ),
596            Some((i, c)) if c.assessment.confidence >= PLAUSIBLE => (
597                Verdict::Plausible,
598                format!(
599                    "{claimed} exists and partly matches the citation: {}",
600                    describe(c)
601                ),
602                Some(i),
603            ),
604            Some((i, c)) => match elsewhere(Some(i)) {
605                Some((j, other)) => (
606                    Verdict::Mismatch,
607                    format!(
608                        "{claimed} is {}, but the citation matches {}",
609                        describe(c),
610                        describe(other)
611                    ),
612                    Some(j),
613                ),
614                None => (
615                    Verdict::Mismatch,
616                    format!(
617                        "{claimed} is {}, which does not match the citation",
618                        describe(c)
619                    ),
620                    Some(i),
621                ),
622            },
623            None if failed => (
624                Verdict::Unverifiable,
625                format!("could not look up {claimed}: providers failed"),
626                None,
627            ),
628            None => match elsewhere(None) {
629                Some((j, other)) => (
630                    Verdict::Mismatch,
631                    format!(
632                        "{claimed} does not exist; the citation matches {}",
633                        describe(other)
634                    ),
635                    Some(j),
636                ),
637                None => (
638                    Verdict::NotFound,
639                    format!("{claimed} does not exist and no matching work was found"),
640                    None,
641                ),
642            },
643        }
644    };
645    // Existing is not the same as citable: say so whatever the verdict.
646    let reason = match best_index.and_then(|i| report.candidates[i].retraction()) {
647        Some(r) => format!("{reason}; note: {}", describe_update(r)),
648        None => reason,
649    };
650    Verification {
651        verdict,
652        reason,
653        best: best_index,
654        report,
655    }
656}
657
658/// "RETRACTED (2010-02-06, notice doi:…, via retraction-watch)".
659pub fn describe_update(u: &Update) -> String {
660    let what = match &u.kind {
661        UpdateKind::Retraction => "RETRACTED".to_owned(),
662        UpdateKind::Withdrawal => "WITHDRAWN".to_owned(),
663        UpdateKind::ExpressionOfConcern => "expression of concern".to_owned(),
664        UpdateKind::Correction => "corrected".to_owned(),
665        other => other.as_str().replace('_', " "),
666    };
667    let details: Vec<String> = [
668        u.date.map(|d| d.to_string()),
669        u.notice.as_ref().map(|n| format!("notice {n}")),
670        u.source.as_ref().map(|s| format!("via {s}")),
671    ]
672    .into_iter()
673    .flatten()
674    .collect();
675    if details.is_empty() {
676        what
677    } else {
678        format!("{what} ({})", details.join(", "))
679    }
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    fn update(kind: UpdateKind, notice: Option<&str>) -> Update {
687        let mut u = Update::new(kind);
688        u.notice = notice.map(|n| n.parse().unwrap());
689        u
690    }
691
692    #[test]
693    fn detailed_notices_replace_bare_flags_without_duplicates() {
694        let mut known = vec![update(UpdateKind::Retraction, None)];
695        let crossref = [
696            update(UpdateKind::Correction, Some("10.1000/c1")),
697            update(UpdateKind::Retraction, Some("10.1000/r")),
698            update(UpdateKind::Correction, Some("10.1000/c2")),
699        ];
700        merge_updates(&mut known, &crossref);
701        merge_updates(&mut known, &crossref); // again: nothing new
702        merge_updates(&mut known, &[update(UpdateKind::Retraction, None)]);
703        let summary: Vec<(String, Option<String>)> = known
704            .iter()
705            .map(|u| {
706                (
707                    u.kind.as_str().to_owned(),
708                    u.notice.as_ref().map(ToString::to_string),
709                )
710            })
711            .collect();
712        assert_eq!(
713            summary,
714            [
715                ("retraction".into(), Some("doi:10.1000/r".into())),
716                ("correction".into(), Some("doi:10.1000/c1".into())),
717                ("correction".into(), Some("doi:10.1000/c2".into())),
718            ]
719        );
720    }
721}