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);