diff --git a/PokerKit/Sources/PokerKit/Equity.swift b/PokerKit/Sources/PokerKit/Equity.swift new file mode 100644 index 0000000..a14d7c2 --- /dev/null +++ b/PokerKit/Sources/PokerKit/Equity.swift @@ -0,0 +1,283 @@ +import Foundation + +/// The result of an equity calculation between two sides (hero vs. villain) — a single +/// hand, or a range of hands, on either side. `winRate + tieRate + loseRate` always sums to +/// (within floating-point rounding) `1.0`. +public struct EquityResult: Sendable { + /// Fraction of trials hero's hand strictly beats villain's, `0...1`. + public let winRate: Double + /// Fraction of trials hero's and villain's hands are exactly equal (chops the pot). + public let tieRate: Double + /// Fraction of trials villain's hand strictly beats hero's. + public let loseRate: Double + /// How many board completions (exact) or sampled scenarios (Monte Carlo) this result is + /// based on. + public let trials: Int + /// `true` for `Equity.headsUp` (full enumeration — an exact answer, not an estimate). + /// `false` for `Equity.monteCarlo`/`handVsRange`/`rangeVsRange` (a sampled estimate; + /// see `EQUITY.md` for the standard-error math behind its documented tolerance). + public let isExact: Bool +} + +/// A minimal, fully deterministic pseudorandom generator (SplitMix64 — Vigna's public-domain +/// algorithm) used only so `Equity.monteCarlo` can be seeded for reproducible test results. +/// Not cryptographically secure, and not meant to be — equity sampling has no such +/// requirement, and a well-known, easily-reimplemented algorithm is preferable here to an +/// opaque one precisely because reproducibility depends on it. +struct SplitMix64: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { self.state = seed } + + mutating func next() -> UInt64 { + state = state &+ 0x9E3779B97F4A7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 + z = (z ^ (z >> 27)) &* 0x94D049BB133111EB + return z ^ (z >> 31) + } +} + +/// Win/tie/lose equity between two hands or ranges, built entirely on `HandEvaluator` — no +/// shortcuts, no precomputed equity tables. See `ai-docs/EQUITY.md` for the ground-truth +/// numbers this is validated against and this file's performance characteristics. +/// +/// Two calculation modes, matching the two situations that actually differ in what's +/// tractable: +/// +/// - **`headsUp`** — one hand vs. one hand, **exact**: enumerates every possible completion +/// of the board (all remaining 5-card boards preflop, fewer postflop) and evaluates each +/// one. Always tractable for a fixed pair of hands — worst case (preflop, empty board) is +/// `C(48, 5) = 1,712,304` boards, large but finite, no sampling error at all. +/// - **`monteCarlo`** (and its `handVsRange`/`rangeVsRange` convenience wrappers) — either +/// side can be a *range* (multiple possible hands), which exact enumeration can't handle +/// at any real range width (range × range × board-completions blows up fast). Uses a +/// **fixed-seed** deterministic sample instead, so the same call always returns the same +/// number — `swift test` never flakes on equity. +public enum Equity { + static let fullDeck: [Card] = Rank.allCases.flatMap { rank in Suit.allCases.map { Card(rank: rank, suit: $0) } } + + /// Default sample size for Monte Carlo calls that don't specify one. `1/sqrt(50_000) ≈ + /// 0.45%` standard error, i.e. roughly `±0.9%` at a 95% confidence interval — see + /// `EQUITY.md` for why this default was picked and how it was verified in practice. + public static let defaultMonteCarloIterations = 50_000 + + /// Fixed default seed so callers who don't pass one still get fully reproducible + /// results. Not a "real" secret — deliberately a memorable, obviously-fixed constant. + public static let defaultSeed: UInt64 = 0xC0FFEE + + // MARK: - Exact: hand vs. hand + + /// Exact win/tie/lose equity for `hero` vs. `villain`, given zero or more known board + /// cards (0 = preflop, 3 = flop dealt, 4 = turn dealt, 5 = river dealt — though any + /// count 0...5 is accepted). Enumerates every possible completion of the remaining board + /// exhaustively; the result has no sampling error. + /// + /// - Precondition: `hero`, `villain`, and `board` share no cards (a card can't be both + /// in a hole-card hand and on the board, or in both hands). + public static func headsUp(hero: HoleCards, villain: HoleCards, board: [Card] = []) -> EquityResult { + let known = [hero.first, hero.second, villain.first, villain.second] + board + precondition(Set(known).count == known.count, "hero/villain/board share a card") + precondition(board.count <= 5, "board can have at most 5 cards") + + let usedSet = Set(known) + let remaining = fullDeck.filter { !usedSet.contains($0) } + let needed = 5 - board.count + + var wins = 0, ties = 0, losses = 0, total = 0 + var current: [Card] = [] + current.reserveCapacity(needed) + + forEachCombination(of: remaining, choose: needed, current: ¤t) { drawn in + let fullBoard = board + drawn + let heroHand = HandEvaluator.bestHand(from: [hero.first, hero.second] + fullBoard) + let villainHand = HandEvaluator.bestHand(from: [villain.first, villain.second] + fullBoard) + total += 1 + if heroHand > villainHand { wins += 1 } + else if heroHand == villainHand { ties += 1 } + else { losses += 1 } + } + + return EquityResult( + winRate: Double(wins) / Double(total), + tieRate: Double(ties) / Double(total), + loseRate: Double(losses) / Double(total), + trials: total, + isExact: true + ) + } + + // MARK: - Monte Carlo: hand/range vs. hand/range + + /// Fixed-seed Monte Carlo win/tie/lose equity for a hand (or range of hands) on each + /// side, given zero or more known board cards. Each trial samples one concrete hand + /// uniformly from `hero`, one from `villain`, and a uniformly random completion of the + /// remaining board — retrying (up to a bounded number of attempts) whenever a sampled + /// combination collides on a card, so the retry logic can never silently bias the result + /// toward whichever range happened to be listed first. + /// + /// - Parameters: + /// - iterations: target number of valid (non-colliding) trials. + /// - seed: `Equity.defaultSeed` unless overridden — same seed always produces the same + /// result, so tests built on this are never flaky. + public static func monteCarlo( + hero: [HoleCards], + villain: [HoleCards], + board: [Card] = [], + iterations: Int = Equity.defaultMonteCarloIterations, + seed: UInt64 = Equity.defaultSeed + ) -> EquityResult { + precondition(!hero.isEmpty && !villain.isEmpty, "both ranges must be non-empty") + precondition(board.count <= 5, "board can have at most 5 cards") + + var rng = SplitMix64(seed: seed) + let needed = 5 - board.count + let maxAttempts = max(iterations * 50, 10_000) + + var wins = 0, ties = 0, losses = 0, trials = 0, attempts = 0 + + while trials < iterations && attempts < maxAttempts { + attempts += 1 + guard let heroHand = hero.randomElement(using: &rng), + let villainHand = villain.randomElement(using: &rng) else { continue } + + let known = [heroHand.first, heroHand.second, villainHand.first, villainHand.second] + board + let usedSet = Set(known) + guard usedSet.count == known.count else { continue } // card collision — retry + + var pool = fullDeck.filter { !usedSet.contains($0) } + guard pool.count >= needed else { continue } + + var drawn: [Card] = [] + drawn.reserveCapacity(needed) + for _ in 0.. villainStrength { wins += 1 } + else if heroStrength == villainStrength { ties += 1 } + else { losses += 1 } + } + + return EquityResult( + winRate: trials > 0 ? Double(wins) / Double(trials) : 0, + tieRate: trials > 0 ? Double(ties) / Double(trials) : 0, + loseRate: trials > 0 ? Double(losses) / Double(trials) : 0, + trials: trials, + isExact: false + ) + } + + /// Every concrete `HoleCards` combo for a canonical hand string ("AA", "AKs", "72o") — + /// 6 combos for a pair (`C(4,2)`), 4 for suited (one per suit), 12 for offsuit + /// (`4 × 3`). Returns `[]` for a malformed notation, matching `HoleCards(canonical:)`'s + /// own failable-init behavior rather than trapping. + /// + /// This exists because **published "AA vs KK ≈ 82.4%" style equity figures are a + /// combo-weighted average**, not the equity of any single specific suit assignment — + /// see `EQUITY.md`'s "A subtlety: which suits?" section. `canonicalVsCanonical` is what + /// actually reproduces those numbers; `headsUp`/`monteCarlo` answer a more specific + /// question (this exact pair of concrete hands) that happens to coincide with the + /// canonical figure only when the suits are unbiased. + public static func expandCanonical(_ notation: String) -> [HoleCards] { + let chars = Array(notation) + guard chars.count == 2 || chars.count == 3, + let r1 = Rank.from(symbol: chars[0]), + let r2 = Rank.from(symbol: chars[1]) else { return [] } + + if chars.count == 2 { + guard r1 == r2 else { return [] } + var combos: [HoleCards] = [] + let suits = Suit.allCases + for i in 0.. EquityResult { + rangeVsRange( + heroRange: expandCanonical(hero), villainRange: expandCanonical(villain), + board: board, iterations: iterations, seed: seed + ) + } + + /// Convenience: one fixed hero hand vs. a villain range. + public static func handVsRange( + hero: HoleCards, + villainRange: [HoleCards], + board: [Card] = [], + iterations: Int = Equity.defaultMonteCarloIterations, + seed: UInt64 = Equity.defaultSeed + ) -> EquityResult { + monteCarlo(hero: [hero], villain: villainRange, board: board, iterations: iterations, seed: seed) + } + + /// Convenience: a hero range vs. a villain range. + public static func rangeVsRange( + heroRange: [HoleCards], + villainRange: [HoleCards], + board: [Card] = [], + iterations: Int = Equity.defaultMonteCarloIterations, + seed: UInt64 = Equity.defaultSeed + ) -> EquityResult { + monteCarlo(hero: heroRange, villain: villainRange, board: board, iterations: iterations, seed: seed) + } + + // MARK: - Combinatorics helper + + /// Calls `action` once per combination of `k` cards chosen from `pool`, backtracking + /// through a single reused array rather than materializing every combination up front — + /// `headsUp`'s preflop case walks up to 1,712,304 of these, so avoiding an allocation + /// per combination (or holding them all in memory at once) matters here. + private static func forEachCombination( + of pool: [Card], + choose k: Int, + startIndex: Int = 0, + current: inout [Card], + action: ([Card]) -> Void + ) { + if k == 0 { + action(current) + return + } + guard pool.count - startIndex >= k else { return } + for i in startIndex...(pool.count - k) { + current.append(pool[i]) + forEachCombination(of: pool, choose: k - 1, startIndex: i + 1, current: ¤t, action: action) + current.removeLast() + } + } +} diff --git a/PokerKit/Sources/PokerKit/HandEvaluator.swift b/PokerKit/Sources/PokerKit/HandEvaluator.swift new file mode 100644 index 0000000..a4f9bd0 --- /dev/null +++ b/PokerKit/Sources/PokerKit/HandEvaluator.swift @@ -0,0 +1,160 @@ +import Foundation + +/// The nine standard poker hand categories, ordered weakest to lowest `rawValue` so +/// `Comparable` conformance falls straight out of the raw value — no separate ranking +/// table to keep in sync. +public enum HandCategory: Int, Comparable, Sendable, CustomStringConvertible { + case highCard, pair, twoPair, trips, straight, flush, fullHouse, quads, straightFlush + + public static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue } + + public var description: String { + switch self { + case .highCard: return "High Card" + case .pair: return "Pair" + case .twoPair: return "Two Pair" + case .trips: return "Three of a Kind" + case .straight: return "Straight" + case .flush: return "Flush" + case .fullHouse: return "Full House" + case .quads: return "Four of a Kind" + case .straightFlush: return "Straight Flush" + } + } +} + +/// A fully comparable strength for one specific best-5-card poker hand: its category, plus +/// enough rank tiebreakers (most significant first) to break every tie the category leaves +/// open — kicker included. Two `HandStrength`s only ever have differently-shaped +/// `tiebreakers` arrays when they're different categories, and `<`/`==` check category +/// first, so a shape mismatch is never actually compared element-by-element. +public struct HandStrength: Comparable, Sendable { + public let category: HandCategory + public let tiebreakers: [Int] + + public static func < (lhs: Self, rhs: Self) -> Bool { + if lhs.category != rhs.category { return lhs.category < rhs.category } + for (l, r) in zip(lhs.tiebreakers, rhs.tiebreakers) where l != r { + return l < r + } + return false + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.category == rhs.category && lhs.tiebreakers == rhs.tiebreakers + } +} + +/// Evaluates the best possible 5-card poker hand out of 5, 6, or 7 cards — the foundation +/// `Equity` is built on. No shortcuts: every category is derived from actual rank/suit +/// counts, not a lookup table, so it's exercised (and testable) the same way for every hand. +/// +/// **Performance note:** `bestHand(from:)` for 6-7 cards evaluates every 5-card sub-hand +/// (`C(6,5) = 6` or `C(7,5) = 21`) and keeps the best by `Comparable` — the textbook +/// correct approach, deliberately not the fastest one (real solvers use precomputed lookup +/// tables). `Equity`'s exact-enumeration mode calls this millions of times per matchup, so +/// the 7-card path avoids array-allocation overhead where cheap to (a static index table +/// instead of a general combinations generator, fixed-size rank counting instead of a +/// dictionary) — see `EQUITY.md`'s performance note for actual measured timing. +public enum HandEvaluator { + /// The 21 ways to choose 5 indices out of 7, precomputed once — avoids generating this + /// with a general-purpose combinations function on every `bestHand(from:)` call, since + /// this is `Equity`'s hottest inner loop. + private static let sevenChooseFiveIndices: [(Int, Int, Int, Int, Int)] = { + var result: [(Int, Int, Int, Int, Int)] = [] + for a in 0..<7 { + for b in (a + 1)..<7 { + for c in (b + 1)..<7 { + for d in (c + 1)..<7 { + for e in (d + 1)..<7 { + result.append((a, b, c, d, e)) + } + } + } + } + } + return result + }() + + /// The best 5-card `HandStrength` achievable from `cards` (5, 6, or 7 of them). + public static func bestHand(from cards: [Card]) -> HandStrength { + precondition((5...7).contains(cards.count), "bestHand(from:) needs 5-7 cards, got \(cards.count)") + + switch cards.count { + case 5: + return evaluate5(cards[0], cards[1], cards[2], cards[3], cards[4]) + case 7: + var best: HandStrength? + for (a, b, c, d, e) in sevenChooseFiveIndices { + let candidate = evaluate5(cards[a], cards[b], cards[c], cards[d], cards[e]) + if best == nil || candidate > best! { best = candidate } + } + return best! + default: // 6 + var best: HandStrength? + for skip in 0..<6 { + var five: [Card] = [] + five.reserveCapacity(5) + for (i, card) in cards.enumerated() where i != skip { five.append(card) } + let candidate = evaluate5(five[0], five[1], five[2], five[3], five[4]) + if best == nil || candidate > best! { best = candidate } + } + return best! + } + } + + /// Evaluates exactly 5 cards. `private` — always reached through `bestHand(from:)`, the + /// only entry point `Equity` (or a test) should need. + private static func evaluate5(_ a: Card, _ b: Card, _ c: Card, _ d: Card, _ e: Card) -> HandStrength { + var ranks = [a.rank.rawValue, b.rank.rawValue, c.rank.rawValue, d.rank.rawValue, e.rank.rawValue] + ranks.sort(by: >) + + let isFlush = a.suit == b.suit && b.suit == c.suit && c.suit == d.suit && d.suit == e.suit + + var straightHigh: Int? + if Set(ranks).count == 5 { + if ranks[0] - ranks[4] == 4 { + straightHigh = ranks[0] + } else if ranks == [14, 5, 4, 3, 2] { + straightHigh = 5 // the wheel: A-2-3-4-5, plays as a 5-high straight + } + } + + // Rank counts via a fixed 15-slot array (indices 2...14) rather than a Dictionary — + // this function runs millions of times inside Equity's exact enumeration. + var countByRank = [Int](repeating: 0, count: 15) + for r in ranks { countByRank[r] += 1 } + + var groups: [(rank: Int, count: Int)] = [] + for r in stride(from: 14, through: 2, by: -1) where countByRank[r] > 0 { + groups.append((r, countByRank[r])) + } + groups.sort { $0.count != $1.count ? $0.count > $1.count : $0.rank > $1.rank } + + if let high = straightHigh, isFlush { + return HandStrength(category: .straightFlush, tiebreakers: [high]) + } + if groups[0].count == 4 { + return HandStrength(category: .quads, tiebreakers: [groups[0].rank, groups[1].rank]) + } + if groups[0].count == 3, groups.count > 1, groups[1].count >= 2 { + return HandStrength(category: .fullHouse, tiebreakers: [groups[0].rank, groups[1].rank]) + } + if isFlush { + return HandStrength(category: .flush, tiebreakers: ranks) + } + if let high = straightHigh { + return HandStrength(category: .straight, tiebreakers: [high]) + } + if groups[0].count == 3 { + return HandStrength(category: .trips, tiebreakers: [groups[0].rank] + groups[1...].map(\.rank)) + } + if groups[0].count == 2, groups.count > 1, groups[1].count == 2 { + return HandStrength(category: .twoPair, tiebreakers: [groups[0].rank, groups[1].rank, groups[2].rank]) + } + if groups[0].count == 2 { + return HandStrength(category: .pair, tiebreakers: [groups[0].rank] + groups[1...].map(\.rank)) + } + return HandStrength(category: .highCard, tiebreakers: ranks) + } +} diff --git a/PokerKit/Sources/PokerKit/StudyTool.swift b/PokerKit/Sources/PokerKit/StudyTool.swift index 992ad23..5f3a40b 100644 --- a/PokerKit/Sources/PokerKit/StudyTool.swift +++ b/PokerKit/Sources/PokerKit/StudyTool.swift @@ -4,6 +4,7 @@ import Foundation public enum StudyTool: String, CaseIterable, Identifiable, Hashable, Sendable { case preflopRanges case pushFold + case equityCalculator case bankroll case handHistoryImport case drills @@ -14,6 +15,7 @@ public enum StudyTool: String, CaseIterable, Identifiable, Hashable, Sendable { switch self { case .preflopRanges: return "Preflop Ranges" case .pushFold: return "Push/Fold Trainer" + case .equityCalculator: return "Equity Calculator" case .bankroll: return "Bankroll Tracker" case .handHistoryImport: return "Hand History Import & Leaks" case .drills: return "Practice Your Leaks" @@ -26,6 +28,8 @@ public enum StudyTool: String, CaseIterable, Identifiable, Hashable, Sendable { return "Opening (raise-first-in) ranges for standard stacks, plus push/fold shove ranges for short stacks — by position and effective stack." case .pushFold: return "Shove-or-fold decisions for short stacks (~1-20bb), by position and effective stack." + case .equityCalculator: + return "Win/tie/lose probability for any hand or hand class vs. another, on any board — exact math, not a rule of thumb." case .bankroll: return "Buy-ins, cashes, ROI, variance, and bankroll-management guardrails for MTTs." case .handHistoryImport: diff --git a/PokerKit/Tests/PokerKitTests/EquityTests.swift b/PokerKit/Tests/PokerKitTests/EquityTests.swift new file mode 100644 index 0000000..bcc2c9d --- /dev/null +++ b/PokerKit/Tests/PokerKitTests/EquityTests.swift @@ -0,0 +1,227 @@ +import Testing +@testable import PokerKit + +/// Tolerance for the canonical-hand-vs-canonical-hand Monte Carlo ground-truth checks +/// below. At `Equity.defaultMonteCarloIterations` (50,000) the standard error is roughly +/// 0.3-0.5%; the tests here use a larger explicit sample (100,000) for extra margin and +/// assert to `±1.0` percentage points — comfortably wider than the actual measured +/// deviation from the cited reference numbers (all under 0.3pt in practice; see +/// EQUITY.md's "Ground-truth validation" table for the exact figures found while building +/// this). Fixed seed means these never flake: the same call always returns the same number. +private let groundTruthTolerance = 1.0 +private let groundTruthIterations = 100_000 + +private func assertApproximately(_ actual: Double, _ expectedPercent: Double, tolerance: Double = groundTruthTolerance) { + let actualPercent = actual * 100 + #expect( + abs(actualPercent - expectedPercent) <= tolerance, + "expected \(expectedPercent)% ± \(tolerance)%, got \(actualPercent)%" + ) +} + +// MARK: - Ground-truth validation (the whole point of this module) +// +// Every expected number below is cross-checked against cardfight.com's published preflop +// equity pages (fetched while building this feature) — see EQUITY.md for the full citation +// list and a table comparing cited vs. measured numbers. + +@Test func groundTruthAAvsKK() { + // Cited: AA 81.71% / KK 17.82% / tie 0.46% (cardfight.com). Commonly rounded elsewhere + // to "82.4%" — see EQUITY.md for why both figures are compatible with what's measured + // here (this is the combo-weighted number; a single specific suit assignment can + // legitimately land up to ~1pt away from it — see the flagship exact test below). + let result = Equity.canonicalVsCanonical("AA", "KK", iterations: groundTruthIterations) + assertApproximately(result.winRate, 81.71) + assertApproximately(result.loseRate, 17.82) +} + +@Test func groundTruthAKsVsQQ() { + // Cited: QQ 53.73% / AKs 45.83% / tie 0.43% (cardfight.com) — matches the task's own + // "≈46.2%" description within the stated tolerance. + let result = Equity.canonicalVsCanonical("AKs", "QQ", iterations: groundTruthIterations) + assertApproximately(result.winRate, 45.83) + assertApproximately(result.loseRate, 53.73) +} + +@Test func groundTruthAKoVsPocketTwos() { + // Cited: 22 52.34% / AKo 47.04% / tie 0.62% (cardfight.com). Colloquially called "a + // coinflip" in poker slang, but the precise number is a real ~5-point favorite to the + // pair, not literally 50/50 — see EQUITY.md. + let result = Equity.canonicalVsCanonical("AKo", "22", iterations: groundTruthIterations) + assertApproximately(result.winRate, 47.04) + assertApproximately(result.loseRate, 52.34) +} + +@Test func groundTruthAAvsSevenDeuceOffsuit() { + // Cited: AA 87.99% / 72o 11.59% / tie 0.42% (cardfight.com). The task's own "88%+" + // description rounds up very slightly — the precise combo-weighted figure is just + // under 88%, not over it (measured 87.78% here); asserted against the precise citation + // rather than the rounded description, same call made for "AKo vs 22 ≈ 50%" — see + // EQUITY.md. + let result = Equity.canonicalVsCanonical("AA", "72o", iterations: groundTruthIterations) + assertApproximately(result.winRate, 87.99) + assertApproximately(result.loseRate, 11.59) +} + +@Test func groundTruthOverpairVsSuitedConnector() { + // The "suited connector vs. overpair" case: QQ vs JTs. Cited: QQ 81.47% / JTs 18.13% / + // tie 0.40% (cardfight.com). + let result = Equity.canonicalVsCanonical("QQ", "JTs", iterations: groundTruthIterations) + assertApproximately(result.winRate, 81.47) + assertApproximately(result.loseRate, 18.13) +} + +// MARK: - Flagship exact enumeration test +// +// Slow — full preflop board enumeration, C(48,5) = 1,712,304 boards, taking on the order of +// minutes in a debug build (see EQUITY.md's performance note for measured timing). Kept as +// exactly one test, not five, specifically to demonstrate `headsUp`'s exact-enumeration path +// really works end-to-end without making the whole suite unbearably slow — the ground-truth +// checks above cover the "does this match reality" question via fast Monte Carlo instead. + +@Test func exactPreflopEnumerationAAvsKK() { + // This is a SPECIFIC suit assignment (both hands use clubs+diamonds — the pair the + // convenience of `HoleCards(canonical:)` init always produces), not the combo-weighted + // canonical figure `groundTruthAAvsKK` checks above — see EQUITY.md's "A subtlety: + // which suits?" section for why these are legitimately different numbers, and why an + // exact zero-sampling-error computation is still the right thing to assert tightly. + let aa = HoleCards(canonical: "AA")! + let kk = HoleCards(canonical: "KK")! + let result = Equity.headsUp(hero: aa, villain: kk) + + #expect(result.isExact) + #expect(result.trials == 1_712_304) + assertApproximately(result.winRate, 82.36, tolerance: 0.5) + assertApproximately(result.tieRate, 0.54, tolerance: 0.5) + assertApproximately(result.loseRate, 17.09, tolerance: 0.5) + + let total = result.winRate + result.tieRate + result.loseRate + #expect(abs(total - 1.0) < 0.0001) +} + +// MARK: - Exact enumeration on smaller board states (fast — no reason not to test these too) + +@Test func exactEquityWithRiverAlreadyDealt() { + // A complete board — only one "trial", no enumeration needed. Two pair beats a busted + // flush draw with only ace-high. + let hero = HoleCards(Card(rank: .ace, suit: .hearts), Card(rank: .king, suit: .hearts))! + let villain = HoleCards(Card(rank: .nine, suit: .spades), Card(rank: .nine, suit: .diamonds))! + let board = [ + Card(rank: .nine, suit: .clubs), Card(rank: .four, suit: .spades), Card(rank: .two, suit: .diamonds), + Card(rank: .seven, suit: .clubs), Card(rank: .three, suit: .hearts), + ] + let result = Equity.headsUp(hero: hero, villain: villain, board: board) + #expect(result.trials == 1) + #expect(result.isExact) + #expect(result.loseRate == 1.0, "AK-high can't beat trip nines on this board") +} + +@Test func exactEquityWithFlopGivenIsFastAndCorrect() { + // Flop given: only C(45, 2) = 990 boards — fast. Hero has flopped the nut flush draw + // and needs to complete it or pair up; villain has an overpair. + let hero = HoleCards(Card(rank: .ace, suit: .spades), Card(rank: .king, suit: .spades))! + let villain = HoleCards(canonical: "QQ")! + let flop = [Card(rank: .two, suit: .spades), Card(rank: .seven, suit: .spades), Card(rank: .nine, suit: .clubs)] + let result = Equity.headsUp(hero: hero, villain: villain, board: flop) + #expect(result.trials == 990) + #expect(result.isExact) + // A flush draw + two overcards against an overpair is a real equity share, not a blowout + // either way. + #expect(result.winRate > 0.35 && result.winRate < 0.55) +} + +@Test func exactEquityWithTurnGivenIsTrivial() { + let hero = HoleCards(canonical: "AA")! + let villain = HoleCards(canonical: "KK")! + let board = [ + Card(rank: .two, suit: .hearts), Card(rank: .seven, suit: .diamonds), + Card(rank: .nine, suit: .clubs), Card(rank: .jack, suit: .spades), + ] + let result = Equity.headsUp(hero: hero, villain: villain, board: board) + #expect(result.trials == 44) // 52 - 4 hole cards - 4 board cards + #expect(result.winRate > 0.9, "an overpair with no drawing danger on the board should be a big favorite") +} + +// MARK: - Structural correctness + +@Test func equityResultAlwaysSumsToOne() { + // The headsUp case here is deliberately given a flop, not left preflop — an empty board + // means full C(48,5) exact enumeration (minutes), and this test only needs to check + // arithmetic, not re-prove the flagship exact test's own result. + let flop = [Card(rank: .two, suit: .hearts), Card(rank: .seven, suit: .diamonds), Card(rank: .nine, suit: .clubs)] + let cases: [EquityResult] = [ + Equity.headsUp(hero: HoleCards(canonical: "AA")!, villain: HoleCards(canonical: "22")!, board: flop), + Equity.canonicalVsCanonical("AKo", "QJs", iterations: 5_000), + Equity.monteCarlo(hero: [HoleCards(canonical: "AA")!], villain: [HoleCards(canonical: "KK")!], iterations: 5_000), + ] + for result in cases { + let total = result.winRate + result.tieRate + result.loseRate + #expect(abs(total - 1.0) < 0.0001, "win+tie+lose should sum to 1, got \(total)") + } +} + +@Test func monteCarloIsDeterministicForAFixedSeed() { + let first = Equity.canonicalVsCanonical("AKs", "TT", iterations: 10_000, seed: 42) + let second = Equity.canonicalVsCanonical("AKs", "TT", iterations: 10_000, seed: 42) + #expect(first.winRate == second.winRate) + #expect(first.tieRate == second.tieRate) + #expect(first.trials == second.trials) +} + +@Test func monteCarloDefaultSeedIsReproducibleAcrossCalls() { + let first = Equity.canonicalVsCanonical("AA", "KK", iterations: 5_000) + let second = Equity.canonicalVsCanonical("AA", "KK", iterations: 5_000) + #expect(first.winRate == second.winRate) +} + +@Test func monteCarloRespectsTheRequestedIterationCount() { + let result = Equity.canonicalVsCanonical("AA", "KK", iterations: 12_345) + #expect(result.trials == 12_345) +} + +@Test func handVsHandAndHandVsSingleHandRangeAgree() { + // A "range" containing exactly one hand should reproduce the same result as the direct + // hand-vs-hand exact call, up to Monte Carlo sampling error — a consistency check that + // the two code paths (headsUp's exact enumeration vs. monteCarlo's sampling) aren't + // secretly answering different questions. Uses a flop-given board (990 boards to + // enumerate exactly, not preflop's 1.7 million) so this stays fast — the flagship test + // above already covers the full preflop exact-enumeration path. + let hero = HoleCards(Card(rank: .ace, suit: .spades), Card(rank: .king, suit: .spades))! + let villain = HoleCards(canonical: "QQ")! + let flop = [Card(rank: .two, suit: .hearts), Card(rank: .seven, suit: .diamonds), Card(rank: .nine, suit: .clubs)] + let exact = Equity.headsUp(hero: hero, villain: villain, board: flop) + let sampled = Equity.monteCarlo(hero: [hero], villain: [villain], board: flop, iterations: 50_000) + assertApproximately(sampled.winRate, exact.winRate * 100, tolerance: 1.5) +} + +@Test func expandCanonicalProducesTheRightComboCounts() { + #expect(Equity.expandCanonical("AA").count == 6) + #expect(Equity.expandCanonical("AKs").count == 4) + #expect(Equity.expandCanonical("AKo").count == 12) +} + +@Test func expandCanonicalCombosAreAllDistinctAndValid() { + for notation in ["AA", "72o", "T9s"] { + let combos = Equity.expandCanonical(notation) + #expect(Set(combos).count == combos.count, "no duplicate combos for \(notation)") + for combo in combos { + #expect(combo.notation == notation, "\(combo.notation) should be a \(notation) combo") + } + } +} + +@Test func expandCanonicalReturnsEmptyForMalformedNotation() { + #expect(Equity.expandCanonical("").isEmpty) + #expect(Equity.expandCanonical("A9x").isEmpty) + #expect(Equity.expandCanonical("AK").isEmpty) // two different ranks with no s/o flag +} + +@Test func rangeVsRangeHandlesMultiComboRangesOnBothSides() { + // A crude "top pairs" range vs. a crude "big broadways" range — just needs to run + // cleanly across multiple combos on both sides and produce sane probabilities. + let heroRange = Equity.expandCanonical("AA") + Equity.expandCanonical("KK") + let villainRange = Equity.expandCanonical("AKs") + Equity.expandCanonical("AKo") + let result = Equity.rangeVsRange(heroRange: heroRange, villainRange: villainRange, iterations: 10_000) + #expect(result.trials == 10_000) + #expect(result.winRate > 0.6, "a pair range should dominate an unpaired broadway range") +} diff --git a/PokerKit/Tests/PokerKitTests/HandEvaluatorTests.swift b/PokerKit/Tests/PokerKitTests/HandEvaluatorTests.swift new file mode 100644 index 0000000..a4fc10e --- /dev/null +++ b/PokerKit/Tests/PokerKitTests/HandEvaluatorTests.swift @@ -0,0 +1,250 @@ +import Testing +@testable import PokerKit + +private func card(_ rank: Rank, _ suit: Suit) -> Card { Card(rank: rank, suit: suit) } + +// MARK: - Category ordering (the ladder itself) + +@Test func royalFlushBeatsQuads() { + let royal = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.king, .spades), card(.queen, .spades), card(.jack, .spades), card(.ten, .spades), + ]) + let quads = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.ace, .hearts), card(.ace, .diamonds), card(.ace, .clubs), card(.king, .spades), + ]) + #expect(royal.category == .straightFlush) + #expect(royal > quads) +} + +@Test func quadsBeatsFullHouse() { + let quads = HandEvaluator.bestHand(from: [ + card(.two, .spades), card(.two, .hearts), card(.two, .diamonds), card(.two, .clubs), card(.king, .spades), + ]) + let fullHouse = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.ace, .hearts), card(.ace, .diamonds), card(.king, .clubs), card(.king, .spades), + ]) + #expect(quads > fullHouse) +} + +@Test func fullHouseBeatsFlush() { + let fullHouse = HandEvaluator.bestHand(from: [ + card(.two, .spades), card(.two, .hearts), card(.two, .diamonds), card(.three, .clubs), card(.three, .spades), + ]) + let flush = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.king, .spades), card(.queen, .spades), card(.jack, .spades), card(.nine, .spades), + ]) + #expect(fullHouse > flush) +} + +@Test func flushBeatsStraight() { + let flush = HandEvaluator.bestHand(from: [ + card(.two, .spades), card(.four, .spades), card(.six, .spades), card(.eight, .spades), card(.ten, .spades), + ]) + let straight = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.king, .hearts), card(.queen, .diamonds), card(.jack, .clubs), card(.ten, .spades), + ]) + #expect(flush > straight) +} + +@Test func straightBeatsTrips() { + let straight = HandEvaluator.bestHand(from: [ + card(.six, .spades), card(.seven, .hearts), card(.eight, .diamonds), card(.nine, .clubs), card(.ten, .spades), + ]) + let trips = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.ace, .hearts), card(.ace, .diamonds), card(.king, .clubs), card(.queen, .spades), + ]) + #expect(straight > trips) +} + +@Test func tripsBeatsTwoPair() { + let trips = HandEvaluator.bestHand(from: [ + card(.two, .spades), card(.two, .hearts), card(.two, .diamonds), card(.three, .clubs), card(.four, .spades), + ]) + let twoPair = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.ace, .hearts), card(.king, .diamonds), card(.king, .clubs), card(.queen, .spades), + ]) + #expect(trips > twoPair) +} + +@Test func twoPairBeatsPair() { + let twoPair = HandEvaluator.bestHand(from: [ + card(.two, .spades), card(.two, .hearts), card(.three, .diamonds), card(.three, .clubs), card(.four, .spades), + ]) + let pair = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.ace, .hearts), card(.king, .diamonds), card(.queen, .clubs), card(.jack, .spades), + ]) + #expect(twoPair > pair) +} + +@Test func pairBeatsHighCard() { + let pair = HandEvaluator.bestHand(from: [ + card(.two, .spades), card(.two, .hearts), card(.three, .diamonds), card(.four, .clubs), card(.five, .spades), + ]) + let highCard = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.king, .hearts), card(.queen, .diamonds), card(.jack, .clubs), card(.nine, .spades), + ]) + #expect(pair > highCard) +} + +// MARK: - The wheel (A-2-3-4-5) + +@Test func wheelIsAStraightNotAceHigh() { + let wheel = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.two, .hearts), card(.three, .diamonds), card(.four, .clubs), card(.five, .spades), + ]) + #expect(wheel.category == .straight) + #expect(wheel.tiebreakers == [5], "The wheel's straight high card is 5, not 14 (ace plays low here)") +} + +@Test func wheelStraightLosesToSixHighStraight() { + let wheel = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.two, .hearts), card(.three, .diamonds), card(.four, .clubs), card(.five, .spades), + ]) + let sixHigh = HandEvaluator.bestHand(from: [ + card(.two, .spades), card(.three, .hearts), card(.four, .diamonds), card(.five, .clubs), card(.six, .spades), + ]) + #expect(sixHigh > wheel) +} + +@Test func wheelFlushIsAStraightFlushNotAHighCardFlush() { + let wheel = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.two, .spades), card(.three, .spades), card(.four, .spades), card(.five, .spades), + ]) + #expect(wheel.category == .straightFlush) + #expect(wheel.tiebreakers == [5]) +} + +@Test func nearWheelRanksIsNotAStraight() { + // A-2-3-4-6 skips 5 — not a straight in either direction. + let notAStraight = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.two, .hearts), card(.three, .diamonds), card(.four, .clubs), card(.six, .spades), + ]) + #expect(notAStraight.category == .highCard) +} + +// MARK: - Kicker tie-breaks within a category + +@Test func higherPairBeatsLowerPair() { + let acePair = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.ace, .hearts), card(.two, .diamonds), card(.three, .clubs), card(.four, .spades), + ]) + let kingPair = HandEvaluator.bestHand(from: [ + card(.king, .spades), card(.king, .hearts), card(.queen, .diamonds), card(.jack, .clubs), card(.ten, .spades), + ]) + #expect(acePair > kingPair) +} + +@Test func samePairHigherKickerWins() { + let jackKicker = HandEvaluator.bestHand(from: [ + card(.two, .spades), card(.two, .hearts), card(.jack, .diamonds), card(.four, .clubs), card(.three, .spades), + ]) + let tenKicker = HandEvaluator.bestHand(from: [ + card(.two, .clubs), card(.two, .diamonds), card(.ten, .spades), card(.four, .hearts), card(.three, .diamonds), + ]) + #expect(jackKicker > tenKicker) +} + +@Test func higherTopPairInTwoPairWins() { + let acesAndTwos = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.ace, .hearts), card(.two, .diamonds), card(.two, .clubs), card(.king, .spades), + ]) + let kingsAndQueens = HandEvaluator.bestHand(from: [ + card(.king, .clubs), card(.king, .diamonds), card(.queen, .spades), card(.queen, .hearts), card(.ace, .clubs), + ]) + #expect(acesAndTwos > kingsAndQueens) +} + +@Test func sameTopPairHigherSecondPairWins() { + let acesAndKings = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.ace, .hearts), card(.king, .diamonds), card(.king, .clubs), card(.two, .spades), + ]) + let acesAndQueens = HandEvaluator.bestHand(from: [ + card(.ace, .clubs), card(.ace, .diamonds), card(.queen, .spades), card(.queen, .hearts), card(.three, .clubs), + ]) + #expect(acesAndKings > acesAndQueens) +} + +@Test func fullHouseTripsRankBreaksTheTie() { + let acesFullOfTwos = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.ace, .hearts), card(.ace, .diamonds), card(.two, .clubs), card(.two, .spades), + ]) + let kingsFullOfQueens = HandEvaluator.bestHand(from: [ + card(.king, .clubs), card(.king, .diamonds), card(.king, .hearts), card(.queen, .spades), card(.queen, .clubs), + ]) + #expect(acesFullOfTwos > kingsFullOfQueens) +} + +@Test func sameTripsHigherPairBreaksTheFullHouseTie() { + // Only possible across 7 cards (two different pairs can't both fit with the same trips + // in just 5 cards) — exercises the 7-card `bestHand` path, not just `evaluate5`. + let acesFullOfKings = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.ace, .hearts), card(.ace, .diamonds), + card(.king, .clubs), card(.king, .spades), card(.two, .clubs), card(.three, .diamonds), + ]) + let acesFullOfQueens = HandEvaluator.bestHand(from: [ + card(.ace, .clubs), card(.ace, .diamonds), card(.ace, .hearts), + card(.queen, .spades), card(.queen, .hearts), card(.two, .spades), card(.three, .clubs), + ]) + #expect(acesFullOfKings > acesFullOfQueens) +} + +@Test func flushHighCardBreaksTheTie() { + let aceHighFlush = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.jack, .spades), card(.eight, .spades), card(.five, .spades), card(.two, .spades), + ]) + let kingHighFlush = HandEvaluator.bestHand(from: [ + card(.king, .hearts), card(.queen, .hearts), card(.nine, .hearts), card(.six, .hearts), card(.three, .hearts), + ]) + #expect(aceHighFlush > kingHighFlush) +} + +@Test func highCardKickersBreakTheTieInOrder() { + let higherSecondCard = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.jack, .hearts), card(.eight, .diamonds), card(.five, .clubs), card(.two, .spades), + ]) + let lowerSecondCard = HandEvaluator.bestHand(from: [ + card(.ace, .clubs), card(.ten, .diamonds), card(.nine, .spades), card(.six, .hearts), card(.three, .clubs), + ]) + #expect(higherSecondCard > lowerSecondCard) +} + +@Test func identicalRanksAreEqualRegardlessOfSuit() { + let a = HandEvaluator.bestHand(from: [ + card(.ace, .spades), card(.king, .hearts), card(.queen, .diamonds), card(.jack, .clubs), card(.nine, .spades), + ]) + let b = HandEvaluator.bestHand(from: [ + card(.ace, .hearts), card(.king, .clubs), card(.queen, .spades), card(.jack, .diamonds), card(.nine, .hearts), + ]) + #expect(a == b) +} + +// MARK: - 7-card best-of selection + +@Test func sevenCardHandPicksTheBestFiveIgnoringTheRest() { + // Board gives a flush; hole cards are irrelevant junk that shouldn't drag the result + // down to two pair or worse. + let hand = HandEvaluator.bestHand(from: [ + card(.two, .clubs), card(.three, .diamonds), // "hole cards" — junk, no pair, no flush help + card(.four, .spades), card(.six, .spades), card(.eight, .spades), card(.ten, .spades), card(.queen, .spades), + ]) + #expect(hand.category == .flush) + #expect(hand.tiebreakers == [12, 10, 8, 6, 4]) +} + +@Test func sevenCardHandFindsTheStraightFlushOverTheSimpleFlush() { + let hand = HandEvaluator.bestHand(from: [ + card(.nine, .spades), card(.ace, .hearts), + card(.five, .spades), card(.six, .spades), card(.seven, .spades), card(.eight, .spades), card(.two, .diamonds), + ]) + #expect(hand.category == .straightFlush) + #expect(hand.tiebreakers == [9]) +} + +@Test func sixCardHandPicksTheBestFive() { + let hand = HandEvaluator.bestHand(from: [ + card(.two, .clubs), + card(.ace, .spades), card(.ace, .hearts), card(.ace, .diamonds), card(.king, .clubs), card(.king, .spades), + ]) + #expect(hand.category == .fullHouse) + #expect(hand.tiebreakers == [14, 13]) +} diff --git a/ai-docs/EQUITY.md b/ai-docs/EQUITY.md new file mode 100644 index 0000000..92dc8e0 --- /dev/null +++ b/ai-docs/EQUITY.md @@ -0,0 +1,283 @@ +# Equity Calculator + +Source: `PokerKit/Sources/PokerKit/HandEvaluator.swift`, `Equity.swift`. Tests: +`HandEvaluatorTests.swift`, `EquityTests.swift`. + +## What it does + +Two layers, built bottom-up: + +- **`HandEvaluator`** — given 5, 6, or 7 cards, returns the best possible 5-card + poker hand as a fully `Comparable` `HandStrength` (category + kicker + tiebreakers). This is a **real evaluator**, not a lookup table or an + approximation — every category (pair, two pair, straight, flush, ...) is + derived from actual rank/suit counts on every call. +- **`Equity`** — win/tie/lose probability between two hands, or a hand/range + vs. a hand/range, on any board state (preflop, flop, turn, or river given). + Built entirely on `HandEvaluator`; no separate equity table, no shortcuts. + +Unlike every other model in this codebase (`PushFoldRange`, `OpeningRange`, +`CallingRange`, `BountyEquity`), **this one isn't a hand-tuned approximation +of a solver's shape** — it's exact math (either literally exhaustive, or a +documented, tolerance-bound Monte Carlo sample of exact math). "Study aid, not +solver output" doesn't apply here; the correctness bar is "matches the actual +probability," and the tests hold it to that. + +## `HandEvaluator` + +`bestHand(from: [Card]) -> HandStrength` accepts 5-7 cards and returns the +best 5-card hand achievable from them. For 6 or 7 cards, it evaluates every +possible 5-card sub-hand (`C(6,5) = 6` or `C(7,5) = 21`) and keeps the best by +`Comparable` — the textbook-correct approach. A real solver would use a +precomputed perfect-hash lookup table instead (evaluations in tens of +nanoseconds rather than the ~1µs+ this takes); that wasn't built here — see +"Performance" below for why that tradeoff was made deliberately, not by +accident. + +`HandStrength` is `category: HandCategory` (the standard nine-rung ladder, +`highCard` through `straightFlush`, ordered by raw value so `Comparable` falls +out for free) plus `tiebreakers: [Int]` — enough rank values, most significant +first, to break every tie the category leaves open (kicker included). Two +`HandStrength`s only ever have different-shaped `tiebreakers` arrays when +they're different categories, and comparison checks category first, so a +shape mismatch never actually gets compared element-by-element. + +**The wheel (A-2-3-4-5)** is handled explicitly: it's a straight (and a +straight flush, if suited) with a **5-high**, not ace-high, so +`tiebreakers == [5]` for a wheel straight — it loses to a 6-high straight, as +it should. `HandEvaluatorTests.swift` tests this directly (`wheelIsAStraightNotAceHigh`, +`wheelStraightLosesToSixHighStraight`, `wheelFlushIsAStraightFlushNotAHighCardFlush`, +`nearWheelRanksIsNotAStraight`). + +`HandEvaluatorTests.swift` covers, exhaustively: the full category ladder +(royal flush > quads > full house > flush > straight > trips > two pair > +pair > high card, each asserted as an explicit pairwise comparison, not just +assumed transitive), the wheel, kicker tie-breaks within every category +(pair kicker, two-pair second-pair, full-house trips-rank, flush high card, +high-card kicker chain), suit-blindness (identical ranks in different suits +compare equal), and the 7-card best-of-5 selection (picking a flush out of +junk hole cards; picking a straight flush over a plain flush; picking the +best 5 out of 6). + +## `Equity` + +Two calculation modes — deliberately different, matching the two situations +that actually differ in what's computationally tractable: + +### `headsUp(hero:villain:board:) -> EquityResult` — exact + +One hand vs. one hand. Enumerates **every** possible completion of the +remaining board and evaluates each one — genuinely exhaustive, zero sampling +error. Tractable for any board state: + +| Board given | Boards to enumerate | +| --- | --- | +| Preflop (0 cards) | `C(48, 5) = 1,712,304` | +| Flop (3 cards) | `C(45, 2) = 990` | +| Turn (4 cards) | `C(44, 1) = 44` | +| River (5 cards) | `1` (already determined) | + +Only the preflop case is actually expensive — see "Performance" below. + +### `monteCarlo`/`handVsRange`/`rangeVsRange` — fixed-seed sampling + +Either side can be a **range** (multiple possible hands) — exact enumeration +doesn't work here at any real range width, because it would mean enumerating +every (hero combo × villain combo × board completion) triple, which blows up +combinatorially even for small ranges (see "A subtlety: which suits?" below +for just how fast this gets out of hand). Each trial samples one concrete +hand uniformly from each side and a uniformly random board completion, +retrying on any card collision. + +**Fixed-seed, always.** `Equity.defaultSeed` (`0xC0FFEE`) is used unless +overridden, via a small deterministic PRNG (`SplitMix64` — Sebastiano Vigna's +public-domain algorithm, chosen specifically because it's simple, widely +reimplemented, and has no hidden state beyond one `UInt64` — reproducibility +is the entire point of using a named, inspectable algorithm here rather than +Swift's own `SystemRandomNumberGenerator`, which is explicitly not seedable). +Two calls with the same seed and iteration count always return bit-identical +results — `EquityTests.swift`'s `monteCarloIsDeterministicForAFixedSeed` and +`monteCarloDefaultSeedIsReproducibleAcrossCalls` assert this directly. Equity +tests here never flake. + +**Default sample size:** `Equity.defaultMonteCarloIterations = 50_000`. +Standard error for a binomial proportion is `sqrt(p(1-p)/n)`; worst-case +(`p = 0.5`) at `n = 50,000` that's `≈0.22%`, or about `±0.45%` at a 95% +confidence interval. The ground-truth tests below use `100,000` iterations +for extra margin (`±0.32%` at 95% CI) since they're the tests this whole +feature is judged by. + +### `canonicalVsCanonical` / `expandCanonical` — the combo-weighted layer + +`expandCanonical("AA")`, `expandCanonical("AKs")`, `expandCanonical("AKo")` +expand a canonical hand string into every concrete `HoleCards` combo it +represents — 6 for a pair (`C(4,2)`), 4 for suited (one per suit), 12 for +offsuit (`4 × 3`). `canonicalVsCanonical(_:_:)` runs `rangeVsRange` across the +full expansion on both sides. **This is what published "AA vs KK ≈ 82.4%" +reference figures actually mean** — see the next section for why that +distinction turned out to matter more than expected while building this. + +## A subtlety: which suits? (read this before trusting a specific number) + +While validating this against published references, `headsUp(hero: +HoleCards(canonical: "AA"), villain: HoleCards(canonical: "KK"))` produced +**82.36% / 0.54% / 17.09%** — extremely close to the commonly-quoted "82.4%" +figure. That looked like confirmation. It wasn't quite measuring the same +thing as the citation, and the gap between "close" and "measuring the same +thing" turned out to be real and worth documenting rather than glossing over. + +`HoleCards(canonical:)` always assigns a pair's two cards to **clubs and +diamonds** (see `HoleCards.swift`) — so `HoleCards(canonical: "AA")` and +`HoleCards(canonical: "KK")` don't just happen to both use those two suits, +they **share both of them**. That's the maximum-overlap case, and it's not +suit-neutral: re-running the exact computation with a non-overlapping suit +assignment (`A♣A♥` vs. `K♦K♠`) gives **81.06% / 0.38% / 18.55%** instead — a +real, reproducible ~1.3-point swing on the win rate, purely from which suits +were used, with the *same two hand classes* and *exact, zero-error* +enumeration on both sides. This isn't noise or a bug: fewer clubs/diamonds +remain in the deck when both hands share those suits, which measurably +changes flush frequency on the runout. + +Published "AA vs KK" figures — and the poker-community shorthand "AA is an +82% favorite over KK" — are **combo-weighted averages** across every way +AA's two suits can relate to KK's two suits (0, 1, or 2 shared), not the +equity of one arbitrary concrete pair of hands. That's exactly what +`canonicalVsCanonical("AA", "KK")` computes (expand both to all 6 combos each, +sample across all 36 pairings), and it lands at **81.92%** in this codebase's +Monte Carlo — within 0.3pt of cardfight.com's cited **81.71%**, and +compatible with the commonly-rounded "82.4%" (see the table below; different +public sources round or compute this slightly differently, which is itself +consistent with there being real, small, legitimate variation depending on +methodology). + +**The practical upshot:** + +- `headsUp` answers "what's the exact equity for *this specific pair of + concrete hands*" — a well-posed, exactly-computable question, useful when + you actually know both players' suits (e.g. from a hand history). +- `canonicalVsCanonical` answers "what's the equity for *this hand class vs. + that hand class*, averaged over how the suits could align" — the question + poker literature is almost always actually asking, and what the ground-truth + ⁠tests below validate against. +- The two can legitimately differ by roughly a percentage point for + pocket-pair-vs-pocket-pair matchups specifically (suited/offsuit hands have + much less room for this, since their own suitedness already pins down more + of the suit relationship). Neither number is "wrong" — they're answers to + different questions that happen to share a name in casual conversation. + +This is exactly the kind of thing "don't hand-wave" means in practice: the +first exact number this produced was close enough to the cited figure that +it would have been easy to call it a match and move on. It wasn't actually +the same computation as the citation, and the 1-point gap between the two +suit-assignment extremes is large enough that it could hide a real bug in a +less-examined codebase. It isn't one here — but only because it got checked. + +## Ground-truth validation + +Every number below was fetched from **cardfight.com**, a dedicated preflop +equity-statistics site, while building this feature (via live page fetches, +not recalled from memory) — chosen because it publishes exact win/tie/lose +percentages per matchup with enough precision to cross-check against +(`AA_KK.html`, `QQ_AKs.html`, `22_AKo.html`, `AA_72o.html`, `QQ_JTs.html`). + +| Matchup | Cited (cardfight.com) | Measured (`canonicalVsCanonical`, 100k iters) | Diff | +| --- | --- | --- | --- | +| AA vs KK | 81.71% / 17.82% / 0.46% | 81.92% / 17.64% / 0.44% | 0.21pt | +| AKs vs QQ | 45.83% / 53.73% / 0.43% | 45.78% / 53.81% / 0.41% | 0.05pt | +| AKo vs 22 | 47.04% / 52.34% / 0.62% | 47.04% / 52.39% / 0.58% | 0.00pt | +| AA vs 72o | 87.99% / 11.59% / 0.42% | 87.78% / 11.79% / 0.43% | 0.21pt | +| QQ vs JTs (overpair vs. suited connector) | 81.47% / 18.13% / 0.40% | 81.32% / 18.28% / 0.40% | 0.15pt | + +All within 0.25 percentage points — well inside the documented Monte Carlo +tolerance (`±1.0pt` is what `EquityTests.swift` actually asserts, deliberately +looser than the observed deviation so the tests don't flake on legitimate +sampling variation if the seed or iteration count ever changes). + +A note on the task's original framing of two of these: + +- **"AKo vs 22 ≈ 50%"** — colloquially, a pair vs. two overcards is called "a + coinflip" in poker slang. The precise number (47.04% / 52.34%) shows the + pair is a real, consistent ~5-point favorite, not a literal coinflip. The + slang predates precise equity calculators; it's a "close enough that both + sides commit" description, not a citation. Tested against the precise + cardfight.com figure instead of the rounded "50%," since precision matters + more here than matching a casual description. +- **"AKs vs QQ ≈ 46.2%"** — matches closely (measured 45.78%, cited 45.83%); + the 46.2% figure appears to be a slightly different rounding of the same + real number, well within normal cross-source variation for this kind of + statistic. +- **"AA vs 72o ≈ 88%+"** — the precise combo-weighted figure (87.99% cited, + 87.78% measured) is just *under* 88%, not over it. An early version of this + test asserted `winRate > 0.88` directly (matching the task's phrasing + literally) and it failed — correctly: the assertion was wrong, not the + code. Fixed by testing against the precise citation instead of the rounded + description, same as the other two notes here. + +## Performance + +Measured on this machine, Swift 6.3 (Xcode 27 beta toolchain), Apple +Silicon, `swift test` (debug/`-Onone` — SwiftPM's default, and what CI runs): + +| Operation | Time | +| --- | --- | +| `headsUp`, preflop (1,712,304 boards) | **~285 seconds** | +| `headsUp`, preflop, release build (`-c release`) | ~32 seconds | +| `headsUp`, flop given (990 boards) | well under 1 second | +| `headsUp`, turn given (44 boards) | instant | +| `monteCarlo`, 100,000 iterations | ~17 seconds | +| `monteCarlo`, 200,000 iterations | ~34 seconds | + +**This is slow, and that's a deliberate tradeoff, not an oversight.** +`HandEvaluator.evaluate5` avoids the most obviously wasteful approach +(a `Dictionary` for rank-counting) in favor of a fixed 15-slot array, and +`bestHand(from:)`'s 7-card path uses a precomputed static index table instead +of a general combinations generator — but a genuinely fast evaluator (the +kind real solvers use) needs a perfect-hash lookup table built from prime-number +rank encoding, which is a meaningfully larger, more error-prone undertaking +than this study tool's scope justifies. `@_optimize(speed)` was tried on the +hot-path functions specifically to see if it could close the gap without that +work; it made no measurable difference (debug-mode's lack of inlining and +bounds-check elision dominates regardless of a single function's own +optimization attribute), so it was removed rather than kept for no benefit. + +**Consequence for this codebase's test suite:** exactly **one** test +(`exactPreflopEnumerationAAvsKK`) pays the full ~285-second preflop +exhaustive-enumeration cost, specifically to prove the exact-enumeration path +really works end-to-end. Every ground-truth validation test uses +`canonicalVsCanonical` (Monte Carlo, ~17s each at 100k iterations) instead — +justified both by the performance difference and by the "which suits?" +finding above (Monte Carlo over the full combo expansion is actually the +*more correct* way to match a published canonical-hand equity figure, not +just the faster one). Total `swift test` time added by this feature is +roughly 5-6 minutes — noticeably slower than the rest of the suite (which +runs in under a second), and worth knowing about before running the full +suite impatiently. + +If this needs to get faster later: the standard next step is a precomputed +lookup-table evaluator (e.g. the well-known "Cactus Kev" 5-card evaluator, or +a two-plus-two-style 7-card perfect-hash table) swapped in behind +`HandEvaluator.bestHand(from:)`'s existing signature — nothing in `Equity` or +any caller would need to change, since they only depend on `bestHand` +returning a correctly-ordered `HandStrength`. + +## Consumers + +- `EquityCalculatorView` (`app/Sources/EquityCalculatorView.swift`) — pick a + hero hand, a villain hand or range, an optional board, see win/tie/lose %. + Wired into the home screen via `StudyTool.equityCalculator`. + +## What this deliberately doesn't do + +- **No range-parsing UI beyond canonical hand strings.** `expandCanonical` + handles one hand class at a time ("AA", "AKs", "72o"); building a full + range-editor UI (percentage sliders, custom range strings like "22+, AJs+") + is out of scope for this pass — `EquityCalculatorView` exposes hand-vs-hand + and hand-vs-single-canonical-class-range, not arbitrary multi-hand ranges. +- **No postflop simulation beyond a given board.** This computes equity + *given* a board (or none); it doesn't model betting, folding equity, or any + strategic decision — it's a pure probability calculator, same category of + tool as an equity calculator website, not a solver. +- **No ICM.** Equity here is always chip-count-share probability, never + tournament-payout-adjusted. Consistent with every other model in this + codebase except that here it's simply out of scope rather than + approximated. diff --git a/ai-docs/README.md b/ai-docs/README.md index b0bc71f..abac5da 100644 --- a/ai-docs/README.md +++ b/ai-docs/README.md @@ -17,6 +17,7 @@ drop into whichever subsystem you're touching: | [DRILLS.md](DRILLS.md) | How "Practice Your Leaks" derives a `DrillFocus` from a leak report and weights spots | | [PREFLOP-GRID.md](PREFLOP-GRID.md) | The 13×13 grid enumeration and range viewer | | [BOUNTY.md](BOUNTY.md) | PKO bounty-adjusted shove ranges: the formula, its source, and what it doesn't model | +| [EQUITY.md](EQUITY.md) | `HandEvaluator`/`Equity`: exact + Monte Carlo win/tie/lose calculation, ground-truth validation, performance | | [TESTING.md](TESTING.md) | Running `swift test` (the Xcode/`DEVELOPER_DIR` requirement) and what CI does | See also **[AGENTS.md](../AGENTS.md)** (build/test/run, conventions, the diff --git a/app/Sources/ContentView.swift b/app/Sources/ContentView.swift index 8a2c316..14684e4 100644 --- a/app/Sources/ContentView.swift +++ b/app/Sources/ContentView.swift @@ -23,6 +23,8 @@ struct ContentView: View { PreflopRangeView() case .pushFold: PushFoldTrainerView() + case .equityCalculator: + EquityCalculatorView() case .bankroll: BankrollTrackerView() case .handHistoryImport: diff --git a/app/Sources/EquityCalculatorView.swift b/app/Sources/EquityCalculatorView.swift new file mode 100644 index 0000000..dee5368 --- /dev/null +++ b/app/Sources/EquityCalculatorView.swift @@ -0,0 +1,252 @@ +import SwiftUI +import PokerKit + +/// Holds the in-flight/last equity calculation. A reference type on purpose: `calculate()` +/// kicks off background work whose completion callback needs to mutate state reliably after +/// `EquityCalculatorView` (a `View`, i.e. a *value type*) may have already been re-created +/// several times by SwiftUI. Capturing `self` for a struct across that async gap is a +/// documented footgun — the completion handler can end up writing into a stale copy that +/// never reaches the screen. A `@State`-held reference type has a single, stable identity +/// for the lifetime of the view, so this can't happen: whoever the closure's `self` is, it's +/// the same object the view is still observing when the closure runs. +@Observable +final class EquityCalculatorModel { + private(set) var isCalculating = false + private(set) var result: EquityResult? + + func calculate(hero: [HoleCards], villain: [HoleCards], board: [Card], iterations: Int) { + isCalculating = true + result = nil + + DispatchQueue.global(qos: .userInitiated).async { [self] in + let computed = Equity.rangeVsRange(heroRange: hero, villainRange: villain, board: board, iterations: iterations) + DispatchQueue.main.async { [self] in + result = computed + isCalculating = false + } + } + } + + func clearResult() { + result = nil + } +} + +/// Pick a hero hand class, a villain hand class, and an optional board; see win/tie/lose %. +/// +/// Both hero and villain are entered as **canonical hand notation** ("AKs", "QQ", "72o") — +/// each expands to every concrete suit combo it represents (`Equity.expandCanonical`) and +/// equity is computed across all of them, exactly like `Equity.canonicalVsCanonical`. This +/// deliberately answers "hand class vs. hand class," the question players actually mean by +/// "what's my equity with AKs here" — not "what's the equity of this one specific pair of +/// suited cards," which `Equity.headsUp` can answer exactly but isn't what this screen is +/// for. See `ai-docs/EQUITY.md`'s "A subtlety: which suits?" section for why that +/// distinction matters. +/// +/// Always uses Monte Carlo (`Equity.rangeVsRange`), never `Equity.headsUp`'s exact +/// enumeration — the exact preflop path takes on the order of minutes even in a release +/// build (see `EQUITY.md`'s performance note), which would hang this screen. 10,000 +/// iterations keeps a tap-to-result under a few seconds; see `EQUITY.md` for the +/// resulting standard error. +struct EquityCalculatorView: View { + private static let liveIterations = 10_000 + + @State private var model = EquityCalculatorModel() + @State private var heroNotation = "AKs" + @State private var villainNotation = "QQ" + @State private var street: Street = .preflop + @State private var boardCards: [Card?] = Array(repeating: nil, count: 5) + + private var boardCardCount: Int { + switch street { + case .preflop: return 0 + case .flop: return 3 + case .turn: return 4 + case .river: return 5 + } + } + + private var heroCombos: [HoleCards] { Equity.expandCanonical(Self.normalize(heroNotation)) } + private var villainCombos: [HoleCards] { Equity.expandCanonical(Self.normalize(villainNotation)) } + private var resolvedBoard: [Card] { Array(boardCards.prefix(boardCardCount).compactMap { $0 }) } + + /// Uppercases the rank characters but not a trailing suited/offsuit flag — + /// `Equity.expandCanonical` requires a lowercase `s`/`o` (`"AKs"`, not `"AKS"`), so a + /// blanket `.uppercased()` on user input would silently invalidate every suited/offsuit + /// hand typed in any case other than already-correct. + private static func normalize(_ input: String) -> String { + let trimmed = input.trimmingCharacters(in: .whitespaces) + guard trimmed.count == 3 else { return trimmed.uppercased() } + return trimmed.prefix(2).uppercased() + trimmed.suffix(1).lowercased() + } + + private var validationMessage: String? { + if heroNotation.isEmpty { return nil } + if heroCombos.isEmpty { return "Hero isn't a valid hand — try \"AKs\", \"QQ\", or \"72o\"." } + if villainNotation.isEmpty { return nil } + if villainCombos.isEmpty { return "Villain isn't a valid hand — try \"AKs\", \"QQ\", or \"72o\"." } + if resolvedBoard.count != boardCardCount { return "Finish setting the board." } + if Set(resolvedBoard).count != resolvedBoard.count { return "Board has a repeated card." } + return nil + } + + private var isReadyToCalculate: Bool { + !heroCombos.isEmpty && !villainCombos.isEmpty + && resolvedBoard.count == boardCardCount + && Set(resolvedBoard).count == resolvedBoard.count + } + + var body: some View { + Form { + Section("Hero") { + TextField("e.g. AKs, QQ, 72o", text: $heroNotation) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .accessibilityIdentifier("heroNotationField") + .onChange(of: heroNotation) { _, _ in model.clearResult() } + } + + Section("Villain") { + TextField("e.g. AKs, QQ, 72o", text: $villainNotation) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .accessibilityIdentifier("villainNotationField") + .onChange(of: villainNotation) { _, _ in model.clearResult() } + } + + Section("Board") { + Picker("Street", selection: $street) { + Text("Preflop").tag(Street.preflop) + Text("Flop").tag(Street.flop) + Text("Turn").tag(Street.turn) + Text("River").tag(Street.river) + } + .pickerStyle(.segmented) + .accessibilityIdentifier("streetPicker") + .onChange(of: street) { _, _ in model.clearResult() } + + ForEach(0.. some View { + Section("Result") { + resultRow(label: "Hero wins", value: result.winRate, tint: .green, identifier: "heroWinRate") + resultRow(label: "Tie", value: result.tieRate, tint: .secondary, identifier: "tieRate") + resultRow(label: "Villain wins", value: result.loseRate, tint: .red, identifier: "villainWinRate") + Text("\(result.trials.formatted()) Monte Carlo simulations, fixed seed — same inputs always give the same result. Not a solver; see ai-docs/EQUITY.md.") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("equityMethodText") + } + } + + private func resultRow(label: String, value: Double, tint: Color, identifier: String) -> some View { + HStack { + Text(label) + Spacer() + Text(String(format: "%.1f%%", value * 100)) + .font(.headline) + .monospacedDigit() + .foregroundStyle(tint) + .accessibilityIdentifier(identifier) + } + } +} + +/// One board card: a rank menu and a suit menu that combine into a `Card?`. Clears back to +/// `nil` if either half is unset, rather than allowing a half-picked card to leak through. +private struct BoardCardPickerRow: View { + let label: String + @Binding var card: Card? + + @State private var rank: Rank? + @State private var suit: Suit? + + var body: some View { + HStack { + Text(label) + Spacer() + Picker("Rank", selection: $rank) { + Text("–").tag(Rank?.none) + ForEach(Rank.allCases.reversed(), id: \.self) { r in + Text(r.symbol).tag(Rank?.some(r)) + } + } + .pickerStyle(.menu) + .accessibilityIdentifier("\(label)RankPicker") + + Picker("Suit", selection: $suit) { + Text("–").tag(Suit?.none) + ForEach(Suit.allCases, id: \.self) { s in + Text(s.symbol).tag(Suit?.some(s)) + } + } + .pickerStyle(.menu) + .accessibilityIdentifier("\(label)SuitPicker") + } + .onChange(of: rank) { _, _ in updateCard() } + .onChange(of: suit) { _, _ in updateCard() } + .onAppear { + rank = card?.rank + suit = card?.suit + } + } + + private func updateCard() { + if let rank, let suit { + card = Card(rank: rank, suit: suit) + } else { + card = nil + } + } +} + +#Preview { + NavigationStack { + EquityCalculatorView() + } +} diff --git a/app/UITests/BankrollTrackerUITests.swift b/app/UITests/BankrollTrackerUITests.swift index e0eeb7f..26f8fa7 100644 --- a/app/UITests/BankrollTrackerUITests.swift +++ b/app/UITests/BankrollTrackerUITests.swift @@ -5,7 +5,14 @@ final class BankrollTrackerUITests: XCTestCase { let app = XCUIApplication() app.launch() - app.staticTexts["Bankroll Tracker"].tap() + // "Bankroll Tracker" can render below the fold on the home list depending on how + // many study tools are above it — scroll until it's actually in the accessibility + // tree before tapping (SwiftUI's List lazily instantiates off-screen rows). + let bankrollRow = app.staticTexts["Bankroll Tracker"] + for _ in 0..<8 where !bankrollRow.exists { + app.swipeUp() + } + bankrollRow.tap() let addButton = app.navigationBars.buttons["Add Session"] XCTAssertTrue(addButton.waitForExistence(timeout: 5)) diff --git a/app/UITests/EquityCalculatorUITests.swift b/app/UITests/EquityCalculatorUITests.swift new file mode 100644 index 0000000..f7031ff --- /dev/null +++ b/app/UITests/EquityCalculatorUITests.swift @@ -0,0 +1,98 @@ +import XCTest + +final class EquityCalculatorUITests: XCTestCase { + func testDefaultHandsCalculatePreflopEquity() { + let app = XCUIApplication() + app.launch() + + app.staticTexts["Equity Calculator"].tap() + + let calculateButton = app.buttons["calculateButton"] + XCTAssertTrue(calculateButton.waitForExistence(timeout: 5)) + XCTAssertTrue(calculateButton.isEnabled, "Default hero/villain (AKs vs QQ) should already be a valid, calculable setup") + + calculateButton.tap() + + // The Result section renders below the fold on a short screen — Form/List lazily + // instantiates rows, so its content isn't in the accessibility tree (and can't be + // found by waitForExistence) until scrolled into view. + let heroWinRate = app.staticTexts["heroWinRate"] + scrollUntilVisible(heroWinRate, in: app) + XCTAssertTrue(heroWinRate.waitForExistence(timeout: 20)) + attachScreenshot(of: app, name: "preflop-result") + + // Read the percentage now, before scrolling further — once scrolled past, a Form + // row can be recycled/deallocated, and re-reading `.label` on a stale reference + // isn't reliable. + let heroPercent = Double(heroWinRate.label.replacingOccurrences(of: "%", with: "")) ?? -1 + + let tieRate = app.staticTexts["tieRate"] + let villainWinRate = app.staticTexts["villainWinRate"] + let equityMethodText = app.staticTexts["equityMethodText"] + scrollUntilVisible(equityMethodText, in: app) + XCTAssertTrue(tieRate.exists) + XCTAssertTrue(villainWinRate.exists) + XCTAssertTrue(equityMethodText.exists) + + // AKs vs QQ is close to a coinflip, not a blowout in either direction. + XCTAssertTrue(heroPercent > 30 && heroPercent < 60, "AKs vs QQ should be roughly 30-60%, got \(heroPercent)%") + } + + func testInvalidHandDisablesCalculate() { + let app = XCUIApplication() + app.launch() + + app.staticTexts["Equity Calculator"].tap() + + let heroField = app.textFields["heroNotationField"] + XCTAssertTrue(heroField.waitForExistence(timeout: 5)) + heroField.tap() + heroField.clearText() + heroField.typeText("XYZ") + + let calculateButton = app.buttons["calculateButton"] + XCTAssertFalse(calculateButton.isEnabled, "An invalid hand notation should disable Calculate") + } + + func testFlopBoardRequiresAllThreeCardsBeforeCalculating() { + let app = XCUIApplication() + app.launch() + + app.staticTexts["Equity Calculator"].tap() + + app.buttons["Flop"].tap() + + // Adding the 3 flop card rows pushes Calculate below the fold — same lazy-rendering + // situation as the Result section above. + let calculateButton = app.buttons["calculateButton"] + scrollUntilVisible(calculateButton, in: app) + XCTAssertTrue(calculateButton.waitForExistence(timeout: 5)) + XCTAssertFalse(calculateButton.isEnabled, "An unset flop should block calculation") + } + + /// Swipes up on `app` until `element` shows up in the accessibility tree (or gives up + /// after a bounded number of attempts) — SwiftUI's `Form`/`List` only instantiate rows + /// near the current viewport, so anything below the fold doesn't exist as far as + /// XCUITest is concerned until it's been scrolled to. + private func scrollUntilVisible(_ element: XCUIElement, in app: XCUIApplication, maxSwipes: Int = 8) { + for _ in 0..