Files
pf1e-simulator/src/pf1e_simulator/search.py
T

319 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Strategy search and playbook optimization (docs/phase-2-llm-architecture.md
sections 3.1, 7, 10; P3.2/P3.3).
``evaluate`` measures one strategy against one scenario and returns its
``StrategyBenchmark`` (danger metrics, win counts, and the party's 3-sigma
win-rate band). ``optimize`` searches the strategy space in three stages - a
coarse 24-cell grid over the target/spell/charge/class-ability levers, a
retreat-threshold refinement of the top grid cells, then any LLM
``candidates`` - and returns a ranked ``PlaybookEntry`` tuple.
``strategy_name`` renders a deterministic label for a spec and
``format_playbook`` renders the ranked playbook as aligned ASCII columns.
Deterministic: the only randomness comes from the fixed ``seed`` passed to
the sweep runner.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
from pf1e_simulator.objectives import DangerMetrics
from pf1e_simulator.runner import EncounterSpec
from pf1e_simulator.scenarios import ScenarioSpec
from pf1e_simulator.sweep import SweepCellResult
from pf1e_simulator.scenarios import build_encounter
from pf1e_simulator.strategy import StrategySpec, compile_strategy
from pf1e_simulator.sweep import SweepCell, SweepRunner
@dataclass(frozen=True)
class StrategyBenchmark:
"""The measured outcome of one strategy on one scenario (P3.2)."""
strategy: StrategySpec
scenario_name: str
danger: DangerMetrics
wins: dict[str, int]
draws: int
avg_rounds: float
win_rate_band: tuple[float, float]
@dataclass(frozen=True)
class PlaybookEntry:
"""One ranked row of an optimized playbook (P3.2/P3.3)."""
rank: int
strategy: StrategySpec
benchmark: StrategyBenchmark
def strategy_name(strategy: StrategySpec) -> str:
"""A deterministic label for `strategy` from its non-default levers.
Levers render in field order (target_priority, spell_policy, use_charges,
use_class_abilities, retreat_hp_fraction), joined with ", "; the `name`
field is never part of the label. An all-default spec is "default".
"""
levers: list[str] = []
if strategy.target_priority != "nearest":
levers.append(strategy.target_priority)
if strategy.spell_policy != "default":
levers.append(f"spell_policy={strategy.spell_policy}")
if not strategy.use_charges:
levers.append("use_charges=false")
if not strategy.use_class_abilities:
levers.append("use_class_abilities=false")
if strategy.retreat_hp_fraction != 0.0:
levers.append(f"retreat={strategy.retreat_hp_fraction}")
if not levers:
return "default"
return ", ".join(levers)
def _grid_specs() -> tuple[StrategySpec, ...]:
"""The coarse 24-cell grid: 3 targets x 2 spell policies x 2 x 2, retreat 0."""
specs: list[StrategySpec] = []
for target_priority in ("nearest", "weakest_ac", "lowest_hp"):
for spell_policy in ("default", "none"):
for use_charges in (True, False):
for use_class_abilities in (True, False):
raw = StrategySpec(
target_priority=target_priority,
spell_policy=spell_policy,
use_charges=use_charges,
use_class_abilities=use_class_abilities,
)
specs.append(replace(raw, name=strategy_name(raw)))
return tuple(specs)
def _refine_specs(top: Sequence[StrategySpec]) -> tuple[StrategySpec, ...]:
"""Retreat-threshold refinements of the top grid specs (3 per spec)."""
refined: list[StrategySpec] = []
for spec in top:
for fraction in (0.25, 0.5, 0.75):
raw = replace(spec, retreat_hp_fraction=fraction)
refined.append(replace(raw, name=strategy_name(raw)))
return tuple(refined)
def _rank_key(
strategy: StrategySpec, danger: DangerMetrics
) -> tuple[float, float, float, float, str]:
"""Sort key: best first (survive high, hp lost low, tail high, rounds low)."""
return (
-danger.p_survives,
danger.expected_hp_lost,
-danger.worst_decile_party_hp,
danger.expected_rounds,
strategy_name(strategy),
)
def _cell(
scenario: ScenarioSpec,
encounter: EncounterSpec,
spec: StrategySpec,
runs: int,
seed: int,
) -> SweepCell:
"""One sweep cell for `spec` under its compiled strategy."""
return SweepCell(
scenario=scenario.name,
strategy=strategy_name(spec),
spec=encounter,
policy=compile_strategy(spec),
runs=runs,
seed=seed,
)
def _cells(
scenario: ScenarioSpec,
encounter: EncounterSpec,
specs: Sequence[StrategySpec],
runs: int,
seed: int,
) -> tuple[SweepCell, ...]:
"""One cell per spec, in spec order."""
return tuple(_cell(scenario, encounter, spec, runs, seed) for spec in specs)
def _benchmark(
strategy: StrategySpec,
scenario_name: str,
result: SweepCellResult,
party: str,
) -> StrategyBenchmark:
"""Build the benchmark for one strategy from its sweep cell result."""
return StrategyBenchmark(
strategy=strategy,
scenario_name=scenario_name,
danger=result.danger,
wins=result.report.wins,
draws=result.report.draws,
avg_rounds=result.report.avg_rounds,
win_rate_band=result.report.win_rate_band(party),
)
def evaluate( # noqa: PLR0913 - pinned Phase-2 API (6 knobs)
strategy: StrategySpec,
scenario: ScenarioSpec,
*,
party: str,
runs: int,
seed: int,
workers: int = 4,
) -> StrategyBenchmark:
"""Measure one strategy on one scenario (P3.2).
Compiles `strategy` and runs a single sweep cell of `runs` seeded battles;
the benchmark carries the party's danger metrics, win counts, and 3-sigma
win-rate band.
"""
encounter = build_encounter(scenario)
runner = SweepRunner(party=party, workers=workers)
(result,) = runner.run((_cell(scenario, encounter, strategy, runs, seed),))
return _benchmark(strategy, scenario.name, result, party)
def optimize( # noqa: PLR0913 - pinned Phase-2 API (7 knobs)
scenario: ScenarioSpec,
*,
party: str,
runs: int,
seed: int,
workers: int = 4,
max_total_runs: int | None = None,
candidates: Sequence[StrategySpec] = (),
) -> tuple[PlaybookEntry, ...]:
"""Search the strategy space and return a ranked playbook (P3.2/P3.3).
Stage 1 sweeps the coarse 24-cell grid (target priority x spell policy x
charges x class abilities, retreat 0); stage 2 refines the top 3 grid
cells over retreat thresholds 0.25/0.5/0.75; stage 3 adds the LLM
`candidates`, deduplicated by field equality against every spec already
run. Entries are ranked by (-p_survives, +expected_hp_lost,
-worst_decile_party_hp, +avg_rounds, strategy_name). `max_total_runs`
caps the total Monte Carlo cost before anything executes (mirrors
SweepRunner.run). The signature is pinned by the Phase-2 API, hence the
PLR0913 noqa.
"""
encounter = build_encounter(scenario)
runner = SweepRunner(party=party, workers=workers)
stage1_specs = _grid_specs()
seen: set[StrategySpec] = set(stage1_specs)
stage3_specs: list[StrategySpec] = []
for spec in candidates:
if spec not in seen:
seen.add(spec)
stage3_specs.append(spec)
total_runs = (len(stage1_specs) + 9 + len(stage3_specs)) * runs
if max_total_runs is not None and total_runs > max_total_runs:
msg = (
f"optimize budget exceeded: {total_runs} runs > "
f"{max_total_runs} max_total_runs"
)
raise ValueError(msg)
stage1_results = runner.run(_cells(scenario, encounter, stage1_specs, runs, seed))
stage1_ranked = sorted(
zip(stage1_specs, stage1_results, strict=True),
key=lambda pair: _rank_key(pair[0], pair[1].danger),
)
top3 = [spec for spec, _ in stage1_ranked[:3]]
stage2_specs = _refine_specs(top3)
stage2_results = runner.run(_cells(scenario, encounter, stage2_specs, runs, seed))
seen = set(stage1_specs) | set(stage2_specs)
stage3_specs = []
for spec in candidates:
if spec not in seen:
seen.add(spec)
stage3_specs.append(spec)
stage3_results = (
runner.run(_cells(scenario, encounter, stage3_specs, runs, seed))
if stage3_specs
else ()
)
pairs = [
*zip(stage1_specs, stage1_results, strict=True),
*zip(stage2_specs, stage2_results, strict=True),
*zip(stage3_specs, stage3_results, strict=True),
]
benchmarks = [
_benchmark(spec, scenario.name, result, party) for spec, result in pairs
]
ordered = sorted(benchmarks, key=lambda b: _rank_key(b.strategy, b.danger))
return tuple(
PlaybookEntry(rank=index, strategy=benchmark.strategy, benchmark=benchmark)
for index, benchmark in enumerate(ordered, start=1)
)
def _win_rate_text(benchmark: StrategyBenchmark, party: str) -> str:
"""The party's win rate with its 3-sigma band, e.g. "93.0 [89.5, 96.5]"."""
rate = benchmark.wins.get(party, 0) / benchmark.danger.runs
low, high = benchmark.win_rate_band
return f"{rate * 100:.1f} [{low * 100:.1f}, {high * 100:.1f}]"
def format_playbook(entries: Sequence[PlaybookEntry], party: str) -> str:
"""Render a ranked playbook as aligned plain-text columns (ASCII only).
One row per entry in order, under a header line and a separator row of
dashes. Percentages and HP values use one decimal; the Win% column shows
the party's win rate with its 3-sigma band. Column widths are computed
from the content; columns are separated by a single space.
"""
headers = (
"Rank",
"Strategy",
"Runs",
"P(survive)",
"P(TPK)",
"P(>=1 death)",
"HP lost",
"Worst-decile HP",
"Rounds",
"Win% (3σ)",
)
rows = [
(
str(entry.rank),
entry.benchmark.strategy.name,
str(entry.benchmark.danger.runs),
f"{entry.benchmark.danger.p_survives:.1%}",
f"{entry.benchmark.danger.p_tpk:.1%}",
f"{entry.benchmark.danger.p_any_death:.1%}",
f"{entry.benchmark.danger.expected_hp_lost:.1f}",
f"{entry.benchmark.danger.worst_decile_party_hp:.1f}",
f"{entry.benchmark.danger.expected_rounds:.1f}",
_win_rate_text(entry.benchmark, party),
)
for entry in entries
]
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)