de88543c5c
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.
136 lines
5.0 KiB
Python
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),
|
|
)
|