You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
363 lines
13 KiB
363 lines
13 KiB
"""Tests for the LifeEngine — probabilistic life events with cascading pressures."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import random
|
|
|
|
import pytest
|
|
|
|
from app.models.agent import (
|
|
AgentPersona, FamilyMember, LifePressure, LifeState, Personality,
|
|
)
|
|
from app.models.world import (
|
|
WorldBlueprint, WorldState, Location, TimeConfig,
|
|
)
|
|
from app.services.life_engine import (
|
|
EVAL_INTERVAL, MAX_EVENTS_PER_CYCLE, MAX_PRESSURES, LifeEngine,
|
|
)
|
|
from tests.conftest import MockLLM
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
def _make_life_state(**overrides) -> LifeState:
|
|
defaults = dict(
|
|
childhood_summary="Grew up in a small town.",
|
|
finances=0.5,
|
|
career=0.5,
|
|
health=0.5,
|
|
family=[
|
|
FamilyMember(name="Rosa", relation="mother", age=55, dependency=0.3),
|
|
FamilyMember(name="Leo", relation="brother", age=25, dependency=0.1),
|
|
],
|
|
)
|
|
defaults.update(overrides)
|
|
return LifeState(**defaults)
|
|
|
|
|
|
def _make_agent(id: int = 1, name: str = "TestAgent", life_state: LifeState | None = None, **personality_kw) -> AgentPersona:
|
|
p_defaults = dict(
|
|
honesty=0.5, ambition=0.5, empathy=0.5,
|
|
confrontational=0.5, conformity=0.5, brand_loyalty=0.5,
|
|
price_sensitivity=0.5, social_proof=0.5, novelty_seeking=0.5,
|
|
)
|
|
p_defaults.update(personality_kw)
|
|
return AgentPersona(
|
|
id=id,
|
|
name=name,
|
|
role="worker",
|
|
age=30,
|
|
personality=Personality(**p_defaults),
|
|
background=f"{name} is a worker.",
|
|
life_state=life_state or _make_life_state(),
|
|
)
|
|
|
|
|
|
def _make_world(day: int = 5) -> WorldState:
|
|
bp = WorldBlueprint(
|
|
name="Test World",
|
|
description="A test world.",
|
|
rules=["Rule 1"],
|
|
locations=[Location(id="town", name="Town", type="public", description="A town")],
|
|
resources=["money"],
|
|
initial_tensions=["tension"],
|
|
time_config=TimeConfig(total_days=30, rounds_per_day=3),
|
|
)
|
|
return WorldState(blueprint=bp, day=day)
|
|
|
|
|
|
def _engine() -> LifeEngine:
|
|
return LifeEngine(llm=MockLLM())
|
|
|
|
|
|
# ============================================================================
|
|
# _tick_pressures
|
|
# ============================================================================
|
|
|
|
class TestTickPressures:
|
|
def test_escalate_past_deadline(self):
|
|
engine = _engine()
|
|
agent = _make_agent(life_state=_make_life_state(
|
|
pressures=[
|
|
LifePressure(domain="finances", description="Debt due", severity=0.5, deadline_day=3, created_day=1),
|
|
]
|
|
))
|
|
engine._tick_pressures(agent, current_day=5)
|
|
|
|
assert len(agent.life_state.pressures) == 1
|
|
p = agent.life_state.pressures[0]
|
|
assert p.severity == pytest.approx(0.7)
|
|
assert p.deadline_day is None
|
|
|
|
def test_resolve_when_domain_improved(self):
|
|
engine = _engine()
|
|
agent = _make_agent(life_state=_make_life_state(
|
|
finances=0.8,
|
|
pressures=[
|
|
LifePressure(domain="finances", description="Money trouble", severity=0.3, created_day=1),
|
|
]
|
|
))
|
|
engine._tick_pressures(agent, current_day=5)
|
|
|
|
assert len(agent.life_state.pressures) == 0
|
|
assert any("Resolved" in entry for entry in agent.life_state.life_log)
|
|
|
|
def test_decay_old_low_severity(self):
|
|
engine = _engine()
|
|
agent = _make_agent(life_state=_make_life_state(
|
|
pressures=[
|
|
LifePressure(domain="health", description="Minor ache", severity=0.2, created_day=1),
|
|
]
|
|
))
|
|
engine._tick_pressures(agent, current_day=25)
|
|
|
|
assert len(agent.life_state.pressures) == 0
|
|
|
|
def test_keeps_high_severity_old_pressure(self):
|
|
engine = _engine()
|
|
agent = _make_agent(life_state=_make_life_state(
|
|
pressures=[
|
|
LifePressure(domain="health", description="Chronic issue", severity=0.6, created_day=1),
|
|
]
|
|
))
|
|
engine._tick_pressures(agent, current_day=25)
|
|
|
|
assert len(agent.life_state.pressures) == 1
|
|
|
|
|
|
# ============================================================================
|
|
# evaluate
|
|
# ============================================================================
|
|
|
|
class TestEvaluate:
|
|
async def test_skips_non_interval_rounds(self):
|
|
engine = _engine()
|
|
agents = [_make_agent()]
|
|
world = _make_world()
|
|
results = await engine.evaluate(agents, world, round_num=3)
|
|
assert results == []
|
|
|
|
async def test_runs_on_interval_round(self):
|
|
engine = _engine()
|
|
agents = [_make_agent()]
|
|
world = _make_world()
|
|
results = await engine.evaluate(agents, world, round_num=EVAL_INTERVAL)
|
|
assert isinstance(results, list)
|
|
|
|
async def test_skips_agents_without_life_state(self):
|
|
engine = _engine()
|
|
agent = _make_agent()
|
|
agent.life_state = None
|
|
results = await engine.evaluate([agent], _make_world(), round_num=EVAL_INTERVAL)
|
|
assert results == []
|
|
|
|
async def test_respects_max_events_per_cycle(self):
|
|
engine = _engine()
|
|
agents = [_make_agent(id=i, name=f"Agent{i}", life_state=_make_life_state(finances=0.1)) for i in range(20)]
|
|
world = _make_world()
|
|
results = await engine.evaluate(agents, world, round_num=EVAL_INTERVAL)
|
|
assert len(results) <= MAX_EVENTS_PER_CYCLE
|
|
|
|
|
|
# ============================================================================
|
|
# _select_event
|
|
# ============================================================================
|
|
|
|
class TestSelectEvent:
|
|
def test_returns_event_for_struggling_agent(self):
|
|
engine = _engine()
|
|
agent = _make_agent(life_state=_make_life_state(finances=0.1, career=0.2, health=0.3))
|
|
found = False
|
|
for _ in range(30):
|
|
evt = engine._select_event(agent)
|
|
if evt is not None:
|
|
found = True
|
|
break
|
|
assert found, "Expected at least one event to be selected for a struggling agent"
|
|
|
|
def test_event_has_required_keys(self):
|
|
engine = _engine()
|
|
agent = _make_agent(life_state=_make_life_state(finances=0.1))
|
|
evt = None
|
|
for _ in range(30):
|
|
evt = engine._select_event(agent)
|
|
if evt is not None:
|
|
break
|
|
assert evt is not None
|
|
assert "template" in evt
|
|
assert "description" in evt
|
|
assert "_domain" in evt
|
|
|
|
def test_family_event_fills_placeholder(self):
|
|
engine = _engine()
|
|
agent = _make_agent(life_state=_make_life_state(finances=0.5))
|
|
found = False
|
|
for _ in range(200):
|
|
evt = engine._select_event(agent)
|
|
if evt and evt["_domain"] == "family" and evt.get("pressure"):
|
|
assert "{family_member}" not in evt["pressure"]
|
|
found = True
|
|
break
|
|
if not found:
|
|
pytest.skip("Family event with pressure not drawn in 200 tries")
|
|
|
|
|
|
# ============================================================================
|
|
# _apply_event
|
|
# ============================================================================
|
|
|
|
class TestApplyEvent:
|
|
def test_applies_domain_delta(self):
|
|
engine = _engine()
|
|
agent = _make_agent(life_state=_make_life_state(finances=0.5))
|
|
|
|
event = {
|
|
"template": "unexpected_expense",
|
|
"description": "A major unexpected expense hits",
|
|
"domain_delta": -0.2,
|
|
"pressure": "Must cover urgent costs",
|
|
"weight_factors": {},
|
|
"_domain": "finances",
|
|
"_polarity": "negative",
|
|
}
|
|
engine._apply_event(agent, event, current_day=5)
|
|
assert agent.life_state.finances == pytest.approx(0.3)
|
|
|
|
def test_clamps_domain_at_zero(self):
|
|
engine = _engine()
|
|
agent = _make_agent(life_state=_make_life_state(finances=0.1))
|
|
|
|
event = {
|
|
"template": "theft_or_loss",
|
|
"description": "Resources stolen or lost",
|
|
"domain_delta": -0.25,
|
|
"pressure": "Lost significant resources",
|
|
"weight_factors": {},
|
|
"_domain": "finances",
|
|
"_polarity": "negative",
|
|
}
|
|
engine._apply_event(agent, event, current_day=5)
|
|
assert agent.life_state.finances == pytest.approx(0.0)
|
|
|
|
def test_respects_max_pressures_cap(self):
|
|
engine = _engine()
|
|
existing = [
|
|
LifePressure(domain="finances", description=f"P{i}", severity=0.5, created_day=1)
|
|
for i in range(MAX_PRESSURES)
|
|
]
|
|
agent = _make_agent(life_state=_make_life_state(pressures=existing))
|
|
|
|
event = {
|
|
"template": "debt_called_in",
|
|
"description": "An old debt comes due",
|
|
"domain_delta": -0.15,
|
|
"pressure": "Creditor demands repayment",
|
|
"weight_factors": {},
|
|
"_domain": "finances",
|
|
"_polarity": "negative",
|
|
}
|
|
engine._apply_event(agent, event, current_day=5)
|
|
assert len(agent.life_state.pressures) == MAX_PRESSURES
|
|
|
|
def test_adds_to_life_log(self):
|
|
engine = _engine()
|
|
agent = _make_agent(life_state=_make_life_state())
|
|
|
|
event = {
|
|
"template": "windfall",
|
|
"description": "An unexpected financial gain",
|
|
"domain_delta": 0.2,
|
|
"pressure": None,
|
|
"weight_factors": {},
|
|
"_domain": "finances",
|
|
"_polarity": "positive",
|
|
}
|
|
engine._apply_event(agent, event, current_day=7)
|
|
assert any("Day 7" in entry for entry in agent.life_state.life_log)
|
|
|
|
def test_family_event_no_domain_delta(self):
|
|
engine = _engine()
|
|
agent = _make_agent(life_state=_make_life_state(health=0.5))
|
|
|
|
event = {
|
|
"template": "family_milestone",
|
|
"description": "A family celebration",
|
|
"domain_delta": None,
|
|
"pressure": None,
|
|
"weight_factors": {},
|
|
"_domain": "family",
|
|
"_polarity": "positive",
|
|
}
|
|
engine._apply_event(agent, event, current_day=5)
|
|
assert agent.life_state.health == pytest.approx(0.5)
|
|
assert agent.life_state.finances == pytest.approx(0.5)
|
|
assert agent.life_state.career == pytest.approx(0.5)
|
|
|
|
|
|
# ============================================================================
|
|
# _apply_cascades
|
|
# ============================================================================
|
|
|
|
class TestApplyCascades:
|
|
def test_job_threat_cascades_when_finances_low(self):
|
|
engine = _engine()
|
|
agent = _make_agent(life_state=_make_life_state(finances=0.3))
|
|
|
|
engine._apply_cascades(agent, "job_threat", current_day=5)
|
|
assert agent.life_state.finances == pytest.approx(0.2)
|
|
assert any("money" in p.description.lower() or "work" in p.description.lower() for p in agent.life_state.pressures)
|
|
|
|
def test_job_threat_no_cascade_when_finances_ok(self):
|
|
engine = _engine()
|
|
agent = _make_agent(life_state=_make_life_state(finances=0.6))
|
|
|
|
engine._apply_cascades(agent, "job_threat", current_day=5)
|
|
assert agent.life_state.finances == pytest.approx(0.6)
|
|
assert len(agent.life_state.pressures) == 0
|
|
|
|
def test_family_illness_cascades_with_dependent(self):
|
|
engine = _engine()
|
|
agent = _make_agent(
|
|
empathy=0.8,
|
|
life_state=_make_life_state(
|
|
family=[FamilyMember(name="Rosa", relation="mother", age=60, dependency=0.7)],
|
|
),
|
|
)
|
|
|
|
engine._apply_cascades(agent, "family_illness", current_day=5)
|
|
assert agent.life_state.finances == pytest.approx(0.45)
|
|
assert agent.life_state.health == pytest.approx(0.4)
|
|
assert len(agent.life_state.pressures) == 2
|
|
|
|
def test_family_illness_no_cascade_without_dependent(self):
|
|
engine = _engine()
|
|
agent = _make_agent(
|
|
empathy=0.3,
|
|
life_state=_make_life_state(
|
|
family=[FamilyMember(name="Leo", relation="brother", age=25, dependency=0.1)],
|
|
),
|
|
)
|
|
|
|
engine._apply_cascades(agent, "family_illness", current_day=5)
|
|
assert agent.life_state.finances == pytest.approx(0.5)
|
|
assert agent.life_state.health == pytest.approx(0.5)
|
|
assert len(agent.life_state.pressures) == 0
|
|
|
|
def test_no_cascade_for_unknown_template(self):
|
|
engine = _engine()
|
|
agent = _make_agent(life_state=_make_life_state())
|
|
|
|
engine._apply_cascades(agent, "nonexistent_event", current_day=5)
|
|
assert agent.life_state.finances == pytest.approx(0.5)
|
|
assert len(agent.life_state.pressures) == 0
|
|
|
|
def test_cascade_respects_max_pressures(self):
|
|
engine = _engine()
|
|
existing = [
|
|
LifePressure(domain="finances", description=f"P{i}", severity=0.5, created_day=1)
|
|
for i in range(MAX_PRESSURES)
|
|
]
|
|
agent = _make_agent(life_state=_make_life_state(finances=0.2, pressures=existing))
|
|
|
|
engine._apply_cascades(agent, "job_threat", current_day=5)
|
|
assert len(agent.life_state.pressures) == MAX_PRESSURES
|
|
|