#!/usr/bin/env node /** * 审计「手工要补哪些字段」的差集(**按真实形状实测**,不按分支标签枚举)。 * * ## 为什么需要它 * * 判断差集的方法(pi 提出、dsh 机械枚举过)是: * * 手工要补的 = 校验器要求 − 迁移器自愈 * * 但「迁移器自愈」有两层,只读**分支标签**会算错: * * 1. `normalizeLegacyMessage()` 里有那个 `case`(分支存在) * 2. 那个 `case` 的**守卫**对**你手上的形状**放行(分支真的会执行) * * 第 2 层才是决定性的。实测:`assistant/message`、`tool/result` 都有 heal 分支, * 但它们的守卫是 * * if (Object.hasOwn(data,"message") || …) return event; // 有 message 键 ⇒ 早退 * * 而真实 v0 里这两个事件**都是嵌套 `message` 形状**(109 个文件里扁平旧形状出现 **0** 次) * ⇒ 守卫必命中 ⇒ **不会 heal**。所以按分支标签算出来的差集是漏的。 * * ## 本脚本的做法 * * 不看代码、不看标签,直接对**真实 v0** 做消融实验: * 把某个位置的消息剥成「无 id」形状,跑官方迁移链,看是否被拒绝。 * **拒绝 ⇒ 该位置在差集里**(校验要 id、迁移器不给)—— 这正是"要不要手工补"的定义。 * * 对照组(脚本自己跑,缺一不可): * - 不动任何东西 ⇒ 必须 OK(证明方法不误报) * - `user/message` 剥空 ⇒ 必须 OK(证明确有自愈,不是"什么都拒绝") * * 用法: * node scripts/audit-v0-id-diffset.mjs # 默认扫 /root/.dsh/sessions * node scripts/audit-v0-id-diffset.mjs --root * * 退出码:0 = 扫描完成(**不**因发现差集成员而失败,那是正常结论);2 = 环境/参数问题。 */ import { execFileSync } from 'node:child_process'; import { readdirSync, statSync, mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; const DS = process.env.DSH_INSTALL ?? '/usr/lib/node_modules/@deepseek-ai/dsh'; const { sessionFormatCatalog } = await import( join(DS, 'node_modules/@deepseek-ai/dsh-session-format-catalog/lib/index.js') ); const argv = process.argv.slice(2); const argOf = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; }; const ROOT = argOf('--root', '/root/.dsh/sessions'); const PROD = { recovery: 'recoverable', validation: 'transformed' }; const clone = (o) => structuredClone(o); const readLines = (p) => execFileSync('zstd', ['-dc', p], { maxBuffer: 1 << 30 }).toString('utf8') .split('\n').filter((l) => l.trim().length); /** 生产档迁移:能过就是「无需手工补」。 */ function readable(header, events) { try { const r = sessionFormatCatalog.createRestore(clone(header), PROD); for (const e of clone(events)) r.decodeRow(e); r.finish(); return true; } catch { return false; } } /** * 每个「message 承载位置」:匹配函数 + 消融函数(把该位置剥成无 id/role 形状)。 * 位置按**真实 v0 形状**给,不是按代码分支名。 */ const SITES = { 'agent/inbox/spliced :: inserted[]': { match: (e) => e.type === 'agent/inbox/spliced' && e.data.inserted?.[0] && 'id' in e.data.inserted[0], ablate: (d) => { delete d.inserted[0].id; delete d.inserted[0].role; }, }, 'session/title-llm-request :: messages[]': { match: (e) => e.type === 'session/title-llm-request' && e.data.messages?.[0] && 'id' in e.data.messages[0], ablate: (d) => { delete d.messages[0].id; delete d.messages[0].role; }, }, 'assistant/message :: data.message': { match: (e) => e.type === 'assistant/message' && e.data.message && 'id' in e.data.message, ablate: (d) => { delete d.message.id; delete d.message.role; }, }, 'tool/result :: data.message': { match: (e) => e.type === 'tool/result' && e.data.message && 'id' in e.data.message, ablate: (d) => { delete d.message.id; delete d.message.role; }, }, 'user/message :: data': { match: (e) => e.type === 'user/message', ablate: (d) => { delete d.id; delete d.role; }, }, }; /** 递归收集 //session.jsonl.zstd */ function* walk(dir) { for (const n of readdirSync(dir)) { const p = join(dir, n); if (statSync(p).isDirectory()) yield* walk(p); else if (n === 'session.jsonl.zstd') yield p; } } const stat = {}; for (const k of Object.keys(SITES)) stat[k] = { tested: 0, refused: 0 }; let files = 0, skipped = 0, ctrlNoAb = 0, ctrlUserHealed = 0; for (const path of walk(ROOT)) { let lines, header; try { lines = readLines(path); header = JSON.parse(lines[0]); } catch { skipped++; continue; } if (header.version !== 0) { skipped++; continue; } files++; let events = lines.slice(1).map((l) => JSON.parse(l)); // 基线:先补掉 spliced(否则基线就不可读,测不出别的位置) let n = 0; events = events.map((e) => { if (e.type !== 'agent/inbox/spliced') return e; const inserted = (e.data.inserted ?? []).map((m) => { if ('id' in m && 'role' in m) return m; n++; return { id: `baseline-${e.seq}-${n}`, role: 'user', ...m }; }); return { ...e, data: { ...e.data, inserted } }; }); if (!readable(header, events)) { skipped++; continue; } ctrlNoAb++; // 对照一:不动任何东西 ⇒ 可读 // 对照二:user/message 剥空必须仍可读(证明确有自愈) { const i = events.findIndex(SITES['user/message :: data'].match); if (i >= 0) { const e2 = clone(events); SITES['user/message :: data'].ablate(e2[i].data); if (readable(header, e2)) ctrlUserHealed++; } } for (const [label, { match, ablate }] of Object.entries(SITES)) { const i = events.findIndex(match); if (i < 0) continue; const e2 = clone(events); ablate(e2[i].data); stat[label].tested++; if (!readable(header, e2)) stat[label].refused++; } } console.log(`root: ${ROOT}`); console.log(`v0 文件: ${files}(跳过 ${skipped}:解压失败/非 v0/基线不可读)\n`); console.log('=== 对照组(方法有效性)==='); console.log(` 不动任何东西 ⇒ 可读的基线: ${ctrlNoAb}`); console.log(` user/message 剥 id+role 后仍可读(= 确有自愈): ${ctrlUserHealed}`); if (ctrlNoAb === 0) { console.error('\n[FAIL] 没有可读基线,结论无效'); process.exit(2); } if (ctrlUserHealed === 0) { console.error('\n[FAIL] 自愈对照未成立,方法可能有问题'); process.exit(2); } console.log('\n=== 逐位置消融:剥掉 id(+role) 后是否被拒绝 ==='); console.log(' 位置'.padEnd(42), '样本 拒绝 判定'); const members = []; for (const [label, v] of Object.entries(stat)) { let verdict; if (v.tested === 0) verdict = '(无样本)'; else if (v.refused === v.tested) { verdict = '★ 差集成员(要手工补)'; members.push(label); } else if (v.refused === 0) verdict = '自愈侧(无需补)'; else verdict = `混合(${v.refused}/${v.tested})—— 需按形状细分`; console.log(' ' + label.padEnd(40), String(v.tested).padStart(4), String(v.refused).padStart(6), ' ' + verdict); } console.log(`\n=== 差集(校验要 id、迁移器不给 ⇒ 必须手工补)共 ${members.length} 个位置 ===`); for (const m of members) console.log(' • ' + m); console.log('\n注意:这是**按真实形状实测**的结果,比按代码分支标签枚举更可靠 ——'); console.log('分支存在 ≠ 分支会对你的形状执行(守卫可能早退)。');