Files
Video-Tree-TRM5/app/question_gen/pair_atomic_writer.py
T
iomgaa bdcc93d7de 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).
2026-07-15 09:22:41 -04:00

200 lines
8.7 KiB
Python
Raw 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.
"""accepted 题库的 pair 原子成对落盘 helper(纯件,不依赖 pipeline)。
三件可复用纯件供 Phase 2 新 pipeline 的 on_accept 回调复用:``PairPendingBuffer``
(按 pair_id 收齐才 emit 单元)、``write_accepted``tmp + os.replace 原子写)、
``read_accepted``(聚合成单元并剔除磁盘悬挂孤儿)。
wiring 归属:本模块**只是纯件**,不接任何生成侧回调;真正的 on_accept wiring
**见 Phase 2**。当前项目真实 accepted 写入点是 ``adversarial_filter.write_final_bank``
(Phase 2 待重建的旧代码),本模块沿用其 tmp + os.replace 模式但不 import/不改动它。
unit_hash 校验:``QuestionUnit.unit_hash`` 目前恒为 ""(填充是 Phase 2 的事),且
``GeneratedQuestion`` 不携带 unit_hash 字段。故把"unit_hash 不一致→拒"落地为**同
pair_id 两成员的绑定一致性校验**video_id / task_type / flip_axis),不一致即
fail-fast raise,语义等价——只有 payload 绑定一致的孪生对才允许聚合。
"""
from __future__ import annotations
import json
import os
from collections import defaultdict
from typing import TYPE_CHECKING
from loguru import logger
from core.types import GeneratedQuestion, QuestionUnit
if TYPE_CHECKING:
from pathlib import Path
def _check_pair_binding(first: GeneratedQuestion, second: GeneratedQuestion) -> None:
"""校验同 pair_id 两成员的绑定一致性,不一致 fail-fastunit_hash 语义代偿)。
参数 first/second 为先后到达的孪生对成员。video_id / task_type / flip_axis 任一
不一致即 raise ValueError——绑定不一致的两条题目不构成同一 payload 的孪生对,
拒绝聚合而非静默兜底。
"""
mismatches: list[str] = []
if first.video_id != second.video_id:
mismatches.append(f"video_id: {first.video_id} != {second.video_id}")
if first.task_type != second.task_type:
mismatches.append(f"task_type: {first.task_type} != {second.task_type}")
if first.flip_axis != second.flip_axis:
mismatches.append(f"flip_axis: {first.flip_axis} != {second.flip_axis}")
if mismatches:
raise ValueError(
f"pair {first.pair_id} 两成员绑定不一致(" + "".join(mismatches) + "),拒绝聚合"
)
class PairPendingBuffer:
"""按 pair_id 收齐孪生对才 emit 单元的有状态缓冲器。
喂题接口 ``add`` 逐条消费题目:single 立即 emit ``QuestionUnit.from_single``
pair 成员先缓存,等同一 pair_id 的 original+mirror 都到齐才 emit 一个 pair 单元
(复用 ``QuestionUnit.from_pair`` 走 fail-fast 校验)。批次末尾用
``pending_orphans`` 检测"只落 P 未落 Q"的悬挂项。
"""
def __init__(self) -> None:
"""初始化空缓冲器。pending 以 pair_id 索引首个到达的孪生对成员。"""
self._pending: dict[str, GeneratedQuestion] = {}
def add(self, q: GeneratedQuestion) -> QuestionUnit | None:
"""喂入一条题目 q,返回本次凑齐的单元或 None(pair 尚未配齐)。
single 立即返回 kind="single" 单元;pair 首个成员缓存并返回 None,第二个
成员到齐后返回 kind="pair" 单元。同 pair_id 两成员绑定不一致或角色非法(如
两个 original)即 raise ValueError。
关键实现:配齐后先 ``_check_pair_binding`` 显式绑定校验(把不一致落成清晰
ValueError,不依赖会被 ``-O`` 剥除的 assert),再 ``QuestionUnit.from_pair``
组装。
"""
if not q.pair_id:
return QuestionUnit.from_single(q)
partner = self._pending.get(q.pair_id)
if partner is None:
self._pending[q.pair_id] = q
return None
del self._pending[q.pair_id]
_check_pair_binding(partner, q)
original, mirror = _order_pair(partner, q)
return QuestionUnit.from_pair(original, mirror)
def pending_orphans(self) -> list[GeneratedQuestion]:
"""返回当前仍未配齐的孤儿成员(供批次末尾检测悬挂项)。
返回:
尚在缓冲、未等到伙伴的孪生对成员列表(按 pair_id 插入顺序)。
"""
return list(self._pending.values())
def _order_pair(
a: GeneratedQuestion, b: GeneratedQuestion
) -> tuple[GeneratedQuestion, GeneratedQuestion]:
"""按 question_role 把两成员 a/b 定序为 (original, mirror)。
两成员不构成恰好 1 original + 1 mirror(角色缺失或重复)即 raise ValueError
防 next(...) 静默 StopIteration。
"""
originals = [q for q in (a, b) if q.question_role == "pair_original"]
mirrors = [q for q in (a, b) if q.question_role == "pair_mirror"]
if len(originals) != 1 or len(mirrors) != 1:
raise ValueError(
f"pair {a.pair_id} 角色非法:original={len(originals)} mirror={len(mirrors)}"
"需各恰好 1 条"
)
return originals[0], mirrors[0]
def _is_intact_pair(pair_id: str, group: list[GeneratedQuestion]) -> bool:
"""判定同 pair_id 分组 group 是否为完整合法孪生对(恰好 1 original + 1 mirror)。
完整为 True;否则 warn(含成员构成)并返回 False,由调用方剔除该悬挂 unit。
"""
originals = sum(1 for q in group if q.question_role == "pair_original")
mirrors = sum(1 for q in group if q.question_role == "pair_mirror")
if len(group) == 2 and originals == 1 and mirrors == 1:
return True
logger.warning(
"磁盘悬挂孤儿 pair {}:成员数={}original={} mirror={}),剔除该 unit",
pair_id,
len(group),
originals,
mirrors,
)
return False
def _drop_dangling_pairs(questions: list[GeneratedQuestion]) -> list[GeneratedQuestion]:
"""剔除磁盘上收不齐 2 条 / 角色非法的悬挂孤儿 pair,告警不静默。
与 T6 inference 的 ``_drop_orphan_pairs`` 语义一致:磁盘上"只落 P 未落 Q"是
业务上合法的悬挂项(非结构损坏),warn+drop 而非 raise,使后续 build_units 只
面对合法孪生对。single 全保留。
参数:
questions: 从磁盘读回的题目列表(可混含 single 与孪生对成员)。
返回:
可安全交给 build_units 的题目列表(single 全保留,pair 仅保留合法成对者)。
"""
by_pair: dict[str, list[GeneratedQuestion]] = defaultdict(list)
singles: list[GeneratedQuestion] = []
for q in questions:
if q.pair_id:
by_pair[q.pair_id].append(q)
else:
singles.append(q)
kept_pairs = [q for pid, grp in by_pair.items() if _is_intact_pair(pid, grp) for q in grp]
return singles + kept_pairs
def write_accepted(path: Path, units: list[QuestionUnit]) -> None:
"""把 units 全量原子写到 pathtmp + os.replace),孪生对两题相邻落盘。
parent 不存在则自动创建;任一 pair 单元结构非法(size≠2)落盘前 fail-fast raise。
关键实现:沿用 ``write_final_bank`` 的原子写模式(先写同目录 ``.tmp`` 再
``os.replace`` 覆盖,保证读到的 JSON 恒完整)。序列化复用 T9 pools.py 的
``_q_to_dict``(唯一 GeneratedQuestion↔dict schema,含 pair 四字段),函数内
import 规避 app.question_gen↔app.harness 循环依赖。
"""
from app.harness.pools import _q_to_dict
from app.harness.question_units import flatten_units, validate_units
validate_units(units)
records = [_q_to_dict(q) for q in flatten_units(units)]
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(str(tmp), str(path))
logger.info("accepted 题库全量原子写: {} 单元 / {} 题 → {}", len(units), len(records), path)
def read_accepted(path: Path) -> list[QuestionUnit]:
"""读回 path 的 accepted JSON → 聚合为单元列表,剔除磁盘悬挂孤儿。
返回校验通过的单元列表(single + 合法 pair);磁盘上"只落 P 未落 Q"的悬挂孤儿
被 warn+drop,不进结果、不 raise。
关键实现:反序列化复用 T9 pools.py 的 ``_dict_to_q``pair 四字段 .get 兼容),
函数内 import 规避循环依赖。先 ``_drop_dangling_pairs`` 剔除悬挂孤儿,再
``build_units`` 聚合、``validate_units`` 二次防御闸门。
"""
from app.harness.pools import _dict_to_q
from app.harness.question_units import build_units, validate_units
raw = json.loads(path.read_text(encoding="utf-8"))
questions = [_dict_to_q(d) for d in raw]
kept = _drop_dangling_pairs(questions)
return validate_units(build_units(kept))