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 cc1e7e5..db8566f 100644 --- a/ai-docs/RANGES.md +++ b/ai-docs/RANGES.md @@ -24,6 +24,12 @@ defending models are meaningfully less certain than the two aggressor models — see "Facing a shove" and "Facing an open" below for exactly why, and which parts of each to trust least. +`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 diff --git a/app/Sources/PreflopRangeView.swift b/app/Sources/PreflopRangeView.swift index 8096e7f..52a019f 100644 --- a/app/Sources/PreflopRangeView.swift +++ b/app/Sources/PreflopRangeView.swift @@ -45,26 +45,30 @@ private enum RangeMode: String, CaseIterable, Identifiable { } } -/// A cell's visual role — collapses all four models' distinct action types (push/raise/ -/// call/3-bet/fold) down to what the grid actually needs to render. `.secondary` is only -/// ever produced by `.vsOpen` ("call" as distinct from "3-bet"); every other mode only -/// produces `.primary`/`.fold`. +/// A cell's visual role — collapses every mode's distinct action types (push/raise/call/ +/// 3-bet/fold), plus the Push/Fold bounty overlay, down to what the grid actually needs to +/// render. `.secondary` is only ever produced by `.vsOpen` ("call" as distinct from +/// "3-bet"); `.bountyOnly` is only ever produced by the Push/Fold bounty overlay (a hand +/// that folds in the base chip-EV model but shoves once the bounty is accounted for) — the +/// two never appear together, since the bounty overlay only exists in Push/Fold mode. private enum CellStyle { case primary case secondary + case bountyOnly case fold var color: Color { switch self { case .primary: return .accentColor case .secondary: return .teal + case .bountyOnly: return .orange case .fold: return Color(.secondarySystemBackground) } } var textColor: Color { switch self { - case .primary, .secondary: return .white + case .primary, .secondary, .bountyOnly: return .white case .fold: return .secondary } } @@ -77,6 +81,14 @@ struct PreflopRangeView: View { @State private var heroPosition: DefendingPosition = .bigBlind @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 } + /// `DefendingPosition`s that could plausibly be facing `opponentPosition` — anyone who /// acts after them at an unopened table. The big blind is always valid, so it's a safe /// fallback default no matter what `opponentPosition` is. @@ -84,11 +96,42 @@ struct PreflopRangeView: View { DefendingPosition.allCases.filter { $0.actionOrderIndex > opponentPosition.actionOrderIndex } } + /// Whether each of the 169 grid cells is `PushFoldRange`'s own push/fold action — + /// ignoring any bounty overlay. Only actually used in Push/Fold mode, but cheap enough + /// (169 cells) that computing it unconditionally isn't worth guarding. + private var baseIsAggressiveGrid: [[Bool]] { + PreflopGrid.decisions(position: position, effectiveStackBB: effectiveStackBB) + .map { row in row.map { $0.action == .push } } + } + + /// 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]] { + if let bountyGrid = bountyIsAggressiveGrid { + 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] ? .primary : .bountyOnly + } + } + } + switch mode { case .pushFold: - return PreflopGrid.decisions(position: position, effectiveStackBB: effectiveStackBB) - .map { row in row.map { $0.action == .push ? .primary : .fold } } + return baseIsAggressiveGrid.map { row in row.map { $0 ? .primary : .fold } } case .opening: return PreflopGrid.openingDecisions(position: position, effectiveStackBB: effectiveStackBB) .map { row in row.map { $0.action == .raise ? .primary : .fold } } @@ -121,7 +164,24 @@ struct PreflopRangeView: View { Array(repeating: Array(repeating: .fold, count: PreflopGrid.ranks.count), count: PreflopGrid.ranks.count) } + /// Percentage of the 169 cells actually flagged `true` in the given grid — kept + /// empirical (counted from the same grid that drives rendering) rather than read off a + /// model's raw percentage output 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 a 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 summaryText: String { + if isBountyActive { + let base = percentage(of: baseIsAggressiveGrid) + let bounty = bountyIsAggressiveGrid.map(percentage(of:)) ?? base + return "\(pct(base))% of hands to shove — \(pct(bounty))% with bounty" + } + let flat = cellStyles.flatMap { $0 } guard !flat.isEmpty else { return "" } let total = Double(flat.count) @@ -129,17 +189,19 @@ struct PreflopRangeView: View { let defendPct = Double(defendCount) / total * 100 switch mode { - case .pushFold: return "\(String(format: "%.0f", defendPct))% of hands to shove" - case .opening: return "\(String(format: "%.0f", defendPct))% of hands to open" - case .vsShove: return "\(String(format: "%.0f", defendPct))% of hands to call" + case .pushFold: return "\(pct(defendPct))% of hands to shove" + case .opening: return "\(pct(defendPct))% of hands to open" + case .vsShove: return "\(pct(defendPct))% of hands to call" case .vsOpen: let threeBetCount = flat.filter { $0 == .primary }.count let threeBetPct = Double(threeBetCount) / total * 100 - return "\(String(format: "%.0f", defendPct))% of hands to defend " - + "(\(String(format: "%.0f", threeBetPct))% 3-bet)" + return "\(pct(defendPct))% of hands to defend " + + "(\(pct(threeBetPct))% 3-bet)" } } + private func pct(_ value: Double) -> String { String(format: "%.0f", value) } + var body: some View { ScrollView { VStack(spacing: 20) { @@ -149,6 +211,8 @@ struct PreflopRangeView: View { legend if mode.isDefending { defenseCaveat + } else if isBountyActive { + bountyCaveat } } .padding() @@ -196,6 +260,37 @@ 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") + } } } @@ -273,6 +368,9 @@ struct PreflopRangeView: View { ForEach(mode.legendEntries, id: \.label) { entry in legendSwatch(color: entry.color, label: entry.label) } + if isBountyActive { + legendSwatch(color: .orange, label: "Shove (bounty only)") + } legendSwatch(color: Color(.secondarySystemBackground), label: "Fold") } .font(.caption) @@ -300,6 +398,18 @@ struct PreflopRangeView: View { .multilineTextAlignment(.center) .accessibilityIdentifier("defenseCaveatText") } + + 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 7c14e15..49718c6 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") + } + func testFacingShoveModeRendersAndReactsToOpponentChange() { let app = XCUIApplication() app.launch()