feat(recording): link session observations proposals and action results

Capture all successful state reads with hashes and session-local IDs. Record action intent before POST, retain accepted/rejected/unknown results, and link subsequent observations. Preserve legacy feeds and finalize each invocation synchronously.
This commit is contained in:
0xrsydn 2026-09-22 15:26:59 +07:00
commit 9696282110
10 changed files with 476 additions and 123 deletions

2
.gitignore vendored
View file

@ -8,4 +8,6 @@ deck.json
# Run artifacts written by run.py / JEV_TRACE during live sessions # Run artifacts written by run.py / JEV_TRACE during live sessions
capture/live_*.json capture/live_*.json
capture/decisions.jsonl capture/decisions.jsonl
capture/sessions.jsonl
capture/sessions/
capture/jev*.jsonl capture/jev*.jsonl

View file

@ -5,7 +5,8 @@
- `brain.py`: policy entry point; dispatch, navigation, shops, and minigames. - `brain.py`: policy entry point; dispatch, navigation, shops, and minigames.
- `policy/combat.py` and `policy/selection.py`: combat and selection proposals. - `policy/combat.py` and `policy/selection.py`: combat and selection proposals.
- `policy/context.py`: shared `Decision`, pending actions, and session-owned `PolicyContext`. - `policy/context.py`: shared `Decision`, pending actions, and session-owned `PolicyContext`.
- `run.py`: observedecideact loop, captures, and session attribution. - `run.py`: observedecideact loop and session attribution.
- `recording.py`: linked session journals; schema and limits in `docs/RECORDING.md`.
- `run_state.py`: reported run identity and combat-pile provenance; see `docs/RUN_STATE.md`. - `run_state.py`: reported run identity and combat-pile provenance; see `docs/RUN_STATE.md`.
- `sts2.py`: local game HTTP client. `jev.py`: TypeSafe model client and gates. - `sts2.py`: local game HTTP client. `jev.py`: TypeSafe model client and gates.
- `migrate.py`: dataset migration and integrity checks. - `migrate.py`: dataset migration and integrity checks.
@ -31,6 +32,7 @@ Prefer integration/end-to-end checks; keep only essential regression tests and u
- Keep policy memory session-owned. Proposals do not record execution; reconcile accepted requests with fresh observations. - Keep policy memory session-owned. Proposals do not record execution; reconcile accepted requests with fresh observations.
- Compute arithmetic and legality in code, not in Jev. Confidence does not prove correctness. - Compute arithmetic and legality in code, not in Jev. Confidence does not prove correctness.
- Keep deterministic fallbacks usable with `client=None`. Test model paths with stubs. - Keep deterministic fallbacks usable with `client=None`. Test model paths with stubs.
- Preserve observation/proposal/attempt links. Flush attempt intent before POST; acceptance is not proof of a game effect.
- Preserve session/step attribution. Run outcomes are not per-decision correctness labels. - Preserve session/step attribution. Run outcomes are not per-decision correctness labels.
- Split evaluation data by run, not by decision row. - Split evaluation data by run, not by decision row.

View file

@ -25,6 +25,12 @@ _Avoid_: Guaranteed result, proven lethal
**Decision**: **Decision**:
A proposed next action. A decision does not establish that the game received or applied the action. A proposed next action. A decision does not establish that the game received or applied the action.
**Action attempt**:
One intended submission of a proposed game action. Its record alone does not prove that the game received the request.
**Subsequent observation**:
The first successfully read game observation after an action attempt. It does not prove that the attempt caused a change.
**Action result**: **Action result**:
The game's response to an attempted action. Acceptance alone does not establish that the expected change occurred. The game's response to an attempted action. Acceptance alone does not establish that the expected change occurred.

View file

@ -3,7 +3,8 @@
## Current boundaries ## Current boundaries
```text ```text
run.py observe, execute, report results, record the session run.py observe, execute, report results
recording.py linked observations, proposals, attempts, and results
run_state.py reported run identity and scoped combat-pile evidence run_state.py reported run identity and scoped combat-pile evidence
brain.py entry point, dispatch, navigation, shops, minigames brain.py entry point, dispatch, navigation, shops, minigames
policy/ policy/
@ -51,6 +52,7 @@ shops, and minigames stay together until a further split helps development.
The extraction does not change game decisions, confidence gates, fallbacks, The extraction does not change game decisions, confidence gates, fallbacks,
state reconciliation, or recording. The subsequent state reconciliation, or recording. The subsequent
[run-state pass](RUN_STATE.md) adds reported identity and card-evidence provenance. [run-state pass](RUN_STATE.md) adds reported identity and card-evidence provenance.
[Linked recordings](RECORDING.md) provide the corresponding session journal.
## Extraction verification ## Extraction verification

154
docs/RECORDING.md Normal file
View file

