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:
parent
ba519a850e
commit
9696282110
10 changed files with 476 additions and 123 deletions
79
test_run.py
79
test_run.py
|
|
@ -20,6 +20,7 @@ from __future__ import annotations
|
|||
|
||||
import contextlib
|
||||
import io
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
|
|
@ -125,10 +126,11 @@ class FakeSts2:
|
|||
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."""
|
||||
callbacks = []
|
||||
with tempfile.TemporaryDirectory() as directory, contextlib.ExitStack() as stack:
|
||||
with contextlib.ExitStack() as stack:
|
||||
if directory is None:
|
||||
directory = stack.enter_context(tempfile.TemporaryDirectory())
|
||||
capdir = pathlib.Path(directory) / "capture"
|
||||
stack.enter_context(patch.object(run, "sts2", fake))
|
||||
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_text = '{"counts": {"STALE CARD": 999}}'
|
||||
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(sys, "argv", ["run.py", "--steps", "10", "--pause", "0",
|
||||
"--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))
|
||||
output = stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
|
||||
rc = run.main()
|
||||
for callback in callbacks:
|
||||
callback()
|
||||
def rows(name):
|
||||
path = capdir / name
|
||||
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"), saved=int(not legacy.exists() or legacy.read_text() != legacy_text),
|
||||
sessions = rows("sessions.jsonl")
|
||||
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())
|
||||
|
||||
|
||||
|
|
@ -173,9 +176,9 @@ print("=== 2. a decision that asked nothing logs jev: null ===")
|
|||
stub = run.RecordingClient(StubClient())
|
||||
code_decision = brain.Decision("end_turn", {}, "no playable cards", "code")
|
||||
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("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")
|
||||
|
||||
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))
|
||||
check("row 2 asked nothing", rows[1]["jev"], None)
|
||||
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("=== 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]
|
||||
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(f"=== {PASS} passed, {FAIL} failed ===")
|
||||
sys.exit(1 if FAIL else 0)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue