Files
opensquilla--opensquilla/tests/test_router_self_learning_promotion.py
2026-07-13 13:12:33 +08:00

707 lines
26 KiB
Python

"""Tests for M3 (promotion gate + active pointer) and M4 (auto-rollback)."""
from __future__ import annotations
from datetime import UTC, datetime
import numpy as np
import pytest
from opensquilla.gateway.config import RouterSelfLearningConfig, SquillaRouterConfig
from opensquilla.squilla_router.self_learning import encode_features, write_sample
from opensquilla.squilla_router.self_learning.dataset import TrainingDataset
from opensquilla.squilla_router.self_learning.evaluate import (
decide_promotion,
route_metrics,
session_holdout_splits,
)
from opensquilla.squilla_router.self_learning.orchestrator import (
in_process_trainer,
maybe_run_update_router,
)
from opensquilla.squilla_router.self_learning.promotion import (
learned_bundle_dir,
promote_candidate,
quarantine_candidate,
read_active,
resolve_active_bundle_dir,
rollback_active,
should_rollback,
write_active_atomic,
)
from opensquilla.squilla_router.self_learning.schema import RouterTrainSample
from opensquilla.squilla_router.self_learning.state import (
TrainState,
load_train_state,
save_train_state,
)
NOW = datetime(2026, 6, 6, 12, 0, 0, tzinfo=UTC)
def _cfg(**kw) -> RouterSelfLearningConfig:
base = dict(
enabled=True,
train_min_samples=4,
idle_hours=2.0,
cooldown_hours=72.0,
holdout_min_size=4,
holdout_pct=0.4,
holdout_repeats=2,
max_critical_under_routing=0.5,
cost_tolerance_pct=25.0,
)
base.update(kw)
return RouterSelfLearningConfig(**base)
# --------------------------------------------------------------------------- #
# Active pointer primitives
# --------------------------------------------------------------------------- #
def test_active_pointer_defaults_to_baseline(tmp_path) -> None:
assert read_active(tmp_path) == "baseline"
assert resolve_active_bundle_dir(tmp_path) is None
def test_promote_and_resolve(tmp_path) -> None:
bundle = learned_bundle_dir("v1-x", tmp_path)
bundle.mkdir(parents=True)
(bundle / "lgbm_main.bin").write_text("model", encoding="utf-8")
prev = promote_candidate("v1-x", tmp_path)
assert prev == "baseline"
assert read_active(tmp_path) == "learned/v1-x"
assert resolve_active_bundle_dir(tmp_path) == bundle
def test_resolve_falls_back_when_bundle_incomplete(tmp_path) -> None:
write_active_atomic("learned/ghost", tmp_path) # no such bundle on disk
assert resolve_active_bundle_dir(tmp_path) is None
def test_rollback_reverts_to_baseline(tmp_path) -> None:
write_active_atomic("learned/v1-x", tmp_path)
prev = rollback_active(tmp_path)
assert prev == "learned/v1-x"
assert read_active(tmp_path) == "baseline"
def test_quarantine_moves_bundle_out(tmp_path) -> None:
bundle = learned_bundle_dir("v1-x", tmp_path)
bundle.mkdir(parents=True)
(bundle / "lgbm_main.bin").write_text("m", encoding="utf-8")
dest = quarantine_candidate("v1-x", tmp_path)
assert dest is not None and dest.exists()
assert not bundle.exists()
def test_should_rollback_rules() -> None:
cfg = _cfg(min_monitor_samples=10, complaint_regression_delta=0.05)
# regression beyond delta with enough samples -> rollback
assert should_rollback(pre_complaint_rate=0.1, post_complaint_rate=0.3, post_n=20, config=cfg)
# not enough samples yet
assert not should_rollback(
pre_complaint_rate=0.1, post_complaint_rate=0.9, post_n=5, config=cfg
)
# within delta
assert not should_rollback(
pre_complaint_rate=0.1, post_complaint_rate=0.12, post_n=50, config=cfg
)
# no baseline recorded
assert not should_rollback(
pre_complaint_rate=None, post_complaint_rate=0.9, post_n=50, config=cfg
)
# auto_rollback disabled
off = _cfg(auto_rollback=False)
assert not should_rollback(
pre_complaint_rate=0.0, post_complaint_rate=0.9, post_n=99, config=off
)
# --------------------------------------------------------------------------- #
# Evaluation
# --------------------------------------------------------------------------- #
def test_route_metrics_basic() -> None:
pred = np.array([1, 2, 0, 3])
target = np.array([1, 3, 0, 3]) # under-routes the 2nd (pred 2 < target 3)
served = np.array([1, 1, 0, 3])
m = route_metrics(pred, target, served)
assert m.n == 4
assert m.agreement == 0.75
# critical = target>=2 -> indices 1,3; pred<target only at idx1 -> 0.5
assert m.critical_under_routing_rate == 0.5
def test_holdout_splits_are_session_whole_and_floored(tmp_path) -> None:
ds = TrainingDataset(
X=np.zeros((10, 390), np.float32),
y=np.zeros(10, np.int64),
w=np.ones(10, np.float32),
session_keys=[f"s{i % 2}" for i in range(10)], # 2 sessions
)
splits = session_holdout_splits(ds, holdout_pct=0.4, repeats=2, min_size=2)
assert splits
for train_idx, hold_idx in splits:
# no session appears on both sides
train_sessions = {ds.session_keys[i] for i in train_idx}
hold_sessions = {ds.session_keys[i] for i in hold_idx}
assert not (train_sessions & hold_sessions)
# too few sessions -> no splits
single = TrainingDataset(
X=np.zeros((4, 390), np.float32),
y=np.zeros(4, np.int64),
w=np.ones(4, np.float32),
session_keys=["s0"] * 4,
)
assert session_holdout_splits(single, holdout_pct=0.4, repeats=2, min_size=2) == []
def test_decide_promotion_paths() -> None:
cfg = _cfg(holdout_min_size=4, max_critical_under_routing=0.3, cost_tolerance_pct=10.0)
good_cv = {
"agreement": 0.9,
"critical_under_routing_rate": 0.1,
"mean_pred_idx": 1.0,
"served_mean_idx": 1.0,
"n_holdout": 20,
}
assert decide_promotion(good_cv, golden=None, baseline_golden=None, config=cfg).promote
# quality regression
bad_q = {**good_cv, "critical_under_routing_rate": 0.6}
d = decide_promotion(bad_q, golden=None, baseline_golden=None, config=cfg)
assert not d.promote and "quality_regression" in d.reason
# cost regression (predicts much higher than served)
bad_c = {**good_cv, "mean_pred_idx": 2.5, "served_mean_idx": 1.0}
d = decide_promotion(bad_c, golden=None, baseline_golden=None, config=cfg)
assert not d.promote and "cost_regression" in d.reason
# insufficient eval (no cv, no golden)
empty = {"agreement": None, "n_holdout": 0, "served_mean_idx": 0.0}
assert decide_promotion(empty, golden=None, baseline_golden=None, config=cfg).reason == (
"insufficient_eval"
)
# --------------------------------------------------------------------------- #
# Strategy integration (cache)
# --------------------------------------------------------------------------- #
def test_invalidate_strategy_cache(monkeypatch) -> None:
from opensquilla.engine.steps import squilla_router as step
step._strategy = object()
step._strategy_key = ("x",)
step.invalidate_strategy_cache()
assert step._strategy is None and step._strategy_key is None
def test_cache_key_tracks_active_bundle(monkeypatch) -> None:
from opensquilla.engine.steps import squilla_router as step
cfg = SquillaRouterConfig(self_learning=_cfg())
monkeypatch.setattr(step, "_active_bundle_dir", lambda _c: "learned/v1")
key1 = step._strategy_cache_key(cfg)
monkeypatch.setattr(step, "_active_bundle_dir", lambda _c: "learned/v2")
key2 = step._strategy_cache_key(cfg)
assert key1 != key2
def test_active_bundle_dir_none_when_disabled() -> None:
from opensquilla.engine.steps import squilla_router as step
cfg = SquillaRouterConfig(self_learning=RouterSelfLearningConfig(enabled=False))
assert step._active_bundle_dir(cfg) is None
# --------------------------------------------------------------------------- #
# Orchestrator: promote / reject / rollback
# --------------------------------------------------------------------------- #
def _write_separable_store(tmp_path, agent="agp", n=36) -> None:
"""Confidence-gate (high-value) turns with features cleanly separable by
final class, so CV agreement is high and cost does not regress.
Across 3 sessions this leaves each whole-session holdout fold ~12 training
rows (6 per class) — enough for LightGBM to split past ``min_data_in_leaf``.
"""
rng = np.random.RandomState(1)
for i in range(n):
cls = i % 2 # 0 -> R1, 1 -> R2
fc = "R2" if cls else "R1"
feats = (rng.randn(390) * 0.1).astype(np.float32)
feats[0] = 5.0 if cls else -5.0
write_sample(
RouterTrainSample(
session_key=f"s{i % 3}",
turn_index=i,
ts=f"2026-06-01T00:00:{i:02d}Z",
feature_schema_version="v1",
features_390_b64=encode_features(feats),
route_class=fc,
final_route_class=fc,
routed_tier="c2" if cls else "c1",
confidence_gate_applied=True,
),
agent,
home=tmp_path,
)
def test_orchestrator_promotes_good_candidate(tmp_path) -> None:
pytest.importorskip("lightgbm")
base = tmp_path / "base"
base.mkdir()
(base / "router.runtime.yaml").write_text("k: v\n", encoding="utf-8")
_write_separable_store(tmp_path)
res = maybe_run_update_router(
"agp",
router_cfg=SquillaRouterConfig(self_learning=_cfg()),
home=tmp_path,
now=NOW,
trainer=in_process_trainer,
base_dir=base,
)
assert res.ran and res.promoted and res.reason == "promoted", res
assert read_active(tmp_path) == f"learned/{res.version}"
state = load_train_state("agp", tmp_path)
assert state.active_version == res.version
assert state.promoted_at is not None
assert list((tmp_path / "router" / ".receipts").glob("agp-*-promoted.json"))
def test_orchestrator_rejects_on_golden_floor(tmp_path) -> None:
pytest.importorskip("lightgbm")
base = tmp_path / "base"
base.mkdir()
(base / "router.runtime.yaml").write_text("k: v\n", encoding="utf-8")
_write_separable_store(tmp_path, agent="agr")
# Golden set whose labels contradict the separable signal -> low agreement.
rng = np.random.RandomState(2)
gx = (rng.randn(20, 390) * 0.1).astype(np.float32)
gy = np.zeros(20, np.int64)
for i in range(20):
gx[i, 0] = 5.0 if i % 2 else -5.0
gy[i] = 0 if i % 2 else 2 # inverted vs training signal
golden = tmp_path / "golden.npz"
np.savez(golden, X=gx, y=gy)
res = maybe_run_update_router(
"agr",
router_cfg=SquillaRouterConfig(
self_learning=_cfg(golden_eval_path=str(golden), min_golden_agreement=0.9)
),
home=tmp_path,
now=NOW,
trainer=in_process_trainer,
base_dir=base,
)
assert res.ran and not res.promoted
assert "golden_below_floor" in res.reason
assert read_active(tmp_path) == "baseline" # never swapped
# candidate quarantined
assert (tmp_path / "router" / "learned" / ".quarantine" / res.version).exists()
def test_orchestrator_auto_rolls_back_regressed_candidate(tmp_path) -> None:
# Pre-promoted state with a clean baseline complaint rate.
bundle = learned_bundle_dir("vBad", tmp_path)
bundle.mkdir(parents=True)
(bundle / "lgbm_main.bin").write_text("m", encoding="utf-8")
write_active_atomic("learned/vBad", tmp_path)
save_train_state(
TrainState(
active_version="vBad",
promoted_at="2026-06-05T00:00:00Z",
pre_promotion_complaint_rate=0.0,
),
"agx",
tmp_path,
)
# Post-swap traffic with high complaint rate, recent (so the agent looks
# active and no training runs after the rollback).
for i in range(30):
write_sample(
RouterTrainSample(
session_key="s",
turn_index=i,
ts=f"2026-06-06T11:5{i % 10}:00Z",
feature_schema_version="v1",
features_390_b64=encode_features(np.zeros(390, np.float32)),
route_class="R1",
final_route_class="R2",
complaint_detected=(i < 20), # 20/30 complaints
),
"agx",
home=tmp_path,
)
res = maybe_run_update_router(
"agx",
router_cfg=SquillaRouterConfig(
self_learning=_cfg(min_monitor_samples=10, complaint_regression_delta=0.05)
),
home=tmp_path,
now=NOW,
trainer=in_process_trainer,
base_dir=tmp_path / "base",
)
assert res.rolled_back
assert read_active(tmp_path) == "baseline"
state = load_train_state("agx", tmp_path)
assert state.active_version is None
assert (tmp_path / "router" / "learned" / ".quarantine" / "vBad").exists()
assert list((tmp_path / "router" / ".receipts").glob("agx-*-rollback.json"))
# --------------------------------------------------------------------------- #
# Base-upgrade detach guard (verify_active_bundle)
# --------------------------------------------------------------------------- #
@pytest.fixture(autouse=True)
def _reset_verify_memo():
"""The verify memo is process-global; isolate it per test."""
import opensquilla.squilla_router.self_learning.promotion as promo
promo._verify_key = None
promo._verify_result = None
promo._fp_cache = None
yield
promo._verify_key = None
promo._verify_result = None
promo._fp_cache = None
def _make_learned(tmp_path, version: str, *, base_fingerprint: str | None) -> None:
import json
bundle = learned_bundle_dir(version, tmp_path)
bundle.mkdir(parents=True)
(bundle / "lgbm_main.bin").write_bytes(b"learned-head")
manifest: dict = {"version": version}
if base_fingerprint is not None:
manifest["base_fingerprint"] = base_fingerprint
(bundle / "learned_manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
def _make_base(tmp_path, content: bytes = b"base-model-v1"):
base = tmp_path / "base"
base.mkdir(exist_ok=True)
(base / "lgbm_main.bin").write_bytes(content)
return base
def test_verify_detaches_on_base_upgrade(tmp_path) -> None:
from opensquilla.squilla_router.self_learning.promotion import verify_active_bundle
from opensquilla.squilla_router.self_learning.train import base_bundle_fingerprint
base = _make_base(tmp_path)
old_fp = base_bundle_fingerprint(base)
_make_learned(tmp_path, "v1", base_fingerprint=old_fp)
write_active_atomic("learned/v1", tmp_path)
# Same base: nothing happens.
check = verify_active_bundle(base, tmp_path)
assert not check.detached and read_active(tmp_path) == "learned/v1"
# Base replaced (package upgrade): detach + quarantine + baseline.
(base / "lgbm_main.bin").write_bytes(b"base-model-v2-NEW-WEIGHTS")
check = verify_active_bundle(base, tmp_path)
assert check.detached and check.reason == "base_upgraded"
assert read_active(tmp_path) == "baseline"
assert (tmp_path / "router" / "learned" / ".quarantine" / "v1").exists()
def test_verify_trusts_legacy_bundle_without_fingerprint(tmp_path) -> None:
"""Pre-fingerprint candidates must not be mass-detached on upgrade."""
from opensquilla.squilla_router.self_learning.promotion import verify_active_bundle
base = _make_base(tmp_path)
_make_learned(tmp_path, "vLegacy", base_fingerprint=None)
write_active_atomic("learned/vLegacy", tmp_path)
check = verify_active_bundle(base, tmp_path)
assert not check.detached
assert read_active(tmp_path) == "learned/vLegacy"
def test_verify_memoizes_per_pointer_and_base(tmp_path, monkeypatch) -> None:
"""The 39MB hash must not run on every strategy-cache-key computation."""
import opensquilla.squilla_router.self_learning.train as train_mod
from opensquilla.squilla_router.self_learning.promotion import verify_active_bundle
base = _make_base(tmp_path)
fp = train_mod.base_bundle_fingerprint(base)
_make_learned(tmp_path, "v1", base_fingerprint=fp)
write_active_atomic("learned/v1", tmp_path)
calls = {"n": 0}
real = train_mod.base_bundle_fingerprint
def counting(base_dir):
calls["n"] += 1
return real(base_dir)
monkeypatch.setattr(train_mod, "base_bundle_fingerprint", counting)
import opensquilla.squilla_router.self_learning.promotion as promo
promo._fp_cache = None
verify_active_bundle(base, tmp_path)
verify_active_bundle(base, tmp_path)
verify_active_bundle(base, tmp_path)
# Repeated calls are stable, never detach, and the expensive hash is
# stat-gated to a single computation (see the dedicated hash-once test).
assert read_active(tmp_path) == "learned/v1"
assert calls["n"] == 1
promo._fp_cache = None
def test_verify_noop_on_baseline_pointer(tmp_path) -> None:
from opensquilla.squilla_router.self_learning.promotion import verify_active_bundle
base = _make_base(tmp_path)
check = verify_active_bundle(base, tmp_path)
assert not check.detached
assert read_active(tmp_path) == "baseline"
def test_orchestrator_reconciles_detached_candidate(tmp_path) -> None:
"""After a base upgrade the offline pass clears promotion-monitor state."""
from opensquilla.squilla_router.self_learning.train import base_bundle_fingerprint
base = _make_base(tmp_path)
old_fp = base_bundle_fingerprint(base)
_make_learned(tmp_path, "vOld", base_fingerprint=old_fp)
write_active_atomic("learned/vOld", tmp_path)
save_train_state(
TrainState(
active_version="vOld",
promoted_at="2026-06-05T00:00:00Z",
pre_promotion_complaint_rate=0.10,
),
"agd",
tmp_path,
)
# Upgrade the base.
(base / "lgbm_main.bin").write_bytes(b"base-model-v2")
res = maybe_run_update_router(
"agd",
router_cfg=SquillaRouterConfig(self_learning=_cfg()),
home=tmp_path,
now=NOW,
trainer=in_process_trainer,
base_dir=base,
)
# No training data -> gates fail, but the detach must have reconciled.
assert not res.rolled_back # detach is not a regression rollback
assert read_active(tmp_path) == "baseline"
state = load_train_state("agd", tmp_path)
assert state.active_version is None and state.promoted_at is None
assert state.pre_promotion_complaint_rate is None
assert list((tmp_path / "router" / ".receipts").glob("agd-*-detached.json"))
assert (tmp_path / "router" / "learned" / ".quarantine" / "vOld").exists()
def test_candidate_manifest_records_base_fingerprint(tmp_path) -> None:
import json
pytest.importorskip("lightgbm")
from types import SimpleNamespace
from opensquilla.squilla_router.self_learning.train import (
base_bundle_fingerprint,
build_candidate_bundle,
train_booster,
)
base = tmp_path / "base"
base.mkdir()
booster, _ = train_booster(
_mini_dataset(), base_model_path=None, config=SimpleNamespace(num_boost_round=8)
)
booster.save_model(str(base / "lgbm_main.bin"))
expected = base_bundle_fingerprint(base)
info = build_candidate_bundle(
_mini_dataset(),
base_dir=base,
learned_root=tmp_path / "learned",
config=SimpleNamespace(num_boost_round=4),
)
assert info.base_fingerprint == expected
manifest = json.loads(
(tmp_path / "learned" / info.version / "learned_manifest.json").read_text()
)
assert manifest["base_fingerprint"] == expected
def _mini_dataset() -> TrainingDataset:
rng = np.random.RandomState(0)
n = 24
return TrainingDataset(
X=rng.rand(n, 390).astype(np.float32),
y=(np.arange(n) % 3).astype(np.int64),
w=np.ones(n, dtype=np.float32),
served=(np.arange(n) % 3).astype(np.int64),
session_keys=[f"s{i // 4}" for i in range(n)],
turn_indices=[i % 4 for i in range(n)],
days=["2026-06-01"] * n,
reasons=["normal"] * n,
feature_schema_version="v1",
n_sessions=6,
)
# --------------------------------------------------------------------------- #
# Engine fallback chain: learned -> baseline -> heuristic
# --------------------------------------------------------------------------- #
def test_broken_learned_bundle_falls_back_to_baseline(tmp_path, monkeypatch) -> None:
"""A corrupt learned bundle must degrade to the shipped ML baseline, not
straight to heuristic tiering."""
from opensquilla.engine.steps import squilla_router as step
built = []
class _FakeStrategy:
source = "v4_phase3"
_available = True
def __init__(self, bundle_dir=None, **_kw):
built.append(bundle_dir)
if bundle_dir == "/learned/broken":
raise RuntimeError("incomplete V4 router artifact bundle")
import opensquilla.squilla_router.v4_phase3 as v4mod
monkeypatch.setattr(v4mod, "V4Phase3Strategy", _FakeStrategy)
monkeypatch.setattr(step, "_active_bundle_dir", lambda _c: "/learned/broken")
step.invalidate_strategy_cache()
cfg = SquillaRouterConfig(self_learning=_cfg())
strategy = step._get_strategy(cfg)
# First attempt hit the learned dir, second the baseline (None -> packaged).
assert built == ["/learned/broken", None]
assert isinstance(strategy, _FakeStrategy)
step.invalidate_strategy_cache()
def test_learned_and_baseline_both_broken_degrades_to_heuristic(
tmp_path, monkeypatch
) -> None:
from opensquilla.engine.routing.heuristic import HeuristicRouterStrategy
from opensquilla.engine.steps import squilla_router as step
class _AlwaysBroken:
source = "v4_phase3"
def __init__(self, **_kw):
raise RuntimeError("no runtime")
import opensquilla.squilla_router.v4_phase3 as v4mod
monkeypatch.setattr(v4mod, "V4Phase3Strategy", _AlwaysBroken)
monkeypatch.setattr(step, "_active_bundle_dir", lambda _c: "/learned/broken")
monkeypatch.setattr(step, "_router_runtime_warning_emitted", False)
step.invalidate_strategy_cache()
cfg = SquillaRouterConfig(self_learning=_cfg())
strategy = step._get_strategy(cfg)
assert isinstance(strategy, HeuristicRouterStrategy)
step.invalidate_strategy_cache()
def test_failed_detach_is_not_memoized_and_retries(tmp_path, monkeypatch) -> None:
"""A transient detach failure must not permanently trust the stale bundle."""
import opensquilla.squilla_router.self_learning.promotion as promo
from opensquilla.squilla_router.self_learning.promotion import verify_active_bundle
from opensquilla.squilla_router.self_learning.train import base_bundle_fingerprint
base = _make_base(tmp_path)
old_fp = base_bundle_fingerprint(base)
_make_learned(tmp_path, "v1", base_fingerprint=old_fp)
write_active_atomic("learned/v1", tmp_path)
(base / "lgbm_main.bin").write_bytes(b"base-model-v2-UPGRADED")
calls = {"n": 0}
real_rollback = promo.rollback_active
def flaky_rollback(home=None, **kw):
calls["n"] += 1
if calls["n"] == 1:
raise OSError("disk full")
return real_rollback(home, **kw)
monkeypatch.setattr(promo, "rollback_active", flaky_rollback)
first = verify_active_bundle(base, tmp_path)
assert not first.detached # fail-open on the transient error
assert read_active(tmp_path) == "learned/v1"
second = verify_active_bundle(base, tmp_path) # must RETRY, not trust memo
assert second.detached and second.reason == "base_upgraded"
assert read_active(tmp_path) == "baseline"
assert calls["n"] == 2
def test_fingerprint_hash_runs_once_per_base_file_change(tmp_path, monkeypatch) -> None:
"""The 39MB sha256 must be stat-gated, not recomputed per call."""
import opensquilla.squilla_router.self_learning.promotion as promo
import opensquilla.squilla_router.self_learning.train as train_mod
from opensquilla.squilla_router.self_learning.promotion import verify_active_bundle
base = _make_base(tmp_path)
fp = train_mod.base_bundle_fingerprint(base)
_make_learned(tmp_path, "v1", base_fingerprint=fp)
write_active_atomic("learned/v1", tmp_path)
promo._fp_cache = None
hashes = {"n": 0}
real = train_mod.base_bundle_fingerprint
def counting(base_dir):
hashes["n"] += 1
return real(base_dir)
monkeypatch.setattr(train_mod, "base_bundle_fingerprint", counting)
for _ in range(5):
verify_active_bundle(base, tmp_path)
assert hashes["n"] == 1 # one hash; four stat-gated cache hits
promo._fp_cache = None
def test_engine_active_bundle_dir_invokes_verify_and_detaches(
tmp_path, monkeypatch
) -> None:
"""The real engine path must run the base-upgrade guard, not just resolve.
No monkeypatching of _active_bundle_dir itself: config points v4_bundle_dir
at a synthetic base, the state home holds a promoted-but-stale candidate,
and resolving the bundle through the engine must detach it.
"""
from opensquilla.engine.steps import squilla_router as step
from opensquilla.squilla_router.self_learning.train import base_bundle_fingerprint
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path))
base = _make_base(tmp_path)
old_fp = base_bundle_fingerprint(base)
_make_learned(tmp_path, "vStale", base_fingerprint=old_fp)
write_active_atomic("learned/vStale", tmp_path)
(base / "lgbm_main.bin").write_bytes(b"base-model-v2-UPGRADED")
cfg = SquillaRouterConfig(self_learning=_cfg(), v4_bundle_dir=str(base))
resolved = step._active_bundle_dir(cfg)
assert resolved is None # stale candidate detached -> baseline
assert read_active(tmp_path) == "baseline"
assert (tmp_path / "router" / "learned" / ".quarantine" / "vStale").exists()