mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 12:21:49 +00:00
71 lines
2.5 KiB
Python
71 lines
2.5 KiB
Python
"""Pure helpers for fee[dB]ack v0.3.0 song-stats scoring + upsert logic.
|
|
|
|
The score/accuracy formulas mirror the frontend recorder (static/v3/
|
|
stats-recorder.js) so the value the badge shows and the value the server
|
|
stores agree. Kept pure + flat-importable (constitution Principle V); tested
|
|
in tests/test_song_score.py.
|
|
|
|
accuracy(hits, misses) = hits / max(1, hits + misses) # 0..1
|
|
score(hits, misses) = round(hits * 100 * accuracy) # monotonic in accuracy
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
__all__ = ["accuracy", "score", "merge_stats"]
|
|
|
|
|
|
def accuracy(hits: int, misses: int) -> float:
|
|
hits = max(0, int(hits or 0))
|
|
misses = max(0, int(misses or 0))
|
|
return hits / max(1, hits + misses)
|
|
|
|
|
|
def score(hits: int, misses: int) -> int:
|
|
"""Deterministic, monotonic-in-accuracy integer score.
|
|
|
|
Rounds half-AWAY-from-zero to match the frontend recorder's JS
|
|
Math.round() (e.g. hits=3, misses=5 → 112.5 → 113), not Python's
|
|
banker's rounding which would give 112 and disagree with the client."""
|
|
hits = max(0, int(hits or 0))
|
|
return int(math.floor(hits * 100 * accuracy(hits, misses) + 0.5))
|
|
|
|
|
|
def merge_stats(existing: dict | None, session: dict) -> dict:
|
|
"""Upsert/max merge of a scored session into the existing row.
|
|
|
|
`plays` increments; `best_*` take the max of old/new; `last_*` take the
|
|
new session; `last_position` falls back to the existing value when the
|
|
session doesn't carry one. Returns the merged field dict (no IO).
|
|
"""
|
|
e = existing or {}
|
|
|
|
def _i(v, d=0):
|
|
try:
|
|
return int(v)
|
|
except (TypeError, ValueError, OverflowError):
|
|
return d
|
|
|
|
def _f(v, d=0.0):
|
|
# Reject NaN/Inf as well as unparseable values: a stored non-finite
|
|
# would later break JSON serialization of /api/stats reads.
|
|
try:
|
|
f = float(v)
|
|
return f if math.isfinite(f) else d
|
|
except (TypeError, ValueError, OverflowError):
|
|
return d
|
|
|
|
new_score = _i(session.get("score"))
|
|
new_acc = _f(session.get("accuracy"))
|
|
sess_pos = session.get("last_position")
|
|
last_position = _f(sess_pos) if sess_pos is not None else _f(e.get("last_position"))
|
|
return {
|
|
"plays": _i(e.get("plays")) + 1,
|
|
"best_score": max(_i(e.get("best_score")), new_score),
|
|
"best_accuracy": max(_f(e.get("best_accuracy")), new_acc),
|
|
"last_score": new_score,
|
|
"last_accuracy": new_acc,
|
|
"last_position": last_position,
|
|
}
|