'use client'; import { useEffect, useRef, useState } from 'react'; import { AdminShell } from '@/components/AdminShell'; import { type PolicySummary, type SimEvent, type SimRun, getSimRun, getSimRuns, startSimulation, } from '@/lib/api'; const KNOWN_POLICIES = ['linucb-v1', 'egreedy-v1']; const ACTIONS = ['done', 'snooze', 'dismiss']; // Shown as reference only — actual reward is dwell-time inferred for 'done' const ACTION_REWARDS: Record = { done: 1.0, snooze: 0.1, dismiss: -1.0, }; // ── SVG reward curve ──────────────────────────────────────────────────────── function RewardCurve({ summary, policies }: { summary: Record; policies: string[] }) { const W = 520, H = 160, PAD = { t: 10, r: 10, b: 30, l: 40 }; const iW = W - PAD.l - PAD.r; const iH = H - PAD.t - PAD.b; const allVals = policies.flatMap((p) => summary[p]?.cumulative_rewards ?? []); const minY = Math.min(0, ...allVals); const maxY = Math.max(1, ...allVals); const n = Math.max(...policies.map((p) => (summary[p]?.cumulative_rewards ?? []).length)); const xScale = (i: number) => PAD.l + (i / Math.max(1, n - 1)) * iW; const yScale = (v: number) => PAD.t + iH - ((v - minY) / (maxY - minY)) * iH; const COLORS = ['#818cf8', '#34d399', '#f87171', '#fbbf24']; const path = (vals: number[]) => vals .map((v, i) => `${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(1)},${yScale(v).toFixed(1)}`) .join(' '); // Axis labels const yLabels = [minY, (minY + maxY) / 2, maxY]; return ( {/* Grid */} {yLabels.map((v, i) => ( {v.toFixed(1)} ))} {/* Zero line */} {minY < 0 && ( )} {/* Curves */} {policies.map((p, pi) => { const vals = summary[p]?.cumulative_rewards ?? []; if (!vals.length) return null; return ( ); })} {/* X axis */} Round {/* Legend */} {policies.map((p, pi) => ( {p} ))} ); } // ── Action distribution table ─────────────────────────────────────────────── function ActionTable({ summary, policies, }: { summary: Record; policies: string[]; }) { return ( {policies.map((p) => ( ))} {ACTIONS.map((action) => ( {policies.map((p) => { const n = summary[p]?.action_counts?.[action] ?? 0; const total = Object.values(summary[p]?.action_counts ?? {}).reduce( (a, b) => a + b, 0 ); const pct = total > 0 ? ((n / total) * 100).toFixed(1) : '—'; return ( ); })} ))}
Action{p}Reward
{action} {n} ({pct}%) 0 ? 'text-green-400' : ACTION_REWARDS[action] < 0 ? 'text-red-400' : 'text-gray-500'}`}> {ACTION_REWARDS[action] >= 0 ? '+' : ''}{ACTION_REWARDS[action]}
); } // ── Per-persona breakdown ─────────────────────────────────────────────────── function PersonaTable({ breakdown, policies, }: { breakdown: Record>; policies: string[]; }) { const personas = Object.keys(breakdown); return ( {policies.map((p) => ( ))} {personas.map((persona) => { const pdata = breakdown[persona]; const best = policies.reduce((a, b) => (pdata[a]?.reward ?? -Infinity) >= (pdata[b]?.reward ?? -Infinity) ? a : b ); return ( {policies.map((p) => { const d = pdata[p]; const mean = d && d.n > 0 ? (d.reward / d.n).toFixed(3) : '—'; return ( ); })} ); })}
Persona{p}
mean reward
Winner
{persona} {mean} {best}
); } // ── Run detail panel ──────────────────────────────────────────────────────── function RunDetail({ runId, onClose }: { runId: string; onClose: () => void }) { const [data, setData] = useState<{ run: SimRun; events: SimEvent[] } | null>(null); const [error, setError] = useState(''); const pollRef = useRef | null>(null); const load = async () => { try { const d = await getSimRun(runId); setData(d); if (d.run.status !== 'running' && d.run.status !== 'pending') { if (pollRef.current) clearInterval(pollRef.current); } } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed to load'); if (pollRef.current) clearInterval(pollRef.current); } }; useEffect(() => { load(); pollRef.current = setInterval(load, 3000); return () => { if (pollRef.current) clearInterval(pollRef.current); }; }, [runId]); const run = data?.run; const summary: Record | null = run?.summaryJson ? JSON.parse(run.summaryJson) : null; const breakdown: Record> | null = run?.personaBreakdownJson ? JSON.parse(run.personaBreakdownJson) : null; const policies = run ? [run.policyA, run.policyB] : []; return (

Simulation {runId}

{run && (

{run.nUsers} users × {run.nRounds} rounds × {run.tasksPerRound} tasks {' · '}{run.useLlm ? 'LLM judge' : 'Rule judge'}

)}
{error &&

{error}

} {run && (
{run.winner && run.status === 'done' && ( Winner: {run.winner} )}
)} {summary && ( <> {/* Metric cards */}
{policies.map((p) => (
{p}
))}
{/* Cumulative reward chart */}

Cumulative reward over rounds

{/* Action distribution */}

Action distribution

)} {breakdown && (

Per-persona mean reward

)} {run?.status === 'running' && (

Simulation running — auto-refreshing every 3s…

)}
); } // ── Status badge ──────────────────────────────────────────────────────────── function StatusBadge({ status }: { status: string }) { const styles: Record = { pending: 'bg-gray-800 text-gray-400', running: 'bg-yellow-900/60 text-yellow-300 border border-yellow-700', done: 'bg-green-900/60 text-green-300 border border-green-700', failed: 'bg-red-900/60 text-red-300 border border-red-700', }; return ( {status} ); } function Metric({ label, value }: { label: string; value: string | undefined }) { return (
{label}
{value ?? '—'}
); } // ── Main page ─────────────────────────────────────────────────────────────── export default function SimulationsPage() { const [runs, setRuns] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [selectedId, setSelectedId] = useState(null); // Form state const [nUsers, setNUsers] = useState(5); const [nRounds, setNRounds] = useState(20); const [tasksPerRound, setTasksPerRound] = useState(8); const [useLlm, setUseLlm] = useState(false); const [policyA, setPolicyA] = useState('linucb-v1'); const [policyB, setPolicyB] = useState('egreedy-v1'); const [launching, setLaunching] = useState(false); const [launchError, setLaunchError] = useState(''); const loadRuns = async () => { setLoading(true); try { const { runs: r } = await getSimRuns(); setRuns(r); } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed to load'); } finally { setLoading(false); } }; useEffect(() => { loadRuns(); }, []); const handleStart = async () => { if (policyA === policyB) { setLaunchError('Policies must be different'); return; } setLaunching(true); setLaunchError(''); try { const { id } = await startSimulation({ nUsers, nRounds, tasksPerRound, useLlm, policies: [policyA, policyB], }); await loadRuns(); setSelectedId(id); } catch (e: unknown) { setLaunchError(e instanceof Error ? e.message : 'Failed to start'); } finally { setLaunching(false); } }; return (

Simulations

Compare recommendation policies offline using synthetic users and LLM-judged reactions. ml/serving must be running.

{/* Launch form */}

New simulation

setNUsers(Number(e.target.value))} className={inputCls} /> setNRounds(Number(e.target.value))} className={inputCls} /> setTasksPerRound(Number(e.target.value))} className={inputCls} /> {!useLlm &&

Deterministic rule judge

} {useLlm &&

Requires ANTHROPIC_API_KEY

}
{launchError &&

{launchError}

}
{/* Runs list */}

Past runs

{loading &&

Loading…

} {error &&

{error}

} {runs.length === 0 && !loading && (

No simulation runs yet.

)}
{runs.map((run) => ( ))}
{selectedId && ( setSelectedId(null)} /> )}
); } // ── Small helpers ─────────────────────────────────────────────────────────── const inputCls = 'w-full bg-gray-800 border border-gray-700 rounded px-2.5 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-indigo-500'; const selectCls = 'w-full bg-gray-800 border border-gray-700 rounded px-2.5 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-indigo-500'; function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
{children}
); }