← poker-tutor
import Foundation
import PokerCore
import PokerCoach
/// The player's weaknesses, ranked by what they have actually cost.
///
/// Built from the written-down hands rather than from the coach's memory, for two reasons. The
/// coach's profile starts empty every time the app opens, so a scorecard read off it tells someone
/// with a year of hands behind them that they have no leaks. And the history can be pointed at one
/// sitting or at all of them, which is the question worth asking — "is the thing I have been working
/// on actually better this evening?" — and memory cannot answer it.
///
/// Four rules run through the whole of this type, and they are all the same rule: say only what the
/// hands support.
///
/// - A mistake made twice is not a habit. Nothing is called a leak until there have been enough
/// chances to make it, and until then the count is shown with a note saying exactly that.
/// - A cost that cannot be worked out from what was recorded is reported as unknown, never as
/// zero. Nought big blinds reads as "this is free", which is a different claim entirely.
/// - How often a habit happens is counted over every hand in view, arranged ones included. What it
/// costs is counted only over the hands that were dealt rather than arranged. The two answers
/// differ because the questions do: being put in a situation on purpose does not change how often
/// you get it right when you are in it, and it does change what the evening won or lost. An
/// assessment sits the player in the big blind and on a short stack far more often than a real
/// table does, so pricing a habit per hundred hands over those hands states a fact about ordinary
/// play that was measured on anything but. Where every hand in view was arranged, the money is
/// reported as not known — which is the same refusal as the one above.
/// - A player with nothing much wrong is told so. The coach does not manufacture a list to look
/// busy; that is the fastest way to stop being believed.
public struct LeakScorecard: Sendable, Equatable {
/// How sure the scorecard is about one leak.
public enum Standing: String, Sendable, Equatable {
/// Enough chances, and it happens more often than it should. A habit.
case costing
/// Heading the wrong way, but on too few chances to say so yet.
case watching
/// Either it does not happen often enough to matter, or it has never been measured.
case fine
}
/// Where a leak mostly happens, when it mostly happens somewhere.
public struct SeatSpot: Sendable, Equatable {
/// The seat as it was written down — "CO". Kept because that is what is on disk.
public let position: String
public let tableSize: Int
public let times: Int
private var seat: Position? { Position(rawValue: position) }
/// "seat 8 of 9" — the number leads, as it does everywhere else.
public var seatPhrase: String { seat?.seatPhrase(inTableOf: tableSize) ?? position }
public var fullName: String { seat?.fullName ?? position }
public var number: Int? { seat?.number(inTableOf: tableSize) }
/// "seat 8 of 9 (cutoff)".
public var described: String { seat?.described(inTableOf: tableSize) ?? position }
}
/// One leak, measured.
public struct Entry: Sendable, Equatable, Identifiable {
public let leak: Leak
public var id: String { leak.id }
/// How many times the player was in a spot where this mistake was available. Counted over
/// every hand in view, arranged ones included.
public let chances: Int
/// How many of those went wrong, on the same count.
public let times: Int
/// How many of those happened in a hand that was arranged rather than dealt. They are in
/// `times` and out of the money, and the row says so rather than leaving the two numbers to
/// disagree in silence.
public let arrangedTimes: Int
/// How many of the dealt instances the coach was able to put a price on.
public let pricedTimes: Int
/// What it has cost, in big blinds, over the hands that were dealt rather than arranged —
/// nil where nothing recorded lets that be worked out, or where there are no such hands.
/// Never zero standing in for "not known".
public let costInBB: Double?
/// The same cost spread over the dealt hands in view, which is how a poker player reads any
/// number like this. Nil for the same reasons.
public let costPer100: Double?
/// The hands the money above is spread over: the ones in view that were dealt rather than
/// arranged.
public let costHands: Int
/// The hands in view that were arranged — an assessment. They are counted towards how often
/// this happens and kept out of what it costs.
public let arrangedHands: Int
public let standing: Standing
/// Where it mostly happens, if it mostly happens in one seat rather than everywhere.
public let worstSeat: SeatSpot?
/// Whether this one has been drilled, and what is owed on it. Nil where it never has been.
public let drill: DrillBook.Standing?
/// What the assessment named, where it named this one. Nil otherwise.
///
/// The app used to carry two bars for the same question and let them contradict each other
/// on one screen. The assessment names a habit when even the cautious end of what its
/// chances can show is still worse than a steady player, which can happen off eight
/// chances; this card names one when there have been as many chances as the catalogue says
/// it takes. So a fresh install produced a report reading "One habit was already clear
/// enough to be worth naming" beside a card reading "Nothing confirmed yet", and a row
/// whose own note said nothing is called a habit until there have been twenty-five. Two
/// cards, visible at once, and the wording of one denied the other.
///
/// The report is right and this card was reading the wrong hands: a forty-hand assessment
/// is evidence gathered on purpose, in the situation, and a measured interval that clears
/// the bar is a stronger statement than a raw count. So it carries the row and prints its
/// own numbers, exactly as a failed check does below.
public let assessment: AssessmentReport.Named?
/// The check that put this row back on the list, where the hands in view have nothing of
/// their own to say about it.
///
/// A reopened habit is forced onto the costing list below, and rightly: a check is evidence
/// in the same situation, gathered on purpose. But the count beside it came from the hands
/// in view, and drills are read all-time while the card follows the scope bar — so setting
/// the scope to one sitting produced a row under the heading "what is costing you" reading
/// "This has not come up yet" and "Only 0 chances so far", with an empty bar. The app
/// telling somebody a habit is costing them money and, one line down, that it has never
/// seen it. Where the hands in view cannot carry the row, the check's own numbers do, and
/// the sentence says which they are.
public var reopenedCheck: DrillBook.Standing? {
guard let drill, drill.isReopened, drill.chances > 0, !enoughChances else { return nil }
return drill
}
/// The assessment's finding, where it is the thing carrying this row.
///
/// Same rule as the check above: only where the hands in view have not had enough chances
/// to speak for themselves. Once they have, they are the better evidence and they win —
/// four hundred hands of ordinary poker outrank forty arranged ones, in either direction.
public var namedByAssessment: AssessmentReport.Named? {
guard let assessment, assessment.certain, reopenedCheck == nil, !enoughChances else { return nil }
return assessment
}
/// How often it goes wrong: out of the hands in view, or out of the evidence carrying the
/// row where the hands in view cannot.
///
/// The check's rate is the one the check was judged on — the one with the forgiven slip in
/// it. Printing the raw rate here while the pass or fail turned on the smoothed one is the
/// same split the drill report had removed from it: it put "2 of 12 went wrong — 17% of the
/// time. A steady player is nearer 18%" under the heading "what is costing you", and drew
/// the bar short of the mark on a row that had just failed. One number, or the screen
/// argues with itself.
public var rate: Double {
if let check = reopenedCheck {
return DrillRule.rate(chances: check.chances, mistakes: check.mistakes)
}
if let assessment = namedByAssessment { return assessment.rate }
return chances > 0 ? Double(times) / Double(chances) : 0
}
public var percent: Int { Int((rate * 100).rounded()) }
public var acceptablePercent: Int { Int((leak.acceptableRate * 100).rounded()) }
/// Whether there have been enough chances for the rate to mean anything.
public var enoughChances: Bool { chances >= leak.minimumSample }
public var costIsKnown: Bool { costInBB != nil }
/// "14 times out of 62 chances — 23% of the time."
public var frequencyLine: String {
if let check = reopenedCheck {
return "The check put this back on your list: \(check.mistakes) of "
+ "\(check.chances) went wrong. One slip is always added to that count before "
+ "it is judged, which makes it \(percent)%, against the "
+ "\(acceptablePercent)% this has to be under. The hands you are looking at "
+ "here have not been in this situation often enough to add anything."
}
if let assessment = namedByAssessment {
return "The assessment put this on your list: \(assessment.times) of "
+ "\(assessment.chances) went wrong, and even the cautious end of what "
+ "\(assessment.chances) chances can show is at least \(assessment.lowPercent)%, "
+ "against the \(acceptablePercent)% this has to be under. The hands you are "
+ "looking at here have not been in this situation often enough to add anything."
}
guard chances > 0 else { return "This has not come up yet." }
return "\(times) \(times == 1 ? "time" : "times") out of \(chances) "
+ "\(chances == 1 ? "chance" : "chances") — \(percent)% of the time. "
+ "Anything over \(acceptablePercent)% is where this stops being ordinary and "
+ "starts being a habit."
}
/// What it has cost, or an admission that nobody knows.
///
/// "About 8.0 for every 100 hands you play" is a claim about ordinary poker, so it is
/// worked out over ordinary hands. On a fresh install the first thing anybody does is a
/// forty-hand assessment, and that sentence used to be printed off nothing but arranged
/// hands — the seat and the stacks chosen, the big blind three times as often as a real
/// table deals it. The count above is fair on those hands and the money never was.
public var costLine: String {
guard let costInBB, let costPer100 else {
if costHands == 0, arrangedHands > 0 {
return "Every hand you are looking at was arranged rather than dealt, so what "
+ "this costs in ordinary play is something these hands cannot say. How "
+ "often it happens, above, is the part they can measure."
}
return "What this has cost cannot be worked out from what was written down, so it "
+ "is not being guessed at. The count above is the part that is measured."
}
let money = String(format: "It has cost you %.1f big blinds so far — about %.1f for "
+ "every 100 hands you play.", costInBB, costPer100)
guard arrangedHands > 0 else { return money }
let dealt = costHands == 1
? "the one hand here that was dealt rather than arranged"
: "the \(costHands) hands here that were dealt rather than arranged"
let arranged = arrangedHands == 1
? "The one arranged hand counts towards how often this happens and stays out of "
+ "the money."
: "The \(arrangedHands) arranged ones count towards how often this happens and "
+ "stay out of the money."
return money + " That is counted over \(dealt). " + arranged
}
/// Where only some of the instances could be priced, said out loud.
///
/// The money on a row is the sum of the ones that could be priced and nothing else. Beside
/// "14 times out of 62 chances" a reader will divide the cost by 14, and where only four
/// of the fourteen carried a price that is wrong by a factor of three. Erring low is not
/// the same as being clear, so the row says which number the money is out of.
public var pricedNote: String? {
guard costInBB != nil, pricedTimes < times else { return nil }
var line = "That cost is \(pricedTimes) of the \(times)."
if arrangedTimes > 0 {
line += " \(arrangedTimes) of them happened in arranged hands, which are counted "
+ "here and kept out of the money."
}
let unpriced = times - pricedTimes - arrangedTimes
if unpriced > 0 {
line += " Nothing written down lets the other \(unpriced) be priced, so "
+ "\(unpriced == 1 ? "it is" : "they are") counted and left uncosted rather "
+ "than guessed at."
}
return line
}
/// The small-sample warning, or nothing at all where there is nothing to warn about.
///
/// A row carried by a check does not get one. It would be measuring the wrong thing: the
/// chances that put it back on the list are the check's twelve, not the nought this scope
/// happens to hold, and "nothing is called a habit until there have been 25" is not what
/// happened to this one.
public var sampleNote: String? {
guard reopenedCheck == nil, namedByAssessment == nil else { return nil }
if times < 3 && times > 0 {
return "Seen \(times) \(times == 1 ? "time" : "times"). Twice is an accident; it "
+ "takes more than that before anyone should call it a habit."
}
guard !enoughChances else { return nil }
return "Only \(chances) \(chances == 1 ? "chance" : "chances") so far. Nothing is "
+ "called a habit here until there have been \(leak.minimumSample), so treat this "
+ "as something to keep an eye on rather than a fact about your game."
}
/// "Mostly from seat 2 of 9 (the big blind) — 9 of the 14." Nil where it is spread about.
public var seatLine: String? {
guard let worstSeat else { return nil }
return "Mostly from \(worstSeat.described) — \(worstSeat.times) of the \(times)."
}
}
/// The moment the drill dates on this scorecard were worked out against.
public let asOf: Date
/// How many hands the scorecard was built from.
public let hands: Int
/// Of those, how many were dealt rather than arranged. This is the denominator under every
/// money figure on the card; `hands` is the denominator under every rate.
public let dealtHands: Int
/// Of those, how many were arranged — an assessment. Counted towards the rates, kept out of
/// the money.
public let arrangedHands: Int
/// Hands that were handed in and dropped on the way: drill and check hands, which this card
/// never counts. Kept so the card can tell "nothing has been played here" apart from "what was
/// played here was practice", which are different answers and used to share a sentence.
public let drillHands: Int
/// Every leak that has come up at least once, worst first.
public let entries: [Entry]
/// Every leak in the catalogue, in catalogue order, measured or not — the full list, for
/// someone who wants to see what they are doing well too.
public let allEntries: [Entry]
/// The same counts in the shape the coach keeps them in, so a rating can be worked out from
/// saved hands rather than only from the current sitting. The coach's own memory starts empty
/// every launch; this does not.
public let profile: LeakProfile
/// What these hands say the player is worth, on the same 0-100 scale the tables are set to.
/// Read it with `hands` in mind — under a couple of hundred it is pulled hard towards the
/// middle on purpose, because that is all the hands support.
public var rating: SkillRating { rating(for: LeakCatalogue.format) }
/// The same number on a named scale — the ladder asks for the scale of the game the counted
/// hands were played at, which is not always the game the player is looking at now.
public func rating(for format: CoachFormat) -> SkillRating {
SkillRating(costPer100: profile.costPer100Hands, hands: profile.handsPlayed,
leakPenalty: profile.leakPenalty, format: format)
}
/// Real habits, worst first.
public var costing: [Entry] { entries.filter { $0.standing == .costing } }
/// Heading the wrong way on too few chances to be sure.
public var watching: [Entry] { entries.filter { $0.standing == .watching } }
/// Poker takes a lot of hands before anything means much. Under this, the individual verdicts
/// are worth more than any rate on this screen. Matches the bar the rest of the app uses.
public var enoughHands: Bool { hands >= 200 }
/// What the confirmed habits are costing together, per 100 hands of ordinary poker. Nil if none
/// of them could be priced — including when every hand in view was arranged, which is the state
/// a fresh install is in the moment the assessment ends.
public var costPer100: Double? {
let priced = costing.compactMap(\.costPer100)
return priced.isEmpty ? nil : priced.reduce(0, +)
}
/// The one sentence at the top of the screen. Silence is a real answer here: a player with
/// nothing much wrong gets told that plainly rather than handed a manufactured list.
public var summary: String {
if hands == 0 {
// "No hands here yet" under a scope bar reading "45 hands" is the app calling its own
// heading a liar. Every hand in a drill sitting is dropped on the way in — that is the
// rule that stops anybody practising a habit off their own record — so a card built
// from one is empty for a reason worth saying out loud.
guard drillHands == 0 else {
let played = drillHands == 1
? "The one hand in what you are looking at was"
: "All \(drillHands) hands in what you are looking at were"
return "\(played) practice at one situation. Drill "
+ "hands are deliberately kept off this list: doing something forty times over "
+ "while you watch for it says nothing about how you play when you are not. "
+ "What counts is the check a few days later."
}
return "No hands here yet. Play a few and this fills in on its own."
}
if costing.isEmpty {
let measured = entries.contains { $0.times > 0 }
if !measured {
return "Nothing has gone wrong often enough to measure in these \(hands) hands. "
+ "Keep playing — this needs a few hundred before it can tell you much."
}
return "Nothing is costing you much yet. Mistakes have been made, but none of them "
+ "often enough in the same spot to be a habit worth working on."
}
let count = costing.count == 1 ? "One habit is" : "\(costing.count) habits are"
guard let costPer100 else {
guard dealtHands == 0, arrangedHands > 0 else {
return "\(count) showing up often enough to be worth working on, worst first."
}
return "\(count) showing up often enough to be worth working on, worst first. Every "
+ "hand here was arranged rather than dealt, so what they are costing you at an "
+ "ordinary table is left blank instead of being worked out from hands that were "
+ "set up for you."
}
return String(format: "%@ costing you about %.1f big blinds every 100 hands, worst first.",
count, costPer100)
}
/// The caveat under the summary, where the sample is too small to be believed on its own.
public var sampleNote: String? {
guard hands > 0, !enoughHands else { return nil }
return "This is \(hands) hands. Under a couple of hundred, treat everything below as a "
+ "first impression rather than a finding."
}
/// Habits that were drilled, cleared, and then came back when they were checked. They are on
/// the list again, and the row says exactly that rather than starting the count from nothing.
public var reopened: [Entry] { entries.filter { $0.drill?.isReopened == true } }
public static let empty = LeakScorecard(of: [])
// MARK: - Building it
private struct Tally {
var chances = 0
var times = 0
/// Instances that happened in an arranged hand. Part of `times`, and out of the money.
var arrangedTimes = 0
var pricedTimes = 0
/// Every priced instance, arranged ones included. This one feeds the rating and nothing a
/// player reads in big blinds — see `profile` at the bottom of the initialiser.
var cost = 0.0
/// The money the row prints: the priced instances that happened in hands which were dealt
/// rather than arranged.
var dealtCost = 0.0
/// Which seat each instance happened in, so the row can say where to look.
var seats: [String: [Int]] = [:]
}
/// Builds the scorecard from hands that were written down.
///
/// Drill hands are dropped on the way in, and that is a judgement rather than an oversight. A
/// drill deals the same situation forty times in a row to somebody who knows it is coming, so
/// letting those decisions into the count would let anybody drill a habit off their own record
/// without playing a single hand of poker differently. What a drill produces is reported by the
/// drill, and the check that runs days later — when the player has stopped watching for it — is
/// the part that is allowed to put a habit back on this list.
/// - Parameter assessment: what the last finished assessment named, so this card and that
/// report cannot be read side by side saying opposite things. Nil where there has not been
/// one, and ignored on any row the hands in view can already speak for.
public init(of allRecords: [HandRecord], drills: DrillBook = .empty,
assessment: AssessmentReport? = nil) {
let assessed = Dictionary(uniqueKeysWithValues: (assessment?.named ?? []).map { ($0.leak.id, $0) })
asOf = drills.now
let records = allRecords.filter { !$0.isDrill && !$0.isRetest }
hands = records.count
dealtHands = records.filter { !$0.isArranged }.count
arrangedHands = records.count - dealtHands
drillHands = allRecords.count - records.count
var tallies: [String: Tally] = [:]
for record in records {
let dealt = !record.isArranged
for verdict in record.verdicts {
guard let spot = verdict.spotKind else { continue }
// Every leak that lives in this spot had its chance to happen here. That is the
// denominator, and it is why the coach's quiet verdicts are written down too: a
// rate needs the decisions that went right as much as the ones that did not.
for leak in LeakCatalogue.all where leak.spot == spot {
tallies[leak.id, default: Tally()].chances += 1
}
guard verdict.countsTowardsLeak,
let error = verdict.errorKind,
let leak = LeakCatalogue.matching(spot: spot, error: error) else { continue }
var tally = tallies[leak.id, default: Tally()]
tally.times += 1
if !dealt { tally.arrangedTimes += 1 }
if verdict.costInBB > 0 {
tally.cost += verdict.costInBB
// The money on the row is ordinary poker only. An arranged hand put the player
// in this spot on purpose, so what it cost them says more about the arrangement
// than about their game.
if dealt {
tally.pricedTimes += 1
tally.dealtCost += verdict.costInBB
}
}
if !record.position.isEmpty {
tally.seats[record.position, default: []].append(record.seatsAtTable)
}
tallies[leak.id] = tally
}
}
let handCount = records.count
let dealtCount = dealtHands
// Only the habits the app is allowed to say somebody has get a row. A habit whose rate
// cannot separate a player who does the thing every time from one who never does is not a
// rate about the player — the sweep is in `LeakCatalogue` — so it is not put in front of
// them under any heading, including the ones that say "not proved". What it costs is still
// in `tallies` and still counts towards the rating below.
allEntries = LeakCatalogue.nameable.map { leak in
let tally = tallies[leak.id] ?? Tally()
let rate = tally.chances > 0 ? Double(tally.times) / Double(tally.chances) : 0
let tooOften = rate > leak.acceptableRate
let drill = drills.standing(of: leak.id)
var standing: Standing
if tally.chances >= leak.minimumSample, tally.times >= 3, tooOften {
standing = .costing
} else if tally.times >= 2, tooOften {
standing = .watching
} else {
standing = .fine
}
// A habit that was drilled, cleared, and then came back under a check is a habit
// again, whatever the hands since happen to say. The check is evidence in the same
// situation, and reopening something already measured well enough to be drilled needs
// less of it than naming it from nothing did.
if drill?.isReopened == true { standing = .costing }
// And so does a habit the assessment named, for the same reason: it is evidence
// gathered on purpose in the situation, and it cleared a bar this card has no way of
// clearing off forty hands. Only where the hands in view have too few chances of their
// own — past that they are the better evidence and they decide.
let named = assessed[leak.id]
if let named, named.certain, tally.chances < leak.minimumSample { standing = .costing }
// `pricedTimes` only counts dealt hands, so a cost here always has dealt hands under
// it and the per-100 never divides by nothing.
let cost: Double? = tally.pricedTimes > 0 ? tally.dealtCost : nil
return Entry(leak: leak,
chances: tally.chances,
times: tally.times,
arrangedTimes: tally.arrangedTimes,
pricedTimes: tally.pricedTimes,
costInBB: cost,
costPer100: cost.map { dealtCount > 0 ? $0 / Double(dealtCount) * 100 : 0 },
costHands: dealtCount,
arrangedHands: handCount - dealtCount,
standing: standing,
worstSeat: LeakScorecard.worstSeat(tally),
drill: drill,
assessment: named)
}
// A habit that has been drilled belongs on the screen even where the hands in view never
// put the player in that situation — the drill is the thing that has something to say.
entries = allEntries.filter { $0.chances > 0 || $0.drill != nil || $0.assessment != nil }
.sorted(by: LeakScorecard.worseFirst)
// The same counts, in the shape the coach keeps them, so the rating has one definition
// rather than two. Only the priced instances go into the total: an unknown cost stays
// unknown here too rather than being quietly counted as nought.
// This total is every priced instance over every hand, arranged ones included, which is on
// purpose and is the one place that differs from the money the rows print. The rating is a
// 0-100 scale rather than a claim about what an evening costs, both halves of the division
// are counted over the same hands, and the README argues that hands the player was put in
// still say how well they played them. What the rows print is big blinds, labelled as what
// ordinary play costs, so that one is worked out over ordinary hands.
// Catalogue order, not dictionary order. Adding doubles is not associative, so summing
// while walking a dictionary gives a total that changes in the last decimal place between
// launches of the same build on the same hands — Swift shuffles that order every time the
// program starts. It is a trivial amount of money and it feeds the rating the ladder is
// hung on, which makes it exactly the class of thing this project does not allow: a number
// that answers differently on each run cannot be the number a promotion turns on.
var rebuilt: [String: LeakProfile.Record] = [:]
var total = 0.0
for leak in LeakCatalogue.all {
guard let tally = tallies[leak.id] else { continue }
rebuilt[leak.id] = LeakProfile.Record(opportunities: tally.chances, mistakes: tally.times,
costInBB: tally.cost)
total += tally.cost
}
profile = LeakProfile(handsPlayed: handCount,
decisionsJudged: records.reduce(0) { $0 + $1.verdicts.count },
totalCostInBB: total,
records: rebuilt)
}
/// Worst first — but "worst" has to answer to the sample before it answers to the money.
///
/// A confirmed habit always outranks one that is only being watched, however expensive the
/// watched one looks: ranking a leak seen twice above one seen forty times is exactly how a
/// screen like this starts lying. Inside a group the money decides, and a leak whose cost is
/// unknown sits below the ones that could be priced rather than being given a nought and
/// ranked as though it were harmless.
private static func worseFirst(_ a: Entry, _ b: Entry) -> Bool {
func rank(_ entry: Entry) -> Int {
switch entry.standing {
case .costing: return entry.costIsKnown ? 0 : 1
case .watching: return 2
case .fine: return 3
}
}
let (left, right) = (rank(a), rank(b))
if left != right { return left < right }
if let x = a.costInBB, let y = b.costInBB, x != y { return x > y }
if a.times != b.times { return a.times > b.times }
return a.leak.id < b.leak.id
}
/// The seat a leak mostly happens in — reported only when it really is mostly one seat, and
/// only off enough instances to be worth saying. Anything less is a pattern in noise.
private static func worstSeat(_ tally: Tally) -> SeatSpot? {
guard tally.times >= 3 else { return nil }
guard let (position, sizes) = tally.seats.max(by: { a, b in
a.value.count != b.value.count ? a.value.count < b.value.count : a.key > b.key
}) else { return nil }
// A clear majority — three in five — rather than merely the largest share. An even split
// between two seats is not "mostly from" either of them.
guard sizes.count * 5 >= tally.times * 3 else { return nil }
// A tournament table shrinks as people bust, so the same seat name is a different number at
// a different size. Report the size these instances mostly happened at; ties go to the
// smaller table, as they do on the My game screen.
var counts: [Int: Int] = [:]
for size in sizes { counts[size, default: 0] += 1 }
let commonest = counts.max { a, b in
a.value != b.value ? a.value < b.value : a.key > b.key
}?.key ?? 9
return SeatSpot(position: position, tableSize: commonest, times: sizes.count)
}
}