# 失败族:「看起来正常、实际全错」 > 本文把本项目开发过程中**反复出现的一类失败**命名、归类,并给出对应纪律。 > 它不是理论总结,**每一条都是实际发生过的**(附出处)。 --- ## 1. 这个族的共同结构 ```text 输入合法 → 不抛异常 → 不打日志 → 产出一个「看起来合理」的结果 → 一路流到下游 ``` **它的可怕之处不在"错",在"错得没有声音"。** - 崩溃会立刻暴露 → 代价是几分钟 - 这个族**不会暴露** → 代价是「你以为在测新代码,其实在测旧的」「你以为端口在板上,其实飘在外面」 **判据**:如果你无法**从外部观测**区分"对了"和"错了",那它属于这个族。 --- ## 1b. ★★ 观测层的同一结构:**把「近端事件」当成「远端结果」** 上面那个结构描述的是**代码**层。本会话又暴露出一个**同构的变体**,它发生在**观测/验证**层: > **我们唯一能直接观测到的,永远是"近端事件";而我们要的结论,是"远端结果"。** | 说法 | 近端事件(真的发生了,我们观测到的) | 远端结果(我们**以为**发生了) | |---|---|---| | `{ok:true}` | 调用**被接受** | 效果**发生了** | | 目录里有 / 没有 | 枚举**完成了** | 能力**全集已知** | | 「我已发出消息」 | 消息**发出去了** | 对方**已同步** | | 「测试通过了」 | 断言**没抛** | 行为**是对的** | **⇒ 四次不同的场景,同一个动作:把"我观测到的"当成了"我想要的那个"。** ### 两个轴,同一个病 | 轴 | 我们观测到的 | 我们当成 | 实例 | |---|---|---|---| | **步骤** | 近端事件(`ok:true`) | 远端结果(效果发生) | `ok:true` / 目录 / 我发出去了 | | **时间** | **快照**(我读过的那份代码 / 那份清单) | **现状**(现在的代码 / 现在的清单) | 「同一条代码路径」——依据是**读过的** `session-chat.ts`,而对方**已经切了主路** | **两者都是"把可观测到的东西当成真相"**:一个在**空间上差几步**,一个在**时间上差一段**。 ⇒ **任何观测都是某一时刻的快照。** 拿它当"现状"用,就一定会遇到"它已经变了"。 **推论(协作场景尤其重要)**: > **「我发过了」≠「你收到了」;「我改过了」≠「你看到的还是新的」。** > ⇒ **同步的时机应该在「改动稳定」之后,而不是「我有话要说」的时候。** **为什么这个变体特别难自觉**(比代码层那个更难): **近端事件是我们唯一能直接观测到的东西** —— 怀疑它就等于承认"我手上没有证据"。 而人不会主动放弃自己唯一的证据。 ### ★★ 它对 17 例**全覆盖** —— 而"覆盖"这件事本身被纠正了两次 **第一次纠正(我错)**:我曾判定 §2 的 **#1(坐标系混用)与 #8(供电死锁)** 属于 "另一种机制:根本没有信号",想把 §1b 降为"覆盖 14/17 的主视角"。 **错法本身属于这个族**:拿**覆盖面**(近端量)去决定**机制地位**(远端结论)。 **第二次纠正(还是我错,由并行会话提出)**:改用"修法是否不同"判定后,我得出 "两例修法都是**往外挪** ⇒ 同机制"。 > **但"往外挪"是方向,不是距离。** | 例 | 修法 | **到远端了吗** | |---|---|---| | **#8** | 预算改成"**含待放置那台**" | ✅ **到顶了** —— 它算的**就是**声明本身("放进去之后够不够") | | **#1** | `tests/model-library.test.ts`:`\|position\| ≤ size/2` | ❌ **只挪了一半** —— 量的是"**在盒内**",声明的是"**位置正确**"。**放在错误角落的端口一样在盒内。** | **而 #1 那条检查能不能抓住原来那个 bug,取决于坐标恰好落在哪半边**: corner-origin 的数落在 `[0, size]`,而盒子是 `±size/2` ⇒ **靠边的越出去,靠中间的照样通过**。 ⇒ **它抓的是"这个错够不够大",不是"位置对不对"。** 同一类错误只要偏移小一点, 测试全绿、端口照样是错的 —— 这正是本文件第 3 节那条「**结构性校验 > 逐点校验**」被违背的现场。 ⇒ **#1 至今是未收口项**(见下方"待收口的远端检查")。 ### 判据要**两问**,缺一不可 > ① **修法是否不同?** —— 让"机制是否不同"这个**不可证伪**的问题变得可证伪 > ② **新检查量的是不是声明本身?** —— 这才是**远端**判据 **① 只解决了"能不能判",没解决"判到没判"。** | 状态 | 含义 | 处境 | |---|---|---| | 检查量的是**声明本身** | 到顶 | 这一类错误**已收口** | | 检查量的是**声明的代理** | 半途 | **只挡住了大错**,小一点的同类错误照样通过 | | **没有检查** | 起点 | 全暴露 | **⇒ 机制只有一个(近端当远端);真正在变的是「检查离声明有多远」。** **"代理检查"最危险的地方**:它**看起来像**已经收口了 —— 有测试、是绿的、还能真的抓到一次真实 bug。 ⇒ 于是没人再往下查。**"抓到了那个 bug"不等于"这一类 bug 被挡住了"。** ### 待收口的远端检查(#1) 真正的远端检查是:**端口锚点是否真的落在模型上的那个连接器上**(GPIO 排针 / USB / 网口)。 "在盒内"永远答不了 —— 它连"那儿有没有连接器"都不知道。 做法:把 GLB 按朝向归一化、缩放到契约 `size`,再算**每个端口到模型三角面的距离** (**不是到顶点** —— CAD 的面很大,顶点距离是弱代理)。飘在空中的端口距离显著 > 0。 ### 两个轴,同一个病 | 轴 | 我们观测到的 | 我们当成 | 实例 | |---|---|---|---| | **步骤** | 近端事件(`ok:true`) | 远端结果(效果发生) | `ok:true` / 目录 / 我发出去了 | | **时间** | **快照**(我读过的那份代码 / 那份清单) | **现状**(现在的代码 / 现在的清单) | 「同一条代码路径」——依据是**读过的** `session-chat.ts`,而对方**已经切了主路** | **两者都是"把可观测到的东西当成真相"**:一个在**空间上差几步**,一个在**时间上差一段**。 ⇒ **任何观测都是某一时刻的快照。** 拿它当"现状"用,就一定会遇到"它已经变了"。 **推论(协作场景尤其重要)**: > **「我发过了」≠「你收到了」;「我改过了」≠「你看到的还是新的」。** > ⇒ **同步的时机应该在「改动稳定」之后,而不是「我有话要说」的时候。** **为什么这个变体特别难自觉**(比代码层那个更难): **对策(可执行)**: 1. **写下你实际观测到的是什么**,而不是写结论。写「返回 200 `{ok:true}`」而不是「验证通过」。 2. **问一句:从这个观测到我的结论,中间还差几步?** 差的那几步就是**必须补的验证**。 3. **"远端结果"能直接观测时,就别用"近端事件"代理** —— 消息有没有到,去看会话里有没有它; 效果有没有发生,去看副作用,而不是看返回值。 4. **建理论时同样适用**:别用"它解释了多少"去论证"它是什么"。 **覆盖面是近端量,机制地位是远端结论** —— 这两者之间的距离,就是本条纪律本身。 --- ## 2. 已发生的二十例 | # | 实例 | 表面现象 | 真实情况 | 出处 | |---|---|---|---|---| | 1 | **坐标系混用** | 代码读起来完全合理 | 三个型号用了两套原点约定,树莓派 **6 个端口 4 个在几何体外** | `contracts/library.ts` 文件头 | | 2 | **`1 << 47` 得 32768** | 补偿算法不抛错、返回数字 | JS 位运算是 32 位而 Bosch 算法是 int64,**整条补偿链全错** | `core/devices/bme280.ts` | | 3 | **热重载报成功实则没换掉** | 回执"清缓存 20 模块,重建 1 fiber" | 线上跑的还是**旧代码**;两次回执长得一模一样 | `docs/00` §4.4 | | 4 | **路由 disposer 被丢弃** | 接口都在、状态在变 | 旧路由残留 → 新代码注册撞重复路径被 catch 吞掉 → **一直由旧代码服务**,只表现为**空 body 400** | `docs/00` §4.4 | | 5 | **静默落零** | 端口位置是 `(0,0,0)` | 既无 `position` 也无 `portLayout` 时的默认值 —— **看着有值,全是错的** | `contracts/library.ts` | | 6 | **静默忽略显式坐标** | 有规则时一切正常 | 同时给了规则与显式坐标 → **显式值被无声丢弃** | 同上 | | 7 | **地址冲突永不触发** | 警告系统工作正常 | 总线键用了**从机侧**端口,同总线的两个从机被分进两个桶 | `core/state/assembly-state.ts` | | 8 | **供电死锁** | 放置逻辑正常 | 预算只算已放置组件的供给 ⇒ **放置第一台电源自己就过不了预算** | 同上 | | 9 | **校验公式方向混淆** | 校验器工作正常 | 把 `inset` 当成沿边方向的边距(实为垂直方向)⇒ **把坐标正确的型号判成非法** | `contracts/library.ts` | | 10 | **并发守卫被 `await` 时序穿透** | 守卫写在函数开头,读起来完全正确 | 守卫在 `await server.listen()` / `spawn()` **之后**才置位 ⇒ 第二次调用在置位前就穿过去了,**守卫形同虚设** | `core/sim/engine.ts` | | 11 | **优化前提被拒绝路径破坏** | 同步逻辑一切正常 | `apply()` 按 `revision` 短路,隐含假设「revision 没变 ⇒ 本地也没变」;但**被拒的动作不改宿主状态 ⇒ revision 不变**,而本地 mesh 已被拖动过 ⇒ **短路掉了一次本该发生的重调和**,留下第二本账 | `scene/sync.ts` | | 12 | **测试替身的规则与生产不一致** | **测试全绿** | 替身用宽松的 `pathname.startsWith(path)`,生产用 `pathname === p \|\| pathname.startsWith(p + '/')`。前缀带尾斜杠时**前者匹配、后者不匹配** ⇒ 测试通过而线上 404/405,且 handler 一行日志都不出 | `tests/model-store.test.ts` | | 13 | **shim 没被打进包** | 开发期一切正常 | `package.json` 的 `files` 只有 `["lib"]`,而 `project-shim/` 在包根 ⇒ **从 tgz 装出来的包跑不了仿真**(PYTHONPATH 指向不存在的目录)。开发期是 junction 直连源码目录,整个仓库都在,**随便写个相对路径都能找到** | `scripts/build.sh` | | 14 | **两套 schema 方言用混** | `parameters` 看着完全正确 | DSH 的 `parameters` 是**源方言**(属性内 `required: true`,由 `defineTool` 编译),`output.schema` 是**已编译**形态(对象级 `required: [...]`)。我们不用 `defineTool` ⇒ 没有编译这一步。`output.schema` **注册失败**(响的,还好);`parameters` **不校验、原样交给模型** ⇒ `required` 形同虚设(**静默的,更坏**) | `host/tools.ts` | | 15 | **给拿不到的字段编一个假值** | 日志里有 `pid=12345`,像模像样 | 宿主刻意不暴露进程身份(契约原文 "Target identity remains provider-private"),`SubprocessHandle` 上**没有 pid**。用 `?? -1` 填上 ⇒ 日志**看着像诊断信息、实际是垃圾**,排查时把人带偏 | `core/sim/engine.ts`、`tests/sim-engine.test.ts` | | 16 | **在输出排空前就读它** | 探测"正常"返回"没找到解释器" | 宿主 `done` 只保证**进程退出**,此刻 `'data'` 还没投递完 ⇒ 读到空字符串 ⇒ **一个明明可用的解释器被判成不可用**。同样的错在工具适配器里会**丢掉最后几行**(通常正是 traceback) | `host/subprocess-spawner.ts` | | 17 | **把枚举目录当能力全集** | 目录里没有 ⇒ 结论"做不到" | 用「服务/方法目录」判断能力有无,而**属性不是方法**、**对象上的方法不是服务**。实测两次:① `cordis_inspect` 只枚举**方法**,`ctx.sessions.currentProvideInfo` 是**属性** ⇒ 看不见,于是误判"拿不到 sessionId" ② 我只查了 90 个 Cordis **Service**,没查 **Agent 对象自己**的方法 ⇒ 误判"没有发消息通道",而 `Agent.followup()` 一直在那儿 | 本会话两次自我更正 | | 18 | **占位物阶段的性能是隐形的** | 代码跑得好好的 | `nearestPort()` 在**每次鼠标移动**时做一次 `scene.traverse()`。**占位盒时代全场景 1 个节点**,成本是 1,完全隐形;换上真树莓派模型后是 **2067 个节点**,成本凭空涨**三个数量级** —— 而它**不报错、只是变卡**。同源还有两处(`getObjectByName` 白查、拖线起点重复查) | `scene/interaction.ts` | | 19 | **为性能加的缓存,用一类 bug 换掉了另一类** | 加了缓存之后**更快了**(验证过) | 交互层为了不每帧遍历场景,把锚点清单**缓存**下来、**按装配 `revision` 做键**。但场景能在 **`revision` 不变时重建**(`rebuildModel()` → `forceResync()` 只重置渲染水位)⇒ 缓存里留着**已销毁的旧锚点** ⇒ `nearestPort()` 里 `if (!marker.parent) continue` **把它们全部跳过** ⇒ **悬浮与拖线起点判定一起静默失效**,直到下次装配变更。**症状"有时候变绿有时候不变"** | `scene/interaction.ts` / `scene/sync.ts` | | 20 | **`visible = false` 不等于"不可交互"** | 把标记隐藏起来了,界面上看不见 | **three.js 的射线检测完全无视 `visible`(r180 实测)**:射线打在**不可见** mesh 上照样命中。⇒ 隐藏锚点**不会**弄坏"从端口起拖线"(✅ 好消息),但**它仍然是点击目标**(❌ 坏消息)—— 用户会点到一个**看不见的东西**上,得到「开始拖线」而不是「移动组件」,**而且不知道为什么**。⇒ **悬停高亮不是装饰品,它是让这些隐形点击区变得诚实的东西** | `spike/raycast-visible.mjs` | > 第 9 例有个讽刺之处:**是结构校验器把校验公式自己的 bug 抓了出来**,第一次运行就抓到。 > 第 10 例的教训更隐蔽:**代码读起来是对的,错的是它在异步时序中的位置。** > 第 11 例是**优化与交互的冲突**:短路本身没错,错在它的**前提**被一条新路径(拒绝)破坏了 —— > 而这条路径在写优化时还不存在。 > 第 12 例最阴:**它让测试变成了共犯** —— 你以为有测试保护,实际上测试证明的是替身自己。 > 第 13 例的教训是**"开发期能跑"根本不构成证据**:junction 安装与 tgz 安装是两个世界, > 而开发期永远只用前者。 > 第 14 例**同一份文件里一半响一半哑**:`output.schema` 被宿主校验挡下(响), > `parameters` 无人校验(哑)。**"有校验的地方报错"会让人以为整个文件没问题。** > 第 15 例是**"填满字段"的诱惑**:字段有值 ≠ 值有意义。拿不到就不写。 > 第 16 例与第 10 例同源(都是 `await` 时序),但方向相反:第 10 例是**守卫置位太晚**, > 第 16 例是**读取得太早** —— 同一个 `done`,两边都不能只信它。 > 第 17 例**同一个人在一次会话里犯了四次**,而且每次都发生在**刚为上一次道歉之后**: > ① 只查 Cordis Service 目录 ⇒ 断言"没有发消息通道"(其实 `Agent.followup` 一直在) > ② 只查服务**方法**目录 ⇒ 差点断言"拿不到 sessionId"(其实是**属性**) > ③ 只读对方的**一条消息**(提议)⇒ 断言"我那条路由该退场", > 而对方的**代码**里早就选了保留它(已实证 > 未实证) > ④ 只看了 `package.json` 的 `dsh.client.inject`(**包名**)⇒ 据此推断客户端服务注入, > 而真正管服务注入的是 bundle **导出的** `inject`(**服务键**)—— **两份同名清单,含义不同** > > **四次的形态完全一样:拿一个"清单"当"全集",然后下否定结论。** > 而"清单"依次是:**服务目录 → 方法目录 → 同事的一句话 → 同名但不同义的配置项**。 > ⇒ **这个动作与信息来源的权威性无关,它是推理习惯本身的问题。** > **"目录"之所以危险,是因为它看起来就像一个权威答案。** > > 它不产生错误结果,只产生**错误结论** —— 而错误结论会让整条路被放弃,代价比 bug 大。 --- ## 3. 对应纪律(每条都由某一例逼出来) | 纪律 | 逼出它的例子 | |---|---| | **拒绝加载 > 告警**。加载期路径上的 warning 是死信——那时没人盯控制台。 | 5、6 | | **对称性检查**:若"都没有"是错误,则"都有"也必须是错误。**要么歧义不存在,要么拒绝加载,没有中间态。** | 6 | | **结构性校验 > 逐点校验**。逐点检查全绿、而规则是错的——只有校验规则本身才能发现。 | 1、9 | | **两处真相必除**。同一事实有两个存放点,就一定会分叉。 | 1、4、6、7 | | **所有权唯一**。「谁释放谁」含糊 ⇒ 双释放或泄漏,且不报错。 | (前端 provider 换装,已定:**谁创建谁释放**) | | **任何缓存/热更新边界都要有可外部观测的版本指纹**。 | 3、4 | | **未实现的方法必须明确抛错**,绝不静默返回假值——静默会让覆盖测试误判该路径已覆盖。 | (`BridgeServer` 对 gpio / 未知方法的处理) | | **能用"消灭问题"解决,就不要用"管理问题"解决**。例:不缓存 ⇒ 所有权天然唯一;声明式布局 ⇒ 等距由规则推出而非人肉保证。 | 1 | | **异步守卫必须在第一个 `await` 之前同步置位**。写在 await 之后 = 没写。 | 10 | | **引入短路/缓存时,必须列出「哪些路径会让前提不成立」**。优化本身没错,错在它的前提被后来的新路径破坏。 | 11 | | **交互期间本地可以暂时偏离投影,但"交互结束"必须无条件触发一次重调和** —— 不能指望 `revision` 变化来代劳(被拒时它不变)。 | 11 | | **★ 可执行检查**:每引入一个短路条件,**必须同时列出「哪些路径会让这个前提不成立」,并在那些路径上显式兜底**。只写条件、不写反例路径 ⇒ 这个优化迟早被一条新路径击穿。 | 11 | | **测试替身的调度/匹配/序列化规则必须逐字复刻生产**。替身一旦比生产"宽松",测试就从保护变成共犯。 | 12 | | **"开发期能跑"不是证据**。凡是安装形态与开发形态不同的东西(打包清单、junction vs tgz、PATH 解析),**必须在安装形态下验证一次**,并在构建脚本里加断言。 | 13 | | **第三方方言不要靠"看起来像"推断**:找到它的**编译入口**(这里是 `defineTool`),确认哪一部分是源方言、哪一部分是已编译产物。**用错的那一半往往不报错。** | 14 | | **有校验的地方报错 ≠ 整个文件没问题**。同一份定义里,没被校验的字段才是危险的那个。 | 14 | | **拿不到的字段就不写**。不要为了让结构"完整"而编造值——`-1`/`0`/自增计数都会变成日志里的假诊断。把字段改成可选。 | 15 | | **★ 凡是"进程退出"这类事件,都要问一句:它的输出排空了吗?** `done`/`exit` 只保证退出事实,不保证 stdio 已送达。**要么等流结束,要么明确接受截断**,别默认它已经完整。 | 16 | | **外部可观测的指纹要覆盖"能力有没有接上"**,不只覆盖"代码是新是旧"。工具注册失败是完全静默的(模型只会说"我没这个能力")——`capabilities.simTools` 就是为此存在的。 | 3、4、14 | | **★ 判断"有没有某能力",要看契约与源码,不能只看枚举出来的目录。** 目录通常只列**方法**(属性不出现),也只覆盖**某一类对象**(服务 ≠ 对象自己的方法)。**"目录里没有"只是一个待验证的假设**,不是结论 —— 下结论前必须去读声明或源码。 | 17 | | **★★ 任何二手信息(目录、清单、队友描述)都只是待验证假设;结论必须以一手事实为据(源码、实测、跑一遍)。** 二手信息比一手**更可信**,所以**更不容易起疑** —— 这正是它危险的地方。 | 17 | | **★ 下"做不到"的结论,证据门槛必须高于"做得到"。** 说做不到 ⇒ 整条路被放弃、没人再查;说做得到 ⇒ 顶多是白试一次。 | 17 | | **★★ "调用没报错" ≠ "效果发生了"。** 返回 `{ok:true}` 只说明**函数没抛**,不说明**副作用发生了**。声称"已实测"之前,先问:**我看到的是"被接受",还是"结果真的出现了"?** | 本会话 ⑪ 的自我更正 | | **★ 这个错专门发生在「验证」这一层,比代码层的同类错误更难自觉** —— 因为验证者**以为自己在做严谨的事**,而实际上把"没有异常"当成了"通过"。⇒ 写验证结论时,**必须写出你实际观测到的是什么**(状态码?日志?还是最终效果?)。 | 同上 | | **★★ 同一个坑,两个会话**各自独立**踩了一次(相隔十几分钟)** —— 见 §4 的推论。 | 同上 | | **★★ 换真几何会改变性能画像,不只是"好看一点"。** 任何「每帧 / 每次输入做一次全树遍历或全表扫描」的写法,在**占位物阶段都是隐形的** —— 这类代码**在 demo 里永远测不出来,只在真实数据下暴露**。 | 18 | | **★ 往库里加型号时,"节点数量级"要作为一个已知量记下来**(本插件已在导入时记录 `ImportedModelRecord.nodes`)。实测 `rpi-4b` **2067** / `mpl3115a2` **13** / `mpr121` **1** —— 中间差三个数量级,**下一个导入的模型很可能是另一个 2000+**,而它进场景之前没人会想到去量。 | 18 | | **★★ 缓存引入的"失效时机"是一个新的状态;不验证它就等于引入一个没测过的分支。** 加缓存时**只验证了"更快",没验证"还对不对"** —— 那就把一类 bug(慢)换成了另一类(**可能永远不刷新的副本**)。**"用一类 bug 换掉另一类"必须显式记账。** | 19 | | **★★ 发现别人持有的东西,不要"自己再发现一遍"并维护副本 —— 问持有者。** 副本必然要回答"什么时候该失效",于是有了键、有了猜、有了 bug。**问持有者就没有这个问题。** | 19 | | **★ 这类能力应当做成「必填」而不是「可选」**:可选 = 忘了传就**静默**失去功能,症状还是"有时候不好使"。**让它编译不过,比让它运行时安静地坏掉好。** | 19 | | **★★★ 结论同样是需要失效的状态。** 与上一条是**同一个形状,只高了一层**:
· 缓存那次:**数据**被缓存了,**失效键选错**
· 这次:**结论**被缓存了,**根本没有失效键**
⇒ **结论也有前提。前提被修复时,结论必须重审 —— 而"重审"不会自动发生。**
实例:曾建议"砍掉悬停变色",理由是"用户说时有时无";**根因(缓存键错)修掉之后那个前提就不成立了,却没回头重审建议**。重审的结论正好相反 —— 在"没有圆球"的世界里它**必须**回来(见第 20 例)。 | 19 / 20 | | **★ 契约里"先声明"的判据,不是"现在有没有消费者",而是「有没有一个能被兑现的触发条件」**:
· **有触发条件 + 条件可观测** ⇒ 可以先声明(`orientation` ✓ —— 公约原文写着"等首个真实模型落地时再加",**触发器到了就兑现了**)
· **没有触发条件** ⇒ 就是死字段(`direction` 原来那样 ✗ —— 加了字段、零消费者,全仓库对 `.direction` 的引用**全是 CSS `flexDirection`**)
⚠️ 早先写成"**字段没有消费者就不该存在**"是**过宽**的 —— 那条会把 `orientation` 一起禁掉,而它后来被证明是对的。 | — | --- ## 4. 一条元教训:收敛是**必要不充分**信号 本项目里出现过两次「两个人独立得出同一设计」,两次都收敛了。但: > **两个人独立得出同一个设计,只有在「约束被正确识别」时才说明问题。** > 若约束识别错了,两边会**一起**错。 而这一轮**恰好就栽在约束上**:一方漏检了「那个封装是谁的封装」,另一方算错了「`inset` 是哪个方向的」。 ⇒ **收敛值得记,但不能替代对约束本身的检查。** ### ★ 推论(反向也成立,而且更有用):**失败**的收敛是**强**证据 同一条纪律,反过来用价值更大: | | 两个人**都做对**了 | 两个人**都做错**了 | |---|---|---| | 说明 | **弱** —— 可能是一起错了(上例) | **强** —— 陷阱是**结构性的**,不是谁不小心 | **实证**:`ok:true` 那条过度声称,**两个会话各自独立犯了一次,相隔十几分钟**, 两边当时手里都只有 `{"ok":true,…}`,都把它当成了"端到端验证通过"。 ⇒ **一个人犯两次,可以归因于他粗心;两个人各犯一次,只能归因于这个动作本身太顺手。** ⇒ **评审时**:发现两边**都**做了某个判断,先别当互相印证 —— 但要问一句 「我们是不是都从同一个**看起来像权威答案**的东西推出来的?」(目录、状态码、一句转述…) **推论**:设计评审时,**先核对约束,再看两边是否一致** —— 顺序反了会把"一起错"当成"互相印证"。 --- ## 5. 怎么用它 - **写新代码时**:问一句「如果这里错了,**谁能从外部看出来**?」看不出来 ⇒ 加结构性校验或指纹。 - **排查时**:现象"正常"但行为不对 ⇒ 先怀疑这一族,而不是先怀疑逻辑。 - **评审时**:先核约束,再看一致性(§4)。 - **命名它**:说「这属于失败族」比说「这里有个 bug」更能让人想起整套对策。