diff --git a/README.md b/README.md index 4429db0..bab0745 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file +The project is for educational purposes. Climb data belongs to Tension Climbing and Kilter respectively. diff --git a/pyproject.toml b/pyproject.toml index 48d02c4..76fefb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/requirements.txt b/requirements.txt index a8e98b3..284f29b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/scripts/01_tokenize_routes.py b/scripts/01_tokenize_routes.py index 5537f45..abc21c1 100644 --- a/scripts/01_tokenize_routes.py +++ b/scripts/01_tokenize_routes.py @@ -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):,}") diff --git a/scripts/02_train_grade_predictor.py b/scripts/02_train_grade_predictor.py index db792f2..07a8faa 100644 --- a/scripts/02_train_grade_predictor.py +++ b/scripts/02_train_grade_predictor.py @@ -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[""] - unk_id = stoi[""] + 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 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: [""] + tokens[1:] if tokens else [""] ) - 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() \ No newline at end of file + main() diff --git a/scripts/03_train_route_generator.py b/scripts/03_train_route_generator.py index cd16520..b2885a8 100644 --- a/scripts/03_train_route_generator.py +++ b/scripts/03_train_route_generator.py @@ -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[""] - unk_id = stoi[""] + 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() \ No newline at end of file + main() diff --git a/scripts/04_evaluate_generated_routes.py b/scripts/04_evaluate_generated_routes.py index 1492d74..c931984 100644 --- a/scripts/04_evaluate_generated_routes.py +++ b/scripts/04_evaluate_generated_routes.py @@ -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() \ No newline at end of file + main() diff --git a/scripts/demo_generate_and_visualize.py b/scripts/demo_generate_and_visualize.py index 7cad3d5..a7eb6b1 100644 --- a/scripts/demo_generate_and_visualize.py +++ b/scripts/demo_generate_and_visualize.py @@ -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 diff --git a/scripts/demo_predict_grade.py b/scripts/demo_predict_grade.py index 384da86..fcc9d8a 100644 --- a/scripts/demo_predict_grade.py +++ b/scripts/demo_predict_grade.py @@ -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 diff --git a/src/climbingboardgpt/checkpoints.py b/src/climbingboardgpt/checkpoints.py new file mode 100644 index 0000000..5f9f3d9 --- /dev/null +++ b/src/climbingboardgpt/checkpoints.py @@ -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) diff --git a/src/climbingboardgpt/data.py b/src/climbingboardgpt/data.py index b2ba148..9619434 100644 --- a/src/climbingboardgpt/data.py +++ b/src/climbingboardgpt/data.py @@ -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), - ) \ No newline at end of file + ) diff --git a/src/climbingboardgpt/datasets.py b/src/climbingboardgpt/datasets.py index 342fc60..31706dc 100644 --- a/src/climbingboardgpt/datasets.py +++ b/src/climbingboardgpt/datasets.py @@ -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], } diff --git a/src/climbingboardgpt/evaluation.py b/src/climbingboardgpt/evaluation.py index c62b64a..72469c8 100644 --- a/src/climbingboardgpt/evaluation.py +++ b/src/climbingboardgpt/evaluation.py @@ -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() diff --git a/src/climbingboardgpt/generation.py b/src/climbingboardgpt/generation.py index 7df01bd..d9d6c48 100644 --- a/src/climbingboardgpt/generation.py +++ b/src/climbingboardgpt/generation.py @@ -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]: diff --git a/src/climbingboardgpt/inference.py b/src/climbingboardgpt/inference.py index d9f70d4..49bdc03 100644 --- a/src/climbingboardgpt/inference.py +++ b/src/climbingboardgpt/inference.py @@ -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, } - diff --git a/src/climbingboardgpt/tokenization.py b/src/climbingboardgpt/tokenization.py index f03b14d..9301fe4 100644 --- a/src/climbingboardgpt/tokenization.py +++ b/src/climbingboardgpt/tokenization.py @@ -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(): diff --git a/src/climbingboardgpt/utils.py b/src/climbingboardgpt/utils.py index 2c1ec10..d8e34e1 100644 --- a/src/climbingboardgpt/utils.py +++ b/src/climbingboardgpt/utils.py @@ -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( diff --git a/src/climbingboardgpt/visualization.py b/src/climbingboardgpt/visualization.py index 5dfea8f..ad44f81 100644 --- a/src/climbingboardgpt/visualization.py +++ b/src/climbingboardgpt/visualization.py @@ -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: diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..22bb536 --- /dev/null +++ b/tests/test_core.py @@ -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("['', '']"), ["", ""]) + self.assertEqual(parse_tokens(" "), ["", ""]) + + def test_tokens_to_hold_records_extracts_shared_shape(self): + records = tokens_to_hold_records(["", "", ""]) + self.assertEqual( + records, + [ + { + "token": "", + "board_token_prefix": "TB2", + "board_prefix": "TB2", + "placement_id": 344, + "role": "start", + }, + { + "token": "", + "board_token_prefix": "TB2", + "board_prefix": "TB2", + "placement_id": 603, + "role": "finish", + }, + ], + ) + + def test_validity_summary_flags_basic_valid_route(self): + summary = validity_summary( + ["", "", ""], + 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), + [ + "", + "", + "", + "", + "", + "", + "", + "", + ], + ) + + 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() diff --git a/webapp/app.py b/webapp/app.py index a032538..646c371 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -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, diff --git a/webapp/static/app.css b/webapp/static/app.css index d467f8c..aca1e70 100644 --- a/webapp/static/app.css +++ b/webapp/static/app.css @@ -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; } diff --git a/webapp/static/index.html b/webapp/static/index.html index 121b0a8..83244e2 100644 --- a/webapp/static/index.html +++ b/webapp/static/index.html @@ -4,7 +4,7 @@ ClimbingBoardGPT - +