Entering Results & Calculating Correct Score vs. Correct Result
Premier League Predictor: FastAPI & Redis
Chapter 6 · Entering Results & Calculating Correct Score vs. Correct Result
Predictions are stored and locked. This chapter turns a real scoreline into points: entering a fixture's result, scoring every prediction against it, and keeping running totals that the later chapters will rank. The scoring rule itself is simple. The interesting part is what "keep a running total" means in Redis, because the obvious way to do it is wrong the moment a result is corrected.
The Scoring Rule
The point values were confirmed with the user during the PostgreSQL sibling's own scoring chapter and are reused here unchanged: 40 points for a correct score, 10 points for a correct result (the right win, draw or loss with the wrong scoreline), and 0 otherwise. Scoring is a pure function with no Redis in it at all:
The order of the two checks matters, because an exact score always implies a correct outcome. Checking the outcome first would score every exact prediction as 10. Exercise 2 runs both orders side by side. Verified on six cases, including two draws:
Guests: Average the Points, Not the Scorelines
Chapter 5 stored each guest as their own field. A week's guests have to become one comparable figure, and the relational siblings settled how: score each guest first, then average the points. A scoreline can't be meaningfully averaged (half a goal isn't a result), but a point value always can.
The field is called guest_avg_x100 on purpose — the reason comes later in this chapter.
Worked example, actual result 2-1: Alan predicted 3-0 (10), Bea 2-1 (40), Carl 1-1 (0). The average is
50 / 3 = 16.67, stored as 1667. Verified output for the full set of sources:
Entering the Result — Without Creating an Orphan
Chapter 5 locked predictions once a fixture has scores, so entering the result is also what closes
them. The natural write is HSET fixture:{id} home_score ... away_score ... — and that
reproduces Chapter 2's missing-foreign-key problem in a new place:
The fix is the same shape as Chapter 5's: put the existence check inside a small Lua script, so the check and the write can't be separated.
Verified: entering a result for fixture:999 returns -1, and afterwards
neither fixture:999 nor fixture:999:points exists. The route maps
-1 to a 404. As in Chapter 5, the home and away scores are validated by Pydantic
(ge=0) before anything reaches Redis.
Running Totals: The Obvious Way Is Wrong
Chapter 8's prediction leaderboard needs each source's total across the season. The obvious Redis
idiom is an increment: every time a fixture is scored, HINCRBY totals user 40.
SUM over stored rows, computed
fresh each time. A stored counter has no memory of what it already counted.
The Fix: Store Points Per Fixture, Apply Only the Difference
Keep each fixture's points in their own hash, fixture:{id}:points, and update the running
totals by new minus old. Re-scoring the same result then adds zero, and a correction adds
exactly the change. Reading the old value and writing the new one has to be atomic — two concurrent
corrections would otherwise both read the same "old" — so it goes in a Lua script, as in Chapter 5:
The whole scoring route is then three steps: enter the result (Lua), read the predictions
(HGETALL), compute and apply the points (Lua).
Why the Guest Average Is Stored as an Integer
The field is guest_avg_x100 — hundredths of a point — rather than 16.67.
Redis has HINCRBYFLOAT, which looks like the natural fit for a fractional average. Applying the
same sequence of ten corrections both ways:
HINCRBY exact; divide by 100 only when displaying. This will matter in Chapter 8, where the
guest column is ranked.
A Crash Between the Steps
Entering the result and applying the points are two separate Redis calls. If the server dies between them, the fixture has a result and no points:
Nothing was left half-applied, because scoring is idempotent — running it again is the whole recovery procedure. That is a direct dividend of the delta design above; the naive increment version would have needed to know whether the first attempt had already counted.
totals holds each source's season total as a hash. A hash is enough to store the
numbers, but not to rank them cheaply — that is the job of the sorted sets in Chapters 7 and 8.
The delta approach carries straight across: ZINCRBY takes the same "difference, not
total" argument that HINCRBY did here.
Hands-On Exercises
Submit the identical 40-point score for the same source twice, once through a plain HINCRBY total and once through the APPLY_POINTS Lua script. Report both totals and explain what each design remembers that the other doesn't.
📄 View solutionWrite a second version of score_prediction that checks the outcome before the exact score. Run both versions on five cases (including two draws) and report which cases differ and why.
📄 View solutionEnter a result for a fixture that was never created using a plain HSET, then explain why a later chapter's existence checks would treat the result as a real fixture, and what the Lua-guarded version does instead.
📄 View solutionChapter 6 Quick Reference
- 40 for a correct score, 10 for a correct result — a pure function; check the exact score before the outcome
- Guests: average points, not scorelines — stored as integer hundredths (
guest_avg_x100) - Verified: HSET on a missing fixture creates one — guard result entry with an in-script
EXISTS - Verified: increment totals aren't idempotent — a repeated or corrected result double-counts
- Store per-fixture points, apply new-minus-old — one Lua script, atomic, safe to re-run
- Verified: HINCRBYFLOAT drifts —
'13.3299999999999984'after one correction; integers stay exact - Crash recovery is just re-running — idempotent scoring needs no bookkeeping about what was applied