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,42 @@
|
||||
import type { SimulationMetrics } from '../strategies/types';
|
||||
import type { TickNotification } from '@ai-tycoon/game-engine';
|
||||
|
||||
export interface RevenueBreakpoint {
|
||||
tick: number;
|
||||
revenueBefore: number;
|
||||
revenueAfter: number;
|
||||
percentIncrease: number;
|
||||
possibleCause: string;
|
||||
}
|
||||
|
||||
export function detectBreakpoints(
|
||||
metrics: SimulationMetrics[],
|
||||
notifications: TickNotification[],
|
||||
threshold = 3.0,
|
||||
minRevenueFloor = 5000,
|
||||
): RevenueBreakpoint[] {
|
||||
const breakpoints: RevenueBreakpoint[] = [];
|
||||
|
||||
for (let i = 1; i < metrics.length; i++) {
|
||||
const prev = metrics[i - 1];
|
||||
const curr = metrics[i];
|
||||
|
||||
if (prev.revenue >= minRevenueFloor && curr.revenue / prev.revenue > threshold) {
|
||||
const nearby = notifications.filter(n =>
|
||||
n.type === 'success' && Math.abs(parseInt(String(curr.tick)) - parseInt(String(prev.tick))) < 120,
|
||||
);
|
||||
|
||||
const cause = nearby.length > 0 ? nearby[nearby.length - 1].title : 'Unknown';
|
||||
|
||||
breakpoints.push({
|
||||
tick: curr.tick,
|
||||
revenueBefore: prev.revenue,
|
||||
revenueAfter: curr.revenue,
|
||||
percentIncrease: ((curr.revenue - prev.revenue) / prev.revenue) * 100,
|
||||
possibleCause: cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return breakpoints;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { SimulationMetrics } from '../strategies/types';
|
||||
|
||||
export interface CashFlowPeriod {
|
||||
startTick: number;
|
||||
endTick: number;
|
||||
durationTicks: number;
|
||||
averageBurnRate: number;
|
||||
startingCash: number;
|
||||
endingCash: number;
|
||||
}
|
||||
|
||||
export interface BankruptcyRisk {
|
||||
tick: number;
|
||||
cash: number;
|
||||
burnRate: number;
|
||||
ticksToZero: number;
|
||||
hasRevenueStream: boolean;
|
||||
}
|
||||
|
||||
export interface CashFlowResult {
|
||||
peakCash: { amount: number; tick: number };
|
||||
minCash: { amount: number; tick: number };
|
||||
negativePeriods: CashFlowPeriod[];
|
||||
bankruptcyRisks: BankruptcyRisk[];
|
||||
averageBurnRateByEra: Record<string, number>;
|
||||
}
|
||||
|
||||
export function analyzeCashFlow(
|
||||
metrics: SimulationMetrics[],
|
||||
bankruptcyThresholdTicks = 300,
|
||||
): CashFlowResult {
|
||||
let peakCash = { amount: -Infinity, tick: 0 };
|
||||
let minCash = { amount: Infinity, tick: 0 };
|
||||
const negativePeriods: CashFlowPeriod[] = [];
|
||||
const bankruptcyRisks: BankruptcyRisk[] = [];
|
||||
const eraBurnSums = new Map<string, { total: number; count: number }>();
|
||||
|
||||
let negStart: { tick: number; cash: number; burnSum: number; count: number } | null = null;
|
||||
|
||||
for (const m of metrics) {
|
||||
if (m.money > peakCash.amount) peakCash = { amount: m.money, tick: m.tick };
|
||||
if (m.money < minCash.amount) minCash = { amount: m.money, tick: m.tick };
|
||||
|
||||
const netFlow = m.netCashFlow;
|
||||
|
||||
const eraStat = eraBurnSums.get(m.era) ?? { total: 0, count: 0 };
|
||||
eraStat.total += netFlow;
|
||||
eraStat.count += 1;
|
||||
eraBurnSums.set(m.era, eraStat);
|
||||
|
||||
if (netFlow < 0) {
|
||||
if (!negStart) {
|
||||
negStart = { tick: m.tick, cash: m.money, burnSum: netFlow, count: 1 };
|
||||
} else {
|
||||
negStart.burnSum += netFlow;
|
||||
negStart.count += 1;
|
||||
}
|
||||
|
||||
const burnRate = Math.abs(netFlow);
|
||||
if (burnRate > 0) {
|
||||
const ticksToZero = m.money / burnRate;
|
||||
const hasEverHadRevenue = m.totalRevenue > 0;
|
||||
if (ticksToZero < bankruptcyThresholdTicks && hasEverHadRevenue) {
|
||||
bankruptcyRisks.push({
|
||||
tick: m.tick,
|
||||
cash: m.money,
|
||||
burnRate: -burnRate,
|
||||
ticksToZero: Math.round(ticksToZero),
|
||||
hasRevenueStream: m.revenue > 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (negStart) {
|
||||
negativePeriods.push({
|
||||
startTick: negStart.tick,
|
||||
endTick: m.tick,
|
||||
durationTicks: m.tick - negStart.tick,
|
||||
averageBurnRate: negStart.burnSum / negStart.count,
|
||||
startingCash: negStart.cash,
|
||||
endingCash: m.money,
|
||||
});
|
||||
negStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (negStart) {
|
||||
const lastTick = metrics[metrics.length - 1]?.tick ?? negStart.tick;
|
||||
negativePeriods.push({
|
||||
startTick: negStart.tick,
|
||||
endTick: lastTick,
|
||||
durationTicks: lastTick - negStart.tick,
|
||||
averageBurnRate: negStart.burnSum / negStart.count,
|
||||
startingCash: negStart.cash,
|
||||
endingCash: metrics[metrics.length - 1]?.money ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
const averageBurnRateByEra: Record<string, number> = {};
|
||||
for (const [era, stat] of eraBurnSums) {
|
||||
averageBurnRateByEra[era] = stat.count > 0 ? stat.total / stat.count : 0;
|
||||
}
|
||||
|
||||
if (peakCash.amount === -Infinity) peakCash = { amount: 0, tick: 0 };
|
||||
if (minCash.amount === Infinity) minCash = { amount: 0, tick: 0 };
|
||||
|
||||
return { peakCash, minCash, negativePeriods, bankruptcyRisks, averageBurnRateByEra };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { GameState, RackSkuId } from '@ai-tycoon/shared';
|
||||
import { RACK_SKU_CONFIGS } from '@ai-tycoon/shared';
|
||||
import type { SimulationMetrics } from '../strategies/types';
|
||||
|
||||
export interface DeadZone {
|
||||
startTick: number;
|
||||
endTick: number;
|
||||
durationTicks: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function getCheapestSkuCost(state: GameState): number {
|
||||
const era = state.meta.currentEra;
|
||||
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
const eraIdx = eraOrder.indexOf(era);
|
||||
|
||||
let cheapest = Infinity;
|
||||
for (const [, sku] of Object.entries(RACK_SKU_CONFIGS)) {
|
||||
if (eraOrder.indexOf(sku.era) <= eraIdx) {
|
||||
cheapest = Math.min(cheapest, sku.baseCost);
|
||||
}
|
||||
}
|
||||
return cheapest === Infinity ? 100_000 : cheapest;
|
||||
}
|
||||
|
||||
export function detectDeadZones(
|
||||
metrics: SimulationMetrics[],
|
||||
cheapestSkuCost: number,
|
||||
windowSize = 10,
|
||||
revenueTolerance = 0.02,
|
||||
capabilityTolerance = 0.02,
|
||||
): DeadZone[] {
|
||||
const zones: DeadZone[] = [];
|
||||
let zoneStart: number | null = null;
|
||||
|
||||
for (let i = windowSize; i < metrics.length; i++) {
|
||||
const current = metrics[i];
|
||||
const past = metrics[i - windowSize];
|
||||
|
||||
const revFlat = past.revenue > 0
|
||||
? Math.abs(current.revenue - past.revenue) / past.revenue < revenueTolerance
|
||||
: current.revenue === 0;
|
||||
|
||||
const capFlat = past.bestModelCapability > 0
|
||||
? Math.abs(current.bestModelCapability - past.bestModelCapability) / past.bestModelCapability < capabilityTolerance
|
||||
: current.bestModelCapability === 0;
|
||||
|
||||
const isStuck = revFlat && capFlat && current.money < cheapestSkuCost * 2;
|
||||
|
||||
if (isStuck) {
|
||||
if (zoneStart === null) zoneStart = past.tick;
|
||||
} else {
|
||||
if (zoneStart !== null) {
|
||||
const endTick = metrics[i - 1].tick;
|
||||
zones.push({
|
||||
startTick: zoneStart,
|
||||
endTick,
|
||||
durationTicks: endTick - zoneStart,
|
||||
description: 'revenue flat, no affordable upgrades',
|
||||
});
|
||||
zoneStart = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (zoneStart !== null) {
|
||||
const endTick = metrics[metrics.length - 1].tick;
|
||||
zones.push({
|
||||
startTick: zoneStart,
|
||||
endTick,
|
||||
durationTicks: endTick - zoneStart,
|
||||
description: 'revenue flat, no affordable upgrades (ongoing)',
|
||||
});
|
||||
}
|
||||
|
||||
return zones;
|
||||
}
|
||||
|
||||
export { getCheapestSkuCost };
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { SimulationMetrics } from '../strategies/types';
|
||||
import { ERA_THRESHOLDS } from '@ai-tycoon/shared';
|
||||
|
||||
const ERA_ORDER = ['startup', 'scaleup', 'bigtech', 'agi'] as const;
|
||||
|
||||
export interface ThresholdDistance {
|
||||
metric: 'revenue' | 'capability' | 'reputation';
|
||||
current: number;
|
||||
required: number;
|
||||
percentComplete: number;
|
||||
isMet: boolean;
|
||||
}
|
||||
|
||||
export interface EraProximitySnapshot {
|
||||
tick: number;
|
||||
targetEra: string;
|
||||
thresholds: ThresholdDistance[];
|
||||
bottleneck: string;
|
||||
reputationComponents?: {
|
||||
safetyRecord: number;
|
||||
publicPerception: number;
|
||||
employeeSatisfaction: number;
|
||||
regulatoryStanding: number;
|
||||
lowestComponent: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EraCeiling {
|
||||
era: string;
|
||||
metric: string;
|
||||
stuckAtValue: number;
|
||||
stuckSinceTick: number;
|
||||
stuckDurationTicks: number;
|
||||
requiredValue: number;
|
||||
}
|
||||
|
||||
export interface MathCeiling {
|
||||
era: string;
|
||||
theoreticalMax: number;
|
||||
required: number;
|
||||
components: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface EraProximityResult {
|
||||
snapshots: EraProximitySnapshot[];
|
||||
ceilings: EraCeiling[];
|
||||
mathCeilings: MathCeiling[];
|
||||
perEraBottleneck: Record<string, string>;
|
||||
}
|
||||
|
||||
function getNextEra(current: string): string | null {
|
||||
const idx = ERA_ORDER.indexOf(current as typeof ERA_ORDER[number]);
|
||||
return idx >= 0 && idx < ERA_ORDER.length - 1 ? ERA_ORDER[idx + 1] : null;
|
||||
}
|
||||
|
||||
function computeThresholds(m: SimulationMetrics, targetEra: string): ThresholdDistance[] {
|
||||
const thresholds = ERA_THRESHOLDS[targetEra as keyof typeof ERA_THRESHOLDS];
|
||||
if (!thresholds) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
metric: 'revenue' as const,
|
||||
current: m.totalRevenue,
|
||||
required: thresholds.revenue,
|
||||
percentComplete: Math.min(100, (m.totalRevenue / thresholds.revenue) * 100),
|
||||
isMet: m.totalRevenue >= thresholds.revenue,
|
||||
},
|
||||
{
|
||||
metric: 'capability' as const,
|
||||
current: m.bestModelCapability,
|
||||
required: thresholds.capability,
|
||||
percentComplete: Math.min(100, (m.bestModelCapability / thresholds.capability) * 100),
|
||||
isMet: m.bestModelCapability >= thresholds.capability,
|
||||
},
|
||||
{
|
||||
metric: 'reputation' as const,
|
||||
current: m.reputation,
|
||||
required: thresholds.reputation,
|
||||
percentComplete: Math.min(100, (m.reputation / thresholds.reputation) * 100),
|
||||
isMet: m.reputation >= thresholds.reputation,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function analyzeEraProximity(
|
||||
metrics: SimulationMetrics[],
|
||||
ceilingWindowTicks = 1800,
|
||||
): EraProximityResult {
|
||||
const snapshots: EraProximitySnapshot[] = [];
|
||||
const perEraBottleneck: Record<string, string> = {};
|
||||
|
||||
const ceilingTrackers = new Map<string, { value: number; sinceTick: number }>();
|
||||
|
||||
for (const m of metrics) {
|
||||
const targetEra = getNextEra(m.era);
|
||||
if (!targetEra) continue;
|
||||
|
||||
const thresholds = computeThresholds(m, targetEra);
|
||||
if (thresholds.length === 0) continue;
|
||||
|
||||
const unmet = thresholds.filter(t => !t.isMet);
|
||||
const bottleneck = unmet.length > 0
|
||||
? unmet.reduce((a, b) => a.percentComplete < b.percentComplete ? a : b).metric
|
||||
: 'none';
|
||||
|
||||
let reputationComponents: EraProximitySnapshot['reputationComponents'];
|
||||
if (bottleneck === 'reputation') {
|
||||
const comps = {
|
||||
safetyRecord: m.safetyRecord,
|
||||
publicPerception: m.publicPerception,
|
||||
employeeSatisfaction: m.employeeSatisfaction,
|
||||
regulatoryStanding: m.regulatoryStanding,
|
||||
};
|
||||
const lowest = (Object.entries(comps) as [string, number][])
|
||||
.reduce((a, b) => a[1] < b[1] ? a : b);
|
||||
reputationComponents = { ...comps, lowestComponent: lowest[0] };
|
||||
}
|
||||
|
||||
snapshots.push({ tick: m.tick, targetEra, thresholds, bottleneck, reputationComponents });
|
||||
perEraBottleneck[targetEra] = bottleneck;
|
||||
|
||||
for (const t of thresholds) {
|
||||
const key = `${targetEra}:${t.metric}`;
|
||||
if (t.isMet) {
|
||||
ceilingTrackers.delete(key);
|
||||
continue;
|
||||
}
|
||||
if (t.metric === 'capability' && m.activeTrainingPipelines > 0) continue;
|
||||
const tracker = ceilingTrackers.get(key);
|
||||
if (!tracker) {
|
||||
ceilingTrackers.set(key, { value: t.current, sinceTick: m.tick });
|
||||
} else {
|
||||
const improvementPct = tracker.value > 0
|
||||
? ((t.current - tracker.value) / tracker.value) * 100
|
||||
: (t.current > 0 ? 100 : 0);
|
||||
if (improvementPct > 1) {
|
||||
ceilingTrackers.set(key, { value: t.current, sinceTick: m.tick });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ceilings: EraCeiling[] = [];
|
||||
for (const [key, tracker] of ceilingTrackers) {
|
||||
const lastMetric = metrics[metrics.length - 1];
|
||||
if (!lastMetric) break;
|
||||
const duration = lastMetric.tick - tracker.sinceTick;
|
||||
if (duration >= ceilingWindowTicks) {
|
||||
const [era, metric] = key.split(':');
|
||||
const thresholds = ERA_THRESHOLDS[era as keyof typeof ERA_THRESHOLDS];
|
||||
if (!thresholds) continue;
|
||||
ceilings.push({
|
||||
era,
|
||||
metric,
|
||||
stuckAtValue: tracker.value,
|
||||
stuckSinceTick: tracker.sinceTick,
|
||||
stuckDurationTicks: duration,
|
||||
requiredValue: thresholds[metric as keyof typeof thresholds],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mathCeilings: MathCeiling[] = [];
|
||||
const lastMetric = metrics[metrics.length - 1];
|
||||
if (lastMetric) {
|
||||
const targetEra = getNextEra(lastMetric.era);
|
||||
if (targetEra) {
|
||||
const thresholds = ERA_THRESHOLDS[targetEra as keyof typeof ERA_THRESHOLDS];
|
||||
if (thresholds) {
|
||||
const maxSafety = 80;
|
||||
const maxPublic = 100;
|
||||
const maxEmployee = 100;
|
||||
const maxRegulatory = 100;
|
||||
const theoreticalMax = Math.round(
|
||||
maxSafety * 0.3 + maxPublic * 0.3 + maxEmployee * 0.2 + maxRegulatory * 0.2,
|
||||
);
|
||||
if (theoreticalMax < thresholds.reputation) {
|
||||
mathCeilings.push({
|
||||
era: targetEra,
|
||||
theoreticalMax,
|
||||
required: thresholds.reputation,
|
||||
components: { maxSafety, maxPublic, maxEmployee, maxRegulatory },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { snapshots, ceilings, mathCeilings, perEraBottleneck };
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { GameState } from '@ai-tycoon/shared';
|
||||
import { RACK_SKU_CONFIGS, FUNDING_ROUNDS } from '@ai-tycoon/shared';
|
||||
import { TECH_TREE } from '@ai-tycoon/game-engine';
|
||||
|
||||
export interface FeatureUsage {
|
||||
name: string;
|
||||
category: 'research' | 'infrastructure' | 'revenue' | 'talent' | 'model' | 'funding';
|
||||
used: boolean;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface FeatureUtilizationResult {
|
||||
features: FeatureUsage[];
|
||||
coverageByCategory: Record<string, { used: number; available: number; percent: number }>;
|
||||
unusedFeatures: string[];
|
||||
neverAvailable: string[];
|
||||
revenueStreamDiversity: number;
|
||||
}
|
||||
|
||||
const ERA_ORDER = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
|
||||
export function analyzeFeatureUtilization(state: GameState): FeatureUtilizationResult {
|
||||
const features: FeatureUsage[] = [];
|
||||
const currentEraIdx = ERA_ORDER.indexOf(state.meta.currentEra);
|
||||
const completed = new Set(state.research.completedResearch);
|
||||
|
||||
// --- Research nodes ---
|
||||
for (const node of TECH_TREE) {
|
||||
const eraIdx = ERA_ORDER.indexOf(node.era);
|
||||
const available = eraIdx <= currentEraIdx && node.prerequisites.every(p => completed.has(p));
|
||||
features.push({
|
||||
name: `research:${node.id}`,
|
||||
category: 'research',
|
||||
used: completed.has(node.id),
|
||||
available: available || completed.has(node.id),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Infrastructure diversity ---
|
||||
const deployedSkus = new Set<string>();
|
||||
const dcTiers = new Set<string>();
|
||||
const coolingTypes = new Set<string>();
|
||||
const networkFabrics = new Set<string>();
|
||||
const locations = new Set<string>();
|
||||
|
||||
for (const cluster of state.infrastructure.clusters) {
|
||||
locations.add(cluster.locationId);
|
||||
for (const campus of cluster.campuses) {
|
||||
dcTiers.add(campus.dcTier);
|
||||
for (const dc of campus.dataCenters) {
|
||||
coolingTypes.add(dc.coolingType);
|
||||
networkFabrics.add(dc.networkFabric);
|
||||
if (dc.rackSkuId) deployedSkus.add(dc.rackSkuId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [skuId, sku] of Object.entries(RACK_SKU_CONFIGS)) {
|
||||
const eraIdx = ERA_ORDER.indexOf(sku.era);
|
||||
const available = eraIdx <= currentEraIdx && sku.requiredResearch.every((r: string) => completed.has(r));
|
||||
features.push({
|
||||
name: `rack:${skuId}`,
|
||||
category: 'infrastructure',
|
||||
used: deployedSkus.has(skuId),
|
||||
available: available || deployedSkus.has(skuId),
|
||||
});
|
||||
}
|
||||
|
||||
features.push({ name: 'cooling:liquid', category: 'infrastructure', used: coolingTypes.has('liquid') || coolingTypes.has('immersion'), available: completed.has('liquid-cooling-tech') });
|
||||
features.push({ name: 'cooling:immersion', category: 'infrastructure', used: coolingTypes.has('immersion'), available: completed.has('immersion-cooling-tech') });
|
||||
features.push({ name: 'network:400g', category: 'infrastructure', used: networkFabrics.has('ethernet-400g') || networkFabrics.has('infiniband-ndr') || networkFabrics.has('infiniband-xdr'), available: true });
|
||||
features.push({ name: 'network:infiniband', category: 'infrastructure', used: networkFabrics.has('infiniband-ndr') || networkFabrics.has('infiniband-xdr'), available: completed.has('infiniband-networking') });
|
||||
features.push({ name: 'multi-location', category: 'infrastructure', used: locations.size > 1, available: currentEraIdx >= 1 });
|
||||
|
||||
// --- Revenue streams ---
|
||||
const ct = state.market.consumerTiers.tiers;
|
||||
features.push({ name: 'tier:consumer-free', category: 'revenue', used: ct.free.config.isActive, available: true });
|
||||
features.push({ name: 'tier:consumer-plus', category: 'revenue', used: ct.plus.config.isActive, available: true });
|
||||
features.push({ name: 'tier:consumer-pro', category: 'revenue', used: ct.pro.config.isActive, available: true });
|
||||
features.push({ name: 'tier:consumer-team', category: 'revenue', used: ct.team.config.isActive, available: true });
|
||||
|
||||
const at = state.market.apiTiers.tiers;
|
||||
features.push({ name: 'tier:api-free', category: 'revenue', used: at.free.config.isActive, available: true });
|
||||
features.push({ name: 'tier:api-payg', category: 'revenue', used: at.payg.config.isActive, available: true });
|
||||
features.push({ name: 'tier:api-scale', category: 'revenue', used: at.scale.config.isActive, available: true });
|
||||
features.push({ name: 'tier:api-enterprise', category: 'revenue', used: at['enterprise-api'].config.isActive, available: true });
|
||||
|
||||
features.push({ name: 'product:code-assistant', category: 'revenue', used: state.market.codeAssistant.isActive, available: completed.has('code-assistant-product') });
|
||||
features.push({ name: 'product:agents-platform', category: 'revenue', used: state.market.agentsPlatform.isActive, available: completed.has('agents-platform-product') });
|
||||
features.push({ name: 'enterprise-contracts', category: 'revenue', used: state.market.enterprise.activeContracts.length > 0, available: completed.has('enterprise-sales') });
|
||||
features.push({ name: 'open-source-model', category: 'revenue', used: state.market.openSourcedModels.length > 0, available: state.models.baseModels.length > 0 });
|
||||
|
||||
// --- Talent ---
|
||||
const depts = state.talent.departments;
|
||||
features.push({ name: 'dept:research', category: 'talent', used: depts.research.headcount > 0, available: true });
|
||||
features.push({ name: 'dept:engineering', category: 'talent', used: depts.engineering.headcount > 0, available: true });
|
||||
features.push({ name: 'dept:operations', category: 'talent', used: depts.operations.headcount > 0, available: true });
|
||||
features.push({ name: 'dept:sales', category: 'talent', used: depts.sales.headcount > 0, available: true });
|
||||
|
||||
// --- Model training variety ---
|
||||
const architectures = new Set<string>();
|
||||
const paramSizes = new Set<number>();
|
||||
const sftSpecs = new Set<string>();
|
||||
const alignmentMethods = new Set<string>();
|
||||
let hasVariants = false;
|
||||
let hasPointReleases = false;
|
||||
|
||||
for (const pipeline of state.models.activeTrainingPipelines) {
|
||||
architectures.add(pipeline.architecture.type);
|
||||
paramSizes.add(pipeline.architecture.totalParameters);
|
||||
for (const spec of pipeline.stages.sft.specializations) sftSpecs.add(spec);
|
||||
alignmentMethods.add(pipeline.stages.alignment.method);
|
||||
if (pipeline.isPointRelease) hasPointReleases = true;
|
||||
}
|
||||
for (const model of state.models.baseModels) {
|
||||
architectures.add(model.architecture.type);
|
||||
paramSizes.add(model.architecture.totalParameters);
|
||||
for (const spec of model.sftSpecializations) sftSpecs.add(spec);
|
||||
if (model.alignmentMethod) alignmentMethods.add(model.alignmentMethod);
|
||||
}
|
||||
if (state.models.variantJobs.length > 0) hasVariants = true;
|
||||
|
||||
features.push({ name: 'model:dense-arch', category: 'model', used: architectures.has('dense'), available: true });
|
||||
features.push({ name: 'model:moe-arch', category: 'model', used: architectures.has('moe'), available: paramSizes.size > 0 });
|
||||
features.push({ name: 'model:multiple-sizes', category: 'model', used: paramSizes.size > 1, available: state.models.baseModels.length > 0 });
|
||||
features.push({ name: 'model:sft-code', category: 'model', used: sftSpecs.has('code'), available: completed.has('code-generation') });
|
||||
features.push({ name: 'model:sft-math', category: 'model', used: sftSpecs.has('math'), available: completed.has('reasoning-enhancement') });
|
||||
features.push({ name: 'model:sft-creative', category: 'model', used: sftSpecs.has('creative'), available: completed.has('creative-systems') });
|
||||
features.push({ name: 'model:alignment-rlhf', category: 'model', used: alignmentMethods.has('rlhf'), available: completed.has('alignment-research') });
|
||||
features.push({ name: 'model:alignment-constitutional', category: 'model', used: alignmentMethods.has('constitutional'), available: completed.has('constitutional-ai') });
|
||||
features.push({ name: 'model:quantization', category: 'model', used: hasVariants, available: completed.has('quantization') });
|
||||
features.push({ name: 'model:point-releases', category: 'model', used: hasPointReleases, available: state.models.baseModels.length > 0 });
|
||||
|
||||
// --- Funding ---
|
||||
const completedRounds = new Set<string>(state.economy.funding.completedRounds.map(r => r.type));
|
||||
for (const roundType of Object.keys(FUNDING_ROUNDS)) {
|
||||
features.push({
|
||||
name: `funding:${roundType}`,
|
||||
category: 'funding',
|
||||
used: completedRounds.has(roundType),
|
||||
available: true,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Aggregate ---
|
||||
const coverageByCategory: Record<string, { used: number; available: number; percent: number }> = {};
|
||||
for (const f of features) {
|
||||
if (!coverageByCategory[f.category]) {
|
||||
coverageByCategory[f.category] = { used: 0, available: 0, percent: 0 };
|
||||
}
|
||||
if (f.available) {
|
||||
coverageByCategory[f.category].available++;
|
||||
if (f.used) coverageByCategory[f.category].used++;
|
||||
}
|
||||
}
|
||||
for (const cat of Object.values(coverageByCategory)) {
|
||||
cat.percent = cat.available > 0 ? Math.round((cat.used / cat.available) * 100) : 0;
|
||||
}
|
||||
|
||||
const unusedFeatures = features.filter(f => f.available && !f.used).map(f => f.name);
|
||||
const neverAvailable = features.filter(f => !f.available && !f.used).map(f => f.name);
|
||||
|
||||
let revenueStreamDiversity = 0;
|
||||
if (ct.plus.config.isActive || ct.pro.config.isActive || ct.team.config.isActive) revenueStreamDiversity++;
|
||||
if (at.payg.config.isActive || at.scale.config.isActive || at['enterprise-api'].config.isActive) revenueStreamDiversity++;
|
||||
if (state.market.enterprise.activeContracts.length > 0) revenueStreamDiversity++;
|
||||
if (state.market.codeAssistant.isActive) revenueStreamDiversity++;
|
||||
if (state.market.agentsPlatform.isActive) revenueStreamDiversity++;
|
||||
|
||||
return { features, coverageByCategory, unusedFeatures, neverAvailable, revenueStreamDiversity };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { SimulationMetrics } from '../strategies/types';
|
||||
|
||||
export type TrackedMetric = 'revenue' | 'subscribers' | 'developers'
|
||||
| 'bestModelCapability' | 'reputation' | 'totalFlops';
|
||||
|
||||
const TRACKED_METRICS: TrackedMetric[] = [
|
||||
'revenue', 'subscribers', 'developers',
|
||||
'bestModelCapability', 'reputation', 'totalFlops',
|
||||
];
|
||||
|
||||
export interface StagnationAlert {
|
||||
metric: TrackedMetric;
|
||||
startTick: number;
|
||||
endTick: number;
|
||||
durationTicks: number;
|
||||
stuckValue: number;
|
||||
era: string;
|
||||
}
|
||||
|
||||
export interface ExponentialAlert {
|
||||
metric: TrackedMetric;
|
||||
tick: number;
|
||||
growthRate: number;
|
||||
consecutiveSamples: number;
|
||||
}
|
||||
|
||||
export interface GrowthRateResult {
|
||||
stagnations: StagnationAlert[];
|
||||
exponentialAlerts: ExponentialAlert[];
|
||||
}
|
||||
|
||||
function getMetricValue(m: SimulationMetrics, metric: TrackedMetric): number {
|
||||
return m[metric] as number;
|
||||
}
|
||||
|
||||
export function analyzeGrowthRates(
|
||||
metrics: SimulationMetrics[],
|
||||
stagnationWindowSamples = 20,
|
||||
stagnationThreshold = 0.01,
|
||||
): GrowthRateResult {
|
||||
const stagnations: StagnationAlert[] = [];
|
||||
const exponentialAlerts: ExponentialAlert[] = [];
|
||||
|
||||
for (const metric of TRACKED_METRICS) {
|
||||
const growthRates: number[] = [];
|
||||
|
||||
for (let i = 1; i < metrics.length; i++) {
|
||||
const prev = getMetricValue(metrics[i - 1], metric);
|
||||
const curr = getMetricValue(metrics[i], metric);
|
||||
if (prev > 0) {
|
||||
growthRates.push((curr - prev) / prev);
|
||||
} else {
|
||||
growthRates.push(curr > 0 ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
let stagnationStart: number | null = null;
|
||||
let flatCount = 0;
|
||||
|
||||
for (let i = 0; i < growthRates.length; i++) {
|
||||
if (Math.abs(growthRates[i]) < stagnationThreshold) {
|
||||
if (stagnationStart === null) stagnationStart = i;
|
||||
flatCount++;
|
||||
} else {
|
||||
if (flatCount >= stagnationWindowSamples && stagnationStart !== null) {
|
||||
const startIdx = stagnationStart + 1;
|
||||
const endIdx = i + 1;
|
||||
stagnations.push({
|
||||
metric,
|
||||
startTick: metrics[startIdx]?.tick ?? 0,
|
||||
endTick: metrics[endIdx]?.tick ?? metrics[metrics.length - 1]?.tick ?? 0,
|
||||
durationTicks: (metrics[endIdx]?.tick ?? 0) - (metrics[startIdx]?.tick ?? 0),
|
||||
stuckValue: getMetricValue(metrics[startIdx], metric),
|
||||
era: metrics[startIdx]?.era ?? 'unknown',
|
||||
});
|
||||
}
|
||||
stagnationStart = null;
|
||||
flatCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (flatCount >= stagnationWindowSamples && stagnationStart !== null) {
|
||||
const startIdx = stagnationStart + 1;
|
||||
stagnations.push({
|
||||
metric,
|
||||
startTick: metrics[startIdx]?.tick ?? 0,
|
||||
endTick: metrics[metrics.length - 1]?.tick ?? 0,
|
||||
durationTicks: (metrics[metrics.length - 1]?.tick ?? 0) - (metrics[startIdx]?.tick ?? 0),
|
||||
stuckValue: getMetricValue(metrics[startIdx], metric),
|
||||
era: metrics[startIdx]?.era ?? 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
let expCount = 0;
|
||||
for (let i = 0; i < growthRates.length; i++) {
|
||||
if (growthRates[i] > 0.10) {
|
||||
expCount++;
|
||||
if (expCount >= 5) {
|
||||
exponentialAlerts.push({
|
||||
metric,
|
||||
tick: metrics[i + 1]?.tick ?? 0,
|
||||
growthRate: growthRates[i],
|
||||
consecutiveSamples: expCount,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
expCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { stagnations, exponentialAlerts };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { GameState } from '@ai-tycoon/shared';
|
||||
import type { SimulationMetrics } from '../strategies/types';
|
||||
|
||||
export function collectMetrics(state: GameState): SimulationMetrics {
|
||||
const depts = state.talent.departments;
|
||||
const headcount = depts.research.headcount + depts.engineering.headcount
|
||||
+ depts.operations.headcount + depts.sales.headcount;
|
||||
|
||||
const activePipelines = state.models.activeTrainingPipelines.filter(
|
||||
p => p.status === 'active' || p.status === 'stalled',
|
||||
);
|
||||
let bestPipelineProgress = 0;
|
||||
for (const p of activePipelines) {
|
||||
const stage = p.stages[p.currentStage as keyof typeof p.stages];
|
||||
if (stage) {
|
||||
const progress = stage.totalTicks > 0 ? stage.progressTicks / stage.totalTicks : 0;
|
||||
if (progress > bestPipelineProgress) bestPipelineProgress = progress;
|
||||
}
|
||||
}
|
||||
|
||||
const ct = state.market.consumerTiers.tiers;
|
||||
const subscriptionRevenue =
|
||||
(ct.plus.userCount * ct.plus.config.price +
|
||||
ct.pro.userCount * ct.pro.config.price +
|
||||
ct.team.userCount * ct.team.config.price) / 86400;
|
||||
|
||||
let enterpriseRevenue = 0;
|
||||
for (const contract of state.market.enterprise.activeContracts) {
|
||||
enterpriseRevenue += (contract.tokensPerTick / 1_000_000) * contract.pricePerMToken;
|
||||
}
|
||||
|
||||
const apiTokenRevenue = Math.max(0, state.economy.revenuePerTick - subscriptionRevenue - enterpriseRevenue);
|
||||
|
||||
const activeConsumerTiers = Object.values(ct).filter(t => t.config.isActive).length;
|
||||
const activeApiTiers = Object.values(state.market.apiTiers.tiers).filter(t => t.config.isActive).length;
|
||||
|
||||
return {
|
||||
tick: state.meta.tickCount,
|
||||
era: state.meta.currentEra,
|
||||
money: state.economy.money,
|
||||
revenue: state.economy.revenuePerTick,
|
||||
totalRevenue: state.economy.totalRevenue,
|
||||
expensesPerTick: state.economy.expensesPerTick,
|
||||
bestModelCapability: state.models.bestDeployedModelScore,
|
||||
reputation: state.reputation.score,
|
||||
subscribers: state.market.consumerTiers.totalUsers,
|
||||
developers: state.market.apiTiers.totalDevelopers,
|
||||
totalFlops: state.infrastructure.totalFlops,
|
||||
totalTrainingFlops: state.infrastructure.totalTrainingFlops,
|
||||
researchCount: state.research.completedResearch.length,
|
||||
headcount,
|
||||
modelsDeployed: state.models.baseModels.filter(m => m.isDeployed).length,
|
||||
|
||||
safetyRecord: state.reputation.safetyRecord,
|
||||
publicPerception: state.reputation.publicPerception,
|
||||
employeeSatisfaction: state.reputation.employeeSatisfaction,
|
||||
regulatoryStanding: state.reputation.regulatoryStanding,
|
||||
|
||||
netCashFlow: state.economy.revenuePerTick - state.economy.expensesPerTick,
|
||||
|
||||
tokensPerSecondCapacity: state.compute.tokensPerSecondCapacity,
|
||||
tokensPerSecondDemand: state.compute.tokensPerSecondDemand,
|
||||
inferenceUtilization: state.compute.inferenceUtilization,
|
||||
|
||||
activeTrainingPipelines: activePipelines.length,
|
||||
bestPipelineProgress,
|
||||
|
||||
subscriptionRevenue,
|
||||
apiTokenRevenue,
|
||||
enterpriseRevenue,
|
||||
|
||||
researchHeadcount: depts.research.headcount,
|
||||
engineeringHeadcount: depts.engineering.headcount,
|
||||
operationsHeadcount: depts.operations.headcount,
|
||||
salesHeadcount: depts.sales.headcount,
|
||||
|
||||
completedResearchIds: [...state.research.completedResearch],
|
||||
activeConsumerTiers,
|
||||
activeApiTiers,
|
||||
enterpriseContracts: state.market.enterprise.activeContracts.length,
|
||||
fundingRoundsCompleted: state.economy.funding.completedRounds.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SimulationMetrics } from '../strategies/types';
|
||||
import type { TickNotification } from '@ai-tycoon/game-engine';
|
||||
|
||||
export interface Milestone {
|
||||
name: string;
|
||||
tick: number;
|
||||
}
|
||||
|
||||
export function extractMilestones(
|
||||
metrics: SimulationMetrics[],
|
||||
notifications: TickNotification[],
|
||||
): Milestone[] {
|
||||
const milestones: Milestone[] = [];
|
||||
const found = new Set<string>();
|
||||
|
||||
function add(name: string, tick: number) {
|
||||
if (!found.has(name)) {
|
||||
found.add(name);
|
||||
milestones.push({ name, tick });
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of metrics) {
|
||||
if (m.bestModelCapability > 0) add('First model trained', m.tick);
|
||||
if (m.revenue > 0) add('First revenue', m.tick);
|
||||
if (m.money >= 1_000_000) add('$1M cash', m.tick);
|
||||
if (m.subscribers >= 100) add('100 subscribers', m.tick);
|
||||
if (m.subscribers >= 1000) add('1,000 subscribers', m.tick);
|
||||
if (m.subscribers >= 10_000) add('10,000 subscribers', m.tick);
|
||||
if (m.totalRevenue >= 1_000_000) add('$1M total revenue', m.tick);
|
||||
if (m.totalRevenue >= 10_000_000) add('$10M total revenue', m.tick);
|
||||
if (m.totalRevenue >= 100_000_000) add('$100M total revenue', m.tick);
|
||||
if (m.developers >= 100) add('100 API developers', m.tick);
|
||||
if (m.developers >= 1000) add('1,000 API developers', m.tick);
|
||||
}
|
||||
|
||||
for (const n of notifications) {
|
||||
if (n.title === 'Achievement Unlocked!' && n.message.includes('acqui')) {
|
||||
add('First acquisition', 0);
|
||||
}
|
||||
}
|
||||
|
||||
return milestones;
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import type { SimulationResult, SimulationConfig } from '../runner';
|
||||
import { detectDeadZones, getCheapestSkuCost } from './deadZones';
|
||||
import type { DeadZone } from './deadZones';
|
||||
import { detectBreakpoints } from './breakpoints';
|
||||
import type { RevenueBreakpoint } from './breakpoints';
|
||||
import { extractMilestones } from './milestones';
|
||||
import type { Milestone } from './milestones';
|
||||
import { analyzeEraProximity } from './eraProximity';
|
||||
import type { EraProximityResult } from './eraProximity';
|
||||
import { analyzeCashFlow } from './cashFlow';
|
||||
import type { CashFlowResult } from './cashFlow';
|
||||
import { analyzeGrowthRates } from './growthRates';
|
||||
import type { GrowthRateResult } from './growthRates';
|
||||
import { runSanityChecks } from './sanityChecks';
|
||||
import type { SanityCheckResult } from './sanityChecks';
|
||||
import { analyzeFeatureUtilization } from './featureUtilization';
|
||||
import type { FeatureUtilizationResult } from './featureUtilization';
|
||||
import { analyzeSystemInterconnections } from './systemInterconnections';
|
||||
import type { InterconnectionResult } from './systemInterconnections';
|
||||
import type { SimulationMetrics } from '../strategies/types';
|
||||
|
||||
function formatDuration(ticks: number): string {
|
||||
const totalMinutes = Math.floor(ticks / 60);
|
||||
if (totalMinutes < 60) return `${totalMinutes} min`;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const mins = totalMinutes % 60;
|
||||
return mins > 0 ? `${hours} hr ${mins} min` : `${hours} hr`;
|
||||
}
|
||||
|
||||
function formatEraName(era: string): string {
|
||||
switch (era) {
|
||||
case 'startup': return 'Startup';
|
||||
case 'scaleup': return 'Scale-up';
|
||||
case 'bigtech': return 'Big Tech';
|
||||
case 'agi': return 'AGI';
|
||||
default: return era;
|
||||
}
|
||||
}
|
||||
|
||||
function pad(s: string, width: number): string {
|
||||
return s.padEnd(width);
|
||||
}
|
||||
|
||||
function fmtMoney(n: number): string {
|
||||
if (Math.abs(n) >= 1e9) return `$${(n / 1e9).toFixed(1)}B`;
|
||||
if (Math.abs(n) >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;
|
||||
if (Math.abs(n) >= 1e3) return `$${(n / 1e3).toFixed(1)}K`;
|
||||
return `$${n.toFixed(0)}`;
|
||||
}
|
||||
|
||||
interface PerEraSummary {
|
||||
era: string;
|
||||
enteredAtTick: number;
|
||||
exitedAtTick: number | null;
|
||||
durationTicks: number;
|
||||
bottleneckAtExit: string | null;
|
||||
}
|
||||
|
||||
function buildPerEraSummary(result: SimulationResult, eraProximity: EraProximityResult): PerEraSummary[] {
|
||||
const summaries: PerEraSummary[] = [];
|
||||
const transitions = result.eraTransitions;
|
||||
|
||||
const eras: { era: string; enteredAt: number }[] = [{ era: 'startup', enteredAt: 0 }];
|
||||
for (const t of transitions) {
|
||||
eras.push({ era: t.to, enteredAt: t.tick });
|
||||
}
|
||||
|
||||
for (let i = 0; i < eras.length; i++) {
|
||||
const exitTick = i < eras.length - 1 ? eras[i + 1].enteredAt : null;
|
||||
const duration = exitTick !== null ? exitTick - eras[i].enteredAt : (result.metrics[result.metrics.length - 1]?.tick ?? 0) - eras[i].enteredAt;
|
||||
summaries.push({
|
||||
era: eras[i].era,
|
||||
enteredAtTick: eras[i].enteredAt,
|
||||
exitedAtTick: exitTick,
|
||||
durationTicks: duration,
|
||||
bottleneckAtExit: eraProximity.perEraBottleneck[getNextEra(eras[i].era) ?? ''] ?? null,
|
||||
});
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
function getNextEra(era: string): string | null {
|
||||
const order = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
const idx = order.indexOf(era);
|
||||
return idx >= 0 && idx < order.length - 1 ? order[idx + 1] : null;
|
||||
}
|
||||
|
||||
export function printConsoleReport(result: SimulationResult, config: SimulationConfig, verbose = false): void {
|
||||
const { metrics, eraTransitions, notifications, finalState } = result;
|
||||
const cheapestSku = getCheapestSkuCost(finalState);
|
||||
const deadZones = detectDeadZones(metrics, cheapestSku);
|
||||
const breakpoints = detectBreakpoints(metrics, notifications);
|
||||
const milestones = extractMilestones(metrics, notifications);
|
||||
const eraProximity = analyzeEraProximity(metrics);
|
||||
const cashFlow = analyzeCashFlow(metrics);
|
||||
const growthRates = analyzeGrowthRates(metrics);
|
||||
const sanityChecks = runSanityChecks(metrics);
|
||||
const featureUtil = analyzeFeatureUtilization(finalState);
|
||||
const interconnections = analyzeSystemInterconnections(metrics, notifications);
|
||||
const perEraSummary = buildPerEraSummary(result, eraProximity);
|
||||
|
||||
console.log('');
|
||||
console.log('=== AI Tycoon Balance Simulation ===');
|
||||
console.log(`Strategy: ${config.strategy.name} | Ticks: ${config.totalTicks.toLocaleString()} | Decision interval: ${config.decisionInterval}`);
|
||||
console.log(`Wall time: ${(result.wallTimeMs / 1000).toFixed(1)}s${config.seed !== undefined ? ` | Seed: ${config.seed}` : ''}`);
|
||||
console.log('');
|
||||
|
||||
// --- Era Transitions ---
|
||||
console.log('Era Transitions:');
|
||||
if (eraTransitions.length === 0) {
|
||||
console.log(' (none — stuck in Startup)');
|
||||
} else {
|
||||
for (const t of eraTransitions) {
|
||||
const label = `${formatEraName(t.from)} -> ${formatEraName(t.to)}`;
|
||||
console.log(` ${pad(label, 24)} tick ${t.tick.toLocaleString().padStart(7)} (${formatDuration(t.tick)})`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// --- Per-Era Summary ---
|
||||
console.log('Per-Era Summary:');
|
||||
for (const es of perEraSummary) {
|
||||
const dur = formatDuration(es.durationTicks);
|
||||
const bottleneck = es.bottleneckAtExit ? ` | bottleneck: ${es.bottleneckAtExit}` : '';
|
||||
const status = es.exitedAtTick === null ? ' (current)' : '';
|
||||
console.log(` ${pad(formatEraName(es.era), 12)} ${dur.padStart(10)}${bottleneck}${status}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// --- Key Milestones ---
|
||||
console.log('Key Milestones:');
|
||||
if (milestones.length === 0) {
|
||||
console.log(' (none)');
|
||||
} else {
|
||||
for (const m of milestones) {
|
||||
console.log(` ${pad(m.name, 24)} tick ${m.tick.toLocaleString().padStart(7)} (${formatDuration(m.tick)})`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// --- Final State ---
|
||||
console.log('Final State:');
|
||||
const fm = metrics[metrics.length - 1];
|
||||
if (fm) {
|
||||
console.log(` Era: ${formatEraName(fm.era)} | Cash: ${fmtMoney(fm.money)}`);
|
||||
console.log(` Revenue/tick: ${fmtMoney(fm.revenue)} | Total Revenue: ${fmtMoney(fm.totalRevenue)}`);
|
||||
console.log(` Best Model: ${fm.bestModelCapability.toFixed(1)}/100 | Reputation: ${fm.reputation.toFixed(1)}`);
|
||||
console.log(` Subscribers: ${fm.subscribers.toLocaleString()} | API Devs: ${fm.developers.toLocaleString()}`);
|
||||
console.log(` Headcount: ${fm.headcount} | Research: ${fm.researchCount} completed`);
|
||||
console.log(` Total FLOPS: ${fm.totalFlops.toLocaleString()} | Models Deployed: ${fm.modelsDeployed}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// --- Reputation Breakdown ---
|
||||
if (fm) {
|
||||
console.log('Reputation Breakdown:');
|
||||
const comps = [
|
||||
{ name: 'Safety Record', value: fm.safetyRecord, weight: 0.3 },
|
||||
{ name: 'Public Perception', value: fm.publicPerception, weight: 0.3 },
|
||||
{ name: 'Employee Satisfaction', value: fm.employeeSatisfaction, weight: 0.2 },
|
||||
{ name: 'Regulatory Standing', value: fm.regulatoryStanding, weight: 0.2 },
|
||||
];
|
||||
const lowest = comps.reduce((a, b) => a.value < b.value ? a : b);
|
||||
for (const c of comps) {
|
||||
const marker = c === lowest ? ' <-- lowest' : '';
|
||||
console.log(` ${pad(c.name, 24)} ${c.value.toFixed(1).padStart(6)} (x${c.weight})${marker}`);
|
||||
}
|
||||
console.log(` ${pad('Weighted Score', 24)} ${fm.reputation.toFixed(1).padStart(6)}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// --- Cash Flow Summary ---
|
||||
console.log('Cash Flow:');
|
||||
console.log(` Peak cash: ${fmtMoney(cashFlow.peakCash.amount)} at tick ${cashFlow.peakCash.tick.toLocaleString()}`);
|
||||
console.log(` Min cash: ${fmtMoney(cashFlow.minCash.amount)} at tick ${cashFlow.minCash.tick.toLocaleString()}`);
|
||||
if (cashFlow.negativePeriods.length > 0) {
|
||||
console.log(` Negative flow periods: ${cashFlow.negativePeriods.length}`);
|
||||
for (const np of cashFlow.negativePeriods.slice(0, 3)) {
|
||||
console.log(` ticks ${np.startTick.toLocaleString()}-${np.endTick.toLocaleString()} (${formatDuration(np.durationTicks)}) avg burn: ${fmtMoney(np.averageBurnRate)}/tick`);
|
||||
}
|
||||
if (cashFlow.negativePeriods.length > 3) console.log(` ... and ${cashFlow.negativePeriods.length - 3} more`);
|
||||
}
|
||||
if (cashFlow.bankruptcyRisks.length > 0) {
|
||||
console.log(` [!] Bankruptcy risks: ${cashFlow.bankruptcyRisks.length}`);
|
||||
for (const br of cashFlow.bankruptcyRisks.slice(0, 3)) {
|
||||
console.log(` tick ${br.tick.toLocaleString()}: ${br.ticksToZero} ticks to zero, ${br.hasRevenueStream ? 'has revenue' : 'NO revenue'}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// --- Feature Utilization ---
|
||||
console.log('Feature Utilization:');
|
||||
for (const [cat, stats] of Object.entries(featureUtil.coverageByCategory)) {
|
||||
const bar = '#'.repeat(Math.round(stats.percent / 5)) + '-'.repeat(20 - Math.round(stats.percent / 5));
|
||||
console.log(` ${pad(cat, 16)} [${bar}] ${stats.used}/${stats.available} (${stats.percent}%)`);
|
||||
}
|
||||
console.log(` Revenue streams: ${featureUtil.revenueStreamDiversity} active`);
|
||||
if (featureUtil.unusedFeatures.length > 0) {
|
||||
console.log(` Unused but available (${featureUtil.unusedFeatures.length}):`);
|
||||
for (const f of featureUtil.unusedFeatures.slice(0, 10)) {
|
||||
console.log(` - ${f}`);
|
||||
}
|
||||
if (featureUtil.unusedFeatures.length > 10) console.log(` ... and ${featureUtil.unusedFeatures.length - 10} more`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// --- System Interconnections ---
|
||||
console.log(`System Interconnections (overall: ${interconnections.overallScore.toFixed(1)}/10):`);
|
||||
for (const c of interconnections.connections) {
|
||||
const bar = '#'.repeat(c.score) + '-'.repeat(10 - c.score);
|
||||
console.log(` ${pad(`${c.from} -> ${c.to}`, 30)} [${bar}] ${c.score}/10 (${c.events} events)`);
|
||||
}
|
||||
if (interconnections.deadLinks.length > 0) {
|
||||
console.log(` [!] Dead links (no observed effect):`);
|
||||
for (const d of interconnections.deadLinks) {
|
||||
console.log(` ${d.from} -> ${d.to}: ${d.evidence}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// --- Balance Warnings ---
|
||||
const hasWarnings = deadZones.length > 0 || breakpoints.length > 0 || !sanityChecks.passed
|
||||
|| eraProximity.ceilings.length > 0 || growthRates.stagnations.length > 0 || cashFlow.bankruptcyRisks.length > 0;
|
||||
|
||||
if (hasWarnings) {
|
||||
console.log('Diagnostics:');
|
||||
for (const dz of deadZones) {
|
||||
console.log(` [!] Dead zone: ticks ${dz.startTick.toLocaleString()}-${dz.endTick.toLocaleString()} (${formatDuration(dz.durationTicks)}) -- ${dz.description}`);
|
||||
}
|
||||
for (const bp of breakpoints) {
|
||||
console.log(` [!] Breakpoint: tick ${bp.tick.toLocaleString()} -- revenue jumped ${bp.percentIncrease.toFixed(0)}% after "${bp.possibleCause}"`);
|
||||
}
|
||||
for (const c of eraProximity.ceilings) {
|
||||
console.log(` [!] Ceiling: ${c.metric} stuck at ${c.stuckAtValue.toFixed(1)} for ${formatDuration(c.stuckDurationTicks)} (${c.era} needs ${c.requiredValue})`);
|
||||
}
|
||||
for (const mc of eraProximity.mathCeilings) {
|
||||
console.log(` [!] Math ceiling: theoretical max reputation = ${mc.theoreticalMax}, ${mc.era} needs ${mc.required}`);
|
||||
}
|
||||
for (const s of growthRates.stagnations) {
|
||||
console.log(` [!] Stagnation: ${s.metric} flat at ${s.stuckValue.toFixed(1)} for ${formatDuration(s.durationTicks)} (${formatEraName(s.era)})`);
|
||||
}
|
||||
for (const v of sanityChecks.violations) {
|
||||
console.log(` [${v.severity === 'error' ? '!!' : '!'}] ${v.check}: ${v.message} (tick ${v.tick.toLocaleString()})`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
if (eraProximity.snapshots.length > 0) {
|
||||
console.log('Era Proximity Timeline:');
|
||||
for (const s of eraProximity.snapshots.filter((_, i) => i % 10 === 0)) {
|
||||
const parts = s.thresholds.map(t => `${t.metric}: ${t.percentComplete.toFixed(0)}%`).join(', ');
|
||||
console.log(` tick ${s.tick.toLocaleString().padStart(7)} -> ${formatEraName(s.targetEra)}: ${parts} | bottleneck: ${s.bottleneck}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (growthRates.exponentialAlerts.length > 0) {
|
||||
console.log('Exponential Growth Alerts:');
|
||||
for (const ea of growthRates.exponentialAlerts) {
|
||||
console.log(` ${ea.metric} at tick ${ea.tick.toLocaleString()}: ${(ea.growthRate * 100).toFixed(1)}% per interval (${ea.consecutiveSamples} consecutive)`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface BalanceReport {
|
||||
strategy: string;
|
||||
totalTicks: number;
|
||||
seed: number | null;
|
||||
wallTimeMs: number;
|
||||
eraTransitions: Array<{ from: string; to: string; tick: number; wallTime: string }>;
|
||||
milestones: Array<{ name: string; tick: number; wallTime: string }>;
|
||||
deadZones: DeadZone[];
|
||||
breakpoints: RevenueBreakpoint[];
|
||||
finalMetrics: SimulationMetrics | null;
|
||||
passed: boolean;
|
||||
failureReasons: string[];
|
||||
eraProximity: EraProximityResult;
|
||||
cashFlow: CashFlowResult;
|
||||
growthRates: GrowthRateResult;
|
||||
sanityChecks: SanityCheckResult;
|
||||
featureUtilization: FeatureUtilizationResult;
|
||||
systemInterconnections: InterconnectionResult;
|
||||
perEraSummary: PerEraSummary[];
|
||||
}
|
||||
|
||||
const ERA_PACING: Record<string, { max: number }> = {
|
||||
startup: { max: 5_000 },
|
||||
scaleup: { max: 12_000 },
|
||||
bigtech: { max: 18_000 },
|
||||
};
|
||||
|
||||
export function generateJsonReport(result: SimulationResult, config: SimulationConfig): BalanceReport {
|
||||
const { metrics, eraTransitions, notifications, finalState } = result;
|
||||
const cheapestSku = getCheapestSkuCost(finalState);
|
||||
const deadZones = detectDeadZones(metrics, cheapestSku);
|
||||
const breakpoints = detectBreakpoints(metrics, notifications);
|
||||
const milestones = extractMilestones(metrics, notifications);
|
||||
const eraProximity = analyzeEraProximity(metrics);
|
||||
const cashFlow = analyzeCashFlow(metrics);
|
||||
const growthRates = analyzeGrowthRates(metrics);
|
||||
const sanityChecks = runSanityChecks(metrics);
|
||||
const featureUtil = analyzeFeatureUtilization(finalState);
|
||||
const interconnections = analyzeSystemInterconnections(metrics, notifications);
|
||||
const perEraSummary = buildPerEraSummary(result, eraProximity);
|
||||
|
||||
const failures: string[] = [];
|
||||
|
||||
const reachedAgi = eraTransitions.some(t => t.to === 'agi');
|
||||
if (!reachedAgi) {
|
||||
failures.push(`AGI era not reached within ${config.totalTicks} ticks`);
|
||||
}
|
||||
|
||||
const longDeadZones = deadZones.filter(dz => dz.durationTicks > 1800);
|
||||
for (const dz of longDeadZones) {
|
||||
failures.push(`Dead zone of ${dz.durationTicks} ticks (${formatDuration(dz.durationTicks)}) at tick ${dz.startTick}`);
|
||||
}
|
||||
|
||||
const extremeBreakpoints = breakpoints.filter(bp => bp.percentIncrease > 500);
|
||||
for (const bp of extremeBreakpoints) {
|
||||
failures.push(`Revenue breakpoint of ${bp.percentIncrease.toFixed(0)}% at tick ${bp.tick}`);
|
||||
}
|
||||
|
||||
// Era pacing checks
|
||||
let prevTick = 0;
|
||||
for (const t of eraTransitions) {
|
||||
const duration = t.tick - prevTick;
|
||||
const bounds = ERA_PACING[t.from];
|
||||
if (bounds && duration > bounds.max) {
|
||||
failures.push(`${formatEraName(t.from)} era too long: ${duration} ticks (max ${bounds.max})`);
|
||||
}
|
||||
prevTick = t.tick;
|
||||
}
|
||||
|
||||
// Sanity check errors
|
||||
if (!sanityChecks.passed) {
|
||||
const errors = sanityChecks.violations.filter(v => v.severity === 'error');
|
||||
for (const e of errors) {
|
||||
failures.push(`Sanity: ${e.check} at tick ${e.tick}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Bankruptcy risk
|
||||
for (const risk of cashFlow.bankruptcyRisks) {
|
||||
if (!risk.hasRevenueStream) {
|
||||
failures.push(`Bankruptcy risk at tick ${risk.tick}: ${risk.ticksToZero} ticks to zero, no revenue`);
|
||||
}
|
||||
}
|
||||
|
||||
// Ceiling detection — only fail if the era was never actually reached
|
||||
const reachedEras = new Set(eraTransitions.map(t => t.to));
|
||||
for (const ceiling of eraProximity.ceilings) {
|
||||
if (!reachedEras.has(ceiling.era)) {
|
||||
failures.push(`${ceiling.metric} ceiling for ${ceiling.era}: stuck at ${ceiling.stuckAtValue.toFixed(1)} since tick ${ceiling.stuckSinceTick} (need ${ceiling.requiredValue})`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
strategy: config.strategy.name,
|
||||
totalTicks: config.totalTicks,
|
||||
seed: config.seed ?? null,
|
||||
wallTimeMs: result.wallTimeMs,
|
||||
eraTransitions: eraTransitions.map(t => ({
|
||||
from: t.from, to: t.to, tick: t.tick, wallTime: formatDuration(t.tick),
|
||||
})),
|
||||
milestones: milestones.map(m => ({
|
||||
name: m.name, tick: m.tick, wallTime: formatDuration(m.tick),
|
||||
})),
|
||||
deadZones,
|
||||
breakpoints,
|
||||
finalMetrics: metrics.length > 0 ? metrics[metrics.length - 1] : null,
|
||||
passed: failures.length === 0,
|
||||
failureReasons: failures,
|
||||
eraProximity,
|
||||
cashFlow,
|
||||
growthRates,
|
||||
sanityChecks,
|
||||
featureUtilization: featureUtil,
|
||||
systemInterconnections: interconnections,
|
||||
perEraSummary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { SimulationMetrics } from '../strategies/types';
|
||||
import { ERA_THRESHOLDS } from '@ai-tycoon/shared';
|
||||
|
||||
export type SanityCheckSeverity = 'error' | 'warning';
|
||||
|
||||
export interface SanityCheckViolation {
|
||||
tick: number;
|
||||
check: string;
|
||||
message: string;
|
||||
severity: SanityCheckSeverity;
|
||||
actual: number;
|
||||
expected: string;
|
||||
}
|
||||
|
||||
export interface SanityCheckResult {
|
||||
violations: SanityCheckViolation[];
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
const ERA_ORDER = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
|
||||
function nextEra(current: string): string | null {
|
||||
const idx = ERA_ORDER.indexOf(current);
|
||||
return idx >= 0 && idx < ERA_ORDER.length - 1 ? ERA_ORDER[idx + 1] : null;
|
||||
}
|
||||
|
||||
export function runSanityChecks(metrics: SimulationMetrics[]): SanityCheckResult {
|
||||
const violations: SanityCheckViolation[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const m of metrics) {
|
||||
for (const [name, value] of [
|
||||
['safetyRecord', m.safetyRecord],
|
||||
['publicPerception', m.publicPerception],
|
||||
['employeeSatisfaction', m.employeeSatisfaction],
|
||||
['regulatoryStanding', m.regulatoryStanding],
|
||||
] as [string, number][]) {
|
||||
if (value < 0 || value > 100) {
|
||||
const key = `reputation-range:${name}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
violations.push({
|
||||
tick: m.tick, check: 'reputation-range',
|
||||
message: `${name} = ${value.toFixed(2)}, expected 0-100`,
|
||||
severity: 'error', actual: value, expected: '0-100',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const components = [m.safetyRecord, m.publicPerception, m.employeeSatisfaction, m.regulatoryStanding];
|
||||
const aboveThreshold = components.filter(c => c > 10);
|
||||
const belowThreshold = components.filter(c => c < 1.0 && c > 0);
|
||||
if (aboveThreshold.length >= 2 && belowThreshold.length >= 1) {
|
||||
const key = 'reputation-scale-consistency';
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
const lowName = ['safetyRecord', 'publicPerception', 'employeeSatisfaction', 'regulatoryStanding']
|
||||
[components.indexOf(belowThreshold[0])];
|
||||
violations.push({
|
||||
tick: m.tick, check: key,
|
||||
message: `${lowName} = ${belowThreshold[0].toFixed(2)} while others are 10+. Likely a scale mismatch (0-1 vs 0-100)`,
|
||||
severity: 'error', actual: belowThreshold[0], expected: '0-100 scale',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const computed = Math.round(
|
||||
m.safetyRecord * 0.3 + m.publicPerception * 0.3 +
|
||||
m.employeeSatisfaction * 0.2 + m.regulatoryStanding * 0.2,
|
||||
);
|
||||
if (Math.abs(computed - m.reputation) > 2) {
|
||||
const key = `reputation-formula:${m.tick}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
violations.push({
|
||||
tick: m.tick, check: 'reputation-formula-valid',
|
||||
message: `Computed reputation ${computed} != reported ${m.reputation.toFixed(1)} (components: SR=${m.safetyRecord.toFixed(1)}, PP=${m.publicPerception.toFixed(1)}, ES=${m.employeeSatisfaction.toFixed(1)}, RS=${m.regulatoryStanding.toFixed(1)})`,
|
||||
severity: 'error', actual: m.reputation, expected: `~${computed}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (m.money < 0 && !seen.has('money-non-negative')) {
|
||||
seen.add('money-non-negative');
|
||||
violations.push({
|
||||
tick: m.tick, check: 'money-non-negative',
|
||||
message: `Cash is negative: $${m.money.toFixed(0)}`,
|
||||
severity: 'warning', actual: m.money, expected: '>= 0',
|
||||
});
|
||||
}
|
||||
|
||||
if ((m.bestModelCapability < 0 || m.bestModelCapability > 100) && !seen.has('capability-range')) {
|
||||
seen.add('capability-range');
|
||||
violations.push({
|
||||
tick: m.tick, check: 'capability-range',
|
||||
message: `Model capability ${m.bestModelCapability} outside 0-100`,
|
||||
severity: 'error', actual: m.bestModelCapability, expected: '0-100',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const last = metrics[metrics.length - 1];
|
||||
if (last) {
|
||||
const target = nextEra(last.era);
|
||||
if (target) {
|
||||
const thresholds = ERA_THRESHOLDS[target as keyof typeof ERA_THRESHOLDS];
|
||||
if (thresholds) {
|
||||
const maxSafety = 80;
|
||||
const maxPublic = 100;
|
||||
const maxEmployee = 100;
|
||||
const maxRegulatory = 100;
|
||||
const theoreticalMax = Math.round(
|
||||
maxSafety * 0.3 + maxPublic * 0.3 + maxEmployee * 0.2 + maxRegulatory * 0.2,
|
||||
);
|
||||
if (theoreticalMax < thresholds.reputation) {
|
||||
violations.push({
|
||||
tick: last.tick, check: 'mathematical-ceiling',
|
||||
message: `Theoretical max reputation is ${theoreticalMax} (SR_max=80×0.3 + PP_max=100×0.3 + ES_max=100×0.2 + RS_max=100×0.2) but ${target} era requires ${thresholds.reputation}`,
|
||||
severity: 'warning', actual: theoreticalMax, expected: `>= ${thresholds.reputation}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
violations,
|
||||
passed: violations.every(v => v.severity !== 'error'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import type { SimulationMetrics } from '../strategies/types';
|
||||
import type { TickNotification } from '@ai-tycoon/game-engine';
|
||||
|
||||
export interface SystemConnection {
|
||||
from: string;
|
||||
to: string;
|
||||
score: number;
|
||||
evidence: string;
|
||||
events: number;
|
||||
}
|
||||
|
||||
export interface InterconnectionResult {
|
||||
connections: SystemConnection[];
|
||||
overallScore: number;
|
||||
weakLinks: SystemConnection[];
|
||||
deadLinks: SystemConnection[];
|
||||
}
|
||||
|
||||
function findMetricAtTick(metrics: SimulationMetrics[], tick: number): SimulationMetrics | undefined {
|
||||
for (let i = metrics.length - 1; i >= 0; i--) {
|
||||
if (metrics[i].tick <= tick) return metrics[i];
|
||||
}
|
||||
return metrics[0];
|
||||
}
|
||||
|
||||
function findMetricAfterTick(metrics: SimulationMetrics[], tick: number, windowTicks: number): SimulationMetrics | undefined {
|
||||
const target = tick + windowTicks;
|
||||
for (const m of metrics) {
|
||||
if (m.tick >= target) return m;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function measureDelta(
|
||||
metrics: SimulationMetrics[],
|
||||
eventTicks: number[],
|
||||
getter: (m: SimulationMetrics) => number,
|
||||
windowTicks = 300,
|
||||
): { totalDelta: number; events: number } {
|
||||
let totalDelta = 0;
|
||||
let events = 0;
|
||||
for (const tick of eventTicks) {
|
||||
const before = findMetricAtTick(metrics, tick);
|
||||
const after = findMetricAfterTick(metrics, tick, windowTicks);
|
||||
if (before && after) {
|
||||
totalDelta += after[getter.name as keyof SimulationMetrics] !== undefined
|
||||
? getter(after) - getter(before)
|
||||
: 0;
|
||||
events++;
|
||||
}
|
||||
}
|
||||
return { totalDelta, events };
|
||||
}
|
||||
|
||||
function scoreFromDelta(totalDelta: number, events: number, scale: number): number {
|
||||
if (events === 0) return 0;
|
||||
const avgDelta = totalDelta / events;
|
||||
return Math.min(10, Math.max(0, Math.round((avgDelta / scale) * 10)));
|
||||
}
|
||||
|
||||
function detectResearchCompletionTicks(metrics: SimulationMetrics[]): number[] {
|
||||
const ticks: number[] = [];
|
||||
for (let i = 1; i < metrics.length; i++) {
|
||||
if (metrics[i].researchCount > metrics[i - 1].researchCount) {
|
||||
ticks.push(metrics[i].tick);
|
||||
}
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function detectModelDeploymentTicks(metrics: SimulationMetrics[]): number[] {
|
||||
const ticks: number[] = [];
|
||||
for (let i = 1; i < metrics.length; i++) {
|
||||
if (metrics[i].modelsDeployed > metrics[i - 1].modelsDeployed) {
|
||||
ticks.push(metrics[i].tick);
|
||||
}
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function detectFundingTicks(metrics: SimulationMetrics[]): number[] {
|
||||
const ticks: number[] = [];
|
||||
for (let i = 1; i < metrics.length; i++) {
|
||||
if (metrics[i].fundingRoundsCompleted > metrics[i - 1].fundingRoundsCompleted) {
|
||||
ticks.push(metrics[i].tick);
|
||||
}
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function detectHiringSpikes(metrics: SimulationMetrics[]): number[] {
|
||||
const ticks: number[] = [];
|
||||
for (let i = 1; i < metrics.length; i++) {
|
||||
if (metrics[i].headcount - metrics[i - 1].headcount >= 3) {
|
||||
ticks.push(metrics[i].tick);
|
||||
}
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function detectComputeGrowthTicks(metrics: SimulationMetrics[]): number[] {
|
||||
const ticks: number[] = [];
|
||||
for (let i = 1; i < metrics.length; i++) {
|
||||
const prev = metrics[i - 1].totalFlops;
|
||||
const curr = metrics[i].totalFlops;
|
||||
if (prev > 0 && (curr - prev) / prev > 0.1) {
|
||||
ticks.push(metrics[i].tick);
|
||||
}
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export function analyzeSystemInterconnections(
|
||||
metrics: SimulationMetrics[],
|
||||
_notifications: TickNotification[],
|
||||
): InterconnectionResult {
|
||||
const connections: SystemConnection[] = [];
|
||||
|
||||
if (metrics.length < 10) {
|
||||
return { connections: [], overallScore: 0, weakLinks: [], deadLinks: [] };
|
||||
}
|
||||
|
||||
const researchTicks = detectResearchCompletionTicks(metrics);
|
||||
const deployTicks = detectModelDeploymentTicks(metrics);
|
||||
const fundingTicks = detectFundingTicks(metrics);
|
||||
const hiringTicks = detectHiringSpikes(metrics);
|
||||
const computeGrowthTicks = detectComputeGrowthTicks(metrics);
|
||||
|
||||
// Research → Capability
|
||||
{
|
||||
let totalDelta = 0;
|
||||
let events = 0;
|
||||
for (const tick of researchTicks) {
|
||||
const before = findMetricAtTick(metrics, tick);
|
||||
const after = findMetricAfterTick(metrics, tick, 600);
|
||||
if (before && after) {
|
||||
totalDelta += after.bestModelCapability - before.bestModelCapability;
|
||||
events++;
|
||||
}
|
||||
}
|
||||
const score = events > 0 ? scoreFromDelta(totalDelta, events, 5) : 0;
|
||||
connections.push({
|
||||
from: 'Research', to: 'Model Capability', score, events,
|
||||
evidence: events > 0
|
||||
? `${events} research completions, avg capability delta: ${(totalDelta / events).toFixed(1)}`
|
||||
: 'No research completions observed',
|
||||
});
|
||||
}
|
||||
|
||||
// Research → Infrastructure
|
||||
{
|
||||
let totalDelta = 0;
|
||||
let events = 0;
|
||||
for (const tick of researchTicks) {
|
||||
const before = findMetricAtTick(metrics, tick);
|
||||
const after = findMetricAfterTick(metrics, tick, 600);
|
||||
if (before && after) {
|
||||
totalDelta += after.totalFlops - before.totalFlops;
|
||||
events++;
|
||||
}
|
||||
}
|
||||
const score = events > 0 && totalDelta > 0 ? Math.min(10, Math.round((totalDelta / events / 100) * 10)) : 0;
|
||||
connections.push({
|
||||
from: 'Research', to: 'Infrastructure', score, events,
|
||||
evidence: events > 0
|
||||
? `${events} research completions, avg FLOPS delta: ${(totalDelta / events).toFixed(0)}`
|
||||
: 'No research completions observed',
|
||||
});
|
||||
}
|
||||
|
||||
// Talent → Training (hiring → more training pipelines or faster progress)
|
||||
{
|
||||
let totalDelta = 0;
|
||||
let events = 0;
|
||||
for (const tick of hiringTicks) {
|
||||
const before = findMetricAtTick(metrics, tick);
|
||||
const after = findMetricAfterTick(metrics, tick, 300);
|
||||
if (before && after) {
|
||||
totalDelta += after.bestModelCapability - before.bestModelCapability;
|
||||
events++;
|
||||
}
|
||||
}
|
||||
const score = events > 0 ? scoreFromDelta(totalDelta, events, 3) : 0;
|
||||
connections.push({
|
||||
from: 'Talent', to: 'Training', score, events,
|
||||
evidence: events > 0
|
||||
? `${events} hiring spikes, avg capability change: ${(totalDelta / events).toFixed(1)}`
|
||||
: 'No significant hiring events observed',
|
||||
});
|
||||
}
|
||||
|
||||
// Talent → Enterprise
|
||||
{
|
||||
let totalDelta = 0;
|
||||
let events = 0;
|
||||
for (const tick of hiringTicks) {
|
||||
const before = findMetricAtTick(metrics, tick);
|
||||
const after = findMetricAfterTick(metrics, tick, 600);
|
||||
if (before && after) {
|
||||
totalDelta += after.enterpriseContracts - before.enterpriseContracts;
|
||||
events++;
|
||||
}
|
||||
}
|
||||
const score = events > 0 && totalDelta > 0 ? Math.min(10, Math.round((totalDelta / events) * 5)) : 0;
|
||||
connections.push({
|
||||
from: 'Talent', to: 'Enterprise', score, events,
|
||||
evidence: events > 0
|
||||
? `${events} hiring spikes, avg enterprise contract delta: ${(totalDelta / events).toFixed(1)}`
|
||||
: 'No hiring events observed',
|
||||
});
|
||||
}
|
||||
|
||||
// Infrastructure → Revenue
|
||||
{
|
||||
let totalDelta = 0;
|
||||
let events = 0;
|
||||
for (const tick of computeGrowthTicks) {
|
||||
const before = findMetricAtTick(metrics, tick);
|
||||
const after = findMetricAfterTick(metrics, tick, 300);
|
||||
if (before && after && before.revenue > 0) {
|
||||
totalDelta += (after.revenue - before.revenue) / before.revenue;
|
||||
events++;
|
||||
}
|
||||
}
|
||||
const score = events > 0 ? Math.min(10, Math.max(0, Math.round((totalDelta / events) * 20))) : 0;
|
||||
connections.push({
|
||||
from: 'Infrastructure', to: 'Revenue', score, events,
|
||||
evidence: events > 0
|
||||
? `${events} compute growth events, avg revenue growth: ${((totalDelta / events) * 100).toFixed(1)}%`
|
||||
: 'No compute growth events observed',
|
||||
});
|
||||
}
|
||||
|
||||
// Models → Revenue
|
||||
{
|
||||
let totalDelta = 0;
|
||||
let events = 0;
|
||||
for (const tick of deployTicks) {
|
||||
const before = findMetricAtTick(metrics, tick);
|
||||
const after = findMetricAfterTick(metrics, tick, 300);
|
||||
if (before && after) {
|
||||
const revDelta = before.revenue > 0
|
||||
? (after.revenue - before.revenue) / before.revenue
|
||||
: (after.revenue > 0 ? 1 : 0);
|
||||
totalDelta += revDelta;
|
||||
events++;
|
||||
}
|
||||
}
|
||||
const score = events > 0 ? Math.min(10, Math.max(0, Math.round((totalDelta / events) * 10))) : 0;
|
||||
connections.push({
|
||||
from: 'Models', to: 'Revenue', score, events,
|
||||
evidence: events > 0
|
||||
? `${events} model deployments, avg revenue change: ${((totalDelta / events) * 100).toFixed(1)}%`
|
||||
: 'No model deployments observed',
|
||||
});
|
||||
}
|
||||
|
||||
// Models → Enterprise
|
||||
{
|
||||
let totalDelta = 0;
|
||||
let events = 0;
|
||||
for (const tick of deployTicks) {
|
||||
const before = findMetricAtTick(metrics, tick);
|
||||
const after = findMetricAfterTick(metrics, tick, 600);
|
||||
if (before && after) {
|
||||
totalDelta += after.enterpriseContracts - before.enterpriseContracts;
|
||||
events++;
|
||||
}
|
||||
}
|
||||
const score = events > 0 && totalDelta > 0 ? Math.min(10, Math.round((totalDelta / events) * 5)) : 0;
|
||||
connections.push({
|
||||
from: 'Models', to: 'Enterprise', score, events,
|
||||
evidence: events > 0
|
||||
? `${events} deployments, avg enterprise contract delta: ${(totalDelta / events).toFixed(1)}`
|
||||
: 'No model deployments observed',
|
||||
});
|
||||
}
|
||||
|
||||
// Reputation → Era gates
|
||||
{
|
||||
let score = 0;
|
||||
const eraChanges = [];
|
||||
for (let i = 1; i < metrics.length; i++) {
|
||||
if (metrics[i].era !== metrics[i - 1].era) {
|
||||
eraChanges.push({ tick: metrics[i].tick, repBefore: metrics[i - 1].reputation });
|
||||
}
|
||||
}
|
||||
if (eraChanges.length > 0) {
|
||||
let bindingCount = 0;
|
||||
for (const ec of eraChanges) {
|
||||
const before = findMetricAtTick(metrics, ec.tick - 120);
|
||||
if (before && (ec.repBefore - before.reputation) > 1) bindingCount++;
|
||||
}
|
||||
score = Math.min(10, Math.round((bindingCount / eraChanges.length) * 10));
|
||||
}
|
||||
connections.push({
|
||||
from: 'Reputation', to: 'Era Gates', score, events: eraChanges.length,
|
||||
evidence: eraChanges.length > 0
|
||||
? `${eraChanges.length} era transitions observed`
|
||||
: 'No era transitions observed',
|
||||
});
|
||||
}
|
||||
|
||||
// Funding → Growth
|
||||
{
|
||||
let totalDelta = 0;
|
||||
let events = 0;
|
||||
for (const tick of fundingTicks) {
|
||||
const before = findMetricAtTick(metrics, tick);
|
||||
const after = findMetricAfterTick(metrics, tick, 600);
|
||||
if (before && after) {
|
||||
const growthBefore = before.revenue;
|
||||
const growthAfter = after.revenue;
|
||||
totalDelta += growthBefore > 0
|
||||
? (growthAfter - growthBefore) / growthBefore
|
||||
: (growthAfter > 0 ? 1 : 0);
|
||||
events++;
|
||||
}
|
||||
}
|
||||
const score = events > 0 ? Math.min(10, Math.max(0, Math.round((totalDelta / events) * 10))) : 0;
|
||||
connections.push({
|
||||
from: 'Funding', to: 'Growth', score, events,
|
||||
evidence: events > 0
|
||||
? `${events} funding rounds, avg revenue growth: ${((totalDelta / events) * 100).toFixed(1)}%`
|
||||
: 'No funding rounds observed',
|
||||
});
|
||||
}
|
||||
|
||||
// Compute → Serving quality (utilization tracking)
|
||||
{
|
||||
let wellUtilizedCount = 0;
|
||||
let totalSamples = 0;
|
||||
for (const m of metrics) {
|
||||
if (m.tokensPerSecondCapacity > 0) {
|
||||
totalSamples++;
|
||||
const util = m.inferenceUtilization;
|
||||
if (util > 0.2 && util < 0.95) wellUtilizedCount++;
|
||||
}
|
||||
}
|
||||
const score = totalSamples > 0 ? Math.min(10, Math.round((wellUtilizedCount / totalSamples) * 10)) : 0;
|
||||
connections.push({
|
||||
from: 'Compute', to: 'Serving', score, events: totalSamples,
|
||||
evidence: totalSamples > 0
|
||||
? `${wellUtilizedCount}/${totalSamples} samples with healthy utilization (20-95%)`
|
||||
: 'No compute capacity observed',
|
||||
});
|
||||
}
|
||||
|
||||
const overallScore = connections.length > 0
|
||||
? Math.round(connections.reduce((sum, c) => sum + c.score, 0) / connections.length * 10) / 10
|
||||
: 0;
|
||||
|
||||
const weakLinks = connections.filter(c => c.score > 0 && c.score < 3);
|
||||
const deadLinks = connections.filter(c => c.score === 0);
|
||||
|
||||
return { connections, overallScore, weakLinks, deadLinks };
|
||||
}
|
||||
Reference in New Issue
Block a user