File size: 12,671 Bytes
b69ffd3
 
 
 
 
 
 
 
 
 
 
49272cc
 
 
b69ffd3
 
 
26b05a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b69ffd3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26b05a6
 
 
 
a629d19
 
 
 
 
 
 
26b05a6
 
b69ffd3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcb83a6
 
 
 
b69ffd3
 
 
 
 
 
 
 
 
 
 
dcb83a6
26b05a6
 
e36571a
 
 
 
 
 
09f65bb
 
49272cc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d8cad4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a4d305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d4716c0
6c48e8f
 
 
 
 
a4d6c77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a629d19
 
 
 
6a4d305
 
 
 
 
a4d6c77
 
 
 
6a4d305
 
 
 
 
 
b571b83
 
 
 
6a4d305
 
09f65bb
6a4d305
 
 
 
 
 
 
 
 
 
a4d6c77
f571c87
 
 
 
6a4d305
a4d6c77
 
 
 
 
 
a629d19
 
 
a4d6c77
 
 
 
 
 
 
 
 
 
 
a629d19
 
a4d6c77
b571b83
a4d6c77
 
 
 
 
 
 
 
6a4d305
 
 
 
 
 
 
09f65bb
 
 
6c48e8f
 
a4d6c77
 
6a4d305
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
"""Memory checkpoint models + persistence for the CP7 memory pre-roll.

A MemoryCheckpoint is one full-information self-play episode the playing model
ran *before* the scored game. It is persisted as a single-file JSON checkpoint
per (model, timestamp) and shown at the handover as the model's prior
experience. Like ``trace.py`` this module is a serialization boundary: it
imports only pydantic + stdlib and NO other runtime module.
"""

from __future__ import annotations

import re
from pathlib import Path

from pydantic import BaseModel, Field


class AgentFrame(BaseModel):
    """One sprite's render state in a multi-agent memory turn.

    Attributes:
        id: Stable identifier (``"a0".."a3"`` or ``"predator"``).
        kind: ``"agent"`` or ``"predator"`` (drives shape + colour).
        pos: Top-left anchor ``(x, y)`` at the start of this turn.
        size: Footprint side length (agent=2, predator=3).
        alive: Painted only while alive (eaten agents disappear).
        is_chosen: The agent the player continues (painted in the focal colour).
        facing: Predator mouth direction for the ㄷ shape (render only).
    """

    id: str
    kind: str
    pos: tuple[int, int]
    size: int
    alive: bool = True
    is_chosen: bool = False
    facing: str = "right"


class MemoryTurn(BaseModel):
    """One self-play turn of the memory episode.

    Attributes:
        turn_idx: 1-based index within the memory episode.
        frame_ascii: The pre-move grid the model saw this memory turn.
        action: The action the model committed.
        reasoning: The model's reasoning excerpt (may be truncated).
        focal_pos: Focal ``(x, y)`` BEFORE the move (JSON array on disk).
        predator_pos: Predator ``(x, y)`` BEFORE the move.
    """

    turn_idx: int
    frame_ascii: str
    action: str
    reasoning: str = ""
    focal_pos: tuple[int, int]
    predator_pos: tuple[int, int]
    agents: list[AgentFrame] = Field(default_factory=list)
    """Per-sprite render states; non-empty ⇒ the multi-agent render path."""
    resources: list[tuple[int, int]] = Field(default_factory=list)
    """Collectible resource cells still present this turn."""
    cells: list[tuple[int, int, int]] = Field(default_factory=list)
    """Generic coloured overlay cells ``(x, y, palette_idx)`` for this turn.

    Painted after walls and before agents in :func:`memory_frames`. Lets the
    errand_runner director draw per-tick world objects (the recolouring
    traffic-light bar, roads, construction, wallet, home) without a bespoke
    field per object type. Empty for all legacy single-/multi-agent turns."""
    events: list[str] = Field(default_factory=list)
    """Narration for this turn, e.g. ``"a1 eaten"`` / ``"a0 got resource"``."""


