SimulationLab ladder
Level 4 of 10 · Computational social science
VERIFIEDOpinion-cluster counts follow ⌊1/2ε⌋ (Deffuant 2000) at all five ε tested. With the recommender, camps seal into disconnected networks in 100% of runs.

L04 — Echo Chambers

Give 400 simulated people a limited tolerance for disagreement and a friend recommender, and one random social network splits into sealed camps that no longer talk to each other.

hero

animation

The question

Where do echo chambers come from? Two mechanisms are usually blamed:

  1. Bounded confidence. People are only persuaded by views that are already fairly close to their own.
  2. Selective exposure. Platforms (and people) drop ties to those who disagree and suggest like-minded ones instead.

This level builds an agent-based model (ABM) with both mechanisms turned into dials. It then asks: which dial decides how many opinion camps form, and which one decides whether the camps stay in contact? A parallel sweep over both dials gives a phase diagram: consensus / polarisation / fragmentation, with the network-level echo-chamber boundary drawn on top.

Why agent-based? (versus L02's ODEs and L03's DES)

L02 ODE / SSA L03 discrete-event L04 agent-based
State a few population-level concentrations queues, servers and entities flowing through a fixed process every individual's own state (here: an opinion in [0, 1])
Who interacts with whom well-mixed: mass action entities meet resources, not each other local: only along network ties
Heterogeneity averaged away entity attributes, but the process is shared the point of the model: 400 different opinions and neighbourhoods
Structure fixed equations fixed flowchart adaptive: agents rewire the network, and the network steers the agents
What you get out trajectories you could often derive throughput and waiting-time statistics emergent macro patterns (camps, chambers) that no rule mentions

Nothing in the agent rules says "form three groups" or "split the network". Both are emergent: they appear only from many local interactions. This is also why an ABM needs many replications and a parameter sweep. A single run is one draw from a distribution of possible societies.

The model (ODD, compact)

Agent-based models in research papers are documented with the ODD protocol (Overview, Design concepts, Details; Grimm et al. 2006, 2020). ODD is a fixed checklist, so another group can re-implement your model without reading your code. Here is this model in ODD form:

Overview - Purpose: test whether opinion-driven tie rewiring (a "recommender") turns opinion clusters into disconnected echo chambers, and whether it changes how many clusters form. - Entities and state variables: Person agents, each with an opinion $x_i \in [0,1]$ and fixed to a node of an undirected network. Global parameters: confidence bound $\varepsilon$, convergence rate $\mu$, rewiring probability $r$. - Scales: $N = 400$ agents on a $G(n, m)$ random graph with mean degree $\langle k \rangle = 12$ (2400 ties). Time is in steps. One step means every agent acts once, in random order (asynchronous random-sequential updating). A run stops at convergence or after 600 steps. - Process overview: each activated agent $i$ picks a random neighbour $j$ and then:

$$ |x_j - x_i| < \varepsilon:\quad x_i \leftarrow x_i + \mu (x_j - x_i),\;\; x_j \leftarrow x_j - \mu (x_j - x_i) \qquad \text{(Deffuant et al. 2000)} $$

$$ |x_j - x_i| \ge \varepsilon:\quad \text{with prob. } r,\ \text{drop tie } (i,j),\ \text{add tie } (i,k) \text{ to a stranger } k \text{ with } |x_k - x_i| < \varepsilon $$

Design concepts - Emergence: the number of opinion clusters, the network modularity and the number of network components. - Adaptation / interaction: pairwise, along ties only. The recommender samples up to 20 random strangers and returns the first one within $\varepsilon$ that is not already a neighbour. - Stochasticity: initial graph, initial opinions $x_i \sim U(0,1)$, activation order, partner choice, rewiring. The same seeds are used in every sweep cell (common random numbers, from L03). - Observation: major clusters (sorted opinions split at gaps > 0.02, groups with ≥ 2% of agents), Louvain modularity $Q$ of the tie structure alone, opinion assortativity (the Pearson correlation of opinions across ties), components with ≥ 2% of nodes, and Sarle's bimodality coefficient.

Details - Initialisation: nx.gnm_random_graph(400, 2400), uniform opinions, $\mu = 0.3$. - Conservation: the pairwise update conserves $\sum_i x_i$ exactly, and rewiring conserves the tie count. Both facts are used as checks. - Convergence: every 10 steps, test whether all ties that can still talk ($|\Delta x| < \varepsilon$) have $|\Delta x| < 10^{-3}$ and (if $r>0$) whether any cross-camp ties are left to rewire.

M&S concepts you'll learn here

Tech stack