@ -0,0 +1,154 @@
# Linked session recordings
## Files and ownership
`recording.SessionRecorder` owns one recording per `run.main()` invocation.
It has no game or model client. It records and invokes an action callback supplied
by the runner. Policy execution remains in the runner.
```text
capture/
decisions.jsonl compatibility feed
sessions.jsonl compatibility session summaries
sessions/<session-id>/
events.jsonl authoritative, ordered journal
session.json finalized session summary
observations/000001.json full parsed observation
observations/000002.json
...
```
The session ID combines a timestamp with a random UUID. It is generated inside
each invocation, not once per Python process. Session directories and observation
files are created exclusively. Later sessions cannot overwrite earlier captures.
The default session artifacts are ignored by version control.
All successful state reads are captured, including preflight, menus, overlays,
selection screens, rewards, and terminal screens. Identical observations remain
separate reads. The old `live_<step>_combat.json` files are no longer written.
Observation files contain the unmodified parsed state dictionary. They are not
copies of HTTP headers or the original wire encoding. Each observation event
contains the relative path and SHA-256 hash of the stored bytes. Paths are
relative to the configured capture root.
## Journal schema, version 1
Each journal row includes `schema_version`, `session`, `sequence`, `recorded_at`
(UTC), and the legacy local-time `ts` display field. Sequence orders events
within one session. It is not an ordering across processes.
| Event | Purpose and links |
| --- | --- |
| `session_start` | Establish the session before preflight or model initialization. |
| `observation` | Full state reference: `observation_id`, `path`, `sha256`, step, phase, and state type. |
| `proposal` | Policy/system decision, linked by `proposal_id` and `observation_id`. Includes action, parameters, reason, source, and available model evidence. |
| `proposal_status` | Disposition of a proposal: `wait`, `suppressed`, `dry_run`, `execute`, or `session_stopped`. |
| `action_attempt` | Intent to submit one request, linked to its proposal and input observation. Has a distinct `attempt_id`. |
| `action_result` | Response to an attempt: `accepted`, `rejected`, or `unknown`. Response/message or transport error is retained. |
| `session_end` | Final exit code, reason, outcome-file candidates, and any attempt still lacking a subsequent observation. |
Existing diagnostic events, such as `model_error`, `action_error`, and identity
changes, also appear in the journal. `observation_error` records a failed state
read without inventing an observation. Its optional observation reference is the
last successful read, not a payload for the failed request.
### Join direction
```text
observation.observation_id
<- proposal.observation_id
<- action_attempt.proposal_id
<- action_result.attempt_id
<- subsequent observation.after_attempt_id
```
The first successful read after an attempt carries `after_attempt_id`. Later
reads do not reuse that field until another attempt occurs. A terminal dismissal
may have no subsequent read. The session summary explicitly preserves that
unresolved link in `awaiting_observation_after_attempt`.
The subsequent observation is temporal evidence, not proof of causation or
completion. A transition overlay can be that first read while policy memory
continues waiting. The journal does not invent a semantic success label.
Rejected requests are attempts too. Waits, suppressed duplicates, and dry-run
previews have proposals and dispositions, but no attempt or action result.
Dry-run proposals do not update execution-based duplicate suppression.
Preflight dismissal and stop-on-run-end dismissal use the same recording path
as policy actions. Their observations, proposals, attempts, and results are linked.
## Model evidence
Successful model calls retain the request state, questions, and parsed answers
with the proposal. Failed model calls retain the request with `model_error`.
This is not a raw HTTP transcript or a ledger of the model client's internal retries.
`answer_gate_scope: default_helper_not_policy_gate` labels the existing
per-answer `gated` field. That value does not establish the actual gate used by
a particular policy handler. Actual policy-gate metadata remains separate work.
No correctness label is inferred from confidence, action acceptance, or a run outcome.
Reported run IDs and card provenance remain subject to the limits in
[run-state documentation](RUN_STATE.md). The recorder does not strengthen save-derived
identity into an atomic observation identity.
## Durability and failure behavior
Observation files are flushed before their references are used. The journal's
action intent is flushed before the runner calls the game transport. A recording
failure before that point prevents the POST.
A transport failure has an `unknown` result and is not retried automatically.
If recording fails after a POST, its effect can remain unknown. A missing result
must never be interpreted as rejection or permission to retry.
Finalization runs synchronously when the session scope exits. It covers normal
returns, startup failures, handled runtime failures, Python exceptions, and
keyboard interruption. There is no process-global session ID or `atexit` callback.
SIGTERM, SIGKILL, power failure, and storage failure can still leave an incomplete
journal or no summary. An intent can exist even if the process died before the
POST began. A file flush is not a transactional guarantee across the journal,
observation files, and game service. Readers must treat incomplete records and
truncated final lines as incomplete evidence, not as successful execution.
## Compatibility and dataset boundaries
The root `decisions.jsonl` retains `event: decide` rows for unsuppressed actions
and previews. These carry the same IDs as their canonical proposals. Wait and
suppressed proposals appear only in the session journal. Do not count both a
`proposal` and its compatibility `decide` row as separate policy decisions.
The root session feed retains the outcome-file list and points to its journal
through `recording_path`. The historical outcome association remains a filename
set difference; it is not proof that every new game history file belongs to this bot.
An attribution failure is recorded as `outcome_error` without replacing the session's exit status.
Compatibility feeds are not transactional or coordinated across writers. Use
per-session journals for reliable joins when multiple processes share a capture root.
Full observations and file flushes add storage and I/O cost intentionally.
Existing captures and `dataset/` are unchanged. The current dataset migrator
still reads historical inputs and flat captures; it does not import these new
journals automatically. A journal importer and semantic reconciliation labels
can be added separately without rebuilding data during development.
## Validation
- The backend-only recording bookmark passed 294 assertions: 68 facts, 110 policy,
and 116 runner assertions. With the separate upgrade-selection fix applied,
the integrated workspace passed 296 assertions (118 runner assertions).
- Runner flows verify hashes, unchanged observation contents, all join keys,
preflight/terminal actions, rejection, unknown transport outcomes, waits,
duplicate suppression, repeated invocations, and keyboard interruption.
- Twenty-five distinct temporary whole-process scenarios passed with real HTTP
clients and local fixture servers. The server checked that each action intent
existed on disk before it received the action POST.
- Injected journal failures before and after POST verify conservative stopping.
- All prior semantic code bookmarks passed their three Python test scripts.
- Dataset integrity, shell syntax, and offline snapshot replay passed.
No live game actions, paid model calls, new dependencies, or permanent standalone
unit-test files were needed.

