Skip to main content

ipfrs_semantic/
sentiment_analyzer.rs

1//! Lexicon-based sentiment analysis engine with aspect-level sentiment detection.
2//!
3//! This module provides `SentimentAnalyzer` which performs document-level and
4//! aspect-level sentiment scoring using a customisable lexicon. Negation handling,
5//! intensifier/diminisher scaling, and batch-processing utilities are included.
6//!
7//! # Example
8//!
9//! ```rust
10//! use ipfrs_semantic::sentiment_analyzer::{SentimentAnalyzer, SentimentConfig, SentimentPolarity};
11//!
12//! let config = SentimentConfig::default();
13//! let analyzer = SentimentAnalyzer::new(config);
14//! let result = analyzer.analyze("doc1".to_string(), "The service is absolutely fantastic!");
15//! assert_eq!(result.overall.polarity(), SentimentPolarity::Positive);
16//! ```
17
18use std::collections::HashMap;
19
20// ---------------------------------------------------------------------------
21// Public types
22// ---------------------------------------------------------------------------
23
24/// Overall sentiment polarity of a piece of text.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum SentimentPolarity {
27    /// Compound score > 0.05.
28    Positive,
29    /// Compound score < -0.05.
30    Negative,
31    /// Both positive and negative raw scores > 0.1 (ambivalent text).
32    Mixed,
33    /// Compound score in [-0.05, 0.05] and not Mixed.
34    Neutral,
35}
36
37/// Raw sentiment scores for a piece of text, normalised to [0, 1] for the
38/// positive/negative/neutral axes. `compound` is in [-1, 1].
39#[derive(Debug, Clone, PartialEq)]
40pub struct SentimentScore {
41    /// Proportion of positive sentiment signals.
42    pub positive: f64,
43    /// Proportion of negative sentiment signals.
44    pub negative: f64,
45    /// Proportion of neutral (non-sentiment) content.
46    pub neutral: f64,
47    /// Aggregate polarity in [-1, 1].
48    pub compound: f64,
49}
50
51impl SentimentScore {
52    /// Create a zero-valued score (used as an identity element for aggregation).
53    pub fn zero() -> Self {
54        Self {
55            positive: 0.0,
56            negative: 0.0,
57            neutral: 1.0,
58            compound: 0.0,
59        }
60    }
61
62    /// Determine the overall polarity from the component scores.
63    ///
64    /// Mixed takes priority: when both `positive` and `negative` exceed 0.1 the
65    /// text is classified as `Mixed` regardless of the compound score.
66    pub fn polarity(&self) -> SentimentPolarity {
67        if self.positive > 0.1 && self.negative > 0.1 {
68            SentimentPolarity::Mixed
69        } else if self.compound > 0.05 {
70            SentimentPolarity::Positive
71        } else if self.compound < -0.05 {
72            SentimentPolarity::Negative
73        } else {
74            SentimentPolarity::Neutral
75        }
76    }
77}
78
79/// Sentiment score for a particular aspect (topic) within a document.
80#[derive(Debug, Clone)]
81pub struct AspectSentiment {
82    /// The aspect keyword that was matched.
83    pub aspect: String,
84    /// Sentiment score computed within the context window around this aspect.
85    pub sentiment: SentimentScore,
86    /// Words near the aspect that contributed to the sentiment score.
87    pub mentions: Vec<String>,
88}
89
90/// Full sentiment analysis result for one document.
91#[derive(Debug, Clone)]
92pub struct SentimentResult {
93    /// User-supplied identifier for the analysed text.
94    pub text_id: String,
95    /// Document-level sentiment.
96    pub overall: SentimentScore,
97    /// Per-aspect breakdown (one entry per occurrence of each aspect keyword).
98    pub aspects: Vec<AspectSentiment>,
99    /// Total number of tokens in the document.
100    pub word_count: usize,
101    /// Number of tokens that matched a lexicon entry (sentiment words).
102    pub sentiment_word_count: usize,
103}
104
105/// A single entry in the sentiment lexicon.
106#[derive(Debug, Clone)]
107pub struct LexiconEntry {
108    /// The canonical (lowercase) form of the word.
109    pub word: String,
110    /// Positive sentiment contribution [0, ∞).
111    pub positive_score: f64,
112    /// Negative sentiment contribution [0, ∞).
113    pub negative_score: f64,
114    /// Multiplier applied to sentiment scores of *nearby* words.
115    ///
116    /// - `1.0` — neutral (ordinary word)
117    /// - `> 1.0` — intensifier (e.g. "very", "extremely")
118    /// - `< 1.0` — diminisher (e.g. "slightly", "barely")
119    pub intensifier: f64,
120}
121
122impl LexiconEntry {
123    /// Convenience constructor for a purely positive word.
124    pub fn positive(word: impl Into<String>, score: f64) -> Self {
125        Self {
126            word: word.into(),
127            positive_score: score,
128            negative_score: 0.0,
129            intensifier: 1.0,
130        }
131    }
132
133    /// Convenience constructor for a purely negative word.
134    pub fn negative(word: impl Into<String>, score: f64) -> Self {
135        Self {
136            word: word.into(),
137            positive_score: 0.0,
138            negative_score: score,
139            intensifier: 1.0,
140        }
141    }
142
143    /// Convenience constructor for an intensifier or diminisher (no sentiment of its own).
144    pub fn modifier(word: impl Into<String>, intensifier: f64) -> Self {
145        Self {
146            word: word.into(),
147            positive_score: 0.0,
148            negative_score: 0.0,
149            intensifier,
150        }
151    }
152}
153
154/// Configuration for `SentimentAnalyzer`.
155#[derive(Debug, Clone)]
156pub struct SentimentConfig {
157    /// Number of tokens either side of an aspect keyword to consider when
158    /// computing aspect-level sentiment.
159    pub window_size: usize,
160    /// Number of tokens *before* a sentiment word to look for negators.
161    pub negation_window: usize,
162    /// Domain-specific aspect keywords to track.
163    pub aspect_keywords: Vec<String>,
164}
165
166impl Default for SentimentConfig {
167    fn default() -> Self {
168        Self {
169            window_size: 5,
170            negation_window: 3,
171            aspect_keywords: vec![
172                "quality".to_string(),
173                "price".to_string(),
174                "service".to_string(),
175                "speed".to_string(),
176                "reliability".to_string(),
177                "performance".to_string(),
178            ],
179        }
180    }
181}
182
183/// Aggregate statistics over a collection of `SentimentResult`s.
184#[derive(Debug, Clone)]
185pub struct SentimentAnalyzerStats {
186    /// Total number of documents analysed.
187    pub total_analyzed: usize,
188    /// Documents classified as Positive.
189    pub positive_count: usize,
190    /// Documents classified as Negative.
191    pub negative_count: usize,
192    /// Documents classified as Neutral.
193    pub neutral_count: usize,
194    /// Documents classified as Mixed.
195    pub mixed_count: usize,
196    /// Mean compound score across all documents.
197    pub avg_compound: f64,
198}
199
200// ---------------------------------------------------------------------------
201// Internal helpers
202// ---------------------------------------------------------------------------
203
204/// Negation words that flip the polarity of sentiment within their window.
205const NEGATIONS: &[&str] = &[
206    "not", "never", "no", "isn't", "wasn't", "aren't", "weren't", "doesn't", "didn't", "don't",
207    "nor", "neither", "without", "lacks", "lack",
208];
209
210/// Split `text` into lowercase tokens, keeping apostrophes so that contractions
211/// such as "isn't" remain intact.
212fn tokenize(text: &str) -> Vec<String> {
213    text.split(|c: char| !c.is_alphanumeric() && c != '\'')
214        .filter(|s| !s.is_empty())
215        .map(|s| s.to_lowercase())
216        .collect()
217}
218
219/// Check whether any token in `window` is a negation word.
220fn contains_negation(window: &[String]) -> bool {
221    window.iter().any(|w| NEGATIONS.contains(&w.as_str()))
222}
223
224/// Find the maximum intensifier multiplier in a slice of tokens, given the lexicon.
225/// Returns 1.0 if no intensifier or diminisher is present.
226fn find_intensity_multiplier(window: &[String], lexicon: &HashMap<String, LexiconEntry>) -> f64 {
227    let mut multiplier = 1.0_f64;
228    for token in window {
229        if let Some(entry) = lexicon.get(token) {
230            // Only apply if this token is a modifier (no raw sentiment of its own)
231            if entry.positive_score == 0.0
232                && entry.negative_score == 0.0
233                && entry.intensifier != 1.0
234            {
235                // Compose multipliers: use the one that moves furthest from 1.0
236                if (entry.intensifier - 1.0).abs() > (multiplier - 1.0).abs() {
237                    multiplier = entry.intensifier;
238                }
239            }
240        }
241    }
242    multiplier
243}
244
245// ---------------------------------------------------------------------------
246// SentimentAnalyzer
247// ---------------------------------------------------------------------------
248
249/// Lexicon-based sentiment analysis engine with aspect-level detection.
250///
251/// The analyser operates entirely in-process; no external model or network call
252/// is required. Accuracy is limited by the quality of the built-in (or
253/// user-supplied) lexicon — for high-stakes applications consider augmenting
254/// the lexicon via `with_lexicon_entry`.
255pub struct SentimentAnalyzer {
256    /// Runtime configuration.
257    pub config: SentimentConfig,
258    /// Word-to-entry lookup table.
259    pub lexicon: HashMap<String, LexiconEntry>,
260}
261
262impl SentimentAnalyzer {
263    /// Build a new analyser seeded with the built-in lexicon.
264    pub fn new(config: SentimentConfig) -> Self {
265        let mut analyzer = Self {
266            config,
267            lexicon: HashMap::new(),
268        };
269        analyzer.populate_builtin_lexicon();
270        analyzer
271    }
272
273    /// Add or replace a single lexicon entry (builder pattern).
274    pub fn with_lexicon_entry(mut self, entry: LexiconEntry) -> Self {
275        self.lexicon.insert(entry.word.clone(), entry);
276        self
277    }
278
279    // ------------------------------------------------------------------
280    // Core analysis
281    // ------------------------------------------------------------------
282
283    /// Analyse a single document and return a `SentimentResult`.
284    pub fn analyze(&self, text_id: String, text: &str) -> SentimentResult {
285        let tokens = tokenize(text);
286        let word_count = tokens.len();
287
288        // --- document-level accumulation ---
289        let (raw_pos, raw_neg, sentiment_word_count) = self.accumulate_scores(&tokens);
290
291        let overall = self.build_score(raw_pos, raw_neg, word_count);
292
293        // --- aspect detection ---
294        let aspect_keywords: Vec<String> = self
295            .config
296            .aspect_keywords
297            .iter()
298            .map(|k| k.to_lowercase())
299            .collect();
300
301        let mut aspects = Vec::new();
302        for (idx, token) in tokens.iter().enumerate() {
303            if aspect_keywords.contains(token) {
304                let start = idx.saturating_sub(self.config.window_size);
305                let end = (idx + self.config.window_size + 1).min(tokens.len());
306                let window: Vec<String> = tokens[start..end].to_vec();
307
308                let (a_pos, a_neg, _) = self.accumulate_scores(&window);
309                let a_score = self.build_score(a_pos, a_neg, window.len());
310
311                // Collect the tokens that had actual sentiment signal as "mentions"
312                let mentions: Vec<String> = window
313                    .iter()
314                    .filter(|t| {
315                        self.lexicon
316                            .get(*t)
317                            .is_some_and(|e| e.positive_score > 0.0 || e.negative_score > 0.0)
318                    })
319                    .cloned()
320                    .collect();
321
322                aspects.push(AspectSentiment {
323                    aspect: token.clone(),
324                    sentiment: a_score,
325                    mentions,
326                });
327            }
328        }
329
330        SentimentResult {
331            text_id,
332            overall,
333            aspects,
334            word_count,
335            sentiment_word_count,
336        }
337    }
338
339    /// Analyse a batch of `(text_id, text)` pairs.
340    pub fn batch_analyze(&self, texts: &[(String, String)]) -> Vec<SentimentResult> {
341        texts
342            .iter()
343            .map(|(id, text)| self.analyze(id.clone(), text))
344            .collect()
345    }
346
347    // ------------------------------------------------------------------
348    // Aggregation / ranking helpers
349    // ------------------------------------------------------------------
350
351    /// Return the top `n` results ordered by compound score (most positive first).
352    pub fn top_positive<'a>(
353        &self,
354        results: &'a [SentimentResult],
355        n: usize,
356    ) -> Vec<&'a SentimentResult> {
357        let mut sorted: Vec<&SentimentResult> = results.iter().collect();
358        sorted.sort_by(|a, b| {
359            b.overall
360                .compound
361                .partial_cmp(&a.overall.compound)
362                .unwrap_or(std::cmp::Ordering::Equal)
363        });
364        sorted.truncate(n);
365        sorted
366    }
367
368    /// Return the `n` most negative results (lowest compound score first).
369    pub fn top_negative<'a>(
370        &self,
371        results: &'a [SentimentResult],
372        n: usize,
373    ) -> Vec<&'a SentimentResult> {
374        let mut sorted: Vec<&SentimentResult> = results.iter().collect();
375        sorted.sort_by(|a, b| {
376            a.overall
377                .compound
378                .partial_cmp(&b.overall.compound)
379                .unwrap_or(std::cmp::Ordering::Equal)
380        });
381        sorted.truncate(n);
382        sorted
383    }
384
385    /// Compute the mean `SentimentScore` across a slice of results.
386    ///
387    /// Returns `SentimentScore::zero()` for an empty slice.
388    pub fn aggregate_sentiment(&self, results: &[SentimentResult]) -> SentimentScore {
389        if results.is_empty() {
390            return SentimentScore::zero();
391        }
392        let n = results.len() as f64;
393        let pos = results.iter().map(|r| r.overall.positive).sum::<f64>() / n;
394        let neg = results.iter().map(|r| r.overall.negative).sum::<f64>() / n;
395        let neu = results.iter().map(|r| r.overall.neutral).sum::<f64>() / n;
396        let cmp = results.iter().map(|r| r.overall.compound).sum::<f64>() / n;
397        SentimentScore {
398            positive: pos,
399            negative: neg,
400            neutral: neu,
401            compound: cmp,
402        }
403    }
404
405    /// Compute aggregate statistics for a collection of results.
406    pub fn stats(&self, results: &[SentimentResult]) -> SentimentAnalyzerStats {
407        let total_analyzed = results.len();
408        let mut positive_count = 0usize;
409        let mut negative_count = 0usize;
410        let mut neutral_count = 0usize;
411        let mut mixed_count = 0usize;
412        let mut compound_sum = 0.0_f64;
413
414        for r in results {
415            compound_sum += r.overall.compound;
416            match r.overall.polarity() {
417                SentimentPolarity::Positive => positive_count += 1,
418                SentimentPolarity::Negative => negative_count += 1,
419                SentimentPolarity::Neutral => neutral_count += 1,
420                SentimentPolarity::Mixed => mixed_count += 1,
421            }
422        }
423
424        let avg_compound = if total_analyzed > 0 {
425            compound_sum / total_analyzed as f64
426        } else {
427            0.0
428        };
429
430        SentimentAnalyzerStats {
431            total_analyzed,
432            positive_count,
433            negative_count,
434            neutral_count,
435            mixed_count,
436            avg_compound,
437        }
438    }
439
440    // ------------------------------------------------------------------
441    // Internal helpers
442    // ------------------------------------------------------------------
443
444    /// Walk through all tokens and accumulate raw positive/negative scores,
445    /// applying negation and intensity scaling. Returns `(raw_pos, raw_neg,
446    /// sentiment_word_count)`.
447    fn accumulate_scores(&self, tokens: &[String]) -> (f64, f64, usize) {
448        let mut raw_pos = 0.0_f64;
449        let mut raw_neg = 0.0_f64;
450        let mut sentiment_word_count = 0usize;
451
452        for (i, token) in tokens.iter().enumerate() {
453            let entry = match self.lexicon.get(token) {
454                Some(e) => e,
455                None => continue,
456            };
457
458            // Skip pure modifiers (intensifiers / negations) — they are
459            // accounted for through context windows of *other* tokens.
460            if entry.positive_score == 0.0 && entry.negative_score == 0.0 {
461                continue;
462            }
463
464            sentiment_word_count += 1;
465
466            // Context window *before* this token for negation / intensity.
467            let ctx_start = i.saturating_sub(self.config.negation_window);
468            let preceding: Vec<String> = tokens[ctx_start..i].to_vec();
469
470            let negated = contains_negation(&preceding);
471            let intensity = find_intensity_multiplier(&preceding, &self.lexicon);
472
473            let mut pos = entry.positive_score * intensity;
474            let mut neg = entry.negative_score * intensity;
475
476            if negated {
477                // Flip: positive becomes negative and vice-versa.
478                std::mem::swap(&mut pos, &mut neg);
479            }
480
481            raw_pos += pos;
482            raw_neg += neg;
483        }
484
485        (raw_pos, raw_neg, sentiment_word_count)
486    }
487
488    /// Convert raw accumulated scores into a normalised `SentimentScore`.
489    fn build_score(&self, raw_pos: f64, raw_neg: f64, word_count: usize) -> SentimentScore {
490        let wc = word_count.max(1) as f64;
491
492        // Normalise by word count so scores are comparable across document lengths.
493        let pos = (raw_pos / wc).min(1.0_f64);
494        let neg = (raw_neg / wc).min(1.0_f64);
495        let neu = (1.0_f64 - pos - neg).max(0.0_f64);
496
497        let denom = (pos + neg + neu + 0.001_f64).max(0.001_f64);
498        let compound = (pos - neg) / denom;
499
500        SentimentScore {
501            positive: pos,
502            negative: neg,
503            neutral: neu,
504            compound,
505        }
506    }
507
508    /// Populate the built-in lexicon covering common positive/negative words,
509    /// intensifiers, diminishers, and negation words.
510    fn populate_builtin_lexicon(&mut self) {
511        // ---- Positive words ----
512        let positive_words = [
513            ("good", 0.7),
514            ("great", 0.85),
515            ("excellent", 0.95),
516            ("amazing", 0.90),
517            ("wonderful", 0.90),
518            ("fantastic", 0.95),
519            ("love", 0.85),
520            ("best", 0.90),
521            ("perfect", 1.00),
522            ("outstanding", 0.95),
523            ("brilliant", 0.90),
524            ("superb", 0.90),
525            ("incredible", 0.90),
526            ("awesome", 0.85),
527            ("positive", 0.65),
528            ("helpful", 0.70),
529            ("fast", 0.60),
530            ("reliable", 0.70),
531            ("efficient", 0.70),
532            ("clear", 0.55),
533            ("smooth", 0.60),
534            ("easy", 0.60),
535            ("simple", 0.55),
536            ("pleasant", 0.65),
537            ("satisfied", 0.70),
538            ("happy", 0.80),
539            ("delighted", 0.85),
540            ("impressed", 0.75),
541            ("accurate", 0.65),
542            ("responsive", 0.65),
543            ("innovative", 0.70),
544            ("intuitive", 0.65),
545        ];
546
547        for (word, score) in &positive_words {
548            self.lexicon
549                .insert(word.to_string(), LexiconEntry::positive(*word, *score));
550        }
551
552        // ---- Negative words ----
553        let negative_words = [
554            ("bad", 0.70),
555            ("terrible", 0.90),
556            ("awful", 0.90),
557            ("horrible", 0.95),
558            ("hate", 0.85),
559            ("worst", 0.95),
560            ("poor", 0.65),
561            ("broken", 0.80),
562            ("slow", 0.60),
563            ("difficult", 0.55),
564            ("frustrating", 0.80),
565            ("disappointing", 0.75),
566            ("unreliable", 0.75),
567            ("complex", 0.50),
568            ("confusing", 0.65),
569            ("annoying", 0.70),
570            ("useless", 0.85),
571            ("failed", 0.80),
572            ("error", 0.65),
573            ("problem", 0.60),
574            ("issue", 0.50),
575            ("bug", 0.65),
576            ("crash", 0.85),
577            ("delay", 0.60),
578            ("expensive", 0.60),
579            ("lacking", 0.55),
580            ("outdated", 0.55),
581            ("clunky", 0.65),
582        ];
583
584        for (word, score) in &negative_words {
585            self.lexicon
586                .insert(word.to_string(), LexiconEntry::negative(*word, *score));
587        }
588
589        // ---- Intensifiers ----
590        let intensifiers = [
591            ("very", 1.5),
592            ("extremely", 1.8),
593            ("incredibly", 1.7),
594            ("absolutely", 1.8),
595            ("totally", 1.6),
596            ("highly", 1.5),
597            ("really", 1.4),
598            ("deeply", 1.5),
599            ("utterly", 1.7),
600            ("truly", 1.4),
601        ];
602
603        for (word, mult) in &intensifiers {
604            self.lexicon
605                .insert(word.to_string(), LexiconEntry::modifier(*word, *mult));
606        }
607
608        // ---- Diminishers ----
609        let diminishers = [
610            ("slightly", 0.5),
611            ("somewhat", 0.6),
612            ("barely", 0.3),
613            ("hardly", 0.3),
614            ("rarely", 0.4),
615            ("mildly", 0.5),
616            ("partially", 0.6),
617            ("almost", 0.7),
618        ];
619
620        for (word, mult) in &diminishers {
621            self.lexicon
622                .insert(word.to_string(), LexiconEntry::modifier(*word, *mult));
623        }
624
625        // ---- Negations (stored as pure modifiers with intensifier = 1.0;
626        //      negation logic is handled separately via NEGATIONS constant) ----
627        let negations = [
628            "not", "never", "no", "isn't", "wasn't", "aren't", "weren't", "doesn't", "didn't",
629            "don't", "nor", "neither", "without",
630        ];
631        for word in &negations {
632            self.lexicon
633                .entry(word.to_string())
634                .or_insert_with(|| LexiconEntry::modifier(*word, 1.0));
635        }
636    }
637}
638
639// ---------------------------------------------------------------------------
640// Tests
641// ---------------------------------------------------------------------------
642
643#[cfg(test)]
644mod tests {
645    use super::{
646        tokenize, AspectSentiment, LexiconEntry, SentimentAnalyzer, SentimentConfig,
647        SentimentPolarity, SentimentResult, SentimentScore,
648    };
649
650    // ------ helpers -------------------------------------------------------
651
652    fn default_analyzer() -> SentimentAnalyzer {
653        SentimentAnalyzer::new(SentimentConfig::default())
654    }
655
656    // ------ SentimentScore unit tests ------------------------------------
657
658    #[test]
659    fn test_sentiment_score_zero_is_neutral() {
660        let s = SentimentScore::zero();
661        assert_eq!(s.polarity(), SentimentPolarity::Neutral);
662    }
663
664    #[test]
665    fn test_polarity_positive_compound() {
666        let s = SentimentScore {
667            positive: 0.8,
668            negative: 0.0,
669            neutral: 0.2,
670            compound: 0.8,
671        };
672        assert_eq!(s.polarity(), SentimentPolarity::Positive);
673    }
674
675    #[test]
676    fn test_polarity_negative_compound() {
677        let s = SentimentScore {
678            positive: 0.0,
679            negative: 0.8,
680            neutral: 0.2,
681            compound: -0.8,
682        };
683        assert_eq!(s.polarity(), SentimentPolarity::Negative);
684    }
685
686    #[test]
687    fn test_polarity_mixed_both_significant() {
688        let s = SentimentScore {
689            positive: 0.4,
690            negative: 0.3,
691            neutral: 0.3,
692            compound: 0.1,
693        };
694        // Both pos and neg > 0.1 → Mixed
695        assert_eq!(s.polarity(), SentimentPolarity::Mixed);
696    }
697
698    #[test]
699    fn test_polarity_neutral_small_compound() {
700        let s = SentimentScore {
701            positive: 0.02,
702            negative: 0.01,
703            neutral: 0.97,
704            compound: 0.02,
705        };
706        assert_eq!(s.polarity(), SentimentPolarity::Neutral);
707    }
708
709    #[test]
710    fn test_compound_range_positive() {
711        let s = SentimentScore {
712            positive: 0.9,
713            negative: 0.0,
714            neutral: 0.1,
715            compound: 0.9,
716        };
717        assert!(s.compound > 0.05, "should be positive");
718    }
719
720    #[test]
721    fn test_compound_range_negative() {
722        let s = SentimentScore {
723            positive: 0.0,
724            negative: 0.9,
725            neutral: 0.1,
726            compound: -0.9,
727        };
728        assert!(s.compound < -0.05, "should be negative");
729    }
730
731    // ------ tokenize unit tests ------------------------------------------
732
733    #[test]
734    fn test_tokenize_basic() {
735        let tokens = tokenize("Hello, World!");
736        assert_eq!(tokens, vec!["hello", "world"]);
737    }
738
739    #[test]
740    fn test_tokenize_contractions_preserved() {
741        let tokens = tokenize("it isn't broken");
742        assert!(tokens.contains(&"isn't".to_string()));
743    }
744
745    #[test]
746    fn test_tokenize_empty_string() {
747        assert!(tokenize("").is_empty());
748    }
749
750    #[test]
751    fn test_tokenize_punctuation_only() {
752        assert!(tokenize("... --- !!!").is_empty());
753    }
754
755    #[test]
756    fn test_tokenize_lowercases() {
757        let tokens = tokenize("GOOD BAD");
758        assert_eq!(tokens, vec!["good", "bad"]);
759    }
760
761    // ------ LexiconEntry constructors ------------------------------------
762
763    #[test]
764    fn test_lexicon_entry_positive() {
765        let e = LexiconEntry::positive("good", 0.7);
766        assert_eq!(e.word, "good");
767        assert!(e.positive_score > 0.0);
768        assert_eq!(e.negative_score, 0.0);
769        assert_eq!(e.intensifier, 1.0);
770    }
771
772    #[test]
773    fn test_lexicon_entry_negative() {
774        let e = LexiconEntry::negative("bad", 0.7);
775        assert_eq!(e.positive_score, 0.0);
776        assert!(e.negative_score > 0.0);
777    }
778
779    #[test]
780    fn test_lexicon_entry_modifier_intensifier() {
781        let e = LexiconEntry::modifier("very", 1.5);
782        assert_eq!(e.positive_score, 0.0);
783        assert_eq!(e.negative_score, 0.0);
784        assert_eq!(e.intensifier, 1.5);
785    }
786
787    #[test]
788    fn test_lexicon_entry_modifier_diminisher() {
789        let e = LexiconEntry::modifier("slightly", 0.5);
790        assert_eq!(e.intensifier, 0.5);
791    }
792
793    // ------ SentimentAnalyzer::new & lexicon coverage --------------------
794
795    #[test]
796    fn test_new_has_positive_words() {
797        let a = default_analyzer();
798        assert!(a.lexicon.contains_key("good"));
799        assert!(a.lexicon.contains_key("excellent"));
800    }
801
802    #[test]
803    fn test_new_has_negative_words() {
804        let a = default_analyzer();
805        assert!(a.lexicon.contains_key("bad"));
806        assert!(a.lexicon.contains_key("terrible"));
807    }
808
809    #[test]
810    fn test_new_has_intensifiers() {
811        let a = default_analyzer();
812        let e = a.lexicon.get("very").expect("very must exist");
813        assert!(e.intensifier > 1.0);
814    }
815
816    #[test]
817    fn test_new_has_diminishers() {
818        let a = default_analyzer();
819        let e = a.lexicon.get("slightly").expect("slightly must exist");
820        assert!(e.intensifier < 1.0);
821    }
822
823    #[test]
824    fn test_lexicon_has_at_least_60_entries() {
825        let a = default_analyzer();
826        assert!(
827            a.lexicon.len() >= 60,
828            "expected ≥60 entries, got {}",
829            a.lexicon.len()
830        );
831    }
832
833    // ------ with_lexicon_entry -------------------------------------------
834
835    #[test]
836    fn test_with_lexicon_entry_adds_word() {
837        let a = default_analyzer().with_lexicon_entry(LexiconEntry::positive("stellar", 0.9));
838        assert!(a.lexicon.contains_key("stellar"));
839    }
840
841    #[test]
842    fn test_with_lexicon_entry_overrides_existing() {
843        let a = default_analyzer().with_lexicon_entry(LexiconEntry::positive("good", 0.999));
844        let e = a.lexicon.get("good").expect("good must exist");
845        assert!((e.positive_score - 0.999).abs() < 1e-9);
846    }
847
848    // ------ analyze: polarity classification ----------------------------
849
850    #[test]
851    fn test_analyze_positive_text() {
852        let a = default_analyzer();
853        let r = a.analyze(
854            "t1".to_string(),
855            "This is absolutely excellent and amazing!",
856        );
857        assert_eq!(r.overall.polarity(), SentimentPolarity::Positive);
858    }
859
860    #[test]
861    fn test_analyze_negative_text() {
862        let a = default_analyzer();
863        let r = a.analyze("t1".to_string(), "The service is terrible and frustrating.");
864        assert_eq!(r.overall.polarity(), SentimentPolarity::Negative);
865    }
866
867    #[test]
868    fn test_analyze_neutral_text() {
869        let a = default_analyzer();
870        let r = a.analyze("t1".to_string(), "The document is a plain text file.");
871        assert_eq!(r.overall.polarity(), SentimentPolarity::Neutral);
872    }
873
874    // ------ analyze: word counts ----------------------------------------
875
876    #[test]
877    fn test_analyze_word_count() {
878        let a = default_analyzer();
879        let r = a.analyze("t1".to_string(), "good bad");
880        assert_eq!(r.word_count, 2);
881    }
882
883    #[test]
884    fn test_analyze_sentiment_word_count_non_zero_for_sentiment_text() {
885        let a = default_analyzer();
886        let r = a.analyze("t1".to_string(), "excellent");
887        assert!(r.sentiment_word_count > 0);
888    }
889
890    #[test]
891    fn test_analyze_empty_text() {
892        let a = default_analyzer();
893        let r = a.analyze("t1".to_string(), "");
894        assert_eq!(r.word_count, 0);
895        assert_eq!(r.sentiment_word_count, 0);
896    }
897
898    // ------ negation handling -------------------------------------------
899
900    #[test]
901    fn test_negation_flips_positive_to_negative() {
902        let a = default_analyzer();
903        let pos = a.analyze("pos".to_string(), "excellent");
904        let neg = a.analyze("neg".to_string(), "not excellent");
905        // After negation the compound should be lower (more negative)
906        assert!(
907            neg.overall.compound < pos.overall.compound,
908            "negation should reduce compound: {} vs {}",
909            neg.overall.compound,
910            pos.overall.compound
911        );
912    }
913
914    #[test]
915    fn test_negation_flips_negative_to_positive() {
916        let a = default_analyzer();
917        let base = a.analyze("base".to_string(), "terrible");
918        let neg = a.analyze("neg".to_string(), "not terrible");
919        assert!(neg.overall.compound > base.overall.compound);
920    }
921
922    #[test]
923    fn test_contraction_negation() {
924        let a = default_analyzer();
925        let r = a.analyze("t1".to_string(), "isn't broken");
926        // "broken" is negative; "isn't" negates → positive compound
927        assert!(r.overall.compound >= 0.0);
928    }
929
930    // ------ intensifier / diminisher handling ---------------------------
931
932    #[test]
933    fn test_intensifier_boosts_positive() {
934        // Compare with equal word counts so normalisation is comparable.
935        // "extremely good word" vs "neutral good word" — the intensifier should
936        // produce a higher raw positive contribution even after dividing by wc=3.
937        let a = default_analyzer();
938        let base = a.analyze("base".to_string(), "the good thing");
939        let boosted = a.analyze("boosted".to_string(), "extremely good thing");
940        // The boosted text uses an intensifier, so its positive score must be higher.
941        assert!(
942            boosted.overall.positive >= base.overall.positive,
943            "intensifier should boost positive: base={}, boosted={}",
944            base.overall.positive,
945            boosted.overall.positive
946        );
947    }
948
949    #[test]
950    fn test_diminisher_reduces_sentiment() {
951        let a = default_analyzer();
952        let base = a.analyze("base".to_string(), "good");
953        let reduced = a.analyze("reduced".to_string(), "slightly good");
954        assert!(reduced.overall.positive <= base.overall.positive);
955    }
956
957    // ------ aspect detection --------------------------------------------
958
959    #[test]
960    fn test_aspect_detected_for_keyword() {
961        let a = default_analyzer();
962        let r = a.analyze("t1".to_string(), "The service is excellent and fast.");
963        let service_aspects: Vec<&AspectSentiment> = r
964            .aspects
965            .iter()
966            .filter(|asp| asp.aspect == "service")
967            .collect();
968        assert!(!service_aspects.is_empty(), "expected a 'service' aspect");
969    }
970
971    #[test]
972    fn test_aspect_not_detected_for_missing_keyword() {
973        let a = default_analyzer();
974        let r = a.analyze("t1".to_string(), "Everything is fine.");
975        assert!(r.aspects.is_empty());
976    }
977
978    #[test]
979    fn test_aspect_mentions_non_empty() {
980        let a = default_analyzer();
981        let r = a.analyze("t1".to_string(), "The quality is great and reliable.");
982        let aspect = r.aspects.iter().find(|a| a.aspect == "quality");
983        assert!(aspect.is_some());
984        if let Some(asp) = aspect {
985            // At least "great" or "reliable" should be in mentions
986            assert!(!asp.mentions.is_empty(), "mentions should not be empty");
987        }
988    }
989
990    #[test]
991    fn test_aspect_multiple_occurrences() {
992        let a = default_analyzer();
993        let r = a.analyze(
994            "t1".to_string(),
995            "The performance is great. Performance is also reliable.",
996        );
997        let perf_count = r
998            .aspects
999            .iter()
1000            .filter(|a| a.aspect == "performance")
1001            .count();
1002        assert_eq!(perf_count, 2, "two occurrences of 'performance'");
1003    }
1004
1005    // ------ batch_analyze -----------------------------------------------
1006
1007    #[test]
1008    fn test_batch_analyze_returns_correct_count() {
1009        let a = default_analyzer();
1010        let texts = vec![
1011            ("t1".to_string(), "great product".to_string()),
1012            ("t2".to_string(), "terrible service".to_string()),
1013            ("t3".to_string(), "average experience".to_string()),
1014        ];
1015        let results = a.batch_analyze(&texts);
1016        assert_eq!(results.len(), 3);
1017    }
1018
1019    #[test]
1020    fn test_batch_analyze_empty() {
1021        let a = default_analyzer();
1022        let results = a.batch_analyze(&[]);
1023        assert!(results.is_empty());
1024    }
1025
1026    #[test]
1027    fn test_batch_analyze_preserves_text_ids() {
1028        let a = default_analyzer();
1029        let texts = vec![
1030            ("id_one".to_string(), "good".to_string()),
1031            ("id_two".to_string(), "bad".to_string()),
1032        ];
1033        let results = a.batch_analyze(&texts);
1034        assert_eq!(results[0].text_id, "id_one");
1035        assert_eq!(results[1].text_id, "id_two");
1036    }
1037
1038    // ------ top_positive / top_negative ---------------------------------
1039
1040    #[test]
1041    fn test_top_positive_ordering() {
1042        let a = default_analyzer();
1043        let texts = vec![
1044            ("neg".to_string(), "horrible terrible awful".to_string()),
1045            ("pos".to_string(), "excellent amazing perfect".to_string()),
1046            ("neu".to_string(), "the cat sat on a mat".to_string()),
1047        ];
1048        let results = a.batch_analyze(&texts);
1049        let top = a.top_positive(&results, 1);
1050        assert_eq!(top[0].text_id, "pos");
1051    }
1052
1053    #[test]
1054    fn test_top_negative_ordering() {
1055        let a = default_analyzer();
1056        let texts = vec![
1057            ("neg".to_string(), "horrible terrible awful".to_string()),
1058            ("pos".to_string(), "excellent amazing perfect".to_string()),
1059        ];
1060        let results = a.batch_analyze(&texts);
1061        let bottom = a.top_negative(&results, 1);
1062        assert_eq!(bottom[0].text_id, "neg");
1063    }
1064
1065    #[test]
1066    fn test_top_positive_n_larger_than_results() {
1067        let a = default_analyzer();
1068        let texts = vec![("t1".to_string(), "good".to_string())];
1069        let results = a.batch_analyze(&texts);
1070        let top = a.top_positive(&results, 100);
1071        assert_eq!(top.len(), 1);
1072    }
1073
1074    #[test]
1075    fn test_top_negative_n_larger_than_results() {
1076        let a = default_analyzer();
1077        let texts = vec![("t1".to_string(), "bad".to_string())];
1078        let results = a.batch_analyze(&texts);
1079        let bottom = a.top_negative(&results, 100);
1080        assert_eq!(bottom.len(), 1);
1081    }
1082
1083    // ------ aggregate_sentiment -----------------------------------------
1084
1085    #[test]
1086    fn test_aggregate_empty_returns_zero() {
1087        let a = default_analyzer();
1088        let agg = a.aggregate_sentiment(&[]);
1089        assert_eq!(agg.polarity(), SentimentPolarity::Neutral);
1090    }
1091
1092    #[test]
1093    fn test_aggregate_single_equals_result() {
1094        let a = default_analyzer();
1095        let r = a.analyze("t1".to_string(), "excellent");
1096        let agg = a.aggregate_sentiment(std::slice::from_ref(&r));
1097        assert!((agg.compound - r.overall.compound).abs() < 1e-9);
1098    }
1099
1100    #[test]
1101    fn test_aggregate_mixed_batch() {
1102        let a = default_analyzer();
1103        let texts = vec![
1104            (
1105                "pos".to_string(),
1106                "excellent perfect outstanding".to_string(),
1107            ),
1108            ("neg".to_string(), "terrible awful horrible".to_string()),
1109        ];
1110        let results = a.batch_analyze(&texts);
1111        let agg = a.aggregate_sentiment(&results);
1112        // The average should be near zero but the components exist
1113        assert!(agg.positive > 0.0);
1114        assert!(agg.negative > 0.0);
1115    }
1116
1117    // ------ stats -------------------------------------------------------
1118
1119    #[test]
1120    fn test_stats_empty() {
1121        let a = default_analyzer();
1122        let s = a.stats(&[]);
1123        assert_eq!(s.total_analyzed, 0);
1124        assert_eq!(s.avg_compound, 0.0);
1125    }
1126
1127    #[test]
1128    fn test_stats_counts_positive() {
1129        let a = default_analyzer();
1130        let texts = vec![
1131            ("t1".to_string(), "excellent amazing".to_string()),
1132            ("t2".to_string(), "great wonderful".to_string()),
1133        ];
1134        let results = a.batch_analyze(&texts);
1135        let s = a.stats(&results);
1136        assert!(s.positive_count > 0);
1137    }
1138
1139    #[test]
1140    fn test_stats_counts_negative() {
1141        let a = default_analyzer();
1142        let texts = vec![("t1".to_string(), "terrible awful".to_string())];
1143        let results = a.batch_analyze(&texts);
1144        let s = a.stats(&results);
1145        assert!(s.negative_count > 0);
1146    }
1147
1148    #[test]
1149    fn test_stats_total_analyzed() {
1150        let a = default_analyzer();
1151        let texts: Vec<(String, String)> = (0..7)
1152            .map(|i| (format!("t{}", i), "good".to_string()))
1153            .collect();
1154        let results = a.batch_analyze(&texts);
1155        let s = a.stats(&results);
1156        assert_eq!(s.total_analyzed, 7);
1157    }
1158
1159    #[test]
1160    fn test_stats_avg_compound_single() {
1161        let a = default_analyzer();
1162        let r = a.analyze("t1".to_string(), "excellent");
1163        let compound = r.overall.compound;
1164        let s = a.stats(&[r]);
1165        assert!((s.avg_compound - compound).abs() < 1e-9);
1166    }
1167
1168    // ------ SentimentResult fields --------------------------------------
1169
1170    #[test]
1171    fn test_result_text_id_preserved() {
1172        let a = default_analyzer();
1173        let r = a.analyze("my-unique-id".to_string(), "good");
1174        assert_eq!(r.text_id, "my-unique-id");
1175    }
1176
1177    #[test]
1178    fn test_result_compound_in_valid_range() {
1179        let a = default_analyzer();
1180        let texts = [
1181            "absolutely fantastic amazing wonderful",
1182            "horrible terrible awful crash",
1183            "the quick brown fox jumps",
1184        ];
1185        for text in &texts {
1186            let r = a.analyze("t".to_string(), text);
1187            assert!(
1188                r.overall.compound >= -1.0 && r.overall.compound <= 1.0,
1189                "compound out of range for {:?}: {}",
1190                text,
1191                r.overall.compound
1192            );
1193        }
1194    }
1195
1196    // ------ custom config -----------------------------------------------
1197
1198    #[test]
1199    fn test_custom_aspect_keywords() {
1200        let config = SentimentConfig {
1201            aspect_keywords: vec!["battery".to_string(), "camera".to_string()],
1202            ..SentimentConfig::default()
1203        };
1204        let a = SentimentAnalyzer::new(config);
1205        let r = a.analyze(
1206            "t1".to_string(),
1207            "The battery is amazing but the camera is slow.",
1208        );
1209        let aspects: Vec<&str> = r.aspects.iter().map(|a| a.aspect.as_str()).collect();
1210        assert!(aspects.contains(&"battery"));
1211        assert!(aspects.contains(&"camera"));
1212    }
1213
1214    #[test]
1215    fn test_custom_window_size_zero() {
1216        let config = SentimentConfig {
1217            window_size: 0,
1218            ..SentimentConfig::default()
1219        };
1220        let a = SentimentAnalyzer::new(config);
1221        let r = a.analyze("t1".to_string(), "quality is great");
1222        // aspect should still be found (zero window = only the aspect token itself)
1223        assert!(!r.aspects.is_empty());
1224    }
1225
1226    #[test]
1227    fn test_stats_mixed_counted_correctly() {
1228        // Build a text whose score will be Mixed
1229        let a = default_analyzer();
1230        // Craft a result manually to control scores precisely
1231        let result = SentimentResult {
1232            text_id: "m1".to_string(),
1233            overall: SentimentScore {
1234                positive: 0.3,
1235                negative: 0.3,
1236                neutral: 0.4,
1237                compound: 0.0,
1238            },
1239            aspects: vec![],
1240            word_count: 10,
1241            sentiment_word_count: 4,
1242        };
1243        let s = a.stats(&[result]);
1244        assert_eq!(s.mixed_count, 1);
1245    }
1246
1247    #[test]
1248    fn test_stats_neutral_counted_correctly() {
1249        let a = default_analyzer();
1250        let result = SentimentResult {
1251            text_id: "n1".to_string(),
1252            overall: SentimentScore {
1253                positive: 0.02,
1254                negative: 0.01,
1255                neutral: 0.97,
1256                compound: 0.01,
1257            },
1258            aspects: vec![],
1259            word_count: 5,
1260            sentiment_word_count: 0,
1261        };
1262        let s = a.stats(&[result]);
1263        assert_eq!(s.neutral_count, 1);
1264    }
1265}