Leaderboard Model & Result Classification

1. Overview

A leaderboard turns the individual race results of a series into a ranked set of standings — who is winning the season, which school has the most points, which team leads the club competition. This page describes the durable model: the schema that stores a leaderboard’s configuration, and the way race results are scored and classified into standings.

This is the foundation. It is deliberately separate from two other concerns:

Automated recalculation (Leaderboard Sync) is deferred. Today, standings are aggregated manually (SQL or admin tooling) by reading RaceResult.points. This model is what makes that possible: as long as the points are scored correctly on import and the leaderboard configuration is in place, standings can be produced at any time.

2. The two-stage pipeline

Producing standings is a two-stage pipeline. The model keeps the two stages cleanly separated — they are configured independently and implemented by two different abstractions.

  Race finish positions          Stage 1: SCORING            Stage 2: AGGREGATION
  ┌───────────────────┐      ┌──────────────────────┐    ┌──────────────────────┐
  │ RaceResult.position│─────▶│ PointsCalculator     │───▶│ LeaderboardCalculator │───▶ Standings
  │ (1, 2, 3, …)       │      │ position → points    │    │ group + sum + rank    │
  └───────────────────┘      └──────────────────────┘    └──────────────────────┘
                              per-series scoring scale     per-LeaderboardType strategy
  1. Scoring (PointsCalculator) converts a finishing position into points using the series' scoring scale. Points are written to RaceResult.points at result-import time.

  2. Aggregation (LeaderboardCalculator) groups the scored results by the leaderboard’s subject (rider, school, club/team), sums the points, and ranks them into standings. The strategy is chosen by the leaderboard’s LeaderboardType.

Keeping these separate is what lets the same scoring scale feed several different leaderboards, and the same aggregation strategy work with different scoring scales.

3. Schema

3.1. Leaderboard

The configuration entity. One row per leaderboard (e.g. "Individual — Road League 2026").

Field Type Purpose

name

String(100)

Display name.

type

LeaderboardType

Aggregation strategy — see Classification & aggregation: LeaderboardType.

gender

Gender (nullable)

Optional gender filter; NULL = gender-neutral (e.g. when the category structure already splits men/women).

topCount

Integer

For TOP_SCHOOLS-style boards: how many best results per subject to count.

resultLimit

Integer

Max entries shown in the standings.

active

Boolean

Whether the board is live.

pointsCalculatorClass

String(255)

The scoring scale — short code or fully-qualified PointsCalculator class. See Scoring: PointsCalculator.

series

Series (required)

The series this board scores.

levelFilter

CustomListValue

Optional sub-classification filter (e.g. PS vs HS) — see Level filtering (PS vs HS).

groupByList

CustomList

The CustomList whose values the board groups by (e.g. Schools, Clubs).

categories

Set<LeaderboardCategory>

Which series categories feed this board — see below.

3.2. LeaderboardCategory

Maps a leaderboard to the series categories whose results it aggregates. This replaces the old approach of hardcoding series_category_id lists in SQL.

Field Type Purpose

leaderboard

Leaderboard (required)

Owning board.

seriesCategory

EventCategory (required)

A series-level category that feeds the board.

pointsMultiplier

BigDecimal (nullable)

Optional weighting applied to this category’s points.

3.3. Series, ResultSet, RaceResult

The leaderboard model sits on top of the existing results schema:

  • Series — the season/competition. A Leaderboard belongs to exactly one series; its categories (EventCategory rows with series set) are the series-level categories that LeaderboardCategory references.

  • ResultSet — a set of results for one event/category/state. Carries the state (ResultSetState), an optional leaderboard link, and (for the sync layer) an errorMessage.

  • RaceResult — one rider’s result within a ResultSet. The fields the model depends on:

    Field Role in the model

    position

    Input to scoring (PointsCalculator).

    points

    Output of scoring; the value aggregation sums.

    seq

    Deterministic ordering key (positions can tie); also a secondary upsert key.

    status

    ResultStatus — FINISHED / DNS / DNF / RELEGATED / DQ / LAPPED. Non-finishers are scored per series rules.

    person, eventParticipant

    The rider. For a participant-linked result the EP is the authoritative source of the grouping CustomListValues — see Classification source: EventParticipant vs RaceResult.

    custom1, custom2, custom3

    CustomListValue slots that carry grouping (school, club, team) only for participant-less results — team events (the TTT), school-as-entity results, and the materialised standings written by the sync layer. For participant-linked rows these are a fallback, not the source of truth — see Classification source: EventParticipant vs RaceResult.

