Advertisement

← poker-tutor

Sources/PokerTests/CoachTests.swift

73.6 KB Download file
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import PokerUI
import TestKit

/// A player with exactly one deliberate flaw, used to test whether the coach can find it.
public struct PlantedPlayer: @unchecked Sendable {
    public let name: String
    public let leakID: String
    public let decide: (DecisionView, inout SeededRandom) -> PlayerAction

    public init(name: String, leakID: String, decide: @escaping (DecisionView, inout SeededRandom) -> PlayerAction) {
        self.name = name
        self.leakID = leakID
        self.decide = decide
    }

    /// Folds the big blind to any raise, no matter the price or the hand.
    public static let foldsBigBlind = PlantedPlayer(name: "folds the big blind", leakID: "bb-overfold") { view, rng in
        if view.street == .preflop, view.position == .bigBlind, view.toCall > 0 { return .fold }
        return solid(view, &rng)
    }

    /// Never raises first in; calls the blind with everything worth playing.
    public static let limpsEverything = PlantedPlayer(name: "limps instead of raising", leakID: "limping") { view, rng in
        if view.street == .preflop, view.isUnopenedPot, view.legal.canCall,
           PreflopTable.percentile(view.holeCards) <= 0.35 {
            return .call
        }
        return solid(view, &rng)
    }

    /// Checks the river with everything, however strong.
    public static let neverValueBetsRiver = PlantedPlayer(name: "never bets the river", leakID: "missed-river-value") { view, rng in
        if view.street == .river, view.legal.canCheck { return .check }
        return solid(view, &rng)
    }

    /// Checks the turn with everything, however strong.
    ///
    /// The mirror of the river player above, and it exists for the same reason: a bar on a habit
    /// can only be set by measuring both the player who has it and the player who does not. Until
    /// this existed the turn habit's bar had been measured against one population, so there was no
    /// way to tell a bar that catches the flaw from one that convicts everybody.
    public static let neverValueBetsTurn = PlantedPlayer(name: "never bets the turn", leakID: "missed-turn-value") { view, rng in
        if view.street == .turn, view.legal.canCheck { return .check }
        return solid(view, &rng)
    }

    /// Calls every bet on the river, whatever it is holding.
    ///
    /// The counterpart the "paying off on the river" bar needed, and it exists for the same reason
    /// the turn player above does: a bar can only be set by measuring the player who has the habit
    /// against the player who does not, and until this existed there was only one population to
    /// measure. It calls rather than folds even when it can beat nothing, which is exactly what the
    /// habit is.
    public static let callsEveryRiverBet = PlantedPlayer(name: "calls every river bet", leakID: "river-hero-call") { view, rng in
        if view.street == .river, view.toCall > 0, view.legal.canCall { return .call }
        return solid(view, &rng)
    }

    /// Folds to any bet on the flop unless it holds two pair or better.
    public static let foldsToEveryFlopBet = PlantedPlayer(name: "folds to every flop bet", leakID: "fold-to-cbet") { view, rng in
        if view.street == .flop, view.toCall > 0 {
            let read = HandReader.assess(hole: view.holeCards, board: view.board, opponents: view.opponentCount)
            if read.category < .twoPair { return .fold }
        }
        return solid(view, &rng)
    }

    /// Bets the smallest amount the rules allow, every time it bets the flop.
    ///
    /// The counterpart the "betting the wrong amount" bar needed. A min-bet is the wrong size with
    /// almost every hand — it charges nothing to draw against a strong one and folds nothing out
    /// with a weak one — so a player who only ever makes it is the population the bar has to catch.
    public static let alwaysMinBetsFlop = PlantedPlayer(name: "min-bets every flop", leakID: "bet-sizing") { view, rng in
        let action = solid(view, &rng)
        guard view.street == .flop else { return action }
        switch action {
        case .bet: return .bet(to: view.legal.minRaiseTo)
        case .raise: return .raise(to: view.legal.minRaiseTo)
        default: return action
        }
    }

    /// Shoves the whole stack in every time it bets the flop.
    ///
    /// The other end of the sizing habit from the min-bettor, and the reason both had to be built:
    /// the bar has to catch the wrong size in both directions or it is not a bar about sizing.
    public static let alwaysOverbetsFlop = PlantedPlayer(name: "shoves every flop", leakID: "bet-sizing") { view, rng in
        let action = solid(view, &rng)
        guard view.street == .flop else { return action }
        switch action {
        case .bet: return .bet(to: view.legal.maxRaiseTo)
        case .raise: return .raise(to: view.legal.maxRaiseTo)
        default: return action
        }
    }

    /// Plays far too many hands before the flop.
    public static let playsEverything = PlantedPlayer(name: "plays too many hands", leakID: "too-many-hands") { view, rng in
        if view.street == .preflop, view.isUnopenedPot, view.legal.canBetOrRaise,
           PreflopTable.percentile(view.holeCards) <= 0.85 {
            let to = min(view.legal.maxRaiseTo, max(view.legal.minRaiseTo, view.bigBlind * 2))
            return view.legal.canBet ? .bet(to: to) : .raise(to: to)
        }
        return solid(view, &rng)
    }

    // MARK: Cash-table flaws

    /// Puts a whole stack in with one pair: calls any bet on the flop or turn holding one pair,
    /// and moves the stack in with top pair or better rather than betting a size.
    public static let stacksOffOnePair = PlantedPlayer(name: "stacks off with one pair", leakID: "stacked-off-one-pair") { view, rng in
        if view.street == .flop || view.street == .turn {
            let read = HandReader.assess(hole: view.holeCards, board: view.board, opponents: view.opponentCount)
            if read.isOnePair {
                if view.toCall > 0, view.legal.canCall { return .call }
                if read.category >= .topPair, view.legal.canBetOrRaise {
                    return view.legal.canBet ? .bet(to: view.legal.maxRaiseTo) : .raise(to: view.legal.maxRaiseTo)
                }
            }
        }
        return solid(view, &rng)
    }

    /// Calls any raise or re-raise holding a small pair, whatever is left behind to win.
    public static let setMinesEverything = PlantedPlayer(name: "calls every raise with a small pair", leakID: "set-mine-no-odds") { view, rng in
        if view.street == .preflop, view.toCall > 0, view.legal.canCall, view.preflopRaiseCount >= 1 {
            let hand = HandClass(view.holeCards)
            if hand.isPair, hand.high <= .nine { return .call }
        }
        return solid(view, &rng)
    }

    /// Folds every small pair and every pair of cards that fit together to a single raise, however
    /// much money there is behind to win.
    public static let foldsSpeculativeHands = PlantedPlayer(name: "folds the hands the stacks pay for", leakID: "missed-implied-odds") { view, rng in
        if view.street == .preflop, view.toCall > 0, view.preflopRaiseCount == 1, view.position != .bigBlind {
            let hand = HandClass(view.holeCards)
            let smallPair = hand.isPair && hand.high <= .nine
            let fitting = hand.suited && hand.gap <= 1 && hand.high <= .jack && hand.low >= .five
            if smallPair || fitting { return .fold }
        }
        return solid(view, &rng)
    }

    /// Sound baseline play — the control. This one has no planted flaw.
    public static func solid(_ view: DecisionView, _ rng: inout SeededRandom) -> PlayerAction {
        var bot = Bot(id: "control", personality: .solidRegular, skill: 88, seed: rng.next())
        // At a cash table the control plays the money behind, as any sound cash player does.
        bot.playsImpliedOdds = LeakCatalogue.format == .cash
        return bot.decide(view)
    }

    public static let control = PlantedPlayer(name: "no planted flaw", leakID: "") { view, rng in
        solid(view, &rng)
    }

    /// Sound everywhere except one seat, where it opens far too many hands. The planted player for
    /// the seat diagnosis: a flaw that lives in a seat rather than in a situation.
    public static func badInSeat(_ seat: Position) -> PlantedPlayer {
        PlantedPlayer(name: "plays badly from \(seat.fullName)", leakID: "too-many-hands") { view, rng in
            if view.position == seat, view.street == .preflop, view.isUnopenedPot,
               view.legal.canBetOrRaise, PreflopTable.percentile(view.holeCards) <= 0.85 {
                let to = min(view.legal.maxRaiseTo, max(view.legal.minRaiseTo, view.bigBlind * 2))
                return view.legal.canBet ? .bet(to: to) : .raise(to: to)
            }
            return solid(view, &rng)
        }
    }

