timchen0618 commited on
Commit
b03f016
·
1 Parent(s): 3adde9f

Deploy research dashboard

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +8 -0
  2. .gitattributes +0 -35
  3. .gitignore +6 -0
  4. .hfignore +8 -0
  5. CLAUDE.md +91 -0
  6. Dockerfile +25 -0
  7. README.md +31 -5
  8. backend/__init__.py +0 -0
  9. backend/api/__init__.py +0 -0
  10. backend/api/experiments.py +730 -0
  11. backend/api/manifest.py +104 -0
  12. backend/api/model_datasets.py +409 -0
  13. backend/api/plan_revisions.py +60 -0
  14. backend/api/presets.py +188 -0
  15. backend/app.py +36 -0
  16. backend/data/activity_logs.json +53 -0
  17. backend/data/artifacts.json +1 -0
  18. backend/data/experiment_notes.json +42 -0
  19. backend/data/experiments.json +39 -0
  20. backend/data/runs.json +1 -0
  21. backend/data/sub_experiments.json +1 -0
  22. backend/data/summary_findings.json +1 -0
  23. backend/requirements.txt +5 -0
  24. docs/managing_presets.md +194 -0
  25. docs/plans/2026-03-07-research-dashboard-design.md +86 -0
  26. frontend/dist/assets/ExperimentsApp-B57tv21O.js +0 -0
  27. frontend/dist/assets/ExperimentsApp-DnJR2-55.css +1 -0
  28. frontend/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  29. frontend/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  30. frontend/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  31. frontend/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  32. frontend/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  33. frontend/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  34. frontend/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  35. frontend/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  36. frontend/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  37. frontend/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  38. frontend/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  39. frontend/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  40. frontend/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  41. frontend/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  42. frontend/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  43. frontend/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  44. frontend/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  45. frontend/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  46. frontend/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  47. frontend/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  48. frontend/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  49. frontend/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  50. frontend/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ frontend/node_modules
