Gen 4.8 — Formula-Based Postflop (Design)

# Gen 4.8 — Formula-Based Postflop

## Motivation

Gen 3/4 postflop uses **80+ parameters** in a categorical decision tree with 7 first-in branches and 5 raised-pot branches. Each metric (hs, ppot, npot, nutpot, rpot) gets its own side-channel through boolean checks (`is_nut_draw`, `draw_quality > thresh`) and separate OR-chain logic. The primary signal is `hs` for bets/raises and `win_prob` for calls, with ppot handled as categorical adjustments.

Gen 4.8 replaces this with a **unified score formula** + **threshold comparisons**, mirroring the approach that works for G47 preflop. Target: ~16 parameters, no categorical branches, same mathematical foundation.

## Current Architecture (Gen 3/4)

### First-in decision tree (7 branches, priority order)

| # | Branch | Signal | Key threshold |
|---|--------|--------|--------------|
| 1 | Check-raise trap | `hs` | cr_flop=0.95, cr_turn=0.93 |
| 2 | Raise-bluff CR | ppot, model | (flop HU only) |
| 3 | Conti-bet | fold_eq, ppot | conti_fold_thresh |
| 4 | Semi-bluff | `hs`, ppot | semi_bluff_hs, semi_bluff_ppot |
| 5 | Value bet | `hs` | bet_flop=0.15, bet_turn=0.40, bet_river=0.47 |
| 6 | Bluff bet | `win_prob` | bluff_range from fold equity |
| 7 | Check | — | default |

### Raised-pot decision tree (5 branches)

| # | Branch | Signal | Key threshold |
|---|--------|--------|--------------|
| 1 | Rope-a-dope | `hs` | rd_flop=0.95, rd_turn=0.93 |
| 2 | Semi-bluff raise | `hs`, ppot | same as first-in |
| 3 | Raise | `hs` | raise_base=0.80 + adjustments |
| 4 | Call | `win_prob` | call_base=0.40 + adjustments |
| 5 | Fold | — | default |

### Key observation

The system already uses **two different signals**:
- `hs` (raw hand strength) for **bets and raises** — asserting "I'm ahead NOW"
- `win_prob` (EHS = `hs×(1-npot) + (1-hs)×ppot`) for **calls** — "I have equity to continue"

This is sound: betting asserts current strength, calling allows for future improvement. But ppot, nutpot, and rpot are handled through categorical side-channels instead of continuous formulas.

## Gen 4.8 Design

### Composite scores

Three scores replace the categorical metrics:

#### 1. value_score — for betting/raising (asserting strength)

```
value_score = hs × (1 − npot × npot_weight) + nutpot × nutredraw_weight × (1 − hs)
```

- **hs × (1 − npot × w)**: current strength, discounted by vulnerability
- **nutpot × w × (1 − hs)**: small bonus for nut redraws even when behind
- High for: top pair on dry board, sets, straights
- Moderate for: vulnerable top pair on wet board (npot discount)
- Low for: air, weak draws

#### 2. draw_score — for semi-bluffing and calling with draws

```
draw_quality = nutpot / (nutpot + rpot + ε)
draw_score = ppot × draw_quality × ppot_weight
```

- **ppot × quality**: draw value, weighted by how likely the draw is to the nuts
- High for: nut flush draw (high ppot, high nutpot, low rpot)
- Moderate for: non-nut flush draw (high ppot, moderate rpot)
- Low for: gutshot, backdoor draws
- Zero for: made hands with no improvement potential

#### 3. fold_equity — from opponent model (already computed)

Unchanged from current implementation: `opp_max_hs`, `opp_max_ppot`, `all_fold_to_bet`.

### Unified decision: first-in

```
combined = value_score + draw_score

// 1. Trap? (mixed strategy with randomness)
trap_prob = clamp(
    (value_score − trap_gate)         // only with monsters
    × opp_aggression                   // aggressive opponents bet for us
    × (1 − street_progress²)          // trap more on flop/turn
    × trap_random_scale               // tunable mixing
)
if value_score >= trap_gate && rng() < trap_prob → CHECK (trap)

// 2. Value bet or semi-bluff?
bet_thresh = bet_base + (bet_river_base − bet_base) × street_progress²
bet_thresh = bet_thresh / opp_looseness

if combined >= bet_thresh → BET
    sizing = sizing_base + (value_score − bet_thresh) × sizing_strength_mult
    sizing = clamp(sizing, sizing_min, sizing_max)

// 3. Pure bluff?
bluff_fold_eq = all_fold_to_bet × fold_freq_mult
if value_score <= bluff_hs_max && bluff_fold_eq >= bluff_fold_min → BET (bluff sizing)

// 4. Check
→ CHECK
```

