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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions PokerKit/Sources/PokerKit/Equity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions PokerKit/Tests/PokerKitTests/EquityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
41 changes: 40 additions & 1 deletion ai-docs/EQUITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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`
Expand Down
82 changes: 72 additions & 10 deletions app/Sources/EquityCalculatorView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -43,19 +61,31 @@ 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()
@State private var heroNotation = "AKs"
@State private var villainNotation = "QQ"
@State private var street: Street = .preflop
@State private var boardCards: [Card?] = Array(repeating: nil, count: 5)
@State private var mode: EquityMode = .fast

private var boardCardCount: Int {
switch street {
Expand Down Expand Up @@ -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..<boardCardCount, id: \.self) { index in
BoardCardPickerRow(
Expand All @@ -139,9 +176,27 @@ struct EquityCalculatorView: View {
}
}

Section("Mode") {
Picker("Mode", selection: $mode) {
ForEach(EquityMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.disabled(street == .preflop)
.accessibilityIdentifier("equityModePicker")
.onChange(of: mode) { _, _ in model.clearResult() }

if street == .preflop {
Text("Precise needs a board — an exact answer across a full hand class preflop takes several minutes to compute. Fast (Monte Carlo) is accurate enough for study purposes; see ai-docs/EQUITY.md.")
.font(.caption)
.foregroundStyle(.secondary)
}
}

Section {
Button {
model.calculate(hero: heroCombos, villain: villainCombos, board: resolvedBoard, iterations: Self.liveIterations)
model.calculate(hero: heroCombos, villain: villainCombos, board: resolvedBoard, mode: mode, iterations: Self.liveIterations)
} label: {
HStack {
Spacer()
Expand Down Expand Up @@ -177,13 +232,20 @@ struct EquityCalculatorView: View {
resultRow(label: "Hero wins", value: result.winRate, tint: .green, identifier: "heroWinRate")
resultRow(label: "Tie", value: result.tieRate, tint: .secondary, identifier: "tieRate")
resultRow(label: "Villain wins", value: result.loseRate, tint: .red, identifier: "villainWinRate")
Text("\(result.trials.formatted()) Monte Carlo simulations, fixed seed — same inputs always give the same result. Not a solver; see ai-docs/EQUITY.md.")
Text(methodCaption(for: result))
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("equityMethodText")
}
}

private func methodCaption(for result: EquityResult) -> 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)
Expand Down
Loading
Loading