Archived
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:
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user