Files
Video-Tree-TRM5/tests/unit/test_batching_pair_lock.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

305 lines
12 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 的 unit 粒度切分测试(Task 5)。
覆盖 pair 契约在 mini-batch 构建中的三条铁律:
- (a) 同 pair_id 两题整锁进同一 batch(像小类整组不拆);
- (b) pair 按 unit correctness(双向 AND)落 correct/error 桶,不因 P 对 Q 错被劈;
- (c) 非 AR byte-identicalAR unit 折叠不改变非 AR 的 rng.sample/shuffle 抽样序列——
纯非 AR 输入下 build_batches 结果与"引入 QuestionUnit 前"的旧算法逐字节一致。
"""
from __future__ import annotations
import math
import random
from app.harness.batching import build_batches
from core.types import GeneratedQuestion
# ---------------------------------------------------------------------------
# 辅助构造
# ---------------------------------------------------------------------------
def _make_single(
qid: str,
task_type: str = "default",
video_id: str = "v1",
) -> GeneratedQuestion:
"""构造最小 single 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",
question_role="single",
)
def _make_pair(
pair_id: str,
task_type: str = "AR",
video_id: str = "v1",
) -> tuple[GeneratedQuestion, GeneratedQuestion]:
"""构造一个孪生对(original + mirror),共享 pair_id / flip_axis。"""
common = {
"video_id": video_id,
"task_type": task_type,
"question": "?",
"options": ("A. a", "B. b", "C. c", "D. d"),
"answer": "A",
"source_nodes": ("n1",),
"difficulty": "medium",
"pair_id": pair_id,
"flip_axis": "before_after",
}
original = GeneratedQuestion(
question_id=f"{pair_id}_o", question_role="pair_original", **common
)
mirror = GeneratedQuestion(question_id=f"{pair_id}_m", question_role="pair_mirror", **common)
return original, mirror
def _batch_of(batches: list[list[GeneratedQuestion]], qid: str) -> int:
"""返回 qid 所在 batch 的下标;不存在返回 -1。"""
for i, b in enumerate(batches):
if any(q.question_id == qid for q in b):
return i
return -1
# ---------------------------------------------------------------------------
# (a) 同 pair_id 两题落同一 batch(整锁不拆)
# ---------------------------------------------------------------------------
class TestPairStaysInSameBatch:
"""孪生对两题必须整锁进同一 batch,无论散布在多少 single 之间。"""
def test_single_pair_same_batch(self) -> None:
po, pm = _make_pair("pairA", task_type="AR")
singles = [_make_single(f"s{i}", task_type="RETRIEVAL") for i in range(10)]
items = [*singles, po, pm]
correctness = {q.question_id: False for q in items}
batches, count = build_batches(
items, correctness, batch_size=4, min_class_per_batch=2, seed=3
)
assert count == 12
idx_o = _batch_of(batches, "pairA_o")
idx_m = _batch_of(batches, "pairA_m")
assert idx_o != -1 and idx_o == idx_m
def test_many_pairs_each_intact(self) -> None:
items: list[GeneratedQuestion] = []
for k in range(5):
po, pm = _make_pair(f"p{k}", task_type="AR")
items.extend([po, pm])
items.extend(_make_single(f"s{i}", task_type="SPATIAL") for i in range(6))
correctness = {q.question_id: False for q in items}
batches, _ = build_batches(items, correctness, batch_size=6, min_class_per_batch=2, seed=11)
for k in range(5):
idx_o = _batch_of(batches, f"p{k}_o")
idx_m = _batch_of(batches, f"p{k}_m")
assert idx_o != -1 and idx_o == idx_m, f"pair p{k} 被拆到不同 batch"
# 每个 batch 容量不超限(pair 占 2
for b in batches:
assert len(b) <= 6
# ---------------------------------------------------------------------------
# (b) pair 按 unit correctness(双向 AND)分桶
# ---------------------------------------------------------------------------
class TestPairBucketedByUnitCorrectness:
"""P 对 Q 错的 pair 是 error 单元,不因单题分歧被劈到两个桶。"""
def test_p_correct_q_wrong_pair_is_error_unit(self) -> None:
po, pm = _make_pair("pairErr", task_type="AR")
# 混一个 single 错题避免空池边界
s0 = _make_single("s0", task_type="AR")
items = [po, pm, s0]
# original 对、mirror 错 → 单元级 AND = 错 → 应作为 error 单元整体进 batch
correctness = {"pairErr_o": True, "pairErr_m": False, "s0": False}
batches, count = build_batches(
items, correctness, batch_size=6, min_class_per_batch=2, seed=0, correct_ratio=0.0
)
idx_o = _batch_of(batches, "pairErr_o")
idx_m = _batch_of(batches, "pairErr_m")
# 两题都在(未被"P 对"劈掉)且同 batch
assert idx_o != -1 and idx_o == idx_m
# 纯错题模式下 pair 单元整体被选入
assert count == 3
def test_both_correct_pair_excluded_in_pure_error_mode(self) -> None:
po, pm = _make_pair("pairOk", task_type="AR")
s_err = _make_single("s_err", task_type="AR")
items = [po, pm, s_err]
correctness = {"pairOk_o": True, "pairOk_m": True, "s_err": False}
batches, count = build_batches(
items, correctness, batch_size=6, min_class_per_batch=2, seed=0, correct_ratio=0.0
)
# both-correct pair 是 correct 单元,纯错题模式下不入池
assert count == 1
assert _batch_of(batches, "pairOk_o") == -1
assert _batch_of(batches, "pairOk_m") == -1
assert _batch_of(batches, "s_err") != -1
def test_both_correct_pair_stays_intact_when_mixed(self) -> None:
"""correct_ratio>0 时 both-correct pair 可作为整体 correct 单元混入。"""
errs = [_make_single(f"e{i}", task_type="AR") for i in range(4)]
po, pm = _make_pair("pairOk", task_type="AR")
items = [*errs, po, pm]
correctness = {f"e{i}": False for i in range(4)}
correctness["pairOk_o"] = True
correctness["pairOk_m"] = True
batches, _ = build_batches(
items, correctness, batch_size=10, min_class_per_batch=2, seed=1, correct_ratio=0.5
)
idx_o = _batch_of(batches, "pairOk_o")
idx_m = _batch_of(batches, "pairOk_m")
# 若被混入,两题必须整体同 batch;若未被采样,两题都不在
if idx_o == -1:
assert idx_m == -1
else:
assert idx_o == idx_m
# ---------------------------------------------------------------------------
# (c) 非 AR byte-identical:与"引入 QuestionUnit 前"旧算法逐字节一致
# ---------------------------------------------------------------------------
def _reference_select_mixed(
items: list[GeneratedQuestion],
correctness: dict[str, bool],
correct_ratio: float,
rng: random.Random,
) -> dict[str, list[GeneratedQuestion]]:
"""旧版 _select_mixed_by_task_type 的忠实副本(逐题、单一 rng)。"""
errors_by_type: dict[str, list[GeneratedQuestion]] = {}
correct_by_type: dict[str, list[GeneratedQuestion]] = {}
for q in items:
qid = q.question_id
if correctness.get(qid) is False:
errors_by_type.setdefault(q.task_type, []).append(q)
elif correctness.get(qid, False):
correct_by_type.setdefault(q.task_type, []).append(q)
if correct_ratio <= 0:
return errors_by_type
grouped: dict[str, list[GeneratedQuestion]] = {}
for task_type in sorted(errors_by_type):
errs = errors_by_type[task_type]
n_correct = round(len(errs) * correct_ratio / (1 - correct_ratio))
available = correct_by_type.get(task_type, [])
sampled = (
list(available) if len(available) <= n_correct else rng.sample(available, n_correct)
)
grouped[task_type] = errs + sampled
return grouped
def _reference_build_batches(
items: list[GeneratedQuestion],
correctness: dict[str, bool],
batch_size: int,
min_class_per_batch: int,
seed: int,
correct_ratio: float = 0.0,
) -> tuple[list[list[GeneratedQuestion]], int]:
"""引入 QuestionUnit 前的旧版 build_batches 的忠实副本(逐题、单一 rng)。"""
rng = random.Random(seed)
grouped = _reference_select_mixed(items, correctness, correct_ratio, rng)
total = sum(len(g) for g in grouped.values())
if total == 0:
return [], 0
nb = max(1, math.ceil(total / batch_size))
batches: list[list[GeneratedQuestion]] = [[] for _ in range(nb)]
small = {t: g for t, g in grouped.items() if len(g) <= min_class_per_batch}
large = {t: g for t, g in grouped.items() if len(g) > min_class_per_batch}
for t in sorted(small, key=lambda t: (-len(small[t]), t)):
group = small[t]
placed = False
for b in batches:
if len(b) + len(group) <= batch_size:
b.extend(group)
placed = True
break
if not placed:
batches.append(list(group))
nb_live = len(batches)
pointer = 0
for task_type in sorted(large):
group = list(large[task_type])
rng.shuffle(group)
for q in group:
for offset in range(nb_live):
idx = (pointer + offset) % nb_live
if len(batches[idx]) < batch_size:
batches[idx].append(q)
pointer = (idx + 1) % nb_live
break
result = [b for b in batches if b]
return result, sum(len(b) for b in result)
class TestNonARByteIdentical:
"""纯 single 输入下 build_batches 与旧逐题算法逐字节一致(黄金对照)。"""
def _assert_identical(
self,
items: list[GeneratedQuestion],
correctness: dict[str, bool],
batch_size: int,
min_class_per_batch: int,
seed: int,
correct_ratio: float,
) -> None:
got_batches, got_count = build_batches(
items, correctness, batch_size, min_class_per_batch, seed, correct_ratio
)
ref_batches, ref_count = _reference_build_batches(
items, correctness, batch_size, min_class_per_batch, seed, correct_ratio
)
got_ids = [[q.question_id for q in b] for b in got_batches]
ref_ids = [[q.question_id for q in b] for b in ref_batches]
assert got_ids == ref_ids
assert got_count == ref_count
def test_pure_errors_identical(self) -> None:
items = [_make_single(f"q{i}", task_type=f"type_{i % 3}") for i in range(20)]
correctness = {f"q{i}": False for i in range(20)}
self._assert_identical(items, correctness, 5, 2, 42, 0.0)
def test_mixed_ratio_identical(self) -> None:
items = [_make_single(f"q{i}", task_type=f"type_{i % 4}") for i in range(40)]
correctness = {f"q{i}": (i % 3 == 0) for i in range(40)}
self._assert_identical(items, correctness, 8, 3, 7, 0.5)
def test_large_round_robin_identical(self) -> None:
items = [_make_single(f"q{i}", task_type="big") for i in range(30)]
correctness = {f"q{i}": False for i in range(30)}
self._assert_identical(items, correctness, 6, 2, 99, 0.0)
def test_ar_folding_does_not_shift_nonar_draws(self) -> None:
"""加入 AR pair 不改变非 AR single 的抽样序列(draw 流独立)。"""
singles = [_make_single(f"q{i}", task_type=f"type_{i % 3}") for i in range(24)]
s_correctness = {f"q{i}": (i % 4 == 0) for i in range(24)}
base, _ = build_batches(singles, s_correctness, 6, 2, 5, 0.5)
base_ids = {q.question_id for b in base for q in b}
# 追加若干 AR pair(不同 task_type),非 AR single 的入选集合应不变
items = list(singles)
correctness = dict(s_correctness)
for k in range(3):
po, pm = _make_pair(f"p{k}", task_type="AR")
items.extend([po, pm])
correctness[po.question_id] = False
correctness[pm.question_id] = False
with_ar, _ = build_batches(items, correctness, 6, 2, 5, 0.5)
with_ar_single_ids = {q.question_id for b in with_ar for q in b if q.pair_id is None}
assert with_ar_single_ids == base_ids