Skip to main content

aspero_engine/
verdict.rs

1//! Turning scored candidates into a verdict and a one-sentence reason.
2
3use aspero_core::matching::{self, PLAUSIBLE, VERIFIED};
4use aspero_core::publication::first_page;
5use aspero_core::{Agreement, Claim, Update, UpdateKind};
6
7use crate::resolve::{Candidate, ClaimExt, ResolveReport};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10#[non_exhaustive]
11pub enum Verdict {
12    /// A work matching the claim exists (and so do its claimed identifiers).
13    Verified,
14    /// A work partly matching the claim exists; check the details.
15    Plausible,
16    /// The claimed identifier is wrong: it doesn't exist, or it belongs to
17    /// a work that doesn't match the rest of the claim.
18    Mismatch,
19    /// No matching work was found.
20    NotFound,
21    /// Providers failed, so nothing conclusive can be said.
22    Unverifiable,
23}
24
25impl Verdict {
26    pub fn as_str(self) -> &'static str {
27        match self {
28            Verdict::Verified => "verified",
29            Verdict::Plausible => "plausible",
30            Verdict::Mismatch => "mismatch",
31            Verdict::NotFound => "not_found",
32            Verdict::Unverifiable => "unverifiable",
33        }
34    }
35}
36
37#[derive(Debug, Clone)]
38pub struct Verification {
39    pub verdict: Verdict,
40    /// One sentence, for people and agents.
41    pub reason: String,
42    /// The candidate the verdict is about, as an index into
43    /// `report.candidates`: the matching work, or the best guess at what was
44    /// meant.
45    pub best: Option<usize>,
46    pub report: ResolveReport,
47}
48
49/// The same title plus matching authors *or* year, with the other wrong: a
50/// real work cited with an error (wrong year, misattributed authors), not a
51/// missing one. The title alone proves little: short titles like "Protein
52/// Folding" occur inside many longer ones.
53fn near_miss(c: &Candidate) -> bool {
54    let a = &c.assessment;
55    a.title.agreement == Agreement::Match
56        && (a.authors.agreement == Agreement::Match || a.year.agreement == Agreement::Match)
57}
58
59/// The fields that don't fully agree, as a clause: "its year (2017)
60/// differs", "its authors and year (2017) differ". With `known_only`, only
61/// fields both sides gave and that disagree; otherwise also those the
62/// citation doesn't confirm.
63fn differences(c: &Candidate, known_only: bool) -> Option<String> {
64    let a = &c.assessment;
65    let w = &c.record.work;
66    let off = |check: &matching::FieldCheck| match known_only {
67        true => matches!(check.agreement, Agreement::Mismatch | Agreement::Partial),
68        false => check.agreement != Agreement::Match,
69    };
70    let value = |v: Option<String>| v.map(|v| format!(" ({v})")).unwrap_or_default();
71    let fields: Vec<String> = [
72        off(&a.title).then(|| "title".to_owned()),
73        off(&a.authors).then(|| "authors".to_owned()),
74        matches!(a.year.agreement, Agreement::Mismatch | Agreement::Partial)
75            .then(|| format!("year{}", value(w.issued.map(|d| d.year().to_string())))),
76        (a.container.agreement == Agreement::Mismatch).then(|| {
77            let journal = w.container_title.as_ref().map(|t| format!("\"{t}\""));
78            format!("journal{}", value(journal))
79        }),
80        (a.volume.agreement == Agreement::Mismatch)
81            .then(|| format!("volume{}", value(w.volume.clone()))),
82        (a.page.agreement == Agreement::Mismatch).then(|| {
83            let first = w.page.as_deref().map(|p| first_page(p).to_owned());
84            format!("first page{}", value(first))
85        }),
86    ]
87    .into_iter()
88    .flatten()
89    .collect();
90    match fields.len() {
91        0 => None,
92        1 => Some(format!("its {} differs", fields[0])),
93        _ => Some(format!("its {} differ", fields.join(" and "))),
94    }
95}
96
97fn describe(c: &Candidate) -> String {
98    let w = &c.record.work;
99    let title = w.title.as_deref().unwrap_or("(untitled)");
100    let year = w
101        .issued
102        .map(|d| format!(" ({})", d.year()))
103        .unwrap_or_default();
104    let id = c.ids.first().map(|i| format!(", {i}")).unwrap_or_default();
105    format!("\"{title}\"{year}{id}")
106}
107
108/// Turns gathered candidates into a verdict.
109pub(crate) fn judge(claim: &Claim, report: ResolveReport) -> Verification {
110    let best = report.candidates.first();
111    let best_conf = best.map_or(0.0, |c| c.assessment.confidence);
112    let failed = !report.errors.is_empty()
113        || report
114            .lookups
115            .iter()
116            .any(|l| l.record.is_none() && !l.is_not_found());
117
118    let (verdict, reason, best_index) = if claim.ids.is_empty() {
119        match best {
120            Some(c) if best_conf >= VERIFIED => (
121                Verdict::Verified,
122                format!("matches {}", describe(c)),
123                Some(0),
124            ),
125            Some(c) if best_conf >= PLAUSIBLE => (
126                Verdict::Plausible,
127                format!(
128                    "partly matches {}; {}",
129                    describe(c),
130                    differences(c, true).unwrap_or_else(|| "check the details".into())
131                ),
132                Some(0),
133            ),
134            Some(c) if near_miss(c) => (
135                Verdict::Mismatch,
136                format!(
137                    "closest work is {}, but {}",
138                    describe(c),
139                    differences(c, false).unwrap_or_else(|| "its details differ".into())
140                ),
141                Some(0),
142            ),
143            _ if failed => (
144                Verdict::Unverifiable,
145                "providers failed; nothing conclusive".into(),
146                None,
147            ),
148            _ => (Verdict::NotFound, "no matching work found".into(), None),
149        }
150    } else {
151        let claimed = claim
152            .ids
153            .iter()
154            .map(ToString::to_string)
155            .collect::<Vec<_>>()
156            .join(", ");
157        let by_id = report
158            .candidates
159            .iter()
160            .position(|c| c.assessment.ids == Agreement::Match);
161        let elsewhere = |skip: Option<usize>| {
162            report
163                .candidates
164                .iter()
165                .enumerate()
166                .find(|(i, c)| Some(*i) != skip && c.assessment.confidence >= PLAUSIBLE)
167        };
168        match by_id.map(|i| (i, &report.candidates[i])) {
169            Some((i, c)) if !claim.is_descriptive() => (
170                Verdict::Verified,
171                format!("{claimed} exists: {}", describe(c)),
172                Some(i),
173            ),
174            Some((i, c)) if c.assessment.confidence >= VERIFIED => (
175                Verdict::Verified,
176                format!("{claimed} and the citation agree: {}", describe(c)),
177                Some(i),
178            ),
179            Some((i, c)) if c.assessment.confidence >= PLAUSIBLE => (
180                Verdict::Plausible,
181                format!(
182                    "{claimed} exists and partly matches the citation: {}{}",
183                    describe(c),
184                    differences(c, true)
185                        .map(|d| format!("; {d}"))
186                        .unwrap_or_default()
187                ),
188                Some(i),
189            ),
190            Some((i, c)) => match elsewhere(Some(i)) {
191                Some((j, other)) => (
192                    Verdict::Mismatch,
193                    format!(
194                        "{claimed} is {}, but the citation matches {}",
195                        describe(c),
196                        describe(other)
197                    ),
198                    Some(j),
199                ),
200                None => (
201                    Verdict::Mismatch,
202                    format!(
203                        "{claimed} is {}, which does not match the citation",
204                        describe(c)
205                    ),
206                    Some(i),
207                ),
208            },
209            None if failed => (
210                Verdict::Unverifiable,
211                format!("could not look up {claimed}: providers failed"),
212                None,
213            ),
214            None => match elsewhere(None) {
215                Some((j, other)) => (
216                    Verdict::Mismatch,
217                    format!(
218                        "{claimed} does not exist; the citation matches {}",
219                        describe(other)
220                    ),
221                    Some(j),
222                ),
223                None => (
224                    Verdict::NotFound,
225                    format!("{claimed} does not exist and no matching work was found"),
226                    None,
227                ),
228            },
229        }
230    };
231    // Existing is not the same as citable: say so whatever the verdict.
232    let reason = match best_index.and_then(|i| report.candidates[i].retraction()) {
233        Some(r) => format!("{reason}; note: {}", describe_update(r)),
234        None => reason,
235    };
236    Verification {
237        verdict,
238        reason,
239        best: best_index,
240        report,
241    }
242}
243
244/// "RETRACTED (2010-02-06, notice doi:…, via retraction-watch)".
245pub fn describe_update(u: &Update) -> String {
246    let what = match &u.kind {
247        UpdateKind::Retraction => "RETRACTED".to_owned(),
248        UpdateKind::Withdrawal => "WITHDRAWN".to_owned(),
249        UpdateKind::ExpressionOfConcern => "expression of concern".to_owned(),
250        UpdateKind::Correction => "corrected".to_owned(),
251        other => other.as_str().replace('_', " "),
252    };
253    let details: Vec<String> = [
254        u.date.map(|d| d.to_string()),
255        u.notice.as_ref().map(|n| format!("notice {n}")),
256        u.source.as_ref().map(|s| format!("via {s}")),
257    ]
258    .into_iter()
259    .flatten()
260    .collect();
261    if details.is_empty() {
262        what
263    } else {
264        format!("{what} ({})", details.join(", "))
265    }
266}