PLO Tag Query & Browsing Contract
How to query the PLO range/tag browser and what comes back. Applies to
plo4, plo5, and plo6 hand requests (examples use plo4). The
controls described here are additive: they never mutate the underlying
canonical bucket enumeration, they only filter, sort, page, and annotate
it.
The request carries two optional objects:
| Object | Purpose |
|---|---|
ploTagOptions (alias plo_tag_options) | Response shaping: schema, per-combo tag/facet payloads, paging, sorting, profiling, strategy toggle. |
plo_tag_query | Filtering + query semantics: require / exclude tags, **comboContains**, groupBy, plus its own paging/sort that can override ploTagOptions. |
Both are optional. Sending neither returns the default first page of the
canonical bucket map. Sending plo_tag_query (even empty) switches the
response into "query mode" and adds a ploTagQueryResult block.
Every field accepts both camelCase and snake_case. Where a field is
read from both objects, the precedence rules in
Field precedence apply.
Quick reference
plo_tag_query
| Field | Type | Default | Meaning |
|---|---|---|---|
require | string or string[] | [] | Tag names/aliases the hand must have (AND). |
exclude | string or string[] | [] | Tag names/aliases the hand must not have. |
comboContains (combo_contains) | string or string[] | [] | Suit-specific card filter (AND). See comboContains. |
rankPattern (rank_pattern) | string | unset | Exact rank-multiset filter (ranks only, any suit/order). See rankPattern. |
groupBy (group_by) | string or string[] | [] | Grouping key(s); echoed back. |
normalize | string | "conditional" | Echoed normalization mode. |
pageOffset (page_offset) | int | 0 | Page start. |
pageSize (page_size) | int or "all" | 200 | Page size; "all" returns the full matched set. |
sortField (sort_field) | string | "" | "combos", "pct", "ev", "raise"/"betRaise". |
sortActionSlot (sort_action_slot) | int | -1 | Action column for pct/ev. |
sortDir (sort_dir) | string | "desc" | "desc" or "asc". |
skipZeroPages (skip_zero_pages) | bool | false | Echoed hint for pct/ev/raise. See Sorting. |
max_groups | int | 0 | Reserved (grouping cap). |
max_examples_per_group | int | 0 | Reserved (grouping cap). |
ploTagOptions
| Field | Type | Default | Meaning |
|---|---|---|---|
includeSchema (include_schema) | bool | false | Add ploTagSchema (the tag dictionary for this variant/street). |
includePerComboTags (include_per_combo_tags) | bool | false | Add ploTagMap (tag-id list per combo). |
includePerComboFacets (include_per_combo_facets) | bool | false | Add ploFacets (named facet values per combo). |
profile | bool | false | Add ploProfile timing/count block. |
pageOffset (page_offset) | int | 0 | Page start. |
pageSize (page_size) | int or "all" | 200 | Page size; "all" = full matched set. |
aggregateAll (aggregate_all) | bool | false | Compute exact aggregate over the full matched set, then strip per-combo payload. |
sortField (sort_field) | string | "" | Same values as plo_tag_query.sortField. |
sortActionSlot (sort_action_slot) | int | -1 | Action column for pct/ev. |
sortDir (sort_dir) | string | "desc" | "desc" or "asc". |
skipZeroPages (skip_zero_pages) | bool | false | Echoed hint for pct/ev/raise. See Sorting. |
comboContains,rankPattern,require,exclude, andgroupBylive only inplo_tag_query.ploTagOptionsdoes not carry them.
Strategy inference toggle
Tag browsing works without inference. Strategy outputs (ploStrategy,
ploReach, actionLabels, and the pct/ev/raise sorts) require
inference to be turned on with any one of:
- top level:
ploGetStrategy/plo_get_strategy/ploRunStrategy/plo_run_strategy - inside
ploTagOptions:getStrategy/get_strategy/includeStrategy/include_strategy
comboContains
comboContains filters the range down to hands that physically contain
specific, suit-qualified cards. It is the right tool for "show me every
hand that has the Ace of hearts" or "show me hands with both the Ah and the
Kh". For an exact set of ranks in any suit/order (e.g. "every A-K-8-3
hand") use rankPattern instead; broad class searches (e.g.
"any ace", "any pair") belong in require / exclude.
Value format
Each entry is a string of one or more concatenated 2-character cards:
- Rank (first char):
2 3 4 5 6 7 8 9 T J Q K A, case-insensitive. - Suit (second char):
c d h s, case-insensitive. - Normalized internally to uppercase rank + lowercase suit (
ah->Ah).
"comboContains": ["Ah"] // single card
"comboContains": ["AhKh"] // compact multi-card string
"comboContains": ["Ah", "Kh"] // array form, same as "AhKh"
"comboContains": ["AhKsQdJc"] // a fully specified 4-card hand (plo4)A value may be a single string or an array of strings. Multiple cards (whether in one compact string or spread across the array) are combined and de-duplicated, then matched with AND semantics: a hand must contain every requested card.
What it returns
Unlike normal browsing (which returns canonical, suit-isomorphic
representatives with a multiplicity), comboContains returns the actual
physical combos that contain the requested cards. Every returned row
therefore has multiplicity = 1, and:
totalMatchingHandCount == totalMatchingComboCountEach combo is still mapped to its bucket in ploBucketMap, so bucketing,
tags, facets, sorting, and strategy all work on the physical rows.
Exact, deterministic match counts (plo4 preflop, empty board):
| Query | Matched hands | Why |
|---|---|---|
["Ah"] | 20825 | choose the other 3 cards from 51: C(51,3) |
["AhKh"] | 1225 | choose the other 2 from 50: C(50,2) |
["AhKsQdJc"] | 1 | the hand is fully specified |
Postflop, the board cards are removed from the deck first, so the counts shrink accordingly.
Validation (fails closed)
- A value must have even length and every 2-char chunk must be a valid
card. Anything else (e.g.
"AK83","ZZ","Ah5") marks that value invalid. - If any value is invalid, the whole query fails closed:
0matches, with:
"ploTagQueryResult": {
"status": "invalid_combo_contains",
"warning": "one_or_more_combo_contains_cards_are_invalid",
"comboContains": ["Ah", "ZZ"]
}- Requesting more distinct cards than the hand holds (e.g. 5 cards for
plo4) yields0matches withstatus: "ok". - Requesting a card that is on the board yields
0matches (you cannot hold a board card).
Composing with tags and sort
comboContains composes with require / exclude (the tag filter is
applied to each physical combo) and with any sortField:
"plo_tag_query": {
"comboContains": ["Ah"],
"require": ["double_suited"],
"exclude": ["pair"],
"sortField": "raise",
"sortDir": "desc",
"pageOffset": 0,
"pageSize": 50
}rankPattern
rankPattern filters the range down to hands whose ranks are exactly a
given multiset, ignoring suits and order. It is the rank-only complement to
comboContains: use comboContains when you care about
specific suited cards (Ah), and rankPattern when you care only about the
ranks ("every A-K-8-3 hand, any suits").
Value format
A single string of exactly holeCardCount rank characters (4 for
plo4, 5 for plo5, 6 for plo6), no suits:
- Rank:
2 3 4 5 6 7 8 9 T J Q K A, case-insensitive. - Order does not matter; duplicate ranks are allowed (and meaningful).
- Echoed back uppercased, in the order sent (not reordered).
"rankPattern": "AK83" // plo4: ranks {A,K,8,3}, any suits, any order
"rankPattern": "3k8a" // same set as "AK83" (case- and order-insensitive)
"rankPattern": "AAK3" // plo4 one-pair shape: ranks {A,A,K,3}Unlike
comboContains,rankPatternis a single string (not an array) and carries no suits.comboContains: "AK83"is still invalid exact-card input and fails closed asinvalid_combo_contains— the two filters do not overlap.
Semantics
The hand's hole-card ranks are sorted and compared for exact multiset
equality against the sorted pattern. A plo4 hand matches AK83 only if
its four ranks are exactly one A, one K, one 8, and one 3. Because duplicate
ranks are significant, AAK3 matches only hands holding exactly two aces
(plus a K and a 3) — not AAA3 and not AK83.
What it returns
On its own, rankPattern browses like the default: it returns canonical,
suit-isomorphic representatives with their multiplicity, so
totalMatchingComboCount is the raw-combo total and ploMultiplicity values
can be > 1. Combined with comboContains, it returns the physical combos
that contain the requested cards (multiplicity = 1), exactly as
comboContains does on its own.
Exact, deterministic raw-combo counts (plo4 preflop, empty board):
| Query | totalMatchingComboCount | Why |
|---|---|---|
rankPattern: "AK83" | 256 | 4 distinct ranks × 4 suits each: 4^4 |
rankPattern: "AAK3" | 96 | aces C(4,2)=6, K 4, 3 4: 6·4·4 |
rankPattern: "AK83" + comboContains: ["Ah"] | 64 | Ah fixed, other 3 ranks × 4 suits: 4^3 |
Postflop the board cards are removed from the deck first, so counts shrink
accordingly (a rank fully consumed by the board can drop the match to 0).
Validation (fails closed)
The pattern must be exactly holeCardCount characters and every character a
valid rank. Anything else — wrong length (e.g. "AK8" or "AK835" for
plo4), an invalid rank character ("AK8Z"), or an empty string — fails
closed: 0 matches, with:
"ploTagQueryResult": {
"status": "invalid_rank_pattern",
"warning": "rank_pattern_is_invalid",
"rankPattern": "AK8Z"
}If both comboContains and rankPattern are invalid in the same request,
invalid_combo_contains takes precedence.
Composing with comboContains, tags, and sort
rankPattern ANDs with comboContains, require / exclude, and any
sortField:
"plo_tag_query": {
"rankPattern": "AK83",
"comboContains": ["Ah"],
"sortField": "raise",
"sortDir": "desc"
}This returns the A-K-8-3 hands that hold the Ah, ordered by collapsed
bet/raise probability.
Tag filtering: require / exclude
require and exclude take tag names or aliases and combine as
"has all of require AND none of exclude". The available vocabulary is
data-driven and depends on the variant and street, so treat the schema
as the source of truth rather than hard-coding names:
- Set
ploTagOptions.includeSchema: trueto getploTagSchema, an array of{ id, name, plane, domain, aliases }entries valid for the currentholeCardCountand board street. - Use a
nameor any of itsaliasesinrequire/exclude.
If a require tag cannot be resolved the query fails closed
(status: "unresolved_required_tag"); an unresolved exclude tag is
ignored with status: "ok_with_unresolved_exclude". Unresolved aliases are
listed under ploTagUnresolved.
Sorting
Set sortField (and sortDir, default "desc"). Pages are slices of one
global order, so paging through a sort is stable.
sortField | Needs strategy? | sortActionSlot? | Ranks by |
|---|---|---|---|
"combos" | no | no | suit-isomorphic multiplicity |
"pct" | yes | required | probability of the chosen action slot |
"ev" | yes | required | EV of the chosen action slot |
"raise" / "betRaise" / "bet_raise" / "betraise" | yes | no | collapsed bet/raise = sum(strategy[2..10]) |
Action slots: 0 = fold, 1 = check/call, 2..10 = all-in + bet/raise sizes.
"combos"is resolved purely from multiplicity (known before inference), so it is applied directly (ploPaging.sort.applied: true)."pct"/"ev"/"raise"depend on model output, so they require the strategy toggle. The full matched set is ranked by that output, then sliced to the requested page; the finalploPaging.sort.appliedistrueandploPageOrderreflects the order. Combos with no inference output always sort to the end regardless of direction.skipZeroPagesis accepted and echoed back underploPaging.sort(andploTagQueryResult). It is a forward-looking hint; the current pipeline does not prune zero/unavailable rows from the page on its own — those rows simply sort last (see above). Do not rely on it to change counts.- For
"ev", if the model emits no usable or non-identical EVs the sort is not applied andsort.applied: falsewithsort.reason: "ev_unavailable". - An unsupported
sortFieldkeeps normal paging and setssort.applied: falsewithsort.warning: "unsupported_sort_field".
Paging
pageOffset/pageSizepage the matched set. IntegerpageSizeis capped (default200).pageSize: "all"returns the entire matched set with per-combo data (no cap). The full unfiltered PLO set is large, so this is an explicit opt-in.aggregateAll: trueenumerates the full matched set for an exact aggregate and then strips the per-combo payload, so only the rollup (ploOverallAggregate, bucket-tree aggregates) is returned.- Response paging echoes
pageOffset,pageSize,pageHandCount,totalMatchingHandCount,totalMatchingComboCount,hasNextPage,nextOffset.
Field precedence
When both objects are present:
plo_tag_querysuppliesrequire,exclude,groupBy,comboContains,rankPattern, and (always) itspageOffset/pageSize.skipZeroPagesfromplo_tag_querywins only if it is present.- A sort from
plo_tag_querywins only ifsortFieldis non-empty, so aploTagOptions-level sort survives a filter-onlyplo_tag_query.
Response
Always present (taxonomy)
| Field | Meaning |
|---|---|
taxonomyOnly | true. |
holeCardCount | 4, 5, or 6. |
numBoardCards | board size (0 preflop). |
sampledMode | false (results are exact). |
ploBucketTree | 3-level taxonomy tree (id, label, children). |
ploBucketMap | { comboString: bucketKey } for the page. Combo strings are concatenated cards, e.g. "AhKdQcJs". |
ploMultiplicity | { comboString: suitIsomorphicCount } (always 1 for comboContains). |
ploBucketCounts | { bucketKey: matchedCount } over the full matched set. |
ploPaging | paging block + sort echo (see below). |
ploPageOrder | array of combo strings in global sort order (present when a sort is applied). |
fullTotalCount, totalCanonicalHandCount, totalRawComboCount | enumeration totals. |
ploPaging.sort echoes the request and the resolution state. field and
dir are always present; applied says whether the page already reflects
the global order; actionSlot appears for pct/ev; skipZeroPages is
echoed when sent; warning/reason appear on the unsupported/ev-degenerate
paths.
"sort": {
"field": "raise",
"dir": "desc",
"applied": true
}When plo_tag_query is present: ploTagQueryResult
"ploTagQueryResult": {
"schemaVersion": 1,
"sampledMode": false,
"estimated": false,
"available": true,
"status": "ok",
"normalization": "conditional",
"require": ["double_suited"],
"exclude": ["pair"],
"groupBy": [],
"comboContains": ["Ah"],
"matchedHandCount": 8479,
"matchedComboCount": 8479,
"matchedWeight": 8479,
"paging": { "...": "mirror of ploPaging" },
"groups": []
}status values: ok, invalid_combo_contains, invalid_rank_pattern,
unresolved_required_tag, ok_with_unresolved_exclude,
tag_data_unavailable. rankPattern (uppercased) is echoed only when a rank
pattern was requested. (groups is reserved and returned empty by this
path.)
When tag payloads are requested
includeSchema->ploTagSchema:[{ id, name, plane, domain, aliases }].includePerComboTags->ploTagMap:{ comboString: [tagId, ...] }(resolve ids viaploTagSchema).includePerComboFacets->ploFacets:{ comboString: { facetName: value } }.ploTagAvailable,ploTagSchemaVersion, and on alias problemsploTagWarning+ploTagUnresolved.
When strategy inference is on
| Field | Meaning |
|---|---|
ploStrategy | per combo: strategy[11], evs[11], ev, raise, betRaise, and reach (if a reach query ran). |
ploOverallAggregate | multiplicity-weighted fold/call/raise + EV rollup. |
ploBucketTree[].aggregate | the same rollup attached per tree node. |
ploReach | { comboString: reachProbability } (when requested). |
actionLabels, actionHistory, tableState, supportScore | grid/UI metadata. |
A single ploStrategy entry:
"AhKhQdJd": {
"strategy": [0.12, 0.35, 0.03, 0.10, 0.18, 0.08, 0.14, 0.0, 0.0, 0.0, 0.0],
"raise": 0.53,
"betRaise": 0.53,
"ev": 1.24,
"evs": [0.0, 1.1, 1.2, 1.3, 1.4, 1.2, 1.1, 0.0, 0.0, 0.0, 0.0]
}When profile: true
ploProfile with filterPageMs, bucketCount,
enumeratedCanonicalHands, matchedCanonicalHands, returnedPageHands,
totalBuildMs.
Worked examples
1. Browse the default first page (no inference)
{
"protocol_ver": "v2.1",
"hand": { "game_type": "plo4", "...": "..." },
"ploTagOptions": { "pageOffset": 0, "pageSize": 50 }
}Returns the first 50 canonical buckets/combos with ploMultiplicity, no
ploStrategy.
2. comboContains: every hand with the Ah
{
"protocol_ver": "v2.1",
"hand": { "game_type": "plo4", "...": "..." },
"ploTagOptions": { "pageOffset": 0, "pageSize": 100 },
"plo_tag_query": { "comboContains": ["Ah"] }
}ploTagQueryResult.matchedHandCount == 20825; every key in ploBucketMap
contains Ah; all ploMultiplicity values are 1.
3. comboContains + tags + collapsed raise sort (with inference)
{
"protocol_ver": "v2.1",
"show_labels": true,
"hand": { "game_type": "plo4", "...": "..." },
"ploTagOptions": {
"getStrategy": true,
"pageOffset": 0,
"pageSize": 50,
"sortField": "raise",
"sortDir": "desc",
"includePerComboTags": true
},
"plo_tag_query": {
"require": ["double_suited"],
"exclude": ["pair"],
"comboContains": ["Ah"],
"sortField": "raise",
"sortDir": "desc"
}
}Returns double-suited, unpaired hands that contain Ah, ordered by total
bet/raise probability; ploPageOrder holds the order and each ploStrategy
row carries raise/betRaise.
4. pct sort on one size, skipping zero rows
{
"protocol_ver": "v2.1",
"hand": { "game_type": "plo4", "...": "..." },
"ploTagOptions": {
"getStrategy": true,
"pageOffset": 0,
"pageSize": 50,
"sortField": "pct",
"sortActionSlot": 4,
"sortDir": "desc",
"skipZeroPages": true
}
}Ranks combos by the probability of action slot 4 (a bet/raise size).
Combos with zero/no probability there sort to the end. skipZeroPages is
echoed under ploPaging.sort but does not itself prune the page (see
Sorting).
5. rankPattern: every A-K-8-3 hand (rank-only, any suits)
{
"protocol_ver": "v2.1",
"hand": { "game_type": "plo4", "...": "..." },
"plo_tag_query": { "rankPattern": "AK83", "pageSize": "all" }
}ploTagQueryResult.status == "ok", rankPattern == "AK83", and
ploPaging.totalMatchingComboCount == 256; every key in ploBucketMap has
ranks {A,K,8,3} in some suit order. Adding "comboContains": ["Ah"]
narrows it to the 64 physical combos that hold the ace of hearts (each with
multiplicity = 1).