数字我 Digital Me:全量入库(大文件走 Git LFS)
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
nginx serve /uploads direct (audio speed) / patch-nginx (push) Failing after 1m34s
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
// 知识图谱(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} 有帝表却没有君临边`);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user