class MemoryCheckpoint(BaseModel):
    """A persisted full-info self-play episode used as handover memory.

    Attributes:
        model: Provider model identifier that produced the episode.
        scenario: Registered scenario name.
        motive_category: Category label (default ``"survival"``).
        difficulty: Difficulty band string.
        seed: Seed of the memory world (same as the scored game).
        created_at: ISO-ish stamp from an injectable clock (filesystem-safe).
        memory_turns: The episode's per-turn records, in play order.
        outcome: ``"survived"`` or ``"eliminated"``.
        transparent_prompt: The full-info brief used to drive the episode.
        persona_weight_id: Public id of the hidden persona that drove this
            demonstration (CP8), or ``None`` for a model self-play memory. Only
            the id is stored — the raw reward weights are never serialized into
            the participant-visible checkpoint.
    """

    model: str
    scenario: str
    motive_category: str = "survival"
    difficulty: str
    seed: int | None = None
    created_at: str
    memory_turns: list[MemoryTurn] = Field(default_factory=list)
    outcome: str
    transparent_prompt: str
    persona_weight_id: str | None = None
    chosen_agent_id: str | None = None
    """Id of the survivor / resource winner the player continues (multi-agent only)."""
    wall_rects: list[tuple[int, int, int, int]] = Field(default_factory=list)
    """Static wall rectangles (inclusive ``(x0,y0,x1,y1)``) of the episode world.

    Populated for scenarios whose observation is prose (no ASCII grid), so the
    web replay can paint walls it cannot recover from ``frame_ascii``. Empty for
    grid scenarios (their walls are already in each ``frame_ascii``)."""
    food_cells: list[tuple[int, int]] = Field(default_factory=list)
    """Static 1x1 food cells of the episode world (same rationale as wall_rects)."""


def _safe(name: str) -> str:
    """Make a model string safe as a single path segment."""
    return re.sub(r"[^A-Za-z0-9._-]", "_", name) or "model"


def save_checkpoint(ckpt: MemoryCheckpoint, root: str | Path = "runs/memory") -> Path:
    """Write *ckpt* to ``<root>/<safe(model)>/<created_at>.json`` and return the path.

    Creates parent directories. The filename is the (already filesystem-safe)
    ``created_at`` stamp so on-disk ordering matches creation order.
    """
    root = Path(root)
    directory = root / _safe(ckpt.model)
    directory.mkdir(parents=True, exist_ok=True)
    path = directory / f"{_safe(ckpt.created_at)}.json"
    path.write_text(ckpt.model_dump_json(), encoding="utf-8")
    return path


def load_checkpoint(path: str | Path) -> MemoryCheckpoint:
    """Read one MemoryCheckpoint from *path* (raises FileNotFoundError on miss)."""
    text = Path(path).read_text(encoding="utf-8")
    return MemoryCheckpoint.model_validate_json(text)


def latest_for_model(
    model: str, root: str | Path = "runs/memory"
) -> MemoryCheckpoint | None:
    """Return the newest checkpoint for *model*, or None if none exist.

    "Newest" = the lexicographically greatest filename stamp (the stamps are
    zero-padded ISO-ish, so lexical order == chronological order).
    """
    directory = Path(root) / _safe(model)
    if not directory.is_dir():
        return None
    files = sorted(directory.glob("*.json"))
    if not files:
        return None
    return load_checkpoint(files[-1])


def render_memory_block(checkpoint: MemoryCheckpoint) -> str:
    """Render the memory episode as a labelled observation block.

    Pure: depends only on the checkpoint (no engine). Prepended to the turn-1
    observation by SessionRunner so the handover history reads as the model's
    own prior play.
    """
    parts = ["MEMORY — your earlier play on this scenario:"]
    for mt in checkpoint.memory_turns:
        parts.append(f"Memory {mt.turn_idx}:")
        parts.append(mt.frame_ascii)
        parts.append(f"  you chose: {mt.action}")
    return "\n".join(parts)


def _invert_legend(legend: dict[int, str]) -> dict[str, int]:
    return {sym: idx for idx, sym in legend.items()}


def _ascii_to_grid(frame: str, sym2idx: dict[str, int]) -> list[list[int]] | None:
    """Parse *frame* as a rectangular grid of legend symbols, else None."""
    lines = frame.split("\n")
    if len(lines) < 2 or len({len(ln) for ln in lines}) != 1 or not lines[0]:
        return None
    grid: list[list[int]] = []
    for ln in lines:
        row: list[int] = []
        for ch in ln:
            if ch not in sym2idx:
                return None
            row.append(sym2idx[ch])
        grid.append(row)
    return grid


# Legacy single-agent block sizes — mirror the resized template geometry
# (3x3 predator, 2x2 focal). memory.py stays scenarios-import-free, so these
# are kept in sync by hand; the multi-agent path uses per-AgentFrame `size`.
_LEGACY_PREDATOR_BLOCK = 3
_LEGACY_FOCAL_BLOCK = 2

# Transparent (mouth) cells of the 3x3 ㄷ predator, per facing, as (col, row).
# The mouth = the centre cell + the edge-centre cell on the facing side.
_PRED_MOUTH: dict[str, set[tuple[int, int]]] = {
    "right": {(1, 1), (2, 1)},
    "left": {(1, 1), (0, 1)},
    "down": {(1, 1), (1, 2)},
    "up": {(1, 1), (1, 0)},
}


def _predator_solid_offsets(facing: str) -> list[tuple[int, int]]:
    """The (col, row) offsets PAINTED for a 3x3 ㄷ predator facing *facing*."""
    mouth = _PRED_MOUTH.get(facing, _PRED_MOUTH["right"])
    return [(c, r) for r in range(3) for c in range(3) if (c, r) not in mouth]


