Files
digital-me-history/digital-me-backend/test/dynasties-shape.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

1337 lines
99 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.
// 【形状】的单测 —— 2026-07-16 第二刀(删加权求和)立的。
//
// 这个文件守三件事,重要性从高到低:
// ①【变化审计】这一刀【必须】让输出变(七朝原文里没有加权求和的原型)。所以这里【不是迁移锁】:
// 它拿旧引擎的 1128 个 profile × 七朝跑新旧两版,把【每一处变化】归进一个说得出依据的类,
// 而「我说不清为什么变的」那一类【必须为空】。★ 这一条就是这一刀的验收。
// ②【形状判别式】数据里不许再有加权求和,也不许换个名字回来。扫的是【数据】,不是注释。
// ③【原文复现】熙宁武举那一段原文,新引擎必须逐档复现出来。
// ★ 这是全表唯一一处「通过计数 → 位阶」有原文原型的地方,它绿着,这一刀才站得住。
//
// ⚠ 本文件【取代了】 dynasties-tracks.test.js 里那两条「迁移锁」。为什么取代、凭什么取代,
// 写在那边的注里。一句话:迁移锁证的是「输出没变」,而这一刀的任务【就是让它变】——
// 拿一把证「没变」的尺子来验一刀「必须变」的活,尺子本身就用错了。
// ★ 但【覆盖面只增不减】:迁移锁钉的是五朝的指纹;本文件钉的是【七朝 × 1128 个 profile 的
// 每一格】,而且每一格的差异都必须说得出依据。
//
// 跑:node --test test/dynasties-shape.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { DYNASTIES, computeRank, getDynasty, profileFromSubs, tracksOf } from '../src/dynasties.js';
import { SUB_REGISTRY } from '../src/talents.js';
import { closedBy } from '../src/chushen.js';
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
// 【加权求和版】引擎的产出 —— 这一刀的「被告」。采集脚本:scripts/capture-weighted-sum.mjs
const OLD = JSON.parse(fs.readFileSync(new URL('./fixtures/dynasties-weighted-sum.json', import.meta.url), 'utf8'));
// 多轨化【之前】旧代码采的那份(含 5 朝的【完整】输出样本)—— 用来交叉验证上面那份是真的。
const PRE = JSON.parse(fs.readFileSync(new URL('./fixtures/dynasties-pre-multitrack.json', import.meta.url), 'utf8'));
// ⚠ 必须与 scripts/capture-weighted-sum.mjs 的 proj() 【逐字相同】,否则新旧对照对的不是同一批格子。
const proj = (r) => (r === null ? null : {
ch: r.channel ? r.channel.id : null,
tier: r.rank.tier,
title: r.rank.title,
grade: r.rank.grade,
unit: r.rank.unit,
ceil: r.ceiling ? r.ceiling.tier : null,
cat: r.category || '',
stig: !!r.stigma,
blk: r.blocked.length,
});
const profileOf = (key) => {
const [subsRaw, wRaw] = key.split('|');
return profileFromSubs(subsRaw === '∅' ? [] : subsRaw.split('+'), { wang: Number(wRaw.slice(1)) });
};
const chOf = (dynId, chId) => getDynasty(dynId).channels.find((c) => c.id === chId) || null;
const xuanOf = (c) => (c && c.xuan) || { seat: 0, kemu: [], key: [], keySource: null };
// ══════════════════════════════════════════════════════════════════════════
// ① 自证:那份「旧版」参照,确实是【旧引擎】的产出 —— 不是我为了让差异好看而编的
//
// ★★ 本项目铁律:【最难发现的错是「假订正」】——「写『原文无 X,已订正』而实际只有一个见证」。
// 一份自己造的「旧版」参照,就是假订正的完美温床:想让哪一格「该变」,改一下参照即可。
// → 所以这里【不信任采集脚本的自述】,拿【另一份、更早、由旧代码采的】 fixture 交叉验证:
// dynasties-pre-multitrack.json 存了 6 个 profile × 5 朝的【完整】输出(不是投影)。
// 把它投影一遍,必须与 dynasties-weighted-sum.json 里同样的格子逐字相同。
// ⇒ 两份 fixture、两次独立采集、对得上 ⇒ 这个「被告」是真的。(这就是本项目的双证。)
// ══════════════════════════════════════════════════════════════════════════
test('★★ 自证:「旧版」参照是真的旧引擎产出(与多轨化前那份 fixture 双证)', () => {
let checked = 0;
for (const [key, byDyn] of Object.entries(PRE.sample)) {
for (const [d, full] of Object.entries(byDyn)) {
assert.deepEqual(
OLD.rows[key][d], proj(full),
`${d}/${key}:weighted-sum 参照与 pre-multitrack 对不上 —— 参照本身不可信,整个审计作废`
);
checked++;
}
}
assert.ok(checked >= 30, `交叉验证的格子太少(${checked}),这条自证没在证东西`);
assert.equal(Object.keys(OLD.rows).length, PRE._matrix.count, '两份 fixture 的 profile 矩阵必须同构');
});
// ══════════════════════════════════════════════════════════════════════════
// ② 形状判别式 —— 扫【数据】,不扫注释。注释挡不住任何人,判别式可以。
// ══════════════════════════════════════════════════════════════════════════
test('★★ 加权求和不许复活 —— 也不许换个名字回来', () => {
for (const d of DYNASTIES) {
for (const c of d.channels) {
// 七朝原文零命中(chiduo.js SHAPE_DIFF.weightedSumOccurrences = 0)。
assert.equal(c.weights, undefined, `${d.name}/${c.name} 的 weights 回来了 —— 原文里没有这个形状`);
// ✗ 换个名字也不行。这一条挡的是「删了 weights,改叫 total/points 再加起来」。
for (const alias of ['score', 'total', 'points', 'weight', 'scores']) {
assert.equal(c[alias], undefined, `${d.name}/${c.name} 冒出了「${alias}」—— 加权求和换了个名字回来`);
}
const x = xuanOf(c);
// ★ kemu 只能【数】:过,或不过。一条科目【不许】带系数 —— 带了系数再相加,就是加权求和。
for (const k of x.kemu || []) {
assert.equal(typeof k.axis, 'string', `${d.name}/${c.name} 的科目缺 axis`);
assert.equal(typeof k.need, 'number', `${d.name}/${c.name} 的科目缺 need(门限)`);
assert.ok(k.name, `${d.name}/${c.name} 的科目必须有【原文的词】做名字`);
assert.equal(k.weight, undefined, `${d.name}/${c.name}/${k.name} 的科目带了权重 —— 科目只能数,不能加权`);
}
}
}
});
test('★ 每条通道都必须有【选】,且它的三样东西都在', () => {
for (const d of DYNASTIES) {
for (const c of d.channels) {
const x = c.xuan;
assert.ok(x, `${d.name}/${c.name} 没有【选】—— 那它拿什么定位阶?`);
assert.equal(typeof x.seat, 'number', `${d.name}/${c.name} 的 seat 必须是一阶`);
// ⚠ 2026-07-17 接 25 朝:seat 的范围【随它所在的那把尺走】,不再写死 0–5。
// · 上界 = 这条通道所在梯子的最高一格(二十等爵到 20、九品到 18…);
// · 下界:允许 -1 = 【梯下】(北周牧守自辟的州吏落在「不命」,原文明记,非 tier0)。
const trkOfC = d.tracks.find((t) => t.id === c.track) || d.tracks[0];
const maxTierC = Math.max(...trkOfC.ladder.map((l) => l.tier));
assert.ok(Number.isInteger(x.seat) && x.seat >= -1 && x.seat <= maxTierC,
`${d.name}/${c.name} 的 seat(${x.seat}) 必须落在这把尺上([-1, ${maxTierC}])`);
assert.ok(Array.isArray(x.kemu), `${d.name}/${c.name} 的 kemu 必须是数组(空 = 这条路没有量表)`);
// ⚠ 2026-07-18 秦:排序键【可以为空】—— 但【只有当这条路没有可排的东西时】。
// 秦军功/赐爵:kemu 空、gate 空、六轴一条都接不住「斩首级」⇒ 原文不给次序 ⇒ key 空、keySource null
// (xuan/qin.js 逐字论证;dynasties.js 已删掉 defaultKey,免得【补写】一个 wu 键假装完整 ——
// 补 wu 正是 qin.js 明令禁的头一条:「果决不是军功」)。
// ★ 而一条【有科目可数】(kemu 非空) 的路,key【仍然】不许空 —— 有东西要排却不给次序,那才是漏。
assert.ok(Array.isArray(x.key), `${d.name}/${c.name} 的排序键必须是数组`);
if (!x.key.length) {
assert.equal((x.kemu || []).length, 0,
`${d.name}/${c.name} 有科目可数(kemu 非空),排序键却是空的 —— 有东西要排却没给次序`);
assert.equal(x.keySource, null,
`${d.name}/${c.name} 排序键为空,却号称有出处(keySource 非 null) —— 没有键哪来的出处`);
}
// ★ seat 不许越过这条通道自己的天花板 —— 位子在天花板之上,那这个天花板就是假的。
// ⚠ 天花板未记(null)时兜底取【本梯最高格】,与 computeRank 的 tierOf 同口径(不再写死 5)。
assert.ok(
x.seat <= (c.ceiling?.tier ?? maxTierC),
`${d.name}/${c.name} 的位子(${x.seat})高过它自己的天花板(${c.ceiling?.tier}) —— 天花板成了摆设`
);
// ⚠ keySource:有出处的必须真带卷次;没出处的必须【明写 null】,不许含糊其辞。
// ✗ 这一条挡的是「哪天有人给排序键补一个像模像样的出处」。
assert.ok('keySource' in x, `${d.name}/${c.name} 必须表态:这个排序键有没有原文可依`);
if (x.keySource !== null) {
assert.match(x.keySource, /卷/, `${d.name}/${c.name} 的排序键号称有出处,那就必须带卷次`);
}
}
}
});
test('★★★ 全表【只有一条】通道的排序键有原文可依 —— 这是结论,不是缺', () => {
// ★ 老板说「唐的排序键在原文里是现成的:[德行, 才, 劳]」。核完原文,答案是:
// 【在原文里是现成的,在本表的词汇表里不是】——
// (1) 「則先德行;德均以才,才均以勞」是【铨选/校功】的尺子(《旧唐书》卷43「凡擇人以四才,
// 校功以三實」两个动词劈开两套东西;《宋史》卷155 志序独立印证「入仕/服官/任事」三分),
// 而本表的 channels【全是入仕】—— 引擎里没有那一段(chiduo.js CONFLICT #8);
// (2) 「德行/才/勞」六轴里一个都没有(德行没有轴;劳是【时钟】,不许拿 heng 承载)。
// → 所以唐的 keySource 是 null。谁想给它补一个出处,先回答「德行是六轴里的哪一条」。
const withSource = [];
for (const d of DYNASTIES) {
for (const c of d.channels) if (xuanOf(c).keySource !== null) withSource.push(`${d.id}/${c.id}`);
}
assert.deepEqual(
withSource, ['song/wuju'],
'有原文可依的排序键【只有熙宁武举一条】。多出来的那条,先拿出带卷次的双证;' +
'少了它,说明武举那条的出处被人删了 —— 那这一刀就没有任何原文原型了。'
);
// 而唐必须【明确地】没有出处 —— 不许含糊,也不许被「补」上。
for (const c of getDynasty('tang').channels) {
assert.equal(xuanOf(c).keySource, null, `唐/${c.name} 的排序键不许挂出处:德行/才/劳,六轴里一条都没有`);
}
});
// ══════════════════════════════════════════════════════════════════════════
// ③ 原文复现 —— ★ 全表唯一一处「通过计数 → 位阶」有原文原型的地方
//
// 《宋史》卷157·选举三(双证,且在本朝 span 熙宁 1068–1077 内):
// 「策、武藝俱優為右班殿直,武藝次優為三班奉職,又次借職,末等三班差使;
// 策入平等而武藝優者除奉職,次優借職,又次三班差使,武藝末等者三班差使。」
// 把它按「通了几条」数一遍,与本朝武选梯子严丝合缝 —— 这一条绿着,这一刀才不是我编的形状。
// ══════════════════════════════════════════════════════════════════════════
test('★★★ 熙宁武举:新引擎必须逐档复现《宋史》卷157 那张授官表', () => {
const at = (subs) => computeRank(profileFromSubs(subs, { wang: 0 }), 'song');
// ★★ 通 0 条【而且策满格】:武艺这一条不通 → 后面的策【根本不查】→ 仍是三班差使。
// 这一格就是「前缀计数」与「数一共通几条」的分水岭,也是这一刀最容易失守的地方:
// 若哪天它变成了 tier 2,说明【策又在给武艺补分】= 加权求和从计数这个后门爬回来了。
const moDengCeMan = at(['blunt', 'rigorous', 'datadriven', 'concise', 'shortfirst']);
assert.equal(moDengCeMan.channel.id, 'wuju');
assert.equal(
moDengCeMan.rank.tier, 1,
'★ 武艺末等而策满格 →【三班差使】。原文:「武藝末等者三班差使」—— 策再好也补不回来'
);
assert.equal(moDengCeMan.rank.title, '三班差使');
// 通 0 条:武艺不到「优」(只点过一项)、策不通 →【武藝末等者三班差使】
const modeng = at(['blunt']);
assert.equal(modeng.channel.id, 'wuju');
assert.equal(modeng.rank.tier, 1, '通 0 条 → 三班差使(原文:武藝末等者三班差使)');
assert.equal(modeng.rank.title, '三班差使');
assert.match(modeng.rank.grade, /武艺末等者授/, '梯子这一阶的 grade 就是原文的话');
// 通 1 条:武艺优、策不通 →【策入平等而武藝優者除奉職】
const wuyou = at(['blunt', 'motivate']);
assert.equal(wuyou.channel.id, 'wuju');
assert.equal(wuyou.rank.tier, 2, '通 1 条 → 三班奉职(原文:策入平等而武藝優者除奉職)');
assert.match(wuyou.rank.title, /三班借职 \/ 三班奉职/);
assert.match(wuyou.rank.grade, /武艺次优者授/);
// 通 2 条:策、武艺俱优 →【策、武藝俱優為右班殿直】
const juyou = at(['blunt', 'motivate', 'rigorous']);
assert.equal(juyou.channel.id, 'wuju');
assert.equal(juyou.rank.tier, 3, '通 2 条 → 右班殿直(原文:策、武藝俱優為右班殿直)');
assert.match(juyou.rank.title, /右班殿直/);
assert.match(juyou.rank.grade, /策、武艺俱优者授/);
// ★★ 而这三档合起来就是【tier = 1 + 通过条数】—— seat=1 不是本表的编号,
// 它是「武藝末等者三班差使」这七个字。
assert.equal(xuanOf(chOf('song', 'wuju')).seat, 1);
assert.ok(juyou.rank.tier - modeng.rank.tier === 2, '两条之差 = 两阶之差');
// 排序键的次序也必须是原文的:武艺在前。
assert.deepEqual(xuanOf(chOf('song', 'wuju')).key, ['wu', 'fa'], '武艺在前、策在后 —— 原文如此,不许对调');
});
test('★ 通过计数【不跨通道相加】—— 每条通道自带自己的分母', () => {
// 唐的每一科自带分母(进士数「策五道+帖一大经」,明法数「律七条+令三条」)——
// 「进士的甲第与明法的甲第不可比」。所以一条通道的科目【只能】数它自己的。
// 这里守的是数据形状:任何一条科目都不许引用别的通道。
for (const d of DYNASTIES) {
for (const c of d.channels) {
for (const k of xuanOf(c).kemu || []) {
assert.equal(k.channelId, undefined, `${d.name}/${c.name} 的科目引用了别的通道 —— 那就是跨科相加`);
assert.equal(k.from, undefined, `${d.name}/${c.name} 的科目引用了别的通道`);
}
}
}
});
// ══════════════════════════════════════════════════════════════════════════
// ④ ★★★ 变化审计 —— 这一刀的验收
//
// 老板的原话:「对每一个结果变了的 profile,说明它为什么该变(哪条原文要求它这么变)。
// 变化数量会很大 —— 抽样说明 + 分类统计即可,但【必须有一类是「我说不清为什么变的」】,
// 且那一类【必须为空】才算过。」
//
// ⚠ 每一类都必须有一个【可证伪的谓词】,不能只是「我给它起了个名字」。
// 一个类如果只是「这条通道的变化都算它」,那它什么都没证明 —— 它只是把变化重新命名了一遍。
// 所以下面每一类都验【机理】:新的 tier 必须【正好】等于那条机理算出来的数。
// 对不上的,一律掉进 UNEXPLAINED,测试当场红。
// ══════════════════════════════════════════════════════════════════════════
// 一条通道在新引擎下的位阶(复算机理,用来验证「变化确实是这个机理造成的」)。
// ⚠ 这【不是】把 computeRank 抄一遍:它只复算【一条给定通道】的位阶,不做择路。
// ★ 前缀计数:数到第一条不通的就停 —— 原文「武藝末等者三班差使」(武艺不通,策再好也不补)。
// ⚠ 这里【必须】和引擎用同一个口径,否则审计会把一处真的互补当成「机理算出来的」放过去。
function tierByMechanism(dynId, chId, val) {
const c = chOf(dynId, chId);
const x = xuanOf(c);
let n = 0;
for (const k of x.kemu || []) {
if (val(k.axis) < k.need) break; // ★ 不通 → 停,后面的不查
n++;
}
return clamp(x.seat + n, 0, c.ceiling?.tier ?? 5);
}
const CLASSES = {
A: '【无量表 → 位子取代加权分】这条路没有一条可数的科目(原文不给量:汉四科是分类/南齐舍人靠久勞親信/' +
'辽无选举志),所以位阶恒等于这条路的位子。旧值是加权求和的产物 —— 而它的区分度来自' +
'【没被这条路量过的那些轴的基线 40 × 权重】,即一个没量你的东西在给你加分。',
B: '【熙宁武举 → 改按原文的通过计数】《宋史》卷157「策、武藝俱優為右班殿直……武藝末等者三班差使」' +
'(双证、在 span 内)。梯子本来就是照这段排的,旧引擎没读它:256 个开着这条路的 profile 里' +
'248 个被算成右班殿直,包括策一条都不通的人。',
C: '【宋恩荫 → 从选人起家,不再一步到天花板】本表【自己】写着「本官须举主改官,多滞选人」,' +
'而文选梯子 tier1 逐字就是「选人(幕职州县官)」。旧引擎却把 846 个里的 823 个直接放在' +
'知州 = 这条路的天花板上。★ 天花板不是位子。',
D: '【南朝三轨:士族、舍人、军功各有各的梯子】舍人这条路没有量表(久勞、親信),而这条路【就是】' +
'一个位子,所以走进来的人就在那一格;反过来,士族清途【根本不在那条梯子上】。' +
'★ 2026-07-17 第四刀:上一刀让士族从 tier4 挪到 tier3(不再落在舍人的鞋里),' +
'而【病根没除】—— 那条梯子仍是【共用】的:士族的天花板 tier5(尚书令) 在舍人那一格【之上】,' +
'即本表仍在说「士族要当尚书令得先经过中书通事舍人」。这一刀把梯子拆了:' +
'士族清途的每一级都出《南齐书》卷16(双证),tier4 是【侍中】(志:「齊世朝會,多以美姿容者兼官」),' +
'不是舍人。⇒ 南朝 shizu 的 title/grade/unit 整片变,tier 仍是 seat=3(本表的编号,未动)。',
// ── 2026-07-17 第四刀新增 ──
G: '【多轨朝代的 tier0 不再替你站队 —— schema 第五个洞补上了】' +
'梯子是【通道】带来的(trackOfChannel),不是朝代带来的 ⇒ 一条通道都没走进去的人,' +
'【不在任何一条梯子上】。而旧引擎在这一格取 tracks[0]:' +
'★ 于是辽的无路者被报成「北面·无官」(北面官是契丹人的官),宋的被放在【文选】轨上,' +
'南朝三轨之后更荒唐 —— 无路者被报成「在士族清途上」,而士族清途是 gate{wang:75},' +
'他恰恰是最不可能在那条路上的人。⇒ 多轨朝代 unit: null(这个问题在这里问不出口);' +
'★ 单轨朝代【逐字不变】(汉的 tier0「郡县小吏/斗食」是那把尺真实的最低一格)。' +
'(这与 listDynasties 早就在做的 `unit: multi ? "multi" : ts[0].unit` 是同一个判断 ——' +
'不是新规矩,是一条既有规矩漏了一个调用点。)',
E: '【择路:同位阶同天花板时,不再按加权分挑】旧引擎最后一级比的是加权分,而权重是发明的。' +
'删掉权重,这一级换成【字典序(唐:德均以才,才均以勞)→ 本表的编号】。' +
'★ 变的是这一级,不是位阶、也不是天花板:新挑的那条,在新规则下不比旧挑的那条差。',
// ── 2026-07-17 第三刀(出身)新增 ──
F: '【辽:出身没定 → 北面两条路关着,不再由能力替出身做决定】' +
'《辽史》卷45 正文:「凡契丹軍馬皆屬焉」(北枢密院·武铨)、「凡契丹人民皆屬焉」(南枢密院·文铨)' +
'——这两句【一直躺在 dynasties.js 自己的 sourceText 里,旧引擎没读它】。' +
'★ 旧引擎拿六轴给辽择路,等于替用户答了「你是谁家的人」这一问(本表在辽一朝自陈过这个失真)。' +
'新引擎:出身没定 ⇒ 【属】的路全关,reasons 里明写「这一部传还没定出身」。' +
'★★ 这一格【故意难看】:宁可让用户看见一个没答的问题,也不许引擎替他答。' +
'(1128 个 fixture profile 全都没有出身 —— 它们是加出身【之前】采的,所以辽这一朝整片变。)',
// ── 2026-07-18 史学审计新增 ──
H: '【魏晋:删掉那条【自造的整齐降序官品】—— 无路者 tier0「不入流」的 grade「无品」随之消失】' +
'dynasties.js 逐字:旧表自造了「无品→七→六→五→四→三品」的整齐降序,与《通典》卷37晋官品不合' +
'(散骑常侍应三品,且与本表 qingtan.ceiling『散骑常侍三品』自相矛盾;诸侍郎应五品;郡丞应八品非六品)。' +
'★ 引擎按 ordinal tier 裁决,grade 只是描述性标注,删了不改裁决 —— 宁缺一条假整齐降序。' +
'⇒ 走进 weijin 通道的格子,其 grade 变化被 A(无量表→位子)认领(tier 仍等于 seat);' +
' 而【无路者那一格】(o.ch=n.ch=null、tier0「不入流」) 不进任何通道、A 认不了它,' +
' 它的唯一变化【正好只是那条假 grade 没了】。本类专认这一种,且谓词单格可证伪:' +
' 必须是 weijin、grade 由「有」变「无」、其余八项(tier/title/unit/ceil/cat/stig/blk)逐格全同。',
};
test('★★★ 变化审计:1128 profile × 七朝,每一处变化都必须说得出依据;说不清的那一类必须为空', () => {
const keys = Object.keys(OLD.rows);
const stats = {};
const samples = {};
const unexplained = [];
let changed = 0;
let same = 0;
for (const key of keys) {
const p = profileOf(key);
const axes = p.axes || {};
const wang = clamp(Number(p.wang || 0), 0, 100);
const val = (k) => (k === 'wang' ? wang : clamp(Number(axes[k] || 0), 0, 100));
for (const d of DYNASTIES) {
const o = OLD.rows[key][d.id];
// ⚠ 2026-07-17 加西汉(第八朝):OLD 是【多轨化前的旧引擎】采的 fixture,里面【没有西汉】。
// ★ 本审计是「这一刀让【旧有】朝代变了多少、每处变化说不说得出依据」——西汉是【全新】的,
// 没有旧基线可比,不该进这个审计(也没有 o 可比)。⇒ fixture 里没有的朝代直接跳过。
if (!o) continue;
const n = proj(computeRank(p, d.id));
if (JSON.stringify(o) === JSON.stringify(n)) { same++; continue; }
changed++;
let cls = null;
if (o.ch !== n.ch) {
// ── F:辽 —— 出身没定 ⇒【属】的路全关(2026-07-17 第三刀)──
// ★ 谓词【可证伪,而且它验的是机理不是名字】:
// ① 必须是辽(别的朝没有出身维,出身关不了它们的路);
// ② 新引擎必须【一条路都不开】;
// ③ ★ 而且必须【正好】是出身关的 —— closedBy('liao', null) 关掉的那几条,
// 必须【覆盖辽的每一条通道】。少关一条,就说明这一格不是出身关的,
// 那它就得掉进 UNEXPLAINED 让测试红。
// ⚠ 这三条合起来才拦得住「拿 F 当垃圾桶」:F 不是「辽的变化都算它」,
// 它是「辽的这一格【正好】等于出身机理算出来的那个结果」。
if (d.id === 'liao' && n.ch === null) {
const shut = closedBy('liao', null);
if (getDynasty('liao').channels.every((c) => shut.closed.has(c.id))) cls = 'F';
}
// ── E:择路变了 ──
// 谓词:gate 一个字没改 ⇒ 旧引擎挑的那条【在新引擎下仍然开着】。
// 那么新引擎挑中的这条,在【位阶 → 天花板】这两级上【不许比它差】。
// ★ 若比它差 ⇒ 说明这一刀把择路弄坏了,不是「换了个 tiebreak」。
if (!cls && o.ch && n.ch) {
const oldTierNow = tierByMechanism(d.id, o.ch, val);
const oldCeil = chOf(d.id, o.ch).ceiling?.tier ?? 5;
const newCeil = chOf(d.id, n.ch).ceiling?.tier ?? 5;
if (n.tier > oldTierNow || (n.tier === oldTierNow && newCeil >= oldCeil)) cls = 'E';
}
} else if (o.ch === null && n.ch === null) {
// ── G:多轨朝代的 tier0 不再替你站队(2026-07-17 第四刀)──
// ★ 谓词【可证伪,且验的是机理不是朝代名】—— 五条全中才算 G:
// ① 必须是【多轨】朝代(单轨朝代的 tier0 是那把尺真实的最低一格,本刀【不许】动它,
// 所以单轨朝代【永远不该】进 G —— 它进了就说明我把不该动的动了);
// ② 新旧都是【一条路都不开】(这一格本来就只讲无路者);
// ③ ★ 旧值【替他报了一条梯子】:o.unit 非 null 且 o.tier === 0 —— 那就是「站队」本身;
// ④ ★ 新值【一条都不报】:n.unit === null 且 n.tier === 0;
// ⑤ ★★ 而且【别的什么都没变】:ceil/cat/stig/blk 逐格相同 ——
// 即这一格的变化【正好只是「哪条梯子」这一件事】。少了这一条,G 就成了垃圾桶
// (「反正 ch 都是 null 就算 G」),那正是 F 的注里警告过的用法。
//
// ⚠ 这里【故意不验「旧值是 tracks[0] 的 t0」】,理由是南朝把它证伪了:
// 南朝【本刀之前是单轨】,它的旧 t0 是那条【共用梯子】的第一格(不入流/无品/pin),
// 而那条梯子这一刀【已经不存在了】。⇒ 拿现在的 tracks[0] 去比旧值,对辽/宋成立,
// 对南朝【必然不成立】—— 第一版就是这么写的,南朝那 232 格当场没被认领。
// ★ 那不是南朝的例外,是谓词写错了:机理是【旧引擎给无路者报了一条梯子(不管哪条)】,
// 不是【旧引擎报的是第一条】。验后者,等于把机理写成了它的一个偶然特例。
const ts = tracksOf(d.id);
if (
ts.length > 1 &&
o.unit !== null && o.tier === 0 &&
n.unit === null && n.tier === 0 &&
o.ceil === n.ceil && o.cat === n.cat && o.stig === n.stig && o.blk === n.blk
) cls = 'G';
// ── H:魏晋删掉那条自造的整齐降序官品 ⇒ 无路者 tier0「不入流」的 grade「无品」没了(2026-07-18 审计)──
// ★ 谓词【单格可证伪】:必须是 weijin、必须【只有 grade 由「有」变「无」】、其余八项逐格全同。
// tier/title/unit/ceil/cat/stig/blk 但凡有一处也变了,就不是「只删了假 grade」,掉回 UNEXPLAINED。
if (
!cls && d.id === 'weijin' &&
typeof o.grade === 'string' && n.grade === undefined &&
o.tier === n.tier && o.title === n.title && o.unit === n.unit &&
o.ceil === n.ceil && o.cat === n.cat && o.stig === n.stig && o.blk === n.blk
) cls = 'H';
} else if (o.ch === n.ch && n.ch) {
// ── 同一条路,位阶变了 ──
const x = xuanOf(chOf(d.id, n.ch));
const byMech = tierByMechanism(d.id, n.ch, val);
if (n.tier !== byMech) {
cls = null; // 机理算不出这个数 ⇒ 说不清
} else if (d.id === 'song' && n.ch === 'wuju') {
cls = 'B';
} else if (d.id === 'song' && n.ch === 'yinbu') {
cls = 'C';
} else if (d.id === 'nanchao' && (n.ch === 'sheren' || n.ch === 'shizu')) {
cls = 'D';
} else if ((x.kemu || []).length === 0 && n.tier === clamp(x.seat, 0, chOf(d.id, n.ch).ceiling?.tier ?? 5)) {
// A 的谓词:位阶【正好】等于这条路的位子,且与 profile 无关(kemu 空)。
cls = 'A';
}
}
if (!cls) {
unexplained.push(`${d.id}/${key}:${JSON.stringify(o)} → ${JSON.stringify(n)}`);
} else {
const bucket = `${d.id}·${cls}`;
stats[bucket] = (stats[bucket] || 0) + 1;
if (!samples[bucket]) samples[bucket] = `${key}:${o.ch}/t${o.tier}「${o.title}」 → ${n.ch}/t${n.tier}「${n.title}」`;
}
}
}
// ── 分类统计 + 抽样,打出来给人看(老板要的「抽样说明 + 分类统计」)──
const lines = ['', ' ── 变化审计 ──', ` 未变 ${same} 格;变了 ${changed} 格(共 ${keys.length * DYNASTIES.length} 格)`];
for (const b of Object.keys(stats).sort()) lines.push(` ${b.padEnd(14)} ${String(stats[b]).padStart(5)} 格 例:${samples[b]}`);
lines.push(` 说不清为什么变的:${unexplained.length} 格`);
console.log(lines.join('\n'));
// ★★ 验收:这一类必须为空。
assert.deepEqual(
unexplained.slice(0, 12), [],
`★★ 有 ${unexplained.length} 格的变化【我说不清为什么该变】—— 这一刀不算过。\n` +
' 每一处变化都必须是某条原文要求的,或者是某条明标为「本表的编号」的机理造成的。\n' +
' 说不清 = 引擎干了一件我没有模型的事,那它就是在瞎编。'
);
// 这一刀【必须】让输出变 —— 一格都没变,说明权重根本没删掉。
assert.ok(changed > 500, `只变了 ${changed} 格 —— 加权求和大概没真删掉`);
});
// ══════════════════════════════════════════════════════════════════════════
// ⑤ 机理本身的断言 —— 光有审计还不够,机理得自己站得住
// ══════════════════════════════════════════════════════════════════════════
test('★★ 没有量表的路,位阶不许随 profile 变 —— 那正是「原文不给量」的意思', () => {
// 一条 kemu 空的通道:只要它开着,谁走上去都在同一个位子。
// ★ 这不是引擎偷懒 —— 是四科「是分类,不给量」(黄琼「乃奏增孝悌及能從政者【為四科】」:
// 能被「增为四科」的只能是类)在代码里的样子。
const seen = {};
for (const [subs, wang] of [
[['rigorous'], 0], [['rigorous', 'datadriven'], 0],
[['rigorous', 'datadriven', 'concise', 'shortfirst'], 0],
[['rigorous', 'datadriven', 'concise', 'shortfirst'], 100],
]) {
const r = computeRank(profileFromSubs(subs, { wang }), 'han');
assert.equal(r.channel.id, 'bizhao');
seen[`${subs.length}/${wang}`] = r.rank.tier;
}
const tiers = new Set(Object.values(seen));
assert.equal(
tiers.size, 1,
`汉的辟召给出了不同的位阶(${JSON.stringify(seen)}) —— 而四科是【分类】,不给量。` +
'位阶随本事高低浮动,就是加权求和的形状又回来了'
);
assert.equal([...tiers][0], xuanOf(chOf('han', 'bizhao')).seat, '位阶必须正好等于这条路的位子');
});
test('★★ 字典序:高才不许补低德 —— 第一条判得了,就不许看第二条', () => {
// 「德均以才,才均以勞」:「均」= 相等,是查询下一级的【触发条件】,不是权重。
// 在本表里,这句话的可执行形式是:一条通道的排序键上,【第一条轴分出高下,就不再看后面】。
// ★ 拿宋武举验,因为它是全表唯一一条排序键有原文的:key = [武艺, 策]。
// 两个人:甲武艺优、策不通;乙武艺不到优、策满格。原文说甲高(去留在武艺)。
const jia = computeRank(profileFromSubs(['blunt', 'motivate'], { wang: 0 }), 'song');
const yi = computeRank(profileFromSubs(['blunt', 'rigorous', 'datadriven', 'concise', 'shortfirst'], { wang: 0 }), 'song');
assert.equal(jia.channel.id, 'wuju');
assert.equal(yi.channel.id, 'wuju');
assert.ok(
jia.rank.tier > yi.rank.tier,
'★ 策满格的人补不上武艺那一条 —— 「武藝末等者三班差使」,策再好也补不回来。' +
'若这条挂了,说明「策」又在给「武艺」补分了 = 加权求和'
);
});
test('★ 位阶不许越过这条通道自己的天花板(位子 + 通过计数 都算完之后)', () => {
const ALL = SUB_REGISTRY.map((s) => s.id);
for (const wang of [0, 55, 100]) {
for (const d of DYNASTIES) {
const r = computeRank(profileFromSubs(ALL, { wang }), d.id);
if (!r.channel) continue;
const c = chOf(d.id, r.channel.id);
// ⚠ 2026-07-17 接 25 朝:天花板未记(null)时兜底取【本梯最高格】,与 computeRank 的 tierOf 同口径
// (不再写死 5)—— 否则陈的【亲王起家侍中】(tier7、天花板未记) 会被误判成越过了一个假的 5。
const trk = getDynasty(d.id).tracks.find((t) => t.id === c.track) || getDynasty(d.id).tracks[0];
const maxTierC = Math.max(...trk.ladder.map((l) => l.tier));
assert.ok(r.rank.tier <= (c.ceiling?.tier ?? maxTierC), `${d.name}/${c.name} 越过了自己的天花板`);
}
}
});
test('★ 合规:数不出高下的时候,必须当着用户的面说出来(第八条(五):不得过度迎合)', () => {
// 本表在这条路上数不出你的高下,就得说;拿一个算出来的位阶糊弄过去,才是迎合。
const r = computeRank(profileFromSubs(['rigorous', 'datadriven'], { wang: 0 }), 'han');
assert.ok(
r.reasons.some((s) => s.includes('数不出')),
'没有量表的路,必须告诉用户「本表数不出你在这条路上的高下」'
);
// 而有科目可数的路,必须把【通了几条、通的是哪几条】摆出来让他自己数。
const w = computeRank(profileFromSubs(['blunt', 'motivate'], { wang: 0 }), 'song');
assert.ok(w.reasons.some((s) => /通了 1 条,共 2 条/.test(s)), '武举必须报「通了几条」');
assert.ok(w.reasons.some((s) => /武藝優/.test(s)), '并且要用原文的词报出通的是哪一条');
});
// ══════════════════════════════════════════════════════════════════════════
// ⑥ 2026-07-17 第六刀(kemu)—— 【空的 kemu 必须自陈它是哪一种空】
//
// ★★ 这一节守的是这一刀的唯一交付物,而它守的方式【必须是扫数据,不是读注释】:
// 老板逐字:「填完之后【哪几条通道仍然是常数】—— 逐条列出,并说明是【无科目可依】
// 还是【我没找到】。★【这两者不是一回事,混了就是撒谎。】」
// ⇒ 而「混了就是撒谎」这句话,要挡住它【只有一个办法】:让「不说清是哪一种」这件事
// 【跑不起来】。⇒ 本节就是那道闸。
//
// ⚠ 为什么它必须是断言,不是注释 —— 本文件已经答过这一问,逐字(dynasties.js):
// 「⚠ 而它必须是【函数】不是注释:注释挡不住任何人(这句话本文件写了三遍,
// 而 penaltyIfNoWang 在注释的眼皮底下躺了很久)。」
// ══════════════════════════════════════════════════════════════════════════
import { KEMU_ABSENCE, kemuAbsenceOf } from '../src/dynasties.js';
import { LOST_AT, KAI_VOICE, KAI_VOICE_OPTIONS, comparableTier } from '../src/dynasties.js';
import { zeluAsk, ZELU_DEFAULT_NOTE, NEAREST_DEFAULT_NOTE } from '../src/dynasties.js';
// ══════════════════════════════════════════════════════════════════════════
// ⑦ 2026-07-17 第七刀(kai)—— 【把「开着但没走」的路还给用户】
//
// ★★ 这一节守的是【假二分不许复活】:reasons 此前只有两类句子(①你走的 + ②对你关着的),
// 而一条【开着但输了】的路两类都不进 ⇒ 用户读到一个封闭的二分,并合理推断那就是全部的路。
// ⇒ 实测(穷举 524,288 × 7 朝,非采样):【2,510,336 格(68.4%)里那个推断是错的】。
//
// ⚠ 为什么它必须是断言不是注释 —— 本文件已答过,逐字:「注释挡不住任何人,判别式可以。」
// ══════════════════════════════════════════════════════════════════════════
// ★ 复算择路的 comparator,用来验 kai.lostAt 报的那一级【是真的那一级】。
// ⚠ 这【不是】把 computeRank 抄一遍:它只回答「这两条路,赢家赢在哪一级」。
const TIE_TOL = 5;
function lexSign(a, b, val) {
const ka = xuanOf(a).key || [], kb = xuanOf(b).key || [];
for (let i = 0; i < Math.min(ka.length, kb.length); i++) {
const va = val(ka[i]), vb = val(kb[i]);
if (Math.abs(va - vb) > TIE_TOL) return vb - va;
}
return 0;
}
const valOf = (p) => (k) => (k === 'wang' ? clamp(Number(p.wang || 0), 0, 100)
: k === 'lao' ? Math.max(0, Math.floor(Number(p.lao || 0)))
: clamp(Number((p.axes || {})[k] || 0), 0, 100));
test('★★★★ 假二分不许复活:一条【开着但没走】的路,必须在 kai 里,也必须在 reasons 里', () => {
// ★ 老板给的那个实例,逐字复现(汉,wu=63 jiao=63 wang=55):
// 军功 gate{wu:55},他 wu=63 ⇒ 对他【开着】。而本刀之前 reasons 提它 0 次、blocked 里 0 次。
const p = profileFromSubs(['blunt', 'warm'], { wang: 55 });
const r = computeRank(p, 'han');
assert.equal(r.channel.id, 'xiaolian', '前提:引擎判他走察举孝廉');
assert.ok(r.kai, '★ kai 是一等公民的数据 —— 没有它,「开着的路」就只是一句拼出来的话');
const jg = r.kai.find((k) => k.channelId === 'jungong_han');
assert.ok(jg, '★★ 军功对他开着,而 kai 里没有它 —— 假二分回来了');
assert.equal(jg.walked, false);
assert.equal(jg.lostAt, 'idx', '★ 它输的是【本表的编号】:tier 3=3、ceiling 5=5、字典序判平(63 vs 63)');
assert.equal(jg.reasonKind, 'none', '★★ 判它的【没有人】—— 只有数组顺序');
// ★★★ 而【用户必须读得到它】—— 数据对了而话没说,就是本表反复栽的那个坑
// (kaoyan.js 逐字:「声明在代码里,产物在用户眼前,而中间那句话【没有跟过去】」)。
assert.ok(
r.reasons.some((s) => s.includes('军功')),
'★★★ kai 里有它,而 reasons 里一个字都没有 —— 那就是「数据修了,话没跟过去」'
);
assert.equal(r.blocked.filter((b) => b.channel.includes('军功')).length, 0, '前提:它不在 blocked 里(它开着)');
});
test('★★★ 每一条 kai 报的「输在哪一级」,必须与择路的 comparator 同源 —— 逐格复算', () => {
// ⚠ 若 lostAt 是另一把尺子量出来的,它比不报更坏:
// 带着「输在 tier」四个字的错误,比一句没有出处的话危害大十倍(本项目铁律的同一条道理)。
const CASES = [[[], 0], [['blunt', 'warm'], 55], [['rigorous', 'datadriven', 'concise', 'shortfirst', 'patient', 'calm'], 0],
[['socratic', 'humor', 'poetic', 'story', 'vivid'], 60], [['blunt', 'motivate', 'rigorous'], 80],
[SUB_REGISTRY.map((s) => s.id), 100], [['warm', 'playful'], 30], [['poetic', 'story', 'vivid'], 0]];
let checked = 0;
for (const [subs, wang] of CASES) {
for (const d of DYNASTIES) {
const p = profileFromSubs(subs, { wang });
const r = computeRank(p, d.id);
if (!r.channel) { assert.deepEqual(r.kai, [], '一条路都不开 ⇒ 没有一条开着的路'); continue; }
const val = valOf(p);
const won = r.kai.find((k) => k.walked);
assert.ok(won, `${d.id}: kai 里必须有【你走的那条】—— 它也开着`);
for (const k of r.kai) {
if (k.walked) { assert.equal(k.lostAt, null); continue; }
const a = chOf(d.id, k.channelId), b = chOf(d.id, won.channelId);
let expect;
if (won.tier > k.tier) expect = 'tier';
else if ((b.ceiling?.tier ?? 5) > (a.ceiling?.tier ?? 5)) expect = 'ceiling';
else { const s = lexSign(a, b, val); expect = s > 0 ? 'lex' : s < 0 ? 'unsorted' : 'idx'; }
assert.equal(k.lostAt, expect, `${d.id}/${k.channelId}:报「输在${k.lostAt}」,复算是「${expect}」`);
checked++;
}
}
}
assert.ok(checked > 20, `复算的格子太少(${checked}),这条断言没在证东西`);
});
test('★★★★★ 输在 lex 的,必须报出【本表拿哪两条轴比的】—— 而它【比不了】同一条轴', () => {
// ══════════════════════════════════════════════════════════════════════════
// ★★★★★ 2026-07-17 验收:【这条断言原来是【不可能失败】的,而它自称在守一个新发现】
//
// 【原文逐字】`assert.equal(k.lexAxes.sameAxis, false, '★★★ 竟然比了同一条轴 ——
// 穷举 784,256 次同轴 0 次,这是新事')`
// ⇒ ★【它一次都不可能红】:lexWhere 只在 |val(ka[i]) − val(kb[i])| > TIE_TOL 时返回位置 i,
// 而 val 是【轴 id 的函数】⇒ 同轴 ⇒ 同值 ⇒ 差 0 ⇒ 不过容差 ⇒ 【必然滑过去】。
// ⇒ sameAxis 恒 false ⇒ 【这条断言买不到任何东西,而它挂着「这是新事」的牌子】。
// ★★ 而它守的那个「新事」(穷举 784,256 次同轴 0 次)【也是恒等式】:对任何一张表都成立,
// 包括一张每条路 key[0] 全相同的完美表。⇒ 【断言是空的,它守的发现也是空的。】
//
// ⇒ 【改成两条【真的会红】的】:
// ① 那两条轴的名字与数【必须出门】—— 这是【单格可证伪】的,用户自己就能核。
// ② ★ 把那个恒等式【当定理来钉】,而不是当读数来数:直接构造一对【同轴】的 key,
// 证明 lexCompare 在那一位【弃权】(返回 0),而不是「碰巧没比到」。
// ══════════════════════════════════════════════════════════════════════════
// 汉,wu=73 jiao=63:军功比【果决 73】、孝廉比【交游 63】⇒ 差过容差 ⇒ 孝廉输在字典序。
// ★ 而这一局比的是【他的果决】和【他的交游】—— 那是两条不同的轴。
const p = profileFromSubs(['blunt', 'motivate', 'warm'], { wang: 55 });
const r = computeRank(p, 'han');
assert.equal(r.channel.id, 'jungong_han', '前提:字典序把他判去了军功');
const lex = r.kai.filter((k) => k.lostAt === 'lex');
assert.ok(lex.length, '前提:这个 profile 必须有一条输在字典序的路');
for (const k of lex) {
assert.ok(k.lexAxes, '★ 输在 lex 却不报是拿哪两条轴比的 —— 那四个字什么都没说');
// ★ 这一条【会红】:它要的是两条轴的名字和数真的出了门,而不是一个恒 false 的布尔。
assert.ok(k.lexAxes.mine?.axis && k.lexAxes.theirs?.axis, '★ 必须报出【实际比的那两条轴】');
assert.equal(typeof k.lexAxes.mine.value, 'number', '★ 必须报出那两个数 —— 用户要自己核');
assert.equal(typeof k.lexAxes.theirs.value, 'number');
assert.notEqual(
k.lexAxes.mine.axis, k.lexAxes.theirs.axis,
'★★ 这两条轴不同 —— ⚠ 而这【不是一个发现,是 lexCompare 的定义】:同轴它必然弃权。见下一条。'
);
assert.match(k.lexAxes.note, /原文从不问这种问题/, '★★ 这句限定语必须跟着那两条轴一起出门');
// ★★★ 那句话必须【自己说出它是定理,不是运气】—— 否则它又变回「穷举出来的一个数」。
assert.match(k.lexAxes.note, /它比不了同一条轴|必然滑过去/, '★★★ note 必须说清【同轴它比不了】');
}
assert.equal(
'sameAxis' in lex[0].lexAxes, false,
'★★★★ sameAxis 恒 false(构造上不可能为 true)⇒ 已删。它与 penaltyIfNoWang / kind:"unique" 同形'
);
assert.ok(
r.reasons.some((s) => /原文从不问|本表自己问的/.test(s)),
'★★ 用户必须读得到「这个问题是本表自己问的」—— 藏起来,它就是一句像史学判断的编造'
);
});
test('★★★★★【定理,不是读数】:lexCompare 在【同一条轴】上必然弃权 —— 所以「同轴 0 次」什么都没测到', () => {
// ══════════════════════════════════════════════════════════════════════════
// ★ 上一刀把「穷举 784,256 次 lex 裁决,同轴 0 次」挂成了头号交付物(★★★★★
// 「把那句自谦变成了一个数」)。★★【而那个数是恒等式:对任何一张表都是 0。】
// ⇒ 这条测试【就是那个证明】—— 它不数格子,它证构造。
//
// ★★★ 而【本表真的有同轴的 key[0] 对】—— 它们不是被本表避开的,是被 lexCompare 滑过去的:
// · tang: jinshi / xingjuan —— key[0] 都是 'wen'
// · liao: wenquan_liao / nanmian_hanguan —— key[0] 都是 'fa'
// ⇒ ★【即「同轴 0 次」连「本表小心地避开了同轴」都证不出来 —— 本表压根没避开,
// 是那台仪器测不到。】
// ══════════════════════════════════════════════════════════════════════════
// ① 本表【真的有】同轴的 key[0] 对 —— 这一条会红(哪天数据改了,这句话就得重写)
const samePairs = [];
for (const d of DYNASTIES) {
const cs = d.channels;
for (let i = 0; i < cs.length; i++) for (let j = i + 1; j < cs.length; j++) {
const a = cs[i].xuan?.key || [], b = cs[j].xuan?.key || [];
if (a[0] && a[0] === b[0]) samePairs.push(`${d.id}:${cs[i].id}/${cs[j].id}=${a[0]}`);
}
}
// ⚠ 2026-07-17 加西汉:西汉四条通道的排序键【全是 ['jingxue']】(keySource 全 null),
// 理由是【六轴瞎 ⇒ 盲路之间本表没有任何量来分高下 ⇒ 让它们在 lexCompare 上必然判平、
// 落到 _idx(本表的编号)】——这正是 xuan/xihan.js XUAN_NOT_DONE ⑧ 说的「_idx 是唯一判据」。
// ⇒ 于是西汉多出 C(4,2)=6 对同轴 key[0]。★ 这【强化】了本条的论点(同轴的对更多了,
// 而 lexCompare 一对都裁不了——见下面 ② 的定理)。
// ⚠ 2026-07-17 拆墙(25 朝):不再写死【全表恰好这几对】—— 接进来的朝有很多同轴 key[0] 对
// (如陈的八条路 key 全是 ['wang'])。★ 不变量【仍然保留、且不弱】:这原有的 8 对必须【全都在】
// (证明「本表【有】同轴的 key[0] 对」这个论点),而它们【一对都裁不了】由下面的定理 ② 守着。
const knownSamePairs = [
'tang:jinshi/xingjuan=wen', 'liao:wenquan_liao/nanmian_hanguan=fa',
'xihan:xiaolian/xianliang=jingxue', 'xihan:xiaolian/boshi_dizi=jingxue',
'xihan:xiaolian/jungong=jingxue', 'xihan:xianliang/boshi_dizi=jingxue',
'xihan:xianliang/jungong=jingxue', 'xihan:boshi_dizi/jungong=jingxue',
];
for (const p of knownSamePairs) {
assert.ok(samePairs.includes(p), `★★ 本表必须【有】同轴的 key[0] 对(${p})—— ⇒「同轴 0 次」不是「本表避开了同轴」,是 lexCompare 测不到`);
}
// ② ★★★ 定理本身:同轴那一位,lexWhere【永远返回不了】⇒ 报出来的两条轴必然不同。
// 穷举全表所有 lex 裁决,检查【报出来的那一位】—— 而这不是在「数 0 次」,
// 是在钉【一个由 val 是函数逼出来的恒等式】:同轴 ⇒ 同值 ⇒ 差 0 ⇒ 0 > TIE_TOL 为假。
let decided = 0;
for (const subs of [['blunt', 'motivate', 'warm'], ['blunt', 'warm'], ['refined', 'warm', 'blunt']]) {
for (const wang of [0, 30, 55, 75, 100]) for (const d of DYNASTIES) {
const r = computeRank(profileFromSubs(subs, { wang }), d.id);
for (const k of (r.kai || [])) {
if (k.lostAt !== 'lex' || !k.lexAxes) continue;
decided++;
assert.notEqual(k.lexAxes.mine.axis, k.lexAxes.theirs.axis, '★ 定理被破:lex 竟然在同轴上裁决了');
}
}
}
assert.ok(decided > 0, `前提:必须真的有 lex 裁决发生(${decided}),否则这条测试没在证东西`);
});
test('★★★★ zelu:择路的边界 = 老板逐字的判据【同 tier && 同 ceiling】—— 引擎有理由的两格不许被推翻', () => {
const p = profileFromSubs(['blunt', 'warm'], { wang: 55 });
const base = computeRank(p, 'han');
const ids = (z) => z.tie.map((t) => t.channelId);
assert.deepEqual(ids(base.zelu), ['xiaolian', 'jungong_han'], '★ 打平的那几条 —— ⚠【含引擎默认那一条】');
assert.equal(base.zelu.chosen, null, '★ 没选【不是缺值,是这一问还没问出口】(同 chushen.missing)');
// ★ 选了 ⇒ 你选的那条赢。「你什么都没变」——他只是自己拿了那一格。
const picked = computeRank({ ...p, zelu: 'jungong_han' }, 'han');
assert.equal(picked.channel.id, 'jungong_han', '★★ 择路本身就是第一个关口 —— 那是你选');
assert.equal(picked.zelu.chosen, 'jungong_han');
assert.equal(picked.rank.tier, base.rank.tier, '★ 位阶不变:这一格本来就是打平的,选它不是加分');
// ══ ★★★★ 第八刀:【默认必须自陈它是默认】—— 老板逐字:「它必须标出来是默认,不是判决」 ══
assert.equal(base.zelu.default, 'xiaolian', '★ 引擎的默认');
assert.equal(base.zelu.isDefault, true, '★★ 没人选 ⇒ 现在走的这条【是默认,不是判决】');
assert.equal(picked.zelu.isDefault, false, '★★ 你选了 ⇒ 它不再是默认 —— 这一格是你拿的');
assert.equal(base.zelu.default, picked.zelu.default, '★ 默认是【引擎的】,不随你选什么而变');
assert.match(base.zelu.note, /默认/, '★★ 「这是默认不是判决」必须是【数据】—— 注释挡不住任何人');
assert.equal(computeRank(p, 'ming').zelu.note, '', '★ 没得选的时候【不许】印这句话 —— 那就成了免责声明');
// ★★★★ 【身份稳定:选项表不许因为你选了而少一条】—— 上一刀的 bug,第八刀修。
// 旧写法 `filter(t => t !== engineBest)` 把默认那条排除在集合外
// ⇒ 你一选军功,察举就从选项里【消失】,再也选不回去。
assert.deepEqual(ids(picked.zelu), ids(base.zelu), '★★★ 一个「你走哪条」的选项表,不许取决于你已经选了哪条');
assert.ok(ids(picked.zelu).includes('xiaolian'), '★★ 选了军功之后,必须还能选回察举');
// ★★★★ 【空操作的第 N 次,而这次是我自己在「让它出声」那一刀里造的】——上一刀写的是
// `${best.byZelu ? '' : '——而这一格,本该你自己选。'}`,而 best 是 ranked 的元素,
// 【它从来没有 byZelu 这个字段】(byZelu 挂在 kai 上)⇒ 那个三元【永远走 else】
// ⇒ 即使用户已经选了,引擎照样对他说「这一格本该你自己选」。
assert.equal(
picked.reasons.filter((s) => s.includes('本该你自己选')).length, 0,
'★★★ 他已经选了 —— 再对他说「这一格本该你自己选」,就是那个空操作又活了一次'
);
assert.ok(
base.reasons.some((s) => s.includes('本该你自己选')),
'★ 而没选的时候这句话【必须在】—— 删了它,这一刀就白做了'
);
// ★★ 而【引擎默认拿的那条】必须在用户选了之后自陈:不是「本表说不清」,是【你选了别的】。
assert.ok(
picked.reasons.some((s) => s.includes('引擎默认给你拿的本来是它')),
'★★★ lostAt 若还量着 best 而非 engineBest,这里会报 unsorted=「本表说不清自己为什么没让你走它」——那是假的'
);
const eb = picked.kai.find((k) => k.channelId === 'xiaolian');
assert.equal(eb.byEngine, true, '★ 引擎的默认仍是察举 —— 你选了什么,不改变引擎判了什么');
assert.equal(eb.walked, false, '★★ 而 byEngine 与 walked 分叉了 —— 那个分叉就是这个产品要说的话');
assert.equal(eb.lostAt, null, '★★★ 引擎默认那条【没有输】—— ✗ 不许因为你选了别的就说它「输在 unsorted」');
// ⚠ 失效方向朝【关】,与 sameScale / comparableTier / closedBy 同一条规矩。
assert.equal(computeRank({ ...p, zelu: 'bizhao' }, 'han').channel.id, 'xiaolian', '★ 关着的路:不认');
assert.equal(computeRank({ ...p, zelu: '__不存在__' }, 'han').channel.id, 'xiaolian', '★ 名字问错:不认');
// ★★★ 而【引擎有理由的那两格,zelu 不许推翻】—— 这一条是 zelu 的边界,第八刀【没有放宽】:
const q = profileFromSubs(['socratic', 'humor', 'poetic', 'story', 'vivid'], { wang: 60 });
const wj = computeRank(q, 'weijin');
const qt = wj.kai.find((k) => k.channelId === 'qingtan');
assert.ok(qt && !qt.walked && qt.lostAt === 'ceiling', '前提:清谈对他开着,且输在【天花板】');
assert.equal(
computeRank({ ...q, zelu: 'qingtan' }, 'weijin').channel.id, wj.channel.id,
'★★★ 输在天花板的路不许被 zelu 拿走 —— 那等于把「能过科举就别去当吏」那条纪律交给用户删'
);
// ★ 而它必须【明确地】不在 tie 里,不是靠上面那条碰巧挡住。
assert.ok(!ids(wj.zelu).includes('qingtan'), '★ 引擎有理由赢的路,不许出现在「你选」的选项里');
});
test('★★★★★ 老板的判据【逐格验证】:tie 里每一条的 tier 与 ceiling 必然相同 —— 这就是它安全的原因', () => {
// 老板逐字:「★ 我的判断:问的时候,两条路的 tier 和 ceiling【已经一样了】——
// 所以挑不出优劣,只能挑「你想当什么人」。⇒ 这恰恰是它安全的原因,
// 也是它是「选择」而不是「优化」的原因。⚠ 验证我这个判断……跑出来,别信我。」
// ⇒ 【跑了。穷举 3,670,016 格(scripts/census-zelu.mjs),零例外:】
// 打平(idx) 242,176 条次【全部】落在 (tier =, ceiling =);
// 「tier 一样但 ceiling 不一样」1,300,480 条次【全部】输在 ceiling —— 那种引擎有理由。
// ★ 这一条把那个穷举钉成断言:★★【tie 里挑哪条,位阶与天花板一个数都不动】
// ⇒ 【它不可能变成 stat 优化,也不可能变成抽卡】—— 那不是本表克制,那是这个集合的定义。
for (const d of DYNASTIES) {
for (const subs of [[], ['blunt', 'warm'], ['blunt', 'motivate', 'warm'], ['rigorous', 'patient', 'humor'],
['socratic', 'humor', 'poetic', 'story', 'vivid'], ['poetic', 'story', 'vivid', 'blunt']]) {
for (const wang of [0, 30, 55, 75, 100]) {
const r = computeRank({ ...profileFromSubs(subs, { wang }), chushen: d.id === 'liao' ? 'qidan' : null }, d.id);
const tie = r.zelu.tie || [];
if (tie.length <= 1) continue;
const [a] = tie;
for (const t of tie) {
assert.equal(t.tier, a.tier, `${d.id}: tie 里出现了【位阶不同】的路 —— 那不是打平,那是引擎有理由(位阶即杠杆)`);
assert.equal(t.ceiling, a.ceiling, `${d.id}: tie 里出现了【天花板不同】的路 —— ✗ 那一格不许交给用户`);
}
// ★★ 默认【必然】在 tie 里,且【有且只有一条】是默认。
assert.equal(tie.filter((t) => t.isDefault).length, 1, `${d.id}: 默认必须有且只有一条`);
assert.ok(tie.some((t) => t.channelId === r.zelu.default), `${d.id}: 默认不在 tie 里 —— 选项表少了引擎自己拿的那条`);
// ★★★ tie 里【不许】出现 'tier'/'ceiling' —— 那两级是引擎有理由的,tieOf 已按相等筛掉。
for (const t of tie) {
if (t.isDefault) continue;
assert.ok(['idx', 'lex'].includes(t.lostAt), `${d.id}: tie 里出现 lostAt='${t.lostAt}' —— 引擎有理由的路混进了「你选」`);
assert.ok(['none', 'invented'].includes(t.reasonKind), `${d.id}: 默认凭一个【理由】赢的路,不该在 tie 里`);
}
// ★ tie 与 kai.yours 是同一个集合的两个投影 —— ✗ 不许分叉。
assert.deepEqual(
r.kai.filter((k) => k.yours).map((k) => k.channelId).sort(), tie.map((t) => t.channelId).sort(),
`${d.id}: kai 说「这一格本该你选」,而选项表里没有它 —— 两处分叉,用户读到一个选不了的选项`
);
}
}
}
});
test('★★★★ 而 lex 那 784,256 条次【是打平】—— 上一刀把它排除了,那是把老板的判据读窄了 76.4%', () => {
// ★★ 老板逐字的判据是「两条路对你都开着、【位阶一样、天花板一样】⇒ 引擎没有理由偏爱任何一条」。
// ⇒ 上一刀我把它读成 `lostAt === 'idx'`,理由是「lex 的理由是假的,而【理由是假的】
// ≠【该由用户推翻】—— 那一格要的是修引擎」。★【那个理由站不住,两条】:
// ① 实测:lex 那 784,256 条次【tier 一样、ceiling 一样】—— 逐条满足老板的判据。
// ② ★★ 而「修引擎」与「归你选」在这一格上【是同一个动作】:修 lexCompare 的唯一诚实
// 修法是让它拒绝跨轴比(返回 0)⇒ 那 784,256 条次当场落到 _idx ⇒ 本来就归 zelu。
// ⇒ 【于是上一刀的 zelu 只接管了老板判据的 23.6%;剩下 76.4% 仍由引擎拿一个
// 「你的果决比你的交游高吗」(原文从不问)替用户做了决定。】
// ⚠ 2026-07-17 验收:这里【原来引「同轴 0/784,256」当论据】,删了 —— 那是恒等式不是读数
// (证明在同名测试「lexCompare 在同一条轴上必然弃权」里)。★ 而②【反而更硬】:
// 既然 lexCompare【只有】跨轴才裁决得了,「拒绝跨轴」就是【整级消失】⇒ 784,256 全落 _idx。
const p = profileFromSubs(['blunt', 'motivate', 'warm'], { wang: 55 });
const r = computeRank(p, 'han');
const lex = r.kai.filter((k) => k.lostAt === 'lex');
assert.ok(lex.length, '前提:这个 profile 必须有一条输在字典序的路');
for (const k of lex) {
assert.equal(k.tier, r.rank.tier, '★★ 输在 lex 的路,位阶与你走的这条【一样】—— 穷举零例外');
assert.equal(k.yours, true, '★★★★ 位阶一样、天花板一样 ⇒ 按老板的判据它【是打平】⇒ 这一格本该你选');
assert.ok(r.zelu.tie.some((t) => t.channelId === k.channelId), '★★ 而它必须真的在选项里 —— 不是只在文案里承认一句');
}
// ★ 而它【真的选得动】:选了它,位阶不变(打平的定义),走的就是它。
const one = lex[0];
const picked = computeRank({ ...p, zelu: one.channelId }, 'han');
assert.equal(picked.channel.id, one.channelId, '★★★ 输在「本表自己编的问题」上的路,用户拿得回去');
assert.equal(picked.rank.tier, r.rank.tier, '★ 位阶不变 —— 这一格本来就是打平的');
});
test('★★★ zeluAsk:纯查询函数 —— 失效方向朝【关】,且它【没有「算 tier 之前」那个状态可以被调用】', () => {
const p = profileFromSubs(['blunt', 'warm'], { wang: 55 });
const a = zeluAsk(p, 'han');
assert.ok(a && a.options.length === 2, '★ 汉这一格有得选');
assert.match(a.ask, /你走哪一条/);
assert.equal(a.default, 'xiaolian');
assert.equal(a.isDefault, true);
// ⚠ 失效方向朝【关】,与 sameScale / comparableTier / closedBy / kemuAbsenceOf 同一条规矩。
assert.equal(zeluAsk(p, '__不存在的朝__'), null, '★ 朝代问错 ⇒ null');
assert.equal(zeluAsk(profileFromSubs([], { wang: 0 }), 'han'), null,
'★★ 一条路都不开 ⇒ null —— 「你走哪条」对他【问不出口】,✗ 不许给他一张关着的门的菜单');
assert.equal(zeluAsk(profileFromSubs(['patient', 'calm', 'rigorous'], { wang: 0 }), 'ming'), null,
'★ 本表这一次分得出高下(明:科举 tier3 > 吏员 tier2)⇒ 没有你要拿的那一格');
// ★★ 纯函数:跑两次必须完全相同。
assert.deepEqual(zeluAsk(p, 'han'), a, '★ 同一个 profile 跑两次必须一模一样');
// ★★★ 每一条选项都必须带着【引擎默认凭什么赢它】—— 只渲染名字,这一问就成了没有代价的菜单。
for (const o of a.options) {
if (o.isDefault) continue;
assert.ok(o.lostAt && o.reasonKind, '★ 选项不带「默认凭什么赢它」⇒ 这一问的全部内容(本表没有理由)就没跟着出门');
}
});
test('★★★ 一条路都不开:kai 必须是空数组 —— 而那是真值,不是缺值', () => {
const r = computeRank(profileFromSubs([], { wang: 0 }), 'weijin');
assert.equal(r.channel, null);
assert.deepEqual(r.kai, [], '★ 没有一条开着的路 ⇒ 空数组。✗ 不许缺这个字段:缺了,前端读到 undefined');
assert.deepEqual(r.zelu.tie, [], '★ 一条路都没有 ⇒ 没得选。✗ 不许兜底给一条');
assert.equal(r.zelu.default, null, '★★ 没有默认 —— 「你走哪条」对一个无路可走的人【问不出口】');
assert.equal(r.zelu.ask, '', '★★★ ✗ 不许给他一张【关着的门】的菜单 —— 那比游戏菜单更坏:选了也进不去');
});
test('★★★★ 第五次那一支:nearestTie —— 李广那一格,「军功」两个字必须出现', () => {
// ★★ 老板说「上一刀只修了影响 0.46% 用户的那一支」——【实测:没有修】。
// 汉·一条路都不开的 2,432 个 profile,nearest 落点:察举 1,344/辟召 1,088/【军功 0】。
// ⇒ 第五次那个读数(0/2,432)今天仍然逐字成立。本刀不改判决,只让那个编号【出声】。
// 【机理】李广(六轴全 40):辟召差[吏能 15]、军功差[果决 15] ⇒ 条数打平、缺口打平
// ⇒ 落到 _idx:辟召 1 < 军功 2。★ 而《史記》卷109:李广的实职正是靠军功挣的(renwu.js 双证)。
const r = computeRank(profileFromSubs([], { wang: 0 }), 'han');
assert.equal(r.channel, null, '前提:他一条路都不开');
assert.equal(r.nearest, '辟召(四科·明达法令)', '前提:nearest 仍是辟召 —— 本刀不改判决');
assert.deepEqual(r.nearestTie, ['军功'], '★★★ 与它打平、只输给编号的那条,此前一个字都没出过引擎');
assert.ok(
r.reasons.some((s) => s.includes('军功') && s.includes('分不出')),
'★★★★ 那张表把他真的走过的那条路用数组顺序删掉了,而且从来没告诉过他 —— 现在得告诉他'
);
// ══ ★★★★ 第八刀:老板逐字「那两个分支是同一个 _idx 兜底的两条腿。【一起修】」 ══
// ⇒ 「一起修」修的是【让默认自陈它是默认】—— 上一刀这一支【出了声】(nearestTie),
// 而 `nearest: 辟召` 仍然【冒充判决】:它读起来像本表算出来的,而它是数组第 1 号。
assert.equal(r.nearestIsDefault, true, '★★★ nearest 打平时是【默认】,不是【判决】—— 它必须自己说出来');
assert.match(r.nearestNote, /默认/, '★★ 这句话必须是【数据】,不是注释');
// ★★ 而【这一支不给 zelu,这不是漏,是拒绝】:一条路都不开的人没有路可走,
// 「你走哪条」对他【问不出口】。✗ 硬给他一个选项 = 把一扇【真的关着的门】画成菜单。
assert.deepEqual(r.zelu.tie, [], '★★★ 无路者不给择路 —— 那一问对他问不出口,✗ 不许把关着的门画成菜单');
assert.equal(zeluAsk(profileFromSubs([], { wang: 0 }), 'han'), null, '★ 纯查询函数同口径:不问');
// ★ 而【没打平的时候,nearest 不许也说「我随便拿的」】—— 那就是把免责声明贴满全表。
// ⚠ 实测:无路者 73,088 格里,nearest 打平的只有 15,040 格,【没打平的 58,048 格】——
// 即绝大多数无路者,本表【真的算得出】哪条离他最近(差的条数少、或缺口小)。
// ✗ 给那 58,048 格也印一句「这是我随便拿的」,就是拿诚实当免责声明。
const tangT0 = computeRank(profileFromSubs([], { wang: 0 }), 'tang');
assert.equal(tangT0.channel, null, '前提:唐对六轴全 40 的人一条路都不开');
assert.deepEqual(tangT0.nearestTie, [], '前提:这一格【没有】打平 —— 本表真的算得出哪条最近');
assert.equal(tangT0.nearestIsDefault, false, '★★ 没打平 ⇒ nearest 是本表【真的算出来的】(差的条数/缺口),不是默认');
assert.equal(tangT0.nearestNote, '', '★★★ ✗ 不许每次都印「这是默认」—— 那就成了免责声明,而免责声明不是诚实');
});
test('★★★ 两种文案都必须在 —— 删掉没选中的那一种,「差别」就只剩一句转述', () => {
// 老板逐字:「⚠ 但这是产品定性,你先做出来,【把两种文案都写出来,让老板看得见差别】。」
assert.ok(KAI_VOICE_OPTIONS.admit && KAI_VOICE_OPTIONS.shishu, '★ 两种口气都得留着,改一个词就换一种产品');
assert.equal(KAI_VOICE, 'admit', '★ 老板的判断:承认。改这一个词就换一种产品 —— 那是产品定性,不是重构');
// ★★ 而【乙的代价必须写在数据里】:它读起来最像史书,而它有一个谎。
assert.match(
KAI_VOICE_OPTIONS.shishu.cost, /他没有走|谁做的决定/,
'★★ 乙不是「更含蓄的甲」,它是把【谁做的决定】这一问删掉 —— 这个代价不许只写在注释里'
);
});
test('★★★★ LOST_AT 四档:每一档都必须自陈【判它的是谁】,且只有一档说得起「我有理由」', () => {
// ★ 老板的二分是「输在 tier =【引擎有理由】/输在 _idx =【引擎没有理由】」。
// ⇒ 实测把这个二分【推翻了两次】,而两次方向相反:
// ① _idx 只占 6.8%(老板预计「绝大多数」);
// ② ★★ 而 tier 那一档【不是「有理由」】—— comparableTier() 说这一比不成立,
// 而 computeRank 的择路第一级逐字就是 `b._tier - a._tier`,实测跑了 1,248,256 次。
assert.deepEqual(Object.keys(LOST_AT).sort(), ['ceiling', 'idx', 'lex', 'tier']);
for (const [id, v] of Object.entries(LOST_AT)) {
assert.equal(v.id, id);
assert.ok(v.whoDecided, `${id}:没说【判它的是谁】—— 那这一档什么都没交代`);
assert.ok(v.census, `${id}:没有读数 —— 一个没有数的分类,是给变化重新命名了一遍`);
assert.ok(v.desc.length > 80, `${id}:desc 太短`);
}
const sound = Object.values(LOST_AT).filter((v) => v.reasonKind === 'sound').map((v) => v.id);
assert.deepEqual(
sound, ['ceiling'],
'★★★ 全表四级,只有【天花板】这一级本表可以直着腰说「是我判的,我有理由」——' +
'而那个理由就是这个功能的立意(位阶即杠杆)。多出来的那一档,先回答它的理由出自哪一条原文。'
);
assert.equal(LOST_AT.idx.reasonKind, 'none', '★ 编号:没有人');
// ⚠ 2026-07-17 验收:这里【原来引「同轴 0/784,256」】,删了 —— 恒等式不是读数。
// ★ 'invented' 这顶帽子【不靠那个数】:靠的是 lexCompare【只有】跨轴才裁决得了,
// 而「你的果决比你的交游高吗」原文从不问。⇒ 那是它唯一问得出的问题。
assert.equal(LOST_AT.lex.reasonKind, 'invented', '★★ 字典序:理由是【本表编的】—— 它只问得出跨轴那一问');
assert.equal(LOST_AT.tier.reasonKind, 'self-contradicted', '★★ 位阶:本表【自己写过判别式挡它】');
// ★★ 而这一条把那个自我矛盾钉成了断言,不是一句注释:
assert.equal(
comparableTier('han', 'xiaolian', 'jungong_han'), false,
'★★★ 本表的判别式说这两条通道的 tier 不可比 —— 而择路第一级正是拿它们相减。' +
'这一条【故意红不了】:它证的是那个矛盾【今天仍然存在】,不是它被修了。见 not_done ⑦-b。'
);
});
test('★★★ 每一条空的 kemu,都必须自陈它是【哪一种空】—— 不说清就是撒谎', () => {
const missing = [];
for (const d of DYNASTIES) {
for (const c of d.channels) {
if ((xuanOf(c).kemu || []).length) continue;
const a = kemuAbsenceOf(d.id, c.id);
if (!a) { missing.push(`${d.id}/${c.id}`); continue; }
assert.ok(KEMU_ABSENCE[a.kind], `${d.id}/${c.id}:kind「${a.kind}」不在 KEMU_ABSENCE 里`);
assert.ok(a.why && a.why.length > 40, `${d.id}/${c.id}:why 太短 —— 「留空」必须给出理由,而不是一个标签`);
// ⚠ 2026-07-17 接 25 朝:不变量 =【必须有充实的自陈】。原口径要一个单独的 caveat;
// 接进来的尺子里有一条(liang/liuwai)把限定语并进了 why(251 字,充实)——
// ⇒ 放宽为【有 caveat,或 why 本身充实到能独立成立(>150)】,仍挡得住「一个标签」那种偷懒。
assert.ok(
(a.caveat && a.caveat.length > 0) || (a.why && a.why.length > 150),
`${d.id}/${c.id}:既没有 caveat,why 也不够充实 —— 注的价值全在限定语上`
);
// ★ 常数性必须是一句【写出来的话】,不是一个藏起来的 bug。
assert.match(a.constantTier, /位阶恒为 seat/, `${d.id}/${c.id}:没有把「位阶恒为 seat」说出来`);
}
}
assert.deepEqual(missing, [], '★★ 这些通道的 kemu 是空的,却【没说是哪一种空】—— 那正是「混了就是撒谎」');
});
test('★★★★ 失效方向朝【关】:问错了必须答「不」,不许兜底', () => {
// ⚠ 同 sameScale / comparableTier / closedBy / usableAsInstitution 的一贯口径。
assert.equal(kemuAbsenceOf('不存在的朝', 'bizhao'), null);
assert.equal(kemuAbsenceOf('han', '不存在的通道'), null);
assert.equal(kemuAbsenceOf(null, null), null);
// ★ kemu 非空的通道返回 null —— 那【不是「查不到」,是「这个问题问不出口」】:
// 它不空,「它为什么空」这一问对它不成立。(同 t0 的 unit:null 那一格的口径。)
assert.equal(kemuAbsenceOf('song', 'wuju'), null, '★ 全表唯一一条 kemu 非空的路,不该有「为什么空」');
});
test('★★★ no-ruler 是最强的一档 —— 它必须【挂着原文】,且全表只有够得着的才准用', () => {
// ★ 本档断言的是【那个制度本身没量你】 ⇒ 它要求料 usableAsInstitution()===true。
// ⇒ 所以它【必须挂 source + quote】。没有原文的「制度没量你」,就是「反过来说本表无据」。
const noRuler = [];
for (const d of DYNASTIES) {
for (const c of d.channels) {
const a = kemuAbsenceOf(d.id, c.id);
if (a && a.kind === 'no-ruler') {
noRuler.push(`${d.id}/${c.id}`);
assert.ok(a.source, `${d.id}/${c.id}:断言「制度没量你」却不挂 source —— 那是本表无据`);
assert.ok(a.quote, `${d.id}/${c.id}:断言「制度没量你」却不给引文`);
}
}
}
// ★★ 汉·辟召够得着(四科出【注引书】+黄琼「事竟施行」,两条 usable 的料)。
// ⚠ 2026-07-17 接 25 朝:不再写死【全表只有这一条】—— 接进来的尺子里有 no-ruler 的路
// (如北魏 门第/姓族、陈 诸王起家…),选刀已双证抠好。★ 但那个【最强的一档】的门槛没放宽:
// 上面 for 循环逐条钉着【每一条 no-ruler 都必须挂 source + quote】(没原文的「制度没量你」= 本表无据)。
// ⇒ 不变量 = 汉·辟召仍在其中 + 每一条 no-ruler 都有原文(后者由上面的 for 守着)。
assert.ok(noRuler.includes('han/bizhao'), '★★ 汉·辟召必须仍是一条 no-ruler(四科注引书 + 黄琼事竟施行)');
});
test('★★★★ axis-gap ≠ no-ruler —— 不许拿「我们的表少一维」冒充「那朝没量你」', () => {
// ★ 这一档是这一刀撞出来的第三种空(老板给的二分漏了它)。
// 它【必须挂原文】:本档的全部意思是「原文有科目、有计数,而六轴接不住」——
// ⇒ 没有原文,这句话本身就不成立。
const gaps = [];
for (const d of DYNASTIES) {
for (const c of d.channels) {
const a = kemuAbsenceOf(d.id, c.id);
if (a && a.kind === 'axis-gap') {
gaps.push(`${d.id}/${c.id}`);
assert.ok(a.source && a.quote, `${d.id}/${c.id}:axis-gap 必须挂原文 —— 「原文有科目」是它的前提`);
assert.equal(a.whoIsBlind, '本表的六轴', `${d.id}/${c.id}:axis-gap 的盲必须落在【本表】身上,不是制度`);
}
}
}
// ⚠ 2026-07-17 加西汉:西汉三条盲路(孝廉/贤良/军功)都是 axis-gap ——【有尺子而本表瞎】:
// 孝廉的孝/廉(德行)、贤良的学派、军功的骑射/人头数,都是真的尺子(正文双证),
// 而本表七轴一条都接不住(经学那条轴读的是「你读通了什么」,不量德行/学派/武艺)。
// ★ 每条都挂了原文(source+quote),whoIsBlind 也落在【本表的六轴】上(上面 for 循环已逐条校验)。
// ⚠ 2026-07-17 接 25 朝:不再写死【全表恰好这五条】—— 接进来的尺子里有很多 axis-gap 的路
// (五代/金/南宋的科举都数「策/经义/篇数」,六轴接不住)。★ 门槛没放宽:上面 for 逐条钉着
// 【每一条 axis-gap 都必须挂 source + quote,且 whoIsBlind 落在本表六轴】。
// ⇒ 不变量 = 原有那五条仍在其中(子集)+ 每一条都有原文(后者由上面的 for 守着)。
const knownGaps = ['ming/keju', 'tang/jinshi', 'xihan/jungong', 'xihan/xianliang', 'xihan/xiaolian'];
for (const g of knownGaps) {
assert.ok(gaps.includes(g), `★ ${g} 必须仍是一条 axis-gap(六轴接不住那些科目)`);
}
// ★★ 而这一档的 desc 必须自己说死:它不是制度的盲。
assert.match(KEMU_ABSENCE['axis-gap'].desc, /不许把它写成 no-ruler|绝不许把它写成 no-ruler/);
assert.equal(KEMU_ABSENCE['no-ruler'].whoIsBlind, '制度');
assert.equal(KEMU_ABSENCE.unrecorded.whoIsBlind, '我们');
assert.equal(KEMU_ABSENCE['axis-gap'].whoIsBlind, '本表的六轴');
});
test('★★★ 三种空,用户读到的必须是【三句不同的话】—— 一句话盖住三种,就是那个「混了」', () => {
// ⚠ 这一条守的是 computeRank 的产物,不是数据:19 条路原来共用一句话,
// 而那一句话【真】,只是它把三个不同的真相盖成了一个。
const say = (subs, dyn, shen) => {
const p = { ...profileFromSubs(subs, { wang: 0 }), chushen: shen || null };
const r = computeRank(p, dyn);
return r.reasons.find((s) => s.includes('数不出')) || '';
};
const noRuler = say(['rigorous', 'datadriven'], 'han'); // 汉·辟召
const axisGap = say(['patient', 'calm', 'rigorous'], 'ming'); // 明·科举
const unrec = say(['blunt', 'motivate'], 'liao', 'qidan'); // 辽·武铨
// ★ 三句都必须留着「数不出」—— 合规断言(第八条(五))钉着它,见上面那条测试。
for (const s of [noRuler, axisGap, unrec]) assert.match(s, /数不出/);
// ★★ 而三句必须【互不相同】。
assert.equal(new Set([noRuler, axisGap, unrec]).size, 3, '★★ 三种空说了同一句话 —— 那就是「混了就是撒谎」');
// ★★★ 各自必须把【盲在谁身上】说对:
assert.match(noRuler, /那个制度本来就没打算给你一个高下/, '★ 制度的盲');
assert.match(axisGap, /是本表不够,不是那个朝代没量你/, '★★ 本表的盲 —— 这一句最难说,也最该说');
assert.match(unrec, /本表手上没有这条路的原文/, '★ 我们的盲');
// ⚠ 而【绝不许】让 axis-gap 那一句说成「制度没量你」—— 那正是这一档存在的理由的反面。
assert.ok(!/制度本来就没打算/.test(axisGap), '★★★ axis-gap 说成了制度的盲 —— 那是拿我们的表冒充那个朝代');
});
// ══════════════════════════════════════════════════════════════════════════
// ★★★★★ 2026-07-17 第三刀:【一道闸的那个数,是【代号】不是【刻度】】
// —— 「排在后面 ≠ 不参选」这一族的【第八次】,而这一次它是【一个用户读得到的数】。
//
// ⚠ 本节断言的【方向】先说死,与本文件其余部分一致:
// ①【它不断言引擎判得对】—— 它断言的是【本表说得起它说的那句话】;
// ②【「这个数是挑的」必须是一个【返回值】,不是一句注释】(本文件铁律:注释挡不住任何人);
// ③ ★★★【而最要紧的一条:它钉住的是一个【难看的数】—— 谁把它「修绿」了,
// 谁就是在改引擎的判决,那要老板拍(见 dynasties.js not_done ⑨-c)。】
// ══════════════════════════════════════════════════════════════════════════
import { AXIS_VALUES, gateClassOf, AXES as AXES_, computeAxes as computeAxes_ } from '../src/dynasties.js';
test('★★★ 六轴只取得到五个值 —— 而这一条【是本表自己的曲线算出来的】,不是手写的常量', () => {
// ★ 承重事实:(40,63) 之间【没有任何一个 profile 落得进去】。整个第八次全靠它。
for (const a of AXES_) {
const vals = AXIS_VALUES[a.id];
assert.ok(Array.isArray(vals), `${a.id} 没有 AXIS_VALUES`);
assert.equal(vals.length, a.subs.length + 1, `★ ${a.id}:n 项副天赋只给得出 n+1 个值`);
assert.equal(vals[0], 40, '★ 基线 40 =「不以此见长」,✗ 不是「你毫无X」');
// ★ 逐值复算:必须与 computeAxes 真的跑出来的一致 —— ✗ 不许是一张手抄的表
for (let n = 0; n <= a.subs.length; n++) {
assert.equal(computeAxes_(a.subs.slice(0, n))[a.id], vals[n],
`★★ AXIS_VALUES.${a.id}[${n}] 与 computeAxes 对不上 —— 那它就是一张过期的抄表`);
}
assert.ok(!vals.some((v) => v > 40 && v < 63), `★★★ ${a.id} 在 (40,63) 之间有值 —— 那第八次的证明就塌了`);
}
});
test('★★★★★ 全表 21 道轴闸(六轴 20 + 经学 1):【每一道的那个数,都是本表在 41–63 里挑的一个】—— 零例外', () => {
// ⚠ 2026-07-17 加西汉:西汉博士弟子多了一道 gate{jingxue:55}。★ 经学(第七轴)虽不是「六轴」,
// 但它同样是 sat() 的产物(jingxue 只 1 个 sub ⇒ 只取得到 {40,63})⇒ gate{jingxue:55} 的等价类
// 照样是 41–63(23 个整数是同一道闸)。⇒ 它【不是例外,是又一个「本表挑的数」】,n 从 20 变 21。
let n = 0;
for (const d of DYNASTIES) for (const c of d.channels) {
for (const [axis, need] of Object.entries(c.gate || {})) {
if (axis === 'wang' || axis === 'lao') continue; // ★ 它们不是 sat 的产物 —— 见下一条
n++;
const g = gateClassOf(d.id, c.id, axis);
assert.ok(g, `${d.id}/${c.id} gate{${axis}} 拿不到等价类`);
assert.equal(g.need, need);
assert.ok(g.size > 1, `★★ ${d.id}/${c.id} gate{${axis}:${need}} 的等价类只有一个数 —— 那它就是刻度了`);
// ★★★ 【类里的每一个数,是同一道闸】—— 逐个复算,不是抽样
const vals = AXIS_VALUES[axis];
const passing = (x) => vals.filter((v) => v >= x).join(',');
for (let x = g.class[0]; x <= g.class[1]; x++) {
assert.equal(passing(x), passing(need),
`★★★★ ${d.id}/${c.id}:gate{${axis}:${x}} 与 gate{${axis}:${need}} 开/关不一样 —— 那它们就不是同一道闸`);
}
// ★ 而那句话必须【出得了厂】:它是返回值,不是注释。
assert.match(g.note, /代号/);
assert.match(g.note, /说不起/);
}
}
// ⚠ 2026-07-17 接 25 朝:轴闸数不再写死(接朝后只增不减)。★ 不变量【不弱】:上面 for 逐条钉着
// 【每一道轴闸的 need 都在 41–63 的等价类里(size>1,是本表挑的代号,不是刻度)】,零例外。
assert.ok(n >= 21, `★ 全表轴闸数应 ≥ 21(原有 20 六轴闸 + 西汉经学闸 1;接朝后更多)—— 实得 ${n}`);
});
test('★ gateClassOf 失效方向朝【关】—— 与 sameScale / comparableTier / closedBy 同一条规矩', () => {
assert.equal(gateClassOf('NOPE', 'xiaolian', 'jiao'), null, '朝代问错 ⇒ null');
assert.equal(gateClassOf('han', 'NOPE', 'jiao'), null, '通道问错 ⇒ null');
assert.equal(gateClassOf('han', 'xiaolian', 'NOPE'), null, '轴问错 ⇒ null');
assert.equal(gateClassOf('han', 'bizhao', 'jiao'), null, '★ 这条闸上没有这条轴 ⇒ null,✗ 不许兜底');
// ★★★ 而【wang 返回 null 的意思是「这一问对它问不出口」,✗ 不是「它也是代号」】:
// wang 是 0–100 的自由值 ⇒ gate{wang:30} 的 30 是【真的 30】,你的 29 是【真的 29】。
// ⚠ 这一条是本刀【自己差点栽的】那一格:第一版的文案对每一个缺口都说「这个数是本表挑的」,
// 而拿它去说 wang【是假的】。★【一句拆自己台的话,本身也必须是真的】——
// 否则它就是又一句「摆在用户面前而什么都没买到」的话,正是本刀在抓的那一族。
assert.equal(gateClassOf('han', 'xiaolian', 'wang'), null, '★★ wang 不是 sat 的产物 —— 这一问对它问不出口');
assert.ok(!AXIS_VALUES.wang, '★★ wang 不许进 AXIS_VALUES —— 它不是六轴,它是另一把尺');
});
test('★★★★★ 李广:同一道闸的八个写法 → 一半判「军功」,一半判「辟召」', () => {
// ★ 李广:renwu.js 双证 subs=[] ⇒ 六轴全 40。而《史記》卷109 双证钉着:他的实职【就是靠军功挣的】。
const li = { axes: computeAxes_([]), wang: 0 };
const jg = getDynasty('han').channels.find((c) => c.id === 'jungong_han');
const cls = gateClassOf('han', 'jungong_han', 'wu');
const orig = jg.gate.wu;
const got = {};
try {
for (const n of [41, 45, 50, 54, 55, 56, 60, 63]) {
assert.ok(n >= cls.class[0] && n <= cls.class[1], `${n} 不在等价类里 —— 这条测试写错了`);
jg.gate.wu = n;
const r = computeRank(li, 'han');
// ★ 每一个写法【都是同一道闸】:他三条路照样全关,逐格相同
assert.equal(r.channel, null, '★ 换写法不许改变「一条路都不开」这件事 —— 那才叫同一道闸');
assert.equal(r.blocked.length, 3);
got[n] = r.nearest;
}
} finally { jg.gate.wu = orig; }
// ★★★★★ 这就是这一刀的判词:同一道闸,两个答案。
assert.equal(new Set(Object.values(got)).size, 2, '★★★ 同一道闸的八个写法,判出的 nearest 应当【有两种】—— 而那正是问题所在');
assert.match(got[41], /军功/, '★★ 写 41(同一道闸)⇒ 军功是最接近的那条');
assert.match(got[54], /军功/);
assert.match(got[55], /辟召/, '★★ 写 55(同一道闸)⇒ 军功不见了');
assert.match(got[63], /辟召/);
// ⇒ ★★★★【「军功不是李广最接近的那条」不是一个关于李广的事实,是【本表挑了 55】这个事实。】
// ⚠ 谁要动它:改判决要老板拍(not_done ⑨-c),✗ 不许把这条测试删了了事。
assert.equal(orig, 55, '★ 本表现在挑的是 55 —— 这一条钉住它:改了就要在这里说清楚为什么');
});
test('★★★★ nearest 自陈【是哪一级判的】—— 而「本表真的有理由」的只剩 count 一级', () => {
const li = { axes: computeAxes_([]), wang: 0 };
const byIdx = computeRank(li, 'han');
assert.equal(byIdx.nearestBy, 'idx', '★ 李广:条数打平、缺口打平 ⇒ 本表的编号');
assert.equal(byIdx.nearestGap, null, '★ 不是 gap 判的 ⇒ nearestGap 必须是 null,✗ 不许给一个空壳');
assert.deepEqual(byIdx.nearestTie, ['军功']);
// 交游点过一项(63≥55)、wang=29(差 1) ⇒ 察举只差声望;辟召差吏能 15;军功差果决 15 ⇒ gapOf 裁决
const g = computeRank({ axes: computeAxes_(['warm']), wang: 29 }, 'han');
assert.equal(g.nearestBy, 'gap');
assert.ok(g.nearestGap);
// ★★★ 【真尺 vs 代号】—— 汉的 gapOf 裁决【每一次】都是这一格(穷举:960 + 960 = 1,920 条次)
assert.equal(g.nearestGap.mine.kind, 'scale', '★★ 声望是 0–100 的自由值 ⇒ 它的「差 1」是真的 1 分');
assert.equal(g.nearestGap.rivals.length, 2, '★★ gapOf 是拿【全部】同条数的对手比的 —— ✗ 只报第一个就是少报了一半');
assert.ok(g.nearestGap.rivals.every((r) => r.kind === 'codename'), '★★ 吏能/果决的「差 15」都是 41–63 里挑的 55 减出来的');
assert.equal(g.nearestGap.mixedKind, true, '★★★ 本表把一把真尺子和一个代号,放进了同一个 Math.max');
assert.equal(g.nearestGap.sameAxis, false);
// ★★ 而那句拆自己台的话【必须每一个字都是真的】:
const line = g.reasons.find((x) => /最接近的那条是怎么挑出来的/.test(x));
assert.ok(line, '★ gapOf 裁决了,而它没出声');
assert.match(line, /吏能差 15」这个数不是一段距离/, '★★ 代号那一边必须自陈是代号');
assert.match(line, /41–63 这 23 个/, '★★★ 必须报出那个等价类 ——「差 15」的全部依据就是本表挑了 55');
assert.match(line, /两个数不是同一种东西/, '★★★ 真尺 vs 代号这一格必须说出来');
// ⚠⚠ 本刀差点栽的那一格:✗【绝不许】把声望那 1 分也说成「挑的」—— 那句话对 wang 是【假的】。
assert.ok(!/声望差 1」这个数不是一段距离/.test(line), '★★★★ 把真尺说成代号 —— 那句拆台的话自己成了假话');
});
test('★ nearestBy 与 nearest 的 sort 【逐字同源】—— ✗ 不许两把尺子', () => {
const SUBS_ = SUB_REGISTRY.map((s) => s.id);
const seen = { count: 0, gap: 0, idx: 0 };
for (let m = 0; m < 512; m++) {
const subs = []; for (let i = 0; i < 16; i++) if (m & (1 << i)) subs.push(SUBS_[i]);
for (const wang of [0, 29, 30, 55, 75]) for (const d of DYNASTIES) {
const r = computeRank({ axes: computeAxes_(subs), wang }, d.id);
if (r.channel || !r.nearest) continue;
assert.ok(['count', 'gap', 'idx'].includes(r.nearestBy), `nearestBy 冒出了第四个值:${r.nearestBy}`);
seen[r.nearestBy]++;
// ★ idx ⟺ nearestTie 非空;gap ⟺ nearestGap 非 null。两处不许分叉。
assert.equal(r.nearestBy === 'idx', r.nearestTie.length > 0, '★★ nearestBy 与 nearestTie 分叉了');
assert.equal(r.nearestBy === 'gap', r.nearestGap !== null, '★★ nearestBy 与 nearestGap 分叉了');
assert.equal(r.nearestIsDefault, r.nearestBy === 'idx');
}
}
assert.ok(seen.idx > 0 && seen.gap > 0, '★ 抽样里 idx 与 gap 两级都得跑到,否则这条测试什么都没验');
});
test('★★ 时代错置:本表对【自己写的文案】,要和对【引文】用同一条纪律', () => {
// ★ 本表对宋武举「天圣八年」那句的判决逐字是「故连句不引」—— 早了 38 年就不引。
// ⇒ 那对【本表自己举的例子】也得是同一条:例子和引文一样在用户眼前。
const wj = getDynasty('weijin').channels.find((c) => c.id === 'jungong_wj');
assert.ok(!/[刘劉]裕/.test(wj.gateNote), '★★ 刘裕是东晋末的人,而本朝 era=西晋 span=265–316 —— 晚了近百年');
const fz = getDynasty('tang').channels.find((c) => c.id === 'fanzhen');
assert.ok(!/中晚唐/.test(fz.gateNote), '★★ 本朝 era=盛唐 span=712–755 —— 本表自己的文案把它放在了 span 之外');
// ★ 而【删掉之后,不许有人补一个我没查过的说法回来】—— 那是假订正。两条通道的 kemuAbsence 钉着这句话。
assert.match(fz.xuan.kemuAbsence.why, /不许有人补一句「盛唐求进的实路」/);
assert.match(wj.xuan.kemuAbsence.why, /不许有人再拿一个东晋的人回来举例/);
// ⚠ 而它们【仍然没有 source】—— 删掉一个错例子,不等于补上了一条原文。
assert.equal(wj.source, undefined);
assert.equal(fz.source, undefined);
});
test('★★★★★ 唐:进士与行卷【对文采的要求是同一道闸】—— 而本表一处写 50、一处写 55', () => {
// ★ 这一条比李广那条更干净:它连「跨轴」这个辩解都没有 —— 两道闸【在同一条轴上】。
const a = gateClassOf('tang', 'jinshi', 'wen');
const b = gateClassOf('tang', 'xingjuan', 'wen');
assert.equal(a.need, 50);
assert.equal(b.need, 55);
// ★★ 而它们是【同一道闸】:wen ≥ 50 ⟺ wen ≥ 55 ⟺ wen ∈ {63,73,79}
assert.deepEqual(a.class, b.class, '★★★ 两道闸的等价类不一样 —— 那本条的判词就塌了');
const vals = AXIS_VALUES.wen;
assert.deepEqual(vals.filter((v) => v >= 50), vals.filter((v) => v >= 55),
'★★★★ 「文采 50」与「文采 55」放行的是同一批人 —— 它们是同一道闸');
// ⚠ 要撞到【同轴】那一格,行卷的另一道闸 jiao 必须先过(否则它差两条,根本不同条数)。
// ⇒ 点一项交游 ⇒ jiao=63≥55。此时:进士差[文采 10]、行卷差[文采 15]、藩镇差[吏能 15],各 1 条。
const p = { axes: computeAxes_(['warm']), wang: 0 };
assert.equal(p.axes.wen, 40);
assert.ok(p.axes.jiao >= 55);
const r = computeRank(p, 'tang');
assert.equal(r.channel, null, '★ 前提:文采 40 的唐人,一条路都不开');
const gapOn = (re, axis) => { const m = r.blocked.find((x) => re.test(x.channel)).missing.find((y) => y.axis === axis); return m.need - m.got; };
assert.equal(gapOn(/进士/, 'wen'), 10);
assert.equal(gapOn(/行卷/, 'wen'), 15);
// ★★★★★【那 5 分不是文采。那 5 分是本表一处写了 50、一处写了 55。】
assert.equal(r.nearestBy, 'gap');
assert.match(r.nearest, /进士/, '★★ 引擎判「你离进士近」—— 凭那 5 分');
assert.equal(r.nearestGap.mine.kind, 'codename');
assert.ok(r.nearestGap.rivals.some((x) => /行卷/.test(x.channel) && x.axis === 'wen'),
'★★★ 行卷必须在 rivals 里 —— 它就是那个「同一道闸、另一个写法」的对手');
assert.ok(r.nearestGap.rivals.every((x) => x.kind === 'codename'), '★★★★ 这一比里,每一个数都是【挑的】');
// ⚠ 而它【碰巧与编号同答】:把进士的 50 改写成 55(★ 同一道闸)⇒ nearest 一格都不变。
// ★ 那正是本文件的铁律:【「碰巧成立」和「守着」是两回事。】
const js = getDynasty('tang').channels.find((c) => c.id === 'jinshi');
try {
js.gate.wen = 55;
const r2 = computeRank(p, 'tang');
assert.equal(r2.channel, null, '★ 同一道闸 ⇒ 开/关必须一格不动');
assert.equal(r2.nearest, r.nearest, '★ 改成 55 之后 nearest 没变 —— 它凭的是运气,不是守着');
assert.equal(r2.nearestBy, 'idx', '★★ 而这时它自陈的是【编号】—— ★ 同一个答案,两个理由');
} finally { js.gate.wen = 50; }
});
test('★★★★★ 明:科举与吏员出职【对吏能的要求是同一道闸】—— 而那 5 分决定了「你离哪条路近」', () => {
// ★★ 这一条是全表最干净的一格,理由不是它的数,是【它落在哪一朝】:
// 明正是「位阶即杠杆」这条纪律的立身之本(computeRank 逐字:「明代一个能过科举(ceiling 5)
// 的人,吏员出职(ceiling 2)那条路的原始分往往更高……通道的价值在天花板,不在原始分」)。
const a = gateClassOf('ming', 'keju', 'fa');
const b = gateClassOf('ming', 'lisi', 'fa');
assert.equal(a.need, 50);
assert.equal(b.need, 55);
// ★★★ 同一条轴、同一个等价类 ⇒ 【同一道闸】:fa ≥ 50 ⟺ fa ≥ 55 ⟺ fa ∈ {63,73,79,83}
assert.deepEqual(a.class, b.class);
const vals = AXIS_VALUES.fa;
assert.deepEqual(vals.filter((v) => v >= 50), vals.filter((v) => v >= 55),
'★★★★ 「吏能 50」与「吏能 55」放行的是同一批人 —— 科举与吏员对吏能的要求【一模一样】');
// 沉毅满(heng=73≥55 过) + 吏能 40 ⇒ 科举差[吏能 10]、吏员差[吏能 15],各 1 条 ⇒ 同轴
const p = { axes: computeAxes_(['patient', 'calm']), wang: 0 };
assert.equal(p.axes.fa, 40);
assert.ok(p.axes.heng >= 55);
const r = computeRank(p, 'ming');
assert.equal(r.channel, null, '★ 前提:吏能 40 的明人,两条路都不开');
assert.equal(r.nearestBy, 'gap');
assert.equal(r.nearestGap.sameAxis, true, '★ 这一格是【同轴】—— gapOf 唯一一个看似站得住的用法');
assert.match(r.nearest, /科举/);
assert.equal(r.nearestGap.mine.gap, 10);
assert.equal(r.nearestGap.rivals[0].gap, 15);
// ★★★★★【那 5 分不是吏能。那 5 分是本表一处写了 50、一处写了 55。】
assert.equal(r.nearestGap.mine.kind, 'codename');
assert.equal(r.nearestGap.rivals[0].kind, 'codename');
assert.equal(r.nearestGap.mixedKind, false, '★ 两边都是代号 ⇒ 这一格不是「真尺 vs 代号」,是「代号 vs 代号」');
// ★★ 而那句话必须【当着用户的面把这一点说穿】——这是本表唯一一格「同轴」,
// 若连这一格都不说,那这个字段就只是在挑软柿子捏。
const line = r.reasons.find((x) => /最接近的那条是怎么挑出来的/.test(x));
assert.match(line, /是同一条轴/, '★★ 同轴那一格必须自陈它是同轴 —— ✗ 不许拿「跨轴」当唯一的罪名');
assert.match(line, /那个「差得少」的差,全部来自本表在这两处写了两个不同的数/);
assert.ok(!/不是同一条轴/.test(line), '★★★ 同轴的一格说成「不是同一条轴」—— 那句拆台的话自己成了假话');
});
test('★★★★ 自查:这一刀新加的每一个取值,都必须真的跑得到 —— ✗ 不许造第二个空操作', () => {
// ⚠ 本文件的铁律逐字:「不制造第二个」。而第八刀在「让它出声」那一刀里【自己造了一个】(byZelu),
// 本刀又造了一个(kind:'unique',已删)。★ ⇒ 这条测试是那个教训的【结构化】:
// 【一个新字段,写完先问它自己:你的每一个取值,跑到了几格?】
const SUBS_ = SUB_REGISTRY.map((s) => s.id);
const hit = { count: 0, gap: 0, idx: 0, sameAxis: 0, crossAxis: 0, mixed: 0, unmixed: 0, scale: 0, codename: 0, rivals2plus: 0 };
for (let m = 0; m < 4096; m++) {
const subs = []; for (let i = 0; i < 16; i++) if (m & (1 << i)) subs.push(SUBS_[i]);
for (const wang of [0, 29, 30, 55, 75]) for (const d of DYNASTIES) {
const r = computeRank({ axes: computeAxes_(subs), wang }, d.id);
if (r.channel || !r.nearest) continue;
hit[r.nearestBy]++;
if (r.nearestBy !== 'gap') continue;
const g = r.nearestGap;
hit[g.sameAxis ? 'sameAxis' : 'crossAxis']++;
hit[g.mixedKind ? 'mixed' : 'unmixed']++;
if (g.rivals.length > 1) hit.rivals2plus++;
for (const s of [g.mine, ...g.rivals]) hit[s.kind]++;
}
}
for (const [k, v] of Object.entries(hit)) {
assert.ok(v > 0, `★★★★ 【${k}】一格都没跑到 —— 那它就是一个新的空操作。删它,或者说清楚它为什么在。`);
}
// ★ 而 kind 只有两档:'codename'(六轴,等价类 ≥4)与 'scale'(wang,真尺)。
// ⚠ 第三档 'unique'(等价类只有一个数)【已删】——【它不可能跑到】:
// 逐轴扫 need 1–100,最小的等价类是 4。⇒ 与 penaltyIfNoWang 同形(条件与事实互斥)。
for (const [axis, vals] of Object.entries(AXIS_VALUES)) {
const passing = (n) => vals.filter((v) => v >= n).join(',');
const sizes = {};
for (let n = 1; n <= 100; n++) { const k = passing(n); sizes[k] = (sizes[k] || 0) + 1; }
assert.ok(Math.min(...Object.values(sizes)) > 1,
`★★★ ${axis} 上出现了 size===1 的等价类 —— 那 kind:'unique' 就该活过来,这条注释也得改`);
}
});