← poker-tutor
import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerTests
import PokerSession
/// Measures a habit's bar against the two populations it has to separate, across many seeds.
///
/// This exists because the project has been burned three times by moving a threshold off a single
/// run. A bar is only honest if the player who has the habit and the player who does not measure
/// on opposite sides of it, at a sample where the two bands have stopped overlapping — and neither
/// of those things can be seen in one run of one seed. So the numbers written into
/// `LeakCatalogue` come from here, and the band each one came from is copied into a comment
/// beside it.
///
/// pokerlab sweep --hands 4000 --seeds 12 every habit, control only
/// pokerlab sweep --player river --against control --hands 4000 --seeds 20
///
/// The `named` column is the one that matters for a control run: any habit a flawless player is
/// named for is a bar sitting inside the band of the player it exists to clear.
/// pokerlab sweep --format cash --rung 0 --player stackoff --against control
///
/// At a cash table the run goes through the app's own table (`runPlantedCash`) rather than the
/// six-handed rig, at the rung asked for — the bars there have to hold in the rooms people play.
func runSweep(playerName: String, againstName: String?, hands: Int, seedCount: Int, firstSeed: UInt64,
format: AppVariant.Format = .tournament, setup: CashSetup = .standard, rung: Int = 0) {
let seeds = (0..<seedCount).map { firstSeed &+ UInt64($0) &* 977 }
if format == .cash { AppVariant.use(.pokerTutorCash) }
var worst: [String: [Int: Double]] = [:]
func measure(_ player: PlantedPlayer) -> [String: [(rate: Double, chances: Int, named: Bool)]] {
var out: [String: [(rate: Double, chances: Int, named: Bool)]] = [:]
for seed in seeds {
let coach = format == .cash
? runPlantedCash(player, hands: hands, seed: seed, setup: setup, rung: rung, keepEveryReview: true)
: runPlanted(player, hands: hands, seed: seed, keepEveryReview: true)
// Named at any point in the run, not only at the end of it. A habit named at hand three
// hundred and gone by hand four thousand was still named, and the player read it.
let sweep = NamingSweep(coach)
let named = Set(sweep.named.map(\.leak.id))
for (id, marks) in sweep.worstFrom {
for (mark, rate) in marks { worst[id, default: [:]][mark] = max(worst[id]?[mark] ?? 0, rate) }
}
for leak in LeakCatalogue.all {
guard let record = coach.profile.records[leak.id], record.opportunities > 0 else { continue }
out[leak.id, default: []].append((record.rate, record.opportunities, named.contains(leak.id)))
}
}
return out
}
func band(_ runs: [(rate: Double, chances: Int, named: Bool)]) -> String {
let rates = runs.map(\.rate).sorted()
let mean = rates.reduce(0, +) / Double(rates.count)
let chances = runs.map(\.chances).sorted()
return String(format: "%3.0f-%3.0f%% (mean %4.1f) %4d-%4d chances named %d/%d",
rates.first! * 100, rates.last! * 100, mean * 100,
chances.first!, chances.last!, runs.filter(\.named).count, runs.count)
}
let subject = plantedPlayer(named: playerName)
print("\(seedCount) seeds x \(hands) hands, first seed \(firstSeed)."
+ (format == .cash ? " Cash table, \(setup.stakesLabel) \(setup.structure.rawValue), rung \(rung)." : ""))
print("")
let mine = measure(subject)
let theirs = againstName.map { measure(plantedPlayer(named: $0)) }
for leak in LeakCatalogue.all {
guard let runs = mine[leak.id] else { continue }
print(String(format: "%-40@ bar %2.0f%% over %3d", leak.name as NSString,
leak.acceptableRate * 100, leak.minimumSample))
print(" \(subject.name.padding(toLength: 22, withPad: " ", startingAt: 0)) \(band(runs))")
// The worst the running rate ever reached once it had this many chances behind it, across
// every seed. This is the column a floor is chosen from: a habit is named the moment the
// running rate crosses the bar with the floor behind it.
if againstName == nil, let marks = worst[leak.id], !marks.isEmpty {
let cells = NamingSweep.marks.compactMap { mark -> String? in
guard let rate = marks[mark] else { return nil }
return String(format: "%d:%2.0f%%", mark, rate * 100)
}
print(" worst running rate from \(cells.joined(separator: " "))")
}
if let theirs, let other = theirs[leak.id] {
let name = plantedPlayer(named: againstName!).name
print(" \(name.padding(toLength: 22, withPad: " ", startingAt: 0)) \(band(other))")
}
}
}