Advertisement

← poker-tutor

Sources/PokerCoach/Coach.swift

16.3 KB Download file
import Foundation
import PokerCore
import PokerAI

/// The player's standing, in the same 0-100 units the tables are set to.
public struct SkillRating: Sendable {
    public let value: Int
    public let handsBehindIt: Int
    public let costPer100: Double

    /// Hands before the rating stops being pulled towards the middle and is simply the arithmetic.
    ///
    /// Named rather than written down twice, because the screen that warns the reader it is still
    /// settling has to drop the warning at exactly the hand the pulling stops. It used to drop it a
    /// hundred hands early, so at 150 hands the card said "based on what your mistakes cost" about
    /// a number still a quarter of the way to the middle.
    public static let handsToSettle = 200

    /// Turns money lost to mistakes into a rating.
    ///
    /// Anchored on tournament play, because tournaments are the game this app ships. The first
    /// version was anchored on the coach harness, which sits a hundred big blinds deep from the
    /// first hand to the last — nothing like a tournament, where the blinds climb and the stacks
    /// get short and the same player gives up roughly twice as much per hundred hands. The result
    /// was a scale whose top end nobody could reach: the flawless reference player scored zero.
    ///
    /// Measured with `pokerlab calibrate` over 3,000 hands of real tournaments, eight seeds each,
    /// on 2026-09-11 — this time with the penalty each player's own confirmed habits add, because
    /// a real player always carries some and the first anchoring (2026-08-02) had been done as
    /// though nobody did: three confirmed habits put anyone at zero, the worst scripted player
    /// included. The costs also moved since August, so the whole line was re-derived:
    ///
    ///                                   cost bb/100   (range)          penalty
    ///     no planted flaw                9.8          (7.6 – 16.3)       0.0
    ///     folds the big blind           10.4          (8.8 – 11.8)       9.3
    ///     never value bets the river    10.8          (8.4 – 15.2)       0.0
    ///     folds to every flop bet       16.1          (10.4 – 22.4)     15.0
    ///     never raises first in         21.6          (17.5 – 31.2)     15.0
    ///     plays far too many hands      32.4          (21.7 – 39.3)     15.0
    ///
    /// The penalty is capped at `LeakProfile.maximumLeakPenalty` (15). The two ends of that set
    /// the two ends of the scale, penalties in: a player with no deliberate flaw lands at 66 —
    /// just inside "strong amateur", which starts at 65 — and the worst of them at 10. Everything between falls where
    /// the arithmetic puts it: river 64, big blind 56, flop 40, limper 30 — and the ladder's rungs (22,
    /// 35, 48, 60 to move up) were set so that they spread across it. "Low pro" needs under
    /// about 2 bb/100 with nothing confirmed, which is better than any player this repo can
    /// produce, and that is the point: the top of a scale should be something to climb towards
    /// rather than somewhere the reference player already sits.
    ///
    /// Nothing here is a guess at "how good is this person at poker" in the abstract. It measures
    /// what their own mistakes cost, which is the thing the app can actually see.
    public init(costPer100: Double, hands: Int, leakPenalty: Double = 0,
                format: CoachFormat = LeakCatalogue.format) {
        self.costPer100 = costPer100
        self.handsBehindIt = hands
        // Derived from the bands above with the penalties in: 9.8 and 0 -> 66, 32.4 and 15 -> 10.
        let line = Self.line(for: format)
        let raw = line.intercept - costPer100 * line.slope - leakPenalty
        // Until there are enough hands, pull the rating toward the middle rather than pretending
        // to know. Twenty hands of running hot should not make anyone an expert.
        let confidence = min(1.0, Double(hands) / Double(Self.handsToSettle))
        self.value = Int((50 + (raw - 50) * confidence).clamped(to: 0...100).rounded())
    }

    /// What a player would have to be costing themselves to rate this. The inverse of the line
    /// above, kept beside it so the two cannot drift: a test that builds a history "rating 60"
    /// needs the same arithmetic the rating itself uses, and writing it out twice is how a dozen
    /// correct ladder tests came to fail at once.
    public static func costFor(rating: Int, format: CoachFormat = LeakCatalogue.format) -> Double {
        let line = line(for: format)
        return (line.intercept - Double(rating)) / line.slope
    }

