Imvirus commited on
Commit
312f0da
·
verified ·
1 Parent(s): fd4acb5

Upload 17 files

Browse files
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .venv/
5
+ venv/
6
+ .env
7
+ .DS_Store
CITATION.cff ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cff-version: 1.2.0
2
+ message: "If you use SocialDynamics Lab in teaching or research, please cite this software."
3
+ title: "SocialDynamics Lab: Interactive Opinion Dynamics and Misinformation Simulator"
4
+ type: software
5
+ authors:
6
+ - family-names: "Imel"
7
+ given-names: "Anouar"
8
+ version: "1.0.0"
9
+ date-released: "2026-09-23"
10
+ license: "MIT"
11
+ url: "https://huggingface.co/spaces/imvirus/SocialDynamics_Lab"
12
+ repository-code: "https://huggingface.co/spaces/imvirus/SocialDynamics_Lab/tree/main"
13
+ abstract: "A reproducible multi-agent simulator for exploring opinion dynamics across network topology, confirmation bias, committed minorities, and directional misinformation."
14
+ keywords:
15
+ - "opinion dynamics"
16
+ - "social computing"
17
+ - "multi-agent systems"
18
+ - "network science"
19
+ - "misinformation"
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anouar Imel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
app.py ADDED
@@ -0,0 +1,831 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import html
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ import traceback
9
+ from pathlib import Path
10
+
11
+ ROOT = Path(__file__).resolve().parent
12
+ SRC = ROOT / "src"
13
+ if str(SRC) not in sys.path:
14
+ sys.path.insert(0, str(SRC))
15
+
16
+ import gradio as gr
17
+ import numpy as np
18
+ import plotly.graph_objects as go
19
+
20
+ try:
21
+ import spaces
22
+
23
+ @spaces.GPU(duration=1)
24
+ def _zerogpu_startup_probe():
25
+ return "ready"
26
+ except ImportError:
27
+ def _zerogpu_startup_probe():
28
+ return "ready"
29
+
30
+ from socialdynamics.presets import PRESETS
31
+ from socialdynamics.simulation import OpinionSimulation, SimulationConfig
32
+ from socialdynamics.visualization import (
33
+ BLUE,
34
+ GREEN,
35
+ INK,
36
+ INK_2,
37
+ MUTED,
38
+ OCHRE,
39
+ PAPER,
40
+ PAPER_2,
41
+ PLUM,
42
+ VERMILION,
43
+ base_layout,
44
+ distribution_figure,
45
+ evolution_figure,
46
+ influence_figure,
47
+ metric_figure,
48
+ network_figure,
49
+ style_axes,
50
+ )
51
+
52
+ VERSION = "1.1.0"
53
+
54
+ PRESET_META = {
55
+ "Healthy Discussion": ("01", "Baseline", "Low confirmation bias, no external signal. Social influence alone pulls the population together."),
56
+ "Echo Chamber": ("02", "Polarization", "A polarized start with strong cross-opinion attenuation. The graph stays fixed; exchange narrows."),
57
+ "Misinformation Cascade": ("03", "External signal", "A neutral population on a scale-free graph receives a persistent directional signal."),
58
+ "Stubborn Minority": ("04", "Committed agents", "One agent in ten holds +1 permanently while everyone else keeps adapting."),
59
+ }
60
+
61
+
62
+ # ── Configuration helpers ───────────────────────────────────────────────────
63
+
64
+ def _direction(label: str) -> float:
65
+ return 1.0 if str(label).startswith("Positive") else -1.0
66
+
67
+
68
+ def _config(
69
+ n_agents,
70
+ network,
71
+ initial_opinion,
72
+ stubborn_fraction,
73
+ stubborn_mode,
74
+ misinformation_exposure,
75
+ misinformation_strength,
76
+ misinformation_direction,
77
+ confirmation_bias,
78
+ social_influence,
79
+ noise,
80
+ steps,
81
+ seed,
82
+ ):
83
+ return SimulationConfig(
84
+ n_agents=int(n_agents),
85
+ network=network,
86
+ initial_opinion=initial_opinion,
87
+ stubborn_fraction=float(stubborn_fraction),
88
+ stubborn_mode=stubborn_mode,
89
+ misinformation_exposure=float(misinformation_exposure),
90
+ misinformation_strength=float(misinformation_strength),
91
+ misinformation_direction=_direction(misinformation_direction),
92
+ confirmation_bias=float(confirmation_bias),
93
+ social_influence=float(social_influence),
94
+ noise=float(noise),
95
+ steps=int(steps),
96
+ seed=int(seed if seed is not None else 42),
97
+ )
98
+
99
+
100
+ def _composition(opinions: np.ndarray) -> tuple[float, float, float]:
101
+ negative = float(np.mean(opinions < -0.2))
102
+ neutral = float(np.mean(np.abs(opinions) <= 0.2))
103
+ positive = max(0.0, 1.0 - negative - neutral)
104
+ return negative, neutral, positive
105
+
106
+
107
+ def _delta(value: float, threshold: float = 0.005) -> str:
108
+ if value > threshold:
109
+ return f"▲ {value:+.3f}"
110
+ if value < -threshold:
111
+ return f"▼ {value:+.3f}"
112
+ return "— ≈ 0.000"
113
+
114
+
115
+ def _spark(values: list[float], lo: float, hi: float, color: str) -> str:
116
+ w, h = 140, 30
117
+ vals = list(values) if len(values) > 1 else list(values) * 2
118
+ n = len(vals)
119
+ span = (hi - lo) or 1.0
120
+ pts = " ".join(
121
+ f"{i / (n - 1) * w:.1f},{h - 3 - (min(hi, max(lo, v)) - lo) / span * (h - 6):.1f}" for i, v in enumerate(vals)
122
+ )
123
+ mid = ""
124
+ if lo < 0 < hi:
125
+ y0 = h - 3 - (0 - lo) / span * (h - 6)
126
+ mid = f'<line x1="0" y1="{y0:.1f}" x2="{w}" y2="{y0:.1f}" stroke="rgba(27,26,23,.18)" stroke-dasharray="2 3"/>'
127
+ return (
128
+ f'<svg class="spark" viewBox="0 0 {w} {h}" aria-hidden="true">{mid}'
129
+ f'<polyline points="{pts}" fill="none" stroke="{color}" stroke-width="1.6" stroke-linejoin="round"/></svg>'
130
+ )
131
+
132
+
133
+ # ── HTML fragments ──────────────────────────────────────────────────────────
134
+
135
+ READOUTS = (
136
+ ("mean_opinion", "Mean opinion", BLUE, (-1.0, 1.0), "{:+.3f}"),
137
+ ("polarization", "Polarization", VERMILION, (0.0, 1.0), "{:.3f}"),
138
+ ("consensus", "Consensus", GREEN, (0.0, 1.0), "{:.3f}"),
139
+ ("echo_chamber", "Echo-chamber index", PLUM, (0.0, 1.0), "{:.3f}"),
140
+ )
141
+
142
+
143
+ def _cards(sim: OpinionSimulation, step: int, running: bool = True) -> str:
144
+ first = sim.metric_history[0]
145
+ current = sim.metric_history[-1]
146
+ exposed = int(np.sum(sim.misinformation_mask))
147
+ stubborn = int(np.sum(sim.stubborn_mask))
148
+ progress = 100.0 * step / max(1, sim.config.steps)
149
+ negative, neutral, positive = _composition(sim.opinions)
150
+ state = "Running" if running else "Complete"
151
+ state_cls = "live" if running else "done"
152
+
153
+ cells = []
154
+ for key, label, color, (lo, hi), fmt in READOUTS:
155
+ series = [float(m[key]) for m in sim.metric_history]
156
+ cells.append(
157
+ f'<div class="ro"><div class="ro-label"><i style="background:{color}"></i>{label}</div>'
158
+ f'<div class="ro-val">{fmt.format(current[key])}</div>'
159
+ f'<div class="ro-delta">{_delta(current[key] - first[key])} <span>since t=0</span></div>'
160
+ f'{_spark(series, lo, hi, color)}</div>'
161
+ )
162
+ cells.append(
163
+ f'<div class="ro"><div class="ro-label"><i style="background:{OCHRE}"></i>Special agents</div>'
164
+ f'<div class="ro-val">{stubborn}<small> stubborn</small></div>'
165
+ f'<div class="ro-delta">{exposed} exposed to signal</div>'
166
+ f'<div class="ro-note">{stubborn / sim.config.n_agents:.0%} fixed · {exposed / sim.config.n_agents:.0%} exposed</div></div>'
167
+ )
168
+
169
+ return f"""
170
+ <div class="status">
171
+ <div class="status-state"><span class="dot {state_cls}"></span>{state}</div>
172
+ <div class="status-meta">{sim.config.n_agents:,} agents · {html.escape(sim.config.network)} · {html.escape(sim.config.initial_opinion)} start · seed {sim.config.seed}</div>
173
+ <div class="status-step">t = {step:03d} / {sim.config.steps:03d}</div>
174
+ </div>
175
+ <div class="progress"><div class="progress-fill {state_cls}" style="width:{progress:.2f}%"></div></div>
176
+ <div class="readouts">{''.join(cells)}</div>
177
+ <div class="comp">
178
+ <div class="comp-label">Population<br>composition</div>
179
+ <div class="comp-body">
180
+ <div class="comp-track"><span style="width:{negative * 100:.2f}%;background:{BLUE}"></span><span style="width:{neutral * 100:.2f}%;background:#DCD6C8"></span><span style="width:{positive * 100:.2f}%;background:{VERMILION}"></span></div>
181
+ <div class="comp-legend"><span><b>{negative:.0%}</b> negative &lt; −0.2</span><span><b>{neutral:.0%}</b> neutral band</span><span><b>{positive:.0%}</b> positive &gt; +0.2</span></div>
182
+ </div>
183
+ </div>
184
+ """
185
+
186
+
187
+ def _regime(sim: OpinionSimulation) -> tuple[str, str]:
188
+ o = sim.opinions
189
+ m = sim.metric_history[-1]
190
+ mean = float(m["mean_opinion"])
191
+ strong_neg = float(np.mean(o < -0.5))
192
+ strong_pos = float(np.mean(o > 0.5))
193
+ if m["consensus"] >= 0.8:
194
+ return "Consensus", f"The population converges to a shared position near <b>{mean:+.2f}</b>."
195
+ if min(strong_neg, strong_pos) >= 0.2 and m["polarization"] >= 0.25:
196
+ return "Polarized split", f"<b>{strong_neg:.0%}</b> of agents hold strongly negative and <b>{strong_pos:.0%}</b> strongly positive views."
197
+ if abs(mean) >= 0.3:
198
+ side = "positive" if mean > 0 else "negative"
199
+ return "Directional drift", f"The population leans <b>{side}</b> overall (<b>{mean:+.2f}</b>) without reaching full agreement."
200
+ return "Plural / dispersed", "Opinions stay spread across the scale, with no dominant pole and no shared position."
201
+
202
+
203
+ def _summary(sim: OpinionSimulation) -> str:
204
+ first = sim.metric_history[0]
205
+ last = sim.metric_history[-1]
206
+ dmean = last["mean_opinion"] - first["mean_opinion"]
207
+ dpol = last["polarization"] - first["polarization"]
208
+ dcons = last["consensus"] - first["consensus"]
209
+ decho = last["echo_chamber"] - first["echo_chamber"]
210
+ regime, regime_sentence = _regime(sim)
211
+
212
+ if abs(dmean) <= 0.03:
213
+ mean_sentence = "Net directional movement is negligible."
214
+ else:
215
+ mean_sentence = f"The mean shifts <b>{'positive' if dmean > 0 else 'negative'}</b> by <b>{dmean:+.3f}</b>."
216
+ if dpol > 0.03:
217
+ pol_sentence = "Dispersion increases."
218
+ elif dpol < -0.03:
219
+ pol_sentence = "Dispersion decreases."
220
+ else:
221
+ pol_sentence = "Dispersion is broadly stable."
222
+ if decho > 0.05:
223
+ echo_sentence = "Influence concentrates among like-minded neighbours."
224
+ elif decho < -0.05:
225
+ echo_sentence = "Selective same-opinion filtering weakens."
226
+ else:
227
+ echo_sentence = "Selective filtering changes only modestly."
228
+
229
+ c = sim.config
230
+ record = (
231
+ f"β {c.confirmation_bias:.2f} · α {c.social_influence:.2f} · stubborn {c.stubborn_fraction:.0%} ({html.escape(c.stubborn_mode)}) · "
232
+ f"signal {c.misinformation_exposure:.0%} × {c.misinformation_strength:.2f} ({'+' if c.misinformation_direction > 0 else '−'}1) · "
233
+ f"σ {c.noise:.3f} · T {c.steps}"
234
+ )
235
+ return f"""
236
+ <div class="findings">
237
+ <div class="f-left">
238
+ <div class="kicker">Finding · t = {len(sim.history) - 1}</div>
239
+ <div class="f-regime">{regime}</div>
240
+ <p class="f-lead">{regime_sentence}</p>
241
+ </div>
242
+ <div class="f-right">
243
+ <p class="f-copy">{mean_sentence} {pol_sentence} {echo_sentence}</p>
244
+ <div class="f-deltas">
245
+ <span><i style="background:{BLUE}"></i>Δ mean <b>{dmean:+.3f}</b></span>
246
+ <span><i style="background:{VERMILION}"></i>Δ polarization <b>{dpol:+.3f}</b></span>
247
+ <span><i style="background:{GREEN}"></i>Δ consensus <b>{dcons:+.3f}</b></span>
248
+ <span><i style="background:{PLUM}"></i>Δ echo index <b>{decho:+.3f}</b></span>
249
+ </div>
250
+ <div class="f-record"><span>Run record</span>{record}</div>
251
+ <div class="f-note">Diagnostics describe this synthetic run only; they are not estimates of a real population.</div>
252
+ </div>
253
+ </div>
254
+ """
255
+
256
+
257
+ def _waiting(title: str, sub: str) -> str:
258
+ return f'<div class="waiting"><span class="dot live"></span><b>{title}</b><span>{sub}</span></div>'
259
+
260
+
261
+ def _error_panel(exc: Exception) -> str:
262
+ return f"""
263
+ <div class="error-card"><div class="kicker err">Simulation interrupted</div>
264
+ <code>{html.escape(type(exc).__name__)}: {html.escape(str(exc))}</code>
265
+ <div class="f-note">The full traceback was written to the Space logs.</div></div>
266
+ """
267
+
268
+
269
+ def _export_csv(sim: OpinionSimulation) -> str:
270
+ c = sim.config
271
+ prefix = f"socialdynamics_{c.network.replace(' ', '').lower()}_n{c.n_agents}_seed{c.seed}_"
272
+ fd, path = tempfile.mkstemp(prefix=prefix, suffix=".csv")
273
+ history = sim.history_array
274
+ with os.fdopen(fd, "w", newline="", encoding="utf-8") as fh:
275
+ writer = csv.writer(fh)
276
+ writer.writerow(["step", "mean_opinion", "polarization", "consensus", "echo_chamber", "share_negative", "share_neutral", "share_positive"])
277
+ for m, row in zip(sim.metric_history, history):
278
+ neg, neu, pos = _composition(row)
279
+ writer.writerow([int(m["step"]), f"{m['mean_opinion']:.6f}", f"{m['polarization']:.6f}", f"{m['consensus']:.6f}", f"{m['echo_chamber']:.6f}", f"{neg:.4f}", f"{neu:.4f}", f"{pos:.4f}"])
280
+ return path
281
+
282
+
283
+ # ── Event handlers ──────────────────────────────────────────────────────────
284
+
285
+ def run_simulation(*values):
286
+ try:
287
+ cfg = _config(*values)
288
+ sim = OpinionSimulation(cfg)
289
+ yield (
290
+ _cards(sim, 0, running=True),
291
+ network_figure(sim),
292
+ evolution_figure(sim),
293
+ metric_figure(sim),
294
+ distribution_figure(sim),
295
+ influence_figure(sim),
296
+ _waiting("Simulation running", "Charts refresh at a population-aware cadence."),
297
+ gr.DownloadButton(value=None, interactive=False),
298
+ )
299
+
300
+ if cfg.n_agents >= 1000:
301
+ update_every = 40
302
+ elif cfg.n_agents >= 500:
303
+ update_every = 25
304
+ else:
305
+ update_every = 4 if cfg.steps <= 120 else 6
306
+
307
+ for step in sim.stream(update_every=update_every):
308
+ final = step == cfg.steps
309
+ refresh_network = final if cfg.n_agents >= 500 else (final or step <= update_every or step % (update_every * 3) == 0)
310
+ yield (
311
+ _cards(sim, step, running=not final),
312
+ network_figure(sim) if refresh_network else gr.skip(),
313
+ evolution_figure(sim),
314
+ metric_figure(sim),
315
+ distribution_figure(sim),
316
+ influence_figure(sim),
317
+ _summary(sim) if final else _waiting("Dynamics in progress", "Every step is executed; only visual refreshes are throttled."),
318
+ gr.DownloadButton(value=_export_csv(sim), interactive=True) if final else gr.skip(),
319
+ )
320
+ except Exception as exc:
321
+ traceback.print_exc()
322
+ yield (
323
+ '<div class="status err"><div class="status-state"><span class="dot err"></span>Interrupted</div><div class="status-meta">See the error below.</div><div></div></div>',
324
+ gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(), _error_panel(exc), gr.skip(),
325
+ )
326
+
327
+
328
+ def _compare_figure(rows: list[dict[str, float | str]]) -> go.Figure:
329
+ fig = go.Figure()
330
+ colors = {"Polarization": VERMILION, "Consensus": GREEN, "Echo-chamber index": PLUM}
331
+ for col in ("Polarization", "Consensus", "Echo-chamber index"):
332
+ fig.add_trace(go.Bar(
333
+ x=[r["Network"] for r in rows], y=[r[col] for r in rows], name=col.lower(), marker_color=colors[col],
334
+ marker_line_width=0, hovertemplate=f"%{{x}}<br>{col.lower()} %{{y:.3f}}<extra></extra>",
335
+ ))
336
+ fig.update_layout(**base_layout("Final-state scores", "Same parameters, different topology", height=440))
337
+ fig.update_layout(barmode="group", bargap=0.34, bargroupgap=0.08)
338
+ style_axes(fig, "", "Score", yrange=[0, 1])
339
+ return fig
340
+
341
+
342
+ def _compare_table(rows: list[dict[str, float | str]]) -> str:
343
+ metrics = (("Polarization", VERMILION), ("Consensus", GREEN), ("Echo-chamber index", PLUM))
344
+ tops = {name: max(float(r[name]) for r in rows) for name, _ in metrics}
345
+ body = []
346
+ for r in rows:
347
+ cells = [f"<td class='net'>{html.escape(str(r['Network']))}</td><td class='num'>{float(r['Mean opinion']):+.3f}</td>"]
348
+ for name, color in metrics:
349
+ v = float(r[name])
350
+ top = " top" if abs(v - tops[name]) < 1e-9 else ""
351
+ cells.append(f"<td class='num{top}'><span class='cbar' style='width:{max(2, v * 64):.0f}px;background:{color}'></span>{v:.3f}</td>")
352
+ body.append("<tr>" + "".join(cells) + "</tr>")
353
+ return (
354
+ "<table class='ctable'><thead><tr><th>Network</th><th>Mean</th><th>Polarization</th><th>Consensus</th><th>Echo index</th></tr></thead>"
355
+ f"<tbody>{''.join(body)}</tbody></table><div class='f-note'>Bold marks the highest value in each column.</div>"
356
+ )
357
+
358
+
359
+ def compare_networks(*values):
360
+ try:
361
+ base = _config(*values)
362
+ rows: list[dict[str, float | str]] = []
363
+ for network in ("Random", "Small World", "Scale Free"):
364
+ cfg = SimulationConfig(**{**base.__dict__, "network": network})
365
+ sim = OpinionSimulation(cfg)
366
+ sim.run()
367
+ m = sim.metric_history[-1]
368
+ rows.append({"Network": network, "Mean opinion": float(m["mean_opinion"]), "Polarization": float(m["polarization"]), "Consensus": float(m["consensus"]), "Echo-chamber index": float(m["echo_chamber"])})
369
+ return _compare_table(rows), _compare_figure(rows), '<div class="compare-ok">✓ Comparison complete — seed and every non-topology parameter held fixed.</div>'
370
+ except Exception as exc:
371
+ traceback.print_exc()
372
+ return _error_panel(exc), gr.skip(), ""
373
+
374
+
375
+ def _preset_params(p: dict) -> str:
376
+ return f"{p['network']} · {p['initial_opinion']} · β {p['confirmation_bias']:.2f} · α {p['social_influence']:.2f}"
377
+
378
+
379
+ def _banner(name: str | None) -> str:
380
+ if name is None:
381
+ return '<div class="active-scn"><span class="tag">Custom</span><b>Your own configuration</b><span class="hint">Adjust parameters, then run — or load a scenario above.</span></div>'
382
+ num, tag, _ = PRESET_META[name]
383
+ return f'<div class="active-scn"><span class="tag on">Scenario {num}</span><b>{name}</b><span class="hint">{_preset_params(PRESETS[name])} · loaded and running</span></div>'
384
+
385
+
386
+ def preset_values(name: str):
387
+ p = PRESETS[name]
388
+ return (
389
+ p["network"], p["initial_opinion"], p["stubborn_fraction"], p["stubborn_mode"],
390
+ p["misinformation_exposure"], p["misinformation_strength"], p["misinformation_direction"],
391
+ p["confirmation_bias"], p["social_influence"], p["noise"], p["steps"], _banner(name),
392
+ )
393
+
394
+
395
+ # ── Theme ───────────────────────────────────────────────────────────────────
396
+
397
+ def _theme() -> gr.themes.Base:
398
+ theme = gr.themes.Base(
399
+ primary_hue="orange",
400
+ neutral_hue="stone",
401
+ radius_size="sm",
402
+ font=[gr.themes.GoogleFont("IBM Plex Sans"), "ui-sans-serif", "system-ui", "sans-serif"],
403
+ font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace"],
404
+ )
405
+ rule = "rgba(27,26,23,0.20)"
406
+ settings = {
407
+ "body_background_fill": PAPER, "body_text_color": INK, "body_text_color_subdued": MUTED,
408
+ "background_fill_primary": PAPER, "background_fill_secondary": PAPER_2,
409
+ "border_color_primary": rule, "border_color_accent": INK, "border_color_accent_subdued": rule,
410
+ "color_accent": VERMILION, "color_accent_soft": "#F6DED7",
411
+ "link_text_color": VERMILION, "link_text_color_hover": INK,
412
+ "block_background_fill": "transparent", "block_border_width": "0px", "block_border_color": "transparent",
413
+ "block_shadow": "none", "block_radius": "0px", "container_radius": "0px", "embed_radius": "0px",
414
+ "block_label_background_fill": "transparent", "block_label_text_color": INK_2, "block_label_border_width": "0px",
415
+ "block_title_text_color": INK, "block_title_text_weight": "500", "block_info_text_color": MUTED,
416
+ "panel_background_fill": "transparent", "panel_border_width": "0px", "panel_border_color": "transparent",
417
+ "input_background_fill": "#FFFFFF", "input_background_fill_focus": "#FFFFFF",
418
+ "input_border_color": "rgba(27,26,23,0.24)", "input_border_color_focus": INK, "input_border_width": "1px",
419
+ "input_radius": "0px", "input_shadow": "none", "input_shadow_focus": "none",
420
+ "slider_color": VERMILION,
421
+ "checkbox_label_background_fill": "#FFFFFF", "checkbox_label_background_fill_hover": PAPER_2,
422
+ "checkbox_label_background_fill_selected": INK, "checkbox_label_text_color": INK,
423
+ "checkbox_label_text_color_selected": PAPER, "checkbox_label_border_color": "rgba(27,26,23,0.24)",
424
+ "checkbox_label_border_color_selected": INK, "checkbox_background_color_selected": VERMILION,
425
+ "checkbox_border_color_selected": VERMILION, "checkbox_label_shadow": "none",
426
+ "button_primary_background_fill": INK, "button_primary_background_fill_hover": VERMILION,
427
+ "button_primary_text_color": PAPER, "button_primary_text_color_hover": PAPER, "button_primary_border_color": INK,
428
+ "button_secondary_background_fill": "transparent", "button_secondary_background_fill_hover": PAPER_2,
429
+ "button_secondary_text_color": INK, "button_secondary_border_color": INK,
430
+ "button_large_radius": "0px", "button_small_radius": "0px", "button_shadow": "none", "button_shadow_hover": "none",
431
+ "shadow_drop": "none", "shadow_drop_lg": "none", "shadow_spread": "0px",
432
+ "table_border_color": rule, "table_even_background_fill": PAPER, "table_odd_background_fill": PAPER_2,
433
+ "loader_color": VERMILION,
434
+ }
435
+ # Apply one variable at a time (and mirror into *_dark) so an unknown name in a
436
+ # future Gradio release is skipped instead of breaking start-up. The lab is
437
+ # deliberately a single light "paper" theme regardless of OS dark mode.
438
+ for key, value in settings.items():
439
+ for name in (key, f"{key}_dark"):
440
+ try:
441
+ theme.set(**{name: value})
442
+ except (TypeError, AttributeError, ValueError):
443
+ pass
444
+ return theme
445
+
446
+
447
+ CSS = r"""
448
+ :root{--paper:#FBF9F4;--paper-2:#F4F1E9;--paper-3:#EAE5D8;--ink:#1B1A17;--ink-2:#3F3B34;--muted:#6B665C;--rule:rgba(27,26,23,.14);--rule-2:rgba(27,26,23,.08);--blue:#2B59C3;--verm:#D2462C;--ochre:#B7811F;--green:#2F7D5B;--plum:#6E4AA8;--serif:"Newsreader",Georgia,"Times New Roman",serif;--sans:"IBM Plex Sans",ui-sans-serif,system-ui,sans-serif;--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace}
449
+ html,body,gradio-app{background:var(--paper)!important;color-scheme:light}
450
+ .gradio-container{max-width:1480px!important;margin:0 auto!important;padding:0 32px 24px!important;background:var(--paper)!important;color:var(--ink)!important;font-family:var(--sans)!important}
451
+ .gradio-container a{color:var(--verm);text-decoration:underline;text-underline-offset:2px}.gradio-container a:hover{color:var(--ink)}
452
+ footer{display:none!important}
453
+ .gradio-container .prose{color:var(--ink)!important}
454
+ .gradio-container .prose p{margin:0}
455
+
456
+ /* masthead */
457
+ .mast{padding:20px 0 34px;border-bottom:1px solid var(--ink);margin-bottom:28px}
458
+ .mast-rule{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px 20px;font:500 11px/1.2 var(--mono);letter-spacing:.09em;text-transform:uppercase;color:var(--ink-2);padding-bottom:11px;border-bottom:3px double var(--ink)}
459
+ .mast-rule b{font-weight:600;color:var(--ink)}
460
+ .mast-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(0,1fr);gap:56px;align-items:end;margin-top:38px}
461
+ .eyebrow{display:flex;align-items:center;gap:12px;font:500 11px/1.2 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--verm)}
462
+ .eyebrow:before{content:"";width:32px;height:1px;background:var(--verm)}
463
+ .gradio-container .mast h1{font-family:var(--serif)!important;font-weight:400!important;font-size:clamp(2.7rem,5.6vw,5.1rem)!important;line-height:.97!important;letter-spacing:-.028em!important;color:var(--ink)!important;margin:18px 0 20px!important;text-wrap:balance}
464
+ .gradio-container .mast h1 em{font-style:italic;color:var(--verm)}
465
+ .gradio-container .lede{font-family:var(--serif);font-size:19.5px;line-height:1.5;color:var(--ink-2);max-width:38em;text-wrap:pretty;margin:0}
466
+ .facts{display:flex;flex-wrap:wrap;margin-top:28px;border-top:1px solid var(--rule)}
467
+ .facts div{padding:14px 26px 0 0;margin-right:26px;border-right:1px solid var(--rule)}
468
+ .facts div:last-child{border-right:0;margin-right:0}
469
+ .facts b{display:block;font:400 30px/1 var(--serif);color:var(--ink);font-variant-numeric:lining-nums}
470
+ .facts span{display:block;margin-top:6px;font:500 10.5px/1.2 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}
471
+ .eq-card{border:1px solid var(--ink);background:var(--paper-2);padding:22px 24px 20px}
472
+ .eq-label{display:flex;justify-content:space-between;font:500 10.5px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
473
+ .eq{font-family:var(--serif);font-style:italic;font-size:clamp(19px,1.9vw,24px);line-height:1.4;color:var(--ink);margin:16px 0 14px;white-space:nowrap;overflow-x:auto}
474
+ .eq .n{font-style:normal}
475
+ .eq-terms{display:grid;grid-template-columns:auto 1fr;gap:5px 14px;font:400 12.5px/1.45 var(--sans);color:var(--ink-2)}
476
+ .eq-terms i{font-family:var(--serif);font-size:16px;color:var(--ink)}
477
+ .scale{margin-top:18px}
478
+ .scale-bar{height:8px;background:linear-gradient(90deg,#1F3F8F,#5B83D6 25%,#DCD6C8 50%,#E8876F 75%,#B8321C)}
479
+ .scale-ticks{display:flex;justify-content:space-between;margin-top:6px;font:500 10.5px/1 var(--mono);color:var(--muted)}
480
+
481
+ /* section heads */
482
+ .sec-head{display:flex;align-items:baseline;flex-wrap:wrap;gap:6px 16px;border-top:1px solid var(--ink);padding-top:12px;margin:6px 0 16px}
483
+ .sec-num{font:500 11px/1 var(--mono);letter-spacing:.1em;color:var(--verm)}
484
+ .sec-title{font:400 28px/1.1 var(--serif);color:var(--ink);letter-spacing:-.01em}
485
+ .sec-sub{margin-left:auto;font:400 13px/1.4 var(--sans);color:var(--muted);max-width:44em;text-wrap:pretty}
486
+
487
+ /* scenario presets */
488
+ .preset-row{gap:0!important;border:1px solid var(--ink)!important;background:var(--paper)!important}
489
+ .preset-card{padding:18px 20px 18px!important;border-right:1px solid var(--rule)!important;gap:14px!important;justify-content:space-between!important;transition:background .15s}
490
+ .preset-card:last-child{border-right:0!important}
491
+ .preset-card:hover{background:var(--paper-2)!important}
492
+ .pc-top{display:flex;justify-content:space-between;font:500 10.5px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}
493
+ .pc-top b{color:var(--verm);font-weight:500}
494
+ .pc-title{font:400 24px/1.12 var(--serif);color:var(--ink);margin:12px 0 8px;letter-spacing:-.01em}
495
+ .pc-copy{font:400 13px/1.5 var(--sans);color:var(--ink-2);text-wrap:pretty}
496
+ .pc-params{margin-top:12px;font:400 11px/1.4 var(--mono);color:var(--muted)}
497
+ button.preset-btn,.preset-btn button{background:transparent!important;border:1px solid var(--ink)!important;color:var(--ink)!important;border-radius:0!important;font:500 11.5px/1 var(--mono)!important;letter-spacing:.08em!important;text-transform:uppercase!important;min-height:40px!important;box-shadow:none!important}
498
+ button.preset-btn:hover,.preset-btn button:hover{background:var(--ink)!important;color:var(--paper)!important}
499
+ .active-scn{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 14px;padding:12px 0 2px;font:400 13px/1.4 var(--sans);color:var(--ink-2)}
500
+ .active-scn .tag{font:500 10.5px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;padding:5px 8px;border:1px solid var(--ink);color:var(--ink)}
501
+ .active-scn .tag.on{background:var(--ink);color:var(--paper)}
502
+ .active-scn b{font:400 18px/1.2 var(--serif);color:var(--ink)}
503
+ .active-scn .hint{font:400 11.5px/1.4 var(--mono);color:var(--muted)}
504
+
505
+ /* layout */
506
+ .lab-row{gap:28px!important;margin-top:30px!important}
507
+ .control-panel{position:sticky!important;top:14px;align-self:flex-start!important;border:1px solid var(--ink)!important;background:var(--paper-2)!important;padding:20px 20px 20px!important;gap:6px!important}
508
+ .ctl-head{padding-bottom:12px}
509
+ .ctl-head small{display:block;font:500 10.5px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--verm)}
510
+ .ctl-head strong{display:block;margin-top:8px;font:400 24px/1.1 var(--serif);color:var(--ink)}
511
+ .ctl-head p{margin-top:6px!important;font:400 12.5px/1.45 var(--sans);color:var(--muted)}
512
+ .param-group{border:0!important;border-top:1px solid var(--rule)!important;border-radius:0!important;background:transparent!important;padding:12px 0 6px!important;margin:0!important;box-shadow:none!important}
513
+ .param-group>button,.param-group>.label-wrap,.param-group button.label-wrap{font:500 11px/1.2 var(--mono)!important;letter-spacing:.1em!important;text-transform:uppercase!important;color:var(--ink)!important;background:transparent!important;padding:4px 0!important}
514
+ .control-panel label>span,.control-panel [data-testid="block-info"]{font-family:var(--sans)!important}
515
+ .control-panel input[type="number"]{font-family:var(--mono)!important}
516
+ button.run-button,.run-button button{background:var(--ink)!important;color:var(--paper)!important;border:0!important;border-radius:0!important;font:500 13px/1 var(--mono)!important;letter-spacing:.12em!important;text-transform:uppercase!important;min-height:54px!important;box-shadow:none!important;margin-top:10px!important;transition:background .15s}
517
+ button.run-button:hover,.run-button button:hover{background:var(--verm)!important}
518
+ .ctl-foot{font:400 11px/1.5 var(--mono);color:var(--muted);padding-top:8px}
519
+
520
+ /* status + readouts */
521
+ .status{display:grid;grid-template-columns:auto 1fr auto;gap:18px;align-items:center;font:500 11px/1.3 var(--mono);letter-spacing:.07em;text-transform:uppercase;color:var(--ink-2);padding-bottom:10px}
522
+ .status-state{display:flex;align-items:center;gap:8px;color:var(--ink)}
523
+ .status-meta{color:var(--muted);text-align:center}
524
+ .status-step{font-variant-numeric:tabular-nums;color:var(--ink)}
525
+ .status.err{color:var(--verm)}
526
+ .dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex:none}
527
+ .dot.live{background:var(--verm);animation:sdpulse 1.2s ease-in-out infinite}
528
+ .dot.done{background:var(--green)}
529
+ .dot.err{background:var(--verm)}
530
+ @keyframes sdpulse{50%{opacity:.25}}
531
+ .progress{height:2px;background:var(--rule)}
532
+ .progress-fill{height:100%;background:var(--ink);transition:width .4s ease}
533
+ .progress-fill.live{background:var(--verm)}
534
+ .readouts{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));border-bottom:1px solid var(--rule);margin-top:0}
535
+ .ro{padding:16px 18px 12px 0;margin-right:18px;border-right:1px solid var(--rule);min-width:0}
536
+ .ro:last-child{border-right:0;margin-right:0}
537
+ .ro-label{display:flex;align-items:center;gap:8px;font:500 10.5px/1.2 var(--mono);letter-spacing:.09em;text-transform:uppercase;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
538
+ .ro-label i,.f-deltas i{display:inline-block;width:8px;height:8px;flex:none}
539
+ .ro-val{margin:10px 0 6px;font:400 38px/1 var(--serif);color:var(--ink);font-variant-numeric:lining-nums tabular-nums;letter-spacing:-.01em}
540
+ .ro-val small{font:400 13px var(--sans);color:var(--muted);letter-spacing:0}
541
+ .ro-delta{font:400 11.5px/1.3 var(--mono);color:var(--ink-2);white-space:nowrap}
542
+ .ro-delta span{color:var(--muted)}
543
+ .ro-note{margin-top:14px;font:400 11px/1.3 var(--mono);color:var(--muted)}
544
+ .spark{display:block;width:100%;height:auto;margin-top:10px}
545
+ .comp{display:grid;grid-template-columns:120px minmax(0,1fr);gap:18px;align-items:center;padding:14px 0;border-bottom:1px solid var(--rule)}
546
+ .comp-label{font:500 10.5px/1.35 var(--mono);letter-spacing:.09em;text-transform:uppercase;color:var(--muted)}
547
+ .comp-track{display:flex;height:12px;background:var(--paper-3);overflow:hidden}
548
+ .comp-track span{height:100%;transition:width .4s ease}
549
+ .comp-legend{display:flex;justify-content:space-between;flex-wrap:wrap;gap:4px 16px;margin-top:7px;font:400 11.5px/1.3 var(--mono);color:var(--muted)}
550
+ .comp-legend b{color:var(--ink);font-weight:500}
551
+ .legend{display:flex;flex-wrap:wrap;gap:8px 22px;padding:14px 0 10px;font:400 12.5px/1.3 var(--sans);color:var(--ink-2)}
552
+ .legend span{display:flex;align-items:center;gap:8px}
553
+ .lg-dot{width:10px;height:10px;border-radius:50%}
554
+ .lg-dia{width:9px;height:9px;transform:rotate(45deg);background:#DCD6C8;border:1px solid var(--ink)}
555
+ .lg-ring{width:10px;height:10px;border-radius:50%;border:2px solid var(--ochre);background:#DCD6C8}
556
+
557
+ /* chart tabs */
558
+ .viz-tabs{border:1px solid var(--ink)!important;border-radius:0!important;background:var(--paper)!important;padding:0 6px 6px!important}
559
+ .viz-tabs [role="tablist"]{border-bottom:1px solid var(--rule)!important;gap:2px!important;padding:0 4px!important;background:transparent!important}
560
+ .viz-tabs button[role="tab"]{font:500 11.5px/1 var(--mono)!important;letter-spacing:.07em!important;text-transform:uppercase!important;color:var(--muted)!important;border-radius:0!important;padding:14px 12px!important;background:transparent!important;border:0!important}
561
+ .viz-tabs button[role="tab"]:hover{color:var(--ink)!important}
562
+ .viz-tabs button[role="tab"][aria-selected="true"],.viz-tabs button[role="tab"].selected{color:var(--ink)!important}
563
+
564
+ /* findings */
565
+ .waiting{display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px;border-top:1px solid var(--ink);padding:18px 0;margin-top:18px;font:400 12px/1.4 var(--mono);color:var(--muted)}
566
+ .waiting b{font:400 20px/1.1 var(--serif);color:var(--ink)}
567
+ .findings{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.5fr);gap:40px;border-top:1px solid var(--ink);padding:20px 0 6px;margin-top:18px}
568
+ .kicker{font:500 10.5px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--verm)}
569
+ .kicker.err{margin-bottom:10px}
570
+ .f-regime{margin-top:12px;font:400 44px/1 var(--serif);color:var(--ink);letter-spacing:-.02em}
571
+ .gradio-container .f-lead{margin-top:12px!important;font:400 17px/1.5 var(--serif);color:var(--ink-2);text-wrap:pretty}
572
+ .gradio-container .f-copy{font:400 15px/1.6 var(--sans);color:var(--ink-2);text-wrap:pretty}
573
+ .f-lead b,.f-copy b{color:var(--ink);font-weight:600}
574
+ .f-deltas{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
575
+ .f-deltas span{display:flex;align-items:center;gap:8px;padding:7px 10px;border:1px solid var(--rule);background:#fff;font:400 11.5px/1 var(--mono);color:var(--muted)}
576
+ .f-deltas b{color:var(--ink);font-weight:500}
577
+ .f-record{margin-top:14px;padding:10px 12px;background:var(--paper-2);font:400 11.5px/1.55 var(--mono);color:var(--ink-2)}
578
+ .f-record span{display:block;font-weight:500;letter-spacing:.1em;text-transform:uppercase;font-size:10px;color:var(--muted);margin-bottom:3px}
579
+ .f-note{margin-top:10px;font:400 11.5px/1.45 var(--mono);color:var(--muted)}
580
+ button.dl-btn,.dl-btn button{align-self:flex-start;background:transparent!important;border:1px solid var(--ink)!important;border-radius:0!important;color:var(--ink)!important;font:500 11px/1 var(--mono)!important;letter-spacing:.08em!important;text-transform:uppercase!important;box-shadow:none!important;min-height:38px!important;max-width:280px}
581
+ button.dl-btn:hover:not(:disabled),.dl-btn button:hover:not(:disabled){background:var(--ink)!important;color:var(--paper)!important}
582
+ button.dl-btn:disabled{opacity:.4}
583
+ .error-card{border:1px solid var(--verm);background:#FBEEEA;padding:16px 18px;margin-top:18px}
584
+ .error-card code{font:400 12.5px/1.5 var(--mono);color:var(--ink)}
585
+
586
+ /* compare */
587
+ .compare-sec{margin-top:48px!important;gap:12px!important}
588
+ button.compare-button,.compare-button button{align-self:flex-start;background:var(--ink)!important;color:var(--paper)!important;border:0!important;border-radius:0!important;font:500 12px/1 var(--mono)!important;letter-spacing:.1em!important;text-transform:uppercase!important;min-height:46px!important;max-width:360px;box-shadow:none!important}
589
+ button.compare-button:hover,.compare-button button:hover{background:var(--verm)!important}
590
+ .compare-ok{font:400 11.5px/1.4 var(--mono);color:var(--green)}
591
+ .ctable{width:100%;border-collapse:collapse;font:400 13.5px/1.3 var(--sans);color:var(--ink-2)}
592
+ .ctable th{font:500 10.5px/1 var(--mono);letter-spacing:.09em;text-transform:uppercase;color:var(--muted);text-align:right;padding:12px 10px;border-bottom:1px solid var(--ink)}
593
+ .ctable th:first-child,.ctable td:first-child{text-align:left;padding-left:0}
594
+ .ctable td{padding:16px 10px;border-bottom:1px solid var(--rule)}
595
+ .ctable td.net{font:400 19px/1.1 var(--serif);color:var(--ink)}
596
+ .ctable td.num{text-align:right;font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap}
597
+ .ctable td.top{color:var(--ink);font-weight:600}
598
+ .cbar{display:inline-block;height:6px;vertical-align:middle;margin-right:10px}
599
+ .empty-note{padding:26px 0;font:400 12px/1.5 var(--mono);color:var(--muted);border-bottom:1px solid var(--rule)}
600
+
601
+ /* methods */
602
+ .methods{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border-top:1px solid var(--ink);margin-top:56px}
603
+ .m-col{padding:18px 24px 24px 0;margin-right:24px;border-right:1px solid var(--rule);min-width:0}
604
+ .m-col:last-child{border-right:0;margin-right:0}
605
+ .m-num{font:500 10.5px/1 var(--mono);letter-spacing:.1em;color:var(--verm)}
606
+ .gradio-container .m-col h3{margin:10px 0 10px!important;font:400 23px/1.15 var(--serif)!important;color:var(--ink)!important}
607
+ .gradio-container .m-col p{font:400 13.5px/1.6 var(--sans);color:var(--ink-2);margin:0 0 10px!important;text-wrap:pretty}
608
+ .fx{font-family:var(--serif);font-style:italic;font-size:18px;color:var(--ink);padding:6px 0 8px;white-space:nowrap;overflow-x:auto}
609
+ .fx .n{font-style:normal}
610
+ .m-list{display:grid;gap:8px;margin-top:4px}
611
+ .m-list div{display:grid;grid-template-columns:96px 1fr;gap:10px;font:400 12.5px/1.45 var(--sans);color:var(--ink-2);padding-bottom:8px;border-bottom:1px solid var(--rule-2)}
612
+ .m-list b{font:500 11px/1.45 var(--mono);color:var(--ink);letter-spacing:.03em}
613
+ .refs{margin:0;padding-left:18px;font:400 12.5px/1.5 var(--sans);color:var(--ink-2)}
614
+ .refs li{margin-bottom:8px}
615
+ .refs i{font-family:var(--serif);font-size:14px}
616
+ .colophon{display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px 24px;margin-top:40px;padding-top:12px;border-top:3px double var(--ink);font:400 11px/1.5 var(--mono);letter-spacing:.04em;color:var(--muted)}
617
+ .colophon b{color:var(--ink);font-weight:500}
618
+
619
+ @media(max-width:1180px){.mast-grid{grid-template-columns:1fr;gap:32px}.control-panel{position:static!important}.readouts{grid-template-columns:repeat(3,minmax(0,1fr))}.ro:nth-child(3){border-right:0;margin-right:0}.ro:nth-child(n+4){border-top:1px solid var(--rule)}.methods{grid-template-columns:repeat(2,minmax(0,1fr))}.m-col:nth-child(2){border-right:0;margin-right:0}.status-meta{display:none}.status{grid-template-columns:1fr auto}}
620
+ @media(max-width:720px){.gradio-container{padding:0 16px 16px!important}.readouts{grid-template-columns:repeat(2,minmax(0,1fr))}.ro:nth-child(3){border-right:1px solid var(--rule);margin-right:18px}.ro:nth-child(2n){border-right:0;margin-right:0}.ro:nth-child(n+3){border-top:1px solid var(--rule)}.findings{grid-template-columns:1fr;gap:16px}.methods{grid-template-columns:1fr}.m-col{border-right:0!important;margin-right:0!important;border-bottom:1px solid var(--rule)}.comp{grid-template-columns:1fr;gap:8px}.sec-sub{margin-left:0}.preset-card{border-right:0!important;border-bottom:1px solid var(--rule)!important}.f-regime{font-size:36px}}
621
+ """
622
+
623
+ HEAD = """
624
+ <meta name="theme-color" content="#FBF9F4">
625
+ <link rel="preconnect" href="https://fonts.googleapis.com">
626
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
627
+ <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
628
+ <script>
629
+ (function(){
630
+ function light(){document.documentElement.classList.remove('dark');if(document.body){document.body.classList.remove('dark');}}
631
+ light();
632
+ document.addEventListener('DOMContentLoaded',function(){
633
+ light();
634
+ var mo=new MutationObserver(light);
635
+ mo.observe(document.documentElement,{attributes:true,attributeFilter:['class']});
636
+ mo.observe(document.body,{attributes:true,attributeFilter:['class']});
637
+ });
638
+ })();
639
+ </script>
640
+ """
641
+
642
+ MASTHEAD = f"""
643
+ <header class="mast">
644
+ <div class="mast-rule"><span><b>SocialDynamics Lab</b> · Interactive research &amp; teaching simulator</span><span>Opinion dynamics · Network science</span><span>v{VERSION} · MIT</span></div>
645
+ <div class="mast-grid">
646
+ <div>
647
+ <div class="eyebrow">A laboratory for collective opinion</div>
648
+ <h1>How opinions <em>move</em> through a network.</h1>
649
+ <p class="lede">Configure a synthetic population, choose who talks to whom, add confirmation bias, committed minorities, or a persistent misinformation signal — then watch local interaction rules produce consensus, drift, or division.</p>
650
+ <div class="facts">
651
+ <div><b>100–1,000</b><span>Agents</span></div>
652
+ <div><b>3</b><span>Network families</span></div>
653
+ <div><b>5</b><span>Live views</span></div>
654
+ <div><b>Seeded</b><span>Fully reproducible</span></div>
655
+ </div>
656
+ </div>
657
+ <aside class="eq-card">
658
+ <div class="eq-label"><span>Update rule</span><span>per agent · per step</span></div>
659
+ <div class="eq">x<sub>i</sub>(t+1) <span class="n">=</span> clip [ x<sub>i</sub> <span class="n">+</span> α(S<sub>i</sub> <span class="n">−</span> x<sub>i</sub>) <span class="n">+</span> M<sub>i</sub> <span class="n">+</span> ε<sub>i</sub> ]</div>
660
+ <div class="eq-terms">
661
+ <i>S<sub>i</sub></i><span>neighbourhood target, weighted by e<sup>−β|x<sub>i</sub>−x<sub>j</sub>|</sup> — confirmation bias β</span>
662
+ <i>α</i><span>social-influence strength</span>
663
+ <i>M<sub>i</sub></i><span>directional external signal (exposed agents only)</span>
664
+ <i>ε<sub>i</sub></i><span>Gaussian noise · stubborn agents never update</span>
665
+ </div>
666
+ <div class="scale"><div class="scale-bar"></div><div class="scale-ticks"><span>−1 negative</span><span>0</span><span>positive +1</span></div></div>
667
+ </aside>
668
+ </div>
669
+ </header>
670
+ """
671
+
672
+
673
+ def _preset_card_html(name: str) -> str:
674
+ num, tag, copy = PRESET_META[name]
675
+ return (
676
+ f'<div class="pc-top"><b>Scenario {num}</b><span>{tag}</span></div>'
677
+ f'<div class="pc-title">{name}</div><div class="pc-copy">{copy}</div>'
678
+ f'<div class="pc-params">{_preset_params(PRESETS[name])}</div>'
679
+ )
680
+
681
+
682
+ METHODS = """
683
+ <section class="methods">
684
+ <div class="m-col">
685
+ <div class="m-num">§1 · Model</div>
686
+ <h3>Distance-weighted influence</h3>
687
+ <p>Each agent holds a bounded opinion in [−1, +1]. Neighbour influence decays with opinion distance:</p>
688
+ <div class="fx">w<sub>ij</sub> <span class="n">=</span> exp(<span class="n">−</span>β |x<sub>i</sub> <span class="n">−</span> x<sub>j</sub>|),&nbsp; β <span class="n">∈</span> [0, 6]</div>
689
+ <p>Updates are synchronous. A smooth model inspired by DeGroot and bounded-confidence traditions — not either verbatim.</p>
690
+ </div>
691
+ <div class="m-col">
692
+ <div class="m-num">§2 · Diagnostics</div>
693
+ <h3>What the readouts mean</h3>
694
+ <div class="m-list">
695
+ <div><b>Polarization</b><span>Population variance, Var(x) ∈ [0, 1].</span></div>
696
+ <div><b>Consensus</b><span>1 − Std(x), clipped to [0, 1].</span></div>
697
+ <div><b>Echo index</b><span>Drop in edge disagreement once confirmation-bias weights are applied. Near 0 = little selective filtering.</span></div>
698
+ </div>
699
+ </div>
700
+ <div class="m-col">
701
+ <div class="m-num">§3 · Topologies</div>
702
+ <h3>Three network families</h3>
703
+ <div class="m-list">
704
+ <div><b>Random</b><span>Erdős–Rényi, mean degree ≈ 8.</span></div>
705
+ <div><b>Small World</b><span>Watts–Strogatz, k = 8, rewiring p = 0.12.</span></div>
706
+ <div><b>Scale Free</b><span>Barabási–Albert, m = 4 — a few highly connected hubs.</span></div>
707
+ </div>
708
+ <p style="margin-top:12px">Large graphs use a hub-aware radial layout and sampled edges for display only; every tie is simulated.</p>
709
+ </div>
710
+ <div class="m-col">
711
+ <div class="m-num">§4 · References</div>
712
+ <h3>Scientific context</h3>
713
+ <ol class="refs">
714
+ <li>DeGroot (1974). Reaching a consensus. <i>JASA</i> 69.</li>
715
+ <li>Hegselmann &amp; Krause (2002). Opinion dynamics and bounded confidence. <i>JASSS</i> 5(3).</li>
716
+ <li>Watts &amp; Strogatz (1998). Small-world networks. <i>Nature</i> 393.</li>
717
+ <li>Barabási &amp; Albert (1999). Scaling in random networks. <i>Science</i> 286.</li>
718
+ <li>Erdős &amp; Rényi (1959). On random graphs I. <i>Publ. Math.</i> 6.</li>
719
+ </ol>
720
+ </div>
721
+ </section>
722
+ <div class="colophon">
723
+ <span><b>SocialDynamics Lab</b> · Open-source research &amp; teaching demo by Anouar Imel</span>
724
+ <span>A synthetic mechanistic model — not a forecast or measurement of any real population.</span>
725
+ <span>MIT License · v""" + VERSION + """</span>
726
+ </div>
727
+ """
728
+
729
+
730
+ # ── Interface ───────────────────────────────────────────────────────────────
731
+
732
+ with gr.Blocks(title="SocialDynamics Lab — opinion dynamics simulator") as demo:
733
+ gr.HTML(MASTHEAD)
734
+
735
+ probe_btn = gr.Button("probe", visible=False)
736
+ probe_out = gr.Textbox(visible=False)
737
+ probe_btn.click(_zerogpu_startup_probe, outputs=probe_out, api_visibility="private", show_progress="hidden")
738
+
739
+ gr.HTML('<div class="sec-head"><span class="sec-num">01</span><span class="sec-title">Start from a scenario</span><span class="sec-sub">Each preset isolates one mechanism. Loading a scenario sets the parameters and runs it immediately.</span></div>')
740
+ preset_buttons = {}
741
+ with gr.Row(equal_height=True, elem_classes=["preset-row"]):
742
+ for _name in PRESETS:
743
+ with gr.Column(min_width=220, elem_classes=["preset-card"]):
744
+ gr.HTML(_preset_card_html(_name))
745
+ preset_buttons[_name] = gr.Button("Load & run →", elem_classes=["preset-btn"], size="sm")
746
+ preset_banner = gr.HTML(_banner(None))
747
+
748
+ with gr.Row(equal_height=False, elem_classes=["lab-row"]):
749
+ with gr.Column(scale=3, min_width=320, elem_classes=["control-panel"]):
750
+ gr.HTML('<div class="ctl-head"><small>02 · Parameters</small><strong>Configure the experiment</strong><p>Every run is deterministic for a fixed seed.</p></div>')
751
+ with gr.Accordion("A · Population & network", open=True, elem_classes=["param-group"]):
752
+ n_agents = gr.Radio([100, 500, 1000], value=100, label="Population size")
753
+ network = gr.Dropdown(["Random", "Small World", "Scale Free"], value="Small World", label="Network structure")
754
+ initial_opinion = gr.Dropdown(["Random", "Neutral", "Polarized"], value="Random", label="Initial opinion state")
755
+ with gr.Accordion("B · Social dynamics", open=True, elem_classes=["param-group"]):
756
+ confirmation_bias = gr.Slider(0, 1, value=0.45, step=0.01, label="Confirmation bias (β)", info="Suppresses influence from dissimilar neighbours.")
757
+ social_influence = gr.Slider(0, 1, value=0.45, step=0.01, label="Social influence (α)", info="Speed of movement toward the neighbourhood target.")
758
+ stubborn_fraction = gr.Slider(0, 0.30, value=0.05, step=0.01, label="Stubborn agents", info="Share of agents that never update.")
759
+ stubborn_mode = gr.Dropdown(["Keep initial", "Positive (+1)", "Negative (-1)", "Split extremes"], value="Split extremes", label="Stubborn stance")
760
+ with gr.Accordion("C · External signal", open=False, elem_classes=["param-group"]):
761
+ misinformation_exposure = gr.Slider(0, 1, value=0.25, step=0.01, label="Misinformation exposure", info="Share of adaptive agents receiving the signal.")
762
+ misinformation_strength = gr.Slider(0, 1, value=0.35, step=0.01, label="Signal strength")
763
+ misinformation_direction = gr.Radio(["Positive (+1)", "Negative (-1)"], value="Positive (+1)", label="Signal direction")
764
+ with gr.Accordion("D · Run settings", open=False, elem_classes=["param-group"]):
765
+ noise = gr.Slider(0, 0.20, value=0.02, step=0.005, label="Random noise (σ)")
766
+ steps = gr.Slider(50, 300, value=120, step=10, label="Simulation horizon (steps)")
767
+ seed = gr.Number(value=42, precision=0, label="Random seed")
768
+ run_btn = gr.Button("▶ Run experiment", variant="primary", size="lg", elem_classes=["run-button"])
769
+ gr.HTML('<div class="ctl-foot">Large populations refresh charts less often; every step is still simulated.</div>')
770
+
771
+ with gr.Column(scale=10, min_width=620):
772
+ cards = gr.HTML()
773
+ gr.HTML(
774
+ '<div class="legend">'
775
+ f'<span><i class="lg-dot" style="background:{BLUE}"></i>negative opinion</span>'
776
+ '<span><i class="lg-dot" style="background:#DCD6C8"></i>neutral</span>'
777
+ f'<span><i class="lg-dot" style="background:{VERMILION}"></i>positive opinion</span>'
778
+ '<span><i class="lg-dia"></i>stubborn agent</span>'
779
+ '<span><i class="lg-ring"></i>exposed to misinformation</span>'
780
+ '</div>'
781
+ )
782
+ with gr.Tabs(elem_classes=["viz-tabs"]):
783
+ with gr.Tab("Network"):
784
+ network_plot = gr.Plot(show_label=False)
785
+ with gr.Tab("Evolution"):
786
+ evolution_plot = gr.Plot(show_label=False)
787
+ with gr.Tab("Diagnostics"):
788
+ metric_plot = gr.Plot(show_label=False)
789
+ with gr.Tab("Distribution"):
790
+ distribution_plot = gr.Plot(show_label=False)
791
+ with gr.Tab("Influence map"):
792
+ influence_plot = gr.Plot(show_label=False)
793
+ summary = gr.HTML(_waiting("Ready", "Configure an experiment or load a scenario, then run it."))
794
+ download = gr.DownloadButton("↓ Download run data (CSV)", value=None, interactive=False, size="sm", elem_classes=["dl-btn"])
795
+
796
+ with gr.Column(elem_classes=["compare-sec"]):
797
+ gr.HTML('<div class="sec-head"><span class="sec-num">03</span><span class="sec-title">Does topology matter?</span><span class="sec-sub">Rerun the current configuration on all three network families, holding the seed and every other parameter fixed.</span></div>')
798
+ compare_btn = gr.Button("Compare the 3 network structures", elem_classes=["compare-button"])
799
+ compare_status = gr.HTML()
800
+ with gr.Row(equal_height=False):
801
+ with gr.Column(scale=5, min_width=320):
802
+ compare_table = gr.HTML('<div class="empty-note">No comparison yet — results appear here as a table and a grouped chart.</div>')
803
+ with gr.Column(scale=6, min_width=360):
804
+ compare_plot = gr.Plot(show_label=False)
805
+
806
+ gr.HTML(METHODS)
807
+
808
+ inputs = [n_agents, network, initial_opinion, stubborn_fraction, stubborn_mode, misinformation_exposure, misinformation_strength, misinformation_direction, confirmation_bias, social_influence, noise, steps, seed]
809
+ outputs = [cards, network_plot, evolution_plot, metric_plot, distribution_plot, influence_plot, summary, download]
810
+ run_kwargs = dict(inputs=inputs, outputs=outputs, api_visibility="undocumented", concurrency_limit=2, stream_every=0.75)
811
+
812
+ run_btn.click(run_simulation, show_progress="minimal", **run_kwargs)
813
+ demo.load(run_simulation, show_progress="hidden", **run_kwargs)
814
+ compare_btn.click(compare_networks, inputs=inputs, outputs=[compare_table, compare_plot, compare_status], show_progress="minimal", api_visibility="undocumented", concurrency_limit=1)
815
+
816
+ preset_outputs = [network, initial_opinion, stubborn_fraction, stubborn_mode, misinformation_exposure, misinformation_strength, misinformation_direction, confirmation_bias, social_influence, noise, steps, preset_banner]
817
+ for _name, _button in preset_buttons.items():
818
+ _button.click(
819
+ lambda name=_name: preset_values(name), outputs=preset_outputs, queue=False, api_visibility="undocumented"
820
+ ).then(run_simulation, show_progress="minimal", **run_kwargs)
821
+
822
+
823
+ if __name__ == "__main__":
824
+ demo.queue(default_concurrency_limit=2, max_size=16, api_open=False).launch(
825
+ theme=_theme(),
826
+ css=CSS,
827
+ head=HEAD,
828
+ footer_links=[],
829
+ mcp_server=False,
830
+ show_error=True,
831
+ )
pyproject.toml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "socialdynamics-lab"
3
+ version = "1.0.0"
4
+ description = "Interactive multi-agent opinion dynamics and misinformation simulator."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "Anouar Imel" }
10
+ ]
11
+ dependencies = [
12
+ "gradio>=6.5,<7",
13
+ "numpy>=2.0,<3",
14
+ "networkx>=3.4,<4",
15
+ "plotly>=6.0,<7"
16
+ ]
17
+
18
+ [project.urls]
19
+ Space = "https://huggingface.co/spaces/imvirus/SocialDynamics_Lab"
20
+ Source = "https://huggingface.co/spaces/imvirus/SocialDynamics_Lab/tree/main"
21
+
22
+ [tool.pytest.ini_options]
23
+ testpaths = ["tests"]
24
+ addopts = "-q"
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio>=6.5,<7
2
+ numpy>=2.0,<3
3
+ networkx>=3.4,<4
4
+ plotly>=6.0,<7
src/socialdynamics/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Core package for SocialDynamics Lab."""
2
+
3
+ from .simulation import OpinionSimulation, SimulationConfig
4
+
5
+ __version__ = "1.1.0"
6
+ __all__ = ["OpinionSimulation", "SimulationConfig"]
src/socialdynamics/metrics.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import networkx as nx
4
+ import numpy as np
5
+
6
+
7
+ def polarization(opinions: np.ndarray) -> float:
8
+ """Return bounded opinion variance."""
9
+ if opinions.size == 0:
10
+ return 0.0
11
+ value = float(np.var(opinions))
12
+ if abs(value) < 1e-15:
13
+ return 0.0
14
+ return float(np.clip(value, 0.0, 1.0))
15
+
16
+
17
+ def consensus(opinions: np.ndarray) -> float:
18
+ """Return one minus population standard deviation."""
19
+ if opinions.size == 0:
20
+ return 0.0
21
+ std = float(np.std(opinions))
22
+ if abs(std) < 1e-15:
23
+ return 1.0
24
+ return float(np.clip(1.0 - std, 0.0, 1.0))
25
+
26
+
27
+ def echo_chamber_index(
28
+ graph: nx.Graph, opinions: np.ndarray, confirmation_bias: float = 0.0
29
+ ) -> float:
30
+ """Measure selective interaction filtering induced by opinion-distance weighting."""
31
+ if graph.number_of_edges() == 0 or opinions.size < 2:
32
+ return 0.0
33
+
34
+ differences = np.fromiter(
35
+ (abs(float(opinions[u]) - float(opinions[v])) for u, v in graph.edges()),
36
+ dtype=float,
37
+ count=graph.number_of_edges(),
38
+ )
39
+ raw_disagreement = float(np.mean(differences))
40
+ if raw_disagreement < 1e-12:
41
+ return 0.0
42
+
43
+ beta = 6.0 * float(np.clip(confirmation_bias, 0.0, 1.0))
44
+ if beta <= 0.0:
45
+ return 0.0
46
+ weights = np.exp(-beta * differences)
47
+ denominator = float(np.sum(weights))
48
+ if denominator < 1e-12:
49
+ return 1.0
50
+ effective_disagreement = float(np.sum(weights * differences) / denominator)
51
+ score = 1.0 - (effective_disagreement / raw_disagreement)
52
+ return float(np.clip(score, 0.0, 1.0))
53
+
54
+
55
+ def summarize_metrics(
56
+ graph: nx.Graph, opinions: np.ndarray, confirmation_bias: float = 0.0
57
+ ) -> dict[str, float]:
58
+ return {
59
+ "mean_opinion": float(np.mean(opinions)) if opinions.size else 0.0,
60
+ "polarization": polarization(opinions),
61
+ "consensus": consensus(opinions),
62
+ "echo_chamber": echo_chamber_index(graph, opinions, confirmation_bias),
63
+ }
src/socialdynamics/networks.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import random
4
+
5
+ import networkx as nx
6
+
7
+
8
+ NETWORK_ALIASES = {
9
+ "Random": "random",
10
+ "Small World": "small_world",
11
+ "Scale Free": "scale_free",
12
+ }
13
+
14
+
15
+ def build_network(kind: str, n_agents: int, seed: int, average_degree: int = 8) -> nx.Graph:
16
+ """Build a sparse undirected graph with comparable mean degree across families."""
17
+ if n_agents < 2:
18
+ raise ValueError("n_agents must be at least 2")
19
+
20
+ normalized = NETWORK_ALIASES.get(kind, kind).lower().replace("-", "_").replace(" ", "_")
21
+ degree = max(2, min(int(average_degree), n_agents - 1))
22
+
23
+ if normalized == "random":
24
+ probability = min(1.0, degree / max(1, n_agents - 1))
25
+ graph = nx.erdos_renyi_graph(n_agents, probability, seed=seed)
26
+ elif normalized == "small_world":
27
+ if degree % 2:
28
+ degree -= 1
29
+ degree = max(2, min(degree, n_agents - 1 - ((n_agents - 1) % 2)))
30
+ graph = nx.watts_strogatz_graph(n_agents, degree, 0.12, seed=seed)
31
+ elif normalized == "scale_free":
32
+ attachment = max(1, min(degree // 2, n_agents - 1))
33
+ graph = nx.barabasi_albert_graph(n_agents, attachment, seed=seed)
34
+ else:
35
+ raise ValueError(f"Unknown network kind: {kind}")
36
+
37
+ isolates = list(nx.isolates(graph))
38
+ if isolates:
39
+ rng = random.Random(seed + 991)
40
+ nodes = list(graph.nodes())
41
+ for node in isolates:
42
+ choices = [candidate for candidate in nodes if candidate != node]
43
+ graph.add_edge(node, rng.choice(choices))
44
+
45
+ return graph
src/socialdynamics/presets.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ PRESETS = {
2
+ "Healthy Discussion": {
3
+ "network": "Random",
4
+ "initial_opinion": "Random",
5
+ "stubborn_fraction": 0.00,
6
+ "stubborn_mode": "Keep initial",
7
+ "misinformation_exposure": 0.00,
8
+ "misinformation_strength": 0.00,
9
+ "misinformation_direction": "Positive (+1)",
10
+ "confirmation_bias": 0.10,
11
+ "social_influence": 0.55,
12
+ "noise": 0.02,
13
+ "steps": 120,
14
+ },
15
+ "Echo Chamber": {
16
+ "network": "Small World",
17
+ "initial_opinion": "Polarized",
18
+ "stubborn_fraction": 0.04,
19
+ "stubborn_mode": "Split extremes",
20
+ "misinformation_exposure": 0.00,
21
+ "misinformation_strength": 0.00,
22
+ "misinformation_direction": "Positive (+1)",
23
+ "confirmation_bias": 0.85,
24
+ "social_influence": 0.42,
25
+ "noise": 0.01,
26
+ "steps": 160,
27
+ },
28
+ "Misinformation Cascade": {
29
+ "network": "Scale Free",
30
+ "initial_opinion": "Neutral",
31
+ "stubborn_fraction": 0.03,
32
+ "stubborn_mode": "Keep initial",
33
+ "misinformation_exposure": 0.45,
34
+ "misinformation_strength": 0.72,
35
+ "misinformation_direction": "Positive (+1)",
36
+ "confirmation_bias": 0.42,
37
+ "social_influence": 0.48,
38
+ "noise": 0.02,
39
+ "steps": 150,
40
+ },
41
+ "Stubborn Minority": {
42
+ "network": "Small World",
43
+ "initial_opinion": "Neutral",
44
+ "stubborn_fraction": 0.10,
45
+ "stubborn_mode": "Positive (+1)",
46
+ "misinformation_exposure": 0.00,
47
+ "misinformation_strength": 0.00,
48
+ "misinformation_direction": "Positive (+1)",
49
+ "confirmation_bias": 0.28,
50
+ "social_influence": 0.50,
51
+ "noise": 0.015,
52
+ "steps": 150,
53
+ },
54
+ }
src/socialdynamics/simulation.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Iterator
5
+
6
+ import networkx as nx
7
+ import numpy as np
8
+
9
+ from .metrics import summarize_metrics
10
+ from .networks import build_network
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class SimulationConfig:
15
+ n_agents: int = 100
16
+ network: str = "Small World"
17
+ initial_opinion: str = "Random"
18
+ stubborn_fraction: float = 0.05
19
+ stubborn_mode: str = "Split extremes"
20
+ misinformation_exposure: float = 0.25
21
+ misinformation_strength: float = 0.35
22
+ misinformation_direction: float = 1.0
23
+ confirmation_bias: float = 0.45
24
+ social_influence: float = 0.45
25
+ noise: float = 0.02
26
+ steps: int = 120
27
+ seed: int = 42
28
+ average_degree: int = 8
29
+
30
+
31
+ class OpinionSimulation:
32
+ """Synchronous continuous-opinion simulation on a fixed social graph."""
33
+
34
+ def __init__(self, config: SimulationConfig):
35
+ self.config = config
36
+ self._validate()
37
+ self.rng = np.random.default_rng(config.seed)
38
+ self.graph: nx.Graph = build_network(
39
+ config.network, config.n_agents, config.seed, config.average_degree
40
+ )
41
+ self.opinions = self._initialize_opinions()
42
+ self.initial_opinions = self.opinions.copy()
43
+ self.stubborn_mask = self._choose_mask(config.stubborn_fraction)
44
+ self._apply_stubborn_mode()
45
+ self.initial_opinions = self.opinions.copy()
46
+ self.misinformation_mask = self._choose_mask(config.misinformation_exposure, ~self.stubborn_mask)
47
+ self.history: list[np.ndarray] = [self.opinions.copy()]
48
+ self.metric_history: list[dict[str, float]] = [self.metrics(step=0)]
49
+
50
+ def _validate(self) -> None:
51
+ config = self.config
52
+ if config.n_agents not in (100, 500, 1000):
53
+ raise ValueError("n_agents must be one of 100, 500, or 1000")
54
+ for name in (
55
+ "stubborn_fraction",
56
+ "misinformation_exposure",
57
+ "misinformation_strength",
58
+ "confirmation_bias",
59
+ "social_influence",
60
+ ):
61
+ value = float(getattr(config, name))
62
+ if not 0.0 <= value <= 1.0:
63
+ raise ValueError(f"{name} must be in [0, 1]")
64
+ if not 0.0 <= config.noise <= 0.2:
65
+ raise ValueError("noise must be in [0, 0.2]")
66
+ if config.steps < 1:
67
+ raise ValueError("steps must be >= 1")
68
+ if config.misinformation_direction not in (-1.0, 1.0):
69
+ raise ValueError("misinformation_direction must be -1 or +1")
70
+
71
+ def _initialize_opinions(self) -> np.ndarray:
72
+ mode = self.config.initial_opinion.lower()
73
+ n_agents = self.config.n_agents
74
+ if mode == "random":
75
+ values = self.rng.uniform(-1.0, 1.0, size=n_agents)
76
+ elif mode == "neutral":
77
+ values = self.rng.normal(0.0, 0.16, size=n_agents)
78
+ elif mode == "polarized":
79
+ signs = self.rng.choice([-1.0, 1.0], size=n_agents)
80
+ values = signs * self.rng.normal(0.72, 0.13, size=n_agents)
81
+ else:
82
+ raise ValueError(f"Unknown initial opinion mode: {self.config.initial_opinion}")
83
+ return np.clip(values, -1.0, 1.0).astype(np.float64)
84
+
85
+ def _choose_mask(self, fraction: float, eligible: np.ndarray | None = None) -> np.ndarray:
86
+ n_agents = self.config.n_agents
87
+ mask = np.zeros(n_agents, dtype=bool)
88
+ if fraction <= 0:
89
+ return mask
90
+ candidates = np.arange(n_agents) if eligible is None else np.flatnonzero(eligible)
91
+ count = min(len(candidates), int(round(fraction * n_agents)))
92
+ if count > 0:
93
+ picked = self.rng.choice(candidates, size=count, replace=False)
94
+ mask[picked] = True
95
+ return mask
96
+
97
+ def _apply_stubborn_mode(self) -> None:
98
+ indices = np.flatnonzero(self.stubborn_mask)
99
+ if indices.size == 0:
100
+ return
101
+ mode = self.config.stubborn_mode.lower()
102
+ if mode == "keep initial":
103
+ return
104
+ if mode == "positive (+1)":
105
+ self.opinions[indices] = 1.0
106
+ elif mode == "negative (-1)":
107
+ self.opinions[indices] = -1.0
108
+ elif mode == "split extremes":
109
+ shuffled = self.rng.permutation(indices)
110
+ split = len(shuffled) // 2
111
+ self.opinions[shuffled[:split]] = -1.0
112
+ self.opinions[shuffled[split:]] = 1.0
113
+ else:
114
+ raise ValueError(f"Unknown stubborn mode: {self.config.stubborn_mode}")
115
+
116
+ def metrics(self, step: int | None = None) -> dict[str, float]:
117
+ values = summarize_metrics(self.graph, self.opinions, self.config.confirmation_bias)
118
+ values["step"] = float(len(self.history) - 1 if step is None else step)
119
+ return values
120
+
121
+ def step(self) -> np.ndarray:
122
+ old = self.opinions
123
+ new = old.copy()
124
+ beta = 6.0 * self.config.confirmation_bias
125
+ alpha = self.config.social_influence
126
+ misinformation_strength = self.config.misinformation_strength
127
+ misinformation_direction = self.config.misinformation_direction
128
+
129
+ for agent in range(self.config.n_agents):
130
+ if self.stubborn_mask[agent]:
131
+ continue
132
+ neighbors = list(self.graph.neighbors(agent))
133
+ if neighbors:
134
+ neighbor_opinions = old[neighbors]
135
+ distances = np.abs(old[agent] - neighbor_opinions)
136
+ weights = np.exp(-beta * distances)
137
+ total = float(np.sum(weights))
138
+ social_target = (
139
+ float(np.sum(weights * neighbor_opinions) / total)
140
+ if total > 1e-12
141
+ else float(old[agent])
142
+ )
143
+ else:
144
+ social_target = float(old[agent])
145
+
146
+ delta = alpha * (social_target - old[agent])
147
+ if self.misinformation_mask[agent] and misinformation_strength > 0:
148
+ delta += 0.22 * misinformation_strength * (misinformation_direction - old[agent])
149
+ if self.config.noise > 0:
150
+ delta += float(self.rng.normal(0.0, self.config.noise))
151
+ new[agent] = float(np.clip(old[agent] + delta, -1.0, 1.0))
152
+
153
+ self.opinions = new
154
+ self.history.append(new.copy())
155
+ self.metric_history.append(self.metrics(step=len(self.history) - 1))
156
+ return new
157
+
158
+ def run(self, steps: int | None = None) -> np.ndarray:
159
+ for _ in range(self.config.steps if steps is None else steps):
160
+ self.step()
161
+ return self.opinions.copy()
162
+
163
+ def stream(self, update_every: int = 5) -> Iterator[int]:
164
+ update_every = max(1, int(update_every))
165
+ for step in range(1, self.config.steps + 1):
166
+ self.step()
167
+ if step % update_every == 0 or step == self.config.steps:
168
+ yield step
169
+
170
+ @property
171
+ def history_array(self) -> np.ndarray:
172
+ return np.vstack(self.history)
src/socialdynamics/visualization.py ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import networkx as nx
6
+ import numpy as np
7
+ import plotly.graph_objects as go
8
+
9
+ from .simulation import OpinionSimulation
10
+
11
+ # ── Editorial "lab notebook" palette ────────────────────────────────────────
12
+ PAPER = "#FBF9F4"
13
+ PAPER_2 = "#F4F1E9"
14
+ INK = "#1B1A17"
15
+ INK_2 = "#3F3B34"
16
+ MUTED = "#6B665C"
17
+ RULE = "rgba(27,26,23,0.22)"
18
+ GRID = "rgba(27,26,23,0.07)"
19
+ BLUE = "#2B59C3"
20
+ VERMILION = "#D2462C"
21
+ OCHRE = "#B7811F"
22
+ GREEN = "#2F7D5B"
23
+ PLUM = "#6E4AA8"
24
+
25
+ # Backwards-compatible aliases used by earlier versions of app.py
26
+ TEXT = INK
27
+ CYAN = BLUE
28
+ ROSE = VERMILION
29
+ EMERALD = GREEN
30
+ VIOLET = PLUM
31
+ AMBER = OCHRE
32
+
33
+ SANS = "IBM Plex Sans, ui-sans-serif, system-ui, sans-serif"
34
+ MONO = "IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, monospace"
35
+ SERIF = "Newsreader, Georgia, 'Times New Roman', serif"
36
+
37
+ OPINION_SCALE = [
38
+ [0.0, "#1F3F8F"],
39
+ [0.25, "#5B83D6"],
40
+ [0.5, "#DCD6C8"],
41
+ [0.75, "#E8876F"],
42
+ [1.0, "#B8321C"],
43
+ ]
44
+
45
+
46
+ def _hex_to_rgb(value: str) -> tuple[int, int, int]:
47
+ value = value.lstrip("#")
48
+ return int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16)
49
+
50
+
51
+ def opinion_color(x: float) -> str:
52
+ """Map an opinion in [-1, 1] to the diverging opinion scale."""
53
+ t = (float(np.clip(x, -1.0, 1.0)) + 1.0) / 2.0
54
+ for (t0, c0), (t1, c1) in zip(OPINION_SCALE[:-1], OPINION_SCALE[1:]):
55
+ if t <= t1:
56
+ f = 0.0 if t1 == t0 else (t - t0) / (t1 - t0)
57
+ a, b = _hex_to_rgb(c0), _hex_to_rgb(c1)
58
+ r, g, bl = (round(a[k] + (b[k] - a[k]) * f) for k in range(3))
59
+ return f"#{r:02x}{g:02x}{bl:02x}"
60
+ return OPINION_SCALE[-1][1]
61
+
62
+
63
+ def _hub_radial_layout(graph: nx.Graph, seed: int) -> dict[int, np.ndarray]:
64
+ nodes = list(graph.nodes())
65
+ if not nodes:
66
+ return {}
67
+ if len(nodes) == 1:
68
+ return {nodes[0]: np.array([0.0, 0.0], dtype=float)}
69
+
70
+ degrees = dict(graph.degree())
71
+ hub = max(nodes, key=lambda n: (degrees[n], -int(n) if isinstance(n, int) else 0))
72
+ distances = nx.single_source_shortest_path_length(graph, hub)
73
+ max_known = max(distances.values(), default=1)
74
+
75
+ rings: dict[int, list[int]] = {}
76
+ for node in nodes:
77
+ rings.setdefault(int(distances.get(node, max_known + 1)), []).append(node)
78
+
79
+ rng = np.random.default_rng(seed + 1701)
80
+ pos: dict[int, np.ndarray] = {}
81
+ max_ring = max(rings) if rings else 1
82
+
83
+ for ring, ring_nodes in sorted(rings.items()):
84
+ ring_nodes = sorted(ring_nodes, key=lambda n: (-degrees[n], n))
85
+ count = len(ring_nodes)
86
+ phase = float(rng.uniform(0.0, 2.0 * math.pi))
87
+ if ring == 0:
88
+ pos[ring_nodes[0]] = np.array([0.0, 0.0], dtype=float)
89
+ continue
90
+ radius = 0.18 + 0.82 * (ring / max(1, max_ring))
91
+ for idx, node in enumerate(ring_nodes):
92
+ angle = phase + (2.0 * math.pi * idx / max(1, count))
93
+ r = max(0.06, radius + float(rng.uniform(-0.025, 0.025)))
94
+ pos[node] = np.array([1.08 * r * math.cos(angle), r * math.sin(angle)], dtype=float)
95
+ return pos
96
+
97
+
98
+ def _layout(graph: nx.Graph, seed: int) -> dict[int, np.ndarray]:
99
+ if graph.number_of_nodes() <= 250:
100
+ return nx.spring_layout(graph, seed=seed, iterations=42, threshold=1e-4)
101
+ return _hub_radial_layout(graph, seed)
102
+
103
+
104
+ def base_layout(title: str, subtitle: str | None = None, height: int = 520) -> dict:
105
+ """Shared Plotly layout: paper ground, serif titles, mono ticks, hairline rules."""
106
+ title_text = title if not subtitle else (
107
+ f"{title}<br><span style='font-family:{MONO};font-size:10.5px;color:{MUTED};letter-spacing:.06em'>"
108
+ f"{subtitle.upper()}</span>"
109
+ )
110
+ return dict(
111
+ title=dict(text=title_text, x=0.012, xanchor="left", y=0.965, yanchor="top", font=dict(family=SERIF, size=21, color=INK)),
112
+ template="plotly_white",
113
+ paper_bgcolor="rgba(0,0,0,0)",
114
+ plot_bgcolor="rgba(0,0,0,0)",
115
+ font=dict(family=SANS, color=INK_2, size=12),
116
+ height=height,
117
+ margin=dict(l=60, r=28, t=92, b=56),
118
+ hoverlabel=dict(bgcolor=INK, bordercolor=INK, font=dict(family=MONO, size=11, color=PAPER)),
119
+ legend=dict(
120
+ orientation="h", yanchor="bottom", y=1.015, x=1, xanchor="right",
121
+ bgcolor="rgba(0,0,0,0)", font=dict(family=MONO, size=10.5, color=INK_2),
122
+ ),
123
+ )
124
+
125
+
126
+ # Kept for compatibility with callers that imported the private name.
127
+ _base_layout = base_layout
128
+
129
+
130
+ def style_axes(fig: go.Figure, xtitle: str, ytitle: str, yrange=None, xrange=None, ytickformat=None) -> go.Figure:
131
+ common = dict(
132
+ gridcolor=GRID, zeroline=False, showline=True, linecolor=RULE, linewidth=1,
133
+ ticks="outside", tickcolor=RULE, ticklen=4, color=MUTED,
134
+ tickfont=dict(family=MONO, size=10, color=MUTED),
135
+ )
136
+ fig.update_xaxes(title=dict(text=xtitle, font=dict(size=11.5, color=INK_2)), range=xrange, **common)
137
+ fig.update_yaxes(title=dict(text=ytitle, font=dict(size=11.5, color=INK_2)), range=yrange, tickformat=ytickformat, **common)
138
+ return fig
139
+
140
+
141
+ def network_figure(sim: OpinionSimulation, max_edges: int = 2600) -> go.Figure:
142
+ graph = sim.graph
143
+ if sim.config.n_agents >= 1000:
144
+ max_edges = min(max_edges, 900)
145
+ elif sim.config.n_agents >= 500:
146
+ max_edges = min(max_edges, 1400)
147
+
148
+ pos = getattr(sim, "_layout_cache", None)
149
+ if pos is None:
150
+ pos = _layout(graph, sim.config.seed)
151
+ sim._layout_cache = pos
152
+
153
+ edges = list(graph.edges())
154
+ if len(edges) > max_edges:
155
+ rng = np.random.default_rng(sim.config.seed + 73)
156
+ chosen = rng.choice(len(edges), size=max_edges, replace=False)
157
+ edges = [edges[int(i)] for i in chosen]
158
+
159
+ edge_x: list[float | None] = []
160
+ edge_y: list[float | None] = []
161
+ for u, v in edges:
162
+ x0, y0 = pos[u]
163
+ x1, y1 = pos[v]
164
+ edge_x.extend((float(x0), float(x1), None))
165
+ edge_y.extend((float(y0), float(y1), None))
166
+
167
+ edge_alpha = 0.16 if sim.config.n_agents <= 100 else 0.09
168
+ edge_trace = go.Scattergl(
169
+ x=edge_x, y=edge_y, mode="lines",
170
+ line=dict(width=0.6, color=f"rgba(27,26,23,{edge_alpha})"),
171
+ hoverinfo="skip", name="ties",
172
+ )
173
+
174
+ nodes = list(graph.nodes())
175
+ opinions = sim.opinions[nodes]
176
+ base_size = 9.0 if sim.config.n_agents <= 100 else 5.6 if sim.config.n_agents <= 500 else 4.6
177
+ stub = np.array([bool(sim.stubborn_mask[i]) for i in nodes])
178
+ exposed = np.array([bool(sim.misinformation_mask[i]) for i in nodes])
179
+ marker_sizes = np.where(stub, base_size + 4.5, base_size)
180
+ marker_symbols = np.where(stub, "diamond", "circle")
181
+ line_colors = np.where(exposed, OCHRE, np.where(stub, INK, "rgba(251,249,244,0.95)"))
182
+ line_widths = np.where(exposed, 1.8, np.where(stub, 1.1, 0.7))
183
+
184
+ hover = []
185
+ for i in nodes:
186
+ tags = []
187
+ if sim.stubborn_mask[i]:
188
+ tags.append("stubborn")
189
+ if sim.misinformation_mask[i]:
190
+ tags.append("exposed")
191
+ role = " · ".join(tags) if tags else "ordinary"
192
+ hover.append(f"<b>agent {i}</b><br>opinion {sim.opinions[i]:+.3f}<br>degree {graph.degree(i)}<br>role {role}")
193
+
194
+ node_trace = go.Scattergl(
195
+ x=[float(pos[i][0]) for i in nodes],
196
+ y=[float(pos[i][1]) for i in nodes],
197
+ mode="markers", hovertext=hover, hoverinfo="text", name="agents",
198
+ marker=dict(
199
+ size=marker_sizes, symbol=marker_symbols, color=opinions,
200
+ colorscale=OPINION_SCALE, cmin=-1, cmax=1, showscale=True,
201
+ colorbar=dict(
202
+ title=dict(text="OPINION", font=dict(family=MONO, color=MUTED, size=10), side="top"),
203
+ thickness=8, len=0.46, y=0.5, x=1.0, xpad=6,
204
+ tickvals=[-1, -0.5, 0, 0.5, 1], ticktext=["−1", "−.5", "0", "+.5", "+1"],
205
+ tickfont=dict(family=MONO, color=MUTED, size=10), outlinewidth=0, ticks="",
206
+ ),
207
+ line=dict(width=line_widths, color=line_colors),
208
+ ),
209
+ )
210
+
211
+ total_edges = graph.number_of_edges()
212
+ shown_edges = len(edges)
213
+ caption = f"{sim.config.n_agents:,} AGENTS · {total_edges:,} TIES" + (f" · {shown_edges:,} DRAWN" if shown_edges < total_edges else "")
214
+ fig = go.Figure(data=[edge_trace, node_trace])
215
+ fig.update_layout(**base_layout("Network state", f"{sim.config.network} topology", height=620))
216
+ fig.update_layout(
217
+ margin=dict(l=14, r=18, t=92, b=18),
218
+ showlegend=False,
219
+ xaxis=dict(visible=False, fixedrange=True),
220
+ yaxis=dict(visible=False, fixedrange=True, scaleanchor="x", scaleratio=1),
221
+ uirevision="network",
222
+ annotations=[
223
+ dict(
224
+ text=caption, x=1.0, y=1.085, xref="paper", yref="paper", showarrow=False,
225
+ xanchor="right", yanchor="top", font=dict(family=MONO, size=10.5, color=INK_2),
226
+ )
227
+ ],
228
+ )
229
+ return fig
230
+
231
+
232
+ def evolution_figure(sim: OpinionSimulation) -> go.Figure:
233
+ history = sim.history_array
234
+ steps = np.arange(history.shape[0])
235
+ mean = history.mean(axis=1)
236
+ p10 = np.quantile(history, 0.10, axis=1)
237
+ p25 = np.quantile(history, 0.25, axis=1)
238
+ p75 = np.quantile(history, 0.75, axis=1)
239
+ p90 = np.quantile(history, 0.90, axis=1)
240
+
241
+ # A deterministic sample of individual trajectories, coloured by current sign.
242
+ rng = np.random.default_rng(sim.config.seed + 5)
243
+ sample = rng.choice(history.shape[1], size=min(36, history.shape[1]), replace=False)
244
+ fig = go.Figure()
245
+ for sign, color, name in ((1, "rgba(210,70,44,0.26)", "agents ending +"), (-1, "rgba(43,89,195,0.26)", "agents ending −")):
246
+ xs: list[float | None] = []
247
+ ys: list[float | None] = []
248
+ for idx in sample:
249
+ if (history[-1, idx] >= 0) == (sign > 0):
250
+ xs.extend(steps.tolist() + [None])
251
+ ys.extend(history[:, idx].tolist() + [None])
252
+ fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", line=dict(width=0.8, color=color), name=name, hoverinfo="skip", showlegend=False))
253
+
254
+ fig.add_trace(go.Scatter(x=steps, y=p90, mode="lines", line=dict(width=0), showlegend=False, hoverinfo="skip"))
255
+ fig.add_trace(go.Scatter(x=steps, y=p10, mode="lines", fill="tonexty", fillcolor="rgba(27,26,23,0.06)", line=dict(width=0), name="10–90%", hovertemplate="10–90% band<extra></extra>"))
256
+ fig.add_trace(go.Scatter(x=steps, y=p75, mode="lines", line=dict(width=0), showlegend=False, hoverinfo="skip"))
257
+ fig.add_trace(go.Scatter(x=steps, y=p25, mode="lines", fill="tonexty", fillcolor="rgba(27,26,23,0.11)", line=dict(width=0), name="25–75%", hovertemplate="25–75% band<extra></extra>"))
258
+ fig.add_trace(go.Scatter(x=steps, y=mean, mode="lines", line=dict(width=2.4, color=INK), name="mean", hovertemplate="step %{x}<br>mean %{y:+.3f}<extra></extra>"))
259
+ fig.add_hline(y=0, line_width=1, line_dash="dot", line_color=RULE)
260
+ fig.update_layout(**base_layout("Opinion evolution", "Mean, quantile bands, and 36 sampled agents", height=540))
261
+ style_axes(fig, "Simulation step", "Opinion", yrange=[-1.05, 1.05])
262
+ return fig
263
+
264
+
265
+ def metric_figure(sim: OpinionSimulation) -> go.Figure:
266
+ steps = [int(m["step"]) for m in sim.metric_history]
267
+ fig = go.Figure()
268
+ for key, label, color, dash in (
269
+ ("polarization", "polarization", VERMILION, "solid"),
270
+ ("consensus", "consensus", GREEN, "solid"),
271
+ ("echo_chamber", "echo-chamber index", PLUM, "solid"),
272
+ ):
273
+ fig.add_trace(go.Scatter(
274
+ x=steps, y=[m[key] for m in sim.metric_history], mode="lines", name=label,
275
+ line=dict(width=2.2, color=color, dash=dash),
276
+ hovertemplate=f"step %{{x}}<br>{label} %{{y:.3f}}<extra></extra>",
277
+ ))
278
+ fig.update_layout(**base_layout("Collective diagnostics", "Macro-level scores through time", height=540))
279
+ style_axes(fig, "Simulation step", "Score", yrange=[0, 1.02])
280
+ return fig
281
+
282
+
283
+ def distribution_figure(sim: OpinionSimulation) -> go.Figure:
284
+ edges = np.linspace(-1, 1, 31)
285
+ centers = (edges[:-1] + edges[1:]) / 2.0
286
+ width = float(edges[1] - edges[0])
287
+ n = max(1, sim.opinions.size)
288
+ current, _ = np.histogram(sim.opinions, bins=edges)
289
+ initial, _ = np.histogram(sim.initial_opinions, bins=edges)
290
+ current = current / n
291
+ initial = initial / n
292
+
293
+ fig = go.Figure()
294
+ fig.add_vrect(x0=-0.2, x1=0.2, fillcolor="rgba(27,26,23,0.045)", line_width=0, layer="below")
295
+ fig.add_trace(go.Bar(
296
+ x=centers, y=current, width=width * 0.84, name="current",
297
+ marker=dict(color=[opinion_color(c) for c in centers], line=dict(width=0)),
298
+ hovertemplate="opinion %{x:+.2f}<br>share %{y:.1%}<extra>current</extra>",
299
+ ))
300
+ fig.add_trace(go.Scatter(
301
+ x=edges, y=np.r_[initial, initial[-1]], mode="lines", line=dict(shape="hv", width=1.4, color=INK),
302
+ name="initial", hoverinfo="skip",
303
+ ))
304
+ fig.update_layout(**base_layout("Opinion distribution", "Current shares (bars) against the initial state (line)", height=540))
305
+ fig.update_layout(bargap=0)
306
+ style_axes(fig, "Opinion", "Population share", xrange=[-1.02, 1.02], ytickformat=".0%")
307
+ return fig
308
+
309
+
310
+ def influence_figure(sim: OpinionSimulation) -> go.Figure:
311
+ nodes = np.array(list(sim.graph.nodes()), dtype=int)
312
+ degrees = np.array([sim.graph.degree(int(i)) for i in nodes], dtype=float)
313
+ opinions = sim.opinions[nodes]
314
+ stub = sim.stubborn_mask[nodes]
315
+ exposure = sim.misinformation_mask[nodes]
316
+ sizes = np.where(stub, 12.0, 7.5)
317
+ symbols = np.where(stub, "diamond", "circle")
318
+ jitter = np.random.default_rng(sim.config.seed + 11).uniform(-0.22, 0.22, size=degrees.size)
319
+
320
+ hover = [
321
+ f"<b>agent {int(i)}</b><br>degree {int(d)}<br>opinion {o:+.3f}<br>stubborn {'yes' if s else 'no'}<br>exposed {'yes' if e else 'no'}"
322
+ for i, d, o, s, e in zip(nodes, degrees, opinions, stub, exposure)
323
+ ]
324
+
325
+ fig = go.Figure(
326
+ go.Scattergl(
327
+ x=degrees + jitter, y=opinions, mode="markers", hovertext=hover, hoverinfo="text", name="agents",
328
+ marker=dict(
329
+ size=sizes, symbol=symbols, color=opinions, colorscale=OPINION_SCALE, cmin=-1, cmax=1,
330
+ opacity=np.where(exposure, 1.0, 0.78),
331
+ line=dict(width=np.where(exposure, 1.6, 0.5), color=np.where(exposure, OCHRE, np.where(stub, INK, "rgba(251,249,244,0.9)"))),
332
+ showscale=False,
333
+ ),
334
+ )
335
+ )
336
+ fig.add_hline(y=0, line_width=1, line_dash="dot", line_color=RULE)
337
+ fig.update_layout(**base_layout("Influence map", "Connectivity against current opinion · ochre ring = exposed", height=540))
338
+ style_axes(fig, "Node degree", "Opinion", yrange=[-1.05, 1.05])
339
+ return fig
tests/conftest.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ ROOT = Path(__file__).resolve().parents[1]
5
+ SRC = ROOT / "src"
6
+ sys.path.insert(0, str(SRC))
tests/test_large_visualization.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from unittest.mock import patch
2
+
3
+ from socialdynamics.simulation import OpinionSimulation, SimulationConfig
4
+ from socialdynamics.visualization import influence_figure, network_figure
5
+
6
+
7
+ def test_large_network_visualization_avoids_spring_layout():
8
+ for n_agents in (500, 1000):
9
+ sim = OpinionSimulation(SimulationConfig(n_agents=n_agents, steps=1, seed=11))
10
+ with patch("networkx.spring_layout", side_effect=AssertionError("spring_layout called")):
11
+ fig = network_figure(sim)
12
+ assert len(fig.data) == 2
13
+ assert len(sim._layout_cache) == n_agents
14
+
15
+
16
+ def test_influence_map_supports_all_population_sizes():
17
+ for n_agents in (100, 500, 1000):
18
+ sim = OpinionSimulation(SimulationConfig(n_agents=n_agents, steps=2, seed=7))
19
+ sim.run()
20
+ fig = influence_figure(sim)
21
+ assert len(fig.data) == 1
22
+ assert len(fig.data[0].x) == n_agents
tests/test_metrics.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import networkx as nx
2
+ import numpy as np
3
+
4
+ from socialdynamics.metrics import consensus, echo_chamber_index, polarization
5
+
6
+
7
+ def test_consensus_population_scores_as_expected():
8
+ x = np.ones(100) * 0.4
9
+ assert polarization(x) == 0.0
10
+ assert consensus(x) == 1.0
11
+
12
+
13
+ def test_split_extremes_are_highly_polarized():
14
+ x = np.array([-1.0] * 50 + [1.0] * 50)
15
+ assert polarization(x) == 1.0
16
+ assert consensus(x) == 0.0
17
+
18
+
19
+ def test_echo_metric_detects_assortative_edges():
20
+ g = nx.Graph()
21
+ g.add_edges_from([(0, 1), (2, 3), (0, 2)])
22
+ x = np.array([-1.0, -0.9, 0.9, 1.0])
23
+ assert echo_chamber_index(g, x, 0.0) == 0.0
24
+ assert echo_chamber_index(g, x, 0.9) > 0.2
tests/test_networks.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import networkx as nx
2
+
3
+ from socialdynamics.networks import build_network
4
+
5
+
6
+ def test_all_networks_have_expected_nodes_and_edges():
7
+ for kind in ("Random", "Small World", "Scale Free"):
8
+ g = build_network(kind, 100, seed=7)
9
+ assert isinstance(g, nx.Graph)
10
+ assert g.number_of_nodes() == 100
11
+ assert g.number_of_edges() > 0
12
+ assert not list(nx.isolates(g))
13
+
14
+
15
+ def test_network_generation_is_deterministic():
16
+ g1 = build_network("Small World", 100, seed=9)
17
+ g2 = build_network("Small World", 100, seed=9)
18
+ assert set(g1.edges()) == set(g2.edges())
tests/test_simulation.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+ from socialdynamics.simulation import OpinionSimulation, SimulationConfig
4
+
5
+
6
+ def test_simulation_is_seed_deterministic():
7
+ cfg = SimulationConfig(n_agents=100, steps=20, seed=123)
8
+ a = OpinionSimulation(cfg)
9
+ b = OpinionSimulation(cfg)
10
+ a.run()
11
+ b.run()
12
+ assert np.array_equal(a.history_array, b.history_array)
13
+
14
+
15
+ def test_opinions_stay_bounded():
16
+ cfg = SimulationConfig(
17
+ n_agents=100,
18
+ steps=50,
19
+ seed=3,
20
+ misinformation_exposure=1.0,
21
+ misinformation_strength=1.0,
22
+ social_influence=1.0,
23
+ noise=0.2,
24
+ )
25
+ sim = OpinionSimulation(cfg)
26
+ sim.run()
27
+ assert np.all(sim.history_array >= -1.0)
28
+ assert np.all(sim.history_array <= 1.0)
29
+
30
+
31
+ def test_stubborn_agents_never_move():
32
+ cfg = SimulationConfig(
33
+ n_agents=100,
34
+ steps=30,
35
+ seed=4,
36
+ stubborn_fraction=0.20,
37
+ stubborn_mode="Split extremes",
38
+ misinformation_exposure=0.50,
39
+ misinformation_strength=0.8,
40
+ )
41
+ sim = OpinionSimulation(cfg)
42
+ initial = sim.opinions.copy()
43
+ mask = sim.stubborn_mask.copy()
44
+ sim.run()
45
+ assert np.array_equal(sim.opinions[mask], initial[mask])
46
+
47
+
48
+ def test_misinformation_can_shift_mean_positive():
49
+ base = dict(
50
+ n_agents=100,
51
+ network="Random",
52
+ initial_opinion="Neutral",
53
+ stubborn_fraction=0.0,
54
+ confirmation_bias=0.2,
55
+ social_influence=0.4,
56
+ noise=0.0,
57
+ steps=30,
58
+ seed=11,
59
+ )
60
+ control = OpinionSimulation(SimulationConfig(**base, misinformation_exposure=0.0, misinformation_strength=0.0))
61
+ treatment = OpinionSimulation(SimulationConfig(**base, misinformation_exposure=1.0, misinformation_strength=0.8, misinformation_direction=1.0))
62
+ control.run()
63
+ treatment.run()
64
+ assert treatment.opinions.mean() > control.opinions.mean() + 0.1