MicroFish/backend/scripts/run_private_simulation.py

1003 lines
37 KiB
Python

"""
Private Impact Simulation Script
Simulates the impact of a private decision in a closed relational network.
Unlike Twitter/Reddit OASIS simulations, this mode has no social media platform:
no echo chamber, no recency weight, no asyncio.gather() across two platforms.
Differences from run_parallel_simulation.py:
- No OASIS env / agent_graph (no Twitter, no Reddit, no PlatformConfig)
- Single relational simulation (no asyncio.gather parallel platforms)
- Relational actions: REACT_PRIVATELY, CONFRONT, COALITION_BUILD,
SILENT_LEAVE, VOCAL_SUPPORT, DO_NOTHING
- Propagation via cascade_influence graph:
distance 1 = direct exposure (initial_posts targets)
distance 2 = cascade via cascade_influence of reacting agents
- Direct LLM calls via camel-ai ChatAgent (no OASIS action loop)
- Output: backend/scripts/private/actions.jsonl (same JSONL format)
- zep_graph_memory_updater.py reused as-is (platform="private")
Usage:
python run_private_simulation.py --config simulation_config.json
python run_private_simulation.py --config simulation_config.json --no-wait
python run_private_simulation.py --config simulation_config.json --max-rounds 10
Log structure:
sim_xxx/
├── private/
│ └── actions.jsonl # Relational network action log
├── simulation.log # Main simulation process log
└── run_state.json # Run state (API polling)
"""
# ============================================================
# Windows UTF-8 fix — same as run_parallel_simulation.py
# ============================================================
import sys
import os
if sys.platform == 'win32':
os.environ.setdefault('PYTHONUTF8', '1')
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
import builtins
_original_open = builtins.open
def _utf8_open(file, mode='r', buffering=-1, encoding=None, errors=None,
newline=None, closefd=True, opener=None):
"""Wrap open() to default to UTF-8 for text mode — fixes third-party libs."""
if encoding is None and 'b' not in mode:
encoding = 'utf-8'
return _original_open(file, mode, buffering, encoding, errors,
newline, closefd, opener)
builtins.open = _utf8_open
import argparse
import asyncio
import json
import logging
import random
import signal
from datetime import datetime
from typing import Any, Dict, List, Optional, Set, Tuple
# ── Path setup (same as run_parallel_simulation.py) ──────────────────────────
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))
_project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
sys.path.insert(0, _scripts_dir)
sys.path.insert(0, _backend_dir)
from dotenv import load_dotenv
_env_file = os.path.join(_project_root, '.env')
if os.path.exists(_env_file):
load_dotenv(_env_file)
print(f"Loaded env config: {_env_file}")
else:
_backend_env = os.path.join(_backend_dir, '.env')
if os.path.exists(_backend_env):
load_dotenv(_backend_env)
print(f"Loaded env config: {_backend_env}")
# ── Logging helpers ───────────────────────────────────────────────────────────
class MaxTokensWarningFilter(logging.Filter):
"""Suppress camel-ai max_tokens warnings (intentionally not set)."""
def filter(self, record: logging.LogRecord) -> bool:
if "max_tokens" in record.getMessage() and "Invalid or missing" in record.getMessage():
return False
return True
logging.getLogger().addFilter(MaxTokensWarningFilter())
from action_logger import SimulationLogManager, PlatformActionLogger
try:
from camel.models import ModelFactory
from camel.types import ModelPlatformType
from camel.messages import BaseMessage
from camel.agents import ChatAgent
except ImportError as e:
print(f"Error: missing dependency {e}")
print("Please install: pip install camel-ai")
sys.exit(1)
# Optional: Zep graph memory updater — same as run_parallel_simulation.py
try:
from app.services.zep_graph_memory_updater import AgentActivity, ZepGraphMemoryUpdater
_ZEP_AVAILABLE = True
except ImportError:
_ZEP_AVAILABLE = False
# ── Global shutdown state ─────────────────────────────────────────────────────
_shutdown_event: Optional[asyncio.Event] = None
_cleanup_done: bool = False
# ── Relational actions — no social media vocabulary ───────────────────────────
# Divergence from run_parallel_simulation.py:
# TWITTER_ACTIONS / REDDIT_ACTIONS → PRIVATE_ACTIONS
PRIVATE_ACTIONS = [
"REACT_PRIVATELY", # Internal reaction — changes agent state, not visible externally
"CONFRONT", # Direct confrontation with the decision maker
"COALITION_BUILD", # Rallies other network agents to a shared reaction
"SILENT_LEAVE", # Progressive disengagement (resignation, client churn...)
"VOCAL_SUPPORT", # Public or private defense of the decision
"DO_NOTHING", # Indifference — absorbs without reacting
]
# IPC constants (same pattern as run_parallel_simulation.py)
IPC_COMMANDS_DIR = "ipc_commands"
IPC_RESPONSES_DIR = "ipc_responses"
ENV_STATUS_FILE = "env_status.json"
class CommandType:
INTERVIEW = "interview"
BATCH_INTERVIEW = "batch_interview"
CLOSE_ENV = "close_env"
# ── Config / model helpers ────────────────────────────────────────────────────
def load_config(config_path: str) -> Dict[str, Any]:
"""Load simulation config JSON."""
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
def create_model(config: Dict[str, Any]):
"""
Create LLM model from environment variables.
Same logic as run_parallel_simulation.py — no boost variant needed here
(single simulation, no parallel platforms to distribute load).
"""
llm_api_key = os.environ.get("LLM_API_KEY", "")
llm_base_url = os.environ.get("LLM_BASE_URL", "")
llm_model = os.environ.get("LLM_MODEL_NAME", "") or config.get("llm_model", "gpt-4o-mini")
if llm_api_key:
os.environ["OPENAI_API_KEY"] = llm_api_key
if not os.environ.get("OPENAI_API_KEY"):
raise ValueError("Missing API Key — set LLM_API_KEY in .env")
if llm_base_url:
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
print(f"[Private] model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else 'default'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
model_type=llm_model,
)
def get_agent_names_from_config(config: Dict[str, Any]) -> Dict[int, str]:
"""Build agent_id → entity_name mapping from simulation_config."""
agent_names = {}
for cfg in config.get("agent_configs", []):
agent_id = cfg.get("agent_id")
if agent_id is not None:
agent_names[agent_id] = cfg.get("entity_name", f"Agent_{agent_id}")
return agent_names
# ── Relational graph ──────────────────────────────────────────────────────────
def build_relational_graph(agent_configs: List[Dict[str, Any]]) -> Dict[int, List[int]]:
"""
Build the cascade influence graph from agent configs.
Divergence from run_parallel_simulation.py:
No OASIS platform graph — this is a relational influence graph where
cascade_influence[agent_id] = [list of agent_ids this agent can expose].
Returns:
{agent_id: [influenced_agent_ids]}
"""
graph: Dict[int, List[int]] = {}
for cfg in agent_configs:
agent_id = cfg.get("agent_id", 0)
graph[agent_id] = cfg.get("cascade_influence", [])
return graph
def get_initial_exposed_agents(config: Dict[str, Any]) -> Set[int]:
"""
Return the full set of agent IDs — all agents are exposed to the decision
at simulation start.
Relational network propagation: in Private Impact mode, the decision
circulates through the network (e.g. LinkedIn post) and all agents
receive context from round 1. The LLM-generated initial_exposed_agent_ids
is intentionally ignored — exposure is a structural parameter, not an
LLM decision.
"""
exposed: Set[int] = set()
for cfg in config.get("agent_configs", []):
agent_id = cfg.get("agent_id")
if agent_id is not None:
exposed.add(agent_id)
return exposed
def get_decision_context(config: Dict[str, Any]) -> str:
"""
Extract the triggering decision text from event_config.
Supports two event_config formats:
- PrivateImpactConfigGenerator: decision_statement (plain text)
- OASIS initial_posts: content of the first post
"""
event_config = config.get("event_config", {})
# PrivateImpactConfigGenerator format
decision_statement = event_config.get("decision_statement", "")
if decision_statement:
return decision_statement
# OASIS initial_posts format
posts = event_config.get("initial_posts", [])
if posts:
return posts[0].get("content", "A private decision has been made.")
return "A private decision has been made."
# ── Active agent selection ────────────────────────────────────────────────────
def get_active_agents_for_round_private(
agent_configs: List[Dict[str, Any]],
exposed_agents: Set[int],
current_hour: int,
round_num: int,
time_config: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""
Select active agents for this round.
Divergence from run_parallel_simulation.py:
Only exposed agents are eligible (relational propagation gate).
Active hours and activity_level logic is preserved from the original.
Args:
agent_configs: Full list of agent configs.
exposed_agents: Set of agent_ids who have received the decision.
current_hour: Simulated hour (0-23).
round_num: Current round number.
time_config: Time configuration from simulation config.
Returns:
List of agent config dicts for agents that will act this round.
"""
base_min = time_config.get("agents_per_hour_min", 3)
base_max = time_config.get("agents_per_hour_max", 10)
peak_hours = time_config.get("peak_hours", [9, 10, 11, 14, 15, 20, 21, 22])
off_peak_hours = time_config.get("off_peak_hours", [0, 1, 2, 3, 4, 5])
if current_hour in peak_hours:
multiplier = time_config.get("peak_activity_multiplier", 1.5)
elif current_hour in off_peak_hours:
multiplier = time_config.get("off_peak_activity_multiplier", 0.3)
else:
multiplier = 1.0
target_count = int(random.uniform(base_min, base_max) * multiplier)
candidates = []
for cfg in agent_configs:
agent_id = cfg.get("agent_id", 0)
# Only exposed agents can act (relational propagation gate)
if agent_id not in exposed_agents:
continue
active_hours = cfg.get("active_hours", list(range(8, 23)))
if current_hour not in active_hours:
continue
activity_level = cfg.get("activity_level", 0.5)
if random.random() < activity_level:
candidates.append(cfg)
selected = random.sample(candidates, min(target_count, len(candidates))) if candidates else []
return selected
# ── LLM agent decision ────────────────────────────────────────────────────────
async def get_agent_action(
agent_config: Dict[str, Any],
decision_context: str,
network_summary: str,
model: Any,
round_num: int,
) -> Dict[str, Any]:
"""
Query LLM for the agent's relational action in the current round.
Divergence from run_parallel_simulation.py:
No OASIS action loop — direct ChatAgent call.
The persona field encodes all behavioral dimensions (relational link,
trust level, financial sensitivity, reaction mode).
LLM reads this context and adapts decisions naturally — same pattern
as OASIS where persona is injected as-is into the system prompt.
Args:
agent_config: Agent configuration dict (must contain "persona").
decision_context: The triggering decision text.
network_summary: Recent network activity (last N rounds).
model: camel-ai model instance.
round_num: Current round number.
Returns:
{"action_type": str, "action_args": dict}
"""
persona = agent_config.get("persona", "You are a member of a professional network.")
entity_name = agent_config.get(
"entity_name", f"Agent_{agent_config.get('agent_id', 0)}"
)
actions_list = "\n".join(f"- {a}" for a in PRIVATE_ACTIONS)
system_content = (
f"{persona}\n\n"
f"Available actions (choose exactly one):\n{actions_list}\n\n"
"Respond with valid JSON only, no markdown:\n"
'{"action": "<ACTION>", "reasoning": "<brief explanation in 1-2 sentences>", '
'"target_agents": [<agent_ids if COALITION_BUILD, else empty list>]}'
)
user_content = (
f"Round {round_num}.\n\n"
f"Triggering decision:\n{decision_context}\n\n"
f"Recent network activity:\n{network_summary if network_summary else 'No prior activity.'}\n\n"
"What do you do? Choose one action. Respond in JSON only."
)
def _sync_call() -> Dict[str, Any]:
"""Synchronous LLM call — wrapped in asyncio.to_thread to avoid blocking."""
try:
agent = ChatAgent(
system_message=BaseMessage.make_assistant_message(
role_name=entity_name,
content=system_content,
),
model=model,
)
response = agent.step(
BaseMessage.make_user_message(
role_name="Facilitator",
content=user_content,
)
)
text = response.msg.content.strip()
# Strip markdown code fence if present
if "```" in text:
parts = text.split("```")
text = parts[1] if len(parts) > 1 else parts[0]
if text.startswith("json"):
text = text[4:].strip()
data = json.loads(text)
action_type = str(data.get("action", "DO_NOTHING")).upper()
if action_type not in PRIVATE_ACTIONS:
action_type = "DO_NOTHING"
return {
"action_type": action_type,
"action_args": {
"reasoning": data.get("reasoning", ""),
"target_agents": data.get("target_agents", []),
},
}
except Exception:
return {"action_type": "DO_NOTHING", "action_args": {}}
return await asyncio.to_thread(_sync_call)
# ── Private IPC handler ───────────────────────────────────────────────────────
class PrivateIPCHandler:
"""
IPC command handler for the private impact simulation.
Divergence from ParallelIPCHandler in run_parallel_simulation.py:
Single simulation context (no twitter_env / reddit_env).
Interview responses are generated via direct LLM call.
"""
def __init__(
self,
simulation_dir: str,
agent_configs: List[Dict[str, Any]],
model: Any,
):
self.simulation_dir = simulation_dir
self.agent_configs = {cfg["agent_id"]: cfg for cfg in agent_configs}
self.model = model
self.commands_dir = os.path.join(simulation_dir, IPC_COMMANDS_DIR)
self.responses_dir = os.path.join(simulation_dir, IPC_RESPONSES_DIR)
self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)
os.makedirs(self.commands_dir, exist_ok=True)
os.makedirs(self.responses_dir, exist_ok=True)
def update_status(self, status: str) -> None:
"""Write current env status to disk."""
with open(self.status_file, 'w', encoding='utf-8') as f:
json.dump({
"status": status,
"platform": "private",
"timestamp": datetime.now().isoformat(),
}, f, ensure_ascii=False, indent=2)
def poll_command(self) -> Optional[Dict[str, Any]]:
"""Poll for pending IPC commands."""
if not os.path.exists(self.commands_dir):
return None
command_files = sorted(
(os.path.join(self.commands_dir, fn), os.path.getmtime(os.path.join(self.commands_dir, fn)))
for fn in os.listdir(self.commands_dir)
if fn.endswith('.json')
)
for filepath, _ in command_files:
try:
with open(filepath, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
continue
return None
def send_response(
self,
command_id: str,
status: str,
result: Optional[Dict] = None,
error: Optional[str] = None,
) -> None:
"""Write response file and delete the command file."""
response = {
"command_id": command_id,
"status": status,
"result": result,
"error": error,
"timestamp": datetime.now().isoformat(),
}
response_file = os.path.join(self.responses_dir, f"{command_id}.json")
with open(response_file, 'w', encoding='utf-8') as f:
json.dump(response, f, ensure_ascii=False, indent=2)
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
try:
os.remove(command_file)
except OSError:
pass
async def handle_interview(
self,
command_id: str,
agent_id: int,
prompt: str,
) -> bool:
"""Interview an agent via direct LLM call."""
cfg = self.agent_configs.get(agent_id)
if not cfg:
self.send_response(command_id, "failed", error=f"Agent {agent_id} not found")
return False
persona = cfg.get("persona", "")
entity_name = cfg.get("entity_name", f"Agent_{agent_id}")
def _sync_interview() -> str:
agent = ChatAgent(
system_message=BaseMessage.make_assistant_message(
role_name=entity_name,
content=persona,
),
model=self.model,
)
response = agent.step(
BaseMessage.make_user_message(role_name="Interviewer", content=prompt)
)
return response.msg.content
try:
answer = await asyncio.to_thread(_sync_interview)
self.send_response(command_id, "completed", result={
"agent_id": agent_id,
"agent_name": entity_name,
"platform": "private",
"prompt": prompt,
"response": answer,
"timestamp": datetime.now().isoformat(),
})
print(f" Interview done: agent_id={agent_id}")
return True
except Exception as e:
self.send_response(command_id, "failed", error=str(e))
return False
async def handle_batch_interview(
self,
command_id: str,
interviews: List[Dict[str, Any]],
) -> bool:
"""Batch interview multiple agents."""
tasks = [
self.handle_interview(
f"{command_id}_{i}",
item.get("agent_id", 0),
item.get("prompt", ""),
)
for i, item in enumerate(interviews)
]
results_flags = await asyncio.gather(*tasks)
success_count = sum(results_flags)
if success_count > 0:
self.send_response(command_id, "completed", result={
"interviews_count": success_count,
})
return True
self.send_response(command_id, "failed", error="All interviews failed")
return False
async def process_commands(self) -> bool:
"""
Process pending IPC commands.
Returns:
True to keep running, False to exit.
"""
command = self.poll_command()
if not command:
return True
command_id = command.get("command_id")
command_type = command.get("command_type")
args = command.get("args", {})
print(f"\nIPC command received: {command_type}, id={command_id}")
if command_type == CommandType.INTERVIEW:
await self.handle_interview(
command_id,
args.get("agent_id", 0),
args.get("prompt", ""),
)
return True
if command_type == CommandType.BATCH_INTERVIEW:
await self.handle_batch_interview(
command_id,
args.get("interviews", []),
)
return True
if command_type == CommandType.CLOSE_ENV:
print("Close env command received")
self.send_response(command_id, "completed", result={"message": "Environment closing"})
return False
self.send_response(command_id, "failed", error=f"Unknown command: {command_type}")
return True
# ── Main simulation coroutine ─────────────────────────────────────────────────
async def run_private_simulation(
config: Dict[str, Any],
simulation_dir: str,
action_logger: Optional[PlatformActionLogger] = None,
main_logger: Optional[SimulationLogManager] = None,
max_rounds: Optional[int] = None,
zep_updater: Optional[Any] = None,
) -> Tuple[int, List[Dict[str, Any]]]:
"""
Run the private impact simulation.
Divergence from run_twitter_simulation / run_reddit_simulation:
- No OASIS env — direct LLM calls per agent per round
- No PlatformConfig (no recency_weight, no echo_chamber)
- Relational graph drives exposure propagation
- REACT_PRIVATELY does NOT cascade (internal reaction, invisible)
- All other non-DO_NOTHING actions cascade to cascade_influence targets
Args:
config: Simulation config dict.
simulation_dir: Directory for log output.
action_logger: PlatformActionLogger instance ("private" platform).
main_logger: SimulationLogManager for main simulation.log.
max_rounds: Optional round cap.
zep_updater: Optional ZepGraphMemoryUpdater (reused as-is).
Returns:
(total_actions, agent_configs) — for IPC handler initialisation.
"""
def log(msg: str) -> None:
if main_logger:
main_logger.info(f"[Private] {msg}")
print(f"[Private] {msg}")
log("Initializing...")
agent_configs = config.get("agent_configs", [])
agent_names = get_agent_names_from_config(config)
time_config = config.get("time_config", {})
# Build relational cascade graph
relational_graph = build_relational_graph(agent_configs)
# Agents exposed to the decision at simulation start (distance 1)
exposed_agents: Set[int] = get_initial_exposed_agents(config)
log(f"Distance-1 exposed agents: {sorted(exposed_agents)}")
# The triggering decision text (from event_config.initial_posts)
decision_context = get_decision_context(config)
log(f"Decision context: {decision_context[:100]}...")
model = create_model(config)
if action_logger:
action_logger.log_simulation_start(config)
total_actions = 0
# Round 0 — log the decision injection (mirrors initial_posts in OASIS)
if action_logger:
action_logger.log_round_start(0, 0)
event_config = config.get("event_config", {})
initial_posts = event_config.get("initial_posts", [])
initial_count = 0
for post in initial_posts:
poster_id = post.get("poster_agent_id", 0)
content = post.get("content", "")
poster_name = agent_names.get(poster_id, f"Agent_{poster_id}")
if action_logger:
action_logger.log_action(
round_num=0,
agent_id=poster_id,
agent_name=poster_name,
action_type="CREATE_POST",
action_args={"content": content},
)
total_actions += 1
initial_count += 1
if zep_updater and _ZEP_AVAILABLE:
zep_updater.add_activity_from_dict({
"agent_id": poster_id,
"agent_name": poster_name,
"action_type": "CREATE_POST",
"action_args": {"content": content},
"round": 0,
"timestamp": datetime.now().isoformat(),
}, platform="private")
log(f"Decision injected: {initial_count} initial post(s)")
if action_logger:
action_logger.log_round_end(0, initial_count, simulated_day=1)
# Compute total rounds — support both time config formats:
# PrivateImpactConfigGenerator: total_simulation_days + rounds_per_day
# OASIS format: total_simulation_hours + minutes_per_round
if "total_simulation_days" in time_config:
_days = int(time_config["total_simulation_days"])
_rpd = int(time_config.get("rounds_per_day", 3))
total_rounds = _days * _rpd
minutes_per_round = (24 * 60) // _rpd if _rpd > 0 else 480
else:
total_hours = time_config.get("total_simulation_hours", 72)
minutes_per_round = time_config.get("minutes_per_round", 30)
total_rounds = (total_hours * 60) // minutes_per_round
if max_rounds is not None and max_rounds > 0:
original_rounds = total_rounds
total_rounds = min(total_rounds, max_rounds)
if total_rounds < original_rounds:
log(f"Rounds capped: {original_rounds}{total_rounds} (max_rounds={max_rounds})")
# Rolling network activity log for LLM context (last 10 visible actions)
network_log: List[str] = []
start_time = datetime.now()
for round_num in range(total_rounds):
if _shutdown_event and _shutdown_event.is_set():
log(f"Shutdown signal received, stopping at round {round_num + 1}")
break
simulated_minutes = round_num * minutes_per_round
simulated_hour = (simulated_minutes // 60) % 24
simulated_day = simulated_minutes // (60 * 24) + 1
active_cfgs = get_active_agents_for_round_private(
agent_configs, exposed_agents, simulated_hour, round_num, time_config
)
if action_logger:
action_logger.log_round_start(round_num + 1, simulated_hour)
if not active_cfgs:
if action_logger:
action_logger.log_round_end(round_num + 1, 0, simulated_day=simulated_day)
continue
# Build context summary for LLM prompts this round
network_summary = "\n".join(network_log[-10:])
# Query all active agents concurrently
tasks = [
get_agent_action(
cfg, decision_context, network_summary, model, round_num + 1
)
for cfg in active_cfgs
]
action_results = await asyncio.gather(*tasks)
round_action_count = 0
newly_exposed: Set[int] = set()
for cfg, result in zip(active_cfgs, action_results):
agent_id = cfg.get("agent_id", 0)
agent_name = agent_names.get(agent_id, f"Agent_{agent_id}")
action_type = result["action_type"]
action_args = result["action_args"]
if action_logger:
action_logger.log_action(
round_num=round_num + 1,
agent_id=agent_id,
agent_name=agent_name,
action_type=action_type,
action_args=action_args,
)
total_actions += 1
round_action_count += 1
if zep_updater and _ZEP_AVAILABLE:
zep_updater.add_activity_from_dict({
"agent_id": agent_id,
"agent_name": agent_name,
"action_type": action_type,
"action_args": action_args,
"round": round_num + 1,
"timestamp": datetime.now().isoformat(),
}, platform="private")
# Rolling network log — only visible actions update context
if action_type not in ("DO_NOTHING",):
reasoning = action_args.get("reasoning", "")
entry = f"[Round {round_num + 1}] {agent_name}: {action_type}"
if reasoning:
entry += f"{reasoning[:80]}"
network_log.append(entry)
# Propagate exposure via cascade_influence.
# REACT_PRIVATELY is invisible externally — does NOT cascade.
# All other non-DO_NOTHING actions cascade to influenced agents.
if action_type not in ("DO_NOTHING", "REACT_PRIVATELY"):
for influenced_id in relational_graph.get(agent_id, []):
if influenced_id not in exposed_agents:
newly_exposed.add(influenced_id)
influenced_name = agent_names.get(influenced_id, f"Agent_{influenced_id}")
log(f"{influenced_name} exposed via {agent_name}'s {action_type}")
# Expand exposed set with newly reached agents
exposed_agents.update(newly_exposed)
if action_logger:
action_logger.log_round_end(round_num + 1, round_action_count, simulated_day=simulated_day)
if (round_num + 1) % 20 == 0:
progress = (round_num + 1) / total_rounds * 100
log(
f"Day {simulated_day}, {simulated_hour:02d}:00 "
f"— Round {round_num + 1}/{total_rounds} ({progress:.1f}%) "
f"| Exposed: {len(exposed_agents)}/{len(agent_configs)}"
)
if action_logger:
action_logger.log_simulation_end(total_rounds, total_actions)
elapsed = (datetime.now() - start_time).total_seconds()
log(f"Simulation complete! Elapsed: {elapsed:.1f}s, Total actions: {total_actions}")
return total_actions, agent_configs
# ── Entry point ───────────────────────────────────────────────────────────────
async def main() -> None:
parser = argparse.ArgumentParser(description='Private Impact Simulation')
parser.add_argument(
'--config',
type=str,
required=True,
help='Path to simulation_config.json',
)
parser.add_argument(
'--max-rounds',
type=int,
default=None,
help='Maximum number of simulation rounds (optional cap)',
)
parser.add_argument(
'--no-wait',
action='store_true',
default=False,
help='Close environment immediately after simulation (no IPC wait)',
)
args = parser.parse_args()
global _shutdown_event
_shutdown_event = asyncio.Event()
if not os.path.exists(args.config):
print(f"Error: config file not found: {args.config}")
sys.exit(1)
config = load_config(args.config)
simulation_dir = os.path.dirname(args.config) or "."
wait_for_commands = not args.no_wait
# Suppress OASIS loggers (precaution if imported transitively)
for logger_name in ("social.agent", "social.twitter", "social.rec", "oasis.env", "table"):
lg = logging.getLogger(logger_name)
lg.setLevel(logging.CRITICAL)
lg.handlers.clear()
lg.propagate = False
log_manager = SimulationLogManager(simulation_dir)
private_logger = log_manager.get_private_logger()
log_manager.info("=" * 60)
log_manager.info("Private Impact Simulation")
log_manager.info(f"Config: {args.config}")
log_manager.info(f"Simulation ID: {config.get('simulation_id', 'unknown')}")
log_manager.info(f"Wait mode: {'enabled' if wait_for_commands else 'disabled'}")
log_manager.info("=" * 60)
time_config = config.get("time_config", {})
if "total_simulation_days" in time_config:
config_total_rounds = (
int(time_config["total_simulation_days"])
* int(time_config.get("rounds_per_day", 3))
)
else:
total_hours = time_config.get("total_simulation_hours", 72)
minutes_per_round = time_config.get("minutes_per_round", 30)
config_total_rounds = (total_hours * 60) // minutes_per_round
log_manager.info("Simulation parameters:")
if "total_simulation_days" in time_config:
log_manager.info(f" - Total simulated duration: {time_config['total_simulation_days']} days")
log_manager.info(f" - Rounds per day: {time_config.get('rounds_per_day', 3)}")
else:
log_manager.info(f" - Total simulated duration: {time_config.get('total_simulation_hours', 72)}h")
log_manager.info(f" - Minutes per round: {time_config.get('minutes_per_round', 30)}")
log_manager.info(f" - Config total rounds: {config_total_rounds}")
if args.max_rounds:
log_manager.info(f" - Round cap: {args.max_rounds}")
log_manager.info(f" - Agent count: {len(config.get('agent_configs', []))}")
log_manager.info("Log structure:")
log_manager.info(f" - Main log: simulation.log")
log_manager.info(f" - Private actions: private/actions.jsonl")
log_manager.info("=" * 60)
start_time = datetime.now()
total_actions, agent_configs = await run_private_simulation(
config=config,
simulation_dir=simulation_dir,
action_logger=private_logger,
main_logger=log_manager,
max_rounds=args.max_rounds,
)
total_elapsed = (datetime.now() - start_time).total_seconds()
log_manager.info("=" * 60)
log_manager.info(f"Simulation loop complete! Elapsed: {total_elapsed:.1f}s")
if wait_for_commands:
log_manager.info("")
log_manager.info("=" * 60)
log_manager.info("Waiting for commands — environment active")
log_manager.info("Supported: interview, batch_interview, close_env")
log_manager.info("=" * 60)
model = create_model(config)
ipc_handler = PrivateIPCHandler(
simulation_dir=simulation_dir,
agent_configs=agent_configs,
model=model,
)
ipc_handler.update_status("alive")
try:
while not _shutdown_event.is_set():
should_continue = await ipc_handler.process_commands()
if not should_continue:
break
try:
await asyncio.wait_for(_shutdown_event.wait(), timeout=0.5)
break
except asyncio.TimeoutError:
pass
except KeyboardInterrupt:
print("\nInterrupt received")
except asyncio.CancelledError:
print("\nTask cancelled")
except Exception as e:
print(f"\nCommand processing error: {e}")
log_manager.info("\nShutting down...")
ipc_handler.update_status("stopped")
log_manager.info("=" * 60)
log_manager.info("All done!")
log_manager.info(f" - {os.path.join(simulation_dir, 'simulation.log')}")
log_manager.info(f" - {os.path.join(simulation_dir, 'private', 'actions.jsonl')}")
log_manager.info("=" * 60)
def setup_signal_handlers() -> None:
"""
Register SIGTERM/SIGINT handlers.
Same pattern as run_parallel_simulation.py — sets _shutdown_event
instead of calling sys.exit() directly to allow graceful cleanup.
"""
def signal_handler(signum: int, frame: Any) -> None:
global _cleanup_done
sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT"
print(f"\n{sig_name} received, shutting down...")
if not _cleanup_done:
_cleanup_done = True
if _shutdown_event:
_shutdown_event.set()
else:
print("Forced exit...")
sys.exit(1)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
if __name__ == "__main__":
setup_signal_handlers()
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nProgram interrupted")
except SystemExit:
pass
finally:
try:
from multiprocessing import resource_tracker
resource_tracker._resource_tracker._stop()
except Exception:
pass
print("Simulation process exited")