Skip to content

Fix chance-node normalization: exclude both players' hole cards (fixes #95) - #233

Open
IrvinChoi wants to merge 1 commit into
bupticybee:masterfrom
IrvinChoi:fix-chance-node-possible-deals
Open

IrvinChoi wants to merge 1 commit into
bupticybee:masterfrom
IrvinChoi:fix-chance-node-possible-deals

Conversation

@IrvinChoi

Copy link
Copy Markdown

Fixes #95.

Problem

chanceUtility (PCfrSolver.cpp) and chanceBestReponse (BestResponse.cpp) divide each chance child's reach by

possible_deals = cards - board - 2;

For any concrete pair of hands, the dealt card must avoid both players' hole cards, so only cards - board - 4 children are reachable. The reachable children's probabilities therefore sum to (cards-board-4)/(cards-board-2) instead of 1: 44/46 on the turn → river deal, 45/47 on the flop → turn deal. Every value below a chance node is scaled down by that factor, while a fold child under the same parent keeps its exact value — so the solver optimizes a slightly different game, as @yffbit described in #95.

Experiment (as requested in #95)

A check-check turn game, so the exact game value can be enumerated without any solver.

  • Board As Kd 7h 2c, pot 100, OOP QQ,AJs,KQo, IP JJ,AQs,KJo,98s, no bet sizes (check-check only). Command file attached below.
  • enumerate.py (attached): averages, over every compatible hand pair, the showdown result over the 44 remaining river cards.
OOP value (chips) ratio to exact
Exact enumeration 20.903647 1
Current master logic 19.994790 0.956522 (= 44/46)
This PR 20.903648 1.000000

We first noticed this while cross-checking TexasSolver against another CFR engine on a turn game with bets. Before the fix, the two engines' root EVs differed by ~0.49% of the pot and the best-response brackets did not overlap. After the fix, they agree within 0.0002% of the pot. On flop games (two chance layers) the gap is larger.

Change

  • - 2 → - 4 in the two possible_deals lines.
  • The MonteCarolAlg::PUBLIC sampling range is kept identical (possible_deals + 1 + 4 equals the old possible_deals + 1 + 2).
  • experimental/TCfrSolver.cpp has the same line but is not built, so it is left untouched.
  • The same lines exist on the console branch (BestResponse.cpp:118, PCfrSolver.cpp:229); the same 2-line change applies there.
cmd.txt
set_pot 100
set_effective_stack 1000
set_board As,Kd,7h,2c
set_range_oop QQ,AJs,KQo
set_range_ip JJ,AQs,KJo,98s
set_allin_threshold 1.0
build_tree
set_thread_num 4
set_accuracy 0.0001
set_max_iteration 3
set_print_interval 1
set_use_isomorphism 0
start_solve
set_dump_rounds 2
dump_result dump.json

The OOP value is -(player 0 exploitability) from the solver log (the game has no decisions).

enumerate.py
#!/usr/bin/env python3
"""Independent check-check turn game value (no solver).
OOP net = sum_{compatible h,o} E_river[+pot/2 win, -pot/2 lose, 0 tie] / #compatible pairs.
River is uniform over the 52 - 4 board - 4 hole cards = 44 remaining cards."""
import itertools
RV={r:i for i,r in enumerate('23456789TJQKA',2)}; S='cdhs'
def combos(tok):
    if len(tok)==2: r=tok[0]; return [frozenset((r+a,r+b)) for a,b in itertools.combinations(S,2)]
    a,b,t=tok[0],tok[1],tok[2]
    return [frozenset((a+x,b+y)) for x in S for y in S if (x==y)==(t=='s')]
def rng(s,board): return [c for t in s.split(',') for c in combos(t) if not c&set(board)]
def r5(cs):
    rs=sorted((RV[c[0]] for c in cs),reverse=True); fl=len({c[1] for c in cs})==1; u=sorted(set(rs),reverse=True); st=None
    if len(u)==5:
        if u[0]-u[4]==4: st=u[0]
        elif u==[14,5,4,3,2]: st=5
    cnt=sorted(((rs.count(r),r) for r in set(rs)),reverse=True); sh=[c for c,_ in cnt]; k=[r for _,r in cnt]
    if st and fl: return (8,st)
    if sh==[4,1]: return (7,*k)
    if sh==[3,2]: return (6,*k)
    if fl: return (5,*rs)
    if st: return (4,st)
    if sh==[3,1,1]: return (3,*k)
    if sh==[2,2,1]: return (2,*k)
    if sh==[2,1,1,1]: return (1,*k)
    return (0,*rs)
def r7(cs): return max(r5(x) for x in itertools.combinations(cs,5))
board=['As','Kd','7h','2c']; pot=100.0
oop=rng('QQ,AJs,KQo',board); ip=rng('JJ,AQs,KJo,98s',board)
deck=[r+s for r in '23456789TJQKA' for s in S if r+s not in board]
num=den=0.0
for h in oop:
    for o in ip:
        if h&o: continue
        rv=[c for c in deck if c not in h and c not in o]; assert len(rv)==44
        s=0.0
        for c in rv:
            a=r7(board+[c]+list(h)); b=r7(board+[c]+list(o)); s+=pot/2 if a>b else (-pot/2 if a<b else 0)
        num+=s/len(rv); den+=1
print('pairs',int(den),'OOP net (chips)',num/den)

…not -2)

possible_deals divided each chance child's reach by (deck - board - 2),
but a dealt card must avoid both players' hole cards, so only
(deck - board - 4) children are reachable for any hand pair. The
probabilities therefore summed to less than 1 (44/46 on the turn,
45/47 on the flop), scaling every post-chance value down while
fold values at the same parent stayed exact. Fixes bupticybee#95.

The Monte-Carlo sampling range keeps its previous upper bound
(possible_deals + 1 + 4 == old possible_deals + 1 + 2).
@IrvinChoi IrvinChoi mentioned this pull request Oct 6, 2026
@bupticybee

Copy link
Copy Markdown
Owner

You sure about this? I remember I did a lot of math work on this hard for me to believe I could made some mistake here. And exploitability are converging as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

随机节点的概率

2 participants