feat(phase2): adversary and robustness cartography (P4)

This commit is contained in:
2026-09-25 21:34:11 +02:00
parent 016d6e1459
commit cd69312ff4
7 changed files with 1692 additions and 2 deletions
+3
View File
@@ -58,6 +58,9 @@ allowed-confusables = ["σ"]
"src/pf1e_simulator/runner.py" = ["PLR0913"]
# client.py: LLMClient/FakeLLMClient need the full safeguard knobs (cache, budget, pricing, retry, breaker)
"src/pf1e_simulator/client.py" = ["PLR0913"]
# robustness.py: the cartography API takes the full knob set (scenario, specs,
# side, runs, seed, fractions, target, precision, budget) as keyword parameters
"src/pf1e_simulator/robustness.py" = ["PLR0913"]
# ── Basedpyright ──────────────────────────────────────────────────────────────
[tool.basedpyright]
+124
View File
@@ -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
+682
View File
@@ -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)
+24 -1
View File
@@ -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,
+11 -1
View File
@@ -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(
+249
View File
@@ -0,0 +1,249 @@
"""Phase-2 adversary compilation tests (docs/phase-2-llm-architecture.md 10 P4.1).
Deterministic, offline: no network, no LLM. Pins the ``AdversarySpec`` /
``compile_adversary`` / ``compile_matchup`` contract (default-policy identity,
per-side routing on a real ``CombatEngine``, empty ``party_side`` rejection),
the additive ``"healer"`` target priority (prefer the enemy caster, fall back
to ``weakest_ac``), and ``SideSpec.aware`` landing on the right side's
combatants plus its SRD "Surprise" round effect. Conventions follow
tests/test_phase2_search.py: small n (runs <= 5), fixed seed, ROOT-relative
path constants.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from pf1e_simulator.adversary import (
AdversarySpec,
adversary_name,
compile_adversary,
compile_matchup,
)
from pf1e_simulator.combat import CombatantState, CombatEngine, default_policy
from pf1e_simulator.grid import Grid
from pf1e_simulator.loaders import load_combatant
from pf1e_simulator.map import load_map
from pf1e_simulator.rng import SeededRng
from pf1e_simulator.runner import build_states, run_encounter
from pf1e_simulator.scenarios import ScenarioSpec, SideSpec, build_encounter
from pf1e_simulator.strategy import StrategySpec, compile_strategy, select_target
if TYPE_CHECKING:
from pf1e_simulator.models import Combatant
from pf1e_simulator.runner import EncounterSpec
ROOT = Path(__file__).resolve().parents[1]
MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
GOBLIN = ROOT / "data" / "monsters" / "goblin.json"
ORC = ROOT / "data" / "monsters" / "orc.json"
NAIRDA = ROOT / "data" / "pcs" / "nairda.json"
RUNS = 5
SEED = 7
ROUND_CAP = 20
PARTY = "players"
_MAP_SPEC = load_map(MAP)
def _c(path: Path) -> Combatant:
return load_combatant(path)
def _scenario(*, monsters_aware: bool | None = None) -> ScenarioSpec:
"""3 goblins (zone A) vs 2 orcs (zone B), optionally with side awareness."""
return ScenarioSpec(
name="goblins-vs-orcs",
map_file=MAP,
sides=(
SideSpec(
name=PARTY,
zone="A",
combatant_files=(GOBLIN, GOBLIN, GOBLIN),
),
SideSpec(
name="monsters",
zone="B",
combatant_files=(ORC, ORC),
aware=monsters_aware,
),
),
round_cap=ROUND_CAP,
)
def _encounter() -> EncounterSpec:
return build_encounter(_scenario())
def _state(
combatant: Combatant, side: str, pos: tuple[int, int], *, hp: int | None = None
) -> CombatantState:
"""A bare runtime state over a loaded combatant (no battle run)."""
return CombatantState(
combatant=combatant,
side=side,
pos=pos,
hp=combatant.hp_max if hp is None else hp,
resources_left={},
)
def _mini_engine(states: list[CombatantState]) -> CombatEngine:
"""A live engine over the sample arena with the given states (no battle run)."""
return CombatEngine(SeededRng(SEED), Grid.from_spec(_MAP_SPEC), states)
# --- identity ---------------------------------------------------------------
def test_compile_adversary_default_is_default_policy() -> None:
"""The all-default AdversarySpec compiles to default_policy by identity."""
assert compile_adversary(AdversarySpec()) is default_policy
def test_compile_matchup_defaults_is_default_policy() -> None:
"""Two all-default specs return default_policy itself, not a wrapper."""
assert compile_matchup(StrategySpec(), AdversarySpec()) is default_policy
def test_adversary_name_mirrors_strategy_name() -> None:
"""Labels render non-default levers in field order; the name field is excluded."""
assert adversary_name(AdversarySpec()) == "default"
assert adversary_name(AdversarySpec(target_priority="healer")) == "healer"
assert (
adversary_name(AdversarySpec(name="custom", spell_policy="none"))
== "spell_policy=none"
)
assert (
adversary_name(
AdversarySpec(target_priority="weakest_ac", retreat_hp_fraction=0.5)
)
== "weakest_ac, retreat=0.5"
)
# --- validation ------------------------------------------------------------
def test_compile_matchup_empty_party_side_raises() -> None:
"""An empty party_side is a ValueError (pre-assigned msg, EM101/EM102)."""
with pytest.raises(ValueError, match="party_side"):
compile_matchup(StrategySpec(), AdversarySpec(), party_side="")
# --- routing ----------------------------------------------------------------
def test_compile_matchup_routes_by_side() -> None:
"""On a real engine, each side of the matchup delegates to its own policy."""
party_spec = StrategySpec(name="party-focus", target_priority="weakest_ac")
adversary_spec = AdversarySpec(name="adv-focus", target_priority="lowest_hp")
matchup = compile_matchup(party_spec, adversary_spec)
assert matchup is not default_policy
encounter = _encounter()
states = build_states(encounter)
engine = CombatEngine(SeededRng(SEED), Grid.from_spec(encounter.map), states)
party_state = next(s for s in states if s.side == PARTY)
monster_state = next(s for s in states if s.side == "monsters")
assert matchup(engine, party_state) == compile_strategy(party_spec)(
engine, party_state
)
assert matchup(engine, monster_state) == compile_adversary(adversary_spec)(
engine, monster_state
)
def test_matchup_and_healer_strategy_run_deterministically() -> None:
"""A healer spec compiles and runs evaluate-style cells bit-identically."""
healer = StrategySpec(name="healer-party", target_priority="healer")
policy = compile_strategy(healer)
assert policy is not default_policy
matchup = compile_matchup(healer, AdversarySpec(target_priority="weakest_ac"))
spec = _encounter()
_, first = run_encounter(spec, runs=RUNS, seed=SEED, policy=policy, collect_runs=True)
_, again = run_encounter(spec, runs=RUNS, seed=SEED, policy=policy, collect_runs=True)
assert first == again
_, matchup_a = run_encounter(
spec, runs=RUNS, seed=SEED, policy=matchup, collect_runs=True
)
_, matchup_b = run_encounter(
spec, runs=RUNS, seed=SEED, policy=matchup, collect_runs=True
)
assert matchup_a == matchup_b
# --- healer targeting -------------------------------------------------------
def test_healer_prefers_the_enemy_caster() -> None:
"""With a monster-side caster (nairda) among non-casters, healer picks her."""
actor = _state(_c(GOBLIN), PARTY, (1, 1))
nairda = _state(_c(NAIRDA), "monsters", (1, 8))
orc = _state(_c(ORC), "monsters", (1, 16))
assert nairda.combatant.spells # caster precondition (nairda.json)
engine = _mini_engine([actor, nairda, orc])
assert select_target(engine, actor, "healer") is nairda
# Discriminator: weakest_ac sees equal AC 13 and prefers the wounded orc.
assert select_target(engine, actor, "weakest_ac") is orc
def test_healer_falls_back_to_weakest_ac_without_casters() -> None:
"""No active caster on the enemy side -> identical to weakest_ac."""
actor = _state(_c(GOBLIN), PARTY, (1, 1))
orc = _state(_c(ORC), "monsters", (1, 8))
goblin_enemy = _state(_c(GOBLIN), "monsters", (1, 16))
assert not orc.combatant.spells
assert not goblin_enemy.combatant.spells
engine = _mini_engine([actor, orc, goblin_enemy])
healer = select_target(engine, actor, "healer")
assert healer is select_target(engine, actor, "weakest_ac")
assert healer is orc # AC 13 < goblin AC 16
# --- SideSpec.aware (SRD "Surprise") ---------------------------------------
def test_side_spec_aware_applies_per_side() -> None:
"""aware=False lands on that side's combatants only; the other side keeps True."""
encounter = build_encounter(_scenario(monsters_aware=False))
players, monsters = encounter.sides
assert all(c.aware is True for c in players.combatants)
assert all(c.aware is False for c in monsters.combatants)
states = build_states(encounter)
assert all(s.combatant.aware is True for s in states if s.side == PARTY)
assert all(s.combatant.aware is False for s in states if s.side == "monsters")
untouched = build_encounter(_scenario())
assert all(c.aware is True for side in untouched.sides for c in side.combatants)
def test_side_spec_aware_triggers_surprise_round() -> None:
"""Only some aware -> a surprise round runs; the default (aware=None) has none."""
surprise = _encounter_states_and_run(_scenario(monsters_aware=False))
assert "surprise round" in surprise
default = _encounter_states_and_run(_scenario())
assert "surprise round" not in default
def _encounter_states_and_run(scenario: ScenarioSpec) -> tuple[str, ...]:
"""Run one seeded battle of `scenario` and return its transcript."""
encounter = build_encounter(scenario)
engine = CombatEngine(
SeededRng(SEED),
Grid.from_spec(encounter.map),
build_states(encounter),
round_cap=ROUND_CAP,
)
return engine.run().transcript
+599
View File
@@ -0,0 +1,599 @@
"""Tests for the robustness layer (P4.2/P4.3, docs section 10)."""
from __future__ import annotations
import itertools
from pathlib import Path
import pytest
from pf1e_simulator import robustness
from pf1e_simulator.adversary import AdversarySpec
from pf1e_simulator.robustness import (
DangerCell,
MatchupBenchmark,
RobustnessEntry,
SensitivityEntry,
adversarial_analysis,
danger_map,
format_danger_map,
format_frontier,
format_matchup,
format_robustness,
format_sensitivity,
frontier,
matchup_benchmark,
sensitivity,
)
from pf1e_simulator.scenarios import ScenarioSpec, SideSpec
from pf1e_simulator.strategy import StrategySpec
ROOT = Path(__file__).resolve().parent.parent
RUNS = 5
SEED = 7
ROUND_CAP = 20
PARTY = "players"
ARENA = ROOT / "data" / "maps" / "sample_arena.yaml"
GOBLIN = ROOT / "data" / "monsters" / "goblin.json"
ORC = ROOT / "data" / "monsters" / "orc.json"
def _scenario(
*, name: str = "goblins-vs-orcs", sides: tuple[SideSpec, ...] | None = None
) -> ScenarioSpec:
return ScenarioSpec(
name=name,
map_file=ARENA,
sides=sides
or (
SideSpec(PARTY, "A", (GOBLIN, GOBLIN, GOBLIN)),
SideSpec("monsters", "B", (ORC, ORC)),
),
round_cap=ROUND_CAP,
)
def _party(*, name: str = "default") -> StrategySpec:
return StrategySpec(name=name)
def _three_side_scenario() -> ScenarioSpec:
return ScenarioSpec(
name="three-sides",
map_file=ARENA,
sides=(
SideSpec(PARTY, "A", (GOBLIN,)),
SideSpec("monsters", "B", (ORC,)),
SideSpec("spectators", "C", (GOBLIN,)),
),
round_cap=ROUND_CAP,
)
def test_matchup_requires_exactly_two_sides() -> None:
with pytest.raises(ValueError, match="exactly 2 sides"):
matchup_benchmark(
_three_side_scenario(), _party(), AdversarySpec(), runs=RUNS, seed=SEED
)
def test_matchup_requires_valid_party_side() -> None:
scenario = _scenario()
with pytest.raises(ValueError, match="party_side"):
matchup_benchmark(
scenario, _party(), AdversarySpec(), party_side="", runs=RUNS, seed=SEED
)
with pytest.raises(ValueError, match="is not a side"):
matchup_benchmark(
scenario,
_party(),
AdversarySpec(),
party_side="spectators",
runs=RUNS,
seed=SEED,
)
def test_matchup_reports_both_dangers_and_is_reproducible() -> None:
scenario = _scenario()
party = _party()
adversary = AdversarySpec()
first = matchup_benchmark(scenario, party, adversary, runs=RUNS, seed=SEED)
assert isinstance(first, MatchupBenchmark)
assert first.scenario == "goblins-vs-orcs"
assert first.party == "default"
assert first.adversary == "default"
assert first.runs == RUNS
assert first.seed == SEED
assert first.party_side == PARTY
assert first.report.runs == RUNS
for danger in (first.party_danger, first.adversary_danger):
assert 0.0 <= danger.p_survives <= 1.0
assert 0.0 <= danger.p_tpk <= 1.0
assert 0.0 <= danger.p_any_death <= 1.0
assert danger.expected_hp_lost >= 0.0
second = matchup_benchmark(scenario, party, adversary, runs=RUNS, seed=SEED)
assert first == second
assert second.party_danger == first.party_danger
def test_adversarial_analysis_alternates_and_converges() -> None:
scenario = _scenario()
party = _party()
specs = (
AdversarySpec(name="retreat", retreat_hp_fraction=0.5),
AdversarySpec(name="plain"),
)
entries = adversarial_analysis(
scenario,
party_candidates=[party],
adversary_candidates=specs,
runs=RUNS,
seed=SEED,
iterations=2,
)
assert len(entries) == 2
assert all(isinstance(entry, RobustnessEntry) for entry in entries)
assert [entry.iteration for entry in entries] == [1, 2]
# the adversary best response is the candidate with the worst party outlook
benchmarks = {
spec.name: matchup_benchmark(
scenario, party, spec, runs=RUNS, seed=SEED
)
for spec in specs
}
expected = min(
benchmarks.values(),
key=lambda b: (
b.party_danger.p_survives,
-b.party_danger.expected_hp_lost,
b.adversary,
),
)
assert all(entry.benchmark == expected for entry in entries)
assert entries[0].adversary.name == expected.adversary
def test_adversarial_party_best_response() -> None:
scenario = _scenario()
parties = (
StrategySpec(),
StrategySpec(name="bully", target_priority="lowest_hp"),
)
adversary = AdversarySpec()
entries = adversarial_analysis(
scenario,
party_candidates=parties,
adversary_candidates=[adversary],
runs=RUNS,
seed=SEED,
iterations=1,
)
benchmarks = {
spec.name: matchup_benchmark(
scenario, spec, adversary, runs=RUNS, seed=SEED
)
for spec in parties
}
expected = max(
benchmarks.values(),
key=lambda b: (
b.party_danger.p_survives,
-b.party_danger.expected_hp_lost,
b.party,
),
)
assert entries[0].benchmark == expected
def test_adversarial_candidates_required() -> None:
scenario = _scenario()
party = _party()
with pytest.raises(ValueError, match="party_candidates must be non-empty"):
adversarial_analysis(
scenario,
party_candidates=[],
adversary_candidates=[AdversarySpec()],
runs=RUNS,
seed=SEED,
)
with pytest.raises(ValueError, match="adversary_candidates must be non-empty"):
adversarial_analysis(
scenario,
party_candidates=[party],
adversary_candidates=[],
runs=RUNS,
seed=SEED,
)
def test_adversarial_budget_is_enforced_before_any_battle() -> None:
scenario = _scenario()
party = _party()
specs = (
AdversarySpec(name="retreat", retreat_hp_fraction=0.5),
AdversarySpec(name="plain"),
)
cost = 2 * (1 + len(specs)) * RUNS
with pytest.raises(ValueError, match="robustness budget exceeded"):
adversarial_analysis(
scenario,
party_candidates=[party],
adversary_candidates=specs,
runs=RUNS,
seed=SEED,
iterations=2,
max_total_runs=cost - 1,
)
entries = adversarial_analysis(
scenario,
party_candidates=[party],
adversary_candidates=specs,
runs=RUNS,
seed=SEED,
iterations=2,
max_total_runs=cost,
)
assert len(entries) == 2
def test_danger_map_is_3x3_party_major_with_variant_names() -> None:
scenario = _scenario()
cells = danger_map(scenario, _party(), AdversarySpec(), runs=RUNS, seed=SEED)
assert len(cells) == 9
assert all(isinstance(cell, DangerCell) for cell in cells)
order = [(cell.party_hp_fraction, cell.adversary_hp_fraction) for cell in cells]
assert order == [(p, a) for p in (0.5, 0.7, 1.0) for a in (0.5, 0.7, 1.0)]
assert cells[0].benchmark.scenario == "goblins-vs-orcs party_hp=0.5 adv_hp=0.5"
assert cells[-1].benchmark.scenario == "goblins-vs-orcs party_hp=1.0 adv_hp=1.0"
for cell in cells:
assert 0.0 <= cell.benchmark.party_danger.p_survives <= 1.0
def test_danger_map_rejects_out_of_range_fractions() -> None:
scenario = _scenario()
with pytest.raises(ValueError, match="fraction must be in"):
danger_map(
scenario, _party(), AdversarySpec(), fractions=(0.0,), runs=RUNS, seed=SEED
)
with pytest.raises(ValueError, match="fraction must be in"):
danger_map(
scenario, _party(), AdversarySpec(), fractions=(1.5,), runs=RUNS, seed=SEED
)
with pytest.raises(ValueError, match="fractions must be non-empty"):
danger_map(
scenario, _party(), AdversarySpec(), fractions=(), runs=RUNS, seed=SEED
)
def test_danger_map_is_reproducible() -> None:
scenario = _scenario()
first = danger_map(scenario, _party(), AdversarySpec(), runs=RUNS, seed=SEED)
second = danger_map(scenario, _party(), AdversarySpec(), runs=RUNS, seed=SEED)
assert first == second
def test_frontier_finds_reached_threshold() -> None:
scenario = _scenario()
result = frontier(
scenario,
_party(),
AdversarySpec(),
criterion="expected_hp_lost",
target=0.0,
min_fraction=0.25,
max_fraction=1.0,
precision=0.05,
runs=RUNS,
seed=SEED,
)
# expected hp lost is always >= 0, so the threshold is crossed everywhere:
# bisection converges to the smallest probed fraction, rounded to precision
assert result == pytest.approx(0.25)
def test_frontier_returns_none_when_threshold_unreachable() -> None:
scenario = _scenario()
result = frontier(
scenario,
_party(),
AdversarySpec(),
criterion="expected_hp_lost",
target=1e9,
min_fraction=0.25,
max_fraction=1.0,
precision=0.05,
runs=RUNS,
seed=SEED,
)
assert result is None
def test_frontier_is_reproducible() -> None:
scenario = _scenario()
first = frontier(
scenario,
_party(),
AdversarySpec(),
criterion="expected_hp_lost",
target=0.0,
runs=RUNS,
seed=SEED,
)
second = frontier(
scenario,
_party(),
AdversarySpec(),
criterion="expected_hp_lost",
target=0.0,
runs=RUNS,
seed=SEED,
)
assert first == second
def test_frontier_returns_precision_multiple() -> None:
scenario = _scenario()
result = frontier(
scenario,
_party(),
AdversarySpec(),
criterion="expected_hp_lost",
target=0.0,
min_fraction=0.3,
max_fraction=1.0,
precision=0.1,
runs=RUNS,
seed=SEED,
)
assert result is not None
# bisection stops within precision of min_fraction; the result is the
# nearest precision multiple in [min_fraction, min_fraction + precision]
assert 0.3 <= result <= 0.4
assert result == pytest.approx(round(result / 0.1) * 0.1)
def test_frontier_p_survives_uses_inverse_threshold() -> None:
scenario = _scenario()
result = frontier(
scenario,
_party(),
AdversarySpec(),
criterion="p_survives",
target=0.9999,
min_fraction=0.5,
max_fraction=1.0,
precision=0.25,
runs=RUNS,
seed=SEED,
)
# convention: p_survives crosses when value <= target; a present result
# is a precision multiple within the probed range
if result is not None:
assert 0.5 <= result <= 0.75
assert result == pytest.approx(round(result / 0.25) * 0.25)
def test_frontier_validates_arguments() -> None:
scenario = _scenario()
with pytest.raises(ValueError, match="criterion must be one of"):
frontier(
scenario, _party(), AdversarySpec(), criterion="bogus", runs=RUNS, seed=SEED
)
with pytest.raises(ValueError, match="target must be in"):
frontier(
scenario,
_party(),
AdversarySpec(),
criterion="p_tpk",
target=0.0,
runs=RUNS,
seed=SEED,
)
with pytest.raises(ValueError, match="target must be in"):
frontier(
scenario,
_party(),
AdversarySpec(),
criterion="p_survives",
target=1.0,
runs=RUNS,
seed=SEED,
)
with pytest.raises(ValueError, match="target must be >= 0"):
frontier(
scenario,
_party(),
AdversarySpec(),
criterion="expected_hp_lost",
target=-1.0,
runs=RUNS,
seed=SEED,
)
with pytest.raises(ValueError, match="min_fraction must be > 0"):
frontier(
scenario,
_party(),
AdversarySpec(),
criterion="expected_hp_lost",
target=0.0,
min_fraction=0.0,
runs=RUNS,
seed=SEED,
)
with pytest.raises(ValueError, match="max_fraction must be <= 1"):
frontier(
scenario,
_party(),
AdversarySpec(),
criterion="expected_hp_lost",
target=0.0,
max_fraction=1.5,
runs=RUNS,
seed=SEED,
)
with pytest.raises(ValueError, match="min_fraction must be <= max_fraction"):
frontier(
scenario,
_party(),
AdversarySpec(),
criterion="expected_hp_lost",
target=0.0,
min_fraction=0.9,
max_fraction=0.5,
runs=RUNS,
seed=SEED,
)
with pytest.raises(ValueError, match="precision must be > 0"):
frontier(
scenario,
_party(),
AdversarySpec(),
criterion="expected_hp_lost",
target=0.0,
precision=0.0,
runs=RUNS,
seed=SEED,
)
def test_sensitivity_ranks_levers_and_reports_signed_deltas() -> None:
scenario = _scenario()
party = _party()
entries = sensitivity(scenario, party, runs=RUNS, seed=SEED)
assert len(entries) == 7
assert all(isinstance(entry, SensitivityEntry) for entry in entries)
assert {entry.levers for entry in entries} == {
"weakest_ac",
"lowest_hp",
"healer",
"spell_policy=none",
"retreat=0.25",
"retreat=0.5",
"use_charges=false",
}
for previous, current in itertools.pairwise(entries):
assert abs(previous.delta) >= abs(current.delta)
if abs(previous.delta) == abs(current.delta):
assert previous.levers <= current.levers
# delta is the signed change vs the all-default baseline
baseline = matchup_benchmark(
scenario, party, AdversarySpec(), runs=RUNS, seed=SEED
)
base = baseline.party_danger.expected_hp_lost
for entry in entries:
assert entry.delta == pytest.approx(
entry.benchmark.party_danger.expected_hp_lost - base
)
def test_sensitivity_rejects_unknown_criterion() -> None:
scenario = _scenario()
with pytest.raises(ValueError, match="criterion must be one of"):
sensitivity(scenario, _party(), criterion="bogus", runs=RUNS, seed=SEED)
def test_sensitivity_budget_is_enforced() -> None:
scenario = _scenario()
party = _party()
with pytest.raises(ValueError, match="robustness budget exceeded"):
sensitivity(
scenario, party, runs=RUNS, seed=SEED, max_total_runs=8 * RUNS - 1
)
entries = sensitivity(
scenario, party, runs=RUNS, seed=SEED, max_total_runs=8 * RUNS
)
assert len(entries) == 7
def test_format_matchup_ascii_aligned() -> None:
benchmark_ = matchup_benchmark(
_scenario(), _party(), AdversarySpec(), runs=RUNS, seed=SEED
)
text = format_matchup(benchmark_)
lines = text.splitlines()
assert lines[0].split() == [
"Scenario",
"Runs",
"Side",
"Win%",
"Survive",
"TPK",
">=1-death",
"HP-lost",
"Rounds",
]
assert lines[1] == "-" * len(lines[0])
assert "party" in lines[2]
assert "adversary" in lines[3]
assert all(len(line) == len(lines[0]) for line in lines)
assert text.isascii()
def test_format_robustness_ascii_aligned() -> None:
scenario = _scenario()
entries = adversarial_analysis(
scenario,
party_candidates=[_party()],
adversary_candidates=[AdversarySpec()],
runs=RUNS,
seed=SEED,
iterations=2,
)
text = format_robustness(entries)
lines = text.splitlines()
assert lines[0].split() == [
"Iter",
"Party",
"Adversary",
"P(party-survive)",
"P(party-TPK)",
"Party-HP",
"P(adv-survive)",
"P(adv-TPK)",
"Adv-HP",
]
assert len(lines) == 2 + 2
assert all(len(line) == len(lines[0]) for line in lines)
assert text.isascii()
def test_format_danger_map_ascii_aligned() -> None:
cells = danger_map(_scenario(), _party(), AdversarySpec(), runs=RUNS, seed=SEED)
text = format_danger_map(cells)
lines = text.splitlines()
assert lines[0].startswith("Party\\Adv")
assert "adv=0.5" in lines[0]
assert "party=0.5" in lines[2]
assert "party=1.0" in lines[4]
assert "/" in lines[2]
assert all(len(line) == len(lines[0]) for line in lines)
assert text.isascii()
def test_format_frontier_messages() -> None:
assert (
format_frontier("p_tpk", 0.2, None, min_fraction=0.25, max_fraction=1.0)
== "no frontier for p_tpk 0.2 in [0.25, 1.00]"
)
assert (
format_frontier("p_tpk", 0.2, 0.35, min_fraction=0.25, max_fraction=1.0)
== "frontier p_tpk 0.2: party hp_fraction = 0.35"
)
def test_format_sensitivity_ascii_aligned() -> None:
entries = sensitivity(_scenario(), _party(), runs=RUNS, seed=SEED)
text = format_sensitivity(entries, "expected_hp_lost")
lines = text.splitlines()
assert lines[0].split() == ["Rank", "Levers", "Value", "Delta"]
assert len(lines) == 2 + 7
assert lines[1].split()[-1].startswith(("+", "-"))
assert all(len(line) == len(lines[0]) for line in lines)
assert text.isascii()
def test_robustness_module_does_not_import_search() -> None:
source = Path(robustness.__file__).resolve().read_text(encoding="utf-8")
assert "search" not in source