1mod authors;
19mod derivative;
20mod text;
21
22use serde::{Deserialize, Serialize};
23
24use crate::id::WorkId;
25use crate::publication::{self, first_page, journal_in, same_journal};
26use crate::record::SourceRecord;
27
28use authors::{author_overlap, family_name};
29use derivative::claim_mentions_derivative;
30pub use derivative::is_derivative;
31use text::Normalised;
32pub use text::{normalise, search_terms, shared_title, title_similarity, unmarked};
33
34const W_TITLE: f64 = 0.5;
36const W_AUTHORS: f64 = 0.25;
37const W_YEAR: f64 = 0.25;
38
39const YEAR_MISMATCH_PENALTY: f64 = 0.5;
41const TITLE_MISMATCH_PENALTY: f64 = 0.5;
43const AUTHORS_MISMATCH_PENALTY: f64 = 0.5;
45const SINGLE_FIELD_CAP: f64 = 0.7;
47const UNCORROBORATED_CAP: f64 = 0.8;
49const CORROBORATING_AUTHORS: f64 = 0.75;
52const DERIVATIVE_PENALTY: f64 = 0.5;
54const LOCATION_MISMATCH_CAP: f64 = 0.8;
56const JOURNAL_MISMATCH_PENALTY: f64 = 0.9;
58
59pub const VERIFIED: f64 = 0.85;
61pub const PLAUSIBLE: f64 = 0.6;
62
63const AUTHORS_COMPARED: usize = 3;
65
66#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[non_exhaustive]
69pub struct Claim {
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub text: Option<String>,
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub ids: Vec<WorkId>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub title: Option<String>,
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub authors: Vec<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub year: Option<i16>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub container: Option<String>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub volume: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub page: Option<String>,
91}
92
93impl Claim {
94 pub fn from_text(text: impl Into<String>) -> Self {
97 let text = text.into();
98 Claim {
99 ids: find_ids(&text),
100 text: Some(text),
101 ..Claim::default()
102 }
103 }
104
105 pub fn from_input(input: &str) -> Self {
109 let input = input.trim();
110 match input.parse::<WorkId>() {
111 Ok(id) => Claim {
112 ids: vec![id],
113 ..Claim::default()
114 },
115 Err(_) => Claim::from_text(input),
116 }
117 }
118
119 pub fn is_empty(&self) -> bool {
120 self.text.is_none()
121 && self.ids.is_empty()
122 && self.title.is_none()
123 && self.authors.is_empty()
124 }
125
126 pub fn reference(&self) -> Option<String> {
129 if let Some(text) = &self.text {
130 return Some(text.clone());
131 }
132 let location = match (&self.volume, &self.page) {
134 (Some(v), Some(p)) => Some(format!("{v}:{p}")),
135 (Some(v), None) => Some(v.clone()),
136 (None, p) => p.clone(),
137 };
138 let when = [self.year.map(|y| y.to_string()), location]
139 .into_iter()
140 .flatten()
141 .collect::<Vec<_>>()
142 .join(";");
143 let parts: Vec<String> = [
144 (!self.authors.is_empty()).then(|| self.authors.join(", ")),
145 self.title.clone(),
146 self.container.clone(),
147 (!when.is_empty()).then_some(when),
148 ]
149 .into_iter()
150 .flatten()
151 .collect();
152 (!parts.is_empty()).then(|| parts.join(". "))
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum Agreement {
159 Match,
160 Partial,
161 Mismatch,
162 Unknown,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
168pub struct FieldCheck {
169 pub agreement: Agreement,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub score: Option<f64>,
172}
173
174impl FieldCheck {
175 const UNKNOWN: FieldCheck = FieldCheck {
176 agreement: Agreement::Unknown,
177 score: None,
178 };
179
180 fn exact(agrees: bool) -> Self {
181 FieldCheck::scored(if agrees { 1.0 } else { 0.0 })
182 }
183
184 fn scored(score: f64) -> Self {
185 let agreement = match score {
186 s if s >= 0.9 => Agreement::Match,
187 s if s >= 0.5 => Agreement::Partial,
188 _ => Agreement::Mismatch,
189 };
190 FieldCheck {
191 agreement,
192 score: Some(score),
193 }
194 }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
198#[non_exhaustive]
199pub struct Assessment {
200 pub confidence: f64,
201 pub title: FieldCheck,
202 pub authors: FieldCheck,
203 pub year: FieldCheck,
204 pub container: FieldCheck,
207 pub volume: FieldCheck,
208 pub page: FieldCheck,
210 pub ids: Agreement,
214}
215
216pub fn assess(claim: &Claim, record: &SourceRecord) -> Assessment {
218 let work = &record.work;
219 let text = claim.text.as_deref().map(Normalised::new);
220
221 let title = match (&claim.title, &work.title, &text) {
222 (Some(claimed), Some(actual), _) => FieldCheck::scored(title_similarity(claimed, actual)),
223 (None, Some(actual), Some(text)) => FieldCheck::scored(text.title_coverage(actual)),
224 _ => FieldCheck::UNKNOWN,
225 };
226
227 let families: Vec<String> = work.authors.iter().filter_map(family_name).collect();
228 let authors = if families.is_empty() {
229 FieldCheck::UNKNOWN
230 } else if !claim.authors.is_empty() {
231 FieldCheck::scored(author_overlap(&claim.authors, &families))
232 } else if let Some(text) = &text {
233 FieldCheck::scored(text.author_coverage(&families))
234 } else {
235 FieldCheck::UNKNOWN
236 };
237
238 let actual_years: Vec<i16> = work
239 .issued
240 .map(|d| d.year())
241 .into_iter()
242 .chain(work.other_years.iter().copied())
243 .collect();
244 let claimed_years: Vec<i16> = match (claim.year, &text) {
245 (Some(y), _) => vec![y],
246 (None, Some(text)) => text.years(),
247 _ => Vec::new(),
248 };
249 let year = match actual_years.is_empty() || claimed_years.is_empty() {
250 false => {
251 let delta = claimed_years
253 .iter()
254 .flat_map(|c| actual_years.iter().map(move |a| (c - a).unsigned_abs()))
255 .min()
256 .unwrap_or(u16::MAX);
257 FieldCheck::scored(0.5f64.powi(i32::from(delta.min(30))))
261 }
262 true => FieldCheck::UNKNOWN,
263 };
264
265 let container = match (&claim.container, &work.container_title, &text) {
266 (Some(claimed), Some(actual), _) => FieldCheck::exact(same_journal(claimed, actual)),
267 (None, Some(actual), Some(text)) if journal_in(&text.words, actual) => {
268 FieldCheck::exact(true)
269 }
270 _ => FieldCheck::UNKNOWN,
271 };
272 let located = match &claim.text {
273 Some(text) if claim.volume.is_none() && claim.page.is_none() => publication::locator(text),
274 _ => publication::Locator {
275 volume: claim.volume.clone(),
276 page: claim.page.as_deref().map(|p| first_page(p).to_owned()),
277 },
278 };
279 let same = |claimed: Option<&str>, actual: Option<&str>| match (claimed, actual) {
280 (Some(c), Some(a)) if !c.is_empty() && !a.is_empty() => {
281 FieldCheck::exact(c.trim().eq_ignore_ascii_case(a.trim()))
282 }
283 _ => FieldCheck::UNKNOWN,
284 };
285 let volume = same(located.volume.as_deref(), work.volume.as_deref());
286 let page = same(
287 located.page.as_deref(),
288 work.page.as_deref().map(first_page),
289 );
290
291 let located = volume.agreement == Agreement::Match && page.agreement == Agreement::Match;
293 let placed = container.agreement == Agreement::Match && volume.agreement == Agreement::Match;
298 let title = match placed {
299 true if claim.title.is_none() && title.agreement == Agreement::Mismatch => {
300 FieldCheck::UNKNOWN
301 }
302 _ => title,
303 };
304
305 let ids = id_agreement(&claim.ids, &record.ids);
306 let id_only = [title, authors, year].iter().all(|c| c.score.is_none());
308 let mut confidence = if id_only && ids == Agreement::Match {
309 1.0
310 } else {
311 combine(title, authors, year)
312 };
313 let corroborated = authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS)
319 || ids == Agreement::Match
320 || located;
321 if !id_only && !corroborated {
322 confidence = confidence.min(UNCORROBORATED_CAP);
323 }
324 if [volume, page]
327 .iter()
328 .any(|c| c.agreement == Agreement::Mismatch)
329 {
330 confidence = confidence.min(LOCATION_MISMATCH_CAP);
331 }
332 if container.agreement == Agreement::Mismatch {
335 confidence *= JOURNAL_MISMATCH_PENALTY;
336 }
337 if let Some(actual) = &work.title
340 && is_derivative(actual)
341 && !claim_mentions_derivative(claim)
342 {
343 confidence *= DERIVATIVE_PENALTY;
344 }
345
346 Assessment {
347 confidence,
348 title,
349 authors,
350 year,
351 container,
352 volume,
353 page,
354 ids,
355 }
356}
357
358fn combine(title: FieldCheck, authors: FieldCheck, year: FieldCheck) -> f64 {
359 let parts = [(title, W_TITLE), (authors, W_AUTHORS), (year, W_YEAR)];
360 let known: Vec<(f64, f64)> = parts
361 .iter()
362 .filter_map(|(check, w)| check.score.map(|s| (s, *w)))
363 .collect();
364 let weight: f64 = known.iter().map(|(_, w)| w).sum();
365 if weight == 0.0 {
366 return 0.0;
367 }
368 let mut confidence = known.iter().map(|(s, w)| s * w).sum::<f64>() / weight;
369 if year.agreement == Agreement::Mismatch {
370 confidence *= YEAR_MISMATCH_PENALTY;
371 }
372 if title.agreement == Agreement::Mismatch {
373 confidence *= TITLE_MISMATCH_PENALTY;
374 }
375 if authors.agreement == Agreement::Mismatch {
378 confidence *= AUTHORS_MISMATCH_PENALTY;
379 }
380 if known.len() < 2 {
381 confidence = confidence.min(SINGLE_FIELD_CAP);
382 }
383 confidence
384}
385
386pub fn id_agreement(claimed: &[WorkId], actual: &[WorkId]) -> Agreement {
389 if claimed.is_empty() {
390 return Agreement::Unknown;
391 }
392 if claimed
393 .iter()
394 .any(|c| actual.iter().any(|a| same_work_id(c, a)))
395 {
396 return Agreement::Match;
397 }
398 let shares_kind = claimed
399 .iter()
400 .any(|c| actual.iter().any(|a| a.kind() == c.kind()));
401 if shares_kind {
402 Agreement::Mismatch
403 } else {
404 Agreement::Unknown
405 }
406}
407
408pub fn same_work_id(a: &WorkId, b: &WorkId) -> bool {
410 match (a, b) {
411 (WorkId::Arxiv(x), WorkId::Arxiv(y)) => x.unversioned() == y.unversioned(),
412 _ => a == b,
413 }
414}
415
416pub fn find_ids(text: &str) -> Vec<WorkId> {
418 let mut found: Vec<WorkId> = Vec::new();
419 for mention in crate::id::scan(text) {
420 if !found.iter().any(|f| same_work_id(f, &mention.id)) {
421 found.push(mention.id);
422 }
423 }
424 found
425}
426
427#[cfg(test)]
428mod tests;