4. Classification source: EventParticipant vs RaceResult

A result’s classification — the club, team, school, and (via the EP’s category) the series category it groups under — has one authoritative source, chosen by whether the result is tied to a participant:

  • Participant-linked results (the normal case — an individual rider’s finish) classify from the EventParticipant. The EP holds the live, corrected affiliation: when an operator fixes a rider’s club, merges two clubs, or re-categorises a rider, the change lands on the EP and is immediately reflected in every standing. This is the source of truth.

  • Participant-less results classify from RaceResult.custom1/2/3. Some results have no participant to carry the classification:

    • Team events — e.g. the WP Cycling Team Time Trial: one RaceResult per team with eventParticipant = NULL and the club/team on RaceResult.custom1/2. Points are awarded to the entity, not to individual riders.

    • School-as-entity results — where a school (not a rider) is the scored subject.

    • Materialised standings — the aggregation output the sync layer writes back as participant-less RaceResult rows keyed by the grouping CustomListValue.

Aggregation therefore resolves each grouping slot with a coalesce over a LEFT JOIN to the EventParticipant — EP first, RaceResult.custom_* as the fallback:

LEFT JOIN event_participant ep ON ep.id = race_result.event_participant_id
...
COALESCE(ep.custom_1_id, race_result.custom_1_id) AS club_id
COALESCE(ep.custom_2_id, race_result.custom_2_id) AS team_id

One rule serves both shapes: participant-linked rows read the always-current EP; participant-less rows (which have no EP) fall through to RaceResult.custom_*. The LEFT JOIN (not INNER) keeps the participant-less rows in the result set.

This supersedes a transitional design. An earlier implementation read grouping only from RaceResult.custom_* for all results, and kept those columns in sync with the EP via a separate back-fill step. That back-fill was a manual, forgettable operation whose omission silently under-counted clubs and teams for whole events. Under the coalesce rule above the back-fill is not needed and is retired — participant-linked rows are never denormalised onto RaceResult. The migration is tracked in design journal 2026-06/leaderboard-model-to-be.adoc.

5. Scoring: PointsCalculator

A PointsCalculator maps a finishing position to points for a given series. It is a pure function:

public interface PointsCalculator {
    int getPoints(Integer position);
    default String name() { return getClass().getSimpleName(); }
}

Two scales ship today, each matching a league’s published rulebook:

Calculator Scale

SASchoolCyclingSeriesPointsCalculator
(the default)

Deep non-linear curve: 1→250, 2→200, 3–9 → 160 − (pos−3)·10, 10–13 → 80 + (13−pos)·5, 14–38 → 30 + (38−pos)·2, 39–67 → 1 + (67−pos), 68+ → 1.

WpcaRoadLeaguePointsCalculator

Shallow truncated scale, positions 1–12: 50, 45, 40, 36, 34, 32, 30, 28, 26, 24, 22, 20; position 13+ → 2 participation points.

5.1. Selecting the scale

PointsCalculatorFactory resolves a calculator from the value of Leaderboard.pointsCalculatorClass (or the results-import pointsCalculator query parameter): by short code, then by fully-qualified class name via the Spring context, falling back to no-arg reflection. A null/blank/unresolvable value returns the default (SASchoolCyclingSeriesPointsCalculator).

Scoring happens at import time. RaceResult.points is set when results are imported, using the selected calculator. Choosing the wrong calculator on import writes wrong points; the fix is to re-import the event’s results with the correct pointsCalculator. The operator-facing steps are in Import Operations Guide.

6. Classification & aggregation: LeaderboardType

A LeaderboardCalculator aggregates scored results into standings. The strategy is selected by the leaderboard’s LeaderboardType. Each calculator filters results to the board’s categories (and optional gender / levelFilter), groups them by the board’s subject, sums points, and ranks.

Type Code Aggregation strategy

