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