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 or current: say so whatever the
232    // verdict.
233    let best = best_index.map(|i| &report.candidates[i]);
234    let notes: Vec<String> = [
235        best.and_then(Candidate::retraction),
236        best.and_then(|c| c.updates.iter().find(|u| u.kind == UpdateKind::NewVersion)),
237    ]
238    .into_iter()
239    .flatten()
240    .map(describe_update)
241    .collect();
242    let reason = match notes.is_empty() {
243        true => reason,
244        false => format!("{reason}; note: {}", notes.join("; ")),
245    };
246    Verification {
247        verdict,
248        reason,
249        best: best_index,
250        report,
251    }
252}
253
254/// "RETRACTED (2010-02-06, notice doi:…, via retraction-watch)".
255pub fn describe_update(u: &Update) -> String {
256    // The notice of a new version is the newer work itself.
257    if u.kind == UpdateKind::NewVersion {
258        let by = u
259            .notice
260            .as_ref()
261            .map(|n| format!(" by {n}"))
262            .unwrap_or_default();
263        let date = u.date.map(|d| format!(" ({d})")).unwrap_or_default();
264        return format!("superseded{by}{date}");
265    }
266    let what = match &u.kind {
267        UpdateKind::Retraction => "RETRACTED".to_owned(),
268        UpdateKind::Withdrawal => "WITHDRAWN".to_owned(),
269        UpdateKind::ExpressionOfConcern => "expression of concern".to_owned(),
270        UpdateKind::Correction => "corrected".to_owned(),
271        other => other.as_str().replace('_', " "),
272    };
273    let details: Vec<String> = [
274        u.date.map(|d| d.to_string()),
275        u.notice.as_ref().map(|n| format!("notice {n}")),
276        u.source.as_ref().map(|s| format!("via {s}")),
277    ]
278    .into_iter()
279    .flatten()
280    .collect();
281    if details.is_empty() {
282        what
283    } else {
284        format!("{what} ({})", details.join(", "))
285    }
286}