// 知识图谱(KG)的结构保证 —— 2026-07-18 建图那一刀立的。 // // 这个文件守六件事(重要性从高到低): // ①【无悬空边】每条边的 from/to 都必须是真实存在的节点 id —— 物化边【和】按需现算的推演边 // 都不许悬空。dangling edge = 图坏了。这是最重要的一条。 // ②【带出处】每条边都有 provenance;【原文边】(关系)的 cite/juan 与 guanxi 源【逐字一致】。 // ③【推演/原文分开】同僚/继任标 kind:'推演'(且【不物化】),guanxi 具名对标 kind:'原文', // 隐主语(角色施动)标 kind:'推演' —— 三者不混。 // ④【派生非硬编码】节点/边来自源模块的【引用】,不是 kg.js 里抄来的史料常量 // (抽查:node.raw === 源对象本身;kg.js 文本里没有史料名/论赞常量)。 // ⑤【消歧不假合并】该合的合(官职前缀/guan 佐证展开)、该分的分(单字名 ambiguous、隐主语作 // 角色、跨朝同名不焊)—— 拿不准宁可两个节点,绝不把同名异人焊成一个。 // ⑥【kgStats 数量合理】帝/臣/事/制/朝各 >0,边 >0,且实跑数(不写死)。 // // 跑:node --test test/kg.test.js import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { buildKG, kgStats, kgEntity, kgNeighbors, kgSearch, kgPath, kgSubgraph, kgEgo, } from '../src/kg.js'; import { getChao, allChao } from '../src/tianxia.js'; import { GUANXI } from '../src/guanxi.js'; const kg = buildKG(); const ids = new Set(kg.nodes.keys()); // ══════════════════════════════════════════════════════════════════════════ // ① 无悬空边 —— 最重要 // ══════════════════════════════════════════════════════════════════════════ test('★★★ 无悬空边:每条【物化】边的 from/to 都是真实节点', () => { assert.ok(kg.edges.length > 0, '一条物化边都没有 —— 图是空的'); for (const e of kg.edges) { assert.ok(ids.has(e.from), `悬空 from:${e.from}(${e.type})—— 图坏了`); assert.ok(ids.has(e.to), `悬空 to:${e.to}(${e.type})—— 图坏了`); } }); test('★★★ 无悬空边:按需现算的【推演】边(同僚/继任)也不许悬空', () => { // 每朝取前 30 个官节点抽查(覆盖所有朝,且时间可控)。推演边只连本朝已存在的官节点, // 一旦有 bug 造出连到不存在 id 的边,这里当场红。 const perChao = new Map(); for (const n of kg.nodes.values()) { if (n.type !== 'official') continue; const arr = perChao.get(n.dynasty) ?? []; if (arr.length < 30) { arr.push(n); perChao.set(n.dynasty, arr); } } let seenTuiyan = 0, checkedNodes = 0; for (const arr of perChao.values()) { for (const n of arr) { checkedNodes++; const ent = kgEntity(n.id); for (const e of ent.edges) { assert.ok(ids.has(e.from), `推演悬空 from:${e.from}(${e.type})`); assert.ok(ids.has(e.to), `推演悬空 to:${e.to}(${e.type})`); if (e.kind === '推演') seenTuiyan++; } } } assert.ok(checkedNodes > 200, `抽查的官节点太少(${checkedNodes})`); assert.ok(seenTuiyan > 500, `抽查里几乎没见到推演边(${seenTuiyan}) —— 现算没在工作`); }); // ══════════════════════════════════════════════════════════════════════════ // ② 带出处 + 原文边 cite 逐字 // ══════════════════════════════════════════════════════════════════════════ test('★★ 带出处:每条物化边都有 provenance', () => { for (const e of kg.edges) { assert.ok(e.provenance && typeof e.provenance === 'object', `${e.type} 边缺 provenance`); assert.ok(typeof e.provenance.module === 'string', `${e.type} 边的 provenance 缺 module`); } }); test('★★★ 原文边逐字:每条【关系】边的 cite/juan/verb 与 guanxi 源逐字一致(KG 不改一字)', () => { // 源侧建索引:juan|verb|cite 三元组集合。关系边的这三样必须命中其中一条。 const src = new Set(); for (const rows of Object.values(GUANXI)) { for (const r of rows) if (r.cite) src.add(`${r.juan}${r.verb}${r.cite}`); } const rel = kg.edges.filter((e) => e.type === '关系'); assert.ok(rel.length > 0, '一条关系边都没有'); for (const e of rel) { const key = `${e.provenance.juan}${e.verb}${e.provenance.cite}`; assert.ok(src.has(key), `关系边的 cite/juan/verb 不是 guanxi 逐字原文 —— KG 改写了出处:\n ${key}`); } }); // ══════════════════════════════════════════════════════════════════════════ // ③ 推演 / 原文 分开 // ══════════════════════════════════════════════════════════════════════════ test('★★★ 同僚/继任【不物化】—— 只在查询时现算,且一律 kind:推演', () => { // 物化边里绝不该出现同僚/继任(它们是两万+六千条推演,物化=凭空堆「像史实」的边)。 assert.ok(!kg.edges.some((e) => e.type === '同僚' || e.type === '继任'), '同僚/继任 被物化了 —— 违背「不凭空堆推演边」的红线'); // 现算出来的必须是推演。 const ent = kgEntity('off:xihan:錯'); const tl = ent.edges.filter((e) => e.type === '同僚'); const jr = ent.edges.filter((e) => e.type === '继任'); assert.ok(tl.length > 0, '晁錯 一条同僚都没现算出来'); assert.ok(jr.length > 0, '晁錯 一条继任都没现算出来'); assert.ok(tl.every((e) => e.kind === '推演' && e.provenance.deriv === 'tongliao'), '同僚必须 kind:推演 + deriv:tongliao'); assert.ok(jr.every((e) => e.kind === '推演' && e.provenance.deriv === 'guanChain'), '继任必须 kind:推演 + deriv:guanChain'); }); test('★★★ 关系边:guanxi 具名对=原文;隐主语(角色施动)=推演 —— 不混', () => { for (const e of kg.edges.filter((e) => e.type === '关系')) { if (e.from.startsWith('role:')) { assert.equal(e.kind, '推演', `隐主语施动边必须推演:${e.from}→${e.to}`); assert.equal(e.provenance.deriv, 'actorImplied', '隐主语边必须标 deriv:actorImplied'); } else { assert.equal(e.kind, '原文', `具名对必须原文:${e.from}→${e.to}`); } } // 论赞是原文(挂在帝节点上,体例原样带出)。 const gaodi = [...kg.nodes.values()].find((n) => n.type === 'emperor' && n.dynasty === 'xihan' && n.shi === '高皇帝'); assert.ok(gaodi?.dizan?.zan && gaodi.dizan.provenance.module === 'dizan', '高帝论赞应从 dizan 派生'); }); // ══════════════════════════════════════════════════════════════════════════ // ④ 派生非硬编码 // ══════════════════════════════════════════════════════════════════════════ test('★★★ 派生非硬编码:节点 raw 是【源对象本身的引用】,不是拷贝/字面量', () => { const c = getChao('xihan'); // 帝节点 raw 必须 === chao 源 di 数组里的那个对象。 const emp = [...kg.nodes.values()].find((n) => n.type === 'emperor' && n.dynasty === 'xihan' && n.shi === '高皇帝'); assert.ok(emp, '找不到西汉高帝节点'); assert.ok(c.di.includes(emp.raw), '帝节点 raw 不是 chao 源对象 —— 说明是抄进来的,不是派生'); // 官节点 raw 必须是 chao 源 chen 行的引用。 const off = kg.nodes.get('off:xihan:錯'); assert.ok(off && off.raw.length > 0 && off.raw.every((r) => c.chen.includes(r)), '官节点 raw 不是 chen 源行 —— 不是派生'); }); test('★★★ 派生非硬编码:kg.js 的【代码】里没有史料名/论赞常量(抽查,剥掉注释)', () => { const raw = fs.readFileSync(new URL('../src/kg.js', import.meta.url), 'utf8'); // ★ 只扫【代码】不扫注释:注释里用「晁錯/御史大夫」举例讲消歧规则是文档,不是数据。 // 验的是「节点/边的内容不是抄进 kg.js 的常量」—— 剥掉 /* */ 与 // 注释后,代码必须干净。 // (kg.js 无含 // 或 /* 的字符串字面量,故此剥法安全。) const code = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1'); for (const needle of ['晁錯', '劉邦', '贊曰', '周亞夫', '安祿山', '胡惟庸', '御史大夫', '周苛', '朝廷']) { assert.ok(!code.includes(needle), `kg.js 的代码里出现了史料常量「${needle}」—— 节点/边必须从源模块运行时派生,不许抄进来`); } }); // ══════════════════════════════════════════════════════════════════════════ // ⑤ 消歧不假合并 —— 该合的合、该分的分 // ══════════════════════════════════════════════════════════════════════════ test('★★★ 消歧·该合:guanxi「御史大夫晁錯」并入群臣「御史大夫錯」(guan 佐证的唯一展开)', () => { // 群臣里晁错记作单字「錯」+guan 御史大夫;guanxi 记作「晁錯」+同 guan ⇒ 唯一展开 ⇒ 合并。 const cuo = kg.nodes.get('off:xihan:錯'); assert.ok(cuo, '晁錯 应并入群臣「錯」节点,而不是另起炉灶'); assert.ok([...cuo.sources].includes('chen') && [...cuo.sources].includes('guanxi'), '該合:一个节点同时挂着 chen 与 guanxi 两个来源'); assert.ok((cuo.expandedFrom ?? []).some((x) => x.guanxiName === '晁錯' && x.via.includes('御史大夫')), '必须留痕:这是【据 guan 佐证】展开合并的,不是瞎并'); assert.ok([...cuo.aliases].some((a) => a.includes('晁錯')), 'guanxi 的全名留作 alias'); }); test('★★ 消歧·该合:官职前缀由源剥离,节点名不含前缀(「內史周苛」→「周苛」)', () => { const zk = kg.nodes.get('off:xihan:周苛'); assert.ok(zk && zk.name === '周苛', '节点名应是剥去官职前缀的「周苛」'); assert.ok([...zk.aliases].some((a) => a.startsWith('內史')), 'renRaw 原文形(內史周苛)留作 alias,不丢'); }); test('★★★ 消歧·该分:单字 guanxi actor「濞」无 guan 佐证 ⇒ ambiguous,绝不焊进无关官员', () => { const bi = kg.nodes.get('off:xihan:濞'); assert.ok(bi, '濞 应作为独立节点存在(不因是单字就丢弃)'); assert.equal(bi.ambiguous, true, '单字名 + 无 guan 佐证 → 必须标 ambiguous(拿不准不合并)'); assert.ok(bi.flags.singleCharName && bi.flags.guanxiOnly, '标记单字名 + 仅 guanxi 来源'); assert.ok(![...bi.sources].includes('chen'), '★ 濞 未与任何群臣官员焊合 —— 那才是最坏的伪造'); }); test('★★★ 消歧·隐主语:朝廷/太子 作【角色占位节点】,永不当具名人合并', () => { const role = kg.nodes.get('role:xihan:朝廷'); assert.ok(role && role.type === 'role', '隐主语「朝廷」必须是 role 节点,不是 official'); const taizi = kg.nodes.get('role:xihan:太子'); assert.ok(taizi && taizi.type === 'role', '隐主语「太子」同理'); // 角色节点绝不并进人节点:不存在 off:xihan:朝廷 assert.ok(!kg.nodes.has('off:xihan:朝廷'), '朝廷 不许变成一个「臣」节点'); }); test('★★ 消歧·跨朝同名不硬合并:身份作用域=(规范名,朝[,年份簇])', () => { const byName = new Map(); for (const n of kg.nodes.values()) { if (n.type !== 'official') continue; (byName.get(n.normName) ?? byName.set(n.normName, []).get(n.normName)).push(n); } // 找一个跨 ≥2 朝的同名,断言它们是【不同节点】,而不是被焊成一个。 // ★ 现在身份作用域含【年份簇】:同一朝内一个名也可能因年份跨度拆成多簇多节点, // 所以不再断言「每朝恰一个」,而断言:① 每个节点只属于一个朝(id 以 off:朝: 起头,绝不跨朝焊) // ② id 各不相同 ③ 覆盖 ≥2 朝 ④ 节点数 ≥ 朝数(clusters 只会更多不会更少)。 let cross = null; for (const [nm, list] of byName) { const chaos = new Set(list.map((x) => x.dynasty)); if (chaos.size > 1) { cross = { nm, list, chaos }; break; } } assert.ok(cross, '数据里应存在跨朝同名(用来验证不硬合并)'); assert.ok(cross.chaos.size >= 2, `「${cross.nm}」应跨 ≥2 朝`); for (const n of cross.list) { assert.ok(n.id.startsWith(`off:${n.dynasty}:`), `「${cross.nm}」节点 ${n.id} 必须只归一个朝(${n.dynasty})—— 跨朝绝不焊`); } const idSet = new Set(cross.list.map((x) => x.id)); assert.equal(idSet.size, cross.list.length, '跨朝同名节点 id 必须各不相同'); assert.ok(cross.list.length >= cross.chaos.size, `「${cross.nm}」节点数(${cross.list.length}) 应 ≥ 朝数(${cross.chaos.size}):拆簇只增不减`); }); // ══════════════════════════════════════════════════════════════════════════ // ⑤+ 消歧新守则(同名异人拆簇 / 非人名过滤 / 自我继任抑制)—— 图完整性对抗那一刀补的 // ══════════════════════════════════════════════════════════════════════════ test('★★★ 消歧·该分:同名异人按【年份跨度】拆簇 —— 陳平(薨BC178) 与 執金吾平(BC50) 不焊一个', () => { const pings = [...kg.nodes.values()].filter((n) => n.type === 'official' && n.dynasty === 'xihan' && n.normName === '平'); assert.ok(pings.length >= 2, `「平」应因 128 年跨度拆成 ≥2 个节点,实得 ${pings.length}`); assert.ok(!kg.nodes.has('off:xihan:平'), '旧的「无条件焊合」节点 off:xihan:平 不该再存在(已按簇拆开)'); const chenPing = pings.find((n) => n.years.has(-178)); // 陳平:右/左丞相→丞相薨 const zhiJinWuPing = pings.find((n) => n.years.has(-50)); // 執金吾平 assert.ok(chenPing && zhiJinWuPing && chenPing.id !== zhiJinWuPing.id, '陳平 与 執金吾平 必须是两个不同节点'); assert.ok(!chenPing.years.has(-50) && !zhiJinWuPing.years.has(-178), '两簇的年份不许交叉污染'); assert.equal(chenPing.ambiguous, true, '被拆多簇的同名节点应标 ambiguous'); assert.ok((chenPing.ambiguousWhy ?? []).some((w) => w.includes('簇')), 'ambiguousWhy 应说明是按年份跨度拆簇'); // 嘉/光/根 同理都拆开(跨 >100 年) for (const nm of ['嘉', '光', '根']) { const ns = [...kg.nodes.values()].filter((n) => n.type === 'official' && n.dynasty === 'xihan' && n.normName === nm); assert.ok(ns.length >= 2, `「${nm}」跨大年份应拆成 ≥2 个节点,实得 ${ns.length}`); } }); test('★★★ 消歧·非人名过滤:署理/兼任虚词(兼/同/權/檢)不建人节点,真名(水衡都尉守)保留', () => { for (const bad of ['off:tang:兼', 'off:tang:同', 'off:tang:權', 'off:tang:檢']) { assert.ok(!kg.nodes.has(bad), `「${bad}」是源解析残渣(虚词),不该建人节点`); } // parseSkipped 诚实报出(tang 兼11+同13+權2+檢1 = 27) const s = kgStats(); assert.ok(s.parseSkipped.total > 0, 'parseSkipped 应 >0(诚实报出被跳过的非人名残渣)'); // ★ 红线:绝不误删真人 —— 「水衡都尉守」「內史充」是裸任在职「官名+名」,是真名,必须保留。 assert.ok(kg.nodes.has('off:xihan:守'), '「水衡都尉守」是真名(官名+名),不许被虚词过滤误删'); assert.ok(kg.nodes.has('off:xihan:充'), '「內史充」同理,必须保留'); }); test('★★★ 继任守则④:两端同名/异写(错⊂朝错、淩⊂王淩)不产「自己接自己」的伪继任边', () => { // 御史大夫链:朝錯(-155)→錯(-154)。錯 是晁錯单字,朝錯是其异写,同一人 ⇒ 不产 朝錯↔錯 继任边。 const ji = kgEntity('off:xihan:錯').edges.filter((e) => e.type === '继任'); assert.ok(!ji.some((e) => (e.from === 'off:xihan:朝錯' && e.to === 'off:xihan:錯') || (e.from === 'off:xihan:錯' && e.to === 'off:xihan:朝錯')), '朝錯↔錯 是同一人异写,不该有继任边(自己接自己无意义)'); // 但错的真实继任(→介)仍在,守则不是把继任关掉。 assert.ok(ji.some((e) => e.to === 'off:xihan:介' || e.from === 'off:xihan:介'), '錯→介 是真实异任,应保留'); // 全图不存在 from===to 的自继任边(抽查 wei 王淩:太尉 249/251 同名同位,不自接)。 if (kg.nodes.has('off:wei:王淩')) { const self = kgEntity('off:wei:王淩').edges.filter((e) => e.type === '继任' && e.from === e.to); assert.equal(self.length, 0, 'wei 王淩 不该有 from===to 的自继任边'); } }); // ══════════════════════════════════════════════════════════════════════════ // ⑤++ 完整性:事件不再是孤岛 —— 每个事至少连本朝 // ══════════════════════════════════════════════════════════════════════════ test('★★★ 完整性·事件不孤岛:每个事件节点都有一条【发生于→本朝】的归属边(kgEgo/kgPath 走得通)', () => { let events = 0, islands = 0; for (const n of kg.nodes.values()) { if (n.type !== 'event') continue; events++; const a = kg.adj.get(n.id) ?? { out: [], in: [] }; if (a.out.length + a.in.length === 0) islands++; // 每个事件必须有一条 发生于 归属边指向本朝 const toChao = a.out.find((e) => e.type === '发生于' && e.kind === '归属' && e.to === `chao:${n.dynasty}`); assert.ok(toChao, `事件 ${n.id} 缺【发生于→chao:${n.dynasty}】归属边 —— 仍是孤岛`); } assert.ok(events > 800, `事件节点数应可观,实得 ${events}`); assert.equal(islands, 0, `不该再有任何事件孤岛,实得 ${islands}`); // 发生于边不许悬空、且 provenance 派生自 shi const fs = kg.edges.filter((e) => e.type === '发生于'); assert.equal(fs.length, events, '发生于边数应等于事件数(每事一条)'); assert.ok(fs.every((e) => ids.has(e.from) && ids.has(e.to) && e.provenance.deriv === 'shiToChao'), '发生于边应无悬空且标 deriv:shiToChao'); }); test('★★★ 事件投影暴露 layer/impact:派生自 raw,无 impact 的事件不加该字段', () => { let events = 0, withLayer = 0, withImpact = 0; for (const n of kg.nodes.values()) { if (n.type !== 'event') continue; events++; const proj = kgEntity(n.id).node; // layer 必须逐字来自源 shi 条目,不是 KG 现编的 if (n.raw?.layer != null) { assert.equal(proj.layer, n.raw.layer, `事件 ${n.id} 的 layer 应逐字派生自 raw`); withLayer++; } else { assert.ok(!('layer' in proj), `事件 ${n.id} 源无 layer,投影不该凭空加`); } // impact 同理;源没有就【不能】出现该字段(向后兼容) if (n.raw?.impact != null) { assert.equal(proj.impact, n.raw.impact, `事件 ${n.id} 的 impact 应是源对象本身`); withImpact++; } else { assert.ok(!('impact' in proj), `事件 ${n.id} 源无 impact,投影不该出现 impact 字段`); } } assert.equal(withLayer, events, `每个事件都应带 layer,实得 ${withLayer}/${events}`); assert.ok(withImpact > 0, '应有事件带 impact'); assert.ok(withImpact < events, 'impact 是稀疏字段,不该全量覆盖'); // 合法取值域:layer 只能是 shi/zhi/dao for (const n of kg.nodes.values()) { if (n.type !== 'event') continue; assert.ok(['shi', 'zhi', 'dao'].includes(kgEntity(n.id).node.layer), `事件 ${n.id} 的 layer 取值非法`); } }); // ══════════════════════════════════════════════════════════════════════════ // ⑥ kgStats 数量合理(实跑) // ══════════════════════════════════════════════════════════════════════════ test('★★ kgStats:帝/臣/事/制/朝各 >0,边 >0,数量合理', () => { const s = kgStats(); for (const t of ['dynasty', 'emperor', 'official', 'event', 'institution']) { assert.ok(s.nodes.byType[t] > 0, `节点类型 ${t} 应 >0`); } assert.equal(s.nodes.byType.dynasty, 27, '朝应为 27(qin…ming + 西夏 + 元/清,2026-07-30 元清双证接入天下)'); assert.ok(s.edgesMaterialized.total > 0, '物化边应 >0'); assert.ok(s.edgesMaterialized.byType['君临'] > 0 && s.edgesMaterialized.byType['仕于'] > 0 && s.edgesMaterialized.byType['关系'] > 0 && s.edgesMaterialized.byType['行于'] > 0, '各类物化边应 >0'); assert.ok(s.edgesMaterialized.byType['发生于'] > 0, '事→朝 归属边(发生于)应 >0(事件不再孤岛)'); assert.ok(s.edgesDerived.同僚 > 1000 && s.edgesDerived.继任 > 1000, '推演边(同僚/继任)实算总数应显著 >0'); assert.ok(s.ambiguous.total > 0, '应有若干 ambiguous 节点(消歧保守的证据)'); assert.ok(s.unnamedMentions.total > 0, '应诚实报出源未定规范名的群臣行数'); assert.ok(s.parseSkipped.total > 0, '应诚实报出被跳过的非人名残渣(署理/兼任虚词)'); }); // ══════════════════════════════════════════════════════════════════════════ // 查询 API 冒烟 // ══════════════════════════════════════════════════════════════════════════ test('★ 查询 API:search / entity / neighbors / path / subgraph / ego 各自可用', () => { // search:按 alias 命中晁錯 → 群臣「錯」节点 const found = kgSearch('晁錯'); assert.ok(found.some((r) => r.id === 'off:xihan:錯'), 'search「晁錯」应命中 off:xihan:錯'); // entity:分组 + 计数 const ent = kgEntity('off:xihan:錯'); assert.ok(ent.counts.total > 0 && ent.byType['关系'], 'entity 应返回分组的边'); // neighbors:只要原文边(保守视图) const yuanwen = kgNeighbors('off:xihan:錯', { kinds: ['原文'] }); assert.ok(Array.isArray(yuanwen) && yuanwen.every((x) => x.edge.kind === '原文'), 'kinds=原文 应只回原文边'); // path:錯 ↔ 周亞夫(同朝有关联) const p = kgPath('off:xihan:錯', 'off:xihan:周亞夫', { maxHops: 3 }); assert.ok(p.found, '晁錯 与 周亞夫 之间应有一条路径(哪怕经同僚)'); // subgraph:xihan -154 = 同僚图谱那一刀(应含錯/事/角色) const sg = kgSubgraph('xihan', { year: -154 }); assert.ok(sg.nodes.some((n) => n.id === 'off:xihan:錯'), 'subgraph -154 应含晁錯'); assert.ok(sg.nodes.some((n) => n.type === 'event'), 'subgraph -154 应含当年大事'); assert.ok(sg.edges.length > 0, 'subgraph 应有边'); // ego:中心牵连网 const ego = kgEgo('off:xihan:錯', { depth: 1 }); assert.ok(ego.counts.nodes > 1 && ego.counts.edges > 0, 'ego 网应非空'); assert.ok(ego.counts.推演 >= 0 && ego.counts.原文 >= 0, 'ego 计数按 kind 分'); }); test('★ 全部 25 朝都有帝节点接到朝节点(广度:秦到明一个不少)', () => { for (const c of allChao()) { const chaoNode = kg.nodes.get(`chao:${c.code}`); assert.ok(chaoNode, `缺朝节点 ${c.code}`); // pending 的朝(如无帝纪)允许无帝,但节点必须在(「没抠≠没有」)。 if (!c.pending && c.di.length > 0) { const hasEmp = kg.edges.some((e) => e.type === '君临' && e.to === `chao:${c.code}`); assert.ok(hasEmp, `${c.code} 有帝表却没有君临边`); } } });