soci2 / src /soci /engine /entropy.py
RayMelius's picture
Initial implementation of Soci city population simulator
59edb07
"""Entropy management β€” keeps the simulation diverse and interesting."""
from __future__ import annotations
import random
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from soci.agents.agent import Agent
from soci.world.clock import SimClock
from soci.world.events import EventSystem
logger = logging.getLogger(__name__)
class EntropyManager:
"""Manages simulation entropy to prevent bland, repetitive behavior."""
def __init__(self) -> None:
# How often to inject events (every N ticks)
self.event_injection_interval: int = 10
# Ticks since last injection
self._ticks_since_event: int = 0
# Track agent behavior patterns for drift detection
self._action_history: dict[str, list[str]] = {} # agent_id -> last N actions
self._history_window: int = 20
def tick(
self,
agents: list[Agent],
event_system: EventSystem,
clock: SimClock,
city_location_ids: list[str],
) -> list[str]:
"""Process one tick of entropy management. Returns list of notable events/messages."""
messages: list[str] = []
self._ticks_since_event += 1
# Track action patterns
for agent in agents:
if agent.current_action:
history = self._action_history.setdefault(agent.id, [])
history.append(agent.current_action.type)
if len(history) > self._history_window:
self._action_history[agent.id] = history[-self._history_window:]
# Detect repetitive behavior
for agent in agents:
if self._is_stuck_in_loop(agent.id):
messages.append(
f"[ENTROPY] {agent.name} seems stuck in a behavioral loop β€” "
f"injecting stimulus."
)
self._inject_personal_stimulus(agent, clock)
# Periodic event injection
if self._ticks_since_event >= self.event_injection_interval:
new_events = event_system.tick(city_location_ids)
self._ticks_since_event = 0
for evt in new_events:
messages.append(f"[EVENT] {evt.name}: {evt.description}")
else:
# Still tick the event system for weather/expiry
event_system.tick(city_location_ids)
# Time-based entropy: inject daily rhythm changes
if clock.hour == 12 and clock.minute == 0:
messages.append("[RHYTHM] Noon β€” the city bustles with lunch crowds.")
elif clock.hour == 18 and clock.minute == 0:
messages.append("[RHYTHM] Evening β€” people head home or to the bar.")
elif clock.hour == 22 and clock.minute == 0:
messages.append("[RHYTHM] Late night β€” the city quiets down.")
return messages
def _is_stuck_in_loop(self, agent_id: str) -> bool:
"""Detect if an agent is repeating the same actions."""
history = self._action_history.get(agent_id, [])
if len(history) < 10:
return False
# Check if last 10 actions are all the same
recent = history[-10:]
unique = set(recent)
return len(unique) <= 2 and "sleep" not in unique
def _inject_personal_stimulus(self, agent: Agent, clock: SimClock) -> None:
"""Inject a personal event to break an agent out of a loop."""
stimuli = [
f"{agent.name} suddenly remembers something important they forgot to do.",
f"{agent.name} gets an unexpected phone call from an old friend.",
f"{agent.name} notices something unusual in their surroundings.",
f"{agent.name} overhears an interesting conversation nearby.",
f"{agent.name} finds a forgotten note in their pocket.",
f"{agent.name} suddenly craves something completely different.",
]
stimulus = random.choice(stimuli)
agent.add_observation(
tick=clock.total_ticks,
day=clock.day,
time_str=clock.time_str,
content=stimulus,
importance=7,
)
def get_conflict_catalysts(self, agents: list[Agent]) -> list[tuple[str, str, str]]:
"""Identify potential conflicts between agents based on their personas.
Returns list of (agent1_id, agent2_id, tension_description) tuples.
"""
catalysts = []
# Find agents with opposing values or competing interests
for i, a in enumerate(agents):
for b in agents[i + 1:]:
tension = self._find_tension(a, b)
if tension:
catalysts.append((a.id, b.id, tension))
return catalysts
def _find_tension(self, a: Agent, b: Agent) -> str | None:
"""Find natural tension between two agents."""
# Big personality differences can create friction
extraversion_gap = abs(a.persona.extraversion - b.persona.extraversion)
agreeableness_gap = abs(a.persona.agreeableness - b.persona.agreeableness)
if extraversion_gap >= 6 and agreeableness_gap >= 4:
return "personality clash β€” one is outgoing and blunt, the other is reserved and sensitive"
# Competing values
a_values = set(a.persona.values)
b_values = set(b.persona.values)
if a_values and b_values and not a_values.intersection(b_values):
return f"different values β€” {a.name} values {', '.join(a.persona.values)}, while {b.name} values {', '.join(b.persona.values)}"
return None
def to_dict(self) -> dict:
return {
"event_injection_interval": self.event_injection_interval,
"ticks_since_event": self._ticks_since_event,
"action_history": dict(self._action_history),
}
@classmethod
def from_dict(cls, data: dict) -> EntropyManager:
mgr = cls()
mgr.event_injection_interval = data.get("event_injection_interval", 10)
mgr._ticks_since_event = data.get("ticks_since_event", 0)
mgr._action_history = data.get("action_history", {})
return mgr