feat(phase2): adversary and robustness cartography (P4)
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"""Adversary spec and compilation (docs/phase-2-llm-architecture.md section 10, P4.1).
|
||||
|
||||
The adversary-facing mirror of strategy.py: ``AdversarySpec`` is a
|
||||
field-for-field copy of ``StrategySpec`` (same knobs, same validation) so the
|
||||
enemy side of a matchup carries its own spec type without touching the party
|
||||
search space. ``compile_adversary`` has identity semantics — it rebuilds the
|
||||
mirrored ``StrategySpec`` and delegates to ``compile_strategy``, so the
|
||||
all-default spec compiles to the module-level ``default_policy`` object
|
||||
itself. ``compile_matchup`` glues the two sides into ONE ``Policy`` that
|
||||
routes per combatant side: the engine invokes a single policy for every
|
||||
turn (``CombatEngine._take_turn`` → ``_policy(self, state)``), so the policy
|
||||
branches on ``state.side``, following the ``phase2_spike.focus_fire_policy``
|
||||
pattern. Deterministic throughout: no RNG, no LLM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pf1e_simulator.combat import (
|
||||
Action,
|
||||
CombatantState,
|
||||
CombatEngine,
|
||||
Policy,
|
||||
default_policy,
|
||||
)
|
||||
from pf1e_simulator.strategy import (
|
||||
SpellPolicy,
|
||||
StrategySpec,
|
||||
TargetPriority,
|
||||
compile_strategy,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdversarySpec:
|
||||
"""Field-for-field mirror of ``StrategySpec`` (same defaults, same validation)."""
|
||||
|
||||
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 adversary_name(spec: AdversarySpec) -> str:
|
||||
"""A deterministic label for `spec` from its non-default levers.
|
||||
|
||||
Local mirror of ``search.strategy_name`` (same field order: target_priority,
|
||||
spell_policy, use_charges, use_class_abilities, retreat_hp_fraction; joined
|
||||
with ", "; the `name` field never appears; all-default is "default"), kept
|
||||
local so this module never imports search.py.
|
||||
"""
|
||||
levers: list[str] = []
|
||||
if spec.target_priority != "nearest":
|
||||
levers.append(spec.target_priority)
|
||||
if spec.spell_policy != "default":
|
||||
levers.append(f"spell_policy={spec.spell_policy}")
|
||||
if not spec.use_charges:
|
||||
levers.append("use_charges=false")
|
||||
if not spec.use_class_abilities:
|
||||
levers.append("use_class_abilities=false")
|
||||
if spec.retreat_hp_fraction != 0.0:
|
||||
levers.append(f"retreat={spec.retreat_hp_fraction}")
|
||||
if not levers:
|
||||
return "default"
|
||||
return ", ".join(levers)
|
||||
|
||||
|
||||
def compile_adversary(spec: AdversarySpec) -> Policy:
|
||||
"""Compile an ``AdversarySpec`` into a ``Policy`` (identity semantics).
|
||||
|
||||
Rebuilds the mirrored ``StrategySpec`` and delegates to
|
||||
``compile_strategy``, so ``compile_adversary(AdversarySpec()) is
|
||||
default_policy`` holds exactly like the strategy-side identity.
|
||||
"""
|
||||
return compile_strategy(
|
||||
StrategySpec(
|
||||
name=spec.name,
|
||||
target_priority=spec.target_priority,
|
||||
retreat_hp_fraction=spec.retreat_hp_fraction,
|
||||
spell_policy=spec.spell_policy,
|
||||
use_charges=spec.use_charges,
|
||||
use_class_abilities=spec.use_class_abilities,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def compile_matchup(
|
||||
party: StrategySpec, adversary: AdversarySpec, *, party_side: str = "players"
|
||||
) -> Policy:
|
||||
"""One ``Policy`` that routes every turn by ``state.side`` (P4.1).
|
||||
|
||||
Turns of ``party_side`` delegate to ``compile_strategy(party)``; every
|
||||
other side delegates to ``compile_adversary(adversary)`` — the engine
|
||||
invokes ONE policy for all combatants, so side routing lives inside the
|
||||
policy (the ``phase2_spike.focus_fire_policy`` pattern, generalised to
|
||||
per-side compiled strategies). When both specs compile to
|
||||
``default_policy``, the default object itself is returned (identity, no
|
||||
wrapper). An empty ``party_side`` is a ValueError.
|
||||
"""
|
||||
if not party_side:
|
||||
msg = "party_side must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
party_policy = compile_strategy(party)
|
||||
adversary_policy = compile_adversary(adversary)
|
||||
if party_policy is default_policy and adversary_policy is default_policy:
|
||||
return default_policy
|
||||
|
||||
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||
if state.side == party_side:
|
||||
return party_policy(engine, state)
|
||||
return adversary_policy(engine, state)
|
||||
|
||||
return policy
|
||||
@@ -0,0 +1,682 @@
|
||||
"""Robustness layer: alternate min-max and danger cartography
|
||||
(docs/phase-2-llm-architecture.md sections 3.2 and 10; P4.2 and P4.3).
|
||||
|
||||
A party's danger is only meaningful against an adversary that plays to
|
||||
win (D12). This module implements the two robustness layers on top of
|
||||
the compiled Etage-A pipeline:
|
||||
|
||||
- P4.2 - alternate min-max (section 3.2: max_PJ . min_monstres):
|
||||
``adversarial_analysis`` alternately fixes one side and best-responds
|
||||
the other (the party maximizes its own ``DangerMetrics``, then the
|
||||
adversary minimizes the party's), every evaluation being a
|
||||
``matchup_benchmark`` of one party ``StrategySpec`` vs one adversary
|
||||
``AdversarySpec`` over a ``ScenarioSpec``. The same measure gives both
|
||||
senses: danger for the party AND danger for the monsters.
|
||||
- P4.3 - danger cartography (non exhaustive, D14): ``danger_map`` sweeps
|
||||
a grid of per-side initial-HP fractions (``SideSpec.hp_fraction``) and
|
||||
reports both sides' danger per cell, ``frontier`` bisects the party HP
|
||||
fraction where a danger criterion crosses a threshold, and
|
||||
``sensitivity`` ranks which single adversary lever moves the party
|
||||
danger the most.
|
||||
|
||||
Deterministic throughout: no RNG, no LLM, no network. The engine's
|
||||
``SeededRng`` under ``run_encounter`` is the only randomness and it is
|
||||
seeded, so identical inputs produce bit-identical frozen results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from pf1e_simulator.objectives import DangerMetrics
|
||||
from pf1e_simulator.runner import EncounterReport
|
||||
from pf1e_simulator.strategy import StrategySpec
|
||||
|
||||
from pf1e_simulator.adversary import AdversarySpec, adversary_name, compile_matchup
|
||||
from pf1e_simulator.objectives import danger_metrics
|
||||
from pf1e_simulator.runner import run_encounter
|
||||
from pf1e_simulator.scenarios import ScenarioSpec, SideSpec, build_encounter
|
||||
|
||||
_CRITERIA = ("p_survives", "p_tpk", "p_any_death", "expected_hp_lost")
|
||||
_MATCHUP_SIDES = 2 # a matchup is exactly one arena pair (two sides)
|
||||
|
||||
|
||||
def _check_criterion(criterion: str) -> None:
|
||||
if criterion not in _CRITERIA:
|
||||
msg = (
|
||||
"criterion must be one of ('p_survives', 'p_tpk', 'p_any_death', "
|
||||
f"'expected_hp_lost'), got {criterion!r}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _danger_value(danger: DangerMetrics, criterion: str) -> float:
|
||||
"""The `criterion` value of a danger profile (explicit dispatch, no getattr)."""
|
||||
if criterion == "p_survives":
|
||||
return danger.p_survives
|
||||
if criterion == "p_tpk":
|
||||
return danger.p_tpk
|
||||
if criterion == "p_any_death":
|
||||
return danger.p_any_death
|
||||
return danger.expected_hp_lost
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MatchupBenchmark:
|
||||
"""One (party strategy x adversary spec) matchup over a scenario.
|
||||
|
||||
``report`` aggregates all runs; ``party_danger`` is the danger of the
|
||||
``party_side`` side and ``adversary_danger`` the danger of the other
|
||||
side (same measure, both senses: PJ and monsters).
|
||||
"""
|
||||
|
||||
scenario: str
|
||||
party: str
|
||||
adversary: str
|
||||
runs: int
|
||||
seed: int
|
||||
report: EncounterReport
|
||||
party_danger: DangerMetrics
|
||||
adversary_danger: DangerMetrics
|
||||
party_side: str
|
||||
|
||||
|
||||
def _other_side(scenario: ScenarioSpec, party_side: str) -> str:
|
||||
"""The unique side different from ``party_side`` (exactly-2-sides contract)."""
|
||||
names = tuple(side.name for side in scenario.sides)
|
||||
if len(names) != _MATCHUP_SIDES:
|
||||
msg = f"matchup requires exactly 2 sides, got {len(names)}"
|
||||
raise ValueError(msg)
|
||||
if party_side not in names:
|
||||
msg = f"party_side {party_side!r} is not a side of the scenario"
|
||||
raise ValueError(msg)
|
||||
(other,) = (name for name in names if name != party_side)
|
||||
return other
|
||||
|
||||
|
||||
def matchup_benchmark(
|
||||
scenario: ScenarioSpec,
|
||||
party: StrategySpec,
|
||||
adversary: AdversarySpec,
|
||||
*,
|
||||
party_side: str = "players",
|
||||
runs: int = 100,
|
||||
seed: int = 1,
|
||||
) -> MatchupBenchmark:
|
||||
"""Compile one matchup and measure both sides' danger over `runs` battles.
|
||||
|
||||
Deterministic: same inputs -> bit-identical ``MatchupBenchmark``
|
||||
(frozen). Exactly two sides are required (one arena pair), the party
|
||||
side name must be non-empty and be one of them, and ``runs`` >= 1.
|
||||
"""
|
||||
if not party_side:
|
||||
msg = "party_side must be a non-empty string"
|
||||
raise ValueError(msg)
|
||||
adversary_side = _other_side(scenario, party_side)
|
||||
if runs < 1:
|
||||
msg = "runs must be >= 1"
|
||||
raise ValueError(msg)
|
||||
encounter = build_encounter(scenario)
|
||||
policy = compile_matchup(party, adversary, party_side=party_side)
|
||||
report, outcomes = run_encounter(
|
||||
encounter, runs, seed, policy=policy, collect_runs=True
|
||||
)
|
||||
return MatchupBenchmark(
|
||||
scenario=scenario.name,
|
||||
party=party.name,
|
||||
adversary=adversary.name,
|
||||
runs=runs,
|
||||
seed=seed,
|
||||
report=report,
|
||||
party_danger=danger_metrics(outcomes, party_side),
|
||||
adversary_danger=danger_metrics(outcomes, adversary_side),
|
||||
party_side=party_side,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RobustnessEntry:
|
||||
"""One turn of the alternating min-max: the pair kept after that turn."""
|
||||
|
||||
iteration: int
|
||||
party: StrategySpec
|
||||
adversary: AdversarySpec
|
||||
benchmark: MatchupBenchmark
|
||||
|
||||
|
||||
def _party_rank(benchmark: MatchupBenchmark) -> tuple[float, float, str]:
|
||||
"""Key: the party best-response prefers high survival, low HP lost, then name."""
|
||||
danger = benchmark.party_danger
|
||||
return (danger.p_survives, -danger.expected_hp_lost, benchmark.party)
|
||||
|
||||
|
||||
def _adversary_rank(benchmark: MatchupBenchmark) -> tuple[float, float, str]:
|
||||
"""Key: the adversary best-response prefers low party survival, high HP lost."""
|
||||
danger = benchmark.party_danger
|
||||
return (danger.p_survives, -danger.expected_hp_lost, benchmark.adversary)
|
||||
|
||||
|
||||
def adversarial_analysis(
|
||||
scenario: ScenarioSpec,
|
||||
*,
|
||||
party_candidates: Sequence[StrategySpec],
|
||||
adversary_candidates: Sequence[AdversarySpec],
|
||||
party_side: str = "players",
|
||||
runs: int = 100,
|
||||
seed: int = 1,
|
||||
iterations: int = 3,
|
||||
max_total_runs: int | None = None,
|
||||
) -> tuple[RobustnessEntry, ...]:
|
||||
"""Alternate (party, adversary) best responses over `iterations` turns.
|
||||
|
||||
Each turn re-evaluates every party candidate against the current
|
||||
adversary and keeps the best for the party (max own survival), then
|
||||
every adversary candidate against the kept party and keeps the worst
|
||||
for the party (min own survival, max party HP lost) -- the min-max
|
||||
alternation of section 3.2. The kept pair is reported with its
|
||||
benchmark.
|
||||
|
||||
Budget: at most ``iterations * (party + adversary candidates) * runs``
|
||||
battles. When ``max_total_runs`` is set and the budget exceeds it, no
|
||||
battle is fought and a ValueError is raised.
|
||||
"""
|
||||
if not party_candidates:
|
||||
msg = "party_candidates must be non-empty"
|
||||
raise ValueError(msg)
|
||||
if not adversary_candidates:
|
||||
msg = "adversary_candidates must be non-empty"
|
||||
raise ValueError(msg)
|
||||
if iterations < 1:
|
||||
msg = f"iterations must be >= 1, got {iterations}"
|
||||
raise ValueError(msg)
|
||||
if runs < 1:
|
||||
msg = f"runs must be >= 1, got {runs}"
|
||||
raise ValueError(msg)
|
||||
cost = iterations * (len(party_candidates) + len(adversary_candidates)) * runs
|
||||
if max_total_runs is not None and cost > max_total_runs:
|
||||
msg = (
|
||||
f"robustness budget exceeded: {cost} runs > "
|
||||
f"{max_total_runs} max_total_runs"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
party = party_candidates[0]
|
||||
adversary = adversary_candidates[0]
|
||||
entries: list[RobustnessEntry] = []
|
||||
for iteration in range(1, iterations + 1):
|
||||
party_pairs: list[tuple[StrategySpec, MatchupBenchmark]] = [
|
||||
(
|
||||
candidate,
|
||||
matchup_benchmark(
|
||||
scenario,
|
||||
candidate,
|
||||
adversary,
|
||||
party_side=party_side,
|
||||
runs=runs,
|
||||
seed=seed,
|
||||
),
|
||||
)
|
||||
for candidate in party_candidates
|
||||
]
|
||||
party = max(party_pairs, key=lambda pair: _party_rank(pair[1]))[0]
|
||||
adversary_pairs: list[tuple[AdversarySpec, MatchupBenchmark]] = [
|
||||
(
|
||||
candidate,
|
||||
matchup_benchmark(
|
||||
scenario,
|
||||
party,
|
||||
candidate,
|
||||
party_side=party_side,
|
||||
runs=runs,
|
||||
seed=seed,
|
||||
),
|
||||
)
|
||||
for candidate in adversary_candidates
|
||||
]
|
||||
adversary, benchmark = min(
|
||||
adversary_pairs, key=lambda pair: _adversary_rank(pair[1])
|
||||
)
|
||||
entries.append(
|
||||
RobustnessEntry(
|
||||
iteration=iteration,
|
||||
party=party,
|
||||
adversary=adversary,
|
||||
benchmark=benchmark,
|
||||
)
|
||||
)
|
||||
return tuple(entries)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DangerCell:
|
||||
"""One (party HP x adversary HP) cell of the danger map."""
|
||||
|
||||
party_hp_fraction: float
|
||||
adversary_hp_fraction: float
|
||||
benchmark: MatchupBenchmark
|
||||
|
||||
|
||||
def _side_variant(
|
||||
scenario: ScenarioSpec,
|
||||
*,
|
||||
party_side: str,
|
||||
party_hp_fraction: float,
|
||||
adversary_hp_fraction: float,
|
||||
) -> ScenarioSpec:
|
||||
"""A deterministic clone of `scenario` with per-side initial-HP fractions.
|
||||
|
||||
The `party_side` side carries `party_hp_fraction`; every other side
|
||||
carries `adversary_hp_fraction` (the matchup contract is exactly 2
|
||||
sides). Name: "<base> party_hp=<f> adv_hp=<g>" so cells stay distinct.
|
||||
"""
|
||||
sides = tuple(
|
||||
SideSpec(
|
||||
name=side.name,
|
||||
zone=side.zone,
|
||||
combatant_files=side.combatant_files,
|
||||
hp_fraction=(
|
||||
party_hp_fraction if side.name == party_side else adversary_hp_fraction
|
||||
),
|
||||
aware=side.aware,
|
||||
)
|
||||
for side in scenario.sides
|
||||
)
|
||||
return ScenarioSpec(
|
||||
name=(
|
||||
f"{scenario.name} party_hp={party_hp_fraction} "
|
||||
f"adv_hp={adversary_hp_fraction}"
|
||||
),
|
||||
map_file=scenario.map_file,
|
||||
sides=sides,
|
||||
round_cap=scenario.round_cap,
|
||||
)
|
||||
|
||||
|
||||
def danger_map(
|
||||
scenario: ScenarioSpec,
|
||||
party: StrategySpec,
|
||||
adversary: AdversarySpec,
|
||||
*,
|
||||
party_side: str = "players",
|
||||
fractions: Sequence[float] = (0.5, 0.7, 1.0),
|
||||
runs: int = 100,
|
||||
seed: int = 1,
|
||||
) -> tuple[DangerCell, ...]:
|
||||
"""The grid of party HP x adversary HP danger cells (non exhaustive, D14).
|
||||
|
||||
One ``DangerCell`` per combination, party-major (party fraction
|
||||
outermost), each with a distinct scenario name
|
||||
"<base> party_hp=<f> adv_hp=<g>". Every fraction must lie in (0, 1].
|
||||
"""
|
||||
if not fractions:
|
||||
msg = "fractions must be non-empty"
|
||||
raise ValueError(msg)
|
||||
for fraction in fractions:
|
||||
if not 0.0 < fraction <= 1.0:
|
||||
msg = f"fraction must be in (0, 1], got {fraction}"
|
||||
raise ValueError(msg)
|
||||
return tuple(
|
||||
DangerCell(
|
||||
party_hp_fraction=party_frac,
|
||||
adversary_hp_fraction=adv_frac,
|
||||
benchmark=matchup_benchmark(
|
||||
_side_variant(
|
||||
scenario,
|
||||
party_side=party_side,
|
||||
party_hp_fraction=party_frac,
|
||||
adversary_hp_fraction=adv_frac,
|
||||
),
|
||||
party,
|
||||
adversary,
|
||||
party_side=party_side,
|
||||
runs=runs,
|
||||
seed=seed,
|
||||
),
|
||||
)
|
||||
for party_frac in fractions
|
||||
for adv_frac in fractions
|
||||
)
|
||||
|
||||
|
||||
def _validate_frontier(
|
||||
criterion: str,
|
||||
target: float,
|
||||
min_fraction: float,
|
||||
max_fraction: float,
|
||||
precision: float,
|
||||
) -> None:
|
||||
"""Validate frontier parameters (keeps ``frontier`` itself flat)."""
|
||||
_check_criterion(criterion)
|
||||
if criterion == "expected_hp_lost":
|
||||
if target < 0:
|
||||
msg = f"target must be >= 0, got {target}"
|
||||
raise ValueError(msg)
|
||||
elif not 0.0 < target < 1.0:
|
||||
msg = f"target must be in (0, 1), got {target}"
|
||||
raise ValueError(msg)
|
||||
if min_fraction <= 0:
|
||||
msg = f"min_fraction must be > 0, got {min_fraction}"
|
||||
raise ValueError(msg)
|
||||
if max_fraction > 1:
|
||||
msg = f"max_fraction must be <= 1, got {max_fraction}"
|
||||
raise ValueError(msg)
|
||||
if min_fraction > max_fraction:
|
||||
msg = (
|
||||
f"min_fraction must be <= max_fraction, "
|
||||
f"got min={min_fraction} max={max_fraction}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
if precision <= 0:
|
||||
msg = f"precision must be > 0, got {precision}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def frontier(
|
||||
scenario: ScenarioSpec,
|
||||
party: StrategySpec,
|
||||
adversary: AdversarySpec,
|
||||
*,
|
||||
party_side: str = "players",
|
||||
criterion: str = "p_tpk",
|
||||
target: float = 0.2,
|
||||
min_fraction: float = 0.25,
|
||||
max_fraction: float = 1.0,
|
||||
precision: float = 0.05,
|
||||
runs: int = 100,
|
||||
seed: int = 1,
|
||||
) -> float | None:
|
||||
"""The smallest party HP fraction where `criterion` crosses `target`.
|
||||
|
||||
The adversary HP fraction stays at 1.0. Crossing convention (section
|
||||
3.2): ``value >= target`` for p_tpk, p_any_death and expected_hp_lost;
|
||||
``value <= target`` for p_survives. Monotone danger (less party HP is
|
||||
strictly worse): a crossing at ``min_fraction`` already yields the
|
||||
smallest probed fraction, ceiled to the ``precision`` grid; otherwise
|
||||
bisection converges to the smallest crossing fraction and returns
|
||||
``ceil(hi / precision) * precision`` (ceiling keeps the fraction on
|
||||
the crossing side of the threshold). None when not even
|
||||
``max_fraction`` crosses. Probabilities require ``0 < target < 1``;
|
||||
expected_hp_lost requires ``target >= 0``.
|
||||
"""
|
||||
_validate_frontier(criterion, target, min_fraction, max_fraction, precision)
|
||||
|
||||
def _crossed(fraction: float) -> bool:
|
||||
cell = matchup_benchmark(
|
||||
_side_variant(
|
||||
scenario,
|
||||
party_side=party_side,
|
||||
party_hp_fraction=fraction,
|
||||
adversary_hp_fraction=1.0,
|
||||
),
|
||||
party,
|
||||
adversary,
|
||||
party_side=party_side,
|
||||
runs=runs,
|
||||
seed=seed,
|
||||
)
|
||||
value = _danger_value(cell.party_danger, criterion)
|
||||
if criterion == "p_survives":
|
||||
return value <= target
|
||||
return value >= target
|
||||
|
||||
if _crossed(min_fraction):
|
||||
return math.ceil(min_fraction / precision) * precision
|
||||
if not _crossed(max_fraction):
|
||||
return None
|
||||
lo = min_fraction
|
||||
hi = max_fraction
|
||||
while hi - lo > precision:
|
||||
mid = (lo + hi) / 2.0
|
||||
if _crossed(mid):
|
||||
hi = mid
|
||||
else:
|
||||
lo = mid
|
||||
return math.ceil(hi / precision) * precision
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SensitivityEntry:
|
||||
"""One adversary lever raised from the baseline, with its danger delta."""
|
||||
|
||||
levers: str
|
||||
spec: AdversarySpec
|
||||
benchmark: MatchupBenchmark
|
||||
delta: float
|
||||
|
||||
|
||||
def sensitivity(
|
||||
scenario: ScenarioSpec,
|
||||
party: StrategySpec,
|
||||
*,
|
||||
party_side: str = "players",
|
||||
criterion: str = "expected_hp_lost",
|
||||
runs: int = 100,
|
||||
seed: int = 1,
|
||||
max_total_runs: int | None = None,
|
||||
) -> tuple[SensitivityEntry, ...]:
|
||||
"""Rank which single adversary lever moves the party danger the most.
|
||||
|
||||
Baseline is the all-default ``AdversarySpec()``; each of the 7
|
||||
candidate levers varies exactly one knob (target_priority x3,
|
||||
spell_policy=none, retreat x2, use_charges=false). ``delta`` is the
|
||||
signed change of the party ``criterion`` value vs the baseline
|
||||
(positive = more dangerous). Sorted by |delta| descending, ties by
|
||||
lever label ascending. Budget is exactly 8 x runs (baseline + 7
|
||||
levers), enforced before any battle.
|
||||
"""
|
||||
_check_criterion(criterion)
|
||||
cost = 8 * runs
|
||||
if max_total_runs is not None and cost > max_total_runs:
|
||||
msg = (
|
||||
f"robustness budget exceeded: {cost} runs > "
|
||||
f"{max_total_runs} max_total_runs"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
levers = (
|
||||
AdversarySpec(target_priority="weakest_ac"),
|
||||
AdversarySpec(target_priority="lowest_hp"),
|
||||
AdversarySpec(target_priority="healer"),
|
||||
AdversarySpec(spell_policy="none"),
|
||||
AdversarySpec(retreat_hp_fraction=0.25),
|
||||
AdversarySpec(retreat_hp_fraction=0.5),
|
||||
AdversarySpec(use_charges=False),
|
||||
)
|
||||
baseline = matchup_benchmark(
|
||||
scenario,
|
||||
party,
|
||||
AdversarySpec(),
|
||||
party_side=party_side,
|
||||
runs=runs,
|
||||
seed=seed,
|
||||
)
|
||||
base = _danger_value(baseline.party_danger, criterion)
|
||||
entries: list[SensitivityEntry] = []
|
||||
for spec in levers:
|
||||
benchmark = matchup_benchmark(
|
||||
scenario, party, spec, party_side=party_side, runs=runs, seed=seed
|
||||
)
|
||||
entries.append(
|
||||
SensitivityEntry(
|
||||
levers=adversary_name(spec),
|
||||
spec=spec,
|
||||
benchmark=benchmark,
|
||||
delta=_danger_value(benchmark.party_danger, criterion) - base,
|
||||
)
|
||||
)
|
||||
entries.sort(key=lambda entry: (-abs(entry.delta), entry.levers))
|
||||
return tuple(entries)
|
||||
|
||||
|
||||
def _render_line(values: Sequence[str], widths: Sequence[int]) -> str:
|
||||
"""One aligned row: every cell ljust-padded to its column width."""
|
||||
return " ".join(value.ljust(width) for value, width in zip(values, widths, strict=True))
|
||||
|
||||
|
||||
def _render_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str:
|
||||
"""A plain-text grid: header, a full-width dash separator, data rows.
|
||||
|
||||
Column widths are the max width over header and rows (same algorithm
|
||||
as ``sweep.format_sweep``), so every line has identical length and the
|
||||
separator spans the whole header line.
|
||||
"""
|
||||
columns: tuple[Sequence[str], ...] = (headers, *rows)
|
||||
widths = tuple(
|
||||
max(len(row[column]) for row in columns) for column in range(len(headers))
|
||||
)
|
||||
header = _render_line(headers, widths)
|
||||
lines = [header, "-" * len(header)]
|
||||
lines.extend(_render_line(row, widths) for row in rows)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_matchup(benchmark: MatchupBenchmark) -> str:
|
||||
"""Render one matchup: per-side win rate, danger profile and rounds."""
|
||||
headers = (
|
||||
"Scenario",
|
||||
"Runs",
|
||||
"Side",
|
||||
"Win%",
|
||||
"Survive",
|
||||
"TPK",
|
||||
">=1-death",
|
||||
"HP-lost",
|
||||
"Rounds",
|
||||
)
|
||||
adversary_side = next(
|
||||
side for side in benchmark.report.wins if side != benchmark.party_side
|
||||
)
|
||||
party_row = (
|
||||
benchmark.scenario,
|
||||
str(benchmark.runs),
|
||||
"party",
|
||||
f"{benchmark.report.win_rate(benchmark.party_side):.0%}",
|
||||
f"{benchmark.party_danger.p_survives:.0%}",
|
||||
f"{benchmark.party_danger.p_tpk:.0%}",
|
||||
f"{benchmark.party_danger.p_any_death:.0%}",
|
||||
f"{benchmark.party_danger.expected_hp_lost:.1f}",
|
||||
f"{benchmark.report.avg_rounds:.1f}",
|
||||
)
|
||||
adversary_row = (
|
||||
"",
|
||||
"",
|
||||
"adversary",
|
||||
f"{benchmark.report.win_rate(adversary_side):.0%}",
|
||||
f"{benchmark.adversary_danger.p_survives:.0%}",
|
||||
f"{benchmark.adversary_danger.p_tpk:.0%}",
|
||||
f"{benchmark.adversary_danger.p_any_death:.0%}",
|
||||
f"{benchmark.adversary_danger.expected_hp_lost:.1f}",
|
||||
f"{benchmark.report.avg_rounds:.1f}",
|
||||
)
|
||||
return _render_table(headers, (party_row, adversary_row))
|
||||
|
||||
|
||||
def format_robustness(entries: Sequence[RobustnessEntry]) -> str:
|
||||
"""Render the alternating min-max log, one aligned line per turn."""
|
||||
headers = (
|
||||
"Iter",
|
||||
"Party",
|
||||
"Adversary",
|
||||
"P(party-survive)",
|
||||
"P(party-TPK)",
|
||||
"Party-HP",
|
||||
"P(adv-survive)",
|
||||
"P(adv-TPK)",
|
||||
"Adv-HP",
|
||||
)
|
||||
rows = tuple(
|
||||
(
|
||||
str(entry.iteration),
|
||||
entry.party.name,
|
||||
entry.adversary.name,
|
||||
f"{entry.benchmark.party_danger.p_survives:.0%}",
|
||||
f"{entry.benchmark.party_danger.p_tpk:.0%}",
|
||||
f"{entry.benchmark.party_danger.expected_hp_lost:.1f}",
|
||||
f"{entry.benchmark.adversary_danger.p_survives:.0%}",
|
||||
f"{entry.benchmark.adversary_danger.p_tpk:.0%}",
|
||||
f"{entry.benchmark.adversary_danger.expected_hp_lost:.1f}",
|
||||
)
|
||||
for entry in entries
|
||||
)
|
||||
return _render_table(headers, rows)
|
||||
|
||||
|
||||
def format_danger_map(cells: Sequence[DangerCell]) -> str:
|
||||
"""Render the danger grid: rows = party HP, columns = adversary HP.
|
||||
|
||||
Each cell shows party survive% / adversary survive%, both as 0%
|
||||
percentages (order preserved from ``danger_map``, hence the
|
||||
dict.fromkeys dedupe instead of a set).
|
||||
"""
|
||||
party_fractions: tuple[float, ...] = tuple(
|
||||
dict.fromkeys(cell.party_hp_fraction for cell in cells)
|
||||
)
|
||||
adversary_fractions: tuple[float, ...] = tuple(
|
||||
dict.fromkeys(cell.adversary_hp_fraction for cell in cells)
|
||||
)
|
||||
by_cell: dict[tuple[float, float], DangerCell] = {
|
||||
(cell.party_hp_fraction, cell.adversary_hp_fraction): cell for cell in cells
|
||||
}
|
||||
headers = (
|
||||
"Party\\Adv",
|
||||
*(f"adv={fraction}" for fraction in adversary_fractions),
|
||||
)
|
||||
rows: list[tuple[str, ...]] = []
|
||||
for fraction in party_fractions:
|
||||
row: list[str] = [f"party={fraction}"]
|
||||
for adv in adversary_fractions:
|
||||
cell = by_cell[(fraction, adv)]
|
||||
row.append(
|
||||
f"{cell.benchmark.party_danger.p_survives:.0%}/"
|
||||
f"{cell.benchmark.adversary_danger.p_survives:.0%}"
|
||||
)
|
||||
rows.append(tuple(row))
|
||||
return _render_table(headers, rows)
|
||||
|
||||
|
||||
def format_frontier(
|
||||
criterion: str,
|
||||
target: float,
|
||||
fraction: float | None,
|
||||
*,
|
||||
min_fraction: float,
|
||||
max_fraction: float,
|
||||
) -> str:
|
||||
"""Render a frontier result (None -> "no frontier" message)."""
|
||||
if fraction is None:
|
||||
return (
|
||||
f"no frontier for {criterion} {target} "
|
||||
f"in [{min_fraction:.2f}, {max_fraction:.2f}]"
|
||||
)
|
||||
return (
|
||||
f"frontier {criterion} {target}: "
|
||||
f"party hp_fraction = {fraction:.2f}"
|
||||
)
|
||||
|
||||
|
||||
def _format_criterion_value(danger: DangerMetrics, criterion: str) -> str:
|
||||
"""One danger criterion as text: 0% for probabilities, 1 decimal for HP."""
|
||||
value = _danger_value(danger, criterion)
|
||||
if criterion == "expected_hp_lost":
|
||||
return f"{value:.1f}"
|
||||
return f"{value:.0%}"
|
||||
|
||||
|
||||
def format_sensitivity(entries: Sequence[SensitivityEntry], criterion: str) -> str:
|
||||
"""Render the lever ranking: rank, levers, party criterion value, delta."""
|
||||
headers = ("Rank", "Levers", "Value", "Delta")
|
||||
probability = criterion != "expected_hp_lost"
|
||||
rows = tuple(
|
||||
(
|
||||
str(index),
|
||||
entry.levers,
|
||||
_format_criterion_value(entry.benchmark.party_danger, criterion),
|
||||
f"{entry.delta:+.0%}" if probability else f"{entry.delta:+.1f}",
|
||||
)
|
||||
for index, entry in enumerate(entries, start=1)
|
||||
)
|
||||
return _render_table(headers, rows)
|
||||
@@ -35,6 +35,7 @@ class SideSpec:
|
||||
zone: str # deployment zone letter, e.g. "A"
|
||||
combatant_files: tuple[Path, ...] # monster/PJ JSON files, loaded via load_combatant
|
||||
hp_fraction: float = 1.0 # initial HP as a fraction of hp_max, in (0, 1]
|
||||
aware: bool | None = None # SRD "Surprise" override; None keeps Combatant.aware (True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -82,6 +83,19 @@ def _apply_hp_fraction(combatant: Combatant, fraction: float) -> Combatant:
|
||||
)
|
||||
|
||||
|
||||
def _apply_aware(combatant: Combatant, *, aware: bool | None) -> Combatant:
|
||||
"""Apply a side's SRD "Surprise" awareness override via model_copy.
|
||||
|
||||
None keeps the loaded combatant unchanged (Combatant.aware defaults to
|
||||
True, models.py:416-420). SRD "Surprise": only some combatants aware
|
||||
means a surprise round precedes round 1; unaware combatants do not act
|
||||
in it.
|
||||
"""
|
||||
if aware is None:
|
||||
return combatant
|
||||
return combatant.model_copy(update={"aware": aware})
|
||||
|
||||
|
||||
def build_encounter(scenario: ScenarioSpec) -> EncounterSpec:
|
||||
"""Compile a ScenarioSpec into a runner EncounterSpec (deterministic).
|
||||
|
||||
@@ -89,6 +103,12 @@ def build_encounter(scenario: ScenarioSpec) -> EncounterSpec:
|
||||
file with load_combatant, then apply hp_fraction to hp_max via
|
||||
pydantic model_copy(update={"hp_max": max(1, round(hp_max * fraction))})
|
||||
(same model_copy pattern as runner.build_states).
|
||||
- When a side's aware is not None, it is applied to that side's
|
||||
combatants via model_copy(update={"aware": ...}) at the same point
|
||||
where hp_fraction is applied (SRD "Surprise" round semantics: only
|
||||
some aware -> surprise round; Combatant.aware defaults to True, so
|
||||
aware=None keeps the loaded default and is bit-identical to the
|
||||
behaviour without the field).
|
||||
- Duplicate combatant ids are NOT disambiguated here: build_states
|
||||
(called later by run_encounter) handles that. Just list each loaded
|
||||
combatant once per occurrence in combatant_files.
|
||||
@@ -126,7 +146,10 @@ def build_encounter(scenario: ScenarioSpec) -> EncounterSpec:
|
||||
Side(
|
||||
name=side.name,
|
||||
combatants=tuple(
|
||||
_apply_hp_fraction(load_combatant(path), side.hp_fraction)
|
||||
_apply_aware(
|
||||
_apply_hp_fraction(load_combatant(path), side.hp_fraction),
|
||||
aware=side.aware,
|
||||
)
|
||||
for path in side.combatant_files
|
||||
),
|
||||
zone=side.zone,
|
||||
|
||||
@@ -35,7 +35,7 @@ from pf1e_simulator.combat import (
|
||||
default_policy,
|
||||
)
|
||||
|
||||
TargetPriority = Literal["nearest", "weakest_ac", "lowest_hp"]
|
||||
TargetPriority = Literal["nearest", "weakest_ac", "lowest_hp", "healer"]
|
||||
SpellPolicy = Literal["default", "none"]
|
||||
|
||||
|
||||
@@ -83,6 +83,16 @@ def select_target(
|
||||
)
|
||||
case "lowest_hp":
|
||||
return min(enemies, key=lambda s: (s.hp, engine.states.index(s)))
|
||||
case "healer":
|
||||
# Prefer the caster/healer (non-empty combatant.spells); among
|
||||
# several, min by (ac, hp, list order). No active caster on the
|
||||
# enemy side -> identical to weakest_ac.
|
||||
casters = [s for s in enemies if s.combatant.spells]
|
||||
pool = casters or enemies
|
||||
return min(
|
||||
pool,
|
||||
key=lambda s: (s.combatant.ac.total, s.hp, engine.states.index(s)),
|
||||
)
|
||||
|
||||
|
||||
def _melee_actions(
|
||||
|
||||
Reference in New Issue
Block a user