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>
682 lines
28 KiB
TypeScript
682 lines
28 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { writeFileSync } from 'node:fs';
|
|
|
|
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;
|
|
}
|
|
|
|
const summaryPath = getArg('summary', '');
|
|
const outPath = getArg('out', '');
|
|
|
|
if (!summaryPath) {
|
|
console.error('Usage: interpret --summary <path-to-multirun-summary.csv> [--out <path>]');
|
|
process.exit(1);
|
|
}
|
|
|
|
// --- AGI gate thresholds (must match ERA_THRESHOLDS in gameBalance.ts) ---
|
|
const AGI_GATES = {
|
|
capability: 93,
|
|
revenue: 1_000_000_000,
|
|
reputation: 80,
|
|
};
|
|
|
|
interface SummaryRow {
|
|
runId: number;
|
|
seed: number;
|
|
passed: boolean;
|
|
wallTimeMs: number;
|
|
finalEra: string;
|
|
finalMoney: number;
|
|
finalRevenue: number;
|
|
finalTotalRevenue: number;
|
|
finalCapability: number;
|
|
finalReputation: number;
|
|
finalSubscribers: number;
|
|
finalDevelopers: number;
|
|
finalHeadcount: number;
|
|
finalResearchCount: number;
|
|
finalModelsDeployed: number;
|
|
revenueStreamDiversity: number;
|
|
featureUtilization: Record<string, number>;
|
|
interconnectionOverall: number;
|
|
interconnections: Record<string, number>;
|
|
eraTransition_scaleup: number | null;
|
|
eraTransition_bigtech: number | null;
|
|
eraTransition_agi: number | null;
|
|
bankruptcyRisks: number;
|
|
sanityErrors: number;
|
|
failureReasons: string;
|
|
// New pipeline fields (may be absent in old CSVs)
|
|
duration_startup: number | null;
|
|
duration_scaleup: number | null;
|
|
duration_bigtech: number | null;
|
|
duration_agi: number | null;
|
|
bottleneck_scaleup: string;
|
|
bottleneck_bigtech: string;
|
|
bottleneck_agi: string;
|
|
servingMeanUtil: number | null;
|
|
servingPctOverloaded: number | null;
|
|
servingPctUnderloaded: number | null;
|
|
servingPeakUtil: number | null;
|
|
cashMinAmount: number | null;
|
|
cashMinTick: number | null;
|
|
cashPeakAmount: number | null;
|
|
cashPeakTick: number | null;
|
|
lateGameRevenueGrowthRate: number | null;
|
|
unusedFeatures: string;
|
|
}
|
|
|
|
function parseSummaryCsv(content: string): SummaryRow[] {
|
|
const lines = content.trim().split('\n');
|
|
if (lines.length < 2) return [];
|
|
const headers = lines[0].split(',');
|
|
const rows: SummaryRow[] = [];
|
|
|
|
const hasCol = (name: string): boolean => headers.indexOf(name) >= 0;
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const values = parseCSVLine(lines[i]);
|
|
const get = (name: string): string => {
|
|
const idx = headers.indexOf(name);
|
|
return idx >= 0 ? (values[idx] ?? '') : '';
|
|
};
|
|
const num = (name: string): number => { const v = get(name); return v === '' ? 0 : Number(v); };
|
|
const numOrNull = (name: string): number | null => {
|
|
if (!hasCol(name)) return null;
|
|
const v = get(name);
|
|
return v === '' ? null : Number(v);
|
|
};
|
|
|
|
const fuCategories: Record<string, number> = {};
|
|
const icLinks: Record<string, number> = {};
|
|
|
|
for (let h = 0; h < headers.length; h++) {
|
|
if (headers[h].startsWith('featureUtilization_')) {
|
|
fuCategories[headers[h].replace('featureUtilization_', '')] = Number(values[h]) || 0;
|
|
}
|
|
if (headers[h].startsWith('interconnection_') && headers[h] !== 'interconnection_overall') {
|
|
icLinks[headers[h].replace('interconnection_', '')] = Number(values[h]) || 0;
|
|
}
|
|
}
|
|
|
|
rows.push({
|
|
runId: num('runId'),
|
|
seed: num('seed'),
|
|
passed: num('passed') === 1,
|
|
wallTimeMs: num('wallTimeMs'),
|
|
finalEra: get('finalEra'),
|
|
finalMoney: num('finalMoney'),
|
|
finalRevenue: num('finalRevenue'),
|
|
finalTotalRevenue: num('finalTotalRevenue'),
|
|
finalCapability: num('finalCapability'),
|
|
finalReputation: num('finalReputation'),
|
|
finalSubscribers: num('finalSubscribers'),
|
|
finalDevelopers: num('finalDevelopers'),
|
|
finalHeadcount: num('finalHeadcount'),
|
|
finalResearchCount: num('finalResearchCount'),
|
|
finalModelsDeployed: num('finalModelsDeployed'),
|
|
revenueStreamDiversity: num('revenueStreamDiversity'),
|
|
featureUtilization: fuCategories,
|
|
interconnectionOverall: num('interconnection_overall'),
|
|
interconnections: icLinks,
|
|
eraTransition_scaleup: get('eraTransition_scaleup') ? num('eraTransition_scaleup') : null,
|
|
eraTransition_bigtech: get('eraTransition_bigtech') ? num('eraTransition_bigtech') : null,
|
|
eraTransition_agi: get('eraTransition_agi') ? num('eraTransition_agi') : null,
|
|
bankruptcyRisks: num('bankruptcyRisks'),
|
|
sanityErrors: num('sanityErrors'),
|
|
failureReasons: get('failureReasons').replace(/^"|"$/g, ''),
|
|
duration_startup: numOrNull('duration_startup'),
|
|
duration_scaleup: numOrNull('duration_scaleup'),
|
|
duration_bigtech: numOrNull('duration_bigtech'),
|
|
duration_agi: numOrNull('duration_agi'),
|
|
bottleneck_scaleup: get('bottleneck_scaleup'),
|
|
bottleneck_bigtech: get('bottleneck_bigtech'),
|
|
bottleneck_agi: get('bottleneck_agi'),
|
|
servingMeanUtil: numOrNull('servingMeanUtil'),
|
|
servingPctOverloaded: numOrNull('servingPctOverloaded'),
|
|
servingPctUnderloaded: numOrNull('servingPctUnderloaded'),
|
|
servingPeakUtil: numOrNull('servingPeakUtil'),
|
|
cashMinAmount: numOrNull('cashMinAmount'),
|
|
cashMinTick: numOrNull('cashMinTick'),
|
|
cashPeakAmount: numOrNull('cashPeakAmount'),
|
|
cashPeakTick: numOrNull('cashPeakTick'),
|
|
lateGameRevenueGrowthRate: numOrNull('lateGameRevenueGrowthRate'),
|
|
unusedFeatures: get('unusedFeatures').replace(/^"|"$/g, ''),
|
|
});
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function parseCSVLine(line: string): string[] {
|
|
const values: string[] = [];
|
|
let current = '';
|
|
let inQuotes = false;
|
|
for (let i = 0; i < line.length; i++) {
|
|
const ch = line[i];
|
|
if (inQuotes) {
|
|
if (ch === '"' && line[i + 1] === '"') {
|
|
current += '"';
|
|
i++;
|
|
} else if (ch === '"') {
|
|
inQuotes = false;
|
|
} else {
|
|
current += ch;
|
|
}
|
|
} else {
|
|
if (ch === '"') {
|
|
inQuotes = true;
|
|
} else if (ch === ',') {
|
|
values.push(current);
|
|
current = '';
|
|
} else {
|
|
current += ch;
|
|
}
|
|
}
|
|
}
|
|
values.push(current);
|
|
return values;
|
|
}
|
|
|
|
interface Stats {
|
|
mean: number;
|
|
median: number;
|
|
stddev: number;
|
|
min: number;
|
|
max: number;
|
|
p5: number;
|
|
p25: number;
|
|
p75: number;
|
|
p95: number;
|
|
cv: number;
|
|
logCv: number;
|
|
}
|
|
|
|
function computeStats(values: number[]): Stats {
|
|
if (values.length === 0) return { mean: 0, median: 0, stddev: 0, min: 0, max: 0, p5: 0, p25: 0, p75: 0, p95: 0, cv: 0, logCv: 0 };
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
const n = sorted.length;
|
|
const mean = sorted.reduce((a, b) => a + b, 0) / n;
|
|
const median = n % 2 === 0 ? (sorted[n / 2 - 1] + sorted[n / 2]) / 2 : sorted[Math.floor(n / 2)];
|
|
const variance = sorted.reduce((sum, v) => sum + (v - mean) ** 2, 0) / n;
|
|
const stddev = Math.sqrt(variance);
|
|
const min = sorted[0];
|
|
const max = sorted[n - 1];
|
|
const p5 = sorted[Math.floor(n * 0.05)] ?? min;
|
|
const p25 = sorted[Math.floor(n * 0.25)] ?? min;
|
|
const p75 = sorted[Math.min(Math.floor(n * 0.75), n - 1)] ?? max;
|
|
const p95 = sorted[Math.min(Math.floor(n * 0.95), n - 1)] ?? max;
|
|
const cv = mean !== 0 ? stddev / Math.abs(mean) : 0;
|
|
|
|
let logCv = 0;
|
|
const positiveValues = values.filter(v => v > 0);
|
|
if (positiveValues.length > 1) {
|
|
const logValues = positiveValues.map(v => Math.log(v));
|
|
const logMean = logValues.reduce((a, b) => a + b, 0) / logValues.length;
|
|
const logVariance = logValues.reduce((sum, v) => sum + (v - logMean) ** 2, 0) / logValues.length;
|
|
logCv = logMean !== 0 ? Math.sqrt(logVariance) / Math.abs(logMean) : 0;
|
|
}
|
|
|
|
return { mean, median, stddev, min, max, p5, p25, p75, p95, cv, logCv };
|
|
}
|
|
|
|
function fmtNum(n: number, decimals = 1): string {
|
|
if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(decimals)}B`;
|
|
if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(decimals)}M`;
|
|
if (Math.abs(n) >= 1e3) return `${(n / 1e3).toFixed(decimals)}K`;
|
|
return n.toFixed(decimals);
|
|
}
|
|
|
|
function pad(s: string, w: number): string {
|
|
return s.padEnd(w);
|
|
}
|
|
|
|
function formatDuration(ticks: number): string {
|
|
const totalMinutes = Math.floor(ticks / 60);
|
|
if (totalMinutes < 60) return `${totalMinutes}m`;
|
|
const hours = Math.floor(totalMinutes / 60);
|
|
const mins = totalMinutes % 60;
|
|
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
|
}
|
|
|
|
function pct(n: number): string {
|
|
return `${(n * 100).toFixed(0)}%`;
|
|
}
|
|
|
|
const MONETARY_LABELS = new Set(['Final Money', 'Final Revenue/tick', 'Total Revenue']);
|
|
|
|
function statsLine(label: string, s: Stats, formatter: (n: number) => string = n => fmtNum(n)): string {
|
|
const isMonetary = MONETARY_LABELS.has(label);
|
|
const varianceMeasure = isMonetary ? s.logCv : s.cv;
|
|
const varianceThreshold = isMonetary ? 0.15 : 0.3;
|
|
const varianceLabel = isMonetary ? 'logCV' : 'CV';
|
|
const cvFlag = varianceMeasure > varianceThreshold ? ' [HIGH VARIANCE]' : '';
|
|
return ` ${pad(label, 22)} mean=${pad(formatter(s.mean), 10)} median=${pad(formatter(s.median), 10)} stddev=${pad(formatter(s.stddev), 10)} range=[${formatter(s.min)}, ${formatter(s.max)}] p5=${formatter(s.p5)} p95=${formatter(s.p95)} ${varianceLabel}=${varianceMeasure.toFixed(2)}${cvFlag}`;
|
|
}
|
|
|
|
function classifyRun(row: SummaryRow, totalTicks: number): string {
|
|
if (!row.passed) {
|
|
if (row.eraTransition_bigtech === null) return 'early_failure';
|
|
return 'late_failure';
|
|
}
|
|
const agiTick = row.eraTransition_agi;
|
|
if (agiTick !== null) {
|
|
if (agiTick < totalTicks * 0.5) return 'fast_pass';
|
|
if (agiTick < totalTicks * 0.75) return 'clean_pass';
|
|
return 'late_bloomer';
|
|
}
|
|
return 'clean_pass';
|
|
}
|
|
|
|
function generateReport(rows: SummaryRow[]): string {
|
|
const lines: string[] = [];
|
|
const n = rows.length;
|
|
const passCount = rows.filter(r => r.passed).length;
|
|
const failedRuns = rows.filter(r => !r.passed);
|
|
|
|
// Estimate totalTicks from the max eraTransition or walltime pattern
|
|
const maxTransitionTick = Math.max(
|
|
...rows.map(r => r.eraTransition_agi ?? r.eraTransition_bigtech ?? r.eraTransition_scaleup ?? 0),
|
|
);
|
|
const estimatedTotalTicks = maxTransitionTick > 20000 ? 28800 : maxTransitionTick > 10000 ? 14400 : 7200;
|
|
|
|
lines.push('=== Multi-Run Interpretation Report ===');
|
|
lines.push('');
|
|
|
|
// 0. Simulation Health Summary
|
|
const archetypes: Record<string, number> = {};
|
|
for (const r of rows) {
|
|
const type = classifyRun(r, estimatedTotalTicks);
|
|
archetypes[type] = (archetypes[type] ?? 0) + 1;
|
|
}
|
|
|
|
lines.push('0. SIMULATION HEALTH');
|
|
const parts: string[] = [];
|
|
if (archetypes.fast_pass) parts.push(`${archetypes.fast_pass} fast`);
|
|
if (archetypes.clean_pass) parts.push(`${archetypes.clean_pass} clean`);
|
|
if (archetypes.late_bloomer) parts.push(`${archetypes.late_bloomer} late bloomers`);
|
|
const failCount = (archetypes.early_failure ?? 0) + (archetypes.late_failure ?? 0);
|
|
if (failCount > 0) parts.push(`${failCount} failed`);
|
|
lines.push(` ${n} runs: ${parts.join(', ')}`);
|
|
|
|
if (failCount > 0) {
|
|
const failDetail: string[] = [];
|
|
if (archetypes.early_failure) failDetail.push(`${archetypes.early_failure} stuck before Big Tech`);
|
|
if (archetypes.late_failure) failDetail.push(`${archetypes.late_failure} stuck in Big Tech`);
|
|
lines.push(` Failures: ${failDetail.join(', ')}`);
|
|
}
|
|
lines.push('');
|
|
|
|
// 1. Run Overview
|
|
const totalWallTime = rows.reduce((s, r) => s + r.wallTimeMs, 0);
|
|
lines.push('1. RUN OVERVIEW');
|
|
lines.push(` Total runs: ${n}`);
|
|
lines.push(` Pass rate: ${passCount}/${n} (${((passCount / n) * 100).toFixed(0)}%)`);
|
|
lines.push(` Total wall time: ${(totalWallTime / 1000).toFixed(0)}s (avg ${(totalWallTime / n / 1000).toFixed(1)}s per run)`);
|
|
|
|
if (failedRuns.length > 0) {
|
|
lines.push(` Failed seeds: ${failedRuns.map(r => r.seed).join(', ')}`);
|
|
}
|
|
lines.push('');
|
|
|
|
// 2. Key Metrics
|
|
lines.push('2. KEY METRICS');
|
|
const metricDefs: Array<{ label: string; getter: (r: SummaryRow) => number; fmt?: (n: number) => string }> = [
|
|
{ label: 'Final Money', getter: r => r.finalMoney, fmt: n => `$${fmtNum(n)}` },
|
|
{ label: 'Final Revenue/tick', getter: r => r.finalRevenue, fmt: n => `$${fmtNum(n)}` },
|
|
{ label: 'Total Revenue', getter: r => r.finalTotalRevenue, fmt: n => `$${fmtNum(n)}` },
|
|
{ label: 'Capability', getter: r => r.finalCapability, fmt: n => n.toFixed(1) },
|
|
{ label: 'Reputation', getter: r => r.finalReputation, fmt: n => n.toFixed(1) },
|
|
{ label: 'Subscribers', getter: r => r.finalSubscribers, fmt: n => fmtNum(n, 0) },
|
|
{ label: 'API Developers', getter: r => r.finalDevelopers, fmt: n => fmtNum(n, 0) },
|
|
{ label: 'Headcount', getter: r => r.finalHeadcount, fmt: n => String(Math.round(n)) },
|
|
{ label: 'Research Count', getter: r => r.finalResearchCount, fmt: n => String(Math.round(n)) },
|
|
{ label: 'Models Deployed', getter: r => r.finalModelsDeployed, fmt: n => String(Math.round(n)) },
|
|
{ label: 'Revenue Streams', getter: r => r.revenueStreamDiversity, fmt: n => String(Math.round(n)) },
|
|
];
|
|
|
|
// Add late-game revenue growth if available
|
|
const hasLateGrowth = rows.some(r => r.lateGameRevenueGrowthRate !== null);
|
|
if (hasLateGrowth) {
|
|
metricDefs.push({
|
|
label: 'Late Rev Growth/tick',
|
|
getter: r => r.lateGameRevenueGrowthRate ?? 0,
|
|
fmt: n => pct(n),
|
|
});
|
|
}
|
|
|
|
const highVarianceMetrics: string[] = [];
|
|
for (const def of metricDefs) {
|
|
const values = rows.map(def.getter);
|
|
const s = computeStats(values);
|
|
lines.push(statsLine(def.label, s, def.fmt));
|
|
const isMonetary = MONETARY_LABELS.has(def.label);
|
|
if (isMonetary ? s.logCv > 0.15 : s.cv > 0.3) highVarianceMetrics.push(def.label);
|
|
}
|
|
lines.push('');
|
|
|
|
// 3. Era Transition Timing
|
|
lines.push('3. ERA TRANSITION TIMING');
|
|
const eraTransitions: Array<{ label: string; getter: (r: SummaryRow) => number | null }> = [
|
|
{ label: 'Startup → Scale-up', getter: r => r.eraTransition_scaleup },
|
|
{ label: 'Scale-up → Big Tech', getter: r => r.eraTransition_bigtech },
|
|
{ label: 'Big Tech → AGI', getter: r => r.eraTransition_agi },
|
|
];
|
|
|
|
const inconsistentEras: string[] = [];
|
|
for (const et of eraTransitions) {
|
|
const values = rows.map(et.getter).filter((v): v is number => v !== null);
|
|
const reached = values.length;
|
|
if (reached === 0) {
|
|
lines.push(` ${pad(et.label, 24)} never reached`);
|
|
continue;
|
|
}
|
|
const s = computeStats(values);
|
|
const cvFlag = s.cv > 0.25 ? ' [INCONSISTENT]' : '';
|
|
if (s.cv > 0.25) inconsistentEras.push(et.label);
|
|
lines.push(` ${pad(et.label, 24)} ${reached}/${n} reached | mean=${formatDuration(s.mean).padStart(6)} median=${formatDuration(s.median).padStart(6)} p25=${formatDuration(s.p25).padStart(6)} p75=${formatDuration(s.p75).padStart(6)} range=[${formatDuration(s.min)}, ${formatDuration(s.max)}] CV=${s.cv.toFixed(2)}${cvFlag}`);
|
|
}
|
|
lines.push('');
|
|
|
|
// 3B. Per-Era Duration
|
|
const hasEraDurations = rows.some(r => r.duration_startup !== null);
|
|
if (hasEraDurations) {
|
|
lines.push('3B. PER-ERA DURATION');
|
|
const eraDurations: Array<{ label: string; getter: (r: SummaryRow) => number | null; bottleneckGetter?: (r: SummaryRow) => string }> = [
|
|
{ label: 'Startup', getter: r => r.duration_startup },
|
|
{ label: 'Scale-up', getter: r => r.duration_scaleup, bottleneckGetter: r => r.bottleneck_scaleup },
|
|
{ label: 'Big Tech', getter: r => r.duration_bigtech, bottleneckGetter: r => r.bottleneck_bigtech },
|
|
{ label: 'AGI', getter: r => r.duration_agi, bottleneckGetter: r => r.bottleneck_agi },
|
|
];
|
|
|
|
for (const ed of eraDurations) {
|
|
const values = rows.map(ed.getter).filter((v): v is number => v !== null && v > 0);
|
|
if (values.length === 0) {
|
|
lines.push(` ${pad(ed.label, 14)} no data`);
|
|
continue;
|
|
}
|
|
const s = computeStats(values);
|
|
lines.push(` ${pad(ed.label, 14)} ${values.length}/${n} runs | mean=${formatDuration(s.mean).padStart(6)} median=${formatDuration(s.median).padStart(6)} p25=${formatDuration(s.p25).padStart(6)} p75=${formatDuration(s.p75).padStart(6)} range=[${formatDuration(s.min)}, ${formatDuration(s.max)}]`);
|
|
|
|
if (ed.bottleneckGetter) {
|
|
const bottlenecks = rows.map(ed.bottleneckGetter).filter(Boolean);
|
|
if (bottlenecks.length > 0) {
|
|
const freq: Record<string, number> = {};
|
|
for (const b of bottlenecks) freq[b] = (freq[b] ?? 0) + 1;
|
|
const sorted = Object.entries(freq).sort((a, b) => b[1] - a[1]);
|
|
const parts = sorted.map(([gate, count]) => `${gate}: ${((count / bottlenecks.length) * 100).toFixed(0)}%`);
|
|
lines.push(` ${pad('', 14)} exit bottleneck: ${parts.join(', ')}`);
|
|
}
|
|
}
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
// 4. Feature Utilization
|
|
lines.push('4. FEATURE UTILIZATION');
|
|
const fuCategories = Object.keys(rows[0]?.featureUtilization ?? {});
|
|
const consistentlyLow: string[] = [];
|
|
for (const cat of fuCategories) {
|
|
const values = rows.map(r => r.featureUtilization[cat] ?? 0);
|
|
const s = computeStats(values);
|
|
const bar = '#'.repeat(Math.round(s.mean / 5)) + '-'.repeat(20 - Math.round(s.mean / 5));
|
|
const flag = s.mean < 50 ? ' [LOW]' : '';
|
|
if (s.mean < 50) consistentlyLow.push(cat);
|
|
lines.push(` ${pad(cat, 16)} [${bar}] mean=${s.mean.toFixed(0)}% stddev=${s.stddev.toFixed(1)}${flag}`);
|
|
}
|
|
|
|
// Unused features frequency table
|
|
const hasUnusedFeatures = rows.some(r => r.unusedFeatures.length > 0);
|
|
if (hasUnusedFeatures) {
|
|
const skipFreq: Record<string, number> = {};
|
|
for (const r of rows) {
|
|
for (const feat of r.unusedFeatures.split(';').filter(Boolean)) {
|
|
skipFreq[feat] = (skipFreq[feat] ?? 0) + 1;
|
|
}
|
|
}
|
|
const sortedSkips = Object.entries(skipFreq).sort((a, b) => b[1] - a[1]);
|
|
if (sortedSkips.length > 0) {
|
|
lines.push('');
|
|
lines.push(' Most-skipped features:');
|
|
for (const [feat, count] of sortedSkips.slice(0, 15)) {
|
|
lines.push(` ${String(count).padStart(3)}/${n} (${((count / n) * 100).toFixed(0).padStart(2)}%) ${feat}`);
|
|
}
|
|
}
|
|
}
|
|
lines.push('');
|
|
|
|
// 5. System Interconnections
|
|
lines.push('5. SYSTEM INTERCONNECTIONS');
|
|
const icKeys = Object.keys(rows[0]?.interconnections ?? {});
|
|
const weakLinks: string[] = [];
|
|
const deadLinks: string[] = [];
|
|
|
|
{
|
|
const overallValues = rows.map(r => r.interconnectionOverall);
|
|
const overallStats = computeStats(overallValues);
|
|
lines.push(` Overall score: mean=${overallStats.mean.toFixed(1)} stddev=${overallStats.stddev.toFixed(1)} range=[${overallStats.min.toFixed(1)}, ${overallStats.max.toFixed(1)}]`);
|
|
}
|
|
|
|
for (const key of icKeys) {
|
|
const values = rows.map(r => r.interconnections[key] ?? 0);
|
|
const s = computeStats(values);
|
|
const label = key.replace(/_/g, ' → ').replace(/([a-z])([A-Z])/g, '$1 $2');
|
|
const bar = '#'.repeat(Math.round(s.mean)) + '-'.repeat(10 - Math.round(s.mean));
|
|
let flag = '';
|
|
if (s.mean === 0) { flag = ' [DEAD]'; deadLinks.push(label); }
|
|
else if (s.mean < 3) { flag = ' [WEAK]'; weakLinks.push(label); }
|
|
if (s.stddev > 3) { flag += ' [INCONSISTENT]'; }
|
|
lines.push(` ${pad(label, 30)} [${bar}] mean=${s.mean.toFixed(1)} stddev=${s.stddev.toFixed(1)} min=${s.min}${flag}`);
|
|
}
|
|
lines.push('');
|
|
|
|
// 5B. Serving Infrastructure
|
|
const hasServing = rows.some(r => r.servingMeanUtil !== null);
|
|
let servingOverloaded = false;
|
|
let servingUnderloaded = false;
|
|
if (hasServing) {
|
|
lines.push('5B. SERVING INFRASTRUCTURE');
|
|
const meanUtils = rows.map(r => r.servingMeanUtil).filter((v): v is number => v !== null);
|
|
const overloaded = rows.map(r => r.servingPctOverloaded).filter((v): v is number => v !== null);
|
|
const underloaded = rows.map(r => r.servingPctUnderloaded).filter((v): v is number => v !== null);
|
|
const peaks = rows.map(r => r.servingPeakUtil).filter((v): v is number => v !== null);
|
|
|
|
if (meanUtils.length > 0) {
|
|
const sMean = computeStats(meanUtils);
|
|
const sOver = computeStats(overloaded);
|
|
const sUnder = computeStats(underloaded);
|
|
const sPeak = computeStats(peaks);
|
|
|
|
lines.push(` Mean utilization: median=${pct(sMean.median).padStart(4)} mean=${pct(sMean.mean).padStart(4)} range=[${pct(sMean.min)}, ${pct(sMean.max)}]`);
|
|
lines.push(` % ticks overloaded: median=${pct(sOver.median).padStart(4)} mean=${pct(sOver.mean).padStart(4)} range=[${pct(sOver.min)}, ${pct(sOver.max)}]`);
|
|
lines.push(` % ticks underused: median=${pct(sUnder.median).padStart(4)} mean=${pct(sUnder.mean).padStart(4)} range=[${pct(sUnder.min)}, ${pct(sUnder.max)}]`);
|
|
lines.push(` Peak utilization: median=${pct(sPeak.median).padStart(4)} mean=${pct(sPeak.mean).padStart(4)} range=[${pct(sPeak.min)}, ${pct(sPeak.max)}]`);
|
|
|
|
if (sOver.median > 0.5) {
|
|
lines.push(` >> Diagnosis: Chronic overload — demand exceeds capacity ${pct(sOver.median)} of the time`);
|
|
servingOverloaded = true;
|
|
} else if (sUnder.median > 0.5) {
|
|
lines.push(` >> Diagnosis: Chronic underutilization — capacity idle ${pct(sUnder.median)} of the time`);
|
|
servingUnderloaded = true;
|
|
} else if (sOver.median > 0.2 && sUnder.median > 0.2) {
|
|
lines.push(` >> Diagnosis: Volatile — swings between overload (${pct(sOver.median)}) and underuse (${pct(sUnder.median)})`);
|
|
}
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
// 6. Failure Analysis
|
|
lines.push('6. FAILURE ANALYSIS');
|
|
const failureFreq: Record<string, number> = {};
|
|
for (const r of rows) {
|
|
if (!r.failureReasons) continue;
|
|
const seen = new Set<string>();
|
|
for (const reason of r.failureReasons.split('; ').filter(Boolean)) {
|
|
const normalized = reason.replace(/tick \d+/g, 'tick N').replace(/\d+ ticks/g, 'N ticks');
|
|
seen.add(normalized);
|
|
}
|
|
for (const normalized of seen) {
|
|
failureFreq[normalized] = (failureFreq[normalized] ?? 0) + 1;
|
|
}
|
|
}
|
|
const sortedFailures = Object.entries(failureFreq).sort((a, b) => b[1] - a[1]);
|
|
if (sortedFailures.length === 0) {
|
|
lines.push(' No failures detected across all runs.');
|
|
} else {
|
|
for (const [reason, count] of sortedFailures.slice(0, 10)) {
|
|
lines.push(` ${((count / n) * 100).toFixed(0).padStart(3)}% (${count}/${n}) ${reason}`);
|
|
}
|
|
}
|
|
|
|
// Failed run detail with gate analysis
|
|
if (failedRuns.length > 0) {
|
|
lines.push('');
|
|
lines.push(' Failed run detail (vs AGI gates: capability≥93, revenue≥$1B, reputation≥80):');
|
|
const gateBlockers: Record<string, number> = {};
|
|
for (const r of failedRuns) {
|
|
const gates = [
|
|
{ name: 'capability', current: r.finalCapability, required: AGI_GATES.capability, fmt: (n: number) => n.toFixed(1) },
|
|
{ name: 'revenue', current: r.finalTotalRevenue, required: AGI_GATES.revenue, fmt: (n: number) => `$${fmtNum(n)}` },
|
|
{ name: 'reputation', current: r.finalReputation, required: AGI_GATES.reputation, fmt: (n: number) => n.toFixed(1) },
|
|
];
|
|
const unmet = gates.filter(g => g.current < g.required);
|
|
const blocking = unmet.length > 0
|
|
? unmet.reduce((a, b) => (a.current / a.required < b.current / b.required) ? a : b)
|
|
: null;
|
|
|
|
const gateStrs = gates.map(g => {
|
|
const pctComplete = Math.min(100, (g.current / g.required) * 100);
|
|
const marker = g.current >= g.required ? '✓' : '✗';
|
|
return `${g.name}=${g.fmt(g.current)} (${pctComplete.toFixed(0)}%) ${marker}`;
|
|
});
|
|
|
|
lines.push(` seed ${r.seed}: ${gateStrs.join(' | ')}${blocking ? ` — blocked by ${blocking.name}` : ''}`);
|
|
if (blocking) gateBlockers[blocking.name] = (gateBlockers[blocking.name] ?? 0) + 1;
|
|
}
|
|
if (Object.keys(gateBlockers).length > 0) {
|
|
const sorted = Object.entries(gateBlockers).sort((a, b) => b[1] - a[1]);
|
|
lines.push(` Blocking gate frequency: ${sorted.map(([g, c]) => `${g}: ${c}/${failedRuns.length}`).join(', ')}`);
|
|
}
|
|
}
|
|
|
|
// Cash-flow nadir
|
|
const hasCashNadir = rows.some(r => r.cashMinAmount !== null);
|
|
if (hasCashNadir) {
|
|
const nadirAmounts = rows.map(r => r.cashMinAmount).filter((v): v is number => v !== null);
|
|
const nadirTicks = rows.map(r => r.cashMinTick).filter((v): v is number => v !== null);
|
|
const peakAmounts = rows.map(r => r.cashPeakAmount).filter((v): v is number => v !== null);
|
|
if (nadirAmounts.length > 0) {
|
|
lines.push('');
|
|
const sNadir = computeStats(nadirAmounts);
|
|
const sNadirTick = computeStats(nadirTicks);
|
|
const sPeak = computeStats(peakAmounts);
|
|
lines.push(` Cash nadir: median $${fmtNum(sNadir.median)} at ${formatDuration(sNadirTick.median)} | mean $${fmtNum(sNadir.mean)} at ${formatDuration(sNadirTick.mean)}`);
|
|
lines.push(` Cash peak: median $${fmtNum(sPeak.median)} | mean $${fmtNum(sPeak.mean)}`);
|
|
}
|
|
} else {
|
|
const bankruptcyRuns = rows.filter(r => r.bankruptcyRisks > 0).length;
|
|
if (bankruptcyRuns > 0) {
|
|
lines.push(` Bankruptcy risk: ${bankruptcyRuns}/${n} runs (${((bankruptcyRuns / n) * 100).toFixed(0)}%)`);
|
|
}
|
|
}
|
|
|
|
const sanityFailRuns = rows.filter(r => r.sanityErrors > 0).length;
|
|
if (sanityFailRuns > 0) {
|
|
lines.push(` Sanity errors: ${sanityFailRuns}/${n} runs (${((sanityFailRuns / n) * 100).toFixed(0)}%)`);
|
|
}
|
|
lines.push('');
|
|
|
|
// 7. Recommendations
|
|
lines.push('7. RECOMMENDATIONS');
|
|
const recs: string[] = [];
|
|
|
|
if (passCount / n < 0.8) {
|
|
const topFailure = sortedFailures[0];
|
|
if (topFailure) {
|
|
recs.push(`Balance is unstable — "${topFailure[0]}" occurs in ${((topFailure[1] / n) * 100).toFixed(0)}% of runs. This is the top priority fix.`);
|
|
}
|
|
}
|
|
|
|
for (const cat of consistentlyLow) {
|
|
recs.push(`Feature category "${cat}" has <50% utilization on average — review whether ${cat} features are reachable and worthwhile for the strategy.`);
|
|
}
|
|
|
|
for (const link of deadLinks) {
|
|
recs.push(`"${link}" has no measurable effect in any run — investment in the source doesn't translate to improvement in the target.`);
|
|
}
|
|
for (const link of weakLinks) {
|
|
recs.push(`"${link}" is consistently weak (mean <3/10) — the connection exists but is too faint to drive strategy.`);
|
|
}
|
|
|
|
if (servingOverloaded) {
|
|
recs.push('Serving infrastructure is chronically overloaded — demand exceeds capacity for most of the game. Consider faster compute scaling or demand throttling.');
|
|
}
|
|
if (servingUnderloaded) {
|
|
recs.push('Serving infrastructure is chronically underutilized — compute capacity vastly exceeds demand. Consider slowing infrastructure investment or accelerating user growth.');
|
|
}
|
|
|
|
for (const metric of highVarianceMetrics) {
|
|
const def = metricDefs.find(d => d.label === metric)!;
|
|
const values = rows.map(def.getter);
|
|
const s = computeStats(values);
|
|
const isMonetary = MONETARY_LABELS.has(metric);
|
|
const measure = isMonetary ? `logCV=${s.logCv.toFixed(2)}` : `CV=${s.cv.toFixed(2)}`;
|
|
recs.push(`"${metric}" is highly seed-dependent (${measure}) — outcome is more luck than strategy.`);
|
|
}
|
|
|
|
for (const era of inconsistentEras) {
|
|
recs.push(`"${era}" transition timing is inconsistent (CV>0.25) — suggests a fragile threshold crossing that depends on RNG luck.`);
|
|
}
|
|
|
|
// Gate-specific recommendation if failures share a common blocker
|
|
if (failedRuns.length > 0) {
|
|
const gateBlockers: Record<string, number> = {};
|
|
for (const r of failedRuns) {
|
|
const gates = [
|
|
{ name: 'capability', current: r.finalCapability, required: AGI_GATES.capability },
|
|
{ name: 'revenue', current: r.finalTotalRevenue, required: AGI_GATES.revenue },
|
|
{ name: 'reputation', current: r.finalReputation, required: AGI_GATES.reputation },
|
|
];
|
|
const unmet = gates.filter(g => g.current < g.required);
|
|
const blocking = unmet.length > 0
|
|
? unmet.reduce((a, b) => (a.current / a.required < b.current / b.required) ? a : b)
|
|
: null;
|
|
if (blocking) gateBlockers[blocking.name] = (gateBlockers[blocking.name] ?? 0) + 1;
|
|
}
|
|
const dominant = Object.entries(gateBlockers).sort((a, b) => b[1] - a[1])[0];
|
|
if (dominant && dominant[1] / failedRuns.length > 0.5) {
|
|
recs.push(`${dominant[1]}/${failedRuns.length} failures blocked by ${dominant[0]} gate — this is the primary balance bottleneck for AGI transition.`);
|
|
}
|
|
}
|
|
|
|
if (passCount === n && recs.length === 0) {
|
|
recs.push('All runs passed with consistent results. Balance looks stable across seeds.');
|
|
}
|
|
|
|
for (let i = 0; i < recs.length; i++) {
|
|
lines.push(` ${i + 1}. ${recs[i]}`);
|
|
}
|
|
lines.push('');
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
const csvContent = readFileSync(summaryPath, 'utf-8');
|
|
const rows = parseSummaryCsv(csvContent);
|
|
|
|
if (rows.length === 0) {
|
|
console.error('No data found in summary CSV.');
|
|
process.exit(1);
|
|
}
|
|
|
|
const report = generateReport(rows);
|
|
|
|
if (outPath) {
|
|
writeFileSync(outPath, report);
|
|
console.log(`Report written to ${outPath}`);
|
|
} else {
|
|
console.log(report);
|
|
}
|