FarmHouse: decision-model client (Laya/Kev) with shadow and assist modes

farmhouse_decide.py asks a /v1/systemone server which action to take; DECISION_MODE=shadow records agreement with the LLM, assist uses it when DECISION_MIN_CONF sure; off by default. scripts/farmhouse_decision_eval.py replays recorded decisions through it. 17 offline tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Er49soaMyG7Yburq57PeB
This commit is contained in:
fez5587andClaude Sonnet 5.5 committed 2026-10-26 18:05:07 -05:00
1 parent b779987802
commit 34e0fc6b2f
4 files changed
+368

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
"""How often does a decision model (Laya / Kev) pick the same action as the LLM did, on game states the app has already recorded?
DECISION_URL=http://laya:8080 python scripts/farmhouse_decision_eval.py [decisions_dir] [model ...]
Reads the brain's own decision logs (logs/decisions/*.jsonl: the snapshot and the LLM's answer), asks the decision model the same
question for each, and prints agreement overall, at several confidence levels, per action, the "always the most common action"
baseline, and the time per answer. The LLM is the teacher here, not the truth: this measures how well a fast model can stand in
for the slow one, not how well either plays.
"""
import collections
import glob
import importlib.util
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
spec = importlib.util.spec_from_file_location("farmhouse_decide", os.path.join(HERE, "..", "src", "commander_ai_lab", "sim", "farmhouse_decide.py"))
D = importlib.util.module_from_spec(spec)
spec.loader.exec_module(D)
def load(dirpath):
rows = []
for f in sorted(glob.glob(os.path.join(dirpath, "*.jsonl"))):
for line in open(f, encoding="utf-8"):
try:
r = json.loads(line)
except ValueError:
continue
if (r.get("decision") or {}).get("source") == "llm" and r.get("game_state") and r["decision"].get("action") in D.ACTION_HELP:
rows.append(r)
return rows
def main():
dirpath = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "logs", "decisions")
models = sys.argv[2:] or [os.environ.get("DECISION_MODEL", "")]
rows = load(dirpath)
if not rows:
print("no recorded LLM decisions in", dirpath)
return 1
truth = [r["decision"]["action"] for r in rows]
common, n_common = collections.Counter(truth).most_common(1)[0]
print("%d recorded decisions; LLM action mix: %s" % (len(rows), dict(collections.Counter(truth))))
print("baseline 'always %s': %.1f%%\n" % (common, 100.0 * n_common / len(rows)))
for model in models:
os.environ["DECISION_MODEL"] = model
D.STATS.update({"asked": 0, "agreed": 0, "answered_conf": 0, "errors": 0, "ms_total": 0.0, "by_action": {}})
got = []
for r in rows:
a = D.ask(r["game_state"])
got.append(a)
D.record(a, r["decision"]["action"])
ok = [(g, t) for g, t in zip(got, truth) if g]
print("== model %r: answered %d of %d, %.0f ms each" % (model or "auto", len(ok), len(rows), D.stats()["avg_ms"] or 0))
if not ok:
continue
print(" agrees with the LLM on %.1f%% (baseline %.1f%%)" % (100.0 * sum(g["choice"] == t for g, t in ok) / len(ok), 100.0 * n_common / len(rows)))
for lo in (0.5, 0.7, 0.85):
sel = [(g, t) for g, t in ok if g["confidence"] >= lo]
if sel:
print(" when it is >= %.2f sure (%d answers, %.0f%%): agrees %.1f%%" % (
lo, len(sel), 100.0 * len(sel) / len(ok), 100.0 * sum(g["choice"] == t for g, t in sel) / len(sel)))
by = collections.defaultdict(lambda: [0, 0])
for g, t in ok:
by[t][0] += 1
by[t][1] += g["choice"] == t
print(" per LLM action:", {k: "%d/%d" % (v[1], v[0]) for k, v in sorted(by.items())})
print(" it picked:", dict(collections.Counter(g["choice"] for g, t in ok)))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -254,6 +254,7 @@ def build_game_state_snapshot(
"my_life": me.life,
"opp_life": opp.life,
"my_mana_total": mana_total,
"land_play_available": getattr(me, "_lands_played_this_turn", 0) < getattr(me, "_land_plays_this_turn_limit", 1),
"my_mana_by_color": mana_colors,
"my_hand": my_hand,
"my_board": my_board,
@@ -268,6 +269,8 @@ def build_game_state_snapshot(
"opp_graveyard": opp_gy,
"my_library_count": cards_remaining,
"opp_library_count": len(opp.library),
"opp_hand_count": len(opp.hand),
"stack_supported": False,
"deck_drawn_pct": pct_drawn,
}
@@ -578,6 +581,11 @@ class DeepSeekBrain:
if available_mana is not None:
snapshot["my_mana_available"] = available_mana
from commander_ai_lab.sim.strategic_context import turn_context
advice = turn_context(snapshot, (deck_context or {}).get("strategic_preferences"))
snapshot["strategic_advice"] = advice
candidate_actions = advice["candidate_actions"]
if self.config.cache_enabled:
cache_key = self._cache_key(snapshot)
if cache_key in self._cache:
@@ -587,6 +595,18 @@ class DeepSeekBrain:
cached["latency_ms"] = round((time.time() - t_start) * 1000, 1)
return cached
from commander_ai_lab.sim import farmhouse_decide as _fd # FARMHOUSE_DECISION
_fd_mode = _fd.mode()
if _fd_mode == "assist":
_fd_ans = _fd.ask(snapshot, actions=candidate_actions)
if (_fd_ans and _fd_ans["choice"] in candidate_actions
and _fd_ans["confidence"] >= float(os.environ.get("DECISION_MIN_CONF", "0.7"))):
result = _fd.as_decision(_fd_ans, t_start)
result["strategic_advice"] = advice
if self.config.log_decisions:
self._log_decision(snapshot, result, player_index)
return result
if not self._connected:
return self._fallback_action(snapshot, t_start, "not_connected")
@@ -595,7 +615,10 @@ class DeepSeekBrain:
try:
result = self._call_llm(snapshot)
result["source"] = "llm"
if _fd_mode == "shadow": # FARMHOUSE_DECISION_SHADOW
_fd.record(_fd.ask(snapshot, actions=candidate_actions), result.get("action"))
result["latency_ms"] = round((time.time() - t_start) * 1000, 1)
result["strategic_advice"] = advice
if self.config.cache_enabled:
self._put_cache(cache_key, result)
@@ -882,4 +905,5 @@ class DeepSeekBrain:
"api_base": self.config.api_base,
"pending_log_entries": len(self._decision_log),
"current_game_id": self._current_game_id,
"decision": __import__("commander_ai_lab.sim.farmhouse_decide", fromlist=["stats"]).stats(), # FARMHOUSE_DECISION_STATS
}
@@ -0,0 +1,158 @@
"""A fast "which action?" answer from a decision model (Laya, or Kev: both serve POST /v1/systemone).
The LLM brain answers the same question about 25 times a game, each taking seconds. A decision model reads a short text and picks ONE of
a fixed list of options in a single forward pass (tens of milliseconds on a GPU, about a second on a small CPU) and never writes text.
It cannot choose a target card; the engine's heuristics do that, exactly as when the LLM names no target.
Modes (DECISION_MODE; with no DECISION_URL nothing here runs at all):
off do nothing
shadow ask it too and RECORD whether it agreed with the LLM; the game is played with the LLM's answer (the safe way to measure)
assist ask it first; use its answer when it is at least DECISION_MIN_CONF sure, otherwise ask the LLM as before
Settings: DECISION_URL (e.g. http://laya:8080), DECISION_MODEL (a Laya checkpoint name such as english or typed-decisions),
DECISION_MIN_CONF (default 0.7, on the probability of the chosen option), DECISION_TIMEOUT (seconds, default 5), DECISION_API_KEY.
After three failures in a row the model is not asked again for a minute, so a down server costs the game nothing.
"""
import json
import os
import threading
import time
import urllib.request
ACTION_HELP = {
"play_land": "play a land from hand (always do this first)",
"cast_creature": "cast a creature spell",
"cast_removal": "use removal on the opponent's best threat",
"cast_board_wipe": "wipe the board when behind on creatures",
"cast_ramp": "cast mana ramp to accelerate",
"cast_spell": "cast any other spell such as an enchantment or artifact",
"attack_all": "attack with all untapped creatures",
"attack_safe": "attack only with evasive or large creatures",
"hold": "pass and save mana",
}
_lock = threading.Lock()
_fail = {"n": 0, "until": 0.0}
STATS = {"asked": 0, "agreed": 0, "answered_conf": 0, "errors": 0, "ms_total": 0.0, "by_action": {}}
def mode():
m = os.environ.get("DECISION_MODE", "off").strip().lower()
return m if m in ("shadow", "assist") and os.environ.get("DECISION_URL") else "off"
def _card_text(c):
if isinstance(c, str):
return c
bits = [str(c.get("type") or "").strip()]
if c.get("cmc") is not None:
bits.append("cmc %s" % c["cmc"])
if c.get("pt"):
bits.append(str(c["pt"]))
if c.get("castable") is not None:
bits.append("castable" if c["castable"] else "not castable")
roles = c.get("roles")
if roles:
bits.append("/".join(roles) if isinstance(roles, (list, tuple)) else str(roles))
return "%s (%s)" % (c.get("name", "?"), ", ".join(b for b in bits if b))
def compact_state(snap):
"""The snapshot as a short paragraph (a decision model reads about 500 tokens, the LLM's indented JSON is two to three times that)."""
hand = snap.get("my_hand") or []
mine = [c for c in (snap.get("my_board") or []) if isinstance(c, dict)]
theirs = [c for c in (snap.get("opp_board") or []) if isinstance(c, dict)]
my_creatures = [c for c in mine if "creature" in str(c.get("type", "")).lower()]
opp_creatures = [c for c in theirs if "creature" in str(c.get("type", "")).lower()]
threat = snap.get("opp_threat") or {}
cmdr = snap.get("commander") or {}
parts = ["Turn %s. Your life %s, opponent life %s. Mana available %s." % (
snap.get("turn"), snap.get("my_life"), snap.get("opp_life"), snap.get("my_mana_total", snap.get("my_mana_available")))]
parts.append("Opponent threat %s, total power %s." % (threat.get("level", "?"), threat.get("total_power", "?")))
if cmdr:
parts.append("Your commander %s is in the %s." % (cmdr.get("name", "?"), cmdr.get("zone", "?")))
parts.append("Hand: %s." % ("; ".join(_card_text(c) for c in hand[:10]) if hand else "empty"))
parts.append("Your creatures: %s." % (", ".join(_card_text(c) for c in my_creatures[:8]) if my_creatures else "none"))
parts.append("Your other permanents: %d." % (len(mine) - len(my_creatures)))
parts.append("Opponent creatures: %s." % (", ".join(_card_text(c) for c in opp_creatures[:8]) if opp_creatures else "none"))
parts.append("Opponent other permanents: %d." % (len(theirs) - len(opp_creatures)))
advice = snap.get("strategic_advice") or {}
ranking = (advice.get("ranking") or {}).get("ranked") or []
if ranking:
parts.append("CSIE relative ranking: %s." % "; ".join(
"%s %.2f (%s)" % (r["id"], r["score"], r["explanation"])
for r in ranking[:5]))
if snap.get("stack_supported") and snap.get("stack"):
parts.append("Visible stack: %s." % json.dumps(snap["stack"], separators=(",", ":")))
return " ".join(parts)
def ask(snap, actions=None):
"""{'choice', 'confidence', 'probabilities', 'ms', 'model'} or None (not configured, backing off, or an error)."""
url = os.environ.get("DECISION_URL")
if not url:
return None
now = time.time()
with _lock:
if _fail["until"] > now:
return None
acts = [a for a in (actions or ACTION_HELP) if a in ACTION_HELP]
body = {"state": {"body": compact_state(snap)},
"questions": {"action": {"type": "choice", "instructions": "Which action is best for this turn?",
"criteria": {a: ACTION_HELP[a] for a in acts}}}}
model = os.environ.get("DECISION_MODEL")
if model:
body["model"] = model
headers = {"content-type": "application/json"}
if os.environ.get("DECISION_API_KEY"):
headers["authorization"] = "Bearer " + os.environ["DECISION_API_KEY"]
t0 = time.time()
try:
req = urllib.request.Request(url.rstrip("/") + "/v1/systemone", data=json.dumps(body).encode(), headers=headers)
out = json.load(urllib.request.urlopen(req, timeout=float(os.environ.get("DECISION_TIMEOUT", "5"))))
ans = out["answers"]["action"]
probs = ans.get("probabilities") or {}
choice = ans["choice"]
if choice not in acts:
raise ValueError("Decision model selected an action outside the supplied candidates")
with _lock:
_fail["n"] = 0
return {"choice": choice, "confidence": float(probs.get(choice, ans.get("answer_confidence", 0.0))),
"probabilities": probs, "ms": round((time.time() - t0) * 1000, 1), "model": model or "auto"}
except Exception:
with _lock:
_fail["n"] += 1
STATS["errors"] += 1
if _fail["n"] >= 3:
_fail["until"] = time.time() + 60
_fail["n"] = 0
return None
def as_decision(answer, t_start):
"""The brain's own result dict for a decision model's answer (no target card: the engine's heuristics pick it)."""
return {"action": answer["choice"], "target_card": "", "source": "decision",
"reasoning": "decision model %s, p=%.2f" % (answer["model"], answer["confidence"]),
"latency_ms": round((time.time() - t_start) * 1000, 1)}
def record(answer, llm_action):
"""Count one shadow comparison."""
if not answer:
return
with _lock:
STATS["asked"] += 1
STATS["ms_total"] += answer["ms"]
agree = answer["choice"] == llm_action
STATS["agreed"] += 1 if agree else 0
if answer["confidence"] >= float(os.environ.get("DECISION_MIN_CONF", "0.7")):
STATS["answered_conf"] += 1
row = STATS["by_action"].setdefault(llm_action, {"n": 0, "agreed": 0})
row["n"] += 1
row["agreed"] += 1 if agree else 0
def stats():
with _lock:
n = STATS["asked"]
return dict(STATS, agreement=round(STATS["agreed"] / n, 3) if n else None, avg_ms=round(STATS["ms_total"] / n, 1) if n else None)
+111
View File
@@ -0,0 +1,111 @@
"""Run: python -I -X utf8 tests/farmhouse/test_farmhouse_decide.py (offline: a stub /v1/systemone server on localhost)"""
import json
import os
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _load import load # noqa: E402
D = load("farmhouse_decide", "src/commander_ai_lab/sim/farmhouse_decide.py")
FAILS = 0
def ck(name, cond, detail=""):
global FAILS
print(" %s %s%s" % ("ok " if cond else "FAIL", name, (" -- " + str(detail)[:200]) if detail and not cond else ""))
if not cond:
FAILS += 1
SEEN = []
BEHAVIOUR = {"mode": "ok"}
class Stub(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)))
SEEN.append((self.path, body, self.headers.get("authorization")))
if BEHAVIOUR["mode"] == "down":
self.send_response(500)
self.end_headers()
return
crit = list(body["questions"]["action"]["criteria"])
pick = "cast_creature" if "cast_creature" in crit else crit[0]
probs = {k: (0.8 if k == pick else 0.2 / max(len(crit) - 1, 1)) for k in crit}
out = json.dumps({"answers": {"action": {"type": "choice", "choice": pick, "probabilities": probs, "confidence": 0.5}}}).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(out)))
self.end_headers()
self.wfile.write(out)
srv = HTTPServer(("127.0.0.1", 0), Stub)
threading.Thread(target=srv.serve_forever, daemon=True).start()
URL = "http://127.0.0.1:%d" % srv.server_port
SNAP = {"turn": 4, "my_life": 31, "opp_life": 20, "my_mana_total": 4,
"my_hand": [{"name": "Forest", "type": "land", "cmc": 0, "castable": True},
{"name": "Llanowar Elves", "type": "creature", "cmc": 1, "castable": True, "roles": ["ramp"]}, "Sol Ring"],
"my_board": [{"name": "Bear", "type": "creature", "pt": "2/2"}, {"name": "Rock", "type": "artifact"}],
"opp_board": [{"name": "Wurm", "type": "creature", "pt": "6/6"}],
"opp_threat": {"level": "HIGH", "total_power": 6}, "commander": {"name": "Atraxa", "zone": "command zone"}}
# --- the text it reads
t = D.compact_state(SNAP)
ck("the text has the facts the choice depends on", all(x in t for x in ("Turn 4", "life 31", "Llanowar Elves", "Bear (creature, 2/2)", "Wurm", "HIGH")), t)
ck("short enough for a 512-token reader", len(D.compact_state(dict(SNAP, my_hand=[{"name": "Card %d" % i, "type": "creature", "cmc": 3} for i in range(40)]))) < 1900)
ck("an empty snapshot does not raise", isinstance(D.compact_state({}), str))
# --- modes
for k in ("DECISION_URL", "DECISION_MODE", "DECISION_MODEL", "DECISION_API_KEY"):
os.environ.pop(k, None)
ck("no URL: off, and ask() does nothing", D.mode() == "off" and D.ask(SNAP) is None and not SEEN)
os.environ["DECISION_URL"] = URL
ck("a URL alone is still off (the mode must be chosen)", D.mode() == "off")
os.environ["DECISION_MODE"] = "shadow"
ck("shadow", D.mode() == "shadow")
os.environ["DECISION_MODE"] = "assist"
ck("assist", D.mode() == "assist")
os.environ["DECISION_MODE"] = "bogus"
ck("an unknown mode is off", D.mode() == "off")
os.environ["DECISION_MODE"] = "shadow"
# --- asking
os.environ["DECISION_MODEL"] = "typed-decisions"
os.environ["DECISION_API_KEY"] = "k"
a = D.ask(SNAP, actions=["play_land", "cast_creature", "hold", "not_an_action"])
path, body, auth = SEEN[-1]
ck("it posts to /v1/systemone with the model, key and only the known actions",
path == "/v1/systemone" and body["model"] == "typed-decisions" and auth == "Bearer k"
and list(body["questions"]["action"]["criteria"]) == ["play_land", "cast_creature", "hold"], (path, body, auth))
ck("the answer carries the choice, its probability and timing", a and a["choice"] == "cast_creature" and abs(a["confidence"] - 0.8) < 1e-9 and a["ms"] >= 0, a)
d = D.as_decision(a, time.time())
ck("as_decision is a brain result with no target (the engine picks one)", d["action"] == "cast_creature" and d["target_card"] == "" and d["source"] == "decision", d)
# --- shadow bookkeeping
D.record(a, "cast_creature")
D.record(a, "hold")
D.record(None, "hold")
s = D.stats()
ck("agreement is counted over answered questions only", s["asked"] == 2 and s["agreed"] == 1 and s["agreement"] == 0.5 and s["answered_conf"] == 2, s)
ck("per-action tallies", s["by_action"]["hold"] == {"n": 1, "agreed": 0} and s["by_action"]["cast_creature"]["agreed"] == 1)
# --- a server that is down costs nothing after three failures
BEHAVIOUR["mode"] = "down"
n0 = len(SEEN)
res = [D.ask(SNAP) for _ in range(6)]
ck("errors give None, never raise", all(r is None for r in res))
ck("after three failures it stops asking for a while", len(SEEN) - n0 == 3, len(SEEN) - n0)
BEHAVIOUR["mode"] = "ok"
ck("still backing off even though the server is back", D.ask(SNAP) is None)
D._fail["until"] = 0
ck("and asks again once the pause ends", D.ask(SNAP) is not None)
print("\nFAILURES: %d" % FAILS)
sys.exit(1 if FAILS else 0)