Files
Video-Tree-TRM5/app/question_gen/run_store.py

1072 lines
36 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.
"""SQLite 出题管线日志记录器 — 记录批次运行与逐题门判定。
写入频率低(每批次 ~240 题),使用同步 sqlite3 即可。
schema 定义参照 research-wiki/schemas/question-gen-runs.md 和 question-gen-items.md。
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from loguru import logger
if TYPE_CHECKING:
from pathlib import Path
# ---------------------------------------------------------------------------
# GateReport ProtocolTask 6 尚未实现,此处声明 duck-type 接口)
# ---------------------------------------------------------------------------
@runtime_checkable
class _GateResultLike(Protocol):
"""单门判定结果的最小接口。"""
@property
def verdict(self) -> object:
"""PASS / FAIL / SKIP 枚举值,.value 为小写字符串。"""
...
@property
def reason(self) -> str:
"""判定理由。"""
...
@runtime_checkable
class GateReportLike(Protocol):
"""GateReport 的最小 duck-type 接口。"""
@property
def key_verify(self) -> _GateResultLike: ...
@property
def blind_answer(self) -> _GateResultLike: ...
@property
def multi_true(self) -> _GateResultLike: ...
@property
def leak_test(self) -> _GateResultLike: ...
@property
def passed(self) -> bool: ...
@property
def reject_reason(self) -> str | None: ...
# ---------------------------------------------------------------------------
# 数据类
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class RunStats:
"""批次运行的统计摘要。
Attributes
----------
total_slots : int
目标题数(slot 总数)。
accepted : int
最终通过门判定的题数。
rejected : int
最终被拒绝的题数。
heavy_sampled : int
进行重量抽检的题数。
"""
total_slots: int
accepted: int
rejected: int
heavy_sampled: int
# ---------------------------------------------------------------------------
# DDL
# ---------------------------------------------------------------------------
# DEPRECATED(v3): v2 生成落库,Phase 2 生成替换后删;过渡期与 v3 表并存
_DDL_RUNS = """
CREATE TABLE IF NOT EXISTS question_gen_runs (
run_id TEXT PRIMARY KEY,
git_sha TEXT NOT NULL,
config_snapshot TEXT,
started_at TEXT NOT NULL,
ended_at TEXT,
status TEXT NOT NULL DEFAULT 'running',
total_slots INTEGER,
accepted INTEGER,
rejected INTEGER,
heavy_sampled INTEGER
);
"""
# DEPRECATED(v3): v2 生成落库,Phase 2 生成替换后删;过渡期与 v3 表并存
_DDL_ITEMS = """
CREATE TABLE IF NOT EXISTS question_gen_items (
item_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES question_gen_runs(run_id),
slot_id TEXT NOT NULL,
video_id TEXT NOT NULL,
family TEXT NOT NULL,
task_type TEXT NOT NULL,
skill_target TEXT NOT NULL,
attempt INTEGER NOT NULL,
question_text TEXT NOT NULL,
sub_pattern TEXT,
gate_key_verify TEXT,
gate_blind_answer TEXT,
gate_multi_true TEXT,
gate_leak_test TEXT,
gate_reject_reason TEXT,
final_status TEXT NOT NULL DEFAULT 'pending',
difficulty_steps INTEGER,
selector_scores TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
_DDL_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_qgr_status ON question_gen_runs(status);",
"CREATE INDEX IF NOT EXISTS idx_qgi_run ON question_gen_items(run_id);",
"CREATE INDEX IF NOT EXISTS idx_qgi_slot ON question_gen_items(slot_id);",
"CREATE INDEX IF NOT EXISTS idx_qgi_status ON question_gen_items(final_status);",
"CREATE INDEX IF NOT EXISTS idx_qgi_family ON question_gen_items(family);",
"CREATE INDEX IF NOT EXISTS idx_qgi_task_type ON question_gen_items(task_type);",
]
# DEPRECATED(v3): v2 生成落库,Phase 2 生成替换后删;过渡期与 v3 表并存
_DDL_VERDICTS = """
CREATE TABLE IF NOT EXISTS adversarial_verdicts (
question_id TEXT NOT NULL,
question_hash TEXT NOT NULL,
stage TEXT NOT NULL,
round INTEGER NOT NULL,
agent_prediction TEXT,
agent_correct INTEGER,
verdict TEXT NOT NULL,
pair_id TEXT,
agent_config TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (question_id, question_hash, stage)
);
"""
_DDL_VERDICTS_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_av_qid ON adversarial_verdicts(question_id);",
"CREATE INDEX IF NOT EXISTS idx_av_verdict ON adversarial_verdicts(verdict);",
"CREATE INDEX IF NOT EXISTS idx_av_round ON adversarial_verdicts(round);",
]
# ---------------------------------------------------------------------------
# v3 出题领域观测表 DDLfacts/unit_verdict/collapse_metrics/quarantine/resume_state
#
# 时间戳边界(Codex I-4):v3 新表的 ts 列一律由 insert 方法外部传入,DDL 中不设
# DEFAULT (datetime('now'))、方法体不调 datetime.now(),以保证观测幂等可复现——
# 断点续跑重放时不因进程内时钟漂移产生脏数据。resume_state 无 ts 列(schema 未列)。
# ---------------------------------------------------------------------------
_DDL_FACTS = """
CREATE TABLE IF NOT EXISTS facts (
fact_id TEXT PRIMARY KEY,
video_id TEXT,
segment_id TEXT,
subject TEXT,
action TEXT,
object TEXT,
frame_ids TEXT,
polarity TEXT,
fact_type TEXT,
difficulty_tier INTEGER,
verifier_refs TEXT,
cross_agree INTEGER CHECK(cross_agree IN (0, 1)),
negative_at_target TEXT,
session_id TEXT,
ts TEXT
);
"""
_DDL_UNIT_VERDICT = """
CREATE TABLE IF NOT EXISTS unit_verdict (
unit_id TEXT NOT NULL,
pair_id TEXT,
sub_pattern TEXT,
stage INTEGER NOT NULL CHECK(stage BETWEEN 1 AND 6),
verdict TEXT CHECK(verdict IN ('pass', 'fail', 'abstain')),
reason TEXT,
metric_value REAL,
model TEXT,
session_id TEXT,
ts TEXT,
PRIMARY KEY (unit_id, stage)
);
"""
_DDL_COLLAPSE_METRICS = """
CREATE TABLE IF NOT EXISTS collapse_metrics (
pair_id TEXT PRIMARY KEY,
text_only_acc REAL,
single_frame_acc REAL,
placebo_drop REAL,
majority_vote_hit REAL,
slot_chi2 REAL,
distractor_min_dist REAL,
multiformat_consistency REAL,
subtitle_answerability REAL,
ts TEXT
);
"""
_DDL_QUARANTINE = """
CREATE TABLE IF NOT EXISTS quarantine (
content_fingerprint TEXT PRIMARY KEY,
sub_pattern TEXT,
quarantine_reason TEXT,
round_no INTEGER,
ts TEXT
);
"""
_DDL_RESUME_STATE = """
CREATE TABLE IF NOT EXISTS resume_state (
unit_id TEXT PRIMARY KEY,
status TEXT CHECK(status IN ('pending', 'accepted', 'rejected')),
config_fingerprint TEXT,
seq_offset INTEGER
);
"""
_DDL_V3_TABLES = [
_DDL_FACTS,
_DDL_UNIT_VERDICT,
_DDL_COLLAPSE_METRICS,
_DDL_QUARANTINE,
_DDL_RESUME_STATE,
]
_DDL_V3_INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_facts_video ON facts(video_id);",
"CREATE INDEX IF NOT EXISTS idx_facts_session ON facts(session_id);",
# unit_verdict(unit_id) 无需独立索引:主键 (unit_id, stage) 的左前缀已覆盖 unit_id 查询。
"CREATE INDEX IF NOT EXISTS idx_uv_sub_pattern ON unit_verdict(sub_pattern);",
"CREATE INDEX IF NOT EXISTS idx_quar_sub_pattern ON quarantine(sub_pattern);",
"CREATE INDEX IF NOT EXISTS idx_resume_status ON resume_state(status);",
]
# ---------------------------------------------------------------------------
# Store 实现
# ---------------------------------------------------------------------------
class QuestionGenStore:
"""SQLite 出题管线日志记录器。
记录每次出题批次的元数据(run)和每题的门判定结果(item)。
使用同步 sqlite3,写入频率低无需异步。
Parameters
----------
db_path : Path
SQLite 数据库文件路径。父目录必须存在。
"""
def __init__(self, db_path: Path) -> None:
self._db_path = db_path
self._conn = sqlite3.connect(
str(db_path),
check_same_thread=False,
timeout=10.0,
)
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA busy_timeout=5000")
self._conn.execute("PRAGMA foreign_keys=ON")
self._init_schema()
logger.debug("QuestionGenStore 已初始化: {}", db_path)
def _init_schema(self) -> None:
"""幂等创建表和索引。多次调用安全。"""
self._conn.execute(_DDL_RUNS)
self._conn.execute(_DDL_ITEMS)
for idx_sql in _DDL_INDEXES:
self._conn.execute(idx_sql)
self._conn.execute(_DDL_VERDICTS)
for idx_sql in _DDL_VERDICTS_INDEXES:
self._conn.execute(idx_sql)
for ddl in _DDL_V3_TABLES:
self._conn.execute(ddl)
for idx_sql in _DDL_V3_INDEXES:
self._conn.execute(idx_sql)
self._conn.commit()
# 幂等迁移:为已有表加 sub_pattern / selector_scores 列
cols = {r[1] for r in self._conn.execute("PRAGMA table_info(question_gen_items)")}
if "sub_pattern" not in cols:
self._conn.execute("ALTER TABLE question_gen_items ADD COLUMN sub_pattern TEXT")
self._conn.commit()
if "selector_scores" not in cols:
self._conn.execute("ALTER TABLE question_gen_items ADD COLUMN selector_scores TEXT")
self._conn.commit()
def record_run_start(self, run_id: str, git_sha: str, config_snapshot: str) -> None:
"""记录批次开始。
Parameters
----------
run_id : str
批次唯一标识(UUID)。
git_sha : str
当前代码版本 HEAD commit。
config_snapshot : str
科研配置快照(JSON 序列化字符串)。
"""
now = datetime.now(tz=UTC).isoformat(timespec="seconds")
self._conn.execute(
"""
INSERT INTO question_gen_runs (run_id, git_sha, config_snapshot, started_at, status)
VALUES (?, ?, ?, ?, 'running')
""",
(run_id, git_sha, config_snapshot, now),
)
self._conn.commit()
logger.info("出题批次已开始: run_id={}", run_id)
def record_run_end(self, run_id: str, status: str, stats: RunStats) -> None:
"""记录批次结束,更新状态与统计。
Parameters
----------
run_id : str
批次唯一标识。
status : str
最终状态(completed / failed)。
stats : RunStats
批次统计摘要。
"""
now = datetime.now(tz=UTC).isoformat(timespec="seconds")
cursor = self._conn.execute(
"""
UPDATE question_gen_runs
SET ended_at=?, status=?, total_slots=?, accepted=?, rejected=?, heavy_sampled=?
WHERE run_id=?
""",
(
now,
status,
stats.total_slots,
stats.accepted,
stats.rejected,
stats.heavy_sampled,
run_id,
),
)
self._conn.commit()
if cursor.rowcount == 0:
raise ValueError(f"run_id 不存在: {run_id}")
logger.info(
"出题批次已结束: run_id={}, status={}, accepted={}/{}",
run_id,
status,
stats.accepted,
stats.total_slots,
)
def record_item(
self,
item_id: str,
run_id: str,
slot_id: str,
video_id: str,
family: str,
task_type: str,
skill_target: str,
attempt: int,
question_text: str,
sub_pattern: str | None = None,
) -> None:
"""记录一道新生成的题目(初始状态 pending)。
Parameters
----------
item_id : str
题目唯一 ID(每轮独立)。
run_id : str
关联的批次 ID。
slot_id : str
逻辑 slot 标识(同 slot 多次重出共享)。
video_id : str
视频 ID。
family : str
题族(retrieval/reasoning/enumeration/visual/spatial)。
task_type : str
Video-MME 12 类主标签。
skill_target : str
M1-M5 + 题族子标签。
attempt : int
当前重出轮次(1-based)。
question_text : str
题目文本。
sub_pattern : str | None
子模式标识(如有)。
"""
now = datetime.now(tz=UTC).isoformat(timespec="seconds")
self._conn.execute(
"""
INSERT INTO question_gen_items
(item_id, run_id, slot_id, video_id, family, task_type,
skill_target, attempt, question_text, sub_pattern, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
item_id,
run_id,
slot_id,
video_id,
family,
task_type,
skill_target,
attempt,
question_text,
sub_pattern,
now,
),
)
self._conn.commit()
def update_gates(self, item_id: str, report: GateReportLike) -> None:
"""更新门判定结果及 final_status。
Parameters
----------
item_id : str
题目唯一 ID。
report : GateReportLike
门判定报告(duck-type,需有 key_verify/blind_answer/multi_true/leak_test
属性,每个属性具有 .verdict.value 和 .reason;以及 passed/reject_reason 属性)。
"""
final_status = "accepted" if report.passed else "rejected"
cursor = self._conn.execute(
"""
UPDATE question_gen_items
SET gate_key_verify=?, gate_blind_answer=?, gate_multi_true=?,
gate_leak_test=?, gate_reject_reason=?, final_status=?
WHERE item_id=?
""",
(
report.key_verify.verdict.value,
report.blind_answer.verdict.value,
report.multi_true.verdict.value,
report.leak_test.verdict.value,
report.reject_reason,
final_status,
item_id,
),
)
self._conn.commit()
if cursor.rowcount == 0:
raise ValueError(f"item_id 不存在: {item_id}")
def mark_item_rejected(self, item_id: str, reason: str) -> None:
"""将已记录的 item 标记为 rejected(用于门控外的拒绝场景,如去重)。
Parameters
----------
item_id : str
题目唯一 ID。
reason : str
拒绝原因描述。
"""
cursor = self._conn.execute(
"UPDATE question_gen_items SET final_status='rejected', gate_reject_reason=? "
"WHERE item_id=?",
(reason, item_id),
)
self._conn.commit()
if cursor.rowcount == 0:
raise ValueError(f"item_id 不存在: {item_id}")
def update_difficulty(self, item_id: str, difficulty_steps: int) -> None:
"""更新重量抽检产出的 Agent 步数。
Parameters
----------
item_id : str
题目唯一 ID。
difficulty_steps : int
Agent 完成该题所需步数。
"""
cursor = self._conn.execute(
"UPDATE question_gen_items SET difficulty_steps=? WHERE item_id=?",
(difficulty_steps, item_id),
)
self._conn.commit()
if cursor.rowcount == 0:
raise ValueError(f"item_id 不存在: {item_id}")
def record_verdict(
self,
*,
question_id: str,
question_hash: str,
stage: str,
round: int, # noqa: A002 — 与设计列名一致,仅 kwargs 传入无遮蔽风险
agent_prediction: str | None,
agent_correct: bool | None,
verdict: str,
pair_id: str | None,
agent_config: str,
) -> None:
"""写入一条 agent 门判定(同 (question_id, question_hash, stage) upsert)。
每次写入立即 commit,保证崩溃安全(进程中断最多丢失当前未提交的一条)。
Parameters
----------
question_id, question_hash, stage : str
续跑主键三元组(stage ∈ cheat|flip_original|flip_mirror)。
round : int
过滤轮次。
agent_prediction : str | None
agent 预测答案字母。
agent_correct : bool | None
作弊门是否答对(翻转门 stage 可为 None)。
verdict : str
passed | filtered_too_easy | filtered_no_flip | flip_skipped。
pair_id : str | None
关联原题与镜像题。
agent_config : str
agent 配置指纹(skill_mode/max_steps/model)。
"""
self._conn.execute(
"""
INSERT INTO adversarial_verdicts
(question_id, question_hash, stage, round, agent_prediction,
agent_correct, verdict, pair_id, agent_config)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(question_id, question_hash, stage) DO UPDATE SET
round=excluded.round,
agent_prediction=excluded.agent_prediction,
agent_correct=excluded.agent_correct,
verdict=excluded.verdict,
pair_id=excluded.pair_id,
agent_config=excluded.agent_config,
created_at=datetime('now')
""",
(
question_id,
question_hash,
stage,
round,
agent_prediction,
None if agent_correct is None else int(agent_correct),
verdict,
pair_id,
agent_config,
),
)
self._conn.commit()
def completed_stages(self, question_id: str, question_hash: str, agent_config: str) -> set[str]:
"""返回该题在当前 hash+config 下已完成的 stage 集合(续跑用)。
Parameters
----------
question_id : str
题目唯一标识。
question_hash : str
当前题面指纹;hash 不匹配的旧行视为未完成,需重跑试答。
agent_config : str
当前 agent 配置指纹。
Returns
-------
set[str]
已完成的 stage 名称集合。
"""
rows = self._conn.execute(
"SELECT stage FROM adversarial_verdicts "
"WHERE question_id=? AND question_hash=? AND agent_config=?",
(question_id, question_hash, agent_config),
).fetchall()
return {r[0] for r in rows}
def invalidate_stale_config(self, question_id: str, agent_config: str) -> None:
"""agent_config 变化时,删除该题所有非当前 config 的旧 verdict。
Parameters
----------
question_id : str
题目唯一标识。
agent_config : str
当前 agent 配置指纹;保留该 config 行,其余全部删除。
"""
self._conn.execute(
"DELETE FROM adversarial_verdicts WHERE question_id=? AND agent_config!=?",
(question_id, agent_config),
)
self._conn.commit()
def cheat_agent_accuracy(self, round_no: int) -> float:
"""某轮作弊门 agent 正确率(agent_correct 聚合),无数据返 0.0。
Parameters
----------
round_no : int
过滤轮次。
Returns
-------
float
该轮 stage='cheat' 的 agent_correct 平均值;无数据时返回 0.0。
"""
row = self._conn.execute(
"SELECT AVG(agent_correct) FROM adversarial_verdicts WHERE stage='cheat' AND round=?",
(round_no,),
).fetchone()
return float(row[0]) if row and row[0] is not None else 0.0
def final_passed_question_ids(self, hash_by_qid: dict[str, str], agent_config: str) -> set[str]:
"""在当前 hash+config 下通过两门的 question_id 集合(final JSON 全量重建用)。
终判规则(防 stale 泄漏):仅当该题在 **当前 question_hash + 当前
agent_config** 下同时满足——存在 stage='cheat' 且 verdict='passed'
(agent 答错=不太简单),且不存在任何 stage 的 verdict='filtered_no_flip'
(未被翻转门剔除)——才计入 final-passed。stale hash / stale config 的旧行
因不匹配传入的 (qid, hash, config) 天然被排除,绝不泄漏进最终题库。
Parameters
----------
hash_by_qid : dict[str, str]
question_id → 当前 question_hash 映射(来自本轮 all_questions)。
agent_config : str
当前 agent 配置指纹。
Returns
-------
set[str]
终判 passed 的 question_id 集合。
"""
passed: set[str] = set()
for qid, qhash in hash_by_qid.items():
rows = self._conn.execute(
"SELECT stage, verdict FROM adversarial_verdicts "
"WHERE question_id=? AND question_hash=? AND agent_config=?",
(qid, qhash, agent_config),
).fetchall()
if not rows:
continue
cheat_passed = any(stage == "cheat" and verdict == "passed" for stage, verdict in rows)
no_flip = any(verdict == "filtered_no_flip" for _, verdict in rows)
if cheat_passed and not no_flip:
passed.add(qid)
return passed
def update_selector_scores(self, item_id: str, selector_scores_json: str) -> None:
"""写入 grounded selector 打分观测(JSON 字符串)。
Parameters
----------
item_id : str
题目唯一 ID。
selector_scores_json : str
观测 JSONcorrect_score / chosen / pool_size / anneal_rounds / hard_fail。
Raises
------
ValueError
item_id 不存在时抛出。
"""
cursor = self._conn.execute(
"UPDATE question_gen_items SET selector_scores=? WHERE item_id=?",
(selector_scores_json, item_id),
)
self._conn.commit()
if cursor.rowcount == 0:
raise ValueError(f"item_id 不存在: {item_id}")
def get_run_stats(self, run_id: str) -> RunStats:
"""查询批次统计摘要。
Parameters
----------
run_id : str
批次唯一标识。
Returns
-------
RunStats
该批次的统计数据。
Raises
------
ValueError
run_id 不存在时抛出。
"""
row = self._conn.execute(
"SELECT total_slots, accepted, rejected, heavy_sampled "
"FROM question_gen_runs WHERE run_id=?",
(run_id,),
).fetchone()
if row is None:
raise ValueError(f"run_id 不存在: {run_id}")
return RunStats(
total_slots=row[0] or 0,
accepted=row[1] or 0,
rejected=row[2] or 0,
heavy_sampled=row[3] or 0,
)
def load_progress(self) -> dict[str, str]:
"""加载已接受 slot 的进度映射(用于断点续跑)。
从最近一次 running 状态的批次中,只读取 accepted 的 slot。
rejected 的 slot 不纳入 progress,以便重跑时重新尝试。
Returns
-------
dict[str, str]
{slot_id: "accepted"} 映射。无进度时返回空 dict。
"""
row = self._conn.execute(
"SELECT run_id FROM question_gen_runs WHERE status='running' "
"ORDER BY started_at DESC LIMIT 1",
).fetchone()
if row is None:
return {}
run_id = row[0]
rows = self._conn.execute(
"SELECT DISTINCT slot_id FROM question_gen_items "
"WHERE run_id=? AND final_status='accepted'",
(run_id,),
).fetchall()
return {row[0]: "accepted" for row in rows}
# -----------------------------------------------------------------------
# v3 出题领域观测写入(ts 一律外部传入,禁进程内 now,保幂等可复现)
# -----------------------------------------------------------------------
def insert_fact(
self,
*,
fact_id: str,
video_id: str,
segment_id: str,
subject: str,
action: str,
object: str, # noqa: A002 — 与设计列名 object 一致,仅 kwargs 传入无遮蔽风险
frame_ids: str,
polarity: str,
fact_type: str,
difficulty_tier: int,
verifier_refs: str,
cross_agree: int,
negative_at_target: str,
session_id: str,
ts: str,
) -> None:
"""写入一条帧感知抽取 Factfacts 表,主键 fact_id)。
Parameters
----------
fact_id : str
Fact 唯一标识(UUID)。
video_id, segment_id : str
溯源:所属视频与片段。
subject, action, object : str
结构化绑定三元组。
frame_ids : str
感知所用帧 ID 的 JSON 数组字符串。
polarity : str
事实极性(真/假)。
fact_type : str
事实类型(binding/state/manner/order/evidence)。
difficulty_tier : int
感知难度分层。
verifier_refs : str
双 VLMqwen/MiniMax)各自裁决的 JSON 字符串。
cross_agree : int
双 VLM 是否一致(0/1)。
negative_at_target : str
目标点为假的核实结果。
session_id : str
epoch/step 关联 ID。
ts : str
观测时间戳 ISO 字符串,由调用方外部传入(禁进程内 now)。
"""
self._conn.execute(
"""
INSERT INTO facts
(fact_id, video_id, segment_id, subject, action, object, frame_ids,
polarity, fact_type, difficulty_tier, verifier_refs, cross_agree,
negative_at_target, session_id, ts)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
fact_id,
video_id,
segment_id,
subject,
action,
object,
frame_ids,
polarity,
fact_type,
difficulty_tier,
verifier_refs,
cross_agree,
negative_at_target,
session_id,
ts,
),
)
self._conn.commit()
logger.debug("facts 已写入: fact_id={}", fact_id)
def insert_unit_verdict(
self,
*,
unit_id: str,
pair_id: str | None,
sub_pattern: str,
stage: int,
verdict: str,
reason: str,
metric_value: float | None,
model: str | None,
session_id: str,
ts: str,
) -> None:
"""写入六层验证某一层的裁决(unit_verdict 表,主键 (unit_id, stage))。
同一 (unit_id, stage) 重跑时以 upsert 覆盖旧行——每 unit 每层仅保留最新一条,
便于断点续跑重放而不残留过期裁决。
覆盖语义:ON CONFLICT DO UPDATE 用 ``excluded.*`` **整行覆盖**全部非键列
(含可空的 metric_value/model 及 ts)——只保留每层最新完整裁决,不支持部分字段
更新。调用方每次必须传完整行,否则会用 metric_value=None/model=None 误抹先前非空值。
Parameters
----------
unit_id : str
pair/single 单元标识。
pair_id : str | None
所属 pair 关联标识(single 单元可为 None)。
sub_pattern : str
6 子模式之一。
stage : int
验证层号(1-6)。
verdict : str
该层裁决(pass/fail/abstain)。
reason : str
拒因描述。
metric_value : float | None
该层量化值(无量化值时为 None)。
model : str | None
裁判模型名(无模型判定时为 None)。
session_id : str
epoch/step 关联 ID。
ts : str
观测时间戳 ISO 字符串,由调用方外部传入(禁进程内 now)。
"""
self._conn.execute(
"""
INSERT INTO unit_verdict
(unit_id, pair_id, sub_pattern, stage, verdict, reason,
metric_value, model, session_id, ts)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(unit_id, stage) DO UPDATE SET
pair_id=excluded.pair_id,
sub_pattern=excluded.sub_pattern,
verdict=excluded.verdict,
reason=excluded.reason,
metric_value=excluded.metric_value,
model=excluded.model,
session_id=excluded.session_id,
ts=excluded.ts
""",
(
unit_id,
pair_id,
sub_pattern,
stage,
verdict,
reason,
metric_value,
model,
session_id,
ts,
),
)
self._conn.commit()
logger.debug("unit_verdict 已写入: unit_id={}, stage={}", unit_id, stage)
def insert_collapse_metrics(
self,
*,
pair_id: str,
text_only_acc: float,
single_frame_acc: float,
placebo_drop: float,
majority_vote_hit: float,
slot_chi2: float,
distractor_min_dist: float,
multiformat_consistency: float,
subtitle_answerability: float,
ts: str,
) -> None:
"""写入配对坍缩度量(collapse_metrics 表,主键 pair_id)。
同一 pair_id 重算时以 upsert 覆盖旧行——每 pair 仅保留最新一组度量。
覆盖语义:ON CONFLICT DO UPDATE 用 ``excluded.*`` **整行覆盖**全部非键列(含 ts),
不支持部分字段更新;调用方每次必须传完整度量行。
Parameters
----------
pair_id : str
配对唯一标识。
text_only_acc, single_frame_acc, placebo_drop : float
模型探针类度量。
majority_vote_hit, slot_chi2, distractor_min_dist : float
纯结构类度量。
multiformat_consistency, subtitle_answerability : float
探针类度量。
ts : str
观测时间戳 ISO 字符串,由调用方外部传入(禁进程内 now)。
"""
self._conn.execute(
"""
INSERT INTO collapse_metrics
(pair_id, text_only_acc, single_frame_acc, placebo_drop,
majority_vote_hit, slot_chi2, distractor_min_dist,
multiformat_consistency, subtitle_answerability, ts)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pair_id) DO UPDATE SET
text_only_acc=excluded.text_only_acc,
single_frame_acc=excluded.single_frame_acc,
placebo_drop=excluded.placebo_drop,
majority_vote_hit=excluded.majority_vote_hit,
slot_chi2=excluded.slot_chi2,
distractor_min_dist=excluded.distractor_min_dist,
multiformat_consistency=excluded.multiformat_consistency,
subtitle_answerability=excluded.subtitle_answerability,
ts=excluded.ts
""",
(
pair_id,
text_only_acc,
single_frame_acc,
placebo_drop,
majority_vote_hit,
slot_chi2,
distractor_min_dist,
multiformat_consistency,
subtitle_answerability,
ts,
),
)
self._conn.commit()
logger.debug("collapse_metrics 已写入: pair_id={}", pair_id)
def quarantine(
self,
*,
content_fingerprint: str,
sub_pattern: str,
quarantine_reason: str,
round_no: int,
ts: str,
) -> None:
"""将失败题的内容指纹写入隔离区黑名单(quarantine 表,主键 content_fingerprint)。
同一 content_fingerprint 重复调用以 upsert 覆盖——保证语义相同题面只占一行,
补构造前查此表当黑名单去重。覆盖语义:ON CONFLICT DO UPDATE 用 ``excluded.*``
**整行覆盖**全部非键列(含 ts),不支持部分字段更新。
Parameters
----------
content_fingerprint : str
题面语义内容指纹(去重键)。
sub_pattern : str
题目所属子模式。
quarantine_reason : str
隔离原因。
round_no : int
隔离发生的过滤轮次。
ts : str
观测时间戳 ISO 字符串,由调用方外部传入(禁进程内 now)。
"""
self._conn.execute(
"""
INSERT INTO quarantine
(content_fingerprint, sub_pattern, quarantine_reason, round_no, ts)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(content_fingerprint) DO UPDATE SET
sub_pattern=excluded.sub_pattern,
quarantine_reason=excluded.quarantine_reason,
round_no=excluded.round_no,
ts=excluded.ts
""",
(content_fingerprint, sub_pattern, quarantine_reason, round_no, ts),
)
self._conn.commit()
logger.debug("quarantine 已写入: fingerprint={}", content_fingerprint)
def upsert_resume_state(
self,
*,
unit_id: str,
status: str,
config_fingerprint: str,
seq_offset: int,
) -> None:
"""写入/更新断点续跑状态(resume_state 表,主键 unit_id)。
同一 unit_id 覆盖更新——每单元仅保留最新续跑状态。config_fingerprint 变化时
由调用方据此判定旧进度作废并重跑。本表无 ts 列(schema 未定义),但方法体仍禁
进程内 now,保持 v3 观测一致的幂等可复现语义。
覆盖语义:ON CONFLICT DO UPDATE 用 ``excluded.*`` **整行覆盖**全部非键列,不支持
部分字段更新;调用方每次必须传完整状态行。
Parameters
----------
unit_id : str
单元唯一标识。
status : str
续跑状态(pending/accepted/rejected)。
config_fingerprint : str
求解器/裁判 config 指纹,变更时旧进度作废。
seq_offset : int
补构造续编偏移,防止编号相撞。
"""
self._conn.execute(
"""
INSERT INTO resume_state
(unit_id, status, config_fingerprint, seq_offset)
VALUES (?, ?, ?, ?)
ON CONFLICT(unit_id) DO UPDATE SET
status=excluded.status,
config_fingerprint=excluded.config_fingerprint,
seq_offset=excluded.seq_offset
""",
(unit_id, status, config_fingerprint, seq_offset),
)
self._conn.commit()
logger.debug("resume_state 已写入: unit_id={}, status={}", unit_id, status)
def close(self) -> None:
"""关闭数据库连接。"""
self._conn.close()
logger.debug("QuestionGenStore 已关闭")