Files
ctan 9da9537995 feat(phase2): compact observation view for LLM prompts
Build a decision-sufficient ObservationView from the raw snapshot and
render it as a deterministic line-based prompt string. The raw to_dict()
is ~64 KB (~16k tokens) for a 7-combatant encounter; the compact view
renders at ~15.7 KB (~3.9k tokens, 24% of raw), under the 4k-token
ceiling of the phase-2 architecture doc.

Compaction: sparse terrain/elevation/threatened grids, per-combatant
key selection (occupied_cells, abilities, skills, initiative_mod/total,
temp_hp_rounds_left, features, carried_items, weapon_damage, aware,
alignment, armor_spell_failure, arcane_caster, run_rounds dropped),
attacks compacted to formula+type damage strings, spells to 7
decision-relevant fields, pair target_index replaced by the stable
combatant id, condition_definitions dropped. Attacks and spells render
as positional tuples declared by attack_fields/spell_fields legend
lines, with '-' for missing values.

Tests pin the size bounds (compact < 0.25 x raw and < 16000 bytes), the
sparse-grid contract, the dropped-key contract and the deterministic
id/render contract on a caster-heavy 3v4 representative battle.
2026-09-21 13:21:33 +02:00

280 lines
9.5 KiB
Python

"""Phase-2 compact observation view (docs/phase-2-llm-architecture.md P0.2).
``llm_view.build_view`` + ``render`` turn the raw ``Observation`` snapshot
into a compact, deterministic text view for the LLM prompt. This module
measures the compaction on a representative caster-heavy encounter (same
shape as the measurement spike, but with its OWN spec builder - the spike
file is out of scope), prints a summary block for the orchestrator, and
pins LOOSE upper bounds so a regression (a dropped key re-added, a sparse
grid turned dense, ...) fails loudly. It also pins the structural contract:
sparse grids only carry non-default cells, dropped keys stay dropped, the
nested attack/spell expansions stay compact, and the render is
deterministic.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from pf1e_simulator.combat import (
OBSERVATION_SCHEMA_VERSION,
Action,
CombatantState,
CombatEngine,
Observation,
default_policy,
)
from pf1e_simulator.grid import Grid
from pf1e_simulator.llm_view import build_view, render
from pf1e_simulator.loaders import load_combatant
from pf1e_simulator.map import load_map
from pf1e_simulator.rng import SeededRng
from pf1e_simulator.runner import EncounterSpec, Side, build_states
from pf1e_simulator.spells import SPELLS_DIR, SpellSpec, load_spell_registry
if TYPE_CHECKING:
import pytest
from pf1e_simulator.models import Combatant
ROOT = Path(__file__).resolve().parents[1]
MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
# Loose ceilings (regression guards, not exact pins): the docs target a
# compact view of ~2-4k tokens against a raw snapshot of ~16k tokens, i.e.
# 12.5-25% of the raw size. 25% of raw bytes and a 4k-token byte ceiling
# (4 bytes/token) are the outer bounds.
MAX_COMPACT_RATIO = 0.25
MAX_COMPACT_BYTES = 16_000
# Keys the compact view deliberately drops from each combatant snapshot.
_DROPPED_COMBATANT_KEYS = frozenset(
{
"occupied_cells",
"abilities",
"skills",
"initiative_mod",
"initiative_total",
"temp_hp_rounds_left",
"features",
"feature_details",
"carried_items",
"weapon_damage",
"aware",
"alignment",
"armor_spell_failure",
"arcane_caster",
"run_rounds",
}
)
# Every pair key must survive (the pairs are the tactical core); the raw
# ``target_index`` is replaced by the stable combatant ``target`` id.
_PAIR_KEYS = frozenset(
{
"target",
"distance_ft",
"weapon_for",
"weapon_reason",
"range_penalty",
"can_charge",
"charge_reason",
"can_step5_to_attack",
"has_line_of_effect",
"flanking",
"movement_cost_ft",
"target_threatens_me",
"enemies_threatening_me",
"cover_bonus",
}
)
# Keys dropped from the nested attack/spell expansions.
_ATTACK_DROPPED_KEYS = frozenset({"splash", "hardness", "hit_points"})
_SPELL_DROPPED_KEYS = frozenset(
{"summary", "school", "half_on_save", "save_negates", "harmless", "components"}
)
class _Capture:
"""Policy wrapper that snapshots the first observation, then delegates."""
def __init__(self) -> None:
self.observation: Observation | None = None
def policy(self, engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if self.observation is None:
self.observation = engine._build_observation(state)
return default_policy(engine, state)
def _c(path: str) -> Combatant:
return load_combatant(ROOT / "data" / path)
def _representative_spec() -> EncounterSpec:
"""Caster-heavy 3v4 on the sample arena (walls, rubble, water, pillars)."""
return EncounterSpec(
map=load_map(MAP),
round_cap=20,
sides=(
Side(
name="players",
combatants=(_c("pcs/oni.json"), _c("pcs/misty.json"), _c("pcs/nairda.json")),
zone="A",
),
Side(
name="monsters",
combatants=(
_c("monsters/lich.json"),
_c("monsters/thrune_agent.json"),
_c("monsters/orc.json"),
_c("monsters/goblin.json"),
),
zone="B",
),
),
)
def _first_observation(
spec: EncounterSpec, seed: int, registry: dict[str, SpellSpec]
) -> Observation:
grid = Grid.from_spec(spec.map)
states = build_states(spec)
cap = _Capture()
CombatEngine(
SeededRng(seed),
grid,
states,
round_cap=spec.round_cap,
policy=cap.policy,
spell_registry=registry,
).run()
assert cap.observation is not None
return cap.observation
def _emit(line: str) -> None:
sys.stdout.write(line + "\n")
def _is_default_terrain(semantics: dict[str, object]) -> bool:
"""Mirror of ``llm_view._is_default_terrain`` for the structural test."""
return (
semantics.get("move_cost") == 1
and not semantics.get("blocks_los")
and not semantics.get("cover")
and semantics.get("cover_bonus") == 0
)
def test_compact_view_size(capsys: pytest.CaptureFixture[str]) -> None:
"""Measure raw-vs-compact on the representative battle; pin loose bounds."""
registry = load_spell_registry(SPELLS_DIR)
obs = _first_observation(_representative_spec(), seed=1, registry=registry)
raw = json.dumps(obs.to_dict())
view = build_view(obs)
compact = render(view)
raw_bytes = len(raw)
compact_bytes = len(compact)
# Determinism: rebuilding the view from the same observation renders
# byte-identically.
assert render(build_view(obs)) == compact
# Schema version is pinned to the engine's contract.
assert view.schema_version == OBSERVATION_SCHEMA_VERSION
# Loose bounds (regression guards, not exact pins).
assert compact_bytes < raw_bytes * MAX_COMPACT_RATIO
assert compact_bytes < MAX_COMPACT_BYTES
_emit("=== PHASE2 SUMMARY (compact observation view) ===")
_emit("encounter: 7 combatants, round_cap=20, seed=1")
_emit(f"raw observation bytes: {raw_bytes} (~{raw_bytes // 4} tokens)")
_emit(f"compact view bytes: {compact_bytes} (~{compact_bytes // 4} tokens)")
_emit(f"compaction ratio: {compact_bytes / raw_bytes:.1%} of raw")
_emit(f"combatants in view: {len(view.combatants)}")
_emit(f"pairs in view: {len(view.pairs)}")
_emit(f"sparse terrain cells: {len(view.terrain)}")
_emit(f"sparse elevation cells: {len(view.elevation)}")
_emit(f"threatened cells: {len(view.threatened)}")
_emit(f"ranged_threatened cells: {len(view.ranged_threatened)}")
out = capsys.readouterr().out
assert "PHASE2 SUMMARY" in out
def test_view_sparse_grids() -> None:
"""Pin the sparse-grid contract: only non-default/non-zero/True cells."""
registry = load_spell_registry(SPELLS_DIR)
obs = _first_observation(_representative_spec(), seed=1, registry=registry)
raw = obs.to_dict()
view = build_view(obs)
semantics_by_type = {str(sem["type"]): sem for sem in raw["terrain_semantics"]}
# Dimensions match the raw terrain grid.
assert view.dimensions == (len(raw["terrain"]), len(raw["terrain"][0]))
# Sparse terrain only carries non-default cells, with the raw char.
for r, c, terrain_char in view.terrain:
assert not _is_default_terrain(semantics_by_type[terrain_char])
assert raw["terrain"][r][c] == terrain_char
# Sparse elevation only carries non-zero cells.
for r, c, height in view.elevation:
assert height != 0
assert raw["elevation"][r][c] == height
# Sparse threatened grids only carry True cells.
for r, c in view.threatened:
assert raw["threatened"][r][c] is True
for r, c in view.ranged_threatened:
assert raw["ranged_threatened"][r][c] is True
def test_view_dropped_keys() -> None:
"""Pin the key contract: dropped keys stay dropped, pair keys survive."""
registry = load_spell_registry(SPELLS_DIR)
obs = _first_observation(_representative_spec(), seed=1, registry=registry)
view = build_view(obs)
for combatant in view.combatants:
assert _DROPPED_COMBATANT_KEYS.isdisjoint(combatant.keys())
for pair in view.pairs:
assert _PAIR_KEYS.issubset(pair.keys())
# Nested expansions stay compact: no dropped attack/spell keys, and the
# attack damage is rendered as formula+type strings.
for combatant in view.combatants:
for attack in combatant["attacks"]:
assert _ATTACK_DROPPED_KEYS.isdisjoint(attack.keys())
assert all(isinstance(d, str) for d in attack["damage"])
for spell in combatant["spells"]:
assert _SPELL_DROPPED_KEYS.isdisjoint(spell.keys())
def test_view_ids_and_render() -> None:
"""Pin the id contract and the deterministic line-based render."""
registry = load_spell_registry(SPELLS_DIR)
obs = _first_observation(_representative_spec(), seed=1, registry=registry)
raw = obs.to_dict()
view = build_view(obs)
# The acting combatant and pair targets are rendered as ids.
assert view.acting_combatant in raw["initiative_order"]
ids = {combatant["id"] for combatant in view.combatants}
for pair in view.pairs:
assert pair["target"] in ids
# The render is line-based and self-describing.
text = render(view)
assert text.startswith("schema_version=1\n")
assert "combatants:" in text
assert "pairs:" in text
assert "condition_definitions" not in text