abmfact 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.
abmfact/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """ABMfact: is it a finding or an artefact?
2
+
3
+ Audit the conclusions of an agent-based model by rerunning it under changes that a correct
4
+ implementation must not care about, and report which conclusions survive.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ from .adapters import Adapter, AdapterError, CommandAdapter, MesaAdapter, NetLogoAdapter, PythonAdapter
12
+ from .audit import AuditResult, run_audit
13
+ from .claims import Claim, ClaimError, Metric
14
+ from .config import Audit, Budget, ConfigError, load_audit, parse_audit
15
+ from .knobs import Knobs, Support
16
+
17
+ __all__ = [
18
+ "Adapter",
19
+ "AdapterError",
20
+ "Audit",
21
+ "AuditResult",
22
+ "Budget",
23
+ "Claim",
24
+ "ClaimError",
25
+ "CommandAdapter",
26
+ "ConfigError",
27
+ "Knobs",
28
+ "MesaAdapter",
29
+ "Metric",
30
+ "NetLogoAdapter",
31
+ "PythonAdapter",
32
+ "Support",
33
+ "__version__",
34
+ "load_audit",
35
+ "parse_audit",
36
+ "run_audit",
37
+ ]
abmfact/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
abmfact/adapters.py ADDED
@@ -0,0 +1,364 @@
1
+ """Adapters turn a model, in whatever framework it is written, into ``run(params, seed, knobs)``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import json
7
+ import math
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ from collections.abc import Callable, Mapping, Sequence
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .knobs import Knobs, Support, derived_seed
16
+
17
+ Outputs = dict[str, float | list[float]]
18
+ RunFn = Callable[[Mapping[str, Any], int, Knobs], Mapping[str, Any]]
19
+
20
+
21
+ class AdapterError(RuntimeError):
22
+ """A model could not be built, run, or returned outputs ABMfact cannot read."""
23
+
24
+
25
+ def normalise_outputs(raw: Mapping[str, Any]) -> Outputs:
26
+ """Coerce model outputs to floats and lists of floats, rejecting anything else."""
27
+ if not isinstance(raw, Mapping):
28
+ raise AdapterError(f"model must return a mapping of outputs, got {type(raw).__name__}")
29
+ out: Outputs = {}
30
+ for key, value in raw.items():
31
+ if isinstance(value, int | float):
32
+ out[str(key)] = float(value)
33
+ elif hasattr(value, "tolist") and not isinstance(value, str):
34
+ v = value.tolist()
35
+ out[str(key)] = [float(x) for x in v] if isinstance(v, list) else float(v)
36
+ elif isinstance(value, Sequence) and not isinstance(value, str):
37
+ out[str(key)] = [float(x) for x in value]
38
+ else:
39
+ raise AdapterError(f"output {key!r} must be a number or a sequence of numbers")
40
+ return out
41
+
42
+
43
+ class Adapter:
44
+ """Base class. Subclasses implement :meth:`_run`.
45
+
46
+ The base class implements ``rng_streams="independent"`` for models that do not manage their own
47
+ streams, by remapping the seed through an independent derivation.
48
+ """
49
+
50
+ name: str = "model"
51
+ support: Support = Support()
52
+
53
+ def run(self, params: Mapping[str, Any], seed: int, knobs: Knobs) -> Outputs:
54
+ if knobs.rng_streams == "independent" and not self.support.rng_streams:
55
+ seed = derived_seed(seed)
56
+ knobs = knobs.with_(rng_streams="default")
57
+ return normalise_outputs(self._run(dict(params), int(seed), knobs))
58
+
59
+ def _run(self, params: dict[str, Any], seed: int, knobs: Knobs) -> Mapping[str, Any]:
60
+ raise NotImplementedError
61
+
62
+ def describe(self) -> dict[str, Any]:
63
+ return {"adapter": type(self).__name__, "name": self.name, "support": self.support.to_dict()}
64
+
65
+
66
+ class PythonAdapter(Adapter):
67
+ """Wrap a plain Python callable ``fn(params, seed, knobs) -> dict``."""
68
+
69
+ def __init__(self, fn: RunFn, supports: Mapping[str, Any] | Support | None = None, name: str | None = None):
70
+ self.fn = fn
71
+ self.support = supports if isinstance(supports, Support) else Support.from_mapping(supports)
72
+ self.name = str(name or getattr(fn, "__qualname__", "python-model"))
73
+
74
+ def _run(self, params: dict[str, Any], seed: int, knobs: Knobs) -> Mapping[str, Any]:
75
+ return self.fn(params, seed, knobs)
76
+
77
+
78
+ class MesaAdapter(Adapter):
79
+ """Run a Mesa 3 model class.
80
+
81
+ ``outputs`` maps output names to either a callable taking the model, or the string
82
+ ``"datacollector:<column>"`` for a model-level reporter series. ``knob_kwargs`` maps knob names
83
+ (``update_order``, ``agent_order``, ``rng_streams``, ``grid_offset``, ``grid_rotation``,
84
+ ``dt_scale``, ``pop_scale``) to constructor keyword names. A knob is supported exactly when it is
85
+ mapped.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ model_cls: Any,
91
+ steps: int,
92
+ outputs: Mapping[str, Any],
93
+ knob_kwargs: Mapping[str, str] | None = None,
94
+ update_orders: Sequence[str] = (),
95
+ update_order_default: str | None = None,
96
+ seed_kwarg: str = "rng",
97
+ name: str | None = None,
98
+ ):
99
+ self.model_cls = model_cls
100
+ self.steps = int(steps)
101
+ self.outputs = dict(outputs)
102
+ self.knob_kwargs = dict(knob_kwargs or {})
103
+ self.seed_kwarg = seed_kwarg
104
+ self.name = str(name or getattr(model_cls, "__name__", "mesa-model"))
105
+ kk = self.knob_kwargs
106
+ self.support = Support(
107
+ update_order=tuple(update_orders) if "update_order" in kk else (),
108
+ update_order_default=update_order_default or (update_orders[0] if update_orders else None),
109
+ agent_order="agent_order" in kk,
110
+ rng_streams="rng_streams" in kk,
111
+ grid="grid_offset" in kk and "grid_rotation" in kk,
112
+ dt_scale="dt_scale" in kk,
113
+ pop_scale="pop_scale" in kk,
114
+ )
115
+
116
+ def _run(self, params: dict[str, Any], seed: int, knobs: Knobs) -> Mapping[str, Any]:
117
+ kwargs = dict(params)
118
+ kwargs[self.seed_kwarg] = seed
119
+ kd = knobs.to_dict()
120
+ for knob, kw in self.knob_kwargs.items():
121
+ if knob == "agent_order":
122
+ kwargs[kw] = knobs.agent_perm_seed if knobs.agent_order == "shuffled" else None
123
+ elif knob == "update_order":
124
+ kwargs[kw] = knobs.update_order or self.support.update_order_default
125
+ else:
126
+ kwargs[kw] = kd[knob]
127
+ try:
128
+ model = self.model_cls(**kwargs)
129
+ except TypeError as exc:
130
+ raise AdapterError(f"could not build {self.name}: {exc}") from exc
131
+ n = max(1, round(self.steps / knobs.dt_scale)) if self.support.dt_scale else self.steps
132
+ for _ in range(n):
133
+ model.step()
134
+ result: dict[str, Any] = {}
135
+ for out_name, spec in self.outputs.items():
136
+ if callable(spec):
137
+ result[out_name] = spec(model)
138
+ elif isinstance(spec, str) and spec.startswith("datacollector:"):
139
+ col = spec.split(":", 1)[1]
140
+ df = model.datacollector.get_model_vars_dataframe()
141
+ result[out_name] = [float(x) for x in df[col].tolist()]
142
+ else:
143
+ raise AdapterError(f"output spec for {out_name!r} must be callable or 'datacollector:<col>'")
144
+ return result
145
+
146
+
147
+ _NETLOGO_LINKS: dict[tuple[str, str | None], Any] = {}
148
+
149
+
150
+ def _default_netlogo_link(model_path: str, netlogo_home: str | None) -> Any:
151
+ try:
152
+ import pynetlogo
153
+ except ImportError as exc: # pragma: no cover - depends on optional install
154
+ raise AdapterError("NetLogo support needs: pip install 'abmfact[netlogo]' and a NetLogo install") from exc
155
+ link = pynetlogo.NetLogoLink(gui=False, netlogo_home=netlogo_home) # pragma: no cover
156
+ link.load_model(model_path) # pragma: no cover
157
+ return link # pragma: no cover
158
+
159
+
160
+ def _nl_literal(value: Any) -> str:
161
+ if isinstance(value, bool):
162
+ return "true" if value else "false"
163
+ if isinstance(value, int | float):
164
+ if isinstance(value, float) and not math.isfinite(value):
165
+ raise AdapterError("NetLogo globals must be finite numbers")
166
+ return repr(value)
167
+ if isinstance(value, list | tuple):
168
+ return "[" + " ".join(_nl_literal(v) for v in value) + "]"
169
+ return json.dumps(str(value))
170
+
171
+
172
+ class NetLogoAdapter(Adapter):
173
+ """Run a NetLogo model through pynetlogo.
174
+
175
+ ``reporters`` maps output names to NetLogo reporter strings. A reporter whose name ends with
176
+ ``[]`` is collected every tick as a series, otherwise once at the end. ``knob_globals`` maps knob
177
+ names to NetLogo global variable names. One workspace is kept per process and model file.
178
+ """
179
+
180
+ def __init__(
181
+ self,
182
+ model_path: str | os.PathLike[str],
183
+ reporters: Mapping[str, str],
184
+ ticks: int,
185
+ knob_globals: Mapping[str, str] | None = None,
186
+ update_orders: Sequence[str] = (),
187
+ update_order_default: str | None = None,
188
+ setup: str = "setup",
189
+ go: str = "go",
190
+ netlogo_home: str | None = None,
191
+ link_factory: Callable[[str, str | None], Any] | None = None,
192
+ name: str | None = None,
193
+ ):
194
+ self.model_path = str(model_path)
195
+ self.reporters = dict(reporters)
196
+ self.ticks = int(ticks)
197
+ self.knob_globals = dict(knob_globals or {})
198
+ self.setup_cmd, self.go_cmd = setup, go
199
+ self.netlogo_home = netlogo_home
200
+ self.link_factory = link_factory or _default_netlogo_link
201
+ self.name = name or Path(self.model_path).stem
202
+ kg = self.knob_globals
203
+ self.support = Support(
204
+ update_order=tuple(update_orders) if "update_order" in kg else (),
205
+ update_order_default=update_order_default or (update_orders[0] if update_orders else None),
206
+ agent_order="agent_order" in kg,
207
+ rng_streams="rng_streams" in kg,
208
+ grid="grid_offset" in kg and "grid_rotation" in kg,
209
+ dt_scale="dt_scale" in kg,
210
+ pop_scale="pop_scale" in kg,
211
+ )
212
+
213
+ def _link(self) -> Any:
214
+ key = (self.model_path, self.netlogo_home)
215
+ if key not in _NETLOGO_LINKS:
216
+ _NETLOGO_LINKS[key] = self.link_factory(self.model_path, self.netlogo_home)
217
+ return _NETLOGO_LINKS[key]
218
+
219
+ def _run(self, params: dict[str, Any], seed: int, knobs: Knobs) -> Mapping[str, Any]:
220
+ link = self._link()
221
+ for name, param in params.items():
222
+ link.command(f"set {name} {_nl_literal(param)}")
223
+ kd = knobs.to_dict()
224
+ for knob, glob in self.knob_globals.items():
225
+ if knob == "update_order":
226
+ value: Any = knobs.update_order or self.support.update_order_default
227
+ elif knob == "agent_order":
228
+ value = knobs.agent_perm_seed if knobs.agent_order == "shuffled" else -1
229
+ else:
230
+ value = kd[knob]
231
+ link.command(f"set {glob} {_nl_literal(value)}")
232
+ link.command(f"random-seed {int(seed) % 2147483647}")
233
+ link.command(self.setup_cmd)
234
+ ticks = max(1, round(self.ticks / knobs.dt_scale)) if self.support.dt_scale else self.ticks
235
+ series = {k: v for k, v in self.reporters.items() if k.endswith("[]")}
236
+ finals = {k: v for k, v in self.reporters.items() if not k.endswith("[]")}
237
+ result: dict[str, Any] = {}
238
+ if series:
239
+ collected: dict[str, list[float]] = {k: [] for k in series}
240
+ for _ in range(ticks):
241
+ link.command(self.go_cmd)
242
+ for k, rep in series.items():
243
+ collected[k].append(float(link.report(rep)))
244
+ for k, vals in collected.items():
245
+ result[k[:-2]] = vals
246
+ else:
247
+ link.command(f"repeat {ticks} [ {self.go_cmd} ]")
248
+ for k, rep in finals.items():
249
+ result[k] = float(link.report(rep))
250
+ return result
251
+
252
+
253
+ class CommandAdapter(Adapter):
254
+ """Run any program. It reads ``{"params", "seed", "knobs"}`` as JSON on stdin and prints a JSON object."""
255
+
256
+ def __init__(
257
+ self,
258
+ argv: Sequence[str],
259
+ supports: Mapping[str, Any] | None = None,
260
+ timeout: float = 600.0,
261
+ cwd: str | None = None,
262
+ name: str | None = None,
263
+ ):
264
+ if not argv:
265
+ raise AdapterError("command adapter needs a non-empty argv")
266
+ self.argv = [sys.executable if a == "{python}" else str(a) for a in argv]
267
+ self.support = Support.from_mapping(supports)
268
+ self.timeout = float(timeout)
269
+ self.cwd = cwd
270
+ self.name = name or Path(self.argv[-1]).name
271
+
272
+ def _run(self, params: dict[str, Any], seed: int, knobs: Knobs) -> Mapping[str, Any]:
273
+ payload = json.dumps({"params": params, "seed": seed, "knobs": knobs.to_dict()})
274
+ try:
275
+ proc = subprocess.run( # noqa: S603 - argv comes from the user's own audit file
276
+ self.argv,
277
+ input=payload,
278
+ capture_output=True,
279
+ text=True,
280
+ timeout=self.timeout,
281
+ cwd=self.cwd,
282
+ check=False,
283
+ )
284
+ except subprocess.TimeoutExpired as exc:
285
+ raise AdapterError(f"{self.name} timed out after {self.timeout:g}s (seed {seed})") from exc
286
+ if proc.returncode != 0:
287
+ tail = proc.stderr.strip().splitlines()[-5:]
288
+ raise AdapterError(f"{self.name} exited with {proc.returncode} (seed {seed}): " + " | ".join(tail))
289
+ try:
290
+ data = json.loads(proc.stdout)
291
+ except json.JSONDecodeError as exc:
292
+ raise AdapterError(f"{self.name} did not print a JSON object (seed {seed})") from exc
293
+ if not isinstance(data, dict):
294
+ raise AdapterError(f"{self.name} must print a JSON object, got {type(data).__name__}")
295
+ return data
296
+
297
+
298
+ def import_target(target: str) -> Any:
299
+ """Import ``package.module:attribute``."""
300
+ if ":" not in target:
301
+ raise AdapterError(f"target must look like 'package.module:attribute', got {target!r}")
302
+ mod_name, _, attr = target.partition(":")
303
+ try:
304
+ module = importlib.import_module(mod_name)
305
+ except ImportError as exc:
306
+ raise AdapterError(f"cannot import {mod_name!r}: {exc}") from exc
307
+ obj: Any = module
308
+ for part in attr.split("."):
309
+ try:
310
+ obj = getattr(obj, part)
311
+ except AttributeError as exc:
312
+ raise AdapterError(f"{mod_name!r} has no attribute {attr!r}") from exc
313
+ return obj
314
+
315
+
316
+ def load_adapter(cfg: Mapping[str, Any], base_dir: Path | None = None) -> Adapter:
317
+ """Build an adapter from the ``model:`` section of an audit file."""
318
+ kind = str(cfg.get("adapter", "python"))
319
+ base = base_dir or Path.cwd()
320
+ if base_dir is not None and str(base_dir) not in sys.path:
321
+ sys.path.insert(0, str(base_dir))
322
+ if kind == "python":
323
+ obj = import_target(str(cfg["target"]))
324
+ if isinstance(obj, Adapter):
325
+ return obj
326
+ if callable(obj):
327
+ return PythonAdapter(obj, supports=cfg.get("supports"), name=str(cfg["target"]))
328
+ raise AdapterError(f"{cfg['target']} is neither an Adapter nor a callable")
329
+ if kind == "mesa":
330
+ uo = cfg.get("update_orders", [])
331
+ return MesaAdapter(
332
+ import_target(str(cfg["target"])),
333
+ steps=int(cfg["steps"]),
334
+ outputs={
335
+ k: (import_target(v) if ":" in v and not v.startswith("datacollector:") else v)
336
+ for k, v in dict(cfg["outputs"]).items()
337
+ },
338
+ knob_kwargs=cfg.get("knob_kwargs"),
339
+ update_orders=uo,
340
+ update_order_default=cfg.get("update_order_default"),
341
+ seed_kwarg=str(cfg.get("seed_kwarg", "rng")),
342
+ name=str(cfg["target"]),
343
+ )
344
+ if kind == "netlogo":
345
+ path = Path(str(cfg["path"]))
346
+ return NetLogoAdapter(
347
+ path if path.is_absolute() else base / path,
348
+ reporters=dict(cfg["reporters"]),
349
+ ticks=int(cfg["ticks"]),
350
+ knob_globals=cfg.get("knob_globals"),
351
+ update_orders=cfg.get("update_orders", []),
352
+ update_order_default=cfg.get("update_order_default"),
353
+ setup=str(cfg.get("setup", "setup")),
354
+ go=str(cfg.get("go", "go")),
355
+ netlogo_home=cfg.get("netlogo_home"),
356
+ )
357
+ if kind == "command":
358
+ return CommandAdapter(
359
+ list(cfg["argv"]),
360
+ supports=cfg.get("supports"),
361
+ timeout=float(cfg.get("timeout", 600)),
362
+ cwd=str(base),
363
+ )
364
+ raise AdapterError(f"unknown adapter {kind!r}; expected python, mesa, netlogo or command")