#![deny(clippy::arithmetic_side_effects)]
use crate::data::Tables;
use crate::round::python_round;
use crate::text::{allcap_differential, translate_emojis, words_and_emoticons, Token};
use crate::SentimentScores;
use std::cmp::Ordering;
use std::collections::{BTreeSet, HashMap};
#[allow(dead_code)]
pub(crate) const B_INCR: f64 = 0.293;
#[allow(dead_code)]
pub(crate) const B_DECR: f64 = -0.293;
pub(crate) const C_INCR: f64 = 0.733;
pub(crate) const N_SCALAR: f64 = -0.74;
pub(crate) const EP_STEP: f64 = 0.292;
pub(crate) const QM_STEP: f64 = 0.18;
pub(crate) const QM_CAP: f64 = 0.96;
pub(crate) const ALPHA: f64 = 15.0;
pub(crate) const BUT_BEFORE: f64 = 0.5;
pub(crate) const BUT_AFTER: f64 = 1.5;
pub(crate) const DAMP_2: f64 = 0.95;
pub(crate) const DAMP_3: f64 = 0.9;
pub(crate) const NEVER_SCALAR: f64 = 1.25;
pub(crate) const NEGATE: [&str; 59] = [
"aint",
"arent",
"cannot",
"cant",
"couldnt",
"darent",
"didnt",
"doesnt",
"ain't",
"aren't",
"can't",
"couldn't",
"daren't",
"didn't",
"doesn't",
"dont",
"hadnt",
"hasnt",
"havent",
"isnt",
"mightnt",
"mustnt",
"neither",
"don't",
"hadn't",
"hasn't",
"haven't",
"isn't",
"mightn't",
"mustn't",
"neednt",
"needn't",
"never",
"none",
"nope",
"nor",
"not",
"nothing",
"nowhere",
"oughtnt",
"shant",
"shouldnt",
"uhuh",
"wasnt",
"werent",
"oughtn't",
"shan't",
"shouldn't",
"uh-uh",
"wasn't",
"weren't",
"without",
"wont",
"wouldnt",
"won't",
"wouldn't",
"rarely",
"seldom",
"despite",
];
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Valence {
pub(crate) value: f64,
pub(crate) from_int: bool,
}
impl Valence {
const INT_ZERO: Valence = Valence {
value: 0.0,
from_int: true,
};
}
pub(crate) fn polarity_scores(tables: &Tables, text: &str) -> (SentimentScores, f64) {
let translated = translate_emojis(text, &tables.emojis);
let tokens = words_and_emoticons(&translated);
let is_cap_diff = allcap_differential(&tokens);
let mut sentiments: Vec<Valence> = Vec::with_capacity(tokens.len());
for (i, item) in tokens.iter().enumerate() {
if tables.boosters.contains_key(item.lower.as_str()) {
sentiments.push(Valence::INT_ZERO);
continue;
}
if (item.lower == "kind")
&& i.checked_add(1)
.and_then(|j| tokens.get(j))
.is_some_and(|next| next.lower == "of")
{
sentiments.push(Valence::INT_ZERO);
continue;
}
sentiment_valence(tables, is_cap_diff, &tokens, i, item, &mut sentiments);
}
but_check(&tokens, &mut sentiments);
score_valence(&sentiments, &translated)
}
fn sentiment_valence(
tables: &Tables,
is_cap_diff: bool,
tokens: &[Token],
i: usize,
item: &Token,
sentiments: &mut Vec<Valence>,
) {
let mut valence = Valence::INT_ZERO;
if let Some(&lex_valence) = tables.lexicon.get(item.lower.as_str()) {
let mut valence_value = lex_valence;
let prev1 = i.checked_sub(1).and_then(|j| tokens.get(j));
let prev2 = i.checked_sub(2).and_then(|j| tokens.get(j));
let prev3 = i.checked_sub(3).and_then(|j| tokens.get(j));
let next1 = i.checked_add(1).and_then(|j| tokens.get(j));
if (item.lower == "no")
&& next1.is_some_and(|t| tables.lexicon.contains_key(t.lower.as_str()))
{
valence_value = 0.0;
}
if (prev1.is_some_and(|t| t.lower == "no"))
|| (prev2.is_some_and(|t| t.lower == "no"))
|| ((prev3.is_some_and(|t| t.lower == "no"))
&& (prev1.is_some_and(|t| (t.lower == "or") || (t.lower == "nor"))))
{
valence_value = lex_valence * N_SCALAR;
}
if item.is_upper && is_cap_diff {
if valence_value > 0.0 {
valence_value += C_INCR;
} else {
valence_value -= C_INCR;
}
}
let mut valence_from_int = false;
if let Some(p1) = prev1 {
if !tables.lexicon.contains_key(p1.lower.as_str()) {
let s = scalar_inc_dec(p1, valence_value, is_cap_diff, tables);
valence_value += s;
valence_value = negation_check_distance_1(valence_value, p1);
}
}
if let (Some(p1), Some(p2)) = (prev1, prev2) {
if !tables.lexicon.contains_key(p2.lower.as_str()) {
let mut s = scalar_inc_dec(p2, valence_value, is_cap_diff, tables);
if s != 0.0 {
s *= DAMP_2;
}
valence_value += s;
valence_value = negation_check_distance_2(valence_value, p1, p2);
}
}
if let (Some(p1), Some(p2), Some(p3)) = (prev1, prev2, prev3) {
if !tables.lexicon.contains_key(p3.lower.as_str()) {
let mut s = scalar_inc_dec(p3, valence_value, is_cap_diff, tables);
if s != 0.0 {
s *= DAMP_3;
}
valence_value += s;
valence_value = negation_check_distance_3(valence_value, p1, p2, p3);
(valence_value, valence_from_int) =
special_idioms_check(valence_value, tokens, i, tables);
}
}
valence = least_check(valence_value, valence_from_int, prev1, prev2, tables);
}
sentiments.push(valence);
}
#[allow(clippy::search_is_some, clippy::manual_contains)]
fn negated(word_lower: &str) -> bool {
(NEGATE.iter().any(|neg| *neg == word_lower)) || (word_lower.find("n't").is_some())
}
fn scalar_inc_dec(word: &Token, valence: f64, is_cap_diff: bool, tables: &Tables) -> f64 {
let mut scalar = 0.0_f64;
if let Some(&increment) = tables.boosters.get(word.lower.as_str()) {
scalar = increment;
if valence < 0.0 {
scalar = -scalar;
}
if word.is_upper && is_cap_diff {
if valence > 0.0 {
scalar += C_INCR;
} else {
scalar -= C_INCR;
}
}
}
scalar
}
fn negation_check_distance_1(valence: f64, prev1: &Token) -> f64 {
if negated(&prev1.lower) {
valence * N_SCALAR
} else {
valence
}
}
fn negation_check_distance_2(valence: f64, prev1: &Token, prev2: &Token) -> f64 {
if (prev2.lower == "never") && ((prev1.lower == "so") || (prev1.lower == "this")) {
valence * NEVER_SCALAR
} else if (prev2.lower == "without") && (prev1.lower == "doubt") {
valence
} else if negated(&prev2.lower) {
valence * N_SCALAR
} else {
valence
}
}
fn negation_check_distance_3(valence: f64, prev1: &Token, prev2: &Token, prev3: &Token) -> f64 {
if ((prev3.lower == "never") && ((prev2.lower == "so") || (prev2.lower == "this")))
|| ((prev1.lower == "so") || (prev1.lower == "this"))
{
valence * NEVER_SCALAR
} else if (prev3.lower == "without") && ((prev2.lower == "doubt") || (prev1.lower == "doubt")) {
valence
} else if negated(&prev3.lower) {
valence * N_SCALAR
} else {
valence
}
}
fn least_check(
value: f64,
from_int: bool,
prev1: Option<&Token>,
prev2: Option<&Token>,
tables: &Tables,
) -> Valence {
let kept = Valence { value, from_int };
let damped = Valence {
value: value * N_SCALAR,
from_int: false,
};
match (prev1, prev2) {
(Some(p1), Some(p2)) => {
if (!tables.lexicon.contains_key(p1.lower.as_str())) && (p1.lower == "least") {
if (p2.lower != "at") && (p2.lower != "very") {
damped
} else {
kept
}
} else {
kept
}
}
(Some(p1), None) => {
if (!tables.lexicon.contains_key(p1.lower.as_str())) && (p1.lower == "least") {
damped
} else {
kept
}
}
(None, _) => kept,
}
}
fn joined_lower(tokens: &[Token], start: usize, len: usize) -> Option<String> {
let slice = tokens.get(start..start.saturating_add(len))?;
let mut joined = String::new();
for (k, token) in slice.iter().enumerate() {
if k > 0 {
joined.push(' ');
}
joined.push_str(&token.lower);
}
Some(joined)
}
fn special_idioms_check(
valence_in: f64,
tokens: &[Token],
i: usize,
tables: &Tables,
) -> (f64, bool) {
let mut valence = valence_in;
let mut from_int = false;
let onezero = i.checked_sub(1).and_then(|s| joined_lower(tokens, s, 2));
let twoonezero = i.checked_sub(2).and_then(|s| joined_lower(tokens, s, 3));
let twoone = i.checked_sub(2).and_then(|s| joined_lower(tokens, s, 2));
let threetwoone = i.checked_sub(3).and_then(|s| joined_lower(tokens, s, 3));
let threetwo = i.checked_sub(3).and_then(|s| joined_lower(tokens, s, 2));
for seq in [&onezero, &twoonezero, &twoone, &threetwoone, &threetwo] {
if let Some(&hit) = seq
.as_ref()
.and_then(|s| tables.special_cases.get(s.as_str()))
{
valence = hit.value;
from_int = hit.is_int;
break;
}
}
if let Some(&hit) = joined_lower(tokens, i, 2)
.as_ref()
.and_then(|s| tables.special_cases.get(s.as_str()))
{
valence = hit.value;
from_int = hit.is_int;
}
if let Some(&hit) = joined_lower(tokens, i, 3)
.as_ref()
.and_then(|s| tables.special_cases.get(s.as_str()))
{
valence = hit.value;
from_int = hit.is_int;
}
for n_gram in [&threetwoone, &threetwo, &twoone] {
if let Some(&increment) = n_gram
.as_ref()
.and_then(|s| tables.boosters.get(s.as_str()))
{
valence += increment;
from_int = false;
}
}
(valence, from_int)
}
fn but_check(tokens: &[Token], sentiments: &mut [Valence]) {
fn key(value: f64) -> u64 {
if value == 0.0 {
0.0_f64.to_bits()
} else {
value.to_bits()
}
}
if let Some(bi) = tokens.iter().position(|t| t.lower == "but") {
let mut first_equal: HashMap<u64, BTreeSet<usize>> = HashMap::new();
for (pos, sentiment) in sentiments.iter().enumerate() {
let _ = first_equal
.entry(key(sentiment.value))
.or_default()
.insert(pos);
}
for pos in 0..sentiments.len() {
let sentiment = sentiments.get(pos).copied().unwrap_or(Valence::INT_ZERO);
let si = if sentiment.value.is_nan() {
pos
} else {
first_equal
.get(&key(sentiment.value))
.and_then(|set| set.first().copied())
.unwrap_or(pos)
};
let weight = match si.cmp(&bi) {
Ordering::Less => Some(BUT_BEFORE),
Ordering::Greater => Some(BUT_AFTER),
Ordering::Equal => None,
};
if let (Some(weight), Some(slot)) = (weight, sentiments.get_mut(si)) {
let written = Valence {
value: sentiment.value * weight,
from_int: false,
};
let _ = first_equal
.get_mut(&key(slot.value))
.map(|set| set.remove(&si));
let _ = first_equal
.entry(key(written.value))
.or_default()
.insert(si);
*slot = written;
}
}
}
}
fn amplify_ep(text: &str) -> f64 {
let ep_count = text.chars().filter(|&c| c == '!').count().min(4);
(ep_count as f64) * EP_STEP
}
fn amplify_qm(text: &str) -> f64 {
let qm_count = text.chars().filter(|&c| c == '?').count();
let mut qm_amplifier = 0.0_f64;
if qm_count > 1 {
if qm_count <= 3 {
qm_amplifier = (qm_count as f64) * QM_STEP;
} else {
qm_amplifier = QM_CAP;
}
}
qm_amplifier
}
fn punctuation_emphasis(text: &str) -> f64 {
amplify_ep(text) + amplify_qm(text)
}
fn normalize(score: f64) -> f64 {
(score / ((score * score) + ALPHA).sqrt()).clamp(-1.0, 1.0)
}
fn sift_sentiment_scores(sentiments: &[Valence]) -> (f64, f64, f64) {
let mut pos_sum = 0.0_f64;
let mut neg_sum = 0.0_f64;
let mut neu_count = 0.0_f64;
for sentiment in sentiments {
let sentiment_score = sentiment.value;
if sentiment_score > 0.0 {
pos_sum += sentiment_score + 1.0;
}
if sentiment_score < 0.0 {
neg_sum += sentiment_score - 1.0;
}
if sentiment_score == 0.0 {
neu_count += 1.0;
}
}
(pos_sum, neg_sum, neu_count)
}
fn python_sum(values: &[Valence]) -> f64 {
let mut iter = values.iter();
let mut i_result = 0.0_f64;
let (mut f_result, mut c) = loop {
match iter.next() {
None => return i_result,
Some(v) if v.from_int => i_result += v.value,
Some(v) => break (i_result + v.value, 0.0_f64),
}
};
for v in iter {
let x = v.value;
let t = f_result + x;
if f_result.abs() >= x.abs() {
c += (f_result - t) + x;
} else {
c += (x - t) + f_result;
}
f_result = t;
}
if (c != 0.0) && c.is_finite() {
f_result += c;
}
f_result
}
fn score_valence(sentiments: &[Valence], text: &str) -> (SentimentScores, f64) {
let (compound_raw, neg, neu, pos);
if !sentiments.is_empty() {
let mut sum_s = python_sum(sentiments);
let punct_emph_amplifier = punctuation_emphasis(text);
match sum_s.total_cmp(&0.0) {
Ordering::Greater => sum_s += punct_emph_amplifier,
Ordering::Less => sum_s -= punct_emph_amplifier,
Ordering::Equal => {}
}
compound_raw = normalize(sum_s);
let (mut pos_sum, mut neg_sum, neu_count) = sift_sentiment_scores(sentiments);
if pos_sum > neg_sum.abs() {
pos_sum += punct_emph_amplifier;
} else if pos_sum < neg_sum.abs() {
neg_sum -= punct_emph_amplifier;
}
let total = pos_sum + neg_sum.abs() + neu_count;
pos = (pos_sum / total).abs();
neg = (neg_sum / total).abs();
neu = (neu_count / total).abs();
} else {
compound_raw = 0.0;
pos = 0.0;
neg = 0.0;
neu = 0.0;
}
let scores = SentimentScores {
neg: python_round(neg, 3),
neu: python_round(neu, 3),
pos: python_round(pos, 3),
compound: python_round(compound_raw, 4),
};
(scores, compound_raw)
}
#[cfg(test)]
#[allow(clippy::indexing_slicing)]
mod tests {
use super::*;
use crate::text::words_and_emoticons;
fn tables() -> Tables {
Tables::load()
}
#[test]
fn constants_pinned() {
assert_eq!(B_INCR, 0.293);
assert_eq!(B_DECR, -0.293);
assert_eq!(C_INCR, 0.733);
assert_eq!(N_SCALAR, -0.74);
assert_eq!(EP_STEP, 0.292);
assert_eq!(QM_STEP, 0.18);
assert_eq!(QM_CAP, 0.96);
assert_eq!(ALPHA, 15.0);
assert_eq!(BUT_BEFORE, 0.5);
assert_eq!(BUT_AFTER, 1.5);
assert_eq!(DAMP_2, 0.95);
assert_eq!(DAMP_3, 0.9);
assert_eq!(NEVER_SCALAR, 1.25);
assert_eq!(NEGATE.len(), 59);
let t = tables();
assert_eq!(t.boosters.get("so").copied(), Some(B_INCR));
assert_eq!(t.boosters.get("sort of").copied(), Some(B_DECR));
}
#[test]
fn negated_matches_the_reference_set() {
assert!(negated("not"));
assert!(negated("never"));
assert!(negated("without"));
assert!(negated("uh-uh"));
assert!(negated("wasn't"));
assert!(negated("shan't"));
assert!(negated("zzzn'tzzz"));
assert!(!negated("no"));
assert!(!negated("least"));
assert!(!negated("good"));
assert!(!negated(""));
}
#[test]
fn scalar_inc_dec_signs_and_caps() {
let t = tables();
let very = &words_and_emoticons("very")[0];
let very_caps = &words_and_emoticons("VERY")[0];
let plain = &words_and_emoticons("word")[0];
assert_eq!(scalar_inc_dec(very, 1.9, false, &t), 0.293);
assert_eq!(scalar_inc_dec(very, -1.9, false, &t), -0.293);
assert_eq!(scalar_inc_dec(plain, 1.9, false, &t), 0.0);
assert_eq!(scalar_inc_dec(very_caps, 1.9, true, &t), 0.293 + C_INCR);
assert_eq!(scalar_inc_dec(very_caps, -1.9, true, &t), -0.293 - C_INCR);
assert_eq!(scalar_inc_dec(very_caps, 1.9, false, &t), 0.293);
assert_eq!(scalar_inc_dec(very_caps, 0.0, true, &t), 0.293 - C_INCR);
}
#[test]
fn negation_check_distance_2_arms() {
let tok = |s: &str| words_and_emoticons(s).remove(0);
let (never, so, this, without, doubt, not, word) = (
tok("never"),
tok("so"),
tok("this"),
tok("without"),
tok("doubt"),
tok("not"),
tok("word"),
);
assert_eq!(negation_check_distance_2(2.0, &so, &never), 2.5);
assert_eq!(negation_check_distance_2(2.0, &this, &never), 2.5);
assert_eq!(
negation_check_distance_2(2.0, &word, &never),
2.0 * N_SCALAR
);
assert_eq!(negation_check_distance_2(2.0, &doubt, &without), 2.0);
assert_eq!(
negation_check_distance_2(2.0, &word, &without),
2.0 * N_SCALAR
);
assert_eq!(negation_check_distance_2(2.0, &word, ¬), 2.0 * N_SCALAR);
assert_eq!(negation_check_distance_2(2.0, &word, &word), 2.0);
}
#[test]
fn negation_check_distance_3_precedence() {
let tok = |s: &str| words_and_emoticons(s).remove(0);
let (never, so, this, without, doubt, not, word) = (
tok("never"),
tok("so"),
tok("this"),
tok("without"),
tok("doubt"),
tok("not"),
tok("word"),
);
assert_eq!(negation_check_distance_3(2.0, &word, &so, &never), 2.5);
assert_eq!(negation_check_distance_3(2.0, &word, &this, &never), 2.5);
assert_eq!(negation_check_distance_3(2.0, &so, &word, &word), 2.5);
assert_eq!(negation_check_distance_3(2.0, &this, &word, &word), 2.5);
assert_eq!(negation_check_distance_3(2.0, &word, &doubt, &without), 2.0);
assert_eq!(negation_check_distance_3(2.0, &doubt, &word, &without), 2.0);
assert_eq!(
negation_check_distance_3(2.0, &word, &word, ¬),
2.0 * N_SCALAR
);
assert_eq!(
negation_check_distance_3(2.0, &word, &word, &never),
2.0 * N_SCALAR
);
assert_eq!(negation_check_distance_3(2.0, &word, &word, &word), 2.0);
}
#[test]
fn least_check_arms() {
let t = tables();
let tok = |s: &str| words_and_emoticons(s).remove(0);
let (least, at, very, word, good) = (
tok("least"),
tok("at"),
tok("very"),
tok("word"),
tok("good"),
);
let value = |v: Valence| v.value;
let damped = least_check(1.9, false, Some(&least), Some(&word), &t);
assert_eq!(damped.value, 1.9 * N_SCALAR);
assert!(!damped.from_int);
assert_eq!(
value(least_check(1.9, false, Some(&least), Some(&at), &t)),
1.9
);
assert_eq!(
value(least_check(1.9, false, Some(&least), Some(&very), &t)),
1.9
);
assert_eq!(
value(least_check(1.9, false, Some(&word), Some(&at), &t)),
1.9
);
assert_eq!(
value(least_check(1.9, false, Some(&good), Some(&word), &t)),
1.9
);
assert_eq!(
value(least_check(1.9, false, Some(&least), None, &t)),
1.9 * N_SCALAR
);
assert_eq!(value(least_check(1.9, false, Some(&word), None, &t)), 1.9);
assert_eq!(value(least_check(1.9, false, None, None, &t)), 1.9);
assert!(least_check(3.0, true, None, None, &t).from_int);
assert!(least_check(3.0, true, Some(&least), Some(&at), &t).from_int);
assert!(!least_check(3.0, true, Some(&least), Some(&word), &t).from_int);
}
#[test]
fn joined_lower_windows() {
let toks = words_and_emoticons("The Quick brown");
assert_eq!(joined_lower(&toks, 0, 2).as_deref(), Some("the quick"));
assert_eq!(
joined_lower(&toks, 0, 3).as_deref(),
Some("the quick brown")
);
assert_eq!(joined_lower(&toks, 2, 1).as_deref(), Some("brown"));
assert_eq!(joined_lower(&toks, 2, 2), None);
assert_eq!(joined_lower(&toks, 3, 1), None);
assert_eq!(joined_lower(&toks, usize::MAX, 2), None);
}
#[test]
fn special_idioms_direct_windows() {
let t = tables();
let toks = words_and_emoticons("bad ass");
assert_eq!(special_idioms_check(-2.5, &toks, 0, &t), (1.5, false));
let toks = words_and_emoticons("the shit");
assert_eq!(special_idioms_check(-2.6, &toks, 1, &t), (3.0, true));
let toks = words_and_emoticons("kiss of death now");
assert_eq!(special_idioms_check(1.8, &toks, 0, &t), (-1.5, false));
let toks = words_and_emoticons("x sort of good");
assert_eq!(
special_idioms_check(1.9, &toks, 3, &t),
(1.9 + B_DECR, false)
);
let toks = words_and_emoticons("was to die for good");
assert_eq!(special_idioms_check(1.9, &toks, 4, &t), (3.0, true));
let toks = words_and_emoticons("plain words only here");
assert_eq!(special_idioms_check(1.1, &toks, 3, &t), (1.1, false));
}
#[test]
fn but_check_replicates_the_index_quirk() {
let run = |text: &str, values: Vec<f64>| {
let toks = words_and_emoticons(text);
let mut s: Vec<Valence> = values
.into_iter()
.map(|value| Valence {
value,
from_int: false,
})
.collect();
but_check(&toks, &mut s);
s.iter().map(|v| v.value).collect::<Vec<f64>>()
};
assert_eq!(
run("w x but y", vec![2.0, 1.0, 0.0, 1.0]),
vec![0.5, 0.5, 0.0, 1.0]
);
assert_eq!(run("w but y", vec![2.0, 0.0, 2.0]), vec![1.0, 0.0, 3.0]);
assert_eq!(
run("w x but y", vec![1.0, 1.0, 0.0, 1.0]),
vec![0.5, 0.5, 0.0, 1.5]
);
assert_eq!(run("but y z", vec![0.0, 0.5, 0.5]), vec![0.0, 0.75, 0.75]);
assert_eq!(run("w x y", vec![1.0, 2.0, 3.0]), vec![1.0, 2.0, 3.0]);
let toks = words_and_emoticons("but y");
let mut s = vec![
Valence::INT_ZERO,
Valence {
value: 3.0,
from_int: true,
},
];
but_check(&toks, &mut s);
assert!(s[0].from_int);
assert_eq!(s[1].value, 4.5);
assert!(!s[1].from_int);
}
#[test]
fn but_check_matches_the_linear_scan_reference() {
fn linear_scan(bi: usize, sentiments: &mut [Valence]) {
for pos in 0..sentiments.len() {
let sentiment = sentiments.get(pos).copied().unwrap_or(Valence::INT_ZERO);
let si = sentiments
.iter()
.position(|v| v.value == sentiment.value)
.unwrap_or(pos);
let weight = match si.cmp(&bi) {
Ordering::Less => Some(BUT_BEFORE),
Ordering::Greater => Some(BUT_AFTER),
Ordering::Equal => None,
};
if let (Some(weight), Some(slot)) = (weight, sentiments.get_mut(si)) {
*slot = Valence {
value: sentiment.value * weight,
from_int: false,
};
}
}
}
let palette = [
2.0, 1.0, -0.0, 0.0, 1.0, 3.0, -1.5, 2.0, 0.5, 1.5, -1.5, 4.5,
];
for n in 1..=palette.len() {
for shift in 0..palette.len() {
let values: Vec<Valence> = palette
.iter()
.cycle()
.skip(shift)
.take(n)
.map(|&value| Valence {
value,
from_int: false,
})
.collect();
for bi in 0..n {
let tokens: Vec<Token> = (0..n)
.map(|k| Token {
lower: String::from(if k == bi { "but" } else { "w" }),
is_upper: false,
})
.collect();
let mut expected = values.clone();
linear_scan(bi, &mut expected);
let mut actual = values.clone();
but_check(&tokens, &mut actual);
let bits =
|s: &[Valence]| s.iter().map(|v| v.value.to_bits()).collect::<Vec<u64>>();
assert_eq!(
bits(&actual),
bits(&expected),
"n={n} shift={shift} bi={bi}"
);
}
}
}
}
#[test]
fn but_check_nan_falls_back_to_its_own_slot() {
let toks = words_and_emoticons("x but y");
let mut s = floats(&[f64::NAN, 0.0, 2.0]);
but_check(&toks, &mut s);
assert!(s[0].value.is_nan());
assert_eq!(s[1].value.to_bits(), 0.0_f64.to_bits());
assert_eq!(s[2].value, 3.0);
}
#[test]
fn but_check_folds_zero_signs_like_python_eq() {
let toks = words_and_emoticons("w x but");
let mut s = floats(&[0.0, -0.0, 5.0]);
but_check(&toks, &mut s);
assert_eq!(s[0].value.to_bits(), (-0.0_f64).to_bits());
assert_eq!(s[1].value.to_bits(), (-0.0_f64).to_bits());
assert_eq!(s[2].value, 5.0);
}
#[test]
fn but_check_pathological_input_stays_fast() {
let n = 60_000_usize;
let mut tokens: Vec<Token> = Vec::with_capacity(n);
for _ in 1..n {
tokens.push(Token {
lower: String::from("w"),
is_upper: false,
});
}
tokens.push(Token {
lower: String::from("but"),
is_upper: false,
});
let mut s = floats(&(0..n).map(|i| i as f64 + 0.25).collect::<Vec<f64>>());
let started = std::time::Instant::now();
but_check(&tokens, &mut s);
let elapsed = started.elapsed();
for (i, v) in s.iter().enumerate() {
let original = i as f64 + 0.25;
let expected = if i.checked_add(1) == Some(n) {
original
} else {
original * BUT_BEFORE
};
assert_eq!(v.value, expected, "slot {i}");
}
assert!(
elapsed < std::time::Duration::from_secs(10),
"but_check took {elapsed:?} on the pathological input"
);
}
#[test]
fn punctuation_amplifiers_ladder() {
assert_eq!(amplify_ep(""), 0.0);
assert_eq!(amplify_ep("!"), EP_STEP);
assert_eq!(amplify_ep("!!"), 2.0 * EP_STEP);
assert_eq!(amplify_ep("!!!!"), 4.0 * EP_STEP);
assert_eq!(amplify_ep("!!!!!!"), 4.0 * EP_STEP);
assert_eq!(amplify_qm(""), 0.0);
assert_eq!(amplify_qm("?"), 0.0);
assert_eq!(amplify_qm("??"), 2.0 * QM_STEP);
assert_eq!(amplify_qm("???"), 3.0 * QM_STEP);
assert_eq!(amplify_qm("????"), QM_CAP);
assert_eq!(punctuation_emphasis("!?!?"), 2.0 * EP_STEP + 2.0 * QM_STEP);
}
#[test]
fn normalize_shape() {
assert_eq!(normalize(0.0), 0.0);
assert_eq!(normalize(1.0), 1.0 / 16.0_f64.sqrt());
assert_eq!(normalize(-1.0), -(1.0 / 16.0_f64.sqrt()));
assert_eq!(normalize(1.0e10), 1.0);
assert_eq!(normalize(-1.0e10), -1.0);
}
fn floats(values: &[f64]) -> Vec<Valence> {
values
.iter()
.map(|&value| Valence {
value,
from_int: false,
})
.collect()
}
#[test]
fn sift_masses_and_neutral_count() {
let (pos, neg, neu) = sift_sentiment_scores(&floats(&[1.9, -2.6, 0.0, 0.0, 0.5]));
assert_eq!(pos, (1.9 + 1.0) + (0.5 + 1.0));
assert_eq!(neg, -2.6 - 1.0);
assert_eq!(neu, 2.0);
assert_eq!(sift_sentiment_scores(&[]), (0.0, 0.0, 0.0));
}
#[test]
fn python_sum_is_neumaier_compensated() {
let sum = python_sum(&floats(&[1.1, 1.2, 0.0, 1.9]));
assert_eq!(sum.to_bits(), 4.2_f64.to_bits());
let fold = [1.1_f64, 1.2, 0.0, 1.9]
.iter()
.fold(0.0_f64, |acc, &v| acc + v);
assert!(fold < sum);
assert_eq!(python_sum(&[]), 0.0);
assert_eq!(python_sum(&floats(&[-0.0])).to_bits(), 0.0_f64.to_bits());
assert_eq!(
python_sum(&floats(&[-0.0, -0.0])).to_bits(),
0.0_f64.to_bits()
);
assert_eq!(
python_sum(&floats(&[1.9, -1.9])).to_bits(),
0.0_f64.to_bits()
);
}
#[test]
fn python_sum_mixes_int_and_float_paths_like_cpython() {
let iz = || Valence::INT_ZERO;
let ii = |value: f64| Valence {
value,
from_int: true,
};
let ff = |value: f64| Valence {
value,
from_int: false,
};
let mixed = [
iz(),
iz(),
iz(),
ii(3.0),
ii(3.0),
ii(3.0),
iz(),
ff(-2.6),
iz(),
ff(-1.6),
ff(-2.9),
ff(-3.0),
iz(),
iz(),
];
assert_eq!(python_sum(&mixed).to_bits(), 0xbff1_9999_9999_9998);
assert_eq!(python_sum(&[ii(3.0), iz(), ii(-2.0)]), 1.0);
assert_eq!(
python_sum(&[ff(0.1), ff(0.2), ii(3.0)]).to_bits(),
python_sum(&[ff(0.1), ff(0.2), ff(3.0)]).to_bits()
);
assert_eq!(python_sum(&[ff(1.0e100), ii(1.0), ff(-1.0e100)]), 1.0);
assert_eq!(
python_sum(&[ii(9.0), ff(-2.6), ff(-1.6)]).to_bits(),
0x4013_3333_3333_3334
);
assert_eq!(
python_sum(&[ff(9.0), ff(-2.6), ff(-1.6)]).to_bits(),
0x4013_3333_3333_3333
);
}
#[test]
fn score_valence_empty_is_all_zero() {
let (scores, raw) = score_valence(&[], "!!");
assert_eq!(raw, 0.0);
assert_eq!(scores.neg, 0.0);
assert_eq!(scores.neu, 0.0);
assert_eq!(scores.pos, 0.0);
assert_eq!(scores.compound, 0.0);
}
}