Tool Since Used here for
Python 3.12 L01 everything
NumPy L01 opinion arrays, edge arrays, cluster detection, assortativity
Matplotlib (+ Pillow) L01 heatmaps, spaghetti plots, the animated GIF (FuncAnimation + PillowWriter)
SciPy L02 scipy.stats.skew / kurtosis for the bimodality coefficient
SimPy-era output analysis L03 replications, common random numbers, seeds as experimental factors
★ Mesa 3.5 L04 Agent/Model, AgentSet.shuffle_do (the scheduler), discrete_space.Network + FixedAgent, DataCollector, batch_run
★ NetworkX 3.6 L04 random-graph generation, Louvain communities and modularity, connected components, force-directed layout
★ Parallel sweeps L04 mesa.batch_run(number_processes=4) runs the sweep through multiprocessing (spawn start method, hence the __main__ guard on Windows)

Mesa 3.5 cheat-sheet (what changed from most online tutorials)

The API was checked against the installed source in .venv/Lib/site-packages/mesa.

Old tutorials (Mesa ≤ 2) Mesa 3.5 (this code)
Agent(unique_id, model) Agent(model): ids are assigned automatically
self.schedule = RandomActivation(self) gone. Use self.agents.shuffle_do("step") (every model has an AgentSet)
Model(seed=...) Model(rng=...), since seed is deprecated
NetworkGrid(G) + place_agent mesa.discrete_space.Network(G, random=..., layout=None). Agents are FixedAgents with agent.cell = cell, and neighbours are cell.connections
manual self.steps += 1 model.steps increments automatically
batch_run(..., iterations=5) batch_run(..., rng=[0, 1, 2, 3, 4]). iterations is deprecated. Seeds are replicated across every parameter cell
— Network.add_connection / remove_connection keep the cell graph and grid.G in sync, which makes adaptive networks easy
— Network(G) runs nx.spring_layout on construction by default. Pass layout=None in sweeps so every model doesn't pay for a layout it never draws

Industry & scientific tools

Tool What it is Who uses it How this project maps to it
NetLogo (+ BehaviorSpace) Logo-dialect ABM IDE with a GUI. BehaviorSpace runs parameter sweeps teaching, social science, ecology (Northwestern CCL, most intro ABM courses) Person = turtle, ties = links, shuffle_do = ask turtles, batch_run = BehaviorSpace
Mesa Python ABM framework academia, policy analysis (TU Delft, Mesa core team), anyone living in the SciPy stack this project
Repast HPC / Repast4Py C++/Python ABM on MPI for distributed, very large populations Argonne National Lab (e.g. city-scale epidemic models) shard the 400 agents into millions across MPI ranks, and synchronise ties that cross a rank boundary
MASON fast single-process Java multi-agent library George Mason University, large social and ecological simulations same model, JVM speed, and headless runs separate from the visualisation
GAMA GAML-language platform with strong GIS urban planning, IRD, disaster and mobility studies put agents on a real city map and add spatial as well as social ties
AnyLogic commercial multi-method tool (ABM + DES + system dynamics) supply chain, healthcare and pharma consultancies agents with statecharts on a network population, mixed with L03-style process flows
Agents.jl / FLAME GPU Julia ABM / GPU-native ABM performance-critical research the same rules at 10⁶–10⁸ agents
igraph / graph-tool C/C++ network-analysis libraries network science 10–100× faster Louvain/Leiden than NetworkX for large sweeps

Results

All numbers below are printed by sim.py. Seeds are fixed, so a re-run reproduces them exactly. Two consecutive runs gave identical output.

Verification

1. Deffuant's cluster law (static network, r = 0). 10 seeds per ε, N = 400, ⟨k⟩ = 12:

ε ⌊1/(2ε)⌋ major clusters, mean ± sd range within ±1?
0.40 1 1.00 ± 0.00 1–1 ok
0.30 1 1.10 ± 0.32 1–2 ok
0.20 2 2.00 ± 0.00 2–2 ok
0.15 3 2.60 ± 0.52 2–3 ok
0.10 5 4.20 ± 0.63 3–5 ok

verification

The rule is a rough law, and our counts sit on or just under it at small ε. Two effects push the count down. On a sparse network, agents whose neighbours are all out of range become frozen stragglers, and they are not counted because they fall below the 2% "major" threshold. Neighbouring proto-clusters can also merge before they separate. At ε = 0.30, one seed in ten kept a straggler group of ≥ 2% of agents, which is why the mean is 1.1. The sweep's r = 0 row (blue line) follows the same staircase from ε = 0.05 to 0.5.

2. Exact invariant. The Deffuant update moves both partners by equal and opposite amounts, so the mean opinion is conserved. Measured drift over the 210-step showcase run: $|\langle x\rangle_T - \langle x\rangle_0| = 0.0$ (asserted < 1e-10).

3. Analytic initial condition. For i.i.d. uniform opinions, a tie is "across the divide" ($|\Delta x| \ge \varepsilon$) with probability $1-(2\varepsilon-\varepsilon^2)$ = 72.25% at ε = 0.15. The GIF's first frame measures 73.0% on its 2400 ties.

Phase diagram: ε decides how many camps form, r decides whether they still talk

phase diagram

760 runs (19 ε × 8 r × 5 seeds, common random numbers), mesa.batch_run on 4 processes:

