aps-agent/tests/golden/test_preference_features.py

136 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 偏好多特征规则归纳黄金测试(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"