bookworm-smart-assistant/scripts/patches/patch-l1c-rerank-arbitration-aware.js
Bookworm Admin b7a8e29d21 release: v6.7.0 - OTA E2E test release
- VERSION file as authoritative version source
- export.mjs reads VERSION with package.json fallback
- bw-ota.ps1 DryRun mode for safe testing
- auto-setup.ps1 bumped to v3.2.0 (Phase 8 OTA)
2026-04-27 17:59:44 +08:00

179 lines
7.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* Patch L1c: rerankTopK 仲裁感知化 (2026-04-25)
*
* 上下文 (承接 L1/L1b):
* L1b 完成后, disambiguation 层成功把 self-auditor (winner, ~0.881)
* 置于 project-audit-expert (loser, ~0.837) 之上. 但 rerankTopK
* 随后给 loser 打 _rerankBoost=1.2076 + _rerankProtected=true,
* 把 loser.score 拉回 ~2.43 反超 winner. 端到端 self-auditor 失败.
*
* 根因:
* route-analyzer.js Line 597-616 中:
* 1. r.disambiguated 是"曾被 boost 过"的单纯标记 (Line 860 设置),
* 不区分 winner / loser. L1b loser 仍带 disambiguated=true.
* 2. Line 599: `r.score *= max(rerankMultiplier, 1.0)` —— 对 loser
* 也按 1.2076× 放大, 抹掉 L1b 的 cap (winner.score * 0.95).
* 3. Line 600: `_rerankProtected=true` 给 loser 加锁.
* 4. Line 608 cap: `topK.find(r => r.disambiguated)` 取第一个匹配,
* 在 winner/loser 都 disambiguated 时可能错把 loser 当 cap 基线.
*
* 修复 (方案 A+B+C 组合, 选用最稳):
* A. 让 loser (有 _arbitratedBy 标记) 不进入 rerank 保护分支
* B. cap 基线改为"真 winner": disambiguated && !_arbitratedBy
* C. loser 的 rerankMultiplier 硬 cap 到 1.0 (禁止再放大), 防越界
*
* 安全性:
* - 业务路由 (project-audit-expert 在"帮我审一下项目"场景独占 boost,
* 无 _arbitratedBy 标记) 不受影响, 仍走 winner 保护分支.
* - 单 boost 路径 (无跨域竞争) 行为完全不变 (无 _arbitratedBy).
* - cold-start skill 发现 (_rerankBoost 主流程) 保留.
*
* Fail-close:
* 异常退化到旧行为 + stderr 警告. sentinel 防重复 patch.
*
* 用法:
* node patches/patch-l1c-rerank-arbitration-aware.js # dry-run
* node patches/patch-l1c-rerank-arbitration-aware.js --apply # 实际写入
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const TARGET = path.join(__dirname, '..', 'route-analyzer.js');
const SENTINEL = '/* L1c-RERANK-ARBITRATION-AWARE-2026-04-25 */';
const APPLY = process.argv.includes('--apply');
function fail(msg, code = 2) {
process.stderr.write('[L1c] ' + msg + '\n');
process.exit(code);
}
if (!fs.existsSync(TARGET)) fail('target missing: ' + TARGET);
const original = fs.readFileSync(TARGET, 'utf8');
// 检测行尾, patch 字符串用 LF, 必要时转 CRLF 匹配源文件
const EOL = original.includes('\r\n') ? '\r\n' : '\n';
const toEOL = (s) => EOL === '\r\n' ? s.replace(/\r?\n/g, '\r\n') : s;
if (original.includes(SENTINEL)) {
process.stdout.write('[L1c] sentinel present, already patched. no-op.\n');
process.exit(0);
}
// 精准锚点: 旧 rerank 保护代码块 (Line 595-605 区域)
const OLD_BLOCK =
` // rerank score = 原始 BM25 × (1 + jaccard×0.3 + tierRatio×0.2)
// 消歧 boosted 技能受保护: rerank 不降低其排名
const rerankMultiplier = 1 + jaccard * 0.3 + tierRatio * 0.2;
if (r.disambiguated) {
// 消歧已确认此技能优先: 只允许 rerank 增强,不允许被其他技能超越
r.score = r.score * Math.max(rerankMultiplier, 1.0);
r._rerankProtected = true;
} else {
r.score = r.score * rerankMultiplier;
}
r._rerankBoost = rerankMultiplier;`;
const NEW_BLOCK =
` // rerank score = 原始 BM25 × (1 + jaccard×0.3 + tierRatio×0.2)
// 消歧 boosted 技能受保护: rerank 不降低其排名
${SENTINEL}
const rerankMultiplier = 1 + jaccard * 0.3 + tierRatio * 0.2;
// L1c: L1b 仲裁 loser (_arbitratedBy 标记) 不享受保护, 且 multiplier 硬 cap 到 1.0
// 防止被 rerank boost 反超已被 cap 到 winner*0.95 的位置
const _isArbLoser = !!r._arbitratedBy;
if (r.disambiguated && !_isArbLoser) {
// 消歧 winner: 只允许 rerank 增强,不允许被其他技能超越
r.score = r.score * Math.max(rerankMultiplier, 1.0);
r._rerankProtected = true;
} else if (_isArbLoser) {
// 仲裁 loser: 严格不放大, 仅允许收紧 (jaccard/tier 真低分自然降级 OK)
const _capped = Math.min(rerankMultiplier, 1.0);
r.score = r.score * _capped;
r._rerankBoost = _capped;
continue;
} else {
r.score = r.score * rerankMultiplier;
}
r._rerankBoost = rerankMultiplier;`;
const OLD_CAP =
` // 消歧保护 cap: 非消歧技能不得超越消歧 top
const disambTop = topK.find(r => r.disambiguated);
if (disambTop) {
for (const r of topK) {
if (!r.disambiguated && r.score > disambTop.score) {
r.score = disambTop.score * 0.98;
r._rerankCapped = true;
}
}
}`;
const NEW_CAP =
` // 消歧保护 cap: 非消歧技能不得超越消歧 winner
// L1c: cap 基线必须是真 winner (disambiguated && !_arbitratedBy);
// 跨域仲裁 loser 虽然 disambiguated=true, 但被 L1b cap 到 winner*0.95,
// 不可作为 cap 基线 (否则真 winner 会被反向 cap)
const disambTop = topK.find(r => r.disambiguated && !r._arbitratedBy);
if (disambTop) {
for (const r of topK) {
if (r === disambTop) continue;
// 仲裁 loser 也参与 cap: 它的 disambiguated 是历史标记, 不豁免
if (r.score > disambTop.score) {
r.score = disambTop.score * 0.98;
r._rerankCapped = true;
}
}
}`;
const OLD_BLOCK_E = toEOL(OLD_BLOCK);
const NEW_BLOCK_E = toEOL(NEW_BLOCK);
const OLD_CAP_E = toEOL(OLD_CAP);
const NEW_CAP_E = toEOL(NEW_CAP);
if (!original.includes(OLD_BLOCK_E)) fail('anchor missing: rerank protected block');
if (!original.includes(OLD_CAP_E)) fail('anchor missing: disambig cap block');
const patched = original.replace(OLD_BLOCK_E, NEW_BLOCK_E).replace(OLD_CAP_E, NEW_CAP_E);
if (patched === original) fail('no-op replacement, abort to avoid silent failure');
if (!patched.includes(SENTINEL)) fail('sentinel missing post-patch');
// 语法预校验
try {
new (require('vm').Script)(patched, { filename: TARGET });
} catch (e) {
fail('syntax check failed: ' + e.message);
}
const oldHash = crypto.createHash('sha256').update(original).digest('hex').slice(0, 16);
const newHash = crypto.createHash('sha256').update(patched).digest('hex').slice(0, 16);
process.stdout.write('[L1c] target : ' + TARGET + '\n');
process.stdout.write('[L1c] old sha : ' + oldHash + '\n');
process.stdout.write('[L1c] new sha : ' + newHash + '\n');
process.stdout.write('[L1c] +' + (patched.split('\n').length - original.split('\n').length) + ' lines\n');
if (!APPLY) {
process.stdout.write('[L1c] dry-run OK. re-run with --apply to write.\n');
process.exit(0);
}
// 备份 + 原子写
const bakDir = path.join(__dirname, 'bak');
if (!fs.existsSync(bakDir)) fs.mkdirSync(bakDir, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const bakPath = path.join(bakDir, 'route-analyzer.js.' + ts + '.l1c.bak');
fs.writeFileSync(bakPath, original);
const tmp = TARGET + '.l1c.tmp';
fs.writeFileSync(tmp, patched);
fs.renameSync(tmp, TARGET);
process.stdout.write('[L1c] backup : ' + bakPath + '\n');
process.stdout.write('[L1c] applied OK.\n');