Advertisement

← poker-tutor

Sources/PokerTests/AssessmentTests.swift

34.6 KB Download file
import Foundation
import PokerCore
import PokerAI
import PokerCoach
import PokerSession
import TestKit

/// Plays a whole assessment headlessly, driving `TableSession` exactly as a person would: by
/// answering when it is the player's turn and asking for the next hand when the last one is over.
///
/// Nothing about the assessment lives in the app layer, which is what makes this possible — and
/// what makes it the test that matters, because the same calls are what the screen makes.
public func playAssessment(_ player: PlantedPlayer, seed: UInt64,
                           hands: Int = AssessmentRun.standardLength,
                           history: HistoryStore = .inMemory(),
                           pinned: AssessmentSetup? = nil,
                           setup: CashSetup? = nil) -> TableSession {
    let session = TableSession(seed: seed, history: history, variant: AppVariant.current)
    session.pinnedSetup = pinned
    // A cash product's assessment is played at the stakes and structure the player chose.
    if let setup { session.cashSetup = setup }
    session.startAssessment(hands: hands)
    driveAssessment(session, player: player, seed: seed)
    return session
}

/// Answers for the player until the sitting stops. Split out so a test can stop half way, put the
/// session down, and pick a new one up off the same history.
public func driveAssessment(_ session: TableSession, player: PlantedPlayer, seed: UInt64,
                            stoppingAfter limit: Int = .max) {
    var rng = SeededRandom(seed: seed &* 7 &+ 3)
    var steps = 0
    while session.isPlaying, (session.assessment?.handsPlayed ?? 0) < limit {
        steps += 1
        precondition(steps < 50_000, "the assessment did not terminate")
        if session.heroToAct, let view = session.engine?.view(for: session.heroSeat) {
            session.act(player.decide(view, &rng))
        } else if session.engine?.isComplete == true {
            session.continueAfterReview()
        } else {
            session.finishHandNow()
        }
    }
}

/// A coverage with everything measured to death except the spots named — the state the planner is
/// meant to react to.
func coverage(missing gaps: Set<SpotKind>) -> AssessmentCoverage {
    var coverage = AssessmentCoverage()
    for spot in SpotKind.allCases where !gaps.contains(spot) {
        for index in 0..<400 { coverage.saw(spot, mistake: index % 6 == 0) }
    }
    return coverage
}

