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:
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
<li>
|
||||
<span class="rank">${r.rank}</span>
|
||||
<span class="who"><b>${escapeText(r.name)}</b>${r.student_id ? `<small>${escapeText(r.student_id)}</small>` : ""}</span>
|
||||
<span class="score">${r.score}</span>
|
||||
<span class="score">${fmtScore(r.score)}</span>
|
||||
</li>
|
||||
`).join("")}
|
||||
</ol>
|
||||
|
||||
@@ -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(`
|
||||
<div class="card narrow center">
|
||||
<p class="eyebrow">Question ${message.question_idx + 1}</p>
|
||||
<h1 class="big-score">+${message.score}</h1>
|
||||
<h1 class="big-score">+${fmtScore(message.score)}</h1>
|
||||
<p class="muted">submitted in ${seconds}s</p>
|
||||
<p class="muted small">Waiting for the reveal…</p>
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
@@ -296,8 +304,8 @@ function renderReveal(message) {
|
||||
</ol>
|
||||
${message.explanation ? `<p class="explanation">${escapeText(message.explanation)}</p>` : ""}
|
||||
<div class="reveal-stats">
|
||||
<div class="stat"><span class="muted">Your score</span><b>+${message.your_score || 0}</b></div>
|
||||
<div class="stat"><span class="muted">Total</span><b>${message.your_total ?? 0}</b></div>
|
||||
<div class="stat"><span class="muted">Your score</span><b>+${fmtScore(message.your_score || 0)}</b></div>
|
||||
<div class="stat"><span class="muted">Total</span><b>${fmtScore(message.your_total)}</b></div>
|
||||
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
|
||||
</div>
|
||||
<h3>Top 5</h3>
|
||||
@@ -312,7 +320,7 @@ function renderBetween(message) {
|
||||
<p class="eyebrow">Up next</p>
|
||||
<h1>Question ${(message.next_idx ?? 0) + 1}</h1>
|
||||
<div class="reveal-stats">
|
||||
<div class="stat"><span class="muted">Total</span><b>${message.your_total ?? 0}</b></div>
|
||||
<div class="stat"><span class="muted">Total</span><b>${fmtScore(message.your_total)}</b></div>
|
||||
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
|
||||
</div>
|
||||
${renderBoard(message.top5)}
|
||||
@@ -327,7 +335,7 @@ function renderFinished(message) {
|
||||
<article class="card celebration-card">
|
||||
<div class="celebration-banner">Quiz complete</div>
|
||||
<div class="reveal-stats">
|
||||
<div class="stat big"><span class="muted">Your total</span><b>${message.your_total ?? 0}</b></div>
|
||||
<div class="stat big"><span class="muted">Your total</span><b>${fmtScore(message.your_total)}</b></div>
|
||||
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
|
||||
<div class="stat"><span class="muted">Correct</span><b>${message.questions_correct ?? 0} / ${message.questions_answered ?? 0}</b></div>
|
||||
</div>
|
||||
@@ -356,7 +364,7 @@ function renderBoard(rows = []) {
|
||||
<li class="${isYou ? "is-you" : ""}">
|
||||
<span class="rank">${r.rank}</span>
|
||||
<span class="who"><b>${escapeText(r.name)}</b></span>
|
||||
<span class="score">${r.score}</span>
|
||||
<span class="score">${fmtScore(r.score)}</span>
|
||||
</li>
|
||||
`;
|
||||
}).join("")}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user