feat(w05): S2 关系表拆两笔写、S1 自投影类型字典、e2e 跨案例隔离 —— 真跑逼出来的三处

① S2:substitution.relation 拆成两笔同主键覆盖写,中间夹 difference_item ——
   difference_item.substitution_relation_id 声明了 refersTo(要求父行已存在),
   而 relation 的等级/风险/摘要要读 difference_item 的 tally,两张表互相要对方先写。

② S1:总把选中的那一行 chip_type / type_param 原样投一遍(幂等),
   于是这一单的草稿层永不为空 —— parts.chip_type 得以放回 gateLedgers,
   新建的类型随「数据工单确认即入库」一起落进正式表,不再留悬空引用。
   (此前把它删出名单只顾了闸、没顾落地:引用它的表落了地而它自己没落。)

③ e2e:案例 0 之后做跨案例隔离(withDb + isolationCleanup,不论成败都跑、
   失败只告警不拖垮整套)—— LAND_MASTER=1 之下案例 0 为满足 distinct=1 必须把
   共享的原 PN 转挂新类型,那一改会落进主数据,案例 1 共用同一颗料就看到假的类型不一致。
   断言 expectRevised 改名 expectFinalValue:「点名驳回归属」那条对 W05 不适用
   (四步 guide 全空、唯一的 agent 调用发生在意见之前,没有轮次会读到那条观察)。

