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
76 changes: 76 additions & 0 deletions PokerKit/Sources/PokerKit/ICM.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Foundation

/// The **Independent Chip Model** (a.k.a. the Malmuth-Harville method) — converts a set of
/// tournament chip stacks and a payout structure into each player's exact $EV, accounting
/// for the fact that chips aren't linearly worth cash once a payout jump is on the table
/// (doubling up doesn't double your $EV; busting doesn't cost you your whole stack's $ value
/// if you were already guaranteed a min-cash). See `ai-docs/ICM.md` for the full derivation,
/// worked examples validated against a published source, and this model's scope.
///
/// **This is exact math, not a heuristic** — unlike every push/fold/opening/3-bet/4-bet
/// model elsewhere in this codebase, there's no hand-tuned percentage table here. Given
/// stacks and a payout structure, ICM equity is a specific, computable number; this module
/// computes it exactly (to floating-point precision), not an approximation of it.
public enum ICM {
/// Each player's exact ICM $ equity for the given `stacks` and `payouts`.
///
/// `payouts` is ordered 1st-place-first; a finishing position beyond
/// `payouts.count` (i.e. finishing out of the money) pays $0. `stacks` must all be
/// strictly positive — a player with 0 chips has already busted and isn't part of this
/// model's field; see `ICMRiskPremium` for how a bust is represented (removing the
/// player from the array entirely, not zeroing their stack).
///
/// **The algorithm** (Malmuth-Harville): P(a given player finishes 1st) is their share
/// of total remaining chips. Conditional on who finished 1st, P(a given remaining player
/// finishes 2nd) is *their* share of the chips remaining *after removing the 1st-place
/// finisher* — and so on recursively for every subsequent place. A seat's equity is the
/// sum, over every finishing position, of P(finish in that position) × that position's
/// payout.
///
/// **Implementation note:** computed via bitmask memoization over "which players are
/// still uneliminated at this point in the recursion" rather than literally enumerating
/// every one of the `n!` finishing orders the definition above suggests — the same exact
/// math (every finishing order is still implicitly weighted correctly), just sharing
/// work across orders that pass through the same remaining-player set. `O(2^n × n²)`
/// instead of `O(n!)`: trivial for realistic final-table sizes (≤10 players is instant;
/// even 20 players — far beyond any real final table — is still sub-second). Not
/// designed for, and not meaningfully useful at, full-field sizes (hundreds of players);
/// this is a final-table/bubble tool, not a whole-tournament simulator.
public static func equities(stacks: [Double], payouts: [Double]) -> [Double] {
let n = stacks.count
guard n > 0 else { return [] }
precondition(stacks.allSatisfy { $0 > 0 }, "ICM.equities requires every stack to be > 0 — a busted (0-chip) player isn't part of the field; remove them instead of zeroing their stack.")

func payout(atPosition position: Int) -> Double {
position < payouts.count ? payouts[position] : 0
}

var memo: [Int: [Double]] = [:]

func solve(_ remainingMask: Int) -> [Double] {
guard remainingMask != 0 else { return Array(repeating: 0, count: n) }
if let cached = memo[remainingMask] { return cached }

let position = n - remainingMask.nonzeroBitCount
var remainingStackSum = 0.0
for i in 0..<n where remainingMask & (1 << i) != 0 {
remainingStackSum += stacks[i]
}

var result = Array(repeating: 0.0, count: n)
for i in 0..<n where remainingMask & (1 << i) != 0 {
let pNext = stacks[i] / remainingStackSum
let sub = solve(remainingMask & ~(1 << i))
for p in 0..<n {
result[p] += pNext * sub[p]
}
result[i] += pNext * payout(atPosition: position)
}

memo[remainingMask] = result
return result
}

return solve((1 << n) - 1)
}
}
109 changes: 109 additions & 0 deletions PokerKit/Sources/PokerKit/ICMRiskPremium.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import Foundation

