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) {
  • ${r.rank} ${escapeText(r.name)}${r.student_id ? `${escapeText(r.student_id)}` : ""} - ${r.score} + ${fmtScore(r.score)}
  • `).join("")} diff --git a/static/quiz.js b/static/quiz.js index 8796180..e64a380 100644 --- a/static/quiz.js +++ b/static/quiz.js @@ -23,6 +23,14 @@ const store = { let countdownTimer = null; +function fmtScore(value) { + // Scores are floats on a 0.05 grid in [0, 1]. Display as a fixed + // two-decimal string so users see e.g. "0.85" instead of + // "0.8500000000000001" when float math drifts in the leaderboard sum. + const n = Number(value || 0); + return n.toFixed(2); +} + function escapeText(value) { return String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", @@ -254,7 +262,7 @@ function renderSubmitted(message) { setView(`

    Question ${message.question_idx + 1}

    -

    +${message.score}

    +

    +${fmtScore(message.score)}

    submitted in ${seconds}s

    Waiting for the reveal…

    @@ -296,8 +304,8 @@ function renderReveal(message) { ${message.explanation ? `

    ${escapeText(message.explanation)}

    ` : ""}
    -
    Your score+${message.your_score || 0}
    -
    Total${message.your_total ?? 0}
    +
    Your score+${fmtScore(message.your_score || 0)}
    +
    Total${fmtScore(message.your_total)}
    Rank${message.your_rank ?? "—"}

    Top 5

    @@ -312,7 +320,7 @@ function renderBetween(message) {

    Up next

    Question ${(message.next_idx ?? 0) + 1}

    -
    Total${message.your_total ?? 0}
    +
    Total${fmtScore(message.your_total)}
    Rank${message.your_rank ?? "—"}
    ${renderBoard(message.top5)} @@ -327,7 +335,7 @@ function renderFinished(message) {
    Quiz complete
    -
    Your total${message.your_total ?? 0}
    +
    Your total${fmtScore(message.your_total)}
    Rank${message.your_rank ?? "—"}
    Correct${message.questions_correct ?? 0} / ${message.questions_answered ?? 0}
    @@ -356,7 +364,7 @@ function renderBoard(rows = []) {
  • ${r.rank} ${escapeText(r.name)} - ${r.score} + ${fmtScore(r.score)}
  • `; }).join("")} diff --git a/tests/test_scoring.py b/tests/test_scoring.py index 3590a2b..207466a 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -3,24 +3,35 @@ from app.scoring import SCORE_FNS def test_linear_decay_values(): fn = SCORE_FNS["linear_decay"] - assert fn(True, 0, 60_000) == 1000 - assert fn(True, 30_000, 60_000) == 750 - assert fn(True, 60_000, 60_000) == 500 - assert fn(True, 90_000, 60_000) == 500 - assert fn(False, 0, 60_000) == 0 + assert fn(True, 0, 60_000) == 1.0 + assert fn(True, 30_000, 60_000) == 0.75 + assert fn(True, 60_000, 60_000) == 0.5 + # Past the deadline the score floors at 0.5 (still correct, fully decayed). + assert fn(True, 90_000, 60_000) == 0.5 + assert fn(False, 0, 60_000) == 0.0 + + +def test_linear_decay_snaps_to_grid(): + """Every score is on the 0.05 grid (21 distinct values).""" + fn = SCORE_FNS["linear_decay"] + for elapsed in range(0, 60_001, 137): # arbitrary irrational-ish step + s = fn(True, elapsed, 60_000) + # multiplied by 20, must be an integer + assert abs(s * 20 - round(s * 20)) < 1e-9, (elapsed, s) def test_flat_values(): fn = SCORE_FNS["flat"] - assert fn(True, 0, 60_000) == 1000 - assert fn(True, 60_000, 60_000) == 1000 - assert fn(True, 90_000, 60_000) == 1000 - assert fn(False, 0, 60_000) == 0 + assert fn(True, 0, 60_000) == 1.0 + assert fn(True, 60_000, 60_000) == 1.0 + assert fn(True, 90_000, 60_000) == 1.0 + assert fn(False, 0, 60_000) == 0.0 def test_exponential_decay_values(): fn = SCORE_FNS["exponential_decay"] - assert fn(True, 0, 60_000) == 1000 - assert 560 < fn(True, 60_000, 60_000) < 570 + assert fn(True, 0, 60_000) == 1.0 + # At deadline: 0.5 + 0.5 * e^-2 ≈ 0.5677 → snaps to 0.55 + assert fn(True, 60_000, 60_000) == 0.55 assert fn(True, 90_000, 60_000) == fn(True, 60_000, 60_000) - assert fn(False, 0, 60_000) == 0 + assert fn(False, 0, 60_000) == 0.0