public func assessmentTests(_ t: TestRun) {

    t.suite("what the assessment knows it does not know") { t in

        t.test("a situation nobody has been watched in is worth nothing") {
            let empty = AssessmentCoverage()
            for spot in SpotKind.allCases {
                t.equal(empty.certainty(in: spot), 0, "\(spot.name) started out already understood")
            }
        }

        t.test("watching the same spot makes it surer, and never completely sure") {
            // Deliberately not step by step. This is a standard error, so one more chance that
            // pulls the rate back towards a coin flip really does make the measurement slightly
            // less certain, and that is correct rather than a bug — it is the whole reason a
            // muddled player takes longer to read than a consistent one. What has to hold is the
            // direction over a run of hands.
            var coverage = AssessmentCoverage()
            var marks: [Int: Double] = [:]
            for round in 0..<60 {
                coverage.saw(.bigBlindDefence, mistake: round % 4 == 0)
                marks[round + 1] = coverage.certainty(in: .bigBlindDefence)
                t.lessThan(coverage.certainty(in: .bigBlindDefence), 1.0,
                           "nothing is ever completely known")
            }
            t.expect(marks[4]! > marks[1]!, "four chances told it no more than one")
            t.expect(marks[20]! > marks[4]!, "twenty chances told it no more than four")
            t.expect(marks[60]! > marks[20]!, "sixty chances told it no more than twenty")
            t.expect(marks[60]! > 0.8, "sixty chances should firm a rate up; got \(marks[60]!)")
        }

        t.test("a spot that always goes the same way firms up faster than a coin flip") {
            var settled = AssessmentCoverage()
            var muddled = AssessmentCoverage()
            for index in 0..<20 {
                settled.saw(.facingRaise, mistake: false)
                muddled.saw(.facingRaise, mistake: index % 2 == 0)
            }
            t.expect(settled.certainty(in: .facingRaise) > muddled.certainty(in: .facingRaise),
                     "a player who never gets this wrong is better understood than one who is "
                     + "half and half on the same number of hands")
        }
    }

    t.suite("choosing the next hand") { t in

        // This is the adaptive part, tested where it can be pinned down: given a picture of what
        // has and has not been seen, the planner has to go to the gap.

        t.test("it goes to the situation nobody has looked at") {
            let wanted: [SpotKind: String] = [
                .shortStackShove: "short-stack",
                .bigBlindDefence: "big-blind",
                .facingReraise: "facing-heat",
                .facingRiverBet: "bet-at-you",
                .riverDecision: "deep-and-sticky",
            ]
            for (spot, setup) in wanted {
                let chosen = AssessmentPlanner.next(for: coverage(missing: [spot]))
                t.equal(chosen.id, setup,
                        "with only \(spot.name) unmeasured it should set the table up for it")
            }
        }

        t.test("the same picture always chooses the same hand") {
            let picture = coverage(missing: [.turnDecision, .facingRaise])
            let first = AssessmentPlanner.next(for: picture)
            for _ in 0..<5 {
                t.equal(AssessmentPlanner.next(for: picture).id, first.id)
            }
        }

        t.test("every situation the coach measures is reachable") {
            // A spot no setup ever aims at is one the assessment can never report on, which would
            // make its silence about that part of the game meaningless rather than reassuring.
            for spot in SpotKind.allCases {
                guard SpotKind.measured.contains(spot) else { continue }
                let reachable = AssessmentPlanner.setups.contains { ($0.expected[spot] ?? 0) > 0 }
                t.expect(reachable, "no setup ever aims at \(spot.name)")
            }
        }

        t.test("it stops asking a question it has the answer to") {
            let known = coverage(missing: [])
            let unknown = AssessmentCoverage()
            for setup in AssessmentPlanner.setups {
                t.lessThan(AssessmentPlanner.gain(of: setup, given: known),
                           AssessmentPlanner.gain(of: setup, given: unknown),
                           "\(setup.id) is worth as much when everything is known as when nothing is")
            }
        }
    }

    t.suite("an assessment sitting") { t in

        t.test("the player sits where the planner asked, at the depth it asked for") {
            let session = TableSession(seed: 31, history: .inMemory())
            session.startAssessment(hands: 12)
            var checked = 0
            var seatsUsed: Set<Position> = []
            var depths: Set<Int> = []
            var rng = SeededRandom(seed: 99)
            var steps = 0
            while session.isPlaying {
                steps += 1
                precondition(steps < 50_000)
                if let run = session.assessment, let engine = session.engine, engine.actions.count <= 9 {
                    let seat = engine.positions()[session.heroSeat]
                    t.equal(seat, run.setup.seat, "hand \(session.handNumber) sat the player wrong")
                    let stack = (engine.seats[session.heroSeat].stack
                                 + engine.seats[session.heroSeat].committedThisHand) / engine.config.level.bigBlind
                    t.equal(stack, run.setup.depthInBB, "hand \(session.handNumber) was the wrong depth")
                    seatsUsed.insert(run.setup.seat)
                    depths.insert(run.setup.depthInBB)
                    checked += 1
                }
                if session.heroToAct, let view = session.engine?.view(for: session.heroSeat) {
                    session.act(PlantedPlayer.control.decide(view, &rng))
                } else if session.engine?.isComplete == true {
                    session.continueAfterReview()
                } else {
                    session.finishHandNow()
                }
            }
            t.expect(checked > 0, "never got a look at a hand in progress")
            t.expect(seatsUsed.count >= 3, "twelve hands only ever used \(seatsUsed.count) seat(s)")
            t.expect(depths.count >= 2, "the chip depth never changed")
        }

        t.test("it reaches more of the game than sitting still does") {
            // The point of steering, measured rather than asserted: across several seeds, forty
            // steered hands have to put the player in more different kinds of situation than forty
            // hands of an ordinary table do. One seed proves nothing here — the first version of
            // this measurement passed on luck and had to be rewritten, twice.
            var steeredTotal = 0
            var ordinaryTotal = 0
            for seed: UInt64 in [7, 21, 44, 108, 255] {
                let steered = playAssessment(.control, seed: seed).assessment?.coverage ?? AssessmentCoverage()

                let plain = TableSession(seed: seed, history: .inMemory())
                plain.start(.cash)
                var rng = SeededRandom(seed: seed &* 7 &+ 3)
                var cover = AssessmentCoverage()
                var steps = 0
                while plain.isPlaying, plain.handNumber <= AssessmentRun.standardLength {
                    steps += 1
                    precondition(steps < 50_000)
                    if plain.heroToAct, let view = plain.engine?.view(for: plain.heroSeat) {
                        plain.act(PlantedPlayer.control.decide(view, &rng))
                    } else if plain.engine?.isComplete == true {
                        for verdict in plain.history.hands.last?.verdicts ?? [] {
                            if let spot = verdict.spotKind { cover.saw(spot, mistake: verdict.countsTowardsLeak) }
                        }
                        plain.continueAfterReview()
                    } else {
                        plain.finishHandNow()
                    }
                }

                steeredTotal += SpotKind.allCases.filter { steered.chances(in: $0) >= 3 }.count
                ordinaryTotal += SpotKind.allCases.filter { cover.chances(in: $0) >= 3 }.count
            }
            t.expect(steeredTotal > ordinaryTotal,
                     "steering reached \(steeredTotal) situations across five seeds against "
                     + "\(ordinaryTotal) for an ordinary table — it is not earning its keep")
        }

        t.test("it stops when it said it would") {
            let session = playAssessment(.control, seed: 5, hands: 14)
            t.equal(session.assessment?.handsPlayed, 14)
            t.expect(session.assessment?.isFinished == true)
            t.expect(!session.isPlaying, "the sitting should be over")
            t.equal(session.history.hands.count, 14)
        }

        t.test("a hand written down before assessments existed is not one") {
            // The other half of the optional-field rule: an old line has to read, and it has to
            // read as what it was. A missing field must not quietly become "yes".
            let old = HandRecord(sessionID: "old", sessionStarted: Date(), playedAt: Date(),
                                 handNumber: 1, mode: "Tournament", bigBlind: 100, position: "BTN",
                                 holeCards: "Ah Kd", board: "", netChips: 0, wentToShowdown: false,
                                 sawFlop: false, putMoneyInPreflop: false, raisedPreflop: false,
                                 verdicts: [])
            t.expect(!old.isAssessment, "a hand with no kind was taken for an assessment hand")
            t.equal(GameAnalysis(of: [old]).hands, 1, "an ordinary hand was left out of the numbers")
            t.equal(GameAnalysis(of: [old]).assessmentHands, 0)
        }

        t.test("every hand it deals says so on the way to disk") {
            let session = playAssessment(.control, seed: 6, hands: 8)
            t.equal(session.history.hands.count, 8, "no hands to check")
            for hand in session.history.hands {
                t.expect(hand.isAssessment, "hand \(hand.handNumber) was not marked as an assessment hand")
                t.expect(hand.setup != nil, "hand \(hand.handNumber) did not record how it was set up")
                t.expect(AssessmentPlanner.setup(id: hand.setup ?? "") != nil,
                         "hand \(hand.handNumber) recorded a setup nothing recognises")
            }
        }
    }

    t.suite("stopping an assessment half way") { t in

        t.test("it picks back up exactly where it was left") {
            let store = HistoryStore.inMemory()
            let first = TableSession(seed: 77, history: store)
            first.startAssessment(hands: 40)
            driveAssessment(first, player: .foldsBigBlind, seed: 77, stoppingAfter: 15)
            let stoppedAt = first.assessment?.handsPlayed ?? 0
            let sitting = first.sessionID
            t.expect(stoppedAt >= 15 && stoppedAt <= 16, "stopped at \(stoppedAt)")

            // A new session, as a fresh launch would build one, reading the same history back.
            let second = TableSession(seed: 900, history: store)
            let resumed = second.resumeAssessment(sitting)
            t.expect(resumed, "an unfinished sitting should be resumable")
            t.equal(second.assessment?.handsPlayed, stoppedAt)
            t.equal(second.assessment?.coverage, first.assessment?.coverage,
                    "the picture of what has been seen has to survive the reload")
            t.equal(second.sessionID, sitting, "resuming must not start a new sitting")
            // The fourth thing the sitting holds still. The eight opponents are the same eight
            // either way, but the skill they play at was being recomputed off a rating that had
            // moved in the meantime, so a sitting picked back up was measured against slightly
            // better or worse people than it started against.
            t.equal(second.tableDifficulty, first.tableDifficulty,
                    "the opponents changed skill part way through a measurement")
        }

        t.test("walking away leaves the history and the rating alone") {
            let store = HistoryStore.inMemory()
            let session = TableSession(seed: 12, history: store)
            session.startAssessment(hands: 40)
            driveAssessment(session, player: .control, seed: 12, stoppingAfter: 9)
            session.leave()
            let report = AssessmentReport(of: store.hands)
            t.expect(!report.finished, "a sitting that was abandoned must not read as finished")
            t.expect(report.headline.contains("stopped"), "the report has to admit it: \(report.headline)")
            t.equal(LeakScorecard(of: store.hands).hands, store.hands.count,
                    "the hands played still count towards the leaks")
            t.equal(GameAnalysis(of: store.hands).hands, 0,
                    "and none of them count towards a win rate")
        }

        t.test("a finished sitting is not offered again") {
            let store = HistoryStore.inMemory()
            let session = TableSession(seed: 4, history: store)
            _ = playAssessment(.control, seed: 4, hands: 6, history: store)
            t.equal(session.unfinishedAssessment(target: 6)?.id, nil)
            t.expect(session.unfinishedAssessment(target: 40) != nil,
                     "six hands of a forty-hand sitting is unfinished")
        }

        t.test("one sitting walked out on does not hide the one that was finished") {
            // Home shows the result and the way back to the report only while there is nothing to
            // carry on with, and it used to look for the newest *unfinished* sitting rather than
            // simply the newest one. So walking away three hands in, once, and then sitting a full
            // assessment afterwards left the offer stuck on "Carry on" from the three-hand stub for
            // ever, with no route to the report of the one that was actually finished. An
            // assessment that has been superseded is history rather than an offer.
            let store = HistoryStore.inMemory()
            let abandoned = TableSession(seed: 31, history: store)
            abandoned.startAssessment(hands: 6)
            driveAssessment(abandoned, player: .control, seed: 31, stoppingAfter: 2)
            abandoned.leave()
            let stub = store.sessions().first?.id
            let session = TableSession(seed: 4, history: store)
            t.equal(session.unfinishedAssessment(target: 6)?.id, stub,
                    "while it is the newest one, it is the one to carry on with")

            _ = playAssessment(.control, seed: 4, hands: 6, history: store)
            t.equal(session.unfinishedAssessment(target: 6)?.id, nil,
                    "the stub is still being offered over a sitting that ran to the end")
            t.expect(TableSession(seed: 5, history: store)
                        .lastFinishedAssessment(target: 6) != nil,
                     "and the finished one has to be reachable")
        }

        t.test("the sitting on the table is not offered as something to carry on with") {
            // You are in it. Offering it back reads as though there were another one waiting, and
            // it is the thing that hides the finished report behind it.
            let store = HistoryStore.inMemory()
            let session = TableSession(seed: 77, history: store)
            session.startAssessment(hands: 40)
            driveAssessment(session, player: .control, seed: 77, stoppingAfter: 4)
            t.greaterThan(store.hands.count, 0, "no hands were played, so nothing was tested")
            t.equal(session.unfinishedAssessment(target: 40)?.id, nil,
                    "it offered to carry on with the sitting already on the table")
            // A fresh launch, which is not sitting at it, is offered it.
            t.equal(TableSession(seed: 900, history: store)
                        .unfinishedAssessment(target: 40)?.id, session.sessionID)
        }
    }

    t.suite("the assessment report") { t in

        t.test("assessment hands stay out of the win rate and inside the leaks") {
            let session = playAssessment(.foldsBigBlind, seed: 3, hands: 20)
            let all = session.history.hands
            let analysis = GameAnalysis(of: all)
            t.equal(analysis.hands, 0, "an assessment must not be counted as hands played")
            t.equal(analysis.assessmentHands, 20)
            t.expect(analysis.arrangedNote != nil, "the screen has to be able to say why")
            t.equal(LeakScorecard(of: all).hands, 20, "the decisions still count")
        }

        t.test("it gives a range rather than a number it cannot support") {
            let session = playAssessment(.control, seed: 8)
            guard let report = session.assessmentReport else { return t.expect(false, "no report") }
            t.expect(report.ratingRange.contains(report.rating.value),
                     "the rating fell outside its own range")
            t.expect(report.ratingRange.lowerBound <= report.ratingRange.upperBound)
            t.expect(report.precisionNote.contains("\(report.hands) hands"),
                     "the note has to name the sample it is apologising for")
            // Forty hands cannot reach eleven situations, so some of them always come back empty
            // — and the assertion below is about exactly those. Counted first, because a loop over
            // nothing passes without checking a single line.
            let never = report.areas.filter { $0.chances == 0 }
            t.greaterThan(never.count, 0,
                          "\(report.hands) hands reached every one of the \(report.areas.count) "
                          + "situations, so nothing below was checked")
            for area in never {
                t.expect(area.line.contains("Never came up"),
                         "\(area.name) had nothing measured and did not say so")
            }
        }

        t.test("it says what it steered") {
            let session = playAssessment(.control, seed: 9, hands: 20)
            guard let report = session.assessmentReport else { return t.expect(false, "no report") }
            t.equal(report.steering.reduce(0) { $0 + $1.hands }, 20,
                    "every hand has to be accounted for in what was arranged")
            t.expect(report.steering.count >= 3, "twenty hands used only \(report.steering.count) setups")
            t.expect(report.fairnessNote.contains("never touched is the cards"),
                     "the one promise the whole measurement rests on has to be in the report")
        }

        t.test("what each hand was looking at is disclosed at the end, not during") {
            // These hands count towards the leaks, so naming the habit being counted while it is
            // being counted measures somebody who knows what is being watched — which is the same
            // objection that keeps drill hands out of the scorecard altogether. The list is not
            // suppressed, it is moved: it belongs in the report, where saying it costs nothing.
            let session = playAssessment(.control, seed: 9, hands: 20)
            guard let report = session.assessmentReport else { return t.expect(false, "no report") }
            t.greaterThan(report.steering.count, 0, "nothing was arranged, so nothing was checked")
            for steer in report.steering {
                t.expect(!steer.setup.watching.isEmpty,
                         "\(steer.setup.id) was arranged and the report does not say what for")
            }
            t.expect(report.fairnessNote.contains("rather than while you played"),
                     "the report has to own the fact that it kept this back: "
                     + "\"\(report.fairnessNote)\"")
            // And the band above the felt says the seat and the stacks, which the player can see
            // on the felt anyway, and nothing about which habit is being counted.
            for setup in AssessmentPlanner.setups {
                let banner = setup.introduction(atTableOf: 9)
                t.expect(!banner.contains(setup.watching),
                         "the live banner names the habit it is measuring: \"\(banner)\"")
            }
        }

        t.test("it finds a planted flaw and does not invent company for it") {
            // The measurement that decides whether an assessment is worth sitting through. Five
            // seeds, because forty hands of poker is a small sample and one seed here would be
            // measuring luck — which this project has already been caught doing twice.
            //
            // The bars differ on purpose, and the difference is the honest part. Playing far too
            // many hands shows up in every unopened pot, so forty hands proves it outright almost
            // every time. Folding the big blind only shows up when somebody raises at it *and* the
            // hand was worth continuing with, which is six to eleven chances in a sitting this
            // long — enough to raise, not always enough to prove. Setting both bars at "always"
            // would only be achievable by loosening what the report is allowed to claim.
            let seeds: [UInt64] = [11, 202, 3003, 40004, 500005]

            var proven = 0
            var strangersProven = 0
            for seed in seeds {
                let report = playAssessment(.playsEverything, seed: seed).assessmentReport
                if (report?.named ?? []).contains(where: { $0.leak.id == "too-many-hands" }) { proven += 1 }
                strangersProven += (report?.named ?? []).filter { $0.leak.id != "too-many-hands" }.count
            }
            t.expect(proven >= 4,
                     "a player who plays far too many hands was only proved in \(proven) of "
                     + "\(seeds.count) sittings")
            t.lessThan(strangersProven, 2,
                       "\(strangersProven) habits were proved that were never planted")

            var raised = 0
            var strangers = 0
            for seed in seeds {
                let report = playAssessment(.foldsBigBlind, seed: seed).assessmentReport
                let all = (report?.named ?? []) + (report?.watching ?? [])
                if all.contains(where: { $0.leak.id == "bb-overfold" }) { raised += 1 }
                strangers += all.filter { $0.leak.id != "bb-overfold" }.count
            }
            t.expect(raised >= 3,
                     "a player who folds every big blind was only picked up in \(raised) of "
                     + "\(seeds.count) sittings")
            t.lessThan(strangers, 3,
                       "they were handed \(strangers) other habits across \(seeds.count) "
                       + "sittings; a coach that invents company for a real finding stops being "
                       + "believed")
        }

        t.test("the report and the scorecard do not contradict each other on the same screen") {
            // The app carried two bars for one question. The report names a habit when even the
            // cautious end of what its chances can show is still worse than a steady player, which
            // an assessment can clear off eight; the scorecard names one when there have been as
            // many chances as the catalogue says it takes, which is twenty-five. So the first thing
            // a new player ever did produced two cards, visible at once, giving opposite answers —
            // "One habit was already clear enough to be worth naming" beside "Nothing confirmed
            // yet", over a row whose own note read "Nothing is called a habit here until there have
            // been 25".
            //
            // Neither bar moved. The assessment is evidence gathered on purpose, in the situation,
            // and it carries the row — the same way a failed check does — printing its own numbers
            // and saying which they are.
            // Drilled with the player who plays far too many hands, because that is the habit a
            // forty-hand sitting can actually prove: every unopened pot is a chance at it, so the
            // interval clears the bar off about twenty-two of them. The big blind player is the
            // wrong subject for this now — since its bar was re-derived to 30% over 50 chances, a
            // forty-hand sitting mostly *watches* that habit rather than naming it, which is the
            // honest outcome and gives this test nothing to check.
            var carried = 0
            var carriedByReport = 0
            for seed: UInt64 in [11, 202, 3003, 40004, 500005] {
                let session = playAssessment(.playsEverything, seed: seed)
                guard let report = session.assessmentReport else {
                    t.expect(false, "no report on seed \(seed)")
                    continue
                }
                let card = LeakScorecard(of: session.history.hands, assessment: report)
                for finding in report.named {
                    carried += 1
                    let listed = card.costing.map(\.id)
                    t.expect(listed.contains(finding.leak.id),
                             "the report named \(finding.leak.id) and the card, on the same hands, "
                             + "left it off: \(listed)")
                    guard let row = card.entries.first(where: { $0.id == finding.leak.id }) else {
                        t.expect(false, "\(finding.leak.id) is not on the card at all")
                        continue
                    }
                    t.equal(row.sampleNote, nil,
                            "the row denies the finding it is carrying: \"\(row.sampleNote ?? "")\"")
                    // Where the hands in view have enough chances of their own they are the better
                    // evidence and they speak; the assessment only carries a row that cannot.
                    if !row.enoughChances {
                        carriedByReport += 1
                        t.expect(row.frequencyLine.contains("The assessment put this on your list"),
                                 "the row does not say where its evidence came from: "
                                 + "\"\(row.frequencyLine)\"")
                        t.expect(row.frequencyLine.contains("\(finding.times) of \(finding.chances)"),
                                 "the row does not print the assessment's own numbers: "
                                 + "\"\(row.frequencyLine)\"")
                    }
                    t.expect(!card.summary.contains("Nothing is costing you"),
                             "the summary denies the row under it: \"\(card.summary)\"")
                }
                // And nothing the report only watched is promoted to a finding by this.
                for finding in report.watching {
                    t.expect(!card.costing.map(\.id).contains(finding.leak.id)
                             || card.entries.first { $0.id == finding.leak.id }?.enoughChances == true,
                             "\(finding.leak.id) was watched, not named, and the card called it a habit")
                }
            }
            t.greaterThan(carried, 0,
                          "five sittings named nothing at all, so nothing above was checked")
            t.greaterThan(carriedByReport, 0,
                          "every finding had enough chances of its own, so the case this test "
                          + "exists for — the report carrying a row the hands cannot — never came up")
        }

        t.test("what it names outright, it has actually earned") {
            // Anything in the confirmed list has to survive the arithmetic behind it, not just the
            // coach's opinion. This is the check that the strict tier really is strict.
            //
            // A single sitting can legitimately name nothing and watch nothing, so the loops below
            // are counted rather than trusted: five sittings that all came back empty would run
            // every assertion here nought times and still report a pass, which is the same disease
            // as a measurement taken on one lucky seed.
            var namedSeen = 0
            var watchedSeen = 0
            for seed: UInt64 in [11, 202, 3003, 40004, 500005] {
                let report = playAssessment(.playsEverything, seed: seed).assessmentReport
                for finding in (report?.named ?? []) + (report?.watching ?? []) {
                    t.expect(finding.chances >= AssessmentReport.minimumChances,
                             "\(finding.leak.id) was raised off \(finding.chances) chances")
                }
                for finding in report?.named ?? [] {
                    namedSeen += 1
                    t.expect(finding.times >= 3, "\(finding.leak.id) was named off \(finding.times)")
                    t.expect(finding.lowEnd > finding.leak.acceptableRate,
                             "\(finding.leak.id) was named without clearing a steady player's rate")
                    t.expect(finding.certain)
                }
                for finding in report?.watching ?? [] {
                    watchedSeen += 1
                    t.expect(!finding.certain)
                    t.expect(finding.line.contains("not enough to call that a habit"),
                             "an unproven finding has to say so: \(finding.line)")
                }
            }
            t.greaterThan(namedSeen, 0, "five sittings named nothing, so the strict tier was never checked")
            t.greaterThan(watchedSeen, 0, "five sittings watched nothing, so the cautious tier was never checked")
        }

        t.test("what it could not get near, it says it could not get near") {
            // Two of the planted players have flaws that only exist deep in a hand — on the river,
            // or facing a bet on the flop. Forty hands often never gets there. What matters is that
            // the report says nothing about them rather than inventing a verdict, and that it lists
            // the parts of the game it never saw instead of letting silence read as approval.
            for player in [PlantedPlayer.neverValueBetsRiver, .foldsToEveryFlopBet] {
                var wrongNames = 0
                for seed: UInt64 in [11, 202, 3003, 40004, 500005] {
                    let report = playAssessment(player, seed: seed).assessmentReport
                    wrongNames += (report?.named ?? []).filter { $0.leak.id != player.leakID }.count
                    if let note = report?.thinNote {
                        t.expect(note.contains("not the same as saying they are fine"),
                                 "the thin note has to refuse the wrong reading")
                    }
                }
                t.lessThan(wrongNames, 2,
                           "a player who \(player.name) was confidently told about \(wrongNames) "
                           + "habits they were never actually measured on")
            }
        }

        t.test("a player with nothing wrong is told nothing is wrong") {
            var invented = 0
            for seed: UInt64 in [11, 202, 3003, 40004, 500005] {
                let report = playAssessment(.control, seed: seed).assessmentReport
                invented += (report?.named.count ?? 0) + (report?.watching.count ?? 0)
            }
            t.lessThan(invented, 3,
                       "forty hands handed a sound player \(invented) findings across five "
                       + "sittings — at this sample the answer should almost always be silence")
        }
    }
}