Skip to content

feat: continuous coverage evaluation + calibration-triggered retraining - #32

Open
gkneighb wants to merge 1 commit into
llm-d:mainfrom
gkneighb:experiment-c-continuous-coverage
Open

feat: continuous coverage evaluation + calibration-triggered retraining#32
gkneighb wants to merge 1 commit into
llm-d:mainfrom
gkneighb:experiment-c-continuous-coverage

Conversation

@gkneighb

Copy link
Copy Markdown
Member

What does this PR do?

Adds a background loop that re-evaluates p90 coverage on the current model + test buffer every COVERAGE_EVAL_INTERVAL_SEC, without retraining. When the EMA of |coverage - target| exceeds CALIBRATION_TRIGGER_THRESHOLD for CALIBRATION_TRIGGER_K consecutive evaluations, it signals the training loop to retrain immediately instead of waiting out the full RETRAINING_INTERVAL_SEC. Opt-in; disabled by default (COVERAGE_EVAL_INTERVAL_SEC=0).

Why is this change needed?

Implements #19. A statically-trained p90 interval under-covers when the workload drifts between scheduled retrains; the predictor only recovers at the next retrain (a multi-minute lag). Continuously evaluating calibration lets the system detect drift early and shorten that lag.

How was this tested?

  • Unit tests added/updated
  • Integration/e2e tests added/updated — via the synthetic-drift harness (baseline->drift, confirmed the trigger fires and shortens the retrain wait)
  • Manual testing performed — live server: confirmed the loop evaluates, the EMA trips the trigger, and an immediate retrain fires ("Retraining triggered by calibration deviation")

(The loop is threading/timing-based; verified by integration/manual runs rather than unit tests.)

Checklist

  • Commits are signed off (git commit -s) per DCO
  • Code follows project contributing guidelines
  • Tests pass locally (make test)
  • Linters pass (make lint)
  • Documentation updated (env vars; can add to README on request)

Related Issues

Implements #19 (core). The ACI enhancement discussed in that thread follows in a stacked PR.

@kaushikmitr

Copy link
Copy Markdown
Collaborator

Thanks for your contribution. This feature exists to catch calibration problems, so it shouldn't ship without a test proving the detector actually fires. The drift harness the checklist mentions isn't in the diff. Two parts:

Unit tests on the trigger logic, in this PR. These can run with fixed inputs, no live servers:

steady drift → fires
a one-off bad reading → does not fire
noisy data on a small buffer → does not fire
steady ~90 → never fires
a scale check: coverage passed as 0-100 is compared against 90, not 0.9 (cheap insurance, since if quantile_coverage ever returns 0.9 instead of 90 the trigger would fire nonstop and nothing would catch it)
The full end-to-end test belongs in the CI work (#15), not this PR. Training a model, feeding it drifted data, and confirming the trigger fires and shortens the wait needs live servers and timing. But please commit the harness you ran by hand into tests/ (e.g. tests/test_calibration_trigger.py), so #15 can reuse it.

f"Calibration trigger fired (max_dev={max_dev:.2f}pp > "
f"threshold={settings.CALIBRATION_TRIGGER_THRESHOLD:.2f}pp). Requesting immediate retrain."
)
predictor._calibration_trigger.set()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the trigger fires it resets consecutive_bad = 0 but leaves the EMA at its drifted value. The requested retrain takes time, during which the loop keeps evaluating the still-drifted model, so the EMA stays above threshold and re-arms within K evals, and even after the retrain lands the EMA still carries pre-retrain history. With a short COVERAGE_EVAL_INTERVAL_SEC relative to retrain duration, sustained drift can queue back-to-back retrains rather than one. Should we reset both the EMA and consecutive_bad after a triggered retrain completes, or add an explicit cooldown window?

gkneighb added a commit to gkneighb/llm-d-latency-predictor that referenced this pull request Jul 7, 2026
Addresses review on llm-d#32:
- Extract the drift-trigger decision logic into CalibrationTrigger (pure,
  unit-testable with fixed inputs); wire continuous_coverage_loop to it.
- Reset the EMA and counter once a triggered retrain lands, and suppress
  re-firing while it is in flight, so sustained drift no longer queues
  back-to-back retrains during the retrain window.
- Unit tests: steady drift fires; one-off/noisy do not; steady calibrated
  never fires; 0-100 coverage scale (not 0-1); worse-of-ttft/tpot; and no
  re-fire until the retrain lands, then a clean reset.
- Commit the manual drift harness under tests/drift_harness/ for the e2e
  work in llm-d#15.

Signed-off-by: Greg Neighbors <26003+gkneighb@users.noreply.github.com>
@gkneighb

gkneighb commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

Thanks for the careful review -- all three addressed in the latest commit.

