feat(scoring): rescale scores to 0.0-1.0 with 0.05 resolution

Per-question score is now a float in [0.0, 1.0] snapped to a 21-level
0.05 grid, replacing the previous 0-1000 integer scale. Easier to read
on a leaderboard, ties become acceptable rather than vanishingly rare,
and small clock-skew differences no longer split rankings.

DB schema: score is REAL now (SQLite type affinity is loose enough that
existing rows still read fine, but new inserts go in as floats).

Frontend: added fmtScore() helpers in admin.js and quiz.js to render
two decimal places consistently (0.85, 1.20, 5.00) so float-arithmetic
sums never display as 0.8500000000000001.

Tests: linear_decay/flat/exponential_decay assertions updated; added
a snap-to-grid invariant test.
This commit is contained in:
ameer
2026-05-03 00:09:35 +08:00
parent 8e8d5cfff0
commit 7a483ad3ee
6 changed files with 87 additions and 35 deletions

View File

@@ -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)
);

View File

@@ -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:

View File

@@ -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)