Overhaul Models tab UX: action feedback, post-training flow, guided navigation
CI / build-and-push (push) Successful in 29s

- Lift modelsTab state into Zustand store so actions can navigate tabs
- Add toast notifications + auto-tab-switch to all 10 model actions
  (train, configure SFT/alignment, distill, fine-tune, quantize, eval, deploy, open-source)
- Add actionable toast buttons with navigation (e.g., "Go to Families" on training complete)
- Fix post-training config: remove 50% deadline, show until pretraining completes,
  always-visible warning prompt outside card expand, engine reminder at 75%
- PostTrainingConfig now hides already-configured sections independently
- Add tab badges: pulsing dot for active jobs, count for undeployed models, warning for no deployment
- Replace empty states with actionable buttons guiding next steps
- Stage bars show "(skip)" in warning color for unconfigured SFT/Alignment stages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 10:20:00 -04:00
parent fdedd6f4d0
commit 775c6a4fa5
6 changed files with 436 additions and 270 deletions
@@ -61,6 +61,19 @@ export function ToastContainer() {
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{toast.title}</div>
<div className="text-xs text-surface-400">{toast.message}</div>
{toast.action && (
<button
onClick={() => {
if (toast.action!.page) useGameStore.getState().setActivePage(toast.action!.page);
if (toast.action!.modelsTab) useGameStore.getState().setModelsTab(toast.action!.modelsTab);
dismissNotification(toast.id);
setToasts(prev => prev.filter(t => t.id !== toast.id));
}}
className="text-xs text-accent hover:text-accent-light underline mt-1"
>
{toast.action.label}
</button>
)}
</div>
<button
onClick={() => {
+1
View File
@@ -45,6 +45,7 @@ export function useGameLoop(skip = false) {
message: n.message,
type: n.type,
tick: store.meta.tickCount,
...(n.action ? { action: n.action as any } : {}),
});
}
}
+146 -77
View File
@@ -69,7 +69,8 @@ export function ModelsPage() {
const openSourcedModels = useGameStore((s) => s.market.openSourcedModels);
const completedResearch = useGameStore((s) => s.research.completedResearch);
const [modelsTab, setModelsTab] = useState<'overview' | 'train' | 'models' | 'benchmarks' | 'products'>('overview');
const modelsTab = useGameStore((s) => s.modelsTab);
const setModelsTab = useGameStore((s) => s.setModelsTab);
const [modelName, setModelName] = useState('');
const [expandedModel, setExpandedModel] = useState<string | null>(null);
const [expandedPipeline, setExpandedPipeline] = useState<string | null>(null);
@@ -84,6 +85,11 @@ export function ModelsPage() {
const estimatedCapability = Math.min(95, Math.sqrt(trainingFlops) * 5 + Math.log10(1 + totalData / 1e8) * 10);
const activePipelines = pipelines.filter(p => p.status === 'active' || p.status === 'stalled');
const activeVariantJobs = variantJobs.filter(j => j.status === 'active');
const activeEvalJobs = evalJobs.filter(j => j.status === 'active');
const undeployedCount = baseModels.filter(m => !m.isDeployed).length;
const hasActiveJobs = activePipelines.length > 0 || activeVariantJobs.length > 0 || activeEvalJobs.length > 0;
const noModelDeployed = baseModels.length > 0 && !baseModels.some(m => m.isDeployed);
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'] as const;
const currentEraIdx = eraOrder.indexOf(currentEra);
@@ -135,9 +141,6 @@ export function ModelsPage() {
r === 'alignment-research' || r === 'interpretability' || r === 'constitutional-ai',
);
const activeVariantJobs = variantJobs.filter(j => j.status === 'active');
const activeEvalJobs = evalJobs.filter(j => j.status === 'active');
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold">Models</h2>
@@ -157,13 +160,22 @@ export function ModelsPage() {
<button
key={tab.id}
onClick={() => setModelsTab(tab.id)}
className={`px-4 py-2 text-sm rounded-t-lg transition-colors ${
className={`px-4 py-2 text-sm rounded-t-lg transition-colors flex items-center gap-1 ${
modelsTab === tab.id
? 'bg-surface-800 text-surface-100 border-b-2 border-accent'
: 'text-surface-400 hover:text-surface-200 hover:bg-surface-800/50'
}`}
>
{tab.label}
{tab.id === 'overview' && hasActiveJobs && (
<span className="w-2 h-2 rounded-full bg-accent animate-pulse" />
)}
{tab.id === 'models' && undeployedCount > 0 && (
<span className="px-1.5 py-0.5 text-[9px] rounded-full bg-warning/20 text-warning">{undeployedCount}</span>
)}
{tab.id === 'products' && noModelDeployed && (
<span className="w-2 h-2 rounded-full bg-warning" />
)}
</button>
))}
</div>
@@ -247,6 +259,19 @@ export function ModelsPage() {
: `ETA: ${formatDuration(stage.totalTicks - stage.progressTicks)}`}
</div>
{pipeline.currentStage === 'pretraining' && !pipeline.stages.sft && !pipeline.stages.alignment && (
<div className="mt-2 flex items-center gap-2 text-xs text-warning">
<Beaker size={12} />
<span>
Post-training not configured {' '}
<button onClick={() => setExpandedPipeline(pipeline.id)} className="text-accent hover:text-accent-light underline">
configure SFT &amp; Alignment
</button>
{' '}or they'll be skipped.
</span>
</div>
)}
{isExpanded && (
<div className="mt-3 pt-3 border-t border-surface-700 space-y-2">
{pipeline.currentStage === 'pretraining' && (
@@ -256,8 +281,8 @@ export function ModelsPage() {
</div>
)}
{!pipeline.stages.sft && pipeline.stages.pretraining.progressTicks < pipeline.stages.pretraining.totalTicks * 0.5 && (
<PostTrainingConfig pipelineId={pipeline.id} hasAlignmentResearch={hasAlignmentResearch} completedResearch={completedResearch} configureSFT={configureSFT} configureAlignment={configureAlignment} />
{pipeline.currentStage === 'pretraining' && !pipeline.stages.pretraining.isComplete && (!pipeline.stages.sft || !pipeline.stages.alignment) && (
<PostTrainingConfig pipelineId={pipeline.id} hasAlignmentResearch={hasAlignmentResearch} completedResearch={completedResearch} configureSFT={configureSFT} configureAlignment={configureAlignment} sftConfigured={!!pipeline.stages.sft} alignmentConfigured={!!pipeline.stages.alignment} />
)}
{recentEvents.length > 0 && (
@@ -567,6 +592,14 @@ export function ModelsPage() {
{/* Product Lines */}
{modelsTab === 'products' && <div className="space-y-3">
<h3 className="font-semibold">Product Lines</h3>
{noModelDeployed && (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 text-center space-y-3">
<p className="text-surface-400 text-sm">No model deployed yet. Deploy a model to start earning revenue.</p>
<button onClick={() => setModelsTab('models')} className="inline-flex items-center gap-2 bg-accent hover:bg-accent/80 text-white px-4 py-2 rounded-lg text-sm">
<Rocket size={16} /> Go to Families
</button>
</div>
)}
{productLines.map(pl => (
<div key={pl.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center justify-between">
@@ -584,17 +617,28 @@ export function ModelsPage() {
))}
</div>}
{/* Empty state for Models tab */}
{modelsTab === 'models' && families.length === 0 && (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center text-surface-500 text-sm">
No model families yet. Train your first model from the Train New tab.
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center space-y-3">
<p className="text-surface-400 text-sm">No model families yet. Train your first model to get started.</p>
<button onClick={() => setModelsTab('train')} className="inline-flex items-center gap-2 bg-accent hover:bg-accent/80 text-white px-4 py-2 rounded-lg text-sm">
<Play size={16} /> Train Your First Model
</button>
</div>
)}
{/* Empty state for Overview when nothing is active */}
{modelsTab === 'overview' && activePipelines.length === 0 && activeVariantJobs.length === 0 && activeEvalJobs.length === 0 && (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center text-surface-500 text-sm">
No active jobs. Start a training pipeline from the Train New tab.
{modelsTab === 'overview' && !hasActiveJobs && (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center space-y-3">
<p className="text-surface-400 text-sm">No active training or evaluation jobs.</p>
<div className="flex justify-center gap-3">
<button onClick={() => setModelsTab('train')} className="inline-flex items-center gap-2 bg-accent hover:bg-accent/80 text-white px-4 py-2 rounded-lg text-sm">
<Play size={16} /> {families.length === 0 ? 'Train Your First Model' : 'Train New Model'}
</button>
{families.length > 0 && (
<button onClick={() => setModelsTab('models')} className="inline-flex items-center gap-2 bg-surface-700 hover:bg-surface-600 text-surface-200 px-4 py-2 rounded-lg text-sm">
View Families ({families.length})
</button>
)}
</div>
</div>
)}
</div>
@@ -1069,8 +1113,15 @@ function StageBar({ label, active, complete, progress, configured = true }: {
}) {
return (
<div className="flex-1">
<div className="text-[9px] text-center text-surface-500 mb-0.5">{label}</div>
<div className={`h-1 rounded-full ${!configured ? 'bg-surface-800' : complete ? 'bg-success' : active ? 'bg-accent' : 'bg-surface-700'}`}>
<div className={`text-[9px] text-center mb-0.5 ${!configured ? 'text-warning' : 'text-surface-500'}`}>
{label}{!configured && ' (skip)'}
</div>
<div className={`h-1 rounded-full ${
!configured ? 'bg-surface-800 border border-dashed border-warning/30' :
complete ? 'bg-success' :
active ? 'bg-accent' :
'bg-surface-700'
}`}>
{active && !complete && (
<div className="h-full bg-accent rounded-full transition-all" style={{ width: `${progress * 100}%` }} />
)}
@@ -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<SFTSpecialization[]>(['general']);
const [alignMethod, setAlignMethod] = useState<AlignmentMethod>('rlhf');
@@ -1094,75 +1147,91 @@ function PostTrainingConfig({ pipelineId, hasAlignmentResearch, completedResearc
<div className="space-y-3 bg-surface-800/50 rounded-lg p-3">
<div className="text-xs font-medium text-surface-300">Configure Post-Training (optional)</div>
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-surface-400">
<Beaker size={10} /> Supervised Fine-Tuning
</div>
<div className="flex flex-wrap gap-1">
{SFT_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => setSelectedSpecs(prev =>
prev.includes(opt.value)
? prev.filter(s => s !== opt.value)
: [...prev, opt.value]
)}
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
selectedSpecs.includes(opt.value)
? 'bg-accent/20 border-accent text-accent-light'
: 'bg-surface-800 border-surface-600 text-surface-400'
}`}
>
{opt.label}
</button>
))}
</div>
<button
onClick={() => configureSFT(pipelineId, selectedSpecs)}
className="text-[10px] text-accent hover:text-accent-light"
>
Enable SFT
</button>
</div>
{hasAlignmentResearch && (
{!sftConfigured ? (
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-surface-400">
<Shield size={10} /> Alignment
<Beaker size={10} /> Supervised Fine-Tuning
</div>
<div className="flex gap-1">
{(Object.keys(ALIGNMENT_METHODS) as AlignmentMethod[]).map(method => {
const isAvailable = completedResearch.includes(ALIGNMENT_METHODS[method].requiredResearch);
return (
<button
key={method}
disabled={!isAvailable}
onClick={() => setAlignMethod(method)}
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
alignMethod === method ? 'bg-accent/20 border-accent text-accent-light' :
!isAvailable ? 'bg-surface-800 border-surface-700 text-surface-600 cursor-not-allowed' :
'bg-surface-800 border-surface-600 text-surface-400'
}`}
>
{method.toUpperCase()}
</button>
);
})}
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-surface-400">Safety</span>
<input type="range" min={0} max={100} value={safetyWeight * 100}
onChange={(e) => setSafetyWeight(Number(e.target.value) / 100)}
className="flex-1 accent-accent h-1" />
<span className="text-[10px] text-surface-400">Helpful</span>
<div className="flex flex-wrap gap-1">
{SFT_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => setSelectedSpecs(prev =>
prev.includes(opt.value)
? prev.filter(s => s !== opt.value)
: [...prev, opt.value]
)}
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
selectedSpecs.includes(opt.value)
? 'bg-accent/20 border-accent text-accent-light'
: 'bg-surface-800 border-surface-600 text-surface-400'
}`}
>
{opt.label}
</button>
))}
</div>
<button
onClick={() => configureAlignment(pipelineId, alignMethod, safetyWeight)}
onClick={() => configureSFT(pipelineId, selectedSpecs)}
className="text-[10px] text-accent hover:text-accent-light"
>
Enable Alignment
Enable SFT
</button>
</div>
) : (
<div className="flex items-center gap-1 text-xs text-success">
<Beaker size={10} /> SFT configured
</div>
)}
{!alignmentConfigured ? (
hasAlignmentResearch ? (
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-surface-400">
<Shield size={10} /> Alignment
</div>
<div className="flex gap-1">
{(Object.keys(ALIGNMENT_METHODS) as AlignmentMethod[]).map(method => {
const isAvailable = completedResearch.includes(ALIGNMENT_METHODS[method].requiredResearch);
return (
<button
key={method}
disabled={!isAvailable}
onClick={() => setAlignMethod(method)}
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
alignMethod === method ? 'bg-accent/20 border-accent text-accent-light' :
!isAvailable ? 'bg-surface-800 border-surface-700 text-surface-600 cursor-not-allowed' :
'bg-surface-800 border-surface-600 text-surface-400'
}`}
>
{method.toUpperCase()}
</button>
);
})}
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-surface-400">Safety</span>
<input type="range" min={0} max={100} value={safetyWeight * 100}
onChange={(e) => setSafetyWeight(Number(e.target.value) / 100)}
className="flex-1 accent-accent h-1" />
<span className="text-[10px] text-surface-400">Helpful</span>
</div>
<button
onClick={() => configureAlignment(pipelineId, alignMethod, safetyWeight)}
className="text-[10px] text-accent hover:text-accent-light"
>
Enable Alignment
</button>
</div>
) : (
<div className="flex items-center gap-1 text-xs text-surface-500">
<Shield size={10} /> Alignment requires research
</div>
)
) : (
<div className="flex items-center gap-1 text-xs text-success">
<Shield size={10} /> Alignment configured
</div>
)}
</div>
);
+260 -190
View File
@@ -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<DataCenter, 'networkSummary' | 'effectiveComputeRacks' | 'usedSlots' | 'usedPowerKW' | 'energyCostPerTick' | 'maintenanceCostPerTick' | 'currentUptime'> {
@@ -86,6 +90,7 @@ function emptyDC(): Pick<DataCenter, 'networkSummary' | 'effectiveComputeRacks'
interface Actions {
setActivePage: (page: ActivePage) => void;
setInfraNav: (nav: InfraNav) => void;
setModelsTab: (tab: ModelsTab) => void;
addNotification: (n: Omit<GameNotification, 'id' | 'read'>) => void;
dismissNotification: (id: string) => void;
removeNotification: (id: string) => void;
@@ -289,11 +294,14 @@ export const useGameStore = create<Store>()(
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<Store>()(
// --- 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<Store>()(
};
}),
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<Store>()(
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) => {