feat(run): log every decision with the answers and the run outcome

`JEV_TRACE` alone cannot be joined to a decision: it records a time to the
second and the answers, with no run, step or action, so nothing in it can be
traced back to a run.

Wrap the client in `RecordingClient` so the last call's questions and answers
travel with the decision row they produced, and give each process a
`SESSION_ID` so rows from two sessions in the same second stay apart. Diff the
history directory before and after a session to attribute the run records it
produced.

What this supports is arm-level analysis: this decision belongs to this session,
and the session's outcome is the run file. It does NOT say whether an individual
answer was correct -- one run result attached to one step cannot label that
step. Per-decision accuracy needs replay, expert judgement, or ground truth the
code can verify on its own (lethal, legality, affordability).

A decision that asked nothing must carry no answers, so `client.last` is
cleared before each decide(); otherwise rows silently inherit the previous
step's answers.
This commit is contained in:
0xrsydn 2026-09-22 06:06:06 +07:00
commit 17cb02a147
2 changed files with 420 additions and 25 deletions

262
run.py
View file

@ -15,18 +15,153 @@ usage:
from __future__ import annotations
import argparse
import atexit
import json
import os
import pathlib
import sys
import time
import uuid
import brain
import facts as F
import sts2
from jev import JevClient, JevError
from jev import JevClient, JevError, answer_record
DECK_FILE = pathlib.Path("deck.json")
# Where the game writes its own run records. This is the OUTCOME source: it
# carries win/loss, the killer, the seed and the final deck. Override with
# STS2_HISTORY_DIR when the steam id differs.
HISTORY_DIR = pathlib.Path(os.environ.get(
"STS2_HISTORY_DIR",
"~/Library/Application Support/SlayTheSpire2/steam/"
"76561198141226155/modded/profile1/saves/history")).expanduser()
# One id per process, written into every trace row. A decision row and the model
# answers that produced it must be joinable, and a bare wall-clock time is not
# an identifier: JEV_TRACE timestamps only to the second, so several calls share
# a timestamp and nothing in it names the run, step or action. The random suffix
# keeps two processes started in the same second apart.
SESSION_ID = f"{time.strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:6]}"
class RecordingClient:
"""
Wraps a JevClient so the last call's questions and answers travel with the
decision row they produced.
This makes the log ATTRIBUTABLE. `JEV_TRACE` alone cannot be joined to a
decision -- it records a time to the second and the answers, with no run,
step or action -- so nothing in it can be traced back to a run.
What the join supports is arm-level analysis: this decision belongs to this
session, and the session's outcome is the run file, which is what an A/B or
a delayed-reward model needs. It does NOT say whether an individual answer
was correct. One run result attached to one step cannot label that step;
per-decision accuracy needs replay, expert judgement, or a ground truth the
code can verify on its own (lethal, legality, affordability).
"""
def __init__(self, inner: JevClient) -> None:
self._inner = inner
self.last: dict | None = None
@property
def model(self) -> str:
return self._inner.model
def __repr__(self) -> str: # keep the key redacted
return f"RecordingClient({self._inner!r})"
def ask(self, state, questions, model=None):
response = self._inner.ask(state, questions, model)
self.last = {
"model": response.model,
"latency_s": round(response.latency_s, 3),
"questions": questions,
"answers": {qid: answer_record(a)
for qid, a in response.answers.items()},
}
return response
def history_snapshot() -> set[str]:
"""Run-record filenames present right now. Diffed to attribute a session."""
try:
return {p.name for p in HISTORY_DIR.iterdir() if p.suffix == ".run"}
except OSError:
return set()
def run_outcome(name: str) -> dict:
"""The outcome fields of one game run record, or a stub if it is unreadable."""
try:
data = json.loads((HISTORY_DIR / name).read_text())
except (OSError, json.JSONDecodeError):
return {"file": name}
players = data.get("players") or [{}]
points = data.get("map_point_history") or []
return {
"file": name,
"win": data.get("win"),
"killed_by": str(data.get("killed_by_encounter") or "").replace(
"ENCOUNTER.", ""),
"seed": data.get("seed"),
"deck_size": len(players[0].get("deck") or []),
"map_points": sum(len(a) for a in points if isinstance(a, list)),
"run_time_s": data.get("run_time"),
}
def session_record(session_id: str, started_at: str, ended_at: str, steps: int,
sources: dict, produced) -> dict:
"""
session id -> the run record(s) that session produced.
Nothing else writes this mapping: the game's history file does not know our
session id, and the A/B harness only maps a policy to a filename. Without it
a decision cannot be attributed to the run it belongs to, so no arm-level
outcome analysis (A/B, delayed reward) is possible.
It is NOT a per-decision accuracy label. One run result attached to a step
says nothing about whether that step's answer was correct; calibrating a
gate needs labels for the individual answers.
"""
return {
"session": session_id,
"started": started_at,
"ended": ended_at,
"steps": steps,
"sources": sources,
"runs": [run_outcome(name) for name in sorted(produced)],
}
def decision_record(step: int, state_type: str, run_state, decision,
error: str | None, client) -> dict:
"""
One JSONL row per decided action, carrying the model's answers with it.
`jev` is None for a decision that did not ask the model (code paths and
fallbacks), so an absent entry is a fact about the decision, not a gap.
"""
return {
"session": SESSION_ID,
"step": step,
"state_type": state_type,
"run": run_state,
"event": "decide",
"source": decision.source,
"action": decision.action,
"params": decision.params,
"reason": decision.reason,
"confidence": decision.confidence,
"error": error,
"jev": getattr(client, "last", None),
}
# Known starting decks, used only until the first combat exposes the real one.
# Source: the character_select state, which lists starting_deck per character.
STARTING_DECKS: dict[str, dict[str, int]] = {
@ -58,11 +193,24 @@ def preflight(obs: dict) -> str | None:
Detect states the bot cannot proceed from, so a session does not silently
burn its whole step budget doing nothing.
The one that actually bit: after a run ends with a pending Timeline epoch,
the main menu offers only `settings` and `quit`, and the mod REFUSES to
automate the reveal. A whole A/B arm ran with 0 decisions before this was
noticed.
Two that actually bit:
* A pending Timeline epoch leaves the main menu with only settings/quit,
and the mod REFUSES to automate the reveal. A whole A/B arm ran with 0
decisions before this was noticed.
* A parked `game_over` screen blocks every later session. Dismiss it.
"""
if obs.get("state_type") == "game_over":
try:
sts2.act("menu_select", option="main_menu")
# The dismissal is not instant. Without this wait the loop reads the
# state again, still sees game_over, and stops the session at step 1
# -- measured, one whole session made 0 decisions.
time.sleep(1.5)
print("dismissed a parked game-over screen")
except sts2.Sts2Error as exc:
return f"BLOCKED: parked on game_over and could not dismiss it: {exc}"
return None
if obs.get("state_type") != "menu" or obs.get("menu_screen") != "main":
return None
@ -98,6 +246,15 @@ def main() -> int:
ap.add_argument("--max-duplicate-waits", type=int, default=3,
help="how many times to suppress an identical repeated action "
"on an unchanged state before trying it again")
ap.add_argument("--max-jev-errors", type=int, default=8,
help="abort the session after this many consecutive model "
"failures, so a network outage does not silently "
"produce heuristic-only data")
ap.add_argument("--stop-on-run-end", action="store_true",
help="end the session when the run ends instead of starting "
"a fresh one. REQUIRED for A/B work: without it a "
"session can contain several runs and the results "
"cannot be attributed to an arm.")
ap.add_argument("--capture-dir", default="capture")
ap.add_argument("--card-skip-policy", choices=("jev", "combined"), default=None,
help="how card-reward skips are decided (default: brain's own)")
@ -121,11 +278,13 @@ def main() -> int:
if args.card_skip_policy:
brain.CARD_SKIP_POLICY = args.card_skip_policy
print(f"card skip policy: {brain.CARD_SKIP_POLICY}")
# Grepped by ab_card_skip.sh to stamp its attribution rows with the same id.
print(f"session: {SESSION_ID}")
client = None
if not args.no_jev:
try:
client = JevClient()
client = RecordingClient(JevClient())
print(f"jev ready: {client!r}")
except JevError as exc:
print(f"jev unavailable, using heuristics only: {exc}")
@ -138,6 +297,7 @@ def main() -> int:
# One JSON line per decided action. Tail it while the bot plays:
# tail -f capture/decisions.jsonl | jq
record["ts"] = time.strftime("%H:%M:%S")
record.setdefault("session", SESSION_ID)
with trace_path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(record, sort_keys=True, default=str) + "\n")
@ -146,6 +306,25 @@ def main() -> int:
jev_tokens = 0
started = time.monotonic()
# The outcome half of the join: which run record(s) this session produced.
# Registered with atexit so EVERY exit path writes it -- the normal end, the
# step cap, a stuck screen, a model-failure abort, or an exception.
history_before = history_snapshot()
step = 0
started_at = time.strftime("%Y-%m-%dT%H:%M:%S")
def write_session_row() -> None:
try:
row = session_record(SESSION_ID, started_at,
time.strftime("%Y-%m-%dT%H:%M:%S"), step, stats,
history_snapshot() - history_before)
with (capdir / "sessions.jsonl").open("a", encoding="utf-8") as fh:
fh.write(json.dumps(row, sort_keys=True, default=str) + "\n")
except OSError:
pass # bookkeeping must never break the exit
atexit.register(write_session_row)
# The card_reward state does not expose the deck, but combat states expose
# all four piles. Snapshot composition during combat and persist it so it
# survives a restart.
@ -160,6 +339,8 @@ def main() -> int:
last_ok = True
last_action_key: tuple | None = None
duplicate_waits = 0
jev_errors = 0
saw_a_run = False
for step in range(1, args.steps + 1):
try:
@ -170,6 +351,32 @@ def main() -> int:
st = obs.get("state_type")
# One session = one run, when asked. Otherwise the bot dies, returns to
# the menu and starts a fresh run inside the same session, so a single
# session yields several run records and nothing can be attributed to
# an experimental arm.
#
# CRITICAL: dismiss the game-over screen BEFORE stopping. Breaking
# first left the game parked on `game_over`, so every later session saw
# it at step 1 and stopped instantly -- the whole A/B produced nothing.
if args.stop_on_run_end and st == "game_over":
print(f"[{step:03d}] run ended; dismissing game-over, then stopping")
try:
sts2.act("menu_select", option="main_menu")
except sts2.Sts2Error as exc:
print(f"[{step:03d}] could not dismiss game-over: {exc}")
time.sleep(args.pause)
break
if args.stop_on_run_end and st in ("monster", "elite", "boss", "map",
"rewards", "card_reward", "event",
"rest_site", "shop", "treasure",
"card_select", "hand_select"):
saw_a_run = True
if args.stop_on_run_end and saw_a_run and st == "menu" and \
obs.get("menu_screen") == "main":
print(f"[{step:03d}] back at the main menu; run is over, stopping session")
break
# Guard against re-acting while the game is still animating a transition.
# An identical state after our own action means the action has not landed
# yet; acting again queues duplicates (e.g. three map moves in a row).
@ -204,26 +411,42 @@ def main() -> int:
print(f"[{step:03d}] COMBAT {f.describe().splitlines()[0]}")
for line in f.describe().splitlines()[1:]:
print(f" {line}")
if f.deck_counts and f.deck_counts != deck_snapshot:
deck_snapshot = f.deck_counts
# The snapshot carries the name->count map (card identities, which
# are the synergy signal) plus stable all-pile aggregates. Only the
# counts decide whether the deck actually changed.
if f.deck_counts and f.deck_counts != (deck_snapshot or {}).get("counts"):
deck_snapshot = {"counts": f.deck_counts, "summary": f.deck_summary}
save_deck(deck_snapshot)
decision = None
decide_error = None
if isinstance(client, RecordingClient):
# Cleared first: a decision that asks nothing (code paths, fallbacks)
# must not inherit the previous step's answers in the log.
client.last = None
try:
decision = brain.decide(obs, client, deck_snapshot)
jev_errors = 0
except JevError as exc:
print(f"[{step:03d}] jev error: {str(exc)[:160]}")
jev_errors += 1
print(f"[{step:03d}] jev error ({jev_errors}): {str(exc)[:140]}")
decide_error = f"JevError: {str(exc)[:160]}"
decision = brain.simple_decision(obs, client, deck_snapshot)
if jev_errors >= args.max_jev_errors:
print(f"[{step:03d}] ABORT: {jev_errors} consecutive model failures. "
f"The run would continue on heuristics alone, which is not "
f"the data we want. Retry this session.")
return 3
# Fall back WITHOUT the model. Passing the client again just retries
# the same failing request -- measured, a DNS blip re-raised out of
# the "fallback" and killed the session.
decision = brain.simple_decision(obs, None, deck_snapshot)
except Exception as exc: # noqa: BLE001
# A network blip or an unexpected shape must not end the run. Fall
# back to the deterministic handlers and keep playing.
# A network blip or an unexpected shape must not end the run.
print(f"[{step:03d}] unexpected error in decide(): "
f"{type(exc).__name__}: {str(exc)[:160]}")
decide_error = f"{type(exc).__name__}: {str(exc)[:160]}"
try:
decision = brain.simple_decision(obs, client, deck_snapshot)
decision = brain.simple_decision(obs, None, deck_snapshot)
except Exception as inner: # noqa: BLE001
print(f"[{step:03d}] fallback also failed: {inner}")
decision = None
@ -265,18 +488,7 @@ def main() -> int:
stats[decision.source] = stats.get(decision.source, 0) + 1
print(f"[{step:03d}] DECIDE {decision}")
trace({
"step": step,
"state_type": st,
"run": obs.get("run"),
"event": "decide",
"source": decision.source,
"action": decision.action,
"params": decision.params,
"reason": decision.reason,
"confidence": decision.confidence,
"error": decide_error,
})
trace(decision_record(step, st, obs.get("run"), decision, decide_error, client))
last_action_key = action_key
if args.dry_run: