bookworm-smart-assistant/scripts/fusion-weight-learner.js

721 lines
26 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
/**
* 融合权重自适应学习器 (v5.9)
*
* 对路由融合权重 (BM25, Semantic, Context, Project, Workflow) 进行在线学习。
* 基于反馈纠正数据,用 projected gradient descent 调整权重使路由更准确。
*
* 约束:
* - 权重之和 = 1.0 (概率单纯形)
* - 每个权重 ∈ [0.05, 0.6] (防止退化)
* - 学习率 η = 0.02 (保守更新)
*
* 用法:
* node fusion-weight-learner.js # 学习并输出权重
* node fusion-weight-learner.js --dry-run # 仅预览
* node fusion-weight-learner.js --reset # 重置为默认权重
* node fusion-weight-learner.js --json # JSON 输出
*
* 文件:
* debug/fusion-weights.json 学习后的融合权重
*/
const fs = require('fs');
const path = require('path');
// MEDIUM-1: 引入 WeightStore使用 safeWriteJson 进行并发安全写入
// 若加载失败则回退到直接写入 (不阻断功能)
let _weightStore = null;
try {
const _ws = require('./weight-store.js');
if (typeof _ws.safeWriteJson === 'function') {
_weightStore = _ws;
}
} catch {}
const detectClaudeRoot = () => require('./paths.config.js').PATHS.root;
const ROOT = detectClaudeRoot();
const DEBUG_DIR = path.join(ROOT, 'debug');
const FEEDBACK_FILE = path.join(DEBUG_DIR, 'route-feedback.jsonl');
const FUSION_WEIGHTS_FILE = path.join(DEBUG_DIR, 'fusion-weights.json');
const INDEX_FILE = path.join(ROOT, 'skills-index.json');
// 默认融合权重 (与 route-interceptor.js 一致)
const DEFAULT_WEIGHTS = {
bm25: 0.40,
semantic: 0.30,
context: 0.15,
project: 0.10,
workflow: 0.05,
};
// 约束
const MIN_WEIGHT = 0.05;
const MAX_WEIGHT = 0.60;
const LEARNING_RATE = 0.02;
// W1 修复 (patch-w1-weight-decay, 2026-04-16): L2 正则化强度
// 每批次将权重按比例拉回 DEFAULT_WEIGHTS防止 simulateSignals 三维=0 导致的单向漂移
const WEIGHT_DECAY = 0.02;
const DECAY_HALF_LIFE = 7 * 86400000; // 7 天半衰期
// v5.9: 多项目隔离支持
let _isolator = null;
try { _isolator = require('./project-isolator.js'); } catch {}
function getWeightsFile(cwd) {
if (_isolator && cwd) return _isolator.getIsolatedPath('fusion-weights.json', cwd);
return FUSION_WEIGHTS_FILE;
}
/**
* 安全写入 fusion-weights.json
* BUG-FIX: 原来委托给 weight-store.safeWriteJsonasync Promise
* 但没有 await导致进程退出时写入从未完成权重永远停在 bootstrap 值。
* fusion-weights.json 只有一个写入者(本文件),无并发写入,不需要锁。
* 直接使用同步 fs.writeFileSync 保证写入在进程退出前完成。
* @param {string} weightsFile - 目标文件路径
* @param {Object} data - 要写入的数据
*/
function safeWriteFusionWeights(weightsFile, data) {
_directWriteFallback(weightsFile, data);
}
/** 直接写入回退weight-store 不可用时) */
function _directWriteFallback(weightsFile, data) {
try {
if (!fs.existsSync(path.dirname(weightsFile))) {
fs.mkdirSync(path.dirname(weightsFile), { recursive: true });
}
fs.writeFileSync(weightsFile, JSON.stringify(data, null, 2) + '\n');
} catch {}
}
/**
* C1 原子重置助手 (patch-c1-atomic-reset, 2026-04-16)
* 用 tmp+rename 覆盖为 DEFAULT_WEIGHTS避免 unlinkSync 与并发 readFileSync 的 TOCTOU 竞态。
* 读者永远看到合法 JSON (旧值或新的 DEFAULT),不会 ENOENT。
*/
function atomicResetWeightsFile(weightsFile, reason) {
try {
if (!fs.existsSync(path.dirname(weightsFile))) {
fs.mkdirSync(path.dirname(weightsFile), { recursive: true });
}
const payload = {
weights: { ...DEFAULT_WEIGHTS },
resetAt: new Date().toISOString(),
resetReason: reason || 'manual',
};
const _tmp = weightsFile + '.tmp.' + process.pid;
fs.writeFileSync(_tmp, JSON.stringify(payload, null, 2) + '\n');
fs.renameSync(_tmp, weightsFile);
} catch {}
}
/**
* 加载当前融合权重
* @param {string} [cwd] - 工作目录 (v5.9 per-cwd 隔离)
* @returns {Object} { bm25, semantic, context, project, workflow }
*/
function loadWeights(cwd) {
try {
const file = getWeightsFile(cwd);
if (fs.existsSync(file)) {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
if (data.weights) return { ...DEFAULT_WEIGHTS, ...data.weights };
}
} catch {}
return { ...DEFAULT_WEIGHTS };
}
/**
* 投影到概率单纯形 (权重之和=1, 每个 ∈ [MIN, MAX])
* V01 修复: 迭代 clamp+归一化,确保投影后所有约束同时满足
*/
/**
* 标准约束单纯形投影 (Duchi et al. 2008 变体)
* 保证: sum(w) = 1.0 且 w[k] ∈ [MIN_WEIGHT, MAX_WEIGHT]
* 算法: 排序 → 阈值查找 → 投影 → box clamp → 精确余数分配
*/
function projectToSimplex(w) {
const keys = Object.keys(w).sort(); // 确定性排序
const n = keys.length;
// Step 1: 不带 box 约束的单纯形投影 (Duchi 2008)
const vals = keys.map(k => w[k]);
vals.sort((a, b) => b - a); // 降序
let cumSum = 0;
let rho = 0;
for (let j = 0; j < n; j++) {
cumSum += vals[j];
if (vals[j] - (cumSum - 1) / (j + 1) > 0) rho = j + 1;
}
const theta = (keys.reduce((s, k) => s + w[k], 0) - 1) / rho;
for (const k of keys) {
w[k] = Math.max(0, w[k] - theta);
}
// Step 2: Box 约束 clamp [MIN_WEIGHT, MAX_WEIGHT]
for (const k of keys) {
w[k] = Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, w[k]));
}
// Step 3: 精确余数分配到离边界最远的维度
for (const k of keys) {
w[k] = Math.round(w[k] * 1000) / 1000;
}
let residual = 1.0 - keys.reduce((s, k) => s + w[k], 0);
residual = Math.round(residual * 1000) / 1000;
if (Math.abs(residual) > 0) {
// 找离边界最远的维度来吸收残差
let bestKey = keys[0];
let bestRoom = 0;
for (const k of keys) {
const room = residual > 0
? MAX_WEIGHT - w[k] // 需要增加,找上界余量最大的
: w[k] - MIN_WEIGHT; // 需要减少,找下界余量最大的
if (room > bestRoom) { bestRoom = room; bestKey = k; }
}
w[bestKey] = Math.round((w[bestKey] + residual) * 1000) / 1000;
w[bestKey] = Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, w[bestKey]));
}
return w;
}
/**
* 加载反馈数据中的纠正条目
*/
function loadCorrections() {
if (!fs.existsSync(FEEDBACK_FILE)) return [];
return fs.readFileSync(FEEDBACK_FILE, 'utf8')
.split('\n').filter(Boolean)
.map(l => { try { return JSON.parse(l); } catch { return null; } })
.filter(f => f && f.routedTo && f.correctedTo && f.routedTo !== f.correctedTo && f.routedTo !== 'unknown');
}
/**
* 为每条纠正模拟各信号分数
* 因为历史纠正没有保存各信号分数,使用当前引擎重新计算
*/
function simulateSignals(query, skillName, index, analyzer, semanticScorer) {
const signals = { bm25: 0, semantic: 0, context: 0, project: 0, workflow: 0 };
// BM25 分数
try {
const bm25Params = analyzer.buildBM25Params ? analyzer.buildBM25Params(index) : null;
const queryTokens = analyzer.tokenize(query);
const skill = index.skills.find(s => s.name === skillName);
if (skill && bm25Params) {
const { totalScore } = analyzer.scoreSkill(skill, queryTokens, bm25Params);
signals.bm25 = totalScore;
}
} catch {}
// 语义分数
try {
if (semanticScorer) {
const results = semanticScorer.semanticScore(query, index);
const match = results.find(r => r.name === skillName);
if (match) signals.semantic = match.score;
}
} catch {}
// context/project/workflow 无法回溯,保持 0
return signals;
}
/**
* 在线梯度下降学习融合权重
* 思路: 对每条纠正,计算正确技能和错误技能的信号向量差异,
* 沿着提升正确技能分数的方向调整权重
*/
function learnFusionWeights(options = {}) {
const dryRun = options.dryRun || false;
const corrections = loadCorrections();
if (corrections.length < 2) {
return { status: 'skip', reason: '纠正不足2条', count: corrections.length };
}
// 加载依赖
let analyzer, semanticScorer;
try { analyzer = require('./route-analyzer.js'); } catch { return { status: 'error', reason: 'route-analyzer 不可用' }; }
try { semanticScorer = require('./semantic-scorer.js'); } catch { semanticScorer = null; }
let index;
try { index = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8')); } catch { return { status: 'error', reason: 'skills-index.json 不可用' }; }
const weights = loadWeights();
const now = Date.now();
let totalGradient = { bm25: 0, semantic: 0, context: 0, project: 0, workflow: 0 };
let effectiveSamples = 0;
for (const fb of corrections) {
// 时间衰减
const age = now - new Date(fb.ts).getTime();
if (isNaN(age) || age < 0) continue;
const decay = Math.pow(0.5, age / DECAY_HALF_LIFE);
// 类型权重
const typeFactor = fb.type === 'observed' ? 0.8 : (fb.type === 'implicit' ? 0.5 : 1.0);
const sampleWeight = decay * typeFactor;
if (sampleWeight < 0.01) continue;
// 模拟正确技能和错误技能的信号
const correctSignals = simulateSignals(fb.query, fb.correctedTo, index, analyzer, semanticScorer);
const wrongSignals = simulateSignals(fb.query, fb.routedTo, index, analyzer, semanticScorer);
// 梯度: 正确技能信号 - 错误技能信号 (希望提升正确技能的权重组合分数)
for (const key of Object.keys(totalGradient)) {
totalGradient[key] += sampleWeight * (correctSignals[key] - wrongSignals[key]);
}
effectiveSamples += sampleWeight;
}
if (effectiveSamples < 1) {
return { status: 'skip', reason: '有效样本不足', effectiveSamples };
}
// 归一化梯度
for (const key of Object.keys(totalGradient)) {
totalGradient[key] /= effectiveSamples;
}
// 梯度更新 + W1 权重衰减 (L2 正则化拉回 DEFAULT_WEIGHTS)
const newWeights = { ...weights };
for (const key of Object.keys(newWeights)) {
newWeights[key] += LEARNING_RATE * totalGradient[key];
newWeights[key] += WEIGHT_DECAY * (DEFAULT_WEIGHTS[key] - newWeights[key]);
}
// 投影到约束空间
const projected = projectToSimplex(newWeights);
// 计算权重变化量
const deltas = {};
let totalDelta = 0;
for (const key of Object.keys(projected)) {
deltas[key] = Math.round((projected[key] - weights[key]) * 1000) / 1000;
totalDelta += Math.abs(deltas[key]);
}
const result = {
status: 'ok',
corrections: corrections.length,
effectiveSamples: Math.round(effectiveSamples * 10) / 10,
previousWeights: weights,
newWeights: projected,
deltas,
totalDelta: Math.round(totalDelta * 1000) / 1000,
gradient: Object.fromEntries(
Object.entries(totalGradient).map(([k, v]) => [k, Math.round(v * 1000) / 1000])
),
dryRun,
};
if (!dryRun && totalDelta > 0.005) {
if (!fs.existsSync(DEBUG_DIR)) fs.mkdirSync(DEBUG_DIR, { recursive: true });
const output = {
generated: new Date().toISOString(),
weights: projected,
meta: {
corrections: corrections.length,
effectiveSamples: result.effectiveSamples,
gradient: result.gradient,
previousWeights: weights,
},
};
// P1-2 修复: 使用文件锁保护写入,防止与 applyImplicitWeights 竞态
// 根因守卫 + cooldown: 连续 2 周期命中才真正 reset避免单周期误报抖动
const wVals = Object.values(result.newWeights || {});
let degen = 0;
for (let i = 0; i < wVals.length; i++) {
for (let j = i + 1; j < wVals.length; j++) {
if (Math.abs(wVals[i] - wVals[j]) < 0.01) degen++;
}
}
const bm25Val = (result.newWeights && result.newWeights.bm25) || 1;
const DEGEN_COOLDOWN_FILE = path.join(DEBUG_DIR, 'degen-counter.json');
const hitDegen = degen >= 3 || bm25Val <= 0.06;
let consecutiveHits = 0;
try {
if (fs.existsSync(DEGEN_COOLDOWN_FILE)) {
const c = JSON.parse(fs.readFileSync(DEGEN_COOLDOWN_FILE, 'utf8'));
if (typeof c.hits === 'number') consecutiveHits = c.hits;
}
} catch {}
if (hitDegen) {
consecutiveHits += 1;
try {
const _tmp = DEGEN_COOLDOWN_FILE + '.tmp.' + process.pid;
fs.writeFileSync(_tmp, JSON.stringify({ hits: consecutiveHits, degen, bm25: bm25Val, ts: new Date().toISOString() }));
fs.renameSync(_tmp, DEGEN_COOLDOWN_FILE);
} catch {}
if (consecutiveHits >= 2) {
try {
process.stderr.write('[fusion-weight] 连续 ' + consecutiveHits + ' 周期退化命中reset 到 DEFAULT_WEIGHTS\n');
atomicResetWeightsFile(FUSION_WEIGHTS_FILE, 'degen-consecutive-' + consecutiveHits);
// 重置计数器
try { fs.unlinkSync(DEGEN_COOLDOWN_FILE); } catch {}
} catch {}
return Object.assign({}, result, { status: 'reset-on-degeneracy', weights: Object.assign({}, DEFAULT_WEIGHTS) });
}
// 单周期命中: 记录但不 reset (cooldown 防抖)
try { process.stderr.write('[fusion-weight] 退化单周期 (' + consecutiveHits + '/2),等待下次确认\n'); } catch {}
} else {
// 未命中: 清零计数器 (连续性要求)
try { if (fs.existsSync(DEGEN_COOLDOWN_FILE)) fs.unlinkSync(DEGEN_COOLDOWN_FILE); } catch {}
}
safeWriteFusionWeights(FUSION_WEIGHTS_FILE, output);
}
return result;
}
/**
* 重置为默认权重
*/
function resetWeights() {
atomicResetWeightsFile(FUSION_WEIGHTS_FILE, 'manual-reset');
return { status: 'reset', weights: DEFAULT_WEIGHTS };
}
/**
* 将 implicit-feedback.js 的输出回流到 fusion-weights.json (F3-4)
*
* 修复问题: implicit-feedback 只写 route-feedback.jsonl未触发融合权重更新。
* 本函数从 route-feedback.jsonl 读取反馈信号,将其转化为 PGD 梯度输入,
* 调用 learnFusionWeights() 更新 fusion-weights.json。
*
* 反馈权重规则(来自 strategic-evolution-v6.md S1-Phase1:
* confirmed → 权重向当前方向微调 (正向梯度, weight factor 0.5)
* corrected → 权重向正确技能方向调整 (weight factor 1.0)
* timeout-confirm → 弱信号确认 (weight factor 0.1)
* observed → 直接观测,最高可信度 (weight factor 0.8)
*
* @param {Object} [options]
* @param {boolean} [options.dryRun=false] - 仅预览,不写文件
* @param {number} [options.minNewFeedback=3] - 新反馈条数不足时跳过
* @param {string} [options.cwd] - 工作目录per-cwd 权重隔离)
* @returns {Object} { status, applied, skipped, reason, newWeights, totalDelta }
*/
function applyImplicitWeights(options = {}) {
const dryRun = options.dryRun || false;
const minNewFeedback = options.minNewFeedback || 3;
const cwd = options.cwd || null;
try {
// XC6 修复: 如果 learnFusionWeights 在同一 Stop 周期内已运行,
// 先读取最新权重(包含 learnFusionWeights 写入的结果),在此基础上叠加 implicit 梯度,
// 而非用 applyImplicitWeights 结果覆盖 learnFusionWeights 的结果。
// 通过 loadWeights() 获取最新状态(下方已调用),不再需要额外标记文件。
// P2.4 watermark: 若反馈文件 size 未变化 (>=300 bytes 阈值 ~= 3 条新反馈),跳过全量重算
// 避免 1085+ 行反馈每次 Stop 都重放,语义不变 (仍然全量读取,只是跳过无新增的周期)
const WATERMARK_FILE = path.join(DEBUG_DIR, 'implicit-feedback-watermark.json');
const MIN_SIZE_DELTA = 300;
let currentSize = 0;
try { currentSize = fs.existsSync(FEEDBACK_FILE) ? fs.statSync(FEEDBACK_FILE).size : 0; } catch {}
if (!options.ignoreWatermark && currentSize > 0) {
try {
if (fs.existsSync(WATERMARK_FILE)) {
const wm = JSON.parse(fs.readFileSync(WATERMARK_FILE, 'utf8'));
if (typeof wm.lastSize === 'number') {
// RT-V3+V4 修复: 文件被 truncate → 用 overwrite 代替 unlink 消除 unlink-race
if (currentSize < wm.lastSize) {
try {
const _tmp = WATERMARK_FILE + '.tmp.' + process.pid;
fs.writeFileSync(_tmp, JSON.stringify({ lastSize: 0, ts: new Date().toISOString(), reason: 'truncate-reset' }));
fs.renameSync(_tmp, WATERMARK_FILE);
} catch {}
} else if (currentSize - wm.lastSize < MIN_SIZE_DELTA) {
return { status: 'skip', reason: 'watermark no-new-feedback', lastSize: wm.lastSize, currentSize };
}
}
}
} catch {}
}
// 读取 route-feedback.jsonl含 implicit 反馈和 explicit 纠正)
if (!fs.existsSync(FEEDBACK_FILE)) {
return { status: 'skip', reason: 'route-feedback.jsonl 不存在', applied: 0 };
}
const allFeedback = fs.readFileSync(FEEDBACK_FILE, 'utf8')
.split('\n').filter(Boolean)
.map(l => { try { return JSON.parse(l); } catch { return null; } })
.filter(f => f && f.ts && f.routedTo);
// RT-V3+V4 修复: 反馈已完整读入内存 → tmp+rename 原子更新 watermark (防并发读取半字节)
try {
const _wmTmp = WATERMARK_FILE + '.tmp.' + process.pid;
fs.writeFileSync(_wmTmp, JSON.stringify({ lastSize: currentSize, ts: new Date().toISOString() }));
fs.renameSync(_wmTmp, WATERMARK_FILE);
} catch {}
if (allFeedback.length === 0) {
return { status: 'skip', reason: '反馈文件为空', applied: 0 };
}
// 只处理 implicit 和 observed 类型的反馈explicit 已由 learnFusionWeights 消费)
const implicitFeedback = allFeedback.filter(f =>
f.type === 'implicit' || f.type === 'observed'
);
if (implicitFeedback.length < minNewFeedback) {
return {
status: 'skip',
reason: `隐式反馈条数不足 (${implicitFeedback.length} < ${minNewFeedback})`,
applied: 0,
};
}
// 加载当前权重
const currentWeights = loadWeights(cwd);
const now = Date.now();
// 计算加权平均梯度
// 策略: 对 confirmed/timeout-confirm 施加微弱正向梯度(不改变方向)
// 对 corrected/observed-correct 施加明确的梯度(提升正确技能的信号分量)
let totalGradient = { bm25: 0, semantic: 0, context: 0, project: 0, workflow: 0 };
let totalWeight = 0;
// 加载依赖(用于重新计算信号分数)
let analyzer, semanticScorer, index;
try { analyzer = require('./route-analyzer.js'); } catch { analyzer = null; }
try { semanticScorer = require('./semantic-scorer.js'); } catch { semanticScorer = null; }
try { index = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8')); } catch { index = null; }
for (const fb of implicitFeedback) {
// 时间衰减7天半衰期
const age = now - new Date(fb.ts).getTime();
if (isNaN(age) || age < 0) continue;
const decay = Math.pow(0.5, age / DECAY_HALF_LIFE);
// 类型权重
let typeFactor;
switch (fb.implicit) {
case 'observed-correct': typeFactor = 0.8; break;
case 'correct': typeFactor = 1.0; break;
case 'confirm': typeFactor = 0.5; break;
case 'observed-confirm': typeFactor = 0.5; break;
case 'timeout-confirm': typeFactor = fb.weight || 0.1; break;
default:
// 根据 type 判断
typeFactor = (fb.routedTo !== fb.correctedTo) ? 1.0 : 0.5;
}
const sampleWeight = decay * typeFactor;
if (sampleWeight < 0.005) continue;
// 只有纠正类型才需要计算信号差(确认类型梯度为 0不改变权重方向
const isCorrected = fb.routedTo !== fb.correctedTo;
if (isCorrected && analyzer && index) {
// 模拟正确技能和错误技能的信号分数
const correctSignals = simulateSignals(
fb.query || '', fb.correctedTo, index, analyzer, semanticScorer
);
const wrongSignals = simulateSignals(
fb.query || '', fb.routedTo, index, analyzer, semanticScorer
);
for (const key of Object.keys(totalGradient)) {
totalGradient[key] += sampleWeight * (correctSignals[key] - wrongSignals[key]);
}
}
// P1-1 修复: confirmed 类型不计入 totalWeight防止梯度稀释导致学习停滞
if (isCorrected) {
totalWeight += sampleWeight;
}
}
if (totalWeight < 0.1) {
return { status: 'skip', reason: '有效样本权重不足', applied: 0 };
}
// 归一化梯度
for (const key of Object.keys(totalGradient)) {
totalGradient[key] /= totalWeight;
}
// 应用梯度更新(保守学习率,使用 LEARNING_RATE 的一半避免与 learnFusionWeights 叠加)
// W1: 同步使用 WEIGHT_DECAY 的一半,与 implicitLR 成比例
const implicitLR = LEARNING_RATE * 0.5;
const implicitDecay = WEIGHT_DECAY * 0.5;
const newWeights = { ...currentWeights };
for (const key of Object.keys(newWeights)) {
newWeights[key] += implicitLR * (totalGradient[key] || 0);
newWeights[key] += implicitDecay * (DEFAULT_WEIGHTS[key] - newWeights[key]);
}
// 投影到概率单纯形
const projected = projectToSimplex(newWeights);
// 计算变化量
const deltas = {};
let totalDelta = 0;
for (const key of Object.keys(projected)) {
deltas[key] = Math.round((projected[key] - currentWeights[key]) * 1000) / 1000;
totalDelta += Math.abs(deltas[key]);
}
totalDelta = Math.round(totalDelta * 1000) / 1000;
// 变化量过小则跳过(避免频繁写文件)
if (totalDelta < 0.003) {
return {
status: 'skip',
reason: `梯度变化量过小 (${totalDelta}),跳过写入`,
applied: implicitFeedback.length,
totalDelta,
};
}
// 写入 fusion-weights.json非 dry-run 时)
if (!dryRun) {
const weightsFile = getWeightsFile(cwd);
if (!fs.existsSync(DEBUG_DIR)) fs.mkdirSync(DEBUG_DIR, { recursive: true });
const output = {
generated: new Date().toISOString(),
weights: projected,
meta: {
source: 'applyImplicitWeights',
implicitFeedbackCount: implicitFeedback.length,
effectiveWeight: Math.round(totalWeight * 10) / 10,
gradient: Object.fromEntries(
Object.entries(totalGradient).map(([k, v]) => [k, Math.round(v * 1000) / 1000])
),
previousWeights: currentWeights,
},
};
// P1-2 修复: 使用文件锁保护写入,防止与 learnFusionWeights 竞态
safeWriteFusionWeights(weightsFile, output);
}
return {
status: 'ok',
applied: implicitFeedback.length,
effectiveWeight: Math.round(totalWeight * 10) / 10,
previousWeights: currentWeights,
newWeights: projected,
deltas,
totalDelta,
gradient: Object.fromEntries(
Object.entries(totalGradient).map(([k, v]) => [k, Math.round(v * 1000) / 1000])
),
dryRun,
};
} catch (err) {
// fail-open: 任何异常不影响主流程
return { status: 'error', reason: err.message, applied: 0 };
}
}
/**
* 初始化引导: 若 fusion-weights.json 不存在,写入默认权重
* 确保 route-interceptor-bundle 首次运行时有权重可读
*/
function bootstrapWeights(cwd) {
const file = cwd ? getWeightsFile(cwd) : FUSION_WEIGHTS_FILE;
if (fs.existsSync(file)) return { status: 'exists' };
if (!fs.existsSync(DEBUG_DIR)) fs.mkdirSync(DEBUG_DIR, { recursive: true });
const output = {
generated: new Date().toISOString(),
weights: { ...DEFAULT_WEIGHTS },
meta: { source: 'bootstrap', corrections: 0 },
};
safeWriteFusionWeights(file, output);
return { status: 'bootstrapped', weights: DEFAULT_WEIGHTS };
}
/**
* H2 修复: 原子化 bootstrap + learn + apply 权重更新
* 用 sentinel 文件检测"半写入"状态 (Stop hook 被 timeout kill)
*
* - 检测到 30s 内的 sentinel → 跳过 (并发写入保护)
* - 检测到 >30s 的 stale sentinel → 清除后正常跑 (上次 kill 残留)
* - 正常流程: 串联 bootstrap → learn → apply过程中维护 sentinel
*/
function atomicWeightUpdate(options = {}) {
const sentinel = FUSION_WEIGHTS_FILE + '.writing';
const now = Date.now();
if (fs.existsSync(sentinel)) {
try {
const age = now - fs.statSync(sentinel).mtimeMs;
if (age > 30000) {
// Stale sentinel (上次写入被 kill) → 清除
try {
process.stderr.write('[fusion-weight] stale sentinel 清除 (age=' + Math.round(age/1000) + 's)\n');
} catch {}
try { fs.unlinkSync(sentinel); } catch {}
} else {
return { status: 'skip', reason: 'concurrent-write', age };
}
} catch {}
}
try {
if (!fs.existsSync(DEBUG_DIR)) fs.mkdirSync(DEBUG_DIR, { recursive: true });
fs.writeFileSync(sentinel, String(process.pid) + '@' + now);
const bootResult = bootstrapWeights();
const learnResult = learnFusionWeights(options);
const applyResult = applyImplicitWeights(options);
return {
status: 'ok',
bootstrap: bootResult && bootResult.status,
learn: learnResult && learnResult.status,
apply: applyResult && applyResult.status,
};
} catch (err) {
return { status: 'error', reason: err.message };
} finally {
try { if (fs.existsSync(sentinel)) fs.unlinkSync(sentinel); } catch {}
}
}
// 模块导出
if (typeof module !== 'undefined') {
module.exports = {
loadWeights, projectToSimplex, learnFusionWeights,
resetWeights, applyImplicitWeights, bootstrapWeights, atomicWeightUpdate,
DEFAULT_WEIGHTS, FUSION_WEIGHTS_FILE,
};
}
// CLI 入口
if (require.main === module) {
const args = process.argv.slice(2);
const jsonMode = args.includes('--json');
const dryRun = args.includes('--dry-run');
if (args.includes('--reset')) {
const r = resetWeights();
console.log(jsonMode ? JSON.stringify(r, null, 2) : '融合权重已重置为默认值');
process.exit(0);
}
const result = learnFusionWeights({ dryRun });
if (jsonMode) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log('=== 融合权重自适应学习 ===');
if (result.status === 'skip') {
console.log(`跳过: ${result.reason}`);
} else if (result.status === 'error') {
console.log(`错误: ${result.reason}`);
} else {
console.log(`纠正样本: ${result.corrections}, 有效样本: ${result.effectiveSamples}`);
console.log(`梯度: ${JSON.stringify(result.gradient)}`);
console.log(`\n权重变化:`);
for (const [k, v] of Object.entries(result.deltas)) {
const arrow = v > 0 ? '↑' : v < 0 ? '↓' : '=';
console.log(` ${k.padEnd(10)} ${result.previousWeights[k]}${result.newWeights[k]} (${arrow}${Math.abs(v)})`);
}
console.log(`\n总变化量: ${result.totalDelta}${result.dryRun ? ' (dry-run)' : ''}`);
}
}
}