Files
Video-Tree-TRM5/tests/unit/test_adversarial_flip_gate.py

325 lines
11 KiB
Python
Raw Permalink 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.
"""翻转门四路径:passed / filtered_no_flip / flip_skipped / 镜像不入库。"""
import json
import pytest
from app.question_gen.adversarial_config import AdversarialFilterConfig
from app.question_gen.adversarial_filter import (
agent_config_fingerprint,
question_hash,
run_flip_gate,
write_final_bank,
)
from app.question_gen.run_store import QuestionGenStore
from core.types import GeneratedQuestion, LLMResponse
class _FakeAgent:
"""复用 Task 6 语义;带 skill_mode 属性(AgentRunner Protocol 要求)。"""
def __init__(self, preds, model="m1", skill_mode="auto"):
self._preds = preds
self.model = model
self.skill_mode = skill_mode
self.calls: list[str] = []
async def predict(self, questions, *, max_steps, run_id):
self.calls.extend(q.question_id for q in questions)
return {q.question_id: self._preds.get(q.question_id) for q in questions}
class _FakeVLM:
def __init__(self, content):
self._content = content
async def chat_with_images(self, messages, images, *, session_id=None, parent_call_id=None):
return LLMResponse(
content=self._content,
thinking="",
model="fake",
provider="fake",
prompt_tokens=0,
completion_tokens=0,
latency_ms=0,
ttft_ms=None,
max_inter_token_ms=None,
cache_hit=False,
call_id="c",
)
class _FakeMaterial:
subtitle_sentences = ["先炒后蒸"]
frame_paths = ["/f1.jpg"]
def _q(qid, sub="temporal_reasoning_failure"):
return GeneratedQuestion(
question_id=qid,
video_id="v1",
task_type="Action Recognition",
question="X 之前做了什么?",
options=("A. 蒸", "B. 炒", "C. 煮", "D. 炸"),
answer="A",
source_nodes=("n1",),
difficulty="hard",
sub_pattern=sub,
)
def _fp():
return agent_config_fingerprint(skill_mode="auto", max_steps=40, model="m1")
@pytest.fixture(autouse=True)
def _stub_material(monkeypatch):
"""隔离建树素材重建,直接给镜像生成喂假素材。"""
monkeypatch.setattr(
"app.question_gen.adversarial_filter._rebuild_material",
lambda tree, source_nodes: _FakeMaterial(),
)
def _preset_cheat(store, q, pred="A"):
"""预置作弊门 P 预测行(翻转门须复用它,不重跑 agent)。"""
store.record_verdict(
question_id=q.question_id,
question_hash=question_hash(q),
stage="cheat",
round=0,
agent_prediction=pred,
agent_correct=False,
verdict="passed",
pair_id=None,
agent_config=_fp(),
)
@pytest.mark.asyncio
async def test_flip_gate_answer_flips_passed(tmp_path):
store = QuestionGenStore(str(tmp_path / "q.db"))
q = _q("hard")
_preset_cheat(store, q, pred="A") # P canonical="蒸"
agent = _FakeAgent({"hard_mirror": "A"}) # 镜像洗牌后 A=炒 → canonical≠蒸
vlm = _FakeVLM(
json.dumps(
{
"mirror": {
"question": "X 之后?",
"options": ["A. 炒", "B. 蒸", "C. 煮", "D. 炸"],
"answer": "A",
}
},
ensure_ascii=False,
)
)
kept = await run_flip_gate(
[q],
agent=agent,
vlm=vlm,
store=store,
trees={"v1": object()},
config=AdversarialFilterConfig(),
round_no=0,
run_id="r0",
session_id="s",
)
assert {x.question_id for x in kept} == {"hard"}
assert agent.calls == ["hard_mirror"] # C2: 原题 P 未被重跑,只跑镜像
cheat = store._conn.execute(
"SELECT verdict FROM adversarial_verdicts WHERE question_id='hard' AND stage='cheat'"
).fetchone()[0]
assert cheat == "passed"
# flip_original + flip_mirror 两条独立 stage 行,共用同一 pair_id(非空),verdict=passed
by_stage = {
r[0]: (r[1], r[2])
for r in store._conn.execute(
"SELECT stage, verdict, pair_id FROM adversarial_verdicts "
"WHERE question_id='hard' AND stage IN ('flip_original', 'flip_mirror')"
)
}
assert set(by_stage) == {"flip_original", "flip_mirror"}
assert by_stage["flip_original"][0] == "passed"
assert by_stage["flip_mirror"][0] == "passed"
assert by_stage["flip_original"][1] # pair_id 非空
assert by_stage["flip_original"][1] == by_stage["flip_mirror"][1] # 共用同一 pair_id
assert "hard" in store.final_passed_question_ids({"hard": question_hash(q)}, _fp())
store.close()
@pytest.mark.asyncio
async def test_flip_gate_same_answer_filtered(tmp_path):
store = QuestionGenStore(str(tmp_path / "q.db"))
q = _q("stick")
_preset_cheat(store, q, pred="A") # P canonical="蒸"
agent = _FakeAgent({"stick_mirror": "A"}) # 镜像 A=蒸 → canonical 与 P 相同
vlm = _FakeVLM(
json.dumps(
{
"mirror": {
"question": "X 之后?",
"options": ["A. 蒸", "B. 炒", "C. 煮", "D. 炸"],
"answer": "B",
}
},
ensure_ascii=False,
)
)
kept = await run_flip_gate(
[q],
agent=agent,
vlm=vlm,
store=store,
trees={"v1": object()},
config=AdversarialFilterConfig(),
round_no=0,
run_id="r0",
session_id="s",
)
assert kept == [] # 未随问题翻转 → 剔除
cheat = store._conn.execute(
"SELECT verdict FROM adversarial_verdicts WHERE question_id='stick' AND stage='cheat'"
).fetchone()[0]
assert cheat == "filtered_no_flip" # cheat 行被改写 → final 不含它
# flip_original + flip_mirror 两条独立 stage 行,共用同一 pair_id(非空),verdict 一致
by_stage = {
r[0]: (r[1], r[2])
for r in store._conn.execute(
"SELECT stage, verdict, pair_id FROM adversarial_verdicts "
"WHERE question_id='stick' AND stage IN ('flip_original', 'flip_mirror')"
)
}
assert set(by_stage) == {"flip_original", "flip_mirror"}
assert by_stage["flip_original"][0] == "filtered_no_flip"
assert by_stage["flip_mirror"][0] == "filtered_no_flip"
assert by_stage["flip_original"][1] # pair_id 非空
assert by_stage["flip_original"][1] == by_stage["flip_mirror"][1] # 共用同一 pair_id
# 改写后的 cheat 行不再计入终判 passed(双保险:cheat 改判 + filtered_no_flip 排除)
assert "stick" not in store.final_passed_question_ids(
{"stick": question_hash(q)}, _fp()
)
store.close()
@pytest.mark.asyncio
async def test_flip_gate_invalid_mirror_skipped_but_kept(tmp_path):
store = QuestionGenStore(str(tmp_path / "q.db"))
q = _q("murky")
_preset_cheat(store, q, pred="A")
agent = _FakeAgent({}) # 镜像造不出 → agent 不该被调用
vlm = _FakeVLM('{"mirror": null}')
kept = await run_flip_gate(
[q],
agent=agent,
vlm=vlm,
store=store,
trees={"v1": object()},
config=AdversarialFilterConfig(),
round_no=0,
run_id="r0",
session_id="s",
)
assert {x.question_id for x in kept} == {"murky"} # 退回只经作弊门,保留不误杀
assert agent.calls == [] # 镜像 None → 未跑 agent
cheat = store._conn.execute(
"SELECT verdict FROM adversarial_verdicts WHERE question_id='murky' AND stage='cheat'"
).fetchone()[0]
assert cheat == "passed"
store.close()
@pytest.mark.asyncio
async def test_flip_gate_mirror_ok_but_agent_refuses_skipped(tmp_path):
"""镜像生成成功但 agent 对镜像 Q 拒答(None) → flip_skippedsurvivor 保留不误杀。
与 invalid_mirror 用例互补:此处镜像题**造得出来**(走到 agent),只因 agent 无
有效预测→canonical(Q)=None→保守 flip_skipped,绝不据此误杀原题。
"""
store = QuestionGenStore(str(tmp_path / "q.db"))
q = _q("refuse")
_preset_cheat(store, q, pred="A") # P canonical="蒸"
agent = _FakeAgent({}) # 镜像题无预测 → predict 返回 None(拒答)
vlm = _FakeVLM(
json.dumps(
{
"mirror": {
"question": "X 之后?",
"options": ["A. 炒", "B. 蒸", "C. 煮", "D. 炸"],
"answer": "A",
}
},
ensure_ascii=False,
)
)
kept = await run_flip_gate(
[q],
agent=agent,
vlm=vlm,
store=store,
trees={"v1": object()},
config=AdversarialFilterConfig(),
round_no=0,
run_id="r0",
session_id="s",
)
assert {x.question_id for x in kept} == {"refuse"} # 保守保留,不误杀
assert agent.calls == ["refuse_mirror"] # 镜像被跑一次,C2:原题 P 未被重跑
by_stage = {
r[0]: r[1]
for r in store._conn.execute(
"SELECT stage, verdict FROM adversarial_verdicts "
"WHERE question_id='refuse' AND stage IN ('flip_original', 'flip_mirror')"
)
}
assert by_stage == {"flip_original": "flip_skipped", "flip_mirror": "flip_skipped"}
cheat = store._conn.execute(
"SELECT verdict FROM adversarial_verdicts WHERE question_id='refuse' AND stage='cheat'"
).fetchone()[0]
assert cheat == "passed" # cheat 行保持 passed(保留只经作弊门)
# flip_skipped 不算 filtered_no_flip → 仍计入终判 passed(保留题最终可入库)
assert "refuse" in store.final_passed_question_ids(
{"refuse": question_hash(q)}, _fp()
)
store.close()
@pytest.mark.asyncio
async def test_flip_gate_mirror_excluded_and_unsupported_passes(tmp_path):
store = QuestionGenStore(str(tmp_path / "q.db"))
q = _q("hard") # 支持 flip
npq = _q("plain", sub="premature_evidence_anchoring") # 不支持 flip
_preset_cheat(store, q, pred="A")
_preset_cheat(store, npq, pred="B")
agent = _FakeAgent({"hard_mirror": "A"})
vlm = _FakeVLM(
json.dumps(
{
"mirror": {
"question": "X 之后?",
"options": ["A. 炒", "B. 蒸", "C. 煮", "D. 炸"],
"answer": "A",
}
},
ensure_ascii=False,
)
)
kept = await run_flip_gate(
[q, npq],
agent=agent,
vlm=vlm,
store=store,
trees={"v1": object()},
config=AdversarialFilterConfig(),
round_no=0,
run_id="r0",
session_id="s",
)
assert {x.question_id for x in kept} == {"hard", "plain"} # 不支持 flip 直接 passed
assert agent.calls == ["hard_mirror"] # 不支持 flip 的题不跑 agent/VLM
out = tmp_path / "final.json"
write_final_bank(out, store, {"hard": q, "plain": npq}, _fp())
ids = [d["question_id"] for d in json.loads(out.read_text(encoding="utf-8"))]
assert "hard_mirror" not in ids and set(ids) == {"hard", "plain"} # 镜像不入题库
store.close()