Advertisement

← poker-tutor

Sources/PokerCoach/DecisionEvaluator.swift

50.7 KB Download file
import Foundation
import PokerCore
import PokerAI

public enum Severity: Int, Comparable, Sendable, CaseIterable {
    case fine = 0
    case slight          // a fraction of a big blind; not worth mentioning on its own
    case minor
    case moderate
    case major

    public static func < (a: Severity, b: Severity) -> Bool { a.rawValue < b.rawValue }

    public var name: String {
        switch self {
        case .fine: return "fine"
        case .slight: return "a shade off"
        case .minor: return "a small mistake"
        case .moderate: return "a real mistake"
        case .major: return "a big mistake"
        }
    }

    /// Below `.minor`, the coach keeps quiet.
    public var worthMentioning: Bool { self >= .minor }

    static func from(costInBB: Double) -> Severity {
        switch costInBB {
        case ..<0.10: return .fine
        case ..<0.40: return .slight
        case ..<1.20: return .minor
        case ..<3.50: return .moderate
        default: return .major
        }
    }
}

/// The situation a decision was made in. Leaks are defined as errors that keep recurring in the
/// same kind of spot, so this is what makes the leak profile possible.
public enum SpotKind: String, Sendable, CaseIterable, Codable {
    case openingChance          // nobody in yet, hero can raise
    case facingRaise            // one raise in front
    case facingReraise
    case bigBlindDefence
    case shortStackShove        // twelve big blinds or fewer
    case continuationBet        // hero raised preflop, first to act on the flop
    case facingContinuationBet
    case flopDecision
    case turnDecision
    case riverDecision
    case facingRiverBet
    /// Cash tables only: the next chips are most of a hundred-blind stack — facing a bet that
    /// commits it, or choosing to put it in — on the flop or the turn. See `CoachFormat`.
    case deepStackOff
    /// Fixed-limit tables only: last to act on the flop with a real draw and a bet to answer —
    /// the spot the free-card raise exists for. Its own situation so the habit is counted over
    /// the times it could happen rather than over every flop bet. See `CoachFormat.limit`.
    case freeCardChance

    /// The situations a game actually produces. A tournament never reaches the deep stack-off
    /// spot and a cash table never reaches the short-stack one, so the assessment's list of
    /// "situations it never got near" is drawn from this rather than from every case.
    public static var measured: [SpotKind] { measured(for: LeakCatalogue.format) }

    public static func measured(for format: CoachFormat) -> [SpotKind] {
        switch format {
        case .tournament: return allCases.filter { $0 != .deepStackOff && $0 != .freeCardChance }
        case .cash: return allCases.filter { $0 != .shortStackShove && $0 != .freeCardChance }
        // Fixed-limit never puts a whole stack in on one street either: a bet is a fraction of
        // the pot by rule.
        case .limit: return allCases.filter { $0 != .shortStackShove && $0 != .deepStackOff }
        }
    }

    /// Read back off a saved hand.
    ///
    /// A written-down hand keeps the printed name rather than the case, so that is what has to be
    /// matched first; the case is tried as well because it costs nothing and a file written by some
    /// future version may well keep that instead. A name nobody recognises any more returns nil and
    /// the hand simply stops contributing to the scorecard, which is the honest outcome — better
    /// than filing an old decision under whichever leak happens to sit closest.
    public static func recorded(_ text: String) -> SpotKind? {
        allCases.first { $0.name == text } ?? SpotKind(rawValue: text)
    }

    public var name: String {
        switch self {
        case .openingChance: return "opening the pot"
        case .facingRaise: return "facing a raise"
        case .facingReraise: return "facing a re-raise"
        case .bigBlindDefence: return "defending the big blind"
        case .shortStackShove: return "playing a short stack"
        case .continuationBet: return "betting the flop after raising"
        case .facingContinuationBet: return "facing a flop bet"
        case .flopDecision: return "playing the flop"
        case .turnDecision: return "playing the turn"
        case .riverDecision: return "playing the river"
        case .facingRiverBet: return "facing a river bet"
        case .deepStackOff: return "playing for a whole stack"
        case .freeCardChance: return "last to act with cards to come"
        }
    }
}

/// Which way a decision went wrong. A leak is a spot plus a direction.
public enum ErrorKind: String, Sendable, CaseIterable, Codable {
    case foldedTooMuch
    case calledTooWide
    case playedTooManyHands
    case missedValue          // checked or called with a hand worth betting or raising
    case bluffedTooMuch
    case limpedInstead        // called when raising was right
    case passedUpSteal        // folded a hand worth opening
    case wrongSize
    case shovedTooWide
    case failedToShove
    // Cash tables only — see `CoachFormat`.
    case stackedOffOnePair      // put most of a hundred-blind stack in with one pair
    case setMinedWithoutOdds    // called a re-raise with a small pair the stacks do not pay for
    case missedImpliedOdds      // folded a small pair or connected cards the stacks paid to see
    // Fixed-limit tables only — see `CoachFormat.limit`.
    case cappedLight            // put in the third or fourth bet before the flop with a hand that is not the best
    case missedFreeCard         // called a flop bet with a big draw, last to act, instead of raising for the free card

    /// Names these went into the history under before they were reworded.
    ///
    /// The name is what gets written down, so rewording one is a change to the file format
    /// whether or not it was meant as one: a saved hand keeps whatever spelling it was written
    /// with, and a hand whose error no longer resolves stops counting towards anything without
    /// an error message anywhere. So the old spellings go on reading for ever.
    private static let formerNames: [String: ErrorKind] = [
        "calling too wide": .calledTooWide,
        "moving in too wide": .shovedTooWide,
    ]

