Skip to main content

aspero_engine/
registry.rs

1//! Checking records against the registry that issued their DOI (Crossref):
2//! aggregators occasionally merge two works into one record, so another
3//! provider's answer can carry one work's DOI with another's date and page.
4
5use aspero_core::matching::title_similarity;
6use aspero_core::publication::first_page;
7use aspero_core::{IdKind, PartialDate, ProviderId, SourceRecord, UpdateKind, WorkId};
8
9use crate::{Engine, Entry, LookupReport};
10
11/// One field on which a record and the registry's record of the same DOI
12/// disagree.
13#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub struct Disagreement {
16    /// `issued`, `volume`, `page` or `title`.
17    pub field: &'static str,
18    /// The record's value.
19    pub value: String,
20    /// The registry's value.
21    pub registry_value: String,
22    pub registry: ProviderId,
23}
24
25/// Below this title similarity, two records are of different works.
26const SAME_TITLE: f64 = 0.5;
27
28/// Where `record` contradicts `registry`'s record of the same DOI. Dates
29/// compare by year, any of either record's years counting (online-first
30/// and print years differ legitimately); pages by first page.
31pub fn disagreements(record: &SourceRecord, registry: &SourceRecord) -> Vec<Disagreement> {
32    let (w, r) = (&record.work, &registry.work);
33    let mut out = Vec::new();
34    let mut differ = |field, value: String, registry_value: String| {
35        out.push(Disagreement {
36            field,
37            value,
38            registry_value,
39            registry: registry.source.clone(),
40        });
41    };
42    if let (Some(a), Some(b)) = (w.issued, r.issued) {
43        let years = |d: PartialDate, others: &[i16]| {
44            std::iter::once(d.year())
45                .chain(others.iter().copied())
46                .collect::<Vec<_>>()
47        };
48        let ours = years(a, &w.other_years);
49        if !years(b, &r.other_years).iter().any(|y| ours.contains(y)) {
50            differ("issued", a.to_string(), b.to_string());
51        }
52    }
53    if let (Some(a), Some(b)) = (&w.volume, &r.volume)
54        && !a.trim().eq_ignore_ascii_case(b.trim())
55    {
56        differ("volume", a.clone(), b.clone());
57    }
58    if let (Some(a), Some(b)) = (&w.page, &r.page)
59        && !first_page(a).eq_ignore_ascii_case(first_page(b))
60    {
61        differ("page", a.clone(), b.clone());
62    }
63    if let (Some(a), Some(b)) = (&w.title, &r.title)
64        && title_similarity(a, b) < SAME_TITLE
65    {
66        differ("title", a.clone(), b.clone());
67    }
68    out
69}
70
71impl Engine {
72    /// The provider that issues DOIs' metadata and notices (Crossref).
73    pub(crate) fn registry(&self) -> Option<&Entry> {
74        self.entries
75            .iter()
76            .find(|e| e.provider.capabilities().notices)
77    }
78
79    /// For each result, where its record contradicts the registry's record
80    /// of the same DOI; empty when they agree, or there's nothing to
81    /// compare (no DOI, or the registry answered itself). Cached, like
82    /// lookups.
83    pub async fn cross_check(&self, report: &mut LookupReport) -> Vec<Vec<Disagreement>> {
84        let mut found = vec![Vec::new(); report.results.len()];
85        let Some(registry) = self.registry() else {
86            return found;
87        };
88        let works = crate::enrich::found_records(&report.results, |_| true);
89        for (i, theirs) in self.ask_about(registry, works, &mut report.usage).await {
90            if let Some(ours) = &report.results[i].record {
91                found[i] = disagreements(ours, &theirs);
92            }
93        }
94        found
95    }
96
97    /// Dates each notice by when it appeared: the earliest of the date the
98    /// source gives (often the issue's) and the notice's own publication
99    /// dates, from the registry and from the first provider to answer when
100    /// that agrees with the registry. The source's date is kept as
101    /// `deposited` when earlier dates win.
102    pub(crate) async fn date_notices(&self, report: &mut LookupReport) {
103        let Some(registry) = self.registry() else {
104            return;
105        };
106        let mut notices: Vec<WorkId> = Vec::new();
107        for record in report.results.iter().filter_map(|r| r.record.as_ref()) {
108            for u in &record.work.updates {
109                if let Some(n) = &u.notice
110                    && u.kind != UpdateKind::NewVersion
111                    && n.kind() == IdKind::Doi
112                    && !notices.contains(n)
113                {
114                    notices.push(n.clone());
115                }
116            }
117        }
118        if notices.is_empty() {
119            return;
120        }
121        let registered = self
122            .lookup_at(
123                registry,
124                &notices,
125                aspero_core::FieldSet::ALL,
126                &mut report.usage,
127            )
128            .await;
129        let first = self.lookup(&notices, aspero_core::FieldSet::DEFAULT).await;
130        for (p, u) in first.usage {
131            crate::add_usage(&mut report.usage, &p, u);
132        }
133        let appeared: Vec<Option<PartialDate>> = registered
134            .into_iter()
135            .zip(first.results)
136            .map(|((_, theirs), ours)| {
137                let consistent = ours.record.filter(|r| {
138                    theirs
139                        .as_ref()
140                        .is_none_or(|t| disagreements(r, t).is_empty())
141                });
142                [theirs, consistent]
143                    .into_iter()
144                    .flatten()
145                    .filter_map(|r| r.work.issued)
146                    .min_by_key(|d| starts(*d))
147            })
148            .collect();
149        for record in report.results.iter_mut().filter_map(|r| r.record.as_mut()) {
150            for u in &mut record.work.updates {
151                let Some(at) = u
152                    .notice
153                    .as_ref()
154                    .and_then(|n| notices.iter().position(|x| x == n))
155                else {
156                    continue;
157                };
158                let earlier = |a: &PartialDate| u.date.is_none_or(|d| starts(*a) < starts(d));
159                if let Some(earlier) = appeared[at].filter(earlier) {
160                    u.deposited = u.date;
161                    u.date = Some(earlier);
162                }
163            }
164        }
165    }
166}
167
168/// When a date's period starts: 2026-12 is no earlier than 2026-12-01.
169fn starts(d: PartialDate) -> (i16, u8, u8) {
170    (d.year(), d.month().unwrap_or(1), d.day().unwrap_or(1))
171}