We called it GTO. It wasn't.
A button in our app said "GTO." It was reading a 169-cell table, not solving a game. The honest taxonomy — chart vs CFR-approximation vs solved tree — and why we made a unit test fail if the word "GTO" ever comes back.
The problem
“GTO” might be the three most overloaded letters in poker software. “Optimal,” in AI tools generally, is the same lie in a different coat. Both promise something specific — a game-theory-optimal, unexploitable answer — and almost never deliver it. I know, because for a while one of our buttons said GTO too.
It wasn’t solving anything. It read a precomputed range table — 169 cells, one per starting hand — and reported what the table said. Useful! But a lookup is not a solve. So the honest question this post is really about isn’t “is your tool any good.” It’s: what does your “optimal” output actually compute?
There’s a real taxonomy hiding under the marketing, and most tools — ours included, until recently — blur all of it into one word.
Three tiers, and what each is allowed to claim
Clicking a button feels identical no matter which of these you got. The label is the only thing that tells a user what they’re holding — which is exactly why it can’t be marketing: the label alone separates “I read a table” from “I solved your spot.”
| Tier — what it actually computes | Honest label | Not allowed |
|---|---|---|
| 1 · Chart / heuristic — reads a precomputed range, or applies a rule (hand strength, pot odds, SPR). Nothing is solved at request time. | “a baseline range” | GTO · equilibrium · unexploitable |
| 2 · CFR approximation — counterfactual-regret minimization over a deliberately simplified game, halted after a finite budget with the residual exploitability measured — not converged to zero. Real equilibrium math; directionally sound. | “approximate equilibrium of a simplified game” | “the GTO solution” |
| 3 · Solved tree — CFR over the full, unabstracted tree for one defined spot, run until measured exploitability is small. As close to GTO as software gets. | “GTO for these ranges + bet sizes, at this exploitability” | bare “GTO,” no inputs named |
What we actually shipped — and renamed
Recently we did an app-wide honesty pass. Three concrete moves.
The rename
The preflop method was called gto_chart. The rename took two steps: first to preflop_chart; then, when the real DCFR solve landed a day later, the identifier and API field became cfr_equity_realization — the honest name for what actually generates the numbers. The line that read “This hand is in the GTO range for this spot” now reads “…in the preflop baseline range for this spot” (and in Chinese, GTO 开池范围 → 翻前基线范围). “GTO” is gone from the identifier, the API field, and the UI strings in both languages.
A real solver behind it — still tier 2
The table the chart reads isn’t a vendor’s chart we copied. It’s clean-room generated by our own solver: a discounted-CFR variant over a simplified 6-max / 100bb preflop tree, with all-in equity and per-class equity-realization at the terminals. The data file’s _source field carries that attestation, and a contract test rejects it the moment the attestation names a known vendor. But — this is the point — we label it “CFR approx. equilibrium · equity-realization,” tier 2, never tier 3. The tier names the provenance of the numbers — a CFR solve, run offline — not what happens on your request, which is still a lookup; that’s why the answer copy itself says “baseline range” and only the method badge claims CFR. The code comment is blunt: it MUST NEVER be called “GTO.”
The real solve, fenced off
We did build a tier-3 solver: real per-spot CFR over the full unabstracted postflop tree, reporting the exploitability it actually reached. It lives in its own crate, isolated because it wraps AGPL-licensed code and we keep that blast radius to one workspace member. Its result carries a different method type from the preflop one — CfrEquilibrium vs CfrEquityRealization, in separate crates — so the two can’t be mixed up inside the code; tests pin the wire labels on top. It ships behind a gate: it mounts only when an operator switches it on and configures an AGPL §13 source-offer URL — the offer is structurally required, not verified publication — and public access remains bounded by memory, concurrency, and input-size limits. Even here the taxonomy’s ceiling is “GTO for the specified ranges and bet sizes” — and the shipped badge is more conservative still: “Postflop CFR approximation,” with the ranges, bet sizes, iteration cap, and achieved exploitability in the detail line. Never bare “GTO.”
The limit even an honest solver hides
Here’s the part that survives the rename and still trips people: tier 3 isn’t “GTO” either — it’s GTO for an abstraction. A solve is optimal only for the bet sizes you allowed, the ranges you assumed, and the depth you ran. Change the bet-size set and you get a different equilibrium. “GTO” as an unqualified noun — “this is the GTO play” — is always, in every tool, an overclaim. “GTO for these inputs” is the honest form, and the gap isn’t pedantic: a solve with two bet sizes and one with six are different strategies, and a beginner reading “GTO” can’t tell which one they were sold.
Our tier-2 chart stacks its own caveats underneath. Two are documented precision limits: the equities are Monte-Carlo estimates (8,000 trials, rounded to whole percent), not exact, and the terminal equity uses fixed representative suit combos, not the true class average. Underneath those sits a family of fixed model inputs the data file’s own _source enumerates — open 2.5bb, 3-bet 3.2x, 4-bet 2.2x, 5-bet jam sizings; villain 3-bet ~11% and 4-bet ~5.5% frequencies; a best-of-N-defenders defense model with a minimum-defense-frequency floor; a rule overlay that force-includes premiums and excludes trash — among them the equity-realization factor (R between 0.70 and 1.12), the one the method badge itself names. None of these are solved, and the _source says it flatly: “A different sizing/R model yields different ranges.” That’s exactly why we label the output model-dependent — “CFR approx. equilibrium,” not “the solution” — and document each caveat as what it is, because approximately right and labeled exactly beats exactly stated and quietly wrong.
Make the honest label a test, not a vibe
A label you enforce with a style guide drifts back the first busy week. So we don’t enforce it with a style guide. The honesty is held by tests that must pass before every merge:
# renders the solver panel in both languages, asserts zero matches:
solver_view_preflop_baseline_copy_has_no_gto -> /\bGTO\b/ must not appear
# the preflop data must describe itself honestly:
preflop_chart_contract -> _source MUST contain "CFR" + "equity-realization"
_source MUST NOT claim flat "GTO" or "canonical DCFR"
If the word comes back as a claim — in user-facing copy, or as a positive assertion in the data’s description of its own pedigree — the test gate goes red. That’s the reusable part, and it has nothing to do with poker. If you ship an “AI,” “optimal,” “smart,” or “GTO” anything: name the output by what it actually computes — the abstraction and the tolerance, not the aspiration — and put the honest name behind a test that breaks when someone reaches for the bigger word. The marketing word will always try to come back. Let the test gate stop it.
Recap
- “GTO” / “optimal” is a claim about computation, not a vibe. Three tiers hide under it: a memorized chart, a CFR approximation of a simplified game, and a real solve of the full tree.
- A lookup is not a solve. We renamed our preflop
gto_chartaway (today the method iscfr_equity_realization) and label it “CFR approx. equilibrium” (tier 2) — it reads a clean-room table, it doesn’t solve your spot. - Even a real solve is only “GTO for these inputs” — bet sizes, ranges, exploitability. Unqualified “GTO” is always an overclaim, in every tool.
- Enforce the honest label with a test that fails on the forbidden word — not a style guide. The aspirational word always tries to come back; let the test gate stop it.