Skip to main content

llm_token_visualizer/
report.rs

1//! Reports for detect mode: a confidence heatmap of the answer, the flagged
2//! spans, and the alternatives the model weighed at each weak token.
3//!
4//! Three formats share one color scale ([`heat`]): a colored terminal report,
5//! a self-contained HTML page (no scripts, no external assets), and Markdown
6//! that reads well in a pull request or issue comment.
7
8use colored::Colorize;
9use std::fmt::Write as _;
10
11use crate::detect::{LogprobToken, Report, Span};
12
13/// Where the answer came from, shown in report headers.
14#[derive(Debug, Clone, Default)]
15pub struct Meta {
16    /// e.g. the input file name, or "live".
17    pub source: String,
18    /// Model name from the response, if it had one.
19    pub model: Option<String>,
20}
21
22impl Meta {
23    /// Read the `model` field from a raw Chat Completions response.
24    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
35/// Map a probability to an RGB color: warm ink for confident tokens, through
36/// amber, to vermilion for the least likely ones.
37pub 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
61/// For each token, the 1-based number of the span it belongs to (0 = none).
62fn 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
82/// The candidates at a span's weakest token, with the chosen token included
83/// and marked, most likely first.
84fn 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
105/// Show a token so leading spaces and empty strings stay visible.
106fn show(token: &str) -> String {
107    format!("{token:?}")
108}
109
110// ---------------------------------------------------------------- terminal
111
112/// Confident tokens keep the terminal's own color so the answer stays
113/// readable on light and dark themes; only doubt gets a heat color.
114fn 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
123/// Colored terminal report. Honors `NO_COLOR` through the `colored` crate.
124pub 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!("perplexity {:.2}", report.perplexity));
135    head.push(format!("threshold {:.2}", report.threshold));
136    let _ = writeln!(out, "\n  {}\n", head.join("   ").dimmed());
137
138    // The answer as a heatmap, wrapped at about 72 columns.
139    let mut line = String::from("  ");
140    let mut col = 0usize;
141    for (i, t) in tokens.iter().enumerate() {
142        let len = t.token.chars().count();
143        let text = if col + len > 72 && t.token.starts_with(' ') {
144            out.push_str(&line);
145            out.push('\n');
146            line = String::from("  ");
147            col = 0;
148            t.token.trim_start().to_string()
149        } else {
150            t.token.clone()
151        };
152        col += text.chars().count();
153        // Keep a flagged word's leading space out of its underline.
154        let body = text.trim_start();
155        line.push_str(&text[..text.len() - body.len()]);
156        let mut piece = piece_for(body, t.prob());
157        if spans[i] > 0 {
158            piece = piece.bold().underline();
159        }
160        line.push_str(&piece.to_string());
161    }
162    out.push_str(&line);
163    out.push_str("\n\n");
164
165    // One label width for every span so the bars line up.
166    let width = report
167        .spans
168        .iter()
169        .flat_map(candidates)
170        .map(|c| show(&c.0).chars().count())
171        .max()
172        .unwrap_or(0);
173    for (n, s) in report.spans.iter().enumerate() {
174        let (r, g, b) = heat(s.min_prob);
175        let _ = writeln!(
176            out,
177            "  {}  {}   {}",
178            format!("{}", n + 1).truecolor(r, g, b).bold(),
179            s.text.trim().bold(),
180            format!(
181                "{}, weakest {} at p {:.2}",
182                token_range(s),
183                show(&s.weakest_token),
184                s.min_prob
185            )
186            .dimmed()
187        );
188        for (tok, p, chosen) in candidates(s) {
189            let filled = (p * 24.0).round() as usize;
190            let (r, g, b) = heat(p);
191            let bar = format!(
192                "{}{}",
193                "\u{2588}".repeat(filled).truecolor(r, g, b),
194                "\u{2591}".repeat(24 - filled.min(24)).dimmed()
195            );
196            let label = format!("{:<width$}", show(&tok));
197            let note = if chosen { "  chosen" } else { "" };
198            let _ = writeln!(
199                out,
200                "     {}  {}  {:.2}{}",
201                if chosen { label.bold() } else { label.normal() },
202                bar,
203                p,
204                note.dimmed()
205            );
206        }
207        out.push('\n');
208    }
209
210    let msg = summary(report);
211    let _ = writeln!(
212        out,
213        "  {}",
214        if report.flagged() {
215            msg.truecolor(240, 140, 52)
216        } else {
217            msg.normal()
218        }
219    );
220    out
221}
222
223// ---------------------------------------------------------------- markdown
224
225fn md_escape(s: &str) -> String {
226    let mut o = String::with_capacity(s.len());
227    for c in s.chars() {
228        if "\\`*_[]<>|#~".contains(c) {
229            o.push('\\');
230        }
231        o.push(c);
232    }
233    o
234}
235
236/// Markdown report: flagged spans in bold with numbered markers, then a table.
237pub fn markdown(tokens: &[LogprobToken], report: &Report, meta: &Meta) -> String {
238    let mut md = String::new();
239    let spans = span_index(tokens, report);
240    let _ = writeln!(md, "### Token confidence report\n");
241    let mut facts = vec![format!("`{}`", meta.source)];
242    if let Some(m) = &meta.model {
243        facts.push(format!("`{m}`"));
244    }
245    facts.push(format!("{} tokens", report.token_count));
246    facts.push(format!("mean p {:.2}", report.mean_prob));
247    facts.push(format!("perplexity {:.2}", report.perplexity));
248    facts.push(format!("threshold {:.2}", report.threshold));
249    let _ = writeln!(md, "{}\n", facts.join(" · "));
250
251    let mut body = String::new();
252    let mut i = 0;
253    while i < tokens.len() {
254        if spans[i] == 0 {
255            body.push_str(&md_escape(&tokens[i].token));
256            i += 1;
257            continue;
258        }
259        let n = spans[i];
260        let s = &report.spans[n - 1];
261        let text: String = tokens[s.start..s.end]
262            .iter()
263            .map(|t| t.token.as_str())
264            .collect();
265        let lead = &text[..text.len() - text.trim_start().len()];
266        let _ = write!(
267            body,
268            "{lead}**{}**<sup>{n}</sup>",
269            md_escape(text.trim_start())
270        );
271        i = s.end;
272    }
273    let _ = writeln!(md, "> {}\n", body.trim());
274
275    if report.flagged() {
276        md.push_str("| # | Span | Weakest token | p | Model also considered |\n");
277        md.push_str("|---|---|---|---|---|\n");
278        for (n, s) in report.spans.iter().enumerate() {
279            let others: Vec<String> = candidates(s)
280                .into_iter()
281                .filter(|c| !c.2)
282                .map(|c| format!("`{}` {:.2}", c.0.replace('`', "'"), c.1))
283                .collect();
284            let _ = writeln!(
285                md,
286                "| {} | {} | `{}` | {:.2} | {} |",
287                n + 1,
288                md_escape(s.text.trim()),
289                s.weakest_token.replace('`', "'"),
290                s.min_prob,
291                if others.is_empty() {
292                    "n/a".to_string()
293                } else {
294                    others.join(", ")
295                }
296            );
297        }
298        md.push('\n');
299    }
300    let _ = writeln!(md, "{}", summary(report));
301    md
302}
303
304// -------------------------------------------------------------------- html
305
306fn esc(s: &str) -> String {
307    let mut o = String::with_capacity(s.len());
308    for c in s.chars() {
309        match c {
310            '&' => o.push_str("&amp;"),
311            '<' => o.push_str("&lt;"),
312            '>' => o.push_str("&gt;"),
313            '"' => o.push_str("&quot;"),
314            '\'' => o.push_str("&#39;"),
315            _ => o.push(c),
316        }
317    }
318    o
319}
320
321const CSS: &str = r#"
322:root{--bg:#f4efe4;--card:#fbf8f1;--ink:#1d1b17;--mute:#6f6a5f;--rule:#ddd5c4;--flag:#d9432b;--alpha:.62}
323@media (prefers-color-scheme:dark){:root{--bg:#141311;--card:#1b1a17;--ink:#ebe5d8;--mute:#9a9385;--rule:#302d27;--flag:#f0654a;--alpha:.5}}
324*{box-sizing:border-box}
325body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
326main{max-width:860px;margin:0 auto;padding:48px 20px 64px}
327.k{font:12px/1.4 ui-monospace,"SF Mono","Cascadia Mono",Consolas,monospace;letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
328.meta{text-transform:none;letter-spacing:.02em;display:flex;flex-wrap:wrap;gap:6px 18px;margin:10px 0 28px}
329.meta b{color:var(--ink);font-weight:600}
330.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}
331.t{position:relative;border-radius:4px;background:rgba(var(--h),calc(var(--a)*var(--alpha)));padding:.06em 0}
332.t:hover,.t:focus{outline:1.5px solid rgb(var(--h));outline-offset:1px}
333.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}
334.f{text-decoration:underline wavy var(--flag);text-decoration-thickness:1.5px;text-underline-offset:.28em;text-decoration-skip-ink:none}
335sup{font:600 11px ui-monospace,Consolas,monospace;color:var(--flag);margin-left:1px}
336.trace{justify-content:center;display:flex;align-items:flex-end;gap:2px;height:44px;margin:0 0 36px;padding:0 4px}
337.trace i{flex:1;max-width:26px;min-width:2px;border-radius:2px 2px 0 0;background:rgb(var(--h))}
338.spans{display:grid;gap:14px}
339.span{background:var(--card);border:1px solid var(--rule);border-radius:12px;padding:16px 18px}
340.span h3{margin:0 0 10px;font-size:17px;display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
341.span h3 .k{text-transform:none;letter-spacing:0}
342.span h3 .n{font:600 13px ui-monospace,Consolas,monospace;color:var(--flag)}
343.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}
344.bar{height:10px;border-radius:5px;background:var(--rule);overflow:hidden}
345.bar i{display:block;height:100%;background:rgb(var(--h))}
346.row.c{font-weight:700}
347.p{text-align:right;color:var(--mute)}
348.verdict{margin:30px 0 0;padding-top:18px;border-top:1px solid var(--rule)}
349.scale{display:flex;align-items:center;gap:10px;margin-top:14px}
350.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))}
351footer{margin-top:40px}
352footer a{color:inherit}
353"#;
354
355/// Self-contained HTML report. Hover or focus any token for its probability
356/// and the alternatives the model weighed.
357pub fn html(tokens: &[LogprobToken], report: &Report, meta: &Meta) -> String {
358    let spans = span_index(tokens, report);
359    let mut h = String::new();
360    let _ = write!(
361        h,
362        "<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\
363         <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
364         <meta name=\"color-scheme\" content=\"light dark\">\
365         <title>Token confidence: {}</title><style>{}</style></head><body><main>",
366        esc(&meta.source),
367        CSS
368    );
369    let _ = write!(h, "<div class=\"k\">Token confidence report</div>");
370    let _ = write!(
371        h,
372        "<div class=\"meta k\"><span><b>{}</b></span>",
373        esc(&meta.source)
374    );
375    if let Some(m) = &meta.model {
376        let _ = write!(h, "<span>{}</span>", esc(m));
377    }
378    let _ = write!(
379        h,
380        "<span>{} tokens</span><span>mean p {:.2}</span><span>threshold {:.2}</span>\
381         <span>{} flagged</span></div>",
382        report.token_count,
383        report.mean_prob,
384        report.threshold,
385        report.spans.len()
386    );
387
388    // The answer, one element per token.
389    h.push_str("<p class=\"answer\">");
390    for (i, t) in tokens.iter().enumerate() {
391        let n = spans[i];
392        let starts = n > 0 && (i == 0 || spans[i - 1] != n);
393        let ends = n > 0 && (i + 1 == tokens.len() || spans[i + 1] != n);
394        let body = t.token.trim_start();
395        let lead = &t.token[..t.token.len() - body.len()];
396        h.push_str(&esc(lead));
397        if starts {
398            let _ = write!(h, "<span class=\"f\" id=\"s{n}\">");
399        }
400        let p = t.prob();
401        let (r, g, b) = heat(p);
402        let mut tip = format!("p {p:.2}  {}", show(&t.token));
403        for a in t.top_logprobs.iter().filter(|a| a.token != t.token) {
404            let _ = write!(tip, "\n     {:.2}  {}", a.logprob.exp(), show(&a.token));
405        }
406        let _ = write!(
407            h,
408            "<span class=\"t\" tabindex=\"0\" style=\"--h:{r},{g},{b};--a:{:.3}\" data-tip=\"{}\">{}</span>",
409            ((0.95 - p) / 0.95).max(0.0).powf(0.7),
410            esc(&tip),
411            esc(body)
412        );
413        if ends {
414            let _ = write!(h, "</span><sup>{n}</sup>");
415        }
416    }
417    h.push_str("</p>");
418
419    // A strip with one bar per token, height = probability.
420    h.push_str("<div class=\"trace\" aria-hidden=\"true\">");
421    for t in tokens {
422        let p = t.prob();
423        let (r, g, b) = heat(p);
424        let _ = write!(
425            h,
426            "<i style=\"--h:{r},{g},{b};height:{:.0}%\" title=\"{} p {:.2}\"></i>",
427            (p * 100.0).max(4.0),
428            esc(&show(&t.token)),
429            p
430        );
431    }
432    h.push_str("</div>");
433
434    if report.flagged() {
435        h.push_str("<div class=\"k\" style=\"margin-bottom:12px\">Flagged spans</div><div class=\"spans\">");
436        for (n, s) in report.spans.iter().enumerate() {
437            let _ = write!(
438                h,
439                "<section class=\"span\"><h3><span class=\"n\">{}</span>{}<span class=\"k\">weakest {} at p {:.2}</span></h3>",
440                n + 1,
441                esc(s.text.trim()),
442                esc(&show(&s.weakest_token)),
443                s.min_prob
444            );
445            for (tok, p, chosen) in candidates(s) {
446                let (r, g, b) = heat(p);
447                let _ = write!(
448                    h,
449                    "<div class=\"row{}\"><span>{}</span><span class=\"bar\"><i style=\"--h:{r},{g},{b};width:{:.1}%\"></i></span><span class=\"p\">{:.2}</span></div>",
450                    if chosen { " c" } else { "" },
451                    esc(&show(&tok)),
452                    p * 100.0,
453                    p
454                );
455            }
456            h.push_str("</section>");
457        }
458        h.push_str("</div>");
459    }
460
461    let _ = writeln!(
462        h,
463        "<div class=\"verdict\">{}<div class=\"scale k\"><span>p 0</span><span class=\"g\"></span><span>p 1</span></div></div>\
464         <footer class=\"k\">Generated by <a href=\"https://gitlab.com/mattbusel/LLM-Hallucination-Detection-Script\">llm-token-visualizer</a> {}</footer>\
465         </main></body></html>\n",
466        esc(&summary(report)),
467        env!("CARGO_PKG_VERSION")
468    );
469    h
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::detect::{detect, parse_logprobs};
476
477    fn sample() -> (Vec<LogprobToken>, Report, Meta) {
478        let json = include_str!("../examples/logprobs/cuyp.json");
479        let tokens = parse_logprobs(json).unwrap();
480        let report = detect(&tokens, 0.6);
481        (tokens, report, Meta::from_response("cuyp.json", json))
482    }
483
484    #[test]
485    fn meta_reads_model_name() {
486        let (_, _, m) = sample();
487        assert_eq!(m.model.as_deref(), Some("meta-llama/llama-3.1-8b-instruct"));
488    }
489
490    #[test]
491    fn heat_is_monotone_from_red_to_ink() {
492        assert_eq!(heat(0.0), (232, 72, 48));
493        assert_eq!(heat(1.0), (214, 208, 196));
494        // green channel rises as confidence rises
495        let gs: Vec<u8> = [0.2, 0.4, 0.6, 0.8, 0.95]
496            .iter()
497            .map(|&p| heat(p).1)
498            .collect();
499        assert!(gs.windows(2).all(|w| w[0] <= w[1]), "{gs:?}");
500    }
501
502    #[test]
503    fn html_marks_spans_and_alternatives() {
504        let (t, r, m) = sample();
505        let h = html(&t, &r, &m);
506        assert!(h.starts_with("<!DOCTYPE html>"));
507        assert!(h.contains("id=\"s2\""));
508        assert!(h.contains("&quot;üsseldorf&quot;"));
509        assert!(h.contains("2 low-confidence spans"));
510        assert_eq!(h.matches("class=\"t\"").count(), t.len());
511        assert!(!h.contains("<script"));
512    }
513
514    #[test]
515    fn html_escapes_token_text() {
516        let tokens = parse_logprobs(r#"[{"token":"<b>&","logprob":-2.0}]"#).unwrap();
517        let r = detect(&tokens, 0.5);
518        let h = html(&tokens, &r, &Meta::default());
519        assert!(h.contains("&lt;b&gt;&amp;"));
520        assert!(!h.contains("<b>&"));
521    }
522
523    #[test]
524    fn markdown_numbers_spans_and_lists_alternatives() {
525        let (t, r, m) = sample();
526        let md = markdown(&t, &r, &m);
527        assert!(md.contains("**Dordrecht**<sup>2</sup>"), "{md}");
528        assert!(md.contains("`üsseldorf` 0.39"), "{md}");
529        assert!(md.contains("| 2 | Dordrecht | `ord` | 0.57 |"), "{md}");
530    }
531
532    #[test]
533    fn terminal_without_color_is_plain_and_complete() {
534        colored::control::set_override(false);
535        let (t, r, m) = sample();
536        let out = terminal(&t, &r, &m);
537        assert!(out.contains("Aelbert Cuyp died in 1691 in Dordrecht, Netherlands."));
538        assert!(out.contains("\"üsseldorf\""));
539        assert!(out.contains("chosen"));
540        assert!(out.contains("2 low-confidence spans"));
541        colored::control::unset_override();
542    }
543
544    #[test]
545    fn confident_answer_still_warns() {
546        let tokens = parse_logprobs(r#"[{"token":"Yes","logprob":-0.01}]"#).unwrap();
547        let r = detect(&tokens, 0.5);
548        assert!(markdown(&tokens, &r, &Meta::default()).contains("confidently wrong"));
549    }
550}