fix(w05): S3 补一笔只重算派生列 —— 人改了四态,结论才跟得上

浏览器走查实测:件上承诺「你改动下表里任何一条四态,这五项当场重算」,
而把使能阈值由 mismatch 改成 match 保存后,同一行的 judgement_class 仍是
hard_mismatch、risk_level 仍 high —— 行内自相矛盾,结论纹丝不动
(仍「不推荐 / 高风险 / 1 项硬约束不符」)。

断链在:S3 复用的 RELATION_PROJECTION 算结论读的是 judgement_class 的 tally,
而 judgement_class 由 difference_status 在差异明细那一笔的 when 链推出来 ——
那一笔只在 S2。人改的 difference_status 没有任何东西把它转成 judgement_class。

当初不把那一笔放进 S3 是有真理由的:它的 difference_status 走 lookup:'result:items',
而 result: 只在 analyze 那一拍存在,在 S3 重跑会把人刚改的值抹成空。

修法:加 DIFFERENCE_ITEM_RECOMPUTE 作 S3 的第一笔 —— each(mine:…) 不 prune
(避开「已落地行不许 prune」),fields 只有两个主键列 + JUDGEMENT_DERIVED_FIELDS
(judgement_class / risk_level),**一列都不多写**,稀疏 patch 绝不碰
difference_status 与人可改的叙述列;排在 RELATION_PROJECTION 之前。
when 链提成 JUDGEMENT_DERIVED_FIELDS 一处真源,S2 与 S3 spread 同一个,
新测试用 toBe 断言引用相等(不是形状相同)。

**旧测试为什么漏掉**:w05-s3-review-recompute.test.ts 的 HUMAN_EDIT mock 在同一个
patch 里同时写了 difference_status 和 judgement_class —— **把系统本该做的翻译自己
先算好了**,那座缺失的桥就此被掩盖。现改为只写人真能碰的列(difference_status +
evidence_note),并加修前/修后成对断言。

变异验证两路都真红:①拆掉配方里那一笔 → 2/3 红,复原 → 3/3 绿;
②git stash 真 case-model.ts → steps.test.ts 5 条结构测试红,pop → 38/38 绿。