    /// Read back off a saved hand — see `SpotKind.recorded`.
    public static func recorded(_ text: String) -> ErrorKind? {
        guard !text.isEmpty else { return nil }
        return allCases.first { $0.name == text } ?? formerNames[text] ?? ErrorKind(rawValue: text)
    }

    public var name: String {
        switch self {
        case .foldedTooMuch: return "folding too much"
        case .calledTooWide: return "calling with too many hands"
        case .playedTooManyHands: return "playing too many hands"
        case .missedValue: return "missing value"
        case .bluffedTooMuch: return "bluffing too much"
        case .limpedInstead: return "limping instead of raising"
        case .passedUpSteal: return "passing up a steal"
        case .wrongSize: return "betting the wrong amount"
        case .shovedTooWide: return "moving in with too many hands"
        case .failedToShove: return "not moving in when short"
        case .stackedOffOnePair: return "risking a whole stack with one pair"
        case .setMinedWithoutOdds: return "calling to hit a set without the money behind for it"
        case .missedImpliedOdds: return "folding a hand the money behind paid to see"
        case .cappedLight: return "raising again with a hand that is not the best"
        case .missedFreeCard: return "calling with a big draw instead of raising for the free card"
        }
    }
}

/// The coach's read on one decision.
public struct Verdict: Sendable {
    public let street: Street
    public let spot: SpotKind
    public let holeCards: [Card]
    public let board: [Card]
    public let taken: PlayerAction
    public let best: PlayerAction
    /// What the mistake cost, in big blinds. Zero when the decision was right.
    public let costInBB: Double
    public let severity: Severity
    public let error: ErrorKind?
    /// How sure the coach is. Below the threshold it says nothing at all.
    public let confidence: Double
    public let headline: String
    public let detail: String

    public init(street: Street, spot: SpotKind, holeCards: [Card], board: [Card], taken: PlayerAction,
                best: PlayerAction, costInBB: Double, severity: Severity, error: ErrorKind?,
                confidence: Double, headline: String, detail: String) {
        self.street = street
        self.spot = spot
        self.holeCards = holeCards
        self.board = board
        self.taken = taken
        self.best = best
        self.costInBB = costInBB
        self.severity = severity
        self.error = error
        self.confidence = confidence
        self.headline = headline
        self.detail = detail
    }

    public var isMistake: Bool { error != nil && severity.worthMentioning }
    public var shouldSpeak: Bool { isMistake && confidence >= DecisionEvaluator.confidenceThreshold }

    /// Whether this decision counts as one instance of a habit on the scorecard.
    ///
    /// A lower bar than the one for speaking, on purpose: opening too many hands costs a third of a
    /// big blind at a time, which is never worth stopping somebody over and is ruinous forty times
    /// an hour. The same rule is written down a second time on `VerdictRecord` because that side
    /// reads it back off disk, where an older hand may carry no confidence at all. Change one and
    /// change the other — a test holds the two to the same answer.
    public var countsTowardsLeak: Bool {
        error != nil && severity >= .slight && confidence >= DecisionEvaluator.confidenceThreshold
    }
}

/// Judges one decision against what a competent player would have done.
///
/// Three layers, in descending order of how sure it can be. Before the flop it compares against
/// reference ranges, which is close to unimpeachable and is where most amateur money leaks out.
/// After the flop it prices each available action against the opponent's likely holdings. On top
/// of both sits a confidence check, and anything the coach is not sure about, it does not say.
public enum DecisionEvaluator {

    /// A verdict below this confidence is never shown. A coach that flags close spots as errors
    /// teaches the wrong lesson and, worse, stops being believed — so the bar is deliberately set
    /// where it catches most real mistakes and almost no imaginary ones.
    public static let confidenceThreshold = 0.62

    /// - Parameter format: which game this is. A tournament judges a short stack as a push-or-fold
    ///   spot; a cash table never does, and judges the money behind instead — see `CoachFormat`.
    public static func evaluate(_ view: DecisionView, took action: PlayerAction, seed: UInt64 = 1,
                                format: CoachFormat = .tournament) -> Verdict {
        var rng = SeededRandom(seed: seed)
        if view.street == .preflop { return evaluatePreflop(view, took: action, rng: &rng, format: format) }
        if format == .limit { return evaluateLimitPostflop(view, took: action, rng: &rng) }
        return evaluatePostflop(view, took: action, rng: &rng, format: format)
    }

    // MARK: - Before the flop

