Skip to main content

runmat_runtime/builtins/strings/text_analytics/
sentiment.rs

1//! Sentiment scoring helpers for Text Analytics compatibility objects.
2
3use std::collections::{HashMap, HashSet};
4
5use once_cell::sync::Lazy;
6use runmat_builtins::{
7    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
8    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
9    ObjectInstance, ResolveContext, StringArray, Tensor, Type, Value,
10};
11use runmat_macros::runtime_builtin;
12
13use crate::builtins::strings::core::compat::scalar_text;
14use crate::builtins::strings::text_analytics::documents::{
15    document_token_type, documents_from_object, text_analytics_error, tokenized_document_language,
16    words_from_word_vector, TOKENIZED_DOCUMENT_CLASS,
17};
18use crate::builtins::table::{table_variables, TABLE_CLASS};
19use crate::{gather_if_needed_async, BuiltinResult};
20
21const BOOSTER_AMOUNT: f64 = 0.293;
22const CAPS_AMOUNT: f64 = 0.733;
23const NEGATION_SCALAR: f64 = -0.74;
24const NORMALIZATION_ALPHA: f64 = 15.0;
25
26const OUT_COMPOUND: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
27    name: "compoundScores",
28    ty: BuiltinParamType::NumericArray,
29    arity: BuiltinParamArity::Required,
30    default: None,
31    description: "Compound VADER-style sentiment score for each document.",
32}];
33
34const OUT_ALL: [BuiltinParamDescriptor; 4] = [
35    BuiltinParamDescriptor {
36        name: "compoundScores",
37        ty: BuiltinParamType::NumericArray,
38        arity: BuiltinParamArity::Required,
39        default: None,
40        description: "Compound VADER-style sentiment score for each document.",
41    },
42    BuiltinParamDescriptor {
43        name: "positiveScores",
44        ty: BuiltinParamType::NumericArray,
45        arity: BuiltinParamArity::Required,
46        default: None,
47        description: "Positive sentiment proportions.",
48    },
49    BuiltinParamDescriptor {
50        name: "negativeScores",
51        ty: BuiltinParamType::NumericArray,
52        arity: BuiltinParamArity::Required,
53        default: None,
54        description: "Negative sentiment proportions.",
55    },
56    BuiltinParamDescriptor {
57        name: "neutralScores",
58        ty: BuiltinParamType::NumericArray,
59        arity: BuiltinParamArity::Required,
60        default: None,
61        description: "Neutral token proportions.",
62    },
63];
64
65const IN_DOCUMENTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
66    name: "documents",
67    ty: BuiltinParamType::Any,
68    arity: BuiltinParamArity::Required,
69    default: None,
70    description: "tokenizedDocument object.",
71}];
72
73const IN_DOCUMENTS_REST: [BuiltinParamDescriptor; 2] = [
74    BuiltinParamDescriptor {
75        name: "documents",
76        ty: BuiltinParamType::Any,
77        arity: BuiltinParamArity::Required,
78        default: None,
79        description: "tokenizedDocument object.",
80    },
81    BuiltinParamDescriptor {
82        name: "NameValue",
83        ty: BuiltinParamType::Any,
84        arity: BuiltinParamArity::Variadic,
85        default: None,
86        description: "Name-value options: SentimentLexicon, Boosters, Dampeners, Negations.",
87    },
88];
89
90const ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
91    code: "RM.VADER_SENTIMENT_SCORES.INVALID_INPUT",
92    identifier: Some("RunMat:vaderSentimentScores:InvalidInput"),
93    when: "Inputs do not match supported vaderSentimentScores forms.",
94    message: "vaderSentimentScores: invalid input",
95};
96
97const ERRORS: [BuiltinErrorDescriptor; 1] = [ERROR_INVALID_INPUT];
98
99pub const VADER_SENTIMENT_SCORES_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
100    signatures: &[
101        BuiltinSignatureDescriptor {
102            label: "compoundScores = vaderSentimentScores(documents)",
103            inputs: &IN_DOCUMENTS,
104            outputs: &OUT_COMPOUND,
105        },
106        BuiltinSignatureDescriptor {
107            label: "compoundScores = vaderSentimentScores(documents,Name,Value)",
108            inputs: &IN_DOCUMENTS_REST,
109            outputs: &OUT_COMPOUND,
110        },
111        BuiltinSignatureDescriptor {
112            label:
113                "[compoundScores,positiveScores,negativeScores,neutralScores] = vaderSentimentScores(___)",
114            inputs: &IN_DOCUMENTS_REST,
115            outputs: &OUT_ALL,
116        },
117    ],
118    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
119    completion_policy: BuiltinCompletionPolicy::Public,
120    errors: &ERRORS,
121};
122
123fn any_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
124    Type::Unknown
125}
126
127#[runtime_builtin(
128    name = "vaderSentimentScores",
129    category = "strings/text_analytics",
130    summary = "Score tokenized documents using VADER-style sentiment rules.",
131    keywords = "vaderSentimentScores,VADER,sentiment,text analytics,tokenizedDocument",
132    accel = "sink",
133    type_resolver(any_type),
134    descriptor(
135        crate::builtins::strings::text_analytics::sentiment::VADER_SENTIMENT_SCORES_DESCRIPTOR
136    ),
137    builtin_path = "crate::builtins::strings::text_analytics::sentiment"
138)]
139async fn vader_sentiment_scores_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
140    let args = gather_args(args).await?;
141    let (documents_object, options) = parse_args(args)?;
142    if !tokenized_document_language(&documents_object)
143        .trim()
144        .eq_ignore_ascii_case("en")
145    {
146        return Err(sentiment_error(
147            "vaderSentimentScores: only English tokenizedDocument inputs are supported",
148        ));
149    }
150    let documents = documents_from_object(&documents_object, "vaderSentimentScores")?;
151    let scores = score_documents(&documents, &options)?;
152    scores.into_value()
153}
154
155async fn gather_args(args: Vec<Value>) -> BuiltinResult<Vec<Value>> {
156    let mut out = Vec::with_capacity(args.len());
157    for arg in args {
158        out.push(gather_if_needed_async(&arg).await.map_err(|err| {
159            sentiment_error(format!(
160                "vaderSentimentScores: failed to gather input: {err}"
161            ))
162        })?);
163    }
164    Ok(out)
165}
166
167fn parse_args(args: Vec<Value>) -> BuiltinResult<(ObjectInstance, SentimentOptions)> {
168    if args.is_empty() {
169        return Err(sentiment_error(
170            "vaderSentimentScores: expected tokenizedDocument input",
171        ));
172    }
173    if !(args.len() - 1).is_multiple_of(2) {
174        return Err(sentiment_error(
175            "vaderSentimentScores: name-value options must appear in pairs",
176        ));
177    }
178    let documents = match &args[0] {
179        Value::Object(object) if object.is_class(TOKENIZED_DOCUMENT_CLASS) => object.clone(),
180        Value::Object(object) => {
181            return Err(sentiment_error(format!(
182                "vaderSentimentScores: expected tokenizedDocument object, got {}",
183                object.class_name
184            )));
185        }
186        other => {
187            return Err(sentiment_error(format!(
188                "vaderSentimentScores: expected tokenizedDocument object, got {other:?}"
189            )));
190        }
191    };
192
193    let mut options = SentimentOptions::default();
194    let mut idx = 1usize;
195    while idx < args.len() {
196        let name = scalar_text(&args[idx], "vaderSentimentScores")
197            .map_err(|err| sentiment_error(err.to_string()))?
198            .to_ascii_lowercase();
199        match name.as_str() {
200            "sentimentlexicon" => {
201                options.lexicon = sentiment_lexicon_from_table(&args[idx + 1])?;
202            }
203            "boosters" => {
204                options.boosters = modifier_ngrams_from_value(&args[idx + 1], "Boosters")?;
205            }
206            "dampeners" => {
207                options.dampeners = modifier_ngrams_from_value(&args[idx + 1], "Dampeners")?;
208            }
209            "negations" => {
210                let negations: HashSet<String> =
211                    words_from_word_vector(&args[idx + 1], "vaderSentimentScores")?
212                        .into_iter()
213                        .map(|word| word.trim().to_ascii_lowercase())
214                        .filter(|word| !word.is_empty())
215                        .collect();
216                if negations.is_empty() {
217                    return Err(sentiment_error(
218                        "vaderSentimentScores: Negations must contain at least one word",
219                    ));
220                }
221                options.negations = negations;
222            }
223            other => {
224                return Err(sentiment_error(format!(
225                    "vaderSentimentScores: unsupported option '{other}'"
226                )));
227            }
228        }
229        idx += 2;
230    }
231    Ok((documents, options))
232}
233
234#[derive(Clone, Debug)]
235struct SentimentOptions {
236    lexicon: HashMap<String, f64>,
237    boosters: Vec<Vec<String>>,
238    dampeners: Vec<Vec<String>>,
239    negations: HashSet<String>,
240}
241
242impl Default for SentimentOptions {
243    fn default() -> Self {
244        Self {
245            lexicon: DEFAULT_LEXICON.clone(),
246            boosters: DEFAULT_BOOSTERS.iter().map(|item| phrase(item)).collect(),
247            dampeners: DEFAULT_DAMPENERS.iter().map(|item| phrase(item)).collect(),
248            negations: DEFAULT_NEGATIONS
249                .iter()
250                .map(|word| (*word).to_string())
251                .collect(),
252        }
253    }
254}
255
256#[derive(Clone, Debug)]
257struct SentimentOutputs {
258    compound: Vec<f64>,
259    positive: Vec<f64>,
260    negative: Vec<f64>,
261    neutral: Vec<f64>,
262}
263
264impl SentimentOutputs {
265    fn into_value(self) -> BuiltinResult<Value> {
266        let shape = vec![self.compound.len(), 1];
267        let compound = tensor_value(self.compound, shape.clone())?;
268        if let Some(out_count) = crate::output_count::current_output_count() {
269            if out_count == 0 {
270                return Ok(Value::OutputList(Vec::new()));
271            }
272            if out_count == 1 {
273                return Ok(Value::OutputList(vec![compound]));
274            }
275            return Ok(crate::output_count::output_list_with_padding(
276                out_count,
277                vec![
278                    compound,
279                    tensor_value(self.positive, shape.clone())?,
280                    tensor_value(self.negative, shape.clone())?,
281                    tensor_value(self.neutral, shape)?,
282                ],
283            ));
284        }
285        Ok(compound)
286    }
287}
288
289fn score_documents(
290    documents: &[Vec<String>],
291    options: &SentimentOptions,
292) -> BuiltinResult<SentimentOutputs> {
293    let mut compound = Vec::with_capacity(documents.len());
294    let mut positive = Vec::with_capacity(documents.len());
295    let mut negative = Vec::with_capacity(documents.len());
296    let mut neutral = Vec::with_capacity(documents.len());
297    for document in documents {
298        let score = score_document(document, options);
299        compound.push(score.compound);
300        positive.push(score.positive);
301        negative.push(score.negative);
302        neutral.push(score.neutral);
303    }
304    Ok(SentimentOutputs {
305        compound,
306        positive,
307        negative,
308        neutral,
309    })
310}
311
312#[derive(Clone, Copy, Debug)]
313struct DocumentScore {
314    compound: f64,
315    positive: f64,
316    negative: f64,
317    neutral: f64,
318}
319
320fn score_document(tokens: &[String], options: &SentimentOptions) -> DocumentScore {
321    let lower_tokens = tokens
322        .iter()
323        .map(|token| token.to_ascii_lowercase())
324        .collect::<Vec<_>>();
325    let has_caps_emphasis = tokens.iter().any(|token| is_all_caps_word(token))
326        && tokens.iter().any(|token| is_mixed_or_lower_word(token));
327
328    let mut valences = Vec::with_capacity(tokens.len());
329    let mut neutral_count = 0.0f64;
330    for (idx, token) in tokens.iter().enumerate() {
331        let Some(lexeme) = sentiment_lexeme(token, &lower_tokens[idx], options) else {
332            if is_neutral_counted_token(token) {
333                neutral_count += 1.0;
334            }
335            valences.push(0.0);
336            continue;
337        };
338        let mut valence = *options.lexicon.get(lexeme).unwrap_or(&0.0);
339        if valence != 0.0 {
340            valence = apply_modifiers(valence, idx, &lower_tokens, options);
341            if has_caps_emphasis && is_all_caps_word(token) {
342                valence += if valence >= 0.0 {
343                    CAPS_AMOUNT
344                } else {
345                    -CAPS_AMOUNT
346                };
347            }
348        } else {
349            neutral_count += 1.0;
350        }
351        valences.push(valence);
352    }
353
354    let mut sum = valences.iter().sum::<f64>();
355    sum += punctuation_emphasis(tokens, sum);
356    let compound = normalize_score(sum);
357    let positive_sum = valences.iter().filter(|value| **value > 0.0).sum::<f64>();
358    let negative_sum = valences
359        .iter()
360        .filter(|value| **value < 0.0)
361        .map(|value| value.abs())
362        .sum::<f64>();
363    let total = positive_sum + negative_sum + neutral_count;
364    let (positive, negative, neutral) = if total > 0.0 {
365        (
366            positive_sum / total,
367            negative_sum / total,
368            neutral_count / total,
369        )
370    } else {
371        (0.0, 0.0, 0.0)
372    };
373    DocumentScore {
374        compound,
375        positive,
376        negative,
377        neutral,
378    }
379}
380
381fn sentiment_lexeme<'a>(
382    token: &'a str,
383    lower_token: &'a str,
384    options: &'a SentimentOptions,
385) -> Option<&'a str> {
386    if options.lexicon.contains_key(lower_token) {
387        return Some(lower_token);
388    }
389    let stripped =
390        lower_token.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '\'' && ch != '-');
391    if stripped != lower_token && options.lexicon.contains_key(stripped) {
392        return Some(stripped);
393    }
394    if token.chars().count() == 1 {
395        return None;
396    }
397    None
398}
399
400fn apply_modifiers(
401    mut valence: f64,
402    idx: usize,
403    lower_tokens: &[String],
404    options: &SentimentOptions,
405) -> f64 {
406    for distance in 1..=3 {
407        if idx < distance {
408            continue;
409        }
410        let prev = &lower_tokens[idx - distance];
411        let decay = 0.95f64.powi((distance - 1) as i32);
412        if is_single_word_modifier(prev, &options.boosters) {
413            valence += signed_modifier(valence, BOOSTER_AMOUNT * decay);
414        }
415        if is_single_word_modifier(prev, &options.dampeners) {
416            valence -= signed_modifier(valence, BOOSTER_AMOUNT * decay);
417        }
418        if options.negations.contains(prev) || prev.ends_with("n't") {
419            valence *= NEGATION_SCALAR;
420        }
421    }
422    if let Some(amount) = phrase_modifier_ending_at(idx, lower_tokens, &options.boosters) {
423        valence += signed_modifier(valence, amount);
424    }
425    if let Some(amount) = phrase_modifier_ending_at(idx, lower_tokens, &options.dampeners) {
426        valence -= signed_modifier(valence, amount);
427    }
428    valence
429}
430
431fn signed_modifier(valence: f64, amount: f64) -> f64 {
432    if valence < 0.0 {
433        -amount
434    } else {
435        amount
436    }
437}
438
439fn phrase_modifier_ending_at(
440    idx: usize,
441    lower_tokens: &[String],
442    phrases: &[Vec<String>],
443) -> Option<f64> {
444    let mut best_len = 0usize;
445    for phrase in phrases {
446        if phrase.len() <= 1 || phrase.len() > idx || phrase.len() <= best_len {
447            continue;
448        }
449        let start = idx - phrase.len();
450        if lower_tokens[start..idx]
451            .iter()
452            .map(String::as_str)
453            .eq(phrase.iter().map(String::as_str))
454        {
455            best_len = phrase.len();
456        }
457    }
458    (best_len > 0).then_some(BOOSTER_AMOUNT * best_len as f64)
459}
460
461fn is_single_word_modifier(word: &str, phrases: &[Vec<String>]) -> bool {
462    phrases
463        .iter()
464        .any(|phrase| phrase.len() == 1 && phrase[0] == word)
465}
466
467fn punctuation_emphasis(tokens: &[String], sum: f64) -> f64 {
468    if sum == 0.0 {
469        return 0.0;
470    }
471    let exclamations = tokens
472        .iter()
473        .map(|token| token.chars().filter(|ch| *ch == '!').count())
474        .sum::<usize>()
475        .min(4) as f64;
476    let question_marks = tokens
477        .iter()
478        .map(|token| token.chars().filter(|ch| *ch == '?').count())
479        .sum::<usize>();
480    let mut emphasis = exclamations * 0.292;
481    emphasis += match question_marks {
482        0 | 1 => 0.0,
483        2 | 3 => question_marks as f64 * 0.18,
484        _ => 0.96,
485    };
486    if sum < 0.0 {
487        -emphasis
488    } else {
489        emphasis
490    }
491}
492
493fn normalize_score(sum: f64) -> f64 {
494    if sum == 0.0 {
495        0.0
496    } else {
497        sum / (sum.mul_add(sum, NORMALIZATION_ALPHA)).sqrt()
498    }
499}
500
501fn is_neutral_counted_token(token: &str) -> bool {
502    matches!(
503        document_token_type(token),
504        crate::builtins::strings::text_analytics::documents::DocumentTokenType::Letters
505            | crate::builtins::strings::text_analytics::documents::DocumentTokenType::Digits
506            | crate::builtins::strings::text_analytics::documents::DocumentTokenType::Hashtag
507            | crate::builtins::strings::text_analytics::documents::DocumentTokenType::AtMention
508            | crate::builtins::strings::text_analytics::documents::DocumentTokenType::Other
509    )
510}
511
512fn is_all_caps_word(token: &str) -> bool {
513    let mut saw_alpha = false;
514    let mut saw_lower = false;
515    for ch in token.chars().filter(|ch| ch.is_alphabetic()) {
516        saw_alpha = true;
517        if ch.is_lowercase() {
518            saw_lower = true;
519            break;
520        }
521    }
522    saw_alpha && !saw_lower
523}
524
525fn is_mixed_or_lower_word(token: &str) -> bool {
526    token
527        .chars()
528        .any(|ch| ch.is_alphabetic() && ch.is_lowercase())
529}
530
531fn sentiment_lexicon_from_table(value: &Value) -> BuiltinResult<HashMap<String, f64>> {
532    let object = match value {
533        Value::Object(object) if object.is_class(TABLE_CLASS) => object,
534        Value::Object(object) => {
535            return Err(sentiment_error(format!(
536                "vaderSentimentScores: SentimentLexicon must be a table, got {}",
537                object.class_name
538            )));
539        }
540        other => {
541            return Err(sentiment_error(format!(
542                "vaderSentimentScores: SentimentLexicon must be a table, got {other:?}"
543            )));
544        }
545    };
546    let variables = table_variables(object)
547        .map_err(|err| sentiment_error(format!("vaderSentimentScores: {err}")))?;
548    let token_column = variables.fields.get("Token").ok_or_else(|| {
549        sentiment_error("vaderSentimentScores: SentimentLexicon missing Token column")
550    })?;
551    let score_column = variables.fields.get("SentimentScore").ok_or_else(|| {
552        sentiment_error("vaderSentimentScores: SentimentLexicon missing SentimentScore column")
553    })?;
554    let tokens = words_from_word_vector(token_column, "vaderSentimentScores")?;
555    let scores = numeric_vector(score_column, "SentimentScore")?;
556    if tokens.len() != scores.len() {
557        return Err(sentiment_error(
558            "vaderSentimentScores: SentimentLexicon Token and SentimentScore columns must have equal height",
559        ));
560    }
561    let mut lexicon = HashMap::with_capacity(tokens.len());
562    for (token, score) in tokens.into_iter().zip(scores) {
563        let token = token.trim().to_string();
564        if token.is_empty() || token != token.to_ascii_lowercase() {
565            return Err(sentiment_error(
566                "vaderSentimentScores: SentimentLexicon tokens must be nonempty lowercase strings",
567            ));
568        }
569        if !score.is_finite() || !(-4.0..=4.0).contains(&score) {
570            return Err(sentiment_error(
571                "vaderSentimentScores: SentimentScore values must be finite scalars in [-4, 4]",
572            ));
573        }
574        lexicon.insert(token, score);
575    }
576    Ok(lexicon)
577}
578
579fn numeric_vector(value: &Value, column_name: &str) -> BuiltinResult<Vec<f64>> {
580    match value {
581        Value::Num(value) => Ok(vec![*value]),
582        Value::Int(value) => Ok(vec![int_value_to_f64(value)]),
583        Value::Tensor(tensor) => Ok(tensor.data.clone()),
584        Value::Cell(cell) => cell
585            .data
586            .iter()
587            .map(|item| numeric_scalar(item, column_name))
588            .collect(),
589        other => Err(sentiment_error(format!(
590            "vaderSentimentScores: {column_name} must be numeric, got {other:?}"
591        ))),
592    }
593}
594
595fn numeric_scalar(value: &Value, column_name: &str) -> BuiltinResult<f64> {
596    match value {
597        Value::Num(value) => Ok(*value),
598        Value::Int(value) => Ok(int_value_to_f64(value)),
599        Value::Tensor(tensor) if tensor.data.len() == 1 => Ok(tensor.data[0]),
600        other => Err(sentiment_error(format!(
601            "vaderSentimentScores: {column_name} entries must be numeric scalars, got {other:?}"
602        ))),
603    }
604}
605
606fn modifier_ngrams_from_value(value: &Value, option_name: &str) -> BuiltinResult<Vec<Vec<String>>> {
607    let mut phrases = match value {
608        Value::String(_) | Value::CharArray(_) | Value::Cell(_) => {
609            words_from_word_vector(value, "vaderSentimentScores")?
610                .into_iter()
611                .map(|word| vec![word])
612                .collect()
613        }
614        Value::StringArray(array) => phrases_from_string_array(array),
615        other => {
616            return Err(sentiment_error(format!(
617                "vaderSentimentScores: {option_name} must be a string array, character array, or cell array, got {other:?}"
618            )));
619        }
620    };
621    for phrase in &mut phrases {
622        for token in phrase.iter_mut() {
623            *token = token.trim().to_ascii_lowercase();
624        }
625        phrase.retain(|token| !token.is_empty());
626    }
627    phrases.retain(|phrase| !phrase.is_empty());
628    if phrases.is_empty() {
629        return Err(sentiment_error(format!(
630            "vaderSentimentScores: {option_name} must contain at least one word or n-gram"
631        )));
632    }
633    Ok(phrases)
634}
635
636fn phrases_from_string_array(array: &StringArray) -> Vec<Vec<String>> {
637    if array.data.is_empty() {
638        return Vec::new();
639    }
640    if array.rows <= 1 || array.cols <= 1 {
641        return array.data.iter().map(|word| vec![word.clone()]).collect();
642    }
643    let mut phrases = Vec::with_capacity(array.rows);
644    for row in 0..array.rows {
645        let mut phrase = Vec::new();
646        for col in 0..array.cols {
647            let idx = row + col * array.rows;
648            if let Some(token) = array.data.get(idx) {
649                phrase.push(token.clone());
650            }
651        }
652        phrases.push(phrase);
653    }
654    phrases
655}
656
657fn phrase(text: &str) -> Vec<String> {
658    text.split_whitespace()
659        .map(|word| word.to_ascii_lowercase())
660        .collect()
661}
662
663fn tensor_value(data: Vec<f64>, shape: Vec<usize>) -> BuiltinResult<Value> {
664    Tensor::new(data, shape)
665        .map(Value::Tensor)
666        .map_err(|err| sentiment_error(format!("vaderSentimentScores: {err}")))
667}
668
669fn sentiment_error(message: impl Into<String>) -> crate::RuntimeError {
670    text_analytics_error("vaderSentimentScores", message)
671}
672
673fn int_value_to_f64(value: &runmat_builtins::IntValue) -> f64 {
674    match value {
675        runmat_builtins::IntValue::I8(value) => *value as f64,
676        runmat_builtins::IntValue::I16(value) => *value as f64,
677        runmat_builtins::IntValue::I32(value) => *value as f64,
678        runmat_builtins::IntValue::I64(value) => *value as f64,
679        runmat_builtins::IntValue::U8(value) => *value as f64,
680        runmat_builtins::IntValue::U16(value) => *value as f64,
681        runmat_builtins::IntValue::U32(value) => *value as f64,
682        runmat_builtins::IntValue::U64(value) => *value as f64,
683    }
684}
685
686static DEFAULT_LEXICON: Lazy<HashMap<String, f64>> = Lazy::new(|| {
687    [
688        ("abandon", -1.9),
689        ("abandoned", -2.0),
690        ("abuse", -3.2),
691        ("accused", -1.6),
692        ("amazing", 2.8),
693        ("awesome", 3.1),
694        ("awful", -2.9),
695        ("bad", -2.5),
696        ("best", 3.2),
697        ("better", 2.0),
698        ("boring", -1.3),
699        ("creative", 2.0),
700        ("delicious", 2.7),
701        ("disappointing", -2.4),
702        ("efficiency", 1.8),
703        ("excellent", 3.0),
704        ("fail", -2.5),
705        ("failed", -2.4),
706        ("failure", -2.3),
707        ("fantastic", 3.2),
708        ("good", 1.9),
709        ("great", 3.1),
710        ("growth", 1.7),
711        ("happy", 2.7),
712        ("hate", -2.7),
713        ("horrible", -2.9),
714        ("improved", 2.1),
715        ("innovative", 2.2),
716        ("joy", 2.5),
717        ("like", 1.5),
718        ("love", 3.2),
719        ("misleading", -2.1),
720        ("negative", -1.4),
721        ("poor", -2.1),
722        ("positive", 1.8),
723        ("risk", -1.5),
724        ("sad", -2.1),
725        ("strong", 1.6),
726        ("strengthen", 2.1),
727        ("terrible", -3.0),
728        ("weak", -1.8),
729        ("worst", -3.1),
730        ("wrong", -2.1),
731    ]
732    .into_iter()
733    .map(|(token, score)| (token.to_string(), score))
734    .collect()
735});
736
737static DEFAULT_BOOSTERS: &[&str] = &[
738    "absolutely",
739    "amazingly",
740    "awfully",
741    "completely",
742    "considerably",
743    "decidedly",
744    "deeply",
745    "especially",
746    "exceptionally",
747    "extremely",
748    "highly",
749    "incredibly",
750    "majorly",
751    "really",
752    "remarkably",
753    "so",
754    "substantially",
755    "thoroughly",
756    "totally",
757    "utterly",
758    "very",
759];
760
761static DEFAULT_DAMPENERS: &[&str] = &[
762    "almost",
763    "barely",
764    "hardly",
765    "kind of",
766    "kinda",
767    "less",
768    "little",
769    "marginally",
770    "partly",
771    "scarcely",
772    "slightly",
773    "somewhat",
774    "sort of",
775];
776
777static DEFAULT_NEGATIONS: &[&str] = &[
778    "aint", "aren't", "cannot", "can't", "couldn't", "didn't", "doesn't", "don't", "hardly",
779    "isn't", "lack", "lacks", "neither", "never", "no", "none", "nor", "not", "nothing", "nowhere",
780    "rarely", "scarcely", "wasn't", "weren't", "without", "won't", "wouldn't",
781];
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786    use runmat_builtins::{CellArray, StructValue};
787
788    fn run(args: Vec<Value>) -> BuiltinResult<Value> {
789        futures::executor::block_on(vader_sentiment_scores_builtin(args))
790    }
791
792    fn documents(docs: Vec<Vec<&str>>) -> Value {
793        let values = docs
794            .iter()
795            .map(|doc| {
796                Value::StringArray(
797                    StringArray::new(
798                        doc.iter().map(|token| (*token).to_string()).collect(),
799                        vec![1, doc.len()],
800                    )
801                    .unwrap(),
802                )
803            })
804            .collect::<Vec<_>>();
805        let mut object = ObjectInstance::new(TOKENIZED_DOCUMENT_CLASS.to_string());
806        object.properties.insert(
807            "Documents".to_string(),
808            Value::Cell(CellArray::new(values, docs.len(), 1).unwrap()),
809        );
810        object
811            .properties
812            .insert("NumDocuments".to_string(), Value::Num(docs.len() as f64));
813        object.properties.insert(
814            "Shape".to_string(),
815            Value::Tensor(Tensor::new(vec![docs.len() as f64, 1.0], vec![1, 2]).unwrap()),
816        );
817        object
818            .properties
819            .insert("Language".to_string(), Value::String("en".to_string()));
820        Value::Object(object)
821    }
822
823    fn compound(value: Value) -> Vec<f64> {
824        let Value::Tensor(tensor) = value else {
825            panic!("expected tensor");
826        };
827        tensor.data
828    }
829
830    fn outputs(value: Value) -> Vec<Value> {
831        let Value::OutputList(values) = value else {
832            panic!("expected output list");
833        };
834        values
835    }
836
837    #[test]
838    fn vader_scores_positive_negative_and_neutral_documents() {
839        let out = compound(
840            run(vec![documents(vec![
841                vec!["The", "book", "was", "VERY", "good", "!", "!", "!", "!"],
842                vec!["The", "book", "was", "not", "very", "good", "."],
843                vec!["plain", "reference", "text"],
844            ])])
845            .unwrap(),
846        );
847        assert_eq!(out.len(), 3);
848        assert!(out[0] > 0.60, "positive score was {}", out[0]);
849        assert!(out[1] < -0.20, "negated score was {}", out[1]);
850        assert_eq!(out[2], 0.0);
851    }
852
853    #[test]
854    fn vader_returns_four_requested_score_vectors() {
855        let _guard = crate::output_count::push_output_count(Some(4));
856        let values = outputs(run(vec![documents(vec![vec!["good"], vec!["bad"]])]).unwrap());
857        assert_eq!(values.len(), 4);
858        let compound_scores = compound(values[0].clone());
859        let positive_scores = compound(values[1].clone());
860        let negative_scores = compound(values[2].clone());
861        let neutral_scores = compound(values[3].clone());
862        assert!(compound_scores[0] > 0.0);
863        assert!(compound_scores[1] < 0.0);
864        assert!(positive_scores[0] > negative_scores[0]);
865        assert!(negative_scores[1] > positive_scores[1]);
866        assert_eq!(neutral_scores, vec![0.0, 0.0]);
867    }
868
869    #[test]
870    fn vader_accepts_custom_sentiment_lexicon_table() {
871        let table = table_value(
872            vec!["Token".to_string(), "SentimentScore".to_string()],
873            vec![
874                Value::StringArray(
875                    StringArray::new(vec!["innovative".into(), "risk".into()], vec![2, 1]).unwrap(),
876                ),
877                Value::Tensor(Tensor::new(vec![4.0, -3.0], vec![2, 1]).unwrap()),
878            ],
879        );
880        let out = compound(
881            run(vec![
882                documents(vec![vec!["innovative"], vec!["risk"]]),
883                Value::String("SentimentLexicon".to_string()),
884                table,
885            ])
886            .unwrap(),
887        );
888        assert!(out[0] > 0.70);
889        assert!(out[1] < -0.60);
890    }
891
892    #[test]
893    fn vader_accepts_custom_boosters_dampeners_and_negations() {
894        let out = compound(
895            run(vec![
896                documents(vec![
897                    vec!["exceptionally", "good"],
898                    vec!["kinda", "good"],
899                    vec!["never", "good"],
900                ]),
901                Value::String("Boosters".to_string()),
902                Value::StringArray(
903                    StringArray::new(vec!["exceptionally".into()], vec![1, 1]).unwrap(),
904                ),
905                Value::String("Dampeners".to_string()),
906                Value::StringArray(StringArray::new(vec!["kinda".into()], vec![1, 1]).unwrap()),
907                Value::String("Negations".to_string()),
908                Value::StringArray(StringArray::new(vec!["never".into()], vec![1, 1]).unwrap()),
909            ])
910            .unwrap(),
911        );
912        assert!(out[0] > out[1]);
913        assert!(out[2] < 0.0);
914    }
915
916    #[test]
917    fn vader_accepts_modifier_ngram_rows() {
918        let out = compound(
919            run(vec![
920                documents(vec![vec!["kind", "of", "good"], vec!["good"]]),
921                Value::String("Dampeners".to_string()),
922                Value::StringArray(
923                    StringArray::new(
924                        vec!["kind".into(), "unused".into(), "of".into(), "".into()],
925                        vec![2, 2],
926                    )
927                    .unwrap(),
928                ),
929            ])
930            .unwrap(),
931        );
932        assert!(out[0] < out[1]);
933    }
934
935    #[test]
936    fn vader_rejects_invalid_custom_lexicon_scores() {
937        let table = table_value(
938            vec!["Token".to_string(), "SentimentScore".to_string()],
939            vec![
940                Value::StringArray(StringArray::new(vec!["strong".into()], vec![1, 1]).unwrap()),
941                Value::Tensor(Tensor::new(vec![5.0], vec![1, 1]).unwrap()),
942            ],
943        );
944        let err = run(vec![
945            documents(vec![vec!["strong"]]),
946            Value::String("SentimentLexicon".to_string()),
947            table,
948        ])
949        .unwrap_err();
950        assert!(err.to_string().contains("[-4, 4]"));
951    }
952
953    #[test]
954    fn vader_rejects_empty_custom_negations() {
955        let err = run(vec![
956            documents(vec![vec!["good"]]),
957            Value::String("Negations".to_string()),
958            Value::StringArray(StringArray::new(Vec::new(), vec![0, 1]).unwrap()),
959        ])
960        .unwrap_err();
961        assert!(err.to_string().contains("Negations"));
962    }
963
964    fn table_value(names: Vec<String>, columns: Vec<Value>) -> Value {
965        let mut variables = StructValue::new();
966        for (name, column) in names.into_iter().zip(columns) {
967            variables.insert(name, column);
968        }
969        let mut object = ObjectInstance::new(TABLE_CLASS.to_string());
970        object
971            .properties
972            .insert("__table_variables".to_string(), Value::Struct(variables));
973        Value::Object(object)
974    }
975}