Archived
fix(auto): 配方字段按依赖拓扑求值 —— jsonb 会重排对象键,声明顺序靠不住
真跑 e2e 逼出来的地基缺陷。活动清单存在 sys.activity_package.manifest 这个 **jsonb 列**里,而 PG 按「先短后长、同长字典序」重排所有嵌套对象的键。 auto-runner 原先逐字段**按声明顺序**求值、后面的字段靠 ctx.computed 看前面的值 —— 顺序一乱,依赖就断,而且**一声不响**。 W05 实证:judgement_class(15B)被排到它依赖的 difference_status(17B)前面, 求值时后者恒 undefined、when 三级全落空,摔进 constraint_type 兜底档 —— 7 行差异结论全判错,与实际状态无关;而 difference_status 走 lookup:'result:items' 不依赖字段序,所以它是对的。「一个对一个全错」由此而来。 隔离测试复现不出来,因为它直接读 dist/manifest.json,从不经 DB 往返。 修法:字段求值改为**依赖拓扑排序**(Kahn),不用迭代到不动点 —— 后者的数值收敛判据会漏掉「A/B 互相 when 为假、恰好在错误状态下稳定」这种 结构有环却数值收敛的配方;拓扑是结构性判定,不会假阴性。 通用图算法 topoOrder 提进 @utils(契约新增,非破坏); AutoExpr→依赖名提取因 L4 不能 import L5,在 loop-engine 与 activity-registry 各留一份并互相指路。真正的环由装包静态审当场拒装 —— 不兜底,兜底正是这次出事的形态。 变异验证:git stash 只撤 auto-runner.ts 那一处,两条新测试当场变红, 数值与专查报告的 SQL 转录逐字吻合(使能阈值/match 被误判 hard_mismatch/medium); stash pop 后 37/37 绿。E04 92/92、I01 55/55 全绿;全仓 2447 测通过。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AutoRecipe } from '@contracts';
|
||||
import type { AutoExpr, AutoRecipe } from '@contracts';
|
||||
import { taskIdSchema, staffIdSchema } from '@contracts';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import { deriveSide, structuredLog } from '@modules/memory';
|
||||
import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from '@modules/memory';
|
||||
import { createAutoRunner } from '../internal/usecase/auto-runner.ts';
|
||||
@@ -735,3 +736,245 @@ describe('prune:人在填的时候不撤行(F3)', () => {
|
||||
expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **回归:jsonb 打乱 `fields` 键序,`project` 的求值结果必须与原声明序一致**(2026-08-30)。
|
||||
*
|
||||
* 背景(专查报告 `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`):
|
||||
* `AutoAction.fields` 存进 `sys.activity_package.manifest`(`jsonb` 列)之后,PostgreSQL
|
||||
* 按「先短后长、同长字典序」重排对象键 —— `judgement_class` 依赖同笔内先算出来的
|
||||
* `difference_status`,一旦被排到它前面,`ctx.computed.difference_status` 求值时恒为
|
||||
* `undefined`,三级 `when` 链全部落空,摔进 `constraint_type` 兜底档
|
||||
* (`hard_mismatch`/`soft_mismatch`)—— **与这一行真实的差异状态毫无关系**;
|
||||
* `risk_level` 依赖 `judgement_class`,是同一套机制的第二层级联。
|
||||
*
|
||||
* 下面 `DIFFERENCE_ITEM_FIELDS` **逐字段照抄**
|
||||
* `activities/part-substitution-assessment/src/case-model.ts`(2026-08-30 一份)里
|
||||
* `S2_assess` 步「project into substitution.difference_item」那一笔的真实配方 ——
|
||||
* `apps/**` 与 `activities/**` 互不静态 import(编码规范 §2.1),depcruise 会拦真 import,
|
||||
* 只能手抄;`from`/`prune` 为测试自洽而简化,不影响所测的字段求值序问题。
|
||||
* 主键(`substitution_relation_id`+`difference_item_id`)与 `judgement_class`/`risk_level`
|
||||
* 两列的 tally 取值抄自同一活动包的 `manifest.ts` 声明。
|
||||
*/
|
||||
describe('回归 · jsonb 打乱 fields 键序,project 求值仍要与原序一致(2026-08-30)', () => {
|
||||
/** 与 `case-model.ts` 里的 `judgement_class`/`risk_level` 一字不差 —— 三级 when 链依赖同笔内的 difference_status,risk_level 依赖同笔内的 judgement_class */
|
||||
const DIFFERENCE_ITEM_FIELDS: Record<string, AutoExpr> = {
|
||||
substitution_relation_id: { in: 'relationId' },
|
||||
difference_item_id: { cat: [{ in: 'relationId' }, '-', { col: 'param_name' }] },
|
||||
parameter_name: { col: 'param_name' },
|
||||
parameter_desc: { col: 'param_desc' },
|
||||
parameter_unit: { col: 'param_unit' },
|
||||
constraint_type: { col: 'constraint_type' },
|
||||
original_value: {
|
||||
lookup: 'parts.parameter',
|
||||
by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } },
|
||||
take: 'param_value',
|
||||
else: '',
|
||||
},
|
||||
candidate_value: {
|
||||
lookup: 'parts.parameter',
|
||||
by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } },
|
||||
take: 'param_value',
|
||||
else: '',
|
||||
},
|
||||
difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
|
||||
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 分成硬 / 软 —— 依赖同笔的 difference_status */
|
||||
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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
/* 依赖同笔的 judgement_class */
|
||||
risk_level: {
|
||||
when: 'judgement_class = "hard_mismatch"',
|
||||
then: 'high',
|
||||
else: { when: 'judgement_class = "match"', then: 'low', else: 'medium' },
|
||||
},
|
||||
rule_version: 'RULE-V1',
|
||||
};
|
||||
|
||||
/** PostgreSQL jsonb 对对象键的重排规则:先按字节长度升序,同长按字典序 —— 下一条用例直接核对
|
||||
* 这份模拟与专查报告里从库现查出来的真实顺序逐字节一致(不是「差不多像」,是同一个函数)。*/
|
||||
function reorderLikeJsonb<T>(fields: Readonly<Record<string, T>>): Record<string, T> {
|
||||
const keys = Object.keys(fields).sort((a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0));
|
||||
const out: Record<string, T> = {};
|
||||
for (const key of keys) out[key] = fields[key] as T;
|
||||
return out;
|
||||
}
|
||||
|
||||
it('(加分)reorderLikeJsonb 复现的顺序与生产实测(报告从库里现查出来的那一串)逐字一致', () => {
|
||||
expect(Object.keys(reorderLikeJsonb(DIFFERENCE_ITEM_FIELDS))).toEqual([
|
||||
'risk_level',
|
||||
'rule_version',
|
||||
'evidence_note',
|
||||
'original_value',
|
||||
'parameter_desc',
|
||||
'parameter_name',
|
||||
'parameter_unit',
|
||||
'candidate_value',
|
||||
'constraint_type',
|
||||
'judgement_class',
|
||||
'impact_statement',
|
||||
'difference_status',
|
||||
'difference_item_id',
|
||||
'unknown_disposition',
|
||||
'substitution_relation_id',
|
||||
]);
|
||||
});
|
||||
|
||||
/** 七行真实数据 + SQL 转录,来自专查报告「最终落库数据」那一节(6 match + 1 conditional) */
|
||||
const REAL_PARAM_ROWS: Array<Record<string, string>> = [
|
||||
{ param_name: '使能阈值', param_desc: '欠压保护阈值', param_unit: 'V', constraint_type: 'hard' },
|
||||
{ param_name: '工作温度', param_desc: '结温范围', param_unit: '℃', constraint_type: 'hard' },
|
||||
{ param_name: '开关频率', param_desc: '振荡频率', param_unit: 'kHz', constraint_type: 'soft' },
|
||||
{ param_name: '输入电压范围', param_desc: '输入电压范围', param_unit: 'V', constraint_type: 'hard' },
|
||||
{ param_name: '输出电压', param_desc: '输出电压', param_unit: 'V', constraint_type: 'hard' },
|
||||
{ param_name: '输出电流', param_desc: '最大输出电流', param_unit: 'A', constraint_type: 'hard' },
|
||||
{ param_name: '静态电流', param_desc: '静态电流', param_unit: 'µA', constraint_type: 'soft' },
|
||||
];
|
||||
/** 补三行合成数据 —— 七行真实数据全落在 match/conditional,覆盖不到 unknown 与 mismatch 两支 */
|
||||
const SYNTHETIC_PARAM_ROWS: Array<Record<string, string>> = [
|
||||
{ param_name: 'test_unknown_param', param_desc: '(补充覆盖 unknown 分支)', param_unit: '', constraint_type: 'soft' },
|
||||
{ param_name: 'test_hard_mismatch_param', param_desc: '(补充覆盖 hard_mismatch 分支)', param_unit: '', constraint_type: 'hard' },
|
||||
{ param_name: 'test_soft_mismatch_param', param_desc: '(补充覆盖 soft_mismatch 分支)', param_unit: '', constraint_type: 'soft' },
|
||||
];
|
||||
const PARAM_ROWS = [...REAL_PARAM_ROWS, ...SYNTHETIC_PARAM_ROWS];
|
||||
|
||||
/** 对应的 AI 出参(`result:items`)—— 七行抄自专查报告的落库数据,三行合成的覆盖剩余分支 */
|
||||
const RESULT_ITEMS = [
|
||||
{ param_name: '使能阈值', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' },
|
||||
{ param_name: '工作温度', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' },
|
||||
{ param_name: '开关频率', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' },
|
||||
{ param_name: '输入电压范围', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' },
|
||||
{ param_name: '输出电压', difference_status: 'conditional', impact_statement: '需外围电路验证', evidence_note: '', unknown_disposition: '' },
|
||||
{ param_name: '输出电流', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' },
|
||||
{ param_name: '静态电流', difference_status: 'match', impact_statement: '一致', evidence_note: '', unknown_disposition: '' },
|
||||
{ param_name: 'test_unknown_param', difference_status: 'unknown', impact_statement: '', evidence_note: '', unknown_disposition: '待实测' },
|
||||
{ param_name: 'test_hard_mismatch_param', difference_status: 'mismatch', impact_statement: '不满足硬约束', evidence_note: '', unknown_disposition: '' },
|
||||
{ param_name: 'test_soft_mismatch_param', difference_status: 'mismatch', impact_statement: '不满足软约束', evidence_note: '', unknown_disposition: '' },
|
||||
];
|
||||
|
||||
/*
|
||||
* 正确答案(不是猜的,是照 `judgement_class`/`risk_level` 的 when 链手算的):
|
||||
* difference_status=match → judgement_class=match → risk_level=low
|
||||
* difference_status=conditional → judgement_class=conditional → risk_level=medium(两条 when 都不中)
|
||||
* difference_status=unknown → judgement_class=unknown → risk_level=medium
|
||||
* difference_status=mismatch 且 constraint_type=hard → judgement_class=hard_mismatch → risk_level=high
|
||||
* difference_status=mismatch 且 constraint_type=soft → judgement_class=soft_mismatch → risk_level=medium
|
||||
*/
|
||||
const EXPECTED: Record<string, { judgement_class: string; risk_level: string }> = {
|
||||
使能阈值: { judgement_class: 'match', risk_level: 'low' },
|
||||
工作温度: { judgement_class: 'match', risk_level: 'low' },
|
||||
开关频率: { judgement_class: 'match', risk_level: 'low' },
|
||||
输入电压范围: { judgement_class: 'match', risk_level: 'low' },
|
||||
输出电压: { judgement_class: 'conditional', risk_level: 'medium' },
|
||||
输出电流: { judgement_class: 'match', risk_level: 'low' },
|
||||
静态电流: { judgement_class: 'match', risk_level: 'low' },
|
||||
test_unknown_param: { judgement_class: 'unknown', risk_level: 'medium' },
|
||||
test_hard_mismatch_param: { judgement_class: 'hard_mismatch', risk_level: 'high' },
|
||||
test_soft_mismatch_param: { judgement_class: 'soft_mismatch', risk_level: 'medium' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 专用的一次性 harness:**真 `createAutoRunner`**,`sfeel.condition` 接的是**真**
|
||||
* `@sfeel` 的 `evaluateCondition`(不是本文件顶部那个只认三句话的桩)——
|
||||
* `judgement_class`/`risk_level` 的三级 `when` 链要真求值,不能假装。
|
||||
*/
|
||||
function makeHarness(seed: Record<string, unknown>) {
|
||||
const drafts = new Map<string, { key: Record<string, string>; patch: Record<string, unknown> }>();
|
||||
const deps = {
|
||||
data: {
|
||||
view: () => Promise.resolve({ ...seed }),
|
||||
writable: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
domain: 'substitution',
|
||||
object: 'difference_item',
|
||||
label: '差异明细',
|
||||
keys: ['substitution_relation_id', 'difference_item_id'],
|
||||
fields: [],
|
||||
},
|
||||
]),
|
||||
draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }) => {
|
||||
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||
drafts.set(id, { key: i.key, patch: i.patch });
|
||||
return Promise.resolve({ changed: true });
|
||||
},
|
||||
draftDrop: () => Promise.resolve({ dropped: false }),
|
||||
},
|
||||
cards: {
|
||||
issue: () => Promise.reject(new Error('本用例不该发卡')),
|
||||
decide: () => Promise.reject(new Error('本用例不该发卡')),
|
||||
entry: () => Promise.reject(new Error('本用例不该发卡')),
|
||||
ask: () => Promise.reject(new Error('本用例不该发卡')),
|
||||
unanswered: () => Promise.resolve([]),
|
||||
},
|
||||
memory: { append: () => Promise.resolve() },
|
||||
analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze —— 用 seed 直接铺 result:items,不经 analyze 那一笔')) },
|
||||
sfeel: {
|
||||
condition: (expr: string, scope: Record<string, unknown>) => evaluateCondition(expr, scope),
|
||||
expression: () => undefined,
|
||||
unaryTest: () => false,
|
||||
},
|
||||
};
|
||||
const ctx = { taskId: TASK, stepId: 'S2_assess', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
|
||||
// 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 测试文件里 `as never` 是既有做法
|
||||
return { runner: createAutoRunner(deps as never), drafts, ctx };
|
||||
}
|
||||
|
||||
const recipeOf = (fields: Record<string, AutoExpr>): AutoRecipe => ({
|
||||
do: [{ kind: 'project', into: 'substitution.difference_item', from: { rows: PARAM_ROWS }, fields }],
|
||||
});
|
||||
|
||||
it('project 求出的每一行、每一个字段,声明序与打乱成 jsonb 重排序**逐字段完全相同**', async () => {
|
||||
const seed = { relationId: 'REL-1', 'result:items': RESULT_ITEMS };
|
||||
|
||||
const declared = makeHarness(seed);
|
||||
const declaredOut = await declared.runner.run(recipeOf(DIFFERENCE_ITEM_FIELDS), declared.ctx);
|
||||
|
||||
const shuffled = makeHarness(seed);
|
||||
const shuffledOut = await shuffled.runner.run(recipeOf(reorderLikeJsonb(DIFFERENCE_ITEM_FIELDS)), shuffled.ctx);
|
||||
|
||||
expect(declaredOut.failedAt).toBeUndefined();
|
||||
expect(shuffledOut.failedAt).toBeUndefined();
|
||||
|
||||
const rowsByPn = (h: ReturnType<typeof makeHarness>) =>
|
||||
new Map([...h.drafts.values()].map((d) => [String(d.patch['parameter_name']), d.patch]));
|
||||
const a = rowsByPn(declared);
|
||||
const b = rowsByPn(shuffled);
|
||||
|
||||
expect(a.size).toBe(PARAM_ROWS.length);
|
||||
expect(b.size).toBe(PARAM_ROWS.length);
|
||||
for (const [pn, patchFromDeclaredOrder] of a) {
|
||||
expect(b.get(pn)).toEqual(patchFromDeclaredOrder);
|
||||
}
|
||||
});
|
||||
|
||||
it('且求出来的是对的:四态照 difference_status 走,不是清一色摔进 hard_mismatch(bug 复现的具体形状)', async () => {
|
||||
const seed = { relationId: 'REL-1', 'result:items': RESULT_ITEMS };
|
||||
const shuffled = makeHarness(seed);
|
||||
await shuffled.runner.run(recipeOf(reorderLikeJsonb(DIFFERENCE_ITEM_FIELDS)), shuffled.ctx);
|
||||
|
||||
const rowsByPn = new Map([...shuffled.drafts.values()].map((d) => [String(d.patch['parameter_name']), d.patch]));
|
||||
for (const [pn, expected] of Object.entries(EXPECTED)) {
|
||||
const patch = rowsByPn.get(pn);
|
||||
expect(patch, `${pn} 这一行没写下来`).toBeDefined();
|
||||
expect(patch?.['difference_status'], `${pn} 的 difference_status`).toBe(
|
||||
RESULT_ITEMS.find((r) => r.param_name === pn)?.difference_status,
|
||||
);
|
||||
expect(patch?.['judgement_class'], `${pn} 的 judgement_class`).toBe(expected.judgement_class);
|
||||
expect(patch?.['risk_level'], `${pn} 的 risk_level`).toBe(expected.risk_level);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user