feat(phase2): strategy compiler and sweep runner (P2)

This commit is contained in:
2026-09-22 13:46:05 +02:00
parent 15d24be638
commit dbd1a5822e
4 changed files with 818 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
"""Etage A strategy compiler (docs/phase-2-llm-architecture.md sections 2 and 10, P2.1).
Deterministic, additive: the engine (combat.py) is never modified. A
``StrategySpec`` is a small set of frozen knobs (target priority, retreat
threshold, spell policy, charge/class-ability toggles) compiled by
``compile_strategy`` into a ``Policy`` callable. The all-default spec compiles
to the module-level ``default_policy`` object itself (identity), so the common
case is bit-identical and zero-cost. Non-default specs mirror
``default_policy``'s decision flow with the spec's values substituted.
``StrategySpec`` feeds the SweepRunner (sweep.py, later batch) and the LLM
NL-to-spec translation (later batch): the optimizer searches over specs, the
LLM produces specs from natural language, and this module is the single
deterministic executor both rely on.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from pf1e_simulator.combat import (
Action,
CombatantState,
CombatEngine,
Policy,
# The decision helpers below are module-private by convention but stable:
# the engine stays unchanged, and duplicating their spell/hex/channel
# logic here would drift.
_choose_spell_action, # pyright: ignore[reportPrivateUsage]
_emit_channel_energy, # pyright: ignore[reportPrivateUsage]
_emit_hex, # pyright: ignore[reportPrivateUsage]
_spell_combat_actions, # pyright: ignore[reportPrivateUsage]
_swift_bonus_actions, # pyright: ignore[reportPrivateUsage]
default_policy,
)
TargetPriority = Literal["nearest", "weakest_ac", "lowest_hp"]
SpellPolicy = Literal["default", "none"]
@dataclass(frozen=True)
class StrategySpec:
"""Deterministic strategy knobs compiled to a ``Policy`` (Etage A).
All fields default to the behaviour of ``default_policy``; a spec built
with every default compiles to ``default_policy`` itself (identity).
"""
name: str = "default"
target_priority: TargetPriority = "nearest"
retreat_hp_fraction: float = 0.0 # 0.0 = never retreat
spell_policy: SpellPolicy = "default"
use_charges: bool = True
use_class_abilities: bool = True
def __post_init__(self) -> None:
if not self.name:
msg = "name must be a non-empty string"
raise ValueError(msg)
if not 0.0 <= self.retreat_hp_fraction <= 1.0:
msg = "retreat_hp_fraction must be within [0, 1]"
raise ValueError(msg)
def select_target(
engine: CombatEngine, state: CombatantState, priority: TargetPriority
) -> CombatantState | None:
"""The active enemy selected by ``priority``; None when no enemy is active.
Deterministic: ties break on ``engine.states`` index order.
"""
if priority == "nearest":
return engine.nearest_enemy(state)
enemies = [s for s in engine.states if s.side != state.side and s.active]
if not enemies:
return None
match priority:
case "weakest_ac":
return min(
enemies,
key=lambda s: (s.combatant.ac.total, s.hp, engine.states.index(s)),
)
case "lowest_hp":
return min(enemies, key=lambda s: (s.hp, engine.states.index(s)))
def _melee_actions(
engine: CombatEngine,
state: CombatantState,
target: CombatantState,
spec: StrategySpec,
) -> tuple[Action, ...]:
"""The strike ladder: full-attack, charge (if enabled), step, or approach."""
if engine.weapon_for(state, target) is not None:
return (Action(kind="full_attack", target_id=target.combatant.id),)
if spec.use_charges:
charge = engine.can_charge(state, target)
if charge is not None and charge.weapon is not None:
return (Action(kind="charge", target_id=target.combatant.id),)
if engine.can_step5_to_attack(state, target):
return (
Action(kind="5foot_step", target_id=target.combatant.id),
Action(kind="full_attack", target_id=target.combatant.id),
)
return (
Action(kind="move", target_id=target.combatant.id),
Action(kind="attack", target_id=target.combatant.id),
)
def _normal_actions(
engine: CombatEngine,
state: CombatantState,
target: CombatantState,
spec: StrategySpec,
) -> tuple[Action, ...]:
"""The spell/melee action sequence (without swift/free class-ability add-ons)."""
if spec.spell_policy != "none" and state.combatant.spells:
spell_action = _choose_spell_action(engine, state, target)
if spell_action is not None:
return spell_action
return _melee_actions(engine, state, target, spec)
def _class_ability_action(
engine: CombatEngine, state: CombatantState, target: CombatantState
) -> tuple[Action, ...] | None:
"""The channel/hex replacement action when a class ability fires."""
channel_mode = _emit_channel_energy(engine, state)
if channel_mode is not None:
return (
Action(
kind="channel_energy",
target_id=target.combatant.id,
channel_mode=channel_mode,
),
)
if _emit_hex(engine, state, target):
return (Action(kind="hex", target_id=target.combatant.id, hex_name="evil_eye"),)
return None
def _compiled_policy(spec: StrategySpec) -> Policy:
"""Build the ``Policy`` that executes ``spec`` (non-default specs only)."""
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
target = select_target(engine, state, spec.target_priority)
if target is None:
return (Action(kind="wait"),)
if engine.has_condition_flag(state, "must_flee") or (
spec.retreat_hp_fraction > 0.0
and state.hp < spec.retreat_hp_fraction * state.combatant.hp_max
):
return (Action(kind="withdraw", target_id=target.combatant.id),)
if spec.use_class_abilities:
class_action = _class_ability_action(engine, state, target)
if class_action is not None:
return class_action
normal = _normal_actions(engine, state, target, spec)
if not spec.use_class_abilities:
return normal
spell_combat = _spell_combat_actions(engine, state, target)
if spell_combat is not None:
return spell_combat
swift_bonus = _swift_bonus_actions(engine, state, target, normal)
return (*swift_bonus, *normal) if swift_bonus else normal
return policy
_DEFAULT_SPEC = StrategySpec()
def compile_strategy(spec: StrategySpec) -> Policy:
"""Compile a ``StrategySpec`` into a ``Policy`` callable.
The all-default spec returns the module-level ``default_policy`` object
itself (identity), so the common case is bit-identical and zero-cost.
"""
if spec == _DEFAULT_SPEC:
return default_policy
return _compiled_policy(spec)
+201
View File
@@ -0,0 +1,201 @@
"""Parallel, cost-bounded sweep runner for Phase-2 danger cartography.
Executes a grid of (scenario x strategy) cells (docs/phase-2-llm-architecture.md
section 10, P2.3; decisions D9/D10): each cell runs ``run_encounter(...,
collect_runs=True)`` for a fixed number of seeded runs under one policy and
reports the aggregate ``EncounterReport`` plus the party's ``DangerMetrics``
(objectives.danger_metrics). Cells are independent, so they run on a thread
pool (D10); the spell registry is loaded once and shared read-only across
threads. The runner is strategy-agnostic: policies are passed in as callables
(``None`` = ``run_encounter``'s own ``default_policy``) and no strategy module
is imported here.
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
from pf1e_simulator.combat import Policy
from pf1e_simulator.runner import EncounterReport, EncounterSpec
from pf1e_simulator.spells import SpellSpec
from pf1e_simulator.objectives import DangerMetrics, danger_metrics
from pf1e_simulator.runner import run_encounter
from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry
@dataclass(frozen=True)
class SweepCell:
"""One (scenario, strategy) cell of a sweep: N seeded runs with one policy."""
scenario: str
strategy: str
spec: EncounterSpec
policy: Policy | None # None = default_policy (run_encounter's own default)
runs: int
seed: int
@dataclass(frozen=True)
class SweepCellResult:
"""Aggregate + danger metrics for one cell."""
cell: SweepCell
report: EncounterReport
danger: DangerMetrics
class SweepRunner:
"""Run a grid of (scenario, strategy) cells in parallel, bounded by cost.
``party`` is the side name whose danger is measured (passed to
``danger_metrics``); ``workers`` bounds the thread pool. The spell
registry is loaded once in ``__init__`` and shared read-only across all
cells (reloading it per cell would be wasteful; a read-only dict is safe
to share between threads).
"""
def __init__(self, *, party: str, workers: int = 4) -> None:
if not party:
msg = "party must be a non-empty side name"
raise ValueError(msg)
if workers < 1:
msg = "workers must be >= 1"
raise ValueError(msg)
self._party: str = party
self._workers: int = workers
self._registry: dict[str, SpellSpec] = load_spell_registry(SPELLS_DIR)
def run(
self,
cells: Sequence[SweepCell],
*,
max_total_runs: int | None = None,
) -> tuple[SweepCellResult, ...]:
"""Run every cell on the thread pool and return results in input order.
``max_total_runs`` caps the total Monte Carlo cost (docs section 9,
D9/D14 "Budget - plafonds petits"): when the cells' runs sum exceeds
it, a ``ValueError`` is raised before anything executes. Each cell
must run at least once. Exceptions from a cell propagate loudly - a
failing cell fails the whole sweep.
"""
total_runs = sum(cell.runs for cell in cells)
if max_total_runs is not None and total_runs > max_total_runs:
msg = (
f"sweep budget exceeded: {total_runs} runs > "
f"{max_total_runs} max_total_runs"
)
raise ValueError(msg)
for cell in cells:
if cell.runs < 1:
msg = (
f"cell runs must be >= 1 "
f"(scenario={cell.scenario!r}, strategy={cell.strategy!r})"
)
raise ValueError(msg)
with ThreadPoolExecutor(max_workers=self._workers) as pool:
futures = [pool.submit(self._run_cell, cell) for cell in cells]
results = [future.result() for future in futures]
return tuple(results)
def _run_cell(self, cell: SweepCell) -> SweepCellResult:
"""One cell's work: run the encounter and measure the party's danger."""
report, outcomes = run_encounter(
cell.spec,
cell.runs,
cell.seed,
policy=cell.policy,
spell_registry=self._registry,
collect_runs=True,
)
danger = danger_metrics(outcomes, self._party)
return SweepCellResult(cell=cell, report=report, danger=danger)
def run_grid(
self,
scenarios: Sequence[tuple[str, EncounterSpec]],
strategies: Sequence[tuple[str, Policy | None]],
*,
runs: int,
seed: int,
max_total_runs: int | None = None,
) -> tuple[SweepCellResult, ...]:
"""Build the scenario-major (scenario x strategy) grid and run it.
Cells are built in SCENARIO-major order (for each scenario, for each
strategy - mirrors the danger-map table of docs section 16), every
cell sharing the same ``runs`` and ``seed``. Names come from the
tuples; a ``None`` policy stays ``None`` (default_policy).
"""
cells = [
SweepCell(
scenario=scenario_name,
strategy=strategy_name,
spec=spec,
policy=policy,
runs=runs,
seed=seed,
)
for scenario_name, spec in scenarios
for strategy_name, policy in strategies
]
return self.run(cells, max_total_runs=max_total_runs)
def format_sweep(results: Sequence[SweepCellResult], party: str) -> str:
"""Render a sweep as aligned plain-text columns (ASCII only).
One row per result, in input order, under a header line and a separator
row of dashes. ``party`` is the side whose danger was measured: the Win%
column shows that side's win rate (``report.wins.get(party, 0) /
report.runs``) as a percentage with one decimal. Column widths are
computed from the content; columns are separated by a single space.
"""
headers = (
"Scenario",
"Strategy",
"Runs",
"Win%",
"Draws",
"Rounds",
"P(survive)",
"P(TPK)",
"P(>=1 death)",
"HP lost",
"Worst-decile HP",
)
rows = [
(
result.cell.scenario,
result.cell.strategy,
str(result.cell.runs),
f"{result.report.wins.get(party, 0) / result.report.runs * 100:.1f}",
str(result.report.draws),
f"{result.report.avg_rounds:.1f}",
f"{result.danger.p_survives:.1%}",
f"{result.danger.p_tpk:.1%}",
f"{result.danger.p_any_death:.1%}",
f"{result.danger.expected_hp_lost:.1f}",
f"{result.danger.worst_decile_party_hp:.1f}",
)
for result in results
]
widths = [
max(len(header), *(len(row[index]) for row in rows))
for index, header in enumerate(headers)
]
def _line(values: Sequence[str]) -> str:
return " ".join(
value.ljust(width) for value, width in zip(values, widths, strict=True)
)
lines = [_line(headers), _line(["-" * width for width in widths])]
lines.extend(_line(row) for row in rows)
return "\n".join(lines)