    static func evaluatePreflop(_ view: DecisionView, took action: PlayerAction, rng: inout SeededRandom,
                                format: CoachFormat = .tournament) -> Verdict {
        let hand = HandClass(view.holeCards)
        let percentile = PreflopTable.percentile(hand)
        let bb = Double(view.bigBlind)
        let players = view.playersDealtIn
        let position = view.position
        let raises = view.preflopRaiseCount
        let short = format == .tournament && view.effectiveStackInBB <= ReferenceRanges.pushFoldDepth

        let spot: SpotKind
        if short { spot = .shortStackShove }
        else if raises == 0 { spot = .openingChance }
        else if raises == 1 { spot = position == .bigBlind ? .bigBlindDefence : .facingRaise }
        else { spot = .facingReraise }

        // What a competent player does here.
        var best: PlayerAction
        var error: ErrorKind?
        var cost = 0.0
        var reason = ""

        if short {
            let range = ReferenceRanges.shove(depth: view.effectiveStackInBB, position: position,
                                              players: players, facingRaise: raises > 0)
            let shouldShove = percentile <= range && view.legal.canBetOrRaise
            best = shouldShove
                ? (view.legal.canBet ? .bet(to: view.legal.maxRaiseTo) : .raise(to: view.legal.maxRaiseTo))
                : (view.legal.canCheck ? .check : .fold)
            reason = "At \(bbText(view.effectiveStackInBB)) big blinds there is no room to raise and fold — the stack goes in or it does not."

            if shouldShove, isPassive(action) {
                error = .failedToShove
                // The lost value is roughly what is already in the middle, which a shove wins
                // outright a good share of the time.
                cost = Double(view.pot) / bb * 0.45
            } else if !shouldShove, isAggressive(action) {
                error = .shovedTooWide
                cost = view.effectiveStackInBB * 0.14
            }
        } else if raises == 0 {
            let range = ReferenceRanges.open(position: position, players: players, format: format)
            let shouldOpen = percentile <= range
            // Fixed-limit has one raise size; everywhere else the coach recommends a size.
            let size = format == .limit ? view.legal.minRaiseTo
                : ReferenceRanges.openSize(bigBlind: view.bigBlind, limpers: limpers(in: view), players: players)
            let limpersIn = limpers(in: view)
            // At a limit table a call behind callers with a hand that plays is ordinary poker —
            // the pot is bigger, the price is better, and a raise would not thin the field. What
            // the limit coach still names is the open limp: first in, matching the blind.
            let limpingBehindIsFine = format == .limit && limpersIn > 0
            best = shouldOpen && view.legal.canBetOrRaise && !(limpingBehindIsFine && action == .call)
                ? (view.legal.canBet ? .bet(to: size) : .raise(to: size))
                : (view.legal.canCheck ? .check : (limpingBehindIsFine && shouldOpen && view.legal.canCall ? .call : .fold))
            // The seat the player is in is named in full by the explanation this sentence lands
            // inside, so here it is the number on its own — twice in a paragraph is a mouthful.
            reason = "From \(position.seatPhrase(inTableOf: players)) a competent player raises with about \(percentText(range)) of hands when nobody has come in yet. \(hand) is in the top \(percentText(percentile))."

            if shouldOpen, action == .call, view.legal.canCall, !limpingBehindIsFine {
                error = .limpedInstead
                // Limping keeps you in the hand but throws away the chance to win it now.
                cost = max(0.15, openEV(view: view, rng: &rng, format: format) / bb * 0.5)
            } else if shouldOpen, action == .fold {
                error = .passedUpSteal
                cost = max(0, openEV(view: view, rng: &rng, format: format)) / bb
            } else if !shouldOpen, isAggressive(action) || action == .call {
                error = .playedTooManyHands
                cost = max(0.2, -openEV(view: view, rng: &rng, format: format) / bb)
            }
        } else {
            // Facing a raise or a re-raise.
            let opener = openerPosition(in: view) ?? .cutoff
            let raiseRange = raises == 1
                ? ReferenceRanges.threeBet(from: position, versus: opener, players: players, format: format)
                : ReferenceRanges.fourBet(from: position, players: players, format: format)
            let callRange = raises == 1
                ? ReferenceRanges.callVersusOpen(from: position, versus: opener, players: players, format: format)
                : ReferenceRanges.callVersusThreeBet(from: position, players: players, format: format)

            let shouldRaise = percentile <= raiseRange && view.legal.canRaise
            let rangeContinue = percentile <= max(raiseRange, callRange)
            // A cash table adds the question the chart cannot answer: does the money behind pay for
            // a hand that is behind now and wins big when it hits? Nil for every ordinary hand.
            let implied = format == .cash ? impliedOdds(hand: hand, view: view) : nil
            let shouldContinue = rangeContinue || implied == .worthIt

            if shouldRaise {
                let target = Double(view.legal.callCost + view.pot) * 0.85 + Double(view.heroSeat.committedThisRound)
                best = .raise(to: format == .limit ? view.legal.minRaiseTo
                              : ChipRounding.round(Int(target), bigBlind: view.bigBlind,
                                                   minimum: view.legal.minRaiseTo, maximum: view.legal.maxRaiseTo))
            } else if shouldContinue && view.legal.canCall {
                best = .call
            } else {
                best = view.legal.canCheck ? .check : .fold
            }

            // "An open" is the trade's noun for a raise nobody had raised in front of, and it is
            // one of the words this app does without: a beginner reads it as an opening they can
            // walk through. These two sentences are interpolated straight into what the player
            // reads about the hand they just played, so they say "a raise".
            if spot == .bigBlindDefence {
                reason = "The big blind is getting \(oddsText(view)) to call, so it continues with about \(percentText(callRange)) of hands against a raise from \(opener.described(inTableOf: players))."
            } else {
                reason = "Against a raise from \(opener.described(inTableOf: players)), a competent player continues with about \(percentText(max(raiseRange, callRange))) of hands from \(position.seatPhrase(inTableOf: players))."
            }

            // What continuing is actually worth, against the hands the raiser would have. This
            // replaced a formula that produced plausible-looking small numbers and, as a result,
            // graded every big-blind overfold as too trivial to mention — which is how the most
            // expensive habit in amateur poker went undetected.
            let continueValue = continueEV(view: view, rng: &rng, format: format)
            if shouldContinue, action == .fold {
                if !rangeContinue, implied == .worthIt {
                    // The chart said fold; the stacks said call. Folding here gives up the cheapest
                    // way there is to win a big pot, and the write-up says why in those words.
                    error = .missedImpliedOdds
                    cost = max(0.2, impliedValue(hand: hand, view: view) / bb)
                    reason = impliedReason(hand: hand, view: view)
                } else {
                    error = .foldedTooMuch
                    cost = max(0, continueValue) / bb
                }
            } else if format == .limit, raises >= 2, !shouldRaise, isAggressive(action) {
                // The third or fourth bet before the flop with a hand that is not the best there
                // is: at limit nobody folds to it, so it buys nothing and pays two bets to be
                // second best. Priced as the extra bets put in behind.
                error = .cappedLight
                cost = max(0.5, (Double(view.legal.minRaiseTo - view.heroSeat.committedThisRound) / bb) * (1 - continueEquity(view: view, rng: &rng)) * 1.5)
                reason = "Against a re-raise from \(opener.described(inTableOf: players)), a competent limit player puts in another bet with only the top \(percentText(raiseRange)) of hands, because nobody folds to it and every extra bet goes in behind."
            } else if !shouldContinue, action == .call || isAggressive(action) {
                if implied == .notWorthIt, hand.isPair {
                    // Calling to hit a set with too little behind to be paid for it.
                    error = .setMinedWithoutOdds
                    cost = max(0.2, -impliedValue(hand: hand, view: view) / bb)
                    reason = impliedReason(hand: hand, view: view)
                } else {
                    error = .calledTooWide
                    cost = max(0, -continueValue) / bb
                }
            }
        }

        if format.isCash {
            cost = min(cost, action == .fold ? Self.mostOnePreflopFoldCosts : Self.mostOneDecisionCosts)
        }
        let severity = Severity.from(costInBB: cost)
        // Preflop range comparisons are the firmest ground the coach has, but a hand sitting right
        // on the edge of a range is a judgement call and is treated as one.
        let margin = abs(percentile - marginReference(view: view, spot: spot, players: players, format: format))
        let confidence = (0.55 + margin * 3.0).clamped(to: 0...0.97)

        let text = explainPreflop(hand: hand, spot: spot, error: error, view: view, reason: reason, cost: cost)
        return Verdict(street: .preflop, spot: spot, holeCards: view.holeCards, board: view.board,
                       taken: action, best: best, costInBB: error == nil ? 0 : cost,
                       severity: error == nil ? .fine : severity, error: error,
                       confidence: confidence, headline: text.0, detail: text.1)
    }

