from __future__ import annotations import math import networkx as nx import numpy as np import plotly.graph_objects as go from plotly.subplots import make_subplots from .metrics import NullTest from .simulation import OpinionSimulation # ── Dark minimal palette ──────────────────────────────────────── PAPER = "#0C0D0F" PAPER_2 = "#16181C" INK = "#E8E6E1" INK_2 = "#A09D96" MUTED = "#7C7972" RULE = "rgba(255,255,255,0.12)" GRID = "rgba(255,255,255,0.05)" BLUE = "#6C8EF5" VERMILION = "#F0694A" OCHRE = "#D9A441" GREEN = "#5FB88A" PLUM = "#B39AE8" # Backwards-compatible aliases used by earlier versions of app.py TEXT = INK CYAN = BLUE ROSE = VERMILION EMERALD = GREEN VIOLET = PLUM AMBER = OCHRE SANS = "Geist, ui-sans-serif, system-ui, sans-serif" MONO = "Geist Mono, ui-monospace, SFMono-Regular, Menlo, monospace" SERIF = SANS OPINION_SCALE = [ [0.0, "#3D6BE0"], [0.25, "#7E9BEA"], [0.5, "#5A5D66"], [0.75, "#EE8A70"], [1.0, "#E5532F"], ] def _hex_to_rgb(value: str) -> tuple[int, int, int]: value = value.lstrip("#") return int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16) def opinion_color(x: float) -> str: """Map an opinion in [-1, 1] to the diverging opinion scale.""" t = (float(np.clip(x, -1.0, 1.0)) + 1.0) / 2.0 for (t0, c0), (t1, c1) in zip(OPINION_SCALE[:-1], OPINION_SCALE[1:]): if t <= t1: f = 0.0 if t1 == t0 else (t - t0) / (t1 - t0) a, b = _hex_to_rgb(c0), _hex_to_rgb(c1) r, g, bl = (round(a[k] + (b[k] - a[k]) * f) for k in range(3)) return f"#{r:02x}{g:02x}{bl:02x}" return OPINION_SCALE[-1][1] def _hub_radial_layout(graph: nx.Graph, seed: int) -> dict[int, np.ndarray]: nodes = list(graph.nodes()) if not nodes: return {} if len(nodes) == 1: return {nodes[0]: np.array([0.0, 0.0], dtype=float)} degrees = dict(graph.degree()) hub = max(nodes, key=lambda n: (degrees[n], -int(n) if isinstance(n, int) else 0)) distances = nx.single_source_shortest_path_length(graph, hub) max_known = max(distances.values(), default=1) rings: dict[int, list[int]] = {} for node in nodes: rings.setdefault(int(distances.get(node, max_known + 1)), []).append(node) rng = np.random.default_rng(seed + 1701) pos: dict[int, np.ndarray] = {} max_ring = max(rings) if rings else 1 for ring, ring_nodes in sorted(rings.items()): ring_nodes = sorted(ring_nodes, key=lambda n: (-degrees[n], n)) count = len(ring_nodes) phase = float(rng.uniform(0.0, 2.0 * math.pi)) if ring == 0: pos[ring_nodes[0]] = np.array([0.0, 0.0], dtype=float) continue radius = 0.18 + 0.82 * (ring / max(1, max_ring)) for idx, node in enumerate(ring_nodes): angle = phase + (2.0 * math.pi * idx / max(1, count)) r = max(0.06, radius + float(rng.uniform(-0.025, 0.025))) pos[node] = np.array([1.08 * r * math.cos(angle), r * math.sin(angle)], dtype=float) return pos def _layout(graph: nx.Graph, seed: int, previous: dict | None = None) -> dict[int, np.ndarray]: if graph.number_of_nodes() <= 250: return nx.spring_layout(graph, pos=previous, seed=seed, iterations=42, threshold=1e-4) return _hub_radial_layout(graph, seed) def _cached_layout(sim: OpinionSimulation) -> dict[int, np.ndarray]: """Layout of the *current* graph; recomputed only after rewiring changed it.""" version = getattr(sim, "graph_version", 0) pos = getattr(sim, "_layout_cache", None) if pos is None or getattr(sim, "_layout_version", -1) != version: pos = _layout(sim.graph, sim.config.seed, previous=pos) sim._layout_cache = pos sim._layout_version = version return pos def base_layout(title: str, subtitle: str | None = None, height: int = 520) -> dict: """Shared Plotly layout: dark ground, quiet titles, mono ticks, hairline rules.""" title_text = title if not subtitle else ( f"{title}
" f"{subtitle}" ) return dict( title=dict(text=title_text, x=0.012, xanchor="left", y=0.965, yanchor="top", font=dict(family=SANS, size=15, color=INK)), template="plotly_dark", paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", font=dict(family=SANS, color=INK_2, size=12), height=height, margin=dict(l=56, r=24, t=76, b=52), hoverlabel=dict(bgcolor="#1A1C20", bordercolor="rgba(255,255,255,0.14)", font=dict(family=MONO, size=11, color=INK)), legend=dict( orientation="h", yanchor="bottom", y=1.015, x=1, xanchor="right", bgcolor="rgba(0,0,0,0)", font=dict(family=SANS, size=11.5, color=INK_2), ), ) # Kept for compatibility with callers that imported the private name. _base_layout = base_layout def style_axes(fig: go.Figure, xtitle: str, ytitle: str, yrange=None, xrange=None, ytickformat=None) -> go.Figure: common = dict( gridcolor=GRID, zeroline=False, showline=True, linecolor=RULE, linewidth=1, ticks="outside", tickcolor=RULE, ticklen=4, color=MUTED, tickfont=dict(family=MONO, size=10, color=MUTED), ) fig.update_xaxes(title=dict(text=xtitle, font=dict(size=11.5, color=INK_2)), range=xrange, **common) fig.update_yaxes(title=dict(text=ytitle, font=dict(size=11.5, color=INK_2)), range=yrange, tickformat=ytickformat, **common) return fig def network_figure(sim: OpinionSimulation, max_edges: int = 2600) -> go.Figure: graph = sim.graph if sim.config.n_agents >= 1000: max_edges = min(max_edges, 900) elif sim.config.n_agents >= 500: max_edges = min(max_edges, 1400) pos = _cached_layout(sim) edges = list(graph.edges()) if len(edges) > max_edges: rng = np.random.default_rng(sim.config.seed + 73) chosen = rng.choice(len(edges), size=max_edges, replace=False) edges = [edges[int(i)] for i in chosen] edge_x: list[float | None] = [] edge_y: list[float | None] = [] for u, v in edges: x0, y0 = pos[u] x1, y1 = pos[v] edge_x.extend((float(x0), float(x1), None)) edge_y.extend((float(y0), float(y1), None)) edge_alpha = 0.10 if sim.config.n_agents <= 100 else 0.06 edge_trace = go.Scattergl( x=edge_x, y=edge_y, mode="lines", line=dict(width=0.6, color=f"rgba(255,255,255,{edge_alpha})"), hoverinfo="skip", name="ties", ) nodes = list(graph.nodes()) opinions = sim.opinions[nodes] base_size = 9.0 if sim.config.n_agents <= 100 else 5.6 if sim.config.n_agents <= 500 else 4.6 stub = np.array([bool(sim.stubborn_mask[i]) for i in nodes]) exposed = np.array([bool(sim.misinformation_mask[i]) for i in nodes]) marker_sizes = np.where(stub, base_size + 4.5, base_size) marker_symbols = np.where(stub, "diamond", "circle") line_colors = np.where(exposed, OCHRE, np.where(stub, INK, "rgba(12,13,15,0.9)")) line_widths = np.where(exposed, 1.8, np.where(stub, 1.1, 0.7)) hover = [] for i in nodes: tags = [] if sim.stubborn_mask[i]: tags.append("stubborn") if sim.misinformation_mask[i]: tags.append("exposed") role = " · ".join(tags) if tags else "ordinary" hover.append(f"agent {i}
opinion {sim.opinions[i]:+.3f}
degree {graph.degree(i)}
role {role}") node_trace = go.Scattergl( x=[float(pos[i][0]) for i in nodes], y=[float(pos[i][1]) for i in nodes], mode="markers", hovertext=hover, hoverinfo="text", name="agents", marker=dict( size=marker_sizes, symbol=marker_symbols, color=opinions, colorscale=OPINION_SCALE, cmin=-1, cmax=1, showscale=True, colorbar=dict( title=dict(text="opinion", font=dict(family=SANS, color=MUTED, size=11), side="top"), thickness=8, len=0.46, y=0.5, x=1.0, xpad=6, tickvals=[-1, -0.5, 0, 0.5, 1], ticktext=["−1", "−.5", "0", "+.5", "+1"], tickfont=dict(family=MONO, color=MUTED, size=10), outlinewidth=0, ticks="", ), line=dict(width=line_widths, color=line_colors), ), ) total_edges = graph.number_of_edges() shown_edges = len(edges) caption = f"{sim.config.n_agents:,} agents · {total_edges:,} ties" + (f" · {shown_edges:,} drawn" if shown_edges < total_edges else "") if getattr(sim, "rewired_ties", 0): caption += f" · {sim.rewired_ties:,} rewired" r = sim.metric_history[-1].get("assortativity", 0.0) caption += f" · r = {r:+.2f}" fig = go.Figure(data=[edge_trace, node_trace]) subtitle = f"{sim.config.network} topology" + (" · adaptive (homophilic rewiring)" if sim.config.rewiring_rate > 0 else " · fixed ties") fig.update_layout(**base_layout("Network state", subtitle, height=580)) fig.update_layout( margin=dict(l=14, r=18, t=76, b=18), showlegend=False, xaxis=dict(visible=False, fixedrange=True), yaxis=dict(visible=False, fixedrange=True, scaleanchor="x", scaleratio=1), uirevision="network", annotations=[ dict( text=caption, x=1.0, y=1.07, xref="paper", yref="paper", showarrow=False, xanchor="right", yanchor="top", font=dict(family=MONO, size=11, color=MUTED), ) ], ) return fig def evolution_figure(sim: OpinionSimulation) -> go.Figure: history = sim.history_array steps = np.arange(history.shape[0]) mean = history.mean(axis=1) p10 = np.quantile(history, 0.10, axis=1) p25 = np.quantile(history, 0.25, axis=1) p75 = np.quantile(history, 0.75, axis=1) p90 = np.quantile(history, 0.90, axis=1) # A deterministic sample of individual trajectories, coloured by current sign. rng = np.random.default_rng(sim.config.seed + 5) sample = rng.choice(history.shape[1], size=min(36, history.shape[1]), replace=False) fig = go.Figure() for sign, color, name in ((1, "rgba(240,105,74,0.30)", "agents ending +"), (-1, "rgba(108,142,245,0.30)", "agents ending −")): xs: list[float | None] = [] ys: list[float | None] = [] for idx in sample: if (history[-1, idx] >= 0) == (sign > 0): xs.extend(steps.tolist() + [None]) ys.extend(history[:, idx].tolist() + [None]) fig.add_trace(go.Scatter(x=xs, y=ys, mode="lines", line=dict(width=0.8, color=color), name=name, hoverinfo="skip", showlegend=False)) fig.add_trace(go.Scatter(x=steps, y=p90, mode="lines", line=dict(width=0), showlegend=False, hoverinfo="skip")) fig.add_trace(go.Scatter(x=steps, y=p10, mode="lines", fill="tonexty", fillcolor="rgba(255,255,255,0.04)", line=dict(width=0), name="10–90%", hovertemplate="10–90% band")) fig.add_trace(go.Scatter(x=steps, y=p75, mode="lines", line=dict(width=0), showlegend=False, hoverinfo="skip")) fig.add_trace(go.Scatter(x=steps, y=p25, mode="lines", fill="tonexty", fillcolor="rgba(255,255,255,0.08)", line=dict(width=0), name="25–75%", hovertemplate="25–75% band")) fig.add_trace(go.Scatter(x=steps, y=mean, mode="lines", line=dict(width=2.4, color=INK), name="mean", hovertemplate="step %{x}
mean %{y:+.3f}")) fig.add_hline(y=0, line_width=1, line_dash="dot", line_color=RULE) fig.update_layout(**base_layout("Opinion evolution", "Mean, quantile bands, and 36 sampled agents", height=540)) style_axes(fig, "Simulation step", "Opinion", yrange=[-1.05, 1.05]) return fig def metric_figure(sim: OpinionSimulation, null: NullTest | None = None) -> go.Figure: """Two panels: network-blind distribution scores, and structure vs filter.""" steps = [int(m["step"]) for m in sim.metric_history] series = lambda key: [m[key] for m in sim.metric_history] # noqa: E731 fig = make_subplots( rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.13, subplot_titles=( "Distribution · network-blind", "Structure (who sits next to whom) vs filter (what agents hear)", ), ) for key, label, color, dash in ( ("polarization", "polarization Var(x)", VERMILION, "solid"), ("bimodality", "bimodality coefficient", OCHRE, "solid"), ): fig.add_trace(go.Scatter( x=steps, y=series(key), mode="lines", name=label, legendgroup="dist", line=dict(width=2.0, color=color, dash=dash), hovertemplate=f"step %{{x}}
{label} %{{y:.3f}}", ), row=1, col=1) fig.add_hline(y=5 / 9, line_width=1, line_dash="dot", line_color=RULE, row=1, col=1, annotation_text="BC = 5/9", annotation_font=dict(family=MONO, size=10, color=MUTED), annotation_position="top left") if null is not None and null.n_permutations: fig.add_hrect(y0=null.null_low, y1=null.null_high, fillcolor="rgba(255,255,255,0.06)", line_width=0, row=2, col=1, layer="below") fig.add_annotation( x=steps[-1], y=null.null_high, xref="x2", yref="y2", xanchor="right", yanchor="bottom", text="shuffled-opinion 95% band", showarrow=False, font=dict(family=MONO, size=10, color=MUTED), ) for key, label, color, dash, width in ( ("assortativity", "edge assortativity r", BLUE, "solid", 2.4), ("neighbour_corr", "neighbour-mean correlation", GREEN, "dash", 1.6), ("selective_exposure", "selective exposure (set by β)", PLUM, "dot", 2.0), ): fig.add_trace(go.Scatter( x=steps, y=series(key), mode="lines", name=label, legendgroup="struct", line=dict(width=width, color=color, dash=dash), hovertemplate=f"step %{{x}}
{label} %{{y:+.3f}}", ), row=2, col=1) fig.add_hline(y=0, line_width=1, line_dash="dot", line_color=RULE, row=2, col=1) fig.update_layout(**base_layout("Collective diagnostics", None, height=640)) fig.update_layout(margin=dict(l=56, r=24, t=96, b=52), legend=dict(y=1.08)) fig.update_annotations(font=dict(family=SANS, size=12, color=INK_2)) common = dict(gridcolor=GRID, zeroline=False, showline=True, linecolor=RULE, ticks="outside", tickcolor=RULE, ticklen=4, tickfont=dict(family=MONO, size=10, color=MUTED)) fig.update_xaxes(**common) fig.update_yaxes(**common) fig.update_xaxes(title=dict(text="Simulation step", font=dict(size=11.5, color=INK_2)), row=2, col=1) fig.update_yaxes(range=[0, 1.02], title=dict(text="Score", font=dict(size=11.5, color=INK_2)), row=1, col=1) fig.update_yaxes(range=[-0.5, 1.02], title=dict(text="Correlation / share", font=dict(size=11.5, color=INK_2)), row=2, col=1) return fig def distribution_figure(sim: OpinionSimulation) -> go.Figure: edges = np.linspace(-1, 1, 31) centers = (edges[:-1] + edges[1:]) / 2.0 width = float(edges[1] - edges[0]) n = max(1, sim.opinions.size) current, _ = np.histogram(sim.opinions, bins=edges) initial, _ = np.histogram(sim.initial_opinions, bins=edges) current = current / n initial = initial / n fig = go.Figure() fig.add_vrect(x0=-0.2, x1=0.2, fillcolor="rgba(255,255,255,0.03)", line_width=0, layer="below") fig.add_trace(go.Bar( x=centers, y=current, width=width * 0.84, name="current", marker=dict(color=[opinion_color(c) for c in centers], line=dict(width=0)), hovertemplate="opinion %{x:+.2f}
share %{y:.1%}current", )) fig.add_trace(go.Scatter( x=edges, y=np.r_[initial, initial[-1]], mode="lines", line=dict(shape="hv", width=1.4, color=INK), name="initial", hoverinfo="skip", )) fig.update_layout(**base_layout("Opinion distribution", "Current shares (bars) against the initial state (line)", height=540)) fig.update_layout(bargap=0) style_axes(fig, "Opinion", "Population share", xrange=[-1.02, 1.02], ytickformat=".0%") return fig def influence_figure(sim: OpinionSimulation) -> go.Figure: nodes = np.array(list(sim.graph.nodes()), dtype=int) degrees = np.array([sim.graph.degree(int(i)) for i in nodes], dtype=float) opinions = sim.opinions[nodes] stub = sim.stubborn_mask[nodes] exposure = sim.misinformation_mask[nodes] sizes = np.where(stub, 12.0, 7.5) symbols = np.where(stub, "diamond", "circle") jitter = np.random.default_rng(sim.config.seed + 11).uniform(-0.22, 0.22, size=degrees.size) hover = [ f"agent {int(i)}
degree {int(d)}
opinion {o:+.3f}
stubborn {'yes' if s else 'no'}
exposed {'yes' if e else 'no'}" for i, d, o, s, e in zip(nodes, degrees, opinions, stub, exposure) ] fig = go.Figure( go.Scattergl( x=degrees + jitter, y=opinions, mode="markers", hovertext=hover, hoverinfo="text", name="agents", marker=dict( size=sizes, symbol=symbols, color=opinions, colorscale=OPINION_SCALE, cmin=-1, cmax=1, opacity=np.where(exposure, 1.0, 0.78), line=dict(width=np.where(exposure, 1.6, 0.5), color=np.where(exposure, OCHRE, np.where(stub, INK, "rgba(12,13,15,0.9)"))), showscale=False, ), ) ) fig.add_hline(y=0, line_width=1, line_dash="dot", line_color=RULE) fig.update_layout(**base_layout("Influence map", "Connectivity against current opinion · amber ring = exposed", height=540)) style_axes(fig, "Node degree", "Opinion", yrange=[-1.05, 1.05]) return fig def sweep_figure(summary: list[dict[str, float]]) -> go.Figure: """Filter vs chamber: selective exposure and assortativity against beta, with 95% CIs.""" fig = make_subplots(rows=1, cols=2, shared_yaxes=True, horizontal_spacing=0.06, subplot_titles=("Fixed ties", "Homophilic rewiring")) rates = sorted({row["rewiring_rate"] for row in summary}) for col, rate in enumerate(rates[:2], start=1): rows = [r for r in summary if r["rewiring_rate"] == rate] x = [r["confirmation_bias"] for r in rows] for key, label, color, dash in ( ("selective_exposure", "selective exposure (filter)", PLUM, "dot"), ("assortativity", "edge assortativity r (structure)", BLUE, "solid"), ): mean = np.array([r[f"{key}_mean"] for r in rows]) ci = np.array([r[f"{key}_ci95"] for r in rows]) rgb = ",".join(str(c) for c in _hex_to_rgb(color)) fig.add_trace(go.Scatter(x=x + x[::-1], y=list(mean + ci) + list((mean - ci)[::-1]), fill="toself", mode="lines", fillcolor=f"rgba({rgb},0.14)", line=dict(width=0), hoverinfo="skip", showlegend=False), row=1, col=col) fig.add_trace(go.Scatter(x=x, y=mean, mode="lines+markers", name=label, legendgroup=key, showlegend=col == 1, line=dict(width=2.2, color=color, dash=dash), marker=dict(size=6, color=color), customdata=ci, hovertemplate=f"β %{{x:.2f}}
{label} %{{y:+.3f}} ± %{{customdata:.3f}}"), row=1, col=col) fig.add_hline(y=0, line_width=1, line_dash="dot", line_color=RULE, row=1, col=col) if len(rates) > 1: fig.layout.annotations[1].text = f"Homophilic rewiring · rate {rates[1]:.2f}" fig.update_layout(**base_layout("Filter vs chamber", "Mean ± 95% CI across seeds; all other parameters held fixed", height=500)) fig.update_layout(legend=dict(orientation="h", yanchor="top", y=-0.17, x=0, xanchor="left"), margin=dict(l=56, r=24, t=96, b=96)) fig.update_annotations(font=dict(family=SANS, size=12, color=INK_2)) common = dict(gridcolor=GRID, zeroline=False, showline=True, linecolor=RULE, ticks="outside", tickcolor=RULE, ticklen=4, tickfont=dict(family=MONO, size=10, color=MUTED)) fig.update_xaxes(title=dict(text="Confirmation bias (slider value)", font=dict(size=11.5, color=INK_2)), range=[-0.03, 1.03], **common) fig.update_yaxes(range=[-0.35, 1.05], **common) fig.update_yaxes(title=dict(text="Final-state score", font=dict(size=11.5, color=INK_2)), row=1, col=1) return fig