r consensus for all ε ≥ Louvain Q at ε = 0.2 runs where network components = opinion camps
0 0.325 0.26 54% (only the consensus runs)
0.05 0.350 0.48 52%
0.1 0.300 0.48 87%
0.2 0.275 0.49 100%
0.3 0.300 0.50 100%
0.5 0.300 0.50 100%
0.75 0.300 0.55 100%
1 0.300 0.58 100%

What the sweep says:

Trajectories

spaghetti

Same seed, same ε, static vs rewiring. At ε = 0.1 both end with 4 clusters, but the static network keeps a halo of frozen stragglers and one connected graph (Q = 0.26). With r = 0.5 the clusters are cleaner and faster, and they sit in 4 separate network components (Q = 0.74). At ε = 0.2 you get 2 clusters in both rows: Q = 0.26 and one component, vs Q = 0.50 and two components.

Showcase run (hero + GIF)

ε = 0.15, r = 0.3, seed 6 (chosen for three similar-sized camps: 136/132/132 agents). It converges in 210 steps to 3 opinion clusters in 3 network components, with Q = 0.67 and opinion assortativity 1.00. The share of ties across the divide falls from 73% to 4.7% by t = 60 and to 0.0% at the end. The GIF (3.4 MB, 71 frames) shows the force-directed layout morph from a hairball into three islands, while the modularity and assortativity curves rise in the corner panel.

Run it

# from SimulationLab/
uv run python L04_echo_chambers/sim.py

Runtime:

Stage by stage, the script prints the verification table, a per-r sweep summary, the showcase statistics, and all checks passed. The assertions fail the run if the cluster law misses by more than 1 or if the mean opinion drifts.

Outputs in output/: hero.png, echo_chambers.gif, phase_diagram.png, spaghetti.png, verify_clusters.png.

Code tour (sim.py)

Piece What it does
major_clusters, bimodality, opinion_assortativity pure NumPy/SciPy metrics on an opinion vector (and tie list)
Person(FixedAgent) one agent. step() = pick a random neighbour cell, then either Deffuant-average or ask the recommender to rewire
EchoChamber(mesa.Model) builds the G(n,m) graph, wraps it in mesa.discrete_space.Network, creates agents with Person.create_agents, and wires up the DataCollector
EchoChamber.recommend rejection-samples a like-minded stranger (the "recommender")
EchoChamber.modularity Louvain $Q$, cached until the tie structure changes
EchoChamber.converged / step agents.shuffle_do("step"), the convergence test, and data collection at the end (or every collect_every steps)
verify() batch_run at $r = 0$ over 5 ε values × 10 seeds, compared with $\lfloor 1/(2\varepsilon)\rfloor$
sweep() batch_run over ε × r × seeds on 4 processes, collected into arrays [r, ε, seed]
run_tracked() one run with agent data every every steps (agent_reporters={"opinion": "x"}) and edge-list snapshots
layouts, draw_network, animate, plot_* force-directed layout continued frame to frame, glowing network rendering, and the figures
main() verify → sweep → figures → spaghetti → showcase GIF + hero → assertions

Simplifications & ceilings

Go further

  1. Rebuild it in NetLogo with BehaviorSpace and check that you reproduce the same phase diagram. This is the standard cross-validation ("docking") exercise from Axtell et al. (1996).
  2. Friend-of-friend recommender: replace recommend with triadic closure. Does it still seal chambers, or does clustering keep bridges alive?
  3. Contrarians and zealots: add agents with $\mu = 0$ (stubborn) or repulsive influence and map where they break consensus.
  4. Sensitivity analysis: treat $(\varepsilon, r, \mu, \langle k \rangle)$ as uncertain inputs and compute Sobol indices with SALib. That tool arrives in L07.
  5. Scale up: port the agent loop to Numba or Agents.jl and push to $N = 10^5$. Check whether the consensus threshold sharpens (finite-size scaling, as in L01).

References

Every output

Source code

sim.py 443 lines
"""L04 Echo Chambers: Deffuant bounded-confidence opinions on an adaptive network (Mesa 3).

Run from the repo root:  uv run python L04_echo_chambers/sim.py
"""
import os

os.environ.setdefault("OMP_NUM_THREADS", "4")

import time
import warnings
from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import mesa
import networkx as nx
import numpy as np
from matplotlib.collections import LineCollection
from mesa.discrete_space import FixedAgent, Network
from scipy import stats

OUT = Path(__file__).parent / "output"


# ---------------------------------------------------------------- metrics (pure functions)
def major_clusters(x, gap=0.02, min_frac=0.02):
    """Opinion clusters: sorted opinions split wherever the gap > `gap`; count groups >= 2% of agents."""
    xs = np.sort(x)
    sizes = np.diff(np.r_[0, np.flatnonzero(np.diff(xs) > gap) + 1, len(xs)])
    return int((sizes >= min_frac * len(xs)).sum())


