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

461 lines
15 KiB
Python

"""Tests for M2: trigger gates, train state, trainer, and orchestrator."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
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.feedback import (
load_feedback_map,
write_feedback,
)
from opensquilla.squilla_router.self_learning.gates import (
AGENT_ACTIVE,
COOLDOWN,
DISABLED,
INSUFFICIENT_CLASS_DIVERSITY,
INSUFFICIENT_DATA,
NO_DATA,
READY,
evaluate_training_gates,
)
from opensquilla.squilla_router.self_learning.orchestrator import (
in_process_trainer,
maybe_run_update_router,
)
from opensquilla.squilla_router.self_learning.schema import RouterTrainSample
from opensquilla.squilla_router.self_learning.state import (
EventStoreStats,
TrainState,
load_train_state,
save_train_state,
scan_event_store,
)
from opensquilla.squilla_router.self_learning.store import (
ENV_DISABLE,
agent_data_dir,
prune_expired_samples,
)
NOW = datetime(2026, 6, 6, 12, 0, 0, tzinfo=UTC)
def _cfg(**kw) -> RouterSelfLearningConfig:
base = dict(enabled=True, train_min_samples=5, idle_hours=2.0, cooldown_hours=72.0)
base.update(kw)
return RouterSelfLearningConfig(**base)
def _stats(**kw) -> EventStoreStats:
base = dict(total=100, high_value=50, distinct_classes=3, last_ts="2026-06-06T00:00:00Z")
base.update(kw)
return EventStoreStats(**base)
def mk(session, turn, route_class, *, final=None, complaint=False, conf_gate=False, ts=None):
return RouterTrainSample(
session_key=session,
turn_index=turn,
ts=ts or f"2026-06-06T00:00:{turn:02d}Z",
feature_schema_version="v1",
features_390_b64=encode_features(np.random.RandomState(turn).randn(390)),
route_class=route_class,
final_route_class=final or route_class,
complaint_detected=complaint,
confidence_gate_applied=conf_gate,
)
# --------------------------------------------------------------------------- #
# Gates (pure)
# --------------------------------------------------------------------------- #
def test_gate_ready_when_all_pass() -> None:
res = evaluate_training_gates(config=_cfg(), state=TrainState(), stats=_stats(), now=NOW)
assert res.should_train and res.reason == READY
def test_gate_disabled_master_off() -> None:
res = evaluate_training_gates(
config=_cfg(enabled=False), state=TrainState(), stats=_stats(), now=NOW
)
assert not res.should_train and res.reason == DISABLED
def test_gate_disabled_by_env(monkeypatch) -> None:
monkeypatch.setenv(ENV_DISABLE, "1")
res = evaluate_training_gates(config=_cfg(), state=TrainState(), stats=_stats(), now=NOW)
assert res.reason == DISABLED
def test_gate_no_data() -> None:
res = evaluate_training_gates(
config=_cfg(), state=TrainState(), stats=_stats(total=0), now=NOW
)
assert res.reason == NO_DATA
def test_gate_agent_active_blocks() -> None:
# last activity 30 min ago < idle_hours=2 -> defer
recent = (NOW - timedelta(minutes=30)).strftime("%Y-%m-%dT%H:%M:%SZ")
res = evaluate_training_gates(
config=_cfg(), state=TrainState(), stats=_stats(last_ts=recent), now=NOW
)
assert res.reason == AGENT_ACTIVE
def test_gate_cooldown_blocks() -> None:
recent_train = (NOW - timedelta(hours=10)).strftime("%Y-%m-%dT%H:%M:%SZ")
res = evaluate_training_gates(
config=_cfg(cooldown_hours=72.0),
state=TrainState(last_train_ts=recent_train),
stats=_stats(),
now=NOW,
)
assert res.reason == COOLDOWN
def test_gate_insufficient_data() -> None:
res = evaluate_training_gates(
config=_cfg(train_min_samples=200), state=TrainState(), stats=_stats(high_value=50), now=NOW
)
assert res.reason == INSUFFICIENT_DATA
def test_gate_failure_backoff_doubles_threshold() -> None:
# base 5, 3 failures -> 5 * 2^3 = 40; high_value=30 fails, 50 passes
state = TrainState(consecutive_failures=3)
res = evaluate_training_gates(
config=_cfg(train_min_samples=5), state=state, stats=_stats(high_value=30), now=NOW
)
assert res.reason == INSUFFICIENT_DATA
assert res.effective_min_samples == 40
res2 = evaluate_training_gates(
config=_cfg(train_min_samples=5), state=state, stats=_stats(high_value=50), now=NOW
)
assert res2.should_train
def test_gate_class_diversity_floor() -> None:
res = evaluate_training_gates(
config=_cfg(), state=TrainState(), stats=_stats(distinct_classes=1), now=NOW
)
assert res.reason == INSUFFICIENT_CLASS_DIVERSITY
# --------------------------------------------------------------------------- #
# State + stats scan
# --------------------------------------------------------------------------- #
def test_train_state_roundtrip(tmp_path) -> None:
assert load_train_state("a", tmp_path) == TrainState()
st = TrainState(
last_train_ts="2026-06-06T00:00:00Z", consecutive_failures=2, last_version="v1-x"
)
save_train_state(st, "a", tmp_path)
assert load_train_state("a", tmp_path) == st
def test_scan_event_store_counts(tmp_path) -> None:
write_sample(mk("s", 0, "R1"), "ag", home=tmp_path)
write_sample(mk("s", 1, "R1", complaint=True), "ag", home=tmp_path)
write_sample(mk("s", 2, "R2", conf_gate=True), "ag", home=tmp_path)
stats = scan_event_store("ag", home=tmp_path)
assert stats.total == 3
assert stats.high_value == 2 # complaint + conf_gate
assert stats.distinct_classes == 2 # R1, R2
assert stats.dominant_schema_version == "v1"
# --------------------------------------------------------------------------- #
# Trainer (real LightGBM on small synthetic data)
# --------------------------------------------------------------------------- #
def _synthetic_dataset(n=80) -> TrainingDataset:
rng = np.random.RandomState(0)
feats = rng.randn(n, 390).astype(np.float32)
y = rng.randint(0, 4, size=n).astype(np.int64)
# make features weakly separable so training has signal
for c in range(4):
feats[y == c, c] += 3.0
w = np.ones(n, dtype=np.float32)
return TrainingDataset(X=feats, y=y, w=w, feature_schema_version="vTEST", n_sessions=5)
def test_train_candidate_fresh_builds_loadable_bundle(tmp_path) -> None:
pytest.importorskip("lightgbm")
from types import SimpleNamespace
import lightgbm as lgb
from opensquilla.squilla_router.self_learning.train import build_candidate_bundle
base = tmp_path / "base"
base.mkdir()
(base / "router.runtime.yaml").write_text("k: v\n", encoding="utf-8")
(base / "features").mkdir()
(base / "features" / "meta.json").write_text("{}", encoding="utf-8")
# no lgbm_main.bin in base -> trains fresh
learned = tmp_path / "learned"
info = build_candidate_bundle(
_synthetic_dataset(),
base_dir=base,
learned_root=learned,
config=SimpleNamespace(num_boost_round=20),
)
assert info.used_init_model is False
bundle = learned / info.version
assert (bundle / "lgbm_main.bin").is_file() # real file, not symlink
assert (bundle / "router.runtime.yaml").exists() # reused artifact
assert (bundle / "learned_manifest.json").exists()
booster = lgb.Booster(model_file=str(bundle / "lgbm_main.bin"))
pred = booster.predict(_synthetic_dataset(4).X.astype(np.float64))
assert pred.shape == (4, 4) # 4 samples x 4 classes
def test_train_candidate_uses_init_model_when_base_present(tmp_path) -> None:
pytest.importorskip("lightgbm")
from types import SimpleNamespace
from opensquilla.squilla_router.self_learning.train import (
build_candidate_bundle,
train_booster,
)
# First produce a real base lgbm model to continue from.
base = tmp_path / "base"
base.mkdir()
booster, _ = train_booster(
_synthetic_dataset(), base_model_path=None, config=SimpleNamespace(num_boost_round=20)
)
booster.save_model(str(base / "lgbm_main.bin"))
info = build_candidate_bundle(
_synthetic_dataset(),
base_dir=base,
learned_root=tmp_path / "learned",
config=SimpleNamespace(num_boost_round=10),
)
assert info.used_init_model is True
def test_assembled_bundle_manifest_matches_retrained_head(tmp_path) -> None:
"""The copied artifact manifest must describe the *new* lgbm head.
The base manifest pins size/sha256 of the shipped model; without a rewrite
the runtime's bundle validation rejects every candidate as incomplete.
"""
pytest.importorskip("lightgbm")
import hashlib
import json
from types import SimpleNamespace
from opensquilla.squilla_router.self_learning.train import (
build_candidate_bundle,
train_booster,
)
base = tmp_path / "base"
base.mkdir()
booster, _ = train_booster(
_synthetic_dataset(), base_model_path=None, config=SimpleNamespace(num_boost_round=20)
)
booster.save_model(str(base / "lgbm_main.bin"))
base_bytes = (base / "lgbm_main.bin").read_bytes()
(base / "artifact_manifest.json").write_text(
json.dumps(
{
"schema_version": 1,
"files": [
{
"path": "lgbm_main.bin",
"size_bytes": len(base_bytes),
"sha256": hashlib.sha256(base_bytes).hexdigest(),
}
],
}
),
encoding="utf-8",
)
info = build_candidate_bundle(
_synthetic_dataset(),
base_dir=base,
learned_root=tmp_path / "learned",
config=SimpleNamespace(num_boost_round=10),
)
bundle = tmp_path / "learned" / info.version
manifest_path = bundle / "artifact_manifest.json"
assert manifest_path.is_file() and not manifest_path.is_symlink()
new_bytes = (bundle / "lgbm_main.bin").read_bytes()
entry = next(
e
for e in json.loads(manifest_path.read_text(encoding="utf-8"))["files"]
if e["path"] == "lgbm_main.bin"
)
assert entry["size_bytes"] == len(new_bytes)
assert entry["sha256"] == hashlib.sha256(new_bytes).hexdigest()
# The base manifest is untouched.
base_entry = json.loads((base / "artifact_manifest.json").read_text(encoding="utf-8"))
assert base_entry["files"][0]["sha256"] == hashlib.sha256(base_bytes).hexdigest()
# --------------------------------------------------------------------------- #
# Orchestrator (injected trainer; no subprocess)
# --------------------------------------------------------------------------- #
def _router_cfg(**kw) -> SquillaRouterConfig:
return SquillaRouterConfig(self_learning=_cfg(**kw))
def _write_ready_store(tmp_path, agent="ag", n_high=8) -> None:
# idle: timestamps old enough; >=2 distinct final classes + high-value signals
for i in range(n_high):
write_sample(
mk("s1", i, "R0" if i % 2 else "R1", final="R2" if i % 2 else "R3",
complaint=True, ts=f"2026-06-01T00:00:{i:02d}Z"),
agent, home=tmp_path,
)
write_sample(mk("s2", 0, "R1", final="R1", ts="2026-06-01T01:00:00Z"), agent, home=tmp_path)
def test_orchestrator_noop_when_gates_fail(tmp_path) -> None:
# empty store -> NO_DATA, trainer never called
calls = []
res = maybe_run_update_router(
"ag",
router_cfg=_router_cfg(train_min_samples=5),
home=tmp_path,
now=NOW,
trainer=lambda *a, **k: calls.append(1),
base_dir=tmp_path / "base",
)
assert not res.ran and res.reason == NO_DATA and not calls
def test_prune_expired_samples_removes_only_files_past_retention(tmp_path) -> None:
data_dir = agent_data_dir("ag", tmp_path)
data_dir.mkdir(parents=True)
stale = data_dir / "samples-20260401.jsonl"
fresh = data_dir / "samples-20260605.jsonl"
stale.write_text("{}\n", encoding="utf-8")
fresh.write_text("{}\n", encoding="utf-8")
removed = prune_expired_samples("ag", 7, home=tmp_path, now=NOW)
assert removed == 1
assert not stale.exists()
assert fresh.exists()
def test_orchestrator_enforces_retention_before_gates_and_training(tmp_path) -> None:
import json
data_dir = agent_data_dir("ag", tmp_path)
data_dir.mkdir(parents=True)
stale_path = data_dir / "samples-20260401.jsonl"
rows = [
mk("s1", i, "R0" if i % 2 else "R1", final="R2" if i % 2 else "R3",
complaint=True, ts=f"2026-04-01T00:00:{i:02d}Z")
for i in range(8)
]
rows.append(mk("s2", 0, "R1", final="R1", ts="2026-04-01T01:00:00Z"))
stale_path.write_text(
"".join(json.dumps(s.to_json_dict(), ensure_ascii=False) + "\n" for s in rows),
encoding="utf-8",
)
calls = []
res = maybe_run_update_router(
"ag",
router_cfg=_router_cfg(train_min_samples=5, retention_days=7),
home=tmp_path,
now=NOW,
trainer=lambda *a, **k: calls.append(1),
base_dir=tmp_path / "base",
)
assert not stale_path.exists()
assert not res.ran and res.reason == NO_DATA and not calls
def test_orchestrator_applies_sample_retention_to_feedback(tmp_path) -> None:
write_feedback(
"ag",
decision_id="stale-rating",
session_key="agent:ag:webchat:s1",
turn_index=0,
rating="down",
home=tmp_path,
now=NOW - timedelta(days=10),
retention_days=30,
)
assert "stale-rating" in load_feedback_map("ag", home=tmp_path)
res = maybe_run_update_router(
"ag",
router_cfg=_router_cfg(train_min_samples=5, retention_days=7),
home=tmp_path,
now=NOW,
trainer=lambda *a, **k: None,
base_dir=tmp_path / "base",
)
assert not res.ran and res.reason == NO_DATA
assert load_feedback_map("ag", home=tmp_path) == {}
def test_orchestrator_trains_and_records_state(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_ready_store(tmp_path, n_high=8)
res = maybe_run_update_router(
"ag",
router_cfg=_router_cfg(train_min_samples=5, idle_hours=2.0, cooldown_hours=72.0),
home=tmp_path,
now=NOW,
trainer=in_process_trainer,
base_dir=base,
)
# Training ran and a candidate + state were recorded (promotion is gated
# separately; with only 8 samples the gate rejects on insufficient_eval).
assert res.ran and res.version
state = load_train_state("ag", tmp_path)
assert state.last_version == res.version
assert state.last_train_ts is not None
receipts = list((tmp_path / "router" / ".receipts").glob("ag-*-*.json"))
assert receipts
def test_orchestrator_records_failure_and_backsoff(tmp_path) -> None:
_write_ready_store(tmp_path, n_high=8)
def boom(*a, **k):
raise RuntimeError("training blew up")
res = maybe_run_update_router(
"ag",
router_cfg=_router_cfg(train_min_samples=5),
home=tmp_path,
now=NOW,
trainer=boom,
base_dir=tmp_path / "base",
)
assert not res.ran and res.reason == "train_failed" and res.error
state = load_train_state("ag", tmp_path)
assert state.consecutive_failures == 1
assert list((tmp_path / "router" / ".receipts").glob("ag-*-train_failure.json"))