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:
@@ -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 不在 correctness(None)
|
||||
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 进入 batch,unknown 不算错题
|
||||
@@ -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 # 只有错题,无正确题可混入
|
||||
|
||||
Reference in New Issue
Block a user