discourse-sim 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ """
2
+ discourse_sim
3
+ =============
4
+ LLM-augmented Agent-Based Social Simulation for modelling
5
+ public discourse dynamics following a critical real-world event.
6
+
7
+ Quick start
8
+ -----------
9
+ from discourse_sim import DiscourseSimulation
10
+
11
+ sim = DiscourseSimulation(
12
+ critical_event="In November 2023, Dublin city centre saw a major riot...",
13
+ event_date="2023-11-23",
14
+ n_agents=100,
15
+ n_days=15,
16
+ agent_distribution={"centrist": 0.45, "far_right": 0.20,
17
+ "pro_imm": 0.25, "media": 0.10},
18
+ topic="immigration in Ireland",
19
+ ollama_model="mistral:7b-instruct-q4_0",
20
+ )
21
+
22
+ sim.run()
23
+ df = sim.to_dataframe()
24
+ """
25
+
26
+ from discourse_sim.core import DiscourseSimulation
27
+
28
+ __all__ = ["DiscourseSimulation"]
29
+ __version__ = "0.1.0"
@@ -0,0 +1,2 @@
1
+ from discourse_sim.agents.agent import Agent, make_agents, QUIRK_OPTIONS
2
+ __all__ = ["Agent", "make_agents", "QUIRK_OPTIONS"]
@@ -0,0 +1,129 @@
1
+ """
2
+ discourse_sim.agents.agent
3
+ ==========================
4
+ Agent dataclass and population factory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import random
10
+ from dataclasses import dataclass, field
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from discourse_sim.config import SimConfig
15
+
16
+
17
+ QUIRK_OPTIONS = [
18
+ "uses sarcasm heavily",
19
+ "adds emojis liberally",
20
+ "writes short, punchy sentences",
21
+ "writes long reflective sentences",
22
+ "asks rhetorical questions constantly",
23
+ "uses casual slang",
24
+ "formal and polished tone",
25
+ "heavy use of hashtags",
26
+ ]
27
+
28
+
29
+ @dataclass
30
+ class Agent:
31
+ """
32
+ A synthetic social media user.
33
+
34
+ Identity
35
+ --------
36
+ id : unique string identifier (e.g. "agent_0")
37
+ kind : ideological type from SimConfig.agent_distribution
38
+ attitude : [-1, +1] — -1 = strongly pro-topic-subject, +1 = strongly against
39
+ exposure : [0, 1] — cumulative threat narrative saturation
40
+
41
+ Beliefs
42
+ -------
43
+ economic_threat_belief : perceived economic harm from the event subject
44
+ cultural_threat_belief : perceived cultural threat
45
+ security_threat_belief : perceived security/crime risk (updated by threat news)
46
+ humanitarian_belief : humanitarian obligation weight (updated by framing)
47
+
48
+ Psychology
49
+ ----------
50
+ openness : [0,1] — how readily beliefs change (0=rigid, 1=open)
51
+ conformity : [0,1] — peer influence susceptibility
52
+ emotional_reactivity : [0,1] — news impact amplifier
53
+ trust_peers : [0,1] — weight given to social neighbourhood
54
+
55
+ State
56
+ -----
57
+ mood : [-1,+1] — affective state; decays; shocked by threat news
58
+ messages : list of all posts generated so far
59
+ attitude_history : attitude value after each timestep
60
+ reasoning_log : tool-call audit trail per day
61
+ quirk : writing style (assigned once, fixed)
62
+ """
63
+
64
+ id: str
65
+ kind: str
66
+ attitude: float
67
+ exposure: float
68
+
69
+ economic_threat_belief: float = 0.0
70
+ cultural_threat_belief: float = 0.0
71
+ security_threat_belief: float = 0.0
72
+ humanitarian_belief: float = 0.0
73
+
74
+ openness: float = 0.5
75
+ conformity: float = 0.5
76
+ emotional_reactivity: float = 0.5
77
+ trust_peers: float = 0.7
78
+
79
+ mood: float = 0.0
80
+ messages: list = field(default_factory=list)
81
+ attitude_history: list = field(default_factory=list)
82
+ reasoning_log: list = field(default_factory=list)
83
+ quirk: str = ""
84
+
85
+
86
+ def make_agents(config: "SimConfig") -> dict[str, Agent]:
87
+ """
88
+ Spawn N agents according to SimConfig.agent_distribution and kind_priors.
89
+
90
+ The distribution is converted to a weighted sampling pool so that
91
+ proportions are respected even for non-round N values.
92
+ """
93
+ rng = random.Random(config.network_seed)
94
+
95
+ # Build weighted pool (oversample then trim)
96
+ pool: list[str] = []
97
+ for kind, proportion in config.agent_distribution.items():
98
+ count = round(proportion * config.n_agents)
99
+ pool.extend([kind] * count)
100
+
101
+ # Adjust pool length to exactly n_agents
102
+ while len(pool) < config.n_agents:
103
+ pool.append(rng.choice(list(config.agent_distribution.keys())))
104
+ pool = pool[: config.n_agents]
105
+ rng.shuffle(pool)
106
+
107
+ agents: dict[str, Agent] = {}
108
+ for i, kind in enumerate(pool):
109
+ priors = config.kind_priors[kind]
110
+
111
+ def u(key: str) -> float:
112
+ lo, hi = priors[key]
113
+ return rng.uniform(lo, hi)
114
+
115
+ agents[f"agent_{i}"] = Agent(
116
+ id=f"agent_{i}",
117
+ kind=kind,
118
+ attitude=u("attitude"),
119
+ exposure=rng.uniform(0.0, 0.3),
120
+ economic_threat_belief=u("economic_threat"),
121
+ cultural_threat_belief=u("cultural_threat"),
122
+ humanitarian_belief=u("humanitarian"),
123
+ openness=u("openness"),
124
+ emotional_reactivity=u("emotional_react"),
125
+ conformity=rng.uniform(0.3, 0.8),
126
+ trust_peers=rng.uniform(0.4, 0.9),
127
+ )
128
+
129
+ return agents
@@ -0,0 +1,222 @@
1
+ """
2
+ discourse_sim.config
3
+ ====================
4
+ Central configuration dataclass. All user-facing parameters live here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from datetime import date
12
+ from typing import Dict, List, Optional
13
+
14
+
15
+ # ── Default agent kinds and their priors ────────────────────────────────────
16
+ # Users can extend / override these by passing custom_kind_priors to SimConfig.
17
+ DEFAULT_KIND_PRIORS: Dict[str, dict] = {
18
+ "far_right": {
19
+ "attitude": (0.5, 1.0),
20
+ "economic_threat": (0.4, 0.9),
21
+ "cultural_threat": (0.4, 0.9),
22
+ "humanitarian": (-0.5, 0.1),
23
+ "openness": (0.1, 0.4),
24
+ "emotional_react": (0.6, 1.0),
25
+ },
26
+ "pro_imm": {
27
+ "attitude": (-1.0, -0.3),
28
+ "economic_threat": (-0.5, 0.1),
29
+ "cultural_threat": (-0.5, 0.0),
30
+ "humanitarian": (0.4, 1.0),
31
+ "openness": (0.6, 1.0),
32
+ "emotional_react": (0.3, 0.7),
33
+ },
34
+ "centrist": {
35
+ "attitude": (-0.4, 0.4),
36
+ "economic_threat": (-0.2, 0.4),
37
+ "cultural_threat": (-0.2, 0.3),
38
+ "humanitarian": (0.0, 0.5),
39
+ "openness": (0.4, 0.7),
40
+ "emotional_react": (0.3, 0.6),
41
+ },
42
+ "media": {
43
+ "attitude": (-0.3, 0.3),
44
+ "economic_threat": (-0.2, 0.3),
45
+ "cultural_threat": (-0.2, 0.2),
46
+ "humanitarian": (0.1, 0.5),
47
+ "openness": (0.5, 0.8),
48
+ "emotional_react": (0.2, 0.5),
49
+ },
50
+ }
51
+
52
+
53
+ @dataclass
54
+ class SimConfig:
55
+ """
56
+ All configuration for a DiscourseSimulation run.
57
+
58
+ Required parameters
59
+ -------------------
60
+ critical_event : str
61
+ Plain-text description of the event all agents are reacting to.
62
+ This is injected into every agent prompt on every day.
63
+ Example:
64
+ "In November 2023, Dublin city centre erupted in riots following
65
+ a knife attack near a school. Far-right agitators set buses alight
66
+ and attacked Gardaí. The event dominated Irish media for weeks."
67
+
68
+ event_date : str (ISO format: "YYYY-MM-DD")
69
+ Calendar date of Day 0. All subsequent days are offset from this.
70
+
71
+ topic : str
72
+ The subject domain agents post about.
73
+ Example: "immigration in Ireland", "housing policy", "climate protests"
74
+ Used to anchor web search queries and scoring prompts.
75
+
76
+ Optional parameters
77
+ -------------------
78
+ n_agents : int (default 100)
79
+ Total number of synthetic agents.
80
+
81
+ n_days : int (default 15)
82
+ Number of simulation timesteps (one per day).
83
+
84
+ agent_distribution : dict (default Ireland 2025 empirical distribution)
85
+ Proportions of each agent kind. Must sum to 1.0.
86
+ Keys must match entries in kind_priors.
87
+ Example: {"centrist": 0.45, "far_right": 0.20,
88
+ "pro_imm": 0.25, "media": 0.10}
89
+
90
+ kind_priors : dict (default DEFAULT_KIND_PRIORS)
91
+ Per-kind prior distributions for attitude and belief dimensions.
92
+ Each entry: {"attitude": (lo, hi), "economic_threat": (lo, hi), ...}
93
+ Users can pass a fully custom dict or override individual kinds.
94
+
95
+ timeline : dict[int, str] (default: None → auto-generated via LLM search)
96
+ Optional explicit daily news entries keyed by day index (0-based).
97
+ If provided, overrides automatic news retrieval for those days.
98
+ Any day not present falls back to a generic continuation message.
99
+ Example:
100
+ {
101
+ 0: "[VERIFIED] Day 0: The march took place at ...",
102
+ 4: "[VERIFIED] Day 4: Government announced ...",
103
+ }
104
+ If None, agents rely entirely on live DuckDuckGo search results.
105
+
106
+ search_query_prefix : str (default: derived from topic + event summary)
107
+ Prefix prepended to all DuckDuckGo search queries.
108
+ Example: "Dublin immigration march April 2025 Ireland"
109
+ Override this to localise searches for non-Irish events.
110
+
111
+ ollama_model : str (default "mistral:7b-instruct-q4_0")
112
+ Any Ollama model string. Model must be pulled locally.
113
+
114
+ temperature : float (default 0.75)
115
+ LLM temperature for post generation.
116
+
117
+ network_k : int (default 6)
118
+ Watts-Strogatz nearest-neighbour count.
119
+
120
+ network_p : float (default 0.3)
121
+ Watts-Strogatz rewiring probability.
122
+
123
+ network_seed : int (default 42)
124
+ Random seed for network and agent initialisation.
125
+
126
+ use_llm_scoring : bool (default True)
127
+ If False, uses keyword heuristic instead of LLM for attitude scoring.
128
+
129
+ verbose : bool (default True)
130
+ Print per-agent and per-day progress to stdout.
131
+
132
+ threat_keywords : list[str]
133
+ Words in daily news that trigger security_threat_belief increase
134
+ and exposure accumulation. Override for non-immigration topics.
135
+
136
+ humanitarian_keywords : list[str]
137
+ Words in daily news that trigger humanitarian_belief increase.
138
+ """
139
+
140
+ # ── Required ──────────────────────────────────────────────────────────────
141
+ critical_event: str
142
+ event_date: str # "YYYY-MM-DD"
143
+ topic: str
144
+
145
+ # ── Agent population ──────────────────────────────────────────────────────
146
+ n_agents: int = 100
147
+ n_days: int = 15
148
+
149
+ agent_distribution: Dict[str, float] = field(default_factory=lambda: {
150
+ "centrist": 0.45,
151
+ "far_right": 0.20,
152
+ "pro_imm": 0.25,
153
+ "media": 0.10,
154
+ })
155
+
156
+ kind_priors: Dict[str, dict] = field(
157
+ default_factory=lambda: DEFAULT_KIND_PRIORS
158
+ )
159
+
160
+ # ── Timeline ──────────────────────────────────────────────────────────────
161
+ timeline: Optional[Dict[int, str]] = None # None = rely on live search
162
+
163
+ # ── Search ────────────────────────────────────────────────────────────────
164
+ search_query_prefix: Optional[str] = None # None = auto-derived
165
+
166
+ # ── LLM ───────────────────────────────────────────────────────────────────
167
+ ollama_model: str = "mistral:7b-instruct-q4_0"
168
+ temperature: float = 0.75
169
+ use_llm_scoring: bool = True
170
+
171
+ # ── Network ───────────────────────────────────────────────────────────────
172
+ network_k: int = 6
173
+ network_p: float = 0.3
174
+ network_seed: int = 42
175
+
176
+ # ── Belief update keywords ────────────────────────────────────────────────
177
+ threat_keywords: List[str] = field(default_factory=lambda: [
178
+ "arson", "attack", "violence", "crime", "danger",
179
+ "get them out", "deportation", "deport", "riot", "assault",
180
+ ])
181
+ humanitarian_keywords: List[str] = field(default_factory=lambda: [
182
+ "refugee", "asylum", "rights", "children", "family",
183
+ "compassion", "solidarity", "waiting", "humanitarian",
184
+ ])
185
+
186
+ # ── Display ───────────────────────────────────────────────────────────────
187
+ verbose: bool = True
188
+
189
+ # ── Post-init validation ──────────────────────────────────────────────────
190
+ def __post_init__(self):
191
+ # Validate date
192
+ try:
193
+ self._parsed_date = date.fromisoformat(self.event_date)
194
+ except ValueError:
195
+ raise ValueError(
196
+ f"event_date must be ISO format 'YYYY-MM-DD', got: {self.event_date!r}"
197
+ )
198
+
199
+ # Validate distribution sums to ~1.0
200
+ total = sum(self.agent_distribution.values())
201
+ if abs(total - 1.0) > 0.01:
202
+ raise ValueError(
203
+ f"agent_distribution proportions must sum to 1.0, got {total:.3f}"
204
+ )
205
+
206
+ # Validate all distribution keys have priors
207
+ for kind in self.agent_distribution:
208
+ if kind not in self.kind_priors:
209
+ raise ValueError(
210
+ f"Agent kind '{kind}' in agent_distribution has no entry in kind_priors. "
211
+ f"Add a prior for it or use one of: {list(self.kind_priors.keys())}"
212
+ )
213
+
214
+ # Auto-derive search prefix if not provided
215
+ if self.search_query_prefix is None:
216
+ # Use first 8 words of critical_event + topic
217
+ words = self.critical_event.split()[:8]
218
+ self.search_query_prefix = " ".join(words) + " " + self.topic
219
+
220
+ @property
221
+ def parsed_date(self) -> date:
222
+ return self._parsed_date