feat: add pair atomic accepted-bank writer helper

PairPendingBuffer collects pair members by pair_id and emits units;
write_accepted does tmp+os.replace atomic write; read_accepted rebuilds
units and drops disk-dangling orphans. Pure helper; on_accept wiring is
Phase 2 (real write point: adversarial_filter.write_final_bank).
This commit is contained in:
2026-07-15 09:22:41 -04:00
parent 19911e18e0
commit bdcc93d7de
2 changed files with 399 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
"""pair_atomic_writer 纯 helper 单元测试。
覆盖三件核心行为:
- PairPendingBuffersingle 直通成单元、pair 按 pair_id 收齐才 emit、
未配对孤儿可查询、绑定不一致(unit_hash 语义代偿)拒绝。
- write_accepted:全量 tmp + os.replace 原子写,落盘无残留 .tmp。
- read_acceptedbuild_units + validate_units 剔除磁盘悬挂孤儿(warn+drop)。
测试用真实 GeneratedQuestion 构造(single + 合法 AR pair + 故意缺 mirror 的孤儿),
跑完整 add → write → read 往返。
"""
from __future__ import annotations
import json
import pytest
from app.harness.pools import _q_to_dict
from app.question_gen.pair_atomic_writer import (
PairPendingBuffer,
read_accepted,
write_accepted,
)
from core.types import GeneratedQuestion
def _make_single(qid: str = "s1") -> GeneratedQuestion:
"""构造一条真实 single 题(pair_id=Nonequestion_role='single')。"""
return GeneratedQuestion(
question_id=qid,
video_id="vid_A",
task_type="Action Reasoning",
question=f"{qid} 问题文本?",
options=("A. 甲", "B. 乙", "C. 丙", "D. 丁"),
answer="B",
source_nodes=("L2_0", "L3_1"),
difficulty="medium",
)
def _make_pair(
pair_id: str = "p1",
*,
video_id: str = "vid_B",
flip_axis: str = "before_after",
) -> tuple[GeneratedQuestion, GeneratedQuestion]:
"""构造一对合法 AR 孪生对(original + mirror,共享 pair_id/flip_axis)。"""
original = GeneratedQuestion(
question_id=f"{pair_id}_o",
video_id=video_id,
task_type="Action Reasoning",
question="事件 X 发生在事件 Y 之前吗?",
options=("A. 是", "B. 否", "C. 无关", "D. 无法判断"),
answer="A",
source_nodes=("L2_2",),
difficulty="hard",
pair_id=pair_id,
question_role="pair_original",
flip_axis=flip_axis,
)
mirror = GeneratedQuestion(
question_id=f"{pair_id}_m",
video_id=video_id,
task_type="Action Reasoning",
question="事件 Y 发生在事件 X 之前吗?",
options=("A. 是", "B. 否", "C. 无关", "D. 无法判断"),
answer="B",
source_nodes=("L2_2",),
difficulty="hard",
pair_id=pair_id,
question_role="pair_mirror",
flip_axis=flip_axis,
)
return original, mirror
def test_single_add_emits_unit_immediately() -> None:
"""single 题 add 后立即返回 kind='single' 单元,不进 pending。"""
buf = PairPendingBuffer()
unit = buf.add(_make_single())
assert unit is not None
assert unit.kind == "single"
assert unit.size == 1
assert buf.pending_orphans() == []
def test_pair_emits_only_when_both_arrived() -> None:
"""pair 首个成员进 pending 返回 None,第二个到齐才 emit 一个 pair 单元。"""
buf = PairPendingBuffer()
original, mirror = _make_pair()
assert buf.add(original) is None
assert len(buf.pending_orphans()) == 1
unit = buf.add(mirror)
assert unit is not None
assert unit.kind == "pair"
assert unit.size == 2
assert buf.pending_orphans() == []
def test_pair_completes_regardless_of_arrival_order() -> None:
"""mirror 先到、original 后到也能正确成对。"""
buf = PairPendingBuffer()
original, mirror = _make_pair()
assert buf.add(mirror) is None
unit = buf.add(original)
assert unit is not None and unit.kind == "pair" and unit.size == 2
def test_dangling_original_stays_in_pending() -> None:
"""只落 original 未落 mirror → 悬挂在 pending,供调用方批次末尾检测。"""
buf = PairPendingBuffer()
original, _ = _make_pair()
assert buf.add(original) is None
orphans = buf.pending_orphans()
assert len(orphans) == 1
assert orphans[0].question_id == original.question_id
def test_inconsistent_pair_binding_rejected() -> None:
"""同 pair_id 两成员 flip_axis 不一致 → 拒绝(unit_hash 语义代偿)。"""
buf = PairPendingBuffer()
original, _ = _make_pair(flip_axis="before_after")
_, bad_mirror = _make_pair(flip_axis="left_right")
buf.add(original)
with pytest.raises(ValueError, match="绑定不一致"):
buf.add(bad_mirror)
def test_same_role_pair_rejected() -> None:
"""同 pair_id 两成员角色相同(两个 original)→ fail-fast 拒绝。"""
buf = PairPendingBuffer()
o1, _ = _make_pair()
o2, _ = _make_pair()
buf.add(o1)
with pytest.raises(ValueError):
buf.add(o2)
def test_write_read_roundtrip_keeps_pair_and_single(tmp_path) -> None:
"""add → write_accepted → read_accepted 往返:single 直通、pair 不拆、内容等价。"""
buf = PairPendingBuffer()
single = _make_single()
original, mirror = _make_pair()
units = []
for q in (single, original, mirror):
emitted = buf.add(q)
if emitted is not None:
units.append(emitted)
assert buf.pending_orphans() == []
assert len(units) == 2 # 1 single + 1 pair
out = tmp_path / "accepted" / "bank.json"
write_accepted(out, units)
assert out.exists()
assert not out.with_suffix(".tmp").exists() # 原子写无残留
loaded = read_accepted(out)
kinds = sorted(u.kind for u in loaded)
assert kinds == ["pair", "single"]
loaded_pair = next(u for u in loaded if u.kind == "pair")
assert loaded_pair.size == 2
assert {q.question_id for q in loaded_pair.questions} == {
original.question_id,
mirror.question_id,
}
assert {q.flip_axis for q in loaded_pair.questions} == {"before_after"}
def test_read_drops_disk_dangling_orphan(tmp_path, caplog) -> None:
"""磁盘上某 pair 只有 original(缺 mirror)→ read_accepted warn+drop,不 raise。"""
single = _make_single()
original, _ = _make_pair()
# 手工写入"只落 P 未落 Q"的记录:single + 孤零 original
records = [_q_to_dict(single), _q_to_dict(original)]
out = tmp_path / "bank.json"
out.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
loaded = read_accepted(out)
assert len(loaded) == 1
assert loaded[0].kind == "single"
assert loaded[0].questions[0].question_id == single.question_id
def test_write_accepted_rejects_malformed_unit(tmp_path) -> None:
"""validate_units 闸门:结构非法的 pair 单元(size≠2)落盘前 fail-fast。"""
from core.types import QuestionUnit
original, _ = _make_pair()
bad_unit = QuestionUnit("pair", "p1", "Action Reasoning", (original,))
with pytest.raises(ValueError):
write_accepted(tmp_path / "bad.json", [bad_unit])