sentiment-basic 0.1.0

Simple, deterministic positive/negative word-count sentiment scoring with built-in word lists — pure, zero-dependency, no network.
Documentation
//! # sentiment-basic
//!
//! Simple, deterministic positive/negative word-count sentiment scoring with
//! built-in word lists. Pure Rust, **zero dependencies**, no network, no
//! model downloads — the same lightweight signal the
//! [BeLikeNative](https://belikenative.com/) AI writing assistant uses for a
//! fast first-pass tone read.
//!
//! This is a **lexicon** scorer, not a machine-learning model. It is fast,
//! fully deterministic, auditable, and works offline. The trade-off is that
//! it does not understand negation, sarcasm, or context — pair it with a
//! real model for nuanced analysis.
//!
//! ## Quick example
//!
//! ```
//! use sentiment_basic::score;
//!
//! let r = score("I love this bright, happy day. It is awful and terrible though.");
//! assert!(r.positive_hits >= 3); // love, bright, happy
//! assert!(r.negative_hits >= 2); // awful, terrible
//! assert!(r.comparison() > 0.0); // 3 positive vs 2 negative -> leans positive
//! ```

// ============================================================================
// Built-in lexicons.
//
// These are compact, hand-curated lists of unambiguous polarity words. They
// are deliberately small: the goal is a stable, inspectable baseline, not a
// research-grade lexicon. Each word is lower-case ASCII with no whitespace.
// ============================================================================

/// The built-in positive-polarity lexicon.
pub const POSITIVE_WORDS: &[&str] = &[
    "good", "great", "excellent", "amazing", "wonderful", "fantastic", "awesome",
    "love", "loved", "loves", "happy", "joy", "joyful", "beautiful", "brilliant",
    "best", "better", "bright", "charming", "delight", "delightful", "enjoy",
    "enjoyed", "fabulous", "favor", "favorite", "fun", "generous", "gift",
    "glad", "glorious", "grace", "grateful", "heaven", "helpful", "hope",
    "hopeful", "incredible", "inspire", "inspiring", "kind", "lucky", "marvelous",
    "nice", "perfect", "pleasant", "pleasure", "positive", "remarkable",
    "shining", "splendid", "stellar", "superb", "terrific", "thank", "thanks",
    "thrive", "treasure", "triumph", "vibrant", "victory", "warm", "win", "winner",
    "wonder", "worth", "worthy", "yes",
];

/// The built-in negative-polarity lexicon.
pub const NEGATIVE_WORDS: &[&str] = &[
    "bad", "terrible", "awful", "horrible", "worst", "worse", "hate", "hated",
    "hates", "sad", "angry", "annoying", "boring", "broken", "careless",
    "cold", "complain", "complaint", "cruel", "cursed", "damage", "danger",
    "dangerous", "dark", "dead", "death", "defeat", "deny", "depressed",
    "despair", "destroy", "destructive", "difficult", "disappoint",
    "disappointed", "disgust", "disgusting", "dread", "dreadful", "dull",
    "enemy", "evil", "fail", "failed", "failure", "fake", "fear", "gloomy",
    "gross", "harm", "harmful", "harsh", "hurt", "hurts", "ill", "inferior",
    "insult", "lame", "liar", "lonely", "lose", "loser", "loss", "mean",
    "mess", "miserable", "negative", "nightmare", "no", "pain", "painful",
    "pathetic", "poor", "problem", "punish", "reject", "rejected", "rotten",
    "rude", "sick", "sorrow", "stink", "stupid", "suffer", "ugly", "unfair",
    "unhappy", "weak", "wrong",
];

/// The polarity result of scoring a piece of text.
///
/// All fields are derived from simple case-insensitive whole-word matching
/// against [`POSITIVE_WORDS`] and [`NEGATIVE_WORDS`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sentiment {
    /// Number of positive lexicon words matched.
    pub positive_hits: u32,
    /// Number of negative lexicon words matched.
    pub negative_hits: u32,
    /// Total tokens the lexicons were matched against (word count).
    pub tokens: u32,
}

impl Sentiment {
    /// Net polarity: `positive_hits - negative_hits`.
    ///
    /// Positive when the text leans positive, negative when it leans
    /// negative, zero when balanced (or empty).
    #[must_use]
    #[inline]
    pub fn net(&self) -> i64 {
        i64::from(self.positive_hits) - i64::from(self.negative_hits)
    }

    /// Comparison score in `-1.0..=1.0`:
    /// `(positive - negative) / (positive + negative)`.
    ///
    /// Returns `0.0` when no polarity words were found.
    #[must_use]
    #[inline]
    pub fn comparison(&self) -> f64 {
        let total = self.positive_hits + self.negative_hits;
        if total == 0 {
            0.0
        } else {
            (i64::from(self.positive_hits) - i64::from(self.negative_hits)) as f64 / f64::from(total)
        }
    }

    /// Proportion of polarity-bearing tokens that are positive, in `0.0..=1.0`.
    ///
    /// Returns `0.0` when no polarity words were found.
    #[must_use]
    #[inline]
    pub fn positive_share(&self) -> f64 {
        let total = self.positive_hits + self.negative_hits;
        if total == 0 {
            0.0
        } else {
            f64::from(self.positive_hits) / f64::from(total)
        }
    }

