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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions PokerKit/Sources/PokerKit/BountyEquity.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
175 changes: 175 additions & 0 deletions PokerKit/Tests/PokerKitTests/BountyEquityTests.swift
Original file line number Diff line number Diff line change
@@ -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"))
}
Loading
Loading