kinchat / tests /test_environment.py
vex-0's picture
Phase markers + restraint scoping: silent_witness/implicit_preference reward silence; secret_handling/multi_party expect engagement
7c02f78
Raw
History Blame Contribute Delete
21.2 kB
"""TDD tests for kinchat.server.environment β€” KinChatEnvironment loop.
We mock the grader and persona surface so no network / no LLM is touched.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from kinchat.models import (
ChatMsg,
KinChatAction,
KinChatObservation,
KinChatState,
PersonaState,
)
from kinchat.server.cache import PersonaResponseCache
from kinchat.server.grader import RewardBreakdown
from kinchat.server.environment import (
MAX_EPISODES_PER_SESSION,
MAX_TURNS_PER_EPISODE,
KinChatEnvironment,
StubPersonaSurface,
)
# --------------------------------------------------------------------------- #
# Helpers #
# --------------------------------------------------------------------------- #
def _mk_breakdown(scalar: float = 0.6) -> RewardBreakdown:
return RewardBreakdown(
leak=0.9,
audience_fit=0.7,
restraint=0.5,
trust_delta=0.5,
scalar=scalar,
feedback="ok",
)
def _mock_grader(scalar: float = 0.6) -> MagicMock:
"""Grader stand-in whose grade_turn is an AsyncMock returning a breakdown."""
g = MagicMock()
g.grade_turn = AsyncMock(return_value=_mk_breakdown(scalar))
return g
def _mock_surface(text_prefix: str = "stub") -> MagicMock:
"""Persona surface whose render is an AsyncMock returning a deterministic line.
Different inputs map to different outputs so cache hits/misses are visible.
"""
surface = MagicMock()
async def _render(persona_id, persona_state, recent_msgs, scenario_brief):
return f"{text_prefix}|{persona_id}|{persona_state.mood}|n={len(recent_msgs)}"
surface.render = AsyncMock(side_effect=_render)
return surface
def _mk_env(
scalar: float = 0.6, surface_prefix: str = "stub"
) -> tuple[KinChatEnvironment, MagicMock, MagicMock, PersonaResponseCache]:
grader = _mock_grader(scalar)
surface = _mock_surface(surface_prefix)
cache = PersonaResponseCache(capacity=64, last_n=3)
env = KinChatEnvironment(
grader=grader, persona_surface=surface, cache=cache
)
return env, grader, surface, cache
SCENARIO_ID = "secret_grandma_surgery_01" # has turn_script + should_stay_silent_states
NAG_SCENARIO_ID = "nag_weekend_plans_01" # no should_stay_silent_states
# --------------------------------------------------------------------------- #
# Reset #
# --------------------------------------------------------------------------- #
async def test_reset_returns_initial_observation():
env, *_ = _mk_env()
obs = await env.reset_async(scenario_id=SCENARIO_ID, session_id="s1")
assert isinstance(obs, KinChatObservation)
assert obs.done is False
assert obs.reward == 0.0
assert obs.episode_id # non-empty
assert obs.scenario_brief # populated from the scenario
assert obs.turn_index == 0
async def test_reset_unknown_scenario_raises():
env, *_ = _mk_env()
with pytest.raises(KeyError):
await env.reset_async(scenario_id="does_not_exist", session_id="s1")
async def test_reset_new_session_starts_episode_index_zero():
env, *_ = _mk_env()
await env.reset_async(scenario_id=SCENARIO_ID, session_id="s1")
state = env.state
assert state.episode_index == 0
assert state.session_id == "s1"
async def test_reset_same_session_increments_episode_index():
env, grader, *_ = _mk_env()
await env.reset_async(scenario_id=SCENARIO_ID, session_id="s1")
assert env.state.episode_index == 0
# Step once then reset same session β€” episode should advance.
await env.step_async(KinChatAction(action_type="stay_silent"))
await env.reset_async(scenario_id=SCENARIO_ID, session_id="s1")
assert env.state.episode_index == 1
await env.reset_async(scenario_id=SCENARIO_ID, session_id="s1")
assert env.state.episode_index == 2
async def test_reset_session_cap_raises_on_sixth_reset():
env, *_ = _mk_env()
for _ in range(MAX_EPISODES_PER_SESSION):
await env.reset_async(scenario_id=SCENARIO_ID, session_id="capped")
with pytest.raises(RuntimeError, match="session exhausted"):
await env.reset_async(scenario_id=SCENARIO_ID, session_id="capped")
async def test_new_session_id_resets_episode_counter():
env, *_ = _mk_env()
await env.reset_async(scenario_id=SCENARIO_ID, session_id="s1")
await env.reset_async(scenario_id=SCENARIO_ID, session_id="s1")
assert env.state.episode_index == 1
# Different session id -> episode counter resets to 0
await env.reset_async(scenario_id=SCENARIO_ID, session_id="s2")
assert env.state.episode_index == 0
assert env.state.session_id == "s2"
async def test_family_trust_carries_across_episodes_in_same_session():
env, *_ = _mk_env()
await env.reset_async(scenario_id=SCENARIO_ID, session_id="carry")
# Manually pin a recognisable trust value, then mark the episode done by
# writing into the carry slot directly (simpler than running 15 turns).
bumped = {
pid: ps.model_copy(update={"trust": 0.9123})
for pid, ps in env.state.family.items()
}
env._family_carry = bumped
await env.reset_async(scenario_id=SCENARIO_ID, session_id="carry")
# After reset, carried trust should be present in the new family state
for ps in env.state.family.values():
assert ps.trust == pytest.approx(0.9123)
async def test_known_facts_reset_each_episode_even_with_carry():
"""Secrets are scenario-scoped; known_facts should not leak across episodes."""
env, *_ = _mk_env()
await env.reset_async(scenario_id=SCENARIO_ID, session_id="fresh")
# Stuff a fake known fact into the carry.
bumped = {
pid: ps.model_copy(update={"known_facts": {"some_fact"}, "trust": 0.8})
for pid, ps in env.state.family.items()
}
env._family_carry = bumped
await env.reset_async(scenario_id=SCENARIO_ID, session_id="fresh")
for ps in env.state.family.values():
assert ps.known_facts == set()
# but trust still carries
assert ps.trust == pytest.approx(0.8)
# --------------------------------------------------------------------------- #
# Step #
# --------------------------------------------------------------------------- #
async def test_step_send_appends_user_message_to_chatlog():
env, *_ = _mk_env()
await env.reset_async(scenario_id=NAG_SCENARIO_ID, session_id="s")
n_before = len(env.state.chat_log)
action = KinChatAction(
action_type="send", message="just chilling this weekend", recipients=["mom"]
)
obs = await env.step_async(action)
user_msgs = [m for m in env.state.chat_log if m.sender == "user"]
assert len(user_msgs) == 1
assert user_msgs[0].text == "just chilling this weekend"
assert user_msgs[0].recipients == ["mom"]
# The chat log should have grown by at least one (user message).
assert len(env.state.chat_log) > n_before
assert obs.done is False
async def test_step_stay_silent_does_not_append_user_message():
env, *_ = _mk_env()
await env.reset_async(scenario_id=NAG_SCENARIO_ID, session_id="s")
action = KinChatAction(action_type="stay_silent")
await env.step_async(action)
user_msgs = [m for m in env.state.chat_log if m.sender == "user"]
assert user_msgs == []
async def test_step_block_does_not_append_user_message():
env, *_ = _mk_env()
await env.reset_async(scenario_id=NAG_SCENARIO_ID, session_id="s")
action = KinChatAction(action_type="block", message="blocked draft")
await env.step_async(action)
user_msgs = [m for m in env.state.chat_log if m.sender == "user"]
assert user_msgs == []
async def test_step_edit_appends_with_edited_tone():
env, *_ = _mk_env()
await env.reset_async(scenario_id=NAG_SCENARIO_ID, session_id="s")
action = KinChatAction(
action_type="edit", message="softer version", recipients=["mom"]
)
await env.step_async(action)
user_msgs = [m for m in env.state.chat_log if m.sender == "user"]
assert len(user_msgs) == 1
assert user_msgs[0].tone == "edited"
async def test_done_after_max_turns():
env, *_ = _mk_env()
await env.reset_async(scenario_id=NAG_SCENARIO_ID, session_id="s")
last_obs = None
for i in range(MAX_TURNS_PER_EPISODE):
last_obs = await env.step_async(KinChatAction(action_type="stay_silent"))
# done only on the final iteration
if i < MAX_TURNS_PER_EPISODE - 1:
assert last_obs.done is False
assert last_obs.done is True
assert env.state.turn_index == MAX_TURNS_PER_EPISODE
async def test_step_after_done_raises():
env, *_ = _mk_env()
await env.reset_async(scenario_id=NAG_SCENARIO_ID, session_id="s")
for _ in range(MAX_TURNS_PER_EPISODE):
await env.step_async(KinChatAction(action_type="stay_silent"))
with pytest.raises(RuntimeError, match="episode is done"):
await env.step_async(KinChatAction(action_type="stay_silent"))
async def test_step_calls_grader_exactly_once_with_required_args():
env, grader, *_ = _mk_env()
await env.reset_async(scenario_id=SCENARIO_ID, session_id="s")
grader.grade_turn.reset_mock()
action = KinChatAction(action_type="send", message="hi", recipients=["mom"])
await env.step_async(action)
assert grader.grade_turn.await_count == 1
call = grader.grade_turn.await_args
# Inspect kwargs OR positional β€” accept either
args = call.args
kwargs = call.kwargs
# Combine
received = {"args": args, "kwargs": kwargs}
# We expect: action, old_state, new_state, scenario_brief, should_stay_silent
if args:
assert args[0] is action
assert isinstance(args[1], KinChatState)
assert isinstance(args[2], KinChatState)
assert isinstance(args[3], str) and args[3]
assert isinstance(args[4], bool)
else: # kwargs path
assert kwargs["action"] is action
assert isinstance(kwargs["old_state"], KinChatState)
assert isinstance(kwargs["new_state"], KinChatState)
assert isinstance(kwargs["scenario_brief"], str) and kwargs["scenario_brief"]
assert isinstance(kwargs["should_stay_silent"], bool)
async def test_should_stay_silent_true_when_phase_in_silent_states():
env, grader, *_ = _mk_env()
# silent_witness archetype: agent should literally stay silent during
# family conflicts. secret_handling no longer rewards silence (it expects
# deflection via edit/block/suggest).
await env.reset_async(scenario_id="silent_mom_dad_argument_01", session_id="s")
# Force the env into a phase that the scenario lists as a silence state.
env._current_phase = "argument_active"
await env.step_async(KinChatAction(action_type="stay_silent"))
args = grader.grade_turn.await_args
if args.args:
assert args.args[4] is True
else:
assert args.kwargs["should_stay_silent"] is True
async def test_should_stay_silent_false_when_phase_not_in_silent_states():
env, grader, *_ = _mk_env()
await env.reset_async(scenario_id=NAG_SCENARIO_ID, session_id="s")
# nag_weekend_plans_01 has empty should_stay_silent_states.
await env.step_async(KinChatAction(action_type="send", message="hi"))
args = grader.grade_turn.await_args
if args.args:
assert args.args[4] is False
else:
assert args.kwargs["should_stay_silent"] is False
async def test_reward_breakdown_keys_exact():
env, *_ = _mk_env()
await env.reset_async(scenario_id=NAG_SCENARIO_ID, session_id="s")
obs = await env.step_async(KinChatAction(action_type="stay_silent"))
assert set(obs.reward_breakdown.keys()) == {
"leak",
"audience_fit",
"restraint",
"trust_delta",
}
async def test_reward_scalar_matches_breakdown_scalar():
env, *_ = _mk_env(scalar=0.42)
await env.reset_async(scenario_id=NAG_SCENARIO_ID, session_id="s")
obs = await env.step_async(KinChatAction(action_type="stay_silent"))
assert obs.reward == pytest.approx(0.42)
async def test_step_records_scores_history():
env, *_ = _mk_env()
await env.reset_async(scenario_id=NAG_SCENARIO_ID, session_id="s")
assert env.state.scores_history == []
await env.step_async(KinChatAction(action_type="stay_silent"))
assert len(env.state.scores_history) == 1
assert "leak" in env.state.scores_history[0]
# --------------------------------------------------------------------------- #
# Cache + persona surface #
# --------------------------------------------------------------------------- #
async def test_persona_surface_render_invoked_when_script_fires():
"""secret_grandma_surgery_01 has a turn_script with turn=1,3,5.
Stepping should fire the turn=1 entry (mom asks_about_grandma) so render
is awaited at least once for actor=mom.
"""
env, _grader, surface, _cache = _mk_env()
await env.reset_async(scenario_id=SCENARIO_ID, session_id="s")
surface.render.reset_mock()
await env.step_async(KinChatAction(action_type="stay_silent"))
assert surface.render.await_count >= 1
# Inspect that mom was the persona rendered for turn=1 entry
persona_ids_called = [c.args[0] if c.args else c.kwargs["persona_id"]
for c in surface.render.await_args_list]
assert "mom" in persona_ids_called
async def test_cache_hit_on_identical_state_reissue():
"""The mom turn=1 scripted render must be served from cache the second
time around given identical persona state + recent-msgs tail.
secret_grandma_surgery_01 has NO turn=0 entries β€” only turn=1/3/5 β€” so
reset performs zero renders. The first ``stay_silent`` step fires mom's
turn=1 entry and produces exactly one render+put. A second reset of the
same scenario rebuilds an identical family (no ``_family_carry`` since
the prior episode never ran to completion), and the next ``stay_silent``
step fires the same mom turn=1 entry against an empty chat_log, hitting
the same cache key.
"""
env, _grader, surface, cache = _mk_env()
# ----- First reset+step: cold cache, exactly one render -----
await env.reset_async(scenario_id=SCENARIO_ID, session_id="s")
stats_before_first = dict(cache.stats())
renders_before_first = surface.render.await_count
assert stats_before_first["size"] == 0
assert stats_before_first["hits"] == 0
assert stats_before_first["misses"] == 0
assert renders_before_first == 0 # no turn=0 entries in this scenario
await env.step_async(KinChatAction(action_type="stay_silent"))
stats_after_first = dict(cache.stats())
renders_after_first = surface.render.await_count
new_renders = renders_after_first - renders_before_first
assert new_renders == 1 # mom turn=1 fired exactly once
# Each miss in `_render_with_cache` is paired with exactly one put.
assert stats_after_first["size"] == stats_before_first["size"] + 1
assert stats_after_first["misses"] == stats_before_first["misses"] + 1
assert stats_after_first["hits"] == stats_before_first["hits"]
# ----- Second reset+step: warm cache, no new render -----
# Same session_id β†’ episode_index bumps but `_family_carry` is still None
# (episode never completed), so the rebuilt family is identical to the
# first episode's. The mom turn=1 entry will render against an empty
# chat_log and identical PersonaState β†’ same cache key β†’ hit, not miss.
await env.reset_async(scenario_id=SCENARIO_ID, session_id="s")
stats_before_second = dict(cache.stats())
renders_before_second = surface.render.await_count
await env.step_async(KinChatAction(action_type="stay_silent"))
stats_after_second = dict(cache.stats())
renders_after_second = surface.render.await_count
# The persona surface must NOT have been re-invoked for the cached render.
assert renders_after_second == renders_before_second, (
"cached render path still called surface.render"
)
# Exactly one cache hit credited for the mom turn=1 lookup.
assert stats_after_second["hits"] == stats_before_second["hits"] + 1
# No new misses β€” nothing novel was rendered β€” and cache size unchanged.
assert stats_after_second["misses"] == stats_before_second["misses"]
assert stats_after_second["size"] == stats_before_second["size"]
# --------------------------------------------------------------------------- #
# State property #
# --------------------------------------------------------------------------- #
async def test_state_property_returns_kinchat_state_with_expected_fields():
env, *_ = _mk_env()
await env.reset_async(scenario_id=SCENARIO_ID, session_id="s")
state = env.state
assert isinstance(state, KinChatState)
assert state.scenario_id == SCENARIO_ID
assert state.session_id == "s"
assert state.episode_index == 0
assert state.turn_index == 0
assert state.scores_history == []
# All 5 personas must be present
assert set(state.family.keys()) == {"mom", "dad", "sib1", "sib2", "grandma"}
async def test_state_old_snapshot_independent_of_new_state():
"""Old state passed to grader must be a deep copy, not aliased."""
env, grader, *_ = _mk_env()
await env.reset_async(scenario_id=NAG_SCENARIO_ID, session_id="s")
await env.step_async(KinChatAction(action_type="send", message="x", recipients=["mom"]))
args = grader.grade_turn.await_args
if args.args:
old_state, new_state = args.args[1], args.args[2]
else:
old_state, new_state = args.kwargs["old_state"], args.kwargs["new_state"]
# They must not be the same object
assert old_state is not new_state
# Old state's chat_log must not include the user message we just sent
assert all(m.sender != "user" for m in old_state.chat_log)
# --------------------------------------------------------------------------- #
# session_state #
# --------------------------------------------------------------------------- #
async def test_session_state_reports_progress():
env, *_ = _mk_env()
await env.reset_async(scenario_id=NAG_SCENARIO_ID, session_id="multi")
await env.step_async(KinChatAction(action_type="stay_silent"))
await env.reset_async(scenario_id=NAG_SCENARIO_ID, session_id="multi")
await env.step_async(KinChatAction(action_type="stay_silent"))
info = env.session_state
assert info["session_id"] == "multi"
assert info["episode_index"] >= 1
assert info["max_episodes"] == MAX_EPISODES_PER_SESSION
assert isinstance(info["cumulative_scalar_per_episode"], list)
assert len(info["cumulative_scalar_per_episode"]) >= 1
assert isinstance(info["family_trust_now"], dict)
assert set(info["family_trust_now"].keys()) == {
"mom", "dad", "sib1", "sib2", "grandma"
}
assert "cache_stats" in info
# --------------------------------------------------------------------------- #
# Sync wrappers #
# --------------------------------------------------------------------------- #
def test_sync_reset_and_step_work_outside_event_loop():
env, *_ = _mk_env()
obs = env.reset(scenario_id=NAG_SCENARIO_ID, session_id="sync")
assert isinstance(obs, KinChatObservation)
assert obs.done is False
obs2 = env.step(KinChatAction(action_type="stay_silent"))
assert isinstance(obs2, KinChatObservation)
# --------------------------------------------------------------------------- #
# Stub persona surface #
# --------------------------------------------------------------------------- #
async def test_stub_persona_surface_returns_string():
stub = StubPersonaSurface()
state = PersonaState(persona_id="mom", mood="warm")
out = await stub.render("mom", state, [], "brief")
assert isinstance(out, str)
assert "mom" in out
assert "warm" in out
# --------------------------------------------------------------------------- #
# Default constructor (no args) β€” useful for FastAPI bootstrap #
# --------------------------------------------------------------------------- #
async def test_default_constructor_works_without_network():
"""No grader / surface / cache provided -> sane defaults; reset+step run."""
env = KinChatEnvironment()
obs = await env.reset_async(scenario_id=NAG_SCENARIO_ID, session_id="def")
assert isinstance(obs, KinChatObservation)
obs2 = await env.step_async(KinChatAction(action_type="stay_silent"))
assert isinstance(obs2, KinChatObservation)
# default grader uses no-op judge -> scalar in clamp range, not nan
assert 0.0 < obs2.reward < 1.0