diff --git a/app/db.py b/app/db.py index f575634..fa036ae 100644 --- a/app/db.py +++ b/app/db.py @@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS submissions ( answer TEXT, submitted_at TIMESTAMP, elapsed_ms INTEGER, - score INTEGER NOT NULL DEFAULT 0, + score REAL NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'submitted', PRIMARY KEY (sid, student_id, question_idx) ); diff --git a/app/room.py b/app/room.py index 60f119d..55be922 100644 --- a/app/room.py +++ b/app/room.py @@ -621,7 +621,7 @@ class RoomManager: rows = await cursor.fetchall() board = [] for rank, row in enumerate(rows, start=1): - item = {"rank": rank, "name": row["name"], "score": int(row["score"])} + item = {"rank": rank, "name": row["name"], "score": float(row["score"])} if include_ids: item["student_id"] = row["student_id"] if you_student_id is not None and row["student_id"] == you_student_id: @@ -636,14 +636,17 @@ class RoomManager: return item["rank"] return None - async def total_for(self, sid: str, student_id: str) -> int: + async def total_for(self, sid: str, student_id: str) -> float: async with connect(self.settings.db_path) as db: cursor = await db.execute( "SELECT COALESCE(SUM(score), 0) AS total FROM submissions WHERE sid = ? AND student_id = ?", (sid, student_id), ) row = await cursor.fetchone() - return int(row["total"]) + # Snap to two decimals so the sum stays display-friendly even after + # many small float additions; the per-question scores are already + # on a 0.05 grid, so this is mostly defensive. + return round(float(row["total"]), 2) async def submission_for(self, sid: str, student_id: str, question_idx: int) -> dict[str, Any] | None: async with connect(self.settings.db_path) as db: diff --git a/app/scoring.py b/app/scoring.py index eadac44..f8f79ff 100644 --- a/app/scoring.py +++ b/app/scoring.py @@ -1,12 +1,30 @@ -"""Score functions.""" +"""Score functions. + +Scores are floats in [0.0, 1.0] snapped to a 0.05 grid (21 distinct +levels). The discrete grid keeps display readable and ties common +enough that small clock-skew differences don't decide a leaderboard, +while still rewarding faster correct answers. +""" from __future__ import annotations +import math from collections.abc import Callable -ScoreFn = Callable[[bool, int, int], int] +ScoreFn = Callable[[bool, int, int], float] SCORE_FNS: dict[str, ScoreFn] = {} +GRID = 0.05 + + +def _snap(value: float) -> float: + """Snap to the 0.05 grid and clamp to [0.0, 1.0].""" + snapped = round(value / GRID) * GRID + snapped = max(0.0, min(1.0, snapped)) + # Round to two decimals so the wire / display values are always + # exactly e.g. 0.85, never 0.8500000000000001. + return round(snapped, 2) + def register(name: str) -> Callable[[ScoreFn], ScoreFn]: def decorator(func: ScoreFn) -> ScoreFn: @@ -17,24 +35,29 @@ def register(name: str) -> Callable[[ScoreFn], ScoreFn]: @register("linear_decay") -def linear_decay(correct: bool, elapsed_ms: int, time_limit_ms: int) -> int: +def linear_decay(correct: bool, elapsed_ms: int, time_limit_ms: int) -> float: + """Correct answers earn 1.0 instantly, decaying linearly to 0.5 at the + deadline. Wrong (or missed) answers earn 0.0.""" if not correct: - return 0 + return 0.0 elapsed_ms = max(0, min(elapsed_ms, time_limit_ms)) - return round(1000 * (1 - 0.5 * elapsed_ms / time_limit_ms)) + raw = 1.0 - 0.5 * (elapsed_ms / time_limit_ms) + return _snap(raw) @register("flat") -def flat(correct: bool, elapsed_ms: int, time_limit_ms: int) -> int: - return 1000 if correct else 0 +def flat(correct: bool, elapsed_ms: int, time_limit_ms: int) -> float: + """All correct answers earn 1.0 regardless of speed.""" + return 1.0 if correct else 0.0 @register("exponential_decay") -def exponential_decay(correct: bool, elapsed_ms: int, time_limit_ms: int) -> int: +def exponential_decay(correct: bool, elapsed_ms: int, time_limit_ms: int) -> float: + """Correct answers earn 1.0 instantly, decaying exponentially to ~0.57 + at the deadline (e^{-2}/2 + 0.5).""" if not correct: - return 0 - import math - + return 0.0 elapsed_ms = max(0, min(elapsed_ms, time_limit_ms)) decay = math.exp(-2 * elapsed_ms / time_limit_ms) - return round(1000 * (0.5 + 0.5 * decay)) + raw = 0.5 + 0.5 * decay + return _snap(raw) diff --git a/static/admin.js b/static/admin.js index 57a1bcf..0473be0 100644 --- a/static/admin.js +++ b/static/admin.js @@ -28,6 +28,13 @@ const store = { let countdownTimer = null; +function fmtScore(value) { + // Scores are floats on a 0.05 grid in [0, 1]; sums can run up to N + // (one per question). Always render as fixed two-decimal so the + // leaderboard reads "0.85" / "1.20" / "5.00" cleanly. + return Number(value || 0).toFixed(2); +} + function escapeText(value) { return String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", @@ -377,7 +384,7 @@ function renderLeaderboardList(rows) {
Question ${message.question_idx + 1}
-submitted in ${seconds}s
Waiting for the reveal…
@@ -296,8 +304,8 @@ function renderReveal(message) { ${message.explanation ? `${escapeText(message.explanation)}
` : ""}Up next