Skip to main content

rbp_gameplay/
size.rs

1use super::*;
2use rbp_cards::*;
3use rbp_core::*;
4
5/// Abstract bet sizing for Edge::Raise.
6///
7/// Two interpretation modes:
8/// - `SPR(n, d)`: Pot-relative sizing as fraction n/d (e.g., `SPR(1, 2)` = half pot)
9/// - `BBs(n)`: BB-relative sizing for preflop opens (e.g., `BBs(3)` = 3BB)
10#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, Ord, PartialOrd)]
11pub enum Size {
12    SPR(Chips, Chips),
13    BBs(Chips),
14}
15
16impl Size {
17    /// Converts Size to chip amount.
18    pub fn into_chips(self, pot: Chips) -> Chips {
19        match self {
20            Self::SPR(n, d) => (pot as Utility * n as Utility / d as Utility) as Chips,
21            Self::BBs(n) => n * rbp_core::B_BLIND,
22        }
23    }
24    /// Snaps a chip amount to the nearest canonical size.
25    /// At opening spots, snaps to BB; otherwise pot-relative.
26    pub fn from_chips(
27        chips: Chips,
28        pot: Chips,
29        opening: bool,
30        street: Street,
31        depth: usize,
32    ) -> Self {
33        let raises = Self::raises(street, depth);
34        if opening {
35            Self::nearest_bb(chips, raises)
36        } else {
37            Self::nearest_pot(chips, pot, raises)
38        }
39    }
40    fn nearest_bb(chips: Chips, raises: &[Self]) -> Self {
41        let target = chips / rbp_core::B_BLIND;
42        raises
43            .iter()
44            .filter_map(|s| match s {
45                Self::BBs(n) => Some((*n, *s)),
46                Self::SPR(..) => None,
47            })
48            .min_by_key(|(n, _)| (target as i64 - *n as i64).abs())
49            .map(|(_, s)| s)
50            .unwrap_or(Self::BBs(2))
51    }
52    fn nearest_pot(chips: Chips, pot: Chips, raises: &[Self]) -> Self {
53        let target = chips as Utility / pot as Utility;
54        raises
55            .iter()
56            .filter_map(|s| match s {
57                Self::SPR(n, d) => Some((*n as Probability / *d as Probability, *s)),
58                Self::BBs(_) => None,
59            })
60            .min_by(|(a, _), (b, _)| (target - a).abs().partial_cmp(&(target - b).abs()).unwrap())
61            .map(|(_, s)| s)
62            .unwrap_or(Self::SPR(1, 1))
63    }
64    /// Converts to Odds for interop with code expecting Odds type.
65    /// BBs variant returns synthetic n:1 odds.
66    pub fn odds(self) -> Odds {
67        match self {
68            Self::SPR(n, d) => Odds::new(n, d),
69            Self::BBs(n) => Odds::new(n, 1),
70        }
71    }
72    /// Returns the equivalent SPR form for backwards compatibility.
73    /// BBs(n) maps to SPR(n, 1), SPR stays unchanged.
74    pub fn as_spr(self) -> Self {
75        match self {
76            Self::BBs(n) => Self::SPR(n, 1),
77            spr => spr,
78        }
79    }
80    /// Returns available raise sizes for a given street and depth.
81    /// This is the **single source of truth** for which betting edges exist.
82    pub fn raises(street: Street, depth: usize) -> &'static [Self] {
83        if depth > rbp_core::MAX_RAISE_REPEATS {
84            return &[];
85        }
86        match (street, depth) {
87            (Street::Pref, 0) => &Self::PREF_0,
88            (Street::Pref, 1) => &Self::PREF_1,
89            (Street::Pref, _) => &Self::PREF_N,
90            (Street::Flop, 0) => &Self::FLOP_0,
91            (Street::Flop, 1) => &Self::FLOP_1,
92            (Street::Flop, _) => &Self::FLOP_N,
93            (Street::Turn, 0) => &Self::TURN_0,
94            (Street::Turn, _) => &Self::TURN_N,
95            (Street::Rive, 0) => &Self::RIVE_0,
96            (Street::Rive, 1) => &Self::RIVE_1,
97            (Street::Rive, _) => &Self::RIVE_N,
98        }
99    }
100}
101
102/// Blinds values used in preflop opening (must fit in u8 6-9).
103const BLINDS_GRID: [Chips; 4] = [2, 3, 4, 8];
104/// Pot-relative sizes actually used in raises (must fit in u8 10-15, Path uses 4-bit encoding).
105const SPR_GRID: [Size; 6] = [
106    Size::SPR(1, 3), // 0.33 pot
107    Size::SPR(1, 2), // 0.50 pot
108    Size::SPR(2, 3), // 0.66 pot
109    Size::SPR(1, 1), // 1.00 pot
110    Size::SPR(3, 2), // 1.50 pot
111    Size::SPR(2, 1), // 2.00 pot
112];
113
114#[rustfmt::skip]
115impl Size {
116    const PREF_0: [Self; 4] = [Self::BBs(2), Self::BBs(3), Self::BBs(4), Self::BBs(8)];             // Preflop depth=0: BB opens
117    const PREF_1: [Self; 3] = [Self::SPR(1, 1), Self::SPR(3, 2), Self::SPR(2, 1)];                  // Preflop depth=1: 3-bet sizing (1x, 1.5x, 2x pot)
118    const PREF_N: [Self; 2] = [Self::SPR(1, 1), Self::SPR(2, 1)];                                   // Preflop depth=2+: 4-bet+ (1x, 2x pot)
119    const FLOP_0: [Self; 4] = [Self::SPR(1, 3), Self::SPR(1, 2), Self::SPR(1, 1), Self::SPR(2, 1)]; // Flop depth=0: probe bet (1/3 instead of 1/4 due to encoding limit)
120    const FLOP_1: [Self; 3] = [Self::SPR(2, 3), Self::SPR(1, 1), Self::SPR(3, 2)];                  // Flop depth=1: after first raise (2/3x, 1x, 1.5x)
121    const FLOP_N: [Self; 2] = [Self::SPR(1, 1), Self::SPR(3, 2)];                                   // Flop depth=2+: simplified (1x, 1.5x pot)
122    const TURN_0: [Self; 4] = [Self::SPR(1, 3), Self::SPR(2, 3), Self::SPR(1, 1), Self::SPR(2, 1)]; // Turn depth=0: geometric sizing for river setup
123    const TURN_N: [Self; 2] = [Self::SPR(1, 1), Self::SPR(3, 2)];                                   // Turn depth=1+: simplified (1x, 1.5x pot)
124    const RIVE_0: [Self; 4] = [Self::SPR(1, 3), Self::SPR(1, 2), Self::SPR(1, 1), Self::SPR(2, 1)]; // River depth=0: full range including overbets
125    const RIVE_1: [Self; 3] = [Self::SPR(2, 3), Self::SPR(1, 1), Self::SPR(2, 1)];                  // River depth=1: raise (2/3x, 1x, 2x pot)
126    const RIVE_N: [Self; 1] = [Self::SPR(1, 1)];                                                    // River depth=2+: minimal
127}
128
129impl From<Odds> for Size {
130    fn from(odds: Odds) -> Self {
131        Self::SPR(odds.numer(), odds.denom())
132    }
133}
134
135/// u8 bijection: encodes to values 6-15 to avoid collision with Edge's 1-5.
136/// Layout: 6-9 = BBs(BLINDS_GRID[i-6]), 10-15 = SPR(SPR_GRID[i-10])
137impl From<Size> for u8 {
138    fn from(size: Size) -> Self {
139        match size {
140            Size::BBs(n) => {
141                6 + BLINDS_GRID
142                    .iter()
143                    .position(|&b| b == n)
144                    .expect("invalid blinds value") as u8
145            }
146            Size::SPR(..) => {
147                10 + SPR_GRID
148                    .iter()
149                    .position(|&s| s == size)
150                    .expect("invalid SPR value") as u8
151            }
152        }
153    }
154}
155impl From<u8> for Size {
156    fn from(value: u8) -> Self {
157        match value {
158            6..=9 => Self::BBs(BLINDS_GRID[value as usize - 6]),
159            10..=15 => SPR_GRID[value as usize - 10],
160            _ => panic!("invalid size encoding: {}", value),
161        }
162    }
163}
164/// u64 bijection: tag in low bits, value in high bits
165impl From<Size> for u64 {
166    fn from(size: Size) -> Self {
167        match size {
168            Size::BBs(n) => (1 << 19) | ((n as u64) << 3),
169            Size::SPR(n, d) => ((n as u64) << 3) | ((d as u64) << 11),
170        }
171    }
172}
173impl From<u64> for Size {
174    fn from(value: u64) -> Self {
175        if value & (1 << 19) != 0 {
176            Self::BBs(((value >> 3) & 0xFF) as Chips)
177        } else {
178            Self::SPR(
179                ((value >> 3) & 0xFF) as Chips,
180                ((value >> 11) & 0xFF) as Chips,
181            )
182        }
183    }
184}
185impl std::fmt::Display for Size {
186    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
187        match self {
188            Self::SPR(n, d) => write!(f, "{}:{}", n, d),
189            Self::BBs(n) => write!(f, "{}bb", n),
190        }
191    }
192}
193impl TryFrom<&str> for Size {
194    type Error = anyhow::Error;
195    fn try_from(s: &str) -> Result<Self, Self::Error> {
196        if let Some(bb) = s.strip_suffix("bb") {
197            return bb
198                .parse::<Chips>()
199                .map(Self::BBs)
200                .map_err(|e| anyhow::anyhow!("invalid bb format: {}", e));
201        }
202        if let Some((n, d)) = s.split_once(':') {
203            let n = n
204                .parse::<Chips>()
205                .map_err(|e| anyhow::anyhow!("invalid SPR numerator: {}", e))?;
206            let d = d
207                .parse::<Chips>()
208                .map_err(|e| anyhow::anyhow!("invalid SPR denominator: {}", e))?;
209            return Ok(Self::SPR(n, d));
210        }
211        Err(anyhow::anyhow!("invalid size format: {}", s))
212    }
213}
214impl Arbitrary for Size {
215    fn random() -> Self {
216        use rand::prelude::IndexedRandom;
217        let ref mut rng = rand::rng();
218        let all_sizes: Vec<Self> = BLINDS_GRID
219            .iter()
220            .map(|&n| Self::BBs(n))
221            .chain(SPR_GRID.iter().copied())
222            .collect();
223        *all_sizes.choose(rng).expect("sizes empty")
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use rbp_core::MAX_RAISE_REPEATS;
231    /// Verify the raises grid returns expected counts per street/depth.
232    /// This is the authoritative spec for action abstraction branching factor.
233    #[test]
234    fn raises_grid_counts() {
235        // Preflop: BB opens with BB-relative sizes, then pot-relative
236        assert_eq!(Size::raises(Street::Pref, 0).len(), 4); // 2BB, 3BB, 4BB, 8BB
237        assert_eq!(Size::raises(Street::Pref, 1).len(), 3); // 1x, 1.5x, 2x pot
238        assert_eq!(Size::raises(Street::Pref, 2).len(), 2); // 1x, 2x pot
239        assert_eq!(Size::raises(Street::Pref, 3).len(), 2); // 1x, 2x pot
240        // Flop: includes probe bets at depth=0
241        assert_eq!(Size::raises(Street::Flop, 0).len(), 4); // 1/3, 1/2, 1x, 2x
242        assert_eq!(Size::raises(Street::Flop, 1).len(), 3); // 2/3, 1x, 1.5x
243        assert_eq!(Size::raises(Street::Flop, 2).len(), 2); // 1x, 1.5x
244        // Turn: geometric sizing for river setup
245        assert_eq!(Size::raises(Street::Turn, 0).len(), 4); // 1/3, 2/3, 1x, 2x
246        assert_eq!(Size::raises(Street::Turn, 1).len(), 2); // 1x, 1.5x
247        // River: overbets matter more
248        assert_eq!(Size::raises(Street::Rive, 0).len(), 4); // 1/3, 1/2, 1x, 2x
249        assert_eq!(Size::raises(Street::Rive, 1).len(), 3); // 2/3, 1x, 2x
250        // Beyond MAX_RAISE_REPEATS: empty (no more raises allowed)
251        assert_eq!(Size::raises(Street::Pref, MAX_RAISE_REPEATS + 1).len(), 0);
252    }
253    /// Preflop depth=0 must use BBs variant (BB-relative sizing).
254    #[test]
255    fn preflop_opening_uses_bbs() {
256        for size in Size::raises(Street::Pref, 0) {
257            assert!(
258                matches!(size, Size::BBs(_)),
259                "preflop depth=0 should use BBs, got {:?}",
260                size
261            );
262        }
263    }
264    /// Post-flop and preflop depth>0 must use SPR variant (pot-relative).
265    #[test]
266    fn postflop_uses_spr() {
267        for street in [Street::Flop, Street::Turn, Street::Rive] {
268            for depth in 0..=MAX_RAISE_REPEATS {
269                for size in Size::raises(street, depth) {
270                    assert!(
271                        matches!(size, Size::SPR(..)),
272                        "{:?} depth={} should use SPR, got {:?}",
273                        street,
274                        depth,
275                        size
276                    );
277                }
278            }
279        }
280        for depth in 1..=MAX_RAISE_REPEATS {
281            for size in Size::raises(Street::Pref, depth) {
282                assert!(
283                    matches!(size, Size::SPR(..)),
284                    "preflop depth={} should use SPR, got {:?}",
285                    depth,
286                    size
287                );
288            }
289        }
290    }
291    /// All sizes returned by raises() must be encodable to u8 and back.
292    #[test]
293    fn all_raises_are_encodable() {
294        for street in [Street::Pref, Street::Flop, Street::Turn, Street::Rive] {
295            for depth in 0..=MAX_RAISE_REPEATS {
296                for &size in Size::raises(street, depth) {
297                    let encoded = u8::from(size);
298                    let decoded = Size::from(encoded);
299                    assert_eq!(size, decoded, "roundtrip failed for {:?}", size);
300                }
301            }
302        }
303    }
304    /// u8 bijection: encode then decode preserves value.
305    #[test]
306    fn bijective_u8() {
307        for &n in &BLINDS_GRID {
308            let size = Size::BBs(n);
309            assert_eq!(size, Size::from(u8::from(size)));
310        }
311        for &size in &SPR_GRID {
312            assert_eq!(size, Size::from(u8::from(size)));
313        }
314    }
315    /// u64 bijection: encode then decode preserves value.
316    #[test]
317    fn bijective_u64() {
318        for &n in &BLINDS_GRID {
319            let size = Size::BBs(n);
320            assert_eq!(size, Size::from(u64::from(size)));
321        }
322        for &size in &SPR_GRID {
323            assert_eq!(size, Size::from(u64::from(size)));
324        }
325    }
326    /// into_chips: BBs variant multiplies by B_BLIND.
327    #[test]
328    fn into_chips_bbs() {
329        let pot = 100; // ignored for BBs
330        assert_eq!(Size::BBs(2).into_chips(pot), 2 * rbp_core::B_BLIND);
331        assert_eq!(Size::BBs(3).into_chips(pot), 3 * rbp_core::B_BLIND);
332        assert_eq!(Size::BBs(8).into_chips(pot), 8 * rbp_core::B_BLIND);
333    }
334    /// into_chips: SPR variant multiplies pot by fraction.
335    #[test]
336    fn into_chips_spr() {
337        let pot = 100;
338        assert_eq!(Size::SPR(1, 2).into_chips(pot), 50); // half pot
339        assert_eq!(Size::SPR(1, 1).into_chips(pot), 100); // full pot
340        assert_eq!(Size::SPR(2, 1).into_chips(pot), 200); // 2x pot
341    }
342    /// from_chips snaps to nearest canonical size.
343    #[test]
344    fn from_chips_snaps_to_nearest() {
345        let pot = 100;
346        // Opening spot (preflop depth=0): snaps to BBs
347        let size = Size::from_chips(5, pot, true, Street::Pref, 0);
348        assert!(matches!(size, Size::BBs(2) | Size::BBs(3)));
349        // Non-opening: snaps to SPR
350        let size = Size::from_chips(75, pot, false, Street::Flop, 0);
351        assert!(matches!(size, Size::SPR(..)));
352    }
353    /// SPR_GRID must contain all SPR sizes used in any raises() call.
354    #[test]
355    fn spr_grid_is_complete() {
356        let mut all_spr = std::collections::HashSet::new();
357        for street in [Street::Pref, Street::Flop, Street::Turn, Street::Rive] {
358            for depth in 0..=MAX_RAISE_REPEATS {
359                for &size in Size::raises(street, depth) {
360                    if matches!(size, Size::SPR(..)) {
361                        all_spr.insert(size);
362                    }
363                }
364            }
365        }
366        for size in all_spr {
367            assert!(SPR_GRID.contains(&size), "SPR_GRID missing {:?}", size);
368        }
369    }
370    /// BLINDS_GRID must contain all BB values used in raises().
371    #[test]
372    fn blinds_grid_is_complete() {
373        let mut all_bbs = std::collections::HashSet::new();
374        for street in [Street::Pref, Street::Flop, Street::Turn, Street::Rive] {
375            for depth in 0..=MAX_RAISE_REPEATS {
376                for size in Size::raises(street, depth) {
377                    if let Size::BBs(n) = size {
378                        all_bbs.insert(*n);
379                    }
380                }
381            }
382        }
383        for n in all_bbs {
384            assert!(BLINDS_GRID.contains(&n), "BLINDS_GRID missing {}", n);
385        }
386    }
387    /// Display format: BBs shows "Nbb", SPR shows ratio format.
388    #[test]
389    fn display_format() {
390        assert_eq!(format!("{}", Size::BBs(3)), "3bb");
391        assert_eq!(format!("{}", Size::SPR(1, 2)), "1:2");
392        assert_eq!(format!("{}", Size::SPR(1, 1)), "1:1");
393        assert_eq!(format!("{}", Size::SPR(3, 2)), "3:2");
394        assert_eq!(format!("{}", Size::SPR(2, 1)), "2:1");
395        assert_eq!(format!("{}", Size::SPR(1, 3)), "1:3");
396        assert_eq!(format!("{}", Size::SPR(2, 3)), "2:3");
397    }
398    /// String parsing roundtrip: parse(display(size)) == size.
399    #[test]
400    fn string_roundtrip() {
401        for &n in &BLINDS_GRID {
402            let size = Size::BBs(n);
403            let s = size.to_string();
404            assert_eq!(Size::try_from(s.as_str()).unwrap(), size);
405        }
406        for &size in &SPR_GRID {
407            let s = size.to_string();
408            assert_eq!(Size::try_from(s.as_str()).unwrap(), size);
409        }
410    }
411}