1use 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 Verified,
14 Plausible,
16 Mismatch,
19 NotFound,
21 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 pub reason: String,
42 pub best: Option<usize>,
46 pub report: ResolveReport,
47}
48
49fn 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
59fn 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
108pub(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 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
254pub fn describe_update(u: &Update) -> String {
256 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}