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