    /// The two lines, one per game, each derived by `pokerlab calibrate` for its own format.
    ///
    /// The cash line, from `pokerlab calibrate --format cash --hands 3000 --seeds 4` at the
    /// app's own 25/50 no-limit table, re-run on 2026-09-12 after the cash review (every priced
    /// mistake now counts towards the rating at a cash table, the money-behind rule learned the
    /// seat, a preflop fold is capped at six big blinds, and the stack-off habit is priced but
    /// not named — so its player carries no penalty). Cost in bb/100, mean over four seeds, with
    /// the penalty each player's confirmed habits actually incurred, one decision capped at
    /// thirty (`DecisionEvaluator.mostOneDecisionCosts`):
    ///
    ///     no planted flaw                        15.1  (13.0 – 18.4)   penalty  0.0   ->  66
    ///     never value bets the river             16.3  (14.6 – 18.8)            3.4   ->  61
    ///     folds the hands the stacks pay for     16.4  (14.9 – 18.0)            5.6   ->  59
    ///     calls every re-raise with a small pair 23.7  (17.5 – 31.9)            0.0   ->  55
    ///     folds the big blind                    18.3  (14.4 – 21.8)           12.8   ->  49
    ///     never raises first in                  20.0  (14.4 – 24.9)           11.5   ->  48
    ///     folds to every flop bet                28.6  (21.8 – 36.7)           15.0   ->  34
    ///     plays far too many hands               30.2  (25.8 – 36.9)           15.0   ->  32
    ///     stacks off with one pair               60.2  (47.8 – 67.4)            0.0   ->  10
    ///
    /// (The first anchoring, 2026-09-11, was 18.8 -> 66 and 55.3 -> 10 on `87.2 - cost * 1.125`;
    /// the sound player's cost fell because the money-behind rule stopped charging routine
    /// folds, and rose because uncatalogued mistakes now count — net a little lower.)
    ///
    /// Two things about it. A cash table charges a sound player more than a tournament does —
    /// 15 bb/100 against 10 — because every pot is a hundred blinds deep and a misjudgement in a
    /// deep pot is priced at deep-pot money; so the cash line is steeper and the two ratings are
    /// not comparable across the apps. And before the cap, the player who puts a whole stack in
    /// with one pair cost sixteen hundred big blinds a hundred and a single river decision of
    /// the control's was charged 384: a scale anchored on numbers like those is a scale nobody
    /// can move on. With the cap he costs 55 and lands at 10, which is still where he belongs.
    static func line(for format: CoachFormat) -> (intercept: Double, slope: Double) {
        switch format {
        case .tournament: return (83.8, 1.814)
        // 15.1 and 0 -> 66, 60.2 and 0 -> 10.
        case .cash: return (84.8, 1.242)
        // 9.6 and 0 -> 66, 46.3 and 15 -> 10. From `pokerlab calibrate --format cash --structure
        // fixedLimit --hands 3000 --seeds 4` at the app's own 25/50 fixed-limit table, re-run on
        // 2026-09-12 after the cash review (the limit opening model's fold rate, the free-card
        // credit and the limit range model all moved; the line moved from 77.0/1.194). Cost in
        // bb/100, mean over four seeds, with the penalty each player's confirmed habits incurred:
        //
        //     no planted flaw                    9.6  (7.8 – 11.3)   penalty  0.0   ->  66
        //     never raises for the free card     9.1  (8.8 –  9.5)            0.0   ->  66
        //     calls with the best hand           9.5  (8.2 – 10.6)            0.0   ->  66
        //     never value bets the river        10.5  (9.3 – 12.7)            0.0   ->  64
        //     calls the last bet with nothing   12.0 (11.1 – 13.3)           11.0   ->  52
        //     never raises first in             10.9  (9.7 – 12.4)           13.4   ->  51
        //     calls two bets cold               17.7 (16.5 – 18.6)            5.6   ->  50
        //     folds the big blind               16.0 (14.2 – 17.8)           15.0   ->  43
        //     plays far too many hands          20.6 (19.3 – 22.7)           15.0   ->  37
        //     folds the river to one bet        23.8 (18.3 – 28.3)           15.0   ->  34
        //     caps with anything                37.0 (29.9 – 45.7)           15.0   ->  18
        //     folds to every flop bet           39.6 (34.5 – 49.0)           15.0   ->  15
        //     folds a draw to one bet           46.3 (37.8 – 54.6)           15.0   ->  10
        //
        // Three things it says. A limit table charges a sound player less than either other
        // game — 10 bb/100 against 10 at a tournament and 15 at a no-limit cash table — because
        // a bet is small and a misjudgement is priced at one bet. The expensive habits are the
        // folds, exactly as the game's own lore says: the flop-folder and the draw-folder cost
        // three to four times what the loose player costs. And two habits the catalogue prices
        // but cannot name — the free card and calling with the best hand — cost a sound
        // player's amount, which is the arithmetic behind leaving them unnamed.
        case .limit: return (76.7, 1.116)
        }
    }

    /// Whether the number is the arithmetic yet, or still being held near the middle.
    public var isSettled: Bool { handsBehindIt >= Self.handsToSettle }

    public var label: String {
        switch value {
        case ..<25: return "Beginner"
        case ..<45: return "Improving"
        case ..<65: return "Solid amateur"
        case ..<80: return "Strong amateur"
        default: return "Low pro"
        }
    }

    /// A table set slightly above the player is where improvement happens.
    public var tableDifficulty: Int { TableComposition.difficulty(forRating: value) }
}

