"""Memory-generation stage: a full-information self-play episode. Mirrors rollout.py's engine-replay pattern. The agent is given the scenario's transparent brief and plays freely (no scripted Cut, no answer-key leakage) for up to memory_turns, stopping on terminal. The recorded trajectory becomes a MemoryCheckpoint shown at the scored game's handover (see CP7 design §5). """ from __future__ import annotations import random from datetime import datetime, timezone from typing import Callable from proteus.game.agents.base import Agent from proteus.game.engine.ascii_view import legend_text from proteus.game.engine.difficulty import Difficulty from proteus.game.engine.grid import MotiveGridGame from proteus.game.scenarios.base import get_scenario from proteus.game.runtime.memory import MemoryCheckpoint, MemoryTurn from proteus.game.metrics.persona import PersonaWeights, reference_actions _ACTIONS = ["up", "down", "left", "right", "stay"] _REASONING_LIMIT = 2000 # cap stored reasoning so checkpoints stay small def _utc_now_stamp() -> str: """Filesystem-safe UTC stamp, e.g. '2026-06-02T10-40-56Z'.""" return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ") def generate_memory( scenario_name: str, agent: Agent, *, difficulty: Difficulty, seed: int | None, memory_turns: int, model_name: str, clock: Callable[[], str] = _utc_now_stamp, persona: PersonaWeights | None = None, policy: Callable[[object, object], str] | None = None, ) -> MemoryCheckpoint: """Run a full-info self-play episode and return it as a MemoryCheckpoint. Args: scenario_name: Registered scenario key. agent: The playing agent (its provider drives the actions). May be ``None`` when *persona* is set (the reference policy plays instead). difficulty: Difficulty band of the memory world (= the scored game). seed: Seed of the memory world (= the scored game). memory_turns: Max self-play turns; the episode also stops on terminal. model_name: Identifier stored on the checkpoint. clock: Returns the ``created_at`` stamp (injected for deterministic tests). persona: When set, the memory is a *persona demonstration* — each action is the deterministic reference action (first tie-break of ``reference_actions``) for the hidden weights, not a model self-play. The public ``persona_weight_id`` is recorded; the raw weights are never serialized, and the transparent brief stays weight-agnostic so the eval model must infer the persona from behaviour alone (spec §3). """ if persona is None and policy is None and agent is None: raise ValueError("generate_memory needs an agent unless a persona or policy is given") scenario = get_scenario(scenario_name)() rng = random.Random(seed) game = MotiveGridGame(scenario, rng, difficulty, max_steps=memory_turns) brief = scenario.memory_brief or scenario.rules_text turns: list[MemoryTurn] = [] for turn_idx in range(1, memory_turns + 1): focal = game.focal_sprite predator = game.predator_sprite focal_pos = (focal.x, focal.y) if focal else (-1, -1) predator_pos = (predator.x, predator.y) if predator else (-1, -1) frame = scenario.render_frame(game) # scenario-chosen view (compact on open fields) if policy is not None: # Deterministic scripted policy demonstration (no model, no weights). action = policy(scenario, game) reasoning = "" elif persona is not None: # Deterministic persona demonstration: the reference policy plays. action = reference_actions(persona, scenario, game)[0] reasoning = "" else: obs_parts: list[str] = [] if turns: # auto-regressive: the model's own moves this practice run obs_parts.append( "Your moves so far this run (most recent last): " + ", ".join(t.action for t in turns) ) obs_parts += [ "Now:", frame, legend_text(scenario.legend()), f"Available actions: [{', '.join(_ACTIONS)}]", ] observation = "\n".join(obs_parts) result = agent.act(observation, list(_ACTIONS), brief) action = result.action reasoning = result.reasoning[:_REASONING_LIMIT] turns.append(MemoryTurn( turn_idx=turn_idx, frame_ascii=frame, action=action, reasoning=reasoning, focal_pos=focal_pos, predator_pos=predator_pos, )) game.apply_motive_action(action) scenario.record_focal_move(action) if game.eliminated or game.survived: break outcome = "eliminated" if game.eliminated else "survived" return MemoryCheckpoint( model=model_name, scenario=scenario_name, motive_category="survival", difficulty=difficulty.value, seed=seed, created_at=clock(), memory_turns=turns, outcome=outcome, transparent_prompt=brief, persona_weight_id=persona.persona_weight_id if persona else None, wall_rects=list(scenario.wall_rects()), food_cells=list(scenario.food_cells()), )