Skip to main content

aspero_engine/
verdict.rs

1//! Turning scored candidates into a verdict, why ([`Reason`]), and a
2//! one-sentence reason in English.
3
4use aspero_core::matching::{self, PLAUSIBLE, VERIFIED};
5use aspero_core::publication::first_page;
6use aspero_core::{Agreement, Claim, Update, UpdateKind};
7
8use crate::resolve::{Candidate, ClaimExt, ResolveReport};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum Verdict {
13    /// A work matching the claim exists (and so do its claimed identifiers).
14    Verified,
15    /// A work partly matching the claim exists; check the details.
16    Plausible,
17    /// The claimed identifier is wrong: it doesn't exist, or it belongs to
18    /// a work that doesn't match the rest of the claim.
19    Mismatch,
20    /// No matching work was found.
21    NotFound,
22    /// Providers failed, so nothing conclusive can be said.
23    Unverifiable,
24}
25
26impl Verdict {
27    pub fn as_str(self) -> &'static str {
28        match self {
29            Verdict::Verified => "verified",
30            Verdict::Plausible => "plausible",
31            Verdict::Mismatch => "mismatch",
32            Verdict::NotFound => "not_found",
33            Verdict::Unverifiable => "unverifiable",
34        }
35    }
36}
37
38#[derive(Debug, Clone)]
39#[non_exhaustive]
40pub struct Verification {
41    pub verdict: Verdict,
42    /// One sentence, for people and agents: [`why`](Self::why) and
43    /// [`notes`](Self::notes) in English.
44    pub reason: String,
45    /// Why, as data, for front ends that word it themselves.
46    pub why: Reason,
47    /// What to know about the work the verdict is about, whatever the
48    /// verdict: its retraction or withdrawal, and its newer version.
49    pub notes: Vec<Update>,
50    /// The candidate the verdict is about, as an index into
51    /// `report.candidates`: the matching work, or the best guess at what was
52    /// meant.
53    pub best: Option<usize>,
54    pub report: ResolveReport,
55}
56
57/// Why a verdict was reached. "The work" is the verdict's candidate
58/// ([`Verification::best`]); "the identifiers" are those the claim gave.
59#[derive(Debug, Clone, PartialEq, Eq)]
60#[non_exhaustive]
61pub enum Reason {
62    /// The citation matches the work. (Verified)
63    Matches,
64    /// The citation partly matches the work. (Plausible)
65    PartlyMatches { differs: Vec<Difference> },
66    /// The closest work shares the title, but other details differ.
67    /// (Mismatch)
68    Closest { differs: Vec<Difference> },
69    /// No matching work. (NotFound)
70    NoMatch,
71    /// The identifiers exist; the claim gives nothing else to compare.
72    /// (Verified)
73    IdExists,
74    /// The identifiers exist and the citation agrees. (Verified)
75    IdAgrees,
76    /// The identifiers exist and the citation partly matches. (Plausible)
77    IdPartlyMatches { differs: Vec<Difference> },
78    /// The identifiers are another work (candidate `cited`); the citation
79    /// matches the work. (Mismatch)
80    IdElsewhere { cited: usize },
81    /// The identifiers' work (the work) doesn't match the citation, and no
82    /// other does. (Mismatch)
83    IdDisagrees,
84    /// The identifiers don't exist; the citation matches the work.
85    /// (Mismatch)
86    IdMissingElsewhere,
87    /// The identifiers don't exist, and no matching work was found.
88    /// (NotFound)
89    IdMissing,
90    /// Providers failed, so nothing conclusive can be said. (Unverifiable)
91    ProvidersFailed,
92}
93
94impl Reason {
95    /// A short machine-friendly name.
96    pub fn as_str(&self) -> &'static str {
97        match self {
98            Reason::Matches => "matches",
99            Reason::PartlyMatches { .. } => "partly_matches",
100            Reason::Closest { .. } => "closest",
101            Reason::NoMatch => "no_match",
102            Reason::IdExists => "id_exists",
103            Reason::IdAgrees => "id_agrees",
104            Reason::IdPartlyMatches { .. } => "id_partly_matches",
105            Reason::IdElsewhere { .. } => "id_elsewhere",
106            Reason::IdDisagrees => "id_disagrees",
107            Reason::IdMissingElsewhere => "id_missing_elsewhere",
108            Reason::IdMissing => "id_missing",
109            Reason::ProvidersFailed => "providers_failed",
110        }
111    }
112
113    /// The fields that differ, where the reason has them.
114    pub fn differs(&self) -> &[Difference] {
115        match self {
116            Reason::PartlyMatches { differs }
117            | Reason::Closest { differs }
118            | Reason::IdPartlyMatches { differs } => differs,
119            _ => &[],
120        }
121    }
122}
123
124/// A cited field the work disagrees with, and the work's value if it's
125/// worth showing.
126#[derive(Debug, Clone, PartialEq, Eq)]
127#[non_exhaustive]
128pub struct Difference {
129    pub field: CitedField,
130    pub actual: Option<String>,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134#[non_exhaustive]
135pub enum CitedField {
136    Title,
137    Authors,
138    Year,
139    Journal,
140    Volume,
141    FirstPage,
142}
143
144impl CitedField {
145    pub fn as_str(self) -> &'static str {
146        match self {
147            CitedField::Title => "title",
148            CitedField::Authors => "authors",
149            CitedField::Year => "year",
150            CitedField::Journal => "journal",
151            CitedField::Volume => "volume",
152            CitedField::FirstPage => "first_page",
153        }
154    }
155}
156
157impl std::fmt::Display for Difference {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        f.write_str(&self.field.as_str().replace('_', " "))?;
160        match (&self.actual, self.field) {
161            (Some(v), CitedField::Journal) => write!(f, " (\"{v}\")"),
162            (Some(v), _) => write!(f, " ({v})"),
163            (None, _) => Ok(()),
164        }
165    }
166}
167
168/// The same title plus matching authors *or* year, with the other wrong: a
169/// real work cited with an error (wrong year, misattributed authors), not a
170/// missing one. The title alone proves little: short titles like "Protein
171/// Folding" occur inside many longer ones.
172fn near_miss(c: &Candidate) -> bool {
173    let a = &c.assessment;
174    a.title.agreement == Agreement::Match
175        && (a.authors.agreement == Agreement::Match || a.year.agreement == Agreement::Match)
176}
177
178/// The fields that don't fully agree. With `known_only`, only fields both
179/// sides gave and that disagree; otherwise also those the citation doesn't
180/// confirm.
181fn differences(c: &Candidate, known_only: bool) -> Vec<Difference> {
182    let a = &c.assessment;
183    let w = &c.record.work;
184    let off = |check: &matching::FieldCheck| match known_only {
185        true => matches!(check.agreement, Agreement::Mismatch | Agreement::Partial),
186        false => check.agreement != Agreement::Match,
187    };
188    let diff = |field, actual: Option<String>| Difference { field, actual };
189    [
190        off(&a.title).then(|| diff(CitedField::Title, None)),
191        off(&a.authors).then(|| diff(CitedField::Authors, None)),
192        matches!(a.year.agreement, Agreement::Mismatch | Agreement::Partial)
193            .then(|| diff(CitedField::Year, w.issued.map(|d| d.year().to_string()))),
194        (a.container.agreement == Agreement::Mismatch)
195            .then(|| diff(CitedField::Journal, w.container_title.clone())),
196        (a.volume.agreement == Agreement::Mismatch)
197            .then(|| diff(CitedField::Volume, w.volume.clone())),
198        (a.page.agreement == Agreement::Mismatch).then(|| {
199            let first = w.page.as_deref().map(|p| first_page(p).to_owned());
200            diff(CitedField::FirstPage, first)
201        }),
202    ]
203    .into_iter()
204    .flatten()
205    .collect()
206}
207
208/// The differences as a clause: "its year (2017) differs", "its authors and
209/// year (2017) differ".
210fn clause(differs: &[Difference]) -> Option<String> {
211    let fields: Vec<String> = differs.iter().map(ToString::to_string).collect();
212    match fields.len() {
213        0 => None,
214        1 => Some(format!("its {} differs", fields[0])),
215        _ => Some(format!("its {} differ", fields.join(" and "))),
216    }
217}
218
219fn describe(c: &Candidate) -> String {
220    let w = &c.record.work;
221    let title = w.title.as_deref().unwrap_or("(untitled)");
222    let year = w
223        .issued
224        .map(|d| format!(" ({})", d.year()))
225        .unwrap_or_default();
226    let id = c.ids.first().map(|i| format!(", {i}")).unwrap_or_default();
227    format!("\"{title}\"{year}{id}")
228}
229
230/// Turns gathered candidates into a verdict.
231pub(crate) fn judge(claim: &Claim, report: ResolveReport) -> Verification {
232    let (verdict, why, best) = decide(claim, &report);
233    // Existing is not the same as citable or current: say so whatever the
234    // verdict.
235    let notes: Vec<Update> = best
236        .map(|i| &report.candidates[i])
237        .into_iter()
238        .flat_map(|c| {
239            [
240                c.retraction(),
241                c.updates.iter().find(|u| u.kind == UpdateKind::NewVersion),
242            ]
243        })
244        .flatten()
245        .cloned()
246        .collect();
247    let mut reason = explain(claim, &report, &why, best);
248    if !notes.is_empty() {
249        let notes: Vec<String> = notes.iter().map(describe_update).collect();
250        reason = format!("{reason}; note: {}", notes.join("; "));
251    }
252    Verification {
253        verdict,
254        reason,
255        why,
256        notes,
257        best,
258        report,
259    }
260}
261
262fn decide(claim: &Claim, report: &ResolveReport) -> (Verdict, Reason, Option<usize>) {
263    let best = report.candidates.first();
264    let best_conf = best.map_or(0.0, |c| c.assessment.confidence);
265    let failed = !report.errors.is_empty()
266        || report
267            .lookups
268            .iter()
269            .any(|l| l.record.is_none() && !l.is_not_found());
270
271    if claim.ids.is_empty() {
272        return match best {
273            Some(_) if best_conf >= VERIFIED => (Verdict::Verified, Reason::Matches, Some(0)),
274            Some(c) if best_conf >= PLAUSIBLE => (
275                Verdict::Plausible,
276                Reason::PartlyMatches {
277                    differs: differences(c, true),
278                },
279                Some(0),
280            ),
281            Some(c) if near_miss(c) => (
282                Verdict::Mismatch,
283                Reason::Closest {
284                    differs: differences(c, false),
285                },
286                Some(0),
287            ),
288            _ if failed => (Verdict::Unverifiable, Reason::ProvidersFailed, None),
289            _ => (Verdict::NotFound, Reason::NoMatch, None),
290        };
291    }
292    let by_id = report
293        .candidates
294        .iter()
295        .position(|c| c.assessment.ids == Agreement::Match);
296    let elsewhere = |skip: Option<usize>| {
297        report
298            .candidates
299            .iter()
300            .enumerate()
301            .position(|(i, c)| Some(i) != skip && c.assessment.confidence >= PLAUSIBLE)
302    };
303    match by_id.map(|i| (i, &report.candidates[i])) {
304        Some((i, _)) if !claim.is_descriptive() => (Verdict::Verified, Reason::IdExists, Some(i)),
305        Some((i, c)) if c.assessment.confidence >= VERIFIED => {
306            (Verdict::Verified, Reason::IdAgrees, Some(i))
307        }
308        Some((i, c)) if c.assessment.confidence >= PLAUSIBLE => (
309            Verdict::Plausible,
310            Reason::IdPartlyMatches {
311                differs: differences(c, true),
312            },
313            Some(i),
314        ),
315        Some((i, _)) => match elsewhere(Some(i)) {
316            Some(j) => (Verdict::Mismatch, Reason::IdElsewhere { cited: i }, Some(j)),
317            None => (Verdict::Mismatch, Reason::IdDisagrees, Some(i)),
318        },
319        None if failed => (Verdict::Unverifiable, Reason::ProvidersFailed, None),
320        None => match elsewhere(None) {
321            Some(j) => (Verdict::Mismatch, Reason::IdMissingElsewhere, Some(j)),
322            None => (Verdict::NotFound, Reason::IdMissing, None),
323        },
324    }
325}
326
327/// The reason in English, without notes.
328fn explain(claim: &Claim, report: &ResolveReport, why: &Reason, best: Option<usize>) -> String {
329    let claimed = claim
330        .ids
331        .iter()
332        .map(ToString::to_string)
333        .collect::<Vec<_>>()
334        .join(", ");
335    let work = || {
336        best.map(|i| describe(&report.candidates[i]))
337            .unwrap_or_default()
338    };
339    let details = || clause(why.differs());
340    match why {
341        Reason::Matches => format!("matches {}", work()),
342        Reason::PartlyMatches { .. } => format!(
343            "partly matches {}; {}",
344            work(),
345            details().unwrap_or_else(|| "check the details".into())
346        ),
347        Reason::Closest { .. } => format!(
348            "closest work is {}, but {}",
349            work(),
350            details().unwrap_or_else(|| "its details differ".into())
351        ),
352        Reason::NoMatch => "no matching work found".into(),
353        Reason::IdExists => format!("{claimed} exists: {}", work()),
354        Reason::IdAgrees => format!("{claimed} and the citation agree: {}", work()),
355        Reason::IdPartlyMatches { .. } => format!(
356            "{claimed} exists and partly matches the citation: {}{}",
357            work(),
358            details().map(|d| format!("; {d}")).unwrap_or_default()
359        ),
360        Reason::IdElsewhere { cited } => format!(
361            "{claimed} is {}, but the citation matches {}",
362            describe(&report.candidates[*cited]),
363            work()
364        ),
365        Reason::IdDisagrees => {
366            format!("{claimed} is {}, which does not match the citation", work())
367        }
368        Reason::IdMissingElsewhere => {
369            format!("{claimed} does not exist; the citation matches {}", work())
370        }
371        Reason::IdMissing => format!("{claimed} does not exist and no matching work was found"),
372        Reason::ProvidersFailed if claim.ids.is_empty() => {
373            "providers failed; nothing conclusive".into()
374        }
375        Reason::ProvidersFailed => format!("could not look up {claimed}: providers failed"),
376    }
377}
378
379/// "RETRACTED (2010-02-06, notice doi:…, via retraction-watch)".
380pub fn describe_update(u: &Update) -> String {
381    // The notice of a new version is the newer work itself.
382    if u.kind == UpdateKind::NewVersion {
383        let by = u
384            .notice
385            .as_ref()
386            .map(|n| format!(" by {n}"))
387            .unwrap_or_default();
388        let date = u.date.map(|d| format!(" ({d})")).unwrap_or_default();
389        return format!("superseded{by}{date}");
390    }
391    let what = match &u.kind {
392        UpdateKind::Retraction => "RETRACTED".to_owned(),
393        UpdateKind::Withdrawal => "WITHDRAWN".to_owned(),
394        UpdateKind::ExpressionOfConcern => "expression of concern".to_owned(),
395        UpdateKind::Correction => "corrected".to_owned(),
396        other => other.as_str().replace('_', " "),
397    };
398    let details: Vec<String> = [
399        u.date.map(|d| d.to_string()),
400        u.notice.as_ref().map(|n| format!("notice {n}")),
401        u.source.as_ref().map(|s| format!("via {s}")),
402    ]
403    .into_iter()
404    .flatten()
405    .collect();
406    if details.is_empty() {
407        what
408    } else {
409        format!("{what} ({})", details.join(", "))
410    }
411}