use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct ShannonEntropy;
impl ShannonEntropy {
pub fn new() -> Self {
Self
}
pub fn char_entropy(&self, text: &str) -> f64 {
if text.is_empty() {
return 0.0;
}
let mut counts: HashMap<char, usize> = HashMap::new();
for ch in text.chars() {
*counts.entry(ch).or_insert(0) += 1;
}
let total = text.chars().count() as f64;
self.entropy_from_counts(counts.values().copied(), total)
}
pub fn token_entropy(&self, text: &str) -> f64 {
let tokens: Vec<&str> = text.split_whitespace().collect();
if tokens.is_empty() {
return 0.0;
}
let mut counts: HashMap<&str, usize> = HashMap::new();
for tok in &tokens {
*counts.entry(tok).or_insert(0) += 1;
}
let total = tokens.len() as f64;
self.entropy_from_counts(counts.values().copied(), total)
}
pub fn entropy_from_counts(
&self,
counts: impl Iterator<Item = usize>,
total: f64,
) -> f64 {
if total <= 0.0 {
return 0.0;
}
counts.fold(0.0, |acc, c| {
if c == 0 {
acc
} else {
let p = c as f64 / total;
acc - p * p.log2()
}
})
}
}
#[derive(Debug, Clone)]
pub struct PerplexityEstimator {
log_probs: HashMap<String, f64>,
unk_log_prob: f64,
total_tokens: usize,
}
impl PerplexityEstimator {
pub fn from_corpus<I, S>(tokens: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut counts: HashMap<String, usize> = HashMap::new();
let mut total = 0usize;
for tok in tokens {
*counts.entry(tok.as_ref().to_lowercase()).or_insert(0) += 1;
total += 1;
}
let vocab_size = counts.len() + 1; let smoothed_total = (total + vocab_size) as f64;
let log_probs = counts
.iter()
.map(|(tok, &c)| {
let p = (c + 1) as f64 / smoothed_total;
(tok.clone(), p.log2())
})
.collect();
let unk_log_prob = (1.0_f64 / smoothed_total).log2();
Self {
log_probs,
unk_log_prob,
total_tokens: total,
}
}
pub fn perplexity(&self, text: &str) -> f64 {
let tokens: Vec<&str> = text.split_whitespace().collect();
if tokens.is_empty() {
return f64::INFINITY;
}
let n = tokens.len() as f64;
let sum_log_prob: f64 = tokens.iter().map(|t| {
*self
.log_probs
.get(&t.to_lowercase())
.unwrap_or(&self.unk_log_prob)
}).sum();
let avg_cross_entropy = -sum_log_prob / n;
2_f64.powf(avg_cross_entropy)
}
pub fn vocab_size(&self) -> usize {
self.log_probs.len()
}
pub fn corpus_size(&self) -> usize {
self.total_tokens
}
}
#[derive(Debug, Clone)]
pub struct RepetitionReport {
pub ngram_n: usize,
pub total_ngrams: usize,
pub distinct_ngrams: usize,
pub repetition_rate: f64,
pub is_repetitive: bool,
pub top_repeated: Option<(Vec<String>, usize)>,
}
#[derive(Debug, Clone)]
pub struct RepetitionDetector {
n: usize,
threshold: f64,
}
impl Default for RepetitionDetector {
fn default() -> Self {
Self::new(3, 0.3)
}
}
impl RepetitionDetector {
pub fn new(n: usize, threshold: f64) -> Self {
let n = n.max(1);
let threshold = threshold.clamp(0.0, 1.0);
Self { n, threshold }
}
pub fn analyse(&self, text: &str) -> RepetitionReport {
let tokens: Vec<String> = text
.split_whitespace()
.map(|s| s.to_lowercase())
.collect();
if tokens.len() < self.n {
return RepetitionReport {
ngram_n: self.n,
total_ngrams: 0,
distinct_ngrams: 0,
repetition_rate: 0.0,
is_repetitive: false,
top_repeated: None,
};
}
let ngrams: Vec<Vec<String>> = tokens
.windows(self.n)
.map(|w| w.to_vec())
.collect();
let total = ngrams.len();
let mut counts: HashMap<Vec<String>, usize> = HashMap::new();
for ng in &ngrams {
*counts.entry(ng.clone()).or_insert(0) += 1;
}
let distinct = counts.len();
let repetition_rate = if total > 0 {
1.0 - distinct as f64 / total as f64
} else {
0.0
};
let top_repeated = counts
.iter()
.filter(|(_, &c)| c > 1)
.max_by_key(|(_, &c)| c)
.map(|(ng, &c)| (ng.clone(), c));
RepetitionReport {
ngram_n: self.n,
total_ngrams: total,
distinct_ngrams: distinct,
repetition_rate,
is_repetitive: repetition_rate >= self.threshold,
top_repeated,
}
}
}
#[derive(Debug, Clone)]
pub struct TurnEntropy {
pub turn: usize,
pub is_user: bool,
pub token_entropy: f64,
pub char_entropy: f64,
pub repetition_rate: f64,
}
#[derive(Debug, Clone)]
pub struct EntropyTimeline {
turns: Vec<TurnEntropy>,
alert_threshold: f64,
entropy_calc: ShannonEntropy,
repetition: RepetitionDetector,
}
impl EntropyTimeline {
pub fn new(alert_threshold: f64) -> Self {
Self {
turns: Vec::new(),
alert_threshold,
entropy_calc: ShannonEntropy::new(),
repetition: RepetitionDetector::default(),
}
}
pub fn push(&mut self, text: &str, is_user: bool) {
let turn = self.turns.len();
let te = self.entropy_calc.token_entropy(text);
let ce = self.entropy_calc.char_entropy(text);
let rep = self.repetition.analyse(text).repetition_rate;
self.turns.push(TurnEntropy {
turn,
is_user,
token_entropy: te,
char_entropy: ce,
repetition_rate: rep,
});
}
pub fn turns(&self) -> &[TurnEntropy] {
&self.turns
}
pub fn len(&self) -> usize {
self.turns.len()
}
pub fn is_empty(&self) -> bool {
self.turns.is_empty()
}
pub fn check_alerts(&self) -> Vec<EntropyAlert> {
self.turns
.iter()
.filter(|t| !t.is_user && t.token_entropy < self.alert_threshold)
.map(|t| EntropyAlert {
turn: t.turn,
token_entropy: t.token_entropy,
threshold: self.alert_threshold,
repetition_rate: t.repetition_rate,
})
.collect()
}
pub fn rolling_entropy(&self, window: usize) -> Option<f64> {
let model_turns: Vec<f64> = self
.turns
.iter()
.filter(|t| !t.is_user)
.map(|t| t.token_entropy)
.collect();
if model_turns.is_empty() {
return None;
}
let window = window.min(model_turns.len());
let slice = &model_turns[model_turns.len() - window..];
Some(slice.iter().sum::<f64>() / slice.len() as f64)
}
}
#[derive(Debug, Clone)]
pub struct EntropyAlert {
pub turn: usize,
pub token_entropy: f64,
pub threshold: f64,
pub repetition_rate: f64,
}
impl std::fmt::Display for EntropyAlert {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"EntropyAlert[turn={}]: entropy={:.3} bits < threshold={:.3} bits, repetition={:.1}%",
self.turn,
self.token_entropy,
self.threshold,
self.repetition_rate * 100.0,
)
}
}
pub struct PromptEntropy {
pub shannon: ShannonEntropy,
pub perplexity: PerplexityEstimator,
pub repetition_detector: RepetitionDetector,
pub timeline: EntropyTimeline,
}
#[derive(Debug, Clone)]
pub struct EntropyStats {
pub token_entropy: f64,
pub char_entropy: f64,
pub perplexity: f64,
pub repetition: RepetitionReport,
}
impl PromptEntropy {
pub fn new<I, S>(corpus: I, alert_threshold: f64) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
Self {
shannon: ShannonEntropy::new(),
perplexity: PerplexityEstimator::from_corpus(corpus),
repetition_detector: RepetitionDetector::default(),
timeline: EntropyTimeline::new(alert_threshold),
}
}
pub fn analyse(&self, text: &str) -> EntropyStats {
EntropyStats {
token_entropy: self.shannon.token_entropy(text),
char_entropy: self.shannon.char_entropy(text),
perplexity: self.perplexity.perplexity(text),
repetition: self.repetition_detector.analyse(text),
}
}
pub fn push_turn(&mut self, text: &str, is_user: bool) -> EntropyStats {
self.timeline.push(text, is_user);
self.analyse(text)
}
pub fn alerts(&self) -> Vec<EntropyAlert> {
self.timeline.check_alerts()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_char_entropy_empty() {
let se = ShannonEntropy::new();
assert_eq!(se.char_entropy(""), 0.0);
}
#[test]
fn test_char_entropy_uniform() {
let se = ShannonEntropy::new();
assert_eq!(se.char_entropy("aaaa"), 0.0);
}
#[test]
fn test_char_entropy_two_equal_symbols() {
let se = ShannonEntropy::new();
let h = se.char_entropy("ab");
assert!((h - 1.0).abs() < 1e-9, "expected 1.0 bit, got {h}");
}
#[test]
fn test_token_entropy_empty() {
let se = ShannonEntropy::new();
assert_eq!(se.token_entropy(""), 0.0);
}
#[test]
fn test_token_entropy_single() {
let se = ShannonEntropy::new();
assert_eq!(se.token_entropy("hello hello hello"), 0.0);
}
#[test]
fn test_token_entropy_increases_with_variety() {
let se = ShannonEntropy::new();
let low = se.token_entropy("a a a a b");
let high = se.token_entropy("a b c d e f g h i j");
assert!(high > low, "high={high} should exceed low={low}");
}
#[test]
fn test_perplexity_empty_text() {
let corpus = "the quick brown fox".split_whitespace();
let est = PerplexityEstimator::from_corpus(corpus);
assert!(est.perplexity("").is_infinite());
}
#[test]
fn test_perplexity_known_vs_unknown() {
let corpus = "hello world hello world hello".split_whitespace();
let est = PerplexityEstimator::from_corpus(corpus);
let low = est.perplexity("hello world");
let high = est.perplexity("xyzzy frobnicate");
assert!(
high > low,
"unseen tokens should have higher perplexity: low={low}, high={high}"
);
}
#[test]
fn test_perplexity_estimator_vocab_size() {
let corpus = "a b c d e".split_whitespace();
let est = PerplexityEstimator::from_corpus(corpus);
assert_eq!(est.vocab_size(), 5);
assert_eq!(est.corpus_size(), 5);
}
#[test]
fn test_repetition_no_repetition() {
let det = RepetitionDetector::default();
let report = det.analyse("the quick brown fox jumps over the lazy dog");
assert!(!report.is_repetitive, "diverse text should not be flagged");
}
#[test]
fn test_repetition_pure_loop() {
let det = RepetitionDetector::new(2, 0.5);
let text = "a b a b a b a b a b a b a b a b";
let report = det.analyse(text);
assert!(report.is_repetitive, "repeated bigrams should be flagged");
assert!(report.repetition_rate > 0.5);
}
#[test]
fn test_repetition_short_input() {
let det = RepetitionDetector::new(3, 0.3);
let report = det.analyse("hello");
assert_eq!(report.total_ngrams, 0);
assert!(!report.is_repetitive);
}
#[test]
fn test_repetition_top_repeated_present() {
let det = RepetitionDetector::new(2, 0.1);
let report = det.analyse("cat dog cat dog cat dog cat");
assert!(report.top_repeated.is_some());
let (ngram, count) = report.top_repeated.unwrap();
assert!(count >= 3);
assert_eq!(ngram.len(), 2);
}
#[test]
fn test_timeline_len() {
let mut tl = EntropyTimeline::new(1.5);
assert!(tl.is_empty());
tl.push("Hello there", true);
tl.push("Hi, how can I help?", false);
assert_eq!(tl.len(), 2);
}
#[test]
fn test_timeline_no_alert_for_healthy_model_turn() {
let mut tl = EntropyTimeline::new(0.5);
tl.push("I can help you with many things including coding writing and analysis", false);
assert!(
tl.check_alerts().is_empty(),
"diverse model turn should not alert"
);
}
#[test]
fn test_timeline_alert_for_repetitive_model_turn() {
let mut tl = EntropyTimeline::new(2.0);
tl.push("sorry sorry sorry sorry sorry sorry sorry sorry", false);
let alerts = tl.check_alerts();
assert!(
!alerts.is_empty(),
"repetitive model turn should fire alert"
);
}
#[test]
fn test_timeline_user_turns_never_alert() {
let mut tl = EntropyTimeline::new(10.0); tl.push("ok", true); assert!(
tl.check_alerts().is_empty(),
"user turns should never trigger alerts"
);
}
#[test]
fn test_timeline_rolling_entropy_none_when_empty() {
let tl = EntropyTimeline::new(1.5);
assert!(tl.rolling_entropy(3).is_none());
}
#[test]
fn test_timeline_rolling_entropy_value() {
let mut tl = EntropyTimeline::new(1.5);
tl.push("the quick brown fox", false);
tl.push("hello world goodbye", false);
let avg = tl.rolling_entropy(2);
assert!(avg.is_some());
assert!(avg.unwrap() > 0.0);
}
#[test]
fn test_prompt_entropy_analyse() {
let corpus = "the quick brown fox jumps over the lazy dog".split_whitespace();
let pe = PromptEntropy::new(corpus, 1.5);
let stats = pe.analyse("the quick brown fox");
assert!(stats.token_entropy >= 0.0);
assert!(stats.char_entropy >= 0.0);
assert!(stats.perplexity > 0.0);
}
#[test]
fn test_prompt_entropy_push_turn_adds_to_timeline() {
let corpus = "a b c d e".split_whitespace();
let mut pe = PromptEntropy::new(corpus, 1.5);
pe.push_turn("hello world", true);
pe.push_turn("how are you today", false);
assert_eq!(pe.timeline.len(), 2);
}
#[test]
fn test_prompt_entropy_repetitive_text_flagged() {
let corpus = "the the the".split_whitespace();
let pe = PromptEntropy::new(corpus, 1.5);
let stats = pe.analyse("the the the the the the the the the the");
assert!(
stats.repetition.is_repetitive,
"all-same-token text must be flagged"
);
}
#[test]
fn test_entropy_alert_display() {
let alert = EntropyAlert {
turn: 3,
token_entropy: 0.8,
threshold: 1.5,
repetition_rate: 0.72,
};
let s = alert.to_string();
assert!(s.contains("turn=3"));
assert!(s.contains("0.800"));
}
}