diff --git a/README.md b/README.md index ca26a62..1401e95 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,10 @@ | | 快模式(回归判定) | 全量模式(设计体检) | |---|---|---| | 用户在问 | 「有没有回归」「能不能合」「改坏了吗」 | 「这设计怎么样」「还有哪些坑」「按七维审」 | -| 做什么 | 读全 + 证伪 | 读全 + 类型 playbook + 七维 + 证伪 | +| 做什么 | 读全 + 证伪 | 读全 + 反驳 + 类型 playbook + 七维 + 规模扫描 + 证伪 | | 输出 | 紧凑列表,每条 2~3 行;零发现一句话 | 六段式 + 维度覆盖表 | -两档**共用**「读全」与「证伪」,不重复劳动。拿不准就走全量——全量的产出可以裁成快模式,快模式的产出补不回全量。 +两档**共用**「读全」与「证伪」,不重复劳动。**反驳与规模扫描只在全量模式跑**——它们找的是设计与算法层面的问题,不是「这次改动有没有坏」。拿不准就走全量——全量的产出可以裁成快模式,快模式的产出补不回全量。 快模式的完整契约(范围、只报什么、输出形态、什么时候升级到全量)见 `references/regression-review.md`。 @@ -53,13 +53,26 @@ | 2 | 方法契约 | 参数是否遗漏?缺省值是否有歧义(没传 vs 传了零值)?调用方会不会传错、漏传,或被迫绕开函数自己拼? | | 3 | 实现 | 在什么输入、状态或并发交错下会算错、漏处理、泄漏、死锁? | | 4 | 文档 | 读者照着注释/README/错误信息会不会做错事? | -| 5 | 硬编码 | 哪个值本该随环境/租户/调用方/数据规模/版本变化,却被写死在代码里? | -| 6 | 重复 | 同一行为是否存在第二个真相源? | +| 5 | 硬编码 | 哪个值被写死了,而它应当可变?先分清它是**协议固定值**(改了就是 bug)还是**业务政策值**(改了只是口径变了,如退款窗口、账期)? | +| 6 | 重复 | 同一行为是否存在第二个真相源?同一个概念是否分裂成了几条行为不一致的路径? | | 7 | 过度设计 | 删掉它,今天会有什么损失? | 另加扩展面:错误路径与部分失败、并发与生命周期、安全、兼容性、可观测性、性能、测试盲区。 -## 三个机制 +七维之外还有两遍: + +- **反驳**:先假设设计本身可能是错的,对每个公开概念质问四句——它有几处实现?同一条业务动作在正常/异常/恢复路径上语义一致吗?统一调用方能用同一个类型吗?职责有没有被另一个抽象认领?这一遍抓的是**语义分裂**与**职责重叠**,产出大多进「需要你拍板」。 +- **规模扫描**:对每条含循环、递归、聚合、查找、拷贝的路径问——规模 N 是什么、随 N 怎么长、有没有重复访问同一状态/每层拷贝完整结果/每次重新全量扫描。**规模问题不需要有错误结果才算数**,它的证据是规模关系加实测。 + +## 四个机制 + +### 反驳一遍 + +七维全是「和现有东西比」——第 1 维问能不能组合出来,第 6 维问有没有第二个真相源,第 7 维问删掉损失什么。**没有一个动作要求质疑设计本身。** + +反驳这一遍补的就是这个:对每个公开概念(类型、接口、入口、操作、状态名)逐条质问它凭什么存在。抓的典型形态是**语义分裂**——同一个动作有「普通版」和「可靠版」两个名字,两者在异常路径上分叉,文档还只描述了其中一条。 + +每条质问必须落到「谁会因为这个不一致而写错、改错或被绊住」,指不出具体后果就删掉。反驳不是找茬。 ### 证伪闸门 @@ -68,8 +81,10 @@ - 实现 bug → 具体输入是什么?走到哪一行?得到什么错误结果? - 并发问题 → 两个执行体在哪个窗口交错?共享对象是什么? - 重复 → 哪条需求变了,这两处必须一起改? +- 语义分裂 → 两条路径各是什么?一个具体场景下它们的结果差在哪?调用方凭什么知道该走哪条? - 过度设计 → 删掉它,今天的损失是什么? -- 硬编码 → 它必须随什么变化?不变时会怎样静默过期? +- 硬编码 → 它是协议固定值还是业务政策值?若是业务政策值:必须随什么业务口径变化?不改会怎样静默过期? +- 规模 → N 的取值范围是多少?这条路径随 N 怎么长?实测开销是多少? 这条规则的作用不是让报告更短,而是让每条结论都能被作者复核。 @@ -81,13 +96,13 @@ 七维是通用面,但有些缺陷只在特定改动类型里高频出现: -- **批处理 / Job / CLI**:成功判定、游标语义、幂等、环境隔离、dry-run、参数绑定、分页; +- **批处理 / Job / CLI**:成功判定、游标语义、幂等、环境隔离、dry-run、参数绑定、分页、规模; - **数据重放 / 迁移 / 回填**:时间窗口上下界、数据快照、未来数据泄漏、重复执行、只读宣称; -- **API / 库 / SDK**:默认值、零值歧义、向后兼容、错误语义; +- **API / 库 / SDK**:默认值、零值歧义、向后兼容、错误语义、共享状态、零值可用性、单位一致性、跨层形状、规模; - **并发 / 服务 / 守护进程**:锁顺序、取消传播、关闭顺序、后台线程; - **配置 / 部署 / 脚本**:环境差异、密钥、退出码、可观测性。 -每份 playbook 都标注了它覆盖了扩展维度的哪些条目——**扫完 playbook 即视为那些条目已覆盖,不重复扫**。 +每份 playbook 都标注了它覆盖了扩展维度的哪些条目——**扫完 playbook 即视为那些条目已覆盖,不重复扫**。**规模扫描不被任何 playbook 覆盖**:它跟改动类型无关,只要代码里有循环、递归、聚合、查找或整块拷贝就要跑;playbook 里带「规模」标记的条目是该类型的高发场景,不是它的替代品。 ## 快速开始 @@ -171,22 +186,27 @@ git clone https://github.com/Luo-root/deep-code-review.git ~/.claude/skills/deep **为什么保留"需要你拍板"?** 设计后果不都是缺陷。把"设计使然"和"bug"分开,作者才不用在错误的问题上改代码。 +**为什么要单独跑"反驳"?** 七维全是「和现有东西比」——能不能组合出来、有没有第二个真相源、删掉损失什么。**没有一个动作要求质疑设计本身。** 于是同一个动作分裂成"普通版"和"可靠版"、抽象层与后端层的类型形状不一致、职责被两个抽象同时认领——这些都在七维的缝隙里漏掉。反驳这一遍把"先假设设计是错的"变成一个固定动作,而不是可选项。 + +**为什么规模扫描要单独跑?** 七维第 3 维的成立条件是「输入 → 走到哪行 → 错误结果」,而复杂度问题**没有错误结果**——它表现为随规模爆炸。按七维的闸门去卡,它永远不成立。所以它需要自己的证据形态:规模关系加实测数字。 + ## 目录 ```text . -├── SKILL.md # 执行流程、两档模式、七维速查、分级、输出契约 +├── SKILL.md # 执行流程、两档模式、反驳、七维速查、规模扫描、分级、输出契约 ├── references/ -│ ├── dimensions.md # 七维扫描动作与误报边界 +│ ├── dimensions.md # 七维扫描动作与误报边界 + 规模与复杂度 │ ├── playbooks.md # 按改动类型的追加检查 │ ├── verification.md # 证伪闸门与证据阶梯 │ ├── regression-review.md # 快模式:合并判定契约(不依赖任何其他 skill) │ └── output-template.md # 六段式报告骨架与示例 ├── evals/ # 可复现评测护栏 -│ ├── fixtures/tiny-shop/ # 自写的样本仓库(12 个已知缺陷 + 1 个确认点) +│ ├── fixtures/tiny-shop/ # 自写的样本仓库(25 个已知缺陷 + 1 个确认点) │ ├── ground-truth.md # 标准答案 │ ├── rubric.md # 评分表 -│ └── runbook.md # 操作手册 +│ ├── runbook.md # 操作手册 +│ └── baseline.md # 历次评测基线(追加,不覆盖) ├── tools/ │ └── lint-skill.mjs # 仓库自带的 skill 校验器 ├── .github/workflows/ci.yml # CI:换行符 + skill lint @@ -203,7 +223,11 @@ git clone https://github.com/Luo-root/deep-code-review.git ~/.claude/skills/deep 同一 skill 两轮之间会有波动,它不是确定性工具;它的价值是把"证据在哪、结论有多强"写清楚,最终判断仍然由人做。 -因为它有波动,**"改完 skill 之后有没有变好"不能靠感觉**。`evals/` 里有一套可复现的护栏:一份自写的、带 12 个已知缺陷的样本仓库,加标准答案、评分表和操作手册。它同时测「能不能找到 bug」和「会不会乱报」——后者才是让作者不看报告的主因。 +因为它有波动,**"改完 skill 之后有没有变好"不能靠感觉**。`evals/` 里有一套可复现的护栏:一份自写的、带 25 个已知缺陷的样本仓库,加标准答案、评分表和操作手册。它同时测「能不能找到 bug」和「会不会乱报」——后者才是让作者不看报告的主因。 + +首轮评测跑下来的结论值得记一笔:**三轮共同漏掉的两条,恰恰是标准答案自己写错的那两条**——所以「漏报率」这个指标在标准答案没校准之前,是不能直接拿来比较 skill 好坏的。这也是 `evals/baseline.md` 存在的理由:它同时记分数和口径缺陷。 + +**样本决定了测得到什么。** 现有样本是 150 行的结算模块,它测得出契约、文档、错误路径、并发这些面,**测不出算法复杂度与语义分裂**——样本里没有指数级回溯,也没有分裂成两条路径的动作。新能力要落地,得先有能触发它的样本与标准答案条目,否则「改完变好了还是变坏了」又变成凭感觉。 ```bash node tools/lint-skill.mjs . # 本地跑一遍 skill 校验 diff --git a/SKILL.md b/SKILL.md index 92d74ea..11f506a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -46,12 +46,12 @@ description: | | | 快模式(回归判定) | 全量模式(设计体检) | |---|---|---| | 用户在问 | 「这个 PR 有没有回归」「能不能合」「改坏了吗」 | 「这套设计怎么样」「还有哪些坑」「按七维审」「给这个模块做体检」 | -| 做什么 | 第一遍读全 + 第三遍证伪 | 第一遍 + 类型 playbook + 七维 + 证伪 | -| 只报什么 | 本次引入、能落到触发路径的问题 | 七维全部 + 扩展维度 + 类型专属项 | +| 做什么 | 第一遍读全 + 证伪 | 第一遍 + 反驳 + 类型 playbook + 七维 + 规模扫描 + 证伪 | +| 只报什么 | 本次引入、能落到触发路径的问题 | 七维全部 + 扩展维度 + 类型专属项 + 规模扫描 | | 既有问题 | 只在被本次直接放大时提 | 标 `[既有]`,与本次无关的不报 | | 输出 | 紧凑列表,每条 2~3 行;零发现一句话 | 六段式 + 维度覆盖表 | -两档**共用**第一遍与证伪,不重复劳动。拿不准就走全量——全量的产出可以裁成快模式,快模式的产出补不回全量。 +两档**共用**第一遍与证伪,不重复劳动。**反驳与规模扫描只在全量模式跑**——它们找的是设计与算法层面的问题,不是「这次改动有没有坏」。快模式在第一遍读全时若一眼撞见,照报(闸门照旧)。拿不准就走全量——全量的产出可以裁成快模式,快模式的产出补不回全量。 **快模式的校准细则**(范围怎么定、只报什么、输出长什么样、什么时候该升级到全量)见 `references/regression-review.md`。 @@ -77,37 +77,60 @@ description: | 读完后用一句话复述:**这个改动宣称做到什么?** 然后把「宣称覆盖的路径」与「实际改到的路径」各列一遍,差值就是最值得查的地方——dry-run 是否真的不写、重放是否真的复现、全量是否真的全量、只读是否真的只读。 -### 2. 第二遍:先按类型扫,再按维度扫 +### 2. 反驳:先假设这个设计可能是错的 -顺序固定:**先认改动类型 → 跑该类型 playbook 的必查项 → 七维做通用面 → 扩展维度只做横向补充**。 +第一遍建立的是「作者想做什么」。这一步反过来:**先假设设计本身可能是错的**,把它的前提摊开逐条质问。 -理由:playbook 覆盖的是该类型的高频专属缺陷(七维里没有),先跑它性价比最高;七维负责通用面;扩展维度中与 playbook 重复的项,扫完 playbook 即视为已覆盖,不再重复扫。 +对每个公开概念(导出的类型、接口、入口、操作、状态名)问四句: + +1. **它有几处实现?** 同一个业务动作在系统里有几种做法、几个名字?把候选列出来。只有一处 → 这个概念可能是凭空长出来的;有多处 → 它们的行为可能不一致。 +2. **同一条业务动作,语义在所有路径上一致吗?** 正常路径、异常路径、恢复路径上,同一个动作的行为是否相同?同一个名字在两条路径上做出不同结果,就是**语义分裂**。 +3. **统一调用方能用同一个类型/调用吗?** 抽象层声明的参数与返回类型,和实现层、后端层实际用的类型,是同一个形状吗?换一个后端要不要连带改调用代码?形状不一致 → 统一调用方无法复用。 +4. **它凭什么存在、职责归谁?** 它的职责有没有被另一个抽象也认领了?两个抽象的边界说不清,就是职责重叠。 + +每条质问都要落到「**谁会因为这个不一致而写错、改错或被绊住**」。指不出具体后果的,删掉——反驳不是找茬,是找出会咬人的裂缝。 + +这一遍的产出大多属于「需要你拍板」:概念该合并还是该拆、接口该统一成什么形状,本来就该由人来定。判据见 `references/dimensions.md` 的「重复」维(语义分裂)与「方法契约」维(跨层形状)。 + +### 3. 第二遍:先按类型扫,再按维度扫 + +顺序固定:**先认改动类型 → 跑该类型 playbook 的必查项 → 七维做通用面 → 规模扫描 → 扩展维度只做横向补充**。 + +理由:playbook 覆盖的是该类型的高频专属缺陷(七维里没有),先跑它性价比最高;七维负责通用面;规模扫描查算法复杂度,七维的成立条件(「输入 → 错误结果」)管不到它,必须单独跑;扩展维度中与 playbook 重复的项,扫完 playbook 即视为已覆盖,不再重复扫。 按下表把七个维度各扫一遍。**每个维度都要留下结论**,包括「无发现」——未声明的「没问题」和不存在的「没问题」无法区分。 | # | 维度 | 要回答的问题 | 成立条件(答不上来就降级或删除) | |---|---|---|---| | 1 | 设计:机制组合 | 这个能力能不能由现有机制(中间件/切面/option/注册表/钩子/已有函数)自然组合出来?新增抽象是否替组合做了本不该它做的决定? | 能给出一个可预测场景 + 现有机制的具体位置,说明组合够用或多差一点 | -| 2 | 方法契约 | 参数是否遗漏?缺省值是否有歧义(没传 vs 传了零值)?调用方会不会传错、漏传,或被迫绕开函数自己拼? | 指出一个现有或可预测的调用方会因此写错 | +| 2 | 方法契约 | 参数是否遗漏?缺省值是否有歧义(没传 vs 传了零值)?调用方会不会传错、漏传,或被迫绕开函数自己拼?抽象层与实现层、后端层用的类型是同一个形状吗? | 指出一个现有或可预测的调用方会因此写错;跨层形状不一致要并排列出两个签名并指出差异 | | 3 | 实现 | 在什么输入、状态或并发交错下会算错、漏处理、泄漏、死锁? | 给出「输入 → 走到哪行 → 错误结果」 | | 4 | 文档 | 读者照着注释/README/错误信息会不会做错事? | 指出读者会因此做错什么 | -| 5 | 硬编码 | 哪个值本该随环境/租户/调用方/数据规模/版本变化,却被写死在代码里? | 指出它必须随什么变化,或会怎样静默过期 | -| 6 | 重复 | 同一行为是否存在第二个真相源? | 给出两处符号,并说明「哪条需求变了它们必须一起改」 | +| 5 | 硬编码 | 哪个值被写死了,而它应当可变?先分清它是**协议固定值**(改了就是 bug)还是**业务政策值**(改了只是口径变了,如退款窗口、账期)? | 业务政策值:指出它必须随什么业务口径变化,或会怎样静默过期;协议固定值:不算 | +| 6 | 重复 | 同一行为是否存在第二个真相源?同一个概念是否分裂成了几条行为不一致的路径? | 真重复:给出两处符号,并说明「哪条需求变了它们必须一起改」。语义分裂:给出两条路径 + 一处具体差异 | | 7 | 过度设计 | 删掉它,今天会有什么损失? | 答不上来即成立;但要排除对外公开契约 | 七维的扫描动作、探针和误报边界见 `references/dimensions.md`(速查表以该文件的细则为准)。命中特定改动类型时,再追加 `references/playbooks.md` 里对应的必查项(批处理/Job、数据重放/迁移、API/库、并发/服务、配置/部署)——这些类型的专属缺陷不在七维里,漏掉一次就是整类问题。 -扩展维度在同一遍里按改动性质取舍,没扫的在报告里明说:错误路径与部分失败、并发与资源生命周期、安全、兼容性、可观测性、性能。测试只在「断言了错误行为」或「改动会让既有测试静默失效」时报告,不泛化建议补测试。 +**规模扫描**(七维之外单独跑一遍):对每条含循环、递归、聚合、查找、拷贝的路径问三句—— -规模自适应:单文件小改动七维全扫、扩展维度只扫高相关的两项;跨模块大改动先按风险排序分批读,报告里写清抽样与未覆盖部分。 +1. **输入规模 N 是什么?这一步随 N 怎么长?** +2. **有没有重复访问同一个状态、每层拷贝完整结果、每次重新全量扫描?** +3. **爆炸点在哪——N 到多少会出事?** -### 3. 证伪 +规模问题**不需要有错误结果才算数**:它没有「输入 → 错误结果」这种触发路径,它的证据是**规模关系 + 实测**。说得出「N=19 个定义、36 条挂载边时额外开销 89.7 ms / 375 MB」才成立;只说「这里可能慢」不算。细则见 `references/dimensions.md` 的「规模与复杂度」。 + +扩展维度在同一遍里按改动性质取舍,没扫的在报告里明说:错误路径与部分失败、并发与资源生命周期、安全、兼容性、可观测性、性能、测试盲区。测试只在「断言了错误行为」「改动会让既有测试静默失效」「关键分支完全没有覆盖且该分支高风险」时报告,不泛化建议补测试。**「性能」判的是工程坏味道(N+1、循环内 IO、无界缓存);算法层面的复杂度爆炸归「规模扫描」,两者分开报,不要重复。** + +规模自适应:单文件小改动七维全扫、扩展维度只扫高相关的两项;跨模块大改动先按风险排序分批读,报告里写清抽样与未覆盖部分。**规模扫描不按「改动大小」取舍——只要代码里有循环、递归、聚合、查找或整块拷贝,就要跑。** + +### 4. 证伪 对每条候选结论回答 `references/verification.md` 的闸门问题。答不上来就降级或删除,不要用措辞掩盖不确定。 最常见的四类降级(细则见闸门表):没有具体触发路径 → 删;说不出交错窗口 → 降为「待确认」;说不出可预测场景 → 删;说不出两处为什么会一起变 → 降为提示或删。 -### 4. 分级与排序 +### 5. 分级与排序 - **P0**:数据错误或丢失、安全漏洞、线上不可用、不可回滚。合并前必须修。 - **P1**:在可预测的真实输入/调用下出错,或设计会把后续使用者引向错误。应该修。 @@ -116,6 +139,10 @@ description: | 判断依据是「会不会真的咬人」,不是「改起来难不难」。同一个根因只报一次,把所有受影响位置列在同一节。 +**规模与复杂度问题按「N 到多少会出事」定级**:在项目现实的规模下就会拖垮(延迟、内存、吞吐)→ P1;只在远超当前规模时才爆 → P2 或 P3。**说不出 N 的取值就别定级**,降为「需要你拍板」或删掉。 + +**语义分裂按「谁会被绊住」定级**:同一件事走两条路径结果不同,调用方无法预判 → P1;只是两条路径各自自洽、只是名字让人误解 → P2 或进「需要你拍板」。 + **既有问题(基线之前就存在)的处理,全文只有这一条规则:** - 与本次改动无关 → 不报。 @@ -130,13 +157,14 @@ description: | - 已被同一改动在别处处理过的问题。 - 泛泛的「复杂度高」「可读性差」,除非能指出读者会在哪一步读错。 -### 5. 输出 +### 6. 输出 按 `references/output-template.md` 的结构。先结论,后证据;确定项与待拍板项分栏。 ## 判定纪律 - **先判「设计使然」还是「缺陷」。** 由既有设计推导出的结果(例如缺省输入导致本节点不执行)放在「需要拍板」,不要写成 bug。可预期的跳过/缺省是正常语义;只有它与失败不可区分、或静默改变汇聚语义时才算问题。 +- **不要默认设计是对的。** 审查不是只验证「实现有没有偏离声明」——还要质问「这个声明本身站不站得住」。同一个概念在两条路径上行为不同、抽象层与实现层形状不一致、职责被两个抽象同时认领,这些都要主动去问,别等它咬人。 - **不要把「没现成实现」当成「缺失」。** 被审项目的基础库若没有某个应用层能力,不等于引擎缺项;先用现有机制做一次最小组合验证,再下结论。 ## 输出契约 @@ -160,7 +188,7 @@ description: | 5. **已查过没问题**(**条件段**,见下) 6. **本次没覆盖**(没权限、跨仓库、未运行的测试) -第 2 段每格用三态:`无发现` / `未扫(理由)` / `n 条`。扩展维度**各自成行**,不要合并成一行——合并之后「无」就分不清是「扫了没问题」还是「压根没扫」。 +第 2 段每格用三态:`无发现` / `未扫(理由)` / `n 条`。扩展维度**各自成行**,不要合并成一行——合并之后「无」就分不清是「扫了没问题」还是「压根没扫」。**「规模与复杂度」也单独成行**,跑过就写结果,没跑就写「未扫(理由)」。 第 5 段是**条件段**:只有当本次真的跑了测试、构造了复现,或对某个高风险面做了专门证伪时才写;否则整段省略,并在第 6 段写明未做验证。写了就每条带一句验证方式(跑了什么 / 读了什么 / 对比了哪个 base)——**写不出验证方式的不要写**,宁可这段不存在。 diff --git a/evals/README.md b/evals/README.md index 49d75d0..1d1b51e 100644 --- a/evals/README.md +++ b/evals/README.md @@ -9,20 +9,35 @@ | 文件 | 作用 | |---|---| -| `fixtures/tiny-shop/` | **自写**的最小样本仓库,刻意植入 12 个已知缺陷 + 1 个「应当确认没问题」的点 | -| `ground-truth.md` | 标准答案:每个缺陷的位置、维度、级别、触发路径、可接受的变体,以及**不该报**的清单 | +| `fixtures/tiny-shop/` | **自写**的最小样本仓库,刻意植入 25 个已知缺陷 + 1 个「应当确认没问题」的点 | +| `fixtures/rule-engine/` | 同上,专门测**算法复杂度**与**架构/设计语义**——tiny-shop 里没有这两种形态,测不出来 | +| `ground-truth.md` | tiny-shop 的标准答案:每个缺陷的位置、维度、级别、触发路径、可接受的变体,以及**不该报**的清单 | +| `ground-truth-rule-engine.md` | rule-engine 的标准答案(`R×` 编号,与 tiny-shop 的 `G×` 互不干扰),带 v1 → v2 的校正记录 | | `rubric.md` | 评分表:命中 / 误报 / 归类正确性 / 证据强度 / 表达质量 | | `runbook.md` | 怎么跑、跑哪几种问法、什么时候必须跑 | +| `baseline.md` | 历次评测的基线分数,**追加**不覆盖;每轮都记着当时的标准答案口径 | 样本是**自己写的**,不来自任何第三方仓库,所以没有许可问题,也不会把别人的代码带进本仓库。 +## 两个样本各测什么 + +| 样本 | 测的面 | 跑哪几轮 | +|---|---|---| +| `tiny-shop` | 契约、文档、错误路径、并发、兼容性——**通用面** | A + B + C | +| `rule-engine` | 算法复杂度、语义分裂、跨层形状——**新能力面** | B + C | + +**为什么必须是两个样本**:tiny-shop 是 150 行的结算模块,里面没有指数级回溯,也没有分裂成两条路径的动作。 +只跑它就分不清「新能力生效了」和「模型本来就会」;两个样本分开跑、分数分开记,才能同时回答这两问。 + ## 样本为什么长这样 +### tiny-shop —— 测「能不能找到 bug」与「会不会乱报」 + 它刻意覆盖了几类「模型最容易犯错」的地方: - 一个**指针字段没有构造函数** → 零值就 panic(实现维度) - 一个**参数被完全忽略**(`timeout`)和一个**字段被完全忽略**(`Code`)→ 契约维度 -- **文档承诺「只读」但实现写了状态** → 文档维度 +- **一个承诺提到了根本不存在的层**(文档说「不回写数据库」,但模块里没有数据库)→ 文档维度 - **批次中途失败会丢计数** → 错误路径维度 - **同一逻辑写在两个包里且已经漂移**(截断 vs 四舍五入)→ 重复维度 - **同一个错误码有两个错误值** → 兼容性维度 @@ -34,6 +49,24 @@ 最后两条是这个样本最值钱的地方:它们测的不是「能不能找到 bug」,而是**「会不会乱报」**—— 那才是让作者不再看报告的原因。 +### rule-engine —— 测「能不能看出设计本身不合理」 + +- **依赖图按路径重复展开**(没有已访问集合)→ 规模与复杂度维度 +- **每层深拷贝完整依赖链** → 规模与复杂度 / 过度设计维度 +- **每次查折扣都重建整张索引表,且在热路径的循环里** → 规模与复杂度维度 +- **同一个动作两条路径行为不一致**(一个回写购物车一个不回,一个跳过一个报错)→ 语义分裂维度 +- **抽象层与后端层类型不是一个形状**(换后端要改调用代码)→ 跨层形状维度 +- **一个承诺没有落地**(godoc 说「规则集在构造时固定」,实现却直接持有调用方切片)→ 文档承诺维度 +- **一个在文档上限内根本不成立的复杂度形状**(O(n²),但上限 200 行实测 0.5 ms)→ 测「会不会把形状当后果」 + +> **首轮评测校准过 tiny-shop 那条**:原本这里写的是「文档承诺只读但实现写了状态」,标准答案也据此设了一条 P1。 +> 但源码那句承诺有限定语「read-only *with respect to the order store*」,写 `Checkout` 自己的计数器不违反它, +> 而且模块里根本没有数据库可供违反。**是标准答案写错了,不是模型漏报**——详见 `baseline.md` 的「口径缺陷」一节。 +> +> **rule-engine 的标准答案也校准过 3 处**:O(n²) 那条从「应当报出」移到「不该报」(量级不够), +> 索引重建那条级别 P2 → P1(README 上限下单请求 877 ms),语义分裂那条从缺陷改成拍板(godoc 已承认差异) +> 并拆出三条新条目。依据与逐条对照见 `baseline.md`。 + ## 跑一次 见 `runbook.md`。 @@ -43,6 +76,9 @@ CI 只做静态校验(换行符 + skill lint)。评测是人工触发的纪 ## 现状 -这套护栏是随 issue #11 一起引入的,**还没有基线分数**。 +这套护栏随 issue #11 引入,**已跑完多轮**(每次改动后重跑,分数与口径缺陷见 `baseline.md`)。 +最新一节是「rule-engine 轮 + tiny-shop 回归轮」——补「反驳」与「规模扫描」那版改动的评测。 -第一轮跑完之后,请把评分表贴到 issue #11 里作为基线;之后的每次改动都和基线比。 +后续每次改动都和基线比。**注意口径**:改了 `ground-truth.md` / `ground-truth-rule-engine.md` 或 `rubric.md` 之后, +旧分数与新分数不可比——必须重跑一轮,把新一节追加到 `baseline.md`,不要覆盖旧记录。 +**跨样本的 raw 也不可比**(归一化因子 N = 该样本的 GT 条目数,25 vs 9),只能比维度占比。 diff --git a/evals/baseline.md b/evals/baseline.md index be469cd..92396da 100644 --- a/evals/baseline.md +++ b/evals/baseline.md @@ -2,9 +2,9 @@ 首次跑通 `evals/runbook.md` 的三种问法,记录结果作为后续改动的对照点。 -> **口径提示**:本文件记录的是**首轮 12 条 ground truth 口径**下的分数。随后 `ground-truth.md` 已扩充到 25 条(补进首轮实测发现的 13 条真缺陷,见 #14 / #15),`rubric.md` 的分母同步改为 25。 -> **本文件的分数与新口径不可直接比较**——要比较,必须按新口径重跑一轮。 -> 新口径下的重跑结果尚未产生,本文件暂按首轮事实保留。 +> **口径提示**:本文件上半部分是**首轮 12 条 ground truth 口径**下的分数,保留为历史事实、不覆盖。 +> 下半部分「v4 轮」是**25 条口径**下的重跑结果——改完标准答案、重跑、锁定判分口径之后才有资格谈发版。 +> **两轮分数不可直接比较**:GT 条目数(12→25)、归类分母、命中判据三样都变了。要比较必须按同一口径重跑。 **结论先行** @@ -199,4 +199,393 @@ B 轮提出的另一个角度更接近真实问题:承诺里提到的「订单 | `evals/rubric.md` | 评分表与五维口径 | | `evals/runbook.md` | 怎么跑一次评测 | | `evals/fixtures/tiny-shop/` | 样本(约 150 行 Go) | -| 仓外 `scratchpads\dcr-eval\runs\round-{A,B,C}.md` | 三轮原始输出,不入版本控制 | +| 仓外 `scratchpads\dcr-eval\runs\round-{A,B,C}.md` | 首轮三轮原始输出,不入版本控制 | + +--- + +# v4 轮(25 条口径 · 冻结提交 `7c1c4ea`) + +改完标准答案(G4 改判、GT 扩到 25 条、硬编码细则去矛盾)之后重跑的一轮。**这一轮的分数是 v1.1.0 的发版依据。** + +## 运行条件 + +| 项 | 值 | +|---|---| +| 日期 | 2026-10-21 | +| skill 版本 | 冻结提交 `7c1c4ea`(`fix/consistency-and-lint`)——三轮全程未改任何 skill 文件 | +| 被测上下文 | 三个全新子 Agent 上下文,互不污染,轮间不共享结论 | +| 样本 | `evals/fixtures/tiny-shop` 逐字节复制到仓外 `scratchpads\dcr-eval\tiny-shop`,README 中性化 | +| 隔离 | 只禁仓库 `evals/` 目录;skill 路径显式指向装线 `C:\Users\LUOYN\.minimax\skills\deep-code-review`(与仓库同 hash) | +| 原始输出 | 仓外 `scratchpads\dcr-eval-archive\round-{A,B,C}-v4.md`,**不入版本控制** | +| 输出指纹 | A `BA1FFA73…C691B4` / B `A13EA522…F48912` / C `3037B600…238DC`(SHA256) | + +这一轮之前有两次重跑作废:一次跑在「边改边测」的移动靶上,一次让子 Agent 读到了磁盘上的旧 skill 副本。两条都写进了 `evals/runbook.md` 的卫生规则。 + +## 结论先行 + +1. **误报三轮全 0**。包名/目录名这条三处都守住了边界(A 排除、B/C 进拍板且明写「不进必须修」),没有一条混进修复项。 +2. **命中 15.5 / 13.0 / 19.5(满分 25)**。C 轮(模块体检)最好,A 轮(快模式)次之,B 轮最低——B 掉的主要是 G14(单位不一致)这类**设计面**条目,快模式按契约本就不报,符合预期。 +3. **C 轮命中最高、raw 反而最低**(151.6 vs A 的 163.4)。差距全在证据:C 的证据均值 4.20,A 是 4.93。**C 靠「读完 + 读懂」拿命中,A 靠「跑探针」拿证据。** 想提分,方向是让全量轮也像快模式那样去跑探针。 +4. **「文档维度整体失灵」是错的说法**。三轮报出的文档类问题都落在 G3/G11/G14 上——说明模型**能**发现「文档与实现不一致」。真正系统性漏掉的是两种更窄的形态:**位置指代型**(G19:注释说「第二个返回值」,实际 bool 是第一个)和**承诺无对应对象型**(G4:godoc 里的「订单存储」在这个模块里根本不存在)。 +5. **共同漏报(作为条目报出 = 0)只有 3 条**:G4、G19、G22。另有 G23(`Order.ID` 零使用)三轮都没当成条目报出。G9/G25 的 0 是「同一根因只算一次」的结构性结果,不是能力漏报。 +6. **首轮票面的三条断言,第三条不成立**:① 硬编码维度**部分成立**(B、C 结论一致,但只有 B 明确区分「协议固定值 100 不算」,C 全文没提 100);② 包名处置**成立**;③ 「真正的文档缺陷仍报得出来」**不成立**——GT 点名的 G4/G19 三轮都没报出来,三轮报的文档类问题全落在 G3/G11/G14,不能充数。 + +## 汇总 + +| 维度 | A(快模式·合并判定) | B(全量·按七维审) | C(全量·模块体检) | +|---|---|---|---| +| 命中 | 15.5 / 25(62.0%) | 13.0 / 25(52.0%) | 19.5 / 25(78.0%) | +| 误报 | 0 条 | 0 条 | 0 条 | +| 归类合计 / 已报条目数 | 10.5 / 14(75.0%) | 8.0 / 15(53.3%) | 12.0 / 15(80.0%) | +| 证据强度均值 | 4.93(69/14) | 4.73(71/15) | 4.20(63/15) | +| 表达质量 | 14.1 / 14.5 | 15.1 / 15.5 | 15.1 / 15.5 | +| **raw** | **163.4** | **154.4** | **151.6** | + +**分母说明**:归类的分母是**报告实际交付的 finding 条目数**(必须修 + 建议修 + 拍板),不是 25——未报出的 GT 条目按 0 计进归类合计,所以归类同时受漏报拖累。表达质量的满分是「条目数 + 0.5」(C1 的加分)。 + +## 逐条对照 + +`命中/归类`。`—` 表示完全没涉及。 + +| GT | 级别 | A | B | C | 说明 | +|---|---|---|---|---|---| +| G1 零值 `Checkout` panic | P1 | 1/1 | 1/0.5 | 1/1 | B 报 P2,偏一级 | +| G2 `timeout` 形同虚设 | P1 | 1/1 | 1/1 | 1/1 | 三轮都给了零引用证据 | +| G3 `Code` 从未被读取 | P1 | 1/1 | 1/1 | 1/0.5 | C 判成拍板(GT 判缺陷) | +| G4 承诺没有对应对象 | P2 | 0.5/0 | 0.5/0 | 0/0 | A/B 只说了「模块内没有 IO」就判「承诺成立」;C 说的是镜像问题 | +| G5 批次中途失败计数残留 | P1 | 1/0.5 | 1/0.5 | 1/1 | A/B 报 P2,偏一级 | +| G6 两份元→分换算漂移 | P2 | 1/0.5 | 1/0 | 1/0 | B/C 都报 P0,偏两级;A 报 P1 最接近 | +| G7 `E_INVALID` 两个真相源 | P2 | 1/1 | 1/1 | 1/1 | 三轮都给了 `errors.Is` 恒假的论证 | +| G8 `refundWindow` 硬编码 | P2 | 1/1 | 1/0.5 | 1/0.5 | B/C 判成拍板(GT 判缺陷) | +| G9 折扣接口过度设计 | P2 | — | — | — | 同一根因并入 G3,不重复计分 | +| G10 测试盲区 | P2 | 1/0.5 | 0.5/0 | 1/0.5 | A/C 在覆盖行里点到了变体;B 只说了并发路径零覆盖 | +| G11 测试用字符串比较断言 | P2 | 1/0.5 | 1/0.5 | 1/0.5 | 三轮都写进了 E_INVALID 条目的证据与修法,但都无独立级别 | +| G12 `Percent` 无范围校验 | 拍板 | 0.5/0 | 0/0 | 1/0.5 | A 明确排除;B 完全未提;C 在别的拍板项里顺带提到 | +| G13 退款窗口从查询时刻起算 | P1 | 1/0.5 | 1/1 | 1/1 | A 没说业务前提;B/C 的拍板项问了「24h 是不是统一口径」 | +| G14 `Issue` 收元 / 返分 | P1 | 1/1 | — | 1/0.5 | B 漏报;C 判成 P2,偏一级 | +| G15 0 元退款被记成已退款 | P2 | — | — | 1/0.5 | C 判成拍板(GT 判缺陷) | +| G16 `Refunder{}` 零值 panic | P2 | 1/1 | — | 1/0.5 | B 漏报;C 与 G1 并成一条,宿主级别 P1 | +| G17 共享指针 + 非原子自增 | P1 | 1/1 | 1/0.5 | 1/0.5 | B/C 报 P2,偏一级 | +| G18 `issued` map 只增不减 | P2 | — | — | 1/0.5 | B 的性能维度写了「无无界缓存」——**与 G18 相反的覆盖声明**(不扣误报,但属假阴性) | +| G19 注释「第二个返回值」写反 | P2 | — | — | — | B 引了那段 godoc 去**背书**幂等语义,没核返回顺序 | +| G20 NaN/Inf 绕过校验 | P2 | — | 1/0.5 | 1/1 | A 漏报;B 报 P1 偏一级 | +| G21 两个计数口径不一致 | P2 | 0.5/0 | 1/1 | 1/0.5 | A 明确判为不报 | +| G22 状态持久性接缝缺失 | P2 | 0.5/0 | — | — | A 划进「本次没覆盖」;B 兼容性维度未扫;C 判「无发现」 | +| G23 `Order.ID` 零使用 | P3 | — | — | 0.5/0 | C 全文提及但判为「无注入面可报」 | +| G24 折扣先截断再减 | P3 | 0.5/0 | — | 1/0.5 | C 判成拍板(GT 判缺陷) | +| G25 折扣机制整体未接线 | P2 | — | — | — | 同一根因并入 G3 | +| **命中合计 / 25** | | **15.5** | **13.0** | **19.5** | | +| **归类合计** | | **10.5** | **8.0** | **12.0** | 未报出记 0 | + +## 证据强度明细 + +分母 = 该轮交付的全部 finding 条目数(含拍板项)。无一条触发「声称强度高于实际」或「已查过没问题没给验证方式」的扣分。 + +| 轮 | 条目与分数 | +|---|---| +| A | P1-1 换算重复 5、折扣未接线 5、timeout 5、nil Stats 5、计数器竞态 5、Issue 单位 5、窗口滑动 5、**P2-8 硬编码 4**、错误身份 5、零值 Refunder 5、计数残留 5、拍板1 5、拍板2 5、拍板3 5 → **69/14 = 4.93** | +| B | P0 换算 5、P1 折扣 5、timeout 5、窗口 5、NaN/Inf 5、P2 并发 5、错误路径 5、口径 5、零值 5、E_INVALID 5、**过度设计 3**、拍板1 5、拍板2 5、**拍板3 硬编码 3**、拍板4 包名 5 → **71/15 = 4.73** | +| C | P0 换算 5、P1 零值 5、窗口 5、**timeout 4**、计数器 5、P2 上界/NaN 5、并发 5、重复 5、**文档单位 2**、可观测性 4、测试盲区 4、拍板1 折扣 5、**拍板2 硬编码 2**、拍板3 幂等 5、**拍板4 包名 2** → **63/15 = 4.20** | + +**A 轮最强**:11 条 P1/P2 全部有探针。**C 轮最弱**:3 条只读码没跑探针(拍板2 硬编码、拍板4 包名、P2 文档单位),1 条只 grep 确认未覆盖。 + +## 表达质量明细 + +| 轮 | 说明 | +|---|---| +| A | P1-1…P1-7 各 1.0;**P2-8 硬编码 0.6**(讲清机制,只给了「改成 48h 或 7 天」的场景,没有对照例子);P2-9…P2-11 各 1.0;拍板1–3 各 1.0;**C1 +0.5**(`-race` 无告警 + 锁范围推理)→ **14.1 / 14.5** | +| B | P0、P1×4 各 1.0;P2 并发/错误路径/口径/零值/E_INVALID 各 1.0;**过度设计 0.6**(讲清机制,无对照例子);拍板1–4 各 1.0;**C1 +0.5**(8 goroutine × 300 次 `-race` 无告警)→ **15.1 / 15.5** | +| C | P0、P1 零值/窗口/计数器、P2 上界/并发/重复/文档/可观测性/测试盲区 各 1.0;**P1 timeout 0.6**(讲清机制,无对照例子);拍板1–4 各 1.0;**C1 +0.5**(`-race` 报告 PASS)→ **15.1 / 15.5** | + +## 首轮票面三条断言的复核结果 + +| 断言 | 结论 | 依据 | +|---|---|---| +| ① 硬编码维度:B、C 结论一致,且能区分「协议固定值 100 不算」与「业务政策值 24h 算」 | **部分成立** | B 明写「`100` 是协议固定值(分/元),按规则不算」,C **全文未提 100**——从报告文本无法证明它做了这个区分。两轮都把 24h 当业务政策值、都给「参数/配置项」两条路,结论一致 ✔ | +| ② 包名/目录名不进「必须修/建议修」 | **成立** | A 排除候选「按命名细则属口味,不报」;B「这条本身按规则属于命名口味,所以不进必须修」;C「只是风格统一建议,不影响正确性」 | +| ③ 真正的文档缺陷仍报得出来 | **不成立** | GT 点名的 G4、G19 三轮都没报出来。三轮报出的文档类问题全部落在 G3/G11/G14(外加 G1/G17/G7 的 godoc 缺口),不能充数 | + +**断言③ 的正确表述**(建议替换票面原文): + +> 三轮都能报出「文档与实现不一致」类问题(G3/G11/G14),但**位置指代型**(G19)与**承诺无对应对象型**(G4)两类文档缺陷仍系统性漏报。 + +## 共同漏报 / 共同命中 + +- **共同命中(命中 = 1,三轮全中,11 条)**:G1、G2、G3、G5、G6、G7、G8、G11、G13、G17、G21。真正的差别在**归类**:A 全部 1 分;B 的 G6=0、G13=0.5、G1/G5/G17/G20=0.5;C 的 G6=0、G13=0.5。 +- **共同漏报(命中 = 0,三轮全不中)**:G4、G19、G22(能力性漏报)+ G9、G25(结构性,按「同一根因只算一次」不重复计分)。**G23** 三轮都没作为条目报出,建议追加为实质共同漏报。 +- **部分触碰**:G10(A 1/0.5、B 0.5、C 1/0.5)、G12(A 0.5、B 0、C 1/0.5)、G24(A 0.5、B 0、C 1/0.5)、G23(仅 C 0.5)。 +- **单轮独有命中**:G14(G15/G16/G18/G20 同理)——A 漏 G18/G20,B 漏 G14/G15/G16/G18,**C 漏得最少**。 + +## 口径待裁定:6 处分歧与数字影响 + +判分细则已经写进 `evals/rubric.md`(「打分细则」八节),本轮基线按该细则算。下列 6 处是**判分规则本身的宽窄选择**,不是事实分歧——换个读法分数会变,列在这里是为了让基线可被挑战、可被复算。 + +| # | 分歧点 | 本基线采用 | 另一种读法 | 数字影响 | +|---|---|---|---|---| +| 1 | 在别的条目里顺带说对了,算不算命中 1 | **算 1**(细则 §一) | 算 0.5 | A 命中 −1.0;C 命中 −1.0~−2.0 | +| 2 | G4 三轮记 0 还是 0.5 | **A/B 各 0.5、C 0**(细则 §一:「只陈述了相邻事实」算 0.5) | 三轮全 0 | A 命中 −0.5;B 命中 −0.5 | +| 3 | G22 在 A 轮算不算 0.5 | **算 0.5**(「没有持久化层」是相邻事实) | 记 0 | A 命中 −0.5 | +| 4 | G13 归类:「谈没谈业务前提」的最低标准 | **B/C 记 1**(拍板项问了「24h 是不是全平台统一口径 / 按商户渠道区分」就算谈业务前提),A 记 0.5 | B/C 各 0.5 | B 归类 −0.5;C 归类 −0.5 | +| 5 | G8 被放进拍板时归类 | **0.5**(GT 判 P2 缺陷,报告判成拍板 → 偏一级) | 记 1 | B 归类 +0.5;C 归类 +0.5 | +| 6 | 表达质量「最小对照例子」的宽窄 | **保守档 0.6**(只有静态断言 + 场景描述记 0.6) | 宽松档记 1.0 | 三轮表达各 +0.4,raw 各 +0.4 | + +**为什么这样定**:第 1 条按 `worksheet` 明文(「独立成条、或作为别的条目里的一段正确陈述都算 1」),它消掉了「同一条 finding 里顺带提到的到底给 0.5 还是 1」这个每轮都要吵的问题。第 2、3 条选宽档,是因为「陈述了相邻事实」确实比「完全没涉及」多走了一步。第 4 条取「只要把窗口口径摆成需要人来定的问题就算谈业务前提」——B/C 的拍板项原话正是这个形状。第 6 条取保守档,因为「`NewRefunder(window time.Duration)`」这类签名形状是**修法**不是**对照例子**,读者看不出「原来会怎样、现在会怎样」。 + +**独立复算**:本轮分数由一个独立子 Agent 按同一份细则复算过一次(不参考本文件)。复算方在第 2、3、4 条上取窄档,得 raw **A 162.3 / B 153.4 / C 151.1**——与本基线相差 ≤ 1.7 分。**结论方向完全一致**:C 轮命中最高但 raw 最低、误报全 0、断言③不成立、G4/G19/G22 共同漏报。窄档的完整数字留在这份复算报告里,不入版本控制。 + +## 一处措辞过时(不计分) + +A、B 两轮仍写「编译器不报错(起个别名即可)」——这个理由在 `7c1c4ea` 已被实测纠正为 `shop redeclared in this block`(两个同名包一起 import 不起别名,编译器**会**报错)。**处置结论三处都正确**(都没进必须修/建议修),所以不计分;但说明模型在新措辞下仍会把理由压缩回旧说法。已开票(v1.2.0 措辞候选)。 + +## 方法论局限(v4 轮补充) + +- **样本与标准答案同一人写**:基线偏乐观,真实水平要打折看。 +- **每种问法只跑 1 轮**:没有方差估计。单轮差异小于 2 个百分点的结论不要当结论。 +- **快模式轮的 GT 覆盖天然不全**:G8–G11、G14–G25 多数属设计面与测试面,快模式按契约本就不报。拿 A 轮的命中分去比全量轮不公平。 +- **B 轮有一条假阴性**:性能维度写「无…无界缓存」,与 GT 的 G18 相反。不构成编造缺陷、不扣误报分,但它是覆盖声明失真。 +- **原始输出与评分表分离**:原始输出留在仓外(`scratchpads\dcr-eval-archive\`),版本控制里只有评分表——第三方可复算分数,但看不到原始报告全文。 +- **数值复算以 Go 1.24.0 windows/amd64 实跑为主**;`int64(NaN)` 属平台相关的未定义转换,不同架构结果可能不同。 +- **没有做变异对照**:没改坏代码去验证每条 finding 是否真能让测试变红,「finding 是真的」靠读码 + 探针实测,不是测试红。 + +--- + +# rule-engine 轮 + tiny-shop 回归轮(冻结提交 `dad4adc`) + +给 skill 补「反驳」与「规模扫描」之后的第一轮评测。 + +**两个样本、四轮**: + +- **rule-engine(B+C)**:测新能力本身能不能抓到算法复杂度与语义分裂。 +- **tiny-shop(B+C)**:测旧能力有没有退化——`SKILL.md` 的执行顺序变了,按 `runbook.md` 的规则必须重跑才能说「没退化」。 + +**与 v4 节不可直接比较**:样本不同、GT 条目数不同(25 vs 9)、raw 的归一化因子不同(N=25 vs N=9)。要比只能比维度占比。 + +## 运行条件 + +| 项 | 值 | +|---|---| +| 日期 | 2026-10-29 | +| skill 版本 | 冻结提交 `dad4adc`(`feat/refute-and-scale`)——四轮全程未改任何 skill 文件 | +| 被测上下文 | 每轮一个全新子 Agent 上下文,互不污染,轮间不共享结论 | +| 样本 | `evals/fixtures/rule-engine` 与 `evals/fixtures/tiny-shop` 逐字节复制到 `TEMP\dcr-eval-r2\` 下的两个独立子目录,README 中性化(缺陷披露命中 0) | +| 隔离 | 样本父目录 `TEMP\dcr-eval-r2\` 里**没有任何历史评审输出**。runbook 明确要求「原始输出目录与样本目录不要有父子关系,也不要放在同一个父目录下」——首轮(校正前)的 rule-engine 输出原本与样本同在 `scratchpads\` 下,重跑前已把样本换到独立父目录 | +| 原始输出 | 仓外 `scratchpads\dcr-eval-archive\round-{B,C}-rule-engine-v2.md`、`round-{B,C}-tiny-shop-v2.md`,**不入版本控制**;校正前那两轮在 `round-rule-engine\` 子目录下 | +| 轮次 | 两个样本都只跑 B(全量·按七维审)与 C(全量·模块体检)。快模式契约里没有反驳与规模扫描,跑 A 收益低 | +| 工具链 | Go 1.26.10 windows/amd64;全部实测在 TEMP 副本上跑,被审目录 SHA256 前后一致 | + +## 结论先行 + +1. **新能力确实被用起来了**。rule-engine 两轮都把「依赖图按路径重复展开 → 指数膨胀」报成 P1 规模缺陷,都给了多档规模曲线(R1 命中 1/1);两轮还都独立报了「依赖成环 → `fatal error: stack overflow` 整个进程死掉」(同样 P1)。**这是第 3 维成立条件(输入 → 错误结果)按定义抓不到的一类**——复杂度问题没有错误结果。 +2. **误报两轮都是 0**。尤其 `DuplicateLines` 的 O(n²):两轮都**明确驳回**了它(README 上限 200 行 → 19,900 次比较 ≈ 0.5 ms),并写进「已查过没问题」。这正是标准答案 v1 判错的那一条。 +3. **R10(两份逐行相同的定价循环)两轮都没独立成条**。B 轮把 `Apply`/`ApplyChecked` 列进了「重复」维度的覆盖行、也说了「两个方法做同一件事」,但没说「哪条需求变了必须两处一起改」,按 GT 的 0.5 档记 0.5;C 轮完全没碰。新增的「语义分裂」把这两条路径认成了分裂,模型就不再说「重复」——**新能力带出了新盲区**。 +4. **R8(`ApplyChecked` 对任何非空规则集必然失败)两轮都漏**。C 轮甚至在「已查过没问题」里**明确肯定**了 `hasUnknownSKU` 的语义与 README 一致。语义分裂被识别了,**错误语义这一层没被追问**。 +5. **tiny-shop 没有退化,但两轮波动很大**:B 轮命中 13.0 → 18.5(+5.5),C 轮命中 19.5 → 17.0(−2.5);raw B 154.4 → 156.1、C 151.6 → 158.3。**C 轮这条线下降的是命中、上升的是证据与表达**——它把 G14/G15/G16/G11/G19/G24 六条全漏了,但剩下的条目挖得更深。 +6. **两轮都有「覆盖表/结论与正文对不上」**:tiny-shop B 轮的覆盖表写「并发与生命周期 1 条 P2」,正文却没有对应条目(那条就是 G17);tiny-shop C 轮的结论写「P2 6」,正文只有 5 条 P2。**结论里的计数不可信**。 +7. **标准答案本身被推翻 3 处**,每处都有实测依据。这轮最有价值的产出不是分数,是**标准答案被校准的过程**。 + +## 标准答案从 v1 到 v2 的校正 + +首轮(校正前 GT)两轮跑完后,标准答案自己被推翻了三处。**每处都有实测依据**。 + +| # | v1 | v2 | 依据 | +|---|---|---|---| +| 1 | R2 `DuplicateLines` 的 O(n²) 在「应当报出」,P2 | **移入「不该报」** | README 写明一次请求最多 200 个商品行。实测 200 行 → 19,900 次比较 ≈ **0.5 ms**。B 轮明确判定「O(C²) 本身不是问题」并给出实测数字。**原 GT 把「复杂度形状」当成了「规模后果」**——这正是要校准的东西 | +| 2 | R4 `discountFor` 重建索引,P2 | 级别改 **P1** | README 上限(200 规则 × 200 行)下单请求实测 **877 ms / 250.6 MB 分配**。按 skill 自己写的分级校准「现实规模下就拖垮 → P1」,P2 判轻了 | +| 3 | R5 `Apply`/`ApplyChecked` 语义分裂,P1 缺陷 | 级别改 **拍板**,拆出 R8 / R9 / R10 | 两条路径的差异**已被 godoc 明说**(`Apply` 是 "fast path",`ApplyChecked` 是 "safe path")。按 `references/dimensions.md` 第 6 维「上层注释已承认差异 = 已知取舍 → 进拍板,不是缺陷」,主体应是拍板项。但两条路径各自的**后果**里,有两条不在 godoc 覆盖范围内且确实有害——拆成 R8(`ApplyChecked` 必然失败)、R9(`Apply` 不可重入);两份定价循环的代码重复拆成 R10 | + +另外两处收紧: + +- **R1 补边界注**:`reachable` 的 godoc 明写「a dependency shared by several rules appears once per path that reaches it」——按路径展开是**已声明的行为**。所以 R1 的成立点是**规模后果**(在 README 写的深度上限下会怎样),不是「重复展开」这件事本身。 +- **R7 从「不该报」移到「应当报出」**(新增):`Engine` 的 godoc 承诺「The rule set is fixed when the engine is built; ... an Engine may be shared by concurrent callers」,而 `New` 只是 `&Engine{rules: rules}`。承诺的「固定」没落地:调用方改一下 `rules[0].Discount`,引擎立刻按新值结算;并发改则 data race。v1 把它列进「不该报」的理由是「没有具体触发路径」——**这个判断是错的**。 + +**校正后的 GT 共 9 条计入分母**(R1、R3、R4、R5、R6、R7、R8、R9、R10)+ C1 + 6 条「不该报」。 + +## 汇总 + +### rule-engine(9 条口径,N=9) + +| 维度 | B(按七维审) | C(模块体检) | +|---|---|---| +| 命中 | 7.5 / 9(83.3%) | 7.5 / 9(83.3%) | +| 误报 | 0 条 | 0 条 | +| 归类合计 / GT 条目数 | 5.0 / 9 | 6.5 / 9 | +| 归类(按已报条目,辅助值) | 5.0 / 24(20.8%) | 6.5 / 23(28.3%) | +| 证据强度均值 | 4.88(117/24) | 5.00(115/23) | +| 表达质量 | 24.1 / 24.5 | 23.0 / 23.5 | +| **raw** | **80.5** | **82.0** | + +**归类的辅助值为什么这么低**:这两轮各交付了 23–24 条 finding,而 GT 只有 9 条——大部分条目落在 GT 之外(真缺陷但不计入分母),而没被报出的 GT 条目按 0 计进归类合计。这个值在这里**不能直接和 tiny-shop 的 55%–60% 比**,两个样本的 GT 密度不同。 + +### tiny-shop 回归(25 条口径,N=25) + +| 维度 | B(按七维审) | C(模块体检) | B(v4 旧基线) | C(v4 旧基线) | +|---|---|---|---|---| +| 命中 | 18.5 / 25(74.0%) | 17.0 / 25(68.0%) | 13.0 / 25(52.0%) | 19.5 / 25(78.0%) | +| 误报 | 0 条 | 0 条 | 0 条 | 0 条 | +| 归类合计 / GT 条目数 | 11.0 / 25 | 12.0 / 25 | 8.0 / 25 | 12.0 / 25 | +| 归类(按已报条目,辅助值) | 11.0 / 19(57.9%) | 12.0 / 20(60.0%) | 8.0 / 15(53.3%) | 12.0 / 15(80.0%) | +| 证据强度均值 | 4.32(82/19) | 4.40(88/20) | 4.73(71/15) | 4.20(63/15) | +| 表达质量 | 18.7 / 19.5 | 19.3 / 20.5 | 15.1 / 15.5 | 15.1 / 15.5 | +| **raw** | **156.1** | **158.3** | 154.4 | 151.6 | + +## 逐条对照 · rule-engine + +`命中/归类`。`—` 表示完全没涉及。 + +| GT | 级别 | B | C | 说明 | +|---|---|---|---|---| +| R1 依赖图按路径重复展开 + 成环栈溢出 | P1 | 1/1 | 1/1 | 两轮都独立成条并给了多档曲线(B:depth 32 → 11,405,740 条 / 5.97 s / 2,413 MB;C:depth 20 → 6,291,411 条 / 2.70 s / 15.1 GB),两轮都另立一条报环崩溃(同 P1) | +| R3 `copyChain` 每层深拷贝完整链 | P2 | 1/0 | 1/1 | B 在 R1 那条的证据里给了 `copyChain` 分配曲线(33,600 → 8,937,600 B,翻倍即 ×4),宿主条目是 P1 ≠ GT P2 → 归类 0;C 独立成条(过度设计视角),级别 P2 对齐 | +| R4 `discountFor` 每次重建整张索引表 | P1 | 0.5/0 | 1/1 | **B 漏了规模面**——只在 P0 那条里说了「每次都重建整张表」这个事实,覆盖表声称「性能 1 条:Apply 的索引重建与分配曲线」但正文没有对应条目;C 独立成条,4 档曲线 + 对照实验(改一次建表:66.4 ms → 1.93 ms) | +| R5 `Apply`/`ApplyChecked` 语义分裂 | 拍板 | 1/1 | 1/1 | 两轮都判成拍板。B 明写「README 和注释都写明了『快路径 / 安全路径』的分工,所以这属于**已知取舍**,按判定纪律不该算缺陷」——与 GT v2 完全对齐 | +| R6 `Pricer` 与 `sqlstore.Engine.Apply` 跨层形状 | P2 | 1/1 | 1/1 | 两轮都并排列出两个签名 + 编译期断言原文 | +| R7 `New` 不拷贝入参,godoc 承诺的「固定」没落地 | P2 | 1/1 | 1/1 | 两轮都引了 godoc 原文 + 实测(B:400 → −599;C:400 → 200) | +| R8 `ApplyChecked` 把「规则不生效」当成错误 → 必然失败 | P1 | 0.5/0 | 0.5/0 | **两轮都漏**。C 还在「已查过没问题」里肯定了 `hasUnknownSKU` 的语义与 README 一致 | +| R9 `Apply` 就地回写调用方购物车、不可重入 | P1 | 1/1 | 1/0.5 | C 判 P2,偏一级 | +| R10 `Apply`/`ApplyChecked` 两份逐行相同的定价循环 | P2 | 0.5/0 | 0/0 | **两轮都没独立成条**——B 把 `Apply` vs `ApplyChecked` 列进了「重复」维度的覆盖行、拍板项里也说了「两个方法做同一件事」,但没说「哪条需求变了必须两处一起改」,按 GT 的 0.5 档记 0.5;C 完全没碰。两轮都把这两条路径当成「语义分裂」处理,就不再说「重复」 | +| **命中合计 / 9** | | **7.5** | **7.5** | | +| **归类合计** | | **5.0** | **6.5** | 未报出记 0 | + +## 逐条对照 · tiny-shop(回归) + +| GT | 级别 | B | C | 说明 | +|---|---|---|---|---| +| G1 零值 `Checkout` panic | P1 | 1/1 | 1/1 | 两轮都有探针 | +| G2 `timeout` 形同虚设 | P1 | 1/0.5 | 1/0.5 | 两轮都报 P2,偏一级 | +| G3 `Code` 从未被读取 | P1 | 1/1 | 1/1 | 同一根因,扣 G9/G25 | +| G4 承诺没有对应对象 | P2 | 0.5/0 | 0.5/0 | 两轮都在「已查过没问题」里判定承诺成立(与 v4 同款) | +| G5 批次中途失败计数残留 | P1 | 1/0.5 | 1/1 | B 报 P2,偏一级 | +| G6 两份元→分换算漂移 | P2 | 1/0.5 | 1/0 | B 报 P1 偏一级;C 报 P0 偏两级 | +| G7 `E_INVALID` 两个真相源 | P2 | 1/1 | 1/1 | | +| G8 `refundWindow` 硬编码 | P2 | 1/1 | 1/1 | 两轮都判成业务政策值、应报 | +| G9 折扣接口过度设计 | P2 | 0/0 | 0/0 | 同一根因并入 G3 | +| G10 测试盲区 | P2 | 0.5/0 | 1/0.5 | B 只在覆盖行点到;C 在别的条目证据里点到了具体分支 | +| G11 测试用字符串比较断言 | P2 | 1/0.5 | 0/0 | B 在错误语义那条里写了;**C 漏** | +| G12 `Percent` 无范围校验 | 拍板 | 1/0 | 1/0.5 | B 报成 P2 缺陷 → 归类 0;C 在拍板项的现状里写了并给了修法方向 → 归类 0.5 | +| G13 退款窗口从查询时刻起算 | P1 | 1/0.5 | 1/1 | B 判成 bug 且没谈业务前提;C 既判成 bug 又在拍板项里问了「提醒还是闸门」 | +| G14 `Issue` 收元 / 返分 | P1 | 1/1 | 0/0 | **C 漏**——C 的拍板项谈的是「退款金额要不要和实收绑定」,不是单位错配 | +| G15 0 元退款被记成已退款 | P2 | 0.5/0 | 0/0 | B 只谈到「幂等键不含金额」这个相邻事实;两轮都没找到 0 元这个触发点 | +| G16 `Refunder{}` 零值 panic | P2 | 1/1 | 0.5/0 | **C 在「被排除的候选」里写了「真正的问题是零值不可用,已由『零值 panic』那条覆盖」**——它认出了这个问题,但那条只覆盖 `Checkout`,`Refunder` 的 nil map 没单独报;按相邻事实记 0.5,宿主是 P1 与 GT P2 不一致 → 归类 0 | +| G17 共享指针 + 非原子自增 | P1 | 1/0.5 | 1/1 | **B 的覆盖表写「并发与生命周期 1 条 P2」,正文却没有对应条目**;C 独立成条 | +| G18 `issued` map 只增不减 | P2 | 0.5/0 | 1/0.5 | B 的性能维度写「无无界缓存/无界分配」,与 G18 相反的覆盖声明(与 v4 同款假阴性);C 在拍板项的现状里写了并给了内存数字(1M → 71.9 MB) | +| G19 注释「第二个返回值」写反 | P2 | 0/0 | 0/0 | 两轮都漏 | +| G20 NaN/Inf 绕过校验 | P2 | 1/1 | 1/0.5 | B 报的是正数溢出(同位置、同根因「校验只挡了一侧」、同后果同修法,判命中 1——见口径分歧 #2);C 报 P1 偏一级 | +| G21 两个计数口径不一致 | P2 | 1/1 | 1/1 | | +| G22 状态持久性接缝缺失 | P2 | 0/0 | 1/0.5 | **B 漏**;C 报 P1 偏一级 | +| G23 `Order.ID` 零使用 | P3 | 0.5/0 | 1/1 | B 只说「在整个函数体里从未被使用」(GT 是全模块) | +| G24 折扣先截断再减 | P3 | 1/0 | 0/0 | B 在 G12 那条里顺带说了取整方向;**C 漏** | +| G25 折扣机制整体未接线 | P2 | 0/0 | 0/0 | 同一根因并入 G3 | +| **命中合计 / 25** | | **18.5** | **17.0** | | +| **归类合计** | | **11.0** | **12.0** | 未报出记 0 | + +## 证据强度明细 + +分母 = 该轮交付的 finding 条目数(含拍板项)。四轮**没有一条**触发「声称强度高于实际」或「已查过没问题没给验证方式」的扣分。 + +| 轮 | 条目与分数 | +|---|---| +| rule-engine B | P0-1 sqlstore 空壳 5、P0-2 全局查表折扣 5、P1-1 依赖成环栈溢出 5、P1-2 路径枚举 5、P1-3 Plan 不看购物车 5、P1-4 Explain 口径 5、P1-5 Depends 未用 5、P1-6 Apply 非幂等 + race 5、P2-1 负价 5、P2-2 New 别名 5、P2-3 跨层形状 5、P2-4 Lines 别名 5、P2-5 悬空依赖 5、P2-6 README 口径 5、P2-7 DuplicateLines 5、P2-8 ctx 5、P3-1 空 SKU 规则 5、**P3-2 ErrUnknownSKU 无上下文 4**、P3-3 findByID 线性扫描 5、**P3-4 README 上限不校验 3**、拍板-1 后端形状 5、拍板-2 路径收敛 5、拍板-3 立减口径 5、拍板-4 Depends 语义 5 → **117/24 = 4.88** | +| rule-engine C | P0-1 sqlstore 5、P1-1 全局查表折扣 5、P1-2 同一行被多规则各扣一次 5、P1-3 Depends 未读 5、P1-4 规模 discountFor 5、P1-5 规模依赖图 5、P1-6 依赖成环 5、P1-7 依赖 ID 不存在 5、P1-8 Plan 与 Apply 两个答案 5、P1-9 立减口径 5、P2-1 New 不复制 5、P2-2 Explain 别名 5、P2-3 copyChain 5、P2-4 ctx 5、P2-5 Pricer 5、P2-6 DuplicateLines 5、P2-7 Apply 非幂等 5、P2-8 README 上限 5、拍板-1 立减口径 5、拍板-2 Chain.Total 5、拍板-3 路径合并 5、拍板-4 sqlstore vs promo 5、拍板-5 Plan 语义 5 → **115/23 = 5.00** | +| tiny-shop B | P1-1 两份换算 5、P1-2 退款窗口 5、P1-3 Issue 单位 5、P1-4 Code 未接线 5、P1-5 Checkout panic 5、P2-1 Refunder 零值 5、P2-2 批次计数 5、P2-3 两个计数器 5、**P2-4 Run 只返回总额 2**、P2-5 timeout 5、**P2-6 refundWindow 3**、P2-7 PercentageDiscount 5、P2-8 溢出 5、P2-9 退款侧错误 5、拍板-1 折扣 3、拍板-2 Threshold 4、**拍板-3 金额类型 2**、拍板-4 幂等键 5、拍板-5 包名 3 → **82/19 = 4.32** | +| tiny-shop C | P0-1 两份换算 5、P1-1 NaN/Inf 5、P1-2 Checkout panic 5、P1-3 批次计数 5、P1-4 ExpiresAt 5、P1-5 幂等进程内 5、P1-6 discounted total + Code 5、P1-7 并发 5、P2-1 两计数器 5、P2-2 E_INVALID 两身份 5、P2-3 timeout 5、**P2-4 refundWindow 3**、**P2-5 E_INVALID 只带码 2**、P3-1 独占锁 5、**P3-2 Order.ID 3**、拍板-1 折扣 3、拍板-2 舍入 5、拍板-3 退款窗口 5、拍板-4 Refunder 真相源 5、**拍板-5 退款金额绑定 2** → **88/20 = 4.40** | + +**规律**:拿 5 分的条目几乎都带探针(跑测试 / 构造最小复现 / 编译期断言 / 崩溃复现 / 独立进程);拿 3 分的都是「grep + 读完整函数」类的静态结论;拿 2 分的是「读签名 + 说影响」这种没有独立验证手段的条目。 + +## 表达质量明细 + +| 轮 | 说明 | +|---|---| +| rule-engine B | 23 条 1.0;**P3-4 README 上限不校验 0.6**(讲清机制,只说「代码里一处都没有校验」,没有对照例子);**C1 +0.5**(`-race` 8 goroutine × 200 次共享 `*Engine` 零告警 + godoc 推理)→ **24.1 / 24.5** | +| rule-engine C | 23 条全 1.0;**C1 未达成**——这一轮的「已查过没问题」11 条里没有「共享 `*Engine` 并发定价没问题」这一条(并发维度的 2 条都是缺陷:`New` 别名与 `Explain` 返回值别名)→ **无 +0.5** → **23.0 / 23.5** | +| tiny-shop B | 17 条 1.0;**P2-4 Run 只返回总额 0.6**、**P2-6 refundWindow 0.6**(都是讲清机制但没配对照例子);**C1 +0.5**(锁范围推理 + 8 goroutine × 500 次 `-race` 零告警)→ **18.7 / 19.5** | +| tiny-shop C | 17 条 1.0;**P2-4 refundWindow 0.6**、**P2-5 E_INVALID 只带码 0.6**、**P3-2 Order.ID 0.6**;**C1 +0.5**(8 goroutine × 500 次 `-race` 零告警 + 锁范围推理)→ **19.3 / 20.5** | + +**C1 三轮达成、一轮未达成**:rule-engine B、tiny-shop B、tiny-shop C 都主动写清「并发这块查过没问题」并给了依据(`-race` 压测零告警 + 锁范围推理),且**没有把「调用方共享入参」的竞态误当成引擎自身的竞态**;只有 rule-engine C 这一轮没写——它的并发维度两条全是缺陷,「已查过没问题」里也没有并发那一条。 + +## 独立复算(不参考本文件重算一遍) + +本轮分数由一个独立子 Agent 按同一份细则复算过一次(不参考本文件、不看本节)。 + +| 轮 | 本基线 raw | 独立复算 raw | 差 | +|---|---|---|---| +| rule-engine B | 80.5 | 78.95 | +1.55 | +| rule-engine C | 82.0 | 82.00 | 0 | +| tiny-shop B | 156.1 | 158.54 | −2.44 | +| tiny-shop C | 158.3 | 160.05 | −1.75 | + +**结论方向完全一致**:四轮误报全 0;rule-engine C 轮命中与归类均不低于 B 轮;rule-engine 的证据强度高于 tiny-shop(4.88/5.00 vs 4.32/4.40);tiny-shop 两轮 raw 都高于 v4 旧基线;**R8 与 R10 是 rule-engine 两轮共同的薄弱点**。 + +**复算查出的一个实质错误(本基线已更正)**:rule-engine C 轮**没有**确认「并发没问题」——「引擎内部无共享可变状态」那句在**校正前**那一轮的报告里,我错带到了重跑轮。表达分 23.5 → 23.0,raw 82.5 → 82.0。 + +**其余差异全部落在上面那 9 处分歧里**,复算方在下列几处取了不同读法:R4@RE-B(覆盖表算不算正确陈述)、G20@TS-B(溢出算不算 NaN/Inf 的实质)、G10@TS-B/G10@TS-C(宿主级别不一致时的归类)、G12@TS-C(拍板项里的陈述算顺带还是排除)、G16@TS-C(附录里的「已由另一条覆盖」算不算命中)、以及拍板项的证据分(复算方按细则五「同报告已确立前提的探针也算」给到 5 分,本基线取保守档给 2–4 分)。 + +复算方自己列出的口径分歧建议(7 类)留在这份复算报告里,不入版本控制。**逐条按「两种读法」对齐之前,本表的数字应视为一个区间而不是一个点。** + +## GT 之外挖出的真缺陷(两轮合计,下一轮回填) + +GT 之外的真实问题**不扣分也不加分**(细则第三节),但它们证明「当前分母仍只覆盖样本真实缺陷的一部分」。四轮合计挖出 **20 条**,全部经读码/实测复核为真。 + +| # | 缺陷 | rule-engine B | rule-engine C | tiny-shop B | tiny-shop C | +|---|---|---|---|---|---| +| 1 | 折扣金额取自「全局 SKU→折扣」索引而非正在应用的那条规则(后写覆盖前写)→ 金额算错 | P0 | P1 | — | — | +| 2 | `Apply` 完全忽略 `Rule.Depends`,与 `Explain`/`Plan` 的依赖模型对不上;价格随切片顺序变 | P1 | P1 | — | — | +| 3 | 依赖 ID 写错 → `findByID` 返回零值 `Rule` → 静默按 0 折扣结算 | P2 | P1 | — | — | +| 4 | `sqlstore.Engine.Apply` 一条规则都不应用;`DB.Rules` / `req.RuleIDs` 都没被读 | P0 | P0 | — | — | +| 5 | `Outcome.Lines` 直接返回 `req.Items`,是同一块底层数组的别名 | P2 | — | — | — | +| 6 | `DuplicateLines` 返回的是「配对数」不是「出现重复的 SKU 数」 | P2 | P2 | — | — | +| 7 | 折扣超出商品价 → 负单价、负总额(无下限保护) | P2 | P1 | — | — | +| 8 | 立减粒度:README(整单一次)与实现(每命中行一次)不一致 | 拍板 | 拍板 | — | — | +| 9 | `Plan` 的语义与文档不符(忽略购物车,返回整张规则表的依赖闭包) | P1 | P1 | — | — | +| 10 | `Explain` 与 `Apply` 的算式不是一套(解释对不上价格) | P1 | — | — | — | +| 11 | `Explain` 的返回值与引擎内部规则共享底层数组 → 调用方改解释结果就改了规则集 | — | P2 | — | — | +| 12 | `Chain.Total` 把共享依赖按路径重复累加(depth 24 时报出 92 万分) | — | 拍板 | — | — | +| 13 | 共享依赖的折扣该算几次 | 拍板 | 拍板 | — | — | +| 14 | `Apply` 的 error 恒为 nil(虚假失败保险) | — | — | — | — | +| 15 | 四个导出方法的 `ctx` 全被忽略(取消无效) | P2 | P2 | — | — | +| 16 | 被跳过的规则无处可查(`Result` 只有 `Applied`,无 `Skipped`,全模块零日志) | — | — | — | — | +| 17 | 空 SKU 的规则被记成「已应用」 | P3 | — | — | — | +| 18 | `ErrUnknownSKU` 不带任何上下文(哪条规则、哪个 SKU 都丢掉了) | P3 | — | — | — | +| 19 | `findByID` 是线性扫描(当前不是瓶颈,记忆化后会成为) | P3 | — | — | — | +| 20 | README 声明的四个规模上限,代码里一处都没有校验 | P3 | P2 | — | — | + +## 口径待裁定:11 处分歧与数字影响 + +判分细则已经写进 `evals/rubric.md`,本轮基线按该细则算。下列 9 处是**判分规则本身的宽窄选择**,不是事实分歧——换个读法分数会变,列在这里是为了让基线可被挑战、可被复算。 + +| # | 分歧点 | 本基线采用 | 另一种读法 | 数字影响 | +|---|---|---|---|---| +| 1 | R4(B 轮):覆盖表里写「Apply 的索引重建与分配曲线」,正文却没有对应条目——算不算「覆盖表里的一段正确陈述」 | **记 0.5**(只陈述了「每次都重建整张表」这个相邻事实,没把它说成规模缺陷) | 算 1 | B 命中 +0.5 | +| 2 | G20(B 轮):报的是正数溢出(1e17 → MinInt64),不是 NaN/Inf——算不算 G20 的实质 | **算命中 1**:同位置(`checkout.go:68` + `:84`)、同根因(「`< 0` 校验只挡了一侧」)、同后果、同修法 | 要求点名 NaN/Inf → 0.5 | B 命中 −0.5、归类 −1 | +| 3 | G17(B 轮):覆盖表声称有条目、正文没有——算不算命中 | **算 1 / 归类 0.5**(覆盖表标 P2,GT P1 偏一级) | 正文没有就不算 → 0 | B 命中 −1、归类 −0.5 | +| 4 | G15(B 轮):「幂等键不含金额 → 重复请求被静默吞掉」算不算 G15 的相邻事实 | **记 0.5**(同一静默 no-op 机制,但没找到 0 元这个触发点) | 算 1 | B 命中 +0.5、归类 +0.5 | +| 5 | G12(C 轮):拍板项的「现状」里写了百分比越界,同时又在「被排除的候选」里排除它 | **记 1 / 归类 0.5**(在别的拍板项里顺带提到并给了修法方向) | 算明确排除 → 0.5 / 0 | C 命中 −0.5、归类 −0.5 | +| 6 | G10(C 轮):宿主条目级别(P1)与 GT(P2)不一致时的归类 | **记 0.5**(与 v4 同款处理,保持可比) | 记 0 | C 归类 −0.5 | +| 7 | G18(C 轮):在拍板项里陈述的 GT-缺陷条目,归类怎么算 | **记 0.5**(GT 判缺陷而报告判拍板) | 记 1 | C 归类 +0.5 | +| 8 | R3(B 轮):在别的条目里说对了,但宿主条目级别(P1)与 GT(P2)不一致 | **归类 0** | 归类 0.5 | B 归类 +0.5 | +| 9 | R8(两轮):明确否认了(拍板项里只当成两条路径的差异)/ 只陈述了相邻事实 | **命中 0.5、归类 0** | 记 0 | 两轮命中各 −0.5 | +| 10 | R10@RE-B:覆盖表把 `Apply` vs `ApplyChecked` 列在「重复」维度行里 + 拍板项说「两个方法做同一件事」,算不算「只说『有重复代码』」那档 | **记 0.5**(GT 的 0.5 档原文:「只说『有重复代码』记 0.5」) | 记 0 | RE-B 命中 −0.5 | +| 11 | G16@TS-C:附录写「真正的问题是零值不可用,已由『零值 panic』那条覆盖」,算不算识别了该缺陷 | **记 0.5**(陈述了实质,但没把它说成该缺陷、也没给触发路径) | 记 0 | TS-C 命中 −0.5 | + +## 方法论局限 + +- **GT v2 有 4 条(R7–R10)是首轮输出反推补进来的**。每条都能从源码独立推导(不是「模型说了所以它对」),但**作者看过输出**这件事本身是偏差源。这四条的命中在这一轮带 ±0.5 的自证偏差,下一轮起应作为常规条目。 +- **两个样本的 raw 不可比**:归一化因子 N 不同(25 vs 9)。跨样本只看维度占比。 +- **tiny-shop 的 v4 基线跑在冻结提交 `7c1c4ea`**,本轮跑在 `dad4adc`;中间除了新能力还改了执行顺序。**轮间波动也很大**(B 命中 +5.5、C 命中 −3.0),单轮差异小于 3 个百分点的结论不要当结论。 +- **每种问法只跑 1 轮**:没有方差估计。本轮四轮各只有一份样本。 +- **本轮分数经过一次独立复算**(见上一节),四个 raw 相差 0–2.44,方向完全一致;但复算方在 11 处分歧上取了不同读法,**逐条对齐之前这些数字应视为区间**。 +- **有一处覆盖声明失真**:tiny-shop B 轮的性能维度写「无无界缓存/无界分配」,与 GT 的 G18 相反——不构成编造缺陷、不扣误报分,但它是**假阴性**(与 v4 的 B 轮同款,说明这是该轮问法的稳定习惯)。 +- **有一处结论计数与正文对不上**:tiny-shop C 轮结论写「P2 6」,正文只有 5 条 P2。**结论里的各级数量不可直接采信**,评分以正文条目为准。 +- **没有做变异对照**:没改坏代码去验证每条 finding 是否真能让测试变红,「finding 是真的」靠读码 + 探针实测,不是测试红。 +- **跨样本的绝对耗时不可换算**:rule-engine 的规模数字来自本机单次基准,外推到 README 的深度上限是按实测曲线递推,不是实测值。 + +## 相关文件 + +| 文件 | 作用 | +|---|---| +| `evals/ground-truth-rule-engine.md` | 标准答案 v2(R1–R10 里的 9 条 + C1 + 6 条不该报),含 v1 → v2 的校正记录 | +| `evals/ground-truth.md` | tiny-shop 标准答案(25 条 + C1),本轮未改 | +| `evals/rubric.md` | 评分表与打分细则八节(已把 raw 的归一化因子从固定的 25 改成「该样本的 GT 条目数 N」) | +| `evals/runbook.md` | 怎么跑一次评测(两个样本两套标准答案) | +| `evals/fixtures/rule-engine/`、`evals/fixtures/tiny-shop/` | 两个样本 | +| 仓外 `scratchpads\dcr-eval-archive\round-{B,C}-rule-engine-v2.md`、`round-{B,C}-tiny-shop-v2.md` | 四轮原始输出全文,不入版本控制 | +| 仓外 `scratchpads\dcr-eval-archive\round-rule-engine\` | 校正前 GT 的那两轮原始输出(校正依据的来源) | diff --git a/evals/fixtures/rule-engine/README.md b/evals/fixtures/rule-engine/README.md new file mode 100644 index 0000000..0d66592 --- /dev/null +++ b/evals/fixtures/rule-engine/README.md @@ -0,0 +1,24 @@ +# rule-engine(评测样本) + +这是一个**刻意植入缺陷**的最小样本仓库,只用于评测 `deep-code-review` 的审查能力。 +它不是生产代码,也不实现完整业务。 + +## 业务约定 + +- 金额一律以**分**为单位。`Item.Price` 是单价,`Item.Count` 是数量。 +- 促销规则 `Rule` 在购物车同时包含它列出的全部 SKU 时生效,立减 `Discount` 分。 +- `Rule.Depends` 列出必须先应用的规则。**多条规则依赖同一条规则是允许的**(共享依赖)。 +- 规模:一次请求的购物车最多 **200 个商品行**;规则集最多 **200 条规则**;依赖链最深 **50 层**;一条规则最多列 **12 个 SKU**。 +- 两个包都在模块内部(`internal/`),不构成对外公开契约。 + +## 结构 + +```text +internal/ +├── promo/ 规则类型与内存引擎 +└── promo/sqlstore/ 以数据库为后端的引擎 +``` + +## 怎么用 + +见 `evals/runbook.md`。 diff --git a/evals/fixtures/rule-engine/go.mod b/evals/fixtures/rule-engine/go.mod new file mode 100644 index 0000000..51edf69 --- /dev/null +++ b/evals/fixtures/rule-engine/go.mod @@ -0,0 +1,3 @@ +module example.com/rule-engine + +go 1.21 diff --git a/evals/fixtures/rule-engine/internal/promo/engine.go b/evals/fixtures/rule-engine/internal/promo/engine.go new file mode 100644 index 0000000..64457c5 --- /dev/null +++ b/evals/fixtures/rule-engine/internal/promo/engine.go @@ -0,0 +1,223 @@ +package promo + +import "context" + +// Engine is the in-memory promotion engine. +// +// The rule set is fixed when the engine is built; pricing a cart never +// changes it, so an Engine may be shared by concurrent callers. +type Engine struct { + rules []Rule +} + +// New returns an Engine that applies the given rules. +func New(rules []Rule) *Engine { + return &Engine{rules: rules} +} + +// Plan describes what the engine would do for a cart. +type Plan struct { + // Rules lists every rule that takes part in pricing the cart, the rules + // they depend on included. + Rules []Rule +} + +// Plan returns the rules that take part in pricing the cart. +func (e *Engine) Plan(ctx context.Context, req ApplyRequest) (*Plan, error) { + return &Plan{Rules: reachable(e.rules)}, nil +} + +// Chain is the discount chain of a rule: the rule itself plus every rule it +// depends on. +type Chain struct { + // Rule is the rule the chain belongs to. + Rule Rule + // Total is the discount the rule and its dependencies add up to. + Total int64 + // Dependencies holds the chain of every dependency. + Dependencies []Chain +} + +// Explain returns the discount chain of the rule with the given ID. +func (e *Engine) Explain(ctx context.Context, id string) (Chain, error) { + for _, r := range e.rules { + if r.ID == id { + return resolve(r, e.rules), nil + } + } + return Chain{}, ErrUnknownRule +} + +// Apply prices the cart against the engine's rule set. +// +// Apply is the fast path. A rule that refers to a SKU the cart does not hold +// is skipped, and the discounted price of every covered line is written back +// into the cart. +func (e *Engine) Apply(ctx context.Context, req ApplyRequest) (*Result, error) { + total := cartTotal(req.Cart) + applied := make([]string, 0, len(e.rules)) + for _, r := range e.rules { + if e.hasUnknownSKU(r, req.Cart) { + continue + } + for i := range req.Cart { + if !containsSKU(r.SKUs, req.Cart[i].SKU) { + continue + } + off := e.discountFor(req.Cart[i].SKU) + req.Cart[i].Price -= off + total -= off * req.Cart[i].Count + } + applied = append(applied, r.ID) + } + return &Result{Total: total, Applied: applied}, nil +} + +// ApplyChecked prices the cart against the engine's rule set. +// +// ApplyChecked is the safe path. It returns ErrUnknownSKU when a rule refers +// to a SKU the cart does not hold, and it leaves the cart unchanged. +func (e *Engine) ApplyChecked(ctx context.Context, req ApplyRequest) (*Result, error) { + total := cartTotal(req.Cart) + applied := make([]string, 0, len(e.rules)) + for _, r := range e.rules { + if e.hasUnknownSKU(r, req.Cart) { + return nil, ErrUnknownSKU + } + for i := range req.Cart { + if !containsSKU(r.SKUs, req.Cart[i].SKU) { + continue + } + total -= e.discountFor(req.Cart[i].SKU) * req.Cart[i].Count + } + applied = append(applied, r.ID) + } + return &Result{Total: total, Applied: applied}, nil +} + +// DuplicateLines reports the SKUs that appear on more than one cart line. +func DuplicateLines(cart []Item) []string { + var dupes []string + for i, a := range cart { + for j, b := range cart { + if i >= j { + continue + } + if a.SKU == b.SKU { + dupes = append(dupes, a.SKU) + } + } + } + return dupes +} + +// discountFor returns the discount the engine offers for the given SKU. +func (e *Engine) discountFor(sku string) int64 { + index := make(map[string]int64, len(e.rules)) + for _, r := range e.rules { + for _, s := range r.SKUs { + index[s] = r.Discount + } + } + return index[sku] +} + +// hasUnknownSKU reports whether the rule refers to a SKU the cart does not +// hold. +func (e *Engine) hasUnknownSKU(r Rule, cart []Item) bool { + for _, sku := range r.SKUs { + if !cartHasSKU(cart, sku) { + return true + } + } + return false +} + +// cartHasSKU reports whether the cart holds the given SKU. +func cartHasSKU(cart []Item, sku string) bool { + for _, it := range cart { + if it.SKU == sku { + return true + } + } + return false +} + +// reachable returns every rule reachable from the given rules, including the +// rules they depend on. +// +// The result keeps the order the rules were found in, so a dependency shared +// by several rules appears once per path that reaches it. +func reachable(rules []Rule) []Rule { + var out []Rule + for _, r := range rules { + out = append(out, r) + out = append(out, reachableFrom(r, rules)...) + } + return out +} + +// reachableFrom returns the rules the given rule depends on, and everything +// those depend on. +func reachableFrom(rule Rule, rules []Rule) []Rule { + var out []Rule + for _, dep := range rule.Depends { + child := findByID(rules, dep) + out = append(out, child) + out = append(out, reachableFrom(child, rules)...) + } + return out +} + +// resolve evaluates a rule and everything it depends on. +// +// The full chain is kept so that callers can explain how the total was +// reached. +func resolve(rule Rule, rules []Rule) Chain { + out := Chain{Rule: rule, Total: rule.Discount} + for _, dep := range rule.Depends { + child := resolve(findByID(rules, dep), rules) + copied := copyChain(child) + out.Total += child.Total + out.Dependencies = append(out.Dependencies, copied) + } + return out +} + +// copyChain copies a chain together with everything nested in it. +func copyChain(c Chain) Chain { + cpy := Chain{Rule: c.Rule, Total: c.Total} + for _, d := range c.Dependencies { + cpy.Dependencies = append(cpy.Dependencies, copyChain(d)) + } + return cpy +} + +// findByID returns the rule with the given ID. +func findByID(rules []Rule, id string) Rule { + for _, r := range rules { + if r.ID == id { + return r + } + } + return Rule{} +} + +// containsSKU reports whether the list mentions the given SKU. +func containsSKU(skus []string, sku string) bool { + for _, s := range skus { + if s == sku { + return true + } + } + return false +} + +// cartTotal returns the value of the cart in cents. +func cartTotal(cart []Item) int64 { + var total int64 + for _, it := range cart { + total += it.Price * it.Count + } + return total +} diff --git a/evals/fixtures/rule-engine/internal/promo/engine_test.go b/evals/fixtures/rule-engine/internal/promo/engine_test.go new file mode 100644 index 0000000..3201412 --- /dev/null +++ b/evals/fixtures/rule-engine/internal/promo/engine_test.go @@ -0,0 +1,46 @@ +package promo + +import ( + "context" + "testing" +) + +func TestApplyPricesCart(t *testing.T) { + rules := []Rule{ + {ID: "r1", SKUs: []string{"apple"}, Discount: 100}, + } + e := New(rules) + cart := []Item{{SKU: "apple", Count: 2, Price: 500}} + + got, err := e.Apply(context.Background(), ApplyRequest{Cart: cart}) + if err != nil { + t.Fatal(err) + } + if got.Total != 800 { + t.Fatalf("total = %d, want 800", got.Total) + } +} + +func TestApplyCheckedRejectsUnknownSKU(t *testing.T) { + rules := []Rule{ + {ID: "r1", SKUs: []string{"apple", "pear"}, Discount: 100}, + } + e := New(rules) + cart := []Item{{SKU: "apple", Count: 1, Price: 500}} + + if _, err := e.ApplyChecked(context.Background(), ApplyRequest{Cart: cart}); err != ErrUnknownSKU { + t.Fatalf("err = %v, want %v", err, ErrUnknownSKU) + } +} + +func TestDuplicateLinesFindsRepeat(t *testing.T) { + cart := []Item{ + {SKU: "apple", Count: 1, Price: 100}, + {SKU: "apple", Count: 2, Price: 100}, + {SKU: "pear", Count: 1, Price: 100}, + } + dupes := DuplicateLines(cart) + if len(dupes) != 1 || dupes[0] != "apple" { + t.Fatalf("dupes = %v, want [apple]", dupes) + } +} diff --git a/evals/fixtures/rule-engine/internal/promo/rule.go b/evals/fixtures/rule-engine/internal/promo/rule.go new file mode 100644 index 0000000..7c61848 --- /dev/null +++ b/evals/fixtures/rule-engine/internal/promo/rule.go @@ -0,0 +1,60 @@ +// Package promo implements the promotion rule engine. +// +// This package is internal to the pricing service; nothing outside the module +// imports it. +package promo + +import ( + "context" + "errors" +) + +// ErrUnknownSKU is returned when a rule refers to a SKU the cart does not +// hold. +var ErrUnknownSKU = errors.New("E_UNKNOWN_SKU") + +// ErrUnknownRule is returned when a rule ID does not name a rule of the set. +var ErrUnknownRule = errors.New("E_UNKNOWN_RULE") + +// Item is one line of a shopping cart. +type Item struct { + // SKU identifies the product on this line. + SKU string + // Count is how many units the line holds. + Count int64 + // Price is the unit price in cents. + Price int64 +} + +// Rule is a promotion rule. It takes Discount cents off the cart when the +// cart holds every SKU the rule lists. +type Rule struct { + // ID identifies the rule. + ID string + // SKUs are the products the rule applies to. + SKUs []string + // Discount is the amount taken off, in cents. + Discount int64 + // Depends lists the IDs of the rules that must be applied first. Several + // rules may depend on the same rule. + Depends []string +} + +// Pricer prices a cart against a set of promotion rules. +type Pricer interface { + Apply(ctx context.Context, req ApplyRequest) (*Result, error) +} + +// ApplyRequest carries the cart to price. +type ApplyRequest struct { + // Cart is the cart to price. + Cart []Item +} + +// Result is the outcome of pricing a cart. +type Result struct { + // Total is the price in cents after the rules were applied. + Total int64 + // Applied lists the IDs of the rules that were applied. + Applied []string +} diff --git a/evals/fixtures/rule-engine/internal/promo/sqlstore/store.go b/evals/fixtures/rule-engine/internal/promo/sqlstore/store.go new file mode 100644 index 0000000..0734649 --- /dev/null +++ b/evals/fixtures/rule-engine/internal/promo/sqlstore/store.go @@ -0,0 +1,48 @@ +// Package sqlstore implements the promotion engine backed by a SQL database. +// +// This package is internal to the pricing service; nothing outside the module +// imports it. +package sqlstore + +import ( + "context" + + "example.com/rule-engine/internal/promo" +) + +// Request carries the cart to price. +type Request struct { + // Items is the cart to price. + Items []promo.Item + // RuleIDs restricts pricing to the listed rules. + RuleIDs []string +} + +// Outcome is the result of pricing a cart. +type Outcome struct { + // Total is the price in cents after the rules were applied. + Total int64 + // Lines holds one entry per priced cart line. + Lines []promo.Item +} + +// DB is the rule table the engine reads from. +type DB struct { + // Rules are the stored rules. + Rules []promo.Rule +} + +// Engine prices carts against the rules stored in the database. +type Engine struct { + // DB holds the rule table. + DB *DB +} + +// Apply prices the cart against the stored rules. +func (e *Engine) Apply(ctx context.Context, req Request) (*Outcome, error) { + var total int64 + for _, it := range req.Items { + total += it.Price * it.Count + } + return &Outcome{Total: total, Lines: req.Items}, nil +} diff --git a/evals/ground-truth-rule-engine.md b/evals/ground-truth-rule-engine.md new file mode 100644 index 0000000..6e919c7 --- /dev/null +++ b/evals/ground-truth-rule-engine.md @@ -0,0 +1,65 @@ +# 标准答案(rule-engine 样本 · v2) + +`fixtures/rule-engine/` 里**刻意植入**的缺陷清单。这份文件**不要给被测模型看**。 + +这个样本专门测**算法/复杂度**与**架构/设计语义**两类问题——tiny-shop 样本里没有这两种形态,所以单独一套标准答案、单独一轮评测。条目编号用 `R×`,与 tiny-shop 的 `G×` 互不干扰。 + +样本很小(约 370 行 Go),正常一轮就能读全——漏报不能用「没读完」解释。 + +## 校正记录(v1 → v2,2026-10-28) + +v2 由首轮两轮评测的实测结果反推校正。**每条校正都附了依据**,可以直接复核。 + +| # | v1 | v2 | 依据 | +|---|---|---|---| +| 1 | R2 `DuplicateLines` O(n²) 在「应当报出」,P2 | 移入「不该报」 | README 写明一次请求最多 200 个商品行。实测 200 行 → 19,900 次比较 ≈ **0.5 ms**;平方级在这个上限内不可观测。B 轮明确判定「O(C²) 本身不是问题」并给出实测数字。**原 GT 把「复杂度形状」当成了「规模后果」**——这正是 v2 要校准的东西。`DuplicateLines` 的真问题在语义(返回配对数而不是去重集合),那是 GT 之外的另一条 | +| 2 | R4 `discountFor` 重建索引,P2 | 级别改 **P1** | README 上限(200 条规则 × 200 行购物车)下单请求实测 **877 ms / 250 MB 分配**。按 skill 自己写的分级校准「现实规模下就拖垮 → P1」,P2 判轻了 | +| 3 | R5 `Apply`/`ApplyChecked` 语义分裂,P1 缺陷 | 级别改 **拍板**,并拆出 R8 / R9 / R10 | 两条路径的差异**已被 godoc 明说**:`Apply` 是 "fast path"(跳过 + 回写购物车)、`ApplyChecked` 是 "safe path"(返回 `ErrUnknownSKU` + 不改购物车)。按 `references/dimensions.md` 第 6 维「上层注释已承认差异 = 已知取舍 → 进拍板,不是缺陷」,R5 的主体应是拍板项。但**两条路径各自的后果**里,有两条不在 godoc 覆盖范围内且确实有害——拆成 R8、R9;两份定价循环的代码重复拆成 R10 | +| 4 | v1 无此条 | 新增 R7 `New` 不拷贝入参 | `Engine` 的 godoc 写着「The rule set is fixed when the engine is built; ... an Engine may be shared by concurrent callers」,但 `New` 只是 `&Engine{rules: rules}`——直接持有调用方的切片。**承诺的「固定」没有落地**:调用方改一下 `rules[0].Discount`,引擎立刻按新值结算;并发改则 data race。v1 把这条列进了「不该报」(理由「没有具体触发路径」)——**这个判断是错的**,触发路径是有的 | +| 5 | R1 无边界注 | 补边界注 | `reachable` 的 godoc 明写「a dependency shared by several rules appears once per path that reaches it」——**按路径展开是已声明的行为**。所以 R1 的成立点是**规模后果**(在 README 写的深度上限下会怎样),不是「重复展开」这件事本身 | + +**分母**:v2 的「应当报出」共 **9 条**(R1、R3、R4、R5、R6、R7、R8、R9、R10)+ C1。R3、R4、R5、R6 的编号沿用 v1(v1 的 R2 已移出)。 + +## 应当报出 + +| # | 位置 | 维度 | 级别 | 问题 | 触发路径 / 规模 | 可接受的变体 | +|---|---|---|---|---|---|---| +| R1 | `internal/promo/engine.go:151` `reachable`、`:162` `reachableFrom` | 规模与复杂度 | P1 | 依赖图里有**共享节点**时,同一条依赖按**路径**反复展开:`reachableFrom` 每遇到一条 `Depends` 就递归展开整棵子树,没有已访问集合、没有记忆化。依赖图呈菱形时展开条数按 2^depth 增长,呈阶梯形(每档依赖前两档)时按 Fibonacci 增长。另外**没有环检测**:规则集里出现环(`a` 依赖 `b`、`b` 依赖 `a`)时无限递归 → `fatal error: stack overflow`,进程不可恢复地崩掉 | README 写明依赖链最深 50 层。实测菱形图 depth 16 → 524,249 条 / 181 ms;depth 20 → 8,388,561 条 / **3.53 s**;阶梯图 depth 24 → 317,784 条、**同一个规则 ID 出现 121,392 次**(规则集本身只有 24 条规则) | 指出「同一依赖被重复展开 / 没有已访问集合 / 共享依赖下指数增长」任一,**并**给出规模关系或实测数字。**只说「按路径展开、可能有重复」不构成命中**——godoc 已声明该行为,成立点是它在 README 写的深度上限下的规模后果。**环导致栈溢出**单独命中也算 | +| R3 | `internal/promo/engine.go:176` `resolve`、`:188` `copyChain` | 规模与复杂度 / 过度设计 | P2 | `resolve` 每层都把子链**完整深拷贝**一份(`copyChain`)再塞进 `Dependencies`。深度 d 的链要留 O(d²) 份节点。而且这份拷贝是**纯冗余**:`child` 刚由 `resolve` 现场造出来、随即被丢弃,除了 `copyChain` 这一份没人持有它 | 深度 25 → 33.6 KB;50 → 137.2 KB;100 → 554.4 KB;200 → 2,228,800 B(深度翻倍,分配 ×4)。冗余视角:阶梯图 depth=24 时 `Explain` 耗时 110.93 ms,去掉拷贝后 8.11 ms(**拷贝占 92.7%**),分配 266.6 MB | 指出「每层深拷贝完整链 / 内存随深度平方增长 / 应该边走边累加」任一;**或**指出「这份拷贝没有任何存在理由,`child` 随即被丢弃」(过度设计视角)。两种都要**并**给出分配量或规模 | +| R4 | `internal/promo/engine.go:115` `discountFor`,被 `:67` `Apply` 的内层循环调用 | 规模与复杂度 | P1 | `discountFor` 每次被调用都重新 `make` 一张 map 并把全部规则的 SKU 填进去,然后用这张表查**一个** key。`Apply` 的内层循环每覆盖一行就调一次 → 索引被反复重建,单次 `Apply` 退化成 O(规则数 × 行数 × 规则-SKU 总数) | 100 规则 / 200 行 → 索引重建 200 次,1.08 ms / 2.01 MB。README 上限(200 规则 × 12 SKU × 200 行)下单请求实测 **877 ms / 250.6 MB 分配** | 指出「索引在函数内部重建 / 应该建一次复用 / 查一个 key 却重建全表」任一,**并**指出它在循环里。**只说「每次重建 map」而没指出调用点在循环里,记 0.5** | +| R5 | `internal/promo/engine.go:56` `Apply` 与 `:80` `ApplyChecked` | 重复(语义分裂) | **拍板** | 同一个业务动作(给购物车套用促销规则)有两条导出路径,且**行为不一致**:(a) `Apply` 把折后单价**就地写回调用方的购物车**,`ApplyChecked` 不改;(b) 规则引用购物车没有的 SKU 时,`Apply` **静默跳过**该规则,`ApplyChecked` 直接返回 `ErrUnknownSKU`。两条路径的差异**已被 godoc 承认**("fast path" / "safe path"),但**调用方凭什么该走哪条没有任何判据**;统一抽象 `promo.Pricer` 只声明了 `Apply`(宽松那条),等于把「静默丢规则 + 原地改写购物车」接给了所有统一调用方 | 同一个购物车分别传给两个入口:折后单价一个被改一个没被改;含未知 SKU 的规则集一个静默少算一条、一个直接报错。拿 `promo.Pricer` 接后端时,接上的必然是 `Apply` | 指出「同一动作两条路径 / 一个改一个不改 / 一个静默跳过一个报错」任一,**并**指出具体差异。**作为拍板项必须给两条路 + 建议 + 要你定什么**(例如「合并成一个动作,差异变参数」vs「保留两条但把差异显式化」)。判成 P 级缺陷 → 归类按 rubric「GT 判拍板而报告判缺陷」处理 | +| R6 | `internal/promo/rule.go:44` `Pricer` 与 `internal/promo/sqlstore/store.go:42` `Apply` | 方法契约(跨层形状) | P2 | `promo.Pricer` 要求 `Apply(ctx, promo.ApplyRequest) (*promo.Result, error)`;`sqlstore.Engine.Apply` 是 `Apply(ctx, sqlstore.Request) (*sqlstore.Outcome, error)`——请求类型字段名不同(`Cart` vs `Items`)、多一个 `RuleIDs`、返回类型不同(`Result{Total, Applied}` vs `Outcome{Total, Lines}`)。`sqlstore.Engine` 因此**不满足 `promo.Pricer`**,换后端要改调用代码。**这不是编译错误**(各写各的具体类型完全能编过,裂缝只在换后端那天显形) | 想把定价后端从内存换成数据库:持有 `promo.Pricer` 的调用方装不上 `sqlstore.Engine`;写 `var _ promo.Pricer = (*sqlstore.Engine)(nil)` 编译失败 | **并排列出两个签名**并指出具体差异(字段名 / 字段数 / 返回类型任一)。只说「两个后端接口不一样」记 0.5 | +| R7 | `internal/promo/engine.go:14` `New` | 方法契约 / 文档承诺 | P2 | `New` 直接存下调用方传进来的 `[]Rule`(`Rule.SKUs` / `Rule.Depends` 同样是共享的),既不拷贝也不说明。而 `Engine` 的 godoc 明写「**The rule set is fixed when the engine is built**; pricing a cart never changes it, so **an Engine may be shared by concurrent callers**」——承诺的「固定」建立在一句调用方不会改的君子协定上 | 构造后把 `rules[0].Discount` 改成 999,引擎立刻按 999 结算(单价 1000 → 结算 1 分)。一个 goroutine 改 `rules`、另一个调 `Apply` → `-race` 报 `WARNING: DATA RACE` | 指出「`New` 没有拷贝入参」**并**引出「文档承诺的『固定』没有落地」或具体触发路径(改切片 → 金额错 / 并发改 → data race)。只说「建议 `New` 里拷一份」但没指出承诺与触发 → 记 0.5 | +| R8 | `internal/promo/engine.go:127` `hasUnknownSKU`,被 `:84` `ApplyChecked` 调用 | 实现 / 错误语义 | P1 | `hasUnknownSKU` 判的是「购物车没有某条规则要求的 SKU」——而那正是「这条规则不生效」的**正常状态**,不是错误。`ApplyChecked` 因此在**任何非空规则集**下必然返回 `ErrUnknownSKU`:「safe path」永远不可能成功,线上实际只剩「静默跳过规则」的 `Apply` 在跑 | 20 条规则(每条 6 个 SKU)+ 一行购物车,该购物车其实一条规则都没触发 → 仍然返回 `ErrUnknownSKU`(规则实际触发数 0 of 20)。既有测试 `TestApplyCheckedRejectsUnknownSKU` 正是把这个行为断言成了预期 | 指出「`ApplyChecked` 在任何非空规则集下必然失败 / `hasUnknownSKU` 把『规则不生效』当成了错误 / safe path 永不成功」任一,**并**给出触发场景或实测 | +| R9 | `internal/promo/engine.go:56` `Apply` | 实现 | P1 | `Apply` 把折后单价**就地写回调用方的购物车**(`req.Cart[i].Price -= off`),而 godoc 只说「written back into the cart」,**没说它不可重入**。同一个 `[]Item` 调用两次 `Apply`(重试、超时后重算、算完再确认一次)会**扣两次钱** | 同一个 `[]Item` 连续调两次:第一次 `total=400`(行单价 500→400),第二次 `total=300`(行单价再降 100)。结果取决于调用历史,不取决于入参 | 指出「`Apply` 就地改写调用方的购物车 / 不可重入 / 重复调用会重复扣减 / 结果取决于调用历史」任一,**并**给出触发路径或实测数字 | +| R10 | `internal/promo/engine.go:59-72` `Apply` 与 `:83-94` `ApplyChecked` | 重复(真重复) | P2 | 两个方法的定价循环体是复制关系。折扣口径、依赖顺序、SKU 匹配三条需求,任何一条变化都必须同时改两处——改漏一处就是两个入口给出不同金额 | 逐行可对:`Apply` 循环写回购物车、`ApplyChecked` 循环只算账,其余结构一致 | 指出「两份几乎逐行相同的定价循环 / 同一逻辑两处实现」,**并**指出至少一条「哪条需求变了这两处必须一起改」。只说「有重复代码」记 0.5 | + +## 应当明确说「查过没问题」 + +| # | 位置 | 事实 | 判定标准 | +|---|---|---|---| +| C1 | `internal/promo/engine.go:9` `Engine` | `Engine.rules` 只读;`Apply` / `ApplyChecked` / `Plan` / `Explain` 都不改引擎状态——`Apply` 改的是**调用方传入的购物车切片**(这一点本身是 R9,不影响并发安全结论)。多个 goroutine 共用同一个 `*Engine` 并发定价,不会互相影响 | 好的报告会写明「并发这块查过没问题」并给依据(rules 只读 + 改的是入参切片)。**凭空编造竞态算误报**;把 R9 的「调用方改自己传入的切片」当成引擎竞态也算误报 | + +## 不该报 + +| 候选 | 为什么不报 | +|---|---| +| `internal/promo/engine.go:217` `cartTotal` 每次都遍历一遍购物车 | 单次线性遍历,购物车最多 200 行。这不是复杂度缺陷 | +| `Engine` 用指针、`New` 返回 `*Engine` | 正常写法;而且 `Engine{}` 零值可用(`rules` 为 nil 时 `Apply` 照样返回结果),不存在「零值不可用」 | +| `internal/promo/rule.go:55` `Result.Applied` 暴露规则 ID 列表 | 这是对外契约的一部分,不是过度设计 | +| `ErrUnknownSKU` / `ErrUnknownRule` 用 `errors.New` 哨兵 | 正常做法,不该报成错误语义问题 | +| **`internal/promo/engine.go:99` `DuplicateLines` 的 O(C²) 两两比较** | **v2 移入(原 R2)**。README 上限 200 行 → 19,900 次比较,实测 **0.5 ms**——平方级在这个上限内不可观测,不构成规模缺陷。**「两两比较、应该建 map」这个形状本身不是一个缺陷**;它的真问题在语义(返回配对数而不是去重集合),那是 GT 之外的另一条缺陷,见 baseline 的「GT 之外的 14 条真缺陷」 | + +## 计分要点 + +- **规模类条目(R1、R3、R4)没有触发路径**,按「规模关系 + 实测」判:说不出 N 是多少、或给不出任何数字的,最多记 0.5。这一条是为了区分「真的看出了复杂度问题」与「泛泛地说这里可能慢」。 +- **R1 的成立点是规模后果,不是「重复展开」这件事**——`reachable` 的 godoc 已经声明了按路径展开。只陈述该行为、没有落到 README 写的深度/规模上限上会怎样,最多记 0.5。 +- **R3 的两种形态都算数**:规模视角(O(d²) 内存、分配量)与过度设计视角(`child` 随即丢弃、删掉零损失)。两种都要配数字。 +- **R1 与 R3 是两个不同根因**(重复展开 vs 深拷贝),都发生在依赖图上,分开计。R1 的前提是依赖图**有共享节点**(或成环);R3 的前提是链**够深**。 +- **R4 的成立前提是它在热路径上**:只说「这里每次重建 map」而没指出调用点在循环里,记 0.5。 +- **R5 是拍板项**:必须给「两条路 + 建议 + 要你定什么」才算表达满分;判成 P 级缺陷不是错,但归类按 rubric「GT 判拍板而报告判缺陷 → 0」处理。 +- **R8、R9 是缺陷项**:说对了实质但归进「需要你拍板」,命中算 1、归类算 0.5(rubric「GT 判缺陷而报告判拍板」)。 +- **R7 要引出承诺**:只说「建议 `New` 拷一份」而没指出 godoc 承诺的「固定」没落地,记 0.5。 +- **R10 要指明哪条需求必须两处一起改**,否则记 0.5。 +- **R6 要并排列出两个签名**并指出具体差异才算 1;只说「两个后端接口不一样」记 0.5。 +- **快模式(A 轮)本就不跑规模扫描与反驳**,R1、R3、R4、R5 在 A 轮不该拿满分——判分时按该轮的模式契约算(见 `evals/rubric.md`)。 diff --git a/evals/ground-truth.md b/evals/ground-truth.md index df9a06d..b898026 100644 --- a/evals/ground-truth.md +++ b/evals/ground-truth.md @@ -42,8 +42,8 @@ | G20 | `checkout.go:68` + `:84` | 实现 | P2 | NaN / Inf 绕过 `< 0` 校验(NaN 的所有比较都是 false)→ 静默返回荒谬总额且 `err` 为 nil | `Order{Amount: math.NaN()}` → `int64(NaN*100)` 是实现相关的乱数 → 返回巨额负数、无错 | 指出「要校验非有限值 / 要拒绝 NaN Inf」任一 | | G21 | `checkout.go:71` vs `:73` | 实现 | P2 | `Stats.CheckedOut` 在阈值判断**之前**自增,被跳过的订单也计成「已结算」,与 `Processed` 口径冲突 | `Run([{Amount:5.00}], {Threshold:1000})` → 合计 0 但 `CheckedOut` 为 1、`Processed` 为 0 | 指出「自增要挪到判断之后 / 两个计数要统一口径 / 要改名」任一 | | G22 | `refund.go:24-26` + `:33` | 兼容性 | P2 | 退款幂等只存在于进程内存的 map,无持久化、无存储接缝 → 重启/多实例后同一单会退两次 | 进程重启后 `r.Issue("o1", ...)` → 判重失效,重复退款 | 指出「要抽存储接口 / 幂等要跨进程 / 至少在 godoc 写明仅限本进程」任一 | -| G23 | `checkout.go:42` | 过度设计 | P3 | `Order.ID` 全模块零使用(grep `\.ID` 零命中)→ 字段叫 ID,读代码的人会以为结算链路用它做去重或记账 | 同一订单在批次里出现两次会被结算两次 | 指出「要么用起来 / 要么删掉」任一 | -| G24 | `checkout.go:36` | 实现 | P3 | `PercentageDiscount.Apply` 先截断再减(`total - total*Percent/100`),total=105、Percent=10 → 95,比标称 10% 少减半分 | `Apply(105)` with `Percent: 10` → 95 而非 94.5 | 指出「取整方向要明确 / 要先乘后除 / 换算要与金额换算同一套」任一 | +| G23 | `checkout.go:41` | 过度设计 | P3 | `Order.ID` 全模块零使用(grep `\.ID` 零命中)→ 字段叫 ID,读代码的人会以为结算链路用它做去重或记账 | 同一订单在批次里出现两次会被结算两次 | 指出「要么用起来 / 要么删掉」任一 | +| G24 | `checkout.go:35` | 实现 | P3 | `PercentageDiscount.Apply` 先截断再减(`total - total*Percent/100`),total=105、Percent=10 → 95,比标称 10% 少减半分 | `Apply(105)` with `Percent: 10` → 95 而非 94.5 | 指出「取整方向要明确 / 要先乘后除 / 换算要与金额换算同一套」任一 | | G25 | `checkout.go:24-37` | 设计 | P2 | `DiscountStrategy` 接口只有 1 个实现、0 个调用者,且 `internal/` 下不构成对外契约 | —— | 指出「删掉接口直接用具体类型 / 给出第二个真实使用者 / 把折扣接进 `Run`」任一 | **G25 与 G3 / G9 的关系**:G3(`Code` 字段从未被读取)、G9(`DiscountStrategy` 过度设计)、G25 是同一根因「折扣能力整体没接线」在三个维度上的投影。计分时按「同一根因只算一次」处理。 @@ -58,7 +58,7 @@ | 候选 | 为什么不报 | |---|---| -| 两个包都叫 `package shop`、一个文件里放多个类型 | 命名与分包口味,`SKILL.md` 的不报清单第一条。**import 别名是 Go 的正常机制、编译器不报错** → 不算缺陷;要提只能进「需要你拍板」 | +| 两个包都叫 `package shop`、一个文件里放多个类型 | 命名与分包口味,`SKILL.md` 的不报清单第一条。两个包同名时,同一文件里一起 import 而不起别名,编译器**会**报 `shop redeclared in this block`——但起个别名就能解开,编译器会拦住,不会有错误代码上线 → 仍不算缺陷;要提只能进「需要你拍板」 | | `checkout.go:60-61` 承诺「never mutates orders and never writes back to the database」但写了 `c.Processed` / `c.Stats` | **该承诺有限定语**「read-only *with respect to the order store*」,写 `Checkout` 自己的计数器不违反它;模块里也没有数据库可供违反。**不算缺陷**(G4 改后指的是另一个问题:承诺本身没有对应对象) | | `refundWindow` 没导出 | 未导出常量不构成公开契约问题;过度设计维度里「公开契约」是除外项 | | 泛泛的「应该补更多测试」 | 没有具体高风险分支就不算测试盲区;G10 才是合法的那条 | @@ -71,3 +71,4 @@ - **C1 的正向确认单独计分**:主动说清「这块没问题」比不报错更有价值;只在覆盖表里写「并发:无」也算,但带依据的更好。 - **G13 可以判成 bug,也可以判成拍板**:这个模块没有任何调用方,「该不该有退款窗口」本身就是业务问题。判成拍板并给出两条路 + 建议的,**同样算命中**;判成 P1 缺陷的也算命中。但把它说成「已经确认的 bug」而不谈业务前提的,归类扣分。 - **G8 修过口径**:现在 `refundWindow` 属「业务政策值」,按 `references/dimensions.md` 第 5 维的新细则**应该报**。仍然按「真不变量」判为不报的,不算漏报——那一版细则确实有歧义,但复评时以新细则为准。 +- **「包名」这条的理由修过**:原文写「import 别名是 Go 的正常机制、编译器不报错」,实测是错的——两个同名包在同一文件里一起 import 而不起别名,编译器会报 `shop redeclared in this block`。**处置规则没变**(仍然只能进「需要你拍板」,进「必须修/建议修」算误报),所以修正前后的口径对同一份输出给分相同。 diff --git a/evals/rubric.md b/evals/rubric.md index b81c8e7..ca3eae3 100644 --- a/evals/rubric.md +++ b/evals/rubric.md @@ -2,6 +2,8 @@ 对一次审查输出一份评分表。**评分对象是输出,不是「结论对不对」**——结论对但表达不可复核,同样扣分。 +> **本表正文 + 文末「打分细则」是评分的唯一依据。** 细则把容易产生分歧的判据写死了,第三方照着它能复算出同样的分数。当前基线见 `evals/baseline.md`。 + ## 五个维度 ### 1. 命中(recall) @@ -10,12 +12,18 @@ | 项 | 分数 | |---|---| -| 命中且触发路径正确 | 1 | -| 命中但触发路径含糊(只说「可能有问题」) | 0.5 | -| 漏报 | 0 | +| 把该缺陷的实质说对了 | 1 | +| 只碰到一半,或明确否认它 | 0.5 | +| 完全没涉及 | 0 | 满分 25(按 G1–G25 计;C1 不算命中,算第 5 项)。 +> **换样本时同步换口径**:本节写的是 tiny-shop(`evals/ground-truth.md`,G1–G25,N=25)。 +> rule-engine 样本用 `evals/ground-truth-rule-engine.md`(R1–R10 里的 9 条,N=9), +> 判据完全一样,只是对照的条目文件与分母不同。 + +「说对了」的判定见文末细则一:独立成条、或作为别的条目里的一段正确陈述,都算 1。 + **首轮基线用的是旧版 12 条口径**(见 `baseline.md`),与本表的 25 条**不可直接比较**。改 GT 之后必须重跑一轮评测,才能得到同口径的基线。 ### 2. 误报(precision) @@ -69,9 +77,11 @@ ## 总分换算 ``` -raw = 命中 + 归类正确性 + 证据强度 + 表达质量 - 误报扣分 +raw = 命中 + 归类合计 + 证据均值 × N + 表达合计 − 误报扣分 ``` +`N` = 该样本的 GT 条目数(tiny-shop 25 条 → N=25;rule-engine 9 条 → N=9)。 + | raw | 评价 | |---|---| | ≥ 12 | 可用 | @@ -79,21 +89,149 @@ raw = 命中 + 归类正确性 + 证据强度 + 表达质量 - 误报扣分 | 4 ~ 7.9 | 噪声偏多 | | < 4 | 不建议使用这个输出 | -命中项满分 25,加上归类(25 项×1)、证据(25 项×~2)、表达(25 项×~0.7), +命中项满分 N,加上归类(N 项×1)、证据(N 项×~2)、表达(N 项×~0.7), 满 raw 约 100+。**换算时请按维度占比看,不要只看绝对值**——不同轮次之间本来就有波动。 +> **读分提醒**:证据项(均值×N)通常占 raw 的 70% 左右,单项独大。 +> 轮次之间的 raw 差异,先看是「命中更多」还是「证据更硬」造成的。 +> 另外**三个维度的实际分母不是 N**:表达与证据是按**报告交付的 finding 条目数**打分, +> 归类虽然按 N 条 GT 计和,但未报出的记 0,所以它同时受漏报拖累。 +> 报告里必须同时写「合计」和「分母」,否则百分比没法复算。 + ## 记录格式 ```markdown ### <日期> · · <问法> -- 命中:__/25(G__ 命中) +- 命中:__/N(__ 命中;__ 条只碰到一半) - 误报:__ 条(__) -- 归类正确:__/25(按已报条目计:__) -- 证据强度:均值 __ -- 表达质量:__/25 +- 归类合计:__(分母:N 条 GT,未报出记 0);按已报条目:__/__ +- 证据强度:均值 __(__/finding 条目) +- 表达质量:__(分母:finding 条目数) - raw:__ - 备注:漏了 __(为什么没抓到);多报了 __(误判在哪) ``` 贴到 issue 里,或按 `runbook.md` 的建议归档。 + +--- + +# 打分细则(歧义点定死版) + +正文里写得含糊、每轮都会吵起来的地方,在这里定死。**打分时以本节为准。** + +## 一、命中(recall,满分 25) + +| 情形 | 分数 | +|---|---| +| 把该缺陷的**实质说对了**——独立成条,或作为别的条目/覆盖表里的一段正确陈述 | 1 | +| 只碰到一半(只陈述了相邻事实、没把它说成该缺陷);或明确否认它(声明它「不算问题」) | 0.5 | +| 完全没涉及 | 0 | + +补充: + +- **C1 不占命中**,它算表达质量的正向加分。 +- **同一根因只算一次**:折扣能力整体没接线(G3 / G9 / G25)只记最靠前的一条(G3),**命中与归类都不再计分**。 +- 「明确否认」与「没涉及」要分清:**报告讨论了这个缺陷的实质并判定它不是问题 → 0.5**;报告压根没谈这个方向 → 0。 + - 例(A 轮、B 轮):写「模块内也没有任何 IO 调用/没有存储写入路径,承诺成立」——这是 G4 事实的一半(模块里确实没有存储),但结论是「承诺没问题」而非「承诺没有对应对象」→ **命中 0.5、归类 0**。 + - 例(A 轮):在「本次没覆盖」里写「这 4 个文件是纯内存实现,持久化……不在本次范围内」——碰到了 G22 的相邻事实(没有持久化层),但把它划到范围外、没说这是缺陷 → **命中 0.5、归类 0**。 + - 例(C 轮):只写「godoc 里的只读承诺会让人以为 `Run` 是纯函数」——那是 G4 的镜像问题(承诺低估了副作用),没问「承诺指向的对象在不在」→ **G4 记 0**。 +- **只是引用了相关代码/文档但没评估那条性质** → 0。例如 B 轮写「与 `refund.go:24-26` 的文档一致」,那是引用 G19 那段注释去背书幂等语义,没有核对「第二个返回值」的说法 → **G19 记 0**。 + +## 二、归类正确性(满分 25) + +| 情形 | 分数 | +|---|---| +| 该条目拿到了自己的级别,且与 GT 一致 | 1 | +| 级别偏一级 | 0.5 | +| 级别偏两级及以上 | 0 | +| 没有自己的级别(只在别的条目里被顺带提到),但宿主条目级别与 GT 一致 | 0.5 | +| 没有自己的级别,且宿主条目级别与 GT 不一致 | 0 | +| GT 说「该拍板」而报告判成缺陷 | 0 | +| GT 说是缺陷(P 级)而报告判成「需要你拍板」 | 0.5 | + +补充: + +- **未报出的条目记 0**,所以这一项对漏报是二次惩罚。 +- **「按已报条目」的辅助值 = 归类合计 ÷ 报告交付的 finding 条目数** + (finding 条目数 = 必须修 + 建议修 + 拍板项;**不含**「已查过没问题」「本次没覆盖」)。 +- 另可给一个更贴近判级能力的辅助值:**归类合计 ÷ 命中条目数**(命中 ≥ 0.5 的 GT 条目数)。 + +## 三、误报(precision) + +| 情形 | 扣分 | +|---|---| +| 编造不存在的缺陷 | 每条 −2 | +| 把「不该报」清单里的候选报成**修复项**(进必须修 / 建议修 / P3 等任何修复分层) | 每条 −1 | +| 只把它放进「需要你拍板」,或明确排除 | 不扣 | + +**GT 之外的真实问题**:不扣分、不加分(它不是误报,只是不在 GT 里)。 +**漏报**在误报这一项**不扣分**,它在命中和归类两处已经计过了。 + +## 四、三条特例 + +1. **同一根因只算一次**:折扣能力整体没接线(G3 / G9 / G25)只记最靠前的一条(G3),其余**命中与归类都记 0**。 +2. **G12**(`Percent` 无范围校验,GT 要求进「需要你拍板」): + - 独立拍板项 + 给了两条路 + 建议 + 要你定什么 → 归类 1 + - 在别的拍板项里顺带提到(给了具体触发与修法) → 归类 0.5 + - 作为缺陷条目报出 → 归类 0 + - 明确排除(「等某个决策定了再一并处理」) → 命中 0.5、归类 0 +3. **G13**(退款窗口从查询时刻起算):判成 bug 或判成拍板**都算命中**; + 若报告把它说成「已经确认的 bug」,且**没有把「退款窗口该不该有 / 该由谁定」作为需要回答的问题提出来** → 归类 0.5。 + - 判据:报告里出现「这个 24 小时是全平台统一口径,还是按商户/渠道/品类区分」这类**需要用户或业务来定的问题**(写在拍板项里或明确提问)→ 归类 1; + 只是说它是「业务政策值」、应该做成参数或配置项 → **不算**谈业务前提,归类 0.5。 + +## 五、证据强度(1–5 级,取均值) + +| 级 | 含义 | +|---|---| +| 5 | 跑了测试 / 构造了最小复现 | +| 4 | 读了测试并确认覆盖或未覆盖 | +| 3 | grep 了全部调用方 | +| 2 | 读了完整函数 + 相邻实现 | +| 1 | 只读了 diff | + +- 取**全部上报条目**(含拍板项)的均值。 +- 声称强度高于实际做过的,每条 −1。 +- 「已查过没问题」里没有验证方式的,每条 −1。 +- **一条目的级别看它「中心主张」的验证强度**:该主张自己写在条目里的探针/实测算; + 同一份报告里已经为该主张**确立了前提**的探针也算(条目明引,或事实同一)。 + 反过来,条目只是复述别处跑过的、与该主张无关的探针,不给分。 +- **必须逐条列出明细**(哪条几分),否则不可复算。 + +## 六、表达质量(满分 = finding 条目数 + 0.5) + +| 情形 | 分数 | +|---|---| +| 用大白话讲清机制且配了最小对照例子 | 1 | +| 讲清机制没例子 | 0.6 | +| 只有结论 | 0.2 | +| 术语堆砌 / 行号当论据 | 0 | + +- 拍板项另加一条要求:给了「两条路 + 建议 + 要你定什么」才记 1。 +- 主动说清「哪块查过没问题」并给依据(对应 C1)→ 额外 +0.5。 +- **「最小对照例子」的判据**:报告里能指认一个**可复现的具体症状或并置对照**,二者之一即可—— + - 可复现的输入/输出(探针数字,如 `0.29 元 → 结算 28 分 / 退款 29 分`、`NaN → total=-9223372036854775808`); + - 「这里声明/承诺了什么」与「实际发生了什么」的并置(如「总=0 但 Processed=1」对上「期望 total=0、计数为 0」)。 + - 只有**静态断言 + 场景描述**(如「grep 全仓零调用点 + 想接入第二种策略时才发现这个抽象没在管事」、 + 「`timeout` 只出现在签名那一行 + 调用方以为这批最多跑 1 秒」)→ **0.6**。 +- **必须逐条列出明细**。 + +## 七、raw + +``` +raw = 命中 + 归类合计 + 证据均值 × N + 表达合计 − 误报扣分 +``` + +其中 **N = 该样本的 GT 条目数**(tiny-shop 25 条口径 → N=25;rule-engine v2 口径 9 条 → N=9)。 + +> **为什么 N 要跟着样本走**:证据均值是个 1–5 的小量级,不乘一个分母就淹没在 raw 里。乘 N 是为了让它和命中、归类、表达在同一量级上可比。**因此不同样本的 raw 不可直接比较**——tiny-shop 的 163 和 rule-engine 的 raw 相差部分来自 N 不同,比较时必须看维度占比。 + +> **读分提醒**:证据项(均值×N)通常占 raw 的 70% 左右,单项独大。 +> 不要只看 raw 总数,要看维度占比(命中/N、归类/N、证据 1–5、表达/条目数)。 + +## 八、这套细则与首轮打分的口径差异 + +首轮(12 条口径)用的是「只在证据里点到记 0.5」的旧写法。本细则改成 +「**命中 1 = 实质是否说对**」,独立成条或在别的条目里顺带说对都算 1。 +因此**首轮分数与本细则下的分数不可直接比较**,要比较必须按同一细则重跑。 diff --git a/evals/runbook.md b/evals/runbook.md index d11c7a0..edafa9b 100644 --- a/evals/runbook.md +++ b/evals/runbook.md @@ -1,19 +1,41 @@ # 操作手册:怎么跑一次评测 +## 两个样本,两套标准答案 + +| 样本 | 测什么 | 标准答案 | 跑哪几轮 | +|---|---|---|---| +| `evals/fixtures/tiny-shop` | 契约、文档、错误路径、并发、兼容性——通用面 | `ground-truth.md`(G1–G25) | A + B + C | +| `evals/fixtures/rule-engine` | **算法/复杂度**与**架构/设计语义**——反驳与规模扫描 | `ground-truth-rule-engine.md`(R1–R10 里的 9 条 + C1) | B + C | + +**为什么分两个样本**:tiny-shop 是 150 行的结算模块,里面**没有指数级回溯,也没有分裂成两条路径的动作**——它测不出新能力。两个样本分开跑,分数分开记,才能同时回答「旧的没退化」和「新的生效了」。 + ## 准备 ```bash # 把样本复制到临时目录,别在仓库里改 cp -r evals/fixtures/tiny-shop /tmp/dcr-eval +cp -r evals/fixtures/rule-engine /tmp/dcr-eval-rule ``` Windows PowerShell: ```powershell Copy-Item -Recurse evals\fixtures\tiny-shop $env:TEMP\dcr-eval +Copy-Item -Recurse evals\fixtures\rule-engine $env:TEMP\dcr-eval-rule ``` -> **不要把 `ground-truth.md` / `rubric.md` 放进被审目录**——被测模型看到标准答案,这次评测就废了。 +> **不要把 `ground-truth.md` / `ground-truth-rule-engine.md` / `rubric.md` 放进被审目录**——被测模型看到标准答案,这次评测就废了。 +> +> **样本副本要做中性化**:仓库里两个样本的 `README.md` 都写着「刻意植入缺陷」, +> 那句话等于给被测模型透题。复制到隔离目录后,把 README 换成**中性描述版** +> (去掉缺陷披露、去掉「怎么用」段),其余文件**逐字节**复制,SHA256 必须与仓库一致。 +> +> **副作用**:报告里引用 README 的行号会比仓库 fixture 差 1–2 行。 +> 标准答案只引用 `.go` 文件行号,不受影响;评分时**不要**因为 README 行号差一位就扣「表达质量」。 +> +> **被测上下文要能读到 skill 本体**:skill 装好后,约束里要写清「skill 副本在哪个路径」, +> 而不是禁止它读安装目录——它读不到就会去磁盘上乱找,实测捡到过一份过期旧副本, +> 导致那一轮按旧口径判案、整轮作废。要禁的是**仓库里的 `evals/` 目录**(那里才有标准答案)。 ## 跑三种问法 @@ -29,6 +51,19 @@ Copy-Item -Recurse evals\fixtures\tiny-shop $env:TEMP\dcr-eval A 轮验的是**快模式本身**:合并判定必须由这个 skill 直接给出,形态是紧凑列表 + 一句结论,不依赖任何其他工具。 +### rule-engine 样本只跑 B + C + +新能力(**反驳**与**规模扫描**)只在全量模式跑——它们找的是设计与算法层面的问题,不是「这次改动有没有坏」。所以 rule-engine 样本跑两轮: + +| 轮次 | 问法 | 期望 | +|---|---|---| +| B | `用 deep-code-review 按七维审 /tmp/dcr-eval-rule` | 全量模式:七维 + **反驳** + **规模扫描** + 六段式 + 覆盖表 | +| C | `给 /tmp/dcr-eval-rule/internal/promo 这个模块做个体检,看看设计、契约、重复、过度设计` | 全量模式,且应命中 R1/R3/R4/R5/R6 这些「规模 + 语义」条目 | + +**规模类条目在报告里的形态**:给「规模 N → 开销」的关系与实测数字,不是「输入 → 错误结果」那种触发路径。报告没给数字的,按 `ground-truth-rule-engine.md` 的计分要点最多记 0.5。 + +**A 轮不跑 rule-engine**:快模式契约里没有这两遍,跑它只能验出「快模式没乱报」,收益低。 + ## 记录 把每轮的输出原样存下来(不要摘要),再对照 `ground-truth.md` 打分, @@ -39,10 +74,16 @@ A 轮验的是**快模式本身**:合并判定必须由这个 skill 直接给 - **别把 ground truth 喂给模型**。哪怕是「提示一下重点看哪里」也会毁掉结果。 - **一轮不够**。同一 skill 两轮之间有波动是已知的(README 的「评测」一节就写了)。 想看稳定性就跑 3 轮,取中位数。 -- **样本很小**。约 150 行,读得完。漏报不能用「范围太大没读完」解释—— +- **样本都很小**。tiny-shop 约 200 行、rule-engine 约 370 行,都读得完。漏报不能用「范围太大没读完」解释—— 如果模型说「未覆盖」,那是它自己的选择,要记下来。 +- **别把历次评测的原始输出放在样本目录旁边**。那些原始输出本身就是「答案」—— + 上一轮的评审全文躺在样本目录旁边时,有一轮直接把上一轮结论读进去了,那一轮作废重跑。 +- **原始输出目录与样本目录不要有父子关系**,也不要放在同一个父目录下—— + 被测上下文有可能往父目录翻,命中任何一份历史评审都会污染这一轮。 +- **两个样本的隔离目录也要分开**,理由同上:rule-engine 的标准答案和 tiny-shop 的不能互相可见。 - **不是每次都要跑全三种问法**。改 skill 正文时跑 B+C; 只改 description 时重点跑 A(路由);改输出契约时重点看 C(结构)。 + 涉及新能力(反驳 / 规模扫描)的改动,**必须**跑 rule-engine 的 B+C——tiny-shop 测不出它。 ## 什么时候必须跑 @@ -53,13 +94,14 @@ A 轮验的是**快模式本身**:合并判定必须由这个 skill 直接给 ## 归档 -把每轮的输出原样存下来(不要摘要),再对照 `ground-truth.md` 打分, +把每轮的输出原样存下来(不要摘要),再对照对应的标准答案打分 +(tiny-shop → `ground-truth.md`,rule-engine → `ground-truth-rule-engine.md`), 按 `rubric.md` 的格式写出评分表。 **存哪**: -- 原始输出(每轮一份全文)**不入版本控制**,放仓外(例如 `scratchpads\dcr-eval\runs\round-{A,B,C}.md`)。 +- 原始输出(每轮一份全文)**不入版本控制**,放仓外(例如 `scratchpads\dcr-eval-archive\round-{A,B,C}-v4.md`)。 - 评分表入版本控制,追加到 `evals/baseline.md`;跑完一版就追加一节,**不要覆盖旧记录**——旧记录是「当时按当时的标准答案跑出来的结果」,属历史事实。 -- 改了 `ground-truth.md` 或 `rubric.md` 之后,旧分数与新分数**不可比**,必须在记录里写明,并重跑一轮拿新口径的基线。 +- 改了 `ground-truth.md` / `ground-truth-rule-engine.md` 或 `rubric.md` 之后,旧分数与新分数**不可比**,必须在记录里写明,并重跑一轮拿新口径的基线。 **贴到 issue**:如果这次评测是为了验证某个改动,把评分表贴到那个改动的 issue 或 PR 评论里。这样每个 PR 都能看到「这个改动让审查变好还是变坏」。 diff --git a/references/dimensions.md b/references/dimensions.md index 2bc213e..19ecc21 100644 --- a/references/dimensions.md +++ b/references/dimensions.md @@ -46,6 +46,9 @@ 7. 同型相邻参数:`func(a, b string)` 在调用处能否一眼看出哪个是哪个?两个 string/bool 相邻且语义不同,是高频写错点。 8. 变体缺失:现有调用方是否在函数外面自己拼装、自己重试、自己过滤?这说明函数少了一个参数或一个变体。 9. 校验:非法参数是入口早失败,还是走到深处才炸、甚至静默算错? +10. 跨层形状:抽象层/接口层声明的参数与返回类型,和实现层、后端层实际用的类型,是同一个形状吗?换一个实现(换后端、换适配器、换存储),调用方要不要跟着改代码?形状不一致 → 统一调用方无法复用,**这不是编译错误,要按契约裂缝报**,不要因为「能编译」就放过。 + - 判据要**并排列出两个签名**,指出具体差在哪:字段类型不同、字段数量不同、返回类型不同、参数打包方式不同。 + - 同样的分寸:上层注释或设计文档**已经明说**这条差异是有意为之 → 已知取舍,进「需要你拍板」;没被承认的裂缝才是缺陷。 **成立条件**:指出至少一个现有调用方(或明确可预测的调用方)会因此写错。 @@ -94,7 +97,7 @@ **命名 / 分包这类口味问题,也不要报成缺陷。** 判据三条,全过才谈得上「值得提」: -1. **编译器和工具链会不会因此出错?** `import` 别名是 Go 的正常机制,编译器不报错、`go doc` 也能正常生成 → 属口味,不报。 +1. **编译器和工具链会不会因此出错?** 两个包同名时,同一文件里一起 import 而不起别名,编译器会报 `shop redeclared in this block`——但起个别名就能解开,编译器会拦住,不会有错误代码上线;`go doc` 只是包注释串味。→ 仍属口味,不报。 2. **同项目里有没有相反的惯例?** 若项目别处包名都跟目录名对齐,才可能说「这里不一致」;项目内部本来就五花八门,那就不是这一条。 3. **能不能不改?** 改包名要动所有 import,代价与收益不成比例 → 更该不改,不报。 @@ -134,7 +137,7 @@ ## 6. 重复 -**核心问题**:同一行为是否存在第二个真相源? +**核心问题**:同一行为是否存在第二个真相源?同一个概念是否分裂成了几条行为不一致的路径? **扫描动作** @@ -144,10 +147,25 @@ - 两套并行抽象做同一件事(两套 errors、两套 config、两套 client、两套日志)。 - 测试里复制了实现逻辑,导致没测到真实现。 - 同一段编排在多个 handler 里重复(往往是缺一个组合点,接第 1 维)。 +- **同一个动作有几个名字 / 几条实现路径**:把「普通版」与「加强版」「可靠版」「持久版」这类成对出现的入口列出来,逐个比对它们的行为差异。 +- **同一动作在正常路径、异常路径、恢复路径上是否一致**:同一个失败,在「本轮刚发生」和「从上次恢复出来」两种状态下,后续处理是不是同一个。 -**成立条件(单源测试)**:「哪条需求变了,这两处必须一起改?」回答是 → 真重复;回答否 → 形似,不报。 +**成立条件(两条,满足其一即成立)** -**修法不一定是抽公共函数**:可能是删掉一份、让一方调用另一方、把差异参数化。报告里要指明哪一份是真相源。 +1. **真重复(单源测试)**:「哪条需求变了,这两处必须一起改?」回答是 → 成立;回答否 → 形似,不报。 +2. **语义分裂(同一动作,多条路径)**:同一个业务动作在系统里有两条以上实现路径,且**行为不一致**——同样的输入,在不同路径上得到不同结果,或一条路径做了另一条没做的事。 + + 判据要给出三样:**两条路径各是什么**、**一个具体的输入或场景**、**两条路径上不同的那个结果**。 + + 注意这与「真重复」方向相反:真重复是「该一起改却分成了两份」;语义分裂是「**不该一起改,却共享了一个概念**」——调用方以为调用的是同一个东西,实际行为取决于走了哪条路。 + + 典型形态:同一个动作有「普通版」与「可靠版」两个名字,两者在异常路径或恢复路径上分叉(一个会再写一次持久记录,另一个直接返回原状态)。这类问题在文档里看不出来——文档往往只描述了其中一条路径。 + + **差异要看外部可见的副作用**:多写了一条持久记录、把业务失败升级成了另一种故障、让调用方拿到不同的返回值——这类才算语义分裂。差异只在内部实现细节上(一个多算了一次、另一个少了一次中间计算),不报。 + + **分清「缺陷」与「契约未定」**:如果上层注释或设计文档**已经明说**这条差异是有意为之(例如写着「各后端在自己的入口实现,形状不必一致」),那是已知取舍 → 进「需要你拍板」,不是缺陷。没被承认的分裂才是缺陷。 + +**修法不一定是抽公共函数**:可能是删掉一份、让一方调用另一方、把差异参数化。报告里要指明哪一份是真相源。语义分裂的修法通常是**合并成一个动作**(把差异变成参数或开关),而不是再加一个变体。 ## 7. 过度设计 @@ -170,6 +188,49 @@ **与第 1 维的分工**:第 1 维问「这个能力该不该用现有机制组合」,第 7 维问「这个机制本身是不是多余」。 +## 规模与复杂度(七维之外单独跑) + +这一节判的是**算法**层面的不合理,不是工程性能坏味道(N+1、循环内 IO、锁内做慢操作那类归「性能」扩展维度)。 + +**为什么七维管不到它**:第 3 维的成立条件是「输入 → 走到哪行 → 错误结果」。复杂度问题**没有错误结果**——它表现为慢、耗内存、随规模爆炸,不是算错。按第 3 维的闸门去卡,它永远不成立。 + +**核心问题**:这条路径的开销随输入规模怎么长?在项目现实的规模下会不会出事? + +**扫描动作** + +- 递归 / 回溯:同一个状态会不会被重复访问?有没有记忆化或已访问集合? + - **形状**:递归按「子节点 / 挂载点 / 引用」展开,而**同一个指针可以出现在多处**。定义数 N 很小、实例数 M 很大时,代价随 M 指数增长——遍历的到底是「定义」还是「实例」? + - **形状**:`out = append(out, child.collect(...)...)`——父层把子层结果整个拼进来,而 collect 内部又自己拼了一份。深度 d 的总条目是 O(d) 还是 O(d²)? +- 嵌套:外层每走一步,内层是不是又把整个集合扫一遍?(O(n²) 的典型来源) + - **形状**:同一个候选要在每次外层迭代里重算一遍完整结果(`for 外层 { for 内层 { 扫全量 } }`)。 +- 拷贝:每层、每轮是不是把完整结果复制一遍?能不能边走边累加,只留一份? +- 重复扫描:同一个声明表、索引、清单是不是每次都要重新扫? + - **形状**:谓词函数每次调用都遍历声明列表——这个谓词只依赖静态声明,能不能算一次存起来? + - **形状**:同一列表在「建索引」和「查询」两处各扫一遍。 +- 逐层重跑:第 i 层是不是从根把 1..i 层全部重做一遍?缓存键里有没有与本次运行无关的输入? +- 热路径放大:上面这些扫描出现在**每次业务操作都调**的路径上(写一个输出、确认一条消息、渲染一次)吗?列表长度由谁决定、用户能不能配大? +- 锁内放大:复杂度问题发生在**持有全局锁**的路径上吗?那它的影响会被放大成全局停顿。 +- 结构选型:数据结构与操作模式对不对得上?(频繁按 key 查却用线性扫描;需要有序却反复排序) +- 无界:容器只增不减,有没有淘汰路径?(这一条与第 3 维的「资源」重叠,但这里的判据是**规模**,不是「有没有释放」) + +**成立条件** + +- 能说出「规模 N → 这条路径的开销」这条关系,**并且**能指出 N 的实际取值范围,或给出一个可复现的规模点。 +- 证据形式是**规模关系 + 实测数字**,不是触发路径。说得出「N=19 个定义、36 条挂载边时,额外开销 89.7 ms / 375 MB」才成立;只说「这里可能慢」「这里会退化成平方」不算。 +- **更好的证据是规模曲线**:同一个形状跑 128 / 256 / 512 / 1024 四档,指出耗时或分配随档位怎么涨(每层 ×4、每次翻倍 ×4 都是强信号)。单点数字容易被质疑。 + +**取舍要写明**:改法可能不是纯赚——把线性扫描换成索引,时间降下来了,内存可能反而涨。**只报时间不报取舍的结论不完整**,两者都写。 + +**不算** + +- 微优化猜测;没有规模证据的「这里可以更快」。 +- 已有基准证明够用的路径。 +- 复杂度正确、只是常数不好看的路径。 + +**定级**:见 `SKILL.md` 的「分级与排序」——现实规模下就拖垮是 P1,只在远超当前规模时才爆是 P2/P3;说不出 N 的取值就别定级。 + +**修法方向**:优先给能改变复杂度的改法(记忆化、索引、增量累加、换结构),不要只说「加个缓存」。 + ## 扩展维度 ### 错误路径与部分失败 @@ -204,6 +265,8 @@ N+1 查询、循环内 IO、无界分配/缓存、锁内做慢操作、重复序列化。要有规模或调用频率证据,不做微优化猜测。 +> 本节判**工程坏味道**;算法层面的复杂度爆炸(指数回溯、嵌套全扫、每层拷贝)归「规模与复杂度」,两者分开报,不要重复。 + ### 测试盲区 只报:测试断言了错误行为;改动让既有测试静默失效(例如 mock 了新签名但没验证行为);关键分支完全没有覆盖且该分支高风险。不报泛化的「应该加测试」。 diff --git a/references/output-template.md b/references/output-template.md index e38f97b..c34867a 100644 --- a/references/output-template.md +++ b/references/output-template.md @@ -27,6 +27,9 @@ finding 的**字段定义**以 `SKILL.md` 的「输出契约」一节为准, | 安全 | | | | 兼容性 | | | | 可观测性 | | | +| 性能 | | | +| 规模与复杂度 | | | +| 测试盲区 | | | ## 必须修 @@ -114,6 +117,9 @@ finding 的**字段定义**以 `SKILL.md` 的「输出契约」一节为准, | 安全 | 无发现 | 无用户可控输入进入 sink | | 兼容性 | 无发现 | 错误码、配置键、默认行为对比 base | | 可观测性 | 无发现 | 中间件顺序、错误码、日志字段 | +| 性能 | 无发现 | 无循环内 IO、无 N+1;限流器为 O(1) | +| 规模与复杂度 | 无发现 | 3 处递归与嵌套遍历逐个看过,随输入规模线性 | +| 测试盲区 | 无发现 | 既有断言与新分支逐条对;无被静默失效的用例 | ## 必须修 diff --git a/references/playbooks.md b/references/playbooks.md index 2d104be..7671ae1 100644 --- a/references/playbooks.md +++ b/references/playbooks.md @@ -2,10 +2,12 @@ 七维是通用面;下面这些改动类型有高频专属缺陷。先认类型,再追加对应清单——只对命中的类型展开,不要平铺到每次审查。 -**扫描顺序**:先认改动类型 → 跑该类型的必查项 → 七维做通用面 → 扩展维度只做横向补充。 +**扫描顺序**:先认改动类型 → 跑该类型的必查项 → 七维做通用面 → 规模扫描 → 扩展维度只做横向补充。 每份 playbook 开头标注了它覆盖了扩展维度的哪些条目。**扫完 playbook 即视为那些条目已覆盖,不再重复扫**;覆盖表里写「已查(见 XX playbook)」。 +**规模扫描(`dimensions.md` 的「规模与复杂度」)不被任何 playbook 覆盖**——它跟改动类型无关,只要代码里有循环、递归、聚合、查找或整块拷贝就要跑。下面各类型里带「规模」标记的条目是**该类型的高发场景**,不是规模扫描的替代品:通用扫描跑完之后,再看这一类型有没有额外的规模雷区。 + ## 批处理 / Job / CLI 覆盖扩展维度:错误路径与部分失败、并发与生命周期(环境隔离部分)、可观测性(退出码与摘要)。 @@ -18,6 +20,7 @@ - **参数绑定**:`--key=value` 与 `--key value` 在当前框架里是否等价?裸 flag 是否真的映射到 `@Value` 的 key(`--dry-run` vs `memnet.replay.dry-run` 这种改名最容易断链)?命令行、环境变量、配置文件三个来源的优先级是否清楚? - **退出码与摘要**:部分完成是否以 0 退出?「完成」文案是否与实际处理量一致? - **分页**:只处理一页会不会被误认为全量?`limit` 与总量的关系是否可见? +- **规模(该类型高发)**:批处理的数据量是最大的——逐条扫描里有没有嵌套扫描(O(n²))、有没有把整批结果每轮复制一遍、有没有为每条记录重扫一次声明表?批次的「规模」是多少(一次 1 万条还是 100 万条)?这个数字要能从代码或配置里读出来,不能靠猜。 命中这些项时,每条都要落到具体行;「文档这么写、代码没这么做」本身就是合法结论。 @@ -34,12 +37,17 @@ ## API / 库 / SDK -覆盖扩展维度:兼容性(默认值、向后兼容)、方法契约。 +覆盖扩展维度:兼容性(默认值、向后兼容)、方法契约、并发与生命周期(共享状态部分)。 - 参数契约(第 2 维)、默认值、零值歧义、向后兼容。 - 新增导出面是否有第二个使用者(第 7 维)。 - 错误语义是否可判别:typed error / sentinel error,还是字符串比较。 - 文档示例是否可编译、可运行;示例用的默认值是否与实现一致。 +- **共享状态**:导出的指针字段、共享 map/slice、计数器是否允许多个调用方共用?非原子的读-改-写(`x++`)在并发下会丢更新并构成 data race。导出面若允许共享,要么用 atomic/锁保护,要么在 godoc 明确写「非并发安全,每次调用用独立实例」——**两种做法都对,但必须有一个,不能沉默**。 +- **零值可用性**:导出类型是否零值可用?零值不可用时(必填字段、map/slice 未初始化),godoc 有没有写明「必须用构造函数」?两个包要一致——一个提供 `NewX()`、另一个靠零值,是调用方的困惑来源。 +- **单位一致性**:同一个类型上,入口参数与出口返回值的单位是否一致(一个收「元」、一个返「分」)?把上游算出的总额直接传进去,会不会差一个数量级? +- **跨层形状**:根接口/抽象层声明的请求与结果类型,和具体后端(另一个存储、另一个适配器)实际用的类型,是同一个形状吗?有两个后端时,换后端要不要连带改调用方代码?这是契约裂缝,**能编译不等于形状一致**。 +- **规模(该类型高发)**:库的调用频率是关键——一个每秒调用十万次的方法里,一次线性查找就是问题;一个每天调用一次的批处理里则不是。调用频率能不能从调用方代码里读出来? ## 并发 / 服务 / 守护进程 diff --git a/references/verification.md b/references/verification.md index ca262ce..dbf5b71 100644 --- a/references/verification.md +++ b/references/verification.md @@ -13,7 +13,7 @@ | 缺参数/缺省有歧义 | 哪个现有或可预测的调用方会因此传错或绕开? | 删 | | 实现 bug | 输入/状态 → 走到哪行 → 得到什么错误结果? | 降级或删 | | 并发问题 | 两个执行体在哪个窗口交错?共享对象是什么?可见结果是什么? | 降为「待确认」 | -| 硬编码 | 它必须随什么变化?不变时会怎样静默过期? | 删 | +| 硬编码 | 它是协议固定值还是业务政策值?若是业务政策值:它必须随什么业务口径变化?不改会怎样静默过期? | 协议固定值 → 删;业务政策值且说不清随什么变化 → 删 | | 重复 | 两处符号是什么?哪条需求变化时它们必须一起改? | 降为 P3 提示或删 | | 文档不清 | 读者会因此做错什么? | 删 | | 过度设计 | 删掉它,今天的损失是什么? | 删 | @@ -40,7 +40,7 @@ - 缺参数:调用方 ___ 会因此 ___。 - 实现:输入 ___ → 执行到 ___ → 得到 ___(期望 ___)。 - 并发:执行体 A 在 ___ 与 B 在 ___ 交错;共享对象是 ___;结果是 ___。 -- 硬编码:它必须随 ___ 变化;不变时会 ___。 +- 硬编码:它是协议固定值还是业务政策值?若业务政策值:必须随 ___ 变化;不改时会 ___。 - 重复:符号 ___ 与 ___;需求 ___ 变化时两处必须一起改。 - 文档:读者会因此 ___。 - 过度设计:删掉后今天损失 ___(答不上来即成立)。 diff --git a/tools/lint-skill.mjs b/tools/lint-skill.mjs index 7e98007..0a7277d 100644 --- a/tools/lint-skill.mjs +++ b/tools/lint-skill.mjs @@ -17,6 +17,11 @@ import { join, basename, resolve } from 'node:path'; const FORBIDDEN_FRONTMATTER_KEYS = ['allowed-tools', 'license', 'model']; const MAX_BODY_LINES = 500; +// 成品里不出现相对表述:提一次「内置」,这个 skill 就始终是相对于别的东西定义的。 +// 这条规矩以前靠人工 grep 守,容易漏——机器化之后它就退不回去了。 +const RELATIVE_PHRASES = ['内置', '别的工具', '第二个工具']; +const RELATIVE_PHRASE_SCOPE = ['SKILL.md', 'README.md', 'references', 'evals']; + let errors = 0; let warnings = 0; @@ -187,6 +192,43 @@ if (existsSync(refDir)) { } } +// --- 相对表述 --- +// skill 必须自述能力,不靠对比定义。跨工具的选型说明属于「给人看的」,但也不该用 +// 「内置 X」这种措辞——正确做法是让本 skill 覆盖那件事,而不是比较谁更强。 +function collectFiles(rel) { + const abs = join(dir, rel); + if (!existsSync(abs)) return []; + return readdirSync(abs) + .filter((f) => f.endsWith('.md')) + .map((f) => join(abs, f)); +} + +const phraseTargets = [ + ...(existsSync(join(dir, 'SKILL.md')) ? [join(dir, 'SKILL.md')] : []), + ...(existsSync(join(dir, 'README.md')) ? [join(dir, 'README.md')] : []), + ...collectFiles('references'), + ...collectFiles('evals'), +]; + +let phraseHits = 0; +for (const f of phraseTargets) { + const lines = readText(f).split('\n'); + lines.forEach((line, i) => { + for (const phrase of RELATIVE_PHRASES) { + if (line.includes(phrase)) { + fail( + `${f.replace(dir + '/', '')}:${i + 1} 含相对表述「${phrase}」。` + + '成品要自述能力,不要靠与别的东西比较来定义——改写成本 skill 自己的说法。', + ); + phraseHits += 1; + } + } + }); +} +if (phraseHits === 0) { + ok(`no relative phrasing (${RELATIVE_PHRASES.join(' / ')})`); +} + console.log(''); if (errors > 0) { console.error(`✗ Lint failed: ${errors} error(s), ${warnings} warning(s)`);