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::{claim_mentions_derivative, derivative_kind, 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 PLACED: f64 = 0.9;
55const PLACED_YEAR_OFF: f64 = 0.8;
58const NO_EVIDENCE_CAP: f64 = 0.5;
62const SHORT_TITLE_WORDS: usize = 3;
65const DERIVATIVE_PENALTY: f64 = 0.5;
67const LOCATION_MISMATCH_CAP: f64 = 0.8;
69const JOURNAL_MISMATCH_PENALTY: f64 = 0.9;
71
72pub const VERIFIED: f64 = 0.85;
74pub const PLAUSIBLE: f64 = 0.6;
75
76const AUTHORS_COMPARED: usize = 3;
78
79#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
81#[non_exhaustive]
82pub struct Claim {
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub text: Option<String>,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub ids: Vec<WorkId>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub title: Option<String>,
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub authors: Vec<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub year: Option<i16>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub container: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub volume: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub page: Option<String>,
104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
107 pub alternatives: Vec<Alternative>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[non_exhaustive]
113pub struct Alternative {
114 pub claimed: WorkId,
115 pub alternative: WorkId,
116}
117
118impl Alternative {
119 pub fn new(claimed: WorkId, alternative: WorkId) -> Self {
120 Alternative {
121 claimed,
122 alternative,
123 }
124 }
125}
126
127impl Claim {
128 pub fn from_text(text: impl Into<String>) -> Self {
131 let text = text.into();
132 Claim {
133 ids: find_ids(&text),
134 text: Some(text),
135 ..Claim::default()
136 }
137 }
138
139 pub fn from_input(input: &str) -> Self {
143 let input = input.trim();
144 let whole = || match crate::id::scan(input).as_slice() {
145 [m] if m.span == (0..input.len()) => Some(m.id.clone()),
147 _ => None,
148 };
149 match input.parse::<WorkId>().ok().or_else(whole) {
150 Some(id) => Claim {
151 ids: vec![id],
152 ..Claim::default()
153 },
154 None => Claim::from_text(input),
155 }
156 }
157
158 pub fn place(&self) -> Option<Place> {
161 let (volume, page) = match (&self.volume, &self.page) {
162 (Some(v), Some(p)) => (Some(v.clone()), Some(first_page(p).to_owned())),
163 _ => {
164 let found = publication::locator(self.text.as_deref()?);
165 (found.volume, found.page)
166 }
167 };
168 Some(Place::new(volume?, page?))
169 }
170
171 pub fn cited_years(&self) -> Vec<i16> {
173 match (self.year, &self.text) {
174 (Some(year), _) => vec![year],
175 (None, Some(text)) => Normalised::new(text).years(),
176 _ => Vec::new(),
177 }
178 }
179
180 pub fn is_empty(&self) -> bool {
183 let blank = |s: &Option<String>| s.as_deref().is_none_or(|s| s.trim().is_empty());
184 blank(&self.text)
185 && self.ids.is_empty()
186 && blank(&self.title)
187 && self.authors.iter().all(|a| a.trim().is_empty())
188 }
189
190 pub fn reference(&self) -> Option<String> {
193 if let Some(text) = &self.text {
194 return Some(text.clone());
195 }
196 let location = match (&self.volume, &self.page) {
198 (Some(v), Some(p)) => Some(format!("{v}:{p}")),
199 (Some(v), None) => Some(v.clone()),
200 (None, p) => p.clone(),
201 };
202 let when = [self.year.map(|y| y.to_string()), location]
203 .into_iter()
204 .flatten()
205 .collect::<Vec<_>>()
206 .join(";");
207 let parts: Vec<String> = [
208 (!self.authors.is_empty()).then(|| self.authors.join(", ")),
209 self.title.clone(),
210 self.container.clone(),
211 (!when.is_empty()).then_some(when),
212 ]
213 .into_iter()
214 .flatten()
215 .collect();
216 (!parts.is_empty()).then(|| parts.join(". "))
217 }
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum Agreement {
223 Match,
224 Partial,
225 Mismatch,
226 Unknown,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
232pub struct FieldCheck {
233 pub agreement: Agreement,
234 #[serde(skip_serializing_if = "Option::is_none")]
235 pub score: Option<f64>,
236}
237
238impl FieldCheck {
239 const UNKNOWN: FieldCheck = FieldCheck {
240 agreement: Agreement::Unknown,
241 score: None,
242 };
243
244 fn exact(agrees: bool) -> Self {
245 FieldCheck::scored(if agrees { 1.0 } else { 0.0 })
246 }
247
248 fn scored(score: f64) -> Self {
249 let agreement = match score {
250 s if s >= 0.9 => Agreement::Match,
251 s if s >= 0.5 => Agreement::Partial,
252 _ => Agreement::Mismatch,
253 };
254 FieldCheck {
255 agreement,
256 score: Some(score),
257 }
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
262#[non_exhaustive]
263pub struct Assessment {
264 pub confidence: f64,
265 pub title: FieldCheck,
266 pub authors: FieldCheck,
267 pub year: FieldCheck,
268 pub container: FieldCheck,
271 pub volume: FieldCheck,
272 pub page: FieldCheck,
274 pub ids: Agreement,
278}
279
280fn is_url(title: &str) -> bool {
282 let t = title.trim();
283 let starts = |p: &str| t.get(..p.len()).is_some_and(|h| h.eq_ignore_ascii_case(p));
284 starts("http://") || starts("https://") || starts("www.") || t.contains("://")
285}
286
287pub fn assess(claim: &Claim, record: &SourceRecord) -> Assessment {
289 let work = &record.work;
290 let text = claim.text.as_deref().map(Normalised::new);
291 let work_title = work.title.clone().filter(|t| !is_url(t));
294
295 let families: Vec<String> = work.authors.iter().filter_map(family_name).collect();
296 let untitled = claim.title.is_none()
299 && text
300 .as_ref()
301 .is_some_and(|t| t.untitled(&families, work.container_title.as_deref()));
302
303 let title = match (&claim.title, &work_title, &text) {
304 (Some(claimed), Some(actual), _) => FieldCheck::scored(title_similarity(claimed, actual)),
305 (None, Some(actual), Some(text)) if !untitled => {
306 FieldCheck::scored(text.title_coverage(actual))
307 }
308 _ => FieldCheck::UNKNOWN,
309 };
310
311 let authors = if families.is_empty() {
312 FieldCheck::UNKNOWN
313 } else if !claim.authors.is_empty() {
314 FieldCheck::scored(author_overlap(&claim.authors, &families))
315 } else if let Some(text) = &text {
316 match text.author_coverage(&families) {
319 0.0 if !claim.text.as_deref().is_some_and(authors::names_given) => FieldCheck::UNKNOWN,
320 coverage => FieldCheck::scored(coverage),
321 }
322 } else {
323 FieldCheck::UNKNOWN
324 };
325
326 let actual_years: Vec<i16> = work
327 .issued
328 .map(|d| d.year())
329 .into_iter()
330 .chain(work.other_years.iter().copied())
331 .collect();
332 let claimed_years: Vec<i16> = match (claim.year, &text) {
333 (Some(y), _) => vec![y],
334 (None, Some(text)) => text.years(),
335 _ => Vec::new(),
336 };
337 let year = match actual_years.is_empty() || claimed_years.is_empty() {
338 false => {
339 let delta = claimed_years
341 .iter()
342 .flat_map(|c| actual_years.iter().map(move |a| (c - a).unsigned_abs()))
343 .min()
344 .unwrap_or(u16::MAX);
345 FieldCheck::scored(0.5f64.powi(i32::from(delta.min(30))))
349 }
350 true => FieldCheck::UNKNOWN,
351 };
352
353 let container = match (&claim.container, &work.container_title, &text) {
354 (Some(claimed), Some(actual), _) => FieldCheck::exact(same_journal(claimed, actual)),
355 (None, Some(actual), Some(text)) if journal_in(&text.words, actual) => {
356 FieldCheck::exact(true)
357 }
358 _ => FieldCheck::UNKNOWN,
359 };
360 let located = match &claim.text {
361 Some(text) if claim.volume.is_none() && claim.page.is_none() => publication::locator(text),
362 _ => publication::Locator {
363 volume: claim.volume.clone(),
364 page: claim.page.as_deref().map(|p| first_page(p).to_owned()),
365 },
366 };
367 let same = |claimed: Option<&str>, actual: Option<&str>| match (claimed, actual) {
368 (Some(c), Some(a)) if !c.is_empty() && !a.is_empty() => {
369 FieldCheck::exact(c.trim().eq_ignore_ascii_case(a.trim()))
370 }
371 _ => FieldCheck::UNKNOWN,
372 };
373 let volume = same(located.volume.as_deref(), work.volume.as_deref());
374 let page = same(
375 located.page.as_deref(),
376 work.page.as_deref().map(first_page),
377 );
378
379 let located = volume.agreement == Agreement::Match && page.agreement == Agreement::Match;
381 let placed = container.agreement == Agreement::Match && volume.agreement == Agreement::Match;
386 let title = match placed {
387 true if claim.title.is_none() && title.agreement == Agreement::Mismatch => {
388 FieldCheck::UNKNOWN
389 }
390 _ => title,
391 };
392
393 let ids = id_agreement(&claim.ids, &record.ids);
394 let id_only = [title, authors, year].iter().all(|c| c.score.is_none());
396 let mut confidence = if id_only && ids == Agreement::Match {
397 1.0
398 } else {
399 combine(title, authors, year)
400 };
401 let corroborated = authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS)
407 || ids == Agreement::Match
408 || located;
409 if !id_only && !corroborated {
410 confidence = confidence.min(UNCORROBORATED_CAP);
411 }
412 let short_title_in_text = claim.title.is_none()
416 && text.is_some()
417 && work_title
418 .as_deref()
419 .is_some_and(|t| content_words(t).len() < SHORT_TITLE_WORDS);
420 if short_title_in_text && !corroborated && container.agreement != Agreement::Match {
421 confidence = confidence.min(NO_EVIDENCE_CAP);
422 }
423 let identified =
427 matches!(title.agreement, Agreement::Match | Agreement::Partial) || corroborated;
428 if !id_only && !identified {
429 confidence = confidence.min(NO_EVIDENCE_CAP);
430 }
431 let pinned = ids == Agreement::Match
434 || located
435 || (placed && authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS));
436 if untitled && !pinned {
437 confidence = confidence.min(NO_EVIDENCE_CAP);
438 }
439 if located
443 && container.agreement == Agreement::Match
444 && title.agreement != Agreement::Mismatch
445 && authors.agreement != Agreement::Mismatch
446 {
447 let floor = match year.agreement {
448 Agreement::Match | Agreement::Unknown => PLACED,
449 Agreement::Partial => PLACED_YEAR_OFF,
450 Agreement::Mismatch => 0.0,
451 };
452 confidence = confidence.max(floor);
453 }
454 if [volume, page]
457 .iter()
458 .any(|c| c.agreement == Agreement::Mismatch)
459 {
460 confidence = confidence.min(LOCATION_MISMATCH_CAP);
461 }
462 if container.agreement == Agreement::Mismatch {
465 confidence *= JOURNAL_MISMATCH_PENALTY;
466 }
467 if let Some(actual) = &work_title
470 && is_derivative(actual)
471 && !claim_mentions_derivative(claim)
472 {
473 confidence *= DERIVATIVE_PENALTY;
474 }
475
476 Assessment {
477 confidence,
478 title,
479 authors,
480 year,
481 container,
482 volume,
483 page,
484 ids,
485 }
486}
487
488fn combine(title: FieldCheck, authors: FieldCheck, year: FieldCheck) -> f64 {
489 let parts = [(title, W_TITLE), (authors, W_AUTHORS), (year, W_YEAR)];
490 let known: Vec<(f64, f64)> = parts
491 .iter()
492 .filter_map(|(check, w)| check.score.map(|s| (s, *w)))
493 .collect();
494 let weight: f64 = known.iter().map(|(_, w)| w).sum();
495 if weight == 0.0 {
496 return 0.0;
497 }
498 let mut confidence = known.iter().map(|(s, w)| s * w).sum::<f64>() / weight;
499 if year.agreement == Agreement::Mismatch {
500 confidence *= YEAR_MISMATCH_PENALTY;
501 }
502 if title.agreement == Agreement::Mismatch {
503 confidence *= TITLE_MISMATCH_PENALTY;
504 }
505 if authors.agreement == Agreement::Mismatch {
508 confidence *= AUTHORS_MISMATCH_PENALTY;
509 }
510 if known.len() < 2 {
511 confidence = confidence.min(SINGLE_FIELD_CAP);
512 }
513 confidence
514}
515
516pub fn id_agreement(claimed: &[WorkId], actual: &[WorkId]) -> Agreement {
519 if claimed.is_empty() {
520 return Agreement::Unknown;
521 }
522 if claimed
523 .iter()
524 .any(|c| actual.iter().any(|a| same_work_id(c, a)))
525 {
526 return Agreement::Match;
527 }
528 let shares_kind = claimed
529 .iter()
530 .any(|c| actual.iter().any(|a| a.kind() == c.kind()));
531 if shares_kind {
532 Agreement::Mismatch
533 } else {
534 Agreement::Unknown
535 }
536}
537
538pub fn same_work_id(a: &WorkId, b: &WorkId) -> bool {
540 match (a, b) {
541 (WorkId::Arxiv(x), WorkId::Arxiv(y)) => x.unversioned() == y.unversioned(),
542 _ => a == b,
543 }
544}
545
546pub fn find_ids(text: &str) -> Vec<WorkId> {
548 let mut found: Vec<WorkId> = Vec::new();
549 for mention in crate::id::scan(text) {
550 if !found.iter().any(|f| same_work_id(f, &mention.id)) {
551 found.push(mention.id);
552 }
553 }
554 found
555}
556
557#[cfg(test)]
558mod tests;