@@ -122,6 +122,17 @@ class Settings:
122122 TTFT_GATED_MODEL_PATH : str = os .getenv ("LATENCY_TTFT_GATED_MODEL_PATH" , "/tmp/models/ttft_gated.joblib" )
123123 TPOT_GATED_MODEL_PATH : str = os .getenv ("LATENCY_TPOT_GATED_MODEL_PATH" , "/tmp/models/tpot_gated.joblib" )
124124
125+ # Experiment C — continuous coverage evaluation + calibration-triggered retraining.
126+ # When > 0, a background loop re-evaluates coverage on the current model + test
127+ # buffer every COVERAGE_EVAL_INTERVAL_SEC. If the EMA of |coverage - quantile_alpha|
128+ # exceeds CALIBRATION_TRIGGER_THRESHOLD for CALIBRATION_TRIGGER_K consecutive
129+ # evaluations, the next retrain fires immediately instead of waiting out the
130+ # full RETRAINING_INTERVAL_SEC. Set COVERAGE_EVAL_INTERVAL_SEC=0 to disable.
131+ COVERAGE_EVAL_INTERVAL_SEC : int = int (os .getenv ("LATENCY_COVERAGE_EVAL_INTERVAL_SEC" , 0 ))
132+ CALIBRATION_TRIGGER_THRESHOLD : float = float (os .getenv ("LATENCY_CALIBRATION_TRIGGER_THRESHOLD" , "5.0" ))
133+ CALIBRATION_TRIGGER_K : int = int (os .getenv ("LATENCY_CALIBRATION_TRIGGER_K" , 2 ))
134+ CALIBRATION_EMA_ALPHA : float = float (os .getenv ("LATENCY_CALIBRATION_EMA_ALPHA" , "0.3" ))
135+
125136
126137class QueueGatedModel :
127138 """Wraps noqueue + queued sub-models into one joblib-serializable object.
@@ -320,6 +331,13 @@ def __init__(self, model_type: str = None):
320331 self .last_retrain_time = None
321332 self ._shutdown_event = threading .Event ()
322333 self ._training_thread : threading .Thread = None
334+ # Experiment C — calibration trigger. The continuous coverage loop sets
335+ # this when |coverage - quantile_alpha| EMA crosses
336+ # CALIBRATION_TRIGGER_THRESHOLD; the training loop checks it during its
337+ # inter-retrain sleep and fires an immediate retrain rather than waiting
338+ # the rest of RETRAINING_INTERVAL_SEC.
339+ self ._calibration_trigger = threading .Event ()
340+ self ._coverage_eval_thread : threading .Thread | None = None
323341
324342 def _get_prefix_bucket (self , prefix_score : float ) -> int :
325343 """Map prefix cache score to bucket index."""
@@ -714,6 +732,45 @@ def _train_model_with_scaling(
714732 logging .error (f"Error in _train_model_with_scaling: { e } " , exc_info = True )
715733 raise
716734
735+ def evaluate_current_coverage (self ) -> tuple [float | None , float | None ]:
736+ """Re-evaluate coverage on the *currently loaded* model + test buffer,
737+ without retraining. Appends to {ttft,tpot}_coverage_scores so /metrics
738+ reflects up-to-date calibration between scheduled retrains. Returns
739+ (ttft_cov, tpot_cov); either may be None if model/test data isn't ready.
740+ Quantile-objective only — no-op under mean-objective.
741+ """
742+ if self .objective_type == ObjectiveType .MEAN :
743+ return None , None
744+ if not self .is_ready :
745+ return None , None
746+
747+ with self .lock :
748+ ttft_test = list (self .ttft_test_data )
749+ tpot_test = list (self .tpot_test_data )
750+ ttft_model = self .ttft_model
751+ tpot_model = self .tpot_model
752+ ttft_scaler = self .ttft_scaler
753+ tpot_scaler = self .tpot_scaler
754+
755+ ttft_cov : float | None = None
756+ tpot_cov : float | None = None
757+
758+ if ttft_test and ttft_model is not None :
759+ _ , cov , _ = self ._calculate_metrics_on_test (ttft_model , ttft_scaler , ttft_test , "ttft" , "actual_ttft_ms" )
760+ if cov is not None :
761+ with self .lock :
762+ self .ttft_coverage_scores .append (cov )
763+ ttft_cov = cov
764+
765+ if tpot_test and tpot_model is not None :
766+ _ , cov , _ = self ._calculate_metrics_on_test (tpot_model , tpot_scaler , tpot_test , "tpot" , "actual_tpot_ms" )
767+ if cov is not None :
768+ with self .lock :
769+ self .tpot_coverage_scores .append (cov )
770+ tpot_cov = cov
771+
772+ return ttft_cov , tpot_cov
773+
717774 def _calculate_metrics_on_test (self , model , scaler , test_data , model_name , target_col ):
718775 """Calculate metrics on test data.
719776
@@ -1824,11 +1881,85 @@ def continuous_training_loop():
18241881 predictor .train ()
18251882 except Exception :
18261883 logging .error ("Error in periodic retraining" , exc_info = True )
1827- if predictor ._shutdown_event .wait (timeout = settings .RETRAINING_INTERVAL_SEC ):
1828- break
1884+
1885+ # Sleep the retrain interval in 1-second slices so a calibration trigger
1886+ # (set by continuous_coverage_loop) can short-circuit the wait. Shutdown
1887+ # still takes precedence.
1888+ slept = 0.0
1889+ slice_sec = 1.0
1890+ while slept < settings .RETRAINING_INTERVAL_SEC :
1891+ if predictor ._shutdown_event .is_set ():
1892+ logging .info ("Training loop exiting (shutdown)." )
1893+ return
1894+ if predictor ._calibration_trigger .is_set ():
1895+ predictor ._calibration_trigger .clear ()
1896+ logging .info (
1897+ f"Retraining triggered by calibration deviation after { slept :.1f} s of "
1898+ f"{ settings .RETRAINING_INTERVAL_SEC } s scheduled interval"
1899+ )
1900+ break
1901+ time .sleep (slice_sec )
1902+ slept += slice_sec
18291903 logging .info ("Training loop exiting." )
18301904
18311905
1906+ # --- Continuous Coverage Evaluation Loop (Experiment C) ---
1907+ def continuous_coverage_loop ():
1908+ """Periodically re-evaluates calibration of the currently loaded model
1909+ against the test buffer. Detects drift between scheduled retrains and
1910+ sets _calibration_trigger when an EMA of |coverage - quantile_alpha|
1911+ crosses CALIBRATION_TRIGGER_THRESHOLD for K consecutive evaluations.
1912+ No-op when COVERAGE_EVAL_INTERVAL_SEC <= 0.
1913+ """
1914+ if settings .COVERAGE_EVAL_INTERVAL_SEC <= 0 :
1915+ logging .info ("Continuous coverage evaluation disabled (COVERAGE_EVAL_INTERVAL_SEC=0)." )
1916+ return
1917+
1918+ # Wait long enough for the first train() to populate models + initial coverage.
1919+ time .sleep (15 )
1920+ 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
1925+
1926+ logging .info (
1927+ f"Continuous coverage loop started "
1928+ f"(eval_interval={ settings .COVERAGE_EVAL_INTERVAL_SEC } s, "
1929+ f"target={ target_pct :.0f} %, threshold={ settings .CALIBRATION_TRIGGER_THRESHOLD :.2f} pp, "
1930+ f"k={ settings .CALIBRATION_TRIGGER_K } , ema_alpha={ alpha :.2f} )"
1931+ )
1932+
1933+ while not predictor ._shutdown_event .is_set ():
1934+ try :
1935+ 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 } "
1947+ )
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
1957+ except Exception :
1958+ logging .error ("Error in continuous coverage loop" , exc_info = True )
1959+ predictor ._shutdown_event .wait (timeout = settings .COVERAGE_EVAL_INTERVAL_SEC )
1960+ logging .info ("Continuous coverage loop exiting." )
1961+
1962+
18321963# --- FastAPI Events ---
18331964@app .on_event ("startup" )
18341965async def startup_event ():
@@ -1839,6 +1970,13 @@ async def startup_event():
18391970 t .start ()
18401971 logging .info ("Background training started." )
18411972
1973+ # Experiment C — start continuous coverage evaluation only when enabled.
1974+ if settings .COVERAGE_EVAL_INTERVAL_SEC > 0 :
1975+ ct = threading .Thread (target = continuous_coverage_loop , daemon = True )
1976+ predictor ._coverage_eval_thread = ct
1977+ ct .start ()
1978+ logging .info ("Continuous coverage evaluation thread started." )
1979+
18421980
18431981@app .on_event ("shutdown" )
18441982async def shutdown_event ():
0 commit comments