feat(kvcache): ship a fixed reuse model, and put the budget arm on the page - #219
Open
OsherElhadad wants to merge 1 commit into
Open
feat(kvcache): ship a fixed reuse model, and put the budget arm on the page#219OsherElhadad wants to merge 1 commit into
OsherElhadad wants to merge 1 commit into
Conversation
…e page
`BudgetPolicy` has been unreachable from every surface since it landed. The reason was
structural rather than an oversight: it is built from a `Predictor`, no `Predictor` existed
outside a test, and the registry's promise is that every name in it resolves. A name resolving
to an arm with a nil `Predictor` would have been worse than no name — `Decide` returns
`write_5m` when it has no opinion, so that arm never pings, and the page would have shown a
flat write policy under a label promising a learned one.
`kvcache.ReuseModelV1` removes the premise instead of the promise. It is a fixed discrete-time
survival model — one coefficient per feature, one per one-hot level, compiled in and never
re-fitted at runtime — so `NewKeepAliveBudget` builds the arm from nothing, `keepalive-budget`
joins `Registry()`, and the KV-cache page picks it up with no dashboard change at all:
`KVCacheArms()` probes the registry, so the checkbox, the baseline select and the per-(user,hour)
suggest sweep all gain it for free.
The fit is a logistic regression rather than `fit()`'s gradient-boosted pair, and it gives up
0.087 of dollar-AUC to be one. That is the price of shipping: thirty-odd coefficients can be
read in a diff, so a re-fit that broke the model would be visible, and the live pinger consults
the same model on the request path where the standing rule is "a rule, or a portable
logistic-regression dot product -- never an embedded model or a call out of the hot path".
`--db`/`--model-out`/`--go-out` are the wiring the Python's own docstring said was missing, so
the numbers can be re-derived rather than quoted. Fitted on 18.4 days of the hosted capture:
219,650 idle spans, 37,418 of which reach the first sweep, holdout AUC 0.9065 by row and 0.7837
by dollar.
Measured through the page's own scorer against `fixed-5m`:
arm pings off the bill net per ping
optimal (unreachable) 1,041 8.054% 3.069
keepalive-budget (cap 5) 8,790 2.065% 0.0932
keepalive-budget (cap 8) 9,003 2.059% 0.0907
keepalive-5m flat k=5 59,139 1.860% 0.0125
keepalive-budget (cap 2) 7,573 1.850% 0.0969
keepalive-5m flat k=2 25,586 1.808% 0.0280
keepalive-5m flat k=8 90,779 1.381% 0.0060
stop-reason-gated 14,969 0.710% 0.0188
On this window the arm beats every reachable alternative on money, which is NOT what the
previous measurement found -- that one had a constant beating it by 0.89 pp. Two things
changed and only one is the corpus; the other is that three specification defects were fixed,
each of which is recorded in the doc because each is a trap worth not re-entering:
- `sweep_k`, `log_elapsed` and `log_secs_to_deadline` are exact functions of the sweep index
(the third is a constant), which a tree ensemble tolerates and a linear model cannot. The
first fit produced -4.77 and +4.09 on two collinear features, a flat ~14% hazard at every
sweep against a measured 4.86% falling to 0.24%, and an arm that bought its maximum budget on
97% of decisions and saved less than a flat MaxPings=2. The sweep index is a one-hot now: a
free baseline hazard per sweep, whose recovered shape is not monotone.
- the person-period frame labels the last sweep's survival against `inf`, so it is identically
0. Harmless as a truncation, fatal once the curve is composed into a CDF: F jumps to 1.0 at
the tail and `Windows` reads h_maxk = (1-F)/(1-F) = 100%, which the induction propagates back
through every earlier sweep. `gap_s > t_maxk + interval` is observable, so nothing needed
censoring.
- `cache_ttl` is self-consistent offline but a train/serve skew once wired in behind the seam:
`Observation.TTL` is the tier the arm under test chose on the previous turn, not the tier the
corpus recorded, so the feature is the model's own prior output fed back as an input. Dropped.
`user_id` is dropped too -- a per-tenant dummy generalises to no new tenant and would put
tenant identifiers in the binary, and `stat_p`/`log_stat_n` already carry that history.
The fit weight is the one place an argument lost to a measurement, and both are recorded. Fitting
unweighted should be right: the arm compares a probability against an 8.70% break-even, so it
needs calibration rather than ranking, and a stake-weighted fit reports the hazard of a dollar
rather than of a conversation -- fourfold different here. Measured, `stake` is worth 0.22 pp more
of the bill and `none` is 11% better per ping. `stake` ships; `none` stays selectable, because the
argument is good and the gap is near this corpus's seed noise.
Two guards, because they cover different things. The existing `--fixture` one feeds a
hand-written step CDF, so the coefficients, the feature derivation and the standardisation are
all outside it. `kv_ttl_reusemodel_drift_test.go` drives `--score` over the checked-in
`reusemodel_v1.json` -- the artifact the Go table was generated from, so a hand-edit of the
generated file and an uncommitted regeneration both fail -- with a fixture carrying raw
`Observation` fields rather than derived features, so a log1p applied to milliseconds on one side
and seconds on the other is caught rather than agreed on. It compares the per-sweep survival
vector, the CDF on and off the knot grid, the per-window h/s, and the budget.
`TestUnbudgetedArmsAreUnaffectedByTheSeam` becomes an allow-list rather than "none". SILENTLY was
the load-bearing word in its message and it still is; naming the set also catches the other
direction, an arm dropping off the budget path and quietly running on the flat cap, which an
assertion of "none" never could.
No default changes outside the page's own arm set. `DefaultMaxPings` stays at 2, which still
caps the arm below the 8 sweeps it reasons over -- it wins there anyway, and the doc says what
the cap costs.
Verification:
go vet ./... clean
gofmt -l . clean
go build ./... clean
go test ./... pass
go test -race ./kvcache/ ./deploy/harbor/ ./dash/ pass (dash 479s)
mkdocs build --strict clean
kv_ttl_keepalive_policy.py --self-test pass
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.
What this adds
A fixed, checked-in reuse model, and with it the
keepalive-budgetarm on the KV-cache page.PR #212 built
BudgetPolicyand then could not show it to anyone. That was structural, not anoversight: the arm is built from a
Predictor, noPredictorexisted outside a test, and theregistry's promise is that every name in it resolves. A name resolving to an arm with a nil
Predictorwould have been worse than no name at all —Decidereturnswrite_5mwhen it hasno opinion, so that arm never pings, and the page would have shown a flat write policy under a
label promising a learned one.
kvcache.ReuseModelV1kvcache/reusemodel.goPredictorkvcache/registry.gokeepalive-budgetjoinsRegistry();NewKeepAliveBudgetis why it candeploy/harbor/kv_ttl_keepalive_policy.py--db/--model-out/--go-out— the fit wiring its own docstring said was missingkv_ttl_reusemodel_drift_test.godash/needed almost nothing.KVCacheArms()probes the registry rather than carrying alist, so the checkbox, the baseline
<select>and the per-(user,hour)suggest sweep all gainedthe arm for free. The only dash change is adding it to the default arm set, plus the name
constant.
The model, and the three constraints that pick its shape
It is a logistic regression, not the gradient-boosted pair
fit()uses, and it gives up 0.087of dollar-AUC to be one. That is the price of shipping, and all three reasons are real:
would be visible. Thirty-seven trees of thresholds cannot be, so nobody could tell.
the page's whole claim is that replaying a historical window gives the same answer every time.
model, where
proxy/keepalivestrategy.go's own standing rule is "a rule, or … a portablelogistic-regression dot product — never an embedded model or a call out of the hot path."
Fitted on 18.4 days of the hosted capture: 219,650 idle spans, 37,418 of which reach the first
sweep — that second number is the population the decision is actually faced on, and it is worth
having in the provenance. Holdout AUC 0.9065 by row, 0.7837 by dollar, fitted on the early 70%
and scored on the late 30%; split by time, never at random, because one span contributes up to
max_kperson-periods and a random split scores a row against its own siblings.What it is worth
Replayed through the page's own scorer, against the
fixed-5mbaseline the page uses.optimal(unreachable — reads the future)keepalive-budget, cap 5keepalive-budget, cap 8keepalive-5mflatk=5keepalive-budget, cap 2 (page default)keepalive-5mflatk=6keepalive-5mflatk=2(DefaultMaxPings)keepalive-5m-oncekeepalive-5mflatk=8stop-reason-gatedhistorical-probabilityOn this window the arm beats every reachable alternative on money — which is not what #212
measured. That PR reported a flat constant winning by 0.89 pp and said so plainly. Two things
changed and only one of them is the corpus: the window is longer (18.4 days against 16.9), and
three specification defects were fixed. Read #212's figure as measuring a differently-specified
model, not as a result this one contradicts.
The budget varies per conversation, which is the whole point and the one thing a flat
MaxPingscannot do — 41.4% of conversations are worth no ping at all and the arm declines them, which
is where the 6.6× reduction in ping volume comes from:
Three specification defects, each a trap worth not re-entering
All three are in the doc, because all three produced plausible numbers and no error.
1. Collinear sweep features.
sweep_k,log_elapsedandlog_secs_to_deadlineare exactfunctions of the sweep index — the third is
life - intervalat every sweep, a constant. A treeensemble tolerates that as redundancy; a linear model cannot. The first fit produced −4.77 and
+4.09 on two features that move together, summing to a flat ~14% hazard at every sweep where
the corpus measures 4.86% at the first falling to 0.24% by the seventh. The arm bought its
maximum budget on 97% of decisions and saved less than a flat
MaxPings=2. The sweep indexis a one-hot now — a free baseline hazard per sweep — and the shape it recovers rises from −1.25
to +1.83 and then falls, i.e. not monotone, which is exactly why assuming monotone was wrong.
2. The last sweep's label.
person_periodslabels survival atk == max_kagainstinf, soit is identically 0. Harmless as a truncation; fatal once the curve is composed into a CDF —
s_max_kfits to ~0, F jumps to 1.0 at the tail, andWindowscomputesh_max_k = (1−F)/(1−F) = 100%, a certainty the backward induction propagates back through everyearlier sweep. Every span then buys the maximum budget.
gap_s > t_max_k + intervalis observablefor every span in the frame, so nothing needed censoring.
3.
cache_ttlis a train/serve skew, but only once the model is wired in. Offline it is thetier the corpus recorded and fitting on it is self-consistent — which is why the reference fit is
fine. At serve time the seam hands over
Observation.TTL, andsimulate.go:488sets that fromst.tier: the tier the arm under test chose on the previous turn. The feature means one thingin training and another in serving, and the second is the model's own prior output fed back as an
input. Dropped, for 0.0001 of holdout dollar-AUC.
user_idis dropped too, for a different reason: a per-tenant dummy generalises to no tenant thefit never saw and would put tenant identifiers in the shipped binary, while
stat_p/log_stat_nalready carry that tenant's own history in a non-identifying form.
The fit weight: an argument that lost to a measurement
Fitting unweighted should be right, and the argument is a good one: the arm compares a
probability against the rates' own 8.70% break-even, so what it needs is calibration, not ranking,
and a stake-weighted fit is deliberately miscalibrated — it reports the hazard of a dollar, not
of a conversation, and the two differ fourfold here (21.10% stake-weighted at the first sweep
against 4.86% by row).
log_prefixis a covariate precisely so the size effect survives withoutthe weighting.
Both were fitted and both were replayed through the real arm:
--weightstakenonestakeships, because money is the question the page is asked. What the calibration argumentpredicted did not happen: being better calibrated did not produce better decisions, it produced
fewer of them.
--weight nonestays selectable and the argument stays in the code, because the0.22 pp gap is within shouting distance of this corpus's ±0.42 pp seed noise — it is decided, not
settled.
Tests
kvcache— 10 new, each asserting one property the arm depends on rather than one code path:the CDF is a distribution across every horizon from before the first knot to past the last;
F(t_1) = 0by construction, soWindowsdoes not condition on reaching the first sweep twice; ahorizon past the grid clamps rather than extrapolating past 1.0; an unseen model level falls to the
catch-all and not to zero (zero is not "unknown", it is "exactly the reference level"); the level
is case-folded, because the corpus spells one upstream two ways; the standardisation is actually
applied, so the coefficients are not being read against raw values;
ok=falseis reserved for "nomodel";
Validaterefuses a table naming a term this build cannot score, which would otherwise beapplied to a silent zero; the unfitted
max_k+1sweep coefficient cannot change a budget; and theregistry's arm really does carry
ReuseModelV1and really does implementPingBudgeter.deploy/harbor— the guard the existing one cannot be.--fixturefeeds a hand-written stepCDF, so the coefficients, the feature derivation and the standardisation are all outside it — its
own docstring says "the drift guard covers NEITHER".
--scorecloses that: it drives thechecked-in
reusemodel_v1.json, the artifact the Go table was generated from, so a hand-edit ofthe generated file and a regeneration that was never committed both fail. Its fixture carries
raw
Observationfields, never derived features, so alog1papplied to milliseconds on oneside and seconds on the other is caught rather than agreed on. Four layers compared — per-sweep
survival, the CDF on and off the knot grid, the per-window
h/s, and the budget — at 1e-9absolute, because both sides do the same arithmetic in the same order and anything above float
noise is a real divergence rather than a tolerance question. Ten cases, including a model id the
fit never saw, the same id differing only in case, a 5× rate scale, and a
MinPrefixgate.TestUnbudgetedArmsAreUnaffectedByTheSeambecomes an allow-list rather than "none". SILENTLYwas the load-bearing word in its message and it still is. Naming the set also catches the other
direction — an arm dropping off the budget path and quietly running on the flat cap — which an
assertion of "none" never could. Its cross-replay isolation check is untouched.
What this does not change
DefaultMaxPingsstays at 2. That still caps the arm below the 8 sweeps it reasons over, and itwins there anyway (1.850% against flat
k=2's 1.808%, on 7,573 pings against 25,586) — the docsays plainly what the cap costs and how to lift it. No route is added. No live behaviour changes:
the model is consulted by the simulator only. Wiring it into the live pinger is the stacked PR,
where the gate-versus-budget question and the ping-volume argument belong.
One thing observed and deliberately not changed:
BudgetPolicy.Predictor's doc says a nilpredictor means "every budget falls back to
Config.MaxPings". The fallback is real inSimulate, but it is unreachable throughDecide, which returnswrite_5monok=false— so anil-predictor arm fires no pings at all rather than falling back to the cap. Left alone as #212's
to correct.
Verification