    // MARK: - After the flop

    static func evaluatePostflop(_ view: DecisionView, took action: PlayerAction, rng: inout SeededRandom,
                                 format: CoachFormat = .tournament) -> Verdict {
        let bb = Double(view.bigBlind)
        let read = HandReader.assess(hole: view.holeCards, board: view.board, opponents: view.opponentCount)
        let range = RangeReader.threatRange(in: view)
        let equity = RangeEquity.equity(hole: view.holeCards, board: view.board, versus: range, rng: &rng)

        // A hundred big blinds deep, an action that puts most of a stack in is answered by a
        // different set of hands from the one that answers an ordinary bet: the ones prepared to
        // play for everything, which are the strongest part of what they had. Pricing a stack-off
        // against the whole range is what let one pair look like a hand worth a hundred blinds.
        let deep = format == .cash && view.effectiveStackInBB >= Self.deepStackInBB && view.street != .river
        let stackOffEquity = deep
            ? RangeEquity.equity(hole: view.holeCards, board: view.board,
                                 versus: range.strongest(Self.stackOffShare, board: view.board, countingDraws: true), rng: &rng)
            : equity

        // The situation is "playing for a whole stack" when the next chips are most of one: the
        // player is facing a bet that commits the stack, or chose to put it in. Every such
        // decision is a chance at the stack-off habit, whatever the player held.
        let stackSpot = deep && (commits(action, in: view) || (view.toCall > 0 && commits(.call, in: view)))
        var spot = stackSpot ? SpotKind.deepStackOff : postflopSpot(view)
        // A cash table prices sizes the tray can make. The tournament keeps pricing the exact
        // fractions its bars were swept on and rounds only what it recommends (below), so that
        // nothing a tournament player is charged moves while the advice becomes makeable.
        let candidates = candidateActions(view, read: read, equity: equity, roundToChips: format.isCash)
        var values: [(PlayerAction, Double)] = []
        for candidate in candidates {
            values.append((candidate, expectedValue(of: candidate, view: view, equity: equity, read: read,
                                                    committedEquity: stackOffEquity)))
        }
        guard let bestPair = values.max(by: { $0.1 < $1.1 }) else {
            return neutral(view: view, action: action, spot: spot)
        }
        let takenValue = values.first { same($0.0, action) }?.1
            ?? expectedValue(of: action, view: view, equity: equity, read: read, committedEquity: stackOffEquity)

        let costChips = max(0, bestPair.1 - takenValue)
        var cost = costChips / bb
        var error: ErrorKind? = nil

        if cost > 0 {
            error = classify(taken: action, best: bestPair.0, view: view, read: read, equity: equity,
                             deep: deep)
        }
        if error == nil { cost = 0 }
        if format.isCash { cost = min(cost, Self.mostOneDecisionCosts) }
        // "Playing for a whole stack" has one habit written under it. Any other mistake made
        // there — folding a set to a shove, calling one off with nothing — is the ordinary flop or
        // turn mistake it always was, and is filed where that habit can see it. Filing it under
        // the new situation made those decisions invisible to every habit at once.
        if spot == .deepStackOff, error != nil, error != .stackedOffOnePair { spot = postflopSpot(view) }

        // Sensitivity: redo the judgement against a tighter and a looser read of the opponent. If
        // the recommendation flips, the answer was an artefact of a guess about their range and
        // the coach has no business asserting it.
        let confidence = sensitivity(view: view, read: read, best: bestPair.0, deep: deep,
                                     roundToChips: format.isCash, action: action, rng: &rng)

        let severity = Severity.from(costInBB: cost)
        let best = roundedToChips(bestPair.0, in: view)
        let text = explainPostflop(view: view, read: read, equity: equity, taken: action,
                                   best: best, error: error, cost: cost)
        return Verdict(street: view.street, spot: spot, holeCards: view.holeCards, board: view.board,
                       taken: action, best: best, costInBB: error == nil ? 0 : cost,
                       severity: error == nil ? .fine : severity, error: error,
                       confidence: confidence, headline: text.0, detail: text.1)
    }

