Skip to content

Commit 54ff45b

Browse files
committed
RiverEval (src/play/stages/river_eval.rs):
- Mirrors FlopEval/TurnEval — same struct shape with game, case_eval, wins, results - TryFrom<Game> — fails with NotDealt if any board card is missing or no hands present - rank_for_player(i) — ranks a player's 7-card hand against the complete board - The subtle test catch: player 1 (5♦ 5♣) hits four fives on 9♣ 6♦ 5♥ 5♠ 8♠, beating player 0's full house — good domain knowledge to have in the tests Versus additions: - remaining_at_river() — filters the river card from villain range - combined_odds_at_river() — direct Eval::from(Seven) comparison per villain hand, no enumeration needed, returns Result<WinLoseDraw, PKError>
1 parent df91e46 commit 54ff45b

7 files changed

Lines changed: 656 additions & 1 deletion

File tree

src/analysis/ev.rs

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
//! Expected value (EV) calculations for poker decisions.
2+
//!
3+
//! [`Ev`] answers the question: "how many chips do I expect to win or lose on average
4+
//! by calling this bet?"
5+
//!
6+
//! The calculation is grounded in [`WinLoseDraw`] integer counts and [`PotOdds`] chip
7+
//! amounts — no floating-point equity input required. The EV formula is:
8+
//!
9+
//! ```text
10+
//! EV × total_outcomes = wins × pot - losses × call
11+
//! ```
12+
//!
13+
//! Draws are a push (call returned, pot unchanged), so their EV contribution is zero.
14+
//! The sign of the numerator is sufficient to make a call/fold decision without division.
15+
16+
use crate::analysis::gto::odds::WinLoseDraw;
17+
use crate::analysis::pot_odds::PotOdds;
18+
use std::fmt::Display;
19+
20+
/// Expected value of a call, derived from outcome counts and pot geometry.
21+
///
22+
/// Construct with a [`WinLoseDraw`] from any equity calculation and the current
23+
/// [`PotOdds`]. Use [`is_positive`](Ev::is_positive) for the call/fold decision
24+
/// and [`as_chips`](Ev::as_chips) for display or logging.
25+
///
26+
/// # Examples
27+
/// ```
28+
/// use pkcore::analysis::ev::Ev;
29+
/// use pkcore::analysis::gto::odds::WinLoseDraw;
30+
/// use pkcore::analysis::pot_odds::PotOdds;
31+
///
32+
/// // Hero has 70% equity, calling 100 into a 200 pot
33+
/// let odds = WinLoseDraw { wins: 7, losses: 3, draws: 0 };
34+
/// let po = PotOdds::new(200, 100);
35+
/// let ev = Ev::new(odds, po);
36+
/// assert!(ev.is_positive());
37+
/// ```
38+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
39+
pub struct Ev {
40+
/// The win/loss/draw outcome distribution from an equity calculation.
41+
pub odds: WinLoseDraw,
42+
/// The pot size and call amount for this decision point.
43+
pub pot_odds: PotOdds,
44+
}
45+
46+
impl Ev {
47+
/// Creates a new [`Ev`] from a [`WinLoseDraw`] and [`PotOdds`].
48+
///
49+
/// # Examples
50+
/// ```
51+
/// use pkcore::analysis::ev::Ev;
52+
/// use pkcore::analysis::gto::odds::WinLoseDraw;
53+
/// use pkcore::analysis::pot_odds::PotOdds;
54+
///
55+
/// let ev = Ev::new(WinLoseDraw::default(), PotOdds::new(100, 50));
56+
/// assert!(!ev.is_positive());
57+
/// ```
58+
#[must_use]
59+
pub fn new(odds: WinLoseDraw, pot_odds: PotOdds) -> Self {
60+
Self { odds, pot_odds }
61+
}
62+
63+
/// The signed EV numerator: `wins × pot - losses × call`.
64+
///
65+
/// Divide by [`total`](Self::total) to get EV in chip units.
66+
/// The sign alone is sufficient for a call/fold decision via [`is_positive`](Self::is_positive).
67+
///
68+
/// # Examples
69+
/// ```
70+
/// use pkcore::analysis::ev::Ev;
71+
/// use pkcore::analysis::gto::odds::WinLoseDraw;
72+
/// use pkcore::analysis::pot_odds::PotOdds;
73+
///
74+
/// let odds = WinLoseDraw { wins: 7, losses: 3, draws: 0 };
75+
/// let ev = Ev::new(odds, PotOdds::new(200, 100));
76+
/// assert!(ev.numerator() > 0);
77+
/// ```
78+
#[must_use]
79+
pub fn numerator(&self) -> i64 {
80+
let wins = self.odds.wins as i64;
81+
let losses = self.odds.losses as i64;
82+
let pot = self.pot_odds.pot as i64;
83+
let call = self.pot_odds.call as i64;
84+
wins * pot - losses * call
85+
}
86+
87+
/// The total number of outcomes (wins + losses + draws).
88+
///
89+
/// # Examples
90+
/// ```
91+
/// use pkcore::analysis::ev::Ev;
92+
/// use pkcore::analysis::gto::odds::WinLoseDraw;
93+
/// use pkcore::analysis::pot_odds::PotOdds;
94+
///
95+
/// let odds = WinLoseDraw { wins: 6, losses: 3, draws: 1 };
96+
/// let ev = Ev::new(odds, PotOdds::new(100, 50));
97+
/// assert_eq!(ev.total(), 10);
98+
/// ```
99+
#[must_use]
100+
pub fn total(&self) -> u64 {
101+
self.odds.total()
102+
}
103+
104+
/// Returns `true` if calling is +EV.
105+
///
106+
/// Uses integer arithmetic only — no float conversion needed for the decision.
107+
///
108+
/// # Examples
109+
/// ```
110+
/// use pkcore::analysis::ev::Ev;
111+
/// use pkcore::analysis::gto::odds::WinLoseDraw;
112+
/// use pkcore::analysis::pot_odds::PotOdds;
113+
///
114+
/// // Exactly breakeven (50% equity, pot-sized bet) → not profitable
115+
/// let odds = WinLoseDraw { wins: 1, losses: 1, draws: 0 };
116+
/// let ev = Ev::new(odds, PotOdds::new(100, 100));
117+
/// assert!(!ev.is_positive());
118+
/// ```
119+
#[must_use]
120+
pub fn is_positive(&self) -> bool {
121+
self.numerator() > 0
122+
}
123+
124+
/// EV in chip units as `f64`, for display and logging.
125+
///
126+
/// Returns `0.0` if there are no outcomes.
127+
///
128+
/// # Examples
129+
/// ```
130+
/// use pkcore::analysis::ev::Ev;
131+
/// use pkcore::analysis::gto::odds::WinLoseDraw;
132+
/// use pkcore::analysis::pot_odds::PotOdds;
133+
///
134+
/// // 70% equity, calling 100 into 200: EV = (7×200 - 3×100) / 10 = 110 chips
135+
/// let odds = WinLoseDraw { wins: 7, losses: 3, draws: 0 };
136+
/// let ev = Ev::new(odds, PotOdds::new(200, 100));
137+
/// assert!((ev.as_chips() - 110.0).abs() < f64::EPSILON);
138+
/// ```
139+
#[must_use]
140+
pub fn as_chips(&self) -> f64 {
141+
let total = self.total();
142+
if total == 0 {
143+
return 0.0;
144+
}
145+
self.numerator() as f64 / total as f64
146+
}
147+
}
148+
149+
impl Display for Ev {
150+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151+
write!(
152+
f,
153+
"Ev {{ {}, ev: {:.2} chips }}",
154+
self.pot_odds,
155+
self.as_chips()
156+
)
157+
}
158+
}
159+
160+
#[cfg(test)]
161+
#[allow(non_snake_case)]
162+
mod ev_tests {
163+
use super::*;
164+
165+
#[test]
166+
fn test_ev_positive_when_ahead() {
167+
// 70% equity vs pot-sized bet → +EV
168+
let odds = WinLoseDraw { wins: 7, losses: 3, draws: 0 };
169+
let ev = Ev::new(odds, PotOdds::new(100, 100));
170+
assert!(ev.is_positive());
171+
}
172+
173+
#[test]
174+
fn test_ev_negative_when_behind() {
175+
// 30% equity vs pot-sized bet → -EV
176+
let odds = WinLoseDraw { wins: 3, losses: 7, draws: 0 };
177+
let ev = Ev::new(odds, PotOdds::new(100, 100));
178+
assert!(!ev.is_positive());
179+
}
180+
181+
#[test]
182+
fn test_ev_zero_at_breakeven() {
183+
// Exactly 50% equity, pot-sized bet → EV = 0, not positive
184+
let odds = WinLoseDraw { wins: 1, losses: 1, draws: 0 };
185+
let ev = Ev::new(odds, PotOdds::new(100, 100));
186+
assert_eq!(ev.numerator(), 0);
187+
assert!(!ev.is_positive());
188+
}
189+
190+
#[test]
191+
fn test_ev_as_chips_correct() {
192+
// (7×200 - 3×100) / 10 = (1400 - 300) / 10 = 110
193+
let odds = WinLoseDraw { wins: 7, losses: 3, draws: 0 };
194+
let ev = Ev::new(odds, PotOdds::new(200, 100));
195+
assert!((ev.as_chips() - 110.0).abs() < f64::EPSILON);
196+
}
197+
198+
#[test]
199+
fn test_ev_as_chips_zero_outcomes() {
200+
let ev = Ev::new(WinLoseDraw::default(), PotOdds::new(100, 50));
201+
assert_eq!(ev.as_chips(), 0.0);
202+
}
203+
204+
#[test]
205+
fn test_ev_draws_do_not_affect_decision() {
206+
// Draws are a push — adding draws should not change the numerator
207+
let odds_no_draws = WinLoseDraw { wins: 6, losses: 4, draws: 0 };
208+
let odds_with_draws = WinLoseDraw { wins: 6, losses: 4, draws: 10 };
209+
let po = PotOdds::new(100, 100);
210+
let ev_no_draws = Ev::new(odds_no_draws, po);
211+
let ev_with_draws = Ev::new(odds_with_draws, po);
212+
assert_eq!(ev_no_draws.numerator(), ev_with_draws.numerator());
213+
assert_eq!(ev_no_draws.is_positive(), ev_with_draws.is_positive());
214+
}
215+
216+
#[test]
217+
fn test_ev_total() {
218+
let odds = WinLoseDraw { wins: 6, losses: 3, draws: 1 };
219+
let ev = Ev::new(odds, PotOdds::new(100, 50));
220+
assert_eq!(ev.total(), 10);
221+
}
222+
223+
#[test]
224+
fn test_ev_display() {
225+
let odds = WinLoseDraw { wins: 7, losses: 3, draws: 0 };
226+
let ev = Ev::new(odds, PotOdds::new(100, 100));
227+
let s = ev.to_string();
228+
assert!(s.contains("Ev {"));
229+
assert!(s.contains("ev:"));
230+
}
231+
}

