Problem
continuous_training_loop retrains every RETRAINING_INTERVAL_SEC (default 1800s) regardless of model quality or workload-distribution change. Two things follow:
- Calibration is only computed inside
train(). _calculate_metrics_on_test (training_server.py:717) runs once per retrain, populates ttft_coverage_scores / tpot_coverage_scores / ttft_quantile_loss_scores / tpot_quantile_loss_scores, and the /metrics endpoint serves whatever value was computed at the last retrain. Between retrains, /metrics is stale by construction.
- No mechanism reads the calibration signal back into the training cadence. All four metric deques are populated and exposed but never consumed for any decision in
train() or continuous_training_loop.
So under workload drift the system has a window of RETRAINING_INTERVAL_SEC during which (a) predictions are served from the stale model, (b) /metrics shows pre-drift coverage, and (c) the operator has no signal anything is wrong.
Empirical characterization
I prototyped the proposed change against a synthetic-drift harness (training_server in isolation, no router/vLLM) — a workload script drives /add_training_data_bulk with a baseline regime that flips to a drift regime at a configurable time, and a separate script polls /metrics every 5s. Three experiments under RETRAINING_INTERVAL_SEC=300, MIN_SAMPLES_FOR_RETRAIN=1000:
(A) Production-like timing, 3× slope drift. Drift fires at t=600s; first post-drift retrain at t=910s (310s detection lag). Coverage drops from 88.5% pre-drift to 76.8% post-drift, violation rate from 11% to 23%. Between t=600 and t=910, /metrics shows healthy 89% coverage — drift is silent.
(B) 10× slope drift, 20s retrain interval. Coverage moves only ~5pp (88% → 81%) but quantile loss explodes 69× (0.55 → 39.27) and decays gradually. Coverage is a weaker drift signal than QL for sharp shifts because the test buffer is FIFO size 1000 and binary-above/below loses magnitude.
(C) Same as A but with continuous coverage evaluation + calibration trigger (described below). Drift-to-first-detection drops from 310s to 74s (4×). 9 retrains in the post-drift window vs A's 1.
Proposal
Two changes, both contained to training_server.py:
-
continuous_coverage_loop — a background thread that runs every COVERAGE_EVAL_INTERVAL_SEC (default 30s), evaluates _calculate_metrics_on_test against the currently loaded model + test buffer, and appends to the existing *_coverage_scores and *_quantile_loss_scores deques. No training, just evaluation. This alone makes /metrics fresh between retrains.
-
Calibration trigger on continuous_training_loop's inter-retrain wait. Track EMA of |coverage - quantile_alpha| and quantile_loss / qloss_baseline. When either exceeds its threshold for K consecutive evaluations, signal a threading.Event the training loop checks during its sleep — fires train() immediately instead of waiting out RETRAINING_INTERVAL_SEC.
New env vars matching existing style:
LATENCY_COVERAGE_EVAL_INTERVAL_SEC (default: 30; 0 disables)
LATENCY_CALIBRATION_COVERAGE_THRESHOLD (default: 5.0 pp deviation from quantile_alpha)
LATENCY_CALIBRATION_QLOSS_RATIO (default: 3.0× rolling baseline)
LATENCY_CALIBRATION_TRIGGER_K (default: 2 consecutive bad evals)
LATENCY_CALIBRATION_EMA_ALPHA (default: 0.3)
Default RETRAINING_INTERVAL_SEC could be raised once calibration-triggered retraining is in (e.g. to 7200s) since drift events bypass the schedule.
Open design questions for review
-
Dual-signal vs coverage-only. Experiment B showed coverage moves ~5pp under 10× drift while QL moves 69×. C also showed coverage and QL diverging during recovery — model accuracy improves (QL ↓) while calibration degrades (coverage ↓) as predictions converge on the drift-regime mean. A coverage-only trigger underweights both ends. The proposal above uses OR-composition of two signals; happy to argue otherwise if there's a preference.
-
Warmup grace period. In experiment C the trigger fired twice during cold-start (t=46s, t=106s) on transient coverage swings while the model was still converging on its first thousand samples. Easy mitigations: skip triggers for the first WARMUP_RETRAINS retrains, relax the threshold for the first WARMUP_SAMPLES, or both. Worth picking one before code lands.
-
Threshold tuning under different quantile_alpha. Defaults above are for the 0.9 baseline. For 0.99 quantile the natural variance of quantile_coverage is much smaller — threshold should likely scale.
-
/metrics cardinality. Adding a quantile_coverage_deviation_ema and qloss_ratio_ema gauge per model (TTFT/TPOT) is 4 extra series. Trivial but flagging it.
Things I considered and am explicitly leaving out
-
RandomDropDeque (lines 63-88). Defined but never used — actual buckets and test buffer use plain deque(maxlen=...) which is FIFO. So the "buffer dilutes new-regime data" argument isn't real today. If it ever gets wired in, it deserves a separate issue.
-
Feature-distribution drift detection (1D KS-test on incoming samples). Would catch upstream regime change before it shows up as calibration drift. Separate issue — needs a snapshot reference distribution and lifecycle.
-
Test-buffer size and refresh rate. Experiment C showed coverage continues degrading for ~500s post-drift even with aggressive triggered retraining, because the test buffer takes that long to fully refresh (FIFO size 1000 @ 2/s test arrivals). A larger buffer slows refresh; a smaller one is noisy. Worth a separate discussion.
Happy to send a PR for the two-change proposal once the design direction lands. Prototype implementation (~80 lines) is on a local branch; can post a draft PR or wait for feedback first depending on preference.
Problem
continuous_training_loopretrains everyRETRAINING_INTERVAL_SEC(default 1800s) regardless of model quality or workload-distribution change. Two things follow:train()._calculate_metrics_on_test(training_server.py:717) runs once per retrain, populatesttft_coverage_scores/tpot_coverage_scores/ttft_quantile_loss_scores/tpot_quantile_loss_scores, and the/metricsendpoint serves whatever value was computed at the last retrain. Between retrains,/metricsis stale by construction.train()orcontinuous_training_loop.So under workload drift the system has a window of
RETRAINING_INTERVAL_SECduring which (a) predictions are served from the stale model, (b)/metricsshows pre-drift coverage, and (c) the operator has no signal anything is wrong.Empirical characterization
I prototyped the proposed change against a synthetic-drift harness (training_server in isolation, no router/vLLM) — a workload script drives
/add_training_data_bulkwith a baseline regime that flips to a drift regime at a configurable time, and a separate script polls/metricsevery 5s. Three experiments underRETRAINING_INTERVAL_SEC=300, MIN_SAMPLES_FOR_RETRAIN=1000:(A) Production-like timing, 3× slope drift. Drift fires at t=600s; first post-drift retrain at t=910s (310s detection lag). Coverage drops from 88.5% pre-drift to 76.8% post-drift, violation rate from 11% to 23%. Between t=600 and t=910,
/metricsshows healthy 89% coverage — drift is silent.(B) 10× slope drift, 20s retrain interval. Coverage moves only ~5pp (88% → 81%) but quantile loss explodes 69× (0.55 → 39.27) and decays gradually. Coverage is a weaker drift signal than QL for sharp shifts because the test buffer is FIFO size 1000 and binary-above/below loses magnitude.
(C) Same as A but with continuous coverage evaluation + calibration trigger (described below). Drift-to-first-detection drops from 310s to 74s (4×). 9 retrains in the post-drift window vs A's 1.
Proposal
Two changes, both contained to
training_server.py:continuous_coverage_loop— a background thread that runs everyCOVERAGE_EVAL_INTERVAL_SEC(default 30s), evaluates_calculate_metrics_on_testagainst the currently loaded model + test buffer, and appends to the existing*_coverage_scoresand*_quantile_loss_scoresdeques. No training, just evaluation. This alone makes/metricsfresh between retrains.Calibration trigger on
continuous_training_loop's inter-retrain wait. Track EMA of|coverage - quantile_alpha|andquantile_loss / qloss_baseline. When either exceeds its threshold for K consecutive evaluations, signal athreading.Eventthe training loop checks during its sleep — firestrain()immediately instead of waiting outRETRAINING_INTERVAL_SEC.New env vars matching existing style:
Default
RETRAINING_INTERVAL_SECcould be raised once calibration-triggered retraining is in (e.g. to 7200s) since drift events bypass the schedule.Open design questions for review
Dual-signal vs coverage-only. Experiment B showed coverage moves ~5pp under 10× drift while QL moves 69×. C also showed coverage and QL diverging during recovery — model accuracy improves (QL ↓) while calibration degrades (coverage ↓) as predictions converge on the drift-regime mean. A coverage-only trigger underweights both ends. The proposal above uses OR-composition of two signals; happy to argue otherwise if there's a preference.
Warmup grace period. In experiment C the trigger fired twice during cold-start (t=46s, t=106s) on transient coverage swings while the model was still converging on its first thousand samples. Easy mitigations: skip triggers for the first
WARMUP_RETRAINSretrains, relax the threshold for the firstWARMUP_SAMPLES, or both. Worth picking one before code lands.Threshold tuning under different
quantile_alpha. Defaults above are for the0.9baseline. For0.99quantile the natural variance ofquantile_coverageis much smaller — threshold should likely scale./metricscardinality. Adding aquantile_coverage_deviation_emaandqloss_ratio_emagauge per model (TTFT/TPOT) is 4 extra series. Trivial but flagging it.Things I considered and am explicitly leaving out
RandomDropDeque(lines 63-88). Defined but never used — actual buckets and test buffer use plaindeque(maxlen=...)which is FIFO. So the "buffer dilutes new-regime data" argument isn't real today. If it ever gets wired in, it deserves a separate issue.Feature-distribution drift detection (1D KS-test on incoming samples). Would catch upstream regime change before it shows up as calibration drift. Separate issue — needs a snapshot reference distribution and lifecycle.
Test-buffer size and refresh rate. Experiment C showed coverage continues degrading for ~500s post-drift even with aggressive triggered retraining, because the test buffer takes that long to fully refresh (FIFO size 1000 @ 2/s test arrivals). A larger buffer slows refresh; a smaller one is noisy. Worth a separate discussion.
Happy to send a PR for the two-change proposal once the design direction lands. Prototype implementation (~80 lines) is on a local branch; can post a draft PR or wait for feedback first depending on preference.