/// How much tighter an all-in **call** should be than pure chip-EV suggests once ICM is
/// accounted for — the standard "ICM makes calling shoves tighter near the bubble/final
/// table" effect. See `ai-docs/ICM.md`'s "ICM risk premium" section for the derivation and
/// every simplification below, spelled out.
///
/// **This is an adjustment layer, not a replacement.** It never touches `ICM`,
/// `PushFoldRange`, or `CallingRange` — it's a separate, additive opinion computed from
/// `ICM.equities` that a caller can consult alongside (not instead of) the existing chip-EV
/// calling models, the same "overlay, not mutation" shape `BountyEquity` already uses for
/// PKO bounties.
///
/// **What this deliberately simplifies away** (all flagged here rather than silently
/// assumed):
/// - **A single all-in confrontation for full stacks.** Models exactly one hero-vs-villain
/// shove/call where the loser is fully eliminated and the winner absorbs the loser's
/// entire stack (a "double up or bust" spot) — no side pots, no partial/covering stacks,
/// no multi-way all-ins.
/// - **Busting means $0, not a guaranteed min-cash.** A player who loses the confrontation
/// is assumed to win nothing further, even if they were already itm and a real
/// tournament would still pay them the min-cash for whatever place they bust in. This is
/// the standard simplifying assumption introductory ICM risk-premium explanations use; it
/// slightly *overstates* the true risk premium (the real gap between chip-EV and ICM is a
/// little smaller than this model reports, since a real bust often isn't worth literally
/// $0). Getting this exactly right requires knowing the full remaining payout ladder and
/// how many total entrants have already busted — information this module doesn't have and
/// doesn't ask for.
/// - **No Future Game State (FGS).** Doesn't model that winning a confrontation also
/// improves hero's *position* for hands after this one (better `PushFoldRange`/etc. odds
/// from a bigger stack) — only this single all-in's direct $EV.
/// - **`otherStacks` is treated as the entire remaining field.** If there are players alive
/// elsewhere in the tournament not included in `otherStacks`, the computed premium is
/// wrong — this is a final-table/complete-remaining-field tool, matching `ICM.equities`'s
/// own scope.
public enum ICMRiskPremium {
/// A single all-in confrontation's ICM cost/benefit breakdown. All equities are in the
/// same currency units as the `payouts` passed to `assess`.
public struct Assessment: Sendable {
/// The win probability at which calling breaks even in pure chip terms, ignoring
/// ICM entirely: `heroStack / (heroStack + villainStack)`. Independent of `payouts`
/// or `otherStacks` — a pot-odds number, not a tournament-equity one.
public let chipEVRequiredEquity: Double
/// The win probability at which calling breaks even in ICM ($) terms. `.nan` if
/// `payouts` has no positive value to play for (nothing to compute a premium
/// against).
public let icmRequiredEquity: Double
/// `icmRequiredEquity - chipEVRequiredEquity` — how much *extra* win probability ICM
/// demands before calling is profitable. Positive in the standard "protect your
/// stack near a payout jump" case; can be at or near zero when there's no payout
/// jump to protect (see `ai-docs/ICM.md`).
public let riskPremium: Double
/// Hero's ICM equity if this confrontation never happens (stacks unchanged).
public let foldEquity: Double
/// Hero's ICM equity if hero wins (absorbs villain's stack; villain is removed from
/// the field).
public let winEquity: Double
/// Hero's ICM equity if hero loses — fixed at `0` (see the module doc comment's
/// "busting means $0" simplification).
public let loseEquity: Double

public var reasoning: String {
guard icmRequiredEquity.isFinite else {
return "No payout to compute an ICM premium against (payouts sum to $0)."
}
let chip = String(format: "%.1f", chipEVRequiredEquity * 100)
let icm = String(format: "%.1f", icmRequiredEquity * 100)
let premium = String(format: "%.1f", riskPremium * 100)
return "Chip-EV breakeven: \(chip)% equity. ICM breakeven: \(icm)% equity "
+ "(a \(premium)-point risk premium from tournament payout pressure)."
}
}

/// ICM-adjusted call/fold breakeven for hero facing a `villainStack`-sized all-in, with
/// `otherStacks` (everyone else still alive in the tournament) unaffected by the
/// outcome. See the module doc comment for every assumption this makes.
public static func assess(
heroStack: Double,
villainStack: Double,
otherStacks: [Double],
payouts: [Double]
) -> Assessment {
precondition(heroStack > 0 && villainStack > 0, "Both hero and villain need a positive stack to be in a confrontation at all.")
precondition(otherStacks.allSatisfy { $0 > 0 }, "otherStacks must not include already-busted (0-chip) players — omit them instead.")

let chipEVRequired = heroStack / (heroStack + villainStack)

let foldStacks = [heroStack, villainStack] + otherStacks
let foldEquity = ICM.equities(stacks: foldStacks, payouts: payouts)[0]

let winStacks = [heroStack + villainStack] + otherStacks
let winEquity = ICM.equities(stacks: winStacks, payouts: payouts)[0]

let loseEquity = 0.0

let icmRequired: Double = (winEquity - loseEquity) > 0
? (foldEquity - loseEquity) / (winEquity - loseEquity)
: .nan

return Assessment(
chipEVRequiredEquity: chipEVRequired,
icmRequiredEquity: icmRequired,
riskPremium: icmRequired - chipEVRequired,
foldEquity: foldEquity,
winEquity: winEquity,
loseEquity: loseEquity
)
}
}
4 changes: 4 additions & 0 deletions PokerKit/Sources/PokerKit/StudyTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ public enum StudyTool: String, CaseIterable, Identifiable, Hashable, Sendable {
case preflopRanges
case pushFold
case equityCalculator
case icmCalculator
case bankroll
case handHistoryImport
case drills
Expand All @@ -16,6 +17,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 .icmCalculator: return "ICM Calculator"
case .bankroll: return "Bankroll Tracker"
case .handHistoryImport: return "Hand History Import & Leaks"
case .drills: return "Practice Your Leaks"
Expand All @@ -30,6 +32,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 .icmCalculator:
return "Exact tournament $EV for any set of stacks and a payout structure — Malmuth-Harville ICM, not a rule of thumb."
case .bankroll:
return "Buy-ins, cashes, ROI, variance, and bankroll-management guardrails for MTTs."
case .handHistoryImport:
Expand Down
146 changes: 146 additions & 0 deletions PokerKit/Tests/PokerKitTests/ICMTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import Testing
@testable import PokerKit

@Test func equalStacksSplitEquallyRegardlessOfPlayerCount() {
for n in 2...6 {
let stacks = Array(repeating: 1000.0, count: n)
let payouts = [500.0, 300.0, 200.0, 100.0, 50.0, 25.0]
let equities = ICM.equities(stacks: stacks, payouts: payouts)
let expectedEach = payouts.prefix(min(payouts.count, n)).reduce(0, +) / Double(n)
for equity in equities {
#expect(abs(equity - expectedEach) < 1e-9)
}
}
}

@Test func twoPlayerEquityMatchesTheExactClosedForm() {
// With exactly 2 players left, ICM collapses to a formula verifiable by hand:
// seat equity = (ownStack / totalStack) * p1 + (otherStack / totalStack) * p2.
let a = 6000.0
let b = 4000.0
let payouts = [100.0, 50.0]
let equities = ICM.equities(stacks: [a, b], payouts: payouts)

let expectedA = (a / (a + b)) * payouts[0] + (b / (a + b)) * payouts[1]
let expectedB = (b / (a + b)) * payouts[0] + (a / (a + b)) * payouts[1]
#expect(abs(equities[0] - expectedA) < 1e-9)
#expect(abs(equities[1] - expectedB) < 1e-9)
#expect(abs((equities[0] + equities[1]) - (payouts[0] + payouts[1])) < 1e-9)
}

@Test func threeHandedWorkedExampleMatchesWikipediasPublishedICMArticle() {
// Published worked example, Wikipedia's "Independent Chip Model" article (see
// ai-docs/ICM.md for the citation and the full by-hand re-derivation): stacks A/B/C =
// 50%/30%/20% of the chips in play, payouts 70 (1st) / 30 (2nd) / 0 (3rd, unpaid).
// Published (rounded) figures: A ≈ $45, B ≈ $32, C ≈ $22 (sums to ~$100 less rounding).
let stacks = [50.0, 30.0, 20.0]
let payouts = [70.0, 30.0]
let equities = ICM.equities(stacks: stacks, payouts: payouts)

// The source itself only publishes these to whole dollars with an explicit "≈" — its own
// rounding, not this project's precision, sets the tolerance here (loosest for C: the
// source's $22 is actually $22.57 rounded down, off by $0.57 from the true value). The
// exact-fraction assertions below are the real precision check.
#expect(abs(equities[0] - 45) < 0.5)
#expect(abs(equities[1] - 32) < 0.5)
#expect(abs(equities[2] - 22) < 0.6)

// Tight tolerance against the exact fractions this project independently re-derived by
// hand from the same recursive definition (see ai-docs/ICM.md) — 1265/28, 129/4, 158/7.
#expect(abs(equities[0] - 1265.0 / 28.0) < 1e-9)
#expect(abs(equities[1] - 129.0 / 4.0) < 1e-9)
#expect(abs(equities[2] - 158.0 / 7.0) < 1e-9)
#expect(abs(equities.reduce(0, +) - 100) < 1e-9)
}

@Test func threeHandedWorkedExampleWithThreePaidPlacesMatchesHandDerivedFractions() {
// A second worked example, hand-derived by this project (not third-party-sourced — see
// ai-docs/ICM.md) to exercise the full 3-place recursion (the Wikipedia example above
// only pays 2 places, so it never exercises the "3rd place" branch of the recursion).
// Same 50/30/20 stack split, standard 50/30/20 payout of a $1000 pool.
let stacks = [5000.0, 3000.0, 2000.0]
let payouts = [500.0, 300.0, 200.0]
let equities = ICM.equities(stacks: stacks, payouts: payouts)

#expect(abs(equities[0] - 5375.0 / 14.0) < 1e-9)
#expect(abs(equities[1] - 655.0 / 2.0) < 1e-9)
#expect(abs(equities[2] - 2020.0 / 7.0) < 1e-9)
#expect(abs(equities.reduce(0, +) - 1000) < 1e-9)
}

@Test func chipLeaderIsWorthLessPerChipThanTheShortStackTheICMTax() {
let stacks = [5000.0, 3000.0, 2000.0]
let payouts = [500.0, 300.0, 200.0]
let equities = ICM.equities(stacks: stacks, payouts: payouts)

let chipLeaderPerChip = equities[0] / stacks[0]
let shortStackPerChip = equities[2] / stacks[2]
#expect(chipLeaderPerChip < shortStackPerChip, "A top-heavy payout structure should make the chip leader's marginal chip worth less than the short stack's")
}

@Test func totalEquityAlwaysEqualsTotalPrizePoolAcrossVariedFields() {
let cases: [(stacks: [Double], payouts: [Double])] = [
([100, 100, 100, 100], [40, 30, 20, 10]),
([1, 2, 3, 4, 5], [50, 30, 20]),
([9000, 500, 300, 200], [60, 25, 15]),
([17, 33], [70, 30]),
]
for testCase in cases {
let equities = ICM.equities(stacks: testCase.stacks, payouts: testCase.payouts)
let expectedTotal = testCase.payouts.prefix(testCase.stacks.count).reduce(0, +)
#expect(abs(equities.reduce(0, +) - expectedTotal) < 1e-6)
}
}

@Test func singlePlayerGetsFirstPlacePayoutWithCertainty() {
let equities = ICM.equities(stacks: [12345], payouts: [500, 300, 200])
#expect(abs(equities[0] - 500) < 1e-9)
}

// MARK: - ICMRiskPremium

@Test func chipEVBreakevenIsFiftyPercentForEqualStacksHeadsUp() {
let assessment = ICMRiskPremium.assess(heroStack: 1000, villainStack: 1000, otherStacks: [], payouts: [100])
#expect(abs(assessment.chipEVRequiredEquity - 0.5) < 1e-9)
}

@Test func chipEVBreakevenFavorsTheShorterStack() {
// A shorter stack risks less to win relatively more (a bigger fractional double-up), so
// it needs less than 50% equity to profitably call off its whole stack — and the larger
// stack, symmetrically, needs more than 50%.
let shortCalling = ICMRiskPremium.assess(heroStack: 1000, villainStack: 4000, otherStacks: [], payouts: [100])
#expect(shortCalling.chipEVRequiredEquity < 0.5)

let bigCalling = ICMRiskPremium.assess(heroStack: 4000, villainStack: 1000, otherStacks: [], payouts: [100])
#expect(bigCalling.chipEVRequiredEquity > 0.5)
}

@Test func icmRequiredEquityExceedsChipEVNearABubbleWithOtherShortStacksAlive() {
// The textbook ICM-pressure spot: hero and villain are both comfortably above a couple
// of much shorter stacks who are current bubble/min-cash candidates. Busting hero
// shouldn't just cost chips — it should cost hero their shot at the stacks who are
// likely to bust *before* hero if hero survives. That should push the ICM-required
// calling equity above the flat chip-EV number.
let assessment = ICMRiskPremium.assess(
heroStack: 5000, villainStack: 5000, otherStacks: [1000, 1000], payouts: [50, 30, 15, 5]
)
#expect(assessment.icmRequiredEquity > assessment.chipEVRequiredEquity)
#expect(assessment.riskPremium > 0)
}

@Test func icmRequiredEquityIsUnaffectedWhenNothingIsAtStakeBeyondTheConfrontation() {
// Heads-up for the whole prize pool (winner takes all-remaining), no other stacks, no
// payout jump to protect against — ICM pressure should collapse back to the chip-EV
// number (no bubble to protect a min-cash on when there's only one payout left and only
// two players).
let assessment = ICMRiskPremium.assess(heroStack: 3000, villainStack: 7000, otherStacks: [], payouts: [100])
#expect(abs(assessment.icmRequiredEquity - assessment.chipEVRequiredEquity) < 1e-9)
}

@Test func winAndLoseEquityBracketTheFoldEquity() {
let assessment = ICMRiskPremium.assess(
heroStack: 4000, villainStack: 2000, otherStacks: [3000, 1000], payouts: [50, 30, 20]
)
#expect(assessment.loseEquity < assessment.foldEquity)
#expect(assessment.foldEquity < assessment.winEquity)
}
Loading
Loading