volleyballsimtypes 0.0.530 → 0.0.531
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/src/service/match/match-rating.d.ts +13 -1
- package/dist/cjs/src/service/match/match-rating.js +29 -13
- package/dist/cjs/src/service/match/match-rating.test.js +53 -0
- package/dist/esm/src/service/match/match-rating.d.ts +13 -1
- package/dist/esm/src/service/match/match-rating.js +29 -13
- package/dist/esm/src/service/match/match-rating.test.js +53 -0
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Match, MatchTeam } from '.';
|
|
1
|
+
import { Match, MatchScore, MatchTeam } from '.';
|
|
2
2
|
import { MatchContext } from './schemas/match-rating.z';
|
|
3
3
|
export type { MatchContext };
|
|
4
4
|
interface SetScoreVariant {
|
|
@@ -28,6 +28,18 @@ export declare class MatchRating {
|
|
|
28
28
|
private static applyBotPenalty;
|
|
29
29
|
private static computeWeight;
|
|
30
30
|
static calculateProbability(z: number): number;
|
|
31
|
+
/**
|
|
32
|
+
* The FIVB model's probability of each of the six possible set scores, from the rating gap alone.
|
|
33
|
+
*
|
|
34
|
+
* getExpectedResult() has always computed these six numbers and immediately collapsed them into one weighted
|
|
35
|
+
* average. They are exposed here because the SAME distribution is what the Sim draws from to resolve a
|
|
36
|
+
* bot-vs-bot match without simulating it: the model that GRADES a result is then also the model that produces
|
|
37
|
+
* it, so a shortcut result can never disagree with the rating it earns. Extracted rather than duplicated,
|
|
38
|
+
* because a second copy of the cut-points would drift silently.
|
|
39
|
+
*
|
|
40
|
+
* Sums to 1 (P6 is the remainder), and is ordered best-to-worst FOR THE HOME TEAM.
|
|
41
|
+
*/
|
|
42
|
+
static outcomeProbabilities(homeRating: number, awayRating: number): Record<MatchScore, number>;
|
|
31
43
|
getExpectedResult(): number;
|
|
32
44
|
getPoints(team: MatchTeam): number;
|
|
33
45
|
}
|
|
@@ -42,20 +42,36 @@ class MatchRating {
|
|
|
42
42
|
static calculateProbability(z) {
|
|
43
43
|
return 0.5 * (1 + (0, utils_1.erf)(z / Math.SQRT2));
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* The FIVB model's probability of each of the six possible set scores, from the rating gap alone.
|
|
47
|
+
*
|
|
48
|
+
* getExpectedResult() has always computed these six numbers and immediately collapsed them into one weighted
|
|
49
|
+
* average. They are exposed here because the SAME distribution is what the Sim draws from to resolve a
|
|
50
|
+
* bot-vs-bot match without simulating it: the model that GRADES a result is then also the model that produces
|
|
51
|
+
* it, so a shortcut result can never disagree with the rating it earns. Extracted rather than duplicated,
|
|
52
|
+
* because a second copy of the cut-points would drift silently.
|
|
53
|
+
*
|
|
54
|
+
* Sums to 1 (P6 is the remainder), and is ordered best-to-worst FOR THE HOME TEAM.
|
|
55
|
+
*/
|
|
56
|
+
static outcomeProbabilities(homeRating, awayRating) {
|
|
57
|
+
const D = MatchRating.K * (homeRating - awayRating) / 1000;
|
|
58
|
+
return {
|
|
59
|
+
'3-0': MatchRating.calculateProbability(MatchRating.C1 + D),
|
|
60
|
+
'3-1': MatchRating.calculateProbability(MatchRating.C2 + D) - MatchRating.calculateProbability(MatchRating.C1 + D),
|
|
61
|
+
'3-2': MatchRating.calculateProbability(MatchRating.C3 + D) - MatchRating.calculateProbability(MatchRating.C2 + D),
|
|
62
|
+
'2-3': MatchRating.calculateProbability(MatchRating.C4 + D) - MatchRating.calculateProbability(MatchRating.C3 + D),
|
|
63
|
+
'1-3': MatchRating.calculateProbability(MatchRating.C5 + D) - MatchRating.calculateProbability(MatchRating.C4 + D),
|
|
64
|
+
'0-3': 1 - MatchRating.calculateProbability(MatchRating.C5 + D)
|
|
65
|
+
};
|
|
66
|
+
}
|
|
45
67
|
getExpectedResult() {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return P1 * MatchRating.SSV['3-0'] +
|
|
54
|
-
P2 * MatchRating.SSV['3-1'] +
|
|
55
|
-
P3 * MatchRating.SSV['3-2'] +
|
|
56
|
-
P4 * MatchRating.SSV['2-3'] +
|
|
57
|
-
P5 * MatchRating.SSV['1-3'] +
|
|
58
|
-
P6 * MatchRating.SSV['0-3'];
|
|
68
|
+
const P = MatchRating.outcomeProbabilities(this.match.homeTeam.rating, this.match.awayTeam.rating);
|
|
69
|
+
return P['3-0'] * MatchRating.SSV['3-0'] +
|
|
70
|
+
P['3-1'] * MatchRating.SSV['3-1'] +
|
|
71
|
+
P['3-2'] * MatchRating.SSV['3-2'] +
|
|
72
|
+
P['2-3'] * MatchRating.SSV['2-3'] +
|
|
73
|
+
P['1-3'] * MatchRating.SSV['1-3'] +
|
|
74
|
+
P['0-3'] * MatchRating.SSV['0-3'];
|
|
59
75
|
}
|
|
60
76
|
getPoints(team) {
|
|
61
77
|
const homeScoreKey = this.match.getScore();
|
|
@@ -91,6 +91,59 @@ const number_utils_1 = require("../utils/number-utils");
|
|
|
91
91
|
(0, globals_1.expect)(mr1.getExpectedResult()).toBeCloseTo(-mr2.getExpectedResult(), 8);
|
|
92
92
|
});
|
|
93
93
|
});
|
|
94
|
+
// ─── outcomeProbabilities ─────────────────────────────────────────────────────
|
|
95
|
+
(0, globals_1.describe)('MatchRating.outcomeProbabilities()', () => {
|
|
96
|
+
const SCORES = ['3-0', '3-1', '3-2', '2-3', '1-3', '0-3'];
|
|
97
|
+
(0, globals_1.it)('sums to 1 for any rating gap', () => {
|
|
98
|
+
for (const [h, a] of [[1000, 1000], [1500, 1000], [1000, 1500], [100, 100], [130, 70]]) {
|
|
99
|
+
const P = match_rating_1.MatchRating.outcomeProbabilities(h, a);
|
|
100
|
+
const total = SCORES.reduce((sum, key) => sum + P[key], 0);
|
|
101
|
+
(0, globals_1.expect)(total).toBeCloseTo(1, 10);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
(0, globals_1.it)('is never negative', () => {
|
|
105
|
+
for (const [h, a] of [[1000, 1000], [5000, 100], [100, 5000]]) {
|
|
106
|
+
const P = match_rating_1.MatchRating.outcomeProbabilities(h, a);
|
|
107
|
+
for (const key of SCORES)
|
|
108
|
+
(0, globals_1.expect)(P[key]).toBeGreaterThanOrEqual(0);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
(0, globals_1.it)('is symmetric between the two sides at equal rating', () => {
|
|
112
|
+
// 8 rather than 10 decimals: erf() is a series approximation, so the mirrored buckets agree to ~1e-9.
|
|
113
|
+
const P = match_rating_1.MatchRating.outcomeProbabilities(100, 100);
|
|
114
|
+
(0, globals_1.expect)(P['3-0']).toBeCloseTo(P['0-3'], 8);
|
|
115
|
+
(0, globals_1.expect)(P['3-1']).toBeCloseTo(P['1-3'], 8);
|
|
116
|
+
(0, globals_1.expect)(P['3-2']).toBeCloseTo(P['2-3'], 8);
|
|
117
|
+
});
|
|
118
|
+
(0, globals_1.it)('mirrors when the gap is reversed', () => {
|
|
119
|
+
const P = match_rating_1.MatchRating.outcomeProbabilities(130, 70);
|
|
120
|
+
const Q = match_rating_1.MatchRating.outcomeProbabilities(70, 130);
|
|
121
|
+
(0, globals_1.expect)(P['3-0']).toBeCloseTo(Q['0-3'], 8);
|
|
122
|
+
(0, globals_1.expect)(P['3-1']).toBeCloseTo(Q['1-3'], 8);
|
|
123
|
+
(0, globals_1.expect)(P['3-2']).toBeCloseTo(Q['2-3'], 8);
|
|
124
|
+
});
|
|
125
|
+
(0, globals_1.it)('shifts weight toward the stronger side as the gap widens', () => {
|
|
126
|
+
const even = match_rating_1.MatchRating.outcomeProbabilities(100, 100);
|
|
127
|
+
const wide = match_rating_1.MatchRating.outcomeProbabilities(160, 40);
|
|
128
|
+
(0, globals_1.expect)(wide['3-0']).toBeGreaterThan(even['3-0']);
|
|
129
|
+
(0, globals_1.expect)(wide['0-3']).toBeLessThan(even['0-3']);
|
|
130
|
+
});
|
|
131
|
+
// The extraction guarantee: getExpectedResult() must still be the SSV-weighted sum of exactly these six
|
|
132
|
+
// numbers. If someone changes one and not the other, this fails.
|
|
133
|
+
(0, globals_1.it)('reproduces getExpectedResult() as the SSV-weighted sum', () => {
|
|
134
|
+
for (const [h, a] of [[1000, 1000], [1500, 1000], [1000, 1500], [130, 70]]) {
|
|
135
|
+
const home = (0, test_helpers_1.makeTeam)({ rating: h });
|
|
136
|
+
const away = (0, test_helpers_1.makeTeam)({ rating: a });
|
|
137
|
+
const mr = match_rating_1.MatchRating.create({
|
|
138
|
+
match: (0, test_helpers_1.makeMatch30)(home, away),
|
|
139
|
+
context: { kind: 'LEAGUE', tier: 1, homeIsBot: false, awayIsBot: false }
|
|
140
|
+
});
|
|
141
|
+
const P = match_rating_1.MatchRating.outcomeProbabilities(h, a);
|
|
142
|
+
const expected = SCORES.reduce((sum, key) => sum + P[key] * match_rating_1.MatchRating.SSV[key], 0);
|
|
143
|
+
(0, globals_1.expect)(mr.getExpectedResult()).toBeCloseTo(expected, 10);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
94
147
|
// ─── getPoints ────────────────────────────────────────────────────────────────
|
|
95
148
|
(0, globals_1.describe)('MatchRating.getPoints()', () => {
|
|
96
149
|
(0, globals_1.it)('home and away deltas are opposite signs', () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Match, MatchTeam } from '.';
|
|
1
|
+
import { Match, MatchScore, MatchTeam } from '.';
|
|
2
2
|
import { MatchContext } from './schemas/match-rating.z';
|
|
3
3
|
export type { MatchContext };
|
|
4
4
|
interface SetScoreVariant {
|
|
@@ -28,6 +28,18 @@ export declare class MatchRating {
|
|
|
28
28
|
private static applyBotPenalty;
|
|
29
29
|
private static computeWeight;
|
|
30
30
|
static calculateProbability(z: number): number;
|
|
31
|
+
/**
|
|
32
|
+
* The FIVB model's probability of each of the six possible set scores, from the rating gap alone.
|
|
33
|
+
*
|
|
34
|
+
* getExpectedResult() has always computed these six numbers and immediately collapsed them into one weighted
|
|
35
|
+
* average. They are exposed here because the SAME distribution is what the Sim draws from to resolve a
|
|
36
|
+
* bot-vs-bot match without simulating it: the model that GRADES a result is then also the model that produces
|
|
37
|
+
* it, so a shortcut result can never disagree with the rating it earns. Extracted rather than duplicated,
|
|
38
|
+
* because a second copy of the cut-points would drift silently.
|
|
39
|
+
*
|
|
40
|
+
* Sums to 1 (P6 is the remainder), and is ordered best-to-worst FOR THE HOME TEAM.
|
|
41
|
+
*/
|
|
42
|
+
static outcomeProbabilities(homeRating: number, awayRating: number): Record<MatchScore, number>;
|
|
31
43
|
getExpectedResult(): number;
|
|
32
44
|
getPoints(team: MatchTeam): number;
|
|
33
45
|
}
|
|
@@ -39,20 +39,36 @@ export class MatchRating {
|
|
|
39
39
|
static calculateProbability(z) {
|
|
40
40
|
return 0.5 * (1 + erf(z / Math.SQRT2));
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* The FIVB model's probability of each of the six possible set scores, from the rating gap alone.
|
|
44
|
+
*
|
|
45
|
+
* getExpectedResult() has always computed these six numbers and immediately collapsed them into one weighted
|
|
46
|
+
* average. They are exposed here because the SAME distribution is what the Sim draws from to resolve a
|
|
47
|
+
* bot-vs-bot match without simulating it: the model that GRADES a result is then also the model that produces
|
|
48
|
+
* it, so a shortcut result can never disagree with the rating it earns. Extracted rather than duplicated,
|
|
49
|
+
* because a second copy of the cut-points would drift silently.
|
|
50
|
+
*
|
|
51
|
+
* Sums to 1 (P6 is the remainder), and is ordered best-to-worst FOR THE HOME TEAM.
|
|
52
|
+
*/
|
|
53
|
+
static outcomeProbabilities(homeRating, awayRating) {
|
|
54
|
+
const D = MatchRating.K * (homeRating - awayRating) / 1000;
|
|
55
|
+
return {
|
|
56
|
+
'3-0': MatchRating.calculateProbability(MatchRating.C1 + D),
|
|
57
|
+
'3-1': MatchRating.calculateProbability(MatchRating.C2 + D) - MatchRating.calculateProbability(MatchRating.C1 + D),
|
|
58
|
+
'3-2': MatchRating.calculateProbability(MatchRating.C3 + D) - MatchRating.calculateProbability(MatchRating.C2 + D),
|
|
59
|
+
'2-3': MatchRating.calculateProbability(MatchRating.C4 + D) - MatchRating.calculateProbability(MatchRating.C3 + D),
|
|
60
|
+
'1-3': MatchRating.calculateProbability(MatchRating.C5 + D) - MatchRating.calculateProbability(MatchRating.C4 + D),
|
|
61
|
+
'0-3': 1 - MatchRating.calculateProbability(MatchRating.C5 + D)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
42
64
|
getExpectedResult() {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return P1 * MatchRating.SSV['3-0'] +
|
|
51
|
-
P2 * MatchRating.SSV['3-1'] +
|
|
52
|
-
P3 * MatchRating.SSV['3-2'] +
|
|
53
|
-
P4 * MatchRating.SSV['2-3'] +
|
|
54
|
-
P5 * MatchRating.SSV['1-3'] +
|
|
55
|
-
P6 * MatchRating.SSV['0-3'];
|
|
65
|
+
const P = MatchRating.outcomeProbabilities(this.match.homeTeam.rating, this.match.awayTeam.rating);
|
|
66
|
+
return P['3-0'] * MatchRating.SSV['3-0'] +
|
|
67
|
+
P['3-1'] * MatchRating.SSV['3-1'] +
|
|
68
|
+
P['3-2'] * MatchRating.SSV['3-2'] +
|
|
69
|
+
P['2-3'] * MatchRating.SSV['2-3'] +
|
|
70
|
+
P['1-3'] * MatchRating.SSV['1-3'] +
|
|
71
|
+
P['0-3'] * MatchRating.SSV['0-3'];
|
|
56
72
|
}
|
|
57
73
|
getPoints(team) {
|
|
58
74
|
const homeScoreKey = this.match.getScore();
|
|
@@ -89,6 +89,59 @@ describe('MatchRating.getExpectedResult()', () => {
|
|
|
89
89
|
expect(mr1.getExpectedResult()).toBeCloseTo(-mr2.getExpectedResult(), 8);
|
|
90
90
|
});
|
|
91
91
|
});
|
|
92
|
+
// ─── outcomeProbabilities ─────────────────────────────────────────────────────
|
|
93
|
+
describe('MatchRating.outcomeProbabilities()', () => {
|
|
94
|
+
const SCORES = ['3-0', '3-1', '3-2', '2-3', '1-3', '0-3'];
|
|
95
|
+
it('sums to 1 for any rating gap', () => {
|
|
96
|
+
for (const [h, a] of [[1000, 1000], [1500, 1000], [1000, 1500], [100, 100], [130, 70]]) {
|
|
97
|
+
const P = MatchRating.outcomeProbabilities(h, a);
|
|
98
|
+
const total = SCORES.reduce((sum, key) => sum + P[key], 0);
|
|
99
|
+
expect(total).toBeCloseTo(1, 10);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
it('is never negative', () => {
|
|
103
|
+
for (const [h, a] of [[1000, 1000], [5000, 100], [100, 5000]]) {
|
|
104
|
+
const P = MatchRating.outcomeProbabilities(h, a);
|
|
105
|
+
for (const key of SCORES)
|
|
106
|
+
expect(P[key]).toBeGreaterThanOrEqual(0);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
it('is symmetric between the two sides at equal rating', () => {
|
|
110
|
+
// 8 rather than 10 decimals: erf() is a series approximation, so the mirrored buckets agree to ~1e-9.
|
|
111
|
+
const P = MatchRating.outcomeProbabilities(100, 100);
|
|
112
|
+
expect(P['3-0']).toBeCloseTo(P['0-3'], 8);
|
|
113
|
+
expect(P['3-1']).toBeCloseTo(P['1-3'], 8);
|
|
114
|
+
expect(P['3-2']).toBeCloseTo(P['2-3'], 8);
|
|
115
|
+
});
|
|
116
|
+
it('mirrors when the gap is reversed', () => {
|
|
117
|
+
const P = MatchRating.outcomeProbabilities(130, 70);
|
|
118
|
+
const Q = MatchRating.outcomeProbabilities(70, 130);
|
|
119
|
+
expect(P['3-0']).toBeCloseTo(Q['0-3'], 8);
|
|
120
|
+
expect(P['3-1']).toBeCloseTo(Q['1-3'], 8);
|
|
121
|
+
expect(P['3-2']).toBeCloseTo(Q['2-3'], 8);
|
|
122
|
+
});
|
|
123
|
+
it('shifts weight toward the stronger side as the gap widens', () => {
|
|
124
|
+
const even = MatchRating.outcomeProbabilities(100, 100);
|
|
125
|
+
const wide = MatchRating.outcomeProbabilities(160, 40);
|
|
126
|
+
expect(wide['3-0']).toBeGreaterThan(even['3-0']);
|
|
127
|
+
expect(wide['0-3']).toBeLessThan(even['0-3']);
|
|
128
|
+
});
|
|
129
|
+
// The extraction guarantee: getExpectedResult() must still be the SSV-weighted sum of exactly these six
|
|
130
|
+
// numbers. If someone changes one and not the other, this fails.
|
|
131
|
+
it('reproduces getExpectedResult() as the SSV-weighted sum', () => {
|
|
132
|
+
for (const [h, a] of [[1000, 1000], [1500, 1000], [1000, 1500], [130, 70]]) {
|
|
133
|
+
const home = makeTeam({ rating: h });
|
|
134
|
+
const away = makeTeam({ rating: a });
|
|
135
|
+
const mr = MatchRating.create({
|
|
136
|
+
match: makeMatch30(home, away),
|
|
137
|
+
context: { kind: 'LEAGUE', tier: 1, homeIsBot: false, awayIsBot: false }
|
|
138
|
+
});
|
|
139
|
+
const P = MatchRating.outcomeProbabilities(h, a);
|
|
140
|
+
const expected = SCORES.reduce((sum, key) => sum + P[key] * MatchRating.SSV[key], 0);
|
|
141
|
+
expect(mr.getExpectedResult()).toBeCloseTo(expected, 10);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
92
145
|
// ─── getPoints ────────────────────────────────────────────────────────────────
|
|
93
146
|
describe('MatchRating.getPoints()', () => {
|
|
94
147
|
it('home and away deltas are opposite signs', () => {
|