1use colored::Colorize;
9use std::fmt::Write as _;
10
11use crate::detect::{LogprobToken, Report, Span};
12
13#[derive(Debug, Clone, Default)]
15pub struct Meta {
16 pub source: String,
18 pub model: Option<String>,
20}
21
22impl Meta {
23 pub fn from_response(source: impl Into<String>, json: &str) -> Self {
25 let model = serde_json::from_str::<serde_json::Value>(json)
26 .ok()
27 .and_then(|v| v.get("model")?.as_str().map(str::to_string));
28 Meta {
29 source: source.into(),
30 model,
31 }
32 }
33}
34
35pub fn heat(p: f64) -> (u8, u8, u8) {
38 const STOPS: [(f64, (f64, f64, f64)); 4] = [
39 (0.25, (232.0, 72.0, 48.0)),
40 (0.55, (240.0, 140.0, 52.0)),
41 (0.80, (236.0, 192.0, 92.0)),
42 (0.97, (214.0, 208.0, 196.0)),
43 ];
44 let p = p.clamp(0.0, 1.0);
45 if p <= STOPS[0].0 {
46 let c = STOPS[0].1;
47 return (c.0 as u8, c.1 as u8, c.2 as u8);
48 }
49 for w in STOPS.windows(2) {
50 let ((p0, a), (p1, b)) = (w[0], w[1]);
51 if p <= p1 {
52 let t = (p - p0) / (p1 - p0);
53 let mix = |x: f64, y: f64| (x + (y - x) * t).round() as u8;
54 return (mix(a.0, b.0), mix(a.1, b.1), mix(a.2, b.2));
55 }
56 }
57 let c = STOPS[3].1;
58 (c.0 as u8, c.1 as u8, c.2 as u8)
59}
60
61fn span_index(tokens: &[LogprobToken], report: &Report) -> Vec<usize> {
63 let mut idx = vec![0; tokens.len()];
64 for (n, s) in report.spans.iter().enumerate() {
65 for slot in idx.iter_mut().take(s.end).skip(s.start) {
66 *slot = n + 1;
67 }
68 }
69 idx
70}
71
72fn summary(report: &Report) -> String {
73 match report.spans.len() {
74 0 => "No low-confidence spans. That does not prove the answer is correct: \
75 models can be confidently wrong."
76 .to_string(),
77 1 => "1 low-confidence span. Check it before trusting the answer.".to_string(),
78 n => format!("{n} low-confidence spans. Check these claims before trusting the answer."),
79 }
80}
81
82fn candidates(span: &Span) -> Vec<(String, f64, bool)> {
85 let mut out: Vec<(String, f64, bool)> = span
86 .alternatives
87 .iter()
88 .map(|c| (c.token.clone(), c.prob, c.token == span.weakest_token))
89 .collect();
90 if !out.iter().any(|c| c.2) {
91 out.push((span.weakest_token.clone(), span.min_prob, true));
92 }
93 out.sort_by(|a, b| b.1.total_cmp(&a.1));
94 out
95}
96
97fn token_range(s: &Span) -> String {
98 if s.end - s.start == 1 {
99 format!("token {}", s.start)
100 } else {
101 format!("tokens {}-{}", s.start, s.end - 1)
102 }
103}
104
105fn show(token: &str) -> String {
107 format!("{token:?}")
108}
109
110fn piece_for(text: &str, p: f64) -> colored::ColoredString {
115 if p >= 0.9 {
116 text.normal()
117 } else {
118 let (r, g, b) = heat(p);
119 text.truecolor(r, g, b)
120 }
121}
122
123pub fn terminal(tokens: &[LogprobToken], report: &Report, meta: &Meta) -> String {
125 let mut out = String::new();
126 let spans = span_index(tokens, report);
127
128 let mut head = vec![meta.source.clone()];
129 if let Some(m) = &meta.model {
130 head.push(m.clone());
131 }
132 head.push(format!("{} tokens", report.token_count));
133 head.push(format!("mean p {:.2}", report.mean_prob));
134 head.push(format!("threshold {:.2}", report.threshold));
135 let _ = writeln!(out, "\n {}\n", head.join(" ").dimmed());
136
137 let mut line = String::from(" ");
139 let mut col = 0usize;
140 for (i, t) in tokens.iter().enumerate() {
141 let len = t.token.chars().count();
142 let text = if col + len > 72 && t.token.starts_with(' ') {
143 out.push_str(&line);
144 out.push('\n');
145 line = String::from(" ");
146 col = 0;
147 t.token.trim_start().to_string()
148 } else {
149 t.token.clone()
150 };
151 col += text.chars().count();
152 let body = text.trim_start();
154 line.push_str(&text[..text.len() - body.len()]);
155 let mut piece = piece_for(body, t.prob());
156 if spans[i] > 0 {
157 piece = piece.bold().underline();
158 }
159 line.push_str(&piece.to_string());
160 }
161 out.push_str(&line);
162 out.push_str("\n\n");
163
164 let width = report
166 .spans
167 .iter()
168 .flat_map(candidates)
169 .map(|c| show(&c.0).chars().count())
170 .max()
171 .unwrap_or(0);
172 for (n, s) in report.spans.iter().enumerate() {
173 let (r, g, b) = heat(s.min_prob);
174 let _ = writeln!(
175 out,
176 " {} {} {}",
177 format!("{}", n + 1).truecolor(r, g, b).bold(),
178 s.text.trim().bold(),
179 format!(
180 "{}, weakest {} at p {:.2}",
181 token_range(s),
182 show(&s.weakest_token),
183 s.min_prob
184 )
185 .dimmed()
186 );
187 for (tok, p, chosen) in candidates(s) {
188 let filled = (p * 24.0).round() as usize;
189 let (r, g, b) = heat(p);
190 let bar = format!(
191 "{}{}",
192 "\u{2588}".repeat(filled).truecolor(r, g, b),
193 "\u{2591}".repeat(24 - filled.min(24)).dimmed()
194 );
195 let label = format!("{:<width$}", show(&tok));
196 let note = if chosen { " chosen" } else { "" };
197 let _ = writeln!(
198 out,
199 " {} {} {:.2}{}",
200 if chosen { label.bold() } else { label.normal() },
201 bar,
202 p,
203 note.dimmed()
204 );
205 }
206 out.push('\n');
207 }
208
209 let msg = summary(report);
210 let _ = writeln!(
211 out,
212 " {}",
213 if report.flagged() {
214 msg.truecolor(240, 140, 52)
215 } else {
216 msg.normal()
217 }
218 );
219 out
220}
221
222fn md_escape(s: &str) -> String {
225 let mut o = String::with_capacity(s.len());
226 for c in s.chars() {
227 if "\\`*_[]<>|#~".contains(c) {
228 o.push('\\');
229 }
230 o.push(c);
231 }
232 o
233}
234
235pub fn markdown(tokens: &[LogprobToken], report: &Report, meta: &Meta) -> String {
237 let mut md = String::new();
238 let spans = span_index(tokens, report);
239 let _ = writeln!(md, "### Token confidence report\n");
240 let mut facts = vec![format!("`{}`", meta.source)];
241 if let Some(m) = &meta.model {
242 facts.push(format!("`{m}`"));
243 }
244 facts.push(format!("{} tokens", report.token_count));
245 facts.push(format!("mean p {:.2}", report.mean_prob));
246 facts.push(format!("threshold {:.2}", report.threshold));
247 let _ = writeln!(md, "{}\n", facts.join(" · "));
248
249 let mut body = String::new();
250 let mut i = 0;
251 while i < tokens.len() {
252 if spans[i] == 0 {
253 body.push_str(&md_escape(&tokens[i].token));
254 i += 1;
255 continue;
256 }
257 let n = spans[i];
258 let s = &report.spans[n - 1];
259 let text: String = tokens[s.start..s.end]
260 .iter()
261 .map(|t| t.token.as_str())
262 .collect();
263 let lead = &text[..text.len() - text.trim_start().len()];
264 let _ = write!(
265 body,
266 "{lead}**{}**<sup>{n}</sup>",
267 md_escape(text.trim_start())
268 );
269 i = s.end;
270 }
271 let _ = writeln!(md, "> {}\n", body.trim());
272
273 if report.flagged() {
274 md.push_str("| # | Span | Weakest token | p | Model also considered |\n");
275 md.push_str("|---|---|---|---|---|\n");
276 for (n, s) in report.spans.iter().enumerate() {
277 let others: Vec<String> = candidates(s)
278 .into_iter()
279 .filter(|c| !c.2)
280 .map(|c| format!("`{}` {:.2}", c.0.replace('`', "'"), c.1))
281 .collect();
282 let _ = writeln!(
283 md,
284 "| {} | {} | `{}` | {:.2} | {} |",
285 n + 1,
286 md_escape(s.text.trim()),
287 s.weakest_token.replace('`', "'"),
288 s.min_prob,
289 if others.is_empty() {
290 "n/a".to_string()
291 } else {
292 others.join(", ")
293 }
294 );
295 }
296 md.push('\n');
297 }
298 let _ = writeln!(md, "{}", summary(report));
299 md
300}
301
302fn esc(s: &str) -> String {
305 let mut o = String::with_capacity(s.len());
306 for c in s.chars() {
307 match c {
308 '&' => o.push_str("&"),
309 '<' => o.push_str("<"),
310 '>' => o.push_str(">"),
311 '"' => o.push_str("""),
312 '\'' => o.push_str("'"),
313 _ => o.push(c),
314 }
315 }
316 o
317}
318
319const CSS: &str = r#"
320:root{--bg:#f4efe4;--card:#fbf8f1;--ink:#1d1b17;--mute:#6f6a5f;--rule:#ddd5c4;--flag:#d9432b;--alpha:.62}
321@media (prefers-color-scheme:dark){:root{--bg:#141311;--card:#1b1a17;--ink:#ebe5d8;--mute:#9a9385;--rule:#302d27;--flag:#f0654a;--alpha:.5}}
322*{box-sizing:border-box}
323body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
324main{max-width:860px;margin:0 auto;padding:48px 20px 64px}
325.k{font:12px/1.4 ui-monospace,"SF Mono","Cascadia Mono",Consolas,monospace;letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
326.meta{text-transform:none;letter-spacing:.02em;display:flex;flex-wrap:wrap;gap:6px 18px;margin:10px 0 28px}
327.meta b{color:var(--ink);font-weight:600}
328.answer{font:clamp(22px,3.4vw,30px)/1.75 "Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;margin:0 0 20px;padding:28px 30px;background:var(--card);border:1px solid var(--rule);border-radius:14px}
329.t{position:relative;border-radius:4px;background:rgba(var(--h),calc(var(--a)*var(--alpha)));padding:.06em 0}
330.t:hover,.t:focus{outline:1.5px solid rgb(var(--h));outline-offset:1px}
331.t:hover::after,.t:focus::after{content:attr(data-tip);position:absolute;left:0;top:calc(100% + 6px);z-index:2;white-space:pre;background:var(--ink);color:var(--bg);font:12px/1.5 ui-monospace,Consolas,monospace;padding:6px 9px;border-radius:6px;pointer-events:none}
332.f{text-decoration:underline wavy var(--flag);text-decoration-thickness:1.5px;text-underline-offset:.28em;text-decoration-skip-ink:none}
333sup{font:600 11px ui-monospace,Consolas,monospace;color:var(--flag);margin-left:1px}
334.trace{justify-content:center;display:flex;align-items:flex-end;gap:2px;height:44px;margin:0 0 36px;padding:0 4px}
335.trace i{flex:1;max-width:26px;min-width:2px;border-radius:2px 2px 0 0;background:rgb(var(--h))}
336.spans{display:grid;gap:14px}
337.span{background:var(--card);border:1px solid var(--rule);border-radius:12px;padding:16px 18px}
338.span h3{margin:0 0 10px;font-size:17px;display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
339.span h3 .k{text-transform:none;letter-spacing:0}
340.span h3 .n{font:600 13px ui-monospace,Consolas,monospace;color:var(--flag)}
341.row{display:grid;grid-template-columns:minmax(90px,max-content) 1fr 44px;gap:12px;align-items:center;font:14px ui-monospace,Consolas,monospace;margin:4px 0}
342.bar{height:10px;border-radius:5px;background:var(--rule);overflow:hidden}
343.bar i{display:block;height:100%;background:rgb(var(--h))}
344.row.c{font-weight:700}
345.p{text-align:right;color:var(--mute)}
346.verdict{margin:30px 0 0;padding-top:18px;border-top:1px solid var(--rule)}
347.scale{display:flex;align-items:center;gap:10px;margin-top:14px}
348.scale span.g{height:8px;width:180px;border-radius:4px;background:linear-gradient(90deg,rgb(232,72,48),rgb(240,140,52) 40%,rgb(236,192,92) 76%,rgb(214,208,196))}
349footer{margin-top:40px}
350footer a{color:inherit}
351"#;
352
353pub fn html(tokens: &[LogprobToken], report: &Report, meta: &Meta) -> String {
356 let spans = span_index(tokens, report);
357 let mut h = String::new();
358 let _ = write!(
359 h,
360 "<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\
361 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
362 <meta name=\"color-scheme\" content=\"light dark\">\
363 <title>Token confidence: {}</title><style>{}</style></head><body><main>",
364 esc(&meta.source),
365 CSS
366 );
367 let _ = write!(h, "<div class=\"k\">Token confidence report</div>");
368 let _ = write!(
369 h,
370 "<div class=\"meta k\"><span><b>{}</b></span>",
371 esc(&meta.source)
372 );
373 if let Some(m) = &meta.model {
374 let _ = write!(h, "<span>{}</span>", esc(m));
375 }
376 let _ = write!(
377 h,
378 "<span>{} tokens</span><span>mean p {:.2}</span><span>threshold {:.2}</span>\
379 <span>{} flagged</span></div>",
380 report.token_count,
381 report.mean_prob,
382 report.threshold,
383 report.spans.len()
384 );
385
386 h.push_str("<p class=\"answer\">");
388 for (i, t) in tokens.iter().enumerate() {
389 let n = spans[i];
390 let starts = n > 0 && (i == 0 || spans[i - 1] != n);
391 let ends = n > 0 && (i + 1 == tokens.len() || spans[i + 1] != n);
392 let body = t.token.trim_start();
393 let lead = &t.token[..t.token.len() - body.len()];
394 h.push_str(&esc(lead));
395 if starts {
396 let _ = write!(h, "<span class=\"f\" id=\"s{n}\">");
397 }
398 let p = t.prob();
399 let (r, g, b) = heat(p);
400 let mut tip = format!("p {p:.2} {}", show(&t.token));
401 for a in t.top_logprobs.iter().filter(|a| a.token != t.token) {
402 let _ = write!(tip, "\n {:.2} {}", a.logprob.exp(), show(&a.token));
403 }
404 let _ = write!(
405 h,
406 "<span class=\"t\" tabindex=\"0\" style=\"--h:{r},{g},{b};--a:{:.3}\" data-tip=\"{}\">{}</span>",
407 ((0.95 - p) / 0.95).max(0.0).powf(0.7),
408 esc(&tip),
409 esc(body)
410 );
411 if ends {
412 let _ = write!(h, "</span><sup>{n}</sup>");
413 }
414 }
415 h.push_str("</p>");
416
417 h.push_str("<div class=\"trace\" aria-hidden=\"true\">");
419 for t in tokens {
420 let p = t.prob();
421 let (r, g, b) = heat(p);
422 let _ = write!(
423 h,
424 "<i style=\"--h:{r},{g},{b};height:{:.0}%\" title=\"{} p {:.2}\"></i>",
425 (p * 100.0).max(4.0),
426 esc(&show(&t.token)),
427 p
428 );
429 }
430 h.push_str("</div>");
431
432 if report.flagged() {
433 h.push_str("<div class=\"k\" style=\"margin-bottom:12px\">Flagged spans</div><div class=\"spans\">");
434 for (n, s) in report.spans.iter().enumerate() {
435 let _ = write!(
436 h,
437 "<section class=\"span\"><h3><span class=\"n\">{}</span>{}<span class=\"k\">weakest {} at p {:.2}</span></h3>",
438 n + 1,
439 esc(s.text.trim()),
440 esc(&show(&s.weakest_token)),
441 s.min_prob
442 );
443 for (tok, p, chosen) in candidates(s) {
444 let (r, g, b) = heat(p);
445 let _ = write!(
446 h,
447 "<div class=\"row{}\"><span>{}</span><span class=\"bar\"><i style=\"--h:{r},{g},{b};width:{:.1}%\"></i></span><span class=\"p\">{:.2}</span></div>",
448 if chosen { " c" } else { "" },
449 esc(&show(&tok)),
450 p * 100.0,
451 p
452 );
453 }
454 h.push_str("</section>");
455 }
456 h.push_str("</div>");
457 }
458
459 let _ = writeln!(
460 h,
461 "<div class=\"verdict\">{}<div class=\"scale k\"><span>p 0</span><span class=\"g\"></span><span>p 1</span></div></div>\
462 <footer class=\"k\">Generated by <a href=\"https://github.com/Mattbusel/LLM-Hallucination-Detection-Script\">llm-token-visualizer</a> {}</footer>\
463 </main></body></html>\n",
464 esc(&summary(report)),
465 env!("CARGO_PKG_VERSION")
466 );
467 h
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::detect::{detect, parse_logprobs};
474
475 fn sample() -> (Vec<LogprobToken>, Report, Meta) {
476 let json = include_str!("../examples/logprobs/cuyp.json");
477 let tokens = parse_logprobs(json).unwrap();
478 let report = detect(&tokens, 0.6);
479 (tokens, report, Meta::from_response("cuyp.json", json))
480 }
481
482 #[test]
483 fn meta_reads_model_name() {
484 let (_, _, m) = sample();
485 assert_eq!(m.model.as_deref(), Some("meta-llama/llama-3.1-8b-instruct"));
486 }
487
488 #[test]
489 fn heat_is_monotone_from_red_to_ink() {
490 assert_eq!(heat(0.0), (232, 72, 48));
491 assert_eq!(heat(1.0), (214, 208, 196));
492 let gs: Vec<u8> = [0.2, 0.4, 0.6, 0.8, 0.95]
494 .iter()
495 .map(|&p| heat(p).1)
496 .collect();
497 assert!(gs.windows(2).all(|w| w[0] <= w[1]), "{gs:?}");
498 }
499
500 #[test]
501 fn html_marks_spans_and_alternatives() {
502 let (t, r, m) = sample();
503 let h = html(&t, &r, &m);
504 assert!(h.starts_with("<!DOCTYPE html>"));
505 assert!(h.contains("id=\"s2\""));
506 assert!(h.contains(""üsseldorf""));
507 assert!(h.contains("2 low-confidence spans"));
508 assert_eq!(h.matches("class=\"t\"").count(), t.len());
509 assert!(!h.contains("<script"));
510 }
511
512 #[test]
513 fn html_escapes_token_text() {
514 let tokens = parse_logprobs(r#"[{"token":"<b>&","logprob":-2.0}]"#).unwrap();
515 let r = detect(&tokens, 0.5);
516 let h = html(&tokens, &r, &Meta::default());
517 assert!(h.contains("<b>&"));
518 assert!(!h.contains("<b>&"));
519 }
520
521 #[test]
522 fn markdown_numbers_spans_and_lists_alternatives() {
523 let (t, r, m) = sample();
524 let md = markdown(&t, &r, &m);
525 assert!(md.contains("**Dordrecht**<sup>2</sup>"), "{md}");
526 assert!(md.contains("`üsseldorf` 0.39"), "{md}");
527 assert!(md.contains("| 2 | Dordrecht | `ord` | 0.57 |"), "{md}");
528 }
529
530 #[test]
531 fn terminal_without_color_is_plain_and_complete() {
532 colored::control::set_override(false);
533 let (t, r, m) = sample();
534 let out = terminal(&t, &r, &m);
535 assert!(out.contains("Aelbert Cuyp died in 1691 in Dordrecht, Netherlands."));
536 assert!(out.contains("\"üsseldorf\""));
537 assert!(out.contains("chosen"));
538 assert!(out.contains("2 low-confidence spans"));
539 colored::control::unset_override();
540 }
541
542 #[test]
543 fn confident_answer_still_warns() {
544 let tokens = parse_logprobs(r#"[{"token":"Yes","logprob":-0.01}]"#).unwrap();
545 let r = detect(&tokens, 0.5);
546 assert!(markdown(&tokens, &r, &Meta::default()).contains("confidently wrong"));
547 }
548}