66dd880f93
Solidify hand histories into one versioned shape that gets stored, replayed, and exported — the foundation the tap recorder will emit into and RTO consumes. - normalize_structured(): single guarantee of the contract shape — canonical cards (unicode/10/case -> RankSuit tokens, unknown 'Ax'/'x' preserved), hero synced into players[] (RTO finds hero via pos==hero_pos), schema_version stamp, and a completeness summary so consumers skip suit-dependent math on partial hands. Idempotent; runs on store AND read (legacy rows conform on the way out). - list_recent_hands: has_structured flag so the export/RTO knows which hands have a replayable body worth fetching. - docs/HAND_HISTORY.md: the shared contract both repos cite (schema, conventions, ownership rule, one-way HTTP coupling, transport endpoints). - replaces the narrow _normalize_parsed (unicode-only) everywhere. Card format chosen: lists of 2-char tokens (unambiguous, matches what Lyra already stores + the viewer reads). Unknowns kept + flagged rather than dropped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
73 lines
4.0 KiB
Markdown
73 lines
4.0 KiB
Markdown
# Hand-history contract (Lyra → RTO)
|
||
|
||
The canonical structured shape for a poker hand. **Lyra owns hands** — it produces this
|
||
shape (LLM parser today; the tap recorder natively, going forward), stores it, replays it
|
||
in the viewer, and exports it. **RTO consumes it** over HTTP and never reaches into Lyra.
|
||
|
||
Ownership rule: whoever owns the data owns the tools that produce it. Lyra owns the hand
|
||
DB, the viewer, and the copilot loop, so hand capture lives here. RTO is a pure engine.
|
||
|
||
Coupling: **one arrow, Lyra → RTO, HTTP only.** RTO is a standalone service (solve /
|
||
exploit / estimate); Lyra POSTs to it when it wants analysis. No shared package, no shared
|
||
DB, no shared UI components. If RTO is down, Lyra skips analysis and nothing breaks.
|
||
|
||
## Schema (`schema_version: 1`)
|
||
|
||
```jsonc
|
||
{
|
||
"schema_version": 1,
|
||
"game": "NLH", // NLH | PLO | ...
|
||
"stakes": "1/3", // or null
|
||
"hero_pos": "BTN", // one of POSITIONS
|
||
"hero_cards": ["Ah", "Kh"], // convenience mirror of the hero's players[].cards
|
||
"players": [ // every player in the hand, incl. hero
|
||
{"pos": "BTN", "stack": 300, "name": "Hero", "cards": ["Ah","Kh"], "hero": true},
|
||
{"pos": "BB", "stack": 250, "name": "Sal", "cards": null} // cards: null unless shown
|
||
],
|
||
"actions": [ // one flat chronological list across all streets
|
||
{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 15},
|
||
{"street": "flop", "board": ["7d","2c","5h"]}, // a street begins with its board reveal
|
||
{"street": "flop", "pos": "BB", "action": "check"}
|
||
],
|
||
"board": ["7d","2c","5h"], // full final board, 0–5 cards
|
||
"result": {"pot": 40, "hero_net": 25, "summary": "one line"},
|
||
"completeness": {"cards": true, "board": true, "actions": true}
|
||
}
|
||
```
|
||
|
||
### Conventions (load-bearing)
|
||
|
||
- **Cards are lists of 2-char tokens**, `RankSuit`: rank in `23456789TJQKA` (ten = `T`),
|
||
suit in `c d h s` (lowercase). E.g. `["As","5d","2c"]`. RTO maps each token via
|
||
`pokercore.parse_card`. *(Chosen over space-joined strings: unambiguous, no re-splitting,
|
||
and it's what Lyra already stores + what the viewer reads.)*
|
||
- **Unknown cards are kept, not dropped:** `"Ax"` = known rank / unknown suit, `"x"` =
|
||
fully unknown card. The LLM parser emits these when Brian didn't state suits. The tap
|
||
recorder won't — it captures complete cards by construction — so `"x"` is an
|
||
import/parser-only concern.
|
||
- **`completeness`** tells a consumer what's safe to use: `cards`/`board` are `true` only
|
||
when every relevant card is fully specified (no `"x"`). RTO uses `false`-card hands for
|
||
positions/frequencies/pairs and skips suit-dependent math (flushes).
|
||
- **Hero appears in `players[]`** with `"hero": true` and is findable via `pos == hero_pos`.
|
||
`hero_cards` is a mirror for the viewer; `players[].cards` is the source of truth.
|
||
- **Positions:** `UTG UTG1 UTG2 MP LJ HJ CO BTN SB BB`.
|
||
- **Actions:** `post fold check call bet raise allin`. `amount` is a plain number (no `$`),
|
||
null for non-sized actions (fold/check). Street boards appear as `{street, board}` entries.
|
||
- **Streets:** `preflop flop turn river`.
|
||
|
||
`lyra/poker.py:normalize_structured()` is the single function that guarantees this shape.
|
||
It runs on store and on read, and is idempotent.
|
||
|
||
## Transport (HTTP, Lyra serves on :7078)
|
||
|
||
- `GET /hands/data?limit=N` → `{ "hands": [ {id, position, hole_cards, board, result, tag,
|
||
at, lesson, venue, stakes, has_structured}, ... ] }` — flat list for browsing. Use
|
||
`has_structured` to pick which hands have a replayable body worth fetching.
|
||
- `GET /hand/{id}/data` → the full hand row; `structured` is the object above (or `null`
|
||
for a flat quick-log that hasn't been reconstructed).
|
||
|
||
RTO's "Lyra bridge" (its `docs/estimator-design.md`, Phase B) walks `structured.actions`
|
||
to classify each villain decision into `checked_to` / `facing_bet` / `facing_raise`, and
|
||
uses shown `cards` + that street's `board` for board-relative categories. Everything that
|
||
walk needs is in the schema above.
|