def bimodality(x):
    """Sarle's bimodality coefficient (skew^2+1)/(excess kurtosis + small-sample term); >5/9 hints at >1 mode."""
    n = len(x)
    if np.std(x) < 0.01:  # collapsed to one point: unimodal by definition (shape of 1e-4 noise is meaningless)
        return 0.0
    g, k = stats.skew(x, bias=False), stats.kurtosis(x, bias=False)
    return float((g**2 + 1) / (k + 3 * (n - 1) ** 2 / ((n - 2) * (n - 3))))


def opinion_assortativity(G, x):
    """Pearson correlation of opinions across the two ends of every tie (= nx numeric assortativity)."""
    e = np.array(G.edges(), dtype=int)
    a, b = np.r_[x[e[:, 0]], x[e[:, 1]]], np.r_[x[e[:, 1]], x[e[:, 0]]]
    return float(np.corrcoef(a, b)[0, 1]) if a.std() > 0.01 else np.nan  # undefined once opinions collapse


# ---------------------------------------------------------------- the agent-based model
class Person(FixedAgent):
    """One network node. State: opinion x in [0, 1]. Fixed to its cell; its ties are the cell's connections."""

    def __init__(self, model, opinion, cell):
        super().__init__(model)
        self.x = float(opinion)
        self.cell = cell

    def step(self):
        m = self.model
        friends = list(self.cell.connections.values())
        if not friends:  # every tie was rewired away: an isolate listens to no one
            return
        their_cell = self.random.choice(friends)
        other = their_cell.agents[0]
        d = other.x - self.x
        if abs(d) < m.epsilon:  # Deffuant: close enough -> both move toward each other
            self.x += m.mu * d
            other.x -= m.mu * d
        elif self.random.random() < m.rewire:  # too far -> "recommender" swaps the tie for a like-minded one
            new = m.recommend(self)
            if new is not None:
                m.grid.remove_connection(self.cell, their_cell)
                m.grid.add_connection(self.cell, new.cell)
                m.rewires += 1


