misack Claude Opus 4.7 (1M context) commited on
Commit
b6f158e
·
1 Parent(s): bd956f0

Round 7: episodic memory experiment (per-scenario atoms)

Browse files

Minimum-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 CHANGED
@@ -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
wargame/agents/control.py CHANGED
@@ -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"
wargame/api/routes.py CHANGED
@@ -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)
wargame/api/schemas.py CHANGED
@@ -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
wargame/cli.py CHANGED
@@ -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()
wargame/engine/loop.py CHANGED
@@ -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).
wargame/engine/state.py CHANGED
@@ -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
  # ---------------------------------------------------------------------------
wargame/memory/__init__.py ADDED
@@ -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
+ ]
wargame/memory/extractor.py ADDED
@@ -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
wargame/memory/store.py ADDED
@@ -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)
wargame/tests/conftest.py CHANGED
@@ -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",
wargame/tests/test_api.py CHANGED
@@ -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}
wargame/tests/test_memory.py ADDED
@@ -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"]