1use 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}