Files

252 lines
12 KiB
Python
Raw Permalink Normal View History

"""T08 专项离线质检:统一适配 fixture、逐任务/用途准入、冻结 checkpoint 与上下文预算。
依据:docs/v2/11-judgment-providers.md §8、docs/v2/06-validation.md §4/§6;
BR-17/18/19/20/37/38/39、E-10/13/14/15/25、AC-16/17/18/19/20/38/39/40/41。
本批**只交付离线 fixture 路径**。没有 Jev 凭据、没有 Laya 权重与授权,
因此:
- live 传输层只留注入点,未注入时明确 `LiveTransportUnavailable`;
- fixture 通过只证明适配代码结构成立,**不证明**中文专项效果合格
(06-validation.md §6:"fixture 通过仅证明适配代码结构")。
四条不可让渡的规则:
1. **逐任务逐用途准入**:`task_validations` 里没有 (task, usage) 的 passed 记录,
就不执行;服务能连通、别的任务通过、装好了权重,都不算准入(AC-38)。
2. **权重不可隐式换**:运行冻结 `checkpoint_ref`;提供方实际权重不一致即拒绝,
不按语言或负载偷偷路由到别的模型(AC-39)。
3. **超预算不外发**:超出已验证上下文预算时返回 E-25 并保留原证据范围,
绝不截断后半段照样发送(AC-40 / BR-39)。
4. **歧义不硬选**:映射未知、中文否定、条件例外、来源矛盾都落 unknown/人工复核,
不挑一个"看起来正面"的选项(AC-18)。
"""
from __future__ import annotations
import json
from pathlib import Path
from contracts.judgment import JudgmentProfile
from contracts.prescreen import JudgmentConfig
FIXTURE_ROOT = Path(__file__).resolve().parent.parent / "fixtures" / "judgment"
# 离线预算估算:中文按 1 字≈1 token、ASCII 按 4 字符≈1 token 的保守上界。
# 真实预算必须按选定模型的 tokenizer 实测填写(11 §8.1),这里的估算只用于
# 离线 fixture 与"宁可拒绝"的方向,不作为生产阈值。
_CJK_FLOOR = 0x2E80
class ContextBudgetExceeded(RuntimeError):
"""超出已验证上下文预算(E-25)。携带原证据范围,供上层保留引用。"""
def __init__(self, message: str, evidence_ids=()):
super().__init__(message)
self.evidence_ids = list(evidence_ids)
class TaskNotValidated(ValueError):
"""该任务/用途没有 passed 的验收登记(AC-38)。"""
class CheckpointMismatch(ValueError):
"""实际权重修订与运行冻结值不一致(AC-39)。"""
class EgressNotApproved(PermissionError):
"""未获批准的外部出口:不发送(AC-16/19)。"""
class LiveTransportUnavailable(RuntimeError):
"""没有可用的 live 传输层(本批未接入硬件与授权)。"""
def estimate_tokens(text: str) -> int:
"""保守估算 token 数:CJK 每字 1,其它每 4 字符 1,向上取整。"""
if not text:
return 0
cjk = sum(1 for char in text if ord(char) >= _CJK_FLOOR)
other = len(text) - cjk
return cjk + (other + 3) // 4
def load_fixture(name: str) -> dict:
return json.loads((FIXTURE_ROOT / name).read_text(encoding="utf-8"))
# ------------------------------------------------------------------ 准入
def validation_for(profile: JudgmentProfile, task: str, usage: str):
"""取出该任务与用途的登记;没有登记返回 None,不做"默认通过"。"""
for item in profile.task_validations:
if item.task == task and item.allowed_usage == usage:
return item
return None
def assert_task_allowed(profile: JudgmentProfile, task: str, usage: str) -> None:
"""逐任务逐用途准入(AC-38 / AC-41)。
offline 与 runtime 分开登记:只有 offline 通过的配置不能进 runtime;
runtime 还要求验收是独立评测——同模型自评或其生成标签不算证据。
"""
if profile.readiness != "ready":
raise TaskNotValidated("该专项配置尚未就绪,不能执行任何任务")
record = validation_for(profile, task, usage)
if record is None or record.decision != "passed":
raise TaskNotValidated(
f"该配置未在用途 {usage} 通过任务 {task} 的验收;"
"连通、安装或其它任务通过都不能替代该任务准入")
if usage == "runtime" and not record.independent:
raise TaskNotValidated("runtime 准入必须是独立评测结果,同模型自评不能作为效果证据")
def assert_checkpoint_frozen(config: JudgmentConfig, profile: JudgmentProfile) -> None:
"""运行冻结值优先:配置登记被改动后,旧运行仍按原值执行(AC-20/39)。"""
if profile.checkpoint_ref == "unresolved":
raise CheckpointMismatch("权重修订不可复现(unresolved),不得用于任何执行")
if config.checkpoint_ref and config.checkpoint_ref != profile.checkpoint_ref:
raise CheckpointMismatch(
"登记配置的权重修订与本次运行冻结值不一致;请新建运行而不是就地替换")
def assert_within_budget(question: dict, profile: JudgmentProfile) -> None:
"""调用前算预算;超限即拒绝,不截断、不外发(AC-40)。
正文(含提示词)与选项分开计:选项预算不能被正文的余量顶掉,反之亦然。
"""
body = "".join(str(item) for item in question.get("evidence_text") or ())
prompt = str(question.get("prompt") or "")
options = " ".join(str(item) for item in question.get("options") or ())
evidence_ids = question.get("evidence_ids") or ()
body_tokens = estimate_tokens(body) + estimate_tokens(prompt)
if body_tokens > profile.input_token_limit:
raise ContextBudgetExceeded(
f"正文约 {body_tokens} token,超出该模型已验证输入预算 "
f"{profile.input_token_limit};请缩小问题或转人工复核",
evidence_ids=evidence_ids)
option_tokens = estimate_tokens(options)
if option_tokens > profile.option_token_limit:
raise ContextBudgetExceeded(
f"选项约 {option_tokens} token,超出该模型已验证选项预算 "
f"{profile.option_token_limit};请缩减候选项或转人工复核",
evidence_ids=evidence_ids)
# ------------------------------------------------------------------ 映射
def _unknown(reason: str) -> dict:
return {"kind": "unknown", "reason_code": reason}
def map_local_answer(raw: dict, question: dict, profile: JudgmentProfile) -> dict:
"""本地模型原始输出 → 统一契约形状。
歧义一律落 unknown/inconclusive:映射未知、中文否定、条件例外、来源矛盾
都不许"挑一个正面结果"(AC-18)。`confidence` 只作为 self_reported 指标
保留原语义,**不**换算成任何概率。
"""
mapping = (raw.get("mapping") or {})
if raw.get("conflicts"):
return _unknown("inconclusive")
if raw.get("exception_applies"):
return _unknown("inconclusive")
label = raw.get("label")
if not label:
return _unknown("missing_evidence" if not raw.get("evidence_ids") else "inconclusive")
option = mapping.get(label)
if option is None:
# 例如中文否定表述("不符合")未登记映射:不得当成正面选项使用
return _unknown("inconclusive")
metrics = []
if raw.get("confidence") is not None:
metrics.append({"name": "self_confidence", "value": float(raw["confidence"]),
"semantics": "模型自报置信度,未经校准,与审批概率无关",
"source": "self_reported"})
return {"kind": question["type"], "value": option, "native_metrics": metrics,
"actual_checkpoint_ref": raw.get("actual_checkpoint_ref")}
# jev 厂商字段名与本地完全不同:映射表按 mapping_version 固定,主审不读厂商字段
# (AC-17)。新增厂商版本要新增一行映射并单独登记验收,不改这一层的行为。
# 本行只登记 choice 题型的映射:其它题型没有登记就落 unknown,而不是借这一行
# 顺带支持(06-validation.md §6:"阈值和映射按提供方/模型/题型独立登记")。
_DEV_VENDOR_CHOICES = {
"APPROVE": "supported",
"REJECT": "unsupported",
# COND_APPROVE(附条件)刻意**不**映射为通过:附条件不等于通过(BR-21)
}
def map_jev_answer(raw: dict, question: dict, profile: JudgmentProfile) -> dict:
"""Jev 厂商响应 → 统一契约形状(fixture 验证用,不发送任何请求)。"""
if question["type"] != "choice":
return _unknown("inconclusive")
envelope = raw.get("result") or {}
code = envelope.get("decision_code")
value = _DEV_VENDOR_CHOICES.get(code)
if value is None:
return _unknown("inconclusive")
metrics = []
for name, item in (raw.get("metrics") or {}).items():
# 厂商原生指标保留语义与来源;不跨提供方套阈值,也不补齐缺失概率(AC-18)
metrics.append({"name": str(name), "value": float(item),
"semantics": "厂商原生指标,定义与兴邦校准口径不一致",
"source": "provider_native"})
return {"kind": question["type"], "value": value, "native_metrics": metrics,
"actual_checkpoint_ref": raw.get("checkpoint")}
MAPPERS = {"local": map_local_answer, "jev": map_jev_answer}
# ------------------------------------------------------------------ 适配入口
def build_provider_call(config: JudgmentConfig, profile: JudgmentProfile, task: str,
usage: str, *, fixture=None, transport=None, call_log=None):
"""构造 `judge()` 用的 provider_call:先过准入与冻结,再决定是否真的发送。
`fixture(question) -> raw` 是离线路径(不联网);`transport(target, payload)`
是 live 路径。两者不能互相冒充:call_log 会记录 mode,测试据此断言
"local 运行期间没有任何 jev 请求"(AC-17)。
"""
assert_task_allowed(profile, task, usage)
assert_checkpoint_frozen(config, profile)
mapper = MAPPERS[profile.provider]
def call(config: JudgmentConfig, question: dict) -> dict:
# 预算在发送之前判定:超限即结束,调用记录里不会出现任何发送
assert_within_budget(question, profile)
payload = json.dumps({
"question_id": question["id"], "version": question["version"],
"type": question["type"], "prompt": question.get("prompt", ""),
"options": list(question.get("options") or ()),
"evidence_ids": list(question.get("evidence_ids") or ()),
"checkpoint_ref": config.checkpoint_ref,
"prompt_version": config.prompt_version,
"mapping_version": config.mapping_version,
}, ensure_ascii=False)
if fixture is not None:
raw = fixture(question)
mode = "fixture"
else:
if config.provider == "jev" and not profile.egress_approved:
raise EgressNotApproved("该外部出口未获批准,已拒绝发送")
if transport is None:
raise LiveTransportUnavailable(
"本批未接入 live 传输层(缺硬件与授权),只能走离线 fixture")
raw = transport(profile.provider, payload)
mode = "live"
if call_log is not None:
call_log.append({"target": config.provider, "mode": mode,
"question_id": question["id"], "payload": payload})
adapted = mapper(raw, question, profile)
actual = adapted.get("actual_checkpoint_ref")
if actual and actual != config.checkpoint_ref:
raise CheckpointMismatch(
f"提供方实际权重 {actual} 与运行冻结值 {config.checkpoint_ref} 不一致")
return adapted
return call