把第五轮复验用的取证探针固化入库。两者都不枚举「我想到的场景」,而是把 design 的规则整条转写成参考实现再与代码大面积对拍,专治手写用例覆盖不到 的欠约束分支: - test_calc.js:§7 算子全量对拍。参考实现逐字转写自 §7.1/§7.2/§7.3 判定表, 比对 2 种算子模式 × 14 个叫分档 × grade 0~260 共 7308 格(每格比 base/Q/判定倍率/最终子数),另按 design 的 17 张表逐格抽查 141 个写死的 最终子数。断言按档位分组,失败可直接定位。 - test_followdiff.js:§5.2 跟牌强制层级穷举差分。对每手牌枚举全部 C 张出牌 组合,逐个比对参考裁定与 can_followcard,约 11 万组。 两组均通过变异检验:关掉 follow_tractor_cover_ok → followdiff 三种拖拉机 首出全红;大光倍率 3→4 / 常规55档 base 4→5 / 爬坡40·35档 Q 20→40 → calc 精确指出档位与 grade。 过程中的教训已写进 test/README 与 compliance:差分测试的强度取决于造牌器。 第一版纯随机抽牌跑 7.8 万组全绿,但关掉覆盖度校验依然全绿——「同花色三组 互不相邻的两连对」这种唯一能区分「只出零散对子」与「先凑最长拖拉机」的 12 张结构随机抽不出来;掺牌又一度把它撑过枚举上限被静默跳过。故随机源改用 固定种子 PRNG,关键结构用确定性 structHands() 钉死。 全套单测 401 → 462 项全绿,总耗时 < 1.5s。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
237 lines
12 KiB
JavaScript
237 lines
12 KiB
JavaScript
// §5.1/§5.2 跟牌强制层级 · 穷举差分测试
|
||
//
|
||
// 做法:把 design §5.2 的裁定规则**按原文另写一份参考实现**(本文件上半部分,只依据 design
|
||
// 的文字,不看 class.arith 的实现),再随机造手牌、**枚举该手牌的全部 C 张出牌组合**,
|
||
// 逐个比对「参考实现允许 vs can_followcard 允许」。任何一侧多放行或多拒绝都会被抓到。
|
||
//
|
||
// 这类差分比 test_follow.js 的手写正反例更能兜住实现里的欠约束分支
|
||
// (get_followcard 降级递归的 else 分支就曾在这类场景漏判)。
|
||
// 随机源用**固定种子的 PRNG**,不用 Math.random —— 同一份代码每次跑的用例集完全相同,不抖动。
|
||
require('./_shim');
|
||
const A = require('../class.arith.js');
|
||
const t = require('./_assert')();
|
||
|
||
const MF = 4; // 主花色 = 黑桃
|
||
const id = (d, f, n) => (d - 1) * 54 + (f - 1) * 13 + (n - 1);
|
||
const WG = (d, big) => (d - 1) * 54 + (big ? 53 : 52);
|
||
|
||
// ==================== 参考实现(逐条转写自 design §3/§5.2/§5.3)====================
|
||
const num = c => { const m = c % 54; return m === 52 ? 53 : m === 53 ? 54 : m % 13 + 1; };
|
||
const flw = c => { const m = c % 54; return m >= 52 ? 5 : Math.floor(m / 13) + 1; };
|
||
// §3:主牌 = 大小王 + 所有花色的 2 和 7 + 主花色普通牌
|
||
const isTrump = c => { const n = num(c); return n >= 53 || n === 2 || n === 7 || flw(c) === MF; };
|
||
// 跟牌时的「有效花色」:主牌一律归主花色
|
||
const eflw = c => isTrump(c) ? MF : flw(c);
|
||
// §3 主牌顺序 / §5.3 相邻链上的位置(越小越大);副7、副2 各自只占一个等级
|
||
const NORMAL = [1, 13, 12, 11, 10, 9, 8, 6, 5]; // A K Q J 10 9 8 6 5
|
||
function pos(c) {
|
||
const n = num(c), f = flw(c);
|
||
if (n === 54) return 0;
|
||
if (n === 53) return 1;
|
||
if (n === 7) return f === MF ? 2 : 3;
|
||
if (n === 2) return f === MF ? 4 : 5;
|
||
if (f === MF) return 6 + NORMAL.indexOf(n);
|
||
return 100 + NORMAL.indexOf(n); // 副牌:只在同花色内比较
|
||
}
|
||
// §3「对子指同一张具体牌的两副」= 同花色同点数
|
||
const samecard = (a, b) => num(a) === num(b) && flw(a) === flw(b);
|
||
// 取对子(返回每对的代表牌,按大到小)
|
||
function pairsOf(cs) {
|
||
const used = [], ps = [];
|
||
for (let i = 0; i < cs.length; i++) {
|
||
if (used[i]) continue;
|
||
for (let j = i + 1; j < cs.length; j++) {
|
||
if (!used[j] && samecard(cs[i], cs[j])) { used[i] = used[j] = 1; ps.push(cs[i]); break; }
|
||
}
|
||
}
|
||
ps.sort((a, b) => pos(a) - pos(b));
|
||
return ps;
|
||
}
|
||
// §5.3:相邻对子连成拖拉机 → 各连对段的长度,降序(孤立对子长度 1)
|
||
function runsOf(cs) {
|
||
const ps = pairsOf(cs), runs = [];
|
||
let cur = 1;
|
||
for (let i = 0; i + 1 < ps.length; i++) {
|
||
if (pos(ps[i + 1]) - pos(ps[i]) === 1) cur++;
|
||
else { runs.push(cur); cur = 1; }
|
||
}
|
||
if (ps.length) runs.push(cur);
|
||
runs.sort((a, b) => b - a);
|
||
return runs;
|
||
}
|
||
// §5.2 层级2「能凑多长的拖拉机,就必须先凑多长」→ 最长优先的贪心规格档案
|
||
function bestProfile(runs, k) {
|
||
const av = runs.concat().sort((a, b) => b - a), p = [];
|
||
let budget = k, i = 0;
|
||
while (budget > 0 && i < av.length) { const take = Math.min(av[i], budget); p.push(take); budget -= take; i++; }
|
||
return p;
|
||
}
|
||
function lessProfile(a, b) {
|
||
const n = Math.min(a.length, b.length);
|
||
for (let i = 0; i < n; i++) { if (a[i] < b[i]) return true; if (a[i] > b[i]) return false; }
|
||
return a.length < b.length;
|
||
}
|
||
// 参考裁定:这手 X 是不是 design §5.2 允许的跟牌
|
||
function refOk(hand, X, C, F, T) {
|
||
if (X.length !== C) return false;
|
||
const S = hand.filter(c => eflw(c) === F);
|
||
if (S.length === 0) return true; // §5.2 完全缺门:整手可任意出
|
||
if (S.length < C) return S.every(c => X.indexOf(c) >= 0); // §5.2 该花色不够:全出 + 缺口任意补
|
||
if (X.filter(c => eflw(c) === F).length !== C) return false; // 够:必须全打同花色
|
||
if (T > 100 && T < 200) return true; // 单张 / 多张单张
|
||
if (T === 201) return pairsOf(S).length === 0 ? true : pairsOf(X).length >= 1; // 有对必出对
|
||
if (T > 201 && T < 300) return true; // 多对(甩牌压平):分量约束在 flush_follow_ok
|
||
if (T > 300 && T < 400) { // N 连对拖拉机
|
||
const N = T - 300, k = Math.min(pairsOf(S).length, N); // 能凑几对就必须凑几对(层级 3/4/5)
|
||
if (pairsOf(X).length !== k) return false;
|
||
return !lessProfile(runsOf(X), bestProfile(runsOf(S), k)); // 且拖拉机规格不得劣于最优(层级 1/2)
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ==================== 穷举差分 ====================
|
||
// 固定种子的 xorshift32,保证用例集可复现
|
||
let _seed = 0x2717CAFE;
|
||
function rnd(n) { _seed ^= _seed << 13; _seed ^= _seed >>> 17; _seed ^= _seed << 5; _seed >>>= 0; return _seed % n; }
|
||
function combos(arr, k) {
|
||
const out = [];
|
||
(function rec(st, cur) {
|
||
if (cur.length === k) { out.push(cur.slice()); return; }
|
||
for (let i = st; i < arr.length; i++) { cur.push(arr[i]); rec(i + 1, cur); cur.pop(); }
|
||
})(0, []);
|
||
return out;
|
||
}
|
||
|
||
// 造牌器必须**按牌型结构**造手牌,不能纯随机抽:纯随机几乎抽不出「同花色 3 对以上、
|
||
// 且含多组不同长度拖拉机」的手,而 §5.2 层级 1/2 的强制约束恰恰只在那种手上才生效
|
||
// (实测纯随机版关掉 follow_tractor_cover_ok 也全绿 = 差分形同虚设)。
|
||
// 这里按「等级表」逐级选取 0/1/2 张,天然产出富对子、富连对的手牌。
|
||
|
||
// 副花色(梅花)的等级表:A K Q J 10 9 8 6 5(相邻即连对)
|
||
const SUB_RANKS = NORMAL.map(n => [id(1, 2, n), id(2, 2, n)]);
|
||
// 主牌的等级表 = design §5.3 的相邻链:大王-小王-正7-副7-正2-副2-主A…主5
|
||
const TRUMP_RANKS = [
|
||
[WG(1, true), WG(2, true)], // 大王
|
||
[WG(1, false), WG(2, false)], // 小王
|
||
[id(1, MF, 7), id(2, MF, 7)], // 正7
|
||
[id(1, 3, 7), id(2, 3, 7)], // 副7(红心)
|
||
[id(1, MF, 2), id(2, MF, 2)], // 正2
|
||
[id(1, 3, 2), id(2, 3, 2)] // 副2(红心)
|
||
].concat(NORMAL.map(n => [id(1, MF, n), id(2, MF, n)]));
|
||
// 同级但不同花色的另一张副7/副2(§3:跨花色不成对,用于压边界)
|
||
const SAME_LEVEL = [id(1, 2, 7), id(2, 2, 7), id(1, 2, 2), id(2, 2, 2)];
|
||
// 垫牌用的第三花色
|
||
const FILLER = [];
|
||
[1, 13, 12, 10, 8, 5].forEach(n => FILLER.push(id(1, 3, n), id(2, 3, n)));
|
||
|
||
function buildHand(F, C) {
|
||
const ranks = (F === MF) ? TRUMP_RANKS : SUB_RANKS;
|
||
// 覆盖 §5.2 的四类分支:完全缺门 / 该花色不够 / 恰好够 / 有富余
|
||
const mode = rnd(4);
|
||
let targetSuit;
|
||
if (mode === 0) targetSuit = 0;
|
||
else if (mode === 1) targetSuit = 1 + rnd(Math.max(1, C - 1));
|
||
else if (mode === 2) targetSuit = C;
|
||
else targetSuit = C + rnd(4);
|
||
|
||
const suit = [];
|
||
// 从随机起点顺链扫,每级取 0/1/2 张(偏向取 2 张以造出对子与连对)
|
||
let idx = rnd(ranks.length);
|
||
for (let step = 0; step < ranks.length && suit.length < targetSuit; step++) {
|
||
const r = ranks[(idx + step) % ranks.length];
|
||
const w = rnd(10);
|
||
const take = w < 5 ? 2 : w < 8 ? 1 : 0;
|
||
for (let q = 0; q < take && suit.length < targetSuit; q++) suit.push(r[q]);
|
||
}
|
||
// 顺链一圈还没凑够就再补(保证 targetSuit 尽量达成)
|
||
for (let step = 0; step < ranks.length && suit.length < targetSuit; step++) {
|
||
const r = ranks[step];
|
||
for (let q = 0; q < 2 && suit.length < targetSuit; q++) if (suit.indexOf(r[q]) < 0) suit.push(r[q]);
|
||
}
|
||
// 同级不同花色的副7/副2 偶尔掺一张(只在主牌为跟牌花色时才算同花色)
|
||
if (F === MF && rnd(6) === 0) suit.push(SAME_LEVEL[rnd(SAME_LEVEL.length)]);
|
||
|
||
// 非该花色的补充牌:垫牌花色 + (副牌首出时)主牌,用于毙牌/垫牌/混合出牌分支
|
||
const others = [];
|
||
const otherPool = (F === MF) ? FILLER.concat() : FILLER.concat(TRUMP_RANKS.reduce((a, r) => a.concat(r), []));
|
||
const oc = rnd(4);
|
||
for (let i = 0; i < oc && otherPool.length; i++) others.push(otherPool.splice(rnd(otherPool.length), 1)[0]);
|
||
|
||
// 去重后返回(同级不同花色那一张可能与 others 撞车)
|
||
const seen = {}, hand = [];
|
||
suit.concat(others).forEach(c => { if (!seen[c]) { seen[c] = 1; hand.push(c); } });
|
||
return hand;
|
||
}
|
||
|
||
// [张数, 首家花色, 首家牌型, 说明]
|
||
const LEADS = [
|
||
[1, 2, 101, '副牌单张'],
|
||
[2, 2, 201, '副牌对子'],
|
||
[4, 2, 302, '副牌两连对'],
|
||
[6, 2, 303, '副牌三连对'],
|
||
[8, 2, 304, '副牌四连对'],
|
||
[1, MF, 101, '主牌单张'],
|
||
[2, MF, 201, '主牌对子'],
|
||
[4, MF, 302, '主牌两连对'],
|
||
[6, MF, 303, '主牌三连对'],
|
||
[3, MF, 103, '主牌甩三单张(压平)'],
|
||
[4, MF, 202, '主牌甩两对(压平)']
|
||
];
|
||
|
||
// 结构化手牌(确定性):随机造牌打不到的关键结构在此显式钉住,
|
||
// 不让 PRNG 决定 §5.2 层级 1/2 这类强制约束到底有没有被覆盖。
|
||
// 例如「同花色三组互不相邻的两连对」共 12 张,是唯一能让「只出零散对子」与
|
||
// 「先凑最长拖拉机」产生不同结果的形状——少于 3 组时任取 3 对必含相邻对,测不出差别。
|
||
function structHands(F) {
|
||
const R = (F === MF) ? TRUMP_RANKS : SUB_RANKS;
|
||
const p = i => R[i]; // 第 i 级的一对
|
||
const one = i => [R[i][0]]; // 第 i 级的单张
|
||
const cat = (...xs) => [].concat(...xs);
|
||
return [
|
||
cat(p(0), p(1), p(3), p(4), p(6), p(7)), // 三组两连对(互不相邻)
|
||
cat(p(0), p(1), p(2), p(4), p(5), p(7)), // 一个三连对 + 一个两连对 + 一个孤立对
|
||
cat(p(0), p(2), p(4), p(6), p(8)), // 五个孤立对(无任何拖拉机)
|
||
cat(p(0), p(1), p(2), p(3), p(5)), // 一个四连对 + 一个孤立对
|
||
cat(p(0), p(1), p(3), one(5), one(7), one(8)), // 两连对 + 孤立对 + 三张单
|
||
cat(p(0), one(2), one(4), one(6), one(8)), // 一对 + 四张单(对子不够)
|
||
cat(one(0), one(1), one(2), one(3), one(4), one(5), one(6)), // 全单张(无对)
|
||
cat(p(0), p(1), p(2)), // 恰好一个三连对
|
||
cat(p(0), p(1)), // 恰好一个两连对
|
||
cat(one(0), one(1)), // 两张单
|
||
[], // 完全缺门
|
||
cat(one(0)), // 该花色仅 1 张(不够)
|
||
cat(p(0), one(2)) // 该花色 3 张(对子 + 单)
|
||
].map(suit => {
|
||
// 掺几张别的花色,制造垫牌 / 毙牌 / 混合出牌分支;
|
||
// 但同花色已经很多时不再掺——否则手牌被撑过枚举上限、整条用例会被静默跳过,
|
||
// 而「三组两连对」这类 12 张的关键结构恰恰就在上限边缘
|
||
const extra = (F === MF) ? FILLER : FILLER.concat([TRUMP_RANKS[0][0], TRUMP_RANKS[2][0], TRUMP_RANKS[6][0], TRUMP_RANKS[7][0]]);
|
||
return suit.concat(extra.slice(0, Math.max(0, Math.min(3, 12 - suit.length))));
|
||
});
|
||
}
|
||
|
||
const TRIALS_PER_LEAD = 320;
|
||
let grandCombos = 0;
|
||
for (const [C, F, T, desc] of LEADS) {
|
||
let combosCount = 0;
|
||
const bad = [];
|
||
const hands = structHands(F);
|
||
for (let k = 0; k < TRIALS_PER_LEAD; k++) hands.push(buildHand(F, C));
|
||
for (const hand of hands) {
|
||
if (hand.length < C || hand.length > 13) continue; // 组合数上界保护
|
||
for (const X of combos(hand, C)) {
|
||
combosCount++;
|
||
const got = A.can_followcard(MF, hand, X, C, F, T).result === true;
|
||
const exp = refOk(hand, X, C, F, T);
|
||
if (got !== exp && bad.length < 3) bad.push({ hand: hand.slice(), play: X.slice(), code: got, design: exp });
|
||
else if (got !== exp) bad.push('...');
|
||
}
|
||
}
|
||
grandCombos += combosCount;
|
||
t.eq('§5.2 穷举差分 ' + desc + '(' + combosCount + ' 组)失配', bad, []);
|
||
}
|
||
// 钉住覆盖规模:枚举数量若骤降说明造牌逻辑退化、差分形同虚设
|
||
t.eq('§5.2 差分覆盖组合数 ≥ 20000', grandCombos >= 20000, true);
|
||
|
||
process.exit(t.done('followdiff') ? 0 : 1);
|