Drift Monitor
A four-state drift board: one banded strip per feature on a shared PSI scale with the warn and alert rules drawn across every row, the prediction-drift lane on top, and the worst offender named — pick any window from the tick row to read the whole card at that moment.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-drift-monitor.jsonPrompt
Build a React + TypeScript + Tailwind "ChartDriftMonitor" model-monitoring card
in plain HTML/CSS plus one tiny SVG, with zod. Not recharts, and deliberately
not a heatmap: the cell's value must be *positioned*, not just tinted, so a
reader can see a bar cross the alert rule instead of guessing which shade of
orange that was.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; statistic?: "psi" | "kl"; baselineLabel?: string;
thresholds: { warn: number > 0; alert: number > 0 };
features: { id; label; reference?: string;
points: { period: string; psi: number >= 0 }[] }[];
predictionDrift?: { period: string; value: number >= 0 }[] }.
- `psi` is the drift statistic, not the feature's value: a distance between this
window's distribution and the frozen baseline's. It keeps the name under
statistic: "kl" so a payload does not change shape when a team switches
measure. Non-negative — a negative reading means the producer computed
something else, and it is dropped and counted rather than drawn.
- thresholds are DATA, not constants: the PSI convention is 0.10 / 0.25, but a
KL card and a per-team SLO need their own pair, and a card whose bands
disagree with the alerting pipeline is worse than no card. superRefine:
alert strictly above warn, unique feature ids, a ready card needs one feature
with one window. Guard every dereference so a ragged payload yields an issue
rather than a TypeError out of safeParse.
- `reference` is one line about the BASELINE window ("p50 6.2k · p99 118k tok",
"1.83 nats/token"). Never plotted, but never invisible either: drift says a
distribution moved, not what it moved from, and the engineer deciding whether
0.41 matters needs both — so print it beside the worst offender in the verdict
line and for every feature in the screen-reader table.
- Props = z.infer plus rowHeight (22, clamped 14..48), laneHeight (56, clamped
32..160), defaultPeriodIndex, onPeriodChange, onRetry, className and the div's
native props; forwardRef to the card.
- Export the maths beside the component so a test can print the numbers behind
the picture: driftBand(), niceCeil(), apportionPercent(), trendSlope(),
mergePeriodAxis(), predictionLinePath(), rankDriftRows(),
buildDriftMonitorModel().
Behavior
- ONE MERGED AXIS. A window is a label, not a date — "W9" sorts after "W10" —
so never sort it. Merge every feature's window list in turn, keeping a cursor
at the last window already placed and inserting unseen ones in front of it, so
a feature added mid-quarter can arrive first in the array without dragging the
windows it missed to the end of the axis. Give every feature one cell per
window on that axis.
- A HOLE IS NOT A ZERO. A feature that shipped three weeks in gets a dash in the
windows before it existed, never a zero-height bar: zero drift is the
strongest claim on the card, and "this column did not exist" is not a claim.
Count the holes and say how many in view.
- ONE SHARED SCALE, drawn. Top the scale at the two-significant-digit ceiling of
max(peak * 1.08, alert * 1.15) — relative, because the same component carries
PSI near 0.3 and KL divergences three orders of magnitude smaller. Every row
AND the prediction lane use it, and both threshold rules are drawn across all
of them, so a bar's top crosses the alert rule at exactly the value that
colours it alert. The mark is positioned by the number that colours and labels
it, in that order.
- BANDS ARE HALF-OPEN at the lower edge: value >= alert is alert, value >= warn
is watch, otherwise stable. A threshold its own value does not trip is not a
threshold.
- RANK, DO NOT JUST LIST. Sort rows by windows over the alert line, then the
newest reading, then the peak, then payload order. Name the top row in the
header with its newest value, its alert count, its baseline `reference` line
and its least-squares trend per window (regressed on the SHARED axis index, so
a short series does not have its drift rate stretched to the full window). Print the ranking rule under the
card — a ranking the reader cannot reconstruct is just an order.
- WINDOW SCRUB = THE AXIS. The tick row IS the picker: role="tablist", one
role="tab" per window, roving tabindex, Left/Right wrapping plus Home/End,
click to select. Print a label only every stride-th tick, where
stride = max(1, ceil(n/6), ceil(longestLabelChars/3)) — the second term
because a long window label such as "2026-07-06" needs about four columns of
room where "W28" needs one, and measuring the real width would mean the
ResizeObserver this card otherwise avoids; ceil(n/6) alone prints every tick
of a six-window axis and the dates collide. Anchor the stride on the NEWEST
window, so "now" always has a printed tick; the rest keep their label as
sr-only text, which is both the accessible name and — with the stride — the
reason two ticks can never overlap. A tab is one column wide and its rule is
20px tall, so grow the POINTER TARGET with a `before:-inset-y-1`
pseudo-element rather than with the box: the box is a column of the same flex
row the strips use, and 20px + 2×4px clears the 24px minimum without touching
the axis. Selecting a window paints its column through every row and
re-reads the whole panel: each row's value and band, the prediction reading,
"k of n measured features over the alert line", and the biggest rise against
the previous window.
- SHARES THAT ADD UP. Split the measured cells into stable / watch / alert and
apportion the percentages by LARGEST REMAINDER. Rounding each share on its own
is the bug: 50.65 / 37.66 / 11.69 rounds to 51 + 38 + 12 = 101, and a card
publishing 101% of its own cells has told the reader the split is guesswork.
- Four first-class branches of one card: loading (a bar-silhouette skeleton in
the same grid, aria-hidden, plus an sr-only role=status line), empty (worded
so it cannot be mistaken for a failed fetch, and it still states the bands),
error (Try again only when onRetry was passed), ready. status "ready" with
nothing measured renders the empty branch.
- NO OBSERVERS, NO TIMERS, NO rAF. Every x position is a percentage of the same
flex row, and the prediction lane is a preserveAspectRatio="none" viewBox with
vectorEffect="non-scaling-stroke", so the card needs no measurement to stay in
register — nothing to leak and nothing to tear down.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground panel, border rules and
separators, bg-muted cell tracks (bg-muted/40 for a hole),
text-muted-foreground for axis and prose, primary/10 fill with a primary/40
inset ring for the selected column, border-primary on the selected tick,
stroke-ring on focus, and the band ramp bg-chart-2 (stable) / bg-chart-3
(watch) / bg-destructive (alert). Never a hex, never a chart token as small
text — only destructive is legible at 9-10px in both themes.
- COLOUR IS NEVER THE ONLY CHANNEL: the two threshold rules are drawn across
every strip so a crossing survives greyscale, each row prints its band as a
word next to its value, and the sr-only table repeats the band for every cell.
- The tick row and the panel share ONE grid template with no content-driven
column (fixed label and value columns, minmax(0,1fr) between them): an `auto`
column resolves differently in two grids holding different text, and every
tick would then sit a few pixels off the column it labels.
- Cells carry no flex gap — the bars are inset instead — so column i really does
span [i/n, (i+1)/n] and the lane's dots, its polyline and the strips below
line up without measuring anything. Nothing may add to a column's box either:
a 1px border on each side of the selected cell is added to a flex-basis-0
item's hypothetical size, so it would widen that column by 2px, shrink all the
others, and slide the strips out of register with the tick row that has no
such border. Mark the selection with an inset ring or an overlay instead.
- Printed precision follows the scale (one digit finer than its magnitude,
clamped 2..4), so a KL card does not print a column of "0.00"; every number is
tabular-nums.
- ACCESSIBILITY: heading + summary line ids wired to the panel, an sr-only
role=status for loading, and an sr-only WRAPPER DIV (never sr-only on the
table itself — CSS width is only a lower bound for a table box) holding up to
12 rank-spaced windows with every feature, its baseline line and the
prediction lane.
- Motion: the only animation is the skeleton pulse, carrying
motion-reduce:animate-none; the tick hover/selection transition carries
motion-reduce:transition-none.
Customization levers
- Density: rowHeight is the whole ladder's weight — 14 for a fifty-feature wall,
36 for a six-feature card where each bar should read as a bar; laneHeight
sizes the prediction lane independently.
- Bands: thresholds come from the payload, so point them at the same constants
your alerting uses. For a three-band KL card try 0.02 / 0.05 and let the
scale and the printed precision follow by themselves.
- Ranking: rankDriftRows is one comparator — swap the leading key to the newest
reading for a "what is worst right now" board, or to the trend slope for a
"what is getting worse fastest" board.
- Statistic: `statistic` only changes wording and precision, so the same card
serves PSI, KL, JS distance or Wasserstein — rename it in one place.
- Subtract blocks for a tile: drop the prediction lane (omit predictionDrift),
the legend, or the closing paragraph and keep the strips.
- Interaction: for a static report, render with defaultPeriodIndex and hide the
tick row's affordance; for a shared cursor across a page of monitors, lift the
selection into a controlled prop and drive it from onPeriodChange.
- Palette: BAND_FILL is three tokens — re-point them at a host ramp, but keep
the drawn threshold rules, because they are the channel that survives both
greyscale and colour blindness.Concepts
- Threshold banding, not a colour ramp — a heatmap asks the reader to decode a shade; a drift board only ever has three verdicts, so the cell is a bar whose height is the number and whose fill is the band that same number falls in, with both threshold rules drawn across every row. The crossing is a position you can see, which is why the card still works printed in grey.
- One scale, or the rows are lying to each other — every feature and the prediction lane share a single ceiling, so a tall bar really is a bigger distance than a short one three rows down. Per-row autoscaling is the classic small-multiples trap: it makes the calmest feature on the board look exactly as alarming as the worst.
- A hole is not a zero — a feature that shipped mid-quarter gets a dash in the windows before it existed. Zero drift is the strongest claim this card can make, and "this column did not exist yet" is not a claim at all; the count of holes is printed rather than quietly absorbed.
- The axis is merged, never sorted — a window is a label, so
"W9"would sort after"W10". Each feature's list is merged in turn against a cursor, which lets a late-arriving feature be listed first without shoving the windows it missed to the end of the axis. - Sustained drift versus one bad night — the shape is the diagnosis. A monotone climb across ten windows is a population that moved and a retraining ticket; a single spike that returns to the stable band is an incident that has already closed. Ranking by alert windows before today's value is what keeps the second from outranking the first.
- The tick row is the control — the window axis and the window picker are the same widget:
role="tablist"with one tab per column, roving tabindex, arrow keys and Home/End. Only every k-th tick prints its label, anchored on the newest window; the rest keep theirs as screen-reader text, so a dense axis can never collide with itself and every column is still reachable by name. - Shares that add up — the stable / watch / alert split is apportioned by largest remainder, so the three percentages total exactly 100. Rounding each on its own publishes 101, and a card that cannot add up its own cells has undermined every other number on it.
Retrieval Recall@k
A four-state RAG retrieval card: recall@k over precision@k against a log k axis, one line per retriever, with the shipped operating k marked, an MRR@k readout and a coverage bar whose percentages always total 100.
Agent Trajectory
A four-state agent-run timeline — every step a measured segment on the run's own clock, coloured by kind, retries nested inside the step they re-attempt, tokens generated per step on a second track, and a wall-time-versus-generation mix apportioned by largest remainder so the shares add to exactly 100.