From 26244979b9ad4069e705417b3f1a3de082d0c6fe Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Mon, 20 Jul 2026 19:19:50 +0200 Subject: [PATCH 1/2] Add PKO bounty-adjusted shove equity model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New BountyEquity module widens PushFoldRange's shove percentage to account for a collectible bounty when hero covers villain — busting a covered opponent wins their bounty on top of the pot, extra equity a pure chip-EV model has no way to see. Overlay only: never touches PushFoldRange, and bountyBB == 0 is a proven exact no-op. Formula: pot / (pot + bounty) as a break-even-equity-threshold multiplier, sourced from GTO Wizard's PKO theory writeup (a worked numeric example converting a $50 bounty to bb and applying it to a pot-odds calculation). Applied as a widening reciprocal to PushFoldRange's percentage rather than to a raw equity number, since this codebase has no equity model — see ai-docs/BOUNTY.md for the full derivation and every simplification (pot approximated as 2x effective stack, no ICM, being-covered risk explicitly out of scope, calling side deferred pending the CallingRange PR). 19 new tests: bounty=0 reproduces the base model exactly, monotonic widening by bounty size and by shorter stack, clamping at 100%, no-op when hero doesn't cover villain, and a borderline hand flipping fold->push with a large enough bounty. --- PokerKit/Sources/PokerKit/BountyEquity.swift | 145 +++++++++++++++ .../PokerKitTests/BountyEquityTests.swift | 175 ++++++++++++++++++ ai-docs/BOUNTY.md | 174 +++++++++++++++++ ai-docs/RANGES.md | 6 + ai-docs/README.md | 1 + 5 files changed, 501 insertions(+) create mode 100644 PokerKit/Sources/PokerKit/BountyEquity.swift create mode 100644 PokerKit/Tests/PokerKitTests/BountyEquityTests.swift create mode 100644 ai-docs/BOUNTY.md diff --git a/PokerKit/Sources/PokerKit/BountyEquity.swift b/PokerKit/Sources/PokerKit/BountyEquity.swift new file mode 100644 index 0000000..9753a75 --- /dev/null +++ b/PokerKit/Sources/PokerKit/BountyEquity.swift @@ -0,0 +1,145 @@ +import Foundation + +/// A bounty-adjusted push/fold decision — see `ai-docs/BOUNTY.md` for the formula, its +/// derivation, and every simplification it makes before trusting a specific number. +public struct BountyAdjustedDecision: Sendable { + public let action: PushFoldAction + public let handScore: Double + public let baseShovePercentage: Double + public let adjustedShovePercentage: Double + public let baseScoreThreshold: Double + public let adjustedScoreThreshold: Double + public let bountyBB: Double + public let heroCoversVillain: Bool + + /// Whether the bounty actually changed anything for this spot (a hand can only be + /// widened *into* the range, never widened out of it — see `BountyEquity`). + public var bountyChangedTheRange: Bool { adjustedShovePercentage > baseShovePercentage } + + /// A short, human-readable justification that's explicit about whether — and why — a + /// bounty affected this decision, rather than silently folding the adjustment into + /// ordinary-looking numbers. + public var reasoning: String { + let baseLine = "Hand strength score \(formatted(handScore)) vs. a base (no-bounty) shove " + + "threshold of \(formatted(baseScoreThreshold)) (top \(pct(baseShovePercentage))% of hands)." + + guard bountyBB > 0 else { + return baseLine + " No bounty entered." + } + guard heroCoversVillain else { + return baseLine + " A \(formatted(bountyBB))bb bounty is on the table, but hero doesn't " + + "cover villain here, so it isn't collectible on this shove — no adjustment applied." + } + return baseLine + " A \(formatted(bountyBB))bb bounty (hero covers villain) widens the shove " + + "threshold to \(formatted(adjustedScoreThreshold)) (top \(pct(adjustedShovePercentage))% of hands). " + + (action == .push ? "This hand shoves." : "Still not enough, even with the bounty.") + } + + private func pct(_ value: Double) -> String { String(format: "%.0f", value) } + + private func formatted(_ value: Double) -> String { + value == value.rounded() ? String(format: "%.0f", value) : String(format: "%.1f", value) + } +} + +/// Converts a PKO bounty into a widening of a **chip-EV** shove/call percentage — see +/// `ai-docs/BOUNTY.md` for the full derivation and its sources. +/// +/// **The short version:** winning a hand where hero covers villain doesn't just win the +/// pot, it eliminates villain and claims their bounty — extra equity a pure chip-EV model +/// (`PushFoldRange`) has no way to see, since it only ever reasons about chips. The +/// standard approach found across PKO strategy sources treats the bounty as chips added to +/// what you win, which lowers the equity you need to profitably get in — and, in this +/// model, translates to a *wider* range: `newThreshold = oldThreshold × pot / (pot + +/// bounty)`. This module implements that as a widening ratio (the reciprocal, applied to a +/// percentage rather than a raw equity number) rather than a threshold shrink, so it +/// composes with the rest of this codebase's percentage → `PushFoldRange.scoreThreshold` +/// pipeline instead of inventing a second one. +/// +/// **This is an overlay, not a replacement.** It never touches `PushFoldRange` — every +/// function here takes a base percentage as input and returns an adjusted one; `bountyBB == +/// 0` (or `heroCoversVillain == false`) is defined to be an exact no-op, so the plain +/// chip-EV tool is provably unaffected by this module existing. +/// +/// **What this deliberately doesn't model** (see `BOUNTY.md` for the full list): ICM at +/// all, the risk of *being* covered (a separate, negative adjustment this module doesn't +/// compute), future-bounty-overlay / stack-dynamics effects across a whole tournament, and +/// anything about villain's calling range (the bounty math here is symmetric and would +/// apply the same way to a call as to a shove, but only the shove side is wired up — see +/// `BOUNTY.md`'s "Scope" section for why). +public enum BountyEquity { + /// Converts a bounty quoted as a fraction of the tournament's starting stack (a common + /// way PKO structures describe bounty size, e.g. "50% of the buy-in funds the bounty + /// pool, worth ~33% of a starting stack") into bb, given the starting stack size in bb. + /// Negative inputs are clamped to 0 rather than producing a negative bounty. + public static func bountyBB(fractionOfStartingStack fraction: Double, startingStackBB: Double) -> Double { + max(fraction, 0) * max(startingStackBB, 0) + } + + /// The standard PKO break-even-equity-threshold multiplier, `pot / (pot + bounty)` — + /// see the module doc comment and `BOUNTY.md` for the source and derivation. Always in + /// `(0, 1]`: `1` (no change) whenever there's nothing to adjust for (no bounty, hero + /// doesn't cover villain, or a degenerate zero/negative stack), shrinking toward 0 as + /// the bounty grows large relative to the pot. + /// + /// `potBB` is approximated as `2 × effectiveStackBB` — hero's shove plus a covering + /// call, ignoring blinds/antes already in the pot. This is a standard simplification + /// for short-stack push/fold math (blinds/antes are a small fraction of the pot at the + /// stack depths `PushFoldRange` covers) and matches the fact that `PushFoldRange` + /// itself never takes blind/ante size as an input either — this module doesn't + /// introduce a new missing input, it inherits an existing one. + public static func thresholdMultiplier(effectiveStackBB: Double, bountyBB: Double, heroCoversVillain: Bool) -> Double { + guard heroCoversVillain, bountyBB > 0, effectiveStackBB > 0 else { return 1 } + let potBB = 2 * effectiveStackBB + return potBB / (potBB + bountyBB) + } + + /// `baseShovePercentage`, widened by the reciprocal of `thresholdMultiplier` and + /// clamped to 100. Exact no-op (`== baseShovePercentage`) whenever + /// `thresholdMultiplier` is 1 — in particular, always a no-op when `bountyBB == 0`. + public static func widenedPercentage( + baseShovePercentage: Double, + effectiveStackBB: Double, + bountyBB: Double, + heroCoversVillain: Bool + ) -> Double { + let multiplier = thresholdMultiplier(effectiveStackBB: effectiveStackBB, bountyBB: bountyBB, heroCoversVillain: heroCoversVillain) + guard multiplier < 1, multiplier > 0 else { return baseShovePercentage } + return min(baseShovePercentage / multiplier, 100) + } + + /// Bounty-adjusted shove/fold decision for `hand`, composing straight through + /// `PushFoldRange`'s own percentage and threshold functions — this module never + /// re-derives or duplicates either. `bountyBB: 0` reproduces + /// `PushFoldRange.decide(hand:position:effectiveStackBB:)` exactly. + public static func decide( + hand: HoleCards, + position: Position, + effectiveStackBB: Double, + bountyBB: Double, + heroCoversVillain: Bool + ) -> BountyAdjustedDecision { + let basePercentage = PushFoldRange.shovePercentage(position: position, effectiveStackBB: effectiveStackBB) + let adjustedPercentage = widenedPercentage( + baseShovePercentage: basePercentage, + effectiveStackBB: effectiveStackBB, + bountyBB: bountyBB, + heroCoversVillain: heroCoversVillain + ) + let baseThreshold = PushFoldRange.scoreThreshold(forPercentage: basePercentage) + let adjustedThreshold = PushFoldRange.scoreThreshold(forPercentage: adjustedPercentage) + let handScore = ChenScore.score(for: hand) + let action: PushFoldAction = handScore >= adjustedThreshold ? .push : .fold + + return BountyAdjustedDecision( + action: action, + handScore: handScore, + baseShovePercentage: basePercentage, + adjustedShovePercentage: adjustedPercentage, + baseScoreThreshold: baseThreshold, + adjustedScoreThreshold: adjustedThreshold, + bountyBB: bountyBB, + heroCoversVillain: heroCoversVillain + ) + } +} diff --git a/PokerKit/Tests/PokerKitTests/BountyEquityTests.swift b/PokerKit/Tests/PokerKitTests/BountyEquityTests.swift new file mode 100644 index 0000000..0f9f705 --- /dev/null +++ b/PokerKit/Tests/PokerKitTests/BountyEquityTests.swift @@ -0,0 +1,175 @@ +import Testing +@testable import PokerKit + +// MARK: - bountyBB conversion + +@Test func bountyBBMultipliesFractionByStartingStack() { + #expect(BountyEquity.bountyBB(fractionOfStartingStack: 0.33, startingStackBB: 100) == 33) +} + +@Test func bountyBBClampsNegativeInputsToZero() { + #expect(BountyEquity.bountyBB(fractionOfStartingStack: -0.5, startingStackBB: 100) == 0) + #expect(BountyEquity.bountyBB(fractionOfStartingStack: 0.5, startingStackBB: -100) == 0) +} + +// MARK: - thresholdMultiplier + +@Test func thresholdMultiplierIsOneWithNoBounty() { + #expect(BountyEquity.thresholdMultiplier(effectiveStackBB: 10, bountyBB: 0, heroCoversVillain: true) == 1) +} + +@Test func thresholdMultiplierIsOneWhenHeroDoesNotCoverVillain() { + #expect(BountyEquity.thresholdMultiplier(effectiveStackBB: 10, bountyBB: 20, heroCoversVillain: false) == 1) +} + +@Test func thresholdMultiplierMatchesThePotOverPotPlusBountyFormula() { + // 10bb effective -> pot approximated as 2x = 20bb. A 10bb bounty: 20 / (20 + 10) = 2/3. + let multiplier = BountyEquity.thresholdMultiplier(effectiveStackBB: 10, bountyBB: 10, heroCoversVillain: true) + #expect(abs(multiplier - (20.0 / 30.0)) < 0.0001) +} + +@Test func thresholdMultiplierShrinksAsBountyGrows() { + let small = BountyEquity.thresholdMultiplier(effectiveStackBB: 10, bountyBB: 5, heroCoversVillain: true) + let large = BountyEquity.thresholdMultiplier(effectiveStackBB: 10, bountyBB: 50, heroCoversVillain: true) + #expect(large < small) + #expect(small < 1) +} + +// MARK: - widenedPercentage + +@Test func widenedPercentageIsExactNoOpWithZeroBounty() { + for base: Double in [5, 22, 50, 90] { + let widened = BountyEquity.widenedPercentage(baseShovePercentage: base, effectiveStackBB: 10, bountyBB: 0, heroCoversVillain: true) + #expect(widened == base) + } +} + +@Test func widenedPercentageIsExactNoOpWhenHeroDoesNotCoverVillain() { + let widened = BountyEquity.widenedPercentage(baseShovePercentage: 22, effectiveStackBB: 10, bountyBB: 50, heroCoversVillain: false) + #expect(widened == 22) +} + +@Test func widenedPercentageIsMonotonicallyWiderAsBountyGrows() { + var last = BountyEquity.widenedPercentage(baseShovePercentage: 20, effectiveStackBB: 10, bountyBB: 0, heroCoversVillain: true) + for bounty: Double in [1, 5, 10, 20, 50, 100, 500] { + let widened = BountyEquity.widenedPercentage(baseShovePercentage: 20, effectiveStackBB: 10, bountyBB: bounty, heroCoversVillain: true) + #expect(widened >= last, "Widened percentage should never shrink as the bounty grows (bounty \(bounty))") + last = widened + } +} + +@Test func widenedPercentageClampsAtOneHundred() { + let widened = BountyEquity.widenedPercentage(baseShovePercentage: 90, effectiveStackBB: 5, bountyBB: 1000, heroCoversVillain: true) + #expect(widened == 100) +} + +@Test func widenedPercentageWidensMoreAtShorterStacksForTheSameFlatBounty() { + // The same 20bb bounty is a bigger fraction of a smaller approximated pot at a shorter + // stack, so the relative widening should be larger. + let shortStack = BountyEquity.widenedPercentage(baseShovePercentage: 20, effectiveStackBB: 5, bountyBB: 20, heroCoversVillain: true) + let deepStack = BountyEquity.widenedPercentage(baseShovePercentage: 20, effectiveStackBB: 20, bountyBB: 20, heroCoversVillain: true) + #expect(shortStack > deepStack) +} + +// MARK: - decide + +@Test func decideWithZeroBountyReproducesPushFoldRangeExactly() { + for position in Position.allCases { + for stack: Double in [1, 5, 10, 15, 20] { + for handString in ["AA", "72o", "A9s", "KQo", "T9s"] { + let hand = HoleCards(canonical: handString)! + let base = PushFoldRange.decide(hand: hand, position: position, effectiveStackBB: stack) + let withZeroBounty = BountyEquity.decide( + hand: hand, position: position, effectiveStackBB: stack, bountyBB: 0, heroCoversVillain: true + ) + #expect(withZeroBounty.action == base.action) + #expect(withZeroBounty.handScore == base.handScore) + #expect(withZeroBounty.adjustedScoreThreshold == base.scoreThreshold) + #expect(withZeroBounty.adjustedShovePercentage == base.shovePercentage) + #expect(withZeroBounty.bountyChangedTheRange == false) + } + } + } +} + +@Test func decideWithBountyButHeroNotCoveringReproducesBaseDecision() { + let hand = HoleCards(canonical: "A9s")! + let base = PushFoldRange.decide(hand: hand, position: .utg, effectiveStackBB: 10) + let notCovering = BountyEquity.decide( + hand: hand, position: .utg, effectiveStackBB: 10, bountyBB: 100, heroCoversVillain: false + ) + #expect(notCovering.action == base.action) + #expect(notCovering.adjustedShovePercentage == base.shovePercentage) + #expect(notCovering.bountyChangedTheRange == false) +} + +@Test func hugeBountyWidensEvenTheWorstHandIntoTheShoveRange() { + // 72o is the textbook worst hand — a large enough bounty should still be able to push + // its (clamped-at-100%) shove percentage all the way to "shove everything." + let trash = HoleCards(canonical: "72o")! + let decision = BountyEquity.decide( + hand: trash, position: .utg, effectiveStackBB: 20, bountyBB: 100_000, heroCoversVillain: true + ) + #expect(decision.adjustedShovePercentage == 100) + #expect(decision.action == .push) +} + +@Test func moderateBountyCanFlipABorderlineHandFromFoldToPush() { + // Find a hand that's a clear fold with no bounty at this spot but clears the (widened) + // threshold once a meaningful bounty is added. + let position = Position.utg + let stack = 15.0 + let borderlineHand = HoleCards(canonical: "A5s")! + + let withoutBounty = BountyEquity.decide( + hand: borderlineHand, position: position, effectiveStackBB: stack, bountyBB: 0, heroCoversVillain: true + ) + let withBounty = BountyEquity.decide( + hand: borderlineHand, position: position, effectiveStackBB: stack, bountyBB: 30, heroCoversVillain: true + ) + + #expect(withoutBounty.action == .fold, "Test setup assumption: this hand should be a clear fold with no bounty") + #expect(withBounty.action == .push, "A large-enough bounty should flip a borderline fold into a push") + #expect(withBounty.bountyChangedTheRange) +} + +@Test func decideNeverNarrowsTheRangeRelativeToBase() { + // Across a spread of spots, the bounty-adjusted percentage should never be less than + // the base percentage — the overlay only ever widens. + for position in Position.allCases { + for stack: Double in [1, 5, 10, 20] { + for bounty: Double in [0, 1, 10, 50, 200] { + let base = PushFoldRange.shovePercentage(position: position, effectiveStackBB: stack) + let decision = BountyEquity.decide( + hand: HoleCards(canonical: "T9s")!, position: position, effectiveStackBB: stack, + bountyBB: bounty, heroCoversVillain: true + ) + #expect(decision.adjustedShovePercentage >= base) + } + } + } +} + +// MARK: - reasoning + +@Test func reasoningMentionsNoBountyWhenBountyIsZero() { + let decision = BountyEquity.decide( + hand: HoleCards(canonical: "AA")!, position: .utg, effectiveStackBB: 10, bountyBB: 0, heroCoversVillain: true + ) + #expect(decision.reasoning.contains("No bounty entered")) +} + +@Test func reasoningMentionsNotCollectibleWhenHeroDoesNotCoverVillain() { + let decision = BountyEquity.decide( + hand: HoleCards(canonical: "AA")!, position: .utg, effectiveStackBB: 10, bountyBB: 50, heroCoversVillain: false + ) + #expect(decision.reasoning.contains("isn't collectible")) +} + +@Test func reasoningMentionsWideningWhenBountyApplies() { + let decision = BountyEquity.decide( + hand: HoleCards(canonical: "AA")!, position: .utg, effectiveStackBB: 10, bountyBB: 50, heroCoversVillain: true + ) + #expect(decision.reasoning.contains("bounty")) + #expect(decision.reasoning.contains("widens")) +} diff --git a/ai-docs/BOUNTY.md b/ai-docs/BOUNTY.md new file mode 100644 index 0000000..eb9931a --- /dev/null +++ b/ai-docs/BOUNTY.md @@ -0,0 +1,174 @@ +# PKO Bounty-Adjusted Shove Ranges + +Source: `PokerKit/Sources/PokerKit/BountyEquity.swift`. Tests: +`BountyEquityTests.swift`. Builds on `PushFoldRange.swift` (see +`RANGES.md`) without modifying it. + +## What it does + +In a PKO (progressive knockout) tournament, busting a covered opponent wins +their bounty on top of the pot. A pure chip-EV model like `PushFoldRange` +has no way to see that — it only ever reasons about chips — so it +systematically **under-shoves and under-calls** relative to what's actually +correct in a bounty tournament. `BountyEquity` is a widening overlay: given +a base chip-EV shove percentage, it returns a wider one that accounts for +the extra equity a collectible bounty adds. + +**This is a hand-tuned study aid, not solver output** — same posture as +every other model in this codebase, and for a sharper reason than most: +real PKO strategy is the net effect of *two competing forces* (see +"Scope and honest limitations" below), and this module only implements one +of them. + +## The formula — read this before trusting a specific number + +The standard treatment found across PKO strategy sources (see "Source +basis" below) folds a bounty into an all-in decision by adding its +chip-equivalent value to the pot on the winning side of a pot-odds +calculation: + +``` +requiredEquity = amountToCall / (potAfterCalling + bountyValueInChips) +``` + +Compare this to the no-bounty case, `requiredEquity = amountToCall / +potAfterCalling`. Dividing one by the other gives the **threshold +multiplier** this module actually computes: + +``` +thresholdMultiplier = pot / (pot + bounty) +``` + +Multiplying a required-equity threshold by this factor (always in `(0, 1]`) +lowers it — exactly the standard result that a bounty makes it correct to +get all-in with a weaker hand than chip-EV alone would justify. This +codebase doesn't compute literal win-probability equity anywhere (`ChenScore` +is a hand-strength *ranking*, not an equity model — see `RANGES.md`), so +`BountyEquity` doesn't apply this multiplier to a raw equity number. Instead +it applies the **reciprocal** to a **percentage of hands** — `PushFoldRange`'s +existing "shove the top X%" percentage — which is the quantity that actually +flows into this codebase's decision pipeline +(`PushFoldRange.scoreThreshold(forPercentage:)`). A smaller required-equity +threshold and a larger "percentage of hands that clear the bar" are the same +underlying fact stated two different ways; scaling the percentage by the +reciprocal of the equity multiplier is the natural translation of the +sourced formula into the vocabulary this codebase already uses everywhere +else, not a new formula. + +``` +widenedPercentage = min(basePercentage / thresholdMultiplier, 100) + = min(basePercentage × (pot + bounty) / pot, 100) +``` + +### `pot` is approximated as `2 × effectiveStackBB` + +The sourced formula needs a pot size, and nothing in this codebase currently +threads a real pot-size parameter through `PushFoldRange` (it only ever +takes position + effective stack — see `RANGES.md`). Rather than invent a +new required input, `BountyEquity` approximates the all-in pot as hero's +shove plus a covering call — `2 × effectiveStackBB` — and ignores +blinds/antes already in the middle. This is a standard simplification for +short-stack push/fold math: blinds/antes are a small fraction of the pot at +the stack depths `PushFoldRange` covers (1–20bb), and `PushFoldRange` itself +already abstracts them away. **Treat this as the model's least-precise +input** — a spot with unusually large antes relative to the stack (very late +in a tournament) will be slightly under-widened by this approximation. + +## Source basis + +- **The core formula** — `requiredEquity = amountToCall / (pot + bounty × + bountyPower)` — comes from GTO Wizard's "The Theory of Progressive + Knockout Tournaments" (`blog.gtowizard.com`), found via web search while + building this feature. The article works a full numeric example: a $50 + bounty converted through a "bounty power" figure of 0.191 (bb per dollar) + adds 9.55bb to the pot side of the calculation, dropping the required + equity to call from 43.6% to 32.4% in that specific spot. This module + implements the same algebraic shape (`pot` and `pot + bounty` on either + side of a ratio) with the bounty already expressed in bb — `bountyBB` is + this module's equivalent of that article's `bounty × bountyPower`. +- **The general "bounty widens ranges" qualitative fact** — corroborated + independently across multiple sources found via web search (Red Chip + Poker's "Quantifying the Value of the Bounty in Knockouts", + BeyondGTO's PKO strategy guide, bbzpoker's PKO tournament guide): all + describe converting a bounty to a chip-equivalent value and adding it to + what's won on a knockout, all agree the effect is a wider profitable + range early in a PKO. None of these sources gave a second, independently + checkable formula to cross-verify the GTO Wizard one against — this + module's precision is bounded by having one real source for the exact + math, not several agreeing ones (contrast with `RANGES.md`'s opening-range + 100bb anchor, which was cross-verified via two fetches of the same + source). + +## Scope and honest limitations + +- **Only applies when hero covers villain.** If hero doesn't cover villain, + winning the pot doesn't eliminate them — no bounty is collectible, so + `heroCoversVillain: false` is defined to produce *no adjustment at all*, + identical to the base `PushFoldRange` decision. This is enforced in code, + not just documented: `thresholdMultiplier`/`widenedPercentage` both + short-circuit to a no-op the moment `heroCoversVillain` is `false`. +- **Being covered is out of scope, on purpose.** The flip side of this + module — hero risking their own bounty, and the ICM-like risk of being + eliminated — is a *negative* adjustment (should *tighten* a range) that + this module makes no attempt to compute. GTO Wizard's own PKO material + (`blog.gtowizard.com/how-does-icm-impact-pko-strategy/`) frames real PKO + strategy as the net of exactly two competing forces: a bounty-equity + widening for the stack you can cover (what this module computes) and an + ICM-style tightening for the stack that could cover *you*. This module + implements the first and explicitly not the second — a real spot where + neither player fully covers the other needs both, and this module alone + will overstate how wide to play. +- **No ICM at all**, beyond the bullet above — same posture as every range + model in this codebase (`PushFoldRange`/`OpeningRange`/`CallingRange` are + all pure chip-EV; see `RANGES.md`). A bounty-and-ICM-aware model would + need real ICM math, which doesn't exist anywhere in `PokerKit` yet. +- **Assumes the whole bounty is realized on a win.** Real PKO bounties are + often split (e.g. "50% of the bounty pays out immediately, 50% rolls onto + the winner's own head" in some structures) — this module treats + `bountyBB` as the full amount hero collects, so a split-bounty structure + needs the caller to pass in only the immediately-collectible portion. +- **Ignores stack dynamics across a whole tournament** — bounty value + relative to the blinds changes as the tournament progresses (antes and + blinds grow, hero's own bounty grows if they've been knocking players + out); this module is a single-spot calculation, not a tournament-long + strategy. +- **The calling side isn't wired up yet.** `widenedPercentage` is written + generically (it takes any base percentage, not specifically + `PushFoldRange`'s), so it composes with a calling-range percentage the + same way it composes with a shoving one — but this codebase's calling + model (`CallingRange`) isn't on `main` yet (open in a separate PR). This + module's `decide(...)` convenience function only wires up the shove side + for now; extending it to calling once `CallingRange` lands is a follow-up, + not a redesign. + +## The pipeline + +1. `PushFoldRange.shovePercentage(position:effectiveStackBB:)` — the base + chip-EV percentage, unmodified, reused directly. +2. `BountyEquity.thresholdMultiplier(effectiveStackBB:bountyBB:heroCoversVillain:)` + — `pot / (pot + bounty)`, or `1` (no-op) if there's nothing to adjust for. +3. `BountyEquity.widenedPercentage(...)` — the base percentage divided by + that multiplier (i.e. multiplied by its reciprocal), clamped to 100. +4. `PushFoldRange.scoreThreshold(forPercentage:)` — reused directly, exactly + as `OpeningRange` and `CallingRange` already do; still the only + percentage → Chen-score-cutoff conversion in this codebase. +5. `BountyEquity.decide(hand:position:effectiveStackBB:bountyBB:heroCoversVillain:) -> + BountyAdjustedDecision` — shoves if `handScore >= adjustedThreshold`. + Returns a dedicated result type (not `PushFoldDecision`) specifically so + a bounty-adjusted decision can never be silently mistaken for a plain + chip-EV one — its `reasoning` text always states explicitly whether a + bounty was entered, whether it was collectible, and by how much it moved + the threshold. + +## Consumers + +- `PreflopRangeView`'s Push/Fold mode — an optional bounty overlay: a + toggle, a bounty-size control, and a "you cover villain" toggle. When + enabled, hands that only shove *because of* the bounty (fold in the base + model, push in the bounty-adjusted one) render in a third, distinct grid + color, so the widening is visible directly rather than just described in + a percentage. A persistent caveat line links back to this document. + +Nothing here mutates `PushFoldRange` — `bountyBB: 0` is a proven exact +no-op (`decideWithZeroBountyReproducesPushFoldRangeExactly`), so the plain +chip-EV tool is unaffected by this module existing. diff --git a/ai-docs/RANGES.md b/ai-docs/RANGES.md index 7a38429..3da4fda 100644 --- a/ai-docs/RANGES.md +++ b/ai-docs/RANGES.md @@ -14,6 +14,12 @@ Two range models live here, covering two different stack regimes of an MTT: Both are **hand-tuned study aids, not solver output** — see each section below for what "hand-tuned" means and where the numbers come from. +`PushFoldRange` also has an optional PKO **bounty-adjusted** overlay — +`BountyEquity` — that widens its shove percentage when hero covers a bountied +villain, without modifying `PushFoldRange` itself. See **[BOUNTY.md](BOUNTY.md)** +for the formula and its sources; not repeated here since it's a layer on top +of this document's models, not a third one. + ## Push/Fold An **unopened-pot, short-stack push/fold decision**: hero is first to act (or diff --git a/ai-docs/README.md b/ai-docs/README.md index 2212e93..b0bc71f 100644 --- a/ai-docs/README.md +++ b/ai-docs/README.md @@ -16,6 +16,7 @@ drop into whichever subsystem you're touching: | [LEAK-ANALYSIS.md](LEAK-ANALYSIS.md) | `LeakAnalysisEngine`: VPIP/PFR/limp, push-fold adherence, small-sample confidence gating | | [DRILLS.md](DRILLS.md) | How "Practice Your Leaks" derives a `DrillFocus` from a leak report and weights spots | | [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 | | [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 c78ca170cd3af08d643e7981bdf05bed70c34851 Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Mon, 20 Jul 2026 19:20:13 +0200 Subject: [PATCH 2/2] Wire PKO bounty overlay into the Push/Fold range view Push/Fold mode gets an optional bounty toggle, a bounty-size slider (bb), and a "you cover villain" toggle. Off by default so the base chip-EV grid is unchanged unless a user opts in. When active, hands that only shove because of the bounty (fold in the base model, push once BountyEquity is applied) render in a third grid color, so the widening is visible directly rather than only in the summary percentage. Base and bounty percentages are both computed empirically by counting the actual rendered grid cells (not read off the raw formula output) so the displayed numbers can never drift from what's colored on screen. A persistent caveat line points at ai-docs/BOUNTY.md, and swaps to an explicit "not collectible here" message when hero doesn't cover villain. --- app/Sources/PreflopRangeView.swift | 150 +++++++++++++++++++++++--- app/UITests/PreflopRangeUITests.swift | 39 +++++++ 2 files changed, 177 insertions(+), 12 deletions(-) diff --git a/app/Sources/PreflopRangeView.swift b/app/Sources/PreflopRangeView.swift index 18bd8ed..174a8ee 100644 --- a/app/Sources/PreflopRangeView.swift +++ b/app/Sources/PreflopRangeView.swift @@ -40,15 +40,48 @@ private enum RangeMode: String, CaseIterable, Identifiable { } } +/// A cell's visual role. `.bountyOnly` only ever appears in Push/Fold mode with the bounty +/// overlay on — it marks a hand that folds in the base chip-EV model but shoves once the +/// bounty is accounted for, so the widening is visible directly on the grid rather than +/// only in the summary percentage. +private enum CellStyle { + case aggressive + case bountyOnly + case fold + + var color: Color { + switch self { + case .aggressive: return .accentColor + case .bountyOnly: return .orange + case .fold: return Color(.secondarySystemBackground) + } + } + + var textColor: Color { + switch self { + case .aggressive, .bountyOnly: return .white + case .fold: return .secondary + } + } +} + struct PreflopRangeView: View { @State private var mode: RangeMode = .pushFold @State private var position: Position = .utg @State private var effectiveStackBB: Double = RangeMode.pushFold.defaultStack + // PKO bounty overlay — Push/Fold mode only (see BountyEquity/BOUNTY.md). Left off by + // default so the base chip-EV grid is what a user sees unless they opt in. + @State private var bountyEnabled = false + @State private var bountyBB: Double = 20 + @State private var heroCoversVillain = true + + private var isBountyActive: Bool { mode == .pushFold && bountyEnabled } + /// Whether each of the 169 grid cells is the "aggressive" action (shove, or open) for - /// the current mode/position/stack — the grid only needs this boolean to render, so - /// the two decision types (`PushFoldDecision`/`OpeningDecision`) never have to meet. - private var isAggressiveGrid: [[Bool]] { + /// the current mode/position/stack, ignoring any bounty overlay — the base chip-EV + /// grid `PushFoldRange`/`OpeningRange` would render on their own. + private var baseIsAggressiveGrid: [[Bool]] { switch mode { case .pushFold: return PreflopGrid.decisions(position: position, effectiveStackBB: effectiveStackBB) @@ -59,12 +92,48 @@ struct PreflopRangeView: View { } } - private var actionPercentage: Double { - let flat = isAggressiveGrid.flatMap { $0 } + /// Same shape, mapped through `BountyEquity` instead — `nil` unless the bounty overlay + /// is actually active, so callers never pay for it otherwise. + private var bountyIsAggressiveGrid: [[Bool]]? { + guard isBountyActive else { return nil } + return PreflopGrid.hands.map { row in + row.map { hand in + BountyEquity.decide( + hand: hand, position: position, effectiveStackBB: effectiveStackBB, + bountyBB: bountyBB, heroCoversVillain: heroCoversVillain + ).action == .push + } + } + } + + private var cellStyles: [[CellStyle]] { + guard let bountyGrid = bountyIsAggressiveGrid else { + return baseIsAggressiveGrid.map { row in row.map { $0 ? .aggressive : .fold } } + } + let baseGrid = baseIsAggressiveGrid + return baseGrid.indices.map { row in + baseGrid[row].indices.map { col in + guard bountyGrid[row][col] else { return .fold } + return baseGrid[row][col] ? .aggressive : .bountyOnly + } + } + } + + /// Percentage of the 169 cells actually colored "aggressive" in the given grid — kept + /// empirical (counted from the rendered grid) rather than read off + /// `PushFoldRange.shovePercentage`/`OpeningRange.openPercentage` directly, so the + /// summary text can never drift out of sync with what's on screen (Chen-score ties can + /// make the exact count cross a percentile boundary slightly differently than the + /// target percentage would suggest). + private func percentage(of grid: [[Bool]]) -> Double { + let flat = grid.flatMap { $0 } guard !flat.isEmpty else { return 0 } return Double(flat.filter { $0 }.count) / Double(flat.count) * 100 } + private var basePercentage: Double { percentage(of: baseIsAggressiveGrid) } + private var bountyPercentage: Double { bountyIsAggressiveGrid.map(percentage(of:)) ?? basePercentage } + var body: some View { ScrollView { VStack(spacing: 20) { @@ -72,6 +141,9 @@ struct PreflopRangeView: View { summary grid legend + if isBountyActive { + bountyCaveat + } } .padding() } @@ -114,16 +186,55 @@ struct PreflopRangeView: View { Slider(value: $effectiveStackBB, in: mode.stackRange, step: 1) .accessibilityIdentifier("stackSlider") } + + if mode == .pushFold { + bountyControls + } + } + } + + private var bountyControls: some View { + VStack(alignment: .leading, spacing: 12) { + Toggle("PKO Bounty", isOn: $bountyEnabled) + .accessibilityIdentifier("bountyToggle") + + if bountyEnabled { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Bounty") + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + Text("\(Int(bountyBB)) bb") + .font(.headline) + .monospacedDigit() + .accessibilityIdentifier("bountyValue") + } + Slider(value: $bountyBB, in: 0...100, step: 1) + .accessibilityIdentifier("bountySlider") + } + + Toggle("You cover villain", isOn: $heroCoversVillain) + .accessibilityIdentifier("heroCoversVillainToggle") + } } } private var summary: some View { - Text("\(String(format: "%.0f", actionPercentage))\(mode.summarySuffix)") - .font(.subheadline.bold()) - .foregroundStyle(.secondary) - .accessibilityIdentifier("shovePercentageText") + Group { + if isBountyActive { + Text("\(pct(basePercentage))\(mode.summarySuffix) — \(pct(bountyPercentage)) with bounty") + } else { + Text("\(pct(basePercentage))\(mode.summarySuffix)") + } + } + .font(.subheadline.bold()) + .foregroundStyle(.secondary) + .accessibilityIdentifier("shovePercentageText") } + private func pct(_ value: Double) -> String { String(format: "%.0f", value) } + private var grid: some View { VStack(spacing: 2) { ForEach(0.. some View { let notation = PreflopGrid.notation(row: row, col: col) - let isAggressive = isAggressiveGrid[row][col] + let style = cellStyles[row][col] return Text(notation) .font(.system(size: 10, weight: .semibold, design: .rounded)) .minimumScaleFactor(0.6) .lineLimit(1) .frame(maxWidth: .infinity, minHeight: 24) - .background(isAggressive ? Color.accentColor : Color(.secondarySystemBackground)) - .foregroundStyle(isAggressive ? Color.white : Color.secondary) + .background(style.color) + .foregroundStyle(style.textColor) .clipShape(RoundedRectangle(cornerRadius: 3)) .accessibilityIdentifier("cell-\(notation)") } @@ -154,6 +265,9 @@ struct PreflopRangeView: View { private var legend: some View { HStack(spacing: 16) { legendSwatch(color: .accentColor, label: mode.actionLabel) + if isBountyActive { + legendSwatch(color: .orange, label: "\(mode.actionLabel) (bounty only)") + } legendSwatch(color: Color(.secondarySystemBackground), label: "Fold") } .font(.caption) @@ -169,6 +283,18 @@ struct PreflopRangeView: View { Text(label) } } + + private var bountyCaveat: some View { + Text( + heroCoversVillain + ? "Study aid, not solver output. Chip-EV widened for a collectible bounty only — no ICM, no being-covered risk. See ai-docs/BOUNTY.md." + : "You don't cover villain here, so the bounty isn't collectible on this shove — this grid matches the base (no-bounty) range." + ) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .accessibilityIdentifier("bountyCaveatText") + } } #Preview { diff --git a/app/UITests/PreflopRangeUITests.swift b/app/UITests/PreflopRangeUITests.swift index 838fb41..11bc7d2 100644 --- a/app/UITests/PreflopRangeUITests.swift +++ b/app/UITests/PreflopRangeUITests.swift @@ -23,6 +23,45 @@ final class PreflopRangeUITests: XCTestCase { attachScreenshot(of: app, name: "grid-button") } + func testBountyOverlayWidensRangeAndTogglesCaveat() { + let app = XCUIApplication() + app.launch() + + app.staticTexts["Preflop Ranges"].tap() + + let aaCell = app.staticTexts["cell-AA"] + XCTAssertTrue(aaCell.waitForExistence(timeout: 5)) + + let summaryText = app.staticTexts["shovePercentageText"] + let baseSummary = summaryText.label + XCTAssertFalse(baseSummary.contains("with bounty")) + + app.switches["bountyToggle"].tap() + + let bountySlider = app.sliders["bountySlider"] + XCTAssertTrue(bountySlider.waitForExistence(timeout: 5)) + attachScreenshot(of: app, name: "bounty-off-default-size") + + // Drag the bounty slider toward its high end to get a meaningfully wide bounty. + bountySlider.adjust(toNormalizedSliderPosition: 0.9) + + XCTAssertTrue(summaryText.waitForExistence(timeout: 5)) + XCTAssertTrue(summaryText.label.contains("with bounty"), "Summary should show both the base and bounty-widened percentage") + + let caveat = app.staticTexts["bountyCaveatText"] + XCTAssertTrue(caveat.waitForExistence(timeout: 5)) + XCTAssertTrue(caveat.label.contains("BOUNTY.md")) + attachScreenshot(of: app, name: "bounty-on-covering-villain") + + // Flip "You cover villain" off — the bounty should stop being collectible, and the + // caveat text should say so explicitly instead of describing a widened range. + app.switches["heroCoversVillainToggle"].tap() + + XCTAssertTrue(caveat.waitForExistence(timeout: 5)) + XCTAssertTrue(caveat.label.contains("isn't collectible")) + attachScreenshot(of: app, name: "bounty-on-not-covering-villain") + } + private func attachScreenshot(of app: XCUIApplication, name: String) { let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = name