Preformu / tests /test_multi_cqa.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
13.4 kB
"""多 CQA 木桶原理联合决策单元测试(任务 15 / 需求 11)。
覆盖需求:
- 11.1:同时支持上限型规格(杂质,越界为超上限)与下限型规格(含量,越界为低于下限)。
- 11.2:分别对每个 CQA 计算其满足规格的最长时间。
- 11.3:最终货架期 = min(各 CQA 单独货架期)(木桶原理),并标明决定性 CQA。
- 11.4:下限型 CQA 风险判定基于预测区间**下界**与下限的关系(而非上界与上限)。
- 11.5:各 CQA 单独结论与联合结论可区分呈现(per_cqa + 联合结果)。
纯 Python 计算,不调用 LLM(设计 Property 1)。导入路径由 ``tests/conftest.py``
与仓库根 ``conftest.py`` 统一设置。
"""
from __future__ import annotations
import sys
import numpy as np
import pytest
from skills.stability.stability_calculator import StabilityCalculator
from skills.stability.multi_cqa import (
cqa_shelf_life_from_fit,
evaluate_multi_cqa,
joint_shelf_life,
_first_crossing,
_normalize_spec_type,
)
from schemas.decision_result import (
CQAShelfLifeResult,
JointShelfLifeResult,
SpecType,
)
@pytest.fixture
def calc() -> StabilityCalculator:
return StabilityCalculator()
# 上升型杂质(上限型):从 0.10% 缓慢上升,含轻微散度。
IMPURITY_TIMES = [0.0, 3.0, 6.0, 9.0, 12.0]
IMPURITY_VALUES = [0.10, 0.155, 0.205, 0.262, 0.305] # ~0.10 + 0.017·t
# 快速上升型杂质(上限型):更陡,应更早越上限 → 更短货架期。
FAST_IMPURITY_VALUES = [0.10, 0.21, 0.305, 0.41, 0.50] # ~0.10 + 0.033·t
# 下降型含量(下限型):从 100% 缓慢下降。
ASSAY_TIMES = [0.0, 3.0, 6.0, 9.0, 12.0]
ASSAY_VALUES = [100.0, 99.2, 98.5, 97.6, 96.9] # ~100 − 0.26·t
# ---------------------------------------------------------------------------
# 需求 11.1 / 11.4:spec_type 归一化与下限型方向
# ---------------------------------------------------------------------------
def test_normalize_spec_type_variants():
assert _normalize_spec_type("upper") == "upper"
assert _normalize_spec_type("lower") == "lower"
assert _normalize_spec_type("上限") == "upper"
assert _normalize_spec_type("下限型") == "lower"
assert _normalize_spec_type(SpecType.LOWER) == "lower"
assert _normalize_spec_type(SpecType.UPPER) == "upper"
# 缺省 / 未知 → 上限型(向后兼容既有杂质场景)。
assert _normalize_spec_type("") == "upper"
assert _normalize_spec_type(None) == "upper"
def test_first_crossing_upper_and_lower():
times = [0.0, 1.0, 2.0, 3.0, 4.0]
# 上限型:bound 上升,limit=2.5 → 在 t=2..3 之间穿越。
up_bound = [0.0, 1.0, 2.0, 3.0, 4.0]
tc, crossed = _first_crossing(times, up_bound, 2.5, "upper")
assert crossed is True
assert tc == pytest.approx(2.5, abs=1e-9)
# 下限型:bound 下降,limit=1.5 → 在 t=2..3 之间穿越。
low_bound = [4.0, 3.0, 2.0, 1.0, 0.0]
tc2, crossed2 = _first_crossing(times, low_bound, 1.5, "lower")
assert crossed2 is True
assert tc2 == pytest.approx(2.5, abs=1e-9)
# 从不越界 → 右删失,返回 horizon 末端。
tc3, crossed3 = _first_crossing(times, [0.0, 0.1, 0.2, 0.3, 0.4], 10.0, "upper")
assert crossed3 is False
assert tc3 == pytest.approx(4.0)
# ---------------------------------------------------------------------------
# 需求 11.2 / 11.4:单 CQA 货架期由正确的预测区间边界决定
# ---------------------------------------------------------------------------
def test_upper_cqa_shelf_life_uses_ci_upper_bound(calc):
"""上限型 CQA:货架期由 CI 上界达到上限的时间决定,且早于点估计越界时间。"""
fit = calc.fit_zero_order(IMPURITY_TIMES, IMPURITY_VALUES)
res = cqa_shelf_life_from_fit(
fit, cqa_name="总杂质", spec_type="upper",
specification_limit=0.5, horizon_months=48.0,
)
assert res.spec_type == "upper"
# 手动用连续带复算 CI 上界穿越时间。
band = calc.compute_ci_band(fit, t_start=0.0, t_end=48.0, num=361, clip_negative=True)
tc_upper, _ = _first_crossing(band["times"], band["upper"], 0.5, "upper")
tc_point, _ = _first_crossing(band["times"], band["point"], 0.5, "upper")
assert res.shelf_life_months == pytest.approx(tc_upper, abs=0.2)
# 保守性:CI 上界越界早于点估计越界。
assert res.shelf_life_months <= tc_point + 1e-6
def test_lower_cqa_shelf_life_uses_ci_lower_bound(calc):
"""下限型 CQA(含量):货架期由 CI 下界达到下限的时间决定(需求 11.4)。"""
fit = calc.fit_zero_order(ASSAY_TIMES, ASSAY_VALUES)
res = cqa_shelf_life_from_fit(
fit, cqa_name="含量", spec_type="lower",
specification_limit=95.0, horizon_months=48.0,
)
assert res.spec_type == "lower"
band = calc.compute_ci_band(fit, t_start=0.0, t_end=48.0, num=361, clip_negative=False)
tc_lower, _ = _first_crossing(band["times"], band["lower"], 95.0, "lower")
tc_point, _ = _first_crossing(band["times"], band["point"], 95.0, "lower")
# 货架期应由**下界**决定,而非上界/点估计。
assert res.shelf_life_months == pytest.approx(tc_lower, abs=0.2)
# 保守性:CI 下界越界早于点估计越界。
assert res.shelf_life_months <= tc_point + 1e-6
def test_cqa_meeting_spec_within_horizon_is_censored(calc):
"""考察窗内始终满足规格 → 标记右删失,货架期取封顶值。"""
fit = calc.fit_zero_order(IMPURITY_TIMES, IMPURITY_VALUES)
res = cqa_shelf_life_from_fit(
fit, cqa_name="总杂质", spec_type="upper",
specification_limit=5.0, # 远高于任何预测 → 永不越界
horizon_months=24.0,
)
assert res.censored is True
assert res.shelf_life_months == pytest.approx(24.0)
assert res.risk_level == "compliant"
# ---------------------------------------------------------------------------
# 需求 11.3:联合货架期 = min(各 CQA),决定性 CQA 标注正确
# ---------------------------------------------------------------------------
def test_joint_shelf_life_is_min_and_labels_determining_cqa():
"""联合货架期取最短者,determining_cqa 指向最短板。"""
a = CQAShelfLifeResult(cqa_name="含量", spec_type="lower",
specification_limit=95.0, shelf_life_months=30.0)
b = CQAShelfLifeResult(cqa_name="总杂质", spec_type="upper",
specification_limit=0.5, shelf_life_months=18.0)
c = CQAShelfLifeResult(cqa_name="水分", spec_type="upper",
specification_limit=3.0, shelf_life_months=42.0)
joint = joint_shelf_life([a, b, c])
assert isinstance(joint, JointShelfLifeResult)
assert joint.joint_shelf_life_months == pytest.approx(18.0)
assert joint.determining_cqa == "总杂质"
assert len(joint.per_cqa) == 3
def test_evaluate_multi_cqa_min_across_cqa(calc):
"""端到端:多 CQA(含上限+下限)联合货架期 = min(各单独货架期)。"""
cqa_inputs = [
{
"cqa_name": "总杂质", "times": IMPURITY_TIMES, "values": IMPURITY_VALUES,
"spec_type": "upper", "specification_limit": 0.5,
},
{
"cqa_name": "降解产物", "times": IMPURITY_TIMES, "values": FAST_IMPURITY_VALUES,
"spec_type": "upper", "specification_limit": 0.5,
},
{
"cqa_name": "含量", "times": ASSAY_TIMES, "values": ASSAY_VALUES,
"spec_type": "lower", "specification_limit": 95.0,
},
]
joint = evaluate_multi_cqa(cqa_inputs, horizon_months=48.0)
per = {c.cqa_name: c.shelf_life_months for c in joint.per_cqa}
expected_min = min(per.values())
expected_cqa = min(per, key=per.get)
# 木桶原理:联合 = 各 CQA 最短者。
assert joint.joint_shelf_life_months == pytest.approx(expected_min)
assert joint.determining_cqa == expected_cqa
# 更陡的降解产物应早于较缓的总杂质越上限。
assert per["降解产物"] < per["总杂质"]
def test_evaluate_multi_cqa_lower_limit_can_be_determining(calc):
"""下限型 CQA 在下降足够快时可成为决定性短板。"""
fast_assay = [100.0, 96.5, 93.0, 89.0, 85.0] # 快速下降,~ -1.25/月
slow_impurity = [0.10, 0.12, 0.14, 0.165, 0.185] # 缓慢上升
cqa_inputs = [
{
"cqa_name": "总杂质", "times": IMPURITY_TIMES, "values": slow_impurity,
"spec_type": "upper", "specification_limit": 0.5,
},
{
"cqa_name": "含量", "times": ASSAY_TIMES, "values": fast_assay,
"spec_type": "lower", "specification_limit": 95.0,
},
]
joint = evaluate_multi_cqa(cqa_inputs, horizon_months=48.0)
assert joint.determining_cqa == "含量"
per = {c.cqa_name: c.shelf_life_months for c in joint.per_cqa}
assert joint.joint_shelf_life_months == pytest.approx(min(per.values()))
def test_joint_requires_at_least_one_cqa():
with pytest.raises(ValueError):
joint_shelf_life([])
# ---------------------------------------------------------------------------
# 决策引擎集成:execute() 产出 joint_shelf_life(向后兼容既有消费者)
# ---------------------------------------------------------------------------
def _build_intent(primary_cqa: str, spec_limit: float):
from schemas.analysis_intent import (
AnalysisIntent, AnalysisType, UserPreferences, HardConstraints,
ExtractedDataSummary,
)
return AnalysisIntent(
raw_goal="预测多 CQA 货架期",
analysis_type=AnalysisType.SHELF_LIFE_PREDICTION,
preferences=UserPreferences(target_timepoints=[24], required_confidence=0.95),
constraints=HardConstraints(primary_cqa=primary_cqa, specification_limit=spec_limit),
data_summary=ExtractedDataSummary(),
)
def _build_extracted_data():
return {
"batches": [
{
"batch_id": "B001",
"batch_type": "target",
"conditions": [
{
"condition_id": "25C_60RH",
"timepoints": IMPURITY_TIMES,
"cqa_data": [
{
"cqa_name": "总杂质",
"values": IMPURITY_VALUES,
"spec_type": "upper",
"specification_limit": 0.5,
},
{
"cqa_name": "降解产物",
"values": FAST_IMPURITY_VALUES,
"spec_type": "upper",
"specification_limit": 0.5,
},
{
"cqa_name": "含量",
"values": ASSAY_VALUES,
"spec_type": "lower",
"specification_limit": 95.0,
},
],
}
],
}
]
}
def test_decision_engine_populates_joint_shelf_life():
"""决策引擎在多 CQA 数据下产出联合货架期,且 = min(各 CQA)。"""
from layers.regulatory_decision_engine import RegulatoryDecisionEngine
engine = RegulatoryDecisionEngine()
intent = _build_intent(primary_cqa="总杂质", spec_limit=0.5)
result = engine.execute(intent, _build_extracted_data())
assert result.joint_shelf_life is not None
joint = result.joint_shelf_life
per = {c.cqa_name: c.shelf_life_months for c in joint.per_cqa}
assert set(per) == {"总杂质", "降解产物", "含量"}
assert joint.joint_shelf_life_months == pytest.approx(min(per.values()))
assert joint.determining_cqa == min(per, key=per.get)
# 计算追踪应记录联合决策步骤(审计)。
steps = [e["step"] for e in result.calculation_trace.entries]
assert "multi_cqa_joint_shelf_life" in steps
def test_decision_engine_backward_compatible_without_spec_type():
"""无 per-CQA spec 信息时,joint_shelf_life 为 None,既有单 CQA 流程不受影响。"""
from layers.regulatory_decision_engine import RegulatoryDecisionEngine
engine = RegulatoryDecisionEngine()
intent = _build_intent(primary_cqa="总杂质", spec_limit=0.5)
data = {
"batches": [
{
"batch_id": "B001",
"batch_type": "target",
"conditions": [
{
"condition_id": "25C_60RH",
"timepoints": IMPURITY_TIMES,
"cqa_data": [
{"cqa_name": "总杂质", "values": IMPURITY_VALUES},
],
}
],
}
]
}
result = engine.execute(intent, data)
assert result.joint_shelf_life is None
# 既有单 CQA 拟合仍产生。
assert result.can_proceed is True
assert len(result.kinetic_fits) >= 1
if __name__ == "__main__": # pragma: no cover
sys.exit(pytest.main([__file__, "-v"]))