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.
 
 
 
 
 

592 lines
23 KiB

from __future__ import annotations
import logging
import random
import uuid
from pydantic import BaseModel, Field
from app.models.agent import AgentPersona, SocialEdge
from app.models.action import ActionType, ActionEntry, ReactiveResponse
logger = logging.getLogger(__name__)
GOSSIP_WORTHY_ACTIONS = {
ActionType.SPEAK_PUBLIC,
ActionType.PROTEST,
ActionType.DEFECT,
ActionType.FORM_GROUP,
ActionType.PROPOSE_RULE,
ActionType.PURCHASE,
ActionType.ABANDON,
ActionType.RECOMMEND,
ActionType.COMPARE,
ActionType.TRADE,
ActionType.BUILD,
}
class InfoItem(BaseModel):
id: str = Field(default_factory=lambda: uuid.uuid4().hex[:10])
content: str
original_source_id: int
original_source_name: str
source_chain: list[int] = Field(default_factory=list)
hops: int = 0
sentiment_bias: float = 0.0
round_created: int = 0
round_received: int = 0
action_type: str = ""
expired: bool = False
class GossipEngine:
"""Manages hop-by-hop information propagation through the social graph."""
MAX_HOPS = 4
MAX_INFO_AGE = 9
MAX_ITEMS_PER_AGENT = 12
def __init__(self):
self._info_pool: dict[str, dict[int, list[InfoItem]]] = {}
def init_simulation(self, sim_id: str):
self._info_pool[sim_id] = {}
def cleanup(self, sim_id: str):
self._info_pool.pop(sim_id, None)
def _get_pool(self, sim_id: str) -> dict[int, list[InfoItem]]:
if sim_id not in self._info_pool:
self._info_pool[sim_id] = {}
return self._info_pool[sim_id]
def propagate(
self,
sim_id: str,
resolved_entries: list[ActionEntry],
reactions: list[ReactiveResponse],
agents: list[AgentPersona],
round_num: int,
day: int,
time_of_day: str,
is_market: bool,
) -> None:
pool = self._get_pool(sim_id)
agent_map = {a.id: a for a in agents}
conn_map = self._build_conn_map(agents)
self._expire_old(pool, round_num)
new_items = self._create_info_items(
resolved_entries, reactions, agents, round_num, is_market,
)
self._deliver_firsthand(
new_items, resolved_entries, agents, pool, conn_map, round_num,
)
self._relay_existing(pool, agents, agent_map, conn_map, round_num)
self._write_memories(
pool, resolved_entries, agents, agent_map, round_num, day, time_of_day, is_market,
)
self._echo_chamber_reinforcement(pool, agents, conn_map)
@staticmethod
def _build_conn_map(agents: list[AgentPersona]) -> dict[int, list[SocialEdge]]:
return {a.id: list(a.social_connections) for a in agents}
def _expire_old(self, pool: dict[int, list[InfoItem]], round_num: int):
for aid in pool:
pool[aid] = [
item for item in pool[aid]
if (round_num - item.round_created) < self.MAX_INFO_AGE and not item.expired
]
@staticmethod
def _summarize_action(entry: ActionEntry, is_market: bool) -> str | None:
at = entry.action_type
if at == ActionType.SPEAK_PUBLIC and entry.speech:
return f'{entry.agent_name} said: "{entry.speech[:80]}"'
if at == ActionType.PURCHASE:
return f"{entry.agent_name} bought {entry.action_args.get('product', 'the product')}"
if at == ActionType.ABANDON:
reason = entry.action_args.get("reason", "")
return f"{entry.agent_name} left {entry.action_args.get('product', 'the product')}" + (f"{reason[:50]}" if reason else "")
if at == ActionType.RECOMMEND:
return f"{entry.agent_name} recommended {entry.action_args.get('product', 'the product')}"
if at == ActionType.COMPARE:
return f"{entry.agent_name} compared {entry.action_args.get('product_a', '')} vs {entry.action_args.get('product_b', '')}"
if at == ActionType.PROTEST:
return f"{entry.agent_name} protested: {entry.action_args.get('target', 'something')[:60]}"
if at == ActionType.DEFECT:
return f"{entry.agent_name} broke the rules: {entry.action_args.get('how', '')[:50]}"
if at == ActionType.FORM_GROUP:
return f"{entry.agent_name} formed a group: {entry.action_args.get('name', 'a new group')}"
if at == ActionType.PROPOSE_RULE:
return f"{entry.agent_name} proposed a rule: {entry.action_args.get('content', '')[:50]}"
if at == ActionType.TRADE:
return f"{entry.agent_name} traded with someone"
if at == ActionType.BUILD:
return f"{entry.agent_name} built {entry.action_args.get('what', 'something')}"
return None
def _create_info_items(
self,
entries: list[ActionEntry],
reactions: list[ReactiveResponse],
agents: list[AgentPersona],
round_num: int,
is_market: bool,
) -> list[InfoItem]:
items: list[InfoItem] = []
for entry in entries:
if entry.action_type not in GOSSIP_WORTHY_ACTIONS:
continue
content = self._summarize_action(entry, is_market)
if not content:
continue
items.append(InfoItem(
content=content,
original_source_id=entry.agent_id,
original_source_name=entry.agent_name,
source_chain=[entry.agent_id],
hops=0,
sentiment_bias=0.0,
round_created=round_num,
round_received=round_num,
action_type=entry.action_type.value,
))
for reaction in reactions:
if reaction.reaction_type == "silent" or not reaction.content:
continue
items.append(InfoItem(
content=f'{reaction.agent_name} reacted: "{reaction.content[:80]}"',
original_source_id=reaction.agent_id,
original_source_name=reaction.agent_name,
source_chain=[reaction.agent_id],
hops=0,
sentiment_bias=0.0,
round_created=round_num,
round_received=round_num,
action_type="REACTION",
))
return items
def _deliver_firsthand(
self,
new_items: list[InfoItem],
entries: list[ActionEntry],
agents: list[AgentPersona],
pool: dict[int, list[InfoItem]],
conn_map: dict[int, list[SocialEdge]],
round_num: int,
):
source_targets: dict[int, set[int]] = {}
for entry in entries:
if entry.agent_id not in source_targets:
source_targets[entry.agent_id] = set()
source_targets[entry.agent_id].update(entry.targets or [])
for item in new_items:
source_id = item.original_source_id
source_conns = conn_map.get(source_id, [])
strong_neighbors = {e.target_id for e in source_conns if e.strength > 0.6}
interaction_targets = source_targets.get(source_id, set())
for agent in agents:
if agent.id == source_id:
continue
is_direct_witness = (
agent.id in strong_neighbors
or agent.id in interaction_targets
)
if not is_direct_witness:
if agent.social_connections:
any_mutual = any(
e.target_id == source_id and e.strength > 0.5
for e in agent.social_connections
)
if not any_mutual:
continue
else:
if random.random() > 0.08:
continue
delivered = item.model_copy(update={
"round_received": round_num,
"hops": 0,
})
if agent.id not in pool:
pool[agent.id] = []
if not any(existing.id == delivered.id for existing in pool[agent.id]):
pool[agent.id].append(delivered)
if source_id not in pool:
pool[source_id] = []
own_copy = item.model_copy()
if not any(existing.id == own_copy.id for existing in pool[source_id]):
pool[source_id].append(own_copy)
def _relay_existing(
self,
pool: dict[int, list[InfoItem]],
agents: list[AgentPersona],
agent_map: dict[int, AgentPersona],
conn_map: dict[int, list[SocialEdge]],
round_num: int,
):
relay_queue: list[tuple[int, InfoItem]] = []
for agent in agents:
known = pool.get(agent.id, [])
for item in known:
if item.hops >= self.MAX_HOPS:
continue
if item.round_received == round_num:
continue
relay_prob = 0.3 + agent.personality.social_proof * 0.3
if agent.personality.confrontational > 0.6 and "protest" in item.content.lower():
relay_prob += 0.2
if agent.emotional_state in ("angry", "frustrated", "restless"):
relay_prob += 0.15
if random.random() > relay_prob:
continue
bias_delta = self._compute_distortion(agent, item)
for edge in conn_map.get(agent.id, []):
if edge.target_id in item.source_chain:
continue
spread_prob = edge.strength * 0.8
if random.random() > spread_prob:
continue
relayed = item.model_copy(update={
"source_chain": item.source_chain + [agent.id],
"hops": item.hops + 1,
"sentiment_bias": max(-1.0, min(1.0, item.sentiment_bias + bias_delta)),
"round_received": round_num,
})
relay_queue.append((edge.target_id, relayed))
for target_id, relayed_item in relay_queue:
if target_id not in pool:
pool[target_id] = []
if not any(existing.id == relayed_item.id for existing in pool[target_id]):
pool[target_id].append(relayed_item)
@staticmethod
def _compute_distortion(relayer: AgentPersona, item: InfoItem) -> float:
bias = 0.0
if relayer.personality.honesty < 0.3:
bias += random.uniform(-0.15, 0.15)
if relayer.personality.empathy > 0.7:
if item.sentiment_bias < 0:
bias += 0.1
if relayer.personality.confrontational > 0.7:
if "protest" in item.content.lower() or "broke" in item.content.lower():
bias -= 0.1
if relayer.faction:
same_faction = False
for part in item.source_chain:
pass
bias += random.uniform(-0.05, 0.05)
return bias
def _write_memories(
self,
pool: dict[int, list[InfoItem]],
resolved_entries: list[ActionEntry],
agents: list[AgentPersona],
agent_map: dict[int, AgentPersona],
round_num: int,
day: int,
time_of_day: str,
is_market: bool,
):
active_ids = {e.agent_id for e in resolved_entries}
for agent in agents:
if agent.id in active_ids:
my_actions = [e for e in resolved_entries if e.agent_id == agent.id]
summary = f"Day {day} {time_of_day}: "
parts = []
for e in my_actions:
part = e.action_type.value
if e.speech:
part += f': said "{e.speech[:80]}"'
if e.action_type == ActionType.PURCHASE:
part += f': bought {e.action_args.get("product", "the product")}'
elif e.action_type == ActionType.ABANDON:
part += f': left {e.action_args.get("product", "the product")}{e.action_args.get("reason", "")[:60]}'
elif e.action_type == ActionType.RECOMMEND:
part += f': recommended {e.action_args.get("product", "the product")}'
elif e.action_type == ActionType.COMPARE:
part += f': compared {e.action_args.get("product_a", "")} vs {e.action_args.get("product_b", "")}'
elif e.action_type == ActionType.RESEARCH:
findings = e.action_args.get("findings", "")
part += f': searched \'{e.action_args.get("query", "")}\''
if findings:
part += f'{findings[:120]}'
elif e.action_type == ActionType.INVESTIGATE:
response = e.action_args.get("response", "")
target_names = [a.name for a in agents if a.id in e.targets]
target_name = target_names[0] if target_names else "someone"
part += f': asked {target_name} \'{e.action_args.get("question", "")[:50]}\''
if response:
part += f' — they said: \'{response[:80]}\''
if e.targets:
target_names = [a.name for a in agents if a.id in e.targets]
if target_names:
part += f" (to {', '.join(target_names)})"
parts.append(part)
summary += "; ".join(parts) if parts else "A quiet period."
agent.working_memory.append(summary)
else:
my_info = pool.get(agent.id, [])
new_info = [
item for item in my_info
if item.round_received == round_num and item.original_source_id != agent.id
]
if new_info:
self._upgrade_knowledge(agent, new_info)
neg_kw = {"frustrated", "angry", "disappointed", "betrayed",
"ridiculous", "cancel", "leaving", "abandoned", "left",
"protested", "defected", "greed", "overpriced"}
neg_items: list[InfoItem] = []
other_items: list[InfoItem] = []
for item in new_info:
lower = item.content.lower()
if any(kw in lower for kw in neg_kw):
neg_items.append(item)
else:
other_items.append(item)
MAX_SAME_SENTIMENT = 3
if len(neg_items) > MAX_SAME_SENTIMENT:
kept_neg = neg_items[:MAX_SAME_SENTIMENT]
overflow_count = len(neg_items) - MAX_SAME_SENTIMENT
capped = kept_neg + other_items
else:
capped = neg_items + other_items
overflow_count = 0
heard_parts = []
for item in capped[:5]:
framed = self._frame_info(item, agent)
heard_parts.append(framed)
if overflow_count > 0:
heard_parts.append(f"{overflow_count} others shared similar frustrations")
summary = f"Day {day} {time_of_day}: Heard: " + "; ".join(heard_parts)
else:
summary = f"Day {day} {time_of_day}: A quiet period."
agent.working_memory.append(summary)
if len(agent.working_memory) > 9:
agent.working_memory = agent.working_memory[-9:]
@staticmethod
def _upgrade_knowledge(agent: AgentPersona, info_items: list[InfoItem]):
"""Upgrade agent's knowledge_level when they hear about the change through gossip."""
if getattr(agent, "knowledge_level", "full") == "full":
return
change_keywords = ("price", "hike", "increase", "ads", "ad", "cancel", "subscription", "cost")
for item in info_items:
content_lower = item.content.lower()
if any(kw in content_lower for kw in change_keywords):
current = getattr(agent, "knowledge_level", "full")
if current == "unaware":
agent.knowledge_level = "partial"
elif current == "partial" and item.hops <= 1:
agent.knowledge_level = "full"
break
@staticmethod
def _frame_info(item: InfoItem, receiver: AgentPersona) -> str:
content = item.content
if item.hops == 0:
source_tag = "(firsthand)"
elif item.hops == 1:
source_tag = "(heard from someone)"
else:
source_tag = f"(rumor, {item.hops} hops)"
if item.sentiment_bias > 0.3:
sentiment_tag = " — people seem to approve"
elif item.sentiment_bias < -0.3:
sentiment_tag = " — people are upset about this"
else:
sentiment_tag = ""
return f"{content} {source_tag}{sentiment_tag}"
@staticmethod
def _echo_chamber_reinforcement(
pool: dict[int, list[InfoItem]],
agents: list[AgentPersona],
conn_map: dict[int, list[SocialEdge]],
):
for agent in agents:
if not agent.faction:
continue
for edge in agent.social_connections:
other = next((a for a in agents if a.id == edge.target_id), None)
if not other:
continue
if other.faction == agent.faction:
edge.strength = min(1.0, edge.strength + 0.02)
for other_edge in other.social_connections:
if other_edge.target_id == agent.id:
other_edge.strength = min(1.0, other_edge.strength + 0.02)
break
def inject_event_via_gossip(
self,
sim_id: str,
event_desc: str,
agents: list[AgentPersona],
round_num: int,
day: int,
initial_recipients: list[int] | None = None,
):
pool = self._get_pool(sim_id)
item = InfoItem(
content=f"EVENT — {event_desc[:100]}",
original_source_id=-1,
original_source_name="world",
source_chain=[-1],
hops=0,
sentiment_bias=0.0,
round_created=round_num,
round_received=round_num,
action_type="TENSION_EVENT",
)
if initial_recipients:
recipients = initial_recipients
else:
count = max(3, len(agents) // 3)
recipients = [a.id for a in random.sample(agents, min(count, len(agents)))]
for aid in recipients:
if aid not in pool:
pool[aid] = []
delivered = item.model_copy(update={"round_received": round_num})
pool[aid].append(delivered)
agent = next((a for a in agents if a.id == aid), None)
if agent:
agent.working_memory.append(f"Day {day}: EVENT — {event_desc[:100]}")
if len(agent.working_memory) > 9:
agent.working_memory = agent.working_memory[-9:]
def compute_gossip_metrics(
self, sim_id: str, agents: list[AgentPersona],
round_num: int = 0,
) -> dict[str, float]:
pool = self._get_pool(sim_id)
total_agents = len(agents)
if total_agents == 0:
return {"information_spread": 0.0, "echo_chamber_index": 0.0, "rumor_distortion": 0.0}
recent_window = 3
all_recent_ids: set[str] = set()
for items in pool.values():
for item in items:
if round_num - item.round_created < recent_window:
all_recent_ids.add(item.id)
total_unique = len(all_recent_ids)
if total_unique == 0:
information_spread = 0.0
else:
agent_ids = {a.id for a in agents}
coverage_sum = 0.0
for aid in agent_ids:
agent_items = pool.get(aid, [])
agent_recent = {it.id for it in agent_items if round_num - it.round_created < recent_window}
coverage_sum += len(agent_recent) / total_unique
information_spread = coverage_sum / total_agents
all_biases = []
for items in pool.values():
for item in items:
all_biases.append(abs(item.sentiment_bias))
rumor_distortion = sum(all_biases) / len(all_biases) if all_biases else 0.0
intra_faction = 0
inter_faction = 0
for agent in agents:
if not agent.faction:
continue
for edge in agent.social_connections:
other = next((a for a in agents if a.id == edge.target_id), None)
if not other:
continue
if other.faction == agent.faction:
intra_faction += edge.strength
elif other.faction:
inter_faction += edge.strength
total_faction_flow = intra_faction + inter_faction
echo_chamber_index = intra_faction / total_faction_flow if total_faction_flow > 0 else 0.0
return {
"information_spread": min(1.0, information_spread),
"echo_chamber_index": min(1.0, echo_chamber_index),
"rumor_distortion": min(1.0, rumor_distortion),
}
def social_neighbors(
self, agent: AgentPersona, all_agents: list[AgentPersona], count: int = 5,
) -> list[AgentPersona]:
agent_map = {a.id: a for a in all_agents if a.id != agent.id}
if not agent_map:
return []
if not agent.social_connections:
others = list(agent_map.values())
return random.sample(others, min(count, len(others)))
weighted: list[tuple[int, float]] = []
connected_ids = set()
for edge in agent.social_connections:
if edge.target_id in agent_map:
weighted.append((edge.target_id, edge.strength))
connected_ids.add(edge.target_id)
strangers = [aid for aid in agent_map if aid not in connected_ids]
for sid in strangers:
weighted.append((sid, 0.08))
if not weighted:
return []
ids = [w[0] for w in weighted]
weights = [w[1] for w in weighted]
selected_ids: list[int] = []
for _ in range(min(count, len(ids))):
if not ids:
break
pick = random.choices(range(len(ids)), weights=weights, k=1)[0]
selected_ids.append(ids[pick])
ids.pop(pick)
weights.pop(pick)
return [agent_map[aid] for aid in selected_ids if aid in agent_map]