Commit Graph

16 Commits

Author SHA1 Message Date
iomgaa c0d9d7b66e docs: 回写 CHANGELOG、GovDoc 迁移,以及压测那条过期注释
CHANGELOG 落在「未发布」段,按那一段自己的规矩不提前写版本号。写清了四条会抛 ValueError 的
情形与迁移写法,因为这是一次破坏性变更。

migrations/govdoc-saas.md 加一节讲租户命名空间怎么传,含迁完算不算数的四条判据,最终判据是
「同一段文本由两个租户各提交一次,各自拿到自己的那份输出」。参数一律指向 0017 不复述。这份
文档原来说 GovDoc 侧「还没有可迁移的东西」,现在有了第一条能逐条验的接入动作,文件头那句
状态说明跟着补了一句例外。

tools/soak/run_soak.py 那份空绑定上方的注释在复述旧转发规则,顺手改对。它给的理由本来就不准
——让绑定留空的真正原因是绑定的全部键值都进参数快照,每批都不同的值会让故障注入那一步续跑时
报一次假的参数漂移,和转发哪些键无关。三份压测绑定常量里都没有裸键,所以这次变更打不到压测。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 11:53:20 -04:00
iomgaa c4e5732587 docs: 回写全仓库对契约套件的指向,以及 README、架构与 CHANGELOG
套件从 tests/contract/ 搬进 polyloop.testing 之后,全仓库 28 处引用要重新指过。修了 12 处,
其余在 design/(只增不改)与 scratch/(由人清理)里。

**CLAUDE.md 改了四处事实**:§0 权威表里行为契约的权威、§0 那句依赖规则的条数、§5 目录树与
模块数、§1.8 那句「谁断言公共 Protocol 的签名」。§1 的其余硬约束与 §2 的人类门一条没动。

**architecture.md**:分层图第 4 层加一格,装配层从三个变四个;代码地图加一行;第十节按代码
逐项重写——那笔「工具段渲染样式」的欠账**没有被数字对上盖掉**,加了 fingerprints 之后请求
的字段数恰好还是十一,而组成已经换过,所以那一节正面写着它仍然欠着;新增第十条依赖规则
(pytest 只在 testing 那个 extra 里,别处 import 它会让下游的生产环境一 import 本库就
ModuleNotFoundError),带静态与运行时两半;删掉「src/ 下一行代码都没有」那段过期状态说明;
决策索引补齐 0008 到 0016,其中四行原描述说的不是那份文档真正定的东西。

**migrations/dissect.md** 那笔「内存实现不存在」的欠账还掉了。

**压测那边**三条测试守的是一条已经撤销的公共契约,改名并写清它们现在守的是场景自己的选择。
AppWorld 那处刻意的偏离(不补三个反引号)留着不恢复——那条路径要模型输出被 stop 序列截断才
触发,而压测不配 stop 序列,恢复的收益不抵重跑一次压测的成本。但注释的理由改对了:它现在是
一笔有出处的欠账,不是一个决定。

CHANGELOG 攒在「未发布」段,版本号不提前写(§1.10)。
2026-08-27 03:59:39 -04:00
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
iomgaa 5a4d13b0f2 fix(soak): 按 Codex 对抗审查加固故障注入的判据
九类实跑全过之后让 Codex 专门找「判据其实验不到它声称要验的东西」,它报的每条都带具体
失败场景。这次的绿不是假的——实跑数据里这些判据都有料可判——但它们在别的输入下会假绿。

真空成立三处改成无法判定:空日志的「全都解得开」、零步的「没有 env_error」、前后都空的
「审计账没变」。上一版实跑里最后那条正是 0 vs 0 通过的。

「环境是在完整一步之后坏的」原本只有通过和无法判定两档,没有击穿分支——那条判的是注入器
自己,它对外宣称在第 N 次执行之后动手,整类故障的结论都建立在这句话上。

提示词那两条原本只读库写进日志的数,而这套东西反复强调不能拿库的自述验库的行为——它在
自己的核心主张上破了例。现在包一层模型客户端记下每次真正发出去的消息字符数,跑完逐步
对账。字符数口径与库的算法对拍过,不然会全程假击穿。

绝不重放那条补了两个角度:崩溃时账上那几条续跑之后要逐行原样还在(旧条目被改写、被抹掉
原来一路绿灯),以及续跑段里不许出现与崩溃前完全相同的条目。Codex 提的「同一文件不同
内容」在 b 档已被条数判据挡住,a 档不能加同样的规则——那一档续跑本来就该接着跑,模型
再写一次是正常行为,加了会变成随机红。

