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, Filter, IdKind, ProviderId, SearchQuery, SourceRecord,
24    Update, Usage, WorkId, YearRange,
25};
26
27use futures::future::{Either, join_all, select};
28use futures::stream::{self, FuturesUnordered, Stream, StreamExt};
29
30use crate::verdict::{Verification, judge};
31use crate::{Engine, EngineError, LookupResult, SearchReport, add_usage};
32
33/// Title searches per claim: the claim's own title or the matchers' top
34/// distinct titles.
35const MAX_TITLE_SEARCHES: usize = 2;
36
37/// Confidence at which `verify` stops gathering candidates early.
38const DECISIVE: f64 = 0.95;
39
40/// Claims verified at once in batches. Providers pace themselves, so this
41/// overlaps different providers' waits rather than adding load to any one;
42/// and it keeps a batch from queueing so many requests that a caller
43/// sharing the engine (someone waiting) is stuck behind them.
44const CLAIM_CONCURRENCY: usize = 4;
45/// Results per search by volume and first page: those of every journal
46/// that year, from which the journal picks.
47const PLACE_LIMIT: u32 = 25;
48
49/// How a candidate was found.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51#[non_exhaustive]
52pub enum Via {
53    Lookup,
54    CitationMatch,
55    TitleSearch,
56    KeywordSearch,
57    /// A search by volume and first page.
58    PlaceSearch,
59}
60
61impl Via {
62    pub fn as_str(self) -> &'static str {
63        match self {
64            Via::Lookup => "lookup",
65            Via::CitationMatch => "citation_match",
66            Via::TitleSearch => "title_search",
67            Via::KeywordSearch => "keyword_search",
68            Via::PlaceSearch => "place_search",
69        }
70    }
71}
72
73/// One provider surfacing a candidate.
74#[derive(Debug, Clone)]
75pub struct Sighting {
76    pub provider: ProviderId,
77    pub via: Via,
78    /// 1-based rank in that provider's results.
79    pub rank: usize,
80    pub score: Option<f64>,
81}
82
83#[derive(Debug, Clone)]
84pub struct Candidate {
85    /// The best-matching record among those merged into this candidate.
86    pub record: SourceRecord,
87    /// Every identifier any merged record asserted.
88    pub ids: Vec<WorkId>,
89    pub sightings: Vec<Sighting>,
90    pub assessment: Assessment,
91    /// Editorial notices from every merged record, most detailed kept.
92    pub updates: Vec<Update>,
93}
94
95impl Candidate {
96    /// The first retraction or withdrawal notice, if any.
97    pub fn retraction(&self) -> Option<&Update> {
98        self.updates.iter().find(|u| u.kind.is_retraction())
99    }
100}
101
102/// Adds notices not already known. A detailed notice (with the notice's
103/// identifier) replaces a bare one of the same kind, such as OpenAlex's
104/// `is_retracted` flag.
105pub(crate) fn merge_updates(known: &mut Vec<Update>, new: &[Update]) {
106    for update in new {
107        let same_kind = |k: &Update| k.kind == update.kind;
108        if known
109            .iter()
110            .any(|k| same_kind(k) && k.notice == update.notice)
111        {
112            continue; // already known
113        }
114        if update.notice.is_none() {
115            if !known.iter().any(same_kind) {
116                known.push(update.clone());
117            }
118            continue; // a bare flag adds nothing to a known notice
119        }
120        match known
121            .iter_mut()
122            .find(|k| same_kind(k) && k.notice.is_none())
123        {
124            Some(bare) => *bare = update.clone(),
125            None => known.push(update.clone()),
126        }
127    }
128}
129
130#[derive(Debug, Clone, Default)]
131pub struct ResolveReport {
132    /// Most confident first.
133    pub candidates: Vec<Candidate>,
134    /// Lookups of the claim's identifiers, if it had any.
135    pub lookups: Vec<LookupResult>,
136    /// Providers that failed; their absence from candidates is not evidence.
137    pub errors: Vec<EngineError>,
138    pub usage: Vec<(ProviderId, Usage)>,
139}
140
141impl ResolveReport {
142    /// Every failure: providers' searches, and lookups of the claimed
143    /// identifiers that failed rather than finding nothing.
144    pub fn failures(&self) -> Vec<EngineError> {
145        let lookups = self
146            .lookups
147            .iter()
148            .filter(|l| l.record.is_none())
149            .flat_map(|l| &l.attempts)
150            .filter_map(|a| match &a.outcome {
151                crate::Outcome::Failed(error) => Some(EngineError::Provider {
152                    provider: a.provider.clone(),
153                    error: error.clone(),
154                }),
155                _ => None,
156            });
157        self.errors.iter().cloned().chain(lookups).collect()
158    }
159}
160
161impl Engine {
162    /// Gathers and scores every candidate for `claim`. `limit` caps results
163    /// per provider search.
164    pub async fn resolve(&self, claim: &Claim, limit: u32) -> ResolveReport {
165        self.gather(claim, limit, false).await
166    }
167
168    /// Decides whether `claim` refers to a real work, stopping early once it
169    /// does.
170    pub async fn verify(&self, claim: &Claim, limit: u32) -> Verification {
171        // Alternative readings of claimed identifiers are looked up with
172        // them, alongside the citation matchers; which was meant is settled
173        // after, so a broken DOI costs no extra round of lookups.
174        let mut wide = claim.clone();
175        for a in &claim.alternatives {
176            if claim.ids.contains(&a.claimed) && !wide.ids.contains(&a.alternative) {
177                wide.ids.push(a.alternative.clone());
178            }
179        }
180        let mut report = self.gather(&wide, limit, true).await;
181        let settled = settle_alternatives(claim, &mut report);
182        self.detail_notices(&mut report).await;
183        let mut v = judge(&settled, report);
184        // As written, not as settled: a DOI cut short at a line break shows
185        // beside the one found.
186        v.read = claim.as_read();
187        v
188    }
189
190    /// Verifies many claims, several at a time (each provider's pacing
191    /// still applies), yielding each with its claim's index as soon as it
192    /// completes: a slow claim doesn't hold up the rest.
193    pub fn verify_many<'a>(
194        &'a self,
195        claims: &'a [Claim],
196        limit: u32,
197    ) -> impl Stream<Item = (usize, Verification)> + 'a {
198        stream::iter(claims.iter().enumerate())
199            .map(move |(i, claim)| async move { (i, self.verify(claim, limit).await) })
200            .buffer_unordered(CLAIM_CONCURRENCY)
201    }
202
203    /// For candidates known to be retracted only by a bare flag (OpenAlex's
204    /// `is_retracted`), asks the other providers by identifier for the
205    /// notice itself: date, notice DOI, source. Free, cached, and only for
206    /// flagged works.
207    async fn detail_notices(&self, report: &mut ResolveReport) {
208        let bare = |c: &Candidate| {
209            c.updates
210                .iter()
211                .any(|u| u.kind.is_retraction() && u.notice.is_none())
212        };
213        for entry in &self.entries {
214            let provider = entry.provider.id();
215            let kinds = &entry.provider.capabilities().lookup_kinds;
216            let wanted: Vec<(usize, WorkId)> = report
217                .candidates
218                .iter()
219                .enumerate()
220                .filter(|(_, c)| bare(c) && !c.sightings.iter().any(|s| s.provider == provider))
221                .filter_map(|(i, c)| {
222                    Some((
223                        i,
224                        c.ids.iter().find(|id| kinds.contains(&id.kind()))?.clone(),
225                    ))
226                })
227                .collect();
228            if wanted.is_empty() {
229                continue;
230            }
231            let ids: Vec<WorkId> = wanted.iter().map(|(_, id)| id.clone()).collect();
232            let answers = self
233                .lookup_at(entry, &ids, FieldSet::ALL, &mut report.usage)
234                .await;
235            for ((i, _), (_, record)) in wanted.into_iter().zip(answers) {
236                if let Some(record) = record {
237                    merge_updates(&mut report.candidates[i].updates, &record.work.updates);
238                }
239            }
240        }
241    }
242
243    /// Looks up every identifier the claims assert, in one batched call per
244    /// provider. With a cache attached, this warms it so verifying the
245    /// claims one by one (and streaming results) doesn't look them up one
246    /// by one.
247    pub async fn prefetch(&self, claims: &[Claim]) -> crate::LookupReport {
248        let mut ids: Vec<WorkId> = Vec::new();
249        for id in claims.iter().flat_map(|c| &c.ids) {
250            if !ids.contains(id) {
251                ids.push(id.clone());
252            }
253        }
254        self.lookup(&ids, FieldSet::DEFAULT).await
255    }
256
257    async fn gather(&self, claim: &Claim, limit: u32, stop_when_verified: bool) -> ResolveReport {
258        let mut acc = Accumulator {
259            claim,
260            report: ResolveReport::default(),
261        };
262        let done = |acc: &Accumulator| stop_when_verified && acc.verified();
263        let query = |text: &str, citation: bool| {
264            let mut q = if citation {
265                SearchQuery::citation(text)
266            } else {
267                SearchQuery::new(text)
268            };
269            q.page.limit = limit;
270            q
271        };
272
273        // Stage 1, concurrently (both free): lookups of the claimed
274        // identifiers, and citation matchers on the whole reference.
275        let reference = claim.reference();
276        let matchers: Vec<ProviderId> = match &reference {
277            Some(_) => self.searchers(true),
278            None => Vec::new(),
279        };
280        let lookup = async {
281            match claim.ids.is_empty() {
282                true => None,
283                false => Some(self.lookup(&claim.ids, FieldSet::DEFAULT).await),
284            }
285        };
286        let matched = join_all(matchers.iter().map(|p| {
287            let q = query(reference.as_deref().unwrap_or_default(), true);
288            async move { self.search(Some(p.as_str()), &q).await }
289        }));
290        let (lookup, matched) = (std::pin::pin!(lookup), std::pin::pin!(matched));
291        let (lookup, matched) = match select(lookup, matched).await {
292            Either::Left((lookup, matched)) => (lookup, Either::Left(matched)),
293            Either::Right((matched, lookup)) => (lookup.await, Either::Right(matched)),
294        };
295
296        if let Some(lookup) = lookup {
297            for record in lookup.results.iter().filter_map(|r| r.record.as_ref()) {
298                let sighting = Sighting {
299                    provider: record.source.clone(),
300                    via: Via::Lookup,
301                    rank: 1,
302                    score: None,
303                };
304                acc.add(record.clone(), sighting);
305            }
306            acc.usage(lookup.usage);
307            acc.report.lookups = lookup.results;
308        }
309        // When the identifiers' lookup alone verifies the claim, the
310        // matchers can't change the verdict: stop waiting for them (Crossref's
311        // takes seconds).
312        let matched = match matched {
313            Either::Right(matched) => matched,
314            Either::Left(_) if done(&acc) => Vec::new(),
315            Either::Left(pending) => pending.await,
316        };
317        let mut titles: Vec<String> = claim.title.iter().cloned().collect();
318        for (provider, result) in matchers.iter().zip(matched) {
319            let hits = acc.add_search(provider, Via::CitationMatch, result);
320            if let (None, Some(reference)) = (&claim.title, &reference) {
321                // Query the title the reference cites, as found via each
322                // hit: a commentary titled "Recommendation of X" yields X.
323                titles.extend(
324                    hits.iter()
325                        .filter_map(|t| matching::shared_title(t, reference)),
326                );
327            }
328        }
329        // An identifier-only claim has nothing else to compare.
330        if done(&acc) || !claim.is_descriptive() {
331            return acc.finish();
332        }
333
334        // Stage 2, concurrently: title searches (or, with no title to go on,
335        // cleaned-up keywords) on every keyword provider. Results are added
336        // in provider-then-title order, so merging stays deterministic.
337        titles = distinct(titles);
338        titles.truncate(MAX_TITLE_SEARCHES);
339        let mut jobs: Vec<(ProviderId, SearchQuery, Via)> = Vec::new();
340        for provider in self.searchers(false) {
341            if titles.is_empty()
342                && let Some(text) = &claim.text
343            {
344                let q = query(&matching::search_terms(text), false);
345                jobs.push((provider.clone(), q, Via::KeywordSearch));
346            }
347            for title in &titles {
348                jobs.push((provider.clone(), query(title, false), Via::TitleSearch));
349            }
350        }
351        // A reference without a title ("Injury 47 (2016) 797–804") is
352        // found by where it appeared, in the years it cites (and either
353        // side: online first, then in print).
354        if let Some(place) = claim.place() {
355            let years = claim.cited_years();
356            for provider in self.placers() {
357                let mut q = query("", false);
358                q.page.limit = PLACE_LIMIT;
359                q.place = Some(place.clone());
360                q.years = years
361                    .iter()
362                    .min()
363                    .zip(years.iter().max())
364                    .map(|(&a, &b)| YearRange {
365                        from: Some(a.saturating_sub(1)),
366                        to: Some(b.saturating_add(1)),
367                    });
368                jobs.push((provider, q, Via::PlaceSearch));
369            }
370        }
371        // Each answer is added as it arrives, so a verified claim stops
372        // waiting for slower providers. The report is rebuilt in job order
373        // each time, so what's kept doesn't depend on who answered first.
374        let mut pending: FuturesUnordered<_> = jobs
375            .iter()
376            .enumerate()
377            .map(|(i, (p, q, _))| async move { (i, self.search(Some(p.as_str()), q).await) })
378            .collect();
379        let before = acc.report.clone();
380        let mut answers: Vec<Option<Result<SearchReport, EngineError>>> = vec![None; jobs.len()];
381        while let Some((i, result)) = pending.next().await {
382            answers[i] = Some(result);
383            acc.report = before.clone();
384            for ((provider, _, via), answer) in jobs.iter().zip(&answers) {
385                if let Some(result) = answer {
386                    acc.add_search(provider, *via, result.clone());
387                }
388            }
389            if done(&acc) {
390                break;
391            }
392        }
393        acc.finish()
394    }
395
396    /// Keyword engines that can search by volume and first page.
397    fn placers(&self) -> Vec<ProviderId> {
398        self.providers()
399            .filter(|p| {
400                let caps = p.capabilities();
401                caps.search && !caps.citation_search && caps.search_filters.contains(&Filter::Place)
402            })
403            .map(|p| p.id())
404            .collect()
405    }
406
407    /// Providers that search, either citation matchers or keyword engines.
408    fn searchers(&self, matchers: bool) -> Vec<ProviderId> {
409        self.providers()
410            .filter(|p| p.capabilities().search && p.capabilities().citation_search == matchers)
411            .map(|p| p.id())
412            .collect()
413    }
414}
415
416pub(crate) trait ClaimExt {
417    fn is_descriptive(&self) -> bool;
418}
419
420impl ClaimExt for Claim {
421    /// Whether the claim says anything besides identifiers.
422    fn is_descriptive(&self) -> bool {
423        self.text.is_some() || self.title.is_some() || !self.authors.is_empty()
424    }
425}
426
427struct Accumulator<'a> {
428    claim: &'a Claim,
429    report: ResolveReport,
430}
431
432impl Accumulator<'_> {
433    /// Adds a record, merging it into an existing candidate that shares an
434    /// identifier.
435    fn add(&mut self, mut record: SourceRecord, sighting: Sighting) {
436        let candidates = &mut self.report.candidates;
437        let existing = candidates.iter_mut().find(|c| {
438            c.ids
439                .iter()
440                .any(|a| record.ids.iter().any(|b| same_work_id(a, b)))
441        });
442        let candidate = match existing {
443            Some(c) => {
444                for id in &record.ids {
445                    if !c.ids.iter().any(|known| same_work_id(known, id)) {
446                        c.ids.push(id.clone());
447                    }
448                }
449                c.sightings.push(sighting);
450                merge_updates(&mut c.updates, &record.work.updates);
451                // Pool the years every source reports (online-first vs
452                // print, editions), then keep the better-matching record.
453                let pooled: Vec<i16> = years(&c.record).chain(years(&record)).collect();
454                add_years(&mut c.record, &pooled);
455                add_years(&mut record, &pooled);
456                // And keep a free copy's location, whichever record wins.
457                let oa = |r: &SourceRecord| r.work.open_access.clone();
458                let known = oa(&c.record).or_else(|| oa(&record));
459                c.record.work.open_access.clone_from(&known);
460                record.work.open_access = record.work.open_access.take().or(known);
461                c.assessment = matching::assess(self.claim, &c.record);
462                let assessment = matching::assess(self.claim, &record);
463                if assessment.confidence > c.assessment.confidence {
464                    c.record = record;
465                    c.assessment = assessment;
466                }
467                c
468            }
469            None => {
470                let mut updates = Vec::new();
471                merge_updates(&mut updates, &record.work.updates);
472                candidates.push(Candidate {
473                    ids: record.ids.clone(),
474                    updates,
475                    sightings: vec![sighting],
476                    assessment: matching::assess(self.claim, &record),
477                    record,
478                });
479                candidates.last_mut().expect("just pushed")
480            }
481        };
482        // Judge claimed identifiers against everything known about the work.
483        candidate.assessment.ids = id_agreement(&self.claim.ids, &candidate.ids);
484    }
485
486    /// Adds one search's hits; returns the hits' titles.
487    fn add_search(
488        &mut self,
489        provider: &ProviderId,
490        via: Via,
491        result: Result<SearchReport, EngineError>,
492    ) -> Vec<String> {
493        match result {
494            Ok(report) => {
495                if let Some(u) = report.usage {
496                    add_usage(&mut self.report.usage, provider, u);
497                }
498                let mut titles = Vec::new();
499                for (i, hit) in report.page.hits.into_iter().enumerate() {
500                    // An untitled hit (a book chapter registered under the
501                    // book) points to the cited title via its container.
502                    let w = &hit.record.work;
503                    titles.extend(w.title.clone().or_else(|| w.container_title.clone()));
504                    let sighting = Sighting {
505                        provider: provider.clone(),
506                        via,
507                        rank: i + 1,
508                        score: hit.score,
509                    };
510                    self.add(hit.record, sighting);
511                }
512                titles
513            }
514            Err(e) => {
515                self.report.errors.push(e);
516                Vec::new()
517            }
518        }
519    }
520
521    fn usage(&mut self, usage: Vec<(ProviderId, Usage)>) {
522        for (provider, u) in usage {
523            add_usage(&mut self.report.usage, &provider, u);
524        }
525    }
526
527    /// A candidate so clearly right that searching further can't improve
528    /// on it: decisive, and either the identifiers the claim asserted or two
529    /// providers vouch for it. A digest or reprint of a paper can match the
530    /// claim exactly while only a later search finds the original. Two
531    /// providers also settle a claim whose identifier names another work,
532    /// but not one whose identifiers the candidate can't yet be compared
533    /// with: a later provider may add the claimed PMID.
534    fn verified(&self) -> bool {
535        self.report.candidates.iter().any(|c| {
536            let vouched = || {
537                c.sightings
538                    .iter()
539                    .any(|s| s.provider != c.sightings[0].provider)
540            };
541            let corroborated = match c.assessment.ids {
542                Agreement::Match => true,
543                Agreement::Mismatch => vouched(),
544                _ => self.claim.ids.is_empty() && vouched(),
545            };
546            c.assessment.confidence >= DECISIVE && corroborated
547        })
548    }
549
550    fn finish(mut self) -> ResolveReport {
551        rank(&mut self.report.candidates);
552        self.report
553    }
554}
555
556/// The claim with each claimed identifier that doesn't exist replaced by
557/// an alternative reading that does, and the report made about that
558/// claim: lookups of readings not taken dropped, candidates rescored.
559fn settle_alternatives(claim: &Claim, report: &mut ResolveReport) -> Claim {
560    let mut claim = claim.clone();
561    let lookup = |id: &WorkId| report.lookups.iter().find(|l| l.id == *id);
562    let missing = |id: &WorkId| lookup(id).is_some_and(LookupResult::is_not_found);
563    let mut settled: Vec<(WorkId, WorkId)> = Vec::new();
564    for a in &claim.alternatives {
565        if missing(&a.claimed) && lookup(&a.alternative).is_some_and(|l| l.record.is_some()) {
566            settled.push((a.claimed.clone(), a.alternative.clone()));
567        }
568    }
569    // A DOI that doesn't exist, and a work found by the citation's text
570    // whose DOI starts with it: the same DOI, cut short at a line break
571    // (#39).
572    for id in claim
573        .ids
574        .iter()
575        .filter(|id| id.kind() == IdKind::Doi && missing(id))
576    {
577        let cited = id.to_string();
578        let longer = report
579            .candidates
580            .iter()
581            .flat_map(|c| &c.ids)
582            .find(|c| c.kind() == IdKind::Doi && c.to_string().starts_with(&cited) && *c != id);
583        if let Some(longer) = longer
584            && !settled.iter().any(|(from, _)| from == id)
585        {
586            settled.push((id.clone(), longer.clone()));
587        }
588    }
589    if claim.alternatives.is_empty() && settled.is_empty() {
590        return claim;
591    }
592    for (from, to) in settled {
593        for id in claim.ids.iter_mut().filter(|id| **id == from) {
594            *id = to.clone();
595        }
596    }
597    report.lookups.retain(|l| claim.ids.contains(&l.id));
598    for c in &mut report.candidates {
599        c.assessment = matching::assess(&claim, &c.record);
600        c.assessment.ids = id_agreement(&claim.ids, &c.ids);
601    }
602    rank(&mut report.candidates);
603    claim
604}
605
606/// Most confident first; on a tie the earliest-published (an original
607/// precedes its reprints, abstracts and corrections), then the one more
608/// providers agree on.
609fn rank(candidates: &mut [Candidate]) {
610    let issued = |c: &Candidate| {
611        c.record.work.issued.map_or(i64::MAX, |d| {
612            i64::from(d.year()) * 10_000
613                + i64::from(d.month().unwrap_or(12)) * 100
614                + i64::from(d.day().unwrap_or(31))
615        })
616    };
617    candidates.sort_by(|a, b| {
618        b.assessment
619            .confidence
620            .total_cmp(&a.assessment.confidence)
621            .then(issued(a).cmp(&issued(b)))
622            .then(b.sightings.len().cmp(&a.sightings.len()))
623    });
624}
625
626/// Every year a record reports: issued, then other editions or versions.
627fn years(record: &SourceRecord) -> impl Iterator<Item = i16> + '_ {
628    let w = &record.work;
629    w.issued
630        .map(|d| d.year())
631        .into_iter()
632        .chain(w.other_years.iter().copied())
633}
634
635/// Adds `pooled` years the record doesn't already report to its
636/// `other_years`.
637fn add_years(record: &mut SourceRecord, pooled: &[i16]) {
638    let issued = record.work.issued.map(|d| d.year());
639    for &y in pooled {
640        if Some(y) != issued && !record.work.other_years.contains(&y) {
641            record.work.other_years.push(y);
642        }
643    }
644    record.work.other_years.sort_unstable();
645}
646
647/// Titles with duplicates (by normalised form) removed, order kept.
648fn distinct(titles: Vec<String>) -> Vec<String> {
649    let mut seen = Vec::new();
650    titles
651        .into_iter()
652        .filter(|t| {
653            let norm = matching::normalise(t);
654            let new = !norm.is_empty() && !seen.contains(&norm);
655            seen.push(norm);
656            new
657        })
658        .collect()
659}
660
661#[cfg(test)]
662mod tests {
663    use aspero_core::UpdateKind;
664
665    use super::*;
666
667    fn update(kind: UpdateKind, notice: Option<&str>) -> Update {
668        let mut u = Update::new(kind);
669        u.notice = notice.map(|n| n.parse().unwrap());
670        u
671    }
672
673    #[test]
674    fn detailed_notices_replace_bare_flags_without_duplicates() {
675        let mut known = vec![update(UpdateKind::Retraction, None)];
676        let crossref = [
677            update(UpdateKind::Correction, Some("10.1000/c1")),
678            update(UpdateKind::Retraction, Some("10.1000/r")),
679            update(UpdateKind::Correction, Some("10.1000/c2")),
680        ];
681        merge_updates(&mut known, &crossref);
682        merge_updates(&mut known, &crossref); // again: nothing new
683        merge_updates(&mut known, &[update(UpdateKind::Retraction, None)]);
684        let summary: Vec<(String, Option<String>)> = known
685            .iter()
686            .map(|u| {
687                (
688                    u.kind.as_str().to_owned(),
689                    u.notice.as_ref().map(ToString::to_string),
690                )
691            })
692            .collect();
693        assert_eq!(
694            summary,
695            [
696                ("retraction".into(), Some("doi:10.1000/r".into())),
697                ("correction".into(), Some("doi:10.1000/c1".into())),
698                ("correction".into(), Some("doi:10.1000/c2".into())),
699            ]
700        );
701    }
702}