"""题族规格声明单元测试。""" from __future__ import annotations import random from collections import Counter import pytest from scipy import stats from app.question_gen.families import ( ALL_FAMILIES, RETRIEVAL_FAMILY, get_family_for_slot, ) # 12 种任务类型(来自 harness config) ALL_TASK_TYPES: frozenset[str] = frozenset( [ "Action Recognition", "Action Reasoning", "Action Prediction", "Action Sequence", "Object Recognition", "Object Reasoning", "Object Interaction", "Scene Understanding", "Event Reasoning", "Causal Reasoning", "Temporal Reasoning", "Spatial Reasoning", ] ) class TestFamilySpec: """验证 QuestionFamilySpec 声明的完整性与一致性。""" def test_all_families_cover_all_task_types(self) -> None: """12 种任务类型在 ALL_FAMILIES 中全覆盖。""" covered: set[str] = set() for family in ALL_FAMILIES: covered.update(family.legal_task_types) assert covered == ALL_TASK_TYPES def test_skill_targets_unique(self) -> None: """每个 family 的 skill_target 不重复。""" targets = [f.skill_target for f in ALL_FAMILIES] assert len(targets) == len(set(targets)) def test_all_families_tuple_length(self) -> None: """ALL_FAMILIES 包含 5 个家族。""" assert len(ALL_FAMILIES) == 5 def test_family_instances_are_frozen(self) -> None: """Family spec 实例不可修改。""" with pytest.raises(AttributeError): RETRIEVAL_FAMILY.name = "hacked" # type: ignore[misc] def test_legal_task_types_are_frozensets(self) -> None: """legal_task_types 是 frozenset,确保不可变。""" for family in ALL_FAMILIES: assert isinstance(family.legal_task_types, frozenset) def test_sampling_constraints_reasonable(self) -> None: """采样约束值在合理范围。""" for family in ALL_FAMILIES: assert family.sampling.min_subtitles >= 0 assert family.sampling.min_l3_nodes >= 1 def test_leak_profile_thresholds_valid(self) -> None: """泄漏测试阈值在 (0, 1] 范围内。""" for family in ALL_FAMILIES: assert 0 < family.leak_profile.pass_threshold <= 1.0 def test_prompt_templates_not_empty(self) -> None: """每个 family 的 prompt_template 非空。""" for family in ALL_FAMILIES: assert family.prompt_template assert family.prompt_template.endswith(".md") class TestGetFamilyForSlot: """验证 get_family_for_slot 的核心行为。""" def test_respects_legal_task_types(self) -> None: """返回的 family 必须包含给定 task_type。""" rng = random.Random(42) for task_type in ALL_TASK_TYPES: for _ in range(50): family = get_family_for_slot( task_type=task_type, family_ratios={ "RETRIEVAL": 0.30, "REASONING": 0.25, "ENUMERATION": 0.20, "VISUAL": 0.15, "SPATIAL": 0.10, }, rng=rng, ) assert task_type in family.legal_task_types def test_deterministic_with_seed(self) -> None: """相同 seed 产出相同结果。""" ratios = { "RETRIEVAL": 0.30, "REASONING": 0.25, "ENUMERATION": 0.20, "VISUAL": 0.15, "SPATIAL": 0.10, } results_a = [] results_b = [] for seed in range(10): rng_a = random.Random(seed) rng_b = random.Random(seed) results_a.append(get_family_for_slot("Action Reasoning", ratios, rng_a).name) results_b.append(get_family_for_slot("Action Reasoning", ratios, rng_b).name) assert results_a == results_b def test_invalid_task_type_raises(self) -> None: """不存在的 task_type 抛 ValueError。""" rng = random.Random(0) with pytest.raises(ValueError, match="task_type"): get_family_for_slot( task_type="Nonexistent Type", family_ratios={"RETRIEVAL": 1.0}, rng=rng, ) def test_distribution_approximates_ratios(self) -> None: """大样本下分布近似指定比例(chi-square p > 0.01)。 使用 Action Recognition — 只有 RETRIEVAL 和 VISUAL 合法。 """ rng = random.Random(123) ratios = { "RETRIEVAL": 0.30, "REASONING": 0.25, "ENUMERATION": 0.20, "VISUAL": 0.15, "SPATIAL": 0.10, } n = 5000 counter: Counter[str] = Counter() for _ in range(n): family = get_family_for_slot("Action Recognition", ratios, rng) counter[family.name] += 1 # Action Recognition 合法族: RETRIEVAL(0.30), VISUAL(0.15) # 归一化后: RETRIEVAL=0.30/0.45≈0.667, VISUAL=0.15/0.45≈0.333 legal_ratios = {"RETRIEVAL": 0.30, "VISUAL": 0.15} total_weight = sum(legal_ratios.values()) expected_probs = {k: v / total_weight for k, v in legal_ratios.items()} observed = [counter.get("RETRIEVAL", 0), counter.get("VISUAL", 0)] expected = [expected_probs["RETRIEVAL"] * n, expected_probs["VISUAL"] * n] chi2, p_value = stats.chisquare(observed, expected) assert p_value > 0.01, f"分布偏离过大: chi2={chi2:.2f}, p={p_value:.4f}" def test_no_legal_family_raises(self) -> None: """所有 family 权重为 0 时合法族为空,应抛出 ValueError。""" rng = random.Random(0) # Spatial Reasoning 合法族: RETRIEVAL, REASONING, VISUAL, SPATIAL # 如果 ratios 中只含不合法的族名,应抛错 with pytest.raises(ValueError, match="合法"): get_family_for_slot( task_type="Spatial Reasoning", family_ratios={"ENUMERATION": 1.0}, rng=rng, )