取消补了环境侧的静默判据与事件文件完整性。容器里已经发出去的执行在客户端计数上不留痕,
这个盲区如实写进已知缺口,没假装验到;崩溃形态只能落在写入调用返回之后,同样记下来。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:53:00 -04:00
iomgaa e590bea70e fix(soak): 环境客户端把传输层失败也翻成 AppWorldError,「连不上」那一档原本没实现
execute 的 docstring 一直写着「只有环境自己坏了——连不上、HTTP 非 2xx、返回体不是约定
形状——才抛 AppWorldError」,但实现里只处理了后两档。连不上、读超时、连接中途断掉时,
httpx 抛的是它自己的异常类型,而上层的动作执行接缝只认 AppWorldError,于是容器一挂整次
运行会以一个未捕获的第三方异常炸出去,正确的行为是记成环境故障、由库合成一段观察、以
env_error 收尾。

这个缺口是压测里真把容器 docker kill 掉之后才暴露的——在那之前它只写在 docstring 里。
顺带覆盖了另一个一直没验过的路径:客户端读超时同属这一类。

CancelledError 继承 BaseException,不在 httpx.HTTPError 的范围内,取消照旧原样穿过去。

修完重跑那一类,七条判据仍然全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:16:08 -04:00
iomgaa 67f0d355d0 feat(soak): 补上 context_overflow 与 env_error 两类,七类变九类
一次 193 个运行的全量跑完之后,停止原因的十个取值里有两个一次都没出现过。没出现不等于
它们是对的,只等于没验过——这正是「全绿要先怀疑负载」该指向的地方。

context_overflow 的判据不能照字面写成「最后一步的提示词超过上限」:规模判定在调模型之前
做,命中时不产生步记录,所以落盘的每条步记录必定不超上限,那样断言等于断言契约的反面。
改成判「再走一步会有多大」,公式拿全量里 865 对相邻步验过,0 处不符。

env_error 是真把容器 docker kill 掉,不是用测试替身。它自己起一个池、用另一个端口——
共用那个 size=1 的池的话,排在它后面的每一类都会跑在一个不存在的环境上。

实跑:两类都通过,击穿 0、无法判定 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:14:04 -04:00
iomgaa 5d5371792d fix(soak): 父子两侧的崩溃条件对齐,并给自杀留一个窗口
上一轮实跑里确定性自杀一次都没走到——父进程一看见日志尾部形态对上就发信号,而自杀条件
比它严一档(还要求审计账非空),于是外部信号永远抢先,自杀路径成了死代码,崩溃点又变回
碰运气。表现是崩得太早:动作还没执行过,最硬的那条判据没有实料可判。

两处对齐:父进程的命中条件也要求审计账非空(做成必传参数,给默认值等于让某个调用点静默
跳过这一条,而这正是这次出问题的方式);兜底 SIGKILL 之前先等两秒看子进程是不是自己以
137 退出。

实跑结果:两档都走确定性自杀路径,都崩在「写入执行过之后」,绝不重放那条判据在两档都有
实料——审计账 1 条、去重后仍 1 条,续跑前后也都是 1 条。整套七类击穿 0 条、无法判定 0 条。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:41:27 -04:00
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
iomgaa fe33faa3ec fix(soak): 崩溃改成子进程内部的确定性自杀,外部 SIGKILL 抢不到那个窗口
第一次实跑的结果:时机 A(一步完整落地之后崩)三次都没命中,每次都是「发信号与子进程停笔
之间又写进了记录」。原因是库写完步记录之后紧接着就写下一条意图,中间只有内存计算,窗口
窄到外部信号挤不进去。这个观察本身留在模块 docstring 里——它说明自然崩溃几乎总是落在
「有意图没结果」那一态上。

改成在子进程里包一层存储,在写入落盘返回之后按条件调 os._exit(137)。os._exit 不跑
finally、不跑 atexit、不 flush,对磁盘的效果与 SIGKILL 等价,而 JsonlRunStore 本来就
写完即 fsync,没有未刷缓冲要指望退出时替它写。外部 SIGKILL 那条路降级成兜底,没有删。

自杀条件都要求工作区审计账已经非空,即那个声明绝不重放的写入真的执行过。上一次实跑里
最硬的那条判据(绝不重放的动作没被执行两次)报的是无法判定,就是因为崩得太早、审计账
是空的,去重比对真空成立——判定器诚实地报了无法判定而不是绿,现在给它补上实料。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:20:01 -04:00
iomgaa 43971573b7 feat(soak): 故障注入——崩溃续跑、取消、撞预算、解析失败连击
这七类是压测真正要看的东西:一百个任务顺利跑完什么都证明不了,能证明东西的是这些没有
失败现场的路径上库有没有守住承诺。