**What disappears:**
- No separate CONTI_BET logic — emerges from bet_thresh + fold_equity
- No separate SEMI_BLUFF logic — draw_score in combined handles it
- No separate VALUE_BET vs BLUFF_BET — score threshold handles it
- No separate check_raise vs rope_a_dope — unified trap formula

### Unified decision: facing bet

```
combined = value_score + draw_score

// 1. Raise?
raise_thresh = raise_base / opp_looseness
raise_thresh += aggression_penalty(num_raises, opp_wagerers, npot)

if combined >= raise_thresh → RAISE
    sizing = raise_sizing_base + (combined − raise_thresh) × raise_sizing_mult

// 2. Call?
call_thresh = call_base + call_bs_weight × bet_size_ratio
call_thresh = call_thresh / opp_looseness
call_thresh = min(call_thresh, pot_odds × (1 + multiway_margin))  // multiway cap

if combined >= call_thresh → CALL

// 3. Fold
→ FOLD
```

**What disappears:**
- No separate should_call vs should_raise — unified threshold comparison
- No separate call_w_draw, call_w_pot_odds, call_w_commitment — all in the score
- No separate raise_w_draw, raise_w_bluff, raise_w_protection — all in the score
- No HU vs full-ring split — opp_looseness and multiway_margin handle it

### The check-call / check-call / check-raise line

No explicit trap state tracking needed:

| Street | Situation | What happens |
|--------|-----------|-------------|
| Flop | First-in, monster | `trap_prob > rng()` → CHECK (trap) |
| Turn | Facing bet | `combined` very high → CALL (above call_thresh, raise suppressed by slow_play_mult if trapping) |
| River | Facing bet | `combined` still very high, slow_play_mult drops to 1.0 → RAISE (check-raise!) |

The **slow_play_mult** parameter controls check-call vs check-raise on earlier streets:

```
if is_trapping && street < River:
    raise_thresh *= slow_play_mult    // e.g. 1.4 → harder to raise, more calls
```

On the river, `slow_play_mult = 1.0` → raises fire normally → natural check-raise.

### Bet sizing (formula replaces 5+ parameters)

```
bet_frac = clamp(
    sizing_base + (value_score − bet_thresh) × sizing_strength_mult,
    sizing_min,    // minimum (protection)
    sizing_max     // maximum (pot-sized)
)
```

- Strong hands: high value_score → bigger bet (value extraction)
- Draws: high draw_score but low value_score → smaller bet (semi-bluff sizing)
- Bluffs: very low value_score → small bet (risk minimization)

## Parameter Mapping

### Score parameters (5)

| Gen 4.8 parameter | Default | Replaces |
|---|---|---|
| `npot_weight` | 0.5 | npot_trap_ceiling, bet_flop_npot_mult, VOLATILE_NPOT_GATE |
| `nutredraw_weight` | 0.3 | (new — nut redraw bonus when behind) |
| `ppot_weight` | 0.5 | adj_ppot_base_weight, adj_ppot_quality_weight |
| `draw_quality_epsilon` | 0.01 | draw_quality_epsilon |
| `rpot` | (in draw_quality) | rpot handling via nutpot/(nutpot+rpot) |

### First-in threshold parameters (6)

| Gen 4.8 parameter | Default | Current equivalent |
|---|---|---|
| `bet_base` | 0.15 | bet_flop_base |
| `bet_river_base` | 0.47 | bet_turn_base + 0.07 |
| `trap_gate` | 0.85 | check_raise_flop=0.95, check_raise_turn=0.93 |
| `trap_random_scale` | 1.0 | cr_chance (fold_freq × all_fold_to_bet) |
| `bluff_hs_max` | 0.05 | bluff_hs_max=0.05 |
| `bluff_fold_min` | 0.30 | bluff_success_floor=0.30 |

