test(phase2): measurement spike - observation size and policy calls per battle
Representative 7-combatant caster-heavy encounter on sample_arena, measured through the real engine path (engine._build_observation at each policy call, delegating to default_policy): - observation: 64,204 bytes at first call, 67,712 max mid-battle (~16.9k tokens) - policy calls/battle: 124 (7 combatants, round_cap=20), 12 for a 2v2 duel - projected calls per sweep cell: 124 / 12,400 / 124,000 at runs 1/100/1000 Loose bounds (obs < 200 KB, calls/battle < 200) + seed-determinism pinned.
This commit is contained in:
@@ -0,0 +1,183 @@
|
|||||||
|
"""Phase-2 measurement spike (docs/phase-2-llm-architecture.md section 12.b).
|
||||||
|
|
||||||
|
Two numbers decide whether a per-turn LLM is viable for the Monte Carlo
|
||||||
|
sweep: the OBSERVATION SIZE (bytes / rough tokens) and the POLICY CALLS PER
|
||||||
|
BATTLE (hence per sweep cell). This module measures both through the real
|
||||||
|
engine path on a representative caster-heavy encounter, prints a summary
|
||||||
|
block for the orchestrator, and pins LOOSE upper bounds so a regression
|
||||||
|
(bigger snapshot schema, policy called twice per turn, ...) fails loudly.
|
||||||
|
No engine behaviour is changed here: the recorder only reads
|
||||||
|
``engine._build_observation`` (pure read, consumes no RNG) at each policy
|
||||||
|
invocation and delegates to ``default_policy``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
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: a regression past these fails loudly; the real values are
|
||||||
|
# far below (printed by the tests for the orchestrator to quote).
|
||||||
|
MAX_OBS_BYTES = 200_000
|
||||||
|
MAX_CALLS_PER_BATTLE = 200
|
||||||
|
|
||||||
|
# Round cap for the measurement battles: generous enough to exercise buffs,
|
||||||
|
# conditions and summons, small enough to bound the calls/battle figure.
|
||||||
|
MEASURE_ROUND_CAP = 20
|
||||||
|
|
||||||
|
|
||||||
|
class _Recorder:
|
||||||
|
"""Counting policy wrapper: counts invocations, snapshots observation size.
|
||||||
|
|
||||||
|
Delegates every decision to ``default_policy`` so the battle trajectory
|
||||||
|
(and therefore the call count) is the real engine path. The observation
|
||||||
|
is rebuilt at each call via ``engine._build_observation(state)`` - the
|
||||||
|
same snapshot the engine hands to an ``LLMPolicy`` - and its JSON size
|
||||||
|
is tracked (first call = start of battle, max = fullest mid-battle
|
||||||
|
snapshot with buffs/conditions active).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls = 0
|
||||||
|
self.first_bytes: int | None = None
|
||||||
|
self.max_bytes = 0
|
||||||
|
|
||||||
|
def policy(self, engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
self.calls += 1
|
||||||
|
obs = engine._build_observation(state).to_dict()
|
||||||
|
size = len(json.dumps(obs))
|
||||||
|
if self.first_bytes is None:
|
||||||
|
self.first_bytes = size
|
||||||
|
self.max_bytes = max(self.max_bytes, size)
|
||||||
|
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).
|
||||||
|
|
||||||
|
Oni (channel + heal + summon), misty (spell combat), nairda (hexes) vs
|
||||||
|
the lich (high-level caster, fear aura, paralysis touch), a thrune
|
||||||
|
agent, an orc and a goblin: buffs, conditions, spells and pairs all
|
||||||
|
load the snapshot.
|
||||||
|
"""
|
||||||
|
return EncounterSpec(
|
||||||
|
map=load_map(MAP),
|
||||||
|
round_cap=MEASURE_ROUND_CAP,
|
||||||
|
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 _duel_spec() -> EncounterSpec:
|
||||||
|
"""Small 2v2 baseline to show how calls/battle scales with combatants."""
|
||||||
|
return EncounterSpec(
|
||||||
|
map=load_map(MAP),
|
||||||
|
round_cap=MEASURE_ROUND_CAP,
|
||||||
|
sides=(
|
||||||
|
Side(name="players", combatants=(_c("monsters/goblin.json"),) * 2, zone="A"),
|
||||||
|
Side(name="monsters", combatants=(_c("monsters/orc.json"),) * 2, zone="B"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_battle(
|
||||||
|
spec: EncounterSpec, seed: int, registry: dict[str, SpellSpec]
|
||||||
|
) -> _Recorder:
|
||||||
|
grid = Grid.from_spec(spec.map)
|
||||||
|
states = build_states(spec)
|
||||||
|
rec = _Recorder()
|
||||||
|
CombatEngine(
|
||||||
|
SeededRng(seed),
|
||||||
|
grid,
|
||||||
|
states,
|
||||||
|
round_cap=spec.round_cap,
|
||||||
|
policy=rec.policy,
|
||||||
|
spell_registry=registry,
|
||||||
|
).run()
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def _emit(line: str) -> None:
|
||||||
|
sys.stdout.write(line + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_observation_size_and_policy_calls(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
"""Measure both numbers on the representative battle; pin loose bounds."""
|
||||||
|
registry = load_spell_registry(SPELLS_DIR)
|
||||||
|
spec = _representative_spec()
|
||||||
|
rec = _run_battle(spec, seed=1, registry=registry)
|
||||||
|
assert rec.first_bytes is not None
|
||||||
|
|
||||||
|
# Determinism: same seed -> same trajectory -> same numbers.
|
||||||
|
rec2 = _run_battle(spec, seed=1, registry=registry)
|
||||||
|
assert rec2.calls == rec.calls
|
||||||
|
assert rec2.first_bytes == rec.first_bytes
|
||||||
|
assert rec2.max_bytes == rec.max_bytes
|
||||||
|
|
||||||
|
# Loose bounds (regression guards, not exact pins).
|
||||||
|
assert rec.first_bytes < MAX_OBS_BYTES
|
||||||
|
assert rec.max_bytes < MAX_OBS_BYTES
|
||||||
|
assert rec.calls < MAX_CALLS_PER_BATTLE
|
||||||
|
|
||||||
|
duel = _run_battle(_duel_spec(), seed=1, registry=registry)
|
||||||
|
assert duel.calls < MAX_CALLS_PER_BATTLE
|
||||||
|
assert duel.first_bytes is not None
|
||||||
|
|
||||||
|
first_tokens = rec.first_bytes // 4
|
||||||
|
max_tokens = rec.max_bytes // 4
|
||||||
|
_emit("=== PHASE2 SUMMARY (measurement spike) ===")
|
||||||
|
_emit(f"encounter: 7 combatants, round_cap={MEASURE_ROUND_CAP}, seed=1")
|
||||||
|
_emit(f"observation bytes (first call): {rec.first_bytes} (~{first_tokens} tokens)")
|
||||||
|
_emit(f"observation bytes (max in battle): {rec.max_bytes} (~{max_tokens} tokens)")
|
||||||
|
_emit(f"policy calls/battle (7 combatants): {rec.calls}")
|
||||||
|
_emit(f"policy calls/battle (2v2 duel): {duel.calls}")
|
||||||
|
_emit(f"observation 2v2 duel bytes: {duel.first_bytes} (~{duel.first_bytes // 4} tokens)")
|
||||||
|
for runs in (1, 100, 1000):
|
||||||
|
_emit(f"projected calls per sweep cell at runs={runs}: {rec.calls * runs}")
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "PHASE2 SUMMARY" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_projection_is_linear_in_runs() -> None:
|
||||||
|
"""Calls per sweep cell = calls/battle x runs (fixed per-battle count)."""
|
||||||
|
registry = load_spell_registry(SPELLS_DIR)
|
||||||
|
rec = _run_battle(_representative_spec(), seed=7, registry=registry)
|
||||||
|
assert rec.calls > 0
|
||||||
|
assert rec.calls * 1000 == (rec.calls * 100) * 10
|
||||||
Reference in New Issue
Block a user