Add web demo polish and smoke-test pipeline

This commit is contained in:
Pawel
2026-05-24 20:00:40 -04:00
parent 2391c80003
commit bbf276d642
22 changed files with 614 additions and 306 deletions
+49 -2
View File
@@ -158,7 +158,7 @@ ClimbingBoardGPT/
│ └── processed/
├── images/
│ ├── tb2_board_12x12_composite.png
│ └── kilter-original-16x12_compose.png
│ └── kilter-original-16x12_composite.png
├── models/
│ ├── joint_transformer_grade_predictor.pth
│ └── joint_route_gpt_generator.pth
@@ -247,6 +247,53 @@ They define board-specific details such as:
The demo scripts do **not** need the raw databases if the processed tokenization artifacts and trained model checkpoints already exist.
The interactive webapp also needs local demo assets:
```text
data/processed/tokenized/token_metadata.csv
models/joint_transformer_grade_predictor.pth
models/joint_route_gpt_generator.pth
images/tb2_board_12x12_composite.png
images/kilter-original-16x12_composite.png
```
These files are ignored by git because they are generated or binary artifacts. Recreate them with the training pipeline, copy them from a previous run, or mount them into the Docker container as shown in `docker-compose.webapp.yml`.
---
## Fast test pipeline
To verify that scripts `01` through `04` still work without retraining the full models, run the pipeline into a temporary output directory with a tiny data sample and tiny CPU-only models:
```bash
python scripts/01_tokenize_routes.py \
--out-dir /tmp/cbgpt_smoke/tokenized \
--max-routes-per-board 20
python scripts/02_train_grade_predictor.py \
--tokenized-dir /tmp/cbgpt_smoke/tokenized \
--out-dir /tmp/cbgpt_smoke/grade_prediction \
--model-dir /tmp/cbgpt_smoke/models \
--smoke-test
python scripts/03_train_route_generator.py \
--tokenized-dir /tmp/cbgpt_smoke/tokenized \
--out-dir /tmp/cbgpt_smoke/generation \
--model-dir /tmp/cbgpt_smoke/models \
--smoke-test \
--generate-angles 40 \
--generate-grades 6
python scripts/04_evaluate_generated_routes.py \
--tokenized-dir /tmp/cbgpt_smoke/tokenized \
--generated-dir /tmp/cbgpt_smoke/generation \
--out-dir /tmp/cbgpt_smoke/evaluation \
--grade-model-path /tmp/cbgpt_smoke/models/joint_transformer_grade_predictor.pth \
--device cpu
```
The resulting metrics and generated climbs are not meaningful. This path is only a code-path check: it verifies database loading, tokenization, training loops, checkpoint saving/loading, generation, and evaluation without touching the normal `data/processed` or `models` outputs.
---
## Full training pipeline
@@ -360,7 +407,7 @@ The visualization uses calibrated board backgrounds:
```text
images/tb2_board_12x12_composite.png
images/kilter-original-16x12_compose.png
images/kilter-original-16x12_composite.png
```
These are overlaid using product-size coordinate windows:
+9 -6
View File
@@ -9,12 +9,15 @@ description = "Unified TB2/Kilter transformer route modeling, grade prediction,
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"numpy",
"pandas",
"scipy",
"scikit-learn",
"matplotlib",
"torch"
"numpy>=1.26",
"pandas>=2.1",
"scipy>=1.11",
"scikit-learn>=1.3",
"matplotlib>=3.8",
"torch>=2.0",
"fastapi>=0.110",
"uvicorn[standard]>=0.27",
"pydantic>=2.0"
]
[tool.setuptools.packages.find]
+9 -6
View File
@@ -1,6 +1,9 @@
numpy
pandas
scipy
scikit-learn
matplotlib
torch
numpy>=1.26
pandas>=2.1
scipy>=1.11
scikit-learn>=1.3
matplotlib>=3.8
torch>=2.0
fastapi>=0.110
uvicorn[standard]>=0.27
pydantic>=2.0
+15 -1
View File
@@ -104,6 +104,12 @@ Examples:
default=3,
help="Random seed for reproducible splits (default: 3)",
)
parser.add_argument(
"--max-routes-per-board",
type=int,
default=None,
help="Optional smoke-test row limit per board before tokenization.",
)
return parser.parse_args()
@@ -121,6 +127,8 @@ def main() -> None:
8. Save all artifacts to disk
"""
args = parse_args()
if args.max_routes_per_board is not None and args.max_routes_per_board < 3:
raise ValueError("--max-routes-per-board must be at least 3 so train/val/test splits can exist.")
# Set random seed for reproducibility
# This ensures train/val/test splits are the same across runs
@@ -165,7 +173,13 @@ def main() -> None:
# placement 369 with role 6 (middle)
# placement 603 with role 7 (finish)
print("\nLoading data from databases...")
df_climbs, df_placements = load_multi_board_data(configs, project_root=REPO_ROOT)
if args.max_routes_per_board is not None:
print(f" Smoke-test limit: loading at most {args.max_routes_per_board:,} climb-angle rows per board")
df_climbs, df_placements = load_multi_board_data(
configs,
project_root=REPO_ROOT,
max_climbs_per_board=args.max_routes_per_board,
)
placement_lookup = make_placement_lookup(df_placements)
print(f" Total climb-angle entries: {len(df_climbs):,}")
+51 -21
View File
@@ -62,8 +62,11 @@ from climbingboardgpt.datasets import RouteGradeDataset
from climbingboardgpt.grades import to_grouped_v
from climbingboardgpt.metrics import metrics_by_board, print_metrics, regression_metrics
from climbingboardgpt.models import JointRouteTransformerRegressor
from climbingboardgpt.tokenization import encode as encode_tokens
from climbingboardgpt.utils import set_seed, write_json
MSE_LOSS = nn.MSELoss()
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments for grade predictor training.
@@ -101,9 +104,31 @@ accuracy (within ±1 V-grade).
parser.add_argument("--dropout", type=float, default=0.10, help="Dropout probability")
parser.add_argument("--seed", type=int, default=3, help="Random seed")
parser.add_argument("--device", type=str, default=None, help="Device (cpu or cuda)")
parser.add_argument("--num-workers", type=int, default=0, help="DataLoader worker processes")
parser.add_argument(
"--smoke-test",
action="store_true",
help="Use a tiny CPU model and one epoch to exercise the training/evaluation code path.",
)
return parser.parse_args()
def apply_smoke_test_defaults(args: argparse.Namespace) -> None:
"""Mutate args to a tiny deterministic configuration for code-path checks."""
if not args.smoke_test:
return
args.epochs = 1
args.patience = 1
args.batch_size = min(args.batch_size, 16)
args.d_model = 32
args.nhead = 2
args.num_layers = 1
args.dim_feedforward = 64
args.dropout = 0.0
args.device = "cpu"
args.num_workers = 0
def build_coord_features(df_token_meta: pd.DataFrame, vocab_size: int) -> torch.Tensor:
"""Build coordinate feature matrix for the transformer model.
@@ -148,9 +173,8 @@ def run_epoch(model, loader, device, optimizer=None):
"""
is_train = optimizer is not None
model.train(is_train)
criterion = nn.MSELoss()
losses, preds, targets, uuids, boards = [], [], [], [], []
losses, preds, targets, row_ids, uuids, boards = [], [], [], [], [], []
for batch in loader:
input_ids = batch["input_ids"].to(device)
@@ -162,7 +186,7 @@ def run_epoch(model, loader, device, optimizer=None):
# Forward pass: model predicts difficulty from token sequence
pred = model(input_ids, attention_mask)
loss = criterion(pred, target)
loss = MSE_LOSS(pred, target)
if is_train:
# Backward pass: compute gradients and update weights
@@ -174,11 +198,12 @@ def run_epoch(model, loader, device, optimizer=None):
losses.append(loss.item() * input_ids.size(0))
preds.extend(pred.detach().cpu().numpy().tolist())
targets.extend(target.detach().cpu().numpy().tolist())
row_ids.extend(batch["row_id"].detach().cpu().numpy().tolist())
uuids.extend(batch["uuid"])
boards.extend(batch["board_key"])
avg_loss = sum(losses) / max(1, len(loader.dataset))
return avg_loss, np.asarray(preds), np.asarray(targets), uuids, boards
return avg_loss, np.asarray(preds), np.asarray(targets), row_ids, uuids, boards
def main() -> None:
@@ -195,6 +220,7 @@ def main() -> None:
8. Save model checkpoint and metrics
"""
args = parse_args()
apply_smoke_test_defaults(args)
set_seed(args.seed)
args.out_dir.mkdir(parents=True, exist_ok=True)
args.model_dir.mkdir(parents=True, exist_ok=True)
@@ -215,7 +241,7 @@ def main() -> None:
df_token_meta = pd.read_csv(meta_path)
pad_id = stoi["<PAD>"]
unk_id = stoi["<UNK>"]
device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
# ─────────────────────────────────────────────────────────────────────
# Step 2: Prepare input sequences
@@ -223,15 +249,13 @@ def main() -> None:
# For grade prediction, we use the "no_grade" version of the sequence
# and prepend <CLS> for sequence-level pooling.
# The model must PREDICT the grade, not see it in the input!
def encode(tokens):
return [stoi.get(token, unk_id) for token in tokens]
df_routes["tokens_no_grade"] = df_routes["sequence_no_grade"].fillna("").str.split()
df_routes["model_tokens"] = df_routes["tokens_no_grade"].apply(
lambda tokens: ["<CLS>"] + tokens[1:] if tokens else ["<CLS>"]
)
df_routes["model_ids"] = df_routes["model_tokens"].apply(encode)
df_routes["model_ids"] = df_routes["model_tokens"].apply(lambda tokens: encode_tokens(tokens, stoi))
df_routes["seq_len"] = df_routes["model_ids"].apply(len)
df_routes["row_id"] = np.arange(len(df_routes), dtype=np.int64)
max_len = int(df_routes["seq_len"].max())
# ─────────────────────────────────────────────────────────────────────
@@ -245,14 +269,17 @@ def main() -> None:
val_ds = RouteGradeDataset(val_df, max_len=max_len, pad_id=pad_id)
test_ds = RouteGradeDataset(test_df, max_len=max_len, pad_id=pad_id)
train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False)
test_loader = DataLoader(test_ds, batch_size=args.batch_size, shuffle=False)
loader_kwargs = {
"num_workers": int(args.num_workers),
"pin_memory": device.type == "cuda",
}
train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True, **loader_kwargs)
val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False, **loader_kwargs)
test_loader = DataLoader(test_ds, batch_size=args.batch_size, shuffle=False, **loader_kwargs)
# ─────────────────────────────────────────────────────────────────────
# Step 4: Initialize model
# ─────────────────────────────────────────────────────────────────────
device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
coord_features = build_coord_features(df_token_meta, vocab_size=len(stoi))
model = JointRouteTransformerRegressor(
@@ -286,8 +313,8 @@ def main() -> None:
print("\nStarting training...")
for epoch in range(1, args.epochs + 1):
train_loss, train_pred, train_true, _, _ = run_epoch(model, train_loader, device, optimizer)
val_loss, val_pred, val_true, _, _ = run_epoch(model, val_loader, device, optimizer=None)
train_loss, train_pred, train_true, _, _, _ = run_epoch(model, train_loader, device, optimizer)
val_loss, val_pred, val_true, _, _, _ = run_epoch(model, val_loader, device, optimizer=None)
train_metrics = regression_metrics(train_true, train_pred)
val_metrics = regression_metrics(val_true, val_pred)
@@ -332,10 +359,11 @@ def main() -> None:
# ─────────────────────────────────────────────────────────────────────
# Step 6: Test set evaluation
# ─────────────────────────────────────────────────────────────────────
test_loss, test_pred, test_true, test_uuid, test_board = run_epoch(model, test_loader, device, optimizer=None)
test_loss, test_pred, test_true, test_row_id, test_uuid, test_board = run_epoch(model, test_loader, device, optimizer=None)
overall_metrics = regression_metrics(test_true, test_pred)
pred_df = pd.DataFrame({
"row_id": test_row_id,
"uuid": test_uuid,
"board_key": test_board,
"y_true": test_true,
@@ -344,13 +372,15 @@ def main() -> None:
"true_v": [to_grouped_v(value) for value in test_true],
"pred_v": [to_grouped_v(value) for value in test_pred],
})
pred_df = pred_df.merge(
df_routes[["uuid", "climb_name", "angle", "boulder_grade", "sequence_no_grade"]],
on="uuid",
how="left",
)
board_metrics_df = metrics_by_board(pred_df)
pred_df = pred_df.merge(
df_routes[["row_id", "climb_name", "angle", "boulder_grade", "sequence_no_grade"]],
on="row_id",
how="left",
validate="one_to_one",
)
print_metrics("Overall joint test performance", overall_metrics)
print("\nBoard-specific test performance:")
print(board_metrics_df.to_string(index=False))
+34 -9
View File
@@ -63,6 +63,7 @@ from climbingboardgpt.config import load_board_configs, parse_board_keys
from climbingboardgpt.datasets import RouteGPTDataset
from climbingboardgpt.generation import generate_one
from climbingboardgpt.models import JointRouteGPT
from climbingboardgpt.tokenization import encode as encode_tokens
from climbingboardgpt.utils import set_seed
@@ -106,9 +107,32 @@ specific board, or leave unset to generate for all boards.
parser.add_argument("--generate-grades", type=str, default=None, help="Comma-separated V-grades")
parser.add_argument("--seed", type=int, default=3, help="Random seed")
parser.add_argument("--device", type=str, default=None, help="Device (cpu or cuda)")
parser.add_argument("--num-workers", type=int, default=0, help="DataLoader worker processes")
parser.add_argument(
"--smoke-test",
action="store_true",
help="Use a tiny CPU model, one epoch, and a tiny generation grid to exercise the full code path.",
)
return parser.parse_args()
def apply_smoke_test_defaults(args: argparse.Namespace) -> None:
"""Mutate args to a tiny deterministic configuration for code-path checks."""
if not args.smoke_test:
return
args.epochs = 1
args.patience = 1
args.batch_size = min(args.batch_size, 16)
args.n_embd = 32
args.n_head = 2
args.n_layer = 1
args.dropout = 0.0
args.max_new_tokens = min(args.max_new_tokens, 16)
args.n_per_condition = 1
args.device = "cpu"
args.num_workers = 0
def evaluate_loss(model, loader, device) -> float:
"""Evaluate the model on a data loader, returning average loss.
@@ -168,6 +192,7 @@ def main() -> None:
7. Save model checkpoint and generated routes
"""
args = parse_args()
apply_smoke_test_defaults(args)
set_seed(args.seed)
args.out_dir.mkdir(parents=True, exist_ok=True)
args.model_dir.mkdir(parents=True, exist_ok=True)
@@ -185,7 +210,7 @@ def main() -> None:
stoi = {str(k): int(v) for k, v in vocab["stoi"].items()}
itos = {int(k): str(v) for k, v in vocab["itos"].items()}
pad_id = stoi["<PAD>"]
unk_id = stoi["<UNK>"]
device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
# ─────────────────────────────────────────────────────────────────────
# Step 2: Prepare sequences for causal language modeling
@@ -198,11 +223,8 @@ def main() -> None:
#
# The input is shifted right by one position compared to the target.
# This is the standard causal language modeling setup.
def encode(tokens):
return [stoi.get(token, unk_id) for token in tokens]
df_routes["gpt_tokens"] = df_routes["sequence_with_grade"].fillna("").str.split()
df_routes["gpt_ids"] = df_routes["gpt_tokens"].apply(encode)
df_routes["gpt_ids"] = df_routes["gpt_tokens"].apply(lambda tokens: encode_tokens(tokens, stoi))
df_routes["seq_len"] = df_routes["gpt_ids"].apply(len)
max_len = int(df_routes["seq_len"].max())
if max_len < 2:
@@ -220,14 +242,17 @@ def main() -> None:
val_ds = RouteGPTDataset(val_df, max_len=max_len, pad_id=pad_id)
test_ds = RouteGPTDataset(test_df, max_len=max_len, pad_id=pad_id)
train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False)
test_loader = DataLoader(test_ds, batch_size=args.batch_size, shuffle=False)
loader_kwargs = {
"num_workers": int(args.num_workers),
"pin_memory": device.type == "cuda",
}
train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True, **loader_kwargs)
val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False, **loader_kwargs)
test_loader = DataLoader(test_ds, batch_size=args.batch_size, shuffle=False, **loader_kwargs)
# ─────────────────────────────────────────────────────────────────────
# Step 4: Initialize model
# ─────────────────────────────────────────────────────────────────────
device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
model = JointRouteGPT(
vocab_size=len(stoi),
block_size=block_size,
+2 -4
View File
@@ -49,6 +49,7 @@ from climbingboardgpt.evaluation import (
tokens_to_hold_records,
validity_from_records,
)
from climbingboardgpt.checkpoints import load_checkpoint
from climbingboardgpt.grades import to_grouped_v
from climbingboardgpt.models import JointRouteTransformerRegressor
@@ -86,10 +87,7 @@ def load_grade_critic(model_path: Path, device: torch.device):
"""
if not model_path.exists():
return None
try:
checkpoint = torch.load(model_path, map_location=device, weights_only=False)
except TypeError:
checkpoint = torch.load(model_path, map_location=device)
checkpoint = load_checkpoint(model_path, map_location=device, trusted=True)
cfg = checkpoint["config"]
stoi = {str(k): int(v) for k, v in checkpoint["stoi"].items()}
+2 -2
View File
@@ -88,7 +88,7 @@ def parse_args() -> argparse.Namespace:
help=(
"Optional board image to draw under the scatter plot. "
"If omitted, the script automatically uses images/tb2_board_12x12_composite.png "
"for TB2 and images/kilter-original-16x12_compose.png for Kilter when present."
"for TB2 and images/kilter-original-16x12_composite.png for Kilter when present."
),
)
return parser.parse_args()
@@ -98,7 +98,7 @@ def parse_args() -> argparse.Namespace:
def default_background_for_board(board: str) -> Path | None:
candidates = {
"tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png",
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_compose.png",
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_composite.png",
}
path = candidates.get(board)
return path if path is not None and path.exists() else None
+1 -1
View File
@@ -48,7 +48,7 @@ from climbingboardgpt.visualization import load_token_metadata, visualize_route_
def default_background_for_board(board: str) -> Path | None:
candidates = {
"tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png",
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_compose.png",
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_composite.png",
}
path = candidates.get(board)
return path if path is not None and path.exists() else None
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import torch
def load_checkpoint(
checkpoint_path: str | Path,
map_location: str | torch.device,
*,
trusted: bool = False,
) -> dict[str, Any]:
"""Load a PyTorch checkpoint, preferring safer weights-only loading.
Set ``trusted=True`` only for checkpoints produced by this project or an
otherwise trusted source. Older PyTorch versions do not support
``weights_only``; those fall back to the legacy loader for compatibility.
"""
checkpoint_path = Path(checkpoint_path)
try:
return torch.load(checkpoint_path, map_location=map_location, weights_only=True)
except TypeError:
return torch.load(checkpoint_path, map_location=map_location)
except Exception as exc:
if not trusted:
raise RuntimeError(
"Could not load checkpoint with weights_only=True. "
"Only retry with trusted=True for checkpoints from a trusted source."
) from exc
return torch.load(checkpoint_path, map_location=map_location, weights_only=False)
+14 -1
View File
@@ -135,12 +135,14 @@ def build_placements_query(config: BoardConfig) -> tuple[str, list]:
def load_board_data(
config: BoardConfig,
project_root: str | Path | None = None,
max_climbs: int | None = None,
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Load climbs and placements data for a single board.
Args:
config: Board configuration
project_root: Path to project root (for resolving db_path)
max_climbs: Optional row limit for fast smoke-test loads.
Returns:
Tuple of (climbs DataFrame, placements DataFrame)
@@ -154,6 +156,11 @@ def load_board_data(
climbs_query, climbs_params = build_climbs_query(config)
placements_query, placements_params = build_placements_query(config)
if max_climbs is not None:
if max_climbs < 1:
raise ValueError("max_climbs must be at least 1.")
climbs_query = f"{climbs_query}\nORDER BY c.uuid, cs.angle\nLIMIT ?"
climbs_params = [*climbs_params, int(max_climbs)]
with sqlite3.connect(db_path) as conn:
df_climbs = pd.read_sql_query(climbs_query, conn, params=climbs_params)
@@ -174,6 +181,7 @@ def load_board_data(
def load_multi_board_data(
configs: list[BoardConfig],
project_root: str | Path | None = None,
max_climbs_per_board: int | None = None,
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Load and concatenate data from multiple boards.
@@ -184,6 +192,7 @@ def load_multi_board_data(
Args:
configs: List of board configurations
project_root: Path to project root
max_climbs_per_board: Optional row limit per board for smoke tests.
Returns:
Tuple of (combined climbs DataFrame, combined placements DataFrame)
@@ -192,7 +201,11 @@ def load_multi_board_data(
placement_frames = []
for config in configs:
climbs, placements = load_board_data(config, project_root=project_root)
climbs, placements = load_board_data(
config,
project_root=project_root,
max_climbs=max_climbs_per_board,
)
climb_frames.append(climbs)
placement_frames.append(placements)
+2
View File
@@ -6,6 +6,7 @@ from torch.utils.data import Dataset
class RouteGradeDataset(Dataset):
def __init__(self, df, max_len: int, pad_id: int):
self.row_ids = df["row_id"].tolist() if "row_id" in df.columns else df.index.tolist()
self.ids = df["model_ids"].tolist()
self.targets = df["display_difficulty"].astype(float).values
self.uuids = df["uuid"].tolist()
@@ -28,6 +29,7 @@ class RouteGradeDataset(Dataset):
"input_ids": torch.tensor(ids, dtype=torch.long),
"attention_mask": torch.tensor(mask, dtype=torch.bool),
"target": torch.tensor(self.targets[idx], dtype=torch.float32),
"row_id": int(self.row_ids[idx]),
"uuid": self.uuids[idx],
"board_key": self.boards[idx],
}
+24 -52
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import ast
import re
from typing import Iterable
@@ -8,38 +7,11 @@ import numpy as np
import pandas as pd
from scipy.spatial.distance import pdist
HOLD_TOKEN_PATTERN = re.compile(r"^<([A-Z0-9_]+)_p(\d+)_(start|middle|finish|foot|unknown)>$")
from .tokenization import parse_tokens, tokens_to_hold_records
def parse_token_list(value) -> list[str]:
if isinstance(value, list):
return value
if not isinstance(value, str):
return []
try:
parsed = ast.literal_eval(value)
if isinstance(parsed, list):
return parsed
except Exception:
pass
return value.split()
def tokens_to_hold_records(tokens: Iterable[str]) -> list[dict[str, object]]:
rows = []
for token in tokens:
match = HOLD_TOKEN_PATTERN.match(token)
if match is None:
continue
rows.append(
{
"token": token,
"board_token_prefix": match.group(1),
"placement_id": int(match.group(2)),
"role": match.group(3),
}
)
return rows
return parse_tokens(value)
def validity_from_records(records: list[dict[str, object]], requested_board_prefix: str | None = None) -> dict[str, object]:
@@ -102,30 +74,30 @@ def nearest_real_route_same_board(
real_df: pd.DataFrame,
) -> dict[str, object]:
board_frame = real_df[real_df["board_key"] == generated_board_key]
best = {
"nearest_real_jaccard": -1.0,
"nearest_real_uuid": None,
"nearest_real_name": None,
"nearest_real_grouped_v": None,
"nearest_real_angle": None,
if board_frame.empty:
return {
"nearest_real_jaccard": np.nan,
"nearest_real_uuid": None,
"nearest_real_name": None,
"nearest_real_grouped_v": None,
"nearest_real_angle": None,
"novelty_distance": np.nan,
}
similarities = board_frame["hold_set"].map(lambda hold_set: jaccard(generated_set, hold_set))
best_idx = similarities.idxmax()
row = board_frame.loc[best_idx]
nearest_real_jaccard = float(similarities.loc[best_idx])
return {
"nearest_real_jaccard": nearest_real_jaccard,
"nearest_real_uuid": row["uuid"],
"nearest_real_name": row["climb_name"],
"nearest_real_grouped_v": row["grouped_v"],
"nearest_real_angle": row["angle"],
"novelty_distance": 1.0 - nearest_real_jaccard,
}
for _, row in board_frame.iterrows():
similarity = jaccard(generated_set, row["hold_set"])
if similarity > best["nearest_real_jaccard"]:
best.update(
{
"nearest_real_jaccard": similarity,
"nearest_real_uuid": row["uuid"],
"nearest_real_name": row["climb_name"],
"nearest_real_grouped_v": row["grouped_v"],
"nearest_real_angle": row["angle"],
}
)
best["novelty_distance"] = 1.0 - float(best["nearest_real_jaccard"])
return best
def build_placement_coords(df_token_meta: pd.DataFrame) -> dict[tuple[str, int], dict[str, float]]:
hold_meta = df_token_meta[df_token_meta["kind"] == "hold"].dropna(subset=["placement_id"]).copy()
+2 -16
View File
@@ -1,12 +1,11 @@
from __future__ import annotations
import re
from typing import Iterable
import torch
import torch.nn.functional as F
HOLD_TOKEN_PATTERN = re.compile(r"^<([A-Z0-9_]+)_p(\d+)_(start|middle|finish|foot|unknown)>$")
from .tokenization import tokens_to_hold_records
def top_k_filter(logits: torch.Tensor, k: int | None) -> torch.Tensor:
@@ -61,20 +60,7 @@ def prompt_tokens(board_prefix: str, angle: int, grouped_v: int) -> list[str]:
def hold_records(tokens: Iterable[str]) -> list[dict[str, object]]:
rows = []
for token in tokens:
match = HOLD_TOKEN_PATTERN.match(token)
if match is None:
continue
rows.append(
{
"board_prefix": match.group(1),
"placement_id": int(match.group(2)),
"role": match.group(3),
"token": token,
}
)
return rows
return tokens_to_hold_records(tokens)
def validity_summary(tokens: Iterable[str], requested_board_prefix: str | None = None) -> dict[str, object]:
+3 -9
View File
@@ -10,6 +10,7 @@ from pathlib import Path
import torch
from .checkpoints import load_checkpoint
from .config import BoardConfig, load_board_config
from .generation import generate_one
from .grades import to_grouped_v
@@ -75,10 +76,7 @@ def load_grade_predictor(
resolved_device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
try:
checkpoint = torch.load(checkpoint_path, map_location=resolved_device, weights_only=False)
except TypeError:
checkpoint = torch.load(checkpoint_path, map_location=resolved_device)
checkpoint = load_checkpoint(checkpoint_path, map_location=resolved_device, trusted=True)
cfg = checkpoint["config"]
stoi = {str(k): int(v) for k, v in checkpoint["stoi"].items()}
@@ -176,10 +174,7 @@ def load_route_generator(
resolved_device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
try:
checkpoint = torch.load(checkpoint_path, map_location=resolved_device, weights_only=False)
except TypeError:
checkpoint = torch.load(checkpoint_path, map_location=resolved_device)
checkpoint = load_checkpoint(checkpoint_path, map_location=resolved_device, trusted=True)
cfg = checkpoint["config"]
stoi = {str(k): int(v) for k, v in checkpoint["stoi"].items()}
@@ -332,4 +327,3 @@ def predict_frames_grade(
"requested_angle": int(angle),
"frames": frames,
}
+38
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import re
import ast
from typing import Iterable
import numpy as np
@@ -39,6 +40,43 @@ def parse_frames(frames_str: str | None) -> list[tuple[int, int]]:
return [(int(placement_id), int(role_id)) for placement_id, role_id in matches]
def parse_tokens(value) -> list[str]:
"""Parse tokens from a list, repr-style list string, or whitespace sequence."""
if isinstance(value, list):
return [str(v) for v in value]
if not isinstance(value, str):
return []
try:
parsed = ast.literal_eval(value)
if isinstance(parsed, list):
return [str(v) for v in parsed]
except Exception:
pass
return value.split()
def tokens_to_hold_records(tokens: Iterable[str]) -> list[dict[str, object]]:
"""Extract hold records from model tokens using the shared hold-token grammar."""
rows: list[dict[str, object]] = []
for token in tokens:
match = HOLD_TOKEN_PATTERN.match(str(token))
if match is None:
continue
board_prefix = match.group(1)
rows.append(
{
"token": str(token),
"board_token_prefix": board_prefix,
"board_prefix": board_prefix,
"placement_id": int(match.group(2)),
"role": match.group(3),
}
)
return rows
def make_placement_lookup(df_placements: pd.DataFrame) -> dict[tuple[str, int], dict]:
rows = {}
for _, row in df_placements.iterrows():
-1
View File
@@ -94,7 +94,6 @@ def assign_group_splits(
``train``, ``val``, or ``test``.
"""
group_df = df[group_cols + ([stratify_col] if stratify_col else [])].copy()
group_df["__row_index"] = range(len(group_df))
group_df = group_df.drop_duplicates(group_cols).reset_index(drop=True)
train_groups, temp_groups = safe_train_test_split(
+2 -34
View File
@@ -6,15 +6,13 @@ coordinate extent, then scatter route holds in board coordinates.
"""
from __future__ import annotations
import ast
import re
from pathlib import Path
from typing import Iterable
import matplotlib.pyplot as plt
import pandas as pd
HOLD_TOKEN_PATTERN = re.compile(r"^<([A-Z0-9_]+)_p(\d+)_(start|middle|finish|foot|unknown)>$")
from .tokenization import parse_tokens, tokens_to_hold_records
# These are the same coordinate windows used in the earlier visualization
# notebooks. They come from the product size geometry rather than from the
@@ -58,39 +56,9 @@ ROLE_SIZES = {
}
def parse_tokens(value) -> list[str]:
"""Parse a generated token list from a list, repr string, or sequence string."""
if isinstance(value, list):
return [str(v) for v in value]
if not isinstance(value, str):
return []
try:
parsed = ast.literal_eval(value)
if isinstance(parsed, list):
return [str(v) for v in parsed]
except Exception:
pass
return value.split()
def tokens_to_route_records(tokens: Iterable[str]) -> pd.DataFrame:
"""Extract generated hold records from model tokens."""
rows = []
for token in tokens:
match = HOLD_TOKEN_PATTERN.match(str(token))
if match is None:
continue
rows.append(
{
"token": token,
"board_token_prefix": match.group(1),
"placement_id": int(match.group(2)),
"role": match.group(3),
}
)
return pd.DataFrame(rows)
return pd.DataFrame(tokens_to_hold_records(tokens))
def load_token_metadata(tokenized_dir: str | Path) -> pd.DataFrame:
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
import pandas as pd
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "src"))
from climbingboardgpt.config import BoardConfig
from climbingboardgpt.evaluation import nearest_real_route_same_board
from climbingboardgpt.generation import validity_summary
from climbingboardgpt.grades import to_grouped_v
from climbingboardgpt.tokenization import (
parse_frames,
parse_tokens,
tokenize_route,
tokens_to_hold_records,
)
class CoreBehaviorTest(unittest.TestCase):
def test_to_grouped_v_clamps_and_maps_display_difficulty(self):
self.assertEqual(to_grouped_v(10), 0)
self.assertEqual(to_grouped_v(22), 6)
self.assertEqual(to_grouped_v(99), 16)
def test_parse_frames_extracts_placement_role_pairs(self):
self.assertEqual(parse_frames("p344r5p369r6p603r7"), [(344, 5), (369, 6), (603, 7)])
self.assertEqual(parse_frames(None), [])
def test_shared_token_parser_handles_repr_and_sequence_strings(self):
self.assertEqual(parse_tokens("['<BOS>', '<EOS>']"), ["<BOS>", "<EOS>"])
self.assertEqual(parse_tokens("<BOS> <EOS>"), ["<BOS>", "<EOS>"])
def test_tokens_to_hold_records_extracts_shared_shape(self):
records = tokens_to_hold_records(["<BOS>", "<TB2_p344_start>", "<TB2_p603_finish>"])
self.assertEqual(
records,
[
{
"token": "<TB2_p344_start>",
"board_token_prefix": "TB2",
"board_prefix": "TB2",
"placement_id": 344,
"role": "start",
},
{
"token": "<TB2_p603_finish>",
"board_token_prefix": "TB2",
"board_prefix": "TB2",
"placement_id": 603,
"role": "finish",
},
],
)
def test_validity_summary_flags_basic_valid_route(self):
summary = validity_summary(
["<TB2_p344_start>", "<TB2_p369_middle>", "<TB2_p603_finish>"],
requested_board_prefix="TB2",
)
self.assertTrue(summary["basic_valid"])
self.assertEqual(summary["n_hold_tokens"], 3)
def test_tokenize_route_uses_canonical_hold_order(self):
config = BoardConfig(
board_key="tb2",
display_name="TB2",
token_prefix="TB2",
db_path=Path("unused.db"),
layout_id=1,
max_angle=None,
min_fa_date=None,
placement_y_max=None,
include_mirror_placement_id=False,
role_definitions={"start": 5, "middle": 6, "finish": 7, "foot": 8},
)
placement_lookup = {
("tb2", 603): {"x": 0, "y": 120},
("tb2", 344): {"x": 0, "y": 10},
("tb2", 369): {"x": 0, "y": 80},
}
row = {
"frames": "p603r7p369r6p344r5",
"angle": 40,
"display_difficulty": 22,
}
self.assertEqual(
tokenize_route(row, config, placement_lookup),
[
"<BOS>",
"<BOARD_TB2>",
"<ANGLE_40>",
"<GRADE_V6>",
"<TB2_p344_start>",
"<TB2_p369_middle>",
"<TB2_p603_finish>",
"<EOS>",
],
)
def test_nearest_real_route_same_board_finds_best_jaccard_match(self):
real_df = pd.DataFrame(
[
{
"board_key": "tb2",
"hold_set": frozenset({1, 2, 3}),
"uuid": "a",
"climb_name": "close",
"grouped_v": 5,
"angle": 40,
},
{
"board_key": "tb2",
"hold_set": frozenset({7, 8}),
"uuid": "b",
"climb_name": "far",
"grouped_v": 8,
"angle": 45,
},
]
)
result = nearest_real_route_same_board(frozenset({1, 2, 4}), "tb2", real_df)
self.assertEqual(result["nearest_real_uuid"], "a")
self.assertAlmostEqual(result["nearest_real_jaccard"], 0.5)
if __name__ == "__main__":
unittest.main()
+22 -49
View File
@@ -14,9 +14,9 @@ from __future__ import annotations
import hashlib
import os
import re
import sys
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
@@ -39,6 +39,8 @@ from climbingboardgpt.inference import (
predict_frames_grade,
predict_route_grade,
)
from climbingboardgpt.tokenization import tokens_to_hold_records
from climbingboardgpt.utils import json_safe
from climbingboardgpt.visualization import BOARD_CANVAS, load_token_metadata, tokens_to_route_records
@@ -59,7 +61,7 @@ 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",
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_composite.png",
}
@@ -80,26 +82,6 @@ class PredictRequest(BaseModel):
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]
@@ -240,13 +222,10 @@ def _role_limit_validity(tokens: list[str], requested_board_prefix: str) -> dict
"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):
for record in tokens_to_hold_records(tokens):
if str(record["board_token_prefix"]) != str(requested_board_prefix):
continue
role = str(record["role"])
counts[role] = counts.get(role, 0) + 1
n_start = int(counts.get("start", 0))
@@ -305,10 +284,6 @@ def _invalid_prediction_reasons(validity: dict[str, Any]) -> list[str]:
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)))
@@ -335,15 +310,12 @@ def _route_signature_from_holds(board: str, angle: Any, holds: list[dict[str, An
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
for record in tokens_to_hold_records(str(sequence).split()):
holds.append(
{
"board_prefix": match.group(1),
"placement_id": int(match.group(2)),
"role": match.group(3),
"board_prefix": str(record["board_token_prefix"]),
"placement_id": int(record["placement_id"]),
"role": str(record["role"]),
}
)
return holds
@@ -497,7 +469,7 @@ def _payload(result: dict[str, Any], tokens: list[str] | None = None) -> dict[st
holds = _tokens_to_holds(board, tokens)
angle = result.get("requested_angle", result.get("angle", 0))
return _json_safe(
return json_safe(
{
**result,
"tokens": tokens,
@@ -517,13 +489,8 @@ def _payload(result: dict[str, Any], tokens: list[str] | None = None) -> dict[st
)
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:
@asynccontextmanager
async def lifespan(app: FastAPI):
if TORCH_THREADS_INT is not None:
torch.set_num_threads(TORCH_THREADS_INT)
@@ -553,6 +520,12 @@ def startup() -> None:
device=DEVICE,
torch_threads=TORCH_THREADS_INT,
)
yield
app = FastAPI(title="ClimbingBoardGPT", version="0.1.0", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=REPO_ROOT / "webapp" / "static"), name="static")
app.mount("/board-images", StaticFiles(directory=REPO_ROOT / "images"), name="board-images")
@app.get("/")
@@ -604,7 +577,7 @@ def boards():
@app.get("/api/board-holds/{board}")
def board_holds(board: str):
config = _board_config(board)
return _json_safe({
return json_safe({
"board_key": board,
"display_name": config.display_name,
"token_prefix": config.token_prefix,
@@ -729,7 +702,7 @@ def predict(req: PredictRequest):
reasons = _invalid_prediction_reasons(validity)
raise HTTPException(
status_code=400,
detail=_json_safe(
detail=json_safe(
{
"message": "Cannot predict grade for an invalid climb.",
"reasons": reasons,
+138 -83
View File
@@ -1,17 +1,31 @@
: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;
--base00: #263238;
--base01: #2e3c43;
--base02: #314549;
--base03: #546e7a;
--base04: #b2ccd6;
--base05: #eeffff;
--base07: #ffffff;
--base08: #f07178;
--base0a: #ffcb6b;
--base0b: #c3e88d;
--base0c: #89ddff;
--base0d: #82aaff;
--base0e: #c792ea;
--base0f: #ff5370;
--bg: var(--base00);
--off-bg: var(--base01);
--inner-bg: var(--base02);
--fg: var(--base05);
--off-fg: var(--base04);
--muted: var(--base03);
--link: var(--base0d);
--hover: var(--base0c);
--highlight: var(--base0a);
--logo: var(--base0b);
--danger: var(--base08);
--border: rgba(178, 204, 214, 0.18);
--mono: "Fira Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
}
* { box-sizing: border-box; }
@@ -19,57 +33,60 @@
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);
font-size: 16px;
line-height: 1.5rem;
background: var(--bg);
color: var(--fg);
}
.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);
gap: 1.5rem;
max-width: 78rem;
margin: 1rem auto 0;
padding: 0 1rem;
}
.eyebrow {
margin: 0 0 0.35rem;
color: var(--accent);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.12em;
margin: 0;
color: var(--logo);
font-size: 1rem;
}
.site-header h1 {
margin: 0;
font-size: clamp(1.35rem, 2vw, 2.05rem);
letter-spacing: -0.04em;
font-size: 1rem;
font-weight: 600;
}
.site-header p {
margin: 0.4rem 0 0;
.site-header h1::before {
content: "# ";
color: var(--muted);
}
.site-header p {
margin: 0;
color: var(--off-fg);
}
.health {
flex-shrink: 0;
font-size: 0.78rem;
color: var(--accent-2);
color: var(--highlight);
white-space: nowrap;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.45rem 0.7rem;
background: rgba(0, 0, 0, 0.15);
padding: 0.25rem 0.5rem;
background: var(--inner-bg);
}
.layout {
display: grid;
grid-template-columns: 370px minmax(0, 1fr);
gap: 1.25rem;
padding: 1.25rem;
max-width: 1520px;
grid-template-columns: 22rem minmax(0, 1fr);
gap: 2rem;
padding: 2rem 1rem 1rem;
max-width: 78rem;
margin: 0 auto;
}
@@ -80,25 +97,28 @@ body {
}
.card, .result-card {
background: var(--panel);
background: var(--off-bg);
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;
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: var(--fg);
}
.card h2::before, .result-card h2::before {
content: "## ";
color: var(--muted);
}
label {
display: block;
margin: 0.7rem 0;
font-size: 0.82rem;
color: var(--muted);
color: var(--off-fg);
}
input, select, textarea {
@@ -106,16 +126,15 @@ input, select, textarea {
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;
color: var(--fg);
background: var(--inner-bg);
}
input:focus, select:focus, textarea:focus {
outline: 2px solid rgba(128, 203, 196, 0.32);
border-color: var(--accent);
outline: 2px solid rgba(137, 221, 255, 0.28);
border-color: var(--hover);
}
textarea {
@@ -126,26 +145,33 @@ textarea {
button {
width: 100%;
border: 1px solid #4db6ac;
border-radius: 6px;
border: 1px solid var(--link);
padding: 0.68rem 0.9rem;
margin-top: 0.4rem;
font-weight: 700;
color: #102022;
background: var(--accent);
color: var(--bg);
background: var(--link);
cursor: pointer;
font-family: var(--mono);
}
button:hover { filter: brightness(1.06); }
button:hover {
border-color: var(--hover);
background: var(--hover);
}
button:disabled { opacity: 0.55; cursor: not-allowed; }
button.secondary {
background: transparent;
color: var(--accent);
color: var(--link);
border-color: var(--border);
}
button.secondary:hover {
color: var(--bg);
border-color: var(--hover);
}
.button-row {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -153,7 +179,7 @@ button.secondary {
}
.note p, .small {
color: var(--muted);
color: var(--off-fg);
font-size: 0.82rem;
line-height: 1.45;
}
@@ -162,7 +188,7 @@ button.secondary {
list-style: none;
margin: 0.8rem 0 0;
padding: 0;
color: var(--muted);
color: var(--off-fg);
font-size: 0.78rem;
max-height: 160px;
overflow: auto;
@@ -185,19 +211,27 @@ button.secondary {
.result-header p {
margin: 0;
color: var(--muted);
color: var(--off-fg);
font-size: 0.84rem;
}
.result-note {
max-width: 760px;
margin: 0 auto 0.85rem;
color: var(--off-fg);
font-size: 0.78rem;
line-height: 1.45;
text-align: center;
}
.board-stage {
position: relative;
width: 100%;
max-width: 960px;
margin: 0 auto;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
background: #111827;
background: var(--bg);
}
.board-stage img {
@@ -225,24 +259,23 @@ button.secondary {
.hover-marker {
pointer-events: none;
fill: none;
stroke: var(--accent-2);
stroke: var(--highlight);
stroke-width: 0.55;
opacity: 0.9;
}
.json-block {
margin-top: 1rem;
color: var(--muted);
color: var(--off-fg);
}
.json-block pre {
overflow: auto;
max-height: 300px;
padding: 1rem;
background: #111a1f;
color: #cfd8dc;
background: var(--inner-bg);
color: var(--off-fg);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.76rem;
}
@@ -266,10 +299,9 @@ button.secondary {
.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;
border: 1px solid rgba(255, 203, 107, 0.55);
background: rgba(255, 203, 107, 0.12);
color: var(--highlight);
padding: 0.65rem 0.8rem;
font-size: 0.8rem;
text-align: left;
@@ -287,7 +319,7 @@ button.secondary {
.field-help {
display: block;
margin-top: 0.35rem;
color: var(--faint);
color: var(--muted);
font-size: 0.72rem;
line-height: 1.35;
}
@@ -297,7 +329,7 @@ button.secondary {
}
.explain dt {
color: var(--accent-2);
color: var(--highlight);
font-size: 0.78rem;
margin-top: 0.75rem;
}
@@ -308,16 +340,15 @@ button.secondary {
.explain dd {
margin: 0.22rem 0 0;
color: var(--muted);
color: var(--off-fg);
font-size: 0.78rem;
line-height: 1.45;
}
.explain code {
color: var(--accent-2);
background: rgba(0, 0, 0, 0.2);
color: var(--off-fg);
background: var(--inner-bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.05rem 0.25rem;
font-size: 0.74rem;
}
@@ -325,23 +356,47 @@ button.secondary {
.link-list {
margin: 0;
padding-left: 1.1rem;
color: var(--muted);
color: var(--off-fg);
font-size: 0.82rem;
line-height: 1.6;
}
.link-list li::marker {
content: "* ";
color: var(--muted);
}
.link-list a {
color: var(--accent);
text-decoration: none;
color: var(--link);
}
.link-list a:hover {
text-decoration: underline;
color: var(--hover);
}
.site-footer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
max-width: 78rem;
margin: 0 auto 1.5rem;
padding: 0 1rem;
color: var(--off-fg);
font-size: 0.82rem;
}
.site-footer a {
color: var(--link);
}
.site-footer a:hover {
color: var(--hover);
}
#data-acknowledgement-card p {
color: var(--muted);
#data-acknowledgement-card p,
#model-disclaimer-card p {
color: var(--off-fg);
font-size: 0.8rem;
line-height: 1.45;
}
+23 -4
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ClimbingBoardGPT</title>
<link rel="stylesheet" href="/static/app.css?v=16" />
<link rel="stylesheet" href="/static/app.css?v=17" />
</head>
<body>
<header class="site-header">
@@ -94,6 +94,13 @@
</dl>
</div>
<div class="card note" id="model-disclaimer-card">
<h2>Research demo caveat</h2>
<p>
This is an experimental model demo. Generated climbs and predicted grades may be wrong, especially at rare grades or sparse board/angle combinations.
</p>
</div>
<div class="card explain">
<h2>How this works</h2>
<p>
@@ -111,6 +118,7 @@
<ul class="link-list">
<li><a href="https://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a></li>
<li><a href="https://github.com/psark007/ClimbingBoardGPT" target="_blank" rel="noreferrer">ClimbingBoardGPT repo</a></li>
<li><a href="https://github.com/psark007/ClimbingBoardGPT/blob/main/LICENSE" target="_blank" rel="noreferrer">License</a></li>
<li><a href="https://github.com/psark007/Tension-Board-2-Analysis" target="_blank" rel="noreferrer">Tension Board 2 Analysis repo</a></li>
<li><a href="https://github.com/psark007/Kilter-Board-Analysis" target="_blank" rel="noreferrer">Kilter Board Analysis repo</a></li>
<li><a href="https://tensionclimbing.com/products/tension-board-2" target="_blank" rel="noreferrer">Tension Board 2</a></li>
@@ -118,10 +126,10 @@
</ul>
</div>
<div class="card note" id="data-acknowledgement-card">
<div class="card note" id="data-acknowledgement-card">
<h2>Data acknowledgement</h2>
<p>
Tension Board 2 route data is from Tension Climbing. Kilter Board route data is from Kilter.
Board layouts, hold metadata, and route data are derived from Tension Board 2 and Kilter Board datasets. This project is unaffiliated with Tension Climbing or Kilter.
</p>
</div>
@@ -159,6 +167,10 @@
<button id="clear-board-btn" class="secondary clear-board">Clear board</button>
</div>
<p class="result-note">
Known climb means an exact match against the tokenized dataset for the same board, angle, and hold-role set.
</p>
<div id="board-stage" class="board-stage">
<img id="board-bg" alt="Board background" />
<svg id="overlay" xmlns="http://www.w3.org/2000/svg"></svg>
@@ -172,6 +184,13 @@
</section>
</main>
<script src="/static/app.js?v=16"></script>
<footer class="site-footer">
<span>© Pawel Sarkowicz</span>
<a href="https://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a>
<a href="https://github.com/psark007/ClimbingBoardGPT" target="_blank" rel="noreferrer">ClimbingBoardGPT</a>
<a href="https://github.com/psark007/ClimbingBoardGPT/blob/main/LICENSE" target="_blank" rel="noreferrer">License</a>
</footer>
<script src="/static/app.js?v=17"></script>
</body>
</html>