判据一条都不依赖模型的确定性,全是结构不变量。最硬的那条是「声明绝不重放的动作没有被
执行两次」——证据取自环境侧自己记的账(工作区的审计文件),不取库报的步数或动作数,
后者是库对自己行为的陈述,拿它验库的行为就是我们和我们自己对账。

崩溃是真 SIGKILL 子进程,不是模拟的注入点,而且分两种时机:一步完整落地之后崩(续跑应
该真的接着跑),以及意图落盘、结果还没落盘时崩(那条意图声明绝不重放,库应该判定状态
未知、干净停下)。第二种的命中条件是「最后一条意图的重放策略是 never」而不是「最后一条
是意图」——只读工具声明的是 safe,悬在那种意图上续跑会重放接着走,判据会时对时错,而错
的那几次看起来只像模型走了别的路。

判定和记分板一样分三档,「无法判定」不折算成通过:审计账为空时「去重前后条数相等」是真
空成立的,报成通过等于把「什么都没验到」显示成绿。命中不了时机也报无法判定,不降级成
另一种时机假装验过。

调用数护栏按每类故障的边界拦,不在模型客户端里抛异常——在那里抛的话库会把它记成模型调用
失败、合成观察接着跑,护栏本身就成了一次注入进来的故障,把要验的停止原因搅乱了。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:05:05 -04:00
iomgaa a426cbbd21 feat(soak): 压测入口——预算护栏、并发上限、干跑,以及记分板要的四个产物
--budget-calls 与 --concurrency 都没有默认值:一个能跑飞的压测入口迟早会跑飞。达到调用
上限时停止派发新任务,但已经在跑的让它跑完——半路砍断会制造一批没有结束记录的日志,而
那和崩溃长得一模一样,会污染故障注入那边的判定。

--dry-run 不打模型,只把任务集列出来、把每个 RunRequest 真的装配一遍,确认容器起得来、
语料读得进、脱敏闸过得了。它也刻意不往 runs-dir 写东西,写了的话紧接着的全量会撞上
RunIdentityError。

两个场景同时跑时任务轮流排开而不是拼接:共用一份预算,拼接的话排在前面的场景会把预算
吃光,而报告看起来只是「因预算停在第 N 个任务」——一次只压了一半的跑长得像一次正常的跑。

测试里最有价值的一条是真的把产物喂给记分板:手工搭两个 GovDoc 任务共六次运行(真的走
polyloop.session.run,模型是写死的替身),再 import 记分板判定,验十一条不变量全绿。
两边的 sidecar 约定对不上的话这条会当场红。

实跑过一次干跑:两道 AppWorld 题各装配出 12.4k 字符上下文,GovDoc 两个任务六次运行全部
装配成功,脱敏替换 64 处,跑完零残留容器。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:02:53 -04:00
iomgaa 10f4a49f37 fix(soak): 给 plan 与 execute 补上完成通路,它们原本必然跑满预算
打真实模型跑通一个完整任务时发现的:这两个阶段的工具集里没有带完成标记的工具,解释器
也从不产出最终回答,于是模型在第 4 步写完产出物之后还在继续读文档,直到撞上步数上限。
按原来的 50/50/16 算,二十个任务要两千三百多次调用,而且整批的停止原因会全是
step_budget,别的什么都压不出来。

解释器加一条最终回答支路({"final_answer": "..."}),plan 与 execute 的提示词写死收尾
动作;summarize 不变,仍然只能靠 submit_finding 结束。这样三条完成路径同时在场:模型
自报最终回答、agent 调用带完成标记的工具、环境报告完成(AppWorld 那路),它们的可信度
各不相同,压测正需要这个对照。

预算随之下调到 20/25/16——实测每阶段真实用 5 到 7 步,留了一倍余量。原来那个 50 的来历
是 gov-auditor.yaml 的 turns 上限,但那边靠编排校验产物落盘来收阶段,跑满 turns 无所谓;
这里靠模型自己收尾,上限定高只会让它在产出物写完之后接着白烧。

实测:plan 7 步 agent_finished、execute 6 步 agent_finished、summarize 5 步
task_completed,一个任务 18 次调用。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:58:57 -04:00
iomgaa f277197071 feat(soak): GovDoc 形态的场景——JSON 工具调用、按次收窄、提交型完成
这条路径至今零真实负载,而它和 AppWorld 那路压的东西完全不同:动作是 JSON 工具调用而
不是代码,完成由 agent 自报(带完成标记的提交工具)而不是环境报告,工具集按阶段收窄。
一个任务拆成三次运行(plan / execute / summarize),因为「按次收窄工具集」在这个库里
只能这么表达——治理单位是一次运行,一次运行只有一个工具注册表,阶段之间靠工作区传状态。

