mod authors;
mod derivative;
mod text;
use serde::{Deserialize, Serialize};
use crate::id::WorkId;
use crate::provider::Place;
use crate::publication::{self, first_page, journal_in, same_journal};
use crate::record::SourceRecord;
use authors::{author_overlap, family_name};
pub use derivative::{
WorkKind, claim_mentions_derivative, derivative_kind, derivative_work_kind, is_derivative,
};
use text::{Normalised, content_words};
pub use text::{normalise, search_terms, shared_title, title_similarity, unmarked};
const W_TITLE: f64 = 0.5;
const W_AUTHORS: f64 = 0.25;
const W_YEAR: f64 = 0.25;
const YEAR_MISMATCH_PENALTY: f64 = 0.5;
const TITLE_MISMATCH_PENALTY: f64 = 0.5;
const AUTHORS_MISMATCH_PENALTY: f64 = 0.5;
const SINGLE_FIELD_CAP: f64 = 0.7;
const UNCORROBORATED_CAP: f64 = 0.8;
const CORROBORATING_AUTHORS: f64 = 0.75;
const PLACED: f64 = 0.9;
const PLACED_YEAR_OFF: f64 = 0.8;
const NO_EVIDENCE_CAP: f64 = 0.5;
const SHORT_TITLE_WORDS: usize = 3;
const DERIVATIVE_PENALTY: f64 = 0.5;
const LOCATION_MISMATCH_CAP: f64 = 0.8;
const JOURNAL_MISMATCH_PENALTY: f64 = 0.9;
pub const VERIFIED: f64 = 0.85;
pub const PLAUSIBLE: f64 = 0.6;
const AUTHORS_COMPARED: usize = 3;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Claim {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ids: Vec<WorkId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub authors: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub year: Option<i16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub volume: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub alternatives: Vec<Alternative>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Alternative {
pub claimed: WorkId,
pub alternative: WorkId,
}
impl Alternative {
pub fn new(claimed: WorkId, alternative: WorkId) -> Self {
Alternative {
claimed,
alternative,
}
}
}
impl Claim {
pub fn from_text(text: impl Into<String>) -> Self {
let text = text.into();
Claim {
ids: find_ids(&text),
text: Some(text),
..Claim::default()
}
}
pub fn from_input(input: &str) -> Self {
let input = input.trim();
let whole = || match crate::id::scan(input).as_slice() {
[m] if m.span == (0..input.len()) => Some(m.id.clone()),
_ => None,
};
match input.parse::<WorkId>().ok().or_else(whole) {
Some(id) => Claim {
ids: vec![id],
..Claim::default()
},
None => Claim::from_text(input),
}
}
pub fn place(&self) -> Option<Place> {
let (volume, page) = match (&self.volume, &self.page) {
(Some(v), Some(p)) => (Some(v.clone()), Some(first_page(p).to_owned())),
_ => {
let found = publication::locator(self.text.as_deref()?);
(found.volume, found.page)
}
};
Some(Place::new(volume?, page?))
}
pub fn cited_years(&self) -> Vec<i16> {
match (self.year, &self.text) {
(Some(year), _) => vec![year],
(None, Some(text)) => Normalised::new(text).years(),
_ => Vec::new(),
}
}
pub fn is_empty(&self) -> bool {
let blank = |s: &Option<String>| s.as_deref().is_none_or(|s| s.trim().is_empty());
blank(&self.text)
&& self.ids.is_empty()
&& blank(&self.title)
&& self.authors.iter().all(|a| a.trim().is_empty())
}
pub fn reference(&self) -> Option<String> {
if let Some(text) = &self.text {
return Some(text.clone());
}
let location = match (&self.volume, &self.page) {
(Some(v), Some(p)) => Some(format!("{v}:{p}")),
(Some(v), None) => Some(v.clone()),
(None, p) => p.clone(),
};
let when = [self.year.map(|y| y.to_string()), location]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(";");
let parts: Vec<String> = [
(!self.authors.is_empty()).then(|| self.authors.join(", ")),
self.title.clone(),
self.container.clone(),
(!when.is_empty()).then_some(when),
]
.into_iter()
.flatten()
.collect();
(!parts.is_empty()).then(|| parts.join(". "))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Agreement {
Match,
Partial,
Mismatch,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct FieldCheck {
pub agreement: Agreement,
#[serde(skip_serializing_if = "Option::is_none")]
pub score: Option<f64>,
}
impl FieldCheck {
const UNKNOWN: FieldCheck = FieldCheck {
agreement: Agreement::Unknown,
score: None,
};
fn exact(agrees: bool) -> Self {
FieldCheck::scored(if agrees { 1.0 } else { 0.0 })
}
fn scored(score: f64) -> Self {
let agreement = match score {
s if s >= 0.9 => Agreement::Match,
s if s >= 0.5 => Agreement::Partial,
_ => Agreement::Mismatch,
};
FieldCheck {
agreement,
score: Some(score),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Assessment {
pub confidence: f64,
pub title: FieldCheck,
pub authors: FieldCheck,
pub year: FieldCheck,
pub container: FieldCheck,
pub volume: FieldCheck,
pub page: FieldCheck,
pub ids: Agreement,
}
fn is_url(title: &str) -> bool {
let t = title.trim();
let starts = |p: &str| t.get(..p.len()).is_some_and(|h| h.eq_ignore_ascii_case(p));
starts("http://") || starts("https://") || starts("www.") || t.contains("://")
}
pub fn assess(claim: &Claim, record: &SourceRecord) -> Assessment {
let work = &record.work;
let text = claim.text.as_deref().map(Normalised::new);
let work_title = work.title.clone().filter(|t| !is_url(t));
let families: Vec<String> = work.authors.iter().filter_map(family_name).collect();
let untitled = claim.title.is_none()
&& text
.as_ref()
.is_some_and(|t| t.untitled(&families, work.container_title.as_deref()));
let title = match (&claim.title, &work_title, &text) {
(Some(claimed), Some(actual), _) => FieldCheck::scored(title_similarity(claimed, actual)),
(None, Some(actual), Some(text)) if !untitled => {
FieldCheck::scored(text.title_coverage(actual))
}
_ => FieldCheck::UNKNOWN,
};
let authors = if families.is_empty() {
FieldCheck::UNKNOWN
} else if !claim.authors.is_empty() {
FieldCheck::scored(author_overlap(&claim.authors, &families))
} else if let Some(text) = &text {
match text.author_coverage(&families) {
0.0 if !claim.text.as_deref().is_some_and(authors::names_given) => FieldCheck::UNKNOWN,
coverage => FieldCheck::scored(coverage),
}
} else {
FieldCheck::UNKNOWN
};
let actual_years: Vec<i16> = work
.issued
.map(|d| d.year())
.into_iter()
.chain(work.other_years.iter().copied())
.collect();
let claimed_years: Vec<i16> = match (claim.year, &text) {
(Some(y), _) => vec![y],
(None, Some(text)) => text.years(),
_ => Vec::new(),
};
let year = match actual_years.is_empty() || claimed_years.is_empty() {
false => {
let delta = claimed_years
.iter()
.flat_map(|c| actual_years.iter().map(move |a| (c - a).unsigned_abs()))
.min()
.unwrap_or(u16::MAX);
FieldCheck::scored(0.5f64.powi(i32::from(delta.min(30))))
}
true => FieldCheck::UNKNOWN,
};
let container = match (&claim.container, &work.container_title, &text) {
(Some(claimed), Some(actual), _) => FieldCheck::exact(same_journal(claimed, actual)),
(None, Some(actual), Some(text)) if journal_in(&text.words, actual) => {
FieldCheck::exact(true)
}
_ => FieldCheck::UNKNOWN,
};
let located = match &claim.text {
Some(text) if claim.volume.is_none() && claim.page.is_none() => publication::locator(text),
_ => publication::Locator {
volume: claim.volume.clone(),
page: claim.page.as_deref().map(|p| first_page(p).to_owned()),
},
};
let same = |claimed: Option<&str>, actual: Option<&str>| match (claimed, actual) {
(Some(c), Some(a)) if !c.is_empty() && !a.is_empty() => {
FieldCheck::exact(c.trim().eq_ignore_ascii_case(a.trim()))
}
_ => FieldCheck::UNKNOWN,
};
let volume = same(located.volume.as_deref(), work.volume.as_deref());
let page = same(
located.page.as_deref(),
work.page.as_deref().map(first_page),
);
let located = volume.agreement == Agreement::Match && page.agreement == Agreement::Match;
let placed = container.agreement == Agreement::Match && volume.agreement == Agreement::Match;
let title = match placed {
true if claim.title.is_none() && title.agreement == Agreement::Mismatch => {
FieldCheck::UNKNOWN
}
_ => title,
};
let ids = id_agreement(&claim.ids, &record.ids);
let id_only = [title, authors, year].iter().all(|c| c.score.is_none());
let mut confidence = if id_only && ids == Agreement::Match {
1.0
} else {
combine(title, authors, year)
};
let corroborated = authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS)
|| ids == Agreement::Match
|| located;
if !id_only && !corroborated {
confidence = confidence.min(UNCORROBORATED_CAP);
}
let short_title_in_text = claim.title.is_none()
&& text.is_some()
&& work_title
.as_deref()
.is_some_and(|t| content_words(t).len() < SHORT_TITLE_WORDS);
if short_title_in_text && !corroborated && container.agreement != Agreement::Match {
confidence = confidence.min(NO_EVIDENCE_CAP);
}
let identified =
matches!(title.agreement, Agreement::Match | Agreement::Partial) || corroborated;
if !id_only && !identified {
confidence = confidence.min(NO_EVIDENCE_CAP);
}
let pinned = ids == Agreement::Match
|| located
|| (placed && authors.score.is_some_and(|s| s >= CORROBORATING_AUTHORS));
if untitled && !pinned {
confidence = confidence.min(NO_EVIDENCE_CAP);
}
if located
&& container.agreement == Agreement::Match
&& title.agreement != Agreement::Mismatch
&& authors.agreement != Agreement::Mismatch
{
let floor = match year.agreement {
Agreement::Match | Agreement::Unknown => PLACED,
Agreement::Partial => PLACED_YEAR_OFF,
Agreement::Mismatch => 0.0,
};
confidence = confidence.max(floor);
}
if [volume, page]
.iter()
.any(|c| c.agreement == Agreement::Mismatch)
{
confidence = confidence.min(LOCATION_MISMATCH_CAP);
}
if container.agreement == Agreement::Mismatch {
confidence *= JOURNAL_MISMATCH_PENALTY;
}
if let Some(actual) = &work_title
&& is_derivative(actual)
&& !claim_mentions_derivative(claim)
{
confidence *= DERIVATIVE_PENALTY;
}
Assessment {
confidence,
title,
authors,
year,
container,
volume,
page,
ids,
}
}
fn combine(title: FieldCheck, authors: FieldCheck, year: FieldCheck) -> f64 {
let parts = [(title, W_TITLE), (authors, W_AUTHORS), (year, W_YEAR)];
let known: Vec<(f64, f64)> = parts
.iter()
.filter_map(|(check, w)| check.score.map(|s| (s, *w)))
.collect();
let weight: f64 = known.iter().map(|(_, w)| w).sum();
if weight == 0.0 {
return 0.0;
}
let mut confidence = known.iter().map(|(s, w)| s * w).sum::<f64>() / weight;
if year.agreement == Agreement::Mismatch {
confidence *= YEAR_MISMATCH_PENALTY;
}
if title.agreement == Agreement::Mismatch {
confidence *= TITLE_MISMATCH_PENALTY;
}
if authors.agreement == Agreement::Mismatch {
confidence *= AUTHORS_MISMATCH_PENALTY;
}
if known.len() < 2 {
confidence = confidence.min(SINGLE_FIELD_CAP);
}
confidence
}
pub fn id_agreement(claimed: &[WorkId], actual: &[WorkId]) -> Agreement {
if claimed.is_empty() {
return Agreement::Unknown;
}
if claimed
.iter()
.any(|c| actual.iter().any(|a| same_work_id(c, a)))
{
return Agreement::Match;
}
let shares_kind = claimed
.iter()
.any(|c| actual.iter().any(|a| a.kind() == c.kind()));
if shares_kind {
Agreement::Mismatch
} else {
Agreement::Unknown
}
}
pub fn same_work_id(a: &WorkId, b: &WorkId) -> bool {
match (a, b) {
(WorkId::Arxiv(x), WorkId::Arxiv(y)) => x.unversioned() == y.unversioned(),
_ => a == b,
}
}
pub fn find_ids(text: &str) -> Vec<WorkId> {
let mut found: Vec<WorkId> = Vec::new();
for mention in crate::id::scan(text) {
if !found.iter().any(|f| same_work_id(f, &mention.id)) {
found.push(mention.id);
}
}
found
}
#[cfg(test)]
mod tests;