Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions PokerKit/Sources/PokerKit/OmahaEquity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import Foundation

/// Win/tie/lose equity for Omaha/PLO hands — the `Equity` counterpart for 4-card hands,
/// built on `OmahaHandEvaluator` (which enforces the 2-hole/3-board rule) instead of
/// `HandEvaluator.bestHand`'s unrestricted "best 5 of 7." See `ai-docs/OMAHA.md` for the
/// validation against published PLO equity figures and this module's performance notes.
///
/// **Scope note**: unlike `Equity`, there's no `expandCanonical`/`canonicalVsCanonical`
/// equivalent here. Omaha has no standardized "canonical starting hand class" shorthand the
/// way Hold'em's "AKs"/"AKo" is standardized (see `OmahaHoleCards.notation`'s doc comment) —
/// building one is exactly the kind of preflop hand-strength judgment call this project
/// deliberately deferred to a later phase (see `ai-docs/OMAHA.md`). This module only ever
/// operates on concrete `OmahaHoleCards` or lists of them, never a hand-class string.
public enum OmahaEquity {
// MARK: - Exact: hand vs. hand

/// Exact win/tie/lose equity for `hero` vs. `villain`, given zero or more known board
/// cards — no sampling error, via `OmahaHandEvaluator.bestHand` at every possible
/// completion of the remaining board.
///
/// **Tractability is tighter than `Equity.headsUp`'s.** Each board completion costs `120`
/// 5-card evaluations here (`60` per side, the legal 2+3 enumeration) vs. `42` for
/// Hold'em's unrestricted 7-card `bestHand` — and Omaha's 8 known hole cards leave fewer
/// cards to draw the board from, so preflop there are *more* boards to enumerate too
/// (`C(44,5) = 1,086,008` vs. Hold'em's `C(48,5) = 1,712,304`). Net effect: an exact
/// preflop `headsUp` call here does roughly **2x** the work of the already slow (several
/// minutes) Hold'em equivalent — see `OMAHA.md`'s performance note. **This project
/// deliberately never calls this preflop** (not in a test, not from the app); use
/// `monteCarlo` there instead. Postflop (a flop or later already known), this is fast —
/// same shape as `Equity.headsUp`'s own tractability story.
///
/// - Precondition: `hero`, `villain`, and `board` share no cards.
public static func headsUp(hero: OmahaHoleCards, villain: OmahaHoleCards, board: [Card] = []) -> EquityResult {
let known = hero.cards + villain.cards + 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 = Equity.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: &current) { drawn in
let fullBoard = board + drawn
let heroHand = OmahaHandEvaluator.bestHand(hole: hero, board: fullBoard)
let villainHand = OmahaHandEvaluator.bestHand(hole: villain, board: 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 — identical shape and reproducibility guarantee to `Equity.monteCarlo`, built on
/// `OmahaHoleCards`/`OmahaHandEvaluator` instead.
public static func monteCarlo(
hero: [OmahaHoleCards],
villain: [OmahaHoleCards],
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.cards + villainHand.cards + board
let usedSet = Set(known)
guard usedSet.count == known.count else { continue } // card collision — retry

var pool = Equity.fullDeck.filter { !usedSet.contains($0) }
guard pool.count >= needed else { continue }

var drawn: [Card] = []
drawn.reserveCapacity(needed)
for _ in 0..<needed {
let index = Int(rng.next() % UInt64(pool.count))
drawn.append(pool.remove(at: index))
}

let fullBoard = board + drawn
let heroStrength = OmahaHandEvaluator.bestHand(hole: heroHand, board: fullBoard)
let villainStrength = OmahaHandEvaluator.bestHand(hole: villainHand, board: fullBoard)
trials += 1
if heroStrength > 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
)
}

/// Convenience: one fixed hero hand vs. a villain range.
public static func handVsRange(
hero: OmahaHoleCards,
villainRange: [OmahaHoleCards],
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: [OmahaHoleCards],
villainRange: [OmahaHoleCards],
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

/// Identical in shape to `Equity`'s own combination-walker — duplicated (not shared)
/// because that one is `private` to `Equity`, both are ~15 lines, and this module
/// deliberately keeps a zero-diff footprint on `Equity.swift` (see this file's doc
/// comment) rather than widening an existing type's access control just to share it.
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: &current, action: action)
current.removeLast()
}
}
}
61 changes: 61 additions & 0 deletions PokerKit/Sources/PokerKit/OmahaHandEvaluator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation

