| """Offline integration test — runs the full simulation loop with a mock LLM. |
| |
| This test validates the entire pipeline without requiring an API key. |
| Run: python test_simulation.py |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import json |
| import random |
| import sys |
| from pathlib import Path |
| from unittest.mock import AsyncMock, MagicMock |
|
|
| sys.path.insert(0, str(Path(__file__).parent / "src")) |
|
|
| from soci.world.city import City |
| from soci.world.clock import SimClock |
| from soci.world.events import EventSystem |
| from soci.agents.persona import load_personas, Persona |
| from soci.agents.agent import Agent, AgentAction, AgentState |
| from soci.agents.memory import MemoryStream, MemoryType |
| from soci.agents.needs import NeedsState |
| from soci.agents.relationships import RelationshipGraph, Relationship |
| from soci.actions.registry import resolve_action, ActionType |
| from soci.actions.movement import execute_move, get_best_location_for_need |
| from soci.actions.activities import execute_activity |
| from soci.actions.social import should_initiate_conversation, pick_conversation_partner |
| from soci.engine.entropy import EntropyManager |
| from soci.engine.scheduler import prioritize_agents, should_skip_llm |
| from soci.engine.simulation import Simulation |
| from soci.persistence.database import Database |
|
|
|
|
| class MockLLM: |
| """Mock LLM that returns plausible JSON responses without calling the API.""" |
|
|
| def __init__(self): |
| self.usage = MagicMock() |
| self.usage.total_calls = 0 |
| self.usage.total_input_tokens = 0 |
| self.usage.total_output_tokens = 0 |
| self.usage.estimated_cost_usd = 0.0 |
| self.usage.calls_by_model = {} |
| self.usage.summary.return_value = "Mock LLM: 0 calls, $0.00" |
|
|
| async def complete(self, system, user_message, model=None, temperature=0.7, max_tokens=1024): |
| self.usage.total_calls += 1 |
| return "I'm thinking about my day." |
|
|
| async def complete_json(self, system, user_message, model=None, temperature=0.7, max_tokens=1024): |
| self.usage.total_calls += 1 |
|
|
| |
| msg = user_message.lower() |
|
|
| if "plan your day" in msg: |
| return { |
| "plan": [ |
| "Wake up and have breakfast at home", |
| "Go to work at the office", |
| "Have lunch at the cafe", |
| "Continue working", |
| "Go to the park for a walk", |
| "Have dinner", |
| "Relax at home", |
| ], |
| "reasoning": "A balanced day with work and leisure." |
| } |
|
|
| if "what do you do next" in msg: |
| actions = ["work", "eat", "relax", "wander", "move", "exercise"] |
| action = random.choice(actions) |
| targets = { |
| "move": random.choice(["cafe", "park", "house_elena", "office", "grocery"]), |
| "work": "", |
| "eat": "", |
| "relax": "", |
| "wander": "", |
| "exercise": "", |
| } |
| details = { |
| "move": "heading somewhere new", |
| "work": "focusing on a project", |
| "eat": "having a quick meal", |
| "relax": "taking it easy", |
| "wander": "strolling around", |
| "exercise": "doing some stretches", |
| } |
| return { |
| "action": action, |
| "target": targets.get(action, ""), |
| "detail": details.get(action, "doing something"), |
| "duration": random.randint(1, 3), |
| "reasoning": "Felt like it." |
| } |
|
|
| if "how important" in msg: |
| return { |
| "importance": random.randint(3, 8), |
| "reaction": "Interesting, I'll remember that." |
| } |
|
|
| if "reflect" in msg: |
| return { |
| "reflections": [ |
| "I notice I've been spending a lot of time at work lately.", |
| "The neighborhood feels alive today." |
| ], |
| "mood_shift": random.uniform(-0.1, 0.2), |
| "reasoning": "Just thinking about things." |
| } |
|
|
| if "start a conversation" in msg or "you decide to start" in msg: |
| return { |
| "message": "Hey, how's it going?", |
| "inner_thought": "I should catch up with them.", |
| "topic": "daily life" |
| } |
|
|
| if "says:" in msg: |
| return { |
| "message": "Yeah, things are good. How about you?", |
| "inner_thought": "Nice to chat.", |
| "sentiment_delta": 0.05, |
| "trust_delta": 0.02 |
| } |
|
|
| return {"status": "ok"} |
|
|
|
|
| async def run_tests(): |
| print("=" * 60) |
| print("SOCI — OFFLINE INTEGRATION TEST") |
| print("=" * 60) |
| errors = 0 |
|
|
| |
| print("\n[1/12] Clock system...") |
| clock = SimClock(tick_minutes=15, hour=6, minute=0) |
| for _ in range(96): |
| clock.tick() |
| assert clock.day == 2, f"Expected day 2, got {clock.day}" |
| assert clock.hour == 6, f"Expected hour 6, got {clock.hour}" |
| clock_dict = clock.to_dict() |
| restored_clock = SimClock.from_dict(clock_dict) |
| assert restored_clock.day == clock.day |
| print(" PASS: Clock ticks correctly for a full day, serialization works") |
|
|
| |
| print("\n[2/12] City system...") |
| city = City.from_yaml("config/city.yaml") |
| assert len(city.locations) == 20 |
| |
| cafe = city.get_location("cafe") |
| assert cafe is not None |
| assert "street_north" in cafe.connected_to |
| connected = city.get_connected("cafe") |
| assert len(connected) > 0 |
| |
| city.place_agent("test_agent", "cafe") |
| assert "test_agent" in city.get_agents_at("cafe") |
| city.move_agent("test_agent", "cafe", "office") |
| assert "test_agent" not in city.get_agents_at("cafe") |
| assert "test_agent" in city.get_agents_at("office") |
| assert city.find_agent("test_agent") == "office" |
| city.locations["office"].remove_occupant("test_agent") |
| print(" PASS: City loads, connections work, movement works") |
|
|
| |
| print("\n[3/12] Persona system...") |
| personas = load_personas("config/personas.yaml") |
| assert len(personas) == 20 |
| |
| ages = [p.age for p in personas] |
| assert min(ages) <= 20, "Should have young people" |
| assert max(ages) >= 60, "Should have older people" |
| occupations = set(p.occupation for p in personas) |
| assert len(occupations) >= 15, "Should have diverse occupations" |
| |
| prompt = personas[0].system_prompt() |
| assert personas[0].name in prompt |
| assert "personality" in prompt.lower() or "PERSONALITY" in prompt |
| print(f" PASS: 20 personas loaded, ages {min(ages)}-{max(ages)}, {len(occupations)} occupations") |
|
|
| |
| print("\n[4/12] Needs system...") |
| needs = NeedsState() |
| initial_hunger = needs.hunger |
| for _ in range(20): |
| needs.tick() |
| assert needs.hunger < initial_hunger, "Hunger should decay" |
| assert needs.energy < 1.0, "Energy should decay" |
| needs.satisfy("hunger", 0.5) |
| assert needs.hunger > 0.0, "Hunger should be partially satisfied" |
| urgent = needs.urgent_needs |
| desc = needs.describe() |
| assert isinstance(desc, str) |
| print(f" PASS: Needs decay ({desc}), satisfaction works") |
|
|
| |
| print("\n[5/12] Memory system...") |
| mem = MemoryStream() |
| for i in range(30): |
| mem.add(i, 1, f"{6+i//4:02d}:{(i%4)*15:02d}", |
| MemoryType.OBSERVATION, f"Event {i}", importance=random.randint(1, 10)) |
| assert len(mem.memories) == 30 |
| retrieved = mem.retrieve(30, top_k=5) |
| assert len(retrieved) == 5 |
| recent = mem.get_recent(3) |
| assert len(recent) == 3 |
| assert recent[-1].content == "Event 29" |
| |
| mem._importance_accumulator = 100 |
| assert mem.should_reflect() |
| mem.reset_reflection_accumulator() |
| assert not mem.should_reflect() |
| |
| mem_dict = mem.to_dict() |
| restored_mem = MemoryStream.from_dict(mem_dict) |
| assert len(restored_mem.memories) == 30 |
| print(" PASS: Memory storage, retrieval, reflection trigger, serialization") |
|
|
| |
| print("\n[6/12] Relationship system...") |
| graph = RelationshipGraph() |
| rel = graph.get_or_create("elena", "Elena Vasquez") |
| assert rel.familiarity == 0.0 |
| rel.update_after_interaction(tick=10, sentiment_delta=0.1, trust_delta=0.05, note="Had coffee together") |
| assert rel.familiarity > 0.0 |
| assert rel.sentiment > 0.5 |
| assert len(rel.notes) == 1 |
| closest = graph.get_closest(5) |
| assert len(closest) == 1 |
| desc = rel.describe() |
| assert "Elena" in desc |
| |
| g_dict = graph.to_dict() |
| restored_g = RelationshipGraph.from_dict(g_dict) |
| assert restored_g.get("elena") is not None |
| print(" PASS: Relationships form, track sentiment/trust, serialize") |
|
|
| |
| print("\n[7/12] Agent system...") |
| persona = personas[0] |
| agent = Agent(persona) |
| assert agent.name == "Elena Vasquez" |
| assert agent.location == "house_elena" |
| assert agent.state == AgentState.IDLE |
| |
| action = AgentAction(type="work", detail="coding", duration_ticks=3, needs_satisfied={"purpose": 0.3}) |
| agent.start_action(action) |
| assert agent.is_busy |
| assert agent.state == AgentState.WORKING |
| for _ in range(3): |
| agent.tick_action() |
| assert not agent.is_busy |
| assert agent.state == AgentState.IDLE |
| |
| for _ in range(10): |
| agent.tick_needs() |
| |
| agent.add_observation(0, 1, "06:00", "Saw a cat in the park", importance=4) |
| assert len(agent.memory.memories) == 1 |
| |
| a_dict = agent.to_dict() |
| restored_a = Agent.from_dict(a_dict) |
| assert restored_a.name == agent.name |
| assert len(restored_a.memory.memories) == 1 |
| print(" PASS: Agent actions, needs, mood, memory, serialization") |
|
|
| |
| print("\n[8/12] Action resolution...") |
| city2 = City.from_yaml("config/city.yaml") |
| agent2 = Agent(personas[0]) |
| city2.place_agent(agent2.id, agent2.location) |
| raw = {"action": "move", "target": "cafe", "detail": "heading to cafe", "duration": 1} |
| resolved = resolve_action(raw, agent2, city2) |
| assert resolved.type == "move" |
| assert resolved.target == "cafe" |
| |
| raw_bad = {"action": "fly", "target": "moon"} |
| resolved_bad = resolve_action(raw_bad, agent2, city2) |
| assert resolved_bad.type == "wander" |
| print(" PASS: Valid actions resolve, invalid actions fall back to wander") |
|
|
| |
| print("\n[9/12] Movement system...") |
| clock2 = SimClock() |
| agent3 = Agent(personas[0]) |
| city3 = City.from_yaml("config/city.yaml") |
| city3.place_agent(agent3.id, "house_elena") |
| move_action = AgentAction(type="move", target="cafe", detail="walking to cafe") |
| desc = execute_move(agent3, move_action, city3, clock2) |
| assert "cafe" in desc.lower() or "Daily Grind" in desc |
| assert agent3.location == "cafe" |
| |
| suggested = get_best_location_for_need(agent3, "hunger", city3) |
| assert suggested is not None |
| print(f" PASS: Movement works, need-based suggestion: {suggested}") |
|
|
| |
| print("\n[10/12] Events and entropy...") |
| events = EventSystem(event_chance_per_tick=1.0) |
| new = events.tick(["cafe", "park", "office"]) |
| assert len(events.active_events) > 0 or len(new) > 0 |
| world_desc = events.get_world_description() |
| assert "Weather" in world_desc |
| entropy = EntropyManager() |
| agents_list = [Agent(p) for p in personas[:5]] |
| |
| entropy._action_history["elena"] = ["work"] * 15 |
| assert entropy._is_stuck_in_loop("elena") |
| conflicts = entropy.get_conflict_catalysts(agents_list) |
| print(f" PASS: Events fire, entropy detects loops, {len(conflicts)} potential conflicts found") |
|
|
| |
| print("\n[11/12] Full simulation loop (mock LLM)...") |
| mock_llm = MockLLM() |
| city4 = City.from_yaml("config/city.yaml") |
| clock4 = SimClock(tick_minutes=15, hour=6, minute=0) |
| sim = Simulation(city=city4, clock=clock4, llm=mock_llm) |
| sim.load_agents_from_yaml("config/personas.yaml") |
|
|
| |
| agent_ids = list(sim.agents.keys())[:5] |
| sim.agents = {aid: sim.agents[aid] for aid in agent_ids} |
|
|
| events_collected = [] |
| sim.on_event = lambda msg: events_collected.append(msg) |
|
|
| |
| for _ in range(10): |
| await sim.tick() |
|
|
| assert sim.clock.total_ticks == 10 |
| assert len(events_collected) > 0 |
| print(f" PASS: 10 ticks completed, {len(events_collected)} events, " |
| f"{mock_llm.usage.total_calls} LLM calls") |
|
|
| |
| for aid, agent in sim.agents.items(): |
| assert len(agent.memory.memories) > 0, f"{agent.name} should have memories" |
|
|
| |
| print("\n[12/12] Full state serialization...") |
| state = sim.to_dict() |
| state_json = json.dumps(state) |
| assert len(state_json) > 1000, "State should be substantial" |
| restored_state = json.loads(state_json) |
| sim2 = Simulation.from_dict(restored_state, mock_llm) |
| assert len(sim2.agents) == len(sim.agents) |
| assert sim2.clock.total_ticks == sim.clock.total_ticks |
| for aid in sim.agents: |
| assert aid in sim2.agents |
| assert sim2.agents[aid].name == sim.agents[aid].name |
| print(f" PASS: Full state serialized ({len(state_json):,} bytes) and restored") |
|
|
| |
| print("\n" + "=" * 60) |
| if errors == 0: |
| print("ALL 12 TESTS PASSED") |
| else: |
| print(f"{errors} TEST(S) FAILED") |
| print("=" * 60) |
|
|
| |
| print(f"\nSimulation state:") |
| print(f" Clock: {sim.clock.datetime_str}") |
| print(f" Weather: {sim.events.weather.value}") |
| print(f" Mock LLM calls: {mock_llm.usage.total_calls}") |
| print(f"\nAgent status after 10 ticks:") |
| for aid, agent in sim.agents.items(): |
| loc = sim.city.get_location(agent.location) |
| loc_name = loc.name if loc else agent.location |
| print(f" {agent.name}: {agent.state.value} at {loc_name} " |
| f"(mood={agent.mood:.2f}, memories={len(agent.memory.memories)})") |
|
|
| return errors == 0 |
|
|
|
|
| if __name__ == "__main__": |
| success = asyncio.run(run_tests()) |
| sys.exit(0 if success else 1) |
|
|