gutcheck 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gutcheck/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """gutcheck — a felt-sense poker reading-trainer.
2
+
3
+ The importable package: the game engine, the local web table server, the MCP coach
4
+ proxy, and the operator-owned graduating-leak store. Submodules are imported lazily by
5
+ their consumers (``from gutcheck import server`` / ``from gutcheck.seam import Seam``);
6
+ this ``__init__`` deliberately imports nothing so a plain ``import gutcheck`` has no
7
+ side effects and can't trip the server→coach_daemon→… chain at package-load time.
8
+
9
+ All runtime state lives OUTSIDE the package, under ``~/.gutcheck`` (override with
10
+ ``GUTCHECK_DATA_DIR``) — see ``gutcheck.paths`` — so a read-only wheel install is safe.
11
+ """
12
+
13
+ __version__ = "0.1.0"
gutcheck/archetypes.py ADDED
@@ -0,0 +1,168 @@
1
+ """Villain archetypes: legible, tunable, exploitable opponents.
2
+
3
+ Three v0 player types, each a distinct exploit lesson:
4
+ - rock "The Nit" : ultra-tight, value-only, never bluffs, folds to heat.
5
+ Exploit: steal relentlessly; fold when it wakes up.
6
+ - reg "The TAG" : tight-aggressive, c-bets, value-bets, occasional bluff.
7
+ Exploit: the toughest; play straightforward, pick spots.
8
+ - station "The Calling Station": loose-passive, calls too much, rarely folds/raises.
9
+ Exploit: value-bet thin and relentlessly; never bluff it.
10
+
11
+ Decisions key off a hand-strength read + position + a seeded RNG. Made-hand
12
+ strength only in v0 (draws are narrated by the coach layer, not played by bots).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import pokerkit as pk
18
+
19
+ RANK_ORDER = "23456789TJQKA"
20
+ LABEL_TIER = {
21
+ "High card": 0, "One pair": 1, "Two pair": 2, "Three of a kind": 3,
22
+ "Straight": 4, "Flush": 5, "Full house": 6, "Four of a kind": 7,
23
+ "Straight flush": 8,
24
+ }
25
+
26
+
27
+ def _rv(card: str) -> int:
28
+ return RANK_ORDER.index(card[0])
29
+
30
+
31
+ def preflop_bucket(hole: list[str]) -> str:
32
+ """premium / strong / playable / trash."""
33
+ r1, r2 = hole[0][0], hole[1][0]
34
+ s1, s2 = hole[0][1], hole[1][1]
35
+ v1, v2 = _rv(hole[0]), _rv(hole[1])
36
+ hi, lo = max(v1, v2), min(v1, v2)
37
+ pair = r1 == r2
38
+ suited = s1 == s2
39
+ gap = hi - lo
40
+ T, A_, K, Q, J = (RANK_ORDER.index(x) for x in "TAKQJ")
41
+
42
+ if pair and hi >= T: # TT+
43
+ return "premium"
44
+ if hi == A_ and lo >= K: # AK
45
+ return "premium"
46
+ if hi == A_ and lo >= Q: # AQ
47
+ return "strong"
48
+ if pair: # 22-99
49
+ return "strong" if hi >= RANK_ORDER.index("7") else "playable"
50
+ if hi == A_: # any other ace
51
+ return "playable" if (suited or lo >= T) else "trash"
52
+ if hi >= K and lo >= T: # KT+/KJ/KQ
53
+ return "strong" if suited else "playable"
54
+ if suited and gap <= 2 and lo >= RANK_ORDER.index("4"): # suited connectors
55
+ return "playable"
56
+ if hi >= J and lo >= RANK_ORDER.index("9"): # J9+/JT
57
+ return "playable"
58
+ return "trash"
59
+
60
+
61
+ def postflop_strength(hole: list[str], board: list[str]) -> str:
62
+ """air / weak / medium / strong / monster (made-hand only)."""
63
+ h = pk.StandardHighHand.from_game("".join(hole), "".join(board))
64
+ tier = LABEL_TIER[h.entry.label.value]
65
+ if tier == 0:
66
+ return "air"
67
+ if tier == 1: # one pair — refine
68
+ board_ranks = sorted((_rv(c) for c in board), reverse=True)
69
+ hole_ranks = [_rv(c) for c in hole]
70
+ top_board = board_ranks[0]
71
+ if hole[0][0] == hole[1][0] and hole_ranks[0] > top_board:
72
+ return "strong" # overpair
73
+ if top_board in hole_ranks:
74
+ return "medium" # top pair
75
+ return "weak" # middle/bottom/board pair
76
+ if tier == 2:
77
+ return "strong" # two pair
78
+ return "monster" # set+
79
+
80
+
81
+ STRENGTH_RANK = {"air": 0, "weak": 1, "medium": 2, "strong": 3, "monster": 4,
82
+ "trash": 0, "playable": 1, "premium": 4}
83
+
84
+
85
+ def _clamp_raise(ctx: dict, target: int) -> str:
86
+ lo, hi = ctx["legal"]["min_raise_to"], ctx["legal"]["max_raise_to"]
87
+ if lo is None:
88
+ return "c"
89
+ target = max(lo, min(hi, int(target)))
90
+ return f"r{target}"
91
+
92
+
93
+ def decide(archetype: str, ctx: dict) -> str:
94
+ """Return an action token: 'f' | 'c' | 'rN'.
95
+
96
+ ctx keys: hole, board, legal, pot, bb, street_index, rng, facing_bet,
97
+ current_level (the bet amount to call up to this street).
98
+ """
99
+ legal = ctx["legal"]
100
+ rng = ctx["rng"]
101
+ facing = legal["call_amount"] > 0
102
+ can_raise = legal["raise"]
103
+ pot = ctx["pot"]
104
+ bb = ctx["bb"]
105
+
106
+ if ctx["street_index"] == 0:
107
+ strength = STRENGTH_RANK[preflop_bucket(ctx["hole"])]
108
+ open_to = 3 * bb
109
+ else:
110
+ strength = STRENGTH_RANK[postflop_strength(ctx["hole"], ctx["board"])]
111
+ open_to = ctx["current_level"] + max(bb, int(0.66 * pot))
112
+
113
+ # ---- archetype policies ----
114
+ if archetype == "rock":
115
+ # plays only strong+; raises premium/monster for value; never bluffs.
116
+ if not facing:
117
+ if can_raise and strength >= 4:
118
+ return _clamp_raise(ctx, open_to)
119
+ if strength >= 3:
120
+ return "c" # slow with strong but not nut
121
+ return "c" if legal["call_amount"] == 0 else "f"
122
+ # facing a bet
123
+ if strength >= 4 and can_raise and rng.random() < 0.6:
124
+ return _clamp_raise(ctx, open_to)
125
+ if strength >= 3:
126
+ return "c"
127
+ return "f"
128
+
129
+ if archetype == "reg":
130
+ # TAG: opens strong, c-bets, value-raises, bluffs ~12%.
131
+ bluff = rng.random() < 0.12
132
+ if not facing:
133
+ if can_raise and (strength >= 3 or (strength >= 2 and rng.random() < 0.5) or bluff):
134
+ return _clamp_raise(ctx, open_to)
135
+ if strength >= 1:
136
+ return "c"
137
+ return "c" if legal["call_amount"] == 0 else "f"
138
+ if strength >= 3 and can_raise and rng.random() < 0.55:
139
+ return _clamp_raise(ctx, open_to)
140
+ if strength >= 2:
141
+ return "c"
142
+ if strength >= 1 and ctx["street_index"] == 0: # call one bet w/ playable preflop
143
+ return "c"
144
+ if bluff and can_raise and rng.random() < 0.4:
145
+ return _clamp_raise(ctx, open_to)
146
+ return "f"
147
+
148
+ if archetype == "station":
149
+ # loose-passive: calls almost anything, rarely raises, almost never folds.
150
+ if not facing:
151
+ if can_raise and strength >= 4 and rng.random() < 0.4:
152
+ return _clamp_raise(ctx, open_to)
153
+ return "c"
154
+ if strength >= 4 and can_raise and rng.random() < 0.3:
155
+ return _clamp_raise(ctx, open_to)
156
+ # calls with anything but the rawest air, and even some air
157
+ if strength >= 1 or rng.random() < 0.45:
158
+ return "c"
159
+ return "f"
160
+
161
+ raise ValueError(f"unknown archetype: {archetype!r}")
162
+
163
+
164
+ ARCHETYPES = {
165
+ "rock": "The Nit",
166
+ "reg": "The TAG",
167
+ "station": "The Calling Station",
168
+ }
gutcheck/coach.py ADDED
@@ -0,0 +1,302 @@
1
+ """gutcheck coach channel — a tiny local SQLite request/response bus.
2
+
3
+ Decouples the game app (server.py, which WRITES coaching requests) from the coach
4
+ worker (a warm CC session via coach_watch.py, or a headless daemon next slice, which
5
+ READS pending requests and WRITES prose responses). A plain inbox pattern, purely
6
+ local and zero-dep (stdlib sqlite3 only). WAL mode so the
7
+ single-threaded game server and a separate worker process read/write concurrently
8
+ without locking each other out.
9
+
10
+ The channel carries NO money and NO game authority — only a coaching request (a
11
+ frozen snapshot of one spot) and the coach's prose reply. So it deliberately has none
12
+ of career.json's durability/idempotency machinery: a lost request is a non-event (just
13
+ ask again), so there is nothing here a torn write can corrupt.
14
+
15
+ Importable API (server + worker) and a CLI (the worker / debugging):
16
+
17
+ python -m gutcheck.coach ask --kind decision --context-file spot.json -> prints the new id
18
+ python -m gutcheck.coach pending -> JSON list of unanswered
19
+ python -m gutcheck.coach get --id 7 -> JSON of one request
20
+ python -m gutcheck.coach respond --id 7 --body-file reply.txt -> mark answered
21
+ python -m gutcheck.coach poll --id 7 -> {"status":..,"response":..}
22
+ python -m gutcheck.coach clean --older-than 24 -> prune old rows
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import contextlib
29
+ import json
30
+ import sqlite3
31
+ import sys
32
+ import time
33
+ from pathlib import Path
34
+
35
+ from . import paths
36
+
37
+ # The coach channel db lives in the per-user data dir (paths.py / GUTCHECK_DATA_DIR),
38
+ # resolved lazily in _conn() so a test/smoke can redirect it off the real channel.
39
+
40
+ KINDS = ("decision", "review")
41
+ # A coaching request that no worker ever answered is junk after this long — a stale
42
+ # "flow is thinking…" the player long since clicked past. Pruned by clean().
43
+ _PENDING_TTL_H = 6
44
+ # Bound a runaway worker reply: the writer is trusted (your own CC session / a launchd
45
+ # daemon), but a giant response would bloat the row and ship whole to the browser on poll.
46
+ _MAX_RESPONSE = 16 * 1024
47
+ # Paths whose schema this process has already ensured — so the DDL stays OFF the hot
48
+ # poll path (poll fires every ~1.2s). WAL is a persistent DB property; the table is
49
+ # IF NOT EXISTS — both only need running once per path per process.
50
+ _INITIALIZED: set[str] = set()
51
+
52
+
53
+ def _conn(path: Path | None = None) -> sqlite3.Connection:
54
+ if path is None:
55
+ path = paths.coach_db()
56
+ # Self-heal the data dir: the coach CLI and the launchd daemon can run BEFORE the server ever
57
+ # calls ensure_data_dir(), and sqlite.connect() on a missing parent raises "unable to open
58
+ # database file". Ensuring it here covers every coach entry point at the one chokepoint.
59
+ path.parent.mkdir(parents=True, exist_ok=True)
60
+ c = sqlite3.connect(str(path), timeout=5.0)
61
+ c.row_factory = sqlite3.Row
62
+ c.execute("PRAGMA busy_timeout=5000") # wait out a concurrent writer rather than erroring
63
+ # CREATE TABLE runs on EVERY connection (cheap — a no-op when the table exists) so the channel
64
+ # SELF-HEALS after reset_data() deletes coach.db, ACROSS PROCESSES too: every coach op opens a
65
+ # fresh per-call connection (no long-lived fd to a stale inode), so the L3-feared daemon
66
+ # split-brain can't persist. The _INITIALIZED gate below covers only the once-per-process work.
67
+ c.execute(
68
+ """CREATE TABLE IF NOT EXISTS requests (
69
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
70
+ created REAL NOT NULL,
71
+ kind TEXT NOT NULL,
72
+ context TEXT NOT NULL, -- JSON: the frozen spot
73
+ status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'answered'
74
+ response TEXT, -- the coach's prose
75
+ meta TEXT, -- JSON: opaque worker-computed blob
76
+ answered REAL -- (the modeled-equity, for the drill reveal)
77
+ )"""
78
+ )
79
+ key = str(path)
80
+ if key not in _INITIALIZED:
81
+ c.execute("PRAGMA journal_mode=WAL") # persistent db property; set once per process per path
82
+ # An older coach.db predates `meta` — add it (cheap, one-time). Guarded by a column probe
83
+ # because ADD COLUMN errors if it already exists. The probe→ALTER is NOT atomic across
84
+ # PROCESSES, so a concurrent racer can add the column between our probe and our ALTER —
85
+ # swallow ONLY that duplicate-column error; a real lock/IO/read-only failure must propagate
86
+ # (swallowing it would mark this path _INITIALIZED with no meta column, breaking set_meta).
87
+ if "meta" not in {r[1] for r in c.execute("PRAGMA table_info(requests)")}:
88
+ try:
89
+ c.execute("ALTER TABLE requests ADD COLUMN meta TEXT")
90
+ except sqlite3.OperationalError as e:
91
+ if "duplicate column" not in str(e).lower():
92
+ raise
93
+ _INITIALIZED.add(key)
94
+ c.commit()
95
+ return c
96
+
97
+
98
+ def _row_to_dict(r: sqlite3.Row) -> dict:
99
+ d = dict(r)
100
+ try:
101
+ d["context"] = json.loads(d["context"])
102
+ except (json.JSONDecodeError, TypeError):
103
+ d["context"] = {}
104
+ return d
105
+
106
+
107
+ def ask(kind: str, context: dict, path: Path | None = None) -> int:
108
+ """Write a pending coaching request. Returns its id (the poll handle)."""
109
+ if kind not in KINDS:
110
+ raise ValueError(f"unknown coach request kind: {kind!r} (expected {KINDS})")
111
+ # closing() closes the connection; the inner `c` is the transaction (commit/rollback).
112
+ with contextlib.closing(_conn(path)) as c, c:
113
+ cur = c.execute(
114
+ "INSERT INTO requests (created, kind, context, status) VALUES (?,?,?,'pending')",
115
+ (time.time(), kind, json.dumps(context)),
116
+ )
117
+ return int(cur.lastrowid)
118
+
119
+
120
+ def pending(path: Path | None = None) -> list[dict]:
121
+ """All unanswered requests, oldest first (the worker's work queue)."""
122
+ with contextlib.closing(_conn(path)) as c:
123
+ rows = c.execute(
124
+ "SELECT * FROM requests WHERE status='pending' ORDER BY id ASC"
125
+ ).fetchall()
126
+ return [_row_to_dict(r) for r in rows]
127
+
128
+
129
+ def get(req_id: int, path: Path | None = None) -> dict | None:
130
+ with contextlib.closing(_conn(path)) as c:
131
+ r = c.execute("SELECT * FROM requests WHERE id=?", (req_id,)).fetchone()
132
+ return _row_to_dict(r) if r else None
133
+
134
+
135
+ def respond(req_id: int, body: str, path: Path | None = None) -> bool:
136
+ """Attach the coach's reply and mark answered. Returns False if the id is unknown
137
+ or already answered (so a double-respond — two workers, a retry — is a no-op, never
138
+ an overwrite of a delivered answer)."""
139
+ body = (body or "")[:_MAX_RESPONSE]
140
+ with contextlib.closing(_conn(path)) as c, c:
141
+ cur = c.execute(
142
+ "UPDATE requests SET response=?, status='answered', answered=? "
143
+ "WHERE id=? AND status='pending'",
144
+ (body, time.time(), req_id),
145
+ )
146
+ return cur.rowcount > 0
147
+
148
+
149
+ def poll(req_id: int, path: Path | None = None) -> dict:
150
+ """The app's read side: {'status': 'pending'|'answered'|'missing', 'response': ...}."""
151
+ row = get(req_id, path)
152
+ if row is None:
153
+ return {"status": "missing", "response": None}
154
+ return {"status": row["status"], "response": row["response"]}
155
+
156
+
157
+ def set_meta(req_id: int, obj: dict, path: Path | None = None) -> bool:
158
+ """Attach an OPAQUE structured blob to a request, separate from the prose response.
159
+
160
+ The worker computes the modeled equity exactly ONCE (coach_watch._ensure_modeled_equity
161
+ early-returns if it is already on the ctx) and stores it here, so the app can REVEAL it at the
162
+ gut-commit beat WITHOUT re-running the Monte-Carlo — and so the drill's number is byte-identical
163
+ to the number the coach prose cites. (Byte-identical because it is the SAME computed blob, NOT
164
+ because the MC is deterministic — it isn't; a recompute would wiggle ±1-2%. The single-compute
165
+ early-return is what makes it stable.) The channel stays equity-agnostic: just JSON in, JSON out.
166
+
167
+ `meta` (equity) and `response` (prose) are SEPARATE, non-atomic writes BY DESIGN: the equity is the
168
+ ground-truth reveal (authoritative, available the moment it's computed), the prose is best-effort
169
+ narration (may never arrive — no worker up, or one that dies between this write and `respond`). The
170
+ app reveals the number regardless and only narrates when the prose lands.
171
+
172
+ WRITE-ONCE (`AND meta IS NULL`): the FIRST worker to compute the equity wins and the number is FROZEN.
173
+ A concurrent OR restarted worker that computes a second, MC-wiggled value cannot overwrite the number
174
+ the browser may already have revealed — so the late/losing worker must REREAD the stored value and use
175
+ THAT for its prose (coach_watch._ensure_modeled_equity does), keeping the drill reveal and the prose
176
+ byte-identical even under two workers. Returns True iff THIS call set it (False if the id is unknown
177
+ OR meta was already set)."""
178
+ with contextlib.closing(_conn(path)) as c, c:
179
+ cur = c.execute("UPDATE requests SET meta=? WHERE id=? AND meta IS NULL",
180
+ (json.dumps(obj), req_id))
181
+ return cur.rowcount > 0
182
+
183
+
184
+ def poll_meta(req_id: int, path: Path | None = None) -> dict:
185
+ """The app's read side for the opaque blob: {'status': 'ready'|'pending'|'no_equity'|'missing', 'meta': obj|None}.
186
+
187
+ 'ready' = the blob is present and parseable (the drill reveals it).
188
+ 'pending' = the request exists, is still UNANSWERED, and has no blob yet — keep polling.
189
+ 'no_equity' = TERMINAL: the worker finished (answered) but produced no usable number — equity
190
+ computation failed, the spot expired, or the stored blob is corrupt. The app stops
191
+ waiting, reveals "no number", but STILL surfaces the prose (it exists). This split
192
+ (consulting row['status']) is what stops an answered-but-equity-less row from looking
193
+ like "still computing" until timeout and then suppressing prose that's already there
194
+ (codex L3 HIGH).
195
+ 'missing' = no such request (expired/never-was)."""
196
+ row = get(req_id, path)
197
+ if row is None:
198
+ return {"status": "missing", "meta": None}
199
+ raw = row.get("meta")
200
+ if raw:
201
+ try:
202
+ return {"status": "ready", "meta": json.loads(raw)}
203
+ except (json.JSONDecodeError, TypeError):
204
+ return {"status": "no_equity", "meta": None} # present-but-corrupt → terminal, not "computing"
205
+ # no blob yet: still computing only if the request is unanswered; an answered row will never get one
206
+ if row.get("status") == "answered":
207
+ return {"status": "no_equity", "meta": None}
208
+ return {"status": "pending", "meta": None}
209
+
210
+
211
+ def clean(older_than_hours: float = 24.0, path: Path | None = None) -> int:
212
+ """Prune answered rows older than the cutoff, and pending rows older than the
213
+ pending-TTL (a request no worker ever picked up). Returns rows deleted."""
214
+ now = time.time()
215
+ ans_cut = now - older_than_hours * 3600
216
+ pend_cut = now - _PENDING_TTL_H * 3600
217
+ with contextlib.closing(_conn(path)) as c, c:
218
+ cur = c.execute(
219
+ "DELETE FROM requests WHERE (status='answered' AND COALESCE(answered, created) < ?) "
220
+ "OR (status='pending' AND created < ?)",
221
+ (ans_cut, pend_cut),
222
+ )
223
+ return cur.rowcount
224
+
225
+
226
+ # ---------------- CLI ----------------
227
+ def _read_text_arg(direct: str | None, file_arg: str | None) -> str:
228
+ if direct is not None:
229
+ return direct
230
+ if file_arg:
231
+ return Path(file_arg).read_text()
232
+ data = sys.stdin.read()
233
+ if not data:
234
+ raise SystemExit("no body: pass --body, --body-file, or pipe via stdin")
235
+ return data
236
+
237
+
238
+ def main(argv: list[str] | None = None) -> int:
239
+ ap = argparse.ArgumentParser(description="gutcheck coach channel")
240
+ ap.add_argument("--db", default=None, help="channel db path (default: <data-dir>/coach.db)")
241
+ sub = ap.add_subparsers(dest="cmd", required=True)
242
+
243
+ a = sub.add_parser("ask", help="write a pending coaching request")
244
+ a.add_argument("--kind", required=True, choices=KINDS)
245
+ a.add_argument("--context", help="inline JSON context")
246
+ a.add_argument("--context-file", help="JSON context file ('-' for stdin)")
247
+
248
+ sub.add_parser("pending", help="list unanswered requests (JSON)")
249
+
250
+ g = sub.add_parser("get", help="fetch one request (JSON)")
251
+ g.add_argument("--id", type=int, required=True)
252
+
253
+ r = sub.add_parser("respond", help="attach a coaching reply + mark answered")
254
+ r.add_argument("--id", type=int, required=True)
255
+ r.add_argument("--body", help="inline reply text")
256
+ r.add_argument("--body-file", help="reply text file")
257
+
258
+ p = sub.add_parser("poll", help="read a request's status/response (JSON)")
259
+ p.add_argument("--id", type=int, required=True)
260
+
261
+ cl = sub.add_parser("clean", help="prune old rows")
262
+ cl.add_argument("--older-than", type=float, default=24.0, help="answered-row age cutoff (hours)")
263
+
264
+ args = ap.parse_args(argv)
265
+ db = Path(args.db) if args.db else paths.coach_db()
266
+
267
+ if args.cmd == "ask":
268
+ if args.context_file == "-" or (args.context is None and not args.context_file):
269
+ raw = sys.stdin.read()
270
+ elif args.context is not None:
271
+ raw = args.context
272
+ else:
273
+ raw = Path(args.context_file).read_text()
274
+ ctx = json.loads(raw)
275
+ print(ask(args.kind, ctx, db))
276
+ return 0
277
+ if args.cmd == "pending":
278
+ print(json.dumps(pending(db), indent=2))
279
+ return 0
280
+ if args.cmd == "get":
281
+ row = get(args.id, db)
282
+ if row is None:
283
+ print(json.dumps({"error": "not found", "id": args.id}))
284
+ return 1
285
+ print(json.dumps(row, indent=2))
286
+ return 0
287
+ if args.cmd == "respond":
288
+ body = _read_text_arg(args.body, args.body_file)
289
+ ok = respond(args.id, body, db)
290
+ print(json.dumps({"id": args.id, "answered": ok}))
291
+ return 0 if ok else 1
292
+ if args.cmd == "poll":
293
+ print(json.dumps(poll(args.id, db)))
294
+ return 0
295
+ if args.cmd == "clean":
296
+ print(json.dumps({"deleted": clean(args.older_than, db)}))
297
+ return 0
298
+ return 2
299
+
300
+
301
+ if __name__ == "__main__":
302
+ raise SystemExit(main())