Disruption-System / tests /test_hysteresis.py
Vittal-M's picture
Upload 66 files
906e104 verified
"""Hysteresis tests for BatchwiseSelector.
DAHS_2.1 uses a *relative* margin: a switch is blocked unless the new
heuristic's confidence exceeds the current's by at least HYSTERESIS_MARGIN
(15% relative, calibration-invariant across RF and XGB predict_proba).
We feed the selector synthetic state with a stub model whose probabilities
we fully control, then assert the switch fires iff
new_conf >= current_conf * (1 + HYSTERESIS_MARGIN).
"""
from __future__ import annotations
import numpy as np
import pytest
from src.features import FeatureExtractor, SCENARIO_FEATURE_NAMES
from src.hybrid_scheduler import BatchwiseSelector
N_FEATS = len(SCENARIO_FEATURE_NAMES)
class _StubModel:
"""Returns whatever predict_proba vector the test sets on .next_proba."""
def __init__(self):
self.next_proba = np.array([1.0, 0.0, 0.0, 0.0, 0.0, 0.0])
def predict_proba(self, X):
return self.next_proba.reshape(1, -1)
def _scenario_state(n_orders=50, util=0.5, n_broken=0, lunch=False):
return {
"current_time": 10.0,
"n_orders_in_system": n_orders,
"queue_sizes": {z: 5 for z in range(8)},
"zone_utilization": {z: util for z in range(8)},
"n_broken_stations": n_broken,
"lunch_active": lunch,
"surge_multiplier": 1.0,
"completed_so_far": 0,
"waiting_jobs": [],
"completed_jobs": [],
"all_jobs": {},
}
def _make_selector():
fe = FeatureExtractor()
sel = BatchwiseSelector(
model=_StubModel(),
feature_extractor=fe,
feature_importances=np.ones(N_FEATS) / N_FEATS,
feature_names=list(SCENARIO_FEATURE_NAMES),
)
return sel, sel._model
def test_initial_evaluation_picks_argmax():
sel, model = _make_selector()
model.next_proba = np.array([0.05, 0.05, 0.05, 0.05, 0.75, 0.05])
sel.update_state(_scenario_state())
sel._reevaluate(now=0.0)
assert sel._current_heuristic == "wspt"
assert sel._current_confidence == pytest.approx(0.75, abs=1e-6)
def test_small_relative_advantage_blocked_by_hysteresis():
sel, model = _make_selector()
# Lock in WSPT at 0.55
model.next_proba = np.array([0.05, 0.05, 0.05, 0.10, 0.55, 0.20])
sel.update_state(_scenario_state())
sel._reevaluate(now=0.0)
assert sel._current_heuristic == "wspt"
locked = sel._current_confidence # 0.55
# New top: Slack at 0.60. Threshold = 0.55 * 1.15 = 0.6325 → blocked.
model.next_proba = np.array([0.05, 0.05, 0.05, 0.10, 0.15, 0.60])
sel.update_state(_scenario_state())
sel._reevaluate(now=20.0)
assert sel._current_heuristic == "wspt", "Hysteresis should have blocked the switch"
last = sel.switching_log.entries[-1]
assert last["reason"] == "hysteresis_blocked"
assert last["switched"] is False
def test_large_relative_advantage_triggers_switch():
sel, model = _make_selector()
# Lock in WSPT at 0.45
model.next_proba = np.array([0.05, 0.05, 0.10, 0.10, 0.45, 0.25])
sel.update_state(_scenario_state())
sel._reevaluate(now=0.0)
assert sel._current_heuristic == "wspt"
locked = sel._current_confidence # 0.45
# ATC at 0.85 — 0.85 > 0.45 * 1.15 = 0.5175, so switch should fire.
new_top = 0.85
rest = (1.0 - new_top) / 5
proba = np.full(6, rest)
proba[3] = new_top
model.next_proba = proba
sel.update_state(_scenario_state())
sel._reevaluate(now=20.0)
assert sel._current_heuristic == "atc"
last = sel.switching_log.entries[-1]
assert last["switched"] is True
assert last["reason"] == "ml_decision"
def test_same_heuristic_keeps_no_hysteresis_block():
"""If the model picks the same heuristic again, that's a 'hold', not a block."""
sel, model = _make_selector()
model.next_proba = np.array([0.05, 0.05, 0.05, 0.10, 0.55, 0.20])
sel.update_state(_scenario_state())
sel._reevaluate(now=0.0)
sel._reevaluate(now=20.0)
last = sel.switching_log.entries[-1]
assert last["selected"] == "wspt"
assert last["reason"] != "hysteresis_blocked"