// Pressure test: how big a pool of fetched suspects do we need to RELIABLY construct the §5 // overlap-by-2 mystery (DEDUCTION.md)? Fetches a big reference pool of real rpg.actor suspects once // (cached to disk), then Monte-Carlo binary-searches the pool size N: for each probe, sample N // suspects, attempt the build, and measure the success rate. Reports the smallest N clearing the bar. // // tsx server/src/deduction/poolsize.ts // REF=240 THRESHOLD=0.9 TRIALS=120 tsx server/src/deduction/poolsize.ts // REFRESH=1 tsx server/src/deduction/poolsize.ts # ignore cache, refetch import { readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { buildOverlapMystery, verifyRuleOfThree, type SuspectAttributes } from '../../../engine/deduction/index.js' import { loadSuspectPool } from './registry.js' const CACHE = join(dirname(fileURLToPath(import.meta.url)), '.poolcache.json') function mulberry32(seed: number): () => number { let a = seed >>> 0 return () => { a = (a + 0x6d2b79f5) | 0 let t = Math.imul(a ^ (a >>> 15), 1 | a) t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } function sample(arr: T[], n: number, rng: () => number): T[] { const out = arr.slice() for (let i = 0; i < n; i++) { const j = i + Math.floor(rng() * (out.length - i)) ;[out[i], out[j]] = [out[j]!, out[i]!] } return out.slice(0, n) } /** Fraction of `trials` random N-suspect samples from `ref` that yield a valid (rule-of-3) build. */ function successRate(ref: SuspectAttributes[], n: number, trials: number, rng: () => number): number { let ok = 0 for (let i = 0; i < trials; i++) { const lineup = sample(ref, n, rng) const m = buildOverlapMystery(lineup, rng) if (m && verifyRuleOfThree(m)) ok++ } return ok / trials } async function loadReference(target: number, rng: () => number): Promise { if (!process.env.REFRESH) { try { const cached = JSON.parse(await readFile(CACHE, 'utf8')) as SuspectAttributes[] if (cached.length >= target) { console.log(`Using cached reference pool: ${cached.length} suspects (${CACHE}).`) return cached } console.log(`Cache has ${cached.length} < ${target}; refetching.`) } catch { /* no cache yet */ } } console.log(`Fetching ${target} suspects from rpg.actor (this can take a minute)…`) const pool = await loadSuspectPool(target, { rng, concurrency: 8, maxExamine: target * 4 }) console.log(` examined ${pool.examined} → ${pool.suspects.length} usable (skipped: ${ Object.entries(pool.skipped).filter(([, n]) => n).map(([k, n]) => `${k}=${n}`).join(', ') || 'none'})`) await writeFile(CACHE, JSON.stringify(pool.suspects)) console.log(` cached → ${CACHE}`) return pool.suspects } async function main() { const refTarget = Number(process.env.REF ?? 240) const threshold = Number(process.env.THRESHOLD ?? 0.9) const trials = Number(process.env.TRIALS ?? 120) const rng = mulberry32(Number(process.env.SEED ?? 1)) const ref = await loadReference(refTarget, rng) if (ref.length < 10) { console.error('Reference pool too small to search. Try REFRESH=1.') process.exit(1) } // Keep samples reasonably independent: cap N at half the reference pool. const maxN = Math.floor(ref.length / 2) console.log(`\nPressure test — target build: §5 overlap-by-2, rule of 3.`) console.log(`Reference ${ref.length} suspects · threshold ${threshold} · ${trials} trials/probe · maxN ${maxN}\n`) // Coarse curve first, for visibility. console.log('Curve (success rate vs N):') for (const n of [5, 6, 8, 10, 12, 16, 20, 28, 40, 60].filter((n) => n <= maxN)) { const r = successRate(ref, n, trials, rng) const bar = '█'.repeat(Math.round(r * 30)) console.log(` N=${String(n).padStart(3)} ${(r * 100).toFixed(0).padStart(3)}% ${bar}`) } // Binary search for the smallest N meeting the threshold. console.log(`\nBinary search for smallest N with success ≥ ${(threshold * 100).toFixed(0)}%:`) let lo = 5 let hi = maxN if (successRate(ref, hi, trials, rng) < threshold) { console.log(` even N=${hi} only reaches ${(successRate(ref, hi, trials, rng) * 100).toFixed(0)}% — need a bigger reference pool (raise REF).`) process.exit(1) } let answer = hi while (lo <= hi) { const mid = Math.floor((lo + hi) / 2) const r = successRate(ref, mid, trials, rng) console.log(` probe N=${String(mid).padStart(3)} → ${(r * 100).toFixed(0).padStart(3)}% ${r >= threshold ? '✓' : '✗'}`) if (r >= threshold) { answer = mid hi = mid - 1 } else { lo = mid + 1 } } console.log(`\n➡ Fetch ~${answer} suspects-with-generators to build a valid case ≥${(threshold * 100).toFixed(0)}% of the time.`) console.log(` (~${Math.ceil(answer / 0.5)} raw actor fetches at the observed ~50% generator yield.)\n`) // Qualitative sanity check: show one real constructed case so we can eyeball clue quality. printSample(ref, answer, rng) } const NOUN: Record = { headwear: 'hat', tops: 'shirt', bottoms: 'pants', feet: 'shoes', hair: 'hair', glasses: 'glasses', beard: 'beard', necklaces: 'necklace', cape: 'cape', ears: 'earrings', bangs: 'bangs', facial: 'facial hair', } function readToken(tok: string): string { const [cat, kind, ...rest] = tok.split('#') if (cat === 'held') { if (kind === 'both') return `a fragment of a ${rest[1]} ${rest[0]}` if (kind === 'color') return `a fragment of a ${rest[0]} item` return `a fragment of a ${rest[0]}` } const noun = NOUN[cat!] ?? cat if (kind === 'color') return `a piece of a ${rest[0]} ${noun}` // "a piece of a red shirt" if (kind === 'both') return `a piece of a ${rest[1]} ${rest[0]}` // "a piece of a red Basic Shirt" return `a piece of a ${rest[0]}` // title: "a piece of a Basic Shirt" } function attire(s: SuspectAttributes): string { return s.worn.map((w) => `${w.colorBucket ? w.colorBucket + ' ' : ''}${w.title}`).join(', ') } function printSample(ref: SuspectAttributes[], n: number, rng: () => number): void { const lineup = sample(ref, n, rng) const m = buildOverlapMystery(lineup, rng) if (!m) { console.log('(sample: no build this draw)') return } const by = new Map(ref.map((s) => [s.did, s])) const alias = new Map(m.lineup.map((d, i) => [d, String.fromCharCode(65 + i)])) console.log('Sample case from real data — the 5 implicated suspects:') for (const d of m.lineup) { const s = by.get(d)! console.log(` ${alias.get(d)}${d === m.culpritDid ? ' (culprit)' : ''}: ${attire(s)}`) } console.log('\n 3 incriminating clues (each points to ≥2; only the 3 together name the culprit):') m.fragments.forEach((f, i) => { const who = m.matchSets[i]!.map((d) => alias.get(d)).join(', ') console.log(` • "${readToken(f)}" → suspects {${who}}`) }) const common = m.matchSets[0]!.filter((d) => m.matchSets[1]!.includes(d) && m.matchSets[2]!.includes(d)) console.log(` ∩ of all three = {${common.map((d) => alias.get(d)).join(', ')}} → culprit ${alias.get(m.culpritDid)}\n`) } main().catch((err) => { console.error(err) process.exit(1) })