View file

@ -97,8 +97,9 @@ Decision rows now include reported `run_id`, `run_seed`, and available
it does not mean every handler used it. Combat policy uses the combat observation directly. it does not mean every handler used it. Combat policy uses the combat observation directly.
Identity changes and identity transport failures have separate trace events. Identity changes and identity transport failures have separate trace events.
Full observation/action linkage, unique capture names, session identity, and [Linked session recordings](RECORDING.md) now provide observation/action linkage,
session finalization remain recording work. unique captures, per-invocation session identity, and synchronous finalization.
The save-derived identity and historical outcome-association limits still apply.
## Validation ## Validation

View file

@ -108,6 +108,6 @@ behavior. See [the current policy structure](../POLICY.md). No plugin framework
or class hierarchy was added. or class hierarchy was added.
[Run identity and card-evidence provenance](../RUN_STATE.md) have since been added. [Run identity and card-evidence provenance](../RUN_STATE.md) have since been added.
Recording should next link observations, proposals, action attempts, results, and reconciliation. [Linked recordings](../RECORDING.md) now connect observations, proposals, attempts,
Session finalization, capture-name collisions, and actual policy-gate metadata results, and subsequent reads, with unique captures and synchronous finalization.
remain separate work. Semantic reconciliation labels and actual policy-gate metadata remain separate work.

138
recording.py Normal file
View file

