Files
iomgaa ff59457482 fix(soak): 记分板的真空成立与停止原因覆盖,两处都会把「什么都没验」显示成绿
Codex 报了两条数据不足时真空成立的不变量,顺着同类找齐了七条:零步的「步号连续」与
「动作结果与步记录一致」、不足两步的「提示词字符数单调不减」、零意图的「意图都有归宿」、
零载荷的「步记录的内部不变量」、零记录的「不串台」、零行的「日志能被读回来」。同一类
缺陷改一半,剩下那一半照样会在某天把一次什么都没验的跑显示成绿。

各条的数据下限不一样,反直觉的三处写进了说明:「步记录的内部不变量」数的是打着标签的行
不是解出来的记录(违反配对的行本来就解不出记录,按记录数当下限会把它最该判的对象数漏);
「动作结果与步记录一致」不要求那条步记录带动作结果;「不串台」两半各判各的,合成一个的话
一半的真空会被另一半的绿盖住。

「停止原因与轨迹自洽」原本只覆盖四个取值、另外六个直接放行——不是数据不足,是判据本来就
该覆盖而没覆盖,后果和真空成立一样。六个都补了规矩,llm_error 那条按库自己的判据写
(解析失败必定带说明,模型调用失败那一步压根没走到解释器,只看有没有动作结果分不开这两者)。
另加一条断言十个取值一个不漏,将来加了取值而这里没跟上会显式报「还没有规矩」。

「提示词字符数单调不减」的说明原本承诺「历史只追加」,实现只比较库自己记录的数——承诺了
它,读者看见绿就以为截断被排除了。改成只承诺它验得到的,真正的对账在故障注入那侧。

拿 193 次真实运行重跑:十一条仍然全过,而这次那 8 次解析失败连击、1 次模型调用失败、
1 次撞步数上限是被真规矩判过的。

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

