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

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:
@@ -688,4 +735,4 @@ Example prediction payload:
# License
This project is licensed under the MIT License. See the [`LICENSE`](LICENSE) file for details.
The project is for educational purposes. Climb data belongs to Tension Climbing and Kilter respectively.
The project is for educational purposes. Climb data belongs to Tension Climbing and Kilter respectively.

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]

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

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):,}")

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))
@@ -390,4 +420,4 @@ def main() -> None:
if __name__ == "__main__":
main()
main()

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,
@@ -385,4 +410,4 @@ def main() -> None:
if __name__ == "__main__":
main()
main()

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()}
@@ -333,4 +331,4 @@ def main() -> None:
if __name__ == "__main__":
main()
main()

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

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

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)

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,11 +201,15 @@ 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)
return (
pd.concat(climb_frames, ignore_index=True),
pd.concat(placement_frames, ignore_index=True),
)
)

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],
}

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()

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]:

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,
}

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():

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(

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
tests/test_core.py Normal file
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()

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,

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;
}

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>