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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub alternatives: Vec<Alternative>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[non_exhaustive]
100pub struct Alternative {
101 pub claimed: WorkId,
102 pub alternative: WorkId,
103}
104
105impl Alternative {
106 pub fn new(claimed: WorkId, alternative: WorkId) -> Self {
107 Alternative {
108 claimed,
109 alternative,
110 }
111 }
112}
113
114impl Claim {
115 pub fn from_text(text: impl Into<String>) -> Self {
118 let text = text.into();
119 Claim {
120 ids: find_ids(&text),
121 text: Some(text),
122 ..Claim::default()
123 }
124 }
125
126 pub fn from_input(input: &str) -> Self {
130 let input = input.trim();
131 match input.parse::<WorkId>() {
132 Ok(id) => Claim {
133 ids: vec![id],
134 ..Claim::default()
135 },
136 Err(_) => Claim::from_text(input),
137 }
138 }
139
140 pub fn is_empty(&self) -> bool {
143 let blank = |s: &Option<String>| s.as_deref().is_none_or(|s| s.trim().is_empty());
144 blank(&self.text)
145 && self.ids.is_empty()
146 && blank(&self.title)
147 && self.authors.iter().all(|a| a.trim().is_empty())
148 }
149
150 pub fn reference(&self) -> Option<String> {
153 if let Some(text) = &self.text {
154 return Some(text.clone());
155 }
156 let location = match (&self.volume, &self.page) {
158 (Some(v), Some(p)) => Some(format!("{v}:{p}")),
159 (Some(v), None) => Some(v.clone()),
160 (None, p) => p.clone(),
161 };
162 let when = [self.year.map(|y| y.to_string()), location]
163 .into_iter()
164 .flatten()
165 .collect::<Vec<_>>()
166 .join(";");
167 let parts: Vec<String> = [
168 (!self.authors.is_empty()).then(|| self.authors.join(", ")),
169 self.title.clone(),
170 self.container.clone(),
171 (!when.is_empty()).then_some(when),
172 ]
173 .into_iter()
174 .flatten()
175 .collect();
176 (!parts.is_empty()).then(|| parts.join(". "))
177 }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "snake_case")]
182pub enum Agreement {
183 Match,
184 Partial,
185 Mismatch,
186 Unknown,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
192pub struct FieldCheck {
193 pub agreement: Agreement,
194 #[serde(skip_serializing_if = "Option::is_none")]
195 pub score: Option<f64>,
196}
197
198impl FieldCheck {
199 const UNKNOWN: FieldCheck = FieldCheck {
200 agreement: Agreement::Unknown,
201 score: None,
202 };
203
204 fn exact(agrees: bool) -> Self {
205 FieldCheck::scored(if agrees { 1.0 } else { 0.0 })
206 }
207
208 fn scored(score: f64) -> Self {
209 let agreement = match score {
210 s if s >= 0.9 => Agreement::Match,
211 s if s >= 0.5 => Agreement::Partial,
212 _ => Agreement::Mismatch,
213 };
214 FieldCheck {
215 agreement,
216 score: Some(score),
217 }
218 }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
222#[non_exhaustive]
223pub struct Assessment {
224 pub confidence: f64,
225 pub title: FieldCheck,
226 pub authors: FieldCheck,
227 pub year: FieldCheck,
228 pub container: FieldCheck,
231 pub volume: FieldCheck,
232 pub page: FieldCheck,
234 pub ids: Agreement,
238}
239
240pub fn assess(claim: &Claim, record: &SourceRecord) -> Assessment {
242 let work = &record.work;
243 let text = claim.text.as_deref().map(Normalised::new);
244
245 let title = match (&claim.title, &work.title, &text) {
246 (Some(claimed), Some(actual), _) => FieldCheck::scored(title_similarity(claimed, actual)),
247 (None, Some(actual), Some(text)) => FieldCheck::scored(text.title_coverage(actual)),
248 _ => FieldCheck::UNKNOWN,
249 };
250
251 let families: Vec<String> = work.authors.iter().filter_map(family_name).collect();
252 let authors = if families.is_empty() {
253 FieldCheck::UNKNOWN
254 } else if !claim.authors.is_empty() {
255 FieldCheck::scored(author_overlap(&claim.authors, &families))
256 } else if let Some(text) = &text {
257 FieldCheck::scored(text.author_coverage(&families))
258 } else {
259 FieldCheck::UNKNOWN
260 };
261
262 let actual_years: Vec<i16> = work
263 .issued
264 .map(|d| d.year())
265 .into_iter()
266 .chain(work.other_years.iter().copied())
267 .collect();
268 let claimed_years: Vec<i16> = match (claim.year, &text) {
269 (Some(y), _) => vec![y],
270 (None, Some(text)) => text.years(),
271 _ => Vec::new(),
272 };
273 let year = match actual_years.is_empty() || claimed_years.is_empty() {
274 false => {
275 let delta = claimed_years
277 .iter()
278 .flat_map(|c| actual_years.iter().map(move |a| (c - a).unsigned_abs()))
279 .min()
280 .unwrap_or(u16::MAX);
281 FieldCheck::scored(0.5f64.powi(i32::from(delta.min(30))))
285 }
286 true => FieldCheck::UNKNOWN,
287 };
288
289 let container = match (&claim.container, &work.container_title, &text) {
290 (Some(claimed), Some(actual), _) => FieldCheck::exact(same_journal(claimed, actual)),
291 (None, Some(actual), Some(text)) if journal_in(&text.words, actual) => {
292 FieldCheck::exact(true)
293 }
294 _ => FieldCheck::UNKNOWN,
295 };
296 let located = match &claim.text {
297 Some(text) if claim.volume.is_none() && claim.page.is_none() => publication::locator(text),
298 _ => publication::Locator {
299 volume: claim.volume.clone(),
300 page: claim.page.as_deref().map(|p| first_page(p).to_owned()),
301 },
302 };
303 let same = |claimed: Option<&str>, actual: Option<&str>| match (claimed, actual) {
304 (Some(c), Some(a)) if !c.is_empty() && !a.is_empty() => {
305 FieldCheck::exact(c.trim().eq_ignore_ascii_case(a.trim()))
306 }
307 _ => FieldCheck::UNKNOWN,
308 };
309 let volume = same(located.volume.as_deref(), work.volume.as_deref());
310 let page = same(
311 located.page.as_deref(),
312 work.page.as_deref().map(first_page),
313 );
314
315 let located = volume.agreement == Agreement::Match && page.agreement == Agreement::Match;
317 let placed = container.agreement == Agreement::Match && volume.agreement == Agreement::Match;
322 let title = match placed {
323 true if claim.title.is_none() && title.agreement == Agreement::Mismatch => {
324 FieldCheck::UNKNOWN
325 }
326 _ => title,
327 };
328
329 let ids = id_agreement(&claim.ids, &record.ids);
330 let id_only = [title, authors, year].iter().all(|c| c.score.is_none());
332 let mut confidence = if id_only && ids == Agreement::Match {
333 1.0
334 } else {
335 combine(title, authors, year)
336 };
337 let corroborated = authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS)
343 || ids == Agreement::Match
344 || located;
345 if !id_only && !corroborated {
346 confidence = confidence.min(UNCORROBORATED_CAP);
347 }
348 if [volume, page]
351 .iter()
352 .any(|c| c.agreement == Agreement::Mismatch)
353 {
354 confidence = confidence.min(LOCATION_MISMATCH_CAP);
355 }
356 if container.agreement == Agreement::Mismatch {
359 confidence *= JOURNAL_MISMATCH_PENALTY;
360 }
361 if let Some(actual) = &work.title
364 && is_derivative(actual)
365 && !claim_mentions_derivative(claim)
366 {
367 confidence *= DERIVATIVE_PENALTY;
368 }
369
370 Assessment {
371 confidence,
372 title,
373 authors,
374 year,
375 container,
376 volume,
377 page,
378 ids,
379 }
380}
381
382fn combine(title: FieldCheck, authors: FieldCheck, year: FieldCheck) -> f64 {
383 let parts = [(title, W_TITLE), (authors, W_AUTHORS), (year, W_YEAR)];
384 let known: Vec<(f64, f64)> = parts
385 .iter()
386 .filter_map(|(check, w)| check.score.map(|s| (s, *w)))
387 .collect();
388 let weight: f64 = known.iter().map(|(_, w)| w).sum();
389 if weight == 0.0 {
390 return 0.0;
391 }
392 let mut confidence = known.iter().map(|(s, w)| s * w).sum::<f64>() / weight;
393 if year.agreement == Agreement::Mismatch {
394 confidence *= YEAR_MISMATCH_PENALTY;
395 }
396 if title.agreement == Agreement::Mismatch {
397 confidence *= TITLE_MISMATCH_PENALTY;
398 }
399 if authors.agreement == Agreement::Mismatch {
402 confidence *= AUTHORS_MISMATCH_PENALTY;
403 }
404 if known.len() < 2 {
405 confidence = confidence.min(SINGLE_FIELD_CAP);
406 }
407 confidence
408}
409
410pub fn id_agreement(claimed: &[WorkId], actual: &[WorkId]) -> Agreement {
413 if claimed.is_empty() {
414 return Agreement::Unknown;
415 }
416 if claimed
417 .iter()
418 .any(|c| actual.iter().any(|a| same_work_id(c, a)))
419 {
420 return Agreement::Match;
421 }
422 let shares_kind = claimed
423 .iter()
424 .any(|c| actual.iter().any(|a| a.kind() == c.kind()));
425 if shares_kind {
426 Agreement::Mismatch
427 } else {
428 Agreement::Unknown
429 }
430}
431
432pub fn same_work_id(a: &WorkId, b: &WorkId) -> bool {
434 match (a, b) {
435 (WorkId::Arxiv(x), WorkId::Arxiv(y)) => x.unversioned() == y.unversioned(),
436 _ => a == b,
437 }
438}
439
440pub fn find_ids(text: &str) -> Vec<WorkId> {
442 let mut found: Vec<WorkId> = Vec::new();
443 for mention in crate::id::scan(text) {
444 if !found.iter().any(|f| same_work_id(f, &mention.id)) {
445 found.push(mention.id);
446 }
447 }
448 found
449}
450
451#[cfg(test)]
452mod tests;