Spaces:
Running on Zero
Running on Zero
Upload 17 files
Browse files- .gitignore +7 -0
- CITATION.cff +19 -0
- LICENSE +21 -0
- app.py +831 -0
- pyproject.toml +24 -0
- requirements.txt +4 -0
- src/socialdynamics/__init__.py +6 -0
- src/socialdynamics/metrics.py +63 -0
- src/socialdynamics/networks.py +45 -0
- src/socialdynamics/presets.py +54 -0
- src/socialdynamics/simulation.py +172 -0
- src/socialdynamics/visualization.py +339 -0
- tests/conftest.py +6 -0
- tests/test_large_visualization.py +22 -0
- tests/test_metrics.py +24 -0
- tests/test_networks.py +18 -0
- tests/test_simulation.py +64 -0
.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 < −0.2</span><span><b>{neutral:.0%}</b> neutral band</span><span><b>{positive:.0%}</b> positive > +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 & 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>|), β <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 & Krause (2002). Opinion dynamics and bounded confidence. <i>JASSS</i> 5(3).</li>
|
| 716 |
+
<li>Watts & Strogatz (1998). Small-world networks. <i>Nature</i> 393.</li>
|
| 717 |
+
<li>Barabási & Albert (1999). Scaling in random networks. <i>Science</i> 286.</li>
|
| 718 |
+
<li>Erdős & 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 & 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
|