Files
digital-me-history/digital-me-backend/test/kg.test.js
T
admin 0593ff7e62
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
数字我 Digital Me:全量入库(大文件走 Git LFS)
2026-10-10 16:24:43 +08:00

378 lines
25 KiB
JavaScript
Raw 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.
// 知识图谱(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} 有帝表却没有君临边`);
}
}
});