Skip to main content

sentiment_basic/
lib.rs

1//! # sentiment-basic
2//!
3//! Simple, deterministic positive/negative word-count sentiment scoring with
4//! built-in word lists. Pure Rust, **zero dependencies**, no network, no
5//! model downloads — the same lightweight signal the
6//! [BeLikeNative](https://belikenative.com/) AI writing assistant uses for a
7//! fast first-pass tone read.
8//!
9//! This is a **lexicon** scorer, not a machine-learning model. It is fast,
10//! fully deterministic, auditable, and works offline. The trade-off is that
11//! it does not understand negation, sarcasm, or context — pair it with a
12//! real model for nuanced analysis.
13//!
14//! ## Quick example
15//!
16//! ```
17//! use sentiment_basic::score;
18//!
19//! let r = score("I love this bright, happy day. It is awful and terrible though.");
20//! assert!(r.positive_hits >= 3); // love, bright, happy
21//! assert!(r.negative_hits >= 2); // awful, terrible
22//! assert!(r.comparison() > 0.0); // 3 positive vs 2 negative -> leans positive
23//! ```
24
25// ============================================================================
26// Built-in lexicons.
27//
28// These are compact, hand-curated lists of unambiguous polarity words. They
29// are deliberately small: the goal is a stable, inspectable baseline, not a
30// research-grade lexicon. Each word is lower-case ASCII with no whitespace.
31// ============================================================================
32
33/// The built-in positive-polarity lexicon.
34pub const POSITIVE_WORDS: &[&str] = &[
35    "good", "great", "excellent", "amazing", "wonderful", "fantastic", "awesome",
36    "love", "loved", "loves", "happy", "joy", "joyful", "beautiful", "brilliant",
37    "best", "better", "bright", "charming", "delight", "delightful", "enjoy",
38    "enjoyed", "fabulous", "favor", "favorite", "fun", "generous", "gift",
39    "glad", "glorious", "grace", "grateful", "heaven", "helpful", "hope",
40    "hopeful", "incredible", "inspire", "inspiring", "kind", "lucky", "marvelous",
41    "nice", "perfect", "pleasant", "pleasure", "positive", "remarkable",
42    "shining", "splendid", "stellar", "superb", "terrific", "thank", "thanks",
43    "thrive", "treasure", "triumph", "vibrant", "victory", "warm", "win", "winner",
44    "wonder", "worth", "worthy", "yes",
45];
46
47/// The built-in negative-polarity lexicon.
48pub const NEGATIVE_WORDS: &[&str] = &[
49    "bad", "terrible", "awful", "horrible", "worst", "worse", "hate", "hated",
50    "hates", "sad", "angry", "annoying", "boring", "broken", "careless",
51    "cold", "complain", "complaint", "cruel", "cursed", "damage", "danger",
52    "dangerous", "dark", "dead", "death", "defeat", "deny", "depressed",
53    "despair", "destroy", "destructive", "difficult", "disappoint",
54    "disappointed", "disgust", "disgusting", "dread", "dreadful", "dull",
55    "enemy", "evil", "fail", "failed", "failure", "fake", "fear", "gloomy",
56    "gross", "harm", "harmful", "harsh", "hurt", "hurts", "ill", "inferior",
57    "insult", "lame", "liar", "lonely", "lose", "loser", "loss", "mean",
58    "mess", "miserable", "negative", "nightmare", "no", "pain", "painful",
59    "pathetic", "poor", "problem", "punish", "reject", "rejected", "rotten",
60    "rude", "sick", "sorrow", "stink", "stupid", "suffer", "ugly", "unfair",
61    "unhappy", "weak", "wrong",
62];
63
64/// The polarity result of scoring a piece of text.
65///
66/// All fields are derived from simple case-insensitive whole-word matching
67/// against [`POSITIVE_WORDS`] and [`NEGATIVE_WORDS`].
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Sentiment {
70    /// Number of positive lexicon words matched.
71    pub positive_hits: u32,
72    /// Number of negative lexicon words matched.
73    pub negative_hits: u32,
74    /// Total tokens the lexicons were matched against (word count).
75    pub tokens: u32,
76}
77
78impl Sentiment {
79    /// Net polarity: `positive_hits - negative_hits`.
80    ///
81    /// Positive when the text leans positive, negative when it leans
82    /// negative, zero when balanced (or empty).
83    #[must_use]
84    #[inline]
85    pub fn net(&self) -> i64 {
86        i64::from(self.positive_hits) - i64::from(self.negative_hits)
87    }
88
89    /// Comparison score in `-1.0..=1.0`:
90    /// `(positive - negative) / (positive + negative)`.
91    ///
92    /// Returns `0.0` when no polarity words were found.
93    #[must_use]
94    #[inline]
95    pub fn comparison(&self) -> f64 {
96        let total = self.positive_hits + self.negative_hits;
97        if total == 0 {
98            0.0
99        } else {
100            (i64::from(self.positive_hits) - i64::from(self.negative_hits)) as f64 / f64::from(total)
101        }
102    }
103
104    /// Proportion of polarity-bearing tokens that are positive, in `0.0..=1.0`.
105    ///
106    /// Returns `0.0` when no polarity words were found.
107    #[must_use]
108    #[inline]
109    pub fn positive_share(&self) -> f64 {
110        let total = self.positive_hits + self.negative_hits;
111        if total == 0 {
112            0.0
113        } else {
114            f64::from(self.positive_hits) / f64::from(total)
115        }
116    }
117
118    /// Coarse label for the overall polarity.
119    #[must_use]
120    pub fn label(&self) -> &'static str {
121        match self.net() {
122            n if n > 0 => "positive",
123            n if n < 0 => "negative",
124            _ => "neutral",
125        }
126    }
127}
128
129/// Tokenize `text` into lower-cased word tokens for matching.
130///
131/// A token is a maximal run of ASCII letters, digits, or apostrophes — the
132/// same definition used by most word-count utilities, so contractions stay
133/// intact.
134fn tokenize(text: &str) -> Vec<String> {
135    text.split(|c: char| !(c.is_alphanumeric() || c == '\''))
136        .filter(|s| !s.is_empty())
137        .map(|s| s.to_ascii_lowercase())
138        .collect()
139}
140
141/// Score `text` against the built-in lexicons.
142///
143/// The function is total and deterministic: identical inputs always return
144/// identical outputs. Empty input returns a `Sentiment` with all-zero counts
145/// and a `"neutral"` label.
146///
147/// ```
148/// use sentiment_basic::score;
149///
150/// let r = score("");
151/// assert_eq!(r.label(), "neutral");
152/// assert_eq!(r.comparison(), 0.0);
153/// ```
154#[must_use]
155pub fn score(text: &str) -> Sentiment {
156    use std::collections::HashSet;
157
158    let tokens = tokenize(text);
159    let token_count = tokens.len() as u32;
160
161    let pos: HashSet<&str> = POSITIVE_WORDS.iter().copied().collect();
162    let neg: HashSet<&str> = NEGATIVE_WORDS.iter().copied().collect();
163
164    let mut positive_hits = 0u32;
165    let mut negative_hits = 0u32;
166    for tok in &tokens {
167        if pos.contains(tok.as_str()) {
168            positive_hits += 1;
169        } else if neg.contains(tok.as_str()) {
170            negative_hits += 1;
171        }
172    }
173
174    Sentiment {
175        positive_hits,
176        negative_hits,
177        tokens: token_count,
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn empty_text_is_neutral() {
187        let r = score("");
188        assert_eq!(r.positive_hits, 0);
189        assert_eq!(r.negative_hits, 0);
190        assert_eq!(r.net(), 0);
191        assert_eq!(r.comparison(), 0.0);
192        assert_eq!(r.label(), "neutral");
193    }
194
195    #[test]
196    fn counts_positive_words_case_insensitively() {
197        let r = score("GOOD great AMAZING love Happy");
198        assert_eq!(r.positive_hits, 5);
199        assert_eq!(r.negative_hits, 0);
200        assert!(r.comparison() > 0.0);
201        assert_eq!(r.label(), "positive");
202    }
203
204    #[test]
205    fn counts_negative_words_case_insensitively() {
206        let r = score("BAD Terrible awful hate worst");
207        assert_eq!(r.negative_hits, 5);
208        assert_eq!(r.positive_hits, 0);
209        assert!(r.comparison() < 0.0);
210        assert_eq!(r.label(), "negative");
211    }
212
213    #[test]
214    fn mixed_text_balances_correctly() {
215        let r = score("I love this great day, but the food was awful and terrible.");
216        assert_eq!(r.positive_hits, 2); // love, great
217        assert_eq!(r.negative_hits, 2); // awful, terrible
218        assert_eq!(r.net(), 0);
219        assert_eq!(r.comparison(), 0.0);
220        assert_eq!(r.label(), "neutral");
221    }
222
223    #[test]
224    fn whole_word_matching_not_substring() {
225        // "scared" must NOT match "scare"/"scared"-style stems; we only have
226        // exact lexicon entries. "goodness" is not in the lexicon, so it
227        // should not count as a hit for "good".
228        let r = score("goodness scaring");
229        assert_eq!(r.positive_hits, 0);
230        assert_eq!(r.negative_hits, 0);
231    }
232
233    #[test]
234    fn punctuation_does_not_break_matching() {
235        let r = score("great! wonderful? amazing. terrific,");
236        assert_eq!(r.positive_hits, 4);
237    }
238
239    #[test]
240    fn comparison_is_bounded_to_unit_interval() {
241        let r = score("love love love love love");
242        assert!((r.comparison() - 1.0).abs() < 1e-9);
243        let r = score("hate hate hate hate hate");
244        assert!((r.comparison() - (-1.0)).abs() < 1e-9);
245    }
246
247    #[test]
248    fn positive_share() {
249        let r = score("love love hate");
250        assert!((r.positive_share() - 2.0 / 3.0).abs() < 1e-9);
251        // No polarity words -> 0 share.
252        let r = score("the cat sat on the mat");
253        assert_eq!(r.positive_share(), 0.0);
254    }
255
256    #[test]
257    fn tokens_count_all_words_not_just_lexicon() {
258        let r = score("the good cat sat happily");
259        assert_eq!(r.tokens, 5);
260        assert!(r.positive_hits <= 2); // good (happily is not in the lexicon)
261    }
262
263    #[test]
264    fn determinism() {
265        let t = "This is a great, wonderful, terrible, awful sentence.";
266        assert_eq!(score(t), score(t));
267    }
268
269    #[test]
270    fn contraction_handled_as_single_token() {
271        // "can't" is one token; it is not in either lexicon so it is neutral.
272        let r = score("I can't love this enough");
273        assert!(r.positive_hits >= 1); // love
274        assert_eq!(r.negative_hits, 0);
275    }
276
277    #[test]
278    fn label_thresholds() {
279        assert_eq!(score("great wonderful amazing").label(), "positive");
280        assert_eq!(score("awful terrible bad").label(), "negative");
281        assert_eq!(score("great terrible").label(), "neutral"); // tie -> neutral
282        assert_eq!(score("the cat sat").label(), "neutral"); // no hits
283    }
284}