From ac551a38affc9632e9036dcf4442902cad29bd79 Mon Sep 17 00:00:00 2001 From: Liyuk Date: Mon, 17 Aug 2026 10:02:38 -0700 Subject: [PATCH] perf: bound graph-memory recall token injection The recall context is injected on every prompt assembly. Several unbounded inputs let it grow with graph size: - tokenBudget was accepted but ignored (hardcoded 0): every recalled node was injected in full regardless of budget. - Node content was injected verbatim, including a leading name line that duplicates the XML name attribute. - Precise and generalized recall paths each ran to the full limit and were merged with only dedup, doubling node count (12 instead of 6). - Episodic provenance pulled top-3 nodes x 2 sessions at up to 500 chars. - System prompt boilerplate and per-node updated timestamps were verbose. Changes: - assemble.ts: enforce tokenBudget (greedy, highest-priority node first, always keeps >=1 node); strip the leading content name line; truncate content to contentMaxChars (default 400); tighten episodic to top-2 x 300 chars x 150 chars/msg; drop updated timestamps; condense the system prompt boilerplate. - recall.ts: precise path wins; the generalized path only backfills communities the precise path missed, capped at recallMaxNodes. - types.ts + dsh.ts + index.ts: wire recallTokenBudget (default 2000) and contentMaxChars (default 400) through config; 0 = unbounded (backward compatible). Measured on a real 897-node graph: 12->6 nodes/query and ~49% fewer injected tokens per query (2781->1424 avg). Recall quality is preserved because full node content stays available via gm_search. --- dsh.ts | 9 ++- index.ts | 3 +- src/format/assemble.ts | 120 +++++++++++++++++++++++++------- src/recaller/recall.ts | 25 +++++-- src/types.ts | 6 ++ test/assemble.test.ts | 124 ++++++++++++++++++++++++++++++++-- test/recall-community.test.ts | 6 +- test/recall-merge.test.ts | 96 ++++++++++++++++++++++++++ 8 files changed, 345 insertions(+), 44 deletions(-) create mode 100644 test/recall-merge.test.ts diff --git a/dsh.ts b/dsh.ts index 3b0b8fa..0c29c2d 100644 --- a/dsh.ts +++ b/dsh.ts @@ -45,6 +45,10 @@ export interface Config { recallEnabled?: boolean; recallMaxNodes?: number; recallMaxDepth?: number; + /** 每轮召回注入的 token 预算(0 = 不限制) */ + recallTokenBudget?: number; + /** 单节点 content 注入的最大字符数(0 = 不截断) */ + contentMaxChars?: number; maintenanceInterval?: number; llmProvider?: string; llmModel?: string; @@ -151,6 +155,8 @@ export function apply(ctx: DshContext, input: Config = {}): void { compactTurnCount: input.maintenanceInterval ?? DEFAULT_CONFIG.compactTurnCount, recallMaxNodes: input.recallMaxNodes ?? DEFAULT_CONFIG.recallMaxNodes, recallMaxDepth: input.recallMaxDepth ?? DEFAULT_CONFIG.recallMaxDepth, + recallTokenBudget: input.recallTokenBudget ?? DEFAULT_CONFIG.recallTokenBudget, + contentMaxChars: input.contentMaxChars ?? DEFAULT_CONFIG.contentMaxChars, embedding, }; const extractionEnabled = input.extractionEnabled ?? true; @@ -354,7 +360,8 @@ export function apply(ctx: DshContext, input: Config = {}): void { const activeIds = new Set(activeNodes.map((node) => node.id)); const activeEdges = allEdges(db).filter((edge) => activeIds.has(edge.fromId) && activeIds.has(edge.toId)); const built = assembleContext(db, { - tokenBudget: 0, + tokenBudget: config.recallTokenBudget, + contentMaxChars: config.contentMaxChars, activeNodes, activeEdges, recalledNodes: recalled.nodes, diff --git a/index.ts b/index.ts index a1093dd..6eb9798 100755 --- a/index.ts +++ b/index.ts @@ -366,7 +366,8 @@ const graphMemoryPlugin = { // ── 2. 图谱 + 溯源 ───────────────────────────── const { xml, systemPrompt, tokens: gmTokens, episodicXml, episodicTokens } = assembleContext(db, { - tokenBudget: 0, + tokenBudget: cfg.recallTokenBudget, + contentMaxChars: cfg.contentMaxChars, activeNodes, activeEdges, recalledNodes: rec.nodes, diff --git a/src/format/assemble.ts b/src/format/assemble.ts index 8d0aa78..b73e9c7 100755 --- a/src/format/assemble.ts +++ b/src/format/assemble.ts @@ -11,6 +11,65 @@ import { getCommunitySummary, getEpisodicMessages } from "../store/store.ts"; const CHARS_PER_TOKEN = 3; +/** + * 单节点 content 注入的最大字符数(0 = 不截断)。 + * 完整 content 始终可通过 gm_search 按需取回,注入端只需保留核心步骤/结论。 + */ +export const DEFAULT_CONTENT_MAX_CHARS = 400; + +/** 溯源(episodic)注入的收紧参数:top 节点数 / 单节点总字符 / 单条消息字符。 */ +const EPISODIC_TOP_NODES = 2; +const EPISODIC_MAX_CHARS = 300; +const EPISODIC_MESSAGE_CHARS = 150; + +/** XML 标签/属性等固定开销的粗略字符数,纳入节点 token 估算。 */ +const NODE_XML_OVERHEAD_CHARS = 80; + +/** + * 估算单节点注入后的 token 数(name + description + content + XML 固定开销)。 + */ +function estimateNodeTokens(n: GmNode): number { + return Math.ceil( + (n.name.length + n.description.length + n.content.length + NODE_XML_OVERHEAD_CHARS) / CHARS_PER_TOKEN, + ); +} + +/** + * 按字符上限截断 content 正文;截断时追加省略号以提示内容被裁剪。 + */ +function clipBody(content: string, maxChars: number): string { + const trimmed = content.trim(); + if (maxChars > 0 && trimmed.length > maxChars) { + return `${trimmed.slice(0, maxChars)}…`; + } + return trimmed; +} + +/** + * 去掉 content 首行与节点 name 重复的内容。 + * + * 提取模板让 content 首行以 `[name]`(或裸 name)开头,而 XML 标签已有 name 属性, + * 首行是纯冗余。去掉后零信息损失,且对存量节点同样生效(无需迁移数据)。 + */ +function stripLeadingName(content: string, name: string): string { + const lines = content.split("\n"); + if (!lines.length) return content; + const first = lines[0].trim(); + const bare = first.replace(/^\[|\]$/g, "").trim(); + if (first === name || bare === name) { + return lines.slice(1).join("\n").trim(); + } + return content; +} + +/** + * 生成节点正文:先去掉首行 name 冗余,再按上限截断,最后 XML 转义。 + */ +function renderBody(n: GmNode, contentMaxChars: number): string { + const stripped = stripLeadingName(n.content, n.name); + return escapeXml(clipBody(stripped, contentMaxChars)); +} + /** * 构建知识图谱的 system prompt 引导文字 */ @@ -33,8 +92,7 @@ export function buildSystemPromptAddition(params: { sections.push( "## Graph Memory — 知识图谱记忆", "", - "Below `` is your accumulated experience from past conversations.", - "It contains structured knowledge — NOT raw conversation history.", + "Below `` is your accumulated experience from past conversations — structured knowledge, NOT raw history.", "", `Current graph: ${skillCount} skills, ${eventCount} events, ${taskCount} tasks, ${edgeCount} relationships.`, ); @@ -42,8 +100,7 @@ export function buildSystemPromptAddition(params: { if (hasRecalled) { sections.push( "", - `**${recalledCount} nodes recalled from OTHER conversations** — these are proven solutions that worked before.`, - "Apply them directly when the current situation matches their trigger conditions.", + `**${recalledCount} nodes recalled from OTHER conversations** — proven solutions; apply directly when the situation matches their trigger conditions.`, ); } @@ -51,23 +108,18 @@ export function buildSystemPromptAddition(params: { "", "## Recalled context for this query", "", - "This is a context engine. The following was retrieved by semantic search for the current message:", + "Retrieved by semantic search for the current message:", "", - "- **``** — Trimmed conversation traces from sessions that produced the knowledge nodes, ordered by time.", + "- **``** — Trimmed conversation traces from sessions that produced the knowledge nodes.", "- **``** — Relevant triples (TASK/SKILL/EVENT) and edges, grouped by community.", - "- **Recent 5 turns** — Last turn in full, previous 4 turns as user+assistant text only.", "", - "Read this context first. Use `gm_search` only if insufficient. Use `gm_record` to save new knowledge.", + "Read this first. Use `gm_search` if insufficient, `gm_record` to save new knowledge.", ); if (isRich) { sections.push( "", - "**Graph navigation:** Edges show how knowledge connects:", - "- `SOLVED_BY`: an EVENT was fixed by a SKILL — apply the skill when you see similar errors", - "- `USED_SKILL`: a TASK used a SKILL — reuse the same approach for similar tasks", - "- `PATCHES`: a newer SKILL corrects an older one — prefer the newer version", - "- `CONFLICTS_WITH`: two SKILLs are mutually exclusive — check conditions before choosing", + "**Graph navigation:** `SOLVED_BY`=EVENT fixed by SKILL · `USED_SKILL`=TASK used SKILL · `PATCHES`=newer SKILL corrects older · `CONFLICTS_WITH`=mutually exclusive SKILLs.", ); } @@ -76,6 +128,9 @@ export function buildSystemPromptAddition(params: { /** * 组装知识图谱为 XML context + * + * tokenBudget > 0 时按预算贪心裁剪节点(已排序,优先级最高者先保留,至少保留 1 个); + * tokenBudget <= 0 时全量放入(向后兼容)。 */ export function assembleContext( db: DatabaseSyncInstance, @@ -85,9 +140,11 @@ export function assembleContext( activeEdges: GmEdge[]; recalledNodes: GmNode[]; recalledEdges: GmEdge[]; + contentMaxChars?: number; }, ): { xml: string | null; systemPrompt: string; tokens: number; episodicXml: string; episodicTokens: number } { - // recall 返回多少节点就放多少,不截断 + const contentMaxChars = params.contentMaxChars ?? DEFAULT_CONTENT_MAX_CHARS; + const map = new Map(); for (const n of params.recalledNodes) map.set(n.id, { ...n, src: "recalled" }); for (const n of params.activeNodes) map.set(n.id, { ...n, src: "active" }); @@ -103,8 +160,21 @@ export function assembleContext( b.pagerank - a.pagerank ); - // recall 返回的已经是 PPR 排序过的,全量放入 - const selected = sorted; + // ── 按 token 预算裁剪(0 = 不限制,向后兼容)──────────── + let selected = sorted; + if (params.tokenBudget > 0) { + const kept: typeof sorted = []; + let used = 0; + for (const n of sorted) { + const cost = estimateNodeTokens(n); + if (kept.length > 0 && used + cost > params.tokenBudget) break; + kept.push(n); + used += cost; + } + // 至少保留优先级最高的 1 个节点,即使单节点就超预算 + if (kept.length === 0 && sorted.length) kept.push(sorted[0]); + selected = kept; + } if (!selected.length) return { xml: null, systemPrompt: "", tokens: 0, episodicXml: "", episodicTokens: 0 }; @@ -140,8 +210,8 @@ export function assembleContext( for (const n of members) { const tag = n.type.toLowerCase(); const srcAttr = n.src === "recalled" ? ` source="recalled"` : ""; - const timeAttr = ` updated="${new Date(n.updatedAt).toISOString().slice(0, 10)}"`; - xmlParts.push(` <${tag} name="${n.name}" desc="${escapeXml(n.description)}"${srcAttr}${timeAttr}>\n${n.content.trim()}\n `); + const body = renderBody(n, contentMaxChars); + xmlParts.push(` <${tag} name="${n.name}" desc="${escapeXml(n.description)}"${srcAttr}>\n${body}\n `); } xmlParts.push(` `); } @@ -150,8 +220,8 @@ export function assembleContext( for (const n of noCommunity) { const tag = n.type.toLowerCase(); const srcAttr = n.src === "recalled" ? ` source="recalled"` : ""; - const timeAttr = ` updated="${new Date(n.updatedAt).toISOString().slice(0, 10)}"`; - xmlParts.push(` <${tag} name="${n.name}" desc="${escapeXml(n.description)}"${srcAttr}${timeAttr}>\n${n.content.trim()}\n `); + const body = renderBody(n, contentMaxChars); + xmlParts.push(` <${tag} name="${n.name}" desc="${escapeXml(n.description)}"${srcAttr}>\n${body}\n `); } const nodesXml = xmlParts.join("\n"); @@ -172,19 +242,19 @@ export function assembleContext( edgeCount: edges.length, }); - // ── 溯源选拉:PPR top 3 节点 → 拉原始 user/assistant 对话 ── - const topNodes = selected.slice(0, 3); + // ── 溯源选拉:PPR top N 节点 → 拉原始 user/assistant 对话 ── + const topNodes = selected.slice(0, EPISODIC_TOP_NODES); const episodicParts: string[] = []; for (const node of topNodes) { if (!node.sourceSessions?.length) continue; // 取最近的 2 个 session const recentSessions = node.sourceSessions.slice(-2); - const msgs = getEpisodicMessages(db, recentSessions, node.updatedAt, 500); + const msgs = getEpisodicMessages(db, recentSessions, node.updatedAt, EPISODIC_MAX_CHARS); if (!msgs.length) continue; const lines = msgs.map(m => - ` [${m.role.toUpperCase()}] ${escapeXml(m.text.slice(0, 200))}` + ` [${m.role.toUpperCase()}] ${escapeXml(m.text.slice(0, EPISODIC_MESSAGE_CHARS))}` ).join("\n"); episodicParts.push(` \n${lines}\n `); } @@ -205,4 +275,4 @@ export function assembleContext( function escapeXml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); -} \ No newline at end of file +} diff --git a/src/recaller/recall.ts b/src/recaller/recall.ts index df4a769..35e14f7 100755 --- a/src/recaller/recall.ts +++ b/src/recaller/recall.ts @@ -47,8 +47,8 @@ export class Recaller { const precise = await this.recallPrecise(query, limit); const generalized = await this.recallGeneralized(query, limit); - // ── 合并去重(全部保留,只去重复节点) ──────────────── - const merged = this.mergeResults(precise, generalized); + // ── 合并:精确路径优先,泛化路径只补精确未覆盖的社区,总量封顶 limit ── + const merged = this.mergeResults(precise, generalized, limit); return merged; } @@ -179,19 +179,30 @@ export class Recaller { } /** - * 合并两条路径的结果:全部保留,只去重复节点 + * 合并两条路径的结果:精确路径优先,泛化路径只补充精确路径未覆盖的社区,总量封顶 limit。 + * + * 之前两条路径各自跑满 limit 后全量合并,节点数可能翻倍;且泛化路径常补进与精确 + * 路径同社区的高度重复节点。现在泛化路径只在「目标节点属于精确路径未覆盖社区」时才 + * 补入,既保留跨领域概览,又避免同社区冗余,同时把总节点数稳定封顶在 limit。 */ - private mergeResults(precise: RecallResult, generalized: RecallResult): RecallResult { + private mergeResults(precise: RecallResult, generalized: RecallResult, limit: number): RecallResult { const nodeMap = new Map(); const edgeMap = new Map(); - // 精确路径全部入场 + // 精确路径全部入场(已按 PPR 排序并 slice 到 limit) for (const n of precise.nodes) nodeMap.set(n.id, n); for (const e of precise.edges) edgeMap.set(e.id, e); - // 泛化路径去重后全部入场 + // 泛化路径:只补精确路径未覆盖的社区,直到达到总配额 + const preciseCommunityIds = new Set( + precise.nodes.map(n => n.communityId).filter((cid): cid is string => Boolean(cid)), + ); for (const n of generalized.nodes) { - if (!nodeMap.has(n.id)) nodeMap.set(n.id, n); + if (nodeMap.size >= limit) break; + if (nodeMap.has(n.id)) continue; + // 泛化节点若属于精确路径已覆盖的社区,视为冗余,跳过 + if (n.communityId && preciseCommunityIds.has(n.communityId)) continue; + nodeMap.set(n.id, n); } // 合并边:两端都在最终节点集中的边才保留 diff --git a/src/types.ts b/src/types.ts index ab7639a..68856b9 100755 --- a/src/types.ts +++ b/src/types.ts @@ -130,6 +130,10 @@ export interface GmConfig { compactTurnCount: number; recallMaxNodes: number; recallMaxDepth: number; + /** 每轮召回注入的 token 预算(0 = 不限制,向后兼容) */ + recallTokenBudget: number; + /** 单节点 content 注入的最大字符数(0 = 不截断) */ + contentMaxChars: number; freshTailCount: number; embedding?: EmbeddingConfig; llm?: { @@ -152,6 +156,8 @@ export const DEFAULT_CONFIG: GmConfig = { compactTurnCount: 6, recallMaxNodes: 6, recallMaxDepth: 2, + recallTokenBudget: 2000, + contentMaxChars: 400, freshTailCount: 10, dedupThreshold: 0.90, pagerankDamping: 0.85, diff --git a/test/assemble.test.ts b/test/assemble.test.ts index 6550620..95fe314 100755 --- a/test/assemble.test.ts +++ b/test/assemble.test.ts @@ -108,7 +108,7 @@ describe("assembleContext", () => { expect(xml).toContain('source="recalled"'); }); - it("token 预算不截断节点(全量放入)", () => { + it("token 预算裁剪节点(至少保留 1 个)", () => { // 插入很多大节点 const nodes: GmNode[] = []; for (let i = 0; i < 20; i++) { @@ -119,20 +119,130 @@ describe("assembleContext", () => { nodes.push(findById(db, id)!); } - // 很小的 token 预算 + // 很小的 token 预算:单节点就超预算,仍应至少保留 1 个最高优先级节点 const { xml } = assembleContext(db, { - tokenBudget: 1000, // 1000 * 0.15 * 3 = 450 字符 + tokenBudget: 1000, activeNodes: nodes, activeEdges: [], recalledNodes: [], recalledEdges: [], }); - // 不应该包含所有 20 个节点 - if (xml) { - const matches = xml.match(/name="skill-/g); - expect(matches!.length).toBe(20); + const matches = xml!.match(/name="skill-/g); + expect(matches!.length).toBeGreaterThanOrEqual(1); + expect(matches!.length).toBeLessThan(20); + }); + + it("tokenBudget=0 时不裁剪(向后兼容)", () => { + const nodes: GmNode[] = []; + for (let i = 0; i < 10; i++) { + const id = insertNode(db, { name: `skill-${i}`, content: "y".repeat(100) }); + nodes.push(findById(db, id)!); } + + const { xml } = assembleContext(db, { + tokenBudget: 0, + activeNodes: nodes, + activeEdges: [], + recalledNodes: [], + recalledEdges: [], + }); + + const matches = xml!.match(/name="skill-/g); + expect(matches!.length).toBe(10); + }); + + it("预算裁剪优先保留 active + SKILL 节点", () => { + const activeSkill = findById(db, insertNode(db, { name: "active-skill", type: "SKILL" }))!; + const recalledEvent = findById(db, insertNode(db, { name: "recalled-event", type: "EVENT" }))!; + + // 预算只够一个节点 + const { xml } = assembleContext(db, { + tokenBudget: 20, // ~60 字符,只够一个节点 + activeNodes: [activeSkill], + activeEdges: [], + recalledNodes: [recalledEvent], + recalledEdges: [], + }); + + expect(xml).toContain('name="active-skill"'); + expect(xml).not.toContain('name="recalled-event"'); + }); + + it("content 超限时被截断并追加省略号", () => { + const id = insertNode(db, { name: "long-skill", content: "A".repeat(1000) }); + const node = findById(db, id)!; + + const { xml } = assembleContext(db, { + tokenBudget: 128_000, + contentMaxChars: 100, + activeNodes: [node], + activeEdges: [], + recalledNodes: [], + recalledEdges: [], + }); + + expect(xml).toContain("A".repeat(100) + "…"); + expect(xml).not.toContain("A".repeat(101)); + }); + + it("content 未超限时完整保留", () => { + const id = insertNode(db, { name: "short-skill", content: "short content" }); + const node = findById(db, id)!; + + const { xml } = assembleContext(db, { + tokenBudget: 128_000, + contentMaxChars: 100, + activeNodes: [node], + activeEdges: [], + recalledNodes: [], + recalledEdges: [], + }); + + expect(xml).toContain("short content"); + }); + + it("content 首行重复 name 时被去掉", () => { + // 提取模板让 content 首行以 name 开头,XML 标签已有 name 属性,首行冗余 + const id = insertNode(db, { + name: "dedupe-skill", + content: "dedupe-skill\n触发条件: ...\n执行步骤:\n1. 步骤一", + }); + const node = findById(db, id)!; + + const { xml } = assembleContext(db, { + tokenBudget: 128_000, + contentMaxChars: 1000, + activeNodes: [node], + activeEdges: [], + recalledNodes: [], + recalledEdges: [], + }); + + // name 属性仍在,但正文首行不再重复 name + expect(xml).toContain('name="dedupe-skill"'); + expect(xml).toContain("触发条件"); + expect(xml).not.toContain(">dedupe-skill\n触发条件"); // 首行 name 已被剥离 + }); + + it("content 首行带方括号的 name 也被去掉", () => { + const id = insertNode(db, { + name: "bracket-skill", + content: "[bracket-skill]\n目标: 完成某事", + }); + const node = findById(db, id)!; + + const { xml } = assembleContext(db, { + tokenBudget: 128_000, + contentMaxChars: 1000, + activeNodes: [node], + activeEdges: [], + recalledNodes: [], + recalledEdges: [], + }); + + expect(xml).toContain("目标: 完成某事"); + expect(xml).not.toContain("[bracket-skill]"); }); }); diff --git a/test/recall-community.test.ts b/test/recall-community.test.ts index 4983cb7..e890883 100755 --- a/test/recall-community.test.ts +++ b/test/recall-community.test.ts @@ -205,7 +205,7 @@ describe("assemble 社区分组", () => { expect(xml).toContain(""); }); - it("节点输出带 updated 时间属性", () => { + it("节点输出不含冗余 updated 时间戳(token 优化)", () => { const a = insertNode(db, { name: "test-skill", type: "SKILL" }); const node = findById(db, a)!; @@ -217,8 +217,8 @@ describe("assemble 社区分组", () => { recalledEdges: [], }); - // 应该包含 updated="YYYY-MM-DD" 格式 - expect(xml).toMatch(/updated="\d{4}-\d{2}-\d{2}"/); + // updated 时间戳对召回决策无价值,已移除以节省 token + expect(xml).not.toContain("updated="); }); it("无社区的节点放顶层", () => { diff --git a/test/recall-merge.test.ts b/test/recall-merge.test.ts new file mode 100644 index 0000000..6f43e1e --- /dev/null +++ b/test/recall-merge.test.ts @@ -0,0 +1,96 @@ +/** + * graph-memory — 双路径召回合并配额测试 + * + * By: adoresever + * + * 验证 mergeResults 的配额语义:精确路径优先,泛化路径只补精确路径未覆盖的 + * 社区,且总节点数封顶 limit —— 避免双路径全量合并导致节点数翻倍。 + */ + +import { beforeEach, describe, expect, it } from "vitest"; +import type { DatabaseSyncInstance } from "@photostructure/sqlite"; + +import { Recaller } from "../src/recaller/recall.ts"; +import { DEFAULT_CONFIG, type GmNode, type RecallResult } from "../src/types.ts"; +import { createTestDb, insertNode } from "./helpers.ts"; +import { findById } from "../src/store/store.ts"; + +let db: DatabaseSyncInstance; + +beforeEach(() => { + db = createTestDb(); +}); + +function nodeWithCommunity(name: string, communityId: string | null): GmNode { + const id = insertNode(db, { name }); + if (communityId) { + db.prepare("UPDATE gm_nodes SET community_id=? WHERE id=?").run(communityId, id); + } + return findById(db, id)!; +} + +function result(nodes: GmNode[]): RecallResult { + return { nodes, edges: [], tokenEstimate: 0 }; +} + +// mergeResults 是 private,测试通过运行时访问覆盖其配额逻辑。 +function merge(recaller: Recaller, precise: RecallResult, generalized: RecallResult, limit: number) { + return (recaller as any).mergeResults(precise, generalized, limit) as RecallResult; +} + +describe("mergeResults 配额", () => { + it("精确路径满 limit 时泛化不补入", () => { + const recaller = new Recaller(db, DEFAULT_CONFIG); + const preciseNodes = Array.from({ length: 6 }, (_, i) => nodeWithCommunity(`p-${i}`, `c-p-${i}`)); + const genNodes = [nodeWithCommunity("g-0", "c-g-0"), nodeWithCommunity("g-1", "c-g-1")]; + + const merged = merge(recaller, result(preciseNodes), result(genNodes), 6); + + expect(merged.nodes).toHaveLength(6); + const names = merged.nodes.map((n: GmNode) => n.name); + expect(names).not.toContain("g-0"); + expect(names).not.toContain("g-1"); + }); + + it("泛化只补精确路径未覆盖的社区", () => { + const recaller = new Recaller(db, DEFAULT_CONFIG); + const precise = [nodeWithCommunity("p-0", "c-1")]; + const gen = [ + nodeWithCommunity("g-same-comm", "c-1"), // 同社区 → 冗余,跳过 + nodeWithCommunity("g-new-comm", "c-2"), // 新社区 → 补入 + ]; + + const merged = merge(recaller, result(precise), result(gen), 6); + + const names = merged.nodes.map((n: GmNode) => n.name); + expect(names).toContain("g-new-comm"); + expect(names).not.toContain("g-same-comm"); + expect(merged.nodes).toHaveLength(2); + }); + + it("泛化补入受总配额封顶", () => { + const recaller = new Recaller(db, DEFAULT_CONFIG); + const precise = Array.from({ length: 4 }, (_, i) => nodeWithCommunity(`p-${i}`, `c-p-${i}`)); + const gen = [ + nodeWithCommunity("g-0", "c-g-0"), + nodeWithCommunity("g-1", "c-g-1"), + nodeWithCommunity("g-2", "c-g-2"), + ]; + + const merged = merge(recaller, result(precise), result(gen), 6); + + // 精确已 4 个,泛化最多补 2 个到 limit=6 + expect(merged.nodes).toHaveLength(6); + }); + + it("泛化无社区节点仍可补入", () => { + const recaller = new Recaller(db, DEFAULT_CONFIG); + const precise = [nodeWithCommunity("p-0", "c-1")]; + const gen = [nodeWithCommunity("g-no-comm", null)]; // 无社区节点 + + const merged = merge(recaller, result(precise), result(gen), 6); + + const names = merged.nodes.map((n: GmNode) => n.name); + expect(names).toContain("g-no-comm"); + }); +});