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 filesRestructure 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 +1 -0
- README.md +20 -3
- backend/api/experiments.py +426 -0
- backend/app.py +2 -1
- docs/plans/2026-03-07-research-dashboard-design.md +86 -0
- frontend/src/App.tsx +25 -56
- frontend/src/experiments/ExperimentsApp.tsx +60 -0
- frontend/src/experiments/api.ts +83 -0
- frontend/src/experiments/components/ExperimentDetail.tsx +480 -0
- frontend/src/experiments/components/ExperimentList.tsx +249 -0
- frontend/src/experiments/components/SubExperimentView.tsx +149 -0
- frontend/src/experiments/store.ts +91 -0
- frontend/src/experiments/types.ts +63 -0
- frontend/src/visualizer/VisualizerApp.tsx +86 -0
- frontend/tsconfig.app.tsbuildinfo +1 -1
.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:
|
| 3 |
emoji: 📊
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: purple
|
|
@@ -7,13 +7,30 @@ sdk: docker
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
#
|
| 11 |
|
| 12 |
-
A unified
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 4 |
-
const
|
| 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
|
| 11 |
|
| 12 |
-
const
|
| 13 |
-
{ id: "
|
| 14 |
-
{ id: "
|
| 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 [
|
| 23 |
|
| 24 |
return (
|
| 25 |
<div className="h-screen flex flex-col bg-gray-950 text-gray-100">
|
| 26 |
-
{/*
|
| 27 |
-
<div className="flex items-center border-b border-gray-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
| 29 |
<button
|
| 30 |
-
key={
|
| 31 |
-
onClick={() =>
|
| 32 |
-
className={`px-
|
| 33 |
-
|
| 34 |
-
?
|
| 35 |
: "border-transparent text-gray-500 hover:text-gray-300"
|
| 36 |
}`}
|
| 37 |
>
|
| 38 |
-
{
|
| 39 |
</button>
|
| 40 |
))}
|
| 41 |
-
<div className="ml-auto text-xs text-gray-600 px-3">
|
|
|
|
|
|
|
| 42 |
</div>
|
| 43 |
|
| 44 |
-
{/* Active
|
| 45 |
<div className="flex-1 overflow-hidden">
|
| 46 |
<Suspense
|
| 47 |
fallback={
|
|
@@ -50,36 +47,8 @@ export default function App() {
|
|
| 50 |
</div>
|
| 51 |
}
|
| 52 |
>
|
| 53 |
-
{
|
| 54 |
-
|
| 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 |
+
← 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 |
+
×
|
| 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 |
+
← {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"}
|