CodeGraph explore 的端到端预留不变式:CG-26 如何修复渲染预算的三个记账漏洞(附 A/B 基准与源码证据)
本文解析 CodeGraph 项目 codegraph_explore 工具渲染层的核心预算机制——"预留不变式"(reservation invariant):每个被分配器接纳的文件,在任何文件动用结余余量之前,必须至少拿到它被预留的字节数。读完本文,你将理解 CG-26 任务修复的三个记账漏洞(整文件路径缺移位移守卫、节开销按 200 字符平摊、owedPayableBelow 全有全无)、epilogue 从"整段丢弃"到"预算制"的重构方案,并能用仓库自带的探针脚本与回归测试复现验证全部结论。
一、预留不变式与实验设定
CG-26 不是孤立变更,它收口的是 explore 预算分配 epic(CG-10 相关性打分 → CG-12 按权重预留 → CG-21 预留结转 → CG-30 超大簇成员上界 → CG-31 簇路径移位移守卫)遗留下来的三个洞。不变式的完整表述是:
every admitted file receives at least its reservation before any file draws on carry-forward slack —— 每个被接纳的文件在任何文件动用"结转余量"之前,先拿到至少它的预留额。
实验设定决定了表中数字的可信度(引自原文档头部):
- 日期/版本:2026-08-06;新分支
bugfix/CG-26@7cbde95,基线为bugfix/CG-31尖头c54e008(刻意不用main,使所有数字只隔离 CG-26 一个变量); - 测试框架:
scripts/agent-eval/ab-new-vs-baseline.sh,--model sonnet --effort high; - 控制条件:两臂均开启 codegraph、CLI 被屏蔽(全程 0 污染)、
CODEGRAPH_NO_PROMPT_HOOK=1; - 索引状态:所有被测索引均为完全重建,从未增量同步(CG-33 的要求)。
按文档要求,三篇应顺序阅读:CG-30 超大成员 A/B → CG-31 移位移守卫 A/B → 本文。
结论先行:三个仓库无行为回归,且响应第一次对自己的预算保持诚实。 没有仓库被截断,没有仓库丢失文件——okhttp 反而多交付一个文件。两个仓库在最后一名次的文件上损失几百字符源码,换来的是"指针列表"(明确点名本次响应没能覆盖哪些文件)——这些字节在 CG-31 尖头上之所以存在,是因为旧代码超填了一个自己测错的天花板,然后连 epilogue 整段丢弃。
二、三个记账漏洞
漏洞 1:整文件路径没有移位移守卫
旧实现中,BUY 臂(整文件购买规则)的适装测试读的是 totalChars + fileContent.length + FILE_OVERHEAD <= renderCeiling——衡量的是"天花板之前还有多少空间",而那段空间属于循环尚未到达的所有文件;它自己的源码空间姊妹变量(owedBelow)恰恰拒绝了这笔交易。GRACE 臂(整文件宽限规则)则完全没有做适装测试。
实测后果(okhttp 仓库):CallServerInterceptor.kt 以 8,499 字符发出,而它的资金上限(funded ceiling)只有 5,964,它下方第 6 名次的文件什么都没拿到。
CG-26 的修复:两个整文件臂都改为对"它们实际产生的渲染"与 fundedHeadroom 做适装测试;整文件渲染装不下时落入簇路径而不是跳过该文件——用一节簇化的内容换取没有内容,正是资金池设计所要拒绝的交易。
源码印证:src/mcp/tools.ts#L4884-L4927 中,BUY 臂的适装条件为 fileContent.length <= fundedHeadroom,整节渲染需同时满足 wholeSection.length <= fundedHeadroom && totalChars + wholeCost <= renderCeiling,并附带注释说明 GRACE/BUY 两臂现在都"测试它们实际产生的渲染";src/mcp/tools.ts#L4721-L4726 的簇路径同样被约束到 fundedHeadroom(bodyCap = Math.min(allowance, fundedHeadroom))。
漏洞 2:节开销按固定 200 字符记账
真实的节头——文件路径加上至多 maxSymbolsInFileHeader 个符号名——实际运行在 300–500 字符。而下游一切量纲(headroom、fundedHeadroom、所有适装测试)都建立在这些单位之上,所以这个少计不是舍入误差,而是用不存在的字节资金化承诺。
实测后果(okhttp):循环按 26,601 字符做分配,天花板只有 24,400,最终截断把一整个已完整渲染的节扔掉了。
CG-26 的修复分三步:
- 节按其真实成本收费。src/mcp/tools.ts#L4370-L4382 的
sectionOverhead()从该文件自己的候选符号出发,估算"文件头(路径 + 符号名列表)+ 代码围栏 + 空行"的真实长度,并用overheadCache缓存。源码注释直接点破了旧行为的性质:"使用分配器的FILE_OVERHEAD(固定 200)是每待处理文件约 250 字符的范畴错误(category error)"; owedPayableBelow扣留每个待处理文件的预留额 加上 按其自身符号估算的单文件开销(src/mcp/tools.ts#L4402-L4426 中need = r + overhead);- 边际超支时修剪最弱的簇——或把最后一个簇开窗到剩余空间里——而不是因为约 300 字符的记账差异就跳过整个文件。
注意区分两个常量:EXPLORE_ALLOCATION.FILE_OVERHEAD: 200(src/mcp/tools.ts#L561)仍是分配器拆分信封时的平摊常量;CG-26 只修正了渲染循环一侧的记账。
漏洞 3:owedPayableBelow 全有全无
CG-31 的判断"天花板够不到的承诺不是对本文件字节的索取"是对的,但它丢掉了部分情形:当最后一个被接纳文件的 FULL 预留不再装得下时,旧代码为它保留的是零。
实测后果(precise-query 测试夹具):第 5 名文件在 2,948 的预留下拿走了 4,134 字符,而第 6 名——已被接纳、预留 2,539——只剩 4 个字符被跳过。
CG-26 的修复是保留剩余部分,只要该剩余仍值一个节(不低于 MIN_CHARS)。源码即 src/mcp/tools.ts#L4410-L4421:当 held + need > budgetLeft 时计算 partial = budgetLeft - held,若 partial >= MIN_CHARS + overhead 则 held += partial 后 break。注释解释了为何卡在 MIN_CHARS(EXPLORE_ALLOCATION.MIN_CHARS = 700,src/mcp/tools.ts#L548):低于它的切片装不下一个完整方法,碎片反而逼出本工具要阻止的 Read 回退。
三、Epilogue:从"整段丢弃"到"分层预算制"
CG-31 移交的问题:渲染循环为 epilogue 预留了固定 600 字符,而它实测为 1,064(gin)、1,788(django)、2,231(excalidraw);六个套件仓库中四个靠整段丢弃 epilogue 存活——响应里既没有指针列表也没有任何提醒。
团队跑过一遍 margin 扫描,但刻意没有采纳:对照单个套件调一个常量,正是 CG-30 记录里警告过的陷阱。
修复不是更大的常量,而是把 epilogue 拆成两件事:
- 地板(floor):渲染循环必须预留的部分,尺寸取自真实字符串——一句话声明"存在未覆盖区域、另一次 explore(而非 Read)能到达它"(即 src/mcp/tools.ts#L876 的
EPILOGUE_LOST_NOTE),加上每个字节被刻意 WITHHELD 的文件的指针行。被悬崖文件(cliffed file)的字节是在"agent 仍能在后续调用中命名它"(CG-12 的承诺)上被交换走的;如果天花板连这个名字都吃掉了,这笔交易就是一次静默丢弃; - 弹性尾部(elastic tail):指针列表的剩余部分与提醒条目,在循环结束后按优先级、逐条适配到实际剩余的空间里。
于是饱和响应现在能"付得起多少 epilogue 就带多少",而不是零。实现上 renderCeiling = hardCeiling − floor 取代了 hardCeiling − 600,其中 src/mcp/tools.ts#L4273-L4285:
const cliffPointerFloor = [...cliffedFiles]
.slice(0, POINTER_MAX_FILES) // 指针列表仍封顶 10 个文件
.reduce((n, fp) => {
const g = fileGroups.get(fp);
return g ? n + pointerLineFor(fp, g.nodes).length + 1 : n;
}, cliffedFiles.size > 0 ? POINTER_HEADER.length + 2 : 0);
const epilogueFloor = EPILOGUE_LOST_NOTE.length + 2 + cliffPointerFloor;
const renderCeiling = hardCeiling - epilogueFloor;
而 hardCeiling 本身定义为 Math.min(Math.round(budget.maxOutputChars * 1.5), 25000)(src/mcp/tools.ts#L4257)——它必须压在宿主(如 VSCode)约 25K 的内联工具结果限制之下,超过则结果会被外部化成文件让 agent 再 Read 回来(n=4 A/B 中一次 35K 的 vscode explore 就这么发生过)。
四、确定性测量:主要证据
同一份干净重建索引、同一查询、两个构建,每仓库一次 codegraph_explore。用 node scripts/agent-eval/probe-suite-envelope.mjs 复现(该脚本由 CG-26 任务新增,见 scripts/agent-eval/probe-suite-envelope.mjs)。它跑的就是 CG-30/CG-31 表格所用的同六个查询(django / tokio / okhttp / excalidraw / gin / alamofire),数字来自 CG-4 诊断(CODEGRAPH_EXPLORE_DEBUG 旁路 jsonl),因此度量的是发货的分配器本身,而不是从 markdown 反推份额:
| 仓库 | 基线源码字符 | 新构建源码字符 | Δ | 基线文件数 | 新文件数 | 天花板行为 |
|---|---|---|---|---|---|---|
| django | 20,791 | 20,878 | +87 | 6 | 6 | 原本丢弃 epilogue |
| tokio | 21,521 | 21,607 | +86 | 5 | 5 | 原本丢弃 epilogue |
| okhttp | 19,034 | 18,870 | −164 | 5 | 6 | +1 文件交付;保住指针列表 |
| excalidraw | 20,204 | 19,652 | −552 | 8 | 8 | 保住指针列表 |
| gin | 10,776 | 10,776 | 0 | 4 | 4 | 逐字节一致 |
| alamofire | 11,662 | 11,662 | 0 | 2 | 2 | 逐字节一致 |
两个负值要诚实地读:它们不是饥饿(starvation),恰恰相反——在 CG-31 尖头上,两个响应都是超填(over-filled)的:循环少计了自己的节开销,花过了渲染天花板,硬天花板切割随后把 epilogue 拿走抵账;okhttp 还有一个文件被渲染后又丢弃。现在记账精确了,循环停在它声明会停的位置,省下的约 500 字符给了"点名响应没能覆盖哪些文件"的指针列表(excalidraw 上是 2 个,都是 max-files 跳过)。两者都没有任何被接纳的文件挨饿。
gin 与 alamofire 在两构建间逐字节一致——它们都不饱和,所以守卫和 epilogue 适配都没有触发。这正是对照组应当展示的样子。
五、回归夹具:不变式如何在 CI 里被钉住
主夹具 tests/explore-reservation-invariant.test.ts(14 个测试,在 CG-31 尖头上有 3 个失败)基于共享的 displacement-ts 测试夹具(四个流水线阶段争抢一个信封,其一为约 20K 字符的单函数,外加 520 个填充文件把响应压到 24K 档位,使预留真正饱和天花板)。它用三种查询形状从双向钉住不变式:
spread:四个阶段全点名,巨型文件排 #1 且向下超支;tail:巨型文件下方阶段被点名,小而排 #1 的文件与下方的巨型文件竞争——这是 CG-31 夹具够不到的方向;precise:单符号查询,守卫不能压平的集中度情形。
核心闸门(测试文件 L147-L219):
- CG-26 GATE:任意渲染路径(簇 / 整文件宽限 / 整文件购买)上,没有文件发出超过"当时仍空闲"的字节(
emittedChars <= funded + 1,+1 容忍开窗切割的舍入); - CG-26 GATE:每个被接纳文件(
allowance > 0)都被交付,skipped为 null 且响应中实际存在其字节; - 方向性:#1 文件即使下方文件超支也拿到其预留额(或整文件,若整文件更小),并断言该闸门非空洞——
tail形状下确实存在超支者; - 天花板不得再做两件事:分配越过自身(
allocatedChars <= hardCeiling且truncated === false);渲染后丢弃(render === 'dropped'必须为空)。
CG-31 的 tests/explore-displacement-guard.test.ts(11 个测试)继续原样通过。
分配夹具 scripts/agent-eval/allocation-fixtures.json:两个夹具全部 PASS。self-query 闸门换了形态,原因记录在 afterCG26 块中:按信封计价的 answerShareAtLeast 在此读 47.5%,而 CG-31 尖头是 51.0%——但 tools.ts 的源码字节在两臂间逐字节一致。真正移动的是分母:新响应交付了第五个被接纳文件(memory-budget.ts,名次 4,付满了其 3,123 字符的完整预留;CG-31 尖头渲染了它却让天花板丢弃了整个节),并保住了过去会被丢弃的 epilogue 散文。两者都是本 epic 存在的目的。闸门现改为以交付源码计价,答案组读 55.5%,两臂均通过。
六、Agent A/B 运行
提示词 = 确定性查询 + "Trace the flow end to end.",每臂 2 次运行(引自原文档表格):
| django new | django base | excalidraw new | excalidraw base | okhttp new | okhttp base | |
|---|---|---|---|---|---|---|
| 运行数 | 2 | 2 | 2 | 2 | 2 | 2 |
| 时长 (s) | 42 | 43 [36–50] | 53 [45–62] | 45 [37–54] | 49 [39–59] | 42 [32–52] |
| 工具调用 | 4 [3–4] | 4 [3–5] | 3 [2–4] | 5 [4–5] | 4 | 4 [3–5] |
| Read | 0 | 0 | 0 | 0 | 0 | 0 |
| Grep/Glob | 0 | 0 | 0 | 0 | 0 | 0 |
| codegraph 调用 | 3 [2–3] | 3 [2–3] | 3 [2–3] | 4 [3–4] | 3 | 3 [2–4] |
| 上下文占用份额 | 36.6% | 37.6% | 38.2% | 47.2% | 44.8% | 40.8% |
| 分配效率 | 99.2% | 94.7% | 81.9% | 82.5% | 75.8% | 87.6% |
全部 12 次运行、两臂之中 Read 均为 0。 按调用汇总的充分性检查:0 次"Read 我们已返回的文件",所有仓库两臂均 0 次召回缺失——交付少几百字符的响应并没有把 agent 逼回文件。
新臂看似更差之处,及为何不读作回归:
- okhttp 分配效率 75.8% vs 87.6%,占用 44.8% vs 40.8%:新臂信封是 99,411 字符对 89,297——它多返回一个文件、总体源码更多,而该指标度量的是"返回字节中答案引用的份额"。同一笔交易在 CG-31 记录中已在该仓库注明;该指标自身文档说明它是相对量,不得读作浪费;
- excalidraw 与 okhttp 的时长:n=2 且区间完全重叠(45–62 vs 37–54;39–59 vs 32–52),测量机同时还在跑另一臂的构建。excalidraw 新臂用 3 次工具调用完成同样的工作(对照 5 次),并少占用 9 个点的上下文。
七、残留与刻意未改之处
本任务无残留。渲染循环预算现在端到端精确:
totalChars计入flow.text(真实逐节成本)与 epilogue 地板;- 每个套件仓库上
allocatedChars ≤ hardCeiling; - 最终的节边界截断在正常操作中已不可达(保留为 backstop——源码见 src/mcp/tools.ts#L5897-L5910 的最终切割路径,仅在
output.length > hardCeiling时触发)。
一项刻意未改:指针列表仍封顶 10 个文件(POINTER_MAX_FILES,src/mcp/tools.ts#L853)。修剪从名次序列底部进行,且 "+N more files" 尾部被重写为逐条承认每个被丢弃的条目,所以数量永远不会静默出错。
八、小结:这套不变式值得复用的工程判断
CG-26 的价值不在某一行修复,而在它确立的记账纪律,全部有源码与测试锚点可查:
- 任何适装测试必须对"本文件有权花的"(
fundedHeadroom)而非"天花板前的公共空间"做——否则单个文件的超额会隐性地吃掉低名次文件的预留(漏洞 1,src/mcp/tools.ts#L4486-L4490); - 记账单位必须与被计量的真实渲染一致——固定 200 字符的开销假设让循环"相信自己拥有不存在的字节"(漏洞 2,src/mcp/tools.ts#L4370-L4382);
- 部分承诺优于零承诺,但有下限——
MIN_CHARS以下的碎片比指针更糟(漏洞 3); - 不要对着单一套件调常量——margin 扫描被刻意放弃,地板改为从真实字符串量出(epilogue 重构);
- 对照组要真的没动——gin/alamofire 逐字节一致是"守卫未误伤未饱和响应"的直接证据。
复现入口汇总:确定性六仓库包络扫描用 scripts/agent-eval/probe-suite-envelope.mjs(需先 npm run build 且索引为全量重建);不变式回归闸门跑 tests/explore-reservation-invariant.test.ts;分配份额夹具用 node scripts/agent-eval/probe-allocation.mjs 消费 scripts/agent-eval/allocation-fixtures.json;Agent A/B 框架为 scripts/agent-eval/ab-new-vs-baseline.sh。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00