Files
Video-Tree-TRM5/tests/unit/test_harness_batching.py
T
iomgaa 2429dad393 feat(batching): unit 粒度切分——pair 整锁 + 单元级分桶 + 非 AR 独立 rng
build_batches 改以 QuestionUnit 为原子调度单元:孪生对 2 题整锁进同一 batch、
按单元级正确性(双向 AND)落 correct/error 桶,不再因 P 对 Q 错被劈或被 FFD 拆箱。

- 非 AR(single)用 random.Random(seed) 复现旧逐题算法确切 draw 序列,AR(pair)
  用 _rng_ns(seed,"AR") SHA-256 派生独立流;二者 draw 流互不干扰,故 AR 折叠不改变
  非 AR 抽样/洗牌序列——纯非 AR 输入 build_batches 结果与引入 QuestionUnit 前逐字节一致。
- FFD 容量按 unit.size(pair 占 2),round-robin 遇碎片新开 bin 兜底而非报错。
- _select_mixed_by_task_type 分流各跑一次后合并,大类洗牌按 kind 拆分各用对应 rng。

新增黄金测试 test_batching_pair_lock.py 覆盖三条铁律(同 batch / 单元分桶 /
非 AR byte-identical + draw 流独立);既有 batching 测试全绿。
2026-07-15 06:46:28 -04:00

