← poker-tutor
import Foundation
import PokerCore
import PokerCoach
/// Which seat the player loses most from, and why.
///
/// Ken, 2026-09-11: "a mode that diagnoses which position they play the worst in and why and let
/// them drill while playing only that position. We do that for BB and the button, but I would
/// like to also have a mode where if the user plays badly in 4th position, we can identify that
/// and help them fix it."
///
/// Built from the dealt hands only — the ones where the seat was the deal's choice. An assessment
/// or a drill sits the player in one seat on purpose, so counting those would make every seat
/// look like the seat the app happened to arrange most. What is counted per seat is what the
/// coach priced there: how many decisions it counted against the player (the same bar as the
/// scorecard) and what those decisions cost, per hundred hands at the seat.
///
/// The refusal that makes this worth anything is the same one as everywhere else: a flawless
/// player's seats differ by chance, and naming the worst of nine noisy numbers is naming noise.
/// So "the worst seat" is only ever said when the seat has enough hands behind it AND it is worse
/// than the next seat by more than a flawless player's seats ever differ at that sample. Both
/// numbers come from a sweep — see `minimumHands` and `requiredMargin`.
public struct SeatDiagnosis: Sendable, Equatable {
/// One seat, measured.
public struct SeatReport: Sendable, Equatable, Identifiable {
/// The seat as it was written down — "UTG+1". Kept because that is what is on disk.
public let position: String
/// The table these hands were mostly played at, which is what the number is said against.
public let tableSize: Int
public let hands: Int
/// Decisions the coach looked at in this seat.
public let decisions: Int
/// Decisions it counted against the player — the scorecard's bar, not the table's.
public let mistakes: Int
/// What those decisions cost, in big blinds.
public let costInBB: Double
/// The habits behind it, commonest first, with how many times each was counted here.
public let leading: [(leak: Leak, times: Int)]
public var id: String { position }
public var costPer100: Double { hands > 0 ? costInBB / Double(hands) * 100 : 0 }
/// Counted mistakes per decision — the rate a seat drill is judged on.
public var mistakeRate: Double { decisions > 0 ? Double(mistakes) / Double(decisions) : 0 }
public var seat: Position? { Position(rawValue: position) }
/// "seat 4 of 9".
public var seatPhrase: String { seat?.seatPhrase(inTableOf: tableSize) ?? position }
/// "seat 4 of 9 (under the gun + 1)".
public var described: String { seat?.described(inTableOf: tableSize) ?? position }
public var number: Int? { seat?.number(inTableOf: tableSize) }
public static func == (a: SeatReport, b: SeatReport) -> Bool {
a.position == b.position && a.tableSize == b.tableSize && a.hands == b.hands
&& a.decisions == b.decisions && a.mistakes == b.mistakes && a.costInBB == b.costInBB
&& a.leading.map(\.leak.id) == b.leading.map(\.leak.id)
&& a.leading.map(\.times) == b.leading.map(\.times)
}
}
// MARK: - The bars
/// The fewest hands a seat needs before it is compared with the others at all.
///
/// The real sample is decisions, not hands (`minimumDecisions`): a seat the deal puts you in
/// often but that you fold from every time has hands and nothing to judge, and the early
/// seats of a tournament vanish as the table shrinks, so seat 4 of 9 collects a fifth of the
/// hands the button does while producing more decisions per hand. This floor only keeps a
/// seat off the comparison while it is a dozen hands old.
///
/// Where the numbers come from — `pokerlab seat-sweep`, 2026-09-13, the app's own nine-handed
/// tournament table:
///
/// control, 8 seeds × 6,300 hands, bottom rung: the flawless player's counted mistake rate
/// per seat runs 8–13% everywhere except the big blind, which runs 18–25% (defending the
/// blind is where a sound player's decisions are counted most). The gap between its worst
/// and second-worst seat is 2–5 points once every seat has 150 decisions, and the widest
/// it ever reaches on the way there is 14.0 points (the big blind at 287 decisions, one
/// seed). Top rung, 4 seeds: widest 9.2. Named: 0 of 60 seed×checkpoint reads.
///
/// a sound player who opens 85% of hands from seat 4 of 9 only (`PlantedPlayer.badInSeat`),
/// 8 seeds × 6,300: that seat runs 38–44% against 8–13% elsewhere, a gap of 19–26 points.
/// Named 6/8 seeds by 1,350 hands (about 150 decisions at the seat), 8/8 from 2,250 on,
/// with "playing too many hands" leading every time.
///
/// the same flaw planted in the lojack (seat 6) is NOT separable this way: a tournament
/// table is nine-handed only briefly, so the lojack gets ~100 hands in 3,600, and opening
/// wide there is only mildly wrong (gap 8–12 points). Recorded rather than fixed — a
/// seat that barely exists at a shrinking table is not one the diagnosis can speak about.
///
/// Cost per hundred was the first ranking and was dropped: one fold to a re-raise priced at
/// fifty big blinds made a seat with 106 hands read 127 bb/100 against 10–40 elsewhere, and
/// the control was named for it. The rate is what a habit is.
public static let minimumHands = 60
/// Decisions the coach looked at there before the seat's rate is compared: the control's big
/// blind still swung 14 points at 287 decisions; the planted seat is found at about 150.
public static let minimumDecisions = 150
/// How far the worst seat's mistake rate has to be above the second-worst's before it is worth
/// saying — a fraction of decisions, so 0.15 is fifteen points. Sits between the control's
/// widest excursion (14.0) and the planted seat's narrowest gap (19). See `minimumHands`.
public static let requiredMargin = 0.15
/// Fewest decisions a seat drill can clear on. A drill sits the player in the seat every
/// hand, so decisions come far faster than at a table; the floor is the point at which the
/// control's seat rates have stopped swinging (see above), not a round number.
public static let drillFloorDecisions = 60
/// The counted mistake rate a seat drill has to be at or under to clear: the top of the
/// control's band for every seat but the big blind (`bigBlindAcceptableRate`).
public static let acceptableRate = 0.15
public static let bigBlindAcceptableRate = 0.27
public static func acceptableRate(for seat: Position) -> Double {
seat == .bigBlind ? bigBlindAcceptableRate : acceptableRate
}
// MARK: - The reading
/// Every seat played from, in the order they sit round the table.
public let seats: [SeatReport]
/// The seat the player loses most from — or nil while the hands cannot honestly say.
public let worst: SeatReport?
/// Why there is no worst seat yet, for the screen. Nil when there is one.
public let reason: String?
public init(of records: [HandRecord]) {
// The deal's choice of seat only. Arranged hands say which seat the app chose.
let dealt = records.filter { !$0.isArranged }
let order = Position.allCases.map(\.rawValue)
let catalogue = LeakCatalogue.nameable
seats = order.compactMap { name in
let group = dealt.filter { $0.position == name }
guard !group.isEmpty else { return nil }
let seat = Position(rawValue: name)
var sizes: [Int: Int] = [:]
for record in group { sizes[record.seatsAtTable, default: 0] += 1 }
let commonest = sizes.max { a, b in
a.value != b.value ? a.value < b.value : a.key > b.key
}?.key ?? 9
let size = seat?.number(inTableOf: commonest) != nil ? commonest : 9
var decisions = 0, mistakes = 0
var cost = 0.0
var byLeak: [String: Int] = [:]
for record in group {
for verdict in record.verdicts {
decisions += 1
guard verdict.countsTowardsLeak else { continue }
mistakes += 1
// One decision the coach priced at a stack is a price, not a habit: capped
// here as the cash rating caps it, so a seat with few hands cannot be made
// the worst by a single verdict. The rate below is not touched by it.
cost += min(verdict.costInBB, DecisionEvaluator.mostOneDecisionCosts)
if let spot = verdict.spotKind, let error = verdict.errorKind,
let leak = LeakCatalogue.matching(spot: spot, error: error),
catalogue.contains(where: { $0.id == leak.id }) {
byLeak[leak.id, default: 0] += 1
}
}
}
let leading = byLeak
.compactMap { id, times in LeakCatalogue.named(id).map { ($0, times) } }
.sorted { a, b in a.1 != b.1 ? a.1 > b.1 : a.0.id < b.0.id }
.prefix(3)
.map { (leak: $0.0, times: $0.1) }
return SeatReport(position: name, tableSize: size, hands: group.count,
decisions: decisions, mistakes: mistakes, costInBB: cost,
leading: Array(leading))
}
let (worst, reason) = SeatDiagnosis.pickWorst(from: seats)
self.worst = worst
self.reason = reason
}
/// The seat where the most decisions go wrong, among those with enough hands, if it is far
/// enough ahead of the next one to be more than chance.
///
/// Ranked by the counted mistake rate rather than by cost, deliberately. Cost per hundred is
/// what the seat is *said* to lose, but in a tournament a single fold to a re-raise can be
/// priced at fifty big blinds, and in the control sweep one such verdict made a seat with 106
/// hands read 127 bb/100 against 10–40 everywhere else — a flawless player named for a
/// price tail. The rate is what a habit is: how often a decision there goes wrong.
static func pickWorst(from seats: [SeatReport]) -> (SeatReport?, String?) {
let eligible = seats.filter { $0.hands >= minimumHands && $0.decisions >= minimumDecisions }
guard let top = eligible.max(by: { $0.mistakeRate < $1.mistakeRate }) else {
let closest = seats.max { $0.hands < $1.hands }
let more = closest.map { max(0, minimumHands - $0.hands) } ?? minimumHands
return (nil, SeatDiagnosis.notEnoughLine(more: more, seat: closest))
}
// Nothing to compare against a single seat, and nothing to say about a seat where the
// coach counted nothing.
guard top.mistakes > 0 else {
return (nil, "Nothing the coach counted against you from any seat with \(minimumHands) hands yet.")
}
let others = seats.filter {
$0.position != top.position && $0.hands >= minimumHands / 2 && $0.decisions >= minimumDecisions / 2
}
let runnerUp = others.map(\.mistakeRate).max() ?? 0
guard top.mistakeRate - runnerUp >= requiredMargin else {
return (nil, "No one seat stands out yet: how often a decision goes wrong is within "
+ "\(Int((requiredMargin * 100).rounded())) points from seat to seat, which is how a "
+ "sound player's seats differ by chance. More hands will separate them if one "
+ "is really worse.")
}
return (top, nil)
}
static func notEnoughLine(more: Int, seat: SeatReport?) -> String {
guard let seat, more > 0 else {
return "Not enough hands in any one seat yet — it takes \(minimumHands) at a seat before "
+ "its number means anything."
}
return "Not enough hands in any one seat yet — \(more) more at \(seat.seatPhrase), your "
+ "most-played seat, before its number means anything."
}
// MARK: - Saying it
/// "You lose most from seat 4 of 9 (under the gun + 1): about 31 big blinds for every 100
/// hands there, mostly from playing too many hands and folding to a raise too much."
public var headline: String? {
guard let worst else { return nil }
let per100 = Int(worst.costPer100.rounded())
var line = "You lose most from \(worst.described): about \(per100) big blinds for every "
+ "100 hands there"
let names = worst.leading.prefix(2).map { $0.leak.name.lowercasedFirst }
switch names.count {
case 0: line += "."
case 1: line += ", mostly from \(names[0])."
default: line += ", mostly from \(names[0]) and \(names[1])."
}
return line
}
/// The sentence under the headline: the numbers it rests on.
public var evidenceLine: String? {
guard let worst else { return nil }
let hands = worst.hands == 1 ? "1 hand" : "\(worst.hands) hands"
let nextRate = seats.filter { $0.position != worst.position }.map(\.mistakeRate).max() ?? 0
return "\(hands) there, \(worst.mistakes) of \(worst.decisions) decisions counted "
+ "against you — \(Int((worst.mistakeRate * 100).rounded())) in every hundred, against "
+ "\(Int((nextRate * 100).rounded())) at your next-worst seat."
}
/// The report for a seat, by the name it was written down under.
public func report(for position: Position) -> SeatReport? {
seats.first { $0.position == position.rawValue }
}
}
public extension String {
/// "Folding the big blind too often" → "folding the big blind too often", for mid-sentence use.
var lowercasedFirst: String {
guard let first = first else { return self }
return first.lowercased() + dropFirst()
}
}