fix(soak): 合成观察那两档不该逐字比对,判据写错了

193 次真实运行报了 9 处击穿,核下来是判据的错不是库的错:动作被拒绝或环境故障时,库
刻意不把执行器给的观察回填进历史,而是换成合成观察那一段。两个字段承载的本来就不是同
一件事——一个是执行器原文的留痕,一个是真正喂给模型的文本。

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 10:35:25 -04:00
parent fe33faa3ec
commit a641a1fc11
2 changed files with 182 additions and 18 deletions
+39 -5
View File
@@ -57,6 +57,7 @@ from polyloop.serialization import (
) )
from polyloop.stores import RECORD_KEY from polyloop.stores import RECORD_KEY
from polyloop.types import ( from polyloop.types import (
ActionStatus,
Intent, Intent,
IntentKind, IntentKind,
ModelCallResult, ModelCallResult,
@@ -718,6 +719,10 @@ def _check_outcome_agrees_with_step(facts: RunFacts, config: CheckConfig) -> Che
两者一次原子落地,所以它们不可能来自两次不同的执行。对不上说明装配那一层把某一侧 两者一次原子落地,所以它们不可能来自两次不同的执行。对不上说明装配那一层把某一侧
填错了,而这种错在轨迹里完全看不出来——两个字段各自都合法。 填错了,而这种错在轨迹里完全看不出来——两个字段各自都合法。
**观察那三列只在 `executed` 一档上是原样透传,另外两档不是**,判据必须跟着分档,
理由写在 `Invariant` 的说明里(那段会进报告)。这条一开始按「三档都逐字相同」写,
在 193 次真实运行上报了 9 处击穿,核下来全是判据错、不是库错。
""" """
del config del config
breaches: list[Evidence] = [] breaches: list[Evidence] = []
@@ -735,23 +740,30 @@ def _check_outcome_agrees_with_step(facts: RunFacts, config: CheckConfig) -> Che
) )
) )
continue continue
pairs = ( passthrough = outcome.status is ActionStatus.EXECUTED
pairs = [
("action_status", outcome.status, record.step.action_status), ("action_status", outcome.status, record.step.action_status),
( (
"env_reported_completion", "env_reported_completion",
outcome.env_reported_completion, outcome.env_reported_completion,
record.step.env_reported_completion, record.step.env_reported_completion,
), ),
]
if passthrough:
# 只有这一档,步记录上的观察三列是执行器返回值的原样透传。
pairs.append(
( (
"observation_is_synthetic", "observation_is_synthetic",
outcome.observation_is_synthetic, outcome.observation_is_synthetic,
record.step.observation_is_synthetic, record.step.observation_is_synthetic,
), )
)
pairs.append(
( (
"observation_truncated_chars", "observation_truncated_chars",
outcome.observation_truncated_chars, outcome.observation_truncated_chars,
record.step.observation_truncated_chars, record.step.observation_truncated_chars,
), )
) )
for name, from_outcome, from_step in pairs: for name, from_outcome, from_step in pairs:
if from_outcome != from_step: if from_outcome != from_step:
@@ -764,19 +776,33 @@ def _check_outcome_agrees_with_step(facts: RunFacts, config: CheckConfig) -> Che
actual=f"动作结果 {from_outcome!r},步记录 {from_step!r}", actual=f"动作结果 {from_outcome!r},步记录 {from_step!r}",
) )
) )
if passthrough:
if outcome.observation != record.step.observation: if outcome.observation != record.step.observation:
breaches.append( breaches.append(
Evidence( Evidence(
run_id=facts.run_id, run_id=facts.run_id,
line_no=number, line_no=number,
step_idx=record.step.step_idx, step_idx=record.step.step_idx,
expected="步记录的 observation 与动作结果的同名字段相同", expected="executed 档下步记录的 observation 与动作结果的同名字段逐字相同",
actual=( actual=(
f"文本不同(长度 {len(outcome.observation)} vs " f"文本不同(长度 {len(outcome.observation)} vs "
f"{len(record.step.observation)}" 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))) return CheckOutcome(breaches=tuple(_cap(breaches, facts.run_id)))
@@ -1103,7 +1129,15 @@ INVARIANTS: tuple[Invariant, ...] = (
), ),
Invariant( Invariant(
name="动作结果与步记录一致", name="动作结果与步记录一致",
description="同一条 step_completed 里,动作结果与步记录的同名字段说的是同一件事。", description=(
"同一条 step_completed 里,动作结果与步记录说的是同一件事。状态与完成标记这两项"
"在三档下都必须相同。观察那几列只在 executed 一档下逐字相同,因为只有那一档是"
"执行器返回值的原样透传:动作被拒绝与环境故障这两档,库刻意不把执行器给的观察"
"回填进历史,换成合成的那一段——执行器那段是「模型看得见的东西」,每次现造的话"
"同一份配置跑出来的两次运行在模型看来其实不同。执行器的原文没有丢,它就留在同一"
"条记录的动作结果里。所以这两档改判另一件事:库既然替换了观察,就必须把步记录的 "
"observation_is_synthetic 立起来,不立才是真出了问题。"
),
check=_check_outcome_agrees_with_step, check=_check_outcome_agrees_with_step,
), ),
Invariant( Invariant(
+130
View File
@@ -221,6 +221,41 @@ def edit_log(path: Path, edit: Callable[[list[dict]], list[dict]]) -> None:
) )
def reshape_step(
path: Path,
*,
step_idx: int,
status: str,
outcome_observation: str,
step_observation: str,
outcome_is_synthetic: bool = False,
step_is_synthetic: bool = False,
outcome_truncated: int = 0,
step_truncated: int = 0,
) -> None:
"""把某一步改写成「执行器那侧与步记录那侧的观察不是同一段」的样子。
`build_records` 造出来的每一步都是 executed 且两侧逐字相同,而真实产物里被拒绝的动作
与环境故障那两档不长这样——库会替换回填进历史的观察。反例都从这里造。
"""
def edit(items: list[dict]) -> list[dict]:
for item in items:
if item["record"] != "step_completed" or item["step"]["step_idx"] != step_idx:
continue
item["action_outcome"]["status"] = status
item["action_outcome"]["observation"] = outcome_observation
item["action_outcome"]["observation_is_synthetic"] = outcome_is_synthetic
item["action_outcome"]["observation_truncated_chars"] = outcome_truncated
item["step"]["action_status"] = status
item["step"]["observation"] = step_observation
item["step"]["observation_is_synthetic"] = step_is_synthetic
item["step"]["observation_truncated_chars"] = step_truncated
return items
edit_log(path, edit)
def verdict_of(scoreboard, name: str) -> Verdict: def verdict_of(scoreboard, name: str) -> Verdict:
for item in scoreboard.invariants: for item in scoreboard.invariants:
if item.name == name: if item.name == name:
@@ -464,6 +499,101 @@ def test_outcome_disagreeing_with_step_record_is_a_breach(tmp_path: Path) -> Non
assert_breached(evaluate(tmp_path), "动作结果与步记录一致") assert_breached(evaluate(tmp_path), "动作结果与步记录一致")
def test_executed_step_with_a_different_observation_is_a_breach(tmp_path: Path) -> None:
"""executed 档是原样透传,两侧观察必须逐字相同。"""
materialize(tmp_path)
reshape_step(
tmp_path / "soak-0001.jsonl",
step_idx=0,
status="executed",
outcome_observation="环境返回的原文",
step_observation="换了一段别的",
)
assert_breached(evaluate(tmp_path), "动作结果与步记录一致")
@pytest.mark.parametrize(
("status", "executor_text", "executor_is_synthetic"),
[
(
"not_executed",
"工具不存在:'final_answer',本次可见的是 "
"['read_document', 'grep_document', 'write_note']",
True,
),
("env_error", "容器没了:connection refused", False),
],
)
def test_replaced_observation_is_not_a_breach(
tmp_path: Path, status: str, executor_text: str, executor_is_synthetic: bool
) -> None:
"""未执行与环境故障两档,库刻意换掉回填进历史的观察,两侧文本不同是正常的。
数据形状照 `tools/soak/runs/full/govdoc-15-execute.jsonl` 第 36 行那条真实记录造:
执行器那侧留的是「工具不存在」的原文,步记录那侧是合成的那段提示。截断数也一起换掉
(库在这两档下一律填 0),所以它同样不该被比对。
"""
materialize(tmp_path)
reshape_step(
tmp_path / "soak-0001.jsonl",
step_idx=0,
status=status,
outcome_observation=executor_text,
step_observation=(
"[动作被拒绝,这一步没有执行任何工具]\n"
"请对照工具清单检查工具名与参数,然后重新输出一个 JSON 对象。"
),
outcome_is_synthetic=executor_is_synthetic,
step_is_synthetic=True,
outcome_truncated=40,
step_truncated=0,
)
assert verdict_of(evaluate(tmp_path), "动作结果与步记录一致") is Verdict.PASSED
@pytest.mark.parametrize("status", ["not_executed", "env_error"])
def test_replaced_observation_without_the_synthetic_flag_is_a_breach(
tmp_path: Path, status: str
) -> None:
"""库既然替换了观察,就必须把 observation_is_synthetic 立起来,不立才是真出了问题。"""
materialize(tmp_path)
reshape_step(
tmp_path / "soak-0001.jsonl",
step_idx=0,
status=status,
outcome_observation="执行器给的原文",
step_observation="库换上去的那一段",
outcome_is_synthetic=False,
step_is_synthetic=False,
)
assert_breached(evaluate(tmp_path), "动作结果与步记录一致")
@pytest.mark.parametrize("status", ["not_executed", "env_error"])
def test_status_still_has_to_agree_in_every_branch(tmp_path: Path, status: str) -> None:
"""状态与完成标记这两项三档下都比:观察分档,它们不分。"""
materialize(tmp_path)
reshape_step(
tmp_path / "soak-0001.jsonl",
step_idx=0,
status=status,
outcome_observation="执行器给的原文",
step_observation="库换上去的那一段",
outcome_is_synthetic=True,
step_is_synthetic=True,
)
edit_log(
tmp_path / "soak-0001.jsonl",
lambda items: [
{**item, "step": {**item["step"], "action_status": "executed"}}
if item["record"] == "step_completed" and item["step"]["step_idx"] == 0
else item
for item in items
],
)
assert_breached(evaluate(tmp_path), "动作结果与步记录一致")
def test_prompt_chars_going_backwards_is_a_breach(tmp_path: Path) -> None: def test_prompt_chars_going_backwards_is_a_breach(tmp_path: Path) -> None:
materialize(tmp_path, steps=3) materialize(tmp_path, steps=3)