2064 lines
82 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.
"""压测记分板:把一批压测产物判成「通过 / 击穿 / 无法判定」,再渲染成一份 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,
ReplayPolicy,
RunFinished,
RunResult,
RunStarted,
StepCompleted,
StepRecord,
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"
MAX_ACTIONS_SNAPSHOT_KEY = "request.max_actions"
MAX_PARSE_FAILURES_SNAPSHOT_KEY = "request.max_consecutive_parse_failures"
MAX_PROMPT_CHARS_SNAPSHOT_KEY = "request.max_prompt_chars"
#: 单个 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:
"""每一条被换行终结的行都解得出记录。
**一条被终结的行都没有时报「无法判定」。** 空文件与「只写了半行就被杀」都落在这里:
没有任何一行被读回来过,说「日志读得回来」是没有依据的。这一档在崩溃注入那批里是
真实存在的——`write_run_started` 先建文件再写那一行,杀在两者之间就留下这种产物。
"""
del config
if facts.log_error is not None:
return CheckOutcome(undetermined=(Evidence(run_id=facts.run_id, reason=facts.log_error),))
if not facts.lines:
return CheckOutcome(
undetermined=(
Evidence(
run_id=facts.run_id,
reason=(
"日志里没有任何一条被换行终结的行"
+ ("(末尾有撕裂的半行)" if facts.torn_tail else "(文件是空的)")
+ ",没东西可读回来"
),
),
)
)
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:
"""步号从 0 开始逐 1 递增。
**一条步记录都没有时报「无法判定」,不报通过。** 循环零次也会返回一个没有击穿的结果,
而那不是「验过了、对的」,是「没东西可验」——把它算成通过,一批全是崩在第一步之前的
产物会显示成绿的。崩溃注入那两类产物里真的会出现零步的 run。
"""
del config
if not facts.steps():
return CheckOutcome(
undetermined=(
Evidence(
run_id=facts.run_id,
reason="日志里一条 step_completed 都没有,步号这条没东西可验",
),
)
)
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 _dangling_intents(facts: RunFacts) -> list[tuple[int, int, Intent]]:
"""挑出没有归宿的意图,每条带上「它是第几条」与「在日志第几行」。
「意图都有归宿」与「停止原因与轨迹自洽」的 resume_state_unknown 那一档读的是同一件事,
所以只算一次:一处改了另一处没改的话,两条会对同一份日志给出互相矛盾的判定。
"""
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}
dangling: list[tuple[int, int, Intent]] = []
for position, (number, intent) in enumerate(facts.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))
return dangling
def _check_intents_resolved(facts: RunFacts, config: CheckConfig) -> CheckOutcome:
"""每条意图的 result_id 都能找到归宿,悬空至多一条且必须在末尾。
**一条意图都没有时报「无法判定」。** 下限就是一条:一条意图足够验出它悬不悬空,也足够
验出「悬空的那条是不是最后一条」——那两问在只有一条意图时都有确定的答案。不要求日志里
同时有结果记录,一条有意图没结果的日志正是这条要判的那种。
"""
del config
intents = facts.intents()
if not intents:
return CheckOutcome(
undetermined=(
Evidence(
run_id=facts.run_id,
reason="日志里一条意图都没有,归宿这条没东西可验",
),
)
)
dangling = _dangling_intents(facts)
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` 自己在构造期就守这条,
所以一条违反它的载荷根本解不出记录——那样这条不变量就永远只能是通过,成了摆设。
违反它的行同时会被不变量一报出来(它确实读不回来),两条说的是同一处损坏的两个侧面。
**所以这一条不增加发现击穿的覆盖,它增加的是证据的精度**:上一条只能说「第 N 行解不
出来」,这一条直接指出是 `result_id` 与 `action_outcome` 对不上。留着它省的是排查时间,
不是漏判风险。
**一条 `step_completed` 载荷都没有时报「无法判定」。** 下限数的是**打着这个标签的行**,
不是解出来的记录——解不出来的那些行正是这条最该判的对象,按记录数当下限会把它们数漏。
"""
del config
if not facts.payloads_tagged("step_completed"):
return CheckOutcome(
undetermined=(
Evidence(
run_id=facts.run_id,
reason="日志里一条 step_completed 载荷都没有,这条配对没东西可验",
),
)
)
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 处击穿,核下来全是判据错、不是库错。
**一条步记录都没有时报「无法判定」。下限是一条步记录,不要求它带动作结果**:没有动作
结果的那一档也在这条的判定范围里(那时步记录的 `action_status` 必须为空),所以一份
全是解析失败的日志确实验到了这条的一部分,报「判不了」反而是假的。
"""
del config
if not facts.steps():
return CheckOutcome(
undetermined=(
Evidence(
run_id=facts.run_id,
reason="日志里一条 step_completed 记录都没有,两侧一致这条没东西可验",
),
)
)
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:
"""步记录里的 `prompt_chars` 随步号不回退。
**它验的是库自己记下来的那个数,不是历史真的没被截断过。** 两者不是一回事:库要是
截断了历史、却接着记一串不下降的 `prompt_chars`,这条照样通过。记分板手上只有日志,
日志里没有真正发出去的那串消息,这个缺口只能由压测那边包一层模型客户端、拿真实发出去的
消息长度对账来补。名字和说明都不承诺那件事——承诺了它,读者看见绿就以为截断已经被排除。
**不足两步时报「无法判定」**:一步和零步都凑不出相邻的两个值,没有任何比较发生过。
"""
del config
if len(facts.steps()) < 2:
return CheckOutcome(
undetermined=(
Evidence(
run_id=facts.run_id,
reason=(
f"日志里只有 {len(facts.steps())} 条步记录,凑不出相邻两步,"
"单调性没东西可验"
),
),
)
)
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:
"""日志与事件里每一条的 run_id 都等于文件名去掉后缀那部分。
**两半各有各的下限,分开判。** 日志那半要至少一条解得出来的记录,事件那半要至少一条
事件。一份有记录、事件却是空的产物(零步的 run,或者续跑时本进程一步没走完),日志那半
是真判过的,事件那半没东西可验——两半合成一个判定的话,其中一半的真空会被另一半的绿盖住,
而那正是这条要防的那种「看着验过了、其实没验」。
"""
del config
breaches: list[Evidence] = []
blocked: list[Evidence] = []
if not facts.records():
blocked.append(
Evidence(
run_id=facts.run_id,
reason="日志里没有一条解得出来的记录,日志那一半判不了",
)
)
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}") + ",事件那一半判不了",
)
)
elif not facts.event_run_ids:
blocked.append(
Evidence(
run_id=facts.run_id,
reason=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_int(facts: RunFacts, key: str) -> int:
"""从运行开始记录的参数快照里取一个整数上限。取不到就抛 `_MetaError`,让上层报判不了。"""
started = facts.run_started()
if started is None:
raise _MetaError("日志里没有 run_started,读不到参数快照")
raw = started.parameter_snapshot.get(key)
if raw is None:
raise _MetaError(f"参数快照里没有 {key}")
try:
return int(raw)
except ValueError as exc:
raise _MetaError(f"参数快照里的 {key} 不是整数:{_safe_id(raw)}") from exc
def _breach(
facts: RunFacts, expected: str, actual: str, *, step_idx: int | None = None
) -> CheckOutcome:
return CheckOutcome(
breaches=(
Evidence(run_id=facts.run_id, step_idx=step_idx, expected=expected, actual=actual),
)
)
def _blocked(facts: RunFacts, reason: str) -> CheckOutcome:
return CheckOutcome(undetermined=(Evidence(run_id=facts.run_id, reason=reason),))
def _status_name(step: StepRecord) -> str:
return "空" if step.action_status is None else step.action_status.value
def _rule_step_budget(facts: RunFacts, result: RunResult) -> CheckOutcome:
max_steps = _snapshot_int(facts, MAX_STEPS_SNAPSHOT_KEY)
if len(result.steps) == max_steps:
return CheckOutcome()
return _breach(
facts, f"step_budget 时步数 = max_steps = {max_steps}", f"步数 = {len(result.steps)}"
)
def _rule_agent_finished(facts: RunFacts, result: RunResult) -> CheckOutcome:
if result.final_answer:
return CheckOutcome()
return _breach(
facts,
"agent_finished 时 final_answer 非空",
f"final_answer 长度 {_text_len(result.final_answer)}",
)
def _rule_cancelled(facts: RunFacts, result: RunResult) -> CheckOutcome:
del result
if facts.run_finished() is not None:
return CheckOutcome()
return _breach(
facts,
"cancelled 时日志里有 run_finished 记录",
"日志里没有 run_finished,恢复会把它当成可以续跑",
)
def _rule_parse_failed_repeatedly(facts: RunFacts, result: RunResult) -> CheckOutcome:
"""轨迹末尾连续解析失败的步数,恰好等于连续失败上限。
恰好相等而不是「至少」:那个计数每次解析失败加一、加完立刻问,达到上限就停,所以它撞线
时不可能超过上限;而任何一个有效决策会把它清零,所以那一段必定连续、必定贴着末尾。
这几步还必须没有动作状态——解析失败那一支根本不碰环境。
"""
limit = _snapshot_int(facts, MAX_PARSE_FAILURES_SNAPSHOT_KEY)
tail = 0
for step in reversed(result.steps):
if step.parse_ok:
break
tail += 1
breaches: list[Evidence] = []
if tail != limit:
breaches.append(
Evidence(
run_id=facts.run_id,
expected=(f"轨迹末尾连续 {limit} 步解析失败(= max_consecutive_parse_failures"),
actual=f"末尾连续 {tail} 步解析失败",
)
)
for step in result.steps[len(result.steps) - tail :]:
if step.action_status is not None:
breaches.append(
Evidence(
run_id=facts.run_id,
step_idx=step.step_idx,
expected="解析失败的步不碰环境,action_status 为空",
actual=f"action_status = {step.action_status.value}",
)
)
return CheckOutcome(breaches=tuple(_cap(breaches, facts.run_id)))
def _rule_context_overflow(facts: RunFacts, result: RunResult) -> CheckOutcome:
"""落盘的每一步的 `prompt_chars` 都不超过上限。
超限的那次装配根本不产生步记录(那一档在调模型之前就终止,没花钱、没有调用标识要对账),
所以落盘的每一步必定在线内。哪一步超了,说明有一次超限装配被放行去调模型了。
压测那侧的同名判据换个角度验同一件事:它包一层模型客户端,量真实发出去的消息有多长。
**一步都没落盘时报无法判定。** 首次装配就超限的运行正是这样,那是这个停止原因最典型的
形态,可它确实一步都没验到。
"""
limit = _snapshot_int(facts, MAX_PROMPT_CHARS_SNAPSHOT_KEY)
if not result.steps:
return _blocked(
facts,
"这次运行一步都没落盘(首次装配就超限的运行正是这样),"
"没有任何一步的 prompt_chars 可验",
)
breaches = [
Evidence(
run_id=facts.run_id,
step_idx=step.step_idx,
expected=f"prompt_chars ≤ max_prompt_chars = {limit}",
actual=f"prompt_chars = {step.prompt_chars}",
)
for step in result.steps
if step.prompt_chars > limit
]
return CheckOutcome(breaches=tuple(_cap(breaches, facts.run_id)))
def _rule_action_budget(facts: RunFacts, result: RunResult) -> CheckOutcome:
"""动作状态为 executed 的步数,恰好等于已执行动作上限。
数的是 executed 这一档,不是全部动作:未执行与环境故障都不加那个计数
`polyloop._stopping.RunCounters.with_action_executed`)。
"""
limit = _snapshot_int(facts, MAX_ACTIONS_SNAPSHOT_KEY)
executed = sum(1 for step in result.steps if step.action_status is ActionStatus.EXECUTED)
if executed == limit:
return CheckOutcome()
return _breach(
facts,
f"action_budget 时已执行动作数 = max_actions = {limit}",
f"action_status 为 executed 的步有 {executed} 条",
)
def _rule_env_error(facts: RunFacts, result: RunResult) -> CheckOutcome:
if not result.steps:
return _breach(facts, "env_error 时至少有一步(这个原因由一次动作结算撞出来)", "步数 = 0")
last = result.steps[-1]
if last.action_status is ActionStatus.ENV_ERROR:
return CheckOutcome()
return _breach(
facts,
"env_error 时最后一步的 action_status = env_error",
f"action_status = {_status_name(last)}",
step_idx=last.step_idx,
)
def _rule_llm_error(facts: RunFacts, result: RunResult) -> CheckOutcome:
"""最后一步长得像「模型调用失败」那种步。
判据是 `parse_ok=False` 且 `parse_error` 为空,**这一对正是库自己用来把它和解析失败分开
的东西**`polyloop.session` 的续跑结算读的就是这两个字段):那一步压根没走到解释器,
所以没有回喂给模型的说明,而解析失败必定带着一段。只看「没有动作结果」分不开这两者,
只看 `call_id` 为空也分不开——解释器拿到的回复本来就允许不带调用标识。
"""
if not result.steps:
return _breach(facts, "llm_error 时至少有一步(那一步记的就是这次失败)", "步数 = 0")
last = result.steps[-1]
expected = "llm_error 的最后一步:没有动作状态、parse_ok=False、parse_error 为空、call_id 为空"
problems: list[str] = []
if last.action_status is not None:
problems.append(f"action_status = {last.action_status.value}")
if last.parse_ok:
problems.append("parse_ok = True")
if last.parse_error is not None:
problems.append(f"parse_error 有值(长度 {len(last.parse_error)}),那是解析失败的样子")
if last.call_id is not None:
problems.append("call_id 有值")
if not problems:
return CheckOutcome()
return CheckOutcome(
breaches=tuple(
_cap(
[
Evidence(
run_id=facts.run_id,
step_idx=last.step_idx,
expected=expected,
actual=problem,
)
for problem in problems
],
facts.run_id,
)
)
)
def _rule_resume_state_unknown(facts: RunFacts, result: RunResult) -> CheckOutcome:
"""日志里最后一条意图悬空,且它声明绝不重放。
这个停止原因只有两条来路,两条都长这样:模型调用意图写了、结果没写,而这次运行的模型
重放策略是「绝不」;或者动作意图写了、步记录没写,而那个工具声明绝不重放。撞上这一档
时库不再追加步、也不再写意图,所以那条悬空的意图仍然是日志里的最后一条。
"""
del result
intents = facts.intents()
if not intents:
return _blocked(facts, "日志里一条意图都没有,这个停止原因的自洽条件判不了")
dangling = _dangling_intents(facts)
last_position = len(intents) - 1
if not dangling or dangling[-1][0] != last_position:
return _breach(
facts,
"resume_state_unknown 时日志里最后一条意图是悬空的",
"没有悬空的意图" if not dangling else "最后一条意图有归宿",
)
intent = dangling[-1][2]
if intent.replay_policy is ReplayPolicy.NEVER:
return CheckOutcome()
return _breach(
facts,
"悬空那条意图的 replay_policy = never(声明可安全重放的会被直接重放,不会停在这里)",
f"replay_policy = {intent.replay_policy.value}",
)
#: 十个停止原因各自的自洽规矩。`task_completed` 另走一条,它要调用方传进来的工具名。
_STOP_REASON_RULES: Mapping[StopReason, Callable[[RunFacts, RunResult], CheckOutcome]] = {
StopReason.STEP_BUDGET: _rule_step_budget,
StopReason.AGENT_FINISHED: _rule_agent_finished,
StopReason.CANCELLED: _rule_cancelled,
StopReason.PARSE_FAILED_REPEATEDLY: _rule_parse_failed_repeatedly,
StopReason.CONTEXT_OVERFLOW: _rule_context_overflow,
StopReason.ACTION_BUDGET: _rule_action_budget,
StopReason.ENV_ERROR: _rule_env_error,
StopReason.LLM_ERROR: _rule_llm_error,
StopReason.RESUME_STATE_UNKNOWN: _rule_resume_state_unknown,
}
def _check_stop_reason_consistent(facts: RunFacts, config: CheckConfig) -> CheckOutcome:
"""停止原因与轨迹自洽。十个取值各有一条规矩。
读不到停止原因、或者某条规矩要的东西不在日志里(参数快照缺了某个上限),报「无法判定」,
不编一个答案。将来 `StopReason` 加了取值而这里没跟上,也走同一条路——那时它是显式的
「这个取值还没有规矩」,不是一条静默的绿。
"""
result = facts.effective_result()
if result is None:
return _blocked(facts, "既没有 run_finished 也没有 .result.json,读不到停止原因")
reason = result.stop_reason
if reason is StopReason.TASK_COMPLETED:
return _check_task_completed(facts, result, config)
rule = _STOP_REASON_RULES.get(reason)
if rule is None:
return _blocked(facts, f"停止原因 {reason.value} 还没有自洽规矩")
try:
return rule(facts, result)
except _MetaError as problem:
return _blocked(facts, str(problem))
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=(
"日志里每一条被换行终结的行都解得出记录;撕裂只允许出现在最后一行。"
"一条被终结的行都没有的 run 报无法判定——空文件和「只写了半行就被杀」都落在这里,"
"没有任何一行被读回来过。"
),
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 递增,不重复不跳号。"
"一条步记录都没有的 run 报无法判定,不报通过——那种 run 上这条根本没被验过。"
),
check=_check_step_indices,
),
Invariant(
name="意图都有归宿",
description=(
"每条意图的 result_id 都能在对应的结果记录里找到;"
"至多一条悬空,且必须是日志里最后一条意图。"
"一条意图都没有的 run 报无法判定;有意图而没有任何结果记录的 run 照判,"
"那正是这条要判的那种。"
),
check=_check_intents_resolved,
),
Invariant(
name="步记录的内部不变量",
description=(
"step_completed 的 result_id 为空当且仅当 action_outcome 为空。"
"**它发现得了的击穿,「日志能被读回来」也都发现得了**——StepCompleted 在构造期就"
"守这条,所以违反它的那一行本来就解不出记录。留着这一条不是为了多一层覆盖,"
"是为了让证据直接指向那两个字段:上一条只会说「第 N 行解不出来」,拿着那句话还得"
"回去翻文件猜是哪儿对不上。它省的是排查时间,不是漏判风险。"
"一条 step_completed 载荷都没有的 run 报无法判定,下限数的是打着这个标签的行、"
"不是解出来的记录——解不出来的那些行正是它最该判的对象。"
),
check=_check_step_pairing,
),
Invariant(
name="动作结果与步记录一致",
description=(
"同一条 step_completed 里,动作结果与步记录说的是同一件事。状态与完成标记这两项"
"在三档下都必须相同。观察那几列只在 executed 一档下逐字相同,因为只有那一档是"
"执行器返回值的原样透传:动作被拒绝与环境故障这两档,库刻意不把执行器给的观察"
"回填进历史,换成合成的那一段——执行器那段是「模型看得见的东西」,每次现造的话"
"同一份配置跑出来的两次运行在模型看来其实不同。执行器的原文没有丢,它就留在同一"
"条记录的动作结果里。所以这两档改判另一件事:库既然替换了观察,就必须把步记录的 "
"observation_is_synthetic 立起来,不立才是真出了问题。"
"一条步记录都没有的 run 报无法判定。下限是一条步记录,不要求它带动作结果——"
"没有动作结果的那一档也在这条的判定范围里(那时步记录的 action_status 必须为空),"
"所以一份全是解析失败的日志确实验到了这条的一部分。"
),
check=_check_outcome_agrees_with_step,
),
Invariant(
name="提示词字符数单调不减",
description=(
"同一个 run 里步记录的 prompt_chars 随步号不回退。**它验的是库自己记下来的那个"
"数,不是历史真的没被截断过**:库要是截断了历史、却接着记一串不下降的 "
"prompt_chars,这条照样通过。记分板手上只有日志,而日志里没有真正发出去的那串"
"消息,所以那件事这一层验不到,得由压测那边包一层模型客户端、拿真实发出去的消息"
"长度对账。不足两步的 run 报无法判定——一步和零步都凑不出相邻的两个值。"
),
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 都等于文件名去掉后缀那部分。"
"两半各有各的下限:日志那半要至少一条解得出来的记录,事件那半要至少一条事件,"
"任一半没东西可验就报无法判定。一份有记录、事件却是空的产物(零步的 run,或者"
"续跑时本进程一步没走完)只判得了日志那半。"
),
check=_check_no_crosstalk,
),
Invariant(
name="停止原因与轨迹自洽",
description=(
"十个停止原因各有一条规矩。task_completed 要环境侧完成证据或带完成标记的工具;"
"step_budget 时步数等于 max_stepsaction_budget 时 executed 的步数等于 "
"max_actionsparse_failed_repeatedly 时轨迹末尾连续解析失败的步数等于 "
"max_consecutive_parse_failures,且那几步不碰环境;context_overflow 时落盘的每一"
"步的 prompt_chars 都不超过 max_prompt_chars(超限的那次装配根本不产生步记录);"
"env_error 时最后一步的 action_status 是 env_errorllm_error 时最后一步没有动作"
"状态、parse_ok 为假且 parse_error 为空(这一对是库自己用来把它和解析失败分开的);"
"agent_finished 时最终回答非空;cancelled 时日志里有结束记录;"
"resume_state_unknown 时最后一条意图悬空且声明绝不重放。"
"两种情况报无法判定:参数快照里缺某个上限;以及 context_overflow 而一步都没落盘"
"——首次装配就超限的运行正是这样,那时确实一步都没验到。"
),
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())