Sunday League Standings: Tiebreaks, Corrections and Run-in Odds
HardAI-assisted coding · AI-Assisted Debugging
Sunday League Standings: Tiebreaks, Corrections and Run-in Odds
You've joined the small team that runs the results and standings service for the Metro Sunday League federation. Club secretaries enter results through a web form, the service turns them into the league table, and every Monday the table is published to the website and the local paper. The federation's rule book (section 7) decides who sits where, and when two clubs argue about it, the published table is the evidence. Bug reports arrived after the last round, and the roadmap has three features queued behind them.
Codebase
| File | What it does |
|---|---|
league/api.py | run(operations) entry point and the Dispatcher: route table, argument kinds, error rendering |
league/service.py | LeagueService: validates a request, changes league data, tells Standings to refresh |
league/standings.py | Standings: builds and caches the ordered table |
league/tiebreak.py | order_teams: turns team statistics into a first-to-last ordering |
league/table.py | TeamLine (a team's statistics), build_lines, render_row |
league/fixtures.py | FixtureBook: every match and its state; fixture generation; results, forfeits, postponements |
league/sanctions.py | SanctionBook: point deductions |
league/registry.py | TeamRegistry: the clubs, in registration order |
league/models.py | Match and Deduction records, match status constants |
league/config.py | Scoring constants, the forfeit scoreline, id prefixes |
league/errors.py | LeagueError and every error code |
league/editions.py, league/rules.py, league/outlook.py | Placeholders for Parts 2, 3 and 4 |
tests/ | Existing example tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh league and returns one result per operation. Every operation is [name, *args]. Errors are returned as "error: <code>" strings, never raised.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
team | team_id | true | team exists, registration closed |
fixture | match_id, home, away | true | match exists, same team, team not found (home checked before away) |
generate | legs | number of fixtures created | invalid legs, fixtures exist, not enough teams |
result | match_id, home_goals, away_goals | true | match not found, invalid score, result exists |
forfeit | match_id, team_id | true | match not found, not a participant, result exists |
postpone | match_id | true | match not found, result exists, already postponed |
deduct | team_id, points, reason | deduction id ("d-1", "d-2", ...) | team not found, invalid points |
standings | none | the table (see below) | none |
- Unknown operation name:
"error: unknown operation". An operation that is not a non-empty list starting with a string name, a wrong number of arguments, or an argument of the wrong *kind* is"error: invalid arguments", decided before anything else. Kinds: ids (team_id,match_id, ...) are non-empty strings;reasonis a string with a non-space character; numbers are integers (true/falseare not integers). teamregisters a club. Registration order matters (seegenerate). Once any fixture exists, new teams are refused withregistration closed.generateis allowed only withlegsof 1 or 2 and when no fixture exists yet. It creates a full round robin: leg 1 is every pair(teams[i], teams[j])withi < j,iascending thenjascending,teams[i]at home; leg 2 repeats the same order with home and away swapped. Match ids are"m-1","m-2", ... in creation order.fixturecreates a single match by hand. Matches startscheduled.resultgives a scheduled or postponed match its score (home goals first); the match is nowplayed. Scores are integers>= 0.forfeit:team_idfailed to turn up. The match is recorded as a 3-0 win for the other side: the opponent is credited 3 goals and the forfeiting team 0, whichever of them is home. A forfeit counts as a played match in every statistic (played/won/lost, goals for/against, goal difference, away goals) and in head-to-head records; seeconfig.FORFEIT_SCORE.postponemarks a scheduled matchpostponed. A postponed match has no score and does not count in the table; it is still an unplayed match.deductrecords a sanction ofpoints > 0. It reduces that team's table points and nothing else: it is not a match result, so goals, wins, head-to-head records and match counts are unaffected. A team's points may go below zero.- Returned tables are snapshots: a value returned by one operation must never change because of a later operation.
The table. standings returns one row per team, first place to last:
[position, team, played, won, drawn, lost, goals_for, goals_against, goal_difference, points]
position is 1, 2, 3, ... (there are no shared positions, see the tiebreak rules). points is net: 3 per win, 1 per draw, 0 per loss, minus every deduction.
Ordering (rule book section 7). Teams are ranked by points, best first. Teams level on points are separated by these criteria, in this order:
- head-to-head points: points earned in the matches played *between the tied teams only*
- head-to-head goal difference, over those same matches
- overall goal difference
- overall goals scored
- team id, ascending (plain string order). This always separates two different teams.
Detail that matters:
- A head-to-head table counts every played or forfeited match in which both teams are in the tied group (a double round robin has two such matches per pair). Matches against anyone outside the group, and unplayed matches, are ignored. Sanctions are not match results, so they are not part of head-to-head points.
- Take a group of teams level on points and walk down the criteria. At the first criterion where the group's values are not all equal, split the group into sub-groups of equal value (best first). Every sub-group of two or more teams is then resolved from criterion 1 again, using only the teams in that sub-group: its head-to-head table is rebuilt from the matches between just those teams. A criterion that does not split the group is simply skipped.
- The tied group of the whole competition is "everyone", so the first split is always by points. An outcome never depends on registration order or on the order matches were entered.
Worked example 1: a cycle (generate 1 creates m-1 A-B, m-2 A-C, m-3 B-C)
[["team", "A"], ["team", "B"], ["team", "C"], ["generate", 1],
["result", "m-1", 2, 0], ["result", "m-2", 0, 1], ["result", "m-3", 1, 0], ["standings"]]returns [true, true, true, 3, true, true, true, <table>] where the table is
[[1, "A", 2, 1, 0, 1, 2, 1, 1, 3],
[2, "C", 2, 1, 0, 1, 1, 1, 0, 3],
[3, "B", 2, 1, 0, 1, 1, 2, -1, 3]]Every team has 3 points and beat exactly one rival, so head-to-head points are level at 3; head-to-head goal difference (+1, 0, -1) orders them.
Worked example 2: a split that must be resolved again. Five teams A..E; matches (home first): A 1-0 B, A 3-0 C, B 1-1 C, A 0-0 D, B 1-0 D, B 1-0 E, C 4-0 D, C 4-0 E. After these, A, B and C all have 7 points. Among the three, head-to-head points are A 6, B 1, C 1, so A is first and {B, C} remain. Inside the 3-team head-to-head table B has goal difference -1 and C has -3, but that table is no longer relevant: B and C are resolved on their own, where they drew 1-1 (equal head-to-head points, equal head-to-head goal difference), so overall goal difference decides: C (+5) beats B (+1). The order is A, C, B, D, E. (A pairwise "who wins between these two" comparison also gets this one right; it is not a sound comparison in general, because pairwise results are not transitive.)
Part 1: Bug reports from the last round
Goal: make standings follow the rule book above in every situation. Reports from the club secretaries and the league office:
- LG-412 "In Division 3 three clubs finished level on points and each beat one of the others. The published order was different from what the paper's own table calculator gave, and it changes if I enter the clubs in a different order."
- LG-415 "A club was docked points and still shows above a club that now has more points than it." (There is also an older complaint that a club with a deduction won a head-to-head tiebreak it should have lost, or the other way round.)
- LG-418 "We could not raise a side for our home game against Harbour, so we forfeited it. The table gave us the 3-0 win and Harbour the defeat."
- LG-421 "The disciplinary committee's deduction did not show on the website until somebody entered the next result."
tests/ contains example tests; some fail today. Fix the code so the table is right and keep the README rules above as the source of truth. The hidden tests include teams that are tied in groups larger than two.
Part 2: Corrections after publication and published editions (LG-377, LG-430)
Goal: results are sometimes entered wrongly, forfeits get overturned on appeal and deductions are withdrawn after the table has already gone out. Support all of that without rewriting history.
| Operation | Arguments | Returns | Errors (in order) |
|---|---|---|---|
correct | match_id, home_goals, away_goals | {"moved": [[team, was, now], ...]} | match not found, invalid score, no result |
rescind | deduction_id | true | deduction not found, already rescinded |
publish | none | edition id ("ed-1", "ed-2", ...) | none |
edition | edition_id | the table as it was when published | edition not found |
movers | edition_id | [[team, was, now], ...] | edition not found |
Rules
correctreplaces the score of a match that already has a result, whether it was played or forfeited. The match is then an ordinaryplayedmatch with the new score (an overturned forfeit stops being a forfeit:resultandforfeiton it now fail withresult exists). A match with no result (scheduled or postponed) cannot be corrected:no result. Correcting to the same score is allowed.correctreturns{"moved": ...}: the teams whose position differs between the table just before the correction and the table just after it, as[team, position_before, position_after], ordered byposition_afterascending. No change in positions gives{"moved": []}. Failed corrections change nothing.- After a correction the table, every later position and every later
outlookbehave exactly as if the corrected score had been entered in the first place. rescindwithdraws a deduction (by the iddeductreturned) so it no longer counts. A deduction can be rescinded once. Its id is never reused.publishfreezes the current table as the next edition. An edition never changes afterwards, whatever happens to results, sanctions or (Part 3) rules.editionreturns the frozen table (same row format).moverscompares the edition with the one published immediately before it and lists[team, was, now]for teams in both whose position differs, ordered bynowascending;moversof"ed-1"is[]. Edition ids are exactly"ed-"followed by the number without leading zeros; anything else isedition not found.
Example (after the Worked example 1 operations, so A leads on goal difference)
[["publish"], ["correct", "m-1", 0, 3], ["standings"], ["publish"], ["movers", "ed-2"], ["edition", "ed-1"]]returns "ed-1", then {"moved": [["B", 3, 1], ["A", 1, 3]]} (with the corrected 3-0 away win B wins both its games and goes from last to first; A drops from first to last; C stays second so it is not listed), then the new table
[[1, "B", 2, 2, 0, 0, 4, 0, 4, 6], [2, "C", 2, 1, 0, 1, 1, 1, 0, 3], [3, "A", 2, 0, 0, 2, 0, 4, -4, 0]]then "ed-2", [["B", 3, 1], ["A", 1, 3]], and finally the original table exactly as ed-1 was published (A, C, B).
Part 3: Seasons change the rules (LG-401)
Goal: the federation lets each competition choose its points system and tiebreak order, and the hard-coded rule book of Part 1 can no longer stay hard-coded.
| Operation | Arguments | Returns | Errors |
|---|---|---|---|
rules | win_points, draw_points, chain | true | invalid rules |
chain is a list of criterion names, applied in order to teams level on points; points always come first and team id always comes last and are never named in the chain.
| Name | Meaning |
|---|---|
h2h_pts | points in the matches between the tied teams only (as in Part 1) |
h2h_gd | goal difference in those matches |
h2h_gf | goals scored in those matches |
gd | overall goal difference |
gf | overall goals scored |
wins | overall number of wins (forfeit wins included) |
away_gf | overall goals scored in away matches (a forfeit credits its 3 goals to whichever side was away) |
Rules
- Until
rulesis first called the competition useswin_points=3, draw_points=1and the chain["h2h_pts", "h2h_gd", "gd", "gf"]: exactly Part 1. rulesis valid whenwin_pointsanddraw_pointsare integers withwin_points > draw_points >= 0,chainis a list of strings, each one of the names above and none repeated (an empty chain is allowed). Otherwiseinvalid rules, and the current rules stay as they were. Wrong argument kinds areinvalid argumentsas always.- The new rules apply to the whole competition from that moment on, including matches already played: net points, the table, head-to-head points, positions and corrections are all computed under the current rules. The
pointsof a loss is 0. A forfeit is still recorded as 3-0. - The restart rule is unchanged: when a criterion splits a tied group, every sub-group is resolved again from the first criterion of the current chain, using only its own teams. Criteria that do not depend on other teams (
gd,gf, ...) give the same values again, so they never split a group twice. - Editions published earlier keep the table they were published with.
Example: with the Worked example 1 league, ["rules", 3, 1, ["gf", "h2h_pts"]] followed by ["standings"] orders the three level teams by goals scored first: A (2), then B and C (both 1)
- after which B and C form a sub-group that restarts at
gf(still level) and thenh2h_pts, where their one meeting (B 1-0 C) puts B ahead of C.
Part 4: Run-in outlook at scale (LG-419)
Goal: the website wants a "can they still catch the leaders / can they still go down" widget, and the open division has grown to a couple of hundred clubs.
| Operation | Arguments | Returns | Errors (in order) |
|---|---|---|---|
position | team_id | current position (int) | team not found |
outlook | team_id, places | {"best": b, "worst": w, "status": s} | team not found, invalid places |
position equals the team's position in standings right now.
outlook bounds where a team can still finish. places must be between 1 and the number of teams (inclusive), otherwise invalid places. Let, for each team, pts be its current table points (net of sanctions, under the current rules), unplayed the number of its matches that have no result yet (scheduled or postponed; forfeits and played matches are done), and max = pts + unplayed * win_points.
- If no match in the competition is unplayed (this includes a league with no fixtures at all), the table is final:
best = worst =the team's current position. - Otherwise
best = 1 + (number of other teams with pts > max of this team)andworst = (number of teams) - (number of other teams whose max < this team's pts). A team level on points with this team is never counted as ahead of it or behind it: the bounds do not try to guess tiebreaks. They also ignore combinations of results and any future sanctions. statusis"clinched"ifworst <= places, otherwise"eliminated"ifbest > places, otherwise"open".
Example: four teams A..D, generate 1 (six fixtures: m-1 A-B, m-2 A-C, m-3 A-D, m-4 B-C, m-5 B-D, m-6 C-D), then results m-1 1-0, m-2 2-0, m-3 3-0, m-4 1-0, m-5 1-0. Only m-6 is unplayed. A has 9 points and no matches left; B has 6 and none left; C and D have 0 points and 1 match left each (max 3).
["outlook", "A", 1]is{"best": 1, "worst": 1, "status": "clinched"}: every other team'smax(6, 3, 3) is below A's 9 points.["outlook", "C", 2]is{"best": 3, "worst": 4, "status": "eliminated"}: A (9 points) and B (6) already have more than C's maximum of 3, so C cannot finish above third; D's maximum (3) is not below C's current 0 points, so C can still finish fourth.["outlook", "C", 3]is{"best": 3, "worst": 4, "status": "open"}.
Scale. Hidden tests generate leagues of a couple of hundred clubs (a double round robin has tens of thousands of fixtures) and then interleave results, forfeits, postponements, sanctions and rule changes with position and outlook calls, one query after almost every change. The whole hidden suite has a few seconds. Rebuilding every statistic or rescanning every fixture for each call is too slow, and a speed-up must not change any answer from Parts 1-3 (including tables returned earlier staying as they were).
Notes
- Where the code and this README disagree, the README is right. Do not assume any single file is free of bugs.
- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones; hidden tests re-check earlier behaviour (forfeits, sanctions, recursive tiebreaks, snapshots) after the later parts are in.
- Parts 1-3 is a strong result. Part 4 is a stretch.
Example 1
- Input:
- [["team","A"],["team","B"],["team","C"],["generate",1],["result","m-1",2,0],["result","m-2",0,1],["result","m-3",1,0],["standings"]]
- Output:
- [true,true,true,3,true,true,true,[[1,"A",2,1,0,1,2,1,1,3],[2,"C",2,1,0,1,1,1,0,3],[3,"B",2,1,0,1,1,2,-1,3]]]
- Explanation
- A beat B, B beat C, C beat A, so all three have 3 points and one win. Head-to-head points are level; head-to-head goal difference (+1, 0, -1) orders them A, C, B.
Example 2
- Input:
- [...cycle league..., ["publish"], ["correct","m-1",0,3], ["standings"], ["publish"], ["movers","ed-2"], ["edition","ed-1"]]
- Output:
- [..., "ed-1", {"moved":[["B",3,1],["A",1,3]]}, <new table>, "ed-2", [["B",3,1],["A",1,3]], <ed-1 as originally published>]
- Explanation
- Correcting m-1 from 2-0 to 0-3 reverses the order of A and B. The correction reports who moved, and edition ed-1 still shows the table exactly as it was published.
Example 3
- Input:
- [...cycle league..., ["rules",3,1,["gf","h2h_pts"]], ["standings"]]
- Output:
- [..., true, [[1,"A",...],[2,"B",...],[3,"C",...]]]
- Explanation
- Under the new chain the three level teams are separated by goals scored first (A has 2); B and C then form a sub-group that restarts the chain and is decided by their single meeting.
Guidelines
- Python 3 standard library only
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Ordering is fully deterministic: points, then the tiebreak chain, then team id ascending; it never depends on registration order or on the order results were entered
- A table returned by one operation must never change because of a later operation
- Part 4 inputs have a couple of hundred clubs (tens of thousands of fixtures) with a query after almost every change