Add game-simulation package with multi-run balance testing, fix stalled-pipeline trap
Adds a full simulation harness (game-simulation package) with greedy/random strategies, 36-metric diagnostics, multi-run orchestration via child processes, and a statistical interpreter. Includes 2.3x engine performance optimizations (research bonus caching, per-DC dirty tracking, reduced allocations in tick pipeline, single-pass loops). Fixes a critical balance bug where training pipelines stalled on insufficient VRAM would permanently block training slots — the engine never re-checked stalled pipelines, and the greedy strategy didn't pre-check VRAM requirements. This caused 20-25% of seeds to get stuck in Scale-up era. All three fixes (engine un-stalling, strategy VRAM pre-check, stalled pipeline cancellation) bring pass rate from 75% to 100% across 20 random seeds. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { resolve as pathResolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { cpus } from 'node:os';
|
||||
import type { SimulationMetrics } from './strategies/types';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function getArg(name: string, defaultValue: string): string {
|
||||
const idx = args.indexOf(`--${name}`);
|
||||
return idx !== -1 && args[idx + 1] ? args[idx + 1] : defaultValue;
|
||||
}
|
||||
|
||||
function hasFlag(name: string): boolean {
|
||||
return args.includes(`--${name}`);
|
||||
}
|
||||
|
||||
const totalRuns = parseInt(getArg('runs', '0'), 10);
|
||||
const parallel = Math.min(totalRuns, parseInt(getArg('parallel', String(Math.max(1, cpus().length - 1))), 10));
|
||||
const strategyName = getArg('strategy', 'greedy');
|
||||
const totalTicks = parseInt(getArg('ticks', '28800'), 10);
|
||||
const outDir = pathResolve(getArg('out', pathResolve(__dirname, '..')));
|
||||
const baseSeedStr = getArg('seed', '');
|
||||
const baseSeed = baseSeedStr ? parseInt(baseSeedStr, 10) : null;
|
||||
const noTimeseries = hasFlag('no-timeseries');
|
||||
|
||||
if (totalRuns <= 0) {
|
||||
console.error('Usage: multirun --runs <N> [--parallel <P>] [--strategy <name>] [--ticks <N>] [--out <dir>] [--seed <N>] [--no-timeseries]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function deriveSeeds(count: number, base: number | null): number[] {
|
||||
if (base === null) {
|
||||
return Array.from({ length: count }, () => randomInt(1, 2_147_483_647));
|
||||
}
|
||||
// Deterministic derivation from base seed
|
||||
const seeds: number[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
let h = (base + i * 0x9E3779B9) | 0;
|
||||
h = Math.imul(h ^ (h >>> 16), 0x45D9F3B);
|
||||
h = Math.imul(h ^ (h >>> 13), 0x45D9F3B);
|
||||
h = (h ^ (h >>> 16)) >>> 0;
|
||||
seeds.push(h || 1);
|
||||
}
|
||||
return seeds;
|
||||
}
|
||||
|
||||
interface WorkerResult {
|
||||
runId: number;
|
||||
seed: number;
|
||||
passed: boolean;
|
||||
failureReasons: string[];
|
||||
wallTimeMs: number;
|
||||
eraTransitions: Array<{ from: string; to: string; tick: number; wallTime: string }>;
|
||||
finalMetrics: SimulationMetrics | null;
|
||||
featureUtilization: {
|
||||
coverageByCategory: Record<string, { used: number; available: number; percent: number }>;
|
||||
unusedFeatures: string[];
|
||||
revenueStreamDiversity: number;
|
||||
};
|
||||
systemInterconnections: {
|
||||
connections: Array<{ from: string; to: string; score: number; evidence: string; events: number }>;
|
||||
overallScore: number;
|
||||
};
|
||||
cashFlow: { bankruptcyRisks: number };
|
||||
sanityChecks: { passed: boolean; errorCount: number };
|
||||
metrics: SimulationMetrics[];
|
||||
}
|
||||
|
||||
function spawnWorker(runId: number, seed: number): Promise<WorkerResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const workerPath = new URL('./worker.ts', import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1');
|
||||
const cmd = `npx tsx "${workerPath}" --strategy ${strategyName} --ticks ${totalTicks} --seed ${seed} --run-id ${runId}`;
|
||||
exec(cmd, { maxBuffer: 200 * 1024 * 1024 }, (error, stdout, stderr) => {
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
if (error) {
|
||||
reject(new Error(`Run #${runId} (seed ${seed}) failed: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = JSON.parse(stdout) as WorkerResult;
|
||||
resolve(result);
|
||||
} catch (e) {
|
||||
reject(new Error(`Run #${runId} (seed ${seed}) produced invalid JSON: ${(e as Error).message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildSummaryCsv(results: WorkerResult[]): string {
|
||||
const interconnectionKeys = results[0]?.systemInterconnections.connections.map(
|
||||
c => `interconnection_${c.from}_${c.to}`.replace(/\s+/g, ''),
|
||||
) ?? [];
|
||||
|
||||
const headers = [
|
||||
'runId', 'seed', 'passed', 'wallTimeMs',
|
||||
'finalEra', 'finalMoney', 'finalRevenue', 'finalTotalRevenue',
|
||||
'finalCapability', 'finalReputation', 'finalSubscribers', 'finalDevelopers',
|
||||
'finalHeadcount', 'finalResearchCount', 'finalModelsDeployed',
|
||||
'revenueStreamDiversity',
|
||||
'featureUtilization_research', 'featureUtilization_infrastructure',
|
||||
'featureUtilization_revenue', 'featureUtilization_talent',
|
||||
'featureUtilization_model', 'featureUtilization_funding',
|
||||
'interconnection_overall',
|
||||
...interconnectionKeys,
|
||||
'eraTransition_scaleup', 'eraTransition_bigtech', 'eraTransition_agi',
|
||||
'bankruptcyRisks', 'sanityErrors', 'failureReasons',
|
||||
];
|
||||
|
||||
const rows = results.map(r => {
|
||||
const fm = r.finalMetrics;
|
||||
const fu = r.featureUtilization;
|
||||
const ic = r.systemInterconnections;
|
||||
|
||||
const eraMap: Record<string, number | ''> = {
|
||||
scaleup: '', bigtech: '', agi: '',
|
||||
};
|
||||
for (const t of r.eraTransitions) {
|
||||
if (t.to === 'scaleup') eraMap.scaleup = t.tick;
|
||||
if (t.to === 'bigtech') eraMap.bigtech = t.tick;
|
||||
if (t.to === 'agi') eraMap.agi = t.tick;
|
||||
}
|
||||
|
||||
const icScores = ic.connections.map(c => c.score);
|
||||
|
||||
return [
|
||||
r.runId, r.seed, r.passed ? 1 : 0, r.wallTimeMs,
|
||||
fm?.era ?? '', fm?.money ?? '', fm?.revenue ?? '', fm?.totalRevenue ?? '',
|
||||
fm?.bestModelCapability ?? '', fm?.reputation ?? '', fm?.subscribers ?? '', fm?.developers ?? '',
|
||||
fm?.headcount ?? '', fm?.researchCount ?? '', fm?.modelsDeployed ?? '',
|
||||
fu.revenueStreamDiversity,
|
||||
fu.coverageByCategory['research']?.percent ?? '',
|
||||
fu.coverageByCategory['infrastructure']?.percent ?? '',
|
||||
fu.coverageByCategory['revenue']?.percent ?? '',
|
||||
fu.coverageByCategory['talent']?.percent ?? '',
|
||||
fu.coverageByCategory['model']?.percent ?? '',
|
||||
fu.coverageByCategory['funding']?.percent ?? '',
|
||||
ic.overallScore,
|
||||
...icScores,
|
||||
eraMap.scaleup, eraMap.bigtech, eraMap.agi,
|
||||
r.cashFlow.bankruptcyRisks,
|
||||
r.sanityChecks.errorCount,
|
||||
`"${r.failureReasons.join('; ').replace(/"/g, '""')}"`,
|
||||
].join(',');
|
||||
});
|
||||
|
||||
return [headers.join(','), ...rows].join('\n');
|
||||
}
|
||||
|
||||
function buildTimeseriesCsv(results: WorkerResult[]): string {
|
||||
if (results.length === 0 || results[0].metrics.length === 0) return '';
|
||||
|
||||
const sampleKeys = Object.keys(results[0].metrics[0]).filter(k => k !== 'completedResearchIds') as (keyof SimulationMetrics)[];
|
||||
const headers = ['runId', 'seed', ...sampleKeys];
|
||||
|
||||
const rows: string[] = [];
|
||||
for (const r of results) {
|
||||
for (const m of r.metrics) {
|
||||
const values = sampleKeys.map(k => {
|
||||
const v = m[k];
|
||||
return typeof v === 'number' ? v : String(v);
|
||||
});
|
||||
rows.push([r.runId, r.seed, ...values].join(','));
|
||||
}
|
||||
}
|
||||
|
||||
return [headers.join(','), ...rows].join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const seeds = deriveSeeds(totalRuns, baseSeed);
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log(`=== Multi-Run Simulation ===`);
|
||||
console.log(`Runs: ${totalRuns} | Parallel: ${parallel} | Strategy: ${strategyName} | Ticks: ${totalTicks.toLocaleString()}`);
|
||||
console.log(`Seeds: [${seeds.join(', ')}]`);
|
||||
if (baseSeed !== null) console.log(`Base seed: ${baseSeed} (deterministic)`);
|
||||
console.log('');
|
||||
|
||||
const results: WorkerResult[] = [];
|
||||
let completed = 0;
|
||||
const pending = seeds.map((seed, i) => ({ runId: i + 1, seed }));
|
||||
const active = new Set<Promise<void>>();
|
||||
|
||||
async function runOne(runId: number, seed: number): Promise<void> {
|
||||
try {
|
||||
const result = await spawnWorker(runId, seed);
|
||||
results.push(result);
|
||||
completed++;
|
||||
const status = result.passed ? 'PASSED' : 'FAILED';
|
||||
const reasons = !result.passed && result.failureReasons.length > 0
|
||||
? ` [${result.failureReasons.join('; ')}]`
|
||||
: '';
|
||||
console.log(`[${completed}/${totalRuns}] Run #${runId} (seed ${seed}) completed in ${(result.wallTimeMs / 1000).toFixed(1)}s — ${status}${reasons}`);
|
||||
} catch (e) {
|
||||
completed++;
|
||||
console.error(`[${completed}/${totalRuns}] Run #${runId} (seed ${seed}) ERROR: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const job of pending) {
|
||||
if (active.size >= parallel) {
|
||||
await Promise.race(active);
|
||||
}
|
||||
const p = runOne(job.runId, job.seed).then(() => { active.delete(p); });
|
||||
active.add(p);
|
||||
}
|
||||
await Promise.all(active);
|
||||
|
||||
results.sort((a, b) => a.runId - b.runId);
|
||||
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
const passCount = results.filter(r => r.passed).length;
|
||||
console.log('');
|
||||
console.log(`=== Complete ===`);
|
||||
console.log(`${passCount}/${results.length} passed | Total wall time: ${totalTime}s`);
|
||||
|
||||
const summaryCsv = buildSummaryCsv(results);
|
||||
const summaryPath = pathResolve(outDir, 'multirun-summary.csv');
|
||||
writeFileSync(summaryPath, summaryCsv);
|
||||
console.log(`Summary CSV: ${summaryPath}`);
|
||||
|
||||
if (!noTimeseries) {
|
||||
const tsCsv = buildTimeseriesCsv(results);
|
||||
const tsPath = pathResolve(outDir, 'multirun-timeseries.csv');
|
||||
writeFileSync(tsPath, tsCsv);
|
||||
console.log(`Time-series CSV: ${tsPath}`);
|
||||
}
|
||||
|
||||
const failedSeeds = results.filter(r => !r.passed).map(r => r.seed);
|
||||
if (failedSeeds.length > 0) {
|
||||
console.log(`\nFailed seeds (for reproduction): ${failedSeeds.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user