Zayne Rea Sprague Claude Opus 4.6 commited on
Commit
86f6a2a
·
1 Parent(s): e6cfd0f

feat: add Research Dashboard with Experiments page as parent site

Browse files

Restructure agg_visualizer into a Research Dashboard with top-level
navigation (Experiments | Visualizer). The existing visualizer is
preserved as a subpage. New Experiments page provides CRUD for
tracking experiments, runs, sub-experiments, and HF datasets.

Backend: /api/experiments/ with JSON storage in HF dataset repo
(reasoning-degeneration-dev/RESEARCH_DASHBOARD), import endpoint
for exp-runner integration.

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

.gitignore CHANGED
@@ -4,4 +4,5 @@ __pycache__/
4
  *.pyc
5
  .env
6
  backend/presets/
 
7
  .DS_Store
 
4
  *.pyc
5
  .env
6
  backend/presets/
7
+ backend/data/
8
  .DS_Store
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Aggregate Trace Visualizer
3
  emoji: 📊
4
  colorFrom: blue
5
  colorTo: purple
@@ -7,13 +7,30 @@ sdk: docker
7
  pinned: false
8
  ---
9
 
10
- # Aggregate Trace Visualizer
11
 
12
- A unified interface for four trace visualization tools:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  - **Model Trace** - Analyze reasoning traces from model responses (think tags, backtracks, restarts)
15
  - **Arena** - Explore multi-agent game episodes and transcripts
16
  - **RLM** - Navigate hierarchical RLM call traces (GEPA iterations, RLM calls)
 
17
  - **Harbor** - View SWE-bench agent trajectories (ATIF + raw message formats)
 
18
 
19
  Each visualizer loads datasets from HuggingFace and supports preset configurations stored in `reasoning-degeneration-dev/AGG_VIS_PRESETS`.
 
1
  ---
2
+ title: Research Dashboard
3
  emoji: 📊
4
  colorFrom: blue
5
  colorTo: purple
 
7
  pinned: false
8
  ---
9
 
10
+ # Research Dashboard
11
 
12
+ A unified research control pane with two main sections:
13
+
14
+ ## Experiments
15
+
16
+ Track research experiments, hypotheses, runs, and artifacts:
17
+
18
+ - **Experiment tracking** - Create/manage experiments with hypothesis statements, status, and completeness scoring
19
+ - **Run history** - Record runs with conditions, models, clusters, metrics, and HF dataset links
20
+ - **Sub-experiments** - Drill into focused sub-studies with markdown reports
21
+ - **HF dataset catalog** - Link and browse all HuggingFace datasets per experiment
22
+
23
+ Data stored in `reasoning-degeneration-dev/RESEARCH_DASHBOARD`. Supports programmatic import via `/api/experiments/import`.
24
+
25
+ ## Visualizer
26
+
27
+ Six trace visualization tools:
28
 
29
  - **Model Trace** - Analyze reasoning traces from model responses (think tags, backtracks, restarts)
30
  - **Arena** - Explore multi-agent game episodes and transcripts
31
  - **RLM** - Navigate hierarchical RLM call traces (GEPA iterations, RLM calls)
32
+ - **RLM Eval** - RLM evaluation trace viewer
33
  - **Harbor** - View SWE-bench agent trajectories (ATIF + raw message formats)
34
+ - **AdaEvolve** - Explore AdaEvolve optimization traces
35
 
36
  Each visualizer loads datasets from HuggingFace and supports preset configurations stored in `reasoning-degeneration-dev/AGG_VIS_PRESETS`.
