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

721 lines
26 KiB
JavaScript
Raw Permalink Normal View History

#!/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)' : ''}`);
}
}
}