    // MARK: Fixed-limit habits — see `CoachFormat.limit`

    /// Folds any flop or turn bet without two pair, draws included.
    public static let foldsDrawsForOneBet = PlantedPlayer(name: "folds a draw to one bet", leakID: "limit-fold-to-price") { view, rng in
        if view.street == .flop || view.street == .turn, view.toCall > 0 {
            let read = HandReader.assess(hole: view.holeCards, board: view.board, opponents: view.opponentCount)
            if read.category < .twoPair { return .fold }
        }
        return solid(view, &rng)
    }

    /// Never raises after the flop: every raise the sound player would make becomes a call.
    public static let neverRaisesPostflop = PlantedPlayer(name: "calls with the best hand", leakID: "limit-missed-value-raise") { view, rng in
        let action = solid(view, &rng)
        if view.street != .preflop, view.toCall > 0, case .raise = action, view.legal.canCall { return .call }
        return action
    }

    /// Calls one raise cold with most of what it is dealt.
    public static let coldCallsRaises = PlantedPlayer(name: "calls two bets cold", leakID: "limit-cold-call") { view, rng in
        if view.street == .preflop, view.preflopRaiseCount == 1, view.position != .bigBlind, view.legal.canCall,
           PreflopTable.percentile(view.holeCards) <= 0.55 {
            return .call
        }
        return solid(view, &rng)
    }

    /// Folds the river to one bet without two pair.
    public static let foldsRiverToOneBet = PlantedPlayer(name: "folds the river to one bet", leakID: "limit-river-overfold") { view, rng in
        if view.street == .river, view.toCall > 0 {
            let read = HandReader.assess(hole: view.holeCards, board: view.board, opponents: view.opponentCount)
            if read.category < .twoPair { return .fold }
        }
        return solid(view, &rng)
    }

    /// Calls the last bet with anything at all.
    public static let callsRiverWithNothing = PlantedPlayer(name: "calls the last bet with nothing", leakID: "limit-call-down-nothing") { view, rng in
        if view.street == .river, view.toCall > 0, view.legal.canCall { return .call }
        return solid(view, &rng)
    }

    /// Never raises a draw on the flop, however well placed.
    public static let neverRaisesDraws = PlantedPlayer(name: "never raises for the free card", leakID: "limit-no-free-card") { view, rng in
        if view.street == .flop, view.toCall > 0, view.legal.canCall {
            let read = HandReader.assess(hole: view.holeCards, board: view.board, opponents: view.opponentCount)
            if read.outs >= 8, read.category < .twoPair { return .call }
        }
        return solid(view, &rng)
    }

    /// Puts in another bet before the flop with anything it would have played.
    public static let capsEverything = PlantedPlayer(name: "caps with anything", leakID: "limit-cap-light") { view, rng in
        if view.street == .preflop, view.preflopRaiseCount >= 2, view.legal.canRaise,
           PreflopTable.percentile(view.holeCards) <= 0.50 {
            return .raise(to: view.legal.minRaiseTo)
        }
        return solid(view, &rng)
    }
}

/// Runs a planted player through a table of bots and reports what the coach concluded.
/// - Parameter keepEveryReview: hold on to every hand rather than the last few hundred, so a sweep
///   can walk the decisions in the order they happened. What a habit measures at the end of a long
///   run is not the question a player cares about — they see the list at every point along the way,
///   and a habit named at hand 300 and gone by hand 4,000 was still named.
public func runPlanted(_ player: PlantedPlayer, hands: Int, seed: UInt64,
                       keepEveryReview: Bool = false) -> Coach {
    let coach = Coach()
    if keepEveryReview { coach.maxReviewsHeld = Int.max }
    let level = BlindLevel(smallBlind: 50, bigBlind: 100)
    var villains: [Bot] = []
    for i in 1..<6 {
        villains.append(Bot(id: "V\(i)", personality: .solidRegular, skill: 60, seed: seed &+ UInt64(i) &* 131))
    }
    let table = BotTable(bots: villains, seed: seed, humanSeat: 0)
    var rng = SeededRandom(seed: seed &* 13 &+ 1)

    for hand in 0..<hands {
        var seats = [SeatState(seat: 0, playerID: "hero", stack: 10_000)]
        seats += villains.enumerated().map { SeatState(seat: $0.offset + 1, playerID: $0.element.id, stack: 10_000) }
        let engine = HandEngine(seats: seats, config: HandConfig(level: level, buttonSeat: hand % 6, seed: rng.next()))

        var steps = 0
        while !engine.isComplete {
            table.play(engine, observe: false)
            guard !engine.isComplete, engine.actor == 0, let view = engine.view(for: 0) else { break }
            let action = player.decide(view, &rng)
            coach.judge(view, action: action)
            try! engine.apply(action)
            steps += 1
            precondition(steps < 60, "hand did not terminate")
        }
        coach.finishHand(engine: engine, heroSeat: 0, handNumber: hand)
    }
    return coach
}

/// Runs a planted player through the app's own cash table — nine handed, the rung's roster,
/// stacks that carry over, rebuys — and reports what the coach concluded.
///
/// The six-handed harness above is the bar-setting rig for the tournament coach and stays so.
/// The cash coach's bars come from here instead, because the questions it asks are about the
/// money behind, and a rig that resets everybody to a hundred blinds every hand never has any
/// more or less of it. `rung` picks the room; the sweep runs the bottom one, where every player
/// starts, and the top one, where the opponents stop giving money away.
public func runPlantedCash(_ player: PlantedPlayer, hands: Int, seed: UInt64,
                           setup: CashSetup = .standard, rung: Int = 0,
                           keepEveryReview: Bool = false) -> Coach {
    let coach = Coach()
    if keepEveryReview { coach.maxReviewsHeld = Int.max }
    let session = TableSession(coach: coach, seed: seed, history: .inMemory(), variant: .pokerTutorCash)
    session.cashSetup = setup
    session.forcedTier = LadderTier.cashRungs[min(rung, LadderTier.cashRungs.count - 1)]
    var rng = SeededRandom(seed: seed &* 13 &+ 7)
    var steps = 0
    session.start(.cash)
    while session.history.hands.count < hands {
        steps += 1
        precondition(steps < 900_000, "the cash session did not progress")
        if !session.isPlaying { session.start(.cash); continue }
        if session.heroToAct, let view = session.engine?.view(for: session.heroSeat) {
            session.act(player.decide(view, &rng))
        } else if session.engine?.isComplete == true {
            session.continueAfterReview()
        } else {
            session.stepOpponent()
        }
    }
    session.leave()
    return coach
}

/// Runs a planted player through the app's own tournament table — nine handed, the rung's
/// roster, a tournament restarted whenever it ends — and hands back the session, whose history
/// holds every hand as it was written down. That is what the seat diagnosis is built from, so it
/// is what its bars are swept on.
public func runPlantedTournament(_ player: PlantedPlayer, hands: Int, seed: UInt64,
                                 rung: Int = 0) -> TableSession {
    let coach = Coach()
    let session = TableSession(coach: coach, seed: seed, history: .inMemory(), variant: .pokerTutor)
    session.forcedTier = LadderTier.tournamentRungs[min(rung, LadderTier.tournamentRungs.count - 1)]
    var rng = SeededRandom(seed: seed &* 13 &+ 7)
    var steps = 0
    session.start(.tournament)
    while session.history.hands.count < hands {
        steps += 1
        precondition(steps < 2_000_000, "the tournament session did not progress")
        if !session.isPlaying { session.start(.tournament); continue }
        if session.heroToAct, let view = session.engine?.view(for: session.heroSeat) {
            session.act(player.decide(view, &rng))
        } else if session.engine?.isComplete == true {
            session.continueAfterReview()
        } else {
            session.stepOpponent()
        }
    }
    session.leave()
    return session
}

