Files
pf1e-simulator/src/pf1e_simulator/objectives.py
T
ctan de88543c5c feat(phase2): danger-cartography spike - per-run outcomes, objectives, focus-fire adversary
run_encounter gains an opt-in collect_runs flag returning one RunOutcome
per battle (winner, rounds, per-combatant id/side/hp/hp_max/dead) next to
the EncounterReport; default behavior is unchanged. objectives.py provides
the pure optimizer primitives: P(survives all), P(TPK), P(>=1 death),
expected hp lost, expected rounds and a worst-decile (CVaR-style) tail
metric. phase2_spike.py holds the spike-level strengthened monster policy
(focus fire on the lowest-AC enemy, ties to the wounded; party side stays
on default_policy). tests/test_phase2_danger_map.py prints the danger-map
sketch: warband and lich scenarios measured default vs focus-fire at
100 runs / seed 11, pinning the delta deterministically.
2026-09-21 12:07:23 +02:00

136 lines
5.0 KiB
Python

"""Objective primitives for the Phase-2 optimizer and danger cartography.
Pure functions over the per-run summaries produced by
``run_encounter(..., collect_runs=True)`` (docs/phase-2-llm-architecture.md
sections 3.1 and 3.2). These are the metrics an optimizer maximizes (party
survival) and a danger map reports per scenario - no engine access, no
state, no RNG: given the same ``RunOutcome`` tuple they always return the
same numbers, which is what makes them safe as a search objective.
Definitions (all probabilities are empirical frequencies over the runs;
``party`` is the side name being measured, e.g. ``"players"``):
- ``p_survives`` : P(party survives all) - no party member dead.
- ``p_tpk`` : P(total party kill) - every party member dead.
- ``p_any_death`` : P(at least one party member death).
- ``expected_hp_lost``: mean total party hit points lost (hp_max - hp,
clamped to [0, hp_max] per member: healing above max never counts
negative, and overkill on a corpse is not counted as extra loss).
- ``expected_rounds`` : mean battle length.
- ``worst_decile_party_hp`` : TAIL metric - the mean party hp *remaining*
over the worst decile of runs (the 10% with the largest hp loss, at
least 1 run): a CVaR-style measure so rare disasters are not averaged
away. A dead or dying member contributes 0 hp remaining.
A side with no members in a run (should not happen in a normal encounter)
is treated vacuously: survives all, no TPK, 0 hp lost.
"""
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.runner import CombatantOutcome, RunOutcome
@dataclass(frozen=True)
class DangerMetrics:
"""The full danger profile of one party over a batch of run outcomes."""
runs: int
p_survives: float
p_tpk: float
p_any_death: float
expected_hp_lost: float
expected_rounds: float
worst_decile_party_hp: float
def _party_members(outcome: RunOutcome, party: str) -> tuple[CombatantOutcome, ...]:
return tuple(c for c in outcome.combatants if c.side == party)
def _hp_lost(outcome: RunOutcome, party: str) -> int:
return sum(
min(c.hp_max, max(0, c.hp_max - c.hp))
for c in _party_members(outcome, party)
)
def _hp_remaining(outcome: RunOutcome, party: str) -> int:
return sum(max(0, c.hp) for c in _party_members(outcome, party))
def p_survives(outcomes: Sequence[RunOutcome], party: str) -> float:
"""P(no member of `party` is dead at the end of a run)."""
if not outcomes:
return 0.0
hits = sum(1 for o in outcomes if not any(c.dead for c in _party_members(o, party)))
return hits / len(outcomes)
def p_tpk(outcomes: Sequence[RunOutcome], party: str) -> float:
"""P(every member of `party` is dead at the end of a run)."""
if not outcomes:
return 0.0
hits = 0
for o in outcomes:
members = _party_members(o, party)
if members and all(c.dead for c in members):
hits += 1
return hits / len(outcomes)
def p_any_death(outcomes: Sequence[RunOutcome], party: str) -> float:
"""P(at least one member of `party` is dead at the end of a run)."""
if not outcomes:
return 0.0
hits = sum(1 for o in outcomes if any(c.dead for c in _party_members(o, party)))
return hits / len(outcomes)
def expected_hp_lost(outcomes: Sequence[RunOutcome], party: str) -> float:
"""Mean total `party` hit points lost per run (clamped to [0, hp_max] per member)."""
if not outcomes:
return 0.0
return sum(_hp_lost(o, party) for o in outcomes) / len(outcomes)
def expected_rounds(outcomes: Sequence[RunOutcome]) -> float:
"""Mean number of rounds per run (draws count their full round cap)."""
if not outcomes:
return 0.0
return sum(o.rounds for o in outcomes) / len(outcomes)
def worst_decile_party_hp(outcomes: Sequence[RunOutcome], party: str) -> float:
"""Mean `party` hp remaining over the worst-decile runs (tail/CVaR metric).
The worst decile is the ``max(1, ceil(10%))`` runs with the largest hp
loss; ties keep list order. Complements ``expected_hp_lost``: the mean
hides tail disasters, this number does not.
"""
if not outcomes:
return 0.0
tail = max(1, math.ceil(len(outcomes) / 10))
worst = sorted(outcomes, key=lambda o: _hp_lost(o, party), reverse=True)[:tail]
return sum(_hp_remaining(o, party) for o in worst) / tail
def danger_metrics(outcomes: Sequence[RunOutcome], party: str) -> DangerMetrics:
"""Compute the whole danger profile in one call (the optimizer's objective)."""
return DangerMetrics(
runs=len(outcomes),
p_survives=p_survives(outcomes, party),
p_tpk=p_tpk(outcomes, party),
p_any_death=p_any_death(outcomes, party),
expected_hp_lost=expected_hp_lost(outcomes, party),
expected_rounds=expected_rounds(outcomes),
worst_decile_party_hp=worst_decile_party_hp(outcomes, party),
)