    /// Coarse label for the overall polarity.
    #[must_use]
    pub fn label(&self) -> &'static str {
        match self.net() {
            n if n > 0 => "positive",
            n if n < 0 => "negative",
            _ => "neutral",
        }
    }
}

/// Tokenize `text` into lower-cased word tokens for matching.
///
/// A token is a maximal run of ASCII letters, digits, or apostrophes — the
/// same definition used by most word-count utilities, so contractions stay
/// intact.
fn tokenize(text: &str) -> Vec<String> {
    text.split(|c: char| !(c.is_alphanumeric() || c == '\''))
        .filter(|s| !s.is_empty())
        .map(|s| s.to_ascii_lowercase())
        .collect()
}

/// Score `text` against the built-in lexicons.
///
/// The function is total and deterministic: identical inputs always return
/// identical outputs. Empty input returns a `Sentiment` with all-zero counts
/// and a `"neutral"` label.
///
/// ```
/// use sentiment_basic::score;
///
/// let r = score("");
/// assert_eq!(r.label(), "neutral");
/// assert_eq!(r.comparison(), 0.0);
/// ```
#[must_use]
pub fn score(text: &str) -> Sentiment {
    use std::collections::HashSet;

    let tokens = tokenize(text);
    let token_count = tokens.len() as u32;

    let pos: HashSet<&str> = POSITIVE_WORDS.iter().copied().collect();
    let neg: HashSet<&str> = NEGATIVE_WORDS.iter().copied().collect();

    let mut positive_hits = 0u32;
    let mut negative_hits = 0u32;
    for tok in &tokens {
        if pos.contains(tok.as_str()) {
            positive_hits += 1;
        } else if neg.contains(tok.as_str()) {
            negative_hits += 1;
        }
    }

    Sentiment {
        positive_hits,
        negative_hits,
        tokens: token_count,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_text_is_neutral() {
        let r = score("");
        assert_eq!(r.positive_hits, 0);
        assert_eq!(r.negative_hits, 0);
        assert_eq!(r.net(), 0);
        assert_eq!(r.comparison(), 0.0);
        assert_eq!(r.label(), "neutral");
    }

    #[test]
    fn counts_positive_words_case_insensitively() {
        let r = score("GOOD great AMAZING love Happy");
        assert_eq!(r.positive_hits, 5);
        assert_eq!(r.negative_hits, 0);
        assert!(r.comparison() > 0.0);
        assert_eq!(r.label(), "positive");
    }

    #[test]
    fn counts_negative_words_case_insensitively() {
        let r = score("BAD Terrible awful hate worst");
        assert_eq!(r.negative_hits, 5);
        assert_eq!(r.positive_hits, 0);
        assert!(r.comparison() < 0.0);
        assert_eq!(r.label(), "negative");
    }

    #[test]
    fn mixed_text_balances_correctly() {
        let r = score("I love this great day, but the food was awful and terrible.");
        assert_eq!(r.positive_hits, 2); // love, great
        assert_eq!(r.negative_hits, 2); // awful, terrible
        assert_eq!(r.net(), 0);
        assert_eq!(r.comparison(), 0.0);
        assert_eq!(r.label(), "neutral");
    }

    #[test]
    fn whole_word_matching_not_substring() {
        // "scared" must NOT match "scare"/"scared"-style stems; we only have
        // exact lexicon entries. "goodness" is not in the lexicon, so it
        // should not count as a hit for "good".
        let r = score("goodness scaring");
        assert_eq!(r.positive_hits, 0);
        assert_eq!(r.negative_hits, 0);
    }

    #[test]
    fn punctuation_does_not_break_matching() {
        let r = score("great! wonderful? amazing. terrific,");
        assert_eq!(r.positive_hits, 4);
    }

    #[test]
    fn comparison_is_bounded_to_unit_interval() {
        let r = score("love love love love love");
        assert!((r.comparison() - 1.0).abs() < 1e-9);
        let r = score("hate hate hate hate hate");
        assert!((r.comparison() - (-1.0)).abs() < 1e-9);
    }

    #[test]
    fn positive_share() {
        let r = score("love love hate");
        assert!((r.positive_share() - 2.0 / 3.0).abs() < 1e-9);
        // No polarity words -> 0 share.
        let r = score("the cat sat on the mat");
        assert_eq!(r.positive_share(), 0.0);
    }

    #[test]
    fn tokens_count_all_words_not_just_lexicon() {
        let r = score("the good cat sat happily");
        assert_eq!(r.tokens, 5);
        assert!(r.positive_hits <= 2); // good (happily is not in the lexicon)
    }

    #[test]
    fn determinism() {
        let t = "This is a great, wonderful, terrible, awful sentence.";
        assert_eq!(score(t), score(t));
    }

    #[test]
    fn contraction_handled_as_single_token() {
        // "can't" is one token; it is not in either lexicon so it is neutral.
        let r = score("I can't love this enough");
        assert!(r.positive_hits >= 1); // love
        assert_eq!(r.negative_hits, 0);
    }

    #[test]
    fn label_thresholds() {
        assert_eq!(score("great wonderful amazing").label(), "positive");
        assert_eq!(score("awful terrible bad").label(), "negative");
        assert_eq!(score("great terrible").label(), "neutral"); // tie -> neutral
        assert_eq!(score("the cat sat").label(), "neutral"); // no hits
    }
}