Advertisement

← poker-tutor

Sources/PokerSession/Assessment.swift

19.8 KB Download file
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 }
    }
}