/// Omaha's defining hand-construction rule, and the *only* thing that makes Omaha hand
/// evaluation different from Hold'em's "best 5 of any 7": the best 5-card hand must use
/// **exactly 2** of hero's 4 hole cards and **exactly 3** of the 5 board cards — never more,
/// never fewer of either. A hole card you don't use is simply dead; you cannot "borrow" a
/// 3rd hole card even if it would make a better hand, and you cannot play the board alone
/// (0 hole cards) or with only 1.
///
/// **This module doesn't re-rank anything** — `HandEvaluator` (unmodified) still does 100% of
/// the actual 5-card ranking. `OmahaHandEvaluator` only enumerates which 5-card
/// *combinations* are legal to hand to it: exactly `C(4,2) × C(5,3) = 6 × 10 = 60` per hole/
/// board pairing, taking the best of all 60. See `ai-docs/OMAHA.md` for the tests that prove
/// this constraint is actually enforced (not just assumed) — the two clearest cases are a
/// hole hand that could look like quads/a flush if the 2-card cap were ignored, but can't
/// legally reach that hand once it's enforced.
public enum OmahaHandEvaluator {
/// The `C(4,2) = 6` ways to choose 2 of 4 hole-card indices, precomputed once.
private static let fourChooseTwoIndices: [(Int, Int)] = {
var result: [(Int, Int)] = []
for a in 0..<4 {
for b in (a + 1)..<4 {
result.append((a, b))
}
}
return result
}()

/// The `C(5,3) = 10` ways to choose 3 of 5 board-card indices, precomputed once.
private static let fiveChooseThreeIndices: [(Int, Int, Int)] = {
var result: [(Int, Int, Int)] = []
for a in 0..<5 {
for b in (a + 1)..<5 {
for c in (b + 1)..<5 {
result.append((a, b, c))
}
}
}
return result
}()

/// The best *legal* 5-card `HandStrength` for `hole` given a **completed** 5-card
/// `board`. Requires the full river (unlike `HandEvaluator.bestHand`, which accepts 5-7
/// cards) because Omaha's 2-and-3 split is only well-defined against a specific board
/// size — evaluating "best legal hand on the turn" is a different, `OmahaEquity`-level
/// concept (averaging over every possible river), not something this function does
/// itself.
public static func bestHand(hole: OmahaHoleCards, board: [Card]) -> HandStrength {
precondition(board.count == 5, "OmahaHandEvaluator.bestHand needs a completed 5-card board, got \(board.count)")

var best: HandStrength?
for (h1, h2) in fourChooseTwoIndices {
for (b1, b2, b3) in fiveChooseThreeIndices {
let five = [hole.cards[h1], hole.cards[h2], board[b1], board[b2], board[b3]]
let candidate = HandEvaluator.bestHand(from: five)
if best == nil || candidate > best! { best = candidate }
}
}
return best!
}
}
125 changes: 125 additions & 0 deletions PokerKit/Sources/PokerKit/OmahaHoleCards.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import Foundation

/// Four hole cards — Omaha/PLO's hand size, **added alongside** Hold'em's `HoleCards`, never
/// replacing or modifying it. Every existing NLHE model (`ChenScore`, `PushFoldRange`,
/// `OpeningRange`, `CallingRange`, `ThreeBetRange`, `FourBetRange`, `Equity`) is untouched by
/// this type's existence — see `ai-docs/OMAHA.md` for why Omaha needs its own foundation
/// rather than reusing those (Chen's heuristic and every range model built on it is
/// specifically a 2-card scoring system; it has no defined meaning for 4 cards).
///
/// Stored in a canonical (rank-descending, then suit) order, unlike `HoleCards` (which keeps
/// `first`/`second` in whatever order they were constructed and doesn't normalize) — a
/// 4-card hand has no natural "first/second" the way two hole cards do, so there's no
/// meaningful order to preserve, and canonicalizing means two `OmahaHoleCards` built from the
/// same 4 cards in different input orders compare equal and hash identically.
public struct OmahaHoleCards: Hashable, Sendable {
public let cards: [Card]

/// Fails unless `cards` is exactly 4 distinct cards.
public init?(_ cards: [Card]) {
guard cards.count == 4, Set(cards).count == 4 else { return nil }
self.cards = cards.sorted { $0.rank != $1.rank ? $0.rank > $1.rank : $0.suit.rawValue < $1.suit.rawValue }
}

public init?(_ a: Card, _ b: Card, _ c: Card, _ d: Card) {
self.init([a, b, c, d])
}

/// Explicit per-card notation, e.g. `"AsAhKdQc"` — four 2-character rank+suit tokens,
/// unambiguous and exactly round-trips through `init(canonical:)`. Deliberately *not* a
/// Hold'em-style shorthand ("AAKQs" or similar) — see the type's doc comment and
/// `suitPattern` below for why this project didn't invent one for Phase 1.
public var notation: String { cards.map(\.notation).joined() }

/// Parses `notation`'s own output, or `nil` for anything else (wrong length, an invalid
/// rank/suit token, or a repeated card).
public init?(canonical: String) {
let chars = Array(canonical)
guard chars.count == 8 else { return nil }
var parsed: [Card] = []
parsed.reserveCapacity(4)
for i in stride(from: 0, to: 8, by: 2) {
guard let card = Card(notation: String(chars[i...(i + 1)])) else { return nil }
parsed.append(card)
}
self.init(parsed)
}

// MARK: - Suit pattern (descriptive, not part of the parseable notation)

/// How PLO players actually describe a starting hand's suit shape — how many *disjoint
/// pairs* of the 4 cards could each independently complete a flush. This is **purely
/// descriptive, computed from the cards**, not part of `notation`/`init(canonical:)` — as
/// far as this project found, there's no single standardized shorthand for it the way
/// Hold'em's "s"/"o" suffix is standardized (see `ai-docs/OMAHA.md`), so Phase 1 doesn't
/// invent one to parse.
///
/// **`.singleSuited` also covers 3-flush and 4-flush hands** (3 or all 4 cards sharing
/// one suit) — structurally still "exactly one suit has 2+ cards," even though a 3- or
/// 4-flush is a well-known *weaker* structure than a clean 2+2 single-suited hand (only
/// 2 of the same-suited cards can ever be used together, per the 2-hole-card rule — see
/// `OmahaHandEvaluator`). This label is a structural fact, not a strength judgment;
/// distinguishing "how good" a suit pattern is belongs with the hand-strength work this
/// project deliberately deferred to Phase 2.
public enum SuitPattern: String, Sendable {
case doubleSuited = "Double Suited"
case singleSuited = "Single Suited"
case rainbow = "Rainbow"
}

public var suitPattern: SuitPattern {
var counts: [Suit: Int] = [:]
for card in cards { counts[card.suit, default: 0] += 1 }
let suitsWithTwoOrMore = counts.values.filter { $0 >= 2 }.count
if suitsWithTwoOrMore >= 2 { return .doubleSuited }
if suitsWithTwoOrMore == 1 { return .singleSuited }
return .rainbow
}

public static func random(using generator: inout RandomNumberGenerator) -> OmahaHoleCards {
var deck: [Card] = []
for rank in Rank.allCases {
for suit in Suit.allCases {
deck.append(Card(rank: rank, suit: suit))
}
}
deck.shuffle(using: &generator)
return OmahaHoleCards([deck[0], deck[1], deck[2], deck[3]])!
}

public static func random() -> OmahaHoleCards {
var rng: RandomNumberGenerator = SystemRandomNumberGenerator()
return random(using: &rng)
}
}

