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
+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:
for item in scoreboard.invariants:
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), "动作结果与步记录一致")
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:
materialize(tmp_path, steps=3)