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.
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user