backend/api/experiments.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import uuid
4
+ import tempfile
5
+ import threading
6
+ from datetime import datetime, timezone
7
+ from flask import Blueprint, request, jsonify
8
+
9
+ bp = Blueprint("experiments", __name__, url_prefix="/api/experiments")
10
+
11
+ DASHBOARD_REPO = "reasoning-degeneration-dev/RESEARCH_DASHBOARD"
12
+ LOCAL_DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
13
+
14
+ _cache: dict[str, list[dict]] = {}
15
+ _cache_loaded: set[str] = set()
16
+ _lock = threading.Lock()
17
+
18
+ FILES = ["experiments", "runs", "sub_experiments"]
19
+
20
+
21
+ def _ensure_local_dir():
22
+ os.makedirs(LOCAL_DATA_DIR, exist_ok=True)
23
+
24
+
25
+ def _local_path(name: str) -> str:
26
+ _ensure_local_dir()
27
+ return os.path.join(LOCAL_DATA_DIR, f"{name}.json")
28
+
29
+
30
+ def _download_file(name: str) -> list[dict]:
31
+ try:
32
+ from huggingface_hub import hf_hub_download
33
+ path = hf_hub_download(
34
+ DASHBOARD_REPO,
35
+ f"{name}.json",
36
+ repo_type="dataset",
37
+ )
38
+ with open(path) as f:
39
+ data = json.load(f)
40
+ with open(_local_path(name), "w") as f:
41
+ json.dump(data, f, indent=2)
42
+ return data
43
+ except Exception:
44
+ local = _local_path(name)
45
+ if os.path.exists(local):
46
+ with open(local) as f:
47
+ return json.load(f)
48
+ return []
49
+
50
+
51
+ def _upload_file(name: str, data: list[dict]):
52
+ with open(_local_path(name), "w") as f:
53
+ json.dump(data, f, indent=2)
54
+
55
+ def _do_upload():
56
+ try:
57
+ from huggingface_hub import HfApi
58
+ api = HfApi()
59
+ try:
60
+ api.create_repo(DASHBOARD_REPO, repo_type="dataset", exist_ok=True)
61
+ except Exception:
62
+ pass
63
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
64
+ json.dump(data, f, indent=2)
65
+ tmp = f.name
66
+ api.upload_file(
67
+ path_or_fileobj=tmp,
68
+ path_in_repo=f"{name}.json",
69
+ repo_id=DASHBOARD_REPO,
70
+ repo_type="dataset",
71
+ )
72
+ os.unlink(tmp)
73
+ except Exception as e:
74
+ print(f"[experiments] HF upload failed for {name}: {e}")
75
+
76
+ threading.Thread(target=_do_upload, daemon=True).start()
77
+
78
+
79
+ def _get(name: str) -> list[dict]:
80
+ with _lock:
81
+ if name not in _cache_loaded:
82
+ _cache[name] = _download_file(name)
83
+ _cache_loaded.add(name)
84
+ return list(_cache.get(name, []))
85
+
86
+
87
+ def _set(name: str, data: list[dict]):
88
+ with _lock:
89
+ _cache[name] = data
90
+ _cache_loaded.add(name)
91
+ _upload_file(name, data)
92
+
93
+
94
+ def _now() -> str:
95
+ return datetime.now(timezone.utc).isoformat()
96
+
97
+
98
+ # --- Experiments CRUD ---
99
+
100
+ @bp.route("/", methods=["GET"])
101
+ def list_experiments():
102
+ experiments = _get("experiments")
103
+ runs = _get("runs")
104
+ subs = _get("sub_experiments")
105
+
106
+ # Enrich with counts
107
+ result = []
108
+ for exp in experiments:
109
+ exp_runs = [r for r in runs if r.get("experiment_id") == exp["id"]]
110
+ exp_subs = [s for s in subs if s.get("experiment_id") == exp["id"]]
111
+ result.append({
112
+ **exp,
113
+ "run_count": len(exp_runs),
114
+ "sub_count": len(exp_subs),
115
+ })
116
+ return jsonify(result)
117
+
118
+
119
+ @bp.route("/", methods=["POST"])
120
+ def create_experiment():
121
+ data = request.get_json()
122
+ name = data.get("name", "").strip()
123
+ if not name:
124
+ return jsonify({"error": "name is required"}), 400
125
+
126
+ exp_id = data.get("id", name.lower().replace(" ", "_"))
127
+
128
+ experiments = _get("experiments")
129
+ if any(e["id"] == exp_id for e in experiments):
130
+ return jsonify({"error": f"Experiment '{exp_id}' already exists"}), 409
131
+
132
+ experiment = {
133
+ "id": exp_id,
134
+ "name": name,
135
+ "research_project": data.get("research_project", ""),
136
+ "hypothesis": data.get("hypothesis", {
137
+ "statement": "",
138
+ "type": "exploration",
139
+ "status": "pending",
140
+ "success_criteria": "",
141
+ }),
142
+ "stage": data.get("stage", "idea"),
143
+ "completeness": data.get("completeness", 0),
144
+ "models": data.get("models", []),
145
+ "tasks": data.get("tasks", []),
146
+ "tags": data.get("tags", []),
147
+ "hf_repos": data.get("hf_repos", []),
148
+ "wandb_url": data.get("wandb_url", ""),
149
+ "notes": data.get("notes", ""),
150
+ "created": _now(),
151
+ "updated": _now(),
152
+ }
153
+
154
+ experiments.append(experiment)
155
+ _set("experiments", experiments)
156
+ return jsonify(experiment), 201
157
+
158
+
159
+ @bp.route("/<exp_id>", methods=["GET"])
160
+ def get_experiment(exp_id):
161
+ experiments = _get("experiments")
162
+ exp = next((e for e in experiments if e["id"] == exp_id), None)
163
+ if not exp:
164
+ return jsonify({"error": "not found"}), 404
165
+
166
+ runs = [r for r in _get("runs") if r.get("experiment_id") == exp_id]
167
+ subs = [s for s in _get("sub_experiments") if s.get("experiment_id") == exp_id]
168
+
169
+ return jsonify({**exp, "runs": runs, "sub_experiments": subs})
170
+
171
+
172
+ @bp.route("/<exp_id>", methods=["PUT"])
173
+ def update_experiment(exp_id):
174
+ data = request.get_json()
175
+ experiments = _get("experiments")
176
+
177
+ for exp in experiments:
178
+ if exp["id"] == exp_id:
179
+ for key in ["name", "research_project", "hypothesis", "stage",
180
+ "completeness", "models", "tasks", "tags", "hf_repos",
181
+ "wandb_url", "notes"]:
182
+ if key in data:
183
+ exp[key] = data[key]
184
+ exp["updated"] = _now()
185
+ _set("experiments", experiments)
186
+ return jsonify(exp)
187
+
188
+ return jsonify({"error": "not found"}), 404
189
+
190
+
191
+ @bp.route("/<exp_id>", methods=["DELETE"])
192
+ def delete_experiment(exp_id):
193
+ experiments = _get("experiments")
194
+ experiments = [e for e in experiments if e["id"] != exp_id]
195
+ _set("experiments", experiments)
196
+
197
+ # Also delete associated runs and subs
198
+ runs = [r for r in _get("runs") if r.get("experiment_id") != exp_id]
199
+ _set("runs", runs)
200
+ subs = [s for s in _get("sub_experiments") if s.get("experiment_id") != exp_id]
201
+ _set("sub_experiments", subs)
202
+
203
+ return jsonify({"status": "ok"})
204
+
205
+
206
+ # --- Run records ---
207
+
208
+ @bp.route("/<exp_id>/runs", methods=["POST"])
209
+ def create_run(exp_id):
210
+ experiments = _get("experiments")
211
+ if not any(e["id"] == exp_id for e in experiments):
212
+ return jsonify({"error": "experiment not found"}), 404
213
+
214
+ data = request.get_json()
215
+ run = {
216
+ "id": data.get("id", f"run_{uuid.uuid4().hex[:8]}"),
217
+ "experiment_id": exp_id,
218
+ "condition": data.get("condition", ""),
219
+ "model": data.get("model", ""),
220
+ "cluster": data.get("cluster", ""),
221
+ "status": data.get("status", "completed"),
222
+ "hf_dataset": data.get("hf_dataset", ""),
223
+ "metrics": data.get("metrics", {}),
224
+ "timestamp": data.get("timestamp", _now()),
225
+ "notes": data.get("notes", ""),
226
+ }
227
+
228
+ runs = _get("runs")
229
+ runs.append(run)
230
+ _set("runs", runs)
231
+
232
+ # Touch experiment updated timestamp
233
+ for exp in experiments:
234
+ if exp["id"] == exp_id:
235
+ exp["updated"] = _now()
236
+ _set("experiments", experiments)
237
+
238
+ return jsonify(run), 201
239
+
240
+
241
+ @bp.route("/<exp_id>/runs/<run_id>", methods=["PUT"])
242
+ def update_run(exp_id, run_id):
243
+ data = request.get_json()
244
+ runs = _get("runs")
245
+
246
+ for run in runs:
247
+ if run["id"] == run_id and run["experiment_id"] == exp_id:
248
+ for key in ["condition", "model", "cluster", "status",
249
+ "hf_dataset", "metrics", "notes"]:
250
+ if key in data:
251
+ run[key] = data[key]
252
+ _set("runs", runs)
253
+ return jsonify(run)
254
+
255
+ return jsonify({"error": "not found"}), 404
256
+
257
+
258
+ @bp.route("/<exp_id>/runs/<run_id>", methods=["DELETE"])
259
+ def delete_run(exp_id, run_id):
260
+ runs = _get("runs")
261
+ runs = [r for r in runs if not (r["id"] == run_id and r["experiment_id"] == exp_id)]
262
+ _set("runs", runs)
263
+ return jsonify({"status": "ok"})
264
+
265
+
266
+ # --- Sub-experiments ---
267
+
268
+ @bp.route("/<exp_id>/subs", methods=["POST"])
269
+ def create_sub(exp_id):
270
+ experiments = _get("experiments")
271
+ if not any(e["id"] == exp_id for e in experiments):
272
+ return jsonify({"error": "experiment not found"}), 404
273
+
274
+ data = request.get_json()
275
+ name = data.get("name", "").strip()
276
+ if not name:
277
+ return jsonify({"error": "name is required"}), 400
278
+
279
+ sub_id = data.get("id", f"{exp_id}__{name.lower().replace(' ', '_')}")
280
+
281
+ sub = {
282
+ "id": sub_id,
283
+ "experiment_id": exp_id,
284
+ "name": name,
285
+ "hypothesis": data.get("hypothesis", ""),
286
+ "status": data.get("status", "active"),
287
+ "content_md": data.get("content_md", ""),
288
+ "hf_repos": data.get("hf_repos", []),
289
+ "created": _now(),
290
+ "updated": _now(),
291
+ }
292
+
293
+ subs = _get("sub_experiments")
294
+ subs.append(sub)
295
+ _set("sub_experiments", subs)
296
+
297
+ # Touch experiment updated timestamp
298
+ for exp in experiments:
299
+ if exp["id"] == exp_id:
300
+ exp["updated"] = _now()
301
+ _set("experiments", experiments)
302
+
303
+ return jsonify(sub), 201
304
+
305
+
306
+ @bp.route("/<exp_id>/subs/<sub_id>", methods=["PUT"])
307
+ def update_sub(exp_id, sub_id):
308
+ data = request.get_json()
309
+ subs = _get("sub_experiments")
310
+
311
+ for sub in subs:
312
+ if sub["id"] == sub_id and sub["experiment_id"] == exp_id:
313
+ for key in ["name", "hypothesis", "status", "content_md", "hf_repos"]:
314
+ if key in data:
315
+ sub[key] = data[key]
316
+ sub["updated"] = _now()
317
+ _set("sub_experiments", subs)
318
+ return jsonify(sub)
319
+
320
+ return jsonify({"error": "not found"}), 404
321
+
322
+
323
+ @bp.route("/<exp_id>/subs/<sub_id>", methods=["DELETE"])
324
+ def delete_sub(exp_id, sub_id):
325
+ subs = _get("sub_experiments")
326
+ subs = [s for s in subs if not (s["id"] == sub_id and s["experiment_id"] == exp_id)]
327
+ _set("sub_experiments", subs)
328
+ return jsonify({"status": "ok"})
329
+
330
+
331
+ # --- Sync & Import ---
332
+
333
+ @bp.route("/sync", methods=["POST"])
334
+ def sync():
335
+ with _lock:
336
+ _cache.clear()
337
+ _cache_loaded.clear()
338
+ for name in FILES:
339
+ _get(name)
340
+ return jsonify({"status": "ok"})
341
+
342
+
343
+ @bp.route("/import", methods=["POST"])
344
+ def import_experiments():
345
+ """Bulk import from experiment.yaml format (as produced by exp-runner)."""
346
+ data = request.get_json()
347
+ items = data if isinstance(data, list) else [data]
348
+ imported = []
349
+
350
+ experiments = _get("experiments")
351
+ runs = _get("runs")
352
+ subs = _get("sub_experiments")
353
+ existing_ids = {e["id"] for e in experiments}
354
+
355
+ for item in items:
356
+ exp_id = item.get("name", "").lower().replace(" ", "_").replace("-", "_")
357
+ if not exp_id:
358
+ continue
359
+
360
+ hypothesis = item.get("hypothesis", {})
361
+ models = item.get("models", [])
362
+ model_names = [m.get("id", "") if isinstance(m, dict) else str(m) for m in models]
363
+
364
+ if exp_id not in existing_ids:
365
+ experiment = {
366
+ "id": exp_id,
367
+ "name": item.get("name", exp_id),
368
+ "research_project": item.get("research_project", ""),
369
+ "hypothesis": {
370
+ "statement": hypothesis.get("statement", "") if isinstance(hypothesis, dict) else str(hypothesis),
371
+ "type": hypothesis.get("type", "exploration") if isinstance(hypothesis, dict) else "exploration",
372
+ "status": hypothesis.get("status", "pending") if isinstance(hypothesis, dict) else "pending",
373
+ "success_criteria": hypothesis.get("success_criteria", "") if isinstance(hypothesis, dict) else "",
374
+ },
375
+ "stage": "active",
376
+ "completeness": 0,
377
+ "models": model_names,
378
+ "tasks": [],
379
+ "tags": item.get("observability", {}).get("tags", []) if isinstance(item.get("observability"), dict) else [],
380
+ "hf_repos": [],
381
+ "wandb_url": "",
382
+ "notes": "",
383
+ "created": item.get("created", _now()),
384
+ "updated": _now(),
385
+ }
386
+ experiments.append(experiment)
387
+ existing_ids.add(exp_id)
388
+
389
+ # Import runs
390
+ for run_data in item.get("runs", []):
391
+ run_id = run_data.get("run_id", f"run_{uuid.uuid4().hex[:8]}")
392
+ if any(r["id"] == run_id and r["experiment_id"] == exp_id for r in runs):
393
+ continue
394
+ run = {
395
+ "id": run_id,
396
+ "experiment_id": exp_id,
397
+ "condition": run_data.get("condition", ""),
398
+ "model": run_data.get("model", ""),
399
+ "cluster": run_data.get("cluster", ""),
400
+ "status": run_data.get("status", "completed"),
401
+ "hf_dataset": run_data.get("hf_dataset", ""),
402
+ "metrics": run_data.get("metrics", {}),
403
+ "timestamp": run_data.get("timestamp", _now()),
404
+ "notes": run_data.get("notes", ""),
405
+ }
406
+ runs.append(run)
407
+
408
+ # Add HF repo to experiment if present
409
+ if run.get("hf_dataset"):
410
+ for exp in experiments:
411
+ if exp["id"] == exp_id:
412
+ existing_repos = {r["repo"] for r in exp.get("hf_repos", [])}
413
+ if run["hf_dataset"] not in existing_repos:
414
+ exp.setdefault("hf_repos", []).append({
415
+ "repo": run["hf_dataset"],
416
+ "description": f"{run['condition']} - {run['model']}",
417
+ "date": run["timestamp"][:10] if run["timestamp"] else "",
418
+ })
419
+
420
+ imported.append(exp_id)
421
+
422
+ _set("experiments", experiments)
423
+ _set("runs", runs)
424
+ _set("sub_experiments", subs)
425
+
426
+ return jsonify({"imported": imported, "count": len(imported)})
backend/app.py CHANGED
@@ -6,7 +6,7 @@ def create_app():
6
  app = Flask(__name__, static_folder="../frontend/dist", static_url_path="/")
7
  CORS(app)
8
 
9
- from backend.api import model_datasets, arena_datasets, rlm_datasets, rlm_eval_datasets, harbor_datasets, adaevolve_datasets, presets
10
  app.register_blueprint(model_datasets.bp)
11
  app.register_blueprint(arena_datasets.bp)
12
  app.register_blueprint(rlm_datasets.bp)
@@ -14,6 +14,7 @@ def create_app():
14
  app.register_blueprint(harbor_datasets.bp)
15
  app.register_blueprint(adaevolve_datasets.bp)
16
  app.register_blueprint(presets.bp)
 
17
 
18
  @app.route("/api/health")
19
  def health():
 
6
  app = Flask(__name__, static_folder="../frontend/dist", static_url_path="/")
7
  CORS(app)
8
 
9
+ from backend.api import model_datasets, arena_datasets, rlm_datasets, rlm_eval_datasets, harbor_datasets, adaevolve_datasets, presets, experiments
10
  app.register_blueprint(model_datasets.bp)
11
  app.register_blueprint(arena_datasets.bp)
12
  app.register_blueprint(rlm_datasets.bp)
 
14
  app.register_blueprint(harbor_datasets.bp)
15
  app.register_blueprint(adaevolve_datasets.bp)
16
  app.register_blueprint(presets.bp)
17
+ app.register_blueprint(experiments.bp)
18
 
19
  @app.route("/api/health")
20
  def health():
