Files
gogobee/internal/plugin/holdem_tip_cfr_test.go
prosolis 148b8d20f2 Add tip audit logging, CFR alignment tests, and multiway scenarios
Three confidence features for the poker tip system:
- Tip audit table (holdem_tip_audit) logs every tip with full context
  for bulk review after real sessions
- CFR alignment test validates all 32 scenarios against the trained
  5M-iteration policy — rules engine never contradicts the bot's AI
- 8 new multiway test scenarios covering all streets and key decisions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 14:11:59 -07:00

178 lines
4.3 KiB
Go

package plugin
import (
"os"
"strings"
"testing"
"github.com/chehsunliu/poker"
)
var testPolicy PolicyTable
func init() {
path := os.Getenv("HOLDEM_CFR_POLICY")
if path == "" {
path = "../../data/policy.gob"
}
p, err := LoadPolicy(path)
if err == nil {
testPolicy = p
}
}
// policyStrategy looks up the trained policy for the given game state,
// falling back to the pot-odds heuristic if no entry exists.
func policyStrategy(game *HoldemGame, heroIdx int) (probs [cfrNumActions]float64, source string) {
p := game.Players[heroIdx]
numOpp := game.activeCount() - 1
if numOpp < 1 {
numOpp = 1
}
eq := Equity(p.Hole, game.Community, numOpp, 2000)
if testPolicy != nil {
eqBkt := equityBucket(eq.Win + eq.Tie*0.5)
totalPot := game.Pot
for _, pp := range game.Players {
totalPot += pp.Bet
}
spr := 0.0
if totalPot > 0 {
spr = float64(p.Stack) / float64(totalPot)
}
sprBkt := sprBucket(spr)
pos := game.positionLabel(heroIdx)
boardTex := boardTexture(game.Community)
key := buildInfoSetKey(game.Street, pos, eqBkt, sprBkt, boardTex, truncateHistory(game.StreetHistory))
if entry, ok := testPolicy[key]; ok {
return filterLegalActions(entry, game, heroIdx), "trained"
}
}
eq2 := Equity(p.Hole, game.Community, numOpp, 2000)
return filterLegalActions(fallbackStrategy(eq2, game, heroIdx), game, heroIdx), "fallback"
}
// TestTipScenarios_CFRAlignment builds a minimal HoldemGame from each scenario,
// looks up the trained CFR policy (falling back to pot-odds heuristic), and
// verifies the rules-engine tip doesn't recommend an action that the CFR gives
// zero weight to.
func TestTipScenarios_CFRAlignment(t *testing.T) {
if testPolicy != nil {
t.Logf("loaded trained policy with %d info sets", len(testPolicy))
} else {
t.Log("no trained policy found, using fallback heuristic only")
}
for _, s := range tipScenarios {
t.Run(s.Name, func(t *testing.T) {
ctx := scenarioContext(t, s)
tip := strings.ToLower(generateRulesTip(ctx))
game := buildGameFromScenario(s)
heroIdx := 0
probs, source := policyStrategy(game, heroIdx)
tipAction := extractTipAction(tip)
cfrActions := mapTipActionToCFR(tipAction)
if len(cfrActions) == 0 {
return
}
totalWeight := 0.0
for _, cfrIdx := range cfrActions {
totalWeight += probs[cfrIdx]
}
if totalWeight < 0.01 {
t.Errorf("rules tip action %q has zero CFR weight (source: %s).\n tip: %s\n CFR probs: fold=%.2f call/chk=%.2f raise½=%.2f raisePot=%.2f allIn=%.2f",
tipAction, source, tip, probs[0], probs[1], probs[2], probs[3], probs[4])
}
})
}
}
// buildGameFromScenario constructs a minimal HoldemGame for CFR strategy lookup.
func buildGameFromScenario(s TipScenario) *HoldemGame {
hole := [2]poker.Card{
poker.NewCard(s.HoleStr[0]),
poker.NewCard(s.HoleStr[1]),
}
community := make([]poker.Card, len(s.BoardStr))
for i, cs := range s.BoardStr {
community[i] = poker.NewCard(cs)
}
numActive := s.NumActive
if numActive < 2 {
numActive = 2
}
players := make([]*HoldemPlayer, numActive)
players[0] = &HoldemPlayer{
DisplayName: "Hero",
Hole: hole,
Stack: s.Stack,
State: PlayerActive,
}
for i := 1; i < numActive; i++ {
players[i] = &HoldemPlayer{
DisplayName: "Villain",
Hole: [2]poker.Card{poker.NewCard("2c"), poker.NewCard("3d")},
Stack: s.Stack,
State: PlayerActive,
}
}
currentBet := int64(0)
if s.ToCall > 0 {
currentBet = s.ToCall
players[0].Bet = 0
}
pot := s.Pot
if s.ToCall > 0 {
pot -= s.ToCall
if pot < 0 {
pot = 0
}
}
dealerIdx := 0
if s.Position != "BTN" && s.Position != "SB" {
dealerIdx = numActive - 1
}
return &HoldemGame{
Players: players,
Community: community,
Street: s.Street,
Pot: pot,
CurrentBet: currentBet,
MinRaise: currentBet,
DealerIdx: dealerIdx,
ActionIdx: 0,
HandInProgress: true,
}
}
// mapTipActionToCFR maps a rules-engine action verb to corresponding CFR action indices.
func mapTipActionToCFR(action string) []int {
switch action {
case "fold":
return []int{cfrFold}
case "call", "check":
return []int{cfrCallCheck}
case "bet", "raise":
return []int{cfrRaiseHalf, cfrRaisePot, cfrAllIn}
case "shove":
return []int{cfrAllIn}
default:
return nil
}
}