/// Watches the player and builds a picture of their game.
public final class Coach: @unchecked Sendable {
    public private(set) var profile = LeakProfile()
    public private(set) var reviews: [HandReview] = []
    private var pending: [Verdict] = []
    private var seedCounter: UInt64 = 1
    /// Keeps memory bounded during a long session; the store keeps the full history.
    public var maxReviewsHeld = 500

    /// Every decision the coach has spoken about this sitting, and the dearest of them — counted as
    /// the hands go past rather than off `reviews`.
    ///
    /// `reviews` is a window on the last few hundred hands and nothing more. The session report
    /// counted its mistakes off that window and its hands off the profile, which is not capped, so
    /// past five hundred hands the report started quietly reporting fewer mistakes than it had
    /// seen — the cost per hundred it printed beside them kept climbing while the count that was
    /// meant to explain it went down. A running tally has no window to fall out of.
    public private(set) var spokenMistakes = 0
    public private(set) var dearestMistake: Verdict?

    public init() {}

    public var rating: SkillRating {
        SkillRating(costPer100: profile.costPer100Hands, hands: profile.handsPlayed,
                    leakPenalty: profile.leakPenalty)
    }

    /// Judges one decision as the player makes it. Called before the action is applied, because
    /// the verdict has to be based on what the player could see at the time.
    /// Which game is being judged. A tournament judges a stack of twelve big blinds or fewer as a
    /// push-or-fold spot; a cash table never does, and judges the money behind instead — the set
    /// that was called for, the stack that went in with one pair. See `CoachFormat`.
    public var format: CoachFormat = .tournament

    /// The old name for the one difference the two formats had before the cash coach existed.
    public var judgesShortStacks: Bool {
        get { format == .tournament }
        set { format = newValue ? .tournament : .cash }
    }

    @discardableResult
    public func judge(_ view: DecisionView, action: PlayerAction) -> Verdict {
        seedCounter &+= 1
        let verdict = DecisionEvaluator.evaluate(view, took: action, seed: seedCounter, format: format)
        pending.append(verdict)
        profile.record(verdict)
        return verdict
    }

    /// Closes out a hand and produces the review the player sees.
    @discardableResult
    public func finishHand(engine: HandEngine, heroSeat: Int, handNumber: Int) -> HandReview {
        profile.recordHand()
        let review = HandReview(
            handNumber: handNumber,
            holeCards: engine.seats[heroSeat].hole,
            board: engine.board,
            netChips: engine.result?.net[heroSeat] ?? 0,
            bigBlind: engine.config.level.bigBlind,
            verdicts: pending
        )
        pending.removeAll()
        let spoken = review.worthMentioning
        spokenMistakes += spoken.count
        for verdict in spoken where verdict.costInBB > (dearestMistake?.costInBB ?? -1) {
            dearestMistake = verdict
        }
        reviews.append(review)
        if reviews.count > maxReviewsHeld { reviews.removeFirst(reviews.count - maxReviewsHeld) }
        return review
    }

    /// Throws away the verdicts collected for a hand that is never going to finish.
    ///
    /// `pending` is emptied by `finishHand` and by nothing else, so a hand walked out on left its
    /// verdicts sitting there waiting to be attached to whatever hand finished next — in a
    /// different sitting, on a different street, from a different seat, and written into that
    /// hand's record on the way past. From there it reached the scorecard, the drill counts, the
    /// assessment and the history, permanently. It is the same rule as the one about an illegal
    /// action: nothing that was not a decision in this hand may ever be filed against the player as
    /// a mistake in it.
    public func abandonHand() { pending.removeAll() }

    public func reset() {
        profile = LeakProfile()
        reviews.removeAll()
        pending.removeAll()
        spokenMistakes = 0
        dearestMistake = nil
    }

    /// What to work on next: the costliest confirmed leak.
    public var focus: Leak? { profile.confirmed().first?.leak }

    /// A short session report. Every number in it is counted over the whole sitting, so none of
    /// them can disagree with another because one of them was read off a capped list.
    public func sessionReport() -> SessionReport {
        SessionReport(
            hands: profile.handsPlayed,
            mistakes: spokenMistakes,
            costInBB: profile.totalCostInBB,
            rating: rating,
            confirmed: profile.confirmed(),
            watching: profile.watchList(),
            biggest: dearestMistake
        )
    }
}

public struct SessionReport: Sendable {
    public let hands: Int
    public let mistakes: Int
    public let costInBB: Double
    public let rating: SkillRating
    public let confirmed: [(leak: Leak, record: LeakProfile.Record)]
    public let watching: [(leak: Leak, record: LeakProfile.Record)]
    public let biggest: Verdict?

    public var costPer100: Double { hands > 0 ? costInBB / Double(hands) * 100 : 0 }

    public var headline: String {
        guard hands > 0 else { return "No hands played yet." }
        if mistakes == 0 { return "\(hands) hands, nothing worth flagging. Good session." }
        let per100 = String(format: "%.1f", costPer100)
        return "\(hands) hands. \(mistakes) decision\(mistakes == 1 ? "" : "s") cost you money — about \(per100) big blinds per hundred hands."
    }
}