461 lines
15 KiB
Python
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"))
|