Download src/App.tsx from seonglae/AgentStateGraph-Browser: direct link, hf CLI and curl.
- Browser
- Download file 138 kB
-
https://huggingface.co/spaces/seonglae/AgentStateGraph-Browser/resolve/main/src/App.tsx
- Command line
-
hf download hf://spaces/seonglae/AgentStateGraph-Browser/src/App.tsx
-
curl -L -o App.tsx https://huggingface.co/spaces/seonglae/AgentStateGraph-Browser/resolve/main/src/App.tsx
138 kB
| import { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense, Fragment } from 'react' | |
| import { | |
| BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, | |
| Tooltip, ResponsiveContainer, Cell, Legend, | |
| } from 'recharts' | |
| import { cn, fmt, fmtInt, pct, compression } from './lib/utils' | |
| const LandingView = lazy(() => import('./LandingView')) | |
| // ---------- Types ---------- | |
| interface Baseline { | |
| method: string | |
| label: string | |
| trainFitness: number | |
| testFitness: number | |
| states: number | null | |
| transitions: number | null | |
| randomAcceptRate: number | null | |
| permutedAcceptRate: number | null | |
| pmPrecision: number | null | |
| category: string | |
| note: string | |
| } | |
| interface TraceStep { | |
| idx: number | |
| role: string | |
| activity: string | |
| fromState: string | |
| toState: string | null | |
| consumed: boolean | |
| } | |
| interface Trace { | |
| id: string | |
| split: 'train' | 'test' | |
| success: boolean | null | |
| fullFitness: number | null | |
| successFitness: number | null | |
| steps: TraceStep[] | |
| stateSequence: string[] | |
| fitness: number | |
| consumed: number | |
| total: number | |
| firstFailIdx: number | |
| stateVisits: Record<string, number> | |
| } | |
| interface Dataset { | |
| id: string | |
| meta: { | |
| name: string | |
| shortName: string | |
| venue: string | |
| traces: number | |
| trainSize: number | |
| testSize: number | |
| activities: number | |
| description: string | |
| } | |
| baselines: Baseline[] | |
| traces: Trace[] | |
| convergence: { | |
| stateConvergenceAt: number | |
| transitionConvergenceAt: number | |
| fitnessConvergenceAt: number | |
| curve: Array<{ n: number; states: number; transitions: number; fitness: number }> | |
| } | |
| downstream: { | |
| fitnessProfile: { | |
| mean: number; std: number; min: number; max: number | |
| anomalyCount: number; anomalies: string[] | |
| histogram: Array<{ min: number; max: number; count: number }> | |
| } | null | |
| behavioralProfiling: { | |
| stateVisitDistribution: Array<{ state: string; count: number; fraction: number }> | |
| transitionFrequency: Array<{ transition: string; count: number; fraction: number }> | |
| deadTransitions: string[] | |
| transitionCoverage: number | |
| avgUniqueStatesPerTrace: number | |
| } | null | |
| structuralAnalysis: { | |
| density: number | |
| hubs: Array<{ id: string; outDegree: number; inDegree: number }> | |
| sinks: string[] | |
| sources: string[] | |
| selfLoops: string[] | |
| nonTrivialSCCs: string[][] | |
| } | null | |
| differentialFSM: { | |
| successOnlyTransitions: string[] | |
| failureOnlyTransitions: string[] | |
| sharedTransitions: string[] | |
| } | null | |
| failurePrediction: Record<string, unknown> | null | |
| mistakeLocalization: Record<string, unknown> | null | |
| neuralProbe: { | |
| seq_mlp: { cv: number; std: number; holdout: number } | |
| fsm_mlp: { cv: number; std: number; holdout: number } | |
| seq_gru: { cv: number; std: number; holdout: number } | |
| fsm_gru: { cv: number; std: number; holdout: number } | |
| seq_transf: { cv: number; std: number; holdout: number } | |
| fsm_transf: { cv: number; std: number; holdout: number } | |
| } | null | |
| workflowMemory: { | |
| n: number | |
| noMemory: { accuracy: number; correct: number } | |
| awm: { accuracy: number; correct: number } | |
| fsm: { accuracy: number; correct: number } | |
| fsmGain: number | |
| } | null | |
| crossModel: { | |
| models: Array<{ name: string; shortName: string; traces: number; successRate: number }> | |
| matrices: { | |
| fitness: number[][] | |
| auroc: number[][] | |
| stateCount: number[][] | |
| } | |
| summary: { | |
| diagonalAUROC: { mean: number; std: number } | |
| offDiagonalAUROC: { mean: number; std: number } | |
| aurocTransferGap: number | |
| } | |
| } | null | |
| monitor: { | |
| trainSize: number; testSize: number; succSize: number; failSize: number | |
| configs: Array<{ | |
| config: string; gamma: number; window: number | |
| monitoringCurve: Array<{ point: number; threshold: number; f1: number; precision: number; recall: number }> | |
| }> | |
| baselines: Record<string, { auroc: number; curve?: Array<{ point: number; f1: number }> }> | |
| } | null | |
| counterfactual: { | |
| nSuccess: number; nFailure: number | |
| decisionPoints: Array<{ state: string; branching: number; targets: string[] }> | |
| pathDiversity: { uniqueSuccPaths: number; uniqueFailPaths: number; overlap: number } | |
| dpDivergence: Array<{ state: string; jsd: number }> | |
| editDistance: { meanInternal?: number; meanCross?: number } | null | |
| } | null | |
| } | |
| multiseed: { | |
| numSeeds: number | |
| ourFSM: { testFitness: { mean: number; std: number }; states: { mean: number; std: number } } | |
| rpni: { testFitness: { mean: number; std: number }; states: { mean: number; std: number } } | |
| awm: { testFitness: { mean: number; std: number } } | |
| } | null | |
| graph: { | |
| states: Array<{ id: string }> | |
| transitions: Array<{ source: string; target: string; subject: string }> | |
| } | |
| } | |
| type ViewId = 'landing' | 'overview' | 'baselines' | 'convergence' | 'graph' | 'traces' | 'downstream' | 'failure' | 'memory' | 'crossmodel' | 'monitor' | 'counterfactual' | 'about' | |
| // All datasets (passed into sub-views that need cross-dataset comparisons) | |
| type AllDatasets = Dataset[] | |
| // ---------- Palette (warm, no green/purple) ---------- | |
| const PALETTE = { | |
| rust: '#c4553a', | |
| slate: '#3d5a80', | |
| ochre: '#c49a3a', | |
| charcoal: '#2d2926', | |
| clay: '#a66e4e', | |
| iron: '#5c6b73', | |
| teal: '#2a8f82', | |
| } | |
| const METHOD_COLORS: Record<string, string> = { | |
| stateGraph: PALETTE.rust, | |
| rpni: PALETTE.slate, | |
| heuristic: PALETTE.ochre, | |
| inductive: PALETTE.clay, | |
| alpha: PALETTE.iron, | |
| awm: PALETTE.charcoal, | |
| awm_all: '#7a7068', | |
| } | |
| function methodLabel(b: Baseline): string { | |
| if (b.method === 'stateGraph') return 'Agent State Graph' | |
| return b.label | |
| } | |
| // ---------- Chart theme ---------- | |
| const CHART_GRID = '#d5cfc5' | |
| const CHART_TICK = { fill: '#8a8478', fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' } | |
| const CHART_AXIS = { stroke: '#d5cfc5' } | |
| const CHART_TOOLTIP = { | |
| contentStyle: { | |
| background: '#f5f0e8', | |
| border: '1px solid #d5cfc5', | |
| borderRadius: 0, | |
| fontSize: 12, | |
| fontFamily: 'IBM Plex Mono, monospace', | |
| boxShadow: '2px 2px 0 rgba(0,0,0,0.04)', | |
| }, | |
| } | |
| // ---------- URL State ---------- | |
| const VALID_TABS: ViewId[] = ['landing', 'overview', 'baselines', 'convergence', 'graph', 'traces', 'downstream', 'failure', 'memory', 'crossmodel', 'monitor', 'counterfactual', 'about'] | |
| function readParams(): { dataset?: string; tab?: ViewId; trace?: string; method?: string } { | |
| const p = new URLSearchParams(window.location.search) | |
| const tab = p.get('tab') as ViewId | null | |
| return { | |
| dataset: p.get('dataset') || undefined, | |
| tab: tab && VALID_TABS.includes(tab) ? tab : undefined, | |
| trace: p.get('trace') || undefined, | |
| method: p.get('method') || undefined, | |
| } | |
| } | |
| function pushParams(params: Record<string, string>) { | |
| const p = new URLSearchParams(window.location.search) | |
| for (const [k, v] of Object.entries(params)) p.set(k, v) | |
| const url = `${window.location.pathname}?${p.toString()}` | |
| window.history.pushState(null, '', url) | |
| } | |
| // ---------- Main App ---------- | |
| export default function App() { | |
| const [datasets, setDatasets] = useState<Dataset[]>([]) | |
| const [activeDs, setActiveDs] = useState(0) | |
| const [view, setView] = useState<ViewId>(() => { | |
| const tab = new URLSearchParams(window.location.search).get('tab') as ViewId | null | |
| return tab && VALID_TABS.includes(tab) ? tab : 'landing' | |
| }) | |
| const [selectedTrace, setSelectedTrace] = useState<string | null>(null) | |
| const [selectedMethod, setSelectedMethod] = useState<string | null>(null) | |
| const [loadProgress, setLoadProgress] = useState(0) | |
| // On load: fetch slim index first (~3MB), then load per-dataset shards in parallel. | |
| // Falls back to monolithic data.json if index isn't published yet. | |
| // Paths are base-relative (import.meta.env.BASE_URL) so the dashboard works both at | |
| // the dev root and when served under /article/asg/browser/ in production. | |
| useEffect(() => { | |
| const BASE = import.meta.env.BASE_URL | |
| const loadShards = async (): Promise<Dataset[]> => { | |
| const idxRes = await fetch(`${BASE}index.json`) | |
| if (!idxRes.ok) throw new Error('no-index') | |
| const index = await idxRes.json() as Array<{ id: string }> | |
| const shards = await Promise.all(index.map(async (entry, i) => { | |
| const r = await fetch(`${BASE}datasets/${entry.id}.json`) | |
| const ds = await r.json() as Dataset | |
| setLoadProgress(Math.round(((i + 1) / index.length) * 100)) | |
| return ds | |
| })) | |
| return shards | |
| } | |
| const loadMonolith = async (): Promise<Dataset[]> => { | |
| const r = await fetch(`${BASE}data.json`) | |
| setLoadProgress(50) | |
| const data = await r.json() as Dataset[] | |
| setLoadProgress(100) | |
| return data | |
| } | |
| loadShards() | |
| .catch(() => loadMonolith()) | |
| .then(data => { | |
| setDatasets(data) | |
| setLoadProgress(100) | |
| const { dataset, tab, trace, method } = readParams() | |
| if (dataset) { | |
| const idx = data.findIndex(d => d.id === dataset) | |
| if (idx >= 0) setActiveDs(idx) | |
| } | |
| if (tab) setView(tab) | |
| if (trace) setSelectedTrace(trace) | |
| if (method) setSelectedMethod(method) | |
| }) | |
| }, []) | |
| // Sync URL when state changes | |
| const navigate = useCallback((dsIndex: number, tab: ViewId, data?: Dataset[]) => { | |
| const ds = (data || datasets)[dsIndex] | |
| if (ds) pushParams({ dataset: ds.id, tab }) | |
| }, [datasets]) | |
| const handleSetDataset = useCallback((i: number) => { | |
| setActiveDs(i) | |
| setSelectedTrace(null) | |
| setSelectedMethod(null) | |
| // Keep current tab when switching datasets | |
| setView(prev => { | |
| navigate(i, prev) | |
| return prev | |
| }) | |
| }, [navigate]) | |
| const handleSetView = useCallback((v: ViewId) => { | |
| setView(v) | |
| setSelectedTrace(null) | |
| setSelectedMethod(null) | |
| const p = new URLSearchParams(window.location.search) | |
| p.set('tab', v) | |
| p.delete('trace') | |
| p.delete('method') | |
| window.history.pushState(null, '', `${window.location.pathname}?${p.toString()}`) | |
| }, []) | |
| const handleSelectTrace = useCallback((traceId: string | null) => { | |
| setSelectedTrace(traceId) | |
| const p = new URLSearchParams(window.location.search) | |
| if (traceId) p.set('trace', traceId); else p.delete('trace') | |
| window.history.pushState(null, '', `${window.location.pathname}?${p.toString()}`) | |
| }, []) | |
| const handleSelectMethod = useCallback((method: string | null) => { | |
| setSelectedMethod(method) | |
| const p = new URLSearchParams(window.location.search) | |
| if (method) p.set('method', method); else p.delete('method') | |
| window.history.pushState(null, '', `${window.location.pathname}?${p.toString()}`) | |
| }, []) | |
| // Handle browser back/forward | |
| useEffect(() => { | |
| const onPop = () => { | |
| const { dataset, tab, trace, method } = readParams() | |
| if (dataset && datasets.length) { | |
| const idx = datasets.findIndex(d => d.id === dataset) | |
| if (idx >= 0) setActiveDs(idx) | |
| } | |
| if (tab) setView(tab) | |
| setSelectedTrace(trace || null) | |
| setSelectedMethod(method || null) | |
| } | |
| window.addEventListener('popstate', onPop) | |
| return () => window.removeEventListener('popstate', onPop) | |
| }, [datasets]) | |
| if (!datasets.length) { | |
| return ( | |
| <div className="flex flex-col items-center justify-center h-screen gap-3"> | |
| <div className="text-ink-3 text-sm serif italic">Loading...</div> | |
| {loadProgress > 0 && ( | |
| <div className="w-48"> | |
| <div className="h-1 bg-rule rounded-full overflow-hidden"> | |
| <div className="h-full bg-rust transition-all duration-200" style={{ width: `${loadProgress}%` }} /> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| const ds = datasets[activeDs] | |
| const dashboardTabs: [ViewId, string][] = [ | |
| ['overview', 'Overview'], | |
| ['baselines', 'Baselines'], | |
| ['convergence', 'Convergence'], | |
| ['graph', 'Graph'], | |
| ['traces', 'Traces'], | |
| ['downstream', 'Downstream'], | |
| ['failure', 'Failure'], | |
| ['memory', 'Memory'], | |
| ['crossmodel', 'Cross-Model'], | |
| ['monitor', 'Monitor'], | |
| ['counterfactual', 'Counterfactual'], | |
| ['about', 'About'], | |
| ] | |
| // Landing page: full-screen, no header/nav | |
| if (view === 'landing') { | |
| return ( | |
| <Suspense fallback={ | |
| <div className="flex flex-col items-center justify-center h-screen gap-3"> | |
| <div className="text-ink-3 text-sm serif italic">Loading...</div> | |
| <div className="w-48"> | |
| <div className="h-1 bg-rule rounded-full overflow-hidden"> | |
| <div className="h-full bg-rust rounded-full" style={{ width: '100%', animation: 'pulse 1.5s ease-in-out infinite' }} /> | |
| </div> | |
| </div> | |
| </div> | |
| }> | |
| <LandingView | |
| ds={ds} | |
| datasets={datasets} | |
| onSelectDataset={handleSetDataset} | |
| onEnterDashboard={() => handleSetView('overview')} | |
| /> | |
| </Suspense> | |
| ) | |
| } | |
| return ( | |
| <div className="min-h-screen"> | |
| {/* Masthead */} | |
| <header className="thick-rule"> | |
| <div className="max-w-[1200px] mx-auto px-4 sm:px-8 pt-6 pb-5"> | |
| <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> | |
| <div> | |
| <button onClick={() => handleSetView('landing')} className="hover:opacity-70 transition-opacity"> | |
| <h1 className="serif text-[28px] leading-none tracking-tight"> | |
| Agent State Graph | |
| </h1> | |
| </button> | |
| <p className="text-ink-3 text-[12px] mt-1.5 tracking-wide uppercase"> | |
| Experiment Results | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-0 border border-rule overflow-x-auto min-w-0"> | |
| {datasets.map((d, i) => ( | |
| <button | |
| key={d.id} | |
| onClick={() => handleSetDataset(i)} | |
| className={cn( | |
| 'px-4 py-1.5 text-[12px] tracking-wide transition-colors whitespace-nowrap shrink-0', | |
| 'border-r border-rule last:border-r-0', | |
| activeDs === i | |
| ? 'bg-ink text-paper font-medium' | |
| : 'text-ink-2 hover:bg-paper-warm' | |
| )} | |
| > | |
| {d.meta.shortName} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </header> | |
| {/* Nav */} | |
| <nav className="ruled bg-paper-warm/50 overflow-x-auto"> | |
| <div className="max-w-[1200px] mx-auto px-4 sm:px-8 flex gap-0 min-w-0"> | |
| {dashboardTabs.map(([id, label]) => ( | |
| <button | |
| key={id} | |
| onClick={() => handleSetView(id)} | |
| className={cn( | |
| 'px-5 py-2.5 text-[12px] uppercase tracking-widest transition-colors relative whitespace-nowrap shrink-0', | |
| view === id | |
| ? 'text-ink font-medium' | |
| : 'text-ink-3 hover:text-ink-2' | |
| )} | |
| > | |
| {label} | |
| {view === id && ( | |
| <span className="absolute bottom-0 left-5 right-5 h-[2px] bg-rust" /> | |
| )} | |
| </button> | |
| ))} | |
| </div> | |
| </nav> | |
| {/* Content */} | |
| <main className="max-w-[1200px] mx-auto px-4 sm:px-8 py-6 sm:py-8"> | |
| {view === 'overview' && <OverviewView ds={ds} datasets={datasets} onSelectDataset={handleSetDataset} />} | |
| {view === 'baselines' && <BaselinesView ds={ds} datasets={datasets} selectedMethod={selectedMethod} onSelectMethod={handleSelectMethod} />} | |
| {view === 'convergence' && <ConvergenceView ds={ds} datasets={datasets} />} | |
| {view === 'graph' && <GraphView ds={ds} />} | |
| {view === 'traces' && <TracesView ds={ds} selectedTrace={selectedTrace} onSelectTrace={handleSelectTrace} />} | |
| {view === 'downstream' && <DownstreamView ds={ds} datasets={datasets} />} | |
| {view === 'failure' && <FailureView ds={ds} datasets={datasets} />} | |
| {view === 'memory' && <MemoryView ds={ds} datasets={datasets} />} | |
| {view === 'crossmodel' && <CrossModelView ds={ds} datasets={datasets} />} | |
| {view === 'monitor' && <MonitorView ds={ds} datasets={datasets} />} | |
| {view === 'counterfactual' && <CounterfactualView ds={ds} datasets={datasets} />} | |
| {view === 'about' && <AboutView datasets={datasets} />} | |
| </main> | |
| {/* Footer rule */} | |
| <footer className="max-w-[1200px] mx-auto px-4 sm:px-8 pb-12"> | |
| <div className="ruled pt-4 text-[11px] text-ink-4 flex justify-between"> | |
| <span>Agent State Graph / ICML 2026 AIWILD Workshop</span> | |
| <span className="mono">{new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short' })}</span> | |
| </div> | |
| </footer> | |
| </div> | |
| ) | |
| } | |
| // ---------- Overview ---------- | |
| function MiniGraph({ states, transitions }: { states: { id: string }[]; transitions: { source: string; target: string; subject: string }[] }) { | |
| const initialPos = useMemo(() => computeLayout(states, transitions), [states, transitions]) | |
| const [pos, setPos] = useState(initialPos) | |
| useEffect(() => setPos(initialPos), [initialPos]) | |
| const edgeSet = useMemo(() => { | |
| const s = new Set<string>() | |
| for (const t of transitions) s.add(`${t.source}|${t.target}`) | |
| return s | |
| }, [transitions]) | |
| // Zoom + pan | |
| const [zoom, setZoom] = useState(1) | |
| const [pan, setPan] = useState({ x: 0, y: 0 }) | |
| const containerDrag = useRef(false) | |
| const containerLast = useRef({ x: 0, y: 0 }) | |
| // Node drag | |
| const svgRef = useRef<SVGSVGElement>(null) | |
| const dragNode = useRef<string | null>(null) | |
| const toSVG = useCallback((clientX: number, clientY: number) => { | |
| const svg = svgRef.current | |
| if (!svg) return { x: 0, y: 0 } | |
| const pt = svg.createSVGPoint() | |
| pt.x = clientX; pt.y = clientY | |
| const ctm = svg.getScreenCTM() | |
| if (!ctm) return { x: 0, y: 0 } | |
| const svgPt = pt.matrixTransform(ctm.inverse()) | |
| return { x: svgPt.x, y: svgPt.y } | |
| }, []) | |
| const handleWheel = useCallback((e: React.WheelEvent) => { | |
| e.preventDefault() | |
| setZoom(z => Math.max(0.3, Math.min(5, z * (e.deltaY > 0 ? 0.9 : 1.1)))) | |
| }, []) | |
| const handlePointerDown = useCallback((e: React.PointerEvent) => { | |
| containerDrag.current = true | |
| containerLast.current = { x: e.clientX, y: e.clientY } | |
| }, []) | |
| const handlePointerMove = useCallback((e: React.PointerEvent) => { | |
| if (dragNode.current) { | |
| const { x, y } = toSVG(e.clientX, e.clientY) | |
| setPos(prev => ({ ...prev, [dragNode.current!]: { x, y } })) | |
| return | |
| } | |
| if (!containerDrag.current) return | |
| const dx = e.clientX - containerLast.current.x | |
| const dy = e.clientY - containerLast.current.y | |
| containerLast.current = { x: e.clientX, y: e.clientY } | |
| setPan(p => ({ x: p.x + dx, y: p.y + dy })) | |
| }, [toSVG]) | |
| const handlePointerUp = useCallback(() => { | |
| containerDrag.current = false | |
| dragNode.current = null | |
| }, []) | |
| const handleNodeDown = useCallback((e: React.PointerEvent, id: string) => { | |
| e.stopPropagation() | |
| dragNode.current = id | |
| ;(e.target as Element).setPointerCapture(e.pointerId) | |
| }, []) | |
| const handleDoubleClick = useCallback(() => { | |
| setZoom(1); setPan({ x: 0, y: 0 }) | |
| }, []) | |
| return ( | |
| <div className="mt-4 bg-paper-warm border border-rule p-4 overflow-hidden" | |
| onWheel={handleWheel} onPointerDown={handlePointerDown} | |
| onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} | |
| onDoubleClick={handleDoubleClick} | |
| style={{ cursor: 'grab', touchAction: 'none' }}> | |
| <div style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, transformOrigin: 'center center' }}> | |
| <svg ref={svgRef} viewBox="-20 -10 640 430" className="w-full" style={{ maxHeight: '360px' }}> | |
| <defs> | |
| <marker id="mg-a" viewBox="0 0 10 7" refX="0" refY="3.5" markerWidth="10" markerHeight="7" orient="auto" markerUnits="userSpaceOnUse"> | |
| <path d="M0,0 L10,3.5 L0,7" fill="#b5afa5" /> | |
| </marker> | |
| </defs> | |
| {transitions.map((t, i) => { | |
| const from = pos[t.source], to = pos[t.target] | |
| if (!from || !to) return null | |
| const al=10, r=14 | |
| if (t.source === t.target) { | |
| const loopR=30, cx1=from.x-loopR, cy1=from.y-loopR*1.5, cx2=from.x+loopR, cy2=from.y-loopR*1.5 | |
| const sa=Math.atan2(cy1-from.y,cx1-from.x), ea=Math.atan2(cy2-from.y,cx2-from.x) | |
| const epx=from.x+r*Math.cos(ea), epy=from.y+r*Math.sin(ea) | |
| const tdx=epx-cx2, tdy=epy-cy2, td=Math.sqrt(tdx*tdx+tdy*tdy) | |
| return <path key={i} d={`M${from.x+r*Math.cos(sa)},${from.y+r*Math.sin(sa)} C${cx1},${cy1} ${cx2},${cy2} ${epx-tdx/td*al},${epy-tdy/td*al}`} | |
| fill="none" stroke="#b5afa5" strokeWidth={0.8} strokeOpacity={0.5} markerEnd="url(#mg-a)" /> | |
| } | |
| const dx = to.x-from.x, dy = to.y-from.y, d = Math.sqrt(dx*dx+dy*dy), nx=dx/d, ny=dy/d | |
| if (edgeSet.has(`${t.target}|${t.source}`)) { | |
| const c=20, mx=(from.x+to.x)/2-ny*c, my=(from.y+to.y)/2+nx*c | |
| const sa=Math.atan2(my-from.y,mx-from.x), ea=Math.atan2(my-to.y,mx-to.x) | |
| const epx=to.x+r*Math.cos(ea), epy=to.y+r*Math.sin(ea) | |
| const tdx=epx-mx, tdy=epy-my, td=Math.sqrt(tdx*tdx+tdy*tdy) | |
| return <path key={i} d={`M${from.x+r*Math.cos(sa)},${from.y+r*Math.sin(sa)} Q${mx},${my} ${epx-tdx/td*al},${epy-tdy/td*al}`} | |
| fill="none" stroke="#b5afa5" strokeWidth={0.8} strokeOpacity={0.5} markerEnd="url(#mg-a)" /> | |
| } | |
| return <line key={i} x1={from.x+nx*r} y1={from.y+ny*r} x2={to.x-nx*(r+al)} y2={to.y-ny*(r+al)} | |
| stroke="#b5afa5" strokeWidth={0.8} strokeOpacity={0.5} markerEnd="url(#mg-a)" /> | |
| })} | |
| {states.map(s => { | |
| const p = pos[s.id] | |
| if (!p) return null | |
| const isInit = s.id === 'init' | |
| const label = s.id.length > 14 ? s.id.slice(0,12)+'..' : s.id | |
| return ( | |
| <g key={s.id} style={{ cursor: 'grab' }} onPointerDown={(e) => handleNodeDown(e, s.id)}> | |
| <circle cx={p.x} cy={p.y} r={14} fill="#fffdf9" stroke={isInit ? PALETTE.rust : '#b5afa5'} strokeWidth={isInit ? 2 : 1} /> | |
| {isInit && <circle cx={p.x} cy={p.y} r={10} fill="none" stroke={PALETTE.rust} strokeWidth={0.8} />} | |
| <text x={p.x} y={p.y+24} textAnchor="middle" fill="#8a8478" fontSize="9" fontFamily="IBM Plex Mono, monospace">{label}</text> | |
| </g> | |
| ) | |
| })} | |
| </svg> | |
| </div> | |
| <p className="text-[11px] text-ink-3 mt-2 text-center mono"> | |
| {states.length} states, {transitions.length} transitions | |
| </p> | |
| </div> | |
| ) | |
| } | |
| function OverviewView({ ds, datasets, onSelectDataset }: { ds: Dataset; datasets: Dataset[]; onSelectDataset: (i: number) => void }) { | |
| const ours = ds.baselines.find(b => b.category === 'ours')! | |
| const rpni = ds.baselines.find(b => b.method === 'rpni')! | |
| return ( | |
| <div className="space-y-8"> | |
| {/* Title block */} | |
| <div className="anim"> | |
| <div className="flex items-baseline gap-3"> | |
| <h2 className="serif text-[22px] italic">{ds.meta.name}</h2> | |
| <span className="mono text-[11px] text-ink-3">{ds.meta.venue}</span> | |
| </div> | |
| <p className="text-[13px] text-ink-2 mt-1 max-w-xl">{ds.meta.description}</p> | |
| </div> | |
| {/* Key figures - newspaper style */} | |
| <div className="anim d1 grid grid-cols-2 md:grid-cols-4 gap-0 thick-rule ruled-top"> | |
| <Figure label="Traces" value={ds.meta.traces.toString()} note={`${ds.meta.trainSize} / ${ds.meta.testSize}`} /> | |
| <Figure label="Test Fitness" value={fmt(ours.testFitness)} accent /> | |
| <Figure label="States" value={ours.states?.toString() || '–'} note={`vs ${rpni.states?.toLocaleString()} RPNI`} /> | |
| <Figure label="Compression" value={compression(ours.states || 1, rpni.states || 1)} accent note="vs RPNI" /> | |
| </div> | |
| {/* Final graph preview */} | |
| <div className="anim d2"> | |
| <SectionRule title="Learned Automaton" /> | |
| <MiniGraph states={ds.graph.states} transitions={ds.graph.transitions} /> | |
| </div> | |
| {/* Cross-dataset table */} | |
| <div className="anim d3"> | |
| <SectionRule title="Across Datasets" /> | |
| <table className="w-full text-[13px] mt-3"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-2 pr-4 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Dataset</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">N</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">|A|</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Fitness</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">ASG |Q|</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">RPNI |Q|</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Ratio</th> | |
| <th className="text-right py-2 pl-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Rand. Rej.</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {datasets.map((d, i) => { | |
| const o = d.baselines.find(b => b.category === 'ours')! | |
| const r = d.baselines.find(b => b.method === 'rpni')! | |
| const isActive = d.id === ds.id | |
| return ( | |
| <tr | |
| key={d.id} | |
| onClick={() => onSelectDataset(i)} | |
| className={cn('ruled cursor-pointer transition-colors', isActive ? 'bg-rust-dim' : 'hover:bg-ochre-dim')} | |
| > | |
| <td className="py-2.5 pr-4 font-medium"> | |
| {d.meta.name} | |
| {isActive && <span className="ml-2 text-[10px] text-rust italic serif">current</span>} | |
| </td> | |
| <td className="py-2.5 px-3 text-right mono">{fmtInt(d.meta.traces)}</td> | |
| <td className="py-2.5 px-3 text-right mono">{o.states ? o.states - 1 : '–'}</td> | |
| <td className="py-2.5 px-3 text-right mono text-rust font-medium">{fmt(o.testFitness)}</td> | |
| <td className="py-2.5 px-3 text-right mono font-medium">{o.states}</td> | |
| <td className="py-2.5 px-3 text-right mono text-ink-3">{fmtInt(r.states || 0)}</td> | |
| <td className="py-2.5 px-3 text-right mono">{compression(o.states || 1, r.states || 1)}</td> | |
| <td className="py-2.5 pl-3 text-right mono text-rust"> | |
| {o.randomAcceptRate !== null ? pct(1 - o.randomAcceptRate) : '–'} | |
| </td> | |
| </tr> | |
| ) | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| {/* Bar chart */} | |
| <div className="anim d3"> | |
| <SectionRule title="Test Fitness by Method" /> | |
| <div className="mt-4 bg-paper-warm border border-rule p-5"> | |
| <ResponsiveContainer width="100%" height={240}> | |
| <BarChart data={ds.baselines.filter(b => b.testFitness > 0).map(b => ({ ...b, displayLabel: methodLabel(b) }))} barCategoryGap="16%"> | |
| <CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID} vertical={false} /> | |
| <XAxis dataKey="displayLabel" tick={CHART_TICK} axisLine={CHART_AXIS} tickLine={false} /> | |
| <YAxis domain={[0, 1.05]} tick={CHART_TICK} axisLine={false} tickLine={false} tickFormatter={(v: number) => v.toFixed(1)} /> | |
| <Tooltip {...CHART_TOOLTIP} formatter={(v: number | undefined) => [fmt(v ?? 0, 4), 'Fitness']} /> | |
| <Bar dataKey="testFitness" radius={0}> | |
| {ds.baselines.filter(b => b.testFitness > 0).map((b) => ( | |
| <Cell key={b.method} fill={METHOD_COLORS[b.method] || PALETTE.iron} /> | |
| ))} | |
| </Bar> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ---------- Baselines ---------- | |
| function BaselinesView({ ds, datasets, selectedMethod, onSelectMethod }: { | |
| ds: Dataset; datasets: AllDatasets; selectedMethod: string | null; onSelectMethod: (m: string | null) => void | |
| }) { | |
| const sorted = useMemo(() => | |
| [...ds.baselines].sort((a, b) => b.testFitness - a.testFitness), | |
| [ds.baselines] | |
| ) | |
| const ours = ds.baselines.find(b => b.category === 'ours') | |
| const rpni = ds.baselines.find(b => b.method === 'rpni') | |
| // Cross-dataset data for the selected method | |
| const methodCrossData = useMemo(() => { | |
| if (!selectedMethod) return null | |
| return datasets.map(d => { | |
| const b = d.baselines.find(b => b.method === selectedMethod) | |
| return { dataset: d.meta.shortName, datasetName: d.meta.name, baseline: b || null } | |
| }) | |
| }, [selectedMethod, datasets]) | |
| const selBaseline = selectedMethod ? ds.baselines.find(b => b.method === selectedMethod) : null | |
| return ( | |
| <div className="space-y-8"> | |
| <div className="anim"> | |
| <h2 className="serif text-[22px] italic">Baseline Comparison</h2> | |
| <p className="text-[13px] text-ink-2 mt-1"> | |
| All methods evaluated on the same 80/20 train/test split with identical activity extraction. | |
| {' '}<span className="text-ink-4">Click a method for cross-dataset comparison.</span> | |
| </p> | |
| </div> | |
| <div className="anim d1 overflow-x-auto"> | |
| <table className="w-full text-[13px]"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-2 pr-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider w-[200px]">Method</th> | |
| <th className="text-left py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider w-[90px]">Type</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Test Fit.</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">|Q|</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">|delta|</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Rand.</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Perm.</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">PM Prec.</th> | |
| <th className="text-right py-2 pl-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Train Fit.</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {sorted.map((b) => { | |
| const isSelected = selectedMethod === b.method | |
| return ( | |
| <tr | |
| key={b.method} | |
| onClick={() => onSelectMethod(isSelected ? null : b.method)} | |
| className={cn( | |
| 'ruled cursor-pointer transition-colors', | |
| isSelected ? 'bg-slate-dim' : b.category === 'ours' ? 'bg-rust-dim hover:bg-ochre-dim' : 'hover:bg-ochre-dim' | |
| )} | |
| > | |
| <td className="py-2.5 pr-3"> | |
| <div className="flex items-center gap-2.5"> | |
| <span | |
| className="w-[3px] h-[14px] shrink-0" | |
| style={{ background: METHOD_COLORS[b.method] || PALETTE.iron }} | |
| /> | |
| <span className={cn('font-medium', b.category === 'ours' && 'text-rust')}> | |
| {methodLabel(b)} | |
| </span> | |
| {isSelected && <span className="text-[9px] text-slate serif italic ml-1">selected</span>} | |
| </div> | |
| </td> | |
| <td className="py-2.5 px-3 text-[11px] text-ink-3 uppercase tracking-wider"> | |
| {b.category === 'ours' ? 'Ours' : b.category === 'process-mining' ? 'PM' : b.category} | |
| </td> | |
| <td className={cn( | |
| 'py-2.5 px-3 text-right mono font-medium', | |
| b.testFitness >= 0.99 ? 'text-ink' : b.testFitness >= 0.5 ? 'text-ink-2' : 'text-rust' | |
| )}> | |
| {fmt(b.testFitness)} | |
| </td> | |
| <td className="py-2.5 px-3 text-right mono"> | |
| {b.states !== null ? fmtInt(b.states) : '–'} | |
| </td> | |
| <td className="py-2.5 px-3 text-right mono"> | |
| {b.transitions !== null ? fmtInt(b.transitions) : '–'} | |
| </td> | |
| <td className="py-2.5 px-3 text-right mono"> | |
| {b.randomAcceptRate !== null ? pct(b.randomAcceptRate) : '–'} | |
| </td> | |
| <td className="py-2.5 px-3 text-right mono"> | |
| {b.permutedAcceptRate !== null ? pct(b.permutedAcceptRate) : '–'} | |
| </td> | |
| <td className={cn( | |
| 'py-2.5 px-3 text-right mono', | |
| b.pmPrecision !== null && b.pmPrecision < 0.5 ? 'text-rust' : '' | |
| )}> | |
| {b.pmPrecision !== null ? fmt(b.pmPrecision) : '–'} | |
| </td> | |
| <td className="py-2.5 pl-3 text-right mono text-ink-3">{fmt(b.trainFitness)}</td> | |
| </tr> | |
| ) | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| {/* Method detail panel */} | |
| {selectedMethod && selBaseline && methodCrossData && ( | |
| <div className="anim border border-rule bg-paper-warm p-6 space-y-4"> | |
| <div className="flex items-baseline justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <span className="w-[4px] h-[18px] shrink-0" style={{ background: METHOD_COLORS[selectedMethod] || PALETTE.iron }} /> | |
| <h3 className="serif text-[18px] italic">{methodLabel(selBaseline)}</h3> | |
| <span className="text-[11px] text-ink-3 uppercase tracking-wider">{selBaseline.category}</span> | |
| </div> | |
| <button onClick={() => onSelectMethod(null)} className="text-[11px] text-ink-3 hover:text-ink transition-colors uppercase tracking-wider"> | |
| Close | |
| </button> | |
| </div> | |
| {selBaseline.note && ( | |
| <p className="text-[12px] text-ink-3 mono">{selBaseline.note}</p> | |
| )} | |
| <SectionRule title="Cross-Dataset Comparison" /> | |
| <table className="w-full text-[13px]"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-2 pr-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Dataset</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Test Fit.</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Train Fit.</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">|Q|</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">|delta|</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Rand.</th> | |
| <th className="text-right py-2 pl-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Perm.</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {methodCrossData.map(({ dataset, datasetName, baseline: b }) => ( | |
| <tr key={dataset} className="ruled"> | |
| <td className="py-2 pr-3 font-medium">{datasetName}</td> | |
| {b ? ( | |
| <> | |
| <td className="py-2 px-3 text-right mono font-medium">{fmt(b.testFitness)}</td> | |
| <td className="py-2 px-3 text-right mono text-ink-3">{fmt(b.trainFitness)}</td> | |
| <td className="py-2 px-3 text-right mono">{b.states !== null ? fmtInt(b.states) : '–'}</td> | |
| <td className="py-2 px-3 text-right mono">{b.transitions !== null ? fmtInt(b.transitions) : '–'}</td> | |
| <td className="py-2 px-3 text-right mono">{b.randomAcceptRate !== null ? pct(b.randomAcceptRate) : '–'}</td> | |
| <td className="py-2 pl-3 text-right mono">{b.permutedAcceptRate !== null ? pct(b.permutedAcceptRate) : '–'}</td> | |
| </> | |
| ) : ( | |
| <td colSpan={6} className="py-2 px-3 text-ink-4 text-center">Not available</td> | |
| )} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| {/* Observations */} | |
| <div className="anim d2 grid grid-cols-1 sm:grid-cols-3 gap-6"> | |
| <Observation | |
| num="i" | |
| title="Fitness alone is insufficient" | |
| body={`With |A| = ${(ds.baselines.find(b => b.category === 'ours')?.states ?? 1) - 1}, most methods achieve near-perfect replay fitness, so the alphabet alone rarely discriminates on fitness.`} | |
| /> | |
| <Observation | |
| num="ii" | |
| title="Precision reveals flower models" | |
| body="PM4Py miners show 0.20-0.49 precision despite 1.0 fitness. They accept nearly any symbol sequence." | |
| /> | |
| <Observation | |
| num="iii" | |
| title={`${compression(ours?.states || 1, rpni?.states || 1)} compression`} | |
| body={`Our ${ours?.states}-state FSM captures equivalent behavior to RPNI's ${rpni?.states?.toLocaleString()} states with no fitness loss.`} | |
| /> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ---------- Convergence ---------- | |
| function ConvergenceView({ ds, datasets }: { ds: Dataset; datasets: AllDatasets }) { | |
| // Not every dataset has an incremental-convergence run; fall back to one that does | |
| // so the tab always shows a real curve instead of an empty placeholder. | |
| const hasOwn = ds.convergence.curve.length > 0 | |
| const active = hasOwn ? ds : (datasets.find(d => (d.convergence?.curve?.length ?? 0) > 0) ?? ds) | |
| const conv = active.convergence | |
| const curve = conv.curve.map((p: { n: number; states: number; transitions: number; fitness: number }) => ({ | |
| ...p, | |
| transPerState: p.states > 0 ? +(p.transitions / p.states).toFixed(2) : 0, | |
| })) | |
| return ( | |
| <div className="space-y-8"> | |
| <div className="anim"> | |
| <h2 className="serif text-[22px] italic">Convergence</h2> | |
| <p className="text-[13px] text-ink-2 mt-1"> | |
| Structure stabilization as training traces increase. | |
| </p> | |
| {!hasOwn && active !== ds && ( | |
| <p className="text-[12px] text-ink-3 mt-2 bg-paper-warm border border-rule px-3 py-2"> | |
| {ds.meta.name} has no incremental-convergence run; showing <span className="font-medium">{active.meta.name}</span>. The paper's convergence analysis covers SWE-agent, SWE-smith, Mind2Web, and Who&When. | |
| </p> | |
| )} | |
| </div> | |
| {curve.length > 0 ? ( | |
| <div className="anim d1 grid grid-cols-1 sm:grid-cols-3 gap-0 thick-rule ruled-top"> | |
| <Figure | |
| label="State convergence" | |
| value={`${conv.stateConvergenceAt}`} | |
| note={`${((conv.stateConvergenceAt / active.meta.trainSize) * 100).toFixed(0)}% of train`} | |
| /> | |
| <Figure | |
| label="Transition conv." | |
| value={`${conv.transitionConvergenceAt}`} | |
| note={`${((conv.transitionConvergenceAt / active.meta.trainSize) * 100).toFixed(0)}% of train`} | |
| /> | |
| <Figure | |
| label="Fitness conv." | |
| value={`${conv.fitnessConvergenceAt}`} | |
| note={`${((conv.fitnessConvergenceAt / active.meta.trainSize) * 100).toFixed(0)}% of train`} | |
| accent | |
| /> | |
| </div> | |
| ) : ( | |
| <div className="anim d1 bg-paper-warm border border-rule p-5 text-[13px] text-ink-2"> | |
| Incremental-convergence data is not available for this dataset. See SWE-agent, | |
| SWE-smith, Mind2Web and Who&When in the paper for the convergence analysis. | |
| </div> | |
| )} | |
| {curve.length > 0 && ( | |
| <> | |
| <div className="anim d2"> | |
| <SectionRule title="Structure Evolution" /> | |
| <div className="mt-4 bg-paper-warm border border-rule p-5"> | |
| <ResponsiveContainer width="100%" height={320}> | |
| <LineChart data={curve}> | |
| <CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID} /> | |
| <XAxis dataKey="n" tick={CHART_TICK} axisLine={CHART_AXIS} tickLine={false} label={{ value: 'Training traces', position: 'insideBottom', offset: -5, style: { fontSize: 10, fill: '#8a8478' } }} /> | |
| <YAxis yAxisId="count" tick={{ ...CHART_TICK, fill: PALETTE.rust }} axisLine={false} tickLine={false} domain={['dataMin - 1', 'auto']} /> | |
| <YAxis yAxisId="fitness" orientation="right" tick={{ ...CHART_TICK, fill: PALETTE.teal }} axisLine={false} tickLine={false} domain={['dataMin - 0.005', 1.002]} tickFormatter={(v: number) => v.toFixed(3)} /> | |
| <Tooltip {...CHART_TOOLTIP} /> | |
| <Line yAxisId="count" type="stepAfter" dataKey="states" stroke={PALETTE.rust} strokeWidth={2.5} dot={{ r: 2.5, fill: PALETTE.rust, stroke: 'none' }} activeDot={{ r: 4 }} name="States" /> | |
| <Line yAxisId="count" type="stepAfter" dataKey="transPerState" stroke={PALETTE.slate} strokeWidth={2} dot={{ r: 2, fill: PALETTE.slate, stroke: 'none' }} activeDot={{ r: 4 }} name="Trans/State" strokeDasharray="6 3" /> | |
| <Line yAxisId="fitness" type="monotone" dataKey="fitness" stroke={PALETTE.teal} strokeWidth={2} dot={{ r: 2, fill: PALETTE.teal, stroke: 'none' }} activeDot={{ r: 4 }} name="Test Fitness" /> | |
| </LineChart> | |
| </ResponsiveContainer> | |
| <div className="flex gap-6 justify-center mt-3 text-[11px] text-ink-3"> | |
| <span className="flex items-center gap-1.5"> | |
| <span className="w-3 h-[2px] bg-rust inline-block" /> States | |
| </span> | |
| <span className="flex items-center gap-1.5"> | |
| <span className="w-3 h-[2px] bg-slate inline-block" /> Trans/State | |
| </span> | |
| <span className="flex items-center gap-1.5"> | |
| <span className="w-3 h-[2px] inline-block" style={{ background: PALETTE.teal }} /> Fitness | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="anim d3"> | |
| <SectionRule title="Test Fitness (detail)" /> | |
| <div className="mt-4 bg-paper-warm border border-rule p-5"> | |
| <ResponsiveContainer width="100%" height={200}> | |
| <LineChart data={curve}> | |
| <CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID} /> | |
| <XAxis dataKey="n" tick={CHART_TICK} axisLine={CHART_AXIS} tickLine={false} /> | |
| <YAxis domain={['dataMin - 0.005', 1.002]} tick={CHART_TICK} axisLine={false} tickLine={false} tickFormatter={(v: number) => v.toFixed(3)} /> | |
| <Tooltip {...CHART_TOOLTIP} formatter={(v: number | undefined) => [fmt(v ?? 0, 4), 'Fitness']} /> | |
| <Line type="monotone" dataKey="fitness" stroke={PALETTE.rust} strokeWidth={2} dot={{ r: 2.5, fill: PALETTE.rust, stroke: 'none' }} activeDot={{ r: 4 }} /> | |
| </LineChart> | |
| </ResponsiveContainer> | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // ---------- Graph View ---------- | |
| function GraphView({ ds }: { ds: Dataset }) { | |
| const { states, transitions } = ds.graph | |
| const initialPositions = useMemo(() => computeLayout(states, transitions), [states, transitions]) | |
| const [nodePositions, setNodePositions] = useState(initialPositions) | |
| useEffect(() => setNodePositions(initialPositions), [initialPositions]) | |
| const svgRef = useRef<SVGSVGElement>(null) | |
| const dragNode = useRef<string | null>(null) | |
| const [zoom, setZoom] = useState(1) | |
| const [pan, setPan] = useState({ x: 0, y: 0 }) | |
| const containerDrag = useRef(false) | |
| const containerLast = useRef({ x: 0, y: 0 }) | |
| const toSVG = useCallback((clientX: number, clientY: number) => { | |
| const svg = svgRef.current | |
| if (!svg) return { x: 0, y: 0 } | |
| const pt = svg.createSVGPoint() | |
| pt.x = clientX; pt.y = clientY | |
| const ctm = svg.getScreenCTM() | |
| if (!ctm) return { x: 0, y: 0 } | |
| const svgPt = pt.matrixTransform(ctm.inverse()) | |
| return { x: svgPt.x, y: svgPt.y } | |
| }, []) | |
| const handleWheel = useCallback((e: React.WheelEvent) => { | |
| e.preventDefault() | |
| setZoom(z => Math.max(0.3, Math.min(5, z * (e.deltaY > 0 ? 0.9 : 1.1)))) | |
| }, []) | |
| const handleContainerDown = useCallback((e: React.PointerEvent) => { | |
| containerDrag.current = true | |
| containerLast.current = { x: e.clientX, y: e.clientY } | |
| }, []) | |
| const handleNodePointerDown = useCallback((e: React.PointerEvent, id: string) => { | |
| e.stopPropagation() | |
| dragNode.current = id | |
| ;(e.target as Element).setPointerCapture(e.pointerId) | |
| }, []) | |
| const handlePointerMove = useCallback((e: React.PointerEvent) => { | |
| if (dragNode.current) { | |
| const { x, y } = toSVG(e.clientX, e.clientY) | |
| setNodePositions(prev => ({ ...prev, [dragNode.current!]: { x, y } })) | |
| return | |
| } | |
| if (!containerDrag.current) return | |
| const dx = e.clientX - containerLast.current.x | |
| const dy = e.clientY - containerLast.current.y | |
| containerLast.current = { x: e.clientX, y: e.clientY } | |
| setPan(p => ({ x: p.x + dx, y: p.y + dy })) | |
| }, [toSVG]) | |
| const handlePointerUp = useCallback(() => { dragNode.current = null; containerDrag.current = false }, []) | |
| const handleDoubleClick = useCallback(() => { setZoom(1); setPan({ x: 0, y: 0 }) }, []) | |
| // Build edge index for detecting bidirectional pairs | |
| const edgeSet = useMemo(() => { | |
| const s = new Set<string>() | |
| for (const t of transitions) s.add(`${t.source}|${t.target}`) | |
| return s | |
| }, [transitions]) | |
| // Get transition frequencies from downstream data | |
| const transFreq = useMemo(() => { | |
| const freq = new Map<string, number>() | |
| const bp = ds.downstream.behavioralProfiling | |
| if (bp) { | |
| let maxCount = 1 | |
| for (const tf of bp.transitionFrequency) { | |
| const parts = tf.transition.split('\u2192') | |
| if (parts.length === 2) { | |
| freq.set(`${parts[0]}|${parts[1]}`, tf.count) | |
| maxCount = Math.max(maxCount, tf.count) | |
| } | |
| } | |
| // Normalize to 0-1 | |
| for (const [k, v] of freq) freq.set(k, v / maxCount) | |
| } | |
| return freq | |
| }, [ds.downstream.behavioralProfiling]) | |
| // State visit counts for node sizing | |
| const stateVisits = useMemo(() => { | |
| const visits = new Map<string, number>() | |
| const bp = ds.downstream.behavioralProfiling | |
| if (bp) { | |
| let maxCount = 1 | |
| for (const sv of bp.stateVisitDistribution) { | |
| visits.set(sv.state, sv.count) | |
| maxCount = Math.max(maxCount, sv.count) | |
| } | |
| for (const [k, v] of visits) visits.set(k, v / maxCount) | |
| } | |
| return visits | |
| }, [ds.downstream.behavioralProfiling]) | |
| return ( | |
| <div className="space-y-8"> | |
| <div className="anim"> | |
| <h2 className="serif text-[22px] italic">Learned Automaton</h2> | |
| <p className="text-[13px] text-ink-2 mt-1"> | |
| <span className="mono">{states.length}</span> states, <span className="mono">{transitions.length}</span> transitions. | |
| Built from {ds.meta.trainSize} training traces. | |
| {transFreq.size > 0 && ' Edge thickness shows relative frequency.'} | |
| </p> | |
| </div> | |
| <div className="anim d1"> | |
| <div className="bg-paper-warm border border-rule p-6 overflow-hidden" | |
| onWheel={handleWheel} onPointerDown={handleContainerDown} | |
| onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} | |
| onDoubleClick={handleDoubleClick} | |
| style={{ cursor: 'grab', touchAction: 'none' }}> | |
| <div style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, transformOrigin: 'center center' }}> | |
| <svg ref={svgRef} viewBox="-20 -10 640 430" className="w-full" style={{ maxHeight: '520px' }}> | |
| <defs> | |
| <marker id="arrow" viewBox="0 0 10 7" refX="0" refY="3.5" markerWidth="10" markerHeight="7" orient="auto" markerUnits="userSpaceOnUse"> | |
| <path d="M0,0 L10,3.5 L0,7" fill="#8a8478" /> | |
| </marker> | |
| <marker id="arrow-rust" viewBox="0 0 10 7" refX="0" refY="3.5" markerWidth="10" markerHeight="7" orient="auto" markerUnits="userSpaceOnUse"> | |
| <path d="M0,0 L10,3.5 L0,7" fill={PALETTE.rust} /> | |
| </marker> | |
| </defs> | |
| {/* Edges */} | |
| {transitions.map((t, i) => { | |
| const from = nodePositions[t.source] | |
| const to = nodePositions[t.target] | |
| if (!from || !to) return null | |
| const freq = transFreq.get(`${t.source}|${t.target}`) ?? 0.1 | |
| const sw = 0.5 + freq * 2.5 // stroke width 0.5-3 | |
| const opacity = 0.3 + freq * 0.7 | |
| const al = 10 | |
| if (t.source === t.target) { | |
| const r = 14 + (stateVisits.get(t.source) || 0) * 6 | |
| const loopR = 35, cx1 = from.x - loopR, cy1 = from.y - loopR * 1.5 | |
| const cx2 = from.x + loopR, cy2 = from.y - loopR * 1.5 | |
| const sa = Math.atan2(cy1 - from.y, cx1 - from.x) | |
| const ea = Math.atan2(cy2 - from.y, cx2 - from.x) | |
| const epx = from.x + r * Math.cos(ea), epy = from.y + r * Math.sin(ea) | |
| const tdx = epx - cx2, tdy = epy - cy2, td = Math.sqrt(tdx * tdx + tdy * tdy) | |
| return ( | |
| <path key={i} | |
| d={`M${from.x + r * Math.cos(sa)},${from.y + r * Math.sin(sa)} C${cx1},${cy1} ${cx2},${cy2} ${epx - tdx / td * al},${epy - tdy / td * al}`} | |
| fill="none" stroke="#b5afa5" strokeWidth={sw} strokeOpacity={opacity} strokeLinecap="round" | |
| markerEnd="url(#arrow)" /> | |
| ) | |
| } | |
| const dx = to.x - from.x, dy = to.y - from.y | |
| const dist = Math.sqrt(dx * dx + dy * dy) | |
| const nx = dx / dist, ny = dy / dist | |
| const isBidirectional = edgeSet.has(`${t.target}|${t.source}`) | |
| const nodeR = 14 + (stateVisits.get(t.target) || 0) * 6 | |
| if (isBidirectional) { | |
| const curve = 25 | |
| const mx = (from.x + to.x) / 2 - ny * curve | |
| const my = (from.y + to.y) / 2 + nx * curve | |
| const sa = Math.atan2(my - from.y, mx - from.x) | |
| const ea = Math.atan2(my - to.y, mx - to.x) | |
| const srcR = 14 + (stateVisits.get(t.source) || 0) * 6 | |
| const epx = to.x + nodeR * Math.cos(ea), epy = to.y + nodeR * Math.sin(ea) | |
| const tdx = epx - mx, tdy = epy - my, td = Math.sqrt(tdx * tdx + tdy * tdy) | |
| return ( | |
| <path key={i} | |
| d={`M${from.x + srcR * Math.cos(sa)},${from.y + srcR * Math.sin(sa)} Q${mx},${my} ${epx - tdx / td * al},${epy - tdy / td * al}`} | |
| fill="none" stroke="#b5afa5" strokeWidth={sw} strokeOpacity={opacity} strokeLinecap="round" | |
| markerEnd="url(#arrow)" /> | |
| ) | |
| } | |
| return ( | |
| <line key={i} | |
| x1={from.x + nx * nodeR} y1={from.y + ny * nodeR} | |
| x2={to.x - nx * (nodeR + al)} y2={to.y - ny * (nodeR + al)} | |
| stroke="#b5afa5" strokeWidth={sw} strokeOpacity={opacity} strokeLinecap="round" | |
| markerEnd="url(#arrow)" /> | |
| ) | |
| })} | |
| {/* Nodes */} | |
| {states.map((s) => { | |
| const pos = nodePositions[s.id] | |
| if (!pos) return null | |
| const isInit = s.id === 'init' | |
| const visitNorm = stateVisits.get(s.id) || 0 | |
| const r = isInit ? 13 : 14 + visitNorm * 6 | |
| const label = s.id.length > 20 ? s.id.slice(0, 18) + '..' : s.id | |
| return ( | |
| <g key={s.id} style={{ cursor: 'grab' }} | |
| onPointerDown={(e) => handleNodePointerDown(e, s.id)}> | |
| <circle | |
| cx={pos.x} cy={pos.y} r={r} | |
| fill={isInit ? '#f5f0e8' : `rgba(196, 85, 58, ${0.04 + visitNorm * 0.12})`} | |
| stroke={isInit ? PALETTE.rust : visitNorm > 0.5 ? PALETTE.rust : '#b5afa5'} | |
| strokeWidth={isInit ? 2 : visitNorm > 0.5 ? 1.5 : 1} | |
| /> | |
| {isInit && ( | |
| <circle cx={pos.x} cy={pos.y} r={r - 3} fill="none" stroke={PALETTE.rust} strokeWidth={1} /> | |
| )} | |
| <text | |
| x={pos.x} y={pos.y + r + 12} textAnchor="middle" | |
| fill="#4a4540" fontSize="8.5" fontFamily="IBM Plex Mono, monospace" | |
| > | |
| {label} | |
| </text> | |
| </g> | |
| ) | |
| })} | |
| </svg> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="anim d2"> | |
| <SectionRule title="State Alphabet" /> | |
| <div className="flex flex-wrap gap-1.5 mt-3"> | |
| {states.map((s) => ( | |
| <span key={s.id} className="mono text-[11px] text-ink-2 px-2 py-0.5 rounded border border-rule bg-paper-warm whitespace-nowrap"> | |
| {s.id} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Transition table */} | |
| <div className="anim d3"> | |
| <SectionRule title="Transition Table" /> | |
| <table className="w-full text-[12px] mt-3"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-1.5 pr-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">From</th> | |
| <th className="text-center py-1.5 px-1 text-[10px] text-ink-3 font-normal w-[20px]"></th> | |
| <th className="text-left py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">To</th> | |
| <th className="text-left py-1.5 pl-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Type</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {transitions.map((t, i) => ( | |
| <tr key={i} className="ruled"> | |
| <td className="py-1 pr-3 mono text-[11px]">{t.source}</td> | |
| <td className="py-1 px-1 text-center text-ink-4">{'\u2192'}</td> | |
| <td className="py-1 px-3 mono text-[11px]">{t.target}</td> | |
| <td className="py-1 pl-3 text-[10px] text-ink-3"> | |
| {t.source === t.target ? 'self-loop' : t.subject || 'transition'} | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ---------- Traces View ---------- | |
| // Break a long trace ID into readable parts: model tag, persona, trailing index, | |
| // and the remaining scenario core. Falls back gracefully for non-tau2 ids. | |
| function parseTraceId(id: string, datasetId: string) { | |
| let rest = id | |
| if (rest.startsWith(datasetId + '-')) rest = rest.slice(datasetId.length + 1) | |
| let index: string | null = null | |
| const im = rest.match(/-(\d+)$/) | |
| if (im) { index = im[1]; rest = rest.slice(0, rest.length - im[0].length) } | |
| let model: string | null = null | |
| // Only tau2-bench and OSWorld ids embed a model tag (model_scenario...); other | |
| // datasets (sweagent repo__task, webarena-idx, uuids) have no model prefix. | |
| if (datasetId.startsWith('tau2bench-') || datasetId === 'osworld') { | |
| const um = rest.match(/^([^_[]+)_/) | |
| if (um) { model = um[1].replace(/-\d{4}-\d{2}-\d{2}$/, ''); rest = rest.slice(um[0].length) } | |
| } | |
| let persona: string | null = null | |
| const pm = rest.match(/\[PERSONA:([^\]]+)\]/i) | |
| if (pm) { persona = pm[1]; rest = rest.slice(0, pm.index) + rest.slice((pm.index ?? 0) + pm[0].length) } | |
| return { model, persona, index, rest: rest.replace(/^[-_\s]+|[-_\s]+$/g, '') } | |
| } | |
| function TracesView({ ds, selectedTrace, onSelectTrace }: { | |
| ds: Dataset; selectedTrace: string | null; onSelectTrace: (id: string | null) => void | |
| }) { | |
| const [splitFilter, setSplitFilter] = useState<'all' | 'train' | 'test'>('all') | |
| const [successFilter, setSuccessFilter] = useState<'all' | 'success' | 'failure'>('all') | |
| const [search, setSearch] = useState('') | |
| const [page, setPage] = useState(0) | |
| const PAGE_SIZE = 50 | |
| // Reset page on filter change | |
| useEffect(() => { setPage(0) }, [splitFilter, successFilter, search, ds.id]) | |
| const filtered = useMemo(() => { | |
| let t = ds.traces | |
| if (splitFilter !== 'all') t = t.filter(tr => tr.split === splitFilter) | |
| if (successFilter === 'success') t = t.filter(tr => tr.success === true) | |
| if (successFilter === 'failure') t = t.filter(tr => tr.success === false) | |
| if (search) { | |
| const q = search.toLowerCase() | |
| t = t.filter(tr => tr.id.toLowerCase().includes(q)) | |
| } | |
| return t | |
| }, [ds.traces, splitFilter, successFilter, search]) | |
| const totalPages = Math.ceil(filtered.length / PAGE_SIZE) | |
| const pageTraces = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) | |
| const trainCount = ds.traces.filter(t => t.split === 'train').length | |
| const testCount = ds.traces.filter(t => t.split === 'test').length | |
| const successCount = ds.traces.filter(t => t.success === true).length | |
| const failureCount = ds.traces.filter(t => t.success === false).length | |
| const unknownCount = ds.traces.filter(t => t.success === null).length | |
| // Navigate to the page containing the selected trace | |
| useEffect(() => { | |
| if (selectedTrace) { | |
| const idx = filtered.findIndex(t => t.id === selectedTrace) | |
| if (idx >= 0) setPage(Math.floor(idx / PAGE_SIZE)) | |
| } | |
| }, [selectedTrace, filtered]) | |
| return ( | |
| <div className="space-y-6"> | |
| <div className="anim"> | |
| <h2 className="serif text-[22px] italic">Traces</h2> | |
| <p className="text-[13px] text-ink-2 mt-1"> | |
| {ds.traces.length} traces: {trainCount} train, {testCount} test. | |
| {successCount > 0 && ` ${successCount} success, ${failureCount} failure.`} | |
| {unknownCount > 0 && ` ${unknownCount} unlabeled.`} | |
| {' '}<span className="text-ink-4">Click a trace for details.</span> | |
| </p> | |
| </div> | |
| {/* Filters */} | |
| <div className="anim d1 flex items-center gap-4 flex-wrap"> | |
| <div className="flex items-center gap-0 border border-rule"> | |
| {(['all', 'train', 'test'] as const).map(v => ( | |
| <button | |
| key={v} | |
| onClick={() => setSplitFilter(v)} | |
| className={cn( | |
| 'px-3 py-1 text-[11px] uppercase tracking-wider border-r border-rule last:border-r-0 transition-colors', | |
| splitFilter === v ? 'bg-ink text-paper font-medium' : 'text-ink-3 hover:bg-paper-warm' | |
| )} | |
| > | |
| {v === 'all' ? `All (${ds.traces.length})` : v === 'train' ? `Train (${trainCount})` : `Test (${testCount})`} | |
| </button> | |
| ))} | |
| </div> | |
| {successCount + failureCount > 0 && ( | |
| <div className="flex items-center gap-0 border border-rule"> | |
| {(['all', 'success', 'failure'] as const).map(v => ( | |
| <button | |
| key={v} | |
| onClick={() => setSuccessFilter(v)} | |
| className={cn( | |
| 'px-3 py-1 text-[11px] uppercase tracking-wider border-r border-rule last:border-r-0 transition-colors', | |
| successFilter === v ? 'bg-ink text-paper font-medium' : 'text-ink-3 hover:bg-paper-warm' | |
| )} | |
| > | |
| {v === 'all' ? 'Any' : v === 'success' ? `Pass (${successCount})` : `Fail (${failureCount})`} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| <input | |
| type="text" | |
| value={search} | |
| onChange={e => setSearch(e.target.value)} | |
| placeholder="Search trace ID..." | |
| className="px-3 py-1 text-[12px] bg-transparent border border-rule mono text-ink placeholder:text-ink-4 outline-none focus:border-ink-3 w-[280px]" | |
| /> | |
| <span className="text-[11px] text-ink-3 ml-auto mono">{filtered.length} results</span> | |
| </div> | |
| {/* Table */} | |
| <div className="anim d2"> | |
| <table className="w-full text-[13px]"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-2 pr-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider w-8">#</th> | |
| <th className="text-left py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Trace ID</th> | |
| <th className="text-center py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider w-[70px]">Split</th> | |
| <th className="text-center py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider w-[70px]">Label</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider w-[90px]">Full Fit.</th> | |
| <th className="text-right py-2 pl-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider w-[90px]">Succ. Fit.</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {pageTraces.map((t, i) => { | |
| const isSelected = selectedTrace === t.id | |
| return ( | |
| <Fragment key={`${t.id}-${page * PAGE_SIZE + i}`}> | |
| <tr | |
| onClick={() => onSelectTrace(isSelected ? null : t.id)} | |
| className={cn( | |
| 'ruled cursor-pointer transition-colors', | |
| isSelected ? 'bg-slate-dim' : 'hover:bg-ochre-dim' | |
| )} | |
| > | |
| <td className="py-2 pr-3 mono text-ink-4 text-[11px]">{page * PAGE_SIZE + i + 1}</td> | |
| <td className="py-2 px-3 text-[11px] max-w-0" title={t.id}> | |
| {(() => { | |
| const p = parseTraceId(t.id, ds.id) | |
| return ( | |
| <div className="flex items-center gap-1.5 min-w-0"> | |
| {p.model && <span className="mono text-[10px] px-1.5 py-0.5 rounded border border-rule bg-paper-warm whitespace-nowrap shrink-0">{p.model}</span>} | |
| <span className="mono text-ink-2 truncate min-w-0">{p.rest || t.id}</span> | |
| {p.persona && <span className="mono text-[10px] px-1.5 py-0.5 rounded border border-rule text-ink-3 whitespace-nowrap shrink-0">{p.persona}</span>} | |
| {p.index != null && <span className="mono text-ink-4 text-[10px] shrink-0">#{p.index}</span>} | |
| {isSelected && <span className="text-[9px] text-slate serif italic shrink-0">▾</span>} | |
| </div> | |
| ) | |
| })()} | |
| </td> | |
| <td className="py-2 px-3 text-center"> | |
| <span className={cn( | |
| 'text-[10px] uppercase tracking-wider font-medium', | |
| t.split === 'test' ? 'text-rust' : 'text-ink-3' | |
| )}> | |
| {t.split} | |
| </span> | |
| </td> | |
| <td className="py-2 px-3 text-center"> | |
| {t.success === true && <span className="text-[10px] uppercase tracking-wider text-slate font-medium">pass</span>} | |
| {t.success === false && <span className="text-[10px] uppercase tracking-wider text-rust font-medium">fail</span>} | |
| {t.success === null && <span className="text-ink-4">--</span>} | |
| </td> | |
| <td className="py-2 px-3 text-right mono"> | |
| {t.fullFitness !== null ? fmt(t.fullFitness) : ''} | |
| </td> | |
| <td className="py-2 pl-3 text-right mono"> | |
| {t.successFitness !== null ? fmt(t.successFitness) : ''} | |
| </td> | |
| </tr> | |
| {isSelected && ( | |
| <tr> | |
| <td colSpan={6} className="p-0"> | |
| <div className="fold-open"> | |
| <TraceDetailPanel trace={t} onClose={() => onSelectTrace(null)} /> | |
| </div> | |
| </td> | |
| </tr> | |
| )} | |
| </Fragment> | |
| ) | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| {/* Pagination */} | |
| {totalPages > 1 && ( | |
| <div className="flex items-center justify-between"> | |
| <button | |
| onClick={() => setPage(p => Math.max(0, p - 1))} | |
| disabled={page === 0} | |
| className={cn('px-3 py-1 text-[11px] uppercase tracking-wider border border-rule transition-colors', page === 0 ? 'text-ink-4' : 'text-ink-2 hover:bg-paper-warm')} | |
| > | |
| Prev | |
| </button> | |
| <span className="text-[11px] text-ink-3 mono"> | |
| {page + 1} / {totalPages} | |
| </span> | |
| <button | |
| onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} | |
| disabled={page >= totalPages - 1} | |
| className={cn('px-3 py-1 text-[11px] uppercase tracking-wider border border-rule transition-colors', page >= totalPages - 1 ? 'text-ink-4' : 'text-ink-2 hover:bg-paper-warm')} | |
| > | |
| Next | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // ---------- Trace Detail Panel ---------- | |
| function TraceDetailPanel({ trace, onClose }: { | |
| trace: Trace; onClose: () => void | |
| }) { | |
| const [showAll, setShowAll] = useState(false) | |
| const STEP_LIMIT = 40 | |
| const failSteps = trace.steps.filter(s => !s.consumed) | |
| const uniqueStates = [...new Set(trace.stateSequence.filter(s => !s.startsWith('FAIL:')))] | |
| const maxVisits = Math.max(1, ...Object.values(trace.stateVisits)) | |
| // Steps to display (truncated or all) | |
| const displaySteps = showAll ? trace.steps : trace.steps.slice(0, STEP_LIMIT) | |
| const hasMore = trace.steps.length > STEP_LIMIT | |
| return ( | |
| <div className="anim border border-rule bg-paper-warm space-y-0"> | |
| {/* Header */} | |
| <div className="flex items-start justify-between p-5 pb-4 ruled"> | |
| <div> | |
| <div className="flex items-center gap-3"> | |
| <h3 className="serif text-[18px] italic">Trace Detail</h3> | |
| <span className={cn( | |
| 'text-[10px] uppercase tracking-wider font-medium px-1.5 py-0.5 border', | |
| trace.split === 'test' ? 'text-rust border-rust/30' : 'text-ink-3 border-rule' | |
| )}> | |
| {trace.split} | |
| </span> | |
| {trace.success === true && <span className="text-[10px] uppercase tracking-wider text-slate font-medium">pass</span>} | |
| {trace.success === false && <span className="text-[10px] uppercase tracking-wider text-rust font-medium">fail</span>} | |
| </div> | |
| <p className="mono text-[11px] text-ink-3 mt-1 break-all">{trace.id}</p> | |
| </div> | |
| <button onClick={onClose} className="text-[11px] text-ink-3 hover:text-ink transition-colors uppercase tracking-wider shrink-0 ml-4"> | |
| Close | |
| </button> | |
| </div> | |
| {/* Key metrics */} | |
| <div className="grid grid-cols-2 md:grid-cols-5 gap-0 ruled"> | |
| <MiniStat label="Fitness" value={fmt(trace.fitness)} accent={trace.fitness >= 0.99} /> | |
| <MiniStat label="Consumed" value={`${trace.consumed}/${trace.total}`} /> | |
| <MiniStat label="Failures" value={failSteps.length.toString()} accent={failSteps.length > 0} warn /> | |
| <MiniStat label="Unique States" value={uniqueStates.length.toString()} /> | |
| <MiniStat label="First Fail" value={trace.firstFailIdx >= 0 ? `Step ${trace.firstFailIdx}` : 'None'} /> | |
| </div> | |
| {trace.fullFitness !== null && ( | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-0 ruled"> | |
| <MiniStat label="Full-model Fit." value={fmt(trace.fullFitness)} /> | |
| <MiniStat label="Success-model Fit." value={trace.successFitness !== null ? fmt(trace.successFitness) : '–'} /> | |
| <MiniStat | |
| label="Delta" | |
| value={trace.successFitness !== null && trace.fullFitness !== null | |
| ? fmt(Math.abs(trace.fullFitness - trace.successFitness), 4) | |
| : '–'} | |
| /> | |
| </div> | |
| )} | |
| {/* State visits heatmap */} | |
| {uniqueStates.length > 0 && ( | |
| <div className="p-5 ruled"> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-3 font-medium">State Visits</div> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {uniqueStates.map(state => { | |
| const count = trace.stateVisits[state] || 0 | |
| const intensity = count / maxVisits | |
| return ( | |
| <div | |
| key={state} | |
| className="flex flex-col items-center" | |
| title={`${state}: ${count} visit${count !== 1 ? 's' : ''}`} | |
| > | |
| <div | |
| className="px-2 py-1 text-[10px] mono border border-rule" | |
| style={{ | |
| background: intensity > 0.6 | |
| ? `rgba(196, 85, 58, ${0.08 + intensity * 0.18})` | |
| : intensity > 0.3 | |
| ? `rgba(196, 154, 58, ${0.06 + intensity * 0.12})` | |
| : `rgba(61, 90, 128, ${0.04 + intensity * 0.08})`, | |
| }} | |
| > | |
| {state.length > 20 ? state.slice(0, 18) + '..' : state} | |
| </div> | |
| <span className="text-[9px] text-ink-4 mt-0.5 mono">{count}</span> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* State sequence flow */} | |
| {trace.stateSequence.length > 0 && ( | |
| <div className="p-5 ruled overflow-x-auto"> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-3 font-medium">State Sequence</div> | |
| <div className="flex items-center gap-0 min-w-0 flex-wrap"> | |
| {trace.stateSequence.slice(0, 60).map((state, i) => { | |
| const isFail = state.startsWith('FAIL:') | |
| return ( | |
| <span key={i} className="flex items-center shrink-0"> | |
| <span className={cn( | |
| 'text-[10px] mono px-1.5 py-0.5', | |
| isFail ? 'text-rust bg-rust-dim font-medium' : 'text-ink-2' | |
| )}> | |
| {isFail ? state.replace('FAIL:', '') : state} | |
| </span> | |
| {i < trace.stateSequence.length - 1 && i < 59 && ( | |
| <span className={cn('text-[10px] mx-0.5', isFail ? 'text-rust' : 'text-ink-4')}> | |
| {isFail ? '!' : '\u2192'} | |
| </span> | |
| )} | |
| </span> | |
| ) | |
| })} | |
| {trace.stateSequence.length > 60 && ( | |
| <span className="text-[10px] text-ink-4 ml-1 mono">+{trace.stateSequence.length - 60} more</span> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* Step-by-step FSM traversal */} | |
| {trace.steps.length > 0 && ( | |
| <div className="p-5"> | |
| <div className="flex items-baseline justify-between mb-3"> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest font-medium"> | |
| FSM Traversal ({trace.steps.length} steps) | |
| </div> | |
| {hasMore && ( | |
| <button | |
| onClick={() => setShowAll(!showAll)} | |
| className="text-[11px] text-ink-3 hover:text-ink transition-colors uppercase tracking-wider" | |
| > | |
| {showAll ? 'Show less' : `Show all ${trace.steps.length}`} | |
| </button> | |
| )} | |
| </div> | |
| <table className="w-full text-[12px]"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-1.5 pr-2 text-[10px] text-ink-3 font-normal uppercase tracking-wider w-[40px]">Step</th> | |
| <th className="text-left py-1.5 px-2 text-[10px] text-ink-3 font-normal uppercase tracking-wider w-[60px]">Role</th> | |
| <th className="text-left py-1.5 px-2 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Activity</th> | |
| <th className="text-left py-1.5 px-2 text-[10px] text-ink-3 font-normal uppercase tracking-wider w-[130px]">From</th> | |
| <th className="text-center py-1.5 px-1 text-[10px] text-ink-3 font-normal w-[20px]"></th> | |
| <th className="text-left py-1.5 px-2 text-[10px] text-ink-3 font-normal uppercase tracking-wider w-[130px]">To</th> | |
| <th className="text-center py-1.5 pl-2 text-[10px] text-ink-3 font-normal uppercase tracking-wider w-[50px]">Status</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {displaySteps.map((step) => { | |
| const isFail = !step.consumed | |
| const isFirstFail = step.idx === trace.firstFailIdx | |
| return ( | |
| <tr | |
| key={step.idx} | |
| className={cn( | |
| 'ruled', | |
| isFirstFail ? 'bg-rust-dim' : isFail ? 'bg-rust-dim/50' : '' | |
| )} | |
| > | |
| <td className="py-1.5 pr-2 mono text-[10px] text-ink-4">{step.idx}</td> | |
| <td className="py-1.5 px-2 mono text-[10px] text-ink-3">{step.role}</td> | |
| <td className="py-1.5 px-2 mono text-[10px] truncate max-w-0" title={step.activity}> | |
| {step.activity} | |
| </td> | |
| <td className="py-1.5 px-2 mono text-[10px] truncate max-w-0" title={step.fromState}> | |
| {step.fromState} | |
| </td> | |
| <td className="py-1.5 px-1 text-center text-[10px]"> | |
| {isFail | |
| ? <span className="text-rust font-bold">!</span> | |
| : <span className="text-ink-4">{'\u2192'}</span> | |
| } | |
| </td> | |
| <td className={cn( | |
| 'py-1.5 px-2 mono text-[10px] truncate max-w-0', | |
| isFail ? 'text-rust' : '' | |
| )} title={step.toState || 'REJECTED'}> | |
| {step.toState || 'REJECTED'} | |
| </td> | |
| <td className="py-1.5 pl-2 text-center"> | |
| {isFirstFail ? ( | |
| <span className="text-[9px] text-rust font-medium uppercase tracking-wider">1st fail</span> | |
| ) : isFail ? ( | |
| <span className="text-[9px] text-rust/70 uppercase tracking-wider">fail</span> | |
| ) : ( | |
| <span className="text-[9px] text-ink-4 uppercase tracking-wider">ok</span> | |
| )} | |
| </td> | |
| </tr> | |
| ) | |
| })} | |
| </tbody> | |
| </table> | |
| {!showAll && hasMore && ( | |
| <div className="mt-2 text-center"> | |
| <button | |
| onClick={() => setShowAll(true)} | |
| className="text-[11px] text-ink-3 hover:text-ink transition-colors mono" | |
| > | |
| {trace.steps.length - STEP_LIMIT} more steps... | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {trace.steps.length === 0 && ( | |
| <div className="p-5 text-center text-[12px] text-ink-4 italic serif"> | |
| No replay data available for this trace | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| function MiniStat({ label, value, accent, warn }: { label: string; value: string; accent?: boolean; warn?: boolean }) { | |
| return ( | |
| <div className="py-3 px-5 border-r border-rule last:border-r-0"> | |
| <div className="text-[9px] text-ink-4 uppercase tracking-widest">{label}</div> | |
| <div className={cn( | |
| 'mono text-[15px] mt-0.5 font-light', | |
| warn && accent ? 'text-rust' : accent ? 'text-rust' : 'text-ink' | |
| )}> | |
| {value} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ---------- Downstream View ---------- | |
| function DownstreamView({ ds, datasets }: { ds: Dataset; datasets: AllDatasets }) { | |
| const down = ds.downstream | |
| const bp = down.behavioralProfiling | |
| const sa = down.structuralAnalysis | |
| const fp = down.fitnessProfile | |
| const df = down.differentialFSM | |
| return ( | |
| <div className="space-y-8"> | |
| <div className="anim"> | |
| <h2 className="serif text-[22px] italic">Downstream Analysis</h2> | |
| <p className="text-[13px] text-ink-2 mt-1"> | |
| Behavioral profiling, structural analysis, and anomaly detection from the learned FSM. | |
| </p> | |
| </div> | |
| {/* Fitness profile */} | |
| {fp && ( | |
| <div className="anim d1"> | |
| <SectionRule title="Fitness Profile" /> | |
| <div className="grid grid-cols-2 md:grid-cols-5 gap-0 thick-rule ruled-top mt-3"> | |
| <Figure label="Mean" value={fmt(fp.mean)} accent /> | |
| <Figure label="Std Dev" value={fmt(fp.std, 4)} /> | |
| <Figure label="Min" value={fmt(fp.min)} /> | |
| <Figure label="Max" value={fmt(fp.max)} /> | |
| <Figure label="Anomalies" value={fp.anomalyCount.toString()} note={`of ${ds.meta.traces}`} /> | |
| </div> | |
| {fp.histogram.length > 0 && ( | |
| <div className="mt-4 bg-paper-warm border border-rule p-5"> | |
| <ResponsiveContainer width="100%" height={200}> | |
| <BarChart data={fp.histogram.map(h => ({ range: `${h.min.toFixed(2)}`, count: h.count }))}> | |
| <CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID} vertical={false} /> | |
| <XAxis dataKey="range" tick={CHART_TICK} axisLine={CHART_AXIS} tickLine={false} /> | |
| <YAxis tick={CHART_TICK} axisLine={false} tickLine={false} /> | |
| <Tooltip {...CHART_TOOLTIP} /> | |
| <Bar dataKey="count" fill={PALETTE.rust} radius={0} /> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| <div className="text-center text-[10px] text-ink-4 mt-1">Fitness distribution (bin lower bound)</div> | |
| </div> | |
| )} | |
| {fp.anomalies.length > 0 && ( | |
| <div className="mt-3"> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-2 font-medium">Anomalous Traces</div> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {fp.anomalies.map(id => ( | |
| <span key={id} className="mono text-[10px] px-2 py-0.5 bg-rust-dim text-rust border border-rust/20"> | |
| {id.length > 40 ? id.slice(0, 38) + '..' : id} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Behavioral profiling */} | |
| {bp && ( | |
| <div className="anim d2"> | |
| <SectionRule title="Behavioral Profiling" /> | |
| <div className="grid grid-cols-2 gap-0 thick-rule ruled-top mt-3"> | |
| <Figure label="Transition Coverage" value={pct(bp.transitionCoverage)} accent /> | |
| <Figure label="Avg Unique States/Trace" value={fmt(bp.avgUniqueStatesPerTrace, 1)} /> | |
| </div> | |
| <div className="grid grid-cols-2 gap-6 mt-6"> | |
| {/* State visits */} | |
| <div> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-2 font-medium">State Visit Distribution</div> | |
| <table className="w-full text-[12px]"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-1.5 pr-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">State</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Count</th> | |
| <th className="text-right py-1.5 pl-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Per Trace</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {bp.stateVisitDistribution.map(sv => ( | |
| <tr key={sv.state} className="ruled"> | |
| <td className="py-1.5 pr-3 mono text-[11px]">{sv.state}</td> | |
| <td className="py-1.5 px-3 text-right mono text-[11px]">{fmtInt(sv.count)}</td> | |
| <td className="py-1.5 pl-3 text-right mono text-[11px] text-ink-3">{fmt(sv.fraction, 2)}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| {/* Top transitions */} | |
| <div> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-2 font-medium">Top Transitions</div> | |
| <table className="w-full text-[12px]"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-1.5 pr-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Transition</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Count</th> | |
| <th className="text-right py-1.5 pl-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Per Trace</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {bp.transitionFrequency.slice(0, 10).map(tf => ( | |
| <tr key={tf.transition} className="ruled"> | |
| <td className="py-1.5 pr-3 mono text-[10px]">{tf.transition}</td> | |
| <td className="py-1.5 px-3 text-right mono text-[11px]">{fmtInt(tf.count)}</td> | |
| <td className="py-1.5 pl-3 text-right mono text-[11px] text-ink-3">{fmt(tf.fraction, 2)}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| {bp.deadTransitions.length > 0 && ( | |
| <div className="mt-4"> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-2 font-medium">Dead Transitions (in FSM, never observed in data)</div> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {bp.deadTransitions.map(dt => ( | |
| <span key={dt} className="mono text-[10px] px-2 py-0.5 bg-ochre-dim text-ink-2 border border-ochre/20"> | |
| {dt} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Structural analysis */} | |
| {sa && ( | |
| <div className="anim d3"> | |
| <SectionRule title="Structural Analysis" /> | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-0 thick-rule ruled-top mt-3"> | |
| <Figure label="Graph Density" value={fmt(sa.density)} /> | |
| <Figure label="Self-Loops" value={sa.selfLoops.length.toString()} /> | |
| <Figure label="Non-trivial SCCs" value={sa.nonTrivialSCCs.length.toString()} /> | |
| </div> | |
| <div className="grid grid-cols-2 gap-6 mt-6"> | |
| {/* Hubs */} | |
| <div> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-2 font-medium">Hub States</div> | |
| <table className="w-full text-[12px]"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-1.5 pr-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">State</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">In-Degree</th> | |
| <th className="text-right py-1.5 pl-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Out-Degree</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {sa.hubs.map(h => ( | |
| <tr key={h.id} className="ruled"> | |
| <td className="py-1.5 pr-3 mono text-[11px]">{h.id}</td> | |
| <td className="py-1.5 px-3 text-right mono text-[11px]">{h.inDegree}</td> | |
| <td className="py-1.5 pl-3 text-right mono text-[11px]">{h.outDegree}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| {/* Sources, sinks, SCCs */} | |
| <div className="space-y-4"> | |
| {sa.sources.length > 0 && ( | |
| <div> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-1 font-medium">Source States (no incoming)</div> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {sa.sources.map(s => ( | |
| <span key={s} className="mono text-[10px] px-2 py-0.5 bg-slate-dim text-ink-2 border border-slate/20">{s}</span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {sa.sinks.length > 0 && ( | |
| <div> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-1 font-medium">Sink States (no outgoing)</div> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {sa.sinks.map(s => ( | |
| <span key={s} className="mono text-[10px] px-2 py-0.5 bg-rust-dim text-ink-2 border border-rust/20">{s}</span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {sa.selfLoops.length > 0 && ( | |
| <div> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-1 font-medium">Self-Loops</div> | |
| <div className="flex flex-wrap gap-1.5"> | |
| {sa.selfLoops.map(s => ( | |
| <span key={s} className="mono text-[10px] px-2 py-0.5 bg-ochre-dim text-ink-2 border border-ochre/20">{s}</span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {sa.nonTrivialSCCs.length > 0 && ( | |
| <div> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-1 font-medium">Strongly Connected Components</div> | |
| {sa.nonTrivialSCCs.map((scc, i) => ( | |
| <div key={i} className="flex flex-wrap gap-1 mt-1"> | |
| <span className="text-[10px] text-ink-4 serif italic mr-1">SCC {i + 1}:</span> | |
| {scc.map(s => ( | |
| <span key={s} className="mono text-[10px] px-1.5 py-0.5 text-ink-2">{s}</span> | |
| ))} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Differential FSM */} | |
| {df && ( | |
| <div className="anim d4"> | |
| <SectionRule title="Differential FSM (Success vs Failure)" /> | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mt-4"> | |
| <div> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-2 font-medium"> | |
| Success-Only ({df.successOnlyTransitions.length}) | |
| </div> | |
| {df.successOnlyTransitions.length > 0 ? ( | |
| <div className="flex flex-col gap-1"> | |
| {df.successOnlyTransitions.map(t => ( | |
| <span key={t} className="mono text-[10px] px-2 py-0.5 bg-slate-dim text-slate border border-slate/20">{t}</span> | |
| ))} | |
| </div> | |
| ) : ( | |
| <span className="text-[11px] text-ink-4 italic serif">None</span> | |
| )} | |
| </div> | |
| <div> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-2 font-medium"> | |
| Failure-Only ({df.failureOnlyTransitions.length}) | |
| </div> | |
| {df.failureOnlyTransitions.length > 0 ? ( | |
| <div className="flex flex-col gap-1"> | |
| {df.failureOnlyTransitions.map(t => ( | |
| <span key={t} className="mono text-[10px] px-2 py-0.5 bg-rust-dim text-rust border border-rust/20">{t}</span> | |
| ))} | |
| </div> | |
| ) : ( | |
| <span className="text-[11px] text-ink-4 italic serif">None</span> | |
| )} | |
| </div> | |
| <div> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-2 font-medium"> | |
| Shared ({df.sharedTransitions.length}) | |
| </div> | |
| {df.sharedTransitions.length > 0 ? ( | |
| <div className="flex flex-col gap-1"> | |
| {df.sharedTransitions.map(t => ( | |
| <span key={t} className="mono text-[10px] px-2 py-0.5 text-ink-3">{t}</span> | |
| ))} | |
| </div> | |
| ) : ( | |
| <span className="text-[11px] text-ink-4 italic serif">None</span> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Failure prediction lives in the dedicated Failure tab (deduplicated). */} | |
| {/* Cross-dataset downstream comparison */} | |
| <div className="anim d6"> | |
| <SectionRule title="Cross-Dataset Downstream" /> | |
| <table className="w-full text-[13px] mt-3"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-2 pr-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Dataset</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Fitness u</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Fitness sigma</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Anomalies</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Trans. Cov.</th> | |
| <th className="text-right py-2 px-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Density</th> | |
| <th className="text-right py-2 pl-3 text-[11px] text-ink-3 font-normal uppercase tracking-wider">Dead Trans.</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {datasets.map(d => { | |
| const fp2 = d.downstream.fitnessProfile | |
| const bp2 = d.downstream.behavioralProfiling | |
| const sa2 = d.downstream.structuralAnalysis | |
| const isActive = d.id === ds.id | |
| return ( | |
| <tr key={d.id} className={cn('ruled', isActive && 'bg-rust-dim')}> | |
| <td className="py-2 pr-3 font-medium"> | |
| {d.meta.name} | |
| {isActive && <span className="ml-2 text-[10px] text-rust italic serif">current</span>} | |
| </td> | |
| <td className="py-2 px-3 text-right mono">{fp2 ? fmt(fp2.mean) : '–'}</td> | |
| <td className="py-2 px-3 text-right mono text-ink-3">{fp2 ? fmt(fp2.std, 4) : '–'}</td> | |
| <td className="py-2 px-3 text-right mono">{fp2 ? fp2.anomalyCount : '–'}</td> | |
| <td className="py-2 px-3 text-right mono">{bp2 ? pct(bp2.transitionCoverage) : '–'}</td> | |
| <td className="py-2 px-3 text-right mono">{sa2 ? fmt(sa2.density) : '–'}</td> | |
| <td className="py-2 pl-3 text-right mono">{bp2 ? bp2.deadTransitions.length : '–'}</td> | |
| </tr> | |
| ) | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ---------- Failure Prediction View ---------- | |
| function FailureView({ ds, datasets }: { ds: Dataset; datasets: AllDatasets }) { | |
| const fp = ds.downstream.failurePrediction as { | |
| fitnessOnly?: { auroc?: number; f1?: number; accuracy?: number; bestThreshold?: number } | |
| featureBased?: { | |
| holdoutAUROC?: number; holdoutAUROC_LR?: number; holdoutAUROC_GBT?: number | |
| holdoutAUROC_RF?: number; holdoutAUROC_Ensemble?: number | |
| bestModel?: string; numFeatures?: number; numSelectedFeatures?: number | |
| kfoldAUROC?: { mean?: number; std?: number; k?: number; repeats?: number } | |
| f1?: number; accuracy?: number; precision?: number; recall?: number | |
| topFeatures?: Array<{ name: string; weight: number; absWeight: number }> | |
| } | |
| } | null | |
| const np = ds.downstream.neuralProbe | |
| // Cross-dataset AUROC comparison (fitness-only vs ensemble) | |
| const crossData = datasets | |
| .map(d => { | |
| const dfp = d.downstream.failurePrediction as typeof fp | |
| if (!dfp?.fitnessOnly?.auroc || !dfp?.featureBased?.holdoutAUROC) return null | |
| return { | |
| name: d.meta.shortName, | |
| fitnessOnly: dfp.fitnessOnly.auroc, | |
| ensemble: dfp.featureBased.holdoutAUROC_Ensemble ?? dfp.featureBased.holdoutAUROC, | |
| cvMean: dfp.featureBased.kfoldAUROC?.mean ?? 0, | |
| cvStd: dfp.featureBased.kfoldAUROC?.std ?? 0, | |
| } | |
| }) | |
| .filter((x): x is NonNullable<typeof x> => x !== null) | |
| // Neural Seq vs FSM cross-dataset | |
| const neuralCross = datasets | |
| .map(d => { | |
| const n = d.downstream.neuralProbe | |
| if (!n) return null | |
| return { | |
| name: d.meta.shortName, | |
| seq_mlp: n.seq_mlp.cv, fsm_mlp: n.fsm_mlp.cv, | |
| seq_gru: n.seq_gru.cv, fsm_gru: n.fsm_gru.cv, | |
| seq_transf: n.seq_transf.cv, fsm_transf: n.fsm_transf.cv, | |
| } | |
| }) | |
| .filter((x): x is NonNullable<typeof x> => x !== null) | |
| return ( | |
| <div className="space-y-8"> | |
| <div className="anim"> | |
| <h2 className="serif text-[22px] italic">Failure Prediction</h2> | |
| <p className="text-[13px] text-ink-2 mt-1"> | |
| Per-state FSM features predict trace-level success/failure. Compared against | |
| a fitness-only baseline and against neural models (MLP / GRU / Transformer) trained on | |
| sequence features vs FSM-derived features. | |
| </p> | |
| </div> | |
| {/* This-dataset headline */} | |
| {fp?.featureBased && ( | |
| <div className="anim d1"> | |
| <SectionRule title={`${ds.meta.name} — Headline`} /> | |
| <div className="grid grid-cols-2 md:grid-cols-5 gap-0 thick-rule ruled-top mt-3"> | |
| <Figure label="Fitness-Only AUROC" value={fmt(fp.fitnessOnly?.auroc ?? 0.5)} /> | |
| <Figure label="Ensemble AUROC" value={fmt(fp.featureBased.holdoutAUROC_Ensemble ?? fp.featureBased.holdoutAUROC ?? 0.5)} accent /> | |
| <Figure label="CV AUROC" value={fp.featureBased.kfoldAUROC?.mean ? `${fmt(fp.featureBased.kfoldAUROC.mean)} ±${fmt(fp.featureBased.kfoldAUROC.std ?? 0, 3)}` : '—'} /> | |
| <Figure label="Features (selected/total)" value={`${fp.featureBased.numSelectedFeatures ?? '—'}/${fp.featureBased.numFeatures ?? '—'}`} /> | |
| <Figure label="Best Model" value={fp.featureBased.bestModel ?? '—'} /> | |
| </div> | |
| </div> | |
| )} | |
| {/* Cross-dataset AUROC */} | |
| {crossData.length > 0 && ( | |
| <div className="anim d2"> | |
| <SectionRule title="Cross-Dataset AUROC (Fitness-Only vs FSM Ensemble)" /> | |
| <div className="mt-4 bg-paper-warm border border-rule p-5"> | |
| <ResponsiveContainer width="100%" height={280}> | |
| <BarChart data={crossData} barCategoryGap="20%"> | |
| <CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID} vertical={false} /> | |
| <XAxis dataKey="name" tick={CHART_TICK} axisLine={CHART_AXIS} tickLine={false} angle={-20} textAnchor="end" height={50} /> | |
| <YAxis domain={[0.4, 1.0]} tick={CHART_TICK} axisLine={false} tickLine={false} /> | |
| <Tooltip {...CHART_TOOLTIP} formatter={(v) => (typeof v === 'number' ? v.toFixed(3) : String(v))} /> | |
| <Legend wrapperStyle={{ fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }} /> | |
| <Bar dataKey="fitnessOnly" name="Fitness-Only" fill={PALETTE.slate} radius={0} /> | |
| <Bar dataKey="ensemble" name="FSM Ensemble" fill={PALETTE.rust} radius={0} /> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| <div className="text-center text-[10px] text-ink-4 mt-1"> | |
| Holdout AUROC, 80/20 split. FSM ensemble = LR + GBT + RF on per-state features. | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Neural probe comparison */} | |
| {neuralCross.length > 0 && ( | |
| <div className="anim d3"> | |
| <SectionRule title="Neural Models — Sequence vs FSM Features (CV AUROC)" /> | |
| <div className="mt-3 overflow-x-auto"> | |
| <table className="w-full text-[12px]"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-1.5 pr-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Dataset</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">MLP Seq</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">MLP FSM</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">GRU Seq</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">GRU FSM</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Transf Seq</th> | |
| <th className="text-right py-1.5 pl-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Transf FSM</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {neuralCross.map(r => { | |
| const winCell = (seq: number, fsm: number, isFsm: boolean) => { | |
| const fsmWins = fsm > seq | |
| const style = isFsm && fsmWins ? { color: PALETTE.rust, fontWeight: 600 } : {} | |
| return <td className="py-1.5 px-3 text-right mono text-[11px]" style={style}>{fmt(isFsm ? fsm : seq)}</td> | |
| } | |
| return ( | |
| <tr key={r.name} className="ruled"> | |
| <td className="py-1.5 pr-3 mono text-[11px]">{r.name}</td> | |
| {winCell(r.seq_mlp, r.fsm_mlp, false)} | |
| {winCell(r.seq_mlp, r.fsm_mlp, true)} | |
| {winCell(r.seq_gru, r.fsm_gru, false)} | |
| {winCell(r.seq_gru, r.fsm_gru, true)} | |
| {winCell(r.seq_transf, r.fsm_transf, false)} | |
| {winCell(r.seq_transf, r.fsm_transf, true)} | |
| </tr> | |
| ) | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| <div className="text-[10px] text-ink-4 mt-2"> | |
| 10×5-fold CV AUROC. Bold rust = FSM features beat sequence features for that model. | |
| </div> | |
| </div> | |
| )} | |
| {/* Neural probe for current dataset only */} | |
| {np && ( | |
| <div className="anim d4"> | |
| <SectionRule title={`${ds.meta.name} — Neural Probe Detail`} /> | |
| <div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-4"> | |
| {(['mlp', 'gru', 'transf'] as const).map(model => { | |
| const seq = np[`seq_${model}` as const] | |
| const fsm = np[`fsm_${model}` as const] | |
| const delta = fsm.cv - seq.cv | |
| return ( | |
| <div key={model} className="border border-rule bg-paper-warm p-3"> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-2">{model}</div> | |
| <div className="flex justify-between items-baseline mb-1"> | |
| <span className="mono text-[11px] text-ink-3">Seq</span> | |
| <span className="mono text-[13px]">{fmt(seq.cv)} ±{fmt(seq.std, 3)}</span> | |
| </div> | |
| <div className="flex justify-between items-baseline mb-1"> | |
| <span className="mono text-[11px] text-rust">FSM</span> | |
| <span className="mono text-[13px]" style={{ color: PALETTE.rust, fontWeight: 600 }}> | |
| {fmt(fsm.cv)} ±{fmt(fsm.std, 3)} | |
| </span> | |
| </div> | |
| <div className="ruled-top mt-2 pt-1 flex justify-between items-baseline"> | |
| <span className="text-[10px] text-ink-4 uppercase tracking-wider">Δ</span> | |
| <span className="mono text-[12px]" style={{ color: delta >= 0 ? PALETTE.rust : PALETTE.iron }}> | |
| {delta >= 0 ? '+' : ''}{fmt(delta * 100, 1)}pp | |
| </span> | |
| </div> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* Top features */} | |
| {fp?.featureBased?.topFeatures && fp.featureBased.topFeatures.length > 0 && ( | |
| <div className="anim d5"> | |
| <SectionRule title={`${ds.meta.name} — Top Discriminative Features`} /> | |
| <div className="mt-3 grid grid-cols-2 gap-x-8 gap-y-1"> | |
| {fp.featureBased.topFeatures.slice(0, 12).map(f => ( | |
| <div key={f.name} className="flex justify-between items-baseline ruled py-1"> | |
| <span className="mono text-[11px] text-ink-2 truncate pr-3">{f.name}</span> | |
| <span className="mono text-[11px]" style={{ color: f.weight >= 0 ? PALETTE.rust : PALETTE.slate }}> | |
| {f.weight >= 0 ? '+' : ''}{fmt(f.weight, 3)} | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="text-[10px] text-ink-4 mt-2"> | |
| LR coefficients on per-state features. Rust = failure-indicative, slate = success-indicative. | |
| </div> | |
| </div> | |
| )} | |
| {(!fp && !np) && ( | |
| <div className="anim d1 border border-rule bg-paper-warm p-6 text-center"> | |
| <div className="text-[13px] text-ink-2">No failure prediction data for {ds.meta.name}.</div> | |
| <div className="text-[11px] text-ink-3 mt-1">This dataset lacks success/failure labels or hasn't been run through the downstream pipeline.</div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // ---------- Workflow Memory View (FSM vs AWM) ---------- | |
| function MemoryView({ ds, datasets }: { ds: Dataset; datasets: AllDatasets }) { | |
| const wm = ds.downstream.workflowMemory | |
| const cross = datasets | |
| .map(d => { | |
| const w = d.downstream.workflowMemory | |
| if (!w) return null | |
| return { | |
| name: d.meta.shortName, | |
| noMemory: w.noMemory.accuracy, | |
| awm: w.awm.accuracy, | |
| fsm: w.fsm.accuracy, | |
| gain: w.fsmGain, | |
| n: w.n, | |
| } | |
| }) | |
| .filter((x): x is NonNullable<typeof x> => x !== null) | |
| const fsmWins = cross.filter(r => r.fsm > r.awm).length | |
| const meanGain = cross.length > 0 ? cross.reduce((s, r) => s + r.gain, 0) / cross.length : 0 | |
| return ( | |
| <div className="space-y-8"> | |
| <div className="anim"> | |
| <h2 className="serif text-[22px] italic">Workflow Memory (FSM vs AWM)</h2> | |
| <p className="text-[13px] text-ink-2 mt-1"> | |
| LLM next-action prediction (gpt-4.1-mini, top-1 accuracy) with three context conditions: no memory, Agent Workflow Memory (linear workflows), and FSM-state context. | |
| </p> | |
| </div> | |
| {/* Summary */} | |
| {cross.length > 0 && ( | |
| <div className="anim d1"> | |
| <SectionRule title="Summary across datasets" /> | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-0 thick-rule ruled-top mt-3"> | |
| <Figure label="Datasets" value={cross.length.toString()} /> | |
| <Figure label="FSM wins vs AWM" value={`${fsmWins}/${cross.length}`} accent /> | |
| <Figure label="Mean Δ (FSM − AWM)" value={`+${fmt(meanGain * 100, 1)}pp`} accent /> | |
| <Figure label="Total LLM judgments" value={fmtInt(cross.reduce((s, r) => s + r.n, 0))} /> | |
| </div> | |
| </div> | |
| )} | |
| {/* Cross-dataset bar chart */} | |
| {cross.length > 0 && ( | |
| <div className="anim d2"> | |
| <SectionRule title="Per-Dataset Accuracy" /> | |
| <div className="mt-4 bg-paper-warm border border-rule p-5"> | |
| <ResponsiveContainer width="100%" height={320}> | |
| <BarChart data={cross} barCategoryGap="18%"> | |
| <CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID} vertical={false} /> | |
| <XAxis dataKey="name" tick={CHART_TICK} axisLine={CHART_AXIS} tickLine={false} angle={-20} textAnchor="end" height={60} /> | |
| <YAxis domain={[0, 1.0]} tick={CHART_TICK} axisLine={false} tickLine={false} tickFormatter={(v) => `${Math.round(v * 100)}%`} /> | |
| <Tooltip {...CHART_TOOLTIP} formatter={(v) => (typeof v === 'number' ? `${(v * 100).toFixed(1)}%` : String(v))} /> | |
| <Legend wrapperStyle={{ fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }} /> | |
| <Bar dataKey="noMemory" name="No Memory" fill={PALETTE.iron} radius={0} /> | |
| <Bar dataKey="awm" name="AWM" fill={PALETTE.slate} radius={0} /> | |
| <Bar dataKey="fsm" name="FSM" fill={PALETTE.rust} radius={0} /> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| <div className="text-center text-[10px] text-ink-4 mt-1"> | |
| LLM-judged top-1 next-action accuracy, full validation split per dataset. | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Table with deltas */} | |
| {cross.length > 0 && ( | |
| <div className="anim d3"> | |
| <SectionRule title="Detail" /> | |
| <div className="mt-3 overflow-x-auto"> | |
| <table className="w-full text-[12px]"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-1.5 pr-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Dataset</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">N</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">No Mem</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">AWM</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">FSM</th> | |
| <th className="text-right py-1.5 pl-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">FSM − AWM</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {cross.map(r => ( | |
| <tr key={r.name} className="ruled"> | |
| <td className="py-1.5 pr-3 mono text-[11px]">{r.name}</td> | |
| <td className="py-1.5 px-3 text-right mono text-[11px] text-ink-3">{fmtInt(r.n)}</td> | |
| <td className="py-1.5 px-3 text-right mono text-[11px]">{pct(r.noMemory)}</td> | |
| <td className="py-1.5 px-3 text-right mono text-[11px]">{pct(r.awm)}</td> | |
| <td className="py-1.5 px-3 text-right mono text-[11px]" style={{ color: PALETTE.rust, fontWeight: 600 }}>{pct(r.fsm)}</td> | |
| <td className="py-1.5 pl-3 text-right mono text-[11px]" style={{ color: r.gain >= 0 ? PALETTE.rust : PALETTE.iron }}> | |
| {r.gain >= 0 ? '+' : ''}{fmt(r.gain * 100, 1)}pp | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| <div className="text-[10px] text-ink-4 mt-2"> | |
| Highest gains: WebArena +15.7pp, SWE-smith +25.3pp. 6/8 statsig at p<1e-8 (per paper). | |
| </div> | |
| </div> | |
| )} | |
| {!wm && ( | |
| <div className="anim d1 border border-rule bg-paper-warm p-6 text-center"> | |
| <div className="text-[13px] text-ink-2">No workflow memory data for {ds.meta.name}.</div> | |
| <div className="text-[11px] text-ink-3 mt-1">Run <code className="mono">poc-fsm-as-memory.ts</code> with LLM judging enabled.</div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // ---------- Cross-Model FSM View ---------- | |
| function CrossModelView({ ds, datasets }: { ds: Dataset; datasets: AllDatasets }) { | |
| const cm = ds.downstream.crossModel | |
| const available = datasets.filter(d => d.downstream.crossModel) | |
| // Auto-fallback to first available if current dataset has no cross-model data | |
| const active = cm ? ds : available[0] | |
| const acm = active?.downstream.crossModel | |
| if (!acm) { | |
| return ( | |
| <div className="space-y-6"> | |
| <div className="anim"> | |
| <h2 className="serif text-[22px] italic">Cross-Model FSM Transfer</h2> | |
| <p className="text-[13px] text-ink-2 mt-1">No cross-model data available. tau2-bench airline/retail/telecom only.</p> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| const heatColor = (v: number, min: number, max: number) => { | |
| if (max === min) return PALETTE.iron | |
| const t = (v - min) / (max - min) | |
| const r = Math.round(196 - t * 60) | |
| const g = Math.round(85 - t * 20) | |
| const b = Math.round(58 - t * 15) | |
| return `rgb(${r},${g},${b})` | |
| } | |
| // Transfer matrices may arrive as a raw number[][] or as a {labels, values} object | |
| // (the experiment JSON uses the latter); normalize to a 2-D number array. | |
| const toMatrix = (m: unknown): number[][] => | |
| Array.isArray(m) ? (m as number[][]) : (((m as { values?: number[][] })?.values) ?? []) | |
| const fitMatrix = toMatrix(acm.matrices.fitness) | |
| const aurocMatrix = toMatrix(acm.matrices.auroc) | |
| const fitnessFlat = fitMatrix.flat() | |
| const aurocFlat = aurocMatrix.flat() | |
| const fitMin = Math.min(...fitnessFlat), fitMax = Math.max(...fitnessFlat) | |
| const aurocMin = Math.min(...aurocFlat), aurocMax = Math.max(...aurocFlat) | |
| // Paper headline = the combined figure across ALL tau2-bench suites (36 off-diagonal | |
| // pairs), not any single suite. Single-suite telecom (0.66/0.80) understates it. | |
| const suiteSummaries = available.map(d => d.downstream.crossModel!.summary) | |
| const combinedSelf = suiteSummaries.reduce((s, x) => s + x.diagonalAUROC.mean, 0) / suiteSummaries.length | |
| const combinedCross = suiteSummaries.reduce((s, x) => s + x.offDiagonalAUROC.mean, 0) / suiteSummaries.length | |
| return ( | |
| <div className="space-y-8"> | |
| <div className="anim"> | |
| <h2 className="serif text-[22px] italic">Cross-Model FSM Transfer</h2> | |
| <p className="text-[13px] text-ink-2 mt-1"> | |
| tau2-bench traces collected across 4 LLMs. Train FSM on one model's traces, evaluate on another. Diagonal = self-test, off-diagonal = cross-model transfer. | |
| </p> | |
| {available.length > 1 && ( | |
| <div className="mt-3 flex gap-2"> | |
| {available.map(d => ( | |
| <span key={d.id} | |
| className={cn('mono text-[10px] px-2 py-0.5 border', | |
| d.id === active.id ? 'bg-rust text-paper border-rust' : 'text-ink-3 border-rule')}> | |
| {d.meta.name} | |
| </span> | |
| ))} | |
| <span className="mono text-[10px] text-ink-4 ml-2">(showing: {active.meta.name})</span> | |
| </div> | |
| )} | |
| </div> | |
| {/* Combined headline across all suites (paper figure) */} | |
| {available.length > 1 && ( | |
| <div className="anim d1"> | |
| <SectionRule title="AUROC: Self vs Cross-Model (combined, all suites)" /> | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-0 thick-rule ruled-top mt-3"> | |
| <Figure label="Self AUROC (combined)" value={fmt(combinedSelf)} accent /> | |
| <Figure label="Cross AUROC (combined)" value={fmt(combinedCross)} /> | |
| <Figure label="Transfer Gap" value={`−${fmt((combinedSelf - combinedCross) * 100, 1)}pp`} /> | |
| </div> | |
| <div className="text-[10px] text-ink-4 mt-2"> | |
| Mean over the {available.length} tau2-bench suites (36 off-diagonal model pairs). Per-suite breakdown below. | |
| </div> | |
| </div> | |
| )} | |
| {/* Per-suite summary */} | |
| <div className="anim d1"> | |
| <SectionRule title={`AUROC: Self vs Cross-Model (${active.meta.name})`} /> | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-0 thick-rule ruled-top mt-3"> | |
| <Figure label="Diagonal AUROC (self)" value={`${fmt(acm.summary.diagonalAUROC.mean)} ±${fmt(acm.summary.diagonalAUROC.std, 3)}`} accent /> | |
| <Figure label="Off-Diagonal AUROC (cross)" value={`${fmt(acm.summary.offDiagonalAUROC.mean)} ±${fmt(acm.summary.offDiagonalAUROC.std, 3)}`} /> | |
| <Figure label="Transfer Gap" value={`−${fmt(acm.summary.aurocTransferGap * 100, 1)}pp`} /> | |
| </div> | |
| </div> | |
| {/* Models */} | |
| <div className="anim d2"> | |
| <SectionRule title="LLMs in this Suite" /> | |
| <div className="mt-3 grid grid-cols-2 md:grid-cols-4 gap-3"> | |
| {acm.models.map(m => ( | |
| <div key={m.shortName} className="border border-rule bg-paper-warm p-3"> | |
| <div className="mono text-[12px] font-medium mb-1">{m.name}</div> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-wider mb-1">Traces</div> | |
| <div className="mono text-[13px]">{fmtInt(m.traces)}</div> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-wider mt-2 mb-1">Success</div> | |
| <div className="mono text-[13px]">{pct(m.successRate)}</div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Transfer matrix: Fitness */} | |
| <div className="anim d3"> | |
| <SectionRule title="Fitness Transfer Matrix" /> | |
| <div className="mt-3 overflow-x-auto"> | |
| <table className="w-full text-[12px]"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-1.5 pr-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">FSM Source ↓ / Target →</th> | |
| {acm.models.map(m => ( | |
| <th key={m.shortName} className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">{m.shortName}</th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {fitMatrix.map((row, i) => ( | |
| <tr key={i} className="ruled"> | |
| <td className="py-1.5 pr-3 mono text-[11px]">{acm.models[i].shortName}</td> | |
| {row.map((v, j) => ( | |
| <td key={j} className="py-1.5 px-3 text-right mono text-[11px]" | |
| style={{ | |
| color: 'white', | |
| background: heatColor(v, fitMin, fitMax), | |
| fontWeight: i === j ? 700 : 400, | |
| }}> | |
| {fmt(v)} | |
| </td> | |
| ))} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| <div className="text-[10px] text-ink-4 mt-2"> | |
| Bold diagonal = self (train and test on same model). Darker = lower fitness. | |
| </div> | |
| </div> | |
| </div> | |
| {/* Transfer matrix: AUROC */} | |
| <div className="anim d4"> | |
| <SectionRule title="Failure-Prediction AUROC Transfer Matrix" /> | |
| <div className="mt-3 overflow-x-auto"> | |
| <table className="w-full text-[12px]"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-1.5 pr-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">FSM Source ↓ / Target →</th> | |
| {acm.models.map(m => ( | |
| <th key={m.shortName} className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">{m.shortName}</th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {aurocMatrix.map((row, i) => ( | |
| <tr key={i} className="ruled"> | |
| <td className="py-1.5 pr-3 mono text-[11px]">{acm.models[i].shortName}</td> | |
| {row.map((v, j) => ( | |
| <td key={j} className="py-1.5 px-3 text-right mono text-[11px]" | |
| style={{ | |
| color: 'white', | |
| background: heatColor(v, aurocMin, aurocMax), | |
| fontWeight: i === j ? 700 : 400, | |
| }}> | |
| {fmt(v)} | |
| </td> | |
| ))} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| <div className="text-[10px] text-ink-4 mt-2"> | |
| Per-state failure features transfer at {fmt(acm.summary.offDiagonalAUROC.mean)} mean cross-AUROC vs {fmt(acm.summary.diagonalAUROC.mean)} self. | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ---------- Runtime Monitor View ---------- | |
| function MonitorView({ ds, datasets }: { ds: Dataset; datasets: AllDatasets }) { | |
| const available = datasets.filter(d => d.downstream.monitor) | |
| const active = ds.downstream.monitor ? ds : available[0] | |
| const m = active?.downstream.monitor | |
| if (!m) { | |
| return ( | |
| <div className="space-y-6"> | |
| <div className="anim"> | |
| <h2 className="serif text-[22px] italic">Runtime Monitor</h2> | |
| <p className="text-[13px] text-ink-2 mt-1">No monitor data available. Evaluated on SWE-agent, SWE-smith, tau2-bench retail, and tau2-bench airline.</p> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // Build chart data: one row per trace-completion point, one column per config | |
| const points = m.configs[0]?.monitoringCurve.map(c => c.point) || [] | |
| const curveData = points.map(pt => { | |
| const row: Record<string, number> = { point: pt } | |
| m.configs.forEach(c => { | |
| const cp = c.monitoringCurve.find(x => x.point === pt) | |
| row[c.config] = cp?.f1 ?? 0 | |
| }) | |
| return row | |
| }) | |
| const finalF1 = (cfg: typeof m.configs[number]) => cfg.monitoringCurve[cfg.monitoringCurve.length - 1]?.f1 ?? 0 | |
| const bestCfg = m.configs.reduce((a, b) => (finalF1(b) > finalF1(a) ? b : a)) | |
| const earliestStrong = bestCfg.monitoringCurve.find(c => c.f1 >= 0.9) | |
| return ( | |
| <div className="space-y-8"> | |
| <div className="anim"> | |
| <h2 className="serif text-[22px] italic">Runtime Monitor</h2> | |
| <p className="text-[13px] text-ink-2 mt-1"> | |
| Online failure detection from FSM conformance during trace execution. Decay parameter γ controls how quickly past conformance is forgotten; W is a sliding window. | |
| </p> | |
| {available.length > 1 && ( | |
| <div className="mt-3 flex gap-2 flex-wrap"> | |
| {available.map(d => ( | |
| <span key={d.id} | |
| className={cn('mono text-[10px] px-2 py-0.5 border', | |
| d.id === active.id ? 'bg-rust text-paper border-rust' : 'text-ink-3 border-rule')}> | |
| {d.meta.name} | |
| </span> | |
| ))} | |
| <span className="mono text-[10px] text-ink-4 ml-2">(showing: {active.meta.name})</span> | |
| </div> | |
| )} | |
| </div> | |
| {/* Summary */} | |
| <div className="anim d1"> | |
| <SectionRule title="Summary" /> | |
| <div className="grid grid-cols-2 md:grid-cols-5 gap-0 thick-rule ruled-top mt-3"> | |
| <Figure label="Test traces" value={fmtInt(m.testSize)} note={`${m.succSize} succ / ${m.failSize} fail`} /> | |
| <Figure label="Best Config" value={bestCfg.config} /> | |
| <Figure label="Best F1 (final)" value={fmt(finalF1(bestCfg))} accent /> | |
| <Figure label="F1 ≥ 0.9 at" value={earliestStrong ? `${Math.round(earliestStrong.point * 100)}%` : '—'} note="trace completion" /> | |
| <Figure label="Flag-Everything F1" value={fmt((2 * m.failSize) / (m.failSize + m.testSize))} note="trivial baseline" /> | |
| </div> | |
| </div> | |
| {/* Curves */} | |
| <div className="anim d2"> | |
| <SectionRule title="F1 vs Trace Completion" /> | |
| <div className="mt-3 bg-paper-warm border border-rule p-5"> | |
| <ResponsiveContainer width="100%" height={320}> | |
| <LineChart data={curveData}> | |
| <CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID} vertical={false} /> | |
| <XAxis dataKey="point" type="number" domain={[0.1, 1.0]} tick={CHART_TICK} axisLine={CHART_AXIS} tickLine={false} tickFormatter={(v) => `${Math.round(v * 100)}%`} /> | |
| <YAxis domain={[0, 1.0]} tick={CHART_TICK} axisLine={false} tickLine={false} /> | |
| <Tooltip {...CHART_TOOLTIP} formatter={(v) => (typeof v === 'number' ? v.toFixed(3) : String(v))} labelFormatter={(v) => `Completion: ${Math.round(Number(v) * 100)}%`} /> | |
| <Legend wrapperStyle={{ fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }} /> | |
| {m.configs.map((c, i) => ( | |
| <Line key={c.config} dataKey={c.config} type="monotone" | |
| stroke={[PALETTE.rust, PALETTE.slate, PALETTE.ochre, PALETTE.teal][i % 4]} | |
| strokeWidth={2} dot={{ r: 3 }} /> | |
| ))} | |
| </LineChart> | |
| </ResponsiveContainer> | |
| <div className="text-center text-[10px] text-ink-4 mt-1"> | |
| X-axis: fraction of trace observed (online setting). Y-axis: F1 at the threshold tuned for that point. | |
| </div> | |
| </div> | |
| </div> | |
| {/* Baselines */} | |
| {Object.keys(m.baselines).length > 0 && ( | |
| <div className="anim d3"> | |
| <SectionRule title="Single-Feature Baselines (AUROC)" /> | |
| <div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-3"> | |
| {Object.entries(m.baselines).map(([name, b]) => ( | |
| <div key={name} className="border border-rule bg-paper-warm p-3"> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-1">{name}</div> | |
| <div className="mono text-[18px]" style={{ color: b.auroc >= 0.9 ? PALETTE.ochre : PALETTE.iron }}> | |
| {fmt(b.auroc)} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="text-[10px] text-ink-4 mt-2"> | |
| selfLoopRate AUROC = 1.0 on SWE-agent is an artifact of failure traces always containing self-loops — not a meaningful signal in isolation. | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // ---------- Counterfactual Paths View ---------- | |
| function CounterfactualView({ ds, datasets }: { ds: Dataset; datasets: AllDatasets }) { | |
| const available = datasets.filter(d => d.downstream.counterfactual) | |
| const active = ds.downstream.counterfactual ? ds : available[0] | |
| const c = active?.downstream.counterfactual | |
| if (!c) { | |
| return ( | |
| <div className="space-y-6"> | |
| <div className="anim"> | |
| <h2 className="serif text-[22px] italic">Counterfactual Paths</h2> | |
| <p className="text-[13px] text-ink-2 mt-1">No counterfactual data. Available for SWE-agent, SWE-smith, tau2-bench retail, and tau2-bench airline.</p> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| const pathRatio = c.pathDiversity.uniqueSuccPaths > 0 | |
| ? c.pathDiversity.uniqueFailPaths / c.pathDiversity.uniqueSuccPaths | |
| : 0 | |
| const overlapPct = (c.pathDiversity.uniqueSuccPaths + c.pathDiversity.uniqueFailPaths - c.pathDiversity.overlap) > 0 | |
| ? c.pathDiversity.overlap / (c.pathDiversity.uniqueSuccPaths + c.pathDiversity.uniqueFailPaths - c.pathDiversity.overlap) | |
| : 0 | |
| return ( | |
| <div className="space-y-8"> | |
| <div className="anim"> | |
| <h2 className="serif text-[22px] italic">Counterfactual Path Analysis</h2> | |
| <p className="text-[13px] text-ink-2 mt-1"> | |
| Where success and failure trajectories diverge through the FSM. Decision points are states whose outgoing transition distributions differ between success and failure traces (Jensen–Shannon divergence). | |
| </p> | |
| {available.length > 1 && ( | |
| <div className="mt-3 flex gap-2 flex-wrap"> | |
| {available.map(d => ( | |
| <span key={d.id} | |
| className={cn('mono text-[10px] px-2 py-0.5 border', | |
| d.id === active.id ? 'bg-rust text-paper border-rust' : 'text-ink-3 border-rule')}> | |
| {d.meta.name} | |
| </span> | |
| ))} | |
| <span className="mono text-[10px] text-ink-4 ml-2">(showing: {active.meta.name})</span> | |
| </div> | |
| )} | |
| </div> | |
| {/* Summary */} | |
| <div className="anim d1"> | |
| <SectionRule title="Path Diversity" /> | |
| <div className="grid grid-cols-2 md:grid-cols-5 gap-0 thick-rule ruled-top mt-3"> | |
| <Figure label="Success traces" value={fmtInt(c.nSuccess)} /> | |
| <Figure label="Failure traces" value={fmtInt(c.nFailure)} /> | |
| <Figure label="Unique success paths" value={fmtInt(c.pathDiversity.uniqueSuccPaths)} /> | |
| <Figure label="Unique failure paths" value={fmtInt(c.pathDiversity.uniqueFailPaths)} accent /> | |
| <Figure label="Fail/Succ ratio" value={`${fmt(pathRatio, 1)}×`} note={`${fmt(overlapPct * 100, 1)}% overlap`} /> | |
| </div> | |
| </div> | |
| {/* Decision points */} | |
| {c.dpDivergence.length > 0 && ( | |
| <div className="anim d2"> | |
| <SectionRule title="Decision Points (JSD ranked)" /> | |
| <div className="mt-3"> | |
| <table className="w-full text-[12px]"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-1.5 pr-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">State</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">JSD</th> | |
| <th className="text-right py-1.5 pl-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Bar</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {c.dpDivergence | |
| .slice() | |
| .sort((a, b) => b.jsd - a.jsd) | |
| .slice(0, 15) | |
| .map((dp) => { | |
| const maxJsd = Math.max(...c.dpDivergence.map(x => x.jsd), 1e-9) | |
| return ( | |
| <tr key={dp.state} className="ruled"> | |
| <td className="py-1.5 pr-3"> | |
| <span className="mono text-[10px] text-ink-2 px-1.5 py-0.5 rounded border border-rule bg-paper-warm whitespace-nowrap">{dp.state}</span> | |
| </td> | |
| <td className="py-1.5 px-3 text-right mono text-[11px]">{fmt(dp.jsd, 4)}</td> | |
| <td className="py-1.5 pl-3"> | |
| <div className="h-1.5 bg-paper-warm rounded-sm overflow-hidden" style={{ minWidth: 60 }}> | |
| <div className="h-full" style={{ width: `${(dp.jsd / maxJsd) * 100}%`, background: PALETTE.rust }} /> | |
| </div> | |
| </td> | |
| </tr> | |
| ) | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| <div className="text-[10px] text-ink-4 mt-2"> | |
| Higher JSD = stronger divergence between success and failure transition distributions at that state. | |
| </div> | |
| </div> | |
| )} | |
| {/* Branching */} | |
| {c.decisionPoints.length > 0 && ( | |
| <div className="anim d3"> | |
| <SectionRule title="Branching Decisions" /> | |
| <div className="mt-3 space-y-2"> | |
| {c.decisionPoints | |
| .slice() | |
| .sort((a, b) => b.branching - a.branching) | |
| .slice(0, 8) | |
| .map((dp) => ( | |
| <div key={dp.state} className="border border-rule bg-paper-warm p-3"> | |
| <div className="flex justify-between items-baseline mb-1"> | |
| <span className="mono text-[12px] font-medium">{dp.state}</span> | |
| <span className="mono text-[11px] text-ink-3">{dp.branching} branches</span> | |
| </div> | |
| <div className="flex flex-wrap gap-1"> | |
| {dp.targets.map(t => ( | |
| <span key={t} className="mono text-[10px] px-2 py-0.5 bg-paper border border-rule"> | |
| {t} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // ---------- About / Paper View ---------- | |
| function AboutView({ datasets }: { datasets: AllDatasets }) { | |
| const totalTraces = datasets.reduce((s, d) => s + d.meta.traces, 0) | |
| const compressionExtremes = datasets | |
| .map(d => { | |
| const ours = d.baselines.find(b => b.category === 'ours') | |
| const rpni = d.baselines.find(b => b.method === 'rpni') | |
| if (!ours?.states || !rpni?.states) return null | |
| return { name: d.meta.shortName, ratio: rpni.states / ours.states } | |
| }) | |
| .filter((x): x is { name: string; ratio: number } => x !== null) | |
| const compMin = compressionExtremes.length ? Math.min(...compressionExtremes.map(c => c.ratio)) : 0 | |
| const compMax = compressionExtremes.length ? Math.max(...compressionExtremes.map(c => c.ratio)) : 0 | |
| return ( | |
| <div className="space-y-8 max-w-[900px]"> | |
| <div className="anim"> | |
| <h2 className="serif text-[28px] italic leading-tight">Automata from Agent Traces</h2> | |
| <p className="text-[15px] text-ink-2 mt-2 leading-relaxed"> | |
| Failure and next-step prediction for LLM-based agents via minimal finite-state machines extracted from execution traces. | |
| </p> | |
| </div> | |
| <div className="anim d1"> | |
| <SectionRule title="Abstract" /> | |
| <p className="text-[13px] text-ink-2 mt-3 leading-relaxed"> | |
| LLM-based agents execute multi-step tasks, but their behavioural structure remains opaque: long unstructured traces resist the safety auditing and runtime monitoring that deployment requires. | |
| We extract finite-state machines (FSMs) that are provably minimal for the observed prefix language via prefix-tree construction and structural state merging. | |
| Across {datasets.length} public datasets, the FSMs are compact (7–43 states), achieve ≥0.993 replay fitness on held-out data with zero structural variance across splits, and build in milliseconds. | |
| The same FSM unifies workflow memory, next-step prediction, failure prediction, and runtime monitoring. | |
| </p> | |
| </div> | |
| <div className="anim d2"> | |
| <SectionRule title="Headline Numbers" /> | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-0 thick-rule ruled-top mt-3"> | |
| <Figure label="Datasets" value={datasets.length.toString()} accent /> | |
| <Figure label="Total traces" value={fmtInt(totalTraces)} /> | |
| <Figure label="Compression" value={`${fmt(compMin, 0)}–${fmt(compMax, 0)}×`} note="vs RPNI" /> | |
| <Figure label="Replay fitness" value="≥0.993" note="held-out" /> | |
| </div> | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-0 thick-rule mt-0"> | |
| <Figure label="Failure AUROC" value="0.70–0.94" note="holdout" /> | |
| <Figure label="Monitor F1" value="0.904" note="SWE-agent" /> | |
| <Figure label="Workflow Memory" value="8/8 wins" note="vs AWM" /> | |
| <Figure label="Cross-Model" value="1.000" note="combined FSM" /> | |
| </div> | |
| </div> | |
| <div className="anim d3"> | |
| <SectionRule title="Dataset Catalogue" /> | |
| <div className="mt-3 overflow-x-auto"> | |
| <table className="w-full text-[12px]"> | |
| <thead> | |
| <tr className="thick-rule"> | |
| <th className="text-left py-1.5 pr-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Dataset</th> | |
| <th className="text-left py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Venue</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">Traces</th> | |
| <th className="text-right py-1.5 px-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">|A|</th> | |
| <th className="text-right py-1.5 pl-3 text-[10px] text-ink-3 font-normal uppercase tracking-wider">|Q|</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {datasets.map(d => { | |
| const ours = d.baselines.find(b => b.category === 'ours') | |
| return ( | |
| <tr key={d.id} className="ruled"> | |
| <td className="py-1.5 pr-3 mono text-[11px]">{d.meta.name}</td> | |
| <td className="py-1.5 px-3 text-[11px] text-ink-3">{d.meta.venue}</td> | |
| <td className="py-1.5 px-3 text-right mono text-[11px]">{fmtInt(d.meta.traces)}</td> | |
| <td className="py-1.5 px-3 text-right mono text-[11px]">{ours?.states ? ours.states - 1 : '—'}</td> | |
| <td className="py-1.5 pl-3 text-right mono text-[11px]" style={{ color: PALETTE.rust, fontWeight: 600 }}> | |
| {ours?.states ?? '—'} | |
| </td> | |
| </tr> | |
| ) | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ---------- Shared Components ---------- | |
| function Figure({ label, value, note, accent }: { | |
| label: string; value: string; note?: string; accent?: boolean | |
| }) { | |
| return ( | |
| <div className="py-4 pr-6 border-r border-rule last:border-r-0 first:pl-0 pl-6"> | |
| <div className="text-[10px] text-ink-3 uppercase tracking-widest mb-1">{label}</div> | |
| <div className={cn('text-[26px] font-light mono leading-none', accent ? 'text-rust' : 'text-ink')}> | |
| {value} | |
| </div> | |
| {note && <div className="text-[11px] text-ink-4 mt-1">{note}</div>} | |
| </div> | |
| ) | |
| } | |
| function SectionRule({ title }: { title: string }) { | |
| return ( | |
| <div className="flex items-baseline gap-4 thick-rule pb-1"> | |
| <h3 className="text-[11px] text-ink-3 uppercase tracking-widest font-medium shrink-0">{title}</h3> | |
| </div> | |
| ) | |
| } | |
| function Observation({ num, title, body }: { num: string; title: string; body: string }) { | |
| return ( | |
| <div className="ruled-top pt-4"> | |
| <div className="flex items-baseline gap-2 mb-1.5"> | |
| <span className="serif italic text-rust text-[14px]">{num}.</span> | |
| <span className="text-[13px] font-medium">{title}</span> | |
| </div> | |
| <p className="text-[12px] text-ink-2 leading-relaxed">{body}</p> | |
| </div> | |
| ) | |
| } | |
| // ---------- Layout (force-directed simulation) ---------- | |
| function computeLayout( | |
| states: Array<{ id: string }>, | |
| transitions: Array<{ source: string; target: string }> | |
| ): Record<string, { x: number; y: number }> { | |
| const n = states.length | |
| if (n === 0) return {} | |
| const W = 600, H = 400 | |
| const cx = W / 2, cy = H / 2 | |
| // Init: circular layout as starting point | |
| const pos: Record<string, { x: number; y: number }> = {} | |
| const vel: Record<string, { x: number; y: number }> = {} | |
| const initIdx = states.findIndex(s => s.id === 'init') | |
| const r0 = Math.min(160, n * 22) | |
| states.forEach((s, i) => { | |
| const idx = initIdx >= 0 ? (i - initIdx + n) % n : i | |
| const angle = (idx / n) * 2 * Math.PI - Math.PI / 2 | |
| pos[s.id] = { x: cx + r0 * Math.cos(angle), y: cy + r0 * Math.sin(angle) } | |
| vel[s.id] = { x: 0, y: 0 } | |
| }) | |
| // Build adjacency for attraction | |
| const adj = new Map<string, Set<string>>() | |
| for (const s of states) adj.set(s.id, new Set()) | |
| for (const t of transitions) { | |
| if (t.source !== t.target) { | |
| adj.get(t.source)?.add(t.target) | |
| adj.get(t.target)?.add(t.source) | |
| } | |
| } | |
| // In-degree for sizing | |
| const inDeg: Record<string, number> = {} | |
| for (const t of transitions) inDeg[t.target] = (inDeg[t.target] || 0) + 1 | |
| // Simulate | |
| const REPULSION = 8000 | |
| const ATTRACTION = 0.008 | |
| const DAMPING = 0.85 | |
| const ITERS = 200 | |
| for (let iter = 0; iter < ITERS; iter++) { | |
| const temp = 1 - iter / ITERS // cooling | |
| for (const a of states) { | |
| let fx = 0, fy = 0 | |
| // Repulsion from all other nodes | |
| for (const b of states) { | |
| if (a.id === b.id) continue | |
| const dx = pos[a.id].x - pos[b.id].x | |
| const dy = pos[a.id].y - pos[b.id].y | |
| const d2 = dx * dx + dy * dy + 1 | |
| const f = REPULSION / d2 | |
| fx += f * dx / Math.sqrt(d2) | |
| fy += f * dy / Math.sqrt(d2) | |
| } | |
| // Attraction along edges | |
| const neighbors = adj.get(a.id) | |
| if (neighbors) { | |
| for (const bId of neighbors) { | |
| const dx = pos[bId].x - pos[a.id].x | |
| const dy = pos[bId].y - pos[a.id].y | |
| const d = Math.sqrt(dx * dx + dy * dy) | |
| const idealDist = 100 | |
| const f = ATTRACTION * (d - idealDist) | |
| fx += f * dx / (d + 0.1) | |
| fy += f * dy / (d + 0.1) | |
| } | |
| } | |
| // Center gravity | |
| fx += (cx - pos[a.id].x) * 0.001 | |
| fy += (cy - pos[a.id].y) * 0.001 | |
| vel[a.id].x = (vel[a.id].x + fx) * DAMPING * temp | |
| vel[a.id].y = (vel[a.id].y + fy) * DAMPING * temp | |
| } | |
| // Apply velocities + clamp | |
| for (const s of states) { | |
| pos[s.id].x = Math.max(60, Math.min(W - 60, pos[s.id].x + vel[s.id].x)) | |
| pos[s.id].y = Math.max(60, Math.min(H - 60, pos[s.id].y + vel[s.id].y)) | |
| } | |
| } | |
| return pos | |
| } | |