Advertisement

← poker-tutor

Sources/PokerCoach/Leaks.swift

58.0 KB Download file
import Foundation
import PokerCore

/// A named, recurring mistake.
///
/// A single bad fold is a bad fold and nothing more. A leak is the same mistake in the same kind
/// of spot, often enough that it is a habit rather than an accident. That distinction is why every
/// leak carries a minimum sample: the player will never be told they over-fold the big blind on
/// the strength of four hands, because that claim would be worthless and they would be right to
/// stop listening.
public struct Leak: Sendable, Identifiable, Hashable {
    public let id: String
    public let spot: SpotKind
    public let error: ErrorKind
    public let name: String
    /// Plain English, aimed at someone who has never read a poker book.
    public let explanation: String
    /// What a competent player's error rate looks like in this spot.
    public let acceptableRate: Double
    /// How many chances to make this mistake before the coach will name it.
    public let minimumSample: Int
    public let drillHint: String
    /// Whether the app is allowed to say the player has this habit.
    ///
    /// False where the coach can price one decision but cannot measure the habit — where a sweep
    /// has shown that a player who does the thing every single time and a player who never does it
    /// measure the same, so there is no bar and no sample that would separate them. A rate that
    /// cannot separate the two populations is not a rate about the player, and printing one is the
    /// exact fault three habits in this file were already fixed for. Such a habit is never named,
    /// never listed as one to watch, and never turned into a drill; what it costs still counts
    /// towards the rating, because a decision the model can price is still a decision that cost
    /// money. Silence about a habit the app cannot see is the honest answer, not a gap.
    public let canBeNamed: Bool
    /// Which way of arranging the table a drill at this habit has to use, where the habit is
    /// defined by the seat it happens in rather than only by the decision.
    ///
    /// Picking the arrangement off the situation alone is not enough, and got it wrong in the one
    /// way that matters. Three habits live in "opening the pot" and two of them want opposite ends
    /// of the table: the drill for not attacking the blinds was seating the player first to speak,
    /// which is where attacking the blinds is not a thing that exists — while the same sheet
    /// printed "seats 9 and 8, the button and the cutoff" a few lines further down. Nil where any
    /// arrangement that produces the situation will do.
    public let drillSetup: String?

    public init(id: String, spot: SpotKind, error: ErrorKind, name: String, explanation: String,
                acceptableRate: Double, minimumSample: Int, drillHint: String,
                canBeNamed: Bool = true, drillSetup: String? = nil) {
        self.canBeNamed = canBeNamed
        self.id = id
        self.spot = spot
        self.error = error
        self.name = name
        self.explanation = explanation
        self.acceptableRate = acceptableRate
        self.minimumSample = minimumSample
        self.drillHint = drillHint
        self.drillSetup = drillSetup
    }

    public func hash(into hasher: inout Hasher) { hasher.combine(id) }
    public static func == (a: Leak, b: Leak) -> Bool { a.id == b.id }
}

public enum LeakCatalogue {

    /// Which edition of the catalogue this process reads. See `CoachFormat`.
    nonisolated(unsafe) public static var format: CoachFormat = .tournament

    /// The habits the coach prices decisions against, in the edition the format selects.
    public static var all: [Leak] { all(for: format) }

    public static func all(for format: CoachFormat) -> [Leak] {
        switch format {
        case .tournament: return tournament
        case .cash: return cash
        case .limit: return limit
        }
    }