    /// The actions worth weighing up: fold, the passive one, and a few realistic sizes.
    ///
    /// Betting is only ever offered as an alternative when the hand has something behind it —
    /// either it is likely best, or it has a real draw. Without that guard the model happily
    /// recommends raising with no equity at all, because folding out the pot is worth more than
    /// zero, and the coach ends up telling an improving player to bluff more. That is the single
    /// worst piece of advice this app could give, and it would be delivered with a straight face
    /// and a confidence percentage attached.
    /// An amount the chips on the table can make, for a recommendation somebody has to count out.
    static func roundedToChips(_ action: PlayerAction, in view: DecisionView) -> PlayerAction {
        let legal = view.legal
        switch action {
        case .bet(let to):
            return .bet(to: ChipRounding.round(to, bigBlind: view.bigBlind, minimum: legal.minRaiseTo, maximum: legal.maxRaiseTo))
        case .raise(let to):
            return .raise(to: ChipRounding.round(to, bigBlind: view.bigBlind, minimum: legal.minRaiseTo, maximum: legal.maxRaiseTo))
        default:
            return action
        }
    }

    static func candidateActions(_ view: DecisionView, read: HandAssessment, equity: Double,
                                 roundToChips: Bool = false) -> [PlayerAction] {
        let legal = view.legal
        var out: [PlayerAction] = []
        if legal.canCheck { out.append(.check) } else { out.append(.fold); if legal.canCall { out.append(.call) } }
        // Judged on equity against the hands this opponent would actually have, not on how good
        // the hand looks in the abstract. Trips is a monster on one board and third best on
        // another, and using the abstract number here produced verdicts that contradicted their
        // own arithmetic: "you had the best of it" above a line reading nineteen percent.
        let worthBetting = equity >= 0.50 || read.drawEquity >= 0.22
        if legal.canBetOrRaise && worthBetting {
            // Sizes a player would actually use. The coach never recommends an amount it could
            // not explain to someone in one sentence.
            for fraction in [0.33, 0.66, 1.0] {
                let target = legal.canBet
                    ? Int(Double(view.pot) * fraction)
                    : view.heroSeat.committedThisRound + view.toCall + Int(Double(view.pot + view.toCall) * fraction)
                // Rounded to the chips on the table where asked, because a size the tray cannot
                // count out is not advice anybody can take.
                let clamped = roundToChips
                    ? ChipRounding.round(target, bigBlind: view.bigBlind, minimum: legal.minRaiseTo, maximum: legal.maxRaiseTo)
                    : target.clamped(to: legal.minRaiseTo...legal.maxRaiseTo)
                let candidate: PlayerAction = legal.canBet ? .bet(to: clamped) : .raise(to: clamped)
                if !out.contains(where: { same($0, candidate) }) { out.append(candidate) }
            }
        }
        return out
    }

    /// Chips this action is worth on average, measured from here.
    /// - Parameter committedEquity: the hand's share against the hands that play for a stack, used
    ///   in place of `equity` for an action that puts most of the effective stack in. Defaults to
    ///   `equity`, which is what a tournament table and every shallow spot get.
    static func expectedValue(of action: PlayerAction, view: DecisionView, equity plainEquity: Double,
                              read: HandAssessment, committedEquity: Double? = nil) -> Double {
        let pot = Double(view.pot)
        let toCall = Double(view.toCall)
        let equity = commits(action, in: view) ? (committedEquity ?? plainEquity) : plainEquity

        switch action {
        case .fold:
            return 0
        case .check:
            // Checking keeps whatever share of the pot the hand is worth. Valuing it at less than
            // that makes betting look better than it is in every single spot, which turns the
            // coach into something that tells the player to bet more, always — the exact advice
            // that ruins improving players.
            return equity * pot
        case .call:
            return equity * (pot + toCall) - toCall
        case .bet(let to), .raise(let to):
            let size = Double(to - view.heroSeat.committedThisRound) - toCall
            guard size > 0 else { return equity * (pot + toCall) - toCall }
            let folds = foldEquity(betSize: size, pot: pot + toCall)
            // The hands that call are the stronger part of the range — and the bigger the bet, the
            // stronger that part is. Treating this as a fixed penalty makes every larger bet look
            // better than every smaller one, so the model recommends shoving with any decent hand
            // and prices the "mistake" of not doing so at twenty big blinds.
            let ratio = size / max(1, pot + toCall)
            let equityWhenCalled = max(0, equity - 0.10 - 0.26 * min(ratio, 2.0))
            let called = equityWhenCalled * (pot + toCall + 2 * size) - (size + toCall)
            return folds * (pot + toCall) + (1 - folds) * called
        }
    }