/// Runs `body` with the process set to the cash product, and puts the tournament back after.
public func withCashProduct<T>(_ body: () throws -> T) rethrows -> T {
    let before = AppVariant.current
    AppVariant.use(.pokerTutorCash)
    defer { AppVariant.use(before) }
    return try body()
}

func coachTests(_ t: TestRun) {
    cashCoachTests(t)

    t.suite("the coach finds planted leaks") { t in

        // This is the test that decides whether the product works. Each player below has exactly
        // one deliberate flaw. The coach must name that flaw, and must not invent others — a coach
        // that cries wolf is worse than no coach, because the player stops believing the true ones.
        //
        // The two halves used to be tested together, one habit at a time. They are split now, and
        // the split is what caught the last three faults. Each planted player is run at the sample
        // its habit's floor actually needs, which is different for every one of them — 420 hands
        // for the loud ones, twelve thousand for the quiet ones — while the "and nobody else"
        // half is one sweep of the whole catalogue against a player with no flaw at all, at the
        // bottom of this suite. Testing the two together meant the control half was only ever
        // checked on the one habit the test was about.

        for player in [PlantedPlayer.limpsEverything, .playsEverything] {
            t.test("it catches a player who \(player.name)") {
                // Loud habits: every unopened pot is a chance, so 420 hands is plenty.
                let coach = runPlanted(player, hands: 420, seed: 4242)
                let found = coach.profile.confirmed().map(\.leak.id)
                t.expect(found.contains(player.leakID),
                         "expected to find '\(player.leakID)' in a player who \(player.name); found \(found)")
            }
        }

        t.test("it catches a player who folds the big blind, at the sample that takes") {
            // This one used to sit in the loop above at 420 hands, and it passed there against a
            // bar of 18% over 25 chances — a bar the control player crosses on his way past, ten
            // sittings in sixteen. The bar is 30% over 50 chances now (the sweep is in
            // `LeakCatalogue`), which is about 430 hands of big blind defence, so the test asks at
            // a thousand. A player who folds every big blind sits at roughly 53%.
            for seed in [UInt64(4_242), 512] {
                let coach = runPlanted(.foldsBigBlind, hands: 1_000, seed: seed)
                t.expect(coach.profile.confirmed().map(\.leak.id).contains("bb-overfold"),
                         "seed \(seed): missed a player who folds every big blind; found "
                         + "\(coach.profile.confirmed().map(\.leak.id))")
            }
        }

        t.test("it catches a player who folds to every flop bet, at the sample that takes") {
            // Same story, same fix: 20% over 14 chances named three flawless sittings in sixteen,
            // and the bar is 30% over 25 now. A flop bet comes at the player about once in
            // twenty-five hands, so 25 chances is roughly 660 of them and the test asks at 1,500.
            // The flawed player sits at 48-58%, so there is nothing marginal about the catch.
            for seed in [UInt64(4_242), 512] {
                let coach = runPlanted(.foldsToEveryFlopBet, hands: 1_500, seed: seed)
                t.expect(coach.profile.confirmed().map(\.leak.id).contains("fold-to-cbet"),
                         "seed \(seed): missed a player who folds to every flop bet; found "
                         + "\(coach.profile.confirmed().map(\.leak.id))")
            }
        }

        t.test("it catches a player who never bets the river, at the sample that takes") {
            // The bar here — 30% — survived the second sweep. The floor did not: reading the
            // running rate rather than the finishing one, a flawless player reaches 41% with 25
            // chances behind him and only settles under 25% past 200. So the floor is 200 river
            // decisions, which is around six thousand hands, and the test asks at eight.
            for seed in [UInt64(4_242), 512] {
                let flawed = runPlanted(.neverValueBetsRiver, hands: 8_000, seed: seed)
                t.expect(flawed.profile.confirmed().map(\.leak.id).contains("missed-river-value"),
                         "seed \(seed): missed a player who never bets the river; found "
                         + "\(flawed.profile.confirmed().map(\.leak.id))")
            }
        }

        t.test("it catches a player who pays off every river bet, at the sample that takes") {
            // The third habit found sitting inside the band of the player it existed to clear, and
            // the one that needed a planted counterpart building before anything could be said: a
            // bar can only be set by measuring the player who has the habit against the player who
            // does not. Control 15-27% at 8,000 hands, a player who calls every river bet 34-56%,
            // so the bar goes at 30% — and the floor at 150, because the running rate of a
            // flawless player is still touching 29% with a hundred chances behind it.
            for seed in [UInt64(4_242), 512] {
                let flawed = runPlanted(.callsEveryRiverBet, hands: 12_000, seed: seed)
                t.expect(flawed.profile.confirmed().map(\.leak.id).contains("river-hero-call"),
                         "seed \(seed): missed a player who calls every river bet; found "
                         + "\(flawed.profile.confirmed().map(\.leak.id))")
            }
        }

        t.test("it catches a player who never bets the turn, at the sample that takes") {
            // The most expensive of the four to measure. Below about 250 turn decisions the two
            // populations overlap so heavily that no bar exists, so until then the habit sits
            // under "too soon to call" with its count on show and no accusation attached. At 4,000
            // hands the player who checks every turn he is allowed to check sits at 25-33% across
            // twenty seeds and the sound one at 13-21%, so 24% separates them.
            for seed in [UInt64(4_242), 512] {
                let flawed = runPlanted(.neverValueBetsTurn, hands: 4_000, seed: seed)
                t.expect(flawed.profile.confirmed().map(\.leak.id).contains("missed-turn-value"),
                         "seed \(seed): missed a player who never bets the turn; found "
                         + "\(flawed.profile.confirmed().map(\.leak.id))")
            }
        }

        t.test("a player with no flaw is named for nothing, anywhere in the catalogue") {
            // This replaces "it does not invent leaks in a player who has none", which allowed one
            // named habit at 420 hands and called that a measured position. It was not enough on
            // three counts, and each one hid a real fault.
            //
            // It read one seed. It read the profile only at the end of the run, so a habit named at
            // hand three hundred and gone again by hand four thousand never showed up — and a
            // player reads the list at every point in an evening, not only at the end of it. And
            // 420 hands is under the floor of half the catalogue, so the habits it could not reach
            // were passing by never being asked. That last one is exactly how the river-call bar
            // survived three reviews: the gate ran 320 hands, the floor was 14, and the chances
            // came in at 10 to 13.
            //
            // So this runs the control player long enough for every floor in the catalogue to be
            // cleared, replays his decisions in the order they happened, and applies the coach's
            // own three tests after every one. Anything named at any point is a bar sitting inside
            // the band of the player it exists to clear. Three seeds, because one seed is a story.
            //
            // What it does not cover, said out loud rather than left to be discovered: the two
            // short-stack habits never come up at all here, because this table is a hundred big
            // blinds deep from the first hand to the last. Their bars have not been swept.
            var everNamed: [String] = []
            var floorsCleared = 0
            var floorsMissed: [String] = []
            for seed in [UInt64(4_242), 512, 77] {
                let coach = runPlanted(.control, hands: 14_000, seed: seed, keepEveryReview: true)
                let sweep = NamingSweep(coach)
                for moment in sweep.named {
                    everNamed.append("\(moment.leak.id) at \(moment.times)/\(moment.chances) "
                                     + "(seed \(seed))")
                }
                for leak in LeakCatalogue.nameable {
                    let chances = sweep.chances[leak.id] ?? 0
                    guard chances > 0 else { continue }
                    if chances >= leak.minimumSample { floorsCleared += 1 }
                    else { floorsMissed.append("\(leak.id) (\(chances) of \(leak.minimumSample))") }
                }
            }
            t.expect(everNamed.isEmpty,
                     "a player with no planted flaw was told he has a habit: "
                     + "\(everNamed.joined(separator: "; "))")
            // And the silence has to be a measurement rather than an abstention. If a habit never
            // gathered enough chances to be judged, this test says nothing about its bar, and the
            // whole point is that it should not be able to pass by looking away.
            t.expect(floorsMissed.isEmpty,
                     "these habits were never judged at all, so their bars are untested here: "
                     + "\(Set(floorsMissed).sorted().joined(separator: ", "))")
            t.greaterThan(floorsCleared, 0, "nothing was judged, so nothing above was checked")
        }

        t.test("a planted leak is the worst thing it finds") {
            // Not just present in the list — at or near the top of it, ranked by what it costs.
            // Each one is asked at the sample its own habit's floor needs: the big blind bar takes
            // fifty chances at the habit now, which 420 hands does not produce.
            for (player, hands) in [(PlantedPlayer.foldsBigBlind, 1_000),
                                    (.limpsEverything, 420), (.playsEverything, 420)] {
                let coach = runPlanted(player, hands: hands, seed: 99)
                let ranked = coach.profile.confirmed().map(\.leak.id)
                guard let position = ranked.firstIndex(of: player.leakID) else {
                    t.expect(false, "'\(player.leakID)' missing entirely for a player who \(player.name)")
                    continue
                }
                t.lessThan(position, 2, "'\(player.leakID)' should be near the top for a player who \(player.name), was #\(position + 1) of \(ranked.count)")
            }
        }

        t.test("a flawed player rates below a sound one") {
            // Measured, not assumed: at 420 hands this comparison is a coin flip — a sweep of ten
            // seeds got it right five times on one build and six on the next, which is noise
            // wearing a number. The rating separates the two cleanly by four thousand hands. That
            // is a fact about poker, not a fault in the rating: it is built from what mistakes
            // cost, and a few hundred hands do not contain enough mistakes to be sure.
            //
            // Several seeds, because one seed is a story rather than a measurement.
            var correct = 0
            var flawedTotal = 0
            var soundTotal = 0
            let seeds: [UInt64] = [7, 99, 137]

            for seed in seeds {
                let flawed = runPlanted(.foldsBigBlind, hands: 4_000, seed: seed).rating.value
                let sound = runPlanted(.control, hands: 4_000, seed: seed).rating.value
                if flawed < sound { correct += 1 }
                flawedTotal += flawed
                soundTotal += sound
            }

            t.equal(correct, seeds.count,
                    "the rating should separate a player with a known flaw from one without")
            t.lessThan(flawedTotal / seeds.count + 10, soundTotal / seeds.count,
                       "the gap should be a gap, not a rounding error")
        }
    }

    t.suite("the coach knows when to keep quiet") { t in

        t.test("it says nothing about decisions it is unsure of") {
            let coach = runPlanted(.control, hands: 200, seed: 3)
            let spoken = coach.reviews.flatMap(\.worthMentioning)
            let judged = coach.profile.decisionsJudged
            t.greaterThan(judged, 200, "it should be judging plenty of decisions")
            let rate = Double(spoken.count) / Double(max(1, judged))
            t.lessThan(rate, 0.35, "flagging more than a third of a sound player's decisions is crying wolf")
        }

        t.test("every verdict it speaks carries a reason and a cost") {
            let coach = runPlanted(.foldsBigBlind, hands: 200, seed: 11)
            let spoken = coach.reviews.flatMap(\.worthMentioning)
            t.greaterThan(spoken.count, 5, "the planted player should generate flagged decisions")
            for verdict in spoken.prefix(40) {
                t.expect(!verdict.headline.isEmpty, "a spoken verdict must have a headline")
                t.expect(!verdict.detail.isEmpty, "a spoken verdict must explain itself")
                t.greaterThan(verdict.costInBB, 0, "a spoken verdict must cost something")
                t.greaterThan(verdict.confidence, DecisionEvaluator.confidenceThreshold - 0.0001,
                              "a spoken verdict must clear the confidence bar")
            }
        }

        t.test("a hand review names at most two decisions") {
            let coach = runPlanted(.playsEverything, hands: 150, seed: 5)
            t.greaterThan(coach.reviews.count, 0, "there were no reviews to look at")
            for review in coach.reviews {
                t.expect(review.worthMentioning.count <= 2, "a review should not be a lecture")
            }
        }

        t.test("a long sitting reports every mistake it saw, not every one it still holds") {
            // `reviews` is a window on the last few hundred hands and nothing more — memory has to
            // stop somewhere, and the store keeps the full history anyway. The session report
            // counted its mistakes off that window and its hands off the profile, which is not
            // capped, so past the cap it quietly reported fewer mistakes than it had seen: the cost
            // per hundred beside them went on climbing while the count meant to explain it fell.
            let coach = runPlanted(.foldsBigBlind, hands: 700, seed: 11)
            let report = coach.sessionReport()
            t.equal(coach.reviews.count, coach.maxReviewsHeld, "the cap did not come into it")
            t.equal(report.hands, 700)
            let held = coach.reviews.flatMap(\.worthMentioning).count
            t.greaterThan(held, 0, "the planted player made no mistakes at all, so this proves nothing")
            t.greaterThan(report.mistakes, held,
                          "the report is counting the \(held) mistakes it still holds rather than "
                          + "the \(report.mistakes) it saw")
            t.expect(report.biggest != nil, "the dearest decision went out with the window")
            t.expect(report.headline.contains("\(report.mistakes) decision"),
                     "the headline prints a different count again: \"\(report.headline)\"")
        }

        t.test("staying quiet is not the same as saying nothing was there") {
            // The coach's restraint stands: it stops the game only for a mistake worth stopping it
            // over. What it may not do is turn that silence into a claim. A player whose named
            // habit is one of the cheap ones — limping, playing too many hands — used to open the
            // review on one of the hands the habit was counted from and read "Nothing wrong with
            // how you played that one".
            let coach = runPlanted(.limpsEverything, hands: 300, seed: 7)
            let quietButCounted = coach.reviews.filter {
                $0.playedWell && !$0.countedQuietly.isEmpty
            }
            t.greaterThan(quietButCounted.count, 0,
                          "a player who limps every hand produced no quiet counted decisions, so "
                          + "this test proves nothing")
            for review in quietButCounted.prefix(20) {
                t.expect(!review.summary.contains("Well played"),
                         "the review praised a hand a habit was counted from: \(review.summary)")
                t.expect(!review.summary.contains("Nothing wrong"), review.summary)
                t.expect(review.summary.contains("counts") || review.summary.contains("count"),
                         review.summary)
                // And nothing here makes it interrupt: the hand is still one that deals on.
                t.expect(review.worthMentioning.isEmpty)
            }
            // A hand with nothing in it at all still gets the short answer.
            let silent = coach.reviews.filter { $0.playedWell && $0.countedQuietly.isEmpty }
            t.greaterThan(silent.count, 0, "there were no genuinely quiet hands to check")
            for review in silent.prefix(20) {
                t.expect(review.summary.contains("Well played")
                         || review.summary.contains("Nothing wrong"), review.summary)
            }
        }

        t.test("the counting bar reads the same live as it does off disk") {
            // The rule is written down twice — once on `Verdict`, which the table reads, and once on
            // `VerdictRecord`, which reads it back off a file where an older hand may carry no
            // confidence at all. Two copies drift, so this holds them to the same answer on every
            // verdict a real sitting produced.
            let coach = runPlanted(.playsEverything, hands: 200, seed: 5)
            var checked = 0
            for review in coach.reviews {
                for verdict in review.verdicts {
                    let record = VerdictRecord(verdict)
                    t.equal(record.countsTowardsLeak, verdict.countsTowardsLeak,
                            "\(verdict.severity.name), confidence \(verdict.confidence)")
                    checked += 1
                }
            }
            t.greaterThan(checked, 100, "no verdicts were compared, so this asserted nothing")
        }

        t.test("nothing is said without a big enough sample") {
            let coach = runPlanted(.foldsBigBlind, hands: 8, seed: 5)
            t.equal(coach.profile.confirmed().count, 0,
                    "eight hands is not enough to tell anyone what is wrong with their game")
        }
    }

    t.suite("reference ranges") { t in

        t.test("late position plays more hands than early") {
            let open = { (p: Position) in ReferenceRanges.open(position: p, players: 9) }
            t.lessThan(open(.underTheGun), open(.middle))
            t.lessThan(open(.middle), open(.cutoff))
            t.lessThan(open(.cutoff), open(.button))
            t.greaterThan(open(.button), 0.35, "the button opens a lot of hands")
            t.lessThan(open(.underTheGun), 0.16, "under the gun opens few")
        }

        t.test("the big blind defends far wider than anyone calls elsewhere") {
            let defend = ReferenceRanges.defendBigBlind(versus: .button, players: 9)
            let call = ReferenceRanges.callVersusOpen(from: .middle, versus: .button, players: 9)
            t.greaterThan(defend, call * 1.5, "the big blind is already invested and getting a price")
            t.greaterThan(defend, 0.4)
        }

        t.test("shorter stacks move in wider") {
            let shove = { (d: Double) in ReferenceRanges.shove(depth: d, position: .button, players: 6, facingRaise: false) }
            t.greaterThan(shove(3), shove(6))
            t.greaterThan(shove(6), shove(10))
            t.lessThan(shove(12), shove(4))
        }

        t.test("facing a raise narrows everything") {
            let open = ReferenceRanges.shove(depth: 8, position: .button, players: 6, facingRaise: false)
            let facing = ReferenceRanges.shove(depth: 8, position: .button, players: 6, facingRaise: true)
            t.lessThan(facing, open)
        }

        t.test("ranges widen as the table shortens") {
            t.lessThan(ReferenceRanges.open(position: .cutoff, players: 9),
                       ReferenceRanges.open(position: .cutoff, players: 3))
        }
    }

    t.suite("reading an opponent's range") { t in

        t.test("a raise narrows a range, a check barely does") {
            let board = [Card].parse("Kh 8s 3c")
            let full = HandRange.everything(excluding: board)
            let strong = full.strongest(0.3, board: board)
            t.lessThan(strong.count, full.count)
            t.greaterThan(strong.count, 0)
            // The kept hands really are the strong ones.
            let kept = strong.rankedByStrength(board: board)
            let best = HandEvaluator.evaluate(kept.first!.0.cards + board)
            let worstKept = HandEvaluator.evaluate(kept.last!.0.cards + board)
            t.greaterThan(best.value, worstKept.value)
            t.greaterThan(worstKept.category.rawValue, HandRank.Category.highCard.rawValue,
                          "the top 30% on this board should all have at least a pair")
        }

        t.test("equity against a strong range is lower than against everything") {
            var rng = SeededRandom(seed: 5)
            let hole = [Card].parse("Ah Kd")
            let board = [Card].parse("Kh 8s 3c")
            let everything = HandRange.everything(excluding: hole + board)
            let strong = everything.strongest(0.15, board: board)
            let vsAll = RangeEquity.equity(hole: hole, board: board, versus: everything, rng: &rng)
            let vsStrong = RangeEquity.equity(hole: hole, board: board, versus: strong, rng: &rng)
            t.greaterThan(vsAll, 0.7, "top pair beats a random hand most of the time")
            t.lessThan(vsStrong, vsAll, "and much less often against hands that would keep betting")
        }

        t.test("a range never contains cards we can see") {
            let hole = [Card].parse("Ah Kd")
            let board = [Card].parse("Kh 8s 3c")
            let range = HandRange.everything(excluding: hole + board)
            for combo in range.weights.keys {
                for card in hole + board {
                    t.expect(!combo.blocks(card), "\(combo.a)\(combo.b) uses a card that is already visible")
                }
            }
        }
    }

    t.suite("the rating") { t in

        t.test("losing less money rates higher") {
            let clean = SkillRating(costPer100: 0, hands: 500)
            let leaky = SkillRating(costPer100: 8, hands: 500)
            let awful = SkillRating(costPer100: 30, hands: 500)
            t.greaterThan(clean.value, leaky.value)
            t.greaterThan(leaky.value, awful.value)
            // The line is gentler than it was (see Coach.swift): thirty big blinds a hundred with
            // nothing confirmed is "Improving", and the floor is reached by cost and habits together.
            t.greaterThan(clean.value, 80)
            t.lessThan(awful.value, 35)
        }

        // The scale is anchored on what `pokerlab calibrate` measured over tournament play, and
        // these are the two ends of it. They are here so that changing the arithmetic without
        // re-measuring fails, which is how the first version came to have a top nobody could
        // reach: it was anchored on the coach harness, and the app ships tournaments.
        t.test("the scale lands where the measurements put it") {
            // A player with no deliberate flaw measured 9.8 bb/100 over eight seeds (2026-09-11),
            // with nothing confirmed against it.
            let reference = SkillRating(costPer100: 9.8, hands: 3000)
            t.equal(reference.label, "Strong amateur",
                    "the flawless reference player rates \(reference.value) — \(reference.label)")
            t.expect((62...70).contains(reference.value),
                     "the reference player landed at \(reference.value), not near 66")

            // The worst of the scripted players — plays far too many hands — measured 32.4, with
            // its habit confirmed and the penalty at its ceiling.
            let worst = SkillRating(costPer100: 32.4, hands: 3000,
                                    leakPenalty: LeakProfile.maximumLeakPenalty)
            t.equal(worst.label, "Beginner")
            t.expect((5...20).contains(worst.value), "the worst player landed at \(worst.value)")

            // And the top is reachable, but only by somebody better than any player here.
            t.equal(SkillRating(costPer100: 2, hands: 3000).label, "Low pro")
            t.greaterThan(SkillRating(costPer100: 9.8, hands: 3000).value,
                          SkillRating(costPer100: 10.8, hands: 3000).value,
                          "one cheap habit should cost something, but not everything")
        }

        t.test("confirmed habits cannot sink anyone through the floor") {
            // Each confirmed habit used to take up to twenty points on top of the money it had
            // already cost, and the scale had been anchored as if nobody carried any — so three
            // confirmed habits put a real player at zero. The penalty has a ceiling now and the
            // anchors were measured with it in.
            t.equal(LeakProfile.maximumLeakPenalty, 15)
            let floor = SkillRating(costPer100: 32.4, hands: 3000,
                                    leakPenalty: LeakProfile.maximumLeakPenalty)
            t.greaterThan(floor.value, 4, "the worst scripted player, every habit confirmed, rates \(floor.value)")
            t.greaterThan(SkillRating(costPer100: 9.8, hands: 3000).value, 59)

            // And at the app's own tables, over two seeds of tournament play, the control never
            // drops out of "solid amateur". Single runs of a thousand hands swing several big
            // blinds either way (the eight-seed bands are in Coach.swift), so the bar is the
            // bottom of the band, not the mean.
            for seed: UInt64 in [3, 5] {
                let history = HistoryStore.inMemory()
                let session = TableSession(seed: seed, history: history)
                var rng = SeededRandom(seed: seed &* 13 &+ 7)
                let player = PlantedPlayer.control
                var steps = 0
                session.start(.tournament)
                while history.hands.count < 1000, steps < 300_000 {
                    steps += 1
                    if !session.isPlaying { session.start(.tournament); continue }
                    if session.heroToAct, let view = session.engine?.view(for: session.heroSeat) {
                        session.act(player.decide(view, &rng))
                    } else if session.engine?.isComplete == true {
                        session.continueAfterReview()
                    } else {
                        session.stepOpponent()
                    }
                }
                let rating = LeakScorecard(of: history.hands).rating
                t.greaterThan(rating.value, 44,
                              "control seed \(seed): rated \(rating.value) off \(rating.costPer100) bb/100")
            }
        }

        t.test("it does not pretend to know from a handful of hands") {
            let early = SkillRating(costPer100: 0, hands: 10)
            let settled = SkillRating(costPer100: 0, hands: 500)
            t.lessThan(early.value, settled.value, "ten good hands should not make anyone an expert")
            t.greaterThan(early.value, 40, "nor should it call them a beginner")
        }

        t.test("it says it is still settling for exactly as long as it is being held back") {
            // The screen's caveat used to come off a hundred hands before the pulling stopped, 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. The number owns the bar now and the screen reads
            // `isSettled`, so the two cannot drift apart again.
            let bar = SkillRating.handsToSettle
            t.equal(bar, 200, "200 hands is the bar the rest of the app uses")
            t.expect(!SkillRating(costPer100: 0, hands: bar - 50).isSettled)
            t.expect(SkillRating(costPer100: 0, hands: bar).isSettled)

            let held = SkillRating(costPer100: 0, hands: bar - 50)
            let settled = SkillRating(costPer100: 0, hands: bar)
            t.lessThan(held.value, settled.value,
                       "the rating is not being pulled towards the middle at \(bar - 50) hands, so "
                       + "the caveat at that point would be saying nothing")
            t.equal(settled.value, SkillRating(costPer100: 0, hands: 5_000).value,
                    "the rating is still being held back past the point the screen stops saying so")
        }

        t.test("it sets the table slightly above the player") {
            let rating = SkillRating(costPer100: 5, hands: 400)
            t.greaterThan(rating.tableDifficulty, rating.value - 1, "tables should not be easier than the player")
        }
    }
}


