feat(phase2): runner llm_policy and policy_factory entry points
This commit is contained in:
@@ -13,7 +13,9 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pf1e_simulator.combat import Policy
|
||||
from collections.abc import Callable
|
||||
|
||||
from pf1e_simulator.combat import LegacyLLMPolicy, LLMPolicy, Policy
|
||||
from pf1e_simulator.grid import Grid
|
||||
from pf1e_simulator.map import MapSpec
|
||||
from pf1e_simulator.models import Combatant
|
||||
@@ -246,6 +248,25 @@ def _run_outcome(engine: CombatEngine, result: CombatResult) -> RunOutcome:
|
||||
)
|
||||
|
||||
|
||||
def _check_policy_args(
|
||||
policy: Policy | None,
|
||||
llm_policy: LLMPolicy | LegacyLLMPolicy | None,
|
||||
policy_factory: Callable[[int], Policy | None] | None,
|
||||
) -> None:
|
||||
"""Reject conflicting policy entry points (P0.4).
|
||||
|
||||
``policy_factory`` owns the per-run policy slot, so it cannot be combined
|
||||
with ``policy`` or ``llm_policy``. ``policy`` + ``llm_policy`` is left to
|
||||
``CombatEngine`` (single source of truth for the mutual-exclusion error).
|
||||
"""
|
||||
if policy_factory is not None and policy is not None:
|
||||
msg = "policy_factory and policy are mutually exclusive"
|
||||
raise ValueError(msg)
|
||||
if policy_factory is not None and llm_policy is not None:
|
||||
msg = "policy_factory and llm_policy are mutually exclusive"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
@overload
|
||||
def run_encounter(
|
||||
spec: EncounterSpec,
|
||||
@@ -253,6 +274,8 @@ def run_encounter(
|
||||
seed: int,
|
||||
*,
|
||||
policy: Policy | None = None,
|
||||
llm_policy: LLMPolicy | LegacyLLMPolicy | None = None,
|
||||
policy_factory: Callable[[int], Policy | None] | None = None,
|
||||
spell_registry: dict[str, SpellSpec] | None = None,
|
||||
capture_first_transcript: bool = False,
|
||||
monsters_die_at_zero: bool = False,
|
||||
@@ -267,6 +290,8 @@ def run_encounter(
|
||||
seed: int,
|
||||
*,
|
||||
policy: Policy | None = None,
|
||||
llm_policy: LLMPolicy | LegacyLLMPolicy | None = None,
|
||||
policy_factory: Callable[[int], Policy | None] | None = None,
|
||||
spell_registry: dict[str, SpellSpec] | None = None,
|
||||
capture_first_transcript: bool = False,
|
||||
monsters_die_at_zero: bool = False,
|
||||
@@ -280,6 +305,8 @@ def run_encounter(
|
||||
seed: int,
|
||||
*,
|
||||
policy: Policy | None = None,
|
||||
llm_policy: LLMPolicy | LegacyLLMPolicy | None = None,
|
||||
policy_factory: Callable[[int], Policy | None] | None = None,
|
||||
spell_registry: dict[str, SpellSpec] | None = None,
|
||||
capture_first_transcript: bool = False,
|
||||
monsters_die_at_zero: bool = False,
|
||||
@@ -302,10 +329,24 @@ def run_encounter(
|
||||
``RunOutcome`` per battle (winner, rounds, final per-combatant
|
||||
id/side/hp/dead). Default False keeps the signature and behavior of
|
||||
every existing caller unchanged (the report alone).
|
||||
|
||||
``llm_policy`` (Phase 2, P0.4) forwards an observation-only (or legacy
|
||||
two-argument) policy straight to every ``CombatEngine``. It is mutually
|
||||
exclusive with ``policy``; the engine raises ``ValueError`` when both are
|
||||
given, so that conflict surfaces here.
|
||||
|
||||
``policy_factory`` (Phase 2, P0.4) builds the engine-style ``Policy`` per
|
||||
run: it is called once per battle with the run index (``0..runs-1``) and
|
||||
its result is passed as that run's ``policy`` (``None`` falls back to
|
||||
``default_policy``). It is mutually exclusive with both ``policy`` and
|
||||
``llm_policy``. Note the per-run callable is a ``Policy`` (it receives the
|
||||
live engine), not an ``LLMPolicy``; use ``llm_policy`` for the
|
||||
observation-only entry point.
|
||||
"""
|
||||
if runs <= 0:
|
||||
msg = "runs must be positive"
|
||||
raise ValueError(msg)
|
||||
_check_policy_args(policy, llm_policy, policy_factory)
|
||||
grid = Grid.from_spec(spec.map)
|
||||
states = build_states(spec)
|
||||
if spell_registry is None:
|
||||
@@ -320,12 +361,14 @@ def run_encounter(
|
||||
if index > 0:
|
||||
states = build_states(spec)
|
||||
rng = SeededRng(seed + index)
|
||||
run_policy = policy if policy_factory is None else policy_factory(index)
|
||||
engine = CombatEngine(
|
||||
rng,
|
||||
grid,
|
||||
states,
|
||||
round_cap=spec.round_cap,
|
||||
policy=policy,
|
||||
policy=run_policy,
|
||||
llm_policy=llm_policy,
|
||||
spell_registry=spell_registry,
|
||||
monsters_die_at_zero=monsters_die_at_zero,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Phase-2 runner policy entry points (docs/phase-2-llm-architecture.md P0.4).
|
||||
|
||||
``run_encounter`` grows two Phase-2 keyword arguments: ``llm_policy`` (the
|
||||
observation-only/legacy policy every ``CombatEngine`` gets) and
|
||||
``policy_factory`` (a per-run ``Policy`` builder called once per battle with
|
||||
the run index). These tests pin the contract: the factory is invoked exactly
|
||||
once per run in index order, its result is the run's policy (``None`` falls
|
||||
back to ``default_policy``), the three entry points are mutually exclusive,
|
||||
and an ``llm_policy`` really drives a full battle through the runner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from pf1e_simulator.combat import Action, default_policy
|
||||
from pf1e_simulator.loaders import load_combatant
|
||||
from pf1e_simulator.map import load_map
|
||||
from pf1e_simulator.runner import EncounterSpec, Side, run_encounter
|
||||
from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pf1e_simulator.combat import Observation, Policy
|
||||
from pf1e_simulator.models import Combatant
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
|
||||
|
||||
|
||||
def _c(path: str) -> Combatant:
|
||||
return load_combatant(ROOT / "data" / path)
|
||||
|
||||
|
||||
def _duel_spec() -> EncounterSpec:
|
||||
"""A fast 1v1 (goblin archer vs orc) on the sample arena."""
|
||||
return EncounterSpec(
|
||||
map=load_map(MAP),
|
||||
round_cap=20,
|
||||
sides=(
|
||||
Side(name="players", combatants=(_c("monsters/goblin.json"),), zone="A"),
|
||||
Side(name="monsters", combatants=(_c("monsters/orc.json"),), zone="B"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _wait_policy(_observation: Observation) -> tuple[Action, ...]:
|
||||
"""An observation-only policy that never attacks (forces a draw)."""
|
||||
return (Action(kind="wait"),)
|
||||
|
||||
|
||||
def test_policy_factory_called_once_per_run_in_order() -> None:
|
||||
"""The factory sees each run index once, and its policy drives the run."""
|
||||
spec = _duel_spec()
|
||||
registry = load_spell_registry(SPELLS_DIR)
|
||||
calls: list[int] = []
|
||||
|
||||
def factory(index: int) -> Policy | None:
|
||||
calls.append(index)
|
||||
return default_policy
|
||||
|
||||
report = run_encounter(
|
||||
spec, runs=4, seed=3, policy_factory=factory, spell_registry=registry
|
||||
)
|
||||
direct = run_encounter(
|
||||
spec, runs=4, seed=3, policy=default_policy, spell_registry=registry
|
||||
)
|
||||
|
||||
assert calls == [0, 1, 2, 3]
|
||||
# A per-run factory returning default_policy is bit-identical to passing
|
||||
# default_policy directly (same seed stream, same policy each run).
|
||||
assert report.wins == direct.wins
|
||||
assert report.draws == direct.draws
|
||||
assert report.avg_rounds == direct.avg_rounds
|
||||
assert report.attrition == direct.attrition
|
||||
|
||||
|
||||
def test_policy_factory_none_falls_back_to_default_policy() -> None:
|
||||
"""A factory returning None leaves the engine on default_policy."""
|
||||
spec = _duel_spec()
|
||||
registry = load_spell_registry(SPELLS_DIR)
|
||||
|
||||
report = run_encounter(
|
||||
spec,
|
||||
runs=2,
|
||||
seed=5,
|
||||
policy_factory=lambda _index: None,
|
||||
spell_registry=registry,
|
||||
)
|
||||
direct = run_encounter(spec, runs=2, seed=5, spell_registry=registry)
|
||||
|
||||
assert report.wins == direct.wins
|
||||
assert report.avg_rounds == direct.avg_rounds
|
||||
|
||||
|
||||
def test_policy_entry_points_mutually_exclusive() -> None:
|
||||
"""policy / llm_policy / policy_factory reject every conflicting pairing."""
|
||||
spec = _duel_spec()
|
||||
registry = load_spell_registry(SPELLS_DIR)
|
||||
|
||||
def factory(_index: int) -> Policy | None:
|
||||
return default_policy
|
||||
|
||||
with pytest.raises(ValueError, match="policy_factory and policy"):
|
||||
run_encounter(
|
||||
spec,
|
||||
runs=1,
|
||||
seed=1,
|
||||
policy=default_policy,
|
||||
policy_factory=factory,
|
||||
spell_registry=registry,
|
||||
)
|
||||
with pytest.raises(ValueError, match="policy_factory and llm_policy"):
|
||||
run_encounter(
|
||||
spec,
|
||||
runs=1,
|
||||
seed=1,
|
||||
llm_policy=_wait_policy,
|
||||
policy_factory=factory,
|
||||
spell_registry=registry,
|
||||
)
|
||||
with pytest.raises(ValueError, match="policy and llm_policy"):
|
||||
run_encounter(
|
||||
spec,
|
||||
runs=1,
|
||||
seed=1,
|
||||
policy=default_policy,
|
||||
llm_policy=_wait_policy,
|
||||
spell_registry=registry,
|
||||
)
|
||||
|
||||
|
||||
def test_llm_policy_drives_a_full_battle_through_the_runner() -> None:
|
||||
"""The observation-only policy is invoked each turn and settles the battle."""
|
||||
spec = _duel_spec()
|
||||
registry = load_spell_registry(SPELLS_DIR)
|
||||
seen: list[int] = []
|
||||
|
||||
def wait(observation: Observation) -> tuple[Action, ...]:
|
||||
seen.append(observation.round)
|
||||
return (Action(kind="wait"),)
|
||||
|
||||
report = run_encounter(
|
||||
spec, runs=1, seed=1, llm_policy=wait, spell_registry=registry
|
||||
)
|
||||
|
||||
# Both combatants wait every round: the policy is called for every turn
|
||||
# and nobody can win, so the battle draws at the round cap.
|
||||
assert seen
|
||||
assert report.draws == 1
|
||||
assert report.wins == {"players": 0, "monsters": 0}
|
||||
Reference in New Issue
Block a user