    /// The tournament edition: what the app shipped with.
    public static let tournament: [Leak] = [
        // Every bar and every floor below was chosen from one sweep, and it is worth saying what
        // the sweep measures, because the obvious version of it hides the fault it exists to find.
        // A player reads the list at every point in an evening, so a bar that convicts them at
        // three hundred hands and clears them again by four thousand has still told them something
        // untrue. Reading the coach's profile at the end of a long run cannot see that. So the
        // sweep replays the decisions in the order they happened and applies the coach's own three
        // tests after every one, and the number written down is the *worst the running rate ever
        // reached* once it had a given number of chances behind it — across sixteen seeds of ten
        // thousand hands of the control player, who has no planted flaw at all.
        //
        // That is the number a floor has to clear, and it is nothing like the rate the same player
        // finishes on. Folding the big blind finishes at 11-14%; on the way there it passes through
        // 28% with twenty-five chances behind it. The old bar was 18% over 25, so ten of sixteen
        // flawless sittings were told they over-fold their big blind.
        //
        //   pokerlab sweep --player control --hands 10000 --seeds 16
        //   pokerlab sweep --player <flaw> --against control --hands 4000 --seeds 12

        Leak(id: "bb-overfold", spot: .bigBlindDefence, error: .foldedTooMuch,
             name: "Folding the big blind too often",
             explanation: "Your blind is already in the middle before you have looked at your cards, and the price to carry on is usually three to one or better. Folding nearly everything gives that money away, and it comes round again every single time the deal goes round the table — which is what makes it the most expensive habit there is.",
             // Control, sixteen seeds of 10,000 hands: finishes at 11-14% (mean 13.2) over about
             // 1,170 chances, and the worst its running rate ever reaches is 28% from 25 chances,
             // 24% from 50, 22% from 150, 17% from 300. A player who folds every big blind sits at
             // 53% in ordinary play. The old 18% over 25 chances sat under the top of the control
             // band and named ten of sixteen flawless sittings; the bar goes to 30% over 50, six
             // points clear of the worst clean seed at that sample and far under the flawed one.
             acceptableRate: 0.30, minimumSample: 50,
             drillHint: "Big blind against a raise from the late seats, 8 and 9, over and over, until continuing feels normal."),

        Leak(id: "limping", spot: .openingChance, error: .limpedInstead,
             name: "Limping in instead of raising",
             explanation: "Just matching the blind gives up two things at once: the chance to win it there and then, and the lead in the hand, which makes everything after the flop easier. If a hand is good enough to play, it is good enough to raise. If it is not, let it go.",
             acceptableRate: 0.08, minimumSample: 20,
             drillHint: "Unopened pots from the last seat, where plenty of hands are worth a raise — raise or fold, nothing in between.",
             // Drilled from the last seat rather than the first, and it was measured rather than
             // argued. From the first seat to speak, six seeds of the full drill: a sound player
             // cleared 6 of 6 in exactly twenty hands, because every hand is an opening chance and
             // almost none of them is worth raising, so twenty correct folds finish it. A player
             // who limps everything cleared 3 of those 6. From the last seat it is 5 and 1.
             drillSetup: "last-seats"),

        Leak(id: "too-many-hands", spot: .openingChance, error: .playedTooManyHands,
             name: "Playing too many hands",
             explanation: "Weak hands have a habit of making the second-best hand, and the second-best hand is the expensive one to have. Most money is not lost in one disaster. It drains away in hands that were never worth playing.",
             acceptableRate: 0.12, minimumSample: 30,
             drillHint: "Opening decisions from the early seats — 3 and 4 of 9 — where the fewest hands are worth playing.",
             // Where the ranking already put it, written down so it stays there. Six seeds of the
             // full drill: a sound player clears 6 of 6, a player who opens far too much clears
             // none. From the last seat instead it is 0 and 1, which is a drill nobody can finish.
             drillSetup: "early-seat"),

        Leak(id: "passed-steals", spot: .openingChance, error: .passedUpSteal,
             name: "Not attacking the blinds",
             explanation: "In the last couple of seats — 8 and 9 at a full table — most of your profit comes from raising when the few players left behind you are likely to give up. Passing those chances up leaves money on the table every time round.",
             acceptableRate: 0.15, minimumSample: 20,
             drillHint: "Seats 9 and 8, the button and the cutoff, folded to you with the blinds still to act.",
             // The habit is the seat. Ranked on the situation alone this drilled from the first
             // seat to speak, where there are no blinds to attack and nobody behind you to give up.
             drillSetup: "last-seats"),

        Leak(id: "call-too-wide-preflop", spot: .facingRaise, error: .calledTooWide,
             name: "Calling raises with too many hands",
             explanation: "Paying to see a flop against a raise with a hand that is already behind means playing the rest of it second best and, usually, having to act first. Far fewer hands are worth this than most people think, and several of the ones that are should be raised back rather than called.",
             acceptableRate: 0.15, minimumSample: 25,
             drillHint: "Facing a raise from each seat in turn, deciding to fold, call or re-raise."),

        Leak(id: "fold-to-raise", spot: .facingRaise, error: .foldedTooMuch,
             name: "Giving up too easily against a raise",
             explanation: "Not every raise means a strong hand, and giving up every time someone puts pressure on makes you the easiest person at the table to play against. There is a set of hands strong enough to stand up to it, and they get thrown away far too often.",
             acceptableRate: 0.18, minimumSample: 25,
             // It used to say "facing raises from the late seats, 8 and 9", which the drill has
             // never done and cannot: the arrangement that produces this puts the busiest players
             // in front of the player, and they are wherever they are sitting.
             drillHint: "Facing a raise from the seats in front of you, deciding what is worth carrying on with and what is not."),

        Leak(id: "fold-to-3bet", spot: .facingReraise, error: .foldedTooMuch,
             name: "Folding to every re-raise",
             explanation: "If you raise and someone raises you back, folding nearly every time means anyone can take the pot off you simply by raising again. Some of your raising hands have to stand their ground, or raising you becomes free money.",
             acceptableRate: 0.22, minimumSample: 15,
             drillHint: "Opening and then facing a re-raise, deciding what to continue with."),

        Leak(id: "no-shove", spot: .shortStackShove, error: .failedToShove,
             name: "Not moving in with a short stack",
             explanation: "Below about twelve big blinds there is no room to raise and then change your mind — any raise has your stack committed anyway. Raising small when you are short costs you the main thing the move is for: everyone else folding.",
             acceptableRate: 0.12, minimumSample: 12,
             drillHint: "Short-stack spots at ten big blinds and under: all in, or fold."),

        Leak(id: "shove-too-wide", spot: .shortStackShove, error: .shovedTooWide,
             name: "Moving all in with too many hands when short",
             explanation: "A short stack does have to get it all in more often, but not with anything at all. Doing it with hands that are simply too weak turns an awkward stack into no stack.",
             acceptableRate: 0.12, minimumSample: 12,
             drillHint: "Short-stack spots where the right answer is to wait."),

        Leak(id: "no-cbet", spot: .continuationBet, error: .missedValue,
             name: "Not following through after raising",
             explanation: "When you raised before the cards came down, you are the one who is meant to have the strong hand — and most flops miss most hands, including theirs. Checking gives that up and lets someone who also missed win it instead.",
             acceptableRate: 0.20, minimumSample: 20,
             drillHint: "Flops after you raised with one player left in, deciding whether to bet."),

        Leak(id: "fold-to-cbet", spot: .facingContinuationBet, error: .foldedTooMuch,
             name: "Folding to the first bet too often",
             explanation: "The first bet after the flop is the cheapest bluff in poker, and most players make it with almost anything. Folding every time you miss makes you the easiest person at the table to take money from.",
             // Control finishes at 10-17% (mean 12.3) over about 380 chances, and its running rate
             // reaches 25% from 25 chances, 21% from 50, 18% from 150. A player who folds to every
             // flop bet he cannot beat with two pair sits at 48-58% (mean 51.7, twelve seeds of
             // 4,000 hands), so the two are nowhere near each other and the whole problem was the
             // floor: 20% over 14 chances named three of sixteen clean sittings. The bar goes to
             // 30% over 25 — five points clear of the worst clean seed, eighteen under the best
             // flawed one.
             acceptableRate: 0.30, minimumSample: 25,
             drillHint: "Facing flop bets with middle pair, bottom pair and draws."),

        Leak(id: "station-flop", spot: .facingContinuationBet, error: .calledTooWide,
             name: "Calling flop bets with too little",
             explanation: "Paying to see one more card with a hand that is behind and has nothing to improve to is money spent on curiosity. Either it is worth carrying on with or it is not.",
             // Control finishes at 4-9% (mean 6.0) and its running rate reaches 20% from 25
             // chances and 13% from 50, so an 18% bar over 16 chances named one clean sitting in
             // sixteen. The bar is left where it is and the floor goes to 50, which puts five
             // points between it and the worst clean seed. Raising a floor only ever makes this
             // quieter, so it needs the clean measurement and nothing else.
             acceptableRate: 0.18, minimumSample: 50,
             drillHint: "Facing flop bets where folding is the answer."),

        // Both numbers on this one were measured against two populations rather than argued
        // about, the same way the river habit below was, and for the same reason: the old bar of
        // 18% over 14 chances sat inside the band of the player it was meant to clear, so the one
        // habit a flawless control player kept being handed was this one.
        //
        // The two populations, measured the same way, 4,000 hands each (about 270 turn decisions),
        // twenty seeds apiece: the control player misses turn value 13% to 21% of the time (mean
        // 17.7); a player who checks every turn he is allowed to check runs 25% to 33% (mean 29.0).
        // At 8,000 hands the two settle at 15-21% (mean 17.5) and 28-31% (mean 29.4), so those are
        // the real rates and everything below 4,000 hands is noise around them.
        //
        // The sample is the expensive half of this. Measured at four sizes, twenty seeds each:
        //
        //   chances   control      never bets the turn
        //     25       5 - 35%       17 - 38%
        //     50       7 - 36%       18 - 36%
        //    110      10 - 28%       21 - 34%
        //    270      13 - 21%       25 - 33%
        //
        // Nothing separates the two until about 250 chances. A bar anywhere in the overlap either
        // convicts a player with nothing wrong or says nothing at all, so the sample goes to 250 —
        // roughly 3,800 hands of poker — and until then the habit sits under "too soon to call"
        // with its count on show and no accusation attached. That is the honest answer rather than
        // a convenient one: this app can wait, and a player who is told their turn play is leaking
        // when it is not stops believing the rest of the list.
        //
        // The bar goes at 24%: three points above the worst of twenty control seeds and one below
        // the best of twenty flawed ones, which at that sample catches 20 of 20 flawed sittings and
        // convicts 0 of 20 clean ones. Erring towards silence is deliberate — the two bands are
        // only about four points apart at the sample where they part, and missing a habit costs
        // less than naming one that is not there.
        Leak(id: "missed-turn-value", spot: .turnDecision, error: .missedValue,
             name: "Not betting the turn with a good hand",
             explanation: "The fourth card is where the pot gets big. Checking a strong hand to keep someone company usually just gives them a free shot at beating you, and loses the bet they would happily have paid.",
             acceptableRate: 0.24, minimumSample: 250,
             drillHint: "Turn decisions with top pair and better."),

        // Both numbers on this one were measured rather than chosen, after the coach was caught
        // naming it for a player with no planted flaw at all.
        //
        // The bar was 15% over 12 chances, and 12 chances is two slips. Swept over the control
        // player — the one with nothing wrong with it — that named a river habit in 15 of 20
        // sittings of 1,000 hands. It was not seed luck either: at 4,000 hands, ten seeds, the
        // control player misses river value 15% to 23% of the time (mean 19.3%), so a 15% bar
        // convicts a competent player by construction and a bigger sample only makes it surer.
        //
        // The two populations, measured the same way, 4,000 hands each: control 15-23% (mean
        // 19.3, ten seeds); the player who never bets a river at all 38-46% (mean 40.2, five
        // seeds). At 1,000 hands the bands are control 6-29% (twenty seeds) and flawed 19-57%
        // (twenty seeds). So the bar goes between them at 30% — seven points clear of the worst
        // control seed at a real sample — and the sample it takes to say anything goes to 25
        // chances, which is about 700 hands of poker. Below that the rate swings 0% to 42% on the
        // same flawless player, and a number that does that is not a measurement.
        //
        // What this costs is real and is the right price: at 320 hands nothing is said about the
        // river either way, because 320 hands cannot see it. Silence is the honest answer there.
        Leak(id: "missed-river-value", spot: .riverDecision, error: .missedValue,
             name: "Not betting the river with the best hand",
             explanation: "On the last card there is nothing left to come, so a good hand has exactly one way left to earn anything — bet it. Checking a winner is the most common way decent players leave money behind.",
             // The 30% bar held up. The 25-chance floor did not: the same sweep, reading the
             // running rate rather than the finishing one, has the control player reaching 41% from
             // 25 chances, 34% from 50, 31% from 100, 27% from 150 and 25% from 200 — so two of
             // sixteen flawless sittings were named on the way past. The floor goes to 200, five
             // points of clearance, which is roughly 6,000 hands of poker.
             acceptableRate: 0.30, minimumSample: 200,
             drillHint: "River decisions with a hand that beats what they would call with."),

        // The third habit in this file whose bar sat inside the band of the player it existed to
        // clear, and it was found the same way as the other two: by running a player with no
        // planted flaw at a real sample over many seeds and reading what the coach said about him.
        // The bar was 16% over 14 chances, and at 4,000 hands the control player was named for
        // this in nine sittings out of twelve.
        //
        // The two populations, measured the same way, 8,000 hands each (about 120 river calls),
        // fourteen seeds apiece: the control player pays off 15% to 27% of the time (mean 21.4); a
        // player who calls every river bet he is offered runs 34% to 56% (mean 48.7). The gap is
        // wide — this is a much louder habit than the two above — and the whole of the difficulty
        // is in the sample:
        //
        //   hands    chances   control     calls every river bet
        //    4,000     52-79    11 - 33%      30 - 60%
        //    6,000     74-112   13 - 27%      32 - 56%
        //    8,000    106-138   15 - 27%      34 - 56%
        //
        // At 65 chances the two bands overlap — a clean player reads 33% and a flawed one 30% —
        // so no bar exists there. From about 100 chances on they are apart and stay apart. So the
        // bar goes at 30%: three points above the worst of fourteen control seeds and four below
        // the best of fourteen flawed ones, catching 14 of 14 flawed sittings and convicting 0 of
        // 14 clean ones. The chances it takes to say anything go from 14 to 100, which is roughly
        // 7,000 hands of poker.
        //
        // Fourteen chances was never a measurement of anything: it is two slips. What the new
        // numbers cost is real and is the right price — nothing is said about river calls until
        // there are a hundred of them, and until then the habit sits under "too soon to call" with
        // its count on show and no accusation attached.
        Leak(id: "river-hero-call", spot: .facingRiverBet, error: .calledTooWide,
             name: "Paying off on the river",
             explanation: "A bet on the last card is the least likely of all to be a bluff, especially in a friendly game. Paying it off with a hand that beats nothing they would have bet is a donation with a story attached.",
             // And the floor went one step further than the two-population table below on its own
             // asked for. Reading the running rate rather than the finishing one, the control
             // player reaches 29% from 100 chances — one point under the bar — and 23% from 150.
             // So 150 it is: seven points of clearance rather than one.
             acceptableRate: 0.30, minimumSample: 150,
             drillHint: "Facing river bets holding a hand that can only beat a bluff."),

        Leak(id: "river-overfold", spot: .facingRiverBet, error: .foldedTooMuch,
             name: "Folding the river too often",
             explanation: "The other side of the same coin. When the pot is big, a call only has to be right now and then to be worth making, and folding every time invites anyone who is paying attention to bet at you every hand.",
             acceptableRate: 0.20, minimumSample: 14,
             drillHint: "Facing river bets getting four to one or better."),

        Leak(id: "overbluffing", spot: .riverDecision, error: .bluffedTooMuch,
             name: "Bluffing into people who cannot fold",
             explanation: "A bluff only works if the other person is capable of folding. Betting at someone who calls with anything just hands them money, and it is the most common way somebody who is getting better starts losing faster than they did as a beginner.",
             // Control finishes at 3-8% (mean 5.2) and its running rate reaches 13% from 25
             // chances and 10% from 50, against a 15% bar — so one clean sitting in sixteen was
             // named off the old floor of 14. The floor goes to 50, which leaves five points.
             acceptableRate: 0.15, minimumSample: 50,
             drillHint: "River spots against opponents who have shown they will call."),

        // This one is never named, and that is a measured position rather than a shrug.
        //
        // The bar was 25% over 25 chances, and the control player — no planted flaw — measured
        // 20% to 29% (mean 24.9) over twelve seeds of 4,000 hands, so he was named for it in six
        // of the twelve. That is the same fault as the three habits above. What makes this one
        // different is that re-deriving the bar from two populations does not fix it, because the
        // two populations are the same population. Measured the same way, twelve seeds, 4,000
        // hands, about 165 flop bets each:
        //
        //   no planted flaw       20 - 29%  (mean 24.9)
        //   min-bets every flop    0 -  5%  (mean  2.0)
        //   shoves every flop     22 - 34%  (mean 28.0)
        //
        // A player who shoves his whole stack on every flop he bets is three points away from a
        // flawless one, and a player who bets the minimum every time reads *better* than either —
        // because the coach's own preferred size is often the minimum, and a size is counted wrong
        // whenever it differs from that by a single chip. Whatever this number is measuring, it is
        // not how well somebody sizes a bet. Separating a three-point difference from a four-point
        // seed spread would take on the order of a hundred thousand hands.
        //
        // So there is no bar and no sample. The habit stays in the catalogue because the coach can
        // still price one bet against another in one hand, and that money still counts towards the
        // rating — but it is never named, never watched and never drilled. Naming it would need a
        // classifier that compares sizes in bands rather than in chips, and then the sweep above
        // run again against both planted players.
        Leak(id: "bet-sizing", spot: .flopDecision, error: .wrongSize,
             name: "Betting the wrong amount",
             explanation: "Bet too little with a strong hand and you leave money behind. Bet far too much and you chase off everything that would have paid you. How much you bet should follow what you want to happen next, not how pleased you are with your cards.",
             acceptableRate: 0.25, minimumSample: 25,
             drillHint: "Choosing a size on the flop with every sort of hand.",
             canBeNamed: false),
    ]