/// The cash coach: a second edition of the catalogue, and the three habits only a deep stack has.
/// Runs `body` with the process set to the cash product at a fixed-limit table, and puts the
/// tournament back after.
public func withLimitTable<T>(_ body: (CashSetup) throws -> T) rethrows -> T {
    try withCashProduct {
        let setup = CashSetup(stakes: CashSetup.stakesOnOffer[0], structure: .fixedLimit)
        LeakCatalogue.format = .limit
        defer { LeakCatalogue.format = .cash }
        return try body(setup)
    }
}

func limitCoachTests(_ t: TestRun) {
    t.suite("the limit coach") { t in

        t.test("the limit catalogue drops the bet sizes and the stacks and adds the price") {
            let limit = LeakCatalogue.limit.map(\.id)
            for gone in ["stacked-off-one-pair", "set-mine-no-odds", "missed-implied-odds", "bet-sizing",
                         "station-flop", "fold-to-cbet", "river-hero-call", "river-overfold",
                         "call-too-wide-preflop", "no-shove", "shove-too-wide"] {
                t.expect(!limit.contains(gone), "\(gone) has no business at a fixed-limit table")
            }
            for id in LeakCatalogue.limitOnly.map(\.id) {
                t.expect(limit.contains(id), "\(id) is missing from the limit edition")
            }
            t.equal(Set(limit).count, limit.count, "two limit habits share an id")
            var seen: Set<String> = []
            for leak in LeakCatalogue.limit {
                let key = "\(leak.spot)/\(leak.error)"
                t.expect(!seen.contains(key), "two limit habits answer to \(key)")
                seen.insert(key)
            }
            t.equal(LeakCatalogue.all(for: .limit).map(\.id), limit)
            // The free card is its own situation at a limit table and nowhere else; the whole
            // stack never goes in on one street there.
            t.expect(SpotKind.measured(for: .limit).contains(.freeCardChance))
            t.expect(!SpotKind.measured(for: .limit).contains(.deepStackOff))
            t.expect(!SpotKind.measured(for: .limit).contains(.shortStackShove))
            t.expect(!SpotKind.measured(for: .cash).contains(.freeCardChance))
            t.expect(!SpotKind.measured(for: .tournament).contains(.freeCardChance))
        }

        t.test("a fixed-limit sitting judges as a limit coach, and a no-limit one as a cash coach") {
            withCashProduct {
                let session = TableSession(seed: 3, history: .inMemory(), variant: .pokerTutorCash)
                session.cashSetup = CashSetup(stakes: CashSetup.stakesOnOffer[0], structure: .fixedLimit)
                session.start(.cash)
                t.equal(session.coach.format, .limit)
                t.equal(LeakCatalogue.format, .limit)
                t.equal(session.bettingStructure, .fixedLimit)
                session.leave()
                session.cashSetup = CashSetup(stakes: CashSetup.stakesOnOffer[0], structure: .noLimit)
                session.start(.cash)
                t.equal(session.coach.format, .cash)
                t.equal(LeakCatalogue.format, .cash)
                session.leave()
                // The lessons, the setups and the rating line follow the same switch.
                t.equal(Basics.lessons(for: .limit).map(\.id).contains("limit-price"), true)
                t.expect(!Basics.lessons(for: .limit).map(\.id).contains("money-behind"))
                t.expect(!AssessmentPlanner.setups(for: .limit).map(\.id).contains("deep"))
                t.expect(AssessmentPlanner.setups(for: .limit).map(\.id).contains("big-blind"))
            }
            t.equal(LeakCatalogue.format, .tournament)
        }

        // The limit habits, each measured against the player who has it and the player who does
        // not, at the app's own fixed-limit table. Short runs — the bars come from the sweep in
        // `Leaks.swift`; these say the coach can tell the two apart at all.
        func charged(_ player: PlantedPlayer, _ id: String, hands: Int, seeds: [UInt64],
                     setup: CashSetup) -> (flawed: Int, clean: Int, flawedChances: Int) {
            var flawed = 0, clean = 0, chances = 0
            for seed in seeds {
                let a = runPlantedCash(player, hands: hands, seed: seed, setup: setup)
                let b = runPlantedCash(.control, hands: hands, seed: seed, setup: setup)
                flawed += a.profile.records[id]?.mistakes ?? 0
                chances += a.profile.records[id]?.opportunities ?? 0
                clean += b.profile.records[id]?.mistakes ?? 0
            }
            return (flawed, clean, chances)
        }

        t.test("the coach charges the player who folds a draw to one bet, and not the control") {
            withLimitTable { setup in
                // A sound limit player still folds a draw now and then — the sweep put the control
                // at 5-10% against the flawed player's 68-84% — so the bar here is a multiple.
                let r = charged(.foldsDrawsForOneBet, "limit-fold-to-price", hands: 600, seeds: [11, 23], setup: setup)
                t.greaterThan(r.flawed, r.clean * 2 + 5,
                              "folding a draw was charged \(r.flawed) times against \(r.clean) for the control")
            }
        }

        t.test("the coach charges the player who calls the last bet with nothing") {
            withLimitTable { setup in
                let r = charged(.callsRiverWithNothing, "limit-call-down-nothing", hands: 900, seeds: [5, 17], setup: setup)
                t.greaterThan(r.flawed, r.clean * 2 + 3,
                              "calling with nothing was charged \(r.flawed) times against \(r.clean)")
            }
        }

        t.test("the coach charges the player who caps with anything") {
            withLimitTable { setup in
                let r = charged(.capsEverything, "limit-cap-light", hands: 700, seeds: [7, 29], setup: setup)
                t.greaterThan(r.flawed, r.clean * 3 + 5,
                              "capping light was charged \(r.flawed) times against \(r.clean)")
            }
        }

        t.test("the coach charges the player who calls two bets cold") {
            withLimitTable { setup in
                let r = charged(.coldCallsRaises, "limit-cold-call", hands: 900, seeds: [3, 19], setup: setup)
                t.greaterThan(r.flawed, r.clean * 2 + 3,
                              "cold-calling was charged \(r.flawed) times against \(r.clean)")
            }
        }

        t.test("the coach charges the player who folds the river to one bet") {
            withLimitTable { setup in
                let r = charged(.foldsRiverToOneBet, "limit-river-overfold", hands: 1200, seeds: [13, 31], setup: setup)
                t.greaterThan(r.flawed, r.clean * 2 + 3,
                              "folding the river was charged \(r.flawed) times against \(r.clean)")
            }
        }

        t.test("calling with the best hand is priced, and said not to be nameable") {
            // The bands touch — see the catalogue — so this habit is unnameable. What the coach
            // still owes is the price: the player who turns every raise into a call must be
            // charged more than the control, and the cost must count.
            withLimitTable { setup in
                let leak = LeakCatalogue.named("limit-missed-value-raise")
                t.expect(leak != nil && leak!.canBeNamed == false, "the habit should be priced but unnameable")
                let r = charged(.neverRaisesPostflop, "limit-missed-value-raise", hands: 900, seeds: [5, 17], setup: setup)
                t.greaterThan(r.flawed, r.clean, "never raising was charged \(r.flawed) times against \(r.clean)")
            }
        }

        t.test("a player with no planted flaw is never named at a fixed-limit table") {
            // The limit edition's version of the cash sweep: the control at the app's own
            // fixed-limit table, replayed decision by decision, at the bottom rung and the top.
            // Two habits are exempt from the "was it judged" check and say why in the catalogue:
            // the free card (a spot a sound player is in a dozen times in six thousand hands) and
            // the river value bet (five hundred chances is more than six thousand hands hold).
            withLimitTable { setup in
                var everNamed: [String] = []
                var floorsCleared = 0
                var floorsMissed: [String] = []
                for (seed, rung) in [(UInt64(4_242), 0), (77, 3)] {
                    let coach = runPlantedCash(.control, hands: 6_000, seed: seed, setup: setup, rung: rung,
                                               keepEveryReview: true)
                    let sweep = NamingSweep(coach)
                    for moment in sweep.named {
                        everNamed.append("\(moment.leak.id) at \(moment.times)/\(moment.chances) (seed \(seed), rung \(rung))")
                    }
                    for leak in LeakCatalogue.nameable where !["limit-no-free-card", "missed-river-value"].contains(leak.id) {
                        let chances = sweep.chances[leak.id] ?? 0
                        guard chances > 0 else { continue }
                        if chances >= leak.minimumSample { floorsCleared += 1 }
                        else { floorsMissed.append("\(leak.id) (\(chances) of \(leak.minimumSample))") }
                    }
                }
                t.expect(everNamed.isEmpty,
                         "a player with no planted flaw was told he has a habit at a limit table: "
                         + "\(everNamed.joined(separator: "; "))")
                t.expect(floorsMissed.isEmpty,
                         "these limit habits were never judged at all, so their bars are untested here: "
                         + "\(Set(floorsMissed).sorted().joined(separator: ", "))")
                t.greaterThan(floorsCleared, 0, "nothing was judged, so nothing above was checked")
            }
        }
    }
}

