aps-agent/tests/golden/test_preference_features.py

136 lines
6.6 KiB
Python
Raw Permalink Normal View History

# ============================================================
# 偏好多特征规则归纳黄金测试(plan.md §8.3 / 矩阵 66 行剩余项)
# 覆盖:特征抽取(订单结构/客户等级/约束压力)、分箱规则归纳、
# 特征命中与冷启动回退、旧样本向后兼容、可解释中文文案。
# ============================================================
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
from server.knowledge.preferences import PreferenceStore, extract_features
def _store(tmp_path: Path) -> PreferenceStore:
return PreferenceStore(str(tmp_path / "pref.json"))
def _sample_with_features(strategy: str, features: dict, *, source: str = "schedule.run") -> dict:
return {"strategy": strategy, "source": source, "actor": "u1",
"projectId": "p1", "at": "2026-08-02 08:00", "features": dict(features)}
def test_extract_features_from_world():
"""Extract order mix, urgency, customer tier, and conflict pressure."""
today = datetime.now(UTC).astimezone().date()
world = {
"productionOrders": [
{"productCode": "P1", "customerLevel": "VIP", "priority": 1,
"dueDate": (today + timedelta(days=1)).isoformat()}, # inside the half-horizon urgency window
{"productCode": "P1", "customerLevel": "A", "priority": 5,
"dueDate": (today + timedelta(days=14)).isoformat()},
{"productCode": "P2", "customerLevel": "B", "priority": 5,
"dueDate": today.isoformat()}, # due today is urgent
{"productCode": "P3", "customerLevel": "B", "priority": 5,
"dueDate": ""},
],
"scheduleParams": {"planningHorizonDays": 14},
"conflicts": [{"id": 1}, {"id": 2}, {"id": 3}],
}
feats = extract_features(world)
assert feats["orderCount"] == 4
assert feats["vipRatio"] == 0.25 # 1/4 VIP
assert feats["urgencyRatio"] == 0.5 # 2/4 急单
assert feats["mixCount"] == 3
assert feats["hardConflictCount"] == 3
def test_induced_rules_bins_high_urgency_prefers_delivery_first(tmp_path: Path):
"""规则归纳:高急单/多品种特征箱 → 交期优先。"""
ps = _store(tmp_path)
ps.samples = [
_sample_with_features("DELIVERY_FIRST", {"orderCount": 25, "urgencyRatio": 0.8, "mixCount": 9}),
_sample_with_features("DELIVERY_FIRST", {"orderCount": 30, "urgencyRatio": 0.9, "mixCount": 12}),
_sample_with_features("DELIVERY_FIRST", {"orderCount": 22, "urgencyRatio": 0.7, "mixCount": 10}),
]
rules = ps.induced_rules("p1")
assert rules["orderCount"]["high"]["strategy"] == "DELIVERY_FIRST"
assert rules["orderCount"]["high"]["count"] == 3
assert rules["urgencyRatio"]["high"]["strategy"] == "DELIVERY_FIRST"
strategy, votes, used = ps.preferred_strategy_with_features(
{"orderCount": 26, "urgencyRatio": 0.85}, project_id="p1")
assert strategy == "DELIVERY_FIRST"
assert votes["DELIVERY_FIRST"] > 0
assert len(used) >= 2 # 两条特征规则命中
def test_feature_rule_overrides_global_frequency(tmp_path: Path):
"""特征规则应胜过全局频次(多特征回归的价值:场景自适应)。"""
ps = _store(tmp_path)
# 全局上 CAPACITY_BALANCE 最多(4 条),但低品种小单场景全部选 DELIVERY_FIRST
for i in range(4):
ps.samples.append(_sample_with_features("CAPACITY_BALANCE",
{"orderCount": 40, "mixCount": 20}))
for i in range(3):
ps.samples.append(_sample_with_features("DELIVERY_FIRST",
{"orderCount": 2, "mixCount": 1}))
global_strategy, _ = ps.preferred_strategy(project_id="p1")
assert global_strategy == "CAPACITY_BALANCE"
strategy, _, used = ps.preferred_strategy_with_features(
{"orderCount": 1, "mixCount": 1}, project_id="p1")
assert strategy == "DELIVERY_FIRST"
assert used # 规则命中而非全局回退
def test_fallback_when_no_rule_matches(tmp_path: Path):
"""无特征样本/无规则命中 → 回退全局时间衰减偏好(冷启动兼容)。"""
ps = _store(tmp_path)
ps.samples = [
_sample_with_features("COMPREHENSIVE", {"orderCount": 3}),
_sample_with_features("DELIVERY_FIRST", {"orderCount": 3}),
]
# 未知特征(不在分箱表)→ 不命中任何规则 → 回退全局
strategy, _, used = ps.preferred_strategy_with_features(
{"unknownFeature": 99}, project_id="p1")
assert strategy in ("COMPREHENSIVE", "DELIVERY_FIRST")
assert used == []
# 完全没有特征样本 → induced_rules 空 + 回退
ps.samples = [{"strategy": "A", "source": "schedule.run", "actor": "u1",
"projectId": "p1", "at": "2026-08-02 08:00"}]
assert ps.induced_rules("p1") == {}
strategy, _, used = ps.preferred_strategy_with_features(
{"orderCount": 1}, project_id="p1")
assert strategy == "A" and used == []
def test_adopt_signal_weights_double_in_rules(tmp_path: Path):
"""规则得分:采用(scenario.apply) 权重 2 > 试排(schedule.run) 权重 1。"""
ps = _store(tmp_path)
ps.samples = [
_sample_with_features("A", {"orderCount": 2}, source="schedule.run"),
_sample_with_features("B", {"orderCount": 2}, source="scenario.apply"),
]
rules = ps.induced_rules("p1")
rule = rules["orderCount"]["low"]
assert rule["strategy"] == "B" # 1 次采用(权重2) > 1 次试排(权重1)
assert rule["score"] == 2.0
assert abs(rule["support"] - 2 / 3) < 0.01 # 0.67(舍入 2 位)
def test_explain_features_chinese_and_rules(tmp_path: Path):
"""特征版解释:中文依据 + 规则明细(可进证据链)。"""
ps = _store(tmp_path)
ps.samples = [
_sample_with_features("DELIVERY_FIRST", {"orderCount": 25, "urgencyRatio": 0.8}),
_sample_with_features("DELIVERY_FIRST", {"orderCount": 30, "urgencyRatio": 0.9}),
]
expl = ps.explain_features({"orderCount": 26, "urgencyRatio": 0.85}, project_id="p1")
assert expl["strategy"] == "DELIVERY_FIRST"
assert "依据订单结构与约束压力" in expl["reason"]
assert "orderCount" in expl["reason"]
assert expl["rules"] and expl["rules"][0]["bin"] in ("low", "med", "high")
# 无特征样本时回退普通解释并附空规则
ps.samples = []
expl2 = ps.explain_features({"orderCount": 26}, project_id="p1")
assert expl2["rules"] == [] and expl2["confidence"] == "cold"