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