3
+ frontend/src
4
+ .git
5
+ **/__pycache__
6
+ **/*.pyc
7
+ .env
8
+ backend/data
.gitattributes DELETED
@@ -1,35 +0,0 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ node_modules/
2
+ __pycache__/
3
+ *.pyc
4
+ .DS_Store
5
+ .raca/
6
+ frontend/node_modules/
.hfignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ node_modules/
2
+ __pycache__/
3
+ .venv/
4
+ *.pyc
5
+ .DS_Store
6
+ .raca/
7
+ .git/
8
+ dist/
CLAUDE.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Experiment Dashboard Visualizer
2
+
3
+ Research dashboard with experiment tracking and trace visualization.
4
+
5
+ - **Live**: https://huggingface.co/spaces/{HF_ORG}/dashboard
6
+ - **GitHub**: `{github-org}/dashboard-repo`
7
+ - **Remotes**: `origin` (GitHub), `space` (HF Space)
8
+
9
+ ## Updating the Website
10
+
11
+ When the user says "update the website", "sync the dashboard", or "push to the website":
12
+
13
+ 1. **Build the frontend**:
14
+ ```bash
15
+ cd tools/visualizer/frontend && npm run build
16
+ ```
17
+
18
+ 2. **Import experiment data** (reads local files, uploads to HF dataset):
19
+ ```bash
20
+ cd tools/visualizer && python3 scripts/import_experiments.py
21
+ ```
22
+
23
+ 3. **Sync the live Space** (tells the running app to re-download data from HF):
24
+ ```bash
25
+ curl -s -X POST https://{HF_ORG}-dashboard.hf.space/api/experiments/sync
26
+ ```
27
+
28
+ 4. **Push code to HF Space** (deploys new frontend/backend code):
29
+ ```bash
30
+ cd tools/visualizer
31
+ git add -A && git commit -m "update dashboard"
32
+ git push origin main
33
+ git push space main
34
+ ```
35
+
36
+ Steps 1-3 sync **data** (experiments, summary findings, presets). Step 4 deploys **code** changes.
37
+
38
+ If only `summary_findings.md` changed (no code changes), steps 2-3 are sufficient.
39
+
40
+ ## Summary Findings
41
+
42
+ - Source file: `{WORKSPACE}/notes/experiments/summary_findings.md`
43
+ - Only the user writes this file — never edit it
44
+ - Synced to HF via `import_experiments.py` → served at `GET /api/experiments/summary`
45
+ - Shown on the Experiments page via the "Findings / Summary" button
46
+
47
+ ## Experiment Discovery
48
+
49
+ `scripts/import_experiments.py` auto-discovers all experiments in `notes/experiments/`, skipping `old/`, `_templates/`, and hidden directories. To hide specific experiments, add them to `EXCLUDED_EXPERIMENTS` in the script.
50
+
51
+ ## Stack
52
+
53
+ - **Backend**: Flask (Python), blueprints in `backend/api/`
54
+ - **Frontend**: React + TypeScript + Tailwind, built with Vite into `frontend/dist/`
55
+ - **Data**: HF datasets (`RACA_DASHBOARD` for experiments, `RACA-VIS-PRESETS` for visualizer presets)
56
+
57
+ ## AdaEvolve Traces
58
+
59
+ ### Required HuggingFace Dataset Columns
60
+
61
+ | Column | Type | Purpose |
62
+ |--------|------|---------|
63
+ | iteration | int | Iteration number |
64
+ | island_id | int | Which island produced this solution |
65
+ | score | float | Current iteration score |
66
+ | best_score | float | Best score so far |
67
+ | delta | float | Change from previous iteration |
68
+ | adaptation_type | string | "L1_explore", "L1_exploit", "L2_migrate", "L3_meta" |
69
+ | exploration_intensity | float | How exploratory this iteration was |
70
+ | is_valid | bool | Whether solution is valid |
71
+ | task_id | string | Task identifier |
72
+ | prompt_text | string | (optional) Input prompt to model |
73
+ | reasoning_trace | string | (optional) Thinking/reasoning output |
74
+ | program_code | string | (optional) Generated/evolved code |
75
+
76
+ ### Color Mapping
77
+
78
+ L1_explore=blue, L1_exploit=green, L2_migrate=amber, L3_meta=red, other=gray.
79
+
80
+ ### Adding a Preset
81
+
82
+ 1. Upload dataset to HuggingFace (org: `your-org`)
83
+ 2. Add entry to `your-org/RACA-VIS-PRESETS` file `adaevolve_presets.json`:
84
+ ```json
85
+ {"id": "8-char-hex", "name": "Descriptive Name", "repo": "org/dataset-name", "split": "train"}
86
+ ```
87
+ 3. Sync: `curl -X POST "https://your-org-agg-trace-visualizer.hf.space/api/presets/sync"`
88
+
89
+ ### Naming Convention
90
+
91
+ `{Task} {Model}: {Description} ({N} iter)`
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Install nginx
4
+ RUN apt-get update && apt-get install -y --no-install-recommends nginx && rm -rf /var/lib/apt/lists/*
5
+
6
+ WORKDIR /app
7
+
8
+ # Install Python deps (cached unless requirements.txt changes)
9
+ COPY backend/requirements.txt ./backend/
10
+ RUN pip install --no-cache-dir -r backend/requirements.txt
11
+
12
+ # Copy backend + pre-built frontend (built locally by install.sh, no Node needed)
13
+ COPY backend/ ./backend/
14
+ COPY frontend/dist/ ./frontend/dist/
15
+
16
+ # Writable dirs for runtime
17
+ RUN mkdir -p /app/backend/data /app/backend/presets && \
18
+ chmod -R 777 /app/backend/data /app/backend/presets
19
+
20
+ COPY nginx.conf /etc/nginx/nginx.conf
21
+ COPY start.sh ./
22
+ RUN chmod +x start.sh
23
+
24
+ EXPOSE 7860
25
+ CMD ["./start.sh"]
README.md CHANGED
@@ -1,10 +1,36 @@
1
  ---
2
- title: Dashboard
3
- emoji: 👁
4
- colorFrom: indigo
5
- colorTo: red
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Research Dashboard
3
+ emoji: 📊
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
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 `your-org/RACA_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 `your-org/RACA-VIS-PRESETS`.
backend/__init__.py ADDED
File without changes
backend/api/__init__.py ADDED
File without changes
backend/api/experiments.py ADDED
@@ -0,0 +1,730 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ def _resolve_hf_org() -> str:
12
+ """Resolve HF org from env > .raca/config.yaml > fallback."""
13
+ org = os.environ.get("HF_ORG")
14
+ if org and org != "your-org":
15
+ return org
16
+ # Walk up from this file looking for .raca/config.yaml
17
+ from pathlib import Path
18
+ current = Path(__file__).resolve().parent
19
+ for _ in range(10):
20
+ current = current.parent
21
+ config = current / ".raca" / "config.yaml"
22
+ if config.exists():
23
+ try:
24
+ import yaml
25
+ with open(config) as f:
26
+ cfg = yaml.safe_load(f) or {}
27
+ org = cfg.get("hf_org", "")
28
+ if org:
29
+ return org
30
+ except Exception:
31
+ pass
32
+ return "your-org"
33
+
34
+ HF_ORG = _resolve_hf_org()
35
+ DASHBOARD_REPO = f"{HF_ORG}/RACA_DASHBOARD"
36
+ LOCAL_DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
37
+
38
+ _cache: dict[str, list[dict]] = {}
39
+ _cache_loaded: set[str] = set()
40
+ _dashboard_cache: dict[str, dict] = {}
41
+ _dashboard_cache_loaded: bool = False
42
+ _lock = threading.Lock()
43
+
44
+ FILES = ["experiments", "runs", "sub_experiments", "experiment_notes", "summary_findings", "activity_logs", "artifacts"]
45
+
46
+
47
+ def _ensure_local_dir():
48
+ os.makedirs(LOCAL_DATA_DIR, exist_ok=True)
49
+
50
+
51
+ def _local_path(name: str) -> str:
52
+ _ensure_local_dir()
53
+ return os.path.join(LOCAL_DATA_DIR, f"{name}.json")
54
+
55
+
56
+ def _download_file(name: str) -> list[dict]:
57
+ try:
58
+ from huggingface_hub import hf_hub_download
59
+ path = hf_hub_download(
60
+ DASHBOARD_REPO,
61
+ f"{name}.json",
62
+ repo_type="dataset",
63
+ )
64
+ with open(path) as f:
65
+ data = json.load(f)
66
+ with open(_local_path(name), "w") as f:
67
+ json.dump(data, f, indent=2)
68
+ return data
69
+ except Exception:
70
+ local = _local_path(name)
71
+ if os.path.exists(local):
72
+ with open(local) as f:
73
+ return json.load(f)
74
+ return []
75
+
76
+
77
+ def _upload_file(name: str, data: list[dict]):
78
+ with open(_local_path(name), "w") as f:
79
+ json.dump(data, f, indent=2)
80
+
81
+ def _do_upload():
82
+ try:
83
+ from huggingface_hub import HfApi
84
+ api = HfApi()
85
+ try:
86
+ api.create_repo(DASHBOARD_REPO, repo_type="dataset", exist_ok=True)
87
+ except Exception:
88
+ pass
89
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
90
+ json.dump(data, f, indent=2)
91
+ tmp = f.name
92
+ api.upload_file(
93
+ path_or_fileobj=tmp,
94
+ path_in_repo=f"{name}.json",
95
+ repo_id=DASHBOARD_REPO,
96
+ repo_type="dataset",
97
+ )
98
+ os.unlink(tmp)
99
+ except Exception as e:
100
+ print(f"[experiments] HF upload failed for {name}: {e}")
101
+
102
+ threading.Thread(target=_do_upload, daemon=True).start()
103
+
104
+
105
+ def _get(name: str):
106
+ """Get cached data, downloading from HF if needed.
107
+ Returns list[dict] for most files, but dict for activity_logs (keyed by experiment_id).
108
+ """
109
+ # Check cache without holding lock during download
110
+ with _lock:
111
+ if name in _cache_loaded:
112
+ data = _cache.get(name, [])
113
+ return dict(data) if isinstance(data, dict) else list(data)
114
+ need_download = True
115
+
116
+ if need_download:
117
+ # Download outside the lock so one slow download doesn't block all requests
118
+ downloaded = _download_file(name)
119
+ with _lock:
120
+ if name not in _cache_loaded: # Double-check after reacquiring
121
+ _cache[name] = downloaded
122
+ _cache_loaded.add(name)
123
+ data = _cache.get(name, [])
124
+ return dict(data) if isinstance(data, dict) else list(data)
125
+
126
+
127
+ def _set(name: str, data: list[dict]):
128
+ with _lock:
129
+ _cache[name] = data
130
+ _cache_loaded.add(name)
131
+ _upload_file(name, data)
132
+
133
+
134
+ def _now() -> str:
135
+ return datetime.now(timezone.utc).isoformat()
136
+
137
+
138
+ def _load_dashboard_state() -> dict:
139
+ """Load dashboard_state.json (a single dict, not an array) from HF or local fallback."""
140
+ global _dashboard_cache_loaded
141
+ with _lock:
142
+ if _dashboard_cache_loaded:
143
+ return dict(_dashboard_cache)
144
+ try:
145
+ from huggingface_hub import hf_hub_download
146
+ path = hf_hub_download(
147
+ DASHBOARD_REPO,
148
+ "dashboard_state.json",
149
+ repo_type="dataset",
150
+ )
151
+ with open(path) as f:
152
+ data = json.load(f)
153
+ # Cache locally
154
+ local = os.path.join(LOCAL_DATA_DIR, "dashboard_state.json")
155
+ _ensure_local_dir()
156
+ with open(local, "w") as f:
157
+ json.dump(data, f, indent=2)
158
+ with _lock:
159
+ _dashboard_cache.clear()
160
+ _dashboard_cache.update(data if isinstance(data, dict) else {})
161
+ _dashboard_cache_loaded = True
162
+ return dict(_dashboard_cache)
163
+ except Exception:
164
+ local = os.path.join(LOCAL_DATA_DIR, "dashboard_state.json")
165
+ if os.path.exists(local):
166
+ try:
167
+ with open(local) as f:
168
+ data = json.load(f)
169
+ with _lock:
170
+ _dashboard_cache.clear()
171
+ _dashboard_cache.update(data if isinstance(data, dict) else {})
172
+ _dashboard_cache_loaded = True
173
+ return dict(_dashboard_cache)
174
+ except Exception:
175
+ pass
176
+ with _lock:
177
+ _dashboard_cache_loaded = True
178
+ return {}
179
+
180
+
181
+ def _merge_dashboard_state(experiments: list[dict]) -> list[dict]:
182
+ """Enrich experiment list with live dashboard state fields."""
183
+ state = _load_dashboard_state()
184
+ if not state:
185
+ return experiments
186
+
187
+ # Build lookup: dashboard state keyed by experiment id and name
188
+ exp_states: dict[str, dict] = {}
189
+ for exp_id, exp_state in state.items():
190
+ if isinstance(exp_state, dict):
191
+ exp_states[exp_id] = exp_state
192
+ # Also index by name if present
193
+ name = exp_state.get("name", "")
194
+ if name:
195
+ exp_states[name] = exp_state
196
+
197
+ # Enrich existing experiments
198
+ seen_ids = set()
199
+ result = []
200
+ for exp in experiments:
201
+ seen_ids.add(exp["id"])
202
+ ds = exp_states.get(exp["id"]) or exp_states.get(exp.get("name", ""))
203
+ if ds:
204
+ exp = {
205
+ **exp,
206
+ "live_status": ds.get("status"),
207
+ "live_message": ds.get("message", ""),
208
+ "live_jobs": ds.get("jobs", {}),
209
+ "unreachable_clusters": ds.get("unreachable_clusters", {}),
210
+ "live_history": ds.get("history", []),
211
+ "live_started_at": ds.get("started_at"),
212
+ "live_updated_at": ds.get("updated_at"),
213
+ }
214
+ result.append(exp)
215
+
216
+ # Add experiments that exist ONLY in dashboard state (not in experiments.json)
217
+ for exp_id, ds in state.items():
218
+ if exp_id not in seen_ids and isinstance(ds, dict):
219
+ seen_ids.add(exp_id)
220
+ result.append({
221
+ "id": exp_id,
222
+ "name": ds.get("name", exp_id),
223
+ "research_project": ds.get("research_project", ""),
224
+ "hypothesis": {
225
+ "statement": "",
226
+ "type": "exploration",
227
+ "status": "pending",
228
+ "success_criteria": "",
229
+ },
230
+ "stage": "active",
231
+ "completeness": 0,
232
+ "models": [],
233
+ "tasks": [],
234
+ "tags": [],
235
+ "hf_repos": [],
236
+ "wandb_url": "",
237
+ "notes": "",
238
+ "created": ds.get("started_at", _now()),
239
+ "updated": ds.get("updated_at", _now()),
240
+ "run_count": 0,
241
+ "sub_count": 0,
242
+ "note_count": 0,
243
+ "live_status": ds.get("status"),
244
+ "live_message": ds.get("message", ""),
245
+ "live_jobs": ds.get("jobs", {}),
246
+ "unreachable_clusters": ds.get("unreachable_clusters", {}),
247
+ "live_history": ds.get("history", []),
248
+ "live_started_at": ds.get("started_at"),
249
+ "live_updated_at": ds.get("updated_at"),
250
+ })
251
+
252
+ return result
253
+
254
+
255
+ # --- Experiments CRUD ---
256
+
257
+ @bp.route("/", methods=["GET"])
258
+ def list_experiments():
259
+ experiments = _get("experiments")
260
+ runs = _get("runs")
261
+ subs = _get("sub_experiments")
262
+ notes = _get("experiment_notes")
263
+
264
+ # Enrich with counts
265
+ result = []
266
+ for exp in experiments:
267
+ exp_runs = [r for r in runs if r.get("experiment_id") == exp["id"]]
268
+ exp_subs = [s for s in subs if s.get("experiment_id") == exp["id"]]
269
+ exp_notes = [n for n in notes if n.get("experiment_id") == exp["id"]]
270
+ result.append({
271
+ **exp,
272
+ "run_count": len(exp_runs),
273
+ "sub_count": len(exp_subs),
274
+ "note_count": len(exp_notes),
275
+ })
276
+
277
+ # Merge live dashboard state
278
+ result = _merge_dashboard_state(result)
279
+
280
+ return jsonify(result)
281
+
282
+
283
+ @bp.route("/", methods=["POST"])
284
+ def create_experiment():
285
+ data = request.get_json()
286
+ name = data.get("name", "").strip()
287
+ if not name:
288
+ return jsonify({"error": "name is required"}), 400
289
+
290
+ exp_id = data.get("id", name.lower().replace(" ", "_"))
291
+
292
+ experiments = _get("experiments")
293
+ if any(e["id"] == exp_id for e in experiments):
294
+ return jsonify({"error": f"Experiment '{exp_id}' already exists"}), 409
295
+
296
+ experiment = {
297
+ "id": exp_id,
298
+ "name": name,
299
+ "research_project": data.get("research_project", ""),
300
+ "hypothesis": data.get("hypothesis", {
301
+ "statement": "",
302
+ "type": "exploration",
303
+ "status": "pending",
304
+ "success_criteria": "",
305
+ }),
306
+ "stage": data.get("stage", "idea"),
307
+ "completeness": data.get("completeness", 0),
308
+ "models": data.get("models", []),
309
+ "tasks": data.get("tasks", []),
310
+ "tags": data.get("tags", []),
311
+ "hf_repos": data.get("hf_repos", []),
312
+ "wandb_url": data.get("wandb_url", ""),
313
+ "notes": data.get("notes", ""),
314
+ "created": _now(),
315
+ "updated": _now(),
316
+ }
317
+
318
+ experiments.append(experiment)
319
+ _set("experiments", experiments)
320
+ return jsonify(experiment), 201
321
+
322
+
323
+ @bp.route("/<exp_id>", methods=["GET"])
324
+ def get_experiment(exp_id):
325
+ experiments = _get("experiments")
326
+ exp = next((e for e in experiments if e["id"] == exp_id), None)
327
+ if not exp:
328
+ return jsonify({"error": "not found"}), 404
329
+
330
+ runs = [r for r in _get("runs") if r.get("experiment_id") == exp_id]
331
+ subs = [s for s in _get("sub_experiments") if s.get("experiment_id") == exp_id]
332
+ notes = [n for n in _get("experiment_notes") if n.get("experiment_id") == exp_id]
333
+
334
+ # Activity log
335
+ all_logs = _get("activity_logs")
336
+ if isinstance(all_logs, dict):
337
+ activity_log = all_logs.get(exp_id, [])
338
+ elif isinstance(all_logs, list) and len(all_logs) == 1 and isinstance(all_logs[0], dict):
339
+ activity_log = all_logs[0].get(exp_id, [])
340
+ else:
341
+ activity_log = []
342
+
343
+ # Artifacts from manifest
344
+ all_artifacts = _get("artifacts")
345
+ artifacts = [a for a in all_artifacts if a.get("experiment_id") == exp_id]
346
+
347
+ return jsonify({
348
+ **exp,
349
+ "runs": runs,
350
+ "sub_experiments": subs,
351
+ "experiment_notes": notes,
352
+ "activity_log": activity_log,
353
+ "artifacts": artifacts,
354
+ })
355
+
356
+
357
+ @bp.route("/<exp_id>", methods=["PUT"])
358
+ def update_experiment(exp_id):
359
+ data = request.get_json()
360
+ experiments = _get("experiments")
361
+
362
+ for exp in experiments:
363
+ if exp["id"] == exp_id:
364
+ for key in ["name", "research_project", "hypothesis", "stage",
365
+ "completeness", "models", "tasks", "tags", "hf_repos",
366
+ "wandb_url", "notes"]:
367
+ if key in data:
368
+ exp[key] = data[key]
369
+ exp["updated"] = _now()
370
+ _set("experiments", experiments)
371
+ return jsonify(exp)
372
+
373
+ return jsonify({"error": "not found"}), 404
374
+
375
+
376
+ @bp.route("/<exp_id>", methods=["DELETE"])
377
+ def delete_experiment(exp_id):
378
+ experiments = _get("experiments")
379
+ experiments = [e for e in experiments if e["id"] != exp_id]
380
+ _set("experiments", experiments)
381
+
382
+ # Also delete associated runs, subs, and notes
383
+ runs = [r for r in _get("runs") if r.get("experiment_id") != exp_id]
384
+ _set("runs", runs)
385
+ subs = [s for s in _get("sub_experiments") if s.get("experiment_id") != exp_id]
386
+ _set("sub_experiments", subs)
387
+ notes = [n for n in _get("experiment_notes") if n.get("experiment_id") != exp_id]
388
+ _set("experiment_notes", notes)
389
+
390
+ return jsonify({"status": "ok"})
391
+
392
+
393
+ # --- Run records ---
394
+
395
+ @bp.route("/<exp_id>/runs", methods=["POST"])
396
+ def create_run(exp_id):
397
+ experiments = _get("experiments")
398
+ if not any(e["id"] == exp_id for e in experiments):
399
+ return jsonify({"error": "experiment not found"}), 404
400
+
401
+ data = request.get_json()
402
+ run = {
403
+ "id": data.get("id", f"run_{uuid.uuid4().hex[:8]}"),
404
+ "experiment_id": exp_id,
405
+ "condition": data.get("condition", ""),
406
+ "model": data.get("model", ""),
407
+ "cluster": data.get("cluster", ""),
408
+ "status": data.get("status", "completed"),
409
+ "hf_dataset": data.get("hf_dataset", ""),
410
+ "metrics": data.get("metrics", {}),
411
+ "timestamp": data.get("timestamp", _now()),
412
+ "notes": data.get("notes", ""),
413
+ }
414
+
415
+ runs = _get("runs")
416
+ runs.append(run)
417
+ _set("runs", runs)
418
+
419
+ # Touch experiment updated timestamp
420
+ for exp in experiments:
421
+ if exp["id"] == exp_id:
422
+ exp["updated"] = _now()
423
+ _set("experiments", experiments)
424
+
425
+ return jsonify(run), 201
426
+
427
+
428
+ @bp.route("/<exp_id>/runs/<run_id>", methods=["PUT"])
429
+ def update_run(exp_id, run_id):
430
+ data = request.get_json()
431
+ runs = _get("runs")
432
+
433
+ for run in runs:
434
+ if run["id"] == run_id and run["experiment_id"] == exp_id:
435
+ for key in ["condition", "model", "cluster", "status",
436
+ "hf_dataset", "metrics", "notes"]:
437
+ if key in data:
438
+ run[key] = data[key]
439
+ _set("runs", runs)
440
+ return jsonify(run)
441
+
442
+ return jsonify({"error": "not found"}), 404
443
+
444
+
445
+ @bp.route("/<exp_id>/runs/<run_id>", methods=["DELETE"])
446
+ def delete_run(exp_id, run_id):
447
+ runs = _get("runs")
448
+ runs = [r for r in runs if not (r["id"] == run_id and r["experiment_id"] == exp_id)]
449
+ _set("runs", runs)
450
+ return jsonify({"status": "ok"})
451
+
452
+
453
+ # --- Sub-experiments ---
454
+
455
+ @bp.route("/<exp_id>/subs", methods=["POST"])
456
+ def create_sub(exp_id):
457
+ experiments = _get("experiments")
458
+ if not any(e["id"] == exp_id for e in experiments):
459
+ return jsonify({"error": "experiment not found"}), 404
460
+
461
+ data = request.get_json()
462
+ name = data.get("name", "").strip()
463
+ if not name:
464
+ return jsonify({"error": "name is required"}), 400
465
+
466
+ sub_id = data.get("id", f"{exp_id}__{name.lower().replace(' ', '_')}")
467
+
468
+ sub = {
469
+ "id": sub_id,
470
+ "experiment_id": exp_id,
471
+ "name": name,
472
+ "hypothesis": data.get("hypothesis", ""),
473
+ "status": data.get("status", "active"),
474
+ "content_md": data.get("content_md", ""),
475
+ "hf_repos": data.get("hf_repos", []),
476
+ "created": _now(),
477
+ "updated": _now(),
478
+ }
479
+
480
+ subs = _get("sub_experiments")
481
+ subs.append(sub)
482
+ _set("sub_experiments", subs)
483
+
484
+ # Touch experiment updated timestamp
485
+ for exp in experiments:
486
+ if exp["id"] == exp_id:
487
+ exp["updated"] = _now()
488
+ _set("experiments", experiments)
489
+
490
+ return jsonify(sub), 201
491
+
492
+
493
+ @bp.route("/<exp_id>/subs/<sub_id>", methods=["PUT"])
494
+ def update_sub(exp_id, sub_id):
495
+ data = request.get_json()
496
+ subs = _get("sub_experiments")
497
+
498
+ for sub in subs:
499
+ if sub["id"] == sub_id and sub["experiment_id"] == exp_id:
500
+ for key in ["name", "hypothesis", "status", "content_md", "hf_repos"]:
501
+ if key in data:
502
+ sub[key] = data[key]
503
+ sub["updated"] = _now()
504
+ _set("sub_experiments", subs)
505
+ return jsonify(sub)
506
+
507
+ return jsonify({"error": "not found"}), 404
508
+
509
+
510
+ @bp.route("/<exp_id>/subs/<sub_id>", methods=["DELETE"])
511
+ def delete_sub(exp_id, sub_id):
512
+ subs = _get("sub_experiments")
513
+ subs = [s for s in subs if not (s["id"] == sub_id and s["experiment_id"] == exp_id)]
514
+ _set("sub_experiments", subs)
515
+ return jsonify({"status": "ok"})
516
+
517
+
518
+ # --- Experiment Notes ---
519
+
520
+ @bp.route("/<exp_id>/notes", methods=["POST"])
521
+ def create_note(exp_id):
522
+ experiments = _get("experiments")
523
+ if not any(e["id"] == exp_id for e in experiments):
524
+ return jsonify({"error": "experiment not found"}), 404
525
+
526
+ data = request.get_json()
527
+ title = data.get("title", "").strip()
528
+ if not title:
529
+ return jsonify({"error": "title is required"}), 400
530
+
531
+ note_id = data.get("id", f"{exp_id}__note_{uuid.uuid4().hex[:8]}")
532
+
533
+ note = {
534
+ "id": note_id,
535
+ "experiment_id": exp_id,
536
+ "title": title,
537
+ "filename": data.get("filename", ""),
538
+ "content_md": data.get("content_md", ""),
539
+ "created": _now(),
540
+ "updated": _now(),
541
+ }
542
+
543
+ notes = _get("experiment_notes")
544
+ notes.append(note)
545
+ _set("experiment_notes", notes)
546
+ return jsonify(note), 201
547
+
548
+
549
+ @bp.route("/<exp_id>/notes/<note_id>", methods=["GET"])
550
+ def get_note(exp_id, note_id):
551
+ notes = _get("experiment_notes")
552
+ note = next((n for n in notes if n["id"] == note_id and n["experiment_id"] == exp_id), None)
553
+ if not note:
554
+ return jsonify({"error": "not found"}), 404
555
+ return jsonify(note)
556
+
557
+
558
+ @bp.route("/<exp_id>/notes/<note_id>", methods=["PUT"])
559
+ def update_note(exp_id, note_id):
560
+ data = request.get_json()
561
+ notes = _get("experiment_notes")
562
+
563
+ for note in notes:
564
+ if note["id"] == note_id and note["experiment_id"] == exp_id:
565
+ for key in ["title", "content_md"]:
566
+ if key in data:
567
+ note[key] = data[key]
568
+ note["updated"] = _now()
569
+ _set("experiment_notes", notes)
570
+ return jsonify(note)
571
+
572
+ return jsonify({"error": "not found"}), 404
573
+
574
+
575
+ @bp.route("/<exp_id>/notes/<note_id>", methods=["DELETE"])
576
+ def delete_note(exp_id, note_id):
577
+ notes = _get("experiment_notes")
578
+ notes = [n for n in notes if not (n["id"] == note_id and n["experiment_id"] == exp_id)]
579
+ _set("experiment_notes", notes)
580
+ return jsonify({"status": "ok"})
581
+
582
+
583
+ # --- Activity Log ---
584
+
585
+ @bp.route("/<exp_id>/activity-log", methods=["GET"])
586
+ def get_activity_log(exp_id):
587
+ """Get activity log entries for an experiment."""
588
+ all_logs = _get("activity_logs")
589
+ # activity_logs is a dict keyed by experiment_id (or empty list on first load)
590
+ if isinstance(all_logs, dict):
591
+ entries = all_logs.get(exp_id, [])
592
+ elif isinstance(all_logs, list) and len(all_logs) == 1 and isinstance(all_logs[0], dict):
593
+ # HF download may wrap dict in a list
594
+ entries = all_logs[0].get(exp_id, [])
595
+ else:
596
+ entries = []
597
+
598
+ # Sort by timestamp descending (most recent first)
599
+ entries.sort(key=lambda e: e.get("timestamp", ""), reverse=True)
600
+
601
+ # Optional filters
602
+ scope = request.args.get("scope")
603
+ entry_type = request.args.get("type")
604
+ if scope:
605
+ entries = [e for e in entries if e.get("scope") == scope]
606
+ if entry_type:
607
+ entries = [e for e in entries if e.get("type") == entry_type]
608
+
609
+ return jsonify(entries)
610
+
611
+
612
+ # --- Artifacts ---
613
+
614
+ @bp.route("/<exp_id>/artifacts", methods=["GET"])
615
+ def get_artifacts(exp_id):
616
+ """Get artifact entries for an experiment from manifest data."""
617
+ all_artifacts = _get("artifacts")
618
+ artifacts = [a for a in all_artifacts if a.get("experiment_id") == exp_id]
619
+ return jsonify(artifacts)
620
+
621
+
622
+ # --- Summary Findings ---
623
+
624
+ @bp.route("/summary", methods=["GET"])
625
+ def get_summary():
626
+ data = _get("summary_findings")
627
+ if data and len(data) > 0:
628
+ return jsonify(data[0])
629
+ return jsonify({"content_md": "", "updated": ""})
630
+
631
+
632
+ # --- Sync & Import ---
633
+
634
+ @bp.route("/sync", methods=["POST"])
635
+ def sync():
636
+ global _dashboard_cache_loaded
637
+ with _lock:
638
+ _cache.clear()
639
+ _cache_loaded.clear()
640
+ _dashboard_cache.clear()
641
+ _dashboard_cache_loaded = False
642
+ for name in FILES:
643
+ _get(name)
644
+ return jsonify({"status": "ok"})
645
+
646
+
647
+ @bp.route("/import", methods=["POST"])
648
+ def import_experiments():
649
+ """Bulk import from experiment.yaml format (as produced by exp-runner)."""
650
+ data = request.get_json()
651
+ items = data if isinstance(data, list) else [data]
652
+ imported = []
653
+
654
+ experiments = _get("experiments")
655
+ runs = _get("runs")
656
+ subs = _get("sub_experiments")
657
+ existing_ids = {e["id"] for e in experiments}
658
+
659
+ for item in items:
660
+ exp_id = item.get("name", "").lower().replace(" ", "_").replace("-", "_")
661
+ if not exp_id:
662
+ continue
663
+
664
+ hypothesis = item.get("hypothesis", {})
665
+ models = item.get("models", [])
666
+ model_names = [m.get("id", "") if isinstance(m, dict) else str(m) for m in models]
667
+
668
+ if exp_id not in existing_ids:
669
+ experiment = {
670
+ "id": exp_id,
671
+ "name": item.get("name", exp_id),
672
+ "research_project": item.get("research_project", ""),
673
+ "hypothesis": {
674
+ "statement": hypothesis.get("statement", "") if isinstance(hypothesis, dict) else str(hypothesis),
675
+ "type": hypothesis.get("type", "exploration") if isinstance(hypothesis, dict) else "exploration",
676
+ "status": hypothesis.get("status", "pending") if isinstance(hypothesis, dict) else "pending",
677
+ "success_criteria": hypothesis.get("success_criteria", "") if isinstance(hypothesis, dict) else "",
678
+ },
679
+ "stage": "active",
680
+ "completeness": 0,
681
+ "models": model_names,
682
+ "tasks": [],
683
+ "tags": item.get("observability", {}).get("tags", []) if isinstance(item.get("observability"), dict) else [],
684
+ "hf_repos": [],
685
+ "wandb_url": "",
686
+ "notes": "",
687
+ "created": item.get("created", _now()),
688
+ "updated": _now(),
689
+ }
690
+ experiments.append(experiment)
691
+ existing_ids.add(exp_id)
692
+
693
+ # Import runs
694
+ for run_data in item.get("runs", []):
695
+ run_id = run_data.get("run_id", f"run_{uuid.uuid4().hex[:8]}")
696
+ if any(r["id"] == run_id and r["experiment_id"] == exp_id for r in runs):
697
+ continue
698
+ run = {
699
+ "id": run_id,
700
+ "experiment_id": exp_id,
701
+ "condition": run_data.get("condition", ""),
702
+ "model": run_data.get("model", ""),
703
+ "cluster": run_data.get("cluster", ""),
704
+ "status": run_data.get("status", "completed"),
705
+ "hf_dataset": run_data.get("hf_dataset", ""),
706
+ "metrics": run_data.get("metrics", {}),
707
+ "timestamp": run_data.get("timestamp", _now()),
708
+ "notes": run_data.get("notes", ""),
709
+ }
710
+ runs.append(run)
711
+
712
+ # Add HF repo to experiment if present
713
+ if run.get("hf_dataset"):
714
+ for exp in experiments:
715
+ if exp["id"] == exp_id:
716
+ existing_repos = {r["repo"] for r in exp.get("hf_repos", [])}
717
+ if run["hf_dataset"] not in existing_repos:
718
+ exp.setdefault("hf_repos", []).append({
719
+ "repo": run["hf_dataset"],
720
+ "description": f"{run['condition']} - {run['model']}",
721
+ "date": run["timestamp"][:10] if run["timestamp"] else "",
722
+ })
723
+
724
+ imported.append(exp_id)
725
+
726
+ _set("experiments", experiments)
727
+ _set("runs", runs)
728
+ _set("sub_experiments", subs)
729
+
730
+ return jsonify({"imported": imported, "count": len(imported)})
backend/api/manifest.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared manifest query API — loads RACA-PROJECT-MANIFEST from HuggingFace.
2
+
3
+ Provides both a utility function (for use by other blueprints) and a
4
+ shared /api/manifest endpoint so any frontend tab can browse runs.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+
11
+ from datasets import load_dataset
12
+ from flask import Blueprint, jsonify, request
13
+ from huggingface_hub import HfApi
14
+
15
+ ORG_NAME = os.environ.get("HF_ORG", "your-org")
16
+ MANIFEST_REPO = f"{ORG_NAME}/RACA-PROJECT-MANIFEST"
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ bp = Blueprint("manifest", __name__, url_prefix="/api/manifest")
21
+
22
+
23
+ def get_manifest():
24
+ """Load RACA-PROJECT-MANIFEST dataset. Returns list of dicts, or None on failure."""
25
+ try:
26
+ ds = load_dataset(MANIFEST_REPO, split="train")
27
+ return [row for row in ds]
28
+ except Exception as e:
29
+ error_str = str(e).lower()
30
+ if any(phrase in error_str for phrase in [
31
+ "doesn't exist", "404", "no data", "corresponds to no data",
32
+ ]):
33
+ return None
34
+ raise
35
+
36
+
37
+ def _get_live_repos() -> set[str]:
38
+ """Return set of dataset repo IDs that actually exist in the org."""
39
+ try:
40
+ api = HfApi()
41
+ return {
42
+ ds.id for ds in api.list_datasets(author=ORG_NAME)
43
+ }
44
+ except Exception as e:
45
+ log.warning("Failed to list org datasets for liveness check: %s", e)
46
+ return set()
47
+
48
+
49
+ def query_runs(prefix: str, validate: bool = True):
50
+ """Query manifest for datasets matching a name prefix. Returns list of run dicts.
51
+
52
+ Args:
53
+ prefix: filter by dataset_name prefix (empty string = all).
54
+ validate: if True, filter out datasets that no longer exist on HF.
55
+ """
56
+ manifest = get_manifest()
57
+ if manifest is None:
58
+ return []
59
+
60
+ live_repos = _get_live_repos() if validate else set()
61
+
62
+ runs = []
63
+ for row in manifest:
64
+ name = row.get("dataset_name", "")
65
+ if prefix and not name.startswith(prefix):
66
+ continue
67
+ repo = f"{ORG_NAME}/{name}"
68
+ if validate and live_repos and repo not in live_repos:
69
+ continue
70
+ tags = row.get("tags", [])
71
+ if isinstance(tags, str):
72
+ try:
73
+ tags = json.loads(tags)
74
+ except (json.JSONDecodeError, TypeError):
75
+ tags = []
76
+ metadata = row.get("custom_metadata", "{}")
77
+ if isinstance(metadata, str):
78
+ try:
79
+ metadata = json.loads(metadata)
80
+ except (json.JSONDecodeError, TypeError):
81
+ metadata = {}
82
+ runs.append({
83
+ "dataset_name": name,
84
+ "repo": repo,
85
+ "tags": tags,
86
+ "metadata": metadata,
87
+ "created_at": row.get("created", ""),
88
+ })
89
+ return runs
90
+
91
+
92
+ @bp.route("/query", methods=["GET"])
93
+ def query_endpoint():
94
+ """Generic manifest query — any tab can use this.
95
+
96
+ Query params:
97
+ prefix: filter datasets by name prefix (e.g. 'my-experiment-')
98
+ """
99
+ prefix = request.args.get("prefix", "")
100
+ try:
101
+ runs = query_runs(prefix)
102
+ return jsonify(runs)
103
+ except Exception as e:
104
+ return jsonify({"error": f"Failed to query manifest: {e}"}), 500
backend/api/model_datasets.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import hashlib
4
+ from flask import Blueprint, request, jsonify
5
+ from datasets import load_dataset, Dataset
6
+
7
+ bp = Blueprint("model_datasets", __name__, url_prefix="/api/model/datasets")
8
+
9
+ # In-memory cache: id -> {dataset, repo, column, split, n_rows, n_samples}
10
+ _cache: dict[str, dict] = {}
11
+
12
+
13
+ def _make_id(repo: str, column: str, split: str) -> str:
14
+ key = f"{repo}:{column}:{split}"
15
+ return hashlib.md5(key.encode()).hexdigest()[:12]
16
+
17
+
18
+ def _load_hf_dataset(repo: str, split: str) -> Dataset:
19
+ if os.path.exists(repo):
20
+ return Dataset.from_parquet(repo)
21
+ return load_dataset(repo, split=split)
22
+
23
+
24
+ def _detect_response_column(columns: list[str], preferred: str) -> str:
25
+ if preferred and preferred in columns:
26
+ return preferred
27
+ for fallback in ["model_responses", "model_response", "response", "responses", "output", "outputs", "completion", "messages"]:
28
+ if fallback in columns:
29
+ return fallback
30
+ # Last resort: return preferred if set, otherwise first column
31
+ return preferred if preferred else (columns[0] if columns else "")
32
+
33
+
34
+ def _detect_prompt_column(columns: list[str], preferred: str) -> str | None:
35
+ if preferred and preferred in columns:
36
+ return preferred
37
+ for fallback in ["formatted_prompt", "prompt", "question", "input", "instruction"]:
38
+ if fallback in columns:
39
+ return fallback
40
+ return None
41
+
42
+
43
+ def _compute_question_fingerprint(ds: Dataset, n: int = 5) -> str:
44
+ """Hash first N question texts to fingerprint the question set."""
45
+ questions = []
46
+ for i in range(min(n, len(ds))):
47
+ row = ds[i]
48
+ for qcol in ["question", "prompt", "input", "formatted_prompt"]:
49
+ if qcol in row:
50
+ questions.append(str(row[qcol] or "")[:200])
51
+ break
52
+ return hashlib.md5("||".join(questions).encode()).hexdigest()[:8]
53
+
54
+
55
+ def _compute_chat_fingerprint(ds: Dataset, column: str, n: int = 5) -> str:
56
+ """Hash first N user messages from chat-format data to fingerprint the question set."""
57
+ prompts = []
58
+ for i in range(min(n, len(ds))):
59
+ row = ds[i]
60
+ messages = row[column]
61
+ if _is_chat_messages(messages):
62
+ _, user_prompt, _ = _extract_chat_messages(messages)
63
+ prompts.append(user_prompt[:200])
64
+ else:
65
+ prompts.append("")
66
+ return hashlib.md5("||".join(prompts).encode()).hexdigest()[:8]
67
+
68
+
69
+ def _count_samples(ds: Dataset, column: str) -> int:
70
+ if len(ds) == 0:
71
+ return 0
72
+ first = ds[0][column]
73
+ if isinstance(first, list):
74
+ return len(first)
75
+ return 1
76
+
77
+
78
+ def _flatten_evals(evals) -> list[bool]:
79
+ if not isinstance(evals, list):
80
+ return [bool(evals)]
81
+ return [
82
+ bool(e[-1]) if isinstance(e, list) and len(e) > 0
83
+ else (bool(e) if not isinstance(e, list) else False)
84
+ for e in evals
85
+ ]
86
+
87
+
88
+ def _is_chat_messages(value) -> bool:
89
+ """Check if a column value is in chat format (list of role/content dicts)."""
90
+ if not isinstance(value, list) or len(value) == 0:
91
+ return False
92
+ first = value[0]
93
+ return isinstance(first, dict) and "role" in first and "content" in first
94
+
95
+
96
+ def _extract_chat_messages(messages: list[dict]) -> tuple[str, str, str]:
97
+ """Extract (system, user_prompt, assistant_response) from chat messages."""
98
+ system = ""
99
+ user_prompt = ""
100
+ assistant_response = ""
101
+ for msg in messages:
102
+ role = msg.get("role", "")
103
+ content = msg.get("content", "") or ""
104
+ if role == "system":
105
+ system = content
106
+ elif role == "user":
107
+ user_prompt = content
108
+ elif role == "assistant":
109
+ assistant_response = content
110
+ return system, user_prompt, assistant_response
111
+
112
+
113
+ def _extract_reasoning(meta: dict | None) -> str | None:
114
+ """Extract reasoning/thinking content from response metadata's raw_response."""
115
+ if not meta or not isinstance(meta, dict):
116
+ return None
117
+ raw = meta.get("raw_response")
118
+ if not raw or not isinstance(raw, dict):
119
+ return None
120
+ try:
121
+ msg = raw["choices"][0]["message"]
122
+ return (
123
+ msg.get("reasoning_content")
124
+ or msg.get("thinking")
125
+ or msg.get("reasoning")
126
+ )
127
+ except (KeyError, IndexError, TypeError):
128
+ return None
129
+
130
+
131
+ def _merge_reasoning_into_response(response: str, reasoning: str | None) -> str:
132
+ """Prepend <think>{reasoning}</think> to response if reasoning exists
133
+ and isn't already present in the response."""
134
+ if not reasoning:
135
+ return response or ""
136
+ response = response or ""
137
+ # Don't double-add if response already contains the thinking
138
+ if "<think>" in response:
139
+ return response
140
+ return f"<think>{reasoning}</think>\n{response}"
141
+
142
+
143
+ def _analyze_trace(text: str) -> dict:
144
+ if not text:
145
+ return dict(total_len=0, think_len=0, answer_len=0,
146
+ backtracks=0, restarts=0, think_text="", answer_text="")
147
+ think_end = text.find("</think>")
148
+ if think_end > 0:
149
+ # Keep raw tags so display is 1:1 with HuggingFace data
150
+ think_text = text[:think_end + 8] # include </think>
151
+ answer_text = text[think_end + 8:].strip()
152
+ else:
153
+ think_text = text
154
+ answer_text = ""
155
+ t = text.lower()
156
+ backtracks = sum(t.count(w) for w in
157
+ ["wait,", "wait ", "hmm", "let me try", "try again",
158
+ "another approach", "let me reconsider"])
159
+ restarts = sum(t.count(w) for w in
160
+ ["start over", "fresh approach", "different approach", "from scratch"])
161
+ return dict(total_len=len(text), think_len=len(think_text),
162
+ answer_len=len(answer_text), backtracks=backtracks,
163
+ restarts=restarts, think_text=think_text, answer_text=answer_text)
164
+
165
+
166
+ @bp.route("/load", methods=["POST"])
167
+ def load_dataset_endpoint():
168
+ data = request.get_json()
169
+ repo = data.get("repo", "").strip()
170
+ if not repo:
171
+ return jsonify({"error": "repo is required"}), 400
172
+
173
+ split = data.get("split", "train")
174
+ preferred_column = data.get("column") or ""
175
+ preferred_prompt_column = data.get("prompt_column") or ""
176
+
177
+ try:
178
+ ds = _load_hf_dataset(repo, split)
179
+ except Exception as e:
180
+ return jsonify({"error": f"Failed to load dataset: {e}"}), 400
181
+
182
+ columns = ds.column_names
183
+ column = _detect_response_column(columns, preferred_column)
184
+ prompt_column = _detect_prompt_column(columns, preferred_prompt_column)
185
+
186
+ if column not in columns:
187
+ return jsonify({
188
+ "error": f"Column '{column}' not found. Available: {columns}"
189
+ }), 400
190
+
191
+ # Detect chat messages format (list of role/content dicts)
192
+ is_chat = False
193
+ if len(ds) > 0 and column in ds.column_names:
194
+ first_val = ds[0][column]
195
+ is_chat = _is_chat_messages(first_val)
196
+
197
+ n_samples = 1 if is_chat else _count_samples(ds, column)
198
+ ds_id = _make_id(repo, column, split)
199
+
200
+ if is_chat:
201
+ # For chat format, fingerprint based on the user message content
202
+ fingerprint = _compute_chat_fingerprint(ds, column)
203
+ else:
204
+ fingerprint = _compute_question_fingerprint(ds)
205
+
206
+ _cache[ds_id] = {
207
+ "dataset": ds,
208
+ "repo": repo,
209
+ "column": column,
210
+ "prompt_column": prompt_column,
211
+ "split": split,
212
+ "n_rows": len(ds),
213
+ "n_samples": n_samples,
214
+ "question_fingerprint": fingerprint,
215
+ "is_chat": is_chat,
216
+ }
217
+
218
+ short_name = repo.rsplit("/", 1)[-1] if "/" in repo else repo
219
+
220
+ return jsonify({
221
+ "id": ds_id,
222
+ "repo": repo,
223
+ "name": short_name,
224
+ "column": column,
225
+ "prompt_column": prompt_column,
226
+ "columns": columns,
227
+ "split": split,
228
+ "n_rows": len(ds),
229
+ "n_samples": n_samples,
230
+ "question_fingerprint": fingerprint,
231
+ })
232
+
233
+
234
+ @bp.route("/", methods=["GET"])
235
+ def list_datasets():
236
+ result = []
237
+ for ds_id, info in _cache.items():
238
+ result.append({
239
+ "id": ds_id,
240
+ "repo": info["repo"],
241
+ "name": info["repo"].rsplit("/", 1)[-1] if "/" in info["repo"] else info["repo"],
242
+ "column": info["column"],
243
+ "split": info["split"],
244
+ "n_rows": info["n_rows"],
245
+ "n_samples": info["n_samples"],
246
+ "question_fingerprint": info.get("question_fingerprint", ""),
247
+ })
248
+ return jsonify(result)
249
+
250
+
251
+ @bp.route("/<ds_id>/question/<int:idx>", methods=["GET"])
252
+ def get_question(ds_id, idx):
253
+ if ds_id not in _cache:
254
+ return jsonify({"error": "Dataset not loaded"}), 404
255
+
256
+ info = _cache[ds_id]
257
+ ds = info["dataset"]
258
+ column = info["column"]
259
+
260
+ if idx < 0 or idx >= len(ds):
261
+ return jsonify({"error": f"Index {idx} out of range (0-{len(ds)-1})"}), 400
262
+
263
+ row = ds[idx]
264
+ is_chat = info.get("is_chat", False)
265
+
266
+ if is_chat:
267
+ # Chat messages format: extract assistant response, user prompt, system
268
+ messages = row[column]
269
+ system_msg, user_prompt, assistant_response = _extract_chat_messages(messages)
270
+ responses_raw = [assistant_response]
271
+ prompt_text = user_prompt
272
+ question = user_prompt
273
+ else:
274
+ responses_raw = row[column]
275
+ if not isinstance(responses_raw, list):
276
+ responses_raw = [responses_raw]
277
+
278
+ # Check for {column}__metadata to recover reasoning/thinking content
279
+ meta_column = f"{column}__metadata"
280
+ response_metas = None
281
+ if meta_column in row:
282
+ response_metas = row[meta_column]
283
+ if not isinstance(response_metas, list):
284
+ response_metas = [response_metas]
285
+
286
+ # Merge reasoning from metadata into responses
287
+ merged_responses = []
288
+ for i, resp in enumerate(responses_raw):
289
+ meta = response_metas[i] if response_metas and i < len(response_metas) else None
290
+ reasoning = _extract_reasoning(meta)
291
+ merged_responses.append(_merge_reasoning_into_response(resp, reasoning))
292
+ responses_raw = merged_responses
293
+
294
+ # Prompt text from configured prompt column
295
+ prompt_text = ""
296
+ prompt_col = info.get("prompt_column")
297
+ if prompt_col and prompt_col in row:
298
+ val = row[prompt_col]
299
+ if isinstance(val, str):
300
+ prompt_text = val
301
+ elif isinstance(val, list):
302
+ prompt_text = json.dumps(val)
303
+ elif val is not None:
304
+ prompt_text = str(val)
305
+
306
+ question = ""
307
+ for qcol in ["question", "prompt", "input", "problem", "formatted_prompt"]:
308
+ if qcol in row:
309
+ val = row[qcol] or ""
310
+ if isinstance(val, str):
311
+ question = val
312
+ elif isinstance(val, list):
313
+ question = json.dumps(val)
314
+ else:
315
+ question = str(val)
316
+ break
317
+
318
+ eval_correct = []
319
+ if "eval_correct" in row:
320
+ eval_correct = _flatten_evals(row["eval_correct"])
321
+ elif "correct" in row:
322
+ eval_correct = _flatten_evals(row["correct"])
323
+
324
+ # Check extractions with column-aware name
325
+ extractions = []
326
+ extractions_col = f"{column}__extractions"
327
+ for ecol in [extractions_col, "response__extractions"]:
328
+ if ecol in row:
329
+ ext = row[ecol]
330
+ if isinstance(ext, list):
331
+ extractions = [str(e) for e in ext]
332
+ break
333
+
334
+ metadata = {}
335
+ if "metadata" in row:
336
+ metadata = row["metadata"] if isinstance(row["metadata"], dict) else {}
337
+
338
+ analyses = [_analyze_trace(r or "") for r in responses_raw]
339
+
340
+ return jsonify({
341
+ "question": question,
342
+ "prompt_text": prompt_text,
343
+ "responses": [r or "" for r in responses_raw],
344
+ "eval_correct": eval_correct,
345
+ "extractions": extractions,
346
+ "metadata": metadata,
347
+ "analyses": analyses,
348
+ "n_samples": len(responses_raw),
349
+ "index": idx,
350
+ })
351
+
352
+
353
+ @bp.route("/<ds_id>/summary", methods=["GET"])
354
+ def get_summary(ds_id):
355
+ if ds_id not in _cache:
356
+ return jsonify({"error": "Dataset not loaded"}), 404
357
+
358
+ info = _cache[ds_id]
359
+ ds = info["dataset"]
360
+ n_rows = info["n_rows"]
361
+ n_samples = info["n_samples"]
362
+
363
+ # Support both "eval_correct" and "correct" column names
364
+ eval_col = None
365
+ if "eval_correct" in ds.column_names:
366
+ eval_col = "eval_correct"
367
+ elif "correct" in ds.column_names:
368
+ eval_col = "correct"
369
+
370
+ if eval_col is None:
371
+ return jsonify({
372
+ "n_rows": n_rows,
373
+ "n_samples": n_samples,
374
+ "has_eval": False,
375
+ })
376
+
377
+ pass_at = {}
378
+ for k in [1, 2, 4, 8]:
379
+ if k > n_samples:
380
+ break
381
+ correct = sum(1 for i in range(n_rows)
382
+ if any(_flatten_evals(ds[i][eval_col])[:k]))
383
+ pass_at[k] = {"correct": correct, "total": n_rows,
384
+ "rate": correct / n_rows if n_rows > 0 else 0}
385
+
386
+ total_samples = n_rows * n_samples
387
+ total_correct = sum(
388
+ sum(_flatten_evals(ds[i][eval_col]))
389
+ for i in range(n_rows)
390
+ )
391
+
392
+ return jsonify({
393
+ "n_rows": n_rows,
394
+ "n_samples": n_samples,
395
+ "has_eval": True,
396
+ "sample_accuracy": {
397
+ "correct": total_correct,
398
+ "total": total_samples,
399
+ "rate": total_correct / total_samples if total_samples > 0 else 0,
400
+ },
401
+ "pass_at": pass_at,
402
+ })
403
+
404
+
405
+ @bp.route("/<ds_id>", methods=["DELETE"])
406
+ def unload_dataset(ds_id):
407
+ if ds_id in _cache:
408
+ del _cache[ds_id]
409
+ return jsonify({"status": "ok"})
backend/api/plan_revisions.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from flask import Blueprint, jsonify
3
+ from datasets import load_dataset
4
+
5
+ bp = Blueprint("plan_revisions", __name__, url_prefix="/api/plan-revisions")
6
+
7
+ HF_REPO = "timchen0618/bcp-plan-revisions-v1"
8
+
9
+ _cache: dict | None = None
10
+
11
+
12
+ def _load():
13
+ global _cache
14
+ if _cache is not None:
15
+ return _cache
16
+
17
+ ds = load_dataset(HF_REPO, split="train")
18
+
19
+ # Group: condition -> query_id -> sorted list of revision entries
20
+ grouped: dict[str, dict[str, list]] = {}
21
+ for row in ds:
22
+ cond = row["condition"]
23
+ qid = str(row["query_id"])
24
+ grouped.setdefault(cond, {}).setdefault(qid, [])
25
+ grouped[cond][qid].append({
26
+ "revision_index": row["revision_index"],
27
+ "source": row["source"],
28
+ "plan_text": row["plan_text"],
29
+ "total_revisions": row["total_revisions"],
30
+ "status": row["status"],
31
+ })
32
+
33
+ # Sort revisions within each group
34
+ for cond in grouped:
35
+ for qid in grouped[cond]:
36
+ grouped[cond][qid].sort(key=lambda r: r["revision_index"])
37
+
38
+ conditions = sorted(grouped.keys())
39
+ _cache = {"conditions": conditions, "data": grouped}
40
+ return _cache
41
+
42
+
43
+ @bp.get("/")
44
+ def get_data():
45
+ try:
46
+ result = _load()
47
+ return jsonify(result)
48
+ except Exception as e:
49
+ return jsonify({"error": str(e)}), 500
50
+
51
+
52
+ @bp.post("/reload")
53
+ def reload_data():
54
+ global _cache
55
+ _cache = None
56
+ try:
57
+ result = _load()
58
+ return jsonify({"status": "ok", "conditions": result["conditions"]})
59
+ except Exception as e:
60
+ return jsonify({"error": str(e)}), 500
backend/api/presets.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import uuid
4
+ import tempfile
5
+ import threading
6
+ from flask import Blueprint, request, jsonify
7
+
8
+ bp = Blueprint("presets", __name__, url_prefix="/api/presets")
9
+
10
+ HF_ORG = os.environ.get("HF_ORG", "your-org")
11
+ PRESETS_REPO = f"{HF_ORG}/RACA-VIS-PRESETS"
12
+ VALID_TYPES = {"model"}
13
+ LOCAL_PRESETS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "presets")
14
+
15
+ # In-memory cache: vis_type -> list[dict]
16
+ _cache: dict[str, list[dict]] = {}
17
+ _cache_loaded: set[str] = set()
18
+ _lock = threading.Lock()
19
+
20
+
21
+ def _ensure_local_dir():
22
+ os.makedirs(LOCAL_PRESETS_DIR, exist_ok=True)
23
+
24
+
25
+ def _local_path(vis_type: str) -> str:
26
+ _ensure_local_dir()
27
+ return os.path.join(LOCAL_PRESETS_DIR, f"{vis_type}_presets.json")
28
+
29
+
30
+ def _download_presets(vis_type: str) -> list[dict]:
31
+ """Download presets from HuggingFace, falling back to local file."""
32
+ try:
33
+ from huggingface_hub import hf_hub_download
34
+ path = hf_hub_download(
35
+ PRESETS_REPO,
36
+ f"{vis_type}_presets.json",
37
+ repo_type="dataset",
38
+ )
39
+ with open(path) as f:
40
+ presets = json.load(f)
41
+ # Cache locally for offline fallback
42
+ with open(_local_path(vis_type), "w") as f:
43
+ json.dump(presets, f, indent=2)
44
+ return presets
45
+ except Exception:
46
+ # Fall back to local cache
47
+ local = _local_path(vis_type)
48
+ if os.path.exists(local):
49
+ with open(local) as f:
50
+ return json.load(f)
51
+ return []
52
+
53
+
54
+ def _upload_presets(vis_type: str, presets: list[dict]):
55
+ """Upload presets to HuggingFace (best-effort, non-blocking)."""
56
+ # Always save locally first
57
+ with open(_local_path(vis_type), "w") as f:
58
+ json.dump(presets, f, indent=2)
59
+
60
+ def _do_upload():
61
+ try:
62
+ from huggingface_hub import HfApi
63
+ api = HfApi()
64
+ # Ensure repo exists
65
+ try:
66
+ api.create_repo(
67
+ PRESETS_REPO,
68
+ repo_type="dataset",
69
+ exist_ok=True,
70
+ )
71
+ except Exception:
72
+ pass
73
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
74
+ json.dump(presets, f, indent=2)
75
+ tmp = f.name
76
+ api.upload_file(
77
+ path_or_fileobj=tmp,
78
+ path_in_repo=f"{vis_type}_presets.json",
79
+ repo_id=PRESETS_REPO,
80
+ repo_type="dataset",
81
+ )
82
+ os.unlink(tmp)
83
+ except Exception as e:
84
+ print(f"[presets] HF upload failed for {vis_type}: {e}")
85
+
86
+ threading.Thread(target=_do_upload, daemon=True).start()
87
+
88
+
89
+ def _get_presets(vis_type: str) -> list[dict]:
90
+ """Get presets for a visualizer type, downloading if needed."""
91
+ with _lock:
92
+ if vis_type not in _cache_loaded:
93
+ _cache[vis_type] = _download_presets(vis_type)
94
+ _cache_loaded.add(vis_type)
95
+ return list(_cache.get(vis_type, []))
96
+
97
+
98
+ def _set_presets(vis_type: str, presets: list[dict]):
99
+ """Update presets in cache and sync to HF."""
100
+ with _lock:
101
+ _cache[vis_type] = presets
102
+ _cache_loaded.add(vis_type)
103
+ _upload_presets(vis_type, presets)
104
+
105
+
106
+ @bp.route("/<vis_type>", methods=["GET"])
107
+ def list_presets(vis_type):
108
+ if vis_type not in VALID_TYPES:
109
+ return jsonify({"error": f"Invalid type. Must be one of: {VALID_TYPES}"}), 400
110
+ return jsonify(_get_presets(vis_type))
111
+
112
+
113
+ @bp.route("/<vis_type>", methods=["POST"])
114
+ def create_preset(vis_type):
115
+ if vis_type not in VALID_TYPES:
116
+ return jsonify({"error": f"Invalid type. Must be one of: {VALID_TYPES}"}), 400
117
+
118
+ data = request.get_json()
119
+ name = data.get("name", "").strip()
120
+
121
+ if not name:
122
+ return jsonify({"error": "name is required"}), 400
123
+
124
+ preset = {
125
+ "id": uuid.uuid4().hex[:8],
126
+ "name": name,
127
+ }
128
+ # Include type-specific fields
129
+ repo = data.get("repo", "").strip()
130
+ if not repo:
131
+ return jsonify({"error": "repo is required"}), 400
132
+ preset["repo"] = repo
133
+ preset["split"] = data.get("split", "train")
134
+
135
+ if vis_type == "model":
136
+ preset["column"] = data.get("column", "model_responses")
137
+
138
+ presets = _get_presets(vis_type)
139
+ presets.append(preset)
140
+ _set_presets(vis_type, presets)
141
+
142
+ return jsonify(preset), 201
143
+
144
+
145
+ @bp.route("/<vis_type>/<preset_id>", methods=["PUT"])
146
+ def update_preset(vis_type, preset_id):
147
+ if vis_type not in VALID_TYPES:
148
+ return jsonify({"error": f"Invalid type. Must be one of: {VALID_TYPES}"}), 400
149
+
150
+ data = request.get_json()
151
+ presets = _get_presets(vis_type)
152
+
153
+ for p in presets:
154
+ if p["id"] == preset_id:
155
+ if "name" in data:
156
+ p["name"] = data["name"].strip()
157
+ if "column" in data:
158
+ p["column"] = data["column"]
159
+ if "split" in data:
160
+ p["split"] = data["split"]
161
+ if "config" in data:
162
+ p["config"] = data["config"]
163
+ _set_presets(vis_type, presets)
164
+ return jsonify(p)
165
+
166
+ return jsonify({"error": "not found"}), 404
167
+
168
+
169
+ @bp.route("/<vis_type>/<preset_id>", methods=["DELETE"])
170
+ def delete_preset(vis_type, preset_id):
171
+ if vis_type not in VALID_TYPES:
172
+ return jsonify({"error": f"Invalid type. Must be one of: {VALID_TYPES}"}), 400
173
+
174
+ presets = _get_presets(vis_type)
175
+ presets = [p for p in presets if p["id"] != preset_id]
176
+ _set_presets(vis_type, presets)
177
+ return jsonify({"status": "ok"})
178
+
179
+
180
+ @bp.route("/sync", methods=["POST"])
181
+ def sync_presets():
182
+ """Force re-download presets from HF."""
183
+ with _lock:
184
+ _cache.clear()
185
+ _cache_loaded.clear()
186
+ for vt in VALID_TYPES:
187
+ _get_presets(vt)
188
+ return jsonify({"status": "ok"})
backend/app.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask
2
+ from flask_cors import CORS
3
+
4
+
5
+ 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, presets, experiments, manifest, plan_revisions
10
+ app.register_blueprint(model_datasets.bp)
11
+ app.register_blueprint(presets.bp)
12
+ app.register_blueprint(experiments.bp)
13
+ app.register_blueprint(manifest.bp)
14
+ app.register_blueprint(plan_revisions.bp)
15
+
16
+ @app.route("/api/health")
17
+ def health():
18
+ return {"status": "ok"}
19
+
20
+ @app.route("/", defaults={"path": ""})
21
+ @app.route("/<path:path>")
22
+ def serve_frontend(path):
23
+ return app.send_static_file("index.html")
24
+
25
+ return app
26
+
27
+
28
+ app = create_app()
29
+
30
+
31
+ def main():
32
+ app.run(debug=True, port=8080)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
backend/data/activity_logs.json ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "onboarding": [
3
+ {
4
+ "timestamp": "2026-04-06T00:00:00Z",
5
+ "type": "milestone",
6
+ "scope": "experiment",
7
+ "author": "agent",
8
+ "message": "Experiment design complete: Qwen3-1.7B on Countdown, 10-sample canary, torch cluster"
9
+ },
10
+ {
11
+ "timestamp": "2026-04-06T00:01:00Z",
12
+ "type": "milestone",
13
+ "scope": "experiment",
14
+ "author": "agent",
15
+ "message": "Red-team review passed. Key checks: max_tokens=4096 (marginal, monitor for truncation), prompt format verified against reference, evaluation must use CountdownJudge AST-based eval (not string match). Output schema defined with 8 columns including finish_reason."
16
+ },
17
+ {
18
+ "timestamp": "2026-04-06T00:05:00Z",
19
+ "type": "milestone",
20
+ "scope": "job",
21
+ "author": "agent",
22
+ "message": "Canary job 5550586 submitted to torch (h200_courant, 1x H200, 30min). Qwen3-1.7B on 10 Countdown problems. Results \u2192 timchen0618/onboarding-countdown-qwen3-1.7b"
23
+ },
24
+ {
25
+ "timestamp": "2026-04-06T14:22:00Z",
26
+ "type": "note",
27
+ "scope": "job",
28
+ "author": "agent",
29
+ "message": "Job 5550712 failed: OOM during vLLM CUDA graph compilation. Fixed: added enforce_eager=True to skip compilation. Resubmitted as job 5552202."
30
+ },
31
+ {
32
+ "timestamp": "2026-04-06T14:22:00Z",
33
+ "type": "milestone",
34
+ "scope": "job",
35
+ "author": "agent",
36
+ "message": "Job 5552202 submitted to torch (enforce_eager=True fix applied)"
37
+ },
38
+ {
39
+ "timestamp": "2026-04-06T14:36:00Z",
40
+ "type": "note",
41
+ "scope": "job",
42
+ "author": "agent",
43
+ "message": "Job 5552202 failed: OOM (CPU RAM) from vLLM spawn subprocess. Switched to transformers AutoModelForCausalLM \u2014 no subprocess needed. Resubmitted as job 5552573."
44
+ },
45
+ {
46
+ "timestamp": "2026-04-06T15:05:00Z",
47
+ "type": "result",
48
+ "scope": "artifact",
49
+ "author": "agent",
50
+ "message": "Job 5553179 completed: 10/10 samples generated. Evaluator bugs fixed (last <answer> block extraction + cascaded step validation). Re-scored: 10/10 correct (100%). Uploaded to timchen0618/onboarding-countdown-qwen3-1.7b."
51
+ }
52
+ ]
53
+ }
backend/data/artifacts.json ADDED
@@ -0,0 +1 @@
 
 
1
+ []
backend/data/experiment_notes.json ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "onboarding__note__Users_hungting_Desktop_Lab_Projects_notes_experiments_onboarding_EXPERIMENT_README_md",
4
+ "experiment_id": "onboarding",
5
+ "title": "EXPERIMENT_README.md",
6
+ "filename": "EXPERIMENT_README.md",
7
+ "relative_path": "/Users/hungting/Desktop/Lab/Projects/notes/experiments/onboarding/EXPERIMENT_README.md",
8
+ "content_md": "# Welcome to RACA\n\nThis is a sample experiment to show you how the dashboard works. You're looking at the **Overview** tab right now \u2014 it displays the experiment's README (this file).\n\nEverything you see here is generated from plain files in `notes/experiments/onboarding/`. You can browse them in your editor anytime.\n\n## How This Dashboard Works\n\nEach experiment has several tabs at the top. Here's what they do:\n\n### Overview (you are here)\n\nDisplays the experiment's README and any notes you've written in the `user/` folder. This is the main landing page for each experiment \u2014 a summary of what the experiment is, what you're investigating, and what you found.\n\n### Red Team Brief\n\nBefore any experiment runs, RACA reviews the design for potential problems \u2014 wrong evaluation metrics, truncated outputs, missing baselines, wasted compute. The brief lives at `red_team_brief.md`. This tab will be empty until you run your first real experiment.\n\n### Timeline\n\nA chronological log of everything that happened: when jobs were submitted, when artifacts were uploaded, when bugs were found and fixed. This is auto-generated from `activity_log.jsonl` \u2014 RACA writes to it as events happen.\n\n### Runs\n\nTracks each job submission \u2014 which model, which cluster, what status (pending, running, completed, failed), and links to the HuggingFace dataset with the results. Empty until you run something.\n\n### Artifacts\n\nLinks to all HuggingFace datasets produced by this experiment \u2014 canary runs, partial results, final data. Each artifact has metadata about what generated it. Empty until artifacts are uploaded.\n\n### Files\n\nAll the markdown and YAML files in the experiment folder. Click any file to read it. This is a quick way to browse the experiment's configuration and notes without leaving the dashboard.\n\n## Folder Structure\n\n```\nnotes/experiments/onboarding/\n EXPERIMENT_README.md \u2190 this file (shows in Overview tab)\n experiment.yaml \u2190 config: hypothesis, models, tasks\n flow_state.json \u2190 current phase (design/running/complete)\n HUGGINGFACE_REPOS.md \u2190 links to all uploaded datasets\n questions.md \u2190 research questions (read-only)\n red_team_brief.md \u2190 created during preflight review\n activity_log.jsonl \u2190 timeline entries (auto-generated)\n user/ \u2190 YOUR notes \u2014 RACA doesn't touch these\n README.md \u2190 your interpretation and observations\n FINDINGS.md \u2190 key results and surprises\n DECISIONS.md \u2190 design decisions and rationale\n summary.md \u2190 one-paragraph summary when done\n```\n\n**Most of this is automated.** RACA creates and updates the experiment files, uploads artifacts, and keeps the timeline current. The only files you write are in `user/` \u2014 that's your space for notes, findings, and decisions.\n\n## What's Next\n\nThis sample experiment hasn't been run yet \u2014 it's just here to show you the structure. When you're ready to run a real experiment, just tell RACA:\n\n> *I want to test whether Qwen3-8B follows complex instructions better than Llama-3.1-8B*\n\nOr try the full guided tutorial:\n\n> */raca:experiment-tutorial*\n",
9
+ "created": "",
10
+ "updated": ""
11
+ },
12
+ {
13
+ "id": "onboarding__note__Users_hungting_Desktop_Lab_Projects_notes_experiments_onboarding_HUGGINGFACE_REPOS_md",
14
+ "experiment_id": "onboarding",
15
+ "title": "HUGGINGFACE_REPOS.md",
16
+ "filename": "HUGGINGFACE_REPOS.md",
17
+ "relative_path": "/Users/hungting/Desktop/Lab/Projects/notes/experiments/onboarding/HUGGINGFACE_REPOS.md",
18
+ "content_md": "# HuggingFace Repositories\n\n| Dataset | Date | Rows | Purpose |\n|---------|------|------|---------|\n| [onboarding-countdown-qwen3-1.7b \u2014 10 rows, 10/10 correct (2026-04-06)](https://huggingface.co/datasets/timchen0618/onboarding-countdown-qwen3-1.7b) | 2026-04-06 | 10 | Qwen3-1.7B on Countdown (3-4 operands, targets 1-99, seed=42) |\n",
19
+ "created": "",
20
+ "updated": ""
21
+ },
22
+ {
23
+ "id": "onboarding__note__Users_hungting_Desktop_Lab_Projects_notes_experiments_onboarding_questions_md",
24
+ "experiment_id": "onboarding",
25
+ "title": "questions.md",
26
+ "filename": "questions.md",
27
+ "relative_path": "/Users/hungting/Desktop/Lab/Projects/notes/experiments/onboarding/questions.md",
28
+ "content_md": "# Research Questions\n\n1. Can Qwen3-1.7B solve basic Countdown problems (4 numbers, targets < 100)?\n2. What reasoning strategies does the model use (trial-and-error, systematic search, pattern matching)?\n3. Where does the model fail \u2014 wrong arithmetic, giving up, or invalid expressions?\n",
29
+ "created": "",
30
+ "updated": ""
31
+ },
32
+ {
33
+ "id": "onboarding__note__Users_hungting_Desktop_Lab_Projects_notes_experiments_onboarding_red_team_brief_md",
34
+ "experiment_id": "onboarding",
35
+ "title": "red_team_brief.md",
36
+ "filename": "red_team_brief.md",
37
+ "relative_path": "/Users/hungting/Desktop/Lab/Projects/notes/experiments/onboarding/red_team_brief.md",
38
+ "content_md": "# Red Team Brief \u2014 onboarding\n\n**Experiment:** Qwen3-1.7B on Countdown (tutorial/canary)\n**Reviewer:** agent\n**Date:** 2026-04-06\n**Status:** PASS\n\n---\n\n## Hypothesis\n\n> Qwen3-1.7B can solve basic Countdown arithmetic problems (>50% accuracy on 3-4 operand problems)\n\nThis is an exploratory baseline \u2014 a reasonable first question. No prior published result exists for Qwen3-1.7B specifically on this exact config, so the hypothesis is testable and non-trivial.\n\n---\n\n## Config Review\n\n### Model\n- **Qwen/Qwen3-1.7B** \u2014 small instruct model, appropriate for a tutorial canary\n- Known to handle arithmetic reasoning; 1.7B is at the edge of competence for Countdown (expect 20-50% accuracy)\n\n### max_tokens: 4096\n- **Status: PASS (marginal)**\n- Reference minimum is 2048-4096. We are at the ceiling of \"minimum\". For Qwen3-1.7B reasoning traces, 4096 should be sufficient for 3-4 operand problems.\n- **Watch for truncation in outputs** \u2014 if `finish_reason == \"length\"` appears in any response, flag immediately.\n\n### Prompt Format\n- **Must use the CoT + in-context examples prompt from the reference** (not the TinyZero `<think>` variant, since we are not RL-training)\n- Template:\n ```\n Answer the following problem. Explain your reasoning step by step. When you are finished, give your answer in this format: <answer>(your answer)</answer>.\n \n # Problem\n Using the numbers in the list [{numbers}], create an equation that equals {target}. ...\n ```\n- Answer extraction: regex on `<answer>...</answer>` tags\n\n### Evaluation Method\n- **Must use `CountdownJudge.validate_countdown_solution()` \u2014 NOT string matching**\n- Located at: `packages/custom_evaluations/custom_evaluations/sources/countdown/countdown_judge.py`\n- Handles: AST-based evaluation, Unicode operator normalization, multiset number validation\n- **Failure mode if string match is used:** \"3 + 5\" and \"5 + 3\" would score differently. This is wrong.\n\n### Dataset Generation\n- 10 samples (canary size \u2014 fine)\n- Use forward generation (inline, no dependencies): 3-4 operands, targets 1-99\n- Do NOT require all numbers used \u2014 subset is sufficient (this is the easier, more standard setting)\n\n---\n\n## Failure Modes & Mitigations\n\n| Risk | Severity | Mitigation |\n|------|----------|------------|\n| Truncated outputs (`finish_reason=length`) | HIGH | Check every row \u2014 flag if any truncation occurs |\n| String matching instead of equation eval | HIGH | Use `CountdownJudge` \u2014 verified AST-based |\n| Model outputs `\u00d7` instead of `*` | MEDIUM | `CountdownJudge` handles Unicode normalization |\n| `<answer>` tag missing in output | MEDIUM | Log separately as \"format failures\" vs \"wrong answer\" |\n| Duplicate problems in 10 samples | LOW | Use `random.seed(42)` for reproducibility |\n\n---\n\n## Expected Results\n\nBased on typical results from the reference file:\n- Qwen3-1.7B is smaller than Qwen2.5-3B (which achieves 70-80% after GRPO training)\n- For a **pre-GRPO instruct model** at this size, expect roughly **20-50% accuracy**\n- If accuracy is <10%, suspect prompt format issue or evaluation bug \u2014 investigate before scaling\n\n---\n\n## Output Schema\n\nExpected columns in the HuggingFace dataset:\n\n| Column | Type | Description |\n|--------|------|-------------|\n| `prompt` | str | Full prompt sent to the model |\n| `model_response` | str | Full untruncated model output |\n| `model` | str | `Qwen/Qwen3-1.7B` |\n| `numbers` | list[int] | Available operands |\n| `target` | int | Target value |\n| `correct` | bool | Whether the answer evaluates correctly |\n| `extracted_answer` | str | Parsed content from `<answer>` tags (null if missing) |\n| `finish_reason` | str | `stop` or `length` \u2014 flag any `length` rows |\n\n---\n\n## Validation Criteria (for data-validator)\n\nA healthy artifact must satisfy ALL of the following:\n1. All rows have non-empty `model_response`\n2. Zero rows with `finish_reason == \"length\"` (no truncation)\n3. `correct` column is boolean, computed via equation evaluation (not string match)\n4. `extracted_answer` is null only when model didn't produce `<answer>` tags\n5. Row count matches n_samples (10)\n6. No degenerate outputs (empty strings, repeated tokens, only whitespace)\n",
39
+ "created": "",
40
+ "updated": ""
41
+ }
42
+ ]
backend/data/experiments.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "onboarding",
4
+ "name": "Onboarding",
5
+ "research_project": "",
6
+ "hypothesis": {
7
+ "statement": "Qwen3-1.7B can solve basic Countdown arithmetic problems",
8
+ "type": "exploratory",
9
+ "status": "active",
10
+ "success_criteria": "Model produces valid arithmetic expressions that reach the target number on >50% of problems"
11
+ },
12
+ "stage": "active",
13
+ "completeness": 4,
14
+ "models": [],
15
+ "tasks": [],
16
+ "tags": [
17
+ "countdown",
18
+ "reasoning",
19
+ "onboarding",
20
+ "tutorial"
21
+ ],
22
+ "hf_repos": [
23
+ {
24
+ "repo": "timchen0618/onboarding-countdown-qwen3-1.7b",
25
+ "description": "onboarding-countdown-qwen3-1.7b \u2014 10 rows, 10/10 correct (2026-04-06)",
26
+ "date": ""
27
+ }
28
+ ],
29
+ "wandb_url": "",
30
+ "notes": "# Welcome to RACA\n\nThis is a sample experiment to show you how the dashboard works. You're looking at the **Overview** tab right now \u2014 it displays the experiment's README (this file).\n\nEverything you see here is generated from plain files in `notes/experiments/onboarding/`. You can browse them in your editor anytime.\n\n## How This Dashboard Works\n\nEach experiment has several tabs at the top. Here's what they do:\n\n### Overview (you are here)\n\nDisplays the experiment's README and any notes you've written in the `user/` folder. This is the main landing page for each experiment \u2014 a summary of what the experiment is, what you're investigating, and what you found.\n\n### Red Team Brief\n\nBefore any experiment runs, RACA reviews the design for potential problems \u2014 wrong evaluation metrics, truncated outputs, missing baselines, wasted compute. The brief lives at `red_team_brief.md`. This tab will be empty until you run your first real experiment.\n\n### Timeline\n\nA chronological log of everything that happened: when jobs were submitted, when artifacts were uploaded, when bugs were found and fixed. This is auto-generated from `activity_log.jsonl` \u2014 RACA writes to it as events happen.\n\n### Runs\n\nTracks each job submission \u2014 which model, which cluster, what status (pending, running, completed, failed), and links to the HuggingFace dataset with the results. Empty until you run something.\n\n### Artifacts\n\nLinks to all HuggingFace datasets produced by this experiment \u2014 canary runs, partial results, final data. Each artifact has metadata about what generated it. Empty until artifacts are uploaded.\n\n### Files\n\nAll the markdown and YAML files in the experiment folder. Click any file to read it. This is a quick way to browse the experiment's configuration and notes without leaving the dashboard.\n\n## Folder Structure\n\n```\nnotes/experiments/onboarding/\n EXPERIMENT_README.md \u2190 this file (shows in Overview tab)\n experiment.yaml \u2190 config: hypothesis, models, tasks\n flow_state.json \u2190 current phase (design/running/complete)\n HUGGINGFACE_REPOS.md \u2190 links to all uploaded datasets\n questions.md \u2190 research questions (read-only)\n red_team_brief.md \u2190 created during preflight review\n activity_log.jsonl \u2190 timeline entries (auto-generated)\n user/ \u2190 YOUR notes \u2014 RACA doesn't touch these\n README.md \u2190 your interpretation and observations\n FINDINGS.md \u2190 key results and surprises\n DECISIONS.md \u2190 design decisions and rationale\n summary.md \u2190 one-paragraph summary when done\n```\n\n**Most of this is automated.** RACA creates and updates the experiment files, uploads artifacts, and keeps the timeline current. The only files you write are in `user/` \u2014 that's your space for notes, findings, and decisions.\n\n## What's Next\n\nThis sample experiment hasn't been run yet \u2014 it's just here to show you the structure. When you're ready to run a real experiment, just tell RACA:\n\n> *I want to test whether Qwen3-8B follows complex instructions better than Llama-3.1-8B*\n\nOr try the full guided tutorial:\n\n> */raca:experiment-tutorial*\n",
31
+ "zayne_summary": "# Summary\n\n_Write a one-paragraph summary of the experiment and its outcome when you're done._\n\n## Status: active\n\n## Next Steps\n\n_What to do next based on findings._",
32
+ "zayne_readme": "# Onboarding Experiment \u2014 Your Notes\n\n## What I'm investigating\n\nThis is the tutorial experiment \u2014 testing Qwen3-1.7B on Countdown to learn the RACA pipeline.\n\n## Key observations\n\n_Fill this in as you review the results._\n\n## Open questions\n\n_Anything you want to follow up on._",
33
+ "zayne_findings": "# Welcome to Your Dashboard\n\nThis is a sample experiment to show you how the dashboard works. Everything you see here is generated from plain files in `notes/experiments/onboarding/`.\n\n## Dashboard Tabs\n\nEach experiment has tabs at the top:\n\n- **Overview** \u2014 the experiment's README and your notes (you're reading this now)\n- **Red Team Brief** \u2014 RACA reviews experiment designs for problems before running. Empty until your first real experiment.\n- **Timeline** \u2014 chronological log of everything that happened (auto-generated from `activity_log.jsonl`)\n- **Runs** \u2014 tracks each job submission: model, cluster, status, HuggingFace dataset links\n- **Artifacts** \u2014 links to all HuggingFace datasets produced by this experiment\n- **Files** \u2014 browse all experiment files without leaving the dashboard\n\n## What's Automated vs What You Write\n\nMost of this is automated. RACA creates and updates experiment files, uploads artifacts, and keeps the timeline current.\n\nThe `user/` folder is yours \u2014 RACA doesn't touch it:\n- `user/FINDINGS.md` \u2014 key results and surprises (this file)\n- `user/README.md` \u2014 your interpretation and observations\n- `user/DECISIONS.md` \u2014 design decisions and rationale\n- `user/summary.md` \u2014 one-paragraph summary when done\n\n## What's Next\n\nThis sample experiment hasn't been run yet \u2014 it's here to show you the structure. When you're ready:\n\n> *I want to test whether Qwen3-8B follows complex instructions better than Llama-3.1-8B*\n\nOr try the full guided tutorial: `/raca:experiment-tutorial`",
34
+ "zayne_decisions": "# Decisions\n\n| Date | Decision | Rationale |\n|------|----------|-----------|",
35
+ "red_team_brief": "# Red Team Brief \u2014 onboarding\n\n**Experiment:** Qwen3-1.7B on Countdown (tutorial/canary)\n**Reviewer:** agent\n**Date:** 2026-04-06\n**Status:** PASS\n\n---\n\n## Hypothesis\n\n> Qwen3-1.7B can solve basic Countdown arithmetic problems (>50% accuracy on 3-4 operand problems)\n\nThis is an exploratory baseline \u2014 a reasonable first question. No prior published result exists for Qwen3-1.7B specifically on this exact config, so the hypothesis is testable and non-trivial.\n\n---\n\n## Config Review\n\n### Model\n- **Qwen/Qwen3-1.7B** \u2014 small instruct model, appropriate for a tutorial canary\n- Known to handle arithmetic reasoning; 1.7B is at the edge of competence for Countdown (expect 20-50% accuracy)\n\n### max_tokens: 4096\n- **Status: PASS (marginal)**\n- Reference minimum is 2048-4096. We are at the ceiling of \"minimum\". For Qwen3-1.7B reasoning traces, 4096 should be sufficient for 3-4 operand problems.\n- **Watch for truncation in outputs** \u2014 if `finish_reason == \"length\"` appears in any response, flag immediately.\n\n### Prompt Format\n- **Must use the CoT + in-context examples prompt from the reference** (not the TinyZero `<think>` variant, since we are not RL-training)\n- Template:\n ```\n Answer the following problem. Explain your reasoning step by step. When you are finished, give your answer in this format: <answer>(your answer)</answer>.\n \n # Problem\n Using the numbers in the list [{numbers}], create an equation that equals {target}. ...\n ```\n- Answer extraction: regex on `<answer>...</answer>` tags\n\n### Evaluation Method\n- **Must use `CountdownJudge.validate_countdown_solution()` \u2014 NOT string matching**\n- Located at: `packages/custom_evaluations/custom_evaluations/sources/countdown/countdown_judge.py`\n- Handles: AST-based evaluation, Unicode operator normalization, multiset number validation\n- **Failure mode if string match is used:** \"3 + 5\" and \"5 + 3\" would score differently. This is wrong.\n\n### Dataset Generation\n- 10 samples (canary size \u2014 fine)\n- Use forward generation (inline, no dependencies): 3-4 operands, targets 1-99\n- Do NOT require all numbers used \u2014 subset is sufficient (this is the easier, more standard setting)\n\n---\n\n## Failure Modes & Mitigations\n\n| Risk | Severity | Mitigation |\n|------|----------|------------|\n| Truncated outputs (`finish_reason=length`) | HIGH | Check every row \u2014 flag if any truncation occurs |\n| String matching instead of equation eval | HIGH | Use `CountdownJudge` \u2014 verified AST-based |\n| Model outputs `\u00d7` instead of `*` | MEDIUM | `CountdownJudge` handles Unicode normalization |\n| `<answer>` tag missing in output | MEDIUM | Log separately as \"format failures\" vs \"wrong answer\" |\n| Duplicate problems in 10 samples | LOW | Use `random.seed(42)` for reproducibility |\n\n---\n\n## Expected Results\n\nBased on typical results from the reference file:\n- Qwen3-1.7B is smaller than Qwen2.5-3B (which achieves 70-80% after GRPO training)\n- For a **pre-GRPO instruct model** at this size, expect roughly **20-50% accuracy**\n- If accuracy is <10%, suspect prompt format issue or evaluation bug \u2014 investigate before scaling\n\n---\n\n## Output Schema\n\nExpected columns in the HuggingFace dataset:\n\n| Column | Type | Description |\n|--------|------|-------------|\n| `prompt` | str | Full prompt sent to the model |\n| `model_response` | str | Full untruncated model output |\n| `model` | str | `Qwen/Qwen3-1.7B` |\n| `numbers` | list[int] | Available operands |\n| `target` | int | Target value |\n| `correct` | bool | Whether the answer evaluates correctly |\n| `extracted_answer` | str | Parsed content from `<answer>` tags (null if missing) |\n| `finish_reason` | str | `stop` or `length` \u2014 flag any `length` rows |\n\n---\n\n## Validation Criteria (for data-validator)\n\nA healthy artifact must satisfy ALL of the following:\n1. All rows have non-empty `model_response`\n2. Zero rows with `finish_reason == \"length\"` (no truncation)\n3. `correct` column is boolean, computed via equation evaluation (not string match)\n4. `extracted_answer` is null only when model didn't produce `<answer>` tags\n5. Row count matches n_samples (10)\n6. No degenerate outputs (empty strings, repeated tokens, only whitespace)\n",
36
+ "created": "",
37
+ "updated": ""
38
+ }
39
+ ]
backend/data/runs.json ADDED
@@ -0,0 +1 @@
 
 
1
+ []
backend/data/sub_experiments.json ADDED
@@ -0,0 +1 @@
 
 
1
+ []
backend/data/summary_findings.json ADDED
@@ -0,0 +1 @@
 
 
1
+ []
backend/requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask>=3.0.0
2
+ flask-cors>=4.0.0
3
+ datasets>=2.14.0
4
+ gunicorn>=21.0.0
5
+ huggingface_hub>=0.20.0
docs/managing_presets.md ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Managing RACA-VIS-PRESETS Programmatically
2
+
3
+ ## Overview
4
+
5
+ The agg_visualizer stores presets in the HuggingFace dataset repo `your-org/RACA-VIS-PRESETS`. Each visualizer type has its own JSON file:
6
+
7
+ | Type | File | Extra Fields |
8
+ |------|------|-------------|
9
+ | `model` | `model_presets.json` | `column` (default: `"model_responses"`) |
10
+ | `arena` | `arena_presets.json` | none |
11
+ | `rlm` | `rlm_presets.json` | `config` (default: `"rlm_call_traces"`) |
12
+ | `harbor` | `harbor_presets.json` | none |
13
+
14
+ ## Preset Schema
15
+
16
+ Every preset has these base fields:
17
+
18
+ ```json
19
+ {
20
+ "id": "8-char hex",
21
+ "name": "Human-readable name",
22
+ "repo": "org/dataset-name",
23
+ "split": "train"
24
+ }
25
+ ```
26
+
27
+ Plus type-specific fields listed above.
28
+
29
+ ## How to Add Presets from Experiment Markdown Files
30
+
31
+ ### Step 1: Identify repos and their visualizer type
32
+
33
+ Read the experiment markdown file(s) and extract all HuggingFace repo links. Categorize each:
34
+
35
+ - **Countdown / MuSR datasets** (model response traces) → `model` type, set `column: "response"`
36
+ - **FrozenLake / arena datasets** (game episodes) → `arena` type
37
+ - **Harbor / SWE-bench datasets** → `harbor` type
38
+ - **RLM call traces** → `rlm` type, set `config: "rlm_call_traces"`
39
+
40
+ ### Step 2: Download existing presets from HF
41
+
42
+ ```python
43
+ from huggingface_hub import hf_hub_download
44
+ import json
45
+
46
+ PRESETS_REPO = "your-org/RACA-VIS-PRESETS"
47
+
48
+ def load_hf_presets(vis_type):
49
+ try:
50
+ path = hf_hub_download(PRESETS_REPO, f"{vis_type}_presets.json", repo_type="dataset")
51
+ with open(path) as f:
52
+ return json.load(f)
53
+ except Exception:
54
+ return []
55
+
56
+ existing_model = load_hf_presets("model")
57
+ existing_arena = load_hf_presets("arena")
58
+ # ... etc for rlm, harbor
59
+
60
+ # Build set of repos already present
61
+ existing_repos = set()
62
+ for presets_list in [existing_model, existing_arena]:
63
+ for p in presets_list:
64
+ existing_repos.add(p["repo"])
65
+ ```
66
+
67
+ ### Step 3: Build new presets, skipping duplicates
68
+
69
+ ```python
70
+ import uuid
71
+
72
+ new_presets = [] # list of (vis_type, name, repo)
73
+
74
+ # Example: adding strategy compliance countdown presets
75
+ new_presets.append(("model", "SC Countdown K2-Inst TreeSearch",
76
+ "your-org/t1-strategy-countdown-treesearch-kimi-k2-instruct-kimi-inst"))
77
+
78
+ # ... add all repos from the markdown ...
79
+
80
+ # Filter out existing
81
+ to_add = {"model": [], "arena": [], "rlm": [], "harbor": []}
82
+ for vis_type, name, repo in new_presets:
83
+ if repo in existing_repos:
84
+ continue # skip duplicates
85
+ preset = {
86
+ "id": uuid.uuid4().hex[:8],
87
+ "name": name,
88
+ "repo": repo,
89
+ "split": "train",
90
+ }
91
+ if vis_type == "model":
92
+ preset["column"] = "response"
93
+ elif vis_type == "rlm":
94
+ preset["config"] = "rlm_call_traces"
95
+ to_add[vis_type].append(preset)
96
+ ```
97
+
98
+ ### Step 4: Merge and upload to HF
99
+
100
+ ```python
101
+ import tempfile, os
102
+ from huggingface_hub import HfApi
103
+
104
+ api = HfApi()
105
+
106
+ # Merge new presets with existing
107
+ final_model = existing_model + to_add["model"]
108
+ final_arena = existing_arena + to_add["arena"]
109
+
110
+ for vis_type, presets in [("model", final_model), ("arena", final_arena)]:
111
+ if not presets:
112
+ continue
113
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
114
+ json.dump(presets, f, indent=2)
115
+ tmp = f.name
116
+ api.upload_file(
117
+ path_or_fileobj=tmp,
118
+ path_in_repo=f"{vis_type}_presets.json",
119
+ repo_id=PRESETS_REPO,
120
+ repo_type="dataset",
121
+ )
122
+ os.unlink(tmp)
123
+ ```
124
+
125
+ ### Step 5: Sync the deployed HF Space
126
+
127
+ After uploading to the HF dataset, tell the running Space to re-download presets:
128
+
129
+ ```bash
130
+ curl -X POST "https://your-org-agg-trace-visualizer.hf.space/api/presets/sync"
131
+ ```
132
+
133
+ This forces the Space to re-download all preset files from `RACA-VIS-PRESETS` without needing a restart or redeployment.
134
+
135
+ ### Step 6: Sync local preset files
136
+
137
+ ```python
138
+ import shutil
139
+ from huggingface_hub import hf_hub_download
140
+
141
+ local_dir = Path(__file__).parent.parent / "backend" / "presets"
142
+ for vis_type in ["model", "arena", "rlm", "harbor"]:
143
+ try:
144
+ path = hf_hub_download(PRESETS_REPO, f"{vis_type}_presets.json", repo_type="dataset")
145
+ shutil.copy2(path, f"{local_dir}/{vis_type}_presets.json")
146
+ except Exception:
147
+ pass
148
+ ```
149
+
150
+ ## Naming Convention
151
+
152
+ Preset names follow this pattern to be descriptive and avoid future conflicts:
153
+
154
+ ```
155
+ {Experiment} {Task} {Model} {Variant}
156
+ ```
157
+
158
+ ### Experiment prefixes
159
+ - `SC` — Strategy Compliance
160
+ - `Wing` — Wingdings Compliance
161
+
162
+ ### Model abbreviations
163
+ - `K2-Inst` — Kimi-K2-Instruct (RLHF)
164
+ - `K2-Think` — Kimi-K2-Thinking (RLVR)
165
+ - `Q3-Inst` — Qwen3-Next-80B Instruct (RLHF)
166
+ - `Q3-Think` — Qwen3-Next-80B Thinking (RLVR)
167
+
168
+ ### Task names
169
+ - `Countdown` — 8-arg arithmetic countdown
170
+ - `MuSR` — MuSR murder mysteries
171
+ - `FrozenLake` — FrozenLake grid navigation
172
+
173
+ ### Variant names (strategy compliance only)
174
+ - `TreeSearch` / `Baseline` / `Anti` — countdown tree search experiment
175
+ - `CritFirst` / `Anti-CritFirst` — criterion-first cross-cutting analysis
176
+ - `Counterfactual` / `Anti-Counterfactual` — counterfactual hypothesis testing
177
+ - `BackChain` — backward chaining (FrozenLake)
178
+
179
+ ### Examples
180
+
181
+ ```
182
+ SC Countdown K2-Inst TreeSearch # Strategy compliance, countdown, Kimi instruct, tree search variant
183
+ SC MuSR Q3-Think Counterfactual # Strategy compliance, MuSR, Qwen thinking, counterfactual variant
184
+ SC FrozenLake K2-Think BackChain # Strategy compliance, FrozenLake, Kimi thinking, backward chaining
185
+ Wing Countdown Q3-Inst # Wingdings, countdown, Qwen instruct (no variant — wingdings has one condition)
186
+ Wing MuSR K2-Think # Wingdings, MuSR, Kimi thinking
187
+ ```
188
+
189
+ ## Important Notes
190
+
191
+ - **Always check for existing repos** before adding. The script above uses `existing_repos` set to skip duplicates.
192
+ - **The `column` field matters for model presets.** Strategy compliance and wingdings datasets use `"response"` as the response column, not the default `"model_responses"`.
193
+ - **Local files are fallback cache.** The agg_visualizer downloads from HF on startup and caches locally. After uploading to HF, sync the local files so the running app picks up changes without restart (or hit the `/api/presets/sync` endpoint).
194
+ - **Don't modify rlm or harbor presets** unless adding datasets of those types. The script above only touches model and arena.
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 `your-org/RACA_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/dist/assets/ExperimentsApp-B57tv21O.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/dist/assets/ExperimentsApp-DnJR2-55.css ADDED
@@ -0,0 +1 @@
 
 
1
+ @font-face{font-display:block;font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2) format("woff2"),url(/assets/KaTeX_AMS-Regular-DMm9YOAa.woff) format("woff"),url(/assets/KaTeX_AMS-Regular-DRggAlZN.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2) format("woff2"),url(/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff) format("woff"),url(/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2) format("woff2"),url(/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff) format("woff"),url(/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2) format("woff2"),url(/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff) format("woff"),url(/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2) format("woff2"),url(/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff) format("woff"),url(/assets/KaTeX_Fraktur-Regular-CB_wures.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(/assets/KaTeX_Main-Bold-Cx986IdX.woff2) format("woff2"),url(/assets/KaTeX_Main-Bold-Jm3AIy58.woff) format("woff"),url(/assets/KaTeX_Main-Bold-waoOVXN0.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2) format("woff2"),url(/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff) format("woff"),url(/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2) format("woff2"),url(/assets/KaTeX_Main-Italic-BMLOBm91.woff) format("woff"),url(/assets/KaTeX_Main-Italic-3WenGoN9.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(/assets/KaTeX_Main-Regular-B22Nviop.woff2) format("woff2"),url(/assets/KaTeX_Main-Regular-Dr94JaBh.woff) format("woff"),url(/assets/KaTeX_Main-Regular-ypZvNtVU.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2) format("woff2"),url(/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff) format("woff"),url(/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(/assets/KaTeX_Math-Italic-t53AETM-.woff2) format("woff2"),url(/assets/KaTeX_Math-Italic-DA0__PXp.woff) format("woff"),url(/assets/KaTeX_Math-Italic-flOr_0UB.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_SansSerif;font-style:normal;font-weight:700;src:url(/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2) format("woff2"),url(/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff) format("woff"),url(/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_SansSerif;font-style:italic;font-weight:400;src:url(/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2) format("woff2"),url(/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff) format("woff"),url(/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_SansSerif;font-style:normal;font-weight:400;src:url(/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2) format("woff2"),url(/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff) format("woff"),url(/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(/assets/KaTeX_Script-Regular-D3wIWfF6.woff2) format("woff2"),url(/assets/KaTeX_Script-Regular-D5yQViql.woff) format("woff"),url(/assets/KaTeX_Script-Regular-C5JkGWo-.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2) format("woff2"),url(/assets/KaTeX_Size1-Regular-C195tn64.woff) format("woff"),url(/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2) format("woff2"),url(/assets/KaTeX_Size2-Regular-oD1tc_U0.woff) format("woff"),url(/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(data:font/woff2;base64,d09GMgABAAAAAA4oAA4AAAAAHbQAAA3TAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAABmAAgRQIDgmcDBEICo1oijYBNgIkA14LMgAEIAWJAAeBHAyBHBvbGiMRdnO0IkRRkiYDgr9KsJ1NUAf2kILNxgUmgqIgq1P89vcbIcmsQbRps3vCcXdYOKSWEPEKgZgQkprQQsxIXUgq0DqpGKmIvrgkeVGtEQD9DzAO29fM9jYhxZEsL2FeURH2JN4MIcTdO049NCVdxQ/w9NrSYFEBKTDKpLKfNkCGDc1RwjZLQcm3vqJ2UW9Xfa3tgAHz6ivp6vgC2yD4/6352ndnN0X0TL7seypkjZlMsjmZnf0Mm5Q+JykRWQBKCVCVPbARPXWyQtb5VgLB6Biq7/Uixcj2WGqdI8tGSgkuRG+t910GKP2D7AQH0DB9FMDW/obJZ8giFI3Wg8Cvevz0M+5m0rTh7XDBlvo9Y4vm13EXmfttwI4mBo1EG15fxJhUiCLbiiyCf/ZA6MFAhg3pGIZGdGIVjtPn6UcMk9A/UUr9PhoNsCENw1APAq0gpH73e+M+0ueyHbabc3vkbcdtzcf/fiy+NxQEjf9ud/ELBHAXJ0nk4z+MXH2Ev/kWyV4k7SkvpPc9Qr38F6RPWnM9cN6DJ0AdD1BhtgABtmoRoFCvPsBAumNm6soZG2Gk5GyVTo2sJncSyp0jQTYoR6WDvTwaaEcHsxHfvuWhHA3a6bN7twRKtcGok6NsCi7jYRrM2jExsUFMxMQYuJbMhuWNOumEJy9hi29Dmg5zMp/A5+hhPG19j1vBrq8JTLr8ki5VLPmG/PynJHVul440bxg5xuymHUFPBshC+nA9I1FmwbRBTNHAcik3Oae0cxKoI3MOriM42UrPe51nsaGxJ+WfXubAsP84aabUlQSJ1IiE0iPETLUU4CATgfXSCSpuRFRmCGbO+wSpAnzaeaCYW1VNEysRtuXCEL1kUFUbbtMv3Tilt/1c11jt3Q5bbMa84cpWipp8Elw3MZhOHsOlwwVUQM3lAR35JiFQbaYCRnMF2lxAWoOg2gyoIV4PouX8HytNIfLhqpJtXB4vjiViUI8IJ7bkC4ikkQvKksnOTKICwnqWSZ9YS5f0WCxmpgjbIq7EJcM4aI2nmhLNY2JIUgOjXZFWBHb+x5oh6cwb0Tv1ackHdKi0I9OO2wE9aogIOn540CCCziyhN+IaejtgAONKznHlHyutPrHGwCx9S6B8kfS4Mfi4Eyv7OU730bT1SCBjt834cXsf43zVjPUqqJjgrjeGnBxSG4aYAKFuVbeCfkDIjAqMb6yLNIbCuvXhMH2/+k2vkNpkORhR59N1CkzoOENvneIosjYmuTxlhUzaGEJQ/iWqx4dmwpmKjrwTiTGTCVozNAYqk/zXOndWxuWSmJkQpJw3pK5KX6QrLt5LATMqpmPAQhkhK6PUjzHUn7E0gHE0kPE0iKkolgkUx9SZmVAdDgpffdyJKg3k7VmzYGCwVXGz/tXmkOIp+vcWs+EMuhhvN0h9uhfzWJziBQmCREGSIFmQIkgVpAnSBRmC//6hkLZwaVhwxlrJSOdqlFtOYxlau9F2QN5Y98xmIAsiM1HVp2VFX+DHHGg6Ecjh3vmqtidX3qHI2qycTk/iwxSt5UzTmEP92ZBnEWTk4Mx8Mpl78ZDokxg/KWb+Q0QkvdKVmq3TMW+RXEgrsziSAfNXFMhDc60N5N9jQzjfO0kBKpUZl0ZmwJ41j/B9Hz6wmRaJB84niNmQrzp9eSlQCDDzazGDdVi3P36VZQ+Jy4f9UBNp+3zTjqI4abaFAm+GShVaXlsGdF3FYzZcDI6cori4kMxUECl9IjJZpzkvitAoxKue+90pDMvcKRxLl53TmOKCmV/xRolNKSqqUxc6LStOETmFOiLZZptlZepcKiAzteG8PEdpnQpbOMNcMsR4RR2Bs0cKFEvSmIjAFcnarqwUL4lDhHmnVkwu1IwshbiCcgvOheZuYyOteufZZwlcTlLgnZ3o/WcYdzZHW/WGaqaVfmTZ1aWCceJjkbZqsfbkOtcFlUZM/jy+hXHDbaUobWqqXaeWobbLO99yG5N3U4wxco0rQGGcOLASFMXeJoham8M+/x6O2WywK2l4HGbq1CoUyC/IZikQhdq3SiuNrvAEj0AVu9x2x3lp/xWzahaxidezFVtdcb5uEnzyl0ZmYiuKI0exvCd4Xc9CV1KB0db00z92wDPde0kukbvZIWN6jUWFTmPIC/Y4UPCm8UfDTFZpZNon1qLFTkBhxzB+FjQRA2Q/YRJT8pQigslMaUpFyAG8TMlXigiqmAZX4xgijKjRlGpLE0GdplRfCaJo0JQaSxNBk6ZmMzcya0FmrcisDdn0Q3HI2sWSppYigmlM1XT/kLQZSNpMJG0WkjYbSZuDpM1F0uYhFc1HxU4m1QJjDK6iL0S5uSj5rgXc3RejEigtcRBtqYPQsiTskmO5vosV+q4VGIKbOkDg0jtRrq+Em1YloaTFar3EGr1EUC8R0kus1Uus00usL97ABr2BjXoDm/QGNhuWtMVBKOwg/i78lT7hBsAvDmwHc/ao3vmUbBmhjeYySZNWvGkfZAgISDSaDo1SVpzGDsAEkF8B+gEapViUoZgUWXcRIGFZNm6gWbAKk0bp0k1MHG9fLYtV4iS2SmLEQFARzRcnf9PUS0LVn05/J9MiRRBU3v2IrvW974v4N00L7ZMk0wXP1409CHo/an8zTRHD3eSJ6m8D4YMkZNl3M79sqeuAsr/m3f+8/yl7A50aiAEJgeBeMWzu7ui9UfUBCe2TIqZIoOd/3/udRBOQidQZUERzb2/VwZN1H/Sju82ew2H2Wfr6qvfVf3hqwDvAIpkQVFy4B9Pe9e4/XvPeceu7h3dvO56iJPf0+A6cqA2ip18ER+iFgggiuOkvj24bby0N9j2UHIkgqIt+sVgfodC4YghLSMjSZbH0VR/6dMDrYJeKHilKTemt6v6kvzvn3/RrdWtr0GoN/xL+Sex/cPYLUpepx9cz/D46UPU5KXgAQa+NDps1v6J3xP1i2HtaDB0M9aX2deA7SYff//+gUCovMmIK/qfsFcOk+4Y5ZN97XlG6zebqtMbKgeRFi51vnxTQYBUik2rS/Cn6PC8ADR8FGxsRPB82dzfND90gIcshOcYUkfjherBz53odpm6TP8txlwOZ71xmfHHOvq053qFF/MRlS3jP0ELudrf2OeN8DHvp6ZceLe8qKYvWz/7yp0u4dKPfli3CYq0O13Ih71mylJ80tOi10On8wi+F4+LWgDPeJ30msSQt9/vkmHq9/Lvo2b461mP801v3W4xTcs6CbvF9UDdrSt+A8OUbpSh55qAUFXWznBBfdeJ8a4d7ugT5tvxUza3h9m4H7ptTqiG4z0g5dc0X29OcGlhpGFMpQo9ytTS+NViZpNdvU4kWx+LKxNY10kQ1yqGXrhe4/1nvP7E+nd5A92TtaRplbHSqoIdOqtRWti+fkB5/n1+/VvCmz12pG1kpQWsfi1ftlBobm0bpngs16CHkbIwdLnParxtTV3QYRlfJ0KFskH7pdN/YDn+yRuSd7sNH3aO0DYPggk6uWuXrfOc+fa3VTxFVvKaNxHsiHmsXyCLIE5yuOeN3/Jdf8HBL/5M6shjyhxHx9BjB1O0+4NLOnjLLSxwO7ukN4jMbOIcD879KLSi6Pk61Oqm2377n8079PXEEQ7cy7OKEC9nbpet118fxweTafpt69x/Bt8UqGzNQt7aelpc44dn5cqhwf71+qKp/Zf/+a0zcizOUWpl/iBcSXip0pplkatCchoH5c5aUM8I7/dWxAej8WicPL1URFZ9BDJelUwEwTkGqUhgSlydVes95YdXvhh9Gfz/aeFWvgVb4tuLbcv4+wLdutVZv/cUonwBD/6eDlE0aSiKK/uoH3+J1wDE/jMVqY2ysGufN84oIXB0sPzy8ollX/LegY74DgJXJR57sn+VGza0x3DnuIgABFM15LmajjjsNlYj+JEZGbuRYcAMOWxFkPN2w6Wd46xo4gVWQR/X4lyI/R6K/YK0110GzudPRW7Y+UOBGTfNNzHeYT0fiH0taunBpq9HEW8OKSaBGj21L0MqenEmNRWBAWDWAk4CpNoEZJ2tTaPFgbQYj8HxtFilErs3BTRwT8uO1NXQaWfIotchmPkAF5mMBAliEmZiOGVgCG9LgRzpscMAOOwowlT3JhusdazXGSC/hxR3UlmWVwWHpOIKheqONvjyhSiTHIkVUco5bnji8m//zL7PKaT1Vl5I6UE609f+gkr6MZKVyKc7zJRmCahLsdlyA5fdQkRSan9LgnnLEyGSkaKJCJog0wAgvepWBt80+1yKln1bMVtCljfNWDueKLsWwaEbBSfSPTEmVRsUcYYMnEjcjeyCZzBXK9E9BYBXLKjOSpUDR+nEV3TFSUdQaz+ot98QxgXwx0GQ+EEUAKB2qZPkQQ0GqFD8UPFMqyaCHM24BZmSGic9EYMagKizOw9Hz50DMrDLrqqLkTAhplMictiCAx5S3BIUQdeJeLnBy2CNtMfz6cV4u8XKoFZQesbf9YZiIERiHjaNodDW6LgcirX/mPnJIkBGDUpTBhSa0EIr38D5hCIszhCM8URGBqImoWjpvpt1ebu/v3Gl3qJfMnNM+9V+kiRFyROTPHQWOcs1dNW94/ukKMPZBvDi55i5CttdeJz84DLngLqjcdwEZ87bFFR8CIG35OAkDVN6VRDZ7aq67NteYqZ2lpT8oYB2CytoBd6VuAx4WgiAsnuj3WohG+LugzXiQRDeM3XYXlULv4dp5VFYC) format("woff2"),url(/assets/KaTeX_Size3-Regular-CTq5MqoE.woff) format("woff"),url(/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2) format("woff2"),url(/assets/KaTeX_Size4-Regular-BF-4gkZK.woff) format("woff"),url(/assets/KaTeX_Size4-Regular-DWFBv043.ttf) format("truetype")}@font-face{font-display:block;font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2) format("woff2"),url(/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff) format("woff"),url(/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf) format("truetype")}.katex{font: 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;position:relative;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.16.40"}.katex .katex-mathml{clip:rect(1px,1px,1px,1px);border:0;height:1px;overflow:hidden;padding:0;position:absolute;width:1px}.katex .katex-html>.newline{display:block}.katex .base{position:relative;white-space:nowrap;width:-moz-min-content;width:min-content}.katex .base,.katex .strut{display:inline-block}.katex .textbf{font-weight:700}.katex .textit{font-style:italic}.katex .textrm{font-family:KaTeX_Main}.katex .textsf{font-family:KaTeX_SansSerif}.katex .texttt{font-family:KaTeX_Typewriter}.katex .mathnormal{font-family:KaTeX_Math;font-style:italic}.katex .mathit{font-family:KaTeX_Main;font-style:italic}.katex .mathrm{font-style:normal}.katex .mathbf{font-family:KaTeX_Main;font-weight:700}.katex .boldsymbol{font-family:KaTeX_Math;font-style:italic;font-weight:700}.katex .amsrm,.katex .mathbb,.katex .textbb{font-family:KaTeX_AMS}.katex .mathcal{font-family:KaTeX_Caligraphic}.katex .mathfrak,.katex .textfrak{font-family:KaTeX_Fraktur}.katex .mathboldfrak,.katex .textboldfrak{font-family:KaTeX_Fraktur;font-weight:700}.katex .mathtt{font-family:KaTeX_Typewriter}.katex .mathscr,.katex .textscr{font-family:KaTeX_Script}.katex .mathsf,.katex .textsf{font-family:KaTeX_SansSerif}.katex .mathboldsf,.katex .textboldsf{font-family:KaTeX_SansSerif;font-weight:700}.katex .mathitsf,.katex .mathsfit,.katex .textitsf{font-family:KaTeX_SansSerif;font-style:italic}.katex .mainrm{font-family:KaTeX_Main;font-style:normal}.katex .vlist-t{border-collapse:collapse;display:inline-table;table-layout:fixed}.katex .vlist-r{display:table-row}.katex .vlist{display:table-cell;position:relative;vertical-align:bottom}.katex .vlist>span{display:block;height:0;position:relative}.katex .vlist>span>span{display:inline-block}.katex .vlist>span>.pstrut{overflow:hidden;width:0}.katex .vlist-t2{margin-right:-2px}.katex .vlist-s{display:table-cell;font-size:1px;min-width:2px;vertical-align:bottom;width:2px}.katex .vbox{align-items:baseline;display:inline-flex;flex-direction:column}.katex .hbox{width:100%}.katex .hbox,.katex .thinbox{display:inline-flex;flex-direction:row}.katex .thinbox{max-width:0;width:0}.katex .msupsub{text-align:left}.katex .mfrac>span>span{text-align:center}.katex .mfrac .frac-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .hdashline,.katex .hline,.katex .mfrac .frac-line,.katex .overline .overline-line,.katex .rule,.katex .underline .underline-line{min-height:1px}.katex .mspace{display:inline-block}.katex .smash{display:inline;line-height:0}.katex .clap,.katex .llap,.katex .rlap{position:relative;width:0}.katex .clap>.inner,.katex .llap>.inner,.katex .rlap>.inner{position:absolute}.katex .clap>.fix,.katex .llap>.fix,.katex .rlap>.fix{display:inline-block}.katex .llap>.inner{right:0}.katex .clap>.inner,.katex .rlap>.inner{left:0}.katex .clap>.inner>span{margin-left:-50%;margin-right:50%}.katex .rule{border:0 solid;display:inline-block;position:relative}.katex .hline,.katex .overline .overline-line,.katex .underline .underline-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .hdashline{border-bottom-style:dashed;display:inline-block;width:100%}.katex .sqrt>.root{margin-left:.2777777778em;margin-right:-.5555555556em}.katex .fontsize-ensurer.reset-size1.size1,.katex .sizing.reset-size1.size1{font-size:1em}.katex .fontsize-ensurer.reset-size1.size2,.katex .sizing.reset-size1.size2{font-size:1.2em}.katex .fontsize-ensurer.reset-size1.size3,.katex .sizing.reset-size1.size3{font-size:1.4em}.katex .fontsize-ensurer.reset-size1.size4,.katex .sizing.reset-size1.size4{font-size:1.6em}.katex .fontsize-ensurer.reset-size1.size5,.katex .sizing.reset-size1.size5{font-size:1.8em}.katex .fontsize-ensurer.reset-size1.size6,.katex .sizing.reset-size1.size6{font-size:2em}.katex .fontsize-ensurer.reset-size1.size7,.katex .sizing.reset-size1.size7{font-size:2.4em}.katex .fontsize-ensurer.reset-size1.size8,.katex .sizing.reset-size1.size8{font-size:2.88em}.katex .fontsize-ensurer.reset-size1.size9,.katex .sizing.reset-size1.size9{font-size:3.456em}.katex .fontsize-ensurer.reset-size1.size10,.katex .sizing.reset-size1.size10{font-size:4.148em}.katex .fontsize-ensurer.reset-size1.size11,.katex .sizing.reset-size1.size11{font-size:4.976em}.katex .fontsize-ensurer.reset-size2.size1,.katex .sizing.reset-size2.size1{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size2.size2,.katex .sizing.reset-size2.size2{font-size:1em}.katex .fontsize-ensurer.reset-size2.size3,.katex .sizing.reset-size2.size3{font-size:1.1666666667em}.katex .fontsize-ensurer.reset-size2.size4,.katex .sizing.reset-size2.size4{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size2.size5,.katex .sizing.reset-size2.size5{font-size:1.5em}.katex .fontsize-ensurer.reset-size2.size6,.katex .sizing.reset-size2.size6{font-size:1.6666666667em}.katex .fontsize-ensurer.reset-size2.size7,.katex .sizing.reset-size2.size7{font-size:2em}.katex .fontsize-ensurer.reset-size2.size8,.katex .sizing.reset-size2.size8{font-size:2.4em}.katex .fontsize-ensurer.reset-size2.size9,.katex .sizing.reset-size2.size9{font-size:2.88em}.katex .fontsize-ensurer.reset-size2.size10,.katex .sizing.reset-size2.size10{font-size:3.4566666667em}.katex .fontsize-ensurer.reset-size2.size11,.katex .sizing.reset-size2.size11{font-size:4.1466666667em}.katex .fontsize-ensurer.reset-size3.size1,.katex .sizing.reset-size3.size1{font-size:.7142857143em}.katex .fontsize-ensurer.reset-size3.size2,.katex .sizing.reset-size3.size2{font-size:.8571428571em}.katex .fontsize-ensurer.reset-size3.size3,.katex .sizing.reset-size3.size3{font-size:1em}.katex .fontsize-ensurer.reset-size3.size4,.katex .sizing.reset-size3.size4{font-size:1.1428571429em}.katex .fontsize-ensurer.reset-size3.size5,.katex .sizing.reset-size3.size5{font-size:1.2857142857em}.katex .fontsize-ensurer.reset-size3.size6,.katex .sizing.reset-size3.size6{font-size:1.4285714286em}.katex .fontsize-ensurer.reset-size3.size7,.katex .sizing.reset-size3.size7{font-size:1.7142857143em}.katex .fontsize-ensurer.reset-size3.size8,.katex .sizing.reset-size3.size8{font-size:2.0571428571em}.katex .fontsize-ensurer.reset-size3.size9,.katex .sizing.reset-size3.size9{font-size:2.4685714286em}.katex .fontsize-ensurer.reset-size3.size10,.katex .sizing.reset-size3.size10{font-size:2.9628571429em}.katex .fontsize-ensurer.reset-size3.size11,.katex .sizing.reset-size3.size11{font-size:3.5542857143em}.katex .fontsize-ensurer.reset-size4.size1,.katex .sizing.reset-size4.size1{font-size:.625em}.katex .fontsize-ensurer.reset-size4.size2,.katex .sizing.reset-size4.size2{font-size:.75em}.katex .fontsize-ensurer.reset-size4.size3,.katex .sizing.reset-size4.size3{font-size:.875em}.katex .fontsize-ensurer.reset-size4.size4,.katex .sizing.reset-size4.size4{font-size:1em}.katex .fontsize-ensurer.reset-size4.size5,.katex .sizing.reset-size4.size5{font-size:1.125em}.katex .fontsize-ensurer.reset-size4.size6,.katex .sizing.reset-size4.size6{font-size:1.25em}.katex .fontsize-ensurer.reset-size4.size7,.katex .sizing.reset-size4.size7{font-size:1.5em}.katex .fontsize-ensurer.reset-size4.size8,.katex .sizing.reset-size4.size8{font-size:1.8em}.katex .fontsize-ensurer.reset-size4.size9,.katex .sizing.reset-size4.size9{font-size:2.16em}.katex .fontsize-ensurer.reset-size4.size10,.katex .sizing.reset-size4.size10{font-size:2.5925em}.katex .fontsize-ensurer.reset-size4.size11,.katex .sizing.reset-size4.size11{font-size:3.11em}.katex .fontsize-ensurer.reset-size5.size1,.katex .sizing.reset-size5.size1{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size5.size2,.katex .sizing.reset-size5.size2{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size5.size3,.katex .sizing.reset-size5.size3{font-size:.7777777778em}.katex .fontsize-ensurer.reset-size5.size4,.katex .sizing.reset-size5.size4{font-size:.8888888889em}.katex .fontsize-ensurer.reset-size5.size5,.katex .sizing.reset-size5.size5{font-size:1em}.katex .fontsize-ensurer.reset-size5.size6,.katex .sizing.reset-size5.size6{font-size:1.1111111111em}.katex .fontsize-ensurer.reset-size5.size7,.katex .sizing.reset-size5.size7{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size5.size8,.katex .sizing.reset-size5.size8{font-size:1.6em}.katex .fontsize-ensurer.reset-size5.size9,.katex .sizing.reset-size5.size9{font-size:1.92em}.katex .fontsize-ensurer.reset-size5.size10,.katex .sizing.reset-size5.size10{font-size:2.3044444444em}.katex .fontsize-ensurer.reset-size5.size11,.katex .sizing.reset-size5.size11{font-size:2.7644444444em}.katex .fontsize-ensurer.reset-size6.size1,.katex .sizing.reset-size6.size1{font-size:.5em}.katex .fontsize-ensurer.reset-size6.size2,.katex .sizing.reset-size6.size2{font-size:.6em}.katex .fontsize-ensurer.reset-size6.size3,.katex .sizing.reset-size6.size3{font-size:.7em}.katex .fontsize-ensurer.reset-size6.size4,.katex .sizing.reset-size6.size4{font-size:.8em}.katex .fontsize-ensurer.reset-size6.size5,.katex .sizing.reset-size6.size5{font-size:.9em}.katex .fontsize-ensurer.reset-size6.size6,.katex .sizing.reset-size6.size6{font-size:1em}.katex .fontsize-ensurer.reset-size6.size7,.katex .sizing.reset-size6.size7{font-size:1.2em}.katex .fontsize-ensurer.reset-size6.size8,.katex .sizing.reset-size6.size8{font-size:1.44em}.katex .fontsize-ensurer.reset-size6.size9,.katex .sizing.reset-size6.size9{font-size:1.728em}.katex .fontsize-ensurer.reset-size6.size10,.katex .sizing.reset-size6.size10{font-size:2.074em}.katex .fontsize-ensurer.reset-size6.size11,.katex .sizing.reset-size6.size11{font-size:2.488em}.katex .fontsize-ensurer.reset-size7.size1,.katex .sizing.reset-size7.size1{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size7.size2,.katex .sizing.reset-size7.size2{font-size:.5em}.katex .fontsize-ensurer.reset-size7.size3,.katex .sizing.reset-size7.size3{font-size:.5833333333em}.katex .fontsize-ensurer.reset-size7.size4,.katex .sizing.reset-size7.size4{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size7.size5,.katex .sizing.reset-size7.size5{font-size:.75em}.katex .fontsize-ensurer.reset-size7.size6,.katex .sizing.reset-size7.size6{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size7.size7,.katex .sizing.reset-size7.size7{font-size:1em}.katex .fontsize-ensurer.reset-size7.size8,.katex .sizing.reset-size7.size8{font-size:1.2em}.katex .fontsize-ensurer.reset-size7.size9,.katex .sizing.reset-size7.size9{font-size:1.44em}.katex .fontsize-ensurer.reset-size7.size10,.katex .sizing.reset-size7.size10{font-size:1.7283333333em}.katex .fontsize-ensurer.reset-size7.size11,.katex .sizing.reset-size7.size11{font-size:2.0733333333em}.katex .fontsize-ensurer.reset-size8.size1,.katex .sizing.reset-size8.size1{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size8.size2,.katex .sizing.reset-size8.size2{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size8.size3,.katex .sizing.reset-size8.size3{font-size:.4861111111em}.katex .fontsize-ensurer.reset-size8.size4,.katex .sizing.reset-size8.size4{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size8.size5,.katex .sizing.reset-size8.size5{font-size:.625em}.katex .fontsize-ensurer.reset-size8.size6,.katex .sizing.reset-size8.size6{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size8.size7,.katex .sizing.reset-size8.size7{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size8.size8,.katex .sizing.reset-size8.size8{font-size:1em}.katex .fontsize-ensurer.reset-size8.size9,.katex .sizing.reset-size8.size9{font-size:1.2em}.katex .fontsize-ensurer.reset-size8.size10,.katex .sizing.reset-size8.size10{font-size:1.4402777778em}.katex .fontsize-ensurer.reset-size8.size11,.katex .sizing.reset-size8.size11{font-size:1.7277777778em}.katex .fontsize-ensurer.reset-size9.size1,.katex .sizing.reset-size9.size1{font-size:.2893518519em}.katex .fontsize-ensurer.reset-size9.size2,.katex .sizing.reset-size9.size2{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size9.size3,.katex .sizing.reset-size9.size3{font-size:.4050925926em}.katex .fontsize-ensurer.reset-size9.size4,.katex .sizing.reset-size9.size4{font-size:.462962963em}.katex .fontsize-ensurer.reset-size9.size5,.katex .sizing.reset-size9.size5{font-size:.5208333333em}.katex .fontsize-ensurer.reset-size9.size6,.katex .sizing.reset-size9.size6{font-size:.5787037037em}.katex .fontsize-ensurer.reset-size9.size7,.katex .sizing.reset-size9.size7{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size9.size8,.katex .sizing.reset-size9.size8{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size9.size9,.katex .sizing.reset-size9.size9{font-size:1em}.katex .fontsize-ensurer.reset-size9.size10,.katex .sizing.reset-size9.size10{font-size:1.2002314815em}.katex .fontsize-ensurer.reset-size9.size11,.katex .sizing.reset-size9.size11{font-size:1.4398148148em}.katex .fontsize-ensurer.reset-size10.size1,.katex .sizing.reset-size10.size1{font-size:.2410800386em}.katex .fontsize-ensurer.reset-size10.size2,.katex .sizing.reset-size10.size2{font-size:.2892960463em}.katex .fontsize-ensurer.reset-size10.size3,.katex .sizing.reset-size10.size3{font-size:.337512054em}.katex .fontsize-ensurer.reset-size10.size4,.katex .sizing.reset-size10.size4{font-size:.3857280617em}.katex .fontsize-ensurer.reset-size10.size5,.katex .sizing.reset-size10.size5{font-size:.4339440694em}.katex .fontsize-ensurer.reset-size10.size6,.katex .sizing.reset-size10.size6{font-size:.4821600771em}.katex .fontsize-ensurer.reset-size10.size7,.katex .sizing.reset-size10.size7{font-size:.5785920926em}.katex .fontsize-ensurer.reset-size10.size8,.katex .sizing.reset-size10.size8{font-size:.6943105111em}.katex .fontsize-ensurer.reset-size10.size9,.katex .sizing.reset-size10.size9{font-size:.8331726133em}.katex .fontsize-ensurer.reset-size10.size10,.katex .sizing.reset-size10.size10{font-size:1em}.katex .fontsize-ensurer.reset-size10.size11,.katex .sizing.reset-size10.size11{font-size:1.1996142719em}.katex .fontsize-ensurer.reset-size11.size1,.katex .sizing.reset-size11.size1{font-size:.2009646302em}.katex .fontsize-ensurer.reset-size11.size2,.katex .sizing.reset-size11.size2{font-size:.2411575563em}.katex .fontsize-ensurer.reset-size11.size3,.katex .sizing.reset-size11.size3{font-size:.2813504823em}.katex .fontsize-ensurer.reset-size11.size4,.katex .sizing.reset-size11.size4{font-size:.3215434084em}.katex .fontsize-ensurer.reset-size11.size5,.katex .sizing.reset-size11.size5{font-size:.3617363344em}.katex .fontsize-ensurer.reset-size11.size6,.katex .sizing.reset-size11.size6{font-size:.4019292605em}.katex .fontsize-ensurer.reset-size11.size7,.katex .sizing.reset-size11.size7{font-size:.4823151125em}.katex .fontsize-ensurer.reset-size11.size8,.katex .sizing.reset-size11.size8{font-size:.578778135em}.katex .fontsize-ensurer.reset-size11.size9,.katex .sizing.reset-size11.size9{font-size:.6945337621em}.katex .fontsize-ensurer.reset-size11.size10,.katex .sizing.reset-size11.size10{font-size:.8336012862em}.katex .fontsize-ensurer.reset-size11.size11,.katex .sizing.reset-size11.size11{font-size:1em}.katex .delimsizing.size1{font-family:KaTeX_Size1}.katex .delimsizing.size2{font-family:KaTeX_Size2}.katex .delimsizing.size3{font-family:KaTeX_Size3}.katex .delimsizing.size4{font-family:KaTeX_Size4}.katex .delimsizing.mult .delim-size1>span{font-family:KaTeX_Size1}.katex .delimsizing.mult .delim-size4>span{font-family:KaTeX_Size4}.katex .nulldelimiter{display:inline-block;width:.12em}.katex .delimcenter,.katex .op-symbol{position:relative}.katex .op-symbol.small-op{font-family:KaTeX_Size1}.katex .op-symbol.large-op{font-family:KaTeX_Size2}.katex .accent>.vlist-t,.katex .op-limits>.vlist-t{text-align:center}.katex .accent .accent-body{position:relative}.katex .accent .accent-body:not(.accent-full){width:0}.katex .overlay{display:block}.katex .mtable .vertical-separator{display:inline-block;min-width:1px}.katex .mtable .arraycolsep{display:inline-block}.katex .mtable .col-align-c>.vlist-t{text-align:center}.katex .mtable .col-align-l>.vlist-t{text-align:left}.katex .mtable .col-align-r>.vlist-t{text-align:right}.katex .svg-align{text-align:left}.katex svg{fill:currentColor;stroke:currentColor;display:block;height:inherit;position:absolute;width:100%}.katex svg path{stroke:none}.katex svg{fill-rule:nonzero;fill-opacity:1;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1}.katex img{border-style:none;max-height:none;max-width:none;min-height:0;min-width:0}.katex .stretchy{display:block;overflow:hidden;position:relative;width:100%}.katex .stretchy:after,.katex .stretchy:before{content:""}.katex .hide-tail{overflow:hidden;position:relative;width:100%}.katex .halfarrow-left{left:0;overflow:hidden;position:absolute;width:50.2%}.katex .halfarrow-right{overflow:hidden;position:absolute;right:0;width:50.2%}.katex .brace-left{left:0;overflow:hidden;position:absolute;width:25.1%}.katex .brace-center{left:25%;overflow:hidden;position:absolute;width:50%}.katex .brace-right{overflow:hidden;position:absolute;right:0;width:25.1%}.katex .x-arrow-pad{padding:0 .5em}.katex .cd-arrow-pad{padding:0 .55556em 0 .27778em}.katex .mover,.katex .munder,.katex .x-arrow{text-align:center}.katex .boxpad{padding:0 .3em}.katex .fbox,.katex .fcolorbox{border:.04em solid;box-sizing:border-box}.katex .cancel-pad{padding:0 .2em}.katex .cancel-lap{margin-left:-.2em;margin-right:-.2em}.katex .sout{border-bottom-style:solid;border-bottom-width:.08em}.katex .angl{border-right:.049em solid;border-top:.049em solid;box-sizing:border-box;margin-right:.03889em}.katex .anglpad{padding:0 .03889em}.katex .eqn-num:before{content:"(" counter(katexEqnNo) ")";counter-increment:katexEqnNo}.katex .mml-eqn-num:before{content:"(" counter(mmlEqnNo) ")";counter-increment:mmlEqnNo}.katex .mtr-glue{width:50%}.katex .cd-vert-arrow{display:inline-block;position:relative}.katex .cd-label-left{display:inline-block;position:absolute;right:calc(50% + .3em);text-align:left}.katex .cd-label-right{display:inline-block;left:calc(50% + .3em);position:absolute;text-align:right}.katex-display{display:block;margin:1em 0;text-align:center}.katex-display>.katex{display:block;text-align:center;white-space:nowrap}.katex-display>.katex>.katex-html{display:block;position:relative}.katex-display>.katex>.katex-html>.tag{position:absolute;right:0}.katex-display.leqno>.katex>.katex-html>.tag{left:0;right:auto}.katex-display.fleqn>.katex{padding-left:2em;text-align:left}body{counter-reset:katexEqnNo mmlEqnNo}
frontend/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 ADDED
Binary file (28.1 kB). View file
 
frontend/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff ADDED
Binary file (33.5 kB). View file
 
frontend/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf ADDED
Binary file (63.6 kB). View file
 
frontend/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf ADDED
Binary file (12.4 kB). View file
 
frontend/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff ADDED
Binary file (7.72 kB). View file
 
frontend/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 ADDED
Binary file (6.91 kB). View file
 
frontend/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff ADDED
Binary file (7.66 kB). View file
 
frontend/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 ADDED
Binary file (6.91 kB). View file
 
frontend/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf ADDED
Binary file (12.3 kB). View file
 
frontend/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf ADDED
Binary file (19.6 kB). View file
 
frontend/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff ADDED
Binary file (13.3 kB). View file
 
frontend/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 ADDED
Binary file (11.3 kB). View file
 
frontend/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf ADDED
Binary file (19.6 kB). View file
 
frontend/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 ADDED
Binary file (11.3 kB). View file
 
frontend/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff ADDED
Binary file (13.2 kB). View file
 
frontend/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 ADDED
Binary file (25.3 kB). View file
 
frontend/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff ADDED
Binary file (29.9 kB). View file
 
frontend/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf ADDED
Binary file (51.3 kB). View file
 
frontend/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 ADDED
Binary file (16.8 kB). View file
 
frontend/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf ADDED
Binary file (33 kB). View file
 
frontend/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff ADDED
Binary file (19.4 kB). View file
 
frontend/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf ADDED
Binary file (33.6 kB). View file
 
frontend/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff ADDED
Binary file (19.7 kB). View file