Commit ·
9e2b9b8
0
Parent(s):
Interactive research article
Browse files- .gitattributes +5 -0
- .gitignore +24 -0
- Dockerfile +14 -0
- README.md +19 -0
- eslint.config.js +23 -0
- index.html +31 -0
- nginx.conf +16 -0
- package-lock.json +0 -0
- package.json +0 -0
- public/datasets/agentnet.json +3 -0
- public/datasets/atbench.json +3 -0
- public/datasets/gui-odyssey.json +3 -0
- public/datasets/mind2web.json +3 -0
- public/datasets/osworld.json +3 -0
- public/datasets/sweagent.json +3 -0
- public/datasets/swesmith.json +3 -0
- public/datasets/tau2bench-airline.json +3 -0
- public/datasets/tau2bench-retail.json +3 -0
- public/datasets/tau2bench-telecom.json +3 -0
- public/datasets/taubench-airline.json +3 -0
- public/datasets/taubench-retail.json +3 -0
- public/datasets/webarena.json +3 -0
- public/datasets/whoandwhen.json +3 -0
- public/favicon.svg +14 -0
- public/index.json +3 -0
- public/vite.svg +1 -0
- scripts/build-data.ts +996 -0
- src/App.tsx +0 -0
- src/LandingView.tsx +940 -0
- src/assets/react.svg +1 -0
- src/index.css +101 -0
- src/lib/utils.ts +24 -0
- src/main.tsx +10 -0
- tsconfig.app.json +28 -0
- tsconfig.json +7 -0
- tsconfig.node.json +26 -0
- vite.config.ts +26 -0
.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 |
+
}))
|