Files
Video-Tree-TRM5/tests/unit/test_harness_pools.py
T

946 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""三池切分单元测试。
验证:
- 三池互斥(question_id 无重叠)
- test 池自然分布(correct_ratio=None
- save/load 往返一致
- 旧格式拒绝(无 test 键 → ValueError
- build_or_load_pools 冻结复用(pools.json 存在时不重切)
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
from app.harness.pools import (
GlobalPoolStrategy,
PerCategoryPoolStrategy,
build_or_load_pools,
build_pools,
load_pools,
save_pools,
)
from core.types import GeneratedQuestion, PoolConfig
if TYPE_CHECKING:
from pathlib import Path
def _make_question(qid: str, task_type: str = "Action Reasoning") -> GeneratedQuestion:
"""构造测试用 GeneratedQuestion。
参数:
qid: 题目 ID。
task_type: 题型。
返回:
GeneratedQuestion 实例。
"""
return GeneratedQuestion(
question_id=qid,
video_id="video_001",
task_type=task_type,
question=f"Question {qid}?",
options=("A. opt1", "B. opt2", "C. opt3", "D. opt4"),
answer="A",
source_nodes=("node_1",),
difficulty="medium",
)
def _make_question_set(
n: int,
task_types: list[str] | None = None,
) -> list[GeneratedQuestion]:
"""构造 n 道题,交替分配题型。
参数:
n: 题目数量。
task_types: 可选题型列表,轮转分配;None 默认 2 类。
返回:
题目列表。
"""
types = task_types or ["Action Reasoning", "Information Synopsis"]
return [_make_question(f"q_{i:04d}", types[i % len(types)]) for i in range(n)]
def _make_correctness(
questions: list[GeneratedQuestion],
correct_ratio: float = 0.5,
) -> dict[str, bool]:
"""构造 correctness 字典,前 correct_ratio 比例标对。
参数:
questions: 题目列表。
correct_ratio: 对题占比。
返回:
question_id -> bool。
"""
n_correct = round(len(questions) * correct_ratio)
return {q.question_id: (i < n_correct) for i, q in enumerate(questions)}
class TestBuildPoolsMutualExclusion:
"""三池 question_id 互斥验证。"""
def test_build_pools_mutual_exclusion(self) -> None:
"""三池切分后,任意两池不共享 question_id。"""
questions = _make_question_set(200)
correctness = _make_correctness(questions, 0.5)
pools = build_pools(
questions,
correctness,
diag_cfg={
"size": 30,
"correct_ratio": 0.5,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
val_cfg={
"size": 30,
"correct_ratio": 0.5,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
test_cfg={"size": 30},
baseline_run_id="run_baseline",
)
diag_ids = {q.question_id for q in pools.diagnosis}
val_ids = {q.question_id for q in pools.validation}
test_ids = {q.question_id for q in pools.test}
assert diag_ids & val_ids == set(), "诊断池与验证池有重叠"
assert diag_ids & test_ids == set(), "诊断池与测试池有重叠"
assert val_ids & test_ids == set(), "验证池与测试池有重叠"
assert len(diag_ids) == 30
assert len(val_ids) == 30
assert len(test_ids) == 30
class TestBuildPoolsTestNaturalDistribution:
"""test 池使用自然分布(correct_ratio=None)。"""
def test_build_pools_test_natural_distribution(self) -> None:
"""test 池不强制对错比例,保留候选池的自然分布。
构造 correctness 为 50% 对/50% 错,diag/val 用 correct_ratio=0.3
强制裁剪,test 池走自然分布(correct_ratio=None)。验证 test 池
不受 correct_ratio 约束。
"""
questions = _make_question_set(300)
correctness = _make_correctness(questions, 0.5)
pools = build_pools(
questions,
correctness,
diag_cfg={
"size": 20,
"correct_ratio": 0.3,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
val_cfg={
"size": 20,
"correct_ratio": 0.3,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
test_cfg={"size": 20},
baseline_run_id="run_baseline",
)
# diag/val 被 correct_ratio=0.3 裁剪:round(20*0.3) = 6 对, 14 错
diag_correct = sum(1 for q in pools.diagnosis if correctness[q.question_id])
val_correct = sum(1 for q in pools.validation if correctness[q.question_id])
assert diag_correct == 6, "诊断池应强制 30% 对题"
assert val_correct == 6, "验证池应强制 30% 对题"
# test 池自然分布:不受 correct_ratio 约束
assert len(pools.test) == 20
class TestSaveLoadPoolsRoundtrip:
"""save/load 往返一致验证。"""
def test_save_load_pools_roundtrip(self, tmp_path: Path) -> None:
"""save_pools → load_pools 后全字段一致。"""
questions = _make_question_set(100)
correctness = _make_correctness(questions, 0.5)
original = build_pools(
questions,
correctness,
diag_cfg={
"size": 15,
"correct_ratio": 0.5,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
val_cfg={
"size": 15,
"correct_ratio": 0.5,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
test_cfg={"size": 15},
baseline_run_id="run_001",
)
pools_path = tmp_path / "pools.json"
save_pools(original, pools_path)
restored = load_pools(pools_path)
# 标量字段
assert restored.baseline_run_id == original.baseline_run_id
assert restored.baseline_val_accuracy == pytest.approx(original.baseline_val_accuracy)
assert restored.correctness == original.correctness
# 三池逐题比对
for pool_name in ("diagnosis", "validation", "test"):
orig_list = getattr(original, pool_name)
rest_list = getattr(restored, pool_name)
assert len(rest_list) == len(orig_list), f"{pool_name} 长度不一致"
for o, r in zip(orig_list, rest_list, strict=False):
assert o.question_id == r.question_id
assert o.video_id == r.video_id
assert o.task_type == r.task_type
assert o.question == r.question
assert o.options == r.options
assert o.answer == r.answer
assert o.source_nodes == r.source_nodes
assert o.difficulty == r.difficulty
class TestLoadPoolsOldFormatReject:
"""旧格式 pools.json(无 test 键)→ ValueError。"""
def test_load_pools_old_format_reject(self, tmp_path: Path) -> None:
"""缺少 test 键的 pools.json 必须抛出 ValueError。"""
old_format = {
"baseline_run_id": "run_old",
"baseline_val_accuracy": 0.5,
"correctness": {},
"diagnosis": [],
"validation": [],
}
pools_path = tmp_path / "pools.json"
pools_path.write_text(json.dumps(old_format), encoding="utf-8")
with pytest.raises(ValueError, match="旧格式"):
load_pools(pools_path)
class TestBuildOrLoadPoolsFrozen:
"""build_or_load_pools 冻结复用:pools.json 存在时原样加载不重切。"""
def test_build_or_load_pools_frozen(self, tmp_path: Path) -> None:
"""pools.json 已存在时,build_or_load_pools 返回冻结内容。"""
questions = _make_question_set(60)
correctness = _make_correctness(questions, 0.5)
frozen = build_pools(
questions,
correctness,
diag_cfg={
"size": 10,
"correct_ratio": 0.5,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
val_cfg={
"size": 10,
"correct_ratio": 0.5,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
test_cfg={"size": 10},
baseline_run_id="run_frozen",
)
pools_path = tmp_path / "pools.json"
save_pools(frozen, pools_path)
# build_or_load_pools 中 pools.json 存在 → 直接 load_pools
# 此处直接测试 load_pools 行为等价
loaded = load_pools(pools_path)
assert loaded.baseline_run_id == frozen.baseline_run_id
assert loaded.baseline_val_accuracy == pytest.approx(frozen.baseline_val_accuracy)
assert len(loaded.test) == len(frozen.test)
assert len(loaded.validation) == len(frozen.validation)
assert len(loaded.diagnosis) == len(frozen.diagnosis)
# question_id 完全一致
for pool_name in ("diagnosis", "validation", "test"):
orig_ids = [q.question_id for q in getattr(frozen, pool_name)]
load_ids = [q.question_id for q in getattr(loaded, pool_name)]
assert orig_ids == load_ids, f"{pool_name} 冻结后 ID 顺序不一致"
def _run_config_for_frozen(self, tmp_path: Path, seed_name: str) -> object:
"""构造指向 tmp workspace/store + 指定种子名的最小 train RunConfig。"""
from app.harness.config import RunConfig
return RunConfig(
workspace_dir=tmp_path / "ws",
store_dir=tmp_path / "store",
mode="train",
concurrency=4,
max_steps=10,
skill_mode="auto",
n_samples=0,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
epochs=1,
diag_size=10,
diag_correct_ratio=0.5,
val_size=10,
val_correct_ratio=0.5,
edit_budget_start=5,
edit_budget_end=2,
batch_size=15,
min_class_per_batch=2,
eval_min_per_class=2,
early_stop_patience=4,
test_size=10,
use_slow_momentum=True,
gate_e_confirm=20.0,
gate_e_provisional=3.0,
gate_w_net_min=2,
gate_delta_min=0.02,
gate_lambda_dir=-0.642,
gate_e_rollback=10.0,
gate_block=8,
gate_n_max=40,
gate_p_low=0.05,
gate_p_high=0.95,
gate_probe_quota=0.2,
gate_gamma_decay=0.9,
gate_cooldown_steps=2,
gate_guard_err=0.10,
skill_update_mode="patch",
appendix_consolidate_threshold=6,
fresh=True,
seed=seed_name,
test_questions="", # 绕过 _to_pool_config 的 resolve_paths(manifest 依赖)
)
def test_global_frozen_rejects_baseline_mismatch(self, tmp_path: Path) -> None:
"""global 冻结 pools 的 baseline_run_id 与 seed 不符时 fail-loud。"""
seed_name = "myseed"
seed_dir = tmp_path / "store" / "seeds" / seed_name
seed_dir.mkdir(parents=True)
(seed_dir / "seed.json").write_text(
json.dumps({"baseline_run_id": "infer_adhoc", "parent": None})
)
ws = tmp_path / "ws"
ws.mkdir()
(ws / "pools.json").write_text(
json.dumps({"split_mode": "global", "baseline_run_id": "other"}),
encoding="utf-8",
)
config = self._run_config_for_frozen(tmp_path, seed_name)
strategy = GlobalPoolStrategy()
with pytest.raises(ValueError, match="baseline_run_id"):
build_or_load_pools(config, strategy, tmp_path / "nonexistent.db")
class TestGlobalPoolStrategy:
"""GlobalPoolStrategy 封装现有全局三分逻辑。"""
def test_global_strategy_builds_three_pools(self) -> None:
"""GlobalPoolStrategy.build 产出三个互斥池。"""
questions = _make_question_set(200)
correctness = _make_correctness(questions, 0.5)
config = PoolConfig(
task_types=None,
seed=42,
baseline_run_id="run_baseline",
diag_size=30,
diag_correct_ratio=0.5,
val_size=30,
val_correct_ratio=0.5,
test_size=30,
eval_min_per_class=1,
train_ratio=0.667,
test_questions_dir=None,
)
strategy = GlobalPoolStrategy()
pools = strategy.build(questions, correctness, config)
diag_ids = {q.question_id for q in pools.diagnosis}
val_ids = {q.question_id for q in pools.validation}
test_ids = {q.question_id for q in pools.test}
assert diag_ids & val_ids == set()
assert diag_ids & test_ids == set()
assert val_ids & test_ids == set()
assert len(pools.diagnosis) == 30
assert len(pools.validation) == 30
assert len(pools.test) == 30
def test_global_strategy_build_incremental_raises(self) -> None:
"""GlobalPoolStrategy 不支持增量。"""
strategy = GlobalPoolStrategy()
config = PoolConfig(
task_types=None,
seed=0,
baseline_run_id="r",
diag_size=10,
diag_correct_ratio=0.5,
val_size=10,
val_correct_ratio=0.5,
test_size=10,
eval_min_per_class=1,
train_ratio=0.667,
test_questions_dir=None,
)
with pytest.raises(NotImplementedError):
strategy.build_incremental(["Action Reasoning"], [], {}, config)
def _make_per_category_questions():
"""构造 12 类各 30 题,共 360 题。"""
task_types = [
"Action Recognition",
"Action Reasoning",
"Attribute Perception",
"Counting Problem",
"Information Synopsis",
"Object Recognition",
"Object Reasoning",
"OCR Problems",
"Spatial Perception",
"Spatial Reasoning",
"Temporal Perception",
"Temporal Reasoning",
]
questions = []
for tt in task_types:
for i in range(30):
questions.append(_make_question(f"{tt}_{i:03d}", tt))
return questions
class TestPerCategoryPoolStrategy:
"""PerCategoryPoolStrategy per-category 2:1 分层划分。"""
def test_per_category_split_20_10(self):
"""每类 30 题按 correctness 2:1 分层 -> 20 train + 10 val。"""
questions = _make_per_category_questions()
correctness = {}
for q in questions:
idx = int(q.question_id.split("_")[-1])
correctness[q.question_id] = idx < 18
config = PoolConfig(
task_types=None,
seed=42,
baseline_run_id="baseline_v2",
diag_size=0,
diag_correct_ratio=0.0,
val_size=0,
val_correct_ratio=0.0,
test_size=0,
eval_min_per_class=0,
train_ratio=20 / 30,
test_questions_dir=None,
)
strategy = PerCategoryPoolStrategy()
pools = strategy.build(questions, correctness, config)
assert len(pools.diagnosis) == 240
assert len(pools.validation) == 120
from collections import Counter
diag_counts = Counter(q.task_type for q in pools.diagnosis)
val_counts = Counter(q.task_type for q in pools.validation)
for tt in diag_counts:
assert diag_counts[tt] == 20
assert val_counts[tt] == 10
diag_ids = {q.question_id for q in pools.diagnosis}
val_ids = {q.question_id for q in pools.validation}
assert diag_ids & val_ids == set()
def test_per_category_correctness_ratio_aligned(self):
"""train 和 val 的 correctness 比例应对齐。"""
questions = _make_per_category_questions()
correctness = {}
for q in questions:
idx = int(q.question_id.split("_")[-1])
correctness[q.question_id] = idx < 18
config = PoolConfig(
task_types=("Action Reasoning",),
seed=42,
baseline_run_id="baseline_v2",
diag_size=0,
diag_correct_ratio=0.0,
val_size=0,
val_correct_ratio=0.0,
test_size=0,
eval_min_per_class=0,
train_ratio=20 / 30,
test_questions_dir=None,
)
strategy = PerCategoryPoolStrategy()
pools = strategy.build(questions, correctness, config)
assert len(pools.diagnosis) == 20
assert len(pools.validation) == 10
diag_correct = sum(1 for q in pools.diagnosis if correctness[q.question_id])
val_correct = sum(1 for q in pools.validation if correctness[q.question_id])
assert diag_correct == 12
assert val_correct == 6
def test_per_category_all_correct_degrades(self):
"""某类全部 correct -> 退化为非分层 random 20/10。"""
questions = [_make_question(f"q_{i:03d}", "Object Recognition") for i in range(30)]
correctness = {q.question_id: True for q in questions}
config = PoolConfig(
task_types=None,
seed=42,
baseline_run_id="r",
diag_size=0,
diag_correct_ratio=0.0,
val_size=0,
val_correct_ratio=0.0,
test_size=0,
eval_min_per_class=0,
train_ratio=20 / 30,
test_questions_dir=None,
)
strategy = PerCategoryPoolStrategy()
pools = strategy.build(questions, correctness, config)
assert len(pools.diagnosis) == 20
assert len(pools.validation) == 10
def test_per_category_missing_correctness_fails(self):
"""correctness 不完整时 fail-fast。"""
questions = [_make_question(f"q_{i:03d}", "Object Recognition") for i in range(30)]
correctness = {q.question_id: True for q in questions[:25]}
config = PoolConfig(
task_types=None,
seed=42,
baseline_run_id="r",
diag_size=0,
diag_correct_ratio=0.0,
val_size=0,
val_correct_ratio=0.0,
test_size=0,
eval_min_per_class=0,
train_ratio=20 / 30,
test_questions_dir=None,
)
strategy = PerCategoryPoolStrategy()
with pytest.raises(ValueError, match="correctness 缺失"):
strategy.build(questions, correctness, config)
def test_per_category_task_types_filter(self):
"""task_types 过滤只处理指定类别。"""
questions = _make_per_category_questions()
correctness = {q.question_id: True for q in questions}
config = PoolConfig(
task_types=("Action Reasoning", "Information Synopsis"),
seed=42,
baseline_run_id="r",
diag_size=0,
diag_correct_ratio=0.0,
val_size=0,
val_correct_ratio=0.0,
test_size=0,
eval_min_per_class=0,
train_ratio=20 / 30,
test_questions_dir=None,
)
strategy = PerCategoryPoolStrategy()
pools = strategy.build(questions, correctness, config)
assert len(pools.diagnosis) == 40
assert len(pools.validation) == 20
types_in_diag = {q.task_type for q in pools.diagnosis}
assert types_in_diag == {"Action Reasoning", "Information Synopsis"}
def test_per_category_test_pool_filtered_by_task_types(self, tmp_path: Path) -> None:
"""test_questions_dir 加载的 test 池应按 task_types 过滤。"""
test_dir = tmp_path / "test_questions"
test_dir.mkdir()
task_types_all = [
"Action Recognition",
"Action Reasoning",
"Temporal Perception",
]
for tt in task_types_all:
items = []
for i in range(10):
items.append(
{
"question_id": f"{tt}_{i:03d}",
"video_id": "v1",
"task_type": tt,
"question": f"Q {tt} {i}?",
"options": ["A. a", "B. b", "C. c", "D. d"],
"answer": "A",
}
)
slug = tt.lower().replace(" ", "_")
(test_dir / f"{slug}.json").write_text(json.dumps(items, ensure_ascii=False))
# train/val 用的题目(与 test 独立)
questions = _make_per_category_questions()
correctness = {q.question_id: True for q in questions}
config = PoolConfig(
task_types=("Action Recognition",),
seed=42,
baseline_run_id="baseline_v2",
diag_size=0,
diag_correct_ratio=0.0,
val_size=0,
val_correct_ratio=0.0,
test_size=0,
eval_min_per_class=0,
train_ratio=20 / 30,
test_questions_dir=test_dir,
)
strategy = PerCategoryPoolStrategy()
pools = strategy.build(questions, correctness, config)
assert len(pools.test) == 10, (
f"test 池应仅含 Action Recognition 的 10 题,实际 {len(pools.test)}"
)
test_types = {q.task_type for q in pools.test}
assert test_types == {"Action Recognition"}, (
f"test 池应仅含 Action Recognition,实际含 {test_types}"
)
def test_per_category_test_pool_no_filter_when_task_types_none(self, tmp_path: Path) -> None:
"""task_types=None 时,test 池不过滤,加载全部题目。"""
test_dir = tmp_path / "test_questions"
test_dir.mkdir()
task_types_all = [
"Action Recognition",
"Action Reasoning",
"Temporal Perception",
]
total_expected = 0
for tt in task_types_all:
items = []
for i in range(10):
items.append(
{
"question_id": f"{tt}_{i:03d}",
"video_id": "v1",
"task_type": tt,
"question": f"Q {tt} {i}?",
"options": ["A. a", "B. b", "C. c", "D. d"],
"answer": "A",
}
)
slug = tt.lower().replace(" ", "_")
(test_dir / f"{slug}.json").write_text(json.dumps(items, ensure_ascii=False))
total_expected += len(items)
questions = _make_per_category_questions()
correctness = {q.question_id: True for q in questions}
config = PoolConfig(
task_types=None,
seed=42,
baseline_run_id="baseline_v2",
diag_size=0,
diag_correct_ratio=0.0,
val_size=0,
val_correct_ratio=0.0,
test_size=0,
eval_min_per_class=0,
train_ratio=20 / 30,
test_questions_dir=test_dir,
)
strategy = PerCategoryPoolStrategy()
pools = strategy.build(questions, correctness, config)
assert len(pools.test) == total_expected, (
f"task_types=None 时应加载全部 {total_expected} 题,实际 {len(pools.test)}"
)
class TestPerCategorySaveLoad:
"""per_category 格式的 pools.json 冻结/加载。"""
def test_save_load_per_category_roundtrip(self, tmp_path: Path) -> None:
"""per_category 模式 save -> load 往返一致。"""
questions = _make_per_category_questions()
correctness = {q.question_id: True for q in questions}
config = PoolConfig(
task_types=("Action Reasoning",),
seed=42,
baseline_run_id="baseline_v2",
diag_size=0,
diag_correct_ratio=0.0,
val_size=0,
val_correct_ratio=0.0,
test_size=0,
eval_min_per_class=0,
train_ratio=20 / 30,
test_questions_dir=None,
)
strategy = PerCategoryPoolStrategy()
pools = strategy.build(questions, correctness, config)
pools_path = tmp_path / "pools.json"
save_pools(pools, pools_path, split_mode="per_category", config=config)
loaded = load_pools(pools_path)
assert loaded.baseline_run_id == pools.baseline_run_id
assert len(loaded.diagnosis) == len(pools.diagnosis)
assert len(loaded.validation) == len(pools.validation)
# 验证 per_category 格式内容
data = json.loads(pools_path.read_text())
assert data["split_mode"] == "per_category"
assert "categories" in data
assert data["seed"] == 42
assert data["train_ratio"] == pytest.approx(20 / 30)
assert data["test_source"] is None
# categories 内容校验
cats = data["categories"]
assert "Action Reasoning" in cats
assert len(cats["Action Reasoning"]["train"]) == 20
assert len(cats["Action Reasoning"]["val"]) == 10
def test_save_per_category_without_config_raises(self, tmp_path: Path) -> None:
"""per_category 模式未提供 config 时报 ValueError。"""
from app.harness.pools import Pools
pools = Pools(
diagnosis=[],
validation=[],
test=[],
baseline_run_id="r",
baseline_val_accuracy=0.0,
)
with pytest.raises(ValueError, match="per_category 模式下.*必须提供 config"):
save_pools(pools, tmp_path / "pools.json", split_mode="per_category")
def test_save_global_mode_has_split_mode_field(self, tmp_path: Path) -> None:
"""global 模式 save 也写入 split_mode 字段。"""
questions = _make_question_set(60)
correctness = _make_correctness(questions, 0.5)
original = build_pools(
questions,
correctness,
diag_cfg={
"size": 10,
"correct_ratio": 0.5,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
val_cfg={
"size": 10,
"correct_ratio": 0.5,
"task_types": None,
"seed": 42,
"min_per_class": None,
},
test_cfg={"size": 10},
baseline_run_id="run_001",
)
pools_path = tmp_path / "pools.json"
save_pools(original, pools_path, split_mode="global")
data = json.loads(pools_path.read_text())
assert data["split_mode"] == "global"
assert "categories" not in data
# 仍能正常 load
loaded = load_pools(pools_path)
assert loaded.baseline_run_id == "run_001"
assert len(loaded.diagnosis) == 10
def test_load_legacy_format_without_split_mode(self, tmp_path: Path) -> None:
"""旧格式(无 split_mode 字段)仍可加载。"""
legacy = {
"baseline_run_id": "run_legacy",
"baseline_val_accuracy": 0.75,
"correctness": {"q1": True},
"diagnosis": [
{
"question_id": "q1",
"video_id": "v1",
"task_type": "AR",
"question": "Q?",
"options": ["A", "B", "C", "D"],
"answer": "A",
"source_nodes": [],
"difficulty": "medium",
"skill_target": None,
"difficulty_steps": None,
}
],
"validation": [],
"test": [],
}
pools_path = tmp_path / "pools.json"
pools_path.write_text(json.dumps(legacy), encoding="utf-8")
loaded = load_pools(pools_path)
assert loaded.baseline_run_id == "run_legacy"
assert len(loaded.diagnosis) == 1
def test_per_category_categories_multi_type(self, tmp_path: Path) -> None:
"""多类别 per_category save 后 categories 包含所有类别。"""
questions = _make_per_category_questions()
correctness = {q.question_id: True for q in questions}
config = PoolConfig(
task_types=("Action Reasoning", "Information Synopsis"),
seed=0,
baseline_run_id="b",
diag_size=0,
diag_correct_ratio=0.0,
val_size=0,
val_correct_ratio=0.0,
test_size=0,
eval_min_per_class=0,
train_ratio=20 / 30,
test_questions_dir=None,
)
strategy = PerCategoryPoolStrategy()
pools = strategy.build(questions, correctness, config)
pools_path = tmp_path / "pools.json"
save_pools(pools, pools_path, split_mode="per_category", config=config)
data = json.loads(pools_path.read_text())
assert set(data["categories"].keys()) == {
"Action Reasoning",
"Information Synopsis",
}
for tt in data["categories"]:
cat = data["categories"][tt]
assert len(cat["train"]) == 20
assert len(cat["val"]) == 10
# train + val 的 qid 互斥
assert set(cat["train"]) & set(cat["val"]) == set()
class TestRunHoldoutEvalConfig:
"""run_holdout_eval 字段校验。"""
def test_default_true(self):
"""run_holdout_eval 默认值为 True。"""
from pathlib import Path
from app.harness.config import RunConfig
config = RunConfig(
workspace_dir=Path("/tmp/ws"),
store_dir=Path("/tmp/store"),
mode="train",
concurrency=4,
max_steps=10,
skill_mode="auto",
n_samples=0,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
epochs=1,
diag_size=100,
diag_correct_ratio=0.5,
val_size=30,
val_correct_ratio=0.5,
edit_budget_start=5,
edit_budget_end=2,
batch_size=15,
min_class_per_batch=2,
eval_min_per_class=2,
early_stop_patience=4,
test_size=30,
use_slow_momentum=True,
gate_e_confirm=20.0,
gate_e_provisional=3.0,
gate_w_net_min=2,
gate_delta_min=0.02,
gate_lambda_dir=-0.642,
gate_e_rollback=10.0,
gate_block=8,
gate_n_max=40,
gate_p_low=0.05,
gate_p_high=0.95,
gate_probe_quota=0.2,
gate_gamma_decay=0.9,
gate_cooldown_steps=2,
gate_guard_err=0.10,
skill_update_mode="patch",
appendix_consolidate_threshold=6,
run_id="test_run",
)
assert config.run_holdout_eval is True
def test_explicit_false(self):
"""run_holdout_eval 可设为 False。"""
from pathlib import Path
from app.harness.config import RunConfig
config = RunConfig(
workspace_dir=Path("/tmp/ws"),
store_dir=Path("/tmp/store"),
mode="train",
concurrency=4,
max_steps=10,
skill_mode="auto",
n_samples=0,
questions="benchmarks/Video-MME",
skills_version="v1",
prompts_version="v1",
epochs=1,
diag_size=100,
diag_correct_ratio=0.5,
val_size=30,
val_correct_ratio=0.5,
edit_budget_start=5,
edit_budget_end=2,
batch_size=15,
min_class_per_batch=2,
eval_min_per_class=2,
early_stop_patience=4,
test_size=30,
use_slow_momentum=True,
gate_e_confirm=20.0,
gate_e_provisional=3.0,
gate_w_net_min=2,
gate_delta_min=0.02,
gate_lambda_dir=-0.642,
gate_e_rollback=10.0,
gate_block=8,
gate_n_max=40,
gate_p_low=0.05,
gate_p_high=0.95,
gate_probe_quota=0.2,
gate_gamma_decay=0.9,
gate_cooldown_steps=2,
gate_guard_err=0.10,
skill_update_mode="patch",
appendix_consolidate_threshold=6,
run_id="test_run",
run_holdout_eval=False,
)
assert config.run_holdout_eval is False