test-rank-parity.mjs
GitHub
/* ===================================================================== Rank parity test: the online engine (functions/api/_engine.js, driven here through its real HTTP handlers) must produce exactly the same ranks and distances as the client-side buildRanks() in js/app.js. Usage: node test-rank-parity.mjs (uses site/ built by build-variant.mjs) node test-rank-parity.mjs en ja fr ===================================================================== */import fs from 'node:fs';import path from 'node:path';import { pathToFileURL } from 'node:url';const ROOT = 'D:/workspace/guessword';const SITE = path.join(ROOT, 'site');const LANGS = process.argv.slice(2).length ? process.argv.slice(2) : ['en', 'ja', 'fr'];/* ---------- asset access over the built site/ ---------- */function assetBytes(p) { const clean = p.split('?')[0].replace(/^\//, ''); const file = path.join(SITE, clean); if (!fs.existsSync(file)) throw new Error('asset missing: ' + clean); return new Uint8Array(fs.readFileSync(file));}const fakeEnv = { ASSETS: { fetch: (req) => { try { return Promise.resolve(new Response(assetBytes(new URL(req.url).pathname))); } catch (e) { return Promise.resolve(new Response('missing', { status: 404 })); } }, },};const ctx = (url, body) => ({ request: new Request(url, body === undefined ? {} : { method: 'POST', body: JSON.stringify(body) }), env: fakeEnv,});/* ---------- client-side reference (verbatim algorithm from js/app.js) ---------- */function rngRef(seedStr) { let h = 1779033703 ^ seedStr.length; for (let i = 0; i < seedStr.length; i++) { h = Math.imul(h ^ seedStr.charCodeAt(i), 3432918353); h = h << 13 | h >>> 19; } let t = (h = Math.imul(h ^ h >>> 16, 2246822507), h = Math.imul(h ^ h >>> 13, 3266489909), (h ^= h >>> 16) >>> 0); return function () { t |= 0; t = t + 0x6D2B79F5 | 0; let r = Math.imul(t ^ t >>> 15, 1 | t); r = r + Math.imul(r ^ r >>> 7, 61 | r) ^ r; return ((r ^ r >>> 14) >>> 0) / 4294967296; };}function clientPack(L) { // decodePack() + GWMAP-free path const meta = JSON.parse(fs.readFileSync(path.join(SITE, 'data', `gw-${L}-meta.json`), 'utf8')); const parts = []; for (let i = 0; i < meta.chunks; i++) parts.push(fs.readFileSync(path.join(SITE, 'data', `gw-${L}-vec-${i}.bin`))); const bytes = new Uint8Array(Buffer.concat(parts)); const D2 = meta.D, N2 = meta.N; const words = meta.words.split('|'); const unit = new Float32Array(N2 * D2); const min = meta.min, max = meta.max; for (let i = 0; i < N2; i++) for (let d = 0; d < D2; d++) unit[i * D2 + d] = min[d] + (bytes[i * D2 + d] / 255) * (max[d] - min[d]); for (let i = 0; i < N2; i++) { let n2 = 0; for (let d = 0; d < D2; d++) { const v = unit[i * D2 + d]; n2 += v * v; } const inv = 1 / Math.sqrt(n2) || 0; for (let d = 0; d < D2; d++) unit[i * D2 + d] *= inv; } return { D: D2, N: N2, WORDS: words, UNIT: unit, IDX: new Map(words.map((w, i) => [w, i])) };}function buildRanksRef(P, secretWord) { // verbatim buildRanks() const P_D = P.D, P_N = P.N, P_W = P.WORDS, P_U = P.UNIT; const si = P.IDX.get(secretWord); if (si === undefined) return null; const scores = new Float64Array(P_N); for (let i = 0; i < P_N; i++) { if (i === si) { scores[i] = 2; continue; } let dot = 0, off = i * P_D, soff = si * P_D; for (let d = 0; d < P_D; d++) dot += P_U[off + d] * P_U[soff + d]; scores[i] = dot + rngRef(LANG_OF(P, secretWord) + "|" + secretWord + "|" + P_W[i])() * 1e-6; } const order = new Array(P_N); for (let i = 0; i < P_N; i++) order[i] = i; order.sort((a, b) => scores[b] - scores[a] || (P_W[a] < P_W[b] ? -1 : 1)); const rankOf = new Map(), wordAt = new Array(P_N), distOf = new Array(P_N); const K = 3000 / P_N; let prevDist = 0; for (let r = 0; r < P_N; r++) { const i = order[r]; rankOf.set(P_W[i], r + 1); wordAt[r] = P_W[i]; const d = Math.max(prevDist + 1, 1 + Math.round(r * K)); distOf[r] = d; prevDist = d; } return { rankOf, wordAt, distOf, N: P_N, K };}// the client seeds rng() with the *current* language code, which equals the pack's languagelet CUR_LANG = 'en';function LANG_OF(P, w) { return CUR_LANG; }/* ---------- checks ---------- */let failures = 0;const fail = (m) => { failures++; console.log(' FAIL ' + m); };const ok = (m) => console.log(' ok ' + m);const ENGINE_URL = pathToFileURL(path.join(SITE, 'functions', 'api', '_engine.js')).href;const { handle, createEngine } = await import(ENGINE_URL);for (const L of LANGS) { console.log(`\n=== ${L} ===`); CUR_LANG = L; const P = clientPack(L); const eng = createEngine((p) => Promise.resolve(assetBytes(p))); const S = await eng.loadLang(L); console.log(`N=${P.N} D=${P.D} words=${P.WORDS.length}`); if (S.N !== P.N || S.D !== P.D) fail('N/D mismatch'); // unit matrices must be bit-identical let same = S.UNIT.length === P.UNIT.length; if (same) for (let i = 0; i < S.UNIT.length; i++) if (S.UNIT[i] !== P.UNIT[i]) { same = false; console.log(' first diff at', i, S.UNIT[i], P.UNIT[i]); break; } same ? ok('unit matrix bit-identical') : fail('unit matrix differs'); // distances must match for every rank const ref = buildRanksRef(P, P.WORDS[Math.floor(P.N / 3)]); let distOk = true; for (let r = 0; r < P.N; r++) if (S.DIST[r] !== ref.distOf[r]) { distOk = false; fail(`dist table r=${r}: ${S.DIST[r]} vs ${ref.distOf[r]}`); break; } distOk && ok('distance table identical for all ' + P.N + ' ranks'); // ranks: sample secrets x sample words through the real handlers const secrets = [P.WORDS[3], P.WORDS[Math.floor(P.N / 2)], P.WORDS[P.N - 5]]; for (const secret of secrets) { const r2 = buildRanksRef(P, secret); const pool = Array.from({ length: 300 }, (_, k) => P.WORDS[Math.floor(k * P.N / 300)]); pool.push(secret, P.WORDS[0], P.WORDS[P.N - 1]); // drive the actual /api/guess handler via a forged token-free path: use engine directly const si = P.IDX.get(secret); const scores = eng.scorePass(S, si, secret); let bad = 0; for (const w of pool) { const wi = P.IDX.get(w); const rServer = w === secret ? 1 : eng.rankAt(S, scores, wi); const rClient = r2.rankOf.get(w); const dServer = S.DIST[rServer - 1], dClient = r2.distOf[rClient - 1]; if (rServer !== rClient || dServer !== dClient) { bad++; if (bad < 4) fail(`${w}: server r=${rServer} d=${dServer} vs client r=${rClient} d=${dClient}`); } } if (!bad) ok(`ranks identical for ${pool.length} words (secret "${secret}")`); // hint selection: word at rank k must match client wordAt[k-1] let hbad = 0; const ks = [1, 2, 3, 4, 7, 15, 42, 100, 333, 1000, 2500, Math.floor(P.N / 2), P.N]; for (const k of ks) { if (k > P.N) continue; const wi = k === 1 ? si : eng.indexAtRank(S, scores, k); const w = P.WORDS[wi]; if (w !== r2.wordAt[k - 1]) { hbad++; fail(`hint rank ${k}: ${w} vs ${r2.wordAt[k - 1]}`); } } if (!hbad) ok(`hint rank -> word identical (${ks.length} ranks)`); } // full HTTP layer: /api/new -> /api/guess -> /api/hint -> /api/def const base = 'https://x.test'; const rNew = await handle(ctx(base + '/api/new', { lang: L, lo: 0, hi: 3, recent: [] }), 'new'); const jNew = await rNew.json(); if (!jNew.t) fail('api/new failed: ' + JSON.stringify(jNew)); else ok('api/new token ok (pool ' + jNew.pool + ')'); // decode the token by exercising /api/hint r=1 (the server reveals the secret there) const rHint1 = await handle(ctx(base + '/api/hint', { t: jNew.t, r: 1 }), 'hint'); const jHint1 = await rHint1.json(); if (!jHint1.word || jHint1.d !== 1) fail('api/hint r=1: ' + JSON.stringify(jHint1)); else ok('api/hint r=1 reveals secret "' + jHint1.word + '"'); const secret2 = jHint1.word; const r2b = buildRanksRef(P, secret2); const sample = [P.WORDS[10], P.WORDS[1000], secret2]; const rGuess = await handle(ctx(base + '/api/guess', { t: jNew.t, words: sample }), 'guess'); const jGuess = await rGuess.json(); let gbad = 0; (jGuess.items || []).forEach((it, ix) => { const w = sample[ix]; const cr = r2b.rankOf.get(w), cd = r2b.distOf[cr - 1]; if (!it || it.r !== cr || it.d !== cd) { gbad++; fail(`api/guess ${w}: ${JSON.stringify(it)} vs r=${cr} d=${cd}`); } }); if (!gbad) ok('api/guess via HTTP matches client ranks'); const rHint = await handle(ctx(base + '/api/hint', { t: jNew.t, r: 7 }), 'hint'); const jHint = await rHint.json(); if (jHint.word !== r2b.wordAt[6] || jHint.d !== r2b.distOf[6]) fail(`api/hint r=7: ${JSON.stringify(jHint)} vs ${r2b.wordAt[6]}`); else ok('api/hint r=7 matches client'); const rBad = await handle(ctx(base + '/api/guess', { t: 'garbage', words: ['x'] }), 'guess'); if (rBad.status !== 400) fail('bad token accepted'); else ok('bad token rejected'); // /api/def over HTTP (GET) const someWord = P.WORDS.find((w) => /^[a-z]{4,8}$/.test(w)) || P.WORDS[0]; const rDef = await handle({ request: new Request(base + '/api/def?lang=' + L + '&word=' + encodeURIComponent(someWord.toLowerCase())), env: fakeEnv }, 'def'); const jDef = await rDef.json(); if (jDef.entry) ok(`api/def returned an entry for "${someWord}"`); else console.log(` note api/def miss for "${someWord}" (dict may not cover it)`); const rDefMiss = await handle({ request: new Request(base + '/api/def?lang=' + L + '&word=zzzzqqq'), env: fakeEnv }, 'def'); const jDefMiss = await rDefMiss.json(); if (jDefMiss.entry === null) ok('api/def miss -> entry:null'); else fail('api/def miss shape: ' + JSON.stringify(jDefMiss));}console.log(failures ? `\n${failures} FAILURE(S)` : '\nALL PARITY CHECKS PASSED');process.exit(failures ? 1 : 0);