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:
@@ -9,9 +9,12 @@ Covers the pinned schema:
|
||||
- Terrain/elevation layers.
|
||||
- Frozen copies: mutating live state after construction does not change the
|
||||
already-built Observation.
|
||||
- ``to_dict()`` is JSON-serializable and exposes no ``engine.*`` callables.
|
||||
- ``to_dict()`` is JSON-serializable, carries the pinned ``schema_version``
|
||||
key, and exposes no ``engine.*`` callables.
|
||||
- The ``llm_policy`` adapter builds the Observation at the point of policy
|
||||
invocation and delegates to ``llm_policy(observation, state)``.
|
||||
invocation and delegates to ``llm_policy(observation)`` (D1: observation
|
||||
only); a legacy ``llm_policy(observation, state)`` callable keeps working
|
||||
through the compatibility adapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,7 +23,13 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, Observation
|
||||
from pf1e_simulator.combat import (
|
||||
OBSERVATION_SCHEMA_VERSION,
|
||||
Action,
|
||||
CombatantState,
|
||||
CombatEngine,
|
||||
Observation,
|
||||
)
|
||||
from pf1e_simulator.conditions import SHAKEN
|
||||
from pf1e_simulator.dice import parse_dice
|
||||
from pf1e_simulator.effects import ActiveCondition
|
||||
@@ -441,11 +450,11 @@ def test_observation_frozen_copies() -> None:
|
||||
|
||||
|
||||
def test_llm_policy_wrapper_e2e() -> None:
|
||||
"""Given: a CombatEngine with an llm_policy that reads ONLY the observation
|
||||
"""Given: a CombatEngine with an observation-only llm_policy (D1 signature)
|
||||
When: the battle runs
|
||||
Then: the policy's decisions are honored and the transcript is coherent."""
|
||||
|
||||
def llm_policy(obs: Observation, state: CombatantState) -> tuple:
|
||||
def llm_policy(obs: Observation) -> tuple:
|
||||
# Read only the observation: full_attack if the target is adjacent,
|
||||
# otherwise move toward it.
|
||||
target_id = obs.combatants[obs.pairs[0]["target_index"]]["id"]
|
||||
@@ -470,6 +479,65 @@ def test_llm_policy_wrapper_e2e() -> None:
|
||||
assert any("move" in line for line in result.transcript)
|
||||
|
||||
|
||||
def test_legacy_two_arg_llm_policy_still_works() -> None:
|
||||
"""Given: a pre-D1 llm_policy taking (observation, state)
|
||||
When: the battle runs through the compatibility adapter
|
||||
Then: the policy receives BOTH arguments and its decisions are honored."""
|
||||
seen_states: list[CombatantState] = []
|
||||
|
||||
def legacy_policy(obs: Observation, state: CombatantState) -> tuple:
|
||||
seen_states.append(state)
|
||||
assert state.combatant.id == obs.combatants[obs.acting_combatant]["id"]
|
||||
target_id = obs.combatants[obs.pairs[0]["target_index"]]["id"]
|
||||
return (Action(kind="move", target_id=target_id),)
|
||||
|
||||
hero = make_combatant("hero")
|
||||
foe = make_combatant("foe")
|
||||
hero_state = make_state(hero, "players", (0, 0))
|
||||
foe_state = make_state(foe, "monsters", (0, 3))
|
||||
engine = CombatEngine(
|
||||
ScriptedRng([10, 9, 8, 15, 3, 5]),
|
||||
make_grid(),
|
||||
[hero_state, foe_state],
|
||||
round_cap=1,
|
||||
llm_policy=legacy_policy,
|
||||
)
|
||||
|
||||
result = engine.run()
|
||||
assert seen_states, "legacy policy was never invoked"
|
||||
assert any("move" in line for line in result.transcript)
|
||||
|
||||
|
||||
def test_observation_schema_version_pinned() -> None:
|
||||
"""Given: a built Observation
|
||||
When: to_dict() is called
|
||||
Then: the top-level key set is exactly the pinned keys and
|
||||
schema_version == OBSERVATION_SCHEMA_VERSION, JSON-serializable."""
|
||||
hero = make_combatant("hero")
|
||||
foe = make_combatant("foe")
|
||||
hero_state = make_state(hero, "players", (0, 0))
|
||||
foe_state = make_state(foe, "monsters", (0, 3))
|
||||
engine = make_engine([hero_state, foe_state])
|
||||
|
||||
data = engine._build_observation(hero_state).to_dict()
|
||||
assert data["schema_version"] == OBSERVATION_SCHEMA_VERSION
|
||||
assert set(data) == {
|
||||
"schema_version",
|
||||
"round",
|
||||
"acting_combatant",
|
||||
"combatants",
|
||||
"pairs",
|
||||
"terrain",
|
||||
"elevation",
|
||||
"threatened",
|
||||
"ranged_threatened",
|
||||
"terrain_semantics",
|
||||
"condition_definitions",
|
||||
"initiative_order",
|
||||
}
|
||||
json.dumps(data) # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12. policy + llm_policy → ValueError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user