- 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)
179 lines
7.0 KiB
JavaScript
179 lines
7.0 KiB
JavaScript
#!/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');
|