    /// The cash edition: the tournament habits that still apply a hundred big blinds deep, and the
    /// deep-stack habits that only exist there.
    ///
    /// The two short-stack habits go: at a cash table a short stack is a player who has lost some
    /// of a buy-in and can top it back up, not a spot the blinds are about to force, and the cash
    /// coach never judges one as a push-or-fold decision. What comes in is the money behind —
    /// calling to hit a set, folding a hand the stacks would have paid to see, and the hundred-blind
    /// mistake that costs more than every other one put together: a whole stack going in with one
    /// pair.
    public static let cash: [Leak] =
        tournament.filter { $0.spot != .shortStackShove }.map { leak in
            guard let bar = cashBars[leak.id] else { return leak }
            return Leak(id: leak.id, spot: leak.spot, error: leak.error, name: leak.name,
                        explanation: leak.explanation, acceptableRate: bar.rate,
                        minimumSample: bar.sample, drillHint: leak.drillHint,
                        canBeNamed: leak.canBeNamed, drillSetup: leak.drillSetup)
        } + cashOnly

    /// Where a shared habit's bar sits at a cash table, when the cash sweep put it somewhere
    /// other than the tournament sweep did. Measured at the app's own nine-handed cash tables —
    /// `pokerlab sweep --format cash --rung 0|3` — control eight seeds of 6,000 hands at the bottom
    /// rung and four at the top, each planted player six seeds of 3,000 against the control.
    /// The bands are beside each number. A habit not listed keeps the tournament bar, because the
    /// cash sweep found the control inside the same band there.
    static let cashBars: [String: (rate: Double, sample: Int)] = [
        // Control at the top rung reaches 25% from 12 chances and 21% from 25 on one run, 16% from
        // 50, 9% at the bottom rung; the tournament floor of 15 sat under that. Nobody is planted
        // with this habit, so the floor is set from the control alone: 50, six points clear.
        "fold-to-3bet": (0.22, 50),
        // Control 3-9%, running worst 25% from 12, 20% from 25, 13% from 50, 12% from 75 — the
        // tournament floor of 20 named one clean sitting in eight. Fifty leaves seven points.
        "no-cbet": (0.20, 50),
        // Control 10-21% (mean 16), running worst 27% from 12, 26% from 25 and 50, 23% from 75,
        // 22% from 100; the player who folds every flop bet he cannot beat with two pair sits at
        // 56-63%. The bar stays at 30 and the floor goes to 75: seven points of clearance below
        // and twenty-six above.
        "fold-to-cbet": (0.30, 75),
        // Control 3-10% at the bottom rung and 6-9% at the top, but the top rung's running rate
        // reaches 33% from 12, 16% from 25 and 50 — two points under the tournament bar of 18. No
        // flawed player exists for this one (there is no calling station in the planted set), so
        // the bar is set from the control alone: 24 over 50, eight points clear.
        "station-flop": (0.24, 50),
        // Control 20-29% with 60-130 chances in 6,000 hands; its running rate reaches 33% from
        // 100 at both rungs, over the tournament bar of 30, and 150 chances is beyond what six
        // thousand cash hands produce. The player who calls every river bet sits at 46-59%. The
        // bar goes to 35 over the same 150: the control cannot have been seen there yet and the
        // flawed player is eleven points above it.
        "river-hero-call": (0.35, 150),
        // Control 1-8% (mean 5), running worst 17% from 12, 16% from 25, 12% from 50, 11% from 75
        // — the tournament floor of 50 left three points. Seventy-five leaves four to seven.
        "overbluffing": (0.15, 75),
        // Unchanged and worth recording: folding the big blind, control 8-16% against a planted
        // 40-46%; limping 0 against 9-13%; too many hands 0-1 against 48-52%; the turn habit
        // 14-18% (worst 22% from 200) against 25-33%; the river habit 16-25% (worst 23% from
        // 200) against 38-41%. Every one of those is named in six flawed sittings of six and in
        // none of the clean ones, on the tournament bar.
    ]

