Preformu / tests /test_compatibility_rules.py
Kevinshh's picture
Deploy Kernel+Skill architecture to HF Spaces; wire advanced stability features; remove deprecated entry points
19729e9
Raw
History Blame Contribute Delete
9.51 kB
"""确定性相容性规则引擎 ``skills.compatibility.rules`` 的单元 / 属性测试(任务 18)。
覆盖需求 8.2:相容性「计算」为确定性官能团 → 风险规则引擎(纯 Python,风险分级)。
测试取向:
- 规则正确性:已知官能团 × 辅料特性组合命中预期反应与风险等级(红 / 黄 / 绿)。
- 确定性:相同输入恒得相同输出(无随机 / 无网络 / 无 LLM)。
- 聚合正确:整体风险 = 各辅料风险的最坏者(worse 聚合)。
- 鲁棒性:未知辅料 / 空输入 / dict 与 str 混合输入均稳健处理。
导入路径由 tests/conftest.py 统一设置(仓库根加入 sys.path,顶层 skills 包可导入)。
"""
from __future__ import annotations
import pytest
from skills.compatibility import rules
# ---------------------------------------------------------------------------
# 辅料解析
# ---------------------------------------------------------------------------
def test_resolve_known_excipient_lactose_is_reducing_sugar():
profile = rules.resolve_excipient("乳糖")
assert profile.known is True
assert "reducing_sugar" in profile.tags
def test_resolve_excipient_english_alias_case_insensitive():
profile = rules.resolve_excipient("Magnesium Stearate")
assert profile.known is True
assert "alkaline" in profile.tags
def test_resolve_unknown_excipient_marked_not_known_with_no_tags():
profile = rules.resolve_excipient("某种未收录辅料XYZ")
assert profile.known is False
assert profile.tags == frozenset()
def test_resolve_empty_name_returns_unknown_placeholder():
profile = rules.resolve_excipient("")
assert profile.known is False
assert profile.tags == frozenset()
# ---------------------------------------------------------------------------
# 规则命中:五大反应维度
# ---------------------------------------------------------------------------
def test_maillard_high_risk_amine_plus_reducing_sugar():
"""伯胺 + 还原糖(乳糖)→ 美拉德高风险。"""
assessment = rules.evaluate(
functional_groups=[{"id": "primary_amine"}],
excipients=["乳糖"],
)
assert assessment.overall_risk == rules.RISK_HIGH
er = assessment.excipient_results[0]
reactions = {r.reaction: r.risk for r in er.reactions}
assert reactions.get("maillard") == rules.RISK_HIGH
def test_oxidation_high_risk_thiol_plus_peroxide():
"""巯基 + 过氧化物(交联聚维酮)→ 氧化高风险。"""
assessment = rules.evaluate(["thiol"], ["交联聚维酮"])
er = assessment.excipient_results[0]
reactions = {r.reaction: r.risk for r in er.reactions}
assert reactions.get("oxidation") == rules.RISK_HIGH
assert assessment.overall_risk == rules.RISK_HIGH
def test_oxidation_medium_risk_phenol_plus_peroxide():
"""酚羟基 + 过氧化物 → 氧化中风险(非高)。"""
assessment = rules.evaluate(["phenol"], ["聚维酮"])
er = assessment.excipient_results[0]
reactions = {r.reaction: r.risk for r in er.reactions}
assert reactions.get("oxidation") == rules.RISK_MEDIUM
def test_hydrolysis_high_risk_ester_plus_alkaline():
"""酯基 + 碱性辅料(硬脂酸镁)→ 碱催化水解高风险。"""
assessment = rules.evaluate(["ester"], ["硬脂酸镁"])
er = assessment.excipient_results[0]
reactions = {r.reaction: r.risk for r in er.reactions}
assert reactions.get("hydrolysis") == rules.RISK_HIGH
def test_acid_base_medium_risk_carboxyl_plus_alkaline():
"""羧基 + 碱性辅料 → 酸碱相互作用中风险。"""
assessment = rules.evaluate(["carboxylic_acid"], ["磷酸氢钙"])
er = assessment.excipient_results[0]
reactions = {r.reaction: r.risk for r in er.reactions}
assert reactions.get("acid_base") == rules.RISK_MEDIUM
def test_adsorption_low_risk_any_group_plus_high_surface_area():
"""任意官能团 + 高比表面积辅料(胶态二氧化硅)→ 吸附低风险。"""
assessment = rules.evaluate(["alcohol"], ["胶态二氧化硅"])
er = assessment.excipient_results[0]
reactions = {r.reaction: r.risk for r in er.reactions}
assert reactions.get("adsorption") == rules.RISK_LOW
# ---------------------------------------------------------------------------
# 无反应 / 惰性辅料
# ---------------------------------------------------------------------------
def test_inert_excipient_yields_no_significant_risk():
"""甘露醇(非还原糖醇,惰性)+ 伯胺 → 无显著相互作用。"""
assessment = rules.evaluate(["primary_amine"], ["甘露醇"])
er = assessment.excipient_results[0]
assert er.reactions == []
assert er.risk == rules.RISK_NONE
assert assessment.overall_risk == rules.RISK_NONE
def test_no_functional_groups_adds_note():
"""无官能团时仍可评估辅料,但附低置信度提示。"""
assessment = rules.evaluate([], ["乳糖"])
# 无官能团 → 美拉德等规则不命中。
assert assessment.excipient_results[0].reactions == []
assert any("未识别到 API 官能团" in n for n in assessment.notes)
# ---------------------------------------------------------------------------
# 整体风险聚合(取最坏)
# ---------------------------------------------------------------------------
def test_overall_risk_is_worst_of_all_excipients():
"""多辅料:整体风险取各辅料的最坏者。"""
assessment = rules.evaluate(
functional_groups=["primary_amine", "ester"],
excipients=["甘露醇", "胶态二氧化硅", "乳糖"], # none, low(adsorption), high(maillard)
)
risks = {er.display_zh: er.risk for er in assessment.excipient_results}
assert risks["甘露醇"] == rules.RISK_NONE
assert risks["胶态二氧化硅"] == rules.RISK_LOW
assert risks["乳糖"] == rules.RISK_HIGH
assert assessment.overall_risk == rules.RISK_HIGH
def test_counts_and_summary_structure():
assessment = rules.evaluate(["primary_amine", "ester"], ["乳糖", "甘露醇"])
summary = assessment.summary()
assert summary["n_excipients"] == 2
assert summary["overall_risk"] == rules.RISK_HIGH
assert summary["n_high"] >= 1
assert isinstance(summary["excipients"], list)
# 风险色块包含整体 + 每个辅料。
blocks = assessment.risk_blocks()
assert len(blocks) == 1 + 2
assert blocks[0]["color"] == rules.QBD_RISK_COLORS[rules.RISK_HIGH]
# ---------------------------------------------------------------------------
# 输入归一鲁棒性
# ---------------------------------------------------------------------------
def test_accepts_string_and_dict_inputs_equivalently():
"""str 与 dict 形式的官能团 / 辅料输入应等价。"""
a = rules.evaluate(["primary_amine"], ["乳糖"])
b = rules.evaluate([{"id": "primary_amine"}], [{"name": "乳糖"}])
assert a.overall_risk == b.overall_risk
assert a.excipient_results[0].risk == b.excipient_results[0].risk
def test_duplicate_excipients_deduplicated():
assessment = rules.evaluate(["primary_amine"], ["乳糖", "乳糖", "Lactose"])
# 乳糖 与 Lactose 命中同一档;去重按名称小写。
assert len(assessment.excipient_results) == 2 # "乳糖" 与 "Lactose" 名称不同
def test_empty_excipients_returns_empty_results_with_note():
assessment = rules.evaluate(["primary_amine"], [])
assert assessment.excipient_results == []
assert assessment.overall_risk == rules.RISK_NONE
assert any("未提供任何辅料" in n for n in assessment.notes)
# ---------------------------------------------------------------------------
# 确定性(属性式):相同输入恒得相同输出
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"groups,excips",
[
(["primary_amine"], ["乳糖"]),
(["thiol", "ester"], ["交联聚维酮", "硬脂酸镁"]),
(["phenol"], ["聚维酮", "甘露醇"]),
([], ["未知辅料A", "微晶纤维素"]),
],
)
def test_determinism_same_input_same_output(groups, excips):
"""规则引擎确定性:多次评估结果完全一致。"""
first = rules.evaluate(groups, excips).summary()
for _ in range(3):
again = rules.evaluate(groups, excips).summary()
assert again == first
def test_worse_helper_picks_more_severe():
assert rules.worse(rules.RISK_LOW, rules.RISK_HIGH) == rules.RISK_HIGH
assert rules.worse(rules.RISK_MEDIUM, rules.RISK_LOW) == rules.RISK_MEDIUM
assert rules.worse(rules.RISK_NONE, rules.RISK_NONE) == rules.RISK_NONE
def test_risk_color_maps_to_qbd_palette():
assert rules.risk_color(rules.RISK_HIGH) == "#dc3545"
assert rules.risk_color(rules.RISK_MEDIUM) == "#ffc107"
assert rules.risk_color(rules.RISK_LOW) == "#28a745"
# 未知等级回退绿。
assert rules.risk_color("???") == "#28a745"
def test_merged_reaction_takes_worst_risk_and_merges_groups():
"""同一反应被多条规则命中时,取最坏风险并合并触发官能团。
氧化:thiol→高、phenol→中;二者同存时该反应应为高,且两官能团都被记录。
"""
reactions = rules.assess_pair(
group_ids=["thiol", "phenol"], tags=["peroxide"]
)
ox = next(r for r in reactions if r.reaction == "oxidation")
assert ox.risk == rules.RISK_HIGH
assert "thiol" in ox.group_ids and "phenol" in ox.group_ids