#![allow(dead_code)]
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq)]
pub struct ModelConfig {
pub provider: String,
pub model_name: String,
pub temperature: f64,
pub top_p: f64,
}
impl ModelConfig {
pub fn new(
provider: impl Into<String>,
model_name: impl Into<String>,
temperature: f64,
top_p: f64,
) -> Self {
Self {
provider: provider.into(),
model_name: model_name.into(),
temperature: temperature.clamp(0.0, 2.0),
top_p: top_p.clamp(1e-6, 1.0),
}
}
pub fn label(&self) -> String {
format!("{}/{} t={:.1} p={:.2}", self.provider, self.model_name, self.temperature, self.top_p)
}
}
#[derive(Debug, Clone)]
pub struct TokenStream {
pub config: ModelConfig,
pub tokens: Vec<String>,
pub distributions: Vec<HashMap<String, f64>>,
}
impl TokenStream {
pub fn new(config: ModelConfig) -> Self {
Self {
config,
tokens: Vec::new(),
distributions: Vec::new(),
}
}
pub fn push(&mut self, token: impl Into<String>, dist: Option<HashMap<String, f64>>) {
self.tokens.push(token.into());
self.distributions.push(dist.unwrap_or_default());
}
pub fn len(&self) -> usize {
self.tokens.len()
}
pub fn is_empty(&self) -> bool {
self.tokens.is_empty()
}
pub fn text(&self) -> String {
self.tokens.join("")
}
}
#[derive(Debug, Clone)]
pub struct DivergencePoint {
pub position: usize,
pub disagreement_score: f64,
pub model_tokens: HashMap<String, String>,
}
impl DivergencePoint {
pub fn is_high(&self) -> bool {
self.disagreement_score > 0.5
}
pub fn is_moderate(&self) -> bool {
self.disagreement_score > 0.25 && self.disagreement_score <= 0.5
}
}
#[derive(Debug, Clone)]
pub struct DivergenceResult {
pub prompt: String,
pub configs: Vec<ModelConfig>,
pub token_streams: Vec<TokenStream>,
pub divergence_points: Vec<DivergencePoint>,
}
impl DivergenceResult {
pub fn mean_divergence(&self) -> f64 {
if self.divergence_points.is_empty() {
return 0.0;
}
let sum: f64 = self
.divergence_points
.iter()
.map(|p| p.disagreement_score)
.sum();
sum / self.divergence_points.len() as f64
}
pub fn max_divergence(&self) -> f64 {
self.divergence_points
.iter()
.map(|p| p.disagreement_score)
.fold(0.0f64, f64::max)
}
pub fn high_divergence_positions(&self, threshold: f64) -> Vec<usize> {
self.divergence_points
.iter()
.filter(|p| p.disagreement_score >= threshold)
.map(|p| p.position)
.collect()
}
pub fn render_report(&self) -> String {
let mut out = String::new();
out.push_str("=== Divergence Report ===\n");
out.push_str(&format!("Prompt: {}\n", self.prompt));
out.push_str(&format!("Models: {}\n", self.configs.len()));
for cfg in &self.configs {
out.push_str(&format!(" - {}\n", cfg.label()));
}
out.push_str(&format!(
"Mean JS divergence: {:.4}\n",
self.mean_divergence()
));
out.push_str(&format!(
"Max JS divergence: {:.4}\n",
self.max_divergence()
));
out.push_str(&format!(
"High-divergence positions: {}\n\n",
self.high_divergence_positions(0.5).len()
));
for stream in &self.token_streams {
out.push_str(&format!("\n[{}]\n", stream.config.label()));
let point_map: HashMap<usize, f64> = self
.divergence_points
.iter()
.map(|p| (p.position, p.disagreement_score))
.collect();
for (i, token) in stream.tokens.iter().enumerate() {
let score = point_map.get(&i).copied().unwrap_or(0.0);
let colour = if score > 0.5 {
"\x1b[41m" } else if score > 0.25 {
"\x1b[43m" } else {
"\x1b[42m" };
out.push_str(&format!("{}{}\x1b[0m", colour, token));
}
out.push('\n');
}
out.push_str("\n=== Position Detail ===\n");
for point in &self.divergence_points {
out.push_str(&format!(
" pos {:>4} | JS={:.4} | ",
point.position, point.disagreement_score
));
let mut tokens: Vec<(&str, &str)> = point
.model_tokens
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
tokens.sort_by_key(|(k, _)| *k);
for (label, tok) in tokens {
out.push_str(&format!("{label}={tok:?} "));
}
out.push('\n');
}
out
}
}
pub fn jensen_shannon_divergence(distributions: &[&HashMap<String, f64>]) -> f64 {
let n = distributions.len();
if n < 2 {
return 0.0;
}
let mut vocab: Vec<String> = distributions
.iter()
.flat_map(|d| d.keys().cloned())
.collect();
vocab.sort();
vocab.dedup();
if vocab.is_empty() {
return 0.0;
}
let mixture: Vec<f64> = vocab
.iter()
.map(|tok| {
distributions
.iter()
.map(|d| d.get(tok).copied().unwrap_or(0.0))
.sum::<f64>()
/ n as f64
})
.collect();
let mut jsd = 0.0f64;
for dist in distributions {
let kl: f64 = vocab
.iter()
.enumerate()
.map(|(j, tok)| {
let p = dist.get(tok).copied().unwrap_or(0.0);
let m = mixture[j];
if p > 1e-15 && m > 1e-15 {
p * (p / m).ln()
} else {
0.0
}
})
.sum();
jsd += kl;
}
jsd /= n as f64;
let log_n = (n as f64).ln();
if log_n < 1e-15 {
0.0
} else {
(jsd / log_n).clamp(0.0, 1.0)
}
}
pub struct DivergenceDetector {
pub configs: Vec<ModelConfig>,
pub threshold: f64,
}
impl DivergenceDetector {
pub fn new(configs: Vec<ModelConfig>) -> Self {
Self {
configs,
threshold: 0.1,
}
}
pub fn with_threshold(mut self, threshold: f64) -> Self {
self.threshold = threshold.clamp(0.0, 1.0);
self
}
pub fn analyse(&self, prompt: &str, streams: Vec<TokenStream>) -> DivergenceResult {
let max_len = streams.iter().map(|s| s.len()).max().unwrap_or(0);
let mut divergence_points: Vec<DivergencePoint> = Vec::new();
for pos in 0..max_len {
let mut model_tokens: HashMap<String, String> = HashMap::new();
let mut distributions: Vec<HashMap<String, f64>> = Vec::new();
for stream in &streams {
let label = stream.config.label();
if let Some(tok) = stream.tokens.get(pos) {
model_tokens.insert(label, tok.clone());
}
if let Some(dist) = stream.distributions.get(pos) {
if !dist.is_empty() {
distributions.push(dist.clone());
} else {
if let Some(tok) = stream.tokens.get(pos) {
let mut pm = HashMap::new();
pm.insert(tok.clone(), 1.0);
distributions.push(pm);
}
}
}
}
if distributions.len() < 2 {
continue;
}
let refs: Vec<&HashMap<String, f64>> = distributions.iter().collect();
let score = jensen_shannon_divergence(&refs);
if score >= self.threshold {
divergence_points.push(DivergencePoint {
position: pos,
disagreement_score: score,
model_tokens,
});
}
}
DivergenceResult {
prompt: prompt.to_owned(),
configs: self.configs.clone(),
token_streams: streams,
divergence_points,
}
}
pub fn analyse_raw(
&self,
prompt: &str,
stream_data: Vec<(Vec<String>, Vec<HashMap<String, f64>>)>,
) -> DivergenceResult {
let streams: Vec<TokenStream> = stream_data
.into_iter()
.zip(self.configs.iter())
.map(|((tokens, dists), cfg)| TokenStream {
config: cfg.clone(),
tokens,
distributions: dists,
})
.collect();
self.analyse(prompt, streams)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_dist(pairs: &[(&str, f64)]) -> HashMap<String, f64> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), *v))
.collect()
}
#[test]
fn jsd_identical_distributions_is_zero() {
let d1 = make_dist(&[("hello", 0.7), ("world", 0.3)]);
let d2 = make_dist(&[("hello", 0.7), ("world", 0.3)]);
let score = jensen_shannon_divergence(&[&d1, &d2]);
assert!(score < 1e-10, "identical distributions should give JSD=0, got {score}");
}
#[test]
fn jsd_maximally_different() {
let d1 = make_dist(&[("a", 1.0)]);
let d2 = make_dist(&[("b", 1.0)]);
let score = jensen_shannon_divergence(&[&d1, &d2]);
assert!(
score > 0.99,
"maximally different distributions should give JSD≈1, got {score}"
);
}
#[test]
fn jsd_single_distribution_returns_zero() {
let d = make_dist(&[("a", 1.0)]);
let score = jensen_shannon_divergence(&[&d]);
assert_eq!(score, 0.0);
}
#[test]
fn jsd_empty_returns_zero() {
let score = jensen_shannon_divergence(&[]);
assert_eq!(score, 0.0);
}
#[test]
fn jsd_symmetric() {
let d1 = make_dist(&[("a", 0.8), ("b", 0.2)]);
let d2 = make_dist(&[("a", 0.2), ("b", 0.8)]);
let s12 = jensen_shannon_divergence(&[&d1, &d2]);
let s21 = jensen_shannon_divergence(&[&d2, &d1]);
assert!(
(s12 - s21).abs() < 1e-10,
"JSD should be symmetric: {s12} vs {s21}"
);
}
#[test]
fn model_config_label() {
let cfg = ModelConfig::new("openai", "gpt-4o", 0.7, 0.9);
let label = cfg.label();
assert!(label.contains("openai"));
assert!(label.contains("gpt-4o"));
}
#[test]
fn detector_no_divergence_for_identical_streams() {
let configs = vec![
ModelConfig::new("test", "model-a", 0.0, 1.0),
ModelConfig::new("test", "model-b", 0.0, 1.0),
];
let detector = DivergenceDetector::new(configs.clone()).with_threshold(0.01);
let tokens = vec!["The ".to_string(), "sky ".to_string(), "is ".to_string(), "blue.".to_string()];
let dist = make_dist(&[("The ", 1.0)]);
let stream_data = vec![
(tokens.clone(), vec![dist.clone(), dist.clone(), dist.clone(), dist.clone()]),
(tokens.clone(), vec![dist.clone(), dist.clone(), dist.clone(), dist.clone()]),
];
let result = detector.analyse_raw("Why is the sky blue?", stream_data);
assert!(
result.divergence_points.is_empty(),
"identical streams should produce no divergence points"
);
}
#[test]
fn detector_finds_divergence() {
let configs = vec![
ModelConfig::new("test", "model-a", 0.0, 1.0),
ModelConfig::new("test", "model-b", 0.0, 1.0),
];
let detector = DivergenceDetector::new(configs).with_threshold(0.1);
let stream_data = vec![
(
vec!["cat".to_string()],
vec![make_dist(&[("cat", 1.0)])],
),
(
vec!["dog".to_string()],
vec![make_dist(&[("dog", 1.0)])],
),
];
let result = detector.analyse_raw("What animal?", stream_data);
assert!(
!result.divergence_points.is_empty(),
"different tokens at position 0 should produce a divergence point"
);
assert!(result.divergence_points[0].disagreement_score > 0.5);
}
#[test]
fn divergence_result_mean_max() {
let configs = vec![ModelConfig::new("t", "m", 0.0, 1.0)];
let detector = DivergenceDetector::new(configs).with_threshold(0.0);
let result = DivergenceResult {
prompt: "p".into(),
configs: detector.configs.clone(),
token_streams: vec![],
divergence_points: vec![
DivergencePoint { position: 0, disagreement_score: 0.4, model_tokens: HashMap::new() },
DivergencePoint { position: 1, disagreement_score: 0.8, model_tokens: HashMap::new() },
],
};
assert!((result.mean_divergence() - 0.6).abs() < 1e-10);
assert!((result.max_divergence() - 0.8).abs() < 1e-10);
}
#[test]
fn divergence_point_severity_classification() {
let high = DivergencePoint { position: 0, disagreement_score: 0.7, model_tokens: HashMap::new() };
assert!(high.is_high());
assert!(!high.is_moderate());
let moderate = DivergencePoint { position: 0, disagreement_score: 0.35, model_tokens: HashMap::new() };
assert!(!moderate.is_high());
assert!(moderate.is_moderate());
}
#[test]
fn render_report_contains_model_labels() {
let configs = vec![
ModelConfig::new("openai", "gpt-4o", 0.0, 1.0),
ModelConfig::new("openai", "gpt-4o", 1.0, 1.0),
];
let detector = DivergenceDetector::new(configs);
let stream_data = vec![
(vec!["hello".to_string()], vec![make_dist(&[("hello", 1.0)])]),
(vec!["world".to_string()], vec![make_dist(&[("world", 1.0)])]),
];
let result = detector.analyse_raw("test", stream_data);
let report = result.render_report();
assert!(report.contains("gpt-4o"));
assert!(report.contains("Divergence Report"));
}
#[test]
fn token_stream_text() {
let cfg = ModelConfig::new("t", "m", 0.0, 1.0);
let mut s = TokenStream::new(cfg);
s.push("Hello", None);
s.push(" world", None);
assert_eq!(s.text(), "Hello world");
assert_eq!(s.len(), 2);
}
}