### Facing-bet threshold parameters (4)

| Gen 4.8 parameter | Default | Current equivalent |
|---|---|---|
| `raise_base` | 0.80 | raise_base=0.80 (live), raise_default=0.925 (default) |
| `call_base` | 0.40 | call_base=0.40 (live) |
| `call_bs_weight` | 0.25 | 0.25×bs coefficient in call formula |
| `call_safety_margin` | 1.10 | (already in preflop) |

### Sizing parameters (3)

| Gen 4.8 parameter | Default | Current equivalent |
|---|---|---|
| `sizing_base` | 0.50 | bet_value_fraction=0.67, bet_bluff_fraction=0.40 |
| `sizing_strength_mult` | 0.50 | (new — scales with score) |
| `sizing_min` | 0.25 | semi_bluff sizing floor |

### Opponent parameters (3, already exist)

| Parameter | Source |
|---|---|
| `opp_looseness` | VPIP/PFR (same as preflop) |
| `opp_aggression` | Postflop bet/raise frequency from observer |
| `fold_freq_mult` | fold_freq_multiplier |

**Total: ~21 parameters** (vs 80+ in Gen 3/4)

## Score Calibration

### Target: reproduce current behavior

The formula scores must produce decisions close to the current thresholds. Mapping:

**value_score examples (with npot_weight=0.5, nutredraw_weight=0.3):**

| Hand | Board | hs | ppot | npot | nutpot | rpot | value_score | draw_score | Current action |
|------|-------|-----|------|------|--------|------|-------------|------------|----------------|
| Top pair AK | K72 rainbow | 0.70 | 0.05 | 0.05 | 0.02 | 0.01 | 0.683 | 0.024 | BET (hs>0.15) |
| Set 55 | K52 rainbow | 0.93 | 0.02 | 0.02 | 0.95 | 0.00 | 0.921 | 0.010 | TRAP (hs>0.95) |
| Nut flush draw | Q♥7♥2♥ + A♥T♥ | 0.35 | 0.35 | 0.10 | 0.35 | 0.00 | 0.358 | 0.175 | SEMI_BLUFF |
| Non-nut flush draw | Same + K♥T♥ | 0.35 | 0.35 | 0.10 | 0.20 | 0.15 | 0.358 | 0.097 | SEMI_BLUFF (tighter) |
| Vulnerable top pair | AK on T98 | 0.60 | 0.08 | 0.25 | 0.05 | 0.10 | 0.525 | 0.020 | BET (protection) |
| Complete air | 72o on K53 | 0.08 | 0.03 | 0.05 | 0.01 | 0.01 | 0.078 | 0.014 | CHECK or BLUFF |

### Score-to-threshold mapping

**Bet decision (first-in, flop, M=6):**

| Hand type | value_score | draw_score | combined | bet_thresh | Decision |
|-----------|------------|------------|----------|------------|----------|
| Top pair dry | 0.683 | 0.024 | 0.707 | 0.15/1.07=0.14 | BET ✓ |
| Set dry | 0.921 | 0.010 | 0.931 | 0.14 | TRAP (if opp aggressive) or BET |
| Nut flush draw | 0.358 | 0.175 | 0.533 | 0.14 | BET (semi-bluff) ✓ |
| Weak air | 0.078 | 0.014 | 0.092 | 0.14 | CHECK or BLUFF ✓ |

**Call decision (facing half-pot bet, turn):**

| Hand type | combined | call_thresh | Decision |
|-----------|----------|-------------|----------|
| Top pair | 0.707 | 0.40+0.25×0.5=0.525 | CALL ✓ |
| Nut flush draw | 0.533 | 0.525 | CALL (marginal) ✓ |
| Weak air | 0.092 | 0.525 | FOLD ✓ |

**Raise decision (facing half-pot bet, turn):**

| Hand type | combined | raise_thresh | Decision |
|-----------|----------|-------------|----------|
| Set | 0.931 | 0.80/1.07=0.748 | RAISE ✓ |
| Top pair | 0.707 | 0.748 | CALL (not raise) ✓ |
| Nut flush draw | 0.533 | 0.748 | CALL (not raise) ✓ |

## Street Gradient

