← poker-tutor
import Foundation
import PokerCore
import PokerAI
/// The player's standing, in the same 0-100 units the tables are set to.
public struct SkillRating: Sendable {
public let value: Int
public let handsBehindIt: Int
public let costPer100: Double
/// Hands before the rating stops being pulled towards the middle and is simply the arithmetic.
///
/// Named rather than written down twice, because the screen that warns the reader it is still
/// settling has to drop the warning at exactly the hand the pulling stops. It used to drop it a
/// hundred hands early, so at 150 hands the card said "based on what your mistakes cost" about
/// a number still a quarter of the way to the middle.
public static let handsToSettle = 200
/// Turns money lost to mistakes into a rating.
///
/// Anchored on tournament play, because tournaments are the game this app ships. The first
/// version was anchored on the coach harness, which sits a hundred big blinds deep from the
/// first hand to the last — nothing like a tournament, where the blinds climb and the stacks
/// get short and the same player gives up roughly twice as much per hundred hands. The result
/// was a scale whose top end nobody could reach: the flawless reference player scored zero.
///
/// Measured with `pokerlab calibrate` over 3,000 hands of real tournaments, eight seeds each,
/// on 2026-09-11 — this time with the penalty each player's own confirmed habits add, because
/// a real player always carries some and the first anchoring (2026-08-02) had been done as
/// though nobody did: three confirmed habits put anyone at zero, the worst scripted player
/// included. The costs also moved since August, so the whole line was re-derived:
///
/// cost bb/100 (range) penalty
/// no planted flaw 9.8 (7.6 – 16.3) 0.0
/// folds the big blind 10.4 (8.8 – 11.8) 9.3
/// never value bets the river 10.8 (8.4 – 15.2) 0.0
/// folds to every flop bet 16.1 (10.4 – 22.4) 15.0
/// never raises first in 21.6 (17.5 – 31.2) 15.0
/// plays far too many hands 32.4 (21.7 – 39.3) 15.0
///
/// The penalty is capped at `LeakProfile.maximumLeakPenalty` (15). The two ends of that set
/// the two ends of the scale, penalties in: a player with no deliberate flaw lands at 66 —
/// just inside "strong amateur", which starts at 65 — and the worst of them at 10. Everything between falls where
/// the arithmetic puts it: river 64, big blind 56, flop 40, limper 30 — and the ladder's rungs (22,
/// 35, 48, 60 to move up) were set so that they spread across it. "Low pro" needs under
/// about 2 bb/100 with nothing confirmed, which is better than any player this repo can
/// produce, and that is the point: the top of a scale should be something to climb towards
/// rather than somewhere the reference player already sits.
///
/// Nothing here is a guess at "how good is this person at poker" in the abstract. It measures
/// what their own mistakes cost, which is the thing the app can actually see.
public init(costPer100: Double, hands: Int, leakPenalty: Double = 0,
format: CoachFormat = LeakCatalogue.format) {
self.costPer100 = costPer100
self.handsBehindIt = hands
// Derived from the bands above with the penalties in: 9.8 and 0 -> 66, 32.4 and 15 -> 10.
let line = Self.line(for: format)
let raw = line.intercept - costPer100 * line.slope - leakPenalty
// Until there are enough hands, pull the rating toward the middle rather than pretending
// to know. Twenty hands of running hot should not make anyone an expert.
let confidence = min(1.0, Double(hands) / Double(Self.handsToSettle))
self.value = Int((50 + (raw - 50) * confidence).clamped(to: 0...100).rounded())
}
/// What a player would have to be costing themselves to rate this. The inverse of the line
/// above, kept beside it so the two cannot drift: a test that builds a history "rating 60"
/// needs the same arithmetic the rating itself uses, and writing it out twice is how a dozen
/// correct ladder tests came to fail at once.
public static func costFor(rating: Int, format: CoachFormat = LeakCatalogue.format) -> Double {
let line = line(for: format)
return (line.intercept - Double(rating)) / line.slope
}
/// The two lines, one per game, each derived by `pokerlab calibrate` for its own format.
///
/// The cash line, from `pokerlab calibrate --format cash --hands 3000 --seeds 4` at the
/// app's own 25/50 no-limit table, re-run on 2026-09-12 after the cash review (every priced
/// mistake now counts towards the rating at a cash table, the money-behind rule learned the
/// seat, a preflop fold is capped at six big blinds, and the stack-off habit is priced but
/// not named — so its player carries no penalty). Cost in bb/100, mean over four seeds, with
/// the penalty each player's confirmed habits actually incurred, one decision capped at
/// thirty (`DecisionEvaluator.mostOneDecisionCosts`):
///
/// no planted flaw 15.1 (13.0 – 18.4) penalty 0.0 -> 66
/// never value bets the river 16.3 (14.6 – 18.8) 3.4 -> 61
/// folds the hands the stacks pay for 16.4 (14.9 – 18.0) 5.6 -> 59
/// calls every re-raise with a small pair 23.7 (17.5 – 31.9) 0.0 -> 55
/// folds the big blind 18.3 (14.4 – 21.8) 12.8 -> 49
/// never raises first in 20.0 (14.4 – 24.9) 11.5 -> 48
/// folds to every flop bet 28.6 (21.8 – 36.7) 15.0 -> 34
/// plays far too many hands 30.2 (25.8 – 36.9) 15.0 -> 32
/// stacks off with one pair 60.2 (47.8 – 67.4) 0.0 -> 10
///
/// (The first anchoring, 2026-09-11, was 18.8 -> 66 and 55.3 -> 10 on `87.2 - cost * 1.125`;
/// the sound player's cost fell because the money-behind rule stopped charging routine
/// folds, and rose because uncatalogued mistakes now count — net a little lower.)
///
/// Two things about it. A cash table charges a sound player more than a tournament does —
/// 15 bb/100 against 10 — because every pot is a hundred blinds deep and a misjudgement in a
/// deep pot is priced at deep-pot money; so the cash line is steeper and the two ratings are
/// not comparable across the apps. And before the cap, the player who puts a whole stack in
/// with one pair cost sixteen hundred big blinds a hundred and a single river decision of
/// the control's was charged 384: a scale anchored on numbers like those is a scale nobody
/// can move on. With the cap he costs 55 and lands at 10, which is still where he belongs.
static func line(for format: CoachFormat) -> (intercept: Double, slope: Double) {
switch format {
case .tournament: return (83.8, 1.814)
// 15.1 and 0 -> 66, 60.2 and 0 -> 10.
case .cash: return (84.8, 1.242)
// 9.6 and 0 -> 66, 46.3 and 15 -> 10. From `pokerlab calibrate --format cash --structure
// fixedLimit --hands 3000 --seeds 4` at the app's own 25/50 fixed-limit table, re-run on
// 2026-09-12 after the cash review (the limit opening model's fold rate, the free-card
// credit and the limit range model all moved; the line moved from 77.0/1.194). Cost in
// bb/100, mean over four seeds, with the penalty each player's confirmed habits incurred:
//
// no planted flaw 9.6 (7.8 – 11.3) penalty 0.0 -> 66
// never raises for the free card 9.1 (8.8 – 9.5) 0.0 -> 66
// calls with the best hand 9.5 (8.2 – 10.6) 0.0 -> 66
// never value bets the river 10.5 (9.3 – 12.7) 0.0 -> 64
// calls the last bet with nothing 12.0 (11.1 – 13.3) 11.0 -> 52
// never raises first in 10.9 (9.7 – 12.4) 13.4 -> 51
// calls two bets cold 17.7 (16.5 – 18.6) 5.6 -> 50
// folds the big blind 16.0 (14.2 – 17.8) 15.0 -> 43
// plays far too many hands 20.6 (19.3 – 22.7) 15.0 -> 37
// folds the river to one bet 23.8 (18.3 – 28.3) 15.0 -> 34
// caps with anything 37.0 (29.9 – 45.7) 15.0 -> 18
// folds to every flop bet 39.6 (34.5 – 49.0) 15.0 -> 15
// folds a draw to one bet 46.3 (37.8 – 54.6) 15.0 -> 10
//
// Three things it says. A limit table charges a sound player less than either other
// game — 10 bb/100 against 10 at a tournament and 15 at a no-limit cash table — because
// a bet is small and a misjudgement is priced at one bet. The expensive habits are the
// folds, exactly as the game's own lore says: the flop-folder and the draw-folder cost
// three to four times what the loose player costs. And two habits the catalogue prices
// but cannot name — the free card and calling with the best hand — cost a sound
// player's amount, which is the arithmetic behind leaving them unnamed.
case .limit: return (76.7, 1.116)
}
}
/// Whether the number is the arithmetic yet, or still being held near the middle.
public var isSettled: Bool { handsBehindIt >= Self.handsToSettle }
public var label: String {
switch value {
case ..<25: return "Beginner"
case ..<45: return "Improving"
case ..<65: return "Solid amateur"
case ..<80: return "Strong amateur"
default: return "Low pro"
}
}
/// A table set slightly above the player is where improvement happens.
public var tableDifficulty: Int { TableComposition.difficulty(forRating: value) }
}
/// Watches the player and builds a picture of their game.
public final class Coach: @unchecked Sendable {
public private(set) var profile = LeakProfile()
public private(set) var reviews: [HandReview] = []
private var pending: [Verdict] = []
private var seedCounter: UInt64 = 1
/// Keeps memory bounded during a long session; the store keeps the full history.
public var maxReviewsHeld = 500
/// Every decision the coach has spoken about this sitting, and the dearest of them — counted as
/// the hands go past rather than off `reviews`.
///
/// `reviews` is a window on the last few hundred hands and nothing more. The session report
/// counted its mistakes off that window and its hands off the profile, which is not capped, so
/// past five hundred hands the report started quietly reporting fewer mistakes than it had
/// seen — the cost per hundred it printed beside them kept climbing while the count that was
/// meant to explain it went down. A running tally has no window to fall out of.
public private(set) var spokenMistakes = 0
public private(set) var dearestMistake: Verdict?
public init() {}
public var rating: SkillRating {
SkillRating(costPer100: profile.costPer100Hands, hands: profile.handsPlayed,
leakPenalty: profile.leakPenalty)
}
/// Judges one decision as the player makes it. Called before the action is applied, because
/// the verdict has to be based on what the player could see at the time.
/// Which game is being judged. A tournament judges a stack of twelve big blinds or fewer as a
/// push-or-fold spot; a cash table never does, and judges the money behind instead — the set
/// that was called for, the stack that went in with one pair. See `CoachFormat`.
public var format: CoachFormat = .tournament
/// The old name for the one difference the two formats had before the cash coach existed.
public var judgesShortStacks: Bool {
get { format == .tournament }
set { format = newValue ? .tournament : .cash }
}
@discardableResult
public func judge(_ view: DecisionView, action: PlayerAction) -> Verdict {
seedCounter &+= 1
let verdict = DecisionEvaluator.evaluate(view, took: action, seed: seedCounter, format: format)
pending.append(verdict)
profile.record(verdict)
return verdict
}
/// Closes out a hand and produces the review the player sees.
@discardableResult
public func finishHand(engine: HandEngine, heroSeat: Int, handNumber: Int) -> HandReview {
profile.recordHand()
let review = HandReview(
handNumber: handNumber,
holeCards: engine.seats[heroSeat].hole,
board: engine.board,
netChips: engine.result?.net[heroSeat] ?? 0,
bigBlind: engine.config.level.bigBlind,
verdicts: pending
)
pending.removeAll()
let spoken = review.worthMentioning
spokenMistakes += spoken.count
for verdict in spoken where verdict.costInBB > (dearestMistake?.costInBB ?? -1) {
dearestMistake = verdict
}
reviews.append(review)
if reviews.count > maxReviewsHeld { reviews.removeFirst(reviews.count - maxReviewsHeld) }
return review
}
/// Throws away the verdicts collected for a hand that is never going to finish.
///
/// `pending` is emptied by `finishHand` and by nothing else, so a hand walked out on left its
/// verdicts sitting there waiting to be attached to whatever hand finished next — in a
/// different sitting, on a different street, from a different seat, and written into that
/// hand's record on the way past. From there it reached the scorecard, the drill counts, the
/// assessment and the history, permanently. It is the same rule as the one about an illegal
/// action: nothing that was not a decision in this hand may ever be filed against the player as
/// a mistake in it.
public func abandonHand() { pending.removeAll() }
public func reset() {
profile = LeakProfile()
reviews.removeAll()
pending.removeAll()
spokenMistakes = 0
dearestMistake = nil
}
/// What to work on next: the costliest confirmed leak.
public var focus: Leak? { profile.confirmed().first?.leak }
/// A short session report. Every number in it is counted over the whole sitting, so none of
/// them can disagree with another because one of them was read off a capped list.
public func sessionReport() -> SessionReport {
SessionReport(
hands: profile.handsPlayed,
mistakes: spokenMistakes,
costInBB: profile.totalCostInBB,
rating: rating,
confirmed: profile.confirmed(),
watching: profile.watchList(),
biggest: dearestMistake
)
}
}
public struct SessionReport: Sendable {
public let hands: Int
public let mistakes: Int
public let costInBB: Double
public let rating: SkillRating
public let confirmed: [(leak: Leak, record: LeakProfile.Record)]
public let watching: [(leak: Leak, record: LeakProfile.Record)]
public let biggest: Verdict?
public var costPer100: Double { hands > 0 ? costInBB / Double(hands) * 100 : 0 }
public var headline: String {
guard hands > 0 else { return "No hands played yet." }
if mistakes == 0 { return "\(hands) hands, nothing worth flagging. Good session." }
let per100 = String(format: "%.1f", costPer100)
return "\(hands) hands. \(mistakes) decision\(mistakes == 1 ? "" : "s") cost you money — about \(per100) big blinds per hundred hands."
}
}