class EchoChamber(mesa.Model):
    """Deffuant et al. (2000) bounded confidence + opinion-driven rewiring (cf. Kozma & Barrat 2008).

    n agents on a G(n, m) random graph with mean degree k. Each step every agent (random order) talks
    to one random neighbour. |dx| < epsilon: both move mu*dx closer. Otherwise, with probability
    `rewire`, the agent drops that tie and follows someone the recommender finds with |dx| < epsilon.
    Edge count is conserved, so only *who* talks to whom changes.
    """

    def __init__(self, n=400, k=12, epsilon=0.2, rewire=0.0, mu=0.3, t_max=600,
                 collect_every=0, track_agents=False, rng=None):
        super().__init__(rng=rng)
        self.epsilon, self.rewire, self.mu, self.t_max = epsilon, rewire, mu, t_max
        self.collect_every = collect_every
        self.rewires, self._q = 0, (-1, 0.0)  # ties rewired so far; Louvain cache keyed on that count
        G = nx.gnm_random_graph(n, n * k // 2, seed=self.random)
        self.grid = Network(G, random=self.random, layout=None)  # layout=None: skip the spring layout
        self.people = list(Person.create_agents(self, n, self.rng.random(n), list(self.grid.all_cells)))
        self.datacollector = mesa.DataCollector(
            model_reporters={
                "clusters": self.n_clusters,
                "bimodality": lambda m: bimodality(m.opinions()),
                "modularity": self.modularity,
                "assortativity": lambda m: opinion_assortativity(m.grid.G, m.opinions()),
                "components": self.n_components,
                "t_end": "steps",
            },
            agent_reporters={"opinion": "x"} if track_agents else None,
        )
        if collect_every:
            self.datacollector.collect(self)

    def opinions(self):
        return np.array([p.x for p in self.people])

    def recommend(self, person):
        # ponytail: rejection sampling of 20 random strangers; misses when like-minded strangers are rare
        for _ in range(20):
            cand = self.random.choice(self.people)
            if (cand is not person and abs(cand.x - person.x) < self.epsilon
                    and cand.cell.coordinate not in person.cell.connections):
                return cand
        return None

    def n_clusters(self):
        return major_clusters(self.opinions())

    def modularity(self):
        """Louvain modularity Q of the tie structure alone (opinions not used). Recomputed only if ties changed."""
        if self._q[0] != self.rewires:
            G = self.grid.G
            self._q = (self.rewires, nx.community.modularity(G, nx.community.louvain_communities(G, seed=0)))
        return self._q[1]

    def n_components(self):
        return sum(len(c) >= 0.02 * len(self.people) for c in nx.connected_components(self.grid.G))

    def converged(self, tol=1e-3):
        x = self.opinions()
        e = np.array(self.grid.G.edges(), dtype=int)
        d = np.abs(x[e[:, 0]] - x[e[:, 1]])
        talking = d < self.epsilon
        return (d[talking] < tol).all() and (self.rewire == 0 or talking.all())

    def step(self):
        self.agents.shuffle_do("step")
        done = self.steps >= self.t_max or (self.steps % 10 == 0 and self.converged())
        if done or (self.collect_every and self.steps % self.collect_every == 0):
            self.datacollector.collect(self)
        self.running = not done


# ---------------------------------------------------------------- experiments
N, K, T_MAX = 400, 12, 600
EPS = [round(e, 3) for e in np.arange(0.05, 0.501, 0.025)]  # confidence bound epsilon
RW = [0.0, 0.05, 0.1, 0.2, 0.3, 0.5, 0.75, 1.0]              # rewiring probability r, dense near 0
SEEDS = list(range(5))  # same seeds in every cell = common random numbers (L03), sharper contrasts
METRICS = ["clusters", "modularity", "assortativity", "bimodality", "components", "t_end"]


def sweep():
    """Parallel eps x r x seed factorial with mesa.batch_run -> dict metric -> array [r, eps, seed]."""
    rows = mesa.batch_run(
        EchoChamber,
        parameters={"n": N, "k": K, "epsilon": EPS, "rewire": RW, "t_max": T_MAX},
        number_processes=4, rng=SEEDS, max_steps=T_MAX, data_collection_period=-1,
        display_progress=False,
    )
    Z = {m: np.full((len(RW), len(EPS), len(SEEDS)), np.nan) for m in METRICS}
    for row in rows:
        i, j = RW.index(row["rewire"]), EPS.index(row["epsilon"])
        for m in METRICS:
            Z[m][i, j, row["iteration"]] = row[m]
    return Z


def verify(n_seeds=10):
    """No rewiring: number of major opinion clusters ~ floor(1/(2 eps)) (Deffuant et al. 2000)."""
    eps = [0.4, 0.3, 0.2, 0.15, 0.1]
    rows = mesa.batch_run(
        EchoChamber, parameters={"n": N, "k": K, "epsilon": eps, "rewire": 0.0, "t_max": T_MAX},
        number_processes=4, rng=list(range(100, 100 + n_seeds)), max_steps=T_MAX,
        data_collection_period=-1, display_progress=False,
    )
    got = {e: np.array([r["clusters"] for r in rows if r["epsilon"] == e]) for e in eps}
    print("\nVERIFY  r = 0 (static network), %d seeds each, N = %d, <k> = %d" % (n_seeds, N, K))
    print("  eps    floor(1/2eps)   clusters mean +- sd   [min..max]")
    ok = True
    for e in eps:
        rule, c = int(1 / (2 * e) + 1e-9), got[e]
        hit = abs(c.mean() - rule) <= 1
        ok &= hit
        print("  %.2f   %d               %.2f +- %.2f          [%d..%d]  %s"
              % (e, rule, c.mean(), c.std(ddof=1), c.min(), c.max(), "ok" if hit else "MISS"))
    return eps, got, ok


def run_tracked(eps, r, seed, t_max=250, every=1, keep_edges=False):
    """One run with history every `every` steps: times t, opinions X[t, agent] (DataCollector agent reporter),
    model metrics ts, and (optionally) the edge list after every step."""
    m = EchoChamber(n=N, k=K, epsilon=eps, rewire=r, t_max=t_max, collect_every=every, track_agents=True, rng=seed)
    edges = [np.array(m.grid.G.edges())]
    while m.running:
        m.step()
        if keep_edges:
            edges.append(np.array(m.grid.G.edges()))
    X = m.datacollector.get_agent_vars_dataframe()["opinion"].unstack().to_numpy()
    ts = {k: np.array(v, dtype=float) for k, v in m.datacollector.model_vars.items()}
    return ts["t_end"].astype(int), X, ts, edges


# ---------------------------------------------------------------- figures
BG, FG, DIM = "#0b0e14", "#d8dee9", "#8a93a3"
CMAP = plt.get_cmap("Spectral_r")  # opinion 0 = blue ... 1 = red
plt.style.use("dark_background")
plt.rcParams.update({
    "figure.facecolor": BG, "axes.facecolor": BG, "savefig.facecolor": BG, "axes.edgecolor": "#4c5566",
    "axes.labelcolor": FG, "xtick.color": DIM, "ytick.color": DIM, "text.color": FG,
    "font.size": 10, "axes.titlesize": 11, "axes.grid": False,
})


def trajectories(ax, t, X, t_max, lw=0.5, alpha=0.35):
    """Deffuant 'spaghetti': one line per agent, coloured by its starting opinion; drawn flat after convergence."""
    if t[-1] < t_max:
        t, X = np.r_[t, t_max], np.vstack([X, X[-1]])
    segs = np.stack([np.broadcast_to(t, X.T.shape), X.T], axis=-1)
    ax.add_collection(LineCollection(segs, colors=CMAP(X[0]), lw=lw, alpha=alpha))
    ax.set(xlim=(0, t_max), ylim=(-0.02, 1.02))


def draw_network(ax, pos, e, x, eps, node_size=16):
    """Nodes coloured by opinion (with a soft glow); ties coloured if the two ends can still talk, grey if not."""
    col = CMAP((x[e[:, 0]] + x[e[:, 1]]) / 2)
    col[:, 3] = 0.28
    col[np.abs(x[e[:, 0]] - x[e[:, 1]]) >= eps] = (0.62, 0.64, 0.70, 0.10)
    ax.add_collection(LineCollection(pos[e], colors=col, lw=0.45, zorder=1))
    ax.scatter(*pos.T, s=node_size * 5, c=x, cmap=CMAP, vmin=0, vmax=1, alpha=0.12, edgecolors="none", zorder=2)
    ax.scatter(*pos.T, s=node_size, c=x, cmap=CMAP, vmin=0, vmax=1, edgecolors="none", zorder=3)
    ax.set(xlim=(-1.1, 1.1), ylim=(-1.1, 1.1), aspect="equal")
    ax.set_axis_off()


def layouts(edges, frames):
    """Force-directed layout continued frame-to-frame from the previous positions, so the picture morphs smoothly."""
    pos, P = None, {}
    for f in frames:
        G = nx.empty_graph(N)
        G.add_edges_from(edges[f])
        # gravity pulls disconnected chambers back toward the centre, so they stay big on screen
        pos = nx.spring_layout(G, pos=pos, method="energy", gravity=5.0, iterations=100 if pos is None else 10, seed=1)
        P[f] = np.array([pos[i] for i in range(N)])
    return P


def plot_verify(eps_v, got, Z):
    fig, ax = plt.subplots(figsize=(7.5, 4.8))
    e = np.linspace(0.045, 0.5, 800)
    ax.plot(e, np.floor(1 / (2 * e)), color="#ffb454", lw=2, label=r"$\lfloor 1/(2\varepsilon) \rfloor$  (Deffuant et al. 2000)")
    ax.plot(e, 1 / (2 * e), color="#ffb454", lw=1, ls=":", alpha=0.7, label=r"$1/(2\varepsilon)$")
    c0 = Z["clusters"][0]
    ax.plot(EPS, c0.mean(1), "o-", color="#59c2ff", ms=4, lw=1, alpha=0.8,
            label="sweep, r = 0 (mean of %d seeds)" % c0.shape[1])
    for ev in eps_v:
        c = got[ev]
        ax.errorbar(ev, c.mean(), yerr=c.std(ddof=1), fmt="s", color="#aad94c", ms=7, capsize=4, zorder=5)
    ax.plot([], [], "s", color="#aad94c", label="verification, r = 0 (mean +- sd, %d seeds)" % len(got[eps_v[0]]))
    ax.set(xlabel=r"confidence bound $\varepsilon$ (opinion units)", ylabel="major opinion clusters (>= 2% of agents)",
           title="Verification: static network reproduces Deffuant's cluster law", ylim=(0, 11), xlim=(0.04, 0.52))
    ax.legend(frameon=False, fontsize=9)
    fig.tight_layout()
    fig.savefig(OUT / "verify_clusters.png", dpi=150)
    plt.close(fig)


def plot_phase(Z):
    de, ri = EPS[1] - EPS[0], np.arange(len(RW))  # r grid is non-uniform: plot rows by index, label with r
    ext = [EPS[0] - de / 2, EPS[-1] + de / 2, -0.5, len(RW) - 0.5]
    C, Q = Z["clusters"].mean(2), Z["modularity"].mean(2)
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", RuntimeWarning)  # "mean of empty slice"
        A = np.nanmean(Z["assortativity"], axis=2)  # NaN (grey) where every seed reached consensus
    panels = [
        (C, "(a) opinion clusters  ->  regime", "magma", matplotlib.colors.LogNorm(1, 10), "major clusters"),
        (Q, "(b) network modularity Q (Louvain)", "viridis", matplotlib.colors.Normalize(0.2, 0.9), "Q"),
        (A, "(c) opinion assortativity of ties", "cividis", matplotlib.colors.Normalize(0, 1), "Pearson r across ties"),
        (Z["t_end"].mean(2), "(d) steps to converge (cap %d)" % T_MAX, "inferno",
         matplotlib.colors.Normalize(0, T_MAX), "steps"),
    ]
    fig, axes = plt.subplots(2, 2, figsize=(14, 9.5))
    for ax, (D, title, cm, norm, cb) in zip(axes.flat, panels):
        cmo = plt.get_cmap(cm).with_extremes(bad="#2a2f3a")
        im = ax.imshow(D, origin="lower", extent=ext, aspect="auto", cmap=cmo, norm=norm, interpolation="nearest")
        fig.colorbar(im, ax=ax, label=cb, pad=0.02)
        ax.set(title=title, xlabel=r"confidence bound $\varepsilon$", ylabel="rewiring probability r",
               yticks=ri, yticklabels=[f"{r:g}" for r in RW])
        ax.contour(EPS, ri, C, levels=[1.5, 2.5], colors="white", linewidths=1.2)
        ax.contour(EPS, ri, Z["components"].mean(2), levels=[1.5], colors="#5ccfe6", linewidths=1.6, linestyles="--")
    a = axes[0, 0]
    for x_, txt in [(0.41, "CONSENSUS"), (0.215, "POLARISATION"), (0.095, "FRAGMENTATION")]:
        a.text(x_, len(RW) / 2, txt, ha="center", va="center", rotation=90, fontsize=11, weight="bold", color="white", alpha=0.9)
    fig.suptitle("Phase diagram: %d agents, <k> = %d, %d seeds per cell, %d runs via mesa.batch_run (4 processes)"
                 % (N, K, Z["clusters"].shape[2], Z["clusters"].size), fontsize=12)
    fig.text(0.5, 0.01, "white lines: mean clusters = 1.5 and 2.5 (regime borders).   cyan dashed: mean network "
             "components = 1.5 (upper left of it the network has shattered into sealed echo chambers).\n(c) grey = "
             "opinions collapsed to one value, assortativity undefined; yellow specks in the consensus zone are tiny "
             "extremist cliques (< 2% of agents) the recommender sealed off.", ha="center", va="bottom", fontsize=9,
             color=DIM)
    fig.tight_layout(rect=(0, 0.04, 1, 1))
    fig.savefig(OUT / "phase_diagram.png", dpi=130)
    plt.close(fig)


def plot_spaghetti(runs, eps_list, r_list, t_max):
    fig, axes = plt.subplots(len(r_list), len(eps_list), figsize=(15, 7.2), sharex=True, sharey=True)
    for i, r in enumerate(r_list):
        for j, eps in enumerate(eps_list):
            t, X, ts = runs[eps, r]
            ax = axes[i, j]
            trajectories(ax, t, X, t_max)
            c, parts = int(ts["clusters"][-1]), int(ts["components"][-1])
            ax.set_title(r"$\varepsilon$ = %.2f, r = %.1f:  %d cluster%s, Q = %.2f, %d network part%s"
                         % (eps, r, c, "s"[: c > 1], ts["modularity"][-1], parts, "s"[: parts > 1]), fontsize=10)
            if i == len(r_list) - 1:
                ax.set_xlabel("time (steps; 1 step = every agent talks once)")
        axes[i, 0].set_ylabel(("static network" if r == 0 else "recommender rewiring") + "\nopinion x")
    fig.suptitle("Deffuant trajectories: epsilon sets how many camps form; rewiring decides whether they still talk",
                 fontsize=12)
    fig.tight_layout()
    fig.savefig(OUT / "spaghetti.png", dpi=140)
    plt.close(fig)


def animate(t, X, ts, edges, eps, r, path, hold=10):
    """One GIF frame per DataCollector snapshot: t[i] = step, X[i] = opinions, edges[t[i]] = ties at that step."""
    from matplotlib.animation import FuncAnimation, PillowWriter

    T = t[-1]
    P = layouts(edges, t)  # ponytail: O(N^2) force layout per frame dominates; fewer frames or igraph if it drags
    fig = plt.figure(figsize=(12, 6.2), dpi=80)
    gs = fig.add_gridspec(2, 2, width_ratios=[1.05, 1], height_ratios=[1.5, 1], left=0.01, right=0.97,
                          top=0.87, bottom=0.09, wspace=0.12, hspace=0.3)
    axn, axs, axm = fig.add_subplot(gs[:, 0]), fig.add_subplot(gs[0, 1]), fig.add_subplot(gs[1, 1])
    axs.set(xlim=(0, T), ylim=(-0.02, 1.02), ylabel="opinion x")
    axm.set(xlim=(0, T), ylim=(-0.05, 1.05), xlabel="time (steps)")
    lc = LineCollection([], colors=CMAP(X[0]), lw=0.5, alpha=0.35)
    axs.add_collection(lc)
    lq, = axm.plot([], [], color="#aad94c", lw=2, label="modularity Q")
    la, = axm.plot([], [], color="#59c2ff", lw=2, label="opinion assortativity")
    axm.axhline(ts["modularity"][0], color="#aad94c", lw=0.8, ls=":", alpha=0.6)
    axm.legend(frameon=False, loc="center right", fontsize=9)
    head = fig.text(0.02, 0.95, "", fontsize=13, weight="bold")
    sub = fig.text(0.02, 0.91, "", fontsize=10, color=DIM)

    def update(i):
        f = t[i]
        axn.clear()
        draw_network(axn, P[f], edges[f], X[i], eps, node_size=12)
        lc.set_segments(np.stack([np.broadcast_to(t[: i + 1], (N, i + 1)), X[: i + 1].T], axis=-1))
        lq.set_data(t[: i + 1], ts["modularity"][: i + 1])
        la.set_data(t[: i + 1], ts["assortativity"][: i + 1])
        disc = np.mean(np.abs(X[i][edges[f][:, 0]] - X[i][edges[f][:, 1]]) >= eps)
        head.set_text("Echo chambers forming   t = %d" % f)
        sub.set_text(r"Deffuant $\varepsilon$ = %.2f, recommender r = %.1f, %d agents  |  ties across the divide (grey): %4.1f%%"
                     % (eps, r, N, 100 * disc))
        return ()

    anim = FuncAnimation(fig, update, frames=list(range(len(t))) + [len(t) - 1] * hold, blit=False)
    anim.save(path, writer=PillowWriter(fps=10))
    plt.close(fig)
    return P


def plot_hero(t, X, edges, P, eps, r, i_mid=7):
    i_show = int(np.argmax(np.abs(X - X[-1]).max(axis=1) < 0.005))  # crop the flat tail of the spaghetti
    fig = plt.figure(figsize=(18, 9))
    gs = fig.add_gridspec(2, 3, height_ratios=[3, 1], left=0.04, right=0.98, top=0.86, bottom=0.08,
                          hspace=0.18, wspace=0.02)
    for j, (i, label) in enumerate([(0, "one random social network"), (i_mid, "the recommender sorts ties"),
                                    (len(t) - 1, "sealed echo chambers")]):
        ax = fig.add_subplot(gs[0, j])
        draw_network(ax, P[t[i]], edges[t[i]], X[i], eps, node_size=20)
        ax.set_title("t = %d: %s" % (t[i], label), fontsize=13, color=DIM)
    a2 = fig.add_subplot(gs[1, :])
    trajectories(a2, t, X, t[i_show] + 10, lw=0.6, alpha=0.4)
    for i in (0, i_mid):
        a2.axvline(t[i], color=DIM, lw=0.8, ls=":")
    a2.set(xlabel="time (steps)", ylabel="opinion x")
    fig.text(0.5, 0.95, "ECHO CHAMBERS", ha="center", fontsize=24, weight="bold")
    fig.text(0.5, 0.905, r"Deffuant bounded confidence ($\varepsilon$ = %.2f) + a like-minded recommender (r = %.1f) "
             "split one network of %d Mesa agents into camps that no longer talk" % (eps, r, N),
             ha="center", fontsize=12, color=DIM)
    fig.savefig(OUT / "hero.png", dpi=110)
    plt.close(fig)


# ---------------------------------------------------------------- main
def main():
    OUT.mkdir(exist_ok=True)
    t0 = time.perf_counter()

    eps_v, got, ok = verify()
    print("  [%.0f s]" % (time.perf_counter() - t0))

    Z = sweep()
    print("\nSWEEP  %d runs (eps x r x seeds = %d x %d x %d)  [%.0f s]"
          % (Z["clusters"].size, len(EPS), len(RW), len(SEEDS), time.perf_counter() - t0))
    C = Z["clusters"]
    for i, r in enumerate(RW):
        cons = [e for j, e in enumerate(EPS) if (C[i, j:] == 1).all()]
        q = Z["modularity"][i, EPS.index(0.2)].mean()
        split = (Z["components"][i] == C[i]).mean()
        print("  r = %-4g: consensus for all eps >= %.3f | Q(eps=0.2) = %.2f | network parts == opinion camps in %3.0f%% of runs"
              % (r, cons[0], q, 100 * split))
    plot_verify(eps_v, got, Z)
    plot_phase(Z)

    runs, eps_list, r_list = {}, [0.4, 0.2, 0.1], [0.0, 0.5]
    for r in r_list:
        for e in eps_list:
            t, X, ts, _ = run_tracked(e, r, seed=7, every=2)  # every 2nd step: Louvain is the costly reporter
            runs[e, r] = (t, X, ts)
    plot_spaghetti(runs, eps_list, r_list, t_max=250)
    print("\nspaghetti done  [%.0f s]" % (time.perf_counter() - t0))

    eps, r = 0.15, 0.3  # seed 6 = three similar-sized camps; illustrative, the sweep carries the statistics
    t, X, ts, edges = run_tracked(eps, r, seed=6, every=3, keep_edges=True)  # snapshot every 3 steps = GIF frames
    drift = abs(X[-1].mean() - X[0].mean())
    print("SHOWCASE eps=%.2f r=%.1f: %d steps, %d clusters, %d network parts, Q = %.2f, assortativity = %.2f"
          % (eps, r, t[-1], ts["clusters"][-1], ts["components"][-1], ts["modularity"][-1],
             ts["assortativity"][-1]))
    print("  mean opinion drift |<x>_T - <x>_0| = %.1e (pairwise update conserves the mean exactly)" % drift)
    P = animate(t, X, ts, edges, eps, r, OUT / "echo_chambers.gif")
    plot_hero(t, X, edges, P, eps, r)
    print("  gif + hero done  [%.0f s], gif = %.1f MB"
          % (time.perf_counter() - t0, (OUT / "echo_chambers.gif").stat().st_size / 1e6))

    assert drift < 1e-10, "Deffuant update must conserve the mean opinion"
    assert ok, "cluster count strays > 1 from floor(1/(2 eps))"
    print("\nall checks passed, total %.0f s" % (time.perf_counter() - t0))


if __name__ == "__main__":
    main()