293 lines
11 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.
"""app/harness/batching.py 的单元测试。
覆盖 FFD + round-robin mini-batch 构建的核心场景:
确定性、小类不拆、大类 round-robin、正确率混合、空错题、参数校验、
correctness False vs None 精确匹配。
"""
from __future__ import annotations
import random
import pytest
from app.harness.batching import (
_select_mixed_by_task_type,
_validate_params,
build_batches,
)
from app.harness.question_units import build_units
from core.types import GeneratedQuestion
# ---------------------------------------------------------------------------
# 辅助构造
# ---------------------------------------------------------------------------
def _make_q(
qid: str,
task_type: str = "default",
video_id: str = "v1",
) -> GeneratedQuestion:
"""构造最小 GeneratedQuestion 用于测试。"""
return GeneratedQuestion(
question_id=qid,
video_id=video_id,
task_type=task_type,
question=f"question_{qid}",
options=("A. a", "B. b", "C. c", "D. d"),
answer="A",
source_nodes=("n1",),
difficulty="medium",
)
# ---------------------------------------------------------------------------
# test_build_batches_deterministic
# ---------------------------------------------------------------------------
class TestBuildBatchesDeterministic:
"""相同输入 + 相同 seed 产出完全一致的切分。"""
def test_same_seed_same_result(self) -> None:
items = [_make_q(f"q{i}", task_type=f"type_{i % 3}") for i in range(20)]
correctness = {f"q{i}": False for i in range(20)}
r1 = build_batches(items, correctness, batch_size=5, min_class_per_batch=2, seed=42)
r2 = build_batches(items, correctness, batch_size=5, min_class_per_batch=2, seed=42)
assert r1 == r2
def test_different_seed_may_differ(self) -> None:
"""不同 seed 结果应不同(极大概率,用大量样本保证)。"""
items = [_make_q(f"q{i}", task_type=f"type_{i % 5}") for i in range(50)]
correctness = {f"q{i}": False for i in range(50)}
r1 = build_batches(items, correctness, batch_size=10, min_class_per_batch=3, seed=1)
r2 = build_batches(items, correctness, batch_size=10, min_class_per_batch=3, seed=99)
# 至少 batch 内容不同(不比较结构,只比较 selected_count 一致性)
assert r1[1] == r2[1] # 总题数一致
# 但 batch 内部排列几乎必然不同
flat1 = [q.question_id for b in r1[0] for q in b]
flat2 = [q.question_id for b in r2[0] for q in b]
assert flat1 != flat2
# ---------------------------------------------------------------------------
# test_small_class_not_split
# ---------------------------------------------------------------------------
class TestSmallClassNotSplit:
"""小类(≤ min_class_per_batch)整组不拆,锁在同一 batch。"""
def test_small_group_stays_together(self) -> None:
# 2 道题属于 small_type(≤ min_class=3),应在同一 batch
items = [
_make_q("s1", task_type="small_type"),
_make_q("s2", task_type="small_type"),
# 大类 8 道题
*[_make_q(f"big{i}", task_type="big_type") for i in range(8)],
]
correctness = {q.question_id: False for q in items}
batches, count = build_batches(
items, correctness, batch_size=5, min_class_per_batch=3, seed=0
)
assert count == 10
# 找到包含 small_type 的 batch
small_batch = [b for b in batches if any(q.task_type == "small_type" for q in b)]
assert len(small_batch) == 1 # 整组在同一个 batch
small_ids = {q.question_id for q in small_batch[0] if q.task_type == "small_type"}
assert small_ids == {"s1", "s2"}
# ---------------------------------------------------------------------------
# test_large_class_round_robin
# ---------------------------------------------------------------------------
class TestLargeClassRoundRobin:
"""大类样本 round-robin 散布到多个 batch,不集中于单一 batch。"""
def test_large_group_distributed(self) -> None:
# 12 道大类题,batch_size=4min_class=2 → 大类 > 2 → round-robin
items = [_make_q(f"q{i}", task_type="large_type") for i in range(12)]
correctness = {q.question_id: False for q in items}
batches, count = build_batches(
items, correctness, batch_size=4, min_class_per_batch=2, seed=7
)
assert count == 12
assert len(batches) >= 3 # ceil(12/4) = 3
# 每个 batch 不超过 batch_size
for b in batches:
assert len(b) <= 4
# ---------------------------------------------------------------------------
# test_correct_ratio_mixing
# ---------------------------------------------------------------------------
class TestCorrectRatioMixing:
"""correct_ratio > 0 时混入正确题。"""
def test_mixed_includes_correct(self) -> None:
items = [
_make_q("e1", task_type="t1"),
_make_q("e2", task_type="t1"),
_make_q("c1", task_type="t1"),
_make_q("c2", task_type="t1"),
_make_q("c3", task_type="t1"),
]
correctness = {"e1": False, "e2": False, "c1": True, "c2": True, "c3": True}
batches, count = build_batches(
items,
correctness,
batch_size=10,
min_class_per_batch=2,
seed=0,
correct_ratio=0.5,
)
# correct_ratio=0.5 → 错:正 = 1:1 → 2 错 + 2 正 = 4 题
assert count == 4
all_ids = {q.question_id for b in batches for q in b}
assert {"e1", "e2"}.issubset(all_ids) # 错题全部
correct_in = all_ids - {"e1", "e2"}
assert len(correct_in) == 2 # 采样 2 个正确题
assert correct_in.issubset({"c1", "c2", "c3"})
def test_ratio_zero_pure_errors(self) -> None:
items = [
_make_q("e1", task_type="t1"),
_make_q("c1", task_type="t1"),
]
correctness = {"e1": False, "c1": True}
batches, count = build_batches(
items,
correctness,
batch_size=10,
min_class_per_batch=2,
seed=0,
correct_ratio=0.0,
)
assert count == 1
assert batches[0][0].question_id == "e1"
# ---------------------------------------------------------------------------
# test_no_wrong_answers_empty
# ---------------------------------------------------------------------------
class TestNoWrongAnswersEmpty:
"""无错题时返回空列表。"""
def test_all_correct_returns_empty(self) -> None:
items = [_make_q(f"q{i}") for i in range(5)]
correctness = {f"q{i}": True for i in range(5)}
batches, count = build_batches(
items,
correctness,
batch_size=3,
min_class_per_batch=1,
seed=0,
)
assert batches == []
assert count == 0
def test_empty_items_returns_empty(self) -> None:
batches, count = build_batches(
[],
{},
batch_size=3,
min_class_per_batch=1,
seed=0,
)
assert batches == []
assert count == 0
# ---------------------------------------------------------------------------
# test_validate_params_strict
# ---------------------------------------------------------------------------
class TestValidateParamsStrict:
"""参数校验:batch_size < 1、min_class < 1、min_class >= batch_size 都报错。"""
def test_batch_size_zero(self) -> None:
with pytest.raises(ValueError, match="batch_size 必须 >= 1"):
_validate_params(0, 1)
def test_batch_size_negative(self) -> None:
with pytest.raises(ValueError, match="batch_size 必须 >= 1"):
_validate_params(-1, 1)
def test_min_class_zero(self) -> None:
with pytest.raises(ValueError, match="min_class_per_batch 必须 >= 1"):
_validate_params(5, 0)
def test_min_class_equals_batch_size(self) -> None:
with pytest.raises(ValueError, match="min_class_per_batch 必须严格 < batch_size"):
_validate_params(5, 5)
def test_min_class_exceeds_batch_size(self) -> None:
with pytest.raises(ValueError, match="min_class_per_batch 必须严格 < batch_size"):
_validate_params(3, 5)
def test_valid_params_no_error(self) -> None:
_validate_params(5, 3) # 不抛异常
# ---------------------------------------------------------------------------
# test_correctness_false_vs_none
# ---------------------------------------------------------------------------
class TestCorrectnessFalseVsNone:
"""correctness.get(qid) is False 精确匹配:None(未知题)不算错题。"""
def test_none_excluded_from_errors(self) -> None:
items = [
_make_q("wrong", task_type="t1"),
_make_q("right", task_type="t1"),
_make_q("unknown", task_type="t1"),
]
# wrong=False(错题),right=True(正确题),unknown 不在 correctnessNone
correctness: dict[str, bool] = {"wrong": False, "right": True}
batches, count = build_batches(
items,
correctness,
batch_size=10,
min_class_per_batch=2,
seed=0,
correct_ratio=0.0,
)
# 仅 wrong 进入 batchunknown 不算错题
assert count == 1
assert batches[0][0].question_id == "wrong"
def test_explicit_false_only(self) -> None:
"""直接测试 _select_mixed_by_task_type 内部逻辑(single 单元粒度)。"""
items = [
_make_q("f1", task_type="t1"),
_make_q("n1", task_type="t1"), # None(未知)
_make_q("t1", task_type="t1"), # True(正确)
]
correctness: dict[str, bool] = {"f1": False, "t1": True}
rng = random.Random(0)
result = _select_mixed_by_task_type(build_units(items), correctness, 0.0, rng)
assert "t1" in result
assert len(result["t1"]) == 1
assert result["t1"][0].unit_id == "f1"
def test_none_not_treated_as_correct(self) -> None:
"""None(未知)不进正确组,不被 correct_ratio 采样(single 单元粒度)。"""
items = [
_make_q("err", task_type="t1"),
_make_q("unk", task_type="t1"),
]
correctness: dict[str, bool] = {"err": False}
rng = random.Random(0)
result = _select_mixed_by_task_type(build_units(items), correctness, 0.5, rng)
# 只有 err 一题错题,unk 不在 correctness 中 → get 返回 None → 不进 correct 组
assert len(result["t1"]) == 1 # 只有错题,无正确题可混入