    /// Every bar below is PROVISIONAL until the cash sweep sets it — see the comment beside each.
    public static let cashOnly: [Leak] = [
        Leak(id: "stacked-off-one-pair", spot: .deepStackOff, error: .stackedOffOnePair,
             name: "Putting a whole stack in with one pair",
             explanation: "A hundred big blinds is a lot of money to put in with one pair, even the best one pair there is. When somebody is willing to play for everything on the flop or the turn, one pair is very rarely what they have, so the hands that call you are the ones that beat you. One pair is a hand to win a small pot with, and to let go of when the pot stops being small.",
             // Not named, and that is a measured position. Counted over every decision where the
             // next chips were most of a stack. A sound player is almost never in that spot: the
             // control faced it one to six times in 6,000 hands at either rung, so its band at
             // twelve chances has never been seen — and a bar that was never compared against a
             // clean player at its own floor is not a bar (the rule the rest of this file is
             // built on). A person reaches twelve stack-off spots in an evening, and four of
             // twelve would have named the costliest habit on the card off a band nobody
             // measured. The player who stacks off with one pair faced it 31-55 times in 3,000
             // hands and did it 80-100% of the time. The cost of each one still counts, in full.
             acceptableRate: 0.30, minimumSample: 12,
             drillHint: "Spots where a bet or a call would put most of your stack in and all you have is one pair.",
             canBeNamed: false),

        Leak(id: "set-mine-no-odds", spot: .facingReraise, error: .setMinedWithoutOdds,
             name: "Calling a re-raise with a small pair the money behind does not pay for",
             explanation: "A small pair only makes a set about one time in eight, so calling to hit one has to be paid for by what you win the times it hits. Against a re-raise the call is big and the money left behind is not twenty times it, so the times you hit do not cover the times you miss. Small pairs call a single raise and let a re-raise go.",
             // Never named, and that is a measured position. The rate is counted over every
             // re-raise faced, and a small pair is in the hand for a twentieth of them: the player
             // who calls every re-raise with one runs 0-3% (mean 1.3) over 285-377 chances, the
             // control 0-0.1%. One seed in six of the flawed player never produced three of them.
             // A bar that cannot separate a player who always does it from one who never does is
             // not a bar. The cost of each one still counts towards the rating.
             acceptableRate: 0.03, minimumSample: 100,
             drillHint: "Small pairs facing a re-raise, where the money behind decides it.",
             canBeNamed: false),

        Leak(id: "missed-implied-odds", spot: .facingRaise, error: .missedImpliedOdds,
             name: "Folding a hand the money behind pays to play",
             explanation: "With a hundred big blinds in front of everybody, a small pair or two cards that fit together are worth a call against one raise even though they are usually behind right now: the money still to be won when the hand hits is many times the price of the call. Folding them gives up the cheapest way there is to win a big pot.",
             // Re-swept on 2026-09-12 after the rule learned the seat (a call in position on the
             // raiser with only the blinds behind, or a pair with at most two still to act from
             // anywhere) and whose money counts (the raiser's, not the deepest stack): the control
             // runs 0% over 758-1,810 chances in every sitting, worst running rate 0% at every
             // mark — a sound cash player calls these by the rule the coach judges by, so its
             // zero is the rule's, not a discovery — and the player who folds every one of them
             // runs 2% (2.1, all four seeds) over 921-1,054 chances: the spots the rule allows
             // are rare at a nine-handed table. One percent over two hundred chances — three
             // folds of a hand the price paid for, in about six hundred hands — names the flawed
             // player and never the clean one. The first version of the rule, with no seat in
             // it, ran 4-6% and charged a routine fold from the middle as a mistake.
             acceptableRate: 0.01, minimumSample: 200,
             drillHint: "Small pairs and hands that fit together, facing one raise with plenty behind."),
    ]