SCHOOL

S

Group by school (EventParticipant.custom3); sum all points per school. Reused for any single-CustomList grouping — e.g. Club or Team (see note below).

TOP_SCHOOLS

T

As SCHOOL, but per subject count only the best topCount results before summing.

ALL_STARS

A

Cross-gender aggregate of top schools — combines the male and female school totals into one ranking.

INDIVIDUAL_RIDER

I

Group by Person across all events in the series; sum the rider’s points season-wide.

The shared base (AbstractLeaderboardCalculator) applies the common filters: the gender filter (skip when gender is null) and the level filter (match the result’s custom1 against Leaderboard.levelFilter, resolved EP-first per Classification source: EventParticipant vs RaceResult), and resolves the scoring scale via the factory.

Type reuse over schema churn. SCHOOL aggregates by a single CustomList value on the EventParticipant, which is exactly the mechanic a Club or Team board needs. Rather than add new enum values, a club/team board reuses SCHOOL and points groupByList at the relevant CustomList. A more general GROUP / CUSTOM_LIST type — and a COALESCE(team, club) scoring key for team overlays — is tracked as deferred work in Leaderboard Sync. The WP Cycling procedure documents the current Team/Club overlay in concrete terms.

6.1. Level filtering (PS vs HS)

Sub-classifications such as Primary School vs High School are not hardcoded category-ID lists. The board’s levelFilter references the PS or HS CustomListValue, and LeaderboardCategory links the board to its series categories. Two boards (one PS, one HS) over the same series differ only by levelFilter.

7. Worked shape

The two production leagues instantiate this model as follows — full setup steps in their procedures:

League Scoring Boards (LeaderboardType) Grouped by

WC Schools Cycling

SASchoolCyclingSeriesPointsCalculator

SCHOOL / TOP_SCHOOLS / ALL_STARS (+ INDIVIDUAL_RIDER), per PS/HS levelFilter

School (custom3)

WP Cycling Road League

WpcaRoadLeaguePointsCalculator

INDIVIDUAL_RIDER (Individual), SCHOOL reused (Team/Club), All Rounder

Person; Club (custom1) / Team (custom2)

8. Current state & compromises

The model above is the to-be target. These parts are knowingly not there yet; each is tracked in design journal 2026-06/leaderboard-model-to-be.adoc.

Area Compromise carried today

Population

Standings are aggregated by manual SQL, not by the LeaderboardCalculator. The Java calculators exist but fetchResults() is a stub, so the strategy classes do not yet run in production (Feature #143; US #144 "Phase 4: Calculators" was closed against the stub). The short-term build implements them and migrates the work onto the sync / Async Signal & Sweep framework (Feature #403, US #409).

Classification source

The to-be COALESCE(ep, rr) rule (Classification source: EventParticipant vs RaceResult) is adopted going forward; the existing manual queries are migrated off the retired RaceResult.custom_* back-fill in a follow-up review session. Until each query is migrated, its standings depend on the back-fill having been run for that event.

Per-leaderboard event set

A leaderboard’s event scope (e.g. "all events except the TTT", "starred events only") is not modelled — Leaderboard has no event link. The manual SQL uses hardcoded event-ID lists, which have already drifted from reality in production. A first-class Leaderboard ⇄ Event set is the to-be (see Sync deferred items).

Catch-all entity exclusion

The "None"/"Other" catch-all club exclusion is hardcoded CustomListValue ids in each query. The to-be is a CustomListValue annotation/metadata layer so queries and calculators reference semantics (e.g. catch_all) rather than ids — shape under design in the 2026-06 journal.

Grouping type

LeaderboardType.SCHOOL is reused for Club/Team grouping. A general GROUP / CUSTOM_LIST type remains deferred (see Classification & aggregation: LeaderboardType and the sync deferred list).

  • Leaderboard Synchronization — the deferred automated-recalculation layer that builds on this model.

  • Results Import Design — how results (and their points) enter the system.

  • Import Operations Guide — the operator steps for importing results with a chosen calculator.

  • design-journal/2026-06/leaderboard-model-to-be.adoc — the to-be design decisions (EP-authoritative classification, calculator build, event set, CLV metadata) and the compromises tracked above.