5aa9436368
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>
285 lines
11 KiB
TypeScript
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);
|
|
});
|