codegraph 的确定性测量范例:为什么"丢弃 >50% 工厂闭包范围"的改动(CG-27)被测量否决了
本文基于 codegraph 仓库中的基准报告 docs/benchmarks/explore-factory-closure-cg27.md,完整还原 CG-27 任务从"提出改动建议"到"被确定性测量否决、关闭为 obsolete"的全过程:一个跨文件(spanning)的工厂闭包范围是否会妨碍 explore 响应对文件内部闭包的细粒度选择。读完本文,你将掌握 codegraph 中 ENVELOPE_KINDS 丢弃规则、shrinkCluster 按成员收缩、集群密度平局决胜与 CG-30 行窗口化四个机制的真实协作关系,并学会用"hermetic fixture + 逐行送达比对"的测量方法,回答"这个改动该不该做"这类问题。
1. 工厂闭包:一种常见且危险的文件形态
所谓工厂闭包(factory closure),是 createFoo() 这类顶层工厂函数返回一组闭包对象、内部状态全部封闭在函数体内的写法。由于工厂函数往往覆盖文件几乎全部内容,它在代码索引中会形成一个横跨整个文件的符号范围。这不是某个仓库的怪癖,而是一类非常普遍的代码形态:Svelte 5 的 .svelte.ts rune store、React 自定义 hook 模块、IIFE / module-pattern 的 JS,以及 Zustand 的 create((set, get) => ({ … })) 都是这种写法。
CG-27 要检验的机制是 src/mcp/tools.ts#L4975-L4983 中的 ENVELOPE_KINDS 规则:当一个容器节点覆盖文件行数的一半以上时,把它从该文件的聚簇(cluster)范围中丢弃——因为保留它会把内部所有方法合并成一个跨全文件的巨型集群,最终只渲染出容器头部、埋没掉方法本体:
// src/mcp/tools.ts (L4975-L4983, L4999-L5000)
// Container kinds whose body can span most/all of a file. When such a
// node covers most of the file we drop it from the ranges: keeping it
// would merge every method inside it into one giant cluster ...
const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'union',
'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']);
// ...
// Drop whole-file envelope nodes (containers covering >50% of the file).
.filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
注意这个集合里列的是容器类型(class、struct、interface、enum…),并不包含 function 或 method。于是工厂闭包不会触发这条丢弃规则,作为跨文件范围存活下来,把内部所有闭包合并进同一个集群。
CG-27 提出的改动建议是把这条 >50% 丢弃规则扩展为"与 kind 无关",让 function 也参与丢弃。但报告指出,此前的 CG-30 任务已经约束了此类成员可花费的字节数(见 docs/benchmarks/explore-oversize-member-ab-cg30.md),所以剩下的争议是一个排名层面的主张:跨文件范围会把所有内部符号合并成一个集群,导致选择逻辑无法独立地给相关闭包排名。CG-27 的要求是:先测量这个主张,再谈改动。
2. 测量方法:确定性探针替代 agent A/B
测量工具是 scripts/agent-eval/probe-factory-closure.mjs,针对一个 hermetic fixture(tests/fixtures/factory-closure-ts/)运行。探针的确定性来自严格的复现流程:每次运行前把 fixture 复制到临时目录、删除既有 .codegraph 索引、全量重新索引,因此同一个构建跑两次会得到相同的数字(见 probe-factory-closure.mjs#L54-L61 的 cpSync/rmSync/initSync/indexAll 序列)。
报告特别说明为什么不用 agent A/B 实验:被测的主张是"一个文件内部选了哪些符号",而真实 agent 运行的噪音远大于这个粒度的差异,A/B 根本看不清。这是一个值得借鉴的方法论决策——测量对象是确定性管线(索引 → explore → 渲染),就应该在确定性管线上测。
探针的核心度量不是"输出多少字符",而是"哪些内部符号到达了 agent"。它把响应中形如 <行号>\t<文本> 的每一行与目标文件的真实源码逐字比对,只有行号与内容同时吻合才算"送达"(probe-factory-closure.mjs#L83-L93):
// 行号在散文里被引用不算数——必须文本与源行完全一致
for (const line of text.split('\n')) {
const m = /^(\d+)\t(.*)$/.exec(line);
if (!m) continue;
const n = Number(m[1]);
if (n >= 1 && n <= source.length && source[n - 1] === m[2]) delivered.add(n);
}
然后对每个内部闭包检查其定义行是否落在已送达行集合中。
3. 测量夹具:三个 store、两个服务、一个 UI 消费方
fixture tests/fixtures/factory-closure-ts/ 是一个小型 dashboard 应用:三个工厂闭包 store、两个无状态服务和 UI 消费方,竞争同一份响应预算。
| 文件 | 行数 | 形态 |
|---|---|---|
| src/stores/dashboard-store.ts | 385 | createDashboardStore 跨 15–376 行(94%),内含 11 个闭包;文件尾部有一个类型别名 + 辅助函数 |
| src/stores/alerts-store.ts | 141 | createAlertsStore 跨 19–138 行(85%),内含 9 个闭包 |
| src/stores/session-store.ts | 148 | 文件作用域里只有一个工厂,没有任何伴生类型或尾部辅助函数 |
| src/services/metric-service.ts、src/services/filter-parser.ts | 105、62 | 普通顶层函数——对照组 |
fixture 的设计意图很明确:两个工厂文件都超过了 WHOLE_FILE_MAX_LINES,因此无法走"整文件直出"路径,必须经由集群路径渲染——envelope 才真正起作用。这个阈值定义在 src/mcp/tools.ts#L4829:
const WHOLE_FILE_MAX_LINES = isCentralFile ? 280 : 220;
非中心文件的 220 行上限意味着 385 行的 dashboard-store.ts 和 148 行的 session-store.ts 中,只有前者被强制走集群路径(后者靠 fixture 中的其他竞争文件与预算约束间接施压)。
4. 结果 1:envelope 几乎一开始就选不中
shrinkCluster 是处理超尺寸集群的核心函数(src/mcp/tools.ts#L5179-L5207)。它把集群成员按 (importance 降序, size 升序) 排序,并且一旦已经保留了任何成员,任何会使累计超出 cap 的成员都会被拒绝:
// src/mcp/tools.ts (L5180-L5195)
if (c.members.length < 2) return null;
const byImportance = [...c.members].sort((a, b) =>
b.importance - a.importance || (a.end - a.start) - (b.end - b.start) || a.start - b.start);
// ...
for (const r of byImportance) {
const sz = sizeOf(r) + GAP_MARKER.length;
// Always keep the most important range, even if it alone is oversize —
// an empty section sends the agent to Read, which costs far more.
if (keep.length > 0 && kept + sz > cap) continue;
keep.push(r);
kept += sz;
}
这个顺序规则有一个直接推论:一个跨文件的 envelope 成员只有在它是第一个候选时才会被保留——而这要求它必须是顶级重要性层的唯一成员。实测的 9 种查询形态中,有 8 种都存在某个更小的成员与工厂共享同一重要性层(一行的类型别名、尾部辅助函数、另一个闭包),工厂因此排在最后,从未被保留。envelope 规则在这个 fixture 上是无效的——因为选择逻辑已经在内部完成了按符号的细粒度排名。
5. 结果 2:提议的改动是一次大退步
把 >50% 丢弃规则改为与 kind 无关,在主查询 "how does the dashboard store refresh its metrics and apply a filter" 上的测量结果:
| 指标 | baseline | 丢弃该范围后 |
|---|---|---|
dashboard-store.ts(rank #1)交付内容 |
7,539 字符 | 397 字符 |
| 送达的内部闭包定义 | 11 个中的 7 个 | 11 个中的 0 个 |
| 该文件预留(reservation)未被花掉的部分 | 0 | 5,601 中约 5,200 |
退步机制来自集群排序。集群排名在 src/mcp/tools.ts#L5425-L5439 实现:spine 集群优先,其次 maxImportance 降序,平局时按密度(score / span)决胜:
// src/mcp/tools.ts (L5433-L5438)
if (b.c.maxImportance !== a.c.maxImportance) return b.c.maxImportance - a.c.maxImportance;
const densityA = a.c.score / a.span;
const densityB = b.c.score / b.span;
if (densityB !== densityA) return densityB - densityA;
丢弃 envelope 范围后,dashboard-store.ts 被拆成两个集群:378-384(一行的类型别名 + 四行的辅助函数,score 15,span 7)和 4-362(全部闭包,score 116,span 359)。前者密度 15/7 ≈ 2.14,后者 116/359 ≈ 0.32——当两者的 maxImportance 平局时,密度平局决胜让那个琐碎集群获胜。它先被取走,而且是唯一允许被收缩(shrink)的集群——后续集群永远不会被收缩,只能整体取舍。装答案的大集群随后装不下剩余预算,被整体丢弃。
这是本报告最重要的架构洞察:那个跨文件范围正是把文件保持为一个集群的东西,而 shrinkCluster 已经在集群内部完成了 issue 所要求的按符号排名。丢弃 envelope 不是"获得细粒度",而是"摧毁了细粒度排名赖以工作的容器"。
6. 结果 3:更谨慎的同意图改动只是噪音
如果不动聚类粒度、只把 envelope 成员在 shrinkCluster 里延后处理(defer),就绕开了结果 2 的拆分。9 种查询形态、同一 fixture、同一索引下,送达的内部闭包定义数:
| 查询 | 目标 | baseline | deferred |
|---|---|---|---|
| how does the dashboard store refresh its metrics and apply a filter | dashboard | 7/11 | 8/11 |
| createDashboardStore | dashboard | 8/11 | 8/11 |
| how is the dashboard store created and wired up | dashboard | 9/11 | 8/11 |
| createDashboardStore exportCsv summarize | dashboard | 9/11 | 9/11 |
| where is the dashboard store constructed | dashboard | 7/11 | 7/11 |
| how are widgets loaded and the layout reconciled | dashboard | 4/11 | 4/11 |
| createSessionStore(不利形态:工厂是顶级层唯一成员) | alerts | 6/9 | 7/9 |
| how are alerts refreshed and acknowledged | alerts | 9/9 | 9/9 |
| createAlertsStore | alerts | 9/9 | 9/9 |
| 总计 | 68 | 69 |
在一个专门把该模式做到最大可见度的 fixture 上:一处变好、一处变差、七处不变。报告判定"这不是可测量的选择改进",于是什么都没有合入。CG-27 因此关闭为 obsolete,功劳记在 CG-30 名下。
7. envelope 何时真的会被选中——而 CG-30 已经吸收了它
上面表格里的不利形态(createSessionStore 查询、目标是 alerts store)是唯一一种排序规则无法中立化的配置:createAlertsStore 是 importance 10 层的唯一成员,于是它作为第一个候选被保留,以 3,939 字符对 2,468 的上限,所有内部闭包被跳过。
此时出场的是 CG-30 的行窗口化机制:与其整块输出或整文件丢弃,不如按整行开窗——响应携带了该文件 16–108 行,一段连续、可读的工厂头部,覆盖 9 个闭包定义中的 6 个。有界、充分、绝不为空。这正是当年 CG-27 issue 所针对的症状,且已被吸收。
实现上,这个机制由 src/mcp/tools.ts#L5222-L5243 的 headWindowOf 等窗口函数支撑:
// src/mcp/tools.ts (L5222-L5225)
const MIN_WINDOW_LINES = 12;
/** Rendered cost of one source line, line numbering included. */
const lineCost = (ln: number): number =>
(fileLines[ln - 1] ?? '').length + 1 + (withLineNumbers ? String(ln).length + 1 : 0);
窗口按"渲染成本"(含行号前缀)逐行累加,永远在整行边界上截断(正文从不被切到一半),短于 MIN_WINDOW_LINES 的残片会被直接丢弃——除非什么都没输出,此时"绝不为空"的底线优先于上限。窗口与后续部分之间的 GAP_MARKER 和行号跳跃是 agent 判断"这里被裁剪过"的信号。
8. 副产品:测量暴露的一个真实缺陷(另行立案)
结果 2 的机制并不限于假设中的改动。报告用同一探针在确定性 6 仓库套件上巡检"丢弃了某个集群、却把大部分预留闲置"的文件:
| 文件 | 预算 | 已花 | 闲置 | 保留的集群 | 丢弃的集群 |
|---|---|---|---|---|---|
django/db/models/sql/query.py |
10,135 | 1,923 | 8,212 (81%) | 1379–1400,score 14 | 306–929,score 290 |
okhttp .../RealInterceptorChain.kt |
6,058 | 1,474 | 4,584 (76%) | 16–44,score 44 | 113–373,score 171 |
okhttp .../Interceptor.kt |
4,697 | 2,027 | 2,670 (57%) | 85–138,score 21 | 154–257,score 10 |
gin/routergroup.go |
5,782 | 3,273 | 2,509 (43%) | 33–91,score 116 | 103–188,score 128 |
模式清晰:一个按密度排名的顶级集群如果是琐碎的,它会赢下预算,而携带 20 倍 score 的集群被整体丢弃,文件自己的预留也大部分闲置——根源仍是"只有首个被选中的集群允许被收缩"这条规则。其中 query.py 正是 codegraph 的 CLAUDE.md 点名的 _fetch_all 案例文件(见 CLAUDE.md)。这个发现说明:否定一个提议改动的测量过程,顺带审计出了存量缺陷的分布。
9. 常设门禁:pin 住结果,而不是 pin 住机制
与报告配套的门禁测试是 tests/explore-factory-closure.test.ts。它的设计哲学写在文件头注释里:"this file pins the OUTCOME, not the mechanism"——无论未来谁对聚类做了什么改动,工厂闭包文件必须继续把内部闭包送达 agent,这才是阻止 agent 回退到整文件 Read 的底线。
门禁分两层。先自证 fixture 没有腐化(否则下面的断言没有意义):
// __tests__/explore-factory-closure.test.ts (L104-L121)
expect(factory!.kind).toBe('function');
expect(factory!.endLine - factory!.startLine + 1)
.toBeGreaterThan(sourceLines.length * 0.5); // 恰好是 >50% envelope 条件
expect(innerClosures().length).toBeGreaterThanOrEqual(8);
// 超过 WHOLE_FILE_MAX_LINES,确保走集群路径
expect(sourceLines.length).toBeGreaterThan(220);
expect(report.files.find((f) => f.path === TARGET)?.render).toBe('clusters');
再锁结果(tests/explore-factory-closure.test.ts#L124-L156):
- 查询点名的
refreshMetrics、applyFilter两个闭包的定义行必须被送达; - 内部闭包送达率不低于半数(feature/CG-24 分支测得 7/11),且送达不能只堆在工厂头部——最深处被送达的闭包必须越过首尾闭包定义行的中点;
- 该文件的响应段绝不能为空(
emittedChars > 0); - 总响应不超过硬上限(
report.envelope.chars <= report.budget.hardCeiling)。
送达判定与探针脚本同一套规则(行号 + 文本逐字匹配,L78-L87),保证"散文里引用过的行号不算数"。
10. 复现步骤
npm run build
node scripts/agent-eval/probe-factory-closure.mjs # 主查询
node scripts/agent-eval/probe-factory-closure.mjs \
--target src/stores/alerts-store.ts --factory createAlertsStore \
--query "createSessionStore" # 不利形态
npx vitest run __tests__/explore-factory-closure.test.ts # 常设门禁
探针还支持 --json 输出(probe-factory-closure.mjs#L129-L146),会打印每个文件的 rank / render / emitted / final、目标文件的送达行区间、以及逐闭包的送达清单;--target / --factory / --query 三个参数分别切换目标文件、工厂名和查询文本,默认值即主查询配置。
11. 结论:测量先行的判断框架
CG-27 是一个"用测量否决改动"的完整范例,其方法论可以迁移到任何预算敏感的选择系统:
- 改代码前先测量主张本身。issue 的真正问题不是"envelope 该不该丢",而是"不丢它有没有造成可观测的损失"——后者是可以用确定性探针回答的,前者则不必动手。
- A/B 实验有边界。当被测信号(文件内符号选择)的粒度低于 agent 运行噪音时,切换到确定性 harness 是唯一可行的测量路径。
- 大容器范围可能是结构而非噪声。
ENVELOPE_KINDS对function的"漏掉"实际上是整个选择管线的支点:集群是排名和收缩的基本单元,拆掉跨文件范围等于拆掉单元本身。 - 门禁 pin 结果而不是机制。tests/explore-factory-closure.test.ts 不锁死任何实现细节,只锁死"工厂闭包文件必须持续交付内部闭包"这一结果,为后续演进(如 docs/benchmarks/explore-tail-render-cg38.md 所述的尾渲染保护)留出了空间。
- "只有首个集群可收缩"是结构性风险点。结果 2 与第 8 节的存量缺陷共享同一根源:密度平局决胜让琐碎集群赢下预算后,高分集群只能整体被丢、预留大量闲置。这条规则在 src/mcp/tools.ts#L5425-L5439 的排序之后生效,是 codegraph explore 管线中值得持续关注的约束。
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 StartedRust0624
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