**Phase 04 final verification pass — all tasks already in `complete/`; verified + fixed 3 defects found in verification.**
- Verified `/llm` live (both engines, gufo-only instance on :8099, `llm_monitor` on :8095) in a real browser; dev server left running/healthy.
- Completed task-04's truncated layout fix: `#engines` now carries `.items-start` (the CSS rule existed but nothing used it) — gufo 544 px vs vLLM 715 px, no blank card; pinned by `test_llm_engine_panels_are_not_stretched_to_their_tallest_sibling`.
- Fixed: event rows said the subject twice ("gufo gufo is answering again"), and `/ups` mangled mid-message names ("sent to apc: OK" → "sent to OK") → shared `Dash.eventText()` (strips `UpstreamError: ` + a *leading* name only, mirroring `alerts._clean_error`) used by `/system`, `/llm`, `/ups`; new test.
- Fixed: count sparklines autoscaled rollup averages (queue max 0.1379) into full-height spikes → `sparkline({minSpan})`, `minSpan: 1` on sessions/queued (spike 30 px → 4 px); assertions added.
- Tests: `bash .agents/validate.sh` → **EXIT=0** (`uv run pytest --cov --cov-fail-under=94`: **485 passed, 7 skipped**, coverage **96.87%**; `ruff check src tests agent`: All checks passed; `pyright`: 0 errors; `node --check` all static JS clean; repo hygiene clean; `uv pip list --outdated` empty). `uv run pytest -m live --live` → **7 passed**.
- Completion criteria: heroes largest per engine ✔ (38.4 px vs 16.8 px row values); sparkline per cell + selected window ✔ (5/5 gufo, 7/7 vLLM; 6 h chip moved KV max 53 %→58 %); mean+p95 without hover ✔ (243 / 48.8 / 285 ms feet); provenance tag ✔ (`Δ10.0s`/`Δ8.0s` + `title`=exact source); single-engine ✔ (1 panel, totals hidden, zero red); screenshots ✔ `data/logs/llm-verify-{both-engines,charts-and-slots,single-engine,monitor-reference}.png` + `NOTES-chart-set.md`; pytest/cov/ruff/pyright ✔; earlier phases unchanged-behaviour ✔ (all 01–03 tests pass).
- Deviation (documented, not code): throughput heroes stay **neutral ink** — `severity.py` has no throughput row and 0 tok/s idle is healthy; colour comes from rated cells (KV) + reachability. Kept per `NOTES-chart-set`.
- Deviation: could not append the task-04 written verdict to `NOTES-chart-set.md` (it lives in `todo/`, which I must not edit). Verdict: ours reads better than `:8095` — bigger throughput numbers, per-cell shape over the selected window, percentiles inline; `llm_monitor` only sparklines decode+KV and has no window control.
- Docs: AGENTS.md updated (485 test count, `sparkline` `minSpan` rule, `Dash.eventText` event-row rule, `/llm` panel `items-start`); README reviewed in full — commands, config table (all `config.py` vars present), architecture bullets, page descriptions verified accurate; no other inconsistencies found.
- Note for the harness: `.agents/phases/todo/03…/NOTES-verify.md` and `todo/04…/NOTES-chart-set.md` are untracked notes left behind by their phases (task files moved, notes did not).
- Next pending phase: `05_ups_hero_battery_glyph`.
14 KiB
NOTES-chart-set — what /llm is for, and what it shows
Phase 04, task 01 deliverable (no code changes). Decides the cell set, the colour rules, and the contracts tasks 02–03 implement. Both implementations were read side by side on 2026-10-17; every line number below was verified against the file it cites.
The two sources
Reference — ~/Projects/Experimental/llm_monitor/static/index.html (separate app on
:8095; design reference only, never a dependency — ASSUMPTION kept: it is not ours to own,
and importing it would break the no-build single-app structure):
| What | Where |
|---|---|
sparkline CSS vocabulary (.sparks, .spark, .spark-now, .spark-axis, .spark-line, .spark-area) |
L398–495 |
UI.historyLen = 60 (client ring buffer; pushHist L1542–1545; fed per engine L1570–1571, L1582–1583) |
L1003 |
drawSpark(name, values, format, fixedMax) |
L1092 (SPARK_W = 100/SPARK_H = 30 L1090–1091) |
right-aligned growth — const step = SPARK_W / (UI.historyLen - 1), x = SPARK_W - (values.length - 1 - i) * step |
L1158–1163 |
empty state: < 2 values → empty class, blank paths (never a zero line) |
L1112–1118 |
flat-window honesty: pad keeps the line mid-plot, axis reports the one real value, not the pad (const flat L1128) |
L1126–1133 |
top/middle/bottom scale marks (axis() helper, levels, fmtAxis with enough decimals that the three labels stay distinguishable; axis writes L1155–1157) |
L1101–1104, L1135, L1138–1154 |
fixedMax ratio axes: KV sparks called with fixedMax = 1 → 0–100% always |
L1356 (gufo), L1420 (vLLM) |
per-engine throughput dds: #gufo-ptps / #gufo-dtps, #vllm-ptps / #vllm-dtps |
L757 / L761, L802 / L806 |
setThroughput(el, cell) — value + honest tag (Δ<w> for measured, lifetime avg, from TPOT, derived; title = exact source) |
L1303–1320 |
percentile foot lines: #vllm-ttft-p95, #vllm-tpot-p95, #vllm-queue-p95 under their mean cards |
L950, L955, L960 |
token counters as cells whose foot line names the upstream series (#vllm-ptok, #vllm-gtok) |
L964, L969 |
slot cards: SLOT_FIELDS L1170–1177, buildSlotCard L1179 (built once, patched in place), renderSlots L1209 (DOM order == slot order, vanished slots removed) |
— |
| the only thresholds in that app: KV bar warn ≥ 0.75, crit ≥ 0.9 | L1085–1086 |
| single-engine case: fleet block hidden when one engine — "a cross-engine sum of one engine just duplicates that engine's own card" | L1500–1504 |
dead engine holds last good data + stale class + error banner, never zeroes |
L1514–1535 |
Current page — src/dashboards/web/pages/llm.html + src/dashboards/web/static/page-llm.js:
| What | Where |
|---|---|
engine-totals card (#f-decode L45, #f-prompt L47, KV bar L54–56, tokens lifetime L52–53) |
llm.html L39–60 |
four full-height LineCharts #chart-decode/prompt/kv/queue |
llm.html L62–81 (chart divs L66, L71, L75, L79) |
per-engine panels grid #engines + honest "No engines configured" empty state |
llm.html L83–91 |
gufo slots table #slots-card L94–102; events feed L103–106; provenance footer L109–113 |
llm.html |
CELLS — the 14-equal-size grid this phase dissolves |
page-llm.js L16–33 |
cellValue() — per-key payload mapping |
L35–93 |
provenance() — Δ10.0s / formula tag from source, nothing for scraped gauges |
L96–105 |
enginePanel() / renderPanel() |
L112–136 / L138–214 |
JS thresholds to remove: severityFor(value.value, 75, 90) on the KV bar |
L194–195 |
| device_lost non-zero special case (the alarm-chip seed) | L196–200 |
makeCharts() — height: 165 for all four |
L241–249 (165 at L242) |
refreshCharts() — three /api/llm/history calls; the third already carries requests_running,requests_waiting,kv_cache_usage |
L256–292 (L264–265) |
JS thresholds to remove: severityFor(kv, 75, 90) on fleet KV |
L307 |
window chips Dash.rangeControl (affects charts today, sparklines after this phase) |
L374 |
Shared helpers this builds on: charts.js LineChart L43, renderLegend L329 (with
showPeak), sparkline L348–373 (the function task 02 upgrades); app.js severityFor
L118 (documented FALLBACK ONLY — phases 04–05 exist to retire its remaining call sites),
severityFrom L139, hero() L187.
Decision
KEEP (unchanged, do not regress)
- Provenance tags on every throughput number —
provenance()(page-llm.jsL96–105) and the footer legend (llm.htmlL109–113). AGENTS.md invariant 2; thellm_monitorequivalent issetThroughputL1303–1320. Ours is strictly better (it prints the measured Δ window); it ports as-is into the hero cells. - History window chips (
page-llm.jsL374,Dash.rangeControl). Divergence from the reference on purpose:llm_monitorsparks use a fixed 60-sample client buffer (L1003); our sparklines read/api/llm/historywith the selected window, so chips and sparks always agree. Do not copyhistoryLen. - Per-engine panels (
llm.htmlL84) with per-source health, stale/last-good dimming, and the upstream chip. Unconfigured engines render no panel at all (L85–90) — invariant 3. - gufo slot list (
llm.htmlL94–102,renderSlotsL216–239). Adopt fromllm_monitoronly the in-place patch idiom (buildSlotCardL1179) if rows churn; the table itself stays. - Events card (
llm.htmlL103–106). Phase 02 deleted the raw feed from/only; per-engine up/down transitions stay here.
HERO — the two largest numbers, per engine panel
llm.decode_tokens_per_secondandllm.prompt_tokens_per_second(payload cells built insources/llm.py: gufo measured Δcounters, vLLM native gauge → measured → lifetime estimate, each labelled with itssource).- Rendered with
Dash.hero()(app.jsL187) with theΔ<window>provenance in.sub. - Throughput has NO severity.
severity.pyTHRESHOLDS(L46–57) has no throughput row and gets none: 0 tok/s on an idle server is not red, and a page-invented threshold is exactly what invariant "a threshold exists nowhere else" forbids. The number renders in neutral ink; colour in the hero row comes from the engine's reachability (status dot +.staledimming, already implemented inrenderPanel), mirroring the/systemNET slot precedent ("throughput has no good/bad", AGENTS.md UI conventions). Note: the phase checklist says the heroes are "colour-coded" — the honest reading is the page's colour language (neutral numbers, verdict colour on rated cells, reachability colour on the panel). Task 04's side-by-side screenshots should show this; if the operator wants the digits themselves tinted, that needs a ratifiedseverity.pyrow first, not a JS guess.
SPARKLINE CELLS — compact rows, one per metric, each with its window's history
| Cell | Payload source (cellValue, L35–93) |
History series for the spark | Colour |
|---|---|---|---|
| Sessions active | sessions.active (gufo /slots, vLLM count) |
llm.requests_running ✔ stored |
none |
| Queued | requests_deferred / num_requests_waiting |
llm.requests_waiting ✔ stored |
none |
| KV cache % | kv_cache_usage_ratio / gpu_cache_usage (×100) |
llm.kv_cache_usage ✔ stored |
payload severity |
| TTFT | time_to_first_token_seconds__mean, foot: __p95 |
llm.ttft_seconds ✔ stored |
none |
| TPOT | time_per_output_token_seconds__mean, foot: __p95 |
llm.tpot_seconds — new, see Python delta |
none |
| Queue mean | request_queue_time_seconds__mean |
llm.queue_seconds ✔ stored |
none |
| Prefix cache hit % | prefix_cache_hit_ratio / prompt_cache_hit_ratio |
llm.prefix_cache_hit_ratio ✔ stored |
fixed max 100 |
| Spec accept % | spec_accept_rate (gufo only) |
llm.spec_accept_rate — new, see Python delta |
fixed max 100 |
- Percentiles print as foot lines, no hover (reference: L950/L955/L960), from the live snapshot cells — p95 needs no stored series.
- TTFT/TPOT/queue are vLLM-only; spec accept is gufo-only. The other engine's row renders
n/auncoloured (rule 2), or the row is engine-scoped — task 03's choice, but a gufo panel must never show a red/empty latency mystery. /api/llm/history(api.pyL353–365) answers any metric name; unknown ones come back as empty series — so the page can request the full cell list in one call.refreshCharts(L256–292) already makes three calls; extend the third (L264–265) with the cell series instead of adding requests, and feed both charts and sparks from the same responses.
CONDITIONAL / DEMOTED
tokens_total(lifetime counter) → quiet footnote line in each engine panel (the existing.sessions-note/.debugstrip,page-llm.jsL203–213), likellm_monitor's counter cards whose foot line just names the series (L964, L969). A lifetime sum is not a glance metric.preemptionsanddevice_lost→ alarm chips rendered only when non-zero, absent otherwise (seed: thedevice_lostspecial case L196–200). A permanent cell for something that is always 0 trains the eye to ignore the row. Chip class is.chip.fail(existing vocabulary,dash.cssL336); the rule is existence of an incident, not a graded threshold —sources/llm.pyalready treats any non-zerogufo_device_lost_totalas an incident (comment atGUFO_SERIES), and zero/null simply draws no chip.- Engine-totals card (L39–60): keep engines-live, decode/prompt sums, active, queued —
demote tokens to footnote, and remove the fleet KV bar (L54–56 +
severityForat L307):fleet_totals(sources/llm.pyL325–368,kv_cache_usage_maxL360–363) carries noseverity, and the rated per-engine KV cells plus the KV chart say it better. Hide the totals card when fewer than two engines are configured — copyllm_monitorL1500–1504 ("a cross-engine sum of one engine just duplicates that engine's own card"); this also satisfies the single-engine completion criterion.
CHARTS
- All four
LineCharts stay: decode, prompt, KV, queue — one engine per series (queue chart: running+waiting per engine), existing legendrenderLegend(charts.jsL329) withshowPeakon the bursty throughput plots, units-bearing y-format (already passed). - Drop
height: 165→ ~110 (page-llm.jsL242) and move the chart section below the engine panels so the cells read first (the task says half-height "so the cells read first"; keeping full-height charts on top would contradict that). - Task 03 must pass
ariaLabelto all four charts (AGENTS.md: every chart names itself;/systemdid this in phase 03,/llmnever adopted it).
COLOUR RULES (from severity.py and nowhere else)
- KV pressure: rated.
THRESHOLDS["llm.kv_cache_usage_pct"] = (75, 90)(severity.pyL53), attached per engine cell bySEVERITY_CELLS(sources/llm.pyL99–103) withscale=100because both engines publish a 0–1 ratio. The page reads it viaDash.severityFrom(app.jsL139) and bothseverityFor(..., 75, 90)call sites (L194–195, L307) are deleted — theapp.jsL112–117 fallback comment stays true only if phase 04 does this. - Queue depth: not rated —
THRESHOLDShas no queue row (L46–57).severityFromreturns''→ no colour. The queue chart and sparkline carry the signal as shape. If the operator wants a verdict, that is a futureseverity.pyrow (ratified numbers, tests), never a JS threshold. - Throughput, sessions, latency (TTFT/TPOT/queue mean): not rated → colourless numbers. Explicit statement per the task: 0 tok/s on an idle server is not red.
- Alarm chips:
.chip.failwhen the counter is non-zero; absent otherwise (above).
SPARKLINE CONTRACT (input for task 02, mirroring drawSpark L1092–1176)
DashCharts.sparkline() (currently L348–373: full-width autoscale, no labels) becomes:
- Right-aligned growth: newest sample pinned right, x mapped over the selected time
window (
[now − range, now], likeLineChart._xExtent) rather than over sample index — a partly-filled window grows in from the right exactly likedrawSpark's fixed-step buffer (L1158–1163) but stays honest about when samples happened. - Fixed max for ratio metrics (KV, prefix hit, spec accept: 0–100) so a squiggle means
the same thing on every screen — reference
fixedMaxcalls L1356/L1420; autoscale otherwise. - Current/max labels: current value in the cell header (reference
.spark-now, L419) and window max as the label; where the reference prints three axis marks (L1135, L1155–1157) our compact rows printnow+max— adopt its formatting discipline: enough decimals that labels stay distinguishable (L1137–1152), a flat window reports the one real value, never the pad artifact (L1126–1133). - Severity colour: stroke/area from the payload verdict (
ok/warn/failtokens fromseverityFrom, unrated →--series-1), notllm_monitor's fixed per-engine hue. - Empty: < 2 finite points → "no history", never a zero line (reference L1112–1118; current code already does this).
Python delta (the phase's only one, flagged for task 03)
HISTORY_METRICS (sources/llm.py L113–123; sampled at L292–297) does not store TPOT
or spec-accept, so those two cells would show permanent "no history" slots — the dead-cell
anti-pattern this note exists to prevent. Decision: extend HISTORY_METRICS additively —
tpot_seconds←("time_per_output_token_seconds__mean",)(vLLM only)spec_accept_rate←("spec_accept_rate",)(gufo only)
New names in the public metric namespace (allowed: "phases may stop plotting a series,
they may not rename or stop storing it" — adding is the safe direction), consumed by the
generic Sample("llm", engine, f"llm.{metric}", …) loop (L292–297) and the store's generic
rollups; retention needs nothing new. The phase overview says "Python: none expected" —
this is a small, additive, tested exception; /api/llm/* fields keep their names/meanings
(additive-only anchor holds), and task 03 adds a unit test per new series plus an
/api/llm/history round-trip. Everything else in this phase is HTML/JS/CSS.
Non-goals
- No changes to
/api/llm/snapshotconsumers outside this page, no/ups-style hero glyph work (phase 05), no per-slot sparklines, no touchingllm_monitoritself. - The stored-metric set is never pruned by this phase: cells leave the page, series stay in SQLite (PLAN.md, dropped-visuals anchor).