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:
xcs
2026-08-30 07:05:48 +08:00
co-authored by Claude Opus 5
parent 67fa226380
commit 6636abfc39
10 changed files with 840 additions and 6 deletions
@@ -0,0 +1,121 @@
/**
* 配方字段求值序 —— `orderFields` 的单元测试。
*
* 背景见 `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`:
* `fields: {...}` 经一趟 jsonb 存取后键序不再保证,`auto-runner` 逐字段求值本依赖
* 「声明序 = 依赖序」这条隐藏假设。这里直接测 `orderFields` 本身(端到端的
* 「模拟 jsonb 重排 + 真跑 auto-runner」回归测试在 `auto-runner.test.ts`)。
*/
import { describe, expect, it } from 'vitest';
import type { AutoExpr } from '@contracts';
import { AutoFieldCycleError, orderFields } from '../internal/domain/auto-order.ts';
/** 只看排出来的字段名序列,不看表达式本身 */
const names = (fields: Readonly<Record<string, AutoExpr>>) => orderFields(fields).map(([name]) => name);
describe('orderFields', () => {
it('没有 when 的字段:谁先谁后都行,数量对得上就好', () => {
const fields: Record<string, AutoExpr> = { b: { col: 'x' }, a: 'literal', c: { in: 'y' } };
expect(names(fields).sort()).toEqual(['a', 'b', 'c']);
});
it('B 依赖 A(when 里提到 A 的名字):A 排到 B 前面,不管声明序', () => {
// 声明序故意把依赖方 b 摆在被依赖方 a 前面 —— 就是 jsonb 重排会制造出的形状
const fields: Record<string, AutoExpr> = {
b: { when: 'a = "x"', then: 'hit', else: 'miss' },
a: 'x',
};
const order = names(fields);
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
});
/* 逐字照抄 activities/part-substitution-assessment 的 S2_assess 那笔真实配方的依赖形状:
difference_status(叶子)→ judgement_class(依赖 difference_status)→ risk_level(依赖 judgement_class) */
it('三级链:C 依赖 B、B 依赖 A —— 排出 A、B、C,不管输入是哪种乱序', () => {
const chain: Record<string, AutoExpr> = {
/* A:不依赖同笔任何字段 */
difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
/* B:when 链条里提到 A */
judgement_class: {
when: 'difference_status = "match"',
then: 'match',
else: { when: 'difference_status = "unknown"', then: 'unknown', else: 'mismatch' },
},
/* C:when 里提到 B */
risk_level: { when: 'judgement_class = "match"', then: 'low', else: 'high' },
};
// 用「先短后长、同长字典序」的 jsonb 重排顺序喂进去(与生产实测的重排结果同构)
const jsonbLike: Record<string, AutoExpr> = {};
for (const key of ['risk_level', 'judgement_class', 'difference_status']) jsonbLike[key] = chain[key] as AutoExpr;
const order = names(jsonbLike);
expect(order.indexOf('difference_status')).toBeLessThan(order.indexOf('judgement_class'));
expect(order.indexOf('judgement_class')).toBeLessThan(order.indexOf('risk_level'));
});
it('依赖藏在 lookup.by / else 里也认得出', () => {
const fields: Record<string, AutoExpr> = {
target: { when: 'source = "x"', then: 'hit', else: 'miss' },
source: { lookup: 'result:items', by: { k: 'v' }, take: 't', else: { when: 'flag = "y"', then: 'a', else: 'b' } },
flag: 'y',
};
const order = names(fields);
expect(order.indexOf('source')).toBeLessThan(order.indexOf('target'));
expect(order.indexOf('flag')).toBeLessThan(order.indexOf('source'));
});
it('依赖藏在 cat / fmt 里也认得出', () => {
const fields: Record<string, AutoExpr> = {
label: { cat: ['前缀-', { when: 'base != ""', then: { fmt: { in: 'x' }, as: 'digits' }, else: 'x' }] },
base: '5',
};
const order = names(fields);
expect(order.indexOf('base')).toBeLessThan(order.indexOf('label'));
});
it('只认同一个 fields 对象内的名字 —— 引用台账列名 / 基座读数这类全局名字不算依赖,不参与排序', () => {
const fields: Record<string, AutoExpr> = {
a: { when: 'some_ledger_column = "x"', then: '1', else: '0' },
b: { when: 'sub_eval_part_rows > 0', then: '1', else: '0' },
};
// 两个字段互不相干,不该抛错,数量对得上即可
expect(names(fields).sort()).toEqual(['a', 'b']);
});
it('真正的环(A 依赖 B、B 依赖 A):抛 AutoFieldCycleError,不默默给错答案', () => {
const fields: Record<string, AutoExpr> = {
a: { when: 'b = "x"', then: '1', else: '0' },
b: { when: 'a = "x"', then: '1', else: '0' },
};
expect(() => orderFields(fields)).toThrow(AutoFieldCycleError);
try {
orderFields(fields);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(AutoFieldCycleError);
expect([...(err as AutoFieldCycleError).cycle].sort()).toEqual(['a', 'b']);
expect((err as Error).message).toContain('循环依赖');
}
});
/*
* **自引用不算环,故意的**:字段 `a` 的 `when` 提到 `a` 时,那个名字未必是「我自己算出来的值」——
* `evalExpr` 的合并作用域里 `row`(源行本身的列)也在,常见写法是「拿源行同名列做校验后原样投影」
* (如 `status: { when: 'status = "active"', then: 'A', else: 'B' }`,这里的 `status` 指的是
* `ctx.row.status`)。把自引用当环拦下会把这种常见写法误判成装不上的坏配方 ——
* 排序对它也没有意义(不管排在哪一位,求值那一刻自己的 `computed` 值必然还没写进去,
* 这是求值时序的必然,不是「顺序没排对」)。
*/
it('自己依赖自己(when 里提到自己的名字)不算环 —— 常见写法是拿源行同名列做校验', () => {
const fields: Record<string, AutoExpr> = { a: { when: 'a = "x"', then: '1', else: '0' } };
expect(() => orderFields(fields)).not.toThrow();
expect(names(fields)).toEqual(['a']);
});
it('返回的 [name, expr] 就是原表达式(调用方拿它当 Object.entries 的替代品)', () => {
const expr: AutoExpr = { col: 'pn' };
const out = orderFields({ only: expr });
expect(out).toEqual([['only', expr]]);
});
});
@@ -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);
}
});
});