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
+9
View File
@@ -68,6 +68,11 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
structurally separate from post-flop equity fields; inapplicable values are
null. The production coach explains these values; it does not reconstruct
hidden bot logic.
8. **Persist aggregates, not surveillance.** Player history stores the schema
version, setup preference, session/hand counts, baseline agreement, and stable
review-category counters. It never stores hole cards, boards, opponents, or
raw action histories. Storage mutations are serialised because hand completion
and coach analysis run on different coroutines.
## Testing notes
@@ -134,6 +139,10 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
table state but no opponent hole cards. Post-flop equity is explicitly labelled
as an estimate against unknown random hands; disagreement is "worth reviewing,"
never declared solver proof of a mistake.
- Versioned player history survives process restarts in a small aggregate
`SharedPreferences` store. The setup screen restores the last player/coach
preference and shows cross-session agreement/review trends only after at least
ten coached decisions; smaller samples are labelled as insufficient.
- Gradle emits an `archives` deprecation from the Kotlin Multiplatform plugin's
own `jvm()` target registration — upstream in Kotlin 2.2.10, not our build.
- Not built yet: LLM persona layer.
@@ -8,11 +8,17 @@ import androidx.compose.material3.darkColorScheme
import androidx.lifecycle.viewmodel.compose.viewModel
class MainActivity : ComponentActivity() {
private val historyStore by lazy {
SharedPreferencesPlayerHistoryStore(applicationContext)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
val vm: PokerViewModel = viewModel()
val vm: PokerViewModel = viewModel(
factory = PokerViewModel.factory(historyStore),
)
PokerApp(vm)
}
}
@@ -0,0 +1,157 @@
package com.jsjdesigns.poker
import com.jsjdesigns.poker.bot.CoachingReview
import com.jsjdesigns.poker.game.ActionType
import com.jsjdesigns.poker.game.Street
import kotlin.math.roundToInt
const val PLAYER_HISTORY_SCHEMA_VERSION = 1
const val MIN_DECISIONS_FOR_TREND = 10
const val MIN_OCCURRENCES_FOR_LEAK = 3
/**
* Stable, aggregate categories for cross-session coaching.
*
* The identifiers are persisted and must not be renamed. We deliberately store
* no cards, board, opponent data, or raw action history.
*/
enum class LeakKind(val id: String, val label: String) {
PREFLOP_TOO_LOOSE("preflop_too_loose", "Pre-flop continues outside the baseline"),
PREFLOP_TOO_TIGHT("preflop_too_tight", "Pre-flop folds inside the baseline"),
PREFLOP_MISSED_RAISE("preflop_missed_raise", "Passed on baseline pre-flop raises"),
PREFLOP_OVERAGGRESSION("preflop_overaggression", "Raised beyond the pre-flop baseline"),
POSTFLOP_LOOSE_CONTINUE("postflop_loose_continue", "Post-flop continues below the baseline"),
POSTFLOP_OVERFOLD("postflop_overfold", "Post-flop folds above the baseline"),
POSTFLOP_MISSED_AGGRESSION("postflop_missed_aggression", "Passed on baseline bets and raises"),
POSTFLOP_OVERAGGRESSION("postflop_overaggression", "Aggression beyond the post-flop baseline"),
}
data class PlayerHistory(
val schemaVersion: Int = PLAYER_HISTORY_SCHEMA_VERSION,
val playerName: String = "",
val coachEnabled: Boolean = false,
val sessionsStarted: Long = 0,
val handsCompleted: Long = 0,
val decisionsReviewed: Long = 0,
val baselineAligned: Long = 0,
val leakCounts: Map<LeakKind, Long> = emptyMap(),
) {
init {
require(schemaVersion == PLAYER_HISTORY_SCHEMA_VERSION)
require(
listOf(
sessionsStarted,
handsCompleted,
decisionsReviewed,
baselineAligned,
).all { it >= 0 },
)
require(baselineAligned <= decisionsReviewed)
require(leakCounts.values.all { it >= 0 })
}
fun leakCount(kind: LeakKind): Long = leakCounts[kind] ?: 0
}
data class HistoryPresentation(
val headline: String,
val detail: String,
)
fun PlayerHistory.recordSession(playerName: String, coachEnabled: Boolean): PlayerHistory =
copy(
playerName = playerName,
coachEnabled = coachEnabled,
sessionsStarted = sessionsStarted + 1,
)
fun PlayerHistory.recordCompletedHand(): PlayerHistory =
copy(handsCompleted = handsCompleted + 1)
fun PlayerHistory.recordCoachReview(review: CoachingReview): PlayerHistory {
val leak = classifyLeak(review)
val updatedLeaks = if (leak == null) {
leakCounts
} else {
leakCounts + (leak to leakCount(leak) + 1)
}
return copy(
decisionsReviewed = decisionsReviewed + 1,
baselineAligned = baselineAligned + if (review.alignedWithBaseline) 1 else 0,
leakCounts = updatedLeaks,
)
}
/**
* Maps one baseline disagreement to at most one durable trend.
*
* This is a descriptive fundamentals comparison, not solver proof that the
* player made a mistake. Bet and raise already count as the same action family
* in [CoachingReview.alignedWithBaseline].
*/
fun classifyLeak(review: CoachingReview): LeakKind? {
if (review.alignedWithBaseline) return null
val intended = review.trace.intended.type
val chosen = review.trace.chosen.type
val intendedAggressive = intended == ActionType.BET || intended == ActionType.RAISE
val chosenAggressive = chosen == ActionType.BET || chosen == ActionType.RAISE
return if (review.street == Street.PREFLOP) {
when {
intended == ActionType.FOLD && chosen != ActionType.FOLD ->
LeakKind.PREFLOP_TOO_LOOSE
chosen == ActionType.FOLD && intended != ActionType.FOLD ->
LeakKind.PREFLOP_TOO_TIGHT
intendedAggressive && !chosenAggressive ->
LeakKind.PREFLOP_MISSED_RAISE
chosenAggressive && !intendedAggressive ->
LeakKind.PREFLOP_OVERAGGRESSION
else -> null
}
} else {
when {
intended == ActionType.FOLD && chosen != ActionType.FOLD ->
LeakKind.POSTFLOP_LOOSE_CONTINUE
chosen == ActionType.FOLD && intended != ActionType.FOLD ->
LeakKind.POSTFLOP_OVERFOLD
intendedAggressive && !chosenAggressive ->
LeakKind.POSTFLOP_MISSED_AGGRESSION
chosenAggressive && !intendedAggressive ->
LeakKind.POSTFLOP_OVERAGGRESSION
else -> null
}
}
}
fun historyPresentation(history: PlayerHistory): HistoryPresentation? {
if (history.sessionsStarted == 0L && history.handsCompleted == 0L) return null
val headline = "${history.handsCompleted} hands saved • " +
"${history.decisionsReviewed} coached decisions"
if (history.decisionsReviewed < MIN_DECISIONS_FOR_TREND) {
val remaining = MIN_DECISIONS_FOR_TREND - history.decisionsReviewed
return HistoryPresentation(
headline,
"$remaining more coached ${if (remaining == 1L) "decision" else "decisions"} " +
"before calling anything a trend.",
)
}
val agreement = (
history.baselineAligned.toDouble() /
history.decisionsReviewed.toDouble() *
100.0
).roundToInt()
val topLeak = LeakKind.entries
.map { it to history.leakCount(it) }
.filter { it.second >= MIN_OCCURRENCES_FOR_LEAK }
.maxWithOrNull(compareBy<Pair<LeakKind, Long>> { it.second }.thenByDescending { it.first.ordinal })
val detail = if (topLeak == null) {
"Baseline agreement $agreement%. No repeated review pattern yet."
} else {
"Baseline agreement $agreement%. Most frequent review: " +
"${topLeak.first.label} (${topLeak.second})."
}
return HistoryPresentation(headline, detail)
}
@@ -0,0 +1,100 @@
package com.jsjdesigns.poker
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import com.jsjdesigns.poker.bot.CoachingReview
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
interface PlayerHistoryStore {
suspend fun load(): PlayerHistory
suspend fun recordSession(playerName: String, coachEnabled: Boolean): PlayerHistory
suspend fun recordCompletedHand(): PlayerHistory
suspend fun recordCoachReview(review: CoachingReview): PlayerHistory
}
/**
* Small versioned aggregate store.
*
* SharedPreferences is appropriate here because the complete schema is a few
* scalar counters. Mutations are serialised and committed on Dispatchers.IO so a
* hand completion and an asynchronous coach review cannot overwrite each other.
*/
class SharedPreferencesPlayerHistoryStore(context: Context) : PlayerHistoryStore {
private val preferences =
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
private val mutex = Mutex()
override suspend fun load(): PlayerHistory = withContext(Dispatchers.IO) {
mutex.withLock { preferences.readHistory() }
}
override suspend fun recordSession(
playerName: String,
coachEnabled: Boolean,
): PlayerHistory = mutate { it.recordSession(playerName, coachEnabled) }
override suspend fun recordCompletedHand(): PlayerHistory =
mutate(PlayerHistory::recordCompletedHand)
override suspend fun recordCoachReview(review: CoachingReview): PlayerHistory =
mutate { it.recordCoachReview(review) }
private suspend fun mutate(block: (PlayerHistory) -> PlayerHistory): PlayerHistory =
withContext(Dispatchers.IO) {
mutex.withLock {
val updated = block(preferences.readHistory())
check(preferences.writeHistory(updated)) { "could not persist player history" }
updated
}
}
private companion object {
const val PREFERENCES_NAME = "player_history"
const val KEY_SCHEMA = "schema_version"
const val KEY_PLAYER_NAME = "player_name"
const val KEY_COACH_ENABLED = "coach_enabled"
const val KEY_SESSIONS = "sessions_started"
const val KEY_HANDS = "hands_completed"
const val KEY_DECISIONS = "decisions_reviewed"
const val KEY_ALIGNED = "baseline_aligned"
fun SharedPreferences.readHistory(): PlayerHistory {
val storedSchema = getInt(KEY_SCHEMA, PLAYER_HISTORY_SCHEMA_VERSION)
require(storedSchema <= PLAYER_HISTORY_SCHEMA_VERSION) {
"player history schema $storedSchema is newer than supported " +
PLAYER_HISTORY_SCHEMA_VERSION
}
return PlayerHistory(
playerName = getString(KEY_PLAYER_NAME, "").orEmpty(),
coachEnabled = getBoolean(KEY_COACH_ENABLED, false),
sessionsStarted = getLong(KEY_SESSIONS, 0),
handsCompleted = getLong(KEY_HANDS, 0),
decisionsReviewed = getLong(KEY_DECISIONS, 0),
baselineAligned = getLong(KEY_ALIGNED, 0),
leakCounts = LeakKind.entries.associateWith { kind ->
getLong("leak_${kind.id}", 0)
},
)
}
@SuppressLint("UseKtx") // KTX edit discards commit(), so durability failures cannot be reported.
fun SharedPreferences.writeHistory(history: PlayerHistory): Boolean {
val editor = edit()
.putInt(KEY_SCHEMA, history.schemaVersion)
.putString(KEY_PLAYER_NAME, history.playerName)
.putBoolean(KEY_COACH_ENABLED, history.coachEnabled)
.putLong(KEY_SESSIONS, history.sessionsStarted)
.putLong(KEY_HANDS, history.handsCompleted)
.putLong(KEY_DECISIONS, history.decisionsReviewed)
.putLong(KEY_ALIGNED, history.baselineAligned)
for (kind in LeakKind.entries) {
editor.putLong("leak_${kind.id}", history.leakCount(kind))
}
return editor.commit()
}
}
}
@@ -2,6 +2,8 @@ package com.jsjdesigns.poker
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -40,22 +42,34 @@ private val TableGold = Color(0xFFE3C179)
fun PokerApp(vm: PokerViewModel) {
val state by vm.state.collectAsStateWithLifecycle()
if (state.gameConfig == null) {
TakeASeatScreen(onStart = vm::startGame)
TakeASeatScreen(
history = state.playerHistory,
onStart = vm::startGame,
)
} else {
TableScreen(vm)
}
}
@Composable
private fun TakeASeatScreen(onStart: (String, Boolean) -> Unit) {
var playerName by rememberSaveable { mutableStateOf("") }
var coachEnabled by rememberSaveable { mutableStateOf(false) }
private fun TakeASeatScreen(
history: PlayerHistory?,
onStart: (String, Boolean) -> Unit,
) {
var playerName by rememberSaveable(history?.playerName) {
mutableStateOf(history?.playerName?.takeUnless { it == "You" }.orEmpty())
}
var coachEnabled by rememberSaveable(history?.coachEnabled) {
mutableStateOf(history?.coachEnabled ?: false)
}
val progress = history?.let(::historyPresentation)
Column(
modifier = Modifier
.fillMaxSize()
.background(SetupFelt)
.systemBarsPadding()
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
@@ -145,6 +159,25 @@ private fun TakeASeatScreen(onStart: (String, Boolean) -> Unit) {
color = Color.White.copy(alpha = 0.48f),
fontSize = 12.sp,
)
if (progress != null) {
Spacer(Modifier.height(14.dp))
Text(
"YOUR COACH HISTORY",
color = TableGold,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
Text(
progress.headline,
color = Color.White.copy(alpha = 0.72f),
fontSize = 12.sp,
)
Text(
progress.detail,
color = Color.White.copy(alpha = 0.5f),
fontSize = 12.sp,
)
}
}
}
@@ -152,13 +185,14 @@ private fun TakeASeatScreen(onStart: (String, Boolean) -> Unit) {
Button(
onClick = { onStart(playerName, coachEnabled) },
enabled = history != null,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF2C6E49),
contentColor = Color.White,
),
) {
Text("Buy in for $DEFAULT_BUY_IN")
Text(if (history == null) "Loading saved progress…" else "Buy in for $DEFAULT_BUY_IN")
}
}
}
@@ -1,6 +1,7 @@
package com.jsjdesigns.poker
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.jsjdesigns.poker.bot.BotProfile
import com.jsjdesigns.poker.bot.CoachingReview
@@ -23,6 +24,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlin.random.Random
@@ -39,6 +42,8 @@ data class UiState(
val coachReview: CoachingReview? = null,
/** Most recent engine-owned human decision identity observed on screen. */
val latestHeroDecisionToken: Long? = null,
/** Null only while durable aggregate history is loading. */
val playerHistory: PlayerHistory? = null,
) {
/**
* The decision to show, or null.
@@ -94,11 +99,14 @@ data class UiState(
* suspends when the channel is full, the engine cannot outrun the animation:
* backpressure does the pacing for us.
*/
class PokerViewModel : ViewModel() {
class PokerViewModel(
private val historyStore: PlayerHistoryStore,
) : ViewModel() {
private val human = HumanAgent()
private val coach = DecisionCoach()
private val nextHandGate = NextHandGate()
private val historyMutationMutex = Mutex()
// RENDEZVOUS, not a buffer. A 32-deep queue let the engine race dozens of
// frames ahead of the animation, so the board on screen and the action being
// offered could belong to different moments — even different hands. With no
@@ -117,6 +125,16 @@ class PokerViewModel : ViewModel() {
init {
viewModelScope.launch { consumeFrames() }
viewModelScope.launch {
updateHistory { historyStore.load() }
}
}
private suspend fun updateHistory(block: suspend () -> PlayerHistory) {
historyMutationMutex.withLock {
val history = block()
_state.update { it.copy(playerHistory = history) }
}
}
/**
@@ -124,7 +142,7 @@ class PokerViewModel : ViewModel() {
* A rapid double tap cannot launch two engines against the same HumanAgent.
*/
fun startGame(playerName: String, coachEnabled: Boolean) {
if (gameStarted) return
if (gameStarted || _state.value.playerHistory == null) return
gameStarted = true
val config = cashGameConfig(playerName, coachEnabled)
@@ -149,7 +167,17 @@ class PokerViewModel : ViewModel() {
observer = { frames.send(it) },
)
_state.update { UiState(gameConfig = config) }
_state.update { current ->
UiState(
gameConfig = config,
playerHistory = current.playerHistory,
)
}
viewModelScope.launch {
updateHistory {
historyStore.recordSession(config.playerName, config.coachEnabled)
}
}
viewModelScope.launch(Dispatchers.Default) { playContinuously(config) }
}
@@ -222,6 +250,9 @@ class PokerViewModel : ViewModel() {
heroNeedsRebuy = heroNeedsRebuy,
)
}
viewModelScope.launch {
updateHistory { historyStore.recordCompletedHand() }
}
// Do not let the next deal erase the outcome before the
// player has read it and chosen to continue.
nextHandGate.awaitRequest(summary.handNumber)
@@ -270,6 +301,7 @@ class PokerViewModel : ViewModel() {
current
}
}
updateHistory { historyStore.recordCoachReview(review) }
}
}
@@ -291,4 +323,15 @@ class PokerViewModel : ViewModel() {
frames.close()
super.onCleared()
}
companion object {
fun factory(historyStore: PlayerHistoryStore): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
require(modelClass.isAssignableFrom(PokerViewModel::class.java))
return PokerViewModel(historyStore) as T
}
}
}
}
@@ -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()))
}
}