"""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), )