语料是真实公文,但先脱敏再用,且原文与脱敏文本都只在内存里,一个字节不落盘、不入库。
机构名、电话、信用代码、邮箱、联系人姓名换成明显是假的稳定假名(同一原名整份文档换成
同一假名,否则模型会以为是不同主体);金额、项目编号、日期原样保留,它们是审核判断的
依据,换掉任务就没得判了。脱敏后必须过一遍独立的检出校验,有残留就拒绝启动——把未脱敏
的第三方真实信息发给外部模型服务是不可逆的,而漏一处的表现是「压测正常跑完」。

实测:主招标文件 17.5 万字符,校验函数对原文报 61 处、对脱敏后放行,六份语料全过,
40 个金额与全部日期无误伤。

write_note 与 submit_finding 声明为绝不重放,每次调用在工作区留一行审计——那份审计是
环境侧的实际执行次数证据,故障注入要验的「绝不重放的动作没有被执行两次」数的就是它。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:36:58 -04:00
iomgaa 292a936d42 feat(soak): 记分板——十一条不变量逐条判定,任一被击穿整批失败
判定分三档:通过 / 击穿 / 无法判定。第三档不许折算成通过——缺文件、缺字段导致判不了,
和判过了是两回事,混起来会让一次什么都没验的跑看起来全绿。退出码 1 是击穿,2 是只有
无法判定,--allow-undetermined 只放行后者。

守的东西:日志读得回来、跨进程的结果与内存里逐字段相等、步号连续、意图都有归宿(至多
一条悬空且必须在末尾,那正是崩溃点)、动作结果与步记录说同一件事、提示词字符数单调不
减、事件条数等于本进程走完的步数、投递失败计数对得上、并发之间不串台、停止原因与轨迹
自洽。

报告里不出现模型原文、观察、工具名与文档片段——压测语料里有第三方的真实文档,而报告
是要贴给人看的。有四条测试拿哨兵字符串验它确实漏不出去,其中一条是拿恶意工具名试出来
的:原本打算按字符白名单放行工具名,白名单恰好把哨兵放了过去。

每条不变量都配一个「构造出违反它的日志、验它确实报击穿」的用例——一个永远返回通过的
判定器比没有判定器更糟。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:27:09 -04:00
iomgaa 21a9a30ee9 feat(soak): AppWorld 场景适配器——代码围栏解释器与容器执行器
复刻 dissect 的动作协议当压测负载:两条正则、first_only 策略、五条纠错说明逐字照抄,
提示词模板连同出处一起收进 tools/soak/prompts/。有两处刻意不同,都写在代码注释里——
未闭合围栏那一支不补回三反引号(公共契约要求回填历史的文本不长于模型原文),空围栏
那一支不截断。

执行器在 execute 内部顺带问一次 is_done 填 env_reported_completion,并把环境自己数的
执行次数透出来。那个数字是故障注入的判据来源:验「声明绝不重放的动作没有被执行两次」
必须数环境侧,数库自己的计数器等于我们和我们自己对账。

端到端真跑过一道题(82e2fac_1):8 步 task_completed,环境判分通过,事件数与步数与
环境执行次数三者相等,耗时 30 秒。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:16:31 -04:00
iomgaa 4cbdb056b6 feat(soak): 压测的环境层——AppWorld 容器池与薄 HTTP 客户端
⑥ 的验收要自己造负载压,环境用 AppWorld:733 个任务与 197M 数据都在本机、docker 镜像已
拉好、而且它自带评测端点做程序化判分——换成让另一个模型判对错,等于往验收里再塞一个非
确定源,验收本身就不可复现了。

**不 import dissect**(§1.2 禁止反向 import),照它那两个文件当协议文档自己写了一份。
副作用是这反而更强:证明一个不认识 dissect 的第三方只用公共 API 就能驱动真实环境。

复刻了 dissect 记下的几个坑:httpx 必须 trust_env=False,否则本机代理会把 127.0.0.1 的
请求也劫走、表现成 502;健康检查失败先抓容器日志再删;关闭失败按端口分开计数,用全局
计数器的话坏容器的失败会被别的容器的成功清零。

放在仓库根的 tools/ 下而不是 tests/ 下:打包只收 src/,所以它不进 wheel 也不进 sdist
(实测两个产物里 soak 命中数都是 0);而 §1.9 的四层是按「依赖什么」分的,压测不属于其中
任何一层,塞进 tests/ 要么破坏分层要么和 e2e 共用同一道花钱的闸。Makefile 的检查目标
跟着加上 tools/。

冒烟真跑通:起容器、初始化真题、跨调用保持变量、错误代码返回 traceback 而不抛异常、
评测、无容器残留;另验 2 容器并发的租借与归还。

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