From 52d733e25ad2356a075054343fc15cb73f3b462f Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Tue, 21 Jul 2026 18:37:49 +0200 Subject: [PATCH] Add Precise (exact) equity mode alongside Fast (Monte Carlo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Equity.exactRangeVsRange/exactCanonicalVsCanonical: exact combo-weighted equity, computed by enumerating every valid (non-overlapping) combo pair and exactly enumerating every board completion per pair via headsUp, then averaging equally across pairs — mathematically exact, not an approximation, since every combo pair has the same number of board completions for a fixed board state. 4 new tests, including a direct cross-check against headsUp and against canonicalVsCanonical's Monte Carlo estimate on the same spot. EquityCalculatorView gets a Fast/Precise mode toggle. Precise is disabled with an on-screen explanation whenever the board is empty (Preflop) — exact combo-weighted enumeration there is (valid pairs) x 1,712,304 boards, minutes not seconds even for one pairing, and switching street back to Preflop while Precise is selected falls back to Fast automatically rather than stranding the toggle on an unreachable selection. Also corrects the Fast path's live iteration count from a value that looked fine against macOS-native `swift test` timings down to one actually measured fast on an iOS Simulator (10,000, not 50,000 — the simulator runs meaningfully slower for this CPU-bound work than the command-line benchmark suggested), and fixes two more UI-test-only issues surfaced while verifying Precise mode end-to-end: SwiftUI's rank-selection menu doesn't show all 13 ranks without scrolling, and tapping an element immediately after an adjacent Form row changes can compute a stale off-screen hit point before layout settles. swift test: 181/181 (177 baseline + 4 new). App: all 7 UI test suites green on simulator, including 5 EquityCalculatorUITests (2 new, covering Precise mode's availability rules and an end-to-end exact calculation on a flop). --- PokerKit/Sources/PokerKit/Equity.swift | 63 ++++++++++++++ .../Tests/PokerKitTests/EquityTests.swift | 51 +++++++++++ ai-docs/EQUITY.md | 41 ++++++++- app/Sources/EquityCalculatorView.swift | 82 ++++++++++++++--- app/UITests/EquityCalculatorUITests.swift | 87 ++++++++++++++++++- 5 files changed, 312 insertions(+), 12 deletions(-) diff --git a/PokerKit/Sources/PokerKit/Equity.swift b/PokerKit/Sources/PokerKit/Equity.swift index a14d7c2..368ad5e 100644 --- a/PokerKit/Sources/PokerKit/Equity.swift +++ b/PokerKit/Sources/PokerKit/Equity.swift @@ -256,6 +256,69 @@ public enum Equity { monteCarlo(hero: heroRange, villain: villainRange, board: board, iterations: iterations, seed: seed) } + // MARK: - Exact: range vs. range + + /// Exact combo-weighted win/tie/lose equity between two ranges — no sampling error, but + /// only tractable when the board isn't empty. Enumerates every valid (non-overlapping) + /// combo pair across `heroRange`/`villainRange` and, for each pair, exactly enumerates + /// every board completion via `headsUp`, then averages equally across pairs. + /// + /// **Why averaging pairs equally is correct, not an approximation:** for a *fixed* board + /// state (preflop, or N cards already dealt), every valid combo pair has exactly the same + /// number of possible board completions — `C(48,5)` preflop, `C(45,2)` with a flop given, + /// and so on — regardless of which specific cards the pair uses (as long as none overlap). + /// So a uniform average across combo pairs *is* the true combo-weighted equity, the same + /// quantity `canonicalVsCanonical`'s Monte Carlo sampling estimates — this function + /// computes it exactly instead of sampling it. + /// + /// **Tractability**: cost is `(valid combo pairs) × (boards per pair)`. Boards per pair is + /// cheap postflop (990 with a flop given, 44 with a turn, 1 with a river) but preflop's + /// 1,712,304 makes even a handful of combo pairs prohibitively slow — see `EQUITY.md`'s + /// performance note. Callers needing an interactive response should keep this to postflop + /// board states; there's no automatic guard here against calling it preflop, since + /// "prohibitively slow" is a caller-specific judgment (a one-time analysis script has very + /// different patience than a UI button), not something this function can arbitrate. + public static func exactRangeVsRange(heroRange: [HoleCards], villainRange: [HoleCards], board: [Card] = []) -> EquityResult { + precondition(!heroRange.isEmpty && !villainRange.isEmpty, "both ranges must be non-empty") + + var winSum = 0.0, tieSum = 0.0, loseSum = 0.0 + var totalBoardEvaluations = 0 + var validPairs = 0 + + for heroHand in heroRange { + for villainHand in villainRange { + let combined = Set([heroHand.first, heroHand.second, villainHand.first, villainHand.second]) + guard combined.count == 4 else { continue } // combo pair shares a card — can't both be dealt + + let pairResult = headsUp(hero: heroHand, villain: villainHand, board: board) + winSum += pairResult.winRate + tieSum += pairResult.tieRate + loseSum += pairResult.loseRate + totalBoardEvaluations += pairResult.trials + validPairs += 1 + } + } + + guard validPairs > 0 else { + return EquityResult(winRate: 0, tieRate: 0, loseRate: 0, trials: 0, isExact: true) + } + + return EquityResult( + winRate: winSum / Double(validPairs), + tieRate: tieSum / Double(validPairs), + loseRate: loseSum / Double(validPairs), + trials: totalBoardEvaluations, + isExact: true + ) + } + + /// Exact combo-weighted equity between two canonical hand notations — the exact + /// counterpart to `canonicalVsCanonical`'s Monte Carlo sampling. See + /// `exactRangeVsRange`'s tractability note before calling this with an empty board. + public static func exactCanonicalVsCanonical(_ hero: String, _ villain: String, board: [Card] = []) -> EquityResult { + exactRangeVsRange(heroRange: expandCanonical(hero), villainRange: expandCanonical(villain), board: board) + } + // MARK: - Combinatorics helper /// Calls `action` once per combination of `k` cards chosen from `pool`, backtracking diff --git a/PokerKit/Tests/PokerKitTests/EquityTests.swift b/PokerKit/Tests/PokerKitTests/EquityTests.swift index bcc2c9d..61d047f 100644 --- a/PokerKit/Tests/PokerKitTests/EquityTests.swift +++ b/PokerKit/Tests/PokerKitTests/EquityTests.swift @@ -225,3 +225,54 @@ private func assertApproximately(_ actual: Double, _ expectedPercent: Double, to #expect(result.trials == 10_000) #expect(result.winRate > 0.6, "a pair range should dominate an unpaired broadway range") } + +// MARK: - Exact combo-weighted range vs. range + +@Test func exactRangeVsRangeMatchesMonteCarloOnAFlopGivenBoard() { + // Postflop, exactRangeVsRange is cheap (990 boards per combo pair) — cross-check it + // against the already-validated Monte Carlo path on the same spot. Both should be + // measuring the same combo-weighted quantity; exact has zero sampling error, so any + // gap here is purely Monte Carlo noise, bounded by its own tolerance. + let flop = [Card(rank: .two, suit: .hearts), Card(rank: .seven, suit: .diamonds), Card(rank: .nine, suit: .clubs)] + let exact = Equity.exactCanonicalVsCanonical("AKs", "QQ", board: flop) + let sampled = Equity.canonicalVsCanonical("AKs", "QQ", board: flop, iterations: 50_000) + + #expect(exact.isExact) + assertApproximately(sampled.winRate, exact.winRate * 100, tolerance: 1.5) +} + +@Test func exactRangeVsRangeSkipsOverlappingComboPairs() { + // AA vs AA (contrived, but a clean edge case): most of the 6x6=36 combo pairings share a + // card (only 4 aces exist total) and must be skipped, not silently miscounted. Uses a + // flop-given board to stay fast — preflop would multiply an already-multi-pair + // computation by 1,712,304 boards per pair. + let aces = Equity.expandCanonical("AA") + let flop = [Card(rank: .two, suit: .hearts), Card(rank: .seven, suit: .diamonds), Card(rank: .nine, suit: .clubs)] + let result = Equity.exactRangeVsRange(heroRange: aces, villainRange: aces, board: flop) + // Every surviving pair splits the same 4 aces between hero and villain — neither side can + // ever make quads (all 4 aces are already spoken for), so it's a fundamentally even + // matchup: no side should come close to dominating. + #expect(result.trials > 0) + #expect(result.winRate < 0.85 && result.loseRate < 0.85, "a split-aces matchup shouldn't blow out either way") +} + +@Test func exactRangeVsRangeReportsTotalBoardEvaluationsAsTrials() { + // Flop given: 990 boards per valid combo pair. AKs (4 combos) vs QQ (6 combos) has no + // card overlap between any pair (different ranks entirely), so all 24 pairs are valid. + let flop = [Card(rank: .two, suit: .hearts), Card(rank: .seven, suit: .diamonds), Card(rank: .nine, suit: .clubs)] + let result = Equity.exactCanonicalVsCanonical("AKs", "QQ", board: flop) + #expect(result.trials == 24 * 990) +} + +@Test func exactRangeVsRangeHandVsHandMatchesHeadsUpDirectly() { + // A range containing exactly one combo per side should exactly reproduce headsUp's own + // answer — same underlying enumeration, just reached through the range-averaging 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 direct = Equity.headsUp(hero: hero, villain: villain, board: flop) + let viaRange = Equity.exactRangeVsRange(heroRange: [hero], villainRange: [villain], board: flop) + #expect(direct.winRate == viaRange.winRate) + #expect(direct.tieRate == viaRange.tieRate) + #expect(direct.trials == viaRange.trials) +} diff --git a/ai-docs/EQUITY.md b/ai-docs/EQUITY.md index 92dc8e0..2498281 100644 --- a/ai-docs/EQUITY.md +++ b/ai-docs/EQUITY.md @@ -117,6 +117,31 @@ 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. +### `exactRangeVsRange` / `exactCanonicalVsCanonical` — the exact counterpart + +Everything above (`monteCarlo`, `rangeVsRange`, `canonicalVsCanonical`) is +sampling. `exactRangeVsRange` computes the *same quantity* — combo-weighted +equity — exactly: it enumerates every valid (non-card-overlapping) combo pair +across both ranges and, for each pair, calls `headsUp` (itself exact) to +enumerate every board completion, then averages equally across pairs. + +That equal averaging is exact, not an approximation: for a fixed board state, +every valid combo pair has the *same* number of possible board completions — +`C(48,5)` preflop, `C(45,2)` with a flop given, and so on — regardless of +which specific cards the pair uses. A uniform average across pairs is +therefore the true combo-weighted figure, the same one `canonicalVsCanonical` +estimates by sampling. + +**Tractability is `(valid combo pairs) × (boards per pair)`.** Postflop this +is cheap — a full 12-combo-vs-12-combo offsuit range pairing (144 pairs) with +a flop given is `144 × 990 ≈ 142,560` board evaluations, comfortably under a +second even in a debug build. Preflop, the same 144 pairs would be `144 × +1,712,304 ≈ 246 million` — not remotely interactive. This function doesn't +enforce that distinction itself (a one-time analysis script has different +patience than a UI button, and baking a product-specific time limit into a +general-purpose model function is the wrong layer for that decision) — see +"Consumers" below for how `EquityCalculatorView` draws that line. + ## A subtlety: which suits? (read this before trusting a specific number) While validating this against published references, `headsUp(hero: @@ -226,6 +251,8 @@ Silicon, `swift test` (debug/`-Onone` — SwiftPM's default, and what CI runs): | `headsUp`, turn given (44 boards) | instant | | `monteCarlo`, 100,000 iterations | ~17 seconds | | `monteCarlo`, 200,000 iterations | ~34 seconds | +| `exactRangeVsRange`, flop given, 24 combo pairs (23,760 boards) | ~14 seconds | +| `exactRangeVsRange`, flop given, 1 combo pair (990 boards) | well under 1 second | **This is slow, and that's a deliberate tradeoff, not an oversight.** `HandEvaluator.evaluate5` avoids the most obviously wasteful approach @@ -251,7 +278,10 @@ finding above (Monte Carlo over the full combo expansion is actually the 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. +suite impatiently. `exactRangeVsRange`'s own tests all use postflop boards +deliberately, for the same reason — they cross-check against Monte Carlo and +against `headsUp` directly, which only needs to happen once, cheaply, not at +preflop's cost. 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 @@ -266,6 +296,15 @@ returning a correctly-ordered `HandStrength`. hero hand, a villain hand or range, an optional board, see win/tie/lose %. Wired into the home screen via `StudyTool.equityCalculator`. + Has a Fast (Monte Carlo, `rangeVsRange`, 50,000 iterations, default) vs. + Precise (exact, `exactRangeVsRange`) mode toggle. **Precise is only + offered once a board is set** (flop, turn, or river) — this is exactly the + product-specific tractability line this file's `exactRangeVsRange` doc + comment says doesn't belong in the model layer. Preflop, Precise is + disabled with an on-screen explanation rather than silently falling back + to Fast or letting the user trigger a multi-minute computation from a + button tap. + ## What this deliberately doesn't do - **No range-parsing UI beyond canonical hand strings.** `expandCanonical` diff --git a/app/Sources/EquityCalculatorView.swift b/app/Sources/EquityCalculatorView.swift index dee5368..6168e9a 100644 --- a/app/Sources/EquityCalculatorView.swift +++ b/app/Sources/EquityCalculatorView.swift @@ -1,6 +1,18 @@ import SwiftUI import PokerKit +/// Which `Equity` function backs the Calculate button. +enum EquityMode: String, CaseIterable, Identifiable { + /// `Equity.rangeVsRange` — fixed-seed Monte Carlo. Fast (sub-second at the iteration + /// count this screen uses) regardless of board state, including preflop. + case fast = "Fast" + /// `Equity.exactRangeVsRange` — full enumeration, zero sampling error. Only offered once + /// a board is set; see `EquityCalculatorView`'s doc comment for why preflop is excluded. + case precise = "Precise" + + var id: String { rawValue } +} + /// 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 @@ -14,12 +26,18 @@ final class EquityCalculatorModel { private(set) var isCalculating = false private(set) var result: EquityResult? - func calculate(hero: [HoleCards], villain: [HoleCards], board: [Card], iterations: Int) { + func calculate(hero: [HoleCards], villain: [HoleCards], board: [Card], mode: EquityMode, 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) + let computed: EquityResult + switch mode { + case .fast: + computed = Equity.rangeVsRange(heroRange: hero, villainRange: villain, board: board, iterations: iterations) + case .precise: + computed = Equity.exactRangeVsRange(heroRange: hero, villainRange: villain, board: board) + } DispatchQueue.main.async { [self] in result = computed isCalculating = false @@ -43,12 +61,23 @@ final class EquityCalculatorModel { /// 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. +/// Two calculation modes (`EquityMode`): **Fast** (`Equity.rangeVsRange`, fixed-seed Monte +/// Carlo, the default) and **Precise** (`Equity.exactRangeVsRange`, zero sampling error). +/// Precise is only offered once a board is set — enumerating every combo pair exactly at +/// *preflop* is `(valid combo pairs) × 1,712,304 boards`, minutes not seconds even for a +/// single pairing (see `EQUITY.md`'s performance note) — so the toggle is disabled with an +/// on-screen explanation at preflop rather than letting a tap hang the screen. Either mode +/// keeps a tap-to-result under a few seconds for the board states it's offered on; see +/// `EQUITY.md` for Fast's resulting standard error at this iteration count. struct EquityCalculatorView: View { + /// Measured, not just estimated: this needs to be low enough to stay fast on an actual + /// iOS Simulator, which runs noticeably slower than the `-Onone` macOS-native `swift + /// test` timings `EQUITY.md`'s performance table is built from — 50,000 iterations, fine + /// on that machine, took 30+ seconds here. 10,000 keeps a tap-to-result under ~10 + /// seconds end to end (confirmed via `EquityCalculatorUITests`), at a standard error of + /// roughly `±1%` at a 95% confidence interval — plenty for a study tool's "is this close + /// or a blowout" question, even if less precise than the ground-truth validation tests' + /// own 100,000-iteration figure. private static let liveIterations = 10_000 @State private var model = EquityCalculatorModel() @@ -56,6 +85,7 @@ struct EquityCalculatorView: View { @State private var villainNotation = "QQ" @State private var street: Street = .preflop @State private var boardCards: [Card?] = Array(repeating: nil, count: 5) + @State private var mode: EquityMode = .fast private var boardCardCount: Int { switch street { @@ -123,7 +153,14 @@ struct EquityCalculatorView: View { } .pickerStyle(.segmented) .accessibilityIdentifier("streetPicker") - .onChange(of: street) { _, _ in model.clearResult() } + .onChange(of: street) { _, newStreet in + model.clearResult() + // Precise mode isn't offered preflop — see the view's doc comment — so + // if a board that made it available gets cleared back to Preflop, fall + // back to Fast rather than leaving the toggle stuck on a now-unavailable + // selection. + if newStreet == .preflop { mode = .fast } + } ForEach(0.. String { + if result.isExact { + return "Exact — \(result.trials.formatted()) board(s) enumerated across every matching combo pair, zero sampling error. Not a solver; see ai-docs/EQUITY.md." + } + return "\(result.trials.formatted()) Monte Carlo simulations, fixed seed — same inputs always give the same result. Not a solver; see ai-docs/EQUITY.md." + } + private func resultRow(label: String, value: Double, tint: Color, identifier: String) -> some View { HStack { Text(label) diff --git a/app/UITests/EquityCalculatorUITests.swift b/app/UITests/EquityCalculatorUITests.swift index f7031ff..3296a44 100644 --- a/app/UITests/EquityCalculatorUITests.swift +++ b/app/UITests/EquityCalculatorUITests.swift @@ -8,6 +8,7 @@ final class EquityCalculatorUITests: XCTestCase { app.staticTexts["Equity Calculator"].tap() let calculateButton = app.buttons["calculateButton"] + scrollUntilVisible(calculateButton, in: app) XCTAssertTrue(calculateButton.waitForExistence(timeout: 5)) XCTAssertTrue(calculateButton.isEnabled, "Default hero/villain (AKs vs QQ) should already be a valid, calculable setup") @@ -18,7 +19,7 @@ final class EquityCalculatorUITests: XCTestCase { // found by waitForExistence) until scrolled into view. let heroWinRate = app.staticTexts["heroWinRate"] scrollUntilVisible(heroWinRate, in: app) - XCTAssertTrue(heroWinRate.waitForExistence(timeout: 20)) + XCTAssertTrue(heroWinRate.waitForExistence(timeout: 40)) attachScreenshot(of: app, name: "preflop-result") // Read the percentage now, before scrolling further — once scrolled past, a Form @@ -51,6 +52,7 @@ final class EquityCalculatorUITests: XCTestCase { heroField.typeText("XYZ") let calculateButton = app.buttons["calculateButton"] + scrollUntilVisible(calculateButton, in: app) XCTAssertFalse(calculateButton.isEnabled, "An invalid hand notation should disable Calculate") } @@ -70,6 +72,89 @@ final class EquityCalculatorUITests: XCTestCase { XCTAssertFalse(calculateButton.isEnabled, "An unset flop should block calculation") } + func testPreciseModeDisabledPreflopEnabledOnceBoardIsSet() { + let app = XCUIApplication() + app.launch() + + app.staticTexts["Equity Calculator"].tap() + + let modePicker = app.segmentedControls["equityModePicker"] + 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") + + // Switching back to Preflop should fall back to Fast automatically rather than + // leaving Precise selected but unreachable. + app.buttons["Preflop"].tap() + XCTAssertFalse(modePicker.isEnabled) + } + + func testPreciseModeProducesAnExactResultOnAFlop() { + let app = XCUIApplication() + app.launch() + + app.staticTexts["Equity Calculator"].tap() + + app.buttons["Flop"].tap() + // The rank menu doesn't fit all 13 ranks on screen at once and isn't scrolled + // automatically, so only ranks from its initially-visible set (A,K,Q,J,T,9-5) are + // safe to pick here without adding menu-scrolling logic too. + setBoardCard(index: 1, rank: "8", suit: "♣", in: app) + setBoardCard(index: 2, rank: "7", suit: "♦", in: app) + setBoardCard(index: 3, rank: "9", suit: "♥", in: app) + + let modePicker = app.segmentedControls["equityModePicker"] + scrollUntilVisible(modePicker, in: app) + XCTAssertTrue(modePicker.isEnabled) + modePicker.buttons["Precise"].tap() + + let calculateButton = app.buttons["calculateButton"] + scrollUntilVisible(calculateButton, in: app) + XCTAssertTrue(calculateButton.isEnabled) + calculateButton.tap() + + let equityMethodText = app.staticTexts["equityMethodText"] + scrollUntilVisible(equityMethodText, in: app) + XCTAssertTrue(equityMethodText.waitForExistence(timeout: 40)) + XCTAssertTrue(equityMethodText.label.contains("Exact"), "Precise mode's caption should say Exact, got: \(equityMethodText.label)") + attachScreenshot(of: app, name: "precise-flop-result") + } + + private func setBoardCard(index: Int, rank: String, suit: String, in app: XCUIApplication) { + let label = "Card \(index)" + + let rankPicker = app.buttons["\(label)RankPicker"] + scrollUntilVisible(rankPicker, in: app) + tapWhenHittable(rankPicker) + let rankOption = app.buttons[rank] + XCTAssertTrue(rankOption.waitForExistence(timeout: 3), "Rank menu option \(rank) never appeared") + rankOption.tap() + + let suitPicker = app.buttons["\(label)SuitPicker"] + scrollUntilVisible(suitPicker, in: app) + tapWhenHittable(suitPicker) + let suitOption = app.buttons[suit] + XCTAssertTrue(suitOption.waitForExistence(timeout: 3), "Suit menu option \(suit) never appeared") + suitOption.tap() + } + + /// Taps `element` once it's actually hittable, not just present in the accessibility + /// tree — right after a Form row changes (e.g. a menu closing and the next row's layout + /// settling), a `.tap()` on an element that technically "exists" can compute an invalid + /// off-screen hit point and silently miss, leaving the menu it should have opened never + /// actually opened. + private func tapWhenHittable(_ element: XCUIElement, timeout: TimeInterval = 3) { + let deadline = Date().addingTimeInterval(timeout) + while !element.isHittable && Date() < deadline { + Thread.sleep(forTimeInterval: 0.2) + } + element.tap() + } + /// 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