包版本 2.1.5。pnpm gates 退出码 0。

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 07:06:52 +08:00
co-authored by Claude Opus 5
parent 6636abfc39
commit d420284887
5 changed files with 357 additions and 73 deletions
@@ -241,10 +241,10 @@ export const ASSESSMENT_STEPS = [
intent: '核对两颗料在不在库、归没归同一个类型、参数值齐不齐;缺什么就派一张数据工单让人补',
guide: '',
/* Task 7:发给人的数据工单摆的就是这一张 —— 三块竖排(存在性与主数据 / 芯片类型字段 / 参数值),
与 auto.do 第 ⑤ 笔 `card` 的 `gateLedgers` 同源(parts.type_param / parts.profile / parts.parameter ——
`parts.chip_type` 不列入,理由见那笔 `card` 定义处的注释)。 */
与 auto.do 第 ⑥ 笔 `card` 的 `gateLedgers` 同源(parts.chip_type / parts.type_param /
parts.profile / parts.parameter —— 四张表都在闸上,理由见那笔 `card` 定义处的注释)。 */
view: 'input-supplement',
writes: ['sub_eval.part', 'sub_eval.case', 'parts.profile', 'parts.parameter'],
writes: ['sub_eval.part', 'sub_eval.case', 'parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'],
reads: ['originalInternalPn', 'candidateInternalPn', 'case:openedAt', 'parts.profile', 'parts.parameter', 'parts.chip_type', 'parts.type_param'],
tools: [],
doneWhen:
@@ -278,7 +278,82 @@ export const ASSESSMENT_STEPS = [
},
},
/*
* ② 主数据骨架:**两颗料都铺、值回填现值**。
* ② **选中的那一行类型字典,原样投一遍**(2026-08-30,Ruling H35 —— 真跑 e2e 撞见的
* 平台侧连带问题:「数据工单确认即入库」按 `gateLedgers` 整表并入正式表,而
* `parts.chip_type` 从 Task 7 起就没列在 `gateLedgers` 里 —— 人一旦真的新建一个类型,
* `parts.profile`/`parts.type_param` 引用着它一起落地,**字典行本身**却只留在这一单
* 草稿里、从没落地过。下一个任务读到同一颗料,查这个 `chip_type_id` 时字典里空空如也,
* `refersTo` 当场拒——是一条悬空引用,不是「查无此值」那种输入错误)。
*
* **这一笔不看「新建还是已有」,统统投一遍**(不选型的选也一样、新建的选也一样):
* 读的是 `sub_eval.part` 每一行当下的 `chip_type_id`(空串时下面这层 `each` 天然查
* 不到、不产生任何行 —— 人还没选类型的那颗不会被提前铺出假行),按它去
* `parts.chip_type` 里找到那一行、原样把 `type_name`/`description` 抄回自己身上。
* 值没变,`draftWrite` 同 PK 覆盖静默跳过(幂等,不产生多余的写入痕迹)——
* 但**这一单的草稿层从此真的有这一行**,`mine:parts.chip_type` 不再天然为空。
*
* 这条把 `parts.chip_type` 重新放回下面 ⑥ 的 `gateLedgers` 铺平了路:闸不会再拿
* 「零行」的旧理由误拦(那个前提不成立了),而选中的类型(新建的也好、已有的也好)
* 会随「数据工单确认入库」一起落进正式表 —— 不再留悬空引用。
*/
{
kind: 'project' as const,
into: 'parts.chip_type',
/*
* **不带 `prune`**(2026-08-30 第二处修正,e2e 案例 2 撞出来的第二个坑)——
* `parts.chip_type` 在 ⑦ 的 `gateLedgers` 上,人交回数据工单那一刻这张表就跟着
* 「数据工单确认即入库」落进正式表(`landsOnConfirm`);已落地的行 `draftDrop`
* (`prune` 的撤行机制)撤不掉,配方原样抛 `AutoRecipeError`(原文:「这一行是本单
* 在数据工单里建好、人确认后已入库的,撤不掉」)。案例 2(类型不一致·硬拦)第一次
* 提交时 LM2596S-5.0 还在 type_id=1、随首次确认落地;人接着把它改挂到 type_2 后,
* 这一笔重投只剩 type_2 一行,`prune:true` 想把已经落地的 type_1 那行撤掉 —— 直接炸。
* 去掉 `prune` 就不会再触发这条撤行:陈旧的选型历史留在草稿层不是错误(字典行本就
* 该积累、不该因为这一单后来改了主意就被追溯撤销),`parts.profile`/`parts.parameter`
* 那几笔**仍然** `prune:true`(它们是这一单自己的骨架,换料/换类型确实要清旧行,
* 且它们不会像字典这样跨料共享、也不会被单独确认入库)。
*/
from: {
each: 'mine:sub_eval.part',
then: { each: 'parts.chip_type', where: { type_id: { outer: 'chip_type_id' } } },
},
fields: {
type_id: { col: 'type_id' },
type_name: { col: 'type_name' },
description: { col: 'description' },
},
},
/*
* ③ **选中类型的核心参数字段清单,同一套道理原样投一遍**(2026-08-30,与 ② 同一次
* 发现、同一个技法):`parts.type_param` 只有人在件上「新建类型时加字段」才会写它 ——
* 挑一个已有类型时,这张表天然一行都没有,而它此刻仍在 ⑥ 的 `gateLedgers` 上,
* 「零行 = 没干完」的闸对这条路一样是误判(2026-08-30 真跑 e2e 五案例实测坐实:
* 「归到已有类型」的候选缺库 / 类型不一致 / 原PN缺库 / 两颗都缺库四例,全部卡死在
* 同一条「芯片类型核心参数字段 这一单还一行都没有」)。
*
* 修法与 ② 完全同构:按当下选中的 `chip_type_id` 把那个类型的核心参数字段清单
* 整套原样抄回自己身上(值没变,幂等,不产生多余写入)——**新建的类型也走这条路**:
* 人在件上新增的字段行本就已经在 `mine:parts.type_param` 里,这一笔重新投一遍
* 原样命中、照样幂等跳过,不会覆盖人刚填的内容。`prune:true` 清掉的只是「先前选了
* 某个类型、后来改选了另一个」时那些不再相关的陈旧行。
*/
{
kind: 'project' as const,
into: 'parts.type_param',
/* 同上一笔同一个理由,同一次修正 —— 见 ② 那笔头上的大注释,不重复贴 */
from: {
each: 'mine:sub_eval.part',
then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } },
},
fields: {
type_id: { col: 'type_id' },
param_name: { col: 'param_name' },
param_desc: { col: 'param_desc' },
constraint_type: { col: 'constraint_type' },
param_unit: { col: 'param_unit' },
},
},
/*
* ④ 主数据骨架:**两颗料都铺、值回填现值**。
* 在库那颗回填出它此刻的样子(人不必重填);要建那颗全空等人填。
* **「在库的老料还没归类型」于是也被拦住** —— 那一格回填出来是空的,而 `chip_type_id` 必填。
* 人不必在件上「加一行」:要建哪颗料是启动参数就定死的事,行由系统摆好,他只填格子。
@@ -300,7 +375,7 @@ export const ASSESSMENT_STEPS = [
},
},
/*
* ③ 参数骨架 —— 三件事一笔办完:
* ⑤ 参数骨架 —— 三件事一笔办完:
* · 人还没挑类型 → `{outer:'chip_type_id'}` 是空串,`where` 一行不命中,**什么也不铺**;
* 他挑了、按了保存,行就出来 —— **「保存即铺格」不是特判,是这一笔的自然结果**;
* · 在库那颗料**也**铺,值回填现值 → 屏上是它此刻的样子,`_missing` 不会误报;
@@ -339,15 +414,15 @@ export const ASSESSMENT_STEPS = [
},
},
/*
* ④ 案卷一行:`gate_reason` 是**摆在件上给人看**的那句话(条件判断另用基座读数,见 ⑤)。
* ⑥ 案卷一行:`gate_reason` 是**摆在件上给人看**的那句话(条件判断另用基座读数,见 ⑦)。
*
* **排在 ②③ 之后 —— 这是承重的次序,不是随手写的**(2026-08-29 评审 Important 修正):
* **排在 ④⑤ 之后 —— 这是承重的次序,不是随手写的**(2026-08-29 评审 Important 修正):
* `gate_reason` 的 when 链最深一层要读 `parts_profile_missing + parts_parameter_missing`,
* 而这两个数是 ②③ 刚铺出来的骨架撑出来的基座读数。这一笔若排在 ②③ **之前**
* 而这两个数是 ④⑤ 刚铺出来的骨架撑出来的基座读数。这一笔若排在 ④⑤ **之前**
* (原来的次序),它读到的是这一拍**刷新之前**的旧数:当某颗料的 `chip_type_id`
* 刚在这一拍被 ① 设上、②③ 即将把它的主数据与参数骨架铺出来时,那一刻 ③ 即将新建的
* 刚在这一拍被 ① 设上、④⑤ 即将把它的主数据与参数骨架铺出来时,那一刻 ⑤ 即将新建的
* 参数骨架行还不存在,`parts_parameter_missing` 读到 0(不存在的行不算「缺」),
* 片刻之后 ③ 一跑就变成 1。收步与发卡的把关本身没坏(⑤ 排在 ②③④ 之后,`card.when`
* 片刻之后 ⑤ 一跑就变成 1。收步与发卡的把关本身没坏(⑥ 排在 ④⑤ 之后,`card.when`
* 用的是刷新之后的真实读数,卡照样正确发出),但案卷里存的这句话、连同卡面
* `basis` 里「还差什么」那一格,会是一句空话 —— 人最需要一句解释的时候,读到的是空的。
* **每一笔之前刷读数**(auto-runner 的硬规矩)保证的是「下一笔看得见上一笔的结果」,
@@ -385,7 +460,7 @@ export const ASSESSMENT_STEPS = [
},
},
/*
* ⑤ 缺什么就派一张数据工单。
* ⑦ 缺什么就派一张数据工单。
* **条件只用基座读数**:`gate_reason` 为空串时那一列压根不写、于是未绑定,
* 而 S-FEEL 里未绑定的名字 `!= ""` 求值为 **true** —— 拿它当条件,恰恰在一切正常时把卡发出去。
*/
@@ -411,19 +486,22 @@ export const ASSESSMENT_STEPS = [
],
standard: '红带消失、提交钮变绿:两颗料都在库、归了同一个类型、每个核心参数字段都有值。',
/*
* **`parts.chip_type` 永久不列在这里** —— 不是等 Task 7b 补了「新建类型」那张编辑表
* 就该加回来(2026-08-29 评审逮到的 Critical:这张件此刻没有任何 `data-edit`
* 能写 `parts.chip_type`,列了它,`mine:parts.chip_type` 永远是空数组,
* `desk.ts` 的 `emptyGate` 就永远判「这一单还一行都没有 —— 这张卡就是来建它的」,
* 提交钮当场且永久按不下去,三张表填得再全也一样)。
* 更深一层:即便 Task 7b 真的补上了那张编辑表,也不该把它放回来 —— `emptyGate`
* 的语义是「这张卡**就是来建**这张表的,一行都没有 = 没干活」,而
* `parts.chip_type` 的主路是**从下拉挑一个已有类型**,那时 `mine:parts.chip_type`
* 天然就是零行(没新建、没问题);只有走「新建类型」那条岔路时才会写它。
* 一张**只是有时候才写**的表,套「零行 = 没干完」这条闸的前提本身就不成立 ——
* 该拦的是 `parts.type_param`(核心参数字段清单,新建类型时**一定**要写)。
* **`parts.chip_type` 与 `parts.type_param` 都在闸上**(2026-08-30 改,Ruling H35)——
* 从前 `parts.chip_type` 被永久排除在外,理由是「它没有编辑手段,`mine:` 天然为空,
* 列了就永久卡死」;那条理由**从 ②③ 两笔自我投影加上之后就不再成立**——
* `mine:parts.chip_type`/`mine:parts.type_param` 现在恒不为空(只要 `sub_eval.part`
* 里已经选定了类型),闸不会再误判「一行都没有」。
*
* **既然要落地就必须在名单里,这不是碰运气**:「数据工单确认即入库」把 `gateLedgers`
* 列出的表整批并入正式表;`parts.chip_type` 若不在这份名单里,人新建的类型字典行
* 就只留在草稿层,而引用它的 `parts.profile`/`parts.type_param` 却随名单一起落地 ——
* 落地那一刻起,正式表里就有一行指着字典里查无此号的悬空引用(2026-08-30 真跑
* e2e 撞见的平台侧连带发现:范围放行没有对 `refersTo` 做传递闭包,并一张表却不
* 并它引用的表,必然留下悬空引用;那条更普遍的平台缺陷已记 `docs/debt.md`,
* 这里只在活动自己的声明层堵住这一处具体触发点)。四张表都在闸上、也都会一起
* 落地,才是「选类型」这件事完整、闭环的样子。
*/
gateLedgers: ['parts.type_param', 'parts.profile', 'parts.parameter'],
gateLedgers: ['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'],
},
{ kind: 'say' as const, text: '已核对两颗料的在库状态与参数齐备情况。' },
],
@@ -507,7 +585,27 @@ export const ASSESSMENT_STEPS = [
},
},
/*
* ② 逐条明细 —— **行由字典 `each` 出来**,AI 的出参只是拿 `param_name` 去 `lookup` 的一张查表。
* ②→④ **`RELATION_PROJECTION` 写两遍,中间夹着差异明细** —— 不是笔误
* (2026-08-30,真跑 e2e 第一次撞见的循环依赖,`docs/ACT-WORK-05-端到端测试记录.md`
* 追加记录):`substitution.difference_item.substitution_relation_id` 声明了
* `refersTo: 'substitution.relation.substitution_relation_id'`(要求引用的那一行
* 已经真实存在),而 `substitution.relation` 的 `recommendation_level`/`risk_level`/
* `difference_summary`/`recommendation_reason` 这几列**要读差异明细的
* `judgement_class` 分布才算得出来** —— 两张表互相要对方先写。
* 单趟写不出这个顺序:关系表写在明细表前面,`refersTo` 就拦住明细表(「要指的那一行
* 还不存在」,亲测的报错原文);写在后面,关系表自己先炸(明细表还没有,`refersTo`
* 挡在关系表自己那两列 `original_internal_pn`/`candidate_internal_pn` 上倒是不会,
* 但那几个必填的计算列无从算起)。
* **第一遍**(紧跟在 `analyze` 后面)只为把关系表那一行**先摆出来**满足 `refersTo`——
* 这一刻明细表还没有一行,所有 `substitution_difference_item_judgement_class_*`
* 读数都是基座铺的 0,`when` 链逐级落到默认分支(`recommendation_level` 落
* 「recommended」),字段值不对但每一列都写得出、不会报错。**第二遍**(紧跟在明细表
* 后面)用这一刻已经真实存在的明细行重算一遍,把第一遍的占位值覆盖成真值 ——
* 与 S3 每次人改了四态就要重算关系表,是**同一套幂等重算机制**,不是新发明的招式。
*/
RELATION_PROJECTION,
/*
* 逐条明细 —— **行由字典 `each` 出来**,AI 的出参只是拿 `param_name` 去 `lookup` 的一张查表。
* 它漏一条,那一行的四态就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住。
* 从前是 `from each(AI 出参)`:它少给两条就少两行,**而每一行看着都合规**。
*/
@@ -54,7 +54,7 @@ export const manifest: ActivityManifest = {
activityId: ACTIVITY_ID,
name: 'ACT-WORK-05 · 差异评估',
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
packageVersion: '2.1.1',
packageVersion: '2.1.5',
category: '需求分析与方案匹配',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-part-substitution-assessment',
@@ -179,8 +179,39 @@ export const manifest: ActivityManifest = {
level: 'L2',
predicate: 'validation_record_rows = 1 and validation_record_missing = 0',
},
{ key: 'evidence-credible', label: '证据链可信:每条差异的影响说明与判断依据站得住', level: 'L4' },
{ key: 'values-traceable', label: '参数值与原厂资料对得上:候选与原 PN 的取值来源可追溯', level: 'L4' },
/*
* **两条 L4 补了谓词**(2026-08-30,真跑 e2e 撞见的结构性发现,记入
* `docs/ACT-WORK-05-端到端测试记录.md`)——「裁量项带谓词时谓词说了算」这条纪律
* (2026-08-02 立,`engines.ts` 的 `criterionCheck`:`settledByData`)本仓已有先例,
* 从前 W05 这两条 L4 是裸的(无 `predicate`),而 T_release 全程 `auto` 驱动、
* `submit` 是五个声明式动词之一(`packages/contracts/src/auto-recipe.ts`),
* **没有第六个 `judge` 动词** —— 裁量项没谓词就只能等 `judgement`(Agent 的 `judge`
* 行动)来结算,而这个活动压根不会有 Agent 走到这一步。结果是 `submittable()`
* 对这两条永远读到 `pass:false`,`T_release` 的 `submit` 每一拍都被
* `raiseAcceptance` 拒,而拒绝不算错误(不触发 `failedAt`)、下一拍原样重来 ——
* 稳定地把步数预算耗到 `exhausted`,而不是报错崩溃,所以静态审计与包内自测都没照见它。
*
* 两条谓词只覆盖**客观、基座算得出来的那一半**(有没有值、值哪来的),
* **主观那一半(写得好不好、可信不可信)已经由人工验收清单接住**——S3 审核卡
* 第 1/4 条「逐条对过原料与候选料的参数值…」「影响说明站得住…」正是这两条 L4
* 标签里「站得住」「对得上」那两个字的着落点,人签字通过那一刻已经确认过。
* 谓词为真只说「客观前提具备」,不越权替人下「写得好」这个主观判断。
*/
{
key: 'evidence-credible',
label: '证据链可信:每条差异的影响说明与判断依据站得住',
level: 'L4',
/* 客观前提:每一行都有值(必填不缺)——写得「站不站得住」是 S3 人工验收清单的事 */
predicate: 'substitution_difference_item_missing = 0',
},
{
key: 'values-traceable',
label: '参数值与原厂资料对得上:候选与原 PN 的取值来源可追溯',
level: 'L4',
/* 客观前提:原始参数集不缺、差异明细里逐条的原值候选值也不缺 —— 两值均来自
parts.parameter 的 lookup,不是模型现编;「对不对得上」是 S3 人工验收清单的事 */
predicate: 'parts_parameter_missing = 0 and substitution_difference_item_missing = 0',
},
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'gate03-release', label: 'ACT-GATE-03 评审放行:签下去 P-11 落账、P-06 落账,结论供下游消费', level: 'L5' },
],