Skip to content

Commit 7393ac1

Browse files
committed
test(calibration): add trigger unit tests + reset EMA after retrain
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>
1 parent b741ba9 commit 7393ac1

6 files changed

Lines changed: 593 additions & 25 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Calibration-drift detector for continuous coverage evaluation.
2+
3+
The decision logic is pure (no threading, sleeps, or servers) so it can be
4+
unit-tested with fixed inputs. `continuous_coverage_loop` feeds one coverage
5+
evaluation per interval into `CalibrationTrigger.update`, which returns True on
6+
the step that should request a retrain.
7+
8+
See llm-d/llm-d-latency-predictor#19 / #32.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
# Sentinel distinct from None, since last_retrain_time can legitimately be None
14+
# (model never retrained). _UNSET means "not waiting on a triggered retrain".
15+
_UNSET = object()
16+
17+
18+
class CalibrationTrigger:
19+
"""Detects sustained calibration drift and decides when to request a retrain.
20+
21+
Coverage is expected on a 0-100 scale (matching `quantile_coverage`), compared
22+
against `target_pct` (e.g. 90 for p90) -- NOT a 0-1 fraction. An EMA of the
23+
absolute deviation smooths transient noise; a retrain is requested only after
24+
the EMA stays above `threshold` for `k` consecutive evaluations.
25+
26+
After firing, the detector suppresses further firing until the requested
27+
retrain lands (detected by a change in `last_retrain_time`), then resets its
28+
EMA and counter so post-retrain history starts clean. This prevents a slow
29+
retrain from queuing back-to-back retrains while the model is still drifted.
30+
"""
31+
32+
def __init__(self, target_pct: float, threshold: float, k: int, ema_alpha: float):
33+
self.target_pct = target_pct
34+
self.threshold = threshold
35+
self.k = k
36+
self.ema_alpha = ema_alpha
37+
self.ttft_ema = 0.0
38+
self.tpot_ema = 0.0
39+
self.consecutive_bad = 0
40+
self._pending_retrain = _UNSET
41+
42+
@property
43+
def max_dev(self) -> float:
44+
return max(self.ttft_ema, self.tpot_ema)
45+
46+
@property
47+
def awaiting_retrain(self) -> bool:
48+
return self._pending_retrain is not _UNSET
49+
50+
def update(self, ttft_cov: float | None, tpot_cov: float | None, last_retrain_time) -> bool:
51+
"""Fold in one coverage evaluation. Returns True if a retrain should fire now."""
52+
a = self.ema_alpha
53+
if ttft_cov is not None:
54+
self.ttft_ema = (1 - a) * self.ttft_ema + a * abs(ttft_cov - self.target_pct)
55+
if tpot_cov is not None:
56+
self.tpot_ema = (1 - a) * self.tpot_ema + a * abs(tpot_cov - self.target_pct)
57+
58+
# A triggered retrain is in flight: keep tracking the EMA (for /metrics) but
59+
# do not re-fire. Once the retrain lands, reset so history starts fresh.
60+
if self._pending_retrain is not _UNSET:
61+
if last_retrain_time != self._pending_retrain:
62+
self.reset()
63+
return False
64+
65+
if self.max_dev > self.threshold:
66+
self.consecutive_bad += 1
67+
if self.consecutive_bad >= self.k:
68+
self.consecutive_bad = 0
69+
self._pending_retrain = last_retrain_time
70+
return True
71+
else:
72+
self.consecutive_bad = 0
73+
return False
74+
75+
def reset(self) -> None:
76+
"""Clear EMA, counter, and pending-retrain state."""
77+
self.ttft_ema = 0.0
78+
self.tpot_ema = 0.0
79+
self.consecutive_bad = 0
80+
self._pending_retrain = _UNSET

src/llm_d_latency_predictor/training_server.py

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
from sklearn.linear_model import BayesianRidge
3333
from sklearn.preprocessing import StandardScaler
3434

35+
from llm_d_latency_predictor.calibration import CalibrationTrigger
36+
3537
try:
3638
import xgboost as xgb
3739

@@ -1918,42 +1920,35 @@ def continuous_coverage_loop():
19181920
# Wait long enough for the first train() to populate models + initial coverage.
19191921
time.sleep(15)
19201922
target_pct = settings.QUANTILE_ALPHA * 100
1921-
alpha = settings.CALIBRATION_EMA_ALPHA
1922-
ttft_dev_ema = 0.0
1923-
tpot_dev_ema = 0.0
1924-
consecutive_bad = 0
1923+
trigger = CalibrationTrigger(
1924+
target_pct=target_pct,
1925+
threshold=settings.CALIBRATION_TRIGGER_THRESHOLD,
1926+
k=settings.CALIBRATION_TRIGGER_K,
1927+
ema_alpha=settings.CALIBRATION_EMA_ALPHA,
1928+
)
19251929