    /// The fixed-limit edition — see `CoachFormat.limit`.
    ///
    /// Built from the cash edition: the habits that are about bet sizes and stacks go (a bet has
    /// one size; a stack never goes in on one street), four of the shared ones are replaced by
    /// their limit reading under the same situation and mistake — a flop fold is a draw let go
    /// for one small bet, a river call with nothing is a bet given away, a river fold is the pot
    /// folded to one bet, a preflop call of a raise is two bets cold — and four habits exist only
    /// here: the free card, calling with the best hand instead of raising, the turn fold, and
    /// capping with a hand that is not the best. Every bar comes from
    /// `pokerlab sweep --format cash --structure fixedLimit`, bands beside each number.
    public static let limit: [Leak] = {
        let gone: Set<String> = ["stacked-off-one-pair", "set-mine-no-odds", "missed-implied-odds",
                                 "bet-sizing", "station-flop"]
        let replaced = Dictionary(uniqueKeysWithValues: limitOnly.map { ("\($0.spot)/\($0.error)", $0) })
        var out: [Leak] = []
        var used: Set<String> = []
        for leak in cash where !gone.contains(leak.id) {
            let key = "\(leak.spot)/\(leak.error)"
            if let limitLeak = replaced[key] {
                out.append(limitLeak)
                used.insert(key)
            } else if let bar = limitBars[leak.id] {
                out.append(Leak(id: leak.id, spot: leak.spot, error: leak.error, name: leak.name,
                                explanation: leak.explanation, acceptableRate: bar.rate,
                                minimumSample: bar.sample, drillHint: leak.drillHint,
                                canBeNamed: bar.named, drillSetup: leak.drillSetup))
            } else {
                out.append(leak)
            }
        }
        out += limitOnly.filter { !used.contains("\($0.spot)/\($0.error)") }
        return out
    }()

