From 0abd11ff453a84cacfb68476a22102a053784011 Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Wed, 22 Jul 2026 04:48:09 +0200 Subject: [PATCH 1/3] Add ICM (Malmuth-Harville) and an ICM risk-premium adjustment layer ICM.equities computes exact tournament $EV via bitmask-memoized recursion over finishing-position probabilities, validated against a published Wikipedia worked example (exact fractions independently re-derived by hand) plus a second hand-derived 3-payout example, ICM-tax and conservation sanity checks. ICMRiskPremium is a separate overlay (same shape as BountyEquity) computing the ICM-adjusted equity required to profitably call an all-in, with every simplification (single all-in, busting=$0, no FGS, complete-remaining-field assumption) disclosed in the doc comment. --- PokerKit/Sources/PokerKit/ICM.swift | 76 +++++++++ .../Sources/PokerKit/ICMRiskPremium.swift | 109 +++++++++++++ PokerKit/Tests/PokerKitTests/ICMTests.swift | 146 ++++++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 PokerKit/Sources/PokerKit/ICM.swift create mode 100644 PokerKit/Sources/PokerKit/ICMRiskPremium.swift create mode 100644 PokerKit/Tests/PokerKitTests/ICMTests.swift diff --git a/PokerKit/Sources/PokerKit/ICM.swift b/PokerKit/Sources/PokerKit/ICM.swift new file mode 100644 index 0000000..fff4cdf --- /dev/null +++ b/PokerKit/Sources/PokerKit/ICM.swift @@ -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.. 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 + ) + } +} diff --git a/PokerKit/Tests/PokerKitTests/ICMTests.swift b/PokerKit/Tests/PokerKitTests/ICMTests.swift new file mode 100644 index 0000000..39a7dc2 --- /dev/null +++ b/PokerKit/Tests/PokerKitTests/ICMTests.swift @@ -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) +} From fd9ae7738eba2d3a0819fe151cff5d09858ca237 Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Wed, 22 Jul 2026 04:48:18 +0200 Subject: [PATCH 2/3] ICM.md: derivation, worked-example validation, risk-premium scope Full by-hand re-derivation of the Wikipedia worked example's exact fractions, and every ICMRiskPremium simplification spelled out. --- ai-docs/ICM.md | 198 ++++++++++++++++++++++++++++++++++++++++++++++ ai-docs/README.md | 1 + 2 files changed, 199 insertions(+) create mode 100644 ai-docs/ICM.md diff --git a/ai-docs/ICM.md b/ai-docs/ICM.md new file mode 100644 index 0000000..1077b22 --- /dev/null +++ b/ai-docs/ICM.md @@ -0,0 +1,198 @@ +# The Independent Chip Model (ICM) + +Source: `PokerKit/Sources/PokerKit/ICM.swift`, `ICMRiskPremium.swift`. Tests: +`ICMTests.swift`. + +## What it does + +Converts a set of tournament chip stacks and a payout structure into each +player's exact $EV — the **Malmuth-Harville method**, the standard +implementation of ICM used across the poker industry (HoldemResources +Calculator, ICMIZER, and every other ICM tool implement this same algorithm). +Chips aren't worth cash 1:1 once there's a payout structure on the table: +doubling your stack doesn't double your tournament equity, and busting +doesn't cost you your whole stack's worth of $EV if a chunk of that value was +"I'm guaranteed at least the min-cash" money that survives the bust. ICM +quantifies exactly how much. + +**This is exact math, not a hand-tuned study aid** — the one model in this +codebase that isn't. Every other model here (`PushFoldRange`, `OpeningRange`, +`CallingRange`, `ThreeBetRange`, `FourBetRange`) encodes a percentage table +someone had to estimate; ICM equity is a specific, computable number once you +fix the stacks and payouts. `ICM.equities` computes it to floating-point +precision — there's no approximation to disclose about the core algorithm +itself (the approximations live one layer up, in `ICMRiskPremium` — see +below). + +## The algorithm + +For a field of `n` players with chip stacks `s₁...sₙ` and a payout table +`p₁...pₘ` (1st place first; a finishing position beyond `m` pays $0): + +1. **P(a given player finishes 1st)** = their stack ÷ total chips in play. +2. **P(a given remaining player finishes 2nd)**, conditional on who finished + 1st = their stack ÷ the chips remaining *after removing the 1st-place + finisher* (re-normalizing over what's left). +3. Recurse the same way for 3rd, 4th, ... down through every remaining + player. +4. A seat's **equity** = Σ over every finishing position `k` of + P(finish in position `k`) × `pₖ`. + +This is exactly David Harville's 1973 method for predicting horse-race +finishing orders (stack size stands in for "horse strength"), adapted to +poker tournaments by Mason Malmuth in 1987 — hence "Malmuth-Harville." + +### Implementation: bitmask memoization, not literal `n!` enumeration + +The definition above, read literally, enumerates every one of the `n!` +possible finishing orders. `ICM.equities` computes the identical result +without doing that: the recursive sub-problem "compute equity contributions +from here" depends only on *which players are still uneliminated*, not on +the specific order that got them there — so it memoizes on that remaining- +player set (a bitmask), giving `O(2ⁿ × n²)` instead of `O(n!)`. For any +realistic final table (≤10 players) this is effectively instant; even 20 +players — far beyond a real final table — stays comfortably sub-second. This +is not designed for, and not useful at, full-field sizes (hundreds of +entrants) — it's a final-table/bubble tool, matching how ICM is actually used +in practice (nobody runs ICM on a 500-entrant field at hand #1). + +## Validation against a published worked example + +**Source**: Wikipedia's ["Independent Chip Model"](https://en.wikipedia.org/wiki/Independent_Chip_Model) +article (fetched directly while building this feature). Its worked example: +three players A/B/C with chip stacks in a 50%/30%/20% split, payouts of 70 +(1st) and 30 (2nd) — a 100-unit pool, 3rd place unpaid. The article publishes +(loosely rounded, using "≈" throughout): **A ≈ $45, B ≈ $32, C ≈ $22**. + +This project independently re-derived the exact fractions by hand from the +same recursive definition above (shown here so the arithmetic is checkable +without running any code): + +``` +P(A 1st) = 1/2, P(B 1st) = 3/10, P(C 1st) = 1/5 + +Given A 1st (remaining B=3/10, C=1/5, sum=1/2): + P(B 2nd | A 1st) = 3/5, P(C 2nd | A 1st) = 2/5 +Given B 1st (remaining A=1/2, C=1/5, sum=7/10): + P(A 2nd | B 1st) = 5/7, P(C 2nd | B 1st) = 2/7 +Given C 1st (remaining A=1/2, B=3/10, sum=4/5): + P(A 2nd | C 1st) = 5/8, P(B 2nd | C 1st) = 3/8 + +P(A 2nd) = P(B1)(5/7) + P(C1)(5/8) = 3/14 + 1/8 = 19/56 +P(B 2nd) = P(A1)(3/5) + P(C1)(3/8) = 3/10 + 3/40 = 3/8 +P(C 2nd) = P(A1)(2/5) + P(B1)(2/7) = 1/5 + 3/35 = 2/7 + +Equity A = 70(1/2) + 30(19/56) = 35 + 570/56 = 1265/28 ≈ 45.1786 +Equity B = 70(3/10) + 30(3/8) = 21 + 90/8 = 129/4 = 32.25 +Equity C = 70(1/5) + 30(2/7) = 14 + 60/7 = 158/7 ≈ 22.5714 + +Check: 1265/28 + 129/4 + 158/7 = 1265/28 + 903/28 + 632/28 = 2800/28 = 100 ✓ +``` + +`ICMTests.threeHandedWorkedExampleMatchesWikipediasPublishedICMArticle` +checks `ICM.equities` against **both**: the published rounded figures (loose +tolerance, since the source itself only publishes to the nearest dollar — +its `$22` is actually `$22.57` rounded down, so that specific comparison +needs a wider band than the other two) and the exact fractions above (tight, +`1e-9`, this project's own independently-checkable arithmetic — the real +precision validation). A second worked example +(`threeHandedWorkedExampleWithThreePaidPlacesMatchesHandDerivedFractions`) +uses the same 50/30/20 stack split against a 3-paid-place 50/30/20 payout +(`$5375/14, $655/2, $2020/7` on a $1000 pool) — hand-derived the same way, not +third-party-sourced, but included because the Wikipedia example only pays 2 +places and never exercises the recursion's 3rd-place branch. + +Other correctness gates in `ICMTests.swift`: +- **Equal stacks split equity equally** (2 through 6 players) — provable by + symmetry, no citation needed. +- **The 2-player closed form**, `equity = (own/(own+other))·p1 + + (other/(own+other))·p2`, checked against the general algorithm's output. +- **Total equity conservation**: equities always sum to exactly the prize + pool being played for, across a battery of varied stack/payout shapes. +- **The "ICM tax"**: in a top-heavy payout structure, the chip leader's `$` + per chip is *lower* than the short stack's — the core qualitative fact ICM + exists to capture, asserted directly rather than just implied by the + numbers matching. + +## ICM risk premium — `ICMRiskPremium` + +Applies ICM to the most common practical question it answers: **is calling +this all-in still profitable once tournament payout pressure is accounted +for, or does chip-EV alone overstate it?** Near a bubble or a real payout +jump, ICM makes calling shoves *tighter* than chip-EV suggests — busting +doesn't just cost chips, it costs your shot at the min-cash and every payout +jump above it. + +**This is an overlay, not a replacement** — same shape as `BountyEquity` +(see `BOUNTY.md`). It never touches `ICM`, `PushFoldRange`, or +`CallingRange`; it's a separate opinion computed from `ICM.equities` that a +caller can consult *alongside* the existing chip-EV calling models, not +instead of them. Nothing in this codebase's calling ranges is mutated by +this module existing. + +### The math + +For hero facing a `villainStack`-sized all-in (full-stack shove/call — see +"What this simplifies away" below), with `otherStacks` unaffected by the +outcome and a `payouts` table: + +- **Chip-EV breakeven** (ignoring ICM): `heroStack / (heroStack + + villainStack)` — a pot-odds number. Note this *isn't* flat 50% even + without ICM: a shorter stack needs less than 50% equity to profitably call + off its whole stack (a bigger stack risks a lot to win comparatively + little, so it needs more than 50%), which is standard push/fold theory, + not new to this module. +- **ICM breakeven**: solve for the win probability `p` where `EV(call) = + EV(fold)` in ICM-dollar terms. Since `EV(call) = p·winEquity + (1-p)·loseEquity` + is linear in `p`, this has a closed form: + `p = (foldEquity − loseEquity) / (winEquity − loseEquity)`, where: + - `foldEquity` = hero's `ICM.equities` share of the field with stacks + unchanged. + - `winEquity` = hero's share after absorbing villain's stack (villain + removed from the field). + - `loseEquity` = **fixed at 0** — see below. +- **Risk premium** = `ICM breakeven − chip-EV breakeven`. Positive in the + standard "protect your stack near a payout jump" case; + `ICMTests.icmRequiredEquityIsUnaffectedWhenNothingIsAtStakeBeyondTheConfrontation` + checks it collapses to exactly 0 in the one case where it provably should + (heads-up, single payout, nothing else alive — no bubble to protect). + +### What this deliberately simplifies away + +Every one of these is a disclosed scope limitation, not an oversight — also +stated directly in `ICMRiskPremium`'s doc comment so it's visible at the call +site, not just here: + +- **A single all-in for full stacks.** One hero-vs-villain confrontation, + loser fully eliminated, winner absorbs the loser's entire stack. No side + pots, no covering/covered partial-stack all-ins, no multi-way pots. +- **Busting means $0, not a guaranteed min-cash.** `loseEquity` is fixed at + `0` rather than computed — a real tournament often still pays a busted + player *something* (the min-cash for whatever place they finish, even at + 0 chips), which this module doesn't model because doing so correctly + needs the full remaining payout ladder and how many total entrants have + already busted overall — information this module doesn't have and doesn't + ask for. Net effect: **this model slightly overstates the true risk + premium** (the real gap between chip-EV and ICM is a bit smaller than what + it reports), since it treats every bust as forfeiting money a real min-cash + floor would sometimes have protected. This is the same simplification + common introductory ICM risk-premium explanations make. +- **No Future Game State (FGS).** Doesn't model that winning also improves + hero's *position* for every hand after this one (a bigger stack means + better odds in `PushFoldRange`/etc. going forward) — only this one all-in's + direct $EV. +- **`otherStacks` must be the entire remaining field.** If players are alive + elsewhere in the tournament not included in `otherStacks`, the computed + premium is wrong. This matches `ICM.equities`'s own scope — a final-table/ + complete-remaining-field tool, not a partial slice of a larger field. + +## Consumers + +- Planned: an ICM Calculator view (stacks + payouts in, per-seat $EV out) — + see the app's `StudyTool` list. + +`ICM.equities` and `ICMRiskPremium.assess` are both pure functions with no +dependency on anything else in `PokerKit` beyond `Foundation` — they don't +reuse `ChenScore`/`PushFoldRange`'s threshold pipeline the way every other +model in this codebase does, because ICM isn't a hand-ranking problem at all; +it only ever operates on chip stacks and payouts. diff --git a/ai-docs/README.md b/ai-docs/README.md index abac5da..59a2c81 100644 --- a/ai-docs/README.md +++ b/ai-docs/README.md @@ -18,6 +18,7 @@ drop into whichever subsystem you're touching: | [PREFLOP-GRID.md](PREFLOP-GRID.md) | The 13×13 grid enumeration and range viewer | | [BOUNTY.md](BOUNTY.md) | PKO bounty-adjusted shove ranges: the formula, its source, and what it doesn't model | | [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 | | [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 54d34a2b0b3f2ab8c278607b4c3e1d095a408b2f Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Wed, 22 Jul 2026 04:48:27 +0200 Subject: [PATCH 3/3] Add ICM Calculator view, wired into StudyTool Live-recomputing stacks + payouts -> per-seat $EV screen (ICM.equities is pure and fast, no async dispatch needed). Deliberately scoped to just the calculator; ICMRiskPremium has no dedicated screen yet. --- PokerKit/Sources/PokerKit/StudyTool.swift | 4 + app/Sources/ContentView.swift | 2 + app/Sources/ICMCalculatorView.swift | 173 ++++++++++++++++++++++ app/UITests/ICMCalculatorUITests.swift | 82 ++++++++++ 4 files changed, 261 insertions(+) create mode 100644 app/Sources/ICMCalculatorView.swift create mode 100644 app/UITests/ICMCalculatorUITests.swift diff --git a/PokerKit/Sources/PokerKit/StudyTool.swift b/PokerKit/Sources/PokerKit/StudyTool.swift index 5f3a40b..27442c4 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 icmCalculator case bankroll case handHistoryImport case drills @@ -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" @@ -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: diff --git a/app/Sources/ContentView.swift b/app/Sources/ContentView.swift index 14684e4..a703a7a 100644 --- a/app/Sources/ContentView.swift +++ b/app/Sources/ContentView.swift @@ -25,6 +25,8 @@ struct ContentView: View { PushFoldTrainerView() case .equityCalculator: EquityCalculatorView() + case .icmCalculator: + ICMCalculatorView() case .bankroll: BankrollTrackerView() case .handHistoryImport: diff --git a/app/Sources/ICMCalculatorView.swift b/app/Sources/ICMCalculatorView.swift new file mode 100644 index 0000000..c4eddd7 --- /dev/null +++ b/app/Sources/ICMCalculatorView.swift @@ -0,0 +1,173 @@ +import SwiftUI +import PokerKit + +/// One editable stack row. Stack is kept as text (not `Double`) so a field can sit briefly +/// invalid (empty, mid-edit) without losing what the user typed — `ICMCalculatorView` only +/// computes once every row parses. +private struct StackEntry: Identifiable { + let id = UUID() + var name: String + var stack: String +} + +/// Enter chip stacks and a payout structure, see every seat's exact ICM $ equity — +/// `ICM.equities` is a pure, fast function (no async dispatch needed, unlike +/// `EquityCalculatorView`'s Monte Carlo work), so this view recomputes live on every edit +/// rather than needing a Calculate button. +/// +/// Deliberately scoped to just the calculator: `ICMRiskPremium` (the ICM-adjusted +/// required-equity-to-call helper — see `ai-docs/ICM.md`) is implemented and tested but has +/// no dedicated screen yet; wiring a shove/call ICM trainer around it is a natural follow-up, +/// not part of this view. +struct ICMCalculatorView: View { + @State private var stacks: [StackEntry] = [ + StackEntry(name: "Seat 1", stack: "5000"), + StackEntry(name: "Seat 2", stack: "3000"), + StackEntry(name: "Seat 3", stack: "2000"), + ] + @State private var payouts: [String] = ["500", "300", "200"] + + private var parsedStacks: [Double]? { + let values = stacks.map { Double($0.stack) } + guard values.allSatisfy({ $0 != nil && $0! > 0 }) else { return nil } + return values.map { $0! } + } + + private var parsedPayouts: [Double]? { + let values = payouts.map { Double($0) } + guard values.allSatisfy({ $0 != nil && $0! >= 0 }) else { return nil } + return values.map { $0! } + } + + private var equities: [Double]? { + guard let parsedStacks, !parsedStacks.isEmpty, let parsedPayouts else { return nil } + return ICM.equities(stacks: parsedStacks, payouts: parsedPayouts) + } + + private var validationMessage: String? { + if stacks.isEmpty { return "Add at least one stack." } + if parsedStacks == nil { return "Every stack needs a positive number." } + if parsedPayouts == nil { return "Every payout needs a number (0 or more)." } + return nil + } + + var body: some View { + Form { + Section("Stacks") { + ForEach($stacks) { $entry in + HStack { + TextField("Name", text: $entry.name) + .frame(maxWidth: .infinity) + TextField("Chips", text: $entry.stack) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 90) + .accessibilityIdentifier("stackField-\(entry.name)") + } + } + .onDelete { stacks.remove(atOffsets: $0) } + + Button("Add Player") { + stacks.append(StackEntry(name: "Seat \(stacks.count + 1)", stack: "")) + } + .accessibilityIdentifier("addPlayerButton") + } + + Section("Payouts") { + ForEach(Array(payouts.enumerated()), id: \.offset) { index, _ in + HStack { + Text(placeLabel(index)) + .foregroundStyle(.secondary) + .frame(width: 50, alignment: .leading) + TextField("Amount", text: $payouts[index]) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .accessibilityIdentifier("payoutField-\(index)") + } + } + .onDelete { payouts.remove(atOffsets: $0) } + + Button("Add Place") { + payouts.append("") + } + .accessibilityIdentifier("addPlaceButton") + } + + if let equities, let parsedStacks { + resultSection(equities: equities, stacks: parsedStacks) + } else if let validationMessage { + Section { + Text(validationMessage) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("icmValidationText") + } + } + } + .navigationTitle("ICM Calculator") + .navigationBarTitleDisplayMode(.inline) + } + + private func placeLabel(_ index: Int) -> String { + switch index { + case 0: return "1st" + case 1: return "2nd" + case 2: return "3rd" + default: return "\(index + 1)th" + } + } + + private func resultSection(equities: [Double], stacks parsedStacks: [Double]) -> some View { + let totalChips = parsedStacks.reduce(0, +) + return Section("Equity") { + ForEach(stacks.indices, id: \.self) { index in + let stack = parsedStacks[index] + let equity = equities[index] + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(stacks[index].name) + .font(.headline) + Spacer() + Text(currency(equity)) + .font(.headline) + .monospacedDigit() + .accessibilityIdentifier("equityValue-\(stacks[index].name)") + } + Text("\(percent(stack / totalChips)) of chips — \(currency(equity / stack))/chip") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + HStack { + Text("Total") + .foregroundStyle(.secondary) + Spacer() + Text(currency(equities.reduce(0, +))) + .foregroundStyle(.secondary) + .monospacedDigit() + .accessibilityIdentifier("icmTotalEquity") + } + .font(.caption) + + Text("Exact math (Malmuth-Harville ICM), not a solver or a hand-tuned estimate — see ai-docs/ICM.md.") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("icmCaveatText") + } + } + + private func currency(_ value: Double) -> String { + "$" + String(format: "%.2f", value) + } + + private func percent(_ value: Double) -> String { + String(format: "%.1f%%", value * 100) + } +} + +#Preview { + NavigationStack { + ICMCalculatorView() + } +} diff --git a/app/UITests/ICMCalculatorUITests.swift b/app/UITests/ICMCalculatorUITests.swift new file mode 100644 index 0000000..d85f1cd --- /dev/null +++ b/app/UITests/ICMCalculatorUITests.swift @@ -0,0 +1,82 @@ +import XCTest + +final class ICMCalculatorUITests: XCTestCase { + func testDefaultStacksProduceICMEquitySummingToThePrizePool() { + let app = XCUIApplication() + app.launch() + + let icmRow = app.staticTexts["ICM Calculator"] + scrollUntilVisible(icmRow, in: app) + icmRow.tap() + + // Defaults: stacks 5000/3000/2000, payouts 500/300/200 — a $1000 pool, so the total + // equity row should read exactly $1000.00 (the same worked example ai-docs/ICM.md + // hand-derives). + let total = app.staticTexts["icmTotalEquity"] + scrollUntilVisible(total, in: app) + XCTAssertTrue(total.waitForExistence(timeout: 5)) + XCTAssertEqual(total.label, "$1000.00") + + let caveat = app.staticTexts["icmCaveatText"] + XCTAssertTrue(caveat.exists) + XCTAssertTrue(caveat.label.contains("ICM.md")) + attachScreenshot(of: app, name: "icm-default") + } + + func testAddingAPlayerAddsAStackRow() { + let app = XCUIApplication() + app.launch() + + let icmRow = app.staticTexts["ICM Calculator"] + scrollUntilVisible(icmRow, in: app) + icmRow.tap() + + let addPlayerButton = app.buttons["addPlayerButton"] + scrollUntilVisible(addPlayerButton, in: app) + XCTAssertTrue(addPlayerButton.waitForExistence(timeout: 5)) + addPlayerButton.tap() + + let newStackField = app.textFields["stackField-Seat 4"] + scrollUntilVisible(newStackField, in: app) + XCTAssertTrue(newStackField.waitForExistence(timeout: 5), "Adding a player should add a 4th stack row") + } + + func testInvalidStackShowsValidationMessageInsteadOfEquity() { + let app = XCUIApplication() + app.launch() + + let icmRow = app.staticTexts["ICM Calculator"] + scrollUntilVisible(icmRow, in: app) + icmRow.tap() + + let firstStackField = app.textFields["stackField-Seat 1"] + scrollUntilVisible(firstStackField, in: app) + XCTAssertTrue(firstStackField.waitForExistence(timeout: 5)) + firstStackField.tap() + // Appending a non-numeric character (rather than deleting the existing text) is a + // more reliable way to invalidate a `.numberPad` field in XCUITest — the delete key + // synthesis `clearText()` (used elsewhere for a plain text field) doesn't reliably + // land on a number-pad keyboard's layout. + firstStackField.typeText("x") + + let validationText = app.staticTexts["icmValidationText"] + scrollUntilVisible(validationText, in: app) + XCTAssertTrue(validationText.waitForExistence(timeout: 5), "An empty/invalid stack should show a validation message instead of an equity result") + XCTAssertFalse(app.staticTexts["icmTotalEquity"].exists) + attachScreenshot(of: app, name: "icm-invalid-stack") + } + + private func scrollUntilVisible(_ element: XCUIElement, in app: XCUIApplication, maxSwipes: Int = 8) { + for _ in 0..