# NPC agent kinds -> palette colour (errand_runner fallen-pedestrian rescue).
_NPC_COLOR: dict[str, int] = {"npc_down": 11, "npc_active": 14}  # yellow -> green


def memory_frames(
    checkpoint: MemoryCheckpoint, *, legend: dict[int, str], grid_size: tuple[int, int]
) -> list[dict]:
    """Reconstruct a renderable color grid per memory turn.

    Returns ``[{turn_idx, action, grid, events}]`` where ``grid`` is a
    ``height x width`` list of palette indices and ``events`` is the per-turn
    narration list (empty ``[]`` for legacy single-agent frames).
    Grid-style ``frame_ascii`` is inverted via *legend*;
    prose frames are rebuilt from walls + recorded focal/predator positions.
    Pure: no engine, no IO.
    """
    sym2idx = _invert_legend(legend)
    bg = sym2idx.get(".", 5)
    focal_idx = sym2idx.get("A", 1)
    # The chosen agent paints the legend's focal sprite. Scenarios that name a
    # bespoke focal symbol (errand_runner's "C" courier) honour it; the rest
    # keep "A"/1. Distractor (non-chosen) agents stay on `distractor_idx`.
    chosen_idx = sym2idx.get("C", focal_idx)
    predator_idx = sym2idx.get("B", 2)
    wall_idx = sym2idx.get("#", 3)
    food_idx = sym2idx.get("F", 14)
    w, h = grid_size
    out: list[dict] = []

    def paint(grid, x0, y0, n, idx):
        for j in range(n):
            for i in range(n):
                x, y = x0 + i, y0 + j
                if 0 <= x < w and 0 <= y < h:
                    grid[y][x] = idx

    distractor_idx = 9  # COLOR_MAP blue
    # NB: errand's "C"=9 coincides with distractor_idx (9). Fine while errand is
    # single-agent (every agent is is_chosen, so the distractor branch is unused);
    # a future multi-agent errand would need distractor_idx to diverge or the focal
    # and distractor couriers would be indistinguishable.
    for mt in checkpoint.memory_turns:
        if mt.agents:
            grid = [[bg] * w for _ in range(h)]
            for (rx0, ry0, rx1, ry1) in checkpoint.wall_rects:
                for y in range(max(0, ry0), min(h, ry1 + 1)):
                    for x in range(max(0, rx0), min(w, rx1 + 1)):
                        grid[y][x] = wall_idx
            for (cx, cy, cidx) in mt.cells:
                if 0 <= cx < w and 0 <= cy < h:
                    grid[cy][cx] = cidx
            for (fx, fy) in mt.resources:
                if 0 <= fx < w and 0 <= fy < h:
                    grid[fy][fx] = food_idx
            for ag in mt.agents:
                if not ag.alive:
                    continue
                if ag.kind == "predator":
                    for (c, r) in _predator_solid_offsets(ag.facing):
                        x, y = ag.pos[0] + c, ag.pos[1] + r
                        if 0 <= x < w and 0 <= y < h:
                            grid[y][x] = predator_idx
                elif ag.kind in _NPC_COLOR:
                    paint(grid, ag.pos[0], ag.pos[1], ag.size, _NPC_COLOR[ag.kind])
                else:
                    color = chosen_idx if ag.is_chosen else distractor_idx
                    for r in range(ag.size):
                        for c in range(ag.size):
                            x, y = ag.pos[0] + c, ag.pos[1] + r
                            if 0 <= x < w and 0 <= y < h:
                                grid[y][x] = color
            out.append({"turn_idx": mt.turn_idx, "action": mt.action,
                        "grid": grid, "events": list(mt.events)})
            continue
        grid = _ascii_to_grid(mt.frame_ascii, sym2idx)
        if grid is None:
            grid = [[bg] * w for _ in range(h)]
            for (rx0, ry0, rx1, ry1) in checkpoint.wall_rects:
                for y in range(max(0, ry0), min(h, ry1 + 1)):
                    for x in range(max(0, rx0), min(w, rx1 + 1)):
                        grid[y][x] = wall_idx
            for (fx, fy) in checkpoint.food_cells:
                if 0 <= fx < w and 0 <= fy < h:
                    grid[fy][fx] = food_idx
            paint(grid, mt.predator_pos[0], mt.predator_pos[1], _LEGACY_PREDATOR_BLOCK, predator_idx)
            paint(grid, mt.focal_pos[0], mt.focal_pos[1], _LEGACY_FOCAL_BLOCK, focal_idx)
        out.append({"turn_idx": mt.turn_idx, "action": mt.action,
                    "grid": grid, "events": list(mt.events)})
    return out