    /// Where a shared habit's bar sits at a fixed-limit table, when the limit sweep put it
    /// somewhere other than the cash sweep did. Measured at the app's own nine-handed 25/50
    /// fixed-limit table on 2026-09-12 — `pokerlab sweep --format cash --structure fixedLimit`
    /// — control eight seeds of 6,000 hands at the bottom rung and four at the top, each planted
    /// player six seeds of 3,000 against the control. The number that decides a bar is the worst
    /// the control's running rate ever reached from a given number of chances (the "from" list
    /// the sweep prints); the bands are beside each number. A habit not listed keeps the cash bar.
    static let limitBars: [String: (rate: Double, sample: Int, named: Bool)] = [
        // Control 4-11% (mean 8) at the bottom rung, running worst 25% from 12, 18% from 50, 15%
        // from 75, 13% from 100; the cash floor of 50 left two points. Seventy-five leaves five.
        // No player is planted with this habit, so the bar is the control's alone.
        "no-cbet": (0.20, 75, true),
        // Control 0% at every mark (a sound limit player raises or folds first in); the player
        // who limps its playable hands runs 9-12% (mean 11). The cash bar of 8 sat one point under
        // the flawed player's worst sitting. Four over fifty.
        "limping": (0.04, 50, true),
        // Recorded, unchanged: folding the big blind, control 6-12% (running worst 19% from 50)
        // against a planted 54-60%; too many hands 0-1% against 37-43%.
        // The turn habit: control 9-17% with a running worst of 21% from 150 to 300, the player
        // who never bets the turn 18-27%. Those bands touch, and the flawed player was named
        // four sittings of six on the cash bar — by the standard applied to the value raise
        // below, a bar that names the flawed player names a clean sitting too. Priced, not
        // named, at a limit table until a sweep separates them.
        "missed-turn-value": (0.24, 250, false),
        // Control 14-29% (mean 19), running worst 38% from 12, 30% from 50, 29% from 100 to 300
        // and 21% from 500 — a limit player checks a river with the best hand far more than a
        // no-limit player does, because a river bet is small and the hand is often not best by
        // much. The cash floor of 200 sat one point under the control's own running rate. The
        // player who never bets the river runs 40-48%. Five hundred chances is about four
        // thousand hands, and that is what it takes to say this honestly at a limit table.
        "missed-river-value": (0.30, 500, true),
    ]

