vader_parity/lib.rs
1//! VADER sentiment scoring with exact output parity against Python
2//! [vaderSentiment] 3.3.2.
3//!
4//! VADER (Valence Aware Dictionary and sEntiment Reasoner) is the rule-based
5//! sentiment model of Hutto & Gilbert (ICWSM-14). This crate reimplements
6//! that package's whole rule set over that package's own data files,
7//! vendored byte-identical from the 3.3.2 sdist: lexicon scoring, booster
8//! and dampener modifiers with the casing increment, negation with the
9//! "least" special case, contrastive "but" reweighting, exclamation and
10//! question mark emphasis, the special-case idioms, emoji translation, and
11//! the alpha-15 normalization to compound. For every input, all four scores
12//! equal what the Python package returns at its published precision: three
13//! decimals for the proportions, four for the compound. The frozen oracle
14//! vectors in `tests/vectors/frozen.tsv` replay on every `cargo test`, and a
15//! release-time differential corpus against the pinned package holds that
16//! parity with zero tolerated divergence. Casing is pinned with the rest:
17//! `str.isupper()` and `str.lower()` answer from tables vendored out of
18//! CPython 3.14.6 with Unicode data 16.0.0, checked across every codepoint,
19//! so the scores hold still when the Rust standard library adopts a newer
20//! Unicode version than the reference interpreter's.
21//!
22//! ```
23//! use vader_parity::SentimentIntensityAnalyzer;
24//!
25//! let analyzer = SentimentIntensityAnalyzer::new();
26//! let scores = analyzer.polarity_scores("VADER is smart, handsome, and funny.");
27//! assert_eq!(scores.neg, 0.0);
28//! assert_eq!(scores.neu, 0.254);
29//! assert_eq!(scores.pos, 0.746);
30//! assert_eq!(scores.compound, 0.8316);
31//! ```
32//!
33//! # Scoring many texts
34//!
35//! [`SentimentIntensityAnalyzer::new`] parses the vendored lexicons, about
36//! 11,000 data lines, once. Build one analyzer and score many texts. Each
37//! call is a bounded linear pass, and the example below is the crate's own
38//! regression tripwire for that claim.
39//!
40//! ```
41//! use vader_parity::SentimentIntensityAnalyzer;
42//!
43//! let analyzer = SentimentIntensityAnalyzer::new();
44//! let started = std::time::Instant::now();
45//! for _ in 0..10_000 {
46//! analyzer.polarity_scores("Today only kinda sux! But I'll get by, lol");
47//! }
48//! assert!(started.elapsed() < std::time::Duration::from_secs(30));
49//! ```
50//!
51//! # Scope
52//!
53//! The reference is English-only and so is its lexicon. This crate is the
54//! scoring engine only, pinned to reference version 3.3.2, `std`-only, with
55//! zero runtime dependencies. Sentence splitting, custom lexicons, and other
56//! reference versions are out of scope.
57//!
58//! # Attribution
59//!
60//! The VADER algorithm design, the sentiment lexicon, and the emoji lexicon
61//! are the MIT-licensed work of C.J. Hutto and E.E. Gilbert. The vendored
62//! files in `data/` ship with the authors' license text and their recorded
63//! sha256 digests, and the Rust implementation and its tests are this
64//! crate's. If you use VADER, cite: Hutto, C.J. & Gilbert, E.E. (2014).
65//! VADER: A Parsimonious Rule-based Model for Sentiment Analysis of Social
66//! Media Text. Eighth International Conference on Weblogs and Social Media
67//! (ICWSM-14). Ann Arbor, MI.
68//!
69//! [vaderSentiment]: https://pypi.org/project/vaderSentiment/3.3.2/
70
71#![forbid(unsafe_code)]
72#![deny(missing_docs)]
73#![deny(
74 clippy::unwrap_used,
75 clippy::expect_used,
76 clippy::panic,
77 clippy::indexing_slicing
78)]
79
80mod casing;
81mod data;
82mod round;
83mod rules;
84mod text;
85
86/// The four scores for one text. Field values match Python vaderSentiment
87/// 3.3.2, at that package's published precision.
88///
89/// The proportions describe how the text's tokens split across the three
90/// categories and sum to 1 up to rounding residue. The compound is the
91/// normalized, weighted composite in [-1, 1], the single number most callers
92/// read (its sign is the sentiment label). `tests/properties.rs` holds the
93/// distribution invariant, and the frozen vectors pin all four fields.
94#[derive(Clone, Copy, Debug, PartialEq)]
95pub struct SentimentScores {
96 /// Negative proportion, rounded to 3 decimals. In [0, 1].
97 pub neg: f64,
98 /// Neutral proportion, rounded to 3 decimals. In [0, 1].
99 pub neu: f64,
100 /// Positive proportion, rounded to 3 decimals. In [0, 1].
101 pub pos: f64,
102 /// Normalized compound score, rounded to 4 decimals. In [-1, 1].
103 pub compound: f64,
104}
105
106/// The analyzer, holding the loaded lexicons.
107///
108/// Construction parses the vendored VADER 3.3.2 data once, and scoring
109/// reuses the parsed tables. Two analyzers are interchangeable: same input,
110/// same bits, which `tests/properties.rs` asserts.
111pub struct SentimentIntensityAnalyzer {
112 tables: data::Tables,
113}
114
115impl SentimentIntensityAnalyzer {
116 /// Build an analyzer over the vendored VADER 3.3.2 lexicons.
117 ///
118 /// Infallible: the data is vendored and its digests are enforced by
119 /// `tests/provenance.rs` before any caller exists.
120 #[must_use]
121 pub fn new() -> SentimentIntensityAnalyzer {
122 SentimentIntensityAnalyzer {
123 tables: data::Tables::load(),
124 }
125 }
126
127 /// Score one text. Total: every `&str` input returns scores.
128 ///
129 /// Text with nothing scoreable is neutral, not an error, exactly as the
130 /// reference scores it:
131 ///
132 /// ```
133 /// use vader_parity::SentimentIntensityAnalyzer;
134 ///
135 /// let analyzer = SentimentIntensityAnalyzer::new();
136 /// assert_eq!(analyzer.polarity_scores("is?").compound, 0.0);
137 /// assert_eq!(analyzer.polarity_scores("is?").neu, 1.0);
138 /// assert_eq!(analyzer.polarity_scores("").compound, 0.0);
139 /// ```
140 #[must_use]
141 pub fn polarity_scores(&self, text: &str) -> SentimentScores {
142 rules::polarity_scores(&self.tables, text).0
143 }
144
145 /// Score one text and also return the compound before rounding. The
146 /// differential harness compares this bit-for-bit against the oracle
147 /// (invariant 9). Only test builds carry it.
148 #[cfg(test)]
149 pub(crate) fn polarity_scores_with_raw(&self, text: &str) -> (SentimentScores, f64) {
150 rules::polarity_scores(&self.tables, text)
151 }
152}
153
154impl Default for SentimentIntensityAnalyzer {
155 fn default() -> Self {
156 Self::new()
157 }
158}
159
160#[cfg(test)]
161mod invariant9;