1mod authors;
19mod derivative;
20mod text;
21
22use serde::{Deserialize, Serialize};
23
24use crate::id::WorkId;
25use crate::provider::Place;
26use crate::publication::{self, first_page, journal_in, same_journal};
27use crate::record::SourceRecord;
28
29use authors::{author_overlap, family_name};
30pub use derivative::{
31 WorkKind, claim_mentions_derivative, derivative_kind, derivative_work_kind, is_derivative,
32};
33use text::{Normalised, content_words};
34pub use text::{normalise, search_terms, shared_title, title_similarity, unmarked};
35
36const W_TITLE: f64 = 0.5;
38const W_AUTHORS: f64 = 0.25;
39const W_YEAR: f64 = 0.25;
40
41const YEAR_MISMATCH_PENALTY: f64 = 0.5;
43const TITLE_MISMATCH_PENALTY: f64 = 0.5;
45const AUTHORS_MISMATCH_PENALTY: f64 = 0.5;
47const SINGLE_FIELD_CAP: f64 = 0.7;
49const UNCORROBORATED_CAP: f64 = 0.8;
51const CORROBORATING_AUTHORS: f64 = 0.75;
54const PLACED: f64 = 0.9;
57const PLACED_YEAR_OFF: f64 = 0.8;
60const NO_EVIDENCE_CAP: f64 = 0.5;
64const SHORT_TITLE_WORDS: usize = 3;
67const DERIVATIVE_PENALTY: f64 = 0.5;
69const LOCATION_MISMATCH_CAP: f64 = 0.8;
71const JOURNAL_MISMATCH_PENALTY: f64 = 0.9;
73
74pub const VERIFIED: f64 = 0.85;
76pub const PLAUSIBLE: f64 = 0.6;
77
78const AUTHORS_COMPARED: usize = 3;
80
81#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
83#[non_exhaustive]
84pub struct Claim {
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub text: Option<String>,
88 #[serde(default, skip_serializing_if = "Vec::is_empty")]
90 pub ids: Vec<WorkId>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub title: Option<String>,
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub authors: Vec<String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub year: Option<i16>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub container: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub volume: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub page: Option<String>,
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub alternatives: Vec<Alternative>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[non_exhaustive]
115pub struct Alternative {
116 pub claimed: WorkId,
117 pub alternative: WorkId,
118}
119
120impl Alternative {
121 pub fn new(claimed: WorkId, alternative: WorkId) -> Self {
122 Alternative {
123 claimed,
124 alternative,
125 }
126 }
127}
128
129impl Claim {
130 pub fn from_text(text: impl Into<String>) -> Self {
133 let text = text.into();
134 let mut alternatives: Vec<Alternative> = Vec::new();
136 for m in crate::id::scan(&text) {
137 if let Some(other) = m.alternative(&text) {
138 let a = Alternative::new(m.id, other);
139 if !alternatives.contains(&a) {
140 alternatives.push(a);
141 }
142 }
143 }
144 Claim {
145 ids: find_ids(&text),
146 text: Some(text),
147 alternatives,
148 ..Claim::default()
149 }
150 }
151
152 pub fn from_input(input: &str) -> Self {
156 let input = input.trim();
157 let whole = || match crate::id::scan(input).as_slice() {
158 [m] if m.span == (0..input.len()) => Some(m.id.clone()),
160 _ => None,
161 };
162 match input.parse::<WorkId>().ok().or_else(whole) {
163 Some(id) => Claim {
164 ids: vec![id],
165 ..Claim::default()
166 },
167 None => Claim::from_text(input),
168 }
169 }
170
171 pub fn place(&self) -> Option<Place> {
174 let (volume, page) = match (&self.volume, &self.page) {
175 (Some(v), Some(p)) => (Some(v.clone()), Some(first_page(p).to_owned())),
176 _ => {
177 let found = publication::locator(self.text.as_deref()?);
178 (found.volume, found.page)
179 }
180 };
181 Some(Place::new(volume?, page?))
182 }
183
184 pub fn cited_years(&self) -> Vec<i16> {
186 match (self.year, &self.text) {
187 (Some(year), _) => vec![year],
188 (None, Some(text)) => Normalised::new(text).years(),
189 _ => Vec::new(),
190 }
191 }
192
193 pub fn is_empty(&self) -> bool {
196 let blank = |s: &Option<String>| s.as_deref().is_none_or(|s| s.trim().is_empty());
197 blank(&self.text)
198 && self.ids.is_empty()
199 && blank(&self.title)
200 && self.authors.iter().all(|a| a.trim().is_empty())
201 }
202
203 pub fn reference(&self) -> Option<String> {
206 if let Some(text) = &self.text {
207 return Some(text.clone());
208 }
209 let location = match (&self.volume, &self.page) {
211 (Some(v), Some(p)) => Some(format!("{v}:{p}")),
212 (Some(v), None) => Some(v.clone()),
213 (None, p) => p.clone(),
214 };
215 let when = [self.year.map(|y| y.to_string()), location]
216 .into_iter()
217 .flatten()
218 .collect::<Vec<_>>()
219 .join(";");
220 let parts: Vec<String> = [
221 (!self.authors.is_empty()).then(|| self.authors.join(", ")),
222 self.title.clone(),
223 self.container.clone(),
224 (!when.is_empty()).then_some(when),
225 ]
226 .into_iter()
227 .flatten()
228 .collect();
229 (!parts.is_empty()).then(|| parts.join(". "))
230 }
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum Agreement {
236 Match,
237 Partial,
238 Mismatch,
239 Unknown,
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
245pub struct FieldCheck {
246 pub agreement: Agreement,
247 #[serde(skip_serializing_if = "Option::is_none")]
248 pub score: Option<f64>,
249}
250
251impl FieldCheck {
252 const UNKNOWN: FieldCheck = FieldCheck {
253 agreement: Agreement::Unknown,
254 score: None,
255 };
256
257 fn exact(agrees: bool) -> Self {
258 FieldCheck::scored(if agrees { 1.0 } else { 0.0 })
259 }
260
261 fn scored(score: f64) -> Self {
262 let agreement = match score {
263 s if s >= 0.9 => Agreement::Match,
264 s if s >= 0.5 => Agreement::Partial,
265 _ => Agreement::Mismatch,
266 };
267 FieldCheck {
268 agreement,
269 score: Some(score),
270 }
271 }
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
275#[non_exhaustive]
276pub struct Assessment {
277 pub confidence: f64,
278 pub title: FieldCheck,
279 pub authors: FieldCheck,
280 pub year: FieldCheck,
281 pub container: FieldCheck,
284 pub volume: FieldCheck,
285 pub page: FieldCheck,
287 pub ids: Agreement,
291}
292
293fn is_url(title: &str) -> bool {
295 let t = title.trim();
296 let starts = |p: &str| t.get(..p.len()).is_some_and(|h| h.eq_ignore_ascii_case(p));
297 starts("http://") || starts("https://") || starts("www.") || t.contains("://")
298}
299
300pub fn assess(claim: &Claim, record: &SourceRecord) -> Assessment {
302 let work = &record.work;
303 let text = claim.text.as_deref().map(Normalised::new);
304 let work_title = work.title.clone().filter(|t| !is_url(t));
307
308 let families: Vec<String> = work.authors.iter().filter_map(family_name).collect();
309 let untitled = claim.title.is_none()
312 && text
313 .as_ref()
314 .is_some_and(|t| t.untitled(&families, work.container_title.as_deref()));
315
316 let title = match (&claim.title, &work_title, &text) {
317 (Some(claimed), Some(actual), _) => FieldCheck::scored(title_similarity(claimed, actual)),
318 (None, Some(actual), Some(text)) if !untitled => {
319 FieldCheck::scored(text.title_coverage(actual))
320 }
321 _ => FieldCheck::UNKNOWN,
322 };
323
324 let authors = if families.is_empty() {
325 FieldCheck::UNKNOWN
326 } else if !claim.authors.is_empty() {
327 FieldCheck::scored(author_overlap(&claim.authors, &families))
328 } else if let Some(text) = &text {
329 match text.author_coverage(&families) {
332 0.0 if !claim.text.as_deref().is_some_and(authors::names_given) => FieldCheck::UNKNOWN,
333 coverage => FieldCheck::scored(coverage),
334 }
335 } else {
336 FieldCheck::UNKNOWN
337 };
338
339 let actual_years: Vec<i16> = work
340 .issued
341 .map(|d| d.year())
342 .into_iter()
343 .chain(work.other_years.iter().copied())
344 .collect();
345 let claimed_years: Vec<i16> = match (claim.year, &text) {
346 (Some(y), _) => vec![y],
347 (None, Some(text)) => text.years(),
348 _ => Vec::new(),
349 };
350 let year = match actual_years.is_empty() || claimed_years.is_empty() {
351 false => {
352 let delta = claimed_years
354 .iter()
355 .flat_map(|c| actual_years.iter().map(move |a| (c - a).unsigned_abs()))
356 .min()
357 .unwrap_or(u16::MAX);
358 FieldCheck::scored(0.5f64.powi(i32::from(delta.min(30))))
362 }
363 true => FieldCheck::UNKNOWN,
364 };
365
366 let container = match (&claim.container, &work.container_title, &text) {
367 (Some(claimed), Some(actual), _) => FieldCheck::exact(same_journal(claimed, actual)),
368 (None, Some(actual), Some(text)) if journal_in(&text.words, actual) => {
369 FieldCheck::exact(true)
370 }
371 _ => FieldCheck::UNKNOWN,
372 };
373 let located = match &claim.text {
374 Some(text) if claim.volume.is_none() && claim.page.is_none() => publication::locator(text),
375 _ => publication::Locator {
376 volume: claim.volume.clone(),
377 page: claim.page.as_deref().map(|p| first_page(p).to_owned()),
378 },
379 };
380 let same = |claimed: Option<&str>, actual: Option<&str>| match (claimed, actual) {
381 (Some(c), Some(a)) if !c.is_empty() && !a.is_empty() => {
382 FieldCheck::exact(c.trim().eq_ignore_ascii_case(a.trim()))
383 }
384 _ => FieldCheck::UNKNOWN,
385 };
386 let volume = same(located.volume.as_deref(), work.volume.as_deref());
387 let page = same(
388 located.page.as_deref(),
389 work.page.as_deref().map(first_page),
390 );
391
392 let located = volume.agreement == Agreement::Match && page.agreement == Agreement::Match;
394 let placed = container.agreement == Agreement::Match && volume.agreement == Agreement::Match;
399 let title = match placed {
400 true if claim.title.is_none() && title.agreement == Agreement::Mismatch => {
401 FieldCheck::UNKNOWN
402 }
403 _ => title,
404 };
405
406 let ids = id_agreement(&claim.ids, &record.ids);
407 let id_only = [title, authors, year].iter().all(|c| c.score.is_none());
409 let mut confidence = if id_only && ids == Agreement::Match {
410 1.0
411 } else {
412 combine(title, authors, year)
413 };
414 let corroborated = authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS)
420 || ids == Agreement::Match
421 || located;
422 if !id_only && !corroborated {
423 confidence = confidence.min(UNCORROBORATED_CAP);
424 }
425 let short_title_in_text = claim.title.is_none()
429 && text.is_some()
430 && work_title
431 .as_deref()
432 .is_some_and(|t| content_words(t).len() < SHORT_TITLE_WORDS);
433 if short_title_in_text && !corroborated && container.agreement != Agreement::Match {
434 confidence = confidence.min(NO_EVIDENCE_CAP);
435 }
436 let identified =
440 matches!(title.agreement, Agreement::Match | Agreement::Partial) || corroborated;
441 if !id_only && !identified {
442 confidence = confidence.min(NO_EVIDENCE_CAP);
443 }
444 let pinned = ids == Agreement::Match
447 || located
448 || (placed && authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS));
449 if untitled && !pinned {
450 confidence = confidence.min(NO_EVIDENCE_CAP);
451 }
452 if located
456 && container.agreement == Agreement::Match
457 && title.agreement != Agreement::Mismatch
458 && authors.agreement != Agreement::Mismatch
459 {
460 let floor = match year.agreement {
461 Agreement::Match | Agreement::Unknown => PLACED,
462 Agreement::Partial => PLACED_YEAR_OFF,
463 Agreement::Mismatch => 0.0,
464 };
465 confidence = confidence.max(floor);
466 }
467 if [volume, page]
470 .iter()
471 .any(|c| c.agreement == Agreement::Mismatch)
472 {
473 confidence = confidence.min(LOCATION_MISMATCH_CAP);
474 }
475 if container.agreement == Agreement::Mismatch {
478 confidence *= JOURNAL_MISMATCH_PENALTY;
479 }
480 if let Some(actual) = &work_title
483 && is_derivative(actual)
484 && !claim_mentions_derivative(claim)
485 {
486 confidence *= DERIVATIVE_PENALTY;
487 }
488
489 Assessment {
490 confidence,
491 title,
492 authors,
493 year,
494 container,
495 volume,
496 page,
497 ids,
498 }
499}
500
501fn combine(title: FieldCheck, authors: FieldCheck, year: FieldCheck) -> f64 {
502 let parts = [(title, W_TITLE), (authors, W_AUTHORS), (year, W_YEAR)];
503 let known: Vec<(f64, f64)> = parts
504 .iter()
505 .filter_map(|(check, w)| check.score.map(|s| (s, *w)))
506 .collect();
507 let weight: f64 = known.iter().map(|(_, w)| w).sum();
508 if weight == 0.0 {
509 return 0.0;
510 }
511 let mut confidence = known.iter().map(|(s, w)| s * w).sum::<f64>() / weight;
512 if year.agreement == Agreement::Mismatch {
513 confidence *= YEAR_MISMATCH_PENALTY;
514 }
515 if title.agreement == Agreement::Mismatch {
516 confidence *= TITLE_MISMATCH_PENALTY;
517 }
518 if authors.agreement == Agreement::Mismatch {
521 confidence *= AUTHORS_MISMATCH_PENALTY;
522 }
523 if known.len() < 2 {
524 confidence = confidence.min(SINGLE_FIELD_CAP);
525 }
526 confidence
527}
528
529pub fn id_agreement(claimed: &[WorkId], actual: &[WorkId]) -> Agreement {
532 if claimed.is_empty() {
533 return Agreement::Unknown;
534 }
535 if claimed
536 .iter()
537 .any(|c| actual.iter().any(|a| same_work_id(c, a)))
538 {
539 return Agreement::Match;
540 }
541 let shares_kind = claimed
542 .iter()
543 .any(|c| actual.iter().any(|a| a.kind() == c.kind()));
544 if shares_kind {
545 Agreement::Mismatch
546 } else {
547 Agreement::Unknown
548 }
549}
550
551pub fn same_work_id(a: &WorkId, b: &WorkId) -> bool {
553 match (a, b) {
554 (WorkId::Arxiv(x), WorkId::Arxiv(y)) => x.unversioned() == y.unversioned(),
555 _ => a == b,
556 }
557}
558
559pub fn find_ids(text: &str) -> Vec<WorkId> {
561 let mut found: Vec<WorkId> = Vec::new();
562 for mention in crate::id::scan(text) {
563 if !found.iter().any(|f| same_work_id(f, &mention.id)) {
564 found.push(mention.id);
565 }
566 }
567 found
568}
569
570#[cfg(test)]
571mod tests;