ImAnouar1's picture
Upload 16 files
1025a87 verified
Raw History Blame Contribute Delete
73.1 kB
from __future__ import annotations
import csv
import html
import os
import sys
import tempfile
import traceback
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
import gradio as gr
import numpy as np
import plotly.graph_objects as go
try:
import spaces
@spaces.GPU(duration=1)
def _zerogpu_startup_probe():
return "ready"
except ImportError:
def _zerogpu_startup_probe():
return "ready"
from socialdynamics.experiments import aggregate, beta_sweep, run_ensemble, seeds_from, summarise_sweep
from socialdynamics.metrics import NullTest
from socialdynamics.presets import PRESETS
from socialdynamics.simulation import OpinionSimulation, SimulationConfig
from socialdynamics.visualization import (
BLUE,
GREEN,
INK,
INK_2,
MUTED,
OCHRE,
PAPER,
PAPER_2,
PLUM,
VERMILION,
base_layout,
distribution_figure,
evolution_figure,
influence_figure,
metric_figure,
network_figure,
style_axes,
sweep_figure,
)
VERSION = "1.2.0"
PRESET_META = {
"Healthy Discussion": ("01", "Baseline", "Low confirmation bias, no external signal. Social influence alone pulls the population together."),
"Filtered, Not Segregated": ("02", "Filter only", "Two camps and a strong confirmation-bias filter on fixed ties. Polarized, but the camps stay interleaved."),
"Echo Chamber": ("03", "Filter + rewiring", "Same as 02, but agents may drop discordant ties for like-minded ones. The network itself sorts."),
"Misinformation Cascade": ("04", "External signal", "A neutral population on a scale-free graph receives a persistent directional signal."),
"Stubborn Minority": ("05", "Committed agents", "One agent in ten holds +1 permanently while everyone else keeps adapting."),
}
# Thresholds for calling opinion clustering "significant" in the interface.
Z_SIGNIFICANT = 3.0
R_MEANINGFUL = 0.20
# ── Configuration helpers ───────────────────────────────────────────────────
def _direction(label: str) -> float:
return 1.0 if str(label).startswith("Positive") else -1.0
def _config(
n_agents,
network,
initial_opinion,
stubborn_fraction,
stubborn_mode,
misinformation_exposure,
misinformation_strength,
misinformation_direction,
confirmation_bias,
social_influence,
noise,
steps,
seed,
rewiring_rate=0.0,
rewiring_tolerance=0.5,
):
return SimulationConfig(
n_agents=int(n_agents),
network=network,
initial_opinion=initial_opinion,
stubborn_fraction=float(stubborn_fraction),
stubborn_mode=stubborn_mode,
misinformation_exposure=float(misinformation_exposure),
misinformation_strength=float(misinformation_strength),
misinformation_direction=_direction(misinformation_direction),
confirmation_bias=float(confirmation_bias),
social_influence=float(social_influence),
noise=float(noise),
steps=int(steps),
seed=int(seed if seed is not None else 42),
rewiring_rate=float(rewiring_rate),
rewiring_tolerance=float(rewiring_tolerance),
)
def _composition(opinions: np.ndarray) -> tuple[float, float, float]:
negative = float(np.mean(opinions < -0.2))
neutral = float(np.mean(np.abs(opinions) <= 0.2))
positive = max(0.0, 1.0 - negative - neutral)
return negative, neutral, positive
def _delta(value: float, threshold: float = 0.005) -> str:
if value > threshold:
return f"▲ {value:+.3f}"
if value < -threshold:
return f"▼ {value:+.3f}"
return "— ≈ 0.000"
def _spark(values: list[float], lo: float, hi: float, color: str) -> str:
"""Auto-scaled sparkline with a soft area fill and an end marker."""
w, h = 140, 34
vals = [float(v) for v in values] if len(values) > 1 else [float(values[0])] * 2
vmin, vmax = min(vals), max(vals)
if vmax - vmin < 1e-6:
pad = max(abs(vmax) * 0.05, 0.01)
vmin, vmax = vmin - pad, vmax + pad
span = vmax - vmin
n = len(vals)
pts = [(i / (n - 1) * w, h - 5 - (v - vmin) / span * (h - 10)) for i, v in enumerate(vals)]
line = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts)
gid = "sg" + color.lstrip("#")
ex, ey = pts[-1]
return (
f'<svg class="spark" viewBox="0 0 {w} {h}" preserveAspectRatio="none" aria-hidden="true">'
f'<defs><linearGradient id="{gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="{color}" stop-opacity=".28"/>'
f'<stop offset="1" stop-color="{color}" stop-opacity="0"/></linearGradient></defs>'
f'<polygon points="0,{h} {line} {w},{h}" fill="url(#{gid})"/>'
f'<polyline points="{line}" fill="none" stroke="{color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>'
f'<circle cx="{ex:.1f}" cy="{ey:.1f}" r="2.2" fill="{color}"/></svg>'
)
# ── HTML fragments ──────────────────────────────────────────────────────────
# key, label, colour, range, format, note (shown under the sparkline)
READOUTS = (
("mean_opinion", "Mean opinion", BLUE, (-1.0, 1.0), "{:+.3f}", "distribution"),
("polarization", "Polarization", VERMILION, (0.0, 1.0), "{:.3f}", "Var(x) · network-blind"),
("bimodality", "Bimodality", OCHRE, (0.0, 1.0), "{:.3f}", "> 0.555 hints at two modes · skew-sensitive"),
("assortativity", "Neighbour similarity r", GREEN, (-1.0, 1.0), "{:+.3f}", "who sits next to whom · 0 = chance"),
("selective_exposure", "Selective exposure", PLUM, (0.0, 1.0), "{:.3f}", "filter set by β · not clustering"),
)
def _cards(sim: OpinionSimulation, step: int, running: bool = True, null: NullTest | None = None) -> str:
first = sim.metric_history[0]
current = sim.metric_history[-1]
exposed = int(np.sum(sim.misinformation_mask))
stubborn = int(np.sum(sim.stubborn_mask))
progress = 100.0 * step / max(1, sim.config.steps)
negative, neutral, positive = _composition(sim.opinions)
state = "Running" if running else "Complete"
segments = "".join(
f'<span style="flex:{v:.4f} 1 0;background:{c}"></span>'
for v, c in ((negative, "#3D6BE0"), (neutral, "#5A5D66"), (positive, "#E5532F")) if v > 0.004
)
state_cls = "live" if running else "done"
cells = []
for key, label, color, (lo, hi), fmt, note in READOUTS:
series = [float(m[key]) for m in sim.metric_history]
if key == "assortativity" and null is not None and null.n_permutations:
note = f"z = {null.z:+.1f} vs shuffled · p = {null.p_value:.3f}"
cells.append(
f'<div class="ro"><div class="ro-label"><i style="background:{color}"></i>{label}</div>'
f'<div class="ro-val">{fmt.format(current[key])}</div>'
f'<div class="ro-delta">{_delta(current[key] - first[key])} <span>since t=0</span></div>'
f'<div class="ro-sub">{html.escape(note)}</div>'
f'{_spark(series, lo, hi, color)}</div>'
)
rewired = f" · {sim.rewired_ties} ties rewired" if sim.config.rewiring_rate > 0 else ""
cells.append(
f'<div class="ro"><div class="ro-label"><i style="background:{OCHRE}"></i>Special agents</div>'
f'<div class="ro-val">{stubborn}<small> stubborn</small></div>'
f'<div class="ro-delta">{exposed} exposed to signal</div>'
f'<div class="ro-note">{stubborn / sim.config.n_agents:.0%} fixed · {exposed / sim.config.n_agents:.0%} exposed{rewired}</div></div>'
)
return f"""
<div class="status">
<div class="status-state"><span class="dot {state_cls}"></span>{state}</div>
<div class="status-meta">{sim.config.n_agents:,} agents · {html.escape(sim.config.network)}{' (adaptive)' if sim.config.rewiring_rate > 0 else ''} · {html.escape(sim.config.initial_opinion)} start · seed {sim.config.seed}</div>
<div class="status-step">t = {step:03d} / {sim.config.steps:03d}</div>
</div>
<div class="progress"><div class="progress-fill {state_cls}" style="width:{progress:.2f}%"></div></div>
<div class="readouts">{''.join(cells)}</div>
<div class="comp">
<div class="comp-head"><span>Population composition</span><span class="comp-note">thresholds ±0.2</span></div>
<div class="comp-track">{segments}</div>
<div class="comp-legend"><span><i style="background:#3D6BE0"></i>Negative <b>{negative:.0%}</b></span><span><i style="background:#5A5D66"></i>Neutral <b>{neutral:.0%}</b></span><span><i style="background:#E5532F"></i>Positive <b>{positive:.0%}</b></span></div>
</div>
"""
def _clustered(r: float, null: NullTest | None) -> bool:
return null is not None and null.z >= Z_SIGNIFICANT and r >= R_MEANINGFUL
def _regime(sim: OpinionSimulation, null: NullTest | None) -> tuple[str, str]:
o = sim.opinions
m = sim.metric_history[-1]
mean = float(m["mean_opinion"])
strong_neg = float(np.mean(o < -0.5))
strong_pos = float(np.mean(o > 0.5))
if m["consensus"] >= 0.8:
return "Consensus", f"The population converges to a shared position near <b>{mean:+.2f}</b>."
if min(strong_neg, strong_pos) >= 0.2 and m["polarization"] >= 0.25:
split = f"<b>{strong_neg:.0%}</b> of agents hold strongly negative and <b>{strong_pos:.0%}</b> strongly positive views"
if _clustered(m["assortativity"], null):
return "Echo chamber", f"{split}, and the camps are segregated on the network: neighbours agree far more than chance."
return "Polarized, mixed", f"{split}, but the camps remain interleaved on the network: neighbours agree no more than chance."
if abs(mean) >= 0.3:
side = "positive" if mean > 0 else "negative"
return "Directional drift", f"The population leans <b>{side}</b> overall (<b>{mean:+.2f}</b>) without reaching full agreement."
return "Plural / dispersed", "Opinions stay spread across the scale, with no dominant pole and no shared position."
def _structure_sentence(sim: OpinionSimulation, null: NullTest | None) -> str:
m0, m = sim.metric_history[0], sim.metric_history[-1]
r, r0 = m["assortativity"], m0["assortativity"]
zs = f", z = {null.z:+.1f} against shuffled opinions" if null is not None and null.n_permutations else ""
if _clustered(r, null):
where = "built by the dynamics" if r - r0 > 0.1 else "already present at t = 0"
sd = float(np.std(sim.opinions))
tail = (" Opinion spread is small, so this reflects local smoothing rather than opposed camps."
if sd < 0.2 else "")
return f"Neighbours hold similar opinions (r = <b>{r:+.2f}</b>{zs}), {where}.{tail}"
return f"Who is connected to whom is unrelated to opinion (r = <b>{r:+.2f}</b>{zs}): no network echo chamber."
def _filter_sentence(sim: OpinionSimulation) -> str:
se = sim.metric_history[-1]["selective_exposure"]
if sim.config.confirmation_bias <= 0:
return "Confirmation bias is off, so agents weight all neighbours equally."
return (f"Separately, the confirmation-bias filter removes <b>{se:.0%}</b> of neighbour disagreement from what agents hear; "
"this follows from the β setting and the state, not from network position.")
def _summary(sim: OpinionSimulation, null: NullTest | None) -> str:
first = sim.metric_history[0]
last = sim.metric_history[-1]
dmean = last["mean_opinion"] - first["mean_opinion"]
dpol = last["polarization"] - first["polarization"]
dr = last["assortativity"] - first["assortativity"]
dse = last["selective_exposure"] - first["selective_exposure"]
regime, regime_sentence = _regime(sim, null)
if abs(dmean) <= 0.03:
mean_sentence = "Net directional movement is negligible."
else:
mean_sentence = f"The mean shifts <b>{'positive' if dmean > 0 else 'negative'}</b> by <b>{dmean:+.3f}</b>."
if dpol > 0.03:
pol_sentence = "Dispersion increases."
elif dpol < -0.03:
pol_sentence = "Dispersion decreases."
else:
pol_sentence = "Dispersion is broadly stable."
c = sim.config
record = (
f"β {c.confirmation_bias:.2f} · α {c.social_influence:.2f} · stubborn {c.stubborn_fraction:.0%} ({html.escape(c.stubborn_mode)}) · "
f"signal {c.misinformation_exposure:.0%} × {c.misinformation_strength:.2f} ({'+' if c.misinformation_direction > 0 else '−'}1) · "
f"rewiring {c.rewiring_rate:.2f} @ τ {c.rewiring_tolerance:.2f} · σ {c.noise:.3f} · T {c.steps} · seed {c.seed}"
)
null_line = ""
if null is not None and null.n_permutations:
null_line = (f"Null model: {null.n_permutations} node-label permutations on the final graph · "
f"shuffled r = {null.null_mean:+.3f} ± {null.null_sd:.3f}")
return f"""
<div class="findings">
<div class="f-left">
<div class="kicker">Outcome · t = {len(sim.history) - 1} · single seed</div>
<div class="f-regime">{regime}</div>
<p class="f-lead">{regime_sentence}</p>
</div>
<div class="f-right">
<p class="f-copy">{mean_sentence} {pol_sentence} {_structure_sentence(sim, null)} {_filter_sentence(sim)}</p>
<div class="f-deltas">
<span><i style="background:{BLUE}"></i>Δ mean <b>{dmean:+.3f}</b></span>
<span><i style="background:{VERMILION}"></i>Δ polarization <b>{dpol:+.3f}</b></span>
<span><i style="background:{GREEN}"></i>Δ neighbour similarity r <b>{dr:+.3f}</b></span>
<span><i style="background:{PLUM}"></i>Δ selective exposure <b>{dse:+.3f}</b></span>
</div>
<div class="f-record"><span>Run record</span>{record}</div>
<div class="f-note">{null_line}{' · ' if null_line else ''}One seed is one sample; use the multi-seed comparison below before drawing conclusions. Diagnostics describe this synthetic run only.</div>
</div>
</div>
"""
def _waiting(title: str, sub: str) -> str:
return f'<div class="waiting"><span class="dot live"></span><b>{title}</b><span>{sub}</span></div>'
def _error_panel(exc: Exception) -> str:
return f"""
<div class="error-card"><div class="kicker err">Simulation interrupted</div>
<code>{html.escape(type(exc).__name__)}: {html.escape(str(exc))}</code>
<div class="f-note">The full traceback was written to the Space logs.</div></div>
"""
CSV_COLUMNS = (
"step", "mean_opinion", "polarization", "consensus", "bimodality", "assortativity",
"neighbour_corr", "selective_exposure", "components", "rewired_ties",
)
def _provenance(config: SimulationConfig) -> list[str]:
fields = ", ".join(f"{k}={v}" for k, v in config.__dict__.items())
return [f"# socialdynamics-lab {VERSION}", f"# config: {fields}"]
def _export_csv(sim: OpinionSimulation, null: NullTest | None) -> str:
c = sim.config
prefix = f"socialdynamics_{c.network.replace(' ', '').lower()}_n{c.n_agents}_seed{c.seed}_"
fd, path = tempfile.mkstemp(prefix=prefix, suffix=".csv")
history = sim.history_array
with os.fdopen(fd, "w", newline="", encoding="utf-8") as fh:
for line in _provenance(c):
fh.write(line + "\n")
if null is not None and null.n_permutations:
fh.write(f"# final assortativity null: permutations={null.n_permutations}, null_mean={null.null_mean:.6f}, "
f"null_sd={null.null_sd:.6f}, z={null.z:.4f}, p={null.p_value:.4f}\n")
fh.write("# read with pandas.read_csv(path, comment='#')\n")
writer = csv.writer(fh)
writer.writerow([*CSV_COLUMNS, "share_negative", "share_neutral", "share_positive"])
for m, row in zip(sim.metric_history, history):
neg, neu, pos = _composition(row)
writer.writerow([int(m["step"]), *(f"{m[k]:.6f}" for k in CSV_COLUMNS[1:]), f"{neg:.4f}", f"{neu:.4f}", f"{pos:.4f}"])
return path
# ── Event handlers ──────────────────────────────────────────────────────────
def run_simulation(*values):
try:
cfg = _config(*values)
sim = OpinionSimulation(cfg)
yield (
_cards(sim, 0, running=True),
network_figure(sim),
evolution_figure(sim),
metric_figure(sim),
distribution_figure(sim),
influence_figure(sim),
_waiting("Simulation running", "Charts refresh at a population-aware cadence."),
gr.DownloadButton(value=None, interactive=False),
)
if cfg.n_agents >= 1000:
update_every = 40
elif cfg.n_agents >= 500:
update_every = 25
else:
update_every = 4 if cfg.steps <= 120 else 6
for step in sim.stream(update_every=update_every):
final = step == cfg.steps
null = sim.null_test() if final else None
refresh_network = final if cfg.n_agents >= 500 else (final or step <= update_every or step % (update_every * 3) == 0)
yield (
_cards(sim, step, running=not final, null=null),
network_figure(sim) if refresh_network else gr.skip(),
evolution_figure(sim),
metric_figure(sim, null=null),
distribution_figure(sim),
influence_figure(sim),
_summary(sim, null) if final else _waiting("Dynamics in progress", "Every step is executed; only visual refreshes are throttled."),
gr.DownloadButton(value=_export_csv(sim, null), interactive=True) if final else gr.skip(),
)
except Exception as exc:
traceback.print_exc()
yield (
'<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>',
gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(), _error_panel(exc), gr.skip(),
)
COMPARE_METRICS = (
("polarization", "Polarization", VERMILION, "{:.3f}"),
("bimodality", "Bimodality", OCHRE, "{:.3f}"),
("assortativity", "Neighbour r", GREEN, "{:+.3f}"),
("selective_exposure", "Selective exp.", PLUM, "{:.3f}"),
)
def _compare_figure(rows: list[dict[str, float | str]], n_seeds: int) -> go.Figure:
fig = go.Figure()
for key, label, color, _ in COMPARE_METRICS:
fig.add_trace(go.Bar(
x=[r["Network"] for r in rows], y=[r[f"{key}_mean"] for r in rows], name=label.lower(), marker_color=color,
marker_line_width=0,
error_y=dict(type="data", array=[r[f"{key}_ci95"] for r in rows], visible=n_seeds > 1, color=INK_2, thickness=1.2, width=4),
hovertemplate=f"%{{x}}<br>{label.lower()} %{{y:.3f}}<extra></extra>",
))
sub = f"Mean ± 95% CI over {n_seeds} seeds" if n_seeds > 1 else "Single seed · no uncertainty estimate"
fig.update_layout(**base_layout("Final-state scores", sub, height=440))
fig.update_layout(barmode="group", bargap=0.30, bargroupgap=0.06,
legend=dict(orientation="h", yanchor="top", y=-0.12, x=0, xanchor="left"), margin=dict(b=80))
style_axes(fig, "", "Score", yrange=[-0.3, 1.05])
fig.add_hline(y=0, line_width=1, line_dash="dot", line_color="rgba(255,255,255,0.2)")
return fig
def _compare_table(rows: list[dict[str, float | str]], n_seeds: int) -> str:
body = []
for r in rows:
cells = [f"<td class='net'>{html.escape(str(r['Network']))}</td><td class='num'>{float(r['mean_opinion_mean']):+.3f}</td>"]
for key, _, color, fmt in COMPARE_METRICS:
v = float(r[f"{key}_mean"])
ci = float(r[f"{key}_ci95"])
pm = f"<small> ± {ci:.3f}</small>" if n_seeds > 1 else ""
cells.append(f"<td class='num'><span class='cbar' style='width:{max(2, abs(v) * 36):.0f}px;background:{color}'></span>{fmt.format(v)}{pm}</td>")
sig = r["significant_share"]
cells.append(f"<td class='num'>{float(r['assortativity_z_mean']):+.1f}<small> · {sig:.0%}</small></td>")
body.append("<tr>" + "".join(cells) + "</tr>")
return (
"<table class='ctable'><thead><tr><th>Network</th><th>Mean</th><th>Polarization</th><th>Bimodality</th>"
"<th>Neighbour r</th><th>Selective exp.</th><th>z · runs z≥3</th></tr></thead>"
f"<tbody>{''.join(body)}</tbody></table>"
f"<div class='f-note'>± is the 95% CI of the mean over {n_seeds} seed{'s' if n_seeds > 1 else ''}. "
"z compares neighbour similarity with 200 opinion shuffles on the same graph; the last figure is the share of seeds with z ≥ 3.</div>"
)
def compare_networks(n_seeds, *values):
try:
base = _config(*values)
n_seeds = int(n_seeds)
seeds = seeds_from(base.seed, n_seeds)
rows: list[dict[str, float | str]] = []
keys = ("mean_opinion", *(k for k, *_ in COMPARE_METRICS), "assortativity_z")
for network in ("Random", "Small World", "Scale Free"):
cfg = SimulationConfig(**{**base.__dict__, "network": network})
runs = run_ensemble(cfg, seeds)
agg = aggregate(runs, keys)
agg["significant_share"] = float(np.mean([r["assortativity_z"] >= Z_SIGNIFICANT for r in runs]))
rows.append({"Network": network, **agg})
status = f'<div class="compare-ok">Complete · seeds {seeds[0]}–{seeds[-1]} · non-topology parameters held fixed</div>'
return _compare_table(rows, n_seeds), _compare_figure(rows, n_seeds), status
except Exception as exc:
traceback.print_exc()
return _error_panel(exc), gr.skip(), ""
SWEEP_BETAS = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
SWEEP_KEYS = ("selective_exposure", "assortativity", "assortativity_z", "polarization", "bimodality")
def _sweep_csv(rows: list[dict[str, float]], base: SimulationConfig) -> str:
fd, path = tempfile.mkstemp(prefix="socialdynamics_filter_vs_chamber_", suffix=".csv")
cols = ["rewiring_rate", "confirmation_bias", "seed", "selective_exposure", "assortativity", "assortativity_initial",
"assortativity_z", "assortativity_p", "neighbour_corr", "polarization", "bimodality", "components", "rewired_ties"]
with os.fdopen(fd, "w", newline="", encoding="utf-8") as fh:
for line in _provenance(base):
fh.write(line + "\n")
fh.write("# confirmation_bias and rewiring_rate columns override the config line\n")
writer = csv.writer(fh)
writer.writerow(cols)
for r in rows:
writer.writerow([f"{r[c]:.6f}" if isinstance(r[c], float) else r[c] for c in cols])
return path
def _sweep_verdict(summary: list[dict[str, float]]) -> str:
fixed = [r for r in summary if r["rewiring_rate"] == 0.0]
if len(fixed) < 2:
return ""
se = np.array([r["selective_exposure_mean"] for r in fixed])
rr = np.array([r["assortativity_mean"] for r in fixed])
corr = float(np.corrcoef(se, rr)[0, 1]) if np.std(se) > 1e-9 and np.std(rr) > 1e-9 else float("nan")
corr_s = "undefined" if np.isnan(corr) else f"{corr:+.2f}"
parts = [f"On fixed ties, across β the two quantities correlate at <b>{corr_s}</b>: "
f"selective exposure spans {se.min():.2f}–{se.max():.2f} while neighbour similarity spans {rr.min():+.2f}–{rr.max():+.2f}."]
rew = [r for r in summary if r["rewiring_rate"] > 0.0]
if rew:
best = max(rew, key=lambda r: r["assortativity_mean"])
parts.append(f"With rewiring, neighbour similarity peaks at <b>{best['assortativity_mean']:+.2f}</b> "
f"(β = {best['confirmation_bias']:.1f}), where selective exposure is {best['selective_exposure_mean']:.2f}.")
return f'<div class="sweep-verdict">{" ".join(parts)}</div>'
def run_sweep(n_seeds, *values):
try:
base = _config(*values)
# The sweep always uses 100 agents to keep the hosted Space responsive.
base = SimulationConfig(**{**base.__dict__, "n_agents": 100})
rate = base.rewiring_rate if base.rewiring_rate > 0 else 0.10
seeds = seeds_from(base.seed, int(n_seeds))
rows = beta_sweep(base, SWEEP_BETAS, (0.0, rate), seeds, n_permutations=100)
summary = summarise_sweep(rows, SWEEP_KEYS)
status = (f'<div class="compare-ok">Complete · {len(rows)} runs · 100 agents · seeds {seeds[0]}–{seeds[-1]} · '
f'rewiring rate {rate:.2f}, τ {base.rewiring_tolerance:.2f}</div>')
return sweep_figure(summary), _sweep_verdict(summary) + status, gr.DownloadButton(value=_sweep_csv(rows, base), interactive=True)
except Exception as exc:
traceback.print_exc()
return gr.skip(), _error_panel(exc), gr.skip()
def _preset_params(p: dict) -> str:
extra = f" · rewire {p['rewiring_rate']:.2f}" if p.get("rewiring_rate", 0) > 0 else ""
return f"{p['network']} · {p['initial_opinion']} · β {p['confirmation_bias']:.2f} · α {p['social_influence']:.2f}{extra}"
def _banner(name: str | None) -> str:
if name is None:
return '<div class="active-scn"><span class="tag">Custom</span><b>Your own configuration</b><span class="hint">Adjust parameters, then run.</span></div>'
num, tag, _ = PRESET_META[name]
return f'<div class="active-scn"><span class="tag on">{num}</span><b>{name}</b><span class="hint">{_preset_params(PRESETS[name])}</span></div>'
def preset_values(name: str):
p = PRESETS[name]
return (
p["network"], p["initial_opinion"], p["stubborn_fraction"], p["stubborn_mode"],
p["misinformation_exposure"], p["misinformation_strength"], p["misinformation_direction"],
p["confirmation_bias"], p["social_influence"], p["noise"], p["steps"],
p.get("rewiring_rate", 0.0), p.get("rewiring_tolerance", 0.5), _banner(name),
)
# ── Theme ───────────────────────────────────────────────────────────────────
BG = PAPER
SURFACE = "#111316"
LINE = "rgba(255,255,255,0.10)"
def _theme() -> gr.themes.Base:
theme = gr.themes.Base(
primary_hue="neutral",
neutral_hue="zinc",
radius_size="md",
font=[gr.themes.GoogleFont("Geist"), "ui-sans-serif", "system-ui", "sans-serif"],
font_mono=[gr.themes.GoogleFont("Geist Mono"), "ui-monospace", "monospace"],
)
settings = {
"body_background_fill": BG, "body_text_color": INK, "body_text_color_subdued": MUTED,
"background_fill_primary": BG, "background_fill_secondary": SURFACE,
"border_color_primary": LINE, "border_color_accent": INK, "border_color_accent_subdued": LINE,
"color_accent": INK, "color_accent_soft": "rgba(255,255,255,0.08)",
"link_text_color": INK, "link_text_color_hover": VERMILION,
"block_background_fill": "transparent", "block_border_width": "0px", "block_border_color": "transparent",
"block_shadow": "none", "block_radius": "8px", "container_radius": "8px", "embed_radius": "8px",
"block_label_background_fill": "transparent", "block_label_text_color": MUTED, "block_label_border_width": "0px",
"block_title_text_color": "#D6D3CD", "block_title_text_weight": "400", "block_info_text_color": MUTED,
"panel_background_fill": "transparent", "panel_border_width": "0px", "panel_border_color": "transparent",
"input_background_fill": SURFACE, "input_background_fill_focus": PAPER_2,
"input_border_color": LINE, "input_border_color_focus": "rgba(255,255,255,0.28)", "input_border_width": "1px",
"input_radius": "6px", "input_shadow": "none", "input_shadow_focus": "none",
"slider_color": INK,
"checkbox_label_background_fill": SURFACE, "checkbox_label_background_fill_hover": PAPER_2,
"checkbox_label_background_fill_selected": INK, "checkbox_label_text_color": INK,
"checkbox_label_text_color_selected": BG, "checkbox_label_border_color": LINE,
"checkbox_label_border_color_selected": INK, "checkbox_background_color": SURFACE,
"checkbox_background_color_selected": INK, "checkbox_border_color_selected": INK, "checkbox_label_shadow": "none",
"button_primary_background_fill": INK, "button_primary_background_fill_hover": "#FFFFFF",
"button_primary_text_color": BG, "button_primary_text_color_hover": BG, "button_primary_border_color": INK,
"button_secondary_background_fill": "transparent", "button_secondary_background_fill_hover": "rgba(255,255,255,0.05)",
"button_secondary_text_color": INK, "button_secondary_border_color": "rgba(255,255,255,0.14)",
"button_large_radius": "6px", "button_small_radius": "6px", "button_shadow": "none", "button_shadow_hover": "none",
"shadow_drop": "none", "shadow_drop_lg": "none", "shadow_spread": "0px",
"table_border_color": LINE, "table_even_background_fill": BG, "table_odd_background_fill": SURFACE,
"loader_color": INK,
}
# Apply one variable at a time (and mirror into *_dark) so an unknown name in a
# future Gradio release is skipped instead of breaking start-up. The lab is
# a single dark theme regardless of OS preference.
for key, value in settings.items():
for name in (key, f"{key}_dark"):
try:
theme.set(**{name: value})
except (TypeError, AttributeError, ValueError):
pass
return theme
CSS = r"""
:root{--bg:#0A0B0E;--surface:#121419;--surface-2:#171A20;--text:#ECEAE5;--text-2:#A8A59E;--muted:#7F7C75;--line:rgba(255,255,255,.07);--line-2:rgba(255,255,255,.12);--blue:#6C8EF5;--verm:#F0694A;--amber:#D9A441;--green:#5FB88A;--plum:#B39AE8;--neutral:#5A5D66;--sans:"Geist",ui-sans-serif,system-ui,sans-serif;--mono:"Geist Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
--card-bg:linear-gradient(180deg,rgba(255,255,255,.045),rgba(255,255,255,.018));--card-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 1px 2px rgba(0,0,0,.5),0 24px 48px -28px rgba(0,0,0,.9);--radius:14px}
html,body,gradio-app{background:var(--bg)!important;color-scheme:dark}
body{-webkit-font-smoothing:antialiased;background:radial-gradient(1100px 620px at 12% -8%,rgba(108,142,245,.13),transparent 62%),radial-gradient(900px 520px at 96% -4%,rgba(240,105,74,.09),transparent 60%),radial-gradient(1200px 800px at 50% 120%,rgba(95,184,138,.05),transparent 60%),var(--bg)!important;background-attachment:fixed!important}
gradio-app{background:transparent!important}
.gradio-container{max-width:1560px!important;width:100%!important;margin:0 auto!important;padding:0 32px 32px!important;background:transparent!important;color:var(--text)!important;font-family:var(--sans)!important}
.gradio-container .main,.gradio-container .wrap,.gradio-container .contain{background:transparent!important}
.gradio-container a{color:var(--text);text-decoration:none}.gradio-container a:hover{color:var(--verm)}
footer{display:none!important}
.gradio-container .prose{color:var(--text)!important}
.gradio-container .prose p{margin:0}
.gradio-container .form{background:transparent!important;border:0!important;gap:14px!important;box-shadow:none!important}
.gradio-container .block{background:transparent!important;box-shadow:none!important}
*::-webkit-scrollbar{width:8px;height:8px}*::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:8px}*::-webkit-scrollbar-track{background:transparent}
/* card primitive */
.preset-card,.control-panel,.ro,.comp,.viz-tabs,.findings,.ctable-wrap,.m-col,.compare-plot{background:var(--card-bg)!important;border:1px solid var(--line)!important;border-radius:var(--radius)!important;box-shadow:var(--card-shadow)!important}
/* dropdown lists */
.gradio-container ul.options{background:#16181C!important;border:1px solid var(--line-2)!important;border-radius:10px!important;padding:4px!important;box-shadow:0 18px 40px -12px rgba(0,0,0,.85)!important;z-index:1000!important}
.gradio-container ul.options li{border-radius:6px!important;padding:8px 10px!important;font-size:13px!important;color:var(--text-2)!important;background:transparent!important}
.gradio-container ul.options li:hover,.gradio-container ul.options li.active{background:rgba(255,255,255,.07)!important;color:var(--text)!important}
.gradio-container ul.options li.selected{color:var(--text)!important}
/* top bar + intro */
.topbar{display:flex;align-items:center;gap:24px;height:66px;border-bottom:1px solid var(--line)}
.brand{display:flex;align-items:center;gap:11px;font-size:16.5px;font-weight:600;letter-spacing:-.01em;color:var(--text)}
.brand .mark{width:25px;height:25px;border-radius:50%;background:conic-gradient(from 200deg,#3D6BE0,#5A5D66,#E5532F,#5A5D66,#3D6BE0);box-shadow:0 0 0 3px rgba(255,255,255,.04),0 0 18px rgba(240,105,74,.25)}
.brand .ver{font:400 11px var(--mono);color:var(--muted);padding:3px 7px;border:1px solid var(--line);border-radius:999px}
.topbar nav{display:flex;gap:6px;margin-left:auto;font-size:13px}
.topbar nav a{color:var(--text-2)!important;padding:6px 12px;border-radius:8px}
.topbar nav a:hover{background:rgba(255,255,255,.05);color:var(--text)!important}
.intro{padding:48px 0 34px;max-width:52rem}
.eyebrow{display:inline-flex;align-items:center;gap:10px;font:500 11.5px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--text-2);padding:6px 12px 6px 10px;border:1px solid var(--line);border-radius:999px;background:rgba(255,255,255,.03)}
.eyebrow:before{content:"";width:6px;height:6px;border-radius:50%;background:var(--verm);box-shadow:0 0 10px var(--verm)}
.gradio-container .intro h1{margin:18px 0 0!important;font:500 clamp(34px,4.4vw,56px)/1.04 var(--sans)!important;text-wrap:balance;letter-spacing:-.025em!important;color:var(--text)!important}
.gradio-container .intro h1 em{font-style:normal;background:linear-gradient(90deg,#6C8EF5,#F0694A);-webkit-background-clip:text;background-clip:text;color:transparent}
.gradio-container .intro p{margin-top:16px!important;max-width:40rem;font-size:15px;line-height:1.6;color:var(--text-2);text-wrap:pretty}
/* section heads */
.panel{position:relative;padding:20px!important;gap:16px!important;border:1px solid var(--line)!important;border-radius:18px!important;background:linear-gradient(180deg,rgba(255,255,255,.03),rgba(255,255,255,.008))!important;box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 30px 60px -36px rgba(0,0,0,.9)!important}
.sec-head{display:flex;align-items:center;gap:14px;margin:0}
.sec-ico{width:34px;height:34px;flex:none;border-radius:10px;background:conic-gradient(from 200deg,#6C8EF5,#B39AE8,#F0694A,#D9A441,#6C8EF5);opacity:.9;box-shadow:inset 0 0 0 9px #14161B,0 0 18px rgba(179,154,232,.25)}
.compare-sec .sec-ico{background:conic-gradient(from 90deg,#6C8EF5,#B39AE8,#D9A441,#6C8EF5)}
.sec-title{font:600 17px/1.2 var(--sans);letter-spacing:-.01em;color:var(--text)}
.sec-sub{margin-top:4px;font-size:12.5px;color:var(--muted);text-wrap:pretty}
/* scenario presets */
.pc-1{--acc:108,142,245}.pc-2{--acc:179,154,232}.pc-3{--acc:217,164,65}.pc-4{--acc:95,184,138}
.preset-row{gap:14px!important;background:transparent!important;border:0!important;align-items:stretch!important}
.preset-card{position:relative;overflow:hidden;padding:18px!important;gap:16px!important;display:flex!important;flex-direction:column!important;transition:border-color .2s ease,box-shadow .2s ease;background:radial-gradient(360px 160px at 0% 0%,rgba(var(--acc),.16),transparent 70%),var(--card-bg)!important;border-color:rgba(var(--acc),.18)!important}
.preset-card:before{content:"";position:absolute;left:18px;right:18px;top:0;height:1px;background:linear-gradient(90deg,transparent,rgba(var(--acc),.85),transparent)}
.preset-card:hover{border-color:rgba(var(--acc),.4)!important;box-shadow:var(--card-shadow),0 0 0 1px rgba(var(--acc),.12),0 18px 40px -20px rgba(var(--acc),.35)!important}
.preset-card>.block:first-child,.preset-card>div:first-child{flex:1 1 auto}
.pc-top{display:flex;align-items:center;gap:10px}
.pc-num{font:500 10.5px var(--mono);color:rgb(var(--acc));width:28px;height:24px;display:inline-flex;align-items:center;justify-content:center;border-radius:7px;background:rgba(var(--acc),.14);border:1px solid rgba(var(--acc),.3)}
.pc-title{font:500 15px/1.3 var(--sans);color:var(--text)}
.pc-copy{margin-top:10px;font-size:12.5px;line-height:1.55;color:var(--text-2);text-wrap:pretty}
.pc-params{margin-top:12px;display:inline-block;padding:6px 9px;border-radius:7px;background:rgba(0,0,0,.25);border:1px solid rgba(var(--acc),.14);font:400 11px/1.5 var(--mono);color:var(--text-2)}
button.preset-btn,.preset-btn button{margin-top:auto!important;width:100%!important;max-width:none!important;background:rgba(255,255,255,.05)!important;border:1px solid var(--line-2)!important;color:var(--text)!important;border-radius:8px!important;font:500 12.5px/1 var(--sans)!important;min-height:34px!important;box-shadow:inset 0 1px 0 rgba(255,255,255,.05)!important}
.preset-card button.preset-btn,.preset-card .preset-btn button{background:rgba(var(--acc),.12)!important;border-color:rgba(var(--acc),.32)!important;color:#fff!important}
button.preset-btn:hover,.preset-btn button:hover,.preset-card button.preset-btn:hover{background:rgb(var(--acc))!important;color:#0A0B0E!important;border-color:rgb(var(--acc))!important}
.active-scn{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;padding:14px 16px;border-radius:10px;background:rgba(0,0,0,.22);border:1px solid var(--line);font-size:13px;color:var(--text-2)}
.active-scn .tag{font:500 10.5px var(--mono);color:var(--muted);padding:3px 8px;border:1px solid var(--line);border-radius:999px}
.active-scn .tag.on{color:var(--bg);background:var(--text);border-color:var(--text)}
.active-scn b{font-weight:500;color:var(--text)}
.active-scn .hint{font:400 11.5px var(--mono);color:var(--muted)}
/* layout */
.lab-row{gap:24px!important;margin-top:24px!important;align-items:flex-start!important}
.control-panel{position:sticky!important;top:16px;align-self:flex-start!important;overflow:visible!important;padding:18px 18px 16px!important;gap:6px!important;flex-wrap:nowrap!important}
.ctl-head{padding-bottom:8px}
.ctl-head strong{display:block;font:500 15px/1.2 var(--sans);color:var(--text)}
.ctl-head p{margin-top:4px!important;font-size:12px;color:var(--muted)}
.pg-pop{--acc:108,142,245}.pg-dyn{--acc:179,154,232}.pg-sig{--acc:217,164,65}.pg-run{--acc:95,184,138}
.param-group{position:relative;overflow:hidden;border:1px solid rgba(var(--acc),.18)!important;border-radius:12px!important;background:linear-gradient(180deg,rgba(var(--acc),.07),rgba(var(--acc),.015) 60%)!important;padding:4px 12px 10px!important;margin:0 0 10px!important;box-shadow:inset 0 1px 0 rgba(255,255,255,.04)!important}
.param-group:before{content:"";position:absolute;left:12px;right:12px;top:0;height:1px;background:linear-gradient(90deg,transparent,rgba(var(--acc),.7),transparent)}
.param-group>div,.param-group .form,.param-group .block{background:transparent!important;border:0!important}
.param-group>button,.param-group>.label-wrap,.param-group button.label-wrap{display:flex!important;align-items:center!important;gap:10px!important;width:100%!important;font:500 13px/1.2 var(--sans)!important;letter-spacing:0;color:var(--text)!important;background:transparent!important;border:0!important;padding:10px 0 6px!important}
.param-group>button:before,.param-group>.label-wrap:before,.param-group button.label-wrap:before{content:"";width:8px;height:8px;border-radius:50%;flex:none;background:rgb(var(--acc));box-shadow:0 0 10px rgba(var(--acc),.8)}
.param-group .label-wrap .icon,.param-group>button span.icon{color:rgb(var(--acc))!important;margin-left:auto}
.param-group input[type="range"]{--slider-color:rgb(var(--acc))!important;accent-color:rgb(var(--acc))}
.param-group input[type="range"]::-webkit-slider-thumb{background:rgb(var(--acc))!important;box-shadow:0 0 0 4px rgba(var(--acc),.18),0 2px 6px rgba(0,0,0,.6)!important}
.param-group input[type="range"]::-moz-range-thumb{background:rgb(var(--acc))!important}
.param-group input[type="range"]::-moz-range-progress{background:rgb(var(--acc))!important}
.param-group label:has(>input[type="radio"]:checked),.param-group label.selected:has(>input[type="radio"]){background:rgba(var(--acc),.22)!important;color:#fff!important}
.param-group input[type="number"]:focus,.param-group input[type="text"]:focus{border-color:rgba(var(--acc),.6)!important}
.control-panel [data-testid="block-info"],.control-panel label>span{font-size:12.5px!important;color:#D8D5CF!important}
.control-panel [data-testid="block-info"]+div,.control-panel .info{color:var(--muted)!important}
.control-panel input[type="number"]{font:400 12px var(--mono)!important;background:rgba(255,255,255,.04)!important;border:1px solid var(--line)!important;border-radius:6px!important;color:var(--text)!important}
.control-panel select,.control-panel input[type="text"],.control-panel .secondary-wrap{background:rgba(255,255,255,.04)!important;border-radius:8px!important}
.control-panel button.reset-button{color:var(--muted)!important;background:transparent!important;border:0!important}
/* sliders */
.control-panel input[type="range"]{--slider-color:#ECEAE5;--neutral-200:rgba(255,255,255,.1);--neutral-700:rgba(255,255,255,.1);-webkit-appearance:none;appearance:none;height:4px!important;border-radius:4px!important;accent-color:#ECEAE5;outline:none;cursor:pointer}
.control-panel input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:#ECEAE5;border:0;box-shadow:0 0 0 4px rgba(236,234,229,.12),0 2px 6px rgba(0,0,0,.6);cursor:grab}
.control-panel input[type="range"]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#ECEAE5;border:0;box-shadow:0 0 0 4px rgba(236,234,229,.12),0 2px 6px rgba(0,0,0,.6)}
.control-panel input[type="range"]::-moz-range-track{height:4px;border-radius:4px;background:rgba(255,255,255,.1)}
.control-panel input[type="range"]::-moz-range-progress{height:4px;border-radius:4px;background:#ECEAE5}
.control-panel .min_value,.control-panel .max_value{font:400 10.5px var(--mono)!important;color:var(--muted)!important}
/* radios as segmented control */
.control-panel .wrap:has(>label>input[type="radio"]){display:flex!important;flex-wrap:nowrap!important;gap:3px!important;padding:3px!important;border-radius:9px!important;background:rgba(0,0,0,.25)!important;border:1px solid var(--line)!important}
.control-panel label:has(>input[type="radio"]){flex:1 1 0;justify-content:center!important;min-height:30px!important;padding:0 8px!important;border:0!important;border-radius:6px!important;background:transparent!important;box-shadow:none!important;color:var(--text-2)!important;font-size:12.5px!important;cursor:pointer}
.control-panel label:has(>input[type="radio"]:checked),.control-panel label.selected:has(>input[type="radio"]){background:rgba(255,255,255,.1)!important;color:var(--text)!important;box-shadow:inset 0 1px 0 rgba(255,255,255,.08),0 1px 3px rgba(0,0,0,.4)!important}
.control-panel label>input[type="radio"]{display:none!important}
button.run-button,.run-button button{background:linear-gradient(180deg,#FFFFFF,#DDDAD3)!important;color:#0A0B0E!important;border:0!important;border-radius:9px!important;font:600 13.5px/1 var(--sans)!important;min-height:44px!important;box-shadow:inset 0 1px 0 #fff,0 8px 20px -8px rgba(255,255,255,.25)!important;margin-top:14px!important;flex:none!important}
button.run-button:hover,.run-button button:hover{filter:brightness(1.05)}
.ctl-foot{font:400 11px/1.5 var(--mono);color:var(--muted);padding-top:8px;text-align:center}
/* status + readouts */
.status{display:flex;align-items:center;flex-wrap:wrap;gap:8px 18px;font:400 12px/1.3 var(--mono);color:var(--text-2);padding:2px 2px 12px}
.status-state{display:flex;align-items:center;gap:8px;color:var(--text);padding:4px 10px;border-radius:999px;background:rgba(255,255,255,.05);border:1px solid var(--line)}
.status-meta{color:var(--muted)}
.status-step{margin-left:auto;font-variant-numeric:tabular-nums}
.status.err{color:var(--verm)}
.dot{display:inline-block;width:6px;height:6px;border-radius:50%;flex:none}
.dot.live{background:var(--amber);box-shadow:0 0 8px var(--amber);animation:sdpulse 1.2s ease-in-out infinite}
.dot.done{background:var(--green);box-shadow:0 0 8px rgba(95,184,138,.7)}
.dot.err{background:var(--verm)}
@keyframes sdpulse{50%{opacity:.25}}
.progress{height:2px;border-radius:2px;background:var(--line);margin-bottom:16px;overflow:hidden}
.progress-fill{height:100%;background:linear-gradient(90deg,#6C8EF5,#ECEAE5 50%,#F0694A);transition:width .4s ease}
.readouts{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px}
.ro{padding:16px 16px 12px;min-width:0;display:flex;flex-direction:column}
.ro-label{font-size:12px;color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ro-label i{display:none}
.ro-val{margin:10px 0 6px;font:400 26px/1 var(--mono);letter-spacing:-.03em;color:var(--text);font-variant-numeric:tabular-nums}
.ro-val small{font:400 12px var(--sans);color:var(--muted);letter-spacing:0}
.ro-delta{font:400 11px/1.3 var(--mono);color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ro-delta span{color:var(--muted)}
.ro-note{margin-top:auto;padding-top:12px;font:400 11px/1.3 var(--mono);color:var(--muted)}
.spark{display:block;width:100%;height:34px;margin-top:auto;padding-top:10px;box-sizing:content-box}
.comp{margin-top:12px;padding:16px 18px!important}
.comp-head{display:flex;justify-content:space-between;font-size:12px;color:var(--text-2);margin-bottom:12px}
.comp-note{font:400 11px var(--mono);color:var(--muted)}
.comp-track{display:flex;gap:3px;height:10px;padding:2px;border-radius:999px;background:rgba(0,0,0,.35);box-shadow:inset 0 1px 2px rgba(0,0,0,.6)}
.comp-track span{height:100%;border-radius:999px;min-width:6px;transition:flex-grow .4s ease;box-shadow:inset 0 1px 0 rgba(255,255,255,.25)}
.comp-legend{display:flex;flex-wrap:wrap;gap:6px 22px;margin-top:12px;font-size:12px;color:var(--muted)}
.comp-legend span{display:flex;align-items:center;gap:7px}
.comp-legend i{width:8px;height:8px;border-radius:50%}
.comp-legend b{font:400 12px var(--mono);color:var(--text)}
.legend{display:flex;flex-wrap:wrap;gap:6px 18px;padding:18px 2px 12px;font-size:12px;color:var(--text-2)}
.legend span{display:flex;align-items:center;gap:7px}
.lg-dot{width:8px;height:8px;border-radius:50%}
.lg-dia{width:7px;height:7px;transform:rotate(45deg);background:var(--neutral);border:1px solid var(--text)}
.lg-ring{width:9px;height:9px;border-radius:50%;border:1.5px solid var(--amber);background:var(--neutral);box-sizing:border-box}
/* chart tabs */
.viz-tabs{padding:10px!important;gap:8px!important}
.viz-tabs .tab-wrapper{display:flex!important;width:100%!important;padding:4px!important;border:0!important;border-radius:10px!important;background:rgba(0,0,0,.28)!important;box-shadow:inset 0 1px 2px rgba(0,0,0,.5)!important;height:auto!important;margin:0 0 6px!important}
.viz-tabs .tab-container,.viz-tabs [role="tablist"]{display:flex!important;flex:1 1 auto!important;width:100%!important;gap:3px!important;padding:0!important;border:0!important;height:auto!important;overflow:visible!important;background:transparent!important}
.viz-tabs .tab-container::after,.viz-tabs [role="tablist"]::after{display:none!important}
.viz-tabs button[role="tab"]{flex:1 1 auto!important;justify-content:center!important;font:500 12.5px/1 var(--sans)!important;color:var(--muted)!important;border-radius:7px!important;padding:9px 14px!important;background:transparent!important;border:0!important;height:auto!important;white-space:nowrap}
.viz-tabs button[role="tab"]::after{display:none!important}
.viz-tabs button[role="tab"]:hover{color:var(--text)!important}
.viz-tabs button[role="tab"][aria-selected="true"],.viz-tabs button[role="tab"].selected{color:var(--text)!important;background:rgba(255,255,255,.09)!important;box-shadow:inset 0 1px 0 rgba(255,255,255,.08),0 1px 3px rgba(0,0,0,.5)!important}
.viz-tabs .overflow-menu{display:none!important}
.viz-tabs .js-plotly-plot,.viz-tabs .plot-container,.viz-tabs .svg-container{width:100%!important;border-radius:10px}
.viz-tabs .gradio-plot,.viz-tabs [data-testid="plot"]{width:100%!important}
/* findings */
.waiting{display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px;padding:22px 4px;font:400 12px/1.4 var(--mono);color:var(--muted)}
.waiting b{font:500 15px/1.2 var(--sans);color:var(--text)}
.findings{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr);gap:24px 40px;padding:22px 24px!important;margin-top:16px}
.kicker{font-size:12px;color:var(--muted)}
.kicker.err{margin-bottom:10px;color:var(--verm)}
.f-regime{margin-top:8px;font:500 24px/1.15 var(--sans);letter-spacing:-.02em;color:var(--text)}
.gradio-container .f-lead{margin-top:8px!important;font-size:14px;line-height:1.6;color:var(--text-2);text-wrap:pretty}
.gradio-container .f-copy{font-size:14px;line-height:1.6;color:var(--text-2);text-wrap:pretty}
.f-lead b,.f-copy b{color:var(--text);font-weight:500}
.f-deltas{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:14px}
.f-deltas span{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:rgba(0,0,0,.2);font:400 11.5px/1 var(--mono);color:var(--muted)}
.f-deltas i{display:inline-block;width:6px;height:6px;border-radius:50%}
.f-deltas b{margin-left:auto;color:var(--text);font-weight:400}
.f-record{margin-top:10px;padding:10px 12px;border-radius:8px;background:rgba(0,0,0,.25);border:1px solid var(--line);font:400 11.5px/1.6 var(--mono);color:var(--text-2)}
.f-record span{display:block;font:400 11px var(--sans);color:var(--muted);margin-bottom:2px}
.f-note{margin-top:10px;font-size:11.5px;line-height:1.5;color:var(--muted)}
button.dl-btn,.dl-btn button{align-self:flex-start;margin-top:12px!important;background:rgba(255,255,255,.05)!important;border:1px solid var(--line-2)!important;border-radius:8px!important;color:var(--text)!important;font:500 12.5px/1 var(--sans)!important;box-shadow:inset 0 1px 0 rgba(255,255,255,.05)!important;min-height:36px!important;max-width:200px}
button.dl-btn:hover:not(:disabled),.dl-btn button:hover:not(:disabled){background:rgba(255,255,255,.1)!important}
button.dl-btn:disabled{opacity:.35}
.error-card{border:1px solid rgba(240,105,74,.4);border-radius:12px;background:rgba(240,105,74,.06);padding:16px 18px;margin-top:18px}
.error-card code{font:400 12.5px/1.5 var(--mono);color:var(--text)}
/* compare */
.compare-sec{margin-top:40px!important}
.cmp-head{align-items:center!important;gap:16px!important;flex-wrap:wrap!important}
.cmp-head>div:first-child{flex:1 1 auto!important}
.cmp-head .compare-button,.cmp-head button.compare-button{flex:none!important;width:auto!important;min-width:180px!important}
button.compare-button,.compare-button button{align-self:center;background:linear-gradient(180deg,#FFFFFF,#DDDAD3)!important;color:#0A0B0E!important;border:0!important;border-radius:9px!important;font:600 13px/1 var(--sans)!important;min-height:40px!important;max-width:220px;box-shadow:inset 0 1px 0 #fff,0 8px 20px -8px rgba(255,255,255,.25)!important}
button.compare-button:hover,.compare-button button:hover{filter:brightness(1.05)}
.compare-ok{font:400 11.5px/1.4 var(--mono);color:var(--green)}
.ctable-wrap{padding:6px 20px 14px;background:radial-gradient(500px 220px at 0% 0%,rgba(108,142,245,.10),transparent 70%),var(--card-bg)!important}
.compare-plot{padding:6px!important;min-height:200px;background:radial-gradient(500px 220px at 100% 0%,rgba(179,154,232,.10),transparent 70%),var(--card-bg)!important}
.compare-plot .empty,.compare-plot [data-testid="empty"]{opacity:.35}
.ctable{width:100%;border-collapse:collapse;font-size:13px;color:var(--text-2)}
.ctable th{font-weight:400;color:var(--muted);text-align:right;padding:12px 10px;border-bottom:1px solid var(--line)}
.ctable th:first-child,.ctable td:first-child{text-align:left;padding-left:0}
.ctable td{padding:14px 10px;border-bottom:1px solid rgba(255,255,255,.05)}
.ctable tr:last-child td{border-bottom:0}
.ctable td.net{color:var(--text);font-weight:500}
.ctable td.num{text-align:right;font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap;color:var(--muted)}
.ctable td.top{color:var(--text)}
.cbar{display:inline-block;height:5px;border-radius:3px;vertical-align:middle;margin-right:10px}
.empty-note{display:flex;flex-direction:column;gap:6px;padding:22px 4px;font-size:12.5px;color:var(--muted)}
.empty-note b{font:500 14px var(--sans);color:var(--text)}
.en-nets{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
.en-nets span{display:inline-flex;align-items:center;gap:7px;padding:5px 10px;border-radius:999px;font:500 11px var(--mono);color:rgb(var(--c));background:rgba(var(--c),.1);border:1px solid rgba(var(--c),.25)}
.en-nets span:before{content:"";width:5px;height:5px;border-radius:50%;background:rgb(var(--c))}
/* methods */
.methods{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-top:48px}
.m-col:nth-child(1){--acc:108,142,245}.m-col:nth-child(2){--acc:95,184,138}.m-col:nth-child(3){--acc:179,154,232}.m-col:nth-child(4){--acc:217,164,65}
.m-col{position:relative;overflow:hidden;min-width:0;padding:20px;background:radial-gradient(420px 180px at 0% 0%,rgba(var(--acc),.14),transparent 70%),var(--card-bg)!important;border-color:rgba(var(--acc),.16)!important}
.m-col:before{content:"";position:absolute;left:20px;right:20px;top:0;height:1px;background:linear-gradient(90deg,transparent,rgba(var(--acc),.8),transparent)}
.m-num{display:inline-flex;align-items:center;gap:7px;font:500 10.5px var(--mono);color:rgb(var(--acc));padding:4px 9px;border-radius:999px;background:rgba(var(--acc),.12);border:1px solid rgba(var(--acc),.25)}
.m-num:before{content:"";width:5px;height:5px;border-radius:50%;background:rgb(var(--acc));box-shadow:0 0 8px rgb(var(--acc))}
.m-col .m-list b{color:rgb(var(--acc))!important}
.m-col .fx{border-color:rgba(var(--acc),.25)!important;background:rgba(var(--acc),.06)!important}
.m-col .refs li::marker{color:rgb(var(--acc))}
.gradio-container .m-col h3{margin:12px 0 10px!important;font:500 15px/1.3 var(--sans)!important;color:var(--text)!important}
.gradio-container .m-col p{font-size:13px;line-height:1.6;color:var(--text-2);margin:0 0 10px!important;text-wrap:pretty}
.fx{font:400 12.5px var(--mono);color:var(--text);padding:10px 12px;margin:4px 0 10px;border-radius:8px;background:rgba(0,0,0,.28);border:1px solid var(--line);white-space:nowrap;overflow-x:auto}
.fx .n{font-style:normal}
.m-list{display:grid;gap:8px;margin-top:4px}
.m-list div{display:grid;grid-template-columns:88px 1fr;gap:10px;font-size:12.5px;line-height:1.5;color:var(--text-2);padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.05)}
.m-list div:last-child{border-bottom:0}
.m-list b{font-weight:500;color:var(--text)}
.refs{margin:0;padding-left:18px;font-size:12.5px;line-height:1.55;color:var(--text-2)}
.refs li{margin-bottom:6px}
.refs i{color:var(--muted)}
.colophon{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px 24px;margin-top:32px;padding-top:16px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}
.colophon b{color:var(--text-2);font-weight:400}
@media(max-width:1280px){.readouts{grid-template-columns:repeat(3,minmax(0,1fr))}.methods{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:1024px){.control-panel{position:static!important}.status-meta{display:none}}
@media(max-width:720px){.gradio-container{padding:0 14px 16px!important}.readouts{grid-template-columns:repeat(2,minmax(0,1fr))}.findings{grid-template-columns:1fr;gap:16px}.methods{grid-template-columns:1fr}.f-deltas{grid-template-columns:1fr}}
"""
HEAD = """
<meta name="theme-color" content="#0A0B0E">
<script>
(function(){
function kick(){window.dispatchEvent(new Event('resize'));}
document.addEventListener('click',function(e){if(e.target.closest&&e.target.closest('[role="tab"]')){setTimeout(kick,60);setTimeout(kick,300);}},true);
window.addEventListener('load',function(){setTimeout(kick,500);setTimeout(kick,1500);});
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<script>
(function(){
function dark(){document.documentElement.classList.add('dark');if(document.body){document.body.classList.add('dark');}}
dark();
document.addEventListener('DOMContentLoaded',function(){
dark();
var mo=new MutationObserver(function(){if(!document.body.classList.contains('dark')||!document.documentElement.classList.contains('dark')){dark();}});
mo.observe(document.documentElement,{attributes:true,attributeFilter:['class']});
mo.observe(document.body,{attributes:true,attributeFilter:['class']});
});
})();
</script>
"""
CSS += r"""
.readouts{grid-template-columns:repeat(6,minmax(0,1fr))}
.ro-sub{margin-top:4px;font:400 10.5px/1.35 var(--mono);color:var(--muted);white-space:normal}
.ctable td.num small{color:var(--muted);font-size:11px}
.sweep-sec{margin-top:24px!important}
.sweep-verdict{font-size:13.5px;line-height:1.6;color:var(--text-2);padding:4px 2px 8px;text-wrap:pretty}
.sweep-verdict b{color:var(--text);font-weight:500}
.seed-pick{max-width:260px}
.pg-adapt{--acc:240,105,74}
.pc-5{--acc:240,105,74}
.m-col:nth-child(5){--acc:179,154,232}.m-col:nth-child(6){--acc:240,105,74}
.m-col:nth-child(3){--acc:217,164,65}.m-col:nth-child(4){--acc:95,184,138}
.ctable-wrap{overflow-x:auto}
.gradio-container .ctable th,.gradio-container .ctable td{border-left:0!important;border-right:0!important;border-top:0!important;background:transparent!important}
.gradio-container .ctable{border:0!important;min-width:760px}
.gradio-container .ctable tr{border:0!important}
.gradio-container .ctable tbody tr+tr td{border-top:1px solid rgba(255,255,255,.05)!important}
.gradio-container .ctable thead th{border-bottom:1px solid var(--line)!important}
.methods{grid-template-columns:repeat(3,minmax(0,1fr))}
@media(max-width:1500px){.readouts{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:1100px){.methods{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:720px){.readouts{grid-template-columns:repeat(2,minmax(0,1fr))}.methods{grid-template-columns:1fr}}
"""
MASTHEAD = f"""
<header class="topbar">
<div class="brand"><span class="mark"></span>SocialDynamics Lab<span class="ver">v{VERSION}</span></div>
<nav><a href="#compare">Compare</a><a href="#sweep">Filter vs chamber</a><a href="#model">Model</a></nav>
</header>
<div class="intro">
<div class="eyebrow">A laboratory for collective opinion</div>
<h1>How opinions <em>move</em> through a network.</h1>
<p>Set up a synthetic population, choose who talks to whom, add confirmation bias, committed minorities, a persistent external signal or adaptive ties, then watch local rules produce consensus, drift or division. The lab reports what agents <i>hear</i> (the filter you set) separately from <i>who sits next to whom</i> (the structure the dynamics build).</p>
</div>
"""
def _preset_card_html(name: str) -> str:
num, tag, copy = PRESET_META[name]
return (
f'<div class="pc-top"><span class="pc-num">{num}</span><span class="pc-title">{name}</span></div>'
f'<div class="pc-copy">{copy}</div>'
f'<div class="pc-params">{_preset_params(PRESETS[name])}</div>'
)
METHODS = """
<section class="methods" id="model">
<div class="m-col">
<div class="m-num">Model</div>
<h3>Distance-weighted influence</h3>
<p>Each agent holds a bounded opinion in [−1, +1]. Neighbour influence decays with opinion distance:</p>
<div class="fx">w<sub>ij</sub> <span class="n">=</span> exp(<span class="n">−</span>β |x<sub>i</sub> <span class="n">−</span> x<sub>j</sub>|),&nbsp; β <span class="n">=</span> 6 × slider</div>
<p>Updates are synchronous. A smooth model inspired by DeGroot and bounded-confidence traditions, not a verbatim implementation of either.</p>
</div>
<div class="m-col">
<div class="m-num">Adaptive ties</div>
<h3>Homophilic rewiring</h3>
<p>After each opinion update, every adaptive agent with probability ρ inspects one random neighbour. If they differ by more than τ, the tie is dropped and replaced by a tie to a random non-neighbour within τ (after Holme &amp; Newman, 2006). Edge count is conserved. With ρ = 0 the graph is fixed and results match v1.1 exactly.</p>
</div>
<div class="m-col">
<div class="m-num">Distribution</div>
<h3>Network-blind readouts</h3>
<div class="m-list">
<div><b>Polarization</b><span>Var(x) ∈ [0, 1]. Consensus = 1 − √Var is its monotone transform and is exported but not shown twice.</span></div>
<div><b>Bimodality</b><span>Sarle's coefficient; values above 5/9 ≈ 0.555 suggest two modes (Pfister et al., 2013). Strong skew also inflates it, so read it with the Distribution tab.</span></div>
</div>
</div>
<div class="m-col">
<div class="m-num">Structure</div>
<h3>Is there a network echo chamber?</h3>
<div class="m-list">
<div><b>Neighbour r</b><span>Pearson correlation of opinions across edge endpoints, both orientations: Newman's (2003) assortativity. Takes no model parameter as input.</span></div>
<div><b>Null model</b><span>Opinions shuffled across nodes 200× on the same graph; z = (r − mean) / sd. Readout text calls clustering only when z ≥ 3 and r ≥ 0.2.</span></div>
<div><b>Nbr-mean corr.</b><span>corr(x<sub>i</sub>, mean of neighbours), as in Cinelli et al. (2021).</span></div>
</div>
</div>
<div class="m-col">
<div class="m-num">Filter</div>
<h3>Selective exposure</h3>
<p>1 − D<sub>eff</sub>/D<sub>raw</sub>: the share of edge disagreement removed by the β-weights. It is 0 whenever β = 0 and positive for any spread-out state once β &gt; 0, including i.i.d. opinions on an unstructured graph. It describes the filter, not clustering. v1.1 labelled this quantity "echo-chamber index".</p>
</div>
<div class="m-col">
<div class="m-num">References</div>
<h3>Scientific context</h3>
<ol class="refs">
<li>DeGroot (1974). Reaching a consensus. <i>JASA</i> 69.</li>
<li>Hegselmann &amp; Krause (2002). Opinion dynamics and bounded confidence. <i>JASSS</i> 5(3).</li>
<li>Newman (2003). Mixing patterns in networks. <i>Phys. Rev. E</i> 67.</li>
<li>Holme &amp; Newman (2006). Coevolution of networks and opinions. <i>Phys. Rev. E</i> 74.</li>
<li>Cinelli et al. (2021). The echo chamber effect on social media. <i>PNAS</i> 118(9).</li>
<li>Watts &amp; Strogatz (1998); Barabási &amp; Albert (1999); Erdős &amp; Rényi (1959).</li>
</ol>
</div>
</section>
<div class="colophon">
<span><b>SocialDynamics Lab</b> · Anouar Imel</span>
<span>MIT License · v""" + VERSION + """</span>
</div>
"""
# ── Interface ───────────────────────────────────────────────────────────────
with gr.Blocks(title="SocialDynamics Lab — opinion dynamics simulator") as demo:
gr.HTML(MASTHEAD)
probe_btn = gr.Button("probe", visible=False)
probe_out = gr.Textbox(visible=False)
probe_btn.click(_zerogpu_startup_probe, outputs=probe_out, api_visibility="private", show_progress="hidden")
preset_buttons = {}
with gr.Column(elem_classes=["panel", "scn-sec"]):
gr.HTML('<div class="sec-head"><span class="sec-ico"></span><div><div class="sec-title">Scenarios</div><div class="sec-sub">Each preset isolates one mechanism and runs immediately. Compare 02 with 03: same filter, different structure.</div></div></div>')
with gr.Row(equal_height=True, elem_classes=["preset-row"]):
for _i, _name in enumerate(PRESETS):
with gr.Column(min_width=200, elem_classes=["preset-card", f"pc-{_i + 1}"]):
gr.HTML(_preset_card_html(_name))
preset_buttons[_name] = gr.Button("Run scenario", elem_classes=["preset-btn"], size="sm")
preset_banner = gr.HTML(_banner(None))
with gr.Row(equal_height=False, elem_classes=["lab-row"]):
with gr.Column(scale=3, min_width=300, elem_classes=["control-panel"]):
gr.HTML('<div class="ctl-head"><strong>Parameters</strong><p>Deterministic for a fixed seed.</p></div>')
with gr.Accordion("Population & network", open=True, elem_classes=["param-group", "pg-pop"]):
n_agents = gr.Radio([100, 500, 1000], value=100, label="Population size")
network = gr.Dropdown(["Random", "Small World", "Scale Free"], value="Small World", label="Network structure")
initial_opinion = gr.Dropdown(["Random", "Neutral", "Polarized"], value="Random", label="Initial opinion state")
with gr.Accordion("Dynamics", open=True, elem_classes=["param-group", "pg-dyn"]):
confirmation_bias = gr.Slider(0, 1, value=0.45, step=0.01, label="Confirmation bias (β)", info="Suppresses influence from dissimilar neighbours.")
social_influence = gr.Slider(0, 1, value=0.45, step=0.01, label="Social influence (α)", info="Speed of movement toward the neighbourhood target.")
stubborn_fraction = gr.Slider(0, 0.30, value=0.05, step=0.01, label="Stubborn agents", info="Share of agents that never update.")
stubborn_mode = gr.Dropdown(["Keep initial", "Positive (+1)", "Negative (-1)", "Split extremes"], value="Split extremes", label="Stubborn stance")
with gr.Accordion("Adaptive network", open=False, elem_classes=["param-group", "pg-adapt"]):
rewiring_rate = gr.Slider(0, 0.5, value=0.0, step=0.01, label="Homophilic rewiring (ρ)", info="Per-step chance an agent reconsiders one tie. 0 = fixed graph.")
rewiring_tolerance = gr.Slider(0.1, 1.0, value=0.5, step=0.05, label="Rewiring tolerance (τ)", info="Ties spanning more than τ in opinion may be replaced.")
with gr.Accordion("External signal", open=False, elem_classes=["param-group", "pg-sig"]):
misinformation_exposure = gr.Slider(0, 1, value=0.25, step=0.01, label="Misinformation exposure", info="Share of adaptive agents receiving the signal.")
misinformation_strength = gr.Slider(0, 1, value=0.35, step=0.01, label="Signal strength")
misinformation_direction = gr.Radio(["Positive (+1)", "Negative (-1)"], value="Positive (+1)", label="Signal direction")
with gr.Accordion("Run settings", open=False, elem_classes=["param-group", "pg-run"]):
noise = gr.Slider(0, 0.20, value=0.02, step=0.005, label="Random noise (σ)")
steps = gr.Slider(50, 300, value=120, step=10, label="Simulation horizon (steps)")
seed = gr.Number(value=42, precision=0, label="Random seed")
run_btn = gr.Button("Run simulation", variant="primary", size="lg", elem_classes=["run-button"])
gr.HTML('<div class="ctl-foot">Large runs refresh charts less often</div>')
with gr.Column(scale=10, min_width=560):
cards = gr.HTML()
gr.HTML(
'<div class="legend">'
f'<span><i class="lg-dot" style="background:#3D6BE0"></i>Negative</span>'
'<span><i class="lg-dot" style="background:#5A5D66"></i>Neutral</span>'
f'<span><i class="lg-dot" style="background:#E5532F"></i>Positive</span>'
'<span><i class="lg-dia"></i>Stubborn</span>'
'<span><i class="lg-ring"></i>Exposed to signal</span>'
'</div>'
)
with gr.Tabs(elem_classes=["viz-tabs"]):
with gr.Tab("Network"):
network_plot = gr.Plot(show_label=False)
with gr.Tab("Evolution"):
evolution_plot = gr.Plot(show_label=False)
with gr.Tab("Diagnostics"):
metric_plot = gr.Plot(show_label=False)
with gr.Tab("Distribution"):
distribution_plot = gr.Plot(show_label=False)
with gr.Tab("Influence map"):
influence_plot = gr.Plot(show_label=False)
summary = gr.HTML(_waiting("Ready", "Configure an experiment or load a scenario, then run it."))
download = gr.DownloadButton("Download CSV", value=None, interactive=False, size="sm", elem_classes=["dl-btn"])
with gr.Column(elem_classes=["panel", "compare-sec"], elem_id="compare"):
with gr.Row(equal_height=True, elem_classes=["cmp-head"]):
gr.HTML('<div class="sec-head"><span class="sec-ico"></span><div><div class="sec-title">Topology comparison</div><div class="sec-sub">Rerun the current configuration on all three network families over several seeds.</div></div></div>')
compare_seeds = gr.Radio([1, 5, 10], value=5, label="Seeds", elem_classes=["seed-pick"])
compare_btn = gr.Button("Compare networks", elem_classes=["compare-button"])
compare_status = gr.HTML()
with gr.Column():
with gr.Column(elem_classes=["ctable-wrap"]):
compare_table = gr.HTML('<div class="empty-note"><div class="en-nets"><span style="--c:108,142,245">Random</span><span style="--c:179,154,232">Small world</span><span style="--c:217,164,65">Scale free</span></div><b>No comparison yet</b><span>Run “Compare networks” to score all three topologies side by side.</span></div>')
with gr.Column(elem_classes=["compare-plot"]):
compare_plot = gr.Plot(show_label=False)
with gr.Column(elem_classes=["panel", "compare-sec", "sweep-sec"], elem_id="sweep"):
with gr.Row(equal_height=True, elem_classes=["cmp-head"]):
gr.HTML('<div class="sec-head"><span class="sec-ico"></span><div><div class="sec-title">Filter vs chamber</div><div class="sec-sub">Sweep confirmation bias from 0 to 1 with the current settings (100 agents), with and without rewiring. If the filter and the structure were the same thing, the two curves would move together.</div></div></div>')
sweep_seeds = gr.Radio([3, 5, 10], value=5, label="Seeds per point", elem_classes=["seed-pick"])
sweep_btn = gr.Button("Run sweep", elem_classes=["compare-button"])
sweep_status = gr.HTML()
with gr.Column(elem_classes=["compare-plot"]):
sweep_plot = gr.Plot(show_label=False)
sweep_download = gr.DownloadButton("Download sweep CSV", value=None, interactive=False, size="sm", elem_classes=["dl-btn"])
gr.HTML(METHODS)
inputs = [n_agents, network, initial_opinion, stubborn_fraction, stubborn_mode, misinformation_exposure, misinformation_strength, misinformation_direction, confirmation_bias, social_influence, noise, steps, seed, rewiring_rate, rewiring_tolerance]
outputs = [cards, network_plot, evolution_plot, metric_plot, distribution_plot, influence_plot, summary, download]
run_kwargs = dict(inputs=inputs, outputs=outputs, api_visibility="undocumented", concurrency_limit=2, stream_every=0.75)
run_btn.click(run_simulation, show_progress="minimal", **run_kwargs)
demo.load(run_simulation, show_progress="hidden", **run_kwargs)
compare_btn.click(compare_networks, inputs=[compare_seeds, *inputs], outputs=[compare_table, compare_plot, compare_status], show_progress="minimal", api_visibility="undocumented", concurrency_limit=1)
sweep_btn.click(run_sweep, inputs=[sweep_seeds, *inputs], outputs=[sweep_plot, sweep_status, sweep_download], show_progress="minimal", api_visibility="undocumented", concurrency_limit=1)
preset_outputs = [network, initial_opinion, stubborn_fraction, stubborn_mode, misinformation_exposure, misinformation_strength, misinformation_direction, confirmation_bias, social_influence, noise, steps, rewiring_rate, rewiring_tolerance, preset_banner]
for _name, _button in preset_buttons.items():
_button.click(
lambda name=_name: preset_values(name), outputs=preset_outputs, queue=False, api_visibility="undocumented"
).then(run_simulation, show_progress="minimal", **run_kwargs)
if __name__ == "__main__":
demo.queue(default_concurrency_limit=2, max_size=16, api_open=False).launch(
theme=_theme(),
css=CSS,
head=HEAD,
footer_links=[],
mcp_server=False,
show_error=True,
)