seonglae commited on
Commit
9e2b9b8
·
0 Parent(s):

Interactive research article

Browse files
.gitattributes ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ public/index.json filter=lfs diff=lfs merge=lfs -text
2
+ public/datasets/*.json filter=lfs diff=lfs merge=lfs -text
3
+ *.png filter=lfs diff=lfs merge=lfs -text
4
+ package.json -filter -diff -merge text
5
+ package-lock.json -filter -diff -merge text
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-slim AS build
2
+ WORKDIR /app
3
+ RUN apt-get update && apt-get install -y git git-lfs && git lfs install && apt-get clean
4
+ COPY package*.json ./
5
+ RUN npm install
6
+ COPY . .
7
+ ENV ASG_BASE=/
8
+ RUN npm run build
9
+ FROM nginx:alpine
10
+ RUN mkdir -p /var/cache/nginx /tmp && chmod -R 777 /var/cache/nginx /tmp
11
+ COPY nginx.conf /etc/nginx/nginx.conf
12
+ COPY --from=build /app/dist /app/dist
13
+ EXPOSE 7860
14
+ CMD ["nginx","-g","daemon off;"]
README.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: 'Agent State Graph — Dashboard'
3
+ short_desc: 'Interactive dashboard for FSMs extracted from LLM agent traces'
4
+ emoji: 📊
5
+ colorFrom: blue
6
+ colorTo: gray
7
+ sdk: docker
8
+ pinned: false
9
+ app_port: 7860
10
+ tags:
11
+ - data visualization
12
+ - agents
13
+ - interpretability
14
+ ---
15
+
16
+ # Agent State Graph — Dashboard
17
+
18
+ Interactive explorer for the automata extracted from LLM agent traces (12 datasets).
19
+ Also served under seongland.com/article/asg/browser.
eslint.config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ])
index.html ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Agent State Graph — FSMs from LLM Agent Traces</title>
7
+ <meta name="description" content="Interactive dashboard for Agent State Graph: compact finite-state machines extracted from LLM agent traces across 14 datasets. Failure prediction, workflow memory, cross-model transfer, runtime monitoring, and counterfactual paths." />
8
+ <meta name="theme-color" content="#c4553a" />
9
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
10
+
11
+ <!-- Open Graph -->
12
+ <meta property="og:type" content="website" />
13
+ <meta property="og:title" content="Agent State Graph" />
14
+ <meta property="og:description" content="Compact FSMs from LLM agent traces. 14 datasets, ≥0.993 fitness, AUROC up to 0.94, runtime monitor F1=0.904." />
15
+ <meta property="og:image" content="/og.png" />
16
+
17
+ <!-- Twitter Card -->
18
+ <meta name="twitter:card" content="summary_large_image" />
19
+ <meta name="twitter:title" content="Agent State Graph" />
20
+ <meta name="twitter:description" content="Compact FSMs from LLM agent traces. 14 datasets, ≥0.993 fitness, AUROC up to 0.94, runtime monitor F1=0.904." />
21
+ <meta name="twitter:image" content="/og.png" />
22
+
23
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
24
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
25
+ <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
26
+ </head>
27
+ <body>
28
+ <div id="root"></div>
29
+ <script type="module" src="/src/main.tsx"></script>
30
+ </body>
31
+ </html>
nginx.conf ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ worker_processes auto;
2
+ pid /tmp/nginx.pid;
3
+ events { worker_connections 1024; }
4
+ http {
5
+ include /etc/nginx/mime.types;
6
+ default_type application/octet-stream;
7
+ access_log /tmp/access.log; error_log /tmp/error.log;
8
+ sendfile on; keepalive_timeout 65;
9
+ gzip on; gzip_types text/plain text/css application/javascript application/json image/svg+xml;
10
+ server {
11
+ listen 7860; server_name localhost; root /app/dist; index index.html;
12
+ location = /health { return 200 "ok"; add_header Content-Type text/plain; }
13
+ location /assets/ { expires 30d; add_header Cache-Control "public, max-age=2592000, immutable"; }
14
+ location / { try_files $uri $uri/ /index.html; }
15
+ }
16
+ }
package-lock.json ADDED
Binary file (184 kB). View file
 
package.json ADDED
Binary file (1.13 kB). View file
 
public/datasets/agentnet.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:86bf210622b292aa7b746ab12ce89a9f27d2f980a85835602c5d132fc00da5fb
3
+ size 19506736
public/datasets/atbench.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:468adac6e2b9f513fcdab0ef78d1acae46c79669997670f50470dab805cf2f7d
3
+ size 1725154
public/datasets/gui-odyssey.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:aef9b8213b2e0cd8d3397ad9199988d904f02124ea032fe747c73666a1b5fd5c
3
+ size 20065720
public/datasets/mind2web.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9e78c4259203c215ca79f62a1b5b592ac7296196ae81775f60777245be9a3541
3
+ size 773930
public/datasets/osworld.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e32250027c128841090dd8e4f1ffe1cb7e3d2d81990a060a302a0145bf28b02f
3
+ size 11839535
public/datasets/sweagent.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:119367306756c922e6fe22992df6b0465f60c60af5aef788ef98d946ee10c6dd
3
+ size 14925458
public/datasets/swesmith.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3dd570ebeb7457e7b9aeba5db3807ac95bc13786890e89bc52b56030bfc652fc
3
+ size 4534928
public/datasets/tau2bench-airline.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c2f3e34b12323ccac31e8e9de24e9b8e9567887755e670d0db7c9fd4e6d6063e
3
+ size 3613561
public/datasets/tau2bench-retail.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dfcc37e9ff3ce5666514221279e31b86027fcae2755d822626099bd595f451c9
3
+ size 9047686
public/datasets/tau2bench-telecom.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:909001cd3fd4096a11b4fea7c10ebb6ec50e657f8fed75972819f53bb14de327
3
+ size 20071500
public/datasets/taubench-airline.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0acd64ab4d074017d3244012edf63fe3ec33e39ae8f958e11f9860914eaf74bb
3
+ size 965257
public/datasets/taubench-retail.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4de6585056fd42afa05313358c1dd4537e280e3a92c752a390c3b6fef4e2a42b
3
+ size 2413095
public/datasets/webarena.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ded3ec8df89329af6854f27288897e5d423958cb0b2d2f15cb219979801d79cb
3
+ size 16829352
public/datasets/whoandwhen.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f97049802bc737bf836a9aeceffef3c27abf8e752cffb01fccdb136bb0253276
3
+ size 682956
public/favicon.svg ADDED
public/index.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:def1c48a6c2cfeb58fa2fb3fd2250784420fceb6319104255ff3e6457c3b008f
3
+ size 2402173
public/vite.svg ADDED
scripts/build-data.ts ADDED
@@ -0,0 +1,996 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Preprocesses experiment results into a single JSON for the browser.
3
+ * Computes per-trace FSM traversals for the interpretability view.
4
+ * Run: npx tsx scripts/build-data.ts
5
+ */
6
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs'
7
+ import path from 'path'
8
+ import { fileURLToPath } from 'url'
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
11
+ const ROOT = path.resolve(__dirname, '../..')
12
+ // Active 12 datasets (paper set). tau-bench v1 (taubench-airline/retail) was dropped
13
+ // and replaced by tau2-bench; excluded here so the dashboard reflects 12, not 14.
14
+ const DATASETS = [
15
+ 'swesmith', 'whoandwhen', 'mind2web',
16
+ 'sweagent',
17
+ 'gui-odyssey', 'webarena', 'agentnet',
18
+ 'tau2bench-airline', 'tau2bench-retail', 'tau2bench-telecom',
19
+ 'atbench', 'osworld',
20
+ ] as const
21
+
22
+ // ---------- Types ----------
23
+
24
+ interface TraceStep {
25
+ idx: number
26
+ role: string
27
+ activity: string
28
+ fromState: string
29
+ toState: string | null // null = FSM failure
30
+ consumed: boolean
31
+ }
32
+
33
+ interface TraceDetail {
34
+ id: string
35
+ split: 'train' | 'test'
36
+ success: boolean | null
37
+ fullFitness: number | null
38
+ successFitness: number | null
39
+ steps: TraceStep[]
40
+ stateSequence: string[]
41
+ fitness: number
42
+ consumed: number
43
+ total: number
44
+ firstFailIdx: number // -1 if no failure
45
+ stateVisits: Record<string, number>
46
+ }
47
+
48
+ interface DatasetBundle {
49
+ id: string
50
+ meta: {
51
+ name: string
52
+ shortName: string
53
+ venue: string
54
+ traces: number
55
+ trainSize: number
56
+ testSize: number
57
+ activities: number
58
+ description: string
59
+ }
60
+ baselines: Array<{
61
+ method: string
62
+ label: string
63
+ trainFitness: number
64
+ testFitness: number
65
+ states: number | null
66
+ transitions: number | null
67
+ randomAcceptRate: number | null
68
+ permutedAcceptRate: number | null
69
+ pmPrecision: number | null
70
+ category: string
71
+ note: string
72
+ }>
73
+ convergence: {
74
+ stateConvergenceAt: number
75
+ transitionConvergenceAt: number
76
+ fitnessConvergenceAt: number
77
+ curve: Array<{ n: number; states: number; transitions: number; fitness: number }>
78
+ }
79
+ traces: TraceDetail[]
80
+ downstream: {
81
+ fitnessProfile: {
82
+ mean: number; std: number; min: number; max: number
83
+ anomalyCount: number; anomalies: string[]
84
+ histogram: Array<{ min: number; max: number; count: number }>
85
+ } | null
86
+ behavioralProfiling: {
87
+ stateVisitDistribution: Array<{ state: string; count: number; fraction: number }>
88
+ transitionFrequency: Array<{ transition: string; count: number; fraction: number }>
89
+ deadTransitions: string[]
90
+ transitionCoverage: number
91
+ avgUniqueStatesPerTrace: number
92
+ } | null
93
+ structuralAnalysis: {
94
+ density: number
95
+ hubs: Array<{ id: string; outDegree: number; inDegree: number }>
96
+ sinks: string[]
97
+ sources: string[]
98
+ selfLoops: string[]
99
+ nonTrivialSCCs: string[][]
100
+ } | null
101
+ differentialFSM: {
102
+ successOnlyTransitions: string[]
103
+ failureOnlyTransitions: string[]
104
+ sharedTransitions: string[]
105
+ } | null
106
+ failurePrediction: Record<string, unknown> | null
107
+ mistakeLocalization: Record<string, unknown> | null
108
+ neuralProbe: {
109
+ seq_mlp: { cv: number; std: number; holdout: number }
110
+ fsm_mlp: { cv: number; std: number; holdout: number }
111
+ seq_gru: { cv: number; std: number; holdout: number }
112
+ fsm_gru: { cv: number; std: number; holdout: number }
113
+ seq_transf: { cv: number; std: number; holdout: number }
114
+ fsm_transf: { cv: number; std: number; holdout: number }
115
+ } | null
116
+ workflowMemory: {
117
+ n: number
118
+ noMemory: { accuracy: number; correct: number }
119
+ awm: { accuracy: number; correct: number }
120
+ fsm: { accuracy: number; correct: number }
121
+ fsmGain: number
122
+ } | null
123
+ crossModel: {
124
+ models: Array<{ name: string; shortName: string; traces: number; successRate: number }>
125
+ matrices: {
126
+ fitness: number[][]
127
+ auroc: number[][]
128
+ stateCount: number[][]
129
+ }
130
+ summary: {
131
+ diagonalAUROC: { mean: number; std: number }
132
+ offDiagonalAUROC: { mean: number; std: number }
133
+ aurocTransferGap: number
134
+ }
135
+ } | null
136
+ monitor: {
137
+ trainSize: number; testSize: number; succSize: number; failSize: number
138
+ configs: Array<{
139
+ config: string; gamma: number; window: number
140
+ monitoringCurve: Array<{ point: number; threshold: number; f1: number; precision: number; recall: number }>
141
+ }>
142
+ baselines: Record<string, { auroc: number; curve?: Array<{ point: number; f1: number }> }>
143
+ } | null
144
+ counterfactual: {
145
+ nSuccess: number; nFailure: number
146
+ decisionPoints: Array<{ state: string; branching: number; targets: string[] }>
147
+ pathDiversity: { uniqueSuccPaths: number; uniqueFailPaths: number; overlap: number }
148
+ dpDivergence: Array<{ state: string; jsd: number }>
149
+ editDistance: { meanInternal?: number; meanCross?: number } | null
150
+ } | null
151
+ }
152
+ multiseed: {
153
+ numSeeds: number
154
+ ourFSM: { testFitness: { mean: number; std: number }; states: { mean: number; std: number } }
155
+ rpni: { testFitness: { mean: number; std: number }; states: { mean: number; std: number } }
156
+ awm: { testFitness: { mean: number; std: number } }
157
+ } | null
158
+ graph: {
159
+ states: Array<{ id: string }>
160
+ transitions: Array<{ source: string; target: string; subject: string }>
161
+ }
162
+ }
163
+
164
+ const META: Record<string, DatasetBundle['meta']> = {
165
+ swesmith: {
166
+ name: 'SWE-smith',
167
+ shortName: 'SWE',
168
+ venue: 'NeurIPS 2025 D&B',
169
+ traces: 500,
170
+ trainSize: 400,
171
+ testSize: 100,
172
+ activities: 9,
173
+ description: 'Coding agent trajectories with tool calls (bash, str_replace_editor, submit)',
174
+ },
175
+ whoandwhen: {
176
+ name: 'Who & When to Delegate',
177
+ shortName: 'W&W',
178
+ venue: 'arXiv 2505.00212',
179
+ traces: 184,
180
+ trainSize: 147,
181
+ testSize: 37,
182
+ activities: 8,
183
+ description: 'Multi-agent delegation failures across 8 specialized actors',
184
+ },
185
+ mind2web: {
186
+ name: 'Mind2Web',
187
+ shortName: 'M2W',
188
+ venue: 'NeurIPS 2023',
189
+ traces: 500,
190
+ trainSize: 400,
191
+ testSize: 100,
192
+ activities: 7,
193
+ description: 'Web navigation agent traces with click, type, select, hover, enter actions',
194
+ },
195
+ 'taubench-airline': {
196
+ name: 'tau-bench (airline)',
197
+ shortName: 'tau-air',
198
+ venue: 'Sierra 2024',
199
+ traces: 200,
200
+ trainSize: 160,
201
+ testSize: 40,
202
+ activities: 5,
203
+ description: 'Customer service agent traces for airline booking/cancellation with 12 APIs',
204
+ },
205
+ 'taubench-retail': {
206
+ name: 'tau-bench (retail)',
207
+ shortName: 'tau-ret',
208
+ venue: 'Sierra 2024',
209
+ traces: 460,
210
+ trainSize: 368,
211
+ testSize: 92,
212
+ activities: 5,
213
+ description: 'Customer service agent traces for retail order management with 14 APIs',
214
+ },
215
+ sweagent: {
216
+ name: 'SWE-agent',
217
+ shortName: 'SWE-a',
218
+ venue: 'NeurIPS 2024',
219
+ traces: 2000,
220
+ trainSize: 1600,
221
+ testSize: 400,
222
+ activities: 68,
223
+ description: 'SWE-agent coding trajectories with 68 unique bash/editor commands',
224
+ },
225
+ atbench: {
226
+ name: 'ATBench',
227
+ shortName: 'ATBench',
228
+ venue: 'arXiv 2604.02022',
229
+ traces: 1000,
230
+ trainSize: 800,
231
+ testSize: 200,
232
+ activities: 14,
233
+ description: 'Agent trajectory safety benchmark with balanced safe/unsafe outcomes (503 safe / 497 unsafe)',
234
+ },
235
+ osworld: {
236
+ name: 'OSWorld',
237
+ shortName: 'OSWorld',
238
+ venue: 'NeurIPS 2024',
239
+ traces: 2166,
240
+ trainSize: 1732,
241
+ testSize: 434,
242
+ activities: 27,
243
+ description: 'Desktop GUI agent traces (Gelato-30B, GTA1-32B) across real OS applications',
244
+ },
245
+ 'gui-odyssey': {
246
+ name: 'GUI-Odyssey',
247
+ shortName: 'GUI',
248
+ venue: 'CVPR 2025',
249
+ traces: 7735,
250
+ trainSize: 6188,
251
+ testSize: 1547,
252
+ activities: 6,
253
+ description: 'Mobile GUI agent traces with click, type, scroll, swipe, enter, home actions',
254
+ },
255
+ webarena: {
256
+ name: 'WebArena',
257
+ shortName: 'WA',
258
+ venue: 'ICLR 2024 Oral',
259
+ traces: 8337,
260
+ trainSize: 6670,
261
+ testSize: 1667,
262
+ activities: 24,
263
+ description: 'Web agent traces on realistic websites with navigation, form-filling, and content extraction',
264
+ },
265
+ agentnet: {
266
+ name: 'AgentNet',
267
+ shortName: 'ANet',
268
+ venue: 'NeurIPS 2024',
269
+ traces: 5000,
270
+ trainSize: 4000,
271
+ testSize: 1000,
272
+ activities: 24,
273
+ description: 'Desktop GUI agent traces across diverse applications',
274
+ },
275
+ 'tau2bench-airline': {
276
+ name: 'tau2-bench (airline)',
277
+ shortName: 'tau2-air',
278
+ venue: 'arXiv 2506.07982',
279
+ traces: 800,
280
+ trainSize: 640,
281
+ testSize: 160,
282
+ activities: 17,
283
+ description: 'Customer service agent traces (4 LLMs) for airline booking with 12 APIs',
284
+ },
285
+ 'tau2bench-retail': {
286
+ name: 'tau2-bench (retail)',
287
+ shortName: 'tau2-ret',
288
+ venue: 'arXiv 2506.07982',
289
+ traces: 1824,
290
+ trainSize: 1459,
291
+ testSize: 365,
292
+ activities: 18,
293
+ description: 'Customer service agent traces (4 LLMs) for retail order management with 14 APIs',
294
+ },
295
+ 'tau2bench-telecom': {
296
+ name: 'tau2-bench (telecom)',
297
+ shortName: 'tau2-tel',
298
+ venue: 'arXiv 2506.07982',
299
+ traces: 1824,
300
+ trainSize: 1459,
301
+ testSize: 365,
302
+ activities: 4,
303
+ description: 'Customer service agent traces (4 LLMs) for telecom workflows',
304
+ },
305
+ }
306
+
307
+ // ---------- Activity extraction (must match shared.ts exactly) ----------
308
+
309
+ function extractActivity(role: string, content: string, agentName?: string): string {
310
+ const actor = agentName || role
311
+ const c = content.trim()
312
+ const bracketMatch = c.match(/^\[(\w+)\]/)
313
+ if (bracketMatch) return `${actor}:${bracketMatch[1].toLowerCase()}`
314
+ if (c.includes('function_call') || c.includes('tool_call')) {
315
+ // Match "tool_call:toolname" anywhere in content (loader format)
316
+ const prefixMatch = c.match(/tool_call:(\w+)/)
317
+ if (prefixMatch) return `${actor}:tool:${prefixMatch[1]}`
318
+ // Match "name='toolname'" or "name: toolname" (OpenAI/generic format)
319
+ const nameMatch = c.match(/name['"=:\s]+(\w+)/)
320
+ return nameMatch ? `${actor}:tool:${nameMatch[1]}` : `${actor}:tool_call`
321
+ }
322
+ return `${actor}:text`
323
+ }
324
+
325
+ // ---------- FSM replay ----------
326
+
327
+ type TransitionFn = (state: string, symbol: string) => string | null
328
+
329
+ function graphToTransitionFn(graph: { transitions: Array<{ source: string; target: string }> }): TransitionFn {
330
+ const valid = new Set<string>()
331
+ for (const t of graph.transitions) {
332
+ valid.add(`${t.source}|${t.target}`)
333
+ }
334
+ return (current: string, symbol: string) =>
335
+ valid.has(`${current}|${symbol}`) ? symbol : null
336
+ }
337
+
338
+ function replayTrace(
339
+ transitionFn: TransitionFn,
340
+ messages: Array<{ role: string; content: string; name?: string }>,
341
+ ): { steps: TraceStep[]; stateSequence: string[]; fitness: number; consumed: number; total: number; firstFailIdx: number; stateVisits: Record<string, number> } {
342
+ let current = 'init'
343
+ const steps: TraceStep[] = []
344
+ const stateSequence: string[] = ['init']
345
+ const stateVisits: Record<string, number> = { init: 1 }
346
+ let consumed = 0
347
+ let firstFailIdx = -1
348
+
349
+ for (let i = 0; i < messages.length; i++) {
350
+ const msg = messages[i]
351
+ const activity = extractActivity(msg.role, msg.content, msg.name)
352
+ const next = transitionFn(current, activity)
353
+
354
+ if (next !== null) {
355
+ steps.push({ idx: i, role: msg.role, activity, fromState: current, toState: next, consumed: true })
356
+ stateSequence.push(next)
357
+ stateVisits[next] = (stateVisits[next] || 0) + 1
358
+ current = next
359
+ consumed++
360
+ } else {
361
+ steps.push({ idx: i, role: msg.role, activity, fromState: current, toState: null, consumed: false })
362
+ stateSequence.push(`FAIL:${activity}`)
363
+ if (firstFailIdx === -1) firstFailIdx = i
364
+ }
365
+ }
366
+
367
+ return {
368
+ steps,
369
+ stateSequence,
370
+ fitness: messages.length > 0 ? consumed / messages.length : 1,
371
+ consumed,
372
+ total: messages.length,
373
+ firstFailIdx,
374
+ stateVisits,
375
+ }
376
+ }
377
+
378
+ // ---------- Main ----------
379
+
380
+ function loadJSON(p: string) {
381
+ return JSON.parse(readFileSync(path.join(ROOT, p), 'utf-8'))
382
+ }
383
+
384
+ function tryLoadJSON(p: string): Record<string, unknown> | null {
385
+ try { return loadJSON(p) } catch { return null }
386
+ }
387
+
388
+ function loadTauBenchRaw(domain: string): Array<{ id: string; messages: Array<{ role: string; content: string; name?: string }>; annotations?: { success?: boolean } }> {
389
+ const raw: Array<{ task_id: number; reward: number; traj: Array<{ role: string; content: string; tool_calls?: Array<{ function: { name: string } }> }> }> =
390
+ loadJSON(`data/taubench/${domain}.json`)
391
+ return raw.map(entry => ({
392
+ id: `taubench-${domain}-${entry.task_id}`,
393
+ messages: entry.traj.map(step => {
394
+ if (step.role === 'assistant' && step.tool_calls && step.tool_calls.length > 0) {
395
+ return { role: 'assistant', content: `tool_call:${step.tool_calls[0].function.name}` }
396
+ }
397
+ if (step.role === 'tool') {
398
+ const c = step.content || ''
399
+ return { role: 'tool', content: c.length > 500 ? c.slice(0, 500) : c }
400
+ }
401
+ return { role: step.role || 'user', content: step.content || '' }
402
+ }),
403
+ annotations: { success: entry.reward === 1.0 },
404
+ }))
405
+ }
406
+
407
+ const TAU2_FILES: Record<string, string[]> = {
408
+ airline: [
409
+ 'data/tau2bench/gpt-4.1-2025-04-14_airline_default_gpt-4.1-2025-04-14_4trials.json',
410
+ 'data/tau2bench/claude-3-7-sonnet-20250219_airline_default_gpt-4.1-2025-04-14_4trials.json',
411
+ 'data/tau2bench/gpt-4.1-mini-2025-04-14_airline_base_gpt-4.1-2025-04-14_4trials.json',
412
+ 'data/tau2bench/o4-mini-2025-04-16_airline_default_gpt-4.1-2025-04-14_4trials.json',
413
+ ],
414
+ retail: [
415
+ 'data/tau2bench/gpt-4.1-2025-04-14_retail_default_gpt-4.1-2025-04-14_4trials.json',
416
+ 'data/tau2bench/claude-3-7-sonnet-20250219_retail_default_gpt-4.1-2025-04-14_4trials.json',
417
+ 'data/tau2bench/gpt-4.1-mini-2025-04-14_retail_base_gpt-4.1-2025-04-14_4trials.json',
418
+ 'data/tau2bench/o4-mini-2025-04-16_retail_default_gpt-4.1-2025-04-14_4trials.json',
419
+ ],
420
+ telecom: [
421
+ 'data/tau2bench/telecom_gpt41.json',
422
+ 'data/tau2bench/claude-3-7-sonnet-20250219_telecom_default_gpt-4.1-2025-04-14_4trials.json',
423
+ 'data/tau2bench/gpt-4.1-mini-2025-04-14_telecom_base_gpt-4.1-2025-04-14_4trials.json',
424
+ 'data/tau2bench/o4-mini-2025-04-16_telecom-workflow_default_gpt-4.1-2025-04-14_4trials.json',
425
+ ],
426
+ }
427
+
428
+ function loadTau2BenchRaw(domain: string): Array<{ id: string; messages: Array<{ role: string; content: string; name?: string }>; annotations?: { success?: boolean } }> {
429
+ const files = TAU2_FILES[domain] || []
430
+ const allTraces: Array<{ id: string; messages: Array<{ role: string; content: string; name?: string }>; annotations?: { success?: boolean } }> = []
431
+ for (const f of files) {
432
+ try {
433
+ const data: { simulations: Array<{ task_id: string; messages: Array<{ role: string; content: string; tool_calls?: Array<{ name?: string; function?: { name: string } }> }>; reward_info: { reward: number } }> } = loadJSON(f)
434
+ const modelTag = f.replace(/.*\//, '').replace(/\.json$/, '').slice(0, 30)
435
+ for (let idx = 0; idx < data.simulations.length; idx++) {
436
+ const sim = data.simulations[idx]
437
+ const messages = sim.messages
438
+ .filter((m: { role: string }) => m.role !== 'system')
439
+ .map((step: { role: string; content: string; tool_calls?: Array<{ name?: string; function?: { name: string } }> }) => {
440
+ if (step.tool_calls && step.tool_calls.length > 0) {
441
+ const tc = step.tool_calls[0]
442
+ const toolName = tc.name || tc.function?.name || 'unknown'
443
+ return { role: 'assistant', content: `tool_call:${toolName}` }
444
+ }
445
+ if (step.role === 'tool') {
446
+ const c = step.content || ''
447
+ return { role: 'tool', content: c.length > 500 ? c.slice(0, 500) : c }
448
+ }
449
+ return { role: step.role || 'user', content: step.content || '' }
450
+ })
451
+ allTraces.push({
452
+ id: `tau2bench-${domain}-${modelTag}-${sim.task_id}-${idx}`,
453
+ messages,
454
+ annotations: { success: sim.reward_info.reward === 1.0 },
455
+ })
456
+ }
457
+ } catch { console.log(` Warning: could not load ${f}`) }
458
+ }
459
+ return allTraces
460
+ }
461
+
462
+ const neuralProbe: Record<string, DatasetBundle['downstream']['neuralProbe']> =
463
+ tryLoadJSON('experiments/fsm-neural-probe/results.json') || {}
464
+
465
+ // Paper Table 4 (LLM-judged top-1, gpt-4.1-mini, fsmWorkflow format, FULL validation split).
466
+ // Authoritative for the 8 datasets in the paper; older poc-fsm-memory JSONs used gpt-4o-mini
467
+ // with a different prompt format and are not directly comparable.
468
+ const PAPER_TABLE_4: Record<string, { n: number; awm: number; fsm: number }> = {
469
+ webarena: { n: 4800, awm: 0.655, fsm: 0.812 },
470
+ swesmith: { n: 300, awm: 0.747, fsm: 1.000 },
471
+ sweagent: { n: 1200, awm: 0.677, fsm: 0.705 },
472
+ 'tau2bench-telecom': { n: 1095, awm: 0.285, fsm: 0.456 },
473
+ 'tau2bench-retail': { n: 1095, awm: 0.529, fsm: 0.651 },
474
+ 'tau2bench-airline': { n: 480, awm: 0.565, fsm: 0.573 },
475
+ atbench: { n: 600, awm: 0.478, fsm: 0.625 },
476
+ osworld: { n: 1286, awm: 0.550, fsm: 0.707 },
477
+ }
478
+
479
+ const monitorRoot: Record<string, DatasetBundle['downstream']['monitor']> =
480
+ tryLoadJSON('experiments/poc-online-monitor/online-monitor-results.json') || {}
481
+
482
+ const cfRoot: Record<string, DatasetBundle['downstream']['counterfactual']> =
483
+ tryLoadJSON('experiments/counterfactual/counterfactual-results.json') || {}
484
+
485
+ // Incremental-convergence runs (6 datasets). The per-dataset evolution-vN folders are
486
+ // LFS stubs for everything except atbench/osworld, so the dashboard convergence tab only
487
+ // saw 2 of 6 — the same 'taubench-*' vs 'tau2bench-*' keying issue. Build the curve from
488
+ // this file (fitnessTrajectory + final state/edge counts) when evolution snapshots are absent.
489
+ const convRateRoot: Record<string, {
490
+ finalStates: number; finalEdges: number; trainTraces: number
491
+ fitnessTrajectory: Array<{ pctData: number; fitness: number }>
492
+ }> = tryLoadJSON('experiments/convergence-rate/convergence-results.json') || {}
493
+
494
+ function convRateCurve(id: string): { curve: DatasetBundle['convergence']['curve']; fitnessAt: number } | null {
495
+ const c = convRateRoot[id] || convRateRoot[monitorKey(id)]
496
+ if (!c || !c.fitnessTrajectory?.length) return null
497
+ const curve = c.fitnessTrajectory.map(p => ({
498
+ n: Math.round(p.pctData * c.trainTraces),
499
+ states: c.finalStates, // per-point counts unavailable; state count converges near-instantly
500
+ transitions: c.finalEdges,
501
+ fitness: p.fitness,
502
+ }))
503
+ const hit = c.fitnessTrajectory.find(p => p.fitness >= 0.95)
504
+ return { curve, fitnessAt: hit ? Math.round(hit.pctData * c.trainTraces) : 0 }
505
+ }
506
+
507
+ // monitor/counterfactual JSONs key tau2-bench suites as 'taubench-*' (legacy
508
+ // single-tau); the dataset registry uses 'tau2bench-*'. Map before lookup so all
509
+ // four evaluated datasets (swesmith, sweagent, retail, airline) resolve, not just two.
510
+ function monitorKey(id: string): string {
511
+ return id.replace(/^tau2bench-/, 'taubench-')
512
+ }
513
+
514
+ function loadMonitor(id: string): DatasetBundle['downstream']['monitor'] {
515
+ return monitorRoot[id] || monitorRoot[monitorKey(id)] || null
516
+ }
517
+
518
+ function loadCounterfactual(id: string): DatasetBundle['downstream']['counterfactual'] {
519
+ return cfRoot[id] || cfRoot[monitorKey(id)] || null
520
+ }
521
+
522
+ function loadCrossModel(id: string): DatasetBundle['downstream']['crossModel'] {
523
+ const d = tryLoadJSON(`experiments/${id}-cross-model/transfer-matrix.json`)
524
+ if (!d || typeof d !== 'object') return null
525
+ // Matrices in the experiment JSON are stored as {labels, values}; extract the 2-D values
526
+ // (older files may already be a raw number[][]).
527
+ type Mat = number[][] | { values?: number[][] }
528
+ const matVals = (m: Mat | undefined): number[][] =>
529
+ Array.isArray(m) ? m : (m?.values || [])
530
+ const obj = d as {
531
+ models?: Array<{ name: string; shortName: string; totalTraces: number; successRate: number }>
532
+ matrices?: { fitness?: Mat; auroc?: Mat; stateCount?: Mat }
533
+ summary?: {
534
+ diagonalAUROC?: { mean: number; std: number }
535
+ offDiagonalAUROC?: { mean: number; std: number }
536
+ aurocTransferGap?: number
537
+ }
538
+ }
539
+ if (!obj.models || !obj.matrices || !obj.summary) return null
540
+ return {
541
+ models: obj.models.map(m => ({
542
+ name: m.name, shortName: m.shortName, traces: m.totalTraces, successRate: m.successRate,
543
+ })),
544
+ matrices: {
545
+ fitness: matVals(obj.matrices.fitness),
546
+ auroc: matVals(obj.matrices.auroc),
547
+ stateCount: matVals(obj.matrices.stateCount),
548
+ },
549
+ summary: {
550
+ diagonalAUROC: obj.summary.diagonalAUROC || { mean: 0, std: 0 },
551
+ offDiagonalAUROC: obj.summary.offDiagonalAUROC || { mean: 0, std: 0 },
552
+ aurocTransferGap: obj.summary.aurocTransferGap || 0,
553
+ },
554
+ }
555
+ }
556
+
557
+ function loadWorkflowMemory(id: string): DatasetBundle['downstream']['workflowMemory'] {
558
+ const paper = PAPER_TABLE_4[id]
559
+ if (paper) {
560
+ // Use paper-authoritative numbers; pull noMemory from the older JSON if available.
561
+ const json = tryLoadJSON(`experiments/poc-fsm-memory/${id}-results.json`)
562
+ const noMem = (json && typeof json === 'object'
563
+ ? ((json as { llm?: { noMemory?: { accuracy: number } } }).llm?.noMemory?.accuracy ?? null)
564
+ : null)
565
+ return {
566
+ n: paper.n,
567
+ noMemory: { accuracy: noMem ?? 0, correct: noMem != null ? Math.round(noMem * paper.n) : 0 },
568
+ awm: { accuracy: paper.awm, correct: Math.round(paper.awm * paper.n) },
569
+ fsm: { accuracy: paper.fsm, correct: Math.round(paper.fsm * paper.n) },
570
+ fsmGain: paper.fsm - paper.awm,
571
+ }
572
+ }
573
+ return null
574
+ }
575
+
576
+ function buildDataset(id: string): DatasetBundle {
577
+ // tau2-bench: the combined 4-model run lives in the UNVERSIONED folder; the versioned
578
+ // (-v11) folders are stale single-model runs (telecom 5 states vs combined 43). Prefer
579
+ // the combined run for tau2 so baselines/graph match the paper.
580
+ const combinedFirst = id.startsWith('tau2bench-')
581
+
582
+ // Load baselines: (tau2: unversioned combined first) → v12 → v11 → v8 → v7 → v6 → unversioned
583
+ const baseline = (combinedFirst ? tryLoadJSON(`experiments/${id}-baselines/baseline-comparison.json`) : null)
584
+ || tryLoadJSON(`experiments/${id}-baselines-v12/baseline-comparison.json`)
585
+ || tryLoadJSON(`experiments/${id}-baselines-v11/baseline-comparison.json`)
586
+ || tryLoadJSON(`experiments/${id}-baselines-v8/baseline-comparison.json`)
587
+ || tryLoadJSON(`experiments/${id}-baselines-v7/baseline-comparison.json`)
588
+ || tryLoadJSON(`experiments/${id}-baselines-v6/baseline-comparison.json`)
589
+ || loadJSON(`experiments/${id}-baselines/baseline-comparison.json`)
590
+
591
+ // Load evolution: v9 → v8 → v7 → v6 → v5
592
+ let evolution: Record<string, unknown>
593
+ const ev = tryLoadJSON(`experiments/${id}-evolution-v9/evolution-results.json`)
594
+ || tryLoadJSON(`experiments/${id}-evolution-v8/evolution-results.json`)
595
+ || tryLoadJSON(`experiments/${id}-evolution-v7/evolution-results.json`)
596
+ || tryLoadJSON(`experiments/${id}-evolution-v6/evolution-results.json`)
597
+ || tryLoadJSON(`experiments/${id}-evolution-v5/evolution-results.json`)
598
+ evolution = ev || {}
599
+ if (ev) console.log(` Loaded evolution for ${id}`)
600
+ else console.log(` No evolution data for ${id}`)
601
+
602
+ // Load downstream: v15 → v14 → v13 → v12 → v11 → v10-fixed → v9 → v8 → v7 → v2 → v6 → v5
603
+ const downstream: Record<string, unknown> = tryLoadJSON(`experiments/${id}-downstream-v15/downstream-eval.json`)
604
+ || tryLoadJSON(`experiments/${id}-downstream-v14/downstream-eval.json`)
605
+ || tryLoadJSON(`experiments/${id}-downstream-v13/downstream-eval.json`)
606
+ || tryLoadJSON(`experiments/${id}-downstream-v12/downstream-eval.json`)
607
+ || tryLoadJSON(`experiments/${id}-downstream-v11/downstream-eval.json`)
608
+ || tryLoadJSON(`experiments/${id}-downstream-v10-fixed/downstream-eval.json`)
609
+ || tryLoadJSON(`experiments/${id}-downstream-v9/downstream-eval.json`)
610
+ || tryLoadJSON(`experiments/${id}-downstream-v8/downstream-eval.json`)
611
+ || tryLoadJSON(`experiments/${id}-downstream-v7/downstream-eval.json`)
612
+ || tryLoadJSON(`experiments/${id}-downstream-v2/downstream-eval.json`)
613
+ || tryLoadJSON(`experiments/${id}-downstream-v6/downstream-eval.json`)
614
+ || tryLoadJSON(`experiments/${id}-downstream-v5/downstream-eval.json`)
615
+ || {}
616
+ if (Object.keys(downstream).length > 0) console.log(` Loaded downstream for ${id}`)
617
+ else console.log(` No downstream data for ${id}`)
618
+
619
+ // Load graph: (tau2: unversioned combined first) → v12 → v11 → v8 → v7 → v6 → unversioned
620
+ const graph = (combinedFirst ? tryLoadJSON(`experiments/${id}-baselines/train-graph.json`) : null)
621
+ || tryLoadJSON(`experiments/${id}-baselines-v12/train-graph.json`)
622
+ || tryLoadJSON(`experiments/${id}-baselines-v11/train-graph.json`)
623
+ || tryLoadJSON(`experiments/${id}-baselines-v8/train-graph.json`)
624
+ || tryLoadJSON(`experiments/${id}-baselines-v7/train-graph.json`)
625
+ || tryLoadJSON(`experiments/${id}-baselines-v6/train-graph.json`)
626
+ || loadJSON(`experiments/${id}-baselines/train-graph.json`)
627
+
628
+ // Load raw traces for FSM replay
629
+ type RawTrace = { id: string; messages: Array<{ role: string; content: string; name?: string }>; annotations?: { success?: boolean } }
630
+ let rawTraces: RawTrace[]
631
+ if (id === 'taubench-airline') {
632
+ rawTraces = loadTauBenchRaw('airline')
633
+ } else if (id === 'taubench-retail') {
634
+ rawTraces = loadTauBenchRaw('retail')
635
+ } else if (id.startsWith('tau2bench-')) {
636
+ const domain = id.replace('tau2bench-', '')
637
+ rawTraces = loadTau2BenchRaw(domain)
638
+ } else {
639
+ rawTraces = loadJSON(`data/${id}/traces.json`)
640
+ }
641
+ const traceMap = new Map(rawTraces.map(t => [t.id, t]))
642
+
643
+ // For tau2bench: also register traces under the old ID format (without modelTag)
644
+ // so baselines generated with the old loader still match.
645
+ // Old format: tau2bench-{domain}-{task_id}-{idx}
646
+ // New format: tau2bench-{domain}-{modelTag}-{task_id}-{idx}
647
+ if (id.startsWith('tau2bench-')) {
648
+ const domain = id.replace('tau2bench-', '')
649
+ const files = TAU2_FILES[domain] || []
650
+ for (const f of files) {
651
+ try {
652
+ const data: { simulations: Array<{ task_id: string }> } = loadJSON(f)
653
+ for (let idx = 0; idx < data.simulations.length; idx++) {
654
+ const sim = data.simulations[idx]
655
+ const modelTag = f.replace(/.*\//, '').replace(/\.json$/, '').slice(0, 30)
656
+ const newId = `tau2bench-${domain}-${modelTag}-${sim.task_id}-${idx}`
657
+ const oldId = `tau2bench-${domain}-${sim.task_id}-${idx}`
658
+ const trace = traceMap.get(newId)
659
+ if (trace && !traceMap.has(oldId)) {
660
+ traceMap.set(oldId, trace)
661
+ }
662
+ }
663
+ } catch { /* skip */ }
664
+ }
665
+ }
666
+
667
+ console.log(` Loaded ${rawTraces.length} raw traces for replay (traceMap: ${traceMap.size} keys)`)
668
+
669
+ // Build transition function from graph
670
+ const transitionFn = graphToTransitionFn(graph)
671
+
672
+ // Extract baselines
673
+ const baselines: DatasetBundle['baselines'] = []
674
+
675
+ // Our FSM
676
+ const sg = baseline.stateGraph
677
+ baselines.push({
678
+ method: 'stateGraph',
679
+ label: 'Our FSM',
680
+ trainFitness: sg.trainFitness,
681
+ testFitness: sg.testFitness,
682
+ states: sg.stateCount,
683
+ transitions: sg.transitionCount,
684
+ randomAcceptRate: sg.precision?.randomAcceptanceRate ?? null,
685
+ permutedAcceptRate: sg.precision?.permutedAcceptanceRate ?? null,
686
+ pmPrecision: null,
687
+ category: 'ours',
688
+ note: sg.note,
689
+ })
690
+
691
+ // RPNI
692
+ const rpni = baseline.rpni
693
+ baselines.push({
694
+ method: 'rpni',
695
+ label: 'RPNI',
696
+ trainFitness: rpni.trainFitness,
697
+ testFitness: rpni.testFitness,
698
+ states: rpni.stateCount,
699
+ transitions: rpni.transitionCount,
700
+ randomAcceptRate: rpni.precision?.randomAcceptanceRate ?? null,
701
+ permutedAcceptRate: rpni.precision?.permutedAcceptanceRate ?? null,
702
+ pmPrecision: null,
703
+ category: 'automata',
704
+ note: rpni.note,
705
+ })
706
+
707
+ // PM4Py miners
708
+ const pm4py = baseline.pm4py?.miners || {}
709
+ for (const [name, m] of Object.entries(pm4py) as [string, Record<string, unknown>][]) {
710
+ if (!m.success) continue
711
+ const label = name.charAt(0).toUpperCase() + name.slice(1) + ' Miner'
712
+ baselines.push({
713
+ method: name,
714
+ label,
715
+ trainFitness: m.train_fitness as number,
716
+ testFitness: m.test_fitness as number,
717
+ states: null,
718
+ transitions: (m.transitions as number) || null,
719
+ randomAcceptRate: null,
720
+ permutedAcceptRate: null,
721
+ pmPrecision: (m.test_precision as number) ?? null,
722
+ category: 'process-mining',
723
+ note: `${m.places || '?'}p, ${m.transitions || '?'}t`,
724
+ })
725
+ }
726
+
727
+ // AWM
728
+ if (baseline.awm) {
729
+ const awm = baseline.awm
730
+ baselines.push({
731
+ method: 'awm',
732
+ label: 'AWM',
733
+ trainFitness: awm.trainCoverage || 0,
734
+ testFitness: awm.testAvgMatchScore || 0,
735
+ states: null,
736
+ transitions: null,
737
+ randomAcceptRate: null,
738
+ permutedAcceptRate: null,
739
+ pmPrecision: null,
740
+ category: 'workflow',
741
+ note: `${awm.count} workflows (success-only)`,
742
+ })
743
+ }
744
+
745
+ if (baseline.awm_all) {
746
+ const awm = baseline.awm_all
747
+ baselines.push({
748
+ method: 'awm_all',
749
+ label: 'AWM-all',
750
+ trainFitness: awm.trainCoverage || 0,
751
+ testFitness: awm.testAvgMatchScore || 0,
752
+ states: null,
753
+ transitions: null,
754
+ randomAcceptRate: null,
755
+ permutedAcceptRate: null,
756
+ pmPrecision: null,
757
+ category: 'workflow',
758
+ note: `${awm.count} workflows (all traces)`,
759
+ })
760
+ }
761
+
762
+ // EDSM
763
+ if (baseline.edsm && (baseline.edsm as Record<string, unknown>).success) {
764
+ const edsm = baseline.edsm as Record<string, unknown>
765
+ baselines.push({
766
+ method: 'edsm',
767
+ label: 'EDSM',
768
+ trainFitness: (edsm.train_fitness as number) || 0,
769
+ testFitness: (edsm.test_fitness as number) || 0,
770
+ states: (edsm.state_count as number) || null,
771
+ transitions: (edsm.transition_count as number) || null,
772
+ randomAcceptRate: null,
773
+ permutedAcceptRate: null,
774
+ pmPrecision: null,
775
+ category: 'automata',
776
+ note: `EDSM (AALpy): ${edsm.state_count}S, ${edsm.alphabet_size || '?'} alphabet`,
777
+ })
778
+ }
779
+
780
+ // Evolution curve
781
+ const snapshots = evolution.snapshots || evolution.curve || []
782
+ let curve = snapshots.map((p: Record<string, number>) => ({
783
+ n: p.traceCount,
784
+ states: p.states || p.stateCount,
785
+ transitions: p.transitions || p.transitionCount,
786
+ fitness: p.testFitness || p.fitness,
787
+ }))
788
+ const conv = evolution.convergence || {}
789
+ // Fall back to the incremental-convergence-rate run when the evolution file is a stub.
790
+ if (curve.length === 0) {
791
+ const cr = convRateCurve(id)
792
+ if (cr) {
793
+ curve = cr.curve
794
+ conv.fitnessConvergenceAt = conv.fitnessConvergenceAt || cr.fitnessAt
795
+ console.log(` Convergence curve from convergence-rate run for ${id}`)
796
+ }
797
+ }
798
+
799
+ // Per-trace data with FSM traversal
800
+ const trainIds: string[] = baseline.config?.trainIds || []
801
+ const testIds: string[] = baseline.config?.testIds || []
802
+ const perTrace: Record<string, { success: boolean; fullFitness: number; successFitness: number }> = {}
803
+ for (const t of ((downstream as any).failurePrediction?.perTrace || [])) {
804
+ perTrace[t.id] = { success: t.success, fullFitness: t.fullFitness, successFitness: t.successFitness }
805
+ }
806
+
807
+ const traces: TraceDetail[] = []
808
+ let replayCount = 0
809
+
810
+ function buildTraceDetail(tid: string, split: 'train' | 'test'): TraceDetail {
811
+ const pt = perTrace[tid]
812
+ const raw = traceMap.get(tid)
813
+
814
+ if (raw) {
815
+ const replay = replayTrace(transitionFn, raw.messages)
816
+ replayCount++
817
+ return {
818
+ id: tid,
819
+ split,
820
+ success: pt?.success ?? raw.annotations?.success ?? null,
821
+ fullFitness: pt?.fullFitness ?? null,
822
+ successFitness: pt?.successFitness ?? null,
823
+ steps: replay.steps,
824
+ stateSequence: replay.stateSequence,
825
+ fitness: replay.fitness,
826
+ consumed: replay.consumed,
827
+ total: replay.total,
828
+ firstFailIdx: replay.firstFailIdx,
829
+ stateVisits: replay.stateVisits,
830
+ }
831
+ }
832
+
833
+ // Fallback: no raw trace data
834
+ return {
835
+ id: tid,
836
+ split,
837
+ success: pt?.success ?? null,
838
+ fullFitness: pt?.fullFitness ?? null,
839
+ successFitness: pt?.successFitness ?? null,
840
+ steps: [],
841
+ stateSequence: [],
842
+ fitness: 0,
843
+ consumed: 0,
844
+ total: 0,
845
+ firstFailIdx: -1,
846
+ stateVisits: {},
847
+ }
848
+ }
849
+
850
+ for (const tid of trainIds) traces.push(buildTraceDetail(tid, 'train'))
851
+ for (const tid of testIds) traces.push(buildTraceDetail(tid, 'test'))
852
+ console.log(` Replayed ${replayCount}/${traces.length} traces through FSM`)
853
+
854
+ // Load multi-seed data
855
+ let multiseed: DatasetBundle['multiseed'] = null
856
+ try {
857
+ const ms = loadJSON(`experiments/${id}-multiseed/multiseed-results.json`)
858
+ multiseed = {
859
+ numSeeds: ms.config?.numSeeds || 5,
860
+ ourFSM: ms.aggregated?.ourFSM || { testFitness: { mean: 0, std: 0 }, states: { mean: 0, std: 0 } },
861
+ rpni: ms.aggregated?.rpni || { testFitness: { mean: 0, std: 0 }, states: { mean: 0, std: 0 } },
862
+ awm: ms.aggregated?.awm || { testFitness: { mean: 0, std: 0 } },
863
+ }
864
+ console.log(` Loaded multi-seed (${multiseed.numSeeds} seeds)`)
865
+ } catch { console.log(` No multi-seed data for ${id}`) }
866
+
867
+ // Build full downstream
868
+ const ds = downstream as Record<string, any>
869
+ const fp = ds.fitnessProfile || null
870
+ const bp = ds.behavioralProfiling || null
871
+ const sa = ds.structuralAnalysis || null
872
+ const df = ds.differentialFSM?.skipped ? null : (ds.differentialFSM || null)
873
+ // Failure prediction is a labeled task; the 3 unlabeled datasets have no trustworthy
874
+ // success labels (gui-odyssey yields a degenerate AUROC 1.000), so null them out to
875
+ // match the paper's 9-labeled-dataset failure-prediction set.
876
+ const UNLABELED = new Set(['gui-odyssey', 'mind2web', 'whoandwhen'])
877
+ const pred = (UNLABELED.has(id) || ds.failurePrediction?.skipped) ? null : (ds.failurePrediction || null)
878
+ const ml = ds.mistakeLocalization || null
879
+
880
+ return {
881
+ id,
882
+ meta: META[id],
883
+ baselines,
884
+ traces,
885
+ convergence: {
886
+ stateConvergenceAt: conv.stateConvergenceAt || 0,
887
+ transitionConvergenceAt: conv.transitionConvergenceAt || 0,
888
+ fitnessConvergenceAt: conv.fitnessConvergenceAt || 0,
889
+ curve,
890
+ },
891
+ downstream: {
892
+ fitnessProfile: fp ? {
893
+ mean: fp.mean, std: fp.std, min: fp.min, max: fp.max,
894
+ anomalyCount: fp.anomalyCount, anomalies: fp.anomalies?.map((a: any) => a.id || a) || [],
895
+ histogram: fp.histogram || [],
896
+ } : null,
897
+ behavioralProfiling: bp ? {
898
+ stateVisitDistribution: bp.stateVisitDistribution || [],
899
+ transitionFrequency: bp.transitionFrequency || [],
900
+ deadTransitions: bp.deadTransitions || [],
901
+ transitionCoverage: bp.transitionCoverage ?? 1,
902
+ avgUniqueStatesPerTrace: bp.avgUniqueStatesPerTrace ?? 0,
903
+ } : null,
904
+ structuralAnalysis: sa ? {
905
+ density: sa.density,
906
+ hubs: sa.hubs || [],
907
+ sinks: sa.sinks || [],
908
+ sources: sa.sources || [],
909
+ selfLoops: sa.selfLoops || [],
910
+ nonTrivialSCCs: sa.nonTrivialSCCs || [],
911
+ } : null,
912
+ differentialFSM: df ? {
913
+ successOnlyTransitions: df.successOnlyTransitions || [],
914
+ failureOnlyTransitions: df.failureOnlyTransitions || [],
915
+ sharedTransitions: typeof df.sharedTransitions === 'number'
916
+ ? [] : (df.sharedTransitions || []),
917
+ } : null,
918
+ failurePrediction: pred,
919
+ mistakeLocalization: ml,
920
+ neuralProbe: neuralProbe[id] || null,
921
+ workflowMemory: loadWorkflowMemory(id),
922
+ crossModel: loadCrossModel(id),
923
+ monitor: loadMonitor(id),
924
+ counterfactual: loadCounterfactual(id),
925
+ },
926
+ multiseed,
927
+ graph: {
928
+ states: (graph.states || []).map((s: Record<string, string>) => ({ id: s.id })),
929
+ transitions: (graph.transitions || []).map((t: Record<string, string>) => ({
930
+ source: t.source,
931
+ target: t.target,
932
+ subject: t.subject || '',
933
+ })),
934
+ },
935
+ }
936
+ }
937
+
938
+ // Cap heaviest datasets to keep each shard under 22 MB
939
+ // (Cloudflare Pages / Netlify hard limit is 25 MB per static file).
940
+ // Traces are sampled deterministically from the full set, preserving train/test split balance.
941
+ const SHARD_TRACE_CAP: Record<string, number> = {
942
+ agentnet: 3500,
943
+ }
944
+
945
+ function capTraces(d: DatasetBundle): DatasetBundle {
946
+ const cap = SHARD_TRACE_CAP[d.id]
947
+ if (!cap || d.traces.length <= cap) return d
948
+ const train = d.traces.filter(t => t.split === 'train')
949
+ const test = d.traces.filter(t => t.split === 'test')
950
+ const ratio = train.length / (train.length + test.length)
951
+ const trainKeep = Math.round(cap * ratio)
952
+ const testKeep = cap - trainKeep
953
+ // Deterministic: keep the first N of each split (already shuffled by trainTestSplit seed).
954
+ const truncated = [...train.slice(0, trainKeep), ...test.slice(0, testKeep)]
955
+ return { ...d, traces: truncated }
956
+ }
957
+
958
+ function main() {
959
+ const data = DATASETS.map(buildDataset).map(capTraces)
960
+ const pub = path.join(__dirname, '../public')
961
+ const shardDir = path.join(pub, 'datasets')
962
+ mkdirSync(pub, { recursive: true })
963
+ mkdirSync(shardDir, { recursive: true })
964
+
965
+ // Per-dataset shards (lets deploy hosts with ≤25MB-per-file limits accept the build).
966
+ for (const d of data) {
967
+ writeFileSync(path.join(shardDir, `${d.id}.json`), JSON.stringify(d))
968
+ }
969
+
970
+ // Slim index for the landing/list views (everything except per-trace replay details).
971
+ const index = data.map(d => ({
972
+ id: d.id, meta: d.meta, baselines: d.baselines,
973
+ convergence: d.convergence, multiseed: d.multiseed, graph: d.graph,
974
+ downstream: {
975
+ ...d.downstream,
976
+ // Drop the largest passthrough blobs from the index to keep it < ~2MB.
977
+ failurePrediction: d.downstream.failurePrediction
978
+ ? { ...d.downstream.failurePrediction, perTrace: undefined }
979
+ : null,
980
+ mistakeLocalization: null,
981
+ },
982
+ traceCount: d.traces.length,
983
+ }))
984
+ writeFileSync(path.join(pub, 'index.json'), JSON.stringify(index))
985
+
986
+ // Legacy monolithic file (kept for back-compat with older App.tsx).
987
+ writeFileSync(path.join(pub, 'data.json'), JSON.stringify(data))
988
+
989
+ console.log(`\nBuilt data.json + index.json + ${data.length} per-dataset shards`)
990
+ for (const d of data) {
991
+ const withSteps = d.traces.filter(t => t.steps.length > 0).length
992
+ console.log(` ${d.meta.name}: ${d.baselines.length} baselines, ${withSteps}/${d.traces.length} traces with FSM replay`)
993
+ }
994
+ }
995
+
996
+ main()
src/App.tsx ADDED
The diff for this file is too large to render. See raw diff
 
src/LandingView.tsx ADDED
@@ -0,0 +1,940 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Landing page: Turing machine tape feeds into evolving FSM graph.
3
+ * Receipt-like tape feeds from top-left into the graph that grows in the center.
4
+ * All traces concatenated — drag the tape to scrub, or play to auto-advance through all traces.
5
+ */
6
+ import { useRef, useMemo, useState, useEffect, useCallback } from 'react'
7
+
8
+ // ---------- Types ----------
9
+
10
+ interface GraphState { id: string }
11
+ interface GraphTransition { source: string; target: string; subject: string }
12
+ interface TraceStep {
13
+ idx: number; role: string; activity: string
14
+ fromState: string; toState: string | null; consumed: boolean
15
+ }
16
+ interface Trace {
17
+ id: string; steps: TraceStep[]; success: boolean | null
18
+ fitness: number; total: number
19
+ }
20
+ interface Dataset {
21
+ id: string
22
+ meta: { name: string; shortName: string; traces: number; activities: number; description: string }
23
+ graph: { states: GraphState[]; transitions: GraphTransition[] }
24
+ traces: Trace[]
25
+ convergence: { curve: Array<{ n: number; states: number; transitions: number; fitness: number }> }
26
+ }
27
+
28
+ // ---------- Palette ----------
29
+
30
+ const RUST = '#c4553a'
31
+ const SLATE = '#3d5a80'
32
+ const OCHRE = '#c49a3a'
33
+ const PAPER = '#f5f0e8'
34
+ const INK = '#1a1714'
35
+ const INK3 = '#8a8478'
36
+ const INK4 = '#b5afa5'
37
+
38
+ // ---------- Force layout ----------
39
+
40
+ function computeLayout(
41
+ states: GraphState[], transitions: GraphTransition[], w = 400, h = 400
42
+ ): Record<string, { x: number; y: number }> {
43
+ const n = states.length
44
+ if (n === 0) return {}
45
+ const cx = w / 2, cy = h / 2
46
+ const pos: Record<string, { x: number; y: number }> = {}
47
+ const vel: Record<string, { x: number; y: number }> = {}
48
+ const initIdx = states.findIndex(s => s.id === 'init')
49
+ const r0 = Math.min(140, n * 18)
50
+ states.forEach((s, i) => {
51
+ const idx = initIdx >= 0 ? (i - initIdx + n) % n : i
52
+ const angle = (idx / n) * 2 * Math.PI - Math.PI / 2
53
+ pos[s.id] = { x: cx + r0 * Math.cos(angle), y: cy + r0 * Math.sin(angle) }
54
+ vel[s.id] = { x: 0, y: 0 }
55
+ })
56
+ const adj = new Map<string, Set<string>>()
57
+ for (const s of states) adj.set(s.id, new Set())
58
+ for (const t of transitions) {
59
+ if (t.source !== t.target) {
60
+ adj.get(t.source)?.add(t.target)
61
+ adj.get(t.target)?.add(t.source)
62
+ }
63
+ }
64
+ for (let iter = 0; iter < 200; iter++) {
65
+ const temp = 1 - iter / 200
66
+ for (const a of states) {
67
+ let fx = 0, fy = 0
68
+ for (const b of states) {
69
+ if (a.id === b.id) continue
70
+ const dx = pos[a.id].x - pos[b.id].x, dy = pos[a.id].y - pos[b.id].y
71
+ const d2 = dx * dx + dy * dy + 1, f = 6000 / d2
72
+ fx += f * dx / Math.sqrt(d2); fy += f * dy / Math.sqrt(d2)
73
+ }
74
+ const neighbors = adj.get(a.id)
75
+ if (neighbors) for (const bId of neighbors) {
76
+ const dx = pos[bId].x - pos[a.id].x, dy = pos[bId].y - pos[a.id].y
77
+ const d = Math.sqrt(dx * dx + dy * dy), f = 0.008 * (d - 80)
78
+ fx += f * dx / (d + 0.1); fy += f * dy / (d + 0.1)
79
+ }
80
+ fx += (cx - pos[a.id].x) * 0.002; fy += (cy - pos[a.id].y) * 0.002
81
+ vel[a.id].x = (vel[a.id].x + fx) * 0.85 * temp
82
+ vel[a.id].y = (vel[a.id].y + fy) * 0.85 * temp
83
+ }
84
+ for (const s of states) {
85
+ pos[s.id].x = Math.max(40, Math.min(w - 40, pos[s.id].x + vel[s.id].x))
86
+ pos[s.id].y = Math.max(40, Math.min(h - 40, pos[s.id].y + vel[s.id].y))
87
+ }
88
+ }
89
+ return pos
90
+ }
91
+
92
+ // ---------- Concatenated step with trace boundary info ----------
93
+
94
+ interface FlatStep {
95
+ activity: string
96
+ consumed: boolean
97
+ fromState: string
98
+ toState: string | null
99
+ traceIdx: number
100
+ traceId: string
101
+ stepInTrace: number
102
+ isTraceStart: boolean
103
+ }
104
+
105
+ function flattenTraces(traces: Trace[]): FlatStep[] {
106
+ const out: FlatStep[] = []
107
+ const subset = traces.filter(t => t.steps.length > 3)
108
+ for (let ti = 0; ti < subset.length; ti++) {
109
+ const trace = subset[ti]
110
+ for (let si = 0; si < trace.steps.length; si++) {
111
+ const s = trace.steps[si]
112
+ out.push({
113
+ activity: s.activity,
114
+ consumed: s.consumed,
115
+ fromState: s.fromState,
116
+ toState: s.toState,
117
+ traceIdx: ti,
118
+ traceId: trace.id,
119
+ stepInTrace: si,
120
+ isTraceStart: si === 0,
121
+ })
122
+ }
123
+ }
124
+ return out
125
+ }
126
+
127
+ // ---------- Vertical Receipt Tape with drag ----------
128
+
129
+ const CELL_H = 28
130
+ const CELL_W = 160
131
+
132
+ const TRACE_HEADER_H = 18
133
+
134
+ /** Precompute Y offsets for all steps.
135
+ * offsets[i] = Y of the CELL top (after any trace header).
136
+ * headerBefore[i] = extra header height before this cell.
137
+ */
138
+ function buildStepOffsets(steps: FlatStep[]): { cellTop: number[]; totalH: number } {
139
+ const cellTop = new Array(steps.length + 1)
140
+ let y = 0
141
+ for (let i = 0; i < steps.length; i++) {
142
+ if (steps[i].isTraceStart) y += TRACE_HEADER_H
143
+ cellTop[i] = y // Y of the cell itself, after header
144
+ y += CELL_H
145
+ }
146
+ cellTop[steps.length] = y
147
+ return { cellTop, totalH: y }
148
+ }
149
+
150
+ /** Y of everything above step i (headers + cells), for virtualization top-pad */
151
+ function blockTop(steps: FlatStep[], cellTop: number[], i: number): number {
152
+ if (i <= 0) return 0
153
+ // blockTop = cellTop of step i, minus header if step i has one
154
+ return steps[i]?.isTraceStart ? cellTop[i] - TRACE_HEADER_H : cellTop[i]
155
+ }
156
+
157
+ function ReceiptTape({ steps, readHead, onScrub }: {
158
+ steps: FlatStep[]
159
+ readHead: number
160
+ onScrub: (newHead: number) => void
161
+ }) {
162
+ const containerRef = useRef<HTMLDivElement>(null)
163
+ const dragging = useRef(false)
164
+ const dragStartY = useRef(0)
165
+ const dragStartHead = useRef(0)
166
+ const didDrag = useRef(false)
167
+
168
+ // Use 100% of container height via ref
169
+ const [containerH, setContainerH] = useState(600)
170
+ useEffect(() => {
171
+ const el = containerRef.current?.parentElement
172
+ if (el) {
173
+ const ro = new ResizeObserver(entries => {
174
+ for (const entry of entries) setContainerH(entry.contentRect.height)
175
+ })
176
+ ro.observe(el)
177
+ setContainerH(el.clientHeight)
178
+ return () => ro.disconnect()
179
+ }
180
+ }, [])
181
+
182
+ const headOffset = Math.round(containerH * 0.4)
183
+
184
+ // Precompute offsets O(n) once, then O(1) lookup
185
+ const { cellTop: stepOffsets, totalH: tapeTotalH } = useMemo(() => buildStepOffsets(steps), [steps])
186
+ const scrollY = (stepOffsets[readHead] ?? 0) - headOffset
187
+
188
+ const handlePointerDown = useCallback((e: React.PointerEvent) => {
189
+ dragging.current = true
190
+ didDrag.current = false
191
+ dragStartY.current = e.clientY
192
+ dragStartHead.current = readHead
193
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
194
+ }, [readHead])
195
+
196
+ const handlePointerMove = useCallback((e: React.PointerEvent) => {
197
+ if (!dragging.current) return
198
+ const dy = dragStartY.current - e.clientY
199
+ if (Math.abs(dy) > 3) didDrag.current = true
200
+ const delta = Math.round(dy / CELL_H)
201
+ const newHead = Math.max(0, Math.min(steps.length, dragStartHead.current + delta))
202
+ onScrub(newHead)
203
+ }, [steps.length, onScrub])
204
+
205
+ const handlePointerUp = useCallback(() => {
206
+ dragging.current = false
207
+ }, [])
208
+
209
+ const handleWheel = useCallback((e: React.WheelEvent) => {
210
+ e.preventDefault()
211
+ const delta = Math.sign(e.deltaY)
212
+ onScrub(Math.max(0, Math.min(steps.length, readHead + delta)))
213
+ }, [readHead, steps.length, onScrub])
214
+
215
+ // Click on a cell to jump
216
+ const handleCellClick = useCallback((i: number) => {
217
+ if (didDrag.current) return // was a drag, not a click
218
+ onScrub(i)
219
+ }, [onScrub])
220
+
221
+ return (
222
+ <div
223
+ ref={containerRef}
224
+ onPointerDown={handlePointerDown}
225
+ onPointerMove={handlePointerMove}
226
+ onPointerUp={handlePointerUp}
227
+ onPointerCancel={handlePointerUp}
228
+ onWheel={handleWheel}
229
+ style={{
230
+ width: CELL_W + 16,
231
+ height: '100%',
232
+ overflow: 'hidden',
233
+ position: 'relative',
234
+ borderRight: `2px solid ${INK}20`,
235
+ cursor: 'grab',
236
+ userSelect: 'none',
237
+ touchAction: 'none',
238
+ }}
239
+ >
240
+ {/* Tape strip — virtualized: only render visible steps */}
241
+ {(() => {
242
+ // Find visible range
243
+ const viewTop = scrollY - 100 // buffer
244
+ const viewBot = scrollY + containerH + 100
245
+ let startIdx = 0, endIdx = steps.length
246
+ // Binary search for start
247
+ let lo = 0, hi = steps.length
248
+ while (lo < hi) {
249
+ const mid = (lo + hi) >> 1
250
+ if (stepOffsets[mid] + CELL_H + TRACE_HEADER_H < viewTop) lo = mid + 1
251
+ else hi = mid
252
+ }
253
+ startIdx = Math.max(0, lo - 1)
254
+ lo = startIdx; hi = steps.length
255
+ while (lo < hi) {
256
+ const mid = (lo + hi) >> 1
257
+ if (stepOffsets[mid] > viewBot) hi = mid
258
+ else lo = mid + 1
259
+ }
260
+ endIdx = Math.min(steps.length, lo + 1)
261
+
262
+ const topPad = blockTop(steps, stepOffsets, startIdx)
263
+ const totalH = tapeTotalH
264
+
265
+ return (
266
+ <div style={{
267
+ position: 'absolute',
268
+ left: 8,
269
+ top: 0,
270
+ width: CELL_W,
271
+ height: totalH,
272
+ transform: `translateY(${-scrollY}px)`,
273
+ }}>
274
+ <div style={{ height: topPad }} />
275
+ {steps.slice(startIdx, endIdx).map((step, idx) => {
276
+ const i = startIdx + idx
277
+ const isRead = i < readHead
278
+ const isCurrent = i === readHead
279
+ const label = step.activity.length > 14 ? step.activity.slice(0, 12) + '..' : step.activity
280
+
281
+ return (
282
+ <div key={i} onClick={() => handleCellClick(i)}>
283
+ {step.isTraceStart && (
284
+ <div style={{
285
+ height: TRACE_HEADER_H,
286
+ display: 'flex',
287
+ alignItems: 'center',
288
+ paddingLeft: 6,
289
+ background: isRead ? PAPER : '#f8f5ef',
290
+ borderTop: i > 0 ? `1.5px solid ${isRead ? `${RUST}40` : INK4}` : 'none',
291
+ opacity: isRead ? 0.5 : 1,
292
+ }}>
293
+ <span style={{
294
+ fontFamily: "'IBM Plex Mono', monospace",
295
+ fontSize: 7,
296
+ color: isRead ? INK4 : OCHRE,
297
+ textTransform: 'uppercase',
298
+ letterSpacing: '0.5px',
299
+ }}>
300
+ trace {step.traceIdx + 1}: {step.traceId.slice(0, 18)}
301
+ </span>
302
+ </div>
303
+ )}
304
+ <div style={{
305
+ width: CELL_W,
306
+ height: CELL_H,
307
+ display: 'flex',
308
+ alignItems: 'center',
309
+ paddingLeft: 10,
310
+ borderBottom: `1px solid ${isRead ? '#d5cfc520' : '#d5cfc5'}`,
311
+ background: isCurrent ? '#fffcf3' : isRead ? PAPER : '#fffdf9',
312
+ opacity: isRead ? Math.max(0.2, 1 - (readHead - i) * 0.04) : 1,
313
+ cursor: 'pointer',
314
+ }}>
315
+ <span style={{
316
+ fontFamily: "'IBM Plex Mono', monospace",
317
+ fontSize: 8,
318
+ color: isCurrent ? RUST : INK4,
319
+ width: 20,
320
+ textAlign: 'right',
321
+ marginRight: 8,
322
+ flexShrink: 0,
323
+ }}>
324
+ {step.stepInTrace + 1}
325
+ </span>
326
+ <span style={{
327
+ fontFamily: "'IBM Plex Mono', monospace",
328
+ fontSize: 10,
329
+ color: isCurrent ? RUST : isRead ? INK4 : INK3,
330
+ fontWeight: isCurrent ? 700 : 400,
331
+ whiteSpace: 'nowrap',
332
+ overflow: 'hidden',
333
+ textOverflow: 'ellipsis',
334
+ }}>
335
+ {label}
336
+ </span>
337
+ {isRead && (
338
+ <span style={{
339
+ marginLeft: 'auto',
340
+ marginRight: 6,
341
+ width: 5,
342
+ height: 5,
343
+ borderRadius: '50%',
344
+ background: step.consumed ? RUST : `${SLATE}60`,
345
+ flexShrink: 0,
346
+ }} />
347
+ )}
348
+ </div>
349
+ </div>
350
+ )
351
+ })}
352
+ </div>
353
+ )
354
+ })()}
355
+
356
+ {/* Reading head bracket — fixed at headOffset */}
357
+ <div style={{
358
+ position: 'absolute',
359
+ left: 0,
360
+ right: 0,
361
+ top: headOffset - 1,
362
+ height: CELL_H + 2,
363
+ border: `2px solid ${RUST}`,
364
+ borderRadius: 2,
365
+ pointerEvents: 'none',
366
+ zIndex: 2,
367
+ }} />
368
+ {/* Arrow to graph */}
369
+ <div style={{
370
+ position: 'absolute',
371
+ right: -12,
372
+ top: headOffset + CELL_H / 2 - 6,
373
+ width: 0, height: 0,
374
+ borderTop: '6px solid transparent',
375
+ borderBottom: '6px solid transparent',
376
+ borderLeft: `8px solid ${RUST}`,
377
+ zIndex: 2,
378
+ }} />
379
+
380
+ {/* Fades */}
381
+ <div style={{
382
+ position: 'absolute', left: 0, right: 0, top: 0, height: 40,
383
+ background: `linear-gradient(180deg, ${PAPER}, transparent)`,
384
+ pointerEvents: 'none', zIndex: 1,
385
+ }} />
386
+ <div style={{
387
+ position: 'absolute', left: 0, right: 0, bottom: 0, height: 30,
388
+ background: `linear-gradient(0deg, ${PAPER}, transparent)`,
389
+ pointerEvents: 'none', zIndex: 1,
390
+ }} />
391
+ </div>
392
+ )
393
+ }
394
+
395
+ // ---------- Zoomable container ----------
396
+
397
+ function ZoomableGraph({ children }: { children: React.ReactNode }) {
398
+ const containerRef = useRef<HTMLDivElement>(null)
399
+ const [zoom, setZoom] = useState(1)
400
+ const [pan, setPan] = useState({ x: 0, y: 0 })
401
+ const dragging = useRef(false)
402
+ const lastPos = useRef({ x: 0, y: 0 })
403
+
404
+ const handleWheel = useCallback((e: React.WheelEvent) => {
405
+ e.preventDefault()
406
+ const delta = e.deltaY > 0 ? 0.9 : 1.1
407
+ setZoom(z => Math.max(0.3, Math.min(5, z * delta)))
408
+ }, [])
409
+
410
+ const handlePointerDown = useCallback((e: React.PointerEvent) => {
411
+ dragging.current = true
412
+ lastPos.current = { x: e.clientX, y: e.clientY }
413
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
414
+ }, [])
415
+
416
+ const handlePointerMove = useCallback((e: React.PointerEvent) => {
417
+ if (!dragging.current) return
418
+ const dx = e.clientX - lastPos.current.x
419
+ const dy = e.clientY - lastPos.current.y
420
+ lastPos.current = { x: e.clientX, y: e.clientY }
421
+ setPan(p => ({ x: p.x + dx, y: p.y + dy }))
422
+ }, [])
423
+
424
+ const handlePointerUp = useCallback(() => {
425
+ dragging.current = false
426
+ }, [])
427
+
428
+ const handleDoubleClick = useCallback(() => {
429
+ setZoom(1)
430
+ setPan({ x: 0, y: 0 })
431
+ }, [])
432
+
433
+ return (
434
+ <div
435
+ ref={containerRef}
436
+ onWheel={handleWheel}
437
+ onPointerDown={handlePointerDown}
438
+ onPointerMove={handlePointerMove}
439
+ onPointerUp={handlePointerUp}
440
+ onPointerCancel={handlePointerUp}
441
+ onDoubleClick={handleDoubleClick}
442
+ style={{
443
+ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
444
+ minWidth: 0, overflow: 'hidden', cursor: 'grab', touchAction: 'none',
445
+ }}
446
+ >
447
+ <div style={{
448
+ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
449
+ transformOrigin: 'center center',
450
+ }}>
451
+ {children}
452
+ </div>
453
+ </div>
454
+ )
455
+ }
456
+
457
+ // ---------- SVG Graph ----------
458
+
459
+ function GraphView({
460
+ states, transitions, revealedStates, revealedTransitions, layout, size, currentState, activeEdge, onNodeDrag
461
+ }: {
462
+ states: GraphState[]
463
+ transitions: GraphTransition[]
464
+ revealedStates: Set<string>
465
+ revealedTransitions: Set<string>
466
+ layout: Record<string, { x: number; y: number }>
467
+ size: number
468
+ currentState: string
469
+ activeEdge: string | null
470
+ onNodeDrag?: (id: string, x: number, y: number) => void
471
+ }) {
472
+ const edgeSet = useMemo(() => {
473
+ const s = new Set<string>()
474
+ for (const t of transitions) s.add(`${t.source}|${t.target}`)
475
+ return s
476
+ }, [transitions])
477
+
478
+ const svgRef = useRef<SVGSVGElement>(null)
479
+ const dragNode = useRef<string | null>(null)
480
+
481
+ const toSVG = useCallback((clientX: number, clientY: number) => {
482
+ const svg = svgRef.current
483
+ if (!svg) return { x: 0, y: 0 }
484
+ const pt = svg.createSVGPoint()
485
+ pt.x = clientX; pt.y = clientY
486
+ const ctm = svg.getScreenCTM()
487
+ if (!ctm) return { x: 0, y: 0 }
488
+ const svgPt = pt.matrixTransform(ctm.inverse())
489
+ return { x: svgPt.x, y: svgPt.y }
490
+ }, [])
491
+
492
+ const handlePointerDown = useCallback((e: React.PointerEvent, id: string) => {
493
+ if (!onNodeDrag) return
494
+ e.stopPropagation()
495
+ dragNode.current = id
496
+ ;(e.target as Element).setPointerCapture(e.pointerId)
497
+ }, [onNodeDrag])
498
+
499
+ const handlePointerMove = useCallback((e: React.PointerEvent) => {
500
+ if (!dragNode.current || !onNodeDrag) return
501
+ const { x, y } = toSVG(e.clientX, e.clientY)
502
+ onNodeDrag(dragNode.current, x, y)
503
+ }, [onNodeDrag, toSVG])
504
+
505
+ const handlePointerUp = useCallback(() => {
506
+ dragNode.current = null
507
+ }, [])
508
+
509
+ return (
510
+ <svg ref={svgRef} viewBox="-20 -20 440 440" width={size} height={size} style={{ overflow: 'visible' }}
511
+ onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp}>
512
+ <defs>
513
+ <marker id="la" viewBox="0 0 10 7" refX="0" refY="3.5" markerWidth="10" markerHeight="7" orient="auto" markerUnits="userSpaceOnUse">
514
+ <path d="M0,0 L10,3.5 L0,7" fill={INK4} />
515
+ </marker>
516
+ <marker id="la-r" viewBox="0 0 10 7" refX="0" refY="3.5" markerWidth="10" markerHeight="7" orient="auto" markerUnits="userSpaceOnUse">
517
+ <path d="M0,0 L10,3.5 L0,7" fill={RUST} />
518
+ </marker>
519
+ <filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="3" result="b" /><feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge></filter>
520
+ </defs>
521
+
522
+ {transitions.map((t, i) => {
523
+ const key = `${t.source}|${t.target}`
524
+ if (!revealedTransitions.has(key)) return null
525
+ const from = layout[t.source], to = layout[t.target]
526
+ if (!from || !to) return null
527
+ const isNew = revealedTransitions.has(`new:${key}`)
528
+ const isActive = key === activeEdge
529
+ const stroke = isActive || isNew ? RUST : INK4
530
+ const sw = isActive ? 3 : isNew ? 2 : 1
531
+ const op = isActive ? 1 : isNew ? 0.8 : 0.4
532
+ const marker = isActive || isNew ? 'url(#la-r)' : 'url(#la)'
533
+
534
+ const al = 10 // arrow length (userSpaceOnUse)
535
+
536
+ if (t.source === t.target) {
537
+ const nr = 16, loopR = 35
538
+ const cx1 = from.x - loopR, cy1 = from.y - loopR * 1.5
539
+ const cx2 = from.x + loopR, cy2 = from.y - loopR * 1.5
540
+ // Start handle: angle from center to first control point
541
+ const sa = Math.atan2(cy1 - from.y, cx1 - from.x)
542
+ const sx = from.x + nr * Math.cos(sa), sy = from.y + nr * Math.sin(sa)
543
+ // End handle: angle from center to second control point, pull back by al
544
+ const ea = Math.atan2(cy2 - from.y, cx2 - from.x)
545
+ const epx = from.x + nr * Math.cos(ea), epy = from.y + nr * Math.sin(ea)
546
+ const tdx = epx - cx2, tdy = epy - cy2, td = Math.sqrt(tdx * tdx + tdy * tdy)
547
+ const ex = epx - (tdx / td) * al, ey = epy - (tdy / td) * al
548
+ return <path key={i} d={`M${sx},${sy} C${cx1},${cy1} ${cx2},${cy2} ${ex},${ey}`}
549
+ fill="none" stroke={stroke} strokeWidth={sw} strokeOpacity={op} strokeLinecap="round" markerEnd={marker} />
550
+ }
551
+ const dx = to.x - from.x, dy = to.y - from.y, dist = Math.sqrt(dx * dx + dy * dy)
552
+ const nx = dx / dist, ny = dy / dist, nr = 16
553
+ if (edgeSet.has(`${t.target}|${t.source}`)) {
554
+ const c = 20
555
+ const mx = (from.x + to.x) / 2 - ny * c, my = (from.y + to.y) / 2 + nx * c
556
+ const sa = Math.atan2(my - from.y, mx - from.x)
557
+ const ea = Math.atan2(my - to.y, mx - to.x)
558
+ const sx = from.x + nr * Math.cos(sa), sy = from.y + nr * Math.sin(sa)
559
+ const epx = to.x + nr * Math.cos(ea), epy = to.y + nr * Math.sin(ea)
560
+ const tdx = epx - mx, tdy = epy - my, td = Math.sqrt(tdx * tdx + tdy * tdy)
561
+ const ex = epx - (tdx / td) * al, ey = epy - (tdy / td) * al
562
+ return <path key={i} d={`M${sx},${sy} Q${mx},${my} ${ex},${ey}`}
563
+ fill="none" stroke={stroke} strokeWidth={sw} strokeOpacity={op} strokeLinecap="round" markerEnd={marker} />
564
+ }
565
+ return <line key={i} x1={from.x + nx * nr} y1={from.y + ny * nr} x2={to.x - nx * (nr + al)} y2={to.y - ny * (nr + al)}
566
+ stroke={stroke} strokeWidth={sw} strokeOpacity={op} strokeLinecap="round" markerEnd={marker} />
567
+ })}
568
+
569
+ {states.map((s) => {
570
+ if (!revealedStates.has(s.id)) return null
571
+ const pos = layout[s.id]
572
+ if (!pos) return null
573
+ const isInit = s.id === 'init'
574
+ const isNew = revealedStates.has(`new:${s.id}`)
575
+ const isCurrent = s.id === currentState
576
+ const r = isInit ? 16 : 14
577
+ const label = s.id.length > 14 ? s.id.slice(0, 12) + '..' : s.id
578
+ return (
579
+ <g key={s.id} filter={isCurrent && !isInit ? 'url(#glow)' : undefined}
580
+ style={{ cursor: onNodeDrag ? 'grab' : undefined }}
581
+ onPointerDown={onNodeDrag ? (e) => handlePointerDown(e, s.id) : undefined}>
582
+ <circle cx={pos.x} cy={pos.y} r={r}
583
+ fill={isCurrent ? `${RUST}30` : isNew ? `${RUST}18` : '#fffdf9'}
584
+ stroke={isCurrent ? RUST : isNew ? RUST : isInit ? RUST : INK4}
585
+ strokeWidth={isCurrent ? 3 : isNew ? 2.5 : isInit ? 2.5 : 1.5} />
586
+ {isInit && <circle cx={pos.x} cy={pos.y} r={r - 4} fill="none" stroke={RUST} strokeWidth={1} />}
587
+ <text x={pos.x} y={pos.y + r + 13} textAnchor="middle"
588
+ fill={isCurrent ? RUST : isNew ? RUST : INK3} fontSize="10" fontFamily="IBM Plex Mono, monospace"
589
+ fontWeight={isCurrent || isNew ? 700 : 400}>{label}</text>
590
+ </g>
591
+ )
592
+ })}
593
+ </svg>
594
+ )
595
+ }
596
+
597
+ // ---------- Editable Stat ----------
598
+
599
+ function EditableStat({ label, value, total, color, onSubmit }: {
600
+ label: string; value: number; total: number; color: string
601
+ onSubmit: (v: number) => void
602
+ }) {
603
+ const [editing, setEditing] = useState(false)
604
+ const [draft, setDraft] = useState('')
605
+ const inputRef = useRef<HTMLInputElement>(null)
606
+
607
+ const startEdit = useCallback(() => {
608
+ setDraft(String(value))
609
+ setEditing(true)
610
+ }, [value])
611
+
612
+ useEffect(() => {
613
+ if (editing) inputRef.current?.select()
614
+ }, [editing])
615
+
616
+ const commit = useCallback(() => {
617
+ setEditing(false)
618
+ const n = parseInt(draft, 10)
619
+ if (!isNaN(n)) onSubmit(n)
620
+ }, [draft, onSubmit])
621
+
622
+ return (
623
+ <div style={{ textAlign: 'right' }}>
624
+ <div style={{ fontSize: 8, color: INK4, textTransform: 'uppercase', letterSpacing: '0.8px' }}>{label}</div>
625
+ {editing ? (
626
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'flex-end', gap: 1 }}>
627
+ <input ref={inputRef} value={draft} onChange={e => setDraft(e.target.value)}
628
+ onKeyDown={e => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(false) }}
629
+ onBlur={commit}
630
+ style={{
631
+ width: `${Math.max(2, String(total).length)}ch`, fontSize: 14, fontWeight: 500,
632
+ fontFamily: "'IBM Plex Mono', monospace",
633
+ color, textAlign: 'right', border: 'none', borderBottom: `2px solid ${RUST}`,
634
+ background: 'transparent', outline: 'none', padding: 0,
635
+ }} />
636
+ <span style={{ fontSize: 10, color: INK4 }}>/{total}</span>
637
+ </div>
638
+ ) : (
639
+ <div onClick={startEdit} style={{
640
+ fontSize: 14, fontWeight: 500, fontFamily: "'IBM Plex Mono', monospace",
641
+ color, cursor: 'text', borderBottom: `1px dashed ${INK4}40`, paddingBottom: 1,
642
+ }}>
643
+ {value}<span style={{ fontSize: 10, color: INK4 }}>/{total}</span>
644
+ </div>
645
+ )}
646
+ </div>
647
+ )
648
+ }
649
+
650
+ // ---------- Main Landing View ----------
651
+
652
+ export default function LandingView({ ds, datasets, onSelectDataset, onEnterDashboard }: {
653
+ ds: Dataset; datasets: Dataset[]
654
+ onSelectDataset: (i: number) => void; onEnterDashboard: () => void
655
+ }) {
656
+ const [playing, setPlaying] = useState(true)
657
+ const [speed, setSpeed] = useState(1)
658
+ const [readHead, setReadHead] = useState(0)
659
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
660
+
661
+ // Flatten all traces into one continuous tape
662
+ const allSteps = useMemo(() => flattenTraces(ds.traces), [ds.traces])
663
+
664
+ // Current trace info
665
+ const currentStep = allSteps[Math.min(readHead, allSteps.length - 1)]
666
+ const currentTraceIdx = currentStep?.traceIdx ?? 0
667
+ const totalTraces = useMemo(() => {
668
+ const last = allSteps[allSteps.length - 1]
669
+ return last ? last.traceIdx + 1 : 0
670
+ }, [allSteps])
671
+
672
+ const initialLayout = useMemo(() => computeLayout(ds.graph.states, ds.graph.transitions), [ds.graph])
673
+ const [fullLayout, setFullLayout] = useState(initialLayout)
674
+ useEffect(() => setFullLayout(initialLayout), [initialLayout])
675
+
676
+ const handleNodeDrag = useCallback((id: string, x: number, y: number) => {
677
+ setFullLayout(prev => ({ ...prev, [id]: { x, y } }))
678
+ }, [])
679
+
680
+ // Animation timer
681
+ useEffect(() => {
682
+ if (playing) {
683
+ const ms = Math.max(30, Math.round(180 / speed))
684
+ timerRef.current = setInterval(() => {
685
+ setReadHead(prev => {
686
+ if (prev >= allSteps.length) {
687
+ setPlaying(false)
688
+ return prev
689
+ }
690
+ return prev + 1
691
+ })
692
+ }, ms)
693
+ }
694
+ return () => { if (timerRef.current) clearInterval(timerRef.current) }
695
+ }, [playing, speed, allSteps.length])
696
+
697
+ // Current state: the state the machine is sitting in RIGHT NOW
698
+ // = fromState of the next step to read (where we are before consuming it)
699
+ const currentState = useMemo(() => {
700
+ if (readHead < allSteps.length) return allSteps[readHead].fromState
701
+ // All steps consumed: return toState of last step
702
+ const last = allSteps[allSteps.length - 1]
703
+ return last?.toState || last?.fromState || 'init'
704
+ }, [readHead, allSteps])
705
+
706
+ // Incremental revealed sets — cache forward progress, recompute only on backward jumps
707
+ const revealCache = useRef<{
708
+ rs: Set<string>; rt: Set<string>; consumed: number; total: number
709
+ knownStates: Set<string>; head: number; stepsRef: FlatStep[]
710
+ } | null>(null)
711
+
712
+ const { revealedStates, revealedTransitions, statesCount, transitionsCount, currentFitness, activeEdge } = useMemo(() => {
713
+ const cap = Math.min(readHead, allSteps.length)
714
+ let cache = revealCache.current
715
+
716
+ // Invalidate cache if dataset changed or scrubbed backward
717
+ if (!cache || cache.stepsRef !== allSteps || cache.head > cap) {
718
+ cache = { rs: new Set(['init']), rt: new Set(), consumed: 0, total: 0, knownStates: new Set(), head: 0, stepsRef: allSteps }
719
+ }
720
+
721
+ // Process only new steps since last cached position
722
+ for (let i = cache.head; i < cap; i++) {
723
+ const step = allSteps[i]
724
+ cache.total++
725
+ cache.rs.add(step.fromState)
726
+ cache.knownStates.add(step.fromState)
727
+ if (step.toState) {
728
+ cache.rs.add(step.toState)
729
+ cache.rt.add(`${step.fromState}|${step.toState}`)
730
+ cache.consumed++
731
+ cache.knownStates.add(step.toState)
732
+ }
733
+ }
734
+ cache.head = cap
735
+
736
+ // Clean previous frame's new: markers
737
+ for (const k of cache.rs) if (k.startsWith('new:')) cache.rs.delete(k)
738
+ for (const k of cache.rt) if (k.startsWith('new:')) cache.rt.delete(k)
739
+
740
+ // Active edge + new markers for current step
741
+ let lastEdge: string | null = null
742
+ if (cap > 0) {
743
+ const lastStep = allSteps[cap - 1]
744
+ const nextStep = allSteps[cap]
745
+ const crossingTrace = nextStep && nextStep.isTraceStart
746
+ if (!crossingTrace && lastStep.toState) {
747
+ const edgeKey = `${lastStep.fromState}|${lastStep.toState}`
748
+ lastEdge = edgeKey
749
+ // Check if this state/edge appeared before this step
750
+ // (knownStates already has it, but "new" means it was JUST added at this exact step)
751
+ cache.rt.add(`new:${edgeKey}`)
752
+ }
753
+ }
754
+
755
+ revealCache.current = cache
756
+
757
+ const realStates = [...cache.rs].filter(s => !s.startsWith('new:'))
758
+ const realTrans = [...cache.rt].filter(s => !s.startsWith('new:'))
759
+ return {
760
+ revealedStates: cache.rs, revealedTransitions: cache.rt,
761
+ statesCount: realStates.length, transitionsCount: realTrans.length,
762
+ currentFitness: cache.total > 0 ? cache.consumed / cache.total : 1,
763
+ activeEdge: lastEdge,
764
+ }
765
+ }, [readHead, allSteps])
766
+
767
+ const onScrub = useCallback((newHead: number) => {
768
+ setReadHead(newHead)
769
+ }, [])
770
+
771
+ // Arrow key navigation
772
+ useEffect(() => {
773
+ const handler = (e: KeyboardEvent) => {
774
+ if ((e.target as HTMLElement).tagName === 'INPUT') return
775
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
776
+ e.preventDefault()
777
+ setReadHead(prev => Math.min(prev + 1, allSteps.length))
778
+ } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
779
+ e.preventDefault()
780
+ setReadHead(prev => Math.max(prev - 1, 0))
781
+ } else if (e.key === ' ') {
782
+ e.preventDefault()
783
+ setPlaying(p => !p)
784
+ }
785
+ }
786
+ window.addEventListener('keydown', handler)
787
+ return () => window.removeEventListener('keydown', handler)
788
+ }, [allSteps.length])
789
+
790
+ const advance = useCallback(() => {
791
+ setReadHead(prev => Math.min(prev + 1, allSteps.length))
792
+ }, [allSteps.length])
793
+
794
+ const reset = useCallback(() => {
795
+ setPlaying(false)
796
+ setReadHead(0)
797
+ }, [])
798
+
799
+ useEffect(() => { setReadHead(0); setPlaying(false) }, [ds])
800
+
801
+ const graphSize = Math.min(480, typeof window !== 'undefined' ? Math.min(window.innerHeight * 0.65, window.innerWidth * 0.55) : 480)
802
+
803
+ return (
804
+ <div style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: PAPER, overflow: 'hidden' }}>
805
+ {/* Top bar: title + stats + current step */}
806
+ <div style={{ flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', borderBottom: `1px solid #d5cfc5`, gap: 8, overflowX: 'auto' }}>
807
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0, flexShrink: 1 }}>
808
+ <h1 className="serif" style={{ fontSize: 18, lineHeight: 1, letterSpacing: '-0.3px', color: INK, whiteSpace: 'nowrap' }}>
809
+ Agent State Graph
810
+ </h1>
811
+ {readHead > 0 && readHead <= allSteps.length && (
812
+ <div style={{
813
+ fontFamily: "'IBM Plex Mono', monospace", fontSize: 10,
814
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
815
+ minWidth: 0, flexShrink: 1,
816
+ }}>
817
+ <span style={{ color: allSteps[readHead - 1]?.consumed ? RUST : INK3 }}>
818
+ {allSteps[readHead - 1]?.activity}
819
+ </span>
820
+ <span style={{ color: INK4, margin: '0 4px' }}>{'\u2192'}</span>
821
+ <span style={{ color: SLATE }}>
822
+ {allSteps[readHead - 1]?.toState || 'REJECT'}
823
+ </span>
824
+ </div>
825
+ )}
826
+ </div>
827
+ <div style={{ display: 'flex', gap: 20, flexShrink: 0 }}>
828
+ {/* Static stats */}
829
+ {[
830
+ ['States', statesCount, ds.graph.states.length, RUST],
831
+ ['Trans', transitionsCount, ds.graph.transitions.length, SLATE],
832
+ ].map(([label, val, total, color]) => (
833
+ <div key={label as string} style={{ textAlign: 'right' }}>
834
+ <div style={{ fontSize: 8, color: INK4, textTransform: 'uppercase', letterSpacing: '0.8px' }}>{label}</div>
835
+ <div style={{ fontSize: 14, fontWeight: 500, fontFamily: "'IBM Plex Mono', monospace", color: color as string }}>
836
+ {val}<span style={{ fontSize: 10, color: INK4 }}>/{total}</span>
837
+ </div>
838
+ </div>
839
+ ))}
840
+ {/* Editable: Trace */}
841
+ <EditableStat label="Trace" value={currentTraceIdx + 1} total={totalTraces} color={OCHRE}
842
+ onSubmit={(v) => {
843
+ const ti = Math.max(1, Math.min(totalTraces, v)) - 1
844
+ const idx = allSteps.findIndex(s => s.traceIdx === ti)
845
+ if (idx >= 0) onScrub(idx)
846
+ }} />
847
+ {/* Editable: Steps */}
848
+ <EditableStat label="Steps" value={readHead} total={allSteps.length} color={INK}
849
+ onSubmit={(v) => onScrub(Math.max(0, Math.min(allSteps.length, v)))} />
850
+ {/* Static: Fitness */}
851
+ <div style={{ textAlign: 'right' }}>
852
+ <div style={{ fontSize: 8, color: INK4, textTransform: 'uppercase', letterSpacing: '0.8px' }}>Fitness</div>
853
+ <div style={{ fontSize: 14, fontWeight: 500, fontFamily: "'IBM Plex Mono', monospace", color: INK }}>
854
+ {currentFitness.toFixed(3)}
855
+ </div>
856
+ </div>
857
+ </div>
858
+ </div>
859
+
860
+ {/* Main: tape (full height left) + graph (right) */}
861
+ <div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
862
+ {/* Tape column — fills full vertical space */}
863
+ <div style={{ width: CELL_W + 40, flexShrink: 0, height: '100%' }}>
864
+ <ReceiptTape steps={allSteps} readHead={readHead} onScrub={onScrub} />
865
+ </div>
866
+
867
+ {/* Graph — zoomable/pannable */}
868
+ <ZoomableGraph>
869
+ <GraphView
870
+ states={ds.graph.states} transitions={ds.graph.transitions}
871
+ revealedStates={revealedStates} revealedTransitions={revealedTransitions}
872
+ layout={fullLayout} size={graphSize}
873
+ currentState={currentState} activeEdge={activeEdge}
874
+ onNodeDrag={handleNodeDrag}
875
+ />
876
+ </ZoomableGraph>
877
+ </div>
878
+
879
+ {/* Control bar */}
880
+ <div style={{ borderTop: `2px solid ${INK}`, flexShrink: 0 }}>
881
+ <div style={{ maxWidth: 1200, margin: '0 auto', padding: '10px 24px' }}>
882
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 10 }}>
883
+ {/* Playback */}
884
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
885
+ {[
886
+ { label: playing ? 'Pause' : 'Play', onClick: () => setPlaying(p => !p), accent: playing },
887
+ { label: 'Step', onClick: advance, accent: false },
888
+ { label: 'Reset', onClick: reset, accent: false },
889
+ ].map(({ label, onClick, accent }) => (
890
+ <button key={label} onClick={onClick} style={{
891
+ padding: '4px 12px', fontSize: 10, letterSpacing: '0.5px', textTransform: 'uppercase' as const,
892
+ border: `1px solid ${INK4}`, background: 'transparent', cursor: 'pointer',
893
+ color: accent ? RUST : INK, fontFamily: 'inherit',
894
+ }}>{label}</button>
895
+ ))}
896
+ <div style={{ display: 'flex', alignItems: 'center', gap: 3, marginLeft: 6 }}>
897
+ <span style={{ fontSize: 8, color: INK4, textTransform: 'uppercase', letterSpacing: '0.8px' }}>Speed</span>
898
+ {[1, 2, 4, 8].map(s => (
899
+ <button key={s} onClick={() => setSpeed(s)} style={{
900
+ padding: '2px 5px', fontSize: 9, fontFamily: "'IBM Plex Mono', monospace",
901
+ border: `1px solid ${INK4}`, cursor: 'pointer',
902
+ background: speed === s ? INK : 'transparent', color: speed === s ? PAPER : INK,
903
+ }}>{s}x</button>
904
+ ))}
905
+ </div>
906
+ </div>
907
+
908
+ {/* Dataset + enter */}
909
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
910
+ <div style={{ display: 'flex', border: `1px solid ${INK4}`, overflowX: 'auto', minWidth: 0, flexShrink: 1 }}>
911
+ {datasets.map((d, i) => (
912
+ <button key={d.id} onClick={() => onSelectDataset(i)} style={{
913
+ padding: '3px 8px', fontSize: 10, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
914
+ borderRight: i < datasets.length - 1 ? `1px solid ${INK4}` : 'none',
915
+ background: ds.id === d.id ? INK : 'transparent',
916
+ color: ds.id === d.id ? PAPER : INK, fontFamily: 'inherit',
917
+ }}>{d.meta.shortName}</button>
918
+ ))}
919
+ </div>
920
+ <button onClick={onEnterDashboard} style={{
921
+ padding: '4px 14px', fontSize: 10, letterSpacing: '0.5px', textTransform: 'uppercase' as const,
922
+ border: `2px solid ${INK}`, background: 'transparent', cursor: 'pointer',
923
+ fontWeight: 600, fontFamily: 'inherit', flexShrink: 0,
924
+ }}>Dashboard {'\u2192'}</button>
925
+ </div>
926
+ </div>
927
+
928
+ {/* Progress */}
929
+ <div style={{ marginTop: 6, height: 3, background: '#e8e3db', borderRadius: 2, overflow: 'hidden' }}>
930
+ <div style={{
931
+ height: '100%', borderRadius: 2, transition: 'width 120ms',
932
+ width: `${(readHead / Math.max(allSteps.length, 1)) * 100}%`,
933
+ background: `linear-gradient(90deg, ${RUST}, ${SLATE})`,
934
+ }} />
935
+ </div>
936
+ </div>
937
+ </div>
938
+ </div>
939
+ )
940
+ }
src/assets/react.svg ADDED
src/index.css ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ @theme {
4
+ --color-paper: #f5f0e8;
5
+ --color-paper-warm: #efe9de;
6
+ --color-ink: #1a1714;
7
+ --color-ink-2: #4a4540;
8
+ --color-ink-3: #8a8478;
9
+ --color-ink-4: #b5afa5;
10
+ --color-rule: #d5cfc5;
11
+ --color-rule-light: #e5e0d6;
12
+ --color-rust: #c4553a;
13
+ --color-rust-dim: rgba(196, 85, 58, 0.08);
14
+ --color-slate: #3d5a80;
15
+ --color-slate-dim: rgba(61, 90, 128, 0.08);
16
+ --color-ochre: #c49a3a;
17
+ --color-ochre-dim: rgba(196, 154, 58, 0.08);
18
+ --color-charcoal: #2d2926;
19
+ --font-serif: 'Instrument Serif', Georgia, serif;
20
+ --font-sans: 'DM Sans', system-ui, sans-serif;
21
+ --font-mono: 'IBM Plex Mono', 'Menlo', monospace;
22
+ }
23
+
24
+ @layer base {
25
+ * { box-sizing: border-box; margin: 0; padding: 0; }
26
+
27
+ html {
28
+ background: var(--color-paper);
29
+ color: var(--color-ink);
30
+ font-family: var(--font-sans);
31
+ -webkit-font-smoothing: antialiased;
32
+ -moz-osx-font-smoothing: grayscale;
33
+ font-size: 14px;
34
+ line-height: 1.65;
35
+ }
36
+
37
+ body {
38
+ min-height: 100vh;
39
+ background: var(--color-paper);
40
+ }
41
+
42
+ ::selection {
43
+ background: var(--color-rust);
44
+ color: var(--color-paper);
45
+ }
46
+
47
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
48
+ ::-webkit-scrollbar-track { background: transparent; }
49
+ ::-webkit-scrollbar-thumb { background: var(--color-rule); border-radius: 0; }
50
+ }
51
+
52
+ @layer components {
53
+ .mono {
54
+ font-family: var(--font-mono);
55
+ font-variant-numeric: tabular-nums;
56
+ letter-spacing: -0.03em;
57
+ font-size: 0.92em;
58
+ }
59
+
60
+ .serif {
61
+ font-family: var(--font-serif);
62
+ }
63
+
64
+ .ruled {
65
+ border-bottom: 1px solid var(--color-rule);
66
+ }
67
+
68
+ .ruled-top {
69
+ border-top: 1px solid var(--color-rule);
70
+ }
71
+
72
+ .thick-rule {
73
+ border-bottom: 2px solid var(--color-ink);
74
+ }
75
+ }
76
+
77
+ @keyframes reveal {
78
+ from { opacity: 0; transform: translateY(4px); }
79
+ to { opacity: 1; transform: translateY(0); }
80
+ }
81
+
82
+ @keyframes fold-open {
83
+ from { opacity: 0; max-height: 0; }
84
+ to { opacity: 1; max-height: 2000px; }
85
+ }
86
+
87
+ .fold-open {
88
+ animation: fold-open 0.35s cubic-bezier(0.25, 1, 0.5, 1) both;
89
+ overflow: hidden;
90
+ }
91
+
92
+ .anim {
93
+ animation: reveal 0.4s cubic-bezier(0.25, 1, 0.5, 1) both;
94
+ }
95
+
96
+ .d1 { animation-delay: 50ms; }
97
+ .d2 { animation-delay: 100ms; }
98
+ .d3 { animation-delay: 150ms; }
99
+ .d4 { animation-delay: 200ms; }
100
+ .d5 { animation-delay: 250ms; }
101
+ .d6 { animation-delay: 300ms; }
src/lib/utils.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type ClassValue, clsx } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
7
+
8
+ export function fmt(n: number, d = 3): string {
9
+ if (n === 0) return '0'
10
+ return n.toFixed(d)
11
+ }
12
+
13
+ export function fmtInt(n: number): string {
14
+ return n.toLocaleString()
15
+ }
16
+
17
+ export function pct(n: number): string {
18
+ return (n * 100).toFixed(1) + '%'
19
+ }
20
+
21
+ export function compression(ours: number, theirs: number): string {
22
+ if (theirs <= 0 || ours <= 0) return '--'
23
+ return Math.round(theirs / ours) + 'x'
24
+ }
src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.tsx'
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ )
tsconfig.app.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client"],
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "erasableSyntaxOnly": true,
24
+ "noFallthroughCasesInSwitch": true,
25
+ "noUncheckedSideEffectImports": true
26
+ },
27
+ "include": ["src"]
28
+ }
tsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
tsconfig.node.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "erasableSyntaxOnly": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedSideEffectImports": true
24
+ },
25
+ "include": ["vite.config.ts"]
26
+ }
vite.config.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+ import path from 'path'
5
+
6
+ // Base resolution: seongland builds with no env -> served at /article/asg/browser/;
7
+ // the standalone HF Space sets ASG_BASE=/ so it serves at the Space root; dev stays
8
+ // at /. Routing is query-arg based (?tab=…) so only asset URLs depend on the base.
9
+ export default defineConfig(({ command }) => ({
10
+ base: process.env.ASG_BASE ?? (command === 'build' ? '/article/asg/browser/' : '/'),
11
+ plugins: [react(), tailwindcss()],
12
+ resolve: {
13
+ alias: {
14
+ '@': path.resolve(__dirname, './src'),
15
+ },
16
+ },
17
+ build: {
18
+ rollupOptions: {
19
+ output: {
20
+ manualChunks: {
21
+ recharts: ['recharts'],
22
+ },
23
+ },
24
+ },
25
+ },
26
+ }))