@ -0,0 +1,138 @@
"""Session-local journal for observations and runner-supplied action calls."""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import time
from datetime import datetime, timezone
import uuid
def timestamp() -> str:
return datetime.now(timezone.utc).isoformat()
class SessionRecorder:
"""A journal is authoritative; root JSONL files are compatibility feeds.
An attempt records intent before the POST. Neither acceptance nor a later
observation proves that the expected game effect occurred.
"""
def __init__(self, root: str | Path, *, dry_run: bool = False):
self.root = Path(root)
self.session_id = f"{time.strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex}"
self.directory = self.root / "sessions" / self.session_id
self.directory.mkdir(parents=True, exist_ok=False)
(self.directory / "observations").mkdir()
self.events = self.directory / "events.jsonl"
self.started = timestamp()
self.dry_run = dry_run
self.step = 0
self.sources = {"code": 0, "jev": 0, "fallback": 0}
self.exit_code = 1
self.stop_reason = "interrupted"
self.summary: dict = {}
self.sequence = self.observations = self.proposals = self.attempts = 0
self.observation_id: str | None = None
self.awaiting_observation: str | None = None
@staticmethod
def _append(path: Path, row: dict, *, durable: bool = False) -> None:
line = json.dumps(row, sort_keys=True, allow_nan=False) + "\n"
with path.open("a", encoding="utf-8") as stream:
stream.write(line)
if durable:
stream.flush()
os.fsync(stream.fileno())
def write(self, record: dict, *, mirror: bool = True, durable: bool = False) -> dict:
self.sequence += 1
row = {**record, "schema_version": 1, "session": self.session_id,
"sequence": self.sequence, "recorded_at": timestamp(),
"ts": time.strftime("%H:%M:%S")}
self._append(self.events, row, durable=durable)
if mirror:
self._append(self.root / "decisions.jsonl", row)
return row
def observe(self, observation: dict, *, step: int, phase: str) -> str:
self.step = step
self.observations += 1
observation_id = f"{self.session_id}:o{self.observations:06d}"
path = self.directory / "observations" / f"{self.observations:06d}.json"
data = (json.dumps(observation, sort_keys=True, allow_nan=False) + "\n").encode("utf-8")
with path.open("xb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
self.observation_id = observation_id
self.write({"event": "observation", "observation_id": observation_id,
"step": step, "phase": phase, "state_type": observation.get("state_type"),
"path": str(path.relative_to(self.root)), "sha256": hashlib.sha256(data).hexdigest(),
"after_attempt_id": self.awaiting_observation}, mirror=False)
self.awaiting_observation = None
return observation_id
def propose(self, record: dict) -> str:
self.proposals += 1
proposal_id = f"{self.session_id}:p{self.proposals:06d}"
self.write({**record, "event": "proposal", "proposal_id": proposal_id,
"observation_id": self.observation_id}, mirror=False)
return proposal_id
def disposition(self, proposal_id: str, status: str) -> None:
self.write({"event": "proposal_status", "proposal_id": proposal_id,
"status": status, "step": self.step}, mirror=False)
def execute(self, proposal_id: str, decision, act):
"""Flush intent before invoking the transport. Never retry here."""
if self.dry_run:
raise RuntimeError("cannot execute actions in a dry-run recording")
self.attempts += 1
attempt_id = f"{self.session_id}:a{self.attempts:06d}"
self.disposition(proposal_id, "execute")
self.write({"event": "action_attempt", "attempt_id": attempt_id,
"proposal_id": proposal_id, "observation_id": self.observation_id,
"step": self.step, "action": decision.action, "params": decision.params},
mirror=False, durable=True)
self.awaiting_observation = attempt_id
try:
result = act(decision.action, **decision.params)
except BaseException as exc:
self.write({"event": "action_result", "attempt_id": attempt_id,
"step": self.step, "outcome": "unknown",
"error": f"{type(exc).__name__}: {str(exc)[:200]}"}, mirror=False)
raise
self.write({"event": "action_result", "attempt_id": attempt_id, "step": self.step,
"outcome": "accepted" if result.ok else "rejected",
"message": result.message, "response": getattr(result, "raw", None)}, mirror=False)
return result
def finish(self, code: int, reason: str) -> int:
self.exit_code, self.stop_reason = code, reason
return code
def __enter__(self):
self.write({"event": "session_start", "dry_run": self.dry_run}, mirror=False)
return self
def __exit__(self, exc_type, exc, traceback):
if exc is not None:
self.exit_code = 130 if isinstance(exc, KeyboardInterrupt) else 1
self.stop_reason = "interrupted" if isinstance(exc, KeyboardInterrupt) else "exception"
self.summary["error"] = f"{type(exc).__name__}: {str(exc)[:200]}"
row = {**self.summary, "schema_version": 1, "session": self.session_id,
"started": self.started, "ended": timestamp(), "steps": self.step,
"sources": self.sources, "dry_run": self.dry_run,
"exit_code": self.exit_code, "stop_reason": self.stop_reason,
"recording_path": str(self.events.relative_to(self.root)),
"awaiting_observation_after_attempt": self.awaiting_observation}
self.write({**row, "event": "session_end", "step": self.step, "reason": self.stop_reason}, durable=True)
with (self.directory / "session.json").open("x", encoding="utf-8") as stream:
json.dump(row, stream, sort_keys=True, allow_nan=False)
stream.write("\n")
self._append(self.root / "sessions.jsonl", row)
return False

201
run.py
View file

@ -15,20 +15,19 @@ usage:
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import atexit
import json import json
import math import math
import os import os
import pathlib import pathlib
import sys import sys
import time import time
import uuid
import brain import brain
import facts as F import facts as F
import sts2 import sts2
from jev import JevClient, JevError, answer_record from jev import JevClient, JevError, answer_record
from run_state import RunContext from run_state import RunContext
from recording import SessionRecorder
# Where the game writes its own run records. This is the OUTCOME source: it # 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 # carries win/loss, the killer, the seed and the final deck. Override with
@ -38,14 +37,6 @@ HISTORY_DIR = pathlib.Path(os.environ.get(
"~/Library/Application Support/SlayTheSpire2/steam/" "~/Library/Application Support/SlayTheSpire2/steam/"
"76561198141226155/modded/profile1/saves/history")).expanduser() "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: class RecordingClient:
""" """
Wraps a JevClient so the last call's questions and answers travel with the Wraps a JevClient so the last call's questions and answers travel with the
@ -66,6 +57,7 @@ class RecordingClient:
def __init__(self, inner: JevClient) -> None: def __init__(self, inner: JevClient) -> None:
self._inner = inner self._inner = inner
self.last: dict | None = None self.last: dict | None = None
self.last_request: dict | None = None
@property @property
def model(self) -> str: def model(self) -> str:
@ -75,13 +67,16 @@ class RecordingClient:
return f"RecordingClient({self._inner!r})" return f"RecordingClient({self._inner!r})"
def ask(self, state, questions, model=None): def ask(self, state, questions, model=None):
self.last_request = {"state": state, "questions": questions, "model": model or self.model}
response = self._inner.ask(state, questions, model) response = self._inner.ask(state, questions, model)
self.last = { self.last = {
"state": state,
"model": response.model, "model": response.model,
"latency_s": round(response.latency_s, 3), "latency_s": round(response.latency_s, 3),
"questions": questions, "questions": questions,
"answers": {qid: answer_record(a) "answers": {qid: answer_record(a)
for qid, a in response.answers.items()}, for qid, a in response.answers.items()},
"answer_gate_scope": "default_helper_not_policy_gate",
} }
return response return response
@ -114,40 +109,16 @@ def run_outcome(name: str) -> dict:
} }
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, def decision_record(step: int, state_type: str, run_state, decision,
error: str | None, client) -> dict: error: str | None, client, *, session_id: str) -> dict:
""" """
One JSONL row per decided action, carrying the model's answers with it. Shared payload for a proposal and its compatible `decide` feed row.
`jev` is None for a decision that did not ask the model (code paths and `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. fallbacks), so an absent entry is a fact about the decision, not a gap.
""" """
return { return {
"session": SESSION_ID, "session": session_id,
"step": step, "step": step,
"state_type": state_type, "state_type": state_type,
"run": run_state, "run": run_state,
@ -166,7 +137,7 @@ def observe() -> dict:
return sts2.state() return sts2.state()
def preflight(obs: dict, *, dry_run: bool = False) -> str | None: def preflight(obs: dict, *, dry_run: bool = False, act=None) -> str | None:
""" """
Detect states the bot cannot proceed from, so a session does not silently Detect states the bot cannot proceed from, so a session does not silently
burn its whole step budget doing nothing. burn its whole step budget doing nothing.
@ -181,7 +152,7 @@ def preflight(obs: dict, *, dry_run: bool = False) -> str | None:
if dry_run: if dry_run:
return None return None
try: try:
result = sts2.act("menu_select", option="main_menu") result = (act or sts2.act)("menu_select", option="main_menu")
if not result.ok: if not result.ok:
return f"BLOCKED: game-over dismissal rejected: {result.message}" return f"BLOCKED: game-over dismissal rejected: {result.message}"
# The dismissal is not instant. Without this wait the loop reads the # The dismissal is not instant. Without this wait the loop reads the
@ -250,26 +221,90 @@ def main() -> int:
if not math.isfinite(args.stuck_seconds) or args.stuck_seconds <= 0: if not math.isfinite(args.stuck_seconds) or args.stuck_seconds <= 0:
ap.error("stuck-seconds must be finite and positive") ap.error("stuck-seconds must be finite and positive")
try:
with SessionRecorder(args.capture_dir, dry_run=args.dry_run) as recording:
history_before = history_snapshot()
try:
return _run_session(args, recording)
finally:
try:
recording.summary["runs"] = [run_outcome(name) for name in sorted(history_snapshot() - history_before)]
except Exception as exc: # Outcome attribution must not mask the session's actual exit.
recording.summary.update(runs=[], outcome_error=f"{type(exc).__name__}: {str(exc)[:200]}")
except KeyboardInterrupt:
print("interrupted; session recording finalized")
return 130
except OSError as exc:
print(f"recording failed; stopping without another action: {exc}")
return 1
def _run_session(args, recording: SessionRecorder) -> int:
stats = recording.sources
started = time.monotonic()
step = waits = 0
run_context = RunContext()
# Grepped by ab_card_skip.sh for attribution. New for every main() call.
print(f"session: {recording.session_id}")
def trace(record: dict) -> None:
record.setdefault("observation_id", recording.observation_id)
recording.write(record)
def finish(code: int, reason: str) -> int:
elapsed = time.monotonic() - started
print(f"steps={step} waits={waits} elapsed={elapsed:.1f}s sources={stats} "
f"stop={reason} exit={code}")
return recording.finish(code, reason)
def read_observation(phase: str = "loop") -> dict:
observation = observe()
recording.observe(observation, step=step, phase=phase)
return observation
def propose(observation, decision, error=None, client=None):
record = decision_record(step, observation.get("state_type"), observation.get("run"),
decision, error, client, session_id=recording.session_id)
record.update(run_id=run_context.run_id, run_seed=run_context.seed,
deck_provenance=(run_context.deck or {}).get("provenance"),
observation_id=recording.observation_id)
proposal_id = recording.propose(record)
record["proposal_id"] = proposal_id
return record, proposal_id
def system_action(observation, action, params, reason):
decision = brain.Decision(action, params, reason, "code")
record, proposal_id = propose(observation, decision)
trace(record)
stats["code"] += 1
if args.dry_run:
recording.disposition(proposal_id, "dry_run")
return None
return recording.execute(proposal_id, decision, sts2.act)
if not sts2.is_up(): if not sts2.is_up():
print(f"game not reachable at {sts2.BASE}") print(f"game not reachable at {sts2.BASE}")
return 1 return finish(1, "startup_error")
# Fail fast on a state the bot cannot leave, instead of burning the whole # Fail fast on a state the bot cannot leave, instead of burning the whole
# step budget on rejected actions. # step budget on rejected actions.
try: try:
blocker = preflight(observe(), dry_run=args.dry_run) obs = read_observation("preflight")
if args.dry_run and obs.get("state_type") == "game_over":
system_action(obs, "menu_select", {"option": "main_menu"}, "preflight game-over dismissal")
blocker = preflight(obs, dry_run=args.dry_run,
act=lambda action, **params: system_action(obs, action, params, "preflight game-over dismissal"))
except sts2.Sts2Error as exc: except sts2.Sts2Error as exc:
trace({"event": "observation_error", "step": step, "phase": "preflight", "error": str(exc)[:160]})
print(f"cannot read state: {exc}") print(f"cannot read state: {exc}")
return 1 return finish(1, "startup_error")
if blocker: if blocker:
print(blocker) print(blocker)
return 2 return finish(2, "preflight_blocked")
if args.card_skip_policy: if args.card_skip_policy:
brain.CARD_SKIP_POLICY = args.card_skip_policy brain.CARD_SKIP_POLICY = args.card_skip_policy
print(f"card skip policy: {brain.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 client = None
if not args.no_jev: if not args.no_jev:
@ -278,58 +313,8 @@ def main() -> int:
print(f"jev ready: {client!r}") print(f"jev ready: {client!r}")
except JevError as exc: except JevError as exc:
print(f"jev unavailable: {exc}; use --no-jev for an intentional heuristic session") print(f"jev unavailable: {exc}; use --no-jev for an intentional heuristic session")
return 3 return finish(3, "model_initialization")
capdir = pathlib.Path(args.capture_dir)
capdir.mkdir(parents=True, exist_ok=True)
trace_path = capdir / "decisions.jsonl"
def trace(record: dict) -> None:
# 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")
stats = {"code": 0, "jev": 0, "fallback": 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
exit_code = 1
stop_reason = "interrupted"
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)
row.update(exit_code=exit_code, stop_reason=stop_reason, dry_run=args.dry_run)
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
def finish(code: int, reason: str) -> int:
nonlocal exit_code, stop_reason
exit_code, stop_reason = code, reason
trace({"event": "session_end", "step": step, "exit_code": code, "reason": reason})
elapsed = time.monotonic() - started
print(f"steps={step} waits={waits} elapsed={elapsed:.1f}s sources={stats} "
f"stop={reason} exit={code}")
return code
atexit.register(write_session_row)
# Never load the old unscoped deck.json. Visible combat piles are limited
# evidence, not a persistent deck, and remain local to this session/run.
run_context = RunContext()
waits = 0
rejected = 0 rejected = 0
last_sig: str | None = None last_sig: str | None = None
same_state = 0 same_state = 0
@ -343,9 +328,11 @@ def main() -> int:
pending_since = time.monotonic() pending_since = time.monotonic()
for step in range(1, args.steps + 1): for step in range(1, args.steps + 1):
recording.step = step
try: try:
obs = observe() obs = read_observation()
except sts2.Sts2Error as exc: except sts2.Sts2Error as exc:
trace({"event": "observation_error", "step": step, "phase": "loop", "error": str(exc)[:160]})
print(f"[{step:03d}] state read failed: {str(exc)[:160]}") print(f"[{step:03d}] state read failed: {str(exc)[:160]}")
return finish(1, "state_error") return finish(1, "state_error")
@ -384,10 +371,11 @@ def main() -> int:
if args.stop_on_run_end and st == "game_over": if args.stop_on_run_end and st == "game_over":
if args.dry_run: if args.dry_run:
print(f"[{step:03d}] dry run: would dismiss game-over") print(f"[{step:03d}] dry run: would dismiss game-over")
system_action(obs, "menu_select", {"option": "main_menu"}, "run-end dismissal")
return finish(0, "run_end_preview") return finish(0, "run_end_preview")
print(f"[{step:03d}] run ended; dismissing game-over, then stopping") print(f"[{step:03d}] run ended; dismissing game-over, then stopping")
try: try:
result = sts2.act("menu_select", option="main_menu") result = system_action(obs, "menu_select", {"option": "main_menu"}, "run-end dismissal")
except sts2.Sts2Error as exc: except sts2.Sts2Error as exc:
print(f"[{step:03d}] could not dismiss game-over: {exc}") print(f"[{step:03d}] could not dismiss game-over: {exc}")
return finish(1, "dismiss_error") return finish(1, "dismiss_error")
@ -437,7 +425,6 @@ def main() -> int:
if st in ("monster", "elite", "boss"): if st in ("monster", "elite", "boss"):
f = F.combat_facts(obs) f = F.combat_facts(obs)
(capdir / f"live_{step:03d}_combat.json").write_text(json.dumps(obs, indent=2))
print(f"[{step:03d}] COMBAT {f.describe().splitlines()[0]}") print(f"[{step:03d}] COMBAT {f.describe().splitlines()[0]}")
for line in f.describe().splitlines()[1:]: for line in f.describe().splitlines()[1:]:
print(f" {line}") print(f" {line}")
@ -449,6 +436,7 @@ def main() -> int:
# Cleared first: a decision that asks nothing (code paths, fallbacks) # Cleared first: a decision that asks nothing (code paths, fallbacks)
# must not inherit the previous step's answers in the log. # must not inherit the previous step's answers in the log.
client.last = None client.last = None
client.last_request = None
try: try:
decision = brain.decide(obs, client, run_context.deck, context=policy_context) decision = brain.decide(obs, client, run_context.deck, context=policy_context)
# A procedural action or animation wait does not establish model # A procedural action or animation wait does not establish model
@ -460,7 +448,7 @@ def main() -> int:
print(f"[{step:03d}] jev error ({jev_errors}): {str(exc)[:140]}") print(f"[{step:03d}] jev error ({jev_errors}): {str(exc)[:140]}")
decide_error = f"JevError: {str(exc)[:160]}" decide_error = f"JevError: {str(exc)[:160]}"
trace({"step": step, "event": "model_error", "error": decide_error, trace({"step": step, "event": "model_error", "error": decide_error,
"consecutive_failures": jev_errors}) "consecutive_failures": jev_errors, "request": getattr(client, "last_request", None)})
if jev_errors >= args.max_jev_errors: if jev_errors >= args.max_jev_errors:
print(f"[{step:03d}] ABORT: {jev_errors} consecutive model failures. " print(f"[{step:03d}] ABORT: {jev_errors} consecutive model failures. "
f"The run would continue on heuristics alone, which is not " f"The run would continue on heuristics alone, which is not "
@ -491,15 +479,19 @@ def main() -> int:
print(json.dumps(obs, indent=2)[:800]) print(json.dumps(obs, indent=2)[:800])
return finish(1, "no_decision") return finish(1, "no_decision")
record, proposal_id = propose(obs, decision, decide_error, client)
# Bound unresolved actions even if unrelated observation fields change. # Bound unresolved actions even if unrelated observation fields change.
if policy_context.pending is not pending_action: if policy_context.pending is not pending_action:
pending_action = policy_context.pending pending_action = policy_context.pending
pending_since = time.monotonic() pending_since = time.monotonic()
if pending_action is not None and time.monotonic() - pending_since > args.stuck_seconds: if pending_action is not None and time.monotonic() - pending_since > args.stuck_seconds:
recording.disposition(proposal_id, "session_stopped")
return finish(1, "pending_action_timeout") return finish(1, "pending_action_timeout")
# Between turns there is nothing to do but look again. # Between turns there is nothing to do but look again.
if decision.action == "__wait__": if decision.action == "__wait__":
recording.disposition(proposal_id, "wait")
waits += 1 waits += 1
print(f"[{step:03d}] WAIT {decision.reason}") print(f"[{step:03d}] WAIT {decision.reason}")
time.sleep(args.pause) time.sleep(args.pause)
@ -518,6 +510,7 @@ def main() -> int:
if same_state > 0 and last_ok and action_key == last_action_key: if same_state > 0 and last_ok and action_key == last_action_key:
duplicate_waits += 1 duplicate_waits += 1
if duplicate_waits <= args.max_duplicate_waits: if duplicate_waits <= args.max_duplicate_waits:
recording.disposition(proposal_id, "suppressed")
waits += 1 waits += 1
print(f"[{step:03d}] WAIT same action on unchanged state ({same_state})") print(f"[{step:03d}] WAIT same action on unchanged state ({same_state})")
time.sleep(args.pause) time.sleep(args.pause)
@ -528,17 +521,15 @@ def main() -> int:
stats[decision.source] = stats.get(decision.source, 0) + 1 stats[decision.source] = stats.get(decision.source, 0) + 1
print(f"[{step:03d}] DECIDE {decision}") print(f"[{step:03d}] DECIDE {decision}")
record = decision_record(step, st, obs.get("run"), decision, decide_error, client)
record.update(run_id=run_context.run_id, run_seed=run_context.seed,
deck_provenance=(run_context.deck or {}).get("provenance"))
trace(record) trace(record)
last_action_key = action_key
if args.dry_run: if args.dry_run:
recording.disposition(proposal_id, "dry_run")
continue continue
last_action_key = action_key
try: try:
result = sts2.act(decision.action, **decision.params) result = recording.execute(proposal_id, decision, sts2.act)
except sts2.Sts2Error as exc: except sts2.Sts2Error as exc:
print(f"[{step:03d}] action failed: {str(exc)[:200]}") print(f"[{step:03d}] action failed: {str(exc)[:200]}")
trace({"step": step, "event": "action_error", trace({"step": step, "event": "action_error",

View file

@ -20,6 +20,7 @@ from __future__ import annotations
import contextlib import contextlib
import io import io
import hashlib
import itertools import itertools
import json import json
import os import os
@ -125,10 +126,11 @@ class FakeSts2:
return types.SimpleNamespace(ok=ok, message="rejected" if not ok else "") return types.SimpleNamespace(ok=ok, message="rejected" if not ok else "")
def invoke(fake, *flags, client=None, clock=None, decide=None): def invoke(fake, *flags, client=None, clock=None, decide=None, directory=None):
"""Run the real loop with isolated files, no delays, and no live services.""" """Run the real loop with isolated files, no delays, and no live services."""
callbacks = [] with contextlib.ExitStack() as stack:
with tempfile.TemporaryDirectory() as directory, contextlib.ExitStack() as stack: if directory is None:
directory = stack.enter_context(tempfile.TemporaryDirectory())
capdir = pathlib.Path(directory) / "capture" capdir = pathlib.Path(directory) / "capture"
stack.enter_context(patch.object(run, "sts2", fake)) stack.enter_context(patch.object(run, "sts2", fake))
stack.enter_context(patch.object(run, "JevClient", return_value=client or StubClient())) stack.enter_context(patch.object(run, "JevClient", return_value=client or StubClient()))
@ -138,7 +140,6 @@ def invoke(fake, *flags, client=None, clock=None, decide=None):
legacy = pathlib.Path(directory) / "deck.json" legacy = pathlib.Path(directory) / "deck.json"
legacy_text = '{"counts": {"STALE CARD": 999}}' legacy_text = '{"counts": {"STALE CARD": 999}}'
legacy.write_text(legacy_text) legacy.write_text(legacy_text)
stack.enter_context(patch.object(run.atexit, "register", side_effect=callbacks.append))
stack.enter_context(patch.object(run.time, "sleep")) stack.enter_context(patch.object(run.time, "sleep"))
stack.enter_context(patch.object(sys, "argv", ["run.py", "--steps", "10", "--pause", "0", stack.enter_context(patch.object(sys, "argv", ["run.py", "--steps", "10", "--pause", "0",
"--capture-dir", str(capdir), *flags])) "--capture-dir", str(capdir), *flags]))
@ -148,13 +149,15 @@ def invoke(fake, *flags, client=None, clock=None, decide=None):
stack.enter_context(patch.object(brain, "decide", side_effect=decide)) stack.enter_context(patch.object(brain, "decide", side_effect=decide))
output = stack.enter_context(contextlib.redirect_stdout(io.StringIO())) output = stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
rc = run.main() rc = run.main()
for callback in callbacks:
callback()
def rows(name): def rows(name):
path = capdir / name path = capdir / name
return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else [] return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []
return types.SimpleNamespace(rc=rc, rows=rows("decisions.jsonl"), sessions = rows("sessions.jsonl")
sessions=rows("sessions.jsonl"), saved=int(not legacy.exists() or legacy.read_text() != legacy_text), events = [event for session in sessions for event in rows(session["recording_path"])]
blobs = {event["observation_id"]: (capdir / event["path"]).read_bytes()
for event in events if event["event"] == "observation"}
return types.SimpleNamespace(rc=rc, rows=rows("decisions.jsonl"), events=events, blobs=blobs,
sessions=sessions, saved=int(not legacy.exists() or legacy.read_text() != legacy_text),
output=output.getvalue()) output=output.getvalue())
@ -173,9 +176,9 @@ print("=== 2. a decision that asked nothing logs jev: null ===")
stub = run.RecordingClient(StubClient()) stub = run.RecordingClient(StubClient())
code_decision = brain.Decision("end_turn", {}, "no playable cards", "code") code_decision = brain.Decision("end_turn", {}, "no playable cards", "code")
row = run.decision_record(1, "monster", {"act": 1, "floor": 1}, code_decision, row = run.decision_record(1, "monster", {"act": 1, "floor": 1}, code_decision,
None, stub) None, stub, session_id="fixture-session")
check("jev is null", row["jev"], None) check("jev is null", row["jev"], None)
check("the session id travels with the row", row["session"], run.SESSION_ID) check("the session id travels with the row", row["session"], "fixture-session")
check("the action is still recorded", row["action"], "end_turn") check("the action is still recorded", row["action"], "end_turn")
print() print()
@ -212,7 +215,7 @@ check("...with its value and gate outcome, not just a yes/no",
rows[0]["jev"]["answers"]["good_card0"]["gated"]), (0.9, True)) rows[0]["jev"]["answers"]["good_card0"]["gated"]), (0.9, True))
check("row 2 asked nothing", rows[1]["jev"], None) check("row 2 asked nothing", rows[1]["jev"], None)
check("...and is a code decision", rows[1]["source"], "code") check("...and is a code decision", rows[1]["source"], "code")
check("every row carries the session", {r["session"] for r in rows}, {run.SESSION_ID}) check("every row carries the session", {r["session"] for r in rows}, {result.sessions[0]["session"]})
print() print()
print("=== 4. the session row maps to an outcome (join half) ===") print("=== 4. the session row maps to an outcome (join half) ===")
@ -457,6 +460,60 @@ for label, states, identities in [
evidence = [state["deck_composition"] for state, _ in client.requests if "deck_composition" in state] evidence = [state["deck_composition"] for state, _ in client.requests if "deck_composition" in state]
check(label + " receives unknown deck context", evidence[-1], "unknown") check(label + " receives unknown deck context", evidence[-1], "unknown")
print()
print("=== 10. recordings link observations, proposals, attempts, and results ===")
states = [game_over, reward_here, reward_here, {"state_type": "overlay"}, game_over]
client = StubClient(.1)
fake = FakeSts2(states, action_ok=[True, False, True, True])
result = invoke(fake, "--steps", "4", "--stop-on-run-end", client=client)
observations = {e["observation_id"]: e for e in result.events if e["event"] == "observation"}
proposals = {e["proposal_id"]: e for e in result.events if e["event"] == "proposal"}
attempts = {e["attempt_id"]: e for e in result.events if e["event"] == "action_attempt"}
results = [e for e in result.events if e["event"] == "action_result"]
check("full preflight and loop observations survive unchanged",
[json.loads(result.blobs[key]) for key in observations], states)
check("every stored observation matches its SHA-256 reference",
all(hashlib.sha256(result.blobs[key]).hexdigest() == event["sha256"]
for key, event in observations.items()), True)
check("every proposal and attempt references its exact input observation",
all(p["observation_id"] in observations for p in proposals.values()) and
all(a["proposal_id"] in proposals and a["observation_id"] == proposals[a["proposal_id"]]["observation_id"]
for a in attempts.values()), True)
check("results join one-to-one with actual attempts, including both system dismissals",
([e["attempt_id"] for e in results], [e["outcome"] for e in results]),
(list(attempts), ["accepted", "rejected", "accepted", "accepted"]))
check("next observations link attempts without asserting their effects",
[e["after_attempt_id"] for e in observations.values()], [None, *list(attempts)[:3], None])
check("terminal dismissal honestly has no subsequent observation",
result.sessions[0]["awaiting_observation_after_attempt"], list(attempts)[-1])
check("wait decisions are recorded without action attempts",
([p["action"] for p in proposals.values()].count("__wait__"), len(attempts)), (1, 4))
check("successful model records include the exact request state",
[p["jev"]["state"] for p in proposals.values() if p.get("jev")], [s for s, _ in client.requests])
check("session finalizes exactly once before main returns",
(len(result.sessions), sum(e["event"] == "session_end" for e in result.events)), (1, 1))
with tempfile.TemporaryDirectory() as directory:
first = invoke(FakeSts2([menu]), "--steps", "2", "--no-jev", "--dry-run", directory=directory)
second = invoke(FakeSts2([menu]), "--steps", "2", "--no-jev", directory=directory)
check("repeated main calls use distinct session IDs in one capture directory",
len({s["session"] for s in second.sessions}), 2)
check("later sessions never overwrite earlier observations",
{key: second.blobs[key] for key in first.blobs}, first.blobs)
check("dry-run records previews, never attempts or execution-based suppression",
[e["status"] for e in first.events if e["event"] == "proposal_status"], ["dry_run", "dry_run"])
check("duplicate suppression is visible as a proposal disposition",
[e["status"] for e in second.events if e["event"] == "proposal_status"][-2:], ["execute", "suppressed"])
for error, expected_code, reason in [(FakeSts2.Sts2Error("connection closed"), 1, "action_error"),
(KeyboardInterrupt(), 130, "interrupted")]:
fake = FakeSts2([menu], action_error=error)
result = invoke(fake, "--no-jev")
check(f"{reason}: one attempt, unknown outcome, synchronous finalization",
(result.rc, len(fake.actions), [e["outcome"] for e in result.events if e["event"] == "action_result"],
result.sessions[0]["stop_reason"], sum(e["event"] == "session_end" for e in result.events)),
(expected_code, 1, ["unknown"], reason, 1))
print() print()
print(f"=== {PASS} passed, {FAIL} failed ===") print(f"=== {PASS} passed, {FAIL} failed ===")
sys.exit(1 if FAIL else 0) sys.exit(1 if FAIL else 0)