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, content_words};
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 NO_EVIDENCE_CAP: f64 = 0.5;
56const SHORT_TITLE_WORDS: usize = 3;
59const DERIVATIVE_PENALTY: f64 = 0.5;
61const LOCATION_MISMATCH_CAP: f64 = 0.8;
63const JOURNAL_MISMATCH_PENALTY: f64 = 0.9;
65
66pub const VERIFIED: f64 = 0.85;
68pub const PLAUSIBLE: f64 = 0.6;
69
70const AUTHORS_COMPARED: usize = 3;
72
73#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
75#[non_exhaustive]
76pub struct Claim {
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub text: Option<String>,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub ids: Vec<WorkId>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub title: Option<String>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub authors: Vec<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub year: Option<i16>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub container: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub volume: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub page: Option<String>,
98 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub alternatives: Vec<Alternative>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[non_exhaustive]
107pub struct Alternative {
108 pub claimed: WorkId,
109 pub alternative: WorkId,
110}
111
112impl Alternative {
113 pub fn new(claimed: WorkId, alternative: WorkId) -> Self {
114 Alternative {
115 claimed,
116 alternative,
117 }
118 }
119}
120
121impl Claim {
122 pub fn from_text(text: impl Into<String>) -> Self {
125 let text = text.into();
126 Claim {
127 ids: find_ids(&text),
128 text: Some(text),
129 ..Claim::default()
130 }
131 }
132
133 pub fn from_input(input: &str) -> Self {
137 let input = input.trim();
138 let whole = || match crate::id::scan(input).as_slice() {
139 [m] if m.span == (0..input.len()) => Some(m.id.clone()),
141 _ => None,
142 };
143 match input.parse::<WorkId>().ok().or_else(whole) {
144 Some(id) => Claim {
145 ids: vec![id],
146 ..Claim::default()
147 },
148 None => Claim::from_text(input),
149 }
150 }
151
152 pub fn is_empty(&self) -> bool {
155 let blank = |s: &Option<String>| s.as_deref().is_none_or(|s| s.trim().is_empty());
156 blank(&self.text)
157 && self.ids.is_empty()
158 && blank(&self.title)
159 && self.authors.iter().all(|a| a.trim().is_empty())
160 }
161
162 pub fn reference(&self) -> Option<String> {
165 if let Some(text) = &self.text {
166 return Some(text.clone());
167 }
168 let location = match (&self.volume, &self.page) {
170 (Some(v), Some(p)) => Some(format!("{v}:{p}")),
171 (Some(v), None) => Some(v.clone()),
172 (None, p) => p.clone(),
173 };
174 let when = [self.year.map(|y| y.to_string()), location]
175 .into_iter()
176 .flatten()
177 .collect::<Vec<_>>()
178 .join(";");
179 let parts: Vec<String> = [
180 (!self.authors.is_empty()).then(|| self.authors.join(", ")),
181 self.title.clone(),
182 self.container.clone(),
183 (!when.is_empty()).then_some(when),
184 ]
185 .into_iter()
186 .flatten()
187 .collect();
188 (!parts.is_empty()).then(|| parts.join(". "))
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "snake_case")]
194pub enum Agreement {
195 Match,
196 Partial,
197 Mismatch,
198 Unknown,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
204pub struct FieldCheck {
205 pub agreement: Agreement,
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub score: Option<f64>,
208}
209
210impl FieldCheck {
211 const UNKNOWN: FieldCheck = FieldCheck {
212 agreement: Agreement::Unknown,
213 score: None,
214 };
215
216 fn exact(agrees: bool) -> Self {
217 FieldCheck::scored(if agrees { 1.0 } else { 0.0 })
218 }
219
220 fn scored(score: f64) -> Self {
221 let agreement = match score {
222 s if s >= 0.9 => Agreement::Match,
223 s if s >= 0.5 => Agreement::Partial,
224 _ => Agreement::Mismatch,
225 };
226 FieldCheck {
227 agreement,
228 score: Some(score),
229 }
230 }
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
234#[non_exhaustive]
235pub struct Assessment {
236 pub confidence: f64,
237 pub title: FieldCheck,
238 pub authors: FieldCheck,
239 pub year: FieldCheck,
240 pub container: FieldCheck,
243 pub volume: FieldCheck,
244 pub page: FieldCheck,
246 pub ids: Agreement,
250}
251
252fn is_url(title: &str) -> bool {
254 let t = title.trim();
255 let starts = |p: &str| t.get(..p.len()).is_some_and(|h| h.eq_ignore_ascii_case(p));
256 starts("http://") || starts("https://") || starts("www.") || t.contains("://")
257}
258
259pub fn assess(claim: &Claim, record: &SourceRecord) -> Assessment {
261 let work = &record.work;
262 let text = claim.text.as_deref().map(Normalised::new);
263 let work_title = work.title.clone().filter(|t| !is_url(t));
266
267 let title = match (&claim.title, &work_title, &text) {
268 (Some(claimed), Some(actual), _) => FieldCheck::scored(title_similarity(claimed, actual)),
269 (None, Some(actual), Some(text)) => FieldCheck::scored(text.title_coverage(actual)),
270 _ => FieldCheck::UNKNOWN,
271 };
272
273 let families: Vec<String> = work.authors.iter().filter_map(family_name).collect();
274 let authors = if families.is_empty() {
275 FieldCheck::UNKNOWN
276 } else if !claim.authors.is_empty() {
277 FieldCheck::scored(author_overlap(&claim.authors, &families))
278 } else if let Some(text) = &text {
279 FieldCheck::scored(text.author_coverage(&families))
280 } else {
281 FieldCheck::UNKNOWN
282 };
283
284 let actual_years: Vec<i16> = work
285 .issued
286 .map(|d| d.year())
287 .into_iter()
288 .chain(work.other_years.iter().copied())
289 .collect();
290 let claimed_years: Vec<i16> = match (claim.year, &text) {
291 (Some(y), _) => vec![y],
292 (None, Some(text)) => text.years(),
293 _ => Vec::new(),
294 };
295 let year = match actual_years.is_empty() || claimed_years.is_empty() {
296 false => {
297 let delta = claimed_years
299 .iter()
300 .flat_map(|c| actual_years.iter().map(move |a| (c - a).unsigned_abs()))
301 .min()
302 .unwrap_or(u16::MAX);
303 FieldCheck::scored(0.5f64.powi(i32::from(delta.min(30))))
307 }
308 true => FieldCheck::UNKNOWN,
309 };
310
311 let container = match (&claim.container, &work.container_title, &text) {
312 (Some(claimed), Some(actual), _) => FieldCheck::exact(same_journal(claimed, actual)),
313 (None, Some(actual), Some(text)) if journal_in(&text.words, actual) => {
314 FieldCheck::exact(true)
315 }
316 _ => FieldCheck::UNKNOWN,
317 };
318 let located = match &claim.text {
319 Some(text) if claim.volume.is_none() && claim.page.is_none() => publication::locator(text),
320 _ => publication::Locator {
321 volume: claim.volume.clone(),
322 page: claim.page.as_deref().map(|p| first_page(p).to_owned()),
323 },
324 };
325 let same = |claimed: Option<&str>, actual: Option<&str>| match (claimed, actual) {
326 (Some(c), Some(a)) if !c.is_empty() && !a.is_empty() => {
327 FieldCheck::exact(c.trim().eq_ignore_ascii_case(a.trim()))
328 }
329 _ => FieldCheck::UNKNOWN,
330 };
331 let volume = same(located.volume.as_deref(), work.volume.as_deref());
332 let page = same(
333 located.page.as_deref(),
334 work.page.as_deref().map(first_page),
335 );
336
337 let located = volume.agreement == Agreement::Match && page.agreement == Agreement::Match;
339 let placed = container.agreement == Agreement::Match && volume.agreement == Agreement::Match;
344 let title = match placed {
345 true if claim.title.is_none() && title.agreement == Agreement::Mismatch => {
346 FieldCheck::UNKNOWN
347 }
348 _ => title,
349 };
350
351 let ids = id_agreement(&claim.ids, &record.ids);
352 let id_only = [title, authors, year].iter().all(|c| c.score.is_none());
354 let mut confidence = if id_only && ids == Agreement::Match {
355 1.0
356 } else {
357 combine(title, authors, year)
358 };
359 let corroborated = authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS)
365 || ids == Agreement::Match
366 || located;
367 if !id_only && !corroborated {
368 confidence = confidence.min(UNCORROBORATED_CAP);
369 }
370 let short_title_in_text = claim.title.is_none()
374 && text.is_some()
375 && work_title
376 .as_deref()
377 .is_some_and(|t| content_words(t).len() < SHORT_TITLE_WORDS);
378 if short_title_in_text && !corroborated && container.agreement != Agreement::Match {
379 confidence = confidence.min(NO_EVIDENCE_CAP);
380 }
381 let identified =
385 matches!(title.agreement, Agreement::Match | Agreement::Partial) || corroborated;
386 if !id_only && !identified {
387 confidence = confidence.min(NO_EVIDENCE_CAP);
388 }
389 if [volume, page]
392 .iter()
393 .any(|c| c.agreement == Agreement::Mismatch)
394 {
395 confidence = confidence.min(LOCATION_MISMATCH_CAP);
396 }
397 if container.agreement == Agreement::Mismatch {
400 confidence *= JOURNAL_MISMATCH_PENALTY;
401 }
402 if let Some(actual) = &work_title
405 && is_derivative(actual)
406 && !claim_mentions_derivative(claim)
407 {
408 confidence *= DERIVATIVE_PENALTY;
409 }
410
411 Assessment {
412 confidence,
413 title,
414 authors,
415 year,
416 container,
417 volume,
418 page,
419 ids,
420 }
421}
422
423fn combine(title: FieldCheck, authors: FieldCheck, year: FieldCheck) -> f64 {
424 let parts = [(title, W_TITLE), (authors, W_AUTHORS), (year, W_YEAR)];
425 let known: Vec<(f64, f64)> = parts
426 .iter()
427 .filter_map(|(check, w)| check.score.map(|s| (s, *w)))
428 .collect();
429 let weight: f64 = known.iter().map(|(_, w)| w).sum();
430 if weight == 0.0 {
431 return 0.0;
432 }
433 let mut confidence = known.iter().map(|(s, w)| s * w).sum::<f64>() / weight;
434 if year.agreement == Agreement::Mismatch {
435 confidence *= YEAR_MISMATCH_PENALTY;
436 }
437 if title.agreement == Agreement::Mismatch {
438 confidence *= TITLE_MISMATCH_PENALTY;
439 }
440 if authors.agreement == Agreement::Mismatch {
443 confidence *= AUTHORS_MISMATCH_PENALTY;
444 }
445 if known.len() < 2 {
446 confidence = confidence.min(SINGLE_FIELD_CAP);
447 }
448 confidence
449}
450
451pub fn id_agreement(claimed: &[WorkId], actual: &[WorkId]) -> Agreement {
454 if claimed.is_empty() {
455 return Agreement::Unknown;
456 }
457 if claimed
458 .iter()
459 .any(|c| actual.iter().any(|a| same_work_id(c, a)))
460 {
461 return Agreement::Match;
462 }
463 let shares_kind = claimed
464 .iter()
465 .any(|c| actual.iter().any(|a| a.kind() == c.kind()));
466 if shares_kind {
467 Agreement::Mismatch
468 } else {
469 Agreement::Unknown
470 }
471}
472
473pub fn same_work_id(a: &WorkId, b: &WorkId) -> bool {
475 match (a, b) {
476 (WorkId::Arxiv(x), WorkId::Arxiv(y)) => x.unversioned() == y.unversioned(),
477 _ => a == b,
478 }
479}
480
481pub fn find_ids(text: &str) -> Vec<WorkId> {
483 let mut found: Vec<WorkId> = Vec::new();
484 for mention in crate::id::scan(text) {
485 if !found.iter().any(|f| same_work_id(f, &mention.id)) {
486 found.push(mention.id);
487 }
488 }
489 found
490}
491
492#[cfg(test)]
493mod tests;