    /// How often a bet of this size takes the pot down immediately.
    static func foldEquity(betSize: Double, pot: Double) -> Double {
        guard pot > 0 else { return 0.4 }
        let ratio = betSize / pot
        return (0.30 + 0.28 * ratio).clamped(to: 0.15...0.68)
    }

    /// Re-judges against a tighter and a looser read of the opponent, and reports how stable the
    /// answer was.
    /// - Parameter roundToChips: the candidates must be built exactly as the verdict built them —
    ///   a best of "bet 175" compared against a re-judged "bet 173" reads as disagreement, and
    ///   the day the cash candidates were rounded without this the confidence of every cash bet
    ///   collapsed and the stack-off habit went silent.
    static func sensitivity(view: DecisionView, read: HandAssessment, best: PlayerAction, deep: Bool = false,
                            roundToChips: Bool = false, action: PlayerAction, rng: inout SeededRandom) -> Double {
        guard !same(best, action) else { return 0.9 }
        var agreements = 0
        var total = 0
        for shift in [-0.18, 0.18] {
            let base = RangeReader.threatRange(in: view)
            let shifted = shift < 0
                ? base.strongest(0.62, board: view.board)          // they are stronger than assumed
                : base                                              // they are wider than assumed
            let equity = RangeEquity.equity(hole: view.holeCards, board: view.board,
                                            versus: shifted, trials: 300, rng: &rng)
                + (shift > 0 ? 0.06 : 0)
            // A committing action is re-priced against the strongest part of the shifted range,
            // as the verdict priced it — otherwise the check measures a different model.
            let committed = deep
                ? RangeEquity.equity(hole: view.holeCards, board: view.board,
                                     versus: shifted.strongest(Self.stackOffShare, board: view.board, countingDraws: true),
                                     trials: 300, rng: &rng) + (shift > 0 ? 0.06 : 0)
                : nil
            let values = candidateActions(view, read: read, equity: equity.clamped(to: 0...1), roundToChips: roundToChips).map {
                ($0, expectedValue(of: $0, view: view, equity: equity.clamped(to: 0...1), read: read,
                                   committedEquity: committed?.clamped(to: 0...1)))
            }
            if let top = values.max(by: { $0.1 < $1.1 }), same(top.0, best) { agreements += 1 }
            total += 1
        }
        // Full agreement is a firm verdict; disagreement means the answer depended on the guess.
        return total == 0 ? 0.5 : (0.42 + 0.28 * Double(agreements)).clamped(to: 0...0.97)
    }

    static func classify(taken: PlayerAction, best: PlayerAction, view: DecisionView,
                         read: HandAssessment, equity: Double, deep: Bool = false) -> ErrorKind? {
        let tookPassive = isPassive(taken)
        let bestAggressive = isAggressive(best)

        // The hundred-blind mistake, named as itself rather than as a call or a size that was
        // wrong: most of a deep stack went in with one pair and nothing to improve to, and the
        // better action kept the stack. Only where the stacks are deep — a tournament never
        // reaches this branch.
        if deep, commits(taken, in: view), !commits(best, in: view), read.isOnePair, read.drawEquity < 0.12 {
            return .stackedOffOnePair
        }

        if taken == .fold, best != .fold { return .foldedTooMuch }
        if taken == .call, best == .fold { return .calledTooWide }
        if tookPassive && bestAggressive {
            // Only call it a missed bet when there was genuinely value to collect. When the model
            // prefers betting a weak hand, that is a bluff — and telling an improving player to
            // bluff more is how they lose money faster. The coach stays quiet instead.
            return equity >= 0.55 ? .missedValue : nil
        }
        if isAggressive(taken) && best == .fold { return .bluffedTooMuch }
        if isAggressive(taken) && isPassive(best) {
            return equity < 0.45 ? .bluffedTooMuch : .wrongSize
        }
        if isAggressive(taken) && bestAggressive { return .wrongSize }
        return nil
    }

    // MARK: - The money behind (cash tables)

    /// The most one decision is charged at a cash table, in big blinds.
    ///
    /// Stacks at a cash table grow. Two hundred hands into a good evening a player can have four
    /// hundred big blinds in front of them, and a river decision in a pot that size, priced
    /// against a guess at the other player's holdings, came out at 384 big blinds in one run of
    /// the control player — a third of everything that player was charged in a thousand hands,
    /// off one hand, off a model that cannot be trusted to within fifty. A tournament never sees
    /// a pot like it. So one decision here is charged at most this much: still a big mistake,
    /// still the dearest thing on the card, but not one that can rate a sound player zero on its
    /// own.
    public static let mostOneDecisionCosts = 30.0

    /// Stacks this deep, in big blinds, are where a stack-off is judged as one. Below it a pot that
    /// takes most of a stack is an ordinary pot with a short stack in it.
    static let deepStackInBB = 80.0
    /// Deeper still, position starts to weigh on the preflop price too.
    static let veryDeepStackInBB = 150.0
    /// The share of an opponent's hands prepared to play for a whole stack: the strongest part.
    static let stackOffShare = 0.30
    /// An action that puts this share of the effective stack in is playing for it.
    static let commitShare = 0.60

    /// Whether this action puts most of the effective stack in the middle.
    static func commits(_ action: PlayerAction, in view: DecisionView) -> Bool {
        let chipsIn: Int
        switch action {
        case .fold, .check: return false
        case .call: chipsIn = view.toCall
        case .bet(let to), .raise(let to): chipsIn = to - view.heroSeat.committedThisRound
        }
        return Double(chipsIn) >= Self.commitShare * Double(max(1, view.effectiveStack))
    }

    enum ImpliedOdds: Equatable { case worthIt, notWorthIt }