Trigger unit tests. Extracted the decision logic into a pure CalibrationTrigger (no threading/servers) and wired the loop to it. tests/test_calibration_trigger.py covers your cases: steady drift fires; a one-off bad reading doesn't; noisy readings on a small buffer don't; steady ~90% never fires; and the 0-100 scale insurance -- coverage 90 vs target 90 doesn't fire, while a value passed as the 0.9 fraction fires nonstop (asserted, so a scale regression is caught here).

Back-to-back retrains. Went with your first option -- reset after the triggered retrain lands. On fire it records the current last_retrain_time and suppresses re-firing until that changes, then clears the EMA and counter so post-retrain history starts clean and fresh sustained drift is required to fire again. Test test_no_refire_until_retrain_lands_then_resets covers exactly this.

Harness. Committed the manual drift harness under tests/drift_harness/ (+README) so the #15 e2e can reuse it; kept the full server-in-the-loop test out of this PR as you suggested.

Happy to switch to an explicit cooldown window instead of tying the reset to retrain completion if you'd prefer.

def awaiting_retrain(self) -> bool:
return self._pending_retrain is not _UNSET

def update(self, ttft_cov: float | None, tpot_cov: float | None, last_retrain_time) -> bool:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

train() returns early without updating last_retrain_time when samples are below MIN_SAMPLES_FOR_RETRAIN (training_server.py:852-854), and also skips the update when training throws or is_ready is false (line 1126 is guarded). If the trigger fires and the resulting retrain takes any of those paths, _pending_retrain never changes: the detector stays in awaiting_retrain forever, and the feature silently dies (the event was already consumed by the training loop). Realistic path: /flush clears the data buckets and test buffers → trigger had already fired → triggered train() skips → stuck.

_, cov, _ = self._calculate_metrics_on_test(ttft_model, ttft_scaler, ttft_test, "ttft", "actual_ttft_ms")
if cov is not None:
with self.lock:
self.ttft_coverage_scores.append(cov)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This writes into the same 5-slot deques that train() populates, which /metrics exposes as ttft_coverage_percent{idx=...} (:1738-1741). Today those series mean "test coverage at the last 5 retrains"; with a 5s eval interval, the retrain history is evicted within ~25s and the series silently becomes "last 5 evaluations of the current model." Anything that reads these metrics — dashboards, the #15 CI work, benchmark scoring — will report different numbers when this feature is enabled even if model quality is identical, so enabled-vs-disabled runs stop being comparable on a signal that didn't actually change.

Suggest routing live evals to a new metric family instead: append to e.g. self.ttft_live_coverage_scores / tpot_live_coverage_scores and emit them as ttft_live_coverage_percent{idx=...}. The per-retrain deques keep their semantics, existing benchmark signals stay apples-to-apples, and only the real behavior change (calibration-triggered retrains) shows up in comparisons.

