134 lines
6.4 KiB
Python
134 lines
6.4 KiB
Python
# ============================================================
|
||
# 偏好多特征规则归纳黄金测试(plan.md §8.3 / 矩阵 66 行剩余项)
|
||
# 覆盖:特征抽取(订单结构/客户等级/约束压力)、分箱规则归纳、
|
||
# 特征命中与冷启动回退、旧样本向后兼容、可解释中文文案。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
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():
|
||
"""特征抽取:订单结构/客户等级/交期紧迫/品种数/约束压力。"""
|
||
world = {
|
||
"productionOrders": [
|
||
{"productCode": "P1", "customerLevel": "VIP", "priority": 1,
|
||
"dueDate": "2026-08-04"}, # 半周期内 → 急单
|
||
{"productCode": "P1", "customerLevel": "A", "priority": 5,
|
||
"dueDate": "2026-08-20"},
|
||
{"productCode": "P2", "customerLevel": "B", "priority": 5,
|
||
"dueDate": "2026-08-03"}, # 急单
|
||
{"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, scores, 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, scores, 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"
|