docs/plans/2026-03-07-research-dashboard-design.md ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Research Dashboard Design
2
+
3
+ **Date:** 2026-03-07
4
+ **Status:** Approved
5
+
6
+ ## Overview
7
+
8
+ Extend the existing agg_visualizer into a parent "Research Dashboard" website with a top-level navigation bar. The current visualizer becomes one page; a new Experiments page provides a control pane for tracking experiments, hypotheses, runs, and artifacts.
9
+
10
+ Deployed on HuggingFace Spaces (same Space as the current visualizer).
11
+
12
+ ## Audience
13
+
14
+ Primarily the researcher + advisor. May expand to a small team later.
15
+
16
+ ## Architecture
17
+
18
+ ### Navigation
19
+ - Top-level nav bar: `Experiments | Visualizer` (future: Research Map, Knowledge Base)
20
+ - State-driven view switching (useState), not URL routing (HF Spaces doesn't support deep-linking)
21
+ - Current visualizer tabs (Model Trace, Arena, RLM, etc.) nest inside the Visualizer page unchanged
22
+
23
+ ### Data Storage
24
+ - JSON files in HF dataset repo `reasoning-degeneration-dev/RESEARCH_DASHBOARD`
25
+ - Three files: `experiments.json`, `runs.json`, `sub_experiments.json`
26
+ - In-memory cache with async HF upload (same pattern as presets.py)
27
+ - Local JSON fallback in `backend/data/`
28
+
29
+ ### Backend API
30
+ Blueprint at `/api/experiments/`:
31
+
32
+ | Method | Path | Purpose |
33
+ |--------|------|---------|
34
+ | GET | `/` | List all experiments |
35
+ | POST | `/` | Create experiment |
36
+ | GET | `/:id` | Full detail (includes runs + subs) |
37
+ | PUT | `/:id` | Update experiment |
38
+ | DELETE | `/:id` | Delete experiment |
39
+ | POST | `/:id/runs` | Add run record |
40
+ | PUT | `/:id/runs/:run_id` | Update run |
41
+ | DELETE | `/:id/runs/:run_id` | Delete run |
42
+ | POST | `/:id/subs` | Add sub-experiment |
43
+ | PUT | `/:id/subs/:sub_id` | Update sub-experiment |
44
+ | DELETE | `/:id/subs/:sub_id` | Delete sub-experiment |
45
+ | POST | `/sync` | Force re-download from HF |
46
+ | POST | `/import` | Bulk import (experiment.yaml format) |
47
+
48
+ ### Data Model
49
+
50
+ **Experiment:**
51
+ - id, name, research_project, hypothesis (statement, type, status, success_criteria)
52
+ - stage, completeness (0-5), models[], tasks[], tags[]
53
+ - hf_repos[] (repo, description, date), wandb_url, notes (markdown)
54
+ - created, updated timestamps
55
+
56
+ **Run Record:**
57
+ - id, experiment_id, condition, model, cluster, status
58
+ - hf_dataset, metrics (dict), timestamp, notes
59
+
60
+ **Sub-experiment:**
61
+ - id, experiment_id, name, hypothesis, status
62
+ - content_md (full markdown report), hf_repos[]
63
+ - created, updated timestamps
64
+
65
+ ### Frontend
66
+
67
+ Three drill-down levels:
68
+
69
+ 1. **Experiment List** — Cards with name, hypothesis, status badge, completeness, tags, last updated. Sort/filter controls.
70
+ 2. **Experiment Detail** — Hypothesis header, tabbed views (Overview, Runs, Datasets, Sub-experiments). Inline editing.
71
+ 3. **Sub-experiment View** — Breadcrumb, header, markdown-rendered body, HF repos, edit toggle.
72
+
73
+ ### Integration Points
74
+ - exp-runner v2 pushes data via `/api/experiments/import`
75
+ - Flexible ingestion — API accepts data from any source
76
+ - No local filesystem dependency at runtime
77
+
78
+ ## Future Pages (Phase 2+)
79
+ - **Research Map** — Graph/board view of research directions and experiment relationships
80
+ - **Knowledge Base** — Searchable wiki of findings, notes, HF repos
81
+
82
+ ## Tech Stack
83
+ - Backend: Flask (existing)
84
+ - Frontend: React + Vite + Tailwind + Zustand (existing)
85
+ - Deployment: Docker on HuggingFace Spaces (existing)
86
+ - Storage: HF dataset repo as JSON store
frontend/src/App.tsx CHANGED
@@ -1,47 +1,44 @@
1
  import { useState, lazy, Suspense } from "react";
2
 
3
- const ModelApp = lazy(() => import("./model/ModelApp"));
4
- const ArenaApp = lazy(() => import("./arena/ArenaApp"));
5
- const RlmEvalApp = lazy(() => import("./rlm-eval/RlmEvalApp"));
6
- const RlmApp = lazy(() => import("./rlm/RlmApp"));
7
- const HarborApp = lazy(() => import("./harbor/HarborApp"));
8
- const AdaevolveApp = lazy(() => import("./adaevolve/AdaevolveApp"));
9
 
10
- type TabId = "model" | "arena" | "rlm-eval" | "rlm" | "harbor" | "adaevolve";
11
 
12
- const TABS: { id: TabId; label: string; color: string; activeClass: string }[] = [
13
- { id: "model", label: "Model Trace", color: "blue", activeClass: "border-blue-500 text-blue-400" },
14
- { id: "arena", label: "Arena", color: "purple", activeClass: "border-purple-500 text-purple-400" },
15
- { id: "rlm-eval", label: "RLM", color: "emerald", activeClass: "border-emerald-500 text-emerald-400" },
16
- { id: "rlm", label: "RLM+GEPA", color: "orange", activeClass: "border-orange-500 text-orange-400" },
17
- { id: "harbor", label: "Harbor", color: "teal", activeClass: "border-teal-500 text-teal-400" },
18
- { id: "adaevolve", label: "AdaEvolve", color: "rose", activeClass: "border-rose-500 text-rose-400" },
19
  ];
20
 
21
  export default function App() {
22
- const [activeTab, setActiveTab] = useState<TabId>("model");
23
 
24
  return (
25
  <div className="h-screen flex flex-col bg-gray-950 text-gray-100">
26
- {/* Tab bar */}
27
- <div className="flex items-center border-b border-gray-800 bg-gray-900 px-2 shrink-0">
28
- {TABS.map((tab) => (
 
 
 
29
  <button
30
- key={tab.id}
31
- onClick={() => setActiveTab(tab.id)}
32
- className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${
33
- activeTab === tab.id
34
- ? tab.activeClass
35
  : "border-transparent text-gray-500 hover:text-gray-300"
36
  }`}
37
  >
38
- {tab.label}
39
  </button>
40
  ))}
41
- <div className="ml-auto text-xs text-gray-600 px-3">Aggregate Trace Visualizer</div>
 
 
42
  </div>
43
 
44
- {/* Active visualizer */}
45
  <div className="flex-1 overflow-hidden">
46
  <Suspense
47
  fallback={
@@ -50,36 +47,8 @@ export default function App() {
50
  </div>
51
  }
52
  >
53
- {activeTab === "model" && (
54
- <div className="theme-model h-full">
55
- <ModelApp />
56
- </div>
57
- )}
58
- {activeTab === "arena" && (
59
- <div className="theme-arena h-full">
60
- <ArenaApp />
61
- </div>
62
- )}
63
- {activeTab === "rlm-eval" && (
64
- <div className="theme-rlm-eval h-full">
65
- <RlmEvalApp />
66
- </div>
67
- )}
68
- {activeTab === "rlm" && (
69
- <div className="theme-rlm h-full">
70
- <RlmApp />
71
- </div>
72
- )}
73
- {activeTab === "harbor" && (
74
- <div className="theme-harbor h-full">
75
- <HarborApp />
76
- </div>
77
- )}
78
- {activeTab === "adaevolve" && (
79
- <div className="theme-adaevolve h-full">
80
- <AdaevolveApp />
81
- </div>
82
- )}
83
  </Suspense>
84
  </div>
85
  </div>
 
1
  import { useState, lazy, Suspense } from "react";
2
 
3
+ const VisualizerApp = lazy(() => import("./visualizer/VisualizerApp"));
4
+ const ExperimentsApp = lazy(() => import("./experiments/ExperimentsApp"));
 
 
 
 
5
 
6
+ type PageId = "experiments" | "visualizer";
7
 
8
+ const PAGES: { id: PageId; label: string }[] = [
9
+ { id: "experiments", label: "Experiments" },
10
+ { id: "visualizer", label: "Visualizer" },
 
 
 
 
11
  ];
12
 
13
  export default function App() {
14
+ const [activePage, setActivePage] = useState<PageId>("experiments");
15
 
16
  return (
17
  <div className="h-screen flex flex-col bg-gray-950 text-gray-100">
18
+ {/* Top navigation bar */}
19
+ <div className="flex items-center border-b border-gray-700 bg-gray-900 px-4 shrink-0">
20
+ <span className="text-sm font-semibold text-gray-300 mr-6 py-2.5">
21
+ Research Dashboard
22
+ </span>
23
+ {PAGES.map((page) => (
24
  <button
25
+ key={page.id}
26
+ onClick={() => setActivePage(page.id)}
27
+ className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
28
+ activePage === page.id
29
+ ? "border-cyan-500 text-cyan-400"
30
  : "border-transparent text-gray-500 hover:text-gray-300"
31
  }`}
32
  >
33
+ {page.label}
34
  </button>
35
  ))}
36
+ <div className="ml-auto text-xs text-gray-600 px-3">
37
+ reasoning-degeneration-dev
38
+ </div>
39
  </div>
40
 
41
+ {/* Active page */}
42
  <div className="flex-1 overflow-hidden">
43
  <Suspense
44
  fallback={
 
47
  </div>
48
  }
49
  >
50
+ {activePage === "experiments" && <ExperimentsApp />}
51
+ {activePage === "visualizer" && <VisualizerApp />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  </Suspense>
53
  </div>
54
  </div>
frontend/src/experiments/ExperimentsApp.tsx ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useExperimentsState } from "./store";
2
+ import ExperimentList from "./components/ExperimentList";
3
+ import ExperimentDetail from "./components/ExperimentDetail";
4
+ import SubExperimentView from "./components/SubExperimentView";
5
+
6
+ export default function ExperimentsApp() {
7
+ const state = useExperimentsState();
8
+
9
+ if (state.loading && state.experiments.length === 0) {
10
+ return (
11
+ <div className="flex items-center justify-center h-full text-gray-500">
12
+ Loading experiments...
13
+ </div>
14
+ );
15
+ }
16
+
17
+ if (state.error && state.experiments.length === 0) {
18
+ return (
19
+ <div className="flex flex-col items-center justify-center h-full text-gray-500">
20
+ <p className="text-red-400 mb-2">{state.error}</p>
21
+ <button
22
+ onClick={state.loadExperiments}
23
+ className="text-cyan-400 hover:text-cyan-300 text-sm"
24
+ >
25
+ Retry
26
+ </button>
27
+ </div>
28
+ );
29
+ }
30
+
31
+ if (state.view.kind === "sub" && state.currentSub && state.currentDetail) {
32
+ return (
33
+ <SubExperimentView
34
+ sub={state.currentSub}
35
+ experimentName={state.currentDetail.name}
36
+ onBack={() => state.navigateToDetail(state.view.kind === "sub" ? state.view.expId : "")}
37
+ onRefresh={state.refreshDetail}
38
+ />
39
+ );
40
+ }
41
+
42
+ if (state.view.kind === "detail" && state.currentDetail) {
43
+ return (
44
+ <ExperimentDetail
45
+ experiment={state.currentDetail}
46
+ onBack={state.navigateToList}
47
+ onSelectSub={(subId) => state.navigateToSub(state.view.kind === "detail" ? state.view.expId : "", subId)}
48
+ onRefresh={state.refreshDetail}
49
+ />
50
+ );
51
+ }
52
+
53
+ return (
54
+ <ExperimentList
55
+ experiments={state.experiments}
56
+ onSelect={state.navigateToDetail}
57
+ onRefresh={state.loadExperiments}
58
+ />
59
+ );
60
+ }
frontend/src/experiments/api.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Experiment, ExperimentDetail, RunRecord, SubExperiment } from "./types";
2
+
3
+ const BASE = "/api/experiments";
4
+
5
+ async function fetchJSON<T>(url: string, opts?: RequestInit): Promise<T> {
6
+ const res = await fetch(url, {
7
+ headers: { "Content-Type": "application/json" },
8
+ ...opts,
9
+ });
10
+ if (!res.ok) {
11
+ const err = await res.json().catch(() => ({ error: res.statusText }));
12
+ throw new Error(err.error || res.statusText);
13
+ }
14
+ return res.json();
15
+ }
16
+
17
+ export const experimentsApi = {
18
+ list() {
19
+ return fetchJSON<Experiment[]>(`${BASE}/`);
20
+ },
21
+
22
+ get(id: string) {
23
+ return fetchJSON<ExperimentDetail>(`${BASE}/${id}`);
24
+ },
25
+
26
+ create(data: Partial<Experiment>) {
27
+ return fetchJSON<Experiment>(`${BASE}/`, {
28
+ method: "POST",
29
+ body: JSON.stringify(data),
30
+ });
31
+ },
32
+
33
+ update(id: string, data: Partial<Experiment>) {
34
+ return fetchJSON<Experiment>(`${BASE}/${id}`, {
35
+ method: "PUT",
36
+ body: JSON.stringify(data),
37
+ });
38
+ },
39
+
40
+ delete(id: string) {
41
+ return fetchJSON<{ status: string }>(`${BASE}/${id}`, { method: "DELETE" });
42
+ },
43
+
44
+ createRun(expId: string, data: Partial<RunRecord>) {
45
+ return fetchJSON<RunRecord>(`${BASE}/${expId}/runs`, {
46
+ method: "POST",
47
+ body: JSON.stringify(data),
48
+ });
49
+ },
50
+
51
+ updateRun(expId: string, runId: string, data: Partial<RunRecord>) {
52
+ return fetchJSON<RunRecord>(`${BASE}/${expId}/runs/${runId}`, {
53
+ method: "PUT",
54
+ body: JSON.stringify(data),
55
+ });
56
+ },
57
+
58
+ deleteRun(expId: string, runId: string) {
59
+ return fetchJSON<{ status: string }>(`${BASE}/${expId}/runs/${runId}`, { method: "DELETE" });
60
+ },
61
+
62
+ createSub(expId: string, data: Partial<SubExperiment>) {
63
+ return fetchJSON<SubExperiment>(`${BASE}/${expId}/subs`, {
64
+ method: "POST",
65
+ body: JSON.stringify(data),
66
+ });
67
+ },
68
+
69
+ updateSub(expId: string, subId: string, data: Partial<SubExperiment>) {
70
+ return fetchJSON<SubExperiment>(`${BASE}/${expId}/subs/${subId}`, {
71
+ method: "PUT",
72
+ body: JSON.stringify(data),
73
+ });
74
+ },
75
+
76
+ deleteSub(expId: string, subId: string) {
77
+ return fetchJSON<{ status: string }>(`${BASE}/${expId}/subs/${subId}`, { method: "DELETE" });
78
+ },
79
+
80
+ sync() {
81
+ return fetchJSON<{ status: string }>(`${BASE}/sync`, { method: "POST" });
82
+ },
83
+ };
frontend/src/experiments/components/ExperimentDetail.tsx ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import type { ExperimentDetail as ExperimentDetailType, RunRecord, HfRepo } from "../types";
3
+ import { experimentsApi } from "../api";
4
+
5
+ const STATUS_COLORS: Record<string, string> = {
6
+ pending: "bg-gray-600",
7
+ active: "bg-yellow-600",
8
+ exploring: "bg-blue-600",
9
+ supported: "bg-green-600",
10
+ invalidated: "bg-red-600",
11
+ inconclusive: "bg-orange-600",
12
+ };
13
+
14
+ const RUN_STATUS_COLORS: Record<string, string> = {
15
+ running: "text-yellow-400",
16
+ completed: "text-green-400",
17
+ failed: "text-red-400",
18
+ };
19
+
20
+ type Tab = "overview" | "runs" | "datasets" | "subs";
21
+
22
+ interface Props {
23
+ experiment: ExperimentDetailType;
24
+ onBack: () => void;
25
+ onSelectSub: (subId: string) => void;
26
+ onRefresh: () => void;
27
+ }
28
+
29
+ export default function ExperimentDetail({ experiment, onBack, onSelectSub, onRefresh }: Props) {
30
+ const [tab, setTab] = useState<Tab>("overview");
31
+ const [editing, setEditing] = useState(false);
32
+ const [notes, setNotes] = useState(experiment.notes || "");
33
+ const [hypothesisStatus, setHypothesisStatus] = useState<string>(experiment.hypothesis?.status || "pending");
34
+ const [saving, setSaving] = useState(false);
35
+
36
+ // Add run form
37
+ const [showAddRun, setShowAddRun] = useState(false);
38
+ const [runForm, setRunForm] = useState({ condition: "", model: "", cluster: "", hf_dataset: "", notes: "" });
39
+
40
+ // Add dataset form
41
+ const [showAddDataset, setShowAddDataset] = useState(false);
42
+ const [datasetForm, setDatasetForm] = useState({ repo: "", description: "" });
43
+
44
+ // Add sub form
45
+ const [showAddSub, setShowAddSub] = useState(false);
46
+ const [subForm, setSubForm] = useState({ name: "", hypothesis: "" });
47
+
48
+ const handleSave = async () => {
49
+ setSaving(true);
50
+ try {
51
+ await experimentsApi.update(experiment.id, {
52
+ notes,
53
+ hypothesis: { ...experiment.hypothesis, status: hypothesisStatus as any },
54
+ });
55
+ setEditing(false);
56
+ onRefresh();
57
+ } finally {
58
+ setSaving(false);
59
+ }
60
+ };
61
+
62
+ const handleAddRun = async () => {
63
+ if (!runForm.condition) return;
64
+ await experimentsApi.createRun(experiment.id, runForm);
65
+ setRunForm({ condition: "", model: "", cluster: "", hf_dataset: "", notes: "" });
66
+ setShowAddRun(false);
67
+ onRefresh();
68
+ };
69
+
70
+ const handleAddDataset = async () => {
71
+ if (!datasetForm.repo) return;
72
+ const hf_repos = [...(experiment.hf_repos || []), { ...datasetForm, date: new Date().toISOString().slice(0, 10) }];
73
+ await experimentsApi.update(experiment.id, { hf_repos } as any);
74
+ setDatasetForm({ repo: "", description: "" });
75
+ setShowAddDataset(false);
76
+ onRefresh();
77
+ };
78
+
79
+ const handleAddSub = async () => {
80
+ if (!subForm.name) return;
81
+ await experimentsApi.createSub(experiment.id, subForm);
82
+ setSubForm({ name: "", hypothesis: "" });
83
+ setShowAddSub(false);
84
+ onRefresh();
85
+ };
86
+
87
+ const handleDeleteRun = async (runId: string) => {
88
+ await experimentsApi.deleteRun(experiment.id, runId);
89
+ onRefresh();
90
+ };
91
+
92
+ const TABS: { id: Tab; label: string; count?: number }[] = [
93
+ { id: "overview", label: "Overview" },
94
+ { id: "runs", label: "Runs", count: experiment.runs?.length || 0 },
95
+ { id: "datasets", label: "Datasets", count: experiment.hf_repos?.length || 0 },
96
+ { id: "subs", label: "Sub-experiments", count: experiment.sub_experiments?.length || 0 },
97
+ ];
98
+
99
+ return (
100
+ <div className="h-full flex flex-col">
101
+ {/* Header */}
102
+ <div className="px-6 py-4 border-b border-gray-800">
103
+ <div className="flex items-center gap-2 mb-3">
104
+ <button
105
+ onClick={onBack}
106
+ className="text-gray-400 hover:text-gray-200 text-sm transition-colors"
107
+ >
108
+ &larr; Experiments
109
+ </button>
110
+ </div>
111
+ <div className="flex items-start justify-between">
112
+ <div>
113
+ <h1 className="text-lg font-semibold text-gray-200">{experiment.name}</h1>
114
+ {experiment.hypothesis?.statement && (
115
+ <p className="text-sm text-gray-400 mt-1 max-w-2xl">
116
+ {experiment.hypothesis.statement}
117
+ </p>
118
+ )}
119
+ <div className="flex items-center gap-3 mt-2">
120
+ {editing ? (
121
+ <select
122
+ value={hypothesisStatus}
123
+ onChange={(e) => setHypothesisStatus(e.target.value)}
124
+ className="bg-gray-800 text-gray-300 text-xs rounded px-2 py-1 border border-gray-700"
125
+ >
126
+ <option value="pending">pending</option>
127
+ <option value="active">active</option>
128
+ <option value="exploring">exploring</option>
129
+ <option value="supported">supported</option>
130
+ <option value="invalidated">invalidated</option>
131
+ <option value="inconclusive">inconclusive</option>
132
+ </select>
133
+ ) : (
134
+ <span className={`text-xs px-2 py-0.5 rounded-full text-white ${STATUS_COLORS[experiment.hypothesis?.status] || STATUS_COLORS.pending}`}>
135
+ {experiment.hypothesis?.status || "pending"}
136
+ </span>
137
+ )}
138
+ {experiment.hypothesis?.type && (
139
+ <span className="text-xs text-gray-500">{experiment.hypothesis.type}</span>
140
+ )}
141
+ {experiment.hypothesis?.success_criteria && (
142
+ <span className="text-xs text-gray-500 italic">
143
+ Goal: {experiment.hypothesis.success_criteria}
144
+ </span>
145
+ )}
146
+ </div>
147
+ </div>
148
+ <div className="flex gap-2">
149
+ {editing ? (
150
+ <>
151
+ <button
152
+ onClick={() => setEditing(false)}
153
+ className="text-gray-400 hover:text-gray-200 text-sm px-3 py-1.5 rounded transition-colors"
154
+ >
155
+ Cancel
156
+ </button>
157
+ <button
158
+ onClick={handleSave}
159
+ disabled={saving}
160
+ className="bg-cyan-600 hover:bg-cyan-500 text-white text-sm font-medium px-3 py-1.5 rounded transition-colors"
161
+ >
162
+ {saving ? "Saving..." : "Save"}
163
+ </button>
164
+ </>
165
+ ) : (
166
+ <button
167
+ onClick={() => setEditing(true)}
168
+ className="text-gray-400 hover:text-gray-200 text-sm px-3 py-1.5 rounded border border-gray-700 transition-colors"
169
+ >
170
+ Edit
171
+ </button>
172
+ )}
173
+ </div>
174
+ </div>
175
+
176
+ {/* Detail tabs */}
177
+ <div className="flex gap-1 mt-4">
178
+ {TABS.map((t) => (
179
+ <button
180
+ key={t.id}
181
+ onClick={() => setTab(t.id)}
182
+ className={`px-3 py-1.5 text-sm rounded-t transition-colors ${
183
+ tab === t.id
184
+ ? "bg-gray-800 text-gray-200 border border-gray-700 border-b-gray-800"
185
+ : "text-gray-500 hover:text-gray-300"
186
+ }`}
187
+ >
188
+ {t.label}
189
+ {t.count !== undefined && (
190
+ <span className="ml-1 text-xs text-gray-500">({t.count})</span>
191
+ )}
192
+ </button>
193
+ ))}
194
+ </div>
195
+ </div>
196
+
197
+ {/* Tab content */}
198
+ <div className="flex-1 overflow-y-auto p-6">
199
+ {tab === "overview" && (
200
+ <div className="max-w-3xl space-y-4">
201
+ {/* Meta info */}
202
+ <div className="flex gap-6 text-sm text-gray-400">
203
+ {experiment.research_project && (
204
+ <span>Project: <span className="text-gray-300">{experiment.research_project}</span></span>
205
+ )}
206
+ {experiment.wandb_url && (
207
+ <a href={experiment.wandb_url} target="_blank" rel="noopener noreferrer" className="text-cyan-400 hover:text-cyan-300">
208
+ W&B Dashboard
209
+ </a>
210
+ )}
211
+ <span>Stage: <span className="text-gray-300">{experiment.stage}</span></span>
212
+ </div>
213
+
214
+ {/* Models & Tasks */}
215
+ {((experiment.models || []).length > 0 || (experiment.tasks || []).length > 0) && (
216
+ <div className="flex gap-6">
217
+ {(experiment.models || []).length > 0 && (
218
+ <div>
219
+ <span className="text-xs text-gray-500 uppercase tracking-wide">Models</span>
220
+ <div className="flex flex-wrap gap-1 mt-1">
221
+ {experiment.models.map((m) => (
222
+ <span key={m} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded">{m}</span>
223
+ ))}
224
+ </div>
225
+ </div>
226
+ )}
227
+ {(experiment.tasks || []).length > 0 && (
228
+ <div>
229
+ <span className="text-xs text-gray-500 uppercase tracking-wide">Tasks</span>
230
+ <div className="flex flex-wrap gap-1 mt-1">
231
+ {experiment.tasks.map((t) => (
232
+ <span key={t} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded">{t}</span>
233
+ ))}
234
+ </div>
235
+ </div>
236
+ )}
237
+ </div>
238
+ )}
239
+
240
+ {/* Tags */}
241
+ {(experiment.tags || []).length > 0 && (
242
+ <div>
243
+ <span className="text-xs text-gray-500 uppercase tracking-wide">Tags</span>
244
+ <div className="flex flex-wrap gap-1 mt-1">
245
+ {experiment.tags.map((tag) => (
246
+ <span key={tag} className="text-xs bg-cyan-900/30 text-cyan-400 px-2 py-0.5 rounded">{tag}</span>
247
+ ))}
248
+ </div>
249
+ </div>
250
+ )}
251
+
252
+ {/* Notes */}
253
+ <div>
254
+ <span className="text-xs text-gray-500 uppercase tracking-wide">Notes / Key Findings</span>
255
+ {editing ? (
256
+ <textarea
257
+ value={notes}
258
+ onChange={(e) => setNotes(e.target.value)}
259
+ className="w-full mt-2 bg-gray-900 text-gray-200 text-sm rounded px-3 py-2 border border-gray-700 focus:border-cyan-500 outline-none resize-y min-h-[200px] font-mono"
260
+ rows={10}
261
+ />
262
+ ) : (
263
+ <div className="mt-2 text-sm text-gray-300 whitespace-pre-wrap bg-gray-900 rounded p-4 min-h-[100px]">
264
+ {experiment.notes || <span className="text-gray-600 italic">No notes yet. Click Edit to add findings.</span>}
265
+ </div>
266
+ )}
267
+ </div>
268
+ </div>
269
+ )}
270
+
271
+ {tab === "runs" && (
272
+ <div>
273
+ <div className="flex justify-between items-center mb-4">
274
+ <h2 className="text-sm font-medium text-gray-300">Run History</h2>
275
+ <button
276
+ onClick={() => setShowAddRun(true)}
277
+ className="bg-cyan-600 hover:bg-cyan-500 text-white text-xs font-medium px-2.5 py-1 rounded transition-colors"
278
+ >
279
+ + Add Run
280
+ </button>
281
+ </div>
282
+
283
+ {showAddRun && (
284
+ <div className="mb-4 p-3 bg-gray-800 rounded-lg border border-gray-700">
285
+ <div className="grid grid-cols-2 gap-2">
286
+ <input placeholder="Condition" value={runForm.condition} onChange={(e) => setRunForm({ ...runForm, condition: e.target.value })}
287
+ className="bg-gray-900 text-gray-200 text-sm rounded px-2 py-1.5 border border-gray-700 outline-none" />
288
+ <input placeholder="Model" value={runForm.model} onChange={(e) => setRunForm({ ...runForm, model: e.target.value })}
289
+ className="bg-gray-900 text-gray-200 text-sm rounded px-2 py-1.5 border border-gray-700 outline-none" />
290
+ <input placeholder="Cluster" value={runForm.cluster} onChange={(e) => setRunForm({ ...runForm, cluster: e.target.value })}
291
+ className="bg-gray-900 text-gray-200 text-sm rounded px-2 py-1.5 border border-gray-700 outline-none" />
292
+ <input placeholder="HF Dataset" value={runForm.hf_dataset} onChange={(e) => setRunForm({ ...runForm, hf_dataset: e.target.value })}
293
+ className="bg-gray-900 text-gray-200 text-sm rounded px-2 py-1.5 border border-gray-700 outline-none" />
294
+ </div>
295
+ <input placeholder="Notes" value={runForm.notes} onChange={(e) => setRunForm({ ...runForm, notes: e.target.value })}
296
+ className="w-full mt-2 bg-gray-900 text-gray-200 text-sm rounded px-2 py-1.5 border border-gray-700 outline-none" />
297
+ <div className="flex gap-2 justify-end mt-2">
298
+ <button onClick={() => setShowAddRun(false)} className="text-gray-400 text-xs px-2 py-1">Cancel</button>
299
+ <button onClick={handleAddRun} className="bg-cyan-600 text-white text-xs px-2.5 py-1 rounded">Add</button>
300
+ </div>
301
+ </div>
302
+ )}
303
+
304
+ {(experiment.runs || []).length === 0 ? (
305
+ <p className="text-sm text-gray-500">No runs recorded yet.</p>
306
+ ) : (
307
+ <div className="overflow-x-auto">
308
+ <table className="w-full text-sm">
309
+ <thead>
310
+ <tr className="text-xs text-gray-500 uppercase tracking-wide border-b border-gray-800">
311
+ <th className="text-left py-2 px-2">Condition</th>
312
+ <th className="text-left py-2 px-2">Model</th>
313
+ <th className="text-left py-2 px-2">Cluster</th>
314
+ <th className="text-left py-2 px-2">Status</th>
315
+ <th className="text-left py-2 px-2">Metrics</th>
316
+ <th className="text-left py-2 px-2">HF Dataset</th>
317
+ <th className="text-left py-2 px-2">Date</th>
318
+ <th className="text-left py-2 px-2"></th>
319
+ </tr>
320
+ </thead>
321
+ <tbody>
322
+ {[...experiment.runs].reverse().map((run) => (
323
+ <tr key={run.id} className="border-b border-gray-800/50 hover:bg-gray-900/50">
324
+ <td className="py-2 px-2 text-gray-300">{run.condition || "-"}</td>
325
+ <td className="py-2 px-2 text-gray-400">{run.model || "-"}</td>
326
+ <td className="py-2 px-2 text-gray-400">{run.cluster || "-"}</td>
327
+ <td className={`py-2 px-2 ${RUN_STATUS_COLORS[run.status] || "text-gray-400"}`}>{run.status}</td>
328
+ <td className="py-2 px-2 text-gray-400 font-mono text-xs">
329
+ {Object.keys(run.metrics || {}).length > 0
330
+ ? Object.entries(run.metrics).map(([k, v]) => `${k}: ${typeof v === "number" ? v.toFixed(3) : v}`).join(", ")
331
+ : "-"}
332
+ </td>
333
+ <td className="py-2 px-2">
334
+ {run.hf_dataset ? (
335
+ <a
336
+ href={`https://huggingface.co/datasets/${run.hf_dataset}`}
337
+ target="_blank"
338
+ rel="noopener noreferrer"
339
+ className="text-cyan-400 hover:text-cyan-300 text-xs"
340
+ >
341
+ {run.hf_dataset.split("/").pop()}
342
+ </a>
343
+ ) : "-"}
344
+ </td>
345
+ <td className="py-2 px-2 text-gray-500 text-xs">
346
+ {run.timestamp ? new Date(run.timestamp).toLocaleDateString() : "-"}
347
+ </td>
348
+ <td className="py-2 px-2">
349
+ <button
350
+ onClick={() => handleDeleteRun(run.id)}
351
+ className="text-gray-600 hover:text-red-400 text-xs transition-colors"
352
+ >
353
+ &times;
354
+ </button>
355
+ </td>
356
+ </tr>
357
+ ))}
358
+ </tbody>
359
+ </table>
360
+ </div>
361
+ )}
362
+ </div>
363
+ )}
364
+
365
+ {tab === "datasets" && (
366
+ <div>
367
+ <div className="flex justify-between items-center mb-4">
368
+ <h2 className="text-sm font-medium text-gray-300">HuggingFace Datasets</h2>
369
+ <button
370
+ onClick={() => setShowAddDataset(true)}
371
+ className="bg-cyan-600 hover:bg-cyan-500 text-white text-xs font-medium px-2.5 py-1 rounded transition-colors"
372
+ >
373
+ + Add Dataset
374
+ </button>
375
+ </div>
376
+
377
+ {showAddDataset && (
378
+ <div className="mb-4 p-3 bg-gray-800 rounded-lg border border-gray-700">
379
+ <input placeholder="org/dataset-name" value={datasetForm.repo} onChange={(e) => setDatasetForm({ ...datasetForm, repo: e.target.value })}
380
+ className="w-full bg-gray-900 text-gray-200 text-sm rounded px-2 py-1.5 border border-gray-700 outline-none mb-2" />
381
+ <input placeholder="Description" value={datasetForm.description} onChange={(e) => setDatasetForm({ ...datasetForm, description: e.target.value })}
382
+ className="w-full bg-gray-900 text-gray-200 text-sm rounded px-2 py-1.5 border border-gray-700 outline-none" />
383
+ <div className="flex gap-2 justify-end mt-2">
384
+ <button onClick={() => setShowAddDataset(false)} className="text-gray-400 text-xs px-2 py-1">Cancel</button>
385
+ <button onClick={handleAddDataset} className="bg-cyan-600 text-white text-xs px-2.5 py-1 rounded">Add</button>
386
+ </div>
387
+ </div>
388
+ )}
389
+
390
+ {(experiment.hf_repos || []).length === 0 ? (
391
+ <p className="text-sm text-gray-500">No datasets linked yet.</p>
392
+ ) : (
393
+ <div className="grid gap-2">
394
+ {experiment.hf_repos.map((repo, i) => (
395
+ <div key={`${repo.repo}-${i}`} className="flex items-center justify-between bg-gray-900 rounded p-3 border border-gray-800">
396
+ <div>
397
+ <a
398
+ href={`https://huggingface.co/datasets/${repo.repo}`}
399
+ target="_blank"
400
+ rel="noopener noreferrer"
401
+ className="text-cyan-400 hover:text-cyan-300 text-sm"
402
+ >
403
+ {repo.repo}
404
+ </a>
405
+ {repo.description && (
406
+ <p className="text-xs text-gray-500 mt-0.5">{repo.description}</p>
407
+ )}
408
+ </div>
409
+ <span className="text-xs text-gray-600">{repo.date || ""}</span>
410
+ </div>
411
+ ))}
412
+ </div>
413
+ )}
414
+ </div>
415
+ )}
416
+
417
+ {tab === "subs" && (
418
+ <div>
419
+ <div className="flex justify-between items-center mb-4">
420
+ <h2 className="text-sm font-medium text-gray-300">Sub-experiments</h2>
421
+ <button
422
+ onClick={() => setShowAddSub(true)}
423
+ className="bg-cyan-600 hover:bg-cyan-500 text-white text-xs font-medium px-2.5 py-1 rounded transition-colors"
424
+ >
425
+ + Add Sub-experiment
426
+ </button>
427
+ </div>
428
+
429
+ {showAddSub && (
430
+ <div className="mb-4 p-3 bg-gray-800 rounded-lg border border-gray-700">
431
+ <input placeholder="Sub-experiment name" value={subForm.name} onChange={(e) => setSubForm({ ...subForm, name: e.target.value })}
432
+ className="w-full bg-gray-900 text-gray-200 text-sm rounded px-2 py-1.5 border border-gray-700 outline-none mb-2" autoFocus />
433
+ <input placeholder="Hypothesis" value={subForm.hypothesis} onChange={(e) => setSubForm({ ...subForm, hypothesis: e.target.value })}
434
+ className="w-full bg-gray-900 text-gray-200 text-sm rounded px-2 py-1.5 border border-gray-700 outline-none" />
435
+ <div className="flex gap-2 justify-end mt-2">
436
+ <button onClick={() => setShowAddSub(false)} className="text-gray-400 text-xs px-2 py-1">Cancel</button>
437
+ <button onClick={handleAddSub} className="bg-cyan-600 text-white text-xs px-2.5 py-1 rounded">Add</button>
438
+ </div>
439
+ </div>
440
+ )}
441
+
442
+ {(experiment.sub_experiments || []).length === 0 ? (
443
+ <p className="text-sm text-gray-500">No sub-experiments yet.</p>
444
+ ) : (
445
+ <div className="grid gap-2">
446
+ {experiment.sub_experiments.map((sub) => (
447
+ <button
448
+ key={sub.id}
449
+ onClick={() => onSelectSub(sub.id)}
450
+ className="w-full text-left bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded p-3 transition-colors"
451
+ >
452
+ <div className="flex items-center justify-between">
453
+ <div>
454
+ <span className="text-sm text-gray-200">{sub.name}</span>
455
+ {sub.hypothesis && (
456
+ <p className="text-xs text-gray-500 mt-0.5">{sub.hypothesis}</p>
457
+ )}
458
+ </div>
459
+ <div className="flex items-center gap-2">
460
+ <span className={`text-xs px-2 py-0.5 rounded-full text-white ${
461
+ sub.status === "concluded" ? "bg-green-600" :
462
+ sub.status === "active" ? "bg-yellow-600" : "bg-gray-600"
463
+ }`}>
464
+ {sub.status}
465
+ </span>
466
+ <span className="text-xs text-gray-600">
467
+ {sub.updated ? new Date(sub.updated).toLocaleDateString() : ""}
468
+ </span>
469
+ </div>
470
+ </div>
471
+ </button>
472
+ ))}
473
+ </div>
474
+ )}
475
+ </div>
476
+ )}
477
+ </div>
478
+ </div>
479
+ );
480
+ }
frontend/src/experiments/components/ExperimentList.tsx ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import type { Experiment, Stage } from "../types";
3
+ import { experimentsApi } from "../api";
4
+
5
+ const STAGE_COLORS: Record<Stage, string> = {
6
+ idea: "bg-gray-600",
7
+ planned: "bg-blue-600",
8
+ active: "bg-yellow-600",
9
+ concluded: "bg-green-600",
10
+ inconclusive: "bg-orange-600",
11
+ };
12
+
13
+ const STATUS_COLORS: Record<string, string> = {
14
+ pending: "text-gray-400",
15
+ active: "text-yellow-400",
16
+ exploring: "text-blue-400",
17
+ supported: "text-green-400",
18
+ invalidated: "text-red-400",
19
+ inconclusive: "text-orange-400",
20
+ };
21
+
22
+ type SortKey = "updated" | "name" | "stage" | "completeness";
23
+
24
+ interface Props {
25
+ experiments: Experiment[];
26
+ onSelect: (id: string) => void;
27
+ onRefresh: () => void;
28
+ }
29
+
30
+ export default function ExperimentList({ experiments, onSelect, onRefresh }: Props) {
31
+ const [sortBy, setSortBy] = useState<SortKey>("updated");
32
+ const [filterStage, setFilterStage] = useState<Stage | "all">("all");
33
+ const [showCreate, setShowCreate] = useState(false);
34
+ const [newName, setNewName] = useState("");
35
+ const [newHypothesis, setNewHypothesis] = useState("");
36
+ const [creating, setCreating] = useState(false);
37
+
38
+ const filtered = experiments.filter(
39
+ (e) => filterStage === "all" || e.stage === filterStage
40
+ );
41
+
42
+ const sorted = [...filtered].sort((a, b) => {
43
+ switch (sortBy) {
44
+ case "updated":
45
+ return (b.updated || "").localeCompare(a.updated || "");
46
+ case "name":
47
+ return a.name.localeCompare(b.name);
48
+ case "stage":
49
+ return a.stage.localeCompare(b.stage);
50
+ case "completeness":
51
+ return (b.completeness || 0) - (a.completeness || 0);
52
+ default:
53
+ return 0;
54
+ }
55
+ });
56
+
57
+ const handleCreate = async () => {
58
+ if (!newName.trim()) return;
59
+ setCreating(true);
60
+ try {
61
+ await experimentsApi.create({
62
+ name: newName.trim(),
63
+ hypothesis: {
64
+ statement: newHypothesis.trim(),
65
+ type: "exploration",
66
+ status: "pending",
67
+ success_criteria: "",
68
+ },
69
+ });
70
+ setNewName("");
71
+ setNewHypothesis("");
72
+ setShowCreate(false);
73
+ onRefresh();
74
+ } catch (e) {
75
+ // TODO: show error
76
+ } finally {
77
+ setCreating(false);
78
+ }
79
+ };
80
+
81
+ return (
82
+ <div className="h-full flex flex-col">
83
+ {/* Header */}
84
+ <div className="flex items-center justify-between px-6 py-4 border-b border-gray-800">
85
+ <h1 className="text-lg font-semibold text-gray-200">Experiments</h1>
86
+ <div className="flex items-center gap-3">
87
+ {/* Filter */}
88
+ <select
89
+ value={filterStage}
90
+ onChange={(e) => setFilterStage(e.target.value as Stage | "all")}
91
+ className="bg-gray-800 text-gray-300 text-sm rounded px-2 py-1.5 border border-gray-700"
92
+ >
93
+ <option value="all">All stages</option>
94
+ <option value="idea">Idea</option>
95
+ <option value="planned">Planned</option>
96
+ <option value="active">Active</option>
97
+ <option value="concluded">Concluded</option>
98
+ <option value="inconclusive">Inconclusive</option>
99
+ </select>
100
+
101
+ {/* Sort */}
102
+ <select
103
+ value={sortBy}
104
+ onChange={(e) => setSortBy(e.target.value as SortKey)}
105
+ className="bg-gray-800 text-gray-300 text-sm rounded px-2 py-1.5 border border-gray-700"
106
+ >
107
+ <option value="updated">Last Updated</option>
108
+ <option value="name">Name</option>
109
+ <option value="stage">Stage</option>
110
+ <option value="completeness">Completeness</option>
111
+ </select>
112
+
113
+ <button
114
+ onClick={() => setShowCreate(true)}
115
+ className="bg-cyan-600 hover:bg-cyan-500 text-white text-sm font-medium px-3 py-1.5 rounded transition-colors"
116
+ >
117
+ + New Experiment
118
+ </button>
119
+ </div>
120
+ </div>
121
+
122
+ {/* Create modal */}
123
+ {showCreate && (
124
+ <div className="mx-6 mt-4 p-4 bg-gray-800 rounded-lg border border-gray-700">
125
+ <div className="flex flex-col gap-3">
126
+ <input
127
+ type="text"
128
+ placeholder="Experiment name"
129
+ value={newName}
130
+ onChange={(e) => setNewName(e.target.value)}
131
+ className="bg-gray-900 text-gray-200 text-sm rounded px-3 py-2 border border-gray-700 focus:border-cyan-500 outline-none"
132
+ autoFocus
133
+ onKeyDown={(e) => e.key === "Enter" && handleCreate()}
134
+ />
135
+ <textarea
136
+ placeholder="Hypothesis statement (optional)"
137
+ value={newHypothesis}
138
+ onChange={(e) => setNewHypothesis(e.target.value)}
139
+ className="bg-gray-900 text-gray-200 text-sm rounded px-3 py-2 border border-gray-700 focus:border-cyan-500 outline-none resize-none"
140
+ rows={2}
141
+ />
142
+ <div className="flex gap-2 justify-end">
143
+ <button
144
+ onClick={() => setShowCreate(false)}
145
+ className="text-gray-400 hover:text-gray-200 text-sm px-3 py-1.5 rounded transition-colors"
146
+ >
147
+ Cancel
148
+ </button>
149
+ <button
150
+ onClick={handleCreate}
151
+ disabled={creating || !newName.trim()}
152
+ className="bg-cyan-600 hover:bg-cyan-500 disabled:bg-gray-700 disabled:text-gray-500 text-white text-sm font-medium px-3 py-1.5 rounded transition-colors"
153
+ >
154
+ {creating ? "Creating..." : "Create"}
155
+ </button>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ )}
160
+
161
+ {/* Experiment cards */}
162
+ <div className="flex-1 overflow-y-auto p-6">
163
+ {sorted.length === 0 ? (
164
+ <div className="flex flex-col items-center justify-center h-64 text-gray-500">
165
+ <p className="text-lg mb-2">No experiments yet</p>
166
+ <p className="text-sm">Create one to get started</p>
167
+ </div>
168
+ ) : (
169
+ <div className="grid gap-3">
170
+ {sorted.map((exp) => (
171
+ <button
172
+ key={exp.id}
173
+ onClick={() => onSelect(exp.id)}
174
+ className="w-full text-left bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded-lg p-4 transition-colors"
175
+ >
176
+ <div className="flex items-start justify-between">
177
+ <div className="flex-1 min-w-0">
178
+ <div className="flex items-center gap-2 mb-1">
179
+ <span className={`text-xs px-2 py-0.5 rounded-full text-white ${STAGE_COLORS[exp.stage] || STAGE_COLORS.idea}`}>
180
+ {exp.stage}
181
+ </span>
182
+ <h3 className="text-sm font-medium text-gray-200 truncate">
183
+ {exp.name}
184
+ </h3>
185
+ </div>
186
+ {exp.hypothesis?.statement && (
187
+ <p className="text-xs text-gray-400 mt-1 line-clamp-2">
188
+ {exp.hypothesis.statement}
189
+ </p>
190
+ )}
191
+ <div className="flex items-center gap-3 mt-2">
192
+ {exp.hypothesis?.status && (
193
+ <span className={`text-xs ${STATUS_COLORS[exp.hypothesis.status] || "text-gray-400"}`}>
194
+ {exp.hypothesis.status}
195
+ </span>
196
+ )}
197
+ {(exp.models || []).length > 0 && (
198
+ <span className="text-xs text-gray-500">
199
+ {exp.models.slice(0, 3).join(", ")}
200
+ {exp.models.length > 3 && ` +${exp.models.length - 3}`}
201
+ </span>
202
+ )}
203
+ {(exp.tags || []).length > 0 && (
204
+ <div className="flex gap-1">
205
+ {exp.tags.slice(0, 3).map((tag) => (
206
+ <span
207
+ key={tag}
208
+ className="text-xs bg-gray-800 text-gray-400 px-1.5 py-0.5 rounded"
209
+ >
210
+ {tag}
211
+ </span>
212
+ ))}
213
+ </div>
214
+ )}
215
+ </div>
216
+ </div>
217
+ <div className="flex flex-col items-end gap-1 ml-4 shrink-0">
218
+ {/* Completeness dots */}
219
+ <div className="flex gap-0.5">
220
+ {[0, 1, 2, 3, 4].map((i) => (
221
+ <div
222
+ key={i}
223
+ className={`w-1.5 h-1.5 rounded-full ${
224
+ i < (exp.completeness || 0) ? "bg-cyan-500" : "bg-gray-700"
225
+ }`}
226
+ />
227
+ ))}
228
+ </div>
229
+ <span className="text-xs text-gray-500">
230
+ {exp.run_count || 0} runs
231
+ </span>
232
+ {exp.sub_count ? (
233
+ <span className="text-xs text-gray-500">
234
+ {exp.sub_count} sub-exp
235
+ </span>
236
+ ) : null}
237
+ <span className="text-xs text-gray-600">
238
+ {exp.updated ? new Date(exp.updated).toLocaleDateString() : ""}
239
+ </span>
240
+ </div>
241
+ </div>
242
+ </button>
243
+ ))}
244
+ </div>
245
+ )}
246
+ </div>
247
+ </div>
248
+ );
249
+ }
frontend/src/experiments/components/SubExperimentView.tsx ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import type { SubExperiment } from "../types";
3
+ import { experimentsApi } from "../api";
4
+
5
+ interface Props {
6
+ sub: SubExperiment;
7
+ experimentName: string;
8
+ onBack: () => void;
9
+ onRefresh: () => void;
10
+ }
11
+
12
+ export default function SubExperimentView({ sub, experimentName, onBack, onRefresh }: Props) {
13
+ const [editing, setEditing] = useState(false);
14
+ const [content, setContent] = useState(sub.content_md || "");
15
+ const [hypothesis, setHypothesis] = useState(sub.hypothesis || "");
16
+ const [status, setStatus] = useState(sub.status || "active");
17
+ const [saving, setSaving] = useState(false);
18
+
19
+ const handleSave = async () => {
20
+ setSaving(true);
21
+ try {
22
+ await experimentsApi.updateSub(sub.experiment_id, sub.id, {
23
+ content_md: content,
24
+ hypothesis,
25
+ status,
26
+ });
27
+ setEditing(false);
28
+ onRefresh();
29
+ } finally {
30
+ setSaving(false);
31
+ }
32
+ };
33
+
34
+ return (
35
+ <div className="h-full flex flex-col">
36
+ {/* Breadcrumb + header */}
37
+ <div className="px-6 py-4 border-b border-gray-800">
38
+ <div className="flex items-center gap-2 text-sm mb-3">
39
+ <button onClick={onBack} className="text-gray-400 hover:text-gray-200 transition-colors">
40
+ &larr; {experimentName}
41
+ </button>
42
+ <span className="text-gray-600">/</span>
43
+ <span className="text-gray-300">{sub.name}</span>
44
+ </div>
45
+
46
+ <div className="flex items-start justify-between">
47
+ <div>
48
+ <h1 className="text-lg font-semibold text-gray-200">{sub.name}</h1>
49
+ {editing ? (
50
+ <div className="flex items-center gap-2 mt-2">
51
+ <input
52
+ value={hypothesis}
53
+ onChange={(e) => setHypothesis(e.target.value)}
54
+ placeholder="Hypothesis"
55
+ className="bg-gray-900 text-gray-200 text-sm rounded px-2 py-1 border border-gray-700 outline-none flex-1"
56
+ />
57
+ <select
58
+ value={status}
59
+ onChange={(e) => setStatus(e.target.value)}
60
+ className="bg-gray-800 text-gray-300 text-xs rounded px-2 py-1 border border-gray-700"
61
+ >
62
+ <option value="active">active</option>
63
+ <option value="concluded">concluded</option>
64
+ <option value="inconclusive">inconclusive</option>
65
+ </select>
66
+ </div>
67
+ ) : (
68
+ <div className="flex items-center gap-3 mt-1">
69
+ {sub.hypothesis && (
70
+ <p className="text-sm text-gray-400">{sub.hypothesis}</p>
71
+ )}
72
+ <span className={`text-xs px-2 py-0.5 rounded-full text-white ${
73
+ status === "concluded" ? "bg-green-600" :
74
+ status === "active" ? "bg-yellow-600" : "bg-gray-600"
75
+ }`}>
76
+ {status}
77
+ </span>
78
+ </div>
79
+ )}
80
+ </div>
81
+ <div className="flex gap-2">
82
+ {editing ? (
83
+ <>
84
+ <button onClick={() => { setEditing(false); setContent(sub.content_md || ""); }}
85
+ className="text-gray-400 hover:text-gray-200 text-sm px-3 py-1.5 rounded transition-colors">
86
+ Cancel
87
+ </button>
88
+ <button onClick={handleSave} disabled={saving}
89
+ className="bg-cyan-600 hover:bg-cyan-500 text-white text-sm font-medium px-3 py-1.5 rounded transition-colors">
90
+ {saving ? "Saving..." : "Save"}
91
+ </button>
92
+ </>
93
+ ) : (
94
+ <button onClick={() => setEditing(true)}
95
+ className="text-gray-400 hover:text-gray-200 text-sm px-3 py-1.5 rounded border border-gray-700 transition-colors">
96
+ Edit
97
+ </button>
98
+ )}
99
+ </div>
100
+ </div>
101
+ </div>
102
+
103
+ {/* Content */}
104
+ <div className="flex-1 overflow-y-auto p-6">
105
+ <div className="max-w-3xl">
106
+ {editing ? (
107
+ <textarea
108
+ value={content}
109
+ onChange={(e) => setContent(e.target.value)}
110
+ className="w-full bg-gray-900 text-gray-200 text-sm rounded px-4 py-3 border border-gray-700 focus:border-cyan-500 outline-none resize-y font-mono"
111
+ rows={30}
112
+ placeholder="Write your sub-experiment report in markdown..."
113
+ />
114
+ ) : (
115
+ <div className="text-sm text-gray-300 whitespace-pre-wrap bg-gray-900 rounded p-6 min-h-[300px]">
116
+ {sub.content_md || <span className="text-gray-600 italic">No content yet. Click Edit to add your sub-experiment report.</span>}
117
+ </div>
118
+ )}
119
+
120
+ {/* HF Repos */}
121
+ {(sub.hf_repos || []).length > 0 && (
122
+ <div className="mt-6">
123
+ <span className="text-xs text-gray-500 uppercase tracking-wide">Linked Datasets</span>
124
+ <div className="grid gap-2 mt-2">
125
+ {sub.hf_repos.map((repo, i) => (
126
+ <a
127
+ key={`${repo.repo}-${i}`}
128
+ href={`https://huggingface.co/datasets/${repo.repo}`}
129
+ target="_blank"
130
+ rel="noopener noreferrer"
131
+ className="text-cyan-400 hover:text-cyan-300 text-sm"
132
+ >
133
+ {repo.repo}
134
+ </a>
135
+ ))}
136
+ </div>
137
+ </div>
138
+ )}
139
+
140
+ {/* Timestamps */}
141
+ <div className="mt-6 flex gap-4 text-xs text-gray-600">
142
+ {sub.created && <span>Created: {new Date(sub.created).toLocaleDateString()}</span>}
143
+ {sub.updated && <span>Updated: {new Date(sub.updated).toLocaleDateString()}</span>}
144
+ </div>
145
+ </div>
146
+ </div>
147
+ </div>
148
+ );
149
+ }
frontend/src/experiments/store.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useEffect } from "react";
2
+ import type { Experiment, ExperimentDetail, SubExperiment } from "./types";
3
+ import { experimentsApi } from "./api";
4
+
5
+ export type View =
6
+ | { kind: "list" }
7
+ | { kind: "detail"; expId: string }
8
+ | { kind: "sub"; expId: string; subId: string };
9
+
10
+ export function useExperimentsState() {
11
+ const [experiments, setExperiments] = useState<Experiment[]>([]);
12
+ const [currentDetail, setCurrentDetail] = useState<ExperimentDetail | null>(null);
13
+ const [currentSub, setCurrentSub] = useState<SubExperiment | null>(null);
14
+ const [view, setView] = useState<View>({ kind: "list" });
15
+ const [loading, setLoading] = useState(false);
16
+ const [error, setError] = useState<string | null>(null);
17
+
18
+ const loadExperiments = useCallback(async () => {
19
+ setLoading(true);
20
+ setError(null);
21
+ try {
22
+ const data = await experimentsApi.list();
23
+ setExperiments(data);
24
+ } catch (e) {
25
+ setError(e instanceof Error ? e.message : "Failed to load experiments");
26
+ } finally {
27
+ setLoading(false);
28
+ }
29
+ }, []);
30
+
31
+ useEffect(() => {
32
+ loadExperiments();
33
+ }, [loadExperiments]);
34
+
35
+ const navigateToList = useCallback(() => {
36
+ setView({ kind: "list" });
37
+ setCurrentDetail(null);
38
+ setCurrentSub(null);
39
+ loadExperiments();
40
+ }, [loadExperiments]);
41
+
42
+ const navigateToDetail = useCallback(async (expId: string) => {
43
+ setLoading(true);
44
+ setError(null);
45
+ try {
46
+ const detail = await experimentsApi.get(expId);
47
+ setCurrentDetail(detail);
48
+ setView({ kind: "detail", expId });
49
+ } catch (e) {
50
+ setError(e instanceof Error ? e.message : "Failed to load experiment");
51
+ } finally {
52
+ setLoading(false);
53
+ }
54
+ }, []);
55
+
56
+ const navigateToSub = useCallback((expId: string, subId: string) => {
57
+ if (!currentDetail) return;
58
+ const sub = currentDetail.sub_experiments.find(s => s.id === subId);
59
+ if (sub) {
60
+ setCurrentSub(sub);
61
+ setView({ kind: "sub", expId, subId });
62
+ }
63
+ }, [currentDetail]);
64
+
65
+ const refreshDetail = useCallback(async () => {
66
+ if (view.kind === "detail" || view.kind === "sub") {
67
+ const expId = view.expId;
68
+ try {
69
+ const detail = await experimentsApi.get(expId);
70
+ setCurrentDetail(detail);
71
+ } catch (e) {
72
+ // silent refresh failure
73
+ }
74
+ }
75
+ }, [view]);
76
+
77
+ return {
78
+ experiments,
79
+ currentDetail,
80
+ currentSub,
81
+ view,
82
+ loading,
83
+ error,
84
+ setError,
85
+ navigateToList,
86
+ navigateToDetail,
87
+ navigateToSub,
88
+ refreshDetail,
89
+ loadExperiments,
90
+ };
91
+ }
frontend/src/experiments/types.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface HfRepo {
2
+ repo: string;
3
+ description: string;
4
+ date: string;
5
+ }
6
+
7
+ export interface Hypothesis {
8
+ statement: string;
9
+ type: "comparative" | "ablation" | "exploration" | "reproduction";
10
+ status: "pending" | "active" | "supported" | "invalidated" | "inconclusive" | "exploring";
11
+ success_criteria: string;
12
+ }
13
+
14
+ export type Stage = "idea" | "planned" | "active" | "concluded" | "inconclusive";
15
+
16
+ export interface Experiment {
17
+ id: string;
18
+ name: string;
19
+ research_project: string;
20
+ hypothesis: Hypothesis;
21
+ stage: Stage;
22
+ completeness: number;
23
+ models: string[];
24
+ tasks: string[];
25
+ tags: string[];
26
+ hf_repos: HfRepo[];
27
+ wandb_url: string;
28
+ notes: string;
29
+ created: string;
30
+ updated: string;
31
+ run_count?: number;
32
+ sub_count?: number;
33
+ }
34
+
35
+ export interface RunRecord {
36
+ id: string;
37
+ experiment_id: string;
38
+ condition: string;
39
+ model: string;
40
+ cluster: string;
41
+ status: "running" | "completed" | "failed";
42
+ hf_dataset: string;
43
+ metrics: Record<string, number | string>;
44
+ timestamp: string;
45
+ notes: string;
46
+ }
47
+
48
+ export interface SubExperiment {
49
+ id: string;
50
+ experiment_id: string;
51
+ name: string;
52
+ hypothesis: string;
53
+ status: string;
54
+ content_md: string;
55
+ hf_repos: HfRepo[];
56
+ created: string;
57
+ updated: string;
58
+ }
59
+
60
+ export interface ExperimentDetail extends Experiment {
61
+ runs: RunRecord[];
62
+ sub_experiments: SubExperiment[];
63
+ }
frontend/src/visualizer/VisualizerApp.tsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, lazy, Suspense } from "react";
2
+
3
+ const ModelApp = lazy(() => import("../model/ModelApp"));
4
+ const ArenaApp = lazy(() => import("../arena/ArenaApp"));
5
+ const RlmEvalApp = lazy(() => import("../rlm-eval/RlmEvalApp"));
6
+ const RlmApp = lazy(() => import("../rlm/RlmApp"));
7
+ const HarborApp = lazy(() => import("../harbor/HarborApp"));
8
+ const AdaevolveApp = lazy(() => import("../adaevolve/AdaevolveApp"));
9
+
10
+ type TabId = "model" | "arena" | "rlm-eval" | "rlm" | "harbor" | "adaevolve";
11
+
12
+ const TABS: { id: TabId; label: string; color: string; activeClass: string }[] = [
13
+ { id: "model", label: "Model Trace", color: "blue", activeClass: "border-blue-500 text-blue-400" },
14
+ { id: "arena", label: "Arena", color: "purple", activeClass: "border-purple-500 text-purple-400" },
15
+ { id: "rlm-eval", label: "RLM", color: "emerald", activeClass: "border-emerald-500 text-emerald-400" },
16
+ { id: "rlm", label: "RLM+GEPA", color: "orange", activeClass: "border-orange-500 text-orange-400" },
17
+ { id: "harbor", label: "Harbor", color: "teal", activeClass: "border-teal-500 text-teal-400" },
18
+ { id: "adaevolve", label: "AdaEvolve", color: "rose", activeClass: "border-rose-500 text-rose-400" },
19
+ ];
20
+
21
+ export default function VisualizerApp() {
22
+ const [activeTab, setActiveTab] = useState<TabId>("model");
23
+
24
+ return (
25
+ <div className="h-full flex flex-col">
26
+ {/* Visualizer tab bar */}
27
+ <div className="flex items-center border-b border-gray-800 bg-gray-900/50 px-2 shrink-0">
28
+ {TABS.map((tab) => (
29
+ <button
30
+ key={tab.id}
31
+ onClick={() => setActiveTab(tab.id)}
32
+ className={`px-5 py-2 text-sm font-medium border-b-2 transition-colors ${
33
+ activeTab === tab.id
34
+ ? tab.activeClass
35
+ : "border-transparent text-gray-500 hover:text-gray-300"
36
+ }`}
37
+ >
38
+ {tab.label}
39
+ </button>
40
+ ))}
41
+ </div>
42
+
43
+ {/* Active visualizer */}
44
+ <div className="flex-1 overflow-hidden">
45
+ <Suspense
46
+ fallback={
47
+ <div className="flex items-center justify-center h-full text-gray-500">
48
+ Loading...
49
+ </div>
50
+ }
51
+ >
52
+ {activeTab === "model" && (
53
+ <div className="theme-model h-full">
54
+ <ModelApp />
55
+ </div>
56
+ )}
57
+ {activeTab === "arena" && (
58
+ <div className="theme-arena h-full">
59
+ <ArenaApp />
60
+ </div>
61
+ )}
62
+ {activeTab === "rlm-eval" && (
63
+ <div className="theme-rlm-eval h-full">
64
+ <RlmEvalApp />
65
+ </div>
66
+ )}
67
+ {activeTab === "rlm" && (
68
+ <div className="theme-rlm h-full">
69
+ <RlmApp />
70
+ </div>
71
+ )}
72
+ {activeTab === "harbor" && (
73
+ <div className="theme-harbor h-full">
74
+ <HarborApp />
75
+ </div>
76
+ )}
77
+ {activeTab === "adaevolve" && (
78
+ <div className="theme-adaevolve h-full">
79
+ <AdaevolveApp />
80
+ </div>
81
+ )}
82
+ </Suspense>
83
+ </div>
84
+ </div>
85
+ );
86
+ }
frontend/tsconfig.app.tsbuildinfo CHANGED
@@ -1 +1 @@
1
- {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/arena/arenaapp.tsx","./src/arena/api.ts","./src/arena/store.ts","./src/arena/types.ts","./src/arena/components/episodebar.tsx","./src/arena/components/episodenav.tsx","./src/arena/components/sidebar.tsx","./src/arena/components/transcriptpanel.tsx","./src/arena/utils/tracehighlight.ts","./src/harbor/harborapp.tsx","./src/harbor/api.ts","./src/harbor/store.ts","./src/harbor/types.ts","./src/harbor/components/chatbubble.tsx","./src/harbor/components/infobar.tsx","./src/harbor/components/instancelist.tsx","./src/harbor/components/instancenav.tsx","./src/harbor/components/metricssummary.tsx","./src/harbor/components/sidebar.tsx","./src/harbor/components/stepdetail.tsx","./src/harbor/components/trajectoryview.tsx","./src/model/modelapp.tsx","./src/model/api.ts","./src/model/store.ts","./src/model/types.ts","./src/model/components/infobar.tsx","./src/model/components/questionnav.tsx","./src/model/components/sidebar.tsx","./src/model/components/tracepanel.tsx","./src/model/utils/promptparser.ts","./src/model/utils/tracehighlight.ts","./src/rlm/rlmapp.tsx","./src/rlm/api.ts","./src/rlm/store.ts","./src/rlm/types.ts","./src/rlm/components/breadcrumb.tsx","./src/rlm/components/datasetselector.tsx","./src/rlm/components/gepaiterlevel.tsx","./src/rlm/components/overviewlevel.tsx","./src/rlm/components/panel.tsx","./src/rlm/components/rlmdetaillevel.tsx","./src/rlm/components/sidebar.tsx","./src/rlm-eval/rlmevalapp.tsx","./src/rlm-eval/api.ts","./src/rlm-eval/store.ts","./src/rlm-eval/types.ts","./src/rlm-eval/components/breadcrumb.tsx","./src/rlm-eval/components/datasetselector.tsx","./src/rlm-eval/components/exampledetaillevel.tsx","./src/rlm-eval/components/iterationdetail.tsx","./src/rlm-eval/components/overviewlevel.tsx","./src/rlm-eval/components/panel.tsx","./src/rlm-eval/components/sidebar.tsx"],"version":"5.9.3"}
 
1
+ {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/adaevolve/adaevolveapp.tsx","./src/adaevolve/api.ts","./src/adaevolve/store.ts","./src/adaevolve/types.ts","./src/arena/arenaapp.tsx","./src/arena/api.ts","./src/arena/store.ts","./src/arena/types.ts","./src/arena/components/episodebar.tsx","./src/arena/components/episodenav.tsx","./src/arena/components/sidebar.tsx","./src/arena/components/transcriptpanel.tsx","./src/arena/utils/tracehighlight.ts","./src/experiments/experimentsapp.tsx","./src/experiments/api.ts","./src/experiments/store.ts","./src/experiments/types.ts","./src/experiments/components/experimentdetail.tsx","./src/experiments/components/experimentlist.tsx","./src/experiments/components/subexperimentview.tsx","./src/harbor/harborapp.tsx","./src/harbor/api.ts","./src/harbor/store.ts","./src/harbor/types.ts","./src/harbor/components/chatbubble.tsx","./src/harbor/components/infobar.tsx","./src/harbor/components/instancelist.tsx","./src/harbor/components/instancenav.tsx","./src/harbor/components/metricssummary.tsx","./src/harbor/components/sidebar.tsx","./src/harbor/components/stepdetail.tsx","./src/harbor/components/trajectoryview.tsx","./src/model/modelapp.tsx","./src/model/api.ts","./src/model/store.ts","./src/model/types.ts","./src/model/components/infobar.tsx","./src/model/components/questionnav.tsx","./src/model/components/sidebar.tsx","./src/model/components/tracepanel.tsx","./src/model/utils/promptparser.ts","./src/model/utils/tracehighlight.ts","./src/rlm/rlmapp.tsx","./src/rlm/api.ts","./src/rlm/store.ts","./src/rlm/types.ts","./src/rlm/components/breadcrumb.tsx","./src/rlm/components/datasetselector.tsx","./src/rlm/components/gepaiterlevel.tsx","./src/rlm/components/overviewlevel.tsx","./src/rlm/components/panel.tsx","./src/rlm/components/rlmdetaillevel.tsx","./src/rlm/components/sidebar.tsx","./src/rlm-eval/rlmevalapp.tsx","./src/rlm-eval/api.ts","./src/rlm-eval/store.ts","./src/rlm-eval/types.ts","./src/rlm-eval/components/breadcrumb.tsx","./src/rlm-eval/components/datasetselector.tsx","./src/rlm-eval/components/exampledetaillevel.tsx","./src/rlm-eval/components/iterationdetail.tsx","./src/rlm-eval/components/overviewlevel.tsx","./src/rlm-eval/components/panel.tsx","./src/rlm-eval/components/sidebar.tsx","./src/visualizer/visualizerapp.tsx"],"version":"5.9.3"}