Files
PolyLoop/tools/soak/scoreboard.py
T
iomgaa a641a1fc11 fix(soak): 合成观察那两档不该逐字比对,判据写错了
193 次真实运行报了 9 处击穿,核下来是判据的错不是库的错:动作被拒绝或环境故障时,库
刻意不把执行器给的观察回填进历史,而是换成合成观察那一段。两个字段承载的本来就不是同
一件事——一个是执行器原文的留痕,一个是真正喂给模型的文本。

改判据时顺着源码发现被替换的是三列不是一列:观察文本、是否合成、截断字符数在那两档下
全部由库填。所以旧判据在环境故障那一档上必然也会误报,只是这 193 次里没撞上真的环境
故障;截断数那一列则是恰好两边都是 0,潜伏着没炸。

现在 executed 档三列仍然逐字比对,另两档改成断言步记录的「是否合成」标记确实立起来了——
库既然替换了观察,不立这个标记才是真出了问题。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:35:25 -04:00

1702 lines
64 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.
"""压测记分板:把一批压测产物判成「通过 / 击穿 / 无法判定」,再渲染成一份 markdown 报告。
它读的是一次批次跑完之后留在磁盘上的四种文件,每个 run 一组:
<runs_dir>/<run_id>.jsonl polyloop 的 JsonlRunStore 写的日志,一行一条记录
<runs_dir>/<run_id>.result.json 驱动写的 polyloop.serialization.encode(RunResult)
<runs_dir>/<run_id>.events.jsonl 事件出口写的,一条事件一行
<runs_dir>/<run_id>.meta.json 驱动写的运行元信息
`.jsonl` 一定在(run 是按它枚举出来的),另外三个可能缺——进程被 SIGKILL 时来不及写。
**缺文件本身是一条要报告的观察,不是崩溃理由,也不是「通过」。**
## 三档判定,不是两档
一条不变量的判定有三个取值:通过、击穿、无法判定。第三档独立存在,不许折算成前两档中的
任何一个。缺文件、缺字段导致判不了,和判过了是两回事——压成一档的话,一次什么都没验成的
跑会显示成全绿,而那正是最需要被看见的情况。数据结构上这条是硬的:`InvariantResult` 分开存
`breaches` 与 `undetermined` 两串证据,判定由它们算出来,没有一个可以手填的「通过」。
击穿必须带具体证据(哪个 run、哪一行 / 哪一步、期望什么实际什么),这条由 `Evidence` 的
构造期校验守住。一个只会说「有问题」的判定器等于没有判定器。
## 报告里不出现任何模型原文与文档片段
压测语料里有第三方的真实文档,而报告是要贴给人看的。所以报告里只出现计数、枚举取值、
run_id、行号与步号,别的一律只报长度不报内容——**工具名与 call_id 也算别的**:一次没通过
校验的工具调用会把模型编的那串原样记进 `tool_name`,而那串可以是任何东西。
只有 run_id、场景名、故障名这三样字符串会原样出现,它们过一遍 `_safe_id`(只留字母数字与
`_.-`,其余换成 `?`,再截长)。剩下唯一一处外来文本是解码失败时库抛的异常消息,它过
`_scrub` 压成一行并截到 160 字——那些消息是库自己写的,最多引一个枚举取值或一个快照键名,
引不到观察或模型原文(`polyloop.serialization` 的取值函数对字符串字段只报类型不报内容)。
## 不用 JsonlRunStore.read_log 读日志
那个方法读回来的是一份 `RunLog`,没有行号,也在撞见第一处损坏时就整份抛出。记分板要的
恰好相反:逐行读、每行单独判、坏了也接着读完,因为「第几行坏了、后面还有没有记录」正是
要报告的东西。撕裂尾行的判据照抄存储那边:**看有没有被换行终结,不看能不能解析**。
"""
import argparse
import json
import math
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from polyloop.serialization import (
DecodeError,
decode_intent,
decode_model_call_result,
decode_run_finished,
decode_run_result,
decode_run_started,
decode_step_completed,
)
from polyloop.stores import RECORD_KEY
from polyloop.types import (
ActionStatus,
Intent,
IntentKind,
ModelCallResult,
RunFinished,
RunResult,
RunStarted,
StepCompleted,
StopReason,
)
LOG_SUFFIX = ".jsonl"
RESULT_SUFFIX = ".result.json"
EVENTS_SUFFIX = ".events.jsonl"
META_SUFFIX = ".meta.json"
#: 步数上限在参数快照里的键名。快照由 `RunRequest.parameter_snapshot()` 拼出来,
#: 值是十进制字符串。
MAX_STEPS_SNAPSHOT_KEY = "request.max_steps"
#: 单个 run 单条不变量最多列几条证据。超出的部分折成一条「还有 N 条」。
#: 一份四十步的坏日志能刷出四十行同样的证据,那种报告没人会读到底。
MAX_EVIDENCE_PER_RUN = 5
#: 日志里每种标签对应的解码器。标签取值与 `polyloop.stores` 那边一致。
_DECODERS: Mapping[str, Callable[[Mapping[str, object]], object]] = {
"run_started": decode_run_started,
"intent": decode_intent,
"model_call_result": decode_model_call_result,
"step_completed": decode_step_completed,
"run_finished": decode_run_finished,
}
#: 步记录里内容不进报告的字段,只报长度。
#:
#: `tool_name` 与 `call_id` 也在里面:它们看着像标识,可最终仍然来自模型输出——一次没通过
#: 校验的工具调用会把模型编的那串原样记进 `tool_name`,而那串可以是任何东西。
_STEP_OPAQUE_FIELDS = frozenset(
{
"raw_output",
"action",
"parse_error",
"observation",
"tool_arguments",
"tool_name",
"call_id",
}
)
_STEP_FIELDS = (
"step_idx",
"raw_output",
"content_chars",
"thinking_chars",
"action",
"parse_ok",
"parse_error",
"observation",
"observation_is_synthetic",
"observation_truncated_chars",
"prompt_chars",
"call_id",
"step_wall_ms",
"tool_name",
"tool_arguments",
"action_status",
"env_reported_completion",
"schema_version",
)
_SAFE_NAME_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-")
# ---------------------------------------------------------------------------
# 判定与证据
# ---------------------------------------------------------------------------
class Verdict(StrEnum):
"""一条不变量在这一批 run 上的判定。"""
PASSED = "passed"
BREACHED = "breached"
UNDETERMINED = "undetermined"
_VERDICT_LABELS: Mapping[Verdict, str] = {
Verdict.PASSED: "通过",
Verdict.BREACHED: "击穿",
Verdict.UNDETERMINED: "无法判定",
}
@dataclass(frozen=True, slots=True, kw_only=True)
class Evidence:
"""一条击穿证据,或者一条「为什么判不了」。
击穿必须同时写 `expected` 与 `actual`,判不了必须写 `reason`——两者都没有的证据在构造期
就报错。理由见模块 docstring:只说「有问题」的判定器等于没有判定器,而这条限制让那种
证据根本构造不出来。
"""
run_id: str
line_no: int | None = None
step_idx: int | None = None
expected: str | None = None
actual: str | None = None
reason: str | None = None
def __post_init__(self) -> None:
if self.reason is None and (self.expected is None or self.actual is None):
raise ValueError(
"一条证据要么写清楚期望与实际(击穿),要么写清楚为什么判不了(无法判定)"
)
def describe(self) -> str:
"""渲染成一行。报告里的证据都长这样。"""
where = f"run `{self.run_id}`"
if self.line_no is not None:
where += f" 第 {self.line_no} 行"
if self.step_idx is not None:
where += f" 第 {self.step_idx} 步"
if self.expected is None or self.actual is None:
return f"{where}{self.reason}"
body = f"期望 {self.expected},实际 {self.actual}"
if self.reason is not None:
body += f"{self.reason}"
return f"{where}{body}"
@dataclass(frozen=True, slots=True, kw_only=True)
class CheckOutcome:
"""一条不变量在**一个** run 上的检查结果。"""
breaches: tuple[Evidence, ...] = ()
undetermined: tuple[Evidence, ...] = ()
@dataclass(frozen=True, slots=True, kw_only=True)
class InvariantResult:
"""一条不变量在**整批** run 上的判定。
判定不是一个字段,是 `verdict` 算出来的:有击穿就是击穿,否则有判不了的就是判不了,
两串都空才是通过。没有一条路径能把「判不了」写成「通过」。
"""
name: str
description: str
breaches: tuple[Evidence, ...] = ()
undetermined: tuple[Evidence, ...] = ()
def __post_init__(self) -> None:
for item in self.breaches:
if item.expected is None or item.actual is None:
raise ValueError(f"不变量 {self.name!r} 的击穿证据缺期望或实际值")
@property
def verdict(self) -> Verdict:
if self.breaches:
return Verdict.BREACHED
if self.undetermined:
return Verdict.UNDETERMINED
return Verdict.PASSED
# ---------------------------------------------------------------------------
# 读盘
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True, kw_only=True)
class LoadedLine:
"""日志里被换行终结的一行。
解不开的行也在这里,`record` 为空、`failure` 写着为什么——记分板要接着往下读,
「后面还有没有记录」本身就是要报告的东西。
"""
number: int
tag: str
payload: Mapping[str, object]
record: object | None = None
failure: str | None = None
@dataclass(frozen=True, slots=True, kw_only=True)
class RunFacts:
"""一个 run 在磁盘上留下的全部东西,读进内存之后的样子。
四个 `*_error` 字段区分「文件缺了」与「文件在但读不了」——两者都让相关不变量判不了,
但要报告给人的话不是一回事。
"""
run_id: str
lines: tuple[LoadedLine, ...] = ()
torn_tail: bool = False
log_error: str | None = None
result: RunResult | None = None
result_error: str | None = None
#: 每条事件的 run_id,缺键或不是字符串时那一项为空。整个文件缺失时它是 None。
event_run_ids: tuple[str | None, ...] | None = None
events_torn_tail: bool = False
events_error: str | None = None
meta: Mapping[str, object] | None = None
meta_error: str | None = None
def records(self) -> tuple[object, ...]:
return tuple(line.record for line in self.lines if line.record is not None)
def failures(self) -> tuple[LoadedLine, ...]:
return tuple(line for line in self.lines if line.failure is not None)
def payloads_tagged(self, tag: str) -> tuple[LoadedLine, ...]:
return tuple(line for line in self.lines if line.tag == tag)
def run_started(self) -> RunStarted | None:
found = [item for item in self.records() if isinstance(item, RunStarted)]
return found[-1] if found else None
def run_finished(self) -> RunFinished | None:
found = [item for item in self.records() if isinstance(item, RunFinished)]
return found[-1] if found else None
def intents(self) -> tuple[tuple[int, Intent], ...]:
return tuple(
(line.number, line.record) for line in self.lines if isinstance(line.record, Intent)
)
def model_results(self) -> tuple[ModelCallResult, ...]:
return tuple(item for item in self.records() if isinstance(item, ModelCallResult))
def steps(self) -> tuple[tuple[int, StepCompleted], ...]:
return tuple(
(line.number, line.record)
for line in self.lines
if isinstance(line.record, StepCompleted)
)
def effective_result(self) -> RunResult | None:
"""判停止原因用哪一份结果。
先用日志里 `run_finished` 内嵌的那份:取消那条路径上 `session.run` 原样重抛
`CancelledError`、根本不返回结果,于是驱动写不出 `.result.json`,而日志里的结束
记录仍然在。两份都在时它们必须相等,那是不变量二在管的事。
"""
finished = self.run_finished()
if finished is not None:
return finished.result
return self.result
def _read_bytes(path: Path) -> tuple[bytes | None, str | None]:
if not path.exists():
return None, f"没有 {path.name}"
try:
return path.read_bytes(), None
except OSError as exc:
return None, f"读 {path.name} 失败:{_scrub(str(exc))}"
def _split_terminated(raw: bytes) -> tuple[list[tuple[int, bytes]], bool]:
"""按存储那边的规矩切行:判据是有没有被换行终结,不是能不能解析。
末尾那段没有换行的字节对应的那次写从来没有被确认过,按契约它就是没发生。返回值第二项
说的就是「有没有这么一段」——它是进程被杀在写入中途的证据,要报告出来。
行号按原始文件的行数走,空行也占一号,这样报出来的号和 `sed -n 'Np'` 对得上。
"""
chunks = raw.split(b"\n")
torn = bool(chunks) and bool(chunks[-1].strip())
terminated = chunks[:-1] if torn else chunks
numbered = [
(number, chunk) for number, chunk in enumerate(terminated, start=1) if chunk.strip()
]
return numbered, torn
def _load_log(path: Path) -> tuple[tuple[LoadedLine, ...], bool, str | None]:
raw, error = _read_bytes(path)
if raw is None:
return (), False, error
numbered, torn = _split_terminated(raw)
return tuple(_load_line(number, chunk) for number, chunk in numbered), torn, None
def _load_line(number: int, chunk: bytes) -> LoadedLine:
try:
payload = json.loads(chunk)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
return LoadedLine(
number=number,
tag="",
payload={},
failure=f"读不了({type(exc).__name__}",
)
if not isinstance(payload, dict):
return LoadedLine(number=number, tag="", payload={}, failure="不是一个 JSON 对象")
if RECORD_KEY not in payload:
return LoadedLine(
number=number, tag="", payload=payload, failure=f"没有 {RECORD_KEY!r} 标签"
)
tag = payload[RECORD_KEY]
if not isinstance(tag, str) or tag not in _DECODERS:
return LoadedLine(
number=number,
tag="",
payload=payload,
failure=f"记录类型认不得(标签长度 {len(str(tag))}",
)
try:
record = _DECODERS[tag](payload)
except (DecodeError, ValueError, TypeError) as exc:
return LoadedLine(
number=number,
tag=tag,
payload=payload,
failure=f"{tag} 解不出来:{_scrub(str(exc))}",
)
return LoadedLine(number=number, tag=tag, payload=payload, record=record)
def _load_result(path: Path) -> tuple[RunResult | None, str | None]:
raw, error = _read_bytes(path)
if raw is None:
return None, error
try:
payload = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
return None, f"{path.name} 不是合法 JSON{type(exc).__name__}"
if not isinstance(payload, dict):
return None, f"{path.name} 不是一个 JSON 对象"
try:
return decode_run_result(payload), None
except (DecodeError, ValueError, TypeError) as exc:
return None, f"{path.name} 解不出 RunResult{_scrub(str(exc))}"
def _load_events(path: Path) -> tuple[tuple[str | None, ...] | None, bool, str | None]:
raw, error = _read_bytes(path)
if raw is None:
return None, False, error
numbered, torn = _split_terminated(raw)
run_ids: list[str | None] = []
for index, chunk in numbered:
try:
payload = json.loads(chunk)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
return None, torn, f"{path.name}{index} 行不是合法 JSON{type(exc).__name__}"
if not isinstance(payload, dict):
return None, torn, f"{path.name}{index} 行不是一个 JSON 对象"
value = payload.get("run_id")
run_ids.append(value if isinstance(value, str) else None)
return tuple(run_ids), torn, None
def _load_meta(path: Path) -> tuple[Mapping[str, object] | None, str | None]:
raw, error = _read_bytes(path)
if raw is None:
return None, error
try:
payload = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
return None, f"{path.name} 不是合法 JSON{type(exc).__name__}"
if not isinstance(payload, dict):
return None, f"{path.name} 不是一个 JSON 对象"
return payload, None
def list_run_ids(runs_dir: Path) -> tuple[str, ...]:
"""枚举这个目录里的 run。
`.events.jsonl` 也以 `.jsonl` 结尾,所以要显式排掉——不排的话每个 run 会被数成两个,
其中一个的「日志」是一串事件,然后整批报告都是错的。
"""
ids = [
path.name[: -len(LOG_SUFFIX)]
for path in runs_dir.glob(f"*{LOG_SUFFIX}")
if not path.name.endswith(EVENTS_SUFFIX)
]
return tuple(sorted(ids))
def load_run(runs_dir: Path, run_id: str) -> RunFacts:
"""把一个 run 的四个文件读进内存。缺文件不抛异常,记在对应的 `*_error` 上。"""
lines, torn, log_error = _load_log(runs_dir / f"{run_id}{LOG_SUFFIX}")
result, result_error = _load_result(runs_dir / f"{run_id}{RESULT_SUFFIX}")
event_run_ids, events_torn, events_error = _load_events(runs_dir / f"{run_id}{EVENTS_SUFFIX}")
meta, meta_error = _load_meta(runs_dir / f"{run_id}{META_SUFFIX}")
return RunFacts(
run_id=run_id,
lines=lines,
torn_tail=torn,
log_error=log_error,
result=result,
result_error=result_error,
event_run_ids=event_run_ids,
events_torn_tail=events_torn,
events_error=events_error,
meta=meta,
meta_error=meta_error,
)
# ---------------------------------------------------------------------------
# 文本消毒:报告里只出现计数、枚举取值、run_id、行号与步号
# ---------------------------------------------------------------------------
def _scrub(text: str, *, limit: int = 160) -> str:
"""把一段来自外部的文本压成一行、截到上限。
它管的是异常消息这类「本来就该短、但来源不完全可控」的串。真正的文本字段不走这里,
它们根本不进报告——见模块 docstring。
"""
flat = " ".join(text.split())
if len(flat) <= limit:
return flat
return flat[:limit] + "…"
def _safe_id(name: str, *, limit: int = 64) -> str:
"""把一个标识压成安全形态:只留字母数字与 `_.-`,其余换成 `?`,再截长。
只有 run_id、场景名、故障名走这里。它们由驱动写,本来就该长这样;这一层是防着一份被
改过的产物把别的东西塞进这几个位置。
"""
cleaned = "".join(char if char in _SAFE_NAME_CHARS else "?" for char in name)
if len(cleaned) <= limit:
return cleaned
return cleaned[:limit] + "…"
def _cell(text: str) -> str:
"""markdown 表格单元格:竖线要转义,换行要压掉,否则整张表塌了。"""
return text.replace("|", "\\|").replace("\n", " ")
# ---------------------------------------------------------------------------
# 逐个 run 的不变量
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True, kw_only=True)
class CheckConfig:
"""判定时需要、但记分板自己定不了的东西。"""
#: 带完成标记(`ToolSpec.completes_run`)的工具名。**由调用方传进来,不硬编码**——
#: 哪些工具算完成通路是场景的事实,不是记分板的。传 None 表示没给,于是不变量十
#: 在只剩这条路可走时报「无法判定」而不是编一个答案。
completing_tools: frozenset[str] | None = None
def _cap(items: Sequence[Evidence], run_id: str) -> list[Evidence]:
if len(items) <= MAX_EVIDENCE_PER_RUN:
return list(items)
kept = list(items[:MAX_EVIDENCE_PER_RUN])
kept.append(
Evidence(
run_id=run_id,
expected="同类证据全部列出",
actual=f"还有 {len(items) - MAX_EVIDENCE_PER_RUN} 条未列出",
)
)
return kept
def _check_log_readable(facts: RunFacts, config: CheckConfig) -> CheckOutcome:
del config
if facts.log_error is not None:
return CheckOutcome(undetermined=(Evidence(run_id=facts.run_id, reason=facts.log_error),))
breaches = [
Evidence(
run_id=facts.run_id,
line_no=line.number,
expected="这一行被换行终结,所以它必须解得开",
actual=line.failure or "",
)
for line in facts.failures()
]
return CheckOutcome(breaches=tuple(_cap(breaches, facts.run_id)))
def _diff_run_results(left: RunResult, right: RunResult) -> list[str]:
"""逐字段比两份运行结果,返回人能读的差异描述。
文本字段只报长度:`final_answer` 与步记录里那几个装文本的字段可能整段是模型原文或
环境返回的文档,它们不许进报告。
"""
diffs: list[str] = []
if left.run_id != right.run_id:
diffs.append(f"run_id{_safe_id(left.run_id)} vs {_safe_id(right.run_id)}")
if left.stop_reason != right.stop_reason:
diffs.append(f"stop_reason{left.stop_reason.value} vs {right.stop_reason.value}")
if left.final_answer != right.final_answer:
diffs.append(
f"final_answer 文本不同(长度 {_text_len(left.final_answer)} vs "
f"{_text_len(right.final_answer)}"
)
if left.schema_version != right.schema_version:
diffs.append(f"schema_version{left.schema_version} vs {right.schema_version}")
if left.event_delivery_failures != right.event_delivery_failures:
diffs.append(
f"event_delivery_failures{left.event_delivery_failures} vs "
f"{right.event_delivery_failures}"
)
if len(left.steps) != len(right.steps):
diffs.append(f"steps 条数:{len(left.steps)} vs {len(right.steps)}")
return diffs
for index, (one, other) in enumerate(zip(left.steps, right.steps, strict=True)):
for name in _STEP_FIELDS:
first = getattr(one, name)
second = getattr(other, name)
if first == second:
continue
if name in _STEP_OPAQUE_FIELDS:
diffs.append(
f"steps[{index}].{name} 文本不同"
f"(长度 {_text_len(first)} vs {_text_len(second)}"
)
else:
diffs.append(f"steps[{index}].{name}{first!r} vs {second!r}")
return diffs
def _text_len(value: str | None) -> str:
return "空" if value is None else str(len(value))
def _check_result_matches_log(facts: RunFacts, config: CheckConfig) -> CheckOutcome:
del config
finished = facts.run_finished()
if facts.result is None:
return CheckOutcome(
undetermined=(
Evidence(
run_id=facts.run_id,
reason=facts.result_error or f"没有 {RESULT_SUFFIX}",
),
)
)
if finished is None:
return CheckOutcome(
undetermined=(Evidence(run_id=facts.run_id, reason="日志里没有 run_finished 记录"),)
)
if facts.result == finished.result:
return CheckOutcome()
diffs = _diff_run_results(facts.result, finished.result)
breaches = [
Evidence(
run_id=facts.run_id,
expected="跨进程读回来的 RunResult 与日志里那份逐字段相等",
actual=diff,
)
for diff in diffs
]
if not breaches:
breaches = [
Evidence(
run_id=facts.run_id,
expected="跨进程读回来的 RunResult 与日志里那份相等",
actual="两份不相等,但逐字段比不出差异",
)
]
return CheckOutcome(breaches=tuple(_cap(breaches, facts.run_id)))
def _check_step_indices(facts: RunFacts, config: CheckConfig) -> CheckOutcome:
del config
breaches: list[Evidence] = []
for position, (number, step) in enumerate(facts.steps()):
if step.step.step_idx != position:
breaches.append(
Evidence(
run_id=facts.run_id,
line_no=number,
expected=f"step_idx = {position}",
actual=f"step_idx = {step.step.step_idx}",
)
)
return CheckOutcome(breaches=tuple(_cap(breaches, facts.run_id)))
def _check_intents_resolved(facts: RunFacts, config: CheckConfig) -> CheckOutcome:
del config
model_ids = {item.result_id for item in facts.model_results()}
action_ids = {step.result_id for _, step in facts.steps() if step.result_id is not None}
intents = facts.intents()
dangling: list[tuple[int, int, Intent]] = []
for position, (number, intent) in enumerate(intents):
resolved = (
intent.result_id in model_ids
if intent.kind is IntentKind.MODEL_CALL
else intent.result_id in action_ids
)
if not resolved:
dangling.append((position, number, intent))
if not dangling:
return CheckOutcome()
last_position = len(intents) - 1
if len(dangling) == 1 and dangling[0][0] == last_position:
# 这正是崩溃点:进程写了意图、还没来得及写结果。恢复要处理的就是它。
return CheckOutcome()
breaches = [
Evidence(
run_id=facts.run_id,
line_no=number,
expected=(
f"{intent.kind.value} 意图的 result_id 有归宿,或者它是日志里最后一条意图(崩溃点)"
),
actual=(
f"悬空,且日志里共有 {len(dangling)} 条悬空意图,"
f"它在第 {position + 1} / {len(intents)} 条"
),
)
for position, number, intent in dangling
]
return CheckOutcome(breaches=tuple(_cap(breaches, facts.run_id)))
def _check_step_pairing(facts: RunFacts, config: CheckConfig) -> CheckOutcome:
"""`result_id` 为空当且仅当 `action_outcome` 为空。
判据落在**原始载荷**上,不在解出来的记录上:`StepCompleted` 自己在构造期就守这条,
所以一条违反它的载荷根本解不出记录——那样这条不变量就永远只能是通过,成了摆设。
违反它的行同时会被不变量一报出来(它确实读不回来),两条说的是同一处损坏的两个侧面。
"""
del config
breaches: list[Evidence] = []
blocked: list[Evidence] = []
for line in facts.payloads_tagged("step_completed"):
payload = line.payload
if "result_id" not in payload or "action_outcome" not in payload:
blocked.append(
Evidence(
run_id=facts.run_id,
line_no=line.number,
reason="载荷缺 result_id 或 action_outcome,这条判不了",
)
)
continue
has_id = payload["result_id"] is not None
has_outcome = payload["action_outcome"] is not None
if has_id != has_outcome:
breaches.append(
Evidence(
run_id=facts.run_id,
line_no=line.number,
expected="result_id 与 action_outcome 同时为空或同时有值",
actual=f"result_id 有值={has_id}action_outcome 有值={has_outcome}",
)
)
return CheckOutcome(
breaches=tuple(_cap(breaches, facts.run_id)),
undetermined=tuple(_cap(blocked, facts.run_id)),
)
def _check_outcome_agrees_with_step(facts: RunFacts, config: CheckConfig) -> CheckOutcome:
"""同一条 `step_completed` 里,动作结果与步记录说的必须是同一件事。
两者一次原子落地,所以它们不可能来自两次不同的执行。对不上说明装配那一层把某一侧
填错了,而这种错在轨迹里完全看不出来——两个字段各自都合法。
**观察那三列只在 `executed` 一档上是原样透传,另外两档不是**,判据必须跟着分档,
理由写在 `Invariant` 的说明里(那段会进报告)。这条一开始按「三档都逐字相同」写,
在 193 次真实运行上报了 9 处击穿,核下来全是判据错、不是库错。
"""
del config
breaches: list[Evidence] = []
for number, record in facts.steps():
outcome = record.action_outcome
if outcome is None:
if record.step.action_status is not None:
breaches.append(
Evidence(
run_id=facts.run_id,
line_no=number,
step_idx=record.step.step_idx,
expected="没有动作结果时步记录的 action_status 为空",
actual=f"action_status = {record.step.action_status.value}",
)
)
continue
passthrough = outcome.status is ActionStatus.EXECUTED
pairs = [
("action_status", outcome.status, record.step.action_status),
(
"env_reported_completion",
outcome.env_reported_completion,
record.step.env_reported_completion,
),
]
if passthrough:
# 只有这一档,步记录上的观察三列是执行器返回值的原样透传。
pairs.append(
(
"observation_is_synthetic",
outcome.observation_is_synthetic,
record.step.observation_is_synthetic,
)
)
pairs.append(
(
"observation_truncated_chars",
outcome.observation_truncated_chars,
record.step.observation_truncated_chars,
)
)
for name, from_outcome, from_step in pairs:
if from_outcome != from_step:
breaches.append(
Evidence(
run_id=facts.run_id,
line_no=number,
step_idx=record.step.step_idx,
expected=f"步记录的 {name} 等于动作结果的同名字段",
actual=f"动作结果 {from_outcome!r},步记录 {from_step!r}",
)
)
if passthrough:
if outcome.observation != record.step.observation:
breaches.append(
Evidence(
run_id=facts.run_id,
line_no=number,
step_idx=record.step.step_idx,
expected="executed 档下步记录的 observation 与动作结果的同名字段逐字相同",
actual=(
f"文本不同(长度 {len(outcome.observation)} vs "
f"{len(record.step.observation)}"
),
)
)
elif not record.step.observation_is_synthetic:
breaches.append(
Evidence(
run_id=facts.run_id,
line_no=number,
step_idx=record.step.step_idx,
expected=(
f"{outcome.status.value} 档下库替换了回填进历史的观察,"
"所以步记录的 observation_is_synthetic 为 True"
),
actual="observation_is_synthetic = False,那段观察没有被标成合成的",
)
)
return CheckOutcome(breaches=tuple(_cap(breaches, facts.run_id)))
def _check_prompt_chars_monotonic(facts: RunFacts, config: CheckConfig) -> CheckOutcome:
del config
breaches: list[Evidence] = []
previous: int | None = None
for number, record in facts.steps():
current = record.step.prompt_chars
if previous is not None and current < previous:
breaches.append(
Evidence(
run_id=facts.run_id,
line_no=number,
step_idx=record.step.step_idx,
expected=f"prompt_chars ≥ 上一步的 {previous}",
actual=f"prompt_chars = {current}",
)
)
previous = current
return CheckOutcome(breaches=tuple(_cap(breaches, facts.run_id)))
class _MetaError(Exception):
"""`.meta.json` 缺字段或字段类型不对。它让相关不变量判不了,不让它们击穿。"""
def _meta_field(meta: Mapping[str, object] | None, key: str) -> object:
if meta is None:
raise _MetaError(f"没有 {META_SUFFIX}")
if key not in meta:
raise _MetaError(f"{META_SUFFIX} 缺字段 {key!r}")
return meta[key]
def _meta_int(meta: Mapping[str, object] | None, key: str) -> int:
value = _meta_field(meta, key)
if not isinstance(value, int) or isinstance(value, bool):
raise _MetaError(f"{META_SUFFIX}{key} 应当是整数,收到 {type(value).__name__}")
return value
def _meta_optional_int(meta: Mapping[str, object] | None, key: str) -> int | None:
value = _meta_field(meta, key)
if value is None:
return None
if not isinstance(value, int) or isinstance(value, bool):
raise _MetaError(f"{META_SUFFIX}{key} 应当是整数或 null,收到 {type(value).__name__}")
return value
def _check_events_match_local_steps(facts: RunFacts, config: CheckConfig) -> CheckOutcome:
"""事件条数 == 本进程真的走完的步数。
事件只在**这次进程里真的走完**的步上发,从日志读回来直接跳过的步不发
`polyloop.ports` 的 `EventKind.STEP_FINISHED`)。所以续跑时要把跳过的那一段减掉。
"""
del config
if facts.event_run_ids is None or facts.events_error is not None:
return CheckOutcome(
undetermined=(
Evidence(
run_id=facts.run_id,
reason=facts.events_error or f"没有 {EVENTS_SUFFIX}",
),
)
)
try:
resumed = _meta_optional_int(facts.meta, "resumed_from_step") or 0
except _MetaError as problem:
return CheckOutcome(
undetermined=(Evidence(run_id=facts.run_id, reason=f"{problem}(读不到续跑起点)"),)
)
expected = len(facts.steps()) - resumed
actual = len(facts.event_run_ids)
if expected == actual:
return CheckOutcome()
return CheckOutcome(
breaches=(
Evidence(
run_id=facts.run_id,
expected=f"{expected} 条事件(日志 {len(facts.steps())} 步 - 续跑跳过 {resumed} 步)",
actual=f"{actual} 条事件",
),
)
)
def _check_delivery_failures(facts: RunFacts, config: CheckConfig) -> CheckOutcome:
del config
result = facts.effective_result()
if result is None:
return CheckOutcome(
undetermined=(
Evidence(
run_id=facts.run_id,
reason=(facts.result_error or f"没有 {RESULT_SUFFIX}")
+ ",日志里也没有 run_finished",
),
)
)
try:
sink_failures = _meta_int(facts.meta, "sink_failures")
except _MetaError as problem:
return CheckOutcome(undetermined=(Evidence(run_id=facts.run_id, reason=str(problem)),))
if result.event_delivery_failures == sink_failures:
return CheckOutcome()
return CheckOutcome(
breaches=(
Evidence(
run_id=facts.run_id,
expected=f"event_delivery_failures = 出口自己数的 {sink_failures}",
actual=f"event_delivery_failures = {result.event_delivery_failures}",
),
)
)
def _check_no_crosstalk(facts: RunFacts, config: CheckConfig) -> CheckOutcome:
del config
breaches: list[Evidence] = []
blocked: list[Evidence] = []
for line in facts.lines:
record = line.record
if record is None:
continue
found = getattr(record, "run_id", None)
if found != facts.run_id:
breaches.append(
Evidence(
run_id=facts.run_id,
line_no=line.number,
expected=f"记录的 run_id = {facts.run_id}",
actual=f"run_id = {_safe_id(str(found))}",
)
)
if facts.event_run_ids is None or facts.events_error is not None:
blocked.append(
Evidence(
run_id=facts.run_id,
reason=(facts.events_error or f"没有 {EVENTS_SUFFIX}") + ",事件那一半判不了",
)
)
else:
for index, found in enumerate(facts.event_run_ids, start=1):
if found != facts.run_id:
breaches.append(
Evidence(
run_id=facts.run_id,
expected=f"每条事件的 run_id = {facts.run_id}",
actual=f"第 {index} 条事件的 run_id = {_safe_id(str(found))}",
)
)
return CheckOutcome(
breaches=tuple(_cap(breaches, facts.run_id)),
undetermined=tuple(blocked),
)
def _snapshot_max_steps(facts: RunFacts) -> int:
started = facts.run_started()
if started is None:
raise _MetaError("日志里没有 run_started,读不到参数快照")
raw = started.parameter_snapshot.get(MAX_STEPS_SNAPSHOT_KEY)
if raw is None:
raise _MetaError(f"参数快照里没有 {MAX_STEPS_SNAPSHOT_KEY}")
try:
return int(raw)
except ValueError as exc:
raise _MetaError(
f"参数快照里的 {MAX_STEPS_SNAPSHOT_KEY} 不是整数:{_safe_id(raw)}"
) from exc
def _check_stop_reason_consistent(facts: RunFacts, config: CheckConfig) -> CheckOutcome:
"""停止原因与轨迹自洽。
四条规矩各管一个停止原因,其余取值这里不判——它们的判据要么在库的单元测试里,要么
需要记分板拿不到的事实。判不了的就报「无法判定」,不编。
"""
result = facts.effective_result()
if result is None:
return CheckOutcome(
undetermined=(
Evidence(
run_id=facts.run_id,
reason="既没有 run_finished 也没有 .result.json,读不到停止原因",
),
)
)
reason = result.stop_reason
if reason is StopReason.TASK_COMPLETED:
return _check_task_completed(facts, result, config)
if reason is StopReason.STEP_BUDGET:
try:
max_steps = _snapshot_max_steps(facts)
except _MetaError as problem:
return CheckOutcome(undetermined=(Evidence(run_id=facts.run_id, reason=str(problem)),))
if len(result.steps) == max_steps:
return CheckOutcome()
return CheckOutcome(
breaches=(
Evidence(
run_id=facts.run_id,
expected=f"step_budget 时步数 = max_steps = {max_steps}",
actual=f"步数 = {len(result.steps)}",
),
)
)
if reason is StopReason.AGENT_FINISHED:
if result.final_answer:
return CheckOutcome()
return CheckOutcome(
breaches=(
Evidence(
run_id=facts.run_id,
expected="agent_finished 时 final_answer 非空",
actual=f"final_answer 长度 {_text_len(result.final_answer)}",
),
)
)
if reason is StopReason.CANCELLED:
if facts.run_finished() is not None:
return CheckOutcome()
return CheckOutcome(
breaches=(
Evidence(
run_id=facts.run_id,
expected="cancelled 时日志里有 run_finished 记录",
actual="日志里没有 run_finished,恢复会把它当成可以续跑",
),
)
)
return CheckOutcome()
def _check_task_completed(facts: RunFacts, result: RunResult, config: CheckConfig) -> CheckOutcome:
if not result.steps:
return CheckOutcome(
breaches=(
Evidence(
run_id=facts.run_id,
expected="task_completed 时至少有一步",
actual="步数 = 0",
),
)
)
last = result.steps[-1]
if last.env_reported_completion:
return CheckOutcome()
if config.completing_tools is None:
return CheckOutcome(
undetermined=(
Evidence(
run_id=facts.run_id,
step_idx=last.step_idx,
reason=(
"最后一步没有环境侧完成证据,而带完成标记的工具名没有传进来,"
"另一条通路判不了"
),
),
)
)
if last.tool_name is not None and last.tool_name in config.completing_tools:
return CheckOutcome()
return CheckOutcome(
breaches=(
Evidence(
run_id=facts.run_id,
step_idx=last.step_idx,
expected=(
"最后一步 env_reported_completion=True,或者 tool_name 在"
f"带完成标记的那 {len(config.completing_tools)} 个工具里"
),
actual=(
"env_reported_completion=Falsetool_name 不在里面"
+ ("(那一步没有 tool_name" if last.tool_name is None else "")
# 工具名不进报告:它最终来自模型输出,见模块 docstring。
),
),
)
)
@dataclass(frozen=True, slots=True, kw_only=True)
class Invariant:
"""一条不变量:名字、一句话说明、以及怎么在一个 run 上判它。"""
name: str
description: str
check: Callable[[RunFacts, CheckConfig], CheckOutcome]
INVARIANTS: tuple[Invariant, ...] = (
Invariant(
name="日志能被读回来",
description="日志里每一条被换行终结的行都解得出记录;撕裂只允许出现在最后一行。",
check=_check_log_readable,
),
Invariant(
name="跨进程的结果与内存里的一致",
description=(
f"{RESULT_SUFFIX} 解出来的 RunResult 与日志里 run_finished 内嵌的那份逐字段相等。"
),
check=_check_result_matches_log,
),
Invariant(
name="步号连续",
description="step_completed 的 step_idx 从 0 开始逐 1 递增,不重复不跳号。",
check=_check_step_indices,
),
Invariant(
name="意图都有归宿",
description=(
"每条意图的 result_id 都能在对应的结果记录里找到;"
"至多一条悬空,且必须是日志里最后一条意图。"
),
check=_check_intents_resolved,
),
Invariant(
name="步记录的内部不变量",
description="step_completed 的 result_id 为空当且仅当 action_outcome 为空。",
check=_check_step_pairing,
),
Invariant(
name="动作结果与步记录一致",
description=(
"同一条 step_completed 里,动作结果与步记录说的是同一件事。状态与完成标记这两项"
"在三档下都必须相同。观察那几列只在 executed 一档下逐字相同,因为只有那一档是"
"执行器返回值的原样透传:动作被拒绝与环境故障这两档,库刻意不把执行器给的观察"
"回填进历史,换成合成的那一段——执行器那段是「模型看得见的东西」,每次现造的话"
"同一份配置跑出来的两次运行在模型看来其实不同。执行器的原文没有丢,它就留在同一"
"条记录的动作结果里。所以这两档改判另一件事:库既然替换了观察,就必须把步记录的 "
"observation_is_synthetic 立起来,不立才是真出了问题。"
),
check=_check_outcome_agrees_with_step,
),
Invariant(
name="提示词字符数单调不减",
description="同一个 run 里 prompt_chars 随步号不减——历史只追加。",
check=_check_prompt_chars_monotonic,
),
Invariant(
name="事件条数等于本进程走完的步数",
description="事件文件的行数 = 日志里的步数 - 续跑跳过的步数。",
check=_check_events_match_local_steps,
),
Invariant(
name="投递失败计数对得上",
description="结果里的 event_delivery_failures 等于出口自己数的 sink_failures。",
check=_check_delivery_failures,
),
Invariant(
name="不串台",
description="日志与事件里每一条记录的 run_id 都等于文件名去掉后缀那部分。",
check=_check_no_crosstalk,
),
Invariant(
name="停止原因与轨迹自洽",
description=(
"task_completed 有完成证据;step_budget 时步数等于上限;"
"agent_finished 时最终回答非空;cancelled 时日志里有结束记录。"
),
check=_check_stop_reason_consistent,
),
)
# ---------------------------------------------------------------------------
# 统计与基线对照
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True, kw_only=True)
class StepStats:
"""一组 run 的步数分布。没有 run 时四项都是空。"""
runs: int
p50: int | None = None
p90: int | None = None
maximum: int | None = None
mean: float | None = None
def _percentile(values: Sequence[int], fraction: float) -> int:
"""最近秩分位:排序之后取第 ceil(fraction × n) 个。
分位数有好几种定义,插值那几种会在小样本上给出不存在于数据里的值(比如 13.5 步)。
步数是整数计数,报一个半步没有意义,所以取最近秩。
"""
ordered = sorted(values)
rank = math.ceil(fraction * len(ordered))
return ordered[max(rank, 1) - 1]
def _step_stats(counts: Sequence[int]) -> StepStats:
if not counts:
return StepStats(runs=0)
return StepStats(
runs=len(counts),
p50=_percentile(counts, 0.50),
p90=_percentile(counts, 0.90),
maximum=max(counts),
mean=sum(counts) / len(counts),
)
@dataclass(frozen=True, slots=True, kw_only=True)
class RunSummary:
"""逐个 run 那张表的一行。"""
run_id: str
scenario: str
stop_reason: str
steps: int
success: bool | None
wall_ms: int | None
fault: str | None
@dataclass(frozen=True, slots=True, kw_only=True)
class ScenarioStats:
"""一个场景(或整批)的统计。"""
scenario: str
runs: int
stop_reasons: Mapping[str, int]
steps: StepStats
model_calls: int | None
model_calls_from: int
wall_ms: int | None
wall_ms_from: int
scored_runs: int
successes: int
@dataclass(frozen=True, slots=True, kw_only=True)
class Baseline:
"""对照用的一份历史分布。**它是对照不是判定**——模型不同、任务子集不同,偏了不算击穿。"""
label: str
stop_reasons: Mapping[str, int]
steps_p50: int
steps_p90: int
steps_max: int
#: dissect 那边 937 个真实 rollout 的分布。数字来自那份历史数据,不是本库跑出来的。
DISSECT_BASELINE = Baseline(
label="dissect937 个真实 rollout",
stop_reasons={"task_completed": 915, "step_budget": 20, "parse_failed_repeatedly": 2},
steps_p50=13,
steps_p90=24,
steps_max=40,
)
UNKNOWN_SCENARIO = "<未知>"
UNKNOWN_STOP_REASON = "<未知>"
def _summarize(facts: RunFacts) -> RunSummary:
meta = facts.meta or {}
scenario = meta.get("scenario")
fault = meta.get("fault")
success = meta.get("success")
wall = meta.get("wall_ms")
result = facts.effective_result()
return RunSummary(
run_id=facts.run_id,
scenario=_safe_id(scenario) if isinstance(scenario, str) else UNKNOWN_SCENARIO,
stop_reason=result.stop_reason.value if result is not None else UNKNOWN_STOP_REASON,
steps=len(facts.steps()),
success=success if isinstance(success, bool) else None,
wall_ms=wall if isinstance(wall, int) and not isinstance(wall, bool) else None,
fault=_safe_id(fault) if isinstance(fault, str) else None,
)
def _scenario_stats(
scenario: str, summaries: Sequence[RunSummary], runs: Sequence[RunFacts]
) -> ScenarioStats:
stop_reasons: dict[str, int] = {}
for item in summaries:
stop_reasons[item.stop_reason] = stop_reasons.get(item.stop_reason, 0) + 1
calls: list[int] = []
for facts in runs:
value = (facts.meta or {}).get("model_calls")
if isinstance(value, int) and not isinstance(value, bool):
calls.append(value)
walls = [item.wall_ms for item in summaries if item.wall_ms is not None]
scored = [item.success for item in summaries if item.success is not None]
return ScenarioStats(
scenario=scenario,
runs=len(summaries),
stop_reasons=dict(sorted(stop_reasons.items())),
steps=_step_stats([item.steps for item in summaries]),
model_calls=sum(calls) if calls else None,
model_calls_from=len(calls),
wall_ms=sum(walls) if walls else None,
wall_ms_from=len(walls),
scored_runs=len(scored),
successes=sum(1 for item in scored if item),
)
# ---------------------------------------------------------------------------
# 记分板
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True, kw_only=True)
class Scoreboard:
"""一批压测产物判完之后的全部结论。渲染报告与决定退出码都只看它。"""
runs_dir: Path
invariants: tuple[InvariantResult, ...]
summaries: tuple[RunSummary, ...]
overall_stats: ScenarioStats
scenario_stats: tuple[ScenarioStats, ...]
baseline: Baseline
#: 不构成判定、但必须被人看见的事实:撕裂尾行、缺文件。
notes: tuple[str, ...] = ()
@property
def breached(self) -> tuple[InvariantResult, ...]:
return tuple(item for item in self.invariants if item.verdict is Verdict.BREACHED)
@property
def undetermined(self) -> tuple[InvariantResult, ...]:
return tuple(item for item in self.invariants if item.verdict is Verdict.UNDETERMINED)
@property
def verdict(self) -> Verdict:
if self.breached:
return Verdict.BREACHED
if self.undetermined:
return Verdict.UNDETERMINED
return Verdict.PASSED
def _notes_for(facts: RunFacts) -> list[str]:
notes: list[str] = []
if facts.torn_tail:
notes.append(
f"run `{facts.run_id}`:日志最后一行没有被换行终结(撕裂尾行)。"
"按存储契约那次写从未被确认过,它是进程被杀在写入中途的证据。"
)
if facts.events_torn_tail:
notes.append(f"run `{facts.run_id}`:事件文件最后一行没有被换行终结(撕裂尾行)。")
for error in (facts.log_error, facts.result_error, facts.events_error, facts.meta_error):
if error is not None:
notes.append(f"run `{facts.run_id}`{error}。")
return notes
def evaluate(
runs_dir: Path,
*,
completing_tools: frozenset[str] | None = None,
baseline: Baseline = DISSECT_BASELINE,
) -> Scoreboard:
"""读这个目录里的全部 run,逐条判不变量,算统计,返回一份记分板。"""
config = CheckConfig(completing_tools=completing_tools)
runs = [load_run(runs_dir, run_id) for run_id in list_run_ids(runs_dir)]
results: list[InvariantResult] = []
for invariant in INVARIANTS:
breaches: list[Evidence] = []
blocked: list[Evidence] = []
if not runs:
blocked.append(
Evidence(run_id="<批次>", reason=f"{runs_dir} 里没有任何 run,这条没被验过")
)
for facts in runs:
outcome = invariant.check(facts, config)
breaches.extend(outcome.breaches)
blocked.extend(outcome.undetermined)
results.append(
InvariantResult(
name=invariant.name,
description=invariant.description,
breaches=tuple(breaches),
undetermined=tuple(blocked),
)
)
summaries = tuple(_summarize(facts) for facts in runs)
scenarios = sorted({item.scenario for item in summaries})
scenario_stats = tuple(
_scenario_stats(
scenario,
[item for item in summaries if item.scenario == scenario],
[
facts
for facts, item in zip(runs, summaries, strict=True)
if item.scenario == scenario
],
)
for scenario in scenarios
)
notes: list[str] = []
for facts in runs:
notes.extend(_notes_for(facts))
return Scoreboard(
runs_dir=runs_dir,
invariants=tuple(results),
summaries=summaries,
overall_stats=_scenario_stats("全部场景", summaries, runs),
scenario_stats=scenario_stats,
baseline=baseline,
notes=tuple(notes),
)
# ---------------------------------------------------------------------------
# 报告
# ---------------------------------------------------------------------------
def _share(count: int, total: int) -> str:
if total == 0:
return "—"
return f"{count / total * 100:.1f}%"
def _optional(value: object) -> str:
return "—" if value is None else str(value)
def _render_stats(stats: ScenarioStats) -> list[str]:
lines: list[str] = []
lines.append(f"run 数:{stats.runs}")
lines.append("")
lines.append("| 停止原因 | 次数 | 占比 |")
lines.append("|---|---:|---:|")
if stats.stop_reasons:
for reason, count in stats.stop_reasons.items():
lines.append(f"| {_cell(reason)} | {count} | {_share(count, stats.runs)} |")
else:
lines.append("| — | 0 | — |")
lines.append("")
lines.append("| 步数分布 | 值 |")
lines.append("|---|---:|")
lines.append(f"| p50 | {_optional(stats.steps.p50)} |")
lines.append(f"| p90 | {_optional(stats.steps.p90)} |")
lines.append(f"| max | {_optional(stats.steps.maximum)} |")
mean = "—" if stats.steps.mean is None else f"{stats.steps.mean:.2f}"
lines.append(f"| 均值 | {mean} |")
lines.append("")
lines.append("| 其它 | 值 |")
lines.append("|---|---:|")
lines.append(
f"| 模型调用总数 | {_optional(stats.model_calls)}"
f"(来自 {stats.model_calls_from} / {stats.runs} 个 run |"
)
lines.append(
f"| 总耗时(ms) | {_optional(stats.wall_ms)}"
f"(来自 {stats.wall_ms_from} / {stats.runs} 个 run |"
)
rate = _share(stats.successes, stats.scored_runs)
lines.append(f"| 成功率 | {stats.successes} / {stats.scored_runs} = {rate} |")
lines.append("")
return lines
def _render_baseline(scoreboard: Scoreboard) -> list[str]:
stats = scoreboard.overall_stats
baseline = scoreboard.baseline
baseline_total = sum(baseline.stop_reasons.values())
lines = [
"## 与基线的对照",
"",
f"基线是 {baseline.label}。**这是对照不是判定**:模型不同、任务子集不同,"
"偏了不算击穿,但要看得见。",
"",
"| 项目 | 本批次 | 基线 | 偏差 |",
"|---|---:|---:|---:|",
]
names = sorted(set(stats.stop_reasons) | set(baseline.stop_reasons))
for name in names:
mine = stats.stop_reasons.get(name, 0)
theirs = baseline.stop_reasons.get(name, 0)
mine_share = mine / stats.runs * 100 if stats.runs else None
theirs_share = theirs / baseline_total * 100 if baseline_total else None
if mine_share is None or theirs_share is None:
delta = "—"
else:
delta = f"{mine_share - theirs_share:+.1f} 个百分点"
lines.append(
f"| 停止原因 {_cell(name)} | {mine}{_share(mine, stats.runs)} | "
f"{theirs}{_share(theirs, baseline_total)} | {delta} |"
)
for label, mine_value, theirs_value in (
("步数 p50", stats.steps.p50, baseline.steps_p50),
("步数 p90", stats.steps.p90, baseline.steps_p90),
("步数 max", stats.steps.maximum, baseline.steps_max),
):
delta = "—" if mine_value is None else f"{mine_value - theirs_value:+d}"
lines.append(f"| {label} | {_optional(mine_value)} | {theirs_value} | {delta} |")
lines.append("")
return lines
def render_report(scoreboard: Scoreboard) -> str:
"""把一份记分板渲染成 markdown。
这里是「不许出现模型原文与文档片段」那条规矩的最后一道口子:所有进报告的字符串都已经
在判定那一层过过 `_scrub` 或 `_safe_id`,别的字符串从头到尾只以长度出现。
"""
lines: list[str] = ["# PolyLoop 压测记分板", ""]
lines.append(f"- 批次目录:`{scoreboard.runs_dir}`")
lines.append(f"- run 数:{len(scoreboard.summaries)}")
lines.append(f"- 不变量:{len(scoreboard.invariants)} 条")
lines.append("")
lines.append("## 总判定")
lines.append("")
breached = scoreboard.breached
blocked = scoreboard.undetermined
if not breached and not blocked:
lines.append(f"**全部通过:{len(scoreboard.invariants)} 条不变量逐条判过,无一击穿。**")
else:
parts = []
if breached:
parts.append(f"{len(breached)} 条不变量被击穿")
if blocked:
parts.append(f"{len(blocked)} 条无法判定")
lines.append(f"**有 {''.join(parts)}。**")
lines.append("")
lines.append(
"「无法判定」是独立的第三档,不折算成通过:缺文件、缺字段导致判不了,和判过了是两回事。"
)
lines.append("")
lines.append("## 不变量")
lines.append("")
lines.append("| 不变量 | 判定 | 击穿证据 | 无法判定 |")
lines.append("|---|---|---:|---:|")
for item in scoreboard.invariants:
lines.append(
f"| {_cell(item.name)} | {_VERDICT_LABELS[item.verdict]} | "
f"{len(item.breaches)} | {len(item.undetermined)} |"
)
lines.append("")
for item in scoreboard.invariants:
if item.verdict is Verdict.PASSED:
continue
lines.append(f"### {item.name}{_VERDICT_LABELS[item.verdict]}")
lines.append("")
lines.append(item.description)
lines.append("")
if item.breaches:
lines.append("击穿:")
lines.append("")
for evidence in item.breaches:
lines.append(f"- {evidence.describe()}")
lines.append("")
if item.undetermined:
lines.append("无法判定:")
lines.append("")
for evidence in item.undetermined:
lines.append(f"- {evidence.describe()}")
lines.append("")
lines.append("## 观察")
lines.append("")
if scoreboard.notes:
lines.extend(f"- {note}" for note in scoreboard.notes)
else:
lines.append("没有缺文件,也没有撕裂尾行。")
lines.append("")
lines.append("## 统计")
lines.append("")
lines.append("### 全部场景")
lines.append("")
lines.extend(_render_stats(scoreboard.overall_stats))
for stats in scoreboard.scenario_stats:
lines.append(f"### 场景 {stats.scenario}")
lines.append("")
lines.extend(_render_stats(stats))
lines.extend(_render_baseline(scoreboard))
lines.append("## 逐个 run")
lines.append("")
lines.append("| run_id | 场景 | 停止原因 | 步数 | 成功 | 耗时(ms) | 故障注入 |")
lines.append("|---|---|---|---:|---|---:|---|")
for item in scoreboard.summaries:
success = "未判分" if item.success is None else ("是" if item.success else "否")
lines.append(
f"| `{_cell(item.run_id)}` | {_cell(item.scenario)} | "
f"{_cell(item.stop_reason)} | {item.steps} | {success} | "
f"{_optional(item.wall_ms)} | {_cell(item.fault or '—')} |"
)
lines.append("")
return "\n".join(lines)
def write_report(scoreboard: Scoreboard, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(render_report(scoreboard), encoding="utf-8")
# ---------------------------------------------------------------------------
# 命令行
# ---------------------------------------------------------------------------
#: 有不变量被击穿。
EXIT_BREACHED = 1
#: 没有击穿,但有判不了的。默认也算失败,`--allow-undetermined` 放行。
EXIT_UNDETERMINED = 2
def _parse_stop_reason_pair(text: str) -> tuple[str, int]:
name, _, raw = text.partition("=")
if not name or not raw:
raise argparse.ArgumentTypeError(f"要写成 名字=次数,收到 {text!r}")
try:
count = int(raw)
except ValueError as exc:
raise argparse.ArgumentTypeError(f"次数不是整数:{raw!r}") from exc
return name, count
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="scoreboard",
description="把一批压测产物判成通过 / 击穿 / 无法判定,并渲染成 markdown 报告。",
)
parser.add_argument("--runs-dir", required=True, type=Path, help="压测产物所在目录")
parser.add_argument("--report", required=True, type=Path, help="报告写到哪儿")
parser.add_argument(
"--allow-undetermined",
action="store_true",
help="判不了的不算失败。崩溃注入那一类天然会缺文件,只有那时才该开。",
)
parser.add_argument(
"--completing-tool",
action="append",
default=[],
metavar="NAME",
help="带完成标记的工具名,可重复。不给的话「停止原因与轨迹自洽」在只剩这条路时判不了。",
)
parser.add_argument(
"--baseline-label", default=DISSECT_BASELINE.label, help="基线的名字,只进报告"
)
parser.add_argument(
"--baseline-stop-reason",
action="append",
default=[],
type=_parse_stop_reason_pair,
metavar="NAME=COUNT",
help="基线的停止原因分布,可重复。给了任意一条就整份替换默认分布。",
)
parser.add_argument("--baseline-steps-p50", type=int, default=DISSECT_BASELINE.steps_p50)
parser.add_argument("--baseline-steps-p90", type=int, default=DISSECT_BASELINE.steps_p90)
parser.add_argument("--baseline-steps-max", type=int, default=DISSECT_BASELINE.steps_max)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
baseline = Baseline(
label=args.baseline_label,
stop_reasons=(
dict(args.baseline_stop_reason)
if args.baseline_stop_reason
else DISSECT_BASELINE.stop_reasons
),
steps_p50=args.baseline_steps_p50,
steps_p90=args.baseline_steps_p90,
steps_max=args.baseline_steps_max,
)
completing = frozenset(args.completing_tool) if args.completing_tool else None
scoreboard = evaluate(args.runs_dir, completing_tools=completing, baseline=baseline)
write_report(scoreboard, args.report)
print(f"报告写到了 {args.report}")
for item in scoreboard.invariants:
print(f" {_VERDICT_LABELS[item.verdict]}\t{item.name}")
if scoreboard.breached:
print(f"{len(scoreboard.breached)} 条不变量被击穿")
return EXIT_BREACHED
if scoreboard.undetermined:
if args.allow_undetermined:
print(f"{len(scoreboard.undetermined)} 条无法判定,按 --allow-undetermined 放行")
return 0
print(f"{len(scoreboard.undetermined)} 条无法判定")
return EXIT_UNDETERMINED
return 0
if __name__ == "__main__":
raise SystemExit(main())