feat(harness): checkpoint 存 unit_id 序列,断点续跑孪生对不拆
核心算法保真 #3(断点续跑):checkpoint 从逐题 question_id 改为存 unit_id 序列(孪生对折叠为单个 unit_id),恢复时 build_units + 按完整 unit 展开, 续跑后 pair 两成员同进同出、绝不被劈开。 - _batch_unit_ids/_batch_from_ids 对称折叠/展开,保序去重,纯非 AR 下 unit_id==question_id、与旧逐题序列逐字节一致。 - momentum 采样抽取为 _sample_momentum_candidates 纯函数,docstring 显式 记录 Phase 1 设计偏差:仅保证纯非 AR byte-identical,混格 momentum 不保证。 - 新增 test_checkpoint_pair(unit_id 落盘往返、pair 不拆)与 test_non_ar_byte_identical(pools→batching→checkpoint→momentum 端到端黄金)。
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
"""纯非 AR 题库端到端 byte-identical 黄金测试(Task 10)。
|
||||
|
||||
Phase 1 验收铁律:引入 QuestionUnit 后,纯 single 题库过 pools 抽样 → batching 分批
|
||||
→ checkpoint 折叠/恢复 → momentum 采样,端到端结果与"引入 QuestionUnit 前"的逐题
|
||||
旧算法逐字节一致。golden 用固定 seed 的确定性对照(忠实重实现旧逐题逻辑),非空断言。
|
||||
|
||||
momentum 设计偏差:momentum 采样在逐题粒度进行、不折叠 unit,故仅保证纯非 AR 的抽样
|
||||
序列 byte-identical;Phase 1 不保证混格 momentum byte-identical(见 runner
|
||||
_sample_momentum_candidates docstring)。本测试保守证明纯非 AR momentum 不漂。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
from app.harness.batching import build_batches
|
||||
from app.harness.pools import build_pools
|
||||
from app.harness.question_units import build_units
|
||||
from app.harness.runner import _batch_from_ids, _batch_unit_ids, _sample_momentum_candidates
|
||||
from core.types import GeneratedQuestion
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 辅助构造
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _single(qid: str, task_type: str = "RETRIEVAL", video_id: str = "v1") -> GeneratedQuestion:
|
||||
"""构造最小 single 题目。"""
|
||||
return GeneratedQuestion(
|
||||
question_id=qid,
|
||||
video_id=video_id,
|
||||
task_type=task_type,
|
||||
question=f"q_{qid}",
|
||||
options=("A. a", "B. b", "C. c", "D. d"),
|
||||
answer="A",
|
||||
source_nodes=("n1",),
|
||||
difficulty="medium",
|
||||
question_role="single",
|
||||
)
|
||||
|
||||
|
||||
def _pair(
|
||||
pair_id: str, task_type: str = "AR", video_id: str = "v1"
|
||||
) -> tuple[GeneratedQuestion, GeneratedQuestion]:
|
||||
"""构造一个孪生对(original + mirror)。"""
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 引入 QuestionUnit 前的旧逐题 build_batches 忠实副本(单一 rng,无 unit 折叠)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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,
|
||||
) -> list[list[GeneratedQuestion]]:
|
||||
"""引入 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 []
|
||||
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
|
||||
return [b for b in batches if b]
|
||||
|
||||
|
||||
def _ids(batches: list[list[GeneratedQuestion]]) -> list[list[str]]:
|
||||
"""提取 batch 的 question_id 序列,便于逐字节对比。"""
|
||||
return [[q.question_id for q in b] for b in batches]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (1) 纯非 AR 走 size=1 unit:unit_id == question_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPureSingleAreSizeOneUnits:
|
||||
"""纯 single 输入折叠出的每个 unit 都是 size=1、unit_id 等于 question_id。"""
|
||||
|
||||
def test_all_units_size_one(self) -> None:
|
||||
items = [_single(f"s{i}", task_type=f"t{i % 3}") for i in range(15)]
|
||||
units = build_units(items)
|
||||
assert len(units) == len(items)
|
||||
assert all(u.kind == "single" and u.size == 1 for u in units)
|
||||
assert [u.unit_id for u in units] == [q.question_id for q in items]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (2) batching 逐字节一致(黄金对照)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBatchingByteIdentical:
|
||||
"""纯 single build_batches 与旧逐题算法逐字节一致。"""
|
||||
|
||||
def _assert(self, items, correctness, batch_size, min_cls, seed, ratio) -> None:
|
||||
got, _ = build_batches(items, correctness, batch_size, min_cls, seed, ratio)
|
||||
ref = _reference_build_batches(items, correctness, batch_size, min_cls, seed, ratio)
|
||||
assert _ids(got) == _ids(ref)
|
||||
|
||||
def test_pure_errors(self) -> None:
|
||||
items = [_single(f"q{i}", task_type=f"t{i % 3}") for i in range(20)]
|
||||
correctness = {f"q{i}": False for i in range(20)}
|
||||
self._assert(items, correctness, 5, 2, 42, 0.0)
|
||||
|
||||
def test_mixed_ratio(self) -> None:
|
||||
items = [_single(f"q{i}", task_type=f"t{i % 4}") for i in range(40)]
|
||||
correctness = {f"q{i}": (i % 3 == 0) for i in range(40)}
|
||||
self._assert(items, correctness, 8, 3, 7, 0.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (3) 端到端:pools 抽样 → batching → checkpoint 折叠/恢复 逐字节一致
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEndToEndByteIdentical:
|
||||
"""pools → batching → checkpoint unit_id round-trip 全链纯非 AR 逐字节稳定。"""
|
||||
|
||||
def _make_pool_questions(self) -> tuple[list[GeneratedQuestion], dict[str, bool]]:
|
||||
items = [_single(f"s{i}", task_type=f"t{i % 3}") for i in range(60)]
|
||||
correctness = {f"s{i}": (i % 2 == 0) for i in range(60)}
|
||||
return items, correctness
|
||||
|
||||
def test_pools_deterministic_and_pure_single(self) -> None:
|
||||
items, correctness = self._make_pool_questions()
|
||||
cfg = {
|
||||
"diag_cfg": {
|
||||
"size": 18,
|
||||
"correct_ratio": 0.5,
|
||||
"task_types": None,
|
||||
"seed": 5,
|
||||
"min_per_class": None,
|
||||
},
|
||||
"val_cfg": {
|
||||
"size": 12,
|
||||
"correct_ratio": 0.5,
|
||||
"task_types": None,
|
||||
"seed": 5,
|
||||
"min_per_class": None,
|
||||
},
|
||||
"test_cfg": {"size": 10, "seed": 5},
|
||||
"baseline_run_id": "baseline",
|
||||
}
|
||||
p1 = build_pools(items, correctness, **cfg)
|
||||
p2 = build_pools(items, correctness, **cfg)
|
||||
# 确定性:同 seed 同输入池划分逐字节一致
|
||||
assert [q.question_id for q in p1.diagnosis] == [q.question_id for q in p2.diagnosis]
|
||||
# 诊断池纯 single:折叠出的 unit 与逐题一一对应
|
||||
units = build_units(p1.diagnosis)
|
||||
assert [u.unit_id for u in units] == [q.question_id for q in p1.diagnosis]
|
||||
|
||||
def test_batching_then_checkpoint_roundtrip(self) -> None:
|
||||
items, correctness = self._make_pool_questions()
|
||||
p = build_pools(
|
||||
items,
|
||||
correctness,
|
||||
diag_cfg={
|
||||
"size": 18,
|
||||
"correct_ratio": 0.5,
|
||||
"task_types": None,
|
||||
"seed": 5,
|
||||
"min_per_class": None,
|
||||
},
|
||||
val_cfg={
|
||||
"size": 12,
|
||||
"correct_ratio": 0.5,
|
||||
"task_types": None,
|
||||
"seed": 5,
|
||||
"min_per_class": None,
|
||||
},
|
||||
test_cfg={"size": 10, "seed": 5},
|
||||
baseline_run_id="baseline",
|
||||
)
|
||||
batches, _ = build_batches(p.diagnosis, p.correctness, 6, 2, seed=3, correct_ratio=0.5)
|
||||
|
||||
# checkpoint 折叠为 unit_id(纯 single 即 question_id)后恢复
|
||||
epoch_batches = [_batch_unit_ids(b) for b in batches]
|
||||
assert epoch_batches == _ids(batches) # 纯非 AR:unit_id 序列 == question_id 序列
|
||||
rebuilt = [_batch_from_ids(p, ids) for ids in epoch_batches]
|
||||
# 恢复的 batch 与原 batch 逐字节一致(inference 消费的题序不变)
|
||||
assert _ids(rebuilt) == _ids(batches)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (4) momentum 纯非 AR byte-identical(混格偏差已在 runner docstring 记录)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMomentumPureNonARByteIdentical:
|
||||
"""momentum 采样纯非 AR 与旧逐题 random.Random(epoch).sample 逐字节一致。"""
|
||||
|
||||
def test_matches_legacy_rng(self) -> None:
|
||||
pool = [_single(f"s{i}", task_type="RETRIEVAL") for i in range(30)]
|
||||
allowed = {"RETRIEVAL"}
|
||||
epoch = 11
|
||||
samples = 8
|
||||
got = _sample_momentum_candidates(pool, allowed, samples, epoch)
|
||||
|
||||
candidates = [q for q in pool if q.task_type in allowed]
|
||||
ref = random.Random(epoch).sample(candidates, samples)
|
||||
assert [q.question_id for q in got] == [q.question_id for q in ref]
|
||||
|
||||
def test_ar_pairs_of_other_type_do_not_shift(self) -> None:
|
||||
"""向池中加入其它题型的 AR pair,不改变非 AR momentum 的抽样序列。"""
|
||||
singles = [_single(f"s{i}", task_type="RETRIEVAL") for i in range(30)]
|
||||
allowed = {"RETRIEVAL"}
|
||||
epoch = 11
|
||||
samples = 8
|
||||
base = _sample_momentum_candidates(singles, allowed, samples, epoch)
|
||||
|
||||
mixed = list(singles)
|
||||
for k in range(4):
|
||||
po, pm = _pair(f"p{k}", task_type="AR")
|
||||
mixed.extend([po, pm])
|
||||
after = _sample_momentum_candidates(mixed, allowed, samples, epoch)
|
||||
assert [q.question_id for q in base] == [q.question_id for q in after]
|
||||
|
||||
def test_fewer_candidates_than_samples_returns_all(self) -> None:
|
||||
pool = [_single(f"s{i}", task_type="RETRIEVAL") for i in range(3)]
|
||||
got = _sample_momentum_candidates(pool, {"RETRIEVAL"}, 10, epoch=1)
|
||||
assert {q.question_id for q in got} == {"s0", "s1", "s2"}
|
||||
Reference in New Issue
Block a user