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 测试全绿。
This commit is contained in:
2026-07-15 06:46:28 -04:00
parent c412698cff
commit 2429dad393
4 changed files with 576 additions and 127 deletions
+48 -23
View File
@@ -7,22 +7,23 @@ correctness False vs None 精确匹配。
from __future__ import annotations
import pytest
from core.types import GeneratedQuestion
from app.harness.batching import (
build_batches,
_validate_params,
_select_mixed_by_task_type,
)
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",
@@ -45,6 +46,7 @@ def _make_q(
# test_build_batches_deterministic
# ---------------------------------------------------------------------------
class TestBuildBatchesDeterministic:
"""相同输入 + 相同 seed 产出完全一致的切分。"""
@@ -73,6 +75,7 @@ class TestBuildBatchesDeterministic:
# test_small_class_not_split
# ---------------------------------------------------------------------------
class TestSmallClassNotSplit:
"""小类(≤ min_class_per_batch)整组不拆,锁在同一 batch。"""
@@ -90,10 +93,7 @@ class TestSmallClassNotSplit:
)
assert count == 10
# 找到包含 small_type 的 batch
small_batch = [
b for b in batches
if any(q.task_type == "small_type" for q in b)
]
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"}
@@ -103,6 +103,7 @@ class TestSmallClassNotSplit:
# test_large_class_round_robin
# ---------------------------------------------------------------------------
class TestLargeClassRoundRobin:
"""大类样本 round-robin 散布到多个 batch,不集中于单一 batch。"""
@@ -124,6 +125,7 @@ class TestLargeClassRoundRobin:
# test_correct_ratio_mixing
# ---------------------------------------------------------------------------
class TestCorrectRatioMixing:
"""correct_ratio > 0 时混入正确题。"""
@@ -137,7 +139,11 @@ class TestCorrectRatioMixing:
]
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,
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 题
@@ -155,7 +161,11 @@ class TestCorrectRatioMixing:
]
correctness = {"e1": False, "c1": True}
batches, count = build_batches(
items, correctness, batch_size=10, min_class_per_batch=2, seed=0,
items,
correctness,
batch_size=10,
min_class_per_batch=2,
seed=0,
correct_ratio=0.0,
)
assert count == 1
@@ -166,6 +176,7 @@ class TestCorrectRatioMixing:
# test_no_wrong_answers_empty
# ---------------------------------------------------------------------------
class TestNoWrongAnswersEmpty:
"""无错题时返回空列表。"""
@@ -173,14 +184,22 @@ class TestNoWrongAnswersEmpty:
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,
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,
[],
{},
batch_size=3,
min_class_per_batch=1,
seed=0,
)
assert batches == []
assert count == 0
@@ -190,6 +209,7 @@ class TestNoWrongAnswersEmpty:
# test_validate_params_strict
# ---------------------------------------------------------------------------
class TestValidateParamsStrict:
"""参数校验:batch_size < 1、min_class < 1、min_class >= batch_size 都报错。"""
@@ -221,6 +241,7 @@ class TestValidateParamsStrict:
# test_correctness_false_vs_none
# ---------------------------------------------------------------------------
class TestCorrectnessFalseVsNone:
"""correctness.get(qid) is False 精确匹配:None(未知题)不算错题。"""
@@ -233,7 +254,11 @@ class TestCorrectnessFalseVsNone:
# 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,
items,
correctness,
batch_size=10,
min_class_per_batch=2,
seed=0,
correct_ratio=0.0,
)
# 仅 wrong 进入 batchunknown 不算错题
@@ -241,7 +266,7 @@ class TestCorrectnessFalseVsNone:
assert batches[0][0].question_id == "wrong"
def test_explicit_false_only(self) -> None:
"""直接测试 _select_mixed_by_task_type 内部逻辑。"""
"""直接测试 _select_mixed_by_task_type 内部逻辑single 单元粒度)"""
items = [
_make_q("f1", task_type="t1"),
_make_q("n1", task_type="t1"), # None(未知)
@@ -249,19 +274,19 @@ class TestCorrectnessFalseVsNone:
]
correctness: dict[str, bool] = {"f1": False, "t1": True}
rng = random.Random(0)
result = _select_mixed_by_task_type(items, correctness, 0.0, rng)
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].question_id == "f1"
assert result["t1"][0].unit_id == "f1"
def test_none_not_treated_as_correct(self) -> None:
"""None(未知)不进正确组,不被 correct_ratio 采样。"""
"""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(items, correctness, 0.5, rng)
result = _select_mixed_by_task_type(build_units(items), correctness, 0.5, rng)
# 只有 err 一题错题,unk 不在 correctness 中 → get 返回 None → 不进 correct 组
assert len(result["t1"]) == 1 # 只有错题,无正确题可混入