feat(phase2): runner llm_policy and policy_factory entry points

This commit is contained in:
2026-09-21 14:46:25 +02:00
parent ac7fe20054
commit 334f8f9d05
2 changed files with 198 additions and 2 deletions
+45 -2
View File
@@ -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,
)