extension Card {
/// Parses a 2-character rank+suit token like `"As"`, `"Th"`, `"2c"` (suit letter
/// case-insensitive) — the standard shorthand for writing out one concrete card as text.
/// Lives here (alongside the first type that needs it) rather than in `Card.swift`
/// itself, matching `HoleCards.swift`'s own precedent of hosting `Rank.from(symbol:)`
/// even though `Rank` is declared elsewhere.
public init?(notation: String) {
let chars = Array(notation)
guard chars.count == 2, let rank = Rank.from(symbol: chars[0]) else { return nil }
switch chars[1] {
case "s", "S": self.init(rank: rank, suit: .spades)
case "h", "H": self.init(rank: rank, suit: .hearts)
case "d", "D": self.init(rank: rank, suit: .diamonds)
case "c", "C": self.init(rank: rank, suit: .clubs)
default: return nil
}
}

/// The inverse of `init(notation:)` — e.g. `"As"`, `"Th"`, `"2c"`.
public var notation: String { "\(rank.symbol)\(Self.suitLetter(suit))" }

private static func suitLetter(_ suit: Suit) -> String {
switch suit {
case .spades: return "s"
case .hearts: return "h"
case .diamonds: return "d"
case .clubs: return "c"
}
}
}
4 changes: 4 additions & 0 deletions PokerKit/Sources/PokerKit/StudyTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ public enum StudyTool: String, CaseIterable, Identifiable, Hashable, Sendable {
case preflopRanges
case pushFold
case equityCalculator
case omahaEquityCalculator
case icmCalculator
case gameFormats
case bankroll
Expand All @@ -18,6 +19,7 @@ public enum StudyTool: String, CaseIterable, Identifiable, Hashable, Sendable {
case .preflopRanges: return "Preflop Ranges"
case .pushFold: return "Push/Fold Trainer"
case .equityCalculator: return "Equity Calculator"
case .omahaEquityCalculator: return "Omaha Equity (Beta)"
case .icmCalculator: return "ICM Calculator"
case .gameFormats: return "Game Format"
case .bankroll: return "Bankroll Tracker"
Expand All @@ -34,6 +36,8 @@ public enum StudyTool: String, CaseIterable, Identifiable, Hashable, Sendable {
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 .omahaEquityCalculator:
return "Win/tie/lose probability for two 4-card Omaha/PLO hands, entered as explicit notation. Phase 1 foundation — no preflop hand-strength/ranges yet."
case .icmCalculator:
return "Exact tournament $EV for any set of stacks and a payout structure — Malmuth-Harville ICM, not a rule of thumb."
case .gameFormats:
Expand Down
Loading
Loading