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
@@ -345,17 +345,50 @@ describe('S3 / S4 配方', () => {
const s4 = stepOf('T_release')?.auto?.do ?? [];
const s2 = stepOf('S2_assess')?.auto?.do ?? [];
it('S3 四笔:两笔重算在前、卡在后 —— 人答完那一刻重算才发生', () => {
expect(s3.map((a) => a.kind)).toEqual(['project', 'project', 'card', 'say']);
expect(s3[0]?.kind === 'project' && s3[0].into).toBe('substitution.relation');
expect(s3[1]?.kind === 'project' && s3[1].into).toBe('validation.record');
it('S3 五笔:差异明细重算 → 关系 → 验证记录 → 卡 → 交代 —— 人改的四态先落地,结论才有真数据可读', () => {
expect(s3.map((a) => a.kind)).toEqual(['project', 'project', 'project', 'card', 'say']);
expect(s3[0]?.kind === 'project' && s3[0].into).toBe('substitution.difference_item');
expect(s3[1]?.kind === 'project' && s3[1].into).toBe('substitution.relation');
expect(s3[2]?.kind === 'project' && s3[2].into).toBe('validation.record');
});
/*
* **这是 2026-08-30 task-12「结论纹丝不动」缺陷的回归钉**:S3 从前直接从
* `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 起步,人改的 `difference_status`
* 没有任何一笔把它转译成 `judgement_class`,两笔投影读到的 tally 纹丝不动。
* `s3[0]` 必须是**排在关系 / 验证记录投影之前**的差异明细重算笔。
*/
it('差异明细重算笔:只列主键与两个派生列,不碰 difference_status 自己、也不碰人能改的叙事列', () => {
const recompute = s3[0];
expect(recompute?.kind === 'project' && recompute.from).toEqual({ each: 'mine:substitution.difference_item' });
/* `recompute` 经 `s3[0]` 的 tuple 定位精确 narrow 到这一笔自己的字面量类型,它压根没有
`prune` 这个键(不是「值为 undefined」,是键不存在)—— `in` 判存在性,不做属性访问 */
expect('prune' in (recompute ?? {})).toBe(false);
const fields = recompute?.kind === 'project' ? recompute.fields : undefined;
/* 只有两把主键(定位这一行)+ 两个派生列(judgement_class/risk_level)—— 一个都不多写:
difference_status / impact_statement / evidence_note / unknown_disposition(人可以就地改的那几列)
一个都不在这份 fields 里,靠 draftWrite 的稀疏 patch 语义保证不被这一笔碰到。 */
expect(Object.keys(fields ?? {}).sort()).toEqual(['difference_item_id', 'judgement_class', 'risk_level', 'substitution_relation_id']);
});
/*
* **判定分类 / 风险等级的 when 链只许有一处真源**:S3 的重算笔与 S2 差异明细创建笔的
* `judgement_class`/`risk_level` 必须是**同一个对象引用**(都来自 `...JUDGEMENT_DERIVED_FIELDS`
* 展开)——各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的规则。
*/
it('S3 重算笔与 S2 差异明细创建笔共用同一份 judgement_class / risk_level when 链', () => {
const s3Fields = s3[0]?.kind === 'project' ? s3[0].fields : undefined;
const s2Fields = s2[2]?.kind === 'project' ? s2[2].fields : undefined;
expect(s2Fields?.['judgement_class']).toBeDefined();
expect(s3Fields?.['judgement_class']).toBe(s2Fields?.['judgement_class']);
expect(s3Fields?.['risk_level']).toBe(s2Fields?.['risk_level']);
});
it('**S2 与 S3 用的是同一份投影** —— 各写一份早晚分叉', () => {
// S2 的 do 数组里 RELATION_PROJECTION 出现两次(acts[1]/acts[3],同一个引用)——
// 与 S3 比对随便挑一个都行,这里挑 acts[3](紧跟在差异明细之后、值是真的那一次)
expect(s3[0]).toBe(s2[3]);
expect(s3[1]).toBe(s2[4]);
expect(s3[1]).toBe(s2[3]);
expect(s3[2]).toBe(s2[4]);
});
/*
@@ -371,7 +404,7 @@ describe('S3 / S4 配方', () => {
});
it('S3 的卡:审核面孔、单一选项、挂本步的件', () => {
const card = s3[2];
const card = s3[3];
expect(card?.kind === 'card' && card.as).toBe('decide');
expect(card?.kind === 'card' && card.variant).toBe('review');
expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1);
@@ -140,6 +140,70 @@ const SHARED_LET = {
relationId: { cat: ['SUB-', { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' }] },
} as const;
/*
* **人改了四态,判定分类 / 风险等级要跟着重算 —— 而这两列的算法此前只在 S2 出现过一次**
* (2026-08-30,治「结论纹丝不动」的要害缺陷:件上白纸黑字承诺「你改动下表里任何一条
* 四态,这五项当场重算」——实测是假的。人在审核件上把某一行的 `difference_status` 从
* `mismatch` 改成 `match` 并保存,草稿里那一行变成 `difference_status=match` 但
* `judgement_class` 仍是旧的 `hard_mismatch`(**同一行自相矛盾**);`RELATION_PROJECTION`/
* `VALIDATION_PROJECTION` 读的是 `judgement_class` 的 tally,不是 `difference_status`
* 本身,两者之间从来没有一座桥。**S3 存在的全部意义就是让人纠正 AI;纠正流不下去,
* S3 就是摆设。**
*
* **这段 when 链与 S2 差异明细那一笔共用同一份**(下面 `substitution.difference_item`
* 那笔 `project` 的 `fields` 里 `...JUDGEMENT_DERIVED_FIELDS`)—— 判定规则只许有一处真源,
* 与 `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 本身「S2 与 S3 共用同一份」同一条道理。
*/
const JUDGEMENT_DERIVED_FIELDS = {
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' },
},
} as const;
/*
* **S3 专用的重算笔**——逐行把 `judgement_class`/`risk_level` 按这一行**当下**的
* `difference_status`(人可能刚改过)与 `constraint_type`(字典定的,人不改)重新算一遍。
*
* **为什么不能像 S2 那样把差异明细整笔搬过来重投**:S2 那一笔的 `difference_status` 走
* `lookup:'result:items'`(AI 出参,只在 `analyze` 那一拍的拍内作用域里活着);S3 这一拍
* `result:items` 早就不在了,`{lookup:...}` 落到 `else:''`,原笔重投会把人刚改的值**抹成空**
* ——这是真实的设计张力,不是疏忽(评审已确认,见 `SHARED_LET` 定义处的头注)。
*
* **只列这两列,一个都不多写**:`fields` 之外没列的列,`draftWrite` 的 `patch` 是稀疏的
* (只含显式给出的列;DB 侧 `patch = patch || excluded.patch` 是 jsonb **浅合并**,没给的列
* 原样留着,`ledger-store.ts` 的 `put`)——于是这一笔天然不会碰 `difference_status` 自己,
* 也不会碰人可以改的那几列(影响说明 / 判断依据 / UNKNOWN 去向)。**不带 `prune`**:
* `from` 是对这张表自己的无过滤 `each`,枚举出来的集合本来就是「这张表现在有的全部行」,
* `prune` 没有事情可做,而已落地的行本就不许 `prune`(H43)。
*
* **必须排在 `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 之前**:后两笔读的
* `substitution_difference_item_judgement_class_*` 是基座对这张表当下内容现算的 tally,
* 「一笔读的是另一笔写出来的数,它就得排在那一笔后面」(与 `sub_eval.case` 那笔 `gate_reason`
* 排在 ④⑤ 之后是同一条道理,见 S1 那笔 ⑥ 头上的大注释)。
*/
const DIFFERENCE_ITEM_RECOMPUTE = {
kind: 'project' as const,
into: 'substitution.difference_item',
from: { each: 'mine:substitution.difference_item' },
fields: {
/* 主键原样带回,认的是这一行自己 —— 不是新建,是同 PK 覆盖 */
substitution_relation_id: { col: 'substitution_relation_id' },
difference_item_id: { col: 'difference_item_id' },
...JUDGEMENT_DERIVED_FIELDS,
},
} as const;
/*
* **关系与验证记录的投影,S2 与 S3 共用同一份**。
* S2 算出第一版;人在审核件上改了某一条四态之后,S3 用**同一份**重算 ——
@@ -628,21 +692,8 @@ export const ASSESSMENT_STEPS = [
impact_statement: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'impact_statement', else: '' },
evidence_note: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'evidence_note', else: '' },
unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'unknown_disposition', else: '' },
/* 合成列:先认四态,最后才按约束类型把 mismatch 分成硬 / 软 */
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' },
},
/* 合成列:先认四态,最后才按约束类型把 mismatch 分成硬 / 软 —— 与 S3 重算那一笔共用同一份 when 链,不许各写一份 */
...JUDGEMENT_DERIVED_FIELDS,
rule_version: RULE_VERSION,
},
},
@@ -689,7 +740,13 @@ export const ASSESSMENT_STEPS = [
*/
let: SHARED_LET,
do: [
/* 人改了四态,等级 / 风险 / 摘要 / 验证要求跟着重算 —— 摘要只读,改的是四态 */
/*
* **人改了四态,先把这一行自己的判定分类 / 风险等级重算,关系与验证记录的投影才有
* 真数据可读**(2026-08-30 修「结论纹丝不动」的要害缺陷,详见 `DIFFERENCE_ITEM_RECOMPUTE`
* 定义处的头注)。三笔次序不能乱:①→②③ 是「一笔读的是另一笔写出来的数,它就得排在
* 那一笔后面」的又一例 —— 摘要只读,改的是四态。
*/
DIFFERENCE_ITEM_RECOMPUTE,
RELATION_PROJECTION,
VALIDATION_PROJECTION,
{
@@ -54,7 +54,7 @@ export const manifest: ActivityManifest = {
activityId: ACTIVITY_ID,
name: 'ACT-WORK-05 · 差异评估',
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
packageVersion: '2.1.7',
packageVersion: '2.1.8',
category: '需求分析与方案匹配',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-part-substitution-assessment',