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