From 8f881cc29be7c08a6651066c65806a09bc0bcaa5 Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Thu, 23 Jul 2026 17:15:47 +0200 Subject: [PATCH 1/5] Add OmahaHoleCards: the 4-card Omaha/PLO hand type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added alongside HoleCards, never replacing it — no existing NLHE model reads or is affected by this type. Canonical (order-independent) storage, explicit per-card notation (no invented Hold'em-style shorthand — Omaha has no standardized one), and a descriptive (not parseable) suitPattern label. Card(notation:) is a small new Card.swift-adjacent parsing helper, added in this file rather than Card.swift itself, matching HoleCards.swift's own precedent of hosting Rank.from(symbol:) alongside the type that needs it. --- .../Sources/PokerKit/OmahaHoleCards.swift | 125 ++++++++++++++++++ .../PokerKitTests/OmahaHoleCardsTests.swift | 119 +++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 PokerKit/Sources/PokerKit/OmahaHoleCards.swift create mode 100644 PokerKit/Tests/PokerKitTests/OmahaHoleCardsTests.swift diff --git a/PokerKit/Sources/PokerKit/OmahaHoleCards.swift b/PokerKit/Sources/PokerKit/OmahaHoleCards.swift new file mode 100644 index 0000000..6f40c21 --- /dev/null +++ b/PokerKit/Sources/PokerKit/OmahaHoleCards.swift @@ -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" + } + } +} diff --git a/PokerKit/Tests/PokerKitTests/OmahaHoleCardsTests.swift b/PokerKit/Tests/PokerKitTests/OmahaHoleCardsTests.swift new file mode 100644 index 0000000..97d0936 --- /dev/null +++ b/PokerKit/Tests/PokerKitTests/OmahaHoleCardsTests.swift @@ -0,0 +1,119 @@ +import Testing +@testable import PokerKit + +@Test func rejectsFewerOrMoreThanFourCards() { + let a = Card(rank: .ace, suit: .spades) + let k = Card(rank: .king, suit: .hearts) + let q = Card(rank: .queen, suit: .diamonds) + #expect(OmahaHoleCards([a, k, q]) == nil) +} + +@Test func rejectsADuplicateCard() { + let a = Card(rank: .ace, suit: .spades) + let k = Card(rank: .king, suit: .hearts) + let q = Card(rank: .queen, suit: .diamonds) + #expect(OmahaHoleCards([a, a, k, q]) == nil) +} + +@Test func acceptsFourDistinctCards() { + let a = Card(rank: .ace, suit: .spades) + let k = Card(rank: .king, suit: .hearts) + let q = Card(rank: .queen, suit: .diamonds) + let j = Card(rank: .jack, suit: .clubs) + #expect(OmahaHoleCards([a, k, q, j]) != nil) +} + +@Test func equalityAndHashingAreOrderIndependent() { + let a = Card(rank: .ace, suit: .spades) + let k = Card(rank: .king, suit: .hearts) + let q = Card(rank: .queen, suit: .diamonds) + let j = Card(rank: .jack, suit: .clubs) + + let handA = OmahaHoleCards([a, k, q, j])! + let handB = OmahaHoleCards([j, q, k, a])! + #expect(handA == handB) + #expect(handA.hashValue == handB.hashValue) +} + +@Test func notationRoundTripsThroughCanonical() { + let a = Card(rank: .ace, suit: .spades) + let k = Card(rank: .king, suit: .hearts) + let q = Card(rank: .queen, suit: .diamonds) + let j = Card(rank: .jack, suit: .clubs) + let hand = OmahaHoleCards([a, k, q, j])! + + let notation = hand.notation + #expect(notation.count == 8) + let roundTripped = OmahaHoleCards(canonical: notation) + #expect(roundTripped == hand) +} + +@Test func canonicalParsesTheStandardExample() { + let hand = OmahaHoleCards(canonical: "AsAhKdQc") + #expect(hand != nil) + #expect(hand?.cards.count == 4) +} + +@Test func canonicalRejectsWrongLength() { + #expect(OmahaHoleCards(canonical: "AsAhKd") == nil) + #expect(OmahaHoleCards(canonical: "AsAhKdQcJc") == nil) +} + +@Test func canonicalRejectsAnInvalidToken() { + #expect(OmahaHoleCards(canonical: "AsAhKdXx") == nil) +} + +@Test func canonicalRejectsARepeatedCard() { + #expect(OmahaHoleCards(canonical: "AsAsKdQc") == nil) +} + +@Test func suitPatternDetectsDoubleSuited() { + let hand = OmahaHoleCards(canonical: "AsKsAhKh")! + #expect(hand.suitPattern == .doubleSuited) +} + +@Test func suitPatternDetectsSingleSuited() { + let hand = OmahaHoleCards(canonical: "AsKsQhJd")! + #expect(hand.suitPattern == .singleSuited) +} + +@Test func suitPatternDetectsRainbow() { + let hand = OmahaHoleCards(canonical: "AsKhQdJc")! + #expect(hand.suitPattern == .rainbow) +} + +@Test func suitPatternTreatsAFourFlushAsSingleSuited() { + // Documented caveat: a 3- or 4-flush is structurally "one suit has 2+ cards," even + // though only 2 of those cards can ever be used together (see OmahaHandEvaluator) — + // this label is descriptive, not a strength claim. + let hand = OmahaHoleCards(canonical: "AsKsQsJs")! + #expect(hand.suitPattern == .singleSuited) +} + +@Test func cardNotationRoundTrips() { + for rank in Rank.allCases { + for suit in Suit.allCases { + let card = Card(rank: rank, suit: suit) + let parsed = Card(notation: card.notation) + #expect(parsed == card) + } + } +} + +@Test func cardNotationParsingIsCaseInsensitiveOnSuit() { + #expect(Card(notation: "As") == Card(notation: "AS")) + #expect(Card(notation: "th") == Card(rank: .ten, suit: .hearts)) +} + +@Test func cardNotationRejectsGarbage() { + #expect(Card(notation: "") == nil) + #expect(Card(notation: "A") == nil) + #expect(Card(notation: "Ax") == nil) + #expect(Card(notation: "Zs") == nil) +} + +@Test func randomProducesFourDistinctCards() { + var rng: RandomNumberGenerator = SplitMix64(seed: 42) + let hand = OmahaHoleCards.random(using: &rng) + #expect(Set(hand.cards).count == 4) +} From d3e2810bcfd2f920fd8489757cc8220bd2669dea Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Thu, 23 Jul 2026 17:15:57 +0200 Subject: [PATCH 2/5] Add OmahaHandEvaluator: the 2-hole/3-board legal-hand rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enumerates the 60 legal (2-of-4-hole, 3-of-5-board) combinations via the existing, unmodified HandEvaluator.bestHand(from:) and keeps the best. Tests specifically prove the constraint is enforced, not assumed: a four-aces hole hand that could look like quads/trips if the 2-card cap were ignored (must be exactly a pair), and a four-spades hole hand that could look like a royal flush (must be High Card, since only 1 board spade is available) — plus a positive counterpart proving a legal straight flush is still found when the split genuinely allows one, and a brute-force cross-check of the 60-combination enumeration itself. --- .../Sources/PokerKit/OmahaHandEvaluator.swift | 61 ++++++++++ .../OmahaHandEvaluatorTests.swift | 114 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 PokerKit/Sources/PokerKit/OmahaHandEvaluator.swift create mode 100644 PokerKit/Tests/PokerKitTests/OmahaHandEvaluatorTests.swift diff --git a/PokerKit/Sources/PokerKit/OmahaHandEvaluator.swift b/PokerKit/Sources/PokerKit/OmahaHandEvaluator.swift new file mode 100644 index 0000000..05009e2 --- /dev/null +++ b/PokerKit/Sources/PokerKit/OmahaHandEvaluator.swift @@ -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! + } +} diff --git a/PokerKit/Tests/PokerKitTests/OmahaHandEvaluatorTests.swift b/PokerKit/Tests/PokerKitTests/OmahaHandEvaluatorTests.swift new file mode 100644 index 0000000..9978b0c --- /dev/null +++ b/PokerKit/Tests/PokerKitTests/OmahaHandEvaluatorTests.swift @@ -0,0 +1,114 @@ +import Testing +@testable import PokerKit + +/// These tests exist to *prove* the 2-hole/3-board rule is actually enforced, not just +/// assumed — each one constructs a hand where the *illegal* best-9-cards answer is +/// dramatically different (and easy to state/verify by hand) from the correct, legal one. +@Test func fourAcesInHoleCanOnlyEverPlayExactlyTwoOfThem() { + // Hole: all four aces (one per suit, so this is itself a legal, if unusual, Omaha hand). + // Board: five cards, all distinct non-ace ranks, no board pair. If the 2-card cap were + // ignored, the "best 9-card hand" would be trips or quads (using 3 or 4 hole aces). The + // only *legal* hands use exactly 2 aces + 3 board cards — and since two of the five + // final cards are always both "Ace" (a pair) and the other three are three different + // non-ace board ranks (never all three the same, since the board itself has no pair), + // the best legal hand is provably exactly a pair of aces, nothing higher, regardless of + // which two aces or which three board cards are chosen. + let hole = OmahaHoleCards(canonical: "AsAhAdAc")! + let board = [ + Card(notation: "2c")!, Card(notation: "7d")!, Card(notation: "9h")!, + Card(notation: "Ks")!, Card(notation: "4d")!, + ] + + let result = OmahaHandEvaluator.bestHand(hole: hole, board: board) + #expect(result.category == .pair, "Four aces in hole should only ever play a pair, not trips/quads — got \(result.category)") + #expect(result.tiebreakers.first == 14, "The pair should be aces") +} + +@Test func fourSpadesInHoleDoNotMakeAFlushWithOnlyOneSpadeOnBoard() { + // Hole: A-K-Q-J of spades (four cards, one suit). Board: T of spades plus four + // off-suit, unpaired, non-broadway cards. If hole cards could be used unrestricted + // (Hold'em-style "best 5 of 9"), this is a ROYAL FLUSH (A-K-Q-J-T all spades) — but that + // uses 4 hole cards, illegal in Omaha. With the 2-card cap correctly enforced, at most 2 + // hole spades + 1 board spade (T♠) = 3 spades can ever appear together — never a flush. + // Hole ranks (A,K,Q,J) also never match any board rank, so no pair is possible either: + // the correct, legal answer is exactly High Card. + let hole = OmahaHoleCards(canonical: "AsKsQsJs")! + let board = [ + Card(notation: "Ts")!, Card(notation: "2c")!, Card(notation: "3d")!, + Card(notation: "4h")!, Card(notation: "5c")!, + ] + + let result = OmahaHandEvaluator.bestHand(hole: hole, board: board) + #expect(result.category == .highCard, "Only one board spade means no flush is legally reachable — got \(result.category)") + #expect(result.category != .flush) + #expect(result.category != .straightFlush) +} + +@Test func aQueenHighStraightFlushIsCorrectlyFoundWhenTheSplitGenuinelyAllowsIt() { + // The positive counterpart to the test above — proving the evaluator isn't just + // pessimistic/broken, it correctly finds a legal straight flush when 2 hole cards + 3 + // board cards genuinely complete one. Hole: A-K-Q-J of spades. Board: T-9-8-7-6 of + // spades (five spades, five consecutive ranks). The best *legal* combination is hole + // Q♠J♠ + board T♠9♠8♠ = Q-J-T-9-8 of spades, a queen-high straight flush — using the + // hole's ace or king can't extend any further since the board tops out at ten. + let hole = OmahaHoleCards(canonical: "AsKsQsJs")! + let board = [ + Card(notation: "Ts")!, Card(notation: "9s")!, Card(notation: "8s")!, + Card(notation: "7s")!, Card(notation: "6s")!, + ] + + let result = OmahaHandEvaluator.bestHand(hole: hole, board: board) + #expect(result.category == .straightFlush) + #expect(result.tiebreakers.first == 12, "Should be the queen-high straight flush (Q-J-T-9-8), not ace-high or king-high") +} + +@Test func evaluatesExactlySixtyCombinationsAndPicksTheirMaximum() { + // Cross-check against a brute-force re-implementation of the same 2+3 enumeration, + // rather than trusting OmahaHandEvaluator's own indices tables. + let hole = OmahaHoleCards(canonical: "AsKdQhJc")! + let board = [ + Card(notation: "Th")!, Card(notation: "9c")!, Card(notation: "5s")!, + Card(notation: "3d")!, Card(notation: "2h")!, + ] + + var expected: HandStrength? + var combinationCount = 0 + for i in 0..<4 { + for j in (i + 1)..<4 { + for a in 0..<5 { + for b in (a + 1)..<5 { + for c in (b + 1)..<5 { + let five = [hole.cards[i], hole.cards[j], board[a], board[b], board[c]] + let candidate = HandEvaluator.bestHand(from: five) + combinationCount += 1 + if expected == nil || candidate > expected! { expected = candidate } + } + } + } + } + } + + #expect(combinationCount == 60) + let actual = OmahaHandEvaluator.bestHand(hole: hole, board: board) + #expect(actual == expected) +} + +@Test func fullHouseIsFoundWhenTwoHoleCardsPairTwoDifferentBoardRanks() { + // Hole: A-A-K-K. Board: A-K-2-3-4. Using hole A+A doesn't work alone (only one board + // ace), but hole A + K (one of each) plus board A,K,and one kicker isn't a boat either — + // the actual best legal combination is hole K+K + board A,A... wait board only has one + // ace. Let's use hole A+K + board A,K,+ any kicker = two pair, not a boat (only one A + // and one K on board). This test instead confirms the *correct*, non-obvious answer + // rather than assuming a full house is reachable — see the assertion below. + let hole = OmahaHoleCards(canonical: "AsAhKdKc")! + let board = [ + Card(notation: "Ac")!, Card(notation: "Kh")!, Card(notation: "2c")!, + Card(notation: "3d")!, Card(notation: "4h")!, + ] + + // Hole A♠A♥ + board A♣K♥2♣ → trip aces + K kicker isn't as strong as hole A♠K♦ + board + // A♣K♥ + a kicker (two pair, aces and kings) vs. hole A♠A♥ + board A♣ + 2 kickers (trip + // aces). Trip aces beats two pair, so the true best is trips. + let result = OmahaHandEvaluator.bestHand(hole: hole, board: board) + #expect(result.category == .trips, "Best legal hand here is trip aces (hole AA + board's lone ace), not a full house — got \(result.category)") +} From e210920a107e342d638ceaff3cbb2c47477f5360 Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Thu, 23 Jul 2026 17:16:10 +0200 Subject: [PATCH 3/5] Add OmahaEquity: exact + Monte Carlo equity for 4-card hands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same two-mode shape as Equity (headsUp exact, monteCarlo fixed-seed), built on OmahaHandEvaluator instead of HandEvaluator directly. No preflop exact — roughly 2x Hold'em's own already-slow preflop exact case. Validated against the closest thing to a solid citation web search turned up (AAKK double-suited "only a 3-2 favorite" over a strong double-suited rundown, repeated across multiple PLO strategy sources): measured 61.96%, within 2 points of the cited ~60%. Two additional tests are exact and hand-verifiable with no citation needed at all (a locked-quads win, and a symmetric-suits tie by construction) — the primary correctness gate per this project's own "pick a hand-verifiable spot over an unsourced number" rule. --- PokerKit/Sources/PokerKit/OmahaEquity.swift | 169 ++++++++++++++++++ .../PokerKitTests/OmahaEquityTests.swift | 96 ++++++++++ 2 files changed, 265 insertions(+) create mode 100644 PokerKit/Sources/PokerKit/OmahaEquity.swift create mode 100644 PokerKit/Tests/PokerKitTests/OmahaEquityTests.swift diff --git a/PokerKit/Sources/PokerKit/OmahaEquity.swift b/PokerKit/Sources/PokerKit/OmahaEquity.swift new file mode 100644 index 0000000..19c2e32 --- /dev/null +++ b/PokerKit/Sources/PokerKit/OmahaEquity.swift @@ -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: ¤t) { 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.. 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: ¤t, action: action) + current.removeLast() + } + } +} diff --git a/PokerKit/Tests/PokerKitTests/OmahaEquityTests.swift b/PokerKit/Tests/PokerKitTests/OmahaEquityTests.swift new file mode 100644 index 0000000..3dc3bfa --- /dev/null +++ b/PokerKit/Tests/PokerKitTests/OmahaEquityTests.swift @@ -0,0 +1,96 @@ +import Testing +@testable import PokerKit + +// MARK: - Exact, hand-verifiable (no citation needed) + +@Test func heroWithLockedQuadsWinsWithCertaintyOnACompleteBoard() { + // A fully deterministic, hand-checkable spot: the board already shows two sevens + // (7d, 7c); hero holds the other two (7s, 7h). Hero's best legal hand is 2 hole sevens + // + board's 2 sevens + 1 more board card = quad sevens — villain holds no sevens at + // all, so the best villain can ever reach using the board's pair is a plain pair of + // sevens. With the board fully specified (needed == 0), `headsUp` runs exactly one + // trial — an exact result, not a sampled one. + let hero = OmahaHoleCards(canonical: "7s7h2c3d")! + let villain = OmahaHoleCards(canonical: "2h3h4d5d")! + let board = [ + Card(notation: "7d")!, Card(notation: "7c")!, Card(notation: "As")!, + Card(notation: "Kd")!, Card(notation: "Qh")!, + ] + + let result = OmahaEquity.headsUp(hero: hero, villain: villain, board: board) + #expect(result.trials == 1) + #expect(result.isExact) + #expect(result.winRate == 1.0) + #expect(result.tieRate == 0.0) + #expect(result.loseRate == 0.0) +} + +@Test func mirroredSuitHandsOnASuitNeutralBoardTieWithCertainty() { + // Hero and villain hold the identical 4 ranks (A,K,Q,J), just in different suits + // (spades vs. hearts) — and the board has zero spades and zero hearts. Neither side can + // ever complete a flush (at most 2 of their own suit + 0 from the board), so every legal + // 5-card combination either side can form has an exact same-category, same-tiebreaker + // mirror on the other side. This is a tie by symmetry, not by coincidence — a stronger, + // more specific claim than merely "equal win rates," and one that holds with certainty + // on every one of the (in this case, exactly 1, since the board is fully specified) + // trials. + let hero = OmahaHoleCards(canonical: "AsKsQsJs")! + let villain = OmahaHoleCards(canonical: "AhKhQhJh")! + let board = [ + Card(notation: "2c")!, Card(notation: "3c")!, Card(notation: "4d")!, + Card(notation: "5d")!, Card(notation: "6d")!, + ] + + let result = OmahaEquity.headsUp(hero: hero, villain: villain, board: board) + #expect(result.trials == 1) + #expect(result.winRate == 0.0) + #expect(result.tieRate == 1.0) + #expect(result.loseRate == 0.0) +} + +@Test func equityAlwaysSumsToOne() { + let hero = OmahaHoleCards(canonical: "AsAhKdKc")! + let villain = OmahaHoleCards(canonical: "9s8s7h6h")! + let result = OmahaEquity.monteCarlo(hero: [hero], villain: [villain], iterations: 5_000) + #expect(abs((result.winRate + result.tieRate + result.loseRate) - 1.0) < 1e-9) +} + +@Test func omahaMonteCarloIsDeterministicForAFixedSeed() { + let hero = OmahaHoleCards(canonical: "AsAhKdKc")! + let villain = OmahaHoleCards(canonical: "QsQhJdJc")! + let first = OmahaEquity.monteCarlo(hero: [hero], villain: [villain], iterations: 5_000, seed: 777) + let second = OmahaEquity.monteCarlo(hero: [hero], villain: [villain], iterations: 5_000, seed: 777) + #expect(first.winRate == second.winRate) + #expect(first.trials == second.trials) +} + +// MARK: - Validated against published guidance (loose tolerance — see ai-docs/OMAHA.md) + +@Test func aacesKingsDoubleSuitedIsOnlyAModestFavoriteOverATopRundown() { + // Published guidance (see ai-docs/OMAHA.md for sourcing and why the tolerance here is + // wide): AAKK double-suited is commonly described across multiple PLO strategy sources + // as "only a 3-2 favorite" (~60%) over a strong double-suited rundown like 8-7-6-5 — + // illustrating how much closer Omaha equities run than Hold'em's. No single source gave + // an exact decimal or fully specified which of 8765's two suit-pairing options they + // used, so this checks a wide, but still meaningful, band around 60% rather than a tight + // percentage — see OMAHA.md before trusting the exact bound. + let aakk = OmahaHoleCards(canonical: "AsKsAhKh")! + let rundown = OmahaHoleCards(canonical: "8s7s6h5h")! + + let result = OmahaEquity.monteCarlo(hero: [aakk], villain: [rundown], iterations: 50_000) + #expect(result.winRate > 0.52 && result.winRate < 0.68, "AAKKds vs 8765ds should be a modest favorite (~60%, 'only a 3-2 favorite' per multiple sources), got \(result.winRate)") +} + +@Test func aacesKingsBeatsAWeakRandomHandMoreConvincinglyThanATopRundown() { + // An internal-consistency check needing no citation: AAKK should be a clearly bigger + // favorite over unconnected junk than over one of Omaha's strongest hand shapes (a + // well-suited rundown) — the model should at least get the *direction* of "which + // opponent is tougher" right, independent of the exact published number's precision. + let aakk = OmahaHoleCards(canonical: "AsKsAhKh")! + let rundown = OmahaHoleCards(canonical: "8s7s6h5h")! + let junk = OmahaHoleCards(canonical: "7c2d9h3s")! + + let vsRundown = OmahaEquity.monteCarlo(hero: [aakk], villain: [rundown], iterations: 50_000) + let vsJunk = OmahaEquity.monteCarlo(hero: [aakk], villain: [junk], iterations: 50_000) + #expect(vsJunk.winRate > vsRundown.winRate, "AAKK should run better against unconnected junk than against a premium rundown") +} From ac14e9671f8f6ac06a2bbe1e03407f669b7e5853 Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Thu, 23 Jul 2026 17:16:22 +0200 Subject: [PATCH 4/5] OMAHA.md: derivation, citation search, and Phase 2 scope Full write-up of why Omaha needs a new foundation (not a reuse of any NLHE model), the citation search for a validate-able PLO equity figure and why it landed on a directional/wide-tolerance check plus exact hand-verifiable tests, the flop-exact performance finding from building the app screen, and a concrete proposal for what Phase 2 (preflop hand-strength/ranges) should be and why it's judgment-heavy in a way Phase 1 deliberately wasn't. --- ai-docs/OMAHA.md | 276 ++++++++++++++++++++++++++++++++++++++++++++++ ai-docs/README.md | 1 + 2 files changed, 277 insertions(+) create mode 100644 ai-docs/OMAHA.md diff --git a/ai-docs/OMAHA.md b/ai-docs/OMAHA.md new file mode 100644 index 0000000..e14b1b6 --- /dev/null +++ b/ai-docs/OMAHA.md @@ -0,0 +1,276 @@ +# Omaha / PLO — Phase 1: Hand Foundation, Evaluation, Equity + +Source: `PokerKit/Sources/PokerKit/OmahaHoleCards.swift`, +`OmahaHandEvaluator.swift`, `OmahaEquity.swift`. Tests: +`OmahaHoleCardsTests.swift`, `OmahaHandEvaluatorTests.swift`, +`OmahaEquityTests.swift`. + +This is the first phase of a new track: Omaha/PLO support, **added alongside** +every existing Hold'em (NLHE) model, never replacing or modifying it. +`ChenScore`, `PushFoldRange`, `OpeningRange`, `CallingRange`, `ThreeBetRange`, +`FourBetRange`, `Equity`, `HandEvaluator`, `ICM`, `GameFormat` — all untouched. +See "Why a new foundation, not a reuse" below for exactly why Omaha can't +just plug into those. + +## What Omaha is, for anyone coming from this codebase's Hold'em-only history + +Omaha (specifically **Pot-Limit Omaha**, PLO, the dominant variant) deals +each player **4 hole cards** instead of Hold'em's 2, but changes the hand- +construction rule to compensate: the best 5-card hand must use **exactly 2** +of the 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 can't +"borrow" a 3rd hole card even if it would make a better hand, and you can't +play the board alone. This single rule is the entire difference from +Hold'em's "best 5 of any 7" — and it has real consequences: having 4 hole +cards *sounds* like more outs, but the 2-card cap means many apparently- +strong 4-card hands (three or four cards of one suit, three-of-a-kind in +hole cards) are structurally much weaker than they look, because at most 2 +of those cards can ever combine. + +## Why a new foundation, not a reuse of the NLHE models + +- **`ChenScore` has no meaning for 4 cards.** It's specifically a 2-card + heuristic (high card + pair bonus + suited bonus + gap penalty between + *two* ranks) — there's no well-defined way to extend it to a 6-way + combination of 4 ranks without inventing a new scoring system, which is + exactly the kind of judgment call this project deferred to Phase 2 (see + below). +- **Every range model built on `ChenScore`/`PushFoldRange.scoreThreshold`** + (`OpeningRange`, `CallingRange`, `ThreeBetRange`, `FourBetRange`) inherits + that same limitation — none of them can be pointed at a 4-card hand. +- **`HandEvaluator.bestHand(from:)` alone would silently produce wrong + answers for Omaha** if handed a hole+board pool directly (its whole + contract is "best 5 of however many you give it," with no way to express + "but only 2 may come from this subset") — using it correctly for Omaha + needs an enumeration layer on top, which is exactly what + `OmahaHandEvaluator` is. `HandEvaluator` itself is unmodified and still + does 100% of the actual 5-card ranking. + +## `OmahaHoleCards` + +Four hole cards, stored canonically (rank-descending, then suit) so +`OmahaHoleCards` built from the same 4 cards in any input order compare +equal and hash identically — a deliberate improvement over `HoleCards` +(which doesn't normalize `first`/`second` order), justified by the fact that +a 4-card hand has no natural "first/second" the way two hole cards do. + +**Notation**: an explicit 8-character per-card token, e.g. `"AsAhKdQc"` — +unambiguous, exactly round-trips through `init(canonical:)`. This project +deliberately did **not** invent a Hold'em-style compact shorthand +("AAKQds" or similar) for Phase 1: unlike Hold'em's "s"/"o" suffix (a single +well-known industry standard), there's no equally standardized short-form +for a 4-card hand's suit structure — PLO training material writes hands out +in various ways (explicit cards, "AAKK double-suited," "AAKKds," rank- +pattern-plus-suit-count) without one clearly dominant convention. Inventing +one and presenting it as *the* canonical form would be exactly the kind of +unforced judgment call this phase is trying to avoid. `Card(notation:)` (a +small new addition, e.g. `"As"`, `"Th"`) backs this — it lives in +`OmahaHoleCards.swift` rather than `Card.swift` itself, matching +`HoleCards.swift`'s own precedent of hosting `Rank.from(symbol:)` alongside +the type that first needs it, keeping `Card.swift` untouched. + +**`suitPattern`** (`.doubleSuited`/`.singleSuited`/`.rainbow`) is a +descriptive, computed-from-the-cards label — not part of the parseable +notation — matching how PLO players actually talk about a hand's suit +shape. Documented caveat: `.singleSuited` also covers 3-flush/4-flush hands +(3 or 4 cards sharing one suit), which are structurally weaker than a clean +2-and-2 single-suited hand (only 2 of the same-suited cards can ever be used +together — see `OmahaHandEvaluator`) even though this label doesn't +distinguish them. That distinction is a strength judgment, not a structural +fact, and belongs with Phase 2's hand-strength work, not here. + +## `OmahaHandEvaluator` — the 2-hole/3-board rule + +`OmahaHandEvaluator.bestHand(hole:board:)` enumerates all `C(4,2) × C(5,3) = +6 × 10 = 60` legal 5-card combinations for a **completed** (5-card) board, +handing each one to the unmodified `HandEvaluator.bestHand(from:)`, and +keeps the best. Requires a full river board (unlike `HandEvaluator.bestHand`, +which accepts 5-7 cards) — Omaha's 2-and-3 split is only meaningful against +a specific board size; "best legal hand on the turn" is an `OmahaEquity`- +level concept (averaging over every possible river), not something this +function does itself. + +### Proving the rule is actually enforced + +Three tests exist specifically to catch the failure mode where this +constraint is silently ignored or partially wrong — not just checking "this +hand evaluates to something reasonable," but constructing spots where the +*illegal* answer and the *correct* answer are dramatically, obviously +different: + +- **`fourAcesInHoleCanOnlyEverPlayExactlyTwoOfThem`**: hole = all four aces + (one per suit — a legal, if unusual, Omaha hand). Board: five cards, all + distinct non-ace ranks, no board pair. If the 2-card cap were ignored, + the best 9-card hand would be trips or quads. The evaluator must return + exactly a pair of aces — provable by hand, since two of the five final + cards are always both "Ace" (from whichever 2 of the 4 hole aces are + picked) and the board contributes no pair of its own. +- **`fourSpadesInHoleDoNotMakeAFlushWithOnlyOneSpadeOnBoard`**: hole = + A♠K♠Q♠J♠, board = T♠ plus four off-suit unpaired cards. Ignoring the + 2-card cap, this is a **royal flush** (A-K-Q-J-T all spades) — but that + uses 4 hole cards, illegal. Correctly enforced, at most 2 hole spades + 1 + board spade can ever appear together (3 spades, never 5) — the evaluator + must return exactly High Card, never a flush or straight flush. +- **`aQueenHighStraightFlushIsCorrectlyFoundWhenTheSplitGenuinelyAllowsIt`**: + the positive counterpart — same four spades in hole, but a board of + T♠9♠8♠7♠6♠ (five consecutive spades). Here a *legal* straight flush + genuinely exists (hole Q♠J♠ + board T♠9♠8♠ = queen-high straight flush), + proving the evaluator isn't just pessimistic, it correctly finds a legal + nut hand when one is actually reachable within the rule. +- **`evaluatesExactlySixtyCombinationsAndPicksTheirMaximum`**: a brute-force + cross-check, re-implementing the same 60-combination enumeration + independently in the test itself and confirming both agree — so the + production code's precomputed index tables aren't just trusted at face + value. + +## `OmahaEquity` + +Same two-mode shape as `Equity` (`headsUp` exact, `monteCarlo` fixed-seed +sampled), built on `OmahaHandEvaluator` instead of `HandEvaluator` directly. + +**No `expandCanonical`/`canonicalVsCanonical` equivalent** — this module +only ever takes concrete `OmahaHoleCards` or lists of them, never a +hand-class string. Building an Omaha canonical-hand-class abstraction (the +4-card equivalent of expanding "AKs" into its 4 concrete combos) requires +first deciding what a "canonical Omaha starting hand" even means for +equity-aggregation purposes — itself hand-strength/classification judgment, +deferred to Phase 2. + +### Performance — exact enumeration is tighter here than in Hold'em + +`OmahaEquity.headsUp` (exact) costs `120` 5-card evaluations per board +completion (`60` per side, `OmahaHandEvaluator`'s own enumeration) versus +Hold'em's `42` (`HandEvaluator`'s unrestricted 21-combination 7-card +`bestHand`) — and Omaha's 8 known hole cards (4 per side) leave fewer cards +to draw the rest of 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 `OmahaEquity.headsUp` call does roughly **2×** +the raw work of the already-slow (several minutes, per `EQUITY.md`) Hold'em +equivalent — likely 10+ minutes for one matchup. **This project deliberately +never calls `headsUp` preflop** — not in a test, not from the app. +`monteCarlo` is the tool for that (and is what every equity validation below +actually uses). + +**Postflop isn't uniformly "instant" either — flop-exact measurably isn't.** +A given river (0 completions) or turn (≤40 completions) is fast, and this +codebase's own tests only exercise `headsUp` at those depths. A given +*flop* still needs `C(41,2) = 820` completions × 120 evaluations — cheap in +absolute terms, but building the app's Omaha equity screen surfaced that +this measured **close to, and on one run over, a 30-second UI-test +timeout** on a debug build running on the iOS Simulator (vs. Hold'em's own +flop-exact, a genuinely fast 42-evaluations-per-board computation that +comfortably clears the same kind of timeout in `EquityCalculatorView`'s own +tests). The app's Precise mode still offers flop-exact (it does eventually +complete), but treat it as "tractable, not instant," and the UI test that +proves Precise mode works uses a river-given board specifically to avoid +being a flaky benchmark of flop-exact's on-device timing rather than a +correctness check. + +## Validation + +### What a citable, precise number turned out to look like for PLO + +Unlike `RANGES.md`'s opening-range anchor (one source, fetched twice, +identical numbers both times) or `ICM.md`'s worked example (an exact +published fraction, independently re-derivable by hand), **no single source +with a precisely stated PLO equity figure and a fully specified matchup** +was found via web search. Multiple PLO strategy sites cite AAKK +double-suited's equity against a random hand anywhere from **64% to 73%** +depending on the source, with no agreement on methodology (sample size, +which random-hand universe, exact suit assignment). Per this project's own +rule (a bad number is worse than none — see `CLAUDE.md`), that spread is too +wide to validate a specific decimal against. + +**What *was* consistently repeated across independent sources** (found via +web search, e.g. pokerlistings.com's "Pot-Limit Omaha: Top 30 Starting +Hands" and corroborated by others): **AA-KK double-suited is only a 3-2 +favorite over a strong double-suited rundown like 8-7-6-5** — the classic +illustration of how much closer premium-vs-premium equities run in Omaha +than in Hold'em. "3-2" is a ratio, not a decimal (`3/(3+2) = 60%`), and no +source specified the exact suit-pairing of the rundown (8-7-6-5 double- +suited has more than one way to pair 2 suits across 4 ranks) — so this is +used as a **directional, wide-tolerance** check, not a tight one: + +``` +aakk = "AsKsAhKh" (aces and kings, paired across spades/hearts) +rundown = "8s7s6h5h" (8-7 spades, 6-5 hearts — one reasonable pairing; + exact pairing wasn't specified in any source found) + +OmahaEquity.monteCarlo(hero: [aakk], villain: [rundown], iterations: 50,000) + → win 61.96%, tie 0.00%, lose 38.04% +``` + +**61.96% lands almost exactly on the cited "~60% / 3-2" figure** — well +within the wide (52-68%) band the test checks, and close enough to the +specific cited ratio that this project is confident the model is directionally +and quantitatively sound, not just "in the right ballpark." A companion +test (`aacesKingsBeatsAWeakRandomHandMoreConvincinglyThanATopRundown`, no +citation needed) checks the model gets the *shape* right too: AAKK should +run better against unconnected junk than against a premium rundown, which +it does. + +### Hand-verifiable exact tests (the primary correctness gate) + +Per this project's own fallback rule when no solid citation exists ("pick a +hand-verifiable spot rather than assert an unsourced number" — this +document's own governing instruction), two tests carry the real weight of +proving `OmahaEquity` is *exactly* correct, not just directionally +plausible, with a complete board (0 sampling, 1 trial, deterministic): + +- **`heroWithLockedQuadsWinsWithCertaintyOnACompleteBoard`**: hero holds + the other two of a rank the board already pairs (board shows two sevens; + hero holds the remaining two sevens) — hero's best legal hand is provably + quad sevens (2 hole + the board's 2 + 1 more board card), and villain + (holding no sevens) can never do better than a plain pair of sevens off + the same board pair. Asserts `winRate == 1.0` exactly. +- **`mirroredSuitHandsOnASuitNeutralBoardTieWithCertainty`**: hero and + villain hold the identical 4 ranks (A,K,Q,J) in different suits (spades + vs. hearts), on a board with zero spades and zero hearts. Neither side can + ever complete a flush, so every legal 5-card combination either side can + form has an exact mirror on the other side — a tie *by symmetry*, not + coincidence, asserted as `tieRate == 1.0` exactly. + +Both are provable by direct reasoning about the specific cards involved, no +external source required — exactly the "pure board-lock" style fallback this +phase's own scope called for. + +## What Phase 2 should be (and why it's scoped separately) + +**PLO preflop hand-strength / starting-hand ranges.** This is the natural +next piece — a way to say "this 4-card hand is strong/weak preflop," the +Omaha equivalent of `ChenScore` + `PushFoldRange`/`OpeningRange` — and it's +**judgment-heavy in a way Phase 1 deliberately wasn't**: + +- Chen's heuristic doesn't extend to 4 cards (see above) — a genuinely new + scoring approach is needed, not a reuse. Real Omaha hand-strength + heuristics that do exist (e.g. various "Omaha hand value" formulas found + in strategy literature) are **themselves competing, disputed heuristics**, + not an agreed-upon standard the way Chen's is for Hold'em — meaning + *choosing which one to use* is itself a disclosed, debatable judgment + call, before any percentage tables even enter the picture. +- Every downstream number this project would build on top (push/fold + threshold, opening threshold, defending ranges) would inherit that + foundational uncertainty, compounding across the same "which numbers are + sourced vs. hand-tuned" honesty this project already applies rigorously + elsewhere (`RANGES.md`) — worth scoping and flagging deliberately as its + own phase rather than backing into it as a side effect of "add ranges next." +- Concretely, Phase 2 should: (1) pick and disclose one hand-strength + heuristic (or build a simple one from first principles — e.g. equity vs. + a random hand, computed via `OmahaEquity.monteCarlo`, as the ranking + signal itself, which has the advantage of being *this project's own exact + math* rather than a second borrowed heuristic), (2) validate it against + known strong/weak Omaha starting hands directionally (AAKK ds ranks + higher than 7532 rainbow, etc.), (3) only then build push/fold-style + threshold tools on top, with every percentage explicitly flagged as + hand-tuned, the same posture `RANGES.md` already takes for Hold'em. + +This is **not started in this PR** — Phase 1 stops at foundation + +evaluation + equity, a complete and independently useful/testable slice. + +## Consumers + +- Minimal Omaha equity screen in the app (see `StudyTool`) — enter two + explicit hole-card notations and an optional board, get win/tie/lose via + `OmahaEquity.monteCarlo` (or exact once the board is complete). No + preflop "Precise" mode is offered — see the performance note above for + why that would hang the UI. diff --git a/ai-docs/README.md b/ai-docs/README.md index 8ee8f75..5f4ad5e 100644 --- a/ai-docs/README.md +++ b/ai-docs/README.md @@ -20,6 +20,7 @@ drop into whichever subsystem you're touching: | [EQUITY.md](EQUITY.md) | `HandEvaluator`/`Equity`: exact + Monte Carlo win/tie/lose calculation, ground-truth validation, performance | | [ICM.md](ICM.md) | `ICM`/`ICMRiskPremium`: exact Malmuth-Harville tournament equity, validated against a published worked example, ICM-adjusted calling breakeven | | [FORMATS.md](FORMATS.md) | `GameFormat`: per-format defaults (stack, ante, bounty, ICM weight, speed) that seed the other tools — design judgment, not ground-truth math | +| [OMAHA.md](OMAHA.md) | Omaha/PLO Phase 1: `OmahaHoleCards`/`OmahaHandEvaluator`/`OmahaEquity` — 4-card foundation, the 2-hole/3-board rule, equity validated against published PLO guidance | | [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 From 6754c2794435b16e5f2cf5fe092c6fcc60bfe861 Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Thu, 23 Jul 2026 17:16:34 +0200 Subject: [PATCH 5/5] Add minimal Omaha Equity app screen, wired into StudyTool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicit 4-card notation in/out (no invented hand-class shorthand), Fast (Monte Carlo) / Precise (exact, postflop only) modes mirroring EquityCalculatorView's own async-safety pattern. Labeled "(Beta)" — Phase 1 foundation, no preflop hand-strength/ranges yet. UI tests found and fixed a real bug along the way: a still-open keyboard intercepted the Calculate button's tap, landing on a keyboard key instead and corrupting the board notation field — fixed by dismissing the keyboard (typing a newline) before the next interaction, not by working around the symptom. --- PokerKit/Sources/PokerKit/StudyTool.swift | 4 + app/Sources/ContentView.swift | 2 + app/Sources/OmahaEquityCalculatorView.swift | 263 ++++++++++++++++++ .../OmahaEquityCalculatorUITests.swift | 167 +++++++++++ 4 files changed, 436 insertions(+) create mode 100644 app/Sources/OmahaEquityCalculatorView.swift create mode 100644 app/UITests/OmahaEquityCalculatorUITests.swift diff --git a/PokerKit/Sources/PokerKit/StudyTool.swift b/PokerKit/Sources/PokerKit/StudyTool.swift index aed312d..efba653 100644 --- a/PokerKit/Sources/PokerKit/StudyTool.swift +++ b/PokerKit/Sources/PokerKit/StudyTool.swift @@ -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 @@ -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" @@ -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: diff --git a/app/Sources/ContentView.swift b/app/Sources/ContentView.swift index c43e2d7..8fa645e 100644 --- a/app/Sources/ContentView.swift +++ b/app/Sources/ContentView.swift @@ -25,6 +25,8 @@ struct ContentView: View { PushFoldTrainerView() case .equityCalculator: EquityCalculatorView() + case .omahaEquityCalculator: + OmahaEquityCalculatorView() case .icmCalculator: ICMCalculatorView() case .gameFormats: diff --git a/app/Sources/OmahaEquityCalculatorView.swift b/app/Sources/OmahaEquityCalculatorView.swift new file mode 100644 index 0000000..7e4af66 --- /dev/null +++ b/app/Sources/OmahaEquityCalculatorView.swift @@ -0,0 +1,263 @@ +import SwiftUI +import PokerKit + +/// Holds the in-flight/last Omaha equity calculation — a reference type held via `@State`, +/// same async-safety rationale as `EquityCalculatorModel` (see its doc comment): a `View` is +/// a value type SwiftUI can recreate mid-calculation, so background work needs a stable +/// reference-type home to write its result into. +@Observable +final class OmahaEquityCalculatorModel { + private(set) var isCalculating = false + private(set) var result: EquityResult? + + func calculate(hero: OmahaHoleCards, villain: OmahaHoleCards, board: [Card], mode: EquityMode, iterations: Int) { + isCalculating = true + result = nil + + DispatchQueue.global(qos: .userInitiated).async { [self] in + let computed: EquityResult + switch mode { + case .fast: + computed = OmahaEquity.monteCarlo(hero: [hero], villain: [villain], board: board, iterations: iterations) + case .precise: + computed = OmahaEquity.headsUp(hero: hero, villain: villain, board: board) + } + DispatchQueue.main.async { [self] in + result = computed + isCalculating = false + } + } + } + + func clearResult() { + result = nil + } +} + +/// A deliberately minimal Omaha/PLO equity screen — Phase 1's app-facing slice (see +/// `ai-docs/OMAHA.md`). Hero and villain are entered as **explicit 4-card notation** +/// (`"AsKsAhKh"`), not a hand-class shorthand the way `EquityCalculatorView` accepts "AKs" — +/// Omaha has no standardized canonical-hand-class notation to expand from (see +/// `OmahaHoleCards.notation`'s doc comment), and inventing one is exactly the kind of +/// hand-strength judgment call deferred to Phase 2. The board is entered the same way, as a +/// single notation string, rather than per-card rank/suit menu pickers — a disclosed scope +/// cut to keep this screen small; `EquityCalculatorView`'s `BoardCardPickerRow` remains the +/// richer NLHE experience. +/// +/// **No Precise (exact) mode preflop** — `OmahaEquity.headsUp` preflop is roughly 2x the cost +/// of Hold'em's own already-slow (several-minute) preflop exact case (see `OMAHA.md`'s +/// performance note); offering it here would hang the UI. Postflop, exact is offered exactly +/// like `EquityCalculatorView` does — **but flop-exact specifically measured close to (and, +/// on one run, over) 30 seconds** in a debug build on the iOS Simulator, meaningfully slower +/// than Hold'em's own flop-exact mode (a cheaper computation — 42 evaluations per board vs. +/// Omaha's 120). Turn and river exact are fast (≤40 completions, or 0 for a given river). +/// Flop-exact is left enabled here (not blocked) since it does eventually complete, but treat +/// it as "tractable, not instant" rather than assuming it behaves like Hold'em's version. +struct OmahaEquityCalculatorView: View { + /// Not independently measured on an iOS Simulator (unlike `EquityCalculatorView`'s own + /// 10,000, which was) — extrapolated from that measured figure, scaled down by Omaha + /// Monte Carlo's roughly ~2.9x higher per-trial cost (120 5-card evaluations per trial + /// vs. Hold'em Monte Carlo's ~42), to keep a similar tap-to-result wall-clock time. + /// **Flagged as an estimate, not a verified one** — if this feels slow on-device, lower + /// it further; see `OMAHA.md`. + private static let liveIterations = 3_000 + + @State private var model = OmahaEquityCalculatorModel() + @State private var heroNotation = "AsKsAhKh" + @State private var villainNotation = "8s7s6h5h" + @State private var street: Street = .preflop + @State private var boardNotation = "" + @State private var mode: EquityMode = .fast + + private var boardCardCount: Int { + switch street { + case .preflop: return 0 + case .flop: return 3 + case .turn: return 4 + case .river: return 5 + } + } + + /// Uppercases rank characters, lowercases suit characters, for every 2-character card + /// token in `input` — the notation equivalent of `EquityCalculatorView.normalize(_:)`, + /// generalized to any even-length run of tokens (a 4-card hole hand or an N-card board). + private static func normalizeCardTokens(_ input: String) -> String { + let chars = Array(input.trimmingCharacters(in: .whitespaces)) + guard chars.count.isMultiple(of: 2) else { return String(chars) } + var result = "" + var i = 0 + while i + 1 < chars.count { + result += chars[i].uppercased() + result += chars[i + 1].lowercased() + i += 2 + } + return result + } + + private var heroHand: OmahaHoleCards? { OmahaHoleCards(canonical: Self.normalizeCardTokens(heroNotation)) } + private var villainHand: OmahaHoleCards? { OmahaHoleCards(canonical: Self.normalizeCardTokens(villainNotation)) } + + /// `nil` when the board notation doesn't parse to exactly `boardCardCount` cards; `[]` + /// (a valid, complete, zero-card board) at preflop. + private var resolvedBoard: [Card]? { + guard boardCardCount > 0 else { return [] } + let normalized = Self.normalizeCardTokens(boardNotation) + guard normalized.count == boardCardCount * 2 else { return nil } + var cards: [Card] = [] + let chars = Array(normalized) + var i = 0 + while i + 1 < chars.count { + guard let card = Card(notation: String(chars[i...(i + 1)])) else { return nil } + cards.append(card) + i += 2 + } + return cards + } + + private var validationMessage: String? { + if !heroNotation.isEmpty, heroHand == nil { return "Hero isn't a valid 4-card hand — try \"AsKsAhKh\"." } + if !villainNotation.isEmpty, villainHand == nil { return "Villain isn't a valid 4-card hand — try \"8s7s6h5h\"." } + if boardCardCount > 0, resolvedBoard == nil { return "Finish setting the board (\(boardCardCount) cards)." } + if let hero = heroHand, let villain = villainHand, let board = resolvedBoard { + let all = hero.cards + villain.cards + board + if Set(all).count != all.count { return "Hero, villain, and the board can't share a card." } + } + return nil + } + + private var isReadyToCalculate: Bool { + guard let hero = heroHand, let villain = villainHand, let board = resolvedBoard else { return false } + let all = hero.cards + villain.cards + board + return Set(all).count == all.count + } + + var body: some View { + Form { + Section("Hero") { + TextField("e.g. AsKsAhKh", text: $heroNotation) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .accessibilityIdentifier("omahaHeroNotationField") + .onChange(of: heroNotation) { _, _ in model.clearResult() } + } + + Section("Villain") { + TextField("e.g. 8s7s6h5h", text: $villainNotation) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .accessibilityIdentifier("omahaVillainNotationField") + .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("omahaStreetPicker") + .onChange(of: street) { _, newStreet in + model.clearResult() + if newStreet == .preflop { mode = .fast } + } + + if boardCardCount > 0 { + TextField("e.g. Kd7h2c", text: $boardNotation) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .accessibilityIdentifier("omahaBoardNotationField") + .onChange(of: boardNotation) { _, _ in model.clearResult() } + } + } + + Section("Mode") { + Picker("Mode", selection: $mode) { + ForEach(EquityMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + .disabled(street == .preflop) + .accessibilityIdentifier("omahaEquityModePicker") + .onChange(of: mode) { _, _ in model.clearResult() } + + if street == .preflop { + Text("Precise needs a board — exact Omaha enumeration preflop takes upwards of 10 minutes. Fast (Monte Carlo) is accurate enough for study purposes; see ai-docs/OMAHA.md.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Section { + Button { + guard let hero = heroHand, let villain = villainHand, let board = resolvedBoard else { return } + model.calculate(hero: hero, villain: villain, board: board, mode: mode, iterations: Self.liveIterations) + } label: { + HStack { + Spacer() + if model.isCalculating { + ProgressView() + } else { + Text("Calculate") + } + Spacer() + } + } + .buttonStyle(.borderedProminent) + .disabled(!isReadyToCalculate || model.isCalculating) + .accessibilityIdentifier("omahaCalculateButton") + + if let validationMessage { + Text(validationMessage) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if let result = model.result { + resultSection(result) + } + } + .navigationTitle("Omaha Equity") + .navigationBarTitleDisplayMode(.inline) + } + + private func resultSection(_ result: EquityResult) -> some View { + Section("Result") { + resultRow(label: "Hero wins", value: result.winRate, tint: .green, identifier: "omahaHeroWinRate") + resultRow(label: "Tie", value: result.tieRate, tint: .secondary, identifier: "omahaTieRate") + resultRow(label: "Villain wins", value: result.loseRate, tint: .red, identifier: "omahaVillainWinRate") + Text(methodCaption(for: result)) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("omahaEquityMethodText") + } + } + + private func methodCaption(for result: EquityResult) -> String { + if result.isExact { + return "Exact — every legal 2-hole/3-board combination enumerated across every matching board, zero sampling error. Not a solver; see ai-docs/OMAHA.md." + } + return "\(result.trials.formatted()) Monte Carlo simulations, fixed seed — same inputs always give the same result. Not a solver; see ai-docs/OMAHA.md." + } + + 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) + } + } +} + +#Preview { + NavigationStack { + OmahaEquityCalculatorView() + } +} diff --git a/app/UITests/OmahaEquityCalculatorUITests.swift b/app/UITests/OmahaEquityCalculatorUITests.swift new file mode 100644 index 0000000..0a53aa0 --- /dev/null +++ b/app/UITests/OmahaEquityCalculatorUITests.swift @@ -0,0 +1,167 @@ +import XCTest + +final class OmahaEquityCalculatorUITests: XCTestCase { + func testDefaultHandsCalculatePreflopEquity() { + let app = XCUIApplication() + app.launch() + + let row = app.staticTexts["Omaha Equity (Beta)"] + scrollUntilVisible(row, in: app) + row.tap() + + let calculateButton = app.buttons["omahaCalculateButton"] + scrollUntilVisible(calculateButton, in: app) + XCTAssertTrue(calculateButton.waitForExistence(timeout: 5)) + XCTAssertTrue(calculateButton.isEnabled, "Default hero/villain (AAKKds vs 8765ds) should already be a valid, calculable setup") + + calculateButton.tap() + + let heroWinRate = app.staticTexts["omahaHeroWinRate"] + scrollUntilVisible(heroWinRate, in: app) + XCTAssertTrue(heroWinRate.waitForExistence(timeout: 30)) + attachScreenshot(of: app, name: "omaha-preflop-result") + + let heroPercent = Double(heroWinRate.label.replacingOccurrences(of: "%", with: "")) ?? -1 + + let tieRate = app.staticTexts["omahaTieRate"] + let villainWinRate = app.staticTexts["omahaVillainWinRate"] + let methodText = app.staticTexts["omahaEquityMethodText"] + scrollUntilVisible(methodText, in: app) + XCTAssertTrue(tieRate.exists) + XCTAssertTrue(villainWinRate.exists) + XCTAssertTrue(methodText.exists) + XCTAssertTrue(methodText.label.contains("Monte Carlo")) + + // AAKKds vs 8765ds is a modest favorite (~60%, "only a 3-2 favorite" — see + // ai-docs/OMAHA.md), not a coinflip or a blowout. + XCTAssertTrue(heroPercent > 45 && heroPercent < 75, "AAKKds vs 8765ds should be a modest favorite, got \(heroPercent)%") + } + + func testInvalidHandDisablesCalculate() { + let app = XCUIApplication() + app.launch() + + let row = app.staticTexts["Omaha Equity (Beta)"] + scrollUntilVisible(row, in: app) + row.tap() + + let heroField = app.textFields["omahaHeroNotationField"] + XCTAssertTrue(heroField.waitForExistence(timeout: 5)) + heroField.tap() + heroField.typeText("x") + + let calculateButton = app.buttons["omahaCalculateButton"] + scrollUntilVisible(calculateButton, in: app) + XCTAssertFalse(calculateButton.isEnabled, "An invalid hand notation should disable Calculate") + + let validationText = app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'valid 4-card hand'")).firstMatch + XCTAssertTrue(validationText.exists) + } + + func testFlopBoardRequiresAllThreeCardsBeforeCalculating() { + let app = XCUIApplication() + app.launch() + + let row = app.staticTexts["Omaha Equity (Beta)"] + scrollUntilVisible(row, in: app) + row.tap() + + app.buttons["Flop"].tap() + + let calculateButton = app.buttons["omahaCalculateButton"] + scrollUntilVisible(calculateButton, in: app) + XCTAssertTrue(calculateButton.waitForExistence(timeout: 5)) + XCTAssertFalse(calculateButton.isEnabled, "An unset flop should block calculation") + } + + func testPreciseModeDisabledPreflopEnabledOnceBoardIsSet() { + let app = XCUIApplication() + app.launch() + + let row = app.staticTexts["Omaha Equity (Beta)"] + scrollUntilVisible(row, in: app) + row.tap() + + let modePicker = app.segmentedControls["omahaEquityModePicker"] + XCTAssertTrue(modePicker.waitForExistence(timeout: 5)) + XCTAssertFalse(modePicker.isEnabled, "Precise mode requires a board — should be disabled preflop") + + app.buttons["Flop"].tap() + + scrollUntilVisible(modePicker, in: app) + XCTAssertTrue(modePicker.isEnabled, "Precise mode should be available once the street isn't Preflop") + + app.buttons["Preflop"].tap() + XCTAssertFalse(modePicker.isEnabled) + } + + func testPreciseModeProducesAnExactResultOnAGivenRiver() { + // Deliberately a *river*-given board (all 5 cards known), not a flop — a flop-given + // exact Omaha calculation still needs to enumerate C(41,2) = 820 board completions + // at 120 evaluations each, which measured close to (and, on one run, over) a 30s + // timeout on this simulator's debug build. A river-given board needs zero + // completions (a single, instant evaluation) — this test exists to prove Precise + // mode correctly reaches the exact code path, not to benchmark flop-exact's on-device + // timing (see ai-docs/OMAHA.md's performance note). + let app = XCUIApplication() + app.launch() + + let row = app.staticTexts["Omaha Equity (Beta)"] + scrollUntilVisible(row, in: app) + row.tap() + + app.buttons["River"].tap() + + let boardField = app.textFields["omahaBoardNotationField"] + scrollUntilVisible(boardField, in: app) + XCTAssertTrue(boardField.waitForExistence(timeout: 5)) + boardField.tap() + boardField.typeText("2c9d4hTsJc") + // Dismiss the keyboard (typing a newline resigns a single-line TextField's first + // responder) before interacting with anything below it. Without this, a later tap on + // "Calculate" computed its coordinates for the button's expected position but + // actually landed on the still-open keyboard underneath it — confirmed by debugging: + // the board field's value gained a stray trailing "Y" (the keyboard key at that + // screen position), corrupting the board notation and leaving Calculate never really + // tapped. A plain `app.navigationBars.firstMatch.tap()` was tried first and did not + // reliably dismiss the keyboard either. + boardField.typeText("\n") + + let modePicker = app.segmentedControls["omahaEquityModePicker"] + scrollUntilVisible(modePicker, in: app) + XCTAssertTrue(modePicker.isEnabled) + modePicker.buttons["Precise"].tap() + + let calculateButton = app.buttons["omahaCalculateButton"] + scrollUntilVisible(calculateButton, in: app) + XCTAssertTrue(calculateButton.isEnabled) + calculateButton.tap() + + let methodText = app.staticTexts["omahaEquityMethodText"] + scrollUntilVisible(methodText, in: app) + XCTAssertTrue(methodText.waitForExistence(timeout: 15)) + XCTAssertTrue(methodText.label.contains("Exact"), "Precise mode's caption should say Exact, got: \(methodText.label)") + attachScreenshot(of: app, name: "omaha-precise-flop-result") + } + + /// Interleaves swiping with waiting (rather than swiping a fixed number of times up + /// front and only then waiting statically) — after tapping Calculate, the Result section + /// doesn't exist in the accessibility tree at all until the async computation finishes + /// *and* it's scrolled into view. A fixed burst of swipes immediately after the tap can + /// exhaust itself before the computation completes, leaving a `waitForExistence` polling + /// a scroll position the still-to-appear content will never reach. + private func scrollUntilVisible(_ element: XCUIElement, in app: XCUIApplication, timeout: TimeInterval = 30) { + let deadline = Date().addingTimeInterval(timeout) + while !element.exists && Date() < deadline { + app.swipeUp() + Thread.sleep(forTimeInterval: 0.5) + } + } + + private func attachScreenshot(of app: XCUIApplication, name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +}