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                c.assessment = matching::assess(self.claim, &c.record);
397                let assessment = matching::assess(self.claim, &record);
398                if assessment.confidence > c.assessment.confidence {
399                    c.record = record;
400                    c.assessment = assessment;
401                }
402                c
403            }
404            None => {
405                let mut updates = Vec::new();
406                merge_updates(&mut updates, &record.work.updates);
407                candidates.push(Candidate {
408                    ids: record.ids.clone(),
409                    updates,
410                    record,
411                    sightings: vec![sighting],
412                    assessment,
413                });
414                candidates.last_mut().expect("just pushed")
415            }
416        };
417        // Judge claimed identifiers against everything known about the work.
418        candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
419    }
420
421    /// Adds one search's hits; returns the hits' titles.
422    fn add_search(
423        &mut self,
424        provider: &ProviderId,
425        via: Via,
426        result: Result<SearchReport, EngineError>,
427    ) -> Vec<String> {
428        match result {
429            Ok(report) => {
430                if let Some(u) = report.usage {
431                    add_usage(&mut self.report.usage, provider, u);
432                }
433                let mut titles = Vec::new();
434                for (i, hit) in report.page.hits.into_iter().enumerate() {
435                    // An untitled hit (a book chapter registered under the
436                    // book) points to the cited title via its container.
437                    let w = &hit.record.work;
438                    titles.extend(w.title.clone().or_else(|| w.container_title.clone()));
439                    let sighting = Sighting {
440                        provider: provider.clone(),
441                        via,
442                        rank: i + 1,
443                        score: hit.score,
444                    };
445                    self.add(hit.record, sighting);
446                }
447                titles
448            }
449            Err(e) => {
450                self.report.errors.push(e);
451                Vec::new()
452            }
453        }
454    }
455
456    fn usage(&mut self, usage: Vec<(ProviderId, Usage)>) {
457        for (provider, u) in usage {
458            add_usage(&mut self.report.usage, &provider, u);
459        }
460    }
461
462    /// A candidate so clearly right that searching further can't improve
463    /// on it: decisive, and either the identifiers the claim asserted or two
464    /// providers vouch for it. A digest or reprint of a paper can match the
465    /// claim exactly while only a later search finds the original.
466    fn verified(&self) -> bool {
467        self.report.candidates.iter().any(|c| {
468            let corroborated = match self.claim.ids.is_empty() {
469                true => c
470                    .sightings
471                    .iter()
472                    .any(|s| s.provider != c.sightings[0].provider),
473                false => c.assessment.ids == Agreement::Match,
474            };
475            c.assessment.confidence >= DECISIVE && corroborated
476        })
477    }
478
479    fn finish(mut self) -> ResolveReport {
480        // Most confident first; on a tie the earliest-published (an original
481        // precedes its reprints, abstracts and corrections), then the one
482        // more providers agree on.
483        let issued = |c: &Candidate| {
484            c.record.work.issued.map_or(i64::MAX, |d| {
485                i64::from(d.year()) * 10_000
486                    + i64::from(d.month().unwrap_or(12)) * 100
487                    + i64::from(d.day().unwrap_or(31))
488            })
489        };
490        self.report.candidates.sort_by(|a, b| {
491            b.assessment
492                .confidence
493                .total_cmp(&a.assessment.confidence)
494                .then(issued(a).cmp(&issued(b)))
495                .then(b.sightings.len().cmp(&a.sightings.len()))
496        });
497        self.report
498    }
499}
500
501/// Every year a record reports: issued, then other editions or versions.
502fn years(record: &SourceRecord) -> impl Iterator<Item = i16> + '_ {
503    let w = &record.work;
504    w.issued
505        .map(|d| d.year())
506        .into_iter()
507        .chain(w.other_years.iter().copied())
508}
509
510/// Adds `pooled` years the record doesn't already report to its
511/// `other_years`.
512fn add_years(record: &mut SourceRecord, pooled: &[i16]) {
513    let issued = record.work.issued.map(|d| d.year());
514    for &y in pooled {
515        if Some(y) != issued && !record.work.other_years.contains(&y) {
516            record.work.other_years.push(y);
517        }
518    }
519    record.work.other_years.sort_unstable();
520}
521
522/// Titles with duplicates (by normalised form) removed, order kept.
523fn distinct(titles: Vec<String>) -> Vec<String> {
524    let mut seen = Vec::new();
525    titles
526        .into_iter()
527        .filter(|t| {
528            let norm = matching::normalise(t);
529            let new = !norm.is_empty() && !seen.contains(&norm);
530            seen.push(norm);
531            new
532        })
533        .collect()
534}
535
536/// The same title plus matching authors *or* year, with the other wrong: a
537/// real work cited with an error (wrong year, misattributed authors), not a
538/// missing one. The title alone proves little: short titles like "Protein
539/// Folding" occur inside many longer ones.
540fn near_miss(c: &Candidate) -> bool {
541    let a = &c.assessment;
542    a.title.agreement == Agreement::Match
543        && (a.authors.agreement == Agreement::Match || a.year.agreement == Agreement::Match)
544}
545
546/// The fields that don't fully agree, as a clause: "its year (2017)
547/// differs", "its authors and year (2017) differ".
548fn differences(c: &Candidate) -> String {
549    let a = &c.assessment;
550    let year = c
551        .record
552        .work
553        .issued
554        .map(|d| format!(" ({})", d.year()))
555        .unwrap_or_default();
556    let fields: Vec<String> = [
557        (a.title.agreement != Agreement::Match).then(|| "title".to_owned()),
558        (a.authors.agreement != Agreement::Match).then(|| "authors".to_owned()),
559        matches!(a.year.agreement, Agreement::Mismatch | Agreement::Partial)
560            .then(|| format!("year{year}")),
561    ]
562    .into_iter()
563    .flatten()
564    .collect();
565    match fields.len() {
566        0 => "its details differ".into(),
567        1 => format!("its {} differs", fields[0]),
568        _ => format!("its {} differ", fields.join(" and ")),
569    }
570}
571
572fn describe(c: &Candidate) -> String {
573    let w = &c.record.work;
574    let title = w.title.as_deref().unwrap_or("(untitled)");
575    let year = w
576        .issued
577        .map(|d| format!(" ({})", d.year()))
578        .unwrap_or_default();
579    let id = c.ids.first().map(|i| format!(", {i}")).unwrap_or_default();
580    format!("\"{title}\"{year}{id}")
581}
582
583/// Turns gathered candidates into a verdict.
584fn judge(claim: &Claim, report: ResolveReport) -> Verification {
585    let best = report.candidates.first();
586    let best_conf = best.map_or(0.0, |c| c.assessment.confidence);
587    let failed = !report.errors.is_empty()
588        || report
589            .lookups
590            .iter()
591            .any(|l| l.record.is_none() && !l.is_not_found());
592
593    let (verdict, reason, best_index) = if claim.ids.is_empty() {
594        match best {
595            Some(c) if best_conf >= VERIFIED => (
596                Verdict::Verified,
597                format!("matches {}", describe(c)),
598                Some(0),
599            ),
600            Some(c) if best_conf >= PLAUSIBLE => (
601                Verdict::Plausible,
602                format!("partly matches {}; check the details", describe(c)),
603                Some(0),
604            ),
605            Some(c) if near_miss(c) => (
606                Verdict::Mismatch,
607                format!("closest work is {}, but {}", describe(c), differences(c)),
608                Some(0),
609            ),
610            _ if failed => (
611                Verdict::Unverifiable,
612                "providers failed; nothing conclusive".into(),
613                None,
614            ),
615            _ => (Verdict::NotFound, "no matching work found".into(), None),
616        }
617    } else {
618        let claimed = claim
619            .ids
620            .iter()
621            .map(ToString::to_string)
622            .collect::<Vec<_>>()
623            .join(", ");
624        let by_id = report
625            .candidates
626            .iter()
627            .position(|c| c.assessment.ids == Agreement::Match);
628        let elsewhere = |skip: Option<usize>| {
629            report
630                .candidates
631                .iter()
632                .enumerate()
633                .find(|(i, c)| Some(*i) != skip && c.assessment.confidence >= PLAUSIBLE)
634        };
635        match by_id.map(|i| (i, &report.candidates[i])) {
636            Some((i, c)) if !claim.is_descriptive() => (
637                Verdict::Verified,
638                format!("{claimed} exists: {}", describe(c)),
639                Some(i),
640            ),
641            Some((i, c)) if c.assessment.confidence >= VERIFIED => (
642                Verdict::Verified,
643                format!("{claimed} and the citation agree: {}", describe(c)),
644                Some(i),
645            ),
646            Some((i, c)) if c.assessment.confidence >= PLAUSIBLE => (
647                Verdict::Plausible,
648                format!(
649                    "{claimed} exists and partly matches the citation: {}",
650                    describe(c)
651                ),
652                Some(i),
653            ),
654            Some((i, c)) => match elsewhere(Some(i)) {
655                Some((j, other)) => (
656                    Verdict::Mismatch,
657                    format!(
658                        "{claimed} is {}, but the citation matches {}",
659                        describe(c),
660                        describe(other)
661                    ),
662                    Some(j),
663                ),
664                None => (
665                    Verdict::Mismatch,
666                    format!(
667                        "{claimed} is {}, which does not match the citation",
668                        describe(c)
669                    ),
670                    Some(i),
671                ),
672            },
673            None if failed => (
674                Verdict::Unverifiable,
675                format!("could not look up {claimed}: providers failed"),
676                None,
677            ),
678            None => match elsewhere(None) {
679                Some((j, other)) => (
680                    Verdict::Mismatch,
681                    format!(
682                        "{claimed} does not exist; the citation matches {}",
683                        describe(other)
684                    ),
685                    Some(j),
686                ),
687                None => (
688                    Verdict::NotFound,
689                    format!("{claimed} does not exist and no matching work was found"),
690                    None,
691                ),
692            },
693        }
694    };
695    // Existing is not the same as citable: say so whatever the verdict.
696    let reason = match best_index.and_then(|i| report.candidates[i].retraction()) {
697        Some(r) => format!("{reason}; note: {}", describe_update(r)),
698        None => reason,
699    };
700    Verification {
701        verdict,
702        reason,
703        best: best_index,
704        report,
705    }
706}
707
708/// "RETRACTED (2010-02-06, notice doi:…, via retraction-watch)".
709pub fn describe_update(u: &Update) -> String {
710    let what = match &u.kind {
711        UpdateKind::Retraction => "RETRACTED".to_owned(),
712        UpdateKind::Withdrawal => "WITHDRAWN".to_owned(),
713        UpdateKind::ExpressionOfConcern => "expression of concern".to_owned(),
714        UpdateKind::Correction => "corrected".to_owned(),
715        other => other.as_str().replace('_', " "),
716    };
717    let details: Vec<String> = [
718        u.date.map(|d| d.to_string()),
719        u.notice.as_ref().map(|n| format!("notice {n}")),
720        u.source.as_ref().map(|s| format!("via {s}")),
721    ]
722    .into_iter()
723    .flatten()
724    .collect();
725    if details.is_empty() {
726        what
727    } else {
728        format!("{what} ({})", details.join(", "))
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    fn update(kind: UpdateKind, notice: Option<&str>) -> Update {
737        let mut u = Update::new(kind);
738        u.notice = notice.map(|n| n.parse().unwrap());
739        u
740    }
741
742    #[test]
743    fn detailed_notices_replace_bare_flags_without_duplicates() {
744        let mut known = vec![update(UpdateKind::Retraction, None)];
745        let crossref = [
746            update(UpdateKind::Correction, Some("10.1000/c1")),
747            update(UpdateKind::Retraction, Some("10.1000/r")),
748            update(UpdateKind::Correction, Some("10.1000/c2")),
749        ];
750        merge_updates(&mut known, &crossref);
751        merge_updates(&mut known, &crossref); // again: nothing new
752        merge_updates(&mut known, &[update(UpdateKind::Retraction, None)]);
753        let summary: Vec<(String, Option<String>)> = known
754            .iter()
755            .map(|u| {
756                (
757                    u.kind.as_str().to_owned(),
758                    u.notice.as_ref().map(ToString::to_string),
759                )
760            })
761            .collect();
762        assert_eq!(
763            summary,
764            [
765                ("retraction".into(), Some("doi:10.1000/r".into())),
766                ("correction".into(), Some("doi:10.1000/c1".into())),
767                ("correction".into(), Some("doi:10.1000/c2".into())),
768            ]
769        );
770    }
771}