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