Add web demo polish and smoke-test pipeline
This commit is contained in:
@@ -158,7 +158,7 @@ ClimbingBoardGPT/
|
|||||||
│ └── processed/
|
│ └── processed/
|
||||||
├── images/
|
├── images/
|
||||||
│ ├── tb2_board_12x12_composite.png
|
│ ├── tb2_board_12x12_composite.png
|
||||||
│ └── kilter-original-16x12_compose.png
|
│ └── kilter-original-16x12_composite.png
|
||||||
├── models/
|
├── models/
|
||||||
│ ├── joint_transformer_grade_predictor.pth
|
│ ├── joint_transformer_grade_predictor.pth
|
||||||
│ └── joint_route_gpt_generator.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 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
|
## Full training pipeline
|
||||||
@@ -360,7 +407,7 @@ The visualization uses calibrated board backgrounds:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
images/tb2_board_12x12_composite.png
|
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:
|
These are overlaid using product-size coordinate windows:
|
||||||
|
|||||||
+9
-6
@@ -9,12 +9,15 @@ description = "Unified TB2/Kilter transformer route modeling, grade prediction,
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"numpy",
|
"numpy>=1.26",
|
||||||
"pandas",
|
"pandas>=2.1",
|
||||||
"scipy",
|
"scipy>=1.11",
|
||||||
"scikit-learn",
|
"scikit-learn>=1.3",
|
||||||
"matplotlib",
|
"matplotlib>=3.8",
|
||||||
"torch"
|
"torch>=2.0",
|
||||||
|
"fastapi>=0.110",
|
||||||
|
"uvicorn[standard]>=0.27",
|
||||||
|
"pydantic>=2.0"
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
+9
-6
@@ -1,6 +1,9 @@
|
|||||||
numpy
|
numpy>=1.26
|
||||||
pandas
|
pandas>=2.1
|
||||||
scipy
|
scipy>=1.11
|
||||||
scikit-learn
|
scikit-learn>=1.3
|
||||||
matplotlib
|
matplotlib>=3.8
|
||||||
torch
|
torch>=2.0
|
||||||
|
fastapi>=0.110
|
||||||
|
uvicorn[standard]>=0.27
|
||||||
|
pydantic>=2.0
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ Examples:
|
|||||||
default=3,
|
default=3,
|
||||||
help="Random seed for reproducible splits (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()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -121,6 +127,8 @@ def main() -> None:
|
|||||||
8. Save all artifacts to disk
|
8. Save all artifacts to disk
|
||||||
"""
|
"""
|
||||||
args = parse_args()
|
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
|
# Set random seed for reproducibility
|
||||||
# This ensures train/val/test splits are the same across runs
|
# 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 369 with role 6 (middle)
|
||||||
# placement 603 with role 7 (finish)
|
# placement 603 with role 7 (finish)
|
||||||
print("\nLoading data from databases...")
|
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)
|
placement_lookup = make_placement_lookup(df_placements)
|
||||||
|
|
||||||
print(f" Total climb-angle entries: {len(df_climbs):,}")
|
print(f" Total climb-angle entries: {len(df_climbs):,}")
|
||||||
|
|||||||
@@ -62,8 +62,11 @@ from climbingboardgpt.datasets import RouteGradeDataset
|
|||||||
from climbingboardgpt.grades import to_grouped_v
|
from climbingboardgpt.grades import to_grouped_v
|
||||||
from climbingboardgpt.metrics import metrics_by_board, print_metrics, regression_metrics
|
from climbingboardgpt.metrics import metrics_by_board, print_metrics, regression_metrics
|
||||||
from climbingboardgpt.models import JointRouteTransformerRegressor
|
from climbingboardgpt.models import JointRouteTransformerRegressor
|
||||||
|
from climbingboardgpt.tokenization import encode as encode_tokens
|
||||||
from climbingboardgpt.utils import set_seed, write_json
|
from climbingboardgpt.utils import set_seed, write_json
|
||||||
|
|
||||||
|
MSE_LOSS = nn.MSELoss()
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
"""Parse command-line arguments for grade predictor training.
|
"""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("--dropout", type=float, default=0.10, help="Dropout probability")
|
||||||
parser.add_argument("--seed", type=int, default=3, help="Random seed")
|
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("--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()
|
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:
|
def build_coord_features(df_token_meta: pd.DataFrame, vocab_size: int) -> torch.Tensor:
|
||||||
"""Build coordinate feature matrix for the transformer model.
|
"""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
|
is_train = optimizer is not None
|
||||||
model.train(is_train)
|
model.train(is_train)
|
||||||
criterion = nn.MSELoss()
|
|
||||||
|
|
||||||
losses, preds, targets, uuids, boards = [], [], [], [], []
|
losses, preds, targets, row_ids, uuids, boards = [], [], [], [], [], []
|
||||||
|
|
||||||
for batch in loader:
|
for batch in loader:
|
||||||
input_ids = batch["input_ids"].to(device)
|
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
|
# Forward pass: model predicts difficulty from token sequence
|
||||||
pred = model(input_ids, attention_mask)
|
pred = model(input_ids, attention_mask)
|
||||||
loss = criterion(pred, target)
|
loss = MSE_LOSS(pred, target)
|
||||||
|
|
||||||
if is_train:
|
if is_train:
|
||||||
# Backward pass: compute gradients and update weights
|
# 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))
|
losses.append(loss.item() * input_ids.size(0))
|
||||||
preds.extend(pred.detach().cpu().numpy().tolist())
|
preds.extend(pred.detach().cpu().numpy().tolist())
|
||||||
targets.extend(target.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"])
|
uuids.extend(batch["uuid"])
|
||||||
boards.extend(batch["board_key"])
|
boards.extend(batch["board_key"])
|
||||||
|
|
||||||
avg_loss = sum(losses) / max(1, len(loader.dataset))
|
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:
|
def main() -> None:
|
||||||
@@ -195,6 +220,7 @@ def main() -> None:
|
|||||||
8. Save model checkpoint and metrics
|
8. Save model checkpoint and metrics
|
||||||
"""
|
"""
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
apply_smoke_test_defaults(args)
|
||||||
set_seed(args.seed)
|
set_seed(args.seed)
|
||||||
args.out_dir.mkdir(parents=True, exist_ok=True)
|
args.out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
args.model_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)
|
df_token_meta = pd.read_csv(meta_path)
|
||||||
|
|
||||||
pad_id = stoi["<PAD>"]
|
pad_id = stoi["<PAD>"]
|
||||||
unk_id = stoi["<UNK>"]
|
device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
# Step 2: Prepare input sequences
|
# Step 2: Prepare input sequences
|
||||||
@@ -223,15 +249,13 @@ def main() -> None:
|
|||||||
# For grade prediction, we use the "no_grade" version of the sequence
|
# For grade prediction, we use the "no_grade" version of the sequence
|
||||||
# and prepend <CLS> for sequence-level pooling.
|
# and prepend <CLS> for sequence-level pooling.
|
||||||
# The model must PREDICT the grade, not see it in the input!
|
# 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["tokens_no_grade"] = df_routes["sequence_no_grade"].fillna("").str.split()
|
||||||
df_routes["model_tokens"] = df_routes["tokens_no_grade"].apply(
|
df_routes["model_tokens"] = df_routes["tokens_no_grade"].apply(
|
||||||
lambda tokens: ["<CLS>"] + tokens[1:] if tokens else ["<CLS>"]
|
lambda tokens: ["<CLS>"] + tokens[1:] if tokens else ["<CLS>"]
|
||||||
)
|
)
|
||||||
df_routes["model_ids"] = df_routes["model_tokens"].apply(encode)
|
df_routes["model_ids"] = df_routes["model_tokens"].apply(lambda tokens: encode_tokens(tokens, stoi))
|
||||||
df_routes["seq_len"] = df_routes["model_ids"].apply(len)
|
df_routes["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())
|
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)
|
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)
|
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)
|
loader_kwargs = {
|
||||||
val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False)
|
"num_workers": int(args.num_workers),
|
||||||
test_loader = DataLoader(test_ds, batch_size=args.batch_size, shuffle=False)
|
"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
|
# 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))
|
coord_features = build_coord_features(df_token_meta, vocab_size=len(stoi))
|
||||||
|
|
||||||
model = JointRouteTransformerRegressor(
|
model = JointRouteTransformerRegressor(
|
||||||
@@ -286,8 +313,8 @@ def main() -> None:
|
|||||||
|
|
||||||
print("\nStarting training...")
|
print("\nStarting training...")
|
||||||
for epoch in range(1, args.epochs + 1):
|
for epoch in range(1, args.epochs + 1):
|
||||||
train_loss, train_pred, train_true, _, _ = run_epoch(model, train_loader, device, optimizer)
|
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)
|
val_loss, val_pred, val_true, _, _, _ = run_epoch(model, val_loader, device, optimizer=None)
|
||||||
|
|
||||||
train_metrics = regression_metrics(train_true, train_pred)
|
train_metrics = regression_metrics(train_true, train_pred)
|
||||||
val_metrics = regression_metrics(val_true, val_pred)
|
val_metrics = regression_metrics(val_true, val_pred)
|
||||||
@@ -332,10 +359,11 @@ def main() -> None:
|
|||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
# Step 6: Test set evaluation
|
# 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)
|
overall_metrics = regression_metrics(test_true, test_pred)
|
||||||
|
|
||||||
pred_df = pd.DataFrame({
|
pred_df = pd.DataFrame({
|
||||||
|
"row_id": test_row_id,
|
||||||
"uuid": test_uuid,
|
"uuid": test_uuid,
|
||||||
"board_key": test_board,
|
"board_key": test_board,
|
||||||
"y_true": test_true,
|
"y_true": test_true,
|
||||||
@@ -344,13 +372,15 @@ def main() -> None:
|
|||||||
"true_v": [to_grouped_v(value) for value in test_true],
|
"true_v": [to_grouped_v(value) for value in test_true],
|
||||||
"pred_v": [to_grouped_v(value) for value in test_pred],
|
"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)
|
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_metrics("Overall joint test performance", overall_metrics)
|
||||||
print("\nBoard-specific test performance:")
|
print("\nBoard-specific test performance:")
|
||||||
print(board_metrics_df.to_string(index=False))
|
print(board_metrics_df.to_string(index=False))
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ from climbingboardgpt.config import load_board_configs, parse_board_keys
|
|||||||
from climbingboardgpt.datasets import RouteGPTDataset
|
from climbingboardgpt.datasets import RouteGPTDataset
|
||||||
from climbingboardgpt.generation import generate_one
|
from climbingboardgpt.generation import generate_one
|
||||||
from climbingboardgpt.models import JointRouteGPT
|
from climbingboardgpt.models import JointRouteGPT
|
||||||
|
from climbingboardgpt.tokenization import encode as encode_tokens
|
||||||
from climbingboardgpt.utils import set_seed
|
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("--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("--seed", type=int, default=3, help="Random seed")
|
||||||
parser.add_argument("--device", type=str, default=None, help="Device (cpu or cuda)")
|
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()
|
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:
|
def evaluate_loss(model, loader, device) -> float:
|
||||||
"""Evaluate the model on a data loader, returning average loss.
|
"""Evaluate the model on a data loader, returning average loss.
|
||||||
|
|
||||||
@@ -168,6 +192,7 @@ def main() -> None:
|
|||||||
7. Save model checkpoint and generated routes
|
7. Save model checkpoint and generated routes
|
||||||
"""
|
"""
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
apply_smoke_test_defaults(args)
|
||||||
set_seed(args.seed)
|
set_seed(args.seed)
|
||||||
args.out_dir.mkdir(parents=True, exist_ok=True)
|
args.out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
args.model_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()}
|
stoi = {str(k): int(v) for k, v in vocab["stoi"].items()}
|
||||||
itos = {int(k): str(v) for k, v in vocab["itos"].items()}
|
itos = {int(k): str(v) for k, v in vocab["itos"].items()}
|
||||||
pad_id = stoi["<PAD>"]
|
pad_id = stoi["<PAD>"]
|
||||||
unk_id = stoi["<UNK>"]
|
device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
# Step 2: Prepare sequences for causal language modeling
|
# 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.
|
# The input is shifted right by one position compared to the target.
|
||||||
# This is the standard causal language modeling setup.
|
# 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_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)
|
df_routes["seq_len"] = df_routes["gpt_ids"].apply(len)
|
||||||
max_len = int(df_routes["seq_len"].max())
|
max_len = int(df_routes["seq_len"].max())
|
||||||
if max_len < 2:
|
if max_len < 2:
|
||||||
@@ -220,14 +242,17 @@ def main() -> None:
|
|||||||
val_ds = RouteGPTDataset(val_df, max_len=max_len, pad_id=pad_id)
|
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)
|
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)
|
loader_kwargs = {
|
||||||
val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False)
|
"num_workers": int(args.num_workers),
|
||||||
test_loader = DataLoader(test_ds, batch_size=args.batch_size, shuffle=False)
|
"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
|
# Step 4: Initialize model
|
||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
|
||||||
model = JointRouteGPT(
|
model = JointRouteGPT(
|
||||||
vocab_size=len(stoi),
|
vocab_size=len(stoi),
|
||||||
block_size=block_size,
|
block_size=block_size,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from climbingboardgpt.evaluation import (
|
|||||||
tokens_to_hold_records,
|
tokens_to_hold_records,
|
||||||
validity_from_records,
|
validity_from_records,
|
||||||
)
|
)
|
||||||
|
from climbingboardgpt.checkpoints import load_checkpoint
|
||||||
from climbingboardgpt.grades import to_grouped_v
|
from climbingboardgpt.grades import to_grouped_v
|
||||||
from climbingboardgpt.models import JointRouteTransformerRegressor
|
from climbingboardgpt.models import JointRouteTransformerRegressor
|
||||||
|
|
||||||
@@ -86,10 +87,7 @@ def load_grade_critic(model_path: Path, device: torch.device):
|
|||||||
"""
|
"""
|
||||||
if not model_path.exists():
|
if not model_path.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
checkpoint = load_checkpoint(model_path, map_location=device, trusted=True)
|
||||||
checkpoint = torch.load(model_path, map_location=device, weights_only=False)
|
|
||||||
except TypeError:
|
|
||||||
checkpoint = torch.load(model_path, map_location=device)
|
|
||||||
|
|
||||||
cfg = checkpoint["config"]
|
cfg = checkpoint["config"]
|
||||||
stoi = {str(k): int(v) for k, v in checkpoint["stoi"].items()}
|
stoi = {str(k): int(v) for k, v in checkpoint["stoi"].items()}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
help=(
|
help=(
|
||||||
"Optional board image to draw under the scatter plot. "
|
"Optional board image to draw under the scatter plot. "
|
||||||
"If omitted, the script automatically uses images/tb2_board_12x12_composite.png "
|
"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()
|
return parser.parse_args()
|
||||||
@@ -98,7 +98,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
def default_background_for_board(board: str) -> Path | None:
|
def default_background_for_board(board: str) -> Path | None:
|
||||||
candidates = {
|
candidates = {
|
||||||
"tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png",
|
"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)
|
path = candidates.get(board)
|
||||||
return path if path is not None and path.exists() else None
|
return path if path is not None and path.exists() else None
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ from climbingboardgpt.visualization import load_token_metadata, visualize_route_
|
|||||||
def default_background_for_board(board: str) -> Path | None:
|
def default_background_for_board(board: str) -> Path | None:
|
||||||
candidates = {
|
candidates = {
|
||||||
"tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png",
|
"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)
|
path = candidates.get(board)
|
||||||
return path if path is not None and path.exists() else None
|
return path if path is not None and path.exists() else None
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -135,12 +135,14 @@ def build_placements_query(config: BoardConfig) -> tuple[str, list]:
|
|||||||
def load_board_data(
|
def load_board_data(
|
||||||
config: BoardConfig,
|
config: BoardConfig,
|
||||||
project_root: str | Path | None = None,
|
project_root: str | Path | None = None,
|
||||||
|
max_climbs: int | None = None,
|
||||||
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||||
"""Load climbs and placements data for a single board.
|
"""Load climbs and placements data for a single board.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Board configuration
|
config: Board configuration
|
||||||
project_root: Path to project root (for resolving db_path)
|
project_root: Path to project root (for resolving db_path)
|
||||||
|
max_climbs: Optional row limit for fast smoke-test loads.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (climbs DataFrame, placements DataFrame)
|
Tuple of (climbs DataFrame, placements DataFrame)
|
||||||
@@ -154,6 +156,11 @@ def load_board_data(
|
|||||||
|
|
||||||
climbs_query, climbs_params = build_climbs_query(config)
|
climbs_query, climbs_params = build_climbs_query(config)
|
||||||
placements_query, placements_params = build_placements_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:
|
with sqlite3.connect(db_path) as conn:
|
||||||
df_climbs = pd.read_sql_query(climbs_query, conn, params=climbs_params)
|
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(
|
def load_multi_board_data(
|
||||||
configs: list[BoardConfig],
|
configs: list[BoardConfig],
|
||||||
project_root: str | Path | None = None,
|
project_root: str | Path | None = None,
|
||||||
|
max_climbs_per_board: int | None = None,
|
||||||
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||||
"""Load and concatenate data from multiple boards.
|
"""Load and concatenate data from multiple boards.
|
||||||
|
|
||||||
@@ -184,6 +192,7 @@ def load_multi_board_data(
|
|||||||
Args:
|
Args:
|
||||||
configs: List of board configurations
|
configs: List of board configurations
|
||||||
project_root: Path to project root
|
project_root: Path to project root
|
||||||
|
max_climbs_per_board: Optional row limit per board for smoke tests.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (combined climbs DataFrame, combined placements DataFrame)
|
Tuple of (combined climbs DataFrame, combined placements DataFrame)
|
||||||
@@ -192,7 +201,11 @@ def load_multi_board_data(
|
|||||||
placement_frames = []
|
placement_frames = []
|
||||||
|
|
||||||
for config in configs:
|
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)
|
climb_frames.append(climbs)
|
||||||
placement_frames.append(placements)
|
placement_frames.append(placements)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from torch.utils.data import Dataset
|
|||||||
|
|
||||||
class RouteGradeDataset(Dataset):
|
class RouteGradeDataset(Dataset):
|
||||||
def __init__(self, df, max_len: int, pad_id: int):
|
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.ids = df["model_ids"].tolist()
|
||||||
self.targets = df["display_difficulty"].astype(float).values
|
self.targets = df["display_difficulty"].astype(float).values
|
||||||
self.uuids = df["uuid"].tolist()
|
self.uuids = df["uuid"].tolist()
|
||||||
@@ -28,6 +29,7 @@ class RouteGradeDataset(Dataset):
|
|||||||
"input_ids": torch.tensor(ids, dtype=torch.long),
|
"input_ids": torch.tensor(ids, dtype=torch.long),
|
||||||
"attention_mask": torch.tensor(mask, dtype=torch.bool),
|
"attention_mask": torch.tensor(mask, dtype=torch.bool),
|
||||||
"target": torch.tensor(self.targets[idx], dtype=torch.float32),
|
"target": torch.tensor(self.targets[idx], dtype=torch.float32),
|
||||||
|
"row_id": int(self.row_ids[idx]),
|
||||||
"uuid": self.uuids[idx],
|
"uuid": self.uuids[idx],
|
||||||
"board_key": self.boards[idx],
|
"board_key": self.boards[idx],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ast
|
|
||||||
import re
|
import re
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
@@ -8,38 +7,11 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
from scipy.spatial.distance import pdist
|
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]:
|
def parse_token_list(value) -> list[str]:
|
||||||
if isinstance(value, list):
|
return parse_tokens(value)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def validity_from_records(records: list[dict[str, object]], requested_board_prefix: str | None = None) -> dict[str, object]:
|
def validity_from_records(records: list[dict[str, object]], requested_board_prefix: str | None = None) -> dict[str, object]:
|
||||||
@@ -102,29 +74,29 @@ def nearest_real_route_same_board(
|
|||||||
real_df: pd.DataFrame,
|
real_df: pd.DataFrame,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
board_frame = real_df[real_df["board_key"] == generated_board_key]
|
board_frame = real_df[real_df["board_key"] == generated_board_key]
|
||||||
best = {
|
if board_frame.empty:
|
||||||
"nearest_real_jaccard": -1.0,
|
return {
|
||||||
|
"nearest_real_jaccard": np.nan,
|
||||||
"nearest_real_uuid": None,
|
"nearest_real_uuid": None,
|
||||||
"nearest_real_name": None,
|
"nearest_real_name": None,
|
||||||
"nearest_real_grouped_v": None,
|
"nearest_real_grouped_v": None,
|
||||||
"nearest_real_angle": None,
|
"nearest_real_angle": None,
|
||||||
|
"novelty_distance": np.nan,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, row in board_frame.iterrows():
|
similarities = board_frame["hold_set"].map(lambda hold_set: jaccard(generated_set, hold_set))
|
||||||
similarity = jaccard(generated_set, row["hold_set"])
|
best_idx = similarities.idxmax()
|
||||||
if similarity > best["nearest_real_jaccard"]:
|
row = board_frame.loc[best_idx]
|
||||||
best.update(
|
|
||||||
{
|
nearest_real_jaccard = float(similarities.loc[best_idx])
|
||||||
"nearest_real_jaccard": similarity,
|
return {
|
||||||
|
"nearest_real_jaccard": nearest_real_jaccard,
|
||||||
"nearest_real_uuid": row["uuid"],
|
"nearest_real_uuid": row["uuid"],
|
||||||
"nearest_real_name": row["climb_name"],
|
"nearest_real_name": row["climb_name"],
|
||||||
"nearest_real_grouped_v": row["grouped_v"],
|
"nearest_real_grouped_v": row["grouped_v"],
|
||||||
"nearest_real_angle": row["angle"],
|
"nearest_real_angle": row["angle"],
|
||||||
|
"novelty_distance": 1.0 - nearest_real_jaccard,
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
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]]:
|
def build_placement_coords(df_token_meta: pd.DataFrame) -> dict[tuple[str, int], dict[str, float]]:
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
import torch.nn.functional as F
|
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:
|
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]]:
|
def hold_records(tokens: Iterable[str]) -> list[dict[str, object]]:
|
||||||
rows = []
|
return tokens_to_hold_records(tokens)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def validity_summary(tokens: Iterable[str], requested_board_prefix: str | None = None) -> dict[str, object]:
|
def validity_summary(tokens: Iterable[str], requested_board_prefix: str | None = None) -> dict[str, object]:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
|
from .checkpoints import load_checkpoint
|
||||||
from .config import BoardConfig, load_board_config
|
from .config import BoardConfig, load_board_config
|
||||||
from .generation import generate_one
|
from .generation import generate_one
|
||||||
from .grades import to_grouped_v
|
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"))
|
resolved_device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
||||||
|
|
||||||
try:
|
checkpoint = load_checkpoint(checkpoint_path, map_location=resolved_device, trusted=True)
|
||||||
checkpoint = torch.load(checkpoint_path, map_location=resolved_device, weights_only=False)
|
|
||||||
except TypeError:
|
|
||||||
checkpoint = torch.load(checkpoint_path, map_location=resolved_device)
|
|
||||||
|
|
||||||
cfg = checkpoint["config"]
|
cfg = checkpoint["config"]
|
||||||
stoi = {str(k): int(v) for k, v in checkpoint["stoi"].items()}
|
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"))
|
resolved_device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
||||||
|
|
||||||
try:
|
checkpoint = load_checkpoint(checkpoint_path, map_location=resolved_device, trusted=True)
|
||||||
checkpoint = torch.load(checkpoint_path, map_location=resolved_device, weights_only=False)
|
|
||||||
except TypeError:
|
|
||||||
checkpoint = torch.load(checkpoint_path, map_location=resolved_device)
|
|
||||||
|
|
||||||
cfg = checkpoint["config"]
|
cfg = checkpoint["config"]
|
||||||
stoi = {str(k): int(v) for k, v in checkpoint["stoi"].items()}
|
stoi = {str(k): int(v) for k, v in checkpoint["stoi"].items()}
|
||||||
@@ -332,4 +327,3 @@ def predict_frames_grade(
|
|||||||
"requested_angle": int(angle),
|
"requested_angle": int(angle),
|
||||||
"frames": frames,
|
"frames": frames,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import ast
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
import numpy as np
|
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]
|
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]:
|
def make_placement_lookup(df_placements: pd.DataFrame) -> dict[tuple[str, int], dict]:
|
||||||
rows = {}
|
rows = {}
|
||||||
for _, row in df_placements.iterrows():
|
for _, row in df_placements.iterrows():
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ def assign_group_splits(
|
|||||||
``train``, ``val``, or ``test``.
|
``train``, ``val``, or ``test``.
|
||||||
"""
|
"""
|
||||||
group_df = df[group_cols + ([stratify_col] if stratify_col else [])].copy()
|
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)
|
group_df = group_df.drop_duplicates(group_cols).reset_index(drop=True)
|
||||||
|
|
||||||
train_groups, temp_groups = safe_train_test_split(
|
train_groups, temp_groups = safe_train_test_split(
|
||||||
|
|||||||
@@ -6,15 +6,13 @@ coordinate extent, then scatter route holds in board coordinates.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ast
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
import pandas as pd
|
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
|
# These are the same coordinate windows used in the earlier visualization
|
||||||
# notebooks. They come from the product size geometry rather than from the
|
# 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:
|
def tokens_to_route_records(tokens: Iterable[str]) -> pd.DataFrame:
|
||||||
"""Extract generated hold records from model tokens."""
|
"""Extract generated hold records from model tokens."""
|
||||||
rows = []
|
return pd.DataFrame(tokens_to_hold_records(tokens))
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def load_token_metadata(tokenized_dir: str | Path) -> pd.DataFrame:
|
def load_token_metadata(tokenized_dir: str | Path) -> pd.DataFrame:
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||||
|
|
||||||
|
from climbingboardgpt.config import BoardConfig
|
||||||
|
from climbingboardgpt.evaluation import nearest_real_route_same_board
|
||||||
|
from climbingboardgpt.generation import validity_summary
|
||||||
|
from climbingboardgpt.grades import to_grouped_v
|
||||||
|
from climbingboardgpt.tokenization import (
|
||||||
|
parse_frames,
|
||||||
|
parse_tokens,
|
||||||
|
tokenize_route,
|
||||||
|
tokens_to_hold_records,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CoreBehaviorTest(unittest.TestCase):
|
||||||
|
def test_to_grouped_v_clamps_and_maps_display_difficulty(self):
|
||||||
|
self.assertEqual(to_grouped_v(10), 0)
|
||||||
|
self.assertEqual(to_grouped_v(22), 6)
|
||||||
|
self.assertEqual(to_grouped_v(99), 16)
|
||||||
|
|
||||||
|
def test_parse_frames_extracts_placement_role_pairs(self):
|
||||||
|
self.assertEqual(parse_frames("p344r5p369r6p603r7"), [(344, 5), (369, 6), (603, 7)])
|
||||||
|
self.assertEqual(parse_frames(None), [])
|
||||||
|
|
||||||
|
def test_shared_token_parser_handles_repr_and_sequence_strings(self):
|
||||||
|
self.assertEqual(parse_tokens("['<BOS>', '<EOS>']"), ["<BOS>", "<EOS>"])
|
||||||
|
self.assertEqual(parse_tokens("<BOS> <EOS>"), ["<BOS>", "<EOS>"])
|
||||||
|
|
||||||
|
def test_tokens_to_hold_records_extracts_shared_shape(self):
|
||||||
|
records = tokens_to_hold_records(["<BOS>", "<TB2_p344_start>", "<TB2_p603_finish>"])
|
||||||
|
self.assertEqual(
|
||||||
|
records,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"token": "<TB2_p344_start>",
|
||||||
|
"board_token_prefix": "TB2",
|
||||||
|
"board_prefix": "TB2",
|
||||||
|
"placement_id": 344,
|
||||||
|
"role": "start",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"token": "<TB2_p603_finish>",
|
||||||
|
"board_token_prefix": "TB2",
|
||||||
|
"board_prefix": "TB2",
|
||||||
|
"placement_id": 603,
|
||||||
|
"role": "finish",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validity_summary_flags_basic_valid_route(self):
|
||||||
|
summary = validity_summary(
|
||||||
|
["<TB2_p344_start>", "<TB2_p369_middle>", "<TB2_p603_finish>"],
|
||||||
|
requested_board_prefix="TB2",
|
||||||
|
)
|
||||||
|
self.assertTrue(summary["basic_valid"])
|
||||||
|
self.assertEqual(summary["n_hold_tokens"], 3)
|
||||||
|
|
||||||
|
def test_tokenize_route_uses_canonical_hold_order(self):
|
||||||
|
config = BoardConfig(
|
||||||
|
board_key="tb2",
|
||||||
|
display_name="TB2",
|
||||||
|
token_prefix="TB2",
|
||||||
|
db_path=Path("unused.db"),
|
||||||
|
layout_id=1,
|
||||||
|
max_angle=None,
|
||||||
|
min_fa_date=None,
|
||||||
|
placement_y_max=None,
|
||||||
|
include_mirror_placement_id=False,
|
||||||
|
role_definitions={"start": 5, "middle": 6, "finish": 7, "foot": 8},
|
||||||
|
)
|
||||||
|
placement_lookup = {
|
||||||
|
("tb2", 603): {"x": 0, "y": 120},
|
||||||
|
("tb2", 344): {"x": 0, "y": 10},
|
||||||
|
("tb2", 369): {"x": 0, "y": 80},
|
||||||
|
}
|
||||||
|
row = {
|
||||||
|
"frames": "p603r7p369r6p344r5",
|
||||||
|
"angle": 40,
|
||||||
|
"display_difficulty": 22,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tokenize_route(row, config, placement_lookup),
|
||||||
|
[
|
||||||
|
"<BOS>",
|
||||||
|
"<BOARD_TB2>",
|
||||||
|
"<ANGLE_40>",
|
||||||
|
"<GRADE_V6>",
|
||||||
|
"<TB2_p344_start>",
|
||||||
|
"<TB2_p369_middle>",
|
||||||
|
"<TB2_p603_finish>",
|
||||||
|
"<EOS>",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nearest_real_route_same_board_finds_best_jaccard_match(self):
|
||||||
|
real_df = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"board_key": "tb2",
|
||||||
|
"hold_set": frozenset({1, 2, 3}),
|
||||||
|
"uuid": "a",
|
||||||
|
"climb_name": "close",
|
||||||
|
"grouped_v": 5,
|
||||||
|
"angle": 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"board_key": "tb2",
|
||||||
|
"hold_set": frozenset({7, 8}),
|
||||||
|
"uuid": "b",
|
||||||
|
"climb_name": "far",
|
||||||
|
"grouped_v": 8,
|
||||||
|
"angle": 45,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = nearest_real_route_same_board(frozenset({1, 2, 4}), "tb2", real_df)
|
||||||
|
|
||||||
|
self.assertEqual(result["nearest_real_uuid"], "a")
|
||||||
|
self.assertAlmostEqual(result["nearest_real_jaccard"], 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+22
-49
@@ -14,9 +14,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -39,6 +39,8 @@ from climbingboardgpt.inference import (
|
|||||||
predict_frames_grade,
|
predict_frames_grade,
|
||||||
predict_route_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
|
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 = {
|
BOARD_IMAGE_PATHS = {
|
||||||
"tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png",
|
"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)
|
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):
|
def _board_config(board: str):
|
||||||
try:
|
try:
|
||||||
return app.state.board_configs[board]
|
return app.state.board_configs[board]
|
||||||
@@ -240,13 +222,10 @@ def _role_limit_validity(tokens: list[str], requested_board_prefix: str) -> dict
|
|||||||
"unknown": 0,
|
"unknown": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
for token in tokens:
|
for record in tokens_to_hold_records(tokens):
|
||||||
match = HOLD_TOKEN_RE.match(str(token))
|
if str(record["board_token_prefix"]) != str(requested_board_prefix):
|
||||||
if match is None:
|
|
||||||
continue
|
|
||||||
board_prefix, _placement_id, role = match.groups()
|
|
||||||
if str(board_prefix) != str(requested_board_prefix):
|
|
||||||
continue
|
continue
|
||||||
|
role = str(record["role"])
|
||||||
counts[role] = counts.get(role, 0) + 1
|
counts[role] = counts.get(role, 0) + 1
|
||||||
|
|
||||||
n_start = int(counts.get("start", 0))
|
n_start = int(counts.get("start", 0))
|
||||||
@@ -305,10 +284,6 @@ def _invalid_prediction_reasons(validity: dict[str, Any]) -> list[str]:
|
|||||||
return reasons
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
HOLD_TOKEN_RE = re.compile(r"^<([A-Z0-9_]+)_p(\d+)_(start|middle|finish|foot|unknown)>$")
|
|
||||||
|
|
||||||
|
|
||||||
def _angle_key(angle: Any) -> int:
|
def _angle_key(angle: Any) -> int:
|
||||||
try:
|
try:
|
||||||
return int(round(float(angle)))
|
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]]:
|
def _holds_from_sequence(sequence: str) -> list[dict[str, Any]]:
|
||||||
holds: list[dict[str, Any]] = []
|
holds: list[dict[str, Any]] = []
|
||||||
for token in str(sequence).split():
|
for record in tokens_to_hold_records(str(sequence).split()):
|
||||||
match = HOLD_TOKEN_RE.match(token)
|
|
||||||
if match is None:
|
|
||||||
continue
|
|
||||||
holds.append(
|
holds.append(
|
||||||
{
|
{
|
||||||
"board_prefix": match.group(1),
|
"board_prefix": str(record["board_token_prefix"]),
|
||||||
"placement_id": int(match.group(2)),
|
"placement_id": int(record["placement_id"]),
|
||||||
"role": match.group(3),
|
"role": str(record["role"]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return holds
|
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)
|
holds = _tokens_to_holds(board, tokens)
|
||||||
angle = result.get("requested_angle", result.get("angle", 0))
|
angle = result.get("requested_angle", result.get("angle", 0))
|
||||||
|
|
||||||
return _json_safe(
|
return json_safe(
|
||||||
{
|
{
|
||||||
**result,
|
**result,
|
||||||
"tokens": tokens,
|
"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")
|
@asynccontextmanager
|
||||||
app.mount("/static", StaticFiles(directory=REPO_ROOT / "webapp" / "static"), name="static")
|
async def lifespan(app: FastAPI):
|
||||||
app.mount("/board-images", StaticFiles(directory=REPO_ROOT / "images"), name="board-images")
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
def startup() -> None:
|
|
||||||
if TORCH_THREADS_INT is not None:
|
if TORCH_THREADS_INT is not None:
|
||||||
torch.set_num_threads(TORCH_THREADS_INT)
|
torch.set_num_threads(TORCH_THREADS_INT)
|
||||||
|
|
||||||
@@ -553,6 +520,12 @@ def startup() -> None:
|
|||||||
device=DEVICE,
|
device=DEVICE,
|
||||||
torch_threads=TORCH_THREADS_INT,
|
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("/")
|
@app.get("/")
|
||||||
@@ -604,7 +577,7 @@ def boards():
|
|||||||
@app.get("/api/board-holds/{board}")
|
@app.get("/api/board-holds/{board}")
|
||||||
def board_holds(board: str):
|
def board_holds(board: str):
|
||||||
config = _board_config(board)
|
config = _board_config(board)
|
||||||
return _json_safe({
|
return json_safe({
|
||||||
"board_key": board,
|
"board_key": board,
|
||||||
"display_name": config.display_name,
|
"display_name": config.display_name,
|
||||||
"token_prefix": config.token_prefix,
|
"token_prefix": config.token_prefix,
|
||||||
@@ -729,7 +702,7 @@ def predict(req: PredictRequest):
|
|||||||
reasons = _invalid_prediction_reasons(validity)
|
reasons = _invalid_prediction_reasons(validity)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=_json_safe(
|
detail=json_safe(
|
||||||
{
|
{
|
||||||
"message": "Cannot predict grade for an invalid climb.",
|
"message": "Cannot predict grade for an invalid climb.",
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
|
|||||||
+138
-83
@@ -1,17 +1,31 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #263238;
|
--base00: #263238;
|
||||||
--bg-alt: #1f2a30;
|
--base01: #2e3c43;
|
||||||
--panel: #2f3d43;
|
--base02: #314549;
|
||||||
--panel-soft: #37474f;
|
--base03: #546e7a;
|
||||||
--ink: #eceff1;
|
--base04: #b2ccd6;
|
||||||
--muted: #b0bec5;
|
--base05: #eeffff;
|
||||||
--faint: #90a4ae;
|
--base07: #ffffff;
|
||||||
--border: #455a64;
|
--base08: #f07178;
|
||||||
--accent: #80cbc4;
|
--base0a: #ffcb6b;
|
||||||
--accent-2: #ffcc80;
|
--base0b: #c3e88d;
|
||||||
--danger: #ef9a9a;
|
--base0c: #89ddff;
|
||||||
--shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
|
--base0d: #82aaff;
|
||||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
--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; }
|
* { box-sizing: border-box; }
|
||||||
@@ -19,57 +33,60 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
background:
|
font-size: 16px;
|
||||||
radial-gradient(circle at top left, rgba(128, 203, 196, 0.07), transparent 32rem),
|
line-height: 1.5rem;
|
||||||
var(--bg);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header {
|
.site-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
padding: 1.35rem 2rem;
|
max-width: 78rem;
|
||||||
border-bottom: 1px solid var(--border);
|
margin: 1rem auto 0;
|
||||||
background: var(--bg-alt);
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0 0 0.35rem;
|
margin: 0;
|
||||||
color: var(--accent);
|
color: var(--logo);
|
||||||
font-size: 0.82rem;
|
font-size: 1rem;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header h1 {
|
.site-header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(1.35rem, 2vw, 2.05rem);
|
font-size: 1rem;
|
||||||
letter-spacing: -0.04em;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header p {
|
.site-header h1::before {
|
||||||
margin: 0.4rem 0 0;
|
content: "# ";
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--off-fg);
|
||||||
|
}
|
||||||
|
|
||||||
.health {
|
.health {
|
||||||
|
flex-shrink: 0;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--accent-2);
|
color: var(--highlight);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 999px;
|
padding: 0.25rem 0.5rem;
|
||||||
padding: 0.45rem 0.7rem;
|
background: var(--inner-bg);
|
||||||
background: rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 370px minmax(0, 1fr);
|
grid-template-columns: 22rem minmax(0, 1fr);
|
||||||
gap: 1.25rem;
|
gap: 2rem;
|
||||||
padding: 1.25rem;
|
padding: 2rem 1rem 1rem;
|
||||||
max-width: 1520px;
|
max-width: 78rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,25 +97,28 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card, .result-card {
|
.card, .result-card {
|
||||||
background: var(--panel);
|
background: var(--off-bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card h2, .result-card h2 {
|
.card h2, .result-card h2 {
|
||||||
margin: 0 0 0.8rem;
|
margin: 0 0 1rem;
|
||||||
font-size: 1.0rem;
|
font-size: 1rem;
|
||||||
color: var(--accent);
|
font-weight: 600;
|
||||||
letter-spacing: -0.02em;
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2::before, .result-card h2::before {
|
||||||
|
content: "## ";
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0.7rem 0;
|
margin: 0.7rem 0;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--muted);
|
color: var(--off-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
input, select, textarea {
|
input, select, textarea {
|
||||||
@@ -106,16 +126,15 @@ input, select, textarea {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 0.28rem;
|
margin-top: 0.28rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.6rem 0.7rem;
|
padding: 0.6rem 0.7rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: var(--ink);
|
color: var(--fg);
|
||||||
background: #1c272c;
|
background: var(--inner-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus, select:focus, textarea:focus {
|
input:focus, select:focus, textarea:focus {
|
||||||
outline: 2px solid rgba(128, 203, 196, 0.32);
|
outline: 2px solid rgba(137, 221, 255, 0.28);
|
||||||
border-color: var(--accent);
|
border-color: var(--hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
@@ -126,26 +145,33 @@ textarea {
|
|||||||
|
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #4db6ac;
|
border: 1px solid var(--link);
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.68rem 0.9rem;
|
padding: 0.68rem 0.9rem;
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #102022;
|
color: var(--bg);
|
||||||
background: var(--accent);
|
background: var(--link);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--mono);
|
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:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
|
||||||
button.secondary {
|
button.secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.secondary:hover {
|
||||||
|
color: var(--bg);
|
||||||
|
border-color: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
.button-row {
|
.button-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -153,7 +179,7 @@ button.secondary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.note p, .small {
|
.note p, .small {
|
||||||
color: var(--muted);
|
color: var(--off-fg);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
@@ -162,7 +188,7 @@ button.secondary {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0.8rem 0 0;
|
margin: 0.8rem 0 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: var(--muted);
|
color: var(--off-fg);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -185,19 +211,27 @@ button.secondary {
|
|||||||
|
|
||||||
.result-header p {
|
.result-header p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--off-fg);
|
||||||
font-size: 0.84rem;
|
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 {
|
.board-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 960px;
|
max-width: 960px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: #111827;
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-stage img {
|
.board-stage img {
|
||||||
@@ -225,24 +259,23 @@ button.secondary {
|
|||||||
.hover-marker {
|
.hover-marker {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: var(--accent-2);
|
stroke: var(--highlight);
|
||||||
stroke-width: 0.55;
|
stroke-width: 0.55;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-block {
|
.json-block {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
color: var(--muted);
|
color: var(--off-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-block pre {
|
.json-block pre {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #111a1f;
|
background: var(--inner-bg);
|
||||||
color: #cfd8dc;
|
color: var(--off-fg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,10 +299,9 @@ button.secondary {
|
|||||||
.warning-box {
|
.warning-box {
|
||||||
margin: 0.7rem auto 0.85rem;
|
margin: 0.7rem auto 0.85rem;
|
||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
border: 1px solid rgba(255, 204, 128, 0.55);
|
border: 1px solid rgba(255, 203, 107, 0.55);
|
||||||
background: rgba(255, 204, 128, 0.12);
|
background: rgba(255, 203, 107, 0.12);
|
||||||
color: var(--accent-2);
|
color: var(--highlight);
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.65rem 0.8rem;
|
padding: 0.65rem 0.8rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -287,7 +319,7 @@ button.secondary {
|
|||||||
.field-help {
|
.field-help {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
color: var(--faint);
|
color: var(--muted);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
@@ -297,7 +329,7 @@ button.secondary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.explain dt {
|
.explain dt {
|
||||||
color: var(--accent-2);
|
color: var(--highlight);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -308,16 +340,15 @@ button.secondary {
|
|||||||
|
|
||||||
.explain dd {
|
.explain dd {
|
||||||
margin: 0.22rem 0 0;
|
margin: 0.22rem 0 0;
|
||||||
color: var(--muted);
|
color: var(--off-fg);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.explain code {
|
.explain code {
|
||||||
color: var(--accent-2);
|
color: var(--off-fg);
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: var(--inner-bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.05rem 0.25rem;
|
padding: 0.05rem 0.25rem;
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
}
|
}
|
||||||
@@ -325,23 +356,47 @@ button.secondary {
|
|||||||
.link-list {
|
.link-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 1.1rem;
|
padding-left: 1.1rem;
|
||||||
color: var(--muted);
|
color: var(--off-fg);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-list li::marker {
|
||||||
|
content: "* ";
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.link-list a {
|
.link-list a {
|
||||||
color: var(--accent);
|
color: var(--link);
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-list a:hover {
|
.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 {
|
#data-acknowledgement-card p,
|
||||||
color: var(--muted);
|
#model-disclaimer-card p {
|
||||||
|
color: var(--off-fg);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>ClimbingBoardGPT</title>
|
<title>ClimbingBoardGPT</title>
|
||||||
<link rel="stylesheet" href="/static/app.css?v=16" />
|
<link rel="stylesheet" href="/static/app.css?v=17" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
@@ -94,6 +94,13 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card note" id="model-disclaimer-card">
|
||||||
|
<h2>Research demo caveat</h2>
|
||||||
|
<p>
|
||||||
|
This is an experimental model demo. Generated climbs and predicted grades may be wrong, especially at rare grades or sparse board/angle combinations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card explain">
|
<div class="card explain">
|
||||||
<h2>How this works</h2>
|
<h2>How this works</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -111,6 +118,7 @@
|
|||||||
<ul class="link-list">
|
<ul class="link-list">
|
||||||
<li><a href="https://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a></li>
|
<li><a href="https://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a></li>
|
||||||
<li><a href="https://github.com/psark007/ClimbingBoardGPT" target="_blank" rel="noreferrer">ClimbingBoardGPT repo</a></li>
|
<li><a href="https://github.com/psark007/ClimbingBoardGPT" target="_blank" rel="noreferrer">ClimbingBoardGPT repo</a></li>
|
||||||
|
<li><a href="https://github.com/psark007/ClimbingBoardGPT/blob/main/LICENSE" target="_blank" rel="noreferrer">License</a></li>
|
||||||
<li><a href="https://github.com/psark007/Tension-Board-2-Analysis" target="_blank" rel="noreferrer">Tension Board 2 Analysis repo</a></li>
|
<li><a href="https://github.com/psark007/Tension-Board-2-Analysis" target="_blank" rel="noreferrer">Tension Board 2 Analysis repo</a></li>
|
||||||
<li><a href="https://github.com/psark007/Kilter-Board-Analysis" target="_blank" rel="noreferrer">Kilter Board Analysis repo</a></li>
|
<li><a href="https://github.com/psark007/Kilter-Board-Analysis" target="_blank" rel="noreferrer">Kilter Board Analysis repo</a></li>
|
||||||
<li><a href="https://tensionclimbing.com/products/tension-board-2" target="_blank" rel="noreferrer">Tension Board 2</a></li>
|
<li><a href="https://tensionclimbing.com/products/tension-board-2" target="_blank" rel="noreferrer">Tension Board 2</a></li>
|
||||||
@@ -121,7 +129,7 @@
|
|||||||
<div class="card note" id="data-acknowledgement-card">
|
<div class="card note" id="data-acknowledgement-card">
|
||||||
<h2>Data acknowledgement</h2>
|
<h2>Data acknowledgement</h2>
|
||||||
<p>
|
<p>
|
||||||
Tension Board 2 route data is from Tension Climbing. Kilter Board route data is from Kilter.
|
Board layouts, hold metadata, and route data are derived from Tension Board 2 and Kilter Board datasets. This project is unaffiliated with Tension Climbing or Kilter.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -159,6 +167,10 @@
|
|||||||
<button id="clear-board-btn" class="secondary clear-board">Clear board</button>
|
<button id="clear-board-btn" class="secondary clear-board">Clear board</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="result-note">
|
||||||
|
Known climb means an exact match against the tokenized dataset for the same board, angle, and hold-role set.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div id="board-stage" class="board-stage">
|
<div id="board-stage" class="board-stage">
|
||||||
<img id="board-bg" alt="Board background" />
|
<img id="board-bg" alt="Board background" />
|
||||||
<svg id="overlay" xmlns="http://www.w3.org/2000/svg"></svg>
|
<svg id="overlay" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||||
@@ -172,6 +184,13 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/app.js?v=16"></script>
|
<footer class="site-footer">
|
||||||
|
<span>© Pawel Sarkowicz</span>
|
||||||
|
<a href="https://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a>
|
||||||
|
<a href="https://github.com/psark007/ClimbingBoardGPT" target="_blank" rel="noreferrer">ClimbingBoardGPT</a>
|
||||||
|
<a href="https://github.com/psark007/ClimbingBoardGPT/blob/main/LICENSE" target="_blank" rel="noreferrer">License</a>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="/static/app.js?v=17"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user