use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenAttribution {
pub token: String,
pub position: usize,
pub confidence: f32,
pub perplexity: f32,
pub attribution_score: f32,
pub top_alternatives: Vec<(String, f32)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SequenceAttribution {
pub prompt: String,
pub model: String,
pub timestamp: String,
pub tokens: Vec<TokenAttribution>,
pub aggregate_confidence: f32,
pub aggregate_perplexity: f32,
pub semantic_drift: f32,
}
impl SequenceAttribution {
pub fn recompute_aggregates(&mut self) {
let exporter = AttributionExporter::new();
self.semantic_drift = exporter.detect_drift(self);
if self.tokens.is_empty() {
self.aggregate_confidence = 0.0;
self.aggregate_perplexity = 0.0;
} else {
let n = self.tokens.len() as f32;
self.aggregate_confidence = self.tokens.iter().map(|t| t.confidence).sum::<f32>() / n;
self.aggregate_perplexity = self.tokens.iter().map(|t| t.perplexity).sum::<f32>() / n;
}
}
}
pub struct AttributionExporter;
impl Default for AttributionExporter {
fn default() -> Self {
Self::new()
}
}
impl AttributionExporter {
pub fn new() -> Self {
AttributionExporter
}
pub fn to_jsonl(&self, sequences: &[SequenceAttribution]) -> String {
sequences
.iter()
.filter_map(|seq| serde_json::to_string(seq).ok())
.collect::<Vec<_>>()
.join("\n")
}
pub fn to_csv(&self, seq: &SequenceAttribution) -> String {
let mut out = String::from(
"position,token,confidence,perplexity,attribution_score,\
alt_1,alt_1_prob,alt_2,alt_2_prob,alt_3,alt_3_prob,\
alt_4,alt_4_prob,alt_5,alt_5_prob\n",
);
for t in &seq.tokens {
let token_escaped = csv_escape(&t.token);
out.push_str(&format!(
"{},{},{:.6},{:.6},{:.6}",
t.position, token_escaped, t.confidence, t.perplexity, t.attribution_score,
));
for i in 0..5 {
if let Some((alt_tok, alt_prob)) = t.top_alternatives.get(i) {
out.push_str(&format!(",{},{:.6}", csv_escape(alt_tok), alt_prob));
} else {
out.push_str(",,");
}
}
out.push('\n');
}
out
}
pub fn to_html_heatmap(&self, seq: &SequenceAttribution) -> String {
let token_spans: String = seq
.tokens
.iter()
.map(|t| {
let hue = confidence_to_hue(t.confidence);
let bg = format!("hsl({hue},80%,35%)");
let alts = t
.top_alternatives
.iter()
.map(|(tok, p)| format!("{tok}: {p:.3}"))
.collect::<Vec<_>>()
.join(" ");
let tooltip = format!(
"pos:{pos} conf:{conf:.3} ppl:{ppl:.3} attr:{attr:.3} alts:{alts}",
pos = t.position,
conf = t.confidence,
ppl = t.perplexity,
attr = t.attribution_score,
alts = alts,
);
let display_tok = html_escape(&t.token);
format!(
r#"<span class="tok" style="background:{bg}" title="{tooltip}">{display_tok}</span>"#
)
})
.collect();
let high_conf: Vec<usize>;
let low_conf: Vec<usize>;
(high_conf, low_conf) = self.confidence_extremes(seq);
let high_list = high_conf
.iter()
.map(|&i| {
seq.tokens
.get(i)
.map(|t| format!("#{i} “{}” ({:.3})", html_escape(&t.token), t.confidence))
.unwrap_or_default()
})
.collect::<Vec<_>>()
.join(", ");
let low_list = low_conf
.iter()
.map(|&i| {
seq.tokens
.get(i)
.map(|t| format!("#{i} “{}” ({:.3})", html_escape(&t.token), t.confidence))
.unwrap_or_default()
})
.collect::<Vec<_>>()
.join(", ");
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Token Attribution Heatmap — {model}</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: 'Segoe UI', system-ui, sans-serif; background: #0d0d0d; color: #e8e8e8; padding: 1.5rem; }}
h1 {{ font-size: 1.2rem; margin-bottom: 0.4rem; color: #aaffaa; }}
.meta {{ font-size: 0.8rem; color: #888; margin-bottom: 1rem; }}
.prompt {{ background: #1a1a2e; border-left: 3px solid #4488ff; padding: 0.6rem 0.8rem;
font-style: italic; border-radius: 3px; margin-bottom: 1rem; white-space: pre-wrap; }}
.heatmap {{ line-height: 2.2; word-spacing: 2px; }}
.tok {{ display: inline-block; padding: 2px 4px; border-radius: 3px; margin: 1px;
cursor: default; font-family: 'Courier New', monospace; font-size: 0.9rem;
white-space: pre; color: #fff; transition: filter 0.1s; }}
.tok:hover {{ filter: brightness(1.4); }}
.stats {{ margin-top: 1.2rem; font-size: 0.85rem; color: #ccc; }}
.stats table {{ border-collapse: collapse; }}
.stats td {{ padding: 0.2rem 0.8rem 0.2rem 0; }}
.stats td:first-child {{ color: #888; }}
.extremes {{ margin-top: 0.8rem; font-size: 0.8rem; color: #aaa; }}
.extremes b {{ color: #88ddff; }}
.legend {{ margin-top: 0.8rem; display: flex; gap: 6px; align-items: center; font-size: 0.78rem; color: #888; }}
.swatch {{ width: 60px; height: 14px; border-radius: 2px; background: linear-gradient(to right, hsl(0,80%,35%), hsl(120,80%,35%)); }}
</style>
</head>
<body>
<h1>Token Attribution Heatmap</h1>
<div class="meta">Model: <b>{model}</b> | Generated: {timestamp}</div>
<div class="prompt">{prompt_display}</div>
<div class="heatmap">{token_spans}</div>
<div class="stats">
<table>
<tr><td>Tokens</td><td>{token_count}</td></tr>
<tr><td>Aggregate confidence</td><td>{agg_conf:.4}</td></tr>
<tr><td>Aggregate perplexity</td><td>{agg_ppl:.4}</td></tr>
<tr><td>Semantic drift</td><td>{drift:.4}</td></tr>
</table>
</div>
<div class="extremes">
<b>Most confident:</b> {high_list}<br>
<b>Least confident:</b> {low_list}
</div>
<div class="legend">
<div class="swatch"></div>
<span>low confidence → high confidence</span>
</div>
</body>
</html>
"#,
model = html_escape(&seq.model),
timestamp = html_escape(&seq.timestamp),
prompt_display = html_escape(&seq.prompt),
token_count = seq.tokens.len(),
agg_conf = seq.aggregate_confidence,
agg_ppl = seq.aggregate_perplexity,
drift = seq.semantic_drift,
token_spans = token_spans,
high_list = high_list,
low_list = low_list,
)
}
pub fn detect_drift(&self, seq: &SequenceAttribution) -> f32 {
let tokens = &seq.tokens;
if tokens.len() < 2 {
return 0.0;
}
let mid = tokens.len() / 2;
let first_half_mean = tokens[..mid]
.iter()
.map(|t| t.confidence)
.sum::<f32>()
/ mid as f32;
let second_half_mean = tokens[mid..]
.iter()
.map(|t| t.confidence)
.sum::<f32>()
/ (tokens.len() - mid) as f32;
first_half_mean - second_half_mean
}
pub fn confidence_extremes(
&self,
seq: &SequenceAttribution,
) -> (Vec<usize>, Vec<usize>) {
if seq.tokens.is_empty() {
return (vec![], vec![]);
}
let mut indexed: Vec<(usize, f32)> = seq
.tokens
.iter()
.map(|t| (t.position, t.confidence))
.collect();
indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let top_n = indexed.len().min(5);
let most_confident: Vec<usize> = indexed[..top_n].iter().map(|(i, _)| *i).collect();
let least_confident: Vec<usize> = indexed[indexed.len() - top_n..]
.iter()
.rev()
.map(|(i, _)| *i)
.collect();
(most_confident, least_confident)
}
}
fn confidence_to_hue(confidence: f32) -> u32 {
(confidence.clamp(0.0, 1.0) * 120.0).round() as u32
}
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
fn make_seq() -> SequenceAttribution {
SequenceAttribution {
prompt: "What is recursion?".into(),
model: "gpt-4o".into(),
timestamp: "2026-01-01T00:00:00Z".into(),
tokens: vec![
TokenAttribution {
token: "Recursion".into(),
position: 0,
confidence: 0.95,
perplexity: 1.05,
attribution_score: 0.4,
top_alternatives: vec![
("Iteration".into(), 0.03),
("Repetition".into(), 0.01),
],
},
TokenAttribution {
token: "is".into(),
position: 1,
confidence: 0.80,
perplexity: 1.25,
attribution_score: 0.1,
top_alternatives: vec![("was".into(), 0.10)],
},
TokenAttribution {
token: "a".into(),
position: 2,
confidence: 0.50,
perplexity: 2.00,
attribution_score: 0.05,
top_alternatives: vec![],
},
TokenAttribution {
token: "technique".into(),
position: 3,
confidence: 0.40,
perplexity: 2.50,
attribution_score: 0.2,
top_alternatives: vec![("method".into(), 0.30)],
},
],
aggregate_confidence: 0.6625,
aggregate_perplexity: 1.7,
semantic_drift: 0.275,
}
}
#[test]
fn to_jsonl_produces_valid_json_lines() {
let exporter = AttributionExporter::new();
let seq = make_seq();
let output = exporter.to_jsonl(&[seq.clone(), seq]);
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 2);
for line in lines {
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert!(parsed.get("prompt").is_some());
assert!(parsed.get("tokens").is_some());
}
}
#[test]
fn to_jsonl_empty_slice() {
let exporter = AttributionExporter::new();
let out = exporter.to_jsonl(&[]);
assert!(out.is_empty());
}
#[test]
fn to_csv_header_and_rows() {
let exporter = AttributionExporter::new();
let seq = make_seq();
let csv = exporter.to_csv(&seq);
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(lines.len(), 5);
assert!(lines[0].starts_with("position,token,confidence"));
assert!(lines[1].contains("Recursion"));
}
#[test]
fn to_csv_escapes_commas() {
let exporter = AttributionExporter::new();
let mut seq = make_seq();
seq.tokens[0].token = "hello, world".into();
let csv = exporter.to_csv(&seq);
assert!(csv.contains("\"hello, world\""));
}
#[test]
fn to_html_heatmap_is_valid_html() {
let exporter = AttributionExporter::new();
let seq = make_seq();
let html = exporter.to_html_heatmap(&seq);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Recursion"));
assert!(html.contains("hsl("));
assert!(!html.contains("src=\"http"));
}
#[test]
fn detect_drift_positive_for_declining_confidence() {
let exporter = AttributionExporter::new();
let seq = make_seq();
let drift = exporter.detect_drift(&seq);
assert!(drift > 0.0, "Expected positive drift, got {drift}");
}
#[test]
fn detect_drift_zero_for_single_token() {
let exporter = AttributionExporter::new();
let mut seq = make_seq();
seq.tokens.truncate(1);
assert_eq!(exporter.detect_drift(&seq), 0.0);
}
#[test]
fn confidence_extremes_returns_sorted_indices() {
let exporter = AttributionExporter::new();
let seq = make_seq();
let (high, low) = exporter.confidence_extremes(&seq);
assert!(!high.is_empty());
assert!(!low.is_empty());
assert_eq!(high[0], 0);
assert_eq!(low[0], 3);
}
#[test]
fn confidence_extremes_empty_sequence() {
let exporter = AttributionExporter::new();
let mut seq = make_seq();
seq.tokens.clear();
let (high, low) = exporter.confidence_extremes(&seq);
assert!(high.is_empty());
assert!(low.is_empty());
}
#[test]
fn recompute_aggregates_matches_manual() {
let mut seq = make_seq();
seq.recompute_aggregates();
let expected_mean = (0.95 + 0.80 + 0.50 + 0.40) / 4.0;
assert!((seq.aggregate_confidence - expected_mean).abs() < 1e-4);
}
#[test]
fn html_escape_handles_specials() {
assert_eq!(html_escape("<b>"), "<b>");
assert_eq!(html_escape("\"hello\""), ""hello"");
assert_eq!(html_escape("AT&T"), "AT&T");
}
#[test]
fn csv_escape_handles_embedded_quotes() {
assert_eq!(csv_escape("say \"hello\""), "\"say \"\"hello\"\"\"");
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CausalAttribution {
pub token_idx: usize,
pub token_text: String,
pub attribution_scores: Vec<(usize, f64)>,
}
#[derive(Debug)]
pub struct AttributionMap {
pub input_tokens: Vec<String>,
pub baseline_confidences: Vec<f64>,
masked_runs: Vec<(usize, Vec<f64>)>,
}
impl AttributionMap {
pub fn new(input_tokens: Vec<String>, baseline_confidences: Vec<f64>) -> Self {
Self {
input_tokens,
baseline_confidences,
masked_runs: Vec::new(),
}
}
pub fn add_masked_run(&mut self, masked_input_idx: usize, masked_confidences: Vec<f64>) {
if masked_confidences.len() == self.baseline_confidences.len() {
self.masked_runs.push((masked_input_idx, masked_confidences));
}
}
pub fn compute(&self) -> Vec<CausalAttribution> {
let n_output = self.baseline_confidences.len();
let mut attributions: Vec<CausalAttribution> = (0..n_output)
.map(|j| CausalAttribution {
token_idx: j,
token_text: format!("<output@{j}>"),
attribution_scores: Vec::new(),
})
.collect();
for (input_idx, masked_confs) in &self.masked_runs {
for (j, attr) in attributions.iter_mut().enumerate() {
let baseline = self.baseline_confidences[j];
let masked = masked_confs[j];
let score = baseline - masked;
attr.attribution_scores.push((*input_idx, score));
}
}
for attr in &mut attributions {
attr.attribution_scores.sort_by(|(_, a), (_, b)| {
b.abs().partial_cmp(&a.abs()).unwrap_or(std::cmp::Ordering::Equal)
});
}
attributions
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
#[derive(serde::Serialize)]
struct MapExport<'a> {
input_tokens: &'a [String],
baseline_confidences: &'a [f64],
attributions: Vec<CausalAttribution>,
}
let export = MapExport {
input_tokens: &self.input_tokens,
baseline_confidences: &self.baseline_confidences,
attributions: self.compute(),
};
serde_json::to_string_pretty(&export)
}
}
pub struct AttributionRenderer {
pub top_k: usize,
}
impl Default for AttributionRenderer {
fn default() -> Self {
Self::new()
}
}
impl AttributionRenderer {
pub fn new() -> Self {
Self { top_k: 5 }
}
#[must_use]
pub fn with_top_k(mut self, k: usize) -> Self {
self.top_k = k;
self
}
pub fn render_to_string(
&self,
attributions: &[CausalAttribution],
input_tokens: &[String],
) -> String {
let mut out = String::new();
for attr in attributions {
out.push_str(&format!(
"\n Output token #{}: \"{}\"\n",
attr.token_idx, attr.token_text
));
if attr.attribution_scores.is_empty() {
out.push_str(" (no masked runs recorded)\n");
continue;
}
for (input_idx, score) in attr.attribution_scores.iter().take(self.top_k) {
let label = input_tokens
.get(*input_idx)
.map(|s| s.as_str())
.unwrap_or("?");
let bar = self.score_bar(*score);
let color = self.ansi_color(*score);
let reset = "\x1b[0m";
out.push_str(&format!(
" [{:>2}] {:>12} {} {:.4}{}\n",
input_idx,
label,
color,
score,
reset
));
out.push_str(&format!(" {}{}{}\n", color, bar, reset));
}
}
out
}
pub fn render(&self, attributions: &[CausalAttribution], input_tokens: &[String]) {
print!("{}", self.render_to_string(attributions, input_tokens));
}
fn score_bar(&self, score: f64) -> String {
let width = (score.abs() * 40.0).round() as usize;
let ch = if score >= 0.0 { '+' } else { '-' };
std::iter::repeat(ch).take(width.min(40)).collect()
}
fn ansi_color(&self, score: f64) -> &'static str {
if score > 0.3 {
"\x1b[92m" } else if score > 0.1 {
"\x1b[93m" } else if score > -0.1 {
"\x1b[97m" } else {
"\x1b[91m" }
}
}
#[cfg(test)]
mod attribution_map_tests {
use super::*;
#[test]
fn attribution_map_basic_loo() {
let input_tokens = vec!["What".to_string(), "is".to_string(), "gravity".to_string()];
let baseline = vec![0.9, 0.85, 0.7];
let mut map = AttributionMap::new(input_tokens.clone(), baseline.clone());
map.add_masked_run(2, vec![0.5, 0.4, 0.3]);
map.add_masked_run(0, vec![0.85, 0.80, 0.65]);
let attrs = map.compute();
assert_eq!(attrs.len(), 3, "one attribution per output token");
let scores_0 = &attrs[0].attribution_scores;
assert!(!scores_0.is_empty());
let (top_idx, top_score) = scores_0[0];
assert_eq!(top_idx, 2, "gravity should be most influential");
assert!((top_score - 0.4).abs() < 1e-9);
}
#[test]
fn attribution_map_mismatched_run_is_ignored() {
let input_tokens = vec!["A".to_string(), "B".to_string()];
let baseline = vec![0.8, 0.6];
let mut map = AttributionMap::new(input_tokens, baseline);
map.add_masked_run(0, vec![0.5]);
let attrs = map.compute();
for attr in &attrs {
assert!(attr.attribution_scores.is_empty());
}
}
#[test]
fn attribution_map_to_json_is_valid() {
let input_tokens = vec!["Hello".to_string()];
let baseline = vec![0.9];
let mut map = AttributionMap::new(input_tokens, baseline);
map.add_masked_run(0, vec![0.5]);
let json = map.to_json().expect("JSON serialization should succeed");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert!(parsed.get("attributions").is_some());
assert!(parsed.get("input_tokens").is_some());
assert!(parsed.get("baseline_confidences").is_some());
}
#[test]
fn renderer_produces_colored_output() {
let input_tokens = vec!["token0".to_string(), "token1".to_string()];
let baseline = vec![0.8];
let mut map = AttributionMap::new(input_tokens.clone(), baseline);
map.add_masked_run(0, vec![0.3]);
let renderer = AttributionRenderer::new();
let attrs = map.compute();
let output = renderer.render_to_string(&attrs, &input_tokens);
assert!(output.contains("Output token #0"));
assert!(output.contains("\x1b[92m"), "expected green ANSI color for high positive score");
}
#[test]
fn renderer_top_k_limits_output() {
let input_tokens: Vec<String> = (0..10).map(|i| format!("tok{i}")).collect();
let baseline = vec![0.5; 1];
let mut map = AttributionMap::new(input_tokens.clone(), baseline);
for i in 0..10 {
map.add_masked_run(i, vec![0.5 - (i as f64 * 0.01)]);
}
let renderer = AttributionRenderer::new().with_top_k(3);
let attrs = map.compute();
let output = renderer.render_to_string(&attrs, &input_tokens);
let bar_lines = output.lines().filter(|l| l.contains('[') && l.contains(']')).count();
assert!(bar_lines <= 3, "top_k=3 should limit to 3 bars, got {bar_lines}");
}
}