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.
435 lines
16 KiB
435 lines
16 KiB
from __future__ import annotations
|
|
import logging
|
|
import random
|
|
from typing import Any
|
|
|
|
from app.models.world import WorldState, WorldMetrics
|
|
from app.models.agent import AgentPersona
|
|
from app.services.llm import LLMClient, parse_json
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
EVENT_SYSTEM_PROMPT = """You are the fate engine for {world_name}, a society simulation.
|
|
Rules of this world: {rules}
|
|
|
|
The society has become too stable and needs disruption to stay interesting.
|
|
Generate a minor but consequential environmental event that would shake things up.
|
|
|
|
Examples: resource scarcity, a mysterious stranger arrives, a natural disaster, a scandal is revealed, a neighboring community makes contact, an old secret surfaces, a disease breaks out, a valuable resource is discovered.
|
|
|
|
The event should:
|
|
- Be relevant to this specific society and its rules
|
|
- Affect multiple citizens differently (some benefit, some suffer)
|
|
- Create new tensions or reactivate old ones
|
|
- Be described in 1-2 vivid sentences
|
|
|
|
Return JSON:
|
|
{{
|
|
"event": "Description of what happens",
|
|
"resource_changes": {{"resource_name": change_amount}}
|
|
}}"""
|
|
|
|
EVENT_SYSTEM_PROMPT_MARKET = """You are the fate engine for {world_name}, a market simulation.
|
|
Market context: {rules}
|
|
|
|
The consumer conversation has stagnated — people are talking but not acting.
|
|
Generate a MARKET EVENT that forces consumers to make actual decisions (buy, leave, switch, recommend).
|
|
|
|
Good market events:
|
|
- A competitor launches a flash sale or exclusive offer
|
|
- A viral negative review goes mainstream
|
|
- A celebrity endorsement or public rejection
|
|
- A product recall or quality issue surfaces
|
|
- A price increase or decrease is announced
|
|
- A key feature breaks or a new feature launches
|
|
- An industry report ranks the brand lower than expected
|
|
- A class action lawsuit is filed
|
|
|
|
The event should:
|
|
- Directly impact purchase decisions, not just opinions
|
|
- Create urgency — consumers must decide NOW
|
|
- Affect different consumer segments differently
|
|
- Be described in 1-2 concrete sentences
|
|
|
|
Return JSON:
|
|
{{
|
|
"event": "Description of what happens",
|
|
"resource_changes": {{"resource_name": change_amount}}
|
|
}}"""
|
|
|
|
SPLINTER_SYSTEM_PROMPT = """You are a social dynamics engine for {world_name}.
|
|
The dominant faction "{faction_name}" has grown too large ({member_count}/{total} citizens).
|
|
|
|
In real societies, large factions always develop internal disagreements about methods, priorities, or leadership.
|
|
|
|
Generate a splinter ideology that:
|
|
- Agrees with the faction's core goal but disagrees on methods
|
|
- Would appeal to 2-3 members of the faction
|
|
- Creates a new point of tension within the group
|
|
|
|
Return JSON:
|
|
{{
|
|
"splinter_belief": "The belief that differentiates the splinter group",
|
|
"reason": "Why some members would break away"
|
|
}}"""
|
|
|
|
|
|
class TensionEngine:
|
|
def __init__(self, llm: LLMClient):
|
|
self.llm = llm
|
|
self._stable_rounds: dict[str, int] = {}
|
|
self._quiet_rounds: dict[str, int] = {}
|
|
|
|
def cleanup(self, sim_id: str) -> None:
|
|
self._stable_rounds.pop(sim_id, None)
|
|
self._quiet_rounds.pop(sim_id, None)
|
|
|
|
@staticmethod
|
|
def _snapshot_counterfactual(
|
|
forecast: Any,
|
|
sim_id: str,
|
|
event_round: int,
|
|
event_text: str,
|
|
) -> None:
|
|
if forecast is None:
|
|
return
|
|
try:
|
|
forecast.snapshot_for_counterfactual(sim_id, event_round, event_text)
|
|
except Exception:
|
|
pass
|
|
|
|
@staticmethod
|
|
def find_bridge_nodes(agents: list[AgentPersona]) -> list[int]:
|
|
faction_map: dict[str, set[int]] = {}
|
|
for a in agents:
|
|
if a.faction:
|
|
faction_map.setdefault(a.faction, set()).add(a.id)
|
|
|
|
if len(faction_map) < 2:
|
|
return []
|
|
|
|
bridges: list[int] = []
|
|
for agent in agents:
|
|
connected_factions: set[str] = set()
|
|
for edge in agent.social_connections:
|
|
if edge.strength < 0.3:
|
|
continue
|
|
for faction, members in faction_map.items():
|
|
if edge.target_id in members:
|
|
connected_factions.add(faction)
|
|
if agent.faction:
|
|
connected_factions.add(agent.faction)
|
|
if len(connected_factions) >= 2:
|
|
bridges.append(agent.id)
|
|
return bridges
|
|
|
|
async def check_and_apply(
|
|
self,
|
|
world_state: WorldState,
|
|
agents: list[AgentPersona],
|
|
recent_actions_significant: int,
|
|
sim_id: str = "",
|
|
forecast: Any | None = None,
|
|
) -> tuple[WorldState, list[AgentPersona], str | None]:
|
|
is_market = self._is_market(world_state)
|
|
|
|
stable = self._stable_rounds.get(sim_id, 0)
|
|
quiet = self._quiet_rounds.get(sim_id, 0)
|
|
|
|
is_stable = (
|
|
world_state.metrics.stability > 0.85
|
|
and world_state.metrics.conflict < 0.15
|
|
)
|
|
|
|
if is_stable:
|
|
stable += 1
|
|
else:
|
|
stable = max(0, stable - 1)
|
|
|
|
if recent_actions_significant == 0:
|
|
quiet += 1
|
|
else:
|
|
quiet = 0
|
|
|
|
self._stable_rounds[sim_id] = stable
|
|
self._quiet_rounds[sim_id] = quiet
|
|
|
|
market_stagnation = is_market and (
|
|
(world_state.metrics.adoption_rate > 0.85 and stable >= 2)
|
|
or (world_state.metrics.churn_risk > 0.85 and stable >= 2)
|
|
)
|
|
|
|
talk_only_stagnation = is_market and quiet >= 2
|
|
|
|
quiet_threshold = 3 if is_market else 5
|
|
base_needs_intervention = (
|
|
stable >= 3
|
|
or quiet >= quiet_threshold
|
|
or market_stagnation
|
|
or talk_only_stagnation
|
|
)
|
|
|
|
forecast_disrupt = False
|
|
if forecast is not None and getattr(forecast, "available", False):
|
|
try:
|
|
forecast_disrupt = bool(forecast.should_disrupt(sim_id))
|
|
except Exception:
|
|
forecast_disrupt = False
|
|
|
|
partial_quiet_met = quiet >= (2 if is_market else 4)
|
|
partial_stable_met = stable >= 2
|
|
forecast_accelerates = forecast_disrupt and (
|
|
partial_quiet_met or partial_stable_met or market_stagnation or talk_only_stagnation
|
|
)
|
|
|
|
quiet_fallback = quiet > 12
|
|
|
|
needs_intervention = base_needs_intervention or forecast_accelerates or quiet_fallback
|
|
|
|
dominant_faction = self._find_dominant_faction(agents)
|
|
|
|
if not needs_intervention and not dominant_faction:
|
|
return world_state, agents, None
|
|
|
|
if dominant_faction and random.random() < 0.4:
|
|
agents, event_desc = await self._faction_fracture(
|
|
world_state, agents, dominant_faction, forecast=forecast, sim_id=sim_id
|
|
)
|
|
self._stable_rounds[sim_id] = 0
|
|
return world_state, agents, event_desc
|
|
elif talk_only_stagnation:
|
|
agents, event_desc = self._internal_pressure(
|
|
agents, world_state=world_state, forecast=forecast, sim_id=sim_id
|
|
)
|
|
self._stable_rounds[sim_id] = 0
|
|
self._quiet_rounds[sim_id] = 0
|
|
return world_state, agents, event_desc
|
|
elif quiet >= quiet_threshold or random.random() < 0.5:
|
|
world_state, agents, event_desc = await self._external_event(
|
|
world_state, agents, forecast=forecast, sim_id=sim_id
|
|
)
|
|
self._stable_rounds[sim_id] = 0
|
|
self._quiet_rounds[sim_id] = 0
|
|
return world_state, agents, event_desc
|
|
else:
|
|
agents, event_desc = self._internal_pressure(
|
|
agents, world_state=world_state, forecast=forecast, sim_id=sim_id
|
|
)
|
|
self._stable_rounds[sim_id] = 0
|
|
return world_state, agents, event_desc
|
|
|
|
def _find_dominant_faction(self, agents: list[AgentPersona]) -> str | None:
|
|
faction_counts: dict[str, int] = {}
|
|
for a in agents:
|
|
if a.faction:
|
|
faction_counts[a.faction] = faction_counts.get(a.faction, 0) + 1
|
|
|
|
total = len(agents)
|
|
for faction, count in faction_counts.items():
|
|
if count / total > 0.6:
|
|
return faction
|
|
return None
|
|
|
|
def _internal_pressure(
|
|
self,
|
|
agents: list[AgentPersona],
|
|
world_state: WorldState,
|
|
forecast: Any | None = None,
|
|
sim_id: str = "",
|
|
) -> tuple[list[AgentPersona], str]:
|
|
day = world_state.day
|
|
calm_agents = [
|
|
a for a in agents
|
|
if a.emotional_state in ("calm", "content", "satisfied", "curious")
|
|
]
|
|
if not calm_agents:
|
|
calm_agents = agents
|
|
|
|
targets = random.sample(calm_agents, min(3, len(calm_agents)))
|
|
|
|
is_market = (
|
|
world_state.metrics.adoption_rate > 0.0
|
|
or world_state.metrics.churn_risk != 0.2
|
|
or world_state.metrics.brand_sentiment != 0.5
|
|
)
|
|
|
|
if is_market:
|
|
grievances = [
|
|
"Am I just following the crowd with this purchase?",
|
|
"I'm not sure this brand represents me anymore",
|
|
"The competitors are starting to look more appealing",
|
|
"I feel like I'm paying a premium for nothing",
|
|
"Everyone's talking about this change but nobody actually likes it",
|
|
"I was loyal to this brand and they betrayed that trust",
|
|
]
|
|
|
|
action_nudges = [
|
|
"I should seriously consider switching to a competitor",
|
|
"I need to compare alternatives before I commit further",
|
|
"I'm going to recommend people avoid this brand until things improve",
|
|
"I think it's time to cancel my subscription / sell my product",
|
|
"I've been on the fence long enough — time to decide whether I'm in or out",
|
|
"I should publicly share my honest review of this brand",
|
|
]
|
|
else:
|
|
grievances = [
|
|
"I've been overlooked while others prosper",
|
|
"The system benefits others more than me",
|
|
"I'm tired of following rules that don't serve me",
|
|
"Something needs to change around here",
|
|
"I deserve better than this",
|
|
"The current order is unfair to people like me",
|
|
]
|
|
action_nudges = []
|
|
|
|
names = ", ".join(t.name for t in targets)
|
|
if is_market:
|
|
event_desc = (
|
|
f"Consumer patience wore thin: {names} started seriously reconsidering their choices."
|
|
)
|
|
else:
|
|
event_desc = (
|
|
f"A quiet restlessness stirred among some citizens: {names} began to question things."
|
|
)
|
|
self._snapshot_counterfactual(forecast, sim_id, day, event_desc)
|
|
|
|
for agent in targets:
|
|
agent.emotional_state = random.choice(["restless", "dissatisfied", "frustrated"])
|
|
if is_market:
|
|
nudge = random.uniform(0.05, 0.12)
|
|
if agent.personality.brand_loyalty > 0.3:
|
|
agent.personality.brand_loyalty = max(0.0, agent.personality.brand_loyalty - nudge)
|
|
if agent.personality.price_sensitivity < 0.9:
|
|
agent.personality.price_sensitivity = min(1.0, agent.personality.price_sensitivity + nudge * 0.7)
|
|
new_belief = random.choice(grievances)
|
|
if new_belief not in agent.beliefs:
|
|
agent.beliefs.append(new_belief)
|
|
if is_market and action_nudges:
|
|
action_belief = random.choice(action_nudges)
|
|
if action_belief not in agent.beliefs:
|
|
agent.beliefs.append(action_belief)
|
|
if len(agent.beliefs) > 10:
|
|
agent.beliefs.pop(0)
|
|
agent.working_memory.append(
|
|
f"Day {day}: I'm done just talking about this — I need to make a decision."
|
|
)
|
|
else:
|
|
agent.working_memory.append(
|
|
f"Day {day}: A growing sense of unease settled over me."
|
|
)
|
|
|
|
return agents, event_desc
|
|
|
|
@staticmethod
|
|
def _is_market(world_state: WorldState) -> bool:
|
|
return (
|
|
world_state.metrics.adoption_rate > 0.0
|
|
or world_state.metrics.churn_risk != 0.2
|
|
or world_state.metrics.brand_sentiment != 0.5
|
|
)
|
|
|
|
async def _external_event(
|
|
self,
|
|
world_state: WorldState,
|
|
agents: list[AgentPersona],
|
|
forecast: Any | None = None,
|
|
sim_id: str = "",
|
|
) -> tuple[WorldState, list[AgentPersona], str]:
|
|
is_market = self._is_market(world_state)
|
|
|
|
if is_market:
|
|
system = EVENT_SYSTEM_PROMPT_MARKET.format(
|
|
world_name=world_state.blueprint.name,
|
|
rules="; ".join(world_state.blueprint.rules),
|
|
)
|
|
else:
|
|
system = EVENT_SYSTEM_PROMPT.format(
|
|
world_name=world_state.blueprint.name,
|
|
rules="; ".join(world_state.blueprint.rules),
|
|
)
|
|
|
|
user = (
|
|
f"Current state: stability={world_state.metrics.stability:.2f}, "
|
|
f"conflict={world_state.metrics.conflict:.2f}, "
|
|
f"brand_sentiment={world_state.metrics.brand_sentiment:.2f}, "
|
|
f"churn_risk={world_state.metrics.churn_risk:.2f}, "
|
|
f"adoption_rate={world_state.metrics.adoption_rate:.2f}, "
|
|
f"day={world_state.day}, "
|
|
f"institutions={[i.name for i in world_state.institutions]}, "
|
|
f"disputes={world_state.active_disputes}"
|
|
)
|
|
|
|
response = await self.llm.generate(system=system, user=user, json_mode=True, max_tokens=300)
|
|
data = parse_json(response)
|
|
|
|
event_desc = data.get("event", "An unexpected disruption shook the community.")
|
|
|
|
self._snapshot_counterfactual(forecast, sim_id, world_state.day, event_desc)
|
|
|
|
resource_changes = data.get("resource_changes", {})
|
|
if resource_changes:
|
|
world_state.active_disputes.append(f"Disruption: {event_desc[:80]}")
|
|
|
|
world_state.metrics.stability = max(0.0, world_state.metrics.stability - 0.15)
|
|
world_state.metrics.conflict = min(1.0, world_state.metrics.conflict + 0.1)
|
|
|
|
if is_market:
|
|
world_state.metrics.churn_risk = min(1.0, world_state.metrics.churn_risk + 0.08)
|
|
world_state.metrics.brand_sentiment = max(0.0, world_state.metrics.brand_sentiment - 0.05)
|
|
|
|
initial_count = max(3, len(agents) // 3)
|
|
affected = random.sample(agents, min(initial_count, len(agents)))
|
|
for agent in affected:
|
|
event_belief = f"After the recent event: {event_desc[:60]} — things are changing"
|
|
if event_belief not in agent.beliefs:
|
|
agent.beliefs.append(event_belief)
|
|
if len(agent.beliefs) > 10:
|
|
agent.beliefs.pop(0)
|
|
|
|
self._event_initial_recipients = [a.id for a in affected]
|
|
|
|
return world_state, agents, event_desc
|
|
|
|
def get_event_recipients(self) -> list[int] | None:
|
|
recipients = getattr(self, "_event_initial_recipients", None)
|
|
self._event_initial_recipients = None
|
|
return recipients
|
|
|
|
async def _faction_fracture(
|
|
self,
|
|
world_state: WorldState,
|
|
agents: list[AgentPersona],
|
|
faction_name: str,
|
|
forecast: Any | None = None,
|
|
sim_id: str = "",
|
|
) -> tuple[list[AgentPersona], str]:
|
|
members = [a for a in agents if a.faction == faction_name]
|
|
|
|
system = SPLINTER_SYSTEM_PROMPT.format(
|
|
world_name=world_state.blueprint.name,
|
|
faction_name=faction_name,
|
|
member_count=len(members),
|
|
total=len(agents),
|
|
)
|
|
response = await self.llm.generate(system=system, user="Generate a splinter ideology.", json_mode=True, max_tokens=200)
|
|
data = parse_json(response)
|
|
|
|
splinter_belief = data.get("splinter_belief", f"The {faction_name} has lost its way")
|
|
|
|
splinter_targets = random.sample(members, min(3, len(members)))
|
|
names = ", ".join(t.name for t in splinter_targets)
|
|
event_desc = (
|
|
f"Cracks appeared in {faction_name}. {names} began privately questioning "
|
|
f"the group's direction: \"{splinter_belief}\""
|
|
)
|
|
self._snapshot_counterfactual(forecast, sim_id, world_state.day, event_desc)
|
|
|
|
for agent in splinter_targets:
|
|
if splinter_belief not in agent.beliefs:
|
|
agent.beliefs.append(splinter_belief)
|
|
agent.emotional_state = "conflicted"
|
|
agent.working_memory.append(
|
|
f"I've started questioning whether {faction_name} is truly on the right path."
|
|
)
|
|
|
|
return agents, event_desc
|
|
|