    /// The habits that exist only at a fixed-limit table, or read differently there. Every bar
    /// comes from the same sweep as `limitBars`, bands beside each number.
    public static let limitOnly: [Leak] = [
        Leak(id: "limit-fold-to-price", spot: .facingContinuationBet, error: .foldedTooMuch,
             name: "Folding a draw for one small bet",
             explanation: "At a limit table a bet is a fraction of the pot, so a hand with cards to come is nearly always getting the price to see the next one: with eight cards that make your hand you need to be right about one time in five, and the pot is offering far better than that. Folding a draw to one bet on the flop gives up a pot you would have won often enough to pay for every call.",
             // Control 5-7% (mean 6) over 304-849 chances, running worst 31% from 12, 20% from 25,
             // 16% from 50, 13% from 75, 10% from 100 (re-swept 2026-09-12 with the draw folds
             // the free-card spot used to swallow counted here). The player who folds every flop
             // or turn bet without two pair runs 53-69% (mean 63) and is named in every sitting.
             // Twenty-two over seventy-five: nine points clear of the control's worst.
             acceptableRate: 0.22, minimumSample: 75,
             drillHint: "Flops where you have cards to come and somebody bets one small bet into a pot worth many of them."),

        Leak(id: "limit-fold-turn-price", spot: .turnDecision, error: .foldedTooMuch,
             name: "Folding the turn with a hand that still has the price",
             explanation: "The turn bet is the big one, but the pot has grown too. A draw that was worth calling on the flop is usually still worth one bet on the turn, and a made hand that might be best is worth it more. The turn is where careful players fold hands that were about to win.",
             // Control 1-2% (mean 1.3) over 718-1,606 chances, running worst 4% from 12 to 100,
             // 3% from 150 to 300. The same flop-and-turn folder runs 9-12% here (mean 10; it ran
             // 10-15% before the free-card spot stopped swallowing draw folds). Six over a
             // hundred and fifty: three points clear below, three above the flawed player's
             // worst sitting; the old bar of eight sat one point under it.
             acceptableRate: 0.06, minimumSample: 150,
             drillHint: "Turns where you are facing a bet with a draw or a made hand that is not obviously beaten."),

        Leak(id: "limit-missed-value-raise", spot: .facingContinuationBet, error: .missedValue,
             name: "Calling with the best hand instead of raising",
             explanation: "At a limit table nobody folds to a raise, which is exactly why the raise is worth making: with the best hand, every extra bet you put in is matched by worse hands that were going to call anyway. Calling along with a hand that is likely best collects one bet where a raise collects two, and over a session the difference is most of your winnings.",
             // Not named, and that is a measured position. Control 4-13% (mean 7) with a running
             // worst of 27% from 12 and 15-16% from 25 to 300; the player who turns every raise
             // into a call runs 15-19% (mean 17). Those two bands touch: a bar that names the
             // flawed player names a clean sitting too. The cost of each one still counts.
             acceptableRate: 0.25, minimumSample: 40,
             drillHint: "Flops where somebody bets into you and your hand is likely to be the best one.",
             canBeNamed: false),

        Leak(id: "limit-cold-call", spot: .facingRaise, error: .calledTooWide,
             name: "Calling two bets cold with a hand that does not play",
             explanation: "Calling a raise before the flop costs two bets with nothing in the pot to show for it, and it says your hand was not good enough to re-raise. That is the classic way to be second best for a whole pot: the hands worth two bets cold are the ones that play well against a raiser — pairs, a suited ace, two big cards — and the rest are worth folding or, if they are good enough, raising with.",
             // Counted over every raise faced from a seat other than the big blind (the big
             // blind's answer is its own situation). Control 0% (running worst 0% at every mark)
             // over 1,900-2,400 chances; the player who calls two bets cold with most of what it
             // is dealt runs 4-10% (mean 7) — low because most raises faced are answered with a
             // fold, which the rate counts too. Three over sixty.
             acceptableRate: 0.03, minimumSample: 60,
             drillHint: "Facing one raise with nobody else in, with a hand that is neither strong enough to re-raise nor one that plays well against a raiser."),

        Leak(id: "limit-call-down-nothing", spot: .facingRiverBet, error: .calledTooWide,
             name: "Calling the last bet with nothing",
             explanation: "A river call at a limit table is one bet against a big pot, so calling with a hand that can beat a bluff is right far more often than it feels. Calling with a hand that cannot beat anything — no pair, nothing to come — is different: it is a bet given away every time, because even a bluff has you beaten.",
             // Control 1-4% (mean 2), running worst 14% from 12, 13% from 50, 9% from 100, 6% from
             // 150, 5% from 200. The player who calls every last bet runs 20-26% (mean 22) and is
             // named six of six. Fifteen over a hundred: six points clear below, five above.
             acceptableRate: 0.15, minimumSample: 100,
             drillHint: "River bets faced with no pair, where the only question is whether you can beat a bluff."),

        Leak(id: "limit-river-overfold", spot: .facingRiverBet, error: .foldedTooMuch,
             name: "Folding the river to one bet in a big pot",
             explanation: "By the river a limit pot holds ten or twelve bets and the last bet is one more. You need to be best only one time in ten or so for the call to pay, and any hand with a real chance is best more often than that. Folding a pair to one bet in a pot that size is the single most expensive fold in the game, and it is the one careful players make most.",
             // Control 1-2%, running worst 14% from 12, 6% from 50, 5% from 75, 3% from 200, 2%
             // from 500. The player who folds the river without two pair runs 8-12% (mean 10):
             // a sound limit player folds a river so rarely that the habit is quiet even in the
             // player who has it. Five over two hundred: two points clear below, three above.
             acceptableRate: 0.05, minimumSample: 200,
             drillHint: "River bets faced with a pair or better in a pot worth many bets."),

        Leak(id: "limit-no-free-card", spot: .freeCardChance, error: .missedFreeCard,
             name: "Missing the raise that buys a free card",
             explanation: "Last to act on the flop with a real draw, a raise costs one small bet and, about half the time, gets you checked to on the turn — where the bet is twice the size. So the raise often buys the next card for less than calling would have cost, and it wins the pot outright now and then as well. Calling in that spot pays full price for the same card.",
             // Not named, because the spot is rare: last to act on the flop with a real draw and a
             // bet to answer came up 8-27 times in 6,000 hands. The control misses the raise
             // 7-42% of those (mean 29) and the player who never raises a draw 43-100% (mean 80),
             // so the two would separate at about half — but a floor of thirty chances is twelve
             // thousand hands away, and a bar nobody can reach is a bar that was never measured.
             // The cost of each one still counts, and the lesson teaches the play.
             acceptableRate: 0.50, minimumSample: 30,
             drillHint: "Flops where you are last to act, somebody bets, and you have a real draw.",
             canBeNamed: false),

        Leak(id: "limit-cap-light", spot: .facingReraise, error: .cappedLight,
             name: "Capping the betting before the flop with a hand that is not the best",
             explanation: "Once somebody has re-raised before the flop, the hands still raising are the very top of the deck. Raising again with less buys nothing — at a limit table nobody folds to it — and puts two more bets into a pot where you are usually behind. Call with the hands that play, fold the rest, and save the cap for aces, kings and ace-king.",
             // Control 0-2% (mean 1) over 636-1,857 chances, running worst 7% from 12 and 25, 6%
             // from 50, 4% from 75, 3% from 100. The player who puts in another bet with anything
             // it would have played runs 40-46% (mean 43), named six of six. Ten over fifty.
             acceptableRate: 0.10, minimumSample: 50,
             drillHint: "Re-raised pots before the flop, where the question is whether your hand is one of the few worth another bet."),
    ]

    /// Every habit the app is allowed to say somebody has. The list a player is ever shown is built
    /// from this one; `all` stays the list the coach prices decisions against.
    public static var nameable: [Leak] { all.filter(\.canBeNamed) }

    public static func matching(spot: SpotKind, error: ErrorKind) -> Leak? {
        all.first { $0.spot == spot && $0.error == error }
    }

    /// The leak a saved hand names, if this build still has it. A drill written down against an id
    /// that no longer exists simply stops counting, which is the honest outcome.
    public static func named(_ id: String?) -> Leak? {
        guard let id else { return nil }
        return all.first { $0.id == id }
    }
}

/// How often a player makes each mistake, and which ones have crossed from accident into habit.
public struct LeakProfile: Sendable, Equatable {
    public struct Record: Sendable, Equatable {
        public var opportunities = 0
        public var mistakes = 0
        public var costInBB = 0.0
        public var rate: Double { opportunities > 0 ? Double(mistakes) / Double(opportunities) : 0 }

