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 +7 -0
- disensa/analysis.py +198 -0
- disensa/app.py +340 -0
- disensa/baselines.py +96 -0
- disensa/cli.py +195 -0
- disensa/config.py +143 -0
- disensa/engine.py +320 -0
- disensa/ensemble.py +80 -0
- disensa/export.py +116 -0
- disensa/fake_llm.py +87 -0
- disensa/llm.py +164 -0
- disensa/metrics.py +70 -0
- disensa/networks.py +38 -0
- disensa/numeric.py +75 -0
- disensa/personas.py +83 -0
- disensa/prompts.py +81 -0
- disensa/rewiring.py +65 -0
- disensa/runs.py +119 -0
- disensa/static/app.html +1263 -0
- disensa/static/d3.min.js +2 -0
- disensa/static/docs.html +464 -0
- disensa/static/landing.html +273 -0
- disensa-0.1.0.dist-info/METADATA +155 -0
- disensa-0.1.0.dist-info/RECORD +26 -0
- disensa-0.1.0.dist-info/WHEEL +4 -0
- disensa-0.1.0.dist-info/entry_points.txt +2 -0
disensa/__init__.py
ADDED
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)
|