Refactor L0 quality and diffusion graph gating for balanced recall

This commit is contained in:
2026-02-14 17:12:03 +08:00
parent 15640d48f2
commit e8383570e0
3 changed files with 169 additions and 46 deletions

View File

@@ -63,6 +63,8 @@ const SYSTEM_PROMPT = `你是场景摘要器。从一轮对话中提取1-2个场
- 只从正文内容中识别角色名,不要把标签名(如 user、assistant当作角色
- r 使用动作模板短语:“动作+对象/结果”(例:“提出交易条件”、“拒绝对方请求”、“当众揭露秘密”、“安抚对方情绪”)
- r 不要写人名,不要复述整句,不要写心理描写或评价词
- r 正例(合格):提出交易条件、拒绝对方请求、当众揭露秘密、安抚对方情绪、强行打断发言、转移谈话焦点
- r 反例不合格我觉得她现在很害怕、他突然非常生气地大喊起来、user开始说话、assistant解释了很多细节
- 每个锚点 1-3 条
## where
@@ -87,6 +89,46 @@ const JSON_PREFILL = '{"anchors":[';
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const ACTION_STRIP_WORDS = [
'突然', '非常', '有些', '有点', '轻轻', '悄悄', '缓缓', '立刻',
'马上', '然后', '并且', '而且', '开始', '继续', '再次', '正在',
];
function clamp(v, min, max) {
return Math.max(min, Math.min(max, v));
}
function sanitizeActionPhrase(raw) {
let text = String(raw || '')
.normalize('NFKC')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.trim();
if (!text) return '';
text = text
.replace(/[,。!?、;:,.!?;:"'“”‘’()[\]{}<>《》]/g, '')
.replace(/\s+/g, '');
for (const word of ACTION_STRIP_WORDS) {
text = text.replaceAll(word, '');
}
text = text.replace(/(地|得|了|着|过)+$/g, '');
if (text.length < 2) return '';
if (text.length > 12) text = text.slice(0, 12);
return text;
}
function calcAtomQuality(scene, edges, where) {
const sceneLen = String(scene || '').length;
const sceneScore = clamp(sceneLen / 80, 0, 1);
const edgeScore = clamp((edges?.length || 0) / 3, 0, 1);
const whereScore = where ? 1 : 0;
const quality = 0.55 * sceneScore + 0.35 * edgeScore + 0.10 * whereScore;
return Number(quality.toFixed(3));
}
// ============================================================================
// 清洗与构建
// ============================================================================
@@ -103,7 +145,7 @@ function sanitizeEdges(raw) {
.map(e => ({
s: String(e.s || '').trim(),
t: String(e.t || '').trim(),
r: String(e.r || '').trim().slice(0, 30),
r: sanitizeActionPhrase(e.r),
}))
.filter(e => e.s && e.t && e.r)
.slice(0, 3);
@@ -127,6 +169,7 @@ function anchorToAtom(anchor, aiFloor, idx) {
if (scene.length < 15) return null;
const edges = sanitizeEdges(anchor.edges);
const where = String(anchor.where || '').trim();
const quality = calcAtomQuality(scene, edges, where);
return {
atomId: `atom-${aiFloor}-${idx}`,
@@ -139,6 +182,7 @@ function anchorToAtom(anchor, aiFloor, idx) {
// ═══ 图结构层(扩散的 key ═══
edges,
where,
quality,
};
}

View File

@@ -47,20 +47,23 @@ const CONFIG = {
MAX_ITER: 50, // hard iteration cap (typically converges in 15-25)
// Edge weight channel coefficients
// No standalone WHO channel: rely on interaction/action/location only.
// Candidate generation uses WHAT/HOW only.
// WHO/WHERE are reweight-only signals.
GAMMA: {
what: 0.55, // interaction pair overlap — Szymkiewicz-Simpson
where: 0.15, // location exact match — binary
what: 0.45, // interaction pair overlap — Szymkiewicz-Simpson
how: 0.30, // action-term co-occurrence — Jaccard
who: 0.15, // endpoint entity overlap — Jaccard (reweight-only)
where: 0.10, // location exact match — damped (reweight-only)
},
WHERE_MAX_GROUP_SIZE: 16, // skip location-only pair expansion for over-common places
WHERE_FREQ_DAMP_PIVOT: 6, // location freq <= pivot keeps full WHERE score
WHERE_FREQ_DAMP_MIN: 0.20, // lower bound for damped WHERE contribution
HOW_MAX_GROUP_SIZE: 24, // skip ultra-common action terms to avoid dense pair explosion
// Post-verification (Cosine Gate)
COSINE_GATE: 0.45, // min cosine(queryVector, stateVector)
SCORE_FLOOR: 0.10, // min finalScore = PPR_normalized × cosine
DIFFUSION_CAP: 100, // max diffused nodes (excluding seeds)
COSINE_GATE: 0.48, // min cosine(queryVector, stateVector)
SCORE_FLOOR: 0.12, // min finalScore = PPR_normalized × cosine
DIFFUSION_CAP: 80, // max diffused nodes (excluding seeds)
};
// ═══════════════════════════════════════════════════════════════════════════
@@ -226,25 +229,27 @@ function extractAllFeatures(allAtoms, excludeEntities = new Set()) {
/**
* Build inverted index: value → list of atom indices
* @param {object[]} features
* @returns {{ entityIndex: Map, locationIndex: Map }}
* @returns {{ whatIndex: Map, howIndex: Map, locationFreq: Map }}
*/
function buildInvertedIndices(features) {
const entityIndex = new Map();
const locationIndex = new Map();
const whatIndex = new Map();
const howIndex = new Map();
const locationFreq = new Map();
for (let i = 0; i < features.length; i++) {
for (const e of features[i].entities) {
if (!entityIndex.has(e)) entityIndex.set(e, []);
entityIndex.get(e).push(i);
for (const pair of features[i].interactionPairs) {
if (!whatIndex.has(pair)) whatIndex.set(pair, []);
whatIndex.get(pair).push(i);
}
for (const action of features[i].actionTerms) {
if (!howIndex.has(action)) howIndex.set(action, []);
howIndex.get(action).push(i);
}
const loc = features[i].location;
if (loc) {
if (!locationIndex.has(loc)) locationIndex.set(loc, []);
locationIndex.get(loc).push(i);
}
if (loc) locationFreq.set(loc, (locationFreq.get(loc) || 0) + 1);
}
return { entityIndex, locationIndex };
return { whatIndex, howIndex, locationFreq };
}
/**
@@ -277,30 +282,32 @@ function buildGraph(allAtoms, excludeEntities = new Set()) {
const T0 = performance.now();
const features = extractAllFeatures(allAtoms, excludeEntities);
const { entityIndex, locationIndex } = buildInvertedIndices(features);
const locationFreq = new Map();
for (const [loc, indices] of locationIndex.entries()) {
locationFreq.set(loc, indices.length);
}
const { whatIndex, howIndex, locationFreq } = buildInvertedIndices(features);
// Candidate pairs: share ≥1 entity or same location
// Candidate pairs: only WHAT/HOW can create edges
const pairSetByWhat = new Set();
const pairSetByHow = new Set();
const pairSet = new Set();
collectPairsFromIndex(entityIndex, pairSet, N);
let skippedLocationGroups = 0;
for (const [loc, indices] of locationIndex.entries()) {
if (!loc) continue;
if (indices.length > CONFIG.WHERE_MAX_GROUP_SIZE) {
skippedLocationGroups++;
collectPairsFromIndex(whatIndex, pairSetByWhat, N);
let skippedHowGroups = 0;
for (const [term, indices] of howIndex.entries()) {
if (!term) continue;
if (indices.length > CONFIG.HOW_MAX_GROUP_SIZE) {
skippedHowGroups++;
continue;
}
const oneLocMap = new Map([[loc, indices]]);
collectPairsFromIndex(oneLocMap, pairSet, N);
const oneHowMap = new Map([[term, indices]]);
collectPairsFromIndex(oneHowMap, pairSetByHow, N);
}
for (const p of pairSetByWhat) pairSet.add(p);
for (const p of pairSetByHow) pairSet.add(p);
// Compute three-channel edge weights for all candidates
// Compute edge weights for all candidates
const neighbors = Array.from({ length: N }, () => []);
let edgeCount = 0;
const channelStats = { what: 0, where: 0, how: 0 };
const channelStats = { what: 0, where: 0, how: 0, who: 0 };
let reweightWhoUsed = 0;
let reweightWhereUsed = 0;
for (const packed of pairSet) {
const i = Math.floor(packed / N);
@@ -310,6 +317,8 @@ function buildGraph(allAtoms, excludeEntities = new Set()) {
const fj = features[j];
const wWhat = overlapCoefficient(fi.interactionPairs, fj.interactionPairs);
const wHow = jaccard(fi.actionTerms, fj.actionTerms);
const wWho = jaccard(fi.entities, fj.entities);
let wWhere = 0.0;
if (fi.location && fi.location === fj.location) {
const freq = locationFreq.get(fi.location) || 1;
@@ -319,12 +328,12 @@ function buildGraph(allAtoms, excludeEntities = new Set()) {
);
wWhere = damp;
}
const wHow = jaccard(fi.actionTerms, fj.actionTerms);
const weight =
CONFIG.GAMMA.what * wWhat +
CONFIG.GAMMA.where * wWhere +
CONFIG.GAMMA.how * wHow;
CONFIG.GAMMA.how * wHow +
CONFIG.GAMMA.who * wWho +
CONFIG.GAMMA.where * wWhere;
if (weight > 0) {
neighbors[i].push({ target: j, weight });
@@ -332,8 +341,11 @@ function buildGraph(allAtoms, excludeEntities = new Set()) {
edgeCount++;
if (wWhat > 0) channelStats.what++;
if (wWhere > 0) channelStats.where++;
if (wHow > 0) channelStats.how++;
if (wWho > 0) channelStats.who++;
if (wWhere > 0) channelStats.where++;
if (wWho > 0) reweightWhoUsed++;
if (wWhere > 0) reweightWhereUsed++;
}
}
@@ -341,12 +353,28 @@ function buildGraph(allAtoms, excludeEntities = new Set()) {
xbLog.info(MODULE_ID,
`Graph: ${N} nodes, ${edgeCount} edges ` +
`(what=${channelStats.what} where=${channelStats.where} how=${channelStats.how}) ` +
`(whereSkippedGroups=${skippedLocationGroups}) ` +
`(candidate_by_what=${pairSetByWhat.size} candidate_by_how=${pairSetByHow.size}) ` +
`(what=${channelStats.what} how=${channelStats.how} who=${channelStats.who} where=${channelStats.where}) ` +
`(reweight_who_used=${reweightWhoUsed} reweight_where_used=${reweightWhereUsed}) ` +
`(howSkippedGroups=${skippedHowGroups}) ` +
`(${buildTime}ms)`
);
return { neighbors, edgeCount, channelStats, buildTime };
const totalPairs = N > 1 ? (N * (N - 1)) / 2 : 0;
const edgeDensity = totalPairs > 0 ? Number((edgeCount / totalPairs * 100).toFixed(2)) : 0;
return {
neighbors,
edgeCount,
channelStats,
buildTime,
candidatePairs: pairSet.size,
pairsFromWhat: pairSetByWhat.size,
pairsFromHow: pairSetByHow.size,
reweightWhoUsed,
reweightWhereUsed,
edgeDensity,
};
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -673,6 +701,12 @@ export function diffuseFromSeeds(seeds, allAtoms, stateVectors, queryVector, met
graphNodes: N,
graphEdges: 0,
channelStats: graph.channelStats,
candidatePairs: graph.candidatePairs,
pairsFromWhat: graph.pairsFromWhat,
pairsFromHow: graph.pairsFromHow,
edgeDensity: graph.edgeDensity,
reweightWhoUsed: graph.reweightWhoUsed,
reweightWhereUsed: graph.reweightWhereUsed,
time: graph.buildTime,
});
xbLog.info(MODULE_ID, 'No graph edges — skipping diffusion');
@@ -719,6 +753,12 @@ export function diffuseFromSeeds(seeds, allAtoms, stateVectors, queryVector, met
graphNodes: N,
graphEdges: graph.edgeCount,
channelStats: graph.channelStats,
candidatePairs: graph.candidatePairs,
pairsFromWhat: graph.pairsFromWhat,
pairsFromHow: graph.pairsFromHow,
edgeDensity: graph.edgeDensity,
reweightWhoUsed: graph.reweightWhoUsed,
reweightWhereUsed: graph.reweightWhereUsed,
buildTime: graph.buildTime,
iterations,
convergenceError: finalError,
@@ -726,6 +766,9 @@ export function diffuseFromSeeds(seeds, allAtoms, stateVectors, queryVector, met
cosineGatePassed: gateStats.passed,
cosineGateFiltered: gateStats.filtered,
cosineGateNoVector: gateStats.noVector,
postGatePassRate: pprActivated > 0
? Math.round((gateStats.passed / pprActivated) * 100)
: 0,
finalCount: diffused.length,
scoreDistribution: diffused.length > 0
? calcScoreStats(diffused.map(d => d.finalScore))
@@ -783,7 +826,14 @@ function fillMetricsEmpty(metrics) {
cosineGateNoVector: 0,
finalCount: 0,
scoreDistribution: { min: 0, max: 0, mean: 0 },
byChannel: { what: 0, where: 0, how: 0 },
byChannel: { what: 0, where: 0, how: 0, who: 0 },
candidatePairs: 0,
pairsFromWhat: 0,
pairsFromHow: 0,
edgeDensity: 0,
reweightWhoUsed: 0,
reweightWhereUsed: 0,
postGatePassRate: 0,
time: 0,
};
}
@@ -803,9 +853,16 @@ function fillMetrics(metrics, data) {
cosineGatePassed: data.cosineGatePassed || 0,
cosineGateFiltered: data.cosineGateFiltered || 0,
cosineGateNoVector: data.cosineGateNoVector || 0,
postGatePassRate: data.postGatePassRate || 0,
finalCount: data.finalCount || 0,
scoreDistribution: data.scoreDistribution || { min: 0, max: 0, mean: 0 },
byChannel: data.channelStats || { what: 0, where: 0, how: 0 },
byChannel: data.channelStats || { what: 0, where: 0, how: 0, who: 0 },
candidatePairs: data.candidatePairs || 0,
pairsFromWhat: data.pairsFromWhat || 0,
pairsFromHow: data.pairsFromHow || 0,
edgeDensity: data.edgeDensity || 0,
reweightWhoUsed: data.reweightWhoUsed || 0,
reweightWhereUsed: data.reweightWhereUsed || 0,
time: data.time || 0,
};
}

View File

@@ -118,15 +118,22 @@ export function createMetrics() {
seedCount: 0,
graphNodes: 0,
graphEdges: 0,
candidatePairs: 0,
pairsFromWhat: 0,
pairsFromHow: 0,
edgeDensity: 0,
reweightWhoUsed: 0,
reweightWhereUsed: 0,
iterations: 0,
convergenceError: 0,
pprActivated: 0,
cosineGatePassed: 0,
cosineGateFiltered: 0,
cosineGateNoVector: 0,
postGatePassRate: 0,
finalCount: 0,
scoreDistribution: { min: 0, max: 0, mean: 0 },
byChannel: { what: 0, where: 0, how: 0 },
byChannel: { what: 0, where: 0, how: 0, who: 0 },
time: 0,
},
@@ -169,6 +176,7 @@ export function createMetrics() {
eventPrecisionProxy: 0,
l1AttachRate: 0,
rerankRetentionRate: 0,
diffusionEffectiveRate: 0,
potentialIssues: [],
},
};
@@ -368,9 +376,12 @@ export function formatMetricsLog(metrics) {
lines.push('[Diffusion] PPR Spreading Activation');
lines.push(`├─ seeds: ${m.diffusion.seedCount}`);
lines.push(`├─ graph: ${m.diffusion.graphNodes} nodes, ${m.diffusion.graphEdges} edges`);
lines.push(`├─ candidate_pairs: ${m.diffusion.candidatePairs || 0} (what=${m.diffusion.pairsFromWhat || 0}, how=${m.diffusion.pairsFromHow || 0})`);
lines.push(`├─ edge_density: ${m.diffusion.edgeDensity || 0}%`);
if (m.diffusion.graphEdges > 0) {
const ch = m.diffusion.byChannel || {};
lines.push(`─ by_channel: what=${ch.what || 0}, where=${ch.where || 0}, how=${ch.how || 0}`);
lines.push(`─ by_channel: what=${ch.what || 0}, how=${ch.how || 0}, who=${ch.who || 0}, where=${ch.where || 0}`);
lines.push(`│ └─ reweight_used: who=${m.diffusion.reweightWhoUsed || 0}, where=${m.diffusion.reweightWhereUsed || 0}`);
}
if (m.diffusion.iterations > 0) {
lines.push(`├─ ppr: ${m.diffusion.iterations} iterations, ε=${Number(m.diffusion.convergenceError).toExponential(1)}`);
@@ -378,8 +389,10 @@ export function formatMetricsLog(metrics) {
lines.push(`├─ activated (excl seeds): ${m.diffusion.pprActivated}`);
if (m.diffusion.pprActivated > 0) {
lines.push(`├─ cosine_gate: ${m.diffusion.cosineGatePassed} passed, ${m.diffusion.cosineGateFiltered} filtered`);
const passPrefix = m.diffusion.cosineGateNoVector > 0 ? '│ ├─' : '│ └─';
lines.push(`${passPrefix} pass_rate: ${m.diffusion.postGatePassRate || 0}%`);
if (m.diffusion.cosineGateNoVector > 0) {
lines.push(`─ no_vector: ${m.diffusion.cosineGateNoVector}`);
lines.push(`─ no_vector: ${m.diffusion.cosineGateNoVector}`);
}
}
lines.push(`├─ final_injected: ${m.diffusion.finalCount}`);
@@ -435,6 +448,7 @@ export function formatMetricsLog(metrics) {
lines.push(`├─ event_precision_proxy: ${m.quality.eventPrecisionProxy}`);
lines.push(`├─ l1_attach_rate: ${m.quality.l1AttachRate}%`);
lines.push(`├─ rerank_retention_rate: ${m.quality.rerankRetentionRate}%`);
lines.push(`├─ diffusion_effective_rate: ${m.quality.diffusionEffectiveRate}%`);
if (m.quality.potentialIssues && m.quality.potentialIssues.length > 0) {
lines.push(`└─ potential_issues:`);
@@ -642,6 +656,10 @@ export function detectIssues(metrics) {
issues.push('All PPR-activated nodes failed cosine gate - graph structure diverged from query semantics');
}
m.quality.diffusionEffectiveRate = m.diffusion.pprActivated > 0
? Math.round((m.diffusion.finalCount / m.diffusion.pprActivated) * 100)
: 0;
if (m.diffusion.cosineGateNoVector > 5) {
issues.push(`${m.diffusion.cosineGateNoVector} PPR nodes missing vectors - L0 vectorization may be incomplete`);
}
@@ -650,5 +668,9 @@ export function detectIssues(metrics) {
issues.push(`Slow diffusion (${m.diffusion.time}ms) - graph may be too dense`);
}
if (m.diffusion.pprActivated > 0 && (m.diffusion.postGatePassRate < 20 || m.diffusion.postGatePassRate > 60)) {
issues.push(`Diffusion post-gate pass rate out of target (${m.diffusion.postGatePassRate}%)`);
}
return issues;
}