feat(dash): the KV-cache TTL analysis page and its strategy simulator - #104
Merged
OsherElhadad merged 1 commit intoAug 23, 2026
Merged
Conversation
The dashboard half of the KV-cache work: a tab that answers how long a
conversation actually stays idle, what the prompt cache costs at that idle
profile, and what a different TTL policy would have cost on the same history.
The policies, the cost model and the replay are package kvcache; nothing here
decides or prices anything.
Four GET routes, all scopeTenant, all returning numbers, enum labels and ids
only. GET rather than POST for two reasons worth more than tidiness: both
scoping tests probe the mounted table with a GET, so a POST route would be one
neither could check, and a simulation is a view, so its whole input belongs in a
URL that can be bookmarked and pasted into an issue.
GET /api/kvcache the analysis: cards, idle histogram, survival
curve, four grouped views, price list, coverage
GET /api/kvcache/rows the derived dataset, sortable on 13 columns and
paged on the server
GET /api/kvcache/simulate every requested arm replayed and scored against
one baseline
GET /api/kvcache/pricing the editable rate table and what each rate comes
to on the window's own median prefix
The derivation applies a filter in TWO places, deliberately. The predicates
that select which conversations are in scope — tenant, time window, session,
the exclusion of ping rows — run inside the window function. Everything that
selects which requests to SHOW runs outside it. Running `model = X` inside the
partition would make a request's successor the next request on that model,
which is not the next request in the conversation, and on this corpus that is
not a corner case: it would distort the 12,035 requests sitting in a session
that uses more than one model.
The window partitions by (tenant_id, session_id, model), which is exactly
kvcache.Conversation. The model is in the key because a cache entry does not
transfer between models, and a guard now asserts the two groupings agree in
both directions so the SQL cannot drift from the Go type again.
Three things the page refuses to do, each enforced by a test rather than by
review:
- It never renders an absence as a zero. A request with no successor has
idle_ms null, not 0; a tier that was never recorded reads "not recorded" in
both the grouped table and the row pill, never the tier it is replayed as;
a row with incomplete accounting has an unknown cost, never $0.
- It does not present the hit rate as the objective, with a banner saying so:
holding every prefix for an hour raises the hit rate and costs more, so a
reader scanning for the best-looking column would pick the worst arm.
- It never clamps a comparison and says which way it points in words. The
column reads "$X cheaper" or "$X MORE", because a column of signed dollars
headed "saving" was read as a saving when it was the opposite.
Evidence. Rendered in Chromium against a seeded 3,391-request window shaped
like the live corpus (94.9% of gaps inside five minutes against the measured
95.3%, median cached prompt 133k tokens against 124.8k) and inspected panel by
panel. That found four defects no substring test could see: the exact ceiling
reporting the same total as the no-cache arm because the price list was dropped
when threading the config; the by-TTL table folding 295 not-recorded rows into
the five-minute group while the coverage banner directly above reported them as
not recorded; the row pill printing "5m" for those same rows; and the formulas
panel rendering eleven headings above eleven empty boxes because the payload
emitted {name, expression, prose} while the page read {name, formula, note}.
Each is now guarded.
The wire contract between the payloads and the page is checked in both
directions from the struct tags, and the comment states what that check does
NOT catch — a field moving between shapes — with the reproduction, rather than
letting a green line imply coverage it does not have. Cost identities and the
percentage-versus-fraction case are asserted as invariants, because every
nominal check is blind to a key that keeps its name and changes its meaning.
Filter gains a TTL dimension with "none" as a sentinel, for the same reason
Reason has "compacted": an uncached request's stored value is the empty string,
which is also what "no filter" looks like, so an empty field cannot mean it.
gofmt, go vet and go test ./... clean; production binary builds. 51 KV-cache
tests in package dash.
Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The dashboard half of the KV-cache work. Stacked on #99, which carries the calculation half — this branch adds no policy and no arithmetic of its own, and it targets
feat/kv-cache-ttl-cost-modelrather thanmainbecausekvcache/does not exist onmainyet, so a PR againstmainwould not compile.What it is
A tab answering three questions over real history: how long a conversation actually stays idle, what the prompt cache is costing at that idle profile, and what a different TTL policy would have cost on the same requests.
Four
GETroutes/api/kvcache,/api/kvcache/rows,/api/kvcache/simulate,/api/kvcache/pricing— allscopeTenant, all returning numbers, enum labels and ids only. No prompt text, no transcript.GETrather thanPOSTfor two reasons worth more than tidiness: both scoping tests probe the mounted route table with aGET, so aPOSTroute would be one neither could check; and a simulation is a view, so its whole input belongs in a URL that can be bookmarked and pasted into an issue.Not manager-only. The tab carries no
data-manager, so every signed-in account sees it scoped to its own traffic. A manager additionally gets the service-wide view — the User filter, the drill-down on By user, and the User column.TestAPIScopesEveryRouteToTheCaller,TestAPIIgnoresCraftedTenantParamandTestAPIFailsClosedWithoutAPrincipalall walk these four routes.The derivation, and the one thing it would be easy to get wrong
A filter is applied in two places, deliberately. The predicates selecting which conversations are in scope — tenant, time window, session, the exclusion of ping rows — run inside the window function. Everything selecting which requests to show runs outside it. Running
model = Xinside the partition would make a request's successor the next request on that model, which is not the next request in the conversation, and on this corpus that is not a corner case: it would distort the 12,035 requests sitting in a session that uses more than one model.The window partitions by
(tenant_id, session_id, model), which is exactlykvcache.Conversation. The model is in the key because a cache entry does not transfer between models — an opus request cannot read a sonnet request's entry.TestTheSQLPartitionIsExactlyTheConversationKeyasserts the SQL grouping and the Go type agree, structurally and behaviourally, so they cannot drift again.Three things the page refuses to do
Each is enforced by a test rather than by review, because all three are invisible on screen when wrong — the page looks completely fine.
idle_ms: null, not0, and is excluded from every average — 620 of 3,391 on the demo window, and 1,551 of 1,772 conversations on the production corpus hold a single request. A tier that was never recorded reads not recorded in both the grouped table and the row pill, never the tier it is replayed as. A row with incomplete accounting has an unknown cost.fixed-1hhas a better hit rate than the baseline and costs more, so a reader scanning for the best-looking column would pick the worst arm. Nothing sorts or colours by it.$X cheaper/$X MORE. A column of signed dollars headed "saving" was read as a saving when it was the opposite.Evidence
Rendered in Chromium against a seeded 3,391-request window shaped like the live corpus (94.9% of gaps inside five minutes against the measured 95.3%; median cached prompt 133k tokens against 124.8k) and inspected panel by panel. That found four defects no substring test could see:
NewStrategy— every action then cost zero and the DP chose "expire" for everything, presented as the cheapest plan that exists;5mfor those same rows, with the truth only in a tooltip;{name, expression, prose}while the page read{name, formula, note}—namematched by luck, so it read as a stylesheet problem.Each is now guarded. The wire contract is checked in both directions from the struct tags, and its comment states what that check does not catch — a field moving between shapes — with the reproduction, rather than letting a green line imply coverage it does not have. Cost identities and the percentage-versus-fraction case are asserted as invariants, because every nominal check is blind to a key that keeps its name and changes its meaning.
Also
Filtergains aTTLdimension with"none"as a sentinel, for the same reasonReasonhas"compacted": an uncached request's stored value is the empty string, which is also what "no filter" looks like, so an empty field cannot mean it.Docs:
docs/dashboard-kvcache-page.md, in the mkdocs nav, with the four routes and thettlfilter added todocs/reference/routes.md.Verification
gofmt -l,go vet ./...andgo test ./...clean across every package; production binary builds withCGO_ENABLED=1. 51 KV-cache tests in packagedash.Reviewing
Open at
/dashboard/#kvcache. Worth checking by hand: that the not recorded TTL group and the coverage banner agree, thatoptimalreads as a ceiling rather than an option, and that thevs baselinecolumn is unambiguous about direction.