Skip to main content

pickems/simulation/
swiss_system.rs

1use std::{ops::Neg, simd::StdFloat};
2
3use arrayvec::ArrayVec;
4use rand::prelude::*;
5use std::simd::prelude::*;
6
7use crate::{
8    datatypes::{Index, Rating, Set},
9    simulation::Matchups,
10};
11
12/// Mutable state for one Swiss-system tournament iteration.
13#[derive(Debug, Clone, Copy)]
14pub struct SwissSystem {
15    /// Match wins per team.
16    pub wins: [u8; 16],
17    /// Match losses per team.
18    pub losses: [u8; 16],
19    /// Win-loss differential per team, used for record-group sorting.
20    pub diffs: [i8; 16],
21    /// Opponents already faced by each team.
22    pub opponents: [Set; 16],
23    /// Best-of-one win probability matrix, indexed by `[team_a][team_b]`.
24    pub probabilities_bo1: [[f32; 16]; 16],
25    /// Best-of-three win probability matrix, indexed by `[team_a][team_b]`.
26    pub probabilities_bo3: [[f32; 16]; 16],
27    /// Team ratings sorted by initial seed.
28    pub ratings: [Rating; 16],
29    /// Teams that have not yet advanced or been eliminated.
30    pub remaining: Set,
31    /// Number of completed tournament rounds.
32    pub rounds_complete: u8,
33}
34
35impl SwissSystem {
36    // Lane values mirror zero-based seed indices.
37    const SEED_LANES: Simd<u16, 16> = {
38        let mut seeds = [0; 16];
39        let mut i = 1;
40
41        while i < 16 {
42            seeds[i] = i as u16;
43            i += 1;
44        }
45
46        Simd::from_array(seeds)
47    };
48
49    // Mask for the initial seed portion of a packed seeding `u16`.
50    const INITIAL_SEED_MASK: u16 = 0x1F;
51
52    #[allow(clippy::many_single_char_names)]
53    #[must_use]
54    #[cfg_attr(feature = "pprof", inline(never))]
55    /// Create a fresh tournament state and precompute matchup probabilities.
56    pub fn new(ratings: [Rating; 16], sigma: f32) -> Self {
57        const ONE: Simd<f32, 16> = Simd::splat(1.0);
58        const TWO: Simd<f32, 16> = Simd::splat(2.0);
59        let mut r = [0.0_f32; 16];
60
61        for i in 0..16 {
62            r[i] = ratings[i].to_f32();
63        }
64
65        // Precalculate independent map win probabilities for every possible
66        // matchup. Each row fixes team A and compares it against all team B
67        // ratings in SIMD lanes.
68        //
69        // let Ra = team A rating,  Rb = team B rating,  P = team A win probablity
70        // P(Ra, Rb) = 1 / (1 + 10^((Rb - Ra) / sigma))
71        //
72        // `powf` in SIMD compatible operations: x^y => exp(ln(x) * y)
73        //
74        // P(Ra, Rb) = recip(1 + exp(ln(10) * (Rb - Ra) / sigma))
75        //           = recip(1 + exp(u * (Rb - Ra))),  where u = ln(10) / sigma
76        let u = Simd::splat(10.0_f32.ln()) / Simd::splat(sigma);
77        let rb = Simd::from_array(r);
78        let mut probabilities_bo1 = [[0.0; 16]; 16];
79
80        for i in 0..16 {
81            let ra = Simd::splat(r[i]);
82            probabilities_bo1[i] = (ONE + (u * (rb - ra)).exp()).recip().to_array();
83        }
84
85        // Precalculate best-of-three series win probabilities from the map
86        // probabilities. A team wins the series by WW, WLW, or LWW.
87        //
88        // let Q = series win probability,  P = map win probability
89        // Q(W) = P
90        // Q(L) = 1 - P
91        // Q(WW-) = P * P
92        // Q(WLW) = Q(LWW) = P * P * (1 - P)
93        //
94        // let a = P * P,  b = 1 - P
95        // Q = Q(WLW) + Q(LWW) + Q(WW-)
96        //   = P * P * (1 - P) + P * P * (1 - P) + P * P
97        //   = 2 * a * b + a
98        let mut probabilities_bo3 = [[0.0; 16]; 16];
99
100        for i in 0..16 {
101            let p = Simd::from_array(probabilities_bo1[i]);
102            let a = p * p;
103            let b = ONE - p;
104            probabilities_bo3[i] = TWO.mul_add(a * b, a).to_array();
105        }
106
107        let wins = [0; 16];
108        let losses = [0; 16];
109        let diffs = [0; 16];
110        let opponents = [Set::new(); 16];
111
112        Self {
113            wins,
114            losses,
115            diffs,
116            opponents,
117            probabilities_bo1,
118            probabilities_bo3,
119            ratings,
120            remaining: Set::full(),
121            rounds_complete: 0,
122        }
123    }
124
125    /// Reset Swiss System state to restart tournament.
126    #[cfg_attr(feature = "pprof", inline(never))]
127    #[cfg_attr(not(feature = "pprof"), inline)]
128    pub const fn reset(&mut self) {
129        self.wins = [0; 16];
130        self.losses = [0; 16];
131        self.diffs = [0; 16];
132        self.opponents = [Set::new(); 16];
133        self.remaining = Set::full();
134        self.rounds_complete = 0;
135    }
136
137    /// Return the Buchholz difficulty score for a given team.
138    #[cfg_attr(feature = "pprof", inline(never))]
139    fn buchholz(&self, team: Index) -> i8 {
140        const ONE: Simd<u16, 16> = Simd::splat(1);
141
142        let mask = {
143            // Shift the opponent bitset by the lane index so each lane's low
144            // bit says whether that seed has been played. Negating 0/1 gives
145            // 0 or -1, which can be used as an all-bits mask for `diffs`.
146            let shifted = self.opponents[team.to_usize()].splat() >> Self::SEED_LANES;
147            (shifted & ONE).cast::<i8>().neg()
148        };
149
150        (Simd::from_array(self.diffs) & mask).reduce_sum()
151    }
152
153    /// Return remaining team indices sorted by mid-stage seed calculation.
154    ///
155    /// 1. Current win-loss record (higher -> lower seed)
156    /// 2. Buchholz difficulty score (sum of win-loss record for each opponent faced, higher -> lower seed)
157    /// 3. Initial seeding
158    ///
159    /// [Rules and Regs - Mid-stage Seed Calculation](https://github.com/ValveSoftware/counter-strike_rules_and_regs/blob/main/major-supplemental-rulebook.md#Mid-Stage-Seed-Calculation)
160    #[allow(clippy::cast_sign_loss)]
161    #[cfg_attr(feature = "pprof", inline(never))]
162    pub(super) fn seed_teams(&self) -> ArrayVec<Index, 16> {
163        let mut seeding = ArrayVec::<u16, 16>::new();
164
165        // Match only teams that remain in the tournament.
166        for index in self.remaining.iter() {
167            // Win-loss and Buchholz difficulty must be inverted and encoded into unsigned integers.
168            let diff = (15 - self.diffs[index.to_usize()]) as u16;
169            let buchholz = (15 - self.buchholz(index)) as u16;
170
171            // Each piece of seeding information is small enough to fit into 5 bits.
172            // Bit-pack each piece into a 16-bit unsigned integer so that one
173            // unstable integer sort applies every tiebreak in priority order:
174            //
175            // [15] [14 13 12 11 10] [9 8 7 6 5] [4 3 2 1 0]
176            //  --   --------------   ---------   ---------
177            //   |          |             |           |
178            // Spare        |    2. Buchholz diff.    |
179            //         1. Win-loss             3. Initial seed
180            //
181            seeding.push(diff << 10 | buchholz << 5 | index.to_u16());
182        }
183
184        seeding.sort_unstable();
185
186        // Strip back down to just the zero-based initial seed.
187        for packed_seed in &mut seeding {
188            *packed_seed &= Self::INITIAL_SEED_MASK;
189        }
190
191        // `Index` is a transparent newtype of `u16`, `packed_seed` has been masked
192        // down to only the intial seed which is known to be in `0..16`.
193        unsafe { std::mem::transmute(seeding) }
194    }
195
196    /// Simulate one independent match and update records, opponents, and status.
197    #[cfg_attr(feature = "pprof", inline(never))]
198    fn simulate_match<R: rand::Rng>(&mut self, rng: &mut R, seed_a: Index, seed_b: Index) {
199        let r = rng.random();
200        let a = seed_a.to_usize();
201        let b = seed_b.to_usize();
202
203        // Advancement and elimination matches are BO3; all other matches are BO1.
204        let is_bo3 = self.wins[a] == 2 || self.losses[a] == 2;
205
206        // Simulate match outcome.
207        let p = if is_bo3 {
208            self.probabilities_bo3[a][b]
209        } else {
210            self.probabilities_bo1[a][b]
211        };
212
213        let team_a_win = p > r;
214
215        // Update team records.
216        if team_a_win {
217            self.wins[a] += 1;
218            self.losses[b] += 1;
219            self.diffs[a] += 1;
220            self.diffs[b] -= 1;
221        } else {
222            self.losses[a] += 1;
223            self.wins[b] += 1;
224            self.diffs[a] -= 1;
225            self.diffs[b] += 1;
226        }
227
228        self.opponents[a].insert(seed_b);
229        self.opponents[b].insert(seed_a);
230
231        // A team can only reach three wins or losses in a BO3 round, so status
232        // changes are limited to advancement/elimination matches.
233        if is_bo3 {
234            if self.wins[a] == 3 || self.losses[a] == 3 {
235                self.remaining.remove(seed_a);
236            }
237
238            if self.wins[b] == 3 || self.losses[b] == 3 {
239                self.remaining.remove(seed_b);
240            }
241        }
242    }
243
244    /// Simulate one tournament round.
245    #[cfg_attr(feature = "pprof", inline(never))]
246    #[cfg_attr(not(feature = "pprof"), inline)]
247    fn simulate_round<R: rand::Rng>(&mut self, rng: &mut R) {
248        for (a, b) in Matchups::new(self) {
249            self.simulate_match(rng, a, b);
250        }
251
252        self.rounds_complete += 1;
253    }
254
255    /// Simulate all five Swiss rounds.
256    #[cfg_attr(feature = "pprof", inline(never))]
257    #[cfg_attr(not(feature = "pprof"), inline)]
258    pub fn simulate_tournament<R: rand::Rng>(&mut self, rng: &mut R) {
259        while self.rounds_complete < 5 {
260            self.simulate_round(rng);
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use std::ops::{AddAssign, Div, Sub};
268
269    use super::*;
270
271    use crate::{datatypes::Teams, simulation::rng};
272
273    macro_rules! set {
274        ($($n:expr),*) => {
275            [$(Index::new::<$n>(),)*].into_iter().collect()
276        };
277    }
278
279    /// Exact regression test, will break if the seeding algorithm changes.
280    /// Uses fake RNG to isolate algorithmic changes from micro statistical changes.
281    #[test]
282    fn exact_regression_test() {
283        let mut ss = SwissSystem::new(Teams::dummy().ratings, 800.0);
284        ss.simulate_tournament(&mut rng::HalfRng);
285
286        assert_eq!(ss.wins, [3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 1, 1, 1, 0, 0]);
287        assert_eq!(ss.losses, [0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3]);
288        assert_eq!(
289            ss.opponents,
290            [
291                set!(3, 7, 8),
292                set!(2, 6, 9),
293                set!(1, 5, 7, 10),
294                set!(0, 4, 6, 11),
295                set!(3, 5, 11, 12),
296                set!(2, 4, 9, 10, 13),
297                set!(1, 3, 8, 9, 14),
298                set!(0, 2, 8, 10, 15),
299                set!(0, 6, 7, 13, 15),
300                set!(1, 5, 6, 12, 14),
301                set!(2, 5, 7, 11, 13),
302                set!(3, 4, 10, 12),
303                set!(4, 9, 11, 15),
304                set!(5, 8, 10, 14),
305                set!(6, 9, 13),
306                set!(7, 8, 12),
307            ]
308        );
309    }
310
311    /// Statistical regression test, will break on material distribution changes.
312    #[test]
313    #[allow(clippy::cast_sign_loss, clippy::unreadable_literal)]
314    fn statistical_regression_test() {
315        const ITERATIONS: usize = 100_000;
316        const ITER_SPLAT: Simd<f32, 16> = Simd::splat(ITERATIONS as f32);
317        const TOLERANCE: Simd<f32, 16> = Simd::splat(0.005);
318        const THREE: Simd<u8, 16> = Simd::splat(3);
319        const ZERO: Simd<u8, 16> = Simd::splat(0);
320
321        let fresh_ss = SwissSystem::new(Teams::dummy().ratings, 800.0);
322        let mut rng = rng::deterministic();
323        let mut total_three_zero: Simd<u64, 16> = Simd::splat(0);
324        let mut total_advancing: Simd<u64, 16> = Simd::splat(0);
325        let mut total_zero_three: Simd<u64, 16> = Simd::splat(0);
326
327        for _ in 0..ITERATIONS {
328            let mut ss = fresh_ss;
329            ss.simulate_tournament(&mut rng);
330
331            let wins = Simd::from_array(ss.wins);
332            let losses = Simd::from_array(ss.losses);
333
334            let three_wins = wins.simd_eq(THREE);
335            let zero_wins = wins.simd_eq(ZERO);
336            let three_losses = losses.simd_eq(THREE);
337            let zero_losses = losses.simd_eq(ZERO);
338
339            total_three_zero.add_assign((three_wins & zero_losses).to_simd().abs().cast());
340            total_advancing.add_assign((three_wins & !zero_losses).to_simd().abs().cast());
341            total_zero_three.add_assign((zero_wins & three_losses).to_simd().abs().cast());
342        }
343
344        let expected_three_zero = Simd::from_array([
345            0.467134, 0.381915, 0.30356, 0.239474, 0.18577, 0.141047, 0.106158, 0.077854, 0.029252,
346            0.022126, 0.016032, 0.010871, 0.007562, 0.005146, 0.003569, 0.00253,
347        ]);
348
349        let expected_advancing = Simd::from_array([
350            0.482817, 0.542122, 0.585634, 0.604685, 0.604085, 0.584402, 0.547824, 0.497656,
351            0.394673, 0.324406, 0.258943, 0.199543, 0.148371, 0.105615, 0.071796, 0.047428,
352        ]);
353
354        let expected_zero_three = Simd::from_array([
355            0.002564, 0.003679, 0.005201, 0.007576, 0.010758, 0.01579, 0.021963, 0.029273, 0.07803,
356            0.105759, 0.141881, 0.18603, 0.238006, 0.303477, 0.383103, 0.46691,
357        ]);
358
359        for (actual, expected) in [
360            (total_three_zero.cast().div(ITER_SPLAT), expected_three_zero),
361            (total_advancing.cast().div(ITER_SPLAT), expected_advancing),
362            (total_zero_three.cast().div(ITER_SPLAT), expected_zero_three),
363        ] {
364            assert!(
365                actual.sub(expected).abs().simd_lt(TOLERANCE).all(),
366                "Actual: {actual:#?}\n\nExpected: {expected:#?}"
367            );
368        }
369    }
370}