Logprobs Viewer
Token-level probabilities for a model response — the answer itself heat-tinted by uncertainty on one chart token, dotted-underlined below a live threshold, with top-k alternatives as probability bars on hover, focus or arrow key.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/logprobs-viewer.jsonPrompt
Build a React + TypeScript + Tailwind "LogprobsViewer" component with zod and
lucide-react. It renders a model response as an inspectable heat strip of its own
token probabilities. No charting library: the bars are divs, the tint is one CSS
color-mix.
Contract
- A zod schema in a sibling contract file is the single source of truth.
Token: { id: string; text: string; logprob: number; topK: { token: string;
prob: number }[] }.
- `text` is the RAW surface form and is never trimmed: " the" and "the" are
different tokens, and the leading space is what makes the strip line up with
the answer the user actually read. Newlines and tabs are legal token text.
- `logprob` is the natural log of the CHOSEN token's probability (<= 0, finite —
zod rejects Infinity, and providers cap the floor near -9.99). `topK[].prob` is
LINEAR and in [0,1], because that is the number a bar's width is drawn from; a
bar whose width is a log value is a bar that lies. Exponentiate exactly once,
in one helper, and document the asymmetry instead of hiding it.
- `topK` normally CONTAINS the chosen token — that is how the panel can say the
sampler took rank 3. It may also be empty (logprobs requested without
top_logprobs): a first-class case, not a bug.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
viewer's own state. "empty" means logprobs were not requested for this
response, which is an ordinary answer, not a failure; "error" means the run
record failed to load.
- Props: tokens; status; defaultThreshold? (0.6) / threshold? / onThresholdChange?
(controlled and uncontrolled, threshold is a LINEAR probability in [0,1]);
showThreshold? showLegend? showSummary? (all true); tint?: "uncertainty" |
"probability" ("uncertainty"); heatIntensity? (0.62); topK? (5, clamped to >= 1);
onTokenSelect?(token, index); formatProbability?; emptyState?; errorMessage?;
onRetry?; label? ("Token probabilities"); className plus the rest of the
element props, ref forwarded to the <section>.
- Export the exp() helper too — consumers computing their own summaries must not
re-derive it differently.
Behavior
- THE TINT IS AN ALPHA RAMP ON ONE COLOUR. weight = 1 - p by default
("uncertainty": confident text stays clean, risky tokens burn), or p when
tint="probability". alpha = intensity * weight ** 0.65, painted as
`color-mix(in oklab, var(--chart-1) N%, transparent)`. A green-to-red hue ramp
would carry the whole reading in the one channel ~8% of men cannot separate;
gamma 0.65 lifts the middle of the range because real probabilities pile up
against 1. Below 0.5% mix, paint nothing.
- A SECOND, NON-COLOUR CHANNEL: tokens whose probability is under the threshold
get a dotted underline (decoration-destructive), so the flagged set survives
greyscale, a monochrome theme and a colour-blind reader. The token's aria-label
carries the same fact in words.
- The threshold is a QUESTION THE READER ASKS, not a verdict shipped with the
data: a hand-rolled role="slider" (arrows +-1%, PageUp/PageDown +-10%,
Home/End, pointer-capture drag) moves it live, and the summary line restates
"N below 60%" as it moves. Controlled and uncontrolled both work.
- ALTERNATIVES ON HOVER **OR** FOCUS. Keep hoverIndex and focusIndex in refs
written only inside event handlers; the open token is `hover ?? focus`. So the
pointer leaving the strip falls back to the focused token instead of blindly
closing, and blurring falls back to the hovered one. Escape stores the
dismissed index and hides only that panel; moving to another token, or
re-entering this one, revives it.
- The panel is POSITION-MEASURED, not CSS-anchored: read the token's FIRST line
box (getClientRects()[0], never getBoundingClientRect — a token that wraps has
a bounding box as wide as the paragraph), centre the panel on it, clamp it
inside the strip, and flip it above the line when the viewport has no room
below. Measure in a layout effect so the placement lands before paint; render
the first commit with visibility:hidden so the panel's own height can be read.
The panel is pointer-events-none — it overlaps the tokens beneath it, and a
panel that could take the pointer would steal the hover from the token that
opened it and flicker forever.
- KEYBOARD IS A FIRST CLASS PATH: the strip is role="listbox" with roving
tabindex (exactly one token is a tab stop). Left/Right walk tokens; Up/Down
move a VISUAL line — wrapped prose makes "the token below" a geometry
question, so find the nearest line box with a different top, then the nearest
horizontal centre on it. Home/End jump to the ends, Enter/Space fire
onTokenSelect, and a "Jump to least certain" button focuses the weakest token
outright.
- Whitespace IS the layout: the strip is whitespace-pre-wrap and each token
renders its raw text, so the model's own spaces and newlines lay the paragraph
out. A newline token additionally draws a visible glyph, and a zero-length
token draws a placeholder mark — a token you cannot see is a token you cannot
inspect.
- Percentages TRUNCATE, never round up: 99.97% must not print as "100%", and a
4e-7 probability prints as "at most 0.01%" rather than a flat "0%". The only
100% is a real 1. The summary line adds mean logprob and perplexity
(exp(-mean)) — one number for how surprised the model was by its own answer.
- Identity resets: the selection, the panel and the roving anchor reset when the
FIRST token's id changes (a new response), not when the array identity changes
— a streaming answer hands over a new array on every appended token, and
resetting on that would both wipe the reader's selection mid-stream and spin
forever on a parent that builds its tokens inline.
- Defensive maths everywhere: a non-finite logprob reads as 0, probabilities are
clamped into [0,1], topK is sliced to a clamped limit, and `status="ready"`
with zero tokens renders the empty branch instead of an empty box.
- Cleanup: the only subscription is one ResizeObserver on the strip (the panel's
coordinates depend on the wrap), disconnected on unmount. The slider uses
pointer capture instead of window listeners, so there is nothing else to
unbind.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
var(--chart-1) for the tint ramp and the chosen candidate's bar, bg-muted plus
bg-muted-foreground/40 for the other bars, text-destructive and
decoration-destructive for the below-threshold channel, ring-ring for focus and
for the open token. No hex, rgb() or oklch() literals anywhere.
- The strip is font-mono with generous leading so the tint blocks do not collide
between lines; tokens are inline (never inline-block), so the highlight follows
the text's own line boxes.
- Every transition is motion-reduce guarded and the skeleton's pulse stops under
prefers-reduced-motion; nothing about the component needs animation to work.
- a11y: role="listbox" + role="option" + aria-selected + roving tabindex; each
token's aria-label is the quoted token text, its percentage and, when flagged,
"low confidence" — a screen reader gets the data the tint carries. The open
token points at the panel with aria-describedby, and the panel opens with an
sr-only sentence that spells out the whole distribution while the bars
themselves are aria-hidden. The loading branch is aria-busy with an sr-only
status; the error branch is role="alert".
- cn() merges the consumer's className into the root, which also spreads the
remaining element props and forwards its ref.
Customization levers
- Reading direction: `tint` flips what the ink means (uncertainty vs
probability) without touching the data; `heatIntensity` sets how loud the strip
gets (0.3 for a transcript you mostly read, 0.9 for triage).
- Density: `showSummary`, `showThreshold` and `showLegend` each drop a block —
turn all three off for a compact inline strip inside a chat message, keep them
for a debugging surface.
- Policy: `defaultThreshold` (or the controlled `threshold`) decides what counts
as low confidence; wire `onThresholdChange` to persist it per user. `topK`
decides how many alternatives the panel draws.
- Wording: `formatProbability` swaps percentages for logprobs, bits, or a
localised format — it drives the bars, the legend, the summary and the
slider's aria-valuetext at once, so there is exactly one place to change it.
- Chrome: `label` names the region, `emptyState` replaces the "not requested"
body, `errorMessage` + `onRetry` own the envelope failure, and `onTokenSelect`
is where you hang your own action (open the eval row, copy the position, seed
a re-run from here).
- Structure to keep: the exp()-once helper, the truncating formatter, the
hover-or-focus rule and the first-line-box measurement. Those four are what
make the numbers honest and the panel land where the token is.Concepts
- Uncertainty is the ink — the strip tints what the model was least sure of, so the eye lands on the risky span instead of on the 200 confident tokens around it. Flip
tintand the same data answers the opposite question ("what was it sure about?") without a second component. - One hue, two channels — the ramp is alpha on a single chart token, never green-to-red, because a hue ramp puts the entire reading in the channel colour-blind readers lose. The dotted underline below the threshold is the second, non-colour channel, and the token's aria-label is the third.
- The threshold is a question, not a verdict — "low confidence" is not a property the provider ships; it is where the reader decides to draw the line. Dragging the slider re-flags the strip live and the summary restates the count, so the reader can find the cut point that separates real doubt from ordinary decoding noise.
- Hover or focus, never both fighting — the open panel is
hover ?? focus: the pointer leaving the strip falls back to the focused token rather than closing, and Escape dismisses only the index it was on. The panel itself takes no pointer events, which is what stops the classic open-close flicker when a popover covers the thing that opened it. - Arrow keys read wrapped prose as geometry — a heat strip is a paragraph, so "the token below this one" cannot be an array index. Up/Down find the nearest different line box and then the nearest horizontal centre on it, which is what makes a 400-token answer walkable without a mouse.
- Sampled rank is the real finding — a token at 17% that was rank 3 in its own distribution says something a percentage alone does not: temperature, not the model, chose this word. The panel puts the chosen token's rank next to the bars, and says so plainly when the sampler landed outside the returned top-k.
Eval Scorecard
One evaluation run at a glance — an overall dial marked with the pass gate, per-criterion bars carrying their own gate ticks and baseline deltas, regressions called out by name, and the worst failing cases disclosed on the row that owns them.
Finetune Status
One fine-tune run as a card — a pipeline strip that keeps the stage a stopped run died at, derived step/epoch progress, a scrubbable train/validation loss sparkline with broken eval gaps, hyperparameter rows, a checkpoint radiogroup that re-targets the deploy action, and a one-shot cancel behind an inline destructive confirm.