包版本 2.1.8(已装,SQL 核过)。pnpm gates 退出码 0(2454 测)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-30 13:27:33 +08:00
co-authored by Claude Opus 5
parent 764dab342d
commit 9f5b1c99d3
4 changed files with 279 additions and 48 deletions
@@ -11,17 +11,39 @@
* **人在审核件上改了四态,那一行永远不会更新**,这正是 S3 存在的全部理由;
* ② 验证记录的主键 `cat:['VAL-', {in:'evalRef'}]` 反而拼出非空的 `'VAL-'`,被当成一个新主键
* 写出**第二行垃圾**,破了「P-06 一单固定一条」的不变量。
* (① ② 两条已修:S3 有了自己的 `let`,见 `S3_LET_MIRROR`/`S3_RECIPE`。)
*
* **2026-08-30 task-12 加第二条覆盖,同一份文件、同一套机制,但是另一层缺陷**(浏览器走查
* 发现⑤,`docs/superpowers/specs/.../progress.md` H 系列 ruling 之后的那条):①②修好之后,
* `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 的主键与外键**绑得上了**,可它们算结论用的
* `substitution_difference_item_judgement_class_*` tally 读的是**判定分类**列,不是四态列 ——
* 人在审核件上把某一行的 `difference_status` 从 `mismatch` 改成 `match` 并保存,
* **没有任何一笔把这个新值翻译成 `judgement_class`**,tally 纹丝不动,两笔投影算出同样的结论。
* 件上白纸黑字承诺「你改动下表里任何一条四态,这五项当场重算」——这句话曾经是假的。
*
* **这份文件原本的 `HUMAN_EDIT` 恰好把这层缺陷藏起来了**:它在同一笔 `project` 里**同时**
* 写了 `difference_status`(`{col:'status'}`)**和** `judgement_class`(`{col:'jclass'}`)——
* 等于替被测系统把「四态 → 判定分类」的翻译**预先做完**,断言看到的「结论正确重算」其实是
* 这笔 setup 自己写对的,不是 S3 配方推出来的。人在真实审核件上**只能改
* `difference_status`/`impact_statement`/`evidence_note`/`unknown_disposition` 这几列**
* (`difference-review` 目标件的 `data-edit` 绑定只挂了这几个 `data-col`)——`judgement_class`
* 从来不在人能碰的字段里。现在的 `HUMAN_EDIT` 已改为只写 `difference_status` + `evidence_note`
* 两列,与真实的就地编辑同源;`S3_RECIPE` 补上 `RECOMPUTE_MIRROR`(镜像 S2/S3 新共用的
* `DIFFERENCE_ITEM_RECOMPUTE`),让「翻译」这一步真的在被测流程里发生一遍。
*
* 只有把真引擎(`createAutoRunner`)跑起来才测得出 ——
* `activities/part-substitution-assessment/__tests__/steps.test.ts` 的六条用例全是结构断言
* (查 `kind`、查 `into`、`toBe` 引用相等),从来没有对配方求过值,漏不出这一类"名字没绑上"的洞。
* `activities/part-substitution-assessment/__tests__/steps.test.ts` 的用例全是结构断言
* (查 `kind`、查 `into`、`toBe` 引用相等),从来没有对配方求过值,漏不出这一类"名字没绑上"
* 或"两张表的值没对齐"的洞。
*
* **手写镜像,不 import 真配方** —— 与 `w05-gate-reason-order.test.ts` 同一条理由:
* `activities/*` 与 `apps/*` 互相不许 import(ADR-004,`.dependency-cruiser.cjs` 的
* `activities-isolated` / `apps-not-into-activities`)。`REL_MIRROR`/`VAL_MIRROR`/`S3_LET_MIRROR`
* 逐字取自 `case-model.ts` 当前的 `RELATION_PROJECTION`/`VALIDATION_PROJECTION`/`SHARED_LET`
* ——在形状上代表,不是逐字同步的承诺(谁改了这三者的**形状**,该顺手核一遍这份文件还有没有
* 代表性,欠账记在 `docs/debt.md`,与 S1 那份镜像同一条纪律)。
* `activities-isolated` / `apps-not-into-activities`)。
* `REL_MIRROR`/`VAL_MIRROR`/`S3_LET_MIRROR`/`JUDGEMENT_FIELDS_MIRROR`/`RECOMPUTE_MIRROR`
* 逐字取自 `case-model.ts` 当前的
* `RELATION_PROJECTION`/`VALIDATION_PROJECTION`/`SHARED_LET`/`JUDGEMENT_DERIVED_FIELDS`/
* `DIFFERENCE_ITEM_RECOMPUTE` ——在形状上代表,不是逐字同步的承诺(谁改了这五者的**形状**,
* 该顺手核一遍这份文件还有没有代表性,欠账记在 `docs/debt.md`,与 S1 那份镜像同一条纪律)。
*
* **读数层四个函数直接 import 真身**,理由与路径别名 `@app/*` 同 `w05-gate-reason-order.test.ts`
* 头注:`apps/api/src/modules/loop-engine/__tests__/` import `apps/api/src/app/wiring/engines.ts`
@@ -307,8 +329,18 @@ const SETUP_S1S2: AutoRecipe = {
/**
* 阶段二(setup,镜像人在审核件上就地改了一格):把「静态电流」那一行的四态从 `match`
* 改成 `hard_mismatch`(审核人对着候选规格书发现待机功耗超预算,原厂的硬约束没扛住)。
* 与真实的就地编辑同源 —— 只改这一行,不碰另一行,不用 `prune`。
* 改成 `mismatch`(审核人对着候选规格书发现待机功耗超预算,原厂的硬约束没扛住),
* 并顺手把判断依据写清楚。
*
* **只写人在审核件上真正碰得到的那几列**(2026-08-30 task-12 修正,见文件头注)——
* `difference_status` + `evidence_note`。**绝不写 `judgement_class`/`risk_level`**:
* 那是 S3 配方该重算的两列,人手上的表单压根没有这两格(`difference-review` 目标件的
* `data-edit` 绑定只挂了 `difference_status`/`impact_statement`/`evidence_note`/
* `unknown_disposition` 四个 `data-col`)。写它们等于替被测系统预先把「四态 → 判定分类」
* 的翻译做完,会让本用例测不出「S3 到底有没有真的重算」这件事本身 ——
* 这正是这份文件原本的写法把 task-12 那层缺陷藏起来的原因。
*
* 与真实的就地编辑同源:只改这一行,不碰另一行,不用 `prune`。
*/
const HUMAN_EDIT: AutoRecipe = {
do: [
@@ -318,33 +350,63 @@ const HUMAN_EDIT: AutoRecipe = {
from: {
rows: [
{
item_id: `${RELATION_ID}-静态电流`, name: '静态电流', desc: '影响待机功耗',
status: 'mismatch', jclass: 'hard_mismatch', orig: '50uA', cand: '200uA', unit: 'uA',
note: '审核人核对候选规格书 §6.2 实测值,静态电流是原厂 4 倍,超待机功耗预算', risk: 'high',
item_id: `${RELATION_ID}-静态电流`,
status: 'mismatch',
note: '审核人核对候选规格书 §6.2 实测值,静态电流是原厂 4 倍,超待机功耗预算',
},
],
},
fields: {
substitution_relation_id: RELATION_ID,
difference_item_id: { col: 'item_id' },
parameter_name: { col: 'name' },
parameter_desc: { col: 'desc' },
constraint_type: 'hard',
original_value: { col: 'orig' },
candidate_value: { col: 'cand' },
parameter_unit: { col: 'unit' },
difference_status: { col: 'status' },
judgement_class: { col: 'jclass' },
impact_statement: '候选静态电流超出原厂 4 倍,待机功耗预算不满足',
evidence_note: { col: 'note' },
unknown_disposition: '',
risk_level: { col: 'risk' },
rule_version: 'R-TEST',
},
},
],
};
/**
* `JUDGEMENT_DERIVED_FIELDS` 的镜像 —— 逐字取自 `case-model.ts` 当前的常量。
* S2 的差异明细创建笔与 S3 的重算笔(`RECOMPUTE_MIRROR`,下面)共用同一份 ——
* 判定规则只许有一处真源,各写一份早晚分叉。
*/
const JUDGEMENT_FIELDS_MIRROR = {
judgement_class: {
when: 'difference_status = "match"', then: 'match',
else: {
when: 'difference_status = "unknown"', then: 'unknown',
else: {
when: 'difference_status = "conditional"', then: 'conditional',
else: { when: 'constraint_type = "hard"', then: 'hard_mismatch', else: 'soft_mismatch' },
},
},
},
risk_level: {
when: 'judgement_class = "hard_mismatch"', then: 'high',
else: { when: 'judgement_class = "match"', then: 'low', else: 'medium' },
},
};
/**
* `DIFFERENCE_ITEM_RECOMPUTE` 的镜像 —— **这是本用例要钉的核心新增笔**(2026-08-30
* task-12):S3 若没有这一笔(或它排在 `REL_MIRROR`/`VAL_MIRROR` 之后),人改的
* `difference_status` 就没有任何桥转成 `judgement_class`,两笔投影读到的 tally 纹丝不动 ——
* 这正是「结论纹丝不动」那条要害缺陷的机制。只列主键 + 两个派生列,不碰
* `difference_status` 自己、也不碰人可以改的叙事列(`impact_statement`/`evidence_note`/
* `unknown_disposition`)。
*/
const RECOMPUTE_MIRROR: AutoAction = {
kind: 'project',
into: 'substitution.difference_item',
from: { each: 'mine:substitution.difference_item' },
fields: {
substitution_relation_id: { col: 'substitution_relation_id' },
difference_item_id: { col: 'difference_item_id' },
...JUDGEMENT_FIELDS_MIRROR,
},
};
/**
* `RELATION_PROJECTION` 的镜像 —— 逐字取自 `case-model.ts` 当前的常量(不代表逐字同步承诺,
* 见文件头注)。**主键 / 外键全靠 `let` 绑定的名字**,这正是这份测试要钉住的那一段。
@@ -457,10 +519,22 @@ const S3_LET_MIRROR = {
};
/**
* **S3 当前(修复后)的样子**:带上自己的 `let`。case-model.ts 现在就是这样写的
* (`let: SHARED_LET, do: [RELATION_PROJECTION, VALIDATION_PROJECTION, card, say]`)。
* **S3 当前(修复后)的样子**:带上自己的 `let`,并且在关系 / 验证记录投影之前先跑一遍
* 差异明细重算笔(`RECOMPUTE_MIRROR`)。case-model.ts 现在就是这样写的 ——
* `let: SHARED_LET, do: [DIFFERENCE_ITEM_RECOMPUTE, RELATION_PROJECTION, VALIDATION_PROJECTION, card, say]`
* (2026-08-30 task-12 在原有的 `REL_MIRROR, VAL_MIRROR, ...` 前面插入了 `RECOMPUTE_MIRROR`)。
*/
const S3_RECIPE: AutoRecipe = {
let: S3_LET_MIRROR,
do: [RECOMPUTE_MIRROR, REL_MIRROR, VAL_MIRROR, CARD_MIRROR, SAY_MIRROR],
};
/**
* **S3 修复前的样子**(2026-08-30 task-12 之前 `case-model.ts` 实际写的):没有
* `RECOMPUTE_MIRROR` 那一笔,直接从关系 / 验证记录投影起步。留着它专门给下面「同一行
* 自相矛盾」那条用例做对照 —— 不靠临时改文件做变异验证,这份对照本身就是常驻回归。
*/
const S3_RECIPE_PRE_FIX: AutoRecipe = {
let: S3_LET_MIRROR,
do: [REL_MIRROR, VAL_MIRROR, CARD_MIRROR, SAY_MIRROR],
};
@@ -495,3 +569,70 @@ describe('S3 审核卡配方:复用 S2 投影时必须自带 let,否则两笔投
expect(out.issuedCardId).toBeUndefined();
});
});
/*
* **2026-08-30 task-12 加的一组**:钉住「结论纹丝不动」那条要害缺陷本身 ——
* 人改了 `difference_status`,**这一行自己的** `judgement_class`/`risk_level` 有没有真的
* 跟着重算,以及重算有没有误伤人可以改的叙事列。上一组的断言只看关系表 / 验证记录的
* 结果,没有直接看差异明细这一行自己;而「同一行自相矛盾」正是浏览器走查实测到的现象
* ——`difference_status=match` 与 `judgement_class=hard_mismatch` 同时摆在一行上。
*
* 第一条用 `S3_RECIPE_PRE_FIX`(没有 `RECOMPUTE_MIRROR` 那一笔)重现修复前的样子,
* 第二条用 `S3_RECIPE`(现在的样子)证明修复生效 —— 两条对照就是常驻的变异验证,
* 不必临时改这份文件本身来复现「摘掉修复」的红。
*/
describe('S3 审核卡配方:差异明细重算 —— 人改的四态要转成判定分类,不能只停在人写的那一列', () => {
it('修复前的形状(S3_RECIPE_PRE_FIX,没有差异明细重算笔):同一行自相矛盾 —— difference_status 变了,judgement_class 纹丝不动', async () => {
const { runner, drafts, ctx } = harness();
await runner.run(SETUP_S1S2, ctx);
await runner.run(HUMAN_EDIT, ctx);
await runner.run(S3_RECIPE_PRE_FIX, ctx);
const edited = [...drafts.values()].find(
(d) =>
d.domain === 'substitution' &&
d.object === 'difference_item' &&
d.key['difference_item_id'] === `${RELATION_ID}-静态电流`,
);
// 人改的那一列如实落了草稿
expect(edited?.patch['difference_status']).toBe('mismatch');
// 但没有任何一笔把它翻译成 judgement_class —— 停在 setup 阶段写下的旧值,同一行自相矛盾
expect(edited?.patch['judgement_class']).toBe('match');
expect(edited?.patch['risk_level']).toBe('low');
});
it('修复后(S3_RECIPE):差异明细这一行自己的 judgement_class / risk_level 真的跟着 difference_status 翻了,且叙事列一个字没被覆盖', async () => {
const { runner, drafts, ctx } = harness();
await runner.run(SETUP_S1S2, ctx);
await runner.run(HUMAN_EDIT, ctx);
await runner.run(S3_RECIPE, ctx);
const edited = [...drafts.values()].find(
(d) =>
d.domain === 'substitution' &&
d.object === 'difference_item' &&
d.key['difference_item_id'] === `${RELATION_ID}-静态电流`,
);
expect(edited?.patch['difference_status']).toBe('mismatch');
expect(edited?.patch['judgement_class']).toBe('hard_mismatch');
expect(edited?.patch['risk_level']).toBe('high');
// 人在这一行写的判断依据一个字没被重算笔覆盖(它只列了两个派生列)
expect(edited?.patch['evidence_note']).toBe(
'审核人核对候选规格书 §6.2 实测值,静态电流是原厂 4 倍,超待机功耗预算',
);
// 人没碰过的 impact_statement 仍是 SETUP_S1S2 写的那句 —— 证明重算笔没有清空叙事列
expect(edited?.patch['impact_statement']).toBe('无');
// 没被人改过的那一行(输入电压范围)保持原样 —— 重算笔不该误伤没变过的行
const untouched = [...drafts.values()].find(
(d) =>
d.domain === 'substitution' &&
d.object === 'difference_item' &&
d.key['difference_item_id'] === `${RELATION_ID}-输入电压范围`,
);
expect(untouched?.patch['difference_status']).toBe('match');
expect(untouched?.patch['judgement_class']).toBe('match');
expect(untouched?.patch['risk_level']).toBe('low');
});
});