Persist cross-session coach history

This commit is contained in:
Jay
2026-07-26 19:04:29 -04:00
parent 369b1f59d4
commit 1ec3dd1276
7 changed files with 511 additions and 9 deletions
@@ -0,0 +1,153 @@
package com.jsjdesigns.poker
import com.jsjdesigns.poker.bot.CoachingReview
import com.jsjdesigns.poker.bot.DecisionTrace
import com.jsjdesigns.poker.game.Action
import com.jsjdesigns.poker.game.ActionType
import com.jsjdesigns.poker.game.Street
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PlayerHistoryTest {
private fun review(
street: Street,
intended: ActionType,
chosen: ActionType,
aligned: Boolean = false,
) = CoachingReview(
decisionToken = 1,
handNumber = 1,
street = street,
trace = DecisionTrace(
estimatedEquity = null,
handStrengthPercentile = null,
breakEvenEquity = null,
decisionThreshold = null,
preflopRangeThreshold = null,
potOdds = null,
intended = Action(intended),
chosen = Action(chosen),
mistakeApplied = false,
adjustments = emptyList(),
reason = "test",
),
alignedWithBaseline = aligned,
)
@Test
fun `every strategic disagreement has one stable aggregate category`() {
val cases = listOf(
Triple(Street.PREFLOP, ActionType.FOLD to ActionType.CALL, LeakKind.PREFLOP_TOO_LOOSE),
Triple(Street.PREFLOP, ActionType.CALL to ActionType.FOLD, LeakKind.PREFLOP_TOO_TIGHT),
Triple(Street.PREFLOP, ActionType.RAISE to ActionType.CALL, LeakKind.PREFLOP_MISSED_RAISE),
Triple(Street.PREFLOP, ActionType.CALL to ActionType.RAISE, LeakKind.PREFLOP_OVERAGGRESSION),
Triple(Street.RIVER, ActionType.FOLD to ActionType.CALL, LeakKind.POSTFLOP_LOOSE_CONTINUE),
Triple(Street.RIVER, ActionType.CALL to ActionType.FOLD, LeakKind.POSTFLOP_OVERFOLD),
Triple(Street.RIVER, ActionType.BET to ActionType.CHECK, LeakKind.POSTFLOP_MISSED_AGGRESSION),
Triple(Street.RIVER, ActionType.CHECK to ActionType.BET, LeakKind.POSTFLOP_OVERAGGRESSION),
)
for ((street, actions, expected) in cases) {
assertEquals(
expected,
classifyLeak(review(street, actions.first, actions.second)),
)
}
}
@Test
fun `persisted leak identifiers are unique and version one stable`() {
assertEquals(1, PLAYER_HISTORY_SCHEMA_VERSION)
assertEquals(
listOf(
"preflop_too_loose",
"preflop_too_tight",
"preflop_missed_raise",
"preflop_overaggression",
"postflop_loose_continue",
"postflop_overfold",
"postflop_missed_aggression",
"postflop_overaggression",
),
LeakKind.entries.map(LeakKind::id),
)
assertEquals(LeakKind.entries.size, LeakKind.entries.map(LeakKind::id).toSet().size)
}
@Test
fun `aligned decisions improve agreement without inventing a leak`() {
val aligned = review(
street = Street.FLOP,
intended = ActionType.BET,
chosen = ActionType.RAISE,
aligned = true,
)
val history = PlayerHistory().recordCoachReview(aligned)
assertEquals(1L, history.decisionsReviewed)
assertEquals(1L, history.baselineAligned)
assertTrue(history.leakCounts.isEmpty())
assertNull(classifyLeak(aligned))
}
@Test
fun `session preferences hands and reviews aggregate without raw hand data`() {
val looseCall = review(Street.TURN, ActionType.FOLD, ActionType.CALL)
val history = PlayerHistory()
.recordSession("Jay", coachEnabled = true)
.recordCompletedHand()
.recordCompletedHand()
.recordCoachReview(looseCall)
.recordCoachReview(looseCall)
assertEquals("Jay", history.playerName)
assertTrue(history.coachEnabled)
assertEquals(1L, history.sessionsStarted)
assertEquals(2L, history.handsCompleted)
assertEquals(2L, history.decisionsReviewed)
assertEquals(2L, history.leakCount(LeakKind.POSTFLOP_LOOSE_CONTINUE))
}
@Test
fun `history refuses to call a small sample a trend`() {
val history = PlayerHistory(
sessionsStarted = 1,
handsCompleted = 4,
decisionsReviewed = 3,
baselineAligned = 1,
leakCounts = mapOf(LeakKind.POSTFLOP_OVERFOLD to 2),
)
val presentation = historyPresentation(history)!!
assertEquals("4 hands saved • 3 coached decisions", presentation.headline)
assertTrue("7 more coached decisions" in presentation.detail)
assertTrue("trend" in presentation.detail)
}
@Test
fun `mature history reports agreement and the most repeated review`() {
val history = PlayerHistory(
sessionsStarted = 3,
handsCompleted = 42,
decisionsReviewed = 20,
baselineAligned = 13,
leakCounts = mapOf(
LeakKind.PREFLOP_TOO_LOOSE to 3,
LeakKind.POSTFLOP_OVERFOLD to 4,
),
)
val presentation = historyPresentation(history)!!
assertTrue("Baseline agreement 65%" in presentation.detail)
assertTrue(LeakKind.POSTFLOP_OVERFOLD.label in presentation.detail)
assertTrue("(4)" in presentation.detail)
}
@Test
fun `brand new player has no fake history card`() {
assertNull(historyPresentation(PlayerHistory()))
}
}