fix(soak): 合成观察那两档不该逐字比对,判据写错了
193 次真实运行报了 9 处击穿,核下来是判据的错不是库的错:动作被拒绝或环境故障时,库 刻意不把执行器给的观察回填进历史,而是换成合成观察那一段。两个字段承载的本来就不是同 一件事——一个是执行器原文的留痕,一个是真正喂给模型的文本。 改判据时顺着源码发现被替换的是三列不是一列:观察文本、是否合成、截断字符数在那两档下 全部由库填。所以旧判据在环境故障那一档上必然也会误报,只是这 193 次里没撞上真的环境 故障;截断数那一列则是恰好两边都是 0,潜伏着没炸。 现在 executed 档三列仍然逐字比对,另两档改成断言步记录的「是否合成」标记确实立起来了—— 库既然替换了观察,不立这个标记才是真出了问题。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user