        public init(opportunities: Int = 0, mistakes: Int = 0, costInBB: Double = 0) {
            self.opportunities = opportunities
            self.mistakes = mistakes
            self.costInBB = costInBB
        }
    }

    public private(set) var records: [String: Record] = [:]
    public private(set) var handsPlayed = 0
    public private(set) var totalCostInBB = 0.0
    public private(set) var decisionsJudged = 0

    public init() {}

    /// Rebuilt from hands that were written down rather than watched as they happened.
    ///
    /// The coach's memory starts empty every time the app opens, so anything that has to survive a
    /// launch — a rating, a starting point on the ladder — has to be countable off the saved hands
    /// instead. Counting them is `LeakScorecard`'s job; this is the door back in, so there is one
    /// definition of what a rating means rather than two that drift.
    public init(handsPlayed: Int, decisionsJudged: Int, totalCostInBB: Double,
                records: [String: Record]) {
        self.handsPlayed = handsPlayed
        self.decisionsJudged = decisionsJudged
        self.totalCostInBB = totalCostInBB
        self.records = records
    }

    public mutating func record(_ verdict: Verdict) {
        decisionsJudged += 1
        // Every leak whose spot matches had the chance to happen here.
        for leak in LeakCatalogue.all where leak.spot == verdict.spot {
            records[leak.id, default: Record()].opportunities += 1
        }
        // The scorecard counts how *often* a mistake happens, which is a different question from
        // whether any single instance was worth interrupting the game about. Opening too many
        // hands costs a third of a big blind at a time — never enough to mention on its own, and
        // ruinous forty times an hour. Gating the profile on what is worth saying out loud made
        // exactly those leaks invisible.
        guard verdict.error != nil,
              verdict.confidence >= DecisionEvaluator.confidenceThreshold,
              verdict.severity >= .slight else { return }
        // The rating counts the cost of every mistake that lands on a habit. A (situation,
        // mistake) pair no habit covers is priced and then let go — the same rule the scorecard
        // applies when it rebuilds this profile off the saved hands (`LeakScorecard`), and the
        // two have to agree or Home and the table would print two ratings. So a situation is
        // never carved out with one habit under it and the rest left to fall through: a
        // stack-off or free-card decision that is some other mistake is filed back under the
        // flop or turn situation whose habit can see it (`DecisionEvaluator`). Folding a set to
        // a shove at a hundred blinds was free for a day because that rule did not exist. The
        // pairs that still fall through in every format — a big-blind call too wide, a re-raise
        // called too wide, a river bet not made, a turn or river bet of the wrong size — are
        // listed here so nobody rediscovers them: counting them is a re-anchoring of every
        // scale, not a side effect of a fix.
        guard let error = verdict.error,
              let leak = LeakCatalogue.matching(spot: verdict.spot, error: error) else { return }
        records[leak.id, default: Record()].mistakes += 1
        records[leak.id, default: Record()].costInBB += verdict.costInBB
        totalCostInBB += verdict.costInBB
    }

    public mutating func recordHand() { handsPlayed += 1 }

    /// The leaks that have shown up often enough to be real, worst first.
    public func confirmed() -> [(leak: Leak, record: Record)] {
        LeakCatalogue.nameable.compactMap { leak in
            guard let record = records[leak.id],
                  record.opportunities >= leak.minimumSample,
                  record.rate > leak.acceptableRate,
                  record.mistakes >= 3
            else { return nil }
            return (leak, record)
        }
        .sorted { $0.record.costInBB > $1.record.costInBB }
    }

    /// Leaks that are trending badly but do not yet have the sample to be called.
    public func watchList() -> [(leak: Leak, record: Record)] {
        LeakCatalogue.nameable.compactMap { leak in
            guard let record = records[leak.id],
                  record.opportunities < leak.minimumSample,
                  record.mistakes >= 2,
                  record.rate > leak.acceptableRate
            else { return nil }
            return (leak, record)
        }
        .sorted { $0.record.mistakes > $1.record.mistakes }
    }

    public var costPer100Hands: Double {
        handsPlayed > 0 ? totalCostInBB / Double(handsPlayed) * 100 : 0
    }

    /// A rating built only on money lost has a perverse edge: a player who folds everything makes
    /// almost no mistakes, because they are barely in any hands. A confirmed habit is worse than
    /// the same amount of money lost at random — it is systematic, it is exploitable, and it will
    /// keep costing until it is fixed. So each one carries weight of its own.
    /// The most the confirmed habits can take off the rating between them.
    ///
    /// The cost of every mistake is already in `costPer100Hands`, so a habit is priced twice when it
    /// is confirmed: once for the money and once here, for being systematic. That second charge is
    /// meant to say "this will go on happening", not to be a second copy of the money — and without
    /// a ceiling it was. Each confirmed habit could take twenty points, and a real player carries
    /// several at once, so three confirmed habits put anybody at zero, the worst scripted player
    /// included, and the scale had been anchored as though nobody carried any. The anchors in
    /// `SkillRating` are now derived with this ceiling in.
    public static let maximumLeakPenalty = 15.0

    public var leakPenalty: Double {
        guard handsPlayed > 0 else { return 0 }
        return min(Self.maximumLeakPenalty, uncappedLeakPenalty)
    }

    /// The sum before the ceiling, kept so the calibration can print how far over it players run.
    public var uncappedLeakPenalty: Double {
        guard handsPlayed > 0 else { return 0 }
        return confirmed().reduce(0.0) { total, entry in
            let excess = (entry.record.rate - entry.leak.acceptableRate) / max(0.05, entry.leak.acceptableRate)
            // Weighted by how often it happens *and* by what it costs. The cost matters on its own
            // because it is systematic: unlike money lost at random, this amount will go on being
            // lost, in the same spot, until the habit changes.
            let costPer100 = entry.record.costInBB / Double(handsPlayed) * 100
            return total + min(20.0, excess * 6.0 + costPer100 * 2.5)
        }
    }
}