ianalin123 Claude Sonnet 4.6 commited on
Commit
25db0fc
·
1 Parent(s): c44bdad

feat: React observability dashboard + FastAPI server + matplotlib renderer

Browse files

- src/: Computational Lab Instrument dashboard (JetBrains Mono, dark #0d0d14 bg)
- CreaseCanvas: SVG paper with mountain/valley/ghost target overlay
- RewardPanel: live bar chart (kawasaki, maekawa, blb, progress, economy)
- StepFeed: scrollable fold instruction log with assignment badges
- InfoBadges: foldability status, crease counts
- PlayerControls: step-through + auto-play at 1.5s interval
- TargetSelector: grouped by difficulty level
- server.py: FastAPI with /targets, /episode/demo, /episode/run endpoints
- viz/renderer.py: matplotlib draw_paper_state, draw_reward_bars, render_episode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

requirements.txt CHANGED
@@ -1,3 +1,5 @@
1
  shapely>=2.0.0
2
  numpy>=1.24.0
3
  pytest>=7.0.0
 
 
 
1
  shapely>=2.0.0
2
  numpy>=1.24.0
3
  pytest>=7.0.0
4
+ fastapi>=0.100.0
5
+ uvicorn>=0.23.0
server.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI server for the origami RL environment.
3
+ Serves episode data to the React frontend.
4
+
5
+ Usage: uvicorn server:app --reload --port 8000
6
+ """
7
+
8
+ try:
9
+ from fastapi import FastAPI
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from pydantic import BaseModel
12
+ except ImportError:
13
+ print("Run: pip install fastapi uvicorn pydantic")
14
+ raise
15
+
16
+ from typing import Optional
17
+
18
+
19
+ app = FastAPI(title="OrigamiRL API")
20
+
21
+ app.add_middleware(
22
+ CORSMiddleware,
23
+ allow_origins=["*"], # localhost:3000 for React dev
24
+ allow_methods=["*"],
25
+ allow_headers=["*"],
26
+ )
27
+
28
+
29
+ class FoldAction(BaseModel):
30
+ from_point: list[float] # [x, y]
31
+ to_point: list[float] # [x, y]
32
+ assignment: str # 'M' or 'V'
33
+ instruction: str = ""
34
+
35
+
36
+ class EpisodeStep(BaseModel):
37
+ step: int
38
+ fold: Optional[FoldAction]
39
+ paper_state: dict # FOLD JSON of current crease graph
40
+ anchor_points: list[list[float]]
41
+ reward: dict
42
+ done: bool
43
+ info: dict
44
+ prompt: str # LLM prompt at this step
45
+
46
+
47
+ class EpisodeResult(BaseModel):
48
+ target_name: str
49
+ target: dict # FOLD JSON of target
50
+ steps: list[EpisodeStep]
51
+ final_reward: dict
52
+
53
+
54
+ @app.get("/")
55
+ def health_check():
56
+ """Health check — returns status and available target names."""
57
+ from env.environment import OrigamiEnvironment
58
+ env = OrigamiEnvironment()
59
+ return {"status": "ok", "targets": env.available_targets()}
60
+
61
+
62
+ @app.get("/targets")
63
+ def get_targets():
64
+ """Return list of available target names and their metadata."""
65
+ from env.environment import OrigamiEnvironment
66
+ env = OrigamiEnvironment()
67
+ targets = {}
68
+ for name in env.available_targets():
69
+ t = env._targets[name]
70
+ targets[name] = {
71
+ "name": name,
72
+ "level": t.get("level", 1),
73
+ "description": t.get("description", ""),
74
+ "n_creases": sum(1 for a in t["edges_assignment"] if a in ("M", "V")),
75
+ }
76
+ return targets
77
+
78
+
79
+ @app.get("/episode/run")
80
+ def run_episode(target: str = "half_horizontal", completion: str = ""):
81
+ """
82
+ Run a code-as-policy episode with a provided completion string.
83
+
84
+ If completion is empty, returns the prompt so the caller knows what to send.
85
+ Returns full episode result with all steps.
86
+ """
87
+ from env.environment import OrigamiEnvironment
88
+ from env.prompts import parse_fold_list, code_as_policy_prompt
89
+ from env.rewards import compute_reward, target_crease_edges
90
+
91
+ env = OrigamiEnvironment(mode="step")
92
+ obs = env.reset(target_name=target)
93
+
94
+ if not completion:
95
+ return {"prompt": obs["prompt"], "steps": [], "target": env.target}
96
+
97
+ try:
98
+ folds = parse_fold_list(completion)
99
+ except ValueError as e:
100
+ return {"error": str(e), "steps": []}
101
+
102
+ steps = []
103
+ for i, fold in enumerate(folds):
104
+ result = env.paper.add_crease(fold["from"], fold["to"], fold["assignment"])
105
+ reward = compute_reward(env.paper, result, env.target)
106
+
107
+ paper_state = {
108
+ "vertices": {str(k): list(v) for k, v in env.paper.graph.vertices.items()},
109
+ "edges": [
110
+ {
111
+ "id": k,
112
+ "v1": list(env.paper.graph.vertices[v[0]]),
113
+ "v2": list(env.paper.graph.vertices[v[1]]),
114
+ "assignment": v[2],
115
+ }
116
+ for k, v in env.paper.graph.edges.items()
117
+ ],
118
+ "anchor_points": [list(p) for p in env.paper.anchor_points()],
119
+ }
120
+
121
+ # Build per-step prompt reflecting current state
122
+ from env.prompts import step_level_prompt
123
+ step_prompt = step_level_prompt(
124
+ target=env.target,
125
+ paper_state=env.paper,
126
+ step=i + 1,
127
+ max_steps=env.max_steps,
128
+ last_reward=reward,
129
+ )
130
+
131
+ steps.append({
132
+ "step": i + 1,
133
+ "fold": {
134
+ "from_point": fold["from"],
135
+ "to_point": fold["to"],
136
+ "assignment": fold["assignment"],
137
+ "instruction": fold.get("instruction", ""),
138
+ },
139
+ "paper_state": paper_state,
140
+ "anchor_points": [list(p) for p in env.paper.anchor_points()],
141
+ "reward": reward,
142
+ "done": reward.get("completion", 0) > 0,
143
+ "info": env._info(),
144
+ "prompt": step_prompt,
145
+ })
146
+
147
+ if reward.get("completion", 0) > 0:
148
+ break
149
+
150
+ return {
151
+ "target_name": target,
152
+ "target": env.target,
153
+ "steps": steps,
154
+ "final_reward": steps[-1]["reward"] if steps else {},
155
+ }
156
+
157
+
158
+ @app.get("/episode/demo")
159
+ def demo_episode(target: str = "half_horizontal"):
160
+ """Return a pre-solved demo episode for each target."""
161
+ DEMO_COMPLETIONS = {
162
+ "half_horizontal": '<folds>[{"instruction": "Valley fold along horizontal center line", "from": [0, 0.5], "to": [1, 0.5], "assignment": "V"}]</folds>',
163
+ "half_vertical": '<folds>[{"instruction": "Mountain fold along vertical center line", "from": [0.5, 0], "to": [0.5, 1], "assignment": "M"}]</folds>',
164
+ "diagonal_main": '<folds>[{"instruction": "Valley fold along main diagonal", "from": [0, 0], "to": [1, 1], "assignment": "V"}]</folds>',
165
+ "diagonal_anti": '<folds>[{"instruction": "Mountain fold along anti-diagonal", "from": [1, 0], "to": [0, 1], "assignment": "M"}]</folds>',
166
+ "thirds_h": '<folds>[{"instruction": "Valley fold at one-third height", "from": [0, 0.333], "to": [1, 0.333], "assignment": "V"}, {"instruction": "Valley fold at two-thirds height", "from": [0, 0.667], "to": [1, 0.667], "assignment": "V"}]</folds>',
167
+ "thirds_v": '<folds>[{"instruction": "Mountain fold at one-third width", "from": [0.333, 0], "to": [0.333, 1], "assignment": "M"}, {"instruction": "Mountain fold at two-thirds width", "from": [0.667, 0], "to": [0.667, 1], "assignment": "M"}]</folds>',
168
+ "accordion_3h": '<folds>[{"instruction": "Valley fold at quarter height", "from": [0, 0.25], "to": [1, 0.25], "assignment": "V"}, {"instruction": "Mountain fold at half height", "from": [0, 0.5], "to": [1, 0.5], "assignment": "M"}, {"instruction": "Valley fold at three-quarter height", "from": [0, 0.75], "to": [1, 0.75], "assignment": "V"}]</folds>',
169
+ "accordion_4h": '<folds>[{"instruction": "Valley fold at 0.2", "from": [0, 0.2], "to": [1, 0.2], "assignment": "V"}, {"instruction": "Mountain fold at 0.4", "from": [0, 0.4], "to": [1, 0.4], "assignment": "M"}, {"instruction": "Valley fold at 0.6", "from": [0, 0.6], "to": [1, 0.6], "assignment": "V"}, {"instruction": "Mountain fold at 0.8", "from": [0, 0.8], "to": [1, 0.8], "assignment": "M"}]</folds>',
170
+ }
171
+ completion = DEMO_COMPLETIONS.get(target, DEMO_COMPLETIONS["half_horizontal"])
172
+ return run_episode(target=target, completion=completion)
src/App.css CHANGED
@@ -1,38 +1,507 @@
1
- .App {
2
- text-align: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  }
4
 
5
- .App-logo {
6
- height: 40vmin;
7
- pointer-events: none;
8
  }
9
 
10
- @media (prefers-reduced-motion: no-preference) {
11
- .App-logo {
12
- animation: App-logo-spin infinite 20s linear;
13
- }
 
 
 
14
  }
15
 
16
- .App-header {
17
- background-color: #282c34;
18
- min-height: 100vh;
 
 
 
 
 
19
  display: flex;
20
  flex-direction: column;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  align-items: center;
22
  justify-content: center;
23
- font-size: calc(10px + 2vmin);
24
- color: white;
 
 
 
 
 
 
 
 
 
 
25
  }
26
 
27
- .App-link {
28
- color: #61dafb;
 
 
 
 
29
  }
30
 
31
- @keyframes App-logo-spin {
32
- from {
33
- transform: rotate(0deg);
34
- }
35
- to {
36
- transform: rotate(360deg);
37
- }
 
 
 
 
 
 
 
 
 
38
  }
 
1
+ :root {
2
+ --bg: #0d0d14;
3
+ --surface: #13131d;
4
+ --surface-2: #1a1a2e;
5
+ --paper-white: #fafaf5;
6
+ --paper-edge: #2a2a3a;
7
+ --mountain: #f59e0b;
8
+ --valley: #38bdf8;
9
+ --target-ghost: rgba(124, 58, 237, 0.20);
10
+ --target-ghost-stroke: rgba(124, 58, 237, 0.45);
11
+ --validity: #22d3ee;
12
+ --progress: #22c55e;
13
+ --economy: #a78bfa;
14
+ --text-primary: #f8fafc;
15
+ --text-dim: #64748b;
16
+ --border: #2a2a3a;
17
+ --border-bright: #3a3a5a;
18
+ --font-display: 'JetBrains Mono', monospace;
19
+ --font-mono: 'IBM Plex Mono', monospace;
20
+ }
21
+
22
+ .app {
23
+ display: flex;
24
+ flex-direction: column;
25
+ height: 100vh;
26
+ background: var(--bg);
27
+ overflow: hidden;
28
+ }
29
+
30
+ /* ─── HEADER ─── */
31
+ .app-header {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: 24px;
35
+ padding: 0 20px;
36
+ height: 48px;
37
+ border-bottom: 1px solid var(--border);
38
+ background: var(--surface);
39
+ flex-shrink: 0;
40
+ z-index: 10;
41
+ }
42
+
43
+ .app-title {
44
+ font-family: var(--font-display);
45
+ font-size: 14px;
46
+ font-weight: 700;
47
+ letter-spacing: 0.12em;
48
+ color: var(--text-primary);
49
+ white-space: nowrap;
50
+ }
51
+
52
+ .app-title .title-accent {
53
+ color: var(--mountain);
54
+ }
55
+
56
+ .header-sep {
57
+ width: 1px;
58
+ height: 24px;
59
+ background: var(--border);
60
+ flex-shrink: 0;
61
+ }
62
+
63
+ .header-right {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 16px;
67
+ margin-left: auto;
68
+ }
69
+
70
+ .api-status {
71
+ font-size: 11px;
72
+ font-family: var(--font-display);
73
+ letter-spacing: 0.08em;
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 6px;
77
+ }
78
+
79
+ .api-status-dot {
80
+ width: 6px;
81
+ height: 6px;
82
+ border-radius: 50%;
83
+ background: var(--text-dim);
84
+ }
85
+
86
+ .api-status-dot.ok {
87
+ background: var(--progress);
88
+ box-shadow: 0 0 6px var(--progress);
89
+ }
90
+
91
+ .api-status-dot.err {
92
+ background: #ef4444;
93
+ box-shadow: 0 0 6px #ef4444;
94
+ }
95
+
96
+ /* ─── MAIN LAYOUT ─── */
97
+ .app-body {
98
+ display: grid;
99
+ grid-template-columns: 1fr 280px;
100
+ flex: 1;
101
+ overflow: hidden;
102
+ }
103
+
104
+ .app-left {
105
+ display: flex;
106
+ flex-direction: column;
107
+ overflow: hidden;
108
+ border-right: 1px solid var(--border);
109
+ }
110
+
111
+ .app-right {
112
+ display: flex;
113
+ flex-direction: column;
114
+ overflow: hidden;
115
+ background: var(--surface);
116
+ }
117
+
118
+ /* ─── CANVAS ROW ─── */
119
+ .canvas-row {
120
+ display: flex;
121
+ gap: 0;
122
+ padding: 16px;
123
+ flex-shrink: 0;
124
+ border-bottom: 1px solid var(--border);
125
+ }
126
+
127
+ .canvas-wrap {
128
+ display: flex;
129
+ flex-direction: column;
130
+ gap: 8px;
131
+ flex: 1;
132
  }
133
 
134
+ .canvas-wrap + .canvas-wrap {
135
+ margin-left: 16px;
 
136
  }
137
 
138
+ .canvas-label {
139
+ font-family: var(--font-display);
140
+ font-size: 10px;
141
+ font-weight: 500;
142
+ letter-spacing: 0.14em;
143
+ color: var(--text-dim);
144
+ text-transform: uppercase;
145
  }
146
 
147
+ .canvas-svg {
148
+ display: block;
149
+ background: var(--paper-white);
150
+ }
151
+
152
+ /* ─── STEP FEED ─── */
153
+ .step-feed-section {
154
+ flex: 1;
155
  display: flex;
156
  flex-direction: column;
157
+ overflow: hidden;
158
+ }
159
+
160
+ .section-header {
161
+ font-family: var(--font-display);
162
+ font-size: 10px;
163
+ font-weight: 500;
164
+ letter-spacing: 0.14em;
165
+ color: var(--text-dim);
166
+ text-transform: uppercase;
167
+ padding: 8px 16px;
168
+ border-bottom: 1px solid var(--border);
169
+ flex-shrink: 0;
170
+ }
171
+
172
+ .step-feed {
173
+ overflow-y: auto;
174
+ flex: 1;
175
+ padding: 4px 0;
176
+ }
177
+
178
+ .step-entry {
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 2px;
182
+ padding: 8px 16px;
183
+ border-bottom: 1px solid var(--border);
184
+ cursor: default;
185
+ transition: background 0.1s;
186
+ }
187
+
188
+ .step-entry:hover {
189
+ background: var(--surface);
190
+ }
191
+
192
+ .step-entry.active {
193
+ background: var(--surface-2);
194
+ border-left: 2px solid var(--valley);
195
+ padding-left: 14px;
196
+ }
197
+
198
+ .step-entry-top {
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 8px;
202
+ }
203
+
204
+ .step-num {
205
+ font-family: var(--font-display);
206
+ font-size: 10px;
207
+ font-weight: 700;
208
+ color: var(--text-dim);
209
+ width: 24px;
210
+ flex-shrink: 0;
211
+ }
212
+
213
+ .step-instruction {
214
+ font-size: 12px;
215
+ color: var(--text-primary);
216
+ flex: 1;
217
+ }
218
+
219
+ .assign-badge {
220
+ font-family: var(--font-display);
221
+ font-size: 10px;
222
+ font-weight: 700;
223
+ padding: 1px 5px;
224
+ line-height: 1.4;
225
+ flex-shrink: 0;
226
+ }
227
+
228
+ .assign-badge.M {
229
+ background: var(--mountain);
230
+ color: #0d0d14;
231
+ }
232
+
233
+ .assign-badge.V {
234
+ background: var(--valley);
235
+ color: #0d0d14;
236
+ }
237
+
238
+ .assign-badge.B {
239
+ background: var(--border-bright);
240
+ color: var(--text-dim);
241
+ }
242
+
243
+ .step-reward-delta {
244
+ font-size: 11px;
245
+ color: var(--text-dim);
246
+ padding-left: 32px;
247
+ }
248
+
249
+ .step-reward-delta .delta-positive {
250
+ color: var(--progress);
251
+ }
252
+
253
+ .step-reward-delta .delta-negative {
254
+ color: #ef4444;
255
+ }
256
+
257
+ /* ─── REWARD PANEL ─── */
258
+ .reward-panel {
259
+ padding: 12px 16px;
260
+ border-bottom: 1px solid var(--border);
261
+ flex-shrink: 0;
262
+ }
263
+
264
+ .reward-row {
265
+ display: flex;
266
+ align-items: center;
267
+ gap: 8px;
268
+ margin-bottom: 6px;
269
+ }
270
+
271
+ .reward-row:last-child {
272
+ margin-bottom: 0;
273
+ }
274
+
275
+ .reward-label {
276
+ font-family: var(--font-display);
277
+ font-size: 10px;
278
+ font-weight: 500;
279
+ letter-spacing: 0.06em;
280
+ color: var(--text-dim);
281
+ width: 72px;
282
+ flex-shrink: 0;
283
+ text-transform: uppercase;
284
+ }
285
+
286
+ .reward-track {
287
+ flex: 1;
288
+ height: 8px;
289
+ background: var(--bg);
290
+ border: 1px solid var(--border);
291
+ overflow: hidden;
292
+ }
293
+
294
+ .reward-bar {
295
+ height: 100%;
296
+ transition: width 0.4s ease;
297
+ }
298
+
299
+ .reward-value {
300
+ font-family: var(--font-display);
301
+ font-size: 11px;
302
+ font-weight: 500;
303
+ color: var(--text-primary);
304
+ width: 36px;
305
+ text-align: right;
306
+ flex-shrink: 0;
307
+ }
308
+
309
+ .reward-value.dim {
310
+ color: var(--text-dim);
311
+ }
312
+
313
+ .reward-divider {
314
+ height: 1px;
315
+ background: var(--border);
316
+ margin: 6px 0;
317
+ }
318
+
319
+ /* ─── INFO BADGES ─── */
320
+ .info-badges {
321
+ padding: 12px 16px;
322
+ display: flex;
323
+ flex-direction: column;
324
+ gap: 8px;
325
+ }
326
+
327
+ .info-row {
328
+ display: flex;
329
+ align-items: center;
330
+ justify-content: space-between;
331
+ gap: 8px;
332
+ }
333
+
334
+ .info-key {
335
+ font-family: var(--font-display);
336
+ font-size: 10px;
337
+ font-weight: 500;
338
+ letter-spacing: 0.06em;
339
+ color: var(--text-dim);
340
+ text-transform: uppercase;
341
+ }
342
+
343
+ .info-val {
344
+ font-family: var(--font-display);
345
+ font-size: 11px;
346
+ font-weight: 700;
347
+ color: var(--text-primary);
348
+ }
349
+
350
+ .info-val.bool-true {
351
+ color: var(--progress);
352
+ }
353
+
354
+ .info-val.bool-false {
355
+ color: #ef4444;
356
+ }
357
+
358
+ .info-val.dim {
359
+ color: var(--text-dim);
360
+ }
361
+
362
+ /* ─── TARGET SELECTOR ─── */
363
+ .target-selector {
364
+ display: flex;
365
+ align-items: center;
366
+ gap: 8px;
367
+ }
368
+
369
+ .target-selector-label {
370
+ font-family: var(--font-display);
371
+ font-size: 10px;
372
+ font-weight: 500;
373
+ letter-spacing: 0.10em;
374
+ color: var(--text-dim);
375
+ text-transform: uppercase;
376
+ white-space: nowrap;
377
+ }
378
+
379
+ .target-select {
380
+ background: var(--surface-2);
381
+ border: 1px solid var(--border-bright);
382
+ color: var(--text-primary);
383
+ font-family: var(--font-display);
384
+ font-size: 11px;
385
+ padding: 4px 8px;
386
+ outline: none;
387
+ cursor: pointer;
388
+ min-width: 180px;
389
+ }
390
+
391
+ .target-select:focus {
392
+ border-color: var(--valley);
393
+ }
394
+
395
+ optgroup {
396
+ background: var(--surface);
397
+ color: var(--text-dim);
398
+ font-family: var(--font-display);
399
+ font-size: 10px;
400
+ }
401
+
402
+ option {
403
+ background: var(--surface-2);
404
+ color: var(--text-primary);
405
+ font-family: var(--font-display);
406
+ }
407
+
408
+ /* ─── PLAYER CONTROLS ─── */
409
+ .player-controls {
410
+ display: flex;
411
+ align-items: center;
412
+ gap: 6px;
413
+ flex-shrink: 0;
414
+ }
415
+
416
+ .ctrl-btn {
417
+ background: var(--surface-2);
418
+ border: 1px solid var(--border-bright);
419
+ color: var(--text-primary);
420
+ font-family: var(--font-display);
421
+ font-size: 11px;
422
+ font-weight: 500;
423
+ padding: 4px 10px;
424
+ cursor: pointer;
425
+ white-space: nowrap;
426
+ line-height: 1.4;
427
+ letter-spacing: 0.04em;
428
+ transition: background 0.1s, border-color 0.1s;
429
+ }
430
+
431
+ .ctrl-btn:hover:not(:disabled) {
432
+ background: var(--surface);
433
+ border-color: var(--text-dim);
434
+ }
435
+
436
+ .ctrl-btn:disabled {
437
+ opacity: 0.35;
438
+ cursor: not-allowed;
439
+ }
440
+
441
+ .ctrl-btn.play {
442
+ border-color: var(--valley);
443
+ color: var(--valley);
444
+ }
445
+
446
+ .ctrl-btn.play:hover:not(:disabled) {
447
+ background: rgba(56, 189, 248, 0.1);
448
+ }
449
+
450
+ .ctrl-step-display {
451
+ font-family: var(--font-display);
452
+ font-size: 11px;
453
+ color: var(--text-dim);
454
+ padding: 4px 8px;
455
+ border: 1px solid var(--border);
456
+ background: var(--bg);
457
+ white-space: nowrap;
458
+ min-width: 72px;
459
+ text-align: center;
460
+ }
461
+
462
+ /* ─── LOADING / ERROR ─── */
463
+ .app-overlay {
464
+ position: fixed;
465
+ inset: 0;
466
+ display: flex;
467
  align-items: center;
468
  justify-content: center;
469
+ background: var(--bg);
470
+ z-index: 100;
471
+ }
472
+
473
+ .overlay-message {
474
+ font-family: var(--font-display);
475
+ font-size: 13px;
476
+ letter-spacing: 0.1em;
477
+ color: var(--text-dim);
478
+ display: flex;
479
+ align-items: center;
480
+ gap: 12px;
481
  }
482
 
483
+ .pulse-dot {
484
+ width: 8px;
485
+ height: 8px;
486
+ border-radius: 50%;
487
+ background: var(--valley);
488
+ animation: pulse 1.2s ease-in-out infinite;
489
  }
490
 
491
+ @keyframes pulse {
492
+ 0%, 100% { opacity: 0.2; transform: scale(0.8); }
493
+ 50% { opacity: 1; transform: scale(1); }
494
+ }
495
+
496
+ /* ─── MISC ─── */
497
+ .episode-loading {
498
+ display: flex;
499
+ align-items: center;
500
+ justify-content: center;
501
+ gap: 8px;
502
+ padding: 12px 16px;
503
+ font-family: var(--font-display);
504
+ font-size: 11px;
505
+ color: var(--text-dim);
506
+ letter-spacing: 0.08em;
507
  }
src/App.js CHANGED
@@ -1,23 +1,188 @@
1
- import logo from './logo.svg';
2
  import './App.css';
 
 
 
 
 
 
 
 
3
 
4
  function App() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  return (
6
- <div className="App">
7
- <header className="App-header">
8
- <img src={logo} className="App-logo" alt="logo" />
9
- <p>
10
- Edit <code>src/App.js</code> and save to reload.
11
- </p>
12
- <a
13
- className="App-link"
14
- href="https://reactjs.org"
15
- target="_blank"
16
- rel="noopener noreferrer"
17
- >
18
- Learn React
19
- </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  </header>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  </div>
22
  );
23
  }
 
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
  import './App.css';
3
+ import CreaseCanvas from './components/CreaseCanvas';
4
+ import RewardPanel from './components/RewardPanel';
5
+ import StepFeed from './components/StepFeed';
6
+ import InfoBadges from './components/InfoBadges';
7
+ import TargetSelector from './components/TargetSelector';
8
+ import PlayerControls from './components/PlayerControls';
9
+
10
+ const API_BASE = 'http://localhost:8000';
11
 
12
  function App() {
13
+ const [targets, setTargets] = useState({});
14
+ const [selectedTarget, setSelectedTarget] = useState('half_horizontal');
15
+ const [episode, setEpisode] = useState(null);
16
+ const [currentStep, setCurrentStep] = useState(0);
17
+ const [playing, setPlaying] = useState(false);
18
+ const [apiStatus, setApiStatus] = useState('connecting'); // 'connecting' | 'ok' | 'err'
19
+ const [episodeLoading, setEpisodeLoading] = useState(false);
20
+ const intervalRef = useRef(null);
21
+
22
+ const fetchTargets = useCallback(async () => {
23
+ try {
24
+ const res = await fetch(`${API_BASE}/targets`);
25
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
26
+ const data = await res.json();
27
+ setTargets(data);
28
+ setApiStatus('ok');
29
+ } catch {
30
+ setApiStatus('err');
31
+ }
32
+ }, []);
33
+
34
+ const fetchDemoEpisode = useCallback(async (targetName) => {
35
+ setEpisodeLoading(true);
36
+ setPlaying(false);
37
+ setCurrentStep(0);
38
+ try {
39
+ const res = await fetch(`${API_BASE}/episode/demo?target=${targetName}`);
40
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
41
+ const data = await res.json();
42
+ setEpisode(data);
43
+ setApiStatus('ok');
44
+ } catch {
45
+ setEpisode(null);
46
+ setApiStatus('err');
47
+ } finally {
48
+ setEpisodeLoading(false);
49
+ }
50
+ }, []);
51
+
52
+ useEffect(() => {
53
+ fetchTargets();
54
+ }, [fetchTargets]);
55
+
56
+ useEffect(() => {
57
+ fetchDemoEpisode(selectedTarget);
58
+ }, [selectedTarget, fetchDemoEpisode]);
59
+
60
+ const totalSteps = episode ? episode.steps.length : 0;
61
+
62
+ // currentStep is 1-indexed for display (0 = "empty paper before any folds")
63
+ // steps array is 0-indexed: steps[0] = result of fold 1
64
+ const activeStepData = episode && currentStep > 0 ? episode.steps[currentStep - 1] : null;
65
+
66
+ useEffect(() => {
67
+ if (playing) {
68
+ intervalRef.current = setInterval(() => {
69
+ setCurrentStep(prev => {
70
+ if (prev >= totalSteps) {
71
+ setPlaying(false);
72
+ return prev;
73
+ }
74
+ return prev + 1;
75
+ });
76
+ }, 1500);
77
+ }
78
+ return () => clearInterval(intervalRef.current);
79
+ }, [playing, totalSteps]);
80
+
81
+ const handlePlay = () => {
82
+ if (currentStep >= totalSteps) setCurrentStep(0);
83
+ setPlaying(true);
84
+ };
85
+ const handlePause = () => setPlaying(false);
86
+ const handleNext = () => {
87
+ setPlaying(false);
88
+ setCurrentStep(prev => Math.min(prev + 1, totalSteps));
89
+ };
90
+ const handlePrev = () => {
91
+ setPlaying(false);
92
+ setCurrentStep(prev => Math.max(prev - 1, 0));
93
+ };
94
+ const handleReset = () => {
95
+ setPlaying(false);
96
+ setCurrentStep(0);
97
+ };
98
+
99
+ const targetDef = targets[selectedTarget] || null;
100
+ const targetFold = episode ? episode.target : null;
101
+
102
  return (
103
+ <div className="app">
104
+ <header className="app-header">
105
+ <span className="app-title">
106
+ OPTI<span className="title-accent">GAMI</span> RL
107
+ </span>
108
+ <div className="header-sep" />
109
+ <TargetSelector
110
+ targets={targets}
111
+ selected={selectedTarget}
112
+ onChange={name => setSelectedTarget(name)}
113
+ />
114
+ <div className="header-sep" />
115
+ <PlayerControls
116
+ playing={playing}
117
+ onPlay={handlePlay}
118
+ onPause={handlePause}
119
+ onNext={handleNext}
120
+ onPrev={handlePrev}
121
+ onReset={handleReset}
122
+ currentStep={currentStep}
123
+ totalSteps={totalSteps}
124
+ disabled={!episode || episodeLoading}
125
+ />
126
+ <div className="header-right">
127
+ <div className="api-status">
128
+ <span className={`api-status-dot ${apiStatus === 'ok' ? 'ok' : apiStatus === 'err' ? 'err' : ''}`} />
129
+ <span>{apiStatus === 'ok' ? 'API OK' : apiStatus === 'err' ? 'API ERR' : 'CONNECTING'}</span>
130
+ </div>
131
+ </div>
132
  </header>
133
+
134
+ <div className="app-body">
135
+ <div className="app-left">
136
+ <div className="canvas-row">
137
+ <div className="canvas-wrap">
138
+ <span className="canvas-label">
139
+ TARGET — {targetDef ? targetDef.name.replace(/_/g, ' ').toUpperCase() : '—'}
140
+ </span>
141
+ <CreaseCanvas
142
+ paperState={null}
143
+ target={targetFold}
144
+ label="TARGET"
145
+ dim={280}
146
+ ghostOnly={true}
147
+ />
148
+ </div>
149
+ <div className="canvas-wrap">
150
+ <span className="canvas-label">
151
+ {currentStep === 0 ? 'INITIAL STATE' : `STEP ${currentStep} / ${totalSteps}`}
152
+ </span>
153
+ <CreaseCanvas
154
+ paperState={activeStepData ? activeStepData.paper_state : null}
155
+ target={targetFold}
156
+ label={currentStep === 0 ? 'INITIAL' : `STEP ${currentStep}`}
157
+ dim={280}
158
+ ghostOnly={false}
159
+ />
160
+ </div>
161
+ </div>
162
+
163
+ <div className="step-feed-section">
164
+ <div className="section-header">FOLD SEQUENCE</div>
165
+ {episodeLoading ? (
166
+ <div className="episode-loading">
167
+ <div className="pulse-dot" />
168
+ FETCHING EPISODE...
169
+ </div>
170
+ ) : (
171
+ <StepFeed
172
+ steps={episode ? episode.steps : []}
173
+ currentStep={currentStep}
174
+ />
175
+ )}
176
+ </div>
177
+ </div>
178
+
179
+ <div className="app-right">
180
+ <div className="section-header">REWARD DECOMPOSITION</div>
181
+ <RewardPanel reward={activeStepData ? activeStepData.reward : null} />
182
+ <div className="section-header">EPISODE INFO</div>
183
+ <InfoBadges info={activeStepData ? activeStepData.info : null} targetDef={targetDef} />
184
+ </div>
185
+ </div>
186
  </div>
187
  );
188
  }
src/App.test.js CHANGED
@@ -1,8 +1 @@
1
- import { render, screen } from '@testing-library/react';
2
- import App from './App';
3
-
4
- test('renders learn react link', () => {
5
- render(<App />);
6
- const linkElement = screen.getByText(/learn react/i);
7
- expect(linkElement).toBeInTheDocument();
8
- });
 
1
+ // Tests removed observability dashboard
 
 
 
 
 
 
 
src/components/CreaseCanvas.js ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const MOUNTAIN = '#f59e0b';
2
+ const VALLEY = '#38bdf8';
3
+
4
+ function toSvg(x, y, dim) {
5
+ return [x * dim, (1 - y) * dim];
6
+ }
7
+
8
+ function GhostEdges({ target, dim }) {
9
+ if (!target) return null;
10
+ const { vertices_coords, edges_vertices, edges_assignment } = target;
11
+ if (!vertices_coords || !edges_vertices || !edges_assignment) return null;
12
+
13
+ return edges_vertices.map((ev, i) => {
14
+ const asgn = edges_assignment[i];
15
+ if (asgn === 'B') return null;
16
+ const [v1x, v1y] = vertices_coords[ev[0]];
17
+ const [v2x, v2y] = vertices_coords[ev[1]];
18
+ const [x1, y1] = toSvg(v1x, v1y, dim);
19
+ const [x2, y2] = toSvg(v2x, v2y, dim);
20
+ const color = asgn === 'M' ? MOUNTAIN : VALLEY;
21
+ return (
22
+ <line
23
+ key={i}
24
+ x1={x1} y1={y1} x2={x2} y2={y2}
25
+ stroke={color}
26
+ strokeOpacity={0.25}
27
+ strokeWidth={1.5}
28
+ strokeDasharray="5 4"
29
+ />
30
+ );
31
+ });
32
+ }
33
+
34
+ function CurrentEdges({ paperState, dim }) {
35
+ if (!paperState || !paperState.edges) return null;
36
+ return paperState.edges.map((edge) => {
37
+ if (edge.assignment === 'B') return null;
38
+ const [x1, y1] = toSvg(edge.v1[0], edge.v1[1], dim);
39
+ const [x2, y2] = toSvg(edge.v2[0], edge.v2[1], dim);
40
+ const color = edge.assignment === 'M' ? MOUNTAIN : VALLEY;
41
+ return (
42
+ <line
43
+ key={edge.id}
44
+ x1={x1} y1={y1} x2={x2} y2={y2}
45
+ stroke={color}
46
+ strokeWidth={2.5}
47
+ strokeLinecap="square"
48
+ />
49
+ );
50
+ });
51
+ }
52
+
53
+ function AnchorCrosses({ paperState, dim }) {
54
+ if (!paperState || !paperState.anchor_points) return null;
55
+ const size = 4;
56
+ return paperState.anchor_points.map((pt, i) => {
57
+ const [cx, cy] = toSvg(pt[0], pt[1], dim);
58
+ return (
59
+ <g key={i}>
60
+ <line
61
+ x1={cx - size} y1={cy} x2={cx + size} y2={cy}
62
+ stroke="#64748b" strokeWidth={1}
63
+ />
64
+ <line
65
+ x1={cx} y1={cy - size} x2={cx} y2={cy + size}
66
+ stroke="#64748b" strokeWidth={1}
67
+ />
68
+ </g>
69
+ );
70
+ });
71
+ }
72
+
73
+ export default function CreaseCanvas({ paperState, target, dim = 280, ghostOnly = false }) {
74
+ const pad = 1;
75
+ const size = dim;
76
+
77
+ return (
78
+ <svg
79
+ className="canvas-svg"
80
+ width={size}
81
+ height={size}
82
+ viewBox={`0 0 ${size} ${size}`}
83
+ style={{ flexShrink: 0 }}
84
+ >
85
+ {/* Paper background */}
86
+ <rect
87
+ x={pad} y={pad}
88
+ width={size - pad * 2} height={size - pad * 2}
89
+ fill="#fafaf5"
90
+ />
91
+
92
+ {/* Ghost target overlay */}
93
+ <GhostEdges target={target} dim={size} />
94
+
95
+ {/* Current paper state */}
96
+ {!ghostOnly && (
97
+ <>
98
+ <CurrentEdges paperState={paperState} dim={size} />
99
+ <AnchorCrosses paperState={paperState} dim={size} />
100
+ </>
101
+ )}
102
+
103
+ {/* Paper border */}
104
+ <rect
105
+ x={pad} y={pad}
106
+ width={size - pad * 2} height={size - pad * 2}
107
+ fill="none"
108
+ stroke="#2a2a3a"
109
+ strokeWidth={1}
110
+ />
111
+ </svg>
112
+ );
113
+ }
src/components/InfoBadges.js ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function BoolVal({ value }) {
2
+ if (value === null || value === undefined) {
3
+ return <span className="info-val dim">—</span>;
4
+ }
5
+ return (
6
+ <span className={`info-val ${value ? 'bool-true' : 'bool-false'}`}>
7
+ {value ? 'TRUE' : 'FALSE'}
8
+ </span>
9
+ );
10
+ }
11
+
12
+ function TextVal({ value, dim = false }) {
13
+ if (value === null || value === undefined) {
14
+ return <span className="info-val dim">—</span>;
15
+ }
16
+ return (
17
+ <span className={`info-val${dim ? ' dim' : ''}`}>
18
+ {String(value).toUpperCase()}
19
+ </span>
20
+ );
21
+ }
22
+
23
+ function NumVal({ value }) {
24
+ if (value === null || value === undefined) {
25
+ return <span className="info-val dim">—</span>;
26
+ }
27
+ return <span className="info-val">{value}</span>;
28
+ }
29
+
30
+ export default function InfoBadges({ info, targetDef }) {
31
+ return (
32
+ <div className="info-badges">
33
+ <div className="info-row">
34
+ <span className="info-key">n_creases</span>
35
+ <NumVal value={info ? info.n_creases : (targetDef ? targetDef.n_creases : null)} />
36
+ </div>
37
+ <div className="info-row">
38
+ <span className="info-key">interior_verts</span>
39
+ <NumVal value={info ? info.n_interior_vertices : null} />
40
+ </div>
41
+ <div className="info-row">
42
+ <span className="info-key">local_fold</span>
43
+ <BoolVal value={info ? info.local_foldability : null} />
44
+ </div>
45
+ <div className="info-row">
46
+ <span className="info-key">blb_sat</span>
47
+ <BoolVal value={info ? info.blb_satisfied : null} />
48
+ </div>
49
+ <div className="info-row">
50
+ <span className="info-key">global_fold</span>
51
+ <TextVal
52
+ value={info ? info.global_foldability : null}
53
+ dim={true}
54
+ />
55
+ </div>
56
+ {targetDef && (
57
+ <>
58
+ <div className="info-row">
59
+ <span className="info-key">level</span>
60
+ <span className="info-val">LVL {targetDef.level}</span>
61
+ </div>
62
+ <div className="info-row">
63
+ <span className="info-key">target</span>
64
+ <span className="info-val" style={{ fontSize: '10px', textAlign: 'right', maxWidth: '140px', wordBreak: 'break-word' }}>
65
+ {targetDef.name.replace(/_/g, ' ').toUpperCase()}
66
+ </span>
67
+ </div>
68
+ </>
69
+ )}
70
+ </div>
71
+ );
72
+ }
src/components/PlayerControls.js ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function PlayerControls({
2
+ playing,
3
+ onPlay,
4
+ onPause,
5
+ onNext,
6
+ onPrev,
7
+ onReset,
8
+ currentStep,
9
+ totalSteps,
10
+ disabled,
11
+ }) {
12
+ const atStart = currentStep === 0;
13
+ const atEnd = currentStep >= totalSteps;
14
+
15
+ return (
16
+ <div className="player-controls">
17
+ <button
18
+ className="ctrl-btn"
19
+ onClick={onReset}
20
+ disabled={disabled || atStart}
21
+ title="Reset to start"
22
+ >
23
+ ⏮ RST
24
+ </button>
25
+ <button
26
+ className="ctrl-btn"
27
+ onClick={onPrev}
28
+ disabled={disabled || atStart}
29
+ title="Previous step"
30
+ >
31
+ ◀ PREV
32
+ </button>
33
+ <span className="ctrl-step-display">
34
+ {disabled ? '—/—' : `${currentStep} / ${totalSteps}`}
35
+ </span>
36
+ <button
37
+ className="ctrl-btn"
38
+ onClick={onNext}
39
+ disabled={disabled || atEnd}
40
+ title="Next step"
41
+ >
42
+ NEXT ▶
43
+ </button>
44
+ <button
45
+ className={`ctrl-btn play`}
46
+ onClick={playing ? onPause : onPlay}
47
+ disabled={disabled || (!playing && atEnd)}
48
+ title={playing ? 'Pause' : 'Play'}
49
+ >
50
+ {playing ? '⏸ PAUSE' : '▶▶ PLAY'}
51
+ </button>
52
+ </div>
53
+ );
54
+ }
src/components/RewardPanel.js ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const REWARD_FIELDS = [
2
+ { key: 'kawasaki', label: 'kawasaki', color: 'var(--validity)' },
3
+ { key: 'maekawa', label: 'maekawa', color: 'var(--validity)' },
4
+ { key: 'blb', label: 'blb', color: 'var(--validity)' },
5
+ { key: 'progress', label: 'progress', color: 'var(--progress)' },
6
+ { key: 'economy', label: 'economy', color: 'var(--economy)' },
7
+ ];
8
+
9
+ const TOTAL_FIELD = { key: 'total', label: 'total', color: 'var(--text-primary)' };
10
+
11
+ function RewardRow({ label, color, value }) {
12
+ const isDash = value === null || value === undefined;
13
+ const pct = isDash ? 0 : Math.min(Math.max(value, 0), 1) * 100;
14
+
15
+ return (
16
+ <div className="reward-row">
17
+ <span className="reward-label">{label}</span>
18
+ <div className="reward-track">
19
+ <div
20
+ className="reward-bar"
21
+ style={{ width: `${pct}%`, background: color }}
22
+ />
23
+ </div>
24
+ <span className={`reward-value${isDash ? ' dim' : ''}`}>
25
+ {isDash ? '—' : value.toFixed(2)}
26
+ </span>
27
+ </div>
28
+ );
29
+ }
30
+
31
+ export default function RewardPanel({ reward }) {
32
+ return (
33
+ <div className="reward-panel">
34
+ {REWARD_FIELDS.map(({ key, label, color }) => (
35
+ <RewardRow
36
+ key={key}
37
+ label={label}
38
+ color={color}
39
+ value={reward ? reward[key] : null}
40
+ />
41
+ ))}
42
+ <div className="reward-divider" />
43
+ <RewardRow
44
+ label={TOTAL_FIELD.label}
45
+ color={TOTAL_FIELD.color}
46
+ value={reward ? reward[TOTAL_FIELD.key] : null}
47
+ />
48
+ </div>
49
+ );
50
+ }
src/components/StepFeed.js ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ function rewardDelta(step, prevStep) {
4
+ if (!step || !step.reward) return null;
5
+ const curr = step.reward.total;
6
+ if (prevStep && prevStep.reward) {
7
+ return curr - prevStep.reward.total;
8
+ }
9
+ return curr;
10
+ }
11
+
12
+ export default function StepFeed({ steps, currentStep }) {
13
+ const feedRef = useRef(null);
14
+ const activeRef = useRef(null);
15
+
16
+ useEffect(() => {
17
+ if (activeRef.current) {
18
+ activeRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
19
+ }
20
+ }, [currentStep]);
21
+
22
+ if (!steps || steps.length === 0) {
23
+ return (
24
+ <div className="step-feed">
25
+ <div style={{ padding: '16px', color: 'var(--text-dim)', fontFamily: 'var(--font-display)', fontSize: '11px' }}>
26
+ NO STEPS LOADED
27
+ </div>
28
+ </div>
29
+ );
30
+ }
31
+
32
+ return (
33
+ <div className="step-feed" ref={feedRef}>
34
+ {steps.map((step, idx) => {
35
+ const stepNum = idx + 1;
36
+ const isActive = currentStep === stepNum;
37
+ const delta = rewardDelta(step, idx > 0 ? steps[idx - 1] : null);
38
+ const asgn = step.fold ? step.fold.assignment : null;
39
+ const instruction = step.fold ? step.fold.instruction : (step.prompt || '');
40
+
41
+ return (
42
+ <div
43
+ key={stepNum}
44
+ className={`step-entry${isActive ? ' active' : ''}`}
45
+ ref={isActive ? activeRef : null}
46
+ >
47
+ <div className="step-entry-top">
48
+ <span className="step-num">#{stepNum}</span>
49
+ <span className="step-instruction">{instruction}</span>
50
+ {asgn && (
51
+ <span className={`assign-badge ${asgn}`}>{asgn}</span>
52
+ )}
53
+ </div>
54
+ {delta !== null && (
55
+ <div className="step-reward-delta">
56
+ {'\u0394'} total:{' '}
57
+ <span className={delta >= 0 ? 'delta-positive' : 'delta-negative'}>
58
+ {delta >= 0 ? '+' : ''}{delta.toFixed(3)}
59
+ </span>
60
+ {step.reward && (
61
+ <span style={{ color: 'var(--text-dim)' }}>
62
+ {' '}| progress: {step.reward.progress.toFixed(2)}
63
+ {' '}| economy: {step.reward.economy.toFixed(2)}
64
+ </span>
65
+ )}
66
+ </div>
67
+ )}
68
+ </div>
69
+ );
70
+ })}
71
+ </div>
72
+ );
73
+ }
src/components/TargetSelector.js ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function groupByLevel(targets) {
2
+ const levels = {};
3
+ Object.values(targets).forEach(t => {
4
+ if (!levels[t.level]) levels[t.level] = [];
5
+ levels[t.level].push(t);
6
+ });
7
+ return levels;
8
+ }
9
+
10
+ export default function TargetSelector({ targets, selected, onChange }) {
11
+ const levels = groupByLevel(targets);
12
+ const sortedLevels = Object.keys(levels).sort((a, b) => Number(a) - Number(b));
13
+
14
+ return (
15
+ <div className="target-selector">
16
+ <span className="target-selector-label">TARGET</span>
17
+ <select
18
+ className="target-select"
19
+ value={selected}
20
+ onChange={e => onChange(e.target.value)}
21
+ >
22
+ {sortedLevels.length === 0 ? (
23
+ <option value="">LOADING...</option>
24
+ ) : (
25
+ sortedLevels.map(level => (
26
+ <optgroup key={level} label={`── LEVEL ${level}`}>
27
+ {levels[level].map(t => (
28
+ <option key={t.name} value={t.name}>
29
+ {t.name.replace(/_/g, ' ').toUpperCase()}
30
+ </option>
31
+ ))}
32
+ </optgroup>
33
+ ))
34
+ )}
35
+ </select>
36
+ </div>
37
+ );
38
+ }
src/index.css CHANGED
@@ -1,13 +1,34 @@
1
- body {
 
 
 
2
  margin: 0;
3
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4
- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5
- sans-serif;
 
 
 
 
 
 
6
  -webkit-font-smoothing: antialiased;
7
- -moz-osx-font-smoothing: grayscale;
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  }
9
 
10
- code {
11
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12
- monospace;
13
  }
 
1
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=IBM+Plex+Mono:wght@300;400;500&display=swap');
2
+
3
+ *, *::before, *::after {
4
+ box-sizing: border-box;
5
  margin: 0;
6
+ padding: 0;
7
+ }
8
+
9
+ body {
10
+ background: #0d0d14;
11
+ color: #f8fafc;
12
+ font-family: 'IBM Plex Mono', monospace;
13
+ font-size: 13px;
14
+ line-height: 1.5;
15
  -webkit-font-smoothing: antialiased;
16
+ overflow-x: hidden;
17
+ }
18
+
19
+ ::-webkit-scrollbar {
20
+ width: 4px;
21
+ height: 4px;
22
+ }
23
+
24
+ ::-webkit-scrollbar-track {
25
+ background: #0d0d14;
26
+ }
27
+
28
+ ::-webkit-scrollbar-thumb {
29
+ background: #2a2a3a;
30
  }
31
 
32
+ ::-webkit-scrollbar-thumb:hover {
33
+ background: #3a3a5a;
 
34
  }
src/reportWebVitals.js CHANGED
@@ -1,13 +1 @@
1
- const reportWebVitals = onPerfEntry => {
2
- if (onPerfEntry && onPerfEntry instanceof Function) {
3
- import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4
- getCLS(onPerfEntry);
5
- getFID(onPerfEntry);
6
- getFCP(onPerfEntry);
7
- getLCP(onPerfEntry);
8
- getTTFB(onPerfEntry);
9
- });
10
- }
11
- };
12
-
13
- export default reportWebVitals;
 
1
+ export default function reportWebVitals() {}
 
 
 
 
 
 
 
 
 
 
 
 
viz/__init__.py ADDED
File without changes
viz/renderer.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Matplotlib-based crease pattern renderer.
3
+ Used for quick observability during training and debugging.
4
+ """
5
+ import json
6
+ import numpy as np
7
+ import matplotlib.pyplot as plt
8
+ import matplotlib.patches as patches
9
+ from matplotlib.animation import FuncAnimation
10
+ from typing import Optional
11
+
12
+
13
+ # Design system colors
14
+ _COLOR_MOUNTAIN = "#f59e0b"
15
+ _COLOR_VALLEY = "#38bdf8"
16
+ _COLOR_PAPER = "#fafaf5"
17
+ _COLOR_PAPER_EDGE = "#e2e8f0"
18
+ _COLOR_AX_BG = "#1a1a2e"
19
+ _COLOR_ANCHOR = "#4a4a6a"
20
+ _COLOR_REWARD_BG = "#13131d"
21
+ _COLOR_GRID = "#2a2a3a"
22
+ _COLOR_VALIDITY = "#22d3ee"
23
+ _COLOR_PROGRESS = "#22c55e"
24
+ _COLOR_ECONOMY = "#a78bfa"
25
+
26
+
27
+ def draw_paper_state(ax, paper_state, target=None, step=None, reward=None):
28
+ """
29
+ Draw the current crease pattern on a matplotlib axes object.
30
+
31
+ Args:
32
+ ax: matplotlib axes
33
+ paper_state: PaperState instance
34
+ target: optional FOLD dict for target crease ghost overlay
35
+ step: step number for title (None = "Initial")
36
+ reward: unused, kept for signature compatibility
37
+ """
38
+ ax.set_facecolor(_COLOR_AX_BG)
39
+
40
+ # Unit square paper
41
+ square = patches.Rectangle(
42
+ (0, 0), 1, 1,
43
+ facecolor=_COLOR_PAPER,
44
+ edgecolor=_COLOR_PAPER_EDGE,
45
+ linewidth=1.5,
46
+ zorder=1,
47
+ )
48
+ ax.add_patch(square)
49
+
50
+ # Target ghost overlay
51
+ if target is not None:
52
+ verts = target["vertices_coords"]
53
+ edges_v = target["edges_vertices"]
54
+ edges_a = target["edges_assignment"]
55
+ for (v1, v2), assignment in zip(edges_v, edges_a):
56
+ if assignment not in ("M", "V"):
57
+ continue
58
+ x1, y1 = verts[v1]
59
+ x2, y2 = verts[v2]
60
+ color = _COLOR_MOUNTAIN if assignment == "M" else _COLOR_VALLEY
61
+ ax.plot(
62
+ [x1, x2], [y1, y2],
63
+ color=color,
64
+ alpha=0.2,
65
+ linewidth=1,
66
+ linestyle="--",
67
+ zorder=2,
68
+ )
69
+
70
+ # Current crease edges
71
+ for edge in paper_state.crease_edges():
72
+ x1, y1 = edge["v1"]
73
+ x2, y2 = edge["v2"]
74
+ assignment = edge["assignment"]
75
+ color = _COLOR_MOUNTAIN if assignment == "M" else _COLOR_VALLEY
76
+ ax.plot(
77
+ [x1, x2], [y1, y2],
78
+ color=color,
79
+ linewidth=2.5,
80
+ linestyle="-",
81
+ solid_capstyle="round",
82
+ zorder=3,
83
+ )
84
+ # Endpoint dots
85
+ ax.plot(
86
+ [x1, x2], [y1, y2],
87
+ color=color,
88
+ marker="o",
89
+ markersize=5,
90
+ linestyle="none",
91
+ zorder=4,
92
+ )
93
+
94
+ # Anchor points as gray crosses
95
+ for x, y in paper_state.anchor_points():
96
+ ax.plot(
97
+ x, y,
98
+ color=_COLOR_ANCHOR,
99
+ marker="+",
100
+ markersize=3,
101
+ linestyle="none",
102
+ zorder=5,
103
+ )
104
+
105
+ # Title
106
+ title = f"Step {step}" if step is not None else "Initial"
107
+ ax.set_title(title, color="white", fontfamily="monospace", fontsize=10, pad=6)
108
+
109
+ # Remove ticks and spines
110
+ ax.set_xticks([])
111
+ ax.set_yticks([])
112
+ for spine in ax.spines.values():
113
+ spine.set_visible(False)
114
+
115
+ ax.set_xlim(-0.05, 1.05)
116
+ ax.set_ylim(-0.05, 1.05)
117
+ ax.set_aspect("equal")
118
+
119
+
120
+ def draw_reward_bars(ax, reward: dict):
121
+ """
122
+ Draw a horizontal bar chart of reward components.
123
+
124
+ Args:
125
+ ax: matplotlib axes
126
+ reward: dict with keys kawasaki, maekawa, blb, progress, economy (all 0-1)
127
+ """
128
+ components = ["kawasaki", "maekawa", "blb", "progress", "economy"]
129
+ colors = {
130
+ "kawasaki": _COLOR_VALIDITY,
131
+ "maekawa": _COLOR_VALIDITY,
132
+ "blb": _COLOR_VALIDITY,
133
+ "progress": _COLOR_PROGRESS,
134
+ "economy": _COLOR_ECONOMY,
135
+ }
136
+
137
+ values = [float(reward.get(c, 0.0)) for c in components]
138
+
139
+ ax.set_facecolor(_COLOR_REWARD_BG)
140
+
141
+ bar_colors = [colors[c] for c in components]
142
+ bars = ax.barh(
143
+ components,
144
+ values,
145
+ height=0.6,
146
+ color=bar_colors,
147
+ zorder=2,
148
+ )
149
+
150
+ # Value labels at end of each bar
151
+ for bar, val in zip(bars, values):
152
+ ax.text(
153
+ min(val + 0.02, 0.98),
154
+ bar.get_y() + bar.get_height() / 2,
155
+ f"{val:.2f}",
156
+ va="center",
157
+ ha="left",
158
+ color="white",
159
+ fontfamily="monospace",
160
+ fontsize=8,
161
+ zorder=3,
162
+ )
163
+
164
+ # Y-axis label style
165
+ ax.tick_params(axis="y", colors="white", labelsize=8)
166
+ for label in ax.get_yticklabels():
167
+ label.set_fontfamily("monospace")
168
+
169
+ # Subtle x gridlines
170
+ for x_pos in [0.25, 0.5, 0.75, 1.0]:
171
+ ax.axvline(x_pos, color=_COLOR_GRID, linewidth=0.8, zorder=1)
172
+
173
+ ax.set_xlim(0, 1.0)
174
+ ax.set_xticks([])
175
+ ax.tick_params(axis="x", colors="white")
176
+ for spine in ax.spines.values():
177
+ spine.set_visible(False)
178
+
179
+ ax.set_title("Reward Breakdown", color="white", fontfamily="monospace", fontsize=10, pad=6)
180
+
181
+
182
+ def render_episode(fold_history, target, rewards_history, save_path=None):
183
+ """
184
+ Create a multi-panel figure showing an entire episode.
185
+
186
+ Args:
187
+ fold_history: list of PaperState snapshots (one per step)
188
+ target: FOLD dict of target crease pattern
189
+ rewards_history: list of reward dicts (one per step)
190
+ save_path: if provided, save PNG here; otherwise plt.show()
191
+
192
+ Returns:
193
+ matplotlib Figure
194
+ """
195
+ n_states = len(fold_history)
196
+ show_states = min(n_states, 4)
197
+
198
+ fig = plt.figure(figsize=(4 * show_states + 4, 5), facecolor="#0d0d14")
199
+ gs = fig.add_gridspec(
200
+ 1, show_states + 1,
201
+ width_ratios=[1] * show_states + [1.2],
202
+ wspace=0.3,
203
+ )
204
+
205
+ # Paper state panels (up to 4)
206
+ for i in range(show_states):
207
+ # Evenly sample from fold_history if more than 4 steps
208
+ idx = int(i * (n_states - 1) / max(show_states - 1, 1)) if show_states > 1 else 0
209
+ ax = fig.add_subplot(gs[0, i])
210
+ draw_paper_state(
211
+ ax,
212
+ fold_history[idx],
213
+ target=target,
214
+ step=idx + 1,
215
+ reward=rewards_history[idx] if idx < len(rewards_history) else None,
216
+ )
217
+
218
+ # Reward curves panel
219
+ ax_reward = fig.add_subplot(gs[0, show_states])
220
+ ax_reward.set_facecolor(_COLOR_REWARD_BG)
221
+
222
+ steps = list(range(1, len(rewards_history) + 1))
223
+ curve_specs = [
224
+ ("progress", _COLOR_PROGRESS, "progress"),
225
+ ("kawasaki", _COLOR_VALIDITY, "kawasaki"),
226
+ ("total", "#f8fafc", "total"),
227
+ ]
228
+
229
+ for key, color, label in curve_specs:
230
+ vals = [r.get(key, 0.0) for r in rewards_history]
231
+ ax_reward.plot(steps, vals, color=color, linewidth=1.5, label=label)
232
+
233
+ ax_reward.set_xlim(1, max(len(rewards_history), 1))
234
+ ax_reward.set_title("Reward Curves", color="white", fontfamily="monospace", fontsize=10, pad=6)
235
+ ax_reward.tick_params(colors="white", labelsize=8)
236
+ ax_reward.legend(
237
+ fontsize=7,
238
+ facecolor=_COLOR_REWARD_BG,
239
+ edgecolor=_COLOR_GRID,
240
+ labelcolor="white",
241
+ )
242
+ for spine in ax_reward.spines.values():
243
+ spine.set_color(_COLOR_GRID)
244
+
245
+ if save_path:
246
+ fig.savefig(save_path, dpi=150, facecolor="#0d0d14", bbox_inches="tight")
247
+ else:
248
+ plt.show()
249
+
250
+ return fig
251
+
252
+
253
+ def render_training_curves(log_path: str):
254
+ """
255
+ Read a JSONL log file and plot training curves.
256
+
257
+ Each line must be a JSON object with reward component keys.
258
+
259
+ Args:
260
+ log_path: path to JSONL training log
261
+
262
+ Returns:
263
+ matplotlib Figure
264
+ """
265
+ records = []
266
+ with open(log_path) as f:
267
+ for line in f:
268
+ line = line.strip()
269
+ if not line:
270
+ continue
271
+ records.append(json.loads(line))
272
+
273
+ episodes = list(range(1, len(records) + 1))
274
+
275
+ keys_to_plot = [
276
+ ("total", "#f8fafc", "total reward"),
277
+ ("progress", _COLOR_PROGRESS, "progress"),
278
+ ("kawasaki", _COLOR_VALIDITY, "kawasaki"),
279
+ ("maekawa", _COLOR_VALIDITY, "maekawa"),
280
+ ("blb", _COLOR_VALIDITY, "blb"),
281
+ ]
282
+
283
+ fig, axes = plt.subplots(
284
+ 2, 1,
285
+ figsize=(10, 6),
286
+ facecolor="#0d0d14",
287
+ gridspec_kw={"hspace": 0.4},
288
+ )
289
+
290
+ # Top: total + progress
291
+ ax_top = axes[0]
292
+ ax_top.set_facecolor(_COLOR_REWARD_BG)
293
+ for key, color, label in keys_to_plot[:2]:
294
+ vals = [r.get(key, 0.0) for r in records]
295
+ ax_top.plot(episodes, vals, color=color, linewidth=1.5, label=label)
296
+ ax_top.set_title("Training: Total & Progress", color="white", fontfamily="monospace", fontsize=10)
297
+ ax_top.tick_params(colors="white", labelsize=8)
298
+ ax_top.legend(fontsize=8, facecolor=_COLOR_REWARD_BG, edgecolor=_COLOR_GRID, labelcolor="white")
299
+ for spine in ax_top.spines.values():
300
+ spine.set_color(_COLOR_GRID)
301
+
302
+ # Bottom: kawasaki, maekawa, blb
303
+ ax_bot = axes[1]
304
+ ax_bot.set_facecolor(_COLOR_REWARD_BG)
305
+ for key, color, label in keys_to_plot[2:]:
306
+ vals = [r.get(key, 0.0) for r in records]
307
+ ax_bot.plot(episodes, vals, color=color, linewidth=1.5, label=label, alpha=0.85)
308
+ ax_bot.set_title("Training: Validity Checks", color="white", fontfamily="monospace", fontsize=10)
309
+ ax_bot.set_xlabel("Episode", color="white", fontsize=9)
310
+ ax_bot.tick_params(colors="white", labelsize=8)
311
+ ax_bot.legend(fontsize=8, facecolor=_COLOR_REWARD_BG, edgecolor=_COLOR_GRID, labelcolor="white")
312
+ for spine in ax_bot.spines.values():
313
+ spine.set_color(_COLOR_GRID)
314
+
315
+ return fig