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