disensa 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
disensa/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """disensa: multi-model LLM-agent opinion dynamics on social networks."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ # Opinion scale shared by every component (Wang et al. 2025 use [-2, 2]).
6
+ OPINION_MIN = -2.0
7
+ OPINION_MAX = 2.0
disensa/analysis.py ADDED
@@ -0,0 +1,198 @@
1
+ """Individual-level inspection of a finished run.
2
+
3
+ Everything an agent read, thought and wrote is in the run folder; this module turns it into
4
+ tables you can reason about:
5
+
6
+ load_run(path) everything in one object
7
+ agent_timeline(run, i) day-by-day story of one agent: what it read, its reasoning, post
8
+ opinion_shifts(run) every notable change of opinion, with what the agent had just read
9
+ and whether it moved toward it (assimilation) or away (repulsion)
10
+ model_summary(run) the same numbers aggregated per LLM
11
+ change_points(series) days where an agent's opinion trajectory changes regime
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ import networkx as nx
21
+ import numpy as np
22
+ import pandas as pd
23
+
24
+
25
+ @dataclass
26
+ class RunData:
27
+ path: Path
28
+ meta: dict
29
+ agents: pd.DataFrame # one row per agent
30
+ opinions: pd.DataFrame # (day, agent) -> belief, score, opinion
31
+ metrics: pd.DataFrame # one row per day
32
+ posts: pd.DataFrame # (day, agent) -> feed, reasoning, post, memory, error, ...
33
+ rewires: pd.DataFrame # (day, agent, dropped, added)
34
+ edge_snapshots: list[tuple[int, list[tuple[int, int]]]]
35
+
36
+ @property
37
+ def days(self) -> int:
38
+ return int(self.opinions["day"].max())
39
+
40
+ def opinion_matrix(self) -> np.ndarray:
41
+ """Array of shape (days + 1, n_agents)."""
42
+ return self.opinions.pivot(index="day", columns="agent", values="opinion").to_numpy()
43
+
44
+ def graph_at(self, day: int) -> nx.Graph:
45
+ """The network as it was at the end of `day` (after that day's rewiring)."""
46
+ edges = next(e for d, e in reversed(self.edge_snapshots) if d <= day)
47
+ g = nx.Graph()
48
+ g.add_nodes_from(self.agents["agent"])
49
+ g.add_edges_from(edges)
50
+ return g
51
+
52
+
53
+ def load_run(path: str | Path) -> RunData:
54
+ path = Path(path)
55
+ events = []
56
+ for line in open(path / "events.jsonl", encoding="utf-8"):
57
+ try:
58
+ events.append(json.loads(line))
59
+ except json.JSONDecodeError: # a run still in progress may end mid-line
60
+ break
61
+ posts = pd.DataFrame([e for e in events if e.get("type", "post") == "post"])
62
+ rewires = pd.DataFrame(
63
+ [e for e in events if e.get("type") == "rewire"],
64
+ columns=["day", "type", "agent", "dropped", "added"],
65
+ )
66
+ snapshots = []
67
+ for line in open(path / "edges.jsonl", encoding="utf-8"):
68
+ try:
69
+ s = json.loads(line)
70
+ except json.JSONDecodeError:
71
+ break
72
+ snapshots.append((s["day"], [tuple(e) for e in s["edges"]]))
73
+ return RunData(
74
+ path=path,
75
+ meta=json.loads((path / "meta.json").read_text()),
76
+ agents=pd.read_parquet(path / "agents.parquet"),
77
+ opinions=pd.read_parquet(path / "opinions.parquet"),
78
+ metrics=pd.read_parquet(path / "metrics.parquet"),
79
+ posts=posts,
80
+ rewires=rewires,
81
+ edge_snapshots=sorted(snapshots, key=lambda s: s[0]),
82
+ )
83
+
84
+
85
+ def _feed_authors(feed) -> list[int]:
86
+ return [int(f["author"]) for f in (feed or [])]
87
+
88
+
89
+ def agent_timeline(run: RunData, agent: int) -> pd.DataFrame:
90
+ """One row per day for `agent`, with the mean opinion (on the previous day) of what it read."""
91
+ ops = run.opinion_matrix()
92
+ p = run.posts[run.posts["agent"] == agent].sort_values("day")
93
+ rows = []
94
+ for _, r in p.iterrows():
95
+ d = int(r["day"])
96
+ authors = _feed_authors(r["feed"])
97
+ feed_mean = float(np.mean(ops[d - 1, authors])) if authors and d > 0 else np.nan
98
+ rows.append(
99
+ {
100
+ "day": d,
101
+ "opinion": ops[d, agent],
102
+ "belief": r["belief"],
103
+ "score": r["score"],
104
+ "read_from": authors,
105
+ "feed_mean": feed_mean,
106
+ "reasoning": r["reasoning"],
107
+ "post": r["post"],
108
+ "error": r["error"],
109
+ }
110
+ )
111
+ return pd.DataFrame(rows)
112
+
113
+
114
+ def opinion_shifts(run: RunData, min_delta: float = 0.5) -> pd.DataFrame:
115
+ """Every day-to-day change of at least `min_delta`, with its context.
116
+
117
+ direction:
118
+ toward moved in the direction of the average post it read (assimilation)
119
+ away moved against it (repulsion / backfire)
120
+ none read nothing, or what it read matched its old opinion
121
+ """
122
+ ops = run.opinion_matrix()
123
+ model = dict(zip(run.agents["agent"], run.agents["model"], strict=True))
124
+ rows = []
125
+ for _, r in run.posts[run.posts["day"] > 0].iterrows():
126
+ d, i = int(r["day"]), int(r["agent"])
127
+ before, after = ops[d - 1, i], ops[d, i]
128
+ delta = after - before
129
+ if abs(delta) < min_delta:
130
+ continue
131
+ authors = _feed_authors(r["feed"])
132
+ feed_mean = float(np.mean(ops[d - 1, authors])) if authors else np.nan
133
+ pull = feed_mean - before if authors else 0.0
134
+ if not authors or abs(pull) < 1e-9:
135
+ direction = "none"
136
+ else:
137
+ direction = "toward" if np.sign(delta) == np.sign(pull) else "away"
138
+ rows.append(
139
+ {
140
+ "day": d,
141
+ "agent": i,
142
+ "model": model[i],
143
+ "before": before,
144
+ "after": after,
145
+ "delta": delta,
146
+ "n_read": len(authors),
147
+ "feed_mean": feed_mean,
148
+ "direction": direction,
149
+ "read_from": authors,
150
+ "reasoning": r["reasoning"],
151
+ }
152
+ )
153
+ cols = ["day", "agent", "model", "before", "after", "delta", "n_read", "feed_mean"]
154
+ cols += ["direction", "read_from", "reasoning"]
155
+ return pd.DataFrame(rows, columns=cols)
156
+
157
+
158
+ def model_summary(run: RunData, min_delta: float = 0.5) -> pd.DataFrame:
159
+ """Per-model behaviour: how much agents move, in which direction, and how often they fail."""
160
+ ops = run.opinion_matrix()
161
+ steps = np.abs(np.diff(ops, axis=0)) # (days, agents)
162
+ shifts = opinion_shifts(run, min_delta)
163
+ errors = run.posts.assign(err=run.posts["error"].notna()).groupby("model")["err"].mean()
164
+ rows = []
165
+ for m, group in run.agents.groupby("model"):
166
+ idx = group["agent"].to_numpy()
167
+ s = shifts[shifts["model"] == m]
168
+ rows.append(
169
+ {
170
+ "model": m,
171
+ "agents": len(idx),
172
+ "initial_mean": float(ops[0, idx].mean()),
173
+ "final_mean": float(ops[-1, idx].mean()),
174
+ "mean_abs_daily_change": float(steps[:, idx].mean()) if len(steps) else 0.0,
175
+ "shifts": len(s),
176
+ "toward_share": float((s["direction"] == "toward").mean()) if len(s) else np.nan,
177
+ "away_share": float((s["direction"] == "away").mean()) if len(s) else np.nan,
178
+ "error_rate": float(errors.get(m, 0.0)),
179
+ }
180
+ )
181
+ return pd.DataFrame(rows)
182
+
183
+
184
+ def change_points(series: np.ndarray, penalty: float = 1.0) -> list[int]:
185
+ """Days where the trajectory changes regime.
186
+
187
+ Uses PELT from `ruptures` when installed (`uv sync --extra analysis`); otherwise falls
188
+ back to days whose absolute change exceeds `penalty`.
189
+ """
190
+ x = np.asarray(series, dtype=float)
191
+ if len(x) < 3:
192
+ return []
193
+ try:
194
+ import ruptures as rpt
195
+ except ImportError:
196
+ return [int(d) for d in np.flatnonzero(np.abs(np.diff(x)) >= penalty) + 1]
197
+ breaks = rpt.Pelt(model="l2", min_size=1, jump=1).fit(x.reshape(-1, 1)).predict(pen=penalty)
198
+ return [int(b) for b in breaks if b < len(x)]
disensa/app.py ADDED
@@ -0,0 +1,340 @@
1
+ """`disensa app`: a local web app to configure, run and watch simulations.
2
+
3
+ A small standard-library HTTP server bound to 127.0.0.1. It serves one page (static/app.html)
4
+ a landing page at / and the lab at /lab, plus a JSON API:
5
+
6
+ GET / landing page (static/landing.html)
7
+ GET /lab the lab: configure, run, watch (static/app.html)
8
+ GET /docs documentation (static/docs.html)
9
+ GET /api/options topologies, algorithms, installed Ollama models, presets
10
+ GET /api/runs past and running runs, newest first
11
+ POST /api/runs {"engine": "llm"|"numeric", "config": {...}} -> {"id": ...}
12
+ GET /api/runs/<id> the run's data for the viewer (works while it is running)
13
+ GET /api/runs/<id>/status {"status", "day", "days", "error"}
14
+ POST /api/runs/<id>/cancel stop a running simulation after the current day
15
+
16
+ Each run executes in its own thread with its own asyncio loop, writing the usual run folder.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import json
23
+ import threading
24
+ import time
25
+ import traceback
26
+ import urllib.request
27
+ import webbrowser
28
+ from dataclasses import dataclass, field
29
+ from http import HTTPStatus
30
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
31
+ from importlib import resources
32
+ from pathlib import Path
33
+
34
+ import yaml
35
+ from pydantic import ValidationError
36
+
37
+ from disensa.config import ExperimentConfig
38
+ from disensa.fake_llm import BEHAVIOURS
39
+
40
+ OLLAMA_URL = "http://localhost:11434"
41
+ STATIC = resources.files("disensa").joinpath("static")
42
+
43
+
44
+ @dataclass
45
+ class Job:
46
+ id: str
47
+ run_dir: Path
48
+ engine: str
49
+ days: int
50
+ status: str = "running" # running | done | failed | cancelled
51
+ day: int = -1
52
+ error: str | None = None
53
+ started: float = field(default_factory=time.time)
54
+ loop: asyncio.AbstractEventLoop | None = None
55
+ task: asyncio.Task | None = None
56
+
57
+
58
+ class RunManager:
59
+ def __init__(self, runs_root: Path):
60
+ self.runs_root = runs_root
61
+ self.jobs: dict[str, Job] = {}
62
+ self.lock = threading.Lock()
63
+
64
+ # ------------------------------------------------------------------ starting runs
65
+
66
+ def start(self, engine: str, raw: dict) -> Job:
67
+ from disensa.runs import new_run_dir
68
+
69
+ raw = dict(raw)
70
+ raw["output_dir"] = str(self.runs_root)
71
+ if engine == "numeric":
72
+ raw.pop("models", None)
73
+ raw.pop("scorer", None)
74
+ cfg = ExperimentConfig.model_validate(raw)
75
+ if engine == "llm" and not cfg.models:
76
+ raise ValueError("pick at least one model")
77
+ if engine == "numeric" and cfg.baseline is None:
78
+ raise ValueError("pick a numeric algorithm")
79
+ days = cfg.baseline.steps if engine == "numeric" else cfg.simulation.days
80
+ run_dir = new_run_dir(cfg)
81
+ job = Job(id=run_dir.name, run_dir=run_dir, engine=engine, days=days)
82
+ with self.lock:
83
+ self.jobs[job.id] = job
84
+ target = self._run_numeric if engine == "numeric" else self._run_llm
85
+ threading.Thread(target=target, args=(job, cfg), daemon=True).start()
86
+ return job
87
+
88
+ def _progress(self, job: Job):
89
+ def cb(day: int, _metrics: dict) -> None:
90
+ job.day = day
91
+
92
+ return cb
93
+
94
+ def _run_numeric(self, job: Job, cfg: ExperimentConfig) -> None:
95
+ from disensa.numeric import run_numeric
96
+
97
+ try:
98
+ run_numeric(cfg, run_dir=job.run_dir, progress=self._progress(job))
99
+ job.status = "done"
100
+ except Exception as e:
101
+ job.status, job.error = "failed", f"{type(e).__name__}: {e}"
102
+
103
+ def _run_llm(self, job: Job, cfg: ExperimentConfig) -> None:
104
+ from disensa.engine import Simulation
105
+ from disensa.runs import RunWriter, update_meta
106
+
107
+ loop = asyncio.new_event_loop()
108
+ job.loop = loop
109
+ writer = RunWriter(job.run_dir)
110
+ t0 = time.time()
111
+ try:
112
+ sim = Simulation(cfg)
113
+ job.task = loop.create_task(sim.run(writer, self._progress(job)))
114
+ loop.run_until_complete(job.task)
115
+ job.status = "done"
116
+ update_meta(
117
+ job.run_dir,
118
+ finished=True,
119
+ seconds=round(time.time() - t0, 1),
120
+ llm_calls=sim.client.calls,
121
+ cache_hits=sim.client.cache_hits,
122
+ )
123
+ except asyncio.CancelledError:
124
+ job.status = "cancelled"
125
+ update_meta(job.run_dir, cancelled=True)
126
+ except Exception as e:
127
+ job.status, job.error = "failed", f"{type(e).__name__}: {e}"
128
+ traceback.print_exc()
129
+ finally:
130
+ writer.close()
131
+ loop.close()
132
+
133
+ def cancel(self, run_id: str) -> bool:
134
+ job = self.jobs.get(run_id)
135
+ if not job or job.status != "running" or not job.loop or not job.task:
136
+ return False
137
+ job.loop.call_soon_threadsafe(job.task.cancel)
138
+ return True
139
+
140
+ # ------------------------------------------------------------------ reading runs
141
+
142
+ def run_dir(self, run_id: str) -> Path | None:
143
+ if not run_id or "/" in run_id or "\\" in run_id or run_id.startswith("."):
144
+ return None
145
+ d = self.runs_root / run_id
146
+ return d if (d / "meta.json").exists() else None
147
+
148
+ def status(self, run_id: str) -> dict:
149
+ job = self.jobs.get(run_id)
150
+ if job:
151
+ return {"status": job.status, "day": job.day, "days": job.days, "error": job.error}
152
+ d = self.run_dir(run_id)
153
+ meta = json.loads((d / "meta.json").read_text()) if d else {}
154
+ status = (
155
+ "cancelled"
156
+ if meta.get("cancelled")
157
+ else ("done" if meta.get("finished") else "incomplete")
158
+ )
159
+ return {"status": status, "day": None, "days": None, "error": None}
160
+
161
+ def list(self) -> list[dict]:
162
+ if not self.runs_root.exists():
163
+ return []
164
+ out = []
165
+ for d in sorted(self.runs_root.iterdir(), reverse=True):
166
+ meta_path = d / "meta.json"
167
+ if not meta_path.exists() or not (d / "agents.parquet").exists():
168
+ continue
169
+ try:
170
+ meta = json.loads(meta_path.read_text())
171
+ except json.JSONDecodeError:
172
+ continue
173
+ cfg = meta.get("config", {})
174
+ engine = meta.get("engine") or (
175
+ "numeric" if cfg.get("baseline") and not cfg.get("models") else "llm"
176
+ )
177
+ out.append(
178
+ {
179
+ "id": d.name,
180
+ "name": cfg.get("name", d.name),
181
+ "started": meta.get("started"),
182
+ "engine": engine,
183
+ "topology": cfg.get("network", {}).get("topology"),
184
+ "n": cfg.get("network", {}).get("n"),
185
+ "models": [m["name"] for m in cfg.get("models", [])]
186
+ or ([f"numeric/{cfg['baseline']['model']}"] if cfg.get("baseline") else []),
187
+ **self.status(d.name),
188
+ }
189
+ )
190
+ return out[:100]
191
+
192
+ def payload(self, run_id: str) -> dict | None:
193
+ from disensa.analysis import load_run
194
+ from disensa.export import build_payload
195
+
196
+ d = self.run_dir(run_id)
197
+ if d is None:
198
+ return None
199
+ try:
200
+ return build_payload(load_run(d))
201
+ except (FileNotFoundError, ValueError, KeyError, IndexError, StopIteration):
202
+ return None # nothing written yet
203
+
204
+
205
+ def ollama_models() -> tuple[list[str], str | None]:
206
+ try:
207
+ with urllib.request.urlopen(f"{OLLAMA_URL}/api/tags", timeout=1.5) as r:
208
+ tags = json.loads(r.read())
209
+ return sorted(f"ollama_chat/{m['name']}" for m in tags.get("models", [])), None
210
+ except Exception:
211
+ return [], "Ollama is not running (start it with `ollama serve`)."
212
+
213
+
214
+ def presets(config_dir: Path) -> list[dict]:
215
+ out = []
216
+ if not config_dir.exists():
217
+ return out
218
+ for p in sorted(config_dir.rglob("*.yaml")):
219
+ try:
220
+ raw = yaml.safe_load(p.read_text())
221
+ except yaml.YAMLError:
222
+ continue
223
+ if not isinstance(raw, dict) or "network" not in raw:
224
+ continue
225
+ engine = "numeric" if raw.get("baseline") and not raw.get("models") else "llm"
226
+ out.append({"file": str(p.relative_to(config_dir)), "engine": engine, "config": raw})
227
+ return out
228
+
229
+
230
+ def make_handler(manager: RunManager, config_dir: Path):
231
+ class Handler(BaseHTTPRequestHandler):
232
+ server_version = "disensa"
233
+
234
+ def log_message(self, fmt, *args): # keep the terminal quiet
235
+ pass
236
+
237
+ def _send(self, code: int, body: bytes, ctype: str) -> None:
238
+ self.send_response(code)
239
+ self.send_header("Content-Type", ctype)
240
+ self.send_header("Content-Length", str(len(body)))
241
+ self.send_header("Cache-Control", "no-store")
242
+ self.end_headers()
243
+ self.wfile.write(body)
244
+
245
+ def _json(self, obj, code: int = 200) -> None:
246
+ self._send(code, json.dumps(obj, allow_nan=False).encode(), "application/json")
247
+
248
+ def _error(self, code: int, msg: str) -> None:
249
+ self._json({"error": msg}, code)
250
+
251
+ def do_GET(self): # noqa: N802
252
+ path = self.path.split("?", 1)[0]
253
+ pages = {
254
+ "/": "landing.html",
255
+ "/index.html": "landing.html",
256
+ "/lab": "app.html",
257
+ "/docs": "docs.html",
258
+ }
259
+ if path in pages:
260
+ html = STATIC.joinpath(pages[path]).read_text(encoding="utf-8")
261
+ return self._send(200, html.encode(), "text/html; charset=utf-8")
262
+ if path == "/d3.min.js":
263
+ return self._send(
264
+ 200, STATIC.joinpath("d3.min.js").read_bytes(), "text/javascript; charset=utf-8"
265
+ )
266
+ if path == "/api/options":
267
+ models, err = ollama_models()
268
+ return self._json(
269
+ {
270
+ "ollama_models": models,
271
+ "ollama_error": err,
272
+ "fake_models": [
273
+ "fake/bounded",
274
+ *(f"fake/{b}" for b in BEHAVIOURS),
275
+ "fake/scorer",
276
+ ],
277
+ "presets": presets(config_dir),
278
+ }
279
+ )
280
+ if path == "/api/runs":
281
+ return self._json(manager.list())
282
+ parts = path.strip("/").split("/")
283
+ if len(parts) >= 3 and parts[:2] == ["api", "runs"]:
284
+ run_id = parts[2]
285
+ if manager.run_dir(run_id) is None:
286
+ return self._error(404, "run not found")
287
+ if len(parts) == 4 and parts[3] == "status":
288
+ return self._json(manager.status(run_id))
289
+ if len(parts) == 3:
290
+ return self._json(
291
+ {"status": manager.status(run_id), "data": manager.payload(run_id)}
292
+ )
293
+ self._error(404, "not found")
294
+
295
+ def do_POST(self): # noqa: N802
296
+ path = self.path.split("?", 1)[0]
297
+ length = int(self.headers.get("Content-Length") or 0)
298
+ try:
299
+ body = json.loads(self.rfile.read(length) or b"{}")
300
+ except json.JSONDecodeError:
301
+ return self._error(400, "invalid JSON")
302
+ if path == "/api/runs":
303
+ try:
304
+ job = manager.start(body.get("engine", "llm"), body.get("config", {}))
305
+ except ValidationError as e:
306
+ msg = "; ".join(
307
+ f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}"
308
+ for err in e.errors()
309
+ )
310
+ return self._error(400, msg)
311
+ except (ValueError, TypeError) as e:
312
+ return self._error(400, str(e))
313
+ return self._json({"id": job.id}, HTTPStatus.CREATED)
314
+ parts = path.strip("/").split("/")
315
+ if len(parts) == 4 and parts[:2] == ["api", "runs"] and parts[3] == "cancel":
316
+ return self._json({"cancelled": manager.cancel(parts[2])})
317
+ self._error(404, "not found")
318
+
319
+ return Handler
320
+
321
+
322
+ def serve(
323
+ port: int = 8765,
324
+ runs_root: Path = Path("runs"),
325
+ config_dir: Path = Path("configs"),
326
+ open_browser: bool = True,
327
+ ) -> None:
328
+ runs_root.mkdir(parents=True, exist_ok=True)
329
+ manager = RunManager(runs_root)
330
+ server = ThreadingHTTPServer(("127.0.0.1", port), make_handler(manager, config_dir))
331
+ url = f"http://127.0.0.1:{port}/"
332
+ print(f"disensa app running at {url} (Ctrl+C to stop)")
333
+ if open_browser:
334
+ threading.Timer(0.5, lambda: webbrowser.open(url)).start()
335
+ try:
336
+ server.serve_forever()
337
+ except KeyboardInterrupt:
338
+ pass
339
+ finally:
340
+ server.server_close()
disensa/baselines.py ADDED
@@ -0,0 +1,96 @@
1
+ """Classic numeric opinion-dynamics models on a graph.
2
+
3
+ Each model is a function (g, x0, params, rng) -> trajectory array of shape (steps + 1, N).
4
+ Opinions live on [OPINION_MIN, OPINION_MAX].
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+
11
+ import networkx as nx
12
+ import numpy as np
13
+
14
+ from disensa import OPINION_MAX, OPINION_MIN
15
+ from disensa.config import BaselineConfig
16
+
17
+
18
+ def _adjacency(g: nx.Graph) -> np.ndarray:
19
+ return nx.to_numpy_array(g, nodelist=range(g.number_of_nodes()))
20
+
21
+
22
+ def degroot(g: nx.Graph, x0: np.ndarray, cfg: BaselineConfig, rng) -> np.ndarray:
23
+ """DeGroot (1974): everyone moves to the average of self + neighbours."""
24
+ a = _adjacency(g) + np.eye(len(x0))
25
+ w = a / a.sum(axis=1, keepdims=True)
26
+ traj = [x0.copy()]
27
+ for _ in range(cfg.steps):
28
+ traj.append(w @ traj[-1])
29
+ return np.array(traj)
30
+
31
+
32
+ def bounded_confidence(g: nx.Graph, x0: np.ndarray, cfg: BaselineConfig, rng) -> np.ndarray:
33
+ """Synchronous bounded confidence on a graph, as in Wang et al. §3.4.
34
+
35
+ x_i(t) = x_i(t-1) + μ · mean_{j∈N(i), |x_i-x_j|≤ε} (x_j(t-1) - x_i(t-1))
36
+ (Hegselmann–Krause style averaging over all confident neighbours.)
37
+ """
38
+ a = _adjacency(g).astype(bool)
39
+ traj = [x0.copy()]
40
+ for _ in range(cfg.steps):
41
+ x = traj[-1]
42
+ diff = x[None, :] - x[:, None] # diff[i, j] = x_j - x_i
43
+ confident = a & (np.abs(diff) <= cfg.epsilon)
44
+ counts = confident.sum(axis=1)
45
+ pull = np.where(counts > 0, (diff * confident).sum(axis=1) / np.maximum(counts, 1), 0.0)
46
+ traj.append(x + cfg.mu * pull)
47
+ return np.array(traj)
48
+
49
+
50
+ def deffuant(g: nx.Graph, x0: np.ndarray, cfg: BaselineConfig, rng) -> np.ndarray:
51
+ """Deffuant et al. (2000): random pairwise encounters along edges.
52
+
53
+ One "step" = |E| random edge encounters, so step counts are comparable across graphs.
54
+ """
55
+ edges = np.array(g.edges())
56
+ x = x0.copy()
57
+ traj = [x.copy()]
58
+ for _ in range(cfg.steps):
59
+ for e in rng.integers(0, len(edges), len(edges)):
60
+ i, j = edges[e]
61
+ d = x[j] - x[i]
62
+ if abs(d) <= cfg.epsilon:
63
+ x[i] += cfg.mu * d
64
+ x[j] -= cfg.mu * d
65
+ traj.append(x.copy())
66
+ return np.array(traj)
67
+
68
+
69
+ def friedkin_johnsen(g: nx.Graph, x0: np.ndarray, cfg: BaselineConfig, rng) -> np.ndarray:
70
+ """Friedkin–Johnsen (1990) as in Chitra & Musco (2020).
71
+
72
+ Innate opinions s = x0 stay fixed; expressed opinions iterate
73
+ z_i(t+1) = (λ s_i + Σ_j A_ij z_j(t)) / (λ + d_i).
74
+ """
75
+ a = _adjacency(g)
76
+ lam = cfg.stubbornness
77
+ denom = lam + a.sum(axis=1)
78
+ s = x0.copy()
79
+ traj = [s.copy()]
80
+ for _ in range(cfg.steps):
81
+ traj.append((lam * s + a @ traj[-1]) / denom)
82
+ return np.array(traj)
83
+
84
+
85
+ MODELS: dict[str, Callable] = {
86
+ "degroot": degroot,
87
+ "bounded_confidence": bounded_confidence,
88
+ "deffuant": deffuant,
89
+ "friedkin_johnsen": friedkin_johnsen,
90
+ }
91
+
92
+
93
+ def run_baseline(g: nx.Graph, x0: np.ndarray, cfg: BaselineConfig, seed: int = 0) -> np.ndarray:
94
+ rng = np.random.default_rng(seed)
95
+ traj = MODELS[cfg.model](g, np.asarray(x0, dtype=float), cfg, rng)
96
+ return np.clip(traj, OPINION_MIN, OPINION_MAX)