Files
iomgaa 3cc8dc9105 refactor: assert warn sink, rename disk-pair sifter, trim WHAT docstrings
Capture loguru warning via project sink pattern and assert the dangling-orphan
warning is emitted; rename _sift_disk_pairs to _keep_complete_disk_pairs; drop
pure-WHAT docstrings on __init__/pending_orphans while keeping WHY notes.
2026-07-15 09:42:27 -04:00

197 lines
9.2 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.
"""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 索引首个到达的孪生对成员,等伙伴到齐再 emit。"""
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。
关键实现:配齐后**先校验、成功组装出 unit 才从 pending 删除**——若
``_check_pair_binding`` / ``_order_pair`` raise,首成员仍留在 pending,调用方
``pending_orphans`` 可取回被拒的悬挂成员。绑定校验用显式 ValueError(不依赖
会被 ``-O`` 剥除的 assert)。
"""
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
_check_pair_binding(partner, q)
original, mirror = _order_pair(partner, q)
unit = QuestionUnit.from_pair(original, mirror)
del self._pending[q.pair_id]
return unit
def pending_orphans(self) -> list[GeneratedQuestion]:
"""返回仍未配齐的悬挂成员,供调用方在批次末尾检测"只落 P 未落 Q"。"""
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 _validate_disk_pair(pair_id: str, group: list[GeneratedQuestion]) -> list[GeneratedQuestion]:
"""三态判定磁盘上同 pair_id 分组,区分悬挂孤儿(drop)与结构损坏(raise)。
- 恰好 1 original + 1 mirror:合法孪生对,额外做 ``_check_pair_binding`` 显式绑定
校验(video_id/task_type/flip_axis-O 下仍生效),返回其两题。
- size==1(只落 P 未落 Q):业务上合法的悬挂孤儿,warn + drop,返回 []。
- 其余(size>2 超员、或 size==2 角色重复/缺角色):数据损坏/外部篡改,按 P5
fail-loudraise ValueError(含 pair_id 与成员构成),绝不静默吞。
"""
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:
_check_pair_binding(group[0], group[1])
return group
if len(group) == 1:
logger.warning("磁盘悬挂孤儿 pair {}:仅 1 成员(缺伙伴),warn+drop 该 unit", pair_id)
return []
raise ValueError(
f"磁盘 pair {pair_id} 结构损坏:成员数={len(group)}"
f"original={originals} mirror={mirrors}),需恰好 1 original + 1 mirror"
)
def _keep_complete_disk_pairs(questions: list[GeneratedQuestion]) -> list[GeneratedQuestion]:
"""筛选磁盘读回的题目:single 全保留、pair 按 ``_validate_disk_pair`` 三态处理。
悬挂孤儿 warn+drop、结构损坏/绑定不一致 raise ValueError、合法成对保留后交给
build_unitssingle 全保留)。
"""
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() for q in _validate_disk_pair(pid, 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 → 聚合为单元列表。
磁盘是外部输入,按 P5 全量校验后再用:``_keep_complete_disk_pairs`` 对 pair 分组三态处理
——"只落 P 未落 Q"的悬挂孤儿 warn+drop(不进结果、不 raise);结构损坏(超员/
角色重复)或绑定不一致(video_id/task_type/flip_axis)显式 raise ValueError(不
依赖 build_units 内会被 ``-O`` 剥除的 assert)。single 全保留。
关键实现:反序列化复用 T9 pools.py 的 ``_dict_to_q``pair 四字段 .get 兼容),
函数内 import 规避循环依赖。sift 后 ``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 = _keep_complete_disk_pairs(questions)
return validate_units(build_units(kept))