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:

def sign(a, b): return (a > b) - (a < b) # 1 home win, 0 draw, -1 away win def score_prediction(pred, actual): if pred == actual: # exact match FIRST return 40 if sign(*pred) == sign(*actual): # then the outcome return 10 return 0

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:

predicted 2-1, actual 2-1 -> 40 predicted 1-0, actual 2-1 -> 10 # right result, wrong score predicted 0-0, actual 2-1 -> 0 predicted 2-2, actual 1-1 -> 10 # a draw is a result too predicted 0-0, actual 0-0 -> 40 predicted 1-2, actual 2-1 -> 0

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.

def parse(s): h, a = s.split("-") return int(h), int(a) def compute_points(predictions, actual): out, guests = {}, [] for source, value in predictions.items(): pts = score_prediction(parse(value), actual) if source.startswith("guest:"): guests.append(pts) else: out[source] = pts if guests: out["guest_avg_x100"] = round(sum(guests) / len(guests) * 100) return out

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:

{'user': 40, 'expert': 10, 'ai': 0, 'guest_avg_x100': 1667}

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:

Verified: A Result for a Fixture That Doesn't Exist
await r.hset("fixture:999", mapping={"home_score": 1, "away_score": 0}) await r.hgetall("fixture:999") # {'home_score': '1', 'away_score': '0'} await r.exists("fixture:999") # 1 -- a fixture with scores and no teams
HSET creates the key if it isn't there. A "fixture" now exists that has no home team, no away team and no gameweek — and every later existence check will say it is real.

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.

ENTER_RESULT_LUA = """ if redis.call('EXISTS', KEYS[1]) == 0 then return -1 end redis.call('HSET', KEYS[1], 'home_score', ARGV[1], 'away_score', ARGV[2]) return 1 """

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.

Verified: Increments Are Not Idempotent
Two ordinary events break it. First, the same result submitted twice (a double-clicked button, a retried request):
HINCRBY naive user 40 # first submission HINCRBY naive user 40 # the identical one again naive totals: {'user': '80'} # the user only earned 40
Second, a corrected result. Score the fixture as 2-1, then correct it to 1-1, adding each set of points as it is computed:
true totals after the correction: user 0, expert 0, ai 10, guest average 13.33 naive totals: {'user': '40', 'expert': '10', 'ai': '10', 'guest_avg': '30'}
The 2-1 points were never taken back, so the correction only added to them. A relational sibling never has this problem because its totals are a 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:

APPLY_POINTS_LUA = """ for i = 1, #ARGV, 2 do local old = tonumber(redis.call('HGET', KEYS[1], ARGV[i]) or '0') local new = tonumber(ARGV[i+1]) redis.call('HSET', KEYS[1], ARGV[i], ARGV[i+1]) -- this fixture's points redis.call('HINCRBY', KEYS[2], ARGV[i], new - old) -- season total moves by the difference end return 1 """

The whole scoring route is then three steps: enter the result (Lua), read the predictions (HGETALL), compute and apply the points (Lua).

async def score_fixture(r, fixture_id, home, away): rc = await enter_result(keys=[f"fixture:{fixture_id}"], args=[home, away]) if rc == -1: return None # route returns 404 predictions = await r.hgetall(f"fixture:{fixture_id}:predictions") points = compute_points(predictions, (home, away)) args = [x for k, v in points.items() for x in (k, str(v))] await apply_points(keys=[f"fixture:{fixture_id}:points", "totals"], args=args) return points
Verified: Idempotent, and Corrections Land Exactly
enter 2-1: totals {'user': '40', 'expert': '10', 'ai': '0', 'guest_avg_x100': '1667'} enter 2-1 again: totals unchanged correct to 1-1: totals {'user': '0', 'expert': '0', 'ai': '10', 'guest_avg_x100': '1333'}
The corrected totals match the true figures from the previous section exactly, and re-submitting an identical result changes nothing. Scoring a result is now something that can safely be repeated.

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:

Verified: Float Totals Pick Up Noise
HINCRBYFLOAT total: '13.3299999999999984' # this fixture's own points: '13.33' HINCRBY (x100): '1333' # exact, every time
The noise appeared after a single correction, not only after many. The stored total no longer equals the sum of the per-fixture values it is supposed to be built from. Integer hundredths keep every 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:

# result written, then the process "crashes" before scoring result: ['1', '0'] points: {} # a scored-looking fixture with nothing scored # re-running the same route recovers it score_fixture(r, 2, 1, 0) -> {'user': 40} points: {'user': '40'}

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.

What Chapter 7 and 8 Build On
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

Exercise 1

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 solution
Exercise 2

Write 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 solution
Exercise 3

Enter 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 solution

Chapter 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