From 69389d1ebf5e59e2213808b724e90e7a3057727d Mon Sep 17 00:00:00 2001 From: Pawel Date: Fri, 29 May 2026 14:20:22 -0400 Subject: [PATCH] Added & fixed some documentation --- README.md | 24 +++++++++++ scripts/demo_generate_and_visualize.py | 3 ++ scripts/demo_generate_kilter.py | 1 + scripts/demo_generate_tb2.py | 1 + scripts/demo_predict_grade.py | 3 ++ scripts/demo_predict_kilter.py | 1 + scripts/demo_predict_tb2.py | 1 + src/climbingboardgpt/datasets.py | 21 ++++++++++ src/climbingboardgpt/evaluation.py | 19 +++++++++ src/climbingboardgpt/generation.py | 18 ++++++++ src/climbingboardgpt/grades.py | 5 +++ src/climbingboardgpt/metrics.py | 4 ++ src/climbingboardgpt/models.py | 23 ++++++++++- src/climbingboardgpt/paths.py | 7 ++++ src/climbingboardgpt/tokenization.py | 52 +++++++++++++++++++++++ src/climbingboardgpt/utils.py | 12 ++++++ src/climbingboardgpt/visualization.py | 2 + tests/test_core.py | 2 + webapp/app.py | 24 +++++++++++ webapp/static/app.css | 7 ++++ webapp/static/app.js | 57 ++++++++++++++++++++++++++ webapp/static/index.html | 4 ++ 22 files changed, 289 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6b297ed..477222a 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,30 @@ ClimbingBoardGPT/ --- +## Developer code map + +Most reusable behavior lives in `src/climbingboardgpt/`: + +| Module | Responsibility | +|---|---| +| `config.py` | Board-specific JSON config loading and role mappings | +| `data.py` | SQLite queries and board data loading | +| `tokenization.py` | Frames parsing, canonical route ordering, token grammar, vocabulary, token metadata | +| `datasets.py` | PyTorch dataset adapters for grade prediction and GPT training | +| `models.py` | Transformer encoder regressor and GPT-style route generator | +| `generation.py` | Prompt construction, top-k sampling, generated-route validity, frames reconstruction | +| `inference.py` | Checkpoint loading and demo/webapp inference helpers | +| `evaluation.py` | Validity, novelty, nearest-route, and geometry metrics for generated climbs | +| `visualization.py` | Matplotlib board overlays and calibrated board canvases | +| `metrics.py`, `grades.py`, `utils.py` | Shared grade mapping, reporting metrics, JSON/split/reproducibility helpers | + +The numbered scripts are the pipeline entry points. The `webapp/` directory is +the inference-only FastAPI demo plus the browser-side SVG route builder. The +notebooks document the executed analysis runs; the maintained importable code is +the package and scripts above. + +--- + ## Setup Create and activate a virtual environment: diff --git a/scripts/demo_generate_and_visualize.py b/scripts/demo_generate_and_visualize.py index a7eb6b1..d053f85 100644 --- a/scripts/demo_generate_and_visualize.py +++ b/scripts/demo_generate_and_visualize.py @@ -41,6 +41,7 @@ from climbingboardgpt.visualization import load_token_metadata, visualize_route_ def parse_args() -> argparse.Namespace: + """Parse generation, scoring, and visualization options.""" parser = argparse.ArgumentParser( description="Generate ClimbingBoardGPT routes and save route visualizations.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, @@ -96,6 +97,7 @@ def parse_args() -> argparse.Namespace: def default_background_for_board(board: str) -> Path | None: + """Return the bundled board image path for a board when it exists.""" candidates = { "tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png", "kilter": REPO_ROOT / "images" / "kilter-original-16x12_composite.png", @@ -105,6 +107,7 @@ def default_background_for_board(board: str) -> Path | None: def main() -> None: + """Generate routes, optionally score them, and save images plus a CSV.""" args = parse_args() board_config = load_board_for_demo(args.board, config_dir=REPO_ROOT / "configs") diff --git a/scripts/demo_generate_kilter.py b/scripts/demo_generate_kilter.py index a927722..d4ba9d6 100644 --- a/scripts/demo_generate_kilter.py +++ b/scripts/demo_generate_kilter.py @@ -9,6 +9,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] if __name__ == "__main__": + # Delegate to the generic demo so board-specific wrappers stay tiny. cmd = [ sys.executable, str(REPO_ROOT / "scripts" / "demo_generate_and_visualize.py"), diff --git a/scripts/demo_generate_tb2.py b/scripts/demo_generate_tb2.py index f9dd79c..34e867b 100644 --- a/scripts/demo_generate_tb2.py +++ b/scripts/demo_generate_tb2.py @@ -9,6 +9,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] if __name__ == "__main__": + # Delegate to the generic demo so board-specific wrappers stay tiny. cmd = [ sys.executable, str(REPO_ROOT / "scripts" / "demo_generate_and_visualize.py"), diff --git a/scripts/demo_predict_grade.py b/scripts/demo_predict_grade.py index bca4eaa..dd0e3c3 100644 --- a/scripts/demo_predict_grade.py +++ b/scripts/demo_predict_grade.py @@ -46,6 +46,7 @@ from climbingboardgpt.visualization import load_token_metadata, visualize_route_ def default_background_for_board(board: str) -> Path | None: + """Return the bundled board image path for a board when it exists.""" candidates = { "tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png", "kilter": REPO_ROOT / "images" / "kilter-original-16x12_composite.png", @@ -55,6 +56,7 @@ def default_background_for_board(board: str) -> Path | None: def parse_args() -> argparse.Namespace: + """Parse board, frames, model, and optional visualization settings.""" parser = argparse.ArgumentParser( description="Predict climb grade from board, angle, and frames string.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, @@ -99,6 +101,7 @@ def parse_args() -> argparse.Namespace: def main() -> None: + """Predict a frames string's grade and optionally save a route overlay.""" args = parse_args() board_config = load_board_for_demo(args.board, config_dir=REPO_ROOT / "configs") diff --git a/scripts/demo_predict_kilter.py b/scripts/demo_predict_kilter.py index 297b962..3935024 100644 --- a/scripts/demo_predict_kilter.py +++ b/scripts/demo_predict_kilter.py @@ -9,6 +9,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] if __name__ == "__main__": + # Delegate to the generic demo so board-specific wrappers stay tiny. cmd = [ sys.executable, str(REPO_ROOT / "scripts" / "demo_predict_grade.py"), diff --git a/scripts/demo_predict_tb2.py b/scripts/demo_predict_tb2.py index c630823..9df5520 100644 --- a/scripts/demo_predict_tb2.py +++ b/scripts/demo_predict_tb2.py @@ -9,6 +9,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] if __name__ == "__main__": + # Delegate to the generic demo so board-specific wrappers stay tiny. cmd = [ sys.executable, str(REPO_ROOT / "scripts" / "demo_predict_grade.py"), diff --git a/src/climbingboardgpt/datasets.py b/src/climbingboardgpt/datasets.py index 31706dc..ddffff8 100644 --- a/src/climbingboardgpt/datasets.py +++ b/src/climbingboardgpt/datasets.py @@ -1,3 +1,4 @@ +"""PyTorch dataset adapters for tokenized climbing-board routes.""" from __future__ import annotations import torch @@ -5,7 +6,15 @@ from torch.utils.data import Dataset class RouteGradeDataset(Dataset): + """Dataset for transformer encoder grade prediction. + + Each item returns a padded token sequence, a boolean attention mask, the + continuous display-difficulty target, and a small amount of route identity + metadata used when writing prediction CSVs. + """ + def __init__(self, df, max_len: int, pad_id: int): + """Store model IDs and labels from a tokenized route DataFrame.""" 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 @@ -15,9 +24,11 @@ class RouteGradeDataset(Dataset): self.pad_id = int(pad_id) def __len__(self) -> int: + """Return the number of route examples.""" return len(self.ids) def __getitem__(self, idx: int): + """Return one padded encoder example and its regression target.""" ids = list(self.ids[idx])[: self.max_len] mask = [1] * len(ids) if len(ids) < self.max_len: @@ -36,15 +47,25 @@ class RouteGradeDataset(Dataset): class RouteGPTDataset(Dataset): + """Dataset for causal next-token route generation. + + The full sequence is padded once, then split into ``input_ids`` and + ``target_ids`` shifted by one position for teacher-forced language-model + training. + """ + def __init__(self, df, max_len: int, pad_id: int): + """Store GPT token ID sequences from a tokenized route DataFrame.""" self.ids = df["gpt_ids"].tolist() self.max_len = int(max_len) self.pad_id = int(pad_id) def __len__(self) -> int: + """Return the number of route examples.""" return len(self.ids) def __getitem__(self, idx: int): + """Return one padded causal-language-model training example.""" ids = list(self.ids[idx])[: self.max_len] if len(ids) < self.max_len: ids += [self.pad_id] * (self.max_len - len(ids)) diff --git a/src/climbingboardgpt/evaluation.py b/src/climbingboardgpt/evaluation.py index 72469c8..18eb1df 100644 --- a/src/climbingboardgpt/evaluation.py +++ b/src/climbingboardgpt/evaluation.py @@ -1,3 +1,9 @@ +"""Evaluation utilities for generated climbing-board routes. + +The helpers in this module are intentionally model-agnostic: they work from +tokens, frames strings, and token metadata so notebooks, scripts, and tests can +reuse the same route validity, novelty, and geometry calculations. +""" from __future__ import annotations import re @@ -11,10 +17,12 @@ from .tokenization import parse_tokens, tokens_to_hold_records def parse_token_list(value) -> list[str]: + """Compatibility wrapper around the shared token parser.""" return parse_tokens(value) def validity_from_records(records: list[dict[str, object]], requested_board_prefix: str | None = None) -> dict[str, object]: + """Compute evaluation-specific route-validity flags from hold records.""" placements = [int(record["placement_id"]) for record in records] roles = [str(record["role"]) for record in records] prefixes = [str(record["board_token_prefix"]) for record in records] @@ -51,16 +59,19 @@ def validity_from_records(records: list[dict[str, object]], requested_board_pref def frames_to_holds(frames: str | None) -> list[tuple[int, int]]: + """Parse a frames string into ``(placement_id, role_id)`` pairs.""" if not isinstance(frames, str): return [] return [(int(p), int(r)) for p, r in re.findall(r"p(\d+)r(\d+)", frames)] def holds_to_placement_set(holds: Iterable[tuple[int, int]]) -> frozenset[int]: + """Drop role IDs and represent a route by its unique placement IDs.""" return frozenset(int(placement_id) for placement_id, _ in holds) def jaccard(a: frozenset[int], b: frozenset[int]) -> float: + """Return Jaccard similarity between two placement sets.""" if not a and not b: return 1.0 if not a or not b: @@ -73,6 +84,7 @@ def nearest_real_route_same_board( generated_board_key: str, real_df: pd.DataFrame, ) -> dict[str, object]: + """Find the most similar real route on the same board by Jaccard score.""" board_frame = real_df[real_df["board_key"] == generated_board_key] if board_frame.empty: return { @@ -100,6 +112,7 @@ def nearest_real_route_same_board( def build_placement_coords(df_token_meta: pd.DataFrame) -> dict[tuple[str, int], dict[str, float]]: + """Build a placement-coordinate lookup from token metadata.""" hold_meta = df_token_meta[df_token_meta["kind"] == "hold"].dropna(subset=["placement_id"]).copy() coords = {} for _, row in hold_meta.drop_duplicates(["board_key", "placement_id"]).iterrows(): @@ -116,6 +129,12 @@ def simple_route_features( records: list[dict[str, object]], placement_coords: dict[tuple[str, int], dict[str, float]], ) -> dict[str, float]: + """Compute simple geometric route features from hold coordinates. + + These features are descriptive rather than a full climbing-physics model: + height/width describe route spread, and hand-reach distances summarize the + pairwise spacing among start/middle/finish holds. + """ rows = [] for record in records: key = (str(board_key), int(record["placement_id"])) diff --git a/src/climbingboardgpt/generation.py b/src/climbingboardgpt/generation.py index d9d6c48..d913a53 100644 --- a/src/climbingboardgpt/generation.py +++ b/src/climbingboardgpt/generation.py @@ -1,3 +1,4 @@ +"""Sampling and structural-validity helpers for route generation.""" from __future__ import annotations from typing import Iterable @@ -9,6 +10,7 @@ from .tokenization import tokens_to_hold_records def top_k_filter(logits: torch.Tensor, k: int | None) -> torch.Tensor: + """Mask logits outside the top ``k`` choices for each batch row.""" if k is None or k <= 0 or k >= logits.size(-1): return logits values, _ = torch.topk(logits, k) @@ -27,6 +29,11 @@ def sample_ids( eos_id: int | None = None, forbidden_ids: Iterable[int] | None = None, ) -> list[int]: + """Autoregressively sample token IDs from a trained route generator. + + The returned list includes the prompt IDs and all sampled IDs up to either + ``max_new_tokens`` or the first sampled ``eos_id``. + """ model.eval() sequence = torch.tensor([prompt_ids], dtype=torch.long, device=device) forbidden_ids = set(forbidden_ids or []) @@ -36,6 +43,8 @@ def sample_ids( logits, _ = model(idx_cond) logits = logits[:, -1, :] / max(temperature, 1e-6) + # Special tokens like and are valid vocabulary entries but + # should never be emitted in the middle of a generated climb. for token_id in forbidden_ids: logits[:, int(token_id)] = -float("inf") @@ -51,6 +60,7 @@ def sample_ids( def prompt_tokens(board_prefix: str, angle: int, grouped_v: int) -> list[str]: + """Build the conditioning prefix used before sampling hold tokens.""" return [ "", f"", @@ -60,10 +70,12 @@ def prompt_tokens(board_prefix: str, angle: int, grouped_v: int) -> list[str]: def hold_records(tokens: Iterable[str]) -> list[dict[str, object]]: + """Extract hold records from generated tokens.""" return tokens_to_hold_records(tokens) def validity_summary(tokens: Iterable[str], requested_board_prefix: str | None = None) -> dict[str, object]: + """Summarize basic structural validity for generated token sequences.""" records = hold_records(tokens) placements = [record["placement_id"] for record in records] roles = [record["role"] for record in records] @@ -94,6 +106,11 @@ def validity_summary(tokens: Iterable[str], requested_board_prefix: str | None = def generated_tokens_to_frames(tokens: Iterable[str], role_name_to_id: dict[str, int], board_prefix: str | None = None) -> str: + """Convert generated hold tokens back into a frames string. + + Duplicate placements and unknown roles are skipped, matching the forgiving + cleanup used by the demo scripts and webapp. + """ pieces = [] seen = set() for record in hold_records(tokens): @@ -121,6 +138,7 @@ def generate_one( top_k: int | None = 50, max_new_tokens: int = 40, ) -> dict[str, object]: + """Generate one route and return tokens, frames, request metadata, validity.""" unk_id = stoi[""] eos_id = stoi[""] forbidden_ids = [ diff --git a/src/climbingboardgpt/grades.py b/src/climbingboardgpt/grades.py index 54872c7..3396bcf 100644 --- a/src/climbingboardgpt/grades.py +++ b/src/climbingboardgpt/grades.py @@ -1,5 +1,8 @@ +"""Grade-scale helpers for BoardLib display difficulty and grouped V grades.""" from __future__ import annotations +# BoardLib display difficulties are integer-like values. This project groups +# them into V grades so TB2 and Kilter can share a compact grade-token space. GRADE_TO_V = { 10: 0, 11: 0, 12: 0, 13: 1, 14: 1, @@ -22,10 +25,12 @@ GRADE_TO_V = { def to_grouped_v(display_difficulty: float) -> int: + """Map a continuous display difficulty to the nearest grouped V grade.""" rounded = int(round(float(display_difficulty))) rounded = max(min(rounded, max(GRADE_TO_V)), min(GRADE_TO_V)) return GRADE_TO_V[rounded] def grade_token(display_difficulty: float) -> str: + """Return the grade-conditioning token for a display difficulty value.""" return f"" diff --git a/src/climbingboardgpt/metrics.py b/src/climbingboardgpt/metrics.py index 70b327b..fd86fff 100644 --- a/src/climbingboardgpt/metrics.py +++ b/src/climbingboardgpt/metrics.py @@ -1,3 +1,4 @@ +"""Metrics used to evaluate continuous grade predictions.""" from __future__ import annotations import math @@ -10,6 +11,7 @@ from .grades import to_grouped_v def regression_metrics(y_true, y_pred) -> dict[str, float]: + """Compute difficulty-scale and grouped-V-grade prediction metrics.""" y_true = np.asarray(y_true) y_pred = np.asarray(y_pred) true_v = np.asarray([to_grouped_v(x) for x in y_true]) @@ -28,6 +30,7 @@ def regression_metrics(y_true, y_pred) -> dict[str, float]: def metrics_by_board(pred_df: pd.DataFrame) -> pd.DataFrame: + """Compute regression metrics separately for each board in a prediction table.""" rows = [] for board_key, frame in pred_df.groupby("board_key"): metrics = regression_metrics(frame["y_true"].values, frame["y_pred"].values) @@ -36,6 +39,7 @@ def metrics_by_board(pred_df: pd.DataFrame) -> pd.DataFrame: def print_metrics(name: str, metrics: dict[str, float]) -> None: + """Pretty-print a metric dictionary in the training scripts.""" print(name) print("-" * len(name)) for key, value in metrics.items(): diff --git a/src/climbingboardgpt/models.py b/src/climbingboardgpt/models.py index c50bde9..893aabe 100644 --- a/src/climbingboardgpt/models.py +++ b/src/climbingboardgpt/models.py @@ -1,3 +1,4 @@ +"""Neural network definitions for grade prediction and route generation.""" from __future__ import annotations import torch @@ -6,7 +7,13 @@ import torch.nn.functional as F class JointRouteTransformerRegressor(nn.Module): - """Transformer encoder for joint TB2/Kilter route difficulty prediction.""" + """Transformer encoder for joint TB2/Kilter route difficulty prediction. + + Inputs are token IDs plus an attention mask. Token, position, and learned + projections of coordinate metadata are added before the encoder. The first + ```` position is then used as a pooled route representation for scalar + difficulty regression. + """ def __init__( self, @@ -20,6 +27,7 @@ class JointRouteTransformerRegressor(nn.Module): dropout: float = 0.10, pad_id: int = 0, ): + """Create the encoder, coordinate projection, and regression head.""" super().__init__() self.vocab_size = vocab_size self.max_len = max_len @@ -55,9 +63,12 @@ class JointRouteTransformerRegressor(nn.Module): ) def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor: + """Return one continuous difficulty prediction per input sequence.""" batch_size, seq_len = input_ids.shape positions = torch.arange(seq_len, device=input_ids.device).unsqueeze(0).expand(batch_size, seq_len) + # Coordinate features are indexed by token ID, so every occurrence of a + # hold token gets the same physical x/y hint wherever it appears. x = self.token_emb(input_ids) + self.pos_emb(positions) x = x + self.coord_proj(self.coord_features[input_ids]) @@ -70,7 +81,11 @@ class JointRouteTransformerRegressor(nn.Module): class JointRouteGPT(nn.Module): - """Tiny GPT-style causal transformer for board-conditioned route generation.""" + """Tiny GPT-style causal transformer for board-conditioned route generation. + + PyTorch's ``TransformerEncoder`` is used with a causal mask, which makes it + behave like a decoder-only language model for short route sequences. + """ def __init__( self, @@ -82,6 +97,7 @@ class JointRouteGPT(nn.Module): dropout: float = 0.10, pad_id: int = 0, ): + """Create the token/position embeddings, causal blocks, and LM head.""" super().__init__() self.vocab_size = vocab_size self.block_size = block_size @@ -114,6 +130,7 @@ class JointRouteGPT(nn.Module): idx: torch.Tensor, targets: torch.Tensor | None = None, ) -> tuple[torch.Tensor, torch.Tensor | None]: + """Return next-token logits and, when targets are supplied, CE loss.""" _, seq_len = idx.shape if seq_len > self.block_size: idx = idx[:, -self.block_size :] @@ -126,6 +143,8 @@ class JointRouteGPT(nn.Module): torch.ones(seq_len, seq_len, device=idx.device, dtype=torch.bool), diagonal=1, ) + # Padding masks suppress attention to right-padded context tokens while + # the causal mask suppresses attention to future positions. key_padding_mask = idx.eq(self.pad_id) h = self.blocks( diff --git a/src/climbingboardgpt/paths.py b/src/climbingboardgpt/paths.py index 7473370..c99b5e8 100644 --- a/src/climbingboardgpt/paths.py +++ b/src/climbingboardgpt/paths.py @@ -1,9 +1,16 @@ +"""Path discovery helpers for scripts that can be launched from any directory.""" from __future__ import annotations from pathlib import Path def find_project_root(start: str | Path | None = None) -> Path: + """Walk upward until the repository root markers are found. + + The project root is identified by both ``pyproject.toml`` and ``configs``. + If neither marker pair is found, the resolved starting directory is returned + so callers still have a deterministic base path. + """ current = Path(start).resolve() if start is not None else Path.cwd().resolve() for candidate in [current, *current.parents]: if (candidate / "pyproject.toml").exists() and (candidate / "configs").exists(): diff --git a/src/climbingboardgpt/tokenization.py b/src/climbingboardgpt/tokenization.py index 9301fe4..8931577 100644 --- a/src/climbingboardgpt/tokenization.py +++ b/src/climbingboardgpt/tokenization.py @@ -1,3 +1,10 @@ +"""Route tokenization helpers shared by training, evaluation, and demos. + +The project represents every climb as a short symbolic sequence. Board, +angle, grade, and hold-role information are all encoded as tokens, while hold +tokens are namespaced by board so placement IDs from different products cannot +collide. +""" from __future__ import annotations import re @@ -19,6 +26,8 @@ SPECIAL_TOKENS = [ "", ] +# The token grammar is intentionally centralized here so training, generation, +# evaluation, and the webapp parse the same strings in the same way. ANGLE_TOKEN_PATTERN = re.compile(r"^$") GRADE_TOKEN_PATTERN = re.compile(r"^$") BOARD_TOKEN_PATTERN = re.compile(r"^$") @@ -34,6 +43,12 @@ ROLE_SORT_ORDER = { def parse_frames(frames_str: str | None) -> list[tuple[int, int]]: + """Parse a frames string into ``(placement_id, role_id)`` pairs. + + Frames strings are compact concatenations such as ``p344r5p369r6``. Invalid + or missing input returns an empty list so callers can skip unusable climbs + without special-case exception handling. + """ if not isinstance(frames_str, str): return [] matches = re.findall(r"p(\d+)r(\d+)", frames_str) @@ -78,6 +93,7 @@ def tokens_to_hold_records(tokens: Iterable[str]) -> list[dict[str, object]]: def make_placement_lookup(df_placements: pd.DataFrame) -> dict[tuple[str, int], dict]: + """Build a coordinate/metadata lookup keyed by ``(board_key, placement_id)``.""" rows = {} for _, row in df_placements.iterrows(): key = (str(row["board_key"]), int(row["placement_id"])) @@ -86,6 +102,7 @@ def make_placement_lookup(df_placements: pd.DataFrame) -> dict[tuple[str, int], def role_name(role_id: int, config: BoardConfig) -> str: + """Map a board-specific numeric role ID to a shared semantic role name.""" return config.role_id_to_name.get(int(role_id), "unknown") @@ -94,6 +111,7 @@ def placement_xy( placement_id: int, placement_lookup: dict[tuple[str, int], dict], ) -> tuple[float, float]: + """Return raw board coordinates for a placement, or NaNs if unknown.""" row = placement_lookup.get((str(board_key), int(placement_id))) if row is None: return (float("nan"), float("nan")) @@ -105,7 +123,15 @@ def canonicalize_holds( config: BoardConfig, placement_lookup: dict[tuple[str, int], dict], ) -> list[tuple[int, int]]: + """Sort holds into the canonical route order used by all model inputs. + + Frames preserve setter/storage order, which is not always stable + across routes or boards. Canonical ordering keeps starts first, hand/foot + holds in a bottom-to-top scan, and finishes last, giving the models a more + consistent sequence grammar. + """ def key(pair: tuple[int, int]): + """Sort by semantic role, then board position, then placement ID.""" placement_id, role_id = pair x, y = placement_xy(config.board_key, placement_id, placement_lookup) name = role_name(role_id, config) @@ -120,10 +146,12 @@ def canonicalize_holds( def board_token(config: BoardConfig) -> str: + """Return the special conditioning token for a board config.""" return f"" def angle_token(angle: float) -> str: + """Round a wall angle into the shared angle-token format.""" return f"" @@ -132,6 +160,7 @@ def hold_token( role_id: int, config: BoardConfig, ) -> str: + """Return a board-namespaced hold token for a placement and role.""" semantic_role = role_name(role_id, config) return f"<{config.token_prefix}_p{int(placement_id)}_{semantic_role}>" @@ -143,6 +172,12 @@ def tokenize_route( include_grade: bool = True, canonical: bool = True, ) -> list[str]: + """Tokenize one climb row into the sequence consumed by the models. + + ``include_grade=True`` is used for GPT-style generation, where the target + grade is a conditioning token. ``include_grade=False`` is used for grade + prediction so the model cannot read the answer from its input. + """ holds = parse_frames(row["frames"]) if canonical: holds = canonicalize_holds(holds, config, placement_lookup) @@ -165,6 +200,12 @@ def build_route_records( configs_by_key: dict[str, BoardConfig], placement_lookup: dict[tuple[str, int], dict], ) -> pd.DataFrame: + """Create one training/evaluation record per climb-angle row. + + The returned frame keeps both human-readable route metadata and model-ready + token sequences, which lets downstream scripts save compact CSV summaries + while still retaining the richer JSONL training artifacts. + """ records: list[dict] = [] for _, row in df_climbs.iterrows(): @@ -230,6 +271,7 @@ def build_route_records( def build_vocab(df_routes: pd.DataFrame) -> tuple[list[str], dict[str, int], dict[int, str]]: + """Build the shared token vocabulary from grade-conditioned sequences.""" all_tokens: list[str] = [] for tokens in df_routes["tokens_with_grade"]: all_tokens.extend(tokens) @@ -245,11 +287,13 @@ def build_vocab(df_routes: pd.DataFrame) -> tuple[list[str], dict[str, int], dic def encode(tokens: Iterable[str], stoi: dict[str, int]) -> list[int]: + """Convert tokens to integer IDs, using ```` for unseen tokens.""" unk_id = stoi[""] return [stoi.get(token, unk_id) for token in tokens] def decode(ids: Iterable[int], itos: dict[int, str]) -> list[str]: + """Convert integer IDs back to token strings.""" return [itos.get(int(idx), "") for idx in ids] @@ -260,6 +304,12 @@ def build_token_metadata( placement_lookup: dict[tuple[str, int], dict], configs_by_prefix: dict[str, BoardConfig], ) -> pd.DataFrame: + """Build per-token metadata used for coordinate features and plotting. + + Hold tokens receive raw coordinates, normalized coordinates in ``[-1, 1]``, + role labels, and board identity. Non-hold tokens keep neutral coordinate + features so the grade predictor can safely index every token ID. + """ bounds = {} for board_key, frame in df_placements.groupby("board_key"): xs = frame["x"].astype(float) @@ -272,6 +322,7 @@ def build_token_metadata( } def normalize(value: float, lo: float, hi: float) -> float: + """Scale one coordinate into ``[-1, 1]`` with safe missing-value handling.""" if pd.isna(value) or hi == lo: return 0.0 return 2 * ((float(value) - lo) / (hi - lo)) - 1 @@ -353,6 +404,7 @@ def vocab_payload( itos: dict[int, str], configs_by_key: dict[str, BoardConfig], ) -> dict: + """Package vocabulary and board metadata for JSON serialization.""" return { "stoi": stoi, "itos": {str(k): v for k, v in itos.items()}, diff --git a/src/climbingboardgpt/utils.py b/src/climbingboardgpt/utils.py index d8e34e1..ab54160 100644 --- a/src/climbingboardgpt/utils.py +++ b/src/climbingboardgpt/utils.py @@ -1,3 +1,4 @@ +"""Small shared utilities for reproducibility, JSON output, and data splits.""" from __future__ import annotations import json @@ -11,6 +12,7 @@ from sklearn.model_selection import train_test_split def set_seed(seed: int) -> None: + """Seed Python, NumPy, and PyTorch when PyTorch is installed.""" random.seed(seed) np.random.seed(seed) try: @@ -23,6 +25,7 @@ def set_seed(seed: int) -> None: def json_safe(obj: Any) -> Any: + """Convert NumPy/pandas values into JSON-serializable Python objects.""" if isinstance(obj, dict): return {str(k): json_safe(v) for k, v in obj.items()} if isinstance(obj, (list, tuple)): @@ -44,6 +47,7 @@ def json_safe(obj: Any) -> Any: def write_json(path: str | Path, payload: Any) -> None: + """Write an object as indented UTF-8 JSON after ``json_safe`` cleanup.""" path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(json_safe(payload), indent=2), encoding="utf-8") @@ -55,6 +59,12 @@ def safe_train_test_split( random_state: int, stratify_col: str | None = None, ): + """Split a DataFrame with optional stratification and graceful fallback. + + scikit-learn raises when a requested stratum is too small. The tokenization + pipeline prefers stratified splits when possible, but falls back to an + unstratified split rather than failing on tiny smoke-test subsets. + """ stratify = None if stratify_col is not None and stratify_col in df.columns: counts = df[stratify_col].value_counts() @@ -110,6 +120,7 @@ def assign_group_splits( ) def key_frame(frame: pd.DataFrame) -> set[tuple]: + """Return stringified group keys so pandas dtypes cannot affect joins.""" return set(map(tuple, frame[group_cols].astype(str).values.tolist())) train_keys = key_frame(train_groups) @@ -117,6 +128,7 @@ def assign_group_splits( test_keys = key_frame(test_groups) def split_for_row(row) -> str: + """Map one original row back to its group-level split assignment.""" key = tuple(str(row[col]) for col in group_cols) if key in train_keys: return "train" diff --git a/src/climbingboardgpt/visualization.py b/src/climbingboardgpt/visualization.py index ad44f81..bb73c74 100644 --- a/src/climbingboardgpt/visualization.py +++ b/src/climbingboardgpt/visualization.py @@ -98,6 +98,7 @@ def board_canvas_settings(board_key: str, df_token_meta: pd.DataFrame | None = N def _board_holds(df_token_meta: pd.DataFrame, board_key: str) -> pd.DataFrame: + """Return one metadata row per plotted hold for a board.""" holds = df_token_meta[ (df_token_meta["kind"] == "hold") & (df_token_meta["board_key"].astype(str) == str(board_key)) @@ -118,6 +119,7 @@ def _route_with_coords( df_token_meta: pd.DataFrame, board_key: str, ) -> pd.DataFrame: + """Attach x/y coordinates to route hold records using token metadata.""" holds = _board_holds(df_token_meta, board_key) coords = holds[["board_key", "board_token_prefix", "placement_id", "x", "y"]].drop_duplicates( ["board_key", "placement_id"] diff --git a/tests/test_core.py b/tests/test_core.py index 22bb536..d79a316 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -23,6 +23,8 @@ from climbingboardgpt.tokenization import ( class CoreBehaviorTest(unittest.TestCase): + """Smoke tests for token grammar, validity checks, and route matching.""" + 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) diff --git a/webapp/app.py b/webapp/app.py index 60734ca..d8a0921 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -44,6 +44,8 @@ from climbingboardgpt.utils import json_safe from climbingboardgpt.visualization import BOARD_CANVAS, load_token_metadata, tokens_to_route_records +# Environment variables keep deployment-specific paths and resource limits out +# of code, while the defaults make a local checkout runnable without config. DEVICE = os.getenv("CBGPT_DEVICE") or ("cuda" if torch.cuda.is_available() else "cpu") TORCH_THREADS = os.getenv("CBGPT_TORCH_THREADS") TORCH_THREADS_INT = int(TORCH_THREADS) if TORCH_THREADS else None @@ -66,6 +68,8 @@ BOARD_IMAGE_PATHS = { class GenerateRequest(BaseModel): + """JSON body for ``POST /api/generate``.""" + board: str = Field(..., pattern="^(tb2|kilter)$") angle: int = Field(40, ge=0, le=80) grade: int = Field(6, ge=0, le=16) @@ -77,12 +81,15 @@ class GenerateRequest(BaseModel): class PredictRequest(BaseModel): + """JSON body for ``POST /api/predict``.""" + board: str = Field(..., pattern="^(tb2|kilter)$") angle: int = Field(..., ge=0, le=80) frames: str = Field(..., min_length=1, max_length=500) def _board_config(board: str): + """Return the loaded board config or translate unknown boards to HTTP 400.""" try: return app.state.board_configs[board] except KeyError as exc: @@ -90,6 +97,7 @@ def _board_config(board: str): def _require_generator(): + """Return the loaded generator or raise HTTP 503 with a useful path hint.""" if app.state.generator is None: raise HTTPException( status_code=503, @@ -99,6 +107,7 @@ def _require_generator(): def _require_grade_predictor(): + """Return the loaded grade predictor or raise HTTP 503 with a path hint.""" if app.state.grade_predictor is None: raise HTTPException( status_code=503, @@ -108,6 +117,7 @@ def _require_grade_predictor(): def _tokens_to_holds(board: str, tokens: list[str]) -> list[dict[str, Any]]: + """Join route tokens to board coordinates for browser-side SVG drawing.""" route_records = tokens_to_route_records(tokens) if route_records.empty: return [] @@ -151,11 +161,13 @@ def _file_version(path: Path) -> str: def _static_image_url(board: str) -> str: + """Return the static board image URL with a cache-busting query string.""" image_path = BOARD_IMAGE_PATHS[board] return f"/board-images/{image_path.name}?board={board}&v={_file_version(image_path)}" def _file_info(path: Path) -> dict[str, Any]: + """Return lightweight debug metadata for a static file.""" if not path.exists(): return { "path": str(path), @@ -285,6 +297,7 @@ def _invalid_prediction_reasons(validity: dict[str, Any]) -> list[str]: def _angle_key(angle: Any) -> int: + """Normalize angle-like values for signatures and selectors.""" try: return int(round(float(angle))) except Exception: @@ -309,6 +322,7 @@ def _route_signature_from_holds(board: str, angle: Any, holds: list[dict[str, An def _holds_from_sequence(sequence: str) -> list[dict[str, Any]]: + """Extract exact-match hold-role records from a no-grade token sequence.""" holds: list[dict[str, Any]] = [] for record in tokens_to_hold_records(str(sequence).split()): holds.append( @@ -439,6 +453,7 @@ def _load_known_route_lookup(path: Path) -> dict[str, dict[str, Any]]: def _known_route_status(board: str, angle: Any, holds: list[dict[str, Any]]) -> dict[str, Any]: + """Check whether a route exactly matches a tokenized dataset route.""" signature = _route_signature_from_holds(board, angle, holds) if signature is None: return { @@ -462,6 +477,7 @@ def _known_route_status(board: str, angle: Any, holds: list[dict[str, Any]]) -> def _payload(result: dict[str, Any], tokens: list[str] | None = None) -> dict[str, Any]: + """Attach drawable holds, canvas data, image URL, and known-route status.""" board = str(result["board_key"]) tokens = list(tokens if tokens is not None else result.get("tokens", [])) extent = [float(v) for v in BOARD_CANVAS[board]["extent"]] @@ -491,6 +507,7 @@ def _payload(result: dict[str, Any], tokens: list[str] | None = None) -> dict[st @asynccontextmanager async def lifespan(app: FastAPI): + """Load model/checkpoint state once for the process lifetime.""" if TORCH_THREADS_INT is not None: torch.set_num_threads(TORCH_THREADS_INT) @@ -530,11 +547,13 @@ app.mount("/board-images", StaticFiles(directory=REPO_ROOT / "images"), name="bo @app.get("/") def index(): + """Serve the single-page web UI.""" return FileResponse(REPO_ROOT / "webapp" / "static" / "index.html") @app.get("/api/health") def health(): + """Return runtime readiness and deployment diagnostics.""" return { "ok": True, "device": DEVICE, @@ -550,6 +569,7 @@ def health(): @app.get("/api/boards") def boards(): + """Return board metadata needed to initialize client-side controls.""" payload = {} for board, config in app.state.board_configs.items(): extent = [float(v) for v in BOARD_CANVAS[board]["extent"]] @@ -576,6 +596,7 @@ def boards(): @app.get("/api/board-holds/{board}") def board_holds(board: str): + """Return all clickable holds for a board.""" config = _board_config(board) return json_safe({ "board_key": board, @@ -588,6 +609,7 @@ def board_holds(board: str): @app.get("/api/debug/images") def debug_images(): + """Return static-board-image debug metadata.""" payload = {} for board, image_path in BOARD_IMAGE_PATHS.items(): payload[board] = { @@ -599,6 +621,7 @@ def debug_images(): @app.post("/api/generate") def generate(req: GenerateRequest): + """Generate a climb and optionally retry until webapp validity passes.""" generator = _require_generator() config = _board_config(req.board) @@ -682,6 +705,7 @@ def generate(req: GenerateRequest): @app.post("/api/predict") def predict(req: PredictRequest): + """Predict grade for a user-supplied frames string after validity checks.""" predictor = _require_grade_predictor() config = _board_config(req.board) diff --git a/webapp/static/app.css b/webapp/static/app.css index aca1e70..caf3cf8 100644 --- a/webapp/static/app.css +++ b/webapp/static/app.css @@ -1,3 +1,4 @@ +/* Theme tokens shared by the full single-page demo. */ :root { --base00: #263238; --base01: #2e3c43; @@ -30,6 +31,7 @@ * { box-sizing: border-box; } +/* Page shell and header status. */ body { margin: 0; font-family: var(--mono); @@ -90,6 +92,7 @@ body { margin: 0 auto; } +/* Control cards, form fields, and action buttons. */ .controls { display: flex; flex-direction: column; @@ -202,6 +205,7 @@ button.secondary:hover { border-bottom: 1px dashed rgba(176, 190, 197, 0.16); } +/* Result panel and board overlay stage. */ .result-header { text-align: center; margin-bottom: 0.85rem; @@ -248,6 +252,7 @@ button.secondary:hover { pointer-events: auto; } +/* SVG overlay interaction layers. */ .click-target { cursor: crosshair; } @@ -279,12 +284,14 @@ button.secondary:hover { font-size: 0.76rem; } +/* Stack controls above the board on narrower screens. */ @media (max-width: 900px) { .layout { grid-template-columns: 1fr; } .site-header { flex-direction: column; } } +/* Supporting notes, explanations, links, and footer content. */ .checkbox-label { display: flex; align-items: center; diff --git a/webapp/static/app.js b/webapp/static/app.js index 8a880e1..28b63e6 100644 --- a/webapp/static/app.js +++ b/webapp/static/app.js @@ -1,3 +1,11 @@ +/* + * Browser-side controller for the ClimbingBoardGPT demo. + * + * The server returns route tokens, board coordinates, and canvas metadata as + * JSON. This file keeps the current UI state, draws holds as SVG markers over + * the board image, and serializes clicked holds back into frames strings for + * grade prediction. + */ const state = { boards: {}, boardHolds: {}, @@ -6,6 +14,8 @@ const state = { builder: [], }; +// Marker styles are expressed in board-coordinate units because the SVG viewBox +// is calibrated to the same coordinate system as token_metadata.csv. const roleStyle = { start: { fill: "#69f0ae", stroke: "#102022", r: 1.55, shape: "circle" }, middle: { fill: "#82b1ff", stroke: "#102022", r: 1.55, shape: "circle" }, @@ -14,6 +24,8 @@ const roleStyle = { unknown: { fill: "#b0bec5", stroke: "#102022", r: 1.45, shape: "circle" }, }; +// The builder uses brighter styling than generated routes so edited holds stand +// out after a user clicks or removes placements. const builderStyle = { start: { fill: "#00e676", stroke: "#000000", r: 1.85, shape: "circle" }, middle: { fill: "#448aff", stroke: "#000000", r: 1.85, shape: "circle" }, @@ -22,10 +34,12 @@ const builderStyle = { unknown: { fill: "#cfd8dc", stroke: "#000000", r: 1.85, shape: "circle" }, }; +/** Return an element by ID. */ function $(id) { return document.getElementById(id); } +/** Fetch JSON and normalize FastAPI/Pydantic errors into readable exceptions. */ async function fetchJson(url, options = {}) { const response = await fetch(url, options); const text = await response.text(); @@ -50,11 +64,13 @@ async function fetchJson(url, options = {}) { return payload; } +/** Toggle a button's disabled state and working label. */ function setBusy(button, busy) { button.disabled = busy; button.textContent = busy ? "Working…" : button.dataset.label; } +/** Load and cache the clickable hold coordinate list for a board. */ async function ensureBoardHolds(boardKey) { if (!state.boardHolds[boardKey]) { const payload = await fetchJson(`/api/board-holds/${boardKey}`); @@ -63,6 +79,7 @@ async function ensureBoardHolds(boardKey) { return state.boardHolds[boardKey]; } +/** Switch the displayed board image and overlay coordinate system. */ async function setBoardBackground(boardKey) { const board = state.boards[boardKey]; if (!board) return; @@ -85,6 +102,7 @@ async function setBoardBackground(boardKey) { +/** Pick a sensible angle for the active board when the current value is absent. */ function preferredAngleForBoard(boardKey, currentValue = null) { const board = state.boards[boardKey] || {}; const angles = board.available_angles || []; @@ -96,6 +114,7 @@ function preferredAngleForBoard(boardKey, currentValue = null) { return 40; } +/** Rebuild an angle select using angles available in the processed dataset. */ function populateAngleSelect(selectId, boardKey, selectedValue = null) { const select = $(selectId); if (!select) return; @@ -116,6 +135,7 @@ function populateAngleSelect(selectId, boardKey, selectedValue = null) { } } +/** Keep generation and prediction angle selectors in sync. */ function syncAngleSelectors(angleValue = null) { const boardKey = state.activeBoard || $("gen-board")?.value || $("pred-board")?.value || "tb2"; const selected = preferredAngleForBoard(boardKey, angleValue ?? $("gen-angle")?.value ?? $("pred-angle")?.value); @@ -130,6 +150,7 @@ function syncAngleSelectors(angleValue = null) { } +/** Pick a sensible grade for the active board when the current value is absent. */ function preferredGradeForBoard(boardKey, currentValue = null) { const board = state.boards[boardKey] || {}; const grades = board.available_grades || Array.from({ length: 16 }, (_, i) => i); @@ -141,6 +162,7 @@ function preferredGradeForBoard(boardKey, currentValue = null) { return 6; } +/** Rebuild a grade select using grades available in the processed dataset. */ function populateGradeSelect(selectId, boardKey, selectedValue = null) { const select = $(selectId); if (!select) return; @@ -161,6 +183,7 @@ function populateGradeSelect(selectId, boardKey, selectedValue = null) { } } +/** Keep the generation grade selector valid for the active board. */ function syncGradeSelector(gradeValue = null) { const boardKey = state.activeBoard || $("gen-board")?.value || "tb2"; const selected = preferredGradeForBoard(boardKey, gradeValue ?? $("gen-grade")?.value); @@ -170,6 +193,7 @@ function syncGradeSelector(gradeValue = null) { if (genGrade) genGrade.value = String(selected); } +/** Mirror the active board between generation and prediction controls. */ function syncBoardSelectors(boardKey) { const genBoard = $("gen-board"); const predBoard = $("pred-board"); @@ -177,6 +201,7 @@ function syncBoardSelectors(boardKey) { if (predBoard && predBoard.value !== boardKey) predBoard.value = boardKey; } +/** Replace the editable builder state with holds from an API result. */ function setBuilderFromRouteResult(result) { const board = result.board_key; state.builder = (result.holds || []).map((hold) => ({ @@ -189,6 +214,7 @@ function setBuilderFromRouteResult(result) { syncBuilderToFrames(); } +/** Summarize the active board's edited route for headers and validation hints. */ function activeBuilderSummary() { const board = $("pred-board")?.value || state.activeBoard; const holds = state.builder.filter((hold) => hold.board === board); @@ -197,6 +223,7 @@ function activeBuilderSummary() { return { holds, starts, finishes }; } +/** Show an editable-route status message while the user is clicking holds. */ function updateEditingHeader(prefix = "Editing climb") { const summary = activeBuilderSummary(); const frameText = $("pred-frames")?.value?.trim() || ""; @@ -214,10 +241,12 @@ function updateEditingHeader(prefix = "Editing climb") { }, null, 2); } +/** Remove all SVG children from the overlay. */ function clearOverlay() { $("overlay").innerHTML = ""; } +/** Create an SVG element with a simple attribute object. */ function svgEl(name, attrs = {}) { const el = document.createElementNS("http://www.w3.org/2000/svg", name); for (const [key, value] of Object.entries(attrs)) { @@ -226,6 +255,7 @@ function svgEl(name, attrs = {}) { return el; } +/** Return polygon points for the finish-hold star marker. */ function starPoints(cx, cy, outerR, innerR, n = 5) { const points = []; for (let i = 0; i < n * 2; i++) { @@ -236,6 +266,7 @@ function starPoints(cx, cy, outerR, innerR, n = 5) { return points.join(" "); } +/** Draw one route or builder marker into a transformed SVG group. */ function drawMarker(group, hold, style, className = "route-marker") { if (style.shape === "square") { const s = style.r * 2; @@ -274,6 +305,7 @@ function drawMarker(group, hold, style, className = "route-marker") { } } +/** Return a group that flips board coordinates into image/SVG screen space. */ function transformedGroup(resultOrBoardKey) { const boardKey = typeof resultOrBoardKey === "string" ? resultOrBoardKey : resultOrBoardKey.board_key; const board = state.boards[boardKey]; @@ -283,6 +315,7 @@ function transformedGroup(resultOrBoardKey) { }); } +/** Draw transparent click targets over every known hold on the active board. */ function drawSelectableTargets(boardKey, group) { const holds = state.boardHolds[boardKey] || []; for (const hold of holds) { @@ -321,6 +354,7 @@ function drawSelectableTargets(boardKey, group) { } } +/** Render an API result and make it editable through the route builder. */ function drawOverlay(result) { state.lastResult = result; syncBoardSelectors(result.board_key); @@ -329,6 +363,7 @@ function drawOverlay(result) { redrawCurrentOverlay(); } +/** Redraw builder markers and click targets from the current state. */ function redrawCurrentOverlay() { const svg = $("overlay"); svg.innerHTML = ""; @@ -348,24 +383,29 @@ function redrawCurrentOverlay() { +/** Find the selected builder hold index for a board placement. */ function findBuilderHoldIndex(board, placementId) { return state.builder.findIndex( (hold) => hold.board === board && Number(hold.placement_id) === Number(placementId) ); } +/** Return true when a board placement is already present in the builder. */ function isBuilderHoldSelected(board, placementId) { return findBuilderHoldIndex(board, placementId) >= 0; } +/** Return builder holds belonging to the currently displayed board. */ function activeBuilderHolds() { return state.builder.filter((hold) => hold.board === state.activeBoard); } +/** Count a role among builder holds on the currently displayed board. */ function roleCountForActiveBoard(role) { return activeBuilderHolds().filter((hold) => hold.role === role).length; } +/** Enforce the same start/finish limits used by the web API. */ function canAddBuilderRole(role) { if ((role === "start" || role === "finish") && roleCountForActiveBoard(role) >= 2) { showWarnings([ @@ -376,6 +416,7 @@ function canAddBuilderRole(role) { return true; } +/** Convert the builder route into a frames string. */ function frameStringForBuilder() { const board = state.boards[$("pred-board").value]; if (!board) return ""; @@ -386,6 +427,7 @@ function frameStringForBuilder() { .join(""); } +/** Update the frames textarea and selected-hold list from builder state. */ function syncBuilderToFrames() { $("pred-frames").value = frameStringForBuilder(); const list = $("builder-list"); @@ -400,6 +442,7 @@ function syncBuilderToFrames() { updatePredictButton(); } +/** Add or remove a clicked hold, using the currently selected semantic role. */ function addBuilderHold(hold) { const board = $("pred-board").value; const role = $("click-role").value; @@ -435,6 +478,7 @@ function addBuilderHold(hold) { updateEditingHeader("Editing generated / clicked climb"); } +/** Remove the most recently added hold for the selected prediction board. */ function undoBuilderHold() { const board = $("pred-board").value; for (let i = state.builder.length - 1; i >= 0; i--) { @@ -448,11 +492,13 @@ function undoBuilderHold() { updateEditingHeader("Editing generated / clicked climb"); } +/** Backward-compatible clear handler retained for older markup. */ function clearBuilder() { clearEntireBoard(); } +/** Show warning text in the dedicated box, falling back to the subtitle. */ function showWarnings(warnings = []) { const box = $("warning-box"); const normalized = (warnings || []).filter(Boolean).map(String); @@ -475,6 +521,7 @@ function showWarnings(warnings = []) { } +/** Clear builder state without changing result headers or overlay background. */ function clearClickedHoldsOnly() { state.builder = []; const frames = $("pred-frames"); @@ -484,11 +531,13 @@ function clearClickedHoldsOnly() { updatePredictButton(); } +/** Public clear action for both clear buttons. */ function clearBoard() { clearEntireBoard(); } +/** Enable prediction only when a frames string is available. */ function updatePredictButton() { const button = $("predict-btn"); if (!button) return; @@ -501,6 +550,7 @@ function updatePredictButton() { : "Paste a frames string or click holds on the board first."; } +/** Reset route, raw JSON, warnings, and overlay markers for the current board. */ function clearEntireBoard() { state.lastResult = null; state.builder = []; @@ -517,6 +567,7 @@ function clearEntireBoard() { } +/** Format exact-match known-climb metadata for the result subtitle. */ function knownClimbSummary(result) { const known = result.known_climb; if (!known || !known.checked) return null; @@ -528,6 +579,7 @@ function knownClimbSummary(result) { return `known climb: ${name} (${grade}); ${known.match_count} matching route-angle entr${known.match_count === 1 ? "y" : "ies"}`; } +/** Render human-readable result text and raw JSON for an API response. */ function summarizeResult(result, mode) { if (mode === "generate") { const pieces = [ @@ -561,6 +613,7 @@ function summarizeResult(result, mode) { } +/** Convert structured API error JSON into a compact warning message. */ function formatErrorMessage(message) { if (!message) return "Unknown error."; try { @@ -577,6 +630,7 @@ function formatErrorMessage(message) { } } +/** Submit a generation request and render the sampled route. */ async function generate() { const button = $("generate-btn"); setBusy(button, true); @@ -603,6 +657,7 @@ async function generate() { } } +/** Submit a grade-prediction request for the current frames string. */ async function predict() { const button = $("predict-btn"); const frames = $("pred-frames").value.trim(); @@ -635,6 +690,7 @@ async function predict() { } +/** Attach a listener only when optional markup exists. */ function addListenerIfPresent(id, eventName, handler) { const element = $(id); if (!element) { @@ -644,6 +700,7 @@ function addListenerIfPresent(id, eventName, handler) { element.addEventListener(eventName, handler); } +/** Initialize server state, controls, board background, and event listeners. */ async function init() { $("generate-btn").dataset.label = "Generate"; $("predict-btn").dataset.label = "Predict pasted / clicked climb"; diff --git a/webapp/static/index.html b/webapp/static/index.html index ce57d79..27cac1a 100644 --- a/webapp/static/index.html +++ b/webapp/static/index.html @@ -7,6 +7,7 @@ +
+

Generate a climb

@@ -158,6 +160,7 @@
+
@@ -184,6 +187,7 @@
+