feat(phase2): observation-only LLMPolicy entry (D1) + pinned schema_version (P0.1)
- LLMPolicy is now Callable[[Observation], tuple[Action, ...]]: the snapshot is the policy's sole information source; the live CombatantState is no longer passed to new-style policies. - CombatEngine._llm_adapter keeps a compatibility path for pre-D1 two-argument callables: the signature is inspected ONCE at wrap time (_accepts_state) and legacy policies still receive (observation, state). - Observation.to_dict() carries schema_version (OBSERVATION_SCHEMA_VERSION=1) as its first pinned key; bump only on removal/meaning change. - Pinned keyset tests updated (test_observation, test_observation_enriched); new tests: observation-only e2e, legacy 2-arg e2e through the adapter, schema_version pin + JSON-serializability. - README: documents the new entry signature and the schema_version key.
This commit is contained in:
@@ -121,6 +121,7 @@ Phase 0 documented deviations from PF1e (conventions):
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from itertools import pairwise
|
||||
@@ -1259,6 +1260,12 @@ class ChargeResult:
|
||||
reason: str | None
|
||||
|
||||
|
||||
# Observation schema version (P0.1, docs/phase-2-llm-architecture.md section
|
||||
# 10). Bump only when a top-level key is removed or its meaning changes;
|
||||
# additive keys keep the version.
|
||||
OBSERVATION_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Observation:
|
||||
"""Serializable snapshot of combat state for a policy/LLM.
|
||||
@@ -1269,7 +1276,9 @@ class Observation:
|
||||
collections are copies (tuples/dicts), never read back by the engine, so a
|
||||
policy may mutate them freely without touching combat; the snapshot is
|
||||
JSON-serializable (the top level is a frozen dataclass; the inner dicts are
|
||||
plain mutable copies, not deep-frozen).
|
||||
plain mutable copies, not deep-frozen). ``to_dict()`` carries
|
||||
``schema_version`` (``OBSERVATION_SCHEMA_VERSION``) as its first key so a
|
||||
consumer can detect breaking schema changes.
|
||||
|
||||
``combatants`` holds one dict per combatant in ``self._states`` order;
|
||||
``pairs`` holds one dict per (acting combatant x each other combatant) with
|
||||
@@ -1310,6 +1319,7 @@ class Observation:
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
"""Return a JSON-serializable dict with the pinned top-level keys."""
|
||||
return {
|
||||
"schema_version": OBSERVATION_SCHEMA_VERSION,
|
||||
"round": self.round,
|
||||
"acting_combatant": self.acting_combatant,
|
||||
"combatants": self.combatants,
|
||||
@@ -1613,7 +1623,7 @@ class CombatEngine:
|
||||
*,
|
||||
round_cap: int = 100,
|
||||
policy: Policy | None = None,
|
||||
llm_policy: LLMPolicy | None = None,
|
||||
llm_policy: LLMPolicy | LegacyLLMPolicy | None = None,
|
||||
spell_registry: dict[str, SpellSpec] | None = None,
|
||||
reaction_policy: ReactionPolicy | None = None,
|
||||
monsters_die_at_zero: bool = False,
|
||||
@@ -1685,18 +1695,24 @@ class CombatEngine:
|
||||
if self._monsters_die_at_zero and not _is_pc_combatant(state.combatant):
|
||||
state.dies_at_zero = True
|
||||
|
||||
def _llm_adapter(self, llm_policy: LLMPolicy) -> Policy:
|
||||
"""Wrap an ``LLMPolicy`` into a ``Policy``.
|
||||
def _llm_adapter(self, llm_policy: LLMPolicy | LegacyLLMPolicy) -> Policy:
|
||||
"""Wrap an ``LLMPolicy`` (or a pre-D1 two-argument callable) into a ``Policy``.
|
||||
|
||||
At the point of invocation (``actions = self._policy(self, state)`` in
|
||||
``_take_turn``), the Observation is built for the current state and
|
||||
delegated to ``llm_policy(observation, state)``. The adapter consumes
|
||||
no RNG and mutates no state.
|
||||
delegated. New-style policies get ``llm_policy(observation)`` only;
|
||||
a legacy ``llm_policy(observation, state)`` callable is detected ONCE
|
||||
here (signature inspection, not per turn) and keeps receiving the live
|
||||
state. The adapter consumes no RNG and mutates no state.
|
||||
"""
|
||||
two_arg = _accepts_state(llm_policy)
|
||||
call: Callable[..., tuple[Action, ...]] = llm_policy
|
||||
|
||||
def adapter(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||
observation = self._build_observation(state)
|
||||
return llm_policy(observation, state)
|
||||
if two_arg:
|
||||
return call(observation, state)
|
||||
return call(observation)
|
||||
|
||||
return adapter
|
||||
|
||||
@@ -10572,7 +10588,36 @@ def _ready_payload(action: Action) -> Action:
|
||||
|
||||
Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
||||
|
||||
LLMPolicy = Callable[[Observation, CombatantState], tuple[Action, ...]]
|
||||
# D1 (docs/phase-2-llm-architecture.md section 13): the LLM policy entry is
|
||||
# observation-only -- the snapshot is the policy's sole information source and
|
||||
# the live CombatantState is never handed to a new-style policy. Pre-D1
|
||||
# two-argument callables remain accepted via CombatEngine._llm_adapter.
|
||||
LLMPolicy = Callable[[Observation], tuple[Action, ...]]
|
||||
|
||||
LegacyLLMPolicy = Callable[[Observation, CombatantState], tuple[Action, ...]]
|
||||
|
||||
_LEGACY_LLM_POLICY_ARITY = 2
|
||||
|
||||
|
||||
def _accepts_state(llm_policy: LLMPolicy | LegacyLLMPolicy) -> bool:
|
||||
"""True when ``llm_policy`` is a pre-D1 callable taking (observation, state).
|
||||
|
||||
Counts REQUIRED positional parameters (a defaulted second parameter is a
|
||||
new-style policy that tolerates the 1-argument call). Callables whose
|
||||
signature cannot be inspected default to the new-style 1-argument call.
|
||||
"""
|
||||
try:
|
||||
parameters = list(inspect.signature(llm_policy).parameters.values())
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
required_positional = [
|
||||
p
|
||||
for p in parameters
|
||||
if p.default is inspect.Parameter.empty
|
||||
and p.kind
|
||||
in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
||||
]
|
||||
return len(required_positional) >= _LEGACY_LLM_POLICY_ARITY
|
||||
|
||||
ReactionPolicy = Callable[
|
||||
[CombatEngine, CombatantState, ReactionEvent], Action | None
|
||||
|
||||
Reference in New Issue
Block a user