Files
AIHostingTycoon/packages/game-simulation/src/multirun.ts
T
josh 5aa9436368
Balance Check / balance-simulation (push) Successful in 46s
Balance Check / multi-run-balance (push) Successful in 14m6s
CI / build-and-push (push) Successful in 46s
Expand multirun reporting: health summary, era durations, serving diagnostics, cash-flow detail
Propagate per-era duration/bottleneck, serving utilization, cash-flow nadir/peak,
and late-game revenue growth through the worker→CSV→interpret pipeline. Add
simulation health archetype classification, per-era bottleneck frequency,
unused-feature frequency table, failed-run AGI gate analysis, and log-scale
variance for exponential metrics. All new CSV columns parse defensively for
backward compatibility with older summary files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 20:55:49 -04:00

285 lines
11 KiB
TypeScript

import { spawn } 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; diagnosis: string; events: number; eventLabel: string }>;
overallScore: number;
};
cashFlow: {
bankruptcyRisks: number;
minCash: { amount: number; tick: number };
peakCash: { amount: number; tick: number };
};
sanityChecks: { passed: boolean; errorCount: number };
perEraSummary: Array<{ era: string; durationTicks: number; bottleneckAtExit: string | null }>;
serving: {
meanUtilization: number;
pctOverloaded: number;
pctUnderloaded: number;
peakUtilization: number;
};
lateGameRevenueGrowthRate: 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 tsxBin = pathResolve(__dirname, '..', 'node_modules', '.bin', 'tsx');
const child = spawn(tsxBin, [workerPath, '--strategy', strategyName, '--ticks', String(totalTicks), '--seed', String(seed), '--run-id', String(runId)], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk; });
child.stderr.on('data', (chunk: Buffer) => { process.stderr.write(chunk); });
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(`Run #${runId} (seed ${seed}) exited with code ${code}`));
return;
}
try {
resolve(JSON.parse(stdout) as WorkerResult);
} catch (e) {
reject(new Error(`Run #${runId} (seed ${seed}) produced invalid JSON: ${(e as Error).message}`));
}
});
child.on('error', (err) => {
reject(new Error(`Run #${runId} (seed ${seed}) failed to spawn: ${err.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',
'duration_startup', 'duration_scaleup', 'duration_bigtech', 'duration_agi',
'bottleneck_scaleup', 'bottleneck_bigtech', 'bottleneck_agi',
'servingMeanUtil', 'servingPctOverloaded', 'servingPctUnderloaded', 'servingPeakUtil',
'cashMinAmount', 'cashMinTick', 'cashPeakAmount', 'cashPeakTick',
'lateGameRevenueGrowthRate',
'unusedFeatures',
];
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);
const durationMap: Record<string, number | ''> = { startup: '', scaleup: '', bigtech: '', agi: '' };
const bottleneckMap: Record<string, string> = { scaleup: '', bigtech: '', agi: '' };
for (const es of r.perEraSummary) {
durationMap[es.era] = es.durationTicks;
if (es.bottleneckAtExit) bottleneckMap[es.era] = es.bottleneckAtExit;
}
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, '""')}"`,
durationMap.startup, durationMap.scaleup, durationMap.bigtech, durationMap.agi,
bottleneckMap.scaleup, bottleneckMap.bigtech, bottleneckMap.agi,
r.serving.meanUtilization, r.serving.pctOverloaded, r.serving.pctUnderloaded, r.serving.peakUtilization,
r.cashFlow.minCash.amount, r.cashFlow.minCash.tick, r.cashFlow.peakCash.amount, r.cashFlow.peakCash.tick,
r.lateGameRevenueGrowthRate,
`"${fu.unusedFeatures.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);
});