    /// Whether a hand that is behind now but wins big when it hits is paid for by the money left
    /// behind. Nil for any hand that is not that kind of hand.
    ///
    /// The rule of thumb every cash coach teaches, written down: a small pair makes a set about
    /// one time in eight, so it wants the money behind — the pot and what is left to win — to be
    /// about twenty times the call; two cards that fit together hit less cleanly and want more.
    static func impliedOdds(hand: HandClass, view: DecisionView) -> ImpliedOdds? {
        guard view.toCall > 0, let needed = impliedMultiple(hand) else { return nil }
        // The rule is about the money AND the seat. A small pair can take a call from most seats
        // as long as nobody much is left to speak (a re-raise behind takes the price away); two
        // cards that fit together want to be in position on the raiser with at most one player
        // still to act. From the wrong seat the hand is an ordinary hand, judged by the chart.
        guard impliedSeatAllows(hand: hand, view: view) else { return nil }
        let offered = (impliedBehind(view) + Double(view.pot)) / Double(view.toCall)
        return offered >= needed ? .worthIt : .notWorthIt
    }

    /// The money left to win from the player who raised — theirs or the hero's, whichever is
    /// smaller — after the call. Not the deepest stack at the table: only the raiser can pay off.
    static func impliedBehind(_ view: DecisionView) -> Double {
        let effective = view.lastAggressor.map { view.effectiveStack(against: $0) } ?? view.effectiveStack
        return Double(max(0, effective - view.toCall))
    }

    /// Whether the seat lets the money-behind rule apply at all: in position on the raiser with
    /// nobody but the blinds still to speak, or — for a pair, which plays from anywhere — with at
    /// most two players behind. Two is the blinds: a call on the button is the ordinary case.
    static func impliedSeatAllows(hand: HandClass, view: DecisionView) -> Bool {
        let stillToAct = view.playersStillToAct
        let opener = openerPosition(in: view)
        let inPosition = opener.map { PositionAdjustment.isInPosition(view.position, versus: $0) } ?? false
        if hand.isPair { return stillToAct <= 2 || inPosition }
        return inPosition && stillToAct <= 2
    }

    /// Twenty for a small pair, twenty-five for two cards that fit together, nil for anything else.
    ///
    /// Twenty rather than the fifteen some books give, because the count here includes the pot
    /// and what is left behind rather than the stack alone, and because a set does not always get
    /// paid. At a hundred blinds it makes a single raise to three an easy call (the money offered
    /// is thirty-odd times the call) and a re-raise to ten a fold (about fifteen), which is the
    /// line every cash coach draws.
    static func impliedMultiple(_ hand: HandClass) -> Double? {
        if hand.isPair { return hand.high <= .nine ? 20 : nil }
        if hand.suited, hand.gap <= 1, hand.high <= .jack, hand.low >= .five { return 25 }
        return nil
    }

    /// What calling with a hand like that is worth in chips: the times it hits, against what it
    /// costs the times it does not. A rule of thumb rather than a solve, and priced conservatively —
    /// half the money behind is the most the hitting hand is assumed to collect.
    ///
    /// A third of the raiser's money behind is what the hitting hand is assumed to collect, and
    /// never more than twelve times the call. The third is the number that makes this price and
    /// the twenty-times gate above agree: at exactly twenty times the call the call is worth
    /// about nothing, above it something, below it less. The twelve is what a set is actually
    /// paid on average when it hits — the whole reason the rule of thumb is twenty and not two
    /// hundred: with half the money behind and a cap of twenty-five calls, a fold of a small pair
    /// to a re-raise seven hundred blinds deep was priced at twenty-four big blinds, which is not
    /// a number anybody has ever won by calling.
    static func impliedValue(hand: HandClass, view: DecisionView) -> Double {
        let hit = hand.isPair ? 0.12 : 0.10
        let toCall = Double(view.toCall)
        let behind = impliedBehind(view)
        let win = Double(view.pot) + toCall + min(behind / 3, toCall * 12)
        return hit * win - (1 - hit) * toCall
    }

    /// The most a preflop fold is charged at a cash table, in big blinds. A fold before the flop
    /// gives up a share of a small pot; it cannot cost a stack, and pricing one against a re-raised
    /// pot with a deep opponent's range had ace-king folded to a re-raise costing twenty.
    public static let mostOnePreflopFoldCosts = 6.0

    static func impliedReason(hand: HandClass, view: DecisionView) -> String {
        let behind = Int(impliedBehind(view))
        let multiple = Int((Double(behind + view.pot) / Double(max(1, view.toCall))).rounded())
        let needed = Int(impliedMultiple(hand) ?? 15)
        let kind = hand.isPair ? "A small pair makes a set about one time in eight" : "Two cards that fit together hit a big hand about one time in ten"
        return "\(kind), so the call has to be paid for by what is left to win when it does. Here the pot and the money behind come to about \(multiple) times the call, and a hand like this wants about \(needed)."
    }

    // MARK: - Small helpers

    static func neutral(view: DecisionView, action: PlayerAction, spot: SpotKind) -> Verdict {
        Verdict(street: view.street, spot: spot, holeCards: view.holeCards, board: view.board,
                taken: action, best: action, costInBB: 0, severity: .fine, error: nil,
                confidence: 0.5, headline: "", detail: "")
    }

    static func postflopSpot(_ view: DecisionView) -> SpotKind {
        let facingBet = view.toCall > 0
        switch view.street {
        case .flop:
            if facingBet { return .facingContinuationBet }
            return view.heroWasPreflopAggressor ? .continuationBet : .flopDecision
        case .turn: return .turnDecision
        case .river: return facingBet ? .facingRiverBet : .riverDecision
        default: return .flopDecision
        }
    }

