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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user