crosses CALIBRATION_TRIGGER_THRESHOLD for K consecutive evaluations.
No-op when COVERAGE_EVAL_INTERVAL_SEC <= 0.
"""
if settings.COVERAGE_EVAL_INTERVAL_SEC <= 0:

@kaushikmitr kaushikmitr Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great its 0 by default as this can change the production results in unexpected ways unless we do a full E2E benchmark like here https://github.com/kaushikmitr/llm-d/tree/guides/predicted-latency-routing/guides

Can you confirm that with this set to 0, the behavior remains as its currently

@gkneighb

Copy link
Copy Markdown
Member Author

Great catch on the pending-state deadlock -- the /flush path you describe is exactly right, and it's now a regression test. All three addressed in the latest commit:

1. Unlanded retrains release the pending state. Rather than a timeout, train() now counts concluded attempts on every exit path (landed, skipped, failed -- incremented in a finally), and the detector compares that counter against the value captured at fire time. An attempt that concludes without last_retrain_time advancing releases the pending state but keeps the drift EMA, so detection resumes immediately and re-fires after k more consecutive bad evals -- periodic retries until a retrain can actually land (e.g. post-/flush once the buckets refill). Three new unit tests: your /flush-skip scenario, landed-retrain-still-fully-resets (the two release paths don't shadow each other), and no-attempt-signal keeps the original wait-on-last_retrain_time semantics.

2. Live coverage split into its own family, exactly as you suggested: ttft/tpot_live_coverage_percent{idx} from new deques; the per-retrain ttft/tpot_coverage_percent series are untouched by the eval loop, so enabled-vs-disabled runs stay apples-to-apples for dashboards and the #15 CI work. /flush metrics clears both families.

3. Confirmed: at the default COVERAGE_EVAL_INTERVAL_SEC=0, behavior is unchanged. The coverage thread never starts (startup gates on > 0 and the loop itself guards <= 0), _calibration_trigger is never set so the training loop sleeps its full RETRAINING_INTERVAL_SEC (the 1s slicing only improves shutdown responsiveness -- same total interval), and the live deques stay empty so /metrics output is byte-identical. The only code that executes regardless is the attempts counter, which nothing consumes when the feature is off. Agreed a full E2E benchmark like your guide is the bar before flipping the default.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

This PR is marked as stale after 21d of inactivity. After an additional 14d of inactivity (7d to become rotten, then 7d more), it will be closed. To prevent this PR from being closed, add a comment or remove the lifecycle/stale label.

gkneighb added a commit to gkneighb/llm-d-latency-predictor that referenced this pull request Aug 3, 2026
Rebased onto the component-directory restructure (llm-d#16): the module
moved to training/, and calibration lives at training/calibration.py
imported as training.calibration.

Adds a continuous coverage loop that re-evaluates the loaded model
between scheduled retrains and fires an early retrain when an EMA of
|coverage - target| stays over threshold for k evaluations. The pure
CalibrationTrigger carries the decision logic; live coverage lands in
its own metric family; the detector releases its pending state on a
retrain that concludes without landing a model. Opt-in via
LATENCY_COVERAGE_EVAL_INTERVAL_SEC (default 0 = disabled).

Squashes the prior four commits on this branch; review history is
preserved in PR llm-d#32.

Signed-off-by: Greg Neighbors <26003+gkneighb@users.noreply.github.com>
@gkneighb
gkneighb force-pushed the experiment-c-continuous-coverage branch from 974e95b to 954a659 Compare August 3, 2026 03:33
@gkneighb

gkneighb commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@kaushikmitr rebased onto the component-directory restructure (#16) -- the module now lives at training/, and calibration is training/calibration.py imported as training.calibration. The 3-way merge onto the restructured training_server.py was clean, and the feature diff is unchanged from what you reviewed. Green locally: 10/10 calibration unit tests, both ruff gates, full suite. CI is re-running now.

This is the same set of changes addressing your earlier review (attempt-counter release of the pending state on unlanded retrains + the separate *_live_coverage_percent metric family). Whenever you have a few minutes for another pass -- happy to walk through anything.

gkneighb added a commit to gkneighb/llm-d-latency-predictor that referenced this pull request Aug 3, 2026
Rebased onto the component-directory restructure (llm-d#16) and stacked on
the rebased llm-d#32: aci.py moves to training/aci.py, imported as
training.aci.

One-sided upper ACI (Gibbs & Candes 2021) widens the prediction
interval between retrains so SLO coverage holds through detection lag.
Opt-in via LATENCY_ACI_ENABLED (default false); exposes aci_alpha,
aci_offset_ms, and the ACI-adjusted coverage series in /metrics.

Union-merged cleanly with llm-d#32's live-coverage additions (both metric
families coexist). Squashes the prior four ACI commits; review history
is in PR llm-d#33.

Signed-off-by: Greg Neighbors <26003+gkneighb@users.noreply.github.com>
Rebased onto the component-directory restructure (llm-d#16): the module
moved to training/, and calibration lives at training/calibration.py
imported as training.calibration.

Adds a continuous coverage loop that re-evaluates the loaded model
between scheduled retrains and fires an early retrain when an EMA of
|coverage - target| stays over threshold for k evaluations. The pure
CalibrationTrigger carries the decision logic; live coverage lands in
its own metric family; the detector releases its pending state on a
retrain that concludes without landing a model. Opt-in via
LATENCY_COVERAGE_EVAL_INTERVAL_SEC (default 0 = disabled).

Squashes the prior four commits on this branch; review history is
preserved in PR llm-d#32.

Signed-off-by: Greg Neighbors <26003+gkneighb@users.noreply.github.com>
@gkneighb
gkneighb force-pushed the experiment-c-continuous-coverage branch from 954a659 to 492964f Compare August 3, 2026 17:31
gkneighb added a commit to gkneighb/llm-d-latency-predictor that referenced this pull request Aug 3, 2026
Rebased onto the component-directory restructure (llm-d#16) and stacked on
the rebased llm-d#32: aci.py moves to training/aci.py, imported as
training.aci.

One-sided upper ACI (Gibbs & Candes 2021) widens the prediction
interval between retrains so SLO coverage holds through detection lag.
Opt-in via LATENCY_ACI_ENABLED (default false); exposes aci_alpha,
aci_offset_ms, and the ACI-adjusted coverage series in /metrics.

Union-merged cleanly with llm-d#32's live-coverage additions (both metric
families coexist). Squashes the prior four ACI commits; review history
is in PR llm-d#33.

Signed-off-by: Greg Neighbors <26003+gkneighb@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

This PR is marked as stale after 21d of inactivity. After an additional 14d of inactivity (7d to become rotten, then 7d more), it will be closed. To prevent this PR from being closed, add a comment or remove the lifecycle/stale label.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants