Download app.py from ImAnouar1/SocialDynamics_Lab: direct link, hf CLI and curl.
- Browser
- Download file 73.1 kB
-
https://huggingface.co/spaces/ImAnouar1/SocialDynamics_Lab/resolve/main/app.py
- Command line
-
hf download hf://spaces/ImAnouar1/SocialDynamics_Lab/app.py
-
curl -L -o app.py https://huggingface.co/spaces/ImAnouar1/SocialDynamics_Lab/resolve/main/app.py
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 | |
| 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>|), β <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 & 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 β > 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 & 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 & 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 & Strogatz (1998); Barabási & Albert (1999); Erdős & 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, | |
| ) | |