19261930
logging.info(
19271931
f"Continuous coverage loop started "
19281932
f"(eval_interval={settings.COVERAGE_EVAL_INTERVAL_SEC}s, "
19291933
f"target={target_pct:.0f}%, threshold={settings.CALIBRATION_TRIGGER_THRESHOLD:.2f}pp, "
1930-
f"k={settings.CALIBRATION_TRIGGER_K}, ema_alpha={alpha:.2f})"
1934+
f"k={settings.CALIBRATION_TRIGGER_K}, ema_alpha={settings.CALIBRATION_EMA_ALPHA:.2f})"
19311935
)
19321936

19331937
while not predictor._shutdown_event.is_set():
19341938
try:
19351939
ttft_cov, tpot_cov = predictor.evaluate_current_coverage()
1936-
if ttft_cov is not None:
1937-
ttft_dev_ema = (1 - alpha) * ttft_dev_ema + alpha * abs(ttft_cov - target_pct)
1938-
if tpot_cov is not None:
1939-
tpot_dev_ema = (1 - alpha) * tpot_dev_ema + alpha * abs(tpot_cov - target_pct)
1940-
max_dev = max(ttft_dev_ema, tpot_dev_ema)
1941-
if max_dev > settings.CALIBRATION_TRIGGER_THRESHOLD:
1942-
consecutive_bad += 1
1943-
logging.info(
1944-
f"Coverage drift: ttft_cov={ttft_cov} tpot_cov={tpot_cov} "
1945-
f"ttft_dev_ema={ttft_dev_ema:.2f} tpot_dev_ema={tpot_dev_ema:.2f} "
1946-
f"consecutive_bad={consecutive_bad}/{settings.CALIBRATION_TRIGGER_K}"
1940+
fired = trigger.update(ttft_cov, tpot_cov, predictor.last_retrain_time)
1941+
logging.info(
1942+
f"Coverage drift: ttft_cov={ttft_cov} tpot_cov={tpot_cov} "
1943+
f"max_dev={trigger.max_dev:.2f} consecutive_bad={trigger.consecutive_bad}/{settings.CALIBRATION_TRIGGER_K}"
1944+
f"{' (awaiting retrain)' if trigger.awaiting_retrain else ''}"
1945+
)
1946+
if fired:
1947+
logging.warning(
1948+
f"Calibration trigger fired (max_dev={trigger.max_dev:.2f}pp > "
1949+
f"threshold={settings.CALIBRATION_TRIGGER_THRESHOLD:.2f}pp). Requesting immediate retrain."
19471950
)
1948-
if consecutive_bad >= settings.CALIBRATION_TRIGGER_K:
1949-
logging.warning(
1950-
f"Calibration trigger fired (max_dev={max_dev:.2f}pp > "
1951-
f"threshold={settings.CALIBRATION_TRIGGER_THRESHOLD:.2f}pp). Requesting immediate retrain."
1952-
)
1953-
predictor._calibration_trigger.set()
1954-
consecutive_bad = 0 # one trigger per drift event
1955-
else:
1956-
consecutive_bad = 0
1951+
predictor._calibration_trigger.set()
19571952
except Exception:
19581953
logging.error("Error in continuous coverage loop", exc_info=True)
19591954
predictor._shutdown_event.wait(timeout=settings.COVERAGE_EVAL_INTERVAL_SEC)

tests/drift_harness/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Calibration drift harness
2+
3+
Manual harness for exercising continuous coverage evaluation and the
4+
calibration-triggered retrain end-to-end against a live `training_server`.
5+
Committed here so the automated CI/e2e work (#15) can reuse it rather than
6+
re-derive it. The unit tests for the trigger *logic* live in
7+
`tests/test_calibration_trigger.py` and need no server.
8+
9+
## Scripts
10+
11+
- `synth_workload.py` — drives `/add_training_data_bulk` with a synthetic
12+
linear-noise latency model, switching regimes at `--drift-at` to inject drift.
13+
Drift modes: `slope` (multiply slope coefficients — dispersion-dominant on the
14+
heavy-tailed features), `location` (shift intercepts), `noise` (scale sigma).
15+
- `scrape_metrics.py` — polls `/metrics` and records the coverage (and
16+
quantile-loss) series to CSV over time.
17+
18+
## Example
19+
20+
```bash
21+
# terminal 1: run the server with the coverage loop enabled
22+
LATENCY_COVERAGE_EVAL_INTERVAL_SEC=5 uvicorn llm_d_latency_predictor.training_server:app --port 8000
23+
24+
# terminal 2: baseline for 90s, then inject drift; watch the trigger fire
25+
python tests/drift_harness/synth_workload.py --duration 300 --drift-at 90 --rate 150
26+
python tests/drift_harness/scrape_metrics.py --duration 300 --output coverage.csv
27+
```
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""
2+
Polls the training_server's /metrics endpoint, extracts the
3+
ttft_coverage_percent and tpot_coverage_percent values (each a deque of up
4+
to 5 most-recent test-set coverage measurements), and appends one row per
5+
poll to a CSV.
6+
7+
Output columns:
8+
elapsed_sec, ttft_cov_latest, ttft_cov_mean5, tpot_cov_latest, tpot_cov_mean5
9+
10+
Each coverage value is a fraction in [0, 1] (the actual fraction of test
11+
samples for which y_true <= y_pred). For a p90 model the well-calibrated
12+
value is 0.9.
13+
14+
Usage:
15+
python scrape_metrics.py --duration 1800 --output coverage.csv
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import argparse
21+
import csv
22+
import re
23+
import sys
24+
import time
25+
import urllib.request
26+
27+
COV_RE = re.compile(
28+
r'(?P<name>(?:ttft|tpot)_coverage_percent)\{idx="(?P<idx>\d+)"\}\s+(?P<value>[\d.eE+-]+)'
29+
)
30+
QL_RE = re.compile(
31+
r'(?P<name>(?:ttft|tpot)_quantile_loss)\{idx="(?P<idx>\d+)"\}\s+(?P<value>[\d.eE+-]+)'
32+
)
33+
34+
35+
def fetch_metrics(url: str) -> str:
36+
with urllib.request.urlopen(url, timeout=5) as r:
37+
return r.read().decode()
38+
39+
40+
def _parse_series(body: str, pattern: re.Pattern, ttft_name: str) -> tuple[list[float], list[float]]:
41+
"""Return (ttft_values, tpot_values) for a metric family, ordered by idx (oldest→newest)."""
42+
ttft: dict[int, float] = {}
43+
tpot: dict[int, float] = {}
44+
for m in pattern.finditer(body):
45+
idx = int(m["idx"])
46+
value = float(m["value"])
47+
if m["name"] == ttft_name:
48+
ttft[idx] = value
49+
else:
50+
tpot[idx] = value
51+
return (
52+
[ttft[i] for i in sorted(ttft)],
53+
[tpot[i] for i in sorted(tpot)],
54+
)
55+
56+
57+
def parse_coverage(body: str) -> tuple[list[float], list[float]]:
58+
return _parse_series(body, COV_RE, "ttft_coverage_percent")
59+
60+
61+
def parse_quantile_loss(body: str) -> tuple[list[float], list[float]]:
62+
return _parse_series(body, QL_RE, "ttft_quantile_loss")
63+
64+
65+
def main() -> int:
66+
ap = argparse.ArgumentParser()
67+
ap.add_argument("--server", default="http://localhost:8000")
68+
ap.add_argument("--duration", type=float, default=1800.0)
69+
ap.add_argument("--interval", type=float, default=5.0)
70+
ap.add_argument("--output", default="coverage.csv")
71+
args = ap.parse_args()
72+
73+
url = args.server.rstrip("/") + "/metrics"
74+
start = time.time()
75+
76+
def latest(xs):
77+
return xs[-1] if xs else ""
78+
79+
def mean5(xs):
80+
return sum(xs) / len(xs) if xs else ""
81+
82+
with open(args.output, "w", newline="") as f:
83+
w = csv.writer(f)
84+
w.writerow([
85+
"elapsed_sec",
86+
"ttft_cov_latest", "ttft_cov_mean5", "tpot_cov_latest", "tpot_cov_mean5",
87+
"ttft_ql_latest", "ttft_ql_mean5", "tpot_ql_latest", "tpot_ql_mean5",
88+
])
89+
while True:
90+
elapsed = time.time() - start
91+
if elapsed > args.duration:
92+
break
93+
try:
94+
body = fetch_metrics(url)
95+
ttft_c, tpot_c = parse_coverage(body)
96+
ttft_q, tpot_q = parse_quantile_loss(body)
97+
except Exception as e:
98+
print(f"[{elapsed:7.1f}s] scrape error: {e}", file=sys.stderr, flush=True)
99+
ttft_c, tpot_c, ttft_q, tpot_q = [], [], [], []
100+
w.writerow([
101+
f"{elapsed:.1f}",
102+
latest(ttft_c), mean5(ttft_c), latest(tpot_c), mean5(tpot_c),
103+
latest(ttft_q), mean5(ttft_q), latest(tpot_q), mean5(tpot_q),
104+
])
105+
f.flush()
106+
time.sleep(args.interval)
107+
108+
print(f"wrote {args.output}", flush=True)
109+
return 0
110+
111+
112+
if __name__ == "__main__":
113+
sys.exit(main())

0 commit comments

Comments
 (0)