"""纯域求值引擎:排序、硬约束拦截、用户禁忌压过专业推荐、性能基准(T-P2-02)。""" from __future__ import annotations import time from typing import Any from packages.rules import dsl, engine def _rule( rule_key: str, priority: int, *, category: str = "test", confidence: float = 0.9, is_hard: bool = False, conditions: dict[str, Any] | None = None, recommend: tuple[dict[str, Any], ...] = (), avoid: tuple[dict[str, Any], ...] = (), explanation: str | None = None, ) -> dsl.Rule: effects: dict[str, Any] = {} if recommend: effects["recommend"] = list(recommend) if avoid: effects["avoid"] = list(avoid) if explanation: effects["explanation"] = explanation if not effects: effects["explanation"] = "测试规则" # 空效果本身非法,测试助手兜底 return dsl.Rule.model_validate( { "rule_key": rule_key, "category": category, "priority": priority, "conditions": conditions or {}, "effects": effects, "is_hard": is_hard, "confidence": confidence, } ) # ── 排序与命中链 ─────────────────────────────────────────────────────────── def test_hits_sorted_by_priority_then_confidence_then_key() -> None: rules = [ _rule("R-LOW", 50), _rule("R-TIE-B", 90, confidence=0.9), _rule("R-TOP", 95, confidence=0.5), _rule("R-TIE-A", 90, confidence=0.9), _rule("R-TIE-C", 90, confidence=0.8), ] verdict = engine.evaluate(rules, dsl.RuleContext()) assert [h.rule_key for h in verdict.rule_hits] == [ "R-TOP", "R-TIE-A", "R-TIE-B", "R-TIE-C", "R-LOW", ] assert [h.priority for h in verdict.rule_hits] == [95, 90, 90, 90, 50] def test_rule_hit_carries_metadata_and_explanations_in_priority_order() -> None: rules = [ _rule("R-LOW", 50, explanation="趋势解释", category="trend", confidence=0.7), _rule("R-HIGH", 90, explanation="体型解释", category="body_shape", confidence=0.95), ] verdict = engine.evaluate(rules, dsl.RuleContext()) top = verdict.rule_hits[0] assert (top.rule_key, top.priority, top.category, top.confidence) == ( "R-HIGH", 90, "body_shape", 0.95, ) assert top.is_hard is False assert verdict.explanations == ("体型解释", "趋势解释") assert verdict.explanation == "体型解释;趋势解释" def test_no_rules_or_no_hits_yield_empty_verdict() -> None: assert engine.evaluate([], dsl.RuleContext()).recommend == () verdict = engine.evaluate( [_rule("R-X", 90, conditions={"body_shape": "A型"})], dsl.RuleContext(body_shape="H型"), ) assert verdict.rule_hits == () assert verdict.blocked is False # ── 硬约束 ───────────────────────────────────────────────────────────────── def test_hard_rule_with_avoid_populates_hard_blocks() -> None: avoid = {"slot": "shoes", "attrs": {"material": ["真皮"]}} rules = [_rule("R-HARD", 100, is_hard=True, avoid=(avoid,), explanation="合规红线")] verdict = engine.evaluate(rules, dsl.RuleContext()) assert verdict.blocked is True assert len(verdict.hard_blocks) == 1 block = verdict.hard_blocks[0] assert block.rule_key == "R-HARD" assert block.avoid[0].slot == "shoes" assert block.avoid[0].attrs["material"] == ("真皮",) assert block.explanation == "合规红线" def test_hard_rule_without_avoid_does_not_block() -> None: # 拦截语义只由 avoid 表达:is_hard 规则只写 recommend 就无物可拦 rules = [ _rule( "R-HARD-REC", 100, is_hard=True, recommend=({"slot": "top", "attrs": {"collar": ["衬衫领"]}},), ) ] verdict = engine.evaluate(rules, dsl.RuleContext()) assert verdict.hard_blocks == () assert verdict.blocked is False def test_hard_rule_not_fired_does_not_block() -> None: rules = [ _rule( "R-HARD", 100, is_hard=True, conditions={"occasion": "面试"}, avoid=({"slot": "top", "attrs": {"neckline": ["深V"]}},), ) ] assert engine.evaluate(rules, dsl.RuleContext(occasion="通勤")).blocked is False assert engine.evaluate(rules, dsl.RuleContext(occasion="面试")).blocked is True def test_soft_rule_with_avoid_never_blocks() -> None: rules = [_rule("R-SOFT", 90, avoid=({"slot": "top", "attrs": {"silhouette": ["修身"]}},))] assert engine.evaluate(rules, dsl.RuleContext()).hard_blocks == () # ── 冲突仲裁:高优先级 avoid 剔除严格低优先级的冲突 recommend ──────────────── def test_user_taboo_overrides_professional_recommend() -> None: # 用户禁忌(95)与专业推荐(90)同 slot 同维度取值相交 → 推荐被剔除 rules = [ _rule( "R-PRO", dsl.PRIORITY_BODY_SHAPE, recommend=({"slot": "top", "attrs": {"shoulder": ["落肩"]}},), ), _rule( "R-TABOO", dsl.PRIORITY_USER_TABOO, conditions={"user_taboos": {"include": ["oversize"]}}, avoid=({"slot": "top", "attrs": {"shoulder": ["落肩"]}},), ), ] verdict = engine.evaluate(rules, dsl.RuleContext(user_taboos=("oversize",))) assert verdict.recommend == () assert verdict.avoid != () # 禁忌约束保留在 avoid 里供下游过滤 assert [h.rule_key for h in verdict.rule_hits] == ["R-TABOO", "R-PRO"] def test_taboo_keeps_non_conflicting_recommend() -> None: # 不同维度(length vs shoulder)不构成冲突:专业推荐保留 rules = [ _rule( "R-PRO", 90, recommend=({"slot": "top", "attrs": {"shoulder": ["垫肩"]}},), ), _rule( "R-TABOO", 95, avoid=({"slot": "top", "attrs": {"length": ["短款"]}},), ), ] verdict = engine.evaluate(rules, dsl.RuleContext()) assert [item.slot for item in verdict.recommend] == ["top"] assert verdict.recommend[0].attrs["shoulder"] == ("垫肩",) def test_taboo_does_not_override_higher_priority_recommend() -> None: # 合规(100)> 用户禁忌(95):只有"严格更低"优先级的推荐才会被剔除 rules = [ _rule( "R-COMPLIANCE", dsl.PRIORITY_COMPLIANCE, recommend=({"slot": "top", "attrs": {"shoulder": ["落肩"]}},), ), _rule( "R-TABOO", dsl.PRIORITY_USER_TABOO, avoid=({"slot": "top", "attrs": {"shoulder": ["落肩"]}},), ), ] verdict = engine.evaluate(rules, dsl.RuleContext()) assert len(verdict.recommend) == 1 def test_lower_priority_recommend_vetoed_by_higher_soft_avoid() -> None: # 通用优先级语义:体型 avoid(90)同样压过趋势 recommend(50) rules = [ _rule( "R-TREND", dsl.PRIORITY_TREND, recommend=({"slot": "top", "attrs": {"silhouette": ["修身"]}},), ), _rule( "R-BODY", dsl.PRIORITY_BODY_SHAPE, avoid=({"slot": "top", "attrs": {"silhouette": ["修身"]}},), ), ] verdict = engine.evaluate(rules, dsl.RuleContext()) assert verdict.recommend == () def test_same_priority_avoid_does_not_veto_recommend() -> None: # 同优先级不仲裁:推荐与回避并存,由下游按场景取舍 rules = [ _rule( "R-A", 90, recommend=({"slot": "top", "attrs": {"sleeve": ["泡泡袖"]}},), ), _rule( "R-B", 90, avoid=({"slot": "top", "attrs": {"sleeve": ["泡泡袖"]}},), ), ] verdict = engine.evaluate(rules, dsl.RuleContext()) assert len(verdict.recommend) == 1 def test_different_slot_never_conflicts() -> None: rules = [ _rule( "R-REC", 50, recommend=({"slot": "bottom", "attrs": {"silhouette": ["紧身"]}},), ), _rule( "R-AVOID", 100, is_hard=True, avoid=({"slot": "top", "attrs": {"silhouette": ["紧身"]}},), ), ] verdict = engine.evaluate(rules, dsl.RuleContext()) assert len(verdict.recommend) == 1 assert verdict.blocked is True # 拦截归拦截,推荐归推荐 # ── 汇总与去重 ───────────────────────────────────────────────────────────── def test_identical_recommend_deduped_and_order_follows_priority() -> None: item = {"slot": "top", "attrs": {"collar": ["衬衫领"]}} rules = [ _rule("R-STYLE", 70, recommend=(item,)), _rule("R-OCCASION", 80, recommend=(item,)), ] verdict = engine.evaluate(rules, dsl.RuleContext()) assert len(verdict.recommend) == 1 assert len(verdict.avoid) == 0 def test_identical_avoid_deduped() -> None: avoid = {"slot": "top", "attrs": {"silhouette": ["修身"]}} rules = [ _rule("R-A", 90, avoid=(avoid,)), _rule("R-B", 60, avoid=(avoid,)), ] verdict = engine.evaluate(rules, dsl.RuleContext()) assert len(verdict.avoid) == 1 # ── 性能基准(T-P2-02:p95 < 50ms @ 400 条规则)───────────────────────────── def test_benchmark_400_rules_well_under_50ms_budget() -> None: """400 条规则 × 1000 次求值。 断言均值 < 5ms/次(对 50ms 预算留 10 倍裕量,CI 机器差异不至于 flaky), 并直接断言 p95 < 50ms 对应验收指标。求值是纯内存操作,规则从缓存来, 不含任何 I/O。 """ shapes = ("A型", "H型", "X型", "O型") rules = [ _rule( f"R-BENCH-{i:03d}", 50 + (i % 6) * 10, category="bench", confidence=0.5 + (i % 10) / 20, conditions={"body_shape": shapes[i % 4], "season": {"exclude": ["夏"]}}, recommend=({"slot": "top", "attrs": {"collar": [f"领型{i % 5}"]}},), avoid=({"slot": "bottom", "attrs": {"silhouette": ["紧身"]}},), explanation=f"基准规则 {i}", ) for i in range(400) ] assert len(rules) == 400 ctx = dsl.RuleContext(body_shape="A型", season="冬", temperature=18.0) engine.evaluate(rules, ctx) # 预热:首次调用的函数定位等开销不摊给被测对象 timings: list[float] = [] for _ in range(1000): start = time.perf_counter() engine.evaluate(rules, ctx) timings.append(time.perf_counter() - start) mean = sum(timings) / len(timings) p95 = sorted(timings)[int(len(timings) * 0.95) - 1] assert mean < 0.005, f"平均单次求值 {mean * 1000:.2f}ms,超出 5ms 基线" assert p95 < 0.05, f"p95 单次求值 {p95 * 1000:.2f}ms,超出 50ms 预算"