Like preflop's position² gradient, postflop uses a street gradient:

```
street_progress: flop=0.0, turn=0.5, river=1.0

bet_thresh = bet_base + (bet_river_base − bet_base) × street_progress²
```

- Flop: bet_thresh ≈ 0.15 (loose — many streets to realize equity)
- Turn: bet_thresh ≈ 0.31 (tighter — one card left)
- River: bet_thresh ≈ 0.47 (tightest — equity fully realized)

The `street_progress²` makes the curve convex — thresholds rise slowly through the flop, steeply approaching the river. This matches reality: the jump from flop to turn is smaller than turn to river because the turn still offers one more card.

## Implementation Plan

### Phase 1: Parallel implementation (feature-gated)

1. New strategy type: `g48_formula_postflop`
2. Register alongside `g4_adaptive_postflop`
3. Shares equity computation (Gen3Equity) — only the decision logic changes
4. Config: `cash_nl_g48.toml` with formula parameters
5. A/B testing: run G48 vs G47 in self-play and vs G45 field

### Phase 2: Test cases

Build 30-50 postflop hand replay tests:
- Value betting (top pair, sets, flushes) × 3 streets
- Bluffing (air on dry board, missed draws) × 2 streets
- Semi-bluffing (nut flush draw, OESD) × 2 streets
- Trapping (flopped set → check-call → river raise) — full multi-street sequence
- Facing aggression (fold medium pair to 3-bet, call with draw + odds)
- Protection betting (vulnerable top pair on wet board)
- Multi-street lines (check-call flop, lead turn, bet river)

### Phase 3: Parameter sweep

Use the existing GUI sweep tool to tune the ~21 parameters.

### Phase 4: Replacement

If G48 outperforms G47 in self-play and vs field, replace `g4_adaptive_postflop` as the production postflop.

## Risk Assessment

**Low risk:** Equity computation unchanged (same MC rollouts, same ranges). Only the decision logic changes.

**Medium risk:** The categorical tree has been battle-tested in live play. Some edge cases (e.g., multi-way raise wars, short-stack dynamics) may not reproduce perfectly in the formula. The test suite and A/B testing will catch these.

**Key advantage:** 21 parameters vs 80+ means the sweep space is tractable. The current 80-parameter system is effectively un-sweepable — many parameters interact non-linearly and can't be explored independently.

## Open Questions

1. **Should the call score use win_prob (EHS) instead of combined?**
   - Current code uses win_prob for calls, hs for bets/raises
   - The combined score includes draw_score, which may overvalue draws for calling
   - Alternative: use value_score + draw_score for bets/raises, win_prob for calls
   - This preserves the "assert strength vs continue with equity" distinction

2. **How to handle multi-way adjustment?**
   - Current code uses exponential power adjustments (strength^adj)
   - Formula could use: `bet_thresh *= 1 + 0.1 × (num_active - 2)`
   - Simpler and more predictable

3. **Aggression penalty formula?**
   - Current: +0.12 for raise-over-bet, +0.06 per extra raise, +0.08 volatile board
   - Formula: `raise_thresh += agg_penalty_base × (1 + num_raises × 0.5) × (1 + npot × npot_weight)`

4. **Short-stack handling?**
   - Current code has separate OTB (short-handed) thresholds
   - Formula could use: `bet_thresh /= max(1.0, 15.0 / stack_bb)` — tighter when deep, looser when short


id: f7657197c0e74a5cb5f6761eee89ff38
parent_id: e13f1845de9b4b6392ad866354fbd562
created_time: 2026-07-06T19:30:44.870Z
updated_time: 2026-07-17T08:37:46.697Z
is_conflict: 0
latitude: 0.00000000
longitude: 0.00000000
altitude: 0.0000
author: 
source_url: 
is_todo: 0
todo_due: 0
todo_completed: 0
source: joplin-desktop
source_application: net.cozic.joplin-desktop
application_data: 
order: 1783366244870
user_created_time: 2026-07-06T19:30:44.870Z
user_updated_time: 2026-07-06T19:30:44.870Z
encryption_cipher_text: 
encryption_applied: 0
markup_language: 1
is_shared: 0
share_id: 
conflict_original_id: 
master_key_id: 
user_data: 
deleted_time: 1784277466697
type_: 1