← poker-tutor
import Foundation
import PokerCore
import PokerAI
import PokerCoach
/// A sitting with a beginning and an end: about forty hands, each one set up to look at whichever
/// part of the player's game is least understood so far.
///
/// The run holds no hands of its own. It is rebuilt from what was written down — `resuming(from:)`
/// replays the saved hands of an assessment sitting and arrives at exactly the state it was left
/// in — which is what makes stopping half way harmless. There is no second copy of the progress to
/// fall out of step with the history, because there is no second copy.
public struct AssessmentRun: Sendable, Equatable {
/// About forty hands: long enough for the coach to have seen every kind of decision at least
/// a few times, short enough to sit through in one go — roughly twenty-five minutes.
public static let standardLength = 40
public let target: Int
public private(set) var handsPlayed: Int
public private(set) var coverage: AssessmentCoverage
/// What each setup has actually produced, as opposed to what it was expected to.
public private(set) var yield: SetupYield
/// How the table is being set up for the hand about to be dealt.
public private(set) var setup: AssessmentSetup
/// Which setup each hand used, in order. The report counts them up so the player can see
/// exactly what was arranged on their behalf.
public private(set) var setupsUsed: [String]
public init(target: Int = standardLength) {
self.target = max(1, target)
handsPlayed = 0
coverage = AssessmentCoverage()
yield = SetupYield()
setup = AssessmentPlanner.next(for: coverage)
setupsUsed = []
}
public var isFinished: Bool { handsPlayed >= target }
public var handsLeft: Int { max(0, target - handsPlayed) }
public var fraction: Double { Double(handsPlayed) / Double(target) }
/// "Hand 12 of 40" — what sits above the felt while it runs.
public var progressLine: String {
isFinished ? "\(target) of \(target) — done" : "Hand \(handsPlayed + 1) of \(target)"
}
/// Folds a finished hand in and chooses how to set the table up for the next one.
public mutating func observe(_ record: HandRecord) {
handsPlayed += 1
let used = record.setup ?? setup.id
setupsUsed.append(used)
var spots: [SpotKind] = []
for verdict in record.verdicts {
guard let spot = verdict.spotKind else { continue }
spots.append(spot)
coverage.saw(spot, mistake: verdict.countsTowardsLeak)
}
yield.saw(used, spots: spots)
setup = AssessmentPlanner.next(for: coverage, yield: yield)
}
/// Picks a half-finished sitting back up from the hands already on disk.
public static func resuming(from records: [HandRecord], target: Int = standardLength) -> AssessmentRun {
var run = AssessmentRun(target: target)
for record in records.sorted(by: { $0.handNumber < $1.handNumber }) { run.observe(record) }
return run
}
}
/// What an assessment concluded, and how much of it is worth believing.
///
/// Everything here is built from the saved hands, so a report can be produced again tomorrow, or
/// for a sitting somebody walked away from half way through. Two rules run through the whole of
/// it and they are the same rule the scorecard is built on: say only what the hands support, and
/// where they support very little, say that instead of rounding it up into a finding.
public struct AssessmentReport: Sendable {
/// Ninety-five per cent, two-sided. The bar for putting a name to something.
static let confidenceMultiple = 1.96
/// The fewest chances a habit can be raised off at all. Below this the rate is noise wearing a
/// percentage sign, whatever the arithmetic around it says.
public static let minimumChances = 6
/// One kind of situation, and how well it got measured.
public struct Area: Sendable, Identifiable {
public let spot: SpotKind
public let chances: Int
public let mistakes: Int
/// Smoothed, so a spot seen twice does not read as nought or a hundred per cent.
public let rate: Double
public let standardError: Double
public let certainty: Double
public var id: String { spot.rawValue }
public var name: String { spot.name }
/// Fewer chances than this and the percentage is noise wearing a percentage sign.
public var readable: Bool { chances >= AssessmentReport.minimumChances }
public var percent: Int { Int((rate * 100).rounded()) }
public var lowPercent: Int {
Int(((rate - AssessmentReport.confidenceMultiple * standardError) * 100).rounded().clamped(to: 0...100))
}
public var highPercent: Int {
Int(((rate + AssessmentReport.confidenceMultiple * standardError) * 100).rounded().clamped(to: 0...100))
}
/// What this area is worth saying, in a sentence.
public var line: String {
if chances == 0 {
return "Never came up. Nothing measured here at all."
}
if !readable {
return "\(chances) \(chances == 1 ? "chance" : "chances"). Too few to read anything into."
}
// The wording matters here. This is a plain interval on a smoothed rate off as few as
// six chances, and it does not promise to contain the real rate — so it must not be
// written as "your real rate is somewhere between", which promises exactly that and is
// the one kind of certainty the rest of this report refuses to hand out.
return "\(mistakes) of \(chances) went wrong. Allowing for how few hands that is, these "
+ "chances only narrow your real rate down to somewhere around \(lowPercent)% to "
+ "\(highPercent)%, and that is the most a sample this size can honestly claim."
}
}
/// A habit that turned up more often than it should have.
///
/// Whether it goes in `named` or in `watching` is decided by one thing: whether even the
/// cautious end of what these hands can show is still worse than a steady player. Forty hands
/// clears that bar for the loudest habits and does not clear it for the rest, and pretending
/// otherwise is the whole failure mode this type exists to avoid.
public struct Named: Sendable, Identifiable, Equatable {
public let leak: Leak
public let chances: Int
public let times: Int
public let rate: Double
/// The bottom of the range the hands support. This is the number that has to clear the
/// acceptable rate before anything is called a habit, rather than the measured rate.
public let lowEnd: Double
/// True where it did clear it.
public let certain: Bool
public var id: String { leak.id }
public var percent: Int { Int((rate * 100).rounded()) }
public var lowPercent: Int { Int((lowEnd * 100).rounded().clamped(to: 0...100)) }
public var acceptablePercent: Int { Int((leak.acceptableRate * 100).rounded()) }
public var line: String {
guard certain else {
return "\(times) of \(chances) — about \(percent)%, against the "
+ "\(acceptablePercent)% this has to be under. \(chances) chances is not "
+ "enough to call that a habit, so take it as the first place to look rather "
+ "than something I have proved."
}
return "\(times) of \(chances) — even at the cautious end of what \(chances) chances "
+ "can show, that is at least \(lowPercent)%, against the \(acceptablePercent)% "
+ "this has to be under."
}
}
/// One way the table was set up, and how many hands used it.
public struct Steer: Sendable, Identifiable {
public let setup: AssessmentSetup
public let hands: Int
public var id: String { setup.id }
public var line: String { "\(hands) \(hands == 1 ? "hand" : "hands") — \(setup.summary(atTableOf: 9))" }
}
public let hands: Int
public let target: Int
public let finished: Bool
public let decisions: Int
/// The leak counts these hands produced, in the same form the My game screen uses. This is
/// what feeds the scorecard: an assessment is forty hands of ordinary evidence.
public let scorecard: LeakScorecard
/// When the sitting's last hand was played, off the hands themselves. Nil only for a report
/// built from no hands at all.
public let playedOn: Date?
public let rating: SkillRating
/// What the hands actually support, rather than the single number above.
public let ratingRange: ClosedRange<Int>
public let areas: [Area]
/// Habits clear enough to name, most clearly established first.
public let named: [Named]
/// Heading the wrong way, on too few chances to be sure. The place to look first, said as
/// exactly that rather than dressed up as a finding.
public let watching: [Named]
public let steering: [Steer]
/// The one thing to take away, proved or not. Nil where nothing stood out at all.
public var topFinding: Named? { named.first ?? watching.first }
public var costPer100: Double { scorecard.profile.costPer100Hands }
// MARK: - What it says
public var headline: String {
if hands == 0 { return "No hands played, so there is nothing to report." }
if !finished {
return "You stopped \(hands) hands into \(target). Everything below is built from the "
+ "hands you did play, and it is honest about how few that is."
}
if named.isEmpty, watching.isEmpty {
return "\(hands) hands, and nothing went wrong often enough for me to put a name to "
+ "it. That is a decent start rather than a clean bill of health — forty hands is "
+ "a short look at anybody."
}
if named.isEmpty {
let count = watching.count == 1 ? "one thing" : "\(watching.count) things"
return "\(hands) hands. Nothing is settled yet, but \(count) kept happening often "
+ "enough to be where I would look first."
}
let count = named.count == 1 ? "One habit was" : "\(named.count) habits were"
return "\(hands) hands. \(count) already clear enough to be worth naming."
}
public var ratingLine: String {
"Your starting rating is \(rating.value) — \(rating.label). What these hands actually "
+ "support is somewhere between \(ratingRange.lowerBound) and \(ratingRange.upperBound)."
}
/// Why the number is where it is, and why it will move.
public var precisionNote: String {
"A rating off \(hands) hands is held back on purpose. It is only allowed a fraction of "
+ "the way from the middle, because \(hands) hands cannot tell an unlucky evening "
+ "from a real weakness, and a number that leapt to eighty off \(hands) hands would "
+ "be telling you something nobody could know yet. Treat it as a place to start the "
+ "ladder from rather than a verdict: play a couple of hundred more and it will move "
+ "to wherever it belongs, in either direction."
}
/// Exactly what was arranged, said before anybody has to ask.
public var steeringNote: String {
"This was not a normal sitting and the numbers should not be read as one. Between hands I "
+ "chose three things: which seat you sat in, how many big blinds everybody had in "
+ "front of them, and which of the eight opponents sat where. I did that so I could "
+ "put you in the situations I had not seen you handle yet, rather than waiting for "
+ "the deal to get round to them."
}
/// Where the arrangements put the player more often than a real table would. The tournament
/// sits them short-stacked; a cash table has no short stack — it sits them deep instead; a
/// fixed-limit table arranges no depth at all.
static var arrangedWhere: String {
switch LeakCatalogue.format {
case .tournament: return "in the big blind and on a short stack"
case .cash: return "in the big blind and with a very deep stack"
case .limit: return "in the big blind"
}
}
/// The reason the steering does not make the measurement a lie — and the one thing it does
/// spoil, said in the same breath rather than left for somebody to find.
public var fairnessNote: String {
"What I never touched is the cards. Every hand came off a fresh shuffle, so inside any one "
+ "situation the hands you were holding were as random as they are anywhere else. That "
+ "is what makes how often you got a decision wrong in that situation a fair number, "
+ "however often I put you there. What it does not make fair is the money: you spent "
+ "far more time \(Self.arrangedWhere) than anybody does at a real "
+ "table, so what these hands won or lost says nothing, and it is kept out of your win "
+ "rate for that reason. What each arrangement was looking at is listed above, and you "
+ "are reading it now rather than while you played: these hands do count towards your "
+ "leaks, and naming the habit I am counting while I am counting it would change the "
+ "number I got."
}
/// What to say where nothing could be named. Silence is a real answer, and a coach that
/// manufactures a list to look busy is one nobody believes the second time.
public var quietNote: String {
"Naming a habit here takes more than a bad run. It takes enough chances that even the "
+ "cautious end of what those chances can show is still worse than a steady player. "
+ "Nothing cleared that, which is worth more than a list I made up to look useful."
}
/// The parts of the game forty hands could not get near, named rather than passed over in
/// silence — silence about a situation nobody was ever in reads as "you are fine there".
public var thinAreas: [Area] { areas.filter { !$0.readable } }
public var thinNote: String? {
let thin = thinAreas
guard !thin.isEmpty else { return nil }
let list = thin.map(\.name).joined(separator: ", ")
return "\(thin.count) of the \(areas.count) situations barely came up: \(list). Some of "
+ "those need the hand to get a long way before they exist at all, and forty hands "
+ "will not always take you there. I have said nothing about them because I saw "
+ "nothing, which is not the same as saying they are fine."
}
// MARK: - Building it
public init(of records: [HandRecord], target: Int = AssessmentRun.standardLength) {
let sorted = records.sorted { $0.handNumber < $1.handNumber }
hands = sorted.count
self.target = target
finished = sorted.count >= target
decisions = sorted.reduce(0) { $0 + $1.verdicts.count }
let card = LeakScorecard(of: sorted)
scorecard = card
rating = card.rating
playedOn = sorted.map(\.playedAt).max()
// How far the rating could be out, worked from the spread of what the hands actually cost
// rather than from a rule of thumb. A wild session and a steady one at the same average
// are not equally well known, and this is where that difference shows up.
let perHand = sorted.map { record in
record.verdicts.filter(\.countsTowardsLeak).reduce(0.0) { $0 + $1.costInBB }
}
let mean = perHand.isEmpty ? 0 : perHand.reduce(0, +) / Double(perHand.count)
let variance = perHand.count > 1
? perHand.reduce(0.0) { $0 + ($1 - mean) * ($1 - mean) } / Double(perHand.count - 1)
: 0
let spread = perHand.count > 1
? AssessmentReport.confidenceMultiple * (variance / Double(perHand.count)).squareRoot() * 100
: 0
let penalty = card.profile.leakPenalty
let best = SkillRating(costPer100: max(0, mean * 100 - spread), hands: hands, leakPenalty: penalty)
let worst = SkillRating(costPer100: mean * 100 + spread, hands: hands, leakPenalty: penalty)
ratingRange = min(worst.value, best.value)...max(worst.value, best.value)
var coverage = AssessmentCoverage()
for record in sorted {
for verdict in record.verdicts {
guard let spot = verdict.spotKind else { continue }
coverage.saw(spot, mistake: verdict.countsTowardsLeak)
}
}
areas = SpotKind.measured.map { spot in
Area(spot: spot,
chances: coverage.chances(in: spot),
mistakes: coverage.mistakes(in: spot),
rate: coverage.rate(in: spot),
standardError: coverage.standardError(in: spot),
certainty: coverage.certainty(in: spot))
}
// What gets named, and why it is not simply "the most expensive one".
//
// An assessment sits the player in some situations far more often than others, so the
// *total* a habit has cost across these forty hands is partly a measure of how often the
// planner chose to look at it. Ranking by money would therefore rank by what was tested.
// Ranking by how far the cautious end of the measurement clears a steady player's rate
// does not have that problem: it is a rate, so being asked the question more often makes
// it surer rather than bigger.
let candidates = card.entries.compactMap { entry -> Named? in
// Twice is an accident, here as everywhere else in this app — and a percentage off
// three chances is noise wearing a percentage sign, however far past the line it lands.
// Both floors are needed: without the second, a sitting that happened to put somebody
// in the big blind three times and saw three loose folds would announce a habit, and
// it did exactly that before this line existed.
guard entry.times >= 3, entry.chances >= AssessmentReport.minimumChances else { return nil }
let smoothed = Double(entry.times + 1) / Double(entry.chances + 2)
guard smoothed > entry.leak.acceptableRate else { return nil }
let error = (smoothed * (1 - smoothed) / Double(entry.chances + 2)).squareRoot()
let low = smoothed - AssessmentReport.confidenceMultiple * error
return Named(leak: entry.leak, chances: entry.chances, times: entry.times,
rate: smoothed, lowEnd: low, certain: low > entry.leak.acceptableRate)
}
.sorted { a, b in
let (x, y) = (a.lowEnd - a.leak.acceptableRate, b.lowEnd - b.leak.acceptableRate)
return x != y ? x > y : a.leak.id < b.leak.id
}
named = candidates.filter(\.certain)
watching = candidates.filter { !$0.certain }
var counts: [String: Int] = [:]
for record in sorted { counts[record.setup ?? "", default: 0] += 1 }
steering = AssessmentPlanner.setups.compactMap { setup in
guard let used = counts[setup.id], used > 0 else { return nil }
return Steer(setup: setup, hands: used)
}
.sorted { $0.hands > $1.hands }
}
}