Spaces:
Sleeping
Round 7: episodic memory experiment (per-scenario atoms)
Browse filesMinimum-viable episodic memory layer per the user's "memory could be
tested" guidance — KB calls out a full hierarchical memory architecture
(working / episodic / semantic / graph / archival); this round ships
Episodic with strict gating, defers the rest.
Concept: at game completion, an LLM call extracts a few short
verified-against-verdicts insights ("memory atoms") from the debrief.
The selector keeps CONFIRMED + PLAUSIBLE atoms, drops SPECULATIVE,
dedupes >70% substring matches against existing atoms, and caps each
scenario at 50 atoms (oldest evicted). On the next game with the same
scenario, the top 3-5 most-recent atoms are prepended to write_briefing's
USER message under "## Memory of past games (treat as priors, not proof)"
— system prompt untouched so the prompt cache stays warm.
- wargame/memory/store.py — JSON per scenario at games_dir/memory/{id}.json
- wargame/memory/extractor.py — extract_atoms + selector + persistence
- wargame/engine/state.py — MemoryAtom, MemoryAtomList, MemoryConfidence
- wargame/engine/loop.py — extract_and_persist at game completion,
wrapped so memory failure never breaks a real game
- wargame/agents/control.py — _render_memory_block injects into briefing
user message; insights stay English regardless of game language so
atoms migrate cleanly across language switches
- wargame/cli.py — wargame memory list/clear/remove
- API: GET / DELETE /scenarios/{id}/memory[/{atom_id}]
321 tests pass (301 baseline + 20 new). CLAUDE.md §8 updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- CLAUDE.md +7 -1
- wargame/agents/control.py +67 -0
- wargame/api/routes.py +53 -0
- wargame/api/schemas.py +16 -0
- wargame/cli.py +85 -0
- wargame/engine/loop.py +22 -0
- wargame/engine/state.py +43 -0
- wargame/memory/__init__.py +25 -0
- wargame/memory/extractor.py +254 -0
- wargame/memory/store.py +124 -0
- wargame/tests/conftest.py +48 -0
- wargame/tests/test_api.py +81 -0
- wargame/tests/test_memory.py +331 -0
|
@@ -229,7 +229,13 @@ in some paths.
|
|
| 229 |
is per-individual-agent (one different model per role-agent) routing
|
| 230 |
within a role — every agent that calls with `role="agent"` still shares
|
| 231 |
one model in a given game.
|
| 232 |
-
- German localization
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
- Mid-game resume beyond simple snapshot reload
|
| 234 |
- Replay viewer / WebSocket streaming of deliberations
|
| 235 |
- Multiplayer
|
|
|
|
| 229 |
is per-individual-agent (one different model per role-agent) routing
|
| 230 |
within a role — every agent that calls with `role="agent"` still shares
|
| 231 |
one model in a given game.
|
| 232 |
+
- German localization is shipped (per-game `language` field; `--language en|de`).
|
| 233 |
+
- **Episodic memory** is shipped as a per-scenario atom-based store (see
|
| 234 |
+
`wargame/memory/`). The extractor runs on game completion, the selector
|
| 235 |
+
drops `SPECULATIVE` atoms and dedupes against the on-disk store, and the
|
| 236 |
+
control briefing prepends up to five PLAUSIBLE+ atoms as priors. The
|
| 237 |
+
hierarchical layers from the KB (Working, Semantic, Graph, Archival
|
| 238 |
+
memory) are still deferred.
|
| 239 |
- Mid-game resume beyond simple snapshot reload
|
| 240 |
- Replay viewer / WebSocket streaming of deliberations
|
| 241 |
- Multiplayer
|
|
@@ -10,6 +10,7 @@ Five responsibilities:
|
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
|
|
|
| 13 |
from typing import TYPE_CHECKING
|
| 14 |
|
| 15 |
from wargame.agents.base import call_structured, current_language
|
|
@@ -20,12 +21,21 @@ from wargame.engine.state import (
|
|
| 20 |
Briefing,
|
| 21 |
CoachNote,
|
| 22 |
Game,
|
|
|
|
|
|
|
| 23 |
Verdict,
|
| 24 |
VerdictList,
|
| 25 |
WorldState,
|
| 26 |
WorldUpdate,
|
| 27 |
)
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
if TYPE_CHECKING:
|
| 30 |
from wargame.agents.registry import ScenarioConfig
|
| 31 |
|
|
@@ -117,6 +127,59 @@ def _delta_summary(game: Game) -> str:
|
|
| 117 |
# ---------------------------------------------------------------------------
|
| 118 |
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
def write_briefing(scenario: ScenarioConfig, game: Game) -> Briefing:
|
| 121 |
world = game.world
|
| 122 |
system = render(
|
|
@@ -127,7 +190,11 @@ def write_briefing(scenario: ScenarioConfig, game: Game) -> Briefing:
|
|
| 127 |
# Per-turn volatile data goes in the user message so the system prefix
|
| 128 |
# stays byte-identical across turns — a hard prerequisite for the
|
| 129 |
# top-level prompt caching configured in `call_structured`.
|
|
|
|
|
|
|
|
|
|
| 130 |
user = (
|
|
|
|
| 131 |
f"## Current world state\n\n"
|
| 132 |
f"- Turn: **{world.turn} of {world.max_turns}**\n"
|
| 133 |
f"- Public narrative: {world.public_narrative}\n"
|
|
|
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
+
import logging
|
| 14 |
from typing import TYPE_CHECKING
|
| 15 |
|
| 16 |
from wargame.agents.base import call_structured, current_language
|
|
|
|
| 21 |
Briefing,
|
| 22 |
CoachNote,
|
| 23 |
Game,
|
| 24 |
+
MemoryAtom,
|
| 25 |
+
MemoryConfidence,
|
| 26 |
Verdict,
|
| 27 |
VerdictList,
|
| 28 |
WorldState,
|
| 29 |
WorldUpdate,
|
| 30 |
)
|
| 31 |
|
| 32 |
+
log = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# How many atoms to inject per briefing. Capped low so the prompt stays
|
| 36 |
+
# focused; the most-recent N (by `created_at`) win when more are on file.
|
| 37 |
+
_MEMORY_INJECT_MAX = 5
|
| 38 |
+
|
| 39 |
if TYPE_CHECKING:
|
| 40 |
from wargame.agents.registry import ScenarioConfig
|
| 41 |
|
|
|
|
| 127 |
# ---------------------------------------------------------------------------
|
| 128 |
|
| 129 |
|
| 130 |
+
def _render_memory_block(atoms: list[MemoryAtom]) -> str:
|
| 131 |
+
"""Render selected atoms into the prepend-to-user-message memory block.
|
| 132 |
+
|
| 133 |
+
Empty input → empty string (callers must NOT emit an empty heading).
|
| 134 |
+
Atoms are presented as priors, never as facts — the heading and the
|
| 135 |
+
block intro both say so explicitly so Control treats them as hints.
|
| 136 |
+
"""
|
| 137 |
+
if not atoms:
|
| 138 |
+
return ""
|
| 139 |
+
lines = [
|
| 140 |
+
"## Memory of past games (treat as priors, not proof)",
|
| 141 |
+
"",
|
| 142 |
+
(
|
| 143 |
+
"These are insights distilled from previous games on this "
|
| 144 |
+
"scenario. They may be useful priors but should never override "
|
| 145 |
+
"the verdicts and world state from the current game."
|
| 146 |
+
),
|
| 147 |
+
"",
|
| 148 |
+
]
|
| 149 |
+
for atom in atoms:
|
| 150 |
+
provenance = f"source: game {atom.source_game_id[:8]}..."
|
| 151 |
+
if atom.source_turn is not None:
|
| 152 |
+
provenance += f", turn {atom.source_turn}"
|
| 153 |
+
lines.append(
|
| 154 |
+
f"- [confidence: {atom.confidence.value}] {atom.insight} "
|
| 155 |
+
f"({provenance})"
|
| 156 |
+
)
|
| 157 |
+
lines.append("") # trailing blank line
|
| 158 |
+
return "\n".join(lines)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _fetch_memory_atoms_for_briefing(scenario_id: str) -> list[MemoryAtom]:
|
| 162 |
+
"""Pull the top-N most-recent atoms for `scenario_id` (PLAUSIBLE+).
|
| 163 |
+
|
| 164 |
+
Wrapped in try/except so a corrupt memory file or import-time issue can
|
| 165 |
+
never break a briefing — episodic memory is auxiliary by construction.
|
| 166 |
+
"""
|
| 167 |
+
try:
|
| 168 |
+
from wargame.memory import list_atoms
|
| 169 |
+
|
| 170 |
+
atoms = list_atoms(scenario_id, min_confidence=MemoryConfidence.PLAUSIBLE)
|
| 171 |
+
except Exception: # noqa: BLE001
|
| 172 |
+
log.warning(
|
| 173 |
+
"Memory injection failed for scenario %s; briefing continues without it.",
|
| 174 |
+
scenario_id,
|
| 175 |
+
exc_info=True,
|
| 176 |
+
)
|
| 177 |
+
return []
|
| 178 |
+
# Most-recent first (the extractor records `created_at` in ISO 8601).
|
| 179 |
+
atoms.sort(key=lambda a: a.created_at, reverse=True)
|
| 180 |
+
return atoms[:_MEMORY_INJECT_MAX]
|
| 181 |
+
|
| 182 |
+
|
| 183 |
def write_briefing(scenario: ScenarioConfig, game: Game) -> Briefing:
|
| 184 |
world = game.world
|
| 185 |
system = render(
|
|
|
|
| 190 |
# Per-turn volatile data goes in the user message so the system prefix
|
| 191 |
# stays byte-identical across turns — a hard prerequisite for the
|
| 192 |
# top-level prompt caching configured in `call_structured`.
|
| 193 |
+
memory_block = _render_memory_block(
|
| 194 |
+
_fetch_memory_atoms_for_briefing(scenario.scenario_id)
|
| 195 |
+
)
|
| 196 |
user = (
|
| 197 |
+
f"{memory_block}"
|
| 198 |
f"## Current world state\n\n"
|
| 199 |
f"- Turn: **{world.turn} of {world.max_turns}**\n"
|
| 200 |
f"- Public narrative: {world.public_narrative}\n"
|
|
@@ -37,6 +37,8 @@ from wargame.api.schemas import (
|
|
| 37 |
GameListResponse,
|
| 38 |
HardConstraintInput,
|
| 39 |
HardConstraintListResponse,
|
|
|
|
|
|
|
| 40 |
MetricPoint,
|
| 41 |
MetricsTimelinePoint,
|
| 42 |
MetricsTimelineResponse,
|
|
@@ -1280,3 +1282,54 @@ def delete_constraint(
|
|
| 1280 |
game_id=game.id,
|
| 1281 |
constraints=list(game.hard_constraints),
|
| 1282 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
GameListResponse,
|
| 38 |
HardConstraintInput,
|
| 39 |
HardConstraintListResponse,
|
| 40 |
+
MemoryAtomListResponse,
|
| 41 |
+
MemoryClearResponse,
|
| 42 |
MetricPoint,
|
| 43 |
MetricsTimelinePoint,
|
| 44 |
MetricsTimelineResponse,
|
|
|
|
| 1282 |
game_id=game.id,
|
| 1283 |
constraints=list(game.hard_constraints),
|
| 1284 |
)
|
| 1285 |
+
|
| 1286 |
+
|
| 1287 |
+
# ---------------------------------------------------------------------------
|
| 1288 |
+
# Episodic memory endpoints (Round 7)
|
| 1289 |
+
#
|
| 1290 |
+
# Atoms are the per-scenario distillation of a finished game's lessons. The
|
| 1291 |
+
# read endpoint surfaces what's currently injected into briefings; the
|
| 1292 |
+
# delete endpoints let an operator wipe a single atom or the whole store
|
| 1293 |
+
# (useful while iterating on the extractor).
|
| 1294 |
+
# ---------------------------------------------------------------------------
|
| 1295 |
+
|
| 1296 |
+
|
| 1297 |
+
@router.get(
|
| 1298 |
+
"/scenarios/{scenario_id}/memory",
|
| 1299 |
+
response_model=MemoryAtomListResponse,
|
| 1300 |
+
)
|
| 1301 |
+
def get_memory(scenario_id: str) -> MemoryAtomListResponse:
|
| 1302 |
+
"""List PLAUSIBLE+ memory atoms for a scenario."""
|
| 1303 |
+
from wargame.engine.state import MemoryConfidence
|
| 1304 |
+
from wargame.memory import list_atoms
|
| 1305 |
+
|
| 1306 |
+
atoms = list_atoms(scenario_id, min_confidence=MemoryConfidence.PLAUSIBLE)
|
| 1307 |
+
return MemoryAtomListResponse(scenario_id=scenario_id, atoms=atoms)
|
| 1308 |
+
|
| 1309 |
+
|
| 1310 |
+
@router.delete(
|
| 1311 |
+
"/scenarios/{scenario_id}/memory/{atom_id}",
|
| 1312 |
+
status_code=204,
|
| 1313 |
+
)
|
| 1314 |
+
def delete_memory_atom(scenario_id: str, atom_id: str):
|
| 1315 |
+
"""Remove a single memory atom by id. 404 if no such atom."""
|
| 1316 |
+
from wargame.memory import remove_atom
|
| 1317 |
+
|
| 1318 |
+
if not remove_atom(scenario_id, atom_id):
|
| 1319 |
+
raise HTTPException(
|
| 1320 |
+
status_code=404,
|
| 1321 |
+
detail=f"No atom {atom_id!r} on file for scenario {scenario_id!r}.",
|
| 1322 |
+
)
|
| 1323 |
+
return None
|
| 1324 |
+
|
| 1325 |
+
|
| 1326 |
+
@router.delete(
|
| 1327 |
+
"/scenarios/{scenario_id}/memory",
|
| 1328 |
+
response_model=MemoryClearResponse,
|
| 1329 |
+
)
|
| 1330 |
+
def delete_memory_all(scenario_id: str) -> MemoryClearResponse:
|
| 1331 |
+
"""Wipe every memory atom for a scenario."""
|
| 1332 |
+
from wargame.memory import clear
|
| 1333 |
+
|
| 1334 |
+
cleared = clear(scenario_id)
|
| 1335 |
+
return MemoryClearResponse(scenario_id=scenario_id, cleared=cleared)
|
|
@@ -21,6 +21,7 @@ from wargame.engine.state import (
|
|
| 21 |
ConstraintSource,
|
| 22 |
Game,
|
| 23 |
HardConstraint,
|
|
|
|
| 24 |
PeekRecord,
|
| 25 |
ReconResponse,
|
| 26 |
RivalPlan,
|
|
@@ -485,3 +486,18 @@ class ActionCoachSessionResponse(BaseModel):
|
|
| 485 |
synthesized_draft: ActionDraftResponse | None = None
|
| 486 |
committed: bool = False
|
| 487 |
messages: list[dict] # raw dicts for clients to render the chat thread
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
ConstraintSource,
|
| 22 |
Game,
|
| 23 |
HardConstraint,
|
| 24 |
+
MemoryAtom,
|
| 25 |
PeekRecord,
|
| 26 |
ReconResponse,
|
| 27 |
RivalPlan,
|
|
|
|
| 486 |
synthesized_draft: ActionDraftResponse | None = None
|
| 487 |
committed: bool = False
|
| 488 |
messages: list[dict] # raw dicts for clients to render the chat thread
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
# ---------------------------------------------------------------------------
|
| 492 |
+
# Episodic memory (Round 7)
|
| 493 |
+
# ---------------------------------------------------------------------------
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
class MemoryAtomListResponse(BaseModel):
|
| 497 |
+
scenario_id: str
|
| 498 |
+
atoms: list[MemoryAtom]
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
class MemoryClearResponse(BaseModel):
|
| 502 |
+
scenario_id: str
|
| 503 |
+
cleared: int
|
|
@@ -1639,5 +1639,90 @@ def cmd_resume(
|
|
| 1639 |
)
|
| 1640 |
|
| 1641 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1642 |
if __name__ == "__main__":
|
| 1643 |
app()
|
|
|
|
| 1639 |
)
|
| 1640 |
|
| 1641 |
|
| 1642 |
+
# ---------------------------------------------------------------------------
|
| 1643 |
+
# Memory commands (Round 7)
|
| 1644 |
+
# ---------------------------------------------------------------------------
|
| 1645 |
+
|
| 1646 |
+
|
| 1647 |
+
memory_app = typer.Typer(
|
| 1648 |
+
help="Inspect and manage episodic memory atoms persisted per scenario.",
|
| 1649 |
+
no_args_is_help=True,
|
| 1650 |
+
)
|
| 1651 |
+
app.add_typer(memory_app, name="memory")
|
| 1652 |
+
|
| 1653 |
+
|
| 1654 |
+
@memory_app.command("list")
|
| 1655 |
+
def cmd_memory_list(
|
| 1656 |
+
scenario_id: str = typer.Argument(..., help="Scenario id whose memory to read."),
|
| 1657 |
+
) -> None:
|
| 1658 |
+
"""List memory atoms for a scenario."""
|
| 1659 |
+
from wargame.engine.state import MemoryConfidence
|
| 1660 |
+
from wargame.memory import list_atoms
|
| 1661 |
+
|
| 1662 |
+
atoms = list_atoms(scenario_id, min_confidence=MemoryConfidence.PLAUSIBLE)
|
| 1663 |
+
if not atoms:
|
| 1664 |
+
console.print(
|
| 1665 |
+
f"[dim]No atoms on file for scenario [bold]{scenario_id}[/bold].[/dim]"
|
| 1666 |
+
)
|
| 1667 |
+
return
|
| 1668 |
+
t = Table(title=f"Memory atoms: {scenario_id}")
|
| 1669 |
+
t.add_column("ID")
|
| 1670 |
+
t.add_column("Confidence")
|
| 1671 |
+
t.add_column("Insight")
|
| 1672 |
+
t.add_column("Source game")
|
| 1673 |
+
t.add_column("Turn", justify="right")
|
| 1674 |
+
for atom in atoms:
|
| 1675 |
+
truncated = atom.insight if len(atom.insight) <= 80 else atom.insight[:77] + "..."
|
| 1676 |
+
t.add_row(
|
| 1677 |
+
atom.id[:8] + "...",
|
| 1678 |
+
atom.confidence.value,
|
| 1679 |
+
truncated,
|
| 1680 |
+
atom.source_game_id[:8] + "...",
|
| 1681 |
+
str(atom.source_turn) if atom.source_turn is not None else "—",
|
| 1682 |
+
)
|
| 1683 |
+
console.print(t)
|
| 1684 |
+
|
| 1685 |
+
|
| 1686 |
+
@memory_app.command("clear")
|
| 1687 |
+
def cmd_memory_clear(
|
| 1688 |
+
scenario_id: str = typer.Argument(..., help="Scenario id whose memory to wipe."),
|
| 1689 |
+
yes: Annotated[
|
| 1690 |
+
bool,
|
| 1691 |
+
typer.Option("--yes", "-y", help="Skip confirmation prompt."),
|
| 1692 |
+
] = False,
|
| 1693 |
+
) -> None:
|
| 1694 |
+
"""Wipe every memory atom for a scenario."""
|
| 1695 |
+
from wargame.memory import clear
|
| 1696 |
+
|
| 1697 |
+
if not yes:
|
| 1698 |
+
confirm = typer.confirm(
|
| 1699 |
+
f"Wipe ALL memory atoms for scenario {scenario_id!r}?",
|
| 1700 |
+
default=False,
|
| 1701 |
+
)
|
| 1702 |
+
if not confirm:
|
| 1703 |
+
console.print("[dim]Aborted.[/dim]")
|
| 1704 |
+
raise typer.Exit(code=0)
|
| 1705 |
+
removed = clear(scenario_id)
|
| 1706 |
+
console.print(f"[green]Cleared {removed} atom(s) for {scenario_id}.[/green]")
|
| 1707 |
+
|
| 1708 |
+
|
| 1709 |
+
@memory_app.command("remove")
|
| 1710 |
+
def cmd_memory_remove(
|
| 1711 |
+
scenario_id: str = typer.Argument(..., help="Scenario id."),
|
| 1712 |
+
atom_id: str = typer.Argument(..., help="Atom id (full uuid)."),
|
| 1713 |
+
) -> None:
|
| 1714 |
+
"""Remove a single atom by id."""
|
| 1715 |
+
from wargame.memory import remove_atom
|
| 1716 |
+
|
| 1717 |
+
removed = remove_atom(scenario_id, atom_id)
|
| 1718 |
+
if removed:
|
| 1719 |
+
console.print(f"[green]Removed atom {atom_id[:8]}... from {scenario_id}.[/green]")
|
| 1720 |
+
else:
|
| 1721 |
+
console.print(
|
| 1722 |
+
f"[yellow]No atom {atom_id[:8]}... on file for {scenario_id}.[/yellow]"
|
| 1723 |
+
)
|
| 1724 |
+
raise typer.Exit(code=1)
|
| 1725 |
+
|
| 1726 |
+
|
| 1727 |
if __name__ == "__main__":
|
| 1728 |
app()
|
|
@@ -391,6 +391,28 @@ def advance(game: Game, *, generate_next_briefing: bool = True) -> Game:
|
|
| 391 |
if current.turn >= game.world.max_turns:
|
| 392 |
game.status = "complete"
|
| 393 |
persistence.save(game)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
return game
|
| 395 |
|
| 396 |
# Start next turn (seed record, optionally write briefing).
|
|
|
|
| 391 |
if current.turn >= game.world.max_turns:
|
| 392 |
game.status = "complete"
|
| 393 |
persistence.save(game)
|
| 394 |
+
# Episodic-memory extraction is best-effort and lives outside
|
| 395 |
+
# the player-visible flow. Wrapped in a defensive try/except so
|
| 396 |
+
# a malformed LLM response, a missing memory dir, or any other
|
| 397 |
+
# failure surfaces as a warning instead of breaking the debrief.
|
| 398 |
+
try:
|
| 399 |
+
from wargame.memory import extractor as _memory_extractor
|
| 400 |
+
|
| 401 |
+
persisted = _memory_extractor.extract_and_persist(scenario, game)
|
| 402 |
+
if persisted:
|
| 403 |
+
log.info(
|
| 404 |
+
"Memory: persisted %d atom(s) for scenario %s "
|
| 405 |
+
"(game %s).",
|
| 406 |
+
len(persisted),
|
| 407 |
+
scenario.scenario_id,
|
| 408 |
+
game.id[:8],
|
| 409 |
+
)
|
| 410 |
+
except Exception: # noqa: BLE001
|
| 411 |
+
log.warning(
|
| 412 |
+
"Memory extraction failed for game %s; debrief unaffected.",
|
| 413 |
+
game.id[:8],
|
| 414 |
+
exc_info=True,
|
| 415 |
+
)
|
| 416 |
return game
|
| 417 |
|
| 418 |
# Start next turn (seed record, optionally write briefing).
|
|
@@ -395,6 +395,49 @@ class HardConstraintList(BaseModel):
|
|
| 395 |
constraints: list[HardConstraint] = Field(default_factory=list, max_length=20)
|
| 396 |
|
| 397 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
# ---------------------------------------------------------------------------
|
| 399 |
# World state
|
| 400 |
# ---------------------------------------------------------------------------
|
|
|
|
| 395 |
constraints: list[HardConstraint] = Field(default_factory=list, max_length=20)
|
| 396 |
|
| 397 |
|
| 398 |
+
# ---------------------------------------------------------------------------
|
| 399 |
+
# Episodic memory — atoms extracted post-game and re-injected next game
|
| 400 |
+
# ---------------------------------------------------------------------------
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
class MemoryConfidence(str, Enum):
|
| 404 |
+
"""Confidence label on a memory atom.
|
| 405 |
+
|
| 406 |
+
`CONFIRMED` insights are verdict-backed and not contradicted; `PLAUSIBLE`
|
| 407 |
+
insights are supported but partial; `SPECULATIVE` insights are filtered
|
| 408 |
+
out by the selector before persistence.
|
| 409 |
+
"""
|
| 410 |
+
|
| 411 |
+
CONFIRMED = "confirmed"
|
| 412 |
+
PLAUSIBLE = "plausible"
|
| 413 |
+
SPECULATIVE = "speculative"
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
class MemoryAtom(BaseModel):
|
| 417 |
+
"""One transferable lesson from a finished game on a specific scenario.
|
| 418 |
+
|
| 419 |
+
Stored per-scenario in `{games_dir}/memory/{scenario_id}.json`. Insights
|
| 420 |
+
stay in English regardless of game language so atoms migrate cleanly when
|
| 421 |
+
the player switches languages between games on the same scenario.
|
| 422 |
+
"""
|
| 423 |
+
|
| 424 |
+
id: str = Field(default_factory=_uuid)
|
| 425 |
+
scenario_id: str
|
| 426 |
+
insight: str = Field(min_length=10, max_length=400)
|
| 427 |
+
rationale: str = Field(min_length=5, max_length=400)
|
| 428 |
+
confidence: MemoryConfidence
|
| 429 |
+
source_game_id: str
|
| 430 |
+
source_turn: int | None = None
|
| 431 |
+
tags: list[str] = Field(default_factory=list, max_length=8)
|
| 432 |
+
created_at: str = Field(default_factory=_now)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
class MemoryAtomList(BaseModel):
|
| 436 |
+
"""Wrapper for the extractor's structured LLM output."""
|
| 437 |
+
|
| 438 |
+
atoms: list[MemoryAtom] = Field(default_factory=list, max_length=20)
|
| 439 |
+
|
| 440 |
+
|
| 441 |
# ---------------------------------------------------------------------------
|
| 442 |
# World state
|
| 443 |
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Episodic memory: per-scenario atoms extracted from finished games.
|
| 2 |
+
|
| 3 |
+
Atoms persist as JSON-per-scenario at `{games_dir}/memory/{scenario_id}.json`
|
| 4 |
+
and are injected as priors into the briefing of the next game on the same
|
| 5 |
+
scenario. The selector (in `extractor.py`) keeps only verdict-backed atoms
|
| 6 |
+
to prevent self-degradation across runs.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from wargame.memory.store import (
|
| 12 |
+
add_atom,
|
| 13 |
+
clear,
|
| 14 |
+
list_atoms,
|
| 15 |
+
memory_path,
|
| 16 |
+
remove_atom,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
__all__ = [
|
| 20 |
+
"add_atom",
|
| 21 |
+
"clear",
|
| 22 |
+
"list_atoms",
|
| 23 |
+
"memory_path",
|
| 24 |
+
"remove_atom",
|
| 25 |
+
]
|
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Post-game extractor that turns a finished game into MemoryAtom records.
|
| 2 |
+
|
| 3 |
+
Single LLM call that reads the debrief markdown + verdict log + coach note
|
| 4 |
+
and emits a `MemoryAtomList`. The selector then:
|
| 5 |
+
|
| 6 |
+
1. Drops every `SPECULATIVE` atom (only verdict-backed insights survive).
|
| 7 |
+
2. Dedupes against atoms already on disk for this scenario (case-insensitive
|
| 8 |
+
>70% substring overlap).
|
| 9 |
+
3. Caps the per-scenario store at 50 atoms; oldest by `created_at` are
|
| 10 |
+
evicted when a new save would push the total over.
|
| 11 |
+
|
| 12 |
+
Failure isolation: the engine wraps the entire extract+persist pipeline in
|
| 13 |
+
try/except and only logs warnings, so a malformed LLM response never breaks
|
| 14 |
+
the debrief flow.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
from typing import TYPE_CHECKING
|
| 22 |
+
|
| 23 |
+
from wargame.agents.base import call_structured
|
| 24 |
+
from wargame.debrief.exporter import export_debrief
|
| 25 |
+
from wargame.engine.state import (
|
| 26 |
+
Game,
|
| 27 |
+
MemoryAtom,
|
| 28 |
+
MemoryAtomList,
|
| 29 |
+
MemoryConfidence,
|
| 30 |
+
)
|
| 31 |
+
from wargame.memory import store
|
| 32 |
+
|
| 33 |
+
if TYPE_CHECKING:
|
| 34 |
+
from wargame.agents.registry import ScenarioConfig
|
| 35 |
+
|
| 36 |
+
log = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# Hard cap on persisted atoms per scenario. Keeps the briefing prompt
|
| 40 |
+
# bounded even after dozens of plays. Oldest evicted by `created_at`.
|
| 41 |
+
MAX_ATOMS_PER_SCENARIO = 50
|
| 42 |
+
|
| 43 |
+
# Substring-overlap dedupe threshold. The selector drops a candidate atom
|
| 44 |
+
# whose insight has more than this much character-level overlap with any
|
| 45 |
+
# existing atom's insight (case-insensitive).
|
| 46 |
+
DEDUP_OVERLAP_THRESHOLD = 0.70
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
# Extractor — single LLM call
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _extractor_system_prompt(world_bible: str) -> str:
|
| 55 |
+
"""System prompt for the extractor.
|
| 56 |
+
|
| 57 |
+
NOTE: The extractor is the one place where the engine deliberately
|
| 58 |
+
overrides the per-game language directive. Atoms are stored in English
|
| 59 |
+
so a player who plays a scenario once in German and once in English
|
| 60 |
+
sees coherent shared memory across both runs — the briefing injector
|
| 61 |
+
simply renders the English insights inside whatever language the active
|
| 62 |
+
game was created in. The directive in the user message reinforces this.
|
| 63 |
+
"""
|
| 64 |
+
return (
|
| 65 |
+
"You write episodic memory atoms after a finished war-gaming game. "
|
| 66 |
+
"Each atom captures one transferable lesson from THIS game that would "
|
| 67 |
+
"help a future CEO playing the SAME scenario. Be specific (named "
|
| 68 |
+
"entities, named amounts, named turns). Avoid generic platitudes "
|
| 69 |
+
"(\"communicate clearly\", \"manage stakeholders\"). The atom must "
|
| 70 |
+
"reference the world bible's named characters, companies, regulators, "
|
| 71 |
+
"or numbers.\n\n"
|
| 72 |
+
"## World bible (named entities, numbers, posture)\n\n"
|
| 73 |
+
f"{world_bible}\n\n"
|
| 74 |
+
"## Confidence rules — set per atom\n\n"
|
| 75 |
+
"- `confirmed` — a verdict in this game directly backs the insight "
|
| 76 |
+
" (e.g., the rival's countermove fired exactly as the atom describes, "
|
| 77 |
+
" with `effect_minor` or stronger).\n"
|
| 78 |
+
"- `plausible` — partial support in the verdicts and coach note; not "
|
| 79 |
+
" a clean falsifiable observation but worth keeping as a prior.\n"
|
| 80 |
+
"- `speculative` — anything you can't tie to either a verdict outcome "
|
| 81 |
+
" or the coach note's explicit framing. The engine drops these.\n\n"
|
| 82 |
+
"Each atom needs a `rationale` field that names the specific verdict "
|
| 83 |
+
"or coach passage that earned the confidence label. If you can't cite "
|
| 84 |
+
"evidence, label the atom `speculative`."
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _extractor_user_prompt(scenario: ScenarioConfig, game: Game) -> str:
|
| 89 |
+
debrief_md = export_debrief(game)
|
| 90 |
+
coach_note_md = "_(no coach note on record)_"
|
| 91 |
+
if game.turns and game.turns[-1].verdicts:
|
| 92 |
+
# The coach note isn't on the TurnRecord; fall back to the debrief
|
| 93 |
+
# which already includes it (the debrief exporter calls write_coach_note).
|
| 94 |
+
pass
|
| 95 |
+
verdicts_log = json.dumps(
|
| 96 |
+
[
|
| 97 |
+
{
|
| 98 |
+
"turn": t.turn,
|
| 99 |
+
"verdicts": [v.model_dump() for v in t.verdicts],
|
| 100 |
+
}
|
| 101 |
+
for t in game.turns
|
| 102 |
+
],
|
| 103 |
+
indent=2,
|
| 104 |
+
default=str,
|
| 105 |
+
)
|
| 106 |
+
# Deliberate exception to the per-game language directive: atoms stay
|
| 107 |
+
# English regardless of game language so they migrate cleanly across
|
| 108 |
+
# language switches between sessions on the same scenario.
|
| 109 |
+
english_override = (
|
| 110 |
+
"ALWAYS write the `insight` and `rationale` fields in clear English, "
|
| 111 |
+
"regardless of the language directive that may follow. Atoms are "
|
| 112 |
+
"stored shared across language settings. Proper nouns (Norhard "
|
| 113 |
+
"Thermal AG, Bettina Lange, Aergis Climate, …) stay verbatim."
|
| 114 |
+
)
|
| 115 |
+
return (
|
| 116 |
+
f"## Scenario: {scenario.scenario_id} — {scenario.company_name} vs "
|
| 117 |
+
f"{scenario.rival_name}\n\n"
|
| 118 |
+
f"## Source game id\n\n`{game.id}` (max turns: {game.world.max_turns})\n\n"
|
| 119 |
+
f"## Full debrief (already includes the coach note)\n\n{debrief_md}\n\n"
|
| 120 |
+
f"## Verdict log (turn-by-turn)\n\n{verdicts_log}\n\n"
|
| 121 |
+
f"## Output rules\n\n"
|
| 122 |
+
f"- Set `scenario_id` to `{scenario.scenario_id}` on every atom.\n"
|
| 123 |
+
f"- Set `source_game_id` to `{game.id}` on every atom.\n"
|
| 124 |
+
f"- Set `source_turn` to the turn the lesson was learned, when "
|
| 125 |
+
f" the lesson is anchored to a specific turn; otherwise omit.\n"
|
| 126 |
+
f"- Emit at most 6 atoms — quality over quantity. One sharp atom "
|
| 127 |
+
f" beats five vague ones.\n\n"
|
| 128 |
+
f"{english_override}\n\n"
|
| 129 |
+
f"Return a `MemoryAtomList` JSON object with the `atoms` field."
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def extract_atoms(scenario: ScenarioConfig, game: Game) -> MemoryAtomList:
|
| 134 |
+
"""Single LLM call that produces candidate memory atoms from `game`.
|
| 135 |
+
|
| 136 |
+
Uses `role="evaluator"` so it inherits the per-role model override (the
|
| 137 |
+
Evaluator role tag was introduced in Round 3 specifically so calibration
|
| 138 |
+
workloads can route to a cheaper model).
|
| 139 |
+
"""
|
| 140 |
+
system = _extractor_system_prompt(scenario.world_bible)
|
| 141 |
+
user = _extractor_user_prompt(scenario, game)
|
| 142 |
+
return call_structured(
|
| 143 |
+
schema=MemoryAtomList,
|
| 144 |
+
system=system,
|
| 145 |
+
user=user,
|
| 146 |
+
role="evaluator",
|
| 147 |
+
temperature=0.4,
|
| 148 |
+
max_tokens=4000,
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ---------------------------------------------------------------------------
|
| 153 |
+
# Selector — gates promotion to long-term storage
|
| 154 |
+
# ---------------------------------------------------------------------------
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _normalise(text: str) -> str:
|
| 158 |
+
return " ".join(text.strip().casefold().split())
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _overlap_ratio(a: str, b: str) -> float:
|
| 162 |
+
"""Symmetric character-level overlap ratio.
|
| 163 |
+
|
| 164 |
+
Returns the longer-of-the-two substring-match ratio between `a` and `b`,
|
| 165 |
+
using `difflib.SequenceMatcher`. Cheap on the short strings we're
|
| 166 |
+
dealing with (atoms cap at 400 chars).
|
| 167 |
+
"""
|
| 168 |
+
from difflib import SequenceMatcher
|
| 169 |
+
|
| 170 |
+
return SequenceMatcher(None, _normalise(a), _normalise(b)).ratio()
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _is_duplicate(candidate: MemoryAtom, existing: list[MemoryAtom]) -> bool:
|
| 174 |
+
cand_norm = _normalise(candidate.insight)
|
| 175 |
+
for prev in existing:
|
| 176 |
+
prev_norm = _normalise(prev.insight)
|
| 177 |
+
# Substring containment counts as duplicate at any length — guards
|
| 178 |
+
# against an extractor that re-emits an existing atom with extra
|
| 179 |
+
# qualifying clauses bolted on either end.
|
| 180 |
+
if cand_norm in prev_norm or prev_norm in cand_norm:
|
| 181 |
+
return True
|
| 182 |
+
if _overlap_ratio(candidate.insight, prev.insight) > DEDUP_OVERLAP_THRESHOLD:
|
| 183 |
+
return True
|
| 184 |
+
return False
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def select_atoms(
|
| 188 |
+
candidates: MemoryAtomList,
|
| 189 |
+
*,
|
| 190 |
+
existing: list[MemoryAtom],
|
| 191 |
+
) -> list[MemoryAtom]:
|
| 192 |
+
"""Filter `candidates` against the selector rules.
|
| 193 |
+
|
| 194 |
+
Drops `SPECULATIVE` atoms, drops duplicates of `existing` atoms, and
|
| 195 |
+
drops new candidates that duplicate one another (so a single extractor
|
| 196 |
+
call that emits two near-identical atoms only persists one).
|
| 197 |
+
"""
|
| 198 |
+
kept: list[MemoryAtom] = []
|
| 199 |
+
for atom in candidates.atoms:
|
| 200 |
+
if atom.confidence == MemoryConfidence.SPECULATIVE:
|
| 201 |
+
log.info(
|
| 202 |
+
"Memory selector: dropping speculative atom %r", atom.insight[:60]
|
| 203 |
+
)
|
| 204 |
+
continue
|
| 205 |
+
if _is_duplicate(atom, existing):
|
| 206 |
+
log.info(
|
| 207 |
+
"Memory selector: dropping duplicate atom %r", atom.insight[:60]
|
| 208 |
+
)
|
| 209 |
+
continue
|
| 210 |
+
if _is_duplicate(atom, kept):
|
| 211 |
+
log.info(
|
| 212 |
+
"Memory selector: dropping intra-batch duplicate %r",
|
| 213 |
+
atom.insight[:60],
|
| 214 |
+
)
|
| 215 |
+
continue
|
| 216 |
+
kept.append(atom)
|
| 217 |
+
return kept
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _enforce_cap(scenario_id: str) -> None:
|
| 221 |
+
"""Evict oldest atoms by `created_at` when over the per-scenario cap."""
|
| 222 |
+
atoms = store._read_atoms(scenario_id) # noqa: SLF001 — package-private helper
|
| 223 |
+
if len(atoms) <= MAX_ATOMS_PER_SCENARIO:
|
| 224 |
+
return
|
| 225 |
+
atoms.sort(key=lambda a: a.created_at)
|
| 226 |
+
# Drop the oldest until we're back at the cap.
|
| 227 |
+
surviving = atoms[len(atoms) - MAX_ATOMS_PER_SCENARIO :]
|
| 228 |
+
store._write_atoms(scenario_id, surviving) # noqa: SLF001
|
| 229 |
+
log.info(
|
| 230 |
+
"Memory: evicted %d oldest atoms for scenario %s to enforce 50-atom cap.",
|
| 231 |
+
len(atoms) - len(surviving),
|
| 232 |
+
scenario_id,
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ---------------------------------------------------------------------------
|
| 237 |
+
# Public entry point — wired into `loop.advance` on game completion
|
| 238 |
+
# ---------------------------------------------------------------------------
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def extract_and_persist(scenario: ScenarioConfig, game: Game) -> list[MemoryAtom]:
|
| 242 |
+
"""Run the extractor + selector + persistence pipeline.
|
| 243 |
+
|
| 244 |
+
Idempotent in the sense that running it twice on the same finished game
|
| 245 |
+
just hits the dedupe path on the second call. Returns the atoms that
|
| 246 |
+
landed on disk.
|
| 247 |
+
"""
|
| 248 |
+
candidates = extract_atoms(scenario, game)
|
| 249 |
+
existing = store._read_atoms(scenario.scenario_id) # noqa: SLF001
|
| 250 |
+
selected = select_atoms(candidates, existing=existing)
|
| 251 |
+
for atom in selected:
|
| 252 |
+
store.add_atom(atom)
|
| 253 |
+
_enforce_cap(scenario.scenario_id)
|
| 254 |
+
return selected
|
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JSON-per-scenario episodic memory store.
|
| 2 |
+
|
| 3 |
+
One file per scenario at `{games_dir}/memory/{scenario_id}.json`, schema:
|
| 4 |
+
`{"atoms": [MemoryAtom, ...]}`. The store does serialised read-modify-write
|
| 5 |
+
file IO — sufficient for the single-CLI / single-uvicorn-worker MVP. If we
|
| 6 |
+
later need true concurrent writers we'll layer a per-scenario file lock on
|
| 7 |
+
top, but threading the same game across processes is out of scope today.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Iterable
|
| 16 |
+
|
| 17 |
+
from pydantic import ValidationError
|
| 18 |
+
|
| 19 |
+
from wargame.config import settings
|
| 20 |
+
from wargame.engine.state import MemoryAtom, MemoryConfidence
|
| 21 |
+
|
| 22 |
+
log = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# Confidence ordering used to filter `list_atoms`: a query asking for the
|
| 26 |
+
# minimum confidence `PLAUSIBLE` returns CONFIRMED + PLAUSIBLE atoms.
|
| 27 |
+
_CONFIDENCE_RANK: dict[MemoryConfidence, int] = {
|
| 28 |
+
MemoryConfidence.SPECULATIVE: 0,
|
| 29 |
+
MemoryConfidence.PLAUSIBLE: 1,
|
| 30 |
+
MemoryConfidence.CONFIRMED: 2,
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _memory_dir() -> Path:
|
| 35 |
+
d = settings().games_dir / "memory"
|
| 36 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
return d
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def memory_path(scenario_id: str) -> Path:
|
| 41 |
+
"""Resolve the JSON file backing memory for `scenario_id`."""
|
| 42 |
+
return _memory_dir() / f"{scenario_id}.json"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _read_atoms(scenario_id: str) -> list[MemoryAtom]:
|
| 46 |
+
path = memory_path(scenario_id)
|
| 47 |
+
if not path.exists():
|
| 48 |
+
return []
|
| 49 |
+
try:
|
| 50 |
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
| 51 |
+
except json.JSONDecodeError:
|
| 52 |
+
log.warning("Memory file for %s is corrupt; treating as empty.", scenario_id)
|
| 53 |
+
return []
|
| 54 |
+
out: list[MemoryAtom] = []
|
| 55 |
+
for item in raw.get("atoms", []):
|
| 56 |
+
try:
|
| 57 |
+
out.append(MemoryAtom.model_validate(item))
|
| 58 |
+
except ValidationError as e:
|
| 59 |
+
# Skip individually malformed atoms rather than failing the whole
|
| 60 |
+
# store — episodic memory is auxiliary, never load-bearing.
|
| 61 |
+
log.warning("Dropping malformed memory atom in %s: %s", scenario_id, e)
|
| 62 |
+
return out
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _write_atoms(scenario_id: str, atoms: Iterable[MemoryAtom]) -> None:
|
| 66 |
+
path = memory_path(scenario_id)
|
| 67 |
+
payload = {"atoms": [a.model_dump() for a in atoms]}
|
| 68 |
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def list_atoms(
|
| 72 |
+
scenario_id: str,
|
| 73 |
+
*,
|
| 74 |
+
min_confidence: MemoryConfidence = MemoryConfidence.PLAUSIBLE,
|
| 75 |
+
) -> list[MemoryAtom]:
|
| 76 |
+
"""Return atoms for `scenario_id` at or above `min_confidence`.
|
| 77 |
+
|
| 78 |
+
Default `PLAUSIBLE` filter drops `SPECULATIVE` atoms — those should never
|
| 79 |
+
have made it past the selector, but keeping the filter here is the
|
| 80 |
+
defence-in-depth backstop against a corrupt JSON file.
|
| 81 |
+
"""
|
| 82 |
+
threshold = _CONFIDENCE_RANK[min_confidence]
|
| 83 |
+
return [
|
| 84 |
+
a
|
| 85 |
+
for a in _read_atoms(scenario_id)
|
| 86 |
+
if _CONFIDENCE_RANK[a.confidence] >= threshold
|
| 87 |
+
]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def add_atom(atom: MemoryAtom) -> None:
|
| 91 |
+
"""Append `atom` to the scenario's memory file. Dedupes on exact insight match.
|
| 92 |
+
|
| 93 |
+
The selector layer is responsible for the >70% substring dedupe; this
|
| 94 |
+
function only catches identical inserts so two extractor runs that
|
| 95 |
+
coincidentally produce the same string don't double-store.
|
| 96 |
+
"""
|
| 97 |
+
existing = _read_atoms(atom.scenario_id)
|
| 98 |
+
insight_norm = atom.insight.strip().casefold()
|
| 99 |
+
for prev in existing:
|
| 100 |
+
if prev.insight.strip().casefold() == insight_norm:
|
| 101 |
+
return
|
| 102 |
+
existing.append(atom)
|
| 103 |
+
_write_atoms(atom.scenario_id, existing)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def remove_atom(scenario_id: str, atom_id: str) -> bool:
|
| 107 |
+
"""Remove the atom with `atom_id` if present; return True iff one was removed."""
|
| 108 |
+
existing = _read_atoms(scenario_id)
|
| 109 |
+
filtered = [a for a in existing if a.id != atom_id]
|
| 110 |
+
if len(filtered) == len(existing):
|
| 111 |
+
return False
|
| 112 |
+
_write_atoms(scenario_id, filtered)
|
| 113 |
+
return True
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def clear(scenario_id: str) -> int:
|
| 117 |
+
"""Remove every atom for `scenario_id`. Returns the number removed."""
|
| 118 |
+
existing = _read_atoms(scenario_id)
|
| 119 |
+
if not existing:
|
| 120 |
+
# Still write an empty file so callers get a stable on-disk shape.
|
| 121 |
+
_write_atoms(scenario_id, [])
|
| 122 |
+
return 0
|
| 123 |
+
_write_atoms(scenario_id, [])
|
| 124 |
+
return len(existing)
|
|
@@ -698,6 +698,51 @@ def _fake_hard_constraint_list() -> dict[str, Any]:
|
|
| 698 |
}
|
| 699 |
|
| 700 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 701 |
def _fake_scenario_map_synthesis() -> dict[str, Any]:
|
| 702 |
return {
|
| 703 |
"markdown": (
|
|
@@ -761,6 +806,8 @@ def _dispatch(schema_name: str, user: str) -> Any:
|
|
| 761 |
return _fake_constraint_check_result()
|
| 762 |
if schema_name == "HardConstraintList":
|
| 763 |
return _fake_hard_constraint_list()
|
|
|
|
|
|
|
| 764 |
raise AssertionError(f"No fake canned for schema {schema_name!r}")
|
| 765 |
|
| 766 |
|
|
@@ -791,6 +838,7 @@ def fake_llm(monkeypatch: pytest.MonkeyPatch):
|
|
| 791 |
"wargame.agents.critic",
|
| 792 |
"wargame.agents.premortem",
|
| 793 |
"wargame.agents.evaluator",
|
|
|
|
| 794 |
"wargame.scaffold.generator",
|
| 795 |
"wargame.scaffold.setup_agent",
|
| 796 |
"wargame.scaffold.extract_constraints",
|
|
|
|
| 698 |
}
|
| 699 |
|
| 700 |
|
| 701 |
+
def _fake_memory_atom_list(user: str) -> dict[str, Any]:
|
| 702 |
+
"""Two atoms — one CONFIRMED, one PLAUSIBLE — anchored to the fake
|
| 703 |
+
game's scenario id and game id. The user message embeds those values
|
| 704 |
+
via the extractor's prompt; we parse them back so the dispatched
|
| 705 |
+
fixture stays consistent across runs and across scenarios.
|
| 706 |
+
"""
|
| 707 |
+
import re as _re
|
| 708 |
+
|
| 709 |
+
sid_match = _re.search(r"## Scenario:\s*([a-z0-9_]+)", user)
|
| 710 |
+
gid_match = _re.search(r"## Source game id\s*\n\s*\n`([0-9a-f]+)`", user)
|
| 711 |
+
scenario_id = sid_match.group(1) if sid_match else "competitor_disruption"
|
| 712 |
+
game_id = gid_match.group(1) if gid_match else "fake0000fake0000fake0000fake0000"
|
| 713 |
+
return {
|
| 714 |
+
"atoms": [
|
| 715 |
+
{
|
| 716 |
+
"scenario_id": scenario_id,
|
| 717 |
+
"insight": (
|
| 718 |
+
"Bettina Lange the CFO will defect to a public margin-defense "
|
| 719 |
+
"stance once installer-defection passes 7%."
|
| 720 |
+
),
|
| 721 |
+
"rationale": (
|
| 722 |
+
"Turn 2 verdict effect_minor on the CFO's deliberation "
|
| 723 |
+
"matched this read; coach note flagged the alignment."
|
| 724 |
+
),
|
| 725 |
+
"confidence": "confirmed",
|
| 726 |
+
"source_game_id": game_id,
|
| 727 |
+
"source_turn": 2,
|
| 728 |
+
"tags": ["cfo", "installer_channel"],
|
| 729 |
+
},
|
| 730 |
+
{
|
| 731 |
+
"scenario_id": scenario_id,
|
| 732 |
+
"insight": (
|
| 733 |
+
"Aergis Climate counter-prices within 10 days when Norhard "
|
| 734 |
+
"announces a rebate above EUR 5M scope."
|
| 735 |
+
),
|
| 736 |
+
"rationale": "Aergis's revised plan in turns 3-4 implies this cadence.",
|
| 737 |
+
"confidence": "plausible",
|
| 738 |
+
"source_game_id": game_id,
|
| 739 |
+
"source_turn": 3,
|
| 740 |
+
"tags": ["aergis", "pricing"],
|
| 741 |
+
},
|
| 742 |
+
]
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
|
| 746 |
def _fake_scenario_map_synthesis() -> dict[str, Any]:
|
| 747 |
return {
|
| 748 |
"markdown": (
|
|
|
|
| 806 |
return _fake_constraint_check_result()
|
| 807 |
if schema_name == "HardConstraintList":
|
| 808 |
return _fake_hard_constraint_list()
|
| 809 |
+
if schema_name == "MemoryAtomList":
|
| 810 |
+
return _fake_memory_atom_list(user)
|
| 811 |
raise AssertionError(f"No fake canned for schema {schema_name!r}")
|
| 812 |
|
| 813 |
|
|
|
|
| 838 |
"wargame.agents.critic",
|
| 839 |
"wargame.agents.premortem",
|
| 840 |
"wargame.agents.evaluator",
|
| 841 |
+
"wargame.memory.extractor",
|
| 842 |
"wargame.scaffold.generator",
|
| 843 |
"wargame.scaffold.setup_agent",
|
| 844 |
"wargame.scaffold.extract_constraints",
|
|
@@ -749,3 +749,84 @@ def test_sse_stream_route_is_registered(client: TestClient) -> None:
|
|
| 749 |
app = client.app
|
| 750 |
paths = {getattr(r, "path", None) for r in app.routes}
|
| 751 |
assert "/games/{game_id}/stream" in paths
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
app = client.app
|
| 750 |
paths = {getattr(r, "path", None) for r in app.routes}
|
| 751 |
assert "/games/{game_id}/stream" in paths
|
| 752 |
+
|
| 753 |
+
|
| 754 |
+
# ---------------------------------------------------------------------------
|
| 755 |
+
# Episodic memory endpoints (Round 7)
|
| 756 |
+
# ---------------------------------------------------------------------------
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
def test_get_memory_empty(client: TestClient) -> None:
|
| 760 |
+
r = client.get("/scenarios/competitor_disruption/memory")
|
| 761 |
+
assert r.status_code == 200
|
| 762 |
+
assert r.json() == {"scenario_id": "competitor_disruption", "atoms": []}
|
| 763 |
+
|
| 764 |
+
|
| 765 |
+
def test_memory_populates_after_full_game(client: TestClient) -> None:
|
| 766 |
+
"""Round-trip: complete a game via the API; the memory endpoint then lists atoms."""
|
| 767 |
+
action_body = {
|
| 768 |
+
"intent": "Counter-price selectively",
|
| 769 |
+
"justification": "Defend EBIT while stabilizing installer channel.",
|
| 770 |
+
"anchors": [
|
| 771 |
+
{"kind": "prior_analogue", "detail": "2018 KlimaPro price skirmish"},
|
| 772 |
+
{"kind": "stated_capability", "detail": "Finance can model in 48h"},
|
| 773 |
+
{"kind": "resource_committed", "detail": "EUR 2M margin buffer"},
|
| 774 |
+
],
|
| 775 |
+
}
|
| 776 |
+
r = client.post("/games", json={"scenario_id": "competitor_disruption"})
|
| 777 |
+
gid = r.json()["game_id"]
|
| 778 |
+
for _ in range(5):
|
| 779 |
+
client.post(f"/games/{gid}/actions", json=action_body)
|
| 780 |
+
client.post(f"/games/{gid}/advance")
|
| 781 |
+
|
| 782 |
+
r = client.get("/scenarios/competitor_disruption/memory")
|
| 783 |
+
assert r.status_code == 200
|
| 784 |
+
atoms = r.json()["atoms"]
|
| 785 |
+
assert len(atoms) >= 1
|
| 786 |
+
assert any(a["confidence"] == "confirmed" for a in atoms)
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
def test_delete_memory_atom_204(client: TestClient) -> None:
|
| 790 |
+
from wargame.engine.state import MemoryAtom, MemoryConfidence
|
| 791 |
+
from wargame.memory import add_atom, list_atoms
|
| 792 |
+
|
| 793 |
+
atom = MemoryAtom(
|
| 794 |
+
scenario_id="competitor_disruption",
|
| 795 |
+
insight="API delete test atom about Norhard's EBIT posture.",
|
| 796 |
+
rationale="Verdict-backed in turn 2.",
|
| 797 |
+
confidence=MemoryConfidence.CONFIRMED,
|
| 798 |
+
source_game_id="apitestgame00000",
|
| 799 |
+
source_turn=2,
|
| 800 |
+
)
|
| 801 |
+
add_atom(atom)
|
| 802 |
+
assert len(list_atoms("competitor_disruption")) == 1
|
| 803 |
+
|
| 804 |
+
r = client.delete(f"/scenarios/competitor_disruption/memory/{atom.id}")
|
| 805 |
+
assert r.status_code == 204
|
| 806 |
+
assert list_atoms("competitor_disruption") == []
|
| 807 |
+
|
| 808 |
+
|
| 809 |
+
def test_delete_memory_atom_404(client: TestClient) -> None:
|
| 810 |
+
r = client.delete("/scenarios/competitor_disruption/memory/ghostid")
|
| 811 |
+
assert r.status_code == 404
|
| 812 |
+
|
| 813 |
+
|
| 814 |
+
def test_delete_memory_all_returns_count(client: TestClient) -> None:
|
| 815 |
+
from wargame.engine.state import MemoryAtom, MemoryConfidence
|
| 816 |
+
from wargame.memory import add_atom
|
| 817 |
+
|
| 818 |
+
for i in range(3):
|
| 819 |
+
add_atom(
|
| 820 |
+
MemoryAtom(
|
| 821 |
+
scenario_id="competitor_disruption",
|
| 822 |
+
insight=f"Bulk delete test atom number {i} with enough chars.",
|
| 823 |
+
rationale="Verdict-backed in turn 2.",
|
| 824 |
+
confidence=MemoryConfidence.CONFIRMED,
|
| 825 |
+
source_game_id=f"apitest{i:08d}0",
|
| 826 |
+
source_turn=2,
|
| 827 |
+
)
|
| 828 |
+
)
|
| 829 |
+
|
| 830 |
+
r = client.delete("/scenarios/competitor_disruption/memory")
|
| 831 |
+
assert r.status_code == 200
|
| 832 |
+
assert r.json() == {"scenario_id": "competitor_disruption", "cleared": 3}
|
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Episodic memory tests — store round-trip, selector gating, briefing injection.
|
| 2 |
+
|
| 3 |
+
The memory layer is auxiliary by design (a failed extractor must never break
|
| 4 |
+
the debrief), so each test exercises one surface and asserts the failure
|
| 5 |
+
isolation property where it applies.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
from wargame.engine import loop
|
| 13 |
+
from wargame.engine.state import (
|
| 14 |
+
ActionSubmission,
|
| 15 |
+
Anchor,
|
| 16 |
+
AnchorKind,
|
| 17 |
+
MemoryAtom,
|
| 18 |
+
MemoryAtomList,
|
| 19 |
+
MemoryConfidence,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _player_action(intent: str = "Counter-price selectively") -> ActionSubmission:
|
| 24 |
+
return ActionSubmission(
|
| 25 |
+
actor="player",
|
| 26 |
+
intent=intent,
|
| 27 |
+
justification="Defend EBIT while stabilizing installer channel.",
|
| 28 |
+
anchors=[
|
| 29 |
+
Anchor(kind=AnchorKind.PRIOR_ANALOGUE, detail="2018 KlimaPro price skirmish"),
|
| 30 |
+
Anchor(kind=AnchorKind.STATED_CAPABILITY, detail="Finance can model in 48h"),
|
| 31 |
+
Anchor(kind=AnchorKind.RESOURCE_COMMITTED, detail="EUR 2M margin buffer"),
|
| 32 |
+
],
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _make_atom(
|
| 37 |
+
*,
|
| 38 |
+
insight: str,
|
| 39 |
+
confidence: MemoryConfidence = MemoryConfidence.CONFIRMED,
|
| 40 |
+
scenario_id: str = "competitor_disruption",
|
| 41 |
+
source_game_id: str = "fakegame00000000",
|
| 42 |
+
source_turn: int | None = 2,
|
| 43 |
+
created_at: str | None = None,
|
| 44 |
+
) -> MemoryAtom:
|
| 45 |
+
atom = MemoryAtom(
|
| 46 |
+
scenario_id=scenario_id,
|
| 47 |
+
insight=insight,
|
| 48 |
+
rationale="Verdict on turn 2 backed this read.",
|
| 49 |
+
confidence=confidence,
|
| 50 |
+
source_game_id=source_game_id,
|
| 51 |
+
source_turn=source_turn,
|
| 52 |
+
)
|
| 53 |
+
if created_at is not None:
|
| 54 |
+
atom = atom.model_copy(update={"created_at": created_at})
|
| 55 |
+
return atom
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# Store round-trip
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class TestStoreRoundtrip:
|
| 64 |
+
def test_list_returns_empty_when_no_file(self, fake_llm) -> None:
|
| 65 |
+
from wargame.memory import list_atoms
|
| 66 |
+
|
| 67 |
+
assert list_atoms("competitor_disruption") == []
|
| 68 |
+
|
| 69 |
+
def test_add_then_list_returns_atom(self, fake_llm) -> None:
|
| 70 |
+
from wargame.memory import add_atom, list_atoms
|
| 71 |
+
|
| 72 |
+
atom = _make_atom(insight="Bettina Lange holds the EBIT line under 9%.")
|
| 73 |
+
add_atom(atom)
|
| 74 |
+
result = list_atoms("competitor_disruption")
|
| 75 |
+
assert len(result) == 1
|
| 76 |
+
assert result[0].id == atom.id
|
| 77 |
+
assert result[0].insight == atom.insight
|
| 78 |
+
|
| 79 |
+
def test_add_dedupes_exact_match(self, fake_llm) -> None:
|
| 80 |
+
from wargame.memory import add_atom, list_atoms
|
| 81 |
+
|
| 82 |
+
atom1 = _make_atom(insight="Identical insight string here is enough.")
|
| 83 |
+
# Two distinct atom instances with the same insight string.
|
| 84 |
+
atom2 = _make_atom(insight="Identical insight string here is enough.")
|
| 85 |
+
add_atom(atom1)
|
| 86 |
+
add_atom(atom2)
|
| 87 |
+
atoms = list_atoms("competitor_disruption")
|
| 88 |
+
assert len(atoms) == 1
|
| 89 |
+
assert atoms[0].id == atom1.id
|
| 90 |
+
|
| 91 |
+
def test_remove_returns_true_when_found(self, fake_llm) -> None:
|
| 92 |
+
from wargame.memory import add_atom, list_atoms, remove_atom
|
| 93 |
+
|
| 94 |
+
atom = _make_atom(insight="Aergis counter-prices within ten days when threatened.")
|
| 95 |
+
add_atom(atom)
|
| 96 |
+
assert remove_atom("competitor_disruption", atom.id) is True
|
| 97 |
+
assert list_atoms("competitor_disruption") == []
|
| 98 |
+
|
| 99 |
+
def test_remove_returns_false_when_missing(self, fake_llm) -> None:
|
| 100 |
+
from wargame.memory import remove_atom
|
| 101 |
+
|
| 102 |
+
assert remove_atom("competitor_disruption", "ghostid") is False
|
| 103 |
+
|
| 104 |
+
def test_clear_returns_count(self, fake_llm) -> None:
|
| 105 |
+
from wargame.memory import add_atom, clear, list_atoms
|
| 106 |
+
|
| 107 |
+
for i in range(3):
|
| 108 |
+
add_atom(_make_atom(insight=f"Insight number {i} carries enough text."))
|
| 109 |
+
cleared = clear("competitor_disruption")
|
| 110 |
+
assert cleared == 3
|
| 111 |
+
assert list_atoms("competitor_disruption") == []
|
| 112 |
+
|
| 113 |
+
def test_list_min_confidence_drops_speculative(self, fake_llm) -> None:
|
| 114 |
+
# Speculative atoms can land in the file via direct edits even though
|
| 115 |
+
# the selector normally filters them — assert the read filter holds.
|
| 116 |
+
from wargame.memory import store
|
| 117 |
+
|
| 118 |
+
confirmed = _make_atom(
|
| 119 |
+
insight="Confirmed insight that survives the read filter.",
|
| 120 |
+
confidence=MemoryConfidence.CONFIRMED,
|
| 121 |
+
)
|
| 122 |
+
speculative = _make_atom(
|
| 123 |
+
insight="Speculative insight that should be filtered out.",
|
| 124 |
+
confidence=MemoryConfidence.SPECULATIVE,
|
| 125 |
+
)
|
| 126 |
+
store._write_atoms("competitor_disruption", [confirmed, speculative])
|
| 127 |
+
from wargame.memory import list_atoms
|
| 128 |
+
|
| 129 |
+
atoms = list_atoms("competitor_disruption")
|
| 130 |
+
assert len(atoms) == 1
|
| 131 |
+
assert atoms[0].confidence == MemoryConfidence.CONFIRMED
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ---------------------------------------------------------------------------
|
| 135 |
+
# Selector
|
| 136 |
+
# ---------------------------------------------------------------------------
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class TestSelector:
|
| 140 |
+
def test_selector_drops_speculative_keeps_confirmed_and_plausible(
|
| 141 |
+
self, fake_llm
|
| 142 |
+
) -> None:
|
| 143 |
+
from wargame.memory.extractor import select_atoms
|
| 144 |
+
|
| 145 |
+
candidates = MemoryAtomList(
|
| 146 |
+
atoms=[
|
| 147 |
+
_make_atom(
|
| 148 |
+
insight="Confirmed atom A about the CFO's posture.",
|
| 149 |
+
confidence=MemoryConfidence.CONFIRMED,
|
| 150 |
+
),
|
| 151 |
+
_make_atom(
|
| 152 |
+
insight="Plausible atom B about Aergis's pricing cadence.",
|
| 153 |
+
confidence=MemoryConfidence.PLAUSIBLE,
|
| 154 |
+
),
|
| 155 |
+
_make_atom(
|
| 156 |
+
insight="Speculative atom C with no real grounding here.",
|
| 157 |
+
confidence=MemoryConfidence.SPECULATIVE,
|
| 158 |
+
),
|
| 159 |
+
]
|
| 160 |
+
)
|
| 161 |
+
kept = select_atoms(candidates, existing=[])
|
| 162 |
+
assert len(kept) == 2
|
| 163 |
+
assert all(a.confidence != MemoryConfidence.SPECULATIVE for a in kept)
|
| 164 |
+
|
| 165 |
+
def test_selector_dedupes_against_existing(self, fake_llm) -> None:
|
| 166 |
+
from wargame.memory.extractor import select_atoms
|
| 167 |
+
|
| 168 |
+
existing = [
|
| 169 |
+
_make_atom(
|
| 170 |
+
insight=(
|
| 171 |
+
"Bettina Lange holds the EBIT line above 9% throughout "
|
| 172 |
+
"the quarter when challenged."
|
| 173 |
+
)
|
| 174 |
+
)
|
| 175 |
+
]
|
| 176 |
+
candidates = MemoryAtomList(
|
| 177 |
+
atoms=[
|
| 178 |
+
# Near-duplicate of the existing atom — should be dropped.
|
| 179 |
+
_make_atom(
|
| 180 |
+
insight=(
|
| 181 |
+
"Bettina Lange holds the EBIT line above 9% during "
|
| 182 |
+
"the quarter when challenged."
|
| 183 |
+
)
|
| 184 |
+
),
|
| 185 |
+
# Genuinely different insight — should be kept.
|
| 186 |
+
_make_atom(
|
| 187 |
+
insight=(
|
| 188 |
+
"Aergis Climate accelerates its FSR-exposed PR moves "
|
| 189 |
+
"when Norhard goes silent."
|
| 190 |
+
)
|
| 191 |
+
),
|
| 192 |
+
]
|
| 193 |
+
)
|
| 194 |
+
kept = select_atoms(candidates, existing=existing)
|
| 195 |
+
assert len(kept) == 1
|
| 196 |
+
assert "Aergis Climate" in kept[0].insight
|
| 197 |
+
|
| 198 |
+
def test_selector_caps_at_50_evicting_oldest(self, fake_llm) -> None:
|
| 199 |
+
"""Fill the store past 50 atoms and confirm the cap evicts oldest."""
|
| 200 |
+
from wargame.memory import list_atoms, store
|
| 201 |
+
from wargame.memory.extractor import _enforce_cap
|
| 202 |
+
|
| 203 |
+
# Seed 60 atoms with monotonically increasing created_at so the
|
| 204 |
+
# eviction order is deterministic.
|
| 205 |
+
atoms = []
|
| 206 |
+
for i in range(60):
|
| 207 |
+
atoms.append(
|
| 208 |
+
_make_atom(
|
| 209 |
+
insight=f"Atom number {i:02d} with enough characters to validate.",
|
| 210 |
+
created_at=f"2026-01-{(i % 28) + 1:02d}T00:{i:02d}:00+00:00",
|
| 211 |
+
)
|
| 212 |
+
)
|
| 213 |
+
# Sort to make the oldest deterministic.
|
| 214 |
+
atoms.sort(key=lambda a: a.created_at)
|
| 215 |
+
store._write_atoms("competitor_disruption", atoms)
|
| 216 |
+
|
| 217 |
+
_enforce_cap("competitor_disruption")
|
| 218 |
+
survived = list_atoms("competitor_disruption")
|
| 219 |
+
assert len(survived) == 50
|
| 220 |
+
# The oldest 10 should be gone.
|
| 221 |
+
survived_insights = {a.insight for a in survived}
|
| 222 |
+
for old in atoms[:10]:
|
| 223 |
+
assert old.insight not in survived_insights
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ---------------------------------------------------------------------------
|
| 227 |
+
# End-to-end: completing a game persists atoms; next game's briefing picks them up
|
| 228 |
+
# ---------------------------------------------------------------------------
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _play_full_game(scenario_id: str = "competitor_disruption") -> None:
|
| 232 |
+
"""Run a full 5-turn game so the memory extractor fires on completion."""
|
| 233 |
+
game = loop.new_game(scenario_id)
|
| 234 |
+
for turn in range(1, game.world.max_turns + 1):
|
| 235 |
+
loop.act(game, _player_action(f"Turn {turn} action"))
|
| 236 |
+
game = loop.advance(game)
|
| 237 |
+
assert game.status == "complete"
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
class TestEndToEnd:
|
| 241 |
+
def test_completing_a_game_persists_memory_atoms(self, fake_llm) -> None:
|
| 242 |
+
from wargame.memory import list_atoms
|
| 243 |
+
|
| 244 |
+
_play_full_game()
|
| 245 |
+
atoms = list_atoms("competitor_disruption")
|
| 246 |
+
assert len(atoms) >= 1
|
| 247 |
+
# The fake extractor emits one CONFIRMED and one PLAUSIBLE.
|
| 248 |
+
confidences = {a.confidence for a in atoms}
|
| 249 |
+
assert MemoryConfidence.CONFIRMED in confidences
|
| 250 |
+
|
| 251 |
+
def test_next_game_briefing_includes_memory_block(self, fake_llm) -> None:
|
| 252 |
+
from wargame.agents.control import _render_memory_block
|
| 253 |
+
from wargame.memory import list_atoms
|
| 254 |
+
|
| 255 |
+
_play_full_game()
|
| 256 |
+
# The injector's helper is the contract surface; ensure it produces
|
| 257 |
+
# a non-empty block when atoms are on file.
|
| 258 |
+
atoms = list_atoms("competitor_disruption")
|
| 259 |
+
block = _render_memory_block(atoms[:5])
|
| 260 |
+
assert "Memory of past games" in block
|
| 261 |
+
assert "treat as priors" in block.lower()
|
| 262 |
+
# Confidence label must appear inline so Control sees the gate.
|
| 263 |
+
assert "[confidence:" in block
|
| 264 |
+
|
| 265 |
+
def test_render_memory_block_empty_when_no_atoms(self, fake_llm) -> None:
|
| 266 |
+
from wargame.agents.control import _render_memory_block
|
| 267 |
+
|
| 268 |
+
assert _render_memory_block([]) == ""
|
| 269 |
+
|
| 270 |
+
def test_extractor_failure_does_not_break_debrief(
|
| 271 |
+
self, fake_llm, monkeypatch: pytest.MonkeyPatch
|
| 272 |
+
) -> None:
|
| 273 |
+
"""Memory must NEVER break the player-visible flow."""
|
| 274 |
+
from wargame.memory import extractor
|
| 275 |
+
|
| 276 |
+
def _exploding_extractor(scenario, game):
|
| 277 |
+
raise RuntimeError("Simulated extractor failure")
|
| 278 |
+
|
| 279 |
+
monkeypatch.setattr(
|
| 280 |
+
extractor, "extract_and_persist", _exploding_extractor
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
# The full game still has to complete cleanly.
|
| 284 |
+
_play_full_game()
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
# ---------------------------------------------------------------------------
|
| 288 |
+
# Briefing system prompt is cache-stable across memory injections
|
| 289 |
+
# ---------------------------------------------------------------------------
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def test_memory_injection_does_not_touch_system_prompt(
|
| 293 |
+
fake_llm, monkeypatch: pytest.MonkeyPatch
|
| 294 |
+
) -> None:
|
| 295 |
+
"""Cache stability — atoms must land in the user message, not the system."""
|
| 296 |
+
from wargame.agents import control as control_mod
|
| 297 |
+
from wargame.memory import add_atom
|
| 298 |
+
|
| 299 |
+
captured: dict[str, str] = {}
|
| 300 |
+
|
| 301 |
+
real_call = control_mod.call_structured
|
| 302 |
+
|
| 303 |
+
def _capture(*, schema, system, user, role="agent", temperature=0.7, max_tokens=2000):
|
| 304 |
+
# Capture only the FIRST call — that's the briefing call we care about.
|
| 305 |
+
captured.setdefault("system", system)
|
| 306 |
+
captured.setdefault("user", user)
|
| 307 |
+
return real_call(
|
| 308 |
+
schema=schema,
|
| 309 |
+
system=system,
|
| 310 |
+
user=user,
|
| 311 |
+
role=role,
|
| 312 |
+
temperature=temperature,
|
| 313 |
+
max_tokens=max_tokens,
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
monkeypatch.setattr(control_mod, "call_structured", _capture)
|
| 317 |
+
|
| 318 |
+
# Seed a memory atom for the scenario before starting the game.
|
| 319 |
+
add_atom(
|
| 320 |
+
_make_atom(
|
| 321 |
+
insight=(
|
| 322 |
+
"Sentinel atom about Norhard Thermal AG that must not "
|
| 323 |
+
"land in the system prompt of the briefing."
|
| 324 |
+
)
|
| 325 |
+
)
|
| 326 |
+
)
|
| 327 |
+
loop.new_game("competitor_disruption")
|
| 328 |
+
|
| 329 |
+
assert "system" in captured and "user" in captured
|
| 330 |
+
assert "Sentinel atom about Norhard Thermal AG" not in captured["system"]
|
| 331 |
+
assert "Sentinel atom about Norhard Thermal AG" in captured["user"]
|