diff --git a/Dockerfile.webapp b/Dockerfile.webapp
new file mode 100644
index 0000000..e1a3ed9
--- /dev/null
+++ b/Dockerfile.webapp
@@ -0,0 +1,24 @@
+FROM python:3.11-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+
+WORKDIR /app
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends build-essential \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt pyproject.toml README.md ./
+COPY src ./src
+COPY configs ./configs
+COPY images ./images
+COPY webapp ./webapp
+
+RUN pip install --no-cache-dir --upgrade pip \
+ && pip install --no-cache-dir fastapi "uvicorn[standard]" pydantic \
+ && pip install --no-cache-dir -e .
+
+EXPOSE 8055
+
+CMD ["uvicorn", "webapp.app:app", "--host", "0.0.0.0", "--port", "8055"]
diff --git a/README.md b/README.md
index 6c81b8e..baff419 100644
--- a/README.md
+++ b/README.md
@@ -134,10 +134,11 @@ Grade consistency of generated climbs, measured by the trained grade predictor:
| Exact requested V-grade | 28.2% | 29.5% | 27.0% |
| Within ±1 V-grade | 70.8% | 68.5% | 73.0% |
| Within ±2 V-grades | 92.0% | 90.5% | 93.5% |
-| Mean V-grade error | -- | -0.18 | -0.30 |
+| Mean V-grade error | — | -0.18 | -0.30 |
Interpretation: the generator is usually structurally valid and usually close to the requested grade according to the critic, but exact grade control remains imperfect. That is expected: this is a small GPT-style model trained on symbolic route data, not a production setter.
+
---
## Repository layout
@@ -378,7 +379,7 @@ python scripts/demo_generate_tb2.py \
--annotate
```
-### CPU/VPS-friendly run
+### CPU- friendly run
```bash
python scripts/demo_generate_tb2.py \
@@ -576,26 +577,96 @@ The visualizations are calibrated to match the existing board images, but any ch
src/climbingboardgpt/visualization.py
```
+
---
-## Next step: webapp demo
+## Webapp demo
-The next planned layer is a simple webapp with:
+The repository includes a lightweight FastAPI webapp. It is inference-only:
-1. grade prediction from board + angle + frames string,
-2. route generation from board + angle + target grade,
-3. rendered PNG output for both generated climbs and user-submitted climbs.
+- loads the generator and grade predictor once at startup,
+- serves the TB2/Kilter board images as static assets,
+- returns hold coordinates and roles as JSON,
+- draws the climb overlay in the browser as SVG.
-The webapp should use the same backend helpers already added here:
+### Run locally
+
+From the repository root:
+
+```bash
+pip install fastapi "uvicorn[standard]" pydantic
+uvicorn webapp.app:app --host 127.0.0.1 --port 8055
+```
+
+Then open:
```text
-load_route_generator(...)
-generate_route(...)
-load_grade_predictor(...)
-predict_frames_grade(...)
-visualize_route_tokens(...)
+http://127.0.0.1:8055
```
+### Run with Docker
+
+```bash
+docker compose -f docker-compose.webapp.yml up -d --build
+```
+
+The service binds to localhost only:
+
+```text
+127.0.0.1:8055
+```
+
+### Required files for the webapp
+
+The webapp does not need raw SQLite databases. It needs:
+
+```text
+models/joint_route_gpt_generator.pth
+models/joint_transformer_grade_predictor.pth
+data/processed/tokenized/token_metadata.csv
+data/processed/tokenized/token_vocab.json
+configs/
+images/
+src/climbingboardgpt/
+webapp/
+```
+
+### API endpoints
+
+```text
+GET /api/health
+GET /api/boards
+POST /api/generate
+POST /api/predict
+```
+
+Example generation payload:
+
+```json
+{
+ "board": "tb2",
+ "angle": 40,
+ "grade": 6,
+ "temperature": 0.9,
+ "top_k": 50,
+ "max_new_tokens": 40
+}
+```
+
+Example prediction payload:
+
+```json
+{
+ "board": "kilter",
+ "angle": 40,
+ "frames": "p1127r12p1196r13p1216r13p1388r14"
+}
+```
+
+Board-size-specific generation is a planned future extension. For now, the demo uses the full TB2 12x12 and Kilter 16x12-style background images and placement sets.
+
+
+
# License
This project is licensed under the MIT License. See the [`LICENSE`](LICENSE) file for details.
diff --git a/docker-compose.webapp.yml b/docker-compose.webapp.yml
new file mode 100644
index 0000000..2fd5f63
--- /dev/null
+++ b/docker-compose.webapp.yml
@@ -0,0 +1,16 @@
+services:
+ climbingboardgpt-webapp:
+ build:
+ context: .
+ dockerfile: Dockerfile.webapp
+ container_name: climbingboardgpt-webapp
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:8055:8055"
+ environment:
+ CBGPT_DEVICE: "cpu"
+ CBGPT_TORCH_THREADS: "1"
+ volumes:
+ - ./models:/app/models:ro
+ - ./data/processed/tokenized:/app/data/processed/tokenized:ro
+ - ./images:/app/images:ro
diff --git a/pyproject.toml b/pyproject.toml
index 5341fdf..b0e2c07 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,14 +7,14 @@ name = "climbingboardgpt"
version = "0.2.1"
description = "Unified TB2/Kilter transformer route modeling, grade prediction, and GPT-style route generation."
readme = "README.md"
-requires-python = ">=3.10"
+requires-python = ""
dependencies = [
- "numpy>=1.24",
- "pandas>=2.0",
- "scipy>=1.10",
- "scikit-learn>=1.3",
- "matplotlib>=3.7",
- "torch>=2.1",
+ "numpy",
+ "pandas",
+ "scipy",
+ "scikit-learn",
+ "matplotlib",
+ "torch"
]
[tool.setuptools.packages.find]
diff --git a/requirements.txt b/requirements.txt
index c460b5f..a8e98b3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,6 +4,3 @@ scipy
scikit-learn
matplotlib
torch
-jupyter
-nbformat
-boardlib
diff --git a/webapp/__pycache__/app.cpython-314.pyc b/webapp/__pycache__/app.cpython-314.pyc
new file mode 100644
index 0000000..1d42341
Binary files /dev/null and b/webapp/__pycache__/app.cpython-314.pyc differ
diff --git a/webapp/app.py b/webapp/app.py
new file mode 100644
index 0000000..a032538
--- /dev/null
+++ b/webapp/app.py
@@ -0,0 +1,741 @@
+"""FastAPI web demo for ClimbingBoardGPT.
+
+Inference-only design:
+ - model checkpoints and token metadata are loaded once at startup;
+ - board background images are served as static files;
+ - each request returns route hold coordinates/roles as JSON;
+ - the browser draws the overlay as SVG on top of the already-loaded image.
+
+Local run:
+
+ uvicorn webapp.app:app --host 127.0.0.1 --port 8055
+"""
+from __future__ import annotations
+
+import hashlib
+import os
+import re
+import sys
+import time
+from pathlib import Path
+from typing import Any
+
+import pandas as pd
+import torch
+from fastapi import FastAPI, HTTPException
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel, Field
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO_ROOT / "src"))
+
+from climbingboardgpt.config import load_board_config
+from climbingboardgpt.generation import validity_summary
+from climbingboardgpt.inference import (
+ generate_route,
+ load_grade_predictor,
+ load_route_generator,
+ predict_frames_grade,
+ predict_route_grade,
+)
+from climbingboardgpt.visualization import BOARD_CANVAS, load_token_metadata, tokens_to_route_records
+
+
+DEVICE = os.getenv("CBGPT_DEVICE") or ("cuda" if torch.cuda.is_available() else "cpu")
+TORCH_THREADS = os.getenv("CBGPT_TORCH_THREADS")
+TORCH_THREADS_INT = int(TORCH_THREADS) if TORCH_THREADS else None
+
+MODEL_DIR = Path(os.getenv("CBGPT_MODEL_DIR", REPO_ROOT / "models"))
+TOKENIZED_DIR = Path(
+ os.getenv("CBGPT_TOKENIZED_DIR", REPO_ROOT / "data" / "processed" / "tokenized")
+)
+KNOWN_ROUTES_PATH = Path(
+ os.getenv("CBGPT_KNOWN_ROUTES_PATH", TOKENIZED_DIR / "route_sequences.csv")
+)
+
+GENERATOR_PATH = MODEL_DIR / "joint_route_gpt_generator.pth"
+GRADE_MODEL_PATH = MODEL_DIR / "joint_transformer_grade_predictor.pth"
+
+BOARD_IMAGE_PATHS = {
+ "tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png",
+ "kilter": REPO_ROOT / "images" / "kilter-original-16x12_compose.png",
+}
+
+
+class GenerateRequest(BaseModel):
+ board: str = Field(..., pattern="^(tb2|kilter)$")
+ angle: int = Field(40, ge=0, le=80)
+ grade: int = Field(6, ge=0, le=16)
+ temperature: float = Field(0.9, ge=0.1, le=2.0)
+ top_k: int = Field(50, ge=1, le=500)
+ max_new_tokens: int = Field(40, ge=4, le=80)
+ valid_only: bool = Field(True, description="Retry generation until a basic-valid climb is sampled.")
+ max_attempts: int = Field(8, ge=1, le=25, description="Maximum attempts when valid_only is true.")
+
+
+class PredictRequest(BaseModel):
+ board: str = Field(..., pattern="^(tb2|kilter)$")
+ angle: int = Field(..., ge=0, le=80)
+ frames: str = Field(..., min_length=1, max_length=500)
+
+
+def _json_safe(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {str(k): _json_safe(v) for k, v in value.items()}
+ if isinstance(value, list):
+ return [_json_safe(v) for v in value]
+ if isinstance(value, tuple):
+ return [_json_safe(v) for v in value]
+ if hasattr(value, "item"):
+ try:
+ return value.item()
+ except Exception:
+ pass
+ try:
+ if pd.isna(value):
+ return None
+ except Exception:
+ pass
+ return value
+
+
+def _board_config(board: str):
+ try:
+ return app.state.board_configs[board]
+ except KeyError as exc:
+ raise HTTPException(status_code=400, detail=f"Unknown board: {board}") from exc
+
+
+def _require_generator():
+ if app.state.generator is None:
+ raise HTTPException(
+ status_code=503,
+ detail=f"Route generator checkpoint not loaded. Expected {GENERATOR_PATH}.",
+ )
+ return app.state.generator
+
+
+def _require_grade_predictor():
+ if app.state.grade_predictor is None:
+ raise HTTPException(
+ status_code=503,
+ detail=f"Grade predictor checkpoint not loaded. Expected {GRADE_MODEL_PATH}.",
+ )
+ return app.state.grade_predictor
+
+
+def _tokens_to_holds(board: str, tokens: list[str]) -> list[dict[str, Any]]:
+ route_records = tokens_to_route_records(tokens)
+ if route_records.empty:
+ return []
+
+ token_meta = app.state.token_meta
+ coords = token_meta[
+ (token_meta["kind"] == "hold")
+ & (token_meta["board_key"].astype(str) == str(board))
+ ][["board_key", "board_token_prefix", "placement_id", "x", "y"]].drop_duplicates(
+ ["board_key", "placement_id"]
+ )
+
+ merged = route_records.merge(
+ coords,
+ on=["board_token_prefix", "placement_id"],
+ how="left",
+ ).dropna(subset=["x", "y"])
+
+ holds = []
+ for _, row in merged.iterrows():
+ holds.append(
+ {
+ "token": str(row["token"]),
+ "placement_id": int(row["placement_id"]),
+ "role": str(row["role"]),
+ "x": float(row["x"]),
+ "y": float(row["y"]),
+ }
+ )
+ return holds
+
+
+
+def _file_version(path: Path) -> str:
+ """Small cache-busting version string based on mtime and file size."""
+ try:
+ stat = path.stat()
+ return f"{int(stat.st_mtime)}-{stat.st_size}"
+ except FileNotFoundError:
+ return "missing"
+
+
+def _static_image_url(board: str) -> str:
+ image_path = BOARD_IMAGE_PATHS[board]
+ return f"/board-images/{image_path.name}?board={board}&v={_file_version(image_path)}"
+
+
+def _file_info(path: Path) -> dict[str, Any]:
+ if not path.exists():
+ return {
+ "path": str(path),
+ "exists": False,
+ "size_bytes": None,
+ "mtime": None,
+ "sha256_16": None,
+ }
+ data = path.read_bytes()
+ stat = path.stat()
+ return {
+ "path": str(path),
+ "exists": True,
+ "size_bytes": stat.st_size,
+ "mtime": stat.st_mtime,
+ "sha256_16": hashlib.sha256(data).hexdigest()[:16],
+ }
+
+
+def _board_available_holds(board: str) -> list[dict[str, Any]]:
+ """Return clickable hold coordinates for a board.
+
+ Some token-metadata rows can contain missing coordinates for hold-role tokens
+ that exist in the vocabulary but cannot be plotted directly. Those rows must
+ be removed before returning JSON, because FastAPI's JSONResponse rejects NaN.
+ """
+ token_meta = app.state.token_meta
+ holds = token_meta[
+ (token_meta["kind"] == "hold")
+ & (token_meta["board_key"].astype(str) == str(board))
+ ][["board_key", "board_token_prefix", "placement_id", "x", "y"]].drop_duplicates(
+ ["board_key", "placement_id"]
+ )
+
+ holds = holds.copy()
+ holds["x"] = pd.to_numeric(holds["x"], errors="coerce")
+ holds["y"] = pd.to_numeric(holds["y"], errors="coerce")
+ holds = holds.dropna(subset=["x", "y"])
+
+ return [
+ {
+ "placement_id": int(row["placement_id"]),
+ "x": float(row["x"]),
+ "y": float(row["y"]),
+ }
+ for _, row in holds.sort_values(["y", "x", "placement_id"]).iterrows()
+ ]
+
+
+
+
+def _role_limit_validity(tokens: list[str], requested_board_prefix: str) -> dict[str, Any]:
+ """Extra webapp validity checks for start/finish counts.
+
+ The lower-level validity check requires at least one start and at least one
+ finish, but BoardLib-style climbs should not have arbitrarily many starts or
+ finishes. For this demo we enforce at most two starts and at most two finishes.
+ """
+ counts = {
+ "start": 0,
+ "middle": 0,
+ "finish": 0,
+ "foot": 0,
+ "unknown": 0,
+ }
+
+ for token in tokens:
+ match = HOLD_TOKEN_RE.match(str(token))
+ if match is None:
+ continue
+ board_prefix, _placement_id, role = match.groups()
+ if str(board_prefix) != str(requested_board_prefix):
+ continue
+ counts[role] = counts.get(role, 0) + 1
+
+ n_start = int(counts.get("start", 0))
+ n_finish = int(counts.get("finish", 0))
+
+ return {
+ "role_counts": counts,
+ "n_start_holds": n_start,
+ "n_finish_holds": n_finish,
+ "has_at_most_two_starts": n_start <= 2,
+ "has_at_most_two_finishes": n_finish <= 2,
+ "role_limits_valid": n_start <= 2 and n_finish <= 2,
+ }
+
+
+def _combined_validity(tokens: list[str], requested_board_prefix: str) -> dict[str, Any]:
+ """Combined structural validity used by webapp generation and prediction."""
+ base = validity_summary(tokens, requested_board_prefix=requested_board_prefix)
+ role_limits = _role_limit_validity(tokens, requested_board_prefix=requested_board_prefix)
+
+ base_basic = bool(base.get("basic_valid", False))
+ combined_basic = bool(base_basic and role_limits["role_limits_valid"])
+
+ return {
+ **base,
+ **role_limits,
+ "base_basic_valid": base_basic,
+ "basic_valid": combined_basic,
+ "webapp_basic_valid": combined_basic,
+ }
+
+
+def _invalid_prediction_reasons(validity: dict[str, Any]) -> list[str]:
+ """Human-readable reasons a route should not be grade-predicted."""
+ reasons: list[str] = []
+
+ if int(validity.get("n_hold_tokens", 0)) == 0:
+ reasons.append("no hold tokens were found in the frames string")
+ if not bool(validity.get("one_board_only", True)):
+ reasons.append("the route contains holds from more than one board")
+ if not bool(validity.get("matches_requested_board", True)):
+ reasons.append("the route contains holds from the wrong board")
+ if bool(validity.get("has_duplicate_placements", False)):
+ reasons.append("the route contains duplicate placements")
+ if not bool(validity.get("has_start", False)):
+ reasons.append("the route has no start hold")
+ if not bool(validity.get("has_finish", False)):
+ reasons.append("the route has no finish hold")
+ if int(validity.get("n_start_holds", 0)) > 2:
+ reasons.append("the route has more than two start holds")
+ if int(validity.get("n_finish_holds", 0)) > 2:
+ reasons.append("the route has more than two finish holds")
+ if int(validity.get("n_hold_tokens", 0)) < 3:
+ reasons.append("the route has fewer than 3 holds")
+
+ return reasons
+
+
+
+HOLD_TOKEN_RE = re.compile(r"^<([A-Z0-9_]+)_p(\d+)_(start|middle|finish|foot|unknown)>$")
+
+
+def _angle_key(angle: Any) -> int:
+ try:
+ return int(round(float(angle)))
+ except Exception:
+ return 0
+
+
+def _route_signature_from_holds(board: str, angle: Any, holds: list[dict[str, Any]]) -> str | None:
+ """Canonical exact-match signature: board + angle + hold-role multiset."""
+ parts = []
+ for hold in holds:
+ try:
+ placement_id = int(hold["placement_id"])
+ role = str(hold["role"])
+ except Exception:
+ continue
+ parts.append(f"{role}:{placement_id}")
+
+ if not parts:
+ return None
+
+ return f"{board}|{_angle_key(angle)}|" + "|".join(sorted(parts))
+
+
+def _holds_from_sequence(sequence: str) -> list[dict[str, Any]]:
+ holds: list[dict[str, Any]] = []
+ for token in str(sequence).split():
+ match = HOLD_TOKEN_RE.match(token)
+ if match is None:
+ continue
+ holds.append(
+ {
+ "board_prefix": match.group(1),
+ "placement_id": int(match.group(2)),
+ "role": match.group(3),
+ }
+ )
+ return holds
+
+
+
+def _load_available_angles(path: Path) -> dict[str, list[int]]:
+ """Load available wall angles by board from route_sequences.csv.
+
+ Falls back to a conservative common angle set if the processed route table
+ is unavailable. This keeps the webapp usable for demos even if only model
+ checkpoints/token metadata are present.
+ """
+ fallback = {
+ "tb2": [20, 25, 30, 35, 40, 45, 50],
+ "kilter": [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70],
+ }
+ if not path.exists():
+ return fallback
+
+ try:
+ df = pd.read_csv(path, usecols=["board_key", "angle"])
+ except Exception:
+ return fallback
+
+ out: dict[str, list[int]] = {}
+ for board, frame in df.dropna(subset=["board_key", "angle"]).groupby("board_key"):
+ values = sorted({_angle_key(angle) for angle in frame["angle"].tolist()})
+ if values:
+ out[str(board)] = values
+
+ for board, values in fallback.items():
+ out.setdefault(board, values)
+
+ return out
+
+
+
+def _load_available_grades(path: Path) -> dict[str, list[int]]:
+ """Load available grouped V-grades by board from route_sequences.csv."""
+ fallback = {
+ "tb2": list(range(0, 16)),
+ "kilter": list(range(0, 16)),
+ }
+ if not path.exists():
+ return fallback
+
+ try:
+ df = pd.read_csv(path, usecols=["board_key", "grouped_v"])
+ except Exception:
+ return fallback
+
+ out: dict[str, list[int]] = {}
+ for board, frame in df.dropna(subset=["board_key", "grouped_v"]).groupby("board_key"):
+ values = sorted({int(v) for v in frame["grouped_v"].tolist()})
+ if values:
+ out[str(board)] = values
+
+ for board, values in fallback.items():
+ out.setdefault(board, values)
+
+ return out
+
+
+def _load_known_route_lookup(path: Path) -> dict[str, dict[str, Any]]:
+ """Load exact known-route signatures once at startup.
+
+ This is intentionally an exact O(1) lookup, not a nearest-neighbor search.
+ The key is board + angle + exact hold-role set. Multiple real climbs can
+ share the same signature, so each signature stores a count and a few examples.
+ """
+ lookup: dict[str, dict[str, Any]] = {}
+ if not path.exists():
+ return lookup
+
+ usecols = [
+ "uuid",
+ "board_key",
+ "climb_name",
+ "setter_username",
+ "angle",
+ "grouped_v",
+ "boulder_grade",
+ "quality_average",
+ "ascensionist_count",
+ "frames",
+ "sequence_no_grade",
+ ]
+
+ try:
+ df = pd.read_csv(path, usecols=lambda col: col in set(usecols))
+ except Exception:
+ return lookup
+
+ for _, row in df.iterrows():
+ board = str(row.get("board_key", ""))
+ angle = row.get("angle", 0)
+ holds = _holds_from_sequence(str(row.get("sequence_no_grade", "")))
+ signature = _route_signature_from_holds(board, angle, holds)
+ if signature is None:
+ continue
+
+ entry = lookup.setdefault(signature, {"count": 0, "examples": []})
+ entry["count"] += 1
+
+ if len(entry["examples"]) < 5:
+ example = {
+ "uuid": str(row.get("uuid", "")),
+ "climb_name": None if pd.isna(row.get("climb_name", None)) else str(row.get("climb_name", "")),
+ "setter_username": None if pd.isna(row.get("setter_username", None)) else str(row.get("setter_username", "")),
+ "angle": _angle_key(angle),
+ "grouped_v": None if pd.isna(row.get("grouped_v", None)) else int(row.get("grouped_v")),
+ "boulder_grade": None if pd.isna(row.get("boulder_grade", None)) else str(row.get("boulder_grade", "")),
+ "quality_average": None if pd.isna(row.get("quality_average", None)) else float(row.get("quality_average")),
+ "ascensionist_count": None if pd.isna(row.get("ascensionist_count", None)) else int(row.get("ascensionist_count")),
+ "frames": None if pd.isna(row.get("frames", None)) else str(row.get("frames", "")),
+ }
+ entry["examples"].append(example)
+
+ return lookup
+
+
+def _known_route_status(board: str, angle: Any, holds: list[dict[str, Any]]) -> dict[str, Any]:
+ signature = _route_signature_from_holds(board, angle, holds)
+ if signature is None:
+ return {
+ "checked": bool(getattr(app.state, "known_route_lookup", None)),
+ "is_known": False,
+ "match_count": 0,
+ "examples": [],
+ "signature": None,
+ }
+
+ lookup = getattr(app.state, "known_route_lookup", {})
+ match = lookup.get(signature)
+
+ return {
+ "checked": bool(lookup),
+ "is_known": match is not None,
+ "match_count": int(match["count"]) if match else 0,
+ "examples": match["examples"] if match else [],
+ "signature": signature,
+ }
+
+
+def _payload(result: dict[str, Any], tokens: list[str] | None = None) -> dict[str, Any]:
+ board = str(result["board_key"])
+ tokens = list(tokens if tokens is not None else result.get("tokens", []))
+ extent = [float(v) for v in BOARD_CANVAS[board]["extent"]]
+ image_path = BOARD_IMAGE_PATHS[board]
+ holds = _tokens_to_holds(board, tokens)
+ angle = result.get("requested_angle", result.get("angle", 0))
+
+ return _json_safe(
+ {
+ **result,
+ "tokens": tokens,
+ "holds": holds,
+ "known_climb": _known_route_status(board, angle, holds),
+ "canvas": {
+ "extent": extent,
+ "x_min": extent[0],
+ "x_max": extent[1],
+ "y_min": extent[2],
+ "y_max": extent[3],
+ "width": extent[1] - extent[0],
+ "height": extent[3] - extent[2],
+ },
+ "background_url": _static_image_url(board),
+ }
+ )
+
+
+app = FastAPI(title="ClimbingBoardGPT", version="0.1.0")
+app.mount("/static", StaticFiles(directory=REPO_ROOT / "webapp" / "static"), name="static")
+app.mount("/board-images", StaticFiles(directory=REPO_ROOT / "images"), name="board-images")
+
+
+@app.on_event("startup")
+def startup() -> None:
+ if TORCH_THREADS_INT is not None:
+ torch.set_num_threads(TORCH_THREADS_INT)
+
+ app.state.board_configs = {
+ "tb2": load_board_config("tb2", config_dir=REPO_ROOT / "configs"),
+ "kilter": load_board_config("kilter", config_dir=REPO_ROOT / "configs"),
+ }
+ app.state.token_meta = load_token_metadata(TOKENIZED_DIR)
+ app.state.available_angles = _load_available_angles(KNOWN_ROUTES_PATH)
+ app.state.available_grades = _load_available_grades(KNOWN_ROUTES_PATH)
+ started = time.time()
+ app.state.known_route_lookup = _load_known_route_lookup(KNOWN_ROUTES_PATH)
+ app.state.known_route_lookup_seconds = round(time.time() - started, 3)
+
+ app.state.generator = None
+ if GENERATOR_PATH.exists():
+ app.state.generator = load_route_generator(
+ GENERATOR_PATH,
+ device=DEVICE,
+ torch_threads=TORCH_THREADS_INT,
+ )
+
+ app.state.grade_predictor = None
+ if GRADE_MODEL_PATH.exists():
+ app.state.grade_predictor = load_grade_predictor(
+ GRADE_MODEL_PATH,
+ device=DEVICE,
+ torch_threads=TORCH_THREADS_INT,
+ )
+
+
+@app.get("/")
+def index():
+ return FileResponse(REPO_ROOT / "webapp" / "static" / "index.html")
+
+
+@app.get("/api/health")
+def health():
+ return {
+ "ok": True,
+ "device": DEVICE,
+ "generator_loaded": app.state.generator is not None,
+ "grade_predictor_loaded": app.state.grade_predictor is not None,
+ "known_route_signatures": len(getattr(app.state, "known_route_lookup", {})),
+ "known_route_lookup_seconds": getattr(app.state, "known_route_lookup_seconds", None),
+ "available_angles": getattr(app.state, "available_angles", {}),
+ "available_grades": getattr(app.state, "available_grades", {}),
+ "torch_threads": torch.get_num_threads(),
+ }
+
+
+@app.get("/api/boards")
+def boards():
+ payload = {}
+ for board, config in app.state.board_configs.items():
+ extent = [float(v) for v in BOARD_CANVAS[board]["extent"]]
+ payload[board] = {
+ "board_key": board,
+ "display_name": config.display_name,
+ "token_prefix": config.token_prefix,
+ "role_definitions": config.role_definitions,
+ "available_angles": getattr(app.state, "available_angles", {}).get(board, []),
+ "available_grades": getattr(app.state, "available_grades", {}).get(board, []),
+ "background_url": _static_image_url(board),
+ "canvas": {
+ "extent": extent,
+ "x_min": extent[0],
+ "x_max": extent[1],
+ "y_min": extent[2],
+ "y_max": extent[3],
+ "width": extent[1] - extent[0],
+ "height": extent[3] - extent[2],
+ },
+ }
+ return payload
+
+
+@app.get("/api/board-holds/{board}")
+def board_holds(board: str):
+ config = _board_config(board)
+ return _json_safe({
+ "board_key": board,
+ "display_name": config.display_name,
+ "token_prefix": config.token_prefix,
+ "role_definitions": config.role_definitions,
+ "holds": _board_available_holds(board),
+ })
+
+
+@app.get("/api/debug/images")
+def debug_images():
+ payload = {}
+ for board, image_path in BOARD_IMAGE_PATHS.items():
+ payload[board] = {
+ "background_url": _static_image_url(board),
+ "file": _file_info(image_path),
+ }
+ return payload
+
+
+@app.post("/api/generate")
+def generate(req: GenerateRequest):
+ generator = _require_generator()
+ config = _board_config(req.board)
+
+ attempts = req.max_attempts if req.valid_only else 1
+ result = None
+ sampled_results = []
+
+ for attempt in range(1, attempts + 1):
+ candidate = generate_route(
+ generator=generator,
+ board_config=config,
+ angle=req.angle,
+ grade=req.grade,
+ temperature=req.temperature,
+ top_k=req.top_k,
+ max_new_tokens=req.max_new_tokens,
+ )
+ candidate["generation_attempt"] = attempt
+ combined_validity = _combined_validity(
+ list(candidate.get("tokens", [])),
+ requested_board_prefix=config.token_prefix,
+ )
+ candidate.update(combined_validity)
+ candidate["webapp_validity"] = combined_validity
+
+ sampled_results.append(
+ {
+ "attempt": attempt,
+ "basic_valid": bool(candidate.get("basic_valid")),
+ "base_basic_valid": bool(candidate.get("base_basic_valid")),
+ "role_limits_valid": bool(candidate.get("role_limits_valid")),
+ "n_hold_tokens": int(candidate.get("n_hold_tokens", 0)),
+ "n_start_holds": int(candidate.get("n_start_holds", 0)),
+ "n_finish_holds": int(candidate.get("n_finish_holds", 0)),
+ "frames": candidate.get("frames", ""),
+ }
+ )
+
+ result = candidate
+ if not req.valid_only or bool(candidate.get("basic_valid")):
+ break
+
+ if result is None:
+ raise HTTPException(status_code=500, detail="Generation failed unexpectedly.")
+
+ result["requested_valid_only"] = bool(req.valid_only)
+ result["max_attempts"] = int(attempts)
+ result["attempts_used"] = int(result.get("generation_attempt", 1))
+ result["sampled_attempts"] = sampled_results
+
+ warnings = []
+ if req.valid_only and not bool(result.get("basic_valid")):
+ warnings.append(
+ f"Could not sample a valid climb after {attempts} attempts. Showing the last sample."
+ )
+ if not bool(result.get("role_limits_valid", True)):
+ warnings.append(
+ "Last sample violates start/finish role limits: at most two starts and at most two finishes."
+ )
+ elif req.valid_only and result["attempts_used"] > 1:
+ warnings.append(
+ f"Sampled {result['attempts_used']} climbs before finding a valid one."
+ )
+ elif not bool(result.get("basic_valid")):
+ warnings.append("Generated climb is structurally invalid.")
+
+ if app.state.grade_predictor is not None:
+ grade_result = predict_route_grade(app.state.grade_predictor, result["tokens"])
+ result.update(grade_result)
+ result["critic_v_error"] = (
+ int(result["predicted_grouped_v"]) - int(result["requested_grouped_v"])
+ )
+
+ if "warnings" in result and isinstance(result["warnings"], list):
+ result["warnings"].extend(warnings)
+ else:
+ result["warnings"] = warnings
+
+ return _payload(result)
+
+
+@app.post("/api/predict")
+def predict(req: PredictRequest):
+ predictor = _require_grade_predictor()
+ config = _board_config(req.board)
+
+ result = predict_frames_grade(
+ grade_predictor=predictor,
+ frames=req.frames,
+ angle=req.angle,
+ board_config=config,
+ df_token_meta=app.state.token_meta,
+ )
+
+ tokens = list(result["tokens"])
+ validity = _combined_validity(tokens, requested_board_prefix=config.token_prefix)
+ result.update(validity)
+ result["webapp_validity"] = validity
+
+ if not bool(validity.get("basic_valid", False)):
+ reasons = _invalid_prediction_reasons(validity)
+ raise HTTPException(
+ status_code=400,
+ detail=_json_safe(
+ {
+ "message": "Cannot predict grade for an invalid climb.",
+ "reasons": reasons,
+ "validity": validity,
+ }
+ ),
+ )
+
+ return _payload(result, tokens=tokens)
diff --git a/webapp/static/app.css b/webapp/static/app.css
new file mode 100644
index 0000000..d467f8c
--- /dev/null
+++ b/webapp/static/app.css
@@ -0,0 +1,347 @@
+:root {
+ --bg: #263238;
+ --bg-alt: #1f2a30;
+ --panel: #2f3d43;
+ --panel-soft: #37474f;
+ --ink: #eceff1;
+ --muted: #b0bec5;
+ --faint: #90a4ae;
+ --border: #455a64;
+ --accent: #80cbc4;
+ --accent-2: #ffcc80;
+ --danger: #ef9a9a;
+ --shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ font-family: var(--mono);
+ background:
+ radial-gradient(circle at top left, rgba(128, 203, 196, 0.07), transparent 32rem),
+ var(--bg);
+ color: var(--ink);
+}
+
+.site-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ padding: 1.35rem 2rem;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-alt);
+}
+
+.eyebrow {
+ margin: 0 0 0.35rem;
+ color: var(--accent);
+ font-size: 0.82rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+}
+
+.site-header h1 {
+ margin: 0;
+ font-size: clamp(1.35rem, 2vw, 2.05rem);
+ letter-spacing: -0.04em;
+}
+
+.site-header p {
+ margin: 0.4rem 0 0;
+ color: var(--muted);
+}
+
+.health {
+ font-size: 0.78rem;
+ color: var(--accent-2);
+ white-space: nowrap;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 0.45rem 0.7rem;
+ background: rgba(0, 0, 0, 0.15);
+}
+
+.layout {
+ display: grid;
+ grid-template-columns: 370px minmax(0, 1fr);
+ gap: 1.25rem;
+ padding: 1.25rem;
+ max-width: 1520px;
+ margin: 0 auto;
+}
+
+.controls {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.card, .result-card {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ box-shadow: var(--shadow);
+ padding: 1rem;
+}
+
+.card h2, .result-card h2 {
+ margin: 0 0 0.8rem;
+ font-size: 1.0rem;
+ color: var(--accent);
+ letter-spacing: -0.02em;
+}
+
+label {
+ display: block;
+ margin: 0.7rem 0;
+ font-size: 0.82rem;
+ color: var(--muted);
+}
+
+input, select, textarea {
+ display: block;
+ width: 100%;
+ margin-top: 0.28rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 0.6rem 0.7rem;
+ font: inherit;
+ color: var(--ink);
+ background: #1c272c;
+}
+
+input:focus, select:focus, textarea:focus {
+ outline: 2px solid rgba(128, 203, 196, 0.32);
+ border-color: var(--accent);
+}
+
+textarea {
+ resize: vertical;
+ min-height: 96px;
+ font-size: 0.78rem;
+}
+
+button {
+ width: 100%;
+ border: 1px solid #4db6ac;
+ border-radius: 6px;
+ padding: 0.68rem 0.9rem;
+ margin-top: 0.4rem;
+ font-weight: 700;
+ color: #102022;
+ background: var(--accent);
+ cursor: pointer;
+ font-family: var(--mono);
+}
+
+button:hover { filter: brightness(1.06); }
+button:disabled { opacity: 0.55; cursor: not-allowed; }
+
+button.secondary {
+ background: transparent;
+ color: var(--accent);
+ border-color: var(--border);
+}
+
+.button-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.55rem;
+}
+
+.note p, .small {
+ color: var(--muted);
+ font-size: 0.82rem;
+ line-height: 1.45;
+}
+
+.builder-list {
+ list-style: none;
+ margin: 0.8rem 0 0;
+ padding: 0;
+ color: var(--muted);
+ font-size: 0.78rem;
+ max-height: 160px;
+ overflow: auto;
+}
+
+.builder-list li {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.25rem 0;
+ border-bottom: 1px dashed rgba(176, 190, 197, 0.16);
+}
+
+.result-header {
+ text-align: center;
+ margin-bottom: 0.85rem;
+}
+
+.result-header h2 { margin-bottom: 0.25rem; }
+
+.result-header p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.84rem;
+}
+
+.board-stage {
+ position: relative;
+ width: 100%;
+ max-width: 960px;
+ margin: 0 auto;
+ border-radius: 8px;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ background: #111827;
+}
+
+.board-stage img {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+.board-stage svg {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: auto;
+}
+
+.click-target {
+ cursor: crosshair;
+}
+
+.route-marker, .builder-marker {
+ pointer-events: none;
+}
+
+.hover-marker {
+ pointer-events: none;
+ fill: none;
+ stroke: var(--accent-2);
+ stroke-width: 0.55;
+ opacity: 0.9;
+}
+
+.json-block {
+ margin-top: 1rem;
+ color: var(--muted);
+}
+
+.json-block pre {
+ overflow: auto;
+ max-height: 300px;
+ padding: 1rem;
+ background: #111a1f;
+ color: #cfd8dc;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ font-size: 0.76rem;
+}
+
+@media (max-width: 900px) {
+ .layout { grid-template-columns: 1fr; }
+ .site-header { flex-direction: column; }
+}
+
+
+.checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+}
+
+.checkbox-label input {
+ width: auto;
+ margin: 0;
+}
+
+.warning-box {
+ margin: 0.7rem auto 0.85rem;
+ max-width: 760px;
+ border: 1px solid rgba(255, 204, 128, 0.55);
+ background: rgba(255, 204, 128, 0.12);
+ color: var(--accent-2);
+ border-radius: 8px;
+ padding: 0.65rem 0.8rem;
+ font-size: 0.8rem;
+ text-align: left;
+ white-space: pre-line;
+}
+
+.clear-board {
+ width: auto;
+ min-width: 150px;
+ margin: 0.85rem auto 0;
+ display: inline-block;
+}
+
+
+.field-help {
+ display: block;
+ margin-top: 0.35rem;
+ color: var(--faint);
+ font-size: 0.72rem;
+ line-height: 1.35;
+}
+
+.explain dl {
+ margin: 0;
+}
+
+.explain dt {
+ color: var(--accent-2);
+ font-size: 0.78rem;
+ margin-top: 0.75rem;
+}
+
+.explain dt:first-child {
+ margin-top: 0;
+}
+
+.explain dd {
+ margin: 0.22rem 0 0;
+ color: var(--muted);
+ font-size: 0.78rem;
+ line-height: 1.45;
+}
+
+.explain code {
+ color: var(--accent-2);
+ background: rgba(0, 0, 0, 0.2);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 0.05rem 0.25rem;
+ font-size: 0.74rem;
+}
+
+.link-list {
+ margin: 0;
+ padding-left: 1.1rem;
+ color: var(--muted);
+ font-size: 0.82rem;
+ line-height: 1.6;
+}
+
+.link-list a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.link-list a:hover {
+ text-decoration: underline;
+}
+
+
+#data-acknowledgement-card p {
+ color: var(--muted);
+ font-size: 0.8rem;
+ line-height: 1.45;
+}
diff --git a/webapp/static/app.js b/webapp/static/app.js
new file mode 100644
index 0000000..8a880e1
--- /dev/null
+++ b/webapp/static/app.js
@@ -0,0 +1,702 @@
+const state = {
+ boards: {},
+ boardHolds: {},
+ activeBoard: "tb2",
+ lastResult: null,
+ builder: [],
+};
+
+const roleStyle = {
+ start: { fill: "#69f0ae", stroke: "#102022", r: 1.55, shape: "circle" },
+ middle: { fill: "#82b1ff", stroke: "#102022", r: 1.55, shape: "circle" },
+ finish: { fill: "#ff8a80", stroke: "#102022", r: 2.25, shape: "star" },
+ foot: { fill: "#ffd740", stroke: "#102022", r: 1.1, shape: "square" },
+ unknown: { fill: "#b0bec5", stroke: "#102022", r: 1.45, shape: "circle" },
+};
+
+const builderStyle = {
+ start: { fill: "#00e676", stroke: "#000000", r: 1.85, shape: "circle" },
+ middle: { fill: "#448aff", stroke: "#000000", r: 1.85, shape: "circle" },
+ finish: { fill: "#ff5252", stroke: "#000000", r: 2.6, shape: "star" },
+ foot: { fill: "#ffea00", stroke: "#000000", r: 1.25, shape: "square" },
+ unknown: { fill: "#cfd8dc", stroke: "#000000", r: 1.85, shape: "circle" },
+};
+
+function $(id) {
+ return document.getElementById(id);
+}
+
+async function fetchJson(url, options = {}) {
+ const response = await fetch(url, options);
+ const text = await response.text();
+ let payload;
+ try {
+ payload = text ? JSON.parse(text) : {};
+ } catch {
+ payload = { detail: text };
+ }
+ if (!response.ok) {
+ const detail = payload.detail ?? payload;
+ let message;
+ if (typeof detail === "string") {
+ message = detail;
+ } else if (Array.isArray(detail)) {
+ message = detail.map((item) => item.msg || JSON.stringify(item)).join("\n");
+ } else {
+ message = JSON.stringify(detail, null, 2);
+ }
+ throw new Error(message || `Request failed: ${response.status}`);
+ }
+ return payload;
+}
+
+function setBusy(button, busy) {
+ button.disabled = busy;
+ button.textContent = busy ? "Working…" : button.dataset.label;
+}
+
+async function ensureBoardHolds(boardKey) {
+ if (!state.boardHolds[boardKey]) {
+ const payload = await fetchJson(`/api/board-holds/${boardKey}`);
+ state.boardHolds[boardKey] = payload.holds || [];
+ }
+ return state.boardHolds[boardKey];
+}
+
+async function setBoardBackground(boardKey) {
+ const board = state.boards[boardKey];
+ if (!board) return;
+
+ state.activeBoard = boardKey;
+ syncAngleSelectors();
+ syncGradeSelector();
+ const bg = $("board-bg");
+ if (bg.getAttribute("src") !== board.background_url) {
+ bg.src = board.background_url;
+ }
+
+ const svg = $("overlay");
+ const c = board.canvas;
+ svg.setAttribute("viewBox", `${c.x_min} ${c.y_min} ${c.width} ${c.height}`);
+
+ await ensureBoardHolds(boardKey);
+ redrawCurrentOverlay();
+}
+
+
+
+function preferredAngleForBoard(boardKey, currentValue = null) {
+ const board = state.boards[boardKey] || {};
+ const angles = board.available_angles || [];
+ const current = Number(currentValue);
+
+ if (angles.includes(current)) return current;
+ if (angles.includes(40)) return 40;
+ if (angles.length > 0) return angles[Math.floor(angles.length / 2)];
+ return 40;
+}
+
+function populateAngleSelect(selectId, boardKey, selectedValue = null) {
+ const select = $(selectId);
+ if (!select) return;
+
+ const board = state.boards[boardKey] || {};
+ const angles = board.available_angles || [20, 25, 30, 35, 40, 45, 50];
+ const selected = preferredAngleForBoard(boardKey, selectedValue ?? select.value);
+
+ select.innerHTML = "";
+ for (const angle of angles) {
+ const option = document.createElement("option");
+ option.value = String(angle);
+ option.textContent = `${angle}°`;
+ if (Number(angle) === Number(selected)) {
+ option.selected = true;
+ }
+ select.appendChild(option);
+ }
+}
+
+function syncAngleSelectors(angleValue = null) {
+ const boardKey = state.activeBoard || $("gen-board")?.value || $("pred-board")?.value || "tb2";
+ const selected = preferredAngleForBoard(boardKey, angleValue ?? $("gen-angle")?.value ?? $("pred-angle")?.value);
+
+ populateAngleSelect("gen-angle", boardKey, selected);
+ populateAngleSelect("pred-angle", boardKey, selected);
+
+ const genAngle = $("gen-angle");
+ const predAngle = $("pred-angle");
+ if (genAngle) genAngle.value = String(selected);
+ if (predAngle) predAngle.value = String(selected);
+}
+
+
+function preferredGradeForBoard(boardKey, currentValue = null) {
+ const board = state.boards[boardKey] || {};
+ const grades = board.available_grades || Array.from({ length: 16 }, (_, i) => i);
+ const current = Number(currentValue);
+
+ if (grades.includes(current)) return current;
+ if (grades.includes(6)) return 6;
+ if (grades.length > 0) return grades[Math.floor(grades.length / 2)];
+ return 6;
+}
+
+function populateGradeSelect(selectId, boardKey, selectedValue = null) {
+ const select = $(selectId);
+ if (!select) return;
+
+ const board = state.boards[boardKey] || {};
+ const grades = board.available_grades || Array.from({ length: 16 }, (_, i) => i);
+ const selected = preferredGradeForBoard(boardKey, selectedValue ?? select.value);
+
+ select.innerHTML = "";
+ for (const grade of grades) {
+ const option = document.createElement("option");
+ option.value = String(grade);
+ option.textContent = `V${grade}`;
+ if (Number(grade) === Number(selected)) {
+ option.selected = true;
+ }
+ select.appendChild(option);
+ }
+}
+
+function syncGradeSelector(gradeValue = null) {
+ const boardKey = state.activeBoard || $("gen-board")?.value || "tb2";
+ const selected = preferredGradeForBoard(boardKey, gradeValue ?? $("gen-grade")?.value);
+ populateGradeSelect("gen-grade", boardKey, selected);
+
+ const genGrade = $("gen-grade");
+ if (genGrade) genGrade.value = String(selected);
+}
+
+function syncBoardSelectors(boardKey) {
+ const genBoard = $("gen-board");
+ const predBoard = $("pred-board");
+ if (genBoard && genBoard.value !== boardKey) genBoard.value = boardKey;
+ if (predBoard && predBoard.value !== boardKey) predBoard.value = boardKey;
+}
+
+function setBuilderFromRouteResult(result) {
+ const board = result.board_key;
+ state.builder = (result.holds || []).map((hold) => ({
+ board,
+ placement_id: hold.placement_id,
+ x: hold.x,
+ y: hold.y,
+ role: hold.role,
+ }));
+ syncBuilderToFrames();
+}
+
+function activeBuilderSummary() {
+ const board = $("pred-board")?.value || state.activeBoard;
+ const holds = state.builder.filter((hold) => hold.board === board);
+ const starts = holds.filter((hold) => hold.role === "start").length;
+ const finishes = holds.filter((hold) => hold.role === "finish").length;
+ return { holds, starts, finishes };
+}
+
+function updateEditingHeader(prefix = "Editing climb") {
+ const summary = activeBuilderSummary();
+ const frameText = $("pred-frames")?.value?.trim() || "";
+ $("result-title").textContent = prefix;
+ $("result-subtitle").textContent =
+ `${summary.holds.length} selected holds | starts=${summary.starts} | finishes=${summary.finishes} | editing`;
+ $("raw-json").textContent = JSON.stringify({
+ mode: "editable_climb",
+ board: $("pred-board")?.value || state.activeBoard,
+ n_holds: summary.holds.length,
+ starts: summary.starts,
+ finishes: summary.finishes,
+ frames: frameText,
+ note: "Run grade prediction to refresh known-climb status after edits."
+ }, null, 2);
+}
+
+function clearOverlay() {
+ $("overlay").innerHTML = "";
+}
+
+function svgEl(name, attrs = {}) {
+ const el = document.createElementNS("http://www.w3.org/2000/svg", name);
+ for (const [key, value] of Object.entries(attrs)) {
+ el.setAttribute(key, value);
+ }
+ return el;
+}
+
+function starPoints(cx, cy, outerR, innerR, n = 5) {
+ const points = [];
+ for (let i = 0; i < n * 2; i++) {
+ const angle = -Math.PI / 2 + (i * Math.PI) / n;
+ const r = i % 2 === 0 ? outerR : innerR;
+ points.push(`${cx + Math.cos(angle) * r},${cy + Math.sin(angle) * r}`);
+ }
+ return points.join(" ");
+}
+
+function drawMarker(group, hold, style, className = "route-marker") {
+ if (style.shape === "square") {
+ const s = style.r * 2;
+ group.appendChild(svgEl("rect", {
+ x: hold.x - style.r,
+ y: hold.y - style.r,
+ width: s,
+ height: s,
+ rx: 0.28,
+ fill: style.fill,
+ stroke: style.stroke,
+ "stroke-width": 0.35,
+ opacity: 0.96,
+ class: className,
+ }));
+ } else if (style.shape === "star") {
+ group.appendChild(svgEl("polygon", {
+ points: starPoints(hold.x, hold.y, style.r, style.r * 0.48),
+ fill: style.fill,
+ stroke: style.stroke,
+ "stroke-width": 0.38,
+ opacity: 0.98,
+ class: className,
+ }));
+ } else {
+ group.appendChild(svgEl("circle", {
+ cx: hold.x,
+ cy: hold.y,
+ r: style.r,
+ fill: style.fill,
+ stroke: style.stroke,
+ "stroke-width": 0.35,
+ opacity: 0.96,
+ class: className,
+ }));
+ }
+}
+
+function transformedGroup(resultOrBoardKey) {
+ const boardKey = typeof resultOrBoardKey === "string" ? resultOrBoardKey : resultOrBoardKey.board_key;
+ const board = state.boards[boardKey];
+ const c = board.canvas;
+ return svgEl("g", {
+ transform: `translate(0 ${c.y_min + c.y_max}) scale(1 -1)`,
+ });
+}
+
+function drawSelectableTargets(boardKey, group) {
+ const holds = state.boardHolds[boardKey] || [];
+ for (const hold of holds) {
+ const target = svgEl("circle", {
+ cx: hold.x,
+ cy: hold.y,
+ r: 2.45,
+ fill: "transparent",
+ stroke: "transparent",
+ "stroke-width": 0,
+ class: "click-target",
+ });
+
+ target.addEventListener("mouseenter", () => {
+ const hover = svgEl("circle", {
+ cx: hold.x,
+ cy: hold.y,
+ r: 2.15,
+ class: "hover-marker",
+ id: "hover-marker",
+ });
+ group.appendChild(hover);
+ });
+
+ target.addEventListener("mouseleave", () => {
+ const hover = document.getElementById("hover-marker");
+ if (hover) hover.remove();
+ });
+
+ target.addEventListener("click", (event) => {
+ event.stopPropagation();
+ addBuilderHold(hold);
+ });
+
+ group.appendChild(target);
+ }
+}
+
+function drawOverlay(result) {
+ state.lastResult = result;
+ syncBoardSelectors(result.board_key);
+ state.activeBoard = result.board_key;
+ setBuilderFromRouteResult(result);
+ redrawCurrentOverlay();
+}
+
+function redrawCurrentOverlay() {
+ const svg = $("overlay");
+ svg.innerHTML = "";
+ const boardKey = state.activeBoard || $("pred-board")?.value || "tb2";
+
+ const group = transformedGroup(boardKey);
+ svg.appendChild(group);
+
+ for (const hold of state.builder) {
+ if (hold.board !== boardKey) continue;
+ const style = builderStyle[hold.role] || builderStyle.unknown;
+ drawMarker(group, hold, style, "builder-marker");
+ }
+
+ drawSelectableTargets(boardKey, group);
+}
+
+
+
+function findBuilderHoldIndex(board, placementId) {
+ return state.builder.findIndex(
+ (hold) => hold.board === board && Number(hold.placement_id) === Number(placementId)
+ );
+}
+
+function isBuilderHoldSelected(board, placementId) {
+ return findBuilderHoldIndex(board, placementId) >= 0;
+}
+
+function activeBuilderHolds() {
+ return state.builder.filter((hold) => hold.board === state.activeBoard);
+}
+
+function roleCountForActiveBoard(role) {
+ return activeBuilderHolds().filter((hold) => hold.role === role).length;
+}
+
+function canAddBuilderRole(role) {
+ if ((role === "start" || role === "finish") && roleCountForActiveBoard(role) >= 2) {
+ showWarnings([
+ `You already have two ${role} holds. A climb can have at most two ${role} holds in this builder.`
+ ]);
+ return false;
+ }
+ return true;
+}
+
+function frameStringForBuilder() {
+ const board = state.boards[$("pred-board").value];
+ if (!board) return "";
+ const roleDefinitions = board.role_definitions || {};
+ return state.builder
+ .filter((hold) => hold.board === $("pred-board").value)
+ .map((hold) => `p${hold.placement_id}r${roleDefinitions[hold.role]}`)
+ .join("");
+}
+
+function syncBuilderToFrames() {
+ $("pred-frames").value = frameStringForBuilder();
+ const list = $("builder-list");
+ list.innerHTML = "";
+
+ const active = state.builder.filter((hold) => hold.board === $("pred-board").value);
+ for (const [idx, hold] of active.entries()) {
+ const li = document.createElement("li");
+ li.innerHTML = `${idx + 1}. ${hold.role} p${hold.placement_id} `;
+ list.appendChild(li);
+ }
+ updatePredictButton();
+}
+
+function addBuilderHold(hold) {
+ const board = $("pred-board").value;
+ const role = $("click-role").value;
+ if (board !== state.activeBoard) {
+ $("pred-board").value = state.activeBoard;
+ }
+
+ const existingIndex = findBuilderHoldIndex(state.activeBoard, hold.placement_id);
+ if (existingIndex >= 0) {
+ state.builder.splice(existingIndex, 1);
+ showWarnings([]);
+ syncBuilderToFrames();
+ redrawCurrentOverlay();
+ updateEditingHeader("Editing generated / clicked climb");
+ return;
+ }
+
+ if (!canAddBuilderRole(role)) {
+ return;
+ }
+
+ state.builder.push({
+ board: state.activeBoard,
+ placement_id: hold.placement_id,
+ x: hold.x,
+ y: hold.y,
+ role,
+ });
+
+ showWarnings([]);
+ syncBuilderToFrames();
+ redrawCurrentOverlay();
+ updateEditingHeader("Editing generated / clicked climb");
+}
+
+function undoBuilderHold() {
+ const board = $("pred-board").value;
+ for (let i = state.builder.length - 1; i >= 0; i--) {
+ if (state.builder[i].board === board) {
+ state.builder.splice(i, 1);
+ break;
+ }
+ }
+ syncBuilderToFrames();
+ redrawCurrentOverlay();
+ updateEditingHeader("Editing generated / clicked climb");
+}
+
+function clearBuilder() {
+ clearEntireBoard();
+}
+
+
+function showWarnings(warnings = []) {
+ const box = $("warning-box");
+ const normalized = (warnings || []).filter(Boolean).map(String);
+
+ if (!box) {
+ if (normalized.length > 0) {
+ $("result-subtitle").textContent = normalized.join(" | ");
+ }
+ return;
+ }
+
+ if (normalized.length === 0) {
+ box.hidden = true;
+ box.textContent = "";
+ return;
+ }
+
+ box.hidden = false;
+ box.textContent = normalized.join("\n");
+}
+
+
+function clearClickedHoldsOnly() {
+ state.builder = [];
+ const frames = $("pred-frames");
+ if (frames) frames.value = "";
+ const list = $("builder-list");
+ if (list) list.innerHTML = "";
+ updatePredictButton();
+}
+
+function clearBoard() {
+ clearEntireBoard();
+}
+
+
+function updatePredictButton() {
+ const button = $("predict-btn");
+ if (!button) return;
+
+ const frames = $("pred-frames").value.trim();
+ const hasFrames = frames.length > 0;
+ button.disabled = !hasFrames;
+ button.title = hasFrames
+ ? ""
+ : "Paste a frames string or click holds on the board first.";
+}
+
+function clearEntireBoard() {
+ state.lastResult = null;
+ state.builder = [];
+ $("pred-frames").value = "";
+ const list = $("builder-list");
+ if (list) list.innerHTML = "";
+ clearOverlay();
+ showWarnings([]);
+ $("result-title").textContent = "Board cleared";
+ $("result-subtitle").textContent = "Click holds to build a climb, paste a frames string, or generate a new one.";
+ $("raw-json").textContent = "{}";
+ updatePredictButton();
+ redrawCurrentOverlay();
+}
+
+
+function knownClimbSummary(result) {
+ const known = result.known_climb;
+ if (!known || !known.checked) return null;
+ if (!known.is_known) return "not in known-climb database";
+
+ const first = (known.examples || [])[0] || {};
+ const name = first.climb_name || "unnamed climb";
+ const grade = first.boulder_grade || (first.grouped_v !== undefined && first.grouped_v !== null ? `V${first.grouped_v}` : "unknown grade");
+ return `known climb: ${name} (${grade}); ${known.match_count} matching route-angle entr${known.match_count === 1 ? "y" : "ies"}`;
+}
+
+function summarizeResult(result, mode) {
+ if (mode === "generate") {
+ const pieces = [
+ `${result.board_display_name}`,
+ `requested V${result.requested_grouped_v}`,
+ `${result.requested_angle}°`,
+ ];
+
+ if (result.predicted_grouped_v !== undefined) {
+ pieces.push(`predicted V${result.predicted_grouped_v} (${Number(result.predicted_display_difficulty).toFixed(2)})`);
+ if (result.critic_v_error !== undefined) {
+ const sign = Number(result.critic_v_error) >= 0 ? "+" : "";
+ pieces.push(`error ${sign}${result.critic_v_error}V`);
+ }
+ }
+
+ const knownSummary = knownClimbSummary(result);
+ if (knownSummary) pieces.push(knownSummary);
+ $("result-title").textContent = "Generated climb";
+ $("result-subtitle").textContent = pieces.join(" | ");
+ } else {
+ const knownSummary = knownClimbSummary(result);
+ $("result-title").textContent = "Grade prediction";
+ $("result-subtitle").textContent =
+ `${result.board_display_name} | ${result.requested_angle}° | predicted V${result.predicted_grouped_v} (${Number(result.predicted_display_difficulty).toFixed(2)})`
+ + (knownSummary ? ` | ${knownSummary}` : "");
+ }
+
+ showWarnings(result.warnings || []);
+ $("raw-json").textContent = JSON.stringify(result, null, 2);
+}
+
+
+function formatErrorMessage(message) {
+ if (!message) return "Unknown error.";
+ try {
+ const parsed = JSON.parse(message);
+ if (parsed.message && Array.isArray(parsed.reasons)) {
+ return `${parsed.message}\n- ${parsed.reasons.join("\n- ")}`;
+ }
+ if (Array.isArray(parsed.reasons)) {
+ return parsed.reasons.map((r) => `- ${r}`).join("\n");
+ }
+ return JSON.stringify(parsed, null, 2);
+ } catch {
+ return message;
+ }
+}
+
+async function generate() {
+ const button = $("generate-btn");
+ setBusy(button, true);
+ try {
+ const payload = {
+ board: $("gen-board").value,
+ angle: Number($("gen-angle").value),
+ grade: Number($("gen-grade").value),
+ temperature: Number($("gen-temperature").value),
+ top_k: 50,
+ max_new_tokens: 40,
+ };
+ const result = await fetchJson("/api/generate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ drawOverlay(result);
+ summarizeResult(result, "generate");
+ } catch (err) {
+ showWarnings([formatErrorMessage(err.message)]);
+ } finally {
+ setBusy(button, false);
+ }
+}
+
+async function predict() {
+ const button = $("predict-btn");
+ const frames = $("pred-frames").value.trim();
+ if (!frames) {
+ showWarnings(["Paste a frames string or click holds on the board before predicting."]);
+ updatePredictButton();
+ return;
+ }
+
+ setBusy(button, true);
+ try {
+ const payload = {
+ board: $("pred-board").value,
+ angle: Number($("pred-angle").value),
+ frames,
+ };
+ const result = await fetchJson("/api/predict", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ drawOverlay(result);
+ summarizeResult(result, "predict");
+ } catch (err) {
+ showWarnings([formatErrorMessage(err.message)]);
+ } finally {
+ setBusy(button, false);
+ updatePredictButton();
+ }
+}
+
+
+function addListenerIfPresent(id, eventName, handler) {
+ const element = $(id);
+ if (!element) {
+ console.warn(`Missing optional element #${id}; skipping ${eventName} listener.`);
+ return;
+ }
+ element.addEventListener(eventName, handler);
+}
+
+async function init() {
+ $("generate-btn").dataset.label = "Generate";
+ $("predict-btn").dataset.label = "Predict pasted / clicked climb";
+
+ const health = await fetchJson("/api/health");
+ $("health").textContent =
+ `device=${health.device} | generator=${health.generator_loaded ? "loaded" : "missing"} | grade=${health.grade_predictor_loaded ? "loaded" : "missing"}`;
+
+ state.boards = await fetchJson("/api/boards");
+ syncBoardSelectors("tb2");
+ syncAngleSelectors(40);
+ syncGradeSelector(6);
+ await ensureBoardHolds("tb2");
+ await setBoardBackground("tb2");
+ syncAngleSelectors(40);
+
+ addListenerIfPresent("gen-board", "change", async (e) => {
+ syncBoardSelectors(e.target.value);
+ await setBoardBackground(e.target.value);
+ syncGradeSelector();
+ syncBuilderToFrames();
+ updateEditingHeader("Editing climb");
+ });
+
+ addListenerIfPresent("pred-board", "change", async (e) => {
+ syncBoardSelectors(e.target.value);
+ await setBoardBackground(e.target.value);
+ syncGradeSelector();
+ syncBuilderToFrames();
+ updateEditingHeader("Editing climb");
+ });
+
+ addListenerIfPresent("gen-angle", "change", (e) => {
+ syncAngleSelectors(e.target.value);
+ });
+ addListenerIfPresent("pred-angle", "change", (e) => {
+ syncAngleSelectors(e.target.value);
+ });
+ addListenerIfPresent("gen-grade", "change", (e) => {
+ syncGradeSelector(e.target.value);
+ });
+ addListenerIfPresent("pred-frames", "input", updatePredictButton);
+ addListenerIfPresent("generate-btn", "click", generate);
+ addListenerIfPresent("predict-btn", "click", predict);
+ addListenerIfPresent("undo-hold-btn", "click", undoBuilderHold);
+ addListenerIfPresent("clear-holds-btn", "click", clearBoard);
+ addListenerIfPresent("clear-board-btn", "click", clearBoard);
+ updatePredictButton();
+}
+
+init().catch((err) => {
+ const health = $("health");
+ if (health) health.textContent = `Error: ${err.message}`;
+ showWarnings([`Initialization error: ${err.message}`]);
+ console.error(err);
+});
diff --git a/webapp/static/index.html b/webapp/static/index.html
new file mode 100644
index 0000000..18eda8f
--- /dev/null
+++ b/webapp/static/index.html
@@ -0,0 +1,175 @@
+
+
+
+
+
+ ClimbingBoardGPT
+
+
+
+
+
+
+
+
+
Generate a climb
+ Board
+
+ Tension Board 2 (12x12)
+ Kilter Board Original (16x12)
+
+
+ Angle
+
+
+ Target V-grade
+
+
+ Temperature
+
+ Lower = safer/common; higher = more creative/weird. Use temperature ≤ 2.
+
+ Generate
+
+
+
+
Predict grade
+ Board
+
+ Tension Board 2 (12x12)
+ Kilter Board Original (16x12)
+
+
+ Angle
+
+
+ Frames string
+
+
+ Predict pasted / clicked climb
+
+
+
+
Click holds to build a climb
+
Role for next clicked hold
+
+ start
+ middle
+ finish
+ foot
+
+
+
+ Undo
+ Clear board
+
+
+ Click a hold on the board image. The app appends the corresponding
+ BoardLib frame token using the selected semantic role.
+
+
+
+
+
+
What the controls mean
+
+ Temperature
+ Sampling randomness. Lower values are more conservative; higher values are more exploratory.
+
+ Target V-grade
+ The grade token given to the generator. The generated route is also checked by the grade predictor.
+
+ Known climb
+ An exact match against the tokenized dataset: same board, same angle, and same hold-role set.
+
+ Validity
+ A structural check: enough holds, no duplicate placements, at least one start, and at least one finish.
+
+
+
+
+
How this works
+
+ Routes are converted into tokens such as
+ <BOARD_TB2>, <ANGLE_40>,
+ and <TB2_p652_start>.
+
+
+ The generator samples a token sequence. The grade predictor removes the grade token and estimates difficulty from the board, angle, and hold-role tokens.
+
+
+
+
+
+
+
Data acknowledgement
+
+ Tension Board 2 route data is from Tension Climbing. Kilter Board route data is from Kilter.
+
+
+
+
+
Angle scope
+
+ The physical boards can be used at steeper angles than this demo exposes. This model snapshot is intentionally restricted to the angle range used in training/evaluation: TB2 up to 50° and Kilter up to 55°.
+
+
+ The restriction avoids asking the models to extrapolate into sparse, noisier high-angle data where grades and route distributions are less reliable.
+
+
+
+
+
High-grade caveat
+
+ Generation and prediction are both less reliable at edge-case grades, especially around V10 and above. Those grades are rarer in the data, so the generator has weaker grade control and the predictor has less signal for accurate scoring.
+
+
+
+
+
To-do
+
+ This demo currently focuses on the full-board layouts: Tension Board 2 Mirror 12x12 and Kilter Board Original 16x12. Other board sizes are future work.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Raw result JSON
+ {}
+
+
+
+
+
+
+
+