diff --git a/apps/web/src/components/common/ToastContainer.tsx b/apps/web/src/components/common/ToastContainer.tsx index 4643e28..e526724 100644 --- a/apps/web/src/components/common/ToastContainer.tsx +++ b/apps/web/src/components/common/ToastContainer.tsx @@ -61,6 +61,19 @@ export function ToastContainer() {
{toast.title}
{toast.message}
+ {toast.action && ( + + )}
))} @@ -247,6 +259,19 @@ export function ModelsPage() { : `ETA: ${formatDuration(stage.totalTicks - stage.progressTicks)}`} + {pipeline.currentStage === 'pretraining' && !pipeline.stages.sft && !pipeline.stages.alignment && ( +
+ + + Post-training not configured —{' '} + + {' '}or they'll be skipped. + +
+ )} + {isExpanded && (
{pipeline.currentStage === 'pretraining' && ( @@ -256,8 +281,8 @@ export function ModelsPage() {
)} - {!pipeline.stages.sft && pipeline.stages.pretraining.progressTicks < pipeline.stages.pretraining.totalTicks * 0.5 && ( - + {pipeline.currentStage === 'pretraining' && !pipeline.stages.pretraining.isComplete && (!pipeline.stages.sft || !pipeline.stages.alignment) && ( + )} {recentEvents.length > 0 && ( @@ -567,6 +592,14 @@ export function ModelsPage() { {/* Product Lines */} {modelsTab === 'products' &&

Product Lines

+ {noModelDeployed && ( +
+

No model deployed yet. Deploy a model to start earning revenue.

+ +
+ )} {productLines.map(pl => (
@@ -584,17 +617,28 @@ export function ModelsPage() { ))}
} - {/* Empty state for Models tab */} {modelsTab === 'models' && families.length === 0 && ( -
- No model families yet. Train your first model from the Train New tab. +
+

No model families yet. Train your first model to get started.

+
)} - {/* Empty state for Overview when nothing is active */} - {modelsTab === 'overview' && activePipelines.length === 0 && activeVariantJobs.length === 0 && activeEvalJobs.length === 0 && ( -
- No active jobs. Start a training pipeline from the Train New tab. + {modelsTab === 'overview' && !hasActiveJobs && ( +
+

No active training or evaluation jobs.

+
+ + {families.length > 0 && ( + + )} +
)}
@@ -1069,8 +1113,15 @@ function StageBar({ label, active, complete, progress, configured = true }: { }) { return (
-
{label}
-
+
+ {label}{!configured && ' (skip)'} +
+
{active && !complete && (
)} @@ -1079,12 +1130,14 @@ function StageBar({ label, active, complete, progress, configured = true }: { ); } -function PostTrainingConfig({ pipelineId, hasAlignmentResearch, completedResearch, configureSFT, configureAlignment }: { +function PostTrainingConfig({ pipelineId, hasAlignmentResearch, completedResearch, configureSFT, configureAlignment, sftConfigured, alignmentConfigured }: { pipelineId: string; hasAlignmentResearch: boolean; completedResearch: string[]; configureSFT: (pipelineId: string, specializations: SFTSpecialization[]) => void; configureAlignment: (pipelineId: string, method: AlignmentMethod, safetyWeight: number) => void; + sftConfigured: boolean; + alignmentConfigured: boolean; }) { const [selectedSpecs, setSelectedSpecs] = useState(['general']); const [alignMethod, setAlignMethod] = useState('rlhf'); @@ -1094,75 +1147,91 @@ function PostTrainingConfig({ pipelineId, hasAlignmentResearch, completedResearc
Configure Post-Training (optional)
-
-
- Supervised Fine-Tuning -
-
- {SFT_OPTIONS.map(opt => ( - - ))} -
- -
- - {hasAlignmentResearch && ( + {!sftConfigured ? (
- Alignment + Supervised Fine-Tuning
-
- {(Object.keys(ALIGNMENT_METHODS) as AlignmentMethod[]).map(method => { - const isAvailable = completedResearch.includes(ALIGNMENT_METHODS[method].requiredResearch); - return ( - - ); - })} -
-
- Safety - setSafetyWeight(Number(e.target.value) / 100)} - className="flex-1 accent-accent h-1" /> - Helpful +
+ {SFT_OPTIONS.map(opt => ( + + ))}
+ ) : ( +
+ SFT configured +
+ )} + + {!alignmentConfigured ? ( + hasAlignmentResearch ? ( +
+
+ Alignment +
+
+ {(Object.keys(ALIGNMENT_METHODS) as AlignmentMethod[]).map(method => { + const isAvailable = completedResearch.includes(ALIGNMENT_METHODS[method].requiredResearch); + return ( + + ); + })} +
+
+ Safety + setSafetyWeight(Number(e.target.value) / 100)} + className="flex-1 accent-accent h-1" /> + Helpful +
+ +
+ ) : ( +
+ Alignment requires research +
+ ) + ) : ( +
+ Alignment configured +
)}
); diff --git a/apps/web/src/store/index.ts b/apps/web/src/store/index.ts index 0e96482..5fb7690 100644 --- a/apps/web/src/store/index.ts +++ b/apps/web/src/store/index.ts @@ -58,10 +58,13 @@ export interface InfraNav { datacenterId?: string; } +type ModelsTab = 'overview' | 'train' | 'models' | 'benchmarks' | 'products'; + interface UIState { activePage: ActivePage; notifications: GameNotification[]; infraNav: InfraNav; + modelsTab: ModelsTab; } export interface GameNotification { @@ -71,6 +74,7 @@ export interface GameNotification { type: 'info' | 'success' | 'warning' | 'danger'; tick: number; read: boolean; + action?: { label: string; page?: ActivePage; modelsTab?: ModelsTab }; } function emptyDC(): Pick { @@ -86,6 +90,7 @@ function emptyDC(): Pick void; setInfraNav: (nav: InfraNav) => void; + setModelsTab: (tab: ModelsTab) => void; addNotification: (n: Omit) => void; dismissNotification: (id: string) => void; removeNotification: (id: string) => void; @@ -289,11 +294,14 @@ export const useGameStore = create()( activePage: 'dashboard' as ActivePage, notifications: [], infraNav: { level: 'clusters' } as InfraNav, + modelsTab: 'overview' as ModelsTab, setActivePage: (page) => set({ activePage: page }), setInfraNav: (nav) => set({ infraNav: nav }), + setModelsTab: (tab) => set({ modelsTab: tab }), + addNotification: (n) => set((s) => ({ notifications: [ { ...n, id: uuid(), read: false }, @@ -907,197 +915,252 @@ export const useGameStore = create()( // --- Non-infrastructure actions (unchanged) --- - startTrainingPipeline: (config) => set((s) => { - const activeCount = s.models.activeTrainingPipelines.filter(p => p.status === 'active' || p.status === 'stalled').length; - const maxSlots = MAX_CONCURRENT_TRAINING[s.meta.currentEra] ?? 1; - if (activeCount >= maxSlots) return s; + startTrainingPipeline: (config) => { + let created = false; + set((s) => { + const activeCount = s.models.activeTrainingPipelines.filter(p => p.status === 'active' || p.status === 'stalled').length; + const maxSlots = MAX_CONCURRENT_TRAINING[s.meta.currentEra] ?? 1; + if (activeCount >= maxSlots) return s; - const familyId = uuid(); - const pipelineId = uuid(); - const generation = s.models.families.length + 1; + created = true; + const familyId = uuid(); + const pipelineId = uuid(); + const generation = s.models.families.length + 1; - const family: ModelFamily = { - id: familyId, - name: config.modelName, - generation, - baseModelId: null, - variants: [], - createdAtTick: s.meta.tickCount, - }; + const family: ModelFamily = { + id: familyId, + name: config.modelName, + generation, + baseModelId: null, + variants: [], + createdAtTick: s.meta.tickCount, + }; - const pipeline: TrainingPipeline = { - id: pipelineId, - familyId, - modelName: config.modelName, - architecture: config.architecture, - dataMix: config.dataMix, - currentStage: 'pretraining', - stages: { - pretraining: { - targetTokens: config.targetTokens, - processedTokens: 0, - computeAllocated: 0, - progressTicks: 0, - totalTicks: config.totalTicks, - lossValue: 10, - chinchillaRatio: config.targetTokens / (config.architecture.totalParameters * 1e9), - isComplete: false, + const pipeline: TrainingPipeline = { + id: pipelineId, + familyId, + modelName: config.modelName, + architecture: config.architecture, + dataMix: config.dataMix, + currentStage: 'pretraining', + stages: { + pretraining: { + targetTokens: config.targetTokens, + processedTokens: 0, + computeAllocated: 0, + progressTicks: 0, + totalTicks: config.totalTicks, + lossValue: 10, + chinchillaRatio: config.targetTokens / (config.architecture.totalParameters * 1e9), + isComplete: false, + }, + sft: null, + alignment: null, }, - sft: null, - alignment: null, - }, - status: 'active', - allocatedComputeFraction: config.allocatedComputeFraction, - events: [], - startedAtTick: s.meta.tickCount, - }; + status: 'active', + allocatedComputeFraction: config.allocatedComputeFraction, + events: [], + startedAtTick: s.meta.tickCount, + }; - return { + return { + models: { + ...s.models, + families: [...s.models.families, family], + activeTrainingPipelines: [...s.models.activeTrainingPipelines, pipeline], + }, + }; + }); + if (created) { + get().addNotification({ title: 'Training Started', message: `${config.modelName} pre-training has begun.`, type: 'info', tick: get().meta.tickCount }); + set({ modelsTab: 'overview' as ModelsTab }); + } + }, + + configureSFT: (pipelineId, specializations) => { + set((s) => ({ models: { ...s.models, - families: [...s.models.families, family], - activeTrainingPipelines: [...s.models.activeTrainingPipelines, pipeline], + activeTrainingPipelines: s.models.activeTrainingPipelines.map(p => + p.id === pipelineId ? { + ...p, + stages: { + ...p.stages, + sft: { + specializations, + progressTicks: 0, + totalTicks: Math.ceil(p.stages.pretraining.totalTicks * 0.10), + isComplete: false, + }, + }, + } : p, + ), }, - }; - }), + })); + get().addNotification({ title: 'SFT Configured', message: `${specializations.join(', ')} specializations enabled.`, type: 'success', tick: get().meta.tickCount }); + }, - configureSFT: (pipelineId, specializations) => set((s) => ({ - models: { - ...s.models, - activeTrainingPipelines: s.models.activeTrainingPipelines.map(p => - p.id === pipelineId ? { - ...p, - stages: { - ...p.stages, - sft: { - specializations, - progressTicks: 0, - totalTicks: Math.ceil(p.stages.pretraining.totalTicks * 0.10), - isComplete: false, + configureAlignment: (pipelineId, method, safetyWeight) => { + set((s) => ({ + models: { + ...s.models, + activeTrainingPipelines: s.models.activeTrainingPipelines.map(p => + p.id === pipelineId ? { + ...p, + stages: { + ...p.stages, + alignment: { + method, + safetyWeight, + helpfulnessWeight: 1 - safetyWeight, + progressTicks: 0, + totalTicks: Math.ceil(p.stages.pretraining.totalTicks * 0.08), + isComplete: false, + }, }, - }, - } : p, - ), - }, - })), + } : p, + ), + }, + })); + get().addNotification({ title: 'Alignment Configured', message: `${method.toUpperCase()} alignment enabled.`, type: 'success', tick: get().meta.tickCount }); + }, - configureAlignment: (pipelineId, method, safetyWeight) => set((s) => ({ - models: { - ...s.models, - activeTrainingPipelines: s.models.activeTrainingPipelines.map(p => - p.id === pipelineId ? { - ...p, - stages: { - ...p.stages, - alignment: { - method, - safetyWeight, - helpfulnessWeight: 1 - safetyWeight, - progressTicks: 0, - totalTicks: Math.ceil(p.stages.pretraining.totalTicks * 0.08), - isComplete: false, - }, - }, - } : p, - ), - }, - })), + createDistillation: (baseModelId, targetParameters, variantName) => { + let created = false; + set((s) => { + const base = s.models.baseModels.find(m => m.id === baseModelId); + if (!base) return s; + created = true; + const job: VariantCreationJob = { + id: uuid(), + familyId: base.familyId, + baseModelId, + jobType: 'distillation', + config: { targetParameters, targetArchitecture: base.architecture.type, variantName }, + progressTicks: 0, + totalTicks: Math.ceil(base.trainingCostTotal > 0 ? DISTILLATION_TIME_FRACTION * 120 : 30), + allocatedComputeFraction: DISTILLATION_COMPUTE_FRACTION, + status: 'active', + }; + return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } }; + }); + if (created) { + get().addNotification({ title: 'Distillation Started', message: `${variantName} distillation in progress.`, type: 'info', tick: get().meta.tickCount }); + set({ modelsTab: 'overview' as ModelsTab }); + } + }, - createDistillation: (baseModelId, targetParameters, variantName) => set((s) => { - const base = s.models.baseModels.find(m => m.id === baseModelId); - if (!base) return s; - const job: VariantCreationJob = { - id: uuid(), - familyId: base.familyId, - baseModelId, - jobType: 'distillation', - config: { targetParameters, targetArchitecture: base.architecture.type, variantName }, - progressTicks: 0, - totalTicks: Math.ceil(base.trainingCostTotal > 0 ? DISTILLATION_TIME_FRACTION * 120 : 30), - allocatedComputeFraction: DISTILLATION_COMPUTE_FRACTION, - status: 'active', - }; - return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } }; - }), + createFineTune: (baseModelId, specialization, variantName) => { + let created = false; + set((s) => { + const base = s.models.baseModels.find(m => m.id === baseModelId); + if (!base) return s; + created = true; + const job: VariantCreationJob = { + id: uuid(), + familyId: base.familyId, + baseModelId, + jobType: 'fine-tuning', + config: { specialization, datasetIds: [], variantName }, + progressTicks: 0, + totalTicks: Math.ceil(FINETUNE_TIME_FRACTION * 120), + allocatedComputeFraction: FINETUNE_COMPUTE_FRACTION, + status: 'active', + }; + return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } }; + }); + if (created) { + get().addNotification({ title: 'Fine-Tuning Started', message: `${variantName} fine-tuning in progress.`, type: 'info', tick: get().meta.tickCount }); + set({ modelsTab: 'overview' as ModelsTab }); + } + }, - createFineTune: (baseModelId, specialization, variantName) => set((s) => { - const base = s.models.baseModels.find(m => m.id === baseModelId); - if (!base) return s; - const job: VariantCreationJob = { - id: uuid(), - familyId: base.familyId, - baseModelId, - jobType: 'fine-tuning', - config: { specialization, datasetIds: [], variantName }, - progressTicks: 0, - totalTicks: Math.ceil(FINETUNE_TIME_FRACTION * 120), - allocatedComputeFraction: FINETUNE_COMPUTE_FRACTION, - status: 'active', - }; - return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } }; - }), + createQuantization: (baseModelId, level, variantName) => { + let created = false; + set((s) => { + const base = s.models.baseModels.find(m => m.id === baseModelId); + if (!base) return s; + created = true; + const job: VariantCreationJob = { + id: uuid(), + familyId: base.familyId, + baseModelId, + jobType: 'quantization', + config: { level, variantName }, + progressTicks: 0, + totalTicks: QUANTIZATION_TICKS, + allocatedComputeFraction: 0, + status: 'active', + }; + return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } }; + }); + if (created) { + get().addNotification({ title: 'Quantization Started', message: `${variantName} quantization in progress.`, type: 'info', tick: get().meta.tickCount }); + set({ modelsTab: 'overview' as ModelsTab }); + } + }, - createQuantization: (baseModelId, level, variantName) => set((s) => { - const base = s.models.baseModels.find(m => m.id === baseModelId); - if (!base) return s; - const job: VariantCreationJob = { - id: uuid(), - familyId: base.familyId, - baseModelId, - jobType: 'quantization', - config: { level, variantName }, - progressTicks: 0, - totalTicks: QUANTIZATION_TICKS, - allocatedComputeFraction: 0, - status: 'active', - }; - return { models: { ...s.models, variantJobs: [...s.models.variantJobs, job] } }; - }), + startEvaluation: (modelId, benchmarkIds) => { + let created = false; + set((s) => { + const benchmarks = BENCHMARKS.filter(b => benchmarkIds.includes(b.id)); + if (benchmarks.length === 0) return s; + created = true; + const totalTicks = benchmarks.reduce((sum, b) => sum + b.ticksToRun, 0); + const computeCost = benchmarks.reduce((sum, b) => sum + b.computeCost, 0); + const job: EvalJob = { + id: uuid(), + modelId, + benchmarkIds, + progressTicks: 0, + totalTicks, + computeAllocated: computeCost, + status: 'active', + results: [], + }; + return { models: { ...s.models, evalJobs: [...s.models.evalJobs, job] } }; + }); + if (created) { + get().addNotification({ title: 'Evaluation Started', message: `${benchmarkIds.length} benchmark${benchmarkIds.length > 1 ? 's' : ''} queued.`, type: 'info', tick: get().meta.tickCount }); + set({ modelsTab: 'overview' as ModelsTab }); + } + }, - startEvaluation: (modelId, benchmarkIds) => set((s) => { - const benchmarks = BENCHMARKS.filter(b => benchmarkIds.includes(b.id)); - if (benchmarks.length === 0) return s; - const totalTicks = benchmarks.reduce((sum, b) => sum + b.ticksToRun, 0); - const computeCost = benchmarks.reduce((sum, b) => sum + b.computeCost, 0); - const job: EvalJob = { - id: uuid(), - modelId, - benchmarkIds, - progressTicks: 0, - totalTicks, - computeAllocated: computeCost, - status: 'active', - results: [], - }; - return { models: { ...s.models, evalJobs: [...s.models.evalJobs, job] } }; - }), + deployModel: (modelId) => { + const modelName = get().models.baseModels.find(m => m.id === modelId)?.name ?? 'Model'; + set((s) => ({ + models: { + ...s.models, + baseModels: s.models.baseModels.map(m => + m.id === modelId ? { ...m, isDeployed: true } : m, + ), + productLines: s.models.productLines.map(pl => ({ + ...pl, modelId, isActive: true, + })), + }, + market: { + ...s.market, + obsolescence: onModelDeployed(s.market.obsolescence, s.meta.tickCount), + }, + })); + get().addNotification({ title: 'Model Deployed', message: `${modelName} is now serving all product lines.`, type: 'success', tick: get().meta.tickCount }); + set({ modelsTab: 'products' as ModelsTab }); + }, - deployModel: (modelId) => set((s) => ({ - models: { - ...s.models, - baseModels: s.models.baseModels.map(m => - m.id === modelId ? { ...m, isDeployed: true } : m, - ), - productLines: s.models.productLines.map(pl => ({ - ...pl, modelId, isActive: true, - })), - }, - market: { - ...s.market, - obsolescence: onModelDeployed(s.market.obsolescence, s.meta.tickCount), - }, - })), - - deployVariant: (familyId, variantId) => set((s) => ({ - models: { - ...s.models, - families: s.models.families.map(f => - f.id === familyId - ? { ...f, variants: f.variants.map(v => v.id === variantId ? { ...v, isDeployed: true } : v) } - : f, - ), - }, - })), + deployVariant: (familyId, variantId) => { + set((s) => ({ + models: { + ...s.models, + families: s.models.families.map(f => + f.id === familyId + ? { ...f, variants: f.variants.map(v => v.id === variantId ? { ...v, isDeployed: true } : v) } + : f, + ), + }, + })); + get().addNotification({ title: 'Variant Deployed', message: 'Variant is now live.', type: 'success', tick: get().meta.tickCount }); + set({ modelsTab: 'products' as ModelsTab }); + }, setProductPricing: (productLineId, field, value) => set((s) => ({ models: { @@ -1188,20 +1251,27 @@ export const useGameStore = create()( }; }), - openSourceModel: (modelId) => set((s) => { - if (s.market.openSourcedModels.includes(modelId)) return s; - return { - market: { - ...s.market, - openSourcedModels: [...s.market.openSourcedModels, modelId], - }, - reputation: { - ...s.reputation, - score: Math.min(100, s.reputation.score + OPEN_SOURCE_REPUTATION_BOOST), - publicPerception: Math.min(100, s.reputation.publicPerception + OPEN_SOURCE_REPUTATION_BOOST), - }, - }; - }), + openSourceModel: (modelId) => { + let opened = false; + set((s) => { + if (s.market.openSourcedModels.includes(modelId)) return s; + opened = true; + return { + market: { + ...s.market, + openSourcedModels: [...s.market.openSourcedModels, modelId], + }, + reputation: { + ...s.reputation, + score: Math.min(100, s.reputation.score + OPEN_SOURCE_REPUTATION_BOOST), + publicPerception: Math.min(100, s.reputation.publicPerception + OPEN_SOURCE_REPUTATION_BOOST), + }, + }; + }); + if (opened) { + get().addNotification({ title: 'Model Open Sourced', message: 'Reputation boosted! Competitors may benefit.', type: 'success', tick: get().meta.tickCount }); + } + }, setOverloadPolicy: (policy) => set((s) => ({ market: { @@ -1382,7 +1452,7 @@ export const useGameStore = create()( name: 'ai-tycoon-save', version: SAVE_VERSION, partialize: (state) => { - const { activePage, notifications, infraNav, ...rest } = state; + const { activePage, notifications, infraNav, modelsTab, ...rest } = state; return rest; }, migrate: (_persisted, version) => { diff --git a/packages/game-engine/src/systems/modelSystem.ts b/packages/game-engine/src/systems/modelSystem.ts index f3c51f6..437cf6e 100644 --- a/packages/game-engine/src/systems/modelSystem.ts +++ b/packages/game-engine/src/systems/modelSystem.ts @@ -26,7 +26,7 @@ import type { ResearchBonuses } from './researchBonuses'; export interface ModelTickResult { modelsState: ModelsState; completedModels: BaseModel[]; - notifications: { title: string; message: string; type: 'success' | 'warning' | 'info' | 'danger' }[]; + notifications: { title: string; message: string; type: 'success' | 'warning' | 'info' | 'danger'; action?: { label: string; page?: string; modelsTab?: string } }[]; reputationHit: number; legalCosts: number; } @@ -101,6 +101,16 @@ export function processModels(state: GameState, researchBonuses?: ResearchBonuse stage.computeAllocated = effectiveFlops; stage.lossValue = Math.max(0.01, 10 * Math.exp(-stage.progressTicks / stage.totalTicks * 3)); + const progressRatio = stage.progressTicks / stage.totalTicks; + if (progressRatio >= 0.75 && progressRatio < 0.78 && !pipeline.stages.sft && !pipeline.stages.alignment) { + notifications.push({ + title: 'Post-Training Reminder', + message: `${pipeline.modelName} is 75% pre-trained. Configure SFT/Alignment now or they'll be skipped!`, + type: 'warning', + action: { label: 'Configure Now', page: 'models', modelsTab: 'overview' }, + }); + } + if (stage.progressTicks >= stage.totalTicks) { stage.isComplete = true; stage.progressTicks = stage.totalTicks; @@ -174,8 +184,9 @@ export function processModels(state: GameState, researchBonuses?: ResearchBonuse ); notifications.push({ title: 'Variant Created', - message: `${variant.name} (${variant.variantType}) is ready!`, + message: `${variant.name} (${variant.variantType}) is ready! Deploy it to use it.`, type: 'success', + action: { label: 'View in Families', page: 'models', modelsTab: 'models' }, }); } diff --git a/packages/game-engine/src/tick.ts b/packages/game-engine/src/tick.ts index ce150da..6b96f5e 100644 --- a/packages/game-engine/src/tick.ts +++ b/packages/game-engine/src/tick.ts @@ -23,6 +23,7 @@ export interface TickNotification { title: string; message: string; type: 'info' | 'success' | 'warning' | 'danger'; + action?: { label: string; page?: string; modelsTab?: string }; } let cachedAchievementDefs: AchievementDefinition[] | null = null; @@ -45,8 +46,9 @@ export function processTick(state: GameState): Partial { for (const completed of modelResult.completedModels) { notifications.push({ title: 'Training Complete', - message: `${completed.name} is ready! Capability: ${completed.rawCapability.toFixed(1)}/100`, + message: `${completed.name} is ready! Capability: ${completed.rawCapability.toFixed(1)}/100. Deploy it to start earning revenue.`, type: 'success', + action: { label: 'Go to Families', page: 'models', modelsTab: 'models' }, }); } notifications.push(...modelResult.notifications);