    static func isPassive(_ action: PlayerAction) -> Bool { action == .check || action == .call || action == .fold }
    static func isAggressive(_ action: PlayerAction) -> Bool {
        if case .bet = action { return true }
        if case .raise = action { return true }
        return false
    }

    static func same(_ a: PlayerAction, _ b: PlayerAction) -> Bool {
        switch (a, b) {
        case (.fold, .fold), (.check, .check), (.call, .call): return true
        case (.bet(let x), .bet(let y)): return x == y
        case (.raise(let x), .raise(let y)): return x == y
        default: return false
        }
    }

    static func limpers(in view: DecisionView) -> Int {
        view.seats.filter { $0.isInHand && $0.seat != view.hero && $0.position != .bigBlind
            && $0.committedThisRound >= view.bigBlind }.count
    }

    static func openerPosition(in view: DecisionView) -> Position? {
        guard let seat = view.lastAggressor else { return nil }
        return view.seats.first { $0.seat == seat }?.position
    }

    /// Chips gained by calling a raise rather than folding, against the raiser's likely hands.
    ///
    /// The realisation factor is the part most players never account for: a hand out of position
    /// does not collect its whole share of the pot, because it has to act first for the rest of
    /// the hand. That is exactly why the big blind, which is always out of position, still
    /// continues so wide — the price it is getting more than pays for the handicap.
    static func continueEV(view: DecisionView, rng: inout SeededRandom, format: CoachFormat = .tournament) -> Double {
        guard view.toCall > 0 else { return 0 }
        let range = RangeReader.threatRange(in: view)
        let equity = RangeEquity.equity(hole: view.holeCards, board: [], versus: range, trials: 400, rng: &rng)
        // Deeper, the seat matters more: there are more streets' worth of chips for the player who
        // acts last to take, so a hand out of position collects a little less of its share again.
        let veryDeep = format == .cash && view.effectiveStackInBB >= Self.veryDeepStackInBB
        let outOfPosition = view.position != .button && view.position != .cutoff
        let realization = (view.position.isBlind ? 0.82 : 0.95) - (veryDeep && outOfPosition ? 0.04 : 0)
        let pot = Double(view.pot), toCall = Double(view.toCall)
        return equity * realization * (pot + toCall) - toCall
    }

    /// Chips gained by opening the pot with a raise: the times everyone folds, plus what is left
    /// when somebody does not.
    static func openEV(view: DecisionView, rng: inout SeededRandom, format: CoachFormat = .tournament) -> Double {
        let behind = max(1, view.seats.filter { $0.isInHand && $0.seat != view.hero }.count)
        // A raise of one small bet folds fewer people than a raise to two and a half blinds: the
        // per-player fold chance is lower at limit, not higher (it was written the wrong way
        // round once, and inflated every steal at a limit table).
        let foldEquity = pow(format == .limit ? 0.62 : 0.74, Double(behind))
        let raiseSize = format == .limit
            ? Double(view.legal.minRaiseTo - view.heroSeat.committedThisRound)
            : Double(ReferenceRanges.openSize(bigBlind: view.bigBlind,
                                              limpers: limpers(in: view),
                                              players: view.playersDealtIn))
        let pot = Double(view.pot)
        let callers = HandRange.top(0.22, excluding: view.holeCards)
        let equity = RangeEquity.equity(hole: view.holeCards, board: [], versus: callers, trials: 300, rng: &rng)
        // A hand does not collect its whole share of the pot unless it can see what everyone else
        // does first. Assuming it does is what makes opening far too many hands look harmless.
        let realization: Double
        switch view.position {
        case .button, .cutoff: realization = 0.95
        case .smallBlind, .bigBlind: realization = 0.80
        default: realization = 0.78
        }
        let whenCalled = equity * realization * (pot + 2 * raiseSize) - raiseSize
        return foldEquity * pot + (1 - foldEquity) * whenCalled
    }

    static func marginReference(view: DecisionView, spot: SpotKind, players: Int,
                                format: CoachFormat = .tournament) -> Double {
        switch spot {
        case .openingChance: return ReferenceRanges.open(position: view.position, players: players, format: format)
        case .bigBlindDefence: return ReferenceRanges.defendBigBlind(versus: openerPosition(in: view) ?? .cutoff,
                                                                     players: players, format: format)
        case .shortStackShove: return ReferenceRanges.shove(depth: view.effectiveStackInBB, position: view.position,
                                                            players: players, facingRaise: view.preflopRaiseCount > 0)
        default: return ReferenceRanges.callVersusOpen(from: view.position,
                                                       versus: openerPosition(in: view) ?? .cutoff,
                                                       players: players, format: format)
        }
    }

    /// The hand's share against the raiser's likely hands, for pricing a preflop raise put in behind.
    static func continueEquity(view: DecisionView, rng: inout SeededRandom) -> Double {
        let range = RangeReader.threatRange(in: view)
        return RangeEquity.equity(hole: view.holeCards, board: [], versus: range, trials: 300, rng: &rng)
    }

    static func percentText(_ fraction: Double) -> String { "\(Int((fraction * 100).rounded()))%" }
    static func bbText(_ value: Double) -> String { String(format: "%.0f", value) }
    static func oddsText(_ view: DecisionView) -> String {
        guard view.toCall > 0 else { return "a free look" }
        let ratio = Double(view.pot) / Double(view.toCall)
        return String(format: "%.1f to 1", ratio)
    }
}