diff --git a/src/kernelbot/api/main.py b/src/kernelbot/api/main.py index d9f98973b..9c5f99099 100644 --- a/src/kernelbot/api/main.py +++ b/src/kernelbot/api/main.py @@ -887,6 +887,55 @@ async def get_gpus( raise HTTPException(status_code=500, detail=f"Error fetching GPU data: {e}") from e +@app.get("/leaderboard/{leaderboard_id}/rankings") +async def get_leaderboard_rankings( + leaderboard_id: int, + user_info: Annotated[Optional[Any], Depends(optional_user_header)] = None, + db_context=Depends(get_db), +) -> dict: + """Return canonical leaderboard metadata and rankings for all runners.""" + await simple_rate_limit() + try: + with db_context as db: + leaderboard = db.get_leaderboard_by_id(leaderboard_id) + enforce_leaderboard_access(db, leaderboard["name"], user_info) + + rankings = {} + for gpu_type in leaderboard["gpu_types"]: + ranked_entries = db.get_leaderboard_submissions(leaderboard["name"], gpu_type) + rankings[gpu_type] = [ + { + "user_name": entry["user_name"], + "score": entry["submission_score"], + "file_name": entry["submission_name"], + "submission_id": entry["submission_id"], + "submission_count": entry.get("submission_count", 0), + "submission_time": entry["submission_time"], + } + for entry in ranked_entries + ] + + task = leaderboard["task"] + return { + "rankings": rankings, + "leaderboard": { + "name": leaderboard["name"], + "deadline": leaderboard["deadline"], + "lang": task.lang.value, + "description": leaderboard["description"], + "reference": task.files.get("reference.py", ""), + "benchmarks": task.benchmarks, + "gpu_types": leaderboard["gpu_types"], + }, + } + except HTTPException: + raise + except KernelBotError: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error fetching leaderboard rankings: {e}") from e + + @app.get("/submissions/{leaderboard_name}/{gpu_name}") async def get_submissions( leaderboard_name: str, diff --git a/src/libkernelbot/db_types.py b/src/libkernelbot/db_types.py index ee1e84a35..c28ea7bff 100644 --- a/src/libkernelbot/db_types.py +++ b/src/libkernelbot/db_types.py @@ -30,6 +30,7 @@ class LeaderboardRankedEntry(TypedDict): submission_name: str submission_time: datetime.datetime submission_score: float + submission_count: NotRequired[int] leaderboard_name: str user_id: int user_name: str diff --git a/src/libkernelbot/leaderboard_db.py b/src/libkernelbot/leaderboard_db.py index 6f1413e7c..926903323 100644 --- a/src/libkernelbot/leaderboard_db.py +++ b/src/libkernelbot/leaderboard_db.py @@ -654,6 +654,35 @@ def get_leaderboard(self, leaderboard_name: str) -> "LeaderboardItem": else: raise LeaderboardDoesNotExist(leaderboard_name) + def get_leaderboard_by_id(self, leaderboard_id: int) -> "LeaderboardItem": + self.cursor.execute( + """ + SELECT id, name, deadline, task, creator_id, forum_id, secret_seed, description, visibility + FROM leaderboard.leaderboard + WHERE id = %s + """, + (leaderboard_id,), + ) + + res = self.cursor.fetchone() + + if res: + task = LeaderboardTask.from_dict(res[3]) + return LeaderboardItem( + id=res[0], + name=res[1], + deadline=res[2], + task=task, + creator_id=res[4], + forum_id=res[5], + secret_seed=res[6], + gpu_types=self.get_leaderboard_gpu_types(res[1]), + description=res[7], + visibility=res[8], + ) + else: + raise LeaderboardDoesNotExist(str(leaderboard_id)) + def check_leaderboard_access(self, leaderboard_name: str, user_id: str) -> bool: """Returns True if leaderboard is public or user has claimed an invite covering this leaderboard.""" self.cursor.execute( @@ -865,6 +894,14 @@ def get_leaderboard_submissions( if user_id: # Query all if user_id (means called from show-personal) query = """ + WITH submission_counts AS ( + SELECT s.user_id, r.runner, COUNT(DISTINCT s.id) AS submission_count + FROM leaderboard.submission s + JOIN leaderboard.runs r ON r.submission_id = s.id + JOIN leaderboard.leaderboard l ON s.leaderboard_id = l.id + WHERE l.name = %s + GROUP BY s.user_id, r.runner + ) SELECT s.file_name, s.id, @@ -873,11 +910,13 @@ def get_leaderboard_submissions( r.score, r.runner, ui.user_name, - RANK() OVER (ORDER BY r.score ASC) as rank + RANK() OVER (ORDER BY r.score ASC) as rank, + COALESCE(sc.submission_count, 0) AS submission_count FROM leaderboard.runs r JOIN leaderboard.submission s ON r.submission_id = s.id JOIN leaderboard.leaderboard l ON s.leaderboard_id = l.id JOIN leaderboard.user_info ui ON s.user_id = ui.id + LEFT JOIN submission_counts sc ON s.user_id = sc.user_id AND r.runner = sc.runner WHERE l.name = %s AND r.runner = %s AND NOT r.secret @@ -895,11 +934,19 @@ def get_leaderboard_submissions( ORDER BY r.score ASC LIMIT %s OFFSET %s """ - args = (leaderboard_name, gpu_name, user_id, limit, offset) + args = (leaderboard_name, leaderboard_name, gpu_name, user_id, limit, offset) else: # Query best submission per user if no user_id (means called from show) query = """ - WITH best_submissions AS ( + WITH submission_counts AS ( + SELECT s.user_id, r.runner, COUNT(DISTINCT s.id) AS submission_count + FROM leaderboard.submission s + JOIN leaderboard.runs r ON r.submission_id = s.id + JOIN leaderboard.leaderboard l ON s.leaderboard_id = l.id + WHERE l.name = %s + GROUP BY s.user_id, r.runner + ), + best_submissions AS ( SELECT DISTINCT ON (s.user_id) s.id as submission_id, s.file_name, @@ -931,13 +978,15 @@ def get_leaderboard_submissions( bs.score, bs.runner, ui.user_name, - RANK() OVER (ORDER BY bs.score ASC) as rank + RANK() OVER (ORDER BY bs.score ASC) as rank, + COALESCE(sc.submission_count, 0) AS submission_count FROM best_submissions bs JOIN leaderboard.user_info ui ON bs.user_id = ui.id + LEFT JOIN submission_counts sc ON bs.user_id = sc.user_id AND bs.runner = sc.runner ORDER BY bs.score ASC LIMIT %s OFFSET %s """ - args = (leaderboard_name, gpu_name, limit, offset) + args = (leaderboard_name, leaderboard_name, gpu_name, limit, offset) self.cursor.execute(query, args) @@ -950,6 +999,7 @@ def get_leaderboard_submissions( submission_score=submission[4], user_name=submission[6], rank=submission[7], + submission_count=submission[8], leaderboard_name=leaderboard_name, gpu_type=gpu_name, ) diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index 2ad3ab3dd..dc8263cbb 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -700,6 +700,83 @@ def test_public_leaderboard_submissions_no_auth(self, test_client, mock_backend) response = test_client.get("/submissions/public-lb/A100") assert response.status_code == 200 + + def test_public_leaderboard_rankings_no_auth(self, test_client, mock_backend): + """GET /leaderboard/{id}/rankings returns metadata and all GPU rankings.""" + from libkernelbot.consts import Language + from libkernelbot.task import LeaderboardTask, PythonTaskData + + self._setup_db_mock(mock_backend) + task = LeaderboardTask( + lang=Language.Python, + files={"reference.py": "def ref(): pass"}, + config=PythonTaskData(main="eval.py"), + benchmarks=[{"n": 32}], + ) + mock_backend.db.get_leaderboard_by_id = MagicMock( + return_value={ + "id": 123, + "name": "qr_v2", + "deadline": "2026-06-30T00:00:00Z", + "description": "QR", + "task": task, + "gpu_types": ["B200"], + "visibility": "public", + } + ) + mock_backend.db.get_leaderboard = MagicMock(return_value={"visibility": "public"}) + mock_backend.db.get_leaderboard_submissions = MagicMock( + return_value=[ + { + "user_name": "mark", + "submission_score": 1.5, + "submission_name": "submission.py", + "submission_id": 42, + "submission_count": 3, + "submission_time": "2026-06-17T00:00:00Z", + } + ] + ) + + response = test_client.get("/leaderboard/123/rankings") + assert response.status_code == 200 + assert response.json() == { + "rankings": { + "B200": [ + { + "user_name": "mark", + "score": 1.5, + "file_name": "submission.py", + "submission_id": 42, + "submission_count": 3, + "submission_time": "2026-06-17T00:00:00Z", + } + ] + }, + "leaderboard": { + "name": "qr_v2", + "deadline": "2026-06-30T00:00:00Z", + "lang": "py", + "description": "QR", + "reference": "def ref(): pass", + "benchmarks": [{"n": 32}], + "gpu_types": ["B200"], + }, + } + + def test_missing_leaderboard_rankings_returns_404(self, test_client, mock_backend): + """GET /leaderboard/{id}/rankings returns 404 for a missing leaderboard.""" + from libkernelbot.leaderboard_db import LeaderboardDoesNotExist + + self._setup_db_mock(mock_backend) + mock_backend.db.get_leaderboard_by_id = MagicMock( + side_effect=LeaderboardDoesNotExist("999") + ) + + response = test_client.get("/leaderboard/999/rankings") + assert response.status_code == 404 + + class TestAdminExportHF: """Test admin HF export endpoint.""" diff --git a/tests/test_leaderboard_db.py b/tests/test_leaderboard_db.py index 1da4a5223..a1ea6e89e 100644 --- a/tests/test_leaderboard_db.py +++ b/tests/test_leaderboard_db.py @@ -350,6 +350,7 @@ def test_leaderboard_submission_ranked(database, submit_leaderboard): "rank": 1, "submission_id": 2, "submission_name": "submission.py", + "submission_count": 3, "submission_score": Decimal("4.5"), "submission_time": submit_time, "user_id": "5", @@ -361,6 +362,7 @@ def test_leaderboard_submission_ranked(database, submit_leaderboard): "rank": 2, "submission_id": 4, "submission_name": "submission.py", + "submission_count": 1, "submission_score": Decimal("8.0"), "submission_time": submit_time, "user_id": "6",