func cashCoachTests(_ t: TestRun) {
    limitCoachTests(t)
    t.suite("the cash coach") { t in

        // The number a coach lives or dies on, measured at the app's own cash table. The
        // tournament's flawless player is stopped on about 7% of hands; at the bottom cash rung,
        // where six of eight opponents call too much and every pot is multiway, the same player
        // is stopped on 8-9% over 2,000 hands (5% at the top rung; 7-8% at pot-limit; 7% at
        // fixed-limit — `pokerlab coach --player control --format cash --all`). Nothing on the
        // list is one habit: the charges are spread across twenty kinds, half of them sizing
        // and value-raise disagreements a solid regular and the coach are entitled to have. A
        // bot rewritten to read a big bet as a strong hand at cash tables (2026-09-12) moved the
        // rate within noise and raised the cost, and was reverted. This holds the rate under one
        // hand in twelve, over two seeds, in the room the drills and the sweeps use.
        t.test("a sound cash player is not stopped more than one hand in twelve") {
            withCashProduct {
                var opened = 0, hands = 0, spoken = 0
                for seed: UInt64 in [31, 1_009] {
                    let coach = runPlantedCash(.control, hands: 1_500, seed: seed, keepEveryReview: true)
                    opened += coach.reviews.filter { !$0.playedWell }.count
                    spoken += coach.reviews.flatMap(\.verdicts).filter(\.shouldSpeak).count
                    hands += coach.reviews.count
                }
                let rate = Double(opened) / Double(max(1, hands))
                t.lessThan(rate, 1.0 / 12.0,
                           "a player with no flaw had a review opened on \(opened) of \(hands) cash hands "
                           + "(\(Int((rate * 100).rounded()))%) — the coach is accusing a sound player")
                t.greaterThan(spoken, 0, "the coach said nothing at all in \(hands) hands, which is not the same as being right")
            }
        }

        t.test("the tournament catalogue is untouched by the cash one") {
            let ids = LeakCatalogue.tournament.map(\.id)
            t.equal(ids.count, 18, "the tournament edition should still hold its eighteen habits")
            t.expect(ids.contains("no-shove") && ids.contains("shove-too-wide"),
                     "the short-stack habits belong to the tournament edition")
            t.expect(!ids.contains("stacked-off-one-pair"),
                     "a cash habit has no business in the tournament edition")
            // The process starts as the tournament product, and that is the edition it reads.
            t.equal(LeakCatalogue.format, .tournament)
            t.equal(LeakCatalogue.all.map(\.id), ids)
        }

        t.test("the cash catalogue drops the short stack and adds the money behind") {
            let cash = LeakCatalogue.cash.map(\.id)
            t.expect(!cash.contains("no-shove") && !cash.contains("shove-too-wide"),
                     "nobody at a cash table is in a push-or-fold spot")
            for id in ["stacked-off-one-pair", "set-mine-no-odds", "missed-implied-odds"] {
                t.expect(cash.contains(id), "\(id) is missing from the cash edition")
            }
            for leak in LeakCatalogue.tournament where leak.spot != .shortStackShove {
                t.expect(cash.contains(leak.id), "\(leak.id) should carry over to the cash table")
            }
            t.equal(Set(cash).count, cash.count, "two cash habits share an id")
            // Every (situation, mistake) pair is one habit, in either edition — a verdict has to
            // land on exactly one row.
            for edition in [LeakCatalogue.tournament, LeakCatalogue.cash] {
                var seen: Set<String> = []
                for leak in edition {
                    let key = "\(leak.spot)/\(leak.error)"
                    t.expect(!seen.contains(key), "two habits answer to \(key)")
                    seen.insert(key)
                }
            }
        }

        t.test("switching the product switches the edition, and switching back restores it") {
            withCashProduct {
                t.equal(LeakCatalogue.format, .cash)
                t.equal(AppVariant.current, .pokerTutorCash)
                t.equal(LeakCatalogue.all.map(\.id), LeakCatalogue.cash.map(\.id))
                t.equal(LeakCatalogue.nameable.count, LeakCatalogue.cash.filter(\.canBeNamed).count)
            }
            t.equal(LeakCatalogue.format, .tournament)
            t.equal(AppVariant.current, .pokerTutor)
        }

        t.test("a cash session judges as a cash coach, a tournament session as a tournament coach") {
            let cash = TableSession(seed: 3, history: .inMemory(), variant: .pokerTutorCash)
            t.equal(cash.coach.format, .cash)
            t.expect(!cash.coach.judgesShortStacks)
            let tournament = TableSession(seed: 3, history: .inMemory(), variant: .pokerTutor)
            t.equal(tournament.coach.format, .tournament)
            t.expect(tournament.coach.judgesShortStacks)
        }

        // The three cash habits, each measured against the player who has it and the player who
        // does not, at the app's own cash table. Short runs — the bars come from the long sweep in
        // `Leaks.swift`; these say the coach can tell the two apart at all.
        t.test("the coach charges the player who stacks off with one pair, and not the control") {
            withCashProduct {
                var flawed = 0, clean = 0
                for seed: UInt64 in [11, 23] {
                    let a = runPlantedCash(.stacksOffOnePair, hands: 700, seed: seed)
                    let b = runPlantedCash(.control, hands: 700, seed: seed)
                    flawed += a.profile.records["stacked-off-one-pair"]?.mistakes ?? 0
                    clean += b.profile.records["stacked-off-one-pair"]?.mistakes ?? 0
                }
                t.greaterThan(flawed, clean + 3,
                              "stacking off was charged \(flawed) times against \(clean) for the control")
            }
        }

        t.test("the coach charges the player who calls every re-raise with a small pair") {
            withCashProduct {
                var flawed = 0, clean = 0
                for seed: UInt64 in [5, 17] {
                    flawed += runPlantedCash(.setMinesEverything, hands: 900, seed: seed)
                        .profile.records["set-mine-no-odds"]?.mistakes ?? 0
                    clean += runPlantedCash(.control, hands: 900, seed: seed)
                        .profile.records["set-mine-no-odds"]?.mistakes ?? 0
                }
                t.greaterThan(flawed, clean + 2,
                              "calling re-raises with small pairs was charged \(flawed) times against \(clean)")
            }
        }

        t.test("the coach charges the player who folds the hands the money behind pays for") {
            withCashProduct {
                var flawed = 0, clean = 0
                for seed: UInt64 in [7, 29] {
                    flawed += runPlantedCash(.foldsSpeculativeHands, hands: 700, seed: seed)
                        .profile.records["missed-implied-odds"]?.mistakes ?? 0
                    clean += runPlantedCash(.control, hands: 700, seed: seed)
                        .profile.records["missed-implied-odds"]?.mistakes ?? 0
                }
                t.greaterThan(flawed, clean + 3,
                              "folding the hands the stacks pay for was charged \(flawed) times against \(clean)")
            }
        }

        t.test("a player with no planted flaw is never named at a cash table, at either end of the ladder") {
            // The cash edition's own version of the sweep above: the control at the app's own
            // nine-handed cash table, twelve thousand hands each — long enough to clear every floor a sound player can reach, the river call included —
            // replayed decision by decision. Two seeds at the bottom rung, where every player
            // starts, and one at the top, where the opponents stop giving money away and the
            // running rates run highest.
            //
            // One habit is exempt from the "was it judged" check, and it is said here rather
            // than left to be found: putting a whole stack in with one pair is counted over the
            // decisions where the next chips were most of a stack, and a sound player faces one
            // of those a handful of times in six thousand hands. Its floor is twelve; the control
            // never gets there. The bar is not looking away; a sound player is simply rarely in
            // that situation.
            withCashProduct {
                var everNamed: [String] = []
                var floorsCleared = 0
                var floorsMissed: [String] = []
                for (seed, rung) in [(UInt64(4_242), 0), (512, 0), (77, 3)] {
                    let coach = runPlantedCash(.control, hands: 12_000, seed: seed, rung: rung,
                                               keepEveryReview: true)
                    let sweep = NamingSweep(coach)
                    for moment in sweep.named {
                        everNamed.append("\(moment.leak.id) at \(moment.times)/\(moment.chances) "
                                         + "(seed \(seed), rung \(rung))")
                    }
                    for leak in LeakCatalogue.nameable where leak.id != "stacked-off-one-pair" {
                        let chances = sweep.chances[leak.id] ?? 0
                        guard chances > 0 else { continue }
                        if chances >= leak.minimumSample { floorsCleared += 1 }
                        else { floorsMissed.append("\(leak.id) (\(chances) of \(leak.minimumSample))") }
                    }
                }
                t.expect(everNamed.isEmpty,
                         "a player with no planted flaw was told he has a habit at a cash table: "
                         + "\(everNamed.joined(separator: "; "))")
                t.expect(floorsMissed.isEmpty,
                         "these cash habits were never judged at all, so their bars are untested here: "
                         + "\(Set(floorsMissed).sorted().joined(separator: ", "))")
                t.greaterThan(floorsCleared, 0, "nothing was judged, so nothing above was checked")
            }
        }

        t.test("the cash scale has the control near the top and the worst scripted player near the bottom") {
            // The cash line was derived with each player's own penalty in (`SkillRating`), so the
            // same two ends hold with penalties: the control at 66 with none, and the player who
            // puts a whole stack in with one pair — a habit priced but not named, so no penalty —
            // at 10. One decision at a cash table is charged at most thirty big blinds, and a
            // preflop fold at most six, which is what keeps a single enormous pot from rating a
            // sound player zero.
            t.equal(DecisionEvaluator.mostOneDecisionCosts, 30)
            t.equal(DecisionEvaluator.mostOnePreflopFoldCosts, 6)
            t.equal(SkillRating(costPer100: 15.1, hands: 3000, format: .cash).value, 66)
            t.equal(SkillRating(costPer100: 60.2, hands: 3000, format: .cash).value, 10)
            t.close(SkillRating.costFor(rating: 66, format: .cash), 15.1, 0.2)
            // The fixed-limit line: the control at 9.6 bb/100 with no penalty rates 66, the
            // player who folds every draw to one bet — 46.3 with every habit confirmed — rates 10.
            t.equal(SkillRating(costPer100: 9.6, hands: 3000, format: .limit).value, 66)
            t.equal(SkillRating(costPer100: 46.3, hands: 3000, leakPenalty: 15, format: .limit).value, 10)
            t.close(SkillRating.costFor(rating: 66, format: .limit), 9.6, 0.2)
            // The two lines are different lines, and the tournament one has not moved.
            t.equal(SkillRating(costPer100: 9.8, hands: 3000, format: .tournament).value, 66)
            t.expect(SkillRating(costPer100: 18.8, hands: 3000, format: .tournament).value
                     < SkillRating(costPer100: 18.8, hands: 3000, format: .cash).value,
                     "the same cost rates lower at a tournament, because a tournament charges less")
            // The rungs of the stakes ladder spread the scripted players out, in order.
            withCashProduct {
                t.equal(LadderTier.entry(forRating: 10).id, "cash-25-50")
                t.equal(LadderTier.entry(forRating: 30).id, "cash-25-50")
                t.equal(LadderTier.entry(forRating: 40).id, "cash-50-100")
                t.equal(LadderTier.entry(forRating: 50).id, "cash-100-200")
                t.equal(LadderTier.entry(forRating: 55).id, "cash-100-200")
                t.equal(LadderTier.entry(forRating: 62).id, "cash-500-1000")
                t.equal(LadderTier.entry(forRating: 66).id, "cash-500-1000")
            }
            // And at the app's own cash table, over two seeds, the control stays a solid amateur.
            withCashProduct {
                for seed: UInt64 in [3, 5] {
                    let history = HistoryStore.inMemory()
                    let session = TableSession(seed: seed, history: history, variant: .pokerTutorCash)
                    var rng = SeededRandom(seed: seed &* 13 &+ 7)
                    var steps = 0
                    session.start(.cash)
                    while history.hands.count < 1000, steps < 300_000 {
                        steps += 1
                        if !session.isPlaying { session.start(.cash); continue }
                        if session.heroToAct, let view = session.engine?.view(for: session.heroSeat) {
                            session.act(PlantedPlayer.control.decide(view, &rng))
                        } else if session.engine?.isComplete == true {
                            session.continueAfterReview()
                        } else {
                            session.stepOpponent()
                        }
                    }
                    let rating = LeakScorecard(of: history.hands).rating
                    t.greaterThan(rating.value, 44,
                                  "cash control seed \(seed): rated \(rating.value) off \(rating.costPer100) bb/100")
                }
            }
        }

        t.test("a tournament table never produces a cash verdict") {
            // The deep-stack rules are switched on by the format, not by the stack. A tournament
            // coach a hundred blinds deep prices the same hands the way it always did.
            let coach = runPlanted(.setMinesEverything, hands: 400, seed: 9, keepEveryReview: true)
            let cashKinds: Set<ErrorKind> = [.stackedOffOnePair, .setMinedWithoutOdds, .missedImpliedOdds]
            let stray = coach.reviews.flatMap(\.verdicts).filter { $0.error.map(cashKinds.contains) ?? false }
            t.equal(stray.count, 0, "a tournament coach produced \(stray.count) cash verdicts")
        }

        t.test("the assessment's arrangements follow the edition") {
            let tournament = AssessmentPlanner.setups(for: .tournament).map(\.id)
            let cash = AssessmentPlanner.setups(for: .cash).map(\.id)
            t.expect(tournament.contains("short-stack") && !cash.contains("short-stack"))
            t.expect(cash.contains("deep") && !tournament.contains("deep"))
            t.expect(cash.contains("early-seat") && cash.contains("last-seats"),
                     "the drills pin on these two ids in both editions")
            withCashProduct {
                t.equal(AssessmentPlanner.setups.map(\.id), cash)
                // Every situation the cash coach measures can be reached by some arrangement.
                for spot in SpotKind.measured {
                    t.expect(AssessmentPlanner.setups.contains { ($0.expected[spot] ?? 0) > 0 },
                             "no cash arrangement reaches \(spot)")
                }
            }
        }
    }
}