src/analysis/gto/vs.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
use crate::analysis::gto::combos::Combos;
22
use crate::analysis::gto::odds::WinLoseDraw;
33
use crate::analysis::gto::twos::Twos;
4+
use crate::analysis::eval::Eval;
5+
use crate::arrays::seven::Seven;
46
use crate::arrays::two::Two;
57
use crate::bard::Bard;
68
use crate::play::board::Board;
79
use crate::play::game::Game;
810
use crate::play::hole_cards::HoleCards;
911
use crate::play::stages::flop_eval::FlopEval;
10-
use crate::{GTO, PKError, SOK};
12+
use crate::{GTO, PKError, Pile, SOK};
1113
use std::collections::HashMap;
1214

1315
use crate::analysis::store::db::hup::HUPResult;
@@ -171,6 +173,47 @@ impl Versus {
171173
self.remaining_at_flop().filter_on_not_card(self.board.turn)
172174
}
173175

176+
/// The remaining villain hands after removing all five board cards.
177+
///
178+
/// Requires a complete board (flop + turn + river). If the river is not dealt,
179+
/// the result will still include hands containing the river card.
180+
#[must_use]
181+
pub fn remaining_at_river(&self) -> Twos {
182+
self.remaining_at_turn().filter_on_not_card(self.board.river)
183+
}
184+
185+
/// Computes hero vs. villain equity on a complete board (all five cards dealt).
186+
///
187+
/// For each remaining villain hand, the hero's 7-card hand and the villain's 7-card
188+
/// hand are ranked against the full board. The result is a [`WinLoseDraw`] aggregated
189+
/// across all villain combos.
190+
///
191+
/// # Errors
192+
///
193+
/// Returns [`PKError::NotDealt`] if the board is not complete (flop + turn + river).
194+
pub fn combined_odds_at_river(&self) -> Result<WinLoseDraw, PKError> {
195+
if !self.board.flop.is_dealt() || !self.board.turn.is_dealt() || !self.board.river.is_dealt() {
196+
return Err(PKError::NotDealt);
197+
}
198+
199+
let hero_rank = Eval::from(Seven::from_case_and_board(&self.hero, &self.board));
200+
201+
let result = self
202+
.remaining_at_river()
203+
.to_vec()
204+
.iter()
205+
.fold(WinLoseDraw::default(), |acc, villain| {
206+
let villain_rank = Eval::from(Seven::from_case_and_board(villain, &self.board));
207+
match hero_rank.cmp(&villain_rank) {
208+
std::cmp::Ordering::Greater => acc + WinLoseDraw { wins: 1, losses: 0, draws: 0 },
209+
std::cmp::Ordering::Less => acc + WinLoseDraw { wins: 0, losses: 1, draws: 0 },
210+
std::cmp::Ordering::Equal => acc + WinLoseDraw { wins: 0, losses: 0, draws: 1 },
211+
}
212+
});
213+
214+
Ok(result)
215+
}
216+
174217
/// All the `Twos` including ones in the hero's hand.
175218
#[must_use]
176219
pub fn twos(&self) -> Twos {

src/analysis/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use case_evals::CaseEvals;
55

66
pub mod case_eval;
77
pub mod case_evals;
8+
pub mod ev;
89
pub mod class;
910
pub mod eval;
1011
pub mod evals;
@@ -15,6 +16,7 @@ pub mod nubibus;
1516
pub mod omaha;
1617
pub mod outs;
1718
pub mod player_wins;
19+
pub mod pot_odds;
1820
pub mod store;
1921
pub mod the_nuts;
2022

0 commit comments

Comments
 (0)