1mod authors;
19mod derivative;
20mod text;
21
22use serde::{Deserialize, Serialize};
23
24use crate::id::{WorkId, starts_with_ci};
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(crate) fn locator(&self) -> publication::Locator {
174 let given = publication::Locator {
175 volume: self.volume.clone(),
176 page: self.page.as_deref().map(|p| first_page(p).to_owned()),
177 };
178 match &self.text {
179 Some(text) if given.volume.is_none() || given.page.is_none() => {
180 let found = publication::locator(text);
181 publication::Locator {
182 volume: given.volume.or(found.volume),
183 page: given.page.or(found.page),
184 }
185 }
186 _ => given,
187 }
188 }
189
190 pub fn place(&self) -> Option<Place> {
192 let located = self.locator();
193 Some(Place::new(located.volume?, located.page?))
194 }
195
196 pub fn as_read(&self) -> Claim {
201 let mut read = self.clone();
202 if read.year.is_none()
203 && let [year] = dedup_years(self.cited_years())[..]
204 {
205 read.year = Some(year);
206 }
207 let located = self.locator();
208 read.volume = read.volume.or(located.volume);
209 read.page = read.page.or(located.page);
210 read
211 }
212
213 pub fn cited_years(&self) -> Vec<i16> {
215 match (self.year, &self.text) {
216 (Some(year), _) => vec![year],
217 (None, Some(text)) => Normalised::new(text).years(),
218 _ => Vec::new(),
219 }
220 }
221
222 pub fn is_empty(&self) -> bool {
225 let blank = |s: &Option<String>| s.as_deref().is_none_or(|s| s.trim().is_empty());
226 blank(&self.text)
227 && self.ids.is_empty()
228 && blank(&self.title)
229 && self.authors.iter().all(|a| a.trim().is_empty())
230 }
231
232 pub fn reference(&self) -> Option<String> {
235 if let Some(text) = &self.text {
236 return Some(text.clone());
237 }
238 let location = match (&self.volume, &self.page) {
240 (Some(v), Some(p)) => Some(format!("{v}:{p}")),
241 (Some(v), None) => Some(v.clone()),
242 (None, p) => p.clone(),
243 };
244 let when = [self.year.map(|y| y.to_string()), location]
245 .into_iter()
246 .flatten()
247 .collect::<Vec<_>>()
248 .join(";");
249 let parts: Vec<String> = [
250 (!self.authors.is_empty()).then(|| self.authors.join(", ")),
251 self.title.clone(),
252 self.container.clone(),
253 (!when.is_empty()).then_some(when),
254 ]
255 .into_iter()
256 .flatten()
257 .collect();
258 (!parts.is_empty()).then(|| parts.join(". "))
259 }
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "snake_case")]
264pub enum Agreement {
265 Match,
266 Partial,
267 Mismatch,
268 Unknown,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
274pub struct FieldCheck {
275 pub agreement: Agreement,
276 #[serde(skip_serializing_if = "Option::is_none")]
277 pub score: Option<f64>,
278}
279
280impl FieldCheck {
281 const UNKNOWN: FieldCheck = FieldCheck {
282 agreement: Agreement::Unknown,
283 score: None,
284 };
285
286 fn exact(agrees: bool) -> Self {
287 FieldCheck::scored(if agrees { 1.0 } else { 0.0 })
288 }
289
290 fn scored(score: f64) -> Self {
291 let agreement = match score {
292 s if s >= 0.9 => Agreement::Match,
293 s if s >= 0.5 => Agreement::Partial,
294 _ => Agreement::Mismatch,
295 };
296 FieldCheck {
297 agreement,
298 score: Some(score),
299 }
300 }
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
304#[non_exhaustive]
305pub struct Assessment {
306 pub confidence: f64,
307 pub title: FieldCheck,
308 pub authors: FieldCheck,
309 pub year: FieldCheck,
310 pub container: FieldCheck,
313 pub volume: FieldCheck,
314 pub page: FieldCheck,
316 pub ids: Agreement,
320}
321
322fn is_url(title: &str) -> bool {
324 let t = title.trim();
325 ["http://", "https://", "www."]
326 .iter()
327 .any(|p| starts_with_ci(t, p))
328 || t.contains("://")
329}
330
331fn dedup_years(mut years: Vec<i16>) -> Vec<i16> {
332 years.sort_unstable();
333 years.dedup();
334 years
335}
336
337pub fn assess(claim: &Claim, record: &SourceRecord) -> Assessment {
339 let work = &record.work;
340 let text = claim.text.as_deref().map(Normalised::new);
341 let work_title = work.title.clone().filter(|t| !is_url(t));
344
345 let families: Vec<String> = work.authors.iter().filter_map(family_name).collect();
346 let untitled = claim.title.is_none()
349 && text
350 .as_ref()
351 .is_some_and(|t| t.untitled(&families, work.container_title.as_deref()));
352
353 let title = match (&claim.title, &work_title, &text) {
354 (Some(claimed), Some(actual), _) => FieldCheck::scored(title_similarity(claimed, actual)),
355 (None, Some(actual), Some(text)) if !untitled => {
356 FieldCheck::scored(text.title_coverage(actual))
357 }
358 _ => FieldCheck::UNKNOWN,
359 };
360
361 let authors = if families.is_empty() {
362 FieldCheck::UNKNOWN
363 } else if !claim.authors.is_empty() {
364 FieldCheck::scored(author_overlap(&claim.authors, &families))
365 } else if let Some(text) = &text {
366 match text.author_coverage(&families) {
369 0.0 if !claim.text.as_deref().is_some_and(authors::names_given) => FieldCheck::UNKNOWN,
370 coverage => FieldCheck::scored(coverage),
371 }
372 } else {
373 FieldCheck::UNKNOWN
374 };
375
376 let actual_years: Vec<i16> = work
377 .issued
378 .map(|d| d.year())
379 .into_iter()
380 .chain(work.other_years.iter().copied())
381 .collect();
382 let claimed_years: Vec<i16> = match (claim.year, &text) {
383 (Some(y), _) => vec![y],
384 (None, Some(text)) => text.years(),
385 _ => Vec::new(),
386 };
387 let year = match actual_years.is_empty() || claimed_years.is_empty() {
388 false => {
389 let delta = claimed_years
391 .iter()
392 .flat_map(|c| actual_years.iter().map(move |a| c.abs_diff(*a)))
393 .min()
394 .unwrap_or(u16::MAX);
395 FieldCheck::scored(0.5f64.powi(i32::from(delta.min(30))))
399 }
400 true => FieldCheck::UNKNOWN,
401 };
402
403 let container = match (&claim.container, &work.container_title, &text) {
404 (Some(claimed), Some(actual), _) => FieldCheck::exact(same_journal(claimed, actual)),
405 (None, Some(actual), Some(text)) if journal_in(&text.words, actual) => {
406 FieldCheck::exact(true)
407 }
408 _ => FieldCheck::UNKNOWN,
409 };
410 let located = claim.locator();
411 let same = |claimed: Option<&str>, actual: Option<&str>| match (claimed, actual) {
412 (Some(c), Some(a)) if !c.is_empty() && !a.is_empty() => {
413 FieldCheck::exact(c.trim().eq_ignore_ascii_case(a.trim()))
414 }
415 _ => FieldCheck::UNKNOWN,
416 };
417 let volume = same(located.volume.as_deref(), work.volume.as_deref());
418 let page = same(
419 located.page.as_deref(),
420 work.page.as_deref().map(first_page),
421 );
422
423 let located = volume.agreement == Agreement::Match && page.agreement == Agreement::Match;
425 let placed = container.agreement == Agreement::Match && volume.agreement == Agreement::Match;
430 let title = match placed {
431 true if claim.title.is_none() && title.agreement == Agreement::Mismatch => {
432 FieldCheck::UNKNOWN
433 }
434 _ => title,
435 };
436
437 let ids = id_agreement(&claim.ids, &record.ids);
438 let id_only = [title, authors, year].iter().all(|c| c.score.is_none());
440 let mut confidence = if id_only && ids == Agreement::Match {
441 1.0
442 } else {
443 combine(title, authors, year)
444 };
445 let corroborated = authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS)
451 || ids == Agreement::Match
452 || located;
453 if !id_only && !corroborated {
454 confidence = confidence.min(UNCORROBORATED_CAP);
455 }
456 let short_title_in_text = claim.title.is_none()
460 && text.is_some()
461 && work_title
462 .as_deref()
463 .is_some_and(|t| content_words(t).len() < SHORT_TITLE_WORDS);
464 if short_title_in_text && !corroborated && container.agreement != Agreement::Match {
465 confidence = confidence.min(NO_EVIDENCE_CAP);
466 }
467 let identified =
471 matches!(title.agreement, Agreement::Match | Agreement::Partial) || corroborated;
472 if !id_only && !identified {
473 confidence = confidence.min(NO_EVIDENCE_CAP);
474 }
475 let pinned = ids == Agreement::Match
478 || located
479 || (placed && authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS));
480 if untitled && !pinned {
481 confidence = confidence.min(NO_EVIDENCE_CAP);
482 }
483 if located
487 && container.agreement == Agreement::Match
488 && title.agreement != Agreement::Mismatch
489 && authors.agreement != Agreement::Mismatch
490 {
491 let floor = match year.agreement {
492 Agreement::Match | Agreement::Unknown => PLACED,
493 Agreement::Partial => PLACED_YEAR_OFF,
494 Agreement::Mismatch => 0.0,
495 };
496 confidence = confidence.max(floor);
497 }
498 if [volume, page]
501 .iter()
502 .any(|c| c.agreement == Agreement::Mismatch)
503 {
504 confidence = confidence.min(LOCATION_MISMATCH_CAP);
505 }
506 if container.agreement == Agreement::Mismatch {
509 confidence *= JOURNAL_MISMATCH_PENALTY;
510 }
511 if let Some(actual) = &work_title
514 && is_derivative(actual)
515 && !claim_mentions_derivative(claim)
516 {
517 confidence *= DERIVATIVE_PENALTY;
518 }
519
520 Assessment {
521 confidence,
522 title,
523 authors,
524 year,
525 container,
526 volume,
527 page,
528 ids,
529 }
530}
531
532fn combine(title: FieldCheck, authors: FieldCheck, year: FieldCheck) -> f64 {
533 let parts = [(title, W_TITLE), (authors, W_AUTHORS), (year, W_YEAR)];
534 let known: Vec<(f64, f64)> = parts
535 .iter()
536 .filter_map(|(check, w)| check.score.map(|s| (s, *w)))
537 .collect();
538 let weight: f64 = known.iter().map(|(_, w)| w).sum();
539 if weight == 0.0 {
540 return 0.0;
541 }
542 let mut confidence = known.iter().map(|(s, w)| s * w).sum::<f64>() / weight;
543 if year.agreement == Agreement::Mismatch {
544 confidence *= YEAR_MISMATCH_PENALTY;
545 }
546 if title.agreement == Agreement::Mismatch {
547 confidence *= TITLE_MISMATCH_PENALTY;
548 }
549 if authors.agreement == Agreement::Mismatch {
552 confidence *= AUTHORS_MISMATCH_PENALTY;
553 }
554 if known.len() < 2 {
555 confidence = confidence.min(SINGLE_FIELD_CAP);
556 }
557 confidence
558}
559
560pub fn id_agreement(claimed: &[WorkId], actual: &[WorkId]) -> Agreement {
563 if claimed.is_empty() {
564 return Agreement::Unknown;
565 }
566 if claimed
567 .iter()
568 .any(|c| actual.iter().any(|a| same_work_id(c, a)))
569 {
570 return Agreement::Match;
571 }
572 let shares_kind = claimed
573 .iter()
574 .any(|c| actual.iter().any(|a| a.kind() == c.kind()));
575 if shares_kind {
576 Agreement::Mismatch
577 } else {
578 Agreement::Unknown
579 }
580}
581
582pub fn same_work_id(a: &WorkId, b: &WorkId) -> bool {
584 match (a, b) {
585 (WorkId::Arxiv(x), WorkId::Arxiv(y)) => x.unversioned() == y.unversioned(),
586 _ => a == b,
587 }
588}
589
590pub fn find_ids(text: &str) -> Vec<WorkId> {
592 let mut found: Vec<WorkId> = Vec::new();
593 for mention in crate::id::scan(text) {
594 if !found.iter().any(|f| same_work_id(f, &mention.id)) {
595 found.push(mention.id);
596 }
597 }
598 found
599}
600
601#[cfg(test)]
602mod tests;