← poker-tutor
import PokerCore
import PokerCoach
/// Whether a habit was ever named during a run, rather than only at the end of it.
///
/// The distinction is the whole point. `Coach.profile.confirmed()` is read live: a player who sits
/// down for an evening sees the list at every point along the way, so a bar that convicts them at
/// three hundred hands and clears them again by four thousand has still told them something untrue.
/// Reading the profile only at the end of a long run hides exactly that, and it is a close cousin
/// of the fault the river-call bar was found by — where a floor no run ever reached was doing the
/// work a correct bar should have been doing.
///
/// So this replays the decisions in the order they happened, keeps the same running tallies the
/// coach keeps, and applies the same three tests after every one: enough chances, more often than a
/// steady player, and at least three of them. The moment all three are true of a player with no
/// planted flaw, the bar is wrong.
public struct NamingSweep {
public struct Moment: Sendable {
public let leak: Leak
/// The chances behind it the first time it was named.
public let chances: Int
public let times: Int
public var rate: Double { chances > 0 ? Double(times) / Double(chances) : 0 }
}
/// Every habit named at any point in the run, with the numbers it was first named on.
public let named: [Moment]
/// How many chances the run produced at each habit, all the way through.
public let chances: [String: Int]
/// The rate each habit finished on.
public let finalRate: [String: Double]
/// The worst the cumulative rate ever got, at or beyond each of a few chance counts.
///
/// This is the number a floor has to be chosen from. A habit is named the moment the running
/// rate crosses the bar with the floor behind it, so what matters is not where a flawless
/// player ends up but the highest his running rate ever reaches once there are enough chances
/// to be judged on. `worstFrom[id]![25]` is "the worst this ever read once it had 25 chances".
public let worstFrom: [String: [Int: Double]]
/// The chance counts `worstFrom` is measured at.
public static let marks = [12, 25, 50, 75, 100, 150, 200, 300, 500, 800]
public init(_ coach: Coach) {
var counts: [String: (chances: Int, times: Int)] = [:]
var firstNamed: [String: Moment] = [:]
var worst: [String: [Int: Double]] = [:]
for review in coach.reviews {
for verdict in review.verdicts {
for leak in LeakCatalogue.all where leak.spot == verdict.spot {
counts[leak.id, default: (0, 0)].chances += 1
}
if verdict.countsTowardsLeak, let error = verdict.error,
let leak = LeakCatalogue.matching(spot: verdict.spot, error: error) {
counts[leak.id, default: (0, 0)].times += 1
}
// Checked after every decision, because that is when a player could read it.
for leak in LeakCatalogue.nameable {
guard let count = counts[leak.id], count.chances > 0 else { continue }
let rate = Double(count.times) / Double(count.chances)
for mark in NamingSweep.marks where count.chances >= mark && count.times >= 3 {
worst[leak.id, default: [:]][mark] = max(worst[leak.id]?[mark] ?? 0, rate)
}
guard firstNamed[leak.id] == nil,
count.chances >= leak.minimumSample, count.times >= 3,
rate > leak.acceptableRate
else { continue }
firstNamed[leak.id] = Moment(leak: leak, chances: count.chances, times: count.times)
}
}
}
named = LeakCatalogue.nameable.compactMap { firstNamed[$0.id] }
var chances: [String: Int] = [:]
var rates: [String: Double] = [:]
for leak in LeakCatalogue.all {
guard let count = counts[leak.id] else { continue }
chances[leak.id] = count.chances
rates[leak.id] = count.chances > 0 ? Double(count.times) / Double(count.chances) : 0
}
self.chances = chances
self.finalRate = rates
self.worstFrom = worst
}
}