← poker-tutor
import Foundation
import PokerCore
import PokerAI
/// How busy the table is made in front of the player for one hand.
///
/// The eight opponents are the same eight all the way through a sitting; what changes is where
/// they sit. Putting the people who raise constantly in the seats that speak before you is how a
/// hand gets steered towards "somebody has raised, now what" without touching a single card.
public enum TableLean: String, Sendable, Equatable, Codable, CaseIterable {
/// The quietest players speak before the hero, so the pot is usually still unopened.
case quiet
case mixed
/// The busiest players speak before the hero, so there is usually a raise to answer.
case busy
/// How it reads in a sentence about the hand about to be dealt.
public var phrase: String {
switch self {
case .quiet: return "with the quietest players speaking before you"
case .mixed: return "with a mix of players either side of you"
case .busy: return "with the busiest players speaking before you"
}
}
}
/// One hand of an assessment, set up on purpose.
///
/// Three things are chosen and nothing else is: which seat the player sits in, how many big blinds
/// everybody has in front of them, and which opponent sits where. The cards come off a fresh
/// shuffle every hand, exactly as they do at a normal table, and that is the whole reason the
/// resulting numbers are worth anything — see `AssessmentReport.fairnessNote`.
public struct AssessmentSetup: Sendable, Equatable, Identifiable {
public let id: String
/// Where the player sits. Chosen, not dealt.
public let seat: Position
/// What everyone at the table has in front of them, in big blinds.
public let depthInBB: Int
public let lean: TableLean
/// What this hand is for, in the player's own words.
public let watching: String
/// Roughly how often a hand set up this way lands the player in each kind of situation.
///
/// A prior, not a promise. The cards still decide, which is exactly why these are chances
/// rather than certainties, and why the planner treats the whole thing as "what would I expect
/// to learn" rather than "what will happen".
public let expected: [SpotKind: Double]
public init(id: String, seat: Position, depthInBB: Int, lean: TableLean, watching: String,
expected: [SpotKind: Double]) {
self.id = id
self.seat = seat
self.depthInBB = depthInBB
self.lean = lean
self.watching = watching
self.expected = expected
}
/// "You are in seat 2 of 9 (the big blind) with 100 big blinds in front of everybody, with the
/// busiest players speaking before you." The seat leads with its number, as it does everywhere
/// else, and the stack is said in words a beginner already has — "deep" is a word you have to
/// be taught.
public func introduction(atTableOf count: Int) -> String {
"You are in \(seat.described(inTableOf: count)) with \(depthInBB) big blinds in front of everybody, \(lean.phrase)."
}
/// The short form, for a list of what the sitting steered.
public func summary(atTableOf count: Int) -> String {
"\(seat.seatPhrase(inTableOf: count)), \(depthInBB) big blinds in front of everybody, \(lean.phrase)"
}
}
/// How much of the player's game has actually been seen, situation by situation.
///
/// The measure is the standard error of a proportion, not a count, because the question is not
/// "how many hands" but "how far off could this number still be". A player who has folded five
/// out of five is already better understood than one who has folded three out of six, and the
/// planner should stop spending hands on the first and keep spending them on the second.
public struct AssessmentCoverage: Sendable, Equatable {
public private(set) var chances: [SpotKind: Int] = [:]
public private(set) var mistakes: [SpotKind: Int] = [:]
public private(set) var decisions = 0
public init() {}
public mutating func saw(_ spot: SpotKind, mistake: Bool) {
decisions += 1
chances[spot, default: 0] += 1
if mistake { mistakes[spot, default: 0] += 1 }
}
public func chances(in spot: SpotKind) -> Int { chances[spot] ?? 0 }
public func mistakes(in spot: SpotKind) -> Int { mistakes[spot] ?? 0 }
/// Two imaginary chances, one that went each way, added to every count.
///
/// Without them a spot seen twice and got wrong twice reads as a hundred per cent with no
/// uncertainty at all, which is how a coach ends up telling somebody they *always* do
/// something on the strength of two hands.
public func rate(in spot: SpotKind) -> Double {
Double(mistakes(in: spot) + 1) / Double(chances(in: spot) + 2)
}
/// How far the measured rate could still be from the real one.
public func standardError(in spot: SpotKind) -> Double {
let p = rate(in: spot)
return (p * (1 - p) / Double(chances(in: spot) + 2)).squareRoot()
}
/// The standard error of a spot nobody has been watched in at all — the worst it ever gets,
/// and therefore what everything else is measured against.
public static let blindStandardError = (0.25 / 2.0).squareRoot()
/// Nought where nothing has been seen, approaching one as the measurement firms up.
public func certainty(in spot: SpotKind) -> Double {
(1 - standardError(in: spot) / Self.blindStandardError).clamped(to: 0...1)
}
/// The gentlest error rate a steady player is allowed in this situation. Going past any one of
/// the habits that live there is worth knowing about, so the lowest of them is the line.
public static func acceptableRate(in spot: SpotKind) -> Double {
LeakCatalogue.all.filter { $0.spot == spot }.map(\.acceptableRate).min() ?? 0.2
}
/// How much this situation looks like it might be hiding something, nought to one and a half.
///
/// Certainty on its own spreads the hands evenly across the eleven situations, and evenly is
/// the wrong answer: forty hands cannot measure eleven things properly, so the parts of the
/// game that come back clean early should be left alone and the hands spent where the answer
/// is still in doubt. The reason is arithmetic rather than curiosity: another ten hands in a
/// spot already sitting above what a steady player manages can change what the report is
/// allowed to say, and another ten in a spot that is plainly fine cannot.
public func doubt(in spot: SpotKind) -> Double {
let seen = chances(in: spot)
guard seen > 0 else { return 0 }
let acceptable = Self.acceptableRate(in: spot)
let excess = (rate(in: spot) - acceptable) / max(0.05, acceptable)
// Damped while the count is tiny, so one bad hand does not send the whole sitting chasing
// a spot on the strength of it.
return excess.clamped(to: 0...1.5) * Double(seen) / Double(seen + 3)
}
/// Below this many chances a report is not allowed to say anything about a situation at all,
/// however lopsided the few it has look. Matches the floor the report itself applies.
public static let readableChances = 6
/// What another hand here would be worth: how little is known, weighted up where what little
/// is known looks wrong.
///
/// The floor is the important part. A situation seen four times has a respectable-looking
/// standard error and is still worth nothing, because nothing can be said off four chances —
/// so until there are enough of them, another hand there is worth exactly as much as a hand in
/// a situation nobody has ever been in. Without that floor the planner walked away from
/// half-measured spots to go and half-measure another one, and finished forty hands with
/// eleven situations it could not speak about.
public func need(in spot: SpotKind) -> Double {
let known = chances(in: spot) >= Self.readableChances ? certainty(in: spot) : 0
return (1 - known) * (1 + doubt(in: spot))
}
/// What is still least understood, worst first. Ties are broken by the catalogue's own order
/// so the same coverage always produces the same answer.
public var leastMeasured: [SpotKind] {
SpotKind.measured.enumerated()
.sorted { a, b in
let (x, y) = (certainty(in: a.element), certainty(in: b.element))
return x != y ? x < y : a.offset < b.offset
}
.map(\.element)
}
}
/// What each way of setting the table up has actually turned out to produce.
///
/// `AssessmentSetup.expected` is a belief, and beliefs about poker are wrong all the time. A setup
/// can promise river decisions and then never get anybody as far as the river, because the player
/// keeps folding before the flop — and a planner that took the promise at face value would deal
/// that same hand forty times waiting for a river that was never coming. That is not a made-up
/// worry: it is exactly what the first version of this did, forty hands of one setup, and it is
/// why what actually happened outvotes what was expected as soon as there is anything to go on.
public struct SetupYield: Sendable, Equatable {
private var handsDealt: [String: Int] = [:]
private var produced: [String: [SpotKind: Int]] = [:]
/// How many hands of a setup it takes before what happened counts for more than what was
/// expected. Three: enough that one strange hand does not rewrite the belief, few enough that
/// a belief which is simply wrong is abandoned inside the first few hands.
public static let priorWeight = 3.0
public init() {}
/// One finished hand: which setup it used, and which situations it actually produced. Repeats
/// within a hand are ignored — the question is how often a hand like this gets you there at
/// all, not how many decisions it hands you once you are.
public mutating func saw(_ setup: String, spots: [SpotKind]) {
handsDealt[setup, default: 0] += 1
for spot in Set(spots) { produced[setup, default: [:]][spot, default: 0] += 1 }
}
public func hands(of setup: String) -> Int { handsDealt[setup] ?? 0 }
/// How often a hand set up this way lands the player in this situation, as best as can be
/// told: what was expected, then what has been seen, with the second taking over.
public func expected(_ spot: SpotKind, of setup: AssessmentSetup) -> Double {
let dealt = Double(hands(of: setup.id))
let seen = Double(produced[setup.id]?[spot] ?? 0)
let prior = setup.expected[spot] ?? 0
return (prior * Self.priorWeight + seen) / (Self.priorWeight + dealt)
}
}
/// Chooses how to set the table up for the next hand.
///
/// It is greedy and it is honest about being greedy: for each way of setting the table up, add up
/// how likely that setup is to produce each kind of decision, weighted by how little is known
/// about that kind of decision, and take the best total. There is no cleverer search here because
/// there is nothing to search — forty hands, seven setups, and every hand changes the answer.
public enum AssessmentPlanner {
/// The ways a hand can be set up, in the edition the format selects. Between them they reach
/// every situation the coach measures in that game.
public static var setups: [AssessmentSetup] { setups(for: LeakCatalogue.format) }
public static func setups(for format: CoachFormat) -> [AssessmentSetup] {
switch format {
case .tournament: return tournamentSetups
case .cash: return cashSetups
case .limit: return limitSetups
}
}
/// The fixed-limit table's arrangements: the cash ones minus the two-hundred-blind seat — a
/// limit pot never asks anybody for a whole stack, so depth is not a thing to arrange — with
/// priors measured at the limit table (`pokerlab assess --format cash --structure fixedLimit
/// --setup <id> --hands 200 --player control`, 2026-09-12). A limit table reaches the flop
/// less often from the early seats than a no-limit one, because a raise is called rather
/// than folded to and the raiser is then answering bets; the turn comes up more.
public static let limitSetups: [AssessmentSetup] = cashSetups.filter { $0.id != "deep" }.map { setup in
guard let measured = limitYields[setup.id] else { return setup }
return AssessmentSetup(id: setup.id, seat: setup.seat, depthInBB: setup.depthInBB, lean: setup.lean,
watching: setup.watching, expected: measured)
}
static let limitYields: [String: [SpotKind: Double]] = [
"early-seat": [.openingChance: 1.00, .facingReraise: 0.06, .continuationBet: 0.09,
.facingContinuationBet: 0.04, .flopDecision: 0.04, .turnDecision: 0.18,
.riverDecision: 0.12, .facingRiverBet: 0.03],
"last-seats": [.openingChance: 0.20, .facingRaise: 0.75, .facingReraise: 0.12,
.continuationBet: 0.02, .facingContinuationBet: 0.07, .flopDecision: 0.07,
.turnDecision: 0.13, .riverDecision: 0.07, .facingRiverBet: 0.07],
"big-blind": [.openingChance: 0.09, .facingReraise: 0.37, .bigBlindDefence: 0.56,
.continuationBet: 0.04, .facingContinuationBet: 0.23, .flopDecision: 0.27,
.turnDecision: 0.49, .riverDecision: 0.22, .facingRiverBet: 0.17,
.freeCardChance: 0.01],
"facing-heat": [.openingChance: 0.50, .facingRaise: 0.46, .facingReraise: 0.09,
.continuationBet: 0.07, .facingContinuationBet: 0.07, .flopDecision: 0.03,
.turnDecision: 0.14, .riverDecision: 0.09, .facingRiverBet: 0.07],
"deep-and-sticky": [.openingChance: 0.34, .facingRaise: 0.65, .facingReraise: 0.09,
.continuationBet: 0.04, .facingContinuationBet: 0.08, .flopDecision: 0.04,
.turnDecision: 0.16, .riverDecision: 0.08, .facingRiverBet: 0.09,
.freeCardChance: 0.01],
"bet-at-you": [.openingChance: 0.06, .facingRaise: 0.64, .facingReraise: 0.35,
.continuationBet: 0.04, .facingContinuationBet: 0.04, .flopDecision: 0.09,
.turnDecision: 0.17, .riverDecision: 0.10, .facingRiverBet: 0.04],
]
/// The cash table's arrangements: the tournament ones minus the short stack — nobody at a cash
/// table is eleven big blinds deep unless they chose to be — and a deep one in its place,
/// because two hundred big blinds is where the hundred-blind mistakes get expensive.
public static let cashSetups: [AssessmentSetup] =
tournamentSetups.filter { $0.id != "short-stack" } + [
AssessmentSetup(
id: "deep", seat: .cutoff, depthInBB: 200, lean: .quiet,
watching: "What you do with two hundred big blinds in front of everybody: whether "
+ "one pair tempts you into a big pot, and whether you call for the hands that "
+ "the money behind pays for.",
// Measured: `pokerlab assess --format cash --setup deep --hands 200 --player
// control`, 2026-09-11. Two hundred big blinds in the cutoff at a quiet table is
// mostly a preflop seat — a raise to answer in half the hands — and reaches the
// flop in about one hand in six. The stack-off spot is rare with a sound player in
// the chair (none in 200 hands; one in 200 from the last seat), and the prior is
// kept small rather than zero so the planner knows the arrangement can reach it.
expected: [.openingChance: 0.43, .facingRaise: 0.54, .facingReraise: 0.10,
.continuationBet: 0.04, .facingContinuationBet: 0.06, .flopDecision: 0.01,
.turnDecision: 0.06, .riverDecision: 0.03, .facingRiverBet: 0.01,
.deepStackOff: 0.02]),
]
/// The tournament's arrangements: what the app shipped with.
public static let tournamentSetups: [AssessmentSetup] = [
AssessmentSetup(
id: "early-seat", seat: .underTheGun, depthInBB: 100, lean: .mixed,
watching: "Which hands you are willing to play from the first seat to speak, where "
+ "everybody else still gets a say after you.",
expected: [.openingChance: 0.95, .facingReraise: 0.16, .continuationBet: 0.24,
.flopDecision: 0.20, .turnDecision: 0.12, .riverDecision: 0.08]),
AssessmentSetup(
id: "last-seats", seat: .button, depthInBB: 100, lean: .quiet,
watching: "Whether you take the pot when it is handed to you: the last seat, with only "
+ "the two blinds left to get past.",
expected: [.openingChance: 0.90, .facingRaise: 0.18, .continuationBet: 0.42,
.flopDecision: 0.32, .turnDecision: 0.20, .riverDecision: 0.14]),
AssessmentSetup(
id: "big-blind", seat: .bigBlind, depthInBB: 100, lean: .busy,
watching: "What you do with the money you have already put in when somebody raises "
+ "at it — the single most expensive habit there is.",
expected: [.bigBlindDefence: 0.72, .facingContinuationBet: 0.30, .flopDecision: 0.28,
.turnDecision: 0.17, .riverDecision: 0.11, .facingRiverBet: 0.10]),
AssessmentSetup(
id: "facing-heat", seat: .middle, depthInBB: 100, lean: .busy,
watching: "How you answer somebody who has already raised, and what happens when you "
+ "raise and they come back at you.",
expected: [.facingRaise: 0.58, .facingReraise: 0.26, .openingChance: 0.30,
.facingContinuationBet: 0.24, .flopDecision: 0.24, .turnDecision: 0.12]),
AssessmentSetup(
id: "short-stack", seat: .hijack, depthInBB: 11, lean: .mixed,
watching: "A stack too short to raise and then change your mind, where the only two "
+ "answers are all in or fold.",
expected: [.shortStackShove: 0.94, .flopDecision: 0.06]),
AssessmentSetup(
id: "deep-and-sticky", seat: .cutoff, depthInBB: 180, lean: .quiet,
watching: "Whether you get paid: plenty of chips in front of everybody, against people "
+ "who call far too much, where the money is made on the last two cards.",
expected: [.openingChance: 0.45, .continuationBet: 0.48, .flopDecision: 0.52,
.turnDecision: 0.40, .riverDecision: 0.32, .facingContinuationBet: 0.14]),
AssessmentSetup(
id: "bet-at-you", seat: .smallBlind, depthInBB: 100, lean: .busy,
watching: "What you do when somebody keeps betting at you and you have to answer "
+ "first, all the way to the last card.",
expected: [.facingRaise: 0.30, .facingContinuationBet: 0.44, .facingRiverBet: 0.24,
.flopDecision: 0.34, .turnDecision: 0.24, .riverDecision: 0.14]),
]
public static func setup(id: String) -> AssessmentSetup? {
setups.first { $0.id == id }
}
/// What a hand set up this way would be expected to teach, given what is already known — and
/// given what this setup has actually been producing, which is not always what it promised.
public static func gain(of setup: AssessmentSetup, given coverage: AssessmentCoverage,
yield: SetupYield = SetupYield()) -> Double {
SpotKind.measured.reduce(0.0) { total, spot in
total + yield.expected(spot, of: setup) * coverage.need(in: spot)
}
}
/// The setup for the next hand. Ties go to the earlier setup in the list, so the same picture
/// always yields the same hand — a run that replayed differently would be untestable.
public static func next(for coverage: AssessmentCoverage,
yield: SetupYield = SetupYield()) -> AssessmentSetup {
var best = setups[0]
var bestGain = gain(of: best, given: coverage, yield: yield)
for setup in setups.dropFirst() {
let value = gain(of: setup, given: coverage, yield: yield)
if value > bestGain + 1e-9 {
best = setup
bestGain = value
}
}
return best
}
}