Multi-Tournament Aggregation
The aggregation layer combines point awards from multiple tournaments into ranking lists. It operates as pure computation — no tournament state is accessed. The consumer is responsible for collecting PointAward[] from multiple getTournamentPoints calls and passing them to generateRankingList.
Aggregation Pipeline
PointAward[] (from multiple tournaments)
|
1. Category Filter ageCategoryCodes, genders, eventTypes
|
2. Rolling Period Exclude awards older than rollingPeriodDays
|
3. Group by Person Collect all awards per personId
|
Point Decay Scale each award by its age (decayFunction), if declared
|
4. Bucket Processing Per counting bucket:
| - Filter by eventTypes + pointComponents
+-> Sort descending - Sort by computed value
+-> Level cap - Apply maxResultsPerLevel
+-> Mandatory select - Apply mandatoryRules (if any)
+-> Best-of-N - Fill remaining slots via bestOfCount
+-> Sum - Bucket total
|
5. Total Points Sum across all buckets
|
6. Minimum Check meetsMinimum if countingResults >= minCountableResults
|
7. Sort + Tiebreak Sort descending, apply tiebreakCriteria
|
8. Assign Ranks 1-based, tied ranks for equal positions
|
v
RankingListEntry[]
Typical Pipeline
import { scaleEngine } from 'tods-competition-factory';
const { generateRankingList } = scaleEngine;
// Step 1: Collect awards from multiple tournaments
const allAwards = [];
for (const tournamentRecord of seasonTournaments) {
scaleEngine.setState(tournamentRecord);
const { personPoints } = scaleEngine.getTournamentPoints({
policyDefinitions: rankingPolicy,
level: tournamentRecord.level,
});
for (const [personId, awards] of Object.entries(personPoints)) {
allAwards.push(...awards.map((a) => ({ ...a, personId })));
}
}
// Step 2: Generate ranking list
const rankingList = generateRankingList({
pointAwards: allAwards,
aggregationRules,
asOfDate: '2025-12-31',
});
Counting Buckets
Counting buckets define how awards are grouped and counted. Each bucket filters awards by eventTypes and extracts point values from pointComponents:
Valid pointComponents are positionPoints, perWinPoints, bonusPoints,
qualityWinPoints, and points. The last is the award's own pre-summed total rather
than a fifth granular component — it is what a bucket sums when the policy names no
pointComponents at all (the default is ['points', 'qualityWinPoints']). That pair
does not double-count, because a quality-win bonus is emitted as its own award
carrying qualityWinPoints and no points. Do not mix 'points' with the granular
components in one bucket, though: there it would count the same award's total twice.
aggregationRules: {
countingBuckets: [
{
bucketName: 'Singles',
eventTypes: ['SINGLES'],
pointComponents: ['positionPoints', 'perWinPoints', 'bonusPoints'],
bestOfCount: 6, // best 6 results count
maxResultsPerLevel: { 7: 2 }, // max 2 level-7 results
},
{
bucketName: 'Doubles',
eventTypes: ['DOUBLES'],
pointComponents: ['positionPoints', 'perWinPoints', 'bonusPoints'],
bestOfCount: 2, // best 2 results count
},
{
bucketName: 'Quality Wins',
pointComponents: ['qualityWinPoints'],
bestOfCount: 0, // 0 = count ALL (no limit)
},
],
}
Point Components
The pointComponents array specifies which fields on each award to sum for the bucket value:
| Component | Source |
|---|---|
'positionPoints' | Finishing position points |
'perWinPoints' | Per-win points |
'bonusPoints' | Champion/finalist bonus |
'qualityWinPoints' | Quality win bonus |
'linePoints' | Team line position points |
'points' | Combined total (position + perWin + bonus) |
bestOfCount
0— Count all results (no limit)N > 0— Count only the best N results; remaining are dropped
weight
A multiplier on the bucket's total, applied after its counting results are selected (default 1). The ITF Junior
combined ranking counts the six best singles results plus one quarter of the six best doubles results:
{ bucketName: 'Doubles', eventTypes: [DOUBLES], bestOfCount: 6, weight: 0.25 }
Selection uses each award's own points, so a weight never changes which results count. The awards keep their own
points; the bucket's bucketTotal and the participant's totalPoints carry the weighted value.
bestPerSeries
Counts only the best result from each tournament series within the window. A series is the same tournament across its
editions: CODES Tournament.tournamentSeriesId, stamped on each award by getTournamentPointAwards. The ITF Junior
rule (Reg. 10) is the best result from up to two editions of the same J100 or higher graded tournament:
{ bucketName: 'Singles', eventTypes: [SINGLES], bestOfCount: 6, bestPerSeries: { levels: [1, 4, 5, 6, 7] } }
levels limits the rule to awards at those levels (all levels when omitted). An award without a
tournamentSeriesId is unaffected. The other editions' results move to droppedResults.
maxResultsPerLevel
Limits how many results from a specific tournament level count toward the bucket:
maxResultsPerLevel: { 7: 2, 6: 3 }
// At most 2 level-7 results and 3 level-6 results count
Results exceeding the level cap are moved to droppedResults.
mandatoryRules
Real-world ranking systems (ATP, WTA) require that results from certain tournament levels always count toward a player's ranking, even if those results are worse than results from lower-tier events. The mandatoryRules array on a counting bucket enforces this:
{
bucketName: 'Singles',
eventTypes: ['SINGLES'],
pointComponents: ['positionPoints', 'perWinPoints', 'bonusPoints'],
bestOfCount: 19,
mandatoryRules: [
{ ruleName: 'Grand Slams', levels: [1] }, // all GS results count
{ ruleName: 'WTA 1000 Combined', levels: [3], bestOfCount: 6 }, // best 6 of L3
],
}
Each MandatoryRule has:
| Field | Description |
|---|---|
ruleName | Optional label for debugging/display |
levels | Tournament levels whose results are mandatory |
bestOfCount | If set, only the best N results from these levels are mandatory; otherwise all count |
Algorithm:
- After scoring and sorting,
maxResultsPerLevelcaps are applied first. - For each mandatory rule, matching results are selected (best N if
bestOfCountis set, otherwise all). - Mandatory results fill counting slots first.
- Remaining
bestOfCountslots are filled with the best non-mandatory results. - If mandatory results exceed
bestOfCount, all mandatory results still count — mandatory rules take priority.
Example: A player with bestOfCount=4 and a Grand Slam mandatory rule who has 3 optional results at 500, 400, 300 points and 1 Grand Slam result at 10 points:
- Without mandatory: best 4 would be impossible (only 4 total), all count = 1210
- With mandatory: GS 10pts counts + best 3 optional (500+400+300) = 1210
The difference becomes clear when there are more results than slots — the mandatory result displaces a higher-scoring optional result that would otherwise count.
Without Counting Buckets
When no countingBuckets are defined, all awards are treated as a single group. The value for each award is points + qualityWinPoints. Global bestOfCount and maxResultsPerLevel from aggregationRules apply:
aggregationRules: {
bestOfCount: 10, // best 10 results overall
maxResultsPerLevel: { 7: 3 }, // max 3 level-7 results
}
Rolling Period
Filter awards by date to implement rolling ranking windows:
aggregationRules: {
rollingPeriodDays: 365, // only awards from the last 365 days
}
Awards are filtered by their endDate field. Awards without an endDate are always included. The cutoff is calculated from asOfDate:
generateRankingList({
pointAwards: allAwards,
aggregationRules: { rollingPeriodDays: 365 },
asOfDate: '2025-12-31', // only awards ending after 2024-12-31
});
Point Decay
A policy can make older results count for less than recent ones. Decay is applied after the rolling-period filter, to native and carried awards alike, using each award's age: the days from its endDate to asOfDate.
aggregationRules: {
rollingPeriodDays: 365,
decayFunction: 'stepped', // 'none' | 'linear' | 'stepped'
decaySteps: [
{ afterDays: 180, multiplier: 0.5 }, // results older than 26 weeks count half
{ afterDays: 270, multiplier: 0.25 },
],
}
decayFunction | Factor for an award of age d days |
|---|---|
'none' | 1 (also the behaviour when decayFunction is absent) |
'linear' | 1 - d / rollingPeriodDays, floored at 0. Without rollingPeriodDays there is no slope, so the factor is 1 |
'stepped' | The multiplier of the largest afterDays the award has reached; 1 before the first step. Step order does not matter |
The factor scales every point component on the award (points, positionPoints, perWinPoints, bonusPoints, qualityWinPoints, linePoints), so a bucket summing granular components and one summing points report the same total. Values are not rounded. A decayed award carries decayFactor so the list can explain why it counts for less than it was awarded; awards without an endDate, or a list without asOfDate, are not decayed. The input awards are never mutated.
The constants NO_DECAY, LINEAR_DECAY and STEPPED_DECAY are exported from rankingConstants.
Category Filter
Filter awards before aggregation by category, gender, or event type:
generateRankingList({
pointAwards: allAwards,
categoryFilter: {
ageCategoryCodes: ['U18'],
genders: ['MALE'],
eventTypes: ['SINGLES'],
},
});
Minimum Countable Results
Flag entries that don't have enough results to be considered for official ranking:
aggregationRules: {
minCountableResults: 3,
}
Entries below the threshold have meetsMinimum: false but are still included in the output with their points and rank.
Cross-Category Aggregation
categoryAggregation rules carry results earned in one category into another category's list (e.g. USTA's 20% next-older carry, or a combined-ranking pool). Each rule names a source and a target CategoryScope; awards matching the source are carried when the list being generated matches the target.
eligibleSourceWindow
Limits which age groups a rule may carry from, counted in age groups, not years:
categoryAggregation: [
{
ruleName: '12U Combined Ranking pool',
source: { ageCategoryCodes: ['12U', '14U', '16U', '18U'] },
target: { ageCategoryCodes: ['12U'] },
eligibleSourceWindow: { olderBy: 2 }, // 14U and 16U, not 18U
},
];
Age groups are ordered by the maximum age each junior code admits (14U → 14, U18 → 17). The ladder is the distinct groups named by the rule's source and target, plus any carried by the awards. olderBy and youngerBy each limit one side; a side the rule leaves undeclared is not limited.
A window that cannot be evaluated is never applied silently. If the target category, or a source category the rule names, cannot be ordered (OPEN, 35O, an unparseable code), or the target names several codes and the categoryFilter does not pick one, the rule is skipped and a RankingListWarning with code UNORDERABLE_AGE_CATEGORY is reported through onWarning. Under a source scope that names no categories, an individual award whose category cannot be ordered is not carried, and is reported the same way.
retentionMonthsAfterAging
When a player ages up, results they earned in their previous (younger) age group can stay eligible on their new group's list for a limited time:
{
source: { ageCategoryCodes: ['12U', '14U'] },
target: { ageCategoryCodes: ['14U'] },
retentionMonthsAfterAging: 12,
}
A carried result from an age group younger than the target stays eligible until the player's aging-up date plus the retention period, measured at asOfDate. The aging-up date is the day the player became too old for that group: birth date + (the group's maximum age + 1) years — a player born 2012-03-01 leaves 12U on 2025-03-01, so 12 months' retention keeps their 12U results through 2026-03-01. Results from the same or an older group (playing up) are not affected.
The birth date comes from the caller:
generateRankingList({
pointAwards,
aggregationRules,
categoryFilter: { ageCategoryCodes: ['14U'] },
asOfDate: '2026-06-01',
participantContext: { birthDate: (personId) => birthDates[personId] }, // ISO YYYY-MM-DD or undefined
onWarning,
});
Without participantContext.birthDate (or without asOfDate) the rule cannot be applied: younger-category results are carried as they were before the rule was honoured, and one MISSING_PARTICIPANT_CONTEXT warning is reported. A player whose birth date is unknown or unparseable is treated the same way, counted in one MISSING_BIRTH_DATE warning per rule.
Warnings
const warnings = [];
const list = generateRankingList({
pointAwards,
aggregationRules,
categoryFilter,
asOfDate,
onWarning: (w) => warnings.push(w),
});
// warnings: [{ code: 'UNORDERABLE_AGE_CATEGORY', ruleName, ageCategoryCode, message }]
onWarning is optional; without it a skipped rule is still skipped.
Tiebreakers
When two entries have the same totalPoints, tiebreak criteria are applied in order:
aggregationRules: {
tiebreakCriteria: ['highestSingleResult', 'mostCountingResults', 'mostWins'],
}
| Criterion | Resolution |
|---|---|
'highestSingleResult' | Highest individual points value among counting results |
'mostCountingResults' | More counting results wins |
'mostWins' | More total winCount across counting results wins |
'headToHead' | The winner of the two players' head-to-head record, from the caller's headToHead function |
{ levelPoints, eventTypes } | A countback by level: the points of counting results at each listed level in turn |
Tied entries that remain unresolved after all criteria share the same rank.
levelPoints countback
A criterion object compares the tied entries' counting results level by level, in the order listed, until one
earned more points there. eventTypes narrows which results count. The ITF Junior countback (Reg. 11) is Grand Slam
singles, then singles at each grade from J500 down, then the same for doubles:
tiebreakCriteria: [
{ levelPoints: [1, 4, 5, 6, 7, 8, 9], eventTypes: [SINGLES] },
{ levelPoints: [1, 4, 5, 6, 7, 8, 9], eventTypes: [DOUBLES] },
],
headToHead
Awards carry no opponents, so the head-to-head record comes from the caller:
generateRankingList({
pointAwards,
aggregationRules: { tiebreakCriteria: ['headToHead', 'highestSingleResult'] },
// negative: personIdA ranks ahead; positive: behind; 0: level or never met
headToHead: (personIdA, personIdB) => (beat(personIdA, personIdB) ? -1 : beat(personIdB, personIdA) ? 1 : 0),
});
Only the sign of the returned value is used, and the function should be antisymmetric (headToHead(a, b) === -headToHead(b, a)). The criterion applies only when exactly two entries share a points total: across three or more, pairwise results can be circular (A beat B, B beat C, C beat A) and give no consistent order, so a larger tie falls through to the remaining criteria. Without a headToHead function the criterion resolves nothing.
RankingListEntry Output
{
personId: string;
totalPoints: number;
rank: number; // 1-based, tied for equal positions
meetsMinimum: boolean; // false if below minCountableResults
countingResults: PointAward[]; // results that count toward total
droppedResults: PointAward[]; // results excluded by bestOfCount or level cap
bucketTotals?: Record<string, number>; // per-bucket totals keyed by bucketName
bucketBreakdown?: [{ // present when countingBuckets are used
bucketName: string;
countingResults: PointAward[];
droppedResults: PointAward[];
bucketTotal: number;
}];
}
bucketTotals is the summary view of bucketBreakdown — the same numbers keyed by
bucket name, for consumers that persist or display totals without the underlying
awards. It is present whenever bucketBreakdown is. A bucket the policy left unnamed
is keyed by position (bucket-0, bucket-1, …) in both views, and two buckets sharing
a name are summed rather than one overwriting the other.
Awards Exempt From Bucket Limits
A categoryAggregation rule carrying subjectToBucketLimits: false contributes on top
of the bucket's bestOfCount cap rather than competing for a slot. Such an award always
counts, is never dropped, and its value is added to the bucket total:
// 500 + 400 + 50(exempt), bestOfCount: 1 -> totalPoints 550
// the 500 wins the single slot, the 400 is dropped, the exempt 50 is added on top
generateRankingList and getParticipantPoints both honour this. They did not always
agree — getParticipantPoints ignored the flag and under-reported, which is fixed as of
6.37.1 by moving the split into the helper both call.
Per-Participant Breakdown
Use getParticipantPoints to inspect a single participant's counting/dropped breakdown.
It is the per-participant view of exactly the computation generateRankingList performs,
so the two agree on totals for the same awards and rules:
import { scaleEngine } from 'tods-competition-factory';
const { getParticipantPoints } = scaleEngine;
const { buckets, totalPoints } = getParticipantPoints({
pointAwards: allAwards,
personId: 'player-abc',
aggregationRules,
});
Ranking Policy Examples
The factory ships with complete ranking policies that demonstrate real-world aggregation configurations. These are located in src/fixtures/policies/:
ATP (POLICY_RANKING_POINTS_ATP.ts)
The ATP policy demonstrates:
- Separate singles/doubles buckets — Singles best-of-19, Doubles best-of-18
- Mandatory counting — Grand Slams (level 1) and ATP 1000 (levels 3, 4) always count in the Singles bucket, even if the results are worse than optional results
- Rolling 52-week period —
rollingPeriodDays: 364 - No gender/category separation — ATP is a single-gender tour
// from POLICY_RANKING_POINTS_ATP.ts
countingBuckets: [
{
bucketName: 'Singles',
eventTypes: ['SINGLES'],
bestOfCount: 19,
pointComponents: ['positionPoints', 'perWinPoints', 'bonusPoints'],
mandatoryRules: [
{ ruleName: 'Grand Slams', levels: [1] },
{ ruleName: 'ATP 1000', levels: [3, 4] },
],
},
{
bucketName: 'Doubles',
eventTypes: ['DOUBLES'],
bestOfCount: 18,
pointComponents: ['positionPoints', 'perWinPoints', 'bonusPoints'],
},
],
WTA (POLICY_RANKING_POINTS_WTA.ts)
The WTA policy demonstrates:
- Mandatory counting with
bestOfCounton a rule — Grand Slams (level 1) all count; best 6 of the combined WTA 1000 events (level 3) are mandatory viabestOfCount: 6 - Minimum countable results —
minCountableResults: 3 - Tiebreakers —
highestSingleResultthenmostCountingResults
// from POLICY_RANKING_POINTS_WTA.ts
countingBuckets: [
{
bucketName: 'Singles',
eventTypes: ['SINGLES'],
bestOfCount: 19,
pointComponents: ['positionPoints', 'perWinPoints', 'bonusPoints'],
mandatoryRules: [
{ ruleName: 'Grand Slams', levels: [1] },
{ ruleName: 'WTA 1000 Combined', levels: [3], bestOfCount: 6 },
],
},
{
bucketName: 'Doubles',
eventTypes: ['DOUBLES'],
bestOfCount: 12,
pointComponents: ['positionPoints', 'perWinPoints', 'bonusPoints'],
},
],
Both policies also include full award profiles (finishing position points, qualifying bonuses, per-win points) for every tournament level in their respective tours. See the source files for the complete definitions.
Related Documentation
- Scale Engine Overview — Introduction and architecture
- Core API Reference — generateRankingList and getParticipantPoints API
- Ranking Points Pipeline — How per-tournament points